gem-skill 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -73,6 +73,7 @@ module Gem::Skill
73
73
  raise Error, "No documentation found for #{gem_name} #{version}" if sources.empty?
74
74
 
75
75
  skill_content = block ? call_llm_streaming(sources, &block) : call_llm(sources)
76
+ skill_content = Frontmatter.build(gem_name, version, skill_content)
76
77
  Cache.store(gem_name, version, skill_content, { sources: sources.keys.map(&:to_s), model: model })
77
78
  skill_content
78
79
  rescue RubyLLM::Error => e
@@ -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,84 @@
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)
10
21
  if Cache.cached?(gem_name, version) && !force
11
22
  Linker.link(gem_name, version)
12
- spinner.success("already cached")
13
- return nil
23
+ content = Cache.read(gem_name, version)
24
+ return finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "already cached")
14
25
  end
15
- Generator.new(gem_name, version, model: model).generate(force: force)
26
+
27
+ content = Generator.new(gem_name, version, model: model).generate(force: force)
16
28
  Linker.link(gem_name, version)
17
- spinner.success("done")
18
- nil
29
+ finalize(gem_name, version, content, spinner, model: model, verify: verify, status: "done")
19
30
  rescue => e
20
31
  spinner.error("failed")
21
- e.message
32
+ Result.failure(e.message)
33
+ end
34
+
35
+ # Run the optional verify pass and settle the spinner + metadata.
36
+ def self.finalize(gem_name, version, content, spinner, model:, verify:, status:)
37
+ unless verify
38
+ spinner.success(status)
39
+ return Result.success
40
+ end
41
+
42
+ result = Verifier.new(gem_name, version, model: model).verify(content)
43
+
44
+ unless result.verifiable
45
+ Cache.merge_metadata(gem_name, version, verification: {
46
+ verified: false,
47
+ verified_at: Time.now.iso8601,
48
+ model: model,
49
+ skipped_reason: "no installed source available"
50
+ })
51
+ spinner.success("#{status} (no source to verify)")
52
+ return Result.success
53
+ end
54
+
55
+ Cache.write_skill(gem_name, version, result.content) if result.changed?
56
+ Cache.merge_metadata(gem_name, version, verification: verification_metadata(result))
57
+
58
+ if result.changed?
59
+ spinner.success("verified — fixed")
60
+ Result.success(verify_fixed: true)
61
+ else
62
+ spinner.success("verified — ok")
63
+ Result.success
64
+ end
65
+ rescue => e
66
+ spinner.error("verify failed")
67
+ Result.failure(e.message)
68
+ end
69
+ private_class_method :finalize
70
+
71
+ # Records that the skill was verified against real source and whether that
72
+ # verification changed anything. Intentionally minimal — the itemized list of
73
+ # what changed is not retained.
74
+ def self.verification_metadata(result)
75
+ {
76
+ verified: true,
77
+ verified_at: Time.now.iso8601,
78
+ model: result.model,
79
+ fixed: result.changed?
80
+ }
22
81
  end
82
+ private_class_method :verification_metadata
23
83
  end
