SLOPSHOPPER

setup-worktrees

Puts session worktrees in <repo>/.worktrees/ and runs your own setup/teardown scripts there, kept in .claude/settings.local.json. Needs function hooks (early…

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · setup-worktrees
│ ┃ Setup worktrees ✕ › fix the failing auth test and add an audit log call │ ┃ [ variable-WORKTREE_ROOT_PATH ][ variable-WO │ ┃ ⏺ Read(src/auth.ts) │ ┃ Setup — runs in each new worktree ⎿ Read 6 lines │ ┃ cp "$WORKTREE_ROOT_PATH/.env" . npm ci ⏺ Update(src/auth.ts) │ ┃ Teardown — runs before a worktree is removed ⎿ Added 2 lines, removed 1 line │ ┃ docker compose down ⏺ Bash(bun test) │ ┃ [ Save ]Not set up ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /setup-worktrees │ ⎿ setup-worktrees: Setup worktrees pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Setup worktrees
[ variable-WORKTREE_ROOT_PATH ][ variable-WORKTREE_PATH ][ v Setup — runs in each new worktree cp "$WORKTREE_ROOT_PATH/.env" . npm ci Teardown — runs before a worktree is removed docker compose down [ Save ]Not set up
Pane · setup-log
No setup log in this session.
README

setup-worktrees

Puts the worktree of every new session, desktop app or terminal, in <repo>/.worktrees/<name> on branch worktree-<name>, and runs your own scripts in it. /setup-worktrees opens a pane with two fields:

scriptruns
setupin the background inside each new worktree
teardownin the background before a worktree is removed

Saving writes WorktreeCreate and WorktreeRemove command hooks, with the scripts inlined, to the repository's .claude/settings.local.json: each developer keeps their own, and nothing is committed. Logs go to .worktrees/.logs/.

While setup runs, the worktree's session shows a spinner and the current step above the prompt, then a toast when it succeeds. A failure stays there, with its exit code and step, until dismissed. Log opens the whole log in a pane.

A step is a line the setup script prints, ::step:: then its name; other output only reaches the log.

Both scripts run inside the worktree with these variables set:

variablevalue
WORKTREE_ROOT_PATHthe main checkout, <repo>
WORKTREE_PATHthe worktree, <repo>/.worktrees/<name>
WORKTREE_NAME<name>
echo "::step::Copying .env"
cp "$WORKTREE_ROOT_PATH/.env" .
echo "::step::Installing dependencies"
npm ci

A script must not send itself to the background: setup is over when it exits.

When a session is removed, a worktree with no uncommitted changes is deleted, and so is its branch when it holds no commits found on no other branch. Anything else opens a macOS dialog that offers to delete the worktree, the branch, or keep both.

The hooks run ~/.claude/setup-worktrees/hook.sh, which the mod writes on save. They need jq and, for the dialog, macOS.

Install

claude plugin marketplace add 0xBADC0FFEE/claude-code-mods
claude plugin install setup-worktrees@claude-code-mods

Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Source 4 files
hooks/register.tsx 193 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
3
4import type { ScriptKind, WorktreeScripts } from '../types'
5import { EMPTY_SCRIPTS, INSTALLED_HOOK_PATH, readWorktreeScripts, withWorktreeHooks } from './settings'
6import { registerSetupProgress, watchSetup } from './setup-progress'
7
8const COMMAND = 'setup-worktrees'
9const PANE = 'setup-worktrees'
10const PANE_TITLE = 'Setup worktrees'
11const SETTINGS_FILE = '.claude/settings.local.json'
12const BUNDLED_HOOK = 'scripts/hook.sh'
13const SCRIPT_ROWS = 6
14
15const root = atom({ plugin: 'setup-worktrees', key: 'root' } as const, '')
16const draft = atom({ plugin: 'setup-worktrees', key: 'draft' } as const, EMPTY_SCRIPTS)
17const error = atom({ plugin: 'setup-worktrees', key: 'error' } as const, '')
18
19type ScriptField = { kind: ScriptKind; title: string; placeholder: string }
20
21const SCRIPT_FIELDS: readonly ScriptField[] = [
22  { kind: 'setup', title: 'Setup — runs in each new worktree', placeholder: 'cp "$WORKTREE_ROOT_PATH/.env" .\nnpm ci' },
23  { kind: 'teardown', title: 'Teardown — runs before a worktree is removed', placeholder: 'docker compose down' },
24]
25
26type ScriptVariable = { name: string; meaning: string }
27
28const SCRIPT_VARIABLES: readonly ScriptVariable[] = [
29  { name: 'WORKTREE_ROOT_PATH', meaning: 'Main checkout of the repository. Click to copy' },
30  { name: 'WORKTREE_PATH', meaning: 'This worktree, the working directory. Click to copy' },
31  { name: 'WORKTREE_NAME', meaning: 'Folder name under .worktrees/. Click to copy' },
32]
33
34export const register: Register = (on, options) => {
35  registerSetupProgress(on, options)
36
37  on('session.start', async ($, e, next) => {
38    await $.command.register({
39      name: COMMAND,
40      description: 'Edit this repo\'s local worktree setup/teardown scripts',
41    })
42    await refreshInstalledHook($)
43    const started = await next(e)
44    void watchSetup($, e.cwd)
45    return started
46  })
47
48  on('command.run', { command: COMMAND }, async $ => {
49    const repo = await repoRoot($)
50    const scripts = readWorktreeScripts(await readSettings($, repo))
51    await update($, root, () => repo)
52    await update($, draft, () => scripts ?? EMPTY_SCRIPTS)
53    await update($, error, () => '')
54    await $.ui.open({ id: PANE, title: PANE_TITLE })
55    return { text: `${PANE_TITLE} pane opened.` }
56  }).catch(($, e, next) => ({ text: next.error.message ?? next.error.kind }))
57
58  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59    const elements = $.ui.resolve(e)
60    const { Box, Text, Button } = elements
61    if (!('Input' in elements)) return <Text>Edit the scripts from the desktop app or a terminal.</Text>
62    const { Input } = elements
63
64    const repo = await read($, root)
65    const scripts = await read($, draft)
66    const stored = readWorktreeScripts(await readSettings($, repo))
67    const failure = await read($, error)
68    const hasChanges = !isSameScripts(scripts, stored ?? EMPTY_SCRIPTS)
69    const notice = failure !== '' ? failure : stored === undefined ? 'Not set up' : ''
70    const edit = (kind: ScriptKind) => (value: string) =>
71      void update($, draft, previous => ({ ...previous, [kind]: value }))
72    const save = () =>
73      void saveScripts($, repo, scripts).catch((failure: unknown) => update($, error, () => String(failure)))
74
75    const copy = (text: string, surface: UiPressArgument['surface']) =>
76      void $.ui.copy({ text, surface }).then(({ isCopied }) => isCopied && $.ui.toast(`Copied ${text}`))
77
78    return (
79      <Box flexDirection="column" alignItems="stretch" gap="lg" padding="md">
80        <Box flexDirection="column" gap="xs">
81          <Box flexDirection="row" flexWrap="wrap" gap="xs">
82            {SCRIPT_VARIABLES.map(({ name }) => (
83              <Button
84                key={`variable-${name}`}
85                variant="secondary"
86                // The desktop joins a hover group only through a hover that restyles something.
87                hover={{ scope: `variable-${name}`, color: 'text' }}
88                onPress={press => copy(`$${name}`, press.surface)}
89              >
90                <Text mono>${name}</Text>
91              </Button>
92            ))}
93          </Box>
94          <Box position="relative" minHeight={1}>
95            {SCRIPT_VARIABLES.map(({ name, meaning }) => (
96              <Box
97                key={`meaning-${name}`}
98                position="absolute"
99                top={0}
100                left={0}
101                right={0}
102                display="none"
103                hover={{ scope: `variable-${name}`, display: 'flex' }}
104              >
105                <Text size="caption" dimColor>{meaning}</Text>
106              </Box>
107            ))}
108          </Box>
109        </Box>
110        {SCRIPT_FIELDS.map(({ kind, title, placeholder }) => (
111          <Box key={kind} flexDirection="column" alignItems="stretch" gap="xs">
112            <Text size="footnote" weight="medium">{title}</Text>
113            <Input
114              key={`script-${kind}`}
115              rows={SCRIPT_ROWS}
116              placeholder={placeholder}
117              value={scripts[kind]}
118              onInput={edit(kind)}
119              onSubmit={save}
120            />
121          </Box>
122        ))}
123        <Box flexDirection="row" gap="md" alignItems="center">
124          <Button
125            key="save"
126            label="Save"
127            variant={hasChanges ? 'primary' : 'secondary'}
128            dimColor={!hasChanges}
129            onPress={hasChanges ? save : ignorePress}
130          />
131          {notice !== '' && <Text size="caption" color={failure !== '' ? 'error' : 'subtle'}>{notice}</Text>}
132        </Box>
133      </Box>
134    )
135  })
136}
137
138function isSameScripts(a: WorktreeScripts, b: WorktreeScripts): boolean {
139  return a.setup === b.setup && a.teardown === b.teardown
140}
141
142// The surface's Button has no disabled state, so an unchanged form gets a dimmed no-op.
143function ignorePress(): void {}
144
145async function saveScripts($: EngineInterface, repo: string, scripts: WorktreeScripts): Promise<void> {
146  await installHook($)
147  await $.fs.write(`${repo}/${SETTINGS_FILE}`, `${JSON.stringify(withWorktreeHooks(await readSettings($, repo), scripts), null, 2)}\n`)
148  await ensureIgnored($, repo, SETTINGS_FILE)
149  await update($, error, () => '')
150  $.ui.invalidate('ui.render')
151}
152
153async function installHook($: EngineInterface): Promise<void> {
154  await $.fs.write(await installedHookPath($), await $.fs.read(`${$.plugin.root}/${BUNDLED_HOOK}`))
155}
156
157async function refreshInstalledHook($: EngineInterface): Promise<void> {
158  const installed = await installedHookPath($)
159  if (!(await $.fs.exists(installed))) return
160  const bundled = await $.fs.read(`${$.plugin.root}/${BUNDLED_HOOK}`)
161  if ((await $.fs.read(installed)) !== bundled) await $.fs.write(installed, bundled)
162}
163
164async function installedHookPath($: EngineInterface): Promise<string> {
165  const home = await $.env.get('HOME')
166  if (!home) throw new Error('HOME is not set')
167  return `${home}/${INSTALLED_HOOK_PATH}`
168}
169
170async function readSettings($: EngineInterface, repo: string): Promise<Record<string, unknown>> {
171  const path = `${repo}/${SETTINGS_FILE}`
172  return (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
173}
174
175async function ensureIgnored($: EngineInterface, repo: string, file: string): Promise<void> {
176  const { exitCode } = await $.process.run(['git', 'check-ignore', '-q', file], { cwd: repo })
177  if (exitCode === 0) return
178
179  const excludeFile = `${repo}/.git/info/exclude`
180  const current = (await $.fs.exists(excludeFile)) ? await $.fs.read(excludeFile) : ''
181  const separator = current === '' || current.endsWith('\n') ? '' : '\n'
182  await $.fs.write(excludeFile, `${current}${separator}/${file}\n`)
183}
184
185async function repoRoot($: EngineInterface): Promise<string> {
186  const { exitCode, stdout } = await $.process.run(
187    ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'],
188    { cwd: await $.session.root() },
189  )
190  if (exitCode !== 0) throw new Error('This session is not inside a git repository.')
191  return stdout.trim().replace(/\/\.git\/?$/, '')
192}
193
hooks/settings.ts 59 lines
1import type { WorktreeScripts } from '../types'
2
3export const INSTALLED_HOOK_PATH = '.claude/setup-worktrees/hook.sh'
4
5const HOOK_INVOCATION = `bash "$HOME/${INSTALLED_HOOK_PATH}"`
6const CREATE_EVENT = 'WorktreeCreate'
7const REMOVE_EVENT = 'WorktreeRemove'
8const SINGLE_QUOTE_ESCAPE = `'\\''`
9
10type Settings = { hooks?: Record<string, unknown> } & Record<string, unknown>
11type HookMatcher = { hooks: { type: 'command'; command: string }[] }
12
13export const EMPTY_SCRIPTS: WorktreeScripts = { setup: '', teardown: '' }
14
15/**
16 * Returns `settings` with the WorktreeCreate/WorktreeRemove hooks replaced by this mod's.
17 * @param settings the parsed settings.local.json
18 * @param scripts the setup and teardown scripts, inlined into the hook commands
19 * @returns a new settings object
20 */
21export function withWorktreeHooks(settings: Settings, scripts: WorktreeScripts): Settings {
22  return {
23    ...settings,
24    hooks: {
25      ...settings.hooks,
26      [CREATE_EVENT]: [commandMatcher('create', scripts.setup)],
27      [REMOVE_EVENT]: [commandMatcher('remove', scripts.teardown)],
28    },
29  }
30}
31
32/**
33 * Reads back the scripts a previous `withWorktreeHooks` inlined.
34 * @param settings the parsed settings.local.json
35 * @returns the scripts, or `undefined` when the hooks are absent or not this mod's
36 */
37export function readWorktreeScripts(settings: Settings): WorktreeScripts | undefined {
38  const setup = inlinedScript(settings.hooks?.[CREATE_EVENT], 'create')
39  const teardown = inlinedScript(settings.hooks?.[REMOVE_EVENT], 'remove')
40  return setup === undefined || teardown === undefined ? undefined : { setup, teardown }
41}
42
43function commandMatcher(action: string, script: string): HookMatcher {
44  const command = script === '' ? `${HOOK_INVOCATION} ${action}` : `${HOOK_INVOCATION} ${action} ${shellQuote(script)}`
45  return { hooks: [{ type: 'command', command }] }
46}
47
48function inlinedScript(matchers: unknown, action: string): string | undefined {
49  const command = (matchers as HookMatcher[] | undefined)?.[0]?.hooks?.[0]?.command
50  const prefix = `${HOOK_INVOCATION} ${action}`
51  if (command === prefix) return ''
52  if (!command?.startsWith(`${prefix} '`) || !command.endsWith("'")) return undefined
53  return command.slice(prefix.length + 2, -1).replaceAll(SINGLE_QUOTE_ESCAPE, "'")
54}
55
56function shellQuote(text: string): string {
57  return `'${text.replaceAll("'", SINGLE_QUOTE_ESCAPE)}'`
58}
59
hooks/setup-progress.tsx 173 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SetupProgress } from '../types'
5
6const WORKTREES_DIR = '.worktrees'
7const LOGS_DIR = `${WORKTREES_DIR}/.logs`
8const STEP_MARKER = '::step::'
9const LOG_PANE = 'setup-log'
10const LOG_PANE_TITLE = 'Setup log'
11const LOG_PANE_LINES = 1000
12const MIN_FENCE = 3
13const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g
14const POLL_MS = 1000
15const MS_PER_SECOND = 1000
16const SECONDS_PER_MINUTE = 60
17const SPINNER_FRAME_MS = 100
18const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20type SetupFiles = { log: string; exitCode: string }
21
22const IDLE: SetupProgress = { phase: 'idle' }
23
24const progress = atom({ plugin: 'setup-worktrees', key: 'setup' } as const, IDLE)
25const setupLog = atom({ plugin: 'setup-worktrees', key: 'setupLog' } as const, '')
26const spinnerFrame = atom({ plugin: 'setup-worktrees', key: 'spinnerFrame' } as const, 0)
27
28/**
29 * Draws the setup progress that `watchSetup` tracks above the prompt, and its log in a pane.
30 * @param on the module's `on`
31 * @returns nothing
32 */
33export const registerSetupProgress: Register = on => {
34  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
35    const setup = await read($, progress)
36    if (e.props.hasSurvey || setup.phase === 'idle') return next(e)
37
38    const { Box, Button, Text } = $.ui.resolve(e)
39    const openLog = () => void $.ui.open({ id: LOG_PANE, title: LOG_PANE_TITLE })
40    const logButton = <Button key="setup-log" label="Log" onPress={openLog} />
41
42    if (setup.phase === 'running') {
43      const spinner = SPINNER_FRAMES[(await read($, spinnerFrame)) % SPINNER_FRAMES.length]
44      return (
45        <Box flexDirection="row" flexWrap="nowrap" gap="sm" alignItems="center">
46          <Box flexGrow={1} flexShrink={1}>
47            <Text dimColor wrap="truncate-end">{spinner} {withStep('Setup', setup.step)}</Text>
48          </Box>
49          {logButton}
50        </Box>
51      )
52    }
53
54    return (
55      <Box flexDirection="row" flexWrap="nowrap" gap="sm" alignItems="center">
56        <Box flexGrow={1} flexShrink={1}>
57          <Text color="error" wrap="truncate-end">{withStep(`Setup failed (exit ${setup.exitCode})`, setup.step)}</Text>
58        </Box>
59        {logButton}
60        <Button key="setup-dismiss" label="Dismiss" onPress={() => void update($, progress, () => IDLE)} />
61      </Box>
62    )
63  })
64
65  on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e) => {
66    const { Markdown, Text } = $.ui.resolve(e)
67    const log = await read($, setupLog)
68    if (log === '' || !(await $.fs.exists(log))) return <Text dimColor>No setup log in this session.</Text>
69    // Markdown, not Code: the desktop draws a Code element's text unselectable.
70    return <Markdown key="setup-log-text" text={codeBlock(printable(lastLines(await $.fs.read(log), LOG_PANE_LINES)))} />
71  })
72}
73
74/**
75 * Tracks the setup script of the worktree at `cwd` until it exits; does nothing outside a worktree.
76 * @param $ the engine
77 * @param cwd the session's working directory
78 * @returns resolves once the first poll is drawn
79 */
80export async function watchSetup($: EngineInterface, cwd: string): Promise<void> {
81  const files = await setupFiles($, cwd)
82  if (files === undefined || !(await $.fs.exists(files.log))) return
83  await update($, setupLog, () => files.log)
84
85  const exitCode = await readExitCode($, files)
86  if (exitCode !== undefined) {
87    const step = lastStep(await $.fs.read(files.log))
88    await update($, progress, (): SetupProgress => (exitCode === 0 ? IDLE : { phase: 'failed', exitCode, step }))
89    return
90  }
91
92  const startedAt = await $.clock.now()
93  const spin = $.clock.every(SPINNER_FRAME_MS, () => void update($, spinnerFrame, frame => frame + 1))
94  const timer = $.clock.every(POLL_MS, () =>
95    void poll($, files, startedAt).then(isDone => {
96      if (!isDone) return
97      timer.cancel()
98      spin.cancel()
99    }),
100  )
101  await poll($, files, startedAt)
102}
103
104async function poll($: EngineInterface, files: SetupFiles, startedAt: number): Promise<boolean> {
105  const elapsedSeconds = Math.round(((await $.clock.now()) - startedAt) / MS_PER_SECOND)
106  const exitCode = await readExitCode($, files)
107  const step = lastStep(await $.fs.read(files.log))
108  // The log pane reads the file while drawing, so it redraws with each poll.
109  $.ui.invalidate('ui.render')
110
111  if (exitCode === undefined) {
112    await update($, progress, (): SetupProgress => ({ phase: 'running', step }))
113    return false
114  }
115
116  if (exitCode === 0) {
117    await update($, progress, () => IDLE)
118    $.ui.toast(`Setup done in ${formatDuration(elapsedSeconds)}`)
119  } else {
120    await update($, progress, (): SetupProgress => ({ phase: 'failed', exitCode, step }))
121  }
122  return true
123}
124
125async function setupFiles($: EngineInterface, cwd: string): Promise<SetupFiles | undefined> {
126  const { exitCode, stdout } = await $.process.run(
127    ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'],
128    { cwd },
129  )
130  if (exitCode !== 0) return undefined
131
132  const [commonDir = '', worktree = ''] = stdout.trim().split('\n')
133  const root = commonDir.replace(/\/\.git\/?$/, '')
134  const prefix = `${root}/${WORKTREES_DIR}/`
135  if (!worktree.startsWith(prefix)) return undefined
136
137  const base = `${root}/${LOGS_DIR}/${worktree.slice(prefix.length).replaceAll('/', '_')}.setup`
138  return { log: `${base}.log`, exitCode: `${base}.exit` }
139}
140
141async function readExitCode($: EngineInterface, files: SetupFiles): Promise<number | undefined> {
142  if (!(await $.fs.exists(files.exitCode))) return undefined
143  return Number((await $.fs.read(files.exitCode)).trim())
144}
145
146function lastStep(log: string): string {
147  const marker = log.split(/\r?\n/).findLast(line => line.startsWith(STEP_MARKER))
148  return marker?.slice(STEP_MARKER.length).trim() ?? ''
149}
150
151function withStep(label: string, step: string): string {
152  return step === '' ? label : `${label} · ${step}`
153}
154
155function codeBlock(text: string): string {
156  const longestBackticks = Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length))
157  const fence = '`'.repeat(Math.max(MIN_FENCE, longestBackticks + 1))
158  return `${fence}text\n${text}\n${fence}`
159}
160
161function printable(text: string): string {
162  return text.replace(ANSI_ESCAPE, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
163}
164
165function lastLines(text: string, count: number): string {
166  return text.split('\n').slice(-count).join('\n')
167}
168
169function formatDuration(seconds: number): string {
170  const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
171  return `${minutes}:${String(seconds % SECONDS_PER_MINUTE).padStart(2, '0')}`
172}
173
types/index.d.ts 22 lines
1export type WorktreeScripts = { setup: string; teardown: string }
2
3export type ScriptKind = keyof WorktreeScripts
4
5export type SetupProgress =
6  | { phase: 'idle' }
7  | { phase: 'running'; step: string }
8  | { phase: 'failed'; exitCode: number; step: string }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'setup-worktrees': {
13      root: string
14      draft: WorktreeScripts
15      error: string
16      setup: SetupProgress
17      setupLog: string
18      spinnerFrame: number
19    }
20  }
21}
22