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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/PROPOSAL.md +83 -0
- data/README.md +165 -0
- data/lib/bundler_skills/agent_registry.rb +62 -0
- data/lib/bundler_skills/command.rb +96 -0
- data/lib/bundler_skills/config.rb +114 -0
- data/lib/bundler_skills/disabling.rb +56 -0
- data/lib/bundler_skills/discovered_skill.rb +37 -0
- data/lib/bundler_skills/discoverer.rb +63 -0
- data/lib/bundler_skills/gitignore_updater.rb +55 -0
- data/lib/bundler_skills/hook.rb +31 -0
- data/lib/bundler_skills/linker.rb +136 -0
- data/lib/bundler_skills/synchronizer.rb +94 -0
- data/lib/bundler_skills/version.rb +5 -0
- data/lib/bundler_skills.rb +15 -0
- data/plugins.rb +6 -0
- metadata +63 -0
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,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
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: []
|