bundler-skills 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 439aafda99d985a5bd8da3676ad50a82f17d1e8bf01a895d193998ae1faf885d
4
- data.tar.gz: 0050bcd8e99963aecc9b847bba9697e2b88b8d55ecee0f3c8d6a7340a6b0ad9c
3
+ metadata.gz: 962c265c0293c266b9fcca439681d9850cdac4332348dd2d1ffe11a4e5580111
4
+ data.tar.gz: 495a9a9f1f2a1c0da2cb02aa14b73c1902b0584e64647066a9da967615b0c4af
5
5
  SHA512:
6
- metadata.gz: de83628fb2e88ab3388afd068d0c478eb901fac43df5858ca280af1012dfaa64363599a4e640874d3d8e392b4189dd7f3962420a0e44d458cb5c5893d6880409
7
- data.tar.gz: 46029390766998a7ede9dc14546d6fbbff54810973196a8e8626ce653b905cbcd43dec302d63f1b21e88915c74703296d5ac22881dd9b3ac2e6930b14b3b29a0
6
+ metadata.gz: 4cad5c43044a98626b9fab90cb799c167e012f56d4c53c3839153648e3daef3f6c9cd10f1fa77771a2a0af8bedccbee77204054bcde6e48b2682fe9a36ca0c4c
7
+ data.tar.gz: 6ec0164595551b1710069fe015151462cee20b87f614ed7b8c5d87d21053e7374332fde34e670196b8b0e0a7e2d1a6491f56b8be22ba09bd2047a7341957ce33
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-06-27
6
+
7
+ ### Changed (breaking)
8
+
9
+ - **bundler-skills is now a regular gem, not a Bundler plugin.** Install it as
10
+ `gem "bundler-skills"` (recommended: in the `development` group) instead of
11
+ `plugin "bundler-skills"`. This removes the `Bundler::Plugin::Index::CommandConflict`
12
+ that occurred (and recurred on every `bundle` run until `bundler plugin
13
+ uninstall`) whenever `bundle update` bumped bundler-skills itself.
14
+ - Syncing now runs from a RubyGems `Gem.post_install` hook
15
+ (`lib/rubygems_plugin.rb`): when a gem is actually installed during
16
+ `bundle install` / `bundle update`, only **that gem's** skills are linked, and
17
+ only that gem's own stale links are pruned (other gems are untouched). A
18
+ cached `bundle install` that installs nothing does no work.
19
+ - The manual command moved from the Bundler subcommand `bundle skills` to the
20
+ plain executable **`bundle exec skills`** (`sync` / `list` / `clean` / `init`,
21
+ with `--dry-run`). `skills sync` scans all gems and prunes all stale links.
22
+
23
+ ### Removed
24
+
25
+ - Production / CI auto-detection (`RAILS_ENV`/`RACK_ENV=production`, `CI`) and the
26
+ `enabled:` config key / `BUNDLER_SKILLS_ENABLED` override. With a
27
+ development-group install the gem isn't present in production/CI anyway. The
28
+ only switch left is `BUNDLER_SKILLS_DISABLED`.
29
+
5
30
  ## [0.3.0] - 2026-06-21
6
31
 
7
32
  ### Changed
data/PROPOSAL.md CHANGED
@@ -76,8 +76,19 @@ even for gems like `rails-html-sanitizer`.
76
76
  linked too (the directory is symlinked as a whole), so reference relative
77
77
  files normally.
