Documentation

Every command, every flag, every caveat.

This page is generated from the project's docs/ folder. New to the tool? Start with the overview and quick start instead — come back here for the full reference.

Installation

terminal
npm install -g claude-modules      # global CLI

Or run it without installing: npx claude-modules --help.

Building from source is covered in the README; see Configuration below for the environment variables.

Configuration

VariableDefaultWhat it points at
CLAUDE_MODULES_HOME ~/.claude-modules Where your modules and the marketplace registry live
CLAUDE_CONFIG_DIR ~/.claude Claude Code's own home — read for the user scope and its plugin caches

How claude-modules stores things, how scopes resolve, and how composition is computed.

Modules

A module is a directory under $CLAUDE_MODULES_HOME/modules/<name>/. Today it holds a single settings.json; whole directories are archived by export so this stays correct as modules grow.

text
$CLAUDE_MODULES_HOME/           # ~/.claude-modules by default
├── settings.json               # the global marketplace registry (see below)
├── user.modules                # the user scope's module list
└── modules/
    ├── base/settings.json
    ├── backend/settings.json
    └── frontend/settings.json

A module's settings.json has exactly four fields:

json
{
  "version": "1.2.0",
  "enabledPlugins": {
    "typescript-lsp@claude-plugins": true,
    "postgres-mcp@claude-plugins": true
  },
  "extraKnownMarketplaces": {
    "claude-plugins": {
      "source": { "source": "github", "repo": "anthropics/claude-plugins" }
    }
  },
  "composedModules": ["base"]
}
  • enabledPlugins and extraKnownMarketplaces match the exact shape Claude Code itself uses in its settings.json, so a module is a portable slice of a real settings file.
  • composedModules is this tool's own addition — see Composition. It is omitted from disk when empty, and written last when present.
  • version is this tool's own addition too, and is advisory: nothing compares, gates, or migrates on it. It is printed by list and info so you can tell two copies of a module apart. See Versioning.

