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.
data/docs/how-it-works.md CHANGED
@@ -16,6 +16,9 @@ Gemfile.lock / gem name
16
16
  ↓
17
17
  Cache write to ~/.gem/skills/<gem>/<version>/
18
18
  ↓
19
+ Verifier (optional, --verify) check the skill's code against the
20
+ gem's actual source; correct mismatches in place
21
+ ↓
19
22
  Linker symlink .claude/skills/<gem> → cache dir
20
23
  ```
21
24
 
@@ -78,27 +81,71 @@ Manages the global skill cache. Structure:
78
81
  └── <gem_name>/
79
82
  └── <version>/
80
83
  ├── SKILL.md
81
- └── metadata.json (gem, version, model, generated_at, sources)
84
+ └── metadata.json (gem, version, model, generated_at, sources,
85
+ and after --verify: a "verification" block with
86
+ source provenance + structured changes)
82
87
  ```
83
88
 
84
89
  `Cache::ROOT` is set once at load time from `GEMSKILL_DIR` (default: `~/.gem/skills`).
85
90
 
91
+ `read_metadata` / `write_skill` / `merge_metadata` let the verifier rewrite a
92
+ cached skill and annotate its metadata without clobbering the original
93
+ `generated_at`, `model`, or `sources`.
94
+
95
+ ### Verifier
96
+
97
+ `lib/gem/skill/verifier.rb`
98
+
99
+ Optional second pass, enabled by `--verify`. Generation synthesizes prose
100
+ sources (README, changelog, examples) which are frequently stale or wrong about
101
+ exact signatures. The verifier re-checks the generated skill against the gem's
102
+ **actual source code** — the only source of truth — and corrects mismatched
103
+ method signatures, default argument values, visibility, return values, and
104
+ behavioral claims.
105
+
106
+ ```ruby
107
+ Verifier.new(gem_name, version, model:).verify(skill_content)
108
+ # => Result(content:, changes:, changed:, verifiable:, source:, model:)
109
+ ```
110
+
111
+ Ground truth comes from `Fetcher#source_code` (the gem's `lib/**/*.rb`), and
112
+ `Fetcher#source_manifest` records which files were examined. Whether the skill
113
+ actually changed is decided by a **deterministic diff** of the content before and
114
+ after — not by trusting the model's self-report — so the exit code is reliable.
115
+ If no installed source is available, `verifiable` is false and the skill is left
116
+ untouched.
117
+
118
+ Each correction in `changes` is a structured, issue-ready Hash
119
+ (`category`, `symbol`, `skill_section`, `source_location`, `was`, `now`,
120
+ `detail`, `source_evidence`) — detailed enough to file a documentation bug
121
+ against the gem. The Runner writes these, plus source provenance, into the
122
+ `verification` block of `metadata.json`.
123
+
86
124
  ### Linker
87
125
 
88
126
  `lib/gem/skill/linker.rb`
89
127
 
90
- Creates and manages directory symlinks in `.claude/skills/` inside a project:
128
+ Creates and manages directory symlinks in the project's skill directory
129
+ (default `.claude/skills/`, Claude Code's convention):
91
130
 
92
131
  ```
93
- .claude/skills/<gem_name> → ~/.gem/skills/<gem_name>/<version>/
132
+ <project_dir>/<gem_name> → ~/.gem/skills/<gem_name>/<version>/
94
133
  ```
95
134
 
96
- Symlinks point to the **version directory**, not directly to `SKILL.md`.
97
- Claude Code discovers `SKILL.md` by reading inside the linked directory.
135
+ The directory is `Linker.project_dir`, read from `GEMSKILL_PROJECT_DIR` each call
136
+ (default `.claude/skills`). Codex users set it to `.agents` or `.codex` so
137
+ `bundle skill` links into a Codex root instead. Symlinks point to the **version
138
+ directory**, not directly to `SKILL.md`; the assistant discovers `SKILL.md` by
139
+ reading inside the linked directory.
98
140
 
99
141
  `Linker.prune_dead_links` removes any symlink whose target no longer exists
100
142
  in the cache (e.g. after `gem skill purge`).
101
143
 
144
+ The cache itself is assistant-neutral. `SKILL.md` is a shared format; other
145
+ assistants read it from their own roots. Note that linking only makes a skill
146
+ *available* — some assistants (e.g. Codex) require it to be in the available-skills
147
+ list or referenced explicitly before it's *active*.
148
+
102
149
  ### Runner
103
150
 
104
151
  `lib/gem/skill/runner.rb`
@@ -107,12 +154,14 @@ Shared core used by both CLI commands. Drives one gem through the
107
154
  cache-check → generate → link sequence:
108
155
 
109
156
  ```ruby
110
- Runner.install_skill(gem_name, version, spinner, force:, model:)
111
- # Returns nil on success, error message string on failure
157
+ Runner.install_skill(gem_name, version, spinner, force:, model:, verify:)
158
+ # => Runner::Result(error:, verify_fixed:, change_count:)
112
159
  ```
113
160
 
114
- Returns the error message rather than raising, so the caller (the concurrent
115
- fiber) can record it without killing other in-flight fibers.
161
+ Captures errors into the result rather than raising, so the caller (the
162
+ concurrent fiber) can record them without killing other in-flight fibers. When
163
+ `verify:` is set, the result's `verify_fixed` lets the CLI aggregate across all
164
+ gems and exit `2` (`EXIT_VERIFY_FIXED`) if any skill was corrected.
116
165
 
117
166
  ---
118
167
 
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)
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # gem/skill first — see gem_command.rb: activation must resolve json < 3
4
+ # before a bare `require "json"` can activate a newer version.
5
+ require "gem/skill"
3
6
  require "async"
4
7
  require "fileutils"
5
8
  require "json"
6
9
  require "tty-spinner"
7
- require "gem/skill"
8
10
 
9
11
  module Gem::Skill
10
12
  # Handles `bundle skill SUBCOMMAND` via Bundler's plugin API (plugins.rb).
11
- # Project-aware: reads Gemfile.lock and manages .claude/skills/ symlinks.
13
+ # Project-aware: reads Gemfile.lock and manages project skill symlinks
14
+ # (directory set by GEMSKILL_PROJECT_DIR, default .claude/skills/).
12
15
  module BundlerCommand
13
16
  SUBCOMMANDS = %w[install refresh list].freeze
14
17
 
@@ -42,9 +45,12 @@ module Gem::Skill
42
45
  return
43
46
  end
44
47
 
45
- force = opts[:force]
46
- model = opts[:model] || Generator::DEFAULT_MODEL
47
- errors = []
48
+ force = opts[:force]
49
+ verify = opts[:verify]
50
+ model = opts[:model] || Generator::DEFAULT_MODEL
51
+ max_tokens = opts[:max_tokens] || Generator::MAX_TOKENS
52
+ errors = []
53
+ results = []
48
54
 
49
55
  multi = TTY::Spinner::Multi.new(
50
56
  "[:spinner] Installing skills (#{model})",
@@ -58,8 +64,9 @@ module Gem::Skill
58
64
  sp = multi.register(" [:spinner] :title")
59
65
  sp.update(title: "#{gem_name} #{version}")
60
66
  barrier.async do
61
- err = install_one(gem_name, version, sp, force: force, model: model)
62
- errors << "#{gem_name} #{version}: #{err}" if err
67
+ result = install_one(gem_name, version, sp, force: force, model: model, verify: verify, max_tokens: max_tokens)
68
+ results << result
69
+ errors << "#{gem_name} #{version}: #{result.error}" if result.error
63
70
  end
64
71
  end
65
72
  barrier.wait
@@ -69,14 +76,18 @@ module Gem::Skill
69
76
 
70
77
  Linker.prune_dead_links
71
78
  report_errors(errors)
79
+ report_verify(results, verify)
72
80
  end
73
81
 
74
82
  def self.refresh(opts = {})
75
83
  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 = []
84
+ linked = Linker.linked_gems.to_h { |e| [e[:gem_name], e[:version]] }
85
+ force = opts[:force]
86
+ verify = opts[:verify]
87
+ model = opts[:model] || Generator::DEFAULT_MODEL
88
+ max_tokens = opts[:max_tokens] || Generator::MAX_TOKENS
89
+ errors = []
90
+ results = []
80
91
 
81
92
  multi = TTY::Spinner::Multi.new(
82
93
  "[:spinner] Refreshing skills (#{model})",
@@ -90,14 +101,15 @@ module Gem::Skill
90
101
  sp = multi.register(" [:spinner] :title")
91
102
  sp.update(title: "#{gem_name} #{version}")
92
103
  barrier.async do
93
- err = if !force && linked[gem_name] == version
104
+ result = if !force && linked[gem_name] == version
94
105
  sp.auto_spin
95
106
  sp.success("up to date")
96
- nil
107
+ Runner::Result.success
97
108
  else
98
- install_one(gem_name, version, sp, force: force, model: model)
109
+ install_one(gem_name, version, sp, force: force, model: model, verify: verify, max_tokens: max_tokens)
99
110
  end
100
- errors << "#{gem_name} #{version}: #{err}" if err
111
+ results << result
112
+ errors << "#{gem_name} #{version}: #{result.error}" if result.error
101
113
  end
102
114
  end
103
115
  barrier.wait
@@ -107,6 +119,7 @@ module Gem::Skill
107
119
 
108
120
  Linker.prune_dead_links
109
121
  report_errors(errors)
122
+ report_verify(results, verify)
110
123
  end
111
124
 
112
125
  def self.list
@@ -120,7 +133,7 @@ module Gem::Skill
120
133
  ok = entries.count { |e| e[:valid] }
121
134
  broken = entries.size - ok
122
135
 
123
- puts "Skills linked in .claude/skills/ (#{ok} ok#{broken > 0 ? ", #{broken} broken" : ""}):"
136
+ puts "Skills linked in #{Linker.project_dir}/ (#{ok} ok#{broken > 0 ? ", #{broken} broken" : ""}):"
124
137
  puts ""
125
138
  entries.each do |e|
126
139
  status = e[:valid] ? "ok " : "BROKEN"
@@ -130,9 +143,9 @@ module Gem::Skill
130
143
 
131
144
  # --- private ---
132
145
 
133
- def self.install_one(gem_name, version, spinner, force:, model:)
146
+ def self.install_one(gem_name, version, spinner, force:, model:, verify: false, max_tokens: Generator::MAX_TOKENS)
134
147
  spinner.auto_spin
135
- Runner.install_skill(gem_name, version, spinner, force: force, model: model)
148
+ Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify, max_tokens: max_tokens)
136
149
  end
137
150
  private_class_method :install_one
138
151
 
@@ -144,6 +157,20 @@ module Gem::Skill
144
157
  end
145
158
  private_class_method :report_errors
146
159
 
160
+ # When --verify applied fixes, report and exit non-zero so callers/CI can
161
+ # detect that the README-derived skill disagreed with the source.
162
+ def self.report_verify(results, verify)
163
+ return unless verify
164
+
165
+ fixed = results.count(&:verify_fixed)
166
+ return if fixed.zero?
167
+
168
+ warn ""
169
+ warn "Verify corrected #{fixed} skill(s) against gem source."
170
+ exit Gem::Skill::EXIT_VERIFY_FIXED
171
+ end
172
+ private_class_method :report_verify
173
+
147
174
  def self.parse_options(args)
148
175
  opts = {}
149
176
  remaining = []
@@ -151,9 +178,12 @@ module Gem::Skill
151
178
  args.each do |arg|
152
179
  case arg
153
180
  when "--force" then opts[:force] = true
181
+ when "--verify" then opts[:verify] = true
154
182
  when "--version", "-v" then opts[:version] = true
155
183
  when /\A--model(?:=(.+))?\z/
156
184
  opts[:model] = $1 || args[args.index(arg) + 1]
185
+ when /\A--max-tokens(?:=(.+))?\z/
186
+ opts[:max_tokens] = ($1 || args[args.index(arg) + 1]).to_i
157
187
  else
158
188
  remaining << arg unless opts[:model].nil? && arg !~ /\A--/
159
189
  remaining << arg if arg !~ /\A--/
@@ -170,13 +200,18 @@ module Gem::Skill
170
200
 
171
201
  Subcommands:
172
202
  install Generate and link skills for all gems in Gemfile.lock
173
- refresh Re-sync .claude/skills/ after bundle update
203
+ refresh Re-sync the project skill directory after bundle update
174
204
  list Show skills linked in this project
175
205
 
176
206
  Options:
177
- --force Regenerate even if already cached
178
- --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
179
- --version, -v Print gem-skill version and exit
207
+ --force Regenerate even if already cached
208
+ --verify Verify generated skills against gem source and fix mismatches
209
+ --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
210
+ --max-tokens TOKENS Max output tokens (overrides GEMSKIL_MAX_TOKENS; default: #{Generator::MAX_TOKENS})
211
+ --version, -v Print gem-skill version and exit
212
+
213
+ Env:
214
+ GEMSKILL_PROJECT_DIR Project dir for symlinks (default: .claude/skills)
180
215
  USAGE
181
216
  end
182
217
  private_class_method :usage