claude-profile — Design Spec¶
Date: 2026-05-24
Status: Draft (awaiting user review)
Repo: claude-profile (this repo)
Package: claude-profile on npm
1. Goal¶
Keep ~/.claude/ in sync across multiple machines so a Claude Code user has the same skills, agents, hooks, plugins, settings, statusline, and (selectively) MCP server config wherever they work — without losing per-machine state (sessions, caches, credentials, project history) and without manually shuffling files.
One git repo (this one) stores multiple named profiles under profiles/<name>/. Each machine clones the repo once, registers any number of profiles, and at any time has exactly one active profile reflected into ~/.claude/. git pull / git push carry profile contents and tool metadata between machines.
Invariant: ~/.claude/ always reflects exactly the active profile's content (filtered through the resolved include/exclude/denylist). The claude CLI, which reads from ~/.claude/, therefore transparently runs with the active profile's settings — no shim, alias, or env var needed. Switching profiles (claude-profile use <name>) is the only thing that changes what claude sees.
2. Non-goals¶
- Syncing secrets (
~/.claude/.credentials.json). Each machine logs in separately. - Syncing per-machine state (sessions, daemon state, caches, project history).
- Real-time / sub-second sync. The tool runs on edits, on hook fire, or on demand — not as a daemon.
- Conflict-free merge of arbitrary text content. Conflicts are resolved by "newer wins, loser preserved as
.conflicts/<file>.<host>.<ts>" — not three-way text merge. - Plugin code/binary sync. Only the install manifest (
installed_plugins.json) is synced; each machine reinstalls plugin code from the marketplace. - Cross-platform Windows support in v1 (macOS + Linux only).
3. Configurability principle¶
Everything is configurable. Defaults exist for ergonomics, but every behavior — paths, debounce timing, hook matchers, commit message format, denylist, include/exclude rules, even the per-machine config location — can be overridden through:
- Tool-shipped defaults — hardcoded in source.
- Repo-level config —
claude-profile.yamlat the repo root. Applies to all profiles. - Profile-level config —
profiles/<name>/claude-profile.yaml(optional). Overlays the repo-level config for that profile only. Deep-merged. - Per-machine config —
~/.claude-profile/config.json. Overlays everything above (path, active profile, host). - CLI flags — one-shot overrides for the current invocation (e.g.,
--profile,--dry-run). - Environment variables —
CLAUDE_PROFILE_HOMEoverrides per-machine config location;CLAUDE_PROFILE_QUIET=1mirrors--quiet.
Lower numbers are overridden by higher numbers. Any value can be inspected with claude-profile config get <key> (which prints the resolved value and the layer it came from).
4. User-facing surface¶
4.1 Installation¶
4.2 First-time setup, per machine¶
- Clones
<git_url>into<dir>(default~/.claude-profile/git/) — unless already cloned, in which case fetches. - Registers
<name>(defaultdefault) as a known profile on this machine. - If no profile is currently active and
<name>is the first init → sets<name>active. - If
<name>already active or--activategiven → activates<name>(reflects its content into~/.claude/, see §9.5). - If
profiles/<name>/doesn't exist in the repo → scaffolds it from the current~/.claude/(equivalent to runningclaude-profile adopt <name>, see §9.9). - Runs an initial sync of the active profile.
To explicitly capture an existing ~/.claude/ into a profile — new or existing, with or without overwriting the repo version — use claude-profile adopt instead. init only scaffolds when the named profile is absent from the repo; adopt is the general-purpose primitive for "publish current state to a profile."
Calling init again with a different --profile adds another profile to the same machine without changing the active one. Calling init again with the same --profile is a no-op (or refreshes if --git differs and matches).
4.3 Day-to-day commands¶
claude-profile sync [--profile <n>] [--dry-run] [--quiet] [--background]
claude-profile status [--profile <n>]
claude-profile diff <path> [--profile <n>]
claude-profile use <name> # switch active profile (rewrites ~/.claude/, always backs up first)
claude-profile adopt <name> [--overwrite] [--switch] [--no-push]
# snapshot current ~/.claude/ into profiles/<name>/ (new or existing)
claude-profile merge <source> --into <dest> [--strategy prefer-dest|prefer-source|newer|mark-conflicts]
# try to merge profile <source> into <dest>
# [--dry-run] [--no-apply] [--no-push]
claude-profile list # show registered profiles, mark active
claude-profile backups [--profile <n>] # list backup snapshots for a profile
claude-profile restore <profile> [<iso_ts>] # restore a backup snapshot (latest if no ts given)
claude-profile prune [--keep N] # delete old backups, keep most recent N per profile (default 10)
claude-profile config [get|set|unset] [key] [value]
claude-profile install-skill [--no-hook] # writes skill + SessionStart/Stop hooks
claude-profile autosync [internal flags] # invoked by the hook; not user-facing
4.4 Exit codes¶
| Code | Meaning |
|---|---|
| 0 | Success; no changes or all changes applied cleanly |
| 1 | Ran successfully but produced conflicts (saved under .conflicts/) |
| 2 | Error (missing config, dirty checkout, network failure, malformed JSON, etc.) |
5. Repository layout¶
claude-profile/ ← this repo (single, shared)
├─ claude-profile.yaml ← repo-level config (defaults for all profiles)
├─ profiles/
│ ├─ default/
│ │ ├─ claude-profile.yaml ← OPTIONAL profile-level override
│ │ ├─ settings.json
│ │ ├─ settings.local.json
│ │ ├─ CLAUDE.md
│ │ ├─ agents/** skills/** hooks/**
│ │ ├─ statusline-command.sh
│ │ ├─ plugins/
│ │ │ ├─ installed_plugins.json
│ │ │ ├─ known_marketplaces.json
│ │ │ └─ marketplaces/**
│ │ ├─ claude.json.partial ← selective keys from ~/.claude.json
│ │ └─ .conflicts/ ← conflict-loser files (committed for visibility)
│ ├─ work/ ← additional profile, same shape
│ └─ ...
├─ .claude-profile/
│ ├─ default/meta.json ← per-file last-known mtimes for default
│ ├─ work/meta.json
│ └─ ...
├─ docs/superpowers/specs/ ← this spec lives here
├─ .remember/ ← existing memory dir; unchanged
└─ .gitignore ← bans secrets + caches as defense in depth
.claude-profile/<name>/meta.json is committed and shared across machines so the merge baseline travels with the repo.
6. Per-machine config¶
~/.claude-profile/config.json (path overridable via CLAUDE_PROFILE_HOME env var):
{
"git": "git@github.com:vishalp/claude-profile.git",
"path": "/Users/vishalp/.claude-profile/git",
"active": "default",
"profiles": ["default", "work"],
"host": "vishalp-mbp"
}
git— remote URL. One repo per machine.path— local clone location. Defaults to~/.claude-profile/git/.active— which profile is currently mirrored into~/.claude/.profiles— array of profiles this machine has initialized (i.e., maintains meta.json for). Determines whatusecan switch to without re-init. Other profiles in the repo are visible vialistbut not tracked until init'd.host— used in commit messages, conflict file names, and skill output. Auto-detected fromos.hostname()on first init; editable viaclaude-profile config set host <name>.
The config is read+written atomically (temp + rename). Concurrent invocations are safe to the extent that the filesystem rename is atomic.
7. Repo + profile config — claude-profile.yaml¶
A YAML at the repo root defines defaults that apply to all profiles. An optional YAML at profiles/<name>/claude-profile.yaml deep-merges over it for that profile. Tool-shipped hardcoded defaults sit underneath both.
Full schema (everything shown is overridable):
# Patterns are globs, evaluated relative to ~/.claude/.
# `exclude` wins over `include`. `denylist` (below) wins over both.
include:
- settings.json
- settings.local.json
- CLAUDE.md
- agents/**
- skills/**
- hooks/**
- statusline-command.sh
- plugins/installed_plugins.json
- plugins/known_marketplaces.json
- plugins/marketplaces/**
exclude:
- cache/**
- daemon*
- daemon/**
- projects/**
- sessions/**
- file-history/**
- shell-snapshots/**
- session-env/**
- tasks/**
- telemetry/**
- jobs/**
- backups/**
- history.jsonl
- stats-cache.json
- mcp-needs-auth-cache.json
- paste-cache/**
- downloads/**
- .last-cleanup
- plugins/cache/**
- plugins/plugin-catalog-cache.json
- plugins/data/**
# Final safety net: paths matching denylist are dropped even if include matches.
# User can extend but cannot remove built-in entries.
denylist:
- .credentials.json
- "*.credentials.json"
- "*.key"
- "*.pem"
- "*.p12"
- .env
- .env.*
- "*secret*"
- "*token*"
# ~/.claude.json is mixed; only these top-level keys get synced.
# All other keys (projects, oauthAccount, userID, cached*, *SeenCount...) remain per-machine.
claude_json:
path: ~/.claude.json # overridable
partial_filename: claude.json.partial # where in profile dir to mirror tracked keys
include_keys:
- mcpServers
- autoUpdates
- autoUpdatesProtectedForNative
- showExpandedTodos
- showSpinnerTree
# Auto-sync hook + skill behavior. Read at hook-fire time.
# Model: SessionStart pulls; Stop pushes. No per-tool hook (see §10.1).
autosync:
enabled: true
pull_on_session_start: true # SessionStart hook: fetch + ff-only into ~/.claude/
push_on_session_stop: true # Stop hook: commit + push if anything changed
pull_timeout_seconds: 3 # synchronous pull; on timeout, proceed with last-known state
push_debounce_seconds: 10 # skip push if the last successful push was less than this ago
pull_command: "claude-profile sync --pull --quiet"
push_command: "claude-profile sync --push --quiet --background"
hook_id_pull: claude-profile-pull
hook_id_push: claude-profile-push
# Commit/push behavior.
git:
pull_strategy: ff-only # also: rebase, merge (not recommended)
push: true # set false to never push automatically
commit_message_template: "sync from {host} at {iso_ts}"
commit_author_from_git: true # use checkout's git config
# Tool state location inside the checkout.
state:
dir: .claude-profile # i.e., <path>/.claude-profile/<profile>/meta.json
# Per-machine backup snapshots (created on every `use` and `restore`).
backups:
dir: ~/.claude-profile/backups # per-machine, never committed
keep_per_profile: 10 # `prune` enforces this; 0 = unlimited
use_hardlinks: true # hardlink files when on same fs; falls back to copy
auto_prune_on_use: true # `use` runs `prune` after creating its snapshot
# set false to leave pruning manual / cron-driven
# Size guardrails. Warn-only; no hard caps.
limits:
marketplaces_warn_mb: 50 # warn if profiles/<active>/marketplaces/** exceeds this
# Logging.
log:
level: info # debug, info, warn, error
color: auto
Profile-level claude-profile.yaml uses the exact same schema; deep-merge rules:
- Scalars → replace.
- Arrays → replace (NOT concat) — explicit and predictable. To extend an array, copy it and add entries.
- Objects → recursive deep-merge.
8. Merge semantics¶
8.1 Ordinary files¶
For each path F resolved from include − exclude − denylist rules:
| Source | Value |
|---|---|
home_mtime |
stat(~/.claude/F).mtime, or null if absent |
repo_mtime |
stat(<path>/profiles/<active>/F).mtime, or null if absent |
last_sync[F] |
from <path>/.claude-profile/<active>/meta.json, or null if first sync |
Decision table:
| home | repo | last_sync | Action |
|---|---|---|---|
| absent | absent | absent | no-op |
| present | absent | absent | first sync: copy home → repo |
| absent | present | absent | first sync: copy repo → home |
| present | present | absent | first sync: copy newer → other; tie → home → repo |
| = last_sync | = last_sync | present | no-op |
| > last_sync | = last_sync | present | copy home → repo |
| = last_sync | > last_sync | present | copy repo → home |
| > last_sync | > last_sync | present | conflict: keep newer of the two on both sides; write loser to profiles/<active>/.conflicts/<F>.<host>.<ts> |
| absent | = last_sync | present | delete from repo (user deleted on home) |
| = last_sync | absent | present | delete from home (deletion came from another machine) |
| absent | absent | present | clear from meta (deletion is settled) |
| absent | > last_sync | present | repo wins (delete vs change) — copy repo → home; log warning |
| > last_sync | absent | present | home wins (change vs delete) — copy home → repo; log warning |
After all writes succeed, meta.json is updated with the new mtimes for every touched path.
8.2 ~/.claude.json (key-level merge)¶
~/.claude.json is treated specially because it mixes syncable and per-machine state.
For each key K in claude_json.include_keys:
- Load
home_value = ~/.claude.json[K](may be absent). - Load
repo_value = profiles/<active>/claude.json.partial[K](may be absent). - Compare against
meta.json[__claude_json__][K]timestamp. - Apply the §8.1 decision table at the per-key level.
- For object-typed values (e.g.,
mcpServers), recurse one level deeper: merge object keys independently, so serverfoofrom one machine and serverbarfrom another both end up in the final value. Conflicts on the same nested key resolve by timestamp.
After merging, write the final value back to:
- ~/.claude.json in place (other top-level keys preserved verbatim).
- profiles/<active>/claude.json.partial (only the tracked keys).
Untracked keys in ~/.claude.json are read-only from the tool's perspective and never written to the repo.
8.3 Why meta.json matters¶
git clone and git pull reset filesystem mtimes to the operation time. A naïve "newer wins" comparison without a baseline would always pick whichever side has fresher mtimes — usually the just-pulled side — even when that side hadn't actually changed.
meta.json records the mtime of each file at the moment of the last successful sync. A file is "changed since last sync" iff current_mtime > last_sync. This makes the merge resilient to mtime resets.
9. Commands — detailed behavior¶
9.1 init¶
- Validate flags.
--gitis required;--profiledefaults todefault;--pathdefaults to~/.claude-profile/git/. - If
~/.claude-profile/config.jsonalready exists: - If existing
git≠<url>→ abort ("config already set for repo X; runclaude-profile config set git <url>to repoint"). - If existing
path≠<dir>→ abort ("config already set for path X; runclaude-profile config set path <dir>"). - Otherwise → idempotent, continue. The new profile is added to the
profilesarray. - If
<path>exists and is a non-empty non-git directory → abort. - If
<path>is an existing valid clone of<url>→git fetch. - Else →
git clone <url> <path>. - Detect
os.hostname()forhost(only if not already set). - Append
<profile>toprofilesin config (no duplicates). - If
activeis unset → setactive = <profile>. - If
--activategiven andactive ≠ <profile>→ activate (see §9.5 use semantics). - If
profiles/<profile>/is absent in the checkout → scaffold from current~/.claude/(filtered through include/exclude/denylist), commit, push. - Run
claude-profile synconce against the active profile.
9.2 sync¶
--profile <n> overrides which profile to sync. By default syncs the active profile.
Behavior when target is the active profile:
1. Load resolved config (machine → profile YAML → repo YAML → defaults).
2. cd <path> and:
a. git fetch.
b. If checkout has uncommitted changes that don't belong to this profile's scope → abort with diff. (Changes inside profiles/<active>/ and .claude-profile/<active>/ are expected mid-sync.)
c. git pull per git.pull_strategy. Non-FF on ff-only → abort, exit 2.
3. Walk profiles/<active>/ and ~/.claude/ against the resolved include/exclude/denylist.
4. For each path, apply §8.1 decision table.
5. For ~/.claude.json, apply §8.2.
6. If --dry-run: print plan, exit 0.
7. Apply writes atomically (per-file temp + rename).
8. Update .claude-profile/<active>/meta.json.
9. git add <profile_dir> <state_dir>/<active> (scoped — never add -A) and git commit -m <template> (skip if nothing staged).
10. If git.push is true → git push. On failure: warn, exit 0 (sync still applied locally; next sync retries push).
11. Touch ~/.claude-profile/last-sync.timestamp.
12. Exit 1 if conflicts recorded; else 0.
Behavior when target is not the active profile:
- Repo-side only. Steps 1, 2, then a "scan repo for changes vs meta" (no ~/.claude/ interaction), then commit/push if anything moved. Useful for keeping a non-active profile fresh after pulling from another machine.
--background re-execs detached. --quiet suppresses non-error output.
9.3 status¶
Runs through §9.2 step 5 without writes. Output:
no-op settings.json
home→repo skills/my-skill/SKILL.md
repo→home agents/researcher.md
conflict hooks/post-edit.sh (last_sync 2026-05-23T10:14:00, home 2026-05-24T08:02:11, repo 2026-05-24T09:15:33)
delete-repo agents/old-agent.md
9.4 diff <path>¶
- Ordinary files: unified
diff -u ~/.claude/<path> <path>/profiles/<active>/<path>. claude.json(the literal string): per-key JSON diff for tracked keys only.
9.5 use <name>¶
Switches active profile and reflects the new profile's content into ~/.claude/. Always backs up the outgoing state first so switching is fully reversible.
- Validate
<name>is inconfig.profiles. If not → suggestclaude-profile init --profile <name> --activate. - Run
claude-profile syncagainst the currently-active profile (final flush so nothing is lost in git). - Always back up the current
~/.claude/to a timestamped per-machine snapshot: - Destination:
~/.claude-profile/backups/<outgoing>/<iso_ts>/(NOT under the git checkout — these are per-machine, never committed). - Contents: every file matching the outgoing profile's resolved include − exclude − denylist, plus the tracked-keys subset of
~/.claude.jsonwritten asclaude.json.partial. - Hard-link where possible (same filesystem) to minimize disk use; fall back to copy.
- Write a
MANIFEST.jsonlisting the source path, sha256, and outgoing profile name, plus the resolved config snapshot at backup time. Used byclaude-profile restore. - Remove from
~/.claude/every file that is in scope of the outgoing profile but NOT in scope of the incoming profile. (Files in both scopes are overwritten in step 5.) - For each file in scope of the incoming profile: copy from
profiles/<name>/into~/.claude/. - Apply
claude.json.partialfor incoming (merge into~/.claude.jsonas in §8.2). - Update
config.active = <name>. Reset.claude-profile/<name>/meta.jsonbaseline (current mtimes become last_sync) so the very nextsyncis a no-op. - If
backups.auto_prune_on_useis true (default) → runclaude-profile prunefor the outgoing profile, keeping the most recentbackups.keep_per_profilesnapshots (default 10). Setauto_prune_on_use: falsein machine config to skip this. - Print summary: "Switched to profile
. Backup saved at ~/.claude-profile/backups/ / /." (Plus a pruned-N line if anything was deleted.)
A claude-profile restore <outgoing> [<iso_ts>] command (or claude-profile use <outgoing> if registered) recovers by switching back; the backup remains for forensic / "I want this single file back" use.
The very first init --activate on a machine also runs step 3 against whatever already exists in ~/.claude/, under a synthetic __preexisting__ profile name, so pre-claude-profile state is never silently overwritten.
use is intentionally not chained with sync — after use, the next regular sync handles propagation.
9.6 backups, restore, prune¶
backups [--profile <n>]— list snapshots under~/.claude-profile/backups/<n>/newest-first, with size and a one-line summary from eachMANIFEST.json.restore <profile> [<iso_ts>]— restore a snapshot back into~/.claude/. Always backs up the current~/.claude/first (same asuse), so restore itself is reversible. If<iso_ts>is omitted, restores the most recent snapshot for<profile>.prune [--keep N]— delete old backups, keeping the most recent N per profile (defaultN=10, configurable viabackups.keep_per_profile).
9.7 list¶
$ claude-profile list
default (active, last sync 2 minutes ago)
work (registered, last sync 1 hour ago)
experiment (in repo, not registered on this machine — run `init --profile experiment` to add)
9.8 config¶
claude-profile config get [key]— prints one key or whole resolved config. With--explain, also prints the source layer.claude-profile config set <key> <value>— writes to~/.claude-profile/config.json.claude-profile config unset <key>— removes a per-machine override (falls back to YAML/defaults).
Keys use dot notation: autosync.debounce_seconds, git.push, etc. Setting any key here overrides repo/profile YAML.
9.9 adopt <name>¶
Captures the current ~/.claude/ and writes it to profiles/<name>/ in the repo. Works for both brand-new and existing profiles.
- Validate
<name>(matches[a-z0-9_-]+, not a reserved word like__preexisting__). - Ensure the checkout exists and is clean outside the target profile's scope. If
~/.claude-profile/config.jsonis absent → abort: "Runclaude-profile init --git <url>first." - Check whether
profiles/<name>/already exists in the repo: - New profile: create the directory; proceed.
- Existing profile, no
--overwrite: abort with: "Profile<name>already exists in the repo. Pass--overwriteto replace it from the current ~/.claude/, or pick a different name." - Existing profile,
--overwritegiven: proceed; the existing tree will be rewritten. - Resolve include/exclude/denylist for
<name>: - New profile, no per-profile yaml yet → use repo-yaml + defaults.
- Existing profile with its own yaml → honor it (so adopt cannot accidentally publish files an explicit
excluderules out). - If
<name>matchesconfig.activeon this machine, create a per-machine backup of~/.claude/first (same mechanism asuse). This is not destructive locally — adopting your own active profile is just a forced commit — but the backup makes the publish reversible. - Walk
~/.claude/against the resolved rules: - Copy each in-scope file into
profiles/<name>/. - Compute the
claude.json.partialfrom the tracked keys of~/.claude.json. - When
--overwrite: remove any file inprofiles/<name>/that is no longer in scope (true replacement, not additive merge). - Initialize/refresh
.claude-profile/<name>/meta.jsonwith the current mtimes — this becomes the new sync baseline so the very nextsyncon any machine is a clean no-op rather than a noisy diff. git add(scoped:profiles/<name>/+.claude-profile/<name>/); commit with template:- New profile:
"adopt {name} from {host} at {iso_ts}" - Existing profile +
--overwrite:"re-adopt {name} from {host} at {iso_ts}" - Unless
--no-push:git push. Failure is non-fatal (warn, exit 0; next sync retries). --switch: register<name>inconfig.profilesif not already there, then run the activation half ofuse <name>(rewrites~/.claude/from the profile we just wrote — for a fresh adopt this is a no-op, but it normalizes the meta baseline and setsconfig.active). Skip if<name>is already active.- Print summary:
"Adopted ~/.claude/ → profiles/<name>/ ({N} files, {bytes}). [active | registered | unregistered]."
Common flows:
# Bootstrap a brand-new machine whose ~/.claude/ already has years of state:
claude-profile init --git <url> # clone repo only; do not activate any profile
claude-profile adopt my-laptop --switch # seed a new profile from current state and use it
# Fork the current active profile into a new one (without changing what's active):
claude-profile adopt experiment
# Force-republish the active profile from current ~/.claude/, replacing whatever's in the repo:
claude-profile adopt default --overwrite
adopt is intentionally separate from sync: sync is merge-aware (uses meta.json to decide direction per file), adopt is replace-only (whatever is in ~/.claude/ becomes the profile). Use sync for day-to-day; use adopt when you want the home-side state to win unconditionally.
9.10 merge <source> --into <dest>¶
Best-effort merge of one profile's content into another. <source> is read-only throughout — only <dest> is written. Both profiles must exist in the repo; <source> need not be registered on this machine.
Strategies (per-file resolution when the same path exists in both with different content):
| Strategy | Behavior on file conflict |
|---|---|
prefer-dest (default) |
Keep <dest>'s version. <source>-only files are copied in. Safest. |
prefer-source |
Replace <dest>'s file with <source>'s. Useful for "import everything from work into personal." |
newer |
Use whichever file has the newer mtime per its profile's meta.json. Mirrors §8.1 sync semantics. |
mark-conflicts |
Keep both: write <source>'s copy to profiles/<dest>/.conflicts/<path>.<source>.<ts>, leave <dest>'s original in place. User resolves manually. |
Strategy applies to ordinary files. For claude.json.partial, a per-key merge runs regardless of strategy (key-level, like §8.2): keys only in source are added, keys only in dest are kept, and keys in both follow the strategy (with newer falling back to prefer-dest since per-key mtimes aren't tracked).
Behavior:
- Validate both profiles exist in the repo.
- Resolve include/exclude/denylist for
<dest>. Files in<source>that fall outside<dest>'s scope are not merged in — the destination's scope rules win. Print a notice for each skipped path so the user can broadenincludeif they actually wanted those files. - Walk both trees and build a merge plan: per file, classify as
source-only,dest-only,identical, orconflict. Apply the strategy toconflicts. - If
--dry-run: print the plan grouped by classification; exit 0. No writes, no commit. - Apply writes (atomic per-file temp + rename). Update
.claude-profile/<dest>/meta.jsonwith new mtimes. - Git add (scoped:
profiles/<dest>/+.claude-profile/<dest>/); commit with template:"merge {source} into {dest} ({strategy}) from {host} at {iso_ts}". Ifmark-conflictsleft any files in.conflicts/, the message also notes the count. - Unless
--no-push:git push. - If
<dest>is the active profile on this machine and--no-applywas not given (default): runclaude-profile syncso the merged content is reflected into~/.claude/immediately. Otherwise the next regular sync will pick it up. - Print summary:
Common flows:
# Pull useful work skills into personal, keeping personal's settings on conflict:
claude-profile merge work --into personal
# Aggressive: import everything from work, overwriting personal on conflicts:
claude-profile merge work --into personal --strategy prefer-source
# See what would happen first:
claude-profile merge work --into personal --dry-run
# Merge into a NEW profile (not touching either existing one):
# 1. adopt the base to create the new profile from current ~/.claude/, OR
# 2. use git directly to copy profiles/<base>/ to profiles/<new>/, commit, then merge
# (No dedicated --into-new flag in v1 — compose from existing primitives.)
Not handled in v1:
- Three-way merge with a common ancestor. We don't track profile lineage, so the merge is two-way only. Use mark-conflicts if you need the loser preserved.
- Recursive line-level merging of text files. Files are merged at file granularity, not hunks. claude.json.partial is the only key-level merge.
- A dedicated "merge into a new profile" flag. Composable via existing commands (see above) — revisit in v2 if used often.
- Merging into a non-active profile and immediately switching to it. Run merge then use <dest> explicitly — keeps the operations independently reviewable.
9.11 install-skill¶
- Writes
~/.claude/skills/profile/SKILL.md(content in §10.2). The skill is invocable as/profile. - Unless
--no-hook: merges hook entries into~/.claude/settings.json(idempotent byid). - Both writes are in active-profile scope, so the next sync propagates skill+hook to every other machine.
10. Auto-sync — skill + hook¶
10.1 Hooks (session-bracket trigger)¶
Two hooks, one at each session boundary. No per-tool firing — we let the filesystem be the source of truth and diff at the end.
Merged into ~/.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"id": "claude-profile-pull",
"hooks": [
{ "type": "command",
"command": "timeout 3 claude-profile sync --pull --quiet || true",
"timeout_seconds": 3 }
]
}
],
"Stop": [
{
"id": "claude-profile-push",
"hooks": [
{ "type": "command",
"command": "claude-profile sync --push --quiet --background" }
]
}
]
}
}
hook_id_pull, hook_id_push, pull_command, push_command, pull_timeout_seconds, and push_debounce_seconds come from autosync: config and can be overridden per machine. Setting pull_on_session_start: false or push_on_session_stop: false disables the corresponding hook at install time.
claude-profile sync --pull (SessionStart):
- Defensive sweep. Diff
~/.claude/againstprofiles/<active>/in the checkout. If anything in tracked paths changed, snapshot + commit before pulling. This covers the case where the previous session'sStophook was skipped (crash, hard kill, harness bug). Commit message:"sweep from {host} at {iso_ts}". git -C <checkout> fetch+git pull --ff-only. On non-ff or network error → exit 0 with a warning (never blocks the session).- If new commits affected
profiles/<active>/, materialize the active profile into~/.claude/using the standard merge rules (§8). - Exit silently on no-op.
Hook timeout is enforced via the JSON timeout_seconds: 3 field; for safety against harnesses that ignore that field, the installed command is also wrapped: timeout 3 claude-profile sync --pull --quiet || true. Either layer alone is sufficient; both together are the belt-and-suspenders posture (the implementation-plan probe will determine which can be dropped later).
claude-profile sync --push (Stop):
- Read
~/.claude-profile/last-push.timestamp. If less thanautosync.push_debounce_secondsago → exit 0. - Snapshot
~/.claude/intoprofiles/<active>/per include/exclude rules. git statuson the checkout. If empty → exit 0.- Else
git add -A profiles/<active>/→ commit (template from §7) →git push. Update timestamp. - Failures are non-fatal — the next Stop hook (or manual
sync) retries.
Neither hook reads a payload. Tool-name and path filtering are unnecessary because the push step diffs the filesystem against the repo to decide what (if anything) to commit.
Why session-bracket, not per-tool: PostToolUse fires on every Read/Bash/Edit including thousands of unrelated paths; 99% of invocations would be no-ops. SessionStart + Stop fires at most twice per session, catches every change made during the session in a single commit, and removes the hook-payload-schema unknown entirely. Trade-off: changes made outside a Claude session (manual vim ~/.claude/settings.json) only sync on the next session boundary. Acceptable — and the skill nudges Claude to run sync manually when the user mentions editing config by hand.
10.2 Skill¶
~/.claude/skills/profile/SKILL.md (invocable as /profile):
---
name: profile
description: Use when the user mentions syncing/pushing/pulling Claude Code config, switching profiles, adopting current ~/.claude/ as a profile, merging profiles, inspecting profile drift, or listing profiles. Also fires when the user is about to edit files under ~/.claude/. Documents the auto-sync behavior so Claude does not double-trigger syncs.
---
# /profile — claude-profile CLI
`claude-profile` (npm package) syncs ~/.claude/ across machines via a shared git repo
that holds multiple named profiles. Each machine has one active profile reflected
into ~/.claude/ and can switch between any profile it has registered.
## Auto-sync is already wired up
Two hooks bracket every Claude session:
- **SessionStart** runs `claude-profile sync --pull` (synchronous, 3 s timeout) — fast-forwards from the remote and materializes any new commits into ~/.claude/ before the session starts.
- **Stop** runs `claude-profile sync --push` (background) — diffs ~/.claude/ against the repo and, if anything in the tracked paths changed, commits and pushes.
You do not need to manually invoke sync after editing skills, agents, hooks, settings, etc. during a session — the Stop hook will pick it up. The only time to run `sync` by hand is when the user edited config *outside* a Claude session, or explicitly asks for an immediate push/pull.
## When to run sync manually
- The user explicitly asked ("sync my profile", "push my claude config").
- The user is about to switch machines and wants a clean push before logging off.
- After resolving a conflict file under `profiles/<active>/.conflicts/`.
## Commands
| Command | Purpose |
|---|---|
| `claude-profile sync` | Full cycle: pull, merge, commit, push (active profile). |
| `claude-profile status` | Show what sync would do; no writes. |
| `claude-profile diff <path>` | Unified diff between home and repo for one path. |
| `claude-profile use <name>` | Switch active profile (rewrites ~/.claude/ for the new one). |
| `claude-profile adopt <name> [--overwrite] [--switch]` | Snapshot current ~/.claude/ into `profiles/<name>/` (new or existing). |
| `claude-profile merge <source> --into <dest> [--strategy …]` | Best-effort merge of one profile into another; default keeps dest on conflict. |
| `claude-profile list` | Show all profiles in the repo, mark active and registered. |
| `claude-profile init --git <url> [--profile <name>]` | First-time setup, or add a new profile on this machine. |
| `claude-profile config get/set/unset` | Per-machine config (~/.claude-profile/config.json). |
## Conflicts
If both machines edited the same file between syncs, the newer is kept and the
loser is written to `profiles/<active>/.conflicts/<path>.<host>.<ts>`. To resolve:
inspect both, merge by hand, write back to the canonical location, and `sync`.
## Scope
Synced: settings.json, agents/, skills/, hooks/, statusline, CLAUDE.md, plugin
install list, marketplaces metadata, and selective ~/.claude.json keys (see
claude-profile.yaml for the exact list).
Not synced: credentials, caches, sessions, daemon state, project history,
oauthAccount, plugin code (re-installed via marketplace on each machine).
11. Error handling¶
| Failure | Tool response |
|---|---|
~/.claude-profile/config.json missing |
"Run claude-profile init --git <url> first." Exit 2. |
| Checkout path missing | Re-clone from configured git. |
| Checkout has unrelated uncommitted changes | Abort with git status. Exit 2. |
git pull non-fast-forward (ff-only mode) |
Abort. Print git log <local>..<remote>. Exit 2. Never auto-rewrite history. |
| Network failure during fetch/pull/push | Local file merge still runs. Push failure → warning, exit 0. Next sync retries. |
~/.claude.json malformed JSON |
Skip JSON merge; proceed with file merge; clear error. Exit 1. |
| File conflict | Keep newer, save loser to .conflicts/. Exit 1. |
| Partial write failure | Atomic per-file writes (temp + rename). meta.json only updated after all writes succeed. Re-runnable. |
Profile missing in config.profiles for use |
Suggest init --profile <name> --activate. |
use switching with unsynced changes in ~/.claude/ |
Step 2 of §9.5 forces a sync first; if that sync fails, abort the use. |
Secret path slips through include |
Denylist drops it with a warning; .gitignore is a second layer. |
| Stop hook fires but nothing in tracked paths changed | sync --push exits 0 after git status shows clean. Zero overhead. |
| SessionStart hook can't reach remote (offline) | sync --pull warns and exits 0; session proceeds against last-known local state. |
SessionStart pull times out (> pull_timeout_seconds) |
Hook is killed; session proceeds. Next pull happens on the following SessionStart. |
profiles/<active>/marketplaces/** exceeds limits.marketplaces_warn_mb (default 50) |
Warn on sync / adopt. Do not abort. Print path + size + suggestion to exclude or clean. |
adopt <name> when profile already exists, no --overwrite |
Abort with message pointing at --overwrite flag. Exit 2. |
merge <source> --into <dest> when <source> or <dest> doesn't exist in the repo |
Abort with the missing profile name. Exit 2. |
merge produces conflicts under mark-conflicts strategy |
Exit 1 after committing; print path list and "resolve and commit, then run sync". |
merge source has files out of dest's scope |
Warn per-file ("skipped: |
Concurrent sync invocations |
Per-machine file lock at ~/.claude-profile/sync.lock (flock). Second invocation aborts cleanly with "another sync in progress". |
12. Testing¶
12.1 Unit tests (vitest)¶
- Merge decision function: table-driven over every cell of §8.1.
- JSON key-level merge: tracked-keys filtering; nested object merge; per-key conflict resolution.
- Config resolution: defaults < repo YAML < profile YAML < machine config < flags. Each layer can override;
--explainreports source correctly. - Glob resolution: include − exclude − denylist with various patterns; denylist always wins.
- Path safety:
.., absolute paths, symlinks pointing outside~/.claude/are rejected. - Config load/save: round-trip; missing keys; corrupted JSON.
12.2 Integration tests (vitest + tmp dirs)¶
Each test creates a temp bare git repo (the "remote"), one or more temp fake ~/.claude/ instances, and one or more temp checkouts; runs the real claude-profile CLI against them. No fs or child_process mocking — real filesystem in tmp dirs, real git, injectable time source for deterministic mtimes.
Scenarios:
- Fresh
initon machine A (no profile in repo) — scaffolds + commits + pushes. - Fresh
initon machine B (profile exists in repo) — pulls profile into~/.claude/. - Multi-profile
init: machine A createspersonalthenwork; onlypersonalis active untiluse work. - Sync with no changes — exit 0, no commit.
- Home-only change → repo updates.
- Repo-only change (simulated by another machine pushing) → home updates.
- Both sides changed → conflict file appears, newer kept.
- Deletion propagation.
- Malformed
~/.claude.json. - Missing per-machine config.
- Network failure (bad remote URL) — local sync succeeds, push warns.
useswitch reflects new profile into~/.claude/and snapshots outgoing files.install-skillwrites skill + hook idempotently.- Denylist blocks a misconfigured include.
- Concurrent sync invocations: second exits cleanly via lock.
- Profile-level YAML overrides repo-level: a file excluded only in
profiles/work/claude-profile.yamlis dropped when work is active, present when default is active.
12.3 CI¶
GitHub Actions matrix:
- Node 20, Node 22.
- macOS-latest, ubuntu-latest.
- vitest --run, tsc --noEmit, eslint ..
13. Out of scope (v1)¶
- Encryption at rest in the repo. Use a private git repo for sensitive content.
- Three-way text merge of file content.
- Watch-mode / inotify-based real-time sync.
- Windows.
- Plugin code/binary sync (just the install manifest).
- A web UI or TUI for conflict resolution.
14. Open questions for review¶
Resolved during review (2026-05-24):
settings.local.jsondefault include — keep in default include; users opt out via per-profileexclude.- Hook trigger model — chose
SessionStart(pull) +Stop(push) overPostToolUse. Eliminates the payload-schema unknown and reduces hook fires from O(tool calls) to 2 per session. - Harness verification (Stop reliability +
timeout_secondssupport) — belt-and-suspenders: spec bakes in defensive design (shell-sidetimeout 3 … || truewrap,SessionStartdefensive sweep before pull) and the implementation plan must include a probe task that confirmsStopfires on every exit path (graceful,/clear, ctrl-c, model error) and whether the JSONtimeout_secondsfield is honored. After the probe, redundant layers may be simplified. marketplaces/**size — sync by default; warn (not abort) when a profile's marketplaces dir exceedslimits.marketplaces_warn_mb(default 50 MB). No hard cap in v1.- Auto-prune on
use— on by default (backups.auto_prune_on_use: true), configurable off per machine.userunspruneafter creating its backup, keeping the most recentbackups.keep_per_profile(default 10). - Bootstrapping from existing
~/.claude/— addedclaude-profile adopt <name> [--overwrite] [--switch](§9.9) as the general-purpose primitive for "publish current~/.claude/state to a profile, new or existing."initstill scaffolds-from-current when the target profile is absent from the repo, as a convenience. - Profile-to-profile merge — added
claude-profile merge <source> --into <dest>(§9.10) with four strategies (prefer-destdefault,prefer-source,newer,mark-conflicts). Two-way file-granularity merge plus key-level merge forclaude.json.partial. Dedicated--into-newflag deferred to v2 (composable fromadopttoday). - Skill / slash-command name — skill is named
profileand invoked as/profile. Lives at~/.claude/skills/profile/SKILL.md.
Still open: (none — all design questions resolved. Probe results may produce simplification follow-ups during implementation.)