gem-skill 0.1.3 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rubygems/command"
4
+ # gem/skill must be required first: activating the gem-skill spec resolves
5
+ # json to < 3 (ruby_llm's constraint). A bare `require "json"` before that
6
+ # activates the newest installed json and makes the activation raise
7
+ # Gem::ConflictError, which RubyGems swallows — leaving `gem skill` unregistered.
8
+ require "gem/skill"
4
9
  require "async"
5
10
  require "fileutils"
6
11
  require "json"
7
12
  require "tty-spinner"
8
- require "gem/skill"
9
13
 
10
14
  # Registered as `gem skill` via lib/rubygems_plugin.rb.
11
15
  # Manages the global ~/.gem/skills cache.
@@ -14,19 +18,27 @@ class Gem::Commands::SkillCommand < Gem::Command
14
18
  super "skill", "Manage Claude Code AI skills for Ruby gems"
15
19
 
16
20
  add_option("-f", "--force", "Regenerate even if already cached") { |_, o| o[:force] = true }
21
+ 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
22
  add_option("-a", "--all", "Purge all cached versions of a gem") { |_, o| o[:all] = true }
18
23
  add_option("-m", "--model MODEL", "LLM model to use (default: #{Gem::Skill::Generator::DEFAULT_MODEL})") do |model, o|
19
24
  o[:model] = model
20
25
  end
26
+ add_option("--max-tokens TOKENS", "Max output tokens (overrides GEMSKIL_MAX_TOKENS; default: #{Gem::Skill::Generator::MAX_TOKENS})") do |tokens, o|
27
+ o[:max_tokens] = tokens.to_i
28
+ end
29
+ add_option("--temperature TEMP", "Sampling temperature; ignored by reasoning models (overrides GEMSKILL_TEMPERATURE; default: #{Gem::Skill::Generator::DEFAULT_TEMPERATURE})") do |temp, o|
30
+ o[:temperature] = temp.to_f
31
+ end
21
32
  add_option("-v", "--version", "Print gem-skill version and exit") { |_, o| o[:version] = true }
22
33
  end
23
34
 
24
35
  def arguments
25
- "SUBCOMMAND one of: install, list, purge, setup"
36
+ "SUBCOMMAND one of: install, verify, list, purge, setup"
26
37
  end
27
38
 
28
39
  def usage
29
40
  "#{program_name} install GEM_NAME [GEM_NAME ...]\n" \
41
+ " #{program_name} verify GEM_NAME [GEM_NAME ...]\n" \
30
42
  " #{program_name} list\n" \
31
43
  " #{program_name} purge GEM_NAME VERSION\n" \
32
44
  " #{program_name} purge GEM_NAME --all\n" \
@@ -36,6 +48,8 @@ class Gem::Commands::SkillCommand < Gem::Command
36
48
  def description
37
49
  <<~DESC
38
50
  install Generate and cache a SKILL.md for a gem.
51
+ verify Verify an already-cached skill against the gem's source and fix
52
+ mismatches (does not generate; errors if not cached).
39
53
  list Show all skills in the global cache (~/.gem/skills).
40
54
  purge Remove a specific cached version.
41
55
  setup Register gem-skill as a Bundler plugin (run once after install).
@@ -53,6 +67,7 @@ class Gem::Commands::SkillCommand < Gem::Command
53
67
  subcmd = options[:args].shift
54
68
  case subcmd
55
69
  when "install" then cmd_install
70
+ when "verify" then cmd_verify
56
71
  when "list" then cmd_list
57
72
  when "purge" then cmd_purge
58
73
  when "setup" then cmd_setup
@@ -75,8 +90,11 @@ class Gem::Commands::SkillCommand < Gem::Command
75
90
  return
76
91
  end
77
92
 
