Skip to content

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:

  1. Tool-shipped defaults — hardcoded in source.
  2. Repo-level configclaude-profile.yaml at the repo root. Applies to all profiles.
  3. Profile-level configprofiles/<name>/claude-profile.yaml (optional). Overlays the repo-level config for that profile only. Deep-merged.
  4. Per-machine config~/.claude-profile/config.json. Overlays everything above (path, active profile, host).
  5. CLI flags — one-shot overrides for the current invocation (e.g., --profile, --dry-run).
  6. Environment variablesCLAUDE_PROFILE_HOME overrides per-machine config location; CLAUDE_PROFILE_QUIET=1 mirrors --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

npm i -g claude-profile        # or use via `npx claude-profile <cmd>`

4.2 First-time setup, per machine

claude-profile init --git <git_url> [--profile <name>] [--path <dir>] [--activate]
  • Clones <git_url> into <dir> (default ~/.claude-profile/git/) — unless already cloned, in which case fetches.
  • Registers <name> (default default) 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 --activate given → 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 running claude-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 what use can switch to without re-init. Other profiles in the repo are visible via list but not tracked until init'd.
  • host — used in commit messages, conflict file names, and skill output. Auto-detected from os.hostname() on first init; editable via claude-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:

  1. Load home_value = ~/.claude.json[K] (may be absent).
  2. Load repo_value = profiles/<active>/claude.json.partial[K] (may be absent).
  3. Compare against meta.json[__claude_json__][K] timestamp.
  4. Apply the §8.1 decision table at the per-key level.
  5. For object-typed values (e.g., mcpServers), recurse one level deeper: merge object keys independently, so server foo from one machine and server bar from 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

claude-profile init --git <url> [--profile <name>] [--path <dir>] [--activate]
  1. Validate flags. --git is required; --profile defaults to default; --path defaults to ~/.claude-profile/git/.
  2. If ~/.claude-profile/config.json already exists:
  3. If existing git<url> → abort ("config already set for repo X; run claude-profile config set git <url> to repoint").
  4. If existing path<dir> → abort ("config already set for path X; run claude-profile config set path <dir>").
  5. Otherwise → idempotent, continue. The new profile is added to the profiles array.
  6. If <path> exists and is a non-empty non-git directory → abort.
  7. If <path> is an existing valid clone of <url>git fetch.
  8. Else → git clone <url> <path>.
  9. Detect os.hostname() for host (only if not already set).
  10. Append <profile> to profiles in config (no duplicates).
  11. If active is unset → set active = <profile>.
  12. If --activate given and active ≠ <profile> → activate (see §9.5 use semantics).
  13. If profiles/<profile>/ is absent in the checkout → scaffold from current ~/.claude/ (filtered through include/exclude/denylist), commit, push.
  14. Run claude-profile sync once against the active profile.

9.2 sync

claude-profile sync [--profile <n>] [--dry-run] [--quiet] [--background]

--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.

  1. Validate <name> is in config.profiles. If not → suggest claude-profile init --profile <name> --activate.
  2. Run claude-profile sync against the currently-active profile (final flush so nothing is lost in git).
  3. Always back up the current ~/.claude/ to a timestamped per-machine snapshot:
  4. Destination: ~/.claude-profile/backups/<outgoing>/<iso_ts>/ (NOT under the git checkout — these are per-machine, never committed).
  5. Contents: every file matching the outgoing profile's resolved include − exclude − denylist, plus the tracked-keys subset of ~/.claude.json written as claude.json.partial.
  6. Hard-link where possible (same filesystem) to minimize disk use; fall back to copy.
  7. Write a MANIFEST.json listing the source path, sha256, and outgoing profile name, plus the resolved config snapshot at backup time. Used by claude-profile restore.
  8. 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.)
  9. For each file in scope of the incoming profile: copy from profiles/<name>/ into ~/.claude/.
  10. Apply claude.json.partial for incoming (merge into ~/.claude.json as in §8.2).
  11. Update config.active = <name>. Reset .claude-profile/<name>/meta.json baseline (current mtimes become last_sync) so the very next sync is a no-op.
  12. If backups.auto_prune_on_use is true (default) → run claude-profile prune for the outgoing profile, keeping the most recent backups.keep_per_profile snapshots (default 10). Set auto_prune_on_use: false in machine config to skip this.
  13. 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 each MANIFEST.json.
  • restore <profile> [<iso_ts>] — restore a snapshot back into ~/.claude/. Always backs up the current ~/.claude/ first (same as use), 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 (default N=10, configurable via backups.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>

claude-profile adopt <name> [--overwrite] [--switch] [--no-push]

Captures the current ~/.claude/ and writes it to profiles/<name>/ in the repo. Works for both brand-new and existing profiles.

  1. Validate <name> (matches [a-z0-9_-]+, not a reserved word like __preexisting__).
  2. Ensure the checkout exists and is clean outside the target profile's scope. If ~/.claude-profile/config.json is absent → abort: "Run claude-profile init --git <url> first."
  3. Check whether profiles/<name>/ already exists in the repo:
  4. New profile: create the directory; proceed.
  5. Existing profile, no --overwrite: abort with: "Profile <name> already exists in the repo. Pass --overwrite to replace it from the current ~/.claude/, or pick a different name."
  6. Existing profile, --overwrite given: proceed; the existing tree will be rewritten.
  7. Resolve include/exclude/denylist for <name>:
  8. New profile, no per-profile yaml yet → use repo-yaml + defaults.
  9. Existing profile with its own yaml → honor it (so adopt cannot accidentally publish files an explicit exclude rules out).
  10. If <name> matches config.active on this machine, create a per-machine backup of ~/.claude/ first (same mechanism as use). This is not destructive locally — adopting your own active profile is just a forced commit — but the backup makes the publish reversible.
  11. Walk ~/.claude/ against the resolved rules:
  12. Copy each in-scope file into profiles/<name>/.
  13. Compute the claude.json.partial from the tracked keys of ~/.claude.json.
  14. When --overwrite: remove any file in profiles/<name>/ that is no longer in scope (true replacement, not additive merge).
  15. Initialize/refresh .claude-profile/<name>/meta.json with the current mtimes — this becomes the new sync baseline so the very next sync on any machine is a clean no-op rather than a noisy diff.
  16. git add (scoped: profiles/<name>/ + .claude-profile/<name>/); commit with template:
  17. New profile: "adopt {name} from {host} at {iso_ts}"
  18. Existing profile + --overwrite: "re-adopt {name} from {host} at {iso_ts}"
  19. Unless --no-push: git push. Failure is non-fatal (warn, exit 0; next sync retries).
  20. --switch: register <name> in config.profiles if not already there, then run the activation half of use <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 sets config.active). Skip if <name> is already active.
  21. 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>

