From 59bc757c449212daa6cf2026652fed41fa42a680 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 8 Jan 2026 18:37:21 -0500 Subject: [PATCH 1/5] feat: add --output json flag for machine-readable output Add JSON output support to CLI commands for scripting and automation: Tier 1 (create/acquire operations): - browsers create - browser-pools create, acquire - profiles create - extensions upload - proxies create - deploy (JSONL streaming) Tier 2 (list/get operations): - browsers list, get, view - browser-pools list, get, update - profiles list, get - extensions list - proxies list, get Tier 3 (app/invoke/browser sub-operations): - app list, history - deploy history - invoke (JSONL streaming), history - browsers replays list, start - browsers process exec, spawn - browsers fs file-info, list-files For streaming commands (deploy, invoke), output is JSONL format (one JSON object per line) for real-time parsing. Usage: --output json or -o json --- README.md | 56 +++++++++++- cmd/app.go | 49 ++++++++++- cmd/browser_pools.go | 56 +++++++++++- cmd/browsers.go | 194 ++++++++++++++++++++++++++++++++++++++--- cmd/deploy.go | 99 ++++++++++++++++++--- cmd/extensions.go | 61 +++++++++++-- cmd/invoke.go | 93 +++++++++++++++++--- cmd/profiles.go | 75 ++++++++++++++-- cmd/proxies/create.go | 22 ++++- cmd/proxies/get.go | 18 +++- cmd/proxies/list.go | 28 +++++- cmd/proxies/proxies.go | 5 ++ cmd/proxies/types.go | 8 +- 13 files changed, 701 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 5adee9c..c55d88b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,32 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--no-color` - Disable color output - `--log-level ` - Set log level (trace, debug, info, warn, error, fatal, print) +## JSON Output + +Many commands support JSON output for scripting and automation. Use `--output json` or `-o json` to get machine-readable output: + +```bash +# Get browser session details as JSON +kernel browsers create -o json + +# List apps as JSON +kernel app list -o json + +# Deploy with JSONL streaming output (one JSON object per line) +kernel deploy index.ts -o json +``` + +Commands with JSON output support: +- **Browsers**: `create`, `list`, `get`, `view` +- **Browser Pools**: `create`, `list`, `get`, `update`, `acquire` +- **Profiles**: `create`, `list`, `get` +- **Extensions**: `upload`, `list` +- **Proxies**: `create`, `list`, `get` +- **Apps**: `list`, `history` +- **Deploy**: `deploy` (JSONL streaming), `history` +- **Invoke**: `invoke` (JSONL streaming), `history` +- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files` + ### Authentication - `kernel login [--force]` - Login via OAuth 2.0 @@ -134,6 +160,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--force` - Allow overwriting existing version - `--env `, `-e` - Set environment variables (can be used multiple times) - `--env-file ` - Load environment variables from file (can be used multiple times) + - `--output json`, `-o json` - Output JSONL (one JSON object per line for each event) - `kernel deploy logs ` - Stream logs for a deployment @@ -143,6 +170,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `kernel deploy history [app_name]` - Show deployment history - `--limit ` - Max deployments to return (default: 100; 0 = all) + - `--output json`, `-o json` - Output raw JSON array ### App Management @@ -152,14 +180,17 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--payload `, `-p` - JSON payload for the action - `--payload-file `, `-f` - Read JSON payload from a file (use `-` for stdin) - `--sync`, `-s` - Invoke synchronously (timeout after 60s) + - `--output json`, `-o json` - Output JSONL (one JSON object per line for each event) - `kernel app list` - List deployed apps - `--name ` - Filter by app name - `--version ` - Filter by version + - `--output json`, `-o json` - Output raw JSON array - `kernel app history ` - Show deployment history for an app - `--limit ` - Max deployments to return (default: 100; 0 = all) + - `--output json`, `-o json` - Output raw JSON array ### Logs @@ -172,21 +203,26 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### Browser Management - `kernel browsers list` - List running browsers + - `--output json`, `-o json` - Output raw JSON array - `kernel browsers create` - Create a new browser session - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) + - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser - `-y, --yes` - Skip confirmation prompt - `kernel browsers view ` - Get live view URL for a browser + - `--output json`, `-o json` - Output JSON with liveViewUrl +- `kernel browsers get ` - Get detailed browser session info + - `--output json`, `-o json` - Output raw JSON object ### Browser Pools - `kernel browser-pools list` - List browser pools - - `-o, --output json` - Output raw JSON response + - `--output json`, `-o json` - Output raw JSON array - `kernel browser-pools create` - Create a browser pool - `--name ` - Optional unique name for the pool - `--size ` - Number of browsers in the pool (required) @@ -194,14 +230,17 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--timeout ` - Idle timeout for browsers acquired from the pool - `--stealth`, `--headless`, `--kiosk` - Default pool configuration - `--profile-id`, `--profile-name`, `--save-changes`, `--proxy-id`, `--extension`, `--viewport` - Same semantics as `kernel browsers create` + - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools get ` - Get pool details - - `-o, --output json` - Output raw JSON response + - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - Same flags as create plus `--discard-all-idle` to discard all idle browsers in the pool and refill at the specified fill rate + - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased - `kernel browser-pools acquire ` - Acquire a browser from the pool - `--timeout ` - Acquire timeout before returning 204 + - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools release ` - Release a browser back to the pool - `--session-id ` - Browser session ID to release (required) - `--reuse` - Reuse the browser instance (default: true) @@ -218,12 +257,14 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### Browser Replays - `kernel browsers replays list ` - List replays for a browser + - `--output json`, `-o json` - Output raw JSON array - `kernel browsers replays start ` - Start a replay recording - `--framerate ` - Recording framerate (fps) - `--max-duration ` - Maximum duration in seconds + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers replays stop ` - Stop a replay recording - `kernel browsers replays download ` - Download a replay video - - `-o, --output ` - Output file path for the replay video + - `-f, --output-file ` - Output file path for the replay video ### Browser Process Control @@ -234,6 +275,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--timeout ` - Timeout in seconds - `--as-user ` - Run as user - `--as-root` - Run as root + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers process spawn [--] [command...]` - Execute a command asynchronously - `--command ` - Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c) - `--args ` - Command arguments @@ -241,6 +283,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--timeout ` - Timeout in seconds - `--as-user ` - Run as user - `--as-root` - Run as root + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers process kill ` - Send a signal to a process - `--signal ` - Signal to send: TERM, KILL, INT, HUP (default: TERM) - `kernel browsers process status ` - Get process status @@ -262,8 +305,10 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `-o, --output ` - Output zip file path - `kernel browsers fs file-info ` - Get file or directory info - `--path ` - Absolute file or directory path (required) + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers fs list-files ` - List files in a directory - `--path ` - Absolute directory path (required) + - `--output json`, `-o json` - Output raw JSON array - `kernel browsers fs move ` - Move or rename a file or directory - `--src ` - Absolute source path (required) - `--dest ` - Absolute destination path (required) @@ -344,8 +389,10 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### Extension Management - `kernel extensions list` - List all uploaded extensions + - `--output json`, `-o json` - Output raw JSON array - `kernel extensions upload ` - Upload an unpacked browser extension directory - `--name ` - Optional unique extension name + - `--output json`, `-o json` - Output raw JSON object - `kernel extensions download ` - Download an extension archive - `--to ` - Output directory (required) - `kernel extensions download-web-store ` - Download an extension from the Chrome Web Store @@ -357,8 +404,11 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### Proxy Management - `kernel proxies list` - List proxy configurations + - `--output json`, `-o json` - Output raw JSON array - `kernel proxies get ` - Get a proxy configuration by ID + - `--output json`, `-o json` - Output raw JSON object - `kernel proxies create` - Create a new proxy configuration + - `--output json`, `-o json` - Output raw JSON object - `--name ` - Proxy configuration name - `--type ` - Proxy type: datacenter, isp, residential, mobile, custom (required) diff --git a/cmd/app.go b/cmd/app.go index 80949af..152c295 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "strings" @@ -44,9 +45,11 @@ func init() { appListCmd.Flags().Int("limit", 20, "Max apps to return (default 20)") appListCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") appListCmd.Flags().Int("page", 1, "Page number (1-based)") + appListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") // Limit rows returned for app history (0 = all) appHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") + appHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") } func runAppList(cmd *cobra.Command, args []string) error { @@ -56,6 +59,12 @@ func runAppList(cmd *cobra.Command, args []string) error { lim, _ := cmd.Flags().GetInt("limit") perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } // Determine pagination inputs: prefer page/per-page if provided; else map legacy --limit usePager := cmd.Flags().Changed("per-page") || cmd.Flags().Changed("page") @@ -73,7 +82,9 @@ func runAppList(cmd *cobra.Command, args []string) error { page = 1 } - pterm.Debug.Println("Fetching deployed applications...") + if output != "json" { + pterm.Debug.Println("Fetching deployed applications...") + } params := kernel.AppListParams{} if appName != "" { @@ -92,6 +103,19 @@ func runAppList(cmd *cobra.Command, args []string) error { return nil } + if output == "json" { + if apps == nil || len(apps.Items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(apps.Items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if apps == nil || len(apps.Items) == 0 { pterm.Info.Println("No applications found") return nil @@ -193,8 +217,16 @@ func runAppHistory(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) appName := args[0] lim, _ := cmd.Flags().GetInt("limit") + output, _ := cmd.Flags().GetString("output") - pterm.Debug.Printf("Fetching deployment history for app '%s'...\n", appName) + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + if output != "json" { + pterm.Debug.Printf("Fetching deployment history for app '%s'...\n", appName) + } params := kernel.DeploymentListParams{} if appName != "" { @@ -207,6 +239,19 @@ func runAppHistory(cmd *cobra.Command, args []string) error { return nil } + if output == "json" { + if deployments == nil || len(deployments.Items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(deployments.Items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if deployments == nil || len(deployments.Items) == 0 { pterm.Info.Println("No deployments found for this application") return nil diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 8540002..f4500a5 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -95,9 +95,15 @@ type BrowserPoolsCreateInput struct { ProxyID string Extensions []string Viewport string + Output string } func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + params := kernel.BrowserPoolNewParams{ Size: in.Size, } @@ -150,6 +156,15 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + bs, err := json.MarshalIndent(pool, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if pool.Name != "" { pterm.Success.Printf("Created browser pool %s (%s)\n", pool.Name, pool.ID) } else { @@ -224,9 +239,15 @@ type BrowserPoolsUpdateInput struct { Extensions []string Viewport string DiscardAllIdle BoolFlag + Output string } func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + params := kernel.BrowserPoolUpdateParams{} if in.Name != "" { @@ -282,6 +303,16 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(pool, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if pool.Name != "" { pterm.Success.Printf("Updated browser pool %s (%s)\n", pool.Name, pool.ID) } else { @@ -311,9 +342,15 @@ func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput) type BrowserPoolsAcquireInput struct { IDOrName string TimeoutSeconds int64 + Output string } func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + params := kernel.BrowserPoolAcquireParams{} if in.TimeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(in.TimeoutSeconds) @@ -327,6 +364,15 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return nil } + if in.Output == "json" { + bs, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", resp.SessionID}, @@ -435,6 +481,7 @@ var browserPoolsFlushCmd = &cobra.Command{ func init() { browserPoolsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + browserPoolsCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") browserPoolsCreateCmd.Flags().Int64("size", 0, "Number of browsers in the pool") _ = browserPoolsCreateCmd.MarkFlagRequired("size") @@ -466,10 +513,12 @@ func init() { browserPoolsUpdateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") + browserPoolsUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") browserPoolsDeleteCmd.Flags().Bool("force", false, "Force delete even if browsers are leased") browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") + browserPoolsAcquireCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release") _ = browserPoolsReleaseCmd.MarkFlagRequired("session-id") @@ -508,6 +557,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") + output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ Name: name, @@ -523,6 +573,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ProxyID: proxyID, Extensions: extensions, Viewport: viewport, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -553,6 +604,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") + output, _ := cmd.Flags().GetString("output") in := BrowserPoolsUpdateInput{ IDOrName: args[0], @@ -570,6 +622,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { Extensions: extensions, Viewport: viewport, DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -586,8 +639,9 @@ func runBrowserPoolsDelete(cmd *cobra.Command, args []string) error { func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) timeout, _ := cmd.Flags().GetInt64("timeout") + output, _ := cmd.Flags().GetString("output") c := BrowserPoolsCmd{client: &client.BrowserPools} - return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{IDOrName: args[0], TimeoutSeconds: timeout}) + return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{IDOrName: args[0], TimeoutSeconds: timeout, Output: output}) } func runBrowserPoolsRelease(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers.go b/cmd/browsers.go index 67ff175..52e5d1b 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -163,6 +163,7 @@ type BrowsersCreateInput struct { ProxyID string Extensions []string Viewport string + Output string } type BrowsersDeleteInput struct { @@ -172,6 +173,7 @@ type BrowsersDeleteInput struct { type BrowsersViewInput struct { Identifier string + Output string } type BrowsersGetInput struct { @@ -287,7 +289,14 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { } func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { - pterm.Info.Println("Creating browser session...") + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + if in.Output != "json" { + pterm.Info.Println("Creating browser session...") + } params := kernel.BrowserNewParams{} if in.PersistenceID != "" { params.Persistence = kernel.BrowserPersistenceParam{ID: in.PersistenceID} @@ -363,6 +372,15 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + bs, err := json.MarshalIndent(browser, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Persistence, browser.Profile) return nil } @@ -456,10 +474,23 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { } func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + browser, err := b.browsers.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + result := map[string]string{"liveViewUrl": browser.BrowserLiveViewURL} + bs, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(bs)) + return nil + } + if browser.BrowserLiveViewURL == "" { if browser.Headless { pterm.Warning.Println("This browser is running in headless mode and does not have a live view URL") @@ -855,12 +886,14 @@ func (b BrowsersCmd) ComputerSetCursor(ctx context.Context, in BrowsersComputerS // Replays type BrowsersReplaysListInput struct { Identifier string + Output string } type BrowsersReplaysStartInput struct { Identifier string Framerate int MaxDurationSeconds int + Output string } type BrowsersReplaysStopInput struct { @@ -875,6 +908,11 @@ type BrowsersReplaysDownloadInput struct { } func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + br, err := b.browsers.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -883,6 +921,20 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + if items == nil || len(*items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if items == nil || len(*items) == 0 { pterm.Info.Println("No replays found") return nil @@ -896,6 +948,11 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu } func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + br, err := b.browsers.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -911,6 +968,16 @@ func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartIn if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(res, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + rows := pterm.TableData{{"Property", "Value"}, {"Replay ID", res.ReplayID}, {"View URL", res.ReplayViewURL}, {"Started At", util.FormatLocal(res.StartedAt)}} PrintTableNoPad(rows, true) return nil @@ -967,9 +1034,19 @@ type BrowsersProcessExecInput struct { Timeout int AsUser string AsRoot BoolFlag + Output string } -type BrowsersProcessSpawnInput = BrowsersProcessExecInput +type BrowsersProcessSpawnInput struct { + Identifier string + Command string + Args []string + Cwd string + Timeout int + AsUser string + AsRoot BoolFlag + Output string +} type BrowsersProcessKillInput struct { Identifier string @@ -1043,6 +1120,11 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if b.process == nil { pterm.Error.Println("process service not available") return nil @@ -1071,6 +1153,16 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(res, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + rows := pterm.TableData{{"Property", "Value"}, {"Exit Code", fmt.Sprintf("%d", res.ExitCode)}, {"Duration (ms)", fmt.Sprintf("%d", res.DurationMs)}} PrintTableNoPad(rows, true) if res.StdoutB64 != "" { @@ -1101,6 +1193,11 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu } func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if b.process == nil { pterm.Error.Println("process service not available") return nil @@ -1129,6 +1226,16 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(res, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + rows := pterm.TableData{{"Property", "Value"}, {"Process ID", res.ProcessID}, {"PID", fmt.Sprintf("%d", res.Pid)}, {"Started At", util.FormatLocal(res.StartedAt)}} PrintTableNoPad(rows, true) return nil @@ -1247,11 +1354,13 @@ type BrowsersFSDownloadDirZipInput struct { type BrowsersFSFileInfoInput struct { Identifier string Path string + Output string } type BrowsersFSListFilesInput struct { Identifier string Path string + Output string } type BrowsersFSMoveInput struct { @@ -1389,6 +1498,11 @@ func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownload } func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if b.fs == nil { pterm.Error.Println("fs service not available") return nil @@ -1401,12 +1515,27 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(res, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + rows := pterm.TableData{{"Property", "Value"}, {"Path", res.Path}, {"Name", res.Name}, {"Mode", res.Mode}, {"IsDir", fmt.Sprintf("%t", res.IsDir)}, {"SizeBytes", fmt.Sprintf("%d", res.SizeBytes)}, {"ModTime", util.FormatLocal(res.ModTime)}} PrintTableNoPad(rows, true) return nil } func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if b.fs == nil { pterm.Error.Println("fs service not available") return nil @@ -1419,6 +1548,20 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + if res == nil || len(*res) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*res, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if res == nil || len(*res) == 0 { pterm.Info.Println("No files found") return nil @@ -1755,6 +1898,9 @@ func init() { // get flags browsersGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + // view flags + browsersViewCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) browsersCmd.AddCommand(browsersDeleteCmd) @@ -1775,12 +1921,14 @@ func init() { // replays replaysRoot := &cobra.Command{Use: "replays", Short: "Manage browser replays"} replaysList := &cobra.Command{Use: "list ", Short: "List replays for a browser", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysList} + replaysList.Flags().StringP("output", "o", "", "Output format: json for raw API response") replaysStart := &cobra.Command{Use: "start ", Short: "Start a replay recording", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysStart} replaysStart.Flags().Int("framerate", 0, "Recording framerate (fps)") replaysStart.Flags().Int("max-duration", 0, "Maximum duration in seconds") + replaysStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") replaysStop := &cobra.Command{Use: "stop ", Short: "Stop a replay recording", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysStop} replaysDownload := &cobra.Command{Use: "download ", Short: "Download a replay video", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysDownload} - replaysDownload.Flags().StringP("output", "o", "", "Output file path for the replay video") + replaysDownload.Flags().StringP("output-file", "f", "", "Output file path for the replay video") replaysRoot.AddCommand(replaysList, replaysStart, replaysStop, replaysDownload) browsersCmd.AddCommand(replaysRoot) @@ -1793,6 +1941,7 @@ func init() { procExec.Flags().Int("timeout", 0, "Timeout in seconds") procExec.Flags().String("as-user", "", "Run as user") procExec.Flags().Bool("as-root", false, "Run as root") + procExec.Flags().StringP("output", "o", "", "Output format: json for raw API response") procSpawn := &cobra.Command{Use: "spawn [--] [command...]", Short: "Execute a command asynchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessSpawn} procSpawn.Flags().String("command", "", "Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)") procSpawn.Flags().StringSlice("args", []string{}, "Command arguments") @@ -1800,6 +1949,7 @@ func init() { procSpawn.Flags().Int("timeout", 0, "Timeout in seconds") procSpawn.Flags().String("as-user", "", "Run as user") procSpawn.Flags().Bool("as-root", false, "Run as root") + procSpawn.Flags().StringP("output", "o", "", "Output format: json for raw API response") procKill := &cobra.Command{Use: "kill ", Short: "Send a signal to a process", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessKill} procKill.Flags().String("signal", "TERM", "Signal to send (TERM, KILL, INT, HUP)") procStatus := &cobra.Command{Use: "status ", Short: "Get process status", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessStatus} @@ -1829,9 +1979,11 @@ func init() { fsFileInfo := &cobra.Command{Use: "file-info ", Short: "Get file or directory info", Args: cobra.ExactArgs(1), RunE: runBrowsersFSFileInfo} fsFileInfo.Flags().String("path", "", "Absolute file or directory path") _ = fsFileInfo.MarkFlagRequired("path") + fsFileInfo.Flags().StringP("output", "o", "", "Output format: json for raw API response") fsListFiles := &cobra.Command{Use: "list-files ", Short: "List files in a directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSListFiles} fsListFiles.Flags().String("path", "", "Absolute directory path") _ = fsListFiles.MarkFlagRequired("path") + fsListFiles.Flags().StringP("output", "o", "", "Output format: json for raw API response") fsMove := &cobra.Command{Use: "move ", Short: "Move or rename a file or directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSMove} fsMove.Flags().String("src", "", "Absolute source path") fsMove.Flags().String("dest", "", "Absolute destination path") @@ -1953,6 +2105,7 @@ func init() { browsersCmd.AddCommand(playwrightRoot) // Add flags for create command + browsersCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") browsersCreateCmd.Flags().StringP("persistent-id", "p", "", "[DEPRECATED] Use --timeout and profiles instead. Unique identifier for browser session persistence") _ = browsersCreateCmd.Flags().MarkDeprecated("persistent-id", "use --timeout (up to 72 hours) and profiles instead") browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") @@ -2012,6 +2165,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") + output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { pterm.Error.Println("must specify at most one of --pool-id or --pool-name") @@ -2059,7 +2213,9 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { pool = poolName } - pterm.Info.Printf("Acquiring browser from pool %s...\n", pool) + if output != "json" { + pterm.Info.Printf("Acquiring browser from pool %s...\n", pool) + } poolSvc := client.BrowserPools acquireParams := kernel.BrowserPoolAcquireParams{} @@ -2075,6 +2231,14 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { pterm.Error.Println("Acquire request timed out (no browser available). Retry to continue waiting.") return nil } + if output == "json" { + bs, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Persistence, resp.Profile) return nil } @@ -2108,6 +2272,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { ProxyID: proxyID, Extensions: extensions, Viewport: viewport, + Output: output, } svc := client.Browsers @@ -2132,10 +2297,11 @@ func runBrowsersDelete(cmd *cobra.Command, args []string) error { func runBrowsersView(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") identifier := args[0] - in := BrowsersViewInput{Identifier: identifier} + in := BrowsersViewInput{Identifier: identifier, Output: output} svc := client.Browsers b := BrowsersCmd{browsers: &svc} return b.View(cmd.Context(), in) @@ -2173,8 +2339,9 @@ func runBrowsersLogsStream(cmd *cobra.Command, args []string) error { func runBrowsersReplaysList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} - return b.ReplaysList(cmd.Context(), BrowsersReplaysListInput{Identifier: args[0]}) + return b.ReplaysList(cmd.Context(), BrowsersReplaysListInput{Identifier: args[0], Output: output}) } func runBrowsersReplaysStart(cmd *cobra.Command, args []string) error { @@ -2182,8 +2349,9 @@ func runBrowsersReplaysStart(cmd *cobra.Command, args []string) error { svc := client.Browsers fr, _ := cmd.Flags().GetInt("framerate") md, _ := cmd.Flags().GetInt("max-duration") + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} - return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md}) + return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md, Output: output}) } func runBrowsersReplaysStop(cmd *cobra.Command, args []string) error { @@ -2216,8 +2384,9 @@ func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { command = "/bin/bash" argv = []string{"-c", shellCmd} } + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, process: &svc.Process} - return b.ProcessExec(cmd.Context(), BrowsersProcessExecInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}}) + return b.ProcessExec(cmd.Context(), BrowsersProcessExecInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, Output: output}) } func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { @@ -2234,8 +2403,9 @@ func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { command = "/bin/bash" argv = []string{"-c", shellCmd} } + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, process: &svc.Process} - return b.ProcessSpawn(cmd.Context(), BrowsersProcessSpawnInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}}) + return b.ProcessSpawn(cmd.Context(), BrowsersProcessSpawnInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, Output: output}) } func runBrowsersProcessKill(cmd *cobra.Command, args []string) error { @@ -2332,16 +2502,18 @@ func runBrowsersFSFileInfo(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers path, _ := cmd.Flags().GetString("path") + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} - return b.FSFileInfo(cmd.Context(), BrowsersFSFileInfoInput{Identifier: args[0], Path: path}) + return b.FSFileInfo(cmd.Context(), BrowsersFSFileInfoInput{Identifier: args[0], Path: path, Output: output}) } func runBrowsersFSListFiles(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers path, _ := cmd.Flags().GetString("path") + output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} - return b.FSListFiles(cmd.Context(), BrowsersFSListFilesInput{Identifier: args[0], Path: path}) + return b.FSListFiles(cmd.Context(), BrowsersFSListFilesInput{Identifier: args[0], Path: path, Output: output}) } func runBrowsersFSMove(cmd *cobra.Command, args []string) error { diff --git a/cmd/deploy.go b/cmd/deploy.go index 2928610..e50e3a5 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -57,6 +57,7 @@ func init() { deployCmd.Flags().Bool("force", false, "Allow overwrite of an existing version with the same name") deployCmd.Flags().StringArrayP("env", "e", []string{}, "Set environment variables (e.g., KEY=value). May be specified multiple times") deployCmd.Flags().StringArray("env-file", []string{}, "Read environment variables from a file (.env format). May be specified multiple times") + deployCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") // Subcommands under deploy deployLogsCmd.Flags().BoolP("follow", "f", false, "Follow logs in real-time (stream continuously)") @@ -67,6 +68,7 @@ func init() { deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)") + deployHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") deployCmd.AddCommand(deployHistoryCmd) // Flags for GitHub deploy @@ -92,6 +94,12 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { version, _ := cmd.Flags().GetString("version") force, _ := cmd.Flags().GetBool("force") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } // Collect env vars similar to runDeploy envPairs, _ := cmd.Flags().GetStringArray("env") @@ -119,7 +127,9 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { // Build the multipart request body directly for source-based deploy - pterm.Info.Println("Deploying from GitHub source...") + if output != "json" { + pterm.Info.Println("Deploying from GitHub source...") + } startTime := time.Now() // Manually POST multipart with a JSON 'source' field to match backend expectations @@ -190,7 +200,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { return fmt.Errorf("decode deployment response: %w", err) } - return followDeployment(cmd.Context(), client, depCreated.ID, startTime, + return followDeployment(cmd.Context(), client, depCreated.ID, startTime, output, option.WithBaseURL(baseURL), option.WithHeader("Authorization", "Bearer "+apiKey), option.WithMaxRetries(0), @@ -203,6 +213,13 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { entrypoint := args[0] version, _ := cmd.Flags().GetString("version") force, _ := cmd.Flags().GetBool("force") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if version == "" { version = "latest" } @@ -215,14 +232,21 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } sourceDir := filepath.Dir(resolvedEntrypoint) - spinner, _ := pterm.DefaultSpinner.Start("Compressing files...") + var spinner *pterm.SpinnerPrinter + if output != "json" { + spinner, _ = pterm.DefaultSpinner.Start("Compressing files...") + } tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_%d.zip", time.Now().UnixNano())) logger.Debug("compressing files", logger.Args("sourceDir", sourceDir, "tmpFile", tmpFile)) if err := util.ZipDirectory(sourceDir, tmpFile); err != nil { - spinner.Fail("Failed to compress files") + if spinner != nil { + spinner.Fail("Failed to compress files") + } return err } - spinner.Success("Compressed files") + if spinner != nil { + spinner.Success("Compressed files") + } defer os.Remove(tmpFile) // make io.Reader from tmpFile @@ -259,7 +283,9 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } logger.Debug("deploying app", logger.Args("version", version, "force", force, "entrypoint", filepath.Base(resolvedEntrypoint))) - pterm.Info.Println("Deploying...") + if output != "json" { + pterm.Info.Println("Deploying...") + } resp, err := client.Deployments.New(cmd.Context(), kernel.DeploymentNewParams{ File: file, @@ -272,7 +298,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { return util.CleanedUpSdkError{Err: err} } - return followDeployment(cmd.Context(), client, resp.ID, startTime, option.WithMaxRetries(0)) + return followDeployment(cmd.Context(), client, resp.ID, startTime, output, option.WithMaxRetries(0)) } func quoteIfNeeded(s string) string { @@ -359,6 +385,12 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { lim, _ := cmd.Flags().GetInt("limit") perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } // Prefer page/per-page when provided; map legacy --limit otherwise usePager := cmd.Flags().Changed("per-page") || cmd.Flags().Changed("page") @@ -390,12 +422,28 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { params.Limit = kernel.Opt(int64(perPage + 1)) params.Offset = kernel.Opt(int64((page - 1) * perPage)) - pterm.Debug.Println("Fetching deployments...") + if output != "json" { + pterm.Debug.Println("Fetching deployments...") + } deployments, err := client.Deployments.List(cmd.Context(), params) if err != nil { pterm.Error.Printf("Failed to list deployments: %v\n", err) return nil } + + if output == "json" { + if deployments == nil || len(deployments.Items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(deployments.Items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if deployments == nil || len(deployments.Items) == 0 { pterm.Info.Println("No deployments found") return nil @@ -470,10 +518,35 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { return nil } -func followDeployment(ctx context.Context, client kernel.Client, deploymentID string, startTime time.Time, opts ...option.RequestOption) error { +func followDeployment(ctx context.Context, client kernel.Client, deploymentID string, startTime time.Time, output string, opts ...option.RequestOption) error { stream := client.Deployments.FollowStreaming(ctx, deploymentID, kernel.DeploymentFollowParams{}, opts...) + jsonOutput := output == "json" + for stream.Next() { data := stream.Current() + + if jsonOutput { + // Output each event as a JSON line + bs, err := json.Marshal(data) + if err == nil { + fmt.Println(string(bs)) + } + // Check for terminal states + if data.Event == "deployment_state" { + deploymentState := data.AsDeploymentState() + status := deploymentState.Deployment.Status + if status == string(kernel.DeploymentGetResponseStatusFailed) || + status == string(kernel.DeploymentGetResponseStatusStopped) || + status == string(kernel.DeploymentGetResponseStatusRunning) { + return nil + } + } + if data.Event == "error" { + return nil + } + continue + } + switch data.Event { case "log": logEv := data.AsLog() @@ -510,9 +583,11 @@ func followDeployment(ctx context.Context, client kernel.Client, deploymentID st } if serr := stream.Err(); serr != nil { - pterm.Error.Println("✖ Stream error") - pterm.Error.Printf("Deployment ID: %s\n", deploymentID) - pterm.Info.Printf("View logs: kernel deploy logs %s --since 1h\n", deploymentID) + if !jsonOutput { + pterm.Error.Println("✖ Stream error") + pterm.Error.Printf("Deployment ID: %s\n", deploymentID) + pterm.Info.Printf("View logs: kernel deploy logs %s --since 1h\n", deploymentID) + } return fmt.Errorf("stream error: %w", serr) } return nil diff --git a/cmd/extensions.go b/cmd/extensions.go index 2c9118d..1769f7d 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -26,7 +27,9 @@ type ExtensionsService interface { Upload(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (res *kernel.ExtensionUploadResponse, err error) } -type ExtensionsListInput struct{} +type ExtensionsListInput struct { + Output string +} type ExtensionsDeleteInput struct { Identifier string @@ -45,8 +48,9 @@ type ExtensionsDownloadWebStoreInput struct { } type ExtensionsUploadInput struct { - Dir string - Name string + Dir string + Name string + Output string } // ExtensionsCmd handles extension operations independent of cobra. @@ -54,12 +58,33 @@ type ExtensionsCmd struct { extensions ExtensionsService } -func (e ExtensionsCmd) List(ctx context.Context, _ ExtensionsListInput) error { - pterm.Info.Println("Fetching extensions...") +func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + if in.Output != "json" { + pterm.Info.Println("Fetching extensions...") + } items, err := e.extensions.List(ctx) if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + if items == nil || len(*items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if items == nil || len(*items) == 0 { pterm.Info.Println("No extensions found") return nil @@ -259,6 +284,11 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo } func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + if in.Dir == "" { return fmt.Errorf("missing directory argument") } @@ -272,7 +302,9 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err } tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) - pterm.Info.Println("Zipping extension directory...") + if in.Output != "json" { + pterm.Info.Println("Zipping extension directory...") + } if err := util.ZipDirectory(absDir, tmpFile); err != nil { pterm.Error.Println("Failed to zip directory") return err @@ -294,6 +326,15 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + bs, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + name := item.Name if name == "" { name = "-" @@ -322,9 +363,10 @@ var extensionsListCmd = &cobra.Command{ Args: cobra.NoArgs, 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.List(cmd.Context(), ExtensionsListInput{}) + return e.List(cmd.Context(), ExtensionsListInput{Output: output}) }, } @@ -375,9 +417,10 @@ var extensionsUploadCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) name, _ := cmd.Flags().GetString("name") + output, _ := cmd.Flags().GetString("output") svc := client.Extensions e := ExtensionsCmd{extensions: &svc} - return e.Upload(cmd.Context(), ExtensionsUploadInput{Dir: args[0], Name: name}) + return e.Upload(cmd.Context(), ExtensionsUploadInput{Dir: args[0], Name: name, Output: output}) }, } @@ -388,9 +431,11 @@ func init() { extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd) extensionsCmd.AddCommand(extensionsUploadCmd) + extensionsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") extensionsDownloadCmd.Flags().String("to", "", "Output zip file path") extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive") extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)") + extensionsUploadCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name") } diff --git a/cmd/invoke.go b/cmd/invoke.go index 744b2c1..797ae15 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -40,11 +40,13 @@ func init() { invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)") invokeCmd.Flags().StringP("payload-file", "f", "", "Path to a JSON file containing the payload (use '-' for stdin)") invokeCmd.Flags().BoolP("sync", "s", false, "Invoke synchronously (default false). A synchronous invocation will open a long-lived HTTP POST to the Kernel API to wait for the invocation to complete. This will time out after 60 seconds, so only use this option if you expect your invocation to complete in less than 60 seconds. The default is to invoke asynchronously, in which case the CLI will open an SSE connection to the Kernel API after submitting the invocation and wait for the invocation to complete.") + invokeCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") invokeCmd.MarkFlagsMutuallyExclusive("payload", "payload-file") invocationHistoryCmd.Flags().Int("limit", 100, "Max invocations to return (default 100)") invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name") invocationHistoryCmd.Flags().String("version", "", "Filter by invocation version") + invocationHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") invokeCmd.AddCommand(invocationHistoryCmd) } @@ -57,6 +59,14 @@ func runInvoke(cmd *cobra.Command, args []string) error { appName := args[0] actionName := args[1] version, _ := cmd.Flags().GetString("version") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + jsonOutput := output == "json" + if version == "" { return fmt.Errorf("version cannot be an empty string") } @@ -79,7 +89,9 @@ func runInvoke(cmd *cobra.Command, args []string) error { ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) cmd.SetContext(ctx) - pterm.Info.Printf("Invoking \"%s\" (action: %s, version: %s)…\n", appName, actionName, version) + if !jsonOutput { + pterm.Info.Printf("Invoking \"%s\" (action: %s, version: %s)…\n", appName, actionName, version) + } // Create the invocation resp, err := client.Invocations.New(cmd.Context(), params, option.WithMaxRetries(0)) @@ -87,7 +99,9 @@ func runInvoke(cmd *cobra.Command, args []string) error { return handleSdkError(err) } // Log the invocation ID for user reference - pterm.Info.Printfln("Invocation ID: %s", resp.ID) + if !jsonOutput { + pterm.Info.Printfln("Invocation ID: %s", resp.ID) + } // coordinate the cleanup with the polling loop to ensure this is given enough time to run // before this function returns cleanupDone := make(chan struct{}) @@ -99,6 +113,11 @@ func runInvoke(cmd *cobra.Command, args []string) error { }() if resp.Status != kernel.InvocationNewResponseStatusQueued { + if jsonOutput { + bs, _ := json.Marshal(resp) + fmt.Println(string(bs)) + return nil + } succeeded := resp.Status == kernel.InvocationNewResponseStatusSucceeded printResult(succeeded, resp.Output) @@ -116,7 +135,9 @@ func runInvoke(cmd *cobra.Command, args []string) error { once.Do(func() { cleanupStarted.Store(true) defer close(cleanupDone) - pterm.Warning.Println("Invocation cancelled...cleaning up...") + if !jsonOutput { + pterm.Warning.Println("Invocation cancelled...cleaning up...") + } if _, err := client.Invocations.Update( context.Background(), resp.ID, @@ -126,10 +147,14 @@ func runInvoke(cmd *cobra.Command, args []string) error { }, option.WithRequestTimeout(30*time.Second), ); err != nil { - pterm.Error.Printf("Failed to mark invocation as failed: %v\n", err) + if !jsonOutput { + pterm.Error.Printf("Failed to mark invocation as failed: %v\n", err) + } } if err := client.Invocations.DeleteBrowsers(context.Background(), resp.ID, option.WithRequestTimeout(30*time.Second)); err != nil { - pterm.Error.Printf("Failed to cancel invocation: %v\n", err) + if !jsonOutput { + pterm.Error.Printf("Failed to cancel invocation: %v\n", err) + } } }) }) @@ -139,6 +164,27 @@ func runInvoke(cmd *cobra.Command, args []string) error { for stream.Next() { ev := stream.Current() + if jsonOutput { + // Output each event as a JSON line + bs, err := json.Marshal(ev) + if err == nil { + fmt.Println(string(bs)) + } + // Check for terminal states + if ev.Event == "invocation_state" { + stateEv := ev.AsInvocationState() + status := stateEv.Invocation.Status + if status == string(kernel.InvocationGetResponseStatusSucceeded) || + status == string(kernel.InvocationGetResponseStatusFailed) { + return nil + } + } + if ev.Event == "error" { + return nil + } + continue + } + switch ev.Event { case "log": logEv := ev.AsLog() @@ -275,6 +321,12 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { lim, _ := cmd.Flags().GetInt("limit") appFilter, _ := cmd.Flags().GetString("app") versionFilter, _ := cmd.Flags().GetString("version") + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } // Build parameters for the API call params := kernel.InvocationListParams{ @@ -292,14 +344,16 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { } // Build debug message based on filters - if appFilter != "" && versionFilter != "" { - pterm.Debug.Printf("Listing invocations for app '%s' version '%s'...\n", appFilter, versionFilter) - } else if appFilter != "" { - pterm.Debug.Printf("Listing invocations for app '%s'...\n", appFilter) - } else if versionFilter != "" { - pterm.Debug.Printf("Listing invocations for version '%s'...\n", versionFilter) - } else { - pterm.Debug.Printf("Listing all invocations...\n") + if output != "json" { + if appFilter != "" && versionFilter != "" { + pterm.Debug.Printf("Listing invocations for app '%s' version '%s'...\n", appFilter, versionFilter) + } else if appFilter != "" { + pterm.Debug.Printf("Listing invocations for app '%s'...\n", appFilter) + } else if versionFilter != "" { + pterm.Debug.Printf("Listing invocations for version '%s'...\n", versionFilter) + } else { + pterm.Debug.Printf("Listing all invocations...\n") + } } // Make a single API call to get invocations @@ -309,6 +363,19 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { return nil } + if output == "json" { + if len(invocations.Items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(invocations.Items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + table := pterm.TableData{{"Invocation ID", "App Name", "Action", "Version", "Status", "Started At", "Duration", "Output"}} for _, inv := range invocations.Items { diff --git a/cmd/profiles.go b/cmd/profiles.go index f356377..7975ce2 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -27,10 +27,16 @@ type ProfilesService interface { type ProfilesGetInput struct { Identifier string + Output string +} + +type ProfilesListInput struct { + Output string } type ProfilesCreateInput struct { - Name string + Name string + Output string } type ProfilesDeleteInput struct { @@ -49,12 +55,33 @@ type ProfilesCmd struct { profiles ProfilesService } -func (p ProfilesCmd) List(ctx context.Context) error { - pterm.Info.Println("Fetching profiles...") +func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + if in.Output != "json" { + pterm.Info.Println("Fetching profiles...") + } items, err := p.profiles.List(ctx) if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + if items == nil || len(*items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if items == nil || len(*items) == 0 { pterm.Info.Println("No profiles found") return nil @@ -78,6 +105,11 @@ func (p ProfilesCmd) List(ctx context.Context) error { } func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + item, err := p.profiles.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -86,6 +118,16 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) return nil } + + if in.Output == "json" { + bs, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + name := item.Name if name == "" { name = "-" @@ -101,6 +143,11 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { } func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + params := kernel.ProfileNewParams{} if in.Name != "" { params.Name = kernel.Opt(in.Name) @@ -109,6 +156,16 @@ func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { if err != nil { return util.CleanedUpSdkError{Err: err} } + + if in.Output == "json" { + bs, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + name := item.Name if name == "" { name = "-" @@ -255,6 +312,9 @@ func init() { profilesCmd.AddCommand(profilesDeleteCmd) profilesCmd.AddCommand(profilesDownloadCmd) + profilesListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + profilesGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + profilesCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") profilesDownloadCmd.Flags().String("to", "", "Output zip file path") @@ -263,24 +323,27 @@ func init() { func runProfilesList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") svc := client.Profiles p := ProfilesCmd{profiles: &svc} - return p.List(cmd.Context()) + return p.List(cmd.Context(), ProfilesListInput{Output: output}) } func runProfilesGet(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") svc := client.Profiles p := ProfilesCmd{profiles: &svc} - return p.Get(cmd.Context(), ProfilesGetInput{Identifier: args[0]}) + return p.Get(cmd.Context(), ProfilesGetInput{Identifier: args[0], Output: output}) } func runProfilesCreate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) name, _ := cmd.Flags().GetString("name") + output, _ := cmd.Flags().GetString("output") svc := client.Profiles p := ProfilesCmd{profiles: &svc} - return p.Create(cmd.Context(), ProfilesCreateInput{Name: name}) + return p.Create(cmd.Context(), ProfilesCreateInput{Name: name, Output: output}) } func runProfilesDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 633544e..dace94f 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -2,6 +2,7 @@ package proxies import ( "context" + "encoding/json" "fmt" "github.com/kernel/cli/pkg/table" @@ -12,6 +13,11 @@ import ( ) func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + // Validate proxy type var proxyType kernel.ProxyNewParamsType switch in.Type { @@ -160,13 +166,24 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } } - pterm.Info.Printf("Creating %s proxy...\n", proxyType) + if in.Output != "json" { + pterm.Info.Printf("Creating %s proxy...\n", proxyType) + } proxy, err := p.proxies.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + bs, err := json.MarshalIndent(proxy, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + pterm.Success.Printf("Successfully created proxy\n") // Display created proxy details @@ -210,6 +227,8 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { username, _ := cmd.Flags().GetString("username") password, _ := cmd.Flags().GetString("password") + output, _ := cmd.Flags().GetString("output") + svc := client.Proxies p := ProxyCmd{proxies: &svc} return p.Create(cmd.Context(), ProxyCreateInput{ @@ -227,5 +246,6 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { Port: port, Username: username, Password: password, + Output: output, }) } diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 97fdc21..326c32f 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -2,6 +2,7 @@ package proxies import ( "context" + "encoding/json" "fmt" "github.com/kernel/cli/pkg/table" @@ -12,11 +13,25 @@ import ( ) func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + item, err := p.proxies.Get(ctx, in.ID) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + bs, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + // Display proxy details rows := pterm.TableData{{"Property", "Value"}} @@ -127,7 +142,8 @@ func getProxyConfigRows(proxy *kernel.ProxyGetResponse) [][]string { func runProxiesGet(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.Get(cmd.Context(), ProxyGetInput{ID: args[0]}) + return p.Get(cmd.Context(), ProxyGetInput{ID: args[0], Output: output}) } diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index b90781c..2e7ca02 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -2,6 +2,7 @@ package proxies import ( "context" + "encoding/json" "fmt" "strings" @@ -12,14 +13,34 @@ import ( "github.com/spf13/cobra" ) -func (p ProxyCmd) List(ctx context.Context) error { - pterm.Info.Println("Fetching proxy configurations...") +func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + if in.Output != "json" { + pterm.Info.Println("Fetching proxy configurations...") + } items, err := p.proxies.List(ctx) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + if items == nil || len(*items) == 0 { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*items, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if items == nil || len(*items) == 0 { pterm.Info.Println("No proxy configurations found") return nil @@ -119,7 +140,8 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { func runProxiesList(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.List(cmd.Context()) + return p.List(cmd.Context(), ProxyListInput{Output: output}) } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 531a606..b6e7ffa 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -67,6 +67,11 @@ func init() { ProxiesCmd.AddCommand(proxiesCreateCmd) ProxiesCmd.AddCommand(proxiesDeleteCmd) + // Add output flags + proxiesListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + proxiesGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + proxiesCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + // Add flags for create command proxiesCreateCmd.Flags().String("name", "", "Proxy configuration name") proxiesCreateCmd.Flags().String("type", "", "Proxy type (datacenter|isp|residential|mobile|custom)") diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index 979f071..6da63df 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -21,10 +21,13 @@ type ProxyCmd struct { } // Input types for proxy operations -type ProxyListInput struct{} +type ProxyListInput struct { + Output string +} type ProxyGetInput struct { - ID string + ID string + Output string } type ProxyCreateInput struct { @@ -46,6 +49,7 @@ type ProxyCreateInput struct { Port int Username string Password string + Output string } type ProxyDeleteInput struct { From 7070bc56359ce7d3d15f3d09ed6a49df9d766c2d Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 8 Jan 2026 18:50:44 -0500 Subject: [PATCH 2/5] fix: update test files to pass required input structs to List methods --- cmd/profiles_test.go | 4 ++-- cmd/proxies/list_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index 3833924..cf9b9fa 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -86,7 +86,7 @@ func TestProfilesList_Empty(t *testing.T) { buf := captureProfilesOutput(t) fake := &FakeProfilesService{} p := ProfilesCmd{profiles: fake} - _ = p.List(context.Background()) + _ = p.List(context.Background(), ProfilesListInput{}) assert.Contains(t, buf.String(), "No profiles found") } @@ -96,7 +96,7 @@ func TestProfilesList_WithRows(t *testing.T) { rows := []kernel.Profile{{ID: "p1", Name: "alpha", CreatedAt: created, UpdatedAt: created}, {ID: "p2", Name: "", CreatedAt: created, UpdatedAt: created}} fake := &FakeProfilesService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { return &rows, nil }} p := ProfilesCmd{profiles: fake} - _ = p.List(context.Background()) + _ = p.List(context.Background(), ProfilesListInput{}) out := buf.String() assert.Contains(t, out, "p1") assert.Contains(t, out, "alpha") diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index dcb6f71..7a235f6 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -20,7 +20,7 @@ func TestProxyList_Empty(t *testing.T) { } p := ProxyCmd{proxies: fake} - err := p.List(context.Background()) + err := p.List(context.Background(), ProxyListInput{}) assert.NoError(t, err) assert.Contains(t, buf.String(), "No proxy configurations found") @@ -59,7 +59,7 @@ func TestProxyList_WithProxies(t *testing.T) { } p := ProxyCmd{proxies: fake} - err := p.List(context.Background()) + err := p.List(context.Background(), ProxyListInput{}) assert.NoError(t, err) output := buf.String() @@ -101,7 +101,7 @@ func TestProxyList_Error(t *testing.T) { } p := ProxyCmd{proxies: fake} - err := p.List(context.Background()) + err := p.List(context.Background(), ProxyListInput{}) assert.Error(t, err) assert.Contains(t, err.Error(), "API error") From 52b5c9be4b1d391916987d1b8d8f77128d897723 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 8 Jan 2026 18:59:59 -0500 Subject: [PATCH 3/5] fix: handle JSON mode properly for error cases - profiles get: output null instead of pterm error when profile not found - invoke: output JSON error object instead of human-readable text when API call fails in JSON mode Addresses cursor[bot] review feedback. --- cmd/invoke.go | 10 ++++++++++ cmd/profiles.go | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/cmd/invoke.go b/cmd/invoke.go index 797ae15..a1b23ac 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -96,6 +96,16 @@ func runInvoke(cmd *cobra.Command, args []string) error { // Create the invocation resp, err := client.Invocations.New(cmd.Context(), params, option.WithMaxRetries(0)) if err != nil { + if jsonOutput { + // In JSON mode, output error as JSON object + errObj := map[string]interface{}{"error": err.Error()} + if apiErr, ok := err.(*kernel.Error); ok { + errObj["status_code"] = apiErr.StatusCode + } + bs, _ := json.Marshal(errObj) + fmt.Println(string(bs)) + return fmt.Errorf("invocation failed: %w", err) + } return handleSdkError(err) } // Log the invocation ID for user reference diff --git a/cmd/profiles.go b/cmd/profiles.go index 7975ce2..088be0e 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -115,6 +115,10 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { return util.CleanedUpSdkError{Err: err} } if item == nil || item.ID == "" { + if in.Output == "json" { + fmt.Println("null") + return nil + } pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) return nil } From c5a11b5f125b331b61a3dcfadb9e05a970f18aa0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 8 Jan 2026 19:02:27 -0500 Subject: [PATCH 4/5] fix: address remaining cursor[bot] review feedback 1. Fix replays download flag: read "output-file" instead of "output" 2. Return proper exit codes in JSON mode for failures: - deploy: return error on failed/stopped deployments - invoke: return error on failed invocations 3. Handle acquire timeout in JSON mode: - browser-pools acquire: output null instead of pterm warning - browsers create (with pool): output null instead of pterm error --- cmd/browser_pools.go | 4 ++++ cmd/browsers.go | 6 +++++- cmd/deploy.go | 23 +++++++++++++---------- cmd/invoke.go | 23 +++++++++++++---------- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index f4500a5..9cc509d 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -360,6 +360,10 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return util.CleanedUpSdkError{Err: err} } if resp == nil { + if in.Output == "json" { + fmt.Println("null") + return nil + } pterm.Warning.Println("Acquire request timed out (no browser available). Retry to continue waiting.") return nil } diff --git a/cmd/browsers.go b/cmd/browsers.go index 52e5d1b..5379702 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2228,6 +2228,10 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { return util.CleanedUpSdkError{Err: err} } if resp == nil { + if output == "json" { + fmt.Println("null") + return nil + } pterm.Error.Println("Acquire request timed out (no browser available). Retry to continue waiting.") return nil } @@ -2364,7 +2368,7 @@ func runBrowsersReplaysStop(cmd *cobra.Command, args []string) error { func runBrowsersReplaysDownload(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers - out, _ := cmd.Flags().GetString("output") + out, _ := cmd.Flags().GetString("output-file") b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} return b.ReplaysDownload(cmd.Context(), BrowsersReplaysDownloadInput{Identifier: args[0], ReplayID: args[1], Output: out}) } diff --git a/cmd/deploy.go b/cmd/deploy.go index e50e3a5..ba1a614 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -531,19 +531,22 @@ func followDeployment(ctx context.Context, client kernel.Client, deploymentID st if err == nil { fmt.Println(string(bs)) } - // Check for terminal states - if data.Event == "deployment_state" { - deploymentState := data.AsDeploymentState() - status := deploymentState.Deployment.Status - if status == string(kernel.DeploymentGetResponseStatusFailed) || - status == string(kernel.DeploymentGetResponseStatusStopped) || - status == string(kernel.DeploymentGetResponseStatusRunning) { - return nil - } + // Check for terminal states + if data.Event == "deployment_state" { + deploymentState := data.AsDeploymentState() + status := deploymentState.Deployment.Status + if status == string(kernel.DeploymentGetResponseStatusFailed) || + status == string(kernel.DeploymentGetResponseStatusStopped) { + return fmt.Errorf("deployment %s: %s", status, deploymentState.Deployment.StatusReason) } - if data.Event == "error" { + if status == string(kernel.DeploymentGetResponseStatusRunning) { return nil } + } + if data.Event == "error" { + errorEv := data.AsErrorEvent() + return fmt.Errorf("%s: %s", errorEv.Error.Code, errorEv.Error.Message) + } continue } diff --git a/cmd/invoke.go b/cmd/invoke.go index a1b23ac..2ca27c9 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -180,18 +180,21 @@ func runInvoke(cmd *cobra.Command, args []string) error { if err == nil { fmt.Println(string(bs)) } - // Check for terminal states - if ev.Event == "invocation_state" { - stateEv := ev.AsInvocationState() - status := stateEv.Invocation.Status - if status == string(kernel.InvocationGetResponseStatusSucceeded) || - status == string(kernel.InvocationGetResponseStatusFailed) { - return nil - } - } - if ev.Event == "error" { + // Check for terminal states + if ev.Event == "invocation_state" { + stateEv := ev.AsInvocationState() + status := stateEv.Invocation.Status + if status == string(kernel.InvocationGetResponseStatusSucceeded) { return nil } + if status == string(kernel.InvocationGetResponseStatusFailed) { + return fmt.Errorf("invocation failed") + } + } + if ev.Event == "error" { + errEv := ev.AsError() + return fmt.Errorf("%s: %s", errEv.Error.Code, errEv.Error.Message) + } continue } From 362c55a6351b5ad09e753a45a82b10120360a106 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 9 Jan 2026 09:47:42 -0500 Subject: [PATCH 5/5] fix: address Sayan's review feedback 1. Return error (non-zero exit) for invalid --output value instead of just logging and returning nil - more script-friendly 2. Add "output" to allowedFlags in browsers create pool path so --output flag doesn't conflict with pool configuration --- cmd/app.go | 6 ++---- cmd/browser_pools.go | 15 +++++---------- cmd/browsers.go | 31 +++++++++++-------------------- cmd/deploy.go | 9 +++------ cmd/extensions.go | 6 ++---- cmd/invoke.go | 6 ++---- cmd/profiles.go | 9 +++------ cmd/proxies/create.go | 3 +-- cmd/proxies/get.go | 3 +-- cmd/proxies/list.go | 3 +-- 10 files changed, 31 insertions(+), 60 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 152c295..f420b7e 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -62,8 +62,7 @@ func runAppList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } // Determine pagination inputs: prefer page/per-page if provided; else map legacy --limit @@ -220,8 +219,7 @@ func runAppHistory(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if output != "json" { diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 9cc509d..0d18eab 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -35,8 +35,7 @@ type BrowserPoolsListInput struct { func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } pools, err := c.client.List(ctx) @@ -100,8 +99,7 @@ type BrowserPoolsCreateInput struct { func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } params := kernel.BrowserPoolNewParams{ @@ -180,8 +178,7 @@ type BrowserPoolsGetInput struct { func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } pool, err := c.client.Get(ctx, in.IDOrName) @@ -244,8 +241,7 @@ type BrowserPoolsUpdateInput struct { func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } params := kernel.BrowserPoolUpdateParams{} @@ -347,8 +343,7 @@ type BrowserPoolsAcquireInput struct { func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } params := kernel.BrowserPoolAcquireParams{} diff --git a/cmd/browsers.go b/cmd/browsers.go index 5379702..4563269 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -201,8 +201,7 @@ type BrowsersListInput struct { func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } params := kernel.BrowserListParams{} @@ -290,8 +289,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if in.Output != "json" { @@ -475,8 +473,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } browser, err := b.browsers.Get(ctx, in.Identifier) @@ -506,8 +503,7 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } browser, err := b.browsers.Get(ctx, in.Identifier) @@ -909,8 +905,7 @@ type BrowsersReplaysDownloadInput struct { func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } br, err := b.browsers.Get(ctx, in.Identifier) @@ -949,8 +944,7 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } br, err := b.browsers.Get(ctx, in.Identifier) @@ -1121,8 +1115,7 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if b.process == nil { @@ -1194,8 +1187,7 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if b.process == nil { @@ -1499,8 +1491,7 @@ func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownload func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if b.fs == nil { @@ -1532,8 +1523,7 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if b.fs == nil { @@ -2178,6 +2168,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { "pool-id": true, "pool-name": true, "timeout": true, + "output": true, // Global persistent flags that don't configure browsers "no-color": true, "log-level": true, diff --git a/cmd/deploy.go b/cmd/deploy.go index ba1a614..ee734ea 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -97,8 +97,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } // Collect env vars similar to runDeploy @@ -216,8 +215,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if version == "" { @@ -388,8 +386,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } // Prefer page/per-page when provided; map legacy --limit otherwise diff --git a/cmd/extensions.go b/cmd/extensions.go index 1769f7d..fc97e90 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -60,8 +60,7 @@ type ExtensionsCmd struct { func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if in.Output != "json" { @@ -285,8 +284,7 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if in.Dir == "" { diff --git a/cmd/invoke.go b/cmd/invoke.go index 2ca27c9..a90b16c 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -62,8 +62,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } jsonOutput := output == "json" @@ -337,8 +336,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } // Build parameters for the API call diff --git a/cmd/profiles.go b/cmd/profiles.go index 088be0e..71049f9 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -57,8 +57,7 @@ type ProfilesCmd struct { func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if in.Output != "json" { @@ -106,8 +105,7 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } item, err := p.profiles.Get(ctx, in.Identifier) @@ -148,8 +146,7 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } params := kernel.ProfileNewParams{} diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index dace94f..673fbbf 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -14,8 +14,7 @@ import ( func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } // Validate proxy type diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 326c32f..c565642 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -14,8 +14,7 @@ import ( func (p ProxyCmd) Get(ctx context.Context, in ProxyGetInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } item, err := p.proxies.Get(ctx, in.ID) diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 2e7ca02..86f36d4 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -15,8 +15,7 @@ import ( func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { if in.Output != "" && in.Output != "json" { - pterm.Error.Println("unsupported --output value: use 'json'") - return nil + return fmt.Errorf("unsupported --output value: use 'json'") } if in.Output != "json" {