78
- force = options[:force]
79
- model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
93
+ force = options[:force]
94
+ verify = options[:verify]
95
+ model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
96
+ max_tokens = options[:max_tokens] || Gem::Skill::Generator::MAX_TOKENS
97
+ temperature = options[:temperature] || Gem::Skill::Generator::DEFAULT_TEMPERATURE
80
98
 
81
99
  multi = TTY::Spinner::Multi.new(
82
100
  "[:spinner] Generating skills (#{model})",
@@ -84,12 +102,13 @@ class Gem::Commands::SkillCommand < Gem::Command
84
102
  output: $stderr
85
103
  )
86
104
 
105
+ results = []
87
106
  Async do
88
107
  barrier = Async::Barrier.new
89
108
  gem_names.each do |gem_name|
90
109
  spinner = multi.register(" [:spinner] :title")
91
110
  spinner.update(title: gem_name)
92
- barrier.async { install_one(gem_name, spinner: spinner, force: force, model: model) }
111
+ barrier.async { results << install_one(gem_name, spinner: spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature) }
93
112
  end
94
113
  barrier.wait
95
114
  ensure
@@ -97,9 +116,15 @@ class Gem::Commands::SkillCommand < Gem::Command
97
116
  end
98
117
 
99
118
  say "Tip: run 'bundle plugin install gem-skill' to enable 'bundle skill'."
119
+
120
+ fixed = results.count(&:verify_fixed)
121
+ if verify && fixed.positive?
122
+ say "Verify corrected #{fixed} skill(s) against gem source."
123
+ terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
124
+ end
100
125
  end
101
126
 
102
- def install_one(gem_name, spinner:, force:, model:)
127
+ def install_one(gem_name, spinner:, force:, model:, verify: false, max_tokens: Gem::Skill::Generator::MAX_TOKENS, temperature: Gem::Skill::Generator::DEFAULT_TEMPERATURE)
103
128
  spinner.auto_spin
104
129
  version = resolve_installed_version(gem_name)
105
130
  if version.nil?
@@ -107,11 +132,77 @@ class Gem::Commands::SkillCommand < Gem::Command
107
132
  version = install_gem(gem_name)
108
133
  end
109
134
  spinner.update(title: "#{gem_name} #{version}")
110
- err = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model)
111
- alert_error "#{gem_name}: #{err}" if err
135
+ result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature)
136
+ alert_error "#{gem_name}: #{result.error}" if result.error
137
+ result
138
+ rescue Gem::Skill::Error => e
139
+ spinner.error("failed")
140
+ alert_error "#{gem_name}: #{e.message}"
141
+ Gem::Skill::Runner::Result.failure(e.message)
142
+ end
143
+
144
+ def cmd_verify
145
+ gem_names = options[:args].dup
146
+ options[:args].clear
147
+
148
+ if gem_names.empty?
149
+ alert_error "gem_name required. Usage: gem skill verify GEM_NAME [GEM_NAME ...]"
150
+ return
151
+ end
152
+
153
+ model = options[:model] || Gem::Skill::Generator::DEFAULT_MODEL
154
+
155
+ multi = TTY::Spinner::Multi.new(
156
+ "[:spinner] Verifying skills (#{model})",
157
+ format: :dots,
158
+ output: $stderr
159
+ )
160
+
161
+ results = []
162
+ Async do
163
+ barrier = Async::Barrier.new
164
+ gem_names.each do |gem_name|
165
+ spinner = multi.register(" [:spinner] :title")
166
+ spinner.update(title: gem_name)
167
+ barrier.async { results << verify_one(gem_name, spinner: spinner, model: model) }
168
+ end
169
+ barrier.wait
170
+ ensure
171
+ barrier.stop
172
+ end
173
+
174
+ fixed = results.count(&:verify_fixed)
175
+ if fixed.positive?
176
+ say "Verify corrected #{fixed} skill(s) against gem source."
177
+ terminate_interaction Gem::Skill::EXIT_VERIFY_FIXED
178
+ end
179
+ end
180
+
181
+ # Verify an already-cached skill in place. Never generates: the gem must be
182
+ # installed (verification needs its source) and the skill must already be cached.
183
+ def verify_one(gem_name, spinner:, model:)
184
+ spinner.auto_spin
185
+ version = resolve_installed_version(gem_name)
186
+ if version.nil?
187
+ spinner.error("not installed")
188
+ alert_error "#{gem_name}: not installed locally; verification needs the gem's source. Run 'gem install #{gem_name}' first."
189
+ return Gem::Skill::Runner::Result.failure("not installed")
190
+ end
191
+
192
+ spinner.update(title: "#{gem_name} #{version}")
193
+ unless Gem::Skill::Cache.cached?(gem_name, version)
194
+ spinner.error("not cached")
195
+ alert_error "#{gem_name} #{version}: no cached skill to verify. Run 'gem skill install #{gem_name}' first."
196
+ return Gem::Skill::Runner::Result.failure("not cached")
197
+ end
198
+
199
+ result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: false, model: model, verify: true)
200
+ alert_error "#{gem_name}: #{result.error}" if result.error
201
+ result
112
202
  rescue Gem::Skill::Error => e
