dry-cli-autocomplete 0.1.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5de81918d8ea085c400d5578481fd513b595d21b4e6c46fa2cce70ffbbe051f1
4
- data.tar.gz: b57a07d39c36db90a843474694e61c1fcf970f58a2267bc44449ed60017f3369
3
+ metadata.gz: b392efc439cb2a04ce5858ab9f8931cf3be6c987df699c4ef56e0c20782775d9
4
+ data.tar.gz: 749295c776797acfd2a82a059d632df3d1f02170e158c4b3e8ad260def61009a
5
5
  SHA512:
6
- metadata.gz: fd15d74c1ebece2e83817330d91f09f664b301753cbe09f2c5413ee042db260ea04acff53c6b090b1ea275b748f38949fd348f2b4bc847680446e546f57c6c68
7
- data.tar.gz: c7bdc08cdeefa48c8ea9f67c9667fb76053b28a75ff96f7d1239ab2f1135d6f22a296a402a64c364fbedb7e8b7ede2b25e2ae8ebaa14087233d51cd6a3f40ca3
6
+ metadata.gz: b33692dd9535f11c1057a75c5c689dc05d6dff0850094478b7f7cf4867436272855786d6b2c7a1f406ecf029da678be33ff13738d7cbecca69fe1536fde292ef
7
+ data.tar.gz: 5dacc2b7a57ddc9ca177ec1f0c29a1f9d6514e08f0eeec9ce1863a3c88217583ce6f9013f02d0293671fecdf7657e19d9c8d3a57224e34d71bd9aa7be7ac6f1a
@@ -0,0 +1,23 @@
1
+ {
2
+ "search.exclude": {
3
+ "**/node_modules": true,
4
+ "**/bower_components": true,
5
+ "**/*.code-search": true,
6
+ "**/.ruby-lsp": true,
7
+ "**/.rubymate": true
8
+ },
9
+ "files.watcherExclude": {
10
+ ".git/objects/**": true,
11
+ ".git/subtree-cache/**": true,
12
+ ".hg/store/**": true,
13
+ "*/.git/objects/**": true,
14
+ "*/.git/subtree-cache/**": true,
15
+ "*/.hg/store/**": true,
16
+ "**/.trunk/*actions/": true,
17
+ "**/.trunk/*logs/": true,
18
+ "**/.trunk/*notifications/": true,
19
+ "**/.trunk/*out/": true,
20
+ "**/.trunk/*plugins/": true,
21
+ "**/.rubymate": true
22
+ }
23
+ }
data/CHANGELOG.md CHANGED
@@ -1,14 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Options complete the way dry-cli parses them. `option :as_of` completed as `--as_of`, which dry-cli accepts but its own help never shows; it is now `--as-of`. A boolean also completes its `--no-` form, and an alias declared without dashes (`aliases: ["f"]`) gains them (`-f`). A name with capitals is downcased as dry-cli registers it: `option :dryRun` completes as `--dryrun`.
4
+ - In zsh, a `type: :flag` option no longer takes a value. `run --quiet x` completed `x` as `--quiet`'s argument, while dry-cli parses it as `quiet: true` and an argument `x`.
5
+ - In zsh, a boolean's two forms exclude each other, so once `--force` is on the line zsh stops offering `--no-force`. The `--no-` form is described as `Turn off --force` rather than repeating the description, which read backwards.
6
+ - `Command[MyCLI]` keeps its description and examples. dry-cli empties both on every subclass, so `mycli completion --help` printed usage alone. The examples now name the bound `program_name:`.
7
+ - `completion` now writes to the stream a host passes to `Dry::CLI#call(out:)`. It used to write to `$stdout` regardless, so a launcher's own STDOUT, and an in-process Aruba run, received nothing. Called directly, outside `Dry::CLI`, it still falls back to `$stdout`.
8
+
9
+ ## [0.5.0] - 2026-09-16
10
+
11
+ Documentation only; no change to the gem's behaviour.
12
+
13
+ - The README now matches the generated scripts. The bash example is real output, a zsh excerpt is included, and it documents `file:` on arguments and the name-based file guess, `program_name:`, option and positional values, `eval` for bash, placing the zsh `eval` after `compinit`, and Ruby 4.0.
14
+ - The specification moved to `docs/SPECIFICATION.md` and is marked as describing version 0.1.0. It is no longer part of the YARD docs.
15
+ - The YARD docs title names this gem instead of an unrelated project.
16
+ - Added a warning about AI contributions and an author section to the README.
17
+
3
18
  ## [0.1.3] - 2026-08-23
4
19
 
5
- - The zsh script now registers itself when sourced. `eval "$(mycli completion zsh)"`
6
- previously ran the completion function outside a completion context, and zsh
7
- answered with `_tags:comptags:36: can only be called from completion function`.
8
- The script now calls itself when autoloaded from `$fpath` and calls `compdef`
9
- when sourced, so both work.
10
- - The zsh function is named `_mycli` rather than `_mycli_completions`, matching
11
- the name a `#compdef` file is autoloaded under. The bash function is unchanged.
20
+ - The zsh script now registers itself when sourced. `eval "$(mycli completion zsh)"` previously ran the completion function outside a completion context, and zsh answered with `_tags:comptags:36: can only be called from completion function`. The script now calls itself when autoloaded from `$fpath` and calls `compdef` when sourced, so both work.
21
+ - The zsh function is named `_mycli` rather than `_mycli_completions`, matching the name a `#compdef` file is autoloaded under. The bash function is unchanged.
12
22
 