78
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.
79
+ ## Why a RubyGems `post_install` hook (not a Bundler plugin)
80
+
81
+ bundler-skills ships as a regular gem with a `lib/rubygems_plugin.rb` that
82
+ registers a `Gem.post_install` hook. The hook **does** fire during
83
+ `bundle install` for each gem that is actually installed (only `Gem.done_installing`
84
+ is Bundler-skipped), so it is a reliable place to sync that gem's skills.
85
+
86
+ An earlier version was a Bundler plugin that registered a `bundle skills`
87
+ command via `Bundler::Plugin::API.command`. That had a fatal flaw: when
88
+ `bundle update` bumped bundler-skills itself, Bundler re-registered the `skills`
89
+ command while the previous registration was still in its plugin index, raising
90
+ `Bundler::Plugin::Index::CommandConflict` — and it recurred on every subsequent
91
+ `bundle` run until `bundler plugin uninstall`. This is unavoidable for any plugin
92
+ that registers a command. Becoming a regular gem removes the command registration
93
+ entirely (the manual command is now the plain executable `bundle exec skills`),
94
+ so the conflict cannot happen. See [README.md](README.md) for consumer details.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # bundler-skills
2
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
3
+ A gem that auto-symlinks **AI agent skills bundled in your gems** into your
4
+ project on `bundle install`. The Ruby/Bundler counterpart of
5
5
  [antfu/skills-npm](https://github.com/antfu/skills-npm).
6
6
 
7
7
  Gems ship `skills/<name>/SKILL.md`; your project links them into the right agent
@@ -10,15 +10,20 @@ team gets them just by running `bundle install`.
10
10
 
11
11
  ## How it works
12
12
 
13
- After `bundle install` / `bundle update`, the plugin:
13
+ bundler-skills ships a RubyGems `post_install` hook (via
14
+ `lib/rubygems_plugin.rb`). Whenever a gem is **actually installed** during
15
+ `bundle install` / `bundle update`, the hook runs for that gem and:
14
16
 
15
- 1. Scans your resolved dependency gems for `skills/*/SKILL.md`.
17
+ 1. Scans **that gem** for `skills/*/SKILL.md`.
16
18
  2. Detects which agents you use (by marker directories) and symlinks each skill
17
19
  into the right place, named `gem-<gem>--<skill>`.
18
- 3. Adds the generated symlink patterns to `.gitignore` (they are machine-local).
20
+ 3. Prunes that gem's own stale links (so a version that renames or drops a skill
21
+ updates correctly), leaving every other gem's links untouched.
22
+ 4. Adds the generated symlink patterns to `.gitignore` (they are machine-local).
19
23
 
20
- It is **disabled automatically in production/CI** skills are a development-time
21
- concern.
24
+ A `bundle install` that installs nothing (everything already cached) does no
25
+ work — only freshly installed gems are processed. To re-sync everything at once
26
+ (e.g. after removing a gem), run `bundle exec skills` (see below).
22
27
 
23
28
  ### Supported agents
24
29
 
@@ -36,13 +41,16 @@ marker exists, so nothing is created in projects that don't use these tools.
36
41
 
37
42
  ## Installation
38
43
 
39
- Add the plugin to your `Gemfile` (this is the recommended, team-wide way):
44
+ Add the gem to your `Gemfile`, in the `development` group (skills are a
45
+ development-time concern; this is the recommended, team-wide way):
40
46
 
41
47
  ```ruby
42
48
  # Gemfile
43
49
  source "https://rubygems.org"
44
50
 
45
- plugin "bundler-skills"
51
+ group :development do
52
+ gem "bundler-skills"
53
+ end
46
54
 
47
55
  gem "some-gem-that-ships-skills"
48
56
  ```
@@ -53,58 +61,53 @@ Then:
53
61
  bundle install
54
62
  ```
55
63
 
56
- That's it. On install you'll see something like:
64
+ That's it. When a skill-bearing gem is installed you'll see something like:
57
65
 
58
66
  ```
59
- [bundler-skills] 3 skill(s) discovered, 3 linked, 0 relinked, 0 pruned across 1 dir(s) (agents: claude)
67
+ [bundler-skills] 1 skill(s) discovered, 1 linked, 0 relinked, 0 pruned across 1 dir(s) (agents: claude)
68
+ created:
69
+ .claude/skills/gem-rubocop--style -> /path/to/gems/rubocop/skills/style
60
70
  ```
61
71
 
72
+ > In production / CI you typically run `bundle install` with the `development`
73
+ > group excluded (`bundle config set --local without development`), so the gem
74
+ > isn't even present and nothing runs. You can also force it off anywhere with
75
+ > `BUNDLER_SKILLS_DISABLED=1`.
76
+
62
77
  When a run actually changes something (links created, relinked after a gem
63
78
  update, or stale links pruned) the summary is printed in green so it stands out;
64
79
  a run with nothing to do prints the same line in plain text. A changed run also
65
80
  lists each affected skill, grouped by kind, with the path to its `SKILL.md` so
66
- you can review the (third-party) skill contents now linked into your project:
81
+ you can review the (third-party) skill contents now linked into your project.
67
82
 
68
- ```
69
- [bundler-skills] 3 skill(s) discovered, 2 linked, 1 relinked, 0 pruned across 1 dir(s) (agents: claude)
70
- created:
71
- .claude/skills/gem-rubocop--style -> /path/to/gems/rubocop/skills/style
72
- .claude/skills/gem-rubocop--lint -> /path/to/gems/rubocop/skills/lint
73
- relinked:
74
- .claude/skills/gem-rspec--matchers -> /path/to/gems/rspec/skills/matchers
75
- ```
76
-
77
- > Alternatively, install it globally with `bundle plugin install bundler-skills`.
78
- > The `Gemfile` approach is preferred because it propagates to the whole team.
79
-
80
- ## The `bundle skills` command
83
+ ## The `bundle exec skills` command
81
84
 
82
- `bundle install` triggers syncing automatically, but `bundle lock` does **not**
83
- run plugin hooks ([rubygems#7542](https://github.com/ruby/rubygems/issues/7542)).
84
- Use the command to sync manually, or to inspect/clean:
85
+ The `post_install` hook only fires for gems that are freshly installed. To
86
+ re-sync everything at once — after removing a gem, after `bundle lock` (which
87
+ installs nothing), or just to inspect/clean — run the command:
85
88
 
86
89
  ```sh
