diff --git a/README.md b/README.md index b24e838..720c0db 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,36 @@ flags you pass are sent, so unset fields stay untouched. Supported flags: the Things auth token — enable *Things → Settings → General → Enable Things URLs*. +### Shell completions + +`things completions ` prints a completion script for the named +shell. The script delegates back to `things` at completion time, so it never +goes stale as the command surface changes — `things ` completes +subcommands and flag names, and a flag's values complete once you've typed it +(`things list --color ` → `auto`, `always`, `never`). + +The Homebrew cask installs the binary but doesn't generate completions yet — +that's a planned follow-up. Until it lands, and on every install path, load the +script yourself. Completion shells out to `things` by name, so it works as long +as `things` is on your `PATH` (the Homebrew, `go install`, and `make install` +paths all put it there): + +```sh +# bash — add to ~/.bashrc (complete -C is a bash builtin; no extra package needed) +source <(things completions bash) + +# zsh — add to ~/.zshrc, after compinit runs (the stub's bashcompinit needs compdef) +source <(things completions zsh) + +# fish — load now, and/or persist for new shells +things completions fish | source +mkdir -p ~/.config/fish/completions +things completions fish > ~/.config/fish/completions/things.fish +``` + +Completion runs entirely from the static command tree — it never reads the +Things database, so project, area, and tag *names* are not (yet) completed. + ## Agent skill `things-cli` bundles an agent skill that teaches Claude Code, OpenAI's Codex diff --git a/cmd/things/completions.go b/cmd/things/completions.go new file mode 100644 index 0000000..a9fb48e --- /dev/null +++ b/cmd/things/completions.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/alecthomas/kong" +) + +// CompletionsCmd prints a shell completion script for the requested shell to +// stdout. The emitted script wires the shell's native completion to call back +// into `things` at completion time — the runtime side that computes candidates +// is handled by kongplete.Complete in main — so completions stay in sync with +// the CLI surface automatically with no regeneration step. +// +// This output is what Homebrew's generate_completions_from_executable captures +// at install time, and what users on other install paths can source directly: +// +// source <(things completions zsh) # current shell +// things completions zsh > ~/.things-completion.zsh # then source it from rc +type CompletionsCmd struct { + Shell string `arg:"" required:"" enum:"bash,zsh,fish" help:"Shell to emit completions for (bash|zsh|fish)."` +} + +// Per-shell completion stubs. The {{cmd}} placeholder is replaced with the +// command name at runtime. These mirror github.com/willabides/kongplete's +// install templates: bash and zsh register `complete -C` so the shell runs +// `things` itself to compute candidates, and fish wraps the same COMP_LINE +// mechanism. We keep our own copies rather than reuse kongplete's +// InstallCompletions command because that command auto-detects the user's login +// shell and bakes in an absolute binary path; here we take an explicit shell +// argument and emit the bare command name so the script keeps working across a +// `brew upgrade` (which moves the versioned Cellar path). +const ( + bashCompletion = "complete -C {{cmd}} {{cmd}}\n" + zshCompletion = "autoload -U +X bashcompinit && bashcompinit\ncomplete -C {{cmd}} {{cmd}}\n" + fishCompletion = `function __complete_{{cmd}} + set -lx COMP_LINE (commandline -cp) + test -z (commandline -ct) + and set COMP_LINE "$COMP_LINE " + {{cmd}} +end +complete -f -c {{cmd}} -a "(__complete_{{cmd}})" +` +) + +var completionScripts = map[string]string{ + "bash": bashCompletion, + "zsh": zshCompletion, + "fish": fishCompletion, +} + +func (c *CompletionsCmd) Run(kctx *kong.Context, d *Deps) error { + script, err := renderCompletion(c.Shell, kctx.Model.Name) + if err != nil { + return err + } + _, err = fmt.Fprint(d.Stdout, script) + return err +} + +// renderCompletion returns the completion script for shell with the command +// name substituted for {{cmd}}. It errors on a shell that has no template — +// unreachable while the enum tag and completionScripts keys agree, but the +// guard turns a silent empty script into a clear error if the two ever drift. +func renderCompletion(shell, name string) (string, error) { + script, ok := completionScripts[shell] + if !ok { + return "", fmt.Errorf("unsupported shell %q (want bash, zsh, or fish)", shell) + } + return strings.ReplaceAll(script, "{{cmd}}", name), nil +} diff --git a/cmd/things/completions_test.go b/cmd/things/completions_test.go new file mode 100644 index 0000000..d1dd22c --- /dev/null +++ b/cmd/things/completions_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "strconv" + "strings" + "testing" + + "github.com/alecthomas/kong" + "github.com/willabides/kongplete" + + "github.com/ryanlewis/things-cli/internal/skill" + "github.com/ryanlewis/things-cli/internal/things" +) + +// TestCompletionsEmitsScriptPerShell drives `completions ` through the +// real Kong wiring and asserts each shell gets a non-empty script that names +// the binary and carries the shell-specific completion directive. +func TestCompletionsEmitsScriptPerShell(t *testing.T) { + cases := map[string]string{ + "bash": "complete -C things things", + "zsh": "complete -C things things", + "fish": "complete -f -c things", + } + for shell, want := range cases { + t.Run(shell, func(t *testing.T) { + _, ctx := parse(t, "completions", shell) + var buf bytes.Buffer + if err := ctx.Run(&Deps{Stdout: &buf}); err != nil { + t.Fatalf("run completions %s: %v", shell, err) + } + out := buf.String() + if out == "" { + t.Fatalf("completions %s: empty output", shell) + } + if !strings.Contains(out, "things") { + t.Errorf("completions %s: output missing binary name: %q", shell, out) + } + if !strings.Contains(out, want) { + t.Errorf("completions %s: output missing %q: %q", shell, want, out) + } + }) + } +} + +// TestCompletionsZshEnablesBashcompinit guards the zsh-specific preamble: the +// `complete -C` directive only works in zsh after bashcompinit is loaded. +func TestCompletionsZshEnablesBashcompinit(t *testing.T) { + _, ctx := parse(t, "completions", "zsh") + var buf bytes.Buffer + if err := ctx.Run(&Deps{Stdout: &buf}); err != nil { + t.Fatalf("run completions zsh: %v", err) + } + if !strings.Contains(buf.String(), "bashcompinit") { + t.Errorf("zsh script must enable bashcompinit: %q", buf.String()) + } +} + +// TestCompletionsRejectsUnknownShell confirms the enum tag rejects shells we +// don't ship a stub for, rather than emitting an empty or wrong script. +func TestCompletionsRejectsUnknownShell(t *testing.T) { + var cli CLI + parser, err := kong.New(&cli, kong.Name("things"), + kong.Vars{ + "builtin_lists": strings.Join(things.BuiltinLists, ", "), + "skill_agents": skill.AgentNames(), + }, + ) + if err != nil { + t.Fatalf("kong.New: %v", err) + } + if _, err := parser.Parse([]string{"completions", "powershell"}); err == nil { + t.Fatal("expected enum rejection for unsupported shell") + } +} + +// TestRenderCompletionSubstitutesName proves {{cmd}} actually flows from the +// command name into the output (using a non-"things" name so the assertion +// can't pass by coincidence) and that an unknown shell hits the guard rather +// than emitting an empty script. +func TestRenderCompletionSubstitutesName(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish"} { + out, err := renderCompletion(shell, "thingz") + if err != nil { + t.Fatalf("renderCompletion(%q): %v", shell, err) + } + if strings.Contains(out, "{{cmd}}") { + t.Errorf("renderCompletion(%q): placeholder not substituted: %q", shell, out) + } + if !strings.Contains(out, "thingz") || strings.Contains(out, "things ") { + t.Errorf("renderCompletion(%q): name not propagated: %q", shell, out) + } + } + + if _, err := renderCompletion("powershell", "things"); err == nil { + t.Error("renderCompletion: expected error for unsupported shell") + } +} + +// TestRuntimeCompletionAnswersCompLine locks down the load-bearing half of the +// feature: kongplete.Complete answering a COMP_LINE query from the static +// command tree. WithExitFunc intercepts the would-be os.Exit so the assertions +// run in-process; kong.Writers redirects candidates into a buffer. +func TestRuntimeCompletionAnswersCompLine(t *testing.T) { + cases := []struct { + line string + want []string + }{ + {"things ", []string{"list", "add", "completions"}}, // top-level subcommands + {"things list --", []string{"--project", "--color"}}, // flag names + {"things --color ", []string{"auto", "always", "never"}}, // enum values + } + for _, tc := range cases { + t.Run(tc.line, func(t *testing.T) { + t.Setenv("COMP_LINE", tc.line) + t.Setenv("COMP_POINT", strconv.Itoa(len(tc.line))) + + var buf bytes.Buffer + var cli CLI + parser := kong.Must(&cli, kong.Name("things"), kong.Writers(&buf, &buf), + kong.Vars{ + "builtin_lists": strings.Join(things.BuiltinLists, ", "), + "skill_agents": skill.AgentNames(), + }, + ) + + exited := -1 + kongplete.Complete(parser, kongplete.WithExitFunc(func(code int) { exited = code })) + if exited != 0 { + t.Fatalf("expected completion to exit 0, got %d (out=%q)", exited, buf.String()) + } + out := buf.String() + for _, w := range tc.want { + if !strings.Contains(out, w) { + t.Errorf("COMP_LINE=%q: missing %q in candidates:\n%s", tc.line, w, out) + } + } + }) + } +} diff --git a/cmd/things/main.go b/cmd/things/main.go index 999f296..f17fdb6 100644 --- a/cmd/things/main.go +++ b/cmd/things/main.go @@ -13,6 +13,8 @@ import ( "github.com/alecthomas/kong" "github.com/mattn/go-isatty" + "github.com/willabides/kongplete" + "github.com/ryanlewis/things-cli/internal/cache" "github.com/ryanlewis/things-cli/internal/db" "github.com/ryanlewis/things-cli/internal/model" @@ -49,6 +51,8 @@ type CLI struct { Import ImportCmd `cmd:"" help:"Batch create/update via the Things JSON URL scheme. Reads JSON from stdin or --file."` Skill SkillCmd `cmd:"" help:"Manage the bundled agent skill (Claude Code, etc.)."` Ver VersionCmd `cmd:"" name:"version" help:"Print version and exit."` + + Completions CompletionsCmd `cmd:"" help:"Print a shell completion script (bash|zsh|fish)."` } // Deps carries cross-cutting state into each command's Run method. The DB is @@ -718,7 +722,7 @@ func (c *OpenCmd) Run(d *Deps) error { func main() { var cli CLI - ctx := kong.Parse(&cli, + parser := kong.Must(&cli, kong.Name("things"), kong.Description("CLI for Things3"), kong.UsageOnError(), @@ -729,6 +733,15 @@ func main() { }, ) + // Answer shell completion requests. When the shell invokes us with COMP_LINE + // set — via the script emitted by `things completions ` — this + // computes candidates from the command tree and exits. For normal + // invocations COMP_LINE is unset and this is a no-op. + kongplete.Complete(parser) + + ctx, err := parser.Parse(os.Args[1:]) + parser.FatalIfErrorf(err) + if err := output.SetColorMode(cli.Color); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(2) diff --git a/go.mod b/go.mod index 74cd88e..327c9bc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/colorprofile v0.4.3 github.com/mattn/go-isatty v0.0.22 + github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 modernc.org/sqlite v1.50.1 ) @@ -21,12 +22,16 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/posener/complete v1.2.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect diff --git a/go.sum b/go.sum index a78d329..8d6df52 100644 --- a/go.sum +++ b/go.sum @@ -22,12 +22,21 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -42,10 +51,22 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= +github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= @@ -60,6 +81,10 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 3ee2979..d139aa1 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -115,6 +115,10 @@ Pipe JSON to another tool: things --json list today | jq '.[] | .title' ``` +## Shell completions + +`things completions ` prints a completion script for that shell. It delegates back to the binary (which must be on `PATH`), so it stays in sync with the CLI surface. The Homebrew cask installs the binary but doesn't generate completions yet (a planned follow-up); for now the user loads it with `source <(things completions zsh)` (bash/zsh) or `things completions fish | source`. Completion is flag/subcommand-name only — it never reads the Things database. + ## Tips - Prefer `--json` in scripted contexts.