SLOPSHOPPER

leftovers

Keeps a ledger of what Claude left running or lying around on your machine and servers

newbandrowsguardcommandprocess
★ 2v0.1.0MITupdated 2026-10-03homieyangg/claude-code-mods/leftovers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leftovers
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /leftovers ⎿ leftovers: nothing left behind. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

English | 繁體中文 | 简体中文

Three mods for Claude Code, built as function hook plugins. Requires Claude Code 2.1.287 or later.

plan-bar, leftovers and secret-mask in one session

ModWhat it doesCommand
plan-barProgress bars above the prompt while Claude works through a multi-step plan/plans
leftoversKeeps a ledger of services, containers, backups and repos Claude left behind/leftovers
secret-maskMasks token-like strings in tool output before the model sees them/secret-mask

Install

From this repo as a marketplace:

/plugin marketplace add homieyangg/claude-code-mods
/plugin install plan-bar@claude-code-mods
/plugin install leftovers@claude-code-mods
/plugin install secret-mask@claude-code-mods
/reload-plugins

Or clone it and load the folders directly. Edits reload while a session is running.

git clone https://github.com/homieyangg/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/plan-bar --plugin-dir ~/claude-code-mods/leftovers

To load them in every session, add the folders to ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/plan-bar:~/claude-code-mods/leftovers:~/claude-code-mods/secret-mask"
  }
}

plan-bar

plan-bar demo

Each plan gets one row above the prompt: the current stage, a bar split by stage, and a percentage. The row turns yellow while Claude is waiting on you and red when a task fails. A short sound plays when a stage finishes, when the plan waits, and when it ends.

Claude drives the bars itself. The mod registers two tools, plan_set and plan_update, and adds a short note to the system prompt asking Claude to use them for work with three or more steps.

Command
/plansList the current plans
/plans demoRun a 20 second demo
/plans clearRemove all plans
/plans sound off / onTurn sounds off or on

leftovers

leftovers demo

leftovers watches the Bash commands Claude runs, locally and over ssh, and writes down anything that keeps running or stays on disk after the session:

KindRecorded from
launchdlaunchctl bootstrap, launchctl load
systemdsystemctl enable, systemctl start
croncrontab edits
dockerdocker run -d, docker compose up -d
backgroundnohup, tmux new -d
repogh repo create, git worktree add
backupcopies or moves to .bak, .orig, .old

It also tracks git repos Claude edited and shows the ones with uncommitted changes.

Above the prompt, a yellow dot shows how many are left next to the first item; click the count to expand the rest. /leftovers shows the full list grouped by machine. Clean up asks Claude to remove the item using the undo command it recorded, Done drops it from the ledger. Uncommitted repos get See changes and Ignore. When Claude runs the undo command itself (docker rm -f, systemctl disable, git worktree remove and so on) the item drops off on its own. The ledger is kept across sessions.

leftovers list

Command
/leftoversShow the list
/leftovers drop <n>Remove item n from the ledger
/leftovers clearEmpty the ledger

secret-mask

secret-mask demo

When a Bash command prints something that looks like a credential, secret-mask replaces it with its first four characters and …(masked) before the output reaches the model. Results from other tools, such as MCP tools and web fetches, are masked when they are written to the conversation. A toast says how many were masked, and /secret-mask lists them.

It recognizes:

  • API keys starting with sk-
  • GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_, github_pat_
  • Slack tokens: xoxa-, xoxb-, xoxp-, xoxr-, xoxs-
  • JWTs, AWS access key IDs (AKIA…), Google API keys (AIza…) and OAuth tokens (ya29.…)
  • Bearer <token> headers and PEM private key blocks
  • NAME=value and JSON fields whose name contains TOKEN, SECRET, PASSWORD, API_KEY, PRIVATE_KEY or ACCESS_KEY, when the value is at least 16 characters and mixes letters and digits

This is pattern matching, so it will miss things. Results from Read, Edit, Write and NotebookEdit are left alone, because Claude needs the real file contents to edit them. Treat it as a guard against accidents, not as a way to hand Claude a file full of secrets.

Command
/secret-maskList what was masked in this session
/secret-mask off / onPause or resume masking for this session

Clicking the buttons

The buttons above the prompt and in /leftovers take mouse clicks only in Claude Code's fullscreen mode. The default renderer does not turn on mouse reporting, so clicks never reach it. Turn fullscreen on in /config, or add "tui": "fullscreen" to ~/.claude/settings.json. In the default mode, press ctrl+x then tab to move into the row above the prompt, tab to pick a button and Enter to press it.

Language

Each mod has a language option: en (default), zh-TW or zh-CN. Change it in /config, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "leftovers@claude-code-mods": { "options": { "language": "zh-TW" } }
  }
}

Use leftovers@inline as the key when the mod is loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS.

/plugin install notes that the option is not set yet. You can ignore that; it falls back to en.

Development

claude plugin validate ./leftovers
claude plugin test ./leftovers

The demo recordings are made with VHS. media/tapes/setup-demo.sh builds a separate Claude Code config in ~/.claude-demo and a sample project in ~/acme-app; log in once with CLAUDE_CONFIG_DIR=~/.claude-demo claude auth login, then run media/tapes/record.sh plan-bar leftovers secret-mask hero.

License

MIT

