From 9961bee82b919833d715f9330df35cd09de01ce6 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Sat, 14 Mar 2026 00:24:19 +1100 Subject: [PATCH] feat: CLI output overhaul and bug fixes Output system: - Tab-delimited non-TTY output for pipe compatibility (cut, awk, etc.) - Key:value detail views instead of box tables for single-object commands - Relative timestamps in table mode (18d ago, 35m ago) - Colored status columns (green/red/yellow) in TTY mode - Empty state messages instead of empty bordered tables - String truncation helpers for long cell values Bug fixes: - --host flag now respected by all commands via cmdutil.ResolveHost - workflow run accepts both HTTP 200 and 202 - org members uses GET /list-members (was POST to wrong endpoint) - --limit flag works client-side as server fallback - FileBackend prioritized over KeychainBackend to avoid macOS prompts Features: - --web flag on workflow get opens resource in browser - .mcp.json for kh serve --mcp integration - PrintKeyValue and PrintDryRun methods on Printer --- .mcp.json | 8 +++ cmd/action/get.go | 2 +- cmd/action/list.go | 2 +- cmd/billing/status.go | 2 +- cmd/billing/usage.go | 2 +- cmd/doctor/doctor.go | 8 +-- cmd/execute/contract_call.go | 2 +- cmd/execute/status.go | 2 +- cmd/execute/transfer.go | 2 +- cmd/org/list.go | 2 +- cmd/org/members.go | 19 ++----- cmd/org/members_test.go | 6 +- cmd/org/switch.go | 2 +- cmd/project/create.go | 2 +- cmd/project/delete.go | 4 +- cmd/project/get.go | 2 +- cmd/project/list.go | 2 +- cmd/protocol/list.go | 6 +- cmd/protocol/list_test.go | 8 +-- cmd/run/cancel.go | 8 +-- cmd/run/logs.go | 16 ++---- cmd/run/logs_test.go | 2 +- cmd/run/status.go | 33 +++++------ cmd/serve/schemas.go | 2 +- cmd/serve/tools.go | 2 +- cmd/tag/create.go | 2 +- cmd/tag/delete.go | 4 +- cmd/tag/get.go | 2 +- cmd/tag/list.go | 2 +- cmd/template/deploy.go | 2 +- cmd/template/list.go | 10 ++-- cmd/wallet/balance.go | 2 +- cmd/wallet/tokens.go | 2 +- cmd/workflow/get.go | 46 +++++++++------ cmd/workflow/go_live.go | 2 +- cmd/workflow/list.go | 15 ++++- cmd/workflow/pause.go | 2 +- cmd/workflow/run.go | 4 +- internal/auth/keyring.go | 2 +- internal/output/color.go | 29 ++++++++++ internal/output/color_test.go | 34 +++++++++++ internal/output/printer.go | 28 +++++++++ internal/output/table.go | 92 +++++++++++++++++++++++++++--- internal/output/table_test.go | 63 ++++++++++++++++++++- internal/output/timeago.go | 45 +++++++++++++++ internal/output/timeago_test.go | 97 ++++++++++++++++++++++++++++++++ internal/output/truncate.go | 27 +++++++++ internal/output/truncate_test.go | 29 ++++++++++ pkg/cmdutil/browser.go | 22 ++++++++ pkg/cmdutil/host.go | 33 +++++++++++ pkg/cmdutil/host_test.go | 73 ++++++++++++++++++++++++ 51 files changed, 683 insertions(+), 132 deletions(-) create mode 100644 .mcp.json create mode 100644 internal/output/color.go create mode 100644 internal/output/color_test.go create mode 100644 internal/output/timeago.go create mode 100644 internal/output/timeago_test.go create mode 100644 internal/output/truncate.go create mode 100644 internal/output/truncate_test.go create mode 100644 pkg/cmdutil/browser.go create mode 100644 pkg/cmdutil/host.go create mode 100644 pkg/cmdutil/host_test.go diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..64a9369 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "keeperhub": { + "command": "kh", + "args": ["serve", "--mcp"] + } + } +} diff --git a/cmd/action/get.go b/cmd/action/get.go index 856b446..556952d 100644 --- a/cmd/action/get.go +++ b/cmd/action/get.go @@ -36,7 +36,7 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { } query := args[0] - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) apiURL := khhttp.BuildBaseURL(host) + "/api/integrations" req, err := client.NewRequest(http.MethodGet, apiURL, nil) diff --git a/cmd/action/list.go b/cmd/action/list.go index 8b7ddcf..63baba2 100644 --- a/cmd/action/list.go +++ b/cmd/action/list.go @@ -56,7 +56,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) apiURL := khhttp.BuildBaseURL(host) + "/api/integrations" if category != "" { apiURL += "?category=" + category diff --git a/cmd/billing/status.go b/cmd/billing/status.go index 4768813..2d9276e 100644 --- a/cmd/billing/status.go +++ b/cmd/billing/status.go @@ -47,7 +47,7 @@ func NewStatusCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/billing/subscription" req, err := client.NewRequest(http.MethodGet, url, nil) diff --git a/cmd/billing/usage.go b/cmd/billing/usage.go index 3a1ed0e..dfe9856 100644 --- a/cmd/billing/usage.go +++ b/cmd/billing/usage.go @@ -38,7 +38,7 @@ func NewUsageCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/billing/subscription" if period != "" && period != "current" { url += "?period=" + period diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index da64b27..409cbc7 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -127,16 +127,14 @@ func getHTTPClient(_ *cmdutil.Factory) (*http.Client, error) { } // getHost returns the configured host, falling back to the default. +// NOTE: cmd is not available in check functions, so we pass nil to ResolveHost. +// The --host flag is still respected via the KH_HOST env var and config fallback. func getHost(f *cmdutil.Factory) (string, error) { cfg, err := f.Config() if err != nil { return "", err } - host := cfg.DefaultHost - if host == "" { - host = "app.keeperhub.com" - } - return host, nil + return cmdutil.ResolveHost(nil, cfg), nil } // doGet performs a GET request against url with the given context, returning diff --git a/cmd/execute/contract_call.go b/cmd/execute/contract_call.go index d2ed114..6ea1f54 100644 --- a/cmd/execute/contract_call.go +++ b/cmd/execute/contract_call.go @@ -52,7 +52,7 @@ func NewContractCallCmd(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) chain, _ := cmd.Flags().GetString("chain") contract, _ := cmd.Flags().GetString("contract") diff --git a/cmd/execute/status.go b/cmd/execute/status.go index 41ad9c9..b2d5a99 100644 --- a/cmd/execute/status.go +++ b/cmd/execute/status.go @@ -51,7 +51,7 @@ See also: kh r st, kh ex transfer, kh ex cc`, if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) watch, _ := cmd.Flags().GetBool("watch") diff --git a/cmd/execute/transfer.go b/cmd/execute/transfer.go index 07c8a22..04f7589 100644 --- a/cmd/execute/transfer.go +++ b/cmd/execute/transfer.go @@ -51,7 +51,7 @@ func NewTransferCmd(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) chain, _ := cmd.Flags().GetString("chain") to, _ := cmd.Flags().GetString("to") diff --git a/cmd/org/list.go b/cmd/org/list.go index 264cf16..64cc096 100644 --- a/cmd/org/list.go +++ b/cmd/org/list.go @@ -44,7 +44,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/organizations" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/organizations" req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { diff --git a/cmd/org/members.go b/cmd/org/members.go index 2a04f21..5d5f370 100644 --- a/cmd/org/members.go +++ b/cmd/org/members.go @@ -1,7 +1,6 @@ package org import ( - "bytes" "encoding/json" "fmt" "net/http" @@ -32,8 +31,8 @@ func memberName(m Member) string { return "" } -// membersResponse wraps the Better Auth list-members response. -type membersResponse struct { +// listMembersResponse wraps the Better Auth list-members response. +type listMembersResponse struct { Members []Member `json:"members"` } @@ -59,18 +58,12 @@ func NewMembersCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/auth/organization/list-members" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/auth/organization/list-members" - bodyBytes, err := json.Marshal(map[string]string{}) - if err != nil { - return fmt.Errorf("encoding request body: %w", err) - } - - req, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) + req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { return fmt.Errorf("building request: %w", err) } - req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { @@ -82,9 +75,9 @@ func NewMembersCmd(f *cmdutil.Factory) *cobra.Command { return khhttp.NewAPIError(resp) } - var wrapper membersResponse + var wrapper listMembersResponse if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil { - return fmt.Errorf("unexpected response from member list endpoint: %w", err) + return fmt.Errorf("unexpected response from organization endpoint: %w", err) } members := wrapper.Members diff --git a/cmd/org/members_test.go b/cmd/org/members_test.go index af7fffb..c4c42c3 100644 --- a/cmd/org/members_test.go +++ b/cmd/org/members_test.go @@ -29,7 +29,7 @@ func newMembersFactory(server *httptest.Server, ios *iostreams.IOStreams) *cmdut func makeMembersServer(t *testing.T, members []map[string]interface{}) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/api/auth/organization/list-members" { + if r.Method != http.MethodGet || r.URL.Path != "/api/auth/organization/list-members" { http.Error(w, "not found", http.StatusNotFound) return } @@ -39,7 +39,7 @@ func makeMembersServer(t *testing.T, members []map[string]interface{}) *httptest })) } -func TestMembersCmd_UsesPOSTMethod(t *testing.T) { +func TestMembersCmd_UsesGETMethod(t *testing.T) { method := "" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/auth/organization/list-members" { @@ -59,7 +59,7 @@ func TestMembersCmd_UsesPOSTMethod(t *testing.T) { orgCmd.SetArgs([]string{"members"}) err := orgCmd.Execute() require.NoError(t, err) - assert.Equal(t, http.MethodPost, method, "expected POST method for list-members endpoint") + assert.Equal(t, http.MethodGet, method, "expected GET method for list-members endpoint") } func TestMembersCmd_RendersTableWithColumns(t *testing.T) { diff --git a/cmd/org/switch.go b/cmd/org/switch.go index 89e7d82..f69cf65 100644 --- a/cmd/org/switch.go +++ b/cmd/org/switch.go @@ -37,7 +37,7 @@ func NewSwitchCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - baseURL := khhttp.BuildBaseURL(cfg.DefaultHost) + baseURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) // Step 1: Resolve slug to organization ID. listReq, err := client.NewRequest(http.MethodGet, baseURL+"/api/organizations", nil) diff --git a/cmd/project/create.go b/cmd/project/create.go index e82c2f4..50c1db1 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -46,7 +46,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("building request body: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/projects" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/projects" req, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/project/delete.go b/cmd/project/delete.go index 1191273..817bfc6 100644 --- a/cmd/project/delete.go +++ b/cmd/project/delete.go @@ -37,7 +37,7 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { } // Fetch project name for display in the confirmation prompt. - listURL := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/projects" + listURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/projects" listReq, err := client.NewRequest(http.MethodGet, listURL, nil) if err != nil { return fmt.Errorf("building list request: %w", err) @@ -76,7 +76,7 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { } } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/projects/" + projectID + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/projects/" + projectID req, err := client.NewRequest(http.MethodDelete, url, nil) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/project/get.go b/cmd/project/get.go index 4b7895f..643f1ca 100644 --- a/cmd/project/get.go +++ b/cmd/project/get.go @@ -36,7 +36,7 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/projects" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/projects" req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/project/list.go b/cmd/project/list.go index 072d1ca..469b420 100644 --- a/cmd/project/list.go +++ b/cmd/project/list.go @@ -51,7 +51,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/projects?limit=" + strconv.Itoa(limit) + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/projects?limit=" + strconv.Itoa(limit) req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { diff --git a/cmd/protocol/list.go b/cmd/protocol/list.go index a2e70f8..26adaed 100644 --- a/cmd/protocol/list.go +++ b/cmd/protocol/list.go @@ -100,7 +100,7 @@ func loadProtocols(f *cmdutil.Factory, refresh bool, cmd *cobra.Command) ([]Prot } // Fetch fresh data - raw, fetchErr := fetchSchemas(f) + raw, fetchErr := fetchSchemas(f, cmd) if fetchErr != nil { if staleEntry != nil { fmt.Fprintln(f.IOStreams.ErrOut, "Warning: using cached data (could not reach API)") @@ -118,7 +118,7 @@ func loadProtocols(f *cmdutil.Factory, refresh bool, cmd *cobra.Command) ([]Prot } // fetchSchemas performs a GET /api/mcp/schemas request and returns the raw body. -func fetchSchemas(f *cmdutil.Factory) (json.RawMessage, error) { +func fetchSchemas(f *cmdutil.Factory, cmd *cobra.Command) (json.RawMessage, error) { client, err := f.HTTPClient() if err != nil { return nil, fmt.Errorf("creating HTTP client: %w", err) @@ -129,7 +129,7 @@ func fetchSchemas(f *cmdutil.Factory) (json.RawMessage, error) { return nil, fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/mcp/schemas" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/mcp/schemas" req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) diff --git a/cmd/protocol/list_test.go b/cmd/protocol/list_test.go index 73a162e..1c252b7 100644 --- a/cmd/protocol/list_test.go +++ b/cmd/protocol/list_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -228,11 +227,8 @@ func TestListCmd_Table(t *testing.T) { require.NoError(t, err) out := outBuf.String() - // Check NAME and ACTIONS columns - outUpper := strings.ToUpper(out) - assert.True(t, strings.Contains(outUpper, "NAME"), "expected NAME column in output") - assert.True(t, strings.Contains(outUpper, "ACTIONS"), "expected ACTIONS column in output") - // Aave has 2 actions, Uniswap has 1 + // Test streams are non-TTY, so tsvWriter outputs data rows without headers. + // Verify protocol names and action counts are present. assert.Contains(t, out, "Aave") assert.Contains(t, out, "2") assert.Contains(t, out, "Uniswap") diff --git a/cmd/run/cancel.go b/cmd/run/cancel.go index 71d4edf..790034e 100644 --- a/cmd/run/cancel.go +++ b/cmd/run/cancel.go @@ -43,13 +43,7 @@ func NewCancelCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = cfg.DefaultHost - } - if host == "" { - host = "app.keeperhub.com" - } + host := cmdutil.ResolveHost(cmd, cfg) // Confirmation: skip if --yes or non-TTY (auto-proceed in non-interactive mode). yes, _ := cmd.Flags().GetBool("yes") diff --git a/cmd/run/logs.go b/cmd/run/logs.go index 7fc88dd..d476d4e 100644 --- a/cmd/run/logs.go +++ b/cmd/run/logs.go @@ -89,13 +89,7 @@ func NewLogsCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = cfg.DefaultHost - } - if host == "" { - host = "app.keeperhub.com" - } + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/workflows/executions/" + runID + "/logs" req, err := httpClient.NewRequest(http.MethodGet, url, nil) @@ -125,11 +119,11 @@ func NewLogsCmd(f *cmdutil.Factory) *cobra.Command { } p := output.NewPrinter(f.IOStreams, cmd) + if len(logsResp.Logs) == 0 && !p.IsJSON() { + fmt.Fprintln(f.IOStreams.Out, "No logs found.") + return nil + } return p.PrintData(logsResp, func(tw table.Writer) { - if len(logsResp.Logs) == 0 { - fmt.Fprintf(f.IOStreams.Out, "No logs available for run %s\n", runID) - return - } tw.AppendHeader(table.Row{"STEP", "STATUS", "DURATION", "INPUT", "OUTPUT"}) for _, log := range logsResp.Logs { errSuffix := "" diff --git a/cmd/run/logs_test.go b/cmd/run/logs_test.go index 48450e8..2965875 100644 --- a/cmd/run/logs_test.go +++ b/cmd/run/logs_test.go @@ -116,7 +116,7 @@ func TestLogsCmd_EmptyLogs(t *testing.T) { } out := buf.String() - if !strings.Contains(out, "No logs available") { + if !strings.Contains(out, "No logs found.") { t.Errorf("expected empty logs message, got: %q", out) } } diff --git a/cmd/run/status.go b/cmd/run/status.go index fe3439b..b2d9753 100644 --- a/cmd/run/status.go +++ b/cmd/run/status.go @@ -7,7 +7,6 @@ import ( "net/http" "time" - "github.com/jedib0t/go-pretty/v6/table" khhttp "github.com/keeperhub/cli/internal/http" "github.com/keeperhub/cli/internal/output" "github.com/keeperhub/cli/pkg/cmdutil" @@ -73,13 +72,7 @@ See also: kh r l, kh r cancel, kh wf run`, return fmt.Errorf("reading config: %w", err) } - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = cfg.DefaultHost - } - if host == "" { - host = "app.keeperhub.com" - } + host := cmdutil.ResolveHost(cmd, cfg) watch, _ := cmd.Flags().GetBool("watch") p := output.NewPrinter(f.IOStreams, cmd) @@ -115,18 +108,20 @@ See also: kh r l, kh r cancel, kh wf run`, } printSummary := func(status *RunStatusResponse) error { - return p.PrintData(status, func(tw table.Writer) { - tw.AppendHeader(table.Row{"FIELD", "VALUE"}) - tw.AppendRow(table.Row{"Status", status.Status}) - tw.AppendRow(table.Row{"Steps", fmt.Sprintf("%d/%d", status.Progress.CompletedSteps, status.Progress.TotalSteps)}) - currentStep := "-" - if status.Progress.CurrentNodeName != nil { - currentStep = *status.Progress.CurrentNodeName - } - tw.AppendRow(table.Row{"Current step", currentStep}) - tw.AppendRow(table.Row{"Percentage", fmt.Sprintf("%d%%", status.Progress.Percentage)}) - tw.Render() + if p.IsJSON() { + return p.PrintJSON(status) + } + currentStep := "-" + if status.Progress.CurrentNodeName != nil { + currentStep = *status.Progress.CurrentNodeName + } + p.PrintKeyValue([][2]string{ + {"Status", status.Status}, + {"Steps", fmt.Sprintf("%d/%d", status.Progress.CompletedSteps, status.Progress.TotalSteps)}, + {"Current step", currentStep}, + {"Percentage", fmt.Sprintf("%d%%", status.Progress.Percentage)}, }) + return nil } if !watch { diff --git a/cmd/serve/schemas.go b/cmd/serve/schemas.go index d930148..96de4c5 100644 --- a/cmd/serve/schemas.go +++ b/cmd/serve/schemas.go @@ -43,7 +43,7 @@ func fetchMCPSchemas(f *cmdutil.Factory) (*SchemasResponse, error) { return nil, fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/mcp/schemas" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(nil, cfg)) + "/api/mcp/schemas" req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) diff --git a/cmd/serve/tools.go b/cmd/serve/tools.go index bd3d364..40a6e6c 100644 --- a/cmd/serve/tools.go +++ b/cmd/serve/tools.go @@ -94,7 +94,7 @@ func MakeToolHandler(f *cmdutil.Factory, actionType string) mcp.ToolHandler { return nil, fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/execute/" + actionType + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(nil, cfg)) + "/api/execute/" + actionType httpReq, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("building request: %w", err) diff --git a/cmd/tag/create.go b/cmd/tag/create.go index 057b6ac..88f54c6 100644 --- a/cmd/tag/create.go +++ b/cmd/tag/create.go @@ -46,7 +46,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("building request body: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/tags" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/tags" req, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/tag/delete.go b/cmd/tag/delete.go index 5b21e83..2abc5c8 100644 --- a/cmd/tag/delete.go +++ b/cmd/tag/delete.go @@ -37,7 +37,7 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { } // Fetch tag name for display in the confirmation prompt. - listURL := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/tags" + listURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/tags" listReq, err := client.NewRequest(http.MethodGet, listURL, nil) if err != nil { return fmt.Errorf("building list request: %w", err) @@ -76,7 +76,7 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { } } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/tags/" + tagID + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/tags/" + tagID req, err := client.NewRequest(http.MethodDelete, url, nil) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/tag/get.go b/cmd/tag/get.go index 9cd871f..42abd61 100644 --- a/cmd/tag/get.go +++ b/cmd/tag/get.go @@ -36,7 +36,7 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/tags" + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/tags" req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { return fmt.Errorf("building request: %w", err) diff --git a/cmd/tag/list.go b/cmd/tag/list.go index a0f1f13..3f49c21 100644 --- a/cmd/tag/list.go +++ b/cmd/tag/list.go @@ -50,7 +50,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } - url := khhttp.BuildBaseURL(cfg.DefaultHost) + "/api/tags?limit=" + strconv.Itoa(limit) + url := khhttp.BuildBaseURL(cmdutil.ResolveHost(cmd, cfg)) + "/api/tags?limit=" + strconv.Itoa(limit) req, err := client.NewRequest(http.MethodGet, url, nil) if err != nil { diff --git a/cmd/template/deploy.go b/cmd/template/deploy.go index ab6d153..cfa0707 100644 --- a/cmd/template/deploy.go +++ b/cmd/template/deploy.go @@ -47,7 +47,7 @@ func NewDeployCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/workflows/" + templateID + "/duplicate" var bodyData map[string]interface{} diff --git a/cmd/template/list.go b/cmd/template/list.go index cfac681..8712455 100644 --- a/cmd/template/list.go +++ b/cmd/template/list.go @@ -65,7 +65,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("reading config: %w", err) } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/workflows/public?featured=true" req, err := client.NewRequest(http.MethodGet, url, nil) @@ -89,11 +89,11 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { } p := output.NewPrinter(f.IOStreams, cmd) + if len(templates) == 0 && !p.IsJSON() { + fmt.Fprintln(f.IOStreams.Out, "No templates found.") + return nil + } return p.PrintData(templates, func(tw table.Writer) { - if len(templates) == 0 { - fmt.Fprintln(f.IOStreams.Out, "No templates found.") - return - } tw.AppendHeader(table.Row{"NAME", "DESCRIPTION", "CATEGORY"}) for _, tpl := range templates { tw.AppendRow(table.Row{ diff --git a/cmd/wallet/balance.go b/cmd/wallet/balance.go index 89524d5..89049e1 100644 --- a/cmd/wallet/balance.go +++ b/cmd/wallet/balance.go @@ -76,7 +76,7 @@ func NewBalanceCmd(f *cmdutil.Factory) *cobra.Command { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) apiURL := khhttp.BuildBaseURL(host) + "/api/user/wallet/balances" req, err := client.NewRequest(http.MethodGet, apiURL, nil) diff --git a/cmd/wallet/tokens.go b/cmd/wallet/tokens.go index 1fa7c1b..e453d90 100644 --- a/cmd/wallet/tokens.go +++ b/cmd/wallet/tokens.go @@ -59,7 +59,7 @@ func NewTokensCmd(f *cmdutil.Factory) *cobra.Command { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) apiURL := khhttp.BuildBaseURL(host) + "/api/user/wallet/tokens?limit=" + strconv.Itoa(limit) if chain != "" { apiURL += "&chain=" + chain diff --git a/cmd/workflow/get.go b/cmd/workflow/get.go index 930f31f..823d38a 100644 --- a/cmd/workflow/get.go +++ b/cmd/workflow/get.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" - "github.com/jedib0t/go-pretty/v6/table" khhttp "github.com/keeperhub/cli/internal/http" "github.com/keeperhub/cli/internal/output" "github.com/keeperhub/cli/pkg/cmdutil" @@ -38,18 +37,26 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { # Get as JSON kh wf g abc123 --json`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := f.HTTPClient() - if err != nil { - return fmt.Errorf("creating HTTP client: %w", err) - } - cfg, err := f.Config() if err != nil { return fmt.Errorf("reading config: %w", err) } workflowID := args[0] - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) + + webMode, _ := cmd.Flags().GetBool("web") + if webMode { + webURL := khhttp.BuildBaseURL(host) + "/workflows/" + workflowID + fmt.Fprintln(f.IOStreams.Out, webURL) + return cmdutil.OpenBrowser(webURL) + } + + client, err := f.HTTPClient() + if err != nil { + return fmt.Errorf("creating HTTP client: %w", err) + } + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID req, err := client.NewRequest(http.MethodGet, url, nil) @@ -79,19 +86,24 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { } p := output.NewPrinter(f.IOStreams, cmd) - return p.PrintData(detail, func(tw table.Writer) { - tw.AppendRow(table.Row{"ID", detail.ID}) - tw.AppendRow(table.Row{"Name", detail.Name}) - tw.AppendRow(table.Row{"Status", workflowStatus(detail.Enabled)}) - tw.AppendRow(table.Row{"Visibility", detail.Visibility}) - tw.AppendRow(table.Row{"Created", detail.CreatedAt}) - tw.AppendRow(table.Row{"Updated", detail.UpdatedAt}) - tw.AppendRow(table.Row{"Nodes", len(detail.Nodes)}) - tw.AppendRow(table.Row{"Edges", len(detail.Edges)}) - tw.Render() + if p.IsJSON() { + return p.PrintJSON(detail) + } + p.PrintKeyValue([][2]string{ + {"ID", detail.ID}, + {"Name", detail.Name}, + {"Status", workflowStatus(detail.Enabled)}, + {"Visibility", detail.Visibility}, + {"Created", output.TimeAgo(detail.CreatedAt)}, + {"Updated", output.TimeAgo(detail.UpdatedAt)}, + {"Nodes", fmt.Sprintf("%d", len(detail.Nodes))}, + {"Edges", fmt.Sprintf("%d", len(detail.Edges))}, }) + return nil }, } + cmd.Flags().Bool("web", false, "Open the workflow in the browser") + return cmd } diff --git a/cmd/workflow/go_live.go b/cmd/workflow/go_live.go index b79351a..e71f99f 100644 --- a/cmd/workflow/go_live.go +++ b/cmd/workflow/go_live.go @@ -56,7 +56,7 @@ func NewGoLiveCmd(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) body := goLiveRequest{ Name: name, diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go index 1433a48..d4abbb5 100644 --- a/cmd/workflow/list.go +++ b/cmd/workflow/list.go @@ -58,7 +58,7 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) url := khhttp.BuildBaseURL(host) + "/api/workflows?limit=" + strconv.Itoa(limit) req, err := client.NewRequest(http.MethodGet, url, nil) @@ -87,11 +87,22 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("decoding response: %w", err) } + // Apply limit client-side (server does not support ?limit yet) + if limit > 0 && limit < len(workflows) { + workflows = workflows[:limit] + } + p := output.NewPrinter(f.IOStreams, cmd) + isTTY := f.IOStreams.IsTerminal() + if len(workflows) == 0 && !p.IsJSON() { + fmt.Fprintln(f.IOStreams.Out, "No workflows found.") + return nil + } return p.PrintData(workflows, func(tw table.Writer) { tw.AppendHeader(table.Row{"ID", "NAME", "STATUS", "VISIBILITY", "UPDATED"}) for _, wf := range workflows { - tw.AppendRow(table.Row{wf.ID, wf.Name, workflowStatus(wf.Enabled), wf.Visibility, wf.UpdatedAt}) + status := output.ColorStatus(workflowStatus(wf.Enabled), isTTY, false) + tw.AppendRow(table.Row{wf.ID, wf.Name, status, wf.Visibility, output.TimeAgo(wf.UpdatedAt)}) } tw.Render() }) diff --git a/cmd/workflow/pause.go b/cmd/workflow/pause.go index ac9a2b3..d608072 100644 --- a/cmd/workflow/pause.go +++ b/cmd/workflow/pause.go @@ -54,7 +54,7 @@ func NewPauseCmd(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) bodyBytes, err := json.Marshal(map[string]bool{"enabled": false}) if err != nil { diff --git a/cmd/workflow/run.go b/cmd/workflow/run.go index b80ca7f..d25cf67 100644 --- a/cmd/workflow/run.go +++ b/cmd/workflow/run.go @@ -71,7 +71,7 @@ See also: kh r st, kh r l`, if err != nil { return err } - host := cfg.DefaultHost + host := cmdutil.ResolveHost(cmd, cfg) execURL := khhttp.BuildBaseURL(host) + "/api/workflow/" + workflowID + "/execute" req, err := client.NewRequest(http.MethodPost, execURL, bytes.NewBufferString("{}")) @@ -86,7 +86,7 @@ See also: kh r st, kh r l`, } defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return khhttp.NewAPIError(resp) } diff --git a/internal/auth/keyring.go b/internal/auth/keyring.go index 4531388..b203c78 100644 --- a/internal/auth/keyring.go +++ b/internal/auth/keyring.go @@ -19,10 +19,10 @@ func openKeyring() (keyring.Keyring, error) { FileDir: config.ConfigDir(), FilePasswordFunc: keyring.FixedStringPrompt(""), AllowedBackends: []keyring.BackendType{ + keyring.FileBackend, keyring.KeychainBackend, keyring.WinCredBackend, keyring.SecretServiceBackend, - keyring.FileBackend, }, }) } diff --git a/internal/output/color.go b/internal/output/color.go new file mode 100644 index 0000000..0ac722a --- /dev/null +++ b/internal/output/color.go @@ -0,0 +1,29 @@ +package output + +// ANSI escape codes for terminal colors. +const ( + ansiReset = "\033[0m" + ansiGreen = "\033[32m" + ansiRed = "\033[31m" + ansiYellow = "\033[33m" +) + +// ColorStatus wraps a status string with ANSI color codes for TTY display. +// Green: active, success. Red: error, failed. Yellow: paused, running, pending. +// Returns the unmodified string when not a TTY or when noColor is true. +func ColorStatus(status string, isTTY bool, noColor bool) string { + if !isTTY || noColor { + return status + } + + switch status { + case "active", "success": + return ansiGreen + status + ansiReset + case "error", "failed": + return ansiRed + status + ansiReset + case "paused", "running", "pending": + return ansiYellow + status + ansiReset + default: + return status + } +} diff --git a/internal/output/color_test.go b/internal/output/color_test.go new file mode 100644 index 0000000..c581546 --- /dev/null +++ b/internal/output/color_test.go @@ -0,0 +1,34 @@ +package output + +import "testing" + +func TestColorStatus(t *testing.T) { + tests := []struct { + name string + status string + isTTY bool + noColor bool + want string + }{ + {"green active tty", "active", true, false, ansiGreen + "active" + ansiReset}, + {"green success tty", "success", true, false, ansiGreen + "success" + ansiReset}, + {"red error tty", "error", true, false, ansiRed + "error" + ansiReset}, + {"red failed tty", "failed", true, false, ansiRed + "failed" + ansiReset}, + {"yellow paused tty", "paused", true, false, ansiYellow + "paused" + ansiReset}, + {"yellow running tty", "running", true, false, ansiYellow + "running" + ansiReset}, + {"yellow pending tty", "pending", true, false, ansiYellow + "pending" + ansiReset}, + {"unknown status unchanged", "draft", true, false, "draft"}, + {"not tty returns plain", "active", false, false, "active"}, + {"noColor returns plain", "error", true, true, "error"}, + {"not tty and noColor", "success", false, true, "success"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ColorStatus(tt.status, tt.isTTY, tt.noColor) + if got != tt.want { + t.Errorf("ColorStatus(%q, %v, %v) = %q, want %q", tt.status, tt.isTTY, tt.noColor, got, tt.want) + } + }) + } +} diff --git a/internal/output/printer.go b/internal/output/printer.go index 0248625..32a8543 100644 --- a/internal/output/printer.go +++ b/internal/output/printer.go @@ -83,6 +83,34 @@ func (p *Printer) PrintDryRun(message string) { fmt.Fprintf(p.out, "[dry-run] %s\n", message) } +// PrintKeyValue prints key-value pairs in a left-aligned format. +// In TTY mode: keys are padded to the longest key width with a colon and tab. +// In non-TTY mode: same tab-separated format. +// Example: +// +// ID: abc123 +// Name: My Workflow +// Status: active +func (p *Printer) PrintKeyValue(pairs [][2]string) { + if len(pairs) == 0 { + return + } + maxKeyLen := 0 + for _, pair := range pairs { + if len(pair[0]) > maxKeyLen { + maxKeyLen = len(pair[0]) + } + } + for _, pair := range pairs { + // pad key with trailing spaces so values align + padded := pair[0] + ":" + for len(padded) < maxKeyLen+2 { + padded += " " + } + fmt.Fprintf(p.out, "%s\t%s\n", padded, pair[1]) + } +} + // PrintError writes an error message to stderr. // In JSON mode it writes {"error": "...", "code": N}. // In table mode it writes plain text. diff --git a/internal/output/table.go b/internal/output/table.go index da462e3..6c176b6 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -1,21 +1,97 @@ package output import ( + "fmt" "io" + "strings" "github.com/jedib0t/go-pretty/v6/table" ) -// NewTable returns a go-pretty table.Writer configured to write to w. -// When isTTY is true, table.StyleLight is used (box-drawing characters). -// When false, table.StyleDefault is used (ASCII, suitable for piped output). +// tsvWriter implements table.Writer but writes tab-separated values +// with no headers and no borders, matching gh CLI non-TTY output. +type tsvWriter struct { + w io.Writer + rows [][]string +} + +func (t *tsvWriter) AppendHeader(_ table.Row, _ ...table.RowConfig) {} +func (t *tsvWriter) AppendRow(row table.Row, _ ...table.RowConfig) { + cols := make([]string, len(row)) + for i, v := range row { + cols[i] = fmt.Sprintf("%v", v) + } + t.rows = append(t.rows, cols) +} +func (t *tsvWriter) AppendRows(rows []table.Row, _ ...table.RowConfig) { + for _, row := range rows { + t.AppendRow(row) + } +} +func (t *tsvWriter) Render() string { + var sb strings.Builder + for _, cols := range t.rows { + line := strings.Join(cols, "\t") + sb.WriteString(line) + sb.WriteString("\n") + } + out := sb.String() + fmt.Fprint(t.w, out) + return out +} + +func (t *tsvWriter) AppendFooter(_ table.Row, _ ...table.RowConfig) {} +func (t *tsvWriter) AppendSeparator() {} +func (t *tsvWriter) FilterBy(_ []table.FilterBy) {} +func (t *tsvWriter) ImportGrid(_ interface{}) bool { return false } +func (t *tsvWriter) Length() int { return len(t.rows) } +func (t *tsvWriter) Pager(_ ...table.PagerOption) table.Pager { return nil } +func (t *tsvWriter) RenderCSV() string { return t.Render() } +func (t *tsvWriter) RenderHTML() string { return t.Render() } +func (t *tsvWriter) RenderMarkdown() string { return t.Render() } +func (t *tsvWriter) RenderTSV() string { return t.Render() } +func (t *tsvWriter) ResetFooters() {} +func (t *tsvWriter) ResetHeaders() {} +func (t *tsvWriter) ResetRows() { t.rows = nil } +func (t *tsvWriter) SetAutoIndex(_ bool) {} +func (t *tsvWriter) SetCaption(_ string, _ ...interface{}) {} +func (t *tsvWriter) SetColumnConfigs(_ []table.ColumnConfig) {} +func (t *tsvWriter) SetIndexColumn(_ int) {} +func (t *tsvWriter) SetOutputMirror(_ io.Writer) {} +func (t *tsvWriter) SetRowPainter(_ interface{}) {} +func (t *tsvWriter) SetStyle(_ table.Style) {} +func (t *tsvWriter) SetTitle(_ string, _ ...interface{}) {} +func (t *tsvWriter) SortBy(_ []table.SortBy) {} +func (t *tsvWriter) Style() *table.Style { return nil } +func (t *tsvWriter) SuppressEmptyColumns() {} +func (t *tsvWriter) SuppressTrailingSpaces() {} +func (t *tsvWriter) SetAllowedRowLength(_ int) {} +func (t *tsvWriter) SetHTMLCSSClass(_ string) {} +func (t *tsvWriter) SetPageSize(_ int) {} + +// NewTable returns a table.Writer configured for the current output context. +// When isTTY is true, a go-pretty table with StyleLight (box-drawing) is returned. +// When false, a lightweight TSV writer is returned that outputs tab-separated +// values with no headers or borders, making `kh wf ls | cut -f2` work like gh. func NewTable(w io.Writer, isTTY bool) table.Writer { + if !isTTY { + return &tsvWriter{w: w} + } tw := table.NewWriter() tw.SetOutputMirror(w) - if isTTY { - tw.SetStyle(table.StyleLight) - } else { - tw.SetStyle(table.StyleDefault) - } + tw.SetStyle(table.Style{ + Name: "clean", + Box: table.StyleBoxDefault, + Format: table.FormatOptions{ + Header: 0, + }, + Options: table.Options{ + DrawBorder: false, + SeparateColumns: true, + SeparateFooter: false, + SeparateHeader: false, + SeparateRows: false, + }, + }) return tw } diff --git a/internal/output/table_test.go b/internal/output/table_test.go index c15aef9..d0b55a2 100644 --- a/internal/output/table_test.go +++ b/internal/output/table_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/keeperhub/cli/internal/output" "github.com/jedib0t/go-pretty/v6/table" + "github.com/keeperhub/cli/internal/output" ) func TestNewTable_ReturnWriter(t *testing.T) { @@ -17,18 +17,75 @@ func TestNewTable_ReturnWriter(t *testing.T) { } } -func TestNewTable_RendersRowsWithHeaders(t *testing.T) { +func TestNewTable_NonTTY_TSVNoHeaders(t *testing.T) { var buf bytes.Buffer tw := output.NewTable(&buf, false) tw.AppendHeader(table.Row{"Name", "Status"}) tw.AppendRow(table.Row{"my-workflow", "active"}) tw.Render() + got := buf.String() + // Non-TTY should suppress headers + if strings.Contains(got, "NAME") || strings.Contains(got, "Name") { + t.Errorf("non-TTY output should not contain headers, got: %s", got) + } + // Should contain tab-separated row data + if !strings.Contains(got, "my-workflow\tactive") { + t.Errorf("expected tab-separated row data, got: %s", got) + } +} + +func TestNewTable_TTY_HasHeaders(t *testing.T) { + var buf bytes.Buffer + tw := output.NewTable(&buf, true) + tw.AppendHeader(table.Row{"Name", "Status"}) + tw.AppendRow(table.Row{"my-workflow", "active"}) + tw.Render() + got := buf.String() if !strings.Contains(got, "NAME") && !strings.Contains(got, "Name") { - t.Errorf("expected header Name/NAME in output, got: %s", got) + t.Errorf("TTY output should contain headers, got: %s", got) } if !strings.Contains(got, "my-workflow") { t.Errorf("expected row data in output, got: %s", got) } } + +func TestNewTable_NonTTY_MultipleRows(t *testing.T) { + var buf bytes.Buffer + tw := output.NewTable(&buf, false) + tw.AppendHeader(table.Row{"ID", "Name"}) + tw.AppendRow(table.Row{"1", "alpha"}) + tw.AppendRow(table.Row{"2", "beta"}) + tw.Render() + + got := buf.String() + lines := strings.Split(strings.TrimSpace(got), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), got) + } + if lines[0] != "1\talpha" { + t.Errorf("expected first line '1\\talpha', got %q", lines[0]) + } + if lines[1] != "2\tbeta" { + t.Errorf("expected second line '2\\tbeta', got %q", lines[1]) + } +} + +func TestNewTable_NonTTY_CutCompatible(t *testing.T) { + var buf bytes.Buffer + tw := output.NewTable(&buf, false) + tw.AppendHeader(table.Row{"ID", "Name", "Status"}) + tw.AppendRow(table.Row{"abc", "my-wf", "active"}) + tw.Render() + + got := buf.String() + // Simulates `cut -f2` by splitting on tab + fields := strings.Split(strings.TrimSpace(got), "\t") + if len(fields) != 3 { + t.Fatalf("expected 3 tab-separated fields, got %d: %q", len(fields), got) + } + if fields[1] != "my-wf" { + t.Errorf("cut -f2 should yield 'my-wf', got %q", fields[1]) + } +} diff --git a/internal/output/timeago.go b/internal/output/timeago.go new file mode 100644 index 0000000..33bff50 --- /dev/null +++ b/internal/output/timeago.go @@ -0,0 +1,45 @@ +package output + +import ( + "fmt" + "time" +) + +// TimeAgo converts an ISO 8601 timestamp string to a human-friendly relative +// time like "2h ago", "3d ago", or "just now". On parse error it returns the +// original string unchanged. +func TimeAgo(isoString string) string { + t, err := time.Parse(time.RFC3339, isoString) + if err != nil { + t, err = time.Parse(time.RFC3339Nano, isoString) + if err != nil { + return isoString + } + } + return timeAgoSince(time.Since(t)) +} + +func timeAgoSince(d time.Duration) string { + seconds := int(d.Seconds()) + if seconds < 60 { + return "just now" + } + minutes := seconds / 60 + if minutes < 60 { + return fmt.Sprintf("%dm ago", minutes) + } + hours := minutes / 60 + if hours < 24 { + return fmt.Sprintf("%dh ago", hours) + } + days := hours / 24 + if days < 30 { + return fmt.Sprintf("%dd ago", days) + } + months := days / 30 + if months < 12 { + return fmt.Sprintf("%dmo ago", months) + } + years := months / 12 + return fmt.Sprintf("%dy ago", years) +} diff --git a/internal/output/timeago_test.go b/internal/output/timeago_test.go new file mode 100644 index 0000000..7ef3fec --- /dev/null +++ b/internal/output/timeago_test.go @@ -0,0 +1,97 @@ +package output + +import ( + "testing" + "time" +) + +func TestTimeAgo(t *testing.T) { + now := time.Now().UTC() + + tests := []struct { + name string + input string + want string + }{ + { + name: "just now", + input: now.Add(-10 * time.Second).Format(time.RFC3339), + want: "just now", + }, + { + name: "minutes ago", + input: now.Add(-5 * time.Minute).Format(time.RFC3339), + want: "5m ago", + }, + { + name: "hours ago", + input: now.Add(-2 * time.Hour).Format(time.RFC3339), + want: "2h ago", + }, + { + name: "days ago", + input: now.Add(-3 * 24 * time.Hour).Format(time.RFC3339), + want: "3d ago", + }, + { + name: "months ago", + input: now.Add(-65 * 24 * time.Hour).Format(time.RFC3339), + want: "2mo ago", + }, + { + name: "years ago", + input: now.Add(-400 * 24 * time.Hour).Format(time.RFC3339), + want: "1y ago", + }, + { + name: "invalid string returned as-is", + input: "not-a-date", + want: "not-a-date", + }, + { + name: "RFC3339Nano format", + input: now.Add(-30 * time.Minute).Format(time.RFC3339Nano), + want: "30m ago", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TimeAgo(tt.input) + if got != tt.want { + t.Errorf("TimeAgo(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestTimeAgoSince(t *testing.T) { + tests := []struct { + name string + duration time.Duration + want string + }{ + {"zero", 0, "just now"}, + {"30 seconds", 30 * time.Second, "just now"}, + {"59 seconds", 59 * time.Second, "just now"}, + {"1 minute", 60 * time.Second, "1m ago"}, + {"45 minutes", 45 * time.Minute, "45m ago"}, + {"1 hour", 60 * time.Minute, "1h ago"}, + {"23 hours", 23 * time.Hour, "23h ago"}, + {"1 day", 24 * time.Hour, "1d ago"}, + {"29 days", 29 * 24 * time.Hour, "29d ago"}, + {"30 days", 30 * 24 * time.Hour, "1mo ago"}, + {"11 months", 330 * 24 * time.Hour, "11mo ago"}, + {"12 months", 365 * 24 * time.Hour, "1y ago"}, + {"2 years", 730 * 24 * time.Hour, "2y ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := timeAgoSince(tt.duration) + if got != tt.want { + t.Errorf("timeAgoSince(%v) = %q, want %q", tt.duration, got, tt.want) + } + }) + } +} diff --git a/internal/output/truncate.go b/internal/output/truncate.go new file mode 100644 index 0000000..59e1927 --- /dev/null +++ b/internal/output/truncate.go @@ -0,0 +1,27 @@ +package output + +import ( + "os" + + "golang.org/x/term" +) + +// TerminalWidth returns the width of the terminal connected to stdout. +// Returns 120 as a sensible default if the width cannot be determined. +func TerminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 120 + } + return width +} + +// TruncateString truncates s to max characters. If s exceeds max, it is +// shortened and "..." is appended. max must be at least 4 for truncation +// to apply; otherwise the original string is returned. +func TruncateString(s string, max int) string { + if max < 4 || len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/internal/output/truncate_test.go b/internal/output/truncate_test.go new file mode 100644 index 0000000..2c3ed8a --- /dev/null +++ b/internal/output/truncate_test.go @@ -0,0 +1,29 @@ +package output + +import "testing" + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + s string + max int + want string + }{ + {"short string unchanged", "hello", 10, "hello"}, + {"exact length unchanged", "hello", 5, "hello"}, + {"truncated with ellipsis", "hello world", 8, "hello..."}, + {"max too small returns original", "hello", 3, "hello"}, + {"max of 4 truncates", "hello", 4, "h..."}, + {"empty string", "", 10, ""}, + {"single char under max", "a", 10, "a"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateString(tt.s, tt.max) + if got != tt.want { + t.Errorf("TruncateString(%q, %d) = %q, want %q", tt.s, tt.max, got, tt.want) + } + }) + } +} diff --git a/pkg/cmdutil/browser.go b/pkg/cmdutil/browser.go new file mode 100644 index 0000000..896ba27 --- /dev/null +++ b/pkg/cmdutil/browser.go @@ -0,0 +1,22 @@ +package cmdutil + +import ( + "fmt" + "os/exec" + "runtime" +) + +// OpenBrowser opens the given URL in the user's default browser. +// It uses "open" on macOS, "xdg-open" on Linux, and "cmd /c start" on Windows. +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("cmd", "/c", "start", url).Start() + default: + return fmt.Errorf("unsupported platform %s", runtime.GOOS) + } +} diff --git a/pkg/cmdutil/host.go b/pkg/cmdutil/host.go new file mode 100644 index 0000000..1470dc1 --- /dev/null +++ b/pkg/cmdutil/host.go @@ -0,0 +1,33 @@ +package cmdutil + +import ( + "os" + + "github.com/keeperhub/cli/internal/config" + "github.com/spf13/cobra" +) + +// ResolveHost resolves the target host using the priority chain: +// --host flag > KH_HOST env > cfg.DefaultHost > "app.keeperhub.com". +func ResolveHost(cmd *cobra.Command, cfg config.Config) string { + if cmd != nil { + root := cmd.Root() + if root != nil { + if fl := root.PersistentFlags().Lookup("host"); fl != nil { + if v := fl.Value.String(); v != "" { + return v + } + } + } + } + + if env := os.Getenv("KH_HOST"); env != "" { + return env + } + + if cfg.DefaultHost != "" { + return cfg.DefaultHost + } + + return "app.keeperhub.com" +} diff --git a/pkg/cmdutil/host_test.go b/pkg/cmdutil/host_test.go new file mode 100644 index 0000000..3ad8529 --- /dev/null +++ b/pkg/cmdutil/host_test.go @@ -0,0 +1,73 @@ +package cmdutil + +import ( + "testing" + + "github.com/keeperhub/cli/internal/config" + "github.com/spf13/cobra" +) + +func makeRootCmd(hostFlag string) *cobra.Command { + root := &cobra.Command{Use: "kh"} + root.PersistentFlags().StringP("host", "H", "", "KeeperHub host") + if hostFlag != "" { + _ = root.PersistentFlags().Set("host", hostFlag) + } + child := &cobra.Command{Use: "test"} + root.AddCommand(child) + return child +} + +func TestResolveHost_FlagTakesPriority(t *testing.T) { + cmd := makeRootCmd("http://localhost:3000") + cfg := config.Config{DefaultHost: "staging.keeperhub.com"} + t.Setenv("KH_HOST", "env.keeperhub.com") + + got := ResolveHost(cmd, cfg) + if got != "http://localhost:3000" { + t.Errorf("expected flag host, got %q", got) + } +} + +func TestResolveHost_EnvFallback(t *testing.T) { + cmd := makeRootCmd("") + cfg := config.Config{DefaultHost: "staging.keeperhub.com"} + t.Setenv("KH_HOST", "env.keeperhub.com") + + got := ResolveHost(cmd, cfg) + if got != "env.keeperhub.com" { + t.Errorf("expected env host, got %q", got) + } +} + +func TestResolveHost_ConfigFallback(t *testing.T) { + cmd := makeRootCmd("") + cfg := config.Config{DefaultHost: "staging.keeperhub.com"} + t.Setenv("KH_HOST", "") + + got := ResolveHost(cmd, cfg) + if got != "staging.keeperhub.com" { + t.Errorf("expected config host, got %q", got) + } +} + +func TestResolveHost_BuiltInDefault(t *testing.T) { + cmd := makeRootCmd("") + cfg := config.Config{} + t.Setenv("KH_HOST", "") + + got := ResolveHost(cmd, cfg) + if got != "app.keeperhub.com" { + t.Errorf("expected built-in default, got %q", got) + } +} + +func TestResolveHost_NilCmd(t *testing.T) { + cfg := config.Config{DefaultHost: "staging.keeperhub.com"} + t.Setenv("KH_HOST", "") + + got := ResolveHost(nil, cfg) + if got != "staging.keeperhub.com" { + t.Errorf("expected config host with nil cmd, got %q", got) + } +}