13
23
  ## [0.1.2] - 2026-08-22
14
24
 
data/CLAUDE.md CHANGED
@@ -6,9 +6,9 @@ Guidance for Claude Code (claude.ai/code) working in this repository.
6
6
 
7
7
  A Ruby gem that generates static shell completion scripts for any `Dry::CLI` application. A host registers one command; `mycli completion bash` prints a script; the user evaluates it from a shell profile.
8
8
 
9
- **Read `SPECIFICATION.md` first.** It carries the design decisions, the measurements behind them, and the acceptance criteria. This file covers how to work in the repository; that one covers what to build and why.
9
+ **Read `docs/SPECIFICATION.md` first.** It carries the design decisions, the measurements behind them, and the acceptance criteria. This file covers how to work in the repository; that one covers what to build and why.
10
10
 
11
- The gem is a skeleton from `bundle gem` with no implementation yet. Nothing in `lib/` does anything.
11
+ The gem is implemented and released (tags `v0.1.0` through `v0.1.3`). `README.md` documents the host-facing behaviour; keep it in step with `lib/` when behaviour changes.
12
12
 
13
13
  ## Environment
14
14
 
@@ -23,11 +23,12 @@ bundle install
23
23
  bundle exec rspec # the suite
24
24
  bundle exec rubocop # the linter
25
25
  bundle exec rubocop -a # autocorrect
26
- bundle exec rake # both, and the default task
26
+ bundle exec rake # the suite only; it is the default task and what CI runs
27
+ bundle exec rake doc # YARD documentation
27
28
  bin/console # IRB with the gem loaded
28
29
  ```
29
30
 
30
- The gemspec sets `required_ruby_version >= 3.2.0` and `.rubocop.yml` sets `TargetRubyVersion: 3.2`. Keep the two in step: raising one without the other produces a linter that permits syntax the gemspec claims to support, or the reverse.
31
+ The gemspec sets `required_ruby_version >= 4.0`, `.rubocop.yml` sets `TargetRubyVersion: 4.0`, and CI runs Ruby 4.0.6. Keep them in step: raising one without the other produces a linter that permits syntax the gemspec claims to support, or the reverse.
31
32
 
32
33
  ## The trap that has already bitten this repository once
33
34
 
@@ -43,6 +44,8 @@ module Dry
43
44
 
44
45
  `lib/dry/cli/autocomplete/version.rb` deliberately does **not** `require "dry/cli"`, because the gemspec loads it at build time when the dependency may not be installed. It reopens `class CLI` on its own. dry-cli's `CLI` inherits from `Object`, so an empty reopening is compatible whichever loads first.
45
46
 
47
+ The same lexical scoping bites constants. Every file here sits inside `module Dry`, so a bare `Struct` or `Data` resolves to `Dry::Struct` or `Dry::Data` first when a host has loaded them. Write `::Data` and `::Struct`. `spec/dry/cli/autocomplete/dry_struct_host_spec.rb` loads dry-struct to catch this.
48
+
46
49
  For deriving names at runtime, use `dry-inflector`. `Dry::Inflector.new { |i| i.acronym("CLI") }` handles both the casing above and the `underscore` needed for shell function identifiers. `Dry::CLI::Inflector` ships with dry-cli but only has `dasherize` and is marked `@api private`; do not depend on it.
47
50
 
48
51
  ## Architecture
@@ -58,31 +61,41 @@ Four pieces, and the boundary between the first and the rest is load-bearing.
58
61
 
59
62
  Two rules hold this shape together, both measured rather than assumed:
60
63
 
61
- - **The command shim loads no emitter.** A host pays nothing at boot for a command run once per shell. `SPECIFICATION.md` §2.4.
62
- - **The spec builder touches only the registry.** It must be safe to run at shell startup, because it runs at every shell startup. `SPECIFICATION.md` §2.2.
64
+ - **The command shim loads no emitter.** A host pays nothing at boot for a command run once per shell. `docs/SPECIFICATION.md` §2.4.
65
+ - **The spec builder touches only the registry.** It must be safe to run at shell startup, because it runs at every shell startup. `docs/SPECIFICATION.md` §2.2.
66
+
67
+ `SpecBuilder` returns frozen `::Data` values: a `CompletionSpec` holding one `Node` per visible command or group, each with `OptionSpec` and `ArgumentSpec` entries. An argument is a file argument when it declares `file: true`, or, with no `file:` key, when its name matches `/file|path/i`.
63
68
 
64
69
  The emitters take the same description and share no code. A fourth shell should be a new emitter class, never a branch inside an existing one.
65
70
 
66
71
  ## Conventions
67
72
 
68
- - **The generator is not a hot path.** It runs in 0.067ms against a 27-command registry. Do not optimise it, do not add native extensions, and do not cache anything. The reasoning is in `SPECIFICATION.md` §2.3.
73
+ - **The generator is not a hot path.** It runs in 0.067ms against a 27-command registry. Do not optimise it, do not add native extensions, and do not cache anything. The reasoning is in `docs/SPECIFICATION.md` §2.3.
69
74
  - **Read a registry through its methods, not its ivars.** `registry.get(path)` returns a result exposing `command`, `children` and `names`. `instance_variable_get(:@node)` is what the gem this one replaces does, and it will break on a dry-cli release. Do not mistake this for a public API: in 1.4.1 `Registry#get`, all of `CommandRegistry`, every `LookupResult` reader and every `Node` reader carry `@api private`. There is no public way to enumerate a registry, so an upgrade can break the walk and the fixture suite is what catches it.
