skillcask 0.9.0

A lockfile-first package manager for AI agent skills (SKILL.md), backed by Composer repositories
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# skillcask

A lockfile-first package manager for AI agent skills: directories with a `SKILL.md`, read by
Claude Code, Codex, Junie and others.

skillcask installs skills the way Cargo or Composer install libraries. It reads a manifest with
version constraints and commits a lock with content hashes. In CI it does frozen installs that
fail on drift, and it links every skill into each agent's skill directory. It ships as a single
static binary with no runtime, so it works in a repository in any language.

Skills come from Composer repositories or straight from GitHub. Packagist serves public skill
packages; GitLab's Composer registry, Satis, Private Packagist or the static repository built by
`skillcask publish` serve private ones. Any GitHub repository with `SKILL.md` files works too,
which covers most of what [skills.sh](https://skills.sh) lists. skillcask does not need PHP,
Composer or Node.

## Install

**macOS, Linux**

```sh
brew install puniserv/tap/skillcask
# or, without Homebrew:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/puniserv/skillcask/releases/latest/download/skillcask-installer.sh | sh
```

**Windows (PowerShell)**

```powershell
powershell -ExecutionPolicy Bypass -c "irm https://github.com/puniserv/skillcask/releases/latest/download/skillcask-installer.ps1 | iex"
```

The two installer scripts download the binary for your platform from the latest release and put
it in `~/.cargo/bin` (`%USERPROFILE%\.cargo\bin` on Windows).

**npm, bun**

```sh
npm install -g skillcask      # global
bun add -g skillcask          # global, bun
npx skillcask install --frozen
bunx skillcask install --frozen
```

The npm package is a thin wrapper that downloads the release binary for your platform from
GitHub. It does not check a checksum itself; where that matters, use Homebrew or a release
archive and verify it.

**Cargo**

```sh
cargo install --locked skillcask
```

**Release archives** for macOS, Linux and Windows are on the
[releases page](https://github.com/puniserv/skillcask/releases), with sha256 checksums and GitHub
build provenance attestations:

```sh
gh attestation verify skillcask-x86_64-unknown-linux-gnu.tar.xz -R puniserv/skillcask
```

**GitHub Actions**

```yaml
permissions:
  contents: read
  attestations: read   # for the provenance check
steps:
  - uses: actions/checkout@<sha>
  - uses: puniserv/skillcask@<commit sha of a release tag>   # vX.Y.Z
    with:
      version: X.Y.Z               # needed when the action is pinned to a sha, not a tag
      args: install --frozen       # default
      # working-directory: .
      # verify-attestation: "true" # default
```

The action downloads the release archive for the runner (Linux x64 and arm64, macOS, Windows
x64), checks its sha256 and its build provenance attestation, puts `skillcask` on `PATH` and runs
it with `args`. It runs no other action. Used at a tag (`@vX.Y.Z`) it takes the version from the
tag.

## Use

```sh
skillcask add netresearch/agent-jira-skill   # resolve, write skills.toml + skills.lock, install
skillcask add github:mattpocock/skills/tdd   # one skill from a GitHub repository
skillcask install --frozen                   # CI: install exactly the lock or exit non-zero
skillcask status                             # manifest vs lock vs installed files
```

`add` creates:

```
skills.toml                  # manifest, commit it
skills.lock                  # lock, commit it
.agents/skills/<name>/       # installed skill (gitignored)
.agents/skills/.skillcask-installed  # what this machine installed (gitignored)
.claude/skills/<name>  -> ../../.agents/skills/<name>
.gitignore                   # managed block
```

Project skills live in `.skills/<name>/SKILL.md` (`[agents] local`). skillcask links each one
into the store (`.agents/skills/<name> -> ../../.skills/<name>`) and from there into every agent
directory. These links are committed, so they work in every clone. A directory written by hand
straight into `.agents/skills/` is linked the same way. A project skill with the same name as a
locked one is an error. When a skill with the same name exists in your home (`~/.claude/skills`,
`~/.agents/skills`, …), `install` and `doctor` print a warning: Claude Code runs the personal one,
Codex lists both.

### skills.toml

```toml
[repositories]
acme = { type = "composer", url = "https://git.example.com/api/v4/group/42/-/packages/composer/packages.json" }
# A repository you review yourself can skip the cooldown; the others keep it.
own = { url = "https://git.example.com/own/packages.json", min-release-age = "0" }
# Default sources, written by `init`; `= false` turns one off. Packagist is always searched last.
packagist = { url = "https://repo.packagist.org" }
github = { type = "github", url = "https://api.github.com" }   # GitHub Enterprise: its API URL

[skills]
"netresearch/agent-jira-skill" = "^3.34"
"acme/agents-md" = { version = "^1.0", repository = "acme" }
"acme/review" = { version = "1.4.2", repository = "acme", skills = ["review"] }
"mattpocock/skills" = { github = true, version = "^1.3", skills = ["tdd", "code-review"], rename = { code-review = "mp-code-review" } }
"anthropics/skills" = { github = true, skills = ["pdf"] }      # default branch

[agents]
targets = ["claude", "kiro"]   # agent names or paths with a "/"; default: config.toml, else ["claude"]
local = ".skills"        # project skills

[policy]
min-release-age = "3d"   # cooldown for add/update; "0" disables
allow-ranges = true      # false: every version must be exact
packagist = true         # false: drop the implicit Packagist repository
github-requires = false  # true: dependencies may come from GitHub or git URLs
allow-sources = ["acme", "own", "packagist", "github"]   # only these sources; default: all
deny-patterns = false    # true: risky patterns in new skill text fail instead of warning
```

### Agents

Most agents read `.agents/skills` themselves (Codex, Copilot, Cursor, Gemini CLI, OpenCode, Amp,
Junie, Devin/Windsurf, Roo Code, Cline, Goose, Factory, Mistral Vibe), so they need no link.
`[agents] targets` lists the agents that need one, by name, or any directory as a path with a `/`:

| Name | Project | Home (`--global`) |
|---|---|---|
| `claude` | `.claude/skills` | `~/.claude/skills` |
| `kiro` | `.kiro/skills` | `~/.kiro/skills` |
| `junie` | store | `~/.junie/skills` |
| `codex`, `copilot`, `cursor`, `gemini`, `opencode`, `amp`, `devin`, `roo`, `cline`, `goose`, `factory`, `vibe` | store | store |

Set the list once in `config.toml` next to the global manifest; a `skills.toml` that sets
`targets` replaces it. Manifests from before 0.6.0 carry an explicit list: delete the line to
follow `config.toml`.

```toml
# ~/.config/skillcask/config.toml
[agents]
targets = ["claude", "kiro"]

[search]
skills-sh = true   # search also asks skills.sh (it receives the query); written by search --remember
```

When a target is dropped, the next `install` removes the links skillcask made there; `doctor`
reports them. `status` prints the targets, where they came from and the directories they link.

Constraints follow Composer semantics: `^0.3` means `>=0.3.0 <0.4.0`, `~1.2` means
`>=1.2 <2.0`, and pre-releases are only selected when the constraint names a stability
(`^1.0@beta`).

### Allowed sources and policy for every project

`allow-sources` lists the only places skills may come from: repository names, `packagist`,
`github`, or `https://` URL prefixes (`https://api.github.com/repos/acme/` allows one GitHub
organisation). Other repositories get no request, and `install` refuses a lock entry from them,
also with `--frozen`, so a committed lock cannot bring them back. Dependencies follow the same
rule.

Policy can also come from `config.toml` next to the global manifest (`~/.config/skillcask`) and
from the environment. The first layer that sets a key wins:

1. `SKILLCASK_ALLOW_SOURCES`, `SKILLCASK_MIN_RELEASE_AGE`, `SKILLCASK_ALLOW_RANGES`,
   `SKILLCASK_PACKAGIST`, `SKILLCASK_GITHUB_REQUIRES`, `SKILLCASK_DENY_PATTERNS` (overriding an
   enforced key prints a warning);
2. keys `config.toml` lists in `enforce`: a project that sets them otherwise fails;
3. `[policy]` in `skills.toml`;
4. the other keys of `config.toml`, as defaults.

```toml
# ~/.config/skillcask/config.toml: only the company registry and GitHub, in every project
[policy]
allow-sources = ["https://gitlab.example.com/", "github"]
enforce = ["allow-sources"]
```

An enforced `min-release-age` also applies to a repository's own `min-release-age`: a project
may only make it longer. Exceptions, by URL prefix, stay in `config.toml`:

```toml
[policy]
min-release-age = "3d"
enforce = ["min-release-age"]

[policy.repository-min-release-age]
"https://gitlab.example.com/" = "0"   # internal skills, reviewed in merge requests
```

`config.toml` and the environment take URL prefixes, not repository names, since a name means
whatever a project calls it. `skillcask status` prints each key that is set and where it came
from.

### Dependencies

A skill that needs other skills lists them in its `SKILL.md`:

```yaml
---
name: gitlab-branch-mr
version: 1.2.0
requires:
  acme/security-review: ^1.0
  github:mattpocock/skills/pr: main
  "git:https://git.example.com/team/skills.git#review": ^2.0
---
```

`add`, `install` and `update` resolve, lock and install them like your own entries: same
cooldown, same hashes, one version per package (the highest one every requirer accepts). They
stay out of `skills.toml`; the lock records each package's `requires` and switches to
`version = 2`. A dependency is looked up only in your repositories, in order, then Packagist;
a `github:` or `git:<url>[#skill]` one needs `github-requires = true`. `remove` drops dependencies nobody needs any
more, and `status` shows them as `via <requirer>`.

### Name conflicts

Skills have no namespaces: an agent knows a skill by its name. Two skills with the same name in
one project are an error. Install one of them under another name with `rename`:

```toml
"mattpocock/skills" = { github = true, rename = { code-review = "mp-code-review" } }
```

skillcask then installs `.agents/skills/mp-code-review` and changes the `name:` line in its
`SKILL.md`; the lock records the original name and the hash of the renamed copy. Nothing else in
the skill changes, so text that mentions the old name (`/code-review`) stays as it is.
`add`, `install` and `update` offer the rename when they hit a conflict in a terminal.

### Personal skills (`--global`)

`skillcask --global` (`-g`) manages skills for your user instead of a project, with the same
manifest, lock and verification:

```sh
skillcask -g add github:mattpocock/skills/grill-me
skillcask -g install --frozen    # e.g. from a dotfiles repository
```

- `skills.toml`, `skills.lock` and an optional `auth.json` live in `~/.config/skillcask`
  (`$XDG_CONFIG_HOME/skillcask`; `SKILLCASK_HOME` overrides both). Keep them in your dotfiles.
- Skills go to `~/.agents/skills` (read by most agents), with links in `~/.claude/skills`
  (`[agents] targets`, see Agents). Links are absolute, so a symlinked
  `~/.claude` works. `[agents] local` does not apply.
- Personal skills you wrote by hand stay where they are and are not linked anywhere. `status`
  lists them, and copies other tools put in a target, as `not managed`; `status --json` names the
  source `npx skills` recorded for one. `skillcask -g add --adopt <source>` moves such a skill to
  `~/.config/skillcask/adopted/` and installs the package's skill of the same name in its place.
- The list of managed paths is `~/.agents/skills/.skillcask-installed`, local to the machine;
  nothing touches git.

### Commands

Every command except `publish`, `validate` and `projects` takes `-g, --global`.

| Command | |
|---|---|
| `init` | Create `skills.toml`. |
| `add <vendor/name>[@constraint] [--repository r]` | Resolve, lock and install. |
| `add github:<owner/repo>[/skill][@version-or-ref]` | The same from a GitHub repository, see below. |
| `add git:<https-url>[@version-or-ref]` | The same from any git repository over HTTPS, see below. |
| `remove <vendor/name>…` | Drop from manifest and lock; delete files, links and gitignore lines. |
| `install [--frozen]` | Install the lock. `--frozen` (implied by `CI`) fails when the lock is out of date. |
| `update [vendor/name…] [--diff]` | Re-resolve within constraints. `--diff` prints what changes in each updated or added package; it does not ask before installing. |
| `outdated [--strict]` | Newer versions, when cooled-down ones become eligible, and GitHub or git tags moved since they were locked. `--strict` exits 1 when `update` would lock a newer version. |
| `diff [vendor/name] [--to constraint\|--from old.lock]` | Unified diff of skill files from the locked version to the one `update` would lock, or from an older lock to `skills.lock`, see below. |
| `info <vendor/name[@constraint]\|github:owner/repo\|git:url\|skill>` | A package's skills, versions, description, license and requirements, or which package installs a skill. |
| `status` | Exit 1 when manifest, lock or installed files disagree. Also lists the repositories in use (name, type, URL without credentials) and whether `allow-sources` admits each. |
| `doctor [--fix]` | Broken or committed links, stale gitignore block, modified skills; lists each check it ran. |
| `audit [--json\|--sarif]` | What each installed skill reaches (commands, hosts, paths, env vars, scripts) and its findings: modified files, risky lines, invisible characters. Exit 1 on any finding. |
| `search <query> [--skills-sh\|--no-skills-sh [--remember]]` | Packagist (`type=ai-agent-skill`) and skill packages of the other repositories, GitLab included; with skills.sh on, also the GitHub skills it lists, as `github:owner/repo/skill` with their install count. skills.sh is off until you turn it on, since it receives the query: the first search in a terminal asks once, `--remember` saves a flag's choice, both in `config.toml`. |
| `auth set\|remove <host>` | Keep a registry token in git's credential helper (Keychain, Credential Manager, libsecret). |
| `publish --vendor v --base-url URL --out DIR [--inline] [--dist-url TPL] [paths…]` | Build a static Composer repository from a monorepo. |
| `validate [paths…]` | Check `SKILL.md` files against the Agent Skills spec, see below. |
| `projects [dirs…]` | Directories that hold `skills.toml` under the given ones (default: the current one), up to 6 levels down; hidden directories, `node_modules`, `vendor`, `target` and `Library` are skipped. |
| `ui` | A terminal UI over the commands above: skills with their state and updates, `info`, SKILL.md and `audit` per skill, add, adopt, update (diff first), remove, doctor, a filter of the list, search of the registries (and skills.sh, asked once), the repositories in use (`R`) and a projects picker; `g` switches between the project and `--global`. |
| `<other>` | Runs `skillcask-<other>` from `PATH`, like git and cargo plugins. |

`status`, `outdated`, `info`, `search`, `doctor`, `audit` and `projects` take `--json`: one JSON document on one line of
stdout, with the same exit code; warnings and errors stay on stderr. Package text in it is
escaped as in the terminal (`<U+202E>`). `status --json` has `repositories`
(`[{"name", "type", "url", "allowed"}]`); `search --json` results carry `source` (the repository
name, or `skills.sh`) and `installs`, and the document has `"skills-sh": true|false|null` (`null`:
never decided). For example `skillcask outdated --json | jq
'.packages[] | select(.within != .locked)'`.

Exit codes: 0 ok, 1 manifest/lock/status problems, 2 authentication, 3 network, 4 hash mismatch,
5 IO or archive error.

Output is coloured in a terminal (`NO_COLOR` turns it off, `CLICOLOR_FORCE` on) and errors name
the next step on a `hint:` line. For coding agents, `--agent` or `SKILLCASK_AGENT=1` prints terse,
uncoloured, tab-separated output with only the rows that need action; it is on by itself when
`AI_AGENT` or `CLAUDECODE=1` is set, unless `SKILLCASK_AGENT=0`.

A failed connection, HTTP 429 or a 5xx response is retried twice, after 1 s and 2 s or after the
`Retry-After` seconds the server sends. A `Retry-After` above 60 s, a spent GitHub rate limit, a
timeout or a TLS error fails at once.

`add`, `update` and `outdated` warn about a package its registry marks `abandoned` and name the
replacement when there is one (and it is a valid package name). Installing from the lock does not ask the registry.

**Reviewing an update:** a skill is a prompt, so read what changes before you take it.
`skillcask diff` prints a unified diff of the skill files, from the locked version to the one
`update` would lock (newest within the constraint, past the cooldown), for every entry in
`skills.toml` with a newer version; `diff vendor/name --to 2.0.0` compares one package with
another version. A package new to the lock, such as a dependency a new version adds, is shown as
all added. Nothing is installed or written. Control characters, bidi controls and other
invisible characters in the diff are shown as `<U+XXXX>` (a carriage return too, when the line
ends change), and invisible characters in changed files are warned about. Files over 1 MiB or
10,000 lines print `Files differ (too large to show)`. `update --diff` prints the same diff
before it writes the lock, then installs without asking: run `skillcask diff` first to review
and decide. A scheduled CI job can run `skillcask outdated --strict` (exit 1 when something is to
update), then `skillcask update --diff` and open a pull request with the output.

**Reviewing a lock change:** `skillcask diff --from old.lock` prints the same diff from the
versions in another lock to those in `skills.lock`, plus the packages it drops, so a reviewer
sees the skill text a pull request brings in instead of new versions and hashes. Each new dist
is verified against `skills.lock` as `install` would, and the policy decides which repositories
may be fetched. skillcask does not run git; the job fetches the old lock:

```yaml
# GitLab CI: print the diff in the merge request pipeline
skills-diff:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes: [skills.lock]
  script:
    - git fetch --depth 1 origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - git show FETCH_HEAD:skills.lock | skillcask diff --from -
```

`--from -` reads the old lock from stdin. On GitHub the same lines run in a `pull_request` job with `$GITHUB_BASE_REF`; posting the
output as a comment is up to the CI.

**Where a skill comes from:** `skillcask info vendor/name` shows the package's skills with their
descriptions, its versions, description, license, `abandoned` and `requires`, for the locked
version or else the newest one `add` would take (`@constraint` for another); `info
github:owner/repo` lists the skills of a GitHub repository before you add it. `skillcask info
<skill>` names the package that installs an installed skill and the `skills.toml` entry, or the
chain of `requires`, that brings it in.

**Merge conflicts in `skills.lock`:** a lock with conflict markers fails with a hint. Resolve
`skills.toml`, keep either side of the lock (`git checkout --theirs skills.lock`) and run
`skillcask install`: it locks the entries from the other side that the kept lock lacks.

### Skills from GitHub and skills.sh

[skills.sh](https://skills.sh) lists skills as `owner/repo/skill`, for example
`mattpocock/skills/grill-me`. Put `github:` in front and add it:

```sh
skillcask add github:mattpocock/skills/grill-me   # one skill
skillcask add github:mattpocock/skills/tdd        # a second one from the same repository
skillcask add github:anthropics/skills            # every skill in the repository
```

All skills of one repository share one entry in `skills.toml`:

```toml
"mattpocock/skills" = { github = true, version = "^1.3", skills = ["grill-me", "tdd"] }
```

Which version you get:

- **Repository with version tags** (`v1.3.1`): `add` writes `version = "^<major.minor>"`, like
  for Composer packages, and `update` moves within it. The release time of a tag is its commit
  date.
- **Repository without tags**: the entry follows the default branch; `ref = "main"` names a
  branch, tag or commit instead. The lock pins the commit sha, so `install` keeps installing that
  commit until you run `update`.
- `@` picks explicitly: `github:owner/repo@^2.0` (version tags), `github:owner/repo@main` or
  `@<commit sha>` (ref).
- With `policy.allow-ranges = false` a branch counts as a range: `add` writes the full commit sha
  as `ref`, and a hand-written branch name is rejected.

Skills are found by their `SKILL.md` anywhere in the repository, except inside another skill.
The install name is the frontmatter `name`; when that is not a valid name (`Jira Automation`), the
directory name is used. `skills = [...]` takes these names.

GitHub allows 60 anonymous API requests per hour. `add`, `update` and `outdated` use a few per
entry; `install` from the lock uses none, it downloads the pinned archive. Set `GITHUB_TOKEN`
(GitHub Actions has one) or Composer's `"github-oauth": {"github.com": "<token>"}` in `auth.json`
for a higher limit and for private repositories. `SKILLCASK_GITHUB_API` points at GitHub
Enterprise (`https://ghe.example.com/api/v3`).

skills.sh itself is not queried: its API is undocumented and may change without notice. Search
on the website, then add what you picked.

### Skills from any git repository

GitLab, Bitbucket, Azure DevOps, Gitea or a self-hosted server work without a Composer registry:

```sh
skillcask add git:https://gitlab.example.com/acme/skills.git
skillcask add git:https://gitlab.example.com/acme/skills.git@main
```

```toml
"acme/skills" = { git = "https://gitlab.example.com/acme/skills.git", version = "^1.0" }
```

Versions, `ref` and `skills = [...]` work as for GitHub; the entry is named after the last two
parts of the URL. skillcask speaks git's HTTPS protocol itself: no git binary runs, so no hook or
filter of the repository can. It fetches the one commit it needs and checks every file against
the commit's object ids. Credentials come from the same places as for registries (`auth.json`,
the credential helper, `$CI_JOB_TOKEN` in GitLab CI). Git servers keep no release or push times,
so `min-release-age` uses commit dates, which the committer sets; a branch whose newest commit is
too young falls back to an older commit when the server supports partial fetches (`filter`).

## Security

- The lock pins the version, the commit reference, the sha256 of the downloaded archive and
  the tree hash of every installed skill. `install` verifies both hashes, so what gets installed
  is what was reviewed.
- HTTPS only, verified against the system trust store, so corporate CAs work. Verification
  cannot be turned off; `SKILLCASK_CA_FILE` replaces the trust store with a PEM bundle.
- Archives with absolute paths, `..`, devices or symlinks that leave their skill are rejected.
  No symlink is ever written from a package. Size and file count are capped (`--max-download`,
  `--max-unpacked`, `--max-files`).
- File names must be valid on every OS: Windows-reserved names, `<>:"|?*\`, control characters
  and paths that collide by case or Unicode form fail a skill that contains them, and are
  skipped outside the selected skills.
- No package code runs, ever. No telemetry.
- `add` and `update` (and `install` when it resolves) warn about characters a reviewer cannot
  see in the text files of new content: bidi controls (`U+202A`-`U+202E`, `U+2066`-`U+2069`,
  `U+200E`, `U+200F`, `U+061C`), zero-width characters (`U+200B`-`U+200D`, `U+2060`, `U+FEFF`) and
  Unicode tags (`U+E0000`-`U+E007F`), as `warning: acme/x: skills/foo/SKILL.md:12: U+202E
  RIGHT-TO-LEFT OVERRIDE`, at most five per file. A leading byte order mark and a zero width
  joiner inside an emoji are not reported; right-to-left and Indic text can trigger warnings.
  Installing from the lock does not warn again.
- The same commands warn about lines that usually mean harm, as `warning: acme/x:
  skills/foo/SKILL.md:12: downloads code into a shell`, at most five per file:

  | Warning | Matches (case-insensitive) |
  |---|---|
  | downloads code into a shell | `curl`, `wget`, `irm`, `iwr`, `Invoke-WebRequest` or `Invoke-RestMethod` piped (`\|`) into `sh`, `bash`, `zsh`, `dash`, `iex`, `Invoke-Expression`, `python`, `perl`, `ruby` or `node`, also after `sudo` |
  | decodes base64 and runs it | `base64 -d`, `base64 --decode`, `b64decode` or `FromBase64String` with a pipe into a shell, `eval`, `exec(` or `iex` |
  | reads SSH keys | `~/.ssh`, `$HOME/.ssh`, `id_rsa`, `id_ed25519` |
  | reads system account files | `/etc/passwd`, `/etc/shadow` |
  | tells the agent to ignore its instructions | "ignore (all) previous/prior instructions", "disregard all previous/prior" |

  Skills that teach shell setup trigger some of these legitimately, so they are warnings. With
  `deny-patterns = true` under `[policy]` (or enforced in `config.toml`) they fail the command
  with exit 1 and nothing is written.
- `skillcask audit` reviews what is installed now, every skill in `.agents/skills` and the project
  skills: the commands its shell code blocks run, the hosts of its URLs, the paths in the home
  directory or under system roots, the environment variables it reads and the files an agent could
  run. That is read from the text, so it is a hint for review, not a sandbox. It also reports a
  locked skill that differs from the lock, and every risky line and invisible character above,
  with no limit per file, and exits 1 on any of them. `--sarif` prints a SARIF 2.1.0 log for code
  scanning, e.g. in GitHub Actions:

  ```yaml
  - uses: puniserv/skillcask@v0.8.0
    with:
      args: install --frozen
  - run: skillcask audit --sarif > skills.sarif
  - uses: github/codeql-action/upload-sarif@v3
    if: always()
    with:
      sarif_file: skills.sarif
  ```
- Plain git sources pin the commit; the lock's `dist-sha256` hashes the commit's file list
  (path, mode, sha256 of the content), so it does not depend on how a server packs objects.
- `min-release-age` for GitHub sources uses times GitHub sets: release publication for tags,
  push times from the Activity API for branches. A tag without a release, or a ref GitHub lists no
  pushes for, falls back to commit dates, which the committer sets; reviewing what `add`/`update`
  change in `skills.lock` is the real check there.
- Credentials come from Composer's `auth.json` / `COMPOSER_AUTH` (`http-basic`, `bearer`,
  `gitlab-token`, `gitlab-oauth`), then from git's credential helper, and are never printed.
  `skillcask auth set git.example.com` keeps a token in the macOS Keychain, the Windows Credential
  Manager or libsecret instead of a plain-text file (the default username `PRIVATE-TOKEN` sends
  GitLab's header; `--username oauth2` a Bearer token). `SKILLCASK_GIT_CREDENTIALS=0` turns the
  helper off. In GitLab CI use `http-basic` with the
  user `gitlab-ci-token` and the password `$CI_JOB_TOKEN`.

## Publishing skills

**One repository per skill:** a `composer.json` with `"type": "ai-agent-skill"` (the
[netresearch convention](https://github.com/netresearch/composer-agent-skill-plugin)) and semver
tags. Packagist or GitLab's Composer registry picks up the tags; skillcask plays no part.

**Monorepo:** give each `SKILL.md` a `version` in its frontmatter and run

```sh
skillcask publish --vendor acme --base-url https://skills.example.com --out public skills/
```

This writes `packages.json`, `p2/acme/<name>.json` and deterministic zips under `dist/`. Published
versions are immutable, and re-running on unchanged skills changes no file. Serve the directory
over HTTPS.

Check the skills before publishing; `validate` exits 1 on any error and warns about invisible
Unicode, as `add` does:

```sh
skillcask validate skills/
```

It follows the [Agent Skills specification](https://agentskills.io/specification): `name` 1-64
lowercase letters, digits and single hyphens, equal to the directory name; `description` 1-1024
characters; `compatibility` at most 500. Without paths it searches the current directory.
`publish` skips a `SKILL.md` without `name` or `version` and names it in a warning.

To retire a skill, publish a new version with `deprecated: true`, or `deprecated: vendor/name`
naming its replacement, in the frontmatter. It becomes Composer's `abandoned`, and consumers see a
warning on `add`, `update` and `outdated`.

Hosts that cannot serve `/` inside file names (GitLab's Generic Package Registry) take a single
file instead:

```sh
skillcask publish --inline --vendor acme --out composer/ skills/ \
  --dist-url 'https://git.example.com/api/v4/projects/7/packages/generic/{vendor}-{name}/{version}/{name}-{version}.zip'
```

`--inline` puts every package into `packages.json` and writes no `p2/`. Upload each
`dist/<vendor>/<name>/<version>.zip` to the URL the template gives, and commit `packages.json` to
a repository. A repository `url` with a query string is used as given, so clients can read the
file through the GitLab raw files API
(`.../repository/files/composer%2Fpackages.json/raw?ref=main`) with a `gitlab-token`.

## Design

See [docs/specs/2026-10-19-skillcask-v1.md](docs/specs/2026-10-19-skillcask-v1.md).

## License

MIT OR Apache-2.0.