llm / skills
AI skills discovery and management system for LLM agents
Fund package maintenance!
Requires
- php: >=8.2
- composer-plugin-api: ^2.0
- internal/path: ^1.2
Requires (Dev)
- buggregator/trap: ^1.10
- composer/composer: ^2.9.0
- infection/infection: dev-testo-bridge-82 as 0.32.6
- roxblnfk/unpoly: ^1.8
- spiral/code-style: ^2.3
- testo/bridge-infection: 0.1 - 1
- testo/testo: 0.1 - 1
- vimeo/psalm: ^7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Distribute AI Skills as Composer dependencies
A Composer plugin that downloads AI Skills from your Composer/vendor packages and from
arbitrary Git repositories (GitHub and GitLab, added with skills:add), then keeps them synced into
a project-local directory (default .agents/skills/).
An AI Skill is a directory containing a SKILL.md plus any auxiliary files (templates,
examples, fixtures). The directory name is the skill's identity; coding-agent tools read
SKILL.md to learn project-specific instructions, conventions, and recipes.
Skills are assembled in the consumer project on demand, or automatically on composer install /
update. A package doesn't even have to declare anything: skills are
auto-discovered by their SKILL.md files wherever they live.
Install
composer require --dev llm/skills
Composer will prompt to allow the plugin during install — answer y. (For non-interactive
setups, pre-allow with "config": { "allow-plugins": { "llm/skills": true } } in
composer.json.)
Then bootstrap your project's config — this is the one command to run first:
composer skills:init
An interactive wizard walks you through target dir, aliases, trusted vendors, and auto-sync,
and writes a skills.json you commit alongside composer.json. See
Project configuration for the full reference. The plugin still works
without it — defaults are sensible — but committing an explicit skills.json is what makes
your skill setup reproducible across the team.
Auto-sync after every composer install / update is on by default, so after init you
get fresh skills with no further setup. To opt out, set "auto-sync": false in skills.json;
composer install --no-scripts also suppresses the auto-run for a single invocation without
changing the config.
Global composer installation
Install once and use the skills:* commands in any project:
composer global require llm/skills
Then from any project root:
composer skills:show composer skills:update
Project-level settings (target, dependencies, discovery, …) live in the consumer project's
skills.json at the project root. See Project configuration for the
full reference.
Commands
composer skills:init [options] # alias: skills:i
composer skills:update [<package>...] [options] # alias: skills:u
composer skills:show [<package>...] [options] # alias: skills:s
composer skills:add <input> [options] # alias: skills:a
skills:update copies skills into the target directory. skills:show is read-only — it lists
every donor, the per-skill sync status, and what is being skipped and why. skills:init
bootstraps a skills.json at the project root and (when
composer.json carries legacy inline project keys) migrates them out. skills:add registers
a donor that lives outside Composer (e.g. a GitHub repository) and immediately fetches its
skills — see Donor sources.
| Option | Where | Description |
|---|---|---|
<package>... |
both | Restrict to matching donors. Exact (acme/foo) or wildcard (acme/*, *). Listed packages are treated as trusted for this run (see Trust). |
--target=PATH, -t |
both | Override the configured target directory for this run. |
--alias=PATH |
update | Extra path mirrored at the target via a junction/symlink (repeatable). Passing --alias at all replaces the configured aliases entirely. See Aliases. |
--trust=PATTERN |
both | Trust an extra pattern for this run (repeatable). |
--discovery |
both | Include packages that ship SKILL.md files but do not declare extra.skills (see Auto-discovery). |
--from=ID |
update | Scope the sync to a single provider id (composer, github, …). See Donor sources. |
--dry-run |
update | Print actions; no files written. |
Short flag -d for --discovery is registered only by the standalone bin/skills binary;
inside Composer it is reserved for --working-dir.
Examples
composer skills:update # sync everything that is trusted composer skills:update acme/skills-basic # sync one package (implicit trust) composer skills:update 'acme/*' # sync an entire vendor namespace composer skills:update --discovery # also include packages without extra.skills composer skills:update --alias=.claude/skills # mirror target via a junction/symlink composer skills:update --from=github # only refresh remote GitHub donors composer skills:update --dry-run # preview, write nothing composer skills:show # inspect: per-skill status, what is skipped composer skills:init # create skills.json (migrating inline keys) composer skills:add acme/skills # register a GitHub donor and sync it (github is the default) composer skills:add acme/skills \ --skill=code-review --skill=refactor # narrow a donor to two skills
Shipping skills (vendor side)
A donor package declares a directory whose immediate subdirectories are its skills:
// vendor/acme/skills-pro/composer.json { "extra": { "skills": { "source": "resources/skills" } } }
acme/skills-pro/
├── composer.json
└── resources/skills/
├── refactor/
│ ├── SKILL.md
│ └── templates/suggestion.md
└── migrate/
└── SKILL.md
After skills:update, the consumer project gets:
<project>/.agents/skills/
├── refactor/{SKILL.md, templates/suggestion.md}
└── migrate/SKILL.md
sourceis relative to the package root.- Each immediate subdirectory of
sourceis one skill, copied recursively. - Loose files at the root of
source(e.g.README.md) are ignored. - A package without
extra.skillsis not a donor by default — see Auto-discovery.
source also accepts a list of directories — useful for a monorepo published as a
single package whose skills live per component:
// vendor/acme/components/composer.json { "extra": { "skills": { "source": ["packages/dto/skills", "packages/auth/skills"] } } }
Every listed directory is scanned the same way as a single source; skills from all of
them are synced together.
External sources (extra.skills.sources)
A package can also advertise skills that live outside the package itself — a separate
skills repository, or a bundle shared by several integration packages — using the same
sources[] vocabulary as skills.json.
The two lists share one syntax but answer different questions. sources[] in
skills.json is the consumer's pull list: "my project takes skills from here" —
you wrote it, so every entry is trusted by declaration. sources[] in a package's
composer.json is a donor's address book: "my skills live over there" — the same
statement extra.skills.source makes for in-package paths, just pointing outside. It is
third-party input, so it only takes effect when the declaring package clears the exact
same trust gate as its bundled skills would:
// vendor/acme/foo/composer.json { "extra": { "skills": { "source": "resources/skills", // optional: in-package skills "sources": [ // external skills { "from": "github", "package": "acme/project-skills", "ref": "self.version" }, { "from": "gitlab", "package": "acme/shared-skills", "skills": ["deploy"] } ] } } }
- Trust follows the declaring package. The external bundle is fetched and synced exactly
when
acme/fooitself would be allowed to donate skills (built-in/project trust list, direct dependency, or a positionalskills:update acme/foo). Unlike the project's ownsources[], a vendor-declared entry is never implicitly trusted. Projects can turn the feature off entirely with"vendor-sources": falseinskills.json. ref: "self.version"pins the external source to the installed version of the declaring package — made for monorepos that tag the skills repository in lockstep. A released version resolves to the matching tag (1.4.2findsv1.4.2or1.4.2); a branch install maps to the branch (dev-main→main,1.x-dev→1.x). A plain exact pin is also available as"ref": "=1.4.2"(works inskills.jsontoo).- Shared bundles dedupe. Several packages pointing at the same source and ref produce a
single fetch and a single donor; their
skillsallowlists are merged (an entry without an allowlist means "all skills" and wins the merge), and the donor is approved when any of the declaring packages is trusted (or named positionally). - No chaining. The fetched archive's own
extra.skills.sourcesis ignored — depth is exactly one. Itsextra.skills.source(path inside the archive) works as usual. - The
diradapter is not allowed here — for in-package paths useextra.skills.source. - Failures (unknown adapter, unresolvable ref, fetch error) are reported per entry as
acme/foo → github:acme/project-skills — <reason>and never block the rest of the sync.
Project configuration
Project-level settings live in a dedicated skills.json at the project root. The file
is the single source of truth for every decision the plugin makes in your project — what to
copy, where to put it, who to trust, whether to auto-sync. Skill content may arrive from
manifests you did not write (vendor packages, their
external sources), but whether it is allowed in is
always governed here, by the trust lists and the vendor-sources toggle.
// <project-root>/skills.json { "$schema": "https://raw.githubusercontent.com/roxblnfk/skills/master/resources/skills.schema.json", "target": ".agents/skills", "aliases": [".claude/skills", ".cursor/skills"], "discovery": false, "auto-sync": true, "path-from-root": "packages/api", "dependencies": { "composer": { "trusted": ["acme/*", "myorg/skills-internal"] } }, "sources": [ { "from": "github", "package": "acme/skills", "ref": "^1.2.0" }, { "from": "github", "package": "team/skills-pack", "ref": "^2", "skills": ["code-review", "refactor"] } ] }
| Key | Type | Default | Description |
|---|---|---|---|
target |
string | .agents/skills |
Destination directory, relative to the project root. |
aliases |
string[] | [] |
Mirror paths (junction/symlink) pointing at target. See Aliases. |
discovery |
bool | false |
When true, auto-discovery is on by default (CLI overrides). |
auto-sync |
bool | true |
Run skills:update after composer install / update. Set to false to opt out. |
path-from-root |
string | (unset) | The project's own location below an intended outer root, e.g. packages/api. When set, target and aliases resolve against (and stay inside) that verified root instead of the project directory. See path-from-root. |
dependencies |
object | {} |
Per-package-manager config: <id> → bool (walk toggle) or { enabled, trusted, trusted-replace }. Ids: composer (walk default true), npm/go (future, default false). trusted extends the manager's trust list; trusted-replace makes it fully replace the built-in and direct-dependency trust. Deprecated aliases trusted, trusted-replace, local fold into this block. See Trust and Donor sources. |
vendor-sources |
bool | true |
Allow installed packages to advertise external sources via their own extra.skills.sources (gated by the same trust rules as the declaring package). Set to false to ignore them entirely. See External sources. |
sources |
object[] | [] |
The project's own donor source entries — implicitly trusted, you wrote them. Managed by skills:add; documented in Donor sources. Not to be confused with the trust-gated extra.skills.sources a vendor package declares for itself. |
.agents/skills/ is tool-agnostic so Claude Code, Cursor, Aider, … can read the same
directory. Redirect to .claude/skills, .cursor/skills, etc. for single-agent projects.
The fastest way to get a valid skills.json is composer skills:init (see below). Bootstrap
it once and commit it alongside composer.json.
Note
remote was renamed to sources. Existing files keep working — remote is still read
as a deprecated alias. Any write-mode command (skills:update, skills:init, skills:add)
migrates the key in place and prints a [migrate] line; read-only skills:show just emits a
[deprecated] notice. Having both remote and sources in the same file is a fatal config
error — keep sources only.
Note
trusted, trusted-replace and local moved into dependencies. Trust is now
scoped per package manager, so the flat trust surface and the local toggle map collapse
into one block: dependencies.<id> takes a bool (the old local toggle) or an object with
enabled / trusted / trusted-replace. The three legacy keys keep working as deprecated
aliases — flat trusted/trusted-replace fold into dependencies.composer. Write-mode
commands migrate the file in place with a [migrate] line; skills:show just emits a
[deprecated] notice. Having dependencies alongside any legacy key in the same file is a
fatal config error — keep dependencies only.
Strict shape
skills.json is strict:
- Unknown top-level keys fail the run.
$schemais the only metadata key accepted (and silently stripped from the parsed config).- A nested
config-filekey is rejected — the file is the config, not a pointer to one.
The PHP mapper is the authoritative validator at runtime; the
resources/skills.schema.json document mirrors it for IDE /
editor support. A malformed skills.json is fatal; a malformed extra.skills block in a
donor package is skipped with a -v warning so one bad vendor never blocks the rest.
skills:init — bootstrap and migrate
composer skills:init # migrate eagerly (same effect as a future skills:update) composer skills:init --force # overwrite an existing skills.json composer skills:init --path=PATH # non-default location (won't be auto-discovered)
skills:init is the explicit version of the migration that skills:update runs implicitly.
It exists for two cases:
- Pre-
skills:updatesetup — bootstrapskills.jsonbefore the first sync. - Standalone projects (no
composer.jsonat cwd) — write a stubskills.jsonwith the$schemapointer so editors can pick up the schema; nothing else is touched.
Refusal semantics:
- Refuses to overwrite an existing
skills.jsonwithout--force. - Refuses if the inline
extra.skillsblock is malformed — fixcomposer.jsonfirst, then rerun. - Refuses if
--pathpoints at an existing non-file (a directory etc.) with a clear error.
--path=PATH honours the project-root containment rule. Subsequent commands only
auto-discover skills.json at the project root, so a non-default --path also emits a
notice telling the user to move the file.
Note
Upgrading from inline extra.skills? Early versions of llm/skills kept project
settings under extra.skills in composer.json. That surface is deprecated. Starting with
1.3.0, the first write-mode run (skills:update, skills:init, or the
post-update-cmd auto-sync hook) moves the project keys into skills.json automatically
and prints a [migrate] line. skills:show and post-install-cmd stay read-only and just
emit a one-line notice. Donor-side extra.skills.source is never touched.
Aliases
A single project often needs the same skills directory available to several coding agents at
once — Claude Code at .claude/skills, Cursor at .cursor/skills, plus an agent-agnostic
.agents/skills. Copying the same bytes into N places wastes disk and forces them out of sync.
aliases keeps one real directory (target) and creates additional paths as
OS-level mirrors:
- POSIX — symbolic links via
symlink(2). - Windows — directory junctions via
mklink /J. Junctions work without admin/dev-mode privilege, unlikeSeCreateSymbolicLink. Cross-volume junctions are refused with a non-zero exit; the plugin never silently degrades to a copy.
// <project-root>/skills.json { "target": ".agents/skills", "aliases": [".claude/skills", ".cursor/skills"] }
skills:update produces one real .agents/skills/ plus two link paths pointing at it. Reads
through any path see the same files.
Behaviour
- Idempotent. A second run sees the existing link and treats it as already-correct.
- Non-destructive. If the alias path already exists as a real directory, the run fails with a non-zero exit and leaves the directory untouched. To convert it, remove the directory manually and re-run — the plugin never destroys user content.
- Stale aliases not pruned. Removing an entry from
aliasesdoes not delete the junction/symlink on disk. Clean it up manually if needed. - CLI override is total.
--alias=PATH(repeatable) replaces the configuredaliasesfor that run — there is no merging.
composer skills:update --alias=.claude/skills --alias=.cursor/skills
Git
Alias paths are build artefacts and typically belong in .gitignore:
.claude/skills .cursor/skills
On Windows, git status reads junctions transparently — but committing a junction is rarely
what you want, so the ignore line is the safer default.
path-from-root
Sharing a skills directory above the project (monorepos).
By default target (and any aliases) must resolve inside the project root — the
directory the command runs in, which is where composer.json / skills.json live. A relative
target resolves from there, and ../… escapes are rejected by a containment guard.
Some repositories keep the Composer project in a subdirectory while the coding agent is
launched from the repository root — e.g. a monorepo whose agent (OpenCode, …) reads skills
from a root-level .agents/skills:
my-monorepo/ ← agent launched here; skills wanted here
├─ .agents/skills/ ← desired target
└─ packages/api/ ← the Composer project
├─ composer.json
└─ skills.json
Running from packages/api, a plain target: .agents/skills would land in
packages/api/.agents/skills, and ../../.agents/skills would be rejected.
path-from-root re-anchors the guard to a verified ancestor. You declare where the
project sits relative to the intended outer root:
// my-monorepo/packages/api/skills.json { "path-from-root": "packages/api", "target": ".agents/skills" }
skills:update then:
- climbs that many levels up from the project (
packages/api→ the monorepo root); - verifies the climb — the project directory must actually end with
packages/api, otherwise the run aborts withpath-from-root … does not match the project locationand writes nothing; - resolves
target(andaliases) against that root and confines them to it.
Result: skills land in my-monorepo/.agents/skills, reached with a plain target — no ...
- The guard is widened, not removed. A
targetor alias that escapes the re-anchored root is still rejected.path-from-rootmust be a relative path of plain segments (no./.., not absolute). - Portable. The suffix (
packages/api) is identical on every machine; only the absolute prefix differs, and it is never written into config. - Default unchanged. Omit
path-from-rootand the containment root is the project root, exactly as today.
Trust
AI skills are Markdown instructions executed by an agent. A malicious package could ship a prompt-injection payload, so the plugin does not copy skills from a donor unless it is trusted.
Effective trust list:
builtin ∪ project.trusted ∪ --trust=<pattern> ∪ direct-deps
project.trusted is the dependencies.composer.trusted array from skills.json (the
deprecated flat trusted key folds into it). direct-deps is the set of packages declared
under require and require-dev in the consumer's root composer.json. Setting
dependencies.composer.trusted-replace: true drops both implicit sources
(builtin and direct-deps) from the union, leaving only project trust and --trust= —
the explicit-only mode.
| Pattern | Matches |
|---|---|
vendor/package |
Exact package name. |
vendor/* |
Any package in that vendor namespace. |
* |
Every installed package. |
Bare vendor without / is rejected as ambiguous.
Shortcuts
- Named on the CLI is implicit trust.
composer skills:update acme/foosyncsacme/foowithout consulting the trust list. Naming a vendor wildcard (acme/*) extends the grant to every package matching the pattern. - Named is also implicit auto-discovery. If the named package does not declare
extra.skills, the plugin still scans it forSKILL.mdfiles — discovery is enabled for that package only. - Direct dependencies are implicit trust. A package the consumer chose to depend on
(
require/require-dev) does not need a trust pattern: the dependency declaration is already a trust decision. Transitive dependencies are still gated by the trust list. Settingdependencies.composer.trusted-replace: trueturns this off for projects that want explicit-only trust.
Built-in trusted vendors
Shipped in resources/trusted-composer.txt; extended by PR. Other registries (npm, go) will ship their own per-ecosystem files when the corresponding local providers land — see spec-remote.md §8.
Donor sources
llm/skills reads donors from two axes:
- Local providers — walk a manifest the project already owns. Today only
composer;npmandgoare reserved in the vocabulary but ship later. - Remote providers — fetch an explicit ref from a URL (currently GitHub and GitLab; the format is forward-compatible with Bitbucket, npm registry, Go module proxy, private Packagist,
http/zip). Entries come from the project's ownsources[]and from installed packages'extra.skills.sources(see External sources). - Local directory donors — the
diradapter reads a directory already on disk (a shared skills folder next to the repo, a monorepo sibling); no fetch, no cache. See Local directory donors.
The axes coexist. When the same package name arrives via more than one, the later origin wins — project sources[] over vendor-declared, either of those over the transitive Composer pickup — and the displaced donor is logged under -v.
skills:add — register a remote donor
composer skills:add acme/skills # latest stable, write "^X.Y.Z" — github is the default composer skills:add acme/skills --ref=v1.2.3 # pinned tag composer skills:add 'acme/skills@main' # branch HEAD composer skills:add https://github.com/acme/skills # full URL; adapter inferred from host composer skills:add team/skills --from=gitlab # GitLab donor (group/project shorthand) composer skills:add team/skills \ --host=https://github.corp.example.com # GitHub Enterprise composer skills:add team/skills --from=gitlab \ --host=https://gitlab.corp.example.com # self-hosted GitLab composer skills:add acme/skills \ --skill=code-review --skill=refactor # only these two skills composer skills:add acme/skills --no-sync # only edit skills.json composer skills:add ./skills # local directory donor (dir) composer skills:add ../shared-skills --skill=deploy # sibling folder, single skill
--from defaults to github for shorthand input (owner/repo). Pass it explicitly only when targeting a different adapter, or override it when the URL host is ambiguous. Full URLs still resolve the adapter from the host — --from is only consulted as an override.
The command:
- parses the input via the resolved adapter (
githuborgitlab); - resolves the ref — explicit value wins verbatim; without
--refthe adapter picks the highest stable tag, falling back to the highest prerelease tag, then to the default branch HEAD; - downloads the archive into
vendor/llm-skills/cache/...(gitignored by virtue of vendor); - validates that the archive is a donor — either a
composer.jsonwithextra.skills.source, or (for bare skill repos) at least oneSKILL.mdfound by auto-discovery; - upserts the entry into
skills.jsonsources[](stable-sorted by(from, host, package), atomic write — falls back tounlink + renameon Windows whererename()refuses to overwrite an existing destination); - runs a single-entry sync so the new skills land in the target right away — same ergonomics as
composer require. Suppress with--no-sync.
| Option | Description |
|---|---|
<input> |
Shorthand owner/repo, shorthand with @ref, a full URL, or a local directory path (./skills, ../shared, an absolute path). |
--from=ID |
Adapter id (github, gitlab, dir, …). Defaults to github for shorthand; inferred from the URL host for a full URL; inferred as dir when the input opens with a path prefix. |
--host=URL |
Override the adapter's default host (GitHub Enterprise, self-hosted GitLab, private Packagist). |
--ref=REF |
Pin a tag, branch, SHA, or Composer-style constraint (^1.2.3). Without this, the cascade above runs. |
--skill=NAME |
Restrict the donor to a specific skill directory. Repeatable. Names accumulate across consecutive skills:add calls. Without the flag, every skill the donor ships is synced. |
--no-sync |
Skip the automatic single-entry sync after writing skills.json. |
Stored entries look like:
{
"sources": [
{ "from": "github", "package": "acme/skills", "ref": "^1.2.0" },
{ "from": "github", "package": "team/internal-skills",
"host": "https://github.corp.example.com", "ref": "^1",
"skills": ["code-review", "refactor"] }
]
}
The composite key is (from, host, package | url): same triplet = upsert in place, different = append. Manual edits are fine — the next skills:add normalises the order.
Per-entry skill allowlist
A donor often ships more skills than you want in a given project. The optional skills field on each sources[] entry narrows the donor to a named subset:
- Absent / omitted → sync every skill the donor ships (legacy behaviour).
- Non-empty list of names → only those skills are copied; the rest are silently skipped.
- Empty list (
"skills": []) → the donor is registered but no skills are pulled from it. Useful for staging a donor before opting into its content or for temporarily disabling a donor without deleting the entry. - Names that do not exist in the fetched archive emit a
-vwarning (skill "X" declared in the skill allowlist but not found in the donor) so typos surface without aborting the sync.
skills:add --skill=NAME is the CLI surface: pass --skill repeatedly to build the list. The flag is additive on upsert — running skills:add again on the same entry adds the new names to whatever was already stored. A follow-up skills:add without --skill does not touch the existing allowlist (whether it was a populated list or an explicit empty one). Removing a name or clearing the allowlist entirely is a manual edit of skills.json.
Local directory donors (dir)
The dir adapter registers a directory already on disk as an explicit donor — a shared skills folder next to the repo, a monorepo sibling, or a working copy you are iterating on. There is no fetch, no cache, and no ref: the directory is read live on every sync, so edits show up on the next skills:update with nothing to invalidate.
{
"sources": [
{ "from": "dir", "path": "./skills" },
{ "from": "dir", "path": "../shared-skills", "package": "myorg/shared", "skills": ["deploy"] }
]
}
skills:add selects the adapter from the input shape — a path opening with ./, ../, /, \, or a Windows drive letter (X:) is treated as a directory; --from=dir forces it for a bare name:
composer skills:add ./skills # relative to the project root composer skills:add ../shared-skills --skill=deploy composer skills:add /srv/team/skills # absolute path composer skills:add D:\team\skills --from=dir
- Path resolution. A relative
pathresolves from the project root (the same anchortargetuses); absolute paths (including Windows drive letters) are honoured as-is...segments and locations outside the project root are allowed — asources[]entry is an explicit act of trust. The storedpathis kept as typed (normalised to forward slashes). - Package name. The donor's name is the entry's
packageoverride if present, else the directory's owncomposer.jsonname, else it is derived from the resolved path as<parent>/<basename>lowercased (e.g..../testo/skills→testo/skills). - Implicit trust. Like every
sources[]entry, adirdonor is trusted by declaration — you typed the path, so its skills sync without atrustedlisting. url,host, andrefare not applicable and are rejected (there is no host and no version concept); the per-entryskillsallowlist behaves exactly as for other adapters. Apaththat does not exist at sync time degrades to a-vwarning and is skipped, but an explicitskills:addof a missing directory is refused up front (a missing directory at add time is a typo).
Authentication
Remote adapters reuse Composer's auth.json / COMPOSER_AUTH plumbing — no new credential surfaces. A GitHub token configured for composer require works as-is for skills:add.
Self-hosted GitLab. A private GitLab project answers unauthenticated API calls with 404 Project Not Found (it hides private projects rather than returning 401), so the fetch fails until Composer has a token and knows the host is a GitLab instance. Composer only attaches the token to hosts listed in gitlab-domains:
composer config --global gitlab-domains gitlab.example.com composer config --global gitlab-token.gitlab.example.com <personal-access-token> # scope: read_api
Public projects (and everything on gitlab.com) need no setup.
Archive safety
Remote archives are downloaded from a user-configurable host, so every zip entry name is validated before extraction. Absolute paths (/foo, C:/foo), .. segments (../etc/passwd), backslash-rooted paths (\\server\share), and NUL bytes are rejected as a malformed archive; the fetcher emits a per-ref -v warning and never writes to disk. The scratch directory used during extraction is cleaned in a finally regardless of success.
--from=ID filter on sync
composer skills:update --from=composer # only local Composer donors composer skills:update --from=github # only remote GitHub donors
The id matches dependencies.{id} keys and sources[].from values. Each donor's provenance is set at the source: ComposerProvider tags composer; SourceProvider tags the entry's from. The filter is a simple equality check on that tag.
Dependency walk toggles
{ "dependencies": { "composer": false } } // disable Composer discovery entirely
dependencies.composer defaults to true (transitive Composer packages are scanned for donors). Set it to false — either the bool short form above or { "composer": { "enabled": false } } — to stop scanning, useful when the project wants its donors purely from sources[]. The per-manager trusted / trusted-replace fields configure which of those scanned packages are allowed to ship skills (see Trust).
For the full architectural rationale, the version-resolution cascade, the cache layout, and the multi-registry trust model, see spec-remote.md.
Auto-discovery
When a package does not declare extra.skills but ships SKILL.md files anyway, llm/skills
can still pick up the skills inside. Opt in one of three ways:
--discoveryflag on the command line (for a single run);"discovery": trueinskills.json(always on);- Name the package as a positional argument (implicit, per-package — see Shortcuts).
How skills are found
Discovery looks for the files (SKILL.md), not a single hard-coded folder, so skills are
found wherever a package keeps them. A directory holding a SKILL.md is a skill; the
scanner never descends into one (a skill cannot contain a nested skill).
-
Well-known roots first. Each of these conventional roots is probed, and inside it both the flat layout (
<root>/<name>/) and the one-level catalog layout (<root>/<category>/<name>/) are accepted:.agents/skills/ .claude/skills/ .cursor/skills/ skills/ resources/skills/ -
Recursive fallback. Only if none of those roots yields a skill does the scanner walk the rest of the package tree to find
SKILL.mdfiles in non-conventional locations (e.g.maintenance/skills/<name>/). The walk is bounded — it caps depth and skipsvendor/,node_modules/,.git/, hidden directories, and nested packages (any directory with its owncomposer.json).
All of these are discovered (no extra.skills anywhere):
acme/skills-undeclared/ # flat, well-known root
└── skills/
└── auto-skill/SKILL.md
nested/skills-tree/ # multiple roots + catalog layout
├── .claude/skills/
│ └── hidden-claude/SKILL.md
└── skills/
└── php/
└── hidden-catalog/SKILL.md
acme/maintenance/ # recursive fallback (nothing in a well-known root)
└── maintenance/skills/
└── triage/SKILL.md
composer skills:update --discovery # picks up every skill above composer skills:update acme/skills-undeclared # picks up auto-skill only (named ⇒ trust + discovery)
The same scan powers skills:add for remote repositories that ship bare skills without a
Composer manifest.
Auto-discovered donors still pass through the trust filter unless they were named on the CLI. A junction or symlink that escapes the package root is silently rejected.
Sync behaviour
- Non-destructive merge. Files inside the target directory that the donor does not ship are left alone (your local notes survive). Files the donor does ship are overwritten — the donor is the source of truth.
- Idempotent. Running
skills:updatetwice produces the same state with no errors. - Transactional on conflicts. If two donors declare a skill with the same directory name, sync aborts before touching the filesystem; nothing is written. Every offending package is listed in the output.
- Grouped output. Copied skills are grouped by donor package; trailing
[skip]and[hint]blocks summarise what was left out and how to opt in.