Unknown keys are not preserved — the four fields above are reconstructed on every write, so a hand-added fifth key is dropped the next time any command touches the module. (Claude Code's own settings files are different: there, every unknown key is preserved untouched.)

Module names may not be empty and may not contain /, \, or ...

Two unrelated files are both called settings.json. A module's settings.json uses the four-field schema above. The registry's settings.json at $CLAUDE_MODULES_HOME/settings.json uses {"marketplaces": {...}}. Claude Code's own settings.json is a third thing again. They share a filename and nothing else.

extraKnownMarketplaces is double-nested

Note the repeated source key:

json
"claude-plugins": { "source": { "source": "github", "repo": "anthropics/claude-plugins" } }

The outer wrapper is deliberate — it mirrors Claude Code's own entry shape, minus the machine-specific installLocation and lastUpdated fields, which are stripped when a source is copied out of Claude Code's cache.

Recognized source shapes:

ShapeJSON
GitHub{"source": {"source": "github", "repo": "owner/repo"}}
Git URL{"source": {"source": "git", "url": "https://..."}}
Local path{"source": {"source": "local", "path": "/path/to/mp"}}

Anything else is treated as unrecognized, and therefore as not portable — export warns about it. See Marketplaces for the caveat on the git and local shapes.

Scopes

claude-modules writes into the same three settings files Claude Code reads:

ScopeFileNeeds a git repo?
user~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json if setno
project<repo_root>/.claude/settings.jsonyes
local<repo_root>/.claude/settings.local.json, or <cwd>/.claude/settings.local.json outside a repositoryno

--scope defaults to local everywhere it appears. The repository root is found by walking up from the current directory looking for a .git entry — file or directory, so worktrees and submodules both work.

Outside a repository, local falls back to the current directory and prints a warning saying where it landed. project has no such fallback; it errors.

Precedence

Claude Code resolves enabledPlugins with local > project > user. claude-modules models the same order, which is why its reports annotate an entry (<scope> — overridden by <scope>) when a more-specific scope explicitly disables it.

Managed settings outrank all three and can force a plugin on or off. That is invisible to this tool — status --verify exists to catch it.

What gets written

  • Plugin keys are never deleted from a settings file, only flipped to true / false.
  • Marketplaces are purely additive — existing entries are never removed or overwritten.
  • Every other setting in the file (permissions, theme, ...) is left untouched, including keys this tool doesn't recognize.

Composition

There are two different ways modules combine. Same word, two mechanisms.

Ad-hoc union (CLI-time)

Name several modules on one call and they're unioned for that call only:

terminal
claude-modules enable backend frontend

Nothing is recorded. Do it again next time you want the same combination.

Declared composition (persistent)

A module's own composedModules array names other modules it builds on. This is part of the module itself, so every future enable / disable / status / export against it transitively pulls in whatever those modules currently contribute:

terminal
claude-modules create fullstack --compose backend --compose frontend
claude-modules enable fullstack          # gets backend, frontend, and anything they compose

Set it at creation with --compose, or change it later with compose add / compose remove.

Resolution rules

  • Children are resolved depth-first, then unioned as siblings.
  • A module's own declarations win over anything it composes — both for enabledPlugins and for extraKnownMarketplaces.
  • A composed child contributes only its enabled plugin keys. A child's false never propagates upward; but the parent's own false does survive, and suppresses a plugin the child enables. That is what plugin uninstall --disable writes.
  • Cycles are rejected — Composition cycle detected: a -> b -> a. A module cannot compose itself, directly or transitively.

Marketplace conflicts behave two different ways

This distinction matters:

WhereBehavior
Two sibling modules declare the same marketplace name with different sourcesError. The whole call is rejected before anything is written.
A module's marketplace differs from one already in the target settings.jsonWarning. The existing value is kept and the write proceeds.

The first protects you from an incoherent module set. The second refuses to clobber a settings file you may have edited deliberately.

Plugin keys are never conflict-checked — the union is a plain OR.

Validation timing

create --compose and compose add both fully resolve the hypothetical result before writing, so a self-reference, a missing module, a cycle, or a sibling marketplace conflict rejects the call atomically.

Hand-edits are not validated when you make them. Edit composedModules directly and a typo, a cycle, or a conflict surfaces only on the next command that resolves the module.

Known limit: overrides don't propagate past one level

plugin uninstall --disable only beats a directly composed child — resolve it into a grandparent's composition and the override evaporates. See Composing modules → The one-level override limit for the full detail, including the related silent-resolution behavior for disagreeing siblings.

Versioning

version is a semver string bumped automatically whenever a command changes what a module declares.

BumpCommands
Patch — the module gained somethingplugin install, marketplace add --module, compose add
Minor (patch reset to 0) — the module lost somethingplugin uninstall (with or without --disable), marketplace remove --module, compose remove
Nonemarketplace add / remove without --module (global registry only); export / import; any no-op path that logs a warning
  • A new module always starts at 1.0.0 — create, with or without --from-scope / --compose, regardless of how much it's seeded with.
  • Major is never bumped by the tool. There's no CLI command to set a version directly: hand-edit version and the next bump increments from whatever you set, so a hand-edited 2.0.0 becomes 2.0.1 after the next plugin install.
  • An unparseable version silently falls back to 1.0.0 before bumping.

The marketplace registry

$CLAUDE_MODULES_HOME/settings.json is a small global lookup of marketplace name → source:

json
{
  "marketplaces": {
    "claude-plugins": {
      "source": { "source": "github", "repo": "anthropics/claude-plugins" }
    }
  }
}

It's populated by marketplace add and consulted by plugin install, so you don't have to repeat --source every time. Unknown top-level keys in this file are preserved.

Sources resolved out of the registry are copied onto the module as a snapshot, not referenced. Later changes to the registry don't propagate.

Module lists

An optional file — one module name per line — that remembers which modules you applied, so reload can reapply them and status can tell you when a scope has drifted. One list per scope, each living with the thing it describes:

ScopeModule list
user$CLAUDE_MODULES_HOME/user.modulesglobal; not tied to any repository
project<repo_root>/.claude-modulesshared with the team — commit it
local<repo_root>/.claude-modules.local, or <cwd>/.claude-modules.local outside a repositorypersonal to your checkout — gitignore it

Blank lines and # comments are ignored on read.

Repo-scoped lists sit at the repository root and are found by walking up from the current directory, so reload works from any subdirectory. The user list has one fixed location, so reload --scope user works anywhere, including outside a repository.

Each scope reads only its own file. There is no cross-scope fallback, so a list is never applied at a scope it wasn't written for, and enable --scope X --save, reload --scope X, and status --scope X always agree.

Context budget

A secondary benefit: every enabled plugin adds tools Claude has to choose between on every turn, and Claude's own documentation is explicit that this has a cost:

"Tool selection accuracy degrades with more than 30-50 tools loaded at once." — Claude Code docs: Scale to many tools with tool search

Scoping your tools to the role you're currently performing is the fix, and modules make that practical.

The caveat: Claude Code's MCP tool search — on by default — already defers MCP tool schemas from context and loads them on demand. So the context-budget argument is weaker for MCP-heavy plugins specifically than it is for skills, slash commands, subagent definitions, hooks, and LSP servers, which tool search doesn't touch.

The reproducibility, composability, and drift-detection value of modules is unaffected either way — which is why this README leads with those instead.

No token-cost preview

claude-modules reports plugin counts, not tokens. Claude Code knows the number — claude plugin details <plugin> prints a "Projected token cost" — but there's no way to aggregate it per module yet: the command has no --json output, and it only resolves plugins that are already enabled, so it can't cost a module you haven't applied.

list

terminal
claude-modules list

Lists every module under $CLAUDE_MODULES_HOME/modules, with its version, its count of enabled plugins, and its count of known marketplaces.

text
backend (v1.2.0) — 2 plugin(s) enabled, 1 marketplace(s)
base (v1.0.1) — 1 plugin(s) enabled, 1 marketplace(s)
frontend (v1.0.0) — 1 plugin(s) enabled, 1 marketplace(s)

Counts are the module's own declarations, not what it effectively contributes once composed modules are resolved in. Plugins explicitly set to false are not counted as enabled.

With no modules yet:

text
No modules found. Create one with 'claude-modules create <name>'.

This command never writes.

info

terminal
claude-modules info <module>

Shows a module's full detail: what it composes, every plugin it declares (with whether that declaration is enabled or disabled), and every marketplace it knows about with its raw source JSON.

text
backend (v1.2.0): composes 1 module(s):
  base

backend (v1.2.0): 2 plugin(s):
  typescript-lsp@claude-plugins (enabled)
  postgres-mcp@claude-plugins (enabled)

backend: 1 marketplace(s):
  claude-plugins: {"source":{"source":"github","repo":"anthropics/claude-plugins"}}

The composes block is printed only when composedModules is non-empty.

Like marketplace list --module, this reads the module's own file directly — it shows what the module declares, not what it effectively contributes once composition is resolved. To see the resolved result, apply it and read the report, or use status.

This command never writes.

create

terminal
claude-modules create <module> [--from-scope user|project|local] [--compose <module>]... [--dry-run]

Creates a new module. Errors if the name is already taken.

OptionDefaultEffect
--from-scope <scope>unset — empty moduleSeed from that scope's live settings.json
--compose <module>noneBuild on another module (repeatable)
--dry-runoffReport what would be created, write nothing
terminal
claude-modules create backend
claude-modules create backend --from-scope local
claude-modules create frontend --compose base
claude-modules create fullstack --compose backend --compose frontend

New modules always start at version 1.0.0, however much they're seeded with.

--from-scope

A shortcut for capturing a scope you've already configured by hand, or through /plugin, without re-typing every <plugin>@<marketplace> through plugin install:

terminal
cd ~/projects/api-service
claude-modules create backend --from-scope local

It's a shortcut, not the recommended way to build a module: a scope tends to accumulate plugins for whatever you were doing at the time, so seeding from it can just as easily capture a bundle that mixes unrelated concerns (backend and frontend plugins together, say) as it can a clean one. Building a module deliberately, one plugin install at a time, keeps it scoped to a single concern and easier to compose later — see the example workflow in the README.

Only plugins actually enabled (true) there are captured. An explicit false is an override — that's how enable --only suppresses a plugin inherited from a broader scope — not a member of the set that scope uses, so it's skipped rather than recorded as disabled.

The scope is only read, never written.

A marketplace with a non-github source is warned about, since it won't mean anything on another machine.

--compose

Declares a persistent relationship, stored on the new module as composedModules — distinct from the ad-hoc union enable backend frontend performs at CLI time. See Composition.

--compose is independent of --from-scope; combine both to seed from a live scope and declare a composition.

Before anything is written, a self-reference, a missing --compose target, a composition cycle, or a marketplace conflict between two composed modules is rejected. --compose only sets the composition at creation time — use compose add / compose remove to change it afterward.

text
Created module 'fullstack' with 0 plugin(s), 0 marketplace(s), and 2 composed module(s).

remove

terminal
claude-modules remove <module> [--dry-run]

Deletes a module's directory, recursively. Idempotent — if the module doesn't exist, this logs a warning rather than failing.

It only removes the module itself. It does not:

  • touch any scope's settings.json — plugins already enabled stay enabled until the next enable / reload that omits this module
  • clean up references to the module in any module list

If the module is still listed in one of this machine's saved module lists, a warning names the file. That can't reach lists on other machines or other repos, but it catches the common case. reload also names the specific list file if it later hits a module that was removed out from under it.

Changing which modules a module builds on, after it's been created. For how composition is resolved, see Concepts → Composition.

compose add

terminal
claude-modules compose add <module> <composed...> [--dry-run]

Adds one or more composed modules to an existing module's composedModules — the CLI path to change composition after create, short of hand-editing settings.json.

terminal
claude-modules compose add frontend base
claude-modules compose add frontend base shared-tools

Every future enable / disable / status / export against <module> transitively pulls in whatever the newly-composed module(s) contribute.

Validated atomically. A self-reference, a composition cycle, a missing composed module, or a sibling marketplace conflict rejects the entire call — nothing is written, even if some of the named modules would have been fine on their own. This is the same validation create --compose runs.

Idempotent per name. A composed module already present is skipped and noted in the output. If every named module is already composed, this is a no-op that logs a warning rather than failing.

Bumps the module's patch version once per call, regardless of how many modules were added. A no-op call bumps nothing.

compose remove

terminal
claude-modules compose remove <module> <composed...> [--dry-run]

The reciprocal: removes one or more composed modules from an existing module's composedModules.

terminal
claude-modules compose remove frontend shared-tools

Idempotent per name. A module not currently composed is skipped and noted. If none of the named modules are composed, this is a no-op that logs a warning rather than failing.

Never re-validates composition afterward — removing entries can only shrink the effective set, never introduce a new cycle or conflict.

Bumps the module's minor version (patch reset to 0) once per call.

Hand-editing

The only way to bypass validation is to edit a module's settings.json directly. claude-modules does not validate a hand-edit when it's made: a typo, a cycle, or a conflicting marketplace surfaces as an error only on the next command that resolves the module — not at edit time.

Note also that composedModules must be an array of strings. A hand-edited "composedModules": "base" is rejected with an invalid-shape error rather than being coerced.

The one-level override limit

plugin uninstall --disable writes an explicit false into a module, which suppresses a plugin that a directly composed child enables.

That override does not propagate past one level. Resolve the overriding module into a grandparent's composition and it evaporates — the grandparent sees no opinion either way, not an explicit false.

In the same vein, two composed siblings that disagree on a plugin key resolve silently rather than erroring the way a marketplace conflict does: the enabling sibling wins, without warning.

Neither case is common enough today to justify reworking composition's internal representation to track a real enabled / disabled / unmentioned tri-state. If you build a deep composition tree that depends on multi-level exclusion, know the limit.

Running claude-modules plugin with no subcommand prints this group's usage.

plugin install

terminal
claude-modules plugin install <module> <plugin>@<marketplace> [--source '<json>'] [--dry-run]

Enables a plugin inside a module.

terminal
claude-modules plugin install backend typescript-lsp@claude-plugins

Bumps the module's patch version.

Marketplace resolution

If the plugin's marketplace isn't already known to the module, its source is resolved in this order:

  1. an explicit --source '<json>' on this call — stored on this module, and also registered with Claude Code directly (claude plugin marketplace add, best-effort, same semantics as cache warming below) if it isn't already in known_marketplaces.json
  2. the global marketplace registry
  3. Claude Code's own local known_marketplaces.json cache — so if you've already added the marketplace via /plugin inside Claude Code, this picks it up automatically
  4. otherwise the command fails, naming the remedies above

Whichever wins, the source is copied onto the module as a snapshot, not referenced. A non-github source is warned about, since it won't mean anything on another machine.

terminal
claude-modules plugin install backend foo@custom-mp \
  --source '{"source":{"source":"github","repo":"me/custom"}}'

Cache warming

Enabling a plugin in settings.json isn't enough on its own — Claude Code also needs the plugin's content materialized in its own cache ($CLAUDE_CONFIG_DIR/plugins/), or a new session in a repo using it fails with Plugin "<name>" not cached at <marketplace-dir>.

To prevent that, plugin install also runs claude plugin install <plugin>@<marketplace> --scope user -y on your behalf — but only if the plugin isn't cached yet and its marketplace is already known to Claude Code.

This never happens silently. A plugin that was missing and got installed is logged explicitly, and any failure (no claude on PATH, no network, unknown marketplace) is a warning, not a hard error — the module is saved either way.

-y accepts a marketplace-declared "run a command to install" plugin's command without showing it to you first. Fine for the official marketplace; worth knowing for third-party ones.

Because caching is best-effort, a plugin can end up enabled without actually being cached. Every command that writes enabledPlugins reports this inline, annotating the plugin (not cached by Claude Code — run ...), and status answers the same question on demand without writing anything.

enable --install and reload --install re-check this too, so a plugin enabled by hand-editing a module file, or one synced in from another machine, still gets cached before you hit the error in a real session. Plain enable / reload do not attempt it; they only report what's missing.

plugin uninstall

terminal
claude-modules plugin uninstall <module> <plugin>@<marketplace> [--disable] [--dry-run]

The reciprocal of plugin install: disables a plugin inside a module.

terminal
claude-modules plugin uninstall backend typescript-lsp@claude-plugins

By default it deletes the key from the module's enabledPlugins. It deliberately does not:

  • run claude plugin uninstall — Claude Code's plugin cache is shared across modules, so other modules on this machine may still need the plugin cached
  • remove the plugin's marketplace from extraKnownMarketplaces, even if it was the last plugin referencing it

If the plugin isn't currently enabled in the module, this is a no-op that logs a warning rather than failing, and bumps nothing. Otherwise it bumps the module's minor version (patch reset to 0).

--disable

terminal
claude-modules plugin uninstall backend typescript-lsp@claude-plugins --disable

Sets the key to false instead of deleting it.

Meaning
plain uninstall"this module says nothing about the plugin"
--disable"this module explicitly turns it off"

The difference only matters under composition: a module's own explicit false takes precedence over what a composed module contributes, but a merely-absent key has no such effect.

Unlike plain uninstall, --disable isn't gated on the plugin already being enabled in this module — that's the point, since the plugin it's meant to override typically isn't declared here at all. The only true no-op is a plugin that's already explicitly false.

Also bumps the minor version.

The override only survives one level of composition. See the one-level override limit.

Note that a module's false removes the plugin from the computed union — it doesn't actively turn the plugin off in a settings file that already has it true. Only enable --only resets that.

Marketplaces live in two places: the global registry at $CLAUDE_MODULES_HOME/settings.json, and each module's own extraKnownMarketplaces. Every command here targets the global registry by default, and a module instead when given --module.

Running claude-modules marketplace with no subcommand prints this group's usage.

marketplace add

terminal
claude-modules marketplace add <spec> [--name <name>] [--source '<json>'] [--module <module>] [--dry-run]

Registers a marketplace, mirroring /plugin marketplace add <spec>, so plugin install can resolve it without repeating --source.

terminal
claude-modules marketplace add anthropics/claude-plugins
claude-modules marketplace add anthropics/claude-plugins --name official
claude-modules marketplace add anthropics/claude-plugins --module backend
OptionDefaultEffect
--name <name>inferred from the specOverride the marketplace name
--source '<json>'auto-detectedSkip detection; store this source verbatim
--module <module>the global registryRegister onto this module instead
--dry-runoffResolve and report, write nothing

With --module, bumps that module's patch version. Without it, only the global registry changes and no module's version is touched.

Syncing to Claude Code

If the marketplace isn't already known to Claude Code ($CLAUDE_CONFIG_DIR/plugins/known_marketplaces.json), this also runs claude plugin marketplace add <spec> --scope user on your behalf — the same best-effort cache-warming plugin install already does for plugins. A freshly-added marketplace is logged explicitly; a failure (no claude on PATH, no network) is a warning, not a hard error — the registry write above still succeeds either way.

Spec detection

Spec formDetected as
owner/repoGitHub shorthand
owner/repo@v2.0GitHub shorthand pinned to a branch/tag — the name is still inferred from owner/repo alone
https://... or git@...git URL (pin with a trailing #ref)
anything elselocal path

A local path ending in marketplace.json infers its name from the parent directory; a trailing / or .git is stripped.

owner/repo#ref is an error, not a silent misparse — GitHub shorthand doesn't support #ref pinning. The message steers you to owner/repo@ref or the full git URL.

Caveat. Only the GitHub shorthand has a documented settings.json representation. The git-URL and local-path shapes this tool writes are unverified against real Claude Code output. Check the registered entry with marketplace list, or bypass detection entirely with --source '<json>'.

marketplace remove

terminal
claude-modules marketplace remove <name> [--module <module>] [--dry-run]

The reciprocal of marketplace add.

terminal
claude-modules marketplace remove official
claude-modules marketplace remove official --module backend

Idempotent — if the marketplace isn't registered on the selected target, this logs a warning rather than failing.

Without --module, it only edits the global registry. It does not touch any module's extraKnownMarketplaces (those were copied as a snapshot, not a live reference) and does not touch Claude Code's own known_marketplaces.json. A future plugin install that needs this marketplace and can't fall back to Claude Code's cache will fail until it's registered again.

With --module, it only edits that module's own map. If the module inherited the marketplace from a composed module, it isn't in the module's own map, so this reports it as not registered rather than reaching into the composed module.

Version: --module bumps that module's minor version on a successful removal. The idempotent no-op path bumps nothing, and neither does the global-registry-only form.

marketplace list

terminal
claude-modules marketplace list [--module <module>]

Lists registered marketplaces — the global registry by default, or a single module's own extraKnownMarketplaces with --module. Each line shows the name and its raw source JSON.

text
claude-plugins: {"source":{"source":"github","repo":"anthropics/claude-plugins"}}

Like the other --module forms, this shows the module's own map, not marketplaces it inherits from a composed module.

This command never writes.

These are the commands that write Claude Code's own settings.json. For which file each scope maps to, see Concepts → Scopes.

Changes apply to the next session. Claude Code reads enabledPlugins at session start, so these commands have no effect on a session that's already open — which looks exactly like the command having done nothing. Run /reload-plugins in that session (add --force if it warns about the prompt cache), or start a new one.

enable

terminal
claude-modules enable <module...> [--scope user|project|local] [--only] [--install] [--save[=<path>]] [--dry-run]

Computes the union of enabled plugins and known marketplaces across the given modules — resolving each module's composition transitively — and writes it into the target scope.

terminal
claude-modules enable backend
claude-modules enable backend shared-tools --scope project --save
claude-modules enable backend --save=./config/team-modules.list
claude-modules enable backend --scope project --only
claude-modules enable backend --install

Default (additive) behavior

  • Every plugin in the union is enabled. Every other plugin is left exactly as it was, so a later enable for a different module adds to what's already active in that scope rather than replacing it.
  • Marketplaces in the union are added if missing; existing entries are never removed or overwritten. A differing pre-existing source is warned about and kept.
  • Every other setting in the file is untouched.
text
Enabled module(s) [backend] in project scope (/repo/.claude/settings.json); 1 marketplace(s) known.

Enabled plugin(s):
  - code-review@claude-plugins (project)
  - postgres-mcp@claude-plugins (project — not cached by Claude Code — run 'claude plugin install postgres-mcp@claude-plugins --scope user -y')
  - typescript-lsp@claude-plugins (project)

Plugin(s) not cached by Claude Code:
  claude plugin install postgres-mcp@claude-plugins --scope user -y

Run with --install to attempt these automatically, or install manually with the command(s) above.

Already running a Claude Code session? It won't pick this up on its own — run /reload-plugins in it (add --force if it warns about the prompt cache), or start a new session.

Saved module selection to /repo/.claude-modules.
Modules active in project scope: [backend].

(code-review@claude-plugins is there because backend composes base, which declares it.)

The report

The Enabled plugin(s): block covers every scope in effect — local/project/user inside a repository, local/user outside one — each plugin tagged with its scope, colour-coded, and annotated (<scope> — overridden by <scope>) when a more-specific scope explicitly disables it. It always covers the whole chain regardless of --scope, since that's what decides which plugins a session actually loads — enable --scope user inside a repository still shows what local there contributes on top.

The Modules active in <scope> scope: line comes from the scope's saved list (updated first, if --save was given), or this run's own module names — noted as not persisted — when no list exists.

Options

OptionDefaultEffect
--scope user|project|locallocalWhich settings.json to write
--onlyoffMake the scope exactly these modules instead of adding to it
--installoffAttempt to add missing marketplaces and install missing plugins into Claude Code's caches
--save[=<path>]offUpdate the scope's saved module list for reload
--dry-runoffCompute and print the full report, write nothing

project requires a git repository — pass --scope user or --scope local outside of one. local itself needs no repository: outside one it resolves against the current directory instead, and a warning is printed so it's clear where it landed.

--only

Makes the target scope exactly the given modules:

  • every plugin not in the union is explicitly disabled in this scope's own file
  • for local / project, any plugin enabled in a broader scope but not in the union is also disabled here (project and user for local; just user for project)

The broader scope's file is only ever read, never written. The output names which plugins were overridden and which scope they came from. At user scope there is nothing broader to override, but this scope's own plugins are still made exact.

This is the flag to reach for when you want an exclusive role switch.

--install

Attempts, in order:

  1. to add to Claude Code's known_marketplaces.json any union marketplace it doesn't know — one claude plugin marketplace add <source> --scope user per marketplace
  2. to install into Claude Code's plugin cache any union plugin it hasn't cached — one claude plugin install <plugin> --scope user -y per plugin, attempted only when the plugin's marketplace is already known to Claude Code

Marketplaces are always attempted first, so a plugin from a marketplace added in the same run still gets a chance to install. Scope is always user for both, since Claude Code's caches are shared across scopes.

Off by default: a plain enable never shells out to claude; it only reports what's missing.

If a marketplace or plugin is still missing after the attempt, enable exits with code 2 — unless --dry-run was also given, in which case nothing was attempted and the exit code stays 0.

--save

Updates the scope's saved module list, one name per line, for later use by reload. The selected modules are merged into whatever the list already had, matching the additive default. (--only --save replaces the list instead, matching --only's exact-set semantics.)

  • Bare --save writes to that scope's own list — see the module-list table. Saving at one scope never overwrites another's.
  • --save=<path> writes to <path> instead; relative paths resolve against the current directory, not the repository root. A custom path is only ever read back with reload --file <path> — bare reload looks only at the per-scope locations and will not find it. The merge base is still read from the scope's canonical list, if one exists.
  • The value must be given with = (--save=path), not as a following argument (--save path) — enable takes a variadic list of module names, so a space-separated value would be ambiguous with the next one.

