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…

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.
rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.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./clear, and with project-memory on, what you learn in one session is waiting in the next.| Mod | What it does for you |
|---|---|
secret-scrub | Masks 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-guard | Holds 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-trimmer | When 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-dispatch | Brings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn. |
open-items | Keeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press. |
session-keeper | Shows 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-check | Asks 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-memory | Once 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. |
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.
MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.
hooks/register.tsx 781 lines1import 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}
781hooks/preview.ts 143 lines1// 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}
143hooks/rules.ts 458 lines1// 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}
458hooks/config.ts 792 lines1// 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}
792hooks/ui.tsx 114 lines1// 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