gem-skill 0.1.3 → 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.
data/docs/index.md CHANGED
@@ -3,9 +3,10 @@
3
3
  <tr>
4
4
  <td width="40%"><img src="assets/images/gem-skill.jpg" alt="gem-skill logo" style="width:100%;display:block;"></td>
5
5
  <td width="60%">
6
- Generate Claude Code skill files from Ruby gem
7
- documentation and caches them globally so every project that uses a gem
8
- can share the same pre-built knowledge.<br><br>
6
+ Generate <code>SKILL.md</code> files for AI coding assistants
7
+ (Claude Code, OpenAI Codex, and others) from Ruby gem documentation,
8
+ and cache them globally so every project that uses a gem can share the
9
+ same pre-built knowledge.<br><br>
9
10
  <strong><a href="https://madbomber.github.io/gem-skill">Full documentation →</a></strong>
10
11
  </td>
11
12
  </tr>
@@ -14,14 +15,15 @@
14
15
 
15
16
  ## The problem it solves
16
17
 
17
- Every time Claude Code encounters a gem it hasn't seen in the current context,
18
- it re-reads the README, scans examples, and figures out the API. That costs
19
- tokens and time — and the result evaporates when the conversation ends.
18
+ Every time an AI coding assistant encounters a gem it hasn't seen in the current
19
+ context, it re-reads the README, scans examples, and figures out the API. That
20
+ costs tokens and time — and the result evaporates when the conversation ends.
20
21
 
21
22
  `gem-skill` runs that pipeline once, offline, and stores the output as a
22
- `SKILL.md` in `~/.gem/skills`. Projects symlink to the cached version, so
23
- Claude has accurate, version-specific knowledge about each gem without
24
- repeating the ingestion work.
23
+ `SKILL.md` in `~/.gem/skills`. Projects symlink to the cached version, so your
24
+ assistant has accurate, version-specific knowledge about each gem without
25
+ repeating the ingestion work. `SKILL.md` is a shared format — Claude Code,
26
+ OpenAI Codex, and other assistants all read it.
25
27
 
26
28
  ## Quick start
27
29
 
@@ -53,7 +55,8 @@ gem README / changelog / RubyGems API
53
55
 
54
56
  Linker creates .claude/skills/<gem> → cache dir
55
57
 
56
- Claude Code reads SKILL.md automatically
58
+ The assistant reads SKILL.md automatically
59
+ (Claude Code from .claude/skills/; other assistants from their own roots)
57
60
  ```
58
61
 
59
62
  All concurrent work is handled by async fibers — multiple gems are processed
@@ -66,4 +69,5 @@ simultaneously with live TTY spinner progress.
66
69
  - **Concurrent** — all LLM calls run concurrently via async fibers
67
70
  - **Two interfaces** — `gem skill` for global cache management, `bundle skill` for project-aware linking
68
71
  - **Auto-install** — `gem install --with-skill` generates skills during normal gem installation
69
- - **Configurable** — `GEMSKILL_DIR` and `GEMSKILL_MODEL` environment variables
72
+ - **Configurable** — `GEMSKILL_DIR`, `GEMSKILL_PROJECT_DIR`, and `GEMSKILL_MODEL` environment variables
73
+ - **Multi-assistant** — `SKILL.md` works with Claude Code, OpenAI Codex, and others; `GEMSKILL_PROJECT_DIR` points project links at the right directory
data/docs/skill-files.md CHANGED
@@ -1,15 +1,23 @@
1
1
  # Skill Files
2
2
 
3
- A `SKILL.md` is a structured Markdown document that gives Claude Code deep,
4
- practical knowledge about a Ruby gem. Claude reads it automatically when it is
5
- present in `.claude/skills/`.
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)).
6
8
 
7
9
  ## Format
8
10
 
9
- Every generated skill starts with a top-level heading identifying the gem and
10
- version, then covers seven sections:
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:
11
14
 
12
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
+
13
21
  # faraday v2.14.3
14
22
 
15
23
  ## Overview
@@ -34,12 +42,27 @@ Initializer patterns, environment variables, defaults worth knowing.
34
42
  How to test code that uses this gem: mocks, fakes, fixtures, VCR patterns.
35
43
  ```
36
44
 
37
- ## What Claude does with it
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).
38
58
 
39
- When Claude Code opens a project containing `.claude/skills/`, it reads every
40
- `SKILL.md` it finds. This means:
59
+ ## What an assistant does with it
41
60
 
42
- - Claude knows the correct API for the exact version you're using
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
43
66
  - No token cost re-deriving usage from READMEs mid-conversation
44
67
  - The knowledge persists across conversation turns
45
68
  - Multiple gems can be in scope simultaneously
@@ -75,4 +98,44 @@ gem skill install my_gem --force
75
98
 
