skill 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/exe/skill +7 -0
- data/lib/skill/agents.rb +148 -0
- data/lib/skill/archive.rb +143 -0
- data/lib/skill/boundary.rb +42 -0
- data/lib/skill/cli.rb +90 -0
- data/lib/skill/commands/add.rb +139 -0
- data/lib/skill/commands/agents.rb +19 -0
- data/lib/skill/commands/command.rb +84 -0
- data/lib/skill/commands/find.rb +51 -0
- data/lib/skill/commands/list.rb +73 -0
- data/lib/skill/commands/remove.rb +59 -0
- data/lib/skill/commands/selection.rb +87 -0
- data/lib/skill/commands/update.rb +175 -0
- data/lib/skill/commands.rb +15 -0
- data/lib/skill/directory.rb +59 -0
- data/lib/skill/discovery.rb +166 -0
- data/lib/skill/fetcher.rb +62 -0
- data/lib/skill/github.rb +158 -0
- data/lib/skill/http.rb +76 -0
- data/lib/skill/installer.rb +92 -0
- data/lib/skill/limits.rb +40 -0
- data/lib/skill/lockfile.rb +112 -0
- data/lib/skill/manifest.rb +103 -0
- data/lib/skill/session.rb +88 -0
- data/lib/skill/source.rb +137 -0
- data/lib/skill/style.rb +28 -0
- data/lib/skill/targets.rb +173 -0
- data/lib/skill/tree.rb +90 -0
- data/lib/skill/version.rb +5 -0
- data/lib/skill/workspace.rb +161 -0
- data/lib/skill.rb +60 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d71981592f86798fc080114f847e07e7889bbe32b3cd7d6e41f1830e9c9be3f7
|
|
4
|
+
data.tar.gz: e7aa70524f1d82775670aa06c9bddb7073e337f5be30fa27b8f039f8b9b02dc1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8d880355668cece71955dc47f5cce34ac20dfa4155b8fdc66239ba7e5b4de3dc50c5b46ca05c9ceb608e5f14341c58484f7618485891ba4333229d1e6021d508
|
|
7
|
+
data.tar.gz: 0a9e6294de982fc8c93464b436b24d31e217f6d9fc3d85d74c89fa2edeab98088470819fc22c41ab1260de1edb4555b103def81ffdf2c2b695a6bd0c3a70ac53
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Each release's notes are in [`packaging/release-notes/`](packaging/release-notes) and on [GitHub Releases](https://github.com/crmne/skill/releases).
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
The first release: `add`, `list`, `update`, `remove`, `find`, and `agents`. See [the notes](packaging/release-notes/v0.1.0.md).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carmine Paolino
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# skill
|
|
4
|
+
|
|
5
|
+
<strong>Install Agent Skills into RubyLLM apps and coding agents, from Ruby</strong>
|
|
6
|
+
|
|
7
|
+
[](https://github.com/crmne/skill/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
<img src="docs/demo.gif" alt="gem exec skill finds the pdf skill on skills.sh and installs it globally from the home folder, then installs it into app/skills of a new Rails app that uses RubyLLM and shows skills.lock" width="800">
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
[Agent Skills](https://agentskills.io/specification) are folders of instructions, scripts, and reference files that teach a model a workflow. People publish them in GitHub repositories and plugin marketplaces. `skill` copies the ones you pick into the folder your app or agent reads them from, records where each came from, and shows you what changes before it updates them.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
gem exec skill find pdf # search skills.sh
|
|
20
|
+
gem exec skill add anthropics/skills@pdf # install one
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
✓ Installed pdf → app/skills/pdf, .claude/skills/pdf
|
|
25
|
+
Recorded in skills.lock
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It is the Ruby counterpart of [`npx skills`](https://github.com/vercel-labs/skills): the same sources, the same agent folders, the same [skills.sh](https://skills.sh) directory. [RubyLLM](https://rubyllm.com) is a first-class target, and it adds a lockfile with commits, updates that wait for your yes, and refusals instead of guesses. No runtime dependencies, Ruby 3.1 or newer, and it starts in about the time Ruby itself takes.
|
|
29
|
+
|
|
30
|
+
## Running it
|
|
31
|
+
|
|
32
|
+
With RubyGems 3.5 or newer (Ruby 3.3 ships it), run it without installing:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
gem exec skill add owner/repo
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`gem exec` asks RubyGems for the newest version on every run. Pass `--conservative` to use the version you already have, without the lookup:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
gem exec --conservative skill list
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Depending on your RDoc version, `gem exec` may print `Done installing documentation for after 0 seconds` before the output, even when it installed nothing. That line comes from RDoc's RubyGems hook, not from `skill`, and is harmless.
|
|
45
|
+
|
|
46
|
+
Or install it once:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
gem install skill
|
|
50
|
+
skill add owner/repo
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In a Gemfile, add it with `require: false`, so it never loads into your app:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
gem "skill", require: false, group: :development
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Adding skills
|
|
60
|
+
|
|
61
|
+
Name a repository, and pick skills from it:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
skill add anthropics/skills --list # what it holds, installs nothing
|
|
65
|
+
skill add anthropics/skills@pdf # one skill
|
|
66
|
+
skill add anthropics/skills@pdf,xlsx # several
|
|
67
|
+
skill add anthropics/skills --skill pdf --skill docx
|
|
68
|
+
skill add anthropics/skills --plugin document-skills
|
|
69
|
+
skill add anthropics/skills --all # every valid skill
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A repository with one skill installs it directly. A repository with several asks you to pick: `skill` never installs a whole repository you did not ask for.
|
|
73
|
+
|
|
74
|
+
Sources:
|
|
75
|
+
|
|
76
|
+
| Source | Meaning |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `owner/repo` | A GitHub repository, at its default branch |
|
|
79
|
+
| `owner/repo@pdf` | The skill named `pdf` in it |
|
|
80
|
+
| `owner/repo/skills/pdf` | One folder of it |
|
|
81
|
+
| `owner/repo#v1.2.0` | A branch, a tag, or a full 40-character commit SHA (also `--ref`) |
|
|
82
|
+
| `owner/repo#v1.2.0@pdf` | Both |
|
|
83
|
+
| `https://github.com/owner/repo/tree/main/skills/pdf` | A GitHub address |
|
|
84
|
+
| `./path/to/skills` | A folder on your computer |
|
|
85
|
+
|
|
86
|
+
`skill` finds skills where `npx skills` does: a `SKILL.md` at the top, `skills/` and the agents' own folders up to three levels deep, the plugins of a marketplace file (Claude Code's `.claude-plugin/marketplace.json`, Codex's `.agents/plugins/marketplace.json`, Cursor's `.cursor-plugin/marketplace.json`), and when those hold nothing, the whole repository. `--full-depth` searches the whole repository anyway.
|
|
87
|
+
|
|
88
|
+
Private repositories work with a token in `GITHUB_TOKEN` or `GH_TOKEN`.
|
|
89
|
+
|
|
90
|
+
## Where skills go
|
|
91
|
+
|
|
92
|
+
`skill` looks at your project and installs into every folder it shows signs of using:
|
|
93
|
+
|
|
94
|
+
| Target | `--agent` | Project folder | Global folder | Detected by |
|
|
95
|
+
| --- | --- | --- | --- | --- |
|
|
96
|
+
| RubyLLM | `rubyllm` | `app/skills` | none | `ruby_llm` in the Gemfile or lockfile, or an `app/skills` folder |
|
|
97
|
+
| Claude Code | `claude-code` | `.claude/skills` | `~/.claude/skills` | `.claude/` or `CLAUDE.md` |
|
|
98
|
+
| Codex | `codex` | `.agents/skills` | `~/.agents/skills` | `.codex/` or `AGENTS.md` |
|
|
99
|
+
| Cursor | `cursor` | `.agents/skills` | `~/.cursor/skills` | `.cursor/` or `.cursorrules` |
|
|
100
|
+
| Gemini CLI | `gemini-cli` | `.agents/skills` | `~/.gemini/skills` | `.gemini/` or `GEMINI.md` |
|
|
101
|
+
| GitHub Copilot | `github-copilot` | `.agents/skills` | `~/.copilot/skills` | `.github/copilot-instructions.md` |
|
|
102
|
+
| ...and 74 more | | | | their own folders |
|
|
103
|
+
|
|
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
|
+
|
|
106
|
+
Choose instead of detecting:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
skill add anthropics/skills@pdf --agent rubyllm
|
|
110
|
+
skill add anthropics/skills@pdf --agent claude-code,cursor
|
|
111
|
+
skill add anthropics/skills@pdf --dir vendor/skills
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Project or global
|
|
115
|
+
|
|
116
|
+
Inside a project, `skill` installs into the project. Outside one, it installs globally, into your home folder, for the agents you have installed:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
cd ~
|
|
120
|
+
skill add anthropics/skills@pdf
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
✓ Installed pdf → ~/.claude/skills/pdf (global)
|
|
125
|
+
Recorded in ~/.local/state/skill/skills.lock
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
Choose instead of deciding:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
skill add anthropics/skills@pdf --global # -g: the home folder, even inside a project
|
|
134
|
+
skill add anthropics/skills@pdf --project # -p: this folder as a project, even outside one
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Once installed in `app/skills`, RubyLLM finds a skill by name:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
chat = RubyLLM.chat.with_skills(:pdf)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## The lockfile
|
|
144
|
+
|
|
145
|
+
`skills.lock`, at the root of your project, records each skill's source, ref, commit, folder in the repository, the digest of its files, and where it is installed. Commit it with the skills.
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"version": 1,
|
|
150
|
+
"skills": {
|
|
151
|
+
"pdf": {
|
|
152
|
+
"source": "anthropics/skills",
|
|
153
|
+
"ref": null,
|
|
154
|
+
"ref_type": "branch",
|
|
155
|
+
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
|
156
|
+
"path": "skills/pdf",
|
|
157
|
+
"plugin": "document-skills",
|
|
158
|
+
"digest": "sha256-3a9465d4...",
|
|
159
|
+
"executable": false,
|
|
160
|
+
"targets": [".claude/skills", "app/skills"]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Entries are sorted and carry no timestamps, so two branches adding different skills merge cleanly. The global lockfile lives in `~/.local/state/skill/skills.lock` (or `$XDG_STATE_HOME/skill`).
|
|
167
|
+
|
|
168
|
+
## Updating
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
skill update # every skill
|
|
172
|
+
skill update pdf # one
|
|
173
|
+
skill update --dry-run # only show what would change
|
|
174
|
+
skill update --yes # apply without asking
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`update` fetches each source again at the ref you installed:
|
|
178
|
+
|
|
179
|
+
* **A commit** never moves. The skill is skipped.
|
|
180
|
+
* **A tag** is checked again. If someone moved it, `update` says so before anything else.
|
|
181
|
+
* **A branch** moves forward. `update` shows the commit range, a compare link, and every file added, changed, or removed:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
pdf: anthropics/skills dbd4588f9e10..5d6e7f8a9b0c
|
|
185
|
+
https://github.com/anthropics/skills/compare/dbd4588f9e10...5d6e7f8a9b0c
|
|
186
|
+
+ scripts/merge.py
|
|
187
|
+
~ SKILL.md
|
|
188
|
+
Apply this update? [y/N]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Nothing changes until you answer yes, or pass `--yes`. Without a terminal and without `--yes`, `update` exits with status 1, so a scheduled job can tell you an update is waiting. When nothing moved, `update` makes one small request per repository and downloads nothing.
|
|
192
|
+
|
|
193
|
+
To move to another ref, add the skill again with it: `skill add anthropics/skills#v2@pdf`.
|
|
194
|
+
|
|
195
|
+
## Listing and removing
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
skill list # installed skills, and any that were edited or are missing
|
|
199
|
+
skill list --json
|
|
200
|
+
skill remove pdf # from every folder it was installed into
|
|
201
|
+
skill remove pdf --agent claude-code
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`list` also names skill folders `skill` did not install. `remove` deletes only what `skill` installed.
|
|
205
|
+
|
|
206
|
+
## Finding skills
|
|
207
|
+
|
|
208
|
+
`find` searches [skills.sh](https://skills.sh), the directory `npx skills` uses:
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
skill find rails
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
rails-expert jeffallan/claude-skills 4.6K installs
|
|
216
|
+
skill add jeffallan/claude-skills@rails-expert
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`--owner` keeps one GitHub owner's skills.
|
|
220
|
+
|
|
221
|
+
## Safety
|
|
222
|
+
|
|
223
|
+
A skill's instructions steer a model and its scripts run with whatever your sandbox grants, so review a skill like any code you add. `skill` keeps the copying itself safe:
|
|
224
|
+
|
|
225
|
+
* **Every `SKILL.md` is checked** against the [specification](https://agentskills.io/specification) with the same rules as RubyLLM: front matter with a `name` of lowercase letters, digits, and single hyphens, at most 64 characters, and a `description` of at most 1,024. Invalid skills are listed and never installed.
|
|
226
|
+
* **Archives are extracted defensively.** A path that is absolute, holds `..`, or leaves its folder fails the whole download. Links are never written or followed, and a skill holding one is refused.
|
|
227
|
+
* **Sizes are capped.** GitHub sends a whole commit as one archive, so the download and extraction limits fit large repositories like `crmne/ruby_llm` and `heygen-com/hyperframes`, and both stream to disk rather than memory. Only the skills you pick are installed, so their limits stay tight. Raise any of them for sources you trust:
|
|
228
|
+
|
|
229
|
+
| Limit | Default | Variable |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| Download | 512 MB | `SKILL_MAX_DOWNLOAD_BYTES` |
|
|
232
|
+
| Extracted archive | 2 GB | `SKILL_MAX_EXTRACT_BYTES` |
|
|
233
|
+
| Files in the archive | 100,000 | `SKILL_MAX_EXTRACT_FILES` |
|
|
234
|
+
| One skill | 16 MB | `SKILL_MAX_SKILL_BYTES` |
|
|
235
|
+
| Files in one skill | 2,000 | `SKILL_MAX_SKILL_FILES` |
|
|
236
|
+
* **Executable bits are dropped,** with a warning naming the files, unless you pass `--keep-executable`. RubyLLM runs a skill's scripts through an interpreter, so they need none.
|
|
237
|
+
* **Installs are atomic.** Copies are built beside their folders and swapped in once all are ready. A failure leaves everything as it was.
|
|
238
|
+
* **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
|
+
* **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.
|
|
241
|
+
* **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
|
+
|
|
243
|
+
## Environment
|
|
244
|
+
|
|
245
|
+
| Variable | Use |
|
|
246
|
+
| --- | --- |
|
|
247
|
+
| `GITHUB_TOKEN`, `GH_TOKEN` | Read private repositories |
|
|
248
|
+
| `XDG_CONFIG_HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME` | Where agents keep their global folders |
|
|
249
|
+
| `XDG_STATE_HOME` | Where the global lockfile lives |
|
|
250
|
+
| `INSTALL_INTERNAL_SKILLS` | Include skills whose front matter sets `metadata.internal: true` in `--all` |
|
|
251
|
+
| `SKILLS_API_URL` | Another server with the skills.sh search endpoint |
|
|
252
|
+
| `SKILL_MAX_*` | The limits above |
|
|
253
|
+
| `NO_COLOR` | Plain output on a terminal too. Output that is not a terminal is always plain |
|
|
254
|
+
|
|
255
|
+
## Compared with `npx skills`
|
|
256
|
+
|
|
257
|
+
| | `npx skills` | `skill` |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| Runs with | Node.js | Ruby 3.1+, no dependencies |
|
|
260
|
+
| RubyLLM `app/skills` | No | Detected and first |
|
|
261
|
+
| Lockfile | Folder hash, no commit | Source, ref, commit, digest, folders |
|
|
262
|
+
| Updating | Installs the new version | Shows the commits and files, waits for yes; commits never move, moved tags are called out |
|
|
263
|
+
| Installs | Symlinks to one copy, or copies | Copies, swapped in atomically |
|
|
264
|
+
| Project or global | Asks on a terminal, else the project | The project when there is one, else global, and says which |
|
|
265
|
+
| Hosts | GitHub, GitLab, any git, well-known URLs | GitHub and local folders |
|
|
266
|
+
|
|
267
|
+
The agent folders and discovery rules follow [vercel-labs/skills](https://github.com/vercel-labs/skills) (MIT), so both tools install a skill to the same place.
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
```sh
|
|
272
|
+
bundle install
|
|
273
|
+
bundle exec rake # tests and RuboCop
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The tests never touch the network: a fake GitHub serves refs and archives built in the tests.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/skill
ADDED
data/lib/skill/agents.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# The places each target reads skills from, in a project and in the home
|
|
5
|
+
# folder. RubyLLM comes first; the coding agents follow the table of
|
|
6
|
+
# vercel-labs/skills (https://github.com/vercel-labs/skills), so both
|
|
7
|
+
# tools install a skill to the same folder.
|
|
8
|
+
module Agents
|
|
9
|
+
Agent = Struct.new(:key, :name, :project_dir, :global_dir, keyword_init: true) do
|
|
10
|
+
def global?
|
|
11
|
+
!global_dir.nil?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A global folder may start with a placeholder for a folder an
|
|
16
|
+
# environment variable can move: +{config}+ is +XDG_CONFIG_HOME+, and the
|
|
17
|
+
# others are the agents' own variables, such as +CLAUDE_CONFIG_DIR+.
|
|
18
|
+
HOMES = {
|
|
19
|
+
'config' => ['XDG_CONFIG_HOME', '~/.config'],
|
|
20
|
+
'claude' => ['CLAUDE_CONFIG_DIR', '~/.claude'],
|
|
21
|
+
'autohand' => ['AUTOHAND_HOME', '~/.autohand'],
|
|
22
|
+
'grok' => ['GROK_HOME', '~/.grok'],
|
|
23
|
+
'hermes' => ['HERMES_HOME', '~/.hermes'],
|
|
24
|
+
'vibe' => ['VIBE_HOME', '~/.vibe']
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
ROWS = [
|
|
28
|
+
['rubyllm', 'RubyLLM', 'app/skills', nil],
|
|
29
|
+
['aider-desk', 'AiderDesk', '.aider-desk/skills', '~/.aider-desk/skills'],
|
|
30
|
+
['amp', 'Amp', '.agents/skills', '{config}/agents/skills'],
|
|
31
|
+
['antigravity', 'Antigravity', '.agents/skills', '~/.gemini/antigravity/skills'],
|
|
32
|
+
['antigravity-cli', 'Antigravity CLI', '.agents/skills', '~/.gemini/antigravity-cli/skills'],
|
|
33
|
+
['astrbot', 'AstrBot', 'data/skills', '~/.astrbot/data/skills'],
|
|
34
|
+
['autohand-code', 'Autohand Code CLI', '.autohand/skills', '{autohand}/skills'],
|
|
35
|
+
['augment', 'Augment', '.augment/skills', '~/.augment/skills'],
|
|
36
|
+
['bob', 'IBM Bob', '.bob/skills', '~/.bob/skills'],
|
|
37
|
+
['claude-code', 'Claude Code', '.claude/skills', '{claude}/skills'],
|
|
38
|
+
['openclaw', 'OpenClaw', 'skills', '~/.openclaw/skills'],
|
|
39
|
+
['cline', 'Cline', '.agents/skills', '~/.agents/skills'],
|
|
40
|
+
['codearts-agent', 'CodeArts Agent', '.codeartsdoer/skills', '~/.codeartsdoer/skills'],
|
|
41
|
+
['codebuddy', 'CodeBuddy', '.codebuddy/skills', '~/.codebuddy/skills'],
|
|
42
|
+
['codemaker', 'Codemaker', '.codemaker/skills', '~/.codemaker/skills'],
|
|
43
|
+
['codestudio', 'Code Studio', '.codestudio/skills', '~/.codestudio/skills'],
|
|
44
|
+
['codex', 'Codex', '.agents/skills', '~/.agents/skills'],
|
|
45
|
+
['command-code', 'Command Code', '.commandcode/skills', '~/.commandcode/skills'],
|
|
46
|
+
['continue', 'Continue', '.continue/skills', '~/.continue/skills'],
|
|
47
|
+
['cortex', 'Cortex Code', '.cortex/skills', '~/.snowflake/cortex/skills'],
|
|
48
|
+
['crush', 'Crush', '.crush/skills', '~/.config/crush/skills'],
|
|
49
|
+
['cursor', 'Cursor', '.agents/skills', '~/.cursor/skills'],
|
|
50
|
+
['deepagents', 'Deep Agents', '.agents/skills', '~/.deepagents/agent/skills'],
|
|
51
|
+
['devin', 'Devin for Terminal', '.devin/skills', '{config}/devin/skills'],
|
|
52
|
+
['dexto', 'Dexto', '.agents/skills', '~/.agents/skills'],
|
|
53
|
+
['droid', 'Droid', '.agents/skills', '~/.factory/skills'],
|
|
54
|
+
['eve', 'Eve', 'agent/skills', nil],
|
|
55
|
+
['firebender', 'Firebender', '.agents/skills', '~/.firebender/skills'],
|
|
56
|
+
['forgecode', 'ForgeCode', '.forge/skills', '~/.forge/skills'],
|
|
57
|
+
['fx', 'fx', '.agents/skills', '~/.agents/skills'],
|
|
58
|
+
['gemini-cli', 'Gemini CLI', '.agents/skills', '~/.gemini/skills'],
|
|
59
|
+
['github-copilot', 'GitHub Copilot', '.agents/skills', '~/.copilot/skills'],
|
|
60
|
+
['goose', 'Goose', '.goose/skills', '{config}/goose/skills'],
|
|
61
|
+
['grok', 'Grok Build', '.grok/skills', '{grok}/skills'],
|
|
62
|
+
['hermes-agent', 'Hermes Agent', '.hermes/skills', '{hermes}/skills'],
|
|
63
|
+
['inference-sh', 'inference.sh', '.inferencesh/skills', '~/.inferencesh/skills'],
|
|
64
|
+
['jazz', 'Jazz', '.jazz/skills', '~/.jazz/skills'],
|
|
65
|
+
['junie', 'Junie', '.junie/skills', '~/.junie/skills'],
|
|
66
|
+
['iflow-cli', 'iFlow CLI', '.iflow/skills', '~/.iflow/skills'],
|
|
67
|
+
['kilo', 'Kilo Code', '.agents/skills', '~/.kilo/skills'],
|
|
68
|
+
['kimchi', 'Kimchi', '.kimchi/skills', '~/.config/kimchi/harness/skills'],
|
|
69
|
+
['kimi-code-cli', 'Kimi Code CLI', '.agents/skills', '~/.agents/skills'],
|
|
70
|
+
['kiro-cli', 'Kiro CLI', '.kiro/skills', '~/.kiro/skills'],
|
|
71
|
+
['kode', 'Kode', '.kode/skills', '~/.kode/skills'],
|
|
72
|
+
['lingma', 'Lingma', '.lingma/skills', '~/.lingma/skills'],
|
|
73
|
+
['loaf', 'Loaf', '.agents/skills', '~/.agents/skills'],
|
|
74
|
+
['mcpjam', 'MCPJam', '.mcpjam/skills', '~/.mcpjam/skills'],
|
|
75
|
+
['minimax-code', 'MiniMax Code', '.minimax/skills', '~/.minimax/skills'],
|
|
76
|
+
['mistral-vibe', 'Mistral Vibe', '.vibe/skills', '{vibe}/skills'],
|
|
77
|
+
['moxby', 'Moxby', '.moxby/skills', '~/.moxby/skills'],
|
|
78
|
+
['mux', 'Mux', '.mux/skills', '~/.mux/skills'],
|
|
79
|
+
['opencode', 'OpenCode', '.agents/skills', '{config}/opencode/skills'],
|
|
80
|
+
['openhands', 'OpenHands', '.openhands/skills', '~/.openhands/skills'],
|
|
81
|
+
['ona', 'Ona', '.ona/skills', '~/.ona/skills'],
|
|
82
|
+
['pi', 'Pi', '.agents/skills', '~/.agents/skills'],
|
|
83
|
+
['posit-assistant', 'Posit Assistant', '.posit/assistant/skills', '~/.posit/assistant/skills'],
|
|
84
|
+
['qoder', 'Qoder', '.qoder/skills', '~/.qoder/skills'],
|
|
85
|
+
['qoder-cn', 'Qoder CN', '.qoder/skills', '~/.qoder-cn/skills'],
|
|
86
|
+
['qwen-code', 'Qwen Code', '.qwen/skills', '~/.qwen/skills'],
|
|
87
|
+
['replit', 'Replit', '.agents/skills', '{config}/agents/skills'],
|
|
88
|
+
['reasonix', 'Reasonix', '.reasonix/skills', '~/.reasonix/skills'],
|
|
89
|
+
['rovodev', 'Rovo Dev', '.rovodev/skills', '~/.rovodev/skills'],
|
|
90
|
+
['roo', 'Roo Code', '.roo/skills', '~/.roo/skills'],
|
|
91
|
+
['sarvam-code', 'Sarvam Code', '.agents/skills', '~/.agents/skills'],
|
|
92
|
+
['tabnine-cli', 'Tabnine CLI', '.tabnine/agent/skills', '~/.tabnine/agent/skills'],
|
|
93
|
+
['terramind', 'Terramind', '.terramind/skills', '~/.terramind/skills'],
|
|
94
|
+
['tinycloud', 'Tinycloud', '.tinycloud/skills', '~/.tinycloud/skills'],
|
|
95
|
+
['trae', 'Trae', '.trae/skills', '~/.trae/skills'],
|
|
96
|
+
['trae-cn', 'Trae CN', '.trae/skills', '~/.trae-cn/skills'],
|
|
97
|
+
['warp', 'Warp', '.agents/skills', '~/.agents/skills'],
|
|
98
|
+
['windsurf', 'Windsurf', '.windsurf/skills', '~/.codeium/windsurf/skills'],
|
|
99
|
+
['zed', 'Zed', '.agents/skills', '~/.agents/skills'],
|
|
100
|
+
['zcode', 'ZCode', '.zcode/skills', '~/.zcode/skills'],
|
|
101
|
+
['zencoder', 'Zencoder', '.zencoder/skills', '~/.zencoder/skills'],
|
|
102
|
+
['zenflow', 'Zenflow', '.zencoder/skills', '~/.zencoder/skills'],
|
|
103
|
+
['neovate', 'Neovate', '.neovate/skills', '~/.neovate/skills'],
|
|
104
|
+
['pochi', 'Pochi', '.pochi/skills', '~/.pochi/skills'],
|
|
105
|
+
['promptscript', 'PromptScript', '.agents/skills', nil],
|
|
106
|
+
['adal', 'AdaL', '.adal/skills', '~/.adal/skills'],
|
|
107
|
+
['universal', 'Universal', '.agents/skills', '{config}/agents/skills']
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
ALIASES = {
|
|
111
|
+
'ruby_llm' => 'rubyllm', 'ruby-llm' => 'rubyllm', 'rails' => 'rubyllm',
|
|
112
|
+
'claude' => 'claude-code', 'copilot' => 'github-copilot', 'gemini' => 'gemini-cli'
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
115
|
+
ALL = ROWS.to_h do |key, name, project_dir, global_dir|
|
|
116
|
+
[key, Agent.new(key: key, name: name, project_dir: project_dir, global_dir: global_dir).freeze]
|
|
117
|
+
end.freeze
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
# Returns every Agent, RubyLLM first.
|
|
121
|
+
def all
|
|
122
|
+
ALL.values
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns the Agent called +key+ or one of its aliases. Raises
|
|
126
|
+
# UsageError for an unknown name.
|
|
127
|
+
def fetch(key)
|
|
128
|
+
name = key.to_s.strip.downcase
|
|
129
|
+
ALL.fetch(ALIASES.fetch(name, name)) do
|
|
130
|
+
raise UsageError, "Unknown agent #{key.inspect}. Run `skill agents` for the list"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns the absolute global folder of +agent+, or +nil+ when it has
|
|
135
|
+
# none, reading placeholders from +env+ and +~+ as +home+.
|
|
136
|
+
def global_path(agent, env:, home:)
|
|
137
|
+
return unless agent.global?
|
|
138
|
+
|
|
139
|
+
path = agent.global_dir.sub(/\A\{(\w+)\}/) do
|
|
140
|
+
variable, default = HOMES.fetch(Regexp.last_match(1))
|
|
141
|
+
value = env[variable].to_s.strip
|
|
142
|
+
value.empty? ? default : value
|
|
143
|
+
end
|
|
144
|
+
File.expand_path(path.sub(%r{\A~(?=/|\z)}, home))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'rubygems/package'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'zlib'
|
|
7
|
+
|
|
8
|
+
module Skill
|
|
9
|
+
# Extracts a gzipped tarball of a repository, as GitHub serves it, into a
|
|
10
|
+
# folder. Only regular files and folders are written. Links are never
|
|
11
|
+
# written or followed: their paths are returned, so a skill holding one
|
|
12
|
+
# can be refused. Any path that is absolute, holds +..+, or would leave
|
|
13
|
+
# the folder fails the whole archive, as does going over the limits.
|
|
14
|
+
class Archive
|
|
15
|
+
Result = Struct.new(:commit, :links, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
FILE_TYPES = ['0', "\0", ''].freeze
|
|
18
|
+
LINK_TYPES = %w[1 2].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(limits: Limits.new)
|
|
21
|
+
@limits = limits
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Extracts the gzipped tarball at +path+ into the folder +into+, dropping
|
|
25
|
+
# the top folder every GitHub archive has. Returns a Result with the
|
|
26
|
+
# commit git recorded in the archive's pax header, if any.
|
|
27
|
+
def extract(path, into)
|
|
28
|
+
Tempfile.create(['skill', '.tar'], binmode: true) do |tar|
|
|
29
|
+
gunzip(path, tar)
|
|
30
|
+
tar.rewind
|
|
31
|
+
read(tar, File.realpath(into))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def gunzip(path, tar)
|
|
38
|
+
size = 0
|
|
39
|
+
Zlib::GzipReader.open(path) do |gzip|
|
|
40
|
+
while (chunk = gzip.read(1024 * 1024))
|
|
41
|
+
size += chunk.bytesize
|
|
42
|
+
if size > @limits.extract_bytes
|
|
43
|
+
raise UnsafeError,
|
|
44
|
+
Limits.exceeded(:extract_bytes, "The archive expands to more than #{@limits.extract_bytes} bytes")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
tar.write(chunk)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rescue Zlib::Error
|
|
51
|
+
raise UnsafeError, 'The download is not a gzip archive'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(tar, into)
|
|
55
|
+
state = { commit: nil, links: [], files: 0, long_name: nil }
|
|
56
|
+
Gem::Package::TarReader.new(tar).each { |entry| visit(entry, into, state) }
|
|
57
|
+
Result.new(commit: state[:commit], links: state[:links].uniq.freeze)
|
|
58
|
+
rescue Gem::Package::TarInvalidError, ArgumentError => e
|
|
59
|
+
raise UnsafeError, "The download is not a valid tar archive (#{e.class})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def visit(entry, into, state)
|
|
63
|
+
type = entry.header.typeflag
|
|
64
|
+
case type
|
|
65
|
+
when 'g' then state[:commit] ||= pax(entry.read.to_s)['comment']&.then { |sha| sha[/\A\h{40}\z/] }
|
|
66
|
+
when 'x' then state[:long_name] = pax(entry.read.to_s)['path']
|
|
67
|
+
when 'L' then state[:long_name] = entry.read.to_s.delete("\0")
|
|
68
|
+
else store(entry, type, into, state)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def store(entry, type, into, state)
|
|
73
|
+
name = state[:long_name] || entry.full_name
|
|
74
|
+
state[:long_name] = nil
|
|
75
|
+
parts = relative_parts(name)
|
|
76
|
+
return if parts.empty?
|
|
77
|
+
|
|
78
|
+
if FILE_TYPES.include?(type)
|
|
79
|
+
state[:files] += 1
|
|
80
|
+
if state[:files] > @limits.extract_files
|
|
81
|
+
raise UnsafeError,
|
|
82
|
+
Limits.exceeded(:extract_files, "The archive holds more than #{@limits.extract_files} files")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
write(File.join(into, *parts), entry)
|
|
86
|
+
elsif type == '5'
|
|
87
|
+
directory(File.join(into, *parts))
|
|
88
|
+
elsif LINK_TYPES.include?(type)
|
|
89
|
+
state[:links] << parts.join('/')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Splits an archive path into the parts below the archive's top folder,
|
|
94
|
+
# refusing anything that could land outside the extraction folder.
|
|
95
|
+
def relative_parts(name)
|
|
96
|
+
text = name.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
97
|
+
if !text.valid_encoding? || text.include?("\0") || text.include?('\\') || text.start_with?('/') ||
|
|
98
|
+
text.match?(/\A[A-Za-z]:/)
|
|
99
|
+
raise UnsafeError, "The archive holds an unsafe path, #{name.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
parts = text.split('/').reject { |part| part.empty? || part == '.' }
|
|
103
|
+
raise UnsafeError, "The archive holds an unsafe path, #{name.inspect}" if parts.include?('..')
|
|
104
|
+
|
|
105
|
+
parts.drop(1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def write(path, entry)
|
|
109
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
110
|
+
raise UnsafeError, "The archive writes #{path} twice" if File.symlink?(path) || File.directory?(path)
|
|
111
|
+
|
|
112
|
+
File.open(path, 'wb', 0o644) do |file|
|
|
113
|
+
while (chunk = entry.read(1024 * 1024))
|
|
114
|
+
file.write(chunk)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
File.chmod(entry.header.mode.to_i.anybits?(0o111) ? 0o755 : 0o644, path)
|
|
118
|
+
rescue SystemCallError => e
|
|
119
|
+
raise UnsafeError, "The archive could not be extracted: #{e.class}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def directory(path)
|
|
123
|
+
FileUtils.mkdir_p(path)
|
|
124
|
+
rescue SystemCallError => e
|
|
125
|
+
raise UnsafeError, "The archive could not be extracted: #{e.class}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Reads pax extended header records: "<length> <key>=<value>\n".
|
|
129
|
+
def pax(data)
|
|
130
|
+
records = {}
|
|
131
|
+
data = data.dup.force_encoding(Encoding::BINARY)
|
|
132
|
+
until data.empty?
|
|
133
|
+
length = data[/\A\d+/].to_i
|
|
134
|
+
break if length <= 0 || length > data.bytesize
|
|
135
|
+
|
|
136
|
+
key, value = data.byteslice(0, length).sub(/\A\d+ /, '').chomp.split('=', 2)
|
|
137
|
+
records[key] = value.to_s.force_encoding(Encoding::UTF_8) if key
|
|
138
|
+
data = data.byteslice(length..)
|
|
139
|
+
end
|
|
140
|
+
records
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# The folders a command may write skills into. A path is inside when,
|
|
5
|
+
# with +..+ and symbolic links resolved, it lies in one of them, so a
|
|
6
|
+
# lockfile or a link cannot lead an install or a removal elsewhere.
|
|
7
|
+
class Boundary
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
# +folders+ are the folders allowed; +name+ says what they are, as
|
|
11
|
+
# people read it ("the project").
|
|
12
|
+
def initialize(folders, name:)
|
|
13
|
+
@folders = folders.filter_map { |folder| self.class.real_path(File.expand_path(folder)) }.uniq
|
|
14
|
+
@name = name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns whether +path+ really lies inside one of the folders.
|
|
18
|
+
def include?(path)
|
|
19
|
+
real = self.class.real_path(File.expand_path(path))
|
|
20
|
+
return false unless real
|
|
21
|
+
|
|
22
|
+
@folders.any? { |folder| real == folder || real.start_with?("#{folder}/") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns +path+ with the deepest part that exists resolved and the
|
|
26
|
+
# rest appended, or +nil+ when a link in it leads nowhere.
|
|
27
|
+
def self.real_path(path)
|
|
28
|
+
missing = []
|
|
29
|
+
current = path
|
|
30
|
+
until File.exist?(current) || File.symlink?(current)
|
|
31
|
+
parent = File.dirname(current)
|
|
32
|
+
return path if parent == current
|
|
33
|
+
|
|
34
|
+
missing.unshift(File.basename(current))
|
|
35
|
+
current = parent
|
|
36
|
+
end
|
|
37
|
+
File.join(File.realpath(current), *missing)
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|