claude-keep
v0.1.1
Published
Keep your Claude Code home (~/.claude) in a private git repo, and replay MCP servers, plugins and setup steps on a new machine.
Maintainers
Readme
claude-keep
Keeps your Claude Code home directory (~/.claude) in a git repo you own, and
reapplies the machine state (MCP servers, plugins, setup steps) when you set up
a new machine. It versions what you author: skills/, agents/, commands/,
hooks, settings.json and a memory/ tree. It leaves out transcripts,
sessions, caches, credentials and downloaded plugin sources: those are private,
huge, or reinstallable. Two repos are involved: this public tool, and the
private config repo it manages. Zero dependencies, Node 20+.
Requirements
git on PATH (on Windows, Git for Windows, which brings the sh the
bundled pre-commit hook needs), Node 20 or newer, and the Claude Code
CLI (claude), which apply uses to register MCP servers and install
plugins.
gitleaks is optional but recommended. The pre-commit hook init installs
runs gitleaks protect --staged --redact; without gitleaks it prints a notice
and exits 0, so no secret scan runs.
Install
Run it without installing. On a machine that has never seen the package, add
-y so npx does not stop to ask. Or install globally, which gives you
claude-keep and the short alias ckeep:
npx claude-keep <verb>
npx -y claude-keep doctor
npm install --global claude-keepQuick start: a new config repo
npx claude-keep init # ~/.claude becomes a repo
$EDITOR ~/.claude/claude-keep.json # describe MCP servers, tools, setup steps
cd ~/.claude
git remote add origin [email protected]:you/claude-config.git
npx claude-keep push # first commit, then pushMake that remote private: everything in it is committed in the clear.
New machine
Install git and Node 20+, then clone your config repo into place. Either clone
to ~/.claude, or clone anywhere and point CLAUDE_CONFIG_DIR at it.
git clone [email protected]:you/claude-config.git ~/.claude
npx -y claude-keep doctor # what is this machine missing?
npx claude-keep apply # replay MCP servers, plugins, setup steps
claude # start Claude Code and log inRun apply from a terminal, not from inside a Claude Code session: its
steps want the terminal, and a session that rewrites the config it is running
from is worse still. It refuses to run when CLAUDECODE is set.
Then, in each project that should keep its own memory:
npx claude-keep link <slug>Daily use and the machine switch
Back up whenever you want, from anywhere:
npx claude-keep pushTo hand work to another machine, use the bundled handoff skill from inside a
Claude session by typing /handoff. It writes a short note to a temporary file
(where you stopped, the next step, blockers, and the branch and commit you were
on), then runs npx claude-keep push --wip --note <that file>. That commits and
pushes the dirty project tree, files the note under the project's memory slug,
and pushes the config repo.
On the machine you arrive at, run npx claude-keep pull. It tells you whether
what it brought in needs apply.
Verbs
Only link takes a positional argument. Any unknown flag is an error. --help
prints the top-level usage.
init
claude-keep initTurns the config dir into a claude-keep repo. Safe to run again.
- Creates the directory and the repo when missing, with
mainas the initial branch. A directory nested inside someone else's work tree gets a repo of its own rather than writing into the parent. - Merges
.gitignorerule by line: existing rules stay, missing ones are appended under a# added by claude-keepheader. Appended rules land after everything already in the file, including a!negation, so read the merged result before committing it. - Installs the pre-commit hook and pins
core.hooksPathto the repo's own hooks directory, so a globalcore.hooksPathcannot shadow it. It never overwrites a hook it did not write: a foreign pre-commit, or acore.hooksPathalready pointing elsewhere, is reported asskippedwith the reason. - Writes
.gitattributes(* text=auto eol=lf) only on a repo it just created. Renormalizing line endings on a repo that already has history is not safe, so an existing one is left alone. - Creates
claude-keep.jsonandmemory/when absent.
It prints one line per item (repo, .gitattributes, .gitignore, manifest,
memory/, hook) with that item's status and, where it skipped, the reason.
doctor
claude-keep doctorIt changes nothing, and a missing tool or a warning still exits 0, so it is safe
in a script. It exits 1 when it cannot do its job at all: an unparsable
claude-keep.json, or a missing git.
It checks git, node, claude and gitleaks (optional, printed in lower
case so its absence does not read as a real gap), then runs every
deps[].probe line from your manifest through a shell, filtered by the dep's
os and capped at 10 seconds each. Exit 0 means present; a failing probe prints
that dep's hint. Warnings it can add:
- no
claude-keep.json, or the config dir is not a git repo - the pre-commit hook is not active
- a key in
settings.jsonenvwhose name looks like a credential (KEY,TOKEN,SECRET,PASSWORD,AUTH,CREDENTIAL, …) - the current project has no memory link, or its settings file is not valid JSON
CLAUDECODEis set, soapplywould refuse
pull
claude-keep pullRuns git pull --rebase --autostash in the config dir. It fails if the config
dir is not a git repo, or has no remote.
- Lists the changed files, the first 20 by name and the rest as a count.
- When
claude-keep.jsonitself changed, prints every added and removed line carrying arun,probe,command,args,cwdorurlkey. That list is uncapped on purpose: a padded manifest must not be able to hide its last entry behind a cap. Read those lines before runningapply. - Says
run: claude-keep apply (...)when a change touchedsettings.json,claude-keep.json,skills/handoff, or a path a step names incwdorwhen.missing. - If the pull fails, including a rebase conflict, it fails with the last 20
lines of git's output plus the way out:
git -C <config dir> rebase --abort.
apply
claude-keep applyReplays machine state. Refuses to run inside a Claude Code session. It works in
a fixed order, and each item it adds or runs prints a + ... line first. Items
already present, or skipped, appear only in the summary at the end:
- marketplaces from
settings.jsonextraKnownMarketplaces - plugins from
settings.jsonenabledPlugins, the ones set totrue - MCP servers from the manifest, via
claude mcp add-json --scope user - steps from the manifest
- the bundled
handoffskill
apply only adds. It never removes a marketplace, plugin or MCP server, so
anything you registered by hand survives. A plugin whose marketplace was skipped
is skipped too, rather than failing later and taking the rest of the run with
it. A failing step stops the run: the steps after it may depend on it.
The bundled skill is installed once, then left alone. A copy that differs is
treated as your edit, not as a stale version, and is reported as kept. Delete
the directory to get the shipped version back.
link
claude-keep link <slug> [--force]Gives the current project its own memory directory. The slug must match
^[a-z0-9-]{1,40}$.
It creates <config dir>/memory/<slug> and writes autoMemoryDirectory into
.claude/settings.local.json at the git toplevel, not in whatever
subdirectory you are standing in, which is where doctor and push read it
back. When the config dir is ~/.claude, the value is written as
~/.claude/memory/<slug>; otherwise it is the full path.
If the file already links a different directory, link asks before replacing
it. Answer n and the old link stands, though the new memory directory is still
prepared. With no terminal to ask on, it does not guess: it fails before writing
or creating anything and tells you to rerun with --force. Run outside a git
repo, it uses the current directory and says so.
push
claude-keep push [--wip] [--note <file>]Commits and pushes the config repo. Safe inside a Claude session.
The memory slug comes from .claude/settings.local.json, then
.claude/settings.json, at the project root, the same setting link writes. A
project that never linked one files under home. A settings file that will not
parse is an error rather than a silent fallback, since the wrong slug would file
a handoff where the other machine will not look for it.
--wipcommits the dirty project tree aswip: handoff <date> <time>and runsgit push -u origin HEAD. A clean tree is still pushed, since it can be ahead of its remote. It is refused in three cases, so a--wipmeant for a project cannot sweep up something much larger: the project root is your home directory, is the config dir, or contains the config dir. A project push that fails stops the verb there, before the note is filed and the config repo is synced.--note <file>copies that file tomemory/<slug>/handoff.mdand rewrites a single line inmemory/<slug>/MEMORY.md:- [Handoff](handoff.md) — <date> · <summary>. The summary is the note's first non-empty, non-heading line, cut to 80 characters. The line is replaced rather than piled up: there is only ever one current handoff.
The config repo is then committed as sync: <slug> <date> <time> and pushed.
The last line names the command for the other machine:
arrive with: npx claude-keep pull.
The manifest
claude-keep.json sits at the root of the config dir and holds the machine
state a git repo cannot carry. Three root keys are understood. Any key whose
name starts with _ is a comment and is ignored, including the _examples
block the template ships.
| Key | Shape | Read by |
| ------- | ------ | --------------- |
| mcp | object | apply |
| deps | array | doctor |
| steps | array | apply, pull |
mcp is keyed by server name, matching ^[A-Za-z0-9][A-Za-z0-9_-]*$. Each
server needs a type of http, sse or stdio. http and sse need url;
stdio needs command, and may add args (array of strings) and env (object
of strings).
deps entries need name and probe. probe is a shell command line;
exit 0 means present. hint is the install line shown when it fails, either a
string or an object keyed by platform.
steps entries need name and run, a shell command line run from the
config dir unless cwd says otherwise. when holds exactly one guard:
missing skips the step when that path exists, missingBin skips it when that
command is already on PATH. A step with no when runs on every apply, so
keep it idempotent.
Paths expand ~, ${HOME} and ${CLAUDE_CONFIG_DIR} in an MCP server's
command, args and env, and in a step's cwd and when.missing. Those two
may point outside the config dir; that is your call.
OS names are Node's process.platform values: win32, linux, darwin.
The os key means two things by shape. On deps and steps it is an array
that filters: the entry is only used on those platforms. On an mcp server it
is an object keyed by platform whose fields override the defaults.
{
"mcp": {
"notes": {
"type": "stdio",
"command": "~/projects/my-tool/.venv/Scripts/my-tool.exe",
"args": ["--stdio"],
"os": { "linux": { "command": "~/projects/my-tool/.venv/bin/my-tool" } }
}
},
"deps": [
{
"name": "my-tool",
"probe": "my-tool --version",
"hint": { "win32": "winget install Example.MyTool", "linux": "sudo apt install my-tool" }
}
],
"steps": [
{
"name": "build my-tool",
"run": "npm ci && npm run build",
"cwd": "${CLAUDE_CONFIG_DIR}/tools/my-tool",
"when": { "missing": "${CLAUDE_CONFIG_DIR}/tools/my-tool/dist/index.js" }
},
{ "name": "get my-tool", "run": "npm i -g my-tool", "when": { "missingBin": "my-tool" } }
]
}What is versioned and why
The .gitignore that init writes has three groups. Most patterns are anchored
at the root with a leading /, so a project nested inside the config dir keeps
its own files. The exceptions are deliberate: the Python bytecode rules and
**/node_modules/ match at any depth.
| Group | Examples | Why it is ignored |
| ----- | -------- | ----------------- |
| Runtime and sensitive | /.credentials.json, /settings.local.json, /history.jsonl, /projects/, /sessions/, /shell-snapshots/, /todos/, /statsig/, /telemetry/, /ide/, /paste-cache/ | Credentials, transcripts and per-machine scratch. None of it is authored, some is private, and a session means nothing on another machine. |
| Python bytecode | __pycache__/, *.pyc | Generated, and noisy in every diff. |
| Heavy and reinstallable | /plugins/, **/node_modules/ | Fetched, not authored. apply reinstalls plugins from settings.json; a package manager reinstalls the rest. |
Two consequences:
settings.jsonis versioned. Never put a credential in itsenvblock: no API key, no auth token, no cloud access key. It is committed in the clear, anddoctorwarns about it. UseapiKeyHelper, or put the value insettings.local.json, which is ignored.memory/is the versioned memory home.linkpoints each project'sautoMemoryDirectoryat a directory under it, so memory travels with the config repo.
Trust model
claude-keep gives your config repo the same authority you give your own shell.
apply runs the steps[].run command lines from claude-keep.json through a
shell and registers MCP stdio servers that Claude Code will later launch.
doctor runs your deps[].probe lines the same way. Beyond the manifest,
pull writes settings.json, skills/, agents/, commands/ and any
hooks/ tree straight into your Claude Code home, so a change there takes
effect the next time Claude Code starts, whether or not you run apply. The
hooks block in settings.json matters most: those are shell commands Claude
Code runs on its own at session events, so a hook that arrives through pull
runs without you invoking anything. Use claude-keep only with a private
repo you control, review what pull brought in before running apply, and
treat a manifest someone sent you exactly as you would treat a shell script they
sent you. Everything in the repo is committed in the clear, so keep API keys and
tokens out of settings.json env and out of MCP env blocks.
Remote Control
claude-keep is not a replacement for Claude Code's Remote Control, which streams a live session between devices. Syncing files through git is a different job, and the two can be used together.
Not in scope
- Plugins and marketplaces are declared in
settings.json, not in the manifest. - Removal is manual. Dropping a manifest entry does not unregister what
applyalready added. - The arriving-command display in
pullis a line-based scan of the diff. A JSON value sitting on its own line, away from its key, will not be shown.
Development
npm test # node --test, no dependencies to install
node --test test/leak.test.mjs # the privacy gate on its owntest/leak.test.mjs scans every tracked and untracked file in the repo for
private identifiers that must never reach a public package: local usernames,
home directory paths, personal email addresses, and internal project or client
names. A nested repository, whether a submodule or an untracked clone, is left
to its own gate. It also asserts that its own patterns still match their samples
and still ignore a list of innocent lookalikes, so a typo cannot silently
disable it. Use placeholder names such as alice and ~/projects/my-tool in
anything you add.
CI runs the full suite on ubuntu-latest and windows-latest, on Node 20 and
Node 24, plus the leak test as a separate named job.
License
MIT
