gem-skill 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c425b88d925bd6ef993c1ea04581ffd0e457bebc9137cccf68cf3aacd6e00fde
4
- data.tar.gz: ba39b45df96bc6f76c6b2ffa950a3a2cafbba4b7c0b2bfd94ecbd9cd4b38cd06
3
+ metadata.gz: 3cc3ecc8f770110cc9f8605e149ff2d78c44a388a76add0046c595b01ecbf882
4
+ data.tar.gz: 21ac18bb7bcc6de52ac9987561da30821be843cca322f0b45b8e04ed1a9af3a1
5
5
  SHA512:
6
- metadata.gz: 7b4558c9312730d8d7218951591985d868db5bea5f4282f52702ad01feb5ec381c3cfe5af56cb9b50cfa5c45b58e5dbf54bbefee99d6c36d033598383977c35e
7
- data.tar.gz: 7464f9cd590e6899ad7a4d2b5a464c42c054ce4287502625349f5cb438e3c66e53e4b56ab7291f8730404f9f9f9f4636a7412cf736a40f40e6dce9a94a5ff048
6
+ metadata.gz: 4f6fecb42f713486c2abddd7041c6a7294ed4e0cf07affd97457739fe0bd59c8fb080ee80b672817e9a182ff8359896235f2c2c56f16a54eca9528290a3fd07d
7
+ data.tar.gz: acd79346e7b20fcead6bfff93f4f21aa58f81ac203b3fec5616f2f5f55b6d4337c0919cd01a1790159c7e256ec579c14d8e25c2aaa4cd5d85479fea826c4e3fa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
7
7
 
8
8
  ### Added
9
9
  - Bundled `ruby-gem-skills` "router" skill (shipped at the gem root) that teaches assistants how to find cached gem skills in `~/.gem/skills` — a directory neither Claude Code nor Codex scans by default. `gem skill setup` now also copies this skill into each detected assistant's default root (`~/.claude/skills`, `~/.codex/skills`, `~/.agents/skills`), creating none that don't already exist. `Gem::Skill::ROUTER_SKILL_NAME` / `ROUTER_SKILL_DIR` locate the bundled template.
10
+ - `GEMSKIL_MAX_TOKENS` environment variable (default `32767`) — caps the number of output tokens the LLM may generate per skill file. Raise this if generated `SKILL.md` files are being truncated.
11
+ - `--max-tokens TOKENS` CLI flag on `gem skill install`, `bundle skill install`, and `bundle skill refresh` — overrides `GEMSKIL_MAX_TOKENS` for a single invocation. Provider-correct parameter name is selected automatically (`max_completion_tokens` for OpenAI models, `max_tokens` for all others).
12
+ - `GEMSKILL_TEMPERATURE` environment variable (default `0.2`) — sampling temperature for generation. Lower values produce more consistent, less variable output, appropriate for factual reference docs. Applied only to models that support a temperature parameter; reasoning models (e.g. `gpt-5.5`) report `temperature: false` in their RubyLLM metadata and the value is silently skipped for them.
13
+ - `--temperature TEMP` CLI flag on `gem skill install` — overrides `GEMSKILL_TEMPERATURE` for a single invocation.
14
+
15
+ ### Fixed
16
+ - `strip_wrapper_fence` no longer deletes the closing ``` of a skill that legitimately ends with a code block. The trailing wrapper fence is now stripped only when a matching opening wrapper fence (```` ```markdown ```` / ```` ``` ```` at the very start) was present. Previously the final ``` was removed unconditionally, leaving the last code block open at end-of-file — which looked like the LLM truncating its output but was entirely a post-processing bug. Regenerate affected skills with `gem skill install GEM --force`.
17
+
18
+ ### Changed
19
+ - `Generator::MAX_TOKENS` constant reads from `GEMSKIL_MAX_TOKENS` and is passed via `with_params` to every `RubyLLM.chat` call, covering both streaming and non-streaming paths.
20
+ - `Generator#initialize` now accepts `max_tokens:` and `temperature:` keywords so callers can override the token cap and sampling temperature per-instance without touching the environment.
21
+ - `Generator` sets `with_temperature` only when the model's RubyLLM metadata does not mark temperature unsupported, avoiding API errors on reasoning models.
22
+ - `Runner.install_skill` accepts and forwards `max_tokens:` and `temperature:` to `Generator.new`.
23
+ - `README.md` Configuration section now lists all environment variables (gem-skill-specific and provider API keys) in a single reference table.
24
+ - `docs/configuration.md` documents the new `GEMSKIL_MAX_TOKENS` and `GEMSKILL_TEMPERATURE` variables alongside the existing `GEMSKILL_MODEL` entry.
25
+ - `docs/commands/gem-skill.md` and `docs/commands/bundle-skill.md` option tables updated with `--max-tokens` and `--temperature`.
10
26
 
