diff --git a/README.md b/README.md index c027f97..0d92e22 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,19 @@ cu field set Machine "Chilastra,Sunrunner" cu field clear "Last synced" ``` +### Machine-readable output + +Every command that produces output honours `-o json|yaml|csv`, so cu is +scriptable and usable by AI agents without screen-scraping: + +```bash +cu version -o json +cu auth status -o json # reports the unauthenticated state as data too +cu config list -o json # credentials are redacted in every format +cu task list -o json | jq '.[].id' +cu bulk close t1 t2 --yes -o json # per-task results, not just progress lines +``` + ### Cache Management ```bash # View cache statistics diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index ec6e61f..c5d69f9 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/timimsms/cu/internal/auth" "github.com/timimsms/cu/internal/config" + "github.com/timimsms/cu/internal/output" ) var authCmd = &cobra.Command{ @@ -86,6 +87,14 @@ var authLoginCmd = &cobra.Command{ }, } +// authStatus is the machine-readable form of `cu auth status`. +type authStatus struct { + Authenticated bool `json:"authenticated" yaml:"authenticated"` + Workspace string `json:"workspace" yaml:"workspace"` + Email string `json:"email,omitempty" yaml:"email,omitempty"` + TokenSource string `json:"token_source" yaml:"token_source"` +} + var authStatusCmd = &cobra.Command{ Use: "status", Short: "Show authentication status", @@ -98,6 +107,29 @@ var authStatusCmd = &cobra.Command{ } token, err := authMgr.GetToken(workspace) + + // Structured consumers need the same answer the human output gives, + // including the unauthenticated case — which is a state, not a crash, + // so it is reported as data while still exiting non-zero. + if outputFormat != "table" { + status := authStatus{ + Authenticated: err == nil, + Workspace: workspace, + TokenSource: "keyring", + } + if err == nil && token.Email != "" { + status.Email = token.Email + } + if ferr := output.Format(outputFormat, status); ferr != nil { + fmt.Fprintf(os.Stderr, "Failed to format output: %v\n", ferr) + os.Exit(1) + } + if err != nil { + os.Exit(1) + } + return + } + if err != nil { fmt.Println("Not authenticated") fmt.Println("\nRun 'cu auth login' to authenticate") diff --git a/internal/cmd/bulk.go b/internal/cmd/bulk.go index 736d5f9..2e1b7e8 100644 --- a/internal/cmd/bulk.go +++ b/internal/cmd/bulk.go @@ -12,6 +12,64 @@ import ( "github.com/timimsms/cu/internal/output" ) +// bulkOutcome is the per-task result of a bulk operation, and bulkSummary the +// whole run. Progress lines are for a human watching; a scripted caller needs +// to know which ids failed and why, which the progress output cannot express. +type bulkOutcome struct { + TaskID string `json:"task_id"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type bulkSummary struct { + Operation string `json:"operation"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Results []bulkOutcome `json:"results"` +} + +// record appends one task's outcome and prints the human progress line. The +// line is suppressed outside table output, where it would otherwise interleave +// with the structured document on stdout and make it unparseable. +func (b *bulkSummary) record(taskID string, err error) { + if err != nil { + b.Failed++ + b.Results = append(b.Results, bulkOutcome{TaskID: taskID, OK: false, Error: err.Error()}) + human(" ✗ %s: %v", taskID, err) + return + } + b.Succeeded++ + b.Results = append(b.Results, bulkOutcome{TaskID: taskID, OK: true}) + human(" ✓ %s", taskID) +} + +// finish emits the summary in whichever format was requested and exits +// non-zero if any task failed. +func (b bulkSummary) finish() { + if outputFormat != "table" { + if err := output.Format(outputFormat, b); err != nil { + fmt.Fprintf(os.Stderr, "Failed to format output: %v\n", err) + os.Exit(1) + } + } else { + fmt.Printf("\nSummary:\n") + fmt.Printf(" Success: %d\n", b.Succeeded) + fmt.Printf(" Failed: %d\n", b.Failed) + } + + if b.Failed > 0 { + os.Exit(1) + } +} + +// human prints progress intended for a person watching, and only then. +func human(format string, a ...interface{}) { + if outputFormat != "table" { + return + } + fmt.Printf(format+"\n", a...) +} + var bulkCmd = &cobra.Command{ Use: "bulk", Short: "Perform bulk operations on tasks", @@ -124,28 +182,15 @@ Examples: } // Update tasks - var successCount, errorCount int + summary := bulkSummary{Operation: "update"} - fmt.Println("\nUpdating tasks...") + human("\nUpdating tasks...") for _, taskID := range taskIDs { _, err := client.UpdateTask(ctx, taskID, updateOpts) - if err != nil { - errorCount++ - fmt.Printf(" ✗ %s: %v\n", taskID, err) - } else { - successCount++ - fmt.Printf(" ✓ %s\n", taskID) - } + summary.record(taskID, err) } - // Summary - fmt.Printf("\nSummary:\n") - fmt.Printf(" Success: %d\n", successCount) - fmt.Printf(" Failed: %d\n", errorCount) - - if errorCount > 0 { - os.Exit(1) - } + summary.finish() }, } @@ -210,39 +255,25 @@ Examples: status, _ := cmd.Flags().GetString("status") resolver := api.NewStatusResolver(client) - var successCount, errorCount int + summary := bulkSummary{Operation: "close"} - fmt.Println("Closing tasks...") + human("Closing tasks...") for _, taskID := range taskIDs { taskStatus := status if taskStatus == "" { resolved, err := resolver.ClosedStatusForTask(ctx, taskID) if err != nil { - errorCount++ - fmt.Printf(" ✗ %s: %v\n", taskID, err) + summary.record(taskID, err) continue } taskStatus = resolved } _, err := client.UpdateTask(ctx, taskID, &api.TaskUpdateOptions{Status: taskStatus}) - if err != nil { - errorCount++ - fmt.Printf(" ✗ %s: %v\n", taskID, err) - } else { - successCount++ - fmt.Printf(" ✓ %s\n", taskID) - } + summary.record(taskID, err) } - // Summary - fmt.Printf("\nSummary:\n") - fmt.Printf(" Success: %d\n", successCount) - fmt.Printf(" Failed: %d\n", errorCount) - - if errorCount > 0 { - os.Exit(1) - } + summary.finish() }, } @@ -303,38 +334,14 @@ Examples: } // Delete tasks - var successCount, errorCount int - var deletedTasks []string + summary := bulkSummary{Operation: "delete"} - fmt.Println("Deleting tasks...") + human("Deleting tasks...") for _, taskID := range taskIDs { - err := client.DeleteTask(ctx, taskID) - if err != nil { - errorCount++ - fmt.Printf(" ✗ %s: %v\n", taskID, err) - } else { - successCount++ - deletedTasks = append(deletedTasks, taskID) - fmt.Printf(" ✓ %s\n", taskID) - } + summary.record(taskID, client.DeleteTask(ctx, taskID)) } - // Summary - fmt.Printf("\nSummary:\n") - fmt.Printf(" Deleted: %d\n", successCount) - fmt.Printf(" Failed: %d\n", errorCount) - - // Output deleted task IDs for potential recovery scripts - format := cmd.Flag("output").Value.String() - if format != "table" && len(deletedTasks) > 0 { - if err := output.Format(format, deletedTasks); err != nil { - fmt.Fprintf(os.Stderr, "Failed to format output: %v\n", err) - } - } - - if errorCount > 0 { - os.Exit(1) - } + summary.finish() }, } diff --git a/internal/cmd/bulk_test.go b/internal/cmd/bulk_test.go index 157cdb9..c678c5d 100644 --- a/internal/cmd/bulk_test.go +++ b/internal/cmd/bulk_test.go @@ -1,11 +1,13 @@ package cmd import ( + "errors" "strings" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestBulkCommand_Structure(t *testing.T) { @@ -88,3 +90,24 @@ func TestBulkCommand_Structure(t *testing.T) { } }) } + +func TestBulkSummaryRecord(t *testing.T) { + // Progress lines are suppressed outside table output; with outputFormat + // unset (the zero value) `human` stays quiet, so this exercises the + // accounting without writing to stdout. + var s bulkSummary + + s.record("t1", nil) + s.record("t2", errors.New("boom")) + s.record("t3", nil) + + assert.Equal(t, 2, s.Succeeded) + assert.Equal(t, 1, s.Failed) + require.Len(t, s.Results, 3) + + assert.Equal(t, bulkOutcome{TaskID: "t1", OK: true}, s.Results[0]) + assert.Equal(t, "t2", s.Results[1].TaskID) + assert.False(t, s.Results[1].OK) + assert.Equal(t, "boom", s.Results[1].Error, "the failure reason must survive into structured output") + assert.True(t, s.Results[2].OK) +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index c0e260d..3fc4431 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -30,16 +30,30 @@ var configListCmd = &cobra.Command{ } sort.Strings(keys) + // A token here predates the refusal in `config set`, or was added by + // hand. Never print it: `config list` output gets pasted into issues + // and terminals far more casually than an explicit `config get ` + // does. Redaction happens once, before rendering, so every output + // format inherits it rather than each one re-deciding. + values := make(map[string]interface{}, len(settings)) for _, key := range keys { - // A token here predates the refusal in `config set`, or was added - // by hand. Never print it: `config list` output gets pasted into - // issues and terminals far more casually than an explicit - // `config get ` does. if config.IsCredentialKey(key) { - fmt.Printf("%s=%s\n", key, config.RedactedValue) + values[key] = config.RedactedValue continue } - fmt.Printf("%s=%v\n", key, settings[key]) + values[key] = settings[key] + } + + if outputFormat != "table" { + if err := output.Format(outputFormat, values); err != nil { + fmt.Fprintf(os.Stderr, "Failed to format output: %v\n", err) + os.Exit(1) + } + return + } + + for _, key := range keys { + fmt.Printf("%s=%v\n", key, values[key]) } }, } @@ -65,10 +79,21 @@ var configGetCmd = &cobra.Command{ // value here is an unused plaintext leftover. The pointer goes to // stderr so it reaches a person without joining piped output. if config.IsCredentialKey(key) { - fmt.Println(config.RedactedValue) + value = config.RedactedValue fmt.Fprintf(os.Stderr, "%q is not printed. cu authenticates via the system keyring; this value is an unused plaintext leftover.\nTo read or remove it, edit %s directly.\n", key, config.GlobalConfigPath()) + } + + if outputFormat != "table" { + // Encoded as a bare value, not wrapped in an object: `config get` + // asks for one setting, so the result should be usable directly. + // Redaction above applies here too — a format flag must not be a + // way around it. + if err := output.Format(outputFormat, value); err != nil { + fmt.Fprintf(os.Stderr, "Failed to format output: %v\n", err) + os.Exit(1) + } return } diff --git a/internal/cmd/field.go b/internal/cmd/field.go index d8c7ddd..3ad4884 100644 --- a/internal/cmd/field.go +++ b/internal/cmd/field.go @@ -29,6 +29,16 @@ func newFieldRow(f *clickup.CustomField) fieldRow { } } +// fieldWriteResult reports what a set or clear actually wrote, so a scripted +// caller can confirm which field was resolved rather than re-reading the task. +type fieldWriteResult struct { + TaskID string `json:"task_id"` + Field string `json:"field"` + FieldID string `json:"field_id"` + Value string `json:"value,omitempty"` + Cleared bool `json:"cleared"` +} + var fieldCmd = &cobra.Command{ Use: "field", Short: "Manage custom field values", @@ -137,6 +147,12 @@ Examples: return err } + if outputFormat != "table" { + return output.Format(outputFormat, fieldWriteResult{ + TaskID: taskID, Field: field.Name, FieldID: field.ID, Value: raw, Cleared: false, + }) + } + fmt.Printf("Set %s on task %s\n", field.Name, taskID) return nil }, @@ -164,6 +180,12 @@ var fieldClearCmd = &cobra.Command{ return err } + if outputFormat != "table" { + return output.Format(outputFormat, fieldWriteResult{ + TaskID: taskID, Field: field.Name, FieldID: field.ID, Cleared: true, + }) + } + fmt.Printf("Cleared %s on task %s\n", field.Name, taskID) return nil }, diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 0064435..c743129 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -2,16 +2,43 @@ package cmd import ( "fmt" + "runtime" "github.com/spf13/cobra" + "github.com/timimsms/cu/internal/output" "github.com/timimsms/cu/internal/version" ) +// versionInfo is the machine-readable form of the build details that +// version.FullVersion renders for humans. +type versionInfo struct { + Version string `json:"version" yaml:"version"` + Commit string `json:"commit" yaml:"commit"` + Date string `json:"date" yaml:"date"` + BuiltBy string `json:"built_by" yaml:"built_by"` + GoVersion string `json:"go_version" yaml:"go_version"` + OS string `json:"os" yaml:"os"` + Arch string `json:"arch" yaml:"arch"` +} + var versionCmd = &cobra.Command{ Use: "version", Short: "Show cu version information", Long: `Display the version of cu along with build information.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println(version.FullVersion()) + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat == "table" { + fmt.Println(version.FullVersion()) + return nil + } + + return output.Format(outputFormat, versionInfo{ + Version: version.Version, + Commit: version.Commit, + Date: version.Date, + BuiltBy: version.BuiltBy, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }) }, } diff --git a/internal/cmd/version_test.go b/internal/cmd/version_test.go index 4b62f8c..1269ef3 100644 --- a/internal/cmd/version_test.go +++ b/internal/cmd/version_test.go @@ -13,6 +13,8 @@ func TestVersionCommand_Structure(t *testing.T) { assert.NotNil(t, cmd) assert.Equal(t, "version", cmd.Use) assert.NotEmpty(t, cmd.Short) - assert.NotNil(t, cmd.Run) + // RunE rather than Run: formatting can fail, and the error should + // propagate as a non-zero exit rather than being swallowed. + assert.True(t, cmd.Run != nil || cmd.RunE != nil, "version command must be runnable") }) }