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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Gem::Skill
|
|
8
|
+
# Fetches documentation for a gem from multiple sources, in priority order:
|
|
9
|
+
# 1. Local gem installation (Gem::Specification) — metadata + README + CHANGELOG + examples
|
|
10
|
+
# 2. RubyGems API — metadata only, when gem isn't installed locally
|
|
11
|
+
# 3. GitHub raw README — when gem isn't installed locally
|
|
12
|
+
class Fetcher
|
|
13
|
+
RUBYGEMS_API = "https://rubygems.org/api/v1/gems/%s.json"
|
|
14
|
+
GITHUB_RAW = "https://raw.githubusercontent.com/%s/%s/%s"
|
|
15
|
+
MAX_REDIRECTS = 5
|
|
16
|
+
OPEN_TIMEOUT = 5
|
|
17
|
+
READ_TIMEOUT = 10
|
|
18
|
+
|
|
19
|
+
README_CANDIDATES = %w[README.md README.rdoc README.txt README].freeze
|
|
20
|
+
CHANGELOG_CANDIDATES = %w[CHANGELOG.md CHANGELOG.rdoc HISTORY.md CHANGES.md].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :gem_name, :version
|
|
23
|
+
|
|
24
|
+
def initialize(gem_name, version)
|
|
25
|
+
@gem_name = gem_name
|
|
26
|
+
@version = version
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a hash of source_name => content strings (only populated keys).
|
|
30
|
+
def fetch_all
|
|
31
|
+
{
|
|
32
|
+
metadata: metadata,
|
|
33
|
+
readme: readme,
|
|
34
|
+
changelog: changelog,
|
|
35
|
+
examples: examples
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def metadata
|
|
40
|
+
@metadata ||= local_metadata || rubygems_metadata
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def readme
|
|
44
|
+
@readme ||= local_file(*README_CANDIDATES) || github_readme
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def changelog
|
|
48
|
+
@changelog ||= local_file(*CHANGELOG_CANDIDATES)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def examples
|
|
52
|
+
@examples ||= local_examples
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# --- local gem spec ---
|
|
58
|
+
|
|
59
|
+
def gem_spec
|
|
60
|
+
@gem_spec ||= Gem::Specification.find_by_name(gem_name, version)
|
|
61
|
+
rescue Gem::MissingSpecError, Gem::MissingSpecVersionError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def gem_dir
|
|
66
|
+
@gem_dir ||= gem_spec&.gem_dir || locate_gem_dir
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def locate_gem_dir
|
|
70
|
+
dir_name = "#{gem_name}-#{version}"
|
|
71
|
+
Gem.path.each do |base|
|
|
72
|
+
path = File.join(base, "gems", dir_name)
|
|
73
|
+
return path if File.directory?(path)
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def local_file(*candidates)
|
|
79
|
+
dir = gem_dir
|
|
80
|
+
return nil unless dir
|
|
81
|
+
|
|
82
|
+
candidates.each do |name|
|
|
83
|
+
path = File.join(dir, name)
|
|
84
|
+
return File.read(path, encoding: "utf-8") if File.exist?(path)
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def local_metadata
|
|
90
|
+
return nil unless gem_spec
|
|
91
|
+
|
|
92
|
+
spec = gem_spec
|
|
93
|
+
lines = []
|
|
94
|
+
lines << "**Gem:** #{spec.name} #{spec.version}"
|
|
95
|
+
lines << "**Summary:** #{spec.summary}" if spec.summary.to_s.strip.length > 0
|
|
96
|
+
lines << "**Description:** #{spec.description}" if spec.description.to_s.strip.length > 0
|
|
97
|
+
lines << "**Author(s):** #{spec.authors.join(', ')}" if spec.authors.any?
|
|
98
|
+
lines << "**Homepage:** #{spec.homepage}" if spec.homepage.to_s.strip.length > 0
|
|
99
|
+
lines << "**License(s):** #{spec.licenses.join(', ')}" if spec.licenses.any?
|
|
100
|
+
lines << "**Source:** #{spec.metadata['source_code_uri']}" if spec.metadata["source_code_uri"]
|
|
101
|
+
lines << "**Documentation:** #{spec.metadata['documentation_uri']}" if spec.metadata["documentation_uri"]
|
|
102
|
+
|
|
103
|
+
runtime_deps = spec.runtime_dependencies
|
|
104
|
+
if runtime_deps.any?
|
|
105
|
+
dep_list = runtime_deps.map { |d| "#{d.name} (#{d.requirement})" }.join(", ")
|
|
106
|
+
lines << "**Runtime dependencies:** #{dep_list}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
lines.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def local_examples
|
|
113
|
+
dir = gem_dir
|
|
114
|
+
return nil unless dir
|
|
115
|
+
|
|
116
|
+
examples_dir = File.join(dir, "examples")
|
|
117
|
+
return nil unless File.directory?(examples_dir)
|
|
118
|
+
|
|
119
|
+
files = Dir.glob(File.join(examples_dir, "**", "*.{rb,md}")).sort
|
|
120
|
+
return nil if files.empty?
|
|
121
|
+
|
|
122
|
+
files.map do |path|
|
|
123
|
+
relative = path.delete_prefix("#{examples_dir}/")
|
|
124
|
+
content = File.read(path, encoding: "utf-8")
|
|
125
|
+
"### #{relative}\n\n```\n#{content.strip}\n```"
|
|
126
|
+
end.join("\n\n")
|
|
127
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- RubyGems API ---
|
|
132
|
+
|
|
133
|
+
def rubygems_data
|
|
134
|
+
@rubygems_data ||= begin
|
|
135
|
+
body = fetch_url(format(RUBYGEMS_API, gem_name))
|
|
136
|
+
body ? JSON.parse(body) : nil
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rubygems_metadata
|
|
143
|
+
data = rubygems_data
|
|
144
|
+
return nil unless data
|
|
145
|
+
|
|
146
|
+
lines = []
|
|
147
|
+
lines << "**Gem:** #{data['name']} #{data['version']}"
|
|
148
|
+
lines << "**Summary:** #{data['info']}" if data["info"].to_s.strip.length > 0
|
|
149
|
+
lines << "**Homepage:** #{data['homepage_uri']}" if data["homepage_uri"]
|
|
150
|
+
lines << "**Source:** #{data['source_code_uri']}" if data["source_code_uri"]
|
|
151
|
+
lines << "**Documentation:** #{data['documentation_uri']}" if data["documentation_uri"]
|
|
152
|
+
|
|
153
|
+
runtime_deps = data.dig("dependencies", "runtime") || []
|
|
154
|
+
if runtime_deps.any?
|
|
155
|
+
dep_list = runtime_deps.map { |d| "#{d['name']} (#{d['requirements']})" }.join(", ")
|
|
156
|
+
lines << "**Runtime dependencies:** #{dep_list}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- GitHub raw README ---
|
|
163
|
+
|
|
164
|
+
def github_readme
|
|
165
|
+
repo = github_repo
|
|
166
|
+
return nil unless repo
|
|
167
|
+
|
|
168
|
+
README_CANDIDATES.each do |filename|
|
|
169
|
+
%w[main master].each do |branch|
|
|
170
|
+
url = format(GITHUB_RAW, repo, branch, filename)
|
|
171
|
+
content = fetch_url(url)
|
|
172
|
+
return content if content
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def github_repo
|
|
179
|
+
data = rubygems_data
|
|
180
|
+
return nil unless data
|
|
181
|
+
|
|
182
|
+
candidate_uris = [data["source_code_uri"], data["homepage_uri"]].compact
|
|
183
|
+
candidate_uris.each do |uri|
|
|
184
|
+
match = uri.match(%r{github\.com[/:](?<owner>[^/]+)/(?<repo>[^/.\s]+?)(?:\.git)?(?:/|$)})
|
|
185
|
+
return "#{match[:owner]}/#{match[:repo]}" if match
|
|
186
|
+
end
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- HTTP ---
|
|
191
|
+
|
|
192
|
+
def fetch_url(url, redirects_left: MAX_REDIRECTS)
|
|
193
|
+
return nil if redirects_left.zero?
|
|
194
|
+
|
|
195
|
+
uri = URI(url)
|
|
196
|
+
uri.scheme = "https" if uri.scheme == "http"
|
|
197
|
+
|
|
198
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
199
|
+
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
|
|
200
|
+
http.get(uri.request_uri, "User-Agent" => "gem-skill/#{Gem::Skill::VERSION}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
case response
|
|
204
|
+
when Net::HTTPSuccess
|
|
205
|
+
response.body
|
|
206
|
+
when Net::HTTPRedirection
|
|
207
|
+
fetch_url(response["location"], redirects_left: redirects_left - 1)
|
|
208
|
+
end
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Gem::Skill
|
|
6
|
+
# Drives the LLM pipeline: fetches docs, generates a SKILL.md, caches it.
|
|
7
|
+
class Generator
|
|
8
|
+
DEFAULT_MODEL = ENV.fetch("GEMSKILL_MODEL", "gpt-5.5")
|
|
9
|
+
MAX_SOURCE_CHARS = 60_000 # guard against enormous READMEs blowing the context window
|
|
10
|
+
|
|
11
|
+
SYSTEM_INSTRUCTIONS = <<~SYSTEM
|
|
12
|
+
You are a Ruby gem documentation specialist who generates Claude Code skill files.
|
|
13
|
+
A skill file gives Claude Code deep, practical knowledge about a library so it can
|
|
14
|
+
use it correctly without re-reading source docs. Be accurate, concise, and complete
|
|
15
|
+
for the most common use cases. No filler, no marketing language.
|
|
16
|
+
SYSTEM
|
|
17
|
+
|
|
18
|
+
SKILL_PROMPT = <<~PROMPT
|
|
19
|
+
Generate a SKILL.md for the Ruby gem "%<gem_name>s" version %<version>s.
|
|
20
|
+
|
|
21
|
+
Output raw Markdown directly. Do NOT wrap the output in a code fence or any
|
|
22
|
+
other container — the file is Markdown, so no ```markdown wrapper.
|
|
23
|
+
|
|
24
|
+
Begin immediately with the first section heading. Use exactly these sections:
|
|
25
|
+
|
|
26
|
+
## Overview
|
|
27
|
+
One paragraph: what the gem does and when to reach for it.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
Exact Gemfile/gemspec lines and any required post-install steps.
|
|
31
|
+
|
|
32
|
+
## Core API
|
|
33
|
+
The most important classes, methods, and options. Show real method signatures
|
|
34
|
+
and return values. Prefer code over prose.
|
|
35
|
+
|
|
36
|
+
## Common Patterns
|
|
37
|
+
The 3-5 most frequent real-world usage patterns with working code examples.
|
|
38
|
+
|
|
39
|
+
## Gotchas & Edge Cases
|
|
40
|
+
Things that surprise developers: unexpected defaults, version-specific behavior,
|
|
41
|
+
thread safety concerns, performance cliffs, encoding issues.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
Initializer patterns, environment variables, defaults worth knowing.
|
|
45
|
+
|
|
46
|
+
## Testing
|
|
47
|
+
How to test code that uses this gem: mocks, fakes, fixtures, VCR patterns.
|
|
48
|
+
|
|
49
|
+
Synthesize the sources below — do not copy them verbatim.
|
|
50
|
+
Write as a knowledgeable colleague, not a marketing document.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
%<sources>s
|
|
55
|
+
PROMPT
|
|
56
|
+
|
|
57
|
+
attr_reader :gem_name, :version, :model
|
|
58
|
+
|
|
59
|
+
def initialize(gem_name, version, model: DEFAULT_MODEL)
|
|
60
|
+
@gem_name = gem_name
|
|
61
|
+
@version = version
|
|
62
|
+
@model = model
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate and cache a SKILL.md. Returns the skill content string.
|
|
66
|
+
# Pass a block to stream output chunks to the caller for live feedback.
|
|
67
|
+
def generate(force: false, &block)
|
|
68
|
+
return Cache.read(gem_name, version) if Cache.cached?(gem_name, version) && !force
|
|
69
|
+
|
|
70
|
+
sources = Fetcher.new(gem_name, version).fetch_all
|
|
71
|
+
raise Error, "No documentation found for #{gem_name} #{version}" if sources.empty?
|
|
72
|
+
|
|
73
|
+
skill_content = block ? call_llm_streaming(sources, &block) : call_llm(sources)
|
|
74
|
+
Cache.store(gem_name, version, skill_content, { sources: sources.keys.map(&:to_s), model: model })
|
|
75
|
+
skill_content
|
|
76
|
+
rescue RubyLLM::Error => e
|
|
77
|
+
raise Error, e.message
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def call_llm(sources)
|
|
83
|
+
chat = build_chat
|
|
84
|
+
response = chat.ask(format_prompt(sources))
|
|
85
|
+
strip_wrapper_fence(response.content)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def call_llm_streaming(sources)
|
|
89
|
+
content = +""
|
|
90
|
+
chat = build_chat
|
|
91
|
+
chat.ask(format_prompt(sources)) do |chunk|
|
|
92
|
+
text = chunk.content.to_s
|
|
93
|
+
next if text.empty?
|
|
94
|
+
|
|
95
|
+
yield text
|
|
96
|
+
content << text
|
|
97
|
+
end
|
|
98
|
+
strip_wrapper_fence(content)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Removes a leading ```markdown (or ```) fence and its closing ```.
|
|
102
|
+
# Belt-and-suspenders: the prompt instructs the model not to wrap,
|
|
103
|
+
# but some models do it anyway.
|
|
104
|
+
def strip_wrapper_fence(content)
|
|
105
|
+
content
|
|
106
|
+
.sub(/\A\s*```(?:markdown)?\s*\n/, "")
|
|
107
|
+
.sub(/\n```\s*\z/, "")
|
|
108
|
+
.strip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_chat
|
|
112
|
+
RubyLLM.chat(model: model).with_instructions(SYSTEM_INSTRUCTIONS)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def format_prompt(sources)
|
|
116
|
+
formatted = sources.map do |name, content|
|
|
117
|
+
body = content.length > MAX_SOURCE_CHARS ? "#{content[0, MAX_SOURCE_CHARS]}\n[... truncated ...]" : content
|
|
118
|
+
"### #{name.to_s.upcase}\n\n#{body}"
|
|
119
|
+
end.join("\n\n---\n\n")
|
|
120
|
+
|
|
121
|
+
format(SKILL_PROMPT, gem_name: gem_name, version: version, sources: formatted)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gem::Skill
|
|
6
|
+
# Manages .claude/skills/ symlinks in a project, pointing to ~/.gem/skills cache.
|
|
7
|
+
module Linker
|
|
8
|
+
def self.skills_dir(project_root = Dir.pwd)
|
|
9
|
+
File.join(project_root, ".claude", "skills")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.link(gem_name, version, project_root = Dir.pwd)
|
|
13
|
+
target = Cache.skill_path(gem_name, version)
|
|
14
|
+
raise Error, "No cached skill for #{gem_name} #{version}. Run: gem skill install #{gem_name}" \
|
|
15
|
+
unless File.exist?(target)
|
|
16
|
+
|
|
17
|
+
dir = skills_dir(project_root)
|
|
18
|
+
FileUtils.mkdir_p(dir)
|
|
19
|
+
|
|
20
|
+
link_path = File.join(dir, "#{gem_name}.md")
|
|
21
|
+
File.unlink(link_path) if File.symlink?(link_path)
|
|
22
|
+
File.symlink(target, link_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.unlink(gem_name, project_root = Dir.pwd)
|
|
26
|
+
link_path = File.join(skills_dir(project_root), "#{gem_name}.md")
|
|
27
|
+
File.unlink(link_path) if File.symlink?(link_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.linked_gems(project_root = Dir.pwd)
|
|
31
|
+
dir = skills_dir(project_root)
|
|
32
|
+
return [] unless Dir.exist?(dir)
|
|
33
|
+
|
|
34
|
+
Dir.glob(File.join(dir, "*.md")).filter_map do |path|
|
|
35
|
+
next unless File.symlink?(path)
|
|
36
|
+
|
|
37
|
+
gem_name = File.basename(path, ".md")
|
|
38
|
+
target = File.readlink(path)
|
|
39
|
+
version = target.match(%r{/([^/]+)/SKILL\.md$})&.captures&.first
|
|
40
|
+
{ gem_name: gem_name, version: version, target: target, valid: File.exist?(target) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.prune_dead_links(project_root = Dir.pwd)
|
|
45
|
+
linked_gems(project_root)
|
|
46
|
+
.reject { |entry| entry[:valid] }
|
|
47
|
+
.each { |entry| unlink(entry[:gem_name], project_root) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gem::Skill
|
|
4
|
+
# Parses Gemfile.lock to extract direct dependency gem name/version pairs.
|
|
5
|
+
# Direct deps are listed in the DEPENDENCIES section; versions come from specs.
|
|
6
|
+
module Lockfile
|
|
7
|
+
def self.gems(lockfile_path = "Gemfile.lock")
|
|
8
|
+
raise Error, "Gemfile.lock not found at #{lockfile_path}" unless File.exist?(lockfile_path)
|
|
9
|
+
|
|
10
|
+
parse(File.read(lockfile_path))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.parse(content)
|
|
14
|
+
specs = parse_specs(content)
|
|
15
|
+
direct = parse_direct_names(content)
|
|
16
|
+
direct.each_with_object({}) do |name, hash|
|
|
17
|
+
hash[name] = specs[name] if specs.key?(name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.parse_specs(content)
|
|
22
|
+
in_specs = false
|
|
23
|
+
specs = {}
|
|
24
|
+
|
|
25
|
+
content.each_line do |line|
|
|
26
|
+
if line.strip == "specs:"
|
|
27
|
+
in_specs = true
|
|
28
|
+
next
|
|
29
|
+
elsif in_specs && line =~ /\A {4}(\S+) \(([^)]+)\)/
|
|
30
|
+
specs[Regexp.last_match(1)] = Regexp.last_match(2)
|
|
31
|
+
elsif in_specs && line !~ /\A /
|
|
32
|
+
in_specs = false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
specs
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.parse_direct_names(content)
|
|
40
|
+
in_deps = false
|
|
41
|
+
names = []
|
|
42
|
+
|
|
43
|
+
content.each_line do |line|
|
|
44
|
+
if line.strip == "DEPENDENCIES"
|
|
45
|
+
in_deps = true
|
|
46
|
+
next
|
|
47
|
+
elsif in_deps && line =~ /\A (\S+)/
|
|
48
|
+
# Strip version constraints from dep names like "ruby_llm (>= 1.0)"
|
|
49
|
+
names << Regexp.last_match(1)
|
|
50
|
+
elsif in_deps && !line.strip.empty?
|
|
51
|
+
in_deps = false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
names
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/gem/skill.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skill/version"
|
|
4
|
+
require_relative "skill/cache"
|
|
5
|
+
require_relative "skill/fetcher"
|
|
6
|
+
require_relative "skill/generator"
|
|
7
|
+
require_relative "skill/linker"
|
|
8
|
+
require_relative "skill/lockfile"
|
|
9
|
+
|
|
10
|
+
module Gem::Skill
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
ENV_KEY_MAP = {
|
|
14
|
+
anthropic_api_key: "ANTHROPIC_API_KEY",
|
|
15
|
+
openai_api_key: "OPENAI_API_KEY",
|
|
16
|
+
gemini_api_key: "GEMINI_API_KEY",
|
|
17
|
+
mistral_api_key: "MISTRAL_API_KEY",
|
|
18
|
+
deepseek_api_key: "DEEPSEEK_API_KEY",
|
|
19
|
+
openrouter_api_key: "OPENROUTER_API_KEY",
|
|
20
|
+
xai_api_key: "XAI_API_KEY"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Configure RubyLLM from environment variables. Called automatically by the
|
|
24
|
+
# CLI commands so users don't need a separate initializer for standalone use.
|
|
25
|
+
# No-op if RubyLLM is already configured (e.g. in a Rails app).
|
|
26
|
+
def self.configure_llm!
|
|
27
|
+
RubyLLM.configure do |config|
|
|
28
|
+
ENV_KEY_MAP.each do |attr, env_var|
|
|
29
|
+
value = ENV[env_var]
|
|
30
|
+
config.public_send(:"#{attr}=", value) if value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gem/skill/cli/gem_command"
|
|
4
|
+
Gem::CommandManager.instance.register_command :skill
|
|
5
|
+
|
|
6
|
+
require "rubygems/commands/install_command"
|
|
7
|
+
require "tty-spinner"
|
|
8
|
+
|
|
9
|
+
module Gem::Skill
|
|
10
|
+
module InstallSkillOption
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
add_option("--with-skill", "Generate Claude Code SKILL.md files after installation") do |_, opts|
|
|
14
|
+
opts[:generate_skill] = true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Gem::Commands::InstallCommand.prepend(Gem::Skill::InstallSkillOption)
|
|
21
|
+
|
|
22
|
+
module Gem::Skill
|
|
23
|
+
@pending_skills = []
|
|
24
|
+
@pending_lock = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_reader :pending_skills, :pending_lock
|
|
28
|
+
|
|
29
|
+
def generate_pending_skills
|
|
30
|
+
return if @pending_skills.empty?
|
|
31
|
+
|
|
32
|
+
configure_llm!
|
|
33
|
+
|
|
34
|
+
multi = TTY::Spinner::Multi.new(
|
|
35
|
+
"[:spinner] Writing skills",
|
|
36
|
+
format: :dots,
|
|
37
|
+
output: $stderr
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
threads = @pending_skills.map do |gem_info|
|
|
41
|
+
name = gem_info[:name]
|
|
42
|
+
version = gem_info[:version]
|
|
43
|
+
sp = multi.register(" [:spinner] :title")
|
|
44
|
+
sp.update(title: "#{name} #{version}")
|
|
45
|
+
Thread.new(name, version, sp) { |n, v, spinner| generate_one_skill(n, v, spinner) }
|
|
46
|
+
end
|
|
47
|
+
threads.each(&:join)
|
|
48
|
+
rescue => e
|
|
49
|
+
warn "gem-skill: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def generate_one_skill(name, version, spinner)
|
|
53
|
+
spinner.auto_spin
|
|
54
|
+
Generator.new(name, version).generate
|
|
55
|
+
spinner.success("done")
|
|
56
|
+
rescue => e
|
|
57
|
+
spinner.error("failed")
|
|
58
|
+
warn "gem-skill: #{name}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Gem.post_install do |installer|
|
|
64
|
+
next unless installer.options[:generate_skill]
|
|
65
|
+
Gem::Skill.pending_lock.synchronize do
|
|
66
|
+
Gem::Skill.pending_skills << { name: installer.spec.name, version: installer.spec.version.to_s }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
at_exit { Gem::Skill.generate_pending_skills }
|
data/plugins.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler plugin entry point — loaded by `bundle plugin install gem-skill`
|
|
4
|
+
# or `plugin 'gem-skill'` in Gemfile. Registers the `bundle skill` command.
|
|
5
|
+
|
|
6
|
+
require_relative "lib/gem/skill/cli/bundle_command"
|
|
7
|
+
|
|
8
|
+
Bundler::Plugin::API.commands "skill" do |_command, args|
|
|
9
|
+
Gem::Skill::BundlerCommand.run(args)
|
|
10
|
+
end
|
data/scripts/e2e_test
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# End-to-end test: fetch docs for a gem and generate a SKILL.md
|
|
5
|
+
# Usage: bundle exec ruby bin/e2e_test [GEM_NAME [VERSION]]
|
|
6
|
+
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
8
|
+
require "gem/skill"
|
|
9
|
+
require "ruby_llm"
|
|
10
|
+
|
|
11
|
+
GEM_NAME = ARGV[0] || "zeitwerk"
|
|
12
|
+
VERSION = ARGV[1] || Gem::Specification.find_by_name(GEM_NAME)&.version&.to_s
|
|
13
|
+
MODEL = ARGV[2] || "gpt-4o-mini"
|
|
14
|
+
|
|
15
|
+
abort "Gem '#{GEM_NAME}' not installed. Run: gem install #{GEM_NAME}" unless VERSION
|
|
16
|
+
|
|
17
|
+
Gem::Skill.configure_llm!
|
|
18
|
+
|
|
19
|
+
puts "=" * 60
|
|
20
|
+
puts "gem-skill end-to-end test"
|
|
21
|
+
puts "Gem: #{GEM_NAME} #{VERSION}"
|
|
22
|
+
puts "Model: #{MODEL}"
|
|
23
|
+
puts "Cache: #{Gem::Skill::Cache.root}"
|
|
24
|
+
puts "=" * 60
|
|
25
|
+
puts ""
|
|
26
|
+
|
|
27
|
+
# --- Phase 1: Fetch ---
|
|
28
|
+
|
|
29
|
+
puts "[ FETCH ]"
|
|
30
|
+
fetcher = Gem::Skill::Fetcher.new(GEM_NAME, VERSION)
|
|
31
|
+
sources = fetcher.fetch_all
|
|
32
|
+
|
|
33
|
+
if sources.empty?
|
|
34
|
+
abort "No sources found — nothing to generate from."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sources.each do |name, content|
|
|
38
|
+
puts " %-12s %d chars" % [name, content.length]
|
|
39
|
+
end
|
|
40
|
+
puts ""
|
|
41
|
+
|
|
42
|
+
# --- Phase 2: Generate ---
|
|
43
|
+
|
|
44
|
+
puts "[ GENERATE ] streaming output below"
|
|
45
|
+
puts "-" * 60
|
|
46
|
+
|
|
47
|
+
skill_content = ""
|
|
48
|
+
generator = Gem::Skill::Generator.new(GEM_NAME, VERSION, model: MODEL)
|
|
49
|
+
skill_content = generator.generate(force: true) do |chunk|
|
|
50
|
+
print chunk
|
|
51
|
+
$stdout.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts ""
|
|
55
|
+
puts "-" * 60
|
|
56
|
+
puts ""
|
|
57
|
+
|
|
58
|
+
# --- Phase 3: Verify cache ---
|
|
59
|
+
|
|
60
|
+
puts "[ CACHE ]"
|
|
61
|
+
cached_path = Gem::Skill::Cache.skill_path(GEM_NAME, VERSION)
|
|
62
|
+
if File.exist?(cached_path)
|
|
63
|
+
puts " Written: #{cached_path}"
|
|
64
|
+
puts " Size: #{File.size(cached_path)} bytes"
|
|
65
|
+
else
|
|
66
|
+
puts " ERROR: cache file not found at #{cached_path}"
|
|
67
|
+
exit 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
puts ""
|
|
71
|
+
puts "Done."
|
data/sig/gem/skill.rbs
ADDED