Skip to content

Commit cd2d1da

Browse files
feat: improve agent-friendly CLI behavior (#195)
1 parent 0c974f8 commit cd2d1da

39 files changed

Lines changed: 1076 additions & 86 deletions

File tree

e2e/e2e_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,8 @@ func TestRules(t *testing.T) {
148148
func TestSearch(t *testing.T) {
149149
runTestsInDir(t, "testscripts/search")
150150
}
151+
152+
// TestAgentReady tests describe and dry-run contracts.
153+
func TestAgentReady(t *testing.T) {
154+
runTestsInDir(t, "testscripts/agent-ready")
155+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Runtime schema should be machine-readable
2+
exec algolia describe search
3+
! stderr .
4+
stdout -count=1 '"schemaVersion":"v1"'
5+
stdout -count=1 '"name":"algolia search"'
6+
stdout -count=1 '"responseFields"'
7+
stdout -count=1 '"attributesToRetrieve"'
8+
9+
# Dry-run should validate and preview without mutating
10+
stdin update.jsonl
11+
exec algolia objects update test-agent-ready --file - --dry-run --output json
12+
! stderr .
13+
stdout -count=1 '"action":"update_objects"'
14+
stdout -count=1 '"dryRun":true'
15+
stdout -count=1 '"objectCount":1'
16+
17+
-- update.jsonl --
18+
{"objectID":"test-agent-ready","name":"preview"}

internal/docs/docs.go

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,27 @@ import (
99
)
1010

1111
type Command struct {
12-
Name string
13-
Description string
14-
Usage string
15-
Aliases []string
16-
Examples string
17-
Slug string
18-
RunInWebCLI bool
12+
Name string `json:"name"`
13+
Description string `json:"description"`
14+
Usage string `json:"usage"`
15+
Aliases []string `json:"aliases,omitempty"`
16+
Examples string `json:"examples,omitempty"`
17+
Slug string `json:"slug,omitempty"`
18+
RunInWebCLI bool `json:"runInWebCLI,omitempty"`
19+
CommandType string `json:"commandType"`
1920

20-
Flags map[string][]Flag
21+
Annotations map[string]string `json:"annotations,omitempty"`
2122

22-
SubCommands []Command
23+
Flags map[string][]Flag `json:"flags,omitempty"`
24+
25+
SubCommands []Command `json:"subCommands,omitempty"`
2326
}
2427

2528
type Flag struct {
26-
Name string
27-
Shorthand string
28-
Description string
29-
Default string
29+
Name string `json:"name"`
30+
Shorthand string `json:"shorthand,omitempty"`
31+
Description string `json:"description"`
32+
Default string `json:"default"`
3033
}
3134

3235
func newFlag(flag *pflag.Flag) Flag {
@@ -61,6 +64,8 @@ func newCommand(cmd *cobra.Command) Command {
6164
Aliases: cmd.Aliases,
6265
Examples: cmd.Example,
6366
RunInWebCLI: false,
67+
CommandType: commandType(cmd),
68+
Annotations: cmd.Annotations,
6469
}
6570
if value, ok := cmd.Annotations["runInWebCLI"]; ok && value != "" {
6671
command.RunInWebCLI = true
@@ -89,6 +94,22 @@ func newCommand(cmd *cobra.Command) Command {
8994
return command
9095
}
9196

97+
func commandType(cmd *cobra.Command) string {
98+
switch cmd.Name() {
99+
case "tail":
100+
return "stream"
101+
case "search", "browse", "get", "list", "stats", "analyze", "describe", "schema", "open":
102+
return "read"
103+
case "create", "delete", "clear", "import", "update", "set", "save", "move", "copy", "crawl", "run", "pause", "reindex", "unblock", "remove", "add", "setdefault":
104+
return "write"
105+
default:
106+
if cmd.HasAvailableSubCommands() {
107+
return "namespace"
108+
}
109+
return "other"
110+
}
111+
}
112+
92113
func getCommands(cmd *cobra.Command) []Command {
93114
var commands []Command
94115
for _, c := range cmd.Commands() {
@@ -113,6 +134,49 @@ func getCommands(cmd *cobra.Command) []Command {
113134
return commands
114135
}
115136

137+
func describeCommand(cmd *cobra.Command) Command {
138+
command := newCommand(cmd)
139+
if cmd.HasAvailableSubCommands() {
140+
command.SubCommands = getCommands(cmd)
141+
}
142+
return command
143+
}
144+
145+
// DescribeCommand returns a machine-readable description of a command.
146+
func DescribeCommand(cmd *cobra.Command) Command {
147+
return describeCommand(cmd)
148+
}
149+
150+
// FindCommand resolves a command path against the provided root command.
151+
func FindCommand(root *cobra.Command, args []string) (*cobra.Command, error) {
152+
current := root
153+
for _, arg := range args {
154+
next := findChildCommand(current, arg)
155+
if next == nil {
156+
return nil, cmdutil.FlagErrorf("unknown command %q for %q", arg, current.CommandPath())
157+
}
158+
current = next
159+
}
160+
return current, nil
161+
}
162+
163+
func findChildCommand(cmd *cobra.Command, name string) *cobra.Command {
164+
for _, child := range cmd.Commands() {
165+
if child.Hidden || child.Name() == "help" {
166+
continue
167+
}
168+
if child.Name() == name {
169+
return child
170+
}
171+
for _, alias := range child.Aliases {
172+
if alias == name {
173+
return child
174+
}
175+
}
176+
}
177+
return nil
178+
}
179+
116180
type Example struct {
117181
Desc string
118182
Code string

pkg/cmd/apikeys/get/get.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command
5656
},
5757
}
5858

59+
opts.PrintFlags.AddFlags(cmd)
60+
5961
return cmd
6062
}
6163

pkg/cmd/apikeys/get/get_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,25 @@ func Test_runGetCmd(t *testing.T) {
5757
})
5858
}
5959
}
60+
61+
func Test_runGetCmd_outputFlag(t *testing.T) {
62+
name := "test"
63+
r := httpmock.Registry{}
64+
r.Register(
65+
httpmock.REST("GET", "1/keys/foo"),
66+
httpmock.JSONResponse(search.GetApiKeyResponse{
67+
Value: "foo",
68+
Description: &name,
69+
Acl: []search.Acl{search.ACL_SEARCH},
70+
}),
71+
)
72+
73+
f, out := test.NewFactory(false, &r, nil, "")
74+
cmd := NewGetCmd(f, nil)
75+
out, err := test.Execute(cmd, "foo --output ndjson", out)
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
80+
assert.Contains(t, out.String(), `"value":"foo"`)
81+
}

pkg/cmd/apikeys/list/list.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ func runListCmd(opts *ListOptions) error {
7474
return err
7575
}
7676

77+
if opts.PrintFlags.HasStructuredOutput() {
78+
p, err := opts.PrintFlags.ToPrinter()
79+
if err != nil {
80+
return err
81+
}
82+
return p.Print(opts.IO, res)
83+
}
84+
7785
table := printers.NewTablePrinter(opts.IO)
7886
if table.IsTTY() {
7987
table.AddField("KEY", nil, nil)

pkg/cmd/apikeys/list/list_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,35 @@ func Test_runListCmd(t *testing.T) {
6262
})
6363
}
6464
}
65+
66+
func Test_runListCmd_outputJSON(t *testing.T) {
67+
oldNowFn := nowFn
68+
nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z
69+
t.Cleanup(func() { nowFn = oldNowFn })
70+
71+
name := "test"
72+
r := httpmock.Registry{}
73+
r.Register(
74+
httpmock.REST("GET", "1/keys"),
75+
httpmock.JSONResponse(search.ListApiKeysResponse{
76+
Keys: []search.GetApiKeyResponse{
77+
{
78+
Value: "foo",
79+
Description: &name,
80+
Acl: []search.Acl{search.ACL_SEARCH},
81+
CreatedAt: 1577836800,
82+
},
83+
},
84+
}),
85+
)
86+
87+
f, out := test.NewFactory(false, &r, nil, "")
88+
cmd := NewListCmd(f, nil)
89+
out, err := test.Execute(cmd, "--output json", out)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
assert.Contains(t, out.String(), `"keys":[`)
95+
assert.Contains(t, out.String(), `"value":"foo"`)
96+
}

