From cae72af70fccaa1daea8eb53d50f0461168c44e0 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 10:16:13 -0400 Subject: [PATCH 1/8] Normalize API key display formatting --- cmd/api_keys.go | 86 ++++++++++++++++++++++++++++++-------------- cmd/api_keys_test.go | 27 ++++++++++++++ 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 77374cf..7379750 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -126,14 +126,15 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { table := pterm.TableData{{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"}} for _, key := range keys { + display := newAPIKeyDisplay(key) table = append(table, []string{ - key.ID, - key.Name, - formatAPIKeyScope(key), - formatAPIKeyProject(key), - key.MaskedKey, - formatAPIKeyExpiresAt(key), - util.FormatLocal(key.CreatedAt), + display.ID, + display.Name, + display.Scope, + display.Project, + display.MaskedKey, + display.ExpiresAt, + display.CreatedAt, }) } PrintTableNoPad(table, true) @@ -201,36 +202,69 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error { return nil } +type apiKeyDisplay struct { + ID string + Name string + PlaintextKey string + Scope string + Project string + MaskedKey string + CreatedBy string + ExpiresAt string + CreatedAt string +} + func renderCreatedAPIKey(key *kernel.CreatedAPIKey) { + display := newCreatedAPIKeyDisplay(key) rows := pterm.TableData{ {"Field", "Value"}, - {"ID", key.ID}, - {"Name", key.Name}, - {"Key", key.Key}, - {"Scope", formatAPIKeyScope(key.APIKey)}, - {"Project", formatAPIKeyProject(key.APIKey)}, - {"Masked Key", key.MaskedKey}, - {"Expires At", formatAPIKeyExpiresAt(key.APIKey)}, + {"ID", display.ID}, + {"Name", display.Name}, + {"Key", display.PlaintextKey}, + {"Scope", display.Scope}, + {"Project", display.Project}, + {"Masked Key", display.MaskedKey}, + {"Expires At", display.ExpiresAt}, } PrintTableNoPad(rows, true) } func renderAPIKeyDetails(key *kernel.APIKey) { + display := newAPIKeyDisplay(*key) rows := pterm.TableData{ {"Field", "Value"}, - {"ID", key.ID}, - {"Name", key.Name}, - {"Scope", formatAPIKeyScope(*key)}, - {"Project", formatAPIKeyProject(*key)}, - {"Masked Key", key.MaskedKey}, - {"Created By", formatAPIKeyCreator(*key)}, - {"Expires At", formatAPIKeyExpiresAt(*key)}, - {"Created At", util.FormatLocal(key.CreatedAt)}, + {"ID", display.ID}, + {"Name", display.Name}, + {"Scope", display.Scope}, + {"Project", display.Project}, + {"Masked Key", display.MaskedKey}, + {"Created By", display.CreatedBy}, + {"Expires At", display.ExpiresAt}, + {"Created At", display.CreatedAt}, } PrintTableNoPad(rows, true) } -func formatAPIKeyProject(key kernel.APIKey) string { +func newCreatedAPIKeyDisplay(key *kernel.CreatedAPIKey) apiKeyDisplay { + display := newAPIKeyDisplay(key.APIKey) + display.PlaintextKey = key.Key + return display +} + +func newAPIKeyDisplay(key kernel.APIKey) apiKeyDisplay { + return apiKeyDisplay{ + ID: key.ID, + Name: key.Name, + Scope: apiKeyScope(key), + Project: apiKeyProject(key), + MaskedKey: key.MaskedKey, + CreatedBy: apiKeyCreator(key), + ExpiresAt: apiKeyExpiresAt(key), + CreatedAt: util.FormatLocal(key.CreatedAt), + } +} + +func apiKeyProject(key kernel.APIKey) string { if key.JSON.ProjectName.Valid() && key.ProjectName != "" { return key.ProjectName } @@ -240,14 +274,14 @@ func formatAPIKeyProject(key kernel.APIKey) string { return "-" } -func formatAPIKeyScope(key kernel.APIKey) string { +func apiKeyScope(key kernel.APIKey) string { if key.JSON.ProjectID.Valid() && key.ProjectID != "" { return "Project" } return "Org" } -func formatAPIKeyCreator(key kernel.APIKey) string { +func apiKeyCreator(key kernel.APIKey) string { if key.CreatedBy.JSON.Name.Valid() && key.CreatedBy.Name != "" { return key.CreatedBy.Name } @@ -257,7 +291,7 @@ func formatAPIKeyCreator(key kernel.APIKey) string { return "-" } -func formatAPIKeyExpiresAt(key kernel.APIKey) string { +func apiKeyExpiresAt(key kernel.APIKey) string { if !key.JSON.ExpiresAt.Valid() { return "Never" } diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 70ce814..2c897fa 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" @@ -196,6 +197,32 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { assert.Contains(t, out, "Never") } +func TestAPIKeyDisplayNormalizesSDKFields(t *testing.T) { + key := apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":"2026-06-27T12:00:00Z","project_id":"proj_123","project_name":"Prod"}`) + + display := newAPIKeyDisplay(*key) + + assert.Equal(t, "key_123", display.ID) + assert.Equal(t, "ci", display.Name) + assert.Equal(t, "Project", display.Scope) + assert.Equal(t, "Prod", display.Project) + assert.Equal(t, "sk_...123", display.MaskedKey) + assert.Equal(t, "Dev", display.CreatedBy) + assert.Equal(t, util.FormatLocal(key.ExpiresAt), display.ExpiresAt) + assert.Equal(t, util.FormatLocal(key.CreatedAt), display.CreatedAt) +} + +func TestAPIKeyDisplayFallsBackForAbsentOptionalFields(t *testing.T) { + key := apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":null},"expires_at":null,"project_id":null,"project_name":null}`) + + display := newAPIKeyDisplay(*key) + + assert.Equal(t, "Org", display.Scope) + assert.Equal(t, "-", display.Project) + assert.Equal(t, "dev@example.com", display.CreatedBy) + assert.Equal(t, "Never", display.ExpiresAt) +} + func TestAPIKeysUpdateRequiresName(t *testing.T) { c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}} err := c.Update(context.Background(), APIKeysUpdateInput{ID: "key_123"}) From 6e9877ea5aa774dfbf81c6c1ebfbe6dc5d94ab06 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 10:26:57 -0400 Subject: [PATCH 2/8] Normalize detail table headers --- cmd/api_keys.go | 4 ++-- cmd/projects.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 7379750..683e899 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -217,7 +217,7 @@ type apiKeyDisplay struct { func renderCreatedAPIKey(key *kernel.CreatedAPIKey) { display := newCreatedAPIKeyDisplay(key) rows := pterm.TableData{ - {"Field", "Value"}, + {"Property", "Value"}, {"ID", display.ID}, {"Name", display.Name}, {"Key", display.PlaintextKey}, @@ -232,7 +232,7 @@ func renderCreatedAPIKey(key *kernel.CreatedAPIKey) { func renderAPIKeyDetails(key *kernel.APIKey) { display := newAPIKeyDisplay(*key) rows := pterm.TableData{ - {"Field", "Value"}, + {"Property", "Value"}, {"ID", display.ID}, {"Name", display.Name}, {"Scope", display.Scope}, diff --git a/cmd/projects.go b/cmd/projects.go index 4054961..84f7226 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -121,7 +121,7 @@ func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { } table := pterm.TableData{ - {"Field", "Value"}, + {"Property", "Value"}, {"ID", project.ID}, {"Name", project.Name}, {"Status", string(project.Status)}, From 68b0409e577a9ec92927a631db056a1d85b96ac4 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 10:59:49 -0400 Subject: [PATCH 3/8] Normalize CLI output helpers --- cmd/api_keys.go | 2 +- cmd/app.go | 14 ++---- cmd/auth_connections.go | 12 +---- cmd/browser_pools.go | 6 +-- cmd/browsers.go | 12 +---- cmd/credential_providers.go | 12 +---- cmd/credentials.go | 6 +-- cmd/deploy.go | 10 ++--- cmd/extensions.go | 8 +--- cmd/invoke.go | 12 +---- cmd/mcp/server.go | 3 +- cmd/profiles.go | 6 +-- cmd/proxies/list.go | 6 +-- cmd/proxies/proxies.go | 3 +- pkg/util/json.go | 22 ++++++++++ pkg/util/json_test.go | 88 +++++++++++++++++++++++++++++++++++++ pkg/util/output.go | 5 +++ 17 files changed, 140 insertions(+), 87 deletions(-) create mode 100644 pkg/util/json_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 683e899..3d3fa6d 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -414,7 +414,7 @@ func init() { apiKeysUpdateCmd.Flags().String("name", "", "New API key name (required)") _ = apiKeysUpdateCmd.MarkFlagRequired("name") - apiKeysDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(apiKeysDeleteCmd) apiKeysCmd.AddCommand(apiKeysCreateCmd) apiKeysCmd.AddCommand(apiKeysListCmd) diff --git a/cmd/app.go b/cmd/app.go index 7271dcc..200dba6 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -49,7 +49,7 @@ func init() { // Flags for delete appDeleteCmd.Flags().String("version", "", "Only delete deployments for this version (default: all versions)") - appDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(appDeleteCmd) // Add optional filters for list appListCmd.Flags().String("name", "", "Filter by application name") @@ -120,11 +120,7 @@ func runAppList(cmd *cobra.Command, args []string) error { } if output == "json" { - if apps == nil || len(apps.Items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(apps.Items) + return util.PrintPrettyJSONPageItems(apps) } if apps == nil || len(apps.Items) == 0 { @@ -323,11 +319,7 @@ func runAppHistory(cmd *cobra.Command, args []string) error { } if output == "json" { - if deployments == nil || len(deployments.Items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(deployments.Items) + return util.PrintPrettyJSONPageItems(deployments) } if deployments == nil || len(deployments.Items) == 0 { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 153a498..a0bdc48 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -455,17 +455,9 @@ func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput) } if in.Output == "json" { - if page == nil { - fmt.Println("[]") - return nil - } - if page.RawJSON() != "" { + if page != nil && page.RawJSON() != "" { return util.PrintPrettyJSON(page) } - if len(auths) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(auths) } @@ -841,7 +833,7 @@ func init() { authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip") // Delete flags - authConnectionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(authConnectionsDeleteCmd) // Login flags addJSONOutputFlag(authConnectionsLoginCmd) diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index eaacc68..44f9def 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -43,11 +43,7 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err } if in.Output == "json" { - if pools == nil || len(*pools) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*pools) + return util.PrintPrettyJSONPointerSlice(pools) } if pools == nil || len(*pools) == 0 { diff --git a/cmd/browsers.go b/cmd/browsers.go index d73e115..184e0b3 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1129,11 +1129,7 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { @@ -1847,11 +1843,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu } if in.Output == "json" { - if res == nil || len(*res) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*res) + return util.PrintPrettyJSONPointerSlice(res) } if res == nil || len(*res) == 0 { diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index a0f5808..b042819 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -81,11 +81,7 @@ func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProviders } if in.Output == "json" { - if providers == nil || len(*providers) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*providers) + return util.PrintPrettyJSONPointerSlice(providers) } if providers == nil || len(*providers) == 0 { @@ -312,10 +308,6 @@ func (c CredentialProvidersCmd) ListItems(ctx context.Context, in CredentialProv } if in.Output == "json" { - if len(result.Items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(result.Items) } @@ -448,7 +440,7 @@ func init() { credentialProvidersUpdateCmd.Flags().Int64("priority", 0, "Priority order for credential lookups (lower numbers are checked first)") // Delete flags - credentialProvidersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(credentialProvidersDeleteCmd) // Test flags addJSONOutputFlag(credentialProvidersTestCmd) diff --git a/cmd/credentials.go b/cmd/credentials.go index b17a37e..f0c5e76 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -95,10 +95,6 @@ func (c CredentialsCmd) List(ctx context.Context, in CredentialsListInput) error } if in.Output == "json" { - if len(credentials) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(credentials) } @@ -424,7 +420,7 @@ func init() { credentialsUpdateCmd.Flags().StringArray("value", []string{}, "Field name=value pair to update (repeatable)") // Delete flags - credentialsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(credentialsDeleteCmd) // TOTP code flags addJSONOutputFlag(credentialsTotpCodeCmd) diff --git a/cmd/deploy.go b/cmd/deploy.go index f399d6c..48d5e73 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -85,7 +85,7 @@ func init() { deployLogsCmd.Flags().BoolP("with-timestamps", "t", false, "Include timestamps in each log line") deployCmd.AddCommand(deployLogsCmd) - deployDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(deployDeleteCmd) deployCmd.AddCommand(deployDeleteCmd) deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") @@ -539,11 +539,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { } if output == "json" { - if deployments == nil || len(deployments.Items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(deployments.Items) + return util.PrintPrettyJSONPageItems(deployments) } if deployments == nil || len(deployments.Items) == 0 { @@ -572,7 +568,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { dep.StatusReason, }) } - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { diff --git a/cmd/extensions.go b/cmd/extensions.go index 7138e0c..bb48184 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -95,11 +95,7 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { @@ -519,7 +515,7 @@ func init() { extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd) addJSONOutputFlag(extensionsListCmd) - extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(extensionsDeleteCmd) 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)") diff --git a/cmd/invoke.go b/cmd/invoke.go index cca3804..851cb9a 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -493,10 +493,6 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { } if output == "json" { - if len(invocations.Items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(invocations.Items) } @@ -542,7 +538,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { if len(table) == 1 { pterm.Info.Println("No invocations found.") } else { - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) } return nil } @@ -567,10 +563,6 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { } if output == "json" { - if len(resp.Browsers) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(resp.Browsers) } @@ -600,7 +592,7 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { } pterm.Info.Printf("Browsers for invocation %s:\n", invocationID) - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) return nil } diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index 7dd5f64..2a7caac 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -1,6 +1,7 @@ package mcp import ( + "github.com/kernel/cli/pkg/table" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -38,7 +39,7 @@ func runServer(cmd *cobra.Command, args []string) { {"HTTP (recommended)", KernelMCPURL}, {"stdio (via mcp-remote)", "npx -y mcp-remote " + KernelMCPURL}, } - _ = pterm.DefaultTable.WithHasHeader().WithData(rows).Render() + table.PrintTableNoPad(rows, true) pterm.Println() pterm.DefaultSection.Println("Quick Install") diff --git a/cmd/profiles.go b/cmd/profiles.go index bfc055c..a507224 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -104,10 +104,6 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { itemsThisPage := len(items) if in.Output == "json" { - if len(items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(items) } @@ -395,7 +391,7 @@ func init() { addJSONOutputFlag(profilesGetCmd) addJSONOutputFlag(profilesCreateCmd) profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") - profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(profilesDeleteCmd) profilesDownloadCmd.Flags().String("to", "", "Directory to extract the profile into (required)") _ = profilesDownloadCmd.MarkFlagRequired("to") } diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 762503e..ce21d98 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -27,11 +27,7 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index ee93faf..8a4b7a5 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -1,6 +1,7 @@ package proxies import ( + "github.com/kernel/cli/pkg/util" "github.com/spf13/cobra" ) @@ -111,7 +112,7 @@ func init() { proxiesCreateCmd.Flags().StringSlice("bypass-host", nil, "Hostname(s) to bypass proxy and connect directly (repeat or comma-separated)") // Delete flags - proxiesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(proxiesDeleteCmd) // Check flags addJSONOutputFlag(proxiesCheckCmd) diff --git a/pkg/util/json.go b/pkg/util/json.go index aa20d3b..507c90e 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + + "github.com/kernel/kernel-go-sdk/packages/pagination" ) // RawJSONProvider is an interface for SDK types that provide raw JSON responses. @@ -65,3 +67,23 @@ func PrintPrettyJSONSlice[T RawJSONProvider](items []T) error { fmt.Println(buf.String()) return nil } + +// PrintPrettyJSONPointerSlice prints a pointer-to-slice SDK response as a JSON +// array, treating nil as an empty list. +func PrintPrettyJSONPointerSlice[T RawJSONProvider](items *[]T) error { + if items == nil { + fmt.Println("[]") + return nil + } + return PrintPrettyJSONSlice(*items) +} + +// PrintPrettyJSONPageItems prints the item slice from a paginated SDK response, +// treating a nil page as an empty list. +func PrintPrettyJSONPageItems[T RawJSONProvider](page *pagination.OffsetPagination[T]) error { + if page == nil { + fmt.Println("[]") + return nil + } + return PrintPrettyJSONSlice(page.Items) +} diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go new file mode 100644 index 0000000..c1d1ad0 --- /dev/null +++ b/pkg/util/json_test.go @@ -0,0 +1,88 @@ +package util + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type rawJSONStub string + +func (r rawJSONStub) RawJSON() string { + return string(r) +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + + original := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = original + }) + + var buf bytes.Buffer + done := make(chan error, 1) + go func() { + _, copyErr := io.Copy(&buf, reader) + done <- copyErr + }() + + fnErr := fn() + require.NoError(t, writer.Close()) + require.NoError(t, <-done) + require.NoError(t, reader.Close()) + os.Stdout = original + + return buf.String(), fnErr +} + +func TestPrintPrettyJSONPointerSliceTreatsNilAsEmptyList(t *testing.T) { + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPointerSlice[rawJSONStub](nil) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) +} + +func TestPrintPrettyJSONPointerSlicePrintsItems(t *testing.T) { + items := []rawJSONStub{`{"id":"one"}`} + + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPointerSlice(&items) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"one\"\n }\n]\n", out) +} + +func TestPrintPrettyJSONPageItemsTreatsNilAsEmptyList(t *testing.T) { + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPageItems[rawJSONStub](nil) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) +} + +func TestPrintPrettyJSONPageItemsPrintsItems(t *testing.T) { + page := &pagination.OffsetPagination[rawJSONStub]{ + Items: []rawJSONStub{`{"id":"one"}`}, + } + + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPageItems(page) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"one\"\n }\n]\n", out) +} diff --git a/pkg/util/output.go b/pkg/util/output.go index 68ecd58..0860753 100644 --- a/pkg/util/output.go +++ b/pkg/util/output.go @@ -7,6 +7,7 @@ import ( ) const JSONOutputFlagDescription = "Output format: json for raw API response" +const SkipConfirmFlagDescription = "Skip confirmation prompt" func ValidateJSONOutput(output string) error { if output == "" || output == "json" { @@ -18,3 +19,7 @@ func ValidateJSONOutput(output string) error { func AddJSONOutputFlag(cmd *cobra.Command) { cmd.Flags().StringP("output", "o", "", JSONOutputFlagDescription) } + +func AddSkipConfirmFlag(cmd *cobra.Command) { + cmd.Flags().BoolP("yes", "y", false, SkipConfirmFlagDescription) +} From 72cb28e8fad24000e2623b4aaaf0806f3ad68e08 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 11:23:56 -0400 Subject: [PATCH 4/8] Improve CLI error guidance --- cmd/api_keys.go | 12 +- cmd/api_keys_test.go | 3 +- cmd/app.go | 6 +- cmd/auth_connections.go | 14 +- cmd/browser_pools.go | 22 ++- cmd/browsers.go | 255 +++++++++++++---------------------- cmd/browsers_test.go | 9 +- cmd/credential_providers.go | 8 +- cmd/credentials.go | 10 +- cmd/deploy.go | 17 ++- cmd/extensions.go | 78 ++++------- cmd/extensions_test.go | 14 +- cmd/invoke.go | 21 ++- cmd/logs.go | 4 +- cmd/profiles.go | 7 +- cmd/profiles_test.go | 3 +- cmd/proxies/create.go | 14 +- cmd/proxies/create_test.go | 18 ++- cmd/root.go | 3 + cmd/status.go | 9 +- pkg/extensions/webbotauth.go | 8 +- pkg/util/errors.go | 48 +++++++ pkg/util/errors_test.go | 16 +++ 23 files changed, 284 insertions(+), 315 deletions(-) create mode 100644 pkg/util/errors_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 3d3fa6d..9747760 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -58,13 +58,13 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error { return err } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } params := kernel.APIKeyNewParams{Name: in.Name} if in.DaysToExpire.Set { if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 { - return fmt.Errorf("--days-to-expire must be between 1 and 3650") + return fmt.Errorf("invalid --days-to-expire %d; use a value from 1 to 3650", in.DaysToExpire.Value) } params.DaysToExpire = kernel.Int(in.DaysToExpire.Value) } @@ -91,10 +91,10 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { return err } if in.Limit < 0 { - return fmt.Errorf("--limit must be non-negative") + return fmt.Errorf("invalid --limit %d; use 0 or a positive number", in.Limit) } if in.Offset < 0 { - return fmt.Errorf("--offset must be non-negative") + return fmt.Errorf("invalid --offset %d; use 0 or a positive number", in.Offset) } params := kernel.APIKeyListParams{} @@ -164,7 +164,7 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error { return err } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } key, err := c.apiKeys.Update(ctx, in.ID, kernel.APIKeyUpdateParams{Name: in.Name}) @@ -193,7 +193,7 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error { if err := c.apiKeys.Delete(ctx, in.ID); err != nil { if util.IsNotFound(err) { - return fmt.Errorf("API key %q not found", in.ID) + return util.NotFound("API key", in.ID, "kernel api-keys list") } return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 2c897fa..71df26d 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -117,7 +117,8 @@ func TestAPIKeysCreateRejectsInvalidDaysToExpire(t *testing.T) { }) require.Error(t, err) - assert.Contains(t, err.Error(), "--days-to-expire must be between 1 and 3650") + assert.Contains(t, err.Error(), "invalid --days-to-expire 0") + assert.Contains(t, err.Error(), "use a value from 1 to 3650") } func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) { diff --git a/cmd/app.go b/cmd/app.go index 200dba6..dd8acb1 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -115,8 +115,7 @@ func runAppList(cmd *cobra.Command, args []string) error { apps, err := client.Apps.List(cmd.Context(), params) if err != nil { - pterm.Error.Printf("Failed to list applications: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: fmt.Errorf("list applications failed; check your auth and retry: %w", err)} } if output == "json" { @@ -314,8 +313,7 @@ func runAppHistory(cmd *cobra.Command, args []string) error { deployments, err := client.Deployments.List(cmd.Context(), params) if err != nil { - pterm.Error.Printf("Failed to list deployments: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: fmt.Errorf("list deployments failed; check the app name or run `kernel app list`: %w", err)} } if output == "json" { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index a0bdc48..fbe2cb0 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -118,10 +118,10 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn } if in.Domain == "" { - return fmt.Errorf("--domain is required") + return util.RequiredFlag("--domain", "") } if in.ProfileName == "" { - return fmt.Errorf("--profile-name is required") + return util.RequiredFlag("--profile-name", "") } params := kernel.AuthConnectionNewParams{ @@ -251,7 +251,7 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn credentialChanged := in.CredentialNameSet || in.CredentialProviderSet || in.CredentialPathSet || in.CredentialAuto.Set if credentialChanged { if strings.TrimSpace(in.CredentialName) != "" && strings.TrimSpace(in.CredentialProvider) != "" { - return fmt.Errorf("credential reference must use either --credential-name or --credential-provider") + return util.ChooseOnlyOne("--credential-name", "--credential-provider") } params.ManagedAuthUpdateRequest.Credential = kernel.ManagedAuthUpdateRequestCredentialParam{} if in.CredentialNameSet { @@ -282,7 +282,7 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn } if !hasChanges { - return fmt.Errorf("must provide at least one field to update") + return util.SetAtLeastOne("--domain", "--profile-name", "--credential-name", "--credential-provider", "--credential-path", "--credential-auto", "--proxy-id", "--proxy-name") } if in.Output != "json" { @@ -568,10 +568,10 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn } if submitModes == 0 { - return fmt.Errorf("must provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider") + return util.ChooseOne("--field", "--mfa-option-id", "--sign-in-option-id", "--sso-button-selector", "--sso-provider") } if submitModes > 1 { - return fmt.Errorf("provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider") + return util.ChooseOnlyOne("--field", "--mfa-option-id", "--sign-in-option-id", "--sso-button-selector", "--sso-provider") } // Resolve MFA option: the user may pass the label (e.g. "Get a text"), the @@ -1021,7 +1021,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { for _, pair := range fieldPairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid field format: %s (expected key=value)", pair) + return fmt.Errorf("invalid --field %q; use key=value", pair) } fieldValues[parts[0]] = parts[1] } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 44f9def..a5c8105 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -121,8 +121,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if profile != nil { params.Profile = *profile @@ -139,8 +138,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) viewport, err := buildViewportParam(in.Viewport) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if viewport != nil { params.Viewport = *viewport @@ -237,7 +235,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) return err } if in.StartURL != "" && in.ClearStartURL { - return fmt.Errorf("cannot specify both --start-url and --clear-start-url") + return util.ChooseOnlyOne("--start-url", "--clear-start-url") } params := kernel.BrowserPoolUpdateParams{} @@ -269,8 +267,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if profile != nil { params.Profile = *profile @@ -289,8 +286,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) viewport, err := buildViewportParam(in.Viewport) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if viewport != nil { params.Viewport = *viewport @@ -548,7 +544,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") if len(args) > 0 && args[0] != "" { if cmd.Flags().Changed("name") { - return fmt.Errorf("cannot specify pool name as both a positional argument and --name flag") + return util.ChooseOnlyOne("", "--name") } name = args[0] } @@ -677,7 +673,7 @@ func runBrowserPoolsFlush(cmd *cobra.Command, args []string) error { func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*kernel.BrowserProfileParam, error) { if profileID != "" && profileName != "" { - return nil, fmt.Errorf("must specify at most one of --profile-id or --profile-name") + return nil, util.ChooseOnlyOne("--profile-id", "--profile-name") } if profileID == "" && profileName == "" { return nil, nil @@ -696,7 +692,7 @@ func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*ke func validateStartURLFlag(startURL string) error { if strings.HasPrefix(startURL, "-") { - return fmt.Errorf("--start-url requires a URL value") + return fmt.Errorf("--start-url requires a URL; use --start-url https://example.com") } return nil } @@ -730,7 +726,7 @@ func buildViewportParam(viewport string) (*kernel.BrowserViewportParam, error) { width, height, refreshRate, err := parseViewport(viewport) if err != nil { - return nil, fmt.Errorf("invalid viewport format: %v", err) + return nil, fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", viewport, err) } vp := kernel.BrowserViewportParam{ diff --git a/cmd/browsers.go b/cmd/browsers.go index 184e0b3..0989b45 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -217,6 +217,10 @@ type BrowsersCmd struct { playwright BrowserPlaywrightService } +func browserServiceUnavailable(service string) error { + return fmt.Errorf("%s service is unavailable; upgrade the CLI and retry", service) +} + type BrowsersListInput struct { Output string IncludeDeleted bool @@ -242,7 +246,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { case "all": params.Status = kernel.BrowserListParamsStatusAll default: - return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + return util.InvalidChoice("--status", in.Status, "active", "deleted", "all") } } else if in.IncludeDeleted { params.IncludeDeleted = kernel.Opt(true) @@ -356,8 +360,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { // Validate profile selection: at most one of profile-id or profile-name must be provided if in.ProfileID != "" && in.ProfileName != "" { - pterm.Error.Println("must specify at most one of --profile-id or --profile-name") - return nil + return util.ChooseOnlyOne("--profile-id", "--profile-name") } else if in.ProfileID != "" || in.ProfileName != "" { params.Profile = kernel.BrowserProfileParam{ SaveChanges: kernel.Opt(in.ProfileSaveChanges.Value), @@ -398,8 +401,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.Viewport != "" { width, height, refreshRate, err := parseViewport(in.Viewport) if err != nil { - pterm.Error.Printf("Invalid viewport format: %v\n", err) - return nil + return fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", in.Viewport, err) } params.Viewport = kernel.BrowserViewportParam{ Width: width, @@ -553,12 +555,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate profile selection: at most one of profile-id or profile-name must be provided if in.ProfileID != "" && in.ProfileName != "" { - return fmt.Errorf("must specify at most one of --profile-id or --profile-name") + return util.ChooseOnlyOne("--profile-id", "--profile-name") } // Cannot specify both --proxy-id and --clear-proxy if in.ProxyID != "" && in.ClearProxy { - return fmt.Errorf("cannot specify both --proxy-id and --clear-proxy") + return util.ChooseOnlyOne("--proxy-id", "--clear-proxy") } hasProxyChange := in.ProxyID != "" || in.ClearProxy @@ -567,17 +569,17 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate --save-changes is only used with a profile if in.ProfileSaveChanges.Set && !hasProfileChange { - return fmt.Errorf("--save-changes requires --profile-id or --profile-name") + return fmt.Errorf("--save-changes requires a profile; add --profile-id or --profile-name ") } // Validate --force is only used with a viewport change if in.Force && !hasViewportChange { - return fmt.Errorf("--force requires --viewport") + return fmt.Errorf("--force requires --viewport; add --viewport WIDTHxHEIGHT") } // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, or --viewport") + return util.SetAtLeastOne("--proxy-id", "--clear-proxy", "--profile-id", "--profile-name", "--viewport") } params := kernel.BrowserUpdateParams{} @@ -606,7 +608,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasViewportChange { width, height, refreshRate, err := parseViewport(in.Viewport) if err != nil { - return fmt.Errorf("invalid viewport format: %v", err) + return fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", in.Viewport, err) } params.Viewport = kernel.BrowserUpdateParamsViewport{ BrowserViewportParam: shared.BrowserViewportParam{ @@ -650,8 +652,7 @@ type BrowsersLogsStreamInput struct { func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) error { if b.logs == nil { - pterm.Error.Println("logs service not available") - return nil + return browserServiceUnavailable("logs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -669,8 +670,7 @@ func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) } stream := b.logs.StreamStreaming(ctx, br.SessionID, params) if stream == nil { - pterm.Error.Println("failed to open log stream") - return nil + return fmt.Errorf("open log stream failed; check browser %q is running and retry", br.SessionID) } defer stream.Close() for stream.Next() { @@ -776,8 +776,7 @@ type BrowsersComputerWriteClipboardInput struct { func (b BrowsersCmd) ComputerClickMouse(ctx context.Context, in BrowsersComputerClickMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -805,8 +804,7 @@ func (b BrowsersCmd) ComputerClickMouse(ctx context.Context, in BrowsersComputer func (b BrowsersCmd) ComputerMoveMouse(ctx context.Context, in BrowsersComputerMoveMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -835,8 +833,7 @@ func (b BrowsersCmd) ComputerMoveMouse(ctx context.Context, in BrowsersComputerM func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputerScreenshotInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -852,18 +849,15 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer } defer res.Body.Close() if in.To == "" { - pterm.Error.Println("--to is required to save the screenshot") - return nil + return util.RequiredFlag("--to", "") } f, err := os.Create(in.To) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create screenshot file %q failed; choose a writable --to path: %w", in.To, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write screenshot file %q failed; check disk space and permissions: %w", in.To, err) } pterm.Success.Printf("Saved screenshot to %s\n", in.To) return nil @@ -871,8 +865,7 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer func (b BrowsersCmd) ComputerTypeText(ctx context.Context, in BrowsersComputerTypeTextInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -891,16 +884,14 @@ func (b BrowsersCmd) ComputerTypeText(ctx context.Context, in BrowsersComputerTy func (b BrowsersCmd) ComputerPressKey(ctx context.Context, in BrowsersComputerPressKeyInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } if len(in.Keys) == 0 { - pterm.Error.Println("no keys specified") - return nil + return util.RequiredArg("keys", "kernel browsers computer press-key [key...]") } body := kernel.BrowserComputerPressKeyParams{Keys: in.Keys} if in.Duration > 0 { @@ -918,8 +909,7 @@ func (b BrowsersCmd) ComputerPressKey(ctx context.Context, in BrowsersComputerPr func (b BrowsersCmd) ComputerScroll(ctx context.Context, in BrowsersComputerScrollInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -944,16 +934,14 @@ func (b BrowsersCmd) ComputerScroll(ctx context.Context, in BrowsersComputerScro func (b BrowsersCmd) ComputerDragMouse(ctx context.Context, in BrowsersComputerDragMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } if len(in.Path) < 2 { - pterm.Error.Println("path must include at least two points") - return nil + return fmt.Errorf("drag path needs at least two points; pass --point x,y at least twice") } body := kernel.BrowserComputerDragMouseParams{Path: in.Path} if in.Delay > 0 { @@ -990,8 +978,7 @@ func (b BrowsersCmd) ComputerDragMouse(ctx context.Context, in BrowsersComputerD func (b BrowsersCmd) ComputerSetCursor(ctx context.Context, in BrowsersComputerSetCursorInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1012,8 +999,7 @@ func (b BrowsersCmd) ComputerSetCursor(ctx context.Context, in BrowsersComputerS func (b BrowsersCmd) ComputerGetMousePosition(ctx context.Context, in BrowsersComputerGetMousePositionInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1034,8 +1020,7 @@ func (b BrowsersCmd) ComputerGetMousePosition(ctx context.Context, in BrowsersCo func (b BrowsersCmd) ComputerBatch(ctx context.Context, in BrowsersComputerBatchInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1043,8 +1028,7 @@ func (b BrowsersCmd) ComputerBatch(ctx context.Context, in BrowsersComputerBatch } var body kernel.BrowserComputerBatchParams if err := json.Unmarshal([]byte(in.ActionsJSON), &body); err != nil { - pterm.Error.Printf("Invalid JSON: %v\n", err) - return nil + return fmt.Errorf("invalid actions JSON; pass a valid JSON object or array: %w", err) } if err := b.computer.Batch(ctx, br.SessionID, body); err != nil { return util.CleanedUpSdkError{Err: err} @@ -1058,8 +1042,7 @@ func (b BrowsersCmd) ComputerReadClipboard(ctx context.Context, in BrowsersCompu return err } if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1080,8 +1063,7 @@ func (b BrowsersCmd) ComputerReadClipboard(ctx context.Context, in BrowsersCompu func (b BrowsersCmd) ComputerWriteClipboard(ctx context.Context, in BrowsersComputerWriteClipboardInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1200,13 +1182,11 @@ func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDown } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create replay file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write replay file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved replay to %s\n", in.Output) return nil @@ -1296,8 +1276,7 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } if b.playwright == nil { - pterm.Error.Println("playwright service not available") - return nil + return browserServiceUnavailable("playwright") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1335,7 +1314,7 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } } if !res.Success && res.Error != "" { - pterm.Error.Printf("error: %s\n", res.Error) + return fmt.Errorf("Playwright execution failed: %s", res.Error) } return nil } @@ -1346,8 +1325,7 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu } if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1413,8 +1391,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn } if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1452,8 +1429,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1470,8 +1446,7 @@ func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInpu func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatusInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1488,8 +1463,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1505,8 +1479,7 @@ func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinIn func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcessStdoutStreamInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1514,8 +1487,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess } stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) if stream == nil { - pterm.Error.Println("failed to open stdout stream") - return nil + return fmt.Errorf("open stdout stream failed; check process %q is running and retry", in.ProcessID) } defer stream.Close() for stream.Next() { @@ -1539,8 +1511,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess func (b BrowsersCmd) ProcessResize(ctx context.Context, in BrowsersProcessResizeInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1562,8 +1533,7 @@ func (b BrowsersCmd) FSWatchStart(ctx context.Context, in BrowsersFSWatchStartIn } if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1588,8 +1558,7 @@ func (b BrowsersCmd) FSWatchStart(ctx context.Context, in BrowsersFSWatchStartIn func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInput) error { if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1605,8 +1574,7 @@ func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInpu func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEventsInput) error { if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1614,8 +1582,7 @@ func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEvents } stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{ID: br.SessionID}) if stream == nil { - pterm.Error.Println("failed to open watch events stream") - return nil + return fmt.Errorf("open watch events stream failed; check watch %q is active and retry", in.WatchID) } defer stream.Close() for stream.Next() { @@ -1714,8 +1681,7 @@ type BrowsersExtensionsUploadInput struct { func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1734,8 +1700,7 @@ func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInpu func (b BrowsersCmd) FSDeleteDirectory(ctx context.Context, in BrowsersFSDeleteDirInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1750,8 +1715,7 @@ func (b BrowsersCmd) FSDeleteDirectory(ctx context.Context, in BrowsersFSDeleteD func (b BrowsersCmd) FSDeleteFile(ctx context.Context, in BrowsersFSDeleteFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1766,8 +1730,7 @@ func (b BrowsersCmd) FSDeleteFile(ctx context.Context, in BrowsersFSDeleteFileIn func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownloadDirZipInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1785,13 +1748,11 @@ func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownload } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create zip file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write zip file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved zip to %s\n", in.Output) return nil @@ -1803,8 +1764,7 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) } if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1830,8 +1790,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu } if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1860,8 +1819,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu func (b BrowsersCmd) FSMove(ctx context.Context, in BrowsersFSMoveInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1876,8 +1834,7 @@ func (b BrowsersCmd) FSMove(ctx context.Context, in BrowsersFSMoveInput) error { func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1894,13 +1851,11 @@ func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create output file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write output file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved file to %s\n", in.Output) return nil @@ -1908,8 +1863,7 @@ func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) func (b BrowsersCmd) FSSetPermissions(ctx context.Context, in BrowsersFSSetPermsInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1931,8 +1885,7 @@ func (b BrowsersCmd) FSSetPermissions(ctx context.Context, in BrowsersFSSetPerms func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1943,11 +1896,10 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err for _, m := range in.Mappings { f, err := os.Open(m.Local) if err != nil { - pterm.Error.Printf("Failed to open %s: %v\n", m.Local, err) for _, c := range toClose { _ = c.Close() } - return nil + return fmt.Errorf("open upload source %q failed; check the local path: %w", m.Local, err) } toClose = append(toClose, f) files = append(files, kernel.BrowserFUploadParamsFile{DestPath: m.Dest, File: f}) @@ -1956,11 +1908,10 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err for _, lp := range in.Paths { f, err := os.Open(lp) if err != nil { - pterm.Error.Printf("Failed to open %s: %v\n", lp, err) for _, c := range toClose { _ = c.Close() } - return nil + return fmt.Errorf("open upload source %q failed; check the local path: %w", lp, err) } toClose = append(toClose, f) dest := filepath.Join(in.DestDir, filepath.Base(lp)) @@ -1968,8 +1919,7 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err } } if len(files) == 0 { - pterm.Error.Println("no files specified for upload") - return nil + return fmt.Errorf("no files to upload; pass --file local:remote or --path with --dest-dir") } defer func() { for _, c := range toClose { @@ -1989,8 +1939,7 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1998,8 +1947,7 @@ func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInpu } f, err := os.Open(in.ZipPath) if err != nil { - pterm.Error.Printf("Failed to open zip: %v\n", err) - return nil + return fmt.Errorf("open zip %q failed; check --zip path: %w", in.ZipPath, err) } defer f.Close() if err := b.fs.UploadZip(ctx, br.SessionID, kernel.BrowserFUploadZipParams{DestPath: in.DestDir, ZipFile: f}); err != nil { @@ -2011,8 +1959,7 @@ func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInpu func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -2022,14 +1969,12 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu if in.SourcePath != "" { f, err := os.Open(in.SourcePath) if err != nil { - pterm.Error.Printf("Failed to open input: %v\n", err) - return nil + return fmt.Errorf("open source file %q failed; check --source: %w", in.SourcePath, err) } defer f.Close() reader = f } else { - pterm.Error.Println("--source is required") - return nil + return util.RequiredFlag("--source", "") } params := kernel.BrowserFWriteFileParams{Path: in.DestPath} if in.Mode != "" { @@ -2044,8 +1989,7 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensionsUploadInput) error { if b.browsers == nil { - pterm.Error.Println("browsers service not available") - return nil + return browserServiceUnavailable("browsers") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -2053,8 +1997,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions } if len(in.ExtensionPaths) == 0 { - pterm.Error.Println("no extension paths provided") - return nil + return util.RequiredArg("extension path", "kernel browsers extensions upload [extension-dir...]") } var extensions []kernel.BrowserLoadExtensionsParamsExtension @@ -2073,12 +2016,10 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions for _, extPath := range in.ExtensionPaths { info, err := os.Stat(extPath) if err != nil { - pterm.Error.Printf("Failed to stat %s: %v\n", extPath, err) - return nil + return fmt.Errorf("read extension path %q failed; check the directory exists: %w", extPath, err) } if !info.IsDir() { - pterm.Error.Printf("Path %s is not a directory\n", extPath) - return nil + return fmt.Errorf("extension path %q is not a directory; pass an unpacked extension directory", extPath) } extName := generateRandomExtensionName() @@ -2086,15 +2027,13 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName) if err := util.ZipDirectory(extPath, tempZipPath, nil); err != nil { - pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err) - return nil + return fmt.Errorf("zip extension %q failed; check the directory contents: %w", extPath, err) } tempZipFiles = append(tempZipFiles, tempZipPath) zipFile, err := os.Open(tempZipPath) if err != nil { - pterm.Error.Printf("Failed to open zip %s: %v\n", tempZipPath, err) - return nil + return fmt.Errorf("open generated extension zip %q failed: %w", tempZipPath, err) } openFiles = append(openFiles, zipFile) @@ -2186,10 +2125,10 @@ Supported operations: Note: Profiles can only be loaded into sessions that don't already have a profile.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return fmt.Errorf("missing required argument: browser ID\n\nUsage: kernel browsers update [flags]") + return util.RequiredArg("browser ID", "kernel browsers update [flags]") } if len(args) > 1 { - return fmt.Errorf("expected 1 argument (browser ID), got %d", len(args)) + return fmt.Errorf("accepts 1 browser ID, got %d", len(args)) } return nil }, @@ -2558,8 +2497,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { - pterm.Error.Println("must specify at most one of --pool-id or --pool-name") - return nil + return util.ChooseOnlyOne("--pool-id", "--pool-name") } if poolID != "" || poolName != "" { @@ -2623,8 +2561,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { fmt.Println("null") return nil } - pterm.Error.Println("Acquire request timed out (no browser available). Retry to continue waiting.") - return nil + return fmt.Errorf("acquire timed out because no pooled browser is available; retry or increase --timeout") } if output == "json" { return util.PrintPrettyJSON(resp) @@ -2644,8 +2581,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { WithDefaultText("Select a viewport size:"). Show() if err != nil { - pterm.Error.Printf("Failed to select viewport: %v\n", err) - return nil + return fmt.Errorf("select viewport failed; pass --viewport WIDTHxHEIGHT instead: %w", err) } viewport = selectedViewport } @@ -2906,13 +2842,11 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { // Read code from stdin stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) != 0 { - pterm.Error.Println("no code provided. Provide code as an argument or pipe via stdin") - return nil + return util.RequiredArg("Playwright code", "kernel browsers playwright ''") } data, err := io.ReadAll(os.Stdin) if err != nil { - pterm.Error.Printf("failed to read stdin: %v\n", err) - return nil + return fmt.Errorf("read Playwright code from stdin failed: %w", err) } code = string(data) } @@ -3017,8 +2951,7 @@ func runBrowsersFSUpload(cmd *cobra.Command, args []string) error { // format: local:remote parts := strings.SplitN(m, ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - pterm.Error.Printf("invalid --file mapping: %s\n", m) - return nil + return fmt.Errorf("invalid --file %q; use local_path:remote_path", m) } mappings = append(mappings, struct { Local string @@ -3102,12 +3035,10 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error { useRegion := bx || by || bw || bh if useRegion { if !(bx && by && bw && bh) { - pterm.Error.Println("if specifying region, you must provide --x, --y, --width, and --height") - return nil + return fmt.Errorf("screenshot region requires --x, --y, --width, and --height together") } if w <= 0 || h <= 0 { - pterm.Error.Println("--width and --height must be greater than zero") - return nil + return fmt.Errorf("invalid screenshot region %dx%d; --width and --height must be greater than zero", w, h) } } b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} @@ -3162,18 +3093,15 @@ func runBrowsersComputerDragMouse(cmd *cobra.Command, args []string) error { for _, p := range points { parts := strings.SplitN(p, ",", 2) if len(parts) != 2 { - pterm.Error.Printf("invalid --point value: %s (expected x,y)\n", p) - return nil + return fmt.Errorf("invalid --point %q; use x,y", p) } x, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) if err != nil { - pterm.Error.Printf("invalid x in --point %s: %v\n", p, err) - return nil + return fmt.Errorf("invalid x in --point %q; use integer coordinates: %w", p, err) } y, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) if err != nil { - pterm.Error.Printf("invalid y in --point %s: %v\n", p, err) - return nil + return fmt.Errorf("invalid y in --point %q; use integer coordinates: %w", p, err) } path = append(path, []int64{x, y}) } @@ -3203,8 +3131,7 @@ func runBrowsersComputerSetCursor(cmd *cobra.Command, args []string) error { case "false", "0", "no": hidden = false default: - pterm.Error.Printf("Invalid value for --hidden: %s (expected true or false)\n", hiddenStr) - return nil + return util.InvalidChoice("--hidden", hiddenStr, "true", "false") } b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 36190f4..1a3e387 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1639,7 +1639,8 @@ func TestBrowsersCreate_RejectsStartURLFlagToken(t *testing.T) { }) require.Error(t, err) - assert.Contains(t, err.Error(), "--start-url requires a URL value") + assert.Contains(t, err.Error(), "--start-url requires a URL") + assert.Contains(t, err.Error(), "use --start-url https://example.com") assert.False(t, called) } @@ -1652,9 +1653,9 @@ func TestBrowsersCreate_WithInvalidViewport(t *testing.T) { Viewport: "invalid", }) - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "Invalid viewport format") + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid --viewport "invalid"`) + assert.Contains(t, err.Error(), "use WIDTHxHEIGHT") } func TestBrowsersUpdate_WithViewportAndForce(t *testing.T) { diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index b042819..de7a4dd 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -138,19 +138,19 @@ func (c CredentialProvidersCmd) Create(ctx context.Context, in CredentialProvide } if in.ProviderType == "" { - return fmt.Errorf("--provider-type is required") + return util.RequiredFlag("--provider-type", "onepassword") } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } if in.Token == "" { - return fmt.Errorf("--token is required") + return util.RequiredFlag("--token", "") } // Validate provider type providerType := strings.ToLower(in.ProviderType) if providerType != "onepassword" { - return fmt.Errorf("invalid provider type: %s (must be 'onepassword')", in.ProviderType) + return util.InvalidChoice("--provider-type", in.ProviderType, "onepassword") } params := kernel.CredentialProviderNewParams{ diff --git a/cmd/credentials.go b/cmd/credentials.go index f0c5e76..0edaff7 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -171,13 +171,13 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } if in.Domain == "" { - return fmt.Errorf("--domain is required") + return util.RequiredFlag("--domain", "") } if len(in.Values) == 0 { - return fmt.Errorf("at least one --value is required") + return util.RequiredFlag("--value", "key=value") } params := kernel.CredentialNewParams{ @@ -469,7 +469,7 @@ func runCredentialsCreate(cmd *cobra.Command, args []string) error { for _, pair := range valuePairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid value format: %s (expected key=value)", pair) + return fmt.Errorf("invalid --value %q; use key=value", pair) } values[parts[0]] = parts[1] } @@ -499,7 +499,7 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { for _, pair := range valuePairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid value format: %s (expected key=value)", pair) + return fmt.Errorf("invalid --value %q; use key=value", pair) } values[parts[0]] = parts[1] } diff --git a/cmd/deploy.go b/cmd/deploy.go index 48d5e73..82c651c 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -145,7 +145,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { for _, kv := range envPairs { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid env variable format: %s (expected KEY=value)", kv) + return fmt.Errorf("invalid --env %q; use KEY=value", kv) } envVars[parts[0]] = parts[1] } @@ -160,14 +160,14 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { // Manually POST multipart with a JSON 'source' field to match backend expectations apiKey := os.Getenv("KERNEL_API_KEY") if strings.TrimSpace(apiKey) == "" { - return fmt.Errorf("KERNEL_API_KEY is required for github deploy") + return fmt.Errorf("KERNEL_API_KEY is required for GitHub deploy; export KERNEL_API_KEY= and retry") } baseURL := util.GetBaseURL() if region == "" { region = string(kernel.DeploymentNewParamsRegionAwsUsEast1a) } if region != string(kernel.DeploymentNewParamsRegionAwsUsEast1a) { - return fmt.Errorf("invalid --region value: %s (must be %s)", region, kernel.DeploymentNewParamsRegionAwsUsEast1a) + return util.InvalidChoice("--region", region, string(kernel.DeploymentNewParamsRegionAwsUsEast1a)) } var body bytes.Buffer @@ -256,7 +256,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { return fmt.Errorf("failed to resolve entrypoint: %w", err) } if _, err := os.Stat(resolvedEntrypoint); err != nil { - return fmt.Errorf("entrypoint %s does not exist", resolvedEntrypoint) + return fmt.Errorf("entrypoint %q does not exist; pass a valid file path", resolvedEntrypoint) } sourceDir := filepath.Dir(resolvedEntrypoint) @@ -305,7 +305,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { for _, kv := range envPairs { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid env variable format: %s (expected KEY=value)", kv) + return fmt.Errorf("invalid --env %q; use KEY=value", kv) } envVars[parts[0]] = parts[1] } @@ -324,7 +324,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } if region != "" { if region != string(kernel.DeploymentNewParamsRegionAwsUsEast1a) { - return fmt.Errorf("invalid --region value: %s (must be %s)", region, kernel.DeploymentNewParamsRegionAwsUsEast1a) + return util.InvalidChoice("--region", region, string(kernel.DeploymentNewParamsRegionAwsUsEast1a)) } params.Region = kernel.DeploymentNewParamsRegion(region) } @@ -515,7 +515,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { appNameFilter = strings.TrimSpace(args[0]) } if appVersionFilter != "" && appNameFilter == "" { - return fmt.Errorf("--app-version requires app_name") + return fmt.Errorf("--app-version requires app_name; use `kernel deploy history --app-version `") } params := kernel.DeploymentListParams{} @@ -534,8 +534,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { } deployments, err := client.Deployments.List(cmd.Context(), params) if err != nil { - pterm.Error.Printf("Failed to list deployments: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: fmt.Errorf("list deployments failed; check the app name/version or run `kernel app list`: %w", err)} } if output == "json" { diff --git a/cmd/extensions.go b/cmd/extensions.go index bb48184..71516c5 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -122,8 +122,7 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) error { if in.Identifier == "" { - pterm.Error.Println("Missing identifier") - return nil + return util.RequiredArg("extension ID or name", "kernel extensions delete ") } if !in.SkipConfirm { @@ -149,8 +148,7 @@ func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) err func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) error { if in.Identifier == "" { - pterm.Error.Println("Missing identifier") - return nil + return util.RequiredArg("extension ID or name", "kernel extensions download --to ") } res, err := e.extensions.Download(ctx, in.Identifier) if err != nil { @@ -158,56 +156,48 @@ func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) } defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) - return nil + return util.RequiredFlag("--to", "") } outDir, err := filepath.Abs(in.Output) if err != nil { - pterm.Error.Printf("Failed to resolve output path: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("resolve --to path %q failed; choose a valid directory path: %w", in.Output, err) } // Create directory if not exists; if exists, ensure empty if st, err := os.Stat(outDir); err == nil { if !st.IsDir() { - pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is a file; choose an empty directory", outDir) } entries, _ := os.ReadDir(outDir) if len(entries) > 0 { - pterm.Error.Printf("Output directory must be empty: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is not empty; choose an empty directory", outDir) } } else { if err := os.MkdirAll(outDir, 0o755); err != nil { - pterm.Error.Printf("Failed to create output directory: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create --to directory %q failed; check parent permissions: %w", outDir, err) } } // Write response to a temp zip, then extract tmpZip, err := os.CreateTemp("", "kernel-ext-*.zip") if err != nil { - pterm.Error.Printf("Failed to create temp zip: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create temporary extension zip failed; check temp directory permissions: %w", err) } tmpName := tmpZip.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := io.Copy(tmpZip, res.Body); err != nil { _ = tmpZip.Close() - pterm.Error.Printf("Failed to read response: %v\n", err) - return nil + return fmt.Errorf("download extension archive failed while reading response: %w", err) } _ = tmpZip.Close() if err := util.Unzip(tmpName, outDir); err != nil { - pterm.Error.Printf("Failed to extract zip: %v\n", err) - return nil + return fmt.Errorf("extract extension archive into %q failed; choose an empty writable directory: %w", outDir, err) } pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil @@ -215,8 +205,7 @@ func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownloadWebStoreInput) error { if in.URL == "" { - pterm.Error.Println("Missing URL argument") - return nil + return util.RequiredArg("Chrome Web Store URL", "kernel extensions download-web-store --to ") } params := kernel.ExtensionDownloadFromChromeStoreParams{URL: in.URL} switch in.OS { @@ -227,8 +216,7 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo case string(kernel.ExtensionDownloadFromChromeStoreParamsOsWin): params.Os = kernel.ExtensionDownloadFromChromeStoreParamsOsWin default: - pterm.Error.Println("--os must be one of mac, win, linux") - return nil + return util.InvalidChoice("--os", in.OS, "linux", "mac", "win") } res, err := e.extensions.DownloadFromChromeStore(ctx, params) @@ -238,59 +226,50 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) - return nil + return util.RequiredFlag("--to", "") } outDir, err := filepath.Abs(in.Output) if err != nil { - pterm.Error.Printf("Failed to resolve output path: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("resolve --to path %q failed; choose a valid directory path: %w", in.Output, err) } if st, err := os.Stat(outDir); err == nil { if !st.IsDir() { - pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is a file; choose an empty directory", outDir) } entries, _ := os.ReadDir(outDir) if len(entries) > 0 { - pterm.Error.Printf("Output directory must be empty: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is not empty; choose an empty directory", outDir) } } else { if err := os.MkdirAll(outDir, 0o755); err != nil { - pterm.Error.Printf("Failed to create output directory: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create --to directory %q failed; check parent permissions: %w", outDir, err) } } // Save to temp zip then extract var bodyBuf bytes.Buffer if _, err := io.Copy(&bodyBuf, res.Body); err != nil { - pterm.Error.Printf("Failed to read response: %v\n", err) - return nil + return fmt.Errorf("download Web Store archive failed while reading response: %w", err) } tmpZip, err := os.CreateTemp("", "kernel-webstore-*.zip") if err != nil { - pterm.Error.Printf("Failed to create temp zip: %v\n", err) - return nil + return fmt.Errorf("create temporary Web Store zip failed; check temp directory permissions: %w", err) } tmpName := tmpZip.Name() if _, err := tmpZip.Write(bodyBuf.Bytes()); err != nil { _ = tmpZip.Close() - pterm.Error.Printf("Failed to write temp zip: %v\n", err) - return nil + return fmt.Errorf("write temporary Web Store zip failed; check temp directory permissions: %w", err) } _ = tmpZip.Close() defer os.Remove(tmpName) if err := util.Unzip(tmpName, outDir); err != nil { - pterm.Error.Printf("Failed to extract zip: %v\n", err) - return nil + return fmt.Errorf("extract Web Store archive into %q failed; choose an empty writable directory: %w", outDir, err) } pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil @@ -302,15 +281,15 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err } if in.Dir == "" { - return fmt.Errorf("missing directory argument") + return util.RequiredArg("extension directory", "kernel extensions upload ") } absDir, err := filepath.Abs(in.Dir) if err != nil { - return fmt.Errorf("failed to resolve directory: %w", err) + return fmt.Errorf("resolve extension directory %q failed; pass a valid path: %w", in.Dir, err) } stat, err := os.Stat(absDir) if err != nil || !stat.IsDir() { - return fmt.Errorf("directory %s does not exist", absDir) + return fmt.Errorf("extension directory %q does not exist; pass an unpacked extension directory", absDir) } // Pre-flight size check @@ -321,14 +300,13 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) if err := util.ZipDirectory(absDir, tmpFile, &defaultExtensionExclusions); err != nil { - pterm.Error.Println("Failed to zip directory") - return err + return fmt.Errorf("zip extension directory %q failed; check the directory contents: %w", absDir, err) } defer os.Remove(tmpFile) fileInfo, err := os.Stat(tmpFile) if err != nil { - return fmt.Errorf("failed to stat zip: %w", err) + return fmt.Errorf("stat extension bundle %q failed: %w", tmpFile, err) } if in.Output != "json" { @@ -342,12 +320,12 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err pterm.Info.Println(" 1. Ensure you're building the extension for production") pterm.Info.Println(" 2. Remove unnecessary assets (large images, videos)") pterm.Info.Println(" 3. Check manifest.json references only needed files") - return fmt.Errorf("bundle exceeds maximum size") + return fmt.Errorf("extension bundle is %s; keep it under %s", util.FormatBytes(fileInfo.Size()), util.FormatBytes(MaxExtensionSizeBytes)) } f, err := os.Open(tmpFile) if err != nil { - return fmt.Errorf("failed to open temp zip: %w", err) + return fmt.Errorf("open extension bundle %q failed: %w", tmpFile, err) } defer f.Close() diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 8be3c44..1c10dc9 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -100,13 +100,14 @@ func TestExtensionsDelete_NotFound(t *testing.T) { } func TestExtensionsDownload_MissingOutput(t *testing.T) { - buf := capturePtermOutput(t) fake := &FakeExtensionsService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("content")), Header: http.Header{}}, nil }} e := ExtensionsCmd{extensions: fake} - _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) - assert.Contains(t, buf.String(), "Missing --to output directory") + err := e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--to is required") + assert.Contains(t, err.Error(), "add --to ") } func TestExtensionsDownload_ExtractsToDir(t *testing.T) { @@ -158,11 +159,12 @@ func TestExtensionsDownloadWebStore_ExtractsToDir(t *testing.T) { } func TestExtensionsDownloadWebStore_InvalidOS(t *testing.T) { - buf := capturePtermOutput(t) fake := &FakeExtensionsService{} e := ExtensionsCmd{extensions: fake} - _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: "x", OS: "freebsd"}) - assert.Contains(t, buf.String(), "--os must be one of mac, win, linux") + err := e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: "x", OS: "freebsd"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), `invalid --os "freebsd"`) + assert.Contains(t, err.Error(), "linux, mac, win") } func TestExtensionsUpload_Success(t *testing.T) { diff --git a/cmd/invoke.go b/cmd/invoke.go index 851cb9a..1c84037 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -103,7 +103,7 @@ func init() { func runInvoke(cmd *cobra.Command, args []string) error { if len(args) != 2 { - return fmt.Errorf("requires exactly 2 arguments: ") + return util.RequiredArg("app and action", "kernel invoke ") } startTime := time.Now() client := getKernelClient(cmd) @@ -118,7 +118,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { jsonOutput := output == "json" if version == "" { - return fmt.Errorf("version cannot be an empty string") + return fmt.Errorf("--version cannot be empty; omit --version or pass --version ") } isSync, _ := cmd.Flags().GetBool("sync") asyncTimeout, _ := cmd.Flags().GetInt64("async-timeout") @@ -376,7 +376,7 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) if payloadStr != "" { var v interface{} if err := json.Unmarshal([]byte(payloadStr), &v); err != nil { - return "", false, fmt.Errorf("invalid JSON payload: %w", err) + return "", false, fmt.Errorf("invalid --payload JSON; pass a JSON object, array, string, number, boolean, or null: %w", err) } } return payloadStr, true, nil @@ -390,13 +390,13 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) // Read from stdin data, err = io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read payload from stdin: %w", err) + return "", false, fmt.Errorf("read --payload-file - from stdin failed: %w", err) } } else { // Read from file data, err = os.ReadFile(payloadFile) if err != nil { - return "", false, fmt.Errorf("failed to read payload file: %w", err) + return "", false, fmt.Errorf("read --payload-file %q failed; check the path and permissions: %w", payloadFile, err) } } @@ -405,7 +405,7 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) if payloadStr != "" { var v interface{} if err := json.Unmarshal([]byte(payloadStr), &v); err != nil { - return "", false, fmt.Errorf("invalid JSON in payload file: %w", err) + return "", false, fmt.Errorf("invalid JSON in --payload-file %q; fix the file contents: %w", payloadFile, err) } } return payloadStr, true, nil @@ -468,7 +468,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { case "failed": params.Status = kernel.InvocationListParamsStatusFailed default: - return fmt.Errorf("invalid --status value: %s (must be queued, running, succeeded, or failed)", statusFilter) + return util.InvalidChoice("--status", statusFilter, "queued", "running", "succeeded", "failed") } } @@ -488,8 +488,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { // Make a single API call to get invocations invocations, err := client.Invocations.List(cmd.Context(), params) if err != nil { - pterm.Error.Printf("Failed to list invocations: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: fmt.Errorf("list invocations failed; check the app name or run `kernel app list`: %w", err)} } if output == "json" { @@ -640,7 +639,7 @@ func runInvocationUpdate(cmd *cobra.Command, args []string) error { case "failed": parsedStatus = kernel.InvocationUpdateParamsStatusFailed default: - return fmt.Errorf("invalid --status value: %s (must be succeeded or failed)", status) + return util.InvalidChoice("--status", status, "succeeded", "failed") } params := kernel.InvocationUpdateParams{Status: parsedStatus} @@ -648,7 +647,7 @@ func runInvocationUpdate(cmd *cobra.Command, args []string) error { if strings.TrimSpace(output) != "" { var parsed interface{} if err := json.Unmarshal([]byte(output), &parsed); err != nil { - return fmt.Errorf("invalid JSON for --output: %w", err) + return fmt.Errorf("invalid --output JSON; pass a JSON string/object/array or omit --output: %w", err) } } params.Output = kernel.Opt(output) diff --git a/cmd/logs.go b/cmd/logs.go index 4715eeb..daac8e9 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -141,10 +141,10 @@ func runLogs(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list apps: %w", err) } if apps == nil || len(apps.Items) == 0 { - return fmt.Errorf("app \"%s\" not found", appName) + return util.NotFound("App", appName, "kernel app list") } if len(apps.Items) > 1 { - return fmt.Errorf("multiple apps found for \"%s\", please specify a version", appName) + return fmt.Errorf("multiple app versions found for %q; rerun with --version ", appName) } app := apps.Items[0] diff --git a/cmd/profiles.go b/cmd/profiles.go index a507224..46acb07 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -154,8 +154,7 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { fmt.Println("null") return nil } - pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) - return nil + return util.NotFound("Profile", in.Identifier, "kernel profiles list") } if in.Output == "json" { @@ -245,7 +244,7 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) error { if in.To == "" { - return fmt.Errorf("missing required --to for extraction directory") + return util.RequiredFlag("--to", "") } res, err := p.profiles.Download(ctx, in.Identifier) @@ -262,7 +261,7 @@ func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) err if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) - return fmt.Errorf("unexpected status %d from profile download: %s", res.StatusCode, strings.TrimSpace(string(body))) + return fmt.Errorf("profile download returned HTTP %d; retry later or run `kernel profiles get %s`: %s", res.StatusCode, in.Identifier, strings.TrimSpace(string(body))) } if err := extractProfileArchive(res.Body, in.To); err != nil { diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index d104384..5fb5b03 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -226,7 +226,8 @@ func TestProfilesDownload_MissingTo(t *testing.T) { p := ProfilesCmd{profiles: fake} err := p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: ""}) assert.Error(t, err) - assert.Contains(t, err.Error(), "missing required --to") + assert.Contains(t, err.Error(), "--to is required") + assert.Contains(t, err.Error(), "add --to ") } func TestProfilesDownload_ExtractSuccess(t *testing.T) { diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 44de57f..93de576 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -31,7 +31,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "custom": proxyType = kernel.ProxyNewParamsTypeCustom default: - return fmt.Errorf("invalid proxy type: %s", in.Type) + return util.InvalidChoice("--type", in.Type, "datacenter", "isp", "residential", "mobile", "custom") } params := kernel.ProxyNewParams{ @@ -70,7 +70,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { - return fmt.Errorf("--country is required when --city is specified") + return fmt.Errorf("--country is required when --city is set; add --country ") } if in.Country != "" { @@ -94,7 +94,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "windows", "macos", "android": config.Os = in.OS default: - return fmt.Errorf("invalid OS value: %s (must be windows, macos, or android)", in.OS) + return util.InvalidChoice("--os", in.OS, "windows", "macos", "android") } } params.Config = kernel.ProxyNewParamsConfigUnion{ @@ -106,7 +106,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { - return fmt.Errorf("--country is required when --city is specified") + return fmt.Errorf("--country is required when --city is set; add --country ") } if in.Country != "" { @@ -134,10 +134,10 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case kernel.ProxyNewParamsTypeCustom: if in.Host == "" { - return fmt.Errorf("--host is required for custom proxy type") + return fmt.Errorf("--host is required for custom proxies; add --host ") } if in.Port == 0 { - return fmt.Errorf("--port is required for custom proxy type") + return fmt.Errorf("--port is required for custom proxies; add --port ") } config := kernel.ProxyNewParamsConfigCreateCustomProxyConfig{ @@ -164,7 +164,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "https": params.Protocol = kernel.ProxyNewParamsProtocolHTTPS default: - return fmt.Errorf("invalid protocol: %s (must be http or https)", in.Protocol) + return util.InvalidChoice("--protocol", in.Protocol, "http", "https") } } diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index bdb3b30..31e90e1 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -139,7 +139,8 @@ func TestProxyCreate_Residential_CityWithoutCountry(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--country is required when --city is specified") + assert.Contains(t, err.Error(), "--country is required when --city is set") + assert.Contains(t, err.Error(), "add --country ") } func TestProxyCreate_Residential_InvalidOS(t *testing.T) { @@ -152,7 +153,8 @@ func TestProxyCreate_Residential_InvalidOS(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid OS value: linux (must be windows, macos, or android)") + assert.Contains(t, err.Error(), `invalid --os "linux"`) + assert.Contains(t, err.Error(), "windows, macos, android") } func TestProxyCreate_Mobile_Success(t *testing.T) { @@ -236,7 +238,8 @@ func TestProxyCreate_Custom_MissingHost(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--host is required for custom proxy type") + assert.Contains(t, err.Error(), "--host is required for custom proxies") + assert.Contains(t, err.Error(), "add --host ") } func TestProxyCreate_Custom_MissingPort(t *testing.T) { @@ -250,7 +253,8 @@ func TestProxyCreate_Custom_MissingPort(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--port is required for custom proxy type") + assert.Contains(t, err.Error(), "--port is required for custom proxies") + assert.Contains(t, err.Error(), "add --port ") } func TestProxyCreate_InvalidType(t *testing.T) { @@ -262,7 +266,8 @@ func TestProxyCreate_InvalidType(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid proxy type: invalid") + assert.Contains(t, err.Error(), `invalid --type "invalid"`) + assert.Contains(t, err.Error(), "datacenter, isp, residential, mobile, custom") } func TestProxyCreate_Protocol_Valid(t *testing.T) { @@ -309,7 +314,8 @@ func TestProxyCreate_Protocol_Invalid(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid protocol: ftp") + assert.Contains(t, err.Error(), `invalid --protocol "ftp"`) + assert.Contains(t, err.Error(), "http, https") } func TestProxyCreate_BypassHosts_Normalized(t *testing.T) { diff --git a/cmd/root.go b/cmd/root.go index 88d6d87..8168236 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -253,6 +253,9 @@ func isUsageError(err error) bool { "unknown shorthand flag:", "unknown command", "invalid argument", + "accepts ", + "requires at least ", + "requires exactly ", } { if strings.HasPrefix(s, prefix) { return true diff --git a/cmd/status.go b/cmd/status.go index f225e22..39eaf42 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -45,16 +45,15 @@ func runStatus(cmd *cobra.Command, args []string) error { } client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(util.GetBaseURL() + "/status") + statusURL := util.GetBaseURL() + "/status" + resp, err := client.Get(statusURL) if err != nil { - pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") - return nil + return fmt.Errorf("could not reach Kernel API at %s; check https://status.kernel.sh and retry: %w", statusURL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") - return nil + return fmt.Errorf("Kernel API status check returned %s; check https://status.kernel.sh and retry", resp.Status) } var status statusResponse diff --git a/pkg/extensions/webbotauth.go b/pkg/extensions/webbotauth.go index 3f11d68..10ebeb6 100644 --- a/pkg/extensions/webbotauth.go +++ b/pkg/extensions/webbotauth.go @@ -132,14 +132,10 @@ func extractExtensionID(output string) string { // validateToolDependencies checks for required tools (node and npm) func validateToolDependencies() error { if _, err := exec.LookPath("node"); err != nil { - pterm.Error.Println("Node.js is required but not found in PATH") - pterm.Info.Println("Please install Node.js from https://nodejs.org/") - return fmt.Errorf("node not found") + return fmt.Errorf("node is required to build web-bot-auth; install Node.js from https://nodejs.org/ and retry") } if _, err := exec.LookPath("npm"); err != nil { - pterm.Error.Println("npm is required but not found in PATH") - pterm.Info.Println("Please install npm (usually comes with Node.js)") - return fmt.Errorf("npm not found") + return fmt.Errorf("npm is required to build web-bot-auth; install npm and retry") } return nil } diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 76fe233..7c50b95 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/kernel/kernel-go-sdk" ) @@ -39,3 +40,50 @@ func (e CleanedUpSdkError) Error() string { func (e CleanedUpSdkError) Unwrap() error { return e.Err } + +func RequiredFlag(flag, valueHint string) error { + if valueHint == "" { + return fmt.Errorf("%s is required; add %s", flag, flag) + } + return fmt.Errorf("%s is required; add %s %s", flag, flag, valueHint) +} + +func RequiredArg(name, usage string) error { + return fmt.Errorf("missing %s; use: %s", name, usage) +} + +func ChooseOne(flags ...string) error { + return fmt.Errorf("choose one of %s", joinOptions(flags)) +} + +func ChooseOnlyOne(flags ...string) error { + return fmt.Errorf("choose only one of %s", joinOptions(flags)) +} + +func SetAtLeastOne(flags ...string) error { + return fmt.Errorf("set at least one of %s", joinOptions(flags)) +} + +func InvalidChoice(flag, value string, choices ...string) error { + return fmt.Errorf("invalid %s %q; use one of: %s", flag, value, strings.Join(choices, ", ")) +} + +func NotFound(resource, id, listCommand string) error { + if listCommand == "" { + return fmt.Errorf("%s %q not found", resource, id) + } + return fmt.Errorf("%s %q not found; run `%s` to find valid IDs", resource, id, listCommand) +} + +func joinOptions(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " or " + items[1] + default: + return strings.Join(items[:len(items)-1], ", ") + ", or " + items[len(items)-1] + } +} diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go new file mode 100644 index 0000000..aa11ab1 --- /dev/null +++ b/pkg/util/errors_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserErrorHelpers(t *testing.T) { + assert.Equal(t, "--name is required; add --name ", RequiredFlag("--name", "").Error()) + assert.Equal(t, "missing browser ID; use: kernel browsers get ", RequiredArg("browser ID", "kernel browsers get ").Error()) + assert.Equal(t, "choose only one of --profile-id or --profile-name", ChooseOnlyOne("--profile-id", "--profile-name").Error()) + assert.Equal(t, "set at least one of --proxy-id, --profile-id, or --viewport", SetAtLeastOne("--proxy-id", "--profile-id", "--viewport").Error()) + assert.Equal(t, "invalid --status \"bad\"; use one of: active, deleted, all", InvalidChoice("--status", "bad", "active", "deleted", "all").Error()) + assert.Equal(t, "Browser \"brw_123\" not found; run `kernel browsers list` to find valid IDs", NotFound("Browser", "brw_123", "kernel browsers list").Error()) +} From 6896ac86c1f5057f6a318c6d4bac4771171cbd99 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 11:38:25 -0400 Subject: [PATCH 5/8] Fix reviewed error guidance issues --- cmd/auth_connections.go | 2 +- pkg/util/errors.go | 35 ++++++++++++++++++++++++----------- pkg/util/errors_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index fbe2cb0..3f7fdf7 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -282,7 +282,7 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn } if !hasChanges { - return util.SetAtLeastOne("--domain", "--profile-name", "--credential-name", "--credential-provider", "--credential-path", "--credential-auto", "--proxy-id", "--proxy-name") + return util.SetAtLeastOne("--login-url", "--allowed-domain", "--credential-name", "--credential-provider", "--credential-path", "--credential-auto", "--proxy-id", "--proxy-name", "--save-credentials", "--no-save-credentials", "--health-check-interval") } if in.Output != "json" { diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 7c50b95..3337794 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -19,19 +19,16 @@ type CleanedUpSdkError struct { var _ error = CleanedUpSdkError{} func (e CleanedUpSdkError) Error() string { + if kerror, ok := e.Err.(*kernel.Error); ok { + return cleanSdkError(kerror) + } + var kerror *kernel.Error if errors.As(e.Err, &kerror) { - var m map[string]interface{} - if err := json.Unmarshal([]byte(kerror.RawJSON()), &m); err == nil { - message, _ := m["message"].(string) - code, _ := m["code"].(string) - return fmt.Sprintf("%s: %s", code, message) - } else if kerror.Response != nil && kerror.Response.Body != nil { - // try response body as text - body, err := io.ReadAll(kerror.Response.Body) - if err == nil && len(body) > 0 { - return string(body) - } + raw := kerror.Error() + cleaned := cleanSdkError(kerror) + if raw != "" && cleaned != raw { + return strings.Replace(e.Err.Error(), raw, cleaned, 1) } } return e.Err.Error() @@ -41,6 +38,22 @@ func (e CleanedUpSdkError) Unwrap() error { return e.Err } +func cleanSdkError(kerror *kernel.Error) string { + var m map[string]interface{} + if err := json.Unmarshal([]byte(kerror.RawJSON()), &m); err == nil { + message, _ := m["message"].(string) + code, _ := m["code"].(string) + return fmt.Sprintf("%s: %s", code, message) + } else if kerror.Response != nil && kerror.Response.Body != nil { + // try response body as text + body, err := io.ReadAll(kerror.Response.Body) + if err == nil && len(body) > 0 { + return string(body) + } + } + return kerror.Error() +} + func RequiredFlag(flag, valueHint string) error { if valueHint == "" { return fmt.Errorf("%s is required; add %s", flag, flag) diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go index aa11ab1..6745c9a 100644 --- a/pkg/util/errors_test.go +++ b/pkg/util/errors_test.go @@ -1,9 +1,14 @@ package util import ( + "encoding/json" + "fmt" + "net/http" "testing" + "github.com/kernel/kernel-go-sdk" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUserErrorHelpers(t *testing.T) { @@ -14,3 +19,25 @@ func TestUserErrorHelpers(t *testing.T) { assert.Equal(t, "invalid --status \"bad\"; use one of: active, deleted, all", InvalidChoice("--status", "bad", "active", "deleted", "all").Error()) assert.Equal(t, "Browser \"brw_123\" not found; run `kernel browsers list` to find valid IDs", NotFound("Browser", "brw_123", "kernel browsers list").Error()) } + +func TestCleanedUpSdkErrorPreservesOuterContext(t *testing.T) { + apiErr := newKernelError(t, `{"code":"not_found","message":"missing app"}`) + + assert.Equal(t, "not_found: missing app", CleanedUpSdkError{Err: apiErr}.Error()) + assert.Equal(t, + "list applications failed; check your auth and retry: not_found: missing app", + CleanedUpSdkError{Err: fmt.Errorf("list applications failed; check your auth and retry: %w", apiErr)}.Error(), + ) +} + +func newKernelError(t *testing.T, raw string) *kernel.Error { + t.Helper() + + var err kernel.Error + require.NoError(t, json.Unmarshal([]byte(raw), &err)) + req, reqErr := http.NewRequest(http.MethodGet, "https://api.example.test/apps", nil) + require.NoError(t, reqErr) + err.Request = req + err.Response = &http.Response{StatusCode: http.StatusNotFound} + return &err +} From 5d32c4210c6107bb21a619135dfff3257b540aa8 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 11:47:36 -0400 Subject: [PATCH 6/8] Fix browser usage guidance --- cmd/browsers.go | 8 ++++---- cmd/browsers_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 0989b45..754b687 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -891,7 +891,7 @@ func (b BrowsersCmd) ComputerPressKey(ctx context.Context, in BrowsersComputerPr return util.CleanedUpSdkError{Err: err} } if len(in.Keys) == 0 { - return util.RequiredArg("keys", "kernel browsers computer press-key [key...]") + return util.RequiredFlag("--key", "") } body := kernel.BrowserComputerPressKeyParams{Keys: in.Keys} if in.Duration > 0 { @@ -1919,7 +1919,7 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err } } if len(files) == 0 { - return fmt.Errorf("no files to upload; pass --file local:remote or --path with --dest-dir") + return fmt.Errorf("no files to upload; pass --file local:remote or --paths with --dest-dir ") } defer func() { for _, c := range toClose { @@ -1997,7 +1997,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions } if len(in.ExtensionPaths) == 0 { - return util.RequiredArg("extension path", "kernel browsers extensions upload [extension-dir...]") + return util.RequiredArg("extension path", "kernel browsers extensions upload ...") } var extensions []kernel.BrowserLoadExtensionsParamsExtension @@ -2842,7 +2842,7 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { // Read code from stdin stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) != 0 { - return util.RequiredArg("Playwright code", "kernel browsers playwright ''") + return util.RequiredArg("Playwright code", "kernel browsers playwright execute ''") } data, err := io.ReadAll(os.Stdin) if err != nil { diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 1a3e387..c6c68cb 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1302,6 +1302,18 @@ func TestBrowsersFSUpload_MappingAndDestDir_Success(t *testing.T) { assert.Equal(t, 2, len(captured.Files)) } +func TestBrowsersFSUpload_NoFilesGuidesToActualFlags(t *testing.T) { + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, fs: &FakeFSService{}} + + err := b.FSUpload(context.Background(), BrowsersFSUploadInput{Identifier: "id"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--file local:remote") + assert.Contains(t, err.Error(), "--paths ") + assert.Contains(t, err.Error(), "--dest-dir ") +} + func TestBrowsersFSUploadZip_Success(t *testing.T) { setupStdoutCapture(t) z := __writeTempFile(t, "zipdata") @@ -1490,6 +1502,17 @@ func TestBrowsersComputerPressKey_PrintsSuccess(t *testing.T) { assert.Contains(t, out, "Pressed keys: Return,Shift") } +func TestBrowsersComputerPressKey_MissingKeysGuidesToKeyFlag(t *testing.T) { + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, computer: &FakeComputerService{}} + + err := b.ComputerPressKey(context.Background(), BrowsersComputerPressKeyInput{Identifier: "id"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--key is required") + assert.Contains(t, err.Error(), "add --key ") +} + func TestBrowsersComputerScroll_PrintsSuccess(t *testing.T) { setupStdoutCapture(t) fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() From 703fd1f66aa26482ba6d7097eae778f07515baaf Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 13:20:01 -0400 Subject: [PATCH 7/8] Add JSON output for project reads --- cmd/projects.go | 33 +++++++++++-- cmd/projects_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/cmd/projects.go b/cmd/projects.go index 84f7226..35fe932 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -35,7 +35,9 @@ type ProjectsCmd struct { limits ProjectLimitsService } -type ProjectsListInput struct{} +type ProjectsListInput struct { + Output string +} type ProjectsCreateInput struct { Name string @@ -43,6 +45,7 @@ type ProjectsCreateInput struct { type ProjectsGetInput struct { Identifier string + Output string } type ProjectsDeleteInput struct { @@ -77,11 +80,19 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + projects, err := c.projects.List(ctx, kernel.ProjectListParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + return util.PrintPrettyJSONPageItems(projects) + } + if projects == nil || len(projects.Items) == 0 { pterm.Info.Println("No projects found") return nil @@ -110,6 +121,10 @@ func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { } func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + projectID, err := resolveProjectArg(ctx, c.projects, in.Identifier) if err != nil { return err @@ -120,6 +135,14 @@ func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + if project == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(project) + } + table := pterm.TableData{ {"Property", "Value"}, {"ID", project.ID}, @@ -259,7 +282,8 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd { func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.List(cmd.Context(), ProjectsListInput{}) + output, _ := cmd.Flags().GetString("output") + return c.List(cmd.Context(), ProjectsListInput{Output: output}) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -269,7 +293,8 @@ func runProjectsCreate(cmd *cobra.Command, args []string) error { func runProjectsGet(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.Get(cmd.Context(), ProjectsGetInput{Identifier: args[0]}) + output, _ := cmd.Flags().GetString("output") + return c.Get(cmd.Context(), ProjectsGetInput{Identifier: args[0], Output: output}) } func runProjectsDelete(cmd *cobra.Command, args []string) error { @@ -392,6 +417,8 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + addJSONOutputFlag(projectsListCmd) + addJSONOutputFlag(projectsGetCmd) addJSONOutputFlag(projectsLimitsGetCmd) addProjectsLimitsSetFlags(projectsLimitsSetCmd) addJSONOutputFlag(projectsGetLimitsCompatCmd) diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 3855cdf..1e085c8 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -1,8 +1,12 @@ package cmd import ( + "bytes" "context" + "encoding/json" "errors" + "io" + "os" "testing" "github.com/kernel/kernel-go-sdk" @@ -10,6 +14,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/respjson" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type FakeProjectsService struct { @@ -66,6 +71,81 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k return &kernel.ProjectLimits{}, nil } +func TestProjectsList_JSONOutput(t *testing.T) { + project := mustProject(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`) + c := ProjectsCmd{ + projects: &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + return &pagination.OffsetPagination[kernel.Project]{Items: []kernel.Project{project}}, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + var err error + out := captureStdout(t, func() { + err = c.List(context.Background(), ProjectsListInput{Output: "json"}) + }) + + require.NoError(t, err) + assert.JSONEq(t, `[{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}]`, out) +} + +func TestProjectsList_InvalidOutput(t *testing.T) { + c := ProjectsCmd{ + projects: &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + t.Fatal("List should not be called") + return nil, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + err := c.List(context.Background(), ProjectsListInput{Output: "yaml"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestProjectsGet_JSONOutput(t *testing.T) { + project := mustProject(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`) + c := ProjectsCmd{ + projects: &FakeProjectsService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) { + assert.Equal(t, "a12345678901234567890123", id) + return &project, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + var err error + out := captureStdout(t, func() { + err = c.Get(context.Background(), ProjectsGetInput{Identifier: "a12345678901234567890123", Output: "json"}) + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`, out) +} + +func TestProjectsGet_InvalidOutput(t *testing.T) { + c := ProjectsCmd{ + projects: &FakeProjectsService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) { + t.Fatal("Get should not be called") + return nil, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + err := c.Get(context.Background(), ProjectsGetInput{Identifier: "a12345678901234567890123", Output: "yaml"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + func TestProjectsLimitsGet_DefaultOutput(t *testing.T) { buf := capturePtermOutput(t) limits := &kernel.ProjectLimits{ @@ -194,3 +274,34 @@ func TestResolveProjectByName_PaginatesAcrossResults(t *testing.T) { assert.Equal(t, "proj_target", id) assert.Equal(t, []int64{0, 100}, seenOffsets) } + +func mustProject(t *testing.T, raw string) kernel.Project { + t.Helper() + + var project kernel.Project + require.NoError(t, json.Unmarshal([]byte(raw), &project)) + return project +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, writer.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + return buf.String() +} From 2f60cd76d66808221fcc7f1bdc85d13d57966c68 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 13:31:52 -0400 Subject: [PATCH 8/8] Clarify dashboard base URL errors --- pkg/util/errors.go | 27 ++++++++++++++++++++++++++- pkg/util/errors_test.go | 13 +++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 3337794..acf3c6b 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -10,7 +10,7 @@ import ( "github.com/kernel/kernel-go-sdk" ) -// CleanedUpSdkError extracts a message field from the raw JSON resposne. +// CleanedUpSdkError extracts a message field from the raw JSON response. // This is the convention we use in the API for error response bodies (400s and 500s) type CleanedUpSdkError struct { Err error @@ -31,6 +31,9 @@ func (e CleanedUpSdkError) Error() string { return strings.Replace(e.Err.Error(), raw, cleaned, 1) } } + if cleaned, ok := cleanNonJSONAPIResponseError(e.Err.Error()); ok { + return cleaned + } return e.Err.Error() } @@ -44,6 +47,8 @@ func cleanSdkError(kerror *kernel.Error) string { message, _ := m["message"].(string) code, _ := m["code"].(string) return fmt.Sprintf("%s: %s", code, message) + } else if cleaned, ok := cleanNonJSONAPIResponseError(kerror.Error()); ok { + return cleaned } else if kerror.Response != nil && kerror.Response.Body != nil { // try response body as text body, err := io.ReadAll(kerror.Response.Body) @@ -54,6 +59,26 @@ func cleanSdkError(kerror *kernel.Error) string { return kerror.Error() } +func cleanNonJSONAPIResponseError(message string) (string, bool) { + if !strings.Contains(message, "not 'application/json'") || + !strings.Contains(message, "content-type 'text/html") { + return "", false + } + + guidance := fmt.Sprintf( + "server returned HTML instead of Kernel API JSON; KERNEL_BASE_URL resolves to %s. Use an API base URL, not the dashboard URL. For production, unset KERNEL_BASE_URL or set it to https://api.onkernel.com.", + GetBaseURL(), + ) + + const sdkDecodeError = ": expected destination type" + if idx := strings.LastIndex(message, sdkDecodeError); idx >= 0 { + prefix := strings.TrimSuffix(message[:idx], "; check your auth and retry") + return prefix + ": " + guidance, true + } + + return guidance, true +} + func RequiredFlag(flag, valueHint string) error { if valueHint == "" { return fmt.Errorf("%s is required; add %s", flag, flag) diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go index 6745c9a..ecac7e4 100644 --- a/pkg/util/errors_test.go +++ b/pkg/util/errors_test.go @@ -30,6 +30,19 @@ func TestCleanedUpSdkErrorPreservesOuterContext(t *testing.T) { ) } +func TestCleanedUpSdkErrorExplainsDashboardBaseURL(t *testing.T) { + t.Setenv("KERNEL_BASE_URL", "https://dashboard.onkernel.com") + + err := CleanedUpSdkError{ + Err: fmt.Errorf("list applications failed; check your auth and retry: expected destination type of 'string' or '[]byte' for responses with content-type 'text/html; charset=utf-8' that is not 'application/json'"), + } + + assert.Equal(t, + "list applications failed: server returned HTML instead of Kernel API JSON; KERNEL_BASE_URL resolves to https://dashboard.onkernel.com. Use an API base URL, not the dashboard URL. For production, unset KERNEL_BASE_URL or set it to https://api.onkernel.com.", + err.Error(), + ) +} + func newKernelError(t *testing.T, raw string) *kernel.Error { t.Helper()