claude-profile merge <source> --into <dest> [--strategy <s>] [--dry-run] [--no-apply] [--no-push]

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:

  1. Validate both profiles exist in the repo.
  2. 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 broaden include if they actually wanted those files.
  3. Walk both trees and build a merge plan: per file, classify as source-only, dest-only, identical, or conflict. Apply the strategy to conflicts.
  4. If --dry-run: print the plan grouped by classification; exit 0. No writes, no commit.
  5. Apply writes (atomic per-file temp + rename). Update .claude-profile/<dest>/meta.json with new mtimes.
  6. Git add (scoped: profiles/<dest>/ + .claude-profile/<dest>/); commit with template: "merge {source} into {dest} ({strategy}) from {host} at {iso_ts}". If mark-conflicts left any files in .conflicts/, the message also notes the count.
  7. Unless --no-push: git push.
  8. If <dest> is the active profile on this machine and --no-apply was not given (default): run claude-profile sync so the merged content is reflected into ~/.claude/ immediately. Otherwise the next regular sync will pick it up.
  9. Print summary:
    Merged work → personal (strategy: prefer-dest)
      source-only: 12 files copied in
      identical:    7 files (no-op)
      conflict:     3 files (dest kept; pass --strategy prefer-source to flip)
      out-of-scope: 1 file skipped (logs/debug.log — not in personal's include rules)
    

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

claude-profile install-skill [--no-hook]
  1. Writes ~/.claude/skills/profile/SKILL.md (content in §10.2). The skill is invocable as /profile.
  2. Unless --no-hook: merges hook entries into ~/.claude/settings.json (idempotent by id).
  3. 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):

  1. Defensive sweep. Diff ~/.claude/ against profiles/<active>/ in the checkout. If anything in tracked paths changed, snapshot + commit before pulling. This covers the case where the previous session's Stop hook was skipped (crash, hard kill, harness bug). Commit message: "sweep from {host} at {iso_ts}".
  2. git -C <checkout> fetch + git pull --ff-only. On non-ff or network error → exit 0 with a warning (never blocks the session).
  3. If new commits affected profiles/<active>/, materialize the active profile into ~/.claude/ using the standard merge rules (§8).
  4. 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):

  1. Read ~/.claude-profile/last-push.timestamp. If less than autosync.push_debounce_seconds ago → exit 0.
  2. Snapshot ~/.claude/ into profiles/<active>/ per include/exclude rules.
  3. git status on the checkout. If empty → exit 0.
  4. Else git add -A profiles/<active>/ → commit (template from §7) → git push. Update timestamp.
  5. 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: — not in dest's include rules"); continue. Exit 0 if no other issues.
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; --explain reports 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 init on machine A (no profile in repo) — scaffolds + commits + pushes.
  • Fresh init on machine B (profile exists in repo) — pulls profile into ~/.claude/.
  • Multi-profile init: machine A creates personal then work; only personal is active until use 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.
  • use switch reflects new profile into ~/.claude/ and snapshots outgoing files.
  • install-skill writes 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.yaml is 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):

  1. settings.local.json default include — keep in default include; users opt out via per-profile exclude.
  2. Hook trigger model — chose SessionStart (pull) + Stop (push) over PostToolUse. Eliminates the payload-schema unknown and reduces hook fires from O(tool calls) to 2 per session.
  3. Harness verification (Stop reliability + timeout_seconds support) — belt-and-suspenders: spec bakes in defensive design (shell-side timeout 3 … || true wrap, SessionStart defensive sweep before pull) and the implementation plan must include a probe task that confirms Stop fires on every exit path (graceful, /clear, ctrl-c, model error) and whether the JSON timeout_seconds field is honored. After the probe, redundant layers may be simplified.
  4. marketplaces/** size — sync by default; warn (not abort) when a profile's marketplaces dir exceeds limits.marketplaces_warn_mb (default 50 MB). No hard cap in v1.
  5. Auto-prune on use — on by default (backups.auto_prune_on_use: true), configurable off per machine. use runs prune after creating its backup, keeping the most recent backups.keep_per_profile (default 10).
  6. Bootstrapping from existing ~/.claude/ — added claude-profile adopt <name> [--overwrite] [--switch] (§9.9) as the general-purpose primitive for "publish current ~/.claude/ state to a profile, new or existing." init still scaffolds-from-current when the target profile is absent from the repo, as a convenience.
  7. Profile-to-profile merge — added claude-profile merge <source> --into <dest> (§9.10) with four strategies (prefer-dest default, prefer-source, newer, mark-conflicts). Two-way file-granularity merge plus key-level merge for claude.json.partial. Dedicated --into-new flag deferred to v2 (composable from adopt today).
  8. Skill / slash-command name — skill is named profile and 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.)