From d187a2d924786c638d24c85d887b114aa469b7df Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:52:53 -0400 Subject: [PATCH 1/5] Adopt kernel-go-sdk v0.72.0; add extensions get + telemetry events; drop pool save_changes - Bump kernel-go-sdk to v0.72.0. Adapts APIKeyService.Get (now takes APIKeyGetParams) and the browser-pool profile change in kernel/kernel#2484. - New `extensions get `; new `browsers telemetry events ` (paged historical read with X-Next-Offset); new `--replay` on `browsers telemetry stream` (mutually exclusive with --seq). - Browser pools now have their own profile type (BrowserPoolProfile, id/name only, no save_changes; kernel/kernel#2484): drop `--save-changes` from `browser-pools create`/`update`, replace buildProfileParam with resolvePoolProfile (id/name selection, same validation), and drop the save-changes line from the pool profile display. Single-session `browsers create` keeps `--save-changes` (unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- cmd/api_keys.go | 4 +- cmd/api_keys_test.go | 8 +- cmd/browser_pools.go | 200 ++++++++++++++++----------------- cmd/browsers.go | 10 ++ cmd/browsers_telemetry.go | 112 ++++++++++++++++++ cmd/browsers_telemetry_test.go | 52 +++++++++ cmd/extensions.go | 53 +++++++++ cmd/extensions_test.go | 24 ++++ cmd/projects.go | 11 +- go.mod | 2 +- go.sum | 4 +- 12 files changed, 360 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 27f485d..b21b850 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Commands with JSON output support: - `--fill-rate ` - Percentage of the pool to fill per minute - `--timeout ` - Idle timeout for browsers acquired from the pool - `--stealth`, `--headless`, `--kiosk` - Default pool configuration - - `--profile-id`, `--profile-name`, `--save-changes`, `--proxy-id`, `--start-url`, `--extension`, `--viewport` - Same semantics as `kernel browsers create` + - `--profile-id`, `--profile-name`, `--proxy-id`, `--start-url`, `--extension`, `--viewport` - Same semantics as `kernel browsers create` - `--chrome-policy ` / `--chrome-policy-file ` - Custom Chrome enterprise policy applied to every browser in the pool, as a JSON object or from a file (`-` for stdin). Same semantics as `kernel browsers create`. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools get ` - Get pool details diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 77374cf..47cc854 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -14,7 +14,7 @@ import ( type APIKeysService interface { New(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) - Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error) + Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) Update(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error) List(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) Delete(ctx context.Context, id string, opts ...option.RequestOption) error @@ -145,7 +145,7 @@ func (c APIKeysCmd) Get(ctx context.Context, in APIKeysGetInput) error { return err } - key, err := c.apiKeys.Get(ctx, in.ID) + key, err := c.apiKeys.Get(ctx, in.ID, kernel.APIKeyGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 70ce814..74069dd 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -16,7 +16,7 @@ import ( type FakeAPIKeysService struct { NewFunc func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) - GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error) + GetFunc func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) UpdateFunc func(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error) ListFunc func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error @@ -29,9 +29,9 @@ func (f *FakeAPIKeysService) New(ctx context.Context, body kernel.APIKeyNewParam return createdAPIKeyFromJSON(`{"id":"key_123","name":"default","key":"sk_test","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil } -func (f *FakeAPIKeysService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error) { +func (f *FakeAPIKeysService) Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) { if f.GetFunc != nil { - return f.GetFunc(ctx, id, opts...) + return f.GetFunc(ctx, id, query, opts...) } return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil } @@ -125,7 +125,7 @@ func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) { t.Fatal("New should not be called") return nil, nil }, - GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error) { + GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) { t.Fatal("Get should not be called") return nil, nil }, diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index acd8287..63f9882 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -91,23 +91,22 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err } type BrowserPoolsCreateInput struct { - Name string - Size int64 - FillRate int64 - TimeoutSeconds int64 - Stealth BoolFlag - Headless BoolFlag - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - StartURL string - Extensions []string - Viewport string - ChromePolicy string - ChromePolicyFile string - Output string + Name string + Size int64 + FillRate int64 + TimeoutSeconds int64 + Stealth BoolFlag + Headless BoolFlag + Kiosk BoolFlag + ProfileID string + ProfileName string + ProxyID string + StartURL string + Extensions []string + Viewport string + ChromePolicy string + ChromePolicyFile string + Output string } func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) error { @@ -141,13 +140,17 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.KioskMode = kernel.Bool(in.Kiosk.Value) } - profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) + profileID, profileName, profileSet, err := resolvePoolProfile(in.ProfileID, in.ProfileName) if err != nil { pterm.Error.Println(err.Error()) return nil } - if profile != nil { - params.Profile = *profile + if profileSet { + if profileID != "" { + params.Profile.ID = kernel.String(profileID) + } else { + params.Profile.Name = kernel.String(profileName) + } } if in.ProxyID != "" { @@ -239,26 +242,25 @@ func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error } type BrowserPoolsUpdateInput struct { - IDOrName string - Name string - Size int64 - FillRate int64 - TimeoutSeconds int64 - Stealth BoolFlag - Headless BoolFlag - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - StartURL string - ClearStartURL bool - Extensions []string - Viewport string - ChromePolicy string - ChromePolicyFile string - DiscardAllIdle BoolFlag - Output string + IDOrName string + Name string + Size int64 + FillRate int64 + TimeoutSeconds int64 + Stealth BoolFlag + Headless BoolFlag + Kiosk BoolFlag + ProfileID string + ProfileName string + ProxyID string + StartURL string + ClearStartURL bool + Extensions []string + Viewport string + ChromePolicy string + ChromePolicyFile string + DiscardAllIdle BoolFlag + Output string } func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) error { @@ -299,13 +301,17 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.DiscardAllIdle = kernel.Bool(in.DiscardAllIdle.Value) } - profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) + profileID, profileName, profileSet, err := resolvePoolProfile(in.ProfileID, in.ProfileName) if err != nil { pterm.Error.Println(err.Error()) return nil } - if profile != nil { - params.Profile = *profile + if profileSet { + if profileID != "" { + params.Profile.ID = kernel.String(profileID) + } else { + params.Profile.Name = kernel.String(profileName) + } } if in.ProxyID != "" { @@ -561,7 +567,6 @@ func init() { browserPoolsCreateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") - browserPoolsCreateCmd.Flags().Bool("save-changes", false, "Save changes to profile") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") @@ -581,7 +586,6 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") browserPoolsUpdateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") - browserPoolsUpdateCmd.Flags().Bool("save-changes", false, "Save changes to profile") browserPoolsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID") browserPoolsUpdateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsUpdateCmd.Flags().Bool("clear-start-url", false, "Clear the pool start URL") @@ -641,7 +645,6 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { kiosk, _ := cmd.Flags().GetBool("kiosk") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") - saveChanges, _ := cmd.Flags().GetBool("save-changes") proxyID, _ := cmd.Flags().GetString("proxy-id") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") @@ -651,23 +654,22 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ - Name: name, - Size: size, - FillRate: fillRate, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - StartURL: startURL, - Extensions: extensions, - Viewport: viewport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Output: output, + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + ProfileID: profileID, + ProfileName: profileName, + ProxyID: proxyID, + StartURL: startURL, + Extensions: extensions, + Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -693,7 +695,6 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { kiosk, _ := cmd.Flags().GetBool("kiosk") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") - saveChanges, _ := cmd.Flags().GetBool("save-changes") proxyID, _ := cmd.Flags().GetString("proxy-id") startURL, _ := cmd.Flags().GetString("start-url") clearStartURL, _ := cmd.Flags().GetBool("clear-start-url") @@ -705,26 +706,25 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") in := BrowserPoolsUpdateInput{ - IDOrName: args[0], - Name: name, - Size: size, - FillRate: fillRate, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - StartURL: startURL, - ClearStartURL: clearStartURL, - Extensions: extensions, - Viewport: viewport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, - Output: output, + IDOrName: args[0], + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + ProfileID: profileID, + ProfileName: profileName, + ProxyID: proxyID, + StartURL: startURL, + ClearStartURL: clearStartURL, + Extensions: extensions, + Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -772,23 +772,18 @@ func runBrowserPoolsFlush(cmd *cobra.Command, args []string) error { return c.Flush(cmd.Context(), BrowserPoolsFlushInput{IDOrName: args[0]}) } -func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*kernel.BrowserProfileParam, error) { +// resolvePoolProfile validates and resolves a pool profile selection. Browser +// pools have their own profile type with no save_changes; this helper works for +// both create and update param types by returning the resolved id/name plus +// whether a profile was selected at all. +func resolvePoolProfile(profileID, profileName string) (id, name string, set bool, err error) { if profileID != "" && profileName != "" { - return nil, fmt.Errorf("must specify at most one of --profile-id or --profile-name") + return "", "", false, fmt.Errorf("must specify at most one of --profile-id or --profile-name") } if profileID == "" && profileName == "" { - return nil, nil + return "", "", false, nil } - - profile := kernel.BrowserProfileParam{ - SaveChanges: kernel.Bool(saveChanges.Value), - } - if profileID != "" { - profile.ID = kernel.String(profileID) - } else if profileName != "" { - profile.Name = kernel.String(profileName) - } - return &profile, nil + return profileID, profileName, true, nil } func validateStartURLFlag(startURL string) error { @@ -847,15 +842,8 @@ func formatFillRate(rate int64) string { return "-" } -func formatProfile(profile kernel.BrowserProfile) string { - name := util.FirstOrDash(profile.Name, profile.ID) - if name == "-" { - return "-" - } - if profile.SaveChanges { - return fmt.Sprintf("%s (save changes: true)", name) - } - return fmt.Sprintf("%s (save changes: false)", name) +func formatProfile(profile kernel.BrowserPoolBrowserPoolConfigProfile) string { + return util.FirstOrDash(profile.Name, profile.ID) } func formatExtensions(extensions []kernel.BrowserExtension) string { diff --git a/cmd/browsers.go b/cmd/browsers.go index 8d545be..2838baa 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2748,7 +2748,17 @@ followed automatically by Chromium.`, telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", -1, "Resume after sequence number N (Last-Event-ID); replays events with seq > N. Default -1 streams from now") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") + telemetryStream.Flags().String("replay", "", "Replay buffered events on connect: --replay=all starts from the oldest retained event") + telemetryStream.MarkFlagsMutuallyExclusive("seq", "replay") telemetryRoot.AddCommand(telemetryStream) + + telemetryEvents := &cobra.Command{Use: "events ", Short: "Read historical telemetry events (paged)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryEvents} + telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (default 20)") + telemetryEvents.Flags().Int64("offset", 0, "Pagination cursor: pass the X-Next-Offset from a previous response") + telemetryEvents.Flags().String("since", "", "Window start: RFC-3339 timestamp or a duration like 5m (default 5m). Ignored when --offset is set") + telemetryEvents.Flags().String("until", "", "Window end (exclusive): RFC-3339 timestamp or a duration like 5m") + telemetryEvents.Flags().StringP("output", "o", "", "Output format: json") + telemetryRoot.AddCommand(telemetryEvents) browsersCmd.AddCommand(telemetryRoot) // no flags for view; it takes a single positional argument diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 2621520..2e15152 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "os" "os/signal" "slices" @@ -15,6 +16,7 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -23,6 +25,7 @@ import ( // BrowserTelemetryService defines the subset we use for browser telemetry streaming. type BrowserTelemetryService interface { StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]) + Events(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], err error) } type BrowsersTelemetryStreamInput struct { @@ -30,6 +33,16 @@ type BrowsersTelemetryStreamInput struct { Categories []string Types []string Seq int64 + Replay string + Output string +} + +type BrowsersTelemetryEventsInput struct { + Identifier string + Limit int64 + Offset int64 + Since string + Until string Output string } @@ -180,6 +193,9 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt return fmt.Errorf("invalid --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", ")) } } + if in.Replay != "" && in.Replay != "all" { + return fmt.Errorf("invalid --replay value %q: only \"all\" is supported (omit --replay to stream from now)", in.Replay) + } ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) @@ -190,6 +206,9 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt if in.Seq >= 0 { params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) } + if in.Replay != "" { + params.Replay = kernel.Opt(in.Replay) + } stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) defer stream.Close() for stream.Next() { @@ -223,12 +242,105 @@ func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { categories, _ := cmd.Flags().GetStringSlice("categories") types, _ := cmd.Flags().GetStringSlice("types") seq, _ := cmd.Flags().GetInt64("seq") + replay, _ := cmd.Flags().GetString("replay") b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry} return b.TelemetryStream(cmd.Context(), BrowsersTelemetryStreamInput{ Identifier: args[0], Categories: categories, Types: types, Seq: seq, + Replay: replay, + Output: out, + }) +} + +func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEventsInput) error { + if b.telemetry == nil { + return fmt.Errorf("telemetry service not available") + } + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + params := kernel.BrowserTelemetryEventsParams{} + if in.Limit > 0 { + params.Limit = kernel.Opt(in.Limit) + } + if in.Offset > 0 { + params.Offset = kernel.Opt(in.Offset) + } + if in.Since != "" { + params.Since = kernel.Opt(in.Since) + } + if in.Until != "" { + params.Until = kernel.Opt(in.Until) + } + + var raw *http.Response + page, err := b.telemetry.Events(ctx, br.SessionID, params, option.WithResponseInto(&raw)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var items []kernel.BrowserTelemetryEventsResponse + if page != nil { + items = page.Items + } + + if in.Output == "json" { + if len(items) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(items) + } + + if len(items) == 0 { + pterm.Info.Println("No telemetry events found") + return nil + } + + rows := pterm.TableData{{"Seq", "Time", "Category", "Type"}} + for _, it := range items { + ts := time.UnixMicro(it.Event.Ts).Local().Format("2006-01-02 15:04:05") + rows = append(rows, []string{ + strconv.FormatInt(it.Seq, 10), + ts, + it.Event.Category, + it.Event.Type, + }) + } + PrintTableNoPad(rows, true) + // The next-page cursor is the opaque X-Next-Offset header; surface it so + // --offset is actually usable for paging. + if raw != nil { + if next := raw.Header.Get("X-Next-Offset"); next != "" && next != "0" { + pterm.Info.Printf("More events available — re-run with --offset %s\n", next) + } + } + return nil +} + +func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + since, _ := cmd.Flags().GetString("since") + until, _ := cmd.Flags().GetString("until") + b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry} + return b.TelemetryEvents(cmd.Context(), BrowsersTelemetryEventsInput{ + Identifier: args[0], + Limit: limit, + Offset: offset, + Since: since, + Until: until, Output: out, }) } diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index f73a082..8200865 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -10,6 +10,7 @@ import ( kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/stretchr/testify/assert" ) @@ -34,6 +35,7 @@ func captureStdout(t *testing.T, fn func()) string { type FakeBrowserTelemetryService struct { StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] + EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) } func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { @@ -43,6 +45,13 @@ func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id st return makeStream([]kernel.BrowserTelemetryStreamResponse{}) } +func (f *FakeBrowserTelemetryService) Events(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { + if f.EventsFunc != nil { + return f.EventsFunc(ctx, id, query, opts...) + } + return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil +} + func TestTelemetryStream_NilTelemetryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} @@ -350,3 +359,46 @@ func TestTelemetryEnabledCategories(t *testing.T) { } assert.Equal(t, []string{"control", "system"}, telemetryEnabledCategories(cfg)) } + +func TestTelemetryStream_RejectsInvalidReplay(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}} + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{Identifier: "br-1", Seq: -1, Replay: "oldest"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --replay") +} + +func TestTelemetryEvents_Table(t *testing.T) { + buf := capturePtermOutput(t) + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil + }} + fakeTelemetry := &FakeBrowserTelemetryService{ + EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { + assert.Equal(t, "sess-1", id, "events should query the resolved session id") + return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{ + Items: []kernel.BrowserTelemetryEventsResponse{ + {Seq: 7, Event: kernel.BrowserTelemetryEventUnion{Category: "network", Type: "network_response", Ts: 0}}, + }, + }, nil + }, + } + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1"}) + assert.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "7") + assert.Contains(t, out, "network") + assert.Contains(t, out, "network_response") +} + +func TestTelemetryEvents_EmptyJSON(t *testing.T) { + out := captureStdout(t, func() { + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: &FakeBrowserTelemetryService{}} + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Output: "json"}) + assert.NoError(t, err) + }) + assert.Equal(t, "[]\n", out) +} diff --git a/cmd/extensions.go b/cmd/extensions.go index f58b22f..da99041 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -45,6 +45,7 @@ var defaultExtensionExclusions = util.ZipOptions{ // ExtensionsService defines the subset of the Kernel SDK extension client that we use. type ExtensionsService interface { List(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ExtensionListResponse], err error) + Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.ExtensionGetResponse, err error) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) DownloadFromChromeStore(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (res *http.Response, err error) @@ -57,6 +58,11 @@ type ExtensionsListInput struct { Output string } +type ExtensionsGetInput struct { + Identifier string + Output string +} + type ExtensionsDeleteInput struct { Identifier string SkipConfirm bool @@ -139,6 +145,38 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { return nil } +func (e ExtensionsCmd) Get(ctx context.Context, in ExtensionsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Identifier == "" { + pterm.Error.Println("Missing identifier") + return nil + } + + item, err := e.extensions.Get(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + name := item.Name + if name == "" { + name = "-" + } + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", item.ID}) + rows = append(rows, []string{"Name", name}) + rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Size (bytes)", fmt.Sprintf("%d", item.SizeBytes)}) + rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) + PrintTableNoPad(rows, true) + return nil +} + func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) error { if in.Identifier == "" { pterm.Error.Println("Missing identifier") @@ -425,6 +463,19 @@ var extensionsListCmd = &cobra.Command{ }, } +var extensionsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get extension metadata by ID or name", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.Get(cmd.Context(), ExtensionsGetInput{Identifier: args[0], Output: output}) + }, +} + var extensionsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete an extension by ID or name", @@ -529,6 +580,7 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{ func init() { extensionsCmd.AddCommand(extensionsListCmd) + extensionsCmd.AddCommand(extensionsGetCmd) extensionsCmd.AddCommand(extensionsDeleteCmd) extensionsCmd.AddCommand(extensionsDownloadCmd) extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd) @@ -536,6 +588,7 @@ func init() { extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd) addJSONOutputFlag(extensionsListCmd) + addJSONOutputFlag(extensionsGetCmd) extensionsListCmd.Flags().Int("limit", 0, "Maximum number of extensions to return") extensionsListCmd.Flags().Int("offset", 0, "Number of extensions to skip (for pagination)") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 95a6bd1..8362681 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -21,6 +21,7 @@ import ( // FakeExtensionsService implements ExtensionsService type FakeExtensionsService struct { ListFunc func(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ExtensionListResponse], error) + GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExtensionGetResponse, error) DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) DownloadFromChromeStoreFn func(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) @@ -33,6 +34,12 @@ func (f *FakeExtensionsService) List(ctx context.Context, query kernel.Extension } return &pagination.OffsetPagination[kernel.ExtensionListResponse]{Items: []kernel.ExtensionListResponse{}}, nil } +func (f *FakeExtensionsService) Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExtensionGetResponse, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, idOrName, opts...) + } + return &kernel.ExtensionGetResponse{ID: idOrName, Name: "default", CreatedAt: time.Unix(0, 0), SizeBytes: 1}, nil +} func (f *FakeExtensionsService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { if f.DeleteFunc != nil { return f.DeleteFunc(ctx, idOrName, opts...) @@ -58,6 +65,23 @@ func (f *FakeExtensionsService) Upload(ctx context.Context, body kernel.Extensio return &kernel.ExtensionUploadResponse{ID: "e-new", Name: body.Name.Value, CreatedAt: time.Unix(0, 0), SizeBytes: 1}, nil } +func TestExtensionsGet_Table(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeExtensionsService{ + GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExtensionGetResponse, error) { + assert.Equal(t, "my-ext", idOrName) + return &kernel.ExtensionGetResponse{ID: "e-123", Name: "my-ext", CreatedAt: time.Unix(0, 0), SizeBytes: 42}, nil + }, + } + e := ExtensionsCmd{extensions: fake} + err := e.Get(context.Background(), ExtensionsGetInput{Identifier: "my-ext"}) + assert.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "e-123") + assert.Contains(t, out, "my-ext") + assert.Contains(t, out, "42") +} + func TestExtensionsList_Empty(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeExtensionsService{} diff --git a/cmd/projects.go b/cmd/projects.go index 4054961..7baf195 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -110,12 +110,11 @@ func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { } func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { - projectID, err := resolveProjectArg(ctx, c.projects, in.Identifier) - if err != nil { - return err - } - - project, err := c.projects.Get(ctx, projectID) + // The API resolves the GET path parameter by ID or by name (names are unique + // within an organization), so pass the identifier straight through — no + // client-side list-and-match needed. Delete and limits endpoints do not + // resolve names, so those paths keep resolveProjectArg. + project, err := c.projects.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/go.mod b/go.mod index a014ca3..4c5dcfb 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.66.0 + github.com/kernel/kernel-go-sdk v0.72.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 0059b0f..14e37c5 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.66.0 h1:pn+fSHHo4fJ4kYm8uOkF5J2rj6k1FC6NqlLzoxy2jy4= -github.com/kernel/kernel-go-sdk v0.66.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.72.0 h1:QyT5v2PMJjp9GQCGV4fi1IWcCF/fcsFcz1MTXPA/WZU= +github.com/kernel/kernel-go-sdk v0.72.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 662a494a058fe920904ac5ff3192d675ff5116d4 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:27:43 -0400 Subject: [PATCH 2/5] Address review feedback on telemetry events command - Use the shared addJSONOutputFlag for `browsers telemetry events` instead of a hand-rolled --output flag (consistency with extensions get). [Bugbot] - Don't send --since/--until when --offset is set: offset is an opaque X-Next-Offset cursor that already encodes the query window, so the window flags are ignored when paging. Aligns behavior with the flag help and updates --until's help to match --since. Added a test. [Bugbot] - Surface the pagination cursor in JSON mode: `--output json` now emits {"events": [...], "next_offset": "..."} so scripted callers can paginate (table mode already printed the hint). Updated the empty-JSON test. [Bugbot] - README: document `extensions get`, `browsers telemetry events`, and the `--replay` flag on `telemetry stream`. [review] Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++++++ cmd/browsers.go | 4 +-- cmd/browsers_telemetry.go | 45 ++++++++++++++++++++++++++-------- cmd/browsers_telemetry_test.go | 24 +++++++++++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b21b850..ad06e27 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,14 @@ Per-category updates are partial — only categories you name are changed; other - `--categories ` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`) - `--types ` - Filter by event type (e.g. `network_response`, `console_error`) - `--seq ` - Resume after sequence number N (Last-Event-ID); replays events with `seq > N`. Omit to stream from now. + - `--replay all` - Replay buffered events on connect, starting from the oldest retained event (mutually exclusive with `--seq`) - `-o, --output json` - Output newline-delimited JSON envelopes - Default output: tab-separated `