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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +55 -14
- data/docs/cache.md +82 -5
- data/docs/commands/bundle-skill.md +10 -3
- data/docs/commands/gem-skill.md +83 -6
- data/docs/configuration.md +37 -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 +46 -18
- data/lib/gem/skill/cli/gem_command.rb +158 -8
- data/lib/gem/skill/fetcher.rb +52 -0
- data/lib/gem/skill/frontmatter.rb +64 -0
- data/lib/gem/skill/generator.rb +1 -0
- data/lib/gem/skill/linker.rb +17 -3
- data/lib/gem/skill/runner.rb +69 -9
- data/lib/gem/skill/verifier.rb +136 -0
- data/lib/gem/skill/version.rb +1 -1
- data/lib/gem/skill.rb +12 -0
- data/ruby-gem-skills/SKILL.md +53 -0
- metadata +4 -1
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
|
|
7
|
-
|
|
8
|
-
can share the
|
|
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
|
|
18
|
-
it re-reads the README, scans examples, and figures out the API. That
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
practical knowledge about a Ruby gem.
|
|
5
|
-
present in
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
`SKILL.md` it finds. This means:
|
|
59
|
+
## What an assistant does with it
|
|
41
60
|
|
|
42
|
-
|
|
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
|
|
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.
|
data/lib/gem/skill/cache.rb
CHANGED
|
@@ -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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
77
|
-
force
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
100
|
+
result = if !force && linked[gem_name] == version
|
|
94
101
|
sp.auto_spin
|
|
95
102
|
sp.success("up to date")
|
|
96
|
-
|
|
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
|
-
|
|
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 .
|
|
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
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
111
|
-
alert_error "#{gem_name}: #{
|
|
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
|
-
|
|
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
|
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
|