Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ cu field set <task-id> Machine "Chilastra,Sunrunner"
cu field clear <task-id> "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
Expand Down
32 changes: 32 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand Down
135 changes: 71 additions & 64 deletions internal/cmd/bulk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
},
}

Expand Down Expand Up @@ -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()
},
}

Expand Down Expand Up @@ -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()
},
}

Expand Down
23 changes: 23 additions & 0 deletions internal/cmd/bulk_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
39 changes: 32 additions & 7 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`
// 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 <key>` 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])
}
},
}
Expand All @@ -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
}

Expand Down
Loading
Loading