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

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:
| script | runs |
|---|---|
| setup | in the background inside each new worktree |
| teardown | in 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:
| variable | value |
|---|---|
WORKTREE_ROOT_PATH | the main checkout, <repo> |
WORKTREE_PATH | the 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.
claude plugin marketplace add 0xBADC0FFEE/claude-code-mods
claude plugin install setup-worktrees@claude-code-mods
Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
hooks/register.tsx 193 lines1import { 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}
193hooks/settings.ts 59 lines1import 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}
59hooks/setup-progress.tsx 173 lines1import { 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}
173types/index.d.ts 22 lines1export 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