Snapshot and roll back the Claude Code configuration through claude-config

Puts ~/.claude under version control, so trying a plugin, mod or skill is reversible: take a snapshot, experiment, roll back if you do not like the result.
It consists of a shell script, bin/claude-config, and a mod that exposes the script inside Claude Code. Both do the same thing; use whichever is at hand.
The snapshot repository is a bare git repository at ~/.claude-config.git whose work tree is ~/.claude. Nothing inside ~/.claude changes except one file, ~/.claude/.gitignore, which is an allowlist: everything is ignored unless listed.
| Recorded | Not recorded |
|---|---|
settings.json, CLAUDE.md, rules/, skills/, agents/, commands/, hooks/, keybindings.json, statusline.py | transcripts (projects/), history.jsonl, caches, sessions |
plugins/installed_plugins.json, plugins/known_marketplaces.json | plugin caches and marketplace clones |
inventory/<machine>/ |
inventory/<machine>/ lists what is installed on the machine at snapshot time: Homebrew (Brewfile), global npm, cargo, mise, dotnet tool, uv tool, pipx and winget packages — each only if the tool exists — the MCP servers from ~/.claude.json, and the commit of every mod loaded through CLAUDE_CODE_PLUGIN_DIRS. Each machine writes only its own folder, so several machines can share one snapshot repository.
baseline. Pass a remote URL to push snapshots there (an empty, private repository is the right target): ~/claude-mods/config-snapshots/bin/claude-config init git@github.com:<you>/claude-config.git
Without a URL, snapshots stay local. An existing ~/.claude/.gitignore is kept as the allowlist.
PATH in your shell profile: export PATH="$HOME/claude-mods/config-snapshots/bin:$PATH"
/permissions in Claude Code and add the allow rules Bash(claude-config snapshot:*) and Bash(claude-config rollback:*) to the user settings. This needs step 2.To keep the repository elsewhere, set CLAUDE_CONFIG_GIT_DIR to its path — in the shell profile for the terminal, and in the env block of ~/.claude/settings.json for the mod.
| Command | Effect |
|---|---|
claude-config snapshot <name> [message] | Record the current state as <name>; spaces become dashes |
claude-config list | All snapshots with date |
claude-config status | Changes since the last snapshot, plus new entries in ~/.claude the allowlist does not cover |
claude-config diff <name> [--full] | Changed files since <name> and packages installed since then |
claude-config rollback <name> | Restore the config of <name> |
claude-config brew-cleanup <name> | Uninstall Homebrew packages added since <name>, after confirmation |
claude-config prune | Delete caches of plugins and marketplaces that are no longer installed, after confirmation |
Inside Claude Code:
| Command | Effect |
|---|---|
/snapshot <name> | Same as claude-config snapshot |
/snapshots | A pane with all snapshots; selecting one shows its diff, a button rolls back after a second confirming press |
/rollback <name> | Opens the pane on that snapshot |
At session start a toast reports when the config changed since the last snapshot.
A rollback never rewrites history: it commits the current state first, then commits the restored one, so a rollback can itself be undone. It never uninstalls packages; it lists them and leaves the decision to you. snapshot and rollback push to origin when a remote is set; CLAUDE_CONFIG_NO_PUSH=1 skips the push. Restart Claude Code after a rollback.
claude mcp add --scope user live in ~/.claude.json, which also holds login data and runtime state. Only its mcpServers section is recorded, and a rollback reports differences there instead of rewriting the file.plugins/installed_plugins.json contains absolute paths, so plugins are best installed per machine rather than shared across operating systems.lastUpdated in plugins/known_marketplaces.json is not recorded, because Claude Code rewrites it on every background marketplace refresh. A rollback restores that file without the field.Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The snapshot repository ~/.claude-config.git and ~/.claude/.gitignore stay until you delete them.
hooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5
6const PANE = 'config-snapshots'
7const SCRIPT_TIMEOUT_MS = 180_000
8
9const snapshots = atom({ plugin: 'config-snapshots', key: 'snapshots' } as const, [])
10const selected = atom({ plugin: 'config-snapshots', key: 'selected' } as const, null)
11const output = atom({ plugin: 'config-snapshots', key: 'output' } as const, '')
12const isArmed = atom({ plugin: 'config-snapshots', key: 'isArmed' } as const, false)
13const isBusy = atom({ plugin: 'config-snapshots', key: 'isBusy' } as const, false)
14
15async function runScript($: EngineInterface, args: readonly string[]): Promise<string> {
16 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
17 // The script ships inside this mod, so the mod works from wherever it was cloned.
18 const pluginRoot = $.plugin.root.replace(/[\\/]\.claude-plugin[\\/]?$/, '')
19 const script = `${pluginRoot}/bin/claude-config`
20 // Windows cannot execute a shell script directly; Git Bash's bash runs it.
21 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
22 const argv = isWindows ? ['bash', script, ...args] : [script, ...args]
23 const result = await $.process.run(argv, {
24 cwd: home,
25 timeoutMs: SCRIPT_TIMEOUT_MS,
26 })
27 const text = [result.stdout.trim(), result.stderr.trim()].filter(i => i.length > 0).join('\n')
28
29 return result.exitCode === 0 ? text : `Failed (exit ${result.exitCode}):\n${text}`
30}
31
32async function refreshSnapshots($: EngineInterface): Promise<Snapshot[]> {
33 const listing = await runScript($, ['list', '--tsv'])
34 const list = listing
35 .split('\n')
36 .filter(i => i.includes('\t'))
37 .map(i => {
38 const [createdAt = '', name = '', message = ''] = i.split('\t')
39
40 return { createdAt, name, message }
41 })
42 await update($, snapshots, () => list)
43
44 return list
45}
46
47// Every script call goes through here so the pane shows one call at a time and
48// a second press cannot start a rollback while a snapshot is still being written.
49async function busy($: EngineInterface, work: () => Promise<string>): Promise<string> {
50 if (await read($, isBusy)) {
51 return 'claude-config is still running.'
52 }
53 await update($, isBusy, () => true)
54 try {
55 const text = await work()
56 await update($, output, () => text)
57
58 return text
59 } finally {
60 await update($, isBusy, () => false)
61 }
62}
63
64async function selectSnapshot($: EngineInterface, name: string): Promise<void> {
65 await update($, selected, () => name)
66 await update($, isArmed, () => false)
67 await busy($, () => runScript($, ['diff', name]))
68}
69
70async function rollBack($: EngineInterface, name: string): Promise<void> {
71 await update($, isArmed, () => false)
72 await busy($, () => runScript($, ['rollback', name]))
73 await refreshSnapshots($)
74 $.ui.toast(`Config rolled back to ${name}. Restart Claude Code to load it.`)
75}
76
77export const register: Register = on => {
78 on('session.start', async ($, e, next) => {
79 await $.command.register({
80 name: 'snapshot',
81 description: 'Record the Claude Code config as a named snapshot',
82 argumentHint: '<name>',
83 })
84 await $.command.register({
85 name: 'snapshots',
86 description: 'Browse config snapshots, show diffs and roll back',
87 })
88 await $.command.register({
89 name: 'rollback',
90 description: 'Show what rolling back to a snapshot changes, then confirm',
91 argumentHint: '<name>',
92 })
93
94 const status = await runScript($, ['status'])
95 if (!status.startsWith('No config changes')) {
96 $.ui.toast('Claude config changed since the last snapshot. /snapshot <name> records it.')
97 }
98
99 return next(e)
100 })
101
102 on('command.run', { command: 'snapshot' }, async ($, e) => {
103 const label = e.args.trim()
104 if (label.length === 0) {
105 return { text: 'Usage: /snapshot <name>' }
106 }
107 const text = await busy($, () => runScript($, ['snapshot', label]))
108 await refreshSnapshots($)
109
110 return { text }
111 })
112
113 on('command.run', { command: 'snapshots' }, async $ => {
114 await refreshSnapshots($)
115 await $.ui.open({ id: PANE, title: 'Config snapshots', focus: true })
116
117 return {}
118 })
119
120 on('command.run', { command: 'rollback' }, async ($, e) => {
121 const label = e.args.trim()
122 const list = await refreshSnapshots($)
123 if (label.length === 0) {
124 await $.ui.open({ id: PANE, title: 'Config snapshots', focus: true })
125
126 return {}
127 }
128 const snapshot = list.find(i => i.name === label || i.message === label)
129 if (snapshot === undefined) {
130 return { text: `Unknown snapshot: ${label}. /snapshots lists them.` }
131 }
132 await $.ui.open({ id: PANE, title: 'Config snapshots', focus: true })
133 await selectSnapshot($, snapshot.name)
134
135 return {}
136 })
137
138 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
139 const { Box, Text, Button } = $.ui.resolve(e)
140 const list = await read($, snapshots)
141 const current = await read($, selected)
142 const text = await read($, output)
143 const armed = await read($, isArmed)
144 const running = await read($, isBusy)
145
146 return (
147 <Box flexDirection="column">
148 {list.length === 0 && <Text dimColor>No snapshots yet. /snapshot <name> records one.</Text>}
149 {[...list].reverse().map(snapshot => (
150 <Box key={`row-${snapshot.name}`}>
151 <Button
152 key={`select-${snapshot.name}`}
153 plain
154 dimColor={snapshot.name !== current}
155 label={`${snapshot.name === current ? '▸' : ' '} ${snapshot.createdAt} ${snapshot.message}`}
156 onPress={() => selectSnapshot($, snapshot.name)}
157 />
158 </Box>
159 ))}
160
161 {current !== null && (
162 <Box marginTop={1}>
163 {armed ? (
164 <Box>
165 <Button
166 key="confirm"
167 variant="primary"
168 label={`Confirm rollback to ${current}`}
169 onPress={() => rollBack($, current)}
170 />
171 <Button key="cancel" label="Cancel" onPress={() => update($, isArmed, () => false)} />
172 </Box>
173 ) : (
174 <Button
175 key="arm"
176 label={`Roll back to ${current}`}
177 onPress={() => update($, isArmed, () => true)}
178 />
179 )}
180 </Box>
181 )}
182
183 {running && <Text dimColor>Running claude-config…</Text>}
184 {!running && text.length > 0 && (
185 <Box marginTop={1} flexDirection="column">
186 <Text>{text}</Text>
187 </Box>
188 )}
189
190 <Box marginTop={1}>
191 <Button key="close" role="dismiss" dimColor label="Close" onPress={() => $.ui.close({ id: PANE })} />
192 </Box>
193 </Box>
194 )
195 })
196}
197types/index.d.ts 14 lines1export type Snapshot = { createdAt: string; name: string; message: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'config-snapshots': {
6 snapshots: Snapshot[]
7 selected: string | null
8 output: string
9 isArmed: boolean
10 isBusy: boolean
11 }
12 }
13}
14