disable

terminal
claude-modules disable <module...> [--scope user|project|local] [--save] [--dry-run]

The reciprocal of enable: computes the union of enabled plugins across the given modules and flips each one to false in the target scope.

terminal
claude-modules disable backend --scope project
claude-modules disable backend --save
  • Only plugin keys already present in that scope's settings.json are touched — a plugin never enabled there has nothing to disable, and no phantom false is created.
  • A touched key is kept and set to false, never deleted.
  • Marketplaces and every other setting in the file are untouched.
  • A module that enables no plugins is a no-op that logs a warning rather than failing.

--save removes the given modules from the scope's saved list, if one exists. A name not in the list, or no list at all, is a no-op with a warning. Unlike enable --save, it always targets the scope's own canonical list and does not take a path — --save=<path> is an explicit error.

Afterward it prints the same report enable does, plus the Modules active in <scope> scope: line.

disable-all

terminal
claude-modules disable-all [--scope user|project|local] [--dry-run]

Disables every plugin key currently known to the target scope's settings.json, without needing a module — the bulk equivalent of disable.

terminal
claude-modules disable-all --scope local

Keys are kept and set to false, never deleted. Marketplaces and every other setting are untouched. Afterward it prints the same report enable does.

reload

terminal
claude-modules reload [--scope user|project|local] [--file <path>] [--install] [--dry-run]

