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.
- checksums.yaml +4 -4
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +55 -0
- data/README.md +71 -13
- data/docs/assets/images/gem-skill.jpg +0 -0
- data/docs/cache.md +185 -0
- data/docs/commands/bundle-skill.md +154 -0
- data/docs/commands/gem-skill.md +212 -0
- data/docs/configuration.md +112 -0
- data/docs/how-it-works.md +209 -0
- data/docs/index.md +73 -0
- data/docs/installation.md +62 -0
- data/docs/skill-files.md +141 -0
- data/lib/gem/skill/cache.rb +24 -0
- data/lib/gem/skill/cli/bundle_command.rb +53 -18
- data/lib/gem/skill/cli/gem_command.rb +163 -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/mkdocs.yml +115 -0
- data/ruby-gem-skills/SKILL.md +53 -0
- metadata +16 -2
data/docs/skill-files.md
ADDED
|
@@ -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.
|
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
|
|
|
@@ -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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
72
|
-
force
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
100
|
+
result = if !force && linked[gem_name] == version
|
|
89
101
|
sp.auto_spin
|
|
90
102
|
sp.success("up to date")
|
|
91
|
-
|
|
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
|
-
|
|
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 .
|
|
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
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
106
|
-
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
|
|
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
|
-
|
|
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
|
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
|