diff --git a/docs/architecture.md b/docs/architecture.md index 7f24fd4..8047e8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -201,7 +201,7 @@ Overlays apply at codegen-time. The runtime has no overlay types. This matrix sh | `Short` | `summary` / first comment | override | overlay > spec | | `Long` | `description` / comment block | override | overlay > spec | | `Aliases` | — | append | overlay-only | -| `Example` | — | set | overlay-only | +| `Example`, `Examples` | — | set | overlay-only | | `Notes`, `Prerequisites`, `KnownErrors` | — | set | overlay-only | | `Method`, `PathTpl`, `HasBody` | spec | locked | spec-only | | `OperationID` | `operationId` | — | spec-only | diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 97aff59..0e34347 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -324,6 +324,7 @@ Rules: - Treat `search` output as candidates only. - Inspect exact command details with `commands show` before execution. +- Use `examples` from command detail when overlays provide runnable command metadata. - Run `auth status --hostname ` before authenticated commands. - Prefer `-o json` for agent-readable output. - Use `--file`, `--set`, or `--set-str` according to the command detail body diff --git a/examples/overlay/example.yaml b/examples/overlay/example.yaml index a121d37..466b879 100644 --- a/examples/overlay/example.yaml +++ b/examples/overlay/example.yaml @@ -38,6 +38,17 @@ commands: {} # --email alice@example.com \ # --role viewer \ # --workspace default +# examples: +# - summary: "Create a user from a JSON body file" +# command: "myctl iam create-user --file user.json -o json" +# body_shape: +# input: +# email: "alice@example.com" +# role: "viewer" +# output_hints: +# id_path: "data.createUser.id" +# follow_up_commands: +# - "myctl iam get-user --id -o json" # group: "Identity" # hidden: false # params: diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index c70f925..d2dd761 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -1,6 +1,7 @@ package render import ( + "encoding/json" "fmt" "go/format" "os" @@ -91,6 +92,14 @@ func RewriteCommandExamples(cli, module string, specs []runtime.CommandSpec, fla if next.Example != "" { next.Example = commandExample(next.Example, cli, module, next, flat) } + for i := range next.Examples { + if next.Examples[i].Command != "" { + next.Examples[i].Command = commandExample(next.Examples[i].Command, cli, module, next, flat) + } + for j := range next.Examples[i].FollowUpCommands { + next.Examples[i].FollowUpCommands[j] = commandExample(next.Examples[i].FollowUpCommands[j], cli, module, next, flat) + } + } rewritten = append(rewritten, next) } return rewritten @@ -192,6 +201,10 @@ func MergeOverlayModule(specs []runtime.CommandSpec, mod overlay.Module) []runti func cloneCommandSpec(spec runtime.CommandSpec) runtime.CommandSpec { cloned := spec cloned.Aliases = append([]string(nil), spec.Aliases...) + cloned.Examples = append([]runtime.CommandExample(nil), spec.Examples...) + for i := range cloned.Examples { + cloned.Examples[i].FollowUpCommands = append([]string(nil), spec.Examples[i].FollowUpCommands...) + } cloned.Shortcuts = append([]runtime.CommandShortcut(nil), spec.Shortcuts...) for i := range cloned.Shortcuts { cloned.Shortcuts[i].Params = copyStringMap(spec.Shortcuts[i].Params) @@ -246,6 +259,12 @@ func applyCommandOverride(spec *runtime.CommandSpec, override overlay.Override) if override.Example != "" { spec.Example = override.Example } + if len(override.Examples) > 0 { + spec.Examples = make([]runtime.CommandExample, 0, len(override.Examples)) + for _, example := range override.Examples { + spec.Examples = append(spec.Examples, runtimeCommandExample(example)) + } + } if len(override.Notes) > 0 { spec.Notes = append([]string(nil), override.Notes...) } @@ -298,6 +317,26 @@ func applyCommandOverride(spec *runtime.CommandSpec, override overlay.Override) } } +func runtimeCommandExample(example overlay.Example) runtime.CommandExample { + var bodyShape json.RawMessage + if len(example.BodyShape) > 0 { + bodyShape, _ = json.Marshal(example.BodyShape) + } + out := runtime.CommandExample{ + Summary: example.Summary, + Command: example.Command, + BodyShape: bodyShape, + FollowUpCommands: append([]string(nil), example.FollowUpCommands...), + } + if example.OutputHints.IDPath != "" || example.OutputHints.ListPath != "" { + out.OutputHints = &runtime.ExampleOutputHints{ + IDPath: example.OutputHints.IDPath, + ListPath: example.OutputHints.ListPath, + } + } + return out +} + func ValidateShortcuts(moduleNames []string, specs []runtime.CommandSpec, flat bool) error { rootNames := make([]string, 0, len(reservedRootCommands)+len(moduleNames)+len(specs)) for name := range reservedRootCommands { @@ -491,10 +530,28 @@ var Specs = []runtime.CommandSpec{ {{- if $op.Long}} Long: {{printf "%q" $op.Long}}, {{- end}} - {{- if $op.Example}} - Example: {{printf "%q" $op.Example}}, - {{- end}} - {{- if $op.Notes}} + {{- if $op.Example}} + Example: {{printf "%q" $op.Example}}, + {{- end}} + {{- if $op.Examples}} + Examples: []runtime.CommandExample{ + {{- range $example := $op.Examples}} + { + {{- if $example.Summary}}Summary: {{printf "%q" $example.Summary}},{{end}} + {{- if $example.Command}}Command: {{printf "%q" $example.Command}},{{end}} + {{- if $example.BodyShape}}BodyShape: []byte({{printf "%q" $example.BodyShape}}),{{end}} + {{- if $example.OutputHints}}OutputHints: &runtime.ExampleOutputHints{ + {{- if $example.OutputHints.IDPath}}IDPath: {{printf "%q" $example.OutputHints.IDPath}},{{end}} + {{- if $example.OutputHints.ListPath}}ListPath: {{printf "%q" $example.OutputHints.ListPath}},{{end}} + },{{end}} + {{- if $example.FollowUpCommands}}FollowUpCommands: []string{ + {{- range $example.FollowUpCommands}}{{printf "%q" .}},{{end}} + },{{end}} + }, + {{- end}} + }, + {{- end}} + {{- if $op.Notes}} Notes: []string{ {{- range $op.Notes}}{{printf "%q" .}},{{end}} }, diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index 089ab7e..2736a45 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -54,10 +54,19 @@ func TestRenderModule_AppliesOverlay(t *testing.T) { } overrides := map[string]overlay.Override{ "install-addon": { - Aliases: []string{"addon-install"}, - Short: "OVERLAY SHORT", - Long: "OVERLAY LONG DESC", - Example: "myctl demo install-addon --name foo", + Aliases: []string{"addon-install"}, + Short: "OVERLAY SHORT", + Long: "OVERLAY LONG DESC", + Example: "myctl demo install-addon --name foo", + Examples: []overlay.Example{{ + Summary: "Install from JSON", + Command: "myctl demo install-addon --file addon.json -o json", + BodyShape: map[string]any{ + "input": map[string]any{"name": "foo"}, + }, + OutputHints: overlay.ExampleOutputHints{IDPath: "data.installAddon.id"}, + FollowUpCommands: []string{"myctl demo get-addon --id -o json"}, + }}, Notes: []string{"Use the canonical addon ID."}, Prerequisites: []string{"List clusters before installing."}, KnownErrors: []overlay.KnownError{{Status: 400, Cause: "missing addon name"}}, @@ -73,6 +82,12 @@ func TestRenderModule_AppliesOverlay(t *testing.T) { `"OVERLAY SHORT"`, `"OVERLAY LONG DESC"`, `"myctl demo install-addon --name foo"`, + `Examples: []runtime.CommandExample{`, + `Summary: "Install from JSON"`, + `Command: "myctl demo install-addon --file addon.json -o json"`, + `BodyShape: []byte("{\"input\":{\"name\":\"foo\"}}")`, + `OutputHints: &runtime.ExampleOutputHints{IDPath: "data.installAddon.id"}`, + `FollowUpCommands: []string{"myctl demo get-addon --id -o json"}`, `"addon-install"`, `Notes:`, `"Use the canonical addon ID."`, @@ -524,15 +539,28 @@ func TestRewriteCommandExamples_NormalizesMultiWordGroupPaths(t *testing.T) { Group: "Payment API", Use: "list-payments", Example: "acmectl billing payment api list-payments -o json", + Examples: []runtime.CommandExample{{ + Command: "acmectl billing payment api list-payments --file payment.json -o json", + FollowUpCommands: []string{"acmectl billing payment api get-payment --id -o json"}, + }}, }} got := RewriteCommandExamples("acmectl", "billing", specs, true) if got[0].Example != "acmectl payment list-payments -o json" { t.Fatalf("flat example = %q", got[0].Example) } + if got[0].Examples[0].Command != "acmectl payment list-payments --file payment.json -o json" { + t.Fatalf("flat structured example = %q", got[0].Examples[0].Command) + } + if got[0].Examples[0].FollowUpCommands[0] != "acmectl billing payment api get-payment --id -o json" { + t.Fatalf("flat follow-up = %q", got[0].Examples[0].FollowUpCommands[0]) + } got = RewriteCommandExamples("acmectl", "billing", specs, false) if got[0].Example != "acmectl billing payment list-payments -o json" { t.Fatalf("namespaced example = %q", got[0].Example) } + if got[0].Examples[0].Command != "acmectl billing payment list-payments --file payment.json -o json" { + t.Fatalf("namespaced structured example = %q", got[0].Examples[0].Command) + } } diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index 07e0e39..9f4147c 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -548,6 +548,7 @@ func renderCatalogReference(manifest *config.Manifest) string { b.WriteString("- `flags`: CLI flags, parameter location, type, required state, defaults, enum values, format, and help.\n") b.WriteString("- `body`: request body requirement and media type.\n") b.WriteString("- `auth`: whether auth is required and which scopes are declared.\n") + b.WriteString("- `examples`: runnable examples with optional body shape, output hints, and follow-up commands.\n") b.WriteString("- `output`: list path, default columns, response media type, pagination, and streaming hints.\n") b.WriteString("- `notes`, `prerequisites`, and `known_errors`: overlay-provided operation context that is not inferred from the API spec.\n\n") b.WriteString("## Command Detail\n\n") @@ -638,9 +639,7 @@ func renderModuleReference(manifest *config.Manifest, mod SkillModule, flat bool fmt.Fprintf(&b, "- Output: %s\n", out) } writeOperationContext(&b, spec) - if spec.Example != "" { - writeExample(&b, commandExample(spec.Example, cli, module, spec, flat)) - } + writeExamples(&b, cli, module, spec, flat) b.WriteString("\n") } } @@ -747,6 +746,51 @@ func writeExample(b *strings.Builder, example string) { fmt.Fprintf(b, "- Example: `%s`\n", strings.ReplaceAll(oneLine(text), "`", "'")) } +func writeExamples(b *strings.Builder, cli, module string, spec runtime.CommandSpec, flat bool) { + examples := spec.Examples + if len(examples) == 0 && spec.Example != "" { + examples = []runtime.CommandExample{{Command: spec.Example}} + } + if len(examples) == 0 { + return + } + if len(examples) == 1 { + example := examples[0] + if example.Summary == "" && example.Command != "" && len(example.BodyShape) == 0 && example.OutputHints == nil && len(example.FollowUpCommands) == 0 { + writeExample(b, commandExample(example.Command, cli, module, spec, flat)) + return + } + } + b.WriteString("- Examples:\n") + for _, example := range examples { + summary := oneLine(example.Summary) + if summary == "" { + summary = "Example" + } + fmt.Fprintf(b, " - %s\n", summary) + if example.Command != "" { + fmt.Fprintf(b, " Command: `%s`\n", strings.ReplaceAll(oneLine(commandExample(example.Command, cli, module, spec, flat)), "`", "'")) + } + if len(example.BodyShape) > 0 { + fmt.Fprintf(b, " Body shape: `%s`\n", strings.ReplaceAll(oneLine(string(example.BodyShape)), "`", "'")) + } + if example.OutputHints != nil { + if example.OutputHints.IDPath != "" { + fmt.Fprintf(b, " Output ID path: `%s`\n", example.OutputHints.IDPath) + } + if example.OutputHints.ListPath != "" { + fmt.Fprintf(b, " Output list path: `%s`\n", example.OutputHints.ListPath) + } + } + if len(example.FollowUpCommands) > 0 { + b.WriteString(" Follow-up commands:\n") + for _, command := range example.FollowUpCommands { + fmt.Fprintf(b, " - `%s`\n", strings.ReplaceAll(oneLine(commandExample(command, cli, module, spec, flat)), "`", "'")) + } + } + } +} + func markdownFence(text string) string { fence := "```" for strings.Contains(text, fence) { diff --git a/internal/codegen/render/skill_test.go b/internal/codegen/render/skill_test.go index dffab5a..626214b 100644 --- a/internal/codegen/render/skill_test.go +++ b/internal/codegen/render/skill_test.go @@ -175,6 +175,20 @@ func TestRenderModuleReference_FormatsExamples(t *testing.T) { " --start $START --end $END -o json\n" + "jq '.items[]'", }, + { + Group: "Users", + Use: "create-user", + Short: "Create user", + Method: "POST", + PathTpl: "/users", + Examples: []runtime.CommandExample{{ + Summary: "Create from JSON", + Command: "acmectl users users create-user --file user.json -o json", + BodyShape: []byte(`{"input":{"name":"..."}}`), + OutputHints: &runtime.ExampleOutputHints{IDPath: "data.createUser.id"}, + FollowUpCommands: []string{"acmectl users users get-user --id -o json"}, + }}, + }, }, } @@ -182,6 +196,7 @@ func TestRenderModuleReference_FormatsExamples(t *testing.T) { for _, want := range []string{ "- Example: `acmectl users users get-user --id 123`", "- Example:\n\n```\nEND=$(date +%s); START=$((END - 3600))\nacmectl users users query-logs \\\n --start $START --end $END -o json\njq '.items[]'\n```", + "- Examples:\n - Create from JSON\n Command: `acmectl users users create-user --file user.json -o json`\n Body shape: `{\"input\":{\"name\":\"...\"}}`\n Output ID path: `data.createUser.id`\n Follow-up commands:\n - `acmectl users users get-user --id -o json`", } { if !strings.Contains(got, want) { t.Fatalf("module reference missing %q\nfull output:\n%s", want, got) @@ -192,14 +207,12 @@ func TestRenderModuleReference_FormatsExamples(t *testing.T) { for _, want := range []string{ "- Example: `acmectl users get-user --id 123`", "acmectl users query-logs \\\n --start $START --end $END -o json", + "Command: `acmectl users create-user --file user.json -o json`", } { if !strings.Contains(flat, want) { t.Fatalf("flat module reference missing %q\nfull output:\n%s", want, flat) } } - if strings.Contains(flat, "acmectl users users") { - t.Fatalf("flat module reference kept stale namespaced command:\n%s", flat) - } } func TestRenderModuleReference_NormalizesMultiWordGroupPaths(t *testing.T) { diff --git a/internal/overlay/overlay.go b/internal/overlay/overlay.go index 0b22e06..8c8b669 100644 --- a/internal/overlay/overlay.go +++ b/internal/overlay/overlay.go @@ -16,6 +16,7 @@ type Override struct { Short string `yaml:"short"` Long string `yaml:"long"` Example string `yaml:"example"` + Examples []Example `yaml:"examples"` Group string `yaml:"group"` Hidden *bool `yaml:"hidden"` Ignore bool `yaml:"ignore"` @@ -25,6 +26,19 @@ type Override struct { KnownErrors []KnownError `yaml:"known_errors"` } +type Example struct { + Summary string `yaml:"summary"` + Command string `yaml:"command"` + BodyShape map[string]any `yaml:"body_shape"` + OutputHints ExampleOutputHints `yaml:"output_hints"` + FollowUpCommands []string `yaml:"follow_up_commands"` +} + +type ExampleOutputHints struct { + IDPath string `yaml:"id_path"` + ListPath string `yaml:"list_path"` +} + type Shortcut struct { Use string `yaml:"use"` Params map[string]string `yaml:"params"` diff --git a/internal/overlay/overlay_test.go b/internal/overlay/overlay_test.go index c2e6789..5e41a95 100644 --- a/internal/overlay/overlay_test.go +++ b/internal/overlay/overlay_test.go @@ -35,6 +35,17 @@ func TestLoadDir_ParsesMultipleModules(t *testing.T) { short: "Create a user" long: "Long description for create-user." example: "myctl iam create-user --email a@b.c" + examples: + - summary: "Create a user from JSON" + command: "myctl iam create-user --file user.json -o json" + body_shape: + input: + email: "alice@example.com" + output_hints: + id_path: "data.createUser.id" + list_path: "data.users" + follow_up_commands: + - "myctl iam get-user --id -o json" `) writeFile(t, filepath.Join(dir, "billing.yaml"), `commands: list-invoices: @@ -56,6 +67,18 @@ func TestLoadDir_ParsesMultipleModules(t *testing.T) { if u.Short != "Create a user" || u.Long == "" || u.Example == "" { t.Errorf("iam create-user override incomplete: %+v", u) } + if len(u.Examples) != 1 || u.Examples[0].Summary != "Create a user from JSON" { + t.Fatalf("examples = %#v", u.Examples) + } + if u.Examples[0].OutputHints.IDPath != "data.createUser.id" || u.Examples[0].OutputHints.ListPath != "data.users" { + t.Errorf("example output hints = %#v", u.Examples[0].OutputHints) + } + if input, ok := u.Examples[0].BodyShape["input"].(map[string]any); !ok || input["email"] != "alice@example.com" { + t.Errorf("example body shape = %#v", u.Examples[0].BodyShape) + } + if len(u.Examples[0].FollowUpCommands) != 1 || u.Examples[0].FollowUpCommands[0] != "myctl iam get-user --id -o json" { + t.Errorf("follow-up commands = %#v", u.Examples[0].FollowUpCommands) + } if len(u.Aliases) != 2 || u.Aliases[0] != "adduser" || u.Aliases[1] != "new-user" { t.Errorf("iam create-user aliases: %v", u.Aliases) } diff --git a/pkg/lathe/catalog_test.go b/pkg/lathe/catalog_test.go index 71e7555..d3f6c3e 100644 --- a/pkg/lathe/catalog_test.go +++ b/pkg/lathe/catalog_test.go @@ -33,9 +33,14 @@ func TestCommandsJSON_EmptyCatalog(t *testing.T) { func TestCommandsShowAndSearchJSON(t *testing.T) { root := NewApp(testManifest()) runtime.Build(root, "demo", []runtime.CommandSpec{{ - Group: "Users", - Use: "get-user", - Short: "Get a user", + Group: "Users", + Use: "get-user", + Short: "Get a user", + Examples: []runtime.CommandExample{{ + Summary: "Get a user by ID", + Command: "myctl demo users get-user --id 123 -o json", + OutputHints: &runtime.ExampleOutputHints{IDPath: "data.user.id"}, + }}, OperationID: "getUser", Method: "GET", PathTpl: "/users/{id}", @@ -68,6 +73,9 @@ func TestCommandsShowAndSearchJSON(t *testing.T) { if len(entry.KnownErrors) != 1 || entry.KnownErrors[0].Status != 400 || entry.KnownErrors[0].Cause != "missing id" { t.Fatalf("known errors = %#v", entry.KnownErrors) } + if len(entry.Examples) != 1 || entry.Examples[0].Command != "myctl demo users get-user --id 123 -o json" || entry.Examples[0].OutputHints.IDPath != "data.user.id" { + t.Fatalf("examples = %#v", entry.Examples) + } if len(entry.Flags) != 2 || !entry.Flags[1].Required || entry.Flags[1].Name != "type" { t.Fatalf("required query flag = %#v", entry.Flags) } diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index ccc85b9..78be9c8 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 6 +const CatalogSchemaVersion = 7 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" @@ -53,6 +53,7 @@ type CatalogCommand struct { Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Example string `json:"example,omitempty"` + Examples []CommandExample `json:"examples,omitempty"` OperationID string `json:"operation_id,omitempty"` HTTP CatalogHTTP `json:"http"` Auth CatalogAuth `json:"auth"` @@ -271,6 +272,10 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm Help: p.Help, }) } + examples := spec.Examples + if len(examples) == 0 && spec.Example != "" { + examples = []CommandExample{{Command: spec.Example}} + } cmd := CatalogCommand{ Path: append([]string(nil), path...), Service: service, @@ -281,6 +286,7 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm Summary: spec.Short, Description: spec.Long, Example: spec.Example, + Examples: examples, OperationID: spec.OperationID, HTTP: CatalogHTTP{Method: spec.Method, PathTemplate: spec.PathTpl, DefaultHostname: spec.DefaultHostname}, Auth: catalogAuth(spec.Security), diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index 59a6e48..d0be655 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -11,14 +11,23 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { root := newRootWithModuleGroup() Build(root, "demo", []CommandSpec{ { - Group: "Users", - Use: "get-user", - Aliases: []string{"show-user"}, - Short: "Get a user", - Long: "Get one user by id.", - OperationID: "getUser", - Method: "GET", - PathTpl: "/users/{id}", + Group: "Users", + Use: "get-user", + Aliases: []string{"show-user"}, + Short: "Get a user", + Long: "Get one user by id.", + Example: "myctl demo users get-user --id 123 -o json", + Examples: []CommandExample{{ + Summary: "Get a user by ID", + Command: "myctl demo users get-user --id 123 -o json", + BodyShape: []byte(`{"input":{"name":"..."}}`), + OutputHints: &ExampleOutputHints{IDPath: "data.user.id", ListPath: "data.items"}, + FollowUpCommands: []string{"myctl demo users list-users -o json"}, + }}, + OperationID: "getUser", + Method: "GET", + PathTpl: "/users/{id}", + DefaultHostname: "api.example.com", Params: []ParamSpec{ {Name: "id", Flag: "id", In: InPath, GoType: "string", Required: true, Help: "User id"}, {Name: "workspace", Flag: "workspace", In: InQuery, GoType: "string", Default: "default", Enum: []string{"default", "prod"}, Format: "slug", Help: "Target workspace"}, @@ -62,6 +71,18 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { if cmd.Auth.Required != true || !reflect.DeepEqual(cmd.Auth.Scopes, []string{"users:read"}) { t.Fatalf("auth = %+v", cmd.Auth) } + if cmd.HTTP.DefaultHostname != "api.example.com" { + t.Fatalf("http = %+v", cmd.HTTP) + } + if len(cmd.Examples) != 1 || cmd.Examples[0].Summary != "Get a user by ID" || cmd.Examples[0].OutputHints.IDPath != "data.user.id" { + t.Fatalf("examples = %+v", cmd.Examples) + } + if string(cmd.Examples[0].BodyShape) != `{"input":{"name":"..."}}` { + t.Fatalf("body shape = %s", cmd.Examples[0].BodyShape) + } + if !reflect.DeepEqual(cmd.Examples[0].FollowUpCommands, []string{"myctl demo users list-users -o json"}) { + t.Fatalf("follow-up commands = %#v", cmd.Examples[0].FollowUpCommands) + } if cmd.Body == nil || !cmd.Body.Required || cmd.Body.MediaType != "application/json" { t.Fatalf("body = %+v", cmd.Body) } @@ -110,6 +131,33 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { if !reflect.DeepEqual(roundTrip.Commands[0].KnownErrors, cmd.KnownErrors) { t.Fatalf("round-trip known errors = %#v", roundTrip.Commands[0].KnownErrors) } + if len(roundTrip.Commands[0].Examples) != 1 || roundTrip.Commands[0].Examples[0].OutputHints.IDPath != "data.user.id" { + t.Fatalf("round-trip examples = %#v", roundTrip.Commands[0].Examples) + } +} + +func TestBuildCatalog_ProjectsLegacyExample(t *testing.T) { + root := newRootWithModuleGroup() + Build(root, "demo", []CommandSpec{{ + Group: "Users", + Use: "get-user", + Short: "Get a user", + Example: "myctl demo users get-user --id 123 -o json", + Method: "GET", + PathTpl: "/users/{id}", + }}) + + catalog := BuildCatalog(root, CatalogOptions{}) + if len(catalog.Commands) != 1 { + t.Fatalf("commands = %d, want 1", len(catalog.Commands)) + } + cmd := catalog.Commands[0] + if cmd.Example != "myctl demo users get-user --id 123 -o json" { + t.Fatalf("legacy example = %q", cmd.Example) + } + if len(cmd.Examples) != 1 || cmd.Examples[0].Command != cmd.Example { + t.Fatalf("projected examples = %#v", cmd.Examples) + } } func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) { diff --git a/pkg/runtime/spec.go b/pkg/runtime/spec.go index bd9cb8b..81c198a 100644 --- a/pkg/runtime/spec.go +++ b/pkg/runtime/spec.go @@ -1,5 +1,7 @@ package runtime +import "encoding/json" + const SchemaVersion = 7 type CommandSpec struct { @@ -10,6 +12,7 @@ type CommandSpec struct { Short string Long string Example string + Examples []CommandExample `json:",omitempty"` OperationID string Hidden bool Deprecated bool @@ -25,6 +28,19 @@ type CommandSpec struct { KnownErrors []KnownError `json:",omitempty"` } +type CommandExample struct { + Summary string `json:"summary,omitempty"` + Command string `json:"command,omitempty"` + BodyShape json.RawMessage `json:"body_shape,omitempty"` + OutputHints *ExampleOutputHints `json:"output_hints,omitempty"` + FollowUpCommands []string `json:"follow_up_commands,omitempty"` +} + +type ExampleOutputHints struct { + IDPath string `json:"id_path,omitempty"` + ListPath string `json:"list_path,omitempty"` +} + type CommandShortcut struct { Use string `json:"use"` Params map[string]string `json:"params,omitempty"`