Skip to content

Commit cad3bb1

Browse files
committed
Tighten CLI completions and runtime validation
1 parent e4dffec commit cad3bb1

8 files changed

Lines changed: 276 additions & 248 deletions

File tree

cmd/completions.go

Lines changed: 114 additions & 201 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import (
66
"os"
77
"path/filepath"
88
"runtime"
9+
"sort"
910
"strings"
1011

12+
"github.com/GrayCodeAI/eyrie/catalog/registry"
1113
"github.com/GrayCodeAI/hawk/internal/provider/routing"
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/pflag"
1216
)
1317

1418
// FlagInfo describes a CLI flag for completion generation.
@@ -50,212 +54,19 @@ func NewCompletionGenerator() *CompletionGenerator {
5054
}
5155

5256
func (g *CompletionGenerator) populateCommands() {
53-
g.Commands = []CommandInfo{
54-
{
55-
Name: "exec",
56-
Description: "Execute a single command non-interactively",
57-
Flags: []FlagInfo{
58-
{Name: "output-format", Short: "o", Description: "Output format: text or json", Type: "string", Choices: []string{"text", "json"}},
59-
{Name: "auto", Description: "Autonomy level", Type: "string", Choices: []string{"supervised", "basic", "semi", "full", "yolo"}},
60-
{Name: "model", Short: "m", Description: "Model ID to use", Type: "string"},
61-
{Name: "max-turns", Description: "Maximum agentic turns", Type: "int"},
62-
{Name: "cwd", Description: "Working directory", Type: "string"},
63-
{Name: "agent", Description: "Agent persona to use", Type: "string"},
64-
{Name: "session-id", Short: "s", Description: "Continue an existing session", Type: "string"},
65-
},
66-
},
67-
{
68-
Name: "daemon",
69-
Description: "Start the hawk background daemon",
70-
},
71-
{
72-
Name: "mission",
73-
Description: "Multi-agent orchestration on parallel git branches",
74-
},
75-
{
76-
Name: "search",
77-
Description: "Search code using semantic and structural queries",
78-
},
79-
{
80-
Name: "agent",
81-
Description: "Manage agent personas",
82-
},
83-
{
84-
Name: "doctor",
85-
Description: "Run local diagnostics",
86-
},
87-
{
88-
Name: "config",
89-
Description: "Show or update settings",
90-
Subcommands: []CommandInfo{
91-
{Name: "get", Description: "Get a setting value"},
92-
{Name: "set", Description: "Set a setting value"},
93-
{Name: "provider", Description: "Set the LLM provider"},
94-
{Name: "model", Description: "Set the model"},
95-
{Name: "keys", Description: "Show API key configuration"},
96-
},
97-
},
98-
{
99-
Name: "sessions",
100-
Description: "List saved sessions",
101-
},
102-
{
103-
Name: "tools",
104-
Description: "List built-in tools",
105-
},
106-
{
107-
Name: "skills",
108-
Description: "Manage skills",
109-
Subcommands: []CommandInfo{
110-
{Name: "list", Description: "List installed skills"},
111-
{Name: "search", Description: "Search the community skill registry"},
112-
{Name: "install", Description: "Install a skill"},
113-
{Name: "remove", Description: "Remove a skill"},
114-
{Name: "info", Description: "Show skill details"},
115-
{Name: "trending", Description: "Show trending skills"},
116-
{Name: "audit", Description: "Audit installed skills"},
117-
},
118-
},
119-
{
120-
Name: "completion",
121-
Description: "Generate shell completion script",
122-
Subcommands: []CommandInfo{
123-
{Name: "bash", Description: "Generate bash completion"},
124-
{Name: "zsh", Description: "Generate zsh completion"},
125-
{Name: "fish", Description: "Generate fish completion"},
126-
{Name: "powershell", Description: "Generate PowerShell completion"},
127-
},
128-
},
129-
{
130-
Name: "research",
131-
Description: "Autonomous research loop",
132-
Flags: []FlagInfo{
133-
{Name: "grep", Description: "Grep pattern to extract metric", Type: "string"},
134-
{Name: "direction", Description: "Optimization direction", Type: "string", Choices: []string{"lower", "higher"}},
135-
{Name: "budget", Description: "Time budget per experiment in minutes", Type: "int"},
136-
{Name: "branch", Description: "Git branch prefix", Type: "string"},
137-
{Name: "results", Description: "Results TSV file path", Type: "string"},
138-
},
139-
},
140-
{
141-
Name: "context",
142-
Description: "Export project context as a single document",
143-
Flags: []FlagInfo{
144-
{Name: "focus", Description: "Focus on a specific area", Type: "string"},
145-
{Name: "output", Short: "o", Description: "Write context to a file", Type: "string"},
146-
},
147-
},
148-
{
149-
Name: "version",
150-
Description: "Print hawk version",
151-
},
152-
{
153-
Name: "setup",
154-
Description: "Run first-time setup again",
155-
},
156-
{
157-
Name: "plugin",
158-
Description: "Manage plugins",
159-
Subcommands: []CommandInfo{
160-
{Name: "list", Description: "List installed plugins"},
161-
{Name: "install", Description: "Install a plugin"},
162-
{Name: "uninstall", Description: "Uninstall a plugin"},
163-
},
164-
},
165-
{
166-
Name: "mcp",
167-
Description: "Show MCP server configuration",
168-
},
169-
{
170-
Name: "inspect",
171-
Description: "Inspect session or context state",
172-
},
173-
{
174-
Name: "plan",
175-
Description: "Enter plan mode (read-only analysis)",
176-
},
177-
{
178-
Name: "rules",
179-
Description: "Manage project rules",
180-
},
181-
{
182-
Name: "sandbox",
183-
Description: "Bash permission profile (strict/workspace/off); not Docker container mode",
184-
},
185-
{
186-
Name: "cost",
187-
Description: "Show token usage and cost summary",
188-
},
189-
{
190-
Name: "snapshot",
191-
Description: "Manage session snapshots",
192-
},
193-
{
194-
Name: "sight",
195-
Description: "Visual analysis tools",
196-
},
197-
{
198-
Name: "fingerprint",
199-
Description: "Show project fingerprint",
200-
},
201-
}
57+
g.Commands = commandInfosFromCobra(rootCmd.Commands())
58+
augmentCommandInfos(g.Commands)
20259
}
20360

