ambit is a dependency manager for AI agents.
Every agent harness (Claude Code, Codex, Cursor, opencode, VS Code) loads skills, hooks, and MCP servers. Today you copy those files between projects by hand, and they drift. ambit lets you keep them in a git repo, declare which ones a project wants, and install them into whatever harness your team uses.
You write a few lines of config. ambit fetches, resolves, and writes the files.
- Install
- Quick start
- What you can select
- Configuring your project
- Authoring a catalog
- Staying up to date
- CLI reference
- Development
- License
ambit is a single binary with nothing under it. You need git on your PATH and nothing else.
curl -fsSL https://raw.githubusercontent.com/nebulab/ambit/main/install.sh | sh
That puts ambit in ~/.local/bin. Set AMBIT_INSTALL_DIR to put it somewhere else, or download
the binary for your machine straight from the
releases page.
| Binary | For |
|---|---|
ambit-darwin-arm64 |
macOS, Apple silicon |
ambit-darwin-x64 |
macOS, Intel |
ambit-linux-x64 |
Linux, Intel and AMD |
ambit-linux-arm64 |
Linux, ARM |
ambit-windows-x64.exe |
Windows, Intel and AMD |
On Windows the install script needs a POSIX shell, so run it from Git Bash. Anywhere else, run ambit from npm. That needs Node 22.12+:
npx @teamnebulab/ambit --help
To upgrade a binary later, run ambit self-update. See
Updating ambit itself.
Start a project:
$ ambit init
created (5)
ambit.yml
hooks/.gitkeep
mcps/.gitkeep
packs/.gitkeep
skills/.gitkeep
That writes ambit.yml plus the four item directories. Point it at a catalog and say what you want
from it:
version: 1
harnesses: [claude]
catalogs:
- name: company
source: acme/skills
ref: main
requires:
- pack: "company/engineering" # everything that pack names, transitively
- skill: "company/core.*" # every skill under the `core` prefixThen install:
$ ambit install
harnesses (1)
claude
artifacts (5)
.agents/skills/house-style skill-dir link
.agents/skills/code-review skill-dir link
.agents/skills/storybook skill-dir link
.claude/skills skills-link link
.mcp.json harness-config -
Your agent now sees those skills. ambit only touches files it created, so anything you wrote by hand
survives install, prune, and clean.
Three commands cover most of what you will do next:
$ ambit search "*" # everything the catalogs offer, whether you selected it or not
$ ambit resolve --explain # what you would get, and why
$ ambit outdated # has any catalog moved, and would it change anything?
A catalog is a git repo (or a local directory) holding up to four kinds of thing:
| Kind | Lives in | What it is |
|---|---|---|
| Skill | skills/<name>/SKILL.md |
Instructions the agent can load |
| MCP | mcps/<name>.yml |
A server definition |
| Hook | hooks/<name>/hook.yml |
A command that runs on one harness event |
| Pack | packs/<name>.yml |
A named group of capabilities; optionally exportable as a Claude plugin |
An item's name is its path inside its directory, with / read as .. So
skills/close-crm/SKILL.md is the skill close-crm, and packs/function/engineering.yml is the
pack function.engineering.
Your project is also a catalog. ambit init lists it as one, so a skill you write locally is
selected exactly like a skill from a shared repo.
Supported harnesses: claude, codex, cursor, opencode, vscode.
Everything a project declares lives in ambit.yml:
version: 1
harnesses: [claude]
# Where items come from. Order carries no meaning.
catalogs:
- name: company
source: git@github.com:acme/skills.git
ref: "a1b2c3d4" # tag, branch, or commit. Quote it. Omit for the default branch.
- name: personal
source: git@github.com:jane/skills-private.git
ref: main
- name: local
source: path:. # this project's own packs/, skills/, mcps/, hooks/
# What this project selects. Nothing is implicit: an item no entry reaches is
# not installed. Each entry names its kind and carries `<catalog>/<pattern>`.
requires:
- pack: "company/function.engineering"
- skill: "company/core.*"
- skill: "personal/luma"
- hook: "company/guards.*"| Field | Type | Required | Notes |
|---|---|---|---|
version |
int | yes | Must be 1. |
harnesses |
string[] | no | Any of claude, codex, cursor, opencode, vscode. Default [claude]. |
catalogs |
list of maps | no | name, source, ref?. name must be unique and hold no /, since it is the first half of an address. Dots are fine. |
requires |
list of maps | no | Each entry: exactly one key of pack/skill/mcp/hook, carrying <catalog>/<pattern>. An entry matching nothing is an error. |
Source formats: owner/repo, owner/repo@ref (GitHub shorthand),
https://github.com/owner/repo, git@host:owner/repo.git, git:<any-git-url>,
path:./relative/dir.
Your home directory can be the project. ambit init and ambit install work there like anywhere
else: ~/ambit.yml says what you want, and it lands in ~/.agents/ and the harness files under ~,
which is where a harness keeps the config it applies to every project on the machine.
ambit reads that off the root. Your home directory is a user-level install, any other directory is a
project. Claude MCP servers go into ~/.claude.json for a user-level install and .mcp.json for a
project install. A hook that ships a script also uses a different address. A project install
names the script relative to the project root:
"command": "${CLAUDE_PROJECT_DIR}/.agents/hooks/guard-secrets/guard.sh"A user-level install names it outright, since there is no single project to be relative to. A relative path there would resolve inside whichever project you happen to have open:
"command": "/Users/jane/.agents/hooks/guard-secrets/guard.sh"Which files under ~ a harness actually reads as your own config is the harness's own rule, and it
differs between them. Claude Code reads ~/.claude.json, ~/.claude/settings.json, and
~/.claude/skills this way, so MCP servers, hooks, and skills installed at home reach every project.
A catalog is a plain git repo with some of packs/, skills/, mcps/, and hooks/ in it. There is
no config file and no command that writes into one: it is Markdown and YAML, so use your editor and
run ambit validate to check the result.
A skill is a directory holding SKILL.md. ambit reads two optional keys from its frontmatter:
---
name: close-crm
description: "Calls the Close CRM REST API…"
ambit:
requires: # pulled into the bundle alongside this skill
- skill: company-context
- hook: guards.*
expects: # checked by `ambit doctor`
- env: CLOSE_API_KEY
---requires says what this skill cannot work without, so a project that takes the skill gets a
working bundle rather than a broken one. expects says what must be true of the machine. Patterns
here are bare, with no catalog name, and resolve within the catalog that ships the skill. A
catalog can only require what it ships.
name: sentry
transport:
http:
url: https://mcp.sentry.dev/mcp
bearer_token_env_var: SENTRY_TOKEN
# or, for a locally-spawned server:
# transport:
# stdio:
# command: npx
# args: ["-y", "@acme/close-mcp"]
expects:
- env: SENTRY_TOKEN| Key | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Must match the filename stem. .yml and .yaml both work. |
transport |
map | yes | Exactly one key: stdio or http. |
transport.stdio.command |
string | yes for stdio | Executable to spawn. |
transport.stdio.args |
string[] | no | Arguments, in order. |
transport.stdio.env |
map | no | Variables the process is given. Same ${VAR} handling. |
transport.http.url |
string | yes for http | Server endpoint. |
transport.http.bearer_token_env_var |
string | no | Environment variable whose value is sent as a bearer token. |
transport.http.headers |
map | no | ${VAR} becomes a reference in each harness's own syntax. |
expects |
map[] | no | Preconditions. Today only env:. |
A stdio server is given every variable it expects, under the name it expects. transport.stdio.env
is for a server whose own variable names are not the ones your machine sets:
name: planner
transport:
stdio:
command: npx
args: ["-y", "@acme/planner-mcp"]
env:
PLANNER_TOKEN: "${ACME_PLANNER_TOKEN}"
expects:
- env: ACME_PLANNER_TOKENThe process gets PLANNER_TOKEN, taking its value from ACME_PLANNER_TOKEN. A variable an entry
references is not also passed under its own name, so two servers that both read PLANNER_TOKEN can
each take it from a variable of their own.
A hook is a directory holding hook.yml, plus a script if it ships one.
name: block-rm
description: Refuses a destructive rm before it runs
event: PreToolUse
matcher: Bash
type: script # or `command`
command: guard.sh # a file this directory ships, since `type` is `script`
timeout: 30
expects:
- env: SOME_TOKEN| Key | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Must match the directory path under hooks/. |
description |
string | no | Carried into reports. |
event |
string | yes | One of SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, PreCompact, SessionEnd. |
matcher |
string | no | Tool-name filter. Valid only on PreToolUse and PostToolUse. |
type |
string | yes | command or script, saying how to read command. |
command |
string | yes | What to run. |
timeout |
int | no | Seconds. |
expects |
map[] | no | Preconditions. Today only env:. |
type: command is a command line the harness runs as written, like npx prettier --write.
type: script names a file this directory ships, optionally with arguments: guard.sh --strict.
ambit copies the script next to the harness config and rewrites the path, leaving your arguments
untouched. ${VAR} in a command is left as written, since the harness runs it through a shell.
Hook support varies by harness:
| Harness | Written to | Notes |
|---|---|---|
claude, vscode |
.claude/settings.json |
VS Code reads Claude's file natively, so it is written once. |
cursor |
.cursor/hooks.json |
Different event names, and no matcher field, so a matcher is dropped. |
codex |
.codex/hooks.json |
Experimental: needs [features] codex_hooks = true in the user's own config. doctor warns. |
opencode |
— | No declarative hooks. A selected hook is skipped with a warning and the install succeeds. |
A pack groups skills, MCP servers, hooks, and other packs under one name. Selecting it includes
its requirements. Add plugin metadata to export the pack as a Claude Code plugin.
# packs/function/engineering.yml
name: function.engineering
description: Everything an Acme engineer needs — reviews, tooling, and the guards around them.
requires:
- pack: core # packs compose
- skill: code-review
- skill: guides.*
- mcp: linter
- hook: guard-secrets| Key | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Must match the path under packs/, extension dropped and / read as .. |
description |
string | no | What the pack is for. Shown by ambit search. |
requires |
map[] | no | Same grammar as a skill's: one key per entry, bare patterns, same catalog. |
plugin |
map | no | Metadata for exporting a Claude plugin. |
Select packs in ambit.yml, then export them for people who use Claude Code without Ambit:
# ambit.yml
version: 1
catalogs:
- name: local
source: path:.
requires:
- pack: local/reviewsDefine the pack:
# packs/reviews.yml
name: reviews
plugin:
name: acme-reviews
version: "1.0.0"
description: Review changes using the team's conventions.
author:
name: Acme
requires:
- skill: code-reviewCreate skills/code-review/SKILL.md:
---
name: code-review
description: Review changes for correctness and maintainability.
---
Review the diff and report actionable findings.ambit export --format claude-plugin --output dist/pluginsExported 1 Claude plugins to /path/to/project/dist/plugins
acme-reviews/ (acme-reviews, 2 files)
The result contains acme-reviews/.claude-plugin/plugin.json and
acme-reviews/skills/code-review/SKILL.md. Validate or try it with Claude:
claude plugin validate dist/plugins/acme-reviews
claude --plugin-dir ./dist/plugins/acme-reviewsEach selected pack must have plugin metadata. A requirement on another pack with plugin metadata
adds that plugin to the manifest's dependencies and exports it in a separate directory. Packs
without plugin metadata expand into the containing plugin. Skill requirements include transitive
skills, MCP servers, and hooks. A pack containing only plugin dependencies is supported.
Use plugin.dependencies for external plugins already available in the consumer's marketplace.
Ambit records their names without downloading or exporting them. List local dependencies through
requires: [{pack: other-pack}]. External names appear first in the manifest, followed by local
plugin dependencies in declaration order; repeated names appear once. External plugin dependencies
apply to export only; ambit install installs the pack's Ambit requirements.
plugin field |
Type | Required | Behavior |
|---|---|---|---|
name |
string | yes | Claude plugin namespace. Lowercase letters, digits, and single hyphens. |
version |
string | no | Plugin version, such as "1.0.0". |
description |
string | no | Plugin description. Separate from the pack's search description. |
author |
map | no | Author with required name and optional email and url strings. |
homepage |
string | no | Plugin homepage URL. |
repository |
string | no | Source repository URL. |
license |
string | no | License identifier. |
keywords |
string[] | no | Discovery keywords. |
dependencies |
string[] | no | External Claude plugin names. |
directory |
string | no | Output directory basename. Defaults to name; uses the same naming rules. |
commands |
string | no | Catalog-relative directory copied into the plugin's commands/. |
| Export flag | Behavior |
|---|---|
--format claude-plugin |
Required. Claude Code is the supported format. |
--output <dir> |
Required. New output directory, relative to --project or the current project. Existing paths require --force or --check. |
--force |
Replace the entire output directory after validating the export. Preserve JSON formatting when values are unchanged. |
--check |
Compare existing files, executable bits, JSON values, and exact symlink targets without writing. Exit 5 on drift. Cannot combine with --force or --dry-run. |
--link |
Create relative symlinks for skill directories and hook assets. Requires local path: catalogs. |
--link |
Create relative symlinks for skill directories and hook assets. Requires local path: catalogs. |
--dry-run |
Validate packages and report their names and file counts without writing output. |
--json |
Print the output path and plugin names, directories, and file counts as JSON. |
--offline |
Use cached catalogs only. |
--project <dir> |
Read ambit.yml and ambit.lock from this project. |
For a marketplace kept in the same repository as its catalog, use
ambit export --format claude-plugin --output plugins --link. Skills and hook assets remain linked
to their source files; manifests and slash commands are regular files. Keep the catalogs and exported
plugins in the same relative locations. Omit --link to produce standalone copies.
Regenerate an existing marketplace and check it in CI:
ambit export --format claude-plugin --output plugins --link --force
ambit export --format claude-plugin --output plugins --link --check--force removes stale files from the output directory. It refuses to replace a project root,
a catalog root, or a directory containing source skills or hooks. Failed validation leaves the
existing export untouched.
Exports honor catalog revisions in ambit.lock and leave the lock and installed harness configuration
unchanged. Pin remote inputs with a lock or an immutable catalog ref for reproducible packages.
| Content | Export behavior and limits |
|---|---|
| Skills | Copy into skills/<name>/, including supporting files. Names must be flat, lowercase and hyphenated, at most 64 characters; frontmatter must include matching name and a nonempty description. Nested skills are refused. |
| Skill references | Use /plugin-name:skill-name for explicit Claude invocations. Ambit checks these against the exported plugin and its dependency plugins; external plugin skill names cannot be verified. Skill prose is copied unchanged. |
| MCP servers | Write .mcp.json using Claude's environment references. Credential values are never read. Local MCP commands and explicit local-path arguments are refused; reference bundled assets through ${CLAUDE_PLUGIN_ROOT}. |
| Hooks | Write hooks/hooks.json; copy script assets into hooks/ and reference them with ${CLAUDE_PLUGIN_ROOT}. Assets with conflicting paths are refused. |
| Slash commands | Copy the declared directory into commands/. Markdown commands must have YAML frontmatter. |
| Symlinks | Copy their targets when inside the catalog. External targets, cycles, and relative Markdown links above the plugin root are refused. Executable permissions are preserved. |
Export does not publish a marketplace. Distribute the generated directories through your own marketplace. Agent Plugins export is not supported.
ambit.lock records the exact commit each catalog resolved to, and every later command uses that
commit rather than asking what the ref points at now. So ref: main keeps meaning one commit,
ambit install run twice a week apart installs the same bytes, and a lock committed on one machine
installs the same bytes on another. Commit it.
A catalog with no recorded commit resolves its ref against the remote. That happens on a first
install, when you add a catalog, and when you edit a ref.
ambit outdated asks each remote where its ref points now and reports what moving there would
change. It changes nothing itself:
$ ambit outdated
catalogs (2)
company outdated a1b2c3d → f9e1a04
personal current 3f1a99b
packs (1)
~ engineering requires changed
skills (3)
+ code-review required-by:pack:engineering
+ storybook required-by:skill:code-review
~ house-style description changed
mcps (2)
~ sentry transport.http.url changed
- linear was required-by:pack:engineering
hooks (1)
+ guard-secrets PreToolUse Bash — runs .agents/hooks/guard-secrets/guard.sh
The report is about capabilities, not commits. A branch that advanced two hundred commits without touching anything you selected reports a moved commit and an empty diff.
| Freshness | Meaning |
|---|---|
outdated |
The ref points at a different commit than the project uses now. |
current |
It points at the same one. |
pinned |
The ref is a commit, so it cannot point anywhere else. |
unversioned |
A path: source. It has no revision, so use ambit status instead. |
ambit update is the command that moves the pins forward and then installs. ambit update --dry-run is ambit outdated limited to the catalogs you named.
ambit self-update replaces the binary you are running with the newest release:
$ ambit self-update
current 0.2.0
target v0.3.1
asset ambit-darwin-arm64
binary /Users/you/.local/bin/ambit
installed ambit v0.3.1
The download is checked against the release's checksums.txt before it is installed, the same file
install.sh checks against. A mismatch leaves the binary you already have alone, and there is no
flag to skip the check.
Name a release to install that one instead, which is also how you go back after a bad release:
ambit self-update v0.2.0
--dry-run prints the same report and installs nothing.
This command replaces a binary, so it only works on one. Run ambit from npm and it says so and
names the command that does work: npm i -g @teamnebulab/ambit@latest, or nothing at all for npx,
which fetches the newest version every time it runs.
Once a day, other commands check whether a newer release exists and print one line to stderr when there is:
ambit v0.3.1 is available; you are on 0.2.0. To upgrade, run `ambit self-update`.
The check is skipped when stderr is not a terminal, under CI, with --json, with --offline, and
when AMBIT_NO_UPDATE_CHECK is set to anything. It never delays or fails the command it follows.
| Command | What it does |
|---|---|
ambit init |
Scaffold ambit.yml, the four item directories, and a catalogs: entry naming the project itself. Refuses a directory that already has a config. |
ambit search [--catalog <name>…] [--capability <kind>…] <pattern> |
Search every catalog the project lists, whether anything selects the item or not. Same patterns as requires. A pattern matching nothing is exit 0. |
ambit resolve [--explain] |
Compute the bundle and print it. --explain prints why each item is in it. |
ambit why <kind:name> |
Explain why one item is in the bundle, as a chain back to the entry that asked for it. |
ambit install [--frozen] [--adopt] [--copy|--link] |
Resolve, write ambit.lock, install the files, remove what is no longer selected. |
ambit export --format claude-plugin --output <dir> |
Export selected packs and their local plugin dependencies into separate Claude plugin directories. |
ambit outdated |
Ask each remote where its ref points now, and report what moving there would change. |
ambit update [<catalog>…] [--adopt] [--copy|--link] |
Move those pins forward, then install. Every catalog when none is named. |
ambit status [--check] |
Compare what is installed against what resolve produces. --check exits 5 on drift. |
ambit prune |
Remove installed files that are no longer selected. |
ambit clean |
Remove everything ambit installed. |
ambit validate |
Validate the config and every catalog the project lists. A catalog repo runs this too, since it lists itself. |
ambit doctor |
Check preconditions, the lock, ownership, drift, and harness limits. |
ambit self-update [<version>] |
Replace this ambit binary with a released one, checksum verified. The newest release when no version is named. |
| Flag | Notes |
|---|---|
--project <dir> |
The project to act on. Default: the current directory. Not on self-update, which acts on the binary. |
--json |
Machine-readable output. Every command supports it. |
--offline |
Resolve from the local cache alone. Refused by outdated, update, and self-update. |
--dry-run |
On mutating commands: report what would happen and touch nothing. |
--help |
Usage for the program or for any command. |
--version |
Print the ambit version. |
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Unexpected internal error |
| 2 | Config, ownership, export compatibility, or usage error |
| 3 | Resolution error: a pattern matching nothing, missing requirement, cycle, name conflict |
| 4 | Network or cache error |
| 5 | Drift detected (status --check, install --frozen, export --check) |
| 6 | A health check found something (doctor failures) |
Every error names the file, the identifier, and one concrete next step:
error: `requires` entry "pack:company/function.enginering" matches nothing (ambit.yml line 6)
no pack in catalog "company" has a name matching "function.enginering"
correct the pattern, add the item to a catalog, or remove the entry
error: refusing to overwrite unowned path
.agents/skills/close-crm exists but ambit did not create it
move it aside, or run `ambit install --adopt` to take ownership
ambit is built with Bun. You need Bun 1.3 or newer, and git.
bun install
bun test # offline apart from the one compatibility test
bun run typecheck
bun run lint
bun run format # prettier --write; `format:check` is the CI variant
bun run build # dist/, the npm package, bundled for Node
bun run build:binaries # release/, one executable per platform
There is no build step between an edit and a run: bun run src/cli.ts <args> is the CLI.
The npm package is bundled for Node, because npx @teamnebulab/ambit runs under Node. That is
why src/ uses node: APIs and no Bun globals, and why bun run scripts/smoke.ts exists: it
installs a fixture project with node dist/cli.js, which is the one thing bun test cannot check.
test/golden/ holds recorded program output and is exempt from Prettier. Regenerate it with
UPDATE_GOLDEN=1 bun test and read the diff.
bun run fixture builds the fixture catalog the suite resolves against.
AMBIT_SKIP_NETWORK_TESTS=1 skips the dotagents compatibility test.
MIT