pkg/cmd/crawler/get/get.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command
6060
cmd.Flags().
6161
BoolVarP(&opts.ConfigOnly, "config-only", "c", false, "Display only the crawler configuration")
6262

63+
opts.PrintFlags.AddFlags(cmd)
64+
6365
return cmd
6466
}
6567

pkg/cmd/crawler/get/get_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package get
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/algolia/cli/test"
9+
)
10+
11+
func Test_NewGetCmd_outputFlag(t *testing.T) {
12+
f, out := test.NewFactory(false, nil, nil, "")
13+
14+
called := false
15+
cmd := NewGetCmd(f, func(opts *GetOptions) error {
16+
called = true
17+
assert.Equal(t, "my-crawler", opts.ID)
18+
if assert.NotNil(t, opts.PrintFlags.OutputFormat) {
19+
assert.Equal(t, "json", *opts.PrintFlags.OutputFormat)
20+
}
21+
return nil
22+
})
23+
24+
_, err := test.Execute(cmd, "my-crawler --output json", out)
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
assert.True(t, called)
30+
}

pkg/cmd/crawler/test/test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ func NewTestCmd(f *cmdutil.Factory, runF func(*TestOptions) error) *cobra.Comman
8181
cmd.Flags().
8282
StringVarP(&configFile, "config", "F", "", "The configuration file to use to override the crawler's configuration. (use \"-\" to read from standard input)")
8383

84+
opts.PrintFlags.AddFlags(cmd)
85+
8486
return cmd
8587
}
8688

0 commit comments

Comments
 (0)