70
- - **Test against registries this project did not write.** A generator tested against one CLI encodes that CLI's shape. `SPECIFICATION.md` §5.
71
- - **Validate generated shell with the shell.** `bash -n` and `zsh -n` parse without executing. A regex over generated output proves nothing about whether it runs.
75
+ - **Test against registries this project did not write.** A generator tested against one CLI encodes that CLI's shape. `docs/SPECIFICATION.md` §5.
76
+ - **Validate generated shell with the shell.** `bash -n` and `zsh -n` parse without executing. A regex over generated output proves nothing about whether it runs. `spec/support/shell_helpers.rb` skips a missing shell locally and fails on CI.
77
+ - **Emitter output is pinned by golden files** in `spec/support/golden/`. A deliberate output change means updating the golden file in the same commit.
78
+ - **The bash emitter targets bash 3.2.** macOS ships it as `/bin/bash`, so no associative arrays.
79
+ - **The zsh script works both from `$fpath` and from `eval`.** Its footer checks `$funcstack[1]` against the function name, so the function must stay named `_<program>`.
72
80
  - **Commit messages**: imperative mood, 50-character subject, no full stop. A body only where the change needs explaining, saying what and why.
73
81
  - **Writing prose here**: no em dashes, active voice, plain words. Say what a thing does, not how it feels. If a sentence could appear unchanged in another project's README, it says nothing about this one and should go.
74
82
 
75
83
  ## Repository layout
76
84
 
