Skip to content

Commit 11aaeea

Browse files
Support shell completion via carapace spec generation (#197)
* Add a completion example and some docs * Add some more completion test cases * Fix copy paste typo
1 parent b23095c commit 11aaeea

26 files changed

Lines changed: 951 additions & 3 deletions

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Tiny, simple, but powerful CLI framework for modern Go 🚀
2525
- [Sub Commands](#sub-commands)
2626
- [Flags](#flags)
2727
- [Arguments](#arguments)
28+
- [Shell Completion](#shell-completion)
2829
- [Core Principles](#core-principles)
2930
- [😱 Well behaved libraries don't panic](#-well-behaved-libraries-dont-panic)
3031
- [🧘🏻 Keep it Simple](#-keep-it-simple)
@@ -300,6 +301,33 @@ The types you can currently use for positional args are:
300301
> Slice types are not supported (yet), for those you need to use the `cmd.Args()` method to get the arguments manually. I plan to address this but it can be tricky
301302
> as slice types will eat up the remainder of the arguments so I need to figure out a good DevEx for this as it could lead to confusing outcomes
302303
304+
### Shell Completion
305+
306+
`cli` has built-in support for shell completions via [carapace-bin]. Wire in `cli.CompletionSubCommand()` alongside your other subcommands:
307+
308+
```go
309+
cmd, err := cli.New(
310+
"mytool",
311+
// ...
312+
cli.SubCommands(
313+
buildServeCommand,
314+
buildDeployCommand,
315+
cli.CompletionSubCommand(), // add this
316+
),
317+
)
318+
```
319+
320+
Running `mytool completion` outputs a [carapace-spec] YAML document describing your full command tree — all subcommands, flags, and descriptions. Redirect it once to register completions with [carapace-bin]:
321+
322+
```shell
323+
mytool completion > ~/.config/carapace/specs/mytool.yaml
324+
```
325+
326+
carapace-bin then provides completions across bash, zsh, fish, nushell, PowerShell, and more — no shell-specific scripts required.
327+
328+
> [!TIP]
329+
> See the [`./examples/completion`](https://github.com/FollowTheProcess/cli/tree/main/examples/completion) example for a working demonstration, and the [carapace-spec] docs for how to extend the generated YAML with semantic completion hints (file paths, environment variables, etc.)
330+
303331
## Core Principles
304332

305333
When designing and implementing `cli`, I had some core goals and guiding principles for implementation.
@@ -410,3 +438,5 @@ I built `cli` for my own uses really, so I've quickly adopted it across a number
410438
[spf13/pflag]: https://github.com/spf13/pflag
411439
[urfave/cli]: https://github.com/urfave/cli
412440
[functional options]: https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
441+
[carapace-bin]: https://github.com/carapace-sh/carapace-bin
442+
[carapace-spec]: https://github.com/carapace-sh/carapace-spec

completion.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
"strings"
8+
9+
publicflag "go.followtheprocess.codes/cli/flag"
10+
"go.yaml.in/yaml/v4"
11+
)
12+
13+
const completionLong = `
14+
Outputs a carapace-spec YAML document describing this command's flags and subcommands.
15+
16+
Redirect the output to register completions with carapace-bin:
17+
18+
mytool completion > ~/.config/carapace/specs/mytool.yaml
19+
`
20+
21+
// CompletionSubCommand returns a [Builder] that constructs a "completion" subcommand.
22+
//
23+
// When run, it outputs a carapace-spec YAML document to stdout describing the full
24+
// command tree, all flags, and all subcommands.
25+
//
26+
// Wire it into your root command:
27+
//
28+
// cli.New("mytool",
29+
// cli.SubCommands(cli.CompletionSubCommand()),
30+
// ...
31+
// )
32+
//
33+
// Users register completions by running:
34+
//
35+
// mytool completion > ~/.config/carapace/specs/mytool.yaml
36+
//
37+
// carapace-bin then provides completions across bash, zsh, fish, nushell,
38+
// powershell, and more. See https://github.com/carapace-sh/carapace-spec
39+
// for how to extend the generated YAML with semantic completion hints.
40+
func CompletionSubCommand() Builder {
41+
return func() (*Command, error) {
42+
return New(
43+
"completion",
44+
Short("Output a carapace-spec YAML document to stdout"),
45+
Long(completionLong),
46+
Run(func(_ context.Context, cmd *Command) error {
47+
data, err := marshalCompletionSpec(cmd.root())
48+
if err != nil {
49+
return fmt.Errorf("generating carapace spec: %w", err)
50+
}
51+
52+
_, err = cmd.Stdout().Write(data)
53+
54+
return err
55+
}),
56+
)
57+
}
58+
}
59+
60+
// specCommand is the internal representation of a carapace-spec YAML command node.
61+
//
62+
// See https://github.com/carapace-sh/carapace-spec for the full schema.
63+
type specCommand struct {
64+
Name string `yaml:"name"`
65+
Description string `yaml:"description,omitempty"`
66+
Flags map[string]string `yaml:"flags,omitempty"`
67+
PersistentFlags map[string]string `yaml:"persistentflags,omitempty"`
68+
Commands []specCommand `yaml:"commands,omitempty"`
69+
}
70+
71+
// marshalCompletionSpec generates a carapace-spec YAML document for cmd and its
72+
// full subcommand tree.
73+
func marshalCompletionSpec(cmd *Command) ([]byte, error) {
74+
spec := buildSpecTree(cmd)
75+
76+
// Help and version are on every command; declare them once as persistentflags.
77+
spec.PersistentFlags = persistentFlagsFrom(cmd)
78+
79+
data, err := yaml.Marshal(spec)
80+
if err != nil {
81+
return nil, fmt.Errorf("marshalling carapace spec: %w", err)
82+
}
83+
84+
return data, nil
85+
}
86+
87+
// isSystemFlag reports whether name is an automatically-injected flag.
88+
// These flags appear on every command and are emitted as persistentflags
89+
// on the root rather than repeated in every command's flags map.
90+
func isSystemFlag(name string) bool {
91+
return name == "help" || name == "version"
92+
}
93+
94+
// buildSpecTree recursively builds a specCommand tree from cmd.
95+
func buildSpecTree(cmd *Command) specCommand {
96+
spec := specCommand{
97+
Name: cmd.name,
98+
Description: cmd.short,
99+
Flags: specFlagsFrom(cmd),
100+
}
101+
102+
if len(cmd.subcommands) > 0 {
103+
sorted := make([]*Command, len(cmd.subcommands))
104+
copy(sorted, cmd.subcommands)
105+
slices.SortFunc(sorted, func(a, b *Command) int {
106+
return strings.Compare(a.name, b.name)
107+
})
108+
109+
spec.Commands = make([]specCommand, len(sorted))
110+
for i, sub := range sorted {
111+
spec.Commands[i] = buildSpecTree(sub)
112+
}
113+
}
114+
115+
return spec
116+
}
117+
118+
// specFlagsFrom builds the carapace-spec flags map for cmd, excluding system
119+
// flags (help, version) which are emitted as persistentflags on the root.
120+
func specFlagsFrom(cmd *Command) map[string]string {
121+
flags := make(map[string]string)
122+
123+
for name, fl := range cmd.flagSet().All() {
124+
if isSystemFlag(name) {
125+
continue
126+
}
127+
128+
key := specFlagKey(name, fl.Short(), fl.Type(), fl.NoArgValue())
129+
flags[key] = fl.Usage()
130+
}
131+
132+
if len(flags) == 0 {
133+
return nil
134+
}
135+
136+
return flags
137+
}
138+
139+
// persistentFlagsFrom builds the carapace-spec persistentflags map from the
140+
// system flags (help, version) that are automatically added to every command.
141+
func persistentFlagsFrom(cmd *Command) map[string]string {
142+
flags := make(map[string]string)
143+
144+
for name, fl := range cmd.flagSet().All() {
145+
if !isSystemFlag(name) {
146+
continue
147+
}
148+
149+
key := specFlagKey(name, fl.Short(), fl.Type(), fl.NoArgValue())
150+
flags[key] = fl.Usage()
151+
}
152+
153+
if len(flags) == 0 {
154+
return nil
155+
}
156+
157+
return flags
158+
}
159+
160+
// specFlagKey encodes a flag name, shorthand, and type into a carapace-spec
161+
// flag map key.
162+
func specFlagKey(name string, short rune, typ, noArgValue string) string {
163+
var base string
164+
if short != publicflag.NoShortHand {
165+
base = fmt.Sprintf("-%s, --%s", string(short), name)
166+
} else {
167+
base = "--" + name
168+
}
169+
170+
switch {
171+
case noArgValue == "":
172+
// Value is required
173+
return base + "="
174+
case typ == "bool":
175+
return base
176+
case typ == "count":
177+
return base + "*"
178+
default:
179+
// Optional value
180+
return base + "?"
181+
}
182+
}

0 commit comments

Comments
 (0)