Composable, layered configuration profiles for Claude Code. Define reusable building blocks in TOML, compose them with extends, and deploy complete configurations to local machines, Docker containers, or remote systems.
Claude Code's config is spread across settings.json, CLAUDE.md, .mcp.json, skills, hooks, commands, agents, and env vars. There's no way to compose, version, or deploy a full configuration as a unit. ccpm fixes that:
- Compose: Build profiles from reusable layers (read-only base, git permissions, python tooling)
- Merge: Deep recursive merge at every level - dicts recurse, lists accumulate, scalars replace
- Deploy: Push complete configs to local, Docker, or SSH targets with backup
# With uv
uv tool install ccpm
# With pipx
pipx install ccpm
# From source
git clone https://github.com/Metta-AI/ccpm.git
cd ccpm
uv syncBoth ccpm and claude-profile are registered as CLI entry points.
# ~/.claude/profiles/base-readonly.toml
[profile]
name = "base-readonly"
description = "Safe read-only defaults"
[settings]
effortLevel = "medium"
[settings.permissions]
allow = ["Read", "Glob", "Grep", "WebFetch"]
[env]
CLAUDE_CODE_USE_BEDROCK = "1"# ~/.claude/profiles/python-dev.toml
[profile]
name = "python-dev"
description = "Python development with testing"
extends = ["base-readonly", "git-permissions"]
[settings]
model = "us.anthropic.claude-opus-4-6-v1[1m]"
effortLevel = "high"
[settings.permissions]
allow = [
"Edit",
"Write",
"Bash(pytest:*)",
"Bash(ruff check:*)",
]The merge result accumulates permissions from both profiles. effortLevel gets replaced by the child's value, while the parent's allow list gets extended (not replaced).
# Build to a staging directory
ccpm build python-dev
# Deploy to your home directory (backs up existing config)
ccpm deploy python-dev local
# Deploy to a project directory
ccpm deploy python-dev local --project ./my-repo
# Deploy into a Docker container
ccpm deploy docker-deploy "docker my-container"
# Deploy via SSH
ccpm deploy python-dev "ssh user@devbox"Compile a profile into resolved config files. Outputs to a temp directory by default, or specify --output-dir.
$ ccpm build python-dev
Resolved chain: base-readonly -> git-permissions -> edit-permissions -> python-dev
+ .claude/settings.json
+ CLAUDE.md
+ .env.claude
4 files written to /tmp/ccpm-build-xxxxx
ccpm deploy <profile> <target> [--project DIR] [--var KEY=VALUE] [--session FILE] [--dry-run] [--no-backup] [-v]
Build and deploy a profile to a target.
| Target | Example | Description |
|---|---|---|
local |
ccpm deploy prof local |
Deploy to ~/.claude/ (or --project dir) |
docker CONTAINER |
ccpm deploy prof "docker my-ctr" |
docker cp into a running container |
ssh USER@HOST |
ccpm deploy prof "ssh user@box" |
rsync to a remote system |
dir PATH |
ccpm deploy prof ./output |
Write to any directory |
The --dry-run flag shows what would change without writing. Existing configs are backed up to .ccpm-backups/ unless --no-backup is passed.
Show all available profiles from the search path.
$ ccpm list
┌──────────────┬──────────────────────────┬────────────────────────────────┐
│ Name │ Description │ Path │
├──────────────┼──────────────────────────┼────────────────────────────────┤
│ base-readonly│ Safe read-only defaults │ ~/.claude/profiles/base-re... │
│ python-dev │ Python development │ ~/.claude/profiles/python-... │
└──────────────┴──────────────────────────┴────────────────────────────────┘
Check syntax, references, and cycle detection without building.
Print the fully resolved merged config as JSON. Use --section to drill into a subtree:
ccpm show python-dev --section settings.permissionsCompare two resolved profiles as a unified diff.
Reverse-engineer an existing Claude Code configuration into a TOML profile. Reads settings.json, CLAUDE.md, .mcp.json, and scans for skills/commands/agents.
# Print to stdout
ccpm init --source-dir ~/.claude
# Write to file
ccpm init --output my-profile.toml --name my-config--profile-path DIR adds extra directories to the profile search path. Can be specified multiple times.
ccpm --profile-path ./team-profiles build team-baseProfiles are TOML files. Every profile must have a [profile] section with at least a name.
[profile]
name = "my-profile"
description = "What this profile does"
extends = ["base-readonly", "git-permissions"] # optional inheritance chainMaps directly to .claude/settings.json. Any key that Claude Code accepts can go here.
[settings]
model = "us.anthropic.claude-opus-4-6-v1[1m]"
effortLevel = "high"
cleanupPeriodDays = 9999999
[settings.permissions]
allow = [
"Read",
"Edit",
"Bash(pytest:*)",
]
deny = [
"Bash(rm -rf*)",
]Hooks use Claude Code's event names as TOML array-of-tables:
[[settings.hooks.PostToolUse]]
matcher = "Edit|Write"
commands = ["./format.sh"]
[[settings.hooks.SessionStart]]
commands = [".claude/hooks/session-start.sh"]This emits the proper Claude Code hooks JSON format in settings.json.
[settings.enabledPlugins]
"pr-workflow@softmax-plugins" = true
[settings.extraKnownMarketplaces.softmax-plugins]
source = { source = "github", repo = "Metta-AI/softmax-plugins" }[claude_md]
strategy = "append" # "append" (default) | "prepend" | "replace"
content = """
## Rules
1. Always run tests
2. Never force push
"""
# Or reference a file:
# file = "${REPO_ROOT}/CLAUDE.md"When profiles chain, each claude_md entry is applied in order using its strategy.
[[mcp_servers]]
name = "github"
command = "gh"
args = ["mcp"]
[[mcp_servers]]
name = "custom-api"
type = "http"
url = "http://localhost:8080/mcp"Servers are merged by name across profiles - if two profiles define a server with the same name, their configs are deep-merged.
[env]
CLAUDE_CODE_USE_BEDROCK = "1"
ANTHROPIC_MODEL = "us.anthropic.claude-opus-4-6-v1"Emitted as .env.claude with export KEY="VALUE" lines.
Pull variables from .env files (useful for gitignored secrets):
[env]
env_file = ".env" # single file
# env_files = [".env.defaults", ".env.local"] # multiple (later overrides earlier)
EXTRA_VAR = "inline-value" # inline vars override file-loaded varsDeclare credentials that are resolved at build time:
[[credentials]]
name = "anthropic-api-key"
env_var = "ANTHROPIC_API_KEY"
description = "Anthropic API key for Claude Code"
source = "op read 'op://Dev/Anthropic/api-key'" # optional: 1Password, AWS SSM, etc.
optional = false # default: requiredResolution order:
--varCLI overrides- OS environment variables
sourcecommand (if provided)- Error if required and not found
Resolved credentials are emitted into the env section (and thus .env.claude).
[shell]
strategy = "append" # "append" | "prepend" | "replace"
content = """
export PATH="$HOME/.local/bin:$PATH"
alias cc='claude'
"""
# Or reference a file:
# file = "./shell-setup.sh"Emitted as .bashrc.d/ccpm.sh. Shell entries from parent profiles chain using the specified strategy.
Deploy a session log so Claude can resume a prior conversation in the target environment. A warning is injected at the end of the session to alert Claude that the environment may have changed.
[session]
log = "../sessions/my-session.jsonl"
# Optional: override where the session is placed (defaults to cwd from the log)
# project_dir = "/workspace/my-project"
# Optional: custom warning message (default warns about environment changes)
# warning = "You are now running in a Docker container. Check your tools."The session JSONL is copied to .claude/projects/<project-hash>/ matching Claude Code's expected layout. A resume helper script is generated at .claude/ccpm-resume.sh.
You can also pass a session log via the CLI without putting it in the profile:
ccpm build python-dev --session ~/.claude/projects/-Users-me/abc123.jsonl
ccpm deploy docker-deploy "docker my-ctr" --session ./saved-session.jsonlAfter deploying, resume in the target environment:
# Using the generated script
.claude/ccpm-resume.sh
# Or directly
claude --resume <session-id>The default warning injected into the session:
WARNING TO CLAUDE: Your instance has been moved and may be in an incompatible environment or have completely new CLAUDE.md's, SKILL's, permissions, and more. The session history above is from a previous environment. Verify your current environment before making assumptions based on prior context.
Copy files/directories into the output:
[[skills]]
source = "${REPO_ROOT}/skills/my-skill" # directory with SKILL.md
[[commands]]
source = "${HOME}/.claude/commands/todo.md"
[[agents]]
source = "${REPO_ROOT}/agents/reviewer.md"
[[hook_scripts]]
source = "${REPO_ROOT}/.claude/hooks/session-start.sh"
target = ".claude/hooks/session-start.sh"Skills, commands, and agents can also be defined inline:
[[commands]]
name = "review.md"
content = """
Review the current PR for correctness and style.
"""The core operation is step-by-step deep recursive merge through the extends chain:
acc = {}
acc = deep_merge(acc, base) # apply base profile
acc = deep_merge(acc, middle) # apply middle on top
acc = deep_merge(acc, leaf) # apply leaf on top
At every level of nesting:
- Dicts merge recursively (keys combine)
- Lists union with deduplication (items accumulate)
- Scalars are replaced by the child's value
- Child profiles only need to specify what they change
To fully wipe a parent's subtree instead of merging into it:
# Wipe parent's permissions entirely, start fresh
[settings."!replace:permissions"]
allow = ["Read"]
# parent's allow and deny lists are gone${VAR} and ${VAR:-default} syntax is expanded at compile time in all string values:
[env]
API_URL = "${MY_HOST:-localhost}:8080"
[[skills]]
source = "${REPO_ROOT}/skills/my-skill"Variables are resolved from:
--varCLI overrides (ccpm build prof --var REPO_ROOT=/code)- OS environment
Unresolved variables without defaults produce a clear error with a hint to set the variable.
Profiles are discovered in order from:
$CLAUDE_PROFILE_PATH(colon-separated directories)~/.claude/profiles/./.claude/profiles/(current directory)
Additional directories can be added with --profile-path.
You can also pass an absolute path to a TOML file directly:
ccpm build /path/to/my-profile.tomlA compiled profile produces:
output/
.claude/
settings.json # merged settings with hooks in Claude Code format
skills/ # copied skill directories
commands/ # copied command files
agents/ # copied agent files
hooks/ # copied hook scripts
projects/ # session logs (if [session] is configured)
<project-hash>/
<session-id>.jsonl # session log with warning injected
ccpm-resume.sh # helper script to resume session
.mcp.json # MCP server configuration
CLAUDE.md # chained claude_md content
.env.claude # environment variables (sourceable)
.bashrc.d/
ccpm.sh # shell configuration
The examples/profiles/ directory contains profiles demonstrating every feature:
| Profile | What it shows |
|---|---|
base-readonly.toml |
Safe read-only defaults |
git-permissions.toml |
Git/GitHub CLI access |
edit-permissions.toml |
File editing permissions |
python-dev.toml |
Multi-level extends, model override |
full-featured.toml |
Every feature: hooks, plugins, MCP, skills, agents |
with-credentials.toml |
Credential declarations with source commands |
with-env-file.toml |
Loading variables from .env files |
with-shell.toml |
Shell config (PATH, aliases, nvm) |
docker-deploy.toml |
Full container deployment profile |
replace-example.toml |
!replace: escape hatch |
json-ref.toml |
External JSON file references |
with-session.toml |
Session log deployment for cross-environment resumption |
env-vars.toml |
${VAR:-default} expansion |
git clone https://github.com/Metta-AI/ccpm.git
cd ccpm
uv sync
uv run pytest -v193 tests covering deep merge, env expansion, chain resolution, compilation, emission, round-trips, CLI, discovery, backup, credentials, shell config, env file loading, and session log deployment.
MIT