20461
func (g *CompletionGenerator) populateFlags() {
205-
g.Flags = []FlagInfo{
206-
{Name: "provider", Description: "LLM provider", Type: "string", Choices: nil}, // choices filled from Providers
207-
{Name: "model", Short: "m", Description: "Model to use", Type: "string"},
208-
{Name: "print", Short: "p", Description: "Print response and exit", Type: "bool"},
209-
{Name: "resume", Short: "r", Description: "Resume a saved session by ID", Type: "string"},
210-
{Name: "continue", Short: "c", Description: "Continue the most recent conversation", Type: "bool"},
211-
{Name: "mcp", Description: "MCP server command", Type: "string"},
212-
{Name: "allowed-tools", Description: "Tool permission rules to allow", Type: "string"},
213-
{Name: "disallowed-tools", Description: "Tool permission rules to deny", Type: "string"},
214-
{Name: "permission-mode", Description: "Permission mode", Type: "string", Choices: []string{"default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"}},
215-
{Name: "dangerously-skip-permissions", Description: "Bypass all permission checks", Type: "bool"},
216-
{Name: "max-turns", Description: "Maximum number of agentic turns", Type: "int"},
217-
{Name: "max-budget-usd", Description: "Maximum estimated API spend in USD", Type: "string"},
218-
{Name: "system-prompt", Description: "System prompt to use", Type: "string"},
219-
{Name: "system-prompt-file", Description: "Read system prompt from a file", Type: "string"},
220-
{Name: "append-system-prompt", Description: "Append text to system prompt", Type: "string"},
221-
{Name: "append-system-prompt-file", Description: "Read text from a file and append it to the system prompt", Type: "string"},
222-
{Name: "output-format", Description: "Output format for --print", Type: "string", Choices: []string{"text", "json", "stream-json"}},
223-
{Name: "input-format", Description: "Input format for --print", Type: "string", Choices: []string{"text", "stream-json"}},
224-
{Name: "no-session-persistence", Description: "Disable session persistence in print mode", Type: "bool"},
225-
{Name: "session-id", Description: "Use a specific session ID", Type: "string"},
226-
{Name: "settings", Description: "Path to a settings JSON file", Type: "string"},
227-
{Name: "add-dir", Description: "Additional directories to include", Type: "string"},
228-
{Name: "tools", Description: "Available tools configuration", Type: "string"},
229-
{Name: "sandbox", Description: "Bash permission profile (not Docker; use --no-container for host)", Type: "string", Choices: []string{"strict", "workspace", "off"}},
230-
{Name: "auto-commit", Description: "Auto-commit file changes", Type: "bool"},
231-
{Name: "watch", Description: "Watch working directory for file changes", Type: "bool"},
232-
{Name: "vibe", Description: "Vibe coding mode", Type: "bool"},
233-
{Name: "power", Description: "Power level 1-10", Type: "int"},
234-
{Name: "timeout", Description: "Time budget for the operation", Type: "string"},
235-
{Name: "council", Description: "Consult multiple models", Type: "bool"},
236-
{Name: "teach", Description: "Explain reasoning as the agent works", Type: "bool"},
237-
{Name: "teach-depth", Description: "Explanation depth: 1=what, 2=why, 3=how", Type: "int"},
238-
{Name: "auto-skill", Description: "Auto-detect project and install matching skills", Type: "bool"},
239-
{Name: "container", Description: "Force container mode", Type: "bool"},
240-
{Name: "no-container", Description: "Disable container mode", Type: "bool"},
241-
{Name: "version", Short: "v", Description: "Output the version number", Type: "bool"},
242-
{Name: "fork-session", Description: "Create a new session ID when resuming", Type: "bool"},
243-
}
62+
g.Flags = flagsFromFlagSet(rootCmd.Flags())
24463
}
24564