11
27
  ## [0.2.0] - 2026-06-19
12
28
 
data/CLAUDE.md ADDED
@@ -0,0 +1,63 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this gem does
6
+
7
+ `gem-skill` generates `SKILL.md` files from Ruby gem documentation using an LLM (via RubyLLM), caches them globally at `~/.gem/skills/<gem_name>/<version>/SKILL.md`, and symlinks them into projects as directory links: `.claude/skills/<gem_name>/ → ~/.gem/skills/<gem_name>/<version>/`. It registers two CLI entry points: `gem skill` (global cache management) and `bundle skill` (project-aware, driven by `Gemfile.lock`), plus a `gem install --with-skill` flag.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ bundle install
13
+ bundle exec rake test # run all tests (default task)
14
+ ruby -Ilib:test test/gem/skill/cache_test.rb # run a single test file
15
+ bundle exec ruby scripts/e2e_test [GEM VERSION MODEL] # live LLM end-to-end test (needs a provider API key)
16
+ bin/dev_install # point Bundler's plugin indexes at this source tree so
17
+ # `bundle skill` runs local code (no build/release needed)
18
+ bin/dev_install --reset # restore to the released gem install
19
+ ```
20
+
21
+ Docs site is MkDocs (`mkdocs.yml`, `docs/`), deployed by `.github/workflows/deploy-github-pages.yml`.
22
+
23
+ ## Architecture
24
+
25
+ ### Entry points
26
+
27
+ - `lib/rubygems_plugin.rb` — loaded automatically by RubyGems; registers `gem skill` (`Gem::Commands::SkillCommand`) AND prepends `--with-skill` onto `gem install`, queuing installed gems and generating their skills in threads via a `Gem.post_install` hook + `at_exit`
28
+ - `plugins.rb` — Bundler plugin entry point; registers `bundle skill` via `Gem::Skill::BundlerPlugin` → `Gem::Skill::BundlerCommand`
29
+
30
+ ### Core pipeline (in call order)
31
+
32
+ 1. **`Lockfile`** (`lib/gem/skill/lockfile.rb`) — parses `Gemfile.lock`; extracts direct-dependency name→version pairs by cross-referencing the `DEPENDENCIES` and `specs:` sections
33
+ 2. **`Fetcher`** (`lib/gem/skill/fetcher.rb`) — collects raw docs for a single gem/version in priority order: (1) local `Gem::Specification` gem_dir, (2) RubyGems API JSON, (3) GitHub raw README. Returns `{metadata:, readme:, changelog:}` with only populated keys
34
+ 3. **`Generator`** (`lib/gem/skill/generator.rb`) — formats fetched sources into a prompt, calls RubyLLM, strips any wrapping code fence, runs `Frontmatter.build`, stores result via `Cache`. Supports streaming via block
35
+ 4. **`Frontmatter`** (`lib/gem/skill/frontmatter.rb`) — deterministically (no LLM) builds/replaces the YAML frontmatter (`name` + `description`) that makes a SKILL.md discoverable by both Claude Code and Codex; enforces both assistants' name/description constraints
36
+ 5. **`Cache`** (`lib/gem/skill/cache.rb`) — reads/writes `~/.gem/skills/<name>/<version>/SKILL.md` and `metadata.json`; `merge_metadata` string-normalizes keys
37
+ 6. **`Verifier`** (`lib/gem/skill/verifier.rb`) — optional second LLM pass (`--verify` or `gem skill verify`) that re-checks a generated skill against the gem's actual installed source and corrects wrong signatures/claims. `changed?` is decided by a deterministic content diff, never the model's self-report; unverifiable (no local source) is recorded in metadata, not an error
38
+ 7. **`Linker`** (`lib/gem/skill/linker.rb`) — creates/updates directory symlinks in the project skills dir pointing into the cache; `prune_dead_links` removes broken symlinks after a refresh
39
+
40
+ **`Runner`** (`lib/gem/skill/runner.rb`) is the shared generate+cache+link+verify orchestration used by both CLI commands; it returns a `Runner::Result` (`Data.define`) and drives a TTY spinner passed in by the caller.
41
+
42
+ ### CLI layer
43
+
44
+ - `lib/gem/skill/cli/gem_command.rb` — `Gem::Commands::SkillCommand`; subcommands: `install`, `verify`, `list`, `purge`, `setup`. Multi-gem installs run concurrently with the `async` gem (`Async::Barrier`). `setup` registers the Bundler plugin and copies the bundled router skill (`ruby-gem-skills/SKILL.md` at repo root) into each detected assistant's global skill root
45
+ - `lib/gem/skill/cli/bundle_command.rb` — `Gem::Skill::BundlerCommand`; subcommands: `install`, `refresh`, `list`. `install` = generate + link all direct lockfile gems; `refresh` = skip gems already linked at the correct version. Parses its own flags (`--force`, `--verify`, `--model`, `--max-tokens`, `--temperature`, `--version`)
46
+
47
+ ### LLM configuration
48
+
49
+ `Gem::Skill.configure_llm!` reads provider API keys from environment variables (see `ENV_KEY_MAP` in `lib/gem/skill.rb`) and configures RubyLLM. Called automatically by both CLI entry points. No-op if already configured.
50
+
51
+ ## Key constants & environment variables
52
+
53
+ - `Cache::ROOT` = `~/.gem/skills` (override: `GEMSKILL_DIR`)
54
+ - `Linker::DEFAULT_PROJECT_DIR` = `.claude/skills` (override: `GEMSKILL_PROJECT_DIR`, re-read on every call — e.g. `.agents` for Codex)
55
+ - `Generator::DEFAULT_MODEL` = `"gpt-5.5"` (override: `GEMSKILL_MODEL` or `--model`)
56
+ - `Generator::MAX_TOKENS` = 32,767 (override: `GEMSKIL_MAX_TOKENS` — note the single-L spelling — or `--max-tokens`)
57
+ - `Generator::DEFAULT_TEMPERATURE` = 0.2 (override: `GEMSKILL_TEMPERATURE` or `--temperature`; skipped for models that reject temperature)
58
+ - `Generator::MAX_SOURCE_CHARS` = 60,000 (README truncation guard)
59
+ - `Gem::Skill::EXIT_VERIFY_FIXED` = 2 — grep-style exit status when `--verify` applied fixes (0 = clean, 1 = error)
60
+
61
+ ## Testing
62
+
63
+ Uses Minitest (`Minitest::TestTask`). Unit tests live in `test/gem/skill/`; shared cache-sandboxing helpers in `test/support/cache_helpers.rb`. `scripts/e2e_test` runs a full live pipeline (fetch → generate → cache → link) against a real LLM and requires at least one provider API key.
data/README.md CHANGED
@@ -104,13 +104,22 @@ export GEMINI_API_KEY="..." # or Gemini
104
104
 
105
105
  ## Configuration
106
106
 
107
- Two environment variables control `gem-skill`'s behaviour:
107
+ All environment variables and their defaults:
108
108
 
109
109
  | Variable | Default | Description |
110
110
  |---|---|---|
111
111
  | `GEMSKILL_DIR` | `~/.gem/skills` | Root directory for the global skill cache |
112
112
  | `GEMSKILL_PROJECT_DIR` | `.claude/skills` | Project-relative directory where `bundle skill` writes symlinks |
113
113
  | `GEMSKILL_MODEL` | `gpt-5.5` | LLM model used when generating skills |
114
+ | `GEMSKIL_MAX_TOKENS` | `32767` | Maximum output tokens per generated skill file |
115
+ | `GEMSKILL_TEMPERATURE` | `0.2` | Sampling temperature (skipped for reasoning models that reject it) |
116
+ | `ANTHROPIC_API_KEY` | _(none)_ | API key for Anthropic / Claude models |
117
+ | `OPENAI_API_KEY` | _(none)_ | API key for OpenAI / GPT models |
118
+ | `GEMINI_API_KEY` | _(none)_ | API key for Google Gemini models |
119
+ | `MISTRAL_API_KEY` | _(none)_ | API key for Mistral models |
120
+ | `DEEPSEEK_API_KEY` | _(none)_ | API key for DeepSeek models |
121
+ | `OPENROUTER_API_KEY` | _(none)_ | API key for OpenRouter |
122
+ | `XAI_API_KEY` | _(none)_ | API key for xAI (Grok) models |
114
123
 
115
124
  ```bash
