gem-skill 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ # Skill Files
2
+
3
+ A `SKILL.md` is a structured Markdown document that gives an AI coding assistant
4
+ deep, practical knowledge about a Ruby gem. It's a shared format — assistants
5
+ such as Claude Code and OpenAI Codex read it automatically when it is present in
6
+ the skills directory they look in (Claude Code uses `.claude/skills/`; see
7
+ [Using the cache with other assistants](#using-with-other-assistants)).
8
+
9
+ ## Format
10
+
11
+ Every generated skill begins with **YAML frontmatter** — the `name` and
12
+ `description` that make it discoverable as an Agent Skill — followed by a
13
+ top-level heading and seven sections:
14
+
15
+ ```markdown
16
+ ---
17
+ name: faraday
18
+ description: "HTTP client library for Ruby with pluggable adapters and middleware; use when making HTTP requests... (faraday v2.14.3)"
19
+ ---
20
+
21
+ # faraday v2.14.3
22
+
23
+ ## Overview
24
+ What the gem does and when to reach for it.
25
+
26
+ ## Installation
27
+ Exact Gemfile/gemspec lines and any required post-install steps.
28
+
29
+ ## Core API
30
+ Key classes, methods, and options with real method signatures and return values.
31
+
32
+ ## Common Patterns
33
+ The 3–5 most frequent real-world usage patterns with working code examples.
34
+
35
+ ## Gotchas & Edge Cases
36
+ Surprising defaults, version-specific behavior, thread safety, encoding issues.
37
+
38
+ ## Configuration
39
+ Initializer patterns, environment variables, defaults worth knowing.
40
+
41
+ ## Testing
42
+ How to test code that uses this gem: mocks, fakes, fixtures, VCR patterns.
43
+ ```
44
+
45
+ ### Frontmatter
46
+
47
+ The frontmatter is what registers the file as a skill — both Claude Code and
48
+ OpenAI Codex require it, and the `description` is the text loaded into the
49
+ assistant's context to decide *when* the skill applies. gem-skill generates it
50
+ deterministically:
51
+
52
+ - **`name`** — the gem name normalized to hyphen-case (lowercase letters,
53
+ digits, hyphens). For example `ruby_llm` becomes `ruby-llm`, since underscores
54
+ aren't allowed in skill names.
55
+ - **`description`** — a one-line, trigger-oriented summary derived from the
56
+ Overview, with the version appended, sanitized to satisfy both assistants
57
+ (single line, no angle brackets).
58
+
59
+ ## What an assistant does with it
60
+
61
+ When an assistant opens a project whose skills directory contains `SKILL.md`
62
+ files, it reads every one it finds (Claude Code, for instance, reads everything
63
+ in `.claude/skills/`). This means:
64
+
65
+ - The assistant knows the correct API for the exact version you're using
66
+ - No token cost re-deriving usage from READMEs mid-conversation
67
+ - The knowledge persists across conversation turns
68
+ - Multiple gems can be in scope simultaneously
69
+
70
+ ## Sources used to generate
71
+
72
+ The LLM is given up to three sources per gem (in priority order):
73
+
74
+ 1. **Local README + CHANGELOG** — from the gem's install directory
75
+ 2. **RubyGems API** — summary, dependencies, source URI
76
+ 3. **GitHub raw README** — fetched when not installed locally
77
+
78
+ Content is synthesized, not copied verbatim. The model is instructed to write
79
+ as a knowledgeable colleague, not a marketing document.
80
+
81
+ ## Quality and regeneration
82
+
83
+ Skill quality depends on the documentation available for the gem and the model
84
+ used. For gems with poor upstream documentation, results will reflect that.
85
+
86
+ To improve a skill:
87
+
88
+ ```bash
89
+ # Use a more capable model
90
+ gem skill install my_gem --force --model claude-opus-4-8
91
+
92
+ # Or set it as the default
93
+ export GEMSKILL_MODEL="claude-opus-4-8"
94
+ gem skill install my_gem --force
95
+ ```
96
+
97
+ ## Version specificity
98
+
99
+ Skills are cached per version. `faraday 2.12.0` and `faraday 2.14.3` each get
100
+ their own `SKILL.md`. Symlinks in `.claude/skills/` point to the version
101
+ matching your `Gemfile.lock`, so the assistant always has the right version
102
+ context.
103
+
104
+ ## Using with other assistants
105
+
106
+ `SKILL.md` is not specific to one assistant. The `~/.gem/skills` cache is
107
+ assistant-neutral; `bundle skill` links skills into `.claude/skills/`, which
108
+ Claude Code reads automatically. Other assistants discover skills in their own
109
+ roots:
110
+
111
+ | Assistant | Global roots | Project-local roots |
112
+ |---|---|---|
113
+ | Claude Code | `~/.claude/skills/` | `.claude/skills/` |
114
+ | OpenAI Codex | `~/.codex/skills`, `~/.agents/skills` | `.agents/`, `.codex/` |
115
+
116
+ **Project-local (recommended):** point `bundle skill` at the right directory with
117
+ the `GEMSKILL_PROJECT_DIR` environment variable (default `.claude/skills`):
118
+
119
+ ```bash
120
+ export GEMSKILL_PROJECT_DIR=".agents" # or ".codex"
121
+ bundle skill install # symlinks now land in .agents/
122
+ ```
123
+
124
+ See [`GEMSKILL_PROJECT_DIR`](configuration.md#gemskill_project_dir) for the full
125
+ table of suggested values.
126
+
127
+ **Global:** to share cached skills across all projects for an assistant, symlink
128
+ a cached version directory into its global root:
129
+
130
+ ```bash
131
+ ln -s ~/.gem/skills/faraday/2.14.3 ~/.agents/skills/faraday
132
+ ```
133
+
134
+ !!! note "Availability is not the same as activation"
135
+ Assistants differ in how a present `SKILL.md` becomes active. **Claude Code**
136
+ treats every `SKILL.md` under `.claude/skills/` as active automatically.
137
+ **OpenAI Codex** does *not* auto-activate a skill just because the file
138
+ exists — it must appear in the session's available-skills list, or you must
139
+ explicitly point Codex at it. So linking a skill into a Codex root makes it
140
+ *available* but may not make it *active* on its own; check your assistant's
141
+ skill-discovery rules.
@@ -42,6 +42,30 @@ module Gem::Skill
42
42
  File.read(path)
43
43
  end
44
44
 
45
+ # Read metadata.json back as a Hash with string keys. Returns {} if absent
46
+ # or unparseable.
47
+ def self.read_metadata(gem_name, version)
48
+ path = metadata_path(gem_name, version)
49
+ return {} unless File.exist?(path)
50
+
51
+ JSON.parse(File.read(path))
52
+ rescue JSON::ParserError
53
+ {}
54
+ end
55
+
56
+ # Overwrite just the SKILL.md content, leaving metadata untouched.
57
+ def self.write_skill(gem_name, version, skill_content)
58
+ File.write(skill_path(gem_name, version), skill_content)
59
+ end
60
+
61
+ # Merge additional keys into the existing metadata.json, preserving
62
+ # generated_at, model, sources, etc. Keys are normalized to strings so a
63
+ # symbol key never collides with its string twin.
64
+ def self.merge_metadata(gem_name, version, extra)
65
+ data = read_metadata(gem_name, version).merge(extra.transform_keys(&:to_s))
66
+ File.write(metadata_path(gem_name, version), JSON.generate(data))
67
+ end
68
+
45
69
  def self.versions(gem_name)
46
70
  dir = File.join(ROOT, gem_name)
47
71
  return [] unless Dir.exist?(dir)
@@ -8,7 +8,8 @@ require "gem/skill"
8
8
 
9
9
  module Gem::Skill
10
10
  # Handles `bundle skill SUBCOMMAND` via Bundler's plugin API (plugins.rb).
11
- # Project-aware: reads Gemfile.lock and manages .claude/skills/ symlinks.
11
+ # Project-aware: reads Gemfile.lock and manages project skill symlinks
12
+ # (directory set by GEMSKILL_PROJECT_DIR, default .claude/skills/).
12
13
  module BundlerCommand
13
14
  SUBCOMMANDS = %w[install refresh list].freeze
14
15
 
@@ -17,6 +18,11 @@ module Gem::Skill
17
18
  opts, rest = parse_options(args)
18
19
  subcmd = rest.shift
19
20
 
21
+ if opts[:version]
22
+ puts Gem::Skill::VERSION
23
+ return
24
+ end
25
+
20
26
  case subcmd
21
27
  when "install" then install(opts)
22
28
  when "refresh" then refresh(opts)
@@ -37,9 +43,11 @@ module Gem::Skill
37
43
  return
38
44
  end
39
45
 
40
- force = opts[:force]
41
- model = opts[:model] || Generator::DEFAULT_MODEL
42
- errors = []
46
+ force = opts[:force]
47
+ verify = opts[:verify]
48
+ model = opts[:model] || Generator::DEFAULT_MODEL
49
+ errors = []
50
+ results = []
43
51
 
44
52
  multi = TTY::Spinner::Multi.new(
45
53
  "[:spinner] Installing skills (#{model})",
@@ -53,8 +61,9 @@ module Gem::Skill
53
61
  sp = multi.register(" [:spinner] :title")
54
62
  sp.update(title: "#{gem_name} #{version}")
55
63
  barrier.async do
56
- err = install_one(gem_name, version, sp, force: force, model: model)
57
- errors << "#{gem_name} #{version}: #{err}" if err
64
+ result = install_one(gem_name, version, sp, force: force, model: model, verify: verify)
65
+ results << result
66
+ errors << "#{gem_name} #{version}: #{result.error}" if result.error
58
67
  end
59
68
  end
60
69
  barrier.wait
@@ -64,14 +73,17 @@ module Gem::Skill
64
73
 
65
74
  Linker.prune_dead_links
66
75
  report_errors(errors)
76
+ report_verify(results, verify)
67
77
  end
68
78
 
69
79
  def self.refresh(opts = {})
70
80
  gems = Lockfile.gems
71
- linked = Linker.linked_gems.to_h { |e| [e[:gem_name], e[:version]] }
72
- force = opts[:force]
73
- model = opts[:model] || Generator::DEFAULT_MODEL
74
- errors = []
81
+ linked = Linker.linked_gems.to_h { |e| [e[:gem_name], e[:version]] }
82
+ force = opts[:force]
83
+ verify = opts[:verify]
84
+ model = opts[:model] || Generator::DEFAULT_MODEL
85
+ errors = []
86
+ results = []
75
87
 
76
88
  multi = TTY::Spinner::Multi.new(
77
89
  "[:spinner] Refreshing skills (#{model})",
@@ -85,14 +97,15 @@ module Gem::Skill
85
97
  sp = multi.register(" [:spinner] :title")
86
98
  sp.update(title: "#{gem_name} #{version}")
87
99
  barrier.async do
88
- err = if !force && linked[gem_name] == version
100
+ result = if !force && linked[gem_name] == version
89
101
  sp.auto_spin
90
102
  sp.success("up to date")
91
- nil
103
+ Runner::Result.success
92
104
  else
93
- install_one(gem_name, version, sp, force: force, model: model)
105
+ install_one(gem_name, version, sp, force: force, model: model, verify: verify)
94
106
  end
95
- errors << "#{gem_name} #{version}: #{err}" if err
107
+ results << result
108
+ errors << "#{gem_name} #{version}: #{result.error}" if result.error
96
109
  end
97
110
  end
98
111
  barrier.wait
@@ -102,6 +115,7 @@ module Gem::Skill
102
115
 
103
116
  Linker.prune_dead_links
104
117
  report_errors(errors)
118
+ report_verify(results, verify)
105
119
  end
106
120
 
107
121
  def self.list
@@ -115,7 +129,7 @@ module Gem::Skill
115
129
  ok = entries.count { |e| e[:valid] }
116
130
  broken = entries.size - ok
117
131
 
118
- puts "Skills linked in .claude/skills/ (#{ok} ok#{broken > 0 ? ", #{broken} broken" : ""}):"
132
+ puts "Skills linked in #{Linker.project_dir}/ (#{ok} ok#{broken > 0 ? ", #{broken} broken" : ""}):"
119
133
  puts ""
120
134
  entries.each do |e|
121
135
  status = e[:valid] ? "ok " : "BROKEN"
@@ -125,9 +139,9 @@ module Gem::Skill
125
139
 
126
140
  # --- private ---
127
141
 
128
- def self.install_one(gem_name, version, spinner, force:, model:)
142
+ def self.install_one(gem_name, version, spinner, force:, model:, verify: false)
129
143
  spinner.auto_spin
130
- Runner.install_skill(gem_name, version, spinner, force: force, model: model)
144
+ Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify)
131
145
  end
132
146
  private_class_method :install_one
133
147
 
@@ -139,6 +153,20 @@ module Gem::Skill
139
153
  end
140
154
  private_class_method :report_errors
141
155
 
156
+ # When --verify applied fixes, report and exit non-zero so callers/CI can
157
+ # detect that the README-derived skill disagreed with the source.
158
+ def self.report_verify(results, verify)
159
+ return unless verify
160
+
161
+ fixed = results.count(&:verify_fixed)
162
+ return if fixed.zero?
163
+
164
+ warn ""
165
+ warn "Verify corrected #{fixed} skill(s) against gem source."
166
+ exit Gem::Skill::EXIT_VERIFY_FIXED
167
+ end
168
+ private_class_method :report_verify
169
+
142
170
  def self.parse_options(args)
143
171
  opts = {}
144
172
  remaining = []
@@ -146,6 +174,8 @@ module Gem::Skill
146
174
  args.each do |arg|
147
175
  case arg
148
176
  when "--force" then opts[:force] = true
177
+ when "--verify" then opts[:verify] = true
178
+ when "--version", "-v" then opts[:version] = true
149
179
  when /\A--model(?:=(.+))?\z/
150
180
  opts[:model] = $1 || args[args.index(arg) + 1]
151
181
  else
@@ -164,12 +194,17 @@ module Gem::Skill
164
194
 
165
195
  Subcommands:
166
196
  install Generate and link skills for all gems in Gemfile.lock
167
- refresh Re-sync .claude/skills/ after bundle update
197
+ refresh Re-sync the project skill directory after bundle update
168
198
  list Show skills linked in this project
169
199
 
170
200
  Options:
171
201
  --force Regenerate even if already cached
202
+ --verify Verify generated skills against gem source and fix mismatches
172
203
  --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
204
+ --version, -v Print gem-skill version and exit
205
+
206
+ Env:
207
+ GEMSKILL_PROJECT_DIR Project dir for symlinks (default: .claude/skills)
173
208
  USAGE
174
209
  end
175
210
  private_class_method :usage
@@ -14,18 +14,21 @@ class Gem::Commands::SkillCommand < Gem::Command
14
14
  super "skill", "Manage Claude Code AI skills for Ruby gems"
15
15
 
16
16
  add_option("-f", "--force", "Regenerate even if already cached") { |_, o| o[:force] = true }
17
+ 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
18
  add_option("-a", "--all", "Purge all cached versions of a gem") { |_, o| o[:all] = true }
18
19
  add_option("-m", "--model MODEL", "LLM model to use (default: #{Gem::Skill::Generator::DEFAULT_MODEL})") do |model, o|
19
20
  o[:model] = model
20
21
  end
22
+ add_option("-v", "--version", "Print gem-skill version and exit") { |_, o| o[:version] = true }
21
23
  end
22
24
 
23
25
  def arguments
24
- "SUBCOMMAND one of: install, list, purge, setup"
26
+ "SUBCOMMAND one of: install, verify, list, purge, setup"
25
27
  end
26
28
 
27
29
  def usage
28
30
  "#{program_name} install GEM_NAME [GEM_NAME ...]\n" \
31
+ " #{program_name} verify GEM_NAME [GEM_NAME ...]\n" \
29
32
  " #{program_name} list\n" \
30
33
  " #{program_name} purge GEM_NAME VERSION\n" \
31
34
  " #{program_name} purge GEM_NAME --all\n" \
@@ -35,6 +38,8 @@ class Gem::Commands::SkillCommand < Gem::Command
35
38
  def description
36
39
  <<~DESC
37
40
  install Generate and cache a SKILL.md for a gem.
41
+ verify Verify an already-cached skill against the gem's source and fix
42
+ mismatches (does not generate; errors if not cached).
38
43
  list Show all skills in the global cache (~/.gem/skills).
39
44
  purge Remove a specific cached version.
40
45
  setup Register gem-skill as a Bundler plugin (run once after install).
@@ -44,10 +49,15 @@ class Gem::Commands::SkillCommand < Gem::Command
44
49
  end
45
50
 
46
51
  def execute
52
+ if options[:version]
53
+ say Gem::Skill::VERSION
54
+ return
55
+ end
47
56
  Gem::Skill.configure_llm!
48
57
  subcmd = options[:args].shift
49
58
  case subcmd
50
59
  when "install" then cmd_install
60
+ when "verify" then cmd_verify
51
61
  when "list" then cmd_list
52
62
  when "purge" then cmd_purge
53
63
  when "setup" then cmd_setup
@@ -70,8 +80,9 @@ class Gem::Commands::SkillCommand < Gem::Command
70
80
  return
71
81
  end
72
82
 
73
- force = options[:force]
74
- model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
83
+ force = options[:force]
84
+ verify = options[:verify]
85
+ model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
75
86
 
76
87
  multi = TTY::Spinner::Multi.new(
77
88
  "[:spinner] Generating skills (#{model})",
@@ -79,12 +90,13 @@ class Gem::Commands::SkillCommand < Gem::Command
79
90
  output: $stderr
80
91
  )
81
92
 
93
+ results = []
82
94
  Async do
83
95
  barrier = Async::Barrier.new
84
96
  gem_names.each do |gem_name|
85
97
  spinner = multi.register(" [:spinner] :title")
86
98
  spinner.update(title: gem_name)
87
- barrier.async { install_one(gem_name, spinner: spinner, force: force, model: model) }
99
+ barrier.async { results << install_one(gem_name, spinner: spinner, force: force, model: model, verify: verify) }
88
100
  end
89
101
  barrier.wait
90
102
  ensure
@@ -92,9 +104,15 @@ class Gem::Commands::SkillCommand < Gem::Command
92
104
  end
93
105
 
94
106
  say "Tip: run 'bundle plugin install gem-skill' to enable 'bundle skill'."
107
+
108
+ fixed = results.count(&:verify_fixed)
109
+ if verify && fixed.positive?
110
+ say "Verify corrected #{fixed} skill(s) against gem source."
111
+ terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
112
+ end
95
113
  end
96
114
 
97
- def install_one(gem_name, spinner:, force:, model:)
115
+ def install_one(gem_name, spinner:, force:, model:, verify: false)
98
116
  spinner.auto_spin
99
117
  version = resolve_installed_version(gem_name)
100
118
  if version.nil?
@@ -102,11 +120,77 @@ class Gem::Commands::SkillCommand < Gem::Command
102
120
  version = install_gem(gem_name)
103
121
  end
104
122
  spinner.update(title: "#{gem_name} #{version}")
105
- err = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model)
106
- alert_error "#{gem_name}: #{err}" if err
123
+ result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify)
124
+ alert_error "#{gem_name}: #{result.error}" if result.error
125
+ result
126
+ rescue Gem::Skill::Error => e
127
+ spinner.error("failed")
128
+ alert_error "#{gem_name}: #{e.message}"
129
+ Gem::Skill::Runner::Result.failure(e.message)
130
+ end
131
+
132
+ def cmd_verify
133
+ gem_names = options[:args].dup
134
+ options[:args].clear
135
+
136
+ if gem_names.empty?
137
+ alert_error "gem_name required. Usage: gem skill verify GEM_NAME [GEM_NAME ...]"
138
+ return
139
+ end
140
+
141
+ model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
142
+
143
+ multi = TTY::Spinner::Multi.new(
144
+ "[:spinner] Verifying skills (#{model})",
145
+ format: :dots,
146
+ output: $stderr
147
+ )
148
+
149
+ results = []
150
+ Async do
151
+ barrier = Async::Barrier.new
152
+ gem_names.each do |gem_name|
153
+ spinner = multi.register(" [:spinner] :title")
154
+ spinner.update(title: gem_name)
155
+ barrier.async { results << verify_one(gem_name, spinner: spinner, model: model) }
156
+ end
157
+ barrier.wait
158
+ ensure
159
+ barrier.stop
160
+ end
161
+
162
+ fixed = results.count(&:verify_fixed)
163
+ if fixed.positive?
164
+ say "Verify corrected #{fixed} skill(s) against gem source."
165
+ terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
166
+ end
167
+ end
168
+
169
+ # Verify an already-cached skill in place. Never generates: the gem must be
170
+ # installed (verification needs its source) and the skill must already be cached.
171
+ def verify_one(gem_name, spinner:, model:)
172
+ spinner.auto_spin
173
+ version = resolve_installed_version(gem_name)
174
+ if version.nil?
175
+ spinner.error("not installed")
176
+ alert_error "#{gem_name}: not installed locally; verification needs the gem's source. Run 'gem install #{gem_name}' first."
177
+ return Gem::Skill::Runner::Result.failure("not installed")
178
+ end
179
+
180
+ spinner.update(title: "#{gem_name} #{version}")
181
+ unless Gem::Skill::Cache.cached?(gem_name, version)
182
+ spinner.error("not cached")
183
+ alert_error "#{gem_name} #{version}: no cached skill to verify. Run 'gem skill install #{gem_name}' first."
184
+ return Gem::Skill::Runner::Result.failure("not cached")
185
+ end
186
+
187
+ result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: false, model: model, verify: true)
188
+ alert_error "#{gem_name}: #{result.error}" if result.error
189
+ result
107
190
  rescue Gem::Skill::Error => e
108
191
  spinner.error("failed")
109
192
  alert_error "#{gem_name}: #{e.message}"
193
+ Gem::Skill::Runner::Result.failure(e.message)
110
194
  end
111
195
 
112
196
  def cmd_list
@@ -121,13 +205,54 @@ class Gem::Commands::SkillCommand < Gem::Command
121
205
  say ""
122
206
  gems.each do |name|
123
207
  versions = Gem::Skill::Cache.versions(name)
124
- say " %-30s %s" % [name, versions.join(", ")]
208
+ rendered = versions.map { |v| format_version(name, v) }.join(", ")
209
+ say " %-30s %s" % [name, rendered]
125
210
  end
126
211
  say ""
127
212
  say "#{gems.size} gem(s), #{gems.sum { |n| Gem::Skill::Cache.versions(n).size }} version(s) total."
128
213
  end
129
214
 
215
+ CHECK_MARK = "✓" # ✓
216
+
217
+ # True when the cached skill for this gem/version was verified against source.
218
+ def skill_verified?(gem_name, version)
219
+ Gem::Skill::Cache.read_metadata(gem_name, version).dig("verification", "verified") == true
220
+ end
221
+
222
+ # A version label, with a green checkmark appended when the skill is verified.
223
+ def format_version(gem_name, version)
224
+ return version unless skill_verified?(gem_name, version)
225
+
226
+ "#{version} #{colorize_check}"
227
+ end
228
+
229
+ # The checkmark, ANSI-green only when writing to an interactive terminal so
230
+ # redirected/piped output stays clean.
231
+ def colorize_check
232
+ $stdout.tty? ? "\e[32m#{CHECK_MARK}\e[0m" : CHECK_MARK
233
+ end
234
+
235
+ # Default skill roots that assistants scan automatically. The router skill is
236
+ # copied into each one whose assistant home directory already exists.
237
+ ASSISTANT_SKILL_ROOTS = {
238
+ "Claude Code" => "~/.claude/skills",
239
+ "OpenAI Codex" => "~/.codex/skills",
240
+ "Agents (vendor-neutral)" => "~/.agents/skills"
241
+ }.freeze
242
+
130
243
  def cmd_setup
244
+ register_bundler_plugin
245
+ say ""
246
+ install_router_skill
247
+ end
248
+
249
+ def register_bundler_plugin
250
+ plugin_list = `bundle plugin list 2>/dev/null`
251
+ if plugin_list.include?("gem-skill")
252
+ say "gem-skill is already registered as a Bundler plugin."
253
+ return
254
+ end
255
+
131
256
  say "Registering gem-skill as a Bundler plugin..."
132
257
  if system("bundle", "plugin", "install", "gem-skill")
133
258
  say "Done. Use 'bundle skill install' in any project."
@@ -136,6 +261,36 @@ class Gem::Commands::SkillCommand < Gem::Command
136
261
  end
137
262
  end
138
263
 
264
+ # Copy the bundled '#{Gem::Skill::ROUTER_SKILL_NAME}' skill into the default
265
+ # skill root of each detected assistant, so it can discover cached gem skills.
266
+ def install_router_skill
267
+ source = File.join(Gem::Skill::ROUTER_SKILL_DIR, "SKILL.md")
268
+ unless File.exist?(source)
269
+ alert_error "Router skill not found at #{source}"
270
+ return
271
+ end
272
+
273
+ say "Installing the '#{Gem::Skill::ROUTER_SKILL_NAME}' skill so assistants can find cached gem skills:"
274
+ installed = 0
275
+ ASSISTANT_SKILL_ROOTS.each do |label, root|
276
+ base = File.expand_path(root)
277
+ unless Dir.exist?(File.dirname(base))
278
+ say " - #{label}: not detected — skipped"
279
+ next
280
+ end
281
+
282
+ dest_dir = File.join(base, Gem::Skill::ROUTER_SKILL_NAME)
283
+ FileUtils.mkdir_p(dest_dir)
284
+ FileUtils.cp(source, File.join(dest_dir, "SKILL.md"))
285
+ say " - #{label}: installed -> #{dest_dir.sub(Dir.home, '~')}"
286
+ installed += 1
287
+ end
288
+
289
+ return unless installed.zero?
290
+
291
+ say " No assistant directories detected. Create ~/.claude or ~/.codex, then re-run 'gem skill setup'."
292
+ end
293
+
139
294
  def cmd_purge
140
295
  gem_name = options[:args].shift
141
296
  unless gem_name
@@ -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