Re-applies a previously-saved list of module names — equivalent to running enable with that same list.

terminal
claude-modules reload --scope project
claude-modules reload --file ./config/team-modules.list
claude-modules reload --install
  • Reads the list belonging to --scope — exactly what enable --scope <same> --save wrote.
  • Repo-scoped lists are found by walking up from the current directory to the repository root, so this works from any subdirectory. The user list has one fixed location and needs no repository. local outside a repository looks only at the current directory itself.
  • Each scope reads only its own file. There is no cross-scope fallback, so a list is never applied at a scope it wasn't written for.
  • --file <path> reads the list from <path> directly, skipping the search; relative paths resolve against the current directory.
  • --install attempts the same best-effort caching enable --install does — same flag, same semantics, same exit-code-2 contract.

If a listed module no longer exists, the error names the specific list file the stale name came from.

Behavior change. reload used to attempt the caching step unconditionally, with no way to turn it off. It now defaults to off and only attempts it when --install is passed, for symmetry with enable. If you relied on reload silently re-caching plugins, add --install.

update

terminal
claude-modules update [module...] [--scope user|project|local] [--dry-run]

Resolves the union of enabled plugins and known marketplaces across the given modules — the same transitive composition enable/disable/reload compute — and asks Claude Code itself to bring each one up to date, marketplaces first:

  1. claude plugin marketplace update <name>, one per union marketplace
  2. claude plugin update <plugin> --scope <scope> -y, one per union plugin
