From 5081484b02ad1e1eb453a064e922673a325eedc5 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sat, 13 Jun 2026 23:14:16 +0530 Subject: [PATCH] feat(cli): shell completions for bash/zsh/fish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `riskkernel completion `, which prints a completion script to stdout. It completes the top-level commands and the nested sub-subcommands (runs list|resume, audit export|tools|compliance, policy validate|dry-run, approvals list|approve|deny, memory list|show), and the `rk` alias is completed too. The scripts are hand-written static strings — no cobra, no new dependency. Each carries a one-line install hint, and the README shows how to wire it into each shell. --- CHANGELOG.md | 6 + README.md | 11 +- cmd/riskkernel/completion.go | 199 ++++++++++++++++++++++++++++++ cmd/riskkernel/completion_test.go | 110 +++++++++++++++++ cmd/riskkernel/main.go | 3 + 5 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 cmd/riskkernel/completion.go create mode 100644 cmd/riskkernel/completion_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a3340..bd4b4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] ### Added +- **Shell completions.** `riskkernel completion ` prints a completion + script to stdout — tab-complete the top-level commands and their sub-subcommands + (`runs list|resume`, `audit export|tools|compliance`, `policy validate|dry-run`, + `approvals list|approve|deny`, `memory list|show`). Hand-written, no new + dependency; the `rk` alias is completed too. Each shell's script carries its own + one-line install hint. - **`riskkernel doctor`.** Diagnose a setup before relying on it: a checklist over the data dir (creatable/writable), the default provider and its credential, the default budget (flags an explicitly-unlimited one), the API token, a configured diff --git a/README.md b/README.md index d94d0ff..22f622c 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,15 @@ riskkernel init # scaffold a .env + a runnable example in the current dir riskkernel serve # start the daemon (reads .env) ``` -(or `make build` from a clone). Deeper control (loops, checkpoints, approval -gates) is the Python SDK: +(or `make build` from a clone). Tab-complete the CLI in your shell: + +```bash +riskkernel completion bash > /etc/bash_completion.d/riskkernel # bash +riskkernel completion zsh > "${fpath[1]}/_riskkernel" # zsh +riskkernel completion fish > ~/.config/fish/completions/riskkernel.fish # fish +``` + +Deeper control (loops, checkpoints, approval gates) is the Python SDK: ```bash pip install riskkernel diff --git a/cmd/riskkernel/completion.go b/cmd/riskkernel/completion.go new file mode 100644 index 0000000..5e3eedf --- /dev/null +++ b/cmd/riskkernel/completion.go @@ -0,0 +1,199 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// topLevelCommands are the riskkernel subcommands offered as the first +// completion. Kept in sync with the dispatch switch in main.go. +var topLevelCommands = []string{ + "init", "serve", "chat", "runs", "audit", "policy", + "approvals", "memory", "doctor", "healthcheck", "version", "help", +} + +// subCommands maps a top-level command to its sub-subcommands, so completion can +// offer e.g. `runs list|resume`. Commands without sub-subcommands are absent. +// Kept in sync with the per-command dispatch switches (admin.go, policy.go, +// approvals.go, memory.go). +var subCommands = map[string][]string{ + "runs": {"list", "resume"}, + "audit": {"export", "tools", "compliance"}, + "policy": {"validate", "dry-run"}, + "approvals": {"list", "approve", "deny"}, + "memory": {"list", "show"}, +} + +// runCompletion implements `riskkernel completion `: it prints a +// shell completion script to stdout. The scripts are hand-written static strings +// (no cobra) that complete the top-level subcommands and their sub-subcommands. +func runCompletion(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: riskkernel completion ") + } + switch args[0] { + case "bash": + fmt.Print(bashCompletion()) + case "zsh": + fmt.Print(zshCompletion()) + case "fish": + fmt.Print(fishCompletion()) + case "-h", "--help", "help": + fmt.Fprint(os.Stderr, completionHelp) + return nil + default: + return fmt.Errorf("unknown shell %q (want bash|zsh|fish)", args[0]) + } + return nil +} + +const completionHelp = `Generate a shell completion script for riskkernel. + +Usage: + riskkernel completion + +Install (bash): + riskkernel completion bash > /etc/bash_completion.d/riskkernel + # or, per-user, source it from your ~/.bashrc: + riskkernel completion bash > ~/.riskkernel-completion.bash + echo 'source ~/.riskkernel-completion.bash' >> ~/.bashrc + +Install (zsh): + riskkernel completion zsh > "${fpath[1]}/_riskkernel" + # ensure compinit runs in your ~/.zshrc: + # autoload -U compinit && compinit + +Install (fish): + riskkernel completion fish > ~/.config/fish/completions/riskkernel.fish +` + +// sortedTopLevel returns the top-level commands in a deterministic order so the +// generated script is stable across builds. +func sortedTopLevel() []string { + out := append([]string(nil), topLevelCommands...) + sort.Strings(out) + return out +} + +// sortedSubKeys returns the commands that have sub-subcommands, sorted. +func sortedSubKeys() []string { + keys := make([]string, 0, len(subCommands)) + for k := range subCommands { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// bashCompletion returns a bash completion script. It completes the first word +// against the top-level commands and the second word against a command's +// sub-subcommands. +func bashCompletion() string { + var b strings.Builder + b.WriteString("# bash completion for riskkernel\n") + b.WriteString("# install: riskkernel completion bash > /etc/bash_completion.d/riskkernel\n") + b.WriteString("_riskkernel() {\n") + b.WriteString(" local cur\n") + b.WriteString(" COMPREPLY=()\n") + b.WriteString(` cur="${COMP_WORDS[COMP_CWORD]}"` + "\n") + b.WriteString(" local cmds=\"" + strings.Join(sortedTopLevel(), " ") + "\"\n\n") + b.WriteString(" if [[ ${COMP_CWORD} -eq 1 ]]; then\n") + b.WriteString(` COMPREPLY=( $(compgen -W "${cmds}" -- "${cur}") )` + "\n") + b.WriteString(" return 0\n") + b.WriteString(" fi\n\n") + b.WriteString(" if [[ ${COMP_CWORD} -eq 2 ]]; then\n") + b.WriteString(` case "${COMP_WORDS[1]}" in` + "\n") + for _, cmd := range sortedSubKeys() { + subs := append([]string(nil), subCommands[cmd]...) + sort.Strings(subs) + b.WriteString(" " + cmd + ")\n") + b.WriteString(` COMPREPLY=( $(compgen -W "` + strings.Join(subs, " ") + `" -- "${cur}") )` + "\n") + b.WriteString(" return 0\n") + b.WriteString(" ;;\n") + } + b.WriteString(" completion)\n") + b.WriteString(` COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") )` + "\n") + b.WriteString(" return 0\n") + b.WriteString(" ;;\n") + b.WriteString(" esac\n") + b.WriteString(" fi\n") + b.WriteString(" return 0\n") + b.WriteString("}\n") + b.WriteString("complete -F _riskkernel riskkernel\n") + b.WriteString("complete -F _riskkernel rk\n") + return b.String() +} + +// zshCompletion returns a zsh completion script using the standard `#compdef` +// header and `_describe`/`compadd` machinery. +func zshCompletion() string { + var b strings.Builder + b.WriteString("#compdef riskkernel rk\n") + b.WriteString("# zsh completion for riskkernel\n") + b.WriteString(`# install: riskkernel completion zsh > "${fpath[1]}/_riskkernel"` + "\n") + b.WriteString("_riskkernel() {\n") + b.WriteString(" local -a commands\n") + b.WriteString(" commands=(\n") + for _, cmd := range sortedTopLevel() { + b.WriteString(" '" + cmd + ":riskkernel " + cmd + "'\n") + } + b.WriteString(" )\n\n") + b.WriteString(" if (( CURRENT == 2 )); then\n") + b.WriteString(" _describe 'command' commands\n") + b.WriteString(" return\n") + b.WriteString(" fi\n\n") + b.WriteString(" if (( CURRENT == 3 )); then\n") + b.WriteString(" case \"${words[2]}\" in\n") + for _, cmd := range sortedSubKeys() { + subs := append([]string(nil), subCommands[cmd]...) + sort.Strings(subs) + b.WriteString(" " + cmd + ")\n") + b.WriteString(" compadd " + strings.Join(subs, " ") + "\n") + b.WriteString(" return\n") + b.WriteString(" ;;\n") + } + b.WriteString(" completion)\n") + b.WriteString(" compadd bash zsh fish\n") + b.WriteString(" return\n") + b.WriteString(" ;;\n") + b.WriteString(" esac\n") + b.WriteString(" fi\n") + b.WriteString("}\n\n") + b.WriteString("_riskkernel \"$@\"\n") + return b.String() +} + +// fishCompletion returns a fish completion script. fish completions are a list of +// `complete` directives; sub-subcommands are gated on the parent with +// `__fish_seen_subcommand_from`. +func fishCompletion() string { + var b strings.Builder + b.WriteString("# fish completion for riskkernel\n") + b.WriteString("# install: riskkernel completion fish > ~/.config/fish/completions/riskkernel.fish\n\n") + + // Top-level commands: only when no subcommand has been typed yet. + b.WriteString("function __riskkernel_no_subcommand\n") + b.WriteString(" set -l cmd (commandline -opc)\n") + b.WriteString(" test (count $cmd) -eq 1\n") + b.WriteString("end\n\n") + + for _, cmd := range sortedTopLevel() { + b.WriteString("complete -c riskkernel -f -n __riskkernel_no_subcommand -a " + cmd + + " -d 'riskkernel " + cmd + "'\n") + } + b.WriteString("\n") + + for _, cmd := range sortedSubKeys() { + subs := append([]string(nil), subCommands[cmd]...) + sort.Strings(subs) + for _, s := range subs { + b.WriteString("complete -c riskkernel -f -n '__fish_seen_subcommand_from " + cmd + + "' -a " + s + " -d '" + cmd + " " + s + "'\n") + } + } + b.WriteString("complete -c riskkernel -f -n '__fish_seen_subcommand_from completion'" + + " -a 'bash zsh fish' -d 'shell'\n") + return b.String() +} diff --git a/cmd/riskkernel/completion_test.go b/cmd/riskkernel/completion_test.go new file mode 100644 index 0000000..27a1668 --- /dev/null +++ b/cmd/riskkernel/completion_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "strings" + "testing" +) + +// generators maps a shell to its script generator so the table tests can iterate. +var generators = map[string]func() string{ + "bash": bashCompletion, + "zsh": zshCompletion, + "fish": fishCompletion, +} + +// keySubcommands must appear in every generated script — these are the surfaces +// users will tab-complete most, so a regression that drops one is a real bug. +var keySubcommands = []string{ + "serve", "runs", "policy", "audit", "approvals", "memory", "doctor", + "completion", +} + +func TestCompletionScriptsContainCommands(t *testing.T) { + for shell, gen := range generators { + t.Run(shell, func(t *testing.T) { + out := gen() + if strings.TrimSpace(out) == "" { + t.Fatalf("%s completion script is empty", shell) + } + for _, cmd := range keySubcommands { + if !strings.Contains(out, cmd) { + t.Errorf("%s completion script missing subcommand %q", shell, cmd) + } + } + }) + } +} + +// TestCompletionScriptsContainSubSubcommands checks the second-level commands +// (e.g. `runs list`, `audit export`) are offered too. +func TestCompletionScriptsContainSubSubcommands(t *testing.T) { + wantPairs := [][2]string{ + {"runs", "resume"}, + {"audit", "compliance"}, + {"policy", "dry-run"}, + {"approvals", "approve"}, + {"memory", "show"}, + } + for shell, gen := range generators { + out := gen() + for _, p := range wantPairs { + if !strings.Contains(out, p[1]) { + t.Errorf("%s completion script missing sub-subcommand %q (under %q)", shell, p[1], p[0]) + } + } + } +} + +func TestRunCompletionValidShells(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish"} { + if err := runCompletion([]string{shell}); err != nil { + t.Errorf("runCompletion(%q) returned error: %v", shell, err) + } + } +} + +func TestRunCompletionInvalidShell(t *testing.T) { + err := runCompletion([]string{"powershell"}) + if err == nil { + t.Fatal("runCompletion with an unknown shell should return an error") + } + if !strings.Contains(err.Error(), "bash|zsh|fish") { + t.Errorf("error should name the supported shells, got: %v", err) + } +} + +func TestRunCompletionNoArg(t *testing.T) { + if err := runCompletion(nil); err == nil { + t.Fatal("runCompletion with no shell argument should return a usage error") + } + if err := runCompletion([]string{"bash", "extra"}); err == nil { + t.Fatal("runCompletion with extra arguments should return a usage error") + } +} + +// TestBashCompletionHeader is a light shape check: the bash script must register +// the completion function against both the `riskkernel` and `rk` commands. +func TestBashCompletionHeader(t *testing.T) { + out := bashCompletion() + for _, want := range []string{"complete -F _riskkernel riskkernel", "complete -F _riskkernel rk"} { + if !strings.Contains(out, want) { + t.Errorf("bash script missing %q", want) + } + } +} + +// TestZshCompletionHeader checks the zsh script carries the required #compdef +// directive so zsh autoloads it correctly. +func TestZshCompletionHeader(t *testing.T) { + out := zshCompletion() + if !strings.HasPrefix(out, "#compdef riskkernel") { + t.Errorf("zsh script must start with a #compdef directive, got: %q", firstLine(out)) + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/cmd/riskkernel/main.go b/cmd/riskkernel/main.go index 40aee29..4296434 100644 --- a/cmd/riskkernel/main.go +++ b/cmd/riskkernel/main.go @@ -57,6 +57,8 @@ func main() { err = runDoctor(args) case "healthcheck": err = runHealthcheck(args) + case "completion": + err = runCompletion(args) case "version", "--version", "-v": fmt.Println("riskkernel", version.String()) case "help", "--help", "-h": @@ -94,6 +96,7 @@ Usage: riskkernel memory show [namespace] Print a memory file riskkernel doctor Diagnose a setup (config, store, provider, policy) riskkernel healthcheck Probe /healthz (used by the Docker HEALTHCHECK) + riskkernel completion Print a shell completion script (bash|zsh|fish) riskkernel version Print build identity riskkernel help Show this help