Skip to content

Commit 969135e

Browse files
feat(cli): improve agent ergonomics
1 parent 0e91384 commit 969135e

132 files changed

Lines changed: 6864 additions & 2111 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/cmd/agent.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package cmd
2+
3+
// AgentCmd contains helper commands intended to make gog easier to consume from LLM agents.
4+
type AgentCmd struct {
5+
ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes,exit-code" help:"Print stable exit codes for automation"`
6+
}

internal/cmd/agent_cmd_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/steipete/gogcli/internal/outfmt"
9+
)
10+
11+
func TestAgentExitCodes_JSON(t *testing.T) {
12+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
13+
14+
out := captureStdout(t, func() {
15+
if err := (&AgentExitCodesCmd{}).Run(ctx); err != nil {
16+
t.Fatalf("Run: %v", err)
17+
}
18+
})
19+
20+
var doc struct {
21+
ExitCodes map[string]int `json:"exit_codes"`
22+
}
23+
if err := json.Unmarshal([]byte(out), &doc); err != nil {
24+
t.Fatalf("unmarshal: %v (out=%q)", err, out)
25+
}
26+
if doc.ExitCodes["empty_results"] != emptyResultsExitCode {
27+
t.Fatalf("expected empty_results=%d, got %d", emptyResultsExitCode, doc.ExitCodes["empty_results"])
28+
}
29+
if doc.ExitCodes["auth_required"] != exitCodeAuthRequired {
30+
t.Fatalf("expected auth_required=%d, got %d", exitCodeAuthRequired, doc.ExitCodes["auth_required"])
31+
}
32+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestRootDesirePaths_HelpParses(t *testing.T) {
9+
tests := [][]string{
10+
{"send", "--help"},
11+
{"ls", "--help"},
12+
{"search", "--help"},
13+
{"download", "--help"},
14+
{"upload", "--help"},
15+
{"open", "--help"},
16+
{"login", "--help"},
17+
{"logout", "--help"},
18+
{"status", "--help"},
19+
{"me", "--help"},
20+
{"whoami", "--help"},
21+
{"exit-codes", "--help"},
22+
{"agent", "--help"},
23+
}
24+
25+
for _, args := range tests {
26+
args := args
27+
t.Run(strings.Join(args, " "), func(t *testing.T) {
28+
_ = captureStdout(t, func() {
29+
_ = captureStderr(t, func() {
30+
if err := Execute(args); err != nil {
31+
t.Fatalf("Execute(%v): %v", args, err)
32+
}
33+
})
34+
})
35+
})
36+
}
37+
}
38+
39+
func TestDesirePaths_GlobalFlagAliases(t *testing.T) {
40+
out := captureStdout(t, func() {
41+
_ = captureStderr(t, func() {
42+
if err := Execute([]string{"--machine", "version"}); err != nil {
43+
t.Fatalf("Execute: %v", err)
44+
}
45+
})
46+
})
47+
if !strings.HasPrefix(strings.TrimSpace(out), "{") {
48+
t.Fatalf("expected json output with --machine, got: %q", out)
49+
}
50+
51+
out = captureStdout(t, func() {
52+
_ = captureStderr(t, func() {
53+
if err := Execute([]string{"--tsv", "version"}); err != nil {
54+
t.Fatalf("Execute: %v", err)
55+
}
56+
})
57+
})
58+
if strings.HasPrefix(strings.TrimSpace(out), "{") {
59+
t.Fatalf("expected text output with --tsv, got: %q", out)
60+
}
61+
}
62+
63+
func TestDesirePaths_DryRunAlias_ExitsBeforeAuth(t *testing.T) {
64+
out := captureStdout(t, func() {
65+
_ = captureStderr(t, func() {
66+
if err := Execute([]string{
67+
"--json",
68+
"--dryrun",
69+
"send",
70+
"--to", "to@example.com",
71+
"--subject", "Hello",
72+
"--body", "Test",
73+
}); err != nil {
74+
t.Fatalf("Execute: %v", err)
75+
}
76+
})
77+
})
78+
if !strings.Contains(out, "\"dry_run\": true") {
79+
t.Fatalf("expected dry-run json output, got: %q", out)
80+
}
81+
}
82+
83+
func TestDesirePaths_CursorAlias_Parses(t *testing.T) {
84+
parser, _, err := newParser("test parser")
85+
if err != nil {
86+
t.Fatalf("newParser: %v", err)
87+
}
88+
if _, err := parser.Parse([]string{"drive", "ls", "--cursor", "tok"}); err != nil {
89+
t.Fatalf("Parse: %v", err)
90+
}
91+
}

internal/cmd/agent_exit_codes.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"os"
6+
"sort"
7+
"strconv"
8+
9+
"github.com/steipete/gogcli/internal/outfmt"
10+
)
11+
12+
type AgentExitCodesCmd struct{}
13+
14+
func (c *AgentExitCodesCmd) Run(ctx context.Context) error {
15+
// Always emit untransformed JSON, even if the caller enabled global JSON transforms.
16+
ctx = outfmt.WithJSONTransform(ctx, outfmt.JSONTransform{})
17+
18+
codes := map[string]int{
19+
"ok": 0,
20+
"error": 1,
21+
"usage": 2,
22+
"empty_results": emptyResultsExitCode,
23+
"auth_required": exitCodeAuthRequired,
24+
"not_found": exitCodeNotFound,
25+
"permission_denied": exitCodePermissionDenied,
26+
"rate_limited": exitCodeRateLimited,
27+
"retryable": exitCodeRetryable,
28+
"config": exitCodeConfig,
29+
"cancelled": exitCodeCancelled,
30+
}
31+
32+
if outfmt.IsJSON(ctx) {
33+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"exit_codes": codes})
34+
}
35+
36+
// Plain output is TSV so it's easily machine-parsed.
37+
if outfmt.IsPlain(ctx) {
38+
keys := make([]string, 0, len(codes))
39+
for k := range codes {
40+
keys = append(keys, k)
41+
}
42+
sort.Strings(keys)
43+
44+
for _, k := range keys {
45+
_, _ = os.Stdout.WriteString(k + "\t" + strconv.Itoa(codes[k]) + "\n")
46+
}
47+
48+
return nil
49+
}
50+
51+
// Human output.
52+
keys := make([]string, 0, len(codes))
53+
for k := range codes {
54+
keys = append(keys, k)
55+
}
56+
sort.Strings(keys)
57+
for _, k := range keys {
58+
_, _ = os.Stdout.WriteString(k + ": " + strconv.Itoa(codes[k]) + "\n")
59+
}
60+
return nil
61+
}

0 commit comments

Comments
 (0)