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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bash|zsh|fish>` 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 <TAB>` completes
subcommands and flag names, and a flag's values complete once you've typed it
(`things list --color <TAB>` → `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
Expand Down
72 changes: 72 additions & 0 deletions cmd/things/completions.go
Original file line number Diff line number Diff line change
@@ -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
}
140 changes: 140 additions & 0 deletions cmd/things/completions_test.go
Original file line number Diff line number Diff line change
@@ -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 <shell>` 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)
}
}
})
}
}
15 changes: 14 additions & 1 deletion cmd/things/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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 <shell>` — 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)
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
4 changes: 4 additions & 0 deletions internal/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ Pipe JSON to another tool:
things --json list today | jq '.[] | .title'
```

## Shell completions

`things completions <bash|zsh|fish>` 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.
Expand Down