Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
## [Unreleased]

### Added
- **Shell completions.** `riskkernel completion <bash|zsh|fish>` 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
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions cmd/riskkernel/completion.go
Original file line number Diff line number Diff line change
@@ -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 <bash|zsh|fish>`: 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 <bash|zsh|fish>")
}
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 <bash|zsh|fish>

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()
}
110 changes: 110 additions & 0 deletions cmd/riskkernel/completion_test.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions cmd/riskkernel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -94,6 +96,7 @@ Usage:
riskkernel memory show <name> [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 <shell> Print a shell completion script (bash|zsh|fish)
riskkernel version Print build identity
riskkernel help Show this help

Expand Down