87
- bundle skills # (or: bundle skills sync) re-create symlinks
88
- bundle skills list # show discovered skills and target agents (no changes)
89
- bundle skills clean # remove all gem-*--* symlinks this plugin created
90
- bundle skills init # create a bundler-skills.yml config file with defaults
91
- bundle skills <cmd> --dry-run # show what would change without writing
90
+ bundle exec skills # (or: skills sync) re-scan ALL gems and (re)create symlinks
91
+ bundle exec skills list # show discovered skills and target agents (no changes)
92
+ bundle exec skills clean # remove all gem-*--* symlinks this gem created
93
+ bundle exec skills init # create a bundler-skills.yml config file with defaults
94
+ bundle exec skills <cmd> --dry-run # show what would change without writing
92
95
  ```
93
96
 
94
- Unlike the automatic hook, the command always runs (it ignores the
95
- production/CI guard) since invoking it is an explicit action.
97
+ Unlike the automatic hook, `skills sync` scans every resolved gem and prunes any
98
+ stale `gem-*--*` link (so it also cleans up after a removed gem). It always runs
99
+ and ignores `BUNDLER_SKILLS_DISABLED`, since invoking it is an explicit action.
96
100
 
97
101
  ## Configuration
98
102
 
99
103
  All optional. Create `bundler-skills.yml` in your project root:
100
104
 
101
105
  ```yaml
102
- enabled: # nil (auto) | false (off) | [development] (env list)
103
106
  agents: # omit = auto-detect; or list: [claude, cursor]; or "*"
104
107
  - claude
105
108
  - cursor
106
109
  gitignore: true # manage .gitignore (default true)
107
- cleanup: true # prune stale gem-*--* links when a gem is removed (default true)
110
+ cleanup: true # prune stale gem-*--* links (default true)
108
111
  recursive: false # also scan skills/**/SKILL.md (default false)
109
112
  include: # only these gems (empty = all). fnmatch on "gem" or "gem/skill"
110
113
  - rubocop
@@ -115,24 +118,15 @@ exclude: # exclude these (wins over include)
115
118
 
116
119
  Notes:
117
120
 
118
- - Skills from `development`/`test` group gems are included by default — those are
119
- exactly the gems (linters, test helpers) that ship skills. They are only
120
- excluded when your environment sets `bundle config set without development`
121
- (e.g. production), in which case skills aren't wanted anyway.
122
121
  - The link target is the gem's path on your machine; that's why the symlinks are
123
122
  gitignored and re-created on each machine's `bundle install`.
124
123
 
125
124
  ### Disabling
126
125
 
127
- The hook is off automatically when any of these hold:
128
-
129
- - `BUNDLER_SKILLS_DISABLED` is set to a truthy value
130
- - `RAILS_ENV` / `RACK_ENV` is `production`
131
- - `CI` is truthy
132
- - `bundler-skills.yml` has `enabled: false`, or `enabled: [..]` not listing the
133
- current env
134
-
135
- Force it on with `BUNDLER_SKILLS_ENABLED=1` or `enabled: true`.
126
+ Set `BUNDLER_SKILLS_DISABLED` to a truthy value (`1`/`true`/`yes`/`on`) to turn
127
+ the `post_install` hook off. There is no production/CI auto-detection: with the
128
+ recommended `development`-group install the gem isn't present in production/CI in
129
+ the first place. The manual `bundle exec skills` command ignores this switch.
136
130
 
137
131
  ## Naming: `gem-<gem>--<skill>`
138
132
 
@@ -155,7 +149,7 @@ See [PROPOSAL.md](PROPOSAL.md) for the distribution convention. In short: put
155
149
 
156
150
  ## Trust boundary
157
151
 