116
125
  # Store skills on a shared drive accessible to all projects
@@ -121,6 +130,9 @@ export GEMSKILL_MODEL="claude-sonnet-4-6"
121
130
 
122
131
  # Codex users: link skills into a Codex project root instead of .claude/skills
123
132
  export GEMSKILL_PROJECT_DIR=".agents" # or ".codex"
133
+
134
+ # Raise the output token cap if generated skill files are being truncated
135
+ export GEMSKIL_MAX_TOKENS=65536
124
136
  ```
125
137
 
126
138
  The `--model` flag on any command overrides `GEMSKILL_MODEL` for that
@@ -46,6 +46,7 @@ bundle skill install [OPTIONS]
46
46
  | `--force` | Regenerate even if skills are already cached |
47
47
  | `--verify` | Verify generated skills against each gem's source and fix mismatches (exit `2` if any fixes applied) |
48
48
  | `--model MODEL` | LLM model to use (overrides `GEMSKILL_MODEL`) |
49
+ | `--max-tokens TOKENS` | Max output tokens per skill (overrides `GEMSKIL_MAX_TOKENS`; default: 32767) |
49
50
  | `--version`, `-v` | Print the installed gem-skill version and exit |
50
51
 
51
52
  **Example:**
@@ -98,6 +99,7 @@ bundle skill refresh [OPTIONS]
98
99
  |------|-------------|
99
100
  | `--force` | Regenerate all skills, even those already at the correct version |
100
101
  | `--model MODEL` | LLM model to use |
102
+ | `--max-tokens TOKENS` | Max output tokens per skill (overrides `GEMSKIL_MAX_TOKENS`; default: 32767) |
101
103
 
102
104
  `refresh` skips gems that are already linked at the correct version (shows
103
105
  `up to date`), regenerates gems whose version changed, and removes dead symlinks
@@ -20,6 +20,8 @@ gem skill install GEM_NAME [GEM_NAME ...]
20
20
  | `--force`, `-f` | Regenerate even if a skill is already cached |
21
21
  | `--verify` | After generating, verify the skill's code against the gem's actual source and fix mismatches |
22
22
  | `--model MODEL`, `-m MODEL` | LLM model to use (overrides `GEMSKILL_MODEL`) |
23
+ | `--max-tokens TOKENS` | Max output tokens for this run (overrides `GEMSKIL_MAX_TOKENS`; default: 32767) |
24
+ | `--temperature TEMP` | Sampling temperature, skipped by reasoning models (overrides `GEMSKILL_TEMPERATURE`; default: 0.2) |
23
25
  | `--version`, `-v` | Print the installed gem-skill version and exit |
24
26
 
25
27
  **Examples:**
@@ -36,6 +38,12 @@ gem skill install rails --force --model claude-opus-4-8
36
38
 
37
39
  # Generate, then verify the result against the gem's source code
38
40
  gem skill install tty-spinner --verify
41
+
42
+ # Raise the token cap to avoid truncated output
43
+ gem skill install rails --max-tokens 65536
44
+
45
+ # Lower the temperature for more reproducible output (temperature-capable models)
46
+ gem skill install faraday --model claude-sonnet-4-6 --temperature 0.0
39
47
  ```