113
203
  spinner.error("failed")
114
204
  alert_error "#{gem_name}: #{e.message}"
205
+ Gem::Skill::Runner::Result.failure(e.message)
115
206
  end
116
207
 
117
208
  def cmd_list
@@ -126,13 +217,54 @@ class Gem::Commands::SkillCommand < Gem::Command
126
217
  say ""
127
218
  gems.each do |name|
128
219
  versions = Gem::Skill::Cache.versions(name)
129
- say " %-30s %s" % [name, versions.join(", ")]
220
+ rendered = versions.map { |v| format_version(name, v) }.join(", ")
221
+ say " %-30s %s" % [name, rendered]
130
222
  end
131
223
  say ""
132
224
  say "#{gems.size} gem(s), #{gems.sum { |n| Gem::Skill::Cache.versions(n).size }} version(s) total."
133
225
  end
134
226
 
227
+ CHECK_MARK = "✓" # ✓
228
+
229
+ # True when the cached skill for this gem/version was verified against source.
230
+ def skill_verified?(gem_name, version)
231
+ Gem::Skill::Cache.read_metadata(gem_name, version).dig("verification", "verified") == true
232
+ end
233
+
234
+ # A version label, with a green checkmark appended when the skill is verified.
235
+ def format_version(gem_name, version)
236
+ return version unless skill_verified?(gem_name, version)
237
+
238
+ "#{version} #{colorize_check}"
239
+ end
240
+
241
+ # The checkmark, ANSI-green only when writing to an interactive terminal so
242
+ # redirected/piped output stays clean.
243
+ def colorize_check
244
+ $stdout.tty? ? "\e[32m#{CHECK_MARK}\e[0m" : CHECK_MARK
245
+ end
246
+
247
+ # Default skill roots that assistants scan automatically. The router skill is
248
+ # copied into each one whose assistant home directory already exists.
249
+ ASSISTANT_SKILL_ROOTS = {
250
+ "Claude Code" => "~/.claude/skills",
251
+ "OpenAI Codex" => "~/.codex/skills",
252
+ "Agents (vendor-neutral)" => "~/.agents/skills"
253
+ }.freeze
254
+
135
255
  def cmd_setup
256
+ register_bundler_plugin
257
+ say ""
258
+ install_router_skill
259
+ end
260
+
261
+ def register_bundler_plugin
262
+ plugin_list = `bundle plugin list 2>/dev/null`
263
+ if plugin_list.include?("gem-skill")
264
+ say "gem-skill is already registered as a Bundler plugin."
265
+ return
266
+ end
267
+
136
268
  say "Registering gem-skill as a Bundler plugin..."
137
269
  if system("bundle", "plugin", "install", "gem-skill")
138
270
  say "Done. Use 'bundle skill install' in any project."