terminal
claude-modules update backend-dev
claude-modules update backend-dev frontend-dev
claude-modules update --scope user

Unlike every other command on this page, update never writes a settings.json — a module's own file or any scope's. It doesn't change what's enabled, only what version of it Claude Code has installed.

With no module name given, it updates whatever modules are currently active for --scope instead — the same saved list reload would read back. It errors if that scope has no saved list.

--scope (default local) serves two purposes: which saved module list to read (when no name is given), and the --scope passed to claude plugin update — but the two claude subcommands this drives treat it differently:

claude subcommand--scope's effect
claude plugin marketplace updatenot passed — marketplaces aren't scoped
claude plugin updatepassed through; defaults to user when run directly, not local

If your plugins actually live at user scope (the common case for something installed once and shared across repos), pass --scope user explicitly.

Best-effort per item, like enable --install: one marketplace or plugin failing to update (network error, nothing installed at that scope, ...) is logged as a warning and doesn't stop the rest of the run. If anything failed and this wasn't a --dry-run, update exits with code 2 afterward — same contract as enable --install and status.

An already-open Claude Code session doesn't pick up an updated plugin's new code on its own — claude plugin update itself notes "restart required to apply" — so restart the session (or start a new one) afterward. This is distinct from the /reload-plugins hint above, which is about enabledPlugins changes, not plugin version changes.

