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 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
+ [![CI](https://github.com/crmne/skill/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/skill/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+ require 'skill'
6
+
7
+ Skill::CLI.start(ARGV)
@@ -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