@@ -141,6 +273,36 @@ class Gem::Commands::SkillCommand < Gem::Command
141
273
  end
142
274
  end
143
275
 
276
+ # Copy the bundled '#{Gem::Skill::ROUTER_SKILL_NAME}' skill into the default
277
+ # skill root of each detected assistant, so it can discover cached gem skills.
278
+ def install_router_skill
279
+ source = File.join(Gem::Skill::ROUTER_SKILL_DIR, "SKILL.md")
280
+ unless File.exist?(source)
281
+ alert_error "Router skill not found at #{source}"
282
+ return
283
+ end
284
+
285
+ say "Installing the '#{Gem::Skill::ROUTER_SKILL_NAME}' skill so assistants can find cached gem skills:"
286
+ installed = 0
287
+ ASSISTANT_SKILL_ROOTS.each do |label, root|
288
+ base = File.expand_path(root)
289
+ unless Dir.exist?(File.dirname(base))
290
+ say " - #{label}: not detected — skipped"
291
+ next
292
+ end
293
+
294
+ dest_dir = File.join(base, Gem::Skill::ROUTER_SKILL_NAME)
295
+ FileUtils.mkdir_p(dest_dir)
296
+ FileUtils.cp(source, File.join(dest_dir, "SKILL.md"))
297
+ say " - #{label}: installed -> #{dest_dir.sub(Dir.home, '~')}"
298
+ installed += 1
299
+ end
300
+
301
+ return unless installed.zero?
302
+
303
+ say " No assistant directories detected. Create ~/.claude or ~/.codex, then re-run 'gem skill setup'."
304
+ end
305
+
144
306
  def cmd_purge
145
307
  gem_name = options[:args].shift
146
308
  unless gem_name
@@ -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
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem::Skill
4
+ # Builds the YAML frontmatter that makes a SKILL.md discoverable as an Agent
5
+ # Skill. Both Claude Code and OpenAI Codex require `name` + `description`
6
+ # frontmatter; without it the file is never registered/triggered as a skill.
7
+ #
8
+ # Constraints satisfied here (intersection of both assistants):
9
+ # - name: lowercase letters, digits, hyphens only; no leading/trailing or
10
+ # doubled hyphens; <= 40 chars (Claude Code's rule, also valid for Codex)
11
+ # - description: single line, no angle brackets (Claude Code rejects < and >),
12
+ # length-capped (Codex shortens long descriptions)
13
+ #
14
+ # Generation is deterministic (no LLM): the name is derived from the gem name
15
+ # and the description from the skill's Overview section, so the frontmatter is
16
+ # always valid regardless of what the model emitted.
17
+ module Frontmatter
18
+ MAX_NAME_LENGTH = 40
19
+ MAX_DESCRIPTION_LENGTH = 500
20
+
21
+ module_function
22
+
23
+ # Return content with a freshly-built, valid frontmatter block. Any existing
24
+ # leading frontmatter is stripped and replaced, so this is idempotent.
25
+ def build(gem_name, version, content)
26
+ body = strip(content)
27
+ fm = "---\nname: #{slug(gem_name)}\ndescription: #{yaml_quote(description_for(gem_name, version, body))}\n---\n"
28
+ "#{fm}\n#{body}"
29
+ end
30
+
31
+ # True when content already begins with a YAML frontmatter block.
32
+ def present?(content)
33
+ content.to_s.lstrip.start_with?("---")
34
+ end
35
+
36
+ # Remove a leading frontmatter block (if any) and return the body.
37
+ def strip(content)
38
+ content.to_s.sub(/\A\s*---\s*\n.*?\n---\s*\n+/m, "").lstrip
39
+ end
40
+
41
+ # Gem name -> valid skill name. "ruby_llm" -> "ruby-llm", "TTY-Spinner" ->
42
+ # "tty-spinner". Falls back to "skill" if nothing usable remains.
43
+ def slug(gem_name)
44
+ s = gem_name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
45
+ s = "skill" if s.empty?
46
+ s[0, MAX_NAME_LENGTH].sub(/-+\z/, "")
47
+ end
48
+
49
+ # Derive a trigger-oriented description from the body's Overview section,
50
+ # appending the version for context. Sanitized for both assistants.
51
+ def description_for(gem_name, version, body)
52
+ overview = body[/^##\s+Overview\s*\n+(.+?)(?=\n\s*\n|\n##\s|\z)/m, 1]
53
+ text = overview || "Ruby gem #{gem_name}. Use when working with #{gem_name} in Ruby code."
54
+ text = text.gsub(/\s+/, " ").delete("<>").strip
55
+ text = "#{text} (#{gem_name} v#{version})" unless text.include?(version.to_s)
56
+ text[0, MAX_DESCRIPTION_LENGTH].strip
57
+ end
58
+
59
+ # Quote a string as a YAML double-quoted scalar, escaping \ and ".
60
+ def yaml_quote(str)
61
+ %("#{str.gsub(/[\\"]/) { |c| "\\#{c}" }}")
62
+ end
63
+ end
64
+ end
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
+ # Provider plugin gems self-register with RubyLLM only when required; without
5
+ # these, "lms/..." and "apfel/..." models are unreachable.
6
+ require "ruby_llm/providers/lms"
7
+ require "ruby_llm/providers/apfel"
4
8
 
