SLOPSHOPPER

secret-scrub

Masks tokens, API keys and other secrets before a prompt is sent, so they stay out of the transcript and the prompt cache

newcommandtoastpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-scrub
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-scrub ⎿ secret-scrub: Usage: /secret-scrub history, /secret-scrub status, /secret-scrub set <key> <value> [--session|--local|--repo] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-mod-trimtab

English | 繁體中文

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

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

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

The mods

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

Quick start

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

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

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

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

Documentation

License

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

Source 4 files
hooks/register.ts 345 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { configCommand, LANGUAGE, loadConfig, pick, resolve, type ConfigIO, type Lang, type Resolved, type Specs } from './config'
4import { scrubHistory } from './history'
5import { bypassPattern, compilePatterns, scrub, summarize, type ScrubOptions } from './scrub'
6
7const MOD = 'secret-scrub'
8const HISTORY = 'history'
9
10export const SPECS: Specs = {
11  // Not from the repo file: a committed pattern that backtracks without end would hang every prompt.
12  custom_rules: {
13    category: 'protect',
14    type: 'list',
15    default: [],
16    repo: 'never',
17    description: {
18      en: 'Extra regular expressions to mask; a match is replaced whole by the mask; only in the user settings file or the local file',
19      'zh-TW': '另外要遮蔽的正規表示式,符合的整段換成遮蔽標記;只能放在使用者設定檔或 local 檔案',
20    },
21  },
22  allow: {
23    category: 'protect',
24    type: 'list',
25    default: [],
26    repo: 'never',
27    description: {
28      en: 'Values not to mask (regular expressions that must match the whole value, e.g. a fake test token); only in the user settings file or the local file',
29      'zh-TW': '不遮蔽的值(正規表示式,要整段符合,例如測試用的假 token);只能放在使用者設定檔或 local 檔案',
30    },
31  },
32  bypass_marker: {
33    category: 'protect',
34    type: 'string',
35    default: '#noscrub',
36    repo: 'never',
37    description: {
38      en: 'A prompt containing this marker is sent as typed (the marker itself is removed)',
39      'zh-TW': 'prompt 含這個標記時原樣送出(標記本身會移除)',
40    },
41  },
42  language: LANGUAGE,
43}
44
45// The settings, compiled once per change.
46type Scrubbing = { bypass: RegExp; marker: string; options: ScrubOptions; bad: string[]; lang: Lang }
47
48export function scrubbingOf(resolved: Resolved): Scrubbing {
49  const marker = (resolved.values.bypass_marker as string).trim() || '#noscrub'
50  const rules = compilePatterns(resolved.values.custom_rules as readonly string[], 'g')
51  // Whole values only: a short pattern must not let through a real secret that contains it.
52  const allow = compilePatterns((resolved.values.allow as readonly string[]).map(pattern => `^(?:${pattern})$`), '')
53
54  return {
55    bypass: bypassPattern(marker),
56    marker,
57    options: { rules: rules.patterns, allow: allow.patterns },
58    bad: [...rules.bad, ...allow.bad],
59    lang: resolved.lang,
60  }
61}
62
63// Claude Code appends to history.jsonl shortly after a prompt is submitted; rewrite after that.
64const REWRITE_DELAY_MS = 3000
65// `$.fs.read` refuses larger files.
66const MAX_READ = 4 * 1024 * 1024
67const LOCK_TRIES = 10
68const LOCK_WAIT_MS = 100
69
70async function configDir($: EngineInterface): Promise<string> {
71  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
72  if (dir !== undefined && dir !== '') {
73    return dir.replace(/\/$/, '')
74  }
75
76  return `${await $.env.get('HOME')}/.claude`
77}
78
79// proper-lockfile's convention (a `<file>.lock` directory), so an append that takes the same lock
80// waits for the rewrite; mkdir fails while someone else holds it.
81async function withLock<T>($: EngineInterface, lang: Lang, path: string, fn: () => Promise<T>): Promise<T> {
82  const lock = `${path}.lock`
83  for (let i = 0; ; i++) {
84    if ((await $.process.run(['mkdir', lock], { timeoutMs: 3000 })).exitCode === 0) {
85      break
86    }
87    if (i + 1 >= LOCK_TRIES) {
88      throw new Error(pick(lang, { en: `${lock} is held by another process`, 'zh-TW': `${lock} 被占用` }))
89    }
90    await $.clock.sleep(LOCK_WAIT_MS)
91  }
92  try {
93    return await fn()
94  } finally {
95    await $.process.run(['rmdir', lock], { timeoutMs: 3000 })
96  }
97}
98
99// Writes `text` beside `path` and renames it over, unless `path` changed since it was read; an
100// append landing in between would otherwise be lost. Returns false when it changed.
101async function replaceIfUnchanged(
102  $: EngineInterface,
103  lang: Lang,
104  path: string,
105  seen: { size: number; mtimeMs: number },
106  text: string,
107): Promise<boolean> {
108  const staging = `${path}.secret-scrub-staging`
109  await $.fs.write(staging, text)
110  const now = await $.fs.stat(path)
111  if (now.size !== seen.size || now.mtimeMs !== seen.mtimeMs) {
112    await $.process.run(['rm', '-f', staging], { timeoutMs: 3000 })
113
114    return false
115  }
116  const moved = await $.process.run(['mv', '-f', staging, path], { timeoutMs: 3000 })
117  if (moved.exitCode !== 0) {
118    throw new Error(pick(lang, { en: `mv failed: ${moved.stderr.trim()}`, 'zh-TW': `mv 失敗:${moved.stderr.trim()}` }))
119  }
120
121  return true
122}
123
124// Pastes are shared by hash across sessions; a file is rewritten only when it held a secret.
125async function scrubPastes($: EngineInterface, dir: string, hashes: string[], scrubbing: Scrubbing): Promise<number> {
126  let count = 0
127  for (const hash of hashes) {
128    const path = `${dir}/paste-cache/${hash}.txt`
129    if (!(await $.fs.exists(path))) {
130      continue
131    }
132    const { text, labels } = scrub(await $.fs.read(path), scrubbing.options)
133    if (labels.length > 0) {
134      await $.fs.write(path, text)
135      count += labels.length
136    }
137  }
138
139  return count
140}
141
142// Masks secrets left in history.jsonl (and the pastes it points to): one session's lines, or every
143// line when `sessionId` is undefined. Resolves the number of secrets masked.
144async function scrubHistoryFile($: EngineInterface, scrubbing: Scrubbing, sessionId?: string): Promise<number> {
145  const dir = await configDir($)
146  const path = `${dir}/history.jsonl`
147  if (!(await $.fs.exists(path))) {
148    return 0
149  }
150
151  const lang = scrubbing.lang
152  const masked = await withLock($, lang, path, async () => {
153    for (let attempt = 0; attempt < 3; attempt++) {
154      const seen = await $.fs.stat(path)
155      if (seen.isLink || seen.kind !== 'file') {
156        throw new Error(pick(lang, { en: 'history.jsonl is not a regular file', 'zh-TW': 'history.jsonl 不是一般檔案' }))
157      }
158      if (seen.size > MAX_READ) {
159        throw new Error(pick(lang, { en: 'history.jsonl is over 4 MiB and cannot be read', 'zh-TW': 'history.jsonl 超過 4 MiB,無法讀取' }))
160      }
161      const result = scrubHistory(await $.fs.read(path), sessionId, scrubbing.bypass, scrubbing.options)
162      const pastes = await scrubPastes($, dir, result.pasteHashes, scrubbing)
163      if (result.lines === 0 || (await replaceIfUnchanged($, lang, path, seen, result.text))) {
164        return result.labels.length + pastes
165      }
166    }
167    throw new Error(pick(lang, { en: 'history.jsonl keeps being written to; try again later', 'zh-TW': 'history.jsonl 一直在寫入,稍後再試' }))
168  })
169
170  if (masked > 0) {
171    $.ui.log(`secret-scrub: masked ${masked} secret(s) in history.jsonl`, { to: 'debug' })
172  }
173
174  return masked
175}
176
177async function scrubSessionHistory($: EngineInterface, scrubbing: Scrubbing): Promise<void> {
178  try {
179    await scrubHistoryFile($, scrubbing, await $.session.id())
180  } catch (error) {
181    $.ui.toast(
182      pick(scrubbing.lang, {
183        en: `secret-scrub: the prompt history was not masked (${String(error)}); you can run /${MOD} ${HISTORY}`,
184        'zh-TW': `secret-scrub:輸入框歷史未能遮蔽(${String(error)}),可執行 /${MOD} ${HISTORY}`,
185      }),
186      { timeoutMs: 8000 },
187    )
188  }
189}
190
191// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
192function configIO($: EngineInterface): ConfigIO {
193  return {
194    read: async path => {
195      try {
196        return await $.fs.read(path)
197      } catch {
198        return ''
199      }
200    },
201    write: (path, text) => $.fs.write(path, text),
202    isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
203    configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
204    root: () => $.session.root(),
205    repoRoot: async () => (await $.session.repo())?.root,
206    sessionId: () => $.session.id(),
207    now: () => $.clock.now(),
208    storeGet: key => $.store.get(key),
209    storeSet: (key, value) => $.store.set(key, value),
210  }
211}
212// --- end configIO ---
213
214export const register: Register = (on, options) => {
215  // Re-read at session start and after `/secret-scrub set`; `/config` changes reload the module.
216  let scrubbing = scrubbingOf(resolve(SPECS, { options, user: {}, repo: {}, local: {}, session: {} }))
217  const apply = (resolved: Resolved) => {
218    scrubbing = scrubbingOf(resolved)
219  }
220
221  on('session.start', async ($, e, next) => {
222    try {
223      apply(await loadConfig(configIO($), MOD, SPECS, options))
224    } catch (error) {
225      $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
226    }
227    const lang = scrubbing.lang
228    try {
229      await $.command.register({
230        name: MOD,
231        description: pick(lang, {
232          en: 'Mask secrets that any session left in the prompt history (history.jsonl) (history), or view and change settings',
233          'zh-TW': '遮蔽輸入框歷史(history.jsonl)裡所有 session 留下的密鑰(history),或看、改設定',
234        }),
235        argumentHint: 'history | status | set <key> <value> | unset <key>',
236      })
237    } catch (error) {
238      $.ui.log(`session.start: /${MOD} not registered: ${String(error)}`, { to: 'debug' })
239      $.ui.toast(pick(lang, { en: `${MOD}: /${MOD} was not registered (${String(error)})`, 'zh-TW': `${MOD}:/${MOD} 指令沒有註冊成功(${String(error)})` }))
240    }
241    if (scrubbing.bad.length > 0) {
242      $.ui.toast(
243        pick(lang, {
244          en: `${MOD}: ${scrubbing.bad.length} custom rule(s) are not valid regular expressions and are not used (/${MOD} status)`,
245          'zh-TW': `${MOD}:${scrubbing.bad.length} 條自訂規則不是有效的正規表示式,沒有採用(/${MOD} status)`,
246        }),
247      )
248    }
249
250    return next(e)
251  })
252
253  on('command.run', { command: MOD }, async ($, e) => {
254    if (e.args.trim().split(/\s+/)[0] !== HISTORY) {
255      const text = await configCommand(configIO($), MOD, SPECS, options, e.args, apply)
256      const lang = scrubbing.lang
257      const bad =
258        scrubbing.bad.length > 0
259          ? pick(lang, {
260              en: `\nInvalid regular expressions, not used: ${scrubbing.bad.join(', ')}`,
261              'zh-TW': `\n無效、沒有採用的正規表示式:${scrubbing.bad.join('、')}`,
262            })
263          : ''
264      const usage = pick(lang, {
265        en: `Usage: /${MOD} ${HISTORY}, /${MOD} status, /${MOD} set <key> <value> [--session|--local|--repo], /${MOD} unset <key>`,
266        'zh-TW': `用法:/${MOD} ${HISTORY}、/${MOD} status、/${MOD} set <key> <value> [--session|--local|--repo]、/${MOD} unset <key>`,
267      })
268
269      return { text: text === undefined ? usage : `${text}${bad}` }
270    }
271    const lang = scrubbing.lang
272    try {
273      const masked = await scrubHistoryFile($, scrubbing)
274
275      return {
276        text:
277          masked > 0
278            ? pick(lang, { en: `secret-scrub: masked ${masked} secret(s) in the prompt history.`, 'zh-TW': `secret-scrub:已遮蔽輸入框歷史中的 ${masked} 個密鑰。` })
279            : pick(lang, { en: 'secret-scrub: no secrets found in the prompt history.', 'zh-TW': 'secret-scrub:輸入框歷史中沒有找到密鑰。' }),
280      }
281    } catch (error) {
282      return { text: pick(lang, { en: `secret-scrub: masking the prompt history failed (${String(error)}).`, 'zh-TW': `secret-scrub:遮蔽輸入框歷史失敗(${String(error)})。` }) }
283    }
284  })
285
286  // A prompt whose secrets were masked on the way in still sits in history.jsonl as typed.
287  on('session.end', async ($, e, next) => {
288    try {
289      await scrubHistoryFile($, scrubbing, e.sessionId)
290    } catch (error) {
291      $.ui.log(`secret-scrub: history rewrite failed: ${String(error)}`, { to: 'debug' })
292    }
293
294    return next(e)
295  })
296
297  on('prompt.submit', async ($, e, next) => {
298    if (scrubbing.bypass.test(e.text)) {
299      $.ui.toast(pick(scrubbing.lang, { en: `secret-scrub: this prompt is sent as typed, per ${scrubbing.marker}`, 'zh-TW': `secret-scrub:此 prompt 依 ${scrubbing.marker} 原樣送出` }))
300
301      return next({ ...e, text: e.text.replace(scrubbing.bypass, '$1').trim() })
302    }
303
304    const { text, labels } = scrub(e.text, scrubbing.options)
305    if (labels.length === 0) {
306      return next(e)
307    }
308
309    $.ui.toast(
310      pick(scrubbing.lang, {
311        en: `secret-scrub: masked ${labels.length} secret(s) (${summarize(labels)})`,
312        'zh-TW': `secret-scrub:已遮蔽 ${labels.length} 個密鑰(${summarize(labels)})`,
313      }),
314      { timeoutMs: 8000 },
315    )
316
317    const submitted = await next({ ...e, text })
318    // Nothing may throw past `next`: the catch below would send the prompt again.
319    try {
320      const current = scrubbing
321      $.clock.after(REWRITE_DELAY_MS, () => void scrubSessionHistory($, current))
322    } catch (error) {
323      $.ui.log(`secret-scrub: history rewrite not scheduled: ${String(error)}`, { to: 'debug' })
324    }
325
326    return submitted
327  }).catch(async ($, e, next) => {
328    if (next.called) {
329      return next(e)
330    }
331    // A failing scrubber must not send secrets unchecked: hold the prompt back and put it in the
332    // prompt box again, where #noscrub can send it as typed.
333    if (typeof e.text === 'string' && (e.origin === undefined || e.origin.kind === 'composer')) {
334      await $.prompt.fill({ text: e.text, mode: 'replace' })
335    }
336
337    return {
338      drop: pick(scrubbing.lang, {
339        en: `secret-scrub: the check failed; the prompt was not sent and is back in the prompt box; add ${scrubbing.marker} to send it when you are sure it holds no secret`,
340        'zh-TW': `secret-scrub:檢查失敗,prompt 未送出並已放回輸入框;確定不含密鑰時加上 ${scrubbing.marker} 再送出`,
341      }),
342    }
343  })
344}
345
hooks/config.ts 792 lines
1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23  return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28  return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33  return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45  category: Category
46  type: 'boolean' | 'number' | 'string' | 'list'
47  default: Value
48  repo: RepoRule
49  description: string | Text
50  min?: number
51  max?: number
52  // The values a string may take.
53  choices?: readonly string[]
54  // A feature `/<mod> on|off` switches: the value each one sets.
55  toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63  category: 'plain',
64  type: 'string',
65  default: 'en',
66  repo: 'never',
67  choices: ['en', 'zh-TW'],
68  description: {
69    en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70    'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71  },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77  // The language the values come to (`language`, English without it).
78  lang: Lang
79  values: Readonly<Record<string, Value>>
80  sources: Readonly<Record<string, Layer>>
81  ignored: readonly Ignored[]
82  // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83  until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91  options: PluginOptions
92  user: Raw
93  repo: Raw
94  local: Raw
95  session: Raw
96  // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97  until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115  default: { en: 'default', 'zh-TW': '預設' },
116  user: { en: 'user', 'zh-TW': '使用者' },
117  repo: { en: 'repo', 'zh-TW': 'repo' },
118  local: { en: 'local', 'zh-TW': 'local' },
119  session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123  protect: { en: 'protection', 'zh-TW': '保護' },
124  cost: { en: 'cost', 'zh-TW': '花費' },
125  plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129  any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130  'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131  'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132  'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133  'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134  append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135  never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139  return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143  return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150  switch (spec.type) {
151    case 'boolean':
152      return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153    case 'number': {
154      if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155        return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156      }
157      if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158        const min = spec.min ?? '-∞'
159        const max = spec.max ?? '∞'
160
161        return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162      }
163
164      return { value: raw }
165    }
166    case 'string':
167      if (typeof raw !== 'string') {
168        return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169      }
170      if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171        return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172      }
173
174      return { value: raw }
175    case 'list':
176      return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177        ? { value: raw as readonly string[] }
178        : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179  }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185  const t = text.trim()
186  switch (spec.type) {
187    case 'boolean': {
188      const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189      const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191      return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192    }
193    case 'number':
194      return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195    case 'string':
196      return parseValue(spec, t, lang)
197    case 'list': {
198      if (t.startsWith('[')) {
199        try {
200          return parseValue(spec, JSON.parse(t), lang)
201        } catch {
202          return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203        }
204      }
205
206      return { value: t === '' ? [] : [t] }
207    }
208  }
209}
210
211function same(a: Value, b: Value): boolean {
212  return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217  // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218  if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219    return { value }
220  }
221  switch (spec.repo) {
222    case 'only-true':
223      return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224    case 'only-false':
225      return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226    case 'only-lower':
227      return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228    case 'only-higher':
229      return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230    case 'append':
231      return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232    case 'never':
233      return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234  }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240  return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244  let value = spec.default
245  let source: Layer = 'default'
246  const take = (layer: Layer, raw: unknown) => {
247    if (raw === undefined) {
248      return
249    }
250    const parsed = parseValue(spec, raw, lang)
251    if ('error' in parsed) {
252      ignored.push({ key, layer, reason: parsed.error })
253
254      return
255    }
256    const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257    if ('error' in applied) {
258      ignored.push({ key, layer, reason: applied.error })
259
260      return
261    }
262    if (layer === 'user' && same(applied.value, spec.default)) {
263      // `/config` hands over defaults filled in: the same value is no choice of the person's.
264      return
265    }
266    value = applied.value
267    source = layer
268  }
269  // Lists have no `/config` row; their user layer is the file.
270  take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271  take('repo', layers.repo[key])
272  take('local', layers.local[key])
273  take('session', layers.session[key])
274
275  return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279  const values: Record<string, Value> = {}
280  const sources: Record<string, Layer> = {}
281  const ignored: Ignored[] = []
282  // The language first: the other keys' reasons are worded in it.
283  const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284  const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285  for (const [key, spec] of Object.entries(specs)) {
286    const one = resolveKey(key, spec, layers, lang, ignored)
287    values[key] = one.value
288    sources[key] = one.source
289  }
290  for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291    for (const key of Object.keys(layers[layer])) {
292      const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293      if (spec === undefined) {
294        ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295      } else if (layer === 'user' && !isFileValue(key, spec)) {
296        ignored.push({
297          key,
298          layer,
299          reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300        })
301      }
302    }
303  }
304
305  const until: Record<string, number> = {}
306  for (const [key, at] of Object.entries(layers.until ?? {})) {
307    if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308      until[key] = at
309    }
310  }
311
312  return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317  const t = text.trim().toLowerCase()
318  const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319  if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320    return undefined
321  }
322  const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324  return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329  const minutes = Math.ceil(ms / 60_000)
330  if (ms < 60_000) {
331    return '<1m'
332  }
333  const h = Math.floor(minutes / 60)
334  const m = minutes % 60
335
336  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341  const parts = Object.entries(until)
342    .filter(([, at]) => at > now)
343    .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345  return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350  const times = Object.values(resolved.until)
351
352  return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356  return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360  const lang = resolved.lang
361  const keys = Object.keys(specs)
362  if (keys.length === 0) {
363    return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364  }
365  const lines = [
366    pick(lang, {
367      en: `${mod} settings (precedence: session > local > repo > user > default)`,
368      'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369    }),
370  ]
371  for (const key of keys) {
372    const spec = specs[key] as KeySpec
373    const value = resolved.values[key] as Value
374    const source = resolved.sources[key] as Layer
375    const at = resolved.until[key]
376    const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377    const kind = pick(lang, CATEGORY_NAMES[spec.category])
378    const rule = pick(lang, RULE_NAMES[spec.repo])
379    lines.push(`  ${key} = ${show(value)}  [${pick(lang, LAYER_NAMES[source])}${left}]  ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380    lines.push(`    ${say(lang, spec.description)}`)
381  }
382  if (resolved.ignored.length > 0) {
383    lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384    for (const item of resolved.ignored) {
385      const layer = pick(lang, LAYER_NAMES[item.layer])
386      lines.push(pick(lang, { en: `  ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': `  ${layer} 的 ${item.key}:${item.reason}` }))
387    }
388  }
389  lines.push(
390    pick(lang, {
391      en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392      'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393    }),
394  )
395  const toggles = toggleKeys(specs)
396  if (toggles.length > 0) {
397    const names = listOf(lang, toggles)
398    const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399    lines.push(
400      pick(lang, {
401        en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402        'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403      }),
404    )
405  }
406
407  return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411  if (text.trim() === '') {
412    return {}
413  }
414  const parsed: unknown = JSON.parse(text)
415  const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417  return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422  const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423  const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424  const section = { ...modSection(text, mod) }
425  if (value === undefined) {
426    delete section[key]
427  } else {
428    section[key] = value
429  }
430  if (Object.keys(section).length === 0) {
431    delete all[mod]
432  } else {
433    all[mod] = section
434  }
435
436  return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440  const line = `/${LOCAL_FILE}`
441  if (text.split('\n').some(l => l.trim() === line)) {
442    return undefined
443  }
444
445  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452  const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453  if (targets.length > 1) {
454    return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455  }
456  const text = args.replace(TARGET_FLAG, '$1').trim()
457  const key = text.split(/\s+/)[0] ?? ''
458  if (key === '') {
459    return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460  }
461
462  return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470  // The file's text, '' when it is missing.
471  read: (path: string) => Promise<string>
472  write: (path: string, text: string) => Promise<void>
473  isDir: (path: string) => Promise<boolean>
474  // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475  configDir: () => Promise<string>
476  root: () => Promise<string>
477  // The main working tree, whose `.git/info/exclude` covers every worktree.
478  repoRoot: () => Promise<string | undefined>
479  sessionId: () => Promise<string>
480  now: () => Promise<number>
481  storeGet: (key: string) => Promise<unknown>
482  storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490  try {
491    const own = modSection(text, mod)
492    const shared = modSection(text, SHARED)
493    for (const key of Object.keys(shared)) {
494      if (key !== 'language') {
495        broken.push({
496          key: `${SHARED}.${key}`,
497          layer,
498          reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499        })
500      }
501    }
502
503    return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504  } catch {
505    broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507    return {}
508  }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517  const now = await io.now()
518  const kept: Record<string, T & { written: number }> = {}
519  for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520    if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521      kept[other] = stored
522    }
523  }
524  kept[id] = { ...entry, written: now }
525  await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529  const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531  return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536  const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537  if (stored === undefined) {
538    return { values: {}, until: {} }
539  }
540  const timed = Object.entries(stored.until ?? {})
541  // Read only when needed: a mod without features to switch never asks the clock.
542  const now = timed.length === 0 ? 0 : await io.now()
543  const values: Record<string, unknown> = { ...stored.values }
544  const until: Record<string, number> = {}
545  for (const [key, at] of timed) {
546    if (at > now) {
547      until[key] = at
548    } else {
549      delete values[key]
550    }
551  }
552
553  return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557  const current = await sessionValues(io)
558  const values: Record<string, unknown> = { ...current.values }
559  const times = { ...current.until }
560  delete values[key]
561  delete times[key]
562  if (value !== undefined) {
563    values[key] = value
564  }
565  if (value !== undefined && until !== undefined) {
566    times[key] = until
567  }
568  await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573  const sessionId = await io.sessionId()
574  const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575  if (keys.includes(key)) {
576    return false
577  }
578  await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580  return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584  const root = (await io.root()).replace(/\/$/, '')
585
586  return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590  const paths = await filePaths(io)
591  const broken: Problem[] = []
592  const session = await sessionValues(io)
593  const layers = {
594    options,
595    user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596    repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597    local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598    session: session.values,
599    until: session.until,
600  }
601
602  return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606  const { layers, broken } = await loadLayers(io, mod, options)
607  const resolved = resolve(specs, layers)
608
609  const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611  return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615  if (target === 'session') {
616    await writeSession(io, key, value, undefined)
617
618    return
619  }
620  const paths = await filePaths(io)
621  const path = target === 'repo' ? paths.repo : paths.local
622  await io.write(path, withSetting(await io.read(path), mod, key, value))
623  if (target === 'local') {
624    const repo = await io.repoRoot()
625    if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626      const exclude = `${repo}/.git/info/exclude`
627      const added = withLocalExclude(await io.read(exclude))
628      if (added !== undefined) {
629        await io.write(exclude, added)
630      }
631    }
632  }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638  io: ConfigIO,
639  mod: string,
640  specs: Specs,
641  options: PluginOptions,
642  args: string,
643  reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645  const sub = args.trim().split(/\s+/)[0] ?? ''
646  const rest = args.trim().slice(sub.length)
647  if (sub === 'status') {
648    const resolved = await loadConfig(io, mod, specs, options)
649    reload(resolved)
650
651    return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652  }
653  if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654    return switchFeature(io, mod, specs, options, sub, rest, reload)
655  }
656  if (sub !== 'set' && sub !== 'unset') {
657    return undefined
658  }
659  const before = await loadLayers(io, mod, options)
660  const lang = resolve(specs, before.layers).lang
661  const parsed = parseSetArgs(rest, lang)
662  if ('error' in parsed) {
663    return parsed.error
664  }
665  const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666  if (spec === undefined) {
667    return pick(lang, {
668      en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669      'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670    })
671  }
672  let value: Value | undefined
673  if (sub === 'set') {
674    const input = parseInput(spec, parsed.rest, lang)
675    if ('error' in input) {
676      return `${parsed.key} ${input.error}`
677    }
678    value = input.value
679  }
680  if (parsed.target === 'repo' && value !== undefined) {
681    // Do not commit a value every reader would ignore.
682    const { layers } = before
683    const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684    const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685    if (refused !== undefined) {
686      return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687    }
688  }
689  await writeSetting(io, mod, parsed.key, value, parsed.target)
690  const resolved = await loadConfig(io, mod, specs, options)
691  reload(resolved)
692  const shown = show(resolved.values[parsed.key] as Value)
693  const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694  const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695  const where =
696    parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697  const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699  return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703  return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708  const toggles = toggleKeys(specs)
709  let key: string | undefined
710  let ms: number | undefined
711  for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712    if (key === undefined && toggles.includes(word)) {
713      key = word
714      continue
715    }
716    const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717    if (duration === undefined) {
718      const names = listOf(lang, toggles)
719      const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721      return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722    }
723    ms = duration
724  }
725  if (key === undefined && toggles.length === 1) {
726    key = toggles[0]
727  }
728  if (key === undefined) {
729    return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730  }
731
732  return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736  io: ConfigIO,
737  mod: string,
738  specs: Specs,
739  options: PluginOptions,
740  sub: 'on' | 'off',
741  args: string,
742  reload: (resolved: Resolved) => void,
743): Promise<string> {
744  const { layers } = await loadLayers(io, mod, options)
745  const lang = resolve(specs, layers).lang
746  const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747  if ('error' in parsed) {
748    return parsed.error
749  }
750  const { key, ms } = parsed
751  const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752  const rest: Record<string, unknown> = { ...layers.session }
753  delete rest[key]
754  const below = resolve(specs, { ...layers, session: rest })
755  const belowValue = below.values[key] as Value
756  const isOnBelow = !same(belowValue, toggle.off)
757  const now = await io.now()
758  const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759  if (sub === 'on') {
760    if (isOnBelow) {
761      const wasOff = Object.hasOwn(layers.session, key)
762      await writeSession(io, key, undefined, undefined)
763      reload(await loadConfig(io, mod, specs, options))
764      const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766      return wasOff
767        ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768        : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769    }
770    await writeSession(io, key, toggle.on, now + ms)
771    reload(await loadConfig(io, mod, specs, options))
772    const at = new Date(now + ms)
773    const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774    const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776    return pick(lang, {
777      en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778      'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779    })
780  }
781  // Off for the rest of the session; nothing to keep when the layers below are off already.
782  await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783  reload(await loadConfig(io, mod, specs, options))
784
785  return isOnBelow
786    ? pick(lang, {
787        en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788        'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789      })
790    : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792
hooks/history.ts 83 lines
1// Rewrites Claude Code's prompt history (`history.jsonl`): one JSON object per line, the prompt as
2// typed in `display` and pastes either inline (`pastedContents[n].content`) or in
3// `paste-cache/<contentHash>.txt`. The prompt that reached the model was scrubbed; this file still
4// holds what was typed, and the up-arrow recalls it from there.
5
6import { BYPASS, scrub, type ScrubOptions } from './scrub'
7
8type Paste = { content?: unknown; contentHash?: unknown }
9
10type Entry = {
11  display?: unknown
12  sessionId?: unknown
13  pastedContents?: Record<string, Paste>
14}
15
16export type HistoryScrub = {
17  text: string
18  // Lines rewritten, and the secrets masked in them.
19  lines: number
20  labels: string[]
21  // Pastes kept in paste-cache by the lines looked at; their files are scrubbed separately.
22  pasteHashes: string[]
23}
24
25// Scrubs every line, or only one session's when `sessionId` is given. A line sent with #noscrub
26// stays as typed, and a line that is not JSON is left alone.
27// `bypass` and `options`: the settings, as the prompt was scrubbed with them.
28export function scrubHistory(input: string, sessionId?: string, bypass: RegExp = BYPASS, options: ScrubOptions = {}): HistoryScrub {
29  const labels: string[] = []
30  const pasteHashes = new Set<string>()
31  let lines = 0
32
33  const text = input
34    .split('\n')
35    .map(line => {
36      if (line === '' || (sessionId !== undefined && !line.includes(sessionId))) {
37        return line
38      }
39      let entry: Entry
40      try {
41        entry = JSON.parse(line)
42      } catch {
43        return line
44      }
45      if (typeof entry !== 'object' || entry === null) {
46        return line
47      }
48      if (sessionId !== undefined && entry.sessionId !== sessionId) {
49        return line
50      }
51      if (typeof entry.display === 'string' && bypass.test(entry.display)) {
52        return line
53      }
54
55      const found: string[] = []
56      if (typeof entry.display === 'string') {
57        const scrubbed = scrub(entry.display, options)
58        entry.display = scrubbed.text
59        found.push(...scrubbed.labels)
60      }
61      for (const paste of Object.values(entry.pastedContents ?? {})) {
62        if (typeof paste?.content === 'string') {
63          const scrubbed = scrub(paste.content, options)
64          paste.content = scrubbed.text
65          found.push(...scrubbed.labels)
66        }
67        if (typeof paste?.contentHash === 'string' && /^[0-9a-f]+$/.test(paste.contentHash)) {
68          pasteHashes.add(paste.contentHash)
69        }
70      }
71      if (found.length === 0) {
72        return line
73      }
74      lines++
75      labels.push(...found)
76
77      return JSON.stringify(entry)
78    })
79    .join('\n')
80
81  return { text, lines, labels, pasteHashes: [...pasteHashes] }
82}
83
hooks/scrub.ts 135 lines
1// Finds secrets in free text and replaces their values with a placeholder, keeping the
2// surrounding name (`token=`, `Authorization: Bearer`, `API_KEY=`) so the text still reads.
3
4export const MASK = '‹redacted›'
5
6// A prompt containing the marker is sent as typed (the marker itself removed), for the rare
7// case where Claude really needs the secret. The marker is the setting `bypass_marker`.
8export function bypassPattern(marker: string): RegExp {
9  return new RegExp(`(^|\\s)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`)
10}
11
12export const BYPASS = bypassPattern('#noscrub')
13
14// The settings `custom_rules` (masked whole) and `allow` (left as typed) compiled.
15export type ScrubOptions = { rules?: readonly RegExp[]; allow?: readonly RegExp[] }
16
17// Compiles each pattern, global for masking or not for testing; `bad` lists the ones that do not compile.
18export function compilePatterns(patterns: readonly string[], flags: string): { patterns: RegExp[]; bad: string[] } {
19  const compiled: RegExp[] = []
20  const bad: string[] = []
21  for (const pattern of patterns) {
22    try {
23      compiled.push(new RegExp(pattern, flags))
24    } catch {
25      bad.push(pattern)
26    }
27  }
28
29  return { patterns: compiled, bad }
30}
31
32type Rule = {
33  label: string
34  pattern: RegExp
35  // Index of the capture group holding the secret; 0 replaces the whole match.
36  group: number
37}
38
39// Values shorter than this in name=value forms are treated as examples (`token=xxx`).
40const MIN_VALUE = 8
41
42const RULES: Rule[] = [
43  {
44    label: 'private key',
45    pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
46    group: 0,
47  },
48  { label: 'Anthropic key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, group: 0 },
49  { label: 'API key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, group: 0 },
50  { label: 'GitHub token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g, group: 0 },
51  { label: 'Slack token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, group: 0 },
52  { label: 'AWS key', pattern: /\bAKIA[0-9A-Z]{16}\b/g, group: 0 },
53  { label: 'Google key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, group: 0 },
54  {
55    label: 'JWT',
56    pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
57    group: 0,
58  },
59  {
60    label: 'Bearer token',
61    pattern: /\b(?:Bearer|Basic)\s+([A-Za-z0-9._~+\/=-]{8,})/g,
62    group: 1,
63  },
64  {
65    label: 'URL credential',
66    pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@\/]+:([^\s@\/]{4,})@/gi,
67    group: 1,
68  },
69  {
70    label: 'query token',
71    pattern:
72      /[?&](?:access_token|refresh_token|id_token|token|api_?key|apikey|key|secret|client_secret|password|passwd|pwd|auth|sig|signature)=([^&#\s"'`<>]+)/gi,
73    group: 1,
74  },
75  {
76    // `token=…` typed in prose. Only unmistakable names: `key=`, `auth=` and the like are common in
77    // code, and `${VAR}` / `{var}` placeholders are no secret.
78    label: 'token',
79    pattern:
80      /(?<![\w.\/-])(?:access_token|refresh_token|id_token|token|api_?key|secret|client_secret|password|passwd)=(?![{$<])([^&#\s"'`<>()]+)/gi,
81    group: 1,
82  },
83  {
84    label: 'env secret',
85    pattern:
86      /^\s*(?:export\s+)?[A-Z0-9_]*(?:SECRET|TOKEN|API_?KEY|ACCESS_KEY|PASSWORD|PASSWD|PRIVATE_KEY|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*["']?([^\s"'#]+)/gm,
87    group: 1,
88  },
89]
90
91export type Scrubbed = { text: string; labels: string[] }
92
93export function scrub(input: string, options: ScrubOptions = {}): Scrubbed {
94  const labels: string[] = []
95  let text = input
96  const allow = options.allow ?? []
97  const custom = (options.rules ?? []).map(pattern => ({ label: 'custom rule', pattern, group: 0 }))
98
99  for (const rule of [...RULES, ...custom]) {
100    text = text.replace(rule.pattern, (match: string, ...rest: unknown[]) => {
101      if (match.includes(MASK)) {
102        return match
103      }
104      if (rule.group === 0) {
105        if (allow.some(pattern => pattern.test(match))) {
106          return match
107        }
108        labels.push(rule.label)
109
110        return MASK
111      }
112      const value = rest[rule.group - 1]
113      if (typeof value !== 'string' || value.length < MIN_VALUE || value.includes(MASK) || allow.some(pattern => pattern.test(value))) {
114        return match
115      }
116      labels.push(rule.label)
117      const at = match.lastIndexOf(value)
118
119      return match.slice(0, at) + MASK + match.slice(at + value.length)
120    })
121  }
122
123  return { text, labels }
124}
125
126// Counts each label once with its number: "query token ×2, JWT".
127export function summarize(labels: string[]): string {
128  const counts = new Map<string, number>()
129  for (const label of labels) {
130    counts.set(label, (counts.get(label) ?? 0) + 1)
131  }
132
133  return [...counts].map(([label, n]) => (n > 1 ? `${label} ×${n}` : label)).join(', ')
134}
135