gem-skill 0.1.3 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +63 -0
- data/README.md +68 -15
- data/docs/cache.md +82 -5
- data/docs/commands/bundle-skill.md +12 -3
- data/docs/commands/gem-skill.md +91 -6
- data/docs/configuration.md +61 -0
- data/docs/how-it-works.md +58 -9
- data/docs/index.md +15 -11
- data/docs/skill-files.md +73 -10
- data/lib/gem/skill/cache.rb +24 -0
- data/lib/gem/skill/cli/bundle_command.rb +57 -22
- data/lib/gem/skill/cli/gem_command.rb +171 -9
- data/lib/gem/skill/fetcher.rb +52 -0
- data/lib/gem/skill/frontmatter.rb +64 -0
- data/lib/gem/skill/generator.rb +46 -13
- data/lib/gem/skill/linker.rb +17 -3
- data/lib/gem/skill/runner.rb +75 -9
- data/lib/gem/skill/verifier.rb +139 -0
- data/lib/gem/skill/version.rb +1 -1
- data/lib/gem/skill.rb +36 -1
- data/ruby-gem-skills/SKILL.md +53 -0
- metadata +37 -5
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rubygems/command"
|
|
4
|
+
# gem/skill must be required first: activating the gem-skill spec resolves
|
|
5
|
+
# json to < 3 (ruby_llm's constraint). A bare `require "json"` before that
|
|
6
|
+
# activates the newest installed json and makes the activation raise
|
|
7
|
+
# Gem::ConflictError, which RubyGems swallows — leaving `gem skill` unregistered.
|
|
8
|
+
require "gem/skill"
|
|
4
9
|
require "async"
|
|
5
10
|
require "fileutils"
|
|
6
11
|
require "json"
|
|
7
12
|
require "tty-spinner"
|
|
8
|
-
require "gem/skill"
|
|
9
13
|
|
|
10
14
|
# Registered as `gem skill` via lib/rubygems_plugin.rb.
|
|
11
15
|
# Manages the global ~/.gem/skills cache.
|
|
@@ -14,19 +18,27 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
14
18
|
super "skill", "Manage Claude Code AI skills for Ruby gems"
|
|
15
19
|
|
|
16
20
|
add_option("-f", "--force", "Regenerate even if already cached") { |_, o| o[:force] = true }
|
|
21
|
+
add_option("--verify", "Verify generated skill against gem source and fix mismatches (exit #{Gem::Skill::EXIT_VERIFY_FIXED} if fixes applied)") { |_, o| o[:verify] = true }
|
|
17
22
|
add_option("-a", "--all", "Purge all cached versions of a gem") { |_, o| o[:all] = true }
|
|
18
23
|
add_option("-m", "--model MODEL", "LLM model to use (default: #{Gem::Skill::Generator::DEFAULT_MODEL})") do |model, o|
|
|
19
24
|
o[:model] = model
|
|
20
25
|
end
|
|
26
|
+
add_option("--max-tokens TOKENS", "Max output tokens (overrides GEMSKIL_MAX_TOKENS; default: #{Gem::Skill::Generator::MAX_TOKENS})") do |tokens, o|
|
|
27
|
+
o[:max_tokens] = tokens.to_i
|
|
28
|
+
end
|
|
29
|
+
add_option("--temperature TEMP", "Sampling temperature; ignored by reasoning models (overrides GEMSKILL_TEMPERATURE; default: #{Gem::Skill::Generator::DEFAULT_TEMPERATURE})") do |temp, o|
|
|
30
|
+
o[:temperature] = temp.to_f
|
|
31
|
+
end
|
|
21
32
|
add_option("-v", "--version", "Print gem-skill version and exit") { |_, o| o[:version] = true }
|
|
22
33
|
end
|
|
23
34
|
|
|
24
35
|
def arguments
|
|
25
|
-
"SUBCOMMAND one of: install, list, purge, setup"
|
|
36
|
+
"SUBCOMMAND one of: install, verify, list, purge, setup"
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
def usage
|
|
29
40
|
"#{program_name} install GEM_NAME [GEM_NAME ...]\n" \
|
|
41
|
+
" #{program_name} verify GEM_NAME [GEM_NAME ...]\n" \
|
|
30
42
|
" #{program_name} list\n" \
|
|
31
43
|
" #{program_name} purge GEM_NAME VERSION\n" \
|
|
32
44
|
" #{program_name} purge GEM_NAME --all\n" \
|
|
@@ -36,6 +48,8 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
36
48
|
def description
|
|
37
49
|
<<~DESC
|
|
38
50
|
install Generate and cache a SKILL.md for a gem.
|
|
51
|
+
verify Verify an already-cached skill against the gem's source and fix
|
|
52
|
+
mismatches (does not generate; errors if not cached).
|
|
39
53
|
list Show all skills in the global cache (~/.gem/skills).
|
|
40
54
|
purge Remove a specific cached version.
|
|
41
55
|
setup Register gem-skill as a Bundler plugin (run once after install).
|
|
@@ -53,6 +67,7 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
53
67
|
subcmd = options[:args].shift
|
|
54
68
|
case subcmd
|
|
55
69
|
when "install" then cmd_install
|
|
70
|
+
when "verify" then cmd_verify
|
|
56
71
|
when "list" then cmd_list
|
|
57
72
|
when "purge" then cmd_purge
|
|
58
73
|
when "setup" then cmd_setup
|
|
@@ -75,8 +90,11 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
75
90
|
return
|
|
76
91
|
end
|
|
77
92
|
|
|
78
|
-
force
|
|
79
|
-
|
|
93
|
+
force = options[:force]
|
|
94
|
+
verify = options[:verify]
|
|
95
|
+
model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
|
|
96
|
+
max_tokens = options[:max_tokens] || Gem::Skill::Generator::MAX_TOKENS
|
|
97
|
+
temperature = options[:temperature] || Gem::Skill::Generator::DEFAULT_TEMPERATURE
|
|
80
98
|
|
|
81
99
|
multi = TTY::Spinner::Multi.new(
|
|
82
100
|
"[:spinner] Generating skills (#{model})",
|
|
@@ -84,12 +102,13 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
84
102
|
output: $stderr
|
|
85
103
|
)
|
|
86
104
|
|
|
105
|
+
results = []
|
|
87
106
|
Async do
|
|
88
107
|
barrier = Async::Barrier.new
|
|
89
108
|
gem_names.each do |gem_name|
|
|
90
109
|
spinner = multi.register(" [:spinner] :title")
|
|
91
110
|
spinner.update(title: gem_name)
|
|
92
|
-
barrier.async { install_one(gem_name, spinner: spinner, force: force, model: model) }
|
|
111
|
+
barrier.async { results << install_one(gem_name, spinner: spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature) }
|
|
93
112
|
end
|
|
94
113
|
barrier.wait
|
|
95
114
|
ensure
|
|
@@ -97,9 +116,15 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
97
116
|
end
|
|
98
117
|
|
|
99
118
|
say "Tip: run 'bundle plugin install gem-skill' to enable 'bundle skill'."
|
|
119
|
+
|
|
120
|
+
fixed = results.count(&:verify_fixed)
|
|
121
|
+
if verify && fixed.positive?
|
|
122
|
+
say "Verify corrected #{fixed} skill(s) against gem source."
|
|
123
|
+
terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
|
|
124
|
+
end
|
|
100
125
|
end
|
|
101
126
|
|
|
102
|
-
def install_one(gem_name, spinner:, force:, model:)
|
|
127
|
+
def install_one(gem_name, spinner:, force:, model:, verify: false, max_tokens: Gem::Skill::Generator::MAX_TOKENS, temperature: Gem::Skill::Generator::DEFAULT_TEMPERATURE)
|
|
103
128
|
spinner.auto_spin
|
|
104
129
|
version = resolve_installed_version(gem_name)
|
|
105
130
|
if version.nil?
|
|
@@ -107,11 +132,77 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
107
132
|
version = install_gem(gem_name)
|
|
108
133
|
end
|
|
109
134
|
spinner.update(title: "#{gem_name} #{version}")
|
|
110
|
-
|
|
111
|
-
alert_error "#{gem_name}: #{
|
|
135
|
+
result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature)
|
|
136
|
+
alert_error "#{gem_name}: #{result.error}" if result.error
|
|
137
|
+
result
|
|
138
|
+
rescue Gem::Skill::Error => e
|
|
139
|
+
spinner.error("failed")
|
|
140
|
+
alert_error "#{gem_name}: #{e.message}"
|
|
141
|
+
Gem::Skill::Runner::Result.failure(e.message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cmd_verify
|
|
145
|
+
gem_names = options[:args].dup
|
|
146
|
+
options[:args].clear
|
|
147
|
+
|
|
148
|
+
if gem_names.empty?
|
|
149
|
+
alert_error "gem_name required. Usage: gem skill verify GEM_NAME [GEM_NAME ...]"
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
|
|
154
|
+
|
|
155
|
+
multi = TTY::Spinner::Multi.new(
|
|
156
|
+
"[:spinner] Verifying skills (#{model})",
|
|
157
|
+
format: :dots,
|
|
158
|
+
output: $stderr
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
results = []
|
|
162
|
+
Async do
|
|
163
|
+
barrier = Async::Barrier.new
|
|
164
|
+
gem_names.each do |gem_name|
|
|
165
|
+
spinner = multi.register(" [:spinner] :title")
|
|
166
|
+
spinner.update(title: gem_name)
|
|
167
|
+
barrier.async { results << verify_one(gem_name, spinner: spinner, model: model) }
|
|
168
|
+
end
|
|
169
|
+
barrier.wait
|
|
170
|
+
ensure
|
|
171
|
+
barrier.stop
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
fixed = results.count(&:verify_fixed)
|
|
175
|
+
if fixed.positive?
|
|
176
|
+
say "Verify corrected #{fixed} skill(s) against gem source."
|
|
177
|
+
terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Verify an already-cached skill in place. Never generates: the gem must be
|
|
182
|
+
# installed (verification needs its source) and the skill must already be cached.
|
|
183
|
+
def verify_one(gem_name, spinner:, model:)
|
|
184
|
+
spinner.auto_spin
|
|
185
|
+
version = resolve_installed_version(gem_name)
|
|
186
|
+
if version.nil?
|
|
187
|
+
spinner.error("not installed")
|
|
188
|
+
alert_error "#{gem_name}: not installed locally; verification needs the gem's source. Run 'gem install #{gem_name}' first."
|
|
189
|
+
return Gem::Skill::Runner::Result.failure("not installed")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
spinner.update(title: "#{gem_name} #{version}")
|
|
193
|
+
unless Gem::Skill::Cache.cached?(gem_name, version)
|
|
194
|
+
spinner.error("not cached")
|
|
195
|
+
alert_error "#{gem_name} #{version}: no cached skill to verify. Run 'gem skill install #{gem_name}' first."
|
|
196
|
+
return Gem::Skill::Runner::Result.failure("not cached")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: false, model: model, verify: true)
|
|
200
|
+
alert_error "#{gem_name}: #{result.error}" if result.error
|
|
201
|
+
result
|
|
112
202
|
rescue Gem::Skill::Error => e
|
|
113
203
|
spinner.error("failed")
|
|
114
204
|
alert_error "#{gem_name}: #{e.message}"
|
|
205
|
+
Gem::Skill::Runner::Result.failure(e.message)
|
|
115
206
|
end
|
|
116
207
|
|
|
117
208
|
def cmd_list
|
|
@@ -126,13 +217,54 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
126
217
|
say ""
|
|
127
218
|
gems.each do |name|
|
|
128
219
|
versions = Gem::Skill::Cache.versions(name)
|
|
129
|
-
|
|
220
|
+
rendered = versions.map { |v| format_version(name, v) }.join(", ")
|
|
221
|
+
say " %-30s %s" % [name, rendered]
|
|
130
222
|
end
|
|
131
223
|
say ""
|
|
132
224
|
say "#{gems.size} gem(s), #{gems.sum { |n| Gem::Skill::Cache.versions(n).size }} version(s) total."
|
|
133
225
|
end
|
|
134
226
|
|
|
227
|
+
CHECK_MARK = "✓" # ✓
|
|
228
|
+
|
|
229
|
+
# True when the cached skill for this gem/version was verified against source.
|
|
230
|
+
def skill_verified?(gem_name, version)
|
|
231
|
+
Gem::Skill::Cache.read_metadata(gem_name, version).dig("verification", "verified") == true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A version label, with a green checkmark appended when the skill is verified.
|
|
235
|
+
def format_version(gem_name, version)
|
|
236
|
+
return version unless skill_verified?(gem_name, version)
|
|
237
|
+
|
|
238
|
+
"#{version} #{colorize_check}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The checkmark, ANSI-green only when writing to an interactive terminal so
|
|
242
|
+
# redirected/piped output stays clean.
|
|
243
|
+
def colorize_check
|
|
244
|
+
$stdout.tty? ? "\e[32m#{CHECK_MARK}\e[0m" : CHECK_MARK
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Default skill roots that assistants scan automatically. The router skill is
|
|
248
|
+
# copied into each one whose assistant home directory already exists.
|
|
249
|
+
ASSISTANT_SKILL_ROOTS = {
|
|
250
|
+
"Claude Code" => "~/.claude/skills",
|
|
251
|
+
"OpenAI Codex" => "~/.codex/skills",
|
|
252
|
+
"Agents (vendor-neutral)" => "~/.agents/skills"
|
|
253
|
+
}.freeze
|
|
254
|
+
|
|
135
255
|
def cmd_setup
|
|
256
|
+
register_bundler_plugin
|
|
257
|
+
say ""
|
|
258
|
+
install_router_skill
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def register_bundler_plugin
|
|
262
|
+
plugin_list = `bundle plugin list 2>/dev/null`
|
|
263
|
+
if plugin_list.include?("gem-skill")
|
|
264
|
+
say "gem-skill is already registered as a Bundler plugin."
|
|
265
|
+
return
|
|
266
|
+
end
|
|
267
|
+
|
|
136
268
|
say "Registering gem-skill as a Bundler plugin..."
|
|
137
269
|
if system("bundle", "plugin", "install", "gem-skill")
|
|
138
270
|
say "Done. Use 'bundle skill install' in any project."
|
|
@@ -141,6 +273,36 @@ class Gem::Commands::SkillCommand < Gem::Command
|
|
|
141
273
|
end
|
|
142
274
|
end
|
|
143
275
|
|
|
276
|
+
# Copy the bundled '#{Gem::Skill::ROUTER_SKILL_NAME}' skill into the default
|
|
277
|
+
# skill root of each detected assistant, so it can discover cached gem skills.
|
|
278
|
+
def install_router_skill
|
|
279
|
+
source = File.join(Gem::Skill::ROUTER_SKILL_DIR, "SKILL.md")
|
|
280
|
+
unless File.exist?(source)
|
|
281
|
+
alert_error "Router skill not found at #{source}"
|
|
282
|
+
return
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
say "Installing the '#{Gem::Skill::ROUTER_SKILL_NAME}' skill so assistants can find cached gem skills:"
|
|
286
|
+
installed = 0
|
|
287
|
+
ASSISTANT_SKILL_ROOTS.each do |label, root|
|
|
288
|
+
base = File.expand_path(root)
|
|
289
|
+
unless Dir.exist?(File.dirname(base))
|
|
290
|
+
say " - #{label}: not detected — skipped"
|
|
291
|
+
next
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
dest_dir = File.join(base, Gem::Skill::ROUTER_SKILL_NAME)
|
|
295
|
+
FileUtils.mkdir_p(dest_dir)
|
|
296
|
+
FileUtils.cp(source, File.join(dest_dir, "SKILL.md"))
|
|
297
|
+
say " - #{label}: installed -> #{dest_dir.sub(Dir.home, '~')}"
|
|
298
|
+
installed += 1
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
return unless installed.zero?
|
|
302
|
+
|
|
303
|
+
say " No assistant directories detected. Create ~/.claude or ~/.codex, then re-run 'gem skill setup'."
|
|
304
|
+
end
|
|
305
|
+
|
|
144
306
|
def cmd_purge
|
|
145
307
|
gem_name = options[:args].shift
|
|
146
308
|
unless gem_name
|
data/lib/gem/skill/fetcher.rb
CHANGED
|
@@ -19,6 +19,10 @@ module Gem::Skill
|
|
|
19
19
|
README_CANDIDATES = %w[README.md README.rdoc README.txt README].freeze
|
|
20
20
|
CHANGELOG_CANDIDATES = %w[CHANGELOG.md CHANGELOG.rdoc HISTORY.md CHANGES.md].freeze
|
|
21
21
|
|
|
22
|
+
# Cap on concatenated source size handed to the verifier, to protect the
|
|
23
|
+
# context window on large gems. Files are added whole until the cap is hit.
|
|
24
|
+
SOURCE_MAX_CHARS = 150_000
|
|
25
|
+
|
|
22
26
|
attr_reader :gem_name, :version
|
|
23
27
|
|
|
24
28
|
def initialize(gem_name, version)
|
|
@@ -52,8 +56,56 @@ module Gem::Skill
|
|
|
52
56
|
@examples ||= local_examples
|
|
53
57
|
end
|
|
54
58
|
|
|
59
|
+
# The gem's actual Ruby source (lib/**/*.rb), concatenated with per-file
|
|
60
|
+
# headers. This is the ground truth the verifier checks the skill against.
|
|
61
|
+
# Returns nil when the gem isn't installed locally or has no lib sources —
|
|
62
|
+
# verification is only possible against installed source.
|
|
63
|
+
def source_code
|
|
64
|
+
source_bundle&.fetch(:code)
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
private
|
|
56
68
|
|
|
69
|
+
def source_bundle
|
|
70
|
+
return @source_bundle if defined?(@source_bundle)
|
|
71
|
+
|
|
72
|
+
@source_bundle = build_source_bundle
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_source_bundle
|
|
76
|
+
dir = gem_dir
|
|
77
|
+
return nil unless dir
|
|
78
|
+
|
|
79
|
+
lib = File.join(dir, "lib")
|
|
80
|
+
return nil unless File.directory?(lib)
|
|
81
|
+
|
|
82
|
+
files = Dir.glob(File.join(lib, "**", "*.rb")).sort
|
|
83
|
+
return nil if files.empty?
|
|
84
|
+
|
|
85
|
+
out = +""
|
|
86
|
+
included = []
|
|
87
|
+
truncated = false
|
|
88
|
+
|
|
89
|
+
files.each do |path|
|
|
90
|
+
relative = path.delete_prefix("#{dir}/")
|
|
91
|
+
body = File.read(path, encoding: "utf-8")
|
|
92
|
+
chunk = "### #{relative}\n\n```ruby\n#{body}\n```\n\n"
|
|
93
|
+
if !out.empty? && out.length + chunk.length > SOURCE_MAX_CHARS
|
|
94
|
+
truncated = true
|
|
95
|
+
break
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
out << chunk
|
|
99
|
+
included << relative
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return nil if out.empty?
|
|
103
|
+
|
|
104
|
+
{ code: out, files: included, chars: out.length, truncated: truncated }
|
|
105
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
57
109
|
# --- local gem spec ---
|
|
58
110
|
|
|
59
111
|
def gem_spec
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gem::Skill
|
|
4
|
+
# Builds the YAML frontmatter that makes a SKILL.md discoverable as an Agent
|
|
5
|
+
# Skill. Both Claude Code and OpenAI Codex require `name` + `description`
|
|
6
|
+
# frontmatter; without it the file is never registered/triggered as a skill.
|
|
7
|
+
#
|
|
8
|
+
# Constraints satisfied here (intersection of both assistants):
|
|
9
|
+
# - name: lowercase letters, digits, hyphens only; no leading/trailing or
|
|
10
|
+
# doubled hyphens; <= 40 chars (Claude Code's rule, also valid for Codex)
|
|
11
|
+
# - description: single line, no angle brackets (Claude Code rejects < and >),
|
|
12
|
+
# length-capped (Codex shortens long descriptions)
|
|
13
|
+
#
|
|
14
|
+
# Generation is deterministic (no LLM): the name is derived from the gem name
|
|
15
|
+
# and the description from the skill's Overview section, so the frontmatter is
|
|
16
|
+
# always valid regardless of what the model emitted.
|
|
17
|
+
module Frontmatter
|
|
18
|
+
MAX_NAME_LENGTH = 40
|
|
19
|
+
MAX_DESCRIPTION_LENGTH = 500
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Return content with a freshly-built, valid frontmatter block. Any existing
|
|
24
|
+
# leading frontmatter is stripped and replaced, so this is idempotent.
|
|
25
|
+
def build(gem_name, version, content)
|
|
26
|
+
body = strip(content)
|
|
27
|
+
fm = "---\nname: #{slug(gem_name)}\ndescription: #{yaml_quote(description_for(gem_name, version, body))}\n---\n"
|
|
28
|
+
"#{fm}\n#{body}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when content already begins with a YAML frontmatter block.
|
|
32
|
+
def present?(content)
|
|
33
|
+
content.to_s.lstrip.start_with?("---")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove a leading frontmatter block (if any) and return the body.
|
|
37
|
+
def strip(content)
|
|
38
|
+
content.to_s.sub(/\A\s*---\s*\n.*?\n---\s*\n+/m, "").lstrip
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Gem name -> valid skill name. "ruby_llm" -> "ruby-llm", "TTY-Spinner" ->
|
|
42
|
+
# "tty-spinner". Falls back to "skill" if nothing usable remains.
|
|
43
|
+
def slug(gem_name)
|
|
44
|
+
s = gem_name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
45
|
+
s = "skill" if s.empty?
|
|
46
|
+
s[0, MAX_NAME_LENGTH].sub(/-+\z/, "")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Derive a trigger-oriented description from the body's Overview section,
|
|
50
|
+
# appending the version for context. Sanitized for both assistants.
|
|
51
|
+
def description_for(gem_name, version, body)
|
|
52
|
+
overview = body[/^##\s+Overview\s*\n+(.+?)(?=\n\s*\n|\n##\s|\z)/m, 1]
|
|
53
|
+
text = overview || "Ruby gem #{gem_name}. Use when working with #{gem_name} in Ruby code."
|
|
54
|
+
text = text.gsub(/\s+/, " ").delete("<>").strip
|
|
55
|
+
text = "#{text} (#{gem_name} v#{version})" unless text.include?(version.to_s)
|
|
56
|
+
text[0, MAX_DESCRIPTION_LENGTH].strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Quote a string as a YAML double-quoted scalar, escaping \ and ".
|
|
60
|
+
def yaml_quote(str)
|
|
61
|
+
%("#{str.gsub(/[\\"]/) { |c| "\\#{c}" }}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/gem/skill/generator.rb
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ruby_llm"
|
|
4
|
+
# Provider plugin gems self-register with RubyLLM only when required; without
|
|
5
|
+
# these, "lms/..." and "apfel/..." models are unreachable.
|
|
6
|
+
require "ruby_llm/providers/lms"
|
|
7
|
+
require "ruby_llm/providers/apfel"
|
|
4
8
|
|
|
5
9
|
module Gem::Skill
|
|
6
10
|
# Drives the LLM pipeline: fetches docs, generates a SKILL.md, caches it.
|
|
7
11
|
class Generator
|
|
8
|
-
DEFAULT_MODEL
|
|
9
|
-
|
|
12
|
+
DEFAULT_MODEL = ENV.fetch("GEMSKILL_MODEL", "gpt-5.5")
|
|
13
|
+
MAX_TOKENS = ENV.fetch("GEMSKIL_MAX_TOKENS", 32_767).to_i
|
|
14
|
+
DEFAULT_TEMPERATURE = ENV.fetch("GEMSKILL_TEMPERATURE", 0.2).to_f
|
|
15
|
+
MAX_SOURCE_CHARS = 60_000 # guard against enormous READMEs blowing the context window
|
|
10
16
|
|
|
11
17
|
SYSTEM_INSTRUCTIONS = <<~SYSTEM
|
|
12
18
|
You are a Ruby gem documentation specialist who generates Claude Code skill files.
|
|
@@ -56,12 +62,15 @@ module Gem::Skill
|
|
|
56
62
|
%<sources>s
|
|
57
63
|
PROMPT
|
|
58
64
|
|
|
59
|
-
attr_reader :gem_name, :version, :model
|
|
65
|
+
attr_reader :gem_name, :version, :model, :max_tokens, :temperature
|
|
60
66
|
|
|
61
|
-
def initialize(gem_name, version, model: DEFAULT_MODEL)
|
|
62
|
-
@gem_name
|
|
63
|
-
@version
|
|
64
|
-
@model
|
|
67
|
+
def initialize(gem_name, version, model: DEFAULT_MODEL, max_tokens: MAX_TOKENS, temperature: DEFAULT_TEMPERATURE)
|
|
68
|
+
@gem_name = gem_name
|
|
69
|
+
@version = version
|
|
70
|
+
@model = model
|
|
71
|
+
@model_id, @provider = Gem::Skill.parse_model(model)
|
|
72
|
+
@max_tokens = max_tokens
|
|
73
|
+
@temperature = temperature
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
# Generate and cache a SKILL.md. Returns the skill content string.
|
|
@@ -73,6 +82,7 @@ module Gem::Skill
|
|
|
73
82
|
raise Error, "No documentation found for #{gem_name} #{version}" if sources.empty?
|
|
74
83
|
|
|
75
84
|
skill_content = block ? call_llm_streaming(sources, &block) : call_llm(sources)
|
|
85
|
+
skill_content = Frontmatter.build(gem_name, version, skill_content)
|
|
76
86
|
Cache.store(gem_name, version, skill_content, { sources: sources.keys.map(&:to_s), model: model })
|
|
77
87
|
skill_content
|
|
78
88
|
rescue RubyLLM::Error => e
|
|
@@ -100,18 +110,41 @@ module Gem::Skill
|
|
|
100
110
|
strip_wrapper_fence(content)
|
|
101
111
|
end
|
|
102
112
|
|
|
103
|
-
# Removes a
|
|
104
|
-
# Belt-and-suspenders: the prompt instructs the model not to wrap,
|
|
105
|
-
#
|
|
113
|
+
# Removes a ```markdown (or ```) fence that wraps the ENTIRE document.
|
|
114
|
+
# Belt-and-suspenders: the prompt instructs the model not to wrap, but some
|
|
115
|
+
# models do it anyway. The trailing ``` is only stripped when a matching
|
|
116
|
+
# opening wrapper fence was present — otherwise a skill that legitimately
|
|
117
|
+
# ends with a code block would lose its closing fence and leave the block
|
|
118
|
+
# open.
|
|
106
119
|
def strip_wrapper_fence(content)
|
|
107
|
-
content
|
|
108
|
-
|
|
120
|
+
stripped = content.strip
|
|
121
|
+
return stripped unless stripped.match?(/\A```(?:markdown)?\s*\n/)
|
|
122
|
+
|
|
123
|
+
stripped
|
|
124
|
+
.sub(/\A```(?:markdown)?\s*\n/, "")
|
|
109
125
|
.sub(/\n```\s*\z/, "")
|
|
110
126
|
.strip
|
|
111
127
|
end
|
|
112
128
|
|
|
113
129
|
def build_chat
|
|
114
|
-
|
|
130
|
+
# with_max_output_tokens (ruby_llm >= 2.0) maps to the right request
|
|
131
|
+
# parameter per provider (max_tokens vs max_completion_tokens).
|
|
132
|
+
chat = RubyLLM.chat(model: @model_id, provider: @provider).with_max_output_tokens(@max_tokens)
|
|
133
|
+
chat = chat.with_temperature(@temperature) if temperature_supported?
|
|
134
|
+
chat.with_instructions(SYSTEM_INSTRUCTIONS)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def model_info
|
|
138
|
+
@model_info ||= RubyLLM.models.find(@model_id, provider: @provider)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Reasoning models (e.g. gpt-5.5) reject a temperature parameter outright.
|
|
142
|
+
# Only set it for models whose metadata doesn't explicitly mark temperature
|
|
143
|
+
# unsupported; treat absent metadata as supported.
|
|
144
|
+
def temperature_supported?
|
|
145
|
+
return false if @temperature.nil?
|
|
146
|
+
|
|
147
|
+
model_info.metadata.fetch(:temperature, true) != false
|
|
115
148
|
end
|
|
116
149
|
|
|
117
150
|
def format_prompt(sources)
|
data/lib/gem/skill/linker.rb
CHANGED
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module Gem::Skill
|
|
6
|
-
# Manages
|
|
6
|
+
# Manages per-project skill symlinks pointing into the ~/.gem/skills cache.
|
|
7
7
|
# Each symlink is a directory link: <gem_name> -> ~/.gem/skills/<gem>/<version>/
|
|
8
|
-
#
|
|
8
|
+
# The assistant discovers skills by reading SKILL.md inside each linked directory.
|
|
9
|
+
#
|
|
10
|
+
# The project-relative directory is configurable via GEMSKILL_PROJECT_DIR
|
|
11
|
+
# (default ".claude/skills" for Claude Code). Codex users might set it to
|
|
12
|
+
# ".agents" or ".codex"; see the configuration docs.
|
|
9
13
|
module Linker
|
|
14
|
+
DEFAULT_PROJECT_DIR = ".claude/skills"
|
|
15
|
+
|
|
16
|
+
# Project-relative directory where skill symlinks are written. Read from the
|
|
17
|
+
# environment each call so a changed GEMSKILL_PROJECT_DIR takes effect without
|
|
18
|
+
# reloading.
|
|
19
|
+
def self.project_dir
|
|
20
|
+
value = ENV.fetch("GEMSKILL_PROJECT_DIR", DEFAULT_PROJECT_DIR).to_s.strip
|
|
21
|
+
value.empty? ? DEFAULT_PROJECT_DIR : value
|
|
22
|
+
end
|
|
23
|
+
|
|
10
24
|
def self.skills_dir(project_root = Dir.pwd)
|
|
11
|
-
File.join(project_root,
|
|
25
|
+
File.join(project_root, project_dir)
|
|
12
26
|
end
|
|
13
27
|
|
|
14
28
|
def self.link(gem_name, version, project_root = Dir.pwd)
|
data/lib/gem/skill/runner.rb
CHANGED
|
@@ -1,24 +1,90 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Gem::Skill
|
|
4
6
|
# Core install logic shared by gem_command and bundle_command.
|
|
5
7
|
# Callers are responsible for spinner.auto_spin and title setup before calling.
|
|
6
8
|
module Runner
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
9
|
+
# error: nil on success, message string on failure
|
|
10
|
+
# verify_fixed: true when --verify ran and corrected the skill
|
|
11
|
+
Result = Data.define(:error, :verify_fixed) do
|
|
12
|
+
def ok? = error.nil?
|
|
13
|
+
|
|
14
|
+
def self.failure(message) = new(error: message, verify_fixed: false)
|
|
15
|
+
def self.success(verify_fixed: false) = new(error: nil, verify_fixed: verify_fixed)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Generate + cache + link one skill, optionally verifying it against source.
|
|
19
|
+
# Returns a Runner::Result.
|
|
20
|
+
def self.install_skill(gem_name, version, spinner, force:, model:, verify: false,
|
|
21
|
+
max_tokens: Generator::MAX_TOKENS, temperature: Generator::DEFAULT_TEMPERATURE)
|
|
10
22
|
if Cache.cached?(gem_name, version) && !force
|
|
11
23
|
Linker.link(gem_name, version)
|
|
12
|
-
|
|
13
|
-
return
|
|
24
|
+
content = Cache.read(gem_name, version)
|
|
25
|
+
return finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "already cached")
|
|
14
26
|
end
|
|
15
|
-
|
|
27
|
+
|
|
28
|
+
# Stream and discard chunks: a non-streaming request sends nothing over
|
|
29
|
+
# the socket until generation completes, so a slow local model (reasoning
|
|
30
|
+
# models especially) trips Net::ReadTimeout regardless of how high the
|
|
31
|
+
# timeout is set. Streaming keeps bytes flowing between chunks.
|
|
32
|
+
generator = Generator.new(gem_name, version, model: model, max_tokens: max_tokens, temperature: temperature)
|
|
33
|
+
content = generator.generate(force: force) { |_chunk| }
|
|
16
34
|
Linker.link(gem_name, version)
|
|
17
|
-
spinner
|
|
18
|
-
nil
|
|
35
|
+
finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "done")
|
|
19
36
|
rescue => e
|
|
20
37
|
spinner.error("failed")
|
|
21
|
-
e.message
|
|
38
|
+
Result.failure(e.message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run the optional verify pass and settle the spinner + metadata.
|
|
42
|
+
def self.finalize(gem_name, version, content, spinner, model:, verify:, status:)
|
|
43
|
+
unless verify
|
|
44
|
+
spinner.success(status)
|
|
45
|
+
return Result.success
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
result = Verifier.new(gem_name, version, model: model).verify(content)
|
|
49
|
+
|
|
50
|
+
unless result.verifiable
|
|
51
|
+
Cache.merge_metadata(gem_name, version, verification: {
|
|
52
|
+
verified: false,
|
|
53
|
+
verified_at: Time.now.iso8601,
|
|
54
|
+
model: model,
|
|
55
|
+
skipped_reason: "no installed source available"
|
|
56
|
+
})
|
|
57
|
+
spinner.success("#{status} (no source to verify)")
|
|
58
|
+
return Result.success
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Cache.write_skill(gem_name, version, result.content) if result.changed?
|
|
62
|
+
Cache.merge_metadata(gem_name, version, verification: verification_metadata(result))
|
|
63
|
+
|
|
64
|
+
if result.changed?
|
|
65
|
+
spinner.success("verified — fixed")
|
|
66
|
+
Result.success(verify_fixed: true)
|
|
67
|
+
else
|
|
68
|
+
spinner.success("verified — ok")
|
|
69
|
+
Result.success
|
|
70
|
+
end
|
|
71
|
+
rescue => e
|
|
72
|
+
spinner.error("verify failed")
|
|
73
|
+
Result.failure(e.message)
|
|
74
|
+
end
|
|
75
|
+
private_class_method :finalize
|
|
76
|
+
|
|
77
|
+
# Records that the skill was verified against real source and whether that
|
|
78
|
+
# verification changed anything. Intentionally minimal — the itemized list of
|
|
79
|
+
# what changed is not retained.
|
|
80
|
+
def self.verification_metadata(result)
|
|
81
|
+
{
|
|
82
|
+
verified: true,
|
|
83
|
+
verified_at: Time.now.iso8601,
|
|
84
|
+
model: result.model,
|
|
85
|
+
fixed: result.changed?
|
|
86
|
+
}
|
|
22
87
|
end
|
|
88
|
+
private_class_method :verification_metadata
|
|
23
89
|
end
|
|
24
90
|
end
|