77
- | Path | What it is |
78
- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
79
- | `SPECIFICATION.md` | What to build, why, and what "done" means |
80
- | `lib/dry/cli/autocomplete.rb` | Entry point |
81
- | `lib/dry/cli/autocomplete/version.rb` | Version, loaded standalone by the gemspec |
82
- | `sig/` | RBS signatures, generated by `bundle gem` and not yet real |
83
- | `exe/dry-cli-autocomplete` | Generated by `bundle gem`. **Probably should be deleted**: this is a library, and a host provides the executable. |
84
- | `.github/workflows/main.yml` | CI |
85
-
86
- ## Before the first release
87
-
88
- The gemspec still carries `bundle gem` TODOs that will refuse to build: `spec.summary`, `spec.description`, and `spec.metadata["allowed_push_host"]`. The `dry-` prefix and the `Dry::CLI::Autocomplete` namespace imply an affiliation with dry-rb that does not exist, so say so in the README, or ask them first.
85
+ | Path | What it is |
86
+ | ------------------------------------- | ------------------------------------------------------------------------------------ |
87
+ | `docs/SPECIFICATION.md` | What to build, why, and what "done" means |
88
+ | `README.md` | Host-facing documentation |
89
+ | `CHANGELOG.md` | Release notes |
90
+ | `lib/dry/cli/autocomplete.rb` | Entry point for `require "dry/cli/autocomplete"`. Hosts require `command.rb` instead |
91
+ | `lib/dry-cli-autocomplete.rb` | Bundler-style entry point, requires the one above |
92
+ | `lib/dry/cli/autocomplete/version.rb` | Version, loaded standalone by the gemspec |
93
+ | `sig/` | RBS signatures. Only `VERSION` is declared so far |
94
+ | `spec/support/fixtures/` | Registries the suite walks, including shapes modelled on other CLIs |
95
+ | `spec/support/golden/` | Expected bash and zsh output, compared byte for byte |
96
+ | `.github/workflows/main.yml` | CI: installs zsh, runs `bundle exec rake` |
97
+ | `.github/workflows/rubocop.yml` | CI: runs `bundle exec rubocop` |
98
+
99
+ ## Releasing
100
+
101
+ Bump `VERSION` in `lib/dry/cli/autocomplete/version.rb`, add a `CHANGELOG.md` entry, then `bundle exec rake release`. The gemspec requires MFA for pushes. The `dry-` prefix and the `Dry::CLI::Autocomplete` namespace imply an affiliation with dry-rb that does not exist; the README says so, and that note should stay.
data/README.md CHANGED
@@ -5,7 +5,66 @@
5
5
  Shell completion for [dry-cli](https://github.com/dry-rb/dry-cli) applications, with no Ruby in the TAB path.
6
6
 
7
7
  > [!NOTE]
8
- > For the specification of this gem see [SPECIFICATION](SPECIFICATION.md)
8
+ > For the original specification of this gem see [SPECIFICATION](docs/SPECIFICATION.md)
9
+
10
+ ______________________________________________________________________
11
+
12
+ > [!WARNING]
13
+ > This gem was written with a collaboration with Claude Code. Most of the ruby was written by a human (myself), reviewed and pushed to GitHub by Claude (anyone loves writing commit descriptions?). The part where Claude authored the most code is the ZSH autocompletion code as I'm less familiar with it than BASH. If you prefer not to use gems that had some AI contributions that were reviewed by a human, do not use this gem.
14
+
15
+ ## Usage
16
+
17
+ For the impatient:
18
+
19
+ ```bash
20
+ gem install dry-cli dry-cli-autocomplete -N
21
+ ```
22
+
23
+ Register one command:
24
+
25
+ ```ruby
26
+ require "dry/cli"
27
+ require "dry/cli/autocomplete/command"
28
+
29
+ module MyCLI
30
+ extend Dry::CLI::Registry
31
+
32
+ register "deploy", Deploy
33
+ register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
34
+ end
35
+ ```
36
+
37
+ Then load the script from your shell's initialization file:
38
+
39
+ ```bash
40
+ eval "$(mycli completion bash)" # ~/.bashrc
41
+ eval "$(mycli completion zsh)" # ~/.zshrc, after compinit
42
+ ```
43
+
44
+ ### Real Example
45
+
46
+ [`examples/`](examples/README.md) holds a working CLI with the command registered, to try it end to end.
47
+
48
+ ```bash
49
+ $ cd examples && bundle exec bin/mycli -h
50
+ mycli
51
+
52
+ Downloads URLs and finds hosts on the local network, several at once.
53
+
54
+ USAGE
55
+ mycli COMMAND [OPTIONS]
56
+
57
+ COMMANDS
58
+ version, v Print version
59
+ download-urls, download Download URLs, each to its own file
60
+ find-hosts, hosts Find hosts on the local network that listen on
61
+ common TCP ports
62
+ completion Print a shell completion script
63
+
64
+ OPTIONS
65
+ -h, --help Show help
66
+ -v, --version Print version
67
+ ```
9
68
 
10
69
  Your CLI knows its own commands, options, aliases and enum values. The shell does not. This gem walks your registry once, prints a bash or zsh script, and you source it from your profile. Pressing TAB then spawns nothing and costs nothing, because every completion the script will ever offer is already inside it.
11
70
 
@@ -41,6 +100,28 @@ There is a dependency argument too. `completely` pulls `colsole`, `docopt_ng` an
41
100
  Given this registry:
42
101
 
43
102
  ```ruby
103
+ class Version < Dry::CLI::Command
104
+ desc "Print the version"
105
+ option :format, values: %w[json plain], desc: "Output format"
106
+ end
107
+
108
+ class Deploy < Dry::CLI::Command
109
+ desc "Deploy the application"
110
+ option :force, type: :boolean, aliases: ["-f"], desc: "Skip confirmation"
111
+ argument :environment, values: %w[staging production], required: true, desc: "Target environment"
112
+ end
113
+
114
+ class DbStatus < Dry::CLI::Command
115
+ desc "Show pending migrations"
116
+ option :verbose, type: :boolean, desc: "Print full migration history"
117
+ end
118
+
119
+ class DbMigrate < Dry::CLI::Command
120
+ desc "Run pending migrations"
121
+ option :step, desc: "Migrate to a specific step"
122
+ argument :file, desc: "Migration file to run"
123
+ end
124
+
44
125
  register "version", Version
45
126
  register "deploy", Deploy
46
127
  register "db", DbStatus do |prefix|
@@ -49,17 +130,23 @@ end
49
130
  register "secret", Secret, hidden: true
50
131
  ```
51
132
 
52
- `mycli completion bash` prints a `complete -F` function that dispatches on the command path:
133
+ `mycli completion bash` prints a `complete -F` function. It walks `COMP_WORDS` to find the command path under the cursor, answers option values first, then offers that path's words:
53
134
 
54
135
  ```bash
55
136
  _mycli_completions() {
56
137
  # ...walks COMP_WORDS to find the current command path...
138
+
139
+ case "$path:$prev" in
140
+ "version:--format") COMPREPLY=($(compgen -W "json plain" -- "$cur")); return ;;
141
+ esac
142
+
57
143
  words=""
58
144
  case "$path" in
59
145
  "") words="version deploy db" ;;
60
146
  "version") words="--format" ;;
61
- "deploy") words="--force -f" ;;
62
- "db") words="migrate --verbose" ;;
147
+ "deploy") words="--force --no-force -f staging production" ;;
148
+ "db") words="migrate --verbose --no-verbose" ;;
149
+ "db migrate") words="--step" ;;
63
150
  esac
64
151
 
65
152
  COMPREPLY=($(compgen -W "$words" -- "$cur"))
@@ -70,17 +157,62 @@ _mycli_completions() {
70
157
  complete -F _mycli_completions mycli
71
158
  ```
72
159
 
73
- Read what that output proves. `db` offers `migrate` alongside its own `--verbose`, so a group with both a command and children keeps both. `db migrate` gets real file completion. `secret` is absent, because hidden commands stay hidden. The `-f` alias on `deploy` is there because you declared it.
160
+ Read what that output proves:
161
+
162
+ - `db` offers `migrate` alongside its own `--verbose`, so a group with both a command and children keeps both.
163
+ - `db migrate` gets real file completion.
164
+ - `secret` is absent, because hidden commands stay hidden.
165
+ - The `-f` alias on `deploy` is there because you declared it.
166
+ - `--no-force` is there because `--force` is a boolean, and dry-cli accepts both forms. Options are spelled the way dry-cli parses them, so `option :dry_run` completes as `--dry-run`, and an alias declared as `"f"` completes as `-f`.
167
+ - `mycli version --format <TAB>` offers `json plain`, and nothing else.
168
+ - `mycli deploy <TAB>` offers `staging production`, the values declared on the positional.
169
+
170
+ The script uses no associative arrays, so it runs under the bash 3.2 that macOS ships as `/bin/bash`.
171
+
172
+ `mycli completion zsh` prints a native `#compdef` script. Options go through `_arguments` with their `desc` as help text, subcommands go through `_describe` with the command's `desc`, declared values become a value list, and file arguments use `_files`:
173
+
174
+ ```zsh
175
+ ('deploy')
176
+ _arguments -s \
177
+ '--force[Skip confirmation]' \
178
+ '--no-force[Skip confirmation]' \
179
+ '-f[Skip confirmation]' \
180
+ '*:Target environment:(staging production)' && ret=0
181
+ ;;
182
+ ('db')
183
+ _arguments -s \
184
+ '--verbose[Print full migration history]' \
185
+ '--no-verbose[Print full migration history]' && ret=0
186
+ commands=(
187
+ 'migrate:Run pending migrations'
188
+ )
189
+ _describe -t commands 'db command' commands && ret=0
190
+ ;;
191
+ ```
74
192
 
75
- `mycli completion zsh` prints a native `#compdef` script built on `_arguments` and `_describe`, carrying each option's `desc` as help text next to it.
193
+ ### Enum values
76
194
 
77
- Enum values declared on an option or argument come through at no cost:
195
+ Values declared on an option or argument come through at no cost:
78
196
 
79
197
  ```ruby
80
- option :format, values: %w[json yaml table] # completes json yaml table
198
+ option :format, values: %w[json yaml table] # completes json yaml table after --format
81
199
  argument :component, values: %w[major minor] # completes major minor
82
200
  ```
83
201
 
202
+ ### File arguments
203
+
204
+ An argument completes file paths when it declares `file: true`. Without that key, the generator treats any argument whose name contains `file` or `path` as a file argument. Declare `file: false` to opt out of the guess:
205
+
206
+ ```ruby
207
+ argument :output, file: true # completes paths
208
+ argument :path, file: false # does not, despite the name
209
+ argument :config_file # completes paths, by name
210
+ ```
211
+
212
+ ### Program names
213
+
214
+ Shell function names derive from the program name. A program installed as `my-tool` gets `_my_tool_completions` in bash and `_my_tool` in zsh.
215
+
84
216
  ## Installation
85
217
 
86
218
  ```bash
@@ -105,19 +237,33 @@ end
105
237
 
106
238
  That require pulls in the command class and nothing else. No emitter loads until someone actually runs `mycli completion`.
107
239
 
240
+ The command reads the program name from `$PROGRAM_NAME` when it runs. If your executable can be invoked under a different name, such as through a wrapper or a binstub, pin it:
241
+
242
+ ```ruby
243
+ register "completion", Dry::CLI::Autocomplete::Command[MyCLI, program_name: "mycli"]
244
+ ```
245
+
246
+ The command takes one required argument, `bash` or `zsh`, and prints the script to standard output.
247
+
108
248
  Then have your users write the script once and source it. For bash:
109
249
 
110
250
  ```bash
111
251
  mycli completion bash > /usr/local/etc/bash_completion.d/mycli
112
252
  ```
113
253
 
254
+ Or evaluate it from `.bashrc`:
255
+
256
+ ```bash
257
+ eval "$(mycli completion bash)"
258
+ ```
259
+
114
260
  For zsh, put it anywhere on your `$fpath`:
115
261
 
116
262
  ```bash
117
263
  mycli completion zsh > "${fpath[1]}/_mycli"
118
264
  ```
119
265
 
120
- Sourcing it from `.zshrc` works too, if you would rather not manage a file:
266
+ Sourcing it from `.zshrc` works too, if you would rather not manage a file. Place the line after `compinit`, since the script calls `compdef` to register itself:
121
267
 
122
268
  ```bash
123
269
  eval "$(mycli completion zsh)"
@@ -155,19 +301,24 @@ The same table explains two other decisions. The generator will not be optimised
155
301
 
156
302
  ## Development
157
303
 
158
- Ruby 3.2 or newer. This repository uses rbenv, so activate it first:
304
+ Ruby 4.0 or newer, matching the gemspec and CI. This repository uses rbenv, so activate it first:
159
305
 
160
306
  ```bash
161
307
  eval "$(rbenv init -)"
162
308
  bundle install
163
309
  bundle exec rspec # the suite
164
310
  bundle exec rubocop # the linter
165
- bundle exec rake # both
311
+ bundle exec rake # the suite, and the default task
312
+ bundle exec rake doc # YARD documentation
166
313
  bin/console # IRB with the gem loaded
167
314
  ```
168
315
 
169
316
  Two conventions in the suite are worth knowing before you add to it. Fixtures include registries this project did not write, because a generator tested against one CLI quietly encodes that CLI's shape. And generated scripts are validated by the shells themselves, with `bash -n` and `zsh -n` parsing without executing, since a regex over the output proves nothing about whether it runs.
170
317
 
318
+ ## Author
319
+
320
+ - Konstantin Gredeskoul pairing with Claude Code. Every line has been reviewed and co-written by a human. The commits were pushed by Claude to save time writing comment descriptions.
321
+
171
322
  ## Contributing
172
323
 
173
324
  Bug reports and pull requests are welcome at <https://github.com/kigster/dry-cli-autocomplete>.
data/Rakefile CHANGED
@@ -29,8 +29,8 @@ end
29
29
  task build: :permissions
30
30
 
31
31
  YARD::Rake::YardocTask.new(:doc) do |t|
32
- t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md SPECIFICATION.md]
33
- t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
32
+ t.files = %w[lib/**/*.rb - README.md LICENSE.txt CHANGELOG.md]
33
+ t.options.unshift("--title", '"dry-cli-autocomplete: shell completion for dry-cli"')
34
34
  t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
