SLOPSHOPPER

tool-guard

Holds commands that lose work (git, rm, migrations), previews what they would change and lets you decide; checks commit subject format and removes the Claude…

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-guard
› 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(rm -rf build && git push --force origin main) ⎿ Denied by tool-guard: tool-guard: No answer within 10 minutes, so the command did not run. It would have: ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tool-guard ⎿ tool-guard: Usage: /tool-guard allow, /tool-guard status, /tool-guard set <key> <value> [--session|--local|--repo], /tool- ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-mod-trimtab

English | 繁體中文

Small mods that keep Claude Code from losing your work, your secrets and your tokens.

A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.

  • Fewer mistakes: risky git and rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.
  • Fewer tokens: long command output is trimmed before the model reads it; turn on session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on.
  • More fault tolerance: interrupted background agents come back in one key press, open questions survive /clear, and with project-memory on, what you learn in one session is waiting in the next.
  • No extra services, no surprise spending: no mod contacts a service of its own. The few features that call a model go through Claude Code's own model API with your account, and they are off until you turn them on.

The mods

ModWhat it does for you
secret-scrubMasks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache.
tool-guardHolds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through.
output-trimmerWhen a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file.
subagent-dispatchBrings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn.
open-itemsKeeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press.
session-keeperShows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive.
self-checkAsks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code.
project-memoryOnce turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md.

Quick start

Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).

claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab

This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.

The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.

Documentation

License

MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.