Source 4 files
hooks/register.tsx 417 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { DirtyRepo, Leftover } from '../types'
5import {
6  LOCAL,
7  applyDetected,
8  dayOf,
9  detect,
10  hostName,
11  labelName,
12  listText,
13  migrated,
14  ordered,
15  whatOf,
16} from './detect'
17import type { Place } from './detect'
18import { STRINGS, langOf } from './i18n'
19import type { Strings } from './i18n'
20
21const MAX_REPOS = 20
22// Gruvbox:收掉是會動到東西的動作用黃,完成用綠。
23// Button 平常不能上色,滑鼠移到那一列才會亮
24const UNDO_COLOR = '#fabd2f'
25const DONE_COLOR = '#b8bb26'
26const SCRATCH = /^\/(private\/)?(tmp|var\/folders)\//
27
28const items = atom({ plugin: 'leftovers', key: 'items' } as const, [])
29const dirty = atom({ plugin: 'leftovers', key: 'dirty' } as const, [])
30const isOpen = atom({ plugin: 'leftovers', key: 'isOpen' } as const, false)
31
32type Entry = { n: number; item: Leftover } | { n: number; repo: DirtyRepo }
33
34const stored = async <T,>($: EngineInterface, key: string): Promise<T[]> => {
35  const value = await $.store.get(key)
36
37  return Array.isArray(value) ? (value as T[]) : []
38}
39
40const ledger = async ($: EngineInterface) => (await stored<Leftover>($, 'items')).map(migrated)
41
42// 帳本放 store 才能跨 session;每次重讀再寫,另一個 session 同時在記也不會整份互蓋
43const mutate = async ($: EngineInterface, fn: (list: Leftover[]) => Leftover[]) => {
44  const list = fn(await ledger($))
45  await $.store.set('items', list)
46  await update($, items, () => list)
47}
48
49// worktree 常被 script 整批清掉,指令裡看不到 remove,所以直接看目錄還在不在
50const pruneGone = async ($: EngineInterface) => {
51  const home = (await $.env.get('HOME')) ?? ''
52  const gone: string[] = []
53
54  for (const item of await ledger($)) {
55    const path = item.label.startsWith('~') && home !== '' ? `${home}${item.label.slice(1)}` : item.label
56
57    if (item.kind === 'repo' && item.host === LOCAL && path.startsWith('/') && !(await $.fs.exists(path))) {
58      gone.push(item.id)
59    }
60  }
61
62  if (gone.length > 0) {
63    await mutate($, list => list.filter(item => !gone.includes(item.id)))
64  }
65}
66
67// 指令跑完工作目錄可能已經換了,相對路徑要用跑之前的目錄去接
68const placeOf = async ($: EngineInterface): Promise<Place> => {
69  try {
70    return { cwd: await $.session.cwd(), home: (await $.env.get('HOME')) ?? '' }
71  } catch {
72    return { cwd: '', home: '' }
73  }
74}
75
76const git = async ($: EngineInterface, dir: string, ...args: string[]) => {
77  try {
78    const ran = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 })
79
80    return ran.exitCode === 0 ? ran.stdout : undefined
81  } catch {
82    return undefined
83  }
84}
85
86const askToUndo = ($: EngineInterface, t: Strings, item: Leftover) =>
87  $.prompt.submit({
88    text: t.askToUndo(whatOf(t, item), hostName(t, item.host), item.day, item.undo),
89    asUser: true,
90  })
91
92const askToReview = ($: EngineInterface, t: Strings, repo: DirtyRepo) =>
93  $.prompt.submit({ text: t.askToReview(repo.label), asUser: true })
94
95// 目錄對到哪個 repo 根目錄不會變,查過就記著
96const roots = new Map<string, string | undefined>()
97
98// 把動過的目錄對回 git repo 根目錄,記進要追蹤未 commit 狀態的清單
99const track = async ($: EngineInterface, paths: readonly string[]) => {
100  const home = (await $.env.get('HOME')) ?? ''
101  const ignored = await stored<string>($, 'ignored')
102  let repos = await stored<string>($, 'repos')
103  const before = repos.length
104
105  for (const raw of paths) {
106    const dir = raw.startsWith('~') ? `${home}${raw.slice(1)}` : raw
107
108    if (!dir.startsWith('/') || SCRATCH.test(dir)) {
109      continue
110    }
111    if (!roots.has(dir)) {
112      roots.set(dir, (await git($, dir, 'rev-parse', '--show-toplevel'))?.trim())
113    }
114
115    const root = roots.get(dir)
116
117    if (root !== undefined && !ignored.includes(root) && !repos.includes(root)) {
118      repos = [...repos, root].slice(-MAX_REPOS)
119    }
120  }
121
122  if (repos.length !== before) {
123    await $.store.set('repos', repos)
124  }
125}
126
127const refreshDirty = async ($: EngineInterface) => {
128  const home = (await $.env.get('HOME')) ?? ''
129  const repos = await stored<string>($, 'repos')
130  const alive: string[] = []
131  const found: DirtyRepo[] = []
132
133  for (const path of repos) {
134    const status = await git($, path, 'status', '--porcelain')
135
136    if (status === undefined) {
137      continue
138    }
139    alive.push(path)
140
141    const files = status.split('\n').filter(line => line.trim() !== '').length
142
143    if (files > 0) {
144      const label = home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path
145      found.push({ path, label, files })
146    }
147  }
148
149  if (alive.length !== repos.length) {
150    await $.store.set('repos', alive)
151  }
152  if (JSON.stringify(found) !== JSON.stringify(await read($, dirty))) {
153    await update($, dirty, () => found)
154  }
155}
156
157const ignoreRepo = async ($: EngineInterface, path: string) => {
158  await $.store.set('ignored', [...(await stored<string>($, 'ignored')), path])
159  await $.store.set(
160    'repos',
161    (await stored<string>($, 'repos')).filter(one => one !== path),
162  )
163  await update($, dirty, list => list.filter(repo => repo.path !== path))
164}
165
166// band 的一列:一項加它的兩顆按鈕,key 跟輸出列的按鈕同一套
167const bandRow = (
168  $: EngineInterface,
169  t: Strings,
170  { Box, Button, Text }: Pick<ElementTable, 'Box' | 'Button' | 'Text'>,
171  entry: Entry,
172) =>
173  'item' in entry ? (
174    <Box key={`item-${entry.n}`} columnGap={1}>
175      <Text wrap="truncate-middle">{whatOf(t, entry.item)}</Text>
176      <Text dimColor>{hostName(t, entry.item.host)}</Text>
177      <Button
178        key={`undo-${entry.n}`}
179        label={t.undo}
180        hover={{ color: UNDO_COLOR }}
181        onPress={() => {
182          void askToUndo($, t, entry.item)
183        }}
184      />
185      <Button
186        key={`drop-${entry.n}`}
187        label={t.done}
188        dimColor
189        hover={{ color: DONE_COLOR, dimColor: false }}
190        onPress={() => mutate($, all => all.filter(one => one.id !== entry.item.id))}
191      />
192    </Box>
193  ) : (
194    <Box columnGap={1}>
195      <Text wrap="truncate-start">{entry.repo.label}</Text>
196      <Text dimColor>{t.uncommitted(entry.repo.files)}</Text>
197      <Button
198        key={`review-${entry.n}`}
199        label={t.review}
200        onPress={() => {
201          void askToReview($, t, entry.repo)
202        }}
203      />
204      <Button key={`skip-${entry.n}`} label={t.ignore} dimColor onPress={() => ignoreRepo($, entry.repo.path)} />
205    </Box>
206  )
207
208// 記帳出錯不能連累工具本身的結果,只留一行 debug log
209const quietly = async ($: EngineInterface, work: () => Promise<void>) => {
210  try {
211    await work()
212  } catch {
213    $.ui.log('leftovers: bookkeeping failed', { to: 'debug' })
214  }
215}
216
217export const register: Register = (on, options) => {
218  const t = STRINGS[langOf(options)]
219
220  on('session.start', async ($, e, next) => {
221    await $.command.register({ name: 'leftovers', description: t.description, argumentHint: t.argumentHint })
222    await quietly($, async () => {
223      await mutate($, list => list)
224      await pruneGone($)
225      await refreshDirty($)
226    })
227
228    return next(e)
229  })
230
231  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
232    const place = await placeOf($)
233    const ran = await next(e)
234
235    if (ran.deny !== undefined || ran.isError === true) {
236      return ran
237    }
238
239    await quietly($, async () => {
240      const detected = detect(e.command, place)
241
242      if (detected.ops.length > 0) {
243        const meta = { day: dayOf(await $.clock.now()), session: (await $.session.id()).slice(0, 8) }
244        await mutate($, list => applyDetected(list, detected, meta))
245      }
246      if (detected.dirs.length > 0) {
247        await track($, detected.dirs)
248      }
249    })
250
251    return ran
252  })
253
254  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
255    const ran = await next(e)
256
257    await quietly($, () => track($, [e.file_path.slice(0, e.file_path.lastIndexOf('/'))]))
258
259    return ran
260  })
261
262  on('turn.complete', async ($, e, next) => {
263    const ended = await next(e)
264
265    if (e.agentId === undefined) {
266      await quietly($, async () => {
267        await pruneGone($)
268        await refreshDirty($)
269      })
270    }
271
272    return ended
273  })
274
275  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
276    const entries: Entry[] = [
277      ...ordered(await read($, items)).map((item, index) => ({ n: index + 1, item })),
278      ...(await read($, dirty)).map((repo, index) => ({ n: index + 1, repo })),
279    ]
280    const first = entries[0]
281
282    if (e.props.hasSurvey || first === undefined) {
283      return next(e)
284    }
285
286    const ui = $.ui.resolve(e)
287    const { Box, Button, Text } = ui
288    const isListed = entries.length > 1 && (await read($, isOpen))
289
290    return (
291      <Box flexDirection="column">
292        {await next(e)}
293        <Box columnGap={1}>
294          <Text color="yellow">●</Text>
295          {entries.length > 1 ? (
296            <Button
297              key="toggle"
298              label={`${t.left(entries.length)} ${isListed ? '▴' : '▾'}`}
299              plain
300              dimColor
301              onPress={() => update($, isOpen, open => !open)}
302            />
303          ) : (
304            <Text dimColor>{t.left(entries.length)}</Text>
305          )}
306          {!isListed && bandRow($, t, ui, first)}
307        </Box>
308        {isListed && (
309          <Box flexDirection="column" paddingLeft={2}>
310            {entries.map(entry => bandRow($, t, ui, entry))}
311          </Box>
312        )}
313      </Box>
314    )
315  })
316
317  // pane 在 fullscreen 會被 dock 到右邊,位置 mod 管不了,所以清單畫在指令的輸出列
318  on('ui.render', { component: 'CommandOutput', props: { command: 'leftovers' } }, async ($, e, next) => {
319    if (e.props.isErrored || e.props.args.trim().startsWith('clear')) {
320      return next(e)
321    }
322
323    const { Box, Button, Text } = $.ui.resolve(e)
324    const list = ordered(await read($, items))
325    const repos = await read($, dirty)
326
327    if (list.length === 0 && repos.length === 0) {
328      return (
329        <Box>
330          <Text dimColor>{t.nothingLeft}</Text>
331        </Box>
332      )
333    }
334
335    const hosts = [...new Set(list.map(item => item.host))]
336
337    return (
338      <Box flexDirection="column">
339        {hosts.map(host => (
340          <Box flexDirection="column" marginBottom={1}>
341            <Text bold>{hostName(t, host)}</Text>
342            {list.map((item, index) =>
343              item.host !== host ? null : (
344                <Box key={`line-${index + 1}`} columnGap={1}>
345                  <Text dimColor>{item.day}</Text>
346                  <Text>{t.kind[item.kind]}</Text>
347                  <Text wrap="truncate-middle">{labelName(t, item.label)}</Text>
348                  <Button
349                    key={`undo-${index + 1}`}
350                    label={t.undo}
351                    hover={{ color: UNDO_COLOR }}
352                    onPress={() => {
353                      void askToUndo($, t, item)
354                    }}
355                  />
356                  <Button
357                    key={`drop-${index + 1}`}
358                    label={t.done}
359                    dimColor
360                    hover={{ color: DONE_COLOR, dimColor: false }}
361                    onPress={() => mutate($, all => all.filter(one => one.id !== item.id))}
362                  />
363                </Box>
364              ),
365            )}
366          </Box>
367        ))}
368        {repos.length > 0 && (
369          <Box flexDirection="column">
370            <Text bold>{t.uncommittedHeading}</Text>
371            {repos.map((repo, index) => (
372              <Box columnGap={1}>
373                <Text wrap="truncate-start">{repo.label}</Text>
374                <Text dimColor>{t.files(repo.files)}</Text>
375                <Button
376                  key={`review-${index + 1}`}
377                  label={t.review}
378                  onPress={() => {
379                    void askToReview($, t, repo)
380                  }}
381                />
382                <Button
383                  key={`skip-${index + 1}`}
384                  label={t.ignore}
385                  dimColor
386                  onPress={() => ignoreRepo($, repo.path)}
387                />
388              </Box>
389            ))}
390          </Box>
391        )}
392      </Box>
393    )
394  })
395
396  on('command.run', { command: 'leftovers' }, async ($, e) => {
397    const [verb, arg] = e.args.trim().split(/\s+/)
398
399    if (verb === 'clear') {
400      await mutate($, () => [])
401
402      return { text: t.cleared }
403    }
404    if (verb === 'drop') {
405      const target = ordered(await read($, items))[Number(arg) - 1]
406
407      if (target !== undefined) {
408        await mutate($, list => list.filter(item => item.id !== target.id))
409      }
410    }
411
412    await quietly($, () => refreshDirty($))
413
414    return { text: listText(t, ordered(await read($, items)), await read($, dirty)) }
415  })
416}
417
hooks/detect.ts 431 lines
1import type { DirtyRepo, Leftover, LeftoverKind } from '../types'
2import type { Strings } from './i18n'
3
4export const LOCAL = 'local'
5const GITHUB = 'GitHub'
6const UNNAMED = '(unnamed)'
7
8export type Found = Pick<Leftover, 'kind' | 'host' | 'label' | 'undo'>
9export type Op = { add: Found } | { drop: string }
10export type Detected = { ops: Op[]; dirs: string[] }
11export type Place = { cwd: string; home: string }
12
13type Context = { args: string[]; host: string; cwd: string; place: Place; out: Detected }
14
15const SSH_TAKES_VALUE = new Set('-o -p -i -l -F -J -L -R -D -E -S -W -b -c -e -m -w -Q -B -I'.split(' '))
16const COMPOSE_VERBS = new Set('up down run exec logs ps pull build restart stop start'.split(' '))
17const BACKUP = /\.(bak|backup|orig|old)[-.\w]*$/
18
19export const idOf = (kind: LeftoverKind, host: string, label: string) => `${kind}:${host}:${label}`
20
21// 帳本存的是語言中立的代號,顯示時才換成當下語言
22export const hostName = (t: Strings, host: string) => (host === LOCAL ? t.local : host)
23
24export const labelName = (t: Strings, label: string) => (label === UNNAMED ? t.unnamed : label)
25
26export const whatOf = (t: Strings, item: Leftover) => `${t.kind[item.kind]} ${labelName(t, item.label)}`
27
28// 舊版帳本把本機和未命名 container 記成中文,讀進來時換成現在的代號
29export const migrated = (item: Leftover): Leftover => {
30  const host = item.host === '本機' ? LOCAL : item.host
31  const label = item.label === '未命名 container' ? UNNAMED : item.label
32
33  return host === item.host && label === item.label ? item : { ...item, host, label, id: idOf(item.kind, host, label) }
34}
35
36const base = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
37
38const plain = (words: readonly string[]) => words.filter(word => !word.startsWith('-'))
39
40const add = (ctx: Context, kind: LeftoverKind, label: string, undo: string, host = ctx.host) => {
41  const where = host === LOCAL || host === GITHUB ? undo : `ssh ${host} '${undo}'`
42  ctx.out.ops.push({ add: { kind, host, label, undo: where } })
43}
44
45const drop = (ctx: Context, kind: LeftoverKind, label: string, host = ctx.host) => {
46  ctx.out.ops.push({ drop: idOf(kind, host, label) })
47}
48
49// 引號攤平再切段,ssh 帶的遠端指令就能跟本機指令用同一套規則看
50const SHELLS = new Set(['ssh', 'bash', 'sh', 'zsh'])
51
52// 餵給 cat、python 這類的 heredoc 是資料,裡面長得像指令的行不能記;餵給 shell 或 ssh 的才是指令
53const withoutDataHeredocs = (command: string): string =>
54  command.replace(
55    /<<-?[ \t]*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g,
56    (whole: string, _quote: string, _tag: string, rest: string, offset: number) => {
57      const line = command.slice(command.lastIndexOf('\n', offset) + 1, offset)
58      const head = line
59        .split(/;|&&|\|\||\|/)
60        .pop()
61        ?.trim()
62        .split(/\s+/)
63        .find(word => word !== 'sudo')
64
65      return SHELLS.has(head ?? '') ? whole : rest
66    },
67  )
68
69const segmentsOf = (command: string): string[][] =>
70  withoutDataHeredocs(command)
71    .replace(/\$\([^)]*\)/g, 'X')
72    .replace(/=(['"])([^'"\n]*)\1/g, '=$2')
73    .replace(/['"]/g, ' ')
74    .split(/;|&&|\|\||\||\n/)
75    .map(part =>
76      part
77        .trim()
78        .split(/\s+/)
79        .filter(word => word !== '' && word !== 'sudo'),
80    )
81    .filter(words => words.length > 0)
82
83const ASSIGNMENT = /^([A-Za-z_]\w*)=(.*)$/
84const VARIABLE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g
85
86// 同一條指令前面設過的變數先代回去,P=x.plist; launchctl bootstrap gui/1 $P 才會記成 x 而不是 $P
87const expanded = (words: readonly string[], vars: ReadonlyMap<string, string>): string[] =>
88  words.map(word =>
89    word.replace(VARIABLE, (whole: string, braced?: string, bare?: string) => vars.get(braced ?? bare ?? '') ?? whole),
90  )
91
92// 整段都是 NAME=value(可帶 export)才算設變數,回傳是否吃掉這段
93const assignedInto = (vars: Map<string, string>, words: readonly string[]): boolean => {
94  const pairs = words[0] === 'export' ? words.slice(1) : words
95
96  if (pairs.length === 0 || !pairs.every(word => ASSIGNMENT.test(word))) {
97    return false
98  }
99  pairs.forEach(word => {
100    const [, name = '', value = ''] = ASSIGNMENT.exec(word) ?? []
101
102    vars.set(name, value)
103  })
104
105  return true
106}
107
108const stripSsh = (words: readonly string[]): { host: string; rest: string[] } | undefined => {
109  if (words[0] !== 'ssh') {
110    return undefined
111  }
112
113  let at = 1
114
115  while (words[at]?.startsWith('-')) {
116    at += SSH_TAKES_VALUE.has(words[at] ?? '') ? 2 : 1
117  }
118
119  const host = words[at]
120
121  return host === undefined ? undefined : { host, rest: words.slice(at + 1) }
122}
123
124const valueAfter = (args: readonly string[], flag: string): string | undefined => {
125  const at = args.indexOf(flag)
126
127  return at >= 0 ? args[at + 1] : args.find(word => word.startsWith(`${flag}=`))?.slice(flag.length + 1)
128}
129
130// 把路徑接上當時的工作目錄變成絕對路徑,家目錄縮成 ~。
131// 帳本跨 session 共用,相對路徑事後查不到是哪個 repo 的
132const anchored = (place: Place, parts: readonly string[]): string => {
133  const joined = [place.cwd, ...parts].reduce((at, part) => {
134    if (part === '') {
135      return at
136    }
137
138    return at === '' || part.startsWith('/') || part.startsWith('~') ? part : `${at}/${part}`
139  }, '')
140  const full = joined.startsWith('~') && place.home !== '' ? `${place.home}${joined.slice(1)}` : joined
141
142  if (!full.startsWith('/')) {
143    return parts.at(-1) ?? ''
144  }
145
146  const kept: string[] = []
147
148  for (const part of full.split('/')) {
149    if (part === '..') {
150      kept.pop()
151    } else if (part !== '' && part !== '.') {
152      kept.push(part)
153    }
154  }
155
156  const path = `/${kept.join('/')}`
157
158  return place.home !== '' && path.startsWith(`${place.home}/`) ? `~${path.slice(place.home.length)}` : path
159}
160
161// 本機的路徑才接得上工作目錄,遠端的照原樣記
162const pathOn = (ctx: Context, ...parts: string[]): string =>
163  ctx.host === LOCAL ? anchored(ctx.place, [ctx.cwd, ...parts]) : (parts.at(-1) ?? '')
164
165const backupTo = (ctx: Context) => {
166  const target = plain(ctx.args).at(-1)
167
168  if (target !== undefined && BACKUP.test(target)) {
169    const path = pathOn(ctx, target)
170    add(ctx, 'backup', path, `rm ${path}`)
171  }
172}
173
174const RULES: Record<string, (ctx: Context) => void> = {
175  launchctl: ctx => {
176    const [verb, ...rest] = ctx.args
177    const labels = plain(rest).map(word => base(word).replace(/\.plist$/, ''))
178    const last = labels.at(-1)
179
180    if ((verb === 'bootstrap' || verb === 'load') && last !== undefined) {
181      add(ctx, 'launchd', last, `launchctl bootout gui/$(id -u)/${last}`)
182    }
183    if (verb === 'bootout' || verb === 'unload' || verb === 'remove') {
184      labels.forEach(label => drop(ctx, 'launchd', label))
185    }
186  },
187
188  systemctl: ctx => {
189    const scope = ctx.args.includes('--user') ? 'systemctl --user' : 'sudo systemctl'
190    const [verb, ...names] = plain(ctx.args)
191    const units = names.map(name => (name.includes('.') ? name : `${name}.service`))
192
193    if (verb === 'enable' || verb === 'start') {
194      units.forEach(unit => add(ctx, 'systemd', unit, `${scope} disable --now ${unit}`))
195    }
196    if (verb === 'disable' || verb === 'stop' || verb === 'mask') {
197      units.forEach(unit => drop(ctx, 'systemd', unit))
198    }
199  },
200
201  crontab: ctx => {
202    if (ctx.args.includes('-l')) {
203      return
204    }
205    if (ctx.args.includes('-r')) {
206      drop(ctx, 'cron', 'crontab')
207
208      return
209    }
210    add(ctx, 'cron', 'crontab', 'crontab -e')
211  },
212
213  docker: ctx => {
214    const [verb, ...rest] = plain(ctx.args)
215
216    if (verb === 'compose') {
217      const sub = rest.find(word => COMPOSE_VERBS.has(word))
218      const label = ctx.cwd === '' ? 'compose' : ctx.cwd
219      const there = ctx.cwd === '' ? '' : `cd ${ctx.cwd} && `
220
221      if (sub === 'up') {
222        add(ctx, 'docker', label, `${there}docker compose down`)
223      }
224      if (sub === 'down') {
225        drop(ctx, 'docker', label)
226      }
227
228      return
229    }
230
231    const isDetached = ctx.args.some(word => word === '--detach' || /^-[a-z]*d[a-z]*$/.test(word))
232
233    if (verb === 'run' && isDetached && !ctx.args.includes('--rm')) {
234      const name = valueAfter(ctx.args, '--name')
235
236      add(ctx, 'docker', name ?? UNNAMED, name === undefined ? 'docker ps' : `docker rm -f ${name}`)
237    }
238    if (verb === 'rm' || verb === 'stop') {
239      rest.forEach(name => drop(ctx, 'docker', name))
240    }
241  },
242
243  nohup: ctx => {
244    const label = base(plain(ctx.args)[0] ?? '')
245
246    if (label !== '') {
247      add(ctx, 'background', label, `pkill -f ${label}`)
248    }
249  },
250
251  tmux: ctx => {
252    const [verb] = ctx.args
253    const session = valueAfter(ctx.args, verb === 'kill-session' ? '-t' : '-s')
254
255    if (session === undefined) {
256      return
257    }
258    if ((verb === 'new' || verb === 'new-session') && ctx.args.includes('-d')) {
259      add(ctx, 'background', `tmux ${session}`, `tmux kill-session -t ${session}`)
260    }
261    if (verb === 'kill-session') {
262      drop(ctx, 'background', `tmux ${session}`)
263    }
264  },
265
266  gh: ctx => {
267    const [noun, verb, name] = plain(ctx.args)
268
269    if (noun !== 'repo' || name === undefined) {
270      return
271    }
272    if (verb === 'create') {
273      add(ctx, 'repo', base(name), `gh repo delete ${name} --yes`, GITHUB)
274    }
275    if (verb === 'delete') {
276      drop(ctx, 'repo', base(name), GITHUB)
277    }
278  },
279
280  git: ctx => {
281    const at = ctx.args.indexOf('worktree')
282    const verb = ctx.args[at + 1]
283
284    if (at < 0 || verb === undefined) {
285      return
286    }
287
288    // -b / -B 後面那個字是分支名,不是路徑
289    const rest = ctx.args.slice(at + 2).filter((word, index, all) => {
290      const before = all[index - 1]
291
292      return !word.startsWith('-') && before !== '-b' && before !== '-B'
293    })
294    const raw = rest[0]
295
296    if (raw === undefined) {
297      return
298    }
299
300    const inside = valueAfter(ctx.args.slice(0, at), '-C') ?? ''
301    const path = pathOn(ctx, inside, raw)
302
303    if (verb === 'add') {
304      add(ctx, 'repo', path, `git worktree remove ${path}`)
305    }
306    if (verb === 'remove') {
307      drop(ctx, 'repo', path)
308    }
309  },
310
311  cp: backupTo,
312  mv: backupTo,
313
314  rm: ctx => {
315    plain(ctx.args).forEach(path => drop(ctx, 'backup', pathOn(ctx, path)))
316  },
317}
318
319// 從一條 Bash 指令找出「留下了什麼」和「收掉了什麼」,照指令裡的順序排
320export const detect = (command: string, place: Place = { cwd: '', home: '' }): Detected => {
321  const out: Detected = { ops: [], dirs: [] }
322  const vars = new Map<string, string>()
323  let host = LOCAL
324  let cwd = ''
325
326  for (const words of segmentsOf(command)) {
327    const segment = expanded(words, vars)
328    const ssh = stripSsh(segment)
329
330    if (ssh !== undefined) {
331      host = ssh.host
332      cwd = ''
333    }
334
335    const run = ssh?.rest ?? segment
336
337    if (assignedInto(vars, run)) {
338      continue
339    }
340
341    const [head, ...args] = run
342
343    if (head === undefined) {
344      continue
345    }
346    if (head === 'cd' && args[0] !== undefined) {
347      cwd = args[0]
348      if (host === LOCAL) {
349        out.dirs.push(cwd)
350      }
351      continue
352    }
353    if (head === 'git' && host === LOCAL) {
354      const dir = valueAfter(args, '-C')
355
356      if (dir !== undefined) {
357        out.dirs.push(dir)
358      }
359    }
360
361    RULES[head]?.({ args, host, cwd, place, out })
362  }
363
364  return out
365}
366
367export const applyDetected = (
368  items: readonly Leftover[],
369  detected: Detected,
370  meta: Pick<Leftover, 'day' | 'session'>,
371): Leftover[] => {
372  let list = [...items]
373
374  for (const op of detected.ops) {
375    if ('drop' in op) {
376      list = list.filter(item => item.id !== op.drop)
377      continue
378    }
379
380    const id = idOf(op.add.kind, op.add.host, op.add.label)
381
382    if (!list.some(item => item.id === id)) {
383      list.push({ ...op.add, id, ...meta })
384    }
385  }
386
387  return list.slice(-200)
388}
389
390export const dayOf = (ms: number): string => {
391  const date = new Date(ms)
392  const pad = (part: number) => String(part).padStart(2, '0')
393
394  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
395}
396
397// 本機排最前面,其餘照機器名,再照日期
398export const ordered = (items: readonly Leftover[]): Leftover[] =>
399  [...items].sort((a, b) => {
400    const rank = (item: Leftover) => `${item.host === LOCAL ? '' : item.host}\u0000${item.day}`
401
402    return rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0
403  })
404
405export const summary = (t: Strings, items: readonly Leftover[], dirty: readonly DirtyRepo[]): string | undefined => {
406  const total = items.length + dirty.length
407
408  if (total === 0) {
409    return undefined
410  }
411
412  const backups = items.filter(item => item.kind === 'backup').length
413  const repos = items.filter(item => item.kind === 'repo').length
414
415  return t.summary(total, { services: items.length - backups - repos, backups, repos, dirty: dirty.length })
416}
417
418export const listText = (t: Strings, items: readonly Leftover[], dirty: readonly DirtyRepo[]): string => {
419  if (items.length === 0 && dirty.length === 0) {
420    return t.emptyList
421  }
422
423  const rows = items.map(
424    (item, index) =>
425      `${index + 1}. ${item.day} ${whatOf(t, item)} @ ${hostName(t, item.host)}\n${t.undoLine(item.undo)}`,
426  )
427  const repos = dirty.map(repo => t.repoLine(repo.label, repo.files))
428
429  return [summary(t, items, dirty) ?? '', ...rows, ...repos, '', t.footer].join('\n')
430}
431
hooks/i18n.ts 144 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { LeftoverKind } from '../types'
4
5export type Lang = 'en' | 'zh-TW' | 'zh-CN'
6
7const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN']
8
9export const langOf = (options: PluginOptions): Lang => LANGS.find(lang => lang === options.language) ?? 'en'
10
11const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
12
13type Counts = { services: number; backups: number; repos: number; dirty: number }
14
15const en = {
16  kind: {
17    launchd: 'launchd',
18    systemd: 'systemd',
19    cron: 'cron',
20    docker: 'docker',
21    background: 'background',
22    repo: 'repo',
23    backup: 'backup',
24  } as Record<LeftoverKind, string>,
25  local: 'local',
26  unnamed: 'unnamed container',
27  left: (n: number) => plural(n, 'leftover'),
28  undo: 'Clean up',
29  done: 'Done',
30  review: 'See changes',
31  ignore: 'Ignore',
32  uncommitted: (files: number) => `${plural(files, 'file')} uncommitted`,
33  files: (files: number) => plural(files, 'file'),
34  uncommittedHeading: 'Uncommitted',
35  nothingLeft: 'Nothing left behind.',
36  description: 'List what Claude left running or lying around',
37  argumentHint: '[drop <n>|clear]',
38  cleared: 'ledger cleared.',
39  summary: (total: number, { services, backups, repos, dirty }: Counts) =>
40    `${plural(total, 'leftover')}: ${[
41      services > 0 ? plural(services, 'service') : '',
42      backups > 0 ? plural(backups, 'backup') : '',
43      repos > 0 ? plural(repos, 'new repo') : '',
44      dirty > 0 ? `${plural(dirty, 'repo')} uncommitted` : '',
45    ]
46      .filter(part => part !== '')
47      .join(' · ')}`,
48  emptyList: 'nothing left behind.',
49  undoLine: (undo: string) => `   clean up: ${undo}`,
50  repoLine: (label: string, files: number) => `- ${label} ${plural(files, 'file')} uncommitted`,
51  footer: '/leftovers drop <n> removes one from the ledger, /leftovers clear empties it',
52  askToUndo: (what: string, host: string, day: string, undo: string) =>
53    `Clean up this item from leftovers: ${what} (${host}, created ${day}). Check that it still exists first, then remove it. This command should do it: ${undo}`,
54  askToReview: (label: string) => `Show me the uncommitted changes in ${label}. Just list them, don't commit.`,
55}
56
57export type Strings = typeof en
58
59const zhTW: Strings = {
60  kind: {
61    launchd: 'launchd',
62    systemd: 'systemd',
63    cron: 'cron',
64    docker: 'docker',
65    background: '背景程序',
66    repo: 'repo',
67    backup: '備份',
68  },
69  local: '本機',
70  unnamed: '未命名 container',
71  left: n => `留著 ${n} 項`,
72  undo: '收掉',
73  done: '完成',
74  review: '看改了什麼',
75  ignore: '忽略',
76  uncommitted: files => `${files} 檔未 commit`,
77  files: files => `${files} 檔`,
78  uncommittedHeading: '未 commit',
79  nothingLeft: '沒有留下的東西。',
80  description: '列出 Claude 留下還沒收掉的東西',
81  argumentHint: '[drop <編號>|clear]',
82  cleared: '帳本清空了。',
83  summary: (total, { services, backups, repos, dirty }) =>
84    `留著 ${total} 項:${[
85      services > 0 ? `${services} 服務` : '',
86      backups > 0 ? `${backups} 備份` : '',
87      repos > 0 ? `${repos} 新 repo` : '',
88      dirty > 0 ? `${dirty} repo 未 commit` : '',
89    ]
90      .filter(part => part !== '')
91      .join(' · ')}`,
92  emptyList: '沒有留下的東西。',
93  undoLine: undo => `   收掉:${undo}`,
94  repoLine: (label, files) => `- ${label} ${files} 檔未 commit`,
95  footer: '/leftovers drop <編號> 從帳本拿掉,/leftovers clear 全清',
96  askToUndo: (what, host, day, undo) =>
97    `收掉 leftovers 記的這一項:${what}(${host},${day} 建的)。先確認它還在,再收掉,可以參考這個指令:${undo}`,
98  askToReview: label => `看一下 ${label} 還沒 commit 的改動是什麼,先列給我,不要 commit。`,
99}
100
101const zhCN: Strings = {
102  kind: {
103    launchd: 'launchd',
104    systemd: 'systemd',
105    cron: 'cron',
106    docker: 'docker',
107    background: '后台进程',
108    repo: '仓库',
109    backup: '备份',
110  },
111  local: '本机',
112  unnamed: '未命名容器',
113  left: n => `遗留 ${n} 项`,
114  undo: '清理',
115  done: '完成',
116  review: '查看改动',
117  ignore: '忽略',
118  uncommitted: files => `${files} 个文件未提交`,
119  files: files => `${files} 个文件`,
120  uncommittedHeading: '未提交',
121  nothingLeft: '没有遗留的东西。',
122  description: '列出 Claude 遗留、还没清理的东西',
123  argumentHint: '[drop <编号>|clear]',
124  cleared: '账本已清空。',
125  summary: (total, { services, backups, repos, dirty }) =>
126    `遗留 ${total} 项:${[
127      services > 0 ? `${services} 个服务` : '',
128      backups > 0 ? `${backups} 个备份` : '',
129      repos > 0 ? `${repos} 个新仓库` : '',
130      dirty > 0 ? `${dirty} 个仓库未提交` : '',
131    ]
132      .filter(part => part !== '')
133      .join(' · ')}`,
134  emptyList: '没有遗留的东西。',
135  undoLine: undo => `   清理:${undo}`,
136  repoLine: (label, files) => `- ${label} ${files} 个文件未提交`,
137  footer: '/leftovers drop <编号> 从账本移除,/leftovers clear 全部清空',
138  askToUndo: (what, host, day, undo) =>
139    `清理 leftovers 记录的这一项:${what}(${host},${day} 创建)。先确认它还在,再清理,可以参考这个命令:${undo}`,
140  askToReview: label => `看一下 ${label} 还没提交的改动,先列给我,不要提交。`,
141}
142
143export const STRINGS: Record<Lang, Strings> = { en, 'zh-TW': zhTW, 'zh-CN': zhCN }
144
types/index.d.ts 20 lines
1export type LeftoverKind = 'launchd' | 'systemd' | 'cron' | 'docker' | 'background' | 'repo' | 'backup'
2
3export type Leftover = {
4  id: string
5  kind: LeftoverKind
6  host: string
7  label: string
8  undo: string
9  day: string
10  session: string
11}
12
13export type DirtyRepo = { path: string; label: string; files: number }
14
15declare module 'claude-code' {
16  interface PluginState {
17    leftovers: { items: Leftover[]; dirty: DirtyRepo[]; isOpen: boolean }
18  }
19}
20