5
9
  module Gem::Skill
6
10
  # Drives the LLM pipeline: fetches docs, generates a SKILL.md, caches it.
7
11
  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
12
+ DEFAULT_MODEL = ENV.fetch("GEMSKILL_MODEL", "gpt-5.5")
13
+ MAX_TOKENS = ENV.fetch("GEMSKIL_MAX_TOKENS", 32_767).to_i
14
+ DEFAULT_TEMPERATURE = ENV.fetch("GEMSKILL_TEMPERATURE", 0.2).to_f
15
+ MAX_SOURCE_CHARS = 60_000 # guard against enormous READMEs blowing the context window
10
16
 
11
17
  SYSTEM_INSTRUCTIONS = <<~SYSTEM
12
18
  You are a Ruby gem documentation specialist who generates Claude Code skill files.
@@ -56,12 +62,15 @@ module Gem::Skill
56
62
  %<sources>s
57
63
  PROMPT
58
64
 
59
- attr_reader :gem_name, :version, :model
65
+ attr_reader :gem_name, :version, :model, :max_tokens, :temperature
60
66
 
61
- def initialize(gem_name, version, model: DEFAULT_MODEL)
62
- @gem_name = gem_name
63
- @version = version
64
- @model = model
67
+ def initialize(gem_name, version, model: DEFAULT_MODEL, max_tokens: MAX_TOKENS, temperature: DEFAULT_TEMPERATURE)
68
+ @gem_name = gem_name
69
+ @version = version
70
+ @model = model
71
+ @model_id, @provider = Gem::Skill.parse_model(model)
72
+ @max_tokens = max_tokens
73
+ @temperature = temperature
65
74
  end
66
75
 
67
76
  # Generate and cache a SKILL.md. Returns the skill content string.
@@ -73,6 +82,7 @@ module Gem::Skill
73
82
  raise Error, "No documentation found for #{gem_name} #{version}" if sources.empty?
74
83
 
75
84
  skill_content = block ? call_llm_streaming(sources, &block) : call_llm(sources)
85
+ skill_content = Frontmatter.build(gem_name, version, skill_content)
76
86
  Cache.store(gem_name, version, skill_content, { sources: sources.keys.map(&:to_s), model: model })
77
87
  skill_content
78
88
  rescue RubyLLM::Error => e
@@ -100,18 +110,41 @@ module Gem::Skill
100
110
  strip_wrapper_fence(content)
101
111
  end
102
112
 