Source 5 files
hooks/register.tsx 781 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  cleanRemovals,
5  CD_SCRIPT,
6  describe,
7  listed,
8  lostChanges,
9  MIGRATION_LISTERS,
10  parseRm,
11  RM_SCRIPT,
12  size,
13  type Preview,
14} from './preview'
15import {
16  badSubject,
17  cleanDryRun,
18  commitSubject,
19  discardScope,
20  droppedStash,
21  gitCalls,
22  gitRisks,
23  PROTECTED_BRANCHES,
24  pushTargets,
25  shellRisks,
26  stripAttribution,
27  type GitCall,
28  type Risk,
29} from './rules'
30import { configCommand, LANGUAGE, loadConfig, pick, resolve, type ConfigIO, type Lang, type Resolved, type Specs } from './config'
31import { CONFIRM, TONE } from './ui'
32
33const MOD = 'tool-guard'
34const ALLOW = 'allow'
35const ALLOW_WINDOW_MS = 10 * 60 * 1000
36// The rule a command is blocked under when the guard itself failed to check it.
37const CHECK_FAILED = 'check-failed'
38// What a failed check still blocks; every other command runs as usual.
39const GUARDED = /\b(?:git|rm|migrate)\b|db:migrate/
40
41const PANE = 'tool-guard'
42// A hook has 10 s of its own time, but time inside a `$` call is free, so the hold waits on a
43// short `sleep` process until a button sets the decision.
44const POLL_SECONDS = '0.25'
45const HOLD_LIMIT_MS = 10 * 60 * 1000
46const TIMEOUT_MS = 15_000
47
48type Answer = 'proceed' | 'cancel'
49
50// A command waiting on the person: what it would change and, once pressed, their answer.
51type Hold = { command: string; previews: Preview[]; answer?: Answer }
52
53// --- measuring --------------------------------------------------------------------------
54
55async function run($: EngineInterface, argv: string[], cwd?: string) {
56  return $.process.run(argv, { ...(cwd ? { cwd } : {}), timeoutMs: TIMEOUT_MS })
57}
58
59// The absolute folder a command runs in: undefined for the session's, null when it cannot
60// be told (`cd -`, a folder that does not exist).
61async function resolveDir($: EngineInterface, dir: string | undefined): Promise<string | undefined | null> {
62  if (dir === undefined) {
63    return undefined
64  }
65  if (dir === '-') {
66    return null
67  }
68  const ran = await run($, ['bash', '-c', CD_SCRIPT, 'tool-guard', dir])
69  const out = ran.stdout.trim()
70
71  return ran.exitCode === 0 && out !== '' ? out : null
72}
73
74async function currentBranch($: EngineInterface, cwd?: string): Promise<string | undefined> {
75  const ran = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
76
77  return ran.exitCode === 0 ? ran.stdout.trim() : undefined
78}
79
80// What a force push drops: per target branch, the commits there that the pushed source
81// lacks, as of the last fetch. Undefined when it drops none from an unprotected branch.
82async function previewPush($: EngineInterface, lang: Lang, risk: Risk & { call: GitCall }, cwd?: string): Promise<Preview | undefined> {
83  const dropped: string[] = []
84  const notes: string[] = []
85  let isProtected = false
86  const refs: string[] = []
87  for (const target of pushTargets(risk.call.args)) {
88    const branch = target.branch ?? (await currentBranch($, cwd))
89    if (!branch) {
90      notes.push(pick(lang, { en: 'Cannot tell the current branch.', 'zh-TW': '無法判斷目前的分支。' }))
91      continue
92    }
93    isProtected ||= PROTECTED_BRANCHES.includes(branch)
94    const ref = `${target.remote}/${branch}`
95    refs.push(ref)
96    const known = await run($, ['git', 'rev-parse', '--verify', '--quiet', ref], cwd)
97    if (known.exitCode !== 0) {
98      notes.push(
99        pick(lang, {
100          en: `There is no ${ref} locally, so the commits it would overwrite cannot be listed; run git fetch first.`,
101          'zh-TW': `本機沒有 ${ref},無法列出會被蓋掉的 commit;先 git fetch 再看。`,
102        }),
103      )
104      continue
105    }
106    const log = await run($, ['git', 'log', '--oneline', '--no-decorate', `${target.source}..${ref}`], cwd)
107    dropped.push(...log.stdout.split('\n').filter(line => line !== ''))
108  }
109  if (dropped.length === 0 && notes.length === 0 && !isProtected) {
110    return undefined
111  }
112  const where = refs.join(lang === 'zh-TW' ? '、' : ', ') || pick(lang, { en: 'the remote branch', 'zh-TW': '遠端分支' })
113
114  return {
115    rule: 'force-push',
116    label: risk.label,
117    summary:
118      dropped.length > 0
119        ? pick(lang, { en: `Overwrites ${dropped.length} commit(s) on ${where}`, 'zh-TW': `蓋掉 ${where} 上的 ${dropped.length} 個 commit` })
120        : isProtected
121          ? pick(lang, { en: `Rewrites the protected ${where}`, 'zh-TW': `改寫受保護的 ${where}` })
122          : pick(lang, { en: `Force push to ${where}`, 'zh-TW': `force push 到 ${where}` }),
123    ...listed(dropped),
124    note: [...notes, pick(lang, { en: 'The list reflects the remote as of the last fetch.', 'zh-TW': '清單依上次 fetch 的遠端狀態。' })].join(' '),
125  }
126}
127
128async function previewDiscard($: EngineInterface, lang: Lang, risk: Risk & { call: GitCall }, cwd?: string): Promise<Preview | undefined> {
129  if (risk.call.sub === 'clean') {
130    const ran = await run($, ['git', ...cleanDryRun(risk.call.args)], cwd)
131    if (ran.exitCode !== 0) {
132      return {
133        rule: 'discard',
134        label: risk.label,
135        summary: pick(lang, { en: 'Cannot dry-run git clean -n', 'zh-TW': '無法試跑 git clean -n' }),
136        lines: [],
137        more: 0,
138        note: ran.stderr.trim().slice(0, 200),
139      }
140    }
141    const removed = cleanRemovals(ran.stdout)
142
143    return removed.length === 0
144      ? undefined
145      : {
146          rule: 'discard',
147          label: risk.label,
148          summary: pick(lang, { en: `Deletes ${removed.length} untracked path(s)`, 'zh-TW': `刪除 ${removed.length} 個未追蹤的路徑` }),
149          ...listed(removed),
150          note: pick(lang, {
151            en: 'The list comes from git clean -n; untracked files are not in git, so deleting them cannot be undone.',
152            'zh-TW': '清單來自 git clean -n;未追蹤的檔案不在 git 裡,刪了無法復原。',
153          }),
154        }
155  }
156
157  const scope = discardScope(risk.call)
158  const status = await run($, ['git', 'status', '--porcelain', '--', ...scope.paths], cwd)
159  if (status.exitCode !== 0) {
160    return {
161      rule: 'discard',
162      label: risk.label,
163      summary: pick(lang, { en: 'Cannot read git status', 'zh-TW': '無法讀取 git status' }),
164      lines: [],
165      more: 0,
166      note: status.stderr.trim().slice(0, 200),
167    }
168  }
169  const lost = lostChanges(status.stdout, scope.isStagedLost)
170  if (lost.length === 0) {
171    return undefined
172  }
173  const stat = await run($, ['git', 'diff', '--shortstat', ...(scope.isStagedLost ? ['HEAD'] : []), '--', ...scope.paths], cwd)
174
175  return {
176    rule: 'discard',
177    label: risk.label,
178    summary: pick(lang, { en: `Discards uncommitted changes in ${lost.length} file(s)`, 'zh-TW': `丟掉 ${lost.length} 個檔案未 commit 的改動` }),
179    ...listed(lost),
180    note: [
181      stat.stdout.trim(),
182      pick(lang, { en: 'Uncommitted changes cannot be recovered; save them with git stash or a commit first.', 'zh-TW': '未 commit 的改動無法復原,先用 git stash 或 commit 保存。' }),
183    ]
184      .filter(part => part !== '')
185      .join(pick(lang, { en: '. ', 'zh-TW': '。' })),
186  }
187}
188
189async function previewStash($: EngineInterface, lang: Lang, risk: Risk & { call: GitCall }, cwd?: string): Promise<Preview | undefined> {
190  const list = await run($, ['git', 'stash', 'list'], cwd)
191  const stashes = list.stdout.split('\n').filter(line => line !== '')
192  if (stashes.length === 0) {
193    return undefined
194  }
195  const ref = droppedStash(risk.call)
196  if (ref === undefined) {
197    return {
198      rule: 'stash-drop',
199      label: risk.label,
200      summary: pick(lang, { en: `Permanently deletes all ${stashes.length} stash(es)`, 'zh-TW': `永久刪除全部 ${stashes.length} 個 stash` }),
201      ...listed(stashes),
202    }
203  }
204  const show = await run($, ['git', 'stash', 'show', '--stat', ref], cwd)
205  const entry = stashes.find(line => line.startsWith(`${ref}:`))
206
207  return {
208    rule: 'stash-drop',
209    label: risk.label,
210    summary: pick(lang, { en: `Permanently deletes ${entry ?? ref}`, 'zh-TW': `永久刪除 ${entry ?? ref}` }),
211    ...listed(show.stdout.split('\n').filter(line => line.trim() !== '')),
212  }
213}
214
215async function previewRm(
216  $: EngineInterface,
217  lang: Lang,
218  risk: Risk & { rule: 'rm' },
219  skips: readonly string[],
220  cwd?: string,
221): Promise<Preview | undefined> {
222  if (risk.targets.length === 0) {
223    return undefined
224  }
225  const ran = await run($, ['bash', '-c', RM_SCRIPT, 'tool-guard', skips.join('\n'), ...risk.targets], cwd)
226  if (ran.exitCode !== 0) {
227    return {
228      rule: 'rm',
229      label: risk.label,
230      summary: pick(lang, { en: 'Cannot estimate the files to be deleted', 'zh-TW': '無法估算會刪除的檔案' }),
231      lines: [],
232      more: 0,
233      note: ran.stderr.trim().slice(0, 200),
234    }
235  }
236  const measured = parseRm(ran.stdout)
237  // Nothing matches, everything is under a temp folder, or only empty folders go.
238  if (measured.paths === 0 || measured.files === 0) {
239    return undefined
240  }
241
242  return {
243    rule: 'rm',
244    label: risk.label,
245    summary: pick(lang, {
246      en: `Deletes ${measured.files} file(s) (about ${size(measured.bytes)})`,
247      'zh-TW': `刪除 ${measured.files} 個檔案(約 ${size(measured.bytes)})`,
248    }),
249    lines: measured.listed,
250    more: Math.max(0, measured.files - measured.listed.length),
251    note: pick(lang, { en: `Paths: ${risk.targets.join(' ')}`, 'zh-TW': `路徑:${risk.targets.join(' ')}` }),
252  }
253}
254
255async function previewMigrate($: EngineInterface, lang: Lang, risk: Risk & { rule: 'migrate' }, cwd?: string): Promise<Preview | undefined> {
256  const lister = MIGRATION_LISTERS[risk.tool]
257  if (lister === undefined) {
258    return {
259      rule: 'migrate',
260      label: risk.label,
261      summary: pick(lang, { en: 'Runs a migration', 'zh-TW': '執行 migration' }),
262      lines: [],
263      more: 0,
264      note: pick(lang, { en: 'The pending migrations of this tool cannot be listed.', 'zh-TW': '無法列出這個工具待執行的 migration。' }),
265    }
266  }
267  const ran = await run($, lister.argv, cwd)
268  if (ran.exitCode !== 0) {
269    return {
270      rule: 'migrate',
271      label: risk.label,
272      summary: pick(lang, { en: 'Runs a migration', 'zh-TW': '執行 migration' }),
273      lines: [],
274      more: 0,
275      note: pick(lang, {
276        en: `Cannot list the pending migrations (${lister.argv.join(' ')} failed).`,
277        'zh-TW': `無法列出待執行的 migration(${lister.argv.join(' ')} 失敗)。`,
278      }),
279    }
280  }
281  const pending = ran.stdout.split('\n').filter(lister.isPending).map(lister.strip)
282
283  return pending.length === 0
284    ? undefined
285    : {
286        rule: 'migrate',
287        label: risk.label,
288        summary: pick(lang, { en: `Applies ${pending.length} pending migration(s)`, 'zh-TW': `套用 ${pending.length} 個待執行的 migration` }),
289        ...listed(pending),
290        note: pick(lang, { en: `The list comes from ${lister.argv.join(' ')}.`, 'zh-TW': `清單來自 ${lister.argv.join(' ')}。` }),
291      }
292}
293
294// What the risk would change, or undefined when it would change nothing worth asking about.
295// A measurement that fails is shown as such and still asks.
296async function previewOf($: EngineInterface, lang: Lang, risk: Risk, skips: readonly string[]): Promise<Preview | undefined> {
297  const where = 'call' in risk ? risk.call.dir : risk.dir
298  const cwd = await resolveDir($, where)
299  if (cwd === null) {
300    return {
301      rule: risk.rule,
302      label: risk.label,
303      summary: pick(lang, { en: `Cannot estimate (directory ${where} not found)`, 'zh-TW': `無法估算(找不到目錄 ${where})` }),
304      lines: [],
305      more: 0,
306    }
307  }
308  try {
309    switch (risk.rule) {
310      case 'force-push':
311        return await previewPush($, lang, risk, cwd)
312      case 'discard':
313        return await previewDiscard($, lang, risk, cwd)
314      case 'stash-drop':
315        return await previewStash($, lang, risk, cwd)
316      case 'no-verify':
317        return {
318          rule: risk.rule,
319          label: risk.label,
320          summary: pick(lang, {
321            en: 'Skips the checks of git hooks (pre-commit, commit-msg, pre-push)',
322            'zh-TW': '略過 git hooks(pre-commit、commit-msg、pre-push)的檢查',
323          }),
324          lines: [],
325          more: 0,
326        }
327      case 'rm':
328        return await previewRm($, lang, risk, skips, cwd)
329      case 'migrate':
330        return await previewMigrate($, lang, risk, cwd)
331    }
332  } catch (error) {
333    return {
334      rule: risk.rule,
335      label: risk.label,
336      summary: pick(lang, { en: 'Cannot estimate the impact', 'zh-TW': '無法估算影響範圍' }),
337      lines: [],
338      more: 0,
339      note: String(error).slice(0, 200),
340    }
341  }
342}
343
344// --- holding ----------------------------------------------------------------------------
345
346// Waits for the person's answer to `hold`; the pane is already up.
347async function waitForAnswer($: EngineInterface, hold: Hold, signal: AbortSignal): Promise<Answer | 'timeout' | 'interrupted'> {
348  const startedAt = await $.clock.now()
349  while (hold.answer === undefined) {
350    if (signal.aborted) {
351      return 'interrupted'
352    }
353    if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
354      return 'timeout'
355    }
356    await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
357  }
358
359  return hold.answer
360}
361
362type Asked = { answer: Answer | 'dismissed'; reply?: string }
363
364// Asks in the engine's own question dialog, which fits any width: the fallback for a pane the
365// surface will not place. Cancel comes first, so a dialog that settles by default refuses.
366async function askAnswer($: EngineInterface, lang: Lang, previews: Preview[]): Promise<Asked> {
367  const cancel = pick(lang, { en: 'Cancel', 'zh-TW': '取消' })
368  const run = pick(lang, { en: 'Run', 'zh-TW': '執行' })
369  let choice: string
370  try {
371    choice = await $.ui.ask(`${describe(previews, lang)}\n\n${pick(lang, { en: 'Run this command?', 'zh-TW': '要執行這個指令嗎?' })}`, {
372      options: [cancel, run],
373      header: 'tool-guard',
374    })
375  } catch {
376    return { answer: 'dismissed' }
377  }
378  if (choice === run) {
379    return { answer: 'proceed' }
380  }
381
382  return choice === cancel || choice === '' ? { answer: 'cancel' } : { answer: 'cancel', reply: choice }
383}
384
385function paneRows(previews: Preview[]): number {
386  const body = previews.reduce((sum, p) => sum + 1 + p.lines.length + (p.more > 0 ? 1 : 0) + (p.note ? 1 : 0), 0)
387
388  return Math.min(24, 6 + body)
389}
390
391export const SPECS: Specs = {
392  commit_format: {
393    category: 'protect',
394    type: 'boolean',
395    default: false,
396    repo: 'only-true',
397    description: {
398      en: 'Check the commit subject format; a subject that does not match is blocked and Claude is asked to fix it',
399      'zh-TW': '檢查 commit 標題格式,不符合就擋下請 Claude 改',
400    },
401  },
402  commit_pattern: {
403    category: 'plain',
404    type: 'string',
405    default: '',
406    repo: 'any',
407    description: {
408      en: 'Regular expression a commit subject must match; empty means `<emoji> type(scope)!: description` (Conventional Commits)',
409      'zh-TW': 'commit 標題要符合的正規表示式;空白時用 `<emoji> type(scope)!: 描述`(Conventional Commits)',
410    },
411  },
412  strip_attribution: {
413    category: 'plain',
414    type: 'boolean',
415    default: true,
416    repo: 'any',
417    description: { en: "Remove Claude's Co-Authored-By trailer from commit messages", 'zh-TW': '從 commit 訊息移除 Claude 的 Co-Authored-By 署名' },
418  },
419  rm_skip_paths: {
420    category: 'protect',
421    type: 'list',
422    default: [],
423    repo: 'never',
424    description: {
425      en: 'Folders rm is not checked in (besides temp folders); only in the user settings file or the local file',
426      'zh-TW': 'rm 不檢查的資料夾(暫存資料夾之外再加的);只能放在使用者設定檔或 local 檔案',
427    },
428  },
429  language: LANGUAGE,
430}
431
432// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
433function configIO($: EngineInterface): ConfigIO {
434  return {
435    read: async path => {
436      try {
437        return await $.fs.read(path)
438      } catch {
439        return ''
440      }
441    },
442    write: (path, text) => $.fs.write(path, text),
443    isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
444    configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
445    root: () => $.session.root(),
446    repoRoot: async () => (await $.session.repo())?.root,
447    sessionId: () => $.session.id(),
448    now: () => $.clock.now(),
449    storeGet: key => $.store.get(key),
450    storeSet: (key, value) => $.store.set(key, value),
451  }
452}
453// --- end configIO ---
454
455// The setting `commit_pattern` as a RegExp; undefined for the built-in format or a pattern that does not compile.
456export function subjectPattern(pattern: string): RegExp | undefined {
457  if (pattern.trim() === '') {
458    return undefined
459  }
460  try {
461    return new RegExp(pattern)
462  } catch {
463    return undefined
464  }
465}
466
467export const register: Register = (on, options) => {
468  // Re-read at session start and after `/tool-guard set`; `/config` changes reload the module.
469  let config: Resolved = resolve(SPECS, { options, user: {}, repo: {}, local: {}, session: {} })
470  const apply = (resolved: Resolved) => {
471    config = resolved
472  }
473  // One allowance the person grants with /tool-guard allow, for the rules last blocked.
474  let lastBlocked: string[] | undefined
475  let allowance: { rules: string[]; until: number } | undefined
476  let isInteractive = true
477  // The command the pane shows. One at a time: a second risky call waits for the first.
478  let held: Hold | undefined
479
480  // Spends the allowance when it covers every rule and has not expired.
481  function takeAllowance(rules: string[], now: number): boolean {
482    if (!allowance || now > allowance.until || !rules.every(rule => allowance?.rules.includes(rule))) {
483      return false
484    }
485    allowance = undefined
486
487    return true
488  }
489
490  function denial(rules: string[], reason: string): { deny: string } {
491    lastBlocked = rules
492
493    return {
494      deny:
495        `tool-guard: ${reason}\n` +
496        pick(config.lang, {
497          en: `If you are sure it should run, first explain why to the user and ask them to type /${MOD} ${ALLOW}, then run the same command again.`,
498          'zh-TW': `若確定要執行,請先向使用者說明原因並請他輸入 /${MOD} ${ALLOW},再重新執行同一個指令。`,
499        }),
500    }
501  }
502
503  on('session.start', async ($, e, next) => {
504    isInteractive = e.isInteractive
505    try {
506      apply(await loadConfig(configIO($), MOD, SPECS, options))
507    } catch (error) {
508      $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
509    }
510    const lang = config.lang
511    try {
512      await $.command.register({
513        name: MOD,
514        description: pick(lang, {
515          en: 'Allow the next command of the same kind that tool-guard blocked (allow, valid for 10 minutes), or view and change settings',
516          'zh-TW': '允許下一個被 tool-guard 擋下的同類指令(allow,10 分鐘內有效),或看、改設定',
517        }),
518        argumentHint: 'allow | status | set <key> <value> | unset <key>',
519      })
520    } catch (error) {
521      $.ui.log(`session.start: /${MOD} not registered: ${String(error)}`, { to: 'debug' })
522      $.ui.toast(pick(lang, { en: `${MOD}: /${MOD} was not registered (${String(error)})`, 'zh-TW': `${MOD}:/${MOD} 指令沒有註冊成功(${String(error)})` }))
523    }
524    if (config.values.commit_pattern !== '' && subjectPattern(config.values.commit_pattern as string) === undefined) {
525      $.ui.toast(
526        pick(lang, {
527          en: `${MOD}: commit_pattern is not a valid regular expression; the built-in format is used`,
528          'zh-TW': `${MOD}:commit_pattern 不是有效的正規表示式,改用內建格式`,
529        }),
530      )
531    }
532
533    return next(e)
534  })
535
536  on('command.run', { command: MOD }, async ($, e) => {
537    if (e.args.trim().split(/\s+/)[0] !== ALLOW) {
538      const text = await configCommand(configIO($), MOD, SPECS, options, e.args, apply)
539
540      const usage = pick(config.lang, {
541        en: `Usage: /${MOD} ${ALLOW}, /${MOD} status, /${MOD} set <key> <value> [--session|--local|--repo], /${MOD} unset <key>`,
542        'zh-TW': `用法:/${MOD} ${ALLOW}、/${MOD} status、/${MOD} set <key> <value> [--session|--local|--repo]、/${MOD} unset <key>`,
543      })
544
545      return { text: text ?? usage }
546    }
547    if (lastBlocked === undefined) {
548      return { text: pick(config.lang, { en: 'tool-guard has not blocked any command yet.', 'zh-TW': 'tool-guard 目前沒有擋下任何指令。' }) }
549    }
550    allowance = { rules: lastBlocked, until: (await $.clock.now()) + ALLOW_WINDOW_MS }
551    const names = lastBlocked.join(config.lang === 'zh-TW' ? '、' : ', ')
552
553    return {
554      text: pick(config.lang, {
555        en: `The user allowed the next command that tool-guard blocks under the rule(s) "${names}" (valid for 10 minutes); you can run it again.`,
556        'zh-TW': `使用者已允許下一個被 tool-guard 以「${names}」規則擋下的指令(10 分鐘內有效),可以重新執行。`,
557      }),
558    }
559  })
560
561  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
562    const calls = gitCalls(e.command)
563    const risks = [...calls.flatMap(gitRisks), ...shellRisks(e.command)]
564    if (calls.length === 0 && risks.length === 0) {
565      return next(e)
566    }
567
568    // A malformed commit subject is Claude's to fix, not the person's to approve.
569    const lang = config.lang
570    const pattern = subjectPattern(config.values.commit_pattern as string)
571    for (const call of config.values.commit_format === true ? calls : []) {
572      const subject = call.sub === 'commit' ? commitSubject(e.command, call.args) : undefined
573      if (subject !== undefined && badSubject(subject, pattern) && !takeAllowance(['subject'], await $.clock.now())) {
574        $.ui.toast(pick(lang, { en: 'tool-guard: blocked commit subject format', 'zh-TW': 'tool-guard:擋下 commit 標題格式' }))
575
576        return denial(
577          ['subject'],
578          pattern === undefined
579            ? pick(lang, {
580                en:
581                  `The commit subject "${subject}" does not follow Conventional Commits. ` +
582                  'Format: `<emoji> <type>(<scope>): <description>`; emoji and scope are optional, e.g. `✨ feat(claude): add tool-guard`.',
583                'zh-TW':
584                  `commit 標題「${subject}」不符合 Conventional Commits。` +
585                  '格式:`<emoji> <type>(<scope>): <描述>`,emoji 與 scope 可省略,例如 `✨ feat(claude): 新增 tool-guard`。',
586              })
587            : pick(lang, {
588                en: `The commit subject "${subject}" does not match the format set for this repo: \`${pattern.source}\`.`,
589                'zh-TW': `commit 標題「${subject}」不符合這個 repo 設定的格式 \`${pattern.source}\`。`,
590              }),
591        )
592      }
593    }
594
595    const previews: Preview[] = []
596    for (const risk of risks) {
597      const preview = await previewOf($, lang, risk, config.values.rm_skip_paths as readonly string[])
598      if (preview) {
599        previews.push(preview)
600      }
601    }
602    const rules = [...new Set(previews.map(preview => preview.rule))]
603
604    if (previews.length > 0 && takeAllowance(rules, await $.clock.now())) {
605      $.ui.toast(
606        pick(lang, {
607          en: `tool-guard: let through as the user allowed (${rules.join(', ')})`,
608          'zh-TW': `tool-guard:依使用者允許放行(${rules.join('、')})`,
609        }),
610      )
611    } else if (previews.length > 0) {
612      // One hold at a time; `held` is claimed with no await between the check and the claim.
613      while (held !== undefined) {
614        if (next.signal.aborted) {
615          return {
616            deny: pick(lang, {
617              en: 'tool-guard: the turn was interrupted and the command did not run. Do not retry unless the user asks.',
618              'zh-TW': 'tool-guard: 回合已中斷,指令沒有執行。除非使用者要求,不要重試。',
619            }),
620          }
621        }
622        await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
623      }
624      const hold: Hold = { command: e.command, previews }
625      held = hold
626      $.ui.invalidate('ui.render')
627      let answer: Asked['answer'] | 'timeout' | 'interrupted' | 'unplaced' = 'unplaced'
628      let reply: string | undefined
629      try {
630        if (isInteractive) {
631          const opened = await $.ui.open({ id: PANE, title: pick(lang, { en: 'tool-guard confirm', 'zh-TW': 'tool-guard 確認' }), focus: true, rows: paneRows(previews) })
632          if (opened.isPlaced) {
633            answer = await waitForAnswer($, hold, next.signal)
634          } else {
635            $.ui.log(`tool-guard: pane not placed (${opened.reason}), asking in a dialog instead`, { to: 'debug' })
636            await $.ui.close({ id: PANE }).catch(() => undefined)
637            ;({ answer, reply } = await askAnswer($, lang, previews))
638          }
639        }
640      } finally {
641        // Close this call's pane before releasing the hold, so the next call's pane is not
642        // the one closed. An unplaced pane is closed too, so it never shows up later.
643        await $.ui.close({ id: PANE }).catch(() => undefined)
644        if (held === hold) {
645          held = undefined
646        }
647        $.ui.invalidate('ui.render')
648      }
649
650      const what = describe(previews, lang)
651      if (answer === 'unplaced') {
652        $.ui.toast(pick(lang, { en: `tool-guard: blocked ${rules.join(', ')}`, 'zh-TW': `tool-guard:擋下 ${rules.join('、')}` }))
653
654        return denial(
655          rules,
656          pick(lang, {
657            en: `This command would have the following effects; it is blocked because this is a non-interactive session and the user cannot be asked:\n${what}`,
658            'zh-TW': `這個指令會造成以下影響,因為是非互動模式、無法詢問使用者,所以先擋下:\n${what}`,
659          }),
660        )
661      }
662      if (answer !== 'proceed') {
663        const why = {
664          cancel: reply
665            ? pick(lang, { en: `The user did not choose to run it and replied: "${reply}"`, 'zh-TW': `使用者沒有選擇執行,回覆:「${reply}」` })
666            : pick(lang, { en: 'The user pressed Cancel', 'zh-TW': '使用者按了取消' }),
667          timeout: pick(lang, { en: 'No answer within 10 minutes', 'zh-TW': '10 分鐘內沒有回應' }),
668          interrupted: pick(lang, { en: 'The turn was interrupted', 'zh-TW': '回合已中斷' }),
669          dismissed: pick(lang, { en: 'The user closed the question dialog', 'zh-TW': '使用者關閉了詢問對話框' }),
670        }[answer]
671
672        return {
673          deny: pick(lang, {
674            en: `tool-guard: ${why}, so the command did not run. It would have:\n${what}\nDo not retry unless the user asks.`,
675            'zh-TW': `tool-guard: ${why},指令沒有執行。它原本會:\n${what}\n除非使用者要求,不要重試。`,
676          }),
677        }
678      }
679      $.ui.toast(pick(lang, { en: 'tool-guard: confirmed by the user, running the command', 'zh-TW': 'tool-guard:使用者確認,執行指令' }))
680    }
681
682    const stripped = stripAttribution(e.command)
683    if (config.values.strip_attribution === true && stripped.isChanged && calls.some(call => call.sub === 'commit')) {
684      $.ui.toast(pick(lang, { en: 'tool-guard: removed the Claude trailer from the commit message', 'zh-TW': 'tool-guard:已從 commit 訊息移除 Claude 署名' }))
685
686      return next({ ...e, command: stripped.command })
687    }
688
689    return next(e)
690  }).catch(async ($, e, next) => {
691    // A broken guard must not let a destructive command run unchecked: guarded commands wait
692    // for /tool-guard allow, every other command runs as usual.
693    if (next.called || !GUARDED.test(e.command)) {
694      return next(e)
695    }
696    const lang = config.lang
697    // A Bash call raised beneath one of this hook's own `$` calls: `$` rejects here, so it is
698    // judged from `e` alone.
699    if (next.error.kind === 're-entry') {
700      return {
701        deny: pick(lang, {
702          en: 'tool-guard: this command was triggered while a check was running and cannot be checked, so it is blocked.',
703          'zh-TW': 'tool-guard: 這個指令在檢查進行中被觸發,無法檢查,已擋下。',
704        }),
705      }
706    }
707    if (takeAllowance([CHECK_FAILED], await $.clock.now().catch(() => Infinity))) {
708      $.ui.toast(pick(lang, { en: 'tool-guard: the check failed; let through as the user allowed', 'zh-TW': 'tool-guard:檢查失敗,依使用者允許放行' }))
709
710      return next(e)
711    }
712    $.ui.toast(pick(lang, { en: 'tool-guard: the check failed; the command is blocked', 'zh-TW': 'tool-guard:檢查失敗,已擋下指令' }))
713
714    return denial(
715      [CHECK_FAILED],
716      pick(lang, {
717        en: `The check failed (${next.error.message ?? next.error.kind}); a command that has not been checked is always blocked.`,
718        'zh-TW': `檢查失敗(${next.error.message ?? next.error.kind}),未經檢查的指令一律擋下。`,
719      }),
720    )
721  })
722
723  // A pane the person closes by hand is a Cancel.
724  on('ui.close', async ($, e, next) => {
725    if (e.id === PANE && e.origin.kind === 'person' && held && held.answer === undefined) {
726      held.answer = 'cancel'
727    }
728
729    return next(e)
730  })
731
732  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
733    const hold = held
734    if (!hold) {
735      return next(e)
736    }
737    const { Box, Button, Text } = $.ui.resolve(e)
738    const lang = config.lang
739    // The buttons answer the command this pane was drawn for, never whichever is held now.
740    const answer = (choice: Answer) => () => {
741      hold.answer ??= choice
742    }
743
744    return (
745      <Box flexDirection="column" {...CONFIRM}>
746        <Text wrap="truncate-end">
747          <Text dimColor>{pick(lang, { en: 'Command ', 'zh-TW': '指令 ' })}</Text>
748          <Text bold>{hold.command.replace(/\s+/g, ' ')}</Text>
749        </Text>
750        {hold.previews.map((preview, i) => (
751          <Box key={`p${i}`} flexDirection="column" marginTop={1}>
752            <Text>
753              <Text bold>{preview.label}</Text>
754              <Text>  </Text>
755              <Text bold color={TONE.danger}>
756                {preview.summary}
757              </Text>
758            </Text>
759            {preview.lines.map((line, j) => (
760              <Text key={`l${j}`} wrap="truncate-end">
761                {`  ${line}`}
762              </Text>
763            ))}
764            {preview.more > 0 && <Text dimColor>{`  ${pick(lang, { en: `${preview.more} more`, 'zh-TW': `另有 ${preview.more} 項` })}`}</Text>}
765            {preview.note && (
766              <Text dimColor wrap="wrap">
767                {preview.note}
768              </Text>
769            )}
770          </Box>
771        ))}
772        <Box marginTop={1} gap={2}>
773          <Button key="proceed" label={pick(lang, { en: 'Run', 'zh-TW': '執行' })} hotkey="1" plain onPress={answer('proceed')} />
774          <Button key="cancel" label={pick(lang, { en: 'Cancel', 'zh-TW': '取消' })} hotkey="2" plain autoFocus onPress={answer('cancel')} />
775          <Text dimColor>{pick(lang, { en: 'Claude is waiting for your answer', 'zh-TW': 'Claude 等待你回答' })}</Text>
776        </Box>
777      </Box>
778    )
779  })
780}
781
hooks/preview.ts 143 lines
1// What a held command would change, as the pane and the denial show it. register.tsx runs
2// the commands; this file holds their scripts and turns their output into a Preview.
3
4import { pick, type Lang } from './config'
5import type { MigrationTool, Rule } from './rules'
6
7export const LIST_MAX = 10
8
9export type Preview = {
10  rule: Rule
11  // The operation: `git reset --hard`, `rm -rf`.
12  label: string
13  // What it would do, in one line.
14  summary: string
15  lines: string[]
16  // Items beyond `lines`.
17  more: number
18  note?: string
19}
20
21export function listed(items: string[]): { lines: string[]; more: number } {
22  return { lines: items.slice(0, LIST_MAX), more: Math.max(0, items.length - LIST_MAX) }
23}
24
25// `git status --porcelain` rows a discard overwrites, as `XY path`. Untracked files are never
26// touched; without staged loss only rows with an unstaged change (Y) count.
27export function lostChanges(porcelain: string, isStagedLost: boolean): string[] {
28  return porcelain
29    .split('\n')
30    .filter(row => row.length > 3 && !row.startsWith('??') && !row.startsWith('!!'))
31    .filter(row => isStagedLost || row[1] !== ' ')
32}
33
34export function cleanRemovals(stdout: string): string[] {
35  return stdout
36    .split('\n')
37    .filter(line => line.startsWith('Would remove '))
38    .map(line => line.slice('Would remove '.length))
39}
40
41// Expands rm's targets the way the shell would and measures them, skipping paths under the
42// temp folders and under the folders in $1 (one per line, the setting `rm_skip_paths`). The
43// paths arrive as arguments, never as source, so nothing in them runs;
44// compgen -G expands a glob without command substitution. Prints `files bytes paths`, then
45// up to LIST_MAX of the files.
46export const RM_SCRIPT = [
47  'shopt -s nullglob dotglob',
48  'tmp=$(cd "${TMPDIR:-/tmp}" 2>/dev/null && pwd -P)',
49  'skips=()',
50  'while IFS= read -r s; do',
51  '  [[ -z "$s" ]] && continue',
52  '  case "$s" in "~"|"~/"*) s="$HOME${s#\\~}";; esac',
53  '  skips+=("$(cd "$s" 2>/dev/null && pwd -P || printf %s "${s%/}")")',
54  'done <<< "$1"',
55  'shift',
56  'paths=()',
57  'add() {',
58  '  local abs',
59  '  abs="$(cd "$(dirname -- "$1")" 2>/dev/null && pwd -P)/$(basename -- "$1")"',
60  '  case "$abs" in /tmp/*|/private/tmp/*|/var/folders/*|/private/var/folders/*) return;; esac',
61  '  [[ -n "$tmp" && "$abs" == "$tmp"/* ]] && return',
62  '  for s in "${skips[@]}"; do [[ "$abs" == "$s" || "$abs" == "$s"/* ]] && return; done',
63  '  case "$seen" in *"|$abs|"*) return;; esac', // a path two targets match counts once
64  '  seen="$seen|$abs|"',
65  '  paths+=("$1")',
66  '}',
67  'for p in "$@"; do',
68  '  case "$p" in "~"|"~/"*) p="$HOME${p#\\~}";; esac',
69  '  if [[ "$p" == *[*?[]* ]]; then',
70  '    while IFS= read -r m; do add "$m"; done < <(compgen -G "$p")',
71  '  elif [[ -e "$p" || -L "$p" ]]; then',
72  '    add "$p"',
73  '  fi',
74  'done',
75  'if (( ${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi',
76  '# A relative path gets ./ in front, so find never reads a name like -delete as an action.',
77  'for i in "${!paths[@]}"; do case "${paths[$i]}" in /*) ;; *) paths[$i]="./${paths[$i]}";; esac; done',
78  'files=$(find "${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d " ")',
79  'kb=$(du -skc "${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)',
80  'echo "$files $(( ${kb:-0} * 1024 )) ${#paths[@]}"',
81  `find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}`,
82].join('\n')
83
84export type RmMeasure = { files: number; bytes: number; paths: number; listed: string[] }
85
86export function parseRm(stdout: string): RmMeasure {
87  const [head, ...rest] = stdout.split('\n').filter(line => line !== '')
88  const [files = 0, bytes = 0, paths = 0] = (head ?? '0 0 0').split(' ').map(Number)
89
90  return { files, bytes, paths, listed: rest.map(line => line.replace(/^\.\//, '')) }
91}
92
93// Resolves a `cd` target to an absolute folder; prints nothing when it does not exist.
94export const CD_SCRIPT =
95  'unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P'
96
97type Lister = { argv: string[]; isPending: (line: string) => boolean; strip: (line: string) => string }
98
99// Each tool's own status command, and how to read the pending migrations off it.
100export const MIGRATION_LISTERS: Partial<Record<MigrationTool, Lister>> = {
101  django: {
102    argv: ['python3', 'manage.py', 'showmigrations', '--plan'],
103    isPending: line => line.startsWith('[ ]'),
104    strip: line => line.slice(4),
105  },
106  alembic: { argv: ['alembic', 'history', '-r', 'current:head'], isPending: line => line.includes('->'), strip: line => line },
107  rails: { argv: ['bin/rails', 'db:migrate:status'], isPending: line => /^\s*down\b/.test(line), strip: line => line.trim() },
108  prisma: {
109    argv: ['npx', '--no-install', 'prisma', 'migrate', 'status'],
110    isPending: line => /^\s{2}\S/.test(line),
111    strip: line => line.trim(),
112  },
113}
114
115export function size(bytes: number): string {
116  if (!Number.isFinite(bytes) || bytes < 1024) {
117    return `${bytes || 0} B`
118  }
119  const units = ['KB', 'MB', 'GB', 'TB']
120  let n = bytes
121  let i = -1
122  while (n >= 1024 && i < units.length - 1) {
123    n /= 1024
124    i += 1
125  }
126
127  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
128}
129
130// The previews as plain text, for a denial the model relays to the person.
131export function describe(previews: Preview[], lang: Lang = 'en'): string {
132  return previews
133    .map(preview =>
134      [
135        `${preview.label}${lang === 'zh-TW' ? ':' : ': '}${preview.summary}`,
136        ...preview.lines.map(line => `  - ${line}`),
137        ...(preview.more > 0 ? [`  - ${pick(lang, { en: `${preview.more} more`, 'zh-TW': `另有 ${preview.more} 項` })}`] : []),
138        ...(preview.note ? [`  ${preview.note}`] : []),
139      ].join('\n'),
140    )
141    .join('\n')
142}
143
hooks/rules.ts 458 lines
1// Pure checks over a Bash command line: which operations in it can lose work, and what about
2// them is risky. register.tsx measures what each one would actually change before asking.
3
4export type GitCall = {
5  sub: string
6  args: string
7  // The directory `git -C` or a leading `cd <dir> &&` points at, when the command names one.
8  dir?: string
9}
10
11const GIT_CALL =
12  /(?:^|[;&|(\n`]|\$\()\s*(?:[A-Z_][A-Z0-9_]*=\S*\s+)*git((?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|\s+--[a-z-]+(?:=\S+)?)*)\s+([a-z][a-z-]*)([^;&|\n]*)/g
13
14const LEADING_CD = /^\s*cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)\s*&&/
15
16function unquote(text: string): string {
17  return text.replace(/^["']|["']$/g, '')
18}
19
20export function gitCalls(command: string): GitCall[] {
21  const cd = LEADING_CD.exec(command)?.[1]
22  const calls: GitCall[] = []
23  for (const match of command.matchAll(GIT_CALL)) {
24    const globals = match[1] ?? ''
25    const dashC = /-C\s+("[^"]*"|'[^']*'|\S+)/.exec(globals)?.[1]
26    const dir = dashC ? unquote(dashC) : cd ? unquote(cd) : undefined
27    calls.push({ sub: match[2] ?? '', args: match[3] ?? '', ...(dir ? { dir } : {}) })
28  }
29
30  return calls
31}
32
33// Splits text into words, honouring quotes. Good enough to read flags and paths.
34export function tokenize(text: string): string[] {
35  return [...text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
36}
37
38// --- risks -------------------------------------------------------------------------------
39
40export type Rule = 'force-push' | 'no-verify' | 'stash-drop' | 'discard' | 'rm' | 'migrate'
41
42export type MigrationTool = 'django' | 'rails' | 'alembic' | 'prisma' | 'unknown'
43
44export type Risk =
45  | { rule: 'force-push' | 'no-verify' | 'stash-drop' | 'discard'; label: string; call: GitCall }
46  | { rule: 'rm'; label: string; targets: string[]; dir?: string }
47  | { rule: 'migrate'; label: string; tool: MigrationTool; dir?: string }
48
49export function gitRisks(call: GitCall): Risk[] {
50  const risks: Risk[] = []
51  if (call.sub === 'push' && isForcePush(call.args)) {
52    risks.push({ rule: 'force-push', label: 'git push --force', call })
53  }
54  if (skipsHooks(call)) {
55    risks.push({ rule: 'no-verify', label: `git ${call.sub} --no-verify`, call })
56  }
57  const loses = alwaysLoses(call)
58  if (loses) {
59    risks.push({ rule: 'stash-drop', label: loses, call })
60  }
61  if (discardsWorktree(call)) {
62    risks.push({ rule: 'discard', label: `git ${call.sub} ${call.args.trim()}`.trim().slice(0, 80), call })
63  }
64
65  return risks
66}
67
68// --- push -------------------------------------------------------------------------------
69
70export const PROTECTED_BRANCHES = ['main', 'master']
71
72export function isForcePush(args: string): boolean {
73  return /(?:^|\s)(?:--force(?:-with-lease)?(?:=\S*)?|--force-if-includes|-[a-zA-Z]*f[a-zA-Z]*)(?=\s|$)/.test(args) ||
74    /(?:^|\s)\+\S+/.test(args)
75}
76
77export type PushTarget = {
78  remote: string
79  source: string
80  // The remote branch, or undefined when the push sends the current one.
81  branch?: string
82}
83
84// The branches a push overwrites: one per refspec, or the current branch when it names none.
85export function pushTargets(args: string): PushTarget[] {
86  const words = tokenize(args).filter(word => !word.startsWith('-'))
87  const remote = words[0] ?? 'origin'
88  const specs = words.slice(1)
89  if (specs.length === 0) {
90    return [{ remote, source: 'HEAD' }]
91  }
92
93  return specs.map(spec => {
94    // A refspec is src:dst; with no colon the local branch of the same name is pushed.
95    const [source = '', target = source] = spec.replace(/^\+/, '').split(':')
96    const branch = target.replace(/^refs\/heads\//, '')
97
98    return branch === '' || branch === 'HEAD'
99      ? { remote, source: 'HEAD' }
100      : { remote, source: source === '' ? 'HEAD' : source, branch }
101  })
102}
103
104// --- destructive local operations -----------------------------------------------------
105
106// Operations that can throw away uncommitted work; register.tsx asks only when they would.
107export function discardsWorktree(call: GitCall): string | undefined {
108  const { sub, args } = call
109  if (sub === 'reset' && /(?:^|\s)--hard\b/.test(args)) {
110    return 'git reset --hard'
111  }
112  if (sub === 'checkout' && /(?:^|\s)(?:--\s|\.(?:\s|$))/.test(args)) {
113    return 'git checkout -- <path>'
114  }
115  if (sub === 'restore' && (!/(?:^|\s)(?:--staged|-S)\b/.test(args) || /(?:^|\s)(?:--worktree|-W)\b/.test(args))) {
116    return 'git restore <path>'
117  }
118  if (sub === 'clean' && /(?:^|\s)-[a-zA-Z]*f/.test(args)) {
119    return 'git clean -f'
120  }
121
122  return undefined
123}
124
125export type DiscardScope = {
126  // Pathspecs the command limits itself to; empty means the whole worktree.
127  paths: string[]
128  // Whether staged changes are lost too, not just unstaged ones.
129  isStagedLost: boolean
130}
131
132// Which uncommitted changes a reset --hard, checkout or restore overwrites.
133export function discardScope(call: GitCall): DiscardScope {
134  const words = tokenize(call.args)
135  if (call.sub === 'reset') {
136    return { paths: [], isStagedLost: true }
137  }
138  if (call.sub === 'restore') {
139    const paths: string[] = []
140    for (let i = 0; i < words.length; i += 1) {
141      const word = words[i] ?? ''
142      if (word === '-s' || word === '--source') {
143        i += 1
144      } else if (word === '--') {
145        paths.push(...words.slice(i + 1))
146        break
147      } else if (!word.startsWith('-')) {
148        paths.push(word)
149      }
150    }
151    const isStaged = words.some(word => word === '--staged' || /^-[a-zA-Z]*S/.test(word))
152
153    return { paths, isStagedLost: isStaged }
154  }
155  // checkout [<tree-ish>] [--] <paths>: a tree-ish overwrites the index as well.
156  const dashes = words.indexOf('--')
157  const before = (dashes === -1 ? words : words.slice(0, dashes)).filter(word => !word.startsWith('-'))
158  if (dashes !== -1) {
159    return { paths: words.slice(dashes + 1), isStagedLost: before.length > 0 }
160  }
161
162  return before.length > 1 ? { paths: before.slice(1), isStagedLost: true } : { paths: before, isStagedLost: false }
163}
164
165// The flags and paths of a `git clean`, for the dry run that lists what it removes.
166export function cleanDryRun(args: string): string[] {
167  const words = tokenize(args)
168  const flags: string[] = []
169  const paths: string[] = []
170  for (let i = 0; i < words.length; i += 1) {
171    const word = words[i] ?? ''
172    if (word === '--') {
173      paths.push(...words.slice(i + 1))
174      break
175    }
176    if (word === '-e' || word === '--exclude') {
177      flags.push(word, words[i + 1] ?? '')
178      i += 1
179    } else if (word.startsWith('--exclude=') || /^-e./.test(word)) {
180      flags.push(word)
181    } else if (/^-[a-zA-Z]+$/.test(word)) {
182      // -n is added by the caller; -f, -i and -q would change the dry run.
183      const kept = word.replace(/[finq]/g, '')
184      if (kept !== '-') {
185        flags.push(kept)
186      }
187    } else if (!word.startsWith('-')) {
188      paths.push(word)
189    }
190  }
191
192  return ['clean', '-n', ...flags, '--', ...paths]
193}
194
195// Operations that lose work whatever the worktree looks like.
196export function alwaysLoses(call: GitCall): string | undefined {
197  if (call.sub === 'stash' && /^\s*(?:drop|clear)\b/.test(call.args)) {
198    return `git stash ${call.args.trim().split(/\s+/)[0]}`
199  }
200
201  return undefined
202}
203
204// The stash a `git stash drop` names, or undefined for `clear` (all of them).
205export function droppedStash(call: GitCall): string | undefined {
206  const [verb, ref] = tokenize(call.args)
207
208  return verb === 'drop' ? (ref ?? 'stash@{0}') : undefined
209}
210
211export function skipsHooks(call: GitCall): boolean {
212  if (call.sub === 'commit') {
213    return /(?:^|\s)(?:--no-verify|-n)(?=\s|$)/.test(call.args)
214  }
215  // `git push -n` is --dry-run, so only the long form counts there.
216  return call.sub === 'push' && /(?:^|\s)--no-verify(?=\s|$)/.test(call.args)
217}
218
219// --- rm and migrations ------------------------------------------------------------------
220
221// Heredoc bodies are data (a commit message that mentions rm -rf), not commands.
222function stripHeredocs(command: string): string {
223  return command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2(?=[ \t]*(?:\n|$|\)))/g, match =>
224    match.slice(0, match.indexOf('\n')),
225  )
226}
227
228// Splits a command line at ; && || | & and newlines outside quotes.
229export function segments(command: string): string[] {
230  const text = stripHeredocs(command)
231  const out: string[] = []
232  let current = ''
233  let quote: string | undefined
234  for (let i = 0; i < text.length; i += 1) {
235    const c = text[i] ?? ''
236    if (quote) {
237      current += c
238      if (c === '\\' && quote === '"') {
239        current += text[(i += 1)] ?? ''
240      } else if (c === quote) {
241        quote = undefined
242      }
243      continue
244    }
245    if (c === '"' || c === "'") {
246      quote = c
247      current += c
248    } else if (c === '\\') {
249      current += c + (text[(i += 1)] ?? '')
250    } else if (c === '&' && (text[i - 1] === '>' || text[i + 1] === '>')) {
251      current += c // 2>&1, &> file
252    } else if (c === ';' || c === '\n' || c === '|' || c === '&') {
253      out.push(current)
254      current = ''
255      if ((c === '|' || c === '&') && text[i + 1] === c) {
256        i += 1
257      }
258    } else {
259      current += c
260    }
261  }
262  out.push(current)
263
264  return out.filter(segment => segment.trim() !== '')
265}
266
267// Words that can come before the real command without changing what it does.
268const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
269// sudo options that take a value, so the value isn't read as the command.
270const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
271// Commands that only read, so a bare word "migrate" in them isn't a migration.
272const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'cd', 'git'])
273
274// The folder a later `cd arg` moves to, given the folder so far (undefined: the session's).
275function joinDir(dir: string | undefined, arg: string | undefined): string {
276  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
277    return arg ?? '~'
278  }
279
280  return dir ? `${dir}/${arg}` : arg
281}
282
283// The command's own words: leading VAR=value, sudo and harmless prefixes dropped.
284function commandWords(segment: string): string[] {
285  const words = tokenize(segment.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
286  const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
287  while (words.length > 0 && isAssignment(words[0] ?? '')) {
288    words.shift()
289  }
290  if (words[0] === 'sudo') {
291    words.shift()
292    while (words[0]?.startsWith('-')) {
293      if (SUDO_VALUE_OPTIONS.has(words.shift() ?? '')) {
294        words.shift()
295      }
296    }
297  }
298  while (words.length > 0 && (PREFIXES.has(words[0] ?? '') || isAssignment(words[0] ?? ''))) {
299    words.shift()
300  }
301  if (words[0] === 'nice') {
302    words.shift()
303    const option = words.at(0) ?? ''
304    if (option === '-n') {
305      words.splice(0, 2)
306    } else if (/^-\d+$/.test(option)) {
307      words.shift()
308    }
309  }
310
311  return words
312}
313
314function migration(cmd: string, args: string[], dir: string | undefined): Risk | undefined {
315  const joined = [cmd, ...args].join(' ')
316  const at = dir ? { dir } : {}
317  if (/\bprisma\s+migrate\s+(?:status|diff)\b/.test(joined) || /\bdb:migrate:status\b/.test(joined)) {
318    return undefined
319  }
320  if (/\balembic\s+upgrade\b/.test(joined)) {
321    return { rule: 'migrate', label: 'alembic upgrade', tool: 'alembic', ...at }
322  }
323  if (/\bdb:migrate\b/.test(joined)) {
324    return { rule: 'migrate', label: 'db:migrate', tool: 'rails', ...at }
325  }
326  if (/\bprisma\s+migrate\s+(?:dev|deploy)\b/.test(joined)) {
327    return { rule: 'migrate', label: 'prisma migrate', tool: 'prisma', ...at }
328  }
329  if (/\bmanage\.py\s+migrate\b/.test(joined)) {
330    return { rule: 'migrate', label: 'manage.py migrate', tool: 'django', ...at }
331  }
332  if (!READ_ONLY.has(cmd) && args.includes('migrate')) {
333    return { rule: 'migrate', label: joined.slice(0, 60), tool: 'unknown', ...at }
334  }
335
336  return undefined
337}
338
339// The rm -r/-f calls and migrations in a command line, each with the folder it runs in as
340// far as `cd`, `pushd` and `popd` earlier on the line move it.
341export function shellRisks(command: string): Risk[] {
342  const risks: Risk[] = []
343  let dir: string | undefined
344  const scopes: (string | undefined)[] = [] // dir to restore when a ( subshell ) closes
345  const pushed: (string | undefined)[] = [] // pushd stack, for popd
346  for (const raw of segments(command)) {
347    const opens = /^\s*\(+/.exec(raw)?.[0].trim().length ?? 0
348    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
349    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+)+\s*$/, '')
350    const closes = /\)+\s*$/.exec(tail)?.[0].trim().length ?? 0
351    for (let k = 0; k < opens; k += 1) {
352      scopes.push(dir)
353    }
354
355    const [first, ...args] = commandWords(raw)
356    const cmd = (first ?? '').replace(/^\\/, '') // \rm skips aliases; it's still rm
357    if (cmd === 'cd') {
358      dir = args[0] === '-' ? '-' : joinDir(dir, args[0])
359    } else if (cmd === 'pushd') {
360      pushed.push(dir)
361      dir = joinDir(dir, args[0])
362    } else if (cmd === 'popd') {
363      dir = pushed.length > 0 ? pushed.pop() : '-'
364    } else if (cmd === 'rm' || cmd.endsWith('/rm')) {
365      const dashes = args.indexOf('--')
366      const options = (dashes === -1 ? args : args.slice(0, dashes)).filter(arg => arg.startsWith('-'))
367      const isRecursive = options.some(flag => flag === '--recursive' || /^-[a-zA-Z]*[rR]/.test(flag))
368      const isForced = options.some(flag => flag === '--force' || /^-[a-zA-Z]*f/.test(flag))
369      if (isRecursive || isForced) {
370        const targets = dashes === -1
371          ? args.filter(arg => !arg.startsWith('-'))
372          : [...args.slice(0, dashes).filter(arg => !arg.startsWith('-')), ...args.slice(dashes + 1)]
373        risks.push({ rule: 'rm', label: `rm ${options.join(' ')}`, targets, ...(dir ? { dir } : {}) })
374      }
375    } else if (cmd !== '') {
376      const risk = migration(cmd, args, dir)
377      if (risk) {
378        risks.push(risk)
379      }
380    }
381
382    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
383      dir = scopes.pop() // a cd inside ( ... ) doesn't outlive it
384    }
385  }
386
387  return risks
388}
389
390// --- commit messages ------------------------------------------------------------------
391
392const ATTRIBUTION = [
393  /^[ \t]*Co-Authored-By:[^\n]*(?:Claude|anthropic\.com)[^\n]*\n?/gim,
394  /^[^\n]*Generated with \[?Claude Code[^\n]*\n?/gim,
395]
396
397// Removes Claude's co-author trailer and "Generated with Claude Code" line from a command.
398export function stripAttribution(command: string): { command: string; isChanged: boolean } {
399  let out = command
400  for (const pattern of ATTRIBUTION) {
401    out = out.replace(pattern, '')
402  }
403  // Lines removed at the end of a message leave blank lines before the closing quote or EOF.
404  out = out.replace(/\n{2,}(\s*(?:EOF|["']))/g, '\n$1')
405
406  return { command: out, isChanged: out !== command }
407}
408
409// The first line of the message a `git commit` gives inline, or undefined when it gives none
410// this can read (an editor, -F, --no-edit, -C).
411export function commitSubject(command: string, args: string): string | undefined {
412  if (/(?:^|\s)(?:-F|--file|-C|-c|--reuse-message|--reedit-message|--no-edit|--fixup|--squash)\b/.test(args)) {
413    return undefined
414  }
415  // Read from this commit on: a heredoc or a `-m` before it (a script, `python3 -m`) is not its message.
416  const tail = commitTail(command, args)
417  // `-m` may close a cluster of short flags (`-am`, `-qam`): the word after it is still the message.
418  const heredoc = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:=|\s+)["']?\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?[^\n]*\n([\s\S]*?)\n\s*\1\b/.exec(tail)
419  if (heredoc) {
420    return firstLine(heredoc[2] ?? '')
421  }
422  const inline = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/.exec(tail)
423  if (!inline) {
424    return undefined
425  }
426
427  return firstLine(inline[1] ?? inline[2] ?? inline[3] ?? '')
428}
429
430const COMMIT_CALL =
431  /(?:^|[;&|(\n`]|\$\()\s*(?:[A-Z_][A-Z0-9_]*=\S*\s+)*git(?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|\s+--[a-z-]+(?:=\S+)?)*\s+commit/g
432
433// The command line from the `git commit` whose args are `args` on, past the `;&|` that end `args`.
434function commitTail(command: string, args: string): string {
435  for (const match of command.matchAll(COMMIT_CALL)) {
436    const rest = command.slice(match.index + match[0].length)
437    if (rest.startsWith(args)) {
438      return rest
439    }
440  }
441
442  return args
443}
444
445function firstLine(text: string): string | undefined {
446  return text.split('\n').map(line => line.trim()).find(line => line !== '')
447}
448
449// `<emoji> type(scope)!: description`, emoji and scope optional, as the commit skill writes them.
450const CONVENTIONAL = /^(?:\S+\s+)?[a-z]+(?:\([^)]+\))?!?: \S/
451
452const EXEMPT_SUBJECT = /^(?:Merge |Revert "|fixup! |squash! |amend! |Initial commit)/
453
454// `pattern`: the setting `commit_pattern`, in place of Conventional Commits.
455export function badSubject(subject: string, pattern: RegExp = CONVENTIONAL): boolean {
456  return !EXEMPT_SUBJECT.test(subject) && !pattern.test(subject)
457}
458
hooks/config.ts 792 lines
1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23  return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28  return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33  return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45  category: Category
46  type: 'boolean' | 'number' | 'string' | 'list'
47  default: Value
48  repo: RepoRule
49  description: string | Text
50  min?: number
51  max?: number
52  // The values a string may take.
53  choices?: readonly string[]
54  // A feature `/<mod> on|off` switches: the value each one sets.
55  toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63  category: 'plain',
64  type: 'string',
65  default: 'en',
66  repo: 'never',
67  choices: ['en', 'zh-TW'],
68  description: {
69    en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70    'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71  },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77  // The language the values come to (`language`, English without it).
78  lang: Lang
79  values: Readonly<Record<string, Value>>
80  sources: Readonly<Record<string, Layer>>
81  ignored: readonly Ignored[]
82  // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83  until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91  options: PluginOptions
92  user: Raw
93  repo: Raw
94  local: Raw
95  session: Raw
96  // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97  until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115  default: { en: 'default', 'zh-TW': '預設' },
116  user: { en: 'user', 'zh-TW': '使用者' },
117  repo: { en: 'repo', 'zh-TW': 'repo' },
118  local: { en: 'local', 'zh-TW': 'local' },
119  session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123  protect: { en: 'protection', 'zh-TW': '保護' },
124  cost: { en: 'cost', 'zh-TW': '花費' },
125  plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129  any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130  'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131  'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132  'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133  'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134  append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135  never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139  return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143  return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150  switch (spec.type) {
151    case 'boolean':
152      return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153    case 'number': {
154      if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155        return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156      }
157      if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158        const min = spec.min ?? '-∞'
159        const max = spec.max ?? '∞'
160
161        return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162      }
163
164      return { value: raw }
165    }
166    case 'string':
167      if (typeof raw !== 'string') {
168        return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169      }
170      if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171        return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172      }
173
174      return { value: raw }
175    case 'list':
176      return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177        ? { value: raw as readonly string[] }
178        : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179  }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185  const t = text.trim()
186  switch (spec.type) {
187    case 'boolean': {
188      const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189      const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191      return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192    }
193    case 'number':
194      return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195    case 'string':
196      return parseValue(spec, t, lang)
197    case 'list': {
198      if (t.startsWith('[')) {
199        try {
200          return parseValue(spec, JSON.parse(t), lang)
201        } catch {
202          return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203        }
204      }
205
206      return { value: t === '' ? [] : [t] }
207    }
208  }
209}
210
211function same(a: Value, b: Value): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217  // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218  if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219    return { value }
220  }
221  switch (spec.repo) {
222    case 'only-true':
223      return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224    case 'only-false':
225      return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226    case 'only-lower':
227      return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228    case 'only-higher':
229      return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230    case 'append':
231      return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232    case 'never':
233      return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234  }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240  return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244  let value = spec.default
245  let source: Layer = 'default'
246  const take = (layer: Layer, raw: unknown) => {
247    if (raw === undefined) {
248      return
249    }
250    const parsed = parseValue(spec, raw, lang)
251    if ('error' in parsed) {
252      ignored.push({ key, layer, reason: parsed.error })
253
254      return
255    }
256    const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257    if ('error' in applied) {
258      ignored.push({ key, layer, reason: applied.error })
259
260      return
261    }
262    if (layer === 'user' && same(applied.value, spec.default)) {
263      // `/config` hands over defaults filled in: the same value is no choice of the person's.
264      return
265    }
266    value = applied.value
267    source = layer
268  }
269  // Lists have no `/config` row; their user layer is the file.
270  take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271  take('repo', layers.repo[key])
272  take('local', layers.local[key])
273  take('session', layers.session[key])
274
275  return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279  const values: Record<string, Value> = {}
280  const sources: Record<string, Layer> = {}
281  const ignored: Ignored[] = []
282  // The language first: the other keys' reasons are worded in it.
283  const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284  const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285  for (const [key, spec] of Object.entries(specs)) {
286    const one = resolveKey(key, spec, layers, lang, ignored)
287    values[key] = one.value
288    sources[key] = one.source
289  }
290  for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291    for (const key of Object.keys(layers[layer])) {
292      const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293      if (spec === undefined) {
294        ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295      } else if (layer === 'user' && !isFileValue(key, spec)) {
296        ignored.push({
297          key,
298          layer,
299          reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300        })
301      }
302    }
303  }
304
305  const until: Record<string, number> = {}
306  for (const [key, at] of Object.entries(layers.until ?? {})) {
307    if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308      until[key] = at
309    }
310  }
311
312  return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317  const t = text.trim().toLowerCase()
318  const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319  if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320    return undefined
321  }
322  const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324  return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329  const minutes = Math.ceil(ms / 60_000)
330  if (ms < 60_000) {
331    return '<1m'
332  }
333  const h = Math.floor(minutes / 60)
334  const m = minutes % 60
335
336  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341  const parts = Object.entries(until)
342    .filter(([, at]) => at > now)
343    .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345  return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350  const times = Object.values(resolved.until)
351
352  return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356  return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360  const lang = resolved.lang
361  const keys = Object.keys(specs)
362  if (keys.length === 0) {
363    return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364  }
365  const lines = [
366    pick(lang, {
367      en: `${mod} settings (precedence: session > local > repo > user > default)`,
368      'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369    }),
370  ]
371  for (const key of keys) {
372    const spec = specs[key] as KeySpec
373    const value = resolved.values[key] as Value
374    const source = resolved.sources[key] as Layer
375    const at = resolved.until[key]
376    const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377    const kind = pick(lang, CATEGORY_NAMES[spec.category])
378    const rule = pick(lang, RULE_NAMES[spec.repo])
379    lines.push(`  ${key} = ${show(value)}  [${pick(lang, LAYER_NAMES[source])}${left}]  ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380    lines.push(`    ${say(lang, spec.description)}`)
381  }
382  if (resolved.ignored.length > 0) {
383    lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384    for (const item of resolved.ignored) {
385      const layer = pick(lang, LAYER_NAMES[item.layer])
386      lines.push(pick(lang, { en: `  ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': `  ${layer} 的 ${item.key}:${item.reason}` }))
387    }
388  }
389  lines.push(
390    pick(lang, {
391      en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392      'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393    }),
394  )
395  const toggles = toggleKeys(specs)
396  if (toggles.length > 0) {
397    const names = listOf(lang, toggles)
398    const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399    lines.push(
400      pick(lang, {
401        en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402        'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403      }),
404    )
405  }
406
407  return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411  if (text.trim() === '') {
412    return {}
413  }
414  const parsed: unknown = JSON.parse(text)
415  const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417  return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423  const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424  const section = { ...modSection(text, mod) }
425  if (value === undefined) {
426    delete section[key]
427  } else {
428    section[key] = value
429  }
430  if (Object.keys(section).length === 0) {
431    delete all[mod]
432  } else {
433    all[mod] = section
434  }
435
436  return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440  const line = `/${LOCAL_FILE}`
441  if (text.split('\n').some(l => l.trim() === line)) {
442    return undefined
443  }
444
445  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452  const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453  if (targets.length > 1) {
454    return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455  }
456  const text = args.replace(TARGET_FLAG, '$1').trim()
457  const key = text.split(/\s+/)[0] ?? ''
458  if (key === '') {
459    return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460  }
461
462  return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470  // The file's text, '' when it is missing.
471  read: (path: string) => Promise<string>
472  write: (path: string, text: string) => Promise<void>
473  isDir: (path: string) => Promise<boolean>
474  // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475  configDir: () => Promise<string>
476  root: () => Promise<string>
477  // The main working tree, whose `.git/info/exclude` covers every worktree.
478  repoRoot: () => Promise<string | undefined>
479  sessionId: () => Promise<string>
480  now: () => Promise<number>
481  storeGet: (key: string) => Promise<unknown>
482  storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490  try {
491    const own = modSection(text, mod)
492    const shared = modSection(text, SHARED)
493    for (const key of Object.keys(shared)) {
494      if (key !== 'language') {
495        broken.push({
496          key: `${SHARED}.${key}`,
497          layer,
498          reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499        })
500      }
501    }
502
503    return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504  } catch {
505    broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507    return {}
508  }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517  const now = await io.now()
518  const kept: Record<string, T & { written: number }> = {}
519  for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520    if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521      kept[other] = stored
522    }
523  }
524  kept[id] = { ...entry, written: now }
525  await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529  const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531  return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536  const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537  if (stored === undefined) {
538    return { values: {}, until: {} }
539  }
540  const timed = Object.entries(stored.until ?? {})
541  // Read only when needed: a mod without features to switch never asks the clock.
542  const now = timed.length === 0 ? 0 : await io.now()
543  const values: Record<string, unknown> = { ...stored.values }
544  const until: Record<string, number> = {}
545  for (const [key, at] of timed) {
546    if (at > now) {
547      until[key] = at
548    } else {
549      delete values[key]
550    }
551  }
552
553  return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557  const current = await sessionValues(io)
558  const values: Record<string, unknown> = { ...current.values }
559  const times = { ...current.until }
560  delete values[key]
561  delete times[key]
562  if (value !== undefined) {
563    values[key] = value
564  }
565  if (value !== undefined && until !== undefined) {
566    times[key] = until
567  }
568  await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573  const sessionId = await io.sessionId()
574  const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575  if (keys.includes(key)) {
576    return false
577  }
578  await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580  return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584  const root = (await io.root()).replace(/\/$/, '')
585
586  return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590  const paths = await filePaths(io)
591  const broken: Problem[] = []
592  const session = await sessionValues(io)
593  const layers = {
594    options,
595    user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596    repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597    local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598    session: session.values,
599    until: session.until,
600  }
601
602  return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606  const { layers, broken } = await loadLayers(io, mod, options)
607  const resolved = resolve(specs, layers)
608
609  const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611  return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615  if (target === 'session') {
616    await writeSession(io, key, value, undefined)
617
618    return
619  }
620  const paths = await filePaths(io)
621  const path = target === 'repo' ? paths.repo : paths.local
622  await io.write(path, withSetting(await io.read(path), mod, key, value))
623  if (target === 'local') {
624    const repo = await io.repoRoot()
625    if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626      const exclude = `${repo}/.git/info/exclude`
627      const added = withLocalExclude(await io.read(exclude))
628      if (added !== undefined) {
629        await io.write(exclude, added)
630      }
631    }
632  }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638  io: ConfigIO,
639  mod: string,
640  specs: Specs,
641  options: PluginOptions,
642  args: string,
643  reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645  const sub = args.trim().split(/\s+/)[0] ?? ''
646  const rest = args.trim().slice(sub.length)
647  if (sub === 'status') {
648    const resolved = await loadConfig(io, mod, specs, options)
649    reload(resolved)
650
651    return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652  }
653  if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654    return switchFeature(io, mod, specs, options, sub, rest, reload)
655  }
656  if (sub !== 'set' && sub !== 'unset') {
657    return undefined
658  }
659  const before = await loadLayers(io, mod, options)
660  const lang = resolve(specs, before.layers).lang
661  const parsed = parseSetArgs(rest, lang)
662  if ('error' in parsed) {
663    return parsed.error
664  }
665  const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666  if (spec === undefined) {
667    return pick(lang, {
668      en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669      'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670    })
671  }
672  let value: Value | undefined
673  if (sub === 'set') {
674    const input = parseInput(spec, parsed.rest, lang)
675    if ('error' in input) {
676      return `${parsed.key} ${input.error}`
677    }
678    value = input.value
679  }
680  if (parsed.target === 'repo' && value !== undefined) {
681    // Do not commit a value every reader would ignore.
682    const { layers } = before
683    const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684    const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685    if (refused !== undefined) {
686      return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687    }
688  }
689  await writeSetting(io, mod, parsed.key, value, parsed.target)
690  const resolved = await loadConfig(io, mod, specs, options)
691  reload(resolved)
692  const shown = show(resolved.values[parsed.key] as Value)
693  const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694  const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695  const where =
696    parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697  const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699  return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703  return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708  const toggles = toggleKeys(specs)
709  let key: string | undefined
710  let ms: number | undefined
711  for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712    if (key === undefined && toggles.includes(word)) {
713      key = word
714      continue
715    }
716    const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717    if (duration === undefined) {
718      const names = listOf(lang, toggles)
719      const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721      return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722    }
723    ms = duration
724  }
725  if (key === undefined && toggles.length === 1) {
726    key = toggles[0]
727  }
728  if (key === undefined) {
729    return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730  }
731
732  return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736  io: ConfigIO,
737  mod: string,
738  specs: Specs,
739  options: PluginOptions,
740  sub: 'on' | 'off',
741  args: string,
742  reload: (resolved: Resolved) => void,
743): Promise<string> {
744  const { layers } = await loadLayers(io, mod, options)
745  const lang = resolve(specs, layers).lang
746  const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747  if ('error' in parsed) {
748    return parsed.error
749  }
750  const { key, ms } = parsed
751  const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752  const rest: Record<string, unknown> = { ...layers.session }
753  delete rest[key]
754  const below = resolve(specs, { ...layers, session: rest })
755  const belowValue = below.values[key] as Value
756  const isOnBelow = !same(belowValue, toggle.off)
757  const now = await io.now()
758  const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759  if (sub === 'on') {
760    if (isOnBelow) {
761      const wasOff = Object.hasOwn(layers.session, key)
762      await writeSession(io, key, undefined, undefined)
763      reload(await loadConfig(io, mod, specs, options))
764      const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766      return wasOff
767        ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768        : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769    }
770    await writeSession(io, key, toggle.on, now + ms)
771    reload(await loadConfig(io, mod, specs, options))
772    const at = new Date(now + ms)
773    const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774    const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776    return pick(lang, {
777      en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778      'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779    })
780  }
781  // Off for the rest of the session; nothing to keep when the layers below are off already.
782  await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783  reload(await loadConfig(io, mod, specs, options))
784
785  return isOnBelow
786    ? pick(lang, {
787        en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788        'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789      })
790    : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792
hooks/ui.tsx 114 lines
1// The parts every mod's screens share, as DESIGN.md sets them out. Mods cannot import outside their
2// own folder, so each mod that draws a pane or a band keeps a copy; scripts/check-copies.sh checks
3// they match.
4import type { Elements, RenderNode } from 'claude-code'
5
6import { pick, type Lang } from './config'
7
8type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>
9type Words = { en: string; 'zh-TW': string }
10
11// What a color means: yellow waits on the user, red is an error, an interruption or a risk.
12export const TONE = { attention: 'yellow', danger: 'red' } as const
13
14// A card, and the yellow frame of a pane that holds Claude until the user answers.
15export const CARD = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
16export const CONFIRM = { borderStyle: 'round', borderColor: TONE.attention, paddingX: 1 } as const
17
18// Where a part of a bar comes from: `ink` on the terminal, `fill` in desktop's Svg. Main session
19// purple, subagent cyan, named model yellow, default model gray, fork green.
20export const COLORS = {
21  main: { ink: 'magenta', fill: '#8a5cc7' },
22  sub: { ink: 'cyan', fill: '#2f8fb0' },
23  named: { ink: 'yellow', fill: '#c98a12' },
24  default: { ink: 'gray', fill: '#8b8b93' },
25  fork: { ink: 'green', fill: '#3f9a4f' },
26} as const
27
28// The main session's parts (its models), one after another in the same bar.
29export const MAIN_SHADES = [COLORS.main, { ink: 'magentaBright', fill: '#b48ee6' }, { ink: 'magenta', fill: '#6b3fa8' }]
30
31export type Option = { value: string; label: string }
32
33// Switches in place, so nothing below moves (a Select pushes it down when it opens): in the
34// terminal one button that steps to the next value (its hotkey too) and wraps, elsewhere a segment
35// per value with the hotkey on the one after the current, so it steps forward the same way.
36export function switcher(
37  { Box, Button, Text }: Kit,
38  surface: string,
39  key: string,
40  label: string,
41  hotkey: string,
42  options: Option[],
43  value: string,
44  onPick: (value: string) => void,
45): RenderNode {
46  const at = Math.max(0, options.findIndex(option => option.value === value))
47  if (surface === 'terminal') {
48    const next = options[(at + 1) % options.length]!.value
49
50    return (
51      <Box key={`switch:${key}`} flexDirection="row" flexShrink={0}>
52        {label !== '' && <Text dimColor>{label}</Text>}
53        <Button key={key} plain hotkey={hotkey} onPress={() => onPick(next)}>
54          <Text bold>‹ {options[at]?.label ?? value} ›</Text>
55        </Button>
56      </Box>
57    )
58  }
59
60  return (
61    <Box key={`switch:${key}`} flexDirection="row" gap={1} flexShrink={0}>
62      {label !== '' && <Text dimColor>{label}</Text>}
63      {options.map((option, i) => (
64        <Button
65          key={`${key}:${option.value}`}
66          label={option.label}
67          {...(i === (at + 1) % options.length ? { hotkey } : {})}
68          {...(i === at ? { variant: 'primary' as const } : { dimColor: true })}
69          onPress={() => onPick(option.value)}
70        />
71      ))}
72    </Box>
73  )
74}
75
76// A pane's last line: each key and what it does, then how to close it unless `closes` is false
77// (a pane that waits for an answer). The key keeps the default color and the rest is dim, so the
78// eye finds the keys first: the first word ("d done"), or the key in brackets ("‹ Back (b) returns").
79export function hintLine({ Box, Text }: Kit, lang: Lang, items: Words[], closes = true): RenderNode {
80  const all = closes ? [...items, { en: 'Esc closes', 'zh-TW': 'Esc 關閉' }] : items
81  const parts = all.flatMap((item, i) => {
82    const text = pick(lang, item)
83    const m = /^(.*?[((])(\S)([))].*)$/.exec(text) ?? (text.startsWith('‹') ? null : /^()(\S+)(.*)$/.exec(text))
84    const sep = i === 0 ? [] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>]
85    if (m === null) {
86      return [...sep, <Text key={`all${i}`} dimColor>{text}</Text>]
87    }
88
89    return [
90      ...sep,
91      ...(m[1] === '' ? [] : [<Text key={`pre${i}`} dimColor>{m[1]}</Text>]),
92      <Text key={`key${i}`}>{m[2]}</Text>,
93      <Text key={`does${i}`} dimColor>{m[3]}</Text>,
94    ]
95  })
96
97  return (
98    <Box key="hint" marginTop={1}>
99      <Text wrap="wrap">{parts}</Text>
100    </Box>
101  )
102}
103
104// What a pane shows when it has nothing to list; under a header, the caller sets it a line apart.
105export function emptyState({ Box, Text }: Kit, text: string): RenderNode {
106  return (
107    <Box key="empty">
108      <Text dimColor wrap="wrap">
109        {text}
110      </Text>
111    </Box>
112  )
113}
114