From aa97d9a6d5977ed33433a0edf87c7269bbd1e0c3 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 25 Jun 2026 06:24:38 -0400 Subject: [PATCH] feat(lathe): add generated command shortcuts Signed-off-by: samzong --- README.md | 8 ++ examples/petstore/README.md | 5 +- examples/petstore/overlays/pets.yaml | 6 + internal/auth/auth.go | 6 + internal/codegen/render/render.go | 69 ++++++++++- internal/codegen/render/skill.go | 42 +++++++ internal/lathecmd/lathecmd.go | 25 +++- internal/overlay/overlay.go | 6 + pkg/lathe/lathe.go | 1 + pkg/runtime/build.go | 178 +++++++++++++++++++++++++-- pkg/runtime/catalog.go | 92 ++++++++++---- pkg/runtime/spec.go | 8 +- 12 files changed, 407 insertions(+), 39 deletions(-) create mode 100644 examples/petstore/overlays/pets.yaml diff --git a/README.md b/README.md index 0c8a271..ef2c966 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,11 @@ commands: create-user: short: "Create a user in the IAM service" aliases: [adduser] + shortcuts: + - use: quick-user + params: + type: human + role: viewer example: | acmectl iam create-user \ --set email=alice@example.com \ @@ -349,6 +354,9 @@ Bulk pagination defaults fill matching command params that have no spec default. Explicit `commands..params..default` still wins when a command needs a different value. Parameter `required: true` marks an existing generated flag as required when an upstream spec is incomplete; it does not create new flags. +Command `shortcuts` add root-level commands that execute the same generated +operation with preset values for existing parameters. Shortcut params may use +the parameter name or flag name; invocation flags can still override the preset. Run codegen with an overlay directory: diff --git a/examples/petstore/README.md b/examples/petstore/README.md index 5846a83..de0f3d2 100644 --- a/examples/petstore/README.md +++ b/examples/petstore/README.md @@ -5,10 +5,11 @@ Demonstrates the minimal Lathe workflow: OpenAPI 3 spec -> codegen -> working CL ## Path 1. Inspect `cli.yaml`, `specs/sources.yaml`, and the checked-in fixture cache. -2. Run `lathe codegen -cache fixtures`. +2. Run `lathe codegen -cache fixtures -overlay overlays`. 3. Use `cmd/petstore/main.go` to mount `internal/generated`. 4. Run `go mod tidy` and `go build -o bin/petstore ./cmd/petstore`. 5. Verify the generated agent loop with `search`, `commands show`, and `commands schema`. +6. Try the generated-command shortcut: `petstore pet-123` executes `petstore pets get --id 123`. ## Expected output @@ -22,6 +23,7 @@ Authentication: auth Authenticate petstore with a host Modules: + pet-123 Get a pet by ID pets Pets operations Additional Commands: @@ -36,4 +38,5 @@ See [CLI Usage](../../docs/cli-usage.md) for the full command sequence. The key - **`cli.yaml`** — CLI name, description, auth endpoint - **`specs/sources.yaml`** — upstream specs pinned at immutable tags +- **`overlays/pets.yaml`** — generated-command shortcuts and polish - **`cmd//main.go`** — embed `cli.yaml`, call `lathe.NewApp`, then handle `generated.MountModules` errors diff --git a/examples/petstore/overlays/pets.yaml b/examples/petstore/overlays/pets.yaml new file mode 100644 index 0000000..9c63bf7 --- /dev/null +++ b/examples/petstore/overlays/pets.yaml @@ -0,0 +1,6 @@ +commands: + get: + shortcuts: + - use: pet-123 + params: + id: "123" diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fc21f90..7e6e18a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -24,6 +24,12 @@ func NewCommand(m *config.Manifest) *cobra.Command { return cmd } +func NewHiddenLoginCommand(m *config.Manifest) *cobra.Command { + cmd := newLogin(m) + cmd.Hidden = true + return cmd +} + func rootString(cmd *cobra.Command, name string) string { v, _ := cmd.Root().PersistentFlags().GetString(name) return v diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index 87c1b5b..393f065 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -154,6 +154,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.Shortcuts = append([]runtime.CommandShortcut(nil), spec.Shortcuts...) + for i := range cloned.Shortcuts { + cloned.Shortcuts[i].Params = copyStringMap(spec.Shortcuts[i].Params) + } cloned.Notes = append([]string(nil), spec.Notes...) cloned.Prerequisites = append([]string(nil), spec.Prerequisites...) cloned.KnownErrors = append([]runtime.KnownError(nil), spec.KnownErrors...) @@ -216,6 +220,12 @@ func applyCommandOverride(spec *runtime.CommandSpec, override overlay.Override) if len(override.Aliases) > 0 { spec.Aliases = append(spec.Aliases, override.Aliases...) } + for _, shortcut := range override.Shortcuts { + spec.Shortcuts = append(spec.Shortcuts, runtime.CommandShortcut{ + Use: shortcut.Use, + Params: copyStringMap(shortcut.Params), + }) + } if override.Group != "" { spec.Group = override.Group } @@ -247,6 +257,37 @@ func applyCommandOverride(spec *runtime.CommandSpec, override overlay.Override) } } +func ValidateShortcuts(moduleNames []string, specs []runtime.CommandSpec, flat bool) error { + rootNames := make([]string, 0, len(reservedRootCommands)+len(moduleNames)+len(specs)) + for name := range reservedRootCommands { + rootNames = append(rootNames, name) + } + if flat { + seen := map[string]bool{} + for _, spec := range specs { + name := rootCommandName(spec.Group) + if !seen[name] { + rootNames = append(rootNames, name) + seen[name] = true + } + } + return runtime.ValidateShortcuts(specs, rootNames) + } + rootNames = append(rootNames, moduleNames...) + return runtime.ValidateShortcuts(specs, rootNames) +} + +func copyStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + func renderModuleSpecs(name, cliName string, specs []runtime.CommandSpec) error { var buf strings.Builder ctx := moduleCtx{ @@ -308,6 +349,24 @@ func schemaLiteral(s *runtime.SchemaSpec) string { return b.String() } +func stringMapLiteral(values map[string]string) string { + if len(values) == 0 { + return "nil" + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("map[string]string{") + for _, key := range keys { + fmt.Fprintf(&b, "%q: %q,", key, values[key]) + } + b.WriteByte('}') + return b.String() +} + func writeSchemaLiteral(b *strings.Builder, s *runtime.SchemaSpec) { if s == nil { b.WriteString("nil") @@ -343,7 +402,8 @@ func writeSchemaLiteral(b *strings.Builder, s *runtime.SchemaSpec) { } var moduleTmpl = template.Must(template.New("gen").Funcs(template.FuncMap{ - "schemaLiteral": schemaLiteral, + "schemaLiteral": schemaLiteral, + "stringMapLiteral": stringMapLiteral, }).Parse(`// Code generated by lathe codegen. DO NOT EDIT. package {{.Module}} @@ -379,6 +439,13 @@ var Specs = []runtime.CommandSpec{ {{- if $op.Aliases}} Aliases: []string{ {{- range $op.Aliases}}{{printf "%q" .}}, {{end -}} }, {{- end}} + {{- if $op.Shortcuts}} + Shortcuts: []runtime.CommandShortcut{ + {{- range $shortcut := $op.Shortcuts}} + {Use: {{printf "%q" $shortcut.Use}}{{if $shortcut.Params}}, Params: {{stringMapLiteral $shortcut.Params}}{{end}}}, + {{- end}} + }, + {{- end}} Short: {{printf "%q" $op.Short}}, {{- if $op.Long}} Long: {{printf "%q" $op.Long}}, diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index 69b1bed..f870ae5 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -301,6 +301,7 @@ func renderCatalogReference(manifest *config.Manifest) string { fmt.Fprintf(&b, "Run `%s commands --json` to inspect the generated command catalog. Use `--include-hidden` only when hidden commands are relevant.\n\n", cli) b.WriteString("Key fields:\n\n") b.WriteString("- `path`: command path to pass to `commands show` or execute after the CLI name.\n") + b.WriteString("- `shortcuts`: root-level commands that execute the same operation with preset flag values.\n") b.WriteString("- `http`: HTTP method and path template.\n") fmt.Fprintf(&b, "- `http.default_hostname`: optional source-level host selected after explicit `--hostname` and `$%s`; when present it is used before the single-host fallback from `hosts.yml`.\n", manifest.CLI.HostEnv) b.WriteString("- `flags`: CLI flags, parameter location, type, required state, defaults, enum values, format, and help.\n") @@ -362,6 +363,7 @@ func renderModuleReference(manifest *config.Manifest, mod SkillModule, flat bool fmt.Fprintf(&b, "- HTTP: `%s %s`\n", spec.Method, spec.PathTpl) fmt.Fprintf(&b, "- Auth: %s\n", authSummary(spec.Security)) fmt.Fprintf(&b, "- Body: %s\n", bodySummary(spec.RequestBody)) + writeShortcuts(&b, cli, spec) if len(spec.Params) == 0 { b.WriteString("- Flags: none\n") } else { @@ -404,6 +406,46 @@ func renderModuleReference(manifest *config.Manifest, mod SkillModule, flat bool return b.String() } +func writeShortcuts(b *strings.Builder, cli string, spec runtime.CommandSpec) { + if len(spec.Shortcuts) == 0 { + return + } + b.WriteString("- Shortcuts:\n") + for _, shortcut := range spec.Shortcuts { + preset := shortcutPreset(spec, shortcut) + if preset == "" { + fmt.Fprintf(b, " - `%s %s`\n", cli, shortcut.Use) + continue + } + fmt.Fprintf(b, " - `%s %s` preset %s\n", cli, shortcut.Use, preset) + } +} + +func shortcutPreset(spec runtime.CommandSpec, shortcut runtime.CommandShortcut) string { + if len(shortcut.Params) == 0 { + return "" + } + flags := make(map[string]string, len(spec.Params)*2) + for _, param := range spec.Params { + flags[param.Name] = param.Flag + flags[param.Flag] = param.Flag + } + keys := make([]string, 0, len(shortcut.Params)) + for key := range shortcut.Params { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + flag := flags[key] + if flag == "" { + flag = key + } + parts = append(parts, fmt.Sprintf("`--%s=%s`", flag, shortcut.Params[key])) + } + return strings.Join(parts, ", ") +} + func commandExample(example, cli, module string, spec runtime.CommandSpec, flat bool) string { newPath := strings.Join(commandPath(cli, module, spec, true), " ") oldPaths := []string{strings.Join(legacyCommandPath(cli, module, spec, flat), " ")} diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index d0cdeff..cde3610 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -164,9 +164,18 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl } ordered := cfg.Ordered() + moduleNames := make([]string, 0, len(ordered)) + for _, src := range ordered { + name := src.Name + if src.DisplayName != "" { + name = src.DisplayName + } + moduleNames = append(moduleNames, name) + } var mounts []render.ModuleMount var skillModules []render.SkillModule - for _, src := range ordered { + var shortcutRootNames []string + for i, src := range ordered { syncDir := filepath.Join(syncRoot, src.Name) if err := specsync.VerifyState(syncDir, src); err != nil { return err @@ -188,14 +197,20 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl specs[i].DefaultHostname = *src.DefaultHostname } } - cliName := src.Name - if src.DisplayName != "" { - cliName = src.DisplayName - } + cliName := moduleNames[i] flat, err := render.ResolveFlatCommandPath(manifest.CLI.CommandPath, len(ordered), specs) if err != nil { return err } + validateRootNames := append(append([]string(nil), moduleNames...), shortcutRootNames...) + if err := render.ValidateShortcuts(validateRootNames, specs, flat); err != nil { + return err + } + for _, spec := range specs { + for _, shortcut := range spec.Shortcuts { + shortcutRootNames = append(shortcutRootNames, shortcut.Use) + } + } specs = render.RewriteCommandExamples(manifest.CLI.Name, cliName, specs, flat) if err := render.RenderModule(src.Name, cliName, specs, nil); err != nil { return err diff --git a/internal/overlay/overlay.go b/internal/overlay/overlay.go index ea75c25..676b29c 100644 --- a/internal/overlay/overlay.go +++ b/internal/overlay/overlay.go @@ -11,6 +11,7 @@ import ( type Override struct { Aliases []string `yaml:"aliases"` + Shortcuts []Shortcut `yaml:"shortcuts"` Short string `yaml:"short"` Long string `yaml:"long"` Example string `yaml:"example"` @@ -23,6 +24,11 @@ type Override struct { KnownErrors []KnownError `yaml:"known_errors"` } +type Shortcut struct { + Use string `yaml:"use"` + Params map[string]string `yaml:"params"` +} + type ParamOverride struct { Flag string `yaml:"flag"` Help string `yaml:"help"` diff --git a/pkg/lathe/lathe.go b/pkg/lathe/lathe.go index 3393374..b2efb64 100644 --- a/pkg/lathe/lathe.go +++ b/pkg/lathe/lathe.go @@ -48,6 +48,7 @@ func NewApp(m *config.Manifest) *cobra.Command { authCmd := auth.NewCommand(m) authCmd.GroupID = authGroupID cmd.AddCommand(authCmd) + cmd.AddCommand(auth.NewHiddenLoginCommand(m)) cmd.AddCommand(commandsCmd(m)) cmd.AddCommand(searchCmd(m)) if m.Update.GitHub != nil { diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index d880546..359e158 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -33,7 +33,13 @@ func Build(root *cobra.Command, service string, specs []CommandSpec) { for _, group := range buildGroups(service, specs) { svc.AddCommand(group) } + if err := ValidateShortcuts(specs, rootCommandNames(root, svc)); err != nil { + panic(err) + } root.AddCommand(svc) + if err := mountShortcuts(root, specs); err != nil { + panic(err) + } } func BuildFlat(root *cobra.Command, service string, specs []CommandSpec) error { @@ -50,8 +56,11 @@ func BuildFlat(root *cobra.Command, service string, specs []CommandSpec) error { return fmt.Errorf("flat mount command %q conflicts with existing root command", name) } } + if err := ValidateShortcuts(specs, rootCommandNames(root, groups...)); err != nil { + return err + } root.AddCommand(groups...) - return nil + return mountShortcuts(root, specs) } func buildGroups(service string, specs []CommandSpec) []*cobra.Command { @@ -101,7 +110,7 @@ func buildCmd(s CommandSpec) *cobra.Command { } for _, p := range s.Params { - if len(p.Enum) == 0 || !cmd.Flags().Changed(p.Flag) { + if len(p.Enum) == 0 || !flagChangedOrDefault(cmd, p) { continue } raw := flagStringValue(vals[p.Name]) @@ -130,13 +139,13 @@ func buildCmd(s CommandSpec) *cobra.Command { path = strings.Replace(path, "{"+p.Name+"}", url.PathEscape(*v), 1) continue case InHeader: - if !cmd.Flags().Changed(p.Flag) { + if !flagChangedOrDefault(cmd, p) { continue } hdrs[p.Name] = *vals[p.Name].(*string) continue case InVariable: - if !cmd.Flags().Changed(p.Flag) { + if !flagChangedOrDefault(cmd, p) { continue } switch v := vals[p.Name].(type) { @@ -159,7 +168,7 @@ func buildCmd(s CommandSpec) *cobra.Command { } continue case InFormData: - if !cmd.Flags().Changed(p.Flag) { + if !flagChangedOrDefault(cmd, p) { continue } switch v := vals[p.Name].(type) { @@ -172,7 +181,7 @@ func buildCmd(s CommandSpec) *cobra.Command { } continue } - if !cmd.Flags().Changed(p.Flag) && p.Default == "" { + if !flagChangedOrDefault(cmd, p) { continue } switch v := vals[p.Name].(type) { @@ -277,7 +286,9 @@ func buildCmd(s CommandSpec) *cobra.Command { v := new(string) vals[p.Name] = v cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) - _ = cmd.MarkFlagRequired(p.Flag) + if p.Default == "" { + _ = cmd.MarkFlagRequired(p.Flag) + } if p.Deprecated { _ = cmd.Flags().MarkDeprecated(p.Flag, "this flag is deprecated") } @@ -326,7 +337,7 @@ func buildCmd(s CommandSpec) *cobra.Command { vals[p.Name] = v cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) } - if p.Required { + if p.Required && p.Default == "" { _ = cmd.MarkFlagRequired(p.Flag) } if p.Deprecated { @@ -364,14 +375,165 @@ func buildCmd(s CommandSpec) *cobra.Command { return cmd } +func mountShortcuts(root *cobra.Command, specs []CommandSpec) error { + for _, spec := range specs { + for _, shortcut := range spec.Shortcuts { + target, err := shortcutSpec(spec, shortcut) + if err != nil { + return err + } + cmd := buildCmd(target) + cmd.GroupID = ModuleGroupID + root.AddCommand(cmd) + } + } + return nil +} + +func ValidateShortcuts(specs []CommandSpec, rootNames []string) error { + roots := map[string]bool{} + for _, name := range rootNames { + if name != "" { + roots[name] = true + } + } + seen := map[string]bool{} + for _, spec := range specs { + for _, shortcut := range spec.Shortcuts { + name, err := shortcutName(shortcut.Use, spec.Use) + if err != nil { + return err + } + if roots[name] { + return fmt.Errorf("shortcut %q conflicts with root command", name) + } + if seen[name] { + return fmt.Errorf("shortcut %q conflicts with another shortcut", name) + } + seen[name] = true + if _, err := shortcutSpec(spec, shortcut); err != nil { + return err + } + } + } + return nil +} + +func rootCommandNames(root *cobra.Command, planned ...*cobra.Command) []string { + var names []string + for _, cmd := range root.Commands() { + names = append(names, cmd.Name()) + names = append(names, cmd.Aliases...) + } + for _, cmd := range planned { + names = append(names, cmd.Name()) + names = append(names, cmd.Aliases...) + } + return names +} + +func shortcutSpec(spec CommandSpec, shortcut CommandShortcut) (CommandSpec, error) { + name, err := shortcutName(shortcut.Use, spec.Use) + if err != nil { + return CommandSpec{}, err + } + target := spec + target.Use = name + target.Aliases = nil + target.Shortcuts = nil + target.Params = append([]ParamSpec(nil), spec.Params...) + set := map[int]string{} + for key, value := range shortcut.Params { + i := shortcutParamIndex(spec, key) + if i < 0 { + return CommandSpec{}, fmt.Errorf("shortcut %q param %q does not match command %q", name, key, spec.Use) + } + if prior, ok := set[i]; ok && prior != key { + return CommandSpec{}, fmt.Errorf("shortcut %q sets param %q more than once", name, spec.Params[i].Name) + } + if err := validateShortcutParamValue(spec.Params[i], value); err != nil { + return CommandSpec{}, fmt.Errorf("shortcut %q param %q: %w", name, key, err) + } + set[i] = key + target.Params[i].Default = value + target.Params[i].Required = false + } + return target, nil +} + +func shortcutName(use string, target string) (string, error) { + name := strings.TrimSpace(use) + if name == "" || name != use || len(strings.Fields(name)) != 1 { + return "", fmt.Errorf("shortcut for command %q must be a single command name", target) + } + return name, nil +} + +func shortcutParamIndex(spec CommandSpec, key string) int { + for i, param := range spec.Params { + if key == param.Name || key == param.Flag { + return i + } + } + return -1 +} + +func validateShortcutParamValue(p ParamSpec, value string) error { + if strings.HasPrefix(p.GoType, "[]") { + return fmt.Errorf("repeated params are not supported") + } + switch { + case (p.In == InPath || p.In == InHeader) && p.GoType != "" && p.GoType != "string": + return fmt.Errorf("type %s is not supported for %s params", p.GoType, p.In) + case p.In == InFormData && p.GoType == "float64": + return fmt.Errorf("type %s is not supported for %s params", p.GoType, p.In) + case p.In != InVariable && p.GoType == "float64": + return fmt.Errorf("type %s is not supported for %s params", p.GoType, p.In) + } + switch p.GoType { + case "int64": + _, err := strconv.ParseInt(value, 10, 64) + return err + case "float64": + _, err := strconv.ParseFloat(value, 64) + return err + case "bool": + if value != "true" && value != "false" { + return fmt.Errorf("must be true or false") + } + } + return nil +} + +func flagChangedOrDefault(cmd *cobra.Command, p ParamSpec) bool { + return cmd.Flags().Changed(p.Flag) || p.Default != "" +} + func flagStringValue(v any) string { switch tv := v.(type) { case *string: return *tv case *int64: return strconv.FormatInt(*tv, 10) + case *float64: + return strconv.FormatFloat(*tv, 'f', -1, 64) case *bool: return strconv.FormatBool(*tv) + case *[]int64: + if len(*tv) > 0 { + return strconv.FormatInt((*tv)[0], 10) + } + return "" + case *[]float64: + if len(*tv) > 0 { + return strconv.FormatFloat((*tv)[0], 'f', -1, 64) + } + return "" + case *[]bool: + if len(*tv) > 0 { + return strconv.FormatBool((*tv)[0]) + } + return "" case *[]string: if len(*tv) > 0 { return (*tv)[0] diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index 291142a..ccc85b9 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 5 +const CatalogSchemaVersion = 6 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" @@ -44,25 +44,26 @@ type CatalogOutputFormats struct { } type CatalogCommand struct { - Path []string `json:"path"` - Service string `json:"service"` - Group string `json:"group"` - Use string `json:"use"` - Aliases []string `json:"aliases,omitempty"` - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - Example string `json:"example,omitempty"` - OperationID string `json:"operation_id,omitempty"` - HTTP CatalogHTTP `json:"http"` - Auth CatalogAuth `json:"auth"` - Body *CatalogBody `json:"body,omitempty"` - Flags []CatalogFlag `json:"flags"` - Output CatalogOutput `json:"output"` - Hidden bool `json:"hidden"` - Deprecated bool `json:"deprecated"` - Notes []string `json:"notes,omitempty"` - Prerequisites []string `json:"prerequisites,omitempty"` - KnownErrors []KnownError `json:"known_errors,omitempty"` + Path []string `json:"path"` + Service string `json:"service"` + Group string `json:"group"` + Use string `json:"use"` + Aliases []string `json:"aliases,omitempty"` + Shortcuts []CommandShortcut `json:"shortcuts,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Example string `json:"example,omitempty"` + OperationID string `json:"operation_id,omitempty"` + HTTP CatalogHTTP `json:"http"` + Auth CatalogAuth `json:"auth"` + Body *CatalogBody `json:"body,omitempty"` + Flags []CatalogFlag `json:"flags"` + Output CatalogOutput `json:"output"` + Hidden bool `json:"hidden"` + Deprecated bool `json:"deprecated"` + Notes []string `json:"notes,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + KnownErrors []KnownError `json:"known_errors,omitempty"` } type CatalogHTTP struct { @@ -156,18 +157,32 @@ func FindCatalogCommand(root *cobra.Command, path []string, opts CatalogOptions) for _, segment := range path { child := findChildCommand(cur, segment) if child == nil { - return CatalogCommand{}, false + return findCatalogShortcut(root, path, opts) } canonical = append(canonical, child.Name()) cur = child } cmd, ok := catalogCommandFromAnnotation(cur, canonical) if !ok || (!opts.IncludeHidden && cmd.Hidden) { - return CatalogCommand{}, false + return findCatalogShortcut(root, path, opts) } return cmd, true } +func findCatalogShortcut(root *cobra.Command, path []string, opts CatalogOptions) (CatalogCommand, bool) { + if len(path) != 1 { + return CatalogCommand{}, false + } + for _, cmd := range BuildCatalog(root, opts).Commands { + for _, shortcut := range cmd.Shortcuts { + if shortcut.Use == path[0] { + return cmd, true + } + } + } + return CatalogCommand{}, false +} + func SearchCatalog(root *cobra.Command, query string, opts SearchOptions) []SearchResult { limit := opts.Limit if limit <= 0 { @@ -262,6 +277,7 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm Group: spec.Group, Use: spec.Use, Aliases: append([]string(nil), spec.Aliases...), + Shortcuts: cloneShortcuts(spec.Shortcuts), Summary: spec.Short, Description: spec.Long, Example: spec.Example, @@ -294,6 +310,25 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm return cmd } +func cloneShortcuts(shortcuts []CommandShortcut) []CommandShortcut { + out := make([]CommandShortcut, 0, len(shortcuts)) + for _, shortcut := range shortcuts { + out = append(out, CommandShortcut{Use: shortcut.Use, Params: copyStringMap(shortcut.Params)}) + } + return out +} + +func copyStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + func catalogPagination(p *PaginationHint) *CatalogPagination { if p == nil { return nil @@ -327,6 +362,7 @@ type searchView struct { group string use string aliases []string + shortcuts []string summary string description string operationID string @@ -346,6 +382,10 @@ func newSearchView(cmd CatalogCommand) searchView { for _, alias := range cmd.Aliases { aliases = append(aliases, strings.ToLower(alias)) } + shortcuts := make([]string, 0, len(cmd.Shortcuts)) + for _, shortcut := range cmd.Shortcuts { + shortcuts = append(shortcuts, strings.ToLower(shortcut.Use)) + } flags := make([]searchFlagView, 0, len(cmd.Flags)) for _, flag := range cmd.Flags { flags = append(flags, searchFlagView{ @@ -365,6 +405,7 @@ func newSearchView(cmd CatalogCommand) searchView { group: strings.ToLower(cmd.Group), use: strings.ToLower(cmd.Use), aliases: aliases, + shortcuts: shortcuts, summary: strings.ToLower(cmd.Summary), description: strings.ToLower(cmd.Description), operationID: strings.ToLower(cmd.OperationID), @@ -395,7 +436,9 @@ func scoreCatalogCommand(cmd searchView, tokens []string, fullQuery string, norm if fullQuery == cmd.fullPath || fullQuery == cmd.operationID || fullQuery == cmd.use || normalizedQuery == normalizeSearchText(cmd.fullPath) || normalizedQuery == normalizeSearchText(cmd.operationID) || - normalizedQuery == normalizeSearchText(cmd.use) { + normalizedQuery == normalizeSearchText(cmd.use) || + slices.Contains(cmd.shortcuts, fullQuery) || + slices.Contains(cmd.shortcuts, normalizedQuery) { score += 100 } return score, true @@ -408,6 +451,9 @@ func scoreToken(cmd searchView, token string) int { for _, alias := range cmd.aliases { score = max(score, scoreField(alias, token, 80)) } + for _, shortcut := range cmd.shortcuts { + score = max(score, scoreField(shortcut, token, 80)) + } for _, segment := range cmd.path { if strings.HasPrefix(segment, token) { score = max(score, 60) diff --git a/pkg/runtime/spec.go b/pkg/runtime/spec.go index 8ef68e6..bd9cb8b 100644 --- a/pkg/runtime/spec.go +++ b/pkg/runtime/spec.go @@ -1,11 +1,12 @@ package runtime -const SchemaVersion = 6 +const SchemaVersion = 7 type CommandSpec struct { Group string Use string Aliases []string + Shortcuts []CommandShortcut `json:",omitempty"` Short string Long string Example string @@ -24,6 +25,11 @@ type CommandSpec struct { KnownErrors []KnownError `json:",omitempty"` } +type CommandShortcut struct { + Use string `json:"use"` + Params map[string]string `json:"params,omitempty"` +} + type ParamSpec struct { Name string Flag string