24
84
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Gem::Skill
6
+ # Second-pass quality gate for a generated SKILL.md.
7
+ #
8
+ # Generation synthesizes prose sources (README, changelog, examples) which are
9
+ # frequently wrong or stale about exact signatures. The verifier re-checks the
10
+ # generated skill against the gem's ACTUAL source code — the only source of
11
+ # truth — and corrects mismatched method signatures, default argument values,
12
+ # visibility, return values, and behavioral claims.
13
+ #
14
+ # Whether the skill actually changed is decided by a deterministic diff of the
15
+ # content before and after, not by trusting the model's self-report, so callers
16
+ # can rely on #changed? for an exit code and a "fixed" flag.
17
+ class Verifier
18
+ BEGIN_MARK = "===BEGIN SKILL==="
19
+ END_MARK = "===END SKILL==="
20
+
21
+ SYSTEM_INSTRUCTIONS = <<~SYSTEM
22
+ You verify a generated Claude Code SKILL.md for a Ruby gem against the gem's
23
+ ACTUAL SOURCE CODE. The source code is the only source of truth. READMEs,
24
+ changelogs, and docstrings are frequently stale or wrong; when the SKILL.md
25
+ disagrees with the source, the source always wins.
26
+
27
+ Check every concrete claim against the source: method signatures, default
28
+ argument values, keyword vs positional arguments, public/private/protected
29
+ visibility, return values, constant and class/module names, default option
30
+ values, and described runtime behavior (including what arguments a yielded
31
+ block actually receives). Correct anything the source contradicts.
32
+
33
+ Rules:
34
+ - Do NOT invent APIs, methods, or options that are absent from the source.
35
+ - Do NOT restructure, re-style, or "improve" content that is already correct.
36
+ Preserve correct text verbatim so the diff stays minimal.
37
+ - Only change what the source proves is wrong.
38
+ SYSTEM
39
+
40
+ PROMPT = <<~PROMPT
41
+ Verify the SKILL.md below for "%<gem_name>s" v%<version>s against the gem's
42
+ source code. Correct every claim the source contradicts.
43
+
44
+ Output ONLY the full corrected SKILL.md in raw Markdown (even if you change
45
+ nothing), wrapped exactly between these marker lines and with no other text:
46
+ %<begin_mark>s
47
+ <corrected SKILL.md here>
48
+ %<end_mark>s
49
+
50
+ ============================================================
51
+ CURRENT SKILL.md
52
+ ============================================================
53
+
54
+ %<skill>s
55
+
56
+ ============================================================
57
+ GEM SOURCE CODE (ground truth)
58
+ ============================================================
59
+
60
+ %<source>s
61
+ PROMPT
62
+
63
+ # content: the (possibly corrected) skill markdown
64
+ # changed: true iff content differs from the original (diff-based)
65
+ # verifiable: false when no source was available to check against
66
+ # model: the model used for verification
67
+ Result = Data.define(:content, :changed, :verifiable, :model) do
68
+ def changed? = changed
69
+ end
70
+
71
+ attr_reader :gem_name, :version, :model
72
+
73
+ def initialize(gem_name, version, model: Generator::DEFAULT_MODEL)
74
+ @gem_name = gem_name
75
+ @version = version
76
+ @model = model
77
+ end
78
+
79
+ # Verify skill_content against the gem source. Returns a Result.
80
+ def verify(skill_content)
81
+ fetcher = Fetcher.new(gem_name, version)
82
+ source = fetcher.source_code
83
+ if source.nil? || source.strip.empty?
84
+ return Result.new(content: skill_content, changed: false, verifiable: false, model: model)
85
+ end
86
+
87
+ raw = build_chat.ask(format_prompt(skill_content, source)).content.to_s
88
+ # Re-apply frontmatter to both sides so the diff compares like-for-like and
89
+ # the stored skill always keeps valid frontmatter, even if the model dropped it.
90
+ original = Frontmatter.build(gem_name, version, skill_content)
91
+ corrected = Frontmatter.build(gem_name, version, extract_skill(raw, skill_content))
92
+ changed = normalize(corrected) != normalize(original)
93
+
94
+ Result.new(content: (changed ? corrected : skill_content), changed: changed,
95
+ verifiable: true, model: model)
96
+ rescue RubyLLM::Error => e
97
+ raise Error, e.message
98
+ end
99
+
100
+ private
101
+
102
+ def build_chat
103
+ RubyLLM.chat(model: model).with_instructions(SYSTEM_INSTRUCTIONS)
104
+ end
105
+
106
+ def format_prompt(skill_content, source)
107
+ format(
108
+ PROMPT,
109
+ gem_name: gem_name,
110
+ version: version,
111
+ begin_mark: BEGIN_MARK,
112
+ end_mark: END_MARK,
113
+ skill: skill_content,
114
+ source: source
115
+ )
116
+ end
117
+
118
+ # Pull the corrected skill out from between the markers. If the model didn't
119
+ # honor the protocol, fall back to the original so we never corrupt the cache.
120
+ def extract_skill(raw, fallback)
121
+ start = raw.index(BEGIN_MARK)
122
+ return fallback unless start
123
+
124
+ body = raw[(start + BEGIN_MARK.length)..]
125
+ stop = body.index(END_MARK)
126
+ body = body[0...stop] if stop
127
+
128
+ body = body.to_s.strip
129
+ body.empty? ? fallback : body
130
+ end
131
+
132
+ def normalize(text)
133
+ text.to_s.gsub(/[ \t]+$/, "").strip
134
+ end
135
+ end
136
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gem::Skill
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/gem/skill.rb CHANGED
@@ -3,7 +3,9 @@
3
3
  require_relative "skill/version"
4
4
  require_relative "skill/cache"
5
5
  require_relative "skill/fetcher"
6
+ require_relative "skill/frontmatter"
6
7
  require_relative "skill/generator"
8
+ require_relative "skill/verifier"
7
9
  require_relative "skill/linker"