158
- Skills bundled in third-party gems are third-party content. This plugin only
152
+ Skills bundled in third-party gems are third-party content. This gem only
159
153
  **creates symlinks** to them — it never executes their contents. Reviewing what a
160
154
  skill instructs your agent to do is the user's responsibility, the same as
161
155
  reviewing any dependency.
@@ -165,6 +159,11 @@ reviewing any dependency.
165
159
  - POSIX symlinks are assumed; Windows is not supported yet.
166
160
  - Whether Cursor / Codex / Copilot follow symlinked `SKILL.md` during their own
167
161
  directory scans is not formally documented; verified working with Claude Code.
162
+ - The `post_install` hook only fires for gems that are **actually installed**. A
163
+ cached `bundle install` (nothing to install) and `bundle lock` do no syncing —
164
+ run `bundle exec skills` to re-sync on demand.
165
+ - When a gem is **removed**, no hook fires for it, so its `gem-*--*` links linger
166
+ until the next `bundle exec skills` (full sync prunes them).
168
167
 
169
168
  ## Development
170
169
 
data/exe/skills ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler_skills"
5
+ require "bundler_skills/cli"
6
+
7
+ exit BundlerSkills::CLI.new.run(ARGV)
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module BundlerSkills
6
+ # `bundle exec skills [sync|list|clean|init] [--dry-run]`
7
+ #
8
+ # The manual entry point (a plain executable, not a Bundler plugin command).
9
+ # Unlike the post_install hook it ignores the disable switch — running it is an
10
+ # explicit user action — and it reuses the same Synchronizer logic.
11
+ class CLI
12
+ INIT_TEMPLATE = <<~YAML
13
+ # bundler-skills.yml — all keys are optional
14
+ #
15
+ # agents: # omit = auto-detect; or list: [claude, cursor]; or "*"
16
+ # - claude
17
+ # - cursor
18
+ # gitignore: true # manage .gitignore (default true)
19
+ # cleanup: true # prune stale gem-*--* links when a gem is removed (default true)
20
+ # recursive: false # also scan skills/**/SKILL.md (default false)
21
+ # include: # only these gems (empty = all). fnmatch on "gem" or "gem/skill"
22
+ # - rubocop
23
+ # - "rails-*"
24
+ # exclude: # exclude these (wins over include)
25
+ # - some-noisy-gem
26
+ YAML
27
+
28
+ USAGE = <<~HELP
29
+ Usage: bundle exec skills [SUBCOMMAND] [--dry-run]
30
+
31
+ sync (default) discover skills and (re)create symlinks
32
+ list show discovered skills and target agents (no changes)
33
+ clean remove all gem-*--* symlinks this gem created
34
+ init create a bundler-skills.yml config file with defaults
35
+
36
+ Options:
37
+ --dry-run show what would change without writing
38
+ -h, --help show this help
39
+ HELP
40
+
41
+ def initialize(logger: StdoutLogger.new)
42
+ @logger = logger
43
+ end
44
+
45
+ # @param argv [Array<String>]
46
+ # @return [Integer] process exit status
47
+ def run(argv)
48
+ args = argv.dup
49
+ opts = { dry_run: false }
50
+ parser = OptionParser.new do |o|
51
+ o.on("--dry-run") { opts[:dry_run] = true }
52
+ o.on("-h", "--help") { @logger.info(USAGE); return 0 }
53
+ end
54
+ parser.order!(args)
55
+
56
+ subcommand = args.shift || "sync"
57
+ case subcommand
58
+ when "sync" then run_sync(opts[:dry_run])
59
+ when "list" then run_list
60
+ when "clean" then run_clean(opts[:dry_run])
61
+ when "init" then run_init
62
+ when "help" then @logger.info(USAGE)
63
+ else
64
+ @logger.error("[bundler-skills] unknown subcommand: #{subcommand}")
65
+ @logger.info(USAGE)
66
+ return 1
67
+ end
68
+ 0
69
+ rescue OptionParser::ParseError => e
70
+ @logger.error("[bundler-skills] #{e.message}")
71
+ @logger.info(USAGE)
72
+ 1
73
+ end
74
+
75
+ private
76
+
77
+ def synchronizer(dry_run: false)
78
+ config = Config.load
79
+ config = OverrideDryRun.new(config) if dry_run
80
+ Synchronizer.new(config: config, logger: @logger)
81
+ end
82
+
83
+ def run_sync(dry_run)
84
+ synchronizer(dry_run: dry_run).sync
85
+ end
86
+
87
+ def run_list
88
+ result = synchronizer.plan
89
+ if result.discovered.empty?
90
+ @logger.info("[bundler-skills] no skills found in dependency gems")
91
+ return
92
+ end
93
+
94
+ agents = result.agents.map(&:key)
95
+ @logger.info("[bundler-skills] #{result.discovered.size} skill(s) " \
96
+ "-> agents: #{agents.empty? ? '(none detected)' : agents.join(', ')}")
97
+ result.discovered.sort_by(&:link_name).each do |skill|
98
+ @logger.info(" #{skill.link_name} -> #{skill.source_path}")
99
+ end
100
+ end
101
+
102
+ def run_init
103
+ path = File.join(Bundler.root.to_s, Config::CONFIG_FILENAME)
104
+ if File.exist?(path)
105
+ @logger.warn("[bundler-skills] #{Config::CONFIG_FILENAME} already exists")
106
+ return
107
+ end
108
+
109
+ File.write(path, INIT_TEMPLATE)
110
+ @logger.info("[bundler-skills] created #{Config::CONFIG_FILENAME}")
111
+ end
112
+
113
+ def run_clean(dry_run)
114
+ removed = synchronizer(dry_run: dry_run).clean
115
+ total = removed.values.sum(&:size)
116
+ verb = dry_run ? "would remove" : "removed"
117
+ @logger.info("[bundler-skills] #{verb} #{total} link(s)")
118
+ removed.each do |subdir, names|
119
+ names.each { |n| @logger.info(" #{subdir}/#{n}") }
120
+ end
121
+ end
122
+
123
+ # Minimal logger matching the subset of Bundler.ui that Synchronizer/Linker
124
+ # use (info/warn/error/confirm), writing to stdout/stderr.
125
+ class StdoutLogger
126
+ def info(message) = $stdout.puts(message)
127
+ def confirm(message) = $stdout.puts(message)
128
+ def warn(message) = $stderr.puts(message)
129
+ def error(message) = $stderr.puts(message)
130
+ end
131
+
132
+ # Wraps a Config to force dry_run? true without mutating the original.
133
+ class OverrideDryRun
134
+ def initialize(config)
135
+ @config = config
136
+ end
137
+
138
+ def dry_run?
139
+ true
140
+ end
141
+
142
+ def respond_to_missing?(name, include_private = false)
143
+ @config.respond_to?(name, include_private) || super
144
+ end
145
+
146
+ def method_missing(name, *args, &block)
147
+ @config.send(name, *args, &block)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -5,7 +5,6 @@ module BundlerSkills
5
5
  #