24665
func (g *CompletionGenerator) populateProviders() {
247-
g.Providers = []string{
248-
"anthropic",
249-
"openai",
250-
"gemini",
251-
"openrouter",
252-
"grok",
253-
"groq",
254-
"deepseek",
255-
"mistral",
256-
"bedrock",
257-
"vertex",
258-
"ollama",
66+
providers := registry.All()
67+
g.Providers = make([]string, 0, len(providers))
68+
for _, provider := range providers {
69+
g.Providers = append(g.Providers, provider.ProviderID)
25970
}
26071
}
26172

@@ -601,6 +412,10 @@ func (g *CompletionGenerator) GenerateFish() string {
601412

602413
// GenerateJSON returns a machine-readable JSON completion spec for IDE integration.
603414
func (g *CompletionGenerator) GenerateJSON() string {
415+
v := strings.TrimSpace(version)
416+
if v == "" {
417+
v = "dev"
418+
}
604419
spec := struct {
605420
Name string `json:"name"`
606421
Version string `json:"version"`
@@ -611,7 +426,7 @@ func (g *CompletionGenerator) GenerateJSON() string {
611426
Models []string `json:"models"`
612427
}{
613428
Name: "hawk",
614-
Version: "1.0.0",
429+
Version: v,
615430
Commands: g.Commands,
616431
GlobalFlags: g.Flags,
617432
SlashCommands: g.SlashCommands,
@@ -626,6 +441,104 @@ func (g *CompletionGenerator) GenerateJSON() string {
626441
return string(data)
627442
}
628443

444+
func commandInfosFromCobra(cmds []*cobra.Command) []CommandInfo {
445+
infos := make([]CommandInfo, 0, len(cmds))
446+
for _, cmd := range cmds {
447+
if cmd.Hidden {
448+
continue
449+
}
450+
info := CommandInfo{
451+
Name: cmd.Name(),
452+
Description: strings.TrimSpace(cmd.Short),
453+
Flags: flagsFromFlagSet(cmd.NonInheritedFlags()),
454+
}
455+
info.Subcommands = commandInfosFromCobra(cmd.Commands())
456+
infos = append(infos, info)
457+
}
458+
return infos
459+
}
460+
461+
func augmentCommandInfos(commands []CommandInfo) {
462+
for i := range commands {
463+
switch commands[i].Name {
464+
case "completion":
465+
commands[i].Subcommands = []CommandInfo{
466+
{Name: "bash", Description: "Generate bash completion"},
467+
{Name: "zsh", Description: "Generate zsh completion"},
468+
{Name: "fish", Description: "Generate fish completion"},
469+
{Name: "powershell", Description: "Generate PowerShell completion"},
470+
{Name: "json", Description: "Generate machine-readable completion metadata"},
471+
}
472+
case "config":
473+
commands[i].Subcommands = []CommandInfo{
474+
{Name: "get", Description: "Get a setting value"},
475+
{Name: "set", Description: "Set a setting value"},
476+
{Name: "provider", Description: "Set the LLM provider"},
477+
{Name: "model", Description: "Set the model"},
478+
{Name: "keys", Description: "Show API key configuration"},
479+
{Name: "routing-preview", Description: "Show routing JSON for a model"},
480+
{Name: "migrate-deployments", Description: "Upgrade legacy provider config to deployments v2"},
481+
}
482+
}
483+
if len(commands[i].Subcommands) > 0 {
484+
augmentCommandInfos(commands[i].Subcommands)
485+
}
486+
}
487+
}
488+
489+
func flagsFromFlagSet(fs *pflag.FlagSet) []FlagInfo {
490+
if fs == nil {
491+
return nil
492+
}
493+
var flags []FlagInfo
494+
fs.VisitAll(func(f *pflag.Flag) {
495+
if f == nil || f.Name == "help" {
496+
return
497+
}
498+
flags = append(flags, FlagInfo{
499+
Name: f.Name,
500+
Short: f.Shorthand,
501+
Description: f.Usage,
502+
Type: normalizeFlagType(f.Value.Type()),
503+
Choices: flagChoices(f.Name),
504+
})
505+
})
506+
sort.SliceStable(flags, func(i, j int) bool {
507+
return flags[i].Name < flags[j].Name
508+
})
509+
return flags
510+
}
511+
512+
func normalizeFlagType(flagType string) string {
513+
switch flagType {
514+
case "bool":
515+
return "bool"
516+
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64":
517+
return "int"
518+
default:
519+
return "string"
520+
}
521+
}
522+
523+
func flagChoices(name string) []string {
524+
switch name {
525+
case "permission-mode":
526+
return []string{"default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"}
527+
case "output-format":
528+
return []string{"text", "json", "stream-json"}
529+
case "input-format":
530+
return []string{"text", "stream-json"}
531+
case "sandbox":
532+
return []string{"strict", "workspace", "off"}
533+
case "direction":
534+
return []string{"lower", "higher"}
535+
case "auto":
536+
return []string{"supervised", "basic", "semi", "full", "yolo"}
537+
default:
538+
return nil
539+
}
540+
}
541+
629542
// InstallCompletion returns the filesystem path where the completion script
630543
// should be installed for the given shell. It does not write the file; the
631544
// caller should decide whether to proceed.

0 commit comments

Comments
 (0)