dry-cli-autocomplete 0.5.0 → 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: 4e13789705a3120d92019f20ba4bb4c7619a2b76c3d5fe99639dd9ba29c96696
4
- data.tar.gz: dba1d9805eb867d76c1c547f688dbadea1ece6714fc73cef2bcd1c93c30a62c3
3
+ metadata.gz: b392efc439cb2a04ce5858ab9f8931cf3be6c987df699c4ef56e0c20782775d9
4
+ data.tar.gz: 749295c776797acfd2a82a059d632df3d1f02170e158c4b3e8ad260def61009a
5
5
  SHA512:
6
- metadata.gz: 207eb7f84aea51e36d8a5ed3e08a45a5b2f278d1c3cf5d42e408d2caa353ce35b91514889390aba9298c851882e1d3eb25b3582084c32e68ef1f969f3b4443c8
7
- data.tar.gz: bbeed56314a20aec94d8fabd92e43532c316d470348a546ae952a70ace686e84209fb22d99bf1fb3a7b6a529b1e2785d2fe0b57b80ec05cb1fa16074eea3bc7a
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,5 +1,11 @@
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
+
3
9
  ## [0.5.0] - 2026-09-16
4
10
 
5
11
  Documentation only; no change to the gem's behaviour.
data/README.md CHANGED
@@ -12,6 +12,60 @@ ______________________________________________________________________
12
12
  > [!WARNING]
13
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
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
+ ```
68
+
15
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.
16
70
 
17
71
  ```bash
@@ -90,8 +144,8 @@ _mycli_completions() {
90
144
  case "$path" in
91
145
  "") words="version deploy db" ;;
92
146
  "version") words="--format" ;;
93
- "deploy") words="--force -f staging production" ;;
94
- "db") words="migrate --verbose" ;;
147
+ "deploy") words="--force --no-force -f staging production" ;;
148
+ "db") words="migrate --verbose --no-verbose" ;;
95
149
  "db migrate") words="--step" ;;
96
150
  esac
97
151
 
@@ -109,6 +163,7 @@ Read what that output proves:
109
163
  - `db migrate` gets real file completion.
110
164
  - `secret` is absent, because hidden commands stay hidden.
111
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`.
112
167
  - `mycli version --format <TAB>` offers `json plain`, and nothing else.
113
168
  - `mycli deploy <TAB>` offers `staging production`, the values declared on the positional.
114
169
 
@@ -120,12 +175,14 @@ The script uses no associative arrays, so it runs under the bash 3.2 that macOS
120
175
  ('deploy')
121
176
  _arguments -s \
122
177
  '--force[Skip confirmation]' \
178
+ '--no-force[Skip confirmation]' \
123
179
  '-f[Skip confirmation]' \
124
180
  '*:Target environment:(staging production)' && ret=0
125
181
  ;;
126
182
  ('db')
127
183
  _arguments -s \
128
- '--verbose[Print full migration history]' && ret=0
184
+ '--verbose[Print full migration history]' \
185
+ '--no-verbose[Print full migration history]' && ret=0
129
186
  commands=(
130
187
  'migrate:Run pending migrations'
131
188
  )
@@ -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
@@ -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
@@ -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
@@ -25,9 +25,19 @@ 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:`
@@ -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.5.0"
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -46,6 +46,7 @@ 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
@@ -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.20
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