SLOPSHOPPER

config-snapshots

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

newpanecommandtoastprocess
★ 2v0.2.0MITupdated 2026-10-04MichaelP17/claude-mods/config-snapshots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · config-snapshots
│ ┃ Config snapshots ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No snapshots yet. /snapshot <name> records │ config-snapshots │ │ ┃ one. ⏺ Read(src/auth.ts) │ Claude config changed since the last │ │ ┃ ⎿ Read 6 lines │ snapshot. /snapshot <name> records it. │ │ ┃ [ Close ] ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /snapshot │ ⎿ config-snapshots: Usage: /snapshot <name> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Config snapshots
No snapshots yet. /snapshot <name> records one. [ Close ]
README

config-snapshots

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.

What a snapshot holds

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.

RecordedNot recorded
settings.json, CLAUDE.md, rules/, skills/, agents/, commands/, hooks/, keybindings.json, statusline.pytranscripts (projects/), history.jsonl, caches, sessions
plugins/installed_plugins.json, plugins/known_marketplaces.jsonplugin 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.

Setup

  1. Initialise the repository and record the first snapshot, 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.

  1. Optional — use the script by name in a terminal by adding its folder to PATH in your shell profile:
   export PATH="$HOME/claude-mods/config-snapshots/bin:$PATH"
  1. Optional — let Claude take snapshots and roll back without a permission prompt: run /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.

Use

CommandEffect
claude-config snapshot <name> [message]Record the current state as <name>; spaces become dashes
claude-config listAll snapshots with date
claude-config statusChanges 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 pruneDelete caches of plugins and marketplaces that are no longer installed, after confirmation

Inside Claude Code:

CommandEffect
/snapshot <name>Same as claude-config snapshot
/snapshotsA 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.

Limits

  • MCP servers added with 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.
  • Homebrew's auto-update is disabled while the script reads the inventory; otherwise taking a snapshot could migrate formulae.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The snapshot repository ~/.claude-config.git and ~/.claude/.gitignore stay until you delete them.

Source 2 files
hooks/register.tsx 197 lines
1import { 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 &lt;name&gt; 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}
197
types/index.d.ts 14 lines
1export 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