35
35
  end
36
36
 
@@ -1,5 +1,8 @@
1
1
  # `dry-cli-autocomplete`: Specification
2
2
 
3
+ > [!NOTE]
4
+ > This is the specification for version 0.1.0. Later versions may differ; see `CHANGELOG.md` and `README.md` for current behaviour.
5
+
3
6
  Generate static shell completion scripts for any `Dry::CLI` application, from the command registry alone.
4
7
 
5
8
  The intended use is one line in a shell profile:
@@ -12,7 +12,7 @@ module Dry
12
12
  # for whatever this require pulls on *every* invocation, while the
13
13
  # command itself runs about once per shell. The generator is required
14
14
  # inside #call, where the cost is actually incurred. See
15
- # SPECIFICATION.md §1.3 and §2.4, and the spec that pins it.
15
+ # docs/SPECIFICATION.md §1.3 and §2.4, and the spec that pins it.
16
16
  class Command < Dry::CLI::Command
17
17
  SHELLS = %w[bash zsh].freeze
18
18
 
@@ -22,10 +22,17 @@ module Dry
22
22
  # gem may be required long before anyone knows how it was invoked.
23
23
  #
24
24
  # register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
25
+ #
26
+ # dry-cli's inherited hook empties a subclass's description and
27
+ # examples, and this returns a subclass, so both are set again here.
28
+ # The examples name the bound program, not whatever loaded the gem.
25
29
  def self.[](registry, program_name: nil)