status

terminal
claude-modules status [--scope user|project|local] [--verify] [--json]

Read-only audit of a scope's live settings.json against two independent sources of truth — three with --verify. Every check always runs and reports, so none hides another, and status writes nothing.

terminal
claude-modules status
claude-modules status --scope project
claude-modules status --verify
claude-modules status --json

--scope defaults to local. project requires a git repository; local does not.

Check 1 — Claude Code's plugin cache

Prints the same report enable does, additionally cross-checking every effectively enabled plugin (one not overridden by a more-specific scope) against Claude Code's cache and annotating it when missing.

This is the check plugin install / enable --install / reload --install can't give you after the fact: their caching step is best-effort by design, so a caching failure never blocks the settings write and their own success output can't tell you whether it actually happened.

Check 2 — the scope's module list

Found the same way reload finds it, so status --scope X always grades itself against whatever enable --scope X --save wrote — never another scope's list. Unlike reload, a missing file isn't an error; it just means there's nothing to compare against.

When found, its listed modules are resolved exactly like enable would, and diffed against the scope's own settings.json:

  • Missing — a listed module wants a plugin enabled, but it isn't.
  • Stale — a plugin is enabled, but no listed module declares it. Explicit-false entries left by enable --only are never reported as stale, since they were never "enabled" to begin with.