40
48
 
41
49
  If a gem is not installed locally, gem-skill will install it automatically
@@ -74,6 +74,30 @@ A blank or unset value falls back to the `.claude/skills` default.
74
74
  available-skills list or referenced explicitly. See
75
75
  [Using with other assistants](skill-files.md#using-with-other-assistants).
76
76
 
77
+ ### `GEMSKIL_MAX_TOKENS`
78
+
79
+ Controls the maximum number of output tokens the LLM may generate for each skill file.
80
+ Increase this if generated `SKILL.md` files are being truncated.
81
+
82
+ | | |
83
+ |---|---|
84
+ | **Default** | `32767` |
85
+ | **Example** | `export GEMSKIL_MAX_TOKENS=65536` |
86
+
87
+ ### `GEMSKILL_TEMPERATURE`
88
+
89
+ Sampling temperature for generation. Lower values produce more consistent,
90
+ deterministic output — appropriate for factual reference documentation.
91
+
92
+ | | |
93
+ |---|---|
94
+ | **Default** | `0.2` |
95
+ | **Example** | `export GEMSKILL_TEMPERATURE=0.0` |
96
+
97
+ Only applied to models that support a temperature parameter. Reasoning models
98
+ (e.g. `gpt-5.5`) reject it, so the value is silently skipped for them — switch to
99
+ a temperature-supporting model (Claude, Gemini, older GPT) to benefit.
100
+
77
101
  ### `GEMSKILL_MODEL`
78
102
 
79
103
  Controls which LLM model is used when generating skills.
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # gem/skill first — see gem_command.rb: activation must resolve json < 3
4
+ # before a bare `require "json"` can activate a newer version.
5
+ require "gem/skill"
3
6
  require "async"
4
7
  require "fileutils"
5
8
  require "json"
6
9
  require "tty-spinner"
7
- require "gem/skill"
8
10
 
9
11
  module Gem::Skill
10
12
  # Handles `bundle skill SUBCOMMAND` via Bundler's plugin API (plugins.rb).
@@ -43,11 +45,12 @@ module Gem::Skill
43
45
  return
44
46
  end
45
47
 
46
- force = opts[:force]
47
- verify = opts[:verify]
48
- model = opts[:model] || Generator::DEFAULT_MODEL
49
- errors = []
50
- results = []
48
+ force = opts[:force]
49
+ verify = opts[:verify]
50
+ model = opts[:model] || Generator::DEFAULT_MODEL
51
+ max_tokens = opts[:max_tokens] || Generator::MAX_TOKENS
52
+ errors = []
53
+ results = []
51
54
 
52
55
  multi = TTY::Spinner::Multi.new(
53
56
  "[:spinner] Installing skills (#{model})",
@@ -61,7 +64,7 @@ module Gem::Skill
61
64
  sp = multi.register(" [:spinner] :title")
62
65
  sp.update(title: "#{gem_name} #{version}")
63
66
  barrier.async do
64
- result = install_one(gem_name, version, sp, force: force, model: model, verify: verify)
67
+ result = install_one(gem_name, version, sp, force: force, model: model, verify: verify, max_tokens: max_tokens)
65
68
  results << result
66
69
  errors << "#{gem_name} #{version}: #{result.error}" if result.error
67
70
  end
@@ -79,11 +82,12 @@ module Gem::Skill
79
82
  def self.refresh(opts = {})
80
83
  gems = Lockfile.gems
81
84
  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 = []
85
+ force = opts[:force]
86
+ verify = opts[:verify]
87
+ model = opts[:model] || Generator::DEFAULT_MODEL
88
+ max_tokens = opts[:max_tokens] || Generator::MAX_TOKENS
89
+ errors = []
90
+ results = []
87
91
 
88
92
  multi = TTY::Spinner::Multi.new(
89
93
  "[:spinner] Refreshing skills (#{model})",
@@ -102,7 +106,7 @@ module Gem::Skill
102
106
  sp.success("up to date")
103
107
  Runner::Result.success
104
108
  else
105
- install_one(gem_name, version, sp, force: force, model: model, verify: verify)
109
+ install_one(gem_name, version, sp, force: force, model: model, verify: verify, max_tokens: max_tokens)
106
110
  end
107
111
  results << result
108
112
  errors << "#{gem_name} #{version}: #{result.error}" if result.error
@@ -139,9 +143,9 @@ module Gem::Skill
139
143
 
140
144
  # --- private ---
141
145
 
142
- def self.install_one(gem_name, version, spinner, force:, model:, verify: false)
146
+ def self.install_one(gem_name, version, spinner, force:, model:, verify: false, max_tokens: Generator::MAX_TOKENS)
143
147
  spinner.auto_spin
144
- Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify)
148
+ Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify, max_tokens: max_tokens)
145
149
  end
146
150
  private_class_method :install_one
147
151
 
@@ -178,6 +182,8 @@ module Gem::Skill
178
182
  when "--version", "-v" then opts[:version] = true
179
183
  when /\A--model(?:=(.+))?\z/
180
184
  opts[:model] = $1 || args[args.index(arg) + 1]
185
+ when /\A--max-tokens(?:=(.+))?\z/
186
+ opts[:max_tokens] = ($1 || args[args.index(arg) + 1]).to_i
181
187
  else
182
188
  remaining << arg unless opts[:model].nil? && arg !~ /\A--/
183
189
  remaining << arg if arg !~ /\A--/
@@ -198,10 +204,11 @@ module Gem::Skill
198
204
  list Show skills linked in this project
199
205
 
200
206
  Options:
201
- --force Regenerate even if already cached
202
- --verify Verify generated skills against gem source and fix mismatches
203
- --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
204
- --version, -v Print gem-skill version and exit
207
+ --force Regenerate even if already cached
208
+ --verify Verify generated skills against gem source and fix mismatches
209
+ --model MODEL LLM model to use (default: #{Generator::DEFAULT_MODEL})
210
+ --max-tokens TOKENS Max output tokens (overrides GEMSKIL_MAX_TOKENS; default: #{Generator::MAX_TOKENS})
211
+ --version, -v Print gem-skill version and exit
205
212
 
206
213
  Env:
207
214
  GEMSKILL_PROJECT_DIR Project dir for symlinks (default: .claude/skills)
@@ -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.
@@ -19,6 +23,12 @@ class Gem::Commands::SkillCommand < Gem::Command
19
23
  add_option("-m", "--model MODEL", "LLM model to use (default: #{Gem::Skill::Generator::DEFAULT_MODEL})") do |model, o|
20
24
  o[:model] = model
21
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
22
32
  add_option("-v", "--version", "Print gem-skill version and exit") { |_, o| o[:version] = true }
23
33
  end
24
34
 
@@ -80,9 +90,11 @@ class Gem::Commands::SkillCommand < Gem::Command
80
90
  return
81
91
  end
82
92
 
83
- force = options[:force]
84
- verify = options[:verify]
85
- 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
86
98
 
87
99
  multi = TTY::Spinner::Multi.new(
88
100
  "[:spinner] Generating skills (#{model})",
@@ -96,7 +108,7 @@ class Gem::Commands::SkillCommand < Gem::Command
96
108
  gem_names.each do |gem_name|
97
109
  spinner = multi.register(" [:spinner] :title")
98
110
  spinner.update(title: gem_name)
99
- barrier.async { results << install_one(gem_name, spinner: spinner, force: force, model: model, verify: verify) }
111
+ barrier.async { results << install_one(gem_name, spinner: spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature) }
100
112
  end
101
113
  barrier.wait
102
114
  ensure
@@ -112,7 +124,7 @@ class Gem::Commands::SkillCommand < Gem::Command
112
124
  end
113
125
  end
114
126
 
115
- def install_one(gem_name, spinner:, force:, model:, verify: false)
127
+ def install_one(gem_name, spinner:, force:, model:, verify: false, max_tokens: Gem::Skill::Generator::MAX_TOKENS, temperature: Gem::Skill::Generator::DEFAULT_TEMPERATURE)
116
128
  spinner.auto_spin
117
129
  version = resolve_installed_version(gem_name)
118
130
  if version.nil?
@@ -120,7 +132,7 @@ class Gem::Commands::SkillCommand < Gem::Command
120
132
  version = install_gem(gem_name)
121
133
  end
122
134
  spinner.update(title: "#{gem_name} #{version}")
123
- result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify)
135
+ result = Gem::Skill::Runner.install_skill(gem_name, version, spinner, force: force, model: model, verify: verify, max_tokens: max_tokens, temperature: temperature)
124
136
  alert_error "#{gem_name}: #{result.error}" if result.error
125
137
  result
126
138
  rescue Gem::Skill::Error => e
@@ -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.
@@ -101,18 +110,41 @@ module Gem::Skill
101
110
  strip_wrapper_fence(content)
102
111
  end
103
112
 
104
- # Removes a leading ```markdown (or ```) fence and its closing ```.
105
- # Belt-and-suspenders: the prompt instructs the model not to wrap,
106
- # 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.
107
119
  def strip_wrapper_fence(content)
108
- content
109
- .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/, "")
110
125
  .sub(/\n```\s*\z/, "")
111
126
  .strip
112
127
  end
113
128
 
114
129
  def build_chat
115
- 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
116
148
  end
117
149
 
118
150
  def format_prompt(sources)
@@ -17,14 +17,20 @@ module Gem::Skill
17
17
 
18
18
  # Generate + cache + link one skill, optionally verifying it against source.
19
19
  # Returns a Runner::Result.
20
- def self.install_skill(gem_name, version, spinner, force:, model:, verify: false)
20
+ def self.install_skill(gem_name, version, spinner, force:, model:, verify: false,
21
+ max_tokens: Generator::MAX_TOKENS, temperature: Generator::DEFAULT_TEMPERATURE)
21
22
  if Cache.cached?(gem_name, version) && !force
22
23
  Linker.link(gem_name, version)
23
24
  content = Cache.read(gem_name, version)
24
25
  return finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "already cached")
25
26
  end
26
27
 
27
- content = Generator.new(gem_name, version, model: model).generate(force: force)
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| }
28
34
  Linker.link(gem_name, version)
29
35
  finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "done")
30
36
  rescue => e
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
+ require "ruby_llm/providers/lms"
5
+ require "ruby_llm/providers/apfel"
4
6
 
5
7
  module Gem::Skill
6
8
  # Second-pass quality gate for a generated SKILL.md.
@@ -100,7 +102,8 @@ module Gem::Skill
100
102
  private
101
103
 
102
104
  def build_chat
103
- RubyLLM.chat(model: model).with_instructions(SYSTEM_INSTRUCTIONS)
105
+ model_id, provider = Gem::Skill.parse_model(model)
106
+ RubyLLM.chat(model: model_id, provider: provider).with_instructions(SYSTEM_INSTRUCTIONS)
104
107
  end
105
108
 
106
109
  def format_prompt(skill_content, source)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gem::Skill
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/gem/skill.rb CHANGED
@@ -30,14 +30,37 @@ module Gem::Skill
30
30
  mistral_api_key: "MISTRAL_API_KEY",
31
31
  deepseek_api_key: "DEEPSEEK_API_KEY",
32
32
  openrouter_api_key: "OPENROUTER_API_KEY",
33
- xai_api_key: "XAI_API_KEY"
33
+ xai_api_key: "XAI_API_KEY",
34
+ lms_api_base: "LMS_API_BASE",
35
+ lms_api_key: "LMS_API_KEY",
36
+ apfel_api_base: "APFEL_API_BASE",
37
+ apfel_api_key: "APFEL_API_KEY"
34
38
  }.freeze
35
39
 
40
+ # Split a "provider/model" string into [model_id, provider_symbol] when the
41
+ # prefix names a registered RubyLLM provider, e.g.
42
+ # "lms/qwen/qwen3.8-27b" -> ["qwen/qwen3.8-27b", :lms]
43
+ # Otherwise the string is a bare model id: ["gpt-5.5", nil]. A bare id is
44
+ # resolved by RubyLLM's own provider preference, so ids that contain a "/"
45
+ # but don't start with a provider slug (e.g. "qwen/...") pass through intact.
46
+ def self.parse_model(model_string)
47
+ prefix, rest = model_string.to_s.split("/", 2)
48
+ return [model_string, nil] unless rest && RubyLLM::Provider.providers.key?(prefix.to_sym)
49
+
50
+ [rest, prefix.to_sym]
51
+ end
52
+
53
+ # HTTP read timeout in seconds (GEMSKILL_REQUEST_TIMEOUT to override).
54
+ # RubyLLM's default of 300 is tuned for hosted APIs; a local model (lms/apfel)
55
+ # can legitimately take longer than that to finish one skill generation.
56
+ REQUEST_TIMEOUT = ENV.fetch("GEMSKILL_REQUEST_TIMEOUT", 900).to_i
57
+
36
58
  # Configure RubyLLM from environment variables. Called automatically by the
37
59
  # CLI commands so users don't need a separate initializer for standalone use.
38
60
  # No-op if RubyLLM is already configured (e.g. in a Rails app).
39
61
  def self.configure_llm!
40
62
  RubyLLM.configure do |config|
63
+ config.request_timeout = REQUEST_TIMEOUT
41
64
  ENV_KEY_MAP.each do |attr, env_var|
42
65
  value = ENV[env_var]
43
66
  config.public_send(:"#{attr}=", value) if value
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem-skill
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -29,14 +29,42 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.0'
32
+ version: '2.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '1.0'
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ruby_llm-providers-apfel
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ruby_llm-providers-lms
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: tty-spinner
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +93,7 @@ files:
65
93
  - ".envrc"
66
94
  - ".github/workflows/deploy-github-pages.yml"
67
95
  - CHANGELOG.md
96
+ - CLAUDE.md
68
97
  - LICENSE.txt
69
98
  - README.md
70
99
  - Rakefile
@@ -120,14 +149,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
120
149
  requirements:
121
150
  - - ">="
122
151
  - !ruby/object:Gem::Version
123
- version: 3.2.0
152
+ version: '3.4'
124
153
  required_rubygems_version: !ruby/object:Gem::Requirement
125
154
  requirements:
126
155
  - - ">="
127
156
  - !ruby/object:Gem::Version
128
157
  version: '0'
129
158
  requirements: []
130
- rubygems_version: 4.0.14
159
+ rubygems_version: 4.0.21
131
160
  specification_version: 4
132
161
  summary: Generate and manage Claude Code AI skills from Ruby gem documentation.
133
162
  test_files: []