103
- # Removes a leading ```markdown (or ```) fence and its closing ```.
104
- # Belt-and-suspenders: the prompt instructs the model not to wrap,
105
- # but some models do it anyway.
113
+ # Removes a ```markdown (or ```) fence that wraps the ENTIRE document.
114
+ # Belt-and-suspenders: the prompt instructs the model not to wrap, but some
115
+ # models do it anyway. The trailing ``` is only stripped when a matching
116
+ # opening wrapper fence was present — otherwise a skill that legitimately
117
+ # ends with a code block would lose its closing fence and leave the block
118
+ # open.
106
119
  def strip_wrapper_fence(content)
107
- content
108
- .sub(/\A\s*```(?:markdown)?\s*\n/, "")
120
+ stripped = content.strip
121
+ return stripped unless stripped.match?(/\A```(?:markdown)?\s*\n/)
122
+
123
+ stripped
124
+ .sub(/\A```(?:markdown)?\s*\n/, "")
109
125
  .sub(/\n```\s*\z/, "")
110
126
  .strip
111
127
  end
112
128
 
113
129
  def build_chat
114
- RubyLLM.chat(model: model).with_instructions(SYSTEM_INSTRUCTIONS)
130
+ # with_max_output_tokens (ruby_llm >= 2.0) maps to the right request
131
+ # parameter per provider (max_tokens vs max_completion_tokens).
132
+ chat = RubyLLM.chat(model: @model_id, provider: @provider).with_max_output_tokens(@max_tokens)
133
+ chat = chat.with_temperature(@temperature) if temperature_supported?
134
+ chat.with_instructions(SYSTEM_INSTRUCTIONS)
135
+ end
136
+
137
+ def model_info
138
+ @model_info ||= RubyLLM.models.find(@model_id, provider: @provider)
139
+ end
140
+
141
+ # Reasoning models (e.g. gpt-5.5) reject a temperature parameter outright.
142
+ # Only set it for models whose metadata doesn't explicitly mark temperature
143
+ # unsupported; treat absent metadata as supported.
144
+ def temperature_supported?
145
+ return false if @temperature.nil?
146
+
147
+ model_info.metadata.fetch(:temperature, true) != false
115
148
  end
116
149
 
117
150
  def format_prompt(sources)
@@ -3,12 +3,26 @@
3
3
  require "fileutils"
4
4
 
5
5
  module Gem::Skill
6
- # Manages .claude/skills/ symlinks in a project, pointing to ~/.gem/skills cache.
6
+ # Manages per-project skill symlinks pointing into the ~/.gem/skills cache.
7
7
  # Each symlink is a directory link: <gem_name> -> ~/.gem/skills/<gem>/<version>/
8
- # Claude Code discovers skills by reading SKILL.md inside each linked directory.
8
+ # The assistant discovers skills by reading SKILL.md inside each linked directory.
9
+ #
10
+ # The project-relative directory is configurable via GEMSKILL_PROJECT_DIR
11
+ # (default ".claude/skills" for Claude Code). Codex users might set it to
12
+ # ".agents" or ".codex"; see the configuration docs.
9
13
  module Linker
14
+ DEFAULT_PROJECT_DIR = ".claude/skills"
15
+
16
+ # Project-relative directory where skill symlinks are written. Read from the
17
+ # environment each call so a changed GEMSKILL_PROJECT_DIR takes effect without
18
+ # reloading.
19
+ def self.project_dir
20
+ value = ENV.fetch("GEMSKILL_PROJECT_DIR", DEFAULT_PROJECT_DIR).to_s.strip
21
+ value.empty? ? DEFAULT_PROJECT_DIR : value
22
+ end
23
+
10
24
  def self.skills_dir(project_root = Dir.pwd)
11
- File.join(project_root, ".claude", "skills")
25
+ File.join(project_root, project_dir)
12
26
  end
13
27
 
14
28
  def self.link(gem_name, version, project_root = Dir.pwd)