76
99
  Skills are cached per version. `faraday 2.12.0` and `faraday 2.14.3` each get
77
100
  their own `SKILL.md`. Symlinks in `.claude/skills/` point to the version
78
- matching your `Gemfile.lock`, so Claude always has the right version context.
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
 
@@ -42,9 +43,11 @@ module Gem::Skill
42
43
  return
43
44
  end
44
45
 
45
- force = opts[:force]
46
- model = opts[:model] || Generator::DEFAULT_MODEL
47
- errors = []
46
+ force = opts[:force]
47
+ verify = opts[:verify]
48
+ model = opts[:model] || Generator::DEFAULT_MODEL
49
+ errors = []
50
+ results = []
48
51
 
49
52
  multi = TTY::Spinner::Multi.new(
50
53
  "[:spinner] Installing skills (#{model})",
@@ -58,8 +61,9 @@ module Gem::Skill
58
61
  sp = multi.register(" [:spinner] :title")
59
62
  sp.update(title: "#{gem_name} #{version}")
60
63
  barrier.async do
61
- err = install_one(gem_name, version, sp, force: force, model: model)
62
- 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
63
67
  end
64
68
  end
65
69
  barrier.wait
@@ -69,14 +73,17 @@ module Gem::Skill
69
73
 
70
74
  Linker.prune_dead_links
71
75
  report_errors(errors)
76
+ report_verify(results, verify)
72
77
  end
73
78
 
74
79
  def self.refresh(opts = {})
75
80
  gems = Lockfile.gems
76
- linked = Linker.linked_gems.to_h { |e| [e[:gem_name], e[:version]] }
77
- force = opts[:force]
78
- model = opts[:model] || Generator::DEFAULT_MODEL
79
- 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 = []
80
87
 
81
88
  multi = TTY::Spinner::Multi.new(
82
89
  "[:spinner] Refreshing skills (#{model})",
@@ -90,14 +97,15 @@ module Gem::Skill
90
97
  sp = multi.register(" [:spinner] :title")
91
98
  sp.update(title: "#{gem_name} #{version}")
92
99
  barrier.async do
93
- err = if !force && linked[gem_name] == version
100
+ result = if !force && linked[gem_name] == version
94
101
  sp.auto_spin
95
102
  sp.success("up to date")
96
- nil
103
+ Runner::Result.success
97
104
  else
98
- install_one(gem_name, version, sp, force: force, model: model)
105
+ install_one(gem_name, version, sp, force: force, model: model, verify: verify)
99
106
  end
100
- errors << "#{gem_name} #{version}: #{err}" if err
107
+ results << result
108
+ errors << "#{gem_name} #{version}: #{result.error}" if result.error
101
109
  end
102
110
  end
103
111
  barrier.wait
@@ -107,6 +115,7 @@ module Gem::Skill
107
115
 
108
116
  Linker.prune_dead_links
109
117
  report_errors(errors)
118
+ report_verify(results, verify)
110
119
  end
111
120
 
112
121
  def self.list
@@ -120,7 +129,7 @@ module Gem::Skill
120
129
  ok = entries.count { |e| e[:valid] }
121
130
  broken = entries.size - ok
122
131
 
123
- 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" : ""}):"
124
133
  puts ""
125
134
  entries.each do |e|
126
135
  status = e[:valid] ? "ok " : "BROKEN"
@@ -130,9 +139,9 @@ module Gem::Skill
130
139
 
131
140
  # --- private ---
132
141
 
133
- def self.install_one(gem_name, version, spinner, force:, model:)
142
+ def self.install_one(gem_name, version, spinner, force:, model:, verify: false)
134
143
  spinner.auto_spin
135
- 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)
136
145
  end
137
146
  private_class_method :install_one
138
147
 
@@ -144,6 +153,20 @@ module Gem::Skill
144
153
  end
145
154
  private_class_method :report_errors
146
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
+
147
170
  def self.parse_options(args)
148
171
  opts = {}
149
172
  remaining = []
@@ -151,6 +174,7 @@ module Gem::Skill
151
174
  args.each do |arg|
152
175
  case arg
153
176
  when "--force" then opts[:force] = true
177
+ when "--verify" then opts[:verify] = true
154
178
  when "--version", "-v" then opts[:version] = true
155
179
  when /\A--model(?:=(.+))?\z/
156
180
  opts[:model] = $1 || args[args.index(arg) + 1]
@@ -170,13 +194,17 @@ module Gem::Skill
170
194
 
171
195
  Subcommands:
172
196
  install Generate and link skills for all gems in Gemfile.lock
173
- refresh Re-sync .claude/skills/ after bundle update
197
+ refresh Re-sync the project skill directory after bundle update
174
198
  list Show skills linked in this project
175
199
 
176
200
  Options:
177
201
  --force Regenerate even if already cached
202
+ --verify Verify generated skills against gem source and fix mismatches
178
203
  --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
179
204
  --version, -v Print gem-skill version and exit
205
+
206
+ Env:
207
+ GEMSKILL_PROJECT_DIR Project dir for symlinks (default: .claude/skills)
180
208
  USAGE
181
209
  end
182
210
  private_class_method :usage
@@ -14,6 +14,7 @@ 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
@@ -22,11 +23,12 @@ class Gem::Commands::SkillCommand < Gem::Command
22
23
  end
23
24
 
24
25
  def arguments
25
- "SUBCOMMAND one of: install, list, purge, setup"
26
+ "SUBCOMMAND one of: install, verify, list, purge, setup"
26
27
  end
27
28
 
28
29
  def usage
29
30
  "#{program_name} install GEM_NAME [GEM_NAME ...]\n" \
31
+ " #{program_name} verify GEM_NAME [GEM_NAME ...]\n" \
30
32
  " #{program_name} list\n" \
31
33
  " #{program_name} purge GEM_NAME VERSION\n" \
32
34
  " #{program_name} purge GEM_NAME --all\n" \
@@ -36,6 +38,8 @@ class Gem::Commands::SkillCommand < Gem::Command
36
38
  def description
37
39
  <<~DESC
38
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).
39
43
  list Show all skills in the global cache (~/.gem/skills).
