bundler-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: efdb922b385805d47a3b9d255b160810300f0b6e98157213f6a4587d1d2475dc
4
+ data.tar.gz: da6868ab55f898967fe52c7699512898c4a4df2e5ccb7e278cd5882b6c0219f1
5
+ SHA512:
6
+ metadata.gz: f931afeb29f6d9e263b6f9d39ddbbd0ae157fed3bdc9aa45ccacefd6c64d4e0ca3d14732619f91d964dd81c4e5f88c7367bb26a5ebe11da93b82ae91274843d1
7
+ data.tar.gz: cacd1cf9954366a41d0edfeecb13fee38ba865ad4ac8c7b9ae45eb966e44ca8982f5a8e8e1e443c18395475a44ca06b1c1899466b5e5ee7c5aa0c2ee6fff5262
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-06-18
6
+
7
+ ### Added
8
+
9
+ - Initial release of `bundler-skills`.
10
+ - `after-install-all` hook that discovers `skills/*/SKILL.md` in dependency gems
11
+ and symlinks them into agent skill directories after `bundle install`.
12
+ - Multi-agent support: Claude Code (`.claude/skills`), Cursor / Codex / GitHub
13
+ Copilot (`.agents/skills`), detected by marker directories.
14
+ - `gem-<gem>--<skill>` double-hyphen link naming (unambiguous for hyphenated gem
15
+ names).
16
+ - Idempotent linking, stale prune, and `.gitignore` management.
17
+ - Automatic disabling in production / CI (`RAILS_ENV`/`RACK_ENV=production`,
18
+ `CI`, `BUNDLER_SKILLS_DISABLED`); override with `BUNDLER_SKILLS_ENABLED`.
19
+ - `bundle skills` command (`sync` / `list` / `clean`, with `--dry-run`).
20
+ - `bundler-skills.yml` configuration (`enabled`, `agents`, `gitignore`,
21
+ `cleanup`, `recursive`, `include`, `exclude`).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 aki77
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/PROPOSAL.md ADDED
@@ -0,0 +1,83 @@
1
+ # Distributing Agent Skills in gems
2
+
3
+ This document describes how a gem author ships **AI agent skills** so that
4
+ [bundler-skills](README.md) (and, by convention, any compatible tool) can
5
+ discover and link them.
6
+
7
+ ## Convention
8
+
9
+ Put a `skills/` directory at the root of your gem. Each immediate subdirectory
10
+ is one skill and must contain a `SKILL.md` following the
11
+ [Agent Skills](https://code.claude.com/docs/en/skills) format (YAML frontmatter
12
+ with at least `name` and `description`, then Markdown body):
13
+
14
+ ```
15
+ my-gem/
16
+ ├── my-gem.gemspec
17
+ ├── lib/
18
+ │ └── my_gem.rb
19
+ └── skills/
20
+ └── my-gem-helper/
21
+ └── SKILL.md
22
+ ```
23
+
24
+ ```markdown
25
+ ---
26
+ name: my-gem-helper
27
+ description: How to use my-gem's DSL correctly, with common patterns.
28
+ ---
29
+
30
+ # my-gem helper
31
+
32
+ ... instructions for the agent ...
33
+ ```
34
+
35
+ ## Package the skills
36
+
37
+ The `skills/` directory must be part of the published gem, so include it in the
38
+ gemspec `files`:
39
+
40
+ ```ruby
41
+ # my-gem.gemspec
42
+ Gem::Specification.new do |spec|
43
+ # ...
44
+ spec.files += Dir["skills/**/*"]
45
+ end
46
+ ```
47
+
48
+ If your gemspec builds `files` from `git ls-files`, committed skill files are
49
+ included automatically — just make sure they aren't gitignored.
50
+
51
+ ## What consumers get
52
+
53
+ When a project depends on your gem and uses bundler-skills, each skill is
54
+ symlinked into the project's agent directory as:
55
+
56
+ ```
57
+ gem-<your-gem-name>--<skill-name>
58
+ ```
59
+
60
+ For example `skills/my-gem-helper/SKILL.md` in `my-gem` becomes
61
+ `.claude/skills/gem-my-gem--my-gem-helper` (and/or `.agents/skills/...`).
62
+
63
+ The double-hyphen `--` separates the gem name from the skill name. Because
64
+ RubyGems names cannot contain consecutive hyphens, the boundary is unambiguous
65
+ even for gems like `rails-html-sanitizer`.
66
+
67
+ ## Guidelines
68
+
69
+ - **One concern per skill.** Keep each `skills/<name>/` focused; the `name` and
70
+ `description` frontmatter is what an agent uses to decide relevance.
71
+ - **Skill name = directory name.** The subdirectory name becomes the skill name
72
+ in the symlink, so keep it stable and descriptive.
73
+ - **Version together.** The whole point is that skills ship and update with the
74
+ exact version of your gem — treat `SKILL.md` as part of your public surface.
75
+ - **Assets / scripts.** Anything alongside `SKILL.md` in the skill directory is
76
+ linked too (the directory is symlinked as a whole), so reference relative
77
+ files normally.
78
+
79
+ ## Why a Bundler plugin (not RubyGems hooks)
80
+
81
+ RubyGems' `post_install` hooks don't fire during `bundle install`, so a Bundler
82
+ plugin using the `after-install-all` hook is the reliable place to do this. See
83
+ [README.md](README.md) for the consumer-side details.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # bundler-skills
2
+
3
+ A Bundler plugin that auto-symlinks **AI agent skills bundled in your gems**
4
+ into your project after `bundle install`. The Ruby/Bundler counterpart of
5
+ [antfu/skills-npm](https://github.com/antfu/skills-npm).
6
+
7
+ Gems ship `skills/<name>/SKILL.md`; your project links them into the right agent
8
+ directory so the skill version always matches the gem version, and your whole
9
+ team gets them just by running `bundle install`.
10
+
11
+ ## How it works
12
+
13
+ After `bundle install` / `bundle update`, the plugin:
14
+
15
+ 1. Scans your resolved dependency gems for `skills/*/SKILL.md`.
16
+ 2. Detects which agents you use (by marker directories) and symlinks each skill
17
+ into the right place, named `gem-<gem>--<skill>`.
18
+ 3. Adds the generated symlink patterns to `.gitignore` (they are machine-local).
19
+
20
+ It is **disabled automatically in production/CI** — skills are a development-time
21
+ concern.
22
+
23
+ ### Supported agents
24
+
25
+ | Agent | Output directory | Detected when present |
26
+ | -------------- | ----------------- | --------------------- |
27
+ | Claude Code | `.claude/skills/` | `.claude/` |
28
+ | Cursor | `.agents/skills/` | `.cursor/` |
29
+ | Codex | `.agents/skills/` | `.codex/` or `AGENTS.md` |
30
+ | GitHub Copilot | `.agents/skills/` | `.github/` |
31
+
32
+ `.agents/skills/` is the cross-tool standard shared by Cursor / Codex / Copilot;
33
+ Claude Code needs its own `.claude/skills/` because it does not read
34
+ `.agents/skills/` yet. The plugin links into a directory only when that agent's
35
+ marker exists, so nothing is created in projects that don't use these tools.
36
+
37
+ ## Installation
38
+
39
+ Add the plugin to your `Gemfile` (this is the recommended, team-wide way):
40
+
41
+ ```ruby
42
+ # Gemfile
43
+ source "https://rubygems.org"
44
+
45
+ plugin "bundler-skills"
46
+
47
+ gem "some-gem-that-ships-skills"
48
+ ```
49
+
50
+ Then:
51
+
52
+ ```sh
53
+ bundle install
54
+ ```
55
+
56
+ That's it. On install you'll see something like:
57
+
58
+ ```
59
+ [bundler-skills] 3 skill(s) discovered, 3 linked, 0 pruned across 1 dir(s) (agents: claude)
60
+ ```
61
+
62
+ > Alternatively, install it globally with `bundle plugin install bundler-skills`.
63
+ > The `Gemfile` approach is preferred because it propagates to the whole team.
64
+
65
+ ## The `bundle skills` command
66
+
67
+ `bundle install` triggers syncing automatically, but `bundle lock` does **not**
68
+ run plugin hooks ([rubygems#7542](https://github.com/ruby/rubygems/issues/7542)).
69
+ Use the command to sync manually, or to inspect/clean:
70
+
71
+ ```sh
72
+ bundle skills # (or: bundle skills sync) re-create symlinks
73
+ bundle skills list # show discovered skills and target agents (no changes)
74
+ bundle skills clean # remove all gem-*--* symlinks this plugin created
75
+ bundle skills <cmd> --dry-run # show what would change without writing
76
+ ```
77
+
78
+ Unlike the automatic hook, the command always runs (it ignores the
79
+ production/CI guard) since invoking it is an explicit action.
80
+
81
+ ## Configuration
82
+
83
+ All optional. Create `bundler-skills.yml` in your project root:
84
+
85
+ ```yaml
86
+ enabled: # nil (auto) | false (off) | [development] (env list)
87
+ agents: # omit = auto-detect; or list: [claude, cursor]; or "*"
88
+ - claude
89
+ - cursor
90
+ gitignore: true # manage .gitignore (default true)
91
+ cleanup: true # prune stale gem-*--* links when a gem is removed (default true)
92
+ recursive: false # also scan skills/**/SKILL.md (default false)
93
+ include: # only these gems (empty = all). fnmatch on "gem" or "gem/skill"
94
+ - rubocop
95
+ - "rails-*"
96
+ exclude: # exclude these (wins over include)
97
+ - some-noisy-gem
98
+ ```
99
+
100
+ Notes:
101
+
102
+ - Skills from `development`/`test` group gems are included by default — those are
103
+ exactly the gems (linters, test helpers) that ship skills. They are only
104
+ excluded when your environment sets `bundle config set without development`
105
+ (e.g. production), in which case skills aren't wanted anyway.
106
+ - The link target is the gem's path on your machine; that's why the symlinks are
107
+ gitignored and re-created on each machine's `bundle install`.
108
+
109
+ ### Disabling
110
+
111
+ The hook is off automatically when any of these hold:
112
+
113
+ - `BUNDLER_SKILLS_DISABLED` is set to a truthy value
114
+ - `RAILS_ENV` / `RACK_ENV` is `production`
115
+ - `CI` is truthy
116
+ - `bundler-skills.yml` has `enabled: false`, or `enabled: [..]` not listing the
117
+ current env
118
+
119
+ Force it on with `BUNDLER_SKILLS_ENABLED=1` or `enabled: true`.
120
+
121
+ ## Naming: `gem-<gem>--<skill>`
122
+
123
+ The boundary between gem name and skill name is a **double hyphen** (`--`) so a
124
+ gem name that itself contains hyphens stays unambiguous:
125
+
126
+ ```
127
+ gem-rails-html-sanitizer--escaping
128
+ └── gem: rails-html-sanitizer ──┘└ skill: escaping
129
+ ```
130
+
131
+ RubyGems names cannot contain consecutive hyphens, so `--` is always a safe
132
+ delimiter. (This is an intentional improvement over skills-npm's `npm-` naming.)
133
+
134
+ ## For gem authors
135
+
136
+ See [PROPOSAL.md](PROPOSAL.md) for the distribution convention. In short: put
137
+ `skills/<name>/SKILL.md` in your gem and include `skills/` in your gemspec
138
+ `files`.
139
+
140
+ ## Trust boundary
141
+
142
+ Skills bundled in third-party gems are third-party content. This plugin only
143
+ **creates symlinks** to them — it never executes their contents. Reviewing what a
144
+ skill instructs your agent to do is the user's responsibility, the same as
145
+ reviewing any dependency.
146
+
147
+ ## Limitations
148
+
149
+ - POSIX symlinks are assumed; Windows is not supported yet.
150
+ - Whether Cursor / Codex / Copilot follow symlinked `SKILL.md` during their own
151
+ directory scans is not formally documented; verified working with Claude Code.
152
+
153
+ ## Development
154
+
155
+ ```sh
156
+ bundle install
157
+ bundle exec rake test # unit tests
158
+ bundle exec rake integration # real bundle install end-to-end
159
+ ```
160
+
161
+ See [CHANGELOG.md](CHANGELOG.md) for release notes.
162
+
163
+ ## License
164
+
165
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Knows where each supported agent reads project-level skills and how to
5
+ # detect that the agent is in use. Dependency-free: a small internal table,
6
+ # the single place to edit when an agent's convention changes.
7
+ #
8
+ # Output dirs collapse to two in practice:
9
+ # .claude/skills — Claude Code only (does not read .agents/skills yet)
10
+ # .agents/skills — cross-tool standard, shared by Cursor / Codex / Copilot
11
+ module AgentRegistry
12
+ # key : config name (`agents:` entries match this)
13
+ # skills_subdir: output directory relative to the project root
14
+ # markers : any of these existing means "this agent is in use"
15
+ Agent = Struct.new(:key, :skills_subdir, :markers, keyword_init: true)
16
+
17
+ ALL = [
18
+ Agent.new(key: "claude", skills_subdir: ".claude/skills", markers: [".claude"]),
19
+ Agent.new(key: "cursor", skills_subdir: ".agents/skills", markers: [".cursor"]),
20
+ Agent.new(key: "codex", skills_subdir: ".agents/skills", markers: [".codex", "AGENTS.md"]),
21
+ Agent.new(key: "copilot", skills_subdir: ".agents/skills", markers: [".github"])
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ def all
27
+ ALL
28
+ end
29
+
30
+ def find(key)
31
+ ALL.find { |a| a.key == key.to_s }
32
+ end
33
+
34
+ # Agents whose marker exists under root.
35
+ def detect(root)
36
+ ALL.select { |agent| present?(root, agent) }
37
+ end
38
+
39
+ # Resolve the agents to target:
40
+ # config.agents nil -> auto-detect by markers
41
+ # config.agents ["*"] -> all agents
42
+ # config.agents [keys..] -> those keys (unknown keys ignored)
43
+ def resolve(root, config)
44
+ requested = config.agents
45
+ return detect(root) if requested.nil? || requested.empty?
46
+
47
+ keys = Array(requested).map(&:to_s)
48
+ return all if keys.include?("*")
49
+
50
+ keys.filter_map { |key| find(key) }
51
+ end
52
+
53
+ # Distinct output subdirs for the given agents, preserving order.
54
+ def output_subdirs(agents)
55
+ agents.map(&:skills_subdir).uniq
56
+ end
57
+
58
+ def present?(root, agent)
59
+ agent.markers.any? { |m| File.exist?(File.join(root.to_s, m)) }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # `bundle skills [sync|list|clean] [--dry-run]`
5
+ #
6
+ # The manual entry point. Unlike the hook, it ignores the production/CI
7
+ # disabling guard — running it is an explicit user action. It reuses the
8
+ # same Synchronizer logic the hook uses.
9
+ class Command < Bundler::Plugin::API
10
+ command "skills"
11
+
12
+ def exec(_command_name, args)
13
+ dry_run = args.delete("--dry-run") ? true : false
14
+ subcommand = args.shift || "sync"
15
+
16
+ case subcommand
17
+ when "sync" then run_sync(dry_run)
18
+ when "list" then run_list
19
+ when "clean" then run_clean(dry_run)
20
+ when "help", "-h", "--help" then print_help
21
+ else
22
+ Bundler.ui.error("[bundler-skills] unknown subcommand: #{subcommand}")
23
+ print_help
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def synchronizer(dry_run: false)
30
+ config = Config.load
31
+ config = OverrideDryRun.new(config) if dry_run
32
+ Synchronizer.new(config: config)
33
+ end
34
+
35
+ def run_sync(dry_run)
36
+ synchronizer(dry_run: dry_run).sync
37
+ end
38
+
39
+ def run_list
40
+ result = synchronizer.plan
41
+ if result.discovered.empty?
42
+ Bundler.ui.info("[bundler-skills] no skills found in dependency gems")
43
+ return
44
+ end
45
+
46
+ agents = result.agents.map(&:key)
47
+ Bundler.ui.info("[bundler-skills] #{result.discovered.size} skill(s) " \
48
+ "-> agents: #{agents.empty? ? '(none detected)' : agents.join(', ')}")
49
+ result.discovered.sort_by(&:link_name).each do |skill|
50
+ Bundler.ui.info(" #{skill.link_name} -> #{skill.source_path}")
51
+ end
52
+ end
53
+
54
+ def run_clean(dry_run)
55
+ removed = synchronizer(dry_run: dry_run).clean
56
+ total = removed.values.sum(&:size)
57
+ verb = dry_run ? "would remove" : "removed"
58
+ Bundler.ui.info("[bundler-skills] #{verb} #{total} link(s)")
59
+ removed.each do |subdir, names|
60
+ names.each { |n| Bundler.ui.info(" #{subdir}/#{n}") }
61
+ end
62
+ end
63
+
64
+ def print_help
65
+ Bundler.ui.info(<<~HELP)
66
+ Usage: bundle skills [SUBCOMMAND] [--dry-run]
67
+
68
+ sync (default) discover skills and (re)create symlinks
69
+ list show discovered skills and target agents (no changes)
70
+ clean remove all gem-*--* symlinks this plugin created
71
+
72
+ Options:
73
+ --dry-run show what would change without writing
74
+ HELP
75
+ end
76
+
77
+ # Wraps a Config to force dry_run? true without mutating the original.
78
+ class OverrideDryRun
79
+ def initialize(config)
80
+ @config = config
81
+ end
82
+
83
+ def dry_run?
84
+ true
85
+ end
86
+
87
+ def respond_to_missing?(name, include_private = false)
88
+ @config.respond_to?(name, include_private) || super
89
+ end
90
+
91
+ def method_missing(name, *args, &block)
92
+ @config.send(name, *args, &block)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Loads bundler-skills.yml and merges it with defaults.
5
+ #
6
+ # The file is optional: a missing file yields an all-defaults Config so the
7
+ # hook works out of the box. Supported keys:
8
+ # enabled nil(auto) | true | false | [env names]
9
+ # agents nil(auto-detect) | "*" | [keys] | "key"
10
+ # gitignore bool (default true)
11
+ # cleanup bool (default true) — prune stale gem-*--* links
12
+ # recursive bool (default false) — scan skills/**/SKILL.md
13
+ # include [patterns] — fnmatch on gem name or "gem/skill"
14
+ # exclude [patterns] — same, wins over include
15
+ # dry_run / force bool
16
+ class Config
17
+ CONFIG_FILENAME = "bundler-skills.yml"
18
+
19
+ DEFAULTS = {
20
+ "enabled" => nil,
21
+ "agents" => nil,
22
+ "gitignore" => true,
23
+ "cleanup" => true,
24
+ "recursive" => false,
25
+ "dry_run" => false,
26
+ "force" => false,
27
+ "include" => [],
28
+ "exclude" => []
29
+ }.freeze
30
+
31
+ def self.load(root: Bundler.root)
32
+ path = File.join(root.to_s, CONFIG_FILENAME)
33
+ data = read_yaml(path)
34
+ new(DEFAULTS.merge(data))
35
+ end
36
+
37
+ def self.read_yaml(path)
38
+ return {} unless File.file?(path)
39
+
40
+ require "yaml"
41
+ loaded = YAML.safe_load_file(path)
42
+ loaded.is_a?(Hash) ? loaded : {}
43
+ rescue StandardError => e
44
+ Bundler.ui.warn("[bundler-skills] failed to read #{path}: #{e.message}") if defined?(Bundler)
45
+ {}
46
+ end
47
+
48
+ def initialize(data)
49
+ @data = data
50
+ end
51
+
52
+ # nil | true | false | Array<String>. A bare string env name is normalized
53
+ # to a one-element array so `enabled: development` behaves like a list.
54
+ def enabled
55
+ value = @data["enabled"]
56
+ value.is_a?(String) ? [value] : value
57
+ end
58
+
59
+ # nil (auto-detect) | Array<String>. A bare string key is wrapped so
60
+ # `agents: claude` works as well as a YAML list.
61
+ def agents
62
+ value = @data["agents"]
63
+ return nil if value.nil?
64
+
65
+ Array(value).map(&:to_s)
66
+ end
67
+
68
+ def gitignore?
69
+ @data["gitignore"] != false
70
+ end
71
+
72
+ def cleanup?
73
+ @data["cleanup"] != false
74
+ end
75
+
76
+ def recursive?
77
+ @data["recursive"] == true
78
+ end
79
+
80
+ def dry_run?
81
+ @data["dry_run"] == true
82
+ end
83
+
84
+ def force?
85
+ @data["force"] == true
86
+ end
87
+
88
+ def include_patterns
89
+ Array(@data["include"]).map(&:to_s)
90
+ end
91
+
92
+ def exclude_patterns
93
+ Array(@data["exclude"]).map(&:to_s)
94
+ end
95
+
96
+ # include/exclude are matched against the gem name (and "gem/skill") using
97
+ # File.fnmatch wildcards. Empty include = allow all; exclude wins.
98
+ def included?(gem_name, skill_name)
99
+ return false if matches_any?(exclude_patterns, gem_name, skill_name)
100
+ return true if include_patterns.empty?
101
+
102
+ matches_any?(include_patterns, gem_name, skill_name)
103
+ end
104
+
105
+ private
106
+
107
+ def matches_any?(patterns, gem_name, skill_name)
108
+ candidates = [gem_name, "#{gem_name}/#{skill_name}"]
109
+ patterns.any? do |pattern|
110
+ candidates.any? { |c| File.fnmatch?(pattern, c, File::FNM_EXTGLOB) }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Decides whether the plugin should run in the current environment.
5
+ #
6
+ # Skills are a development-time concern, so the hook is silently disabled in
7
+ # production / CI. All inputs are injected (env hash + config) so the logic is
8
+ # a pure function and easy to test. The manual `bundle skills` command does
9
+ # NOT consult this — an explicit user action always runs.
10
+ module Disabling
11
+ TRUTHY = %w[1 true yes on].freeze
12
+
13
+ module_function
14
+
15
+ # @param env [Hash] environment variables (defaults to ENV)
16
+ # @param config [BundlerSkills::Config, nil] loaded config (optional)
17
+ # @return [Boolean] true when the plugin must not run
18
+ def disabled?(env: ENV, config: nil)
19
+ # Explicit overrides win over everything else.
20
+ return true if truthy?(env["BUNDLER_SKILLS_DISABLED"])
21
+ return false if truthy?(env["BUNDLER_SKILLS_ENABLED"])
22
+
23
+ enabled = config&.enabled
24
+ case enabled
25
+ when false
26
+ return true
27
+ when Array
28
+ return true unless enabled.map(&:to_s).include?(current_environment(env))
29
+ when true
30
+ return false
31
+ end
32
+
33
+ production?(env) || ci?(env)
34
+ end
35
+
36
+ def truthy?(value)
37
+ return false if value.nil?
38
+
39
+ TRUTHY.include?(value.to_s.strip.downcase)
40
+ end
41
+
42
+ def production?(env)
43
+ %w[RAILS_ENV RACK_ENV].any? { |key| env[key].to_s.strip.downcase == "production" }
44
+ end
45
+
46
+ def ci?(env)
47
+ truthy?(env["CI"])
48
+ end
49
+
50
+ # Best-effort current environment name for `enabled:` list matching.
51
+ def current_environment(env)
52
+ value = env["RAILS_ENV"] || env["RACK_ENV"]
53
+ value.to_s.strip.empty? ? "development" : value.strip.downcase
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # One skill found inside a dependency gem.
5
+ #
6
+ # source_path : absolute path to the directory containing SKILL.md
7
+ # link_name : symlink basename, "gem-<gem>--<skill>" (double-hyphen boundary
8
+ # so a gem name containing single hyphens stays unambiguous)
9
+ class DiscoveredSkill
10
+ LINK_PREFIX = "gem-"
11
+ BOUNDARY = "--"
12
+
13
+ attr_reader :gem_name, :skill_name, :source_path
14
+
15
+ def initialize(gem_name:, skill_name:, source_path:)
16
+ @gem_name = gem_name
17
+ @skill_name = skill_name
18
+ @source_path = source_path
19
+ end
20
+
21
+ def link_name
22
+ "#{LINK_PREFIX}#{gem_name}#{BOUNDARY}#{skill_name}"
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(DiscoveredSkill) &&
27
+ gem_name == other.gem_name &&
28
+ skill_name == other.skill_name &&
29
+ source_path == other.source_path
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [gem_name, skill_name, source_path].hash
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "discovered_skill"
4
+
5
+ module BundlerSkills
6
+ # Finds skills bundled in the resolved dependency gems.
7
+ #
8
+ # This is the ONLY place that touches the Bundler spec API. The default
9
+ # `specs` come from Bundler.load.specs, which reflects the current
10
+ # environment's resolved gems (includes development/test groups unless
11
+ # `without` excludes them). Each spec exposes full_gem_path / name / version
12
+ # and works for rubygems, path and git sources alike.
13
+ class Discoverer
14
+ SKILL_FILE = "SKILL.md"
15
+
16
+ def initialize(specs: nil, config: Config.new(Config::DEFAULTS), logger: nil)
17
+ @specs = specs
18
+ @config = config
19
+ @logger = logger
20
+ end
21
+
22
+ # @return [Array<DiscoveredSkill>]
23
+ def discover
24
+ specs.flat_map { |spec| skills_in(spec) }.compact
25
+ end
26
+
27
+ private
28
+
29
+ def specs
30
+ @specs ||= Bundler.load.specs
31
+ end
32
+
33
+ def skills_in(spec)
34
+ gem_path = spec.full_gem_path
35
+ return [] unless gem_path && File.directory?(gem_path)
36
+
37
+ pattern = @config.recursive? ? "skills/**/#{SKILL_FILE}" : "skills/*/#{SKILL_FILE}"
38
+ Dir.glob(File.join(gem_path, pattern)).filter_map do |skill_md|
39
+ skill_dir = File.dirname(skill_md)
40
+ skill_name = File.basename(skill_dir)
41
+ next unless @config.included?(spec.name, skill_name)
42
+
43
+ DiscoveredSkill.new(
44
+ gem_name: spec.name,
45
+ skill_name: skill_name,
46
+ source_path: File.expand_path(skill_dir)
47
+ )
48
+ end
49
+ rescue StandardError => e
50
+ warn_skip(spec, e)
51
+ []
52
+ end
53
+
54
+ def warn_skip(spec, error)
55
+ message = "[bundler-skills] skipped #{spec.name}: #{error.class}: #{error.message}"
56
+ if @logger
57
+ @logger.warn(message)
58
+ elsif defined?(Bundler)
59
+ Bundler.ui.warn(message)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Keeps a managed block in .gitignore listing the generated symlink patterns
5
+ # (e.g. .claude/skills/gem-*, .agents/skills/gem-*). The block is delimited by
6
+ # marker comments so it can be detected and rewritten without touching the
7
+ # rest of the file. Idempotent: re-running with the same patterns is a no-op.
8
+ class GitignoreUpdater
9
+ BEGIN_MARKER = "# >>> bundler-skills managed >>>"
10
+ END_MARKER = "# <<< bundler-skills managed <<<"
11
+
12
+ def initialize(gitignore_path:, dry_run: false)
13
+ @gitignore_path = gitignore_path
14
+ @dry_run = dry_run
15
+ end
16
+
17
+ # @param patterns [Array<String>] e.g. [".claude/skills/gem-*"]
18
+ # @return [Boolean] true when the file was changed (or would be, in dry-run)
19
+ def ensure_patterns(patterns)
20
+ patterns = patterns.uniq
21
+ return false if patterns.empty?
22
+
23
+ existing = File.exist?(@gitignore_path) ? File.read(@gitignore_path) : nil
24
+ updated = rewrite(existing, patterns)
25
+ return false if updated == existing
26
+
27
+ File.write(@gitignore_path, updated) unless @dry_run
28
+ true
29
+ end
30
+
31
+ private
32
+
33
+ def rewrite(existing, patterns)
34
+ block = build_block(patterns)
35
+ return block if existing.nil? || existing.empty?
36
+
37
+ if existing.include?(BEGIN_MARKER) && existing.include?(END_MARKER)
38
+ replace_block(existing, block)
39
+ else
40
+ separator = existing.end_with?("\n") ? "\n" : "\n\n"
41
+ "#{existing}#{separator}#{block}"
42
+ end
43
+ end
44
+
45
+ def build_block(patterns)
46
+ lines = [BEGIN_MARKER, *patterns, END_MARKER]
47
+ "#{lines.join("\n")}\n"
48
+ end
49
+
50
+ def replace_block(existing, block)
51
+ pattern = /#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m
52
+ existing.sub(pattern, block)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Registers the after-install-all hook.
5
+ #
6
+ # The hook itself holds no logic: it guards on Disabling (skip in
7
+ # production/CI), then delegates to Synchronizer. Any error is logged as a
8
+ # warning so it never aborts the user's `bundle install`.
9
+ #
10
+ # NOTE: the block argument of after-install-all is an Array<Bundler::Dependency>,
11
+ # NOT specs. We intentionally ignore it and read Bundler.load.specs inside
12
+ # the Synchronizer instead.
13
+ module Hook
14
+ module_function
15
+
16
+ def register
17
+ Bundler::Plugin.add_hook("after-install-all") do |_dependencies|
18
+ call
19
+ end
20
+ end
21
+
22
+ def call
23
+ config = Config.load
24
+ return if Disabling.disabled?(config: config)
25
+
26
+ Synchronizer.new(config: config).sync
27
+ rescue StandardError => e
28
+ Bundler.ui.warn("[bundler-skills] skipped: #{e.class}: #{e.message}")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module BundlerSkills
6
+ # Creates idempotent absolute symlinks for discovered skills into a single
7
+ # output directory, and prunes stale ones we previously created.
8
+ #
9
+ # Pure filesystem operations — no Bundler dependency, so it is fully unit
10
+ # testable against a tmpdir. One Linker instance == one output directory
11
+ # (e.g. .claude/skills or .agents/skills).
12
+ class Linker
13
+ STALE_GLOB = "#{DiscoveredSkill::LINK_PREFIX}*#{DiscoveredSkill::BOUNDARY}*"
14
+
15
+ class Result
16
+ attr_reader :created, :kept, :relinked, :skipped, :pruned
17
+
18
+ def initialize
19
+ @created = []
20
+ @kept = []
21
+ @relinked = []
22
+ @skipped = []
23
+ @pruned = []
24
+ end
25
+ end
26
+
27
+ def initialize(skills_dir:, config: Config.new(Config::DEFAULTS), logger: nil)
28
+ @skills_dir = skills_dir
29
+ @config = config
30
+ @logger = logger
31
+ end
32
+
33
+ # @param skills [Array<DiscoveredSkill>]
34
+ # @return [Result]
35
+ def link(skills)
36
+ result = Result.new
37
+ link_names = skills.map(&:link_name)
38
+
39
+ ensure_dir
40
+ skills.each { |skill| link_one(skill, result) }
41
+ prune_stale(link_names, result) if @config.cleanup?
42
+ result
43
+ end
44
+
45
+ # Remove every gem-*--* symlink we own (for `bundle skills clean`).
46
+ # @return [Array<String>] removed link names
47
+ def clean_all
48
+ removed = []
49
+ Dir.glob(File.join(@skills_dir, STALE_GLOB)).each do |path|
50
+ next unless File.symlink?(path)
51
+
52
+ remove(path)
53
+ removed << File.basename(path)
54
+ end
55
+ removed
56
+ end
57
+
58
+ private
59
+
60
+ def ensure_dir
61
+ return if @config.dry_run?
62
+
63
+ FileUtils.mkdir_p(@skills_dir)
64
+ end
65
+
66
+ def link_one(skill, result)
67
+ link_path = File.join(@skills_dir, skill.link_name)
68
+ target = skill.source_path
69
+
70
+ if File.symlink?(link_path)
71
+ if File.readlink(link_path) == target
72
+ result.kept << skill.link_name
73
+ else
74
+ replace_symlink(link_path, target)
75
+ result.relinked << skill.link_name
76
+ end
77
+ elsif File.exist?(link_path)
78
+ # A real file/dir the user created — never clobber it (unless force).
79
+ if @config.force?
80
+ remove(link_path)
81
+ create_symlink(link_path, target)
82
+ result.relinked << skill.link_name
83
+ else
84
+ warn("refusing to overwrite non-symlink #{link_path}")
85
+ result.skipped << skill.link_name
86
+ end
87
+ else
88
+ create_symlink(link_path, target)
89
+ result.created << skill.link_name
90
+ end
91
+ end
92
+
93
+ # Remove gem-*--* symlinks we own that are no longer in the discovery set.
94
+ # Real directories, other prefixes, and unmanaged symlinks are left alone.
95
+ def prune_stale(valid_link_names, result)
96
+ Dir.glob(File.join(@skills_dir, STALE_GLOB)).each do |path|
97
+ name = File.basename(path)
98
+ next if valid_link_names.include?(name)
99
+ next unless File.symlink?(path) # only prune our own symlinks
100
+
101
+ remove(path)
102
+ result.pruned << name
103
+ end
104
+ end
105
+
106
+ def replace_symlink(link_path, target)
107
+ remove(link_path)
108
+ create_symlink(link_path, target)
109
+ end
110
+
111
+ def create_symlink(link_path, target)
112
+ return if @config.dry_run?
113
+
114
+ File.symlink(target, link_path)
115
+ end
116
+
117
+ def remove(path)
118
+ return if @config.dry_run?
119
+
120
+ if File.symlink?(path)
121
+ File.delete(path)
122
+ else
123
+ FileUtils.remove_entry(path)
124
+ end
125
+ end
126
+
127
+ def warn(message)
128
+ full = "[bundler-skills] #{message}"
129
+ if @logger
130
+ @logger.warn(full)
131
+ elsif defined?(Bundler)
132
+ Bundler.ui.warn(full)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Orchestrates discovery -> linking across the resolved agents. Shared entry
5
+ # point for both the Hook (after-install-all) and the manual `bundle skills`
6
+ # command.
7
+ #
8
+ # Discovery runs once (agent-independent); linking runs once per distinct
9
+ # output directory (.claude/skills and/or .agents/skills). Phase 4 adds the
10
+ # .gitignore update.
11
+ class Synchronizer
12
+ Result = Struct.new(:discovered, :agents, :links_by_dir, :gitignore_changed, keyword_init: true)
13
+
14
+ def initialize(root: Bundler.root, config: Config.load, logger: Bundler.ui, specs: nil)
15
+ @root = root
16
+ @config = config
17
+ @logger = logger
18
+ @specs = specs
19
+ end
20
+
21
+ def sync
22
+ skills = Discoverer.new(specs: @specs, config: @config, logger: @logger).discover
23
+ agents = AgentRegistry.resolve(@root, @config)
24
+ subdirs = AgentRegistry.output_subdirs(agents)
25
+
26
+ links_by_dir = subdirs.to_h do |subdir|
27
+ skills_dir = File.join(@root.to_s, subdir)
28
+ [subdir, Linker.new(skills_dir: skills_dir, config: @config, logger: @logger).link(skills)]
29
+ end
30
+
31
+ gitignore_changed = update_gitignore(subdirs)
32
+
33
+ log_summary(skills, agents, links_by_dir)
34
+ Result.new(
35
+ discovered: skills, agents: agents,
36
+ links_by_dir: links_by_dir, gitignore_changed: gitignore_changed
37
+ )
38
+ end
39
+
40
+ # Discover skills and the agents/dirs that would receive them, without
41
+ # touching the filesystem. Used by `bundle skills list`.
42
+ def plan
43
+ skills = Discoverer.new(specs: @specs, config: @config, logger: @logger).discover
44
+ agents = AgentRegistry.resolve(@root, @config)
45
+ Result.new(
46
+ discovered: skills, agents: agents,
47
+ links_by_dir: {}, gitignore_changed: false
48
+ )
49
+ end
50
+
51
+ # Remove every gem-*--* symlink we own across all known output dirs.
52
+ # Used by `bundle skills clean`. Returns { subdir => [removed names] }.
53
+ def clean
54
+ AgentRegistry.all.map(&:skills_subdir).uniq.to_h do |subdir|
55
+ skills_dir = File.join(@root.to_s, subdir)
56
+ linker = Linker.new(skills_dir: skills_dir, config: @config, logger: @logger)
57
+ [subdir, linker.clean_all]
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def update_gitignore(subdirs)
64
+ return false unless @config.gitignore?
65
+ return false if subdirs.empty?
66
+
67
+ patterns = subdirs.map { |subdir| "#{subdir}/#{DiscoveredSkill::LINK_PREFIX}*" }
68
+ GitignoreUpdater.new(
69
+ gitignore_path: File.join(@root.to_s, ".gitignore"),
70
+ dry_run: @config.dry_run?
71
+ ).ensure_patterns(patterns)
72
+ end
73
+
74
+ def log_summary(skills, agents, links_by_dir)
75
+ return unless @logger
76
+
77
+ if agents.empty?
78
+ @logger.info(
79
+ "[bundler-skills] #{skills.size} skill(s) discovered but no agent detected " \
80
+ "(.claude/.cursor/.codex/AGENTS.md/.github) — nothing linked"
81
+ )
82
+ return
83
+ end
84
+
85
+ created = links_by_dir.values.sum { |r| r.created.size }
86
+ pruned = links_by_dir.values.sum { |r| r.pruned.size }
87
+ @logger.info(
88
+ "[bundler-skills] #{skills.size} skill(s) discovered, " \
89
+ "#{created} linked, #{pruned} pruned across #{links_by_dir.size} dir(s) " \
90
+ "(agents: #{agents.map(&:key).join(', ')})"
91
+ )
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bundler_skills/version"
4
+ require_relative "bundler_skills/disabling"
5
+ require_relative "bundler_skills/config"
6
+ require_relative "bundler_skills/discovered_skill"
7
+ require_relative "bundler_skills/discoverer"
8
+ require_relative "bundler_skills/agent_registry"
9
+ require_relative "bundler_skills/linker"
10
+ require_relative "bundler_skills/gitignore_updater"
11
+ require_relative "bundler_skills/synchronizer"
12
+ require_relative "bundler_skills/hook"
13
+
14
+ module BundlerSkills
15
+ end
data/plugins.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bundler_skills"
4
+ require_relative "lib/bundler_skills/command"
5
+
6
+ BundlerSkills::Hook.register
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundler-skills
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - aki77
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A Bundler plugin that discovers skills/ directories bundled in your dependency
13
+ gems and symlinks them into your project's agent skill directories (.claude/skills,
14
+ .agents/skills) on bundle install. The Ruby/Bundler counterpart of antfu/skills-npm.
15
+ email:
16
+ - aki77@users.noreply.github.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - PROPOSAL.md
24
+ - README.md
25
+ - lib/bundler_skills.rb
26
+ - lib/bundler_skills/agent_registry.rb
27
+ - lib/bundler_skills/command.rb
28
+ - lib/bundler_skills/config.rb
29
+ - lib/bundler_skills/disabling.rb
30
+ - lib/bundler_skills/discovered_skill.rb
31
+ - lib/bundler_skills/discoverer.rb
32
+ - lib/bundler_skills/gitignore_updater.rb
33
+ - lib/bundler_skills/hook.rb
34
+ - lib/bundler_skills/linker.rb
35
+ - lib/bundler_skills/synchronizer.rb
36
+ - lib/bundler_skills/version.rb
37
+ - plugins.rb
38
+ homepage: https://github.com/aki77/bundler-skills
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ homepage_uri: https://github.com/aki77/bundler-skills
43
+ source_code_uri: https://github.com/aki77/bundler-skills.git
44
+ changelog_uri: https://github.com/aki77/bundler-skills/blob/main/CHANGELOG.md
45
+ rubygems_mfa_required: 'true'
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 4.0.10
61
+ specification_version: 4
62
+ summary: Auto-symlink AI agent skills bundled in your gems after bundle install.
63
+ test_files: []