Check 3 — Claude Code's own resolution (--verify only)

Runs claude plugin list --json and diffs its enabled set against the one computed above.

Worth the subprocess because precedence can be decided somewhere this tool can't see: it models scope precedence as local > project > user, but managed settings outrank all three and can force a plugin on or off. On a machine with an administrator policy, the report from checks 1–2 can be confidently wrong, and this is the only way to notice.

Off by default, so plain status keeps its guarantee of running nothing external. A --verify that can't run — no claude on PATH, unparseable output — is a warning, not drift: it never changes the exit code.

--verify behaves identically at all three scopes. Both sides describe the same thing — what a session started in this directory would load — because the report always covers the full local > project > user chain regardless of --scope.

Example

text
Enabled plugin(s):
  - code-review@claude-plugins (project)
  - postgres-mcp@claude-plugins (project — not cached by Claude Code — run 'claude plugin install postgres-mcp@claude-plugins --scope user -y')
  - typescript-lsp@claude-plugins (project)

Module list /repo/.claude-modules (modules: backend) vs project scope (/repo/.claude/settings.json):
  In sync — every enabled plugin matches the listed module(s).

Error: 1 plugin(s) not cached by Claude Code: postgres-mcp@claude-plugins. Run 'claude-modules enable --install' or 'reload' to re-cache them, or install manually with 'claude plugin install <plugin>@<marketplace> --scope user'.

With no module list for the scope, check 2 reports that and skips instead:

text
No module list found for local scope (/repo/.claude/settings.local.json) — skipping module-drift check.

Exit codes

The exit code distinguishes "ran fine" from "found a problem" from "couldn't even check", so status is usable as a CI or pre-session gate, not just a log line to notice in the moment.

CodeMeaning
0Clean: every effectively-enabled plugin is cached, settings.json matches the listed module(s) if any, and — with --verify — Claude Code's own resolution agrees
1status itself couldn't run (bad --scope, no repo root, ...)
2It ran fine, but found a problem — uncached plugins, missing/stale plugins relative to the listed modules, unresolvable listed modules, or a --verify disagreement

--json

Suppresses the human-readable report and prints one JSON object to stdout instead. The exit-code contract is unchanged, so it stays usable as a CI gate while also being scriptable.

json
{
  "ok": false,
  "scope": "project",
  "settingsPath": "/repo/.claude/settings.json",
  "checks": {
    "cache": { "uncachedPluginKeys": ["postgres-mcp@claude-plugins"] },
    "moduleList": {
      "listFilePath": "/repo/.claude-modules",
      "resolutionFailed": false,
      "missingPluginKeys": [],
      "stalePluginKeys": []
    },
    "verify": null
  }
}
  • checks.moduleList.listFilePath is null when the scope has no module list.
  • checks.verify is null unless --verify was passed; otherwise { "ran": true, "unavailable": false, "unexpectedlyEnabled": [], "unexpectedlyDisabled": [] }. unavailable: true means the claude plugin list --json cross-check itself couldn't run — the warning that would normally explain why is suppressed under --json, since that signal relocates into this field.
  • ok mirrors the exit-code decision: false exactly when the command would exit 2.

In that case the human-readable problem summary still prints on stderr, so a script reading only stdout always sees clean JSON.

export

terminal
claude-modules export <module> [--output <path>] [--dry-run]

Packages a module's directory — and the directory of every module it transitively composes — into a single .tar.gz, so the whole composition chain travels together and works out of the box after import on another machine.

terminal
claude-modules export backend
claude-modules export fullstack --output ~/backups/fullstack.tar.gz

Without --output, the archive is written to <module>-<YYYY-MM-DD>.tar.gz in the current directory, date-stamped in local time.

text
[dry-run] Would export module 'fullstack' and 3 composed module(s) (backend, base, frontend) to '/repo/fullstack-2026-08-21.tar.gz'.

Note that the composed set is the full transitive closure — fullstack composes only backend and frontend, but base comes along because both of those compose it.

A composition cycle among the modules being archived is rejected before anything is written.

If any archived module has a marketplace with a non-github source, a warning is logged — same as create --from-scope and plugin install — since that marketplace won't mean anything on another machine.

export does not bump any version: transferring a module isn't changing it.

Archive format

text
manifest.json
modules/<root>/settings.json
modules/<composed>/settings.json
...

The modules/ tree mirrors $CLAUDE_MODULES_HOME/modules/ — one directory per module, whatever it contains. manifest.json records which module is the root and which are composed:

json
{
  "version": 1,
  "rootModule": "fullstack",
  "composedModules": ["backend", "base", "frontend"],
  "exportedAt": "2026-08-21T12:00:00.000Z"
}

That manifest is what lets import --name / --composed-prefix rename modules on the way in without losing track of which directory is which. It's an implementation detail, not something you need to read or edit. An archive with a different manifest version is rejected with a message telling you to upgrade claude-modules.

