skill 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +43 -10
- data/lib/skill/cli.rb +3 -1
- data/lib/skill/commands/command.rb +9 -5
- data/lib/skill/commands/install.rb +110 -0
- data/lib/skill/commands/remove.rb +1 -1
- data/lib/skill/commands.rb +1 -0
- data/lib/skill/lockfile.rb +30 -3
- data/lib/skill/session.rb +5 -1
- data/lib/skill/targets.rb +29 -16
- data/lib/skill/version.rb +1 -1
- data/lib/skill/workspace.rb +7 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee5cb9655ef0c0e762233b03de1732a2d623c3d2c73c3ed194ef4642abbb56da
|
|
4
|
+
data.tar.gz: e868591ccda4402de0f8ee1fd5d6dd69cffa35b46a6d37c3155e4efa4638987a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c201ca2ec016eadcfd7c169fef7697f08c2f23acec0af9c1c19e35c123864389c6b1068054e0404672684b689e361d86caf01e43d95fb52e1f686ce73c4e82bb
|
|
7
|
+
data.tar.gz: 7c8d2af1802808d86e6a81e30d137d715c1a4386d9d8ca5c606ce5ac1debb91fb815ed998d1bdf9e8ef89dd8f630e1941bfe7384e94c7a81d8d4de16d27ae93d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Each release's notes are in [`packaging/release-notes/`](packaging/release-notes) and on [GitHub Releases](https://github.com/crmne/skill/releases).
|
|
4
4
|
|
|
5
|
+
## 0.2.0 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
`--agents` with the `all` and `coding` groups, `skill install` to restore a lockfile, and the global lockfile in `~/.config/skill`. See [the notes](packaging/release-notes/v0.2.0.md).
|
|
8
|
+
|
|
5
9
|
## 0.1.0 (2026-10-17)
|
|
6
10
|
|
|
7
11
|
The first release: `add`, `list`, `update`, `remove`, `find`, and `agents`. See [the notes](packaging/release-notes/v0.1.0.md).
|
data/README.md
CHANGED
|
@@ -91,7 +91,7 @@ Private repositories work with a token in `GITHUB_TOKEN` or `GH_TOKEN`.
|
|
|
91
91
|
|
|
92
92
|
`skill` looks at your project and installs into every folder it shows signs of using:
|
|
93
93
|
|
|
94
|
-
| Target | `--
|
|
94
|
+
| Target | `--agents` | Project folder | Global folder | Detected by |
|
|
95
95
|
| --- | --- | --- | --- | --- |
|
|
96
96
|
| RubyLLM | `rubyllm` | `app/skills` | none | `ruby_llm` in the Gemfile or lockfile, or an `app/skills` folder |
|
|
97
97
|
| Claude Code | `claude-code` | `.claude/skills` | `~/.claude/skills` | `.claude/` or `CLAUDE.md` |
|
|
@@ -103,14 +103,26 @@ Private repositories work with a token in `GITHUB_TOKEN` or `GH_TOKEN`.
|
|
|
103
103
|
|
|
104
104
|
When it finds none, it uses `.agents/skills`, the folder many agents share. `skill agents` lists all 80 targets. Folders several agents share get one copy.
|
|
105
105
|
|
|
106
|
-
Choose instead of detecting:
|
|
106
|
+
Choose instead of detecting with `--agents` (`-a`), a comma-separated list of groups and agent names:
|
|
107
|
+
|
|
108
|
+
| `--agents` | Installs into |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `all` | Every folder detected, RubyLLM's `app/skills` included in a RubyLLM project. The default |
|
|
111
|
+
| `coding` | Every coding agent's folder detected, never RubyLLM's `app/skills` |
|
|
112
|
+
| `ruby_llm` | RubyLLM's `app/skills` only |
|
|
113
|
+
| `claude-code,cursor` | These agents' folders. `skill agents` lists the names |
|
|
114
|
+
| `*` | Every agent `skill agents` lists |
|
|
107
115
|
|
|
108
116
|
```sh
|
|
109
|
-
skill add anthropics/skills@pdf --
|
|
110
|
-
skill add anthropics/skills@pdf --
|
|
117
|
+
skill add anthropics/skills@pdf --agents coding # your coding agents, not your app
|
|
118
|
+
skill add anthropics/skills@pdf --agents ruby_llm # your app, not your coding agents
|
|
119
|
+
skill add anthropics/skills@pdf --agents claude-code,cursor
|
|
120
|
+
skill add anthropics/skills@pdf --agents coding,ruby_llm
|
|
111
121
|
skill add anthropics/skills@pdf --dir vendor/skills
|
|
112
122
|
```
|
|
113
123
|
|
|
124
|
+
`coding` and `all` fall back to `.agents/skills` when they detect nothing. `--agent` still works, as another name for `--agents`.
|
|
125
|
+
|
|
114
126
|
### Project or global
|
|
115
127
|
|
|
116
128
|
Inside a project, `skill` installs into the project. Outside one, it installs globally, into your home folder, for the agents you have installed:
|
|
@@ -122,7 +134,7 @@ skill add anthropics/skills@pdf
|
|
|
122
134
|
|
|
123
135
|
```
|
|
124
136
|
✓ Installed pdf → ~/.claude/skills/pdf (global)
|
|
125
|
-
Recorded in ~/.
|
|
137
|
+
Recorded in ~/.config/skill/skills.lock
|
|
126
138
|
```
|
|
127
139
|
|
|
128
140
|
A project is the nearest folder from where you run `skill` up that holds a `skills.lock`, a `Gemfile`, a `gems.rb`, a `.git`, or a `package.json`, or else an agent's folder or instructions (`.claude`, `.agents`, `.cursor`, `AGENTS.md`, `CLAUDE.md`, and the like). Your home folder never counts, since agents keep their global folders there. `list`, `update`, and `remove` decide the same way.
|
|
@@ -163,7 +175,28 @@ chat = RubyLLM.chat.with_skills(:pdf)
|
|
|
163
175
|
}
|
|
164
176
|
```
|
|
165
177
|
|
|
166
|
-
Entries are sorted and carry no timestamps, so two branches adding different skills merge cleanly.
|
|
178
|
+
Entries are sorted and carry no timestamps, so two branches adding different skills merge cleanly.
|
|
179
|
+
|
|
180
|
+
The global lockfile lives in `~/.config/skill/skills.lock` (or `$XDG_CONFIG_HOME/skill`), so you can keep it with your dotfiles, like a Brewfile. skill 0.1 kept it in `~/.local/state/skill`; the first command that reads it moves it and says so.
|
|
181
|
+
|
|
182
|
+
## Installing from the lockfile
|
|
183
|
+
|
|
184
|
+
`install` installs what `skills.lock` records, the way `bundle install` restores a `Gemfile.lock`: each skill at its recorded commit, into its recorded folders, creating any that are missing.
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
git clone https://github.com/acme/app && cd app
|
|
188
|
+
skill install # the project's skills
|
|
189
|
+
skill install --global # yours, on a new computer, from ~/.config/skill/skills.lock
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
✓ Installed pdf → app/skills/pdf, .claude/skills/pdf
|
|
194
|
+
· xlsx already installed
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A skill already in place with its recorded digest is left alone. Each copy it downloads must match that digest, or nothing is installed. A copy you edited is replaced only with `--force`. `install` never moves a skill to a newer commit: that is `update`'s job.
|
|
198
|
+
|
|
199
|
+
With a source, `skill install owner/repo` works like `skill add`, as `npx skills install` does.
|
|
167
200
|
|
|
168
201
|
## Updating
|
|
169
202
|
|
|
@@ -198,7 +231,7 @@ To move to another ref, add the skill again with it: `skill add anthropics/skill
|
|
|
198
231
|
skill list # installed skills, and any that were edited or are missing
|
|
199
232
|
skill list --json
|
|
200
233
|
skill remove pdf # from every folder it was installed into
|
|
201
|
-
skill remove pdf --
|
|
234
|
+
skill remove pdf --agents claude-code
|
|
202
235
|
```
|
|
203
236
|
|
|
204
237
|
`list` also names skill folders `skill` did not install. `remove` deletes only what `skill` installed.
|
|
@@ -237,7 +270,7 @@ A skill's instructions steer a model and its scripts run with whatever your sand
|
|
|
237
270
|
* **Installs are atomic.** Copies are built beside their folders and swapped in once all are ready. A failure leaves everything as it was.
|
|
238
271
|
* **Skills stay where they belong.** `add`, `update`, and `remove` write only inside the project, or when global inside your home folder (and any agent folder you moved elsewhere with its environment variable). Folders are checked with `..` and symbolic links resolved, so a `skills.lock` entry, a `--dir`, or a linked folder that leads elsewhere is refused with an error naming it, and nothing changes.
|
|
239
272
|
* **Your edits are yours.** A skill edited since it was installed, or a folder `skill` did not install, is replaced or removed only with `--force`.
|
|
240
|
-
* **No downloads at run time.** `skill` reaches the network only in `add`, `update`, and `find`. What it installs is plain files in your project.
|
|
273
|
+
* **No downloads at run time.** `skill` reaches the network only in `add`, `install`, `update`, and `find`. What it installs is plain files in your project.
|
|
241
274
|
* **GitHub without its API.** Refs come from git's own HTTP endpoint and files from one archive of the exact commit, so there is no rate limit and no git needed. A token, when set, goes only to GitHub.
|
|
242
275
|
|
|
243
276
|
## Environment
|
|
@@ -245,8 +278,8 @@ A skill's instructions steer a model and its scripts run with whatever your sand
|
|
|
245
278
|
| Variable | Use |
|
|
246
279
|
| --- | --- |
|
|
247
280
|
| `GITHUB_TOKEN`, `GH_TOKEN` | Read private repositories |
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
281
|
+
| `CLAUDE_CONFIG_DIR`, `CODEX_HOME` | Where those agents keep their global folders |
|
|
282
|
+
| `XDG_CONFIG_HOME` | Where the global lockfile lives, and where some agents keep their global folders |
|
|
250
283
|
| `INSTALL_INTERNAL_SKILLS` | Include skills whose front matter sets `metadata.internal: true` in `--all` |
|
|
251
284
|
| `SKILLS_API_URL` | Another server with the skills.sh search endpoint |
|
|
252
285
|
| `SKILL_MAX_*` | The limits above |
|
data/lib/skill/cli.rb
CHANGED
|
@@ -6,7 +6,8 @@ module Skill
|
|
|
6
6
|
# The +skill+ command line.
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
|
-
'add' => :Add, '
|
|
9
|
+
'add' => :Add, 'a' => :Add,
|
|
10
|
+
'install' => :Install, 'i' => :Install,
|
|
10
11
|
'list' => :List, 'ls' => :List,
|
|
11
12
|
'update' => :Update, 'upgrade' => :Update, 'up' => :Update,
|
|
12
13
|
'remove' => :Remove, 'rm' => :Remove, 'uninstall' => :Remove,
|
|
@@ -19,6 +20,7 @@ module Skill
|
|
|
19
20
|
|
|
20
21
|
Commands:
|
|
21
22
|
add <source> Install skills from GitHub or a local folder
|
|
23
|
+
install Install what skills.lock records, at its commits
|
|
22
24
|
list List installed skills
|
|
23
25
|
update [names] Update installed skills, showing what changes first
|
|
24
26
|
remove <names> Remove installed skills
|
|
@@ -17,13 +17,13 @@ module Skill
|
|
|
17
17
|
attr_reader :session, :options
|
|
18
18
|
|
|
19
19
|
# Parses the arguments with the options +define+ adds, plus the scope
|
|
20
|
-
# options (+--global+, +--project+, +--
|
|
21
|
-
# is false
|
|
20
|
+
# options (+--global+, +--project+, +--agents+, +--dir+) unless +scope+
|
|
21
|
+
# is false, or only +--global+ and +--project+ when it is +:place+.
|
|
22
22
|
# Returns the arguments left.
|
|
23
23
|
def parse(banner, scope: true, &define)
|
|
24
24
|
@parser = OptionParser.new do |opts|
|
|
25
25
|
opts.banner = "Usage: skill #{banner}"
|
|
26
|
-
scope_options(opts) if scope
|
|
26
|
+
scope_options(opts, targets: scope != :place) if scope
|
|
27
27
|
define&.call(opts)
|
|
28
28
|
end
|
|
29
29
|
@parser.parse(@argv)
|
|
@@ -33,12 +33,16 @@ module Skill
|
|
|
33
33
|
@parser.to_s
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def scope_options(opts)
|
|
36
|
+
def scope_options(opts, targets: true)
|
|
37
37
|
opts.on('-g', '--global', 'Use the home folder, even inside a project') { options[:global] = true }
|
|
38
38
|
opts.on('-p', '--project', 'Use the current folder as a project, even outside one') do
|
|
39
39
|
options[:global] = false
|
|
40
40
|
end
|
|
41
|
-
|
|
41
|
+
return unless targets
|
|
42
|
+
|
|
43
|
+
opts.on('-a', '--agents NAMES', '--agent NAMES',
|
|
44
|
+
'Target these agents (comma-separated, repeatable): all (detected, the default),',
|
|
45
|
+
'coding (detected coding agents, not RubyLLM), or names from `skill agents`') do |value|
|
|
42
46
|
options[:agents].concat(split(value))
|
|
43
47
|
end
|
|
44
48
|
opts.on('-d', '--dir PATH', 'Target this folder (repeatable)') do |value|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
module Commands
|
|
5
|
+
# skill install: installs what the lockfile records, each skill at its
|
|
6
|
+
# recorded commit, into its recorded folders, the way +bundle install+
|
|
7
|
+
# restores a Gemfile.lock. Skills already in place with the recorded
|
|
8
|
+
# digest are left alone, and each copy fetched is checked against that
|
|
9
|
+
# digest before it is written. Given a source, works as +skill add+.
|
|
10
|
+
class Install < Command
|
|
11
|
+
def run
|
|
12
|
+
return Add.new(session, @argv).run if @argv.any? { |argument| !argument.start_with?('-') }
|
|
13
|
+
|
|
14
|
+
names = parse('install [options]', scope: :place) do |opts|
|
|
15
|
+
opts.on('-f', '--force', 'Replace skills that were edited since they were installed') do
|
|
16
|
+
options[:force] = true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
raise UsageError, "skill install takes no names.\n\n#{usage}" unless names.empty?
|
|
20
|
+
|
|
21
|
+
install
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def install
|
|
27
|
+
lock = workspace.lock
|
|
28
|
+
return say("No skills recorded in #{workspace.display(lock.path)}.") if lock.names.empty?
|
|
29
|
+
|
|
30
|
+
missing = lock.names.to_h { |name| [name, missing_folders(name)] }.reject { |_name, folders| folders.empty? }
|
|
31
|
+
(lock.names - missing.keys).each do |name|
|
|
32
|
+
say "#{style.dim('·')} #{style.bold(name)} #{style.dim('already installed')}"
|
|
33
|
+
end
|
|
34
|
+
copies = missing.group_by { |name, _folders| lock[name].values_at('source', 'commit') }.values
|
|
35
|
+
restore(copies.flat_map { |group| fetch(group) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def restore(copies)
|
|
39
|
+
return 0 if copies.empty?
|
|
40
|
+
|
|
41
|
+
Installer.new.install(copies)
|
|
42
|
+
copies.each { |copy| say done("Installed #{style.bold(copy.name)} → #{copy.labels.join(', ')}") }
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the recorded folders of +name+ that do not hold it as
|
|
47
|
+
# recorded, after refusing any that hold an edited copy.
|
|
48
|
+
def missing_folders(name)
|
|
49
|
+
entry = workspace.lock[name]
|
|
50
|
+
workspace.folders(entry).reject do |folder|
|
|
51
|
+
path = Installer.destination(folder, name)
|
|
52
|
+
next true if workspace.unchanged?(path, entry)
|
|
53
|
+
|
|
54
|
+
workspace.ensure_replaceable(name, folder, options[:force])
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the copies for +group+, pairs of a skill name and its
|
|
60
|
+
# folders, which share a source and a commit.
|
|
61
|
+
def fetch(group)
|
|
62
|
+
entry = workspace.lock[group.first.first]
|
|
63
|
+
checkout = session.fetcher.checkout(source(entry), resolution: resolution(entry))
|
|
64
|
+
group.map { |name, folders| copy(checkout, name, folders) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def source(entry)
|
|
68
|
+
workspace.source_of(entry)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolution(entry)
|
|
72
|
+
commit = entry['commit']
|
|
73
|
+
return if commit.nil? || entry['ref_type'] == 'local'
|
|
74
|
+
|
|
75
|
+
unless commit.match?(/\A\h{40}\z/)
|
|
76
|
+
raise Error, "#{workspace.display(workspace.lock.path)} records an invalid commit for #{entry['source']}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
GitHub::Resolution.new(type: entry['ref_type'] == 'commit' ? :commit : entry['ref_type'].to_sym,
|
|
80
|
+
name: entry['ref'], commit: commit.downcase, branch: entry['ref'])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def copy(checkout, name, folders)
|
|
84
|
+
entry = workspace.lock[name]
|
|
85
|
+
path = File.join(checkout.root, entry['path'].to_s)
|
|
86
|
+
raise NotFoundError, "#{name}: #{entry['path']} is gone from #{entry['source']}" unless File.directory?(path)
|
|
87
|
+
if checkout.link_inside?(entry['path'])
|
|
88
|
+
raise UnsafeError, "#{entry['path']} holds a symbolic link; skills may not hold links"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
executable = entry['executable'] ? true : false
|
|
92
|
+
tree = Tree.read(path, limits: session.limits)
|
|
93
|
+
verify(name, entry, tree.digest(executable: executable))
|
|
94
|
+
Installer::Copy.new(name: name, tree: tree, targets: folders, executable: executable, entry: entry,
|
|
95
|
+
labels: folders.map { |folder| workspace.display(File.join(folder, name)) })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def verify(name, entry, digest)
|
|
99
|
+
return if digest == entry['digest']
|
|
100
|
+
|
|
101
|
+
if entry['ref_type'] == 'local'
|
|
102
|
+
raise ConflictError, "#{name}: #{entry['source']} changed since it was recorded. " \
|
|
103
|
+
'Run `skill update` to install what it holds now'
|
|
104
|
+
end
|
|
105
|
+
raise UnsafeError, "#{name}: #{entry['source']} at #{entry['commit'].to_s[0, 12]} does not match " \
|
|
106
|
+
"the digest #{workspace.display(workspace.lock.path)} records"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Skill
|
|
4
4
|
module Commands
|
|
5
5
|
# skill remove <names>: deletes skills this command installed, from
|
|
6
|
-
# every folder it recorded, or only those +--
|
|
6
|
+
# every folder it recorded, or only those +--agents+ and +--dir+ name.
|
|
7
7
|
class Remove < Command
|
|
8
8
|
def run
|
|
9
9
|
names = parse('remove <names> [options]') do |opts|
|
data/lib/skill/commands.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Skill
|
|
|
7
7
|
autoload :Agents, 'skill/commands/agents'
|
|
8
8
|
autoload :Command, 'skill/commands/command'
|
|
9
9
|
autoload :Find, 'skill/commands/find'
|
|
10
|
+
autoload :Install, 'skill/commands/install'
|
|
10
11
|
autoload :List, 'skill/commands/list'
|
|
11
12
|
autoload :Remove, 'skill/commands/remove'
|
|
12
13
|
autoload :Selection, 'skill/commands/selection'
|
data/lib/skill/lockfile.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Skill
|
|
|
8
8
|
# fetch it again and +remove+ knows what it owns. Entries are sorted by
|
|
9
9
|
# name and carry no timestamps, so two branches adding different skills
|
|
10
10
|
# merge cleanly. A project's lockfile sits at its root, to be committed;
|
|
11
|
-
# the global one in +$
|
|
11
|
+
# the global one in +$XDG_CONFIG_HOME/skill+ (+~/.config/skill+), beside
|
|
12
|
+
# other settings people keep with their dotfiles.
|
|
12
13
|
#
|
|
13
14
|
# {
|
|
14
15
|
# "version": 1,
|
|
@@ -35,10 +36,36 @@ module Skill
|
|
|
35
36
|
|
|
36
37
|
# Returns the global lockfile's path.
|
|
37
38
|
def self.global_path(env:, home:)
|
|
38
|
-
|
|
39
|
-
File.join(state.empty? ? File.join(home, '.local/state') : state, 'skill', NAME)
|
|
39
|
+
File.join(xdg(env, 'XDG_CONFIG_HOME', File.join(home, '.config')), 'skill', NAME)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Returns where skill 0.1 kept the global lockfile.
|
|
43
|
+
def self.legacy_global_path(env:, home:)
|
|
44
|
+
File.join(xdg(env, 'XDG_STATE_HOME', File.join(home, '.local/state')), 'skill', NAME)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Moves the global lockfile from where skill 0.1 kept it, when only that
|
|
48
|
+
# one exists. Returns the old path when it moved it, else +nil+.
|
|
49
|
+
def self.migrate_global(env:, home:)
|
|
50
|
+
old = legacy_global_path(env: env, home: home)
|
|
51
|
+
current = global_path(env: env, home: home)
|
|
52
|
+
return unless File.file?(old) && !File.exist?(current)
|
|
53
|
+
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(current))
|
|
55
|
+
File.rename(old, current)
|
|
56
|
+
old
|
|
57
|
+
rescue SystemCallError
|
|
58
|
+
FileUtils.cp(old, current)
|
|
59
|
+
FileUtils.rm_f(old)
|
|
60
|
+
old
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.xdg(env, variable, default)
|
|
64
|
+
value = env[variable].to_s.strip
|
|
65
|
+
value.empty? ? default : value
|
|
66
|
+
end
|
|
67
|
+
private_class_method :xdg
|
|
68
|
+
|
|
42
69
|
def initialize(path)
|
|
43
70
|
@path = path
|
|
44
71
|
@skills = File.exist?(path) ? parse(File.read(path, encoding: 'UTF-8')) : {}
|
data/lib/skill/session.rb
CHANGED
|
@@ -53,7 +53,11 @@ module Skill
|
|
|
53
53
|
# Returns the Workspace for the project, or the home folder with
|
|
54
54
|
# +global+, or with +global+ +nil+ whichever +cwd+ is in.
|
|
55
55
|
def workspace(global: nil)
|
|
56
|
-
@workspace ||= Workspace.open(cwd: cwd, home: home, env: env, global: global, limits: limits)
|
|
56
|
+
@workspace ||= Workspace.open(cwd: cwd, home: home, env: env, global: global, limits: limits).tap do |space|
|
|
57
|
+
if space.moved_lock
|
|
58
|
+
warn("Moved the global lockfile from #{space.display(space.moved_lock)} to #{space.display(space.lock.path)}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def limits
|
data/lib/skill/targets.rb
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Skill
|
|
4
4
|
# Decides which folders a command installs to, lists, or updates: the
|
|
5
|
-
# folders of the agents named with +--
|
|
6
|
-
# +--dir+, or else the ones the project or home folder shows
|
|
5
|
+
# folders of the agents and groups named with +--agents+, the folders
|
|
6
|
+
# named with +--dir+, or else the ones the project or home folder shows
|
|
7
|
+
# signs of.
|
|
7
8
|
class Targets
|
|
8
9
|
# One folder skills are installed into, and the agents that read it.
|
|
9
10
|
Target = Struct.new(:path, :label, :agents, keyword_init: true)
|
|
@@ -30,6 +31,14 @@ module Skill
|
|
|
30
31
|
|
|
31
32
|
SHARED_GLOBAL_DIRS = %w[~/.agents/skills {config}/agents/skills].freeze
|
|
32
33
|
FALLBACK = 'universal'
|
|
34
|
+
RUBY_LLM = 'rubyllm'
|
|
35
|
+
|
|
36
|
+
# Names +--agents+ takes for several agents at once: +all+, every agent
|
|
37
|
+
# detected (RubyLLM's +app/skills+ included in a RubyLLM project), and
|
|
38
|
+
# +coding+, every coding agent detected, never RubyLLM. Both fall back to
|
|
39
|
+
# the shared +.agents/skills+ folder when they detect none. +*+ is every
|
|
40
|
+
# agent +skill agents+ lists.
|
|
41
|
+
GROUPS = %w[all coding *].freeze
|
|
33
42
|
|
|
34
43
|
attr_reader :root, :home, :env
|
|
35
44
|
|
|
@@ -45,11 +54,12 @@ module Skill
|
|
|
45
54
|
@global
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
# Returns the Targets for +agents+
|
|
49
|
-
#
|
|
57
|
+
# Returns the Targets for +agents+ (names, aliases, or GROUPS) and
|
|
58
|
+
# +dirs+, or the detected ones (the +all+ group) when both are empty.
|
|
59
|
+
# Folders several agents share are returned once.
|
|
50
60
|
def resolve(agents: [], dirs: [])
|
|
51
|
-
|
|
52
|
-
list =
|
|
61
|
+
agents = ['all'] if agents.empty? && dirs.empty?
|
|
62
|
+
list = agents_named(agents).filter_map { |agent| target_for(agent, strict: true) }
|
|
53
63
|
list += dirs.map { |dir| Target.new(path: File.expand_path(dir, base), label: dir, agents: []) }
|
|
54
64
|
merge(list)
|
|
55
65
|
end
|
|
@@ -60,9 +70,11 @@ module Skill
|
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
# Returns the agents the project or home folder shows signs of, or the
|
|
63
|
-
# shared +.agents/skills+ folder when it shows none.
|
|
64
|
-
|
|
73
|
+
# shared +.agents/skills+ folder when it shows none. With +coding+,
|
|
74
|
+
# leaves RubyLLM out.
|
|
75
|
+
def detected_agents(coding: false)
|
|
65
76
|
found = Agents.all.select { |agent| global? ? installed?(agent) : used_in_project?(agent) }
|
|
77
|
+
found.reject! { |agent| agent.key == RUBY_LLM } if coding
|
|
66
78
|
found.empty? ? [Agents.fetch(FALLBACK)] : found
|
|
67
79
|
end
|
|
68
80
|
|
|
@@ -103,13 +115,14 @@ module Skill
|
|
|
103
115
|
end
|
|
104
116
|
|
|
105
117
|
def agents_named(names)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
names.flat_map do |name|
|
|
119
|
+
case name.to_s.strip.downcase
|
|
120
|
+
when '*' then Agents.all.select { |agent| global? ? agent.global? : true }
|
|
121
|
+
when 'all' then detected_agents
|
|
122
|
+
when 'coding' then detected_agents(coding: true)
|
|
123
|
+
else [Agents.fetch(name)]
|
|
124
|
+
end
|
|
125
|
+
end.uniq
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
def target_for(agent, strict: false)
|
|
@@ -129,7 +142,7 @@ module Skill
|
|
|
129
142
|
end
|
|
130
143
|
|
|
131
144
|
def used_in_project?(agent)
|
|
132
|
-
return ruby_llm? if agent.key ==
|
|
145
|
+
return ruby_llm? if agent.key == RUBY_LLM
|
|
133
146
|
return File.directory?(File.join(root, agent.project_dir)) if agent.key == FALLBACK
|
|
134
147
|
|
|
135
148
|
(PROJECT_SIGNS.fetch(agent.key, []) + own_signs(agent)).any? { |sign| File.exist?(File.join(root, sign)) }
|
data/lib/skill/version.rb
CHANGED
data/lib/skill/workspace.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Skill
|
|
|
8
8
|
ROOT_MARKERS = %w[skills.lock Gemfile gems.rb .git package.json].freeze
|
|
9
9
|
AGENT_MARKERS = %w[.agents .claude .codex .cursor .gemini .windsurf AGENTS.md CLAUDE.md GEMINI.md].freeze
|
|
10
10
|
|
|
11
|
-
attr_reader :root, :targets, :lock
|
|
11
|
+
attr_reader :root, :targets, :lock, :moved_lock
|
|
12
12
|
|
|
13
13
|
# Opens the project around +cwd+, or the home folder with +global+.
|
|
14
14
|
# With +global+ +nil+, uses the home folder when +cwd+ is in no
|
|
@@ -17,9 +17,10 @@ module Skill
|
|
|
17
17
|
project = project_root(cwd, home)
|
|
18
18
|
global = project.nil? if global.nil?
|
|
19
19
|
root = global ? home : project || cwd
|
|
20
|
+
moved = Lockfile.migrate_global(env: env, home: home) if global
|
|
20
21
|
lock = global ? Lockfile.global_path(env: env, home: home) : File.join(root, Lockfile::NAME)
|
|
21
22
|
new(root: root, targets: Targets.new(root: root, home: home, env: env, global: global),
|
|
22
|
-
lock: Lockfile.new(lock), limits: limits)
|
|
23
|
+
lock: Lockfile.new(lock), limits: limits, moved_lock: moved)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Returns the nearest folder from +cwd+ up holding a lockfile, a
|
|
@@ -47,7 +48,10 @@ module Skill
|
|
|
47
48
|
targets.global?
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
# +moved_lock+ is where the global lockfile was before this run moved
|
|
52
|
+
# it, if it did.
|
|
53
|
+
def initialize(root:, targets:, lock:, limits:, moved_lock: nil)
|
|
54
|
+
@moved_lock = moved_lock
|
|
51
55
|
@root = root
|
|
52
56
|
@targets = targets
|
|
53
57
|
@lock = lock
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: skill
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carmine Paolino
|
|
@@ -35,6 +35,7 @@ files:
|
|
|
35
35
|
- lib/skill/commands/agents.rb
|
|
36
36
|
- lib/skill/commands/command.rb
|
|
37
37
|
- lib/skill/commands/find.rb
|
|
38
|
+
- lib/skill/commands/install.rb
|
|
38
39
|
- lib/skill/commands/list.rb
|
|
39
40
|
- lib/skill/commands/remove.rb
|
|
40
41
|
- lib/skill/commands/selection.rb
|