gem-skill 0.1.1 → 0.1.3

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.
@@ -21,7 +21,9 @@ module Gem::Skill
21
21
  Output raw Markdown directly. Do NOT wrap the output in a code fence or any
22
22
  other container — the file is Markdown, so no ```markdown wrapper.
23
23
 
24
- Begin immediately with the first section heading. Use exactly these sections:
24
+ Begin with a top-level heading identifying the gem and version, then use exactly these sections:
25
+
26
+ # %<gem_name>s v%<version>s
25
27
 
26
28
  ## Overview
27
29
  One paragraph: what the gem does and when to reach for it.
@@ -4,26 +4,28 @@ require "fileutils"
4
4
 
5
5
  module Gem::Skill
6
6
  # Manages .claude/skills/ symlinks in a project, pointing to ~/.gem/skills cache.
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.
7
9
  module Linker
8
10
  def self.skills_dir(project_root = Dir.pwd)
9
11
  File.join(project_root, ".claude", "skills")
10
12
  end
11
13
 
12
14
  def self.link(gem_name, version, project_root = Dir.pwd)
13
- target = Cache.skill_path(gem_name, version)
15
+ target_dir = File.dirname(Cache.skill_path(gem_name, version))
14
16
  raise Error, "No cached skill for #{gem_name} #{version}. Run: gem skill install #{gem_name}" \
15
- unless File.exist?(target)
17
+ unless File.exist?(Cache.skill_path(gem_name, version))
16
18
 
17
19
  dir = skills_dir(project_root)
18
20
  FileUtils.mkdir_p(dir)
19
21
 
20
- link_path = File.join(dir, "#{gem_name}.md")
22
+ link_path = File.join(dir, gem_name)
21
23
  File.unlink(link_path) if File.symlink?(link_path)
22
- File.symlink(target, link_path)
24
+ File.symlink(target_dir, link_path)
23
25
  end
24
26
 
25
27
  def self.unlink(gem_name, project_root = Dir.pwd)
26
- link_path = File.join(skills_dir(project_root), "#{gem_name}.md")
28
+ link_path = File.join(skills_dir(project_root), gem_name)
27
29
  File.unlink(link_path) if File.symlink?(link_path)
28
30
  end
29
31
 
@@ -31,13 +33,14 @@ module Gem::Skill
31
33
  dir = skills_dir(project_root)
32
34
  return [] unless Dir.exist?(dir)
33
35
 
34
- Dir.glob(File.join(dir, "*.md")).filter_map do |path|
36
+ Dir.glob(File.join(dir, "*")).filter_map do |path|
35
37
  next unless File.symlink?(path)
36
38
 
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) }
39
+ gem_name = File.basename(path)
40
+ target_dir = File.readlink(path)
41
+ version = target_dir.match(%r{/([^/]+)$})&.captures&.first
42
+ skill_file = File.join(target_dir, "SKILL.md")
43
+ { gem_name: gem_name, version: version, target: target_dir, valid: File.exist?(skill_file) }
41
44
  end
42
45
  end
43
46
 
@@ -11,9 +11,9 @@ module Gem::Skill
11
11
  end
12
12
 
13
13
  def self.parse(content)
14
- specs = parse_specs(content)
15
- direct = parse_direct_names(content)
16
- direct.each_with_object({}) do |name, hash|
14
+ specs = parse_specs(content)
15
+ direct = parse_direct_names(content) + parse_path_dep_names(content)
16
+ direct.uniq.each_with_object({}) do |name, hash|
17
17
  hash[name] = specs[name] if specs.key?(name)
18
18
  end
19
19
  end
@@ -36,6 +36,33 @@ module Gem::Skill
36
36
  specs
37
37
  end
38
38
 
39
+ def self.parse_path_dep_names(content)
40
+ in_path = false
41
+ in_specs = false
42
+ in_gem = false
43
+ names = []
44
+
45
+ content.each_line do |line|
46
+ if line.strip == "PATH"
47
+ in_path = true
48
+ in_specs = false
49
+ in_gem = false
50
+ elsif in_path && line.strip == "specs:"
51
+ in_specs = true
52
+ elsif in_path && in_specs && line =~ /\A {4}\S/
53
+ in_gem = true
54
+ elsif in_path && in_specs && in_gem && line =~ /\A {6}(\S+)/
55
+ names << Regexp.last_match(1)
56
+ elsif in_path && !line.start_with?(" ") && !line.strip.empty?
57
+ in_path = false
58
+ in_specs = false
59
+ in_gem = false
60
+ end
61
+ end
62
+
63
+ names
64
+ end
65
+
39
66
  def self.parse_direct_names(content)
40
67
  in_deps = false
41
68
  names = []
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem::Skill
4
+ # Core install logic shared by gem_command and bundle_command.
5
+ # Callers are responsible for spinner.auto_spin and title setup before calling.
6
+ 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:)
10
+ if Cache.cached?(gem_name, version) && !force
11
+ Linker.link(gem_name, version)
12
+ spinner.success("already cached")
13
+ return nil
14
+ end
15
+ Generator.new(gem_name, version, model: model).generate(force: force)
16
+ Linker.link(gem_name, version)
17
+ spinner.success("done")
18
+ nil
19
+ rescue => e
20
+ spinner.error("failed")
21
+ e.message
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gem::Skill
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/gem/skill.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "skill/fetcher"
6
6
  require_relative "skill/generator"
7
7
  require_relative "skill/linker"
8
8
  require_relative "skill/lockfile"
9
+ require_relative "skill/runner"
9
10
 
10
11
  module Gem::Skill
11
12
  class Error < StandardError; end
data/mkdocs.yml ADDED
@@ -0,0 +1,115 @@
1
+ site_name: gem-skill
2
+ site_description: Generate and manage Claude Code AI skills from Ruby gem documentation
3
+ site_author: Dewayne VanHoozer
4
+ site_url: https://madbomber.github.io/gem-skill
5
+ copyright: Copyright &copy; 2026 Dewayne VanHoozer
6
+
7
+ repo_name: madbomber/gem-skill
8
+ repo_url: https://github.com/madbomber/gem-skill
9
+ edit_uri: edit/main/docs/
10
+
11
+ theme:
12
+ name: material
13
+
14
+ palette:
15
+ - scheme: default
16
+ primary: deep purple
17
+ accent: purple
18
+ toggle:
19
+ icon: material/brightness-7
20
+ name: Switch to dark mode
21
+ - scheme: slate
22
+ primary: deep purple
23
+ accent: purple
24
+ toggle:
25
+ icon: material/brightness-4
26
+ name: Switch to light mode
27
+
28
+ font:
29
+ text: Roboto
30
+ code: Roboto Mono
31
+
32
+ features:
33
+ - navigation.instant
34
+ - navigation.tracking
35
+ - navigation.tabs
36
+ - navigation.tabs.sticky
37
+ - navigation.sections
38
+ - navigation.path
39
+ - navigation.indexes
40
+ - navigation.top
41
+ - toc.follow
42
+ - search.suggest
43
+ - search.highlight
44
+ - search.share
45
+ - header.autohide
46
+ - content.code.copy
47
+ - content.code.annotate
48
+ - content.tabs.link
49
+ - content.tooltips
50
+ - content.action.edit
51
+ - content.action.view
52
+
53
+ plugins:
54
+ - search:
55
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
56
+
57
+ markdown_extensions:
58
+ - abbr
59
+ - admonition
60
+ - attr_list
61
+ - def_list
62
+ - footnotes
63
+ - md_in_html
64
+ - toc:
65
+ permalink: true
66
+ title: On this page
67
+ - pymdownx.betterem:
68
+ smart_enable: all
69
+ - pymdownx.caret
70
+ - pymdownx.details
71
+ - pymdownx.emoji:
72
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
73
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
74
+ - pymdownx.highlight:
75
+ anchor_linenums: true
76
+ line_spans: __span
77
+ pygments_lang_class: true
78
+ - pymdownx.inlinehilite
79
+ - pymdownx.keys
80
+ - pymdownx.magiclink:
81
+ repo_url_shorthand: true
82
+ user: madbomber
83
+ repo: gem-skill
84
+ - pymdownx.mark
85
+ - pymdownx.smartsymbols
86
+ - pymdownx.superfences:
87
+ custom_fences:
88
+ - name: mermaid
89
+ class: mermaid
90
+ format: !!python/name:pymdownx.superfences.fence_code_format
91
+ - pymdownx.tabbed:
92
+ alternate_style: true
93
+ - pymdownx.tasklist:
94
+ custom_checkbox: true
95
+ - pymdownx.tilde
96
+
97
+ extra:
98
+ social:
99
+ - icon: fontawesome/brands/github
100
+ link: https://github.com/madbomber/gem-skill
101
+ name: gem-skill on GitHub
102
+ - icon: fontawesome/solid/gem
103
+ link: https://rubygems.org/gems/gem-skill
104
+ name: gem-skill on RubyGems
105
+
106
+ nav:
107
+ - Home: index.md
108
+ - Installation: installation.md
109
+ - Configuration: configuration.md
110
+ - Commands:
111
+ - gem skill: commands/gem-skill.md
112
+ - bundle skill: commands/bundle-skill.md
113
+ - How It Works: how-it-works.md
114
+ - Cache: cache.md
115
+ - Skill Files: skill-files.md
data/scripts/e2e_test CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
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]]
4
+ # End-to-end test: full pipeline for one gem — fetch, generate, cache, link.
5
+ # Usage: bundle exec ruby scripts/e2e_test [GEM_NAME [VERSION [MODEL]]]
6
6
 
7
7
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
8
8
  require "gem/skill"
9
- require "ruby_llm"
9
+ require "gem/skill/runner"
10
+ require "gem/skill/linker"
11
+ require "async"
12
+ require "tty-spinner"
13
+ require "tmpdir"
10
14
 
11
15
  GEM_NAME = ARGV[0] || "zeitwerk"
12
16
  VERSION = ARGV[1] || Gem::Specification.find_by_name(GEM_NAME)&.version&.to_s
13
- MODEL = ARGV[2] || "gpt-4o-mini"
17
+ MODEL = ARGV[2] || Gem::Skill::Generator::DEFAULT_MODEL
14
18
 
15
19
  abort "Gem '#{GEM_NAME}' not installed. Run: gem install #{GEM_NAME}" unless VERSION
16
20
 
@@ -29,43 +33,73 @@ puts ""
29
33
  puts "[ FETCH ]"
30
34
  fetcher = Gem::Skill::Fetcher.new(GEM_NAME, VERSION)
31
35
  sources = fetcher.fetch_all
36
+ abort "No sources found — nothing to generate from." if sources.empty?
32
37
 
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
38
+ sources.each { |name, content| puts " %-12s %d chars" % [name, content.length] }
40
39
  puts ""
41
40
 
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
41
+ # --- Phase 2: Runner.install_skill via async fiber ---
42
+
43
+ puts "[ RUNNER ] (via Async fiber + TTY spinner)"
44
+ multi = TTY::Spinner::Multi.new("[:spinner] Installing skill", format: :dots, output: $stderr)
45
+ sp = multi.register(" [:spinner] :title")
46
+ sp.update(title: "#{GEM_NAME} #{VERSION}")
47
+
48
+ err = nil
49
+ Async do
50
+ barrier = Async::Barrier.new
51
+ barrier.async do
52
+ sp.auto_spin
53
+ err = Gem::Skill::Runner.install_skill(GEM_NAME, VERSION, sp, force: true, model: MODEL)
54
+ end
55
+ barrier.wait
56
+ ensure
57
+ barrier.stop
52
58
  end
53
59
 
54
- puts ""
55
- puts "-" * 60
60
+ if err
61
+ abort "\nRunner failed: #{err}"
62
+ end
56
63
  puts ""
57
64
 
58
65
  # --- Phase 3: Verify cache ---
59
66
 
60
67
  puts "[ CACHE ]"
61
68
  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
69
+ abort " ERROR: cache file not found at #{cached_path}" unless File.exist?(cached_path)
70
+
71
+ metadata_path = Gem::Skill::Cache.metadata_path(GEM_NAME, VERSION)
72
+ puts " SKILL.md: #{cached_path} (#{File.size(cached_path)} bytes)"
73
+ puts " metadata: #{metadata_path} (#{File.exist?(metadata_path) ? "present" : "MISSING"})"
74
+ puts ""
75
+
76
+ # --- Phase 4: Linker ---
77
+
78
+ puts "[ LINKER ]"
79
+ project_dir = Dir.mktmpdir("gem-skill-e2e")
80
+ begin
81
+ Gem::Skill::Linker.link(GEM_NAME, VERSION, project_dir)
82
+
83
+ link_path = File.join(project_dir, ".claude", "skills", GEM_NAME)
84
+ skill_via_link = File.join(link_path, "SKILL.md")
85
+
86
+ abort " ERROR: symlink not created at #{link_path}" unless File.symlink?(link_path)
87
+ abort " ERROR: symlink does not resolve to a directory" unless File.directory?(link_path)
88
+ abort " ERROR: SKILL.md not accessible through symlink" unless File.exist?(skill_via_link)
89
+
90
+ puts " symlink: #{link_path}"
91
+ puts " -> target: #{File.readlink(link_path)}"
92
+ puts " SKILL.md accessible through symlink: yes"
93
+
94
+ entries = Gem::Skill::Linker.linked_gems(project_dir)
95
+ entry = entries.find { |e| e[:gem_name] == GEM_NAME }
96
+ abort " ERROR: linked_gems did not return #{GEM_NAME}" unless entry
97
+ puts " linked_gems reports: #{entry.inspect}"
98
+ ensure
99
+ FileUtils.rm_rf(project_dir)
68
100
  end
69
101
 
70
102
  puts ""
71
- puts "Done."
103
+ puts "=" * 60
104
+ puts "All phases passed."
105
+ puts "=" * 60
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.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -10,21 +10,21 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: ruby_llm
13
+ name: async
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '2.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.0'
25
+ version: '2.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: thor
27
+ name: ruby_llm
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
@@ -62,10 +62,21 @@ executables: []
62
62
  extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
+ - ".envrc"
66
+ - ".github/workflows/deploy-github-pages.yml"
65
67
  - CHANGELOG.md
66
68
  - LICENSE.txt
67
69
  - README.md
68
70
  - Rakefile
71
+ - docs/assets/images/gem-skill.jpg
72
+ - docs/cache.md
73
+ - docs/commands/bundle-skill.md
74
+ - docs/commands/gem-skill.md
75
+ - docs/configuration.md
76
+ - docs/how-it-works.md
77
+ - docs/index.md
78
+ - docs/installation.md
79
+ - docs/skill-files.md
69
80
  - lib/gem/skill.rb
70
81
  - lib/gem/skill/cache.rb
71
82
  - lib/gem/skill/cli/bundle_command.rb
@@ -74,8 +85,10 @@ files:
74
85
  - lib/gem/skill/generator.rb
75
86
  - lib/gem/skill/linker.rb
76
87
  - lib/gem/skill/lockfile.rb
88
+ - lib/gem/skill/runner.rb
77
89
  - lib/gem/skill/version.rb
78
90
  - lib/rubygems_plugin.rb
91
+ - mkdocs.yml
79
92
  - plugins.rb
80
93
  - scripts/e2e_test
81
94
  - sig/gem/skill.rbs
@@ -85,7 +98,18 @@ licenses:
85
98
  metadata:
86
99
  homepage_uri: https://github.com/madbomber/gem-skill
87
100
  source_code_uri: https://github.com/madbomber/gem-skill
88
- changelog_uri: https://github.com/madbomber/gem-skill/blob/master/CHANGELOG.md
101
+ changelog_uri: https://github.com/madbomber/gem-skill/blob/main/CHANGELOG.md
102
+ post_install_message: |
103
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
104
+ gem-skill installed!
105
+
106
+ 'gem skill install GEM_NAME' is ready to use.
107
+
108
+ To enable 'bundle skill' in your projects, run:
109
+
110
+ gem skill setup
111
+
112
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
89
113
  rdoc_options: []
90
114
  require_paths:
91
115
  - lib