6
6
  # The file is optional: a missing file yields an all-defaults Config so the
7
7
  # hook works out of the box. Supported keys:
8
- # enabled nil(auto) | true | false | [env names]
9
8
  # agents nil(auto-detect) | "*" | [keys] | "key"
10
9
  # gitignore bool (default true)
11
10
  # cleanup bool (default true) — prune stale gem-*--* links
@@ -17,7 +16,6 @@ module BundlerSkills
17
16
  CONFIG_FILENAME = "bundler-skills.yml"
18
17
 
19
18
  DEFAULTS = {
20
- "enabled" => nil,
21
19
  "agents" => nil,
22
20
  "gitignore" => true,
23
21
  "cleanup" => true,
@@ -49,13 +47,6 @@ module BundlerSkills
49
47
  @data = data
50
48
  end
51
49
 
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
50
  # nil (auto-detect) | Array<String>. A bare string key is wrapped so
60
51
  # `agents: claude` works as well as a YAML list.
61
52
  def agents
@@ -1,36 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundlerSkills
4
- # Decides whether the plugin should run in the current environment.
4
+ # Decides whether the post_install hook should run.
5
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.
6
+ # The hook only fires when a gem is actually installed, and in the
7
+ # recommended development-group setup the gem isn't even present in
8
+ # production / CI so there is no environment auto-detection here. The single
9
+ # escape hatch is the BUNDLER_SKILLS_DISABLED env var. The manual CLI ignores
10
+ # this entirely (running it is an explicit user action).
10
11
  module Disabling
11
12
  TRUTHY = %w[1 true yes on].freeze
12
13
 
13
14
  module_function
14
15
 
15
16
  # @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)
17
+ # @return [Boolean] true when the hook must not run
18
+ def disabled?(env: ENV)
19
+ truthy?(env["BUNDLER_SKILLS_DISABLED"])
34
20
  end
35
21
 
36
22
  def truthy?(value)
@@ -38,19 +24,5 @@ module BundlerSkills
38
24
 
39
25
  TRUTHY.include?(value.to_s.strip.downcase)
40
26
  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
27
  end
56
28
  end
@@ -36,14 +36,20 @@ module BundlerSkills
36
36
  end
37
37
 
38
38
  # @param skills [Array<DiscoveredSkill>]
39
+ # @param prune_scope [Symbol, Array<String>, nil] which stale links to prune:
40
+ # :all (default) -> every gem-*--* link we own (full sync)
41
+ # [prefix, ...] -> only links whose basename starts with one of the
42
+ # given prefixes, e.g. "gem-<name>--" (single-gem sync)
43
+ # nil -> prune nothing
44
+ # Pruning still honors @config.cleanup? — when cleanup is off nothing is pruned.
39
45
  # @return [Result]
40
- def link(skills)
46
+ def link(skills, prune_scope: :all)
41
47
  result = Result.new
42
48
  link_names = skills.map(&:link_name)
43
49
 
44
50
  ensure_dir
45
51
  skills.each { |skill| link_one(skill, result) }
46
- prune_stale(link_names, result) if @config.cleanup?
52
+ prune_stale(link_names, result, prune_scope) if @config.cleanup? && prune_scope
47
53
  result
48
54
  end
49
55
 
@@ -97,10 +103,13 @@ module BundlerSkills
97
103
 
98
104
  # Remove gem-*--* symlinks we own that are no longer in the discovery set.
99
105
  # Real directories, other prefixes, and unmanaged symlinks are left alone.
100
- def prune_stale(valid_link_names, result)
106
+ # When +scope+ is an array of prefixes, only links whose basename starts with
107
+ # one of them are considered (so a single-gem sync never touches other gems).
108
+ def prune_stale(valid_link_names, result, scope = :all)
101
109
  Dir.glob(File.join(@skills_dir, STALE_GLOB)).each do |path|
102
110
  name = File.basename(path)
103
111
  next if valid_link_names.include?(name)
112
+ next unless in_scope?(name, scope)
104
113
  next unless File.symlink?(path) # only prune our own symlinks
105
114
 
106
115
  remove(path)
@@ -108,6 +117,12 @@ module BundlerSkills
108
117
  end
109
118
  end
110
119
 
120
+ def in_scope?(name, scope)
121
+ return true if scope == :all
122
+
123
+ Array(scope).any? { |prefix| name.start_with?(prefix) }
124
+ end
125
+
111
126
  def replace_symlink(link_path, target)
112
127
  remove(link_path)
113
128
  create_symlink(link_path, target)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerSkills
4
+ # Entry point for the RubyGems `Gem.post_install` hook (registered in
5
+ # lib/rubygems_plugin.rb). Fires once per gem actually installed during a
6
+ # `bundle install` / `bundle update`, and syncs THAT gem's skills only.
7
+ #
8
+ # Like the old Bundler hook, it holds no real logic: it guards on context and
9
+ # the disable switch, then delegates to Synchronizer#sync_gem. Any error is
10
+ # swallowed as a warning so it never aborts the user's install.
11
+ module RubygemsHook
12
+ module_function
13
+
14
+ # @param installer [#spec] a Gem::Installer (or anything exposing #spec)
15
+ def install(installer)
16
+ spec = installer.spec
17
+ return unless bundle_context?
18
+ return if Disabling.disabled?
19
+
20
+ Synchronizer.new(config: Config.load).sync_gem(spec)
21
+ rescue StandardError => e
22
+ warn("[bundler-skills] skipped #{safe_name(installer)}: #{e.class}: #{e.message}")
23
+ end
24
+
25
+ # Only act inside a `bundle install` for a project with a Gemfile. This
26
+ # keeps a plain `gem install foo` (no project context) from creating
27
+ # symlinks in whatever directory the user happens to be in.
28
+ def bundle_context?
29
+ return false unless defined?(Bundler)
30
+
31
+ root = Bundler.root
32
+ root.join("Gemfile").file? || root.join("gems.rb").file?
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ def safe_name(installer)
38
+ installer.spec.name
39
+ rescue StandardError
40
+ "(unknown gem)"
41
+ end
42
+
43
+ def warn(message)
44
+ if defined?(Bundler)
45
+ Bundler.ui.warn(message)
46
+ else
47
+ Kernel.warn(message)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -25,7 +25,37 @@ module BundlerSkills
25
25
 
26
26
  links_by_dir = subdirs.to_h do |subdir|
27
27
  skills_dir = File.join(@root.to_s, subdir)
28
- [subdir, Linker.new(skills_dir: skills_dir, config: @config, logger: @logger).link(skills)]
28
+ linker = Linker.new(skills_dir: skills_dir, config: @config, logger: @logger)
29
+ [subdir, linker.link(skills, prune_scope: :all)]
30
+ end
31
+
32
+ gitignore_changed = update_gitignore(subdirs)
33
+
34
+ log_summary(skills, agents, links_by_dir)
35
+ Result.new(
36
+ discovered: skills, agents: agents,
37
+ links_by_dir: links_by_dir, gitignore_changed: gitignore_changed
38
+ )
39
+ end
40
+
41
+ # Sync the skills of a SINGLE gem (used by the RubyGems post_install hook).
42
+ #
43
+ # Only links belonging to this gem (gem-<name>--*) are added/updated, and
44
+ # only this gem's stale links are pruned — every other gem's links are left
45
+ # untouched. So when a gem's new version drops or renames a skill, its old
46
+ # link is removed, but unrelated gems are never disturbed.
47
+ #
48
+ # @param spec [#name, #full_gem_path] a Gem::Specification (or compatible)
49
+ def sync_gem(spec)
50
+ skills = Discoverer.new(specs: [spec], config: @config, logger: @logger).discover
51
+ agents = AgentRegistry.resolve(@root, @config)
52
+ subdirs = AgentRegistry.output_subdirs(agents)
53
+ scope = ["#{DiscoveredSkill::LINK_PREFIX}#{spec.name}#{DiscoveredSkill::BOUNDARY}"]
54
+
55
+ links_by_dir = subdirs.to_h do |subdir|
56
+ skills_dir = File.join(@root.to_s, subdir)
57
+ linker = Linker.new(skills_dir: skills_dir, config: @config, logger: @logger)
58
+ [subdir, linker.link(skills, prune_scope: scope)]
29
59
  end
30
60
 
31
61
  gitignore_changed = update_gitignore(subdirs)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundlerSkills
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,7 +9,6 @@ require_relative "bundler_skills/agent_registry"
9
9
  require_relative "bundler_skills/linker"
10
10
  require_relative "bundler_skills/gitignore_updater"
11
11
  require_relative "bundler_skills/synchronizer"
12
- require_relative "bundler_skills/hook"
13
12
 
14
13
  module BundlerSkills
15
14
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RubyGems loads this file automatically (it is on the require path and named
4
+ # by the rubygems_plugin convention) during `bundle install` / `gem install`.
5
+ # We register a post_install hook that syncs each freshly installed gem's
6
+ # skills. This replaces the old Bundler plugin (`command "skills"` caused a
7
+ # CommandConflict whenever the plugin updated itself).
8
+ require_relative "bundler_skills"
9
+ require_relative "bundler_skills/rubygems_hook"
10
+
11
+ Gem.post_install do |installer|
12
+ BundlerSkills::RubygemsHook.install(installer)
13
+ end
metadata CHANGED
@@ -1,20 +1,22 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-skills
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - aki77
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
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.
12
+ description: A gem that discovers skills/ directories bundled in your dependency gems
13
+ and symlinks them into your project's agent skill directories (.claude/skills, .agents/skills)
14
+ on bundle install, via a RubyGems post_install hook. The Ruby/Bundler counterpart
15
+ of antfu/skills-npm.
15
16
  email:
16
17
  - aki77@users.noreply.github.com
17
- executables: []
18
+ executables:
19
+ - skills
18
20
  extensions: []
19
21
  extra_rdoc_files: []
20
22
  files:
@@ -22,19 +24,20 @@ files:
22
24
  - LICENSE.txt
23
25
  - PROPOSAL.md
24
26
  - README.md
27
+ - exe/skills
25
28
  - lib/bundler_skills.rb
26
29
  - lib/bundler_skills/agent_registry.rb
27
- - lib/bundler_skills/command.rb
30
+ - lib/bundler_skills/cli.rb
28
31
  - lib/bundler_skills/config.rb
29
32
  - lib/bundler_skills/disabling.rb
30
33
  - lib/bundler_skills/discovered_skill.rb
31
34
  - lib/bundler_skills/discoverer.rb
32
35
  - lib/bundler_skills/gitignore_updater.rb
33
- - lib/bundler_skills/hook.rb
34
36
  - lib/bundler_skills/linker.rb
37
+ - lib/bundler_skills/rubygems_hook.rb
35
38
  - lib/bundler_skills/synchronizer.rb
36
39
  - lib/bundler_skills/version.rb
37
- - plugins.rb
40
+ - lib/rubygems_plugin.rb
38
41
  homepage: https://github.com/aki77/bundler-skills
39
42
  licenses:
40
43
  - MIT
@@ -1,126 +0,0 @@
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
- INIT_TEMPLATE = <<~YAML
13
- # bundler-skills.yml — all keys are optional
14
- #
15
- # enabled: # nil (auto) | false (off) | [development] (env list)
16
- # agents: # omit = auto-detect; or list: [claude, cursor]; or "*"
17
- # - claude
18
- # - cursor
19
- # gitignore: true # manage .gitignore (default true)
20
- # cleanup: true # prune stale gem-*--* links when a gem is removed (default true)
21
- # recursive: false # also scan skills/**/SKILL.md (default false)
22
- # include: # only these gems (empty = all). fnmatch on "gem" or "gem/skill"
23
- # - rubocop
24
- # - "rails-*"
25
- # exclude: # exclude these (wins over include)
26
- # - some-noisy-gem
27
- YAML
28
-
29
- def exec(_command_name, args)
30
- dry_run = !!args.delete("--dry-run")
31
- subcommand = args.shift || "sync"
32
-
33
- case subcommand
34
- when "sync" then run_sync(dry_run)
35
- when "list" then run_list
36
- when "clean" then run_clean(dry_run)
37
- when "init" then run_init
38
- when "help", "-h", "--help" then print_help
39
- else
40
- Bundler.ui.error("[bundler-skills] unknown subcommand: #{subcommand}")
41
- print_help
42
- end
43
- end
44
-
45
- private
46
-
47
- def synchronizer(dry_run: false)
48
- config = Config.load
49
- config = OverrideDryRun.new(config) if dry_run
50
- Synchronizer.new(config: config)
51
- end
52
-
53
- def run_sync(dry_run)
54
- synchronizer(dry_run: dry_run).sync
55
- end
56
-
57
- def run_list
58
- result = synchronizer.plan
59
- if result.discovered.empty?
60
- Bundler.ui.info("[bundler-skills] no skills found in dependency gems")
61
- return
62
- end
63
-
64
- agents = result.agents.map(&:key)
65
- Bundler.ui.info("[bundler-skills] #{result.discovered.size} skill(s) " \
66
- "-> agents: #{agents.empty? ? '(none detected)' : agents.join(', ')}")
67
- result.discovered.sort_by(&:link_name).each do |skill|
68
- Bundler.ui.info(" #{skill.link_name} -> #{skill.source_path}")
69
- end
70
- end
71
-
72
- def run_init
73
- path = Bundler.root / Config::CONFIG_FILENAME
74
- if File.exist?(path)
75
- Bundler.ui.warn("[bundler-skills] #{Config::CONFIG_FILENAME} already exists")
76
- return
77
- end
78
-
79
- File.write(path, INIT_TEMPLATE)
80
- Bundler.ui.info("[bundler-skills] created #{Config::CONFIG_FILENAME}")
81
- end
82
-
83
- def run_clean(dry_run)
84
- removed = synchronizer(dry_run: dry_run).clean
85
- total = removed.values.sum(&:size)
86
- verb = dry_run ? "would remove" : "removed"
87
- Bundler.ui.info("[bundler-skills] #{verb} #{total} link(s)")
88
- removed.each do |subdir, names|
89
- names.each { |n| Bundler.ui.info(" #{subdir}/#{n}") }
90
- end
91
- end
92
-
93
- def print_help
94
- Bundler.ui.info(<<~HELP)
95
- Usage: bundle skills [SUBCOMMAND] [--dry-run]
96
-
97
- sync (default) discover skills and (re)create symlinks
98
- list show discovered skills and target agents (no changes)
99
- clean remove all gem-*--* symlinks this plugin created
100
- init create a bundler-skills.yml config file with defaults
101
-
102
- Options:
103
- --dry-run show what would change without writing
104
- HELP
105
- end
106
-
107
- # Wraps a Config to force dry_run? true without mutating the original.
108
- class OverrideDryRun
109
- def initialize(config)
110
- @config = config
111
- end
112
-
113
- def dry_run?
114
- true
115
- end
116
-
117
- def respond_to_missing?(name, include_private = false)
118
- @config.respond_to?(name, include_private) || super
119
- end
120
-
121
- def method_missing(name, *args, &block)
122
- @config.send(name, *args, &block)
123
- end
124
- end
125
- end
126
- end
@@ -1,31 +0,0 @@
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
data/plugins.rb DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/bundler_skills"
4
- require_relative "lib/bundler_skills/command"
5
-
6
- BundlerSkills::Hook.register