import

terminal
claude-modules import <archive> [--name <name>] [--composed-prefix <prefix>] [--dry-run]

Unpacks a module — and its composed modules — from an export archive. The reciprocal of export.

terminal
claude-modules import backend-2026-08-21.tar.gz
claude-modules import backend-2026-08-21.tar.gz --name backend-imported
claude-modules import backend-2026-08-21.tar.gz --composed-prefix teammate-

The root module is named from --name if given, otherwise from the name it was exported under.

Composed modules keep their original names unless --composed-prefix is given, in which case every composed module — at every level of the composition tree, not just the root's immediate children — is renamed with that prefix, and every module's composedModules references are rewritten to match, so composition keeps working after the rename.

Collisions

If any module being imported — root or composed — would collide with a module that already exists on this machine, nothing is written. The error lists every collision, and for each recommends either renaming the existing module, or re-running with --name (for a root collision) or --composed-prefix (for a composed-module collision).

--name / --composed-prefix are also rejected up front if they'd land two imported modules on the same final name.

--dry-run validates the archive and checks for collisions entirely in memory, writing nothing at all.

After the write

Modules are written leaves-first, root last, so a crash mid-import leaves the result reading as failed rather than as a broken visible root.

Post-write, composition is re-resolved. If the imported composition doesn't resolve — say two composed modules were hand-edited to declare the same marketplace differently — the import still succeeds with a warning rather than being rolled back.

import carries each module's version through unchanged.

What a module deliberately does not carry

Per-plugin configuration

Claude Code's per-plugin userConfig — set via claude plugin install --config or /plugin configure, and where an MCP server's API key or endpoint typically lives — is not captured by create --from-scope, export, or import.

This is deliberate, not an oversight. A module is designed to be shared and committed (the project-scope module list is meant to go into version control), and capturing userConfig by default would turn that into an accidental secret-exfiltration path.

A plugin that needs configuration to function arrives enabled but unconfigured after any of the above. Configure it separately with Claude Code's own tooling.

Plugin versions

A module records which plugins to enable, not which versions. Syncing one to another machine reproduces the plugin set, not the exact versions — Claude Code updates plugins on its own cadence, and pinning is the marketplace's job.

Treat modules as portable role definitions, not lockfiles.

Non-portable marketplace sources

When plugin install resolves a marketplace from the global registry or from Claude Code's own cache, it copies the source onto the module as a snapshot. A non-github source — a local path, say — won't mean anything on another machine. Both plugin install and export warn when they see one.

Plugin-level dependencies

Claude Code's marketplace plugin manifests support a dependencies field (auto-installed, semver-constrained; native enable/disable won't break them), but this tool only ever reasons about the literal plugin keys a module names — it has no awareness of a plugin's declared dependencies.

As of this writing no plugin in either official marketplace declares one, so this has no real-world impact today. Worth revisiting if that changes.

completions

terminal
claude-modules completions bash|zsh

Prints a tab-completion script for the given shell to stdout — completions itself writes nothing to disk; you choose how to load the output.

terminal
claude-modules completions bash
claude-modules completions zsh

Completes:

  • top-level commands (list, enable, marketplace, ...)
  • marketplace/plugin/compose subcommands (add, remove, list, install, uninstall)
  • every command's own flags (--from-scope, --output, --save, --verify, --json, ...)
  • --scope/--from-scope values (user, project, local), in both the --scope value and --scope=value forms

Positionals like <module>, <plugin>@<marketplace>, and <name> are not completed — the generated script never reads $CLAUDE_MODULES_HOME or shells out to anything, so it's safe to eval in any shell's startup file.

Bash

Add to ~/.bashrc:

terminal
eval "$(claude-modules completions bash)"

Or write it out once instead of evaluating on every shell start:

terminal
claude-modules completions bash > /etc/bash_completion.d/claude-modules

Zsh

Add to ~/.zshrc, after compinit has already run — the generated function only registers itself when compdef is defined, so an eval placed above compinit silently does nothing rather than erroring at shell startup:

terminal
autoload -Uz compinit && compinit
eval "$(claude-modules completions zsh)"

If completions don't appear, check that ordering first.

Alternatively, skip the eval entirely: save the output as a file named _claude-modules somewhere in your $fpath, and compinit's normal autoloading picks it up on the next new shell.

Global options

FlagEffect
-h, --help Show help. Works after any command: claude-modules plugin install --help
-v, --versionPrint the installed version
--verboseEnable debug logging
--dry-run Preview a mutating command's effect — writes nothing, runs no external commands

--dry-run is supported by all 15 mutating commands, and is a no-op on the five that never write (list, info, status, marketplace list, completions). Running a bare group name — claude-modules plugin, marketplace, or compose — prints that group's usage.

Known limitations

  • No token-cost preview. Modules report plugin counts, not tokens. Claude Code knows the number, but there's no way to aggregate it per module yet. See Context budget.
  • No version pinning. A module records which plugins to enable, not which versions. Treat modules as portable role definitions, not lockfiles.
  • Marketplace sources are snapshots. Resolved sources are copied onto the module; later registry changes don't propagate.
  • Plugin configuration isn't captured. Per-plugin userConfig is deliberately excluded so a committed module can't become a secret-exfiltration path.
  • Composition overrides stop after one level. An explicit false beats a directly composed child, but not a grandchild. See the one-level override limit.

This page is generated from the project's docs/ folder and the README. Found a mismatch? Open an issue.