gempilot 0.3.0 → 0.3.2

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.
@@ -0,0 +1,810 @@
1
+ # Command Generation Bootstrap Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `gempilot new command NAME` produce a command that actually runs: bootstrap the CommandKit plumbing the gem is missing (CLI router, base command, an executable that starts the router and is `chmod +x`, the `command_kit` runtime dependency followed by `bundle install`, and the Zeitwerk inflection that lets `cli.rb` define `CLI`), and give `gempilot create --exe` the same scaffold so a CLI gem works from its first commit.
6
+
7
+ **Architecture:** A new `Gempilot::CLI::CliBootstrap` mixin, shared by the `New` and `Create` commands, owns the scaffold. Every step is idempotent (it checks for the file, the dependency line, or the inflection before acting), so `new command` on an already-bootstrapped gem touches nothing, and running it twice is safe. Templates live in a new `data/templates/cli/` directory; `Generator#erb` gains a `from:` keyword so both commands can render them regardless of their own `template_dir`. `create --exe` now runs the bootstrap from inside the new gem instead of rendering the old bare executable, and the generated command test asserts something real (`command_name`) instead of `assert command`, which the generated gem's own RuboCop rejects.
8
+
9
+ **Tech Stack:** Ruby 4.0, command_kit 0.6 (`CommandKit::Commands`, `Commands::AutoLoad`, `Options::Version`, `Command#command_name`), Zeitwerk (`inflector.inflect("cli" => "CLI")`), ERB, Minitest (`test/`), RSpec (`spec/`), RuboCop with rubocop-claude.
10
+
11
+ **Issue:** `69816386-9F7F-11F1-8629-FE6CB9572C2F` — "Command generation is incomplete".
12
+
13
+ ## Global Constraints
14
+
15
+ - Ruby `>= 4.0`; `it` block parameter is fine; `File.absolute_path?` etc. available.
16
+ - Double-quoted strings (`Style/StringLiterals: double_quotes`, single quotes allowed only when the string itself contains double quotes); NO `# frozen_string_literal:` comments; trailing commas in multiline literals/arguments; string constants end in `.freeze`.
17
+ - rdoc doc blocks in canonical form (a bare `##` line, then `# ...` lines); never a comment line that reads like code (`Claude/NoCommentedCode`, `MinLines: 1`); no `TODO`/`NOTE` in comments without attribution (`Claude/TaggedComments`) — `TODO` inside string literals is fine.
18
+ - Method bodies max 10 lines, ABC max 17, `Metrics/BlockLength` max 8 (heredoc/array/hash/method_call count as one); `Naming/PredicateMethod` forbids a method that only returns boolean literals unless it ends in `?` (which is why `insert_dependency` returns the path, not `true`).
19
+ - Zeitwerk: `lib/gempilot/cli/cli_bootstrap.rb` MUST define `Gempilot::CLI::CliBootstrap` (`spec/zeitwerk_spec.rb` eager-loads gempilot; the `cli` directory inflects to `CLI`, the file name to `CliBootstrap`).
20
+ - Generated files must pass the generated gem's own RuboCop (rubocop-claude included) and its Zeitwerk eager-load test; `Style/Documentation` needs a doc block on `class Deploy < Command`, `Style/ClassAndModuleChildren` is disabled in generated gems so `module My::Gem` compact style is fine.
21
+ - The generated gem's `.rubocop.yml` excludes `bin/*` but not `exe/`, so the executable is linted; its content mirrors gempilot's own `exe/gempilot`, which passes the same config.
22
+ - Verification: `bundle exec ruby -Itest -Ilib test/gempilot/cli/new_command_test.rb` (fast), `bundle exec ruby -Itest -Ilib test/gempilot/cli/create_command_test.rb` (includes integration tests that run `bundle exec rake` inside generated gems, ~2 minutes), `bundle exec rspec`, `bundle exec rubocop <files>`, and `bundle exec rake default` for the full gate.
23
+ - Baseline (verified 2026-09-23): minitest `109 runs, 4 failures` — the 4 are the Gemfile-template ordering regression fixed by Task 1 of `docs/superpowers/plans/2026-09-23-land-betterleaks-jruby.md`; apply that one-line change first, after which the baseline is `109 runs, 0 failures`, RSpec `192 examples, 0 failures`, RuboCop no offenses. Final state after this plan: minitest `128 runs, 0 failures`, RSpec `192 examples`, RuboCop no offenses.
24
+ - Commit messages: plain imperative sentences, no conventional-commit prefixes.
25
+
26
+ ---
27
+
28
+ ### Task 1: Shared generator helpers
29
+
30
+ **Files:**
31
+ - Modify: `lib/gempilot/cli/generator.rb` (add `update_file`, `ensure_directory`; extend `erb`)
32
+ - Modify: `lib/gempilot/cli/commands/new.rb` (delete its private `ensure_directory`)
33
+ - Create: `data/templates/cli/exe.erb` (needed by the new generator test; the other two CLI templates arrive in Task 2)
34
+ - Test: `test/gempilot/cli/generator_test.rb`
35
+
36
+ **Interfaces:**
37
+ - Consumes: `CommandKit::FileUtils#erb(source, dest = nil)` (super), `Generator#print_action`, `Generator#mkdir`.
38
+ - Produces: `Generator#update_file(path, content)` (prints `update`, writes), `Generator#ensure_directory(dir)` (prints `mkdir` only when creating), `Generator#erb(source, dest = nil, from: @template_dir)` (renders `File.join(from, source)`). Tasks 2–3 rely on all three.
39
+
40
+ - [ ] **Step 1: Write the failing tests**
41
+
42
+ In `test/gempilot/cli/generator_test.rb`, immediately before `def test_create_file_writes_content`, add:
43
+
44
+ ```ruby
45
+ def test_update_file_overwrites_content
46
+ path = File.join(@tmpdir, "existing.rb")
47
+ File.write(path, "old\n")
48
+ @generator.update_file(path, "new\n")
49
+
50
+ assert_equal "new\n", File.read(path)
51
+ assert_includes @stdout.string, "update"
52
+ end
53
+
54
+ def test_ensure_directory_creates_a_missing_directory_once
55
+ path = File.join(@tmpdir, "nested")
56
+ @generator.ensure_directory(path)
57
+ @generator.ensure_directory(path)
58
+
59
+ assert_predicate Pathname(path), :directory?
60
+ assert_equal 1, @stdout.string.scan("mkdir").size
61
+ end
62
+
63
+ def test_erb_renders_from_another_template_directory
64
+ @generator.instance_variable_set(:@require_path, "test_gem")
65
+ @generator.instance_variable_set(:@gem_module, "TestGem")
66
+ dest = File.join(@tmpdir, "exe")
67
+ @generator.erb("exe.erb", dest, from: File.join(Gempilot::ROOT, "data", "templates", "cli"))
68
+
69
+ assert_includes File.read(dest), "TestGem::CLI.start"
70
+ end
71
+ ```
72
+
73
+ - [ ] **Step 2: Run them to verify they fail**
74
+
75
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/generator_test.rb`
76
+ Expected: `12 runs, ... 3 errors` — `NoMethodError: undefined method 'update_file'`, `undefined method 'ensure_directory'`, and `ArgumentError: unknown keyword: :from`.
77
+
78
+ - [ ] **Step 3: Create the executable template**
79
+
80
+ Create `data/templates/cli/exe.erb` (this is gempilot's own `exe/gempilot`, parameterised):
81
+
82
+ ```erb
83
+ #!/usr/bin/env ruby
84
+
85
+ gemfile = File.expand_path("../Gemfile", __dir__)
86
+
87
+ if File.exist?(gemfile)
88
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
89
+ require "bundler/setup"
90
+ end
91
+
92
+ require "<%= @require_path %>/cli"
93
+
94
+ <%= @gem_module %>::CLI.start
95
+ ```
96
+
97
+ - [ ] **Step 4: Extend Generator**
98
+
99
+ In `lib/gempilot/cli/generator.rb`, directly after `create_file`, add:
100
+
101
+ ```ruby
102
+ def update_file(path, content)
103
+ print_action "update", path
104
+ File.write(path, content)
105
+ end
106
+
107
+ def ensure_directory(dir)
108
+ mkdir(dir) unless File.directory?(dir)
109
+ end
110
+ ```
111
+
112
+ and replace the existing `erb` method with:
113
+
114
+ ```ruby
115
+ # Renders +source+ from the command's template directory, or from the
116
+ # directory given as +from+ for templates shared across commands.
117
+ def erb(source, dest = nil, from: @template_dir)
118
+ print_action("erb", dest, source: source) if dest
119
+
120
+ super(File.join(from, source), dest)
121
+ end
122
+ ```
123
+
124
+ In `lib/gempilot/cli/commands/new.rb`, delete the now-duplicated private method:
125
+
126
+ ```ruby
127
+ def ensure_directory(dir)
128
+ mkdir(dir) unless File.directory?(dir)
129
+ end
130
+ ```
131
+
132
+ (`New`'s other methods keep calling `ensure_directory`; it now comes from `Generator`.)
133
+
134
+ - [ ] **Step 5: Run the tests and rubocop**
135
+
136
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/generator_test.rb && bundle exec ruby -Itest -Ilib test/gempilot/cli/new_command_test.rb && bundle exec rubocop lib/gempilot/cli/generator.rb lib/gempilot/cli/commands/new.rb test/gempilot/cli/generator_test.rb`
137
+ Expected: `12 runs, 0 failures, 0 errors`; `19 runs, 0 failures`; `3 files inspected, no offenses detected`.
138
+
139
+ - [ ] **Step 6: Commit**
140
+
141
+ ```bash
142
+ git add lib/gempilot/cli/generator.rb lib/gempilot/cli/commands/new.rb data/templates/cli/exe.erb test/gempilot/cli/generator_test.rb
143
+ git commit -m "Add shared generator helpers for templates rendered by several commands"
144
+ ```
145
+
146
+ ---
147
+
148
+ ### Task 2: `CliBootstrap` and `gempilot new command`
149
+
150
+ **Files:**
151
+ - Create: `lib/gempilot/cli/cli_bootstrap.rb`
152
+ - Create: `data/templates/cli/cli.rb.erb`, `data/templates/cli/command.rb.erb`
153
+ - Modify: `data/templates/new/command.rb.erb`
154
+ - Modify: `lib/gempilot/cli/commands/new.rb` (`include CliBootstrap`; `add_command` and the generated test content)
155
+ - Test: `test/gempilot/cli/new_command_test.rb`
156
+
157
+ **Interfaces:**
158
+ - Consumes: Task 1's `update_file`, `ensure_directory`, `erb(..., from:)`; `Generator#chmod`, `#sh`, `colors`; `@gem_name`, `@require_path`, `@gem_module` (set by `GemContext#detect_gem_context` in `New`).
159
+ - Produces: private `bootstrap_cli -> String | nil` (the gemspec path when it gained the `command_kit` dependency, so the caller knows a `bundle install` is due; `nil` otherwise). Constants `CliBootstrap::TEMPLATES`, `CLI_INFLECTION`, `TAP_SETUP`. Task 3 relies on `bootstrap_cli` being callable from `Create` with the same three ivars.
160
+
161
+ - [ ] **Step 1: Write the failing tests**
162
+
163
+ In `test/gempilot/cli/new_command_test.rb`, immediately before the `# --- Error handling ---` comment, add:
164
+
165
+ ```ruby
166
+ # --- CommandKit bootstrap ---
167
+
168
+ def test_new_command_bootstraps_cli_router
169
+ run_new_command("command", "deploy")
170
+ content = File.read("lib/my_gem/cli.rb")
171
+
172
+ assert_includes content, "class CLI"
173
+ assert_includes content, "include CommandKit::Commands"
174
+ assert_includes content, 'command_name "my_gem"'
175
+ assert_includes content, "version MyGem::VERSION"
176
+ end
177
+
178
+ def test_new_command_bootstraps_base_command
179
+ run_new_command("command", "deploy")
180
+ content = File.read("lib/my_gem/cli/command.rb")
181
+
182
+ assert_includes content, "class Command < CommandKit::Command"
183
+ assert_includes content, "include CommandKit::Interactive"
184
+ end
185
+
186
+ def test_new_command_creates_executable_starting_the_cli
187
+ run_new_command("command", "deploy")
188
+ content = File.read("exe/my_gem")
189
+
190
+ assert_predicate Pathname("exe/my_gem"), :executable?
191
+ assert_includes content, 'require "my_gem/cli"'
192
+ assert_includes content, "MyGem::CLI.start"
193
+ end
194
+
195
+ def test_new_command_makes_an_existing_executable_executable
196
+ FileUtils.mkdir_p("exe")
197
+ File.write("exe/my_gem", "#!/usr/bin/env ruby\nrequire \"my_gem/cli\"\nMyGem::CLI.start\n")
198
+ run_new_command("command", "deploy")
199
+
200
+ assert_predicate Pathname("exe/my_gem"), :executable?
201
+ assert_equal "#!/usr/bin/env ruby\nrequire \"my_gem/cli\"\nMyGem::CLI.start\n", File.read("exe/my_gem")
202
+ end
203
+
204
+ def test_new_command_keeps_existing_cli_files
205
+ FileUtils.mkdir_p("lib/my_gem/cli")
206
+ File.write("lib/my_gem/cli.rb", "# custom router\n")
207
+ File.write("lib/my_gem/cli/command.rb", "# custom base\n")
208
+ run_new_command("command", "deploy")
209
+
210
+ assert_equal "# custom router\n", File.read("lib/my_gem/cli.rb")
211
+ assert_equal "# custom base\n", File.read("lib/my_gem/cli/command.rb")
212
+ end
213
+
214
+ def test_new_command_adds_command_kit_dependency_to_gemspec
215
+ write_scaffolded_gem
216
+ run_new_command("command", "deploy")
217
+ gemspec = File.read("my_gem.gemspec")
218
+
219
+ assert_includes gemspec, 'spec.add_dependency "command_kit"'
220
+ assert_operator gemspec.index("command_kit"), :<, gemspec.index('"zeitwerk"')
221
+ end
222
+
223
+ def test_new_command_does_not_duplicate_command_kit_dependency
224
+ write_scaffolded_gem
225
+ run_new_command("command", "deploy")
226
+ run_new_command("command", "status")
227
+
228
+ assert_equal 1, File.read("my_gem.gemspec").scan("command_kit").size
229
+ end
230
+
231
+ def test_new_command_inflects_cli_in_the_loader
232
+ write_scaffolded_gem
233
+ run_new_command("command", "deploy")
234
+
235
+ expected = <<~RUBY
236
+ module MyGem
237
+ LOADER = Zeitwerk::Loader.for_gem.tap do |l|
238
+ l.inflector.inflect("cli" => "CLI")
239
+ l.setup
240
+ end
241
+ end
242
+ RUBY
243
+
244
+ assert_includes File.read("lib/my_gem.rb"), expected
245
+ end
246
+
247
+ def test_new_command_inflects_cli_only_once
248
+ write_scaffolded_gem
249
+ run_new_command("command", "deploy")
250
+ run_new_command("command", "status")
251
+
252
+ assert_equal 1, File.read("lib/my_gem.rb").scan("inflect(").size
253
+ end
254
+
255
+ def test_new_command_bundles_when_the_dependency_was_added
256
+ write_scaffolded_gem
257
+ File.write("Gemfile", "source \"https://rubygems.org\"\ngemspec\n")
258
+ sh_calls = run_new_command_recording_sh("command", "deploy")
259
+
260
+ assert_includes sh_calls, ["bundle", "install"]
261
+ end
262
+
263
+ def test_new_command_does_not_bundle_when_the_dependency_was_present
264
+ write_scaffolded_gem
265
+ File.write("Gemfile", "source \"https://rubygems.org\"\ngemspec\n")
266
+ run_new_command("command", "deploy")
267
+ sh_calls = run_new_command_recording_sh("command", "status")
268
+
269
+ assert_empty sh_calls
270
+ end
271
+
272
+ def test_new_command_in_hyphenated_gem_targets_the_nested_module
273
+ FileUtils.rm("my_gem.gemspec")
274
+ FileUtils.rm_rf("lib/my_gem")
275
+ File.write("my-gem.gemspec", 'Gem::Specification.new { |s| s.name = "my-gem" }')
276
+ FileUtils.mkdir_p("lib/my/gem")
277
+ run_new_command("command", "deploy")
278
+
279
+ assert_includes File.read("exe/my-gem"), 'require "my/gem/cli"'
280
+ assert_includes File.read("exe/my-gem"), "My::Gem::CLI.start"
281
+ assert_includes File.read("lib/my/gem/cli.rb"), "module My::Gem"
282
+ end
283
+ ```
284
+
285
+ and, after the existing private `run_new_command` helper at the bottom of the class, add:
286
+
287
+ ```ruby
288
+ def run_new_command_recording_sh(type, path)
289
+ sh_calls = []
290
+ command = Commands::New.new(stdout: StringIO.new)
291
+ command.define_singleton_method(:sh) { |*args| sh_calls << args }
292
+ command.main([type, path])
293
+ sh_calls
294
+ end
295
+
296
+ def write_scaffolded_gem
297
+ File.write("lib/my_gem.rb", <<~RUBY)
298
+ require "zeitwerk"
299
+
300
+ module MyGem
301
+ LOADER = Zeitwerk::Loader.for_gem.tap(&:setup)
302
+ end
303
+ RUBY
304
+ File.write("my_gem.gemspec", <<~RUBY)
305
+ Gem::Specification.new do |spec|
306
+ spec.name = "my_gem"
307
+ spec.add_dependency "zeitwerk"
308
+ end
309
+ RUBY
310
+ end
311
+ ```
312
+
313
+ The file's default fixture is deliberately minimal (a one-line gemspec with no `add_dependency` line and no `lib/my_gem.rb`); those tests prove the bootstrap degrades to a printed hint instead of crashing, while `write_scaffolded_gem` provides the realistic shape for the dependency and inflection tests.
314
+
315
+ - [ ] **Step 2: Run them to verify they fail**
316
+
317
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/new_command_test.rb`
318
+ Expected: `31 runs, ... 12 failures/errors` — `Errno::ENOENT` for `lib/my_gem/cli.rb`, `exe/my_gem`, etc., the gemspec/loader assertions fail, and `sh_calls` is empty where `["bundle", "install"]` is expected. The 19 pre-existing tests still pass.
319
+
320
+ - [ ] **Step 3: Create the CLI templates**
321
+
322
+ Create `data/templates/cli/cli.rb.erb`:
323
+
324
+ ```erb
325
+ require "command_kit/commands"
326
+ require "command_kit/commands/auto_load"
327
+ require "command_kit/options/version"
328
+ require "<%= @require_path %>"
329
+
330
+ module <%= @gem_module %>
331
+ ##
332
+ # Top-level command router for the <%= @gem_name %> CLI.
333
+ class CLI
334
+ include CommandKit::Commands
335
+ include CommandKit::Commands::AutoLoad.new(
336
+ dir: "#{__dir__}/cli/commands",
337
+ namespace: "#{self}::Commands",
338
+ )
339
+ include CommandKit::Options::Version
340
+
341
+ command_name "<%= @gem_name %>"
342
+ version <%= @gem_module %>::VERSION
343
+ end
344
+ end
345
+ ```
346
+
347
+ Create `data/templates/cli/command.rb.erb`:
348
+
349
+ ```erb
350
+ require "command_kit/command"
351
+ require "command_kit/colors"
352
+ require "command_kit/interactive"
353
+
354
+ module <%= @gem_module %>
355
+ class CLI
356
+ ##
357
+ # Base command class for all <%= @gem_name %> subcommands.
358
+ class Command < CommandKit::Command
359
+ include CommandKit::Colors
360
+ include CommandKit::Interactive
361
+ end
362
+ end
363
+ end
364
+ ```
365
+
366
+ (`CommandKit::BugReport` is left out on purpose: it needs a `bug_report_url`, which gempilot cannot know for someone else's gem.)
367
+
368
+ Replace the contents of `data/templates/new/command.rb.erb` with (only the doc block is new; the generated gem's `Style/Documentation` cop requires it):
369
+
370
+ ```erb
371
+ require_relative "../command"
372
+
373
+ module <%= @gem_module %>
374
+ class CLI
375
+ module Commands
376
+ ##
377
+ # The <%= @command_file_name %> command.
378
+ class <%= @command_name %> < Command
379
+ description "TODO: describe the <%= @command_file_name %> command"
380
+
381
+ def run
382
+ puts "TODO: implement <%= @command_file_name %>"
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
388
+ ```
389
+
390
+ - [ ] **Step 4: Create the bootstrap mixin**
391
+
392
+ Create `lib/gempilot/cli/cli_bootstrap.rb`:
393
+
394
+ ```ruby
395
+ module Gempilot
396
+ class CLI
397
+ ##
398
+ # Scaffolds the CommandKit plumbing a generated command needs and the gem
399
+ # is missing: the +CLI+ router, the base +Command+ class, an executable
400
+ # that starts the router, the Zeitwerk inflection letting +cli.rb+ define
401
+ # +CLI+ rather than +Cli+, and the +command_kit+ runtime dependency.
402
+ #
403
+ # Every step is idempotent, so a gem that already has any of these pieces
404
+ # keeps them untouched. Expects the including command to provide the
405
+ # Generator methods and +@gem_name+, +@require_path+, and +@gem_module+.
406
+ module CliBootstrap
407
+ TEMPLATES = Gempilot::ROOT.join("data", "templates", "cli").to_s.freeze
408
+ CLI_INFLECTION = 'l.inflector.inflect("cli" => "CLI")'.freeze
409
+ TAP_SETUP = ".tap(&:setup)".freeze
410
+
411
+ private
412
+
413
+ # Returns the gemspec path when it gained the command_kit dependency
414
+ # (so the caller knows a bundle install is due), nil otherwise.
415
+ def bootstrap_cli
416
+ create_cli_router
417
+ create_base_command
418
+ create_executable
419
+ inflect_cli_constant
420
+ add_command_kit_dependency
421
+ end
422
+
423
+ def create_cli_router
424
+ render_once "cli.rb.erb", File.join("lib", @require_path, "cli.rb")
425
+ end
426
+
427
+ def create_base_command
428
+ render_once "command.rb.erb", File.join("lib", @require_path, "cli", "command.rb")
429
+ end
430
+
431
+ def render_once(template, path)
432
+ return if File.exist?(path)
433
+
434
+ ensure_directory(File.dirname(path))
435
+ erb template, path, from: TEMPLATES
436
+ end
437
+
438
+ def create_executable
439
+ path = File.join("exe", @gem_name)
440
+ render_once "exe.erb", path
441
+ chmod "+x", path unless File.executable?(path)
442
+ return if File.read(path).include?("CLI.start")
443
+
444
+ puts colors.yellow("#{path} does not start #{@gem_module}::CLI; add `#{@gem_module}::CLI.start` to it.")
445
+ end
446
+
447
+ def inflect_cli_constant
448
+ path = File.join("lib", "#{@require_path}.rb")
449
+ source = File.exist?(path) ? File.read(path) : ""
450
+ return if source.include?(CLI_INFLECTION)
451
+
452
+ line = source.lines.find { it.include?(TAP_SETUP) }
453
+ return hint_inflection(path) unless line
454
+
455
+ update_file path, source.sub(TAP_SETUP, inflection_block(indent_of(line)))
456
+ end
457
+
458
+ def inflection_block(indent)
459
+ [".tap do |l|", "#{indent} #{CLI_INFLECTION}", "#{indent} l.setup", "#{indent}end"].join("\n")
460
+ end
461
+
462
+ def indent_of(line)
463
+ line[0, line.length - line.lstrip.length]
464
+ end
465
+
466
+ def hint_inflection(path)
467
+ puts colors.yellow("Could not find #{TAP_SETUP} in #{path}; add #{CLI_INFLECTION} before the loader's setup.")
468
+ end
469
+
470
+ def add_command_kit_dependency
471
+ path = "#{@gem_name}.gemspec"
472
+ lines = File.readlines(path)
473
+ return if lines.any? { it.include?("command_kit") }
474
+
475
+ index = lines.index { it.include?(".add_dependency") } || lines.rindex { it.strip == "end" }
476
+ index ? insert_dependency(path, lines, index) : hint_dependency(path)
477
+ end
478
+
479
+ def insert_dependency(path, lines, index)
480
+ lines.insert(index, %( #{gemspec_receiver(lines)}.add_dependency "command_kit"\n))
481
+ update_file path, lines.join
482
+ path
483
+ end
484
+
485
+ def gemspec_receiver(lines)
486
+ line = lines.find { it.include?(".add_dependency") }
487
+ line ? line.strip[0, line.strip.index(".add_dependency")] : "spec"
488
+ end
489
+
490
+ def hint_dependency(path)
491
+ puts colors.yellow("Could not add command_kit to #{path}; add `spec.add_dependency \"command_kit\"` yourself.")
492
+ end
493
+ end
494
+ end
495
+ end
496
+ ```
497
+
498
+ Why each piece looks the way it does:
499
+ - The inflection is mandatory, not cosmetic: Zeitwerk expects `lib/my_gem/cli.rb` to define `MyGem::Cli`; without `inflect("cli" => "CLI")` the gem's eager-load test and `rake zeitwerk:validate` fail with `Zeitwerk::NameError`. The edit turns the templates' `.tap(&:setup)` into the block form and indents relative to the `LOADER` line, so it is correct for both `for_gem` (2 spaces) and `for_gem_extension` (4 spaces). Only plain string operations are used (`include?`, `sub` with a String pattern, `lines`); no regex.
500
+ - The dependency goes before the first existing `.add_dependency` line (alphabetical for the generated gemspec, which has `zeitwerk`) using that line's receiver, or before the closing `end`; a gemspec with neither gets a hint instead of a broken edit.
501
+ - `insert_dependency` returns the path (truthy) rather than `true` because `Naming/PredicateMethod` rejects boolean-literal returns from methods not ending in `?`.
502
+
503
+ - [ ] **Step 5: Wire the bootstrap into `New`**
504
+
505
+ In `lib/gempilot/cli/commands/new.rb` add the include after `include GemContext`:
506
+
507
+ ```ruby
508
+ include Generator
509
+ include GemContext
510
+ include CliBootstrap
511
+ ```
512
+
513
+ Replace the `add_command` method with these three methods:
514
+
515
+ ```ruby
516
+ def add_command(name)
517
+ name = name.split("::").last if name.include?("::")
518
+ @command_file_name = name.underscore
519
+ @command_name = name.camelize
520
+
521
+ print_adding_banner("command", @command_name)
522
+ dependency_added = bootstrap_cli
523
+ write_command_files
524
+ bundle_install if dependency_added
525
+ end
526
+
527
+ def write_command_files
528
+ file_path = File.join("lib", @require_path, "cli", "commands", "#{@command_file_name}.rb")
529
+ ensure_directory(File.dirname(file_path))
530
+ erb "command.rb.erb", file_path
531
+ add_command_test_file(@command_name, @command_file_name)
532
+ end
533
+
534
+ def bundle_install
535
+ return unless File.exist?("Gemfile")
536
+
537
+ sh "bundle", "install"
538
+ end
539
+ ```
540
+
541
+ Replace `rspec_command_content` and `minitest_command_content` with versions that assert something the generated gem's RuboCop accepts (the old `assert command` trips `Minitest/UselessAssertion`), and add `command_line_name`:
542
+
543
+ ```ruby
544
+ def rspec_command_content(command_name)
545
+ <<~RUBY
546
+ require "spec_helper"
547
+
548
+ RSpec.describe #{@gem_module}::CLI::Commands::#{command_name} do
549
+ it "is registered under its command name" do
550
+ expect(described_class.command_name).to eq("#{command_line_name}")
551
+ end
552
+ end
553
+ RUBY
554
+ end
555
+
556
+ def minitest_command_content(command_name)
557
+ <<~RUBY
558
+ require "test_helper"
559
+ require "#{@require_path}/cli"
560
+
561
+ module #{@gem_module}
562
+ class CLI
563
+ class #{command_name}Test < Minitest::Test
564
+ def test_command_name
565
+ assert_equal "#{command_line_name}", Commands::#{command_name}.command_name
566
+ end
567
+ end
568
+ end
569
+ end
570
+ RUBY
571
+ end
572
+
573
+ # The name CommandKit registers the command under: dashes, not
574
+ # underscores, matching how AutoLoad maps the file name.
575
+ def command_line_name
576
+ @command_file_name.tr("_", "-")
577
+ end
578
+ ```
579
+
580
+ (`CommandKit::Command.command_name` defaults to the dasherized, demodulized class name, which is exactly what `Commands::AutoLoad` derives from the file name, so `DeployNow` in `deploy_now.rb` is `deploy-now` on both sides.)
581
+
582
+ - [ ] **Step 6: Run the tests, the new-command specs, and rubocop**
583
+
584
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/new_command_test.rb && bundle exec rspec spec/gempilot/cli/commands/new_namespace_spec.rb spec/gempilot/cli/commands/new_interactive_spec.rb spec/zeitwerk_spec.rb --no-color && bundle exec rubocop lib/gempilot/cli/cli_bootstrap.rb lib/gempilot/cli/commands/new.rb test/gempilot/cli/new_command_test.rb`
585
+ Expected: `31 runs, 0 failures, 0 errors`; `13 examples, 0 failures`; `3 files inspected, no offenses detected`.
586
+
587
+ - [ ] **Step 7: Commit**
588
+
589
+ ```bash
590
+ git add lib/gempilot/cli/cli_bootstrap.rb lib/gempilot/cli/commands/new.rb data/templates/cli data/templates/new/command.rb.erb test/gempilot/cli/new_command_test.rb
591
+ git commit -m "Bootstrap a CommandKit CLI when generating a command"
592
+ ```
593
+
594
+ ---
595
+
596
+ ### Task 3: `gempilot create --exe` builds the same CLI
597
+
598
+ **Files:**
599
+ - Modify: `lib/gempilot/cli/commands/create.rb` (`include CliBootstrap`, `@gem_module`, option description)
600
+ - Modify: `lib/gempilot/cli/gem_builder.rb` (`render_executable`, doc block)
601
+ - Delete: `data/templates/gem/exe/gem_name.erb`
602
+ - Test: `test/gempilot/cli/create_command_test.rb`
603
+
604
+ **Interfaces:**
605
+ - Consumes: `bootstrap_cli` (Task 2), `GemBuilder#cd` (from `CommandKit::FileUtils`), `@module_name` computed in `Create#derive_naming`.
606
+ - Produces: `create --exe` output containing `lib/<gem>/cli.rb`, `lib/<gem>/cli/command.rb`, `exe/<gem>` (executable, starts `<Module>::CLI`), a gemspec with `spec.add_dependency "command_kit"`, and the loader inflection; `create` without `--exe` is unchanged.
607
+
608
+ - [ ] **Step 1: Write the failing tests**
609
+
610
+ In `test/gempilot/cli/create_command_test.rb`, immediately before `def test_inflects_module_name_correctly`, add:
611
+
612
+ ```ruby
613
+ def test_exe_flag_bootstraps_a_command_kit_cli
614
+ run_create_command("test_gem", "--exe")
615
+
616
+ assert_includes File.read("test_gem/exe/test_gem"), "TestGem::CLI.start"
617
+ assert_includes File.read("test_gem/lib/test_gem/cli.rb"), "class CLI"
618
+ assert_includes File.read("test_gem/lib/test_gem/cli/command.rb"), "class Command < CommandKit::Command"
619
+ assert_includes File.read("test_gem/test_gem.gemspec"), 'spec.add_dependency "command_kit"'
620
+ assert_includes File.read("test_gem/lib/test_gem.rb"), 'l.inflector.inflect("cli" => "CLI")'
621
+ end
622
+
623
+ def test_no_exe_flag_leaves_the_cli_out
624
+ run_create_command("test_gem")
625
+
626
+ refute_path_exists "test_gem/lib/test_gem/cli.rb"
627
+ refute_includes File.read("test_gem/test_gem.gemspec"), "command_kit"
628
+ assert_includes File.read("test_gem/lib/test_gem.rb"), "for_gem.tap(&:setup)"
629
+ end
630
+
631
+ def test_hyphenated_gem_exe_flag_targets_the_extension_module
632
+ run_create_command("gempilot-encryption", "--exe")
633
+ exe = File.read("gempilot-encryption/exe/gempilot-encryption")
634
+ entry = File.read("gempilot-encryption/lib/gempilot/encryption.rb")
635
+ expected_loader = [
636
+ " LOADER = Zeitwerk::Loader.for_gem_extension(Gempilot).tap do |l|",
637
+ ' l.inflector.inflect("cli" => "CLI")',
638
+ " l.setup",
639
+ " end",
640
+ ].join("\n")
641
+
642
+ assert_includes exe, 'require "gempilot/encryption/cli"'
643
+ assert_includes exe, "Gempilot::Encryption::CLI.start"
644
+ assert_includes entry, expected_loader
645
+ end
646
+ ```
647
+
648
+ and, immediately before the `private` keyword at the bottom of the class, the end-to-end test:
649
+
650
+ ```ruby
651
+ def test_generated_cli_gem_runs_end_to_end
652
+ stdout = StringIO.new
653
+ Commands::Create.new(stdout: stdout).main(cli_gem_args)
654
+ patch_gemfile_gempilot_path("cli_gem/Gemfile")
655
+
656
+ Dir.chdir("cli_gem") do
657
+ Commands::New.new(stdout: stdout).main(["command", "deploy"])
658
+
659
+ Bundler.with_unbundled_env do
660
+ output = `bundle exec rake 2>&1`
661
+
662
+ assert_equal 0, $CHILD_STATUS.exitstatus, "Default rake task failed in CLI gem:\n#{output}"
663
+ assert_equal "cli_gem 0.0.1", `bundle exec exe/cli_gem --version 2>&1`.strip
664
+ assert_equal "TODO: implement deploy", `bundle exec exe/cli_gem deploy 2>&1`.strip
665
+ end
666
+ end
667
+ end
668
+ ```
669
+
670
+ then, right after `private`, the argument helper:
671
+
672
+ ```ruby
673
+ def cli_gem_args
674
+ ["--author", "Test Author", "--email", "test@example.com", "--summary", "A test gem",
675
+ "--ruby-version", RUBY_VERSION, "--test", "minitest", "--exe", "--no-git", "cli_gem"]
676
+ end
677
+ ```
678
+
679
+ The end-to-end test mirrors the existing `*_default_rake_task_passes` tests (real `bundle install`, Gemfile patched to the checkout) and additionally generates a command and runs the executable, so it proves the whole scaffold: Zeitwerk eager-loads the CLI files, the gem's RuboCop accepts them, `--version` prints `cli_gem 0.0.1`, and `deploy` dispatches through `AutoLoad`.
680
+
681
+ - [ ] **Step 2: Run the unit tests to verify they fail**
682
+
683
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/create_command_test.rb -n "/exe_flag|cli_gem/"`
684
+ Expected: 4 runs; `test_exe_flag_bootstraps_a_command_kit_cli`, `test_hyphenated_gem_exe_flag_targets_the_extension_module` and the end-to-end test fail (`Errno::ENOENT` for `lib/test_gem/cli.rb`; the executable command is unknown), `test_no_exe_flag_leaves_the_cli_out` already passes.
685
+
686
+ - [ ] **Step 3: Wire the bootstrap into `Create`**
687
+
688
+ In `lib/gempilot/cli/commands/create.rb`:
689
+
690
+ ```ruby
691
+ include Generator
692
+ include GemBuilder
693
+ include CliBootstrap
694
+ ```
695
+
696
+ change the option description:
697
+
698
+ ```ruby
699
+ option :exe, long: "--[no-]exe", desc: "Create a CommandKit CLI with an executable"
700
+ ```
701
+
702
+ and in `derive_naming` set the ivar the CLI templates use, right after `@module_name`:
703
+
704
+ ```ruby
705
+ @module_name = @require_path.camelize
706
+ @gem_module = @module_name
707
+ @module_parts = @module_name.split("::")
708
+ ```
709
+
710
+ In `lib/gempilot/cli/gem_builder.rb` replace `render_executable` with:
711
+
712
+ ```ruby
713
+ # The executable is one piece of the CommandKit CLI, so the whole
714
+ # scaffold (router, base command, exe, inflection, dependency) comes
715
+ # from CliBootstrap, run from inside the new gem.
716
+ def render_executable
717
+ return unless options[:exe]
718
+
719
+ cd(@gem_name) { bootstrap_cli }
720
+ end
721
+ ```
722
+
723
+ and extend the module doc's ivar list:
724
+
725
+ ```ruby
726
+ ## +@gem_name+, +@require_path+, +@module_name+, +@gem_module+,
727
+ ## +@hyphenated+, +@test_framework+, +@branch+.
728
+ ```
729
+
730
+ Delete `data/templates/gem/exe/gem_name.erb` (`git rm data/templates/gem/exe/gem_name.erb`); `data/templates/cli/exe.erb` replaces it. `create_directories` keeps creating `exe/` up front so the printed scaffold listing is unchanged; `render_executable` runs before `run_bundle_install`, so the `command_kit` dependency the bootstrap adds is installed by create's own `bundle install`.
731
+
732
+ - [ ] **Step 4: Run the whole create test file, then the full gate**
733
+
734
+ Run: `bundle exec ruby -Itest -Ilib test/gempilot/cli/create_command_test.rb`
735
+ Expected: `57 runs, 0 failures, 0 errors` (this includes the three integration tests; allow a couple of minutes).
736
+
737
+ Run: `bundle exec rake default 2>&1 | tail -8`
738
+ Expected: minitest `128 runs, 0 failures`, RSpec `192 examples, 0 failures`, RuboCop `no offenses detected`.
739
+
740
+ - [ ] **Step 5: Commit**
741
+
742
+ ```bash
743
+ git add lib/gempilot/cli/commands/create.rb lib/gempilot/cli/gem_builder.rb data/templates/gem/exe/gem_name.erb test/gempilot/cli/create_command_test.rb
744
+ git commit -m "Scaffold the CommandKit CLI for create --exe"
745
+ ```
746
+
747
+ ---
748
+
749
+ ### Task 4: Documentation
750
+
751
+ **Files:**
752
+ - Modify: `README.md` (`gempilot create` options table, `gempilot new` section, Generated Gem Features)
753
+ - Modify: `CLAUDE.md` (Commands and Architecture lists)
754
+
755
+ **Interfaces:**
756
+ - Consumes: final behaviour of Tasks 2–3. No code.
757
+
758
+ - [ ] **Step 1: README**
759
+
760
+ In the `gempilot create` options table change the `--[no-]exe` row to:
761
+
762
+ ```markdown
763
+ | `--[no-]exe` | Create a CommandKit CLI: `exe/<gem>`, `lib/<gem>/cli.rb`, base command, `command_kit` dependency | prompted |
764
+ ```
765
+
766
+ Replace the paragraph under the `gempilot new` code block with:
767
+
768
+ ```markdown
769
+ Creates the source file under `lib/` and a corresponding test file. For
770
+ commands, generates a CommandKit command class in `lib/<gem>/cli/commands/`
771
+ and, the first time, bootstraps the CLI around it: `lib/<gem>/cli.rb` (the
772
+ router), `lib/<gem>/cli/command.rb` (the base class), an executable
773
+ `exe/<gem>` that starts the router, the `command_kit` dependency in the
774
+ gemspec (followed by `bundle install`), and the Zeitwerk inflection
775
+ `"cli" => "CLI"` in `lib/<gem>.rb`. Pieces that already exist are left alone.
776
+ ```
777
+
778
+ In "Generated Gem Features" add, after the Zeitwerk bullet:
779
+
780
+ ```markdown
781
+ - **CommandKit CLI** when created with `--exe`: router, base command, and an
782
+ executable that works from the first commit
783
+ ```
784
+
785
+ - [ ] **Step 2: CLAUDE.md**
786
+
787
+ Change the `gempilot new` command bullet to:
788
+
789
+ ```markdown
790
+ - `gempilot new` — Generate a class, module, or command in an existing gem (templates in `data/templates/new/`); generating a command also bootstraps the CommandKit CLI when it is missing
791
+ ```
792
+
793
+ and the `gempilot create` bullet to:
794
+
795
+ ```markdown
796
+ - `gempilot create` — Scaffold a new gem (templates in `data/templates/gem/`); `--exe` adds a CommandKit CLI
797
+ ```
798
+
799
+ Add to the Architecture list, after the `GemContext` bullet:
800
+
801
+ ```markdown
802
+ - `CliBootstrap` module (`lib/gempilot/cli/cli_bootstrap.rb`) shared by `create --exe` and `new command`: idempotently scaffolds `lib/<gem>/cli.rb`, `lib/<gem>/cli/command.rb`, `exe/<gem>` (chmod +x), the `command_kit` gemspec dependency, and the `"cli" => "CLI"` loader inflection; its templates live in `data/templates/cli/` and are rendered through `Generator#erb(..., from:)`
803
+ ```
804
+
805
+ - [ ] **Step 3: Commit**
806
+
807
+ ```bash
808
+ git add README.md CLAUDE.md
809
+ git commit -m "Document the CommandKit CLI bootstrap"
810
+ ```