26
30
  Class.new(self) do
27
31
  @registry = registry
28
32
  @program_name = program_name
33
+
34
+ desc superclass.description
35
+ example examples_for(program_name || File.basename($PROGRAM_NAME))
29
36
  end
30
37
  end
31
38
 
@@ -33,24 +40,33 @@ module Dry
33
40
  attr_reader :registry, :program_name
34
41
  end
35
42
 
43
+ def self.examples_for(name)
44
+ [
45
+ "bash > /usr/local/etc/bash_completion.d/#{name}",
46
+ "zsh > \"${fpath[1]}/_#{name}\""
47
+ ]
48
+ end
49
+
36
50
  desc "Print a shell completion script"
37
51
 
38
- argument :shell, required: true, values: SHELLS,
39
- desc: "Shell to generate completions for"
52
+ argument :shell, required: true, values: SHELLS, desc: "Shell to generate completions for"
40
53
 
41
- example [
42
- "bash > /usr/local/etc/bash_completion.d/#{File.basename($PROGRAM_NAME)}",
43
- "zsh > \"${fpath[1]}/_#{File.basename($PROGRAM_NAME)}\""
44
- ]
54
+ example examples_for(File.basename($PROGRAM_NAME))
45
55
 
46
56
  def call(shell:, **)
47
57
  require_relative "spec_builder"
48
58
  require_relative "emitters/#{shell}"
49
59
 
50
- spec = SpecBuilder.call(registry, program_name: program_name)
60
+ spec = SpecBuilder.call(registry, program_name:)
61
+
51
62
  out.puts emitter_for(shell).call(spec)
52
63
  end
53
64
 
65
+ # Dry::CLI sets @out from Dry::CLI#call(out:) before it calls a command,
66
+ # and only when the command has not set @out itself, so never assign it
67
+ # here. $stdout is for a command called directly, outside Dry::CLI.
68
+ def out = @out || $stdout
69
+
54
70
  private
55
71
 
56
72
  def registry
@@ -65,9 +81,6 @@ module Dry
65
81
  def emitter_for(shell)
66
82
  Emitters.const_get(shell.capitalize)
67
83
  end