8
10
  require_relative "skill/lockfile"
9
11
  require_relative "skill/runner"
@@ -11,6 +13,16 @@ require_relative "skill/runner"
11
13
  module Gem::Skill
12
14
  class Error < StandardError; end
13
15
 
16
+ # Exit status used when --verify found and corrected mistakes in a generated
17
+ # skill (grep-style: 0 = clean, 1 = error, 2 = verify applied fixes).
18
+ EXIT_VERIFY_FIXED = 2
19
+
20
+ # The bundled "router" skill that teaches assistants how to find cached gem
21
+ # skills in ~/.gem/skills. `gem skill setup` copies it into the assistants'
22
+ # default skill roots. Lives at the repo/gem root, beside README/CHANGELOG.
23
+ ROUTER_SKILL_NAME = "ruby-gem-skills"
24
+ ROUTER_SKILL_DIR = File.expand_path("../../#{ROUTER_SKILL_NAME}", __dir__)
25
+
14
26
  ENV_KEY_MAP = {
15
27
  anthropic_api_key: "ANTHROPIC_API_KEY",
16
28
  openai_api_key: "OPENAI_API_KEY",
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: ruby-gem-skills
3
+ description: "Locate and load gem-skill's cached, version-specific SKILL.md for a Ruby gem. Use whenever working with, debugging, or writing Ruby code that uses a third-party gem (Bundler/Gemfile projects or installed gems) to get accurate, version-pinned API knowledge before relying on memory."
4
+ ---
5
+
6
+ # ruby-gem-skills
7
+
8
+ Detailed, version-specific knowledge for installed Ruby gems is generated by the
9
+ `gem-skill` tool and cached on this machine at:
10
+
11
+ ```
12
+ ~/.gem/skills/<gem_name>/<version>/SKILL.md
13
+ ```
14
+
15
+ (Use `$GEMSKILL_DIR` instead of `~/.gem/skills` if that variable is set.)
16
+
17
+ Most assistants do not scan that directory by default, so follow this procedure
18
+ to find and load a gem's skill before answering from memory.
19
+
20
+ ## When to use
21
+
22
+ - Writing or debugging Ruby code that calls a third-party gem's API.
23
+ - Verifying exact method signatures, options, defaults, or behavior for a
24
+ specific gem version.
25
+
26
+ ## How to load a gem's skill
27
+
28
+ 1. Determine the gem version in use:
29
+ - In a project with a `Gemfile.lock`, read the locked version for the gem.
30
+ - Otherwise run `gem list <gem_name>` (or `bundle show <gem_name>`) to find
31
+ the installed version.
32
+ 2. Read the cached skill for that exact version:
33
+ ```
34
+ ~/.gem/skills/<gem_name>/<version>/SKILL.md
35
+ ```
36
+ 3. If it exists, treat its contents as the authoritative, version-pinned
37
+ reference for that gem and prefer it over training memory. Skills are
38
+ version-specific — always match the version actually in use.
39
+
40
+ ## If the skill is missing
41
+
42
+ Tell the user how to generate it:
43
+
44
+ - One gem: `gem skill install <gem_name>`
45
+ - Every gem in a project (from the project root): `bundle skill install`
46
+ - Verify an existing skill against the gem's real source: `gem skill verify <gem_name>`
47
+
48
+ ## Notes
49
+
50
+ - A `metadata.json` sits beside each `SKILL.md` with provenance (model, sources,
51
+ and verification status). It is for tooling and humans, not required reading.
52
+ - A green checkmark next to a version in `gem skill list` means that skill was
53
+ verified against the gem's actual source code.
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.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -82,14 +82,17 @@ files:
82
82
  - lib/gem/skill/cli/bundle_command.rb
83
83
  - lib/gem/skill/cli/gem_command.rb
84
84
  - lib/gem/skill/fetcher.rb
85
+ - lib/gem/skill/frontmatter.rb
85
86
  - lib/gem/skill/generator.rb
86
87
  - lib/gem/skill/linker.rb
87
88
  - lib/gem/skill/lockfile.rb
88
89
  - lib/gem/skill/runner.rb
90
+ - lib/gem/skill/verifier.rb
89
91
  - lib/gem/skill/version.rb
90
92
  - lib/rubygems_plugin.rb
91
93
  - mkdocs.yml
92
94
  - plugins.rb
95
+ - ruby-gem-skills/SKILL.md
93
96
  - scripts/e2e_test
94
97
  - sig/gem/skill.rbs
95
98
  homepage: https://github.com/madbomber/gem-skill