gem-skill 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +186 -0
- data/Rakefile +8 -0
- data/lib/gem/skill/cache.rb +65 -0
- data/lib/gem/skill/cli/bundle_command.rb +175 -0
- data/lib/gem/skill/cli/gem_command.rb +176 -0
- data/lib/gem/skill/fetcher.rb +213 -0
- data/lib/gem/skill/generator.rb +124 -0
- data/lib/gem/skill/linker.rb +50 -0
- data/lib/gem/skill/lockfile.rb +58 -0
- data/lib/gem/skill/version.rb +5 -0
- data/lib/gem/skill.rb +34 -0
- data/lib/rubygems_plugin.rb +70 -0
- data/plugins.rb +10 -0
- data/scripts/e2e_test +71 -0
- data/sig/gem/skill.rbs +4 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0d438a4ff4e7229ac130284f672c464bbaf770f04ba640177f0effce8822f9ec
|
|
4
|
+
data.tar.gz: 7f86cbc1951664f5ba80602238db6d671450adb3bf7776ec2be0323df342de05
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c120f84db49f729f342a6640375cbd12b9fceb525645406b64488d209973ca77e09d111ed6c92ae9ec9c4107ea060cf32ce4b1726ccb9064fd78b5223b88995d
|
|
7
|
+
data.tar.gz: a6747e0d10b773bfdd3ee7b9c562da01e061cd2fb40341c8c99e41c56be931dff27642d738f4f8e49afff85efa3fec1242ed2f6de036ab9a2880677be598b3de
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewayne VanHoozer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# gem-skill
|
|
2
|
+
|
|
3
|
+
Generates Claude Code skill files from Ruby gem documentation and caches them
|
|
4
|
+
globally so every project that uses a gem can share the same pre-built knowledge.
|
|
5
|
+
|
|
6
|
+
## The problem it solves
|
|
7
|
+
|
|
8
|
+
Every time Claude Code encounters a gem it hasn't seen in the current context, it
|
|
9
|
+
re-reads the README, scans examples, and figures out the API. That costs tokens
|
|
10
|
+
and time — and the result evaporates when the conversation ends.
|
|
11
|
+
|
|
12
|
+
`gem-skill` runs that pipeline once, offline, and stores the output as a
|
|
13
|
+
`SKILL.md` in `~/.gem/skills`. Projects symlink to the cached version, so Claude
|
|
14
|
+
has accurate, version-specific knowledge about each gem without repeating the
|
|
15
|
+
ingestion work.
|
|
16
|
+
|
|
17
|
+
## How the cache is laid out
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
~/.gem/skills/
|
|
21
|
+
└── chunker-ruby/
|
|
22
|
+
├── 1.2.3/
|
|
23
|
+
│ ├── SKILL.md ← generated skill
|
|
24
|
+
│ └── metadata.json ← gem name, version, model used, generated_at
|
|
25
|
+
└── 1.4.0/
|
|
26
|
+
├── SKILL.md
|
|
27
|
+
└── metadata.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Each project's `.claude/skills/` holds symlinks that point into this cache:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
your-app/.claude/skills/
|
|
34
|
+
└── chunker-ruby.md → ~/.gem/skills/chunker-ruby/1.2.3/SKILL.md
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Two projects that pin different versions of the same gem each get the right
|
|
38
|
+
skill; the underlying content is generated once and shared.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
gem install gem-skill
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This gives you the `gem skill` subcommand.
|
|
47
|
+
|
|
48
|
+
For `bundle skill` support (project-aware, reads `Gemfile.lock`), also run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle plugin install gem-skill
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or add it to your `Gemfile`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
plugin "gem-skill"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
`gem-skill` uses [RubyLLM](https://github.com/crmne/ruby_llm) to generate
|
|
63
|
+
skills. Configure at least one provider API key before running:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export OPENAI_API_KEY="..." # default model: gpt-5.5
|
|
67
|
+
export ANTHROPIC_API_KEY="..." # or use Claude
|
|
68
|
+
export GEMINI_API_KEY="..." # or Gemini
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Two environment variables control `gem-skill`'s behaviour:
|
|
74
|
+
|
|
75
|
+
| Variable | Default | Description |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `GEMSKILL_DIR` | `~/.gem/skills` | Root directory for the skill cache |
|
|
78
|
+
| `GEMSKILL_MODEL` | `gpt-5.5` | LLM model used when generating skills |
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Store skills on a shared drive accessible to all projects
|
|
82
|
+
export GEMSKILL_DIR="/Volumes/shared/gem-skills"
|
|
83
|
+
|
|
84
|
+
# Switch the default model to Claude
|
|
85
|
+
export GEMSKILL_MODEL="claude-sonnet-4-6"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `--model` flag on any command overrides `GEMSKILL_MODEL` for that
|
|
89
|
+
invocation. `GEMSKILL_DIR` applies everywhere the cache is read or written.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
### `gem skill` — global cache management
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Generate a skill for an installed gem (version auto-detected)
|
|
97
|
+
gem skill install chunker-ruby
|
|
98
|
+
|
|
99
|
+
# Install skills for multiple gems at once (runs concurrently)
|
|
100
|
+
gem skill install chunker-ruby faraday debug_me
|
|
101
|
+
|
|
102
|
+
# Force regeneration even if already cached
|
|
103
|
+
gem skill install chunker-ruby --force
|
|
104
|
+
|
|
105
|
+
# Use a different model
|
|
106
|
+
gem skill install chunker-ruby --model claude-haiku-4-5
|
|
107
|
+
|
|
108
|
+
# Show everything in the cache
|
|
109
|
+
gem skill list
|
|
110
|
+
|
|
111
|
+
# Remove a specific cached version
|
|
112
|
+
gem skill purge chunker-ruby 1.2.3
|
|
113
|
+
|
|
114
|
+
# Remove all cached versions of a gem
|
|
115
|
+
gem skill purge chunker-ruby --all
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
If a gem isn't installed locally, `gem skill install` will install it first.
|
|
119
|
+
|
|
120
|
+
### `gem install --with-skill`
|
|
121
|
+
|
|
122
|
+
Generate skills for gems as you install them:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
gem install faraday debug_me --with-skill
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Skills are generated concurrently after all gems finish installing.
|
|
129
|
+
|
|
130
|
+
### `bundle skill` — project-aware, driven by Gemfile.lock
|
|
131
|
+
|
|
132
|
+
Run from your project root after `bundle install`:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Generate and link skills for all direct dependencies
|
|
136
|
+
bundle skill install
|
|
137
|
+
|
|
138
|
+
# Re-sync after bundle update (skips gems already at the correct version)
|
|
139
|
+
bundle skill refresh
|
|
140
|
+
|
|
141
|
+
# Show what's linked in this project
|
|
142
|
+
bundle skill list
|
|
143
|
+
|
|
144
|
+
# Options available on install and refresh
|
|
145
|
+
bundle skill install --force
|
|
146
|
+
bundle skill install --model claude-haiku-4-5
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The `install` and `refresh` commands stream LLM output as it is generated, so
|
|
150
|
+
you see progress rather than a silent wait.
|
|
151
|
+
|
|
152
|
+
## What gets generated
|
|
153
|
+
|
|
154
|
+
Each `SKILL.md` covers:
|
|
155
|
+
|
|
156
|
+
- **Overview** — what the gem does and when to use it
|
|
157
|
+
- **Installation** — exact Gemfile lines and post-install steps
|
|
158
|
+
- **Core API** — key classes and methods with real code examples
|
|
159
|
+
- **Common Patterns** — the 3–5 most frequent real-world usage patterns
|
|
160
|
+
- **Gotchas & Edge Cases** — surprising defaults, version-specific behavior,
|
|
161
|
+
thread safety, encoding issues
|
|
162
|
+
- **Configuration** — initializer patterns and environment variables
|
|
163
|
+
- **Testing** — how to test code that uses the gem
|
|
164
|
+
|
|
165
|
+
The content is synthesized from three sources, tried in priority order:
|
|
166
|
+
|
|
167
|
+
1. Local gem install (`Gem::Specification` → `gem_dir`) — README and CHANGELOG
|
|
168
|
+
2. RubyGems API — summary, runtime dependencies, source URI
|
|
169
|
+
3. GitHub raw README — fetched when the gem isn't installed locally
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git clone https://github.com/madbomber/gem-skill
|
|
175
|
+
cd gem-skill
|
|
176
|
+
bundle install
|
|
177
|
+
bundle exec rake test
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Contributing
|
|
181
|
+
|
|
182
|
+
Bug reports and pull requests welcome at https://github.com/madbomber/gem-skill.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Gem::Skill
|
|
8
|
+
# Manages the global ~/.gem/skills cache.
|
|
9
|
+
# Structure: ~/.gem/skills/<gem_name>/<version>/SKILL.md
|
|
10
|
+
module Cache
|
|
11
|
+
ROOT = File.expand_path(ENV.fetch("GEMSKILL_DIR", "~/.gem/skills")).freeze
|
|
12
|
+
|
|
13
|
+
def self.root = ROOT
|
|
14
|
+
|
|
15
|
+
def self.skill_path(gem_name, version)
|
|
16
|
+
File.join(ROOT, gem_name, version, "SKILL.md")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.metadata_path(gem_name, version)
|
|
20
|
+
File.join(ROOT, gem_name, version, "metadata.json")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.cached?(gem_name, version)
|
|
24
|
+
File.exist?(skill_path(gem_name, version))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.store(gem_name, version, skill_content, metadata = {})
|
|
28
|
+
dir = File.join(ROOT, gem_name, version)
|
|
29
|
+
FileUtils.mkdir_p(dir)
|
|
30
|
+
File.write(skill_path(gem_name, version), skill_content)
|
|
31
|
+
File.write(metadata_path(gem_name, version), JSON.generate(metadata.merge(
|
|
32
|
+
gem_name: gem_name,
|
|
33
|
+
version: version,
|
|
34
|
+
generated_at: Time.now.iso8601
|
|
35
|
+
)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.read(gem_name, version)
|
|
39
|
+
path = skill_path(gem_name, version)
|
|
40
|
+
raise Error, "No cached skill for #{gem_name} #{version}" unless File.exist?(path)
|
|
41
|
+
|
|
42
|
+
File.read(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.versions(gem_name)
|
|
46
|
+
dir = File.join(ROOT, gem_name)
|
|
47
|
+
return [] unless Dir.exist?(dir)
|
|
48
|
+
|
|
49
|
+
Dir.children(dir).sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.all_gems
|
|
53
|
+
return [] unless Dir.exist?(ROOT)
|
|
54
|
+
|
|
55
|
+
Dir.children(ROOT).sort
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.purge(gem_name, version)
|
|
59
|
+
dir = File.join(ROOT, gem_name, version)
|
|
60
|
+
FileUtils.rm_rf(dir)
|
|
61
|
+
parent = File.join(ROOT, gem_name)
|
|
62
|
+
Dir.rmdir(parent) if Dir.exist?(parent) && Dir.empty?(parent)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "gem/skill"
|
|
6
|
+
|
|
7
|
+
module Gem::Skill
|
|
8
|
+
# Handles `bundle skill SUBCOMMAND` via Bundler's plugin API (plugins.rb).
|
|
9
|
+
# Project-aware: reads Gemfile.lock and manages .claude/skills/ symlinks.
|
|
10
|
+
module BundlerCommand
|
|
11
|
+
SUBCOMMANDS = %w[install refresh list].freeze
|
|
12
|
+
|
|
13
|
+
def self.run(args)
|
|
14
|
+
Gem::Skill.configure_llm!
|
|
15
|
+
opts, rest = parse_options(args)
|
|
16
|
+
subcmd = rest.shift
|
|
17
|
+
|
|
18
|
+
case subcmd
|
|
19
|
+
when "install" then install(opts)
|
|
20
|
+
when "refresh" then refresh(opts)
|
|
21
|
+
when "list" then list
|
|
22
|
+
when nil, "help", "--help"
|
|
23
|
+
puts usage
|
|
24
|
+
else
|
|
25
|
+
warn "gem-skill: unknown subcommand #{subcmd.inspect}"
|
|
26
|
+
warn usage
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.install(opts = {})
|
|
32
|
+
gems = Lockfile.gems
|
|
33
|
+
if gems.empty?
|
|
34
|
+
puts "No gems found in Gemfile.lock."
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
force = opts[:force]
|
|
39
|
+
model = opts[:model] || Generator::DEFAULT_MODEL
|
|
40
|
+
errors = []
|
|
41
|
+
|
|
42
|
+
puts "Installing skills for #{gems.size} gem(s) (model: #{model})..."
|
|
43
|
+
puts ""
|
|
44
|
+
|
|
45
|
+
gems.each do |gem_name, version|
|
|
46
|
+
if Cache.cached?(gem_name, version) && !force
|
|
47
|
+
print " skip #{gem_name} #{version}"
|
|
48
|
+
else
|
|
49
|
+
print " gen #{gem_name} #{version} "
|
|
50
|
+
$stdout.flush
|
|
51
|
+
Generator.new(gem_name, version, model: model).generate(force: force) do |chunk|
|
|
52
|
+
print chunk
|
|
53
|
+
$stdout.flush
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Linker.link(gem_name, version)
|
|
58
|
+
puts " ✓"
|
|
59
|
+
rescue Gem::Skill::Error => e
|
|
60
|
+
puts " ✗ #{e.message}"
|
|
61
|
+
errors << "#{gem_name} #{version}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Linker.prune_dead_links
|
|
65
|
+
|
|
66
|
+
puts ""
|
|
67
|
+
puts "Done. #{gems.size - errors.size}/#{gems.size} skill(s) linked into .claude/skills/"
|
|
68
|
+
|
|
69
|
+
if errors.any?
|
|
70
|
+
puts ""
|
|
71
|
+
puts "Errors:"
|
|
72
|
+
errors.each { |e| puts " #{e}" }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.refresh(opts = {})
|
|
77
|
+
gems = Lockfile.gems
|
|
78
|
+
linked = Linker.linked_gems.to_h { |e| [e[:gem_name], e[:version]] }
|
|
79
|
+
force = opts[:force]
|
|
80
|
+
model = opts[:model] || Generator::DEFAULT_MODEL
|
|
81
|
+
errors = []
|
|
82
|
+
|
|
83
|
+
puts "Refreshing skills (model: #{model})..."
|
|
84
|
+
puts ""
|
|
85
|
+
|
|
86
|
+
gems.each do |gem_name, version|
|
|
87
|
+
if !force && linked[gem_name] == version
|
|
88
|
+
puts " ok #{gem_name} #{version}"
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
action = linked.key?(gem_name) ? "update" : "new"
|
|
93
|
+
print " #{action.ljust(6)}#{gem_name} #{version} "
|
|
94
|
+
$stdout.flush
|
|
95
|
+
|
|
96
|
+
Generator.new(gem_name, version, model: model).generate(force: force) do |chunk|
|
|
97
|
+
print chunk
|
|
98
|
+
$stdout.flush
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Linker.link(gem_name, version)
|
|
102
|
+
puts " ✓"
|
|
103
|
+
rescue Gem::Skill::Error => e
|
|
104
|
+
puts " ✗ #{e.message}"
|
|
105
|
+
errors << "#{gem_name} #{version}: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Linker.prune_dead_links
|
|
109
|
+
|
|
110
|
+
puts ""
|
|
111
|
+
puts "Refreshed."
|
|
112
|
+
if errors.any?
|
|
113
|
+
puts ""
|
|
114
|
+
puts "Errors:"
|
|
115
|
+
errors.each { |e| puts " #{e}" }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.list
|
|
120
|
+
entries = Linker.linked_gems
|
|
121
|
+
if entries.empty?
|
|
122
|
+
puts "No skills linked in this project."
|
|
123
|
+
puts "Run: bundle skill install"
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ok = entries.count { |e| e[:valid] }
|
|
128
|
+
broken = entries.size - ok
|
|
129
|
+
|
|
130
|
+
puts "Skills linked in .claude/skills/ (#{ok} ok#{broken > 0 ? ", #{broken} broken" : ""}):"
|
|
131
|
+
puts ""
|
|
132
|
+
entries.each do |e|
|
|
133
|
+
status = e[:valid] ? "ok " : "BROKEN"
|
|
134
|
+
puts " [#{status}] %-30s %s" % [e[:gem_name], e[:version]]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- private ---
|
|
139
|
+
|
|
140
|
+
def self.parse_options(args)
|
|
141
|
+
opts = {}
|
|
142
|
+
remaining = []
|
|
143
|
+
|
|
144
|
+
args.each do |arg|
|
|
145
|
+
case arg
|
|
146
|
+
when "--force" then opts[:force] = true
|
|
147
|
+
when /\A--model(?:=(.+))?\z/
|
|
148
|
+
opts[:model] = $1 || args[args.index(arg) + 1]
|
|
149
|
+
else
|
|
150
|
+
remaining << arg unless opts[:model].nil? && arg !~ /\A--/
|
|
151
|
+
remaining << arg if arg !~ /\A--/
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
[opts, remaining]
|
|
156
|
+
end
|
|
157
|
+
private_class_method :parse_options
|
|
158
|
+
|
|
159
|
+
def self.usage
|
|
160
|
+
<<~USAGE
|
|
161
|
+
Usage: bundle skill SUBCOMMAND [OPTIONS]
|
|
162
|
+
|
|
163
|
+
Subcommands:
|
|
164
|
+
install Generate and link skills for all gems in Gemfile.lock
|
|
165
|
+
refresh Re-sync .claude/skills/ after bundle update
|
|
166
|
+
list Show skills linked in this project
|
|
167
|
+
|
|
168
|
+
Options:
|
|
169
|
+
--force Regenerate even if already cached
|
|
170
|
+
--model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
|
|
171
|
+
USAGE
|
|
172
|
+
end
|
|
173
|
+
private_class_method :usage
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/command"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tty-spinner"
|
|
7
|
+
require "gem/skill"
|
|
8
|
+
|
|
9
|
+
# Registered as `gem skill` via lib/rubygems_plugin.rb.
|
|
10
|
+
# Manages the global ~/.gem/skills cache.
|
|
11
|
+
class Gem::Commands::SkillCommand < Gem::Command
|
|
12
|
+
def initialize
|
|
13
|
+
super "skill", "Manage Claude Code AI skills for Ruby gems"
|
|
14
|
+
|
|
15
|
+
add_option("-f", "--force", "Regenerate even if already cached") { |_, o| o[:force] = true }
|
|
16
|
+
add_option("-a", "--all", "Purge all cached versions of a gem") { |_, o| o[:all] = true }
|
|
17
|
+
add_option("-m", "--model MODEL", "LLM model to use (default: #{Gem::Skill::Generator::DEFAULT_MODEL})") do |model, o|
|
|
18
|
+
o[:model] = model
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def arguments
|
|
23
|
+
"SUBCOMMAND one of: install, list, purge"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage
|
|
27
|
+
"#{program_name} install GEM_NAME [GEM_NAME ...]\n" \
|
|
28
|
+
" #{program_name} list\n" \
|
|
29
|
+
" #{program_name} purge GEM_NAME VERSION\n" \
|
|
30
|
+
" #{program_name} purge GEM_NAME --all"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def description
|
|
34
|
+
<<~DESC
|
|
35
|
+
install Generate and cache a SKILL.md for a gem.
|
|
36
|
+
list Show all skills in the global cache (~/.gem/skills).
|
|
37
|
+
purge Remove a specific cached version.
|
|
38
|
+
|
|
39
|
+
Use 'bundle skill install' (after: bundle plugin install gem-skill)
|
|
40
|
+
to generate and link skills for an entire project from Gemfile.lock.
|
|
41
|
+
DESC
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute
|
|
45
|
+
Gem::Skill.configure_llm!
|
|
46
|
+
subcmd = options[:args].shift
|
|
47
|
+
case subcmd
|
|
48
|
+
when "install" then cmd_install
|
|
49
|
+
when "list" then cmd_list
|
|
50
|
+
when "purge" then cmd_purge
|
|
51
|
+
when nil
|
|
52
|
+
say usage
|
|
53
|
+
else
|
|
54
|
+
alert_error "Unknown subcommand: #{subcmd.inspect}"
|
|
55
|
+
say usage
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def cmd_install
|
|
62
|
+
gem_names = options[:args].dup
|
|
63
|
+
options[:args].clear
|
|
64
|
+
|
|
65
|
+
if gem_names.empty?
|
|
66
|
+
alert_error "gem_name required. Usage: gem skill install GEM_NAME [GEM_NAME ...]"
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
force = options[:force]
|
|
71
|
+
model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
|
|
72
|
+
|
|
73
|
+
multi = TTY::Spinner::Multi.new(
|
|
74
|
+
"[:spinner] Generating skills (#{model})",
|
|
75
|
+
format: :dots,
|
|
76
|
+
output: $stderr
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
threads = gem_names.map do |gem_name|
|
|
80
|
+
spinner = multi.register(" [:spinner] :title")
|
|
81
|
+
spinner.update(title: gem_name)
|
|
82
|
+
Thread.new(gem_name, spinner) { |name, sp| install_one(name, spinner: sp, force: force, model: model) }
|
|
83
|
+
end
|
|
84
|
+
threads.each(&:join)
|
|
85
|
+
|
|
86
|
+
say "Tip: run 'bundle plugin install gem-skill' to enable 'bundle skill'."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def install_one(gem_name, spinner:, force:, model:)
|
|
90
|
+
spinner.auto_spin
|
|
91
|
+
|
|
92
|
+
version = resolve_installed_version(gem_name)
|
|
93
|
+
if version.nil?
|
|
94
|
+
spinner.update(title: "#{gem_name} (installing...)")
|
|
95
|
+
version = install_gem(gem_name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if Gem::Skill::Cache.cached?(gem_name, version) && !force
|
|
99
|
+
spinner.update(title: "#{gem_name} #{version}")
|
|
100
|
+
spinner.success("already cached")
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
spinner.update(title: "#{gem_name} #{version}")
|
|
105
|
+
Gem::Skill::Generator.new(gem_name, version, model: model).generate(force: force)
|
|
106
|
+
spinner.success("done")
|
|
107
|
+
rescue => e
|
|
108
|
+
spinner.error("#{gem_name} failed")
|
|
109
|
+
alert_error "#{gem_name}: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def cmd_list
|
|
113
|
+
gems = Gem::Skill::Cache.all_gems
|
|
114
|
+
if gems.empty?
|
|
115
|
+
say "No skills cached yet."
|
|
116
|
+
say "Run: gem skill install GEM_NAME"
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
say "Cached skills in #{Gem::Skill::Cache.root}:"
|
|
121
|
+
say ""
|
|
122
|
+
gems.each do |name|
|
|
123
|
+
versions = Gem::Skill::Cache.versions(name)
|
|
124
|
+
say " %-30s %s" % [name, versions.join(", ")]
|
|
125
|
+
end
|
|
126
|
+
say ""
|
|
127
|
+
say "#{gems.size} gem(s), #{gems.sum { |n| Gem::Skill::Cache.versions(n).size }} version(s) total."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cmd_purge
|
|
131
|
+
gem_name = options[:args].shift
|
|
132
|
+
unless gem_name
|
|
133
|
+
alert_error "Usage: gem skill purge GEM_NAME VERSION\n gem skill purge GEM_NAME --all"
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if options[:all]
|
|
138
|
+
versions = Gem::Skill::Cache.versions(gem_name)
|
|
139
|
+
if versions.empty?
|
|
140
|
+
alert_error "No cached versions for '#{gem_name}'"
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
versions.each { |v| Gem::Skill::Cache.purge(gem_name, v) }
|
|
144
|
+
say "Purged #{versions.size} version(s) of #{gem_name}"
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
version = options[:args].shift
|
|
149
|
+
unless version
|
|
150
|
+
alert_error "Usage: gem skill purge GEM_NAME VERSION\n gem skill purge GEM_NAME --all"
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
unless Gem::Skill::Cache.cached?(gem_name, version)
|
|
155
|
+
alert_error "Not cached: #{gem_name} #{version}"
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Gem::Skill::Cache.purge(gem_name, version)
|
|
160
|
+
say "Purged: #{gem_name} #{version}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def resolve_installed_version(gem_name)
|
|
164
|
+
Gem::Specification.find_by_name(gem_name)&.version&.to_s
|
|
165
|
+
rescue Gem::MissingSpecError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def install_gem(gem_name, version = nil)
|
|
170
|
+
req = version ? Gem::Requirement.new("= #{version}") : Gem::Requirement.default
|
|
171
|
+
specs = Gem.install(gem_name, req)
|
|
172
|
+
specs.find { |s| s.name == gem_name }&.version&.to_s
|
|
173
|
+
rescue Gem::InstallError, Gem::GemNotFoundException, StandardError => e
|
|
174
|
+
raise Gem::Skill::Error, "Could not install '#{gem_name}': #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
end
|