68
-
69
- # Overridable so specs can capture output without reaching for $stdout.
70
- def out = $stdout
71
84
  end
72
85
  end
73
86
  end
@@ -7,7 +7,7 @@ module Dry
7
7
  module Autocomplete
8
8
  module Emitters
9
9
  # Turns a CompletionSpec into a bash `complete -F` script.
10
- # See SPECIFICATION.md §4.1: a static, case-statement walk over
10
+ # See docs/SPECIFICATION.md §4.1: a static, case-statement walk over
11
11
  # COMP_WORDS resolves which node the cursor is under, then
12
12
  # `compgen -W` fills COMPREPLY from that node's children and
13
13
  # option flags, with `compgen -f` added where an argument is a
@@ -97,7 +97,7 @@ module Dry
97
97
  def function_name = "_#{shell_identifier}_completions"
98
98
 
99
99
  # A program installed as `my-tool` cannot name a shell function
100
- # directly. See SPECIFICATION.md §4.2.
100
+ # directly. See docs/SPECIFICATION.md §4.2.
101
101
  def shell_identifier
102
102
  Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
103
103
  end
@@ -127,12 +127,10 @@ module Dry
127
127
  # legitimate next words at this point in the line.
128
128
  def node_words(node)
129
129
  node.children +
130
- node.options.flat_map { |option| option_words(option) } +
130
+ node.options.flat_map(&:flags) +
131
131
  node.arguments.flat_map { |argument| Array(argument.values) }
132
132
  end
133
133
 
134
- def option_words(option) = ["--#{option.name}"] + Array(option.aliases)
135
-
136
134
  # An option that declares values gets its own arm, keyed on the word
137
135
  # before the cursor. Typing `--format ` then TAB should offer what
138
136
  # --format accepts, not the command list again, so this arm answers
@@ -145,7 +143,7 @@ module Dry
145
143
  next if values.empty?
146
144
 
147
145
  key = path_key(node.path)
148
- option_words(option).map do |name|
146
+ option.flags.map do |name|
149
147
  " \"#{quote(key)}:#{quote(name)}\") " \
150
148
  "COMPREPLY=($(compgen -W \"#{quote(values.join(' '))}\" -- \"$cur\")); return ;;"
151
149
  end
@@ -13,7 +13,7 @@ module Dry
13
13
  # `_describe`, declared enum values become a `(a b c)` action, and a
14
14
  # file argument becomes `_files`. That per-option help is the thing
15
15
  # zsh users lose with a shim, and the reason this emitter exists.
16
- # See SPECIFICATION.md §4.1.
16
+ # See docs/SPECIFICATION.md §4.1.
17
17
  #
18
18
  # Shares no code with the bash emitter by design: a fourth shell
19
19
  # should be a new class here, never another branch inside one of
@@ -132,15 +132,34 @@ module Dry
132
132
  # Each alias gets its own spec rather than a {-f,--force} group:
133
133
  # the grouped form needs its description outside the quotes, and
134
134
  # one entry per flag is easier to read in the generated file.
135
+ #
136
+ # A boolean's two forms exclude each other, so once `--force` is on
137
+ # the line zsh stops offering `--no-force`, and the reverse. The
138
+ # `--no-` form says what it turns off rather than repeating the
139
+ # description, which would read backwards.
135
140
  def option_specs(option)
136
- names = ["--#{option.name}"] + Array(option.aliases)
137
- names.map { |name| single_quote("#{name}#{bracketed(option.desc)}#{option_action(option)}") }
141
+ positives = [option.long, *option.alias_flags]
142
+ specs = positives.map do |name|
143
+ single_quote("#{exclusion([option.negation])}#{name}#{bracketed(option.desc)}#{option_action(option)}")
144
+ end
145
+ return specs unless option.negation
146
+
147
+ specs.insert(1, single_quote("#{exclusion(positives)}#{option.negation}[Turn off #{option.long}]"))
148
+ end
149
+
150
+ # A zsh exclusion list such as `(--no-force)`, or nothing when there
151
+ # is nothing to exclude.
152
+ def exclusion(names)
153
+ names = names.compact
154
+ names.empty? ? "" : "(#{names.join(' ')})"
138
155
  end
139
156
 
140
- # A boolean flag takes no value. Anything else gets one field
141
- # naming what it wants and one supplying the completions for it.
157
+ # A boolean or a `type: :flag` option takes no value: dry-cli parses
158
+ # `run --quiet x` as `quiet: true` with `x` as an argument. Anything
159
+ # else gets one field naming what it wants and one supplying the
160
+ # completions for it.
142
161
  def option_action(option)
143
- return "" if option.boolean
162
+ return "" if option.boolean || option.flag
144
163
 
145
164
  ":#{escape_spec(option.name)}:#{value_action(option.values)}"
146
165
  end
@@ -193,7 +212,7 @@ module Dry
193
212
  def function_name = "_#{shell_identifier}"
194
213
 
195
214
  # A program installed as `my-tool` cannot name a shell function
196
- # directly. See SPECIFICATION.md §4.2.
215
+ # directly. See docs/SPECIFICATION.md §4.2.
197
216
  def shell_identifier
