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 +4 -4
- data/Gemfile.lock +1 -1
- data/lib/avo/version.rb +1 -1
- data/lib/generators/avo/skills_generator.rb +222 -152
- data/lib/generators/avo/skills_install_panel.rb +156 -102
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 342b9da8cac48e785b94b6261a29cf466d2320d46a67d94a42b7779afbba3232
|
|
4
|
+
data.tar.gz: 5085b06b9ec693fea5538c8c349766d1afae278be2bf0ffa8329b5e535a70f1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e735bcba96cdfa3a8c3c3eb3a1885076bcd4c4d168bcf9f529404474fac628a4fb2d4a4f225c26f6598600cd9846e046ce2e85c76fb23d5270f85186476bd3df
|
|
7
|
+
data.tar.gz: 84f62915ab3f3b456669538a582b0dec138c8057fdb60cc4fd578e649f03ffa159974079f1deee3c1a4586eff7d67dca23fa2f644d8bf4b121754b399d6f234f
|
data/Gemfile.lock
CHANGED
data/lib/avo/version.rb
CHANGED
|
@@ -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
|
|
9
|
-
# is the
|
|
10
|
-
# with nothing to refresh it.
|
|
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
|
-
#
|
|
13
|
-
#
|
|
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.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
47
|
+
link_or_copy(canonical, dir)
|
|
86
48
|
end
|
|
87
49
|
end
|
|
50
|
+
end
|
|
88
51
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
70
|
+
SkillsInstallPanel::Result.new(defaults.merge(result.answers), false)
|
|
100
71
|
end
|
|
101
72
|
|
|
102
|
-
def
|
|
103
|
-
say
|
|
104
|
-
|
|
73
|
+
def declined
|
|
74
|
+
say("Cancelled. Nothing was installed.", :yellow)
|
|
75
|
+
SkillsInstallPanel::Result.new({}, true)
|
|
105
76
|
end
|
|
106
77
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
#
|
|
122
|
-
#
|
|
123
|
-
def
|
|
124
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
201
|
+
def remove_legacy? = !!answers[:clean_legacy]
|
|
176
202
|
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
217
|
-
|
|
280
|
+
def removal_command(dirs)
|
|
281
|
+
"rm -rf #{by_directory(dirs).keys.map { "#{_1}avo-*" }.join(" ")}"
|
|
282
|
+
end
|
|
218
283
|
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
40
|
-
@
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
85
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|