@@ -1,24 +1,90 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Gem::Skill
4
6
  # Core install logic shared by gem_command and bundle_command.
5
7
  # Callers are responsible for spinner.auto_spin and title setup before calling.
6
8
  module Runner
7
- # Generate + cache + link one skill.
8
- # Returns nil on success, error message string on failure.
9
- def self.install_skill(gem_name, version, spinner, force:, model:)
9
+ # error: nil on success, message string on failure
10
+ # verify_fixed: true when --verify ran and corrected the skill
11
+ Result = Data.define(:error, :verify_fixed) do
12
+ def ok? = error.nil?
13
+
14
+ def self.failure(message) = new(error: message, verify_fixed: false)
15
+ def self.success(verify_fixed: false) = new(error: nil, verify_fixed: verify_fixed)
16
+ end
17
+
18
+ # Generate + cache + link one skill, optionally verifying it against source.
19
+ # Returns a Runner::Result.
20
+ def self.install_skill(gem_name, version, spinner, force:, model:, verify: false,
21
+ max_tokens: Generator::MAX_TOKENS, temperature: Generator::DEFAULT_TEMPERATURE)
10
22
  if Cache.cached?(gem_name, version) && !force
11
23
  Linker.link(gem_name, version)
12
- spinner.success("already cached")
13
- return nil
24
+ content = Cache.read(gem_name, version)
25
+ return finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "already cached")
14
26
  end
15
- Generator.new(gem_name, version, model: model).generate(force: force)
27
+
28
+ # Stream and discard chunks: a non-streaming request sends nothing over
29
+ # the socket until generation completes, so a slow local model (reasoning
30
+ # models especially) trips Net::ReadTimeout regardless of how high the
31
+ # timeout is set. Streaming keeps bytes flowing between chunks.
32
+ generator = Generator.new(gem_name, version, model: model, max_tokens: max_tokens, temperature: temperature)
33
+ content = generator.generate(force: force) { |_chunk| }
16
34
  Linker.link(gem_name, version)
17
- spinner.success("done")
18
- nil
35
+ finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "done")
19
36
  rescue => e
20
37
  spinner.error("failed")
21
- e.message
38
+ Result.failure(e.message)
39
+ end
40
+
41
+ # Run the optional verify pass and settle the spinner + metadata.
42
+ def self.finalize(gem_name, version, content, spinner, model:, verify:, status:)
43
+ unless verify
44
+ spinner.success(status)
45
+ return Result.success
46
+ end
47
+
48
+ result = Verifier.new(gem_name, version, model: model).verify(content)
49
+
50
+ unless result.verifiable
51
+ Cache.merge_metadata(gem_name, version, verification: {
52
+ verified: false,
53
+ verified_at: Time.now.iso8601,
54
+ model: model,
55
+ skipped_reason: "no installed source available"
56
+ })
57
+ spinner.success("#{status} (no source to verify)")
58
+ return Result.success
59
+ end
60
+
61
+ Cache.write_skill(gem_name, version, result.content) if result.changed?
62
+ Cache.merge_metadata(gem_name, version, verification: verification_metadata(result))
63
+
64
+ if result.changed?
65
+ spinner.success("verified — fixed")
66
+ Result.success(verify_fixed: true)
67
+ else
68
+ spinner.success("verified — ok")
69
+ Result.success
70
+ end
71
+ rescue => e
72
+ spinner.error("verify failed")
73
+ Result.failure(e.message)
74
+ end
75
+ private_class_method :finalize
76
+
77
+ # Records that the skill was verified against real source and whether that
78
+ # verification changed anything. Intentionally minimal — the itemized list of
79
+ # what changed is not retained.
80
+ def self.verification_metadata(result)
81
+ {
82
+ verified: true,
83
+ verified_at: Time.now.iso8601,
84
+ model: result.model,
85
+ fixed: result.changed?
86
+ }
22
87
  end
88
+ private_class_method :verification_metadata
23
89
  end
24
90
  end