198
217
  Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
199
218
  end
@@ -9,7 +9,7 @@ module Dry
9
9
  #
10
10
  # Touches nothing beyond the registry and the command classes it
11
11
  # already holds, so it stays cheap enough to run on every shell start.
12
- # See SPECIFICATION.md §2.2 and §2.3.
12
+ # See docs/SPECIFICATION.md §2.2 and §2.3.
13
13
  class SpecBuilder
14
14
  # `::Data`, with the leading colons, and never a bare `Data`. This file
15
15
  # is lexically inside `module Dry`, so an unqualified constant is looked
@@ -25,13 +25,23 @@ module Dry
25
25
  # method Struct defines and Data does not.
26
26
  CompletionSpec = ::Data.define(:program_name, :nodes)
27
27
  Node = ::Data.define(:path, :desc, :options, :arguments, :children)
28
+ # `long`, `negation` and `alias_flags` are spelled the way dry-cli
29
+ # 1.4.1 spells them in Option#parser_options and #alias_names, so
30
+ # both emitters offer exactly what the parser accepts. `flag` is a
31
+ # `type: :flag` option: like a boolean it takes no value, but dry-cli
32
+ # gives it no `--no-` form.
28
33
  OptionSpec = ::Data.define(
29
- :name, :type, :values, :aliases, :default, :desc, :required, :boolean, :array
30
- )
34
+ :name, :type, :values, :aliases, :default, :desc, :required, :boolean, :array,
35
+ :flag, :long, :negation, :alias_flags
36
+ ) do
37
+ # @return [Array<String>] every spelling: the long name, its `--no-`
38
+ # form for a boolean, then the aliases
39
+ def flags = [long, negation, *alias_flags].compact
40
+ end
31
41
  ArgumentSpec = ::Data.define(:name, :values, :desc, :required, :file)
32
42
 
33
43
  # A bare heuristic, used only when a host does not declare `file:`
34
- # explicitly on the argument. See SPECIFICATION.md §4.3.
44
+ # explicitly on the argument. See docs/SPECIFICATION.md §4.3.
35
45
  FILE_ARGUMENT_HEURISTIC = /file|path/i
36
46
 
37
47
  def self.call(registry, program_name:)
@@ -77,13 +87,29 @@ module Dry
77
87
  end
78
88
 
79
89
  def build_option(option)
90
+ long = "--#{dasherize(option.name)}"
80
91
  OptionSpec.new(
81
92
  name: option.name.to_s, type: option.type, values: option.values,
82
93
  aliases: option.aliases, default: option.default, desc: option.options[:desc],
83
- required: option.required? || false, boolean: option.boolean?, array: option.array?
94
+ required: option.required? || false, boolean: option.boolean?, array: option.array?,
95
+ flag: option.respond_to?(:flag?) && option.flag?, long: long,
96
+ negation: option.boolean? ? long.sub("--", "--no-") : nil,
97
+ alias_flags: Array(option.aliases).map { |name| alias_flag(name) }.uniq
84
98
  )
85
99
  end
86
100
 
101
+ # What Dry::CLI::Inflector.dasherize does, without depending on a
102
+ # module dry-cli marks private: `dry_run` becomes `--dry-run` and `dryRun`
103
+ # becomes `--dryrun`, as dry-cli registers them.
104
+ def dasherize(name) = name.to_s.downcase.gsub(/[[:space:]_]/, "-")
105
+
106
+ # One letter gets one dash and anything longer two, whatever the host
107
+ # wrote: `"f"`, `"-f"` and `"--f"` all register as `-f`.
108
+ def alias_flag(name)
109
+ bare = name.to_s.sub(/\A-{1,2}/, "")
110
+ bare.size == 1 ? "-#{bare}" : "--#{bare}"
111
+ end
112
+
87
113
  def build_argument(argument)
88
114
  ArgumentSpec.new(
89
115
  name: argument.name.to_s, values: argument.values, desc: argument.options[:desc],
@@ -9,7 +9,7 @@ module Dry
9
9
  # inherits from Object, so an empty reopening is compatible either way.
10
10
  class CLI
11
11
  module Autocomplete
12
- VERSION = "0.1.3"
12
+ VERSION = "0.6.0"
13
13
  end
14
14
  end
15
15
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dry-cli-autocomplete
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -46,12 +46,13 @@ extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
48
  - ".secrets.baseline"
49
+ - ".vscode/settings.json"
49
50
  - CHANGELOG.md
50
51
  - CLAUDE.md
51
52
  - LICENSE.txt
52
53
  - README.md
53
54
  - Rakefile
54
- - SPECIFICATION.md
55
+ - docs/SPECIFICATION.md
55
56
  - docs/img/badge.svg
56
57
  - justfile
57
58
  - lefthook.yml
@@ -86,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
87
  - !ruby/object:Gem::Version
87
88
  version: '0'
88
89
  requirements: []
89
- rubygems_version: 4.0.18
90
+ rubygems_version: 4.0.22
90
91
  specification_version: 4
91
92
  summary: A missing auto-complete addition for dry-cli powered Ruby CLI tools for BASH
92
93
  & ZSH