40
44
  purge Remove a specific cached version.
41
45
  setup Register gem-skill as a Bundler plugin (run once after install).
@@ -53,6 +57,7 @@ class Gem::Commands::SkillCommand < Gem::Command
53
57
  subcmd = options[:args].shift
54
58
  case subcmd
55
59
  when "install" then cmd_install
60
+ when "verify" then cmd_verify
56
61
  when "list" then cmd_list
57
62
  when "purge" then cmd_purge
58
63
  when "setup" then cmd_setup
@@ -75,8 +80,9 @@ class Gem::Commands::SkillCommand < Gem::Command
75
80
  return
76
81
  end
77
82
 
78
- force = options[:force]
79
- 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
80
86
 
81
87
  multi = TTY::Spinner::Multi.new(
82
88
  "[:spinner] Generating skills (#{model})",
@@ -84,12 +90,13 @@ class Gem::Commands::SkillCommand < Gem::Command
84
90
  output: $stderr
85
91
  )
86
92
 
93
+ results = []
87
94
  Async do
88
95
  barrier = Async::Barrier.new
89
96
  gem_names.each do |gem_name|
90
97
  spinner = multi.register(" [:spinner] :title")
91
98
  spinner.update(title: gem_name)
92
- 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) }
93
100
  end
94
101
  barrier.wait
95
102
  ensure
@@ -97,9 +104,15 @@ class Gem::Commands::SkillCommand < Gem::Command
97
104
  end
98
105
 
99
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
100
113
  end
101
114
 
102
- def install_one(gem_name, spinner:, force:, model:)
115
+ def install_one(gem_name, spinner:, force:, model:, verify: false)
103
116
  spinner.auto_spin
104
117
  version = resolve_installed_version(gem_name)
105
118
  if version.nil?
@@ -107,11 +120,77 @@ class Gem::Commands::SkillCommand < Gem::Command
107
120
  version = install_gem(gem_name)
108
121
  end
109
122
  spinner.update(title: "#{gem_name} #{version}")
110
- err = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model)
111
- 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
112
190
  rescue Gem::Skill::Error => e
113
191
  spinner.error("failed")
114
192
  alert_error "#{gem_name}: #{e.message}"
193
+ Gem::Skill::Runner::Result.failure(e.message)
115
194
  end
116
195
 
117
196
  def cmd_list
@@ -126,13 +205,54 @@ class Gem::Commands::SkillCommand < Gem::Command
126
205
  say ""
127
206
  gems.each do |name|
128
207
  versions = Gem::Skill::Cache.versions(name)
129
- say " %-30s %s" % [name, versions.join(", ")]
208
+ rendered = versions.map { |v| format_version(name, v) }.join(", ")
209
+ say " %-30s %s" % [name, rendered]
130
210
  end
131
211
  say ""
132
212
  say "#{gems.size} gem(s), #{gems.sum { |n| Gem::Skill::Cache.versions(n).size }} version(s) total."
133
213
  end
134
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
+
135
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
+
136
256
  say "Registering gem-skill as a Bundler plugin..."
137
257
  if system("bundle", "plugin", "install", "gem-skill")
138
258
  say "Done. Use 'bundle skill install' in any project."
@@ -141,6 +261,36 @@ class Gem::Commands::SkillCommand < Gem::Command
141
261
  end
142
262
  end
143
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
+
144
294
  def cmd_purge
145
295
  gem_name = options[:args].shift
146
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