avo 4.1.2 → 4.1.3

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: fb3546ef7dd6b1a139d47614126266994823ab88f00ec3e426b95b654fcc6b1a
4
- data.tar.gz: 035f10d186aeaf732f6a3d68713568d839ee4b408e88d8f03c140374b816b7df
3
+ metadata.gz: 342b9da8cac48e785b94b6261a29cf466d2320d46a67d94a42b7779afbba3232
4
+ data.tar.gz: 5085b06b9ec693fea5538c8c349766d1afae278be2bf0ffa8329b5e535a70f1b
5
5
  SHA512:
6
- metadata.gz: 9615e1f3b3c3971a5d522c35d309201d1fbaa35aad6c00cf2d6ddab8d2bde4a595fa5de19dd26eefbba9df69527cdecff8a28cb8884c5f1a977adf6138d10e94
7
- data.tar.gz: bb7bd93dcd97b4dbf21117e3d273c7bf63afbbb10e0eab8c34ef9d4769646cdd7d8fd2dceb00530ee9b5ca0034864b59096340f5f8c1ba710c4373e045848e7e
6
+ metadata.gz: e735bcba96cdfa3a8c3c3eb3a1885076bcd4c4d168bcf9f529404474fac628a4fb2d4a4f225c26f6598600cd9846e046ce2e85c76fb23d5270f85186476bd3df
7
+ data.tar.gz: 84f62915ab3f3b456669538a582b0dec138c8057fdb60cc4fd578e649f03ffa159974079f1deee3c1a4586eff7d67dca23fa2f644d8bf4b121754b399d6f234f
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- avo (4.1.2)
4
+ avo (4.1.3)
5
5
  actionview (>= 6.1)
6
6
  active_link_to
7
7
  activerecord (>= 6.1)
data/lib/avo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Avo
2
- VERSION = "4.1.2" unless const_defined?(:VERSION)
2
+ VERSION = "4.1.3" unless const_defined?(:VERSION)
3
3
  end
@@ -1,3 +1,4 @@
1
+ require "tmpdir"
1
2
  require_relative "base_generator"
2
3
  require_relative "skills_install_panel"
3
4
 
@@ -5,12 +6,12 @@ module Generators
5
6
  module Avo
6
7
  # Installs the Avo skills loader into the host app.
7
8
  #
8
- # Only the loader is copied — the skills themselves stay inside the gem, which
9
- # is the whole point: a copied skill tree drifts from the locked Avo version
10
- # with nothing to refresh it. Re-running this generator refreshes the loader.
9
+ # Only the loader is written — the skills themselves stay inside the gem,
10
+ # which is the point: a copied skill tree drifts from the locked Avo version
11
+ # with nothing to refresh it.
11
12
  #
12
- # Deliberately its own namespace rather than part of `avo:install`, so it does
13
- # not change behavior for apps that already ran the installer.
13
+ # There is one way to run this and it asks. No flags: a flag per question is
14
+ # a second surface to document, keep in step with the screens, and get wrong.
14
15
  class SkillsGenerator < BaseGenerator
15
16
  source_root File.expand_path("templates", __dir__)
16
17
 
@@ -18,212 +19,281 @@ module Generators
18
19
  desc "Install the Avo skills loader so coding agents read the instructions that ship with this app's Avo version."
19
20
 
20
21
  # Claude Code scans .claude/skills; .agents/skills is the cross-agent
