Declare AI skills as git dependencies in ai.json, and spm wires them into your
AI tool (Amp, Claude Code, Cline, OpenAI Codex CLI, GitHub Copilot CLI, Cursor, Gemini CLI and Windsurf) without ever committing
skills to your repo. Anything spm materializes into the working tree is
gitignored — no symlinks, no skills under version control.
📖 Documentation: https://camunda.github.io/spm-cli/ (built from
docs/ and deployed via GitHub Pages).
ai.json ──resolve──▶ ai.lock ──fetch──▶ ~/.spm/store/<repo>@<sha> (global cache, one clone per commit)
│
└─project──▶ materialized where the vendor expects it (see below)
ai.json— you author it, commit it. Declares target vendors + skill deps.ai.lock— generated, commit it. Pins every version selector to an immutable commit SHA → reproducible installs.- Global store (
~/.spm/store) — a fetch cache only: each repo@commit is cloned once and shared across all projects. Nothing is registered or materialized here — it exists purely so repeated installs don't re-clone. - Vendor projection — spm copies the store's skills into the directory where each vendor loads them from. In the default project scope that is a project-local, gitignored dir — nothing spm generates is committed. With
-g/--global(see Global skills) it materializes into a user-global location shared across all your projects instead. - Registration differs per vendor:
- Claude — spm assembles a self-contained plugin marketplace in the project-local, gitignored
.spm/claude/dir and writes a pointer to it into.claude/settings.local.json(gitignored by convention). The dir sits outside.agents/skills/so Copilot's scanner never picks it up. Declarative, per-project, zero VCS footprint. - Copilot CLI — spm copies the resolved skills into a project-local directory,
.agents/skills/spm-managed-skills/<name>/, where Copilot CLI auto-discovers them (.agents/skills/**/SKILL.md). That directory is added to the project's.gitignore(with an explanatory comment) so the materialized skills stay truly local and are never committed. No user-global state, nocopilotCLI required. - Gemini CLI — spm copies the resolved skills one directory deep into the tool-native
.gemini/skills/<name>/, where Gemini CLI auto-discovers them. Because Gemini treats that dir as a team-shared, version-controlled location, spm shares it with your own hand-authored skills: it never wipes the dir, touches only the entries it manages, and gitignores just those spm-managed subdirs (.gemini/skills/<name>/) so they stay local while your own skills remain committable. - Codex CLI — spm copies the resolved skills one directory deep into the cross-tool
.agents/skills/<name>/alias (the same standard location Copilot and Gemini can read), where Codex CLI auto-discovers them. Same shared-dir handling as Gemini: spm never wipes the dir, touches only its managed entries, and gitignores just those spm-managed subdirs (.agents/skills/<name>/). - Cursor — spm copies the resolved skills one directory deep into the tool-native
.cursor/skills/<name>/, where Cursor auto-discovers them. Same shared-dir handling as Gemini: Cursor treats that dir as version-controlled, so spm never wipes it, touches only the entries it manages, and gitignores just those spm-managed subdirs (.cursor/skills/<name>/). - Cline — spm copies the resolved skills one directory deep into the tool-native
.cline/skills/<name>/, where Cline auto-discovers them. Same shared-dir handling as Gemini (never wipes the dir, surgical per-skill add/remove, per-skill gitignore). - Windsurf — spm copies the resolved skills one directory deep into the tool-native
.windsurf/skills/<name>/, where Windsurf's Cascade agent auto-discovers them. Same shared-dir handling as Gemini. (Note: Windsurf's global skills live under~/.codeium/windsurf/skills/, not~/.windsurf/.) - Amp — spm copies the resolved skills one directory deep into the cross-tool
.agents/skills/<name>/alias (Amp's documented default, the same dir Codex reads), where Amp auto-discovers them. Same shared-dir handling as Gemini.
- Claude — spm assembles a self-contained plugin marketplace in the project-local, gitignored
On a fresh clone, teammates run spm install — it repopulates their own fetch cache and re-materializes the project-local skills from ai.lock. Same model as node_modules.
{
"targets": ["claude", "copilot"],
"skills": {
"pdf-tools": { "git": "https://github.com/org/skills", "tag": "v1.2.0", "path": "skills/pdf" },
"reviewer": { "git": "https://github.com/me/reviewer", "branch": "main" },
"pinned": { "git": "https://github.com/x/y", "commit": "a1b2c3d" }
}
}targets lists one or more vendors (amp, claude, cline, codex, copilot, cursor, gemini, windsurf) — skills
resolve once and project into each independently.
ai.json is described by a JSON Schema at schema/ai.schema.json
(draft-07). spm embeds it and validates every ai.json on load, reporting all
violations at once with their JSON path:
error: in ai.json: ai.json does not match schema:
at /skills/x: {"git":"u"} is not valid under any of the schemas listed in the 'oneOf' keyword
Add a "$schema" reference for editor autocompletion/validation:
{ "$schema": "./schema/ai.schema.json", "targets": ["claude"], "skills": {} }Version selectors (exactly one per skill):
| field | meaning | locked to |
|---|---|---|
tag |
git tag (annotated tags deref to commit) | resolved SHA |
branch |
branch tip at install/update time | resolved SHA |
commit |
exact commit | itself |
path (optional) selects a subdirectory — for monorepos holding many skills.
Alongside individual skills, ai.json can depend on a full Claude Code
plugin — one that bundles agents, MCP servers, hooks and scripts in addition
to (or instead of) skills. Declare it under a plugins map, keyed by local
name, with the same git/version selectors as a skill; path points at the
plugin root (the directory holding .claude-plugin/plugin.json):
{
"targets": ["claude", "copilot"],
"plugins": {
"design-system": {
"git": "https://github.com/camunda/design-system",
"branch": "main",
"path": "plugins/camunda-design-system"
}
}
}On install:
- Claude gets the whole plugin registered under a dedicated, project-local
spm-pluginsmarketplace (.spm/claude-plugins/), so its agents, MCP servers, hooks and scripts all load — not just itsSKILL.md. - Every target (including skills-only ones like Copilot, Gemini, …) still gets the plugin's bundled skills, flattened into that target's normal skills dir — a graceful, skills-only degradation.
ai.lockpins the plugin's commit and records its bundled skill set.
A bundled skill whose name collides with a standalone skills entry (or another
plugin's skill) is a hard error, never a silent overwrite.
Add or remove a plugin from the CLI with the --plugin flag (point --path at
the plugin root):
spm add https://github.com/camunda/design-system --branch main \
--path plugins/camunda-design-system --plugin --name design-system
spm remove design-system --pluginTo pull in every skill under a directory at once (each immediate
subdirectory that has its own SKILL.md), add --all instead of naming them
one by one:
spm add https://github.com/org/repo --tag v1.0.0 --path skills --allEach sub-skill becomes its own ai.json entry, keyed by its directory name
(--all cannot be combined with --name). This is the one-shot equivalent of
the per-skill spm add … --path <sub> --name <sub> commands spm suggests when
you point --path at a container of skills.
git accepts any URL the system git understands:
spm add https://github.com/org/repo --tag v1.0.0 # HTTPS
spm add git@github.com:org/repo.git --branch main # SSH (scp-style)
spm add ssh://git@github.com/org/repo.git --branch main # SSH (url form)Any git host works — spm shells out to git and never detects or
special-cases a provider, so GitHub, GitLab, Bitbucket, and self-hosted servers
are all supported with no extra config:
spm add git@bitbucket.org:org/repo.git --branch main # Bitbucket
spm add https://gitlab.com/org/repo.git --tag v1.0.0 # GitLab
spm add ssh://git@git.internal.example.com:7999/p/repo.git --branch main # self-hostedSSH auth goes through your ssh-agent / keys — spm never handles credentials.
Private HTTPS repos use your git credential helper. spm runs git with
GIT_TERMINAL_PROMPT=0, so a missing credential fails with a clear error
instead of hanging on a prompt (helpers and ssh-agent still work).
spm ships as a single self-contained binary (needs the system git on PATH
at runtime).
From npm (recommended) — the zero-setup path on every platform. It puts spm
on your PATH with no manual steps:
npm i -g @camunda8/spm
spm --help@camunda8/spm is a thin launcher that pulls in the matching prebuilt binary for
your OS/CPU via an optional dependency (@camunda8/spm-<os>-<cpu>), so nothing is
compiled or downloaded outside npm. Supported: darwin-x64, darwin-arm64,
linux-x64, linux-arm64, win32-x64. Update with npm i -g @camunda8/spm@latest.
From crates.io — build and install from source via Cargo (needs a Rust
toolchain). The crate is spm-cli; the installed binary is spm:
cargo install spm-cliPrebuilt binary — download a release asset directly; no authentication required (the repo is public). The easiest way is the GitHub CLI:
# pick the asset for your platform (see list below); example: Apple Silicon macOS
gh release download --repo camunda/spm-cli \
--pattern 'spm-aarch64-apple-darwin' --output spm
chmod +x spm && sudo mv spm /usr/local/bin/--repo camunda/spm-cli with no tag grabs the latest release; add
v0.1.0 as the first positional arg to pin a specific version.
Without gh, download straight from the public release URL with curl:
# latest release; swap the asset name for your platform
curl -fsSL -o spm \
https://github.com/camunda/spm-cli/releases/latest/download/spm-aarch64-apple-darwin
chmod +x spm && sudo mv spm /usr/local/bin/Assets: spm-x86_64-unknown-linux-gnu, spm-aarch64-unknown-linux-gnu,
spm-x86_64-apple-darwin, spm-aarch64-apple-darwin,
spm-x86_64-pc-windows-msvc.exe.
From source:
git clone https://github.com/camunda/spm-cli && cd spm-cli
make install # release build → /usr/local/bin/spm
make install PREFIX=~/.local # or a custom prefix
# or: cargo install --path .spm init [--target amp|claude|cline|codex|copilot|cursor|gemini|windsurf ...] [-g] # scaffold ai.json (repeatable / comma-separated)
spm add <git> (--tag|--branch|--commit <v>) \ # add + install a skill
[--path <subdir>] [--name <local-name>] [--all] [-g] # --all: add every skill under --path
[--plugin] # --plugin: add a full plugin (see "Full plugins")
spm target add [vendor ...] # add target vendor(s); no arg = pick interactively
spm remove <name> [--plugin] [-g] # drop a skill (or a plugin with --plugin)
spm update [name] [-g] # re-resolve branches/tags to latest
spm install [-g] # rebuild from ai.lock (after clone)
spm list [-g] # show skills + pinned commits
spm status [-g] # check skills are materialized in this checkout
spm clean [-g] # remove generated vendor config
spm prune [--yes] # wipe the global fetch cache ($SPM_HOME/store, default ~/.spm/store)
spm scan [path] # scan skill content for suspicious patterns (default: .)Skills are markdown + scripts that Claude/Copilot auto-discover and act on, so a malicious or compromised skill repo could smuggle in a payload that hijacks the agent or exfiltrates secrets. spm runs a deterministic content scan over every fetched skill/plugin before it is materialized into an agent-discovered directory. It flags:
- Prompt injection — "ignore previous instructions", "disregard your system prompt", etc.
- Secret/credential exfiltration — references to
~/.ssh/id_rsa,.aws/credentials,.git-credentials,GITHUB_TOKEN, … (escalated when paired with an outbound command). - Obfuscation — zero-width/bidi Unicode control characters, base64/hex blobs that decode to shell commands, and files padded past the 8 MiB scan cap (which would otherwise hide content behind a truncated read).
- Command execution / network exfil —
curl | bash,/dev/tcp/…andnc -ereverse shells. - Path traversal —
../../requested in skill text. - Auto-run triggers —
postinstallscripts, git hooks, bundledMakefiles.
High/critical findings block add/install/update. To review a source (or
gate it in CI) run it standalone — it exits non-zero on any blocking finding:
spm scan # scan the current directory
spm scan ./my-skill # scan a specific pathTo override the gate for content you trust (or a false positive), set
SPM_ALLOW_SUSPICIOUS=1 — findings are then printed as warnings but never block.
By default every command operates on the project in the current directory.
Pass -g (--global) to instead manage a user-global set of skills that is
available to your AI tools in every project:
spm init -g --target copilot # create the global manifest ($SPM_HOME/ai.json)
spm add -g <git> --tag v1.0.0 --name reviewer # install a skill globally
spm list -g # list global skills
spm remove -g reviewer # drop a global skill
spm clean -g # remove global vendor config- The global manifest + lock live under
$SPM_HOME(default~/.spm/ai.jsonand~/.spm/ai.lock) — commit/sync them with your dotfiles for a reproducible personal setup. They reuse the same fetch cache as project installs. - Where global skills materialize:
- Copilot CLI →
~/.copilot/skills/<name>/(its personal-skills dir). This directory is shared with skills you author by hand, so spm only ever touches the entries it manages and never wipes the whole directory. - Gemini CLI →
~/.gemini/skills/<name>/(its user-skills dir). Also shared with your own hand-authored skills, so spm touches only its managed entries and never wipes the directory. - Codex CLI →
~/.agents/skills/<name>/(the cross-tool user alias). Also shared, so spm touches only its managed entries and never wipes the directory. - Cursor →
~/.cursor/skills/<name>/(its user-skills dir). Also shared, so spm touches only its managed entries and never wipes the directory. - Cline →
~/.cline/skills/<name>/(its user-skills dir). Also shared, so spm touches only its managed entries and never wipes the directory. - Windsurf →
~/.codeium/windsurf/skills/<name>/(its user-skills dir — note the~/.codeium/windsurfpath, not~/.windsurf). Also shared, so spm touches only its managed entries and never wipes the directory. - Amp →
~/.config/agents/skills/<name>/(its user-skills dir). Also shared, so spm touches only its managed entries and never wipes the directory. - Claude → a self-contained marketplace under
$SPM_HOME/claude-global/, registered in~/.claude/settings.jsonunder the marketplace namespm-global(skills invoked as/spm-global:<name>). A distinct name keeps it from colliding with a project'sspmmarketplace.
- Copilot CLI →
- A skill installed in both scopes collides by name at discovery time
(
/spm:foovs/spm-global:foofor Claude; a duplicatefoodir for Copilot).spm statuswarns when it detects such a global/project shadow.
spm materializes skills into gitignored project-local dirs (.spm/claude/,
.agents/skills/spm-managed-skills/). Git worktrees have their own working
tree and don't share those untracked files, so — exactly like node_modules —
each checkout needs its own spm install:
git worktree add ../feature -b feature
cd ../feature && spm install # materialize this worktree's skillsSkipping this is the usual reason an agent doesn't see a declared skill in a new
worktree or a fresh clone. spm status tells you at a glance and exits
non-zero when anything is missing, so it works in scripts too:
spm status
# [claude] 0/1 installed .../.spm/claude/plugin/skills
# reviewer MISSING
# error: some declared skills are not materialized in this checkout — run `spm install` hereTo install automatically on every branch checkout and new worktree, add a
post-checkout git hook (worktrees share the repo's .git/hooks):
# .git/hooks/post-checkout — then: chmod +x .git/hooks/post-checkout
#!/bin/sh
# Re-materialize spm skills so Claude/Copilot always see the declared set.
[ -f ai.lock ] && command -v spm >/dev/null 2>&1 && spm install >/dev/null 2>&1
exit 0Claude note:
spm installwrites the absolute path of the current checkout's.spm/claude/into that checkout's.claude/settings.local.json. Since that file is gitignored, a new worktree either has no registration at all or — if it was copied over — one still pointing at the checkout it came from. Either way, runspm installinside the worktree and start (or/reload-pluginsin) the Claude session from that same worktree; discovery is snapshotted at session start.spm statusreports a stale pointer explicitly:! .claude/settings.local.json marketplace points at /repo/.spm/claude, not this checkout (/repo-feature/.spm/claude)
To see what each harness actually loaded: claude plugin list /
claude plugin marketplace list for Claude; copilot skill list for Copilot.
- Cross-OS: shells out to the system
git(no libgit2 build deps); no symlinks; all paths viastd::path. Runs on Linux, macOS, Windows. SPM_HOMEoverrides the store root (default~/.spm, holding only the fetch cache) — used by tests. Vendor output is always project-local and is not affected bySPM_HOME.- Vendor adapters: adding a target means implementing one
Vendortrait (src/vendor/).claudeassembles a plugin-marketplace layout (marketplace.json→plugin.json→skills/<name>/SKILL.md) into the gitignored project-local.spm/claude/and points to it;copilotcopies skills into the gitignored project-local.agents/skills/spm-managed-skills/; the remaining targets (gemini,codex,cursor,cline,windsurf,amp) copy skills one level deep into a shared, team-committable skills dir via the genericsrc/vendor/shareddir.rsadapter — each is just a config row naming its project/global dirs (which may differ, e.g. Windsurf's~/.codeium/windsurf/skillsand Amp's~/.config/agents/skills). All share thesrc/vendor/dirskills.rscopy/remove helpers and keep their materialized files out of VCS via the sharedsrc/gitignore.rshelper.
make check runs the full CI gate locally (fmt-check + clippy + test).
To cut a release, bump the crate version (the single source of truth for
crates.io, npm, and the GitHub Release) with make bump — PART=patch|minor|major
(default patch) or VERSION=X.Y.Z. Since main is protected, make bump-pr
does the bump on a branch and opens the PR for you. See RELEASE.md
for the full procedure.
A pre-commit hook (fmt + clippy) installs itself automatically via
cargo-husky — just run cargo test
(or cargo build) once after cloning and the hook lands in .git/hooks. The
hook source lives in .cargo-husky/hooks/. Bypass a
single commit with git commit --no-verify.