21
- # convention; .cursor/skills is what Cursor reads. Installing to all three
22
- # is why the loader is copied rather than symlinked — a symlink into a
23
- # version-named gem directory breaks on the next `bundle update`.
24
- TARGETS = {
25
- "claude" => ".claude/skills/avo",
26
- "agents" => ".agents/skills/avo",
27
- "cursor" => ".cursor/skills/avo"
22
+ # convention; .cursor/skills is what Cursor reads.
23
+ AGENTS = {
24
+ "agents" => {dir: ".agents/skills/avo", label: ".agents/skills", hint: "Codex, Gemini CLI, Goose, Amp, OpenCode"},
25
+ "claude" => {dir: ".claude/skills/avo", label: ".claude/skills", hint: "Claude Code"},
26
+ "cursor" => {dir: ".cursor/skills/avo", label: ".cursor/skills", hint: "Cursor"}
28
27
  }.freeze
29
28
 
30
- # Where a pre-gem install of avo-hq/skills materialized its catalog.
31
- # `npx skills add` writes project-locally, so the stale copies land in the
32
- # same directories this generator installs into.
33
- LEGACY_ROOTS = [
34
- ".claude/skills",
35
- ".agents/skills",
36
- ".cursor/skills"
37
- ].freeze
38
-
39
- # Checked, reported, and never deleted. This directory is shared by every
40
- # project on the machine — a skill here may be deliberately installed for
41
- # a different app, and a generator run inside one project has no business
42
- # removing it. The user gets the command instead.
43
- GLOBAL_LEGACY_ROOT = "~/.claude/skills"
29
+ # The copy the others link to. Vendor-neutral on purpose: the real file
30
+ # should not live in a directory named after one tool.
31
+ CANONICAL = "agents"
44
32
 
45
- class_option :global, type: :boolean,
46
- desc: "Install to your home directory instead of this app, so every Avo project on this machine shares one loader"
47
- class_option :only, type: :string, desc: "Install one target only (#{TARGETS.keys.join(", ")})"
48
- class_option :panel, type: :boolean, default: true,
49
- desc: "Show the interactive install panel (skipped automatically without a TTY, or when --global/--only is given)"
50
- class_option :path, type: :string, desc: "Install to this directory instead of the default scan paths"
51
- class_option :clean_legacy, type: :boolean,
52
- desc: "Remove skills left by a pre-gem install of avo-hq/skills (skips the prompt either way)"
33
+ # Where a pre-gem install of avo-hq/skills left its catalog, and where an
34
+ # early 4.1.1 install left the resolver we no longer ship.
35
+ LEGACY_ROOTS = [".claude/skills", ".agents/skills", ".cursor/skills"].freeze
36
+ GLOBAL_LEGACY_ROOT = "~/.claude/skills"
53
37
 
54
38
  def install_loader
55
- return if panel_cancelled?
56
-
57
- # Only the loader is written. The resolver stays inside the gem, where
58
- # `bundle update` refreshes it and it cannot drift; a copy in the app
59
- # would be one more file to keep in sync, and 250 lines of shell in
60
- # someone's repo is a reasonable thing to be suspicious of.
61
- destinations.each { |destination| copy_file "skills/SKILL.md", File.join(destination, "SKILL.md") }
62
- end
39
+ return if answers.cancelled?
63
40
 
64
- # A leftover catalog sits in the same scan directories as the loader and
65
- # can shadow it, silently serving instructions written for a different Avo
66
- # version — which is the whole problem this feature removes. Install time
67
- # is the right place to catch it: it is the one moment we know the user is
68
- # present and thinking about skills.
69
- def clean_legacy_skills
70
- leftovers = legacy_skill_dirs
71
- global = global_legacy_skill_dirs
72
- return if leftovers.empty? && global.empty?
73
-
74
- if leftovers.any?
75
- say "\nFound #{pluralize_skills(leftovers.length)} in this project from a previous avo-hq/skills install:"
76
- by_directory(leftovers).each { |dir, count| say " #{dir}#{" " * padding(dir)}#{count}", :yellow }
77
- say "These are not version-pinned and can shadow the skills that ship with your Avo gem."
78
-
79
- if remove_legacy?
80
- by_directory(leftovers).each do |dir, count|
81
- leftovers.select { |path| display_dir(path) == dir }.each { |path| FileUtils.rm_rf(path) }
82
- say_status :remove, "#{dir} (#{pluralize_skills(count)})", :red
83
- end
41
+ canonical = nil
42
+ destinations.each do |dir|
43
+ if canonical.nil? || !link?
44
+ copy_file "skills/SKILL.md", File.join(dir, "SKILL.md")
45
+ canonical ||= dir
84
46
  else
85
- report_kept(leftovers)
47
+ link_or_copy(canonical, dir)
86
48
  end
87
49
  end
50
+ end
88
51
 
89
- report_global(global) if global.any?
90
- say "\nIf you installed the Claude Code plugin, remove it with: /plugin uninstall avo-skills"
52
+ def clean_legacy_skills
53
+ return if answers.cancelled?
54
+
55
+ remove_or_report(legacy_skill_dirs, remove: remove_legacy?)
56
+ remove_or_report(shared_legacy_skill_dirs, remove: remove_shared_legacy?)
91
57
  end
92
58
 
93
59
  no_tasks do
94
- def remove_legacy?
95
- return options[:clean_legacy] unless options[:clean_legacy].nil?
96
- # No TTY to answer, so keep the files and say where they are.
97
- return false if options[:quiet]
60
+ def answers
61
+ @answers ||= SkillsInstallPanel.interactive? ? ask_the_user : take_the_defaults
62
+ end
63
+
64
+ # Answering "No" on the last screen is a cancel — the user was shown what
65
+ # was about to happen and declined it.
66
+ def ask_the_user
67
+ result = SkillsInstallPanel.new(screens, defaults: defaults).run
68
+ return declined if result.cancelled? || result[:confirm] == false
98
69
 
99
- yes?("\nRemove them? [y/N]")
70
+ SkillsInstallPanel::Result.new(defaults.merge(result.answers), false)
100
71
  end
101
72
 
102
- def report_kept(leftovers)
103
- say "\nLeaving them in place. Remove them later with:", :yellow
104
- say " #{removal_command(leftovers)}"
73
+ def declined
74
+ say("Cancelled. Nothing was installed.", :yellow)
75
+ SkillsInstallPanel::Result.new({}, true)
105
76
  end
106
77
 
107
- def report_global(global)
108
- say "\nAlso found #{pluralize_skills(global.length)} in #{GLOBAL_LEGACY_ROOT}, shared by every project on this machine:", :yellow
109
- by_directory(global).each { |dir, count| say " #{dir}#{" " * padding(dir)}#{count}" }
110
- say "Not removing those — another project may still want them. If not, run:"
111
- say " #{removal_command(global)}"
78
+ # No terminal to ask on — piped, or a test run. Install what the screens
79
+ # recommend, but neither destructive one: nobody was there to decline.
80
+ def take_the_defaults
81
+ SkillsInstallPanel::Result.new(defaults.merge(clean_legacy: false, clean_shared_legacy: false), false)
112
82
  end
113
83
 
114
- # One glob per directory rather than one path per skill: with the full
115
- # catalog installed this is the difference between a readable command
116
- # and 35 pasted paths.
117
- def removal_command(dirs)
118
- "rm -rf #{by_directory(dirs).keys.map { "#{_1}avo-*" }.join(" ")}"
84
+ def defaults
85
+ {
86
+ where: "app", agents: AGENTS.keys, link: symlinks_supported?,
87
+ clean_legacy: true, clean_shared_legacy: true, confirm: true
88
+ }
119
89
  end
120
90
 
121
- # Directory => how many legacy skills sit in it, so a full catalog
122
- # reports three lines instead of a hundred.
123
- def by_directory(dirs)
124
- dirs.group_by { display_dir(_1) }.transform_values(&:count).sort.to_h
91
+ # A cleanup screen only when there is something to remove there, and the
92
+ # link screen only once there is more than one directory to keep in step.
93
+ def screens
94
+ list = [where_screen]
95
+ list << legacy_screen if legacy_skill_dirs.any?
96
+ list << shared_legacy_screen if shared_legacy_skill_dirs.any?
97
+ list << agents_screen
98
+ list << link_screen if symlinks_supported?
99
+ list << confirm_screen
100
+ list
125
101
  end
126
102
 
127
- def display_dir(path)
128
- "#{display_path(File.dirname(path))}/"
103
+ def where_screen
104
+ SkillsInstallPanel::Screen.new(
105
+ key: :where, kind: :radio, title: "Where should the Avo skills loader go?",
106
+ hint: "It finds the gem from the working directory, so one copy serves every project correctly.",
107
+ options: [
108
+ SkillsInstallPanel::Option.new(key: "app", label: "This app", hint: "commit it, your team gets it", recommended: true),
109
+ SkillsInstallPanel::Option.new(key: "machine", label: "This machine", hint: "one install, every Avo project")
110
+ ]
111
+ )
129
112
  end
130
113
 
131
- def padding(dir)
132
- [2, 28 - dir.length].max
114
+ def legacy_screen
115
+ SkillsInstallPanel::Screen.new(
116
+ key: :clean_legacy, kind: :radio,
117
+ title: "Remove the #{pluralize_skills(legacy_skill_dirs.length)} left by an earlier install?",
118
+ hint: "They are not version-pinned and can shadow the skills that ship with your gem.",
119
+ options: [
120
+ SkillsInstallPanel::Option.new(key: true, label: "Yes, remove them", recommended: true),
121
+ SkillsInstallPanel::Option.new(key: false, label: "No, leave them")
122
+ ]
123
+ )
133
124
  end
134
125
 
135
- def pluralize_skills(count)
136
- "#{count} skill#{"s" unless count == 1}"
126
+ # Kept a separate question from the project one: this directory is shared
127
+ # with every other project on the machine, and that is worth its own yes.
128
+ def shared_legacy_screen
129
+ SkillsInstallPanel::Screen.new(
130
+ key: :clean_shared_legacy, kind: :radio,
131
+ title: "Remove the #{pluralize_skills(shared_legacy_skill_dirs.length)} in #{GLOBAL_LEGACY_ROOT}?",
132
+ hint: "That directory is shared by every project on this machine, not just this one.",
133
+ options: [
134
+ SkillsInstallPanel::Option.new(key: true, label: "Yes, remove them", recommended: true),
135
+ SkillsInstallPanel::Option.new(key: false, label: "No, leave them")
136
+ ]
137
+ )
137
138
  end
138
139
 
139
- # Cleanup is scoped to wherever this run installs. A project install must
140
- # not delete from the home directory another project may still rely on;
141
- # a --global install is exactly the case where home is fair game.
142
- def legacy_skill_dirs
143
- roots = global_install? ? [File.expand_path(GLOBAL_LEGACY_ROOT)] : LEGACY_ROOTS.map { File.expand_path(_1, destination_root) }
144
- skill_dirs_in(roots)
140
+ def confirm_screen
141
+ SkillsInstallPanel::Screen.new(
142
+ key: :confirm, kind: :radio, title: "Run the installation?", hint: nil,
143
+ summary: ->(a) { summary_lines(a) },
144
+ options: [
145
+ SkillsInstallPanel::Option.new(key: true, label: "Yes, install", recommended: true),
146
+ SkillsInstallPanel::Option.new(key: false, label: "No, cancel")
147
+ ]
148
+ )
145
149
  end
146
150
 
147
- def global_legacy_skill_dirs
148
- return [] if global_install?
151
+ def summary_lines(a)
152
+ agents = Array(a[:agents]).select { AGENTS.key?(_1) }
153
+ root = (a[:where] == "machine") ? "~/" : ""
154
+ lines = [
155
+ summary_row("Install to", (a[:where] == "machine") ? "this machine (your home directory)" : "this app"),
156
+ summary_row("Directories", agents.map { "#{root}#{AGENTS.fetch(_1)[:label]}" }.join(", "))
157
+ ]
158
+ lines << summary_row("Extra copies", a[:link] ? "symlinked to #{root}#{AGENTS.fetch(CANONICAL)[:label]}" : "written separately") if agents.length > 1
159
+ lines << summary_row("Remove", "#{pluralize_skills(legacy_skill_dirs.length)} left in this project") if legacy_skill_dirs.any? && a[:clean_legacy]
160
+ lines << summary_row("Remove", "#{pluralize_skills(shared_legacy_skill_dirs.length)} in #{GLOBAL_LEGACY_ROOT}") if shared_legacy_skill_dirs.any? && a[:clean_shared_legacy]
161
+ lines
162
+ end
149
163
 
150
- skill_dirs_in([File.expand_path(GLOBAL_LEGACY_ROOT)])
164
+ def summary_row(label, value) = "#{label.ljust(14)}#{value}"
165
+
166
+ def agents_screen
167
+ SkillsInstallPanel::Screen.new(
168
+ key: :agents, kind: :checkbox, title: "Which agents should read it?", hint: nil,
169
+ options: AGENTS.map { |key, a| SkillsInstallPanel::Option.new(key: key, label: a[:label], hint: a[:hint]) }
170
+ )
151
171
  end
152
172
 
153
- # Only directories that actually look like a skill are eligible — a
154
- # `rm -rf` driven by a name glob alone is not something to hand a user.
155
- def skill_dirs_in(roots)
156
- roots.flat_map { |root| Dir.glob(File.join(root, "avo-*")) }
157
- .select { |dir| File.directory?(dir) && File.file?(File.join(dir, "SKILL.md")) }
158
- .uniq
159
- .sort
173
+ def link_screen
174
+ SkillsInstallPanel::Screen.new(
175
+ key: :link, kind: :radio, title: "How should the extra directories be written?",
176
+ hint: "One real file keeps them identical and makes an update a single edit.",
177
+ options: [
178
+ SkillsInstallPanel::Option.new(key: true, label: "Symlink to one copy", hint: ".#{CANONICAL}/skills holds the file", recommended: true),
179
+ SkillsInstallPanel::Option.new(key: false, label: "Separate copies", hint: "use when symlinks are awkward")
180
+ ],
181
+ visible_when: ->(a) { Array(a[:agents]).length > 1 }
182
+ )
160
183
  end
161
184
 
162
- def display_path(dir)
163
- home = File.expand_path("~")
164
- return dir.sub(home, "~") if dir.start_with?(home) && !dir.start_with?(File.expand_path(destination_root))
185
+ def destinations
186
+ dirs = selected_agent_keys.map { |k| AGENTS.fetch(k)[:dir] }
187
+ global? ? dirs.map { File.expand_path("~/#{_1}") } : dirs
188
+ end
165
189
 
166
- dir.delete_prefix("#{File.expand_path(destination_root)}/")
190
+ # Canonical first, so it is the one written as a real file.
191
+ def selected_agent_keys
192
+ keys = Array(answers[:agents]).select { AGENTS.key?(_1) }
193
+ keys = AGENTS.keys if keys.empty?
194
+ keys.sort_by { |k| (k == CANONICAL) ? 0 : 1 }
167
195
  end
168
196
 
169
- # The loader holds no per-project state — it finds the app by walking up
170
- # from the working directory at run time — so one global copy serves
171
- # every Avo project on the machine, each resolving its own lock.
172
- def destinations
173
- return [options[:path]] if options[:path].present?
197
+ def global? = answers[:where] == "machine"
198
+
199
+ def link? = !!answers[:link]
174
200
 
175
- return roots.map { |target| File.expand_path("~/#{target}") } if global_install?
201
+ def remove_legacy? = !!answers[:clean_legacy]
176
202
 
177
- if (only = options[:only]).present?
178
- target = TARGETS[only]
179
- raise ::Thor::Error, "Unknown --only value #{only.inspect}. Valid values: #{TARGETS.keys.join(", ")}." if target.nil?
203
+ def remove_shared_legacy? = !!answers[:clean_shared_legacy]
180
204
 
181
- return [target]
205
+ def remove_or_report(dirs, remove:)
206
+ return if dirs.empty?
207
+
208
+ if remove
209
+ by_directory(dirs).each do |dir, count|
210
+ dirs.select { |p| display_dir(p) == dir }.each { |p| FileUtils.rm_rf(p) }
211
+ say_status :remove, "#{dir} (#{pluralize_skills(count)})", :red
212
+ end
213
+ else
214
+ # By directory, never one line per skill: the full catalog is 35 per
215
+ # scan path, and listing them buries everything else.
216
+ say "\nLeaving #{pluralize_skills(dirs.length)} in place:", :yellow
217
+ by_directory(dirs).each { |dir, count| say " #{dir} #{count}" }
218
+ say "Remove them later with:"
219
+ say " #{removal_command(dirs)}"
182
220
  end
221
+ end
183
222
 
184
- TARGETS.values
223
+ # Probed, not assumed: Windows without developer mode, and some CI
224
+ # checkouts, cannot create one. A real file beats a broken install.
225
+ def symlinks_supported?
226
+ return @symlinks_supported if defined?(@symlinks_supported)
227
+
228
+ @symlinks_supported =
229
+ begin
230
+ Dir.mktmpdir do |dir|
231
+ File.write(File.join(dir, "target"), "x")
232
+ File.symlink(File.join(dir, "target"), File.join(dir, "link"))
233
+ File.symlink?(File.join(dir, "link"))
234
+ end
235
+ rescue NotImplementedError, SystemCallError
236
+ false
237
+ end
185
238
  end
186
239
 
187
- def roots
188
- return [TARGETS.fetch(options[:only])] if options[:only].present? && TARGETS.key?(options[:only])
189
- return panel_choice.targets.map { |key| TARGETS.fetch(key) } if panel_choice
240
+ # `destination` is absolute for a machine-wide install and relative for
241
+ # an app one, so expand rather than join — File.join would nest the
242
+ # absolute path under the app root.
243
+ def link_or_copy(canonical, destination)
244
+ target = File.expand_path(File.join(destination, "SKILL.md"), destination_root)
245
+ source = File.expand_path(File.join(canonical, "SKILL.md"), destination_root)
246
+ FileUtils.mkdir_p(File.dirname(target))
247
+ FileUtils.rm_f(target)
248
+ File.symlink(relative_link(source, target), target)
249
+ say_status :symlink, "#{display_path(destination)}/SKILL.md → #{display_path(canonical)}/SKILL.md", :green
250
+ rescue NotImplementedError, SystemCallError => e
251
+ say_status :copy, "#{display_path(destination)}/SKILL.md (symlink unavailable: #{e.class})", :yellow
252
+ copy_file "skills/SKILL.md", File.join(destination, "SKILL.md")
253
+ end
190
254
 
191
- TARGETS.values
255
+ # Relative so the link survives the repo being cloned elsewhere.
256
+ def relative_link(source, target)
257
+ Pathname.new(source).relative_path_from(Pathname.new(File.dirname(target))).to_s
192
258
  end
193
259
 
194
- # Shown once, memoized, and only when the user has not already answered
195
- # with flags. Without a TTY there is nobody to answer, so the defaults
196
- # stand and the install is silent.
197
- def panel_choice
198
- return @panel_choice if defined?(@panel_choice)
260
+ # Subtracts the shared ones so the two cleanup questions never cover the
261
+ # same directory they can overlap when the generator runs in ~.
262
+ def legacy_skill_dirs
263
+ skill_dirs_in(LEGACY_ROOTS.map { File.expand_path(_1, destination_root) }) - shared_legacy_skill_dirs
264
+ end
199
265
 
200
- @panel_choice = if skip_panel?
201
- nil
202
- else
203
- SkillsInstallPanel.new.run
204
- end
266
+ def shared_legacy_skill_dirs
267
+ skill_dirs_in([File.expand_path(GLOBAL_LEGACY_ROOT)])
205
268
  end
206
269
 
207
- def skip_panel?
208
- options[:panel] == false ||
209
- options[:quiet] ||
210
- options[:path].present? ||
211
- options[:only].present? ||
212
- !options[:global].nil? ||
213
- !SkillsInstallPanel.interactive?
270
+ # A leftover skill directory, or the scripts/ directory an early 4.1.1
271
+ # install left behind. A `rm -rf` driven by a name glob alone is not
272
+ # something to hand a user.
273
+ def skill_dirs_in(roots)
274
+ roots.flat_map { |root| Dir.glob(File.join(root, "avo-*")) + Dir.glob(File.join(root, "avo", "scripts")) }
275
+ .select { |dir| File.directory?(dir) && (File.file?(File.join(dir, "SKILL.md")) || File.basename(dir) == "scripts") }
276
+ .uniq
277
+ .sort
214
278
  end
215
279
 
216
- def panel_cancelled?
217
- return false unless panel_choice&.cancelled?
280
+ def removal_command(dirs)
281
+ "rm -rf #{by_directory(dirs).keys.map { "#{_1}avo-*" }.join(" ")}"
282
+ end
218
283
 
219
- say "Cancelled. Nothing was installed.", :yellow
220
- true
284
+ def by_directory(dirs)
285
+ dirs.group_by { display_dir(_1) }.transform_values(&:count).sort.to_h
221
286
  end
222
287
 
223
- def global_install?
224
- return options[:global] unless options[:global].nil?
288
+ def display_dir(path) = "#{display_path(File.dirname(path))}/"
289
+
290
+ def pluralize_skills(count) = "#{count} skill#{"s" unless count == 1}"
225
291
 
226
- panel_choice&.scope == "global"
292
+ def display_path(dir)
293
+ home = File.expand_path("~")
294
+ return dir.sub(home, "~") if dir.start_with?(home) && !dir.start_with?(File.expand_path(destination_root))
295
+
296
+ dir.delete_prefix("#{File.expand_path(destination_root)}/")
227
297
  end
228
298
  end
229
299
  end
@@ -2,97 +2,184 @@ require "io/console"
2
2
 
3
3
  module Generators
4
4
  module Avo
5
- # A small keyboard-driven panel for `rails g avo:skills`.
5
+ # The `rails g avo:skills` install flow: one question per screen, answered
6
+ # up front, then the generator runs to completion without interrupting.
6
7
  #
7
- # Two groups with different semantics: scope is a radio (this app OR the
8
- # machine), targets are checkboxes (any combination of scan directories).
9
- # Written against io/console from stdlib rather than a prompt gem, because
10
- # avo is a dependency of other people's apps and a TUI is not worth adding
11
- # one for.
8
+ # Two screen kinds. A :radio moves its selection with the arrows, so
9
+ # down-then-enter picks the second option pressing space to commit a
10
+ # highlighted row is a step people miss. A :checkbox has an independent
11
+ # state per row, so there space is the toggle and the arrows only move.
12
12
  #
13
- # Falls back to defaults whenever there is no interactive terminal, so
14
- # `-q`, CI, and piped invocations behave predictably. Callers pass explicit
15
- # flags to skip the panel entirely.
13
+ # Written against io/console from stdlib rather than a prompt gem: avo is a
14
+ # dependency of other people's apps, and a TUI is not worth adding one for.
16
15
  class SkillsInstallPanel
17
- SCOPES = [
18
- {key: "local", label: "This app", hint: "committed, so the whole team gets it"},
19
- {key: "global", label: "This machine", hint: "one install, every Avo project"}
20
- ].freeze
21
-
22
- TARGETS = [
23
- {key: "claude", label: ".claude/skills", hint: "Claude Code"},
24
- {key: "agents", label: ".agents/skills", hint: "Codex, Gemini CLI, Goose, Amp, OpenCode"},
25
- {key: "cursor", label: ".cursor/skills", hint: "Cursor"}
26
- ].freeze
27
-
28
- Result = Struct.new(:scope, :targets, :cancelled) do
16
+ # `visible_when` takes the answers so far, so a screen that only makes
17
+ # sense given an earlier answer is skipped rather than asked pointlessly.
18
+ # `summary` takes them too, and renders above the options — how the final
19
+ # confirm screen shows what it is about to do.
20
+ Screen = Struct.new(:key, :kind, :title, :hint, :options, :visible_when, :summary)
21
+ Option = Struct.new(:key, :label, :hint, :recommended)
22
+
23
+ Result = Struct.new(:answers, :cancelled) do
29
24
  def cancelled? = !!cancelled
25
+
26
+ def [](key) = answers[key]
30
27
  end
31
28
 
32
29
  def self.interactive?
33
30
  $stdin.tty? && $stdout.tty?
34
31
  end
35
32
 
36
- def initialize(input: $stdin, output: $stdout)
33
+ # `defaults` seeds each screen, including one that never becomes visible —
34
+ # its default is then simply the answer.
35
+ def initialize(screens, defaults: {}, input: $stdin, output: $stdout)
36
+ @screens = screens
37
37
  @input = input
38
38
  @output = output
39
- @scope = 0
40
- @targets = TARGETS.map { |t| [t[:key], true] }.to_h
41
- @cursor = 0
42
- @rows_drawn = 0
43
- end
44
-
45
- # Rows are the two scopes then the three targets, so one cursor walks
46
- # both groups and the whole panel is arrow-navigable.
47
- def rows
48
- SCOPES.length + TARGETS.length
39
+ @answers = {}
40
+ @screens.each { |s| @answers[s.key] = defaults.fetch(s.key) { initial_for(s) } }
49
41
  end
50
42
 
43
+ # `step` carries the direction, so a skipped screen is stepped over the
44
+ # way it was entered — otherwise going back would walk into it forwards.
51
45
  def run
52
- render
53
- loop do
54
- case read_key
55
- when :up then move(-1)
56
- when :down then move(1)
57
- when :space then toggle
58
- when :enter then return finish
59
- when :cancel then return cancel
46
+ index = 0
47
+ step = 1
48
+
49
+ while index < @screens.length
50
+ screen = @screens[index]
51
+ unless visible?(screen)
52
+ index += step
53
+ next
54
+ end
55
+
56
+ case ask(screen, first: first_visible?(index))
57
+ when :back then step = -1
58
+ when :cancel then return Result.new({}, true)
59
+ else step = 1
60
60
  end
61
- render
61
+ index += step
62
62
  end
63
+
64
+ Result.new(@answers, false)
63
65
  end
64
66
 
65
67
  private
66
68
 
69
+ def visible?(screen) = screen.visible_when.nil? || screen.visible_when.call(@answers)
70
+
71
+ # Back is only offered when there is an earlier screen still worth
72
+ # returning to, so a hidden first screen does not strand the user.
73
+ def first_visible?(index) = @screens[0...index].none? { visible?(_1) }
74
+
67
75
  attr_reader :input, :output
68
76
 
69
- def move(delta)
70
- @cursor = (@cursor + delta) % rows
77
+ def initial_for(screen)
78
+ if screen.kind == :radio
79
+ (screen.options.find(&:recommended) || screen.options.first).key
80
+ else
81
+ screen.options.map(&:key)
82
+ end
71
83
  end
72
84
 
73
- def toggle
74
- if @cursor < SCOPES.length
75
- @scope = @cursor
76
- else
77
- key = TARGETS[@cursor - SCOPES.length][:key]
78
- # Refuse to empty the set: an install with no target writes nothing
79
- # and looks like a silent no-op.
80
- @targets[key] = !@targets[key] unless @targets[key] && selected_targets.length == 1
85
+ def ask(screen, first:)
86
+ cursor = start_cursor(screen)
87
+ rows = 0
88
+
89
+ loop do
90
+ rows = draw(screen, cursor, rows, first: first)
91
+
92
+ case read_key
93
+ when :up then cursor = (cursor - 1) % screen.options.length
94
+ when :down then cursor = (cursor + 1) % screen.options.length
95
+ when :space then toggle(screen, cursor)
96
+ when :enter then return commit(screen, cursor, rows)
97
+ when :back then (return erase(rows) { :back }) unless first
98
+ when :cancel then return erase(rows) { :cancel }
99
+ end
100
+
101
+ # A radio's selection follows the cursor, so moving is choosing and
102
+ # enter alone is enough.
103
+ @answers[screen.key] = screen.options[cursor].key if screen.kind == :radio
81
104
  end
82
105
  end
83
106
 
84
- def selected_targets
85
- TARGETS.map { |t| t[:key] }.select { |k| @targets[k] }
107
+ def start_cursor(screen)
108
+ return 0 unless screen.kind == :radio
109
+
110
+ screen.options.index { |o| o.key == @answers[screen.key] } || 0
86
111
  end
87
112
 
88
- def finish
89
- clear
90
- Result.new(SCOPES[@scope][:key], selected_targets, false)
113
+ def toggle(screen, cursor)
114
+ return if screen.kind == :radio
115
+
116
+ key = screen.options[cursor].key
117
+ selected = @answers[screen.key]
118
+ # Refuse to empty the set: installing nowhere writes nothing and reads
119
+ # as a silent no-op.
120
+ return if selected == [key]
121
+
122
+ @answers[screen.key] = selected.include?(key) ? selected - [key] : selected + [key]
123
+ @answers[screen.key] = screen.options.map(&:key).select { |k| @answers[screen.key].include?(k) }
91
124
  end
92
125
 
93
- def cancel
94
- clear
95
- Result.new(nil, [], true)
126
+ def commit(screen, cursor, rows)
127
+ @answers[screen.key] = screen.options[cursor].key if screen.kind == :radio
128
+ erase(rows) { :next }
129
+ end
130
+
131
+ def erase(rows)
132
+ output.print("\e[#{rows}A\e[J") if rows.positive?
133
+ yield
134
+ end
135
+
136
+ def draw(screen, cursor, previous_rows, first:)
137
+ output.print("\e[#{previous_rows}A\e[J") if previous_rows.positive?
138
+
139
+ lines = ["", " #{bold(screen.title)}"]
140
+ lines << " #{dim(screen.hint)}" if screen.hint
141
+ lines << ""
142
+
143
+ if screen.summary
144
+ screen.summary.call(@answers).each { |line| lines << " #{line}" }
145
+ lines << ""
146
+ end
147
+
148
+ screen.options.each_with_index do |option, i|
149
+ lines << option_row(screen, option, i, cursor)
150
+ end
151
+
152
+ lines << ""
153
+ lines << " #{dim(keys_hint(screen, first: first))}"
154
+ lines << ""
155
+
156
+ output.print(lines.join("\n") + "\n")
157
+ lines.length
158
+ end
159
+
160
+ def option_row(screen, option, index, cursor)
161
+ marker =
162
+ if screen.kind == :radio
163
+ (@answers[screen.key] == option.key) ? "(•)" : "( )"
164
+ else
165
+ @answers[screen.key].include?(option.key) ? "[x]" : "[ ]"
166
+ end
167
+
168
+ label = option.label
169
+ label += " #{dim("(recommended)")}" if option.recommended
170
+ text = "#{(cursor == index) ? "›" : " "} #{marker} #{label}"
171
+ text += " #{dim(option.hint)}" if option.hint
172
+
173
+ (cursor == index) ? " #{bold(text)}" : " #{text}"
174
+ end
175
+
176
+ def keys_hint(screen, first:)
177
+ parts = ["↑↓ move"]
178
+ parts << "space toggle" if screen.kind == :checkbox
179
+ parts << "enter continue"
180
+ parts << "← back" unless first
181
+ parts << "q cancel"
182
+ parts.join(" ")
96
183
  end
97
184
 
98
185
  def read_key
@@ -100,70 +187,37 @@ module Generators
100
187
  case char
101
188
  when "\r", "\n" then :enter
102
189
  when " " then :space
103
- when "", "q", "\e" then escape_or_cancel(char)
190
+ # nil is EOF: stdin closed, or a test keyboard ran dry. Treat it as
191
+ # cancel rather than spinning on it forever.
192
+ when nil, "q", "\u0003", "\u0004" then :cancel
193
+ when "\e" then escape_sequence
104
194
  else :none
105
195
  end
106
196
  rescue Interrupt
107
197
  :cancel
108
198
  end
109
199
 
110
- # An arrow key arrives as ESC [ A. A bare ESC means cancel, so peek at
111
- # what follows before deciding.
112
- def escape_or_cancel(char)
113
- return :cancel unless char == "\e"
200
+ # Arrows arrive as ESC [ A. A bare ESC cancels, so peek before deciding.
201
+ def escape_sequence
114
202
  return :cancel unless input.respond_to?(:read_nonblock)
115
203
 
116
204
  begin
117
205
  seq = input.read_nonblock(2)
118
- rescue IO::WaitReadable, EOFError, Errno::EAGAIN
206
+ rescue IO::WaitReadable, EOFError, Errno::EAGAIN, Errno::EWOULDBLOCK
119
207
  return :cancel
120
208
  end
121
209
 
122
210
  case seq
123
211
  when "[A" then :up
124
212
  when "[B" then :down
213
+ when "[D" then :back
125
214
  else :none
126
215
  end
127
216
  end
128
217
 
129
- def clear
130
- output.print("\e[#{@rows_drawn}A\e[J") if @rows_drawn.positive?
131
- @rows_drawn = 0
132
- end
218
+ def bold(text) = "\e[1m#{text}\e[0m"
133
219
 
134
- def render
135
- clear
136
- lines = []
137
- lines << ""
138
- lines << " Install the Avo skills loader"
139
- lines << ""
140
- lines << " #{dim("Where")}"
141
- SCOPES.each_with_index do |scope, i|
142
- lines << row(i, (@scope == i) ? "(•)" : "( )", scope[:label], scope[:hint])
143
- end
144
- lines << ""
145
- lines << " #{dim("Which agents")}"
146
- TARGETS.each_with_index do |target, i|
147
- lines << row(SCOPES.length + i, @targets[target[:key]] ? "[x]" : "[ ]", target[:label], target[:hint])
148
- end
149
- lines << ""
150
- lines << " #{dim("↑↓ move space select enter install q cancel")}"
151
- lines << ""
152
-
153
- output.print(lines.join("\n") + "\n")
154
- @rows_drawn = lines.length
155
- end
156
-
157
- def row(index, marker, label, hint)
158
- active = @cursor == index
159
- pointer = active ? "›" : " "
160
- text = "#{pointer} #{marker} #{label.ljust(16)} #{dim(hint)}"
161
- active ? " \e[1m#{text}\e[0m" : " #{text}"
162
- end
163
-
164
- def dim(text)
165
- "\e[2m#{text}\e[0m"
166
- end
220
+ def dim(text) = "\e[2m#{text}\e[0m"
167
221
  end
168
222
  end
169
223
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: avo
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.2
4
+ version: 4.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Marin