SLOPSHOPPER

skin

/skin <name> recolours the whole Claude desktop app with one of 16 skins (each with a light and a dark half). The first skin needs one tick of Developer >…

newbandcommandtoaststatusprompt
v1.0.2MITupdated 2026-10-23gregts/reskin-for-claude/skin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skin
› 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 › /skin ⎿ skin: Could not find the Reskin tool folder (skin-tool). Set this mod's toolDir option to the folder that holds skin ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Reskin for Claude

Colour skins for the whole Claude desktop app on macOS: sidebar, chat, cards, menus and buttons. 16 skins, each with a dark and a light half that follows Claude's own Appearance setting.

https://github.com/user-attachments/assets/4dfcedbd-b10f-4441-9617-c501e1b1c003

After each Claude launch, the first skin needs one click on Developer > Enable Main Process Debugger.

Claude desktop with the tokyo-night skin: dark on the left, light on the right

Unofficial, just for fun, use at your own risk. Reskin is not made, checked or endorsed by Anthropic. "Claude" is a trademark of Anthropic, PBC. Reskin changes colours only, through Claude's own Developer menu, and it may stop working after a Claude update. Read SECURITY.md before the first run: the one step it needs opens a debugger on your Mac for a moment. Full notice: NOTICE.md.

Reskin sends nothing off this Mac and does not touch your chats. Undo any time: /skin off, or quit Claude.

AI agents: read skin/skin-tool/AGENTS.md before you run anything, and ask the user before every tick.

The skins

Each picture: the dark half on the left, the light half on the right. Real screenshots of the Claude desktop app with each skin on (the chats are made up).

absolutely<br>absolutely, dark and lightayu<br>ayu, dark and light
catppuccin<br>catppuccin, dark and lightclassic<br>classic, dark and light
dracula<br>dracula, dark and lightgithub<br>github, dark and light
gruvbox<br>gruvbox, dark and lightlinea<br>linea, dark and light
night-owl<br>night-owl, dark and lightnord<br>nord, dark and light
one<br>one, dark and lightrose-pine<br>rose-pine, dark and light
solarized<br>solarized, dark and lighttokyo-night<br>tokyo-night, dark and light
verc<br>verc, dark and lightvs<br>vs, dark and light

The session status dots in the sidebar use the same colours in every skin, one set for dark and one for light, so a dot always means the same thing: blue unread, orange waiting for you, red error, grey running. Where each colour comes from: ATTRIBUTION.md.

What you need

NeedWhy
A Mac with the Claude desktop app in /ApplicationsReskin recolours that app only (not Windows, not Linux, not the claude.ai website), and checks Claude's signature at /Applications/Claude.app.
Claude Code, to get the /skin commandReskin ships as a Claude Code plugin. It has been used from the desktop app's Code tab.
Node 22 or newerThe tool is a Node script and refuses older Node.
Developer Mode on in Claude (first time only)The menu item Reskin needs only exists in Developer Mode. In Claude choose Help > Troubleshooting > Enable Developer Mode, then Enable in the message that follows. Claude restarts, and a Developer menu appears.

No Accessibility permission, no network and no change to Claude's files.

Install

Run these two lines in Terminal (they need Claude Code's claude command):

claude plugin marketplace add gregts/reskin-for-claude
claude plugin install skin@reskin-for-claude

Then type /reload-plugins in a Code tab session of the Claude desktop app, or restart Claude. Every /skin command is typed there, in the Code tab (not in a normal chat).

If you also use hellosverre's claude-skins (it restyles Claude Code's chat drawing, not the whole app), note that both plugins use the /skin command and only one can have it: whichever loads second loses its /skin (if Reskin is the one that loses, it shows a status line saying so). Keep one of them enabled at a time.

Use

  1. Type /skin list to see the skins.
  2. Type /skin nord (or any name). A card asks you to click Developer > Enable Main Process Debugger once in Claude's top menu bar, within 60 seconds. Do not click it before you run /skin: if the item is already ticked, Reskin refuses to start, so quit and reopen Claude first.
  3. Click it. Reskin paints the colours, closes the debugger again and reports what it checked. This is the only step that opens anything.
  4. With Google Chrome installed, Claude opens a chrome://inspect tab in it. Close that tab.
  5. Without Google Chrome, Claude shows a message "Inspector session is active" and freezes until you click OK. Click OK.
  6. Later switches need no click: /skin dracula, /skin off. A small colour helper stays inside Claude until Claude quits. Skins do not survive a quit: reopened, Claude shows its own colours again, and the next /skin <name> needs one click.

Other commands: /skin status, /skin check (is the skin still showing its own colours), /skin remove (takes off the colours and the helper), /skin log (the last five runs), /skin about.

The desktop app does not show a typeahead list of skin names after /skin. Use /skin list instead.

After updating Reskin, quit and reopen Claude once before the next /skin.

After a Claude update, run /skin check once: it tells you whether the skin still paints everything it should.

Without the plugin

From the skin/skin-tool folder of a copy of this repo:

node skin-auto.mjs --check
node skin-auto.mjs tick nord
node skin-auto.mjs switch dracula
node skin-auto.mjs status
node skin-auto.mjs check

--check is read-only and lists what this Mac still needs. tick <name> applies the first skin after Claude starts: click Developer > Enable Main Process Debugger within 60 seconds. After that, switch takes a skin name, off or remove, with no click.

Raycast

skin/skin-tool/raycast/ holds two Raycast script commands. They only switch skins through the colour helper, so run /skin <name> in Claude once first. Without a helper they say so and do nothing. Their "Remove skin" choice also ends the helper (like /skin remove), so after it the next skin needs /skin <name> in Claude again.

What it never does

  • It never patches Claude's files and never changes Claude's settings, including Appearance.
  • It never clicks the Developer menu item by itself and never applies a skin at login or when Claude restarts.
  • It never sends anything off this Mac: its only connections are to Claude on 127.0.0.1.
  • It applies only its own 16 skins, and those change colours only: nothing is hidden, moved or renamed.
  • It never takes screenshots in normal use.

Remove it completely

  1. Quit Claude and open it again. That removes the colours, the helper and any open debugger.
  2. Optional: claude plugin uninstall skin@reskin-for-claude.
  3. Optional: turn Developer Mode off again from Claude's Help menu.
  4. Optional: delete Reskin's own folders, ~/Library/Application Support/claude-skin/ (helper files and the last skin name) and ~/Library/Logs/Reskin/ (the log).

If something goes wrong

  • If a run warns that the Developer menu item may still show a tick, do not click it (a click opens the debugger again). Quit and reopen Claude instead.
  • lsof -nP -iTCP:9229 -sTCP:LISTEN prints nothing when the debugger is closed.
  • /skin log shows the last five runs. The full log is ~/Library/Logs/Reskin/skin.log. Known secret formats are removed from it, but that is a filter, not a guarantee: read an entry before you share it.
  • /skin check reports FAIL or structure changed when a Claude update changed what Reskin paints, with a Repair: line.
  • Exit codes of skin-auto.mjs: 0 ok, 1 failed, 2 usage, 3 port not confirmed closed (quit and reopen Claude), 4 took too long, 5 refused before changing anything, 6 a throwaway Chrome could not be confirmed ended, 130 interrupted.
  • Report a bug with the issue form. Report a security problem privately: see SECURITY.md.

Reskin is a hobby project. When a Claude update breaks it, fixing that comes first; everything else is best effort.

Tested with

Only what was run by hand on a real Mac is listed. Everything else is untested.

Claude desktopMacmacOSChromeNodeWhat ranResult
3.6556.1MacBook Pro 14-inch, M4 Pro27.0.1installed, not running (Chrome started by the click)26.10.0this release's code (from a development copy, not from this repo): tick, check in dark and lightpassed
2.42304.2MacBook Pro 14-inch, M4 Pro27.0.1installed26.10.0this release's code (from a development copy, not from this repo): tick, check in dark and lightpassed
2.42304.0MacBook Pro 14-inch, M4 Pro27.0.1installed26.10.0this release's code (from a development copy, not from this repo): tick, switch, check; with the build before it, all 16 skins seen in dark and lightpassed
2.42304.0MacBook Air 13-inch, M327.0.1not installed26.7.0tick and switch with an earlier build and the earlier skin setpassed

Untested: any other Claude version, Intel Macs, macOS before 27, a Mac without Chrome on this release, menus in a language other than English. Claude's internal page structure changes often; /skin check exists to notice when an update breaks a skin, and skin/skin-tool/tested-versions.json lists the Claude versions the skins were seen working on.

Known limits

LimitWhat you get insteadWhereTest
A Claude update can break the skins at any time/skin check says FAIL or structure changed; the report names Claude's version and says when it is not one of the tested onesjudgeHealth in skin-lib.mjshealth-check.test.mjs
After a run, only five colours are sampled"sampled colour checks: N of M passed (U unknown)". Unreadable samples count as unknown, never as a passjudgeChecks in skin-lib.mjstwo-choice.test.mjs: "sampled colour checks: real presets, ..."
Hover colours as painted and pop-up menus are not checked by the tool (/skin check reads only the hover colour's token value)Look at themnonenone
Some blue stays Claude's blue: named blues, charts, tinted tags, the drag-and-drop highlightLeft alone on purposeaccent rules in make-presets.mjsaccent.test.mjs: "named blues, clay, charts and tint colours are left alone"
The helper ends when Claude quits/skin status offers to reapply the last skin, which needs one clickliveSession in skin-lib.mjstwo-choice.test.mjs: "client: liveSession is active only for the very Claude process ..."
Any program running as you can command the helper or fake its answers/skin talks to the helper through files in your user folder, so another program running as you could change them; quitting Claude always ends the helpercolour helper in skin.mjssecurity.test.mjs: "a removal through the helper is reported ..."
Without Chrome, Claude is frozen and the debugger stays open until you click OK on its messageThe card tells you to click OK; no timer can run sooner. The skin step waits for Claude (PATIENT_MS in skin.mjs)tickRun in skin-auto.mjsshorter-door.test.mjs
The Developer menu item is found by its English nameIf it cannot be found to clear the tick, the run says soUNTICK in skin.mjssecurity.test.mjs: "UNTICK clears the tick without clicking ..."
If the debugger cannot be confirmed closed, Reskin stopsQuit and reopen Claudeguardian in skin-lib.mjsskin-tool.test.mjs: "the guardian: closes on unknown or open, ..."
Running skin.mjs directly has no guardian and no run lockFor developers only; use skin-auto.mjs or /skinskin.mjsnone
Memory and CPU cost was measured by hand on one Mac (M4 Pro), one long chat and test pages; not on 8 GB Macs, many windows at once or days of useMeasured on real Claude: window memory within a few MB of no skin, and no extra CPU at rest. Each skin switch used about 0.1 to 0.15 s of CPU on a very long chat (once 0.57 s). On test pages only: streaming text cost the same with or without a skin, and a Light/Dark switch cost a little more on ordinary test pages, but about 2.5 times as much on a worst-case test page (0.2 s against 0.08 s). Scrolling and the helper's own idle cost were not measured. The helper checks its folder once a second while Claude runs, even with colours off; /skin remove stops itcolours set on every page element (* rule) in make-presets.mjsnone
The log filter removes known secret formats onlyRead an entry before you share itskin-log.mjsrun-log.test.mjs: "redaction: every known format ..."

Test files are in skin/skin-tool/tests/ (node --test --test-concurrency=1 'tests/*.test.mjs', about 2 minutes) and skin/tests/ (claude plugin test skin).

Credits and licence

Reskin is MIT licensed (LICENSE). Every colour scheme belongs to its authors: sources in ATTRIBUTION.md, their licence texts in THIRD-PARTY-NOTICES.md.

Thanks to miniLV/Anthropic-codex-theme, which gives OpenAI's Codex a Claude-style look. It sparked the idea of going the other way: colour skins for Claude. Reskin uses none of its code or colours.

Source 4 files
hooks/register.tsx 462 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TickCard } from '../types'
5import { APPEND_SH, BoundedText, Capture, ENTRY_MAX_BYTES, LOG_DIR, LOG_FILE, LOG_MAX_BYTES, Milestones, appearanceOf, formatEntry, logView, macModeOf, portOf } from './log'
6import type { LogRecord, PortState, Step } from './log'
7import { ABOUT, CARD, RING_SIZE, cardFacts, cardParagraph, countdownSvg, displayName, listText, modeOf, noHelper, nodeCandidates, nodeMajorOk, isSkinName, parse, skinSuggestions, oneClickOf, argumentHintOf, summary, toolDirCandidates } from './logic'
8import type { Preset } from './logic'
9
10// The tick card above the prompt (null when no run waits for a click).
11const card = atom({ plugin: 'skin', key: 'card' } as const, null as TickCard | null)
12const ACCENT = '#d97757'
13const DIM = '#8a8f98'
14// The tool's tick window (TICK.windowMs in skin-lib.mjs), counted from READY_TO_TICK.
15const TICK_WINDOW_S = 60
16
17// Only one run at a time: a tick run waits up to 60 s for the user.
18let isRunning = false
19// Set when the band actually drew the tick card. Where no band is drawn (a surface without one), the status line under
20// the prompt carries the instruction instead, so it is never shown twice.
21let cardDrawn = false
22// The longest a run may take: preparation, the 60 s tick window, the apply and cleanup (the tool stops itself at 150 s).
23const RUN_LIMIT_MS = 180_000
24
25type Env = { tool: string; node: string[] }
26// exitCode is what the chat answer is built from (1 when the tool gave none, as before); the log records null then.
27type Run = { exitCode: number; stdout: string; stderr: string }
28// The steps of one /skin run, for its log entry. The tool is told the mod logs it, so it does not log the step twice.
29type Steps = Step[]
30const TOOL_ENV = { RESKIN_LOG_BY: 'skin-mod' }
31const viaOf = (verb: string) => verb === 'switch' ? 'colour helper' : verb === 'tick' ? 'debugger, you tick' : verb === 'one-click' ? 'debugger, one click' : verb === 'status' ? 'status' : verb === 'check' ? 'health check' : verb
32// A tool ended by a signal has no exit code: the step says so (and records exit 1, as the answer always did).
33const ended = (res: { code: number | null; signal?: string | null }) => res.code !== null ? null : res.signal ? `ended by ${res.signal}` : 'ended without an exit code'
34// Timings for the log only: the local clock, so no engine call ever sits between a piece of output and the card.
35const nowOf = (_$: EngineInterface): number => Date.now()
36// A quick run (switch, status) that has not ended by then is stopped (as process.run's limit did before).
37const QUICK_LIMIT_MS = 20_000
38const resultOf = (stdout: string, exitCode: number) => /^RESULT: (ok|failed|refused)$/m.exec(stdout)?.[1] ?? (exitCode === 0 ? 'ok' : 'failed')
39
40// Finds the tool folder and a Node 22 or newer, without any owner-specific path.
41async function resolveEnv($: EngineInterface, options: Readonly<Record<string, unknown>>): Promise<Env | string> {
42  let tool: string | undefined
43  for (const dir of toolDirCandidates(typeof options.toolDir === 'string' ? options.toolDir : undefined, $.plugin.root)) {
44    const entries = await $.fs.list(dir).catch(() => [])
45    if (entries.some(e => e.name === 'skin-auto.mjs') && entries.some(e => e.name === 'presets')) { tool = dir; break }
46  }
47  if (tool === undefined) return 'Could not find the Reskin tool folder (skin-tool). Set this mod\'s toolDir option to the folder that holds skin-auto.mjs.'
48  for (const node of nodeCandidates(typeof options.node === 'string' ? options.node : undefined)) {
49    const r = await $.process.run([...node, '--version'], { timeoutMs: 5_000 }).catch(() => undefined)
50    if (r !== undefined && r.exitCode === 0 && nodeMajorOk(r.stdout)) return { tool, node }
51  }
52  return 'Reskin needs Node 22 or newer, and none was found (looked in /opt/homebrew/bin, /usr/local/bin and PATH). Install it, or set this mod\'s node option.'
53}
54
55async function presets($: EngineInterface, tool: string): Promise<Preset[]> {
56  const entries = await $.fs.list(`${tool}/presets`)
57  const names = entries.filter(e => e.kind === 'file' && e.name.endsWith('.css')).map(e => e.name.slice(0, -4)).sort()
58  const out: Preset[] = []
59  for (const name of names) {
60    const css = await $.fs.read(`${tool}/presets/${name}.css`).catch(() => '')
61    out.push({ name, mode: modeOf(typeof css === 'string' ? css : '') })
62  }
63  return out
64}
65
66// Typeahead: skin names come from the tool's presets folder, looked up at most every 30 s (no Node, no process).
67// Keystrokes that arrive while a look-up runs share it, so a slow disk never piles up reads.
68let names: { at: number; list: string[] } | undefined
69let looking: Promise<string[]> | undefined
70async function skinNames($: EngineInterface): Promise<string[]> {
71  if (names !== undefined && Date.now() - names.at <= 30_000) return names.list
72  looking ??= lookUpNames($).finally(() => { looking = undefined })
73  return looking
74}
75async function lookUpNames($: EngineInterface): Promise<string[]> {
76  let list: string[] = []
77  let oneClick = true
78  for (const dir of toolDirCandidates(typeof currentOptions.toolDir === 'string' ? currentOptions.toolDir : undefined, $.plugin.root)) {
79    const entries = await $.fs.list(`${dir}/presets`).catch(() => [])
80    list = entries.filter(x => x.kind === 'file' && x.name.endsWith('.css')).map(x => x.name.slice(0, -4)).filter(isSkinName).sort()
81    if (list.length > 0) {
82      // The tool's own release switch decides whether --one-click is offered (a tick-only release refuses it anyway).
83      const there = await $.fs.exists(`${dir}/release.json`).catch(() => null)
84      oneClick = there === false ? true : oneClickOf(there ? await $.fs.read(`${dir}/release.json`).catch(() => '') : '')
85      break
86    }
87  }
88  names = { at: Date.now(), list }
89  oneClickNow = oneClick
90  return list
91}
92let oneClickNow = true
93
94async function macMode($: EngineInterface): Promise<'dark' | 'light' | 'unknown'> {
95  return macModeOf(await $.process.run(['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle']).catch(() => undefined))
96}
97
98// Quick runs (switch, status): no waiting on the user. Streamed like the tick runs, so the log has the time each line came.
99async function quick($: EngineInterface, env: Env, args: string[], steps: Steps, via = viaOf(args[0] ?? '')): Promise<Run> {
100  const t = nowOf($)
101  const argv = [...env.node, `${env.tool}/skin-auto.mjs`, ...args]
102  const out = new Capture()
103  const marks = new Milestones()
104  const stdout = new BoundedText()
105  const stderr = new BoundedText()
106  let exitCode: number | null = null
107  let cut = false
108  const child = $.process.spawn({ argv, env: TOOL_ENV })
109  let timeUp = () => {}
110  const limit = new Promise<'cut'>(r => { timeUp = () => r('cut') })
111  let timer: { cancel: () => void } | undefined
112  try { timer = $.clock.after(QUICK_LIMIT_MS, () => { cut = true; timeUp(); void child.return(undefined as never).catch(() => {}) }) } catch {}
113  const read = async (): Promise<'done'> => {
114    for await (const { stream, text } of child) {
115      if (cut) break
116      out.add(nowOf($) - t, stream === 'stderr' ? 'stderr' : 'stdout', text)
117      marks.feed(stream === 'stderr' ? 'stderr' : 'stdout', text)
118      if (stream === 'stderr') stderr.add(text)
119      else stdout.add(text)
120    }
121    return 'done'
122  }
123  try {
124    if (await Promise.race([read(), limit]) === 'cut') throw new Error(`the tool did not answer within ${QUICK_LIMIT_MS / 1000} s`)
125    const res = await child.result.catch(() => ({ code: null, signal: null as string | null }))
126    const sig = ended(res)
127    if (sig) { out.add(nowOf($) - t, 'stderr', `\n${sig}\n`); marks.add(sig) }
128    exitCode = res.code
129    // The answer is built from cleaned text, so the chat (and the log of it) never shows a secret the tool printed.
130    return { exitCode: res.code ?? 1, stdout: stdout.cleaned(), stderr: stderr.cleaned() }
131  } catch (err) {
132    out.add(nowOf($) - t, 'stderr', `\ncould not run: ${(err as Error).message}\n`)
133    throw err
134  } finally {
135    timer?.cancel()
136    steps.push({ argv, via, exitCode, ms: nowOf($) - t, output: out.finish(), marks: marks.list().join('\n') })
137  }
138}
139
140// Runs that may wait for the user's tick: the output is streamed, so the tick prompt shows the moment the tool is ready
141// (it prints READY_TO_TICK only after every check passed and its safety nets are running), together with the tool's own
142// disclosure (what may appear, the debugger, the colour helper). One-click runs print DISCLOSED before their scripted tick.
143async function streamed($: EngineInterface, env: Env, args: string[], steps: Steps): Promise<Run> {
144  const skin = args.at(-1) ?? ''
145  const t0 = nowOf($)
146  const out = new Capture()
147  const marks = new Milestones()
148  let exitCode: number | null = null
149  const argv = [...env.node, `${env.tool}/skin-auto.mjs`, ...args]
150  let countdown: { cancel: () => void } | undefined
151  let fallback: { cancel: () => void } | undefined
152  const child = $.process.spawn({ argv, env: TOOL_ENV })
153  const stdout = new BoundedText()
154  const stderr = new BoundedText()
155  let cut = false
156  // An independent cap: the command stops waiting at RUN_LIMIT_MS even if the child goes silent, and asks the child to
157  // end (the tool's own guardian still closes the port).
158  let timeUp = () => {}
159  const limit = new Promise<'cut'>(r => { timeUp = () => r('cut') })
160  const timer = $.clock.after(RUN_LIMIT_MS, () => { cut = true; timeUp(); void child.return(undefined as never).catch(() => {}) })
161  const disclosure = (marker: string) => {
162    const all = stdout.text
163    const lines = all.slice(0, all.indexOf(marker)).split('\n').map(l => l.trim())
164    const from = lines.findIndex(l => l.startsWith('Ready.') || l.startsWith('Reskin will now tick'))
165    return from === -1 ? '' : lines.slice(from).filter(l => l !== '' && !l.startsWith('[')).join('\n')
166  }
167  const read = async (): Promise<'done'> => {
168    for await (const { stream, text } of child) {
169      if (cut) break
170      out.add(nowOf($) - t0, stream === 'stderr' ? 'stderr' : 'stdout', text)
171      marks.feed(stream === 'stderr' ? 'stderr' : 'stdout', text)
172      if (stream === 'stderr') { stderr.add(text); continue }
173      stdout.add(text)
174      // Each marker is looked for on every piece, so none is missed when it is split across two pieces.
175      const ready = stdout.newly('READY_TO_TICK')
176      const nudge = stdout.newly('No debugger yet')
177      const disclosed = stdout.newly('DISCLOSED')
178      const seenOpen = stdout.newly('debugger port seen')
179      if (ready) {
180        // The card carries the tool's disclosure in short; the status line stays as a one-line fallback.
181        await update($, card, () => ({ skin, left: TICK_WINDOW_S, phase: 'wait', nudged: false, ...cardFacts(disclosure('READY_TO_TICK')) }))
182        countdown = $.clock.every(1_000, () => { void update($, card, c => c === null || c.phase !== 'wait' ? c : { ...c, left: Math.max(0, c.left - 1) }) })
183        cardDrawn = false
184        fallback = $.clock.after(1_500, () => { if (!cardDrawn) $.ui.status(`Reskin: click Developer > Enable Main Process Debugger once (${TICK_WINDOW_S} s)`) })
185      }
186      if (nudge) await update($, card, c => c === null ? c : { ...c, nudged: true })
187      if (disclosed) $.ui.toast(disclosure('DISCLOSED'), { timeoutMs: 15_000 })
188      if (seenOpen) {
189        await update($, card, c => c === null ? c : { ...c, phase: 'applying' as const })
190        if (!cardDrawn) $.ui.status('Reskin: applying, stop clicking')
191      }
192    }
193    return 'done'
194  }
195  try {
196    const how = await Promise.race([read(), limit])
197    if (how === 'cut') {
198      return { exitCode: 4, stdout: stdout.cleaned(), stderr: `${stderr.cleaned()}\nStopped by /skin after ${RUN_LIMIT_MS / 1000} s. If the debugger may still be open, untick Developer > Enable Main Process Debugger.` }
199    }
200    const res = await child.result.catch(() => ({ code: null, signal: null as string | null }))
201    const sig = ended(res)
202    if (sig) { out.add(nowOf($) - t0, 'stderr', `\n${sig}\n`); marks.add(sig) }
203    exitCode = res.code
204    return { exitCode: res.code ?? 1, stdout: stdout.cleaned(), stderr: stderr.cleaned() }
205  } finally {
206    timer.cancel()
207    countdown?.cancel()
208    fallback?.cancel()
209    const ms = nowOf($) - t0
210    const tail = cut ? `\n[+${(ms / 1000).toFixed(3)}s] /skin stopped waiting after ${RUN_LIMIT_MS / 1000} s` : ''
211    steps.push({ argv, via: viaOf(args[0] ?? ''), exitCode, ms, output: out.finish() + tail, marks: marks.list().join('\n') })
212    await update($, card, () => null)
213    $.ui.status(undefined)
214  }
215}
216
217export const register: Register = (on, options) => {
218  currentOptions = options ?? {}
219  // The tick card: only while a tick run waits. Other plugins' bands (usage-line) still draw beneath it.
220  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221    const c = await read($, card)
222    if (c === null || e.props.hasSurvey) return next(e)
223    cardDrawn = true
224    // Claude Code's own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
225    const drawn = await Promise.resolve(next(e)).catch(() => null)
226    const below = drawn !== null && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
227    const ui = $.ui.resolve(e)
228    const { Box, Text } = ui
229    const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
230    const waiting = c.phase === 'wait'
231    // Top row: the title, then the countdown ring and "How it works" in the top right corner. Below it, one paragraph
232    // across the full width (mockup, 15 Oct): no columns, so no gap down the middle.
233    const ring = Svg !== undefined
234      ? (() => { const d = countdownSvg(c.left, TICK_WINDOW_S); return <Svg key="skin-card-ring" source={d.source} alt={d.alt} width={RING_SIZE} height={RING_SIZE} /> })()
235      : <Text color={c.left <= 5 ? ACCENT : DIM}>{c.left} s</Text>
236    const [before, after] = cardParagraph(c)
237    return (
238      <Box flexDirection="column" gap={1}>
239        <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
240          <Box flexDirection="row" justifyContent="space-between" alignItems="center">
241            <Text bold color={ACCENT}>Reskin · {displayName(c.skin)}</Text>
242            {waiting && (
243              <Box key="skin-card-corner" flexDirection="row" gap={1} alignItems="center">
244                {ring}
245                <Text color={DIM}>{CARD.about}</Text>
246              </Box>
247            )}
248          </Box>
249          {waiting
250            ? <Text color={DIM} wrap="wrap">{before}<Text bold color={DIM}>{CARD.menu}</Text>{after}</Text>
251            : <Text>{c.helper ? CARD.applyingHelper : CARD.applying}</Text>}
252          {waiting && <Text color={DIM} wrap="wrap">{CARD.devMode}</Text>}
253          {waiting && c.nudged && <Text color={ACCENT}>{c.left === 0 ? CARD.timeUp : CARD.nudge}</Text>}
254          {waiting && !c.nudged && c.left === 0 && <Text color={ACCENT}>{CARD.timeUp}</Text>}
255        </Box>
256        {below}
257      </Box>
258    )
259  })
260
261  on('prompt.autocomplete', async ($, e, next) => {
262    const base = await next(e)
263    if (!/^\s*\/skin\s/i.test(e.text)) return base
264    const mine = skinSuggestions(e.text, e.start, e.cursor, await skinNames($), oneClickNow)
265    return mine.length ? { suggestions: [...base.suggestions, ...mine] } : base
266  })
267
268  on('session.start', async ($, e, next) => {
269    // Another plugin (for example claude-skins) may already own /skin: say so instead of failing silently.
270    try {
271      await $.command.register({
272        name: 'skin',
273        description: 'Recolour the whole Claude app: /skin <name>, /skin off, /skin remove, /skin status, /skin check, /skin list, /skin log, /skin about',
274        argumentHint: argumentHintOf((await skinNames($), oneClickNow)),
275      })
276    } catch {
277      $.ui.status('Reskin: /skin is taken by another plugin. Disable one of them to use Reskin.')
278    }
279    return next(e)
280  })
281
282  on('command.run', { command: 'skin' }, async ($, e) => {
283    const parsed = parse(e.args, (await skinNames($), oneClickNow))
284    if (parsed.kind === 'about') return { text: ABOUT }
285    if (parsed.kind === 'log') return { text: await showLog($) }
286    // Every other /skin run leaves one entry in the log, whatever happens.
287    const t0 = nowOf($)
288    const steps: Steps = []
289    // The port is checked alongside the run, never before it: the answer never waits for the log.
290    const portBefore = portNow($)
291    let preset: LogRecord['preset'] = null
292    let tool = 'not looked up'
293    let answer: { text: string; result: string }
294    try {
295      answer = await handle($, parsed, steps, (t: string) => { tool = t }, (p: LogRecord['preset']) => { preset = p })
296    } catch (err) {
297      answer = { text: `The skin step did not finish: ${(err as Error).message}`, result: 'failed' }
298    }
299    // The answer never waits for the log: the entry is written after it, in the background. What the run itself decides
300    // is fixed now: its duration, the tool's own last port check, and a port check started as it ends.
301    const ms = nowOf($) - t0
302    // From the step's milestones (noted from every raw line), so a cut in the kept output never loses it.
303    const toolPort = [...(steps.at(-1)?.marks ?? '').matchAll(/PORT_STATE: (open|closed|unknown)$/gm)].at(-1)?.[1] as PortState | undefined
304    void writeLog($, {
305      command: `/skin ${e.args.trim()}`.trim(), tool, preset, steps, shown: answer.text, result: answer.result,
306      exitCode: steps.at(-1)?.exitCode ?? null, t0, ms, portBefore, portAfter: toolPort ?? portNow($), portAfterFrom: toolPort ? 'the tool\'s own last check' : 'checked as the run ended',
307    })
308    return { text: answer.text }
309  })
310}
311
312type Found = (tool: string) => void
313type Chosen = (p: LogRecord['preset']) => void
314const said = (text: string, result: string) => ({ text, result })
315
316// One /skin run. `result` is what the log records: the tool's own RESULT line when a step ran, otherwise refused or
317// answered (the mod answered by itself).
318async function handle($: EngineInterface, parsed: ReturnType<typeof parse>, steps: Steps, found: Found, chosen: Chosen): Promise<{ text: string; result: string }> {
319  if (parsed.kind === 'bad') return said(parsed.why, 'refused')
320  if (isRunning) return said('A skin change is already running. Wait for it to finish.', 'refused')
321  const env = await resolveEnv($, currentOptions)
322  if (typeof env === 'string') { found(env); return said(env, 'refused') }
323  found(env.tool)
324  isRunning = true
325  try {
326    const last = (r: Run) => resultOf(r.stdout, r.exitCode)
327    if (parsed.kind === 'status') {
328      const r = await quick($, env, ['status'], steps)
329      return said(r.stdout.split('\n').filter(l => l.trim() && !/^(PORT_STATE|RESULT):/.test(l)).join('\n') || summary('status', r.exitCode, r.stdout, r.stderr), last(r))
330    }
331    if (parsed.kind === 'check') {
332      // Read-only, through the colour helper: no tick, no port. The tool prints the whole plain report.
333      const r = await quick($, env, ['check'], steps)
334      return said(r.stdout.split('\n').filter(l => l.trim() && !/^(PORT_STATE|RESULT):/.test(l)).join('\n') || [`The check did not finish (exit ${r.exitCode}).`, ...r.stderr.split('\n').filter(l => l.trim()).slice(-3)].join('\n'), last(r))
335    }
336    if (parsed.kind === 'list') {
337      const all = await presets($, env.tool).catch(() => [] as Preset[])
338      if (all.length === 0) return said(`No skins found in ${env.tool}/presets.`, 'failed')
339      const st = await quick($, env, ['status'], steps, 'status check (for the list)').catch(() => undefined)
340      const extra = st?.stdout.split('\n').filter(l => /^(helper:|Reapply )/.test(l)) ?? []
341      return said([listText(all, await macMode($)), ...extra].join('\n'), 'ok')
342    }
343    if (parsed.kind === 'off' || parsed.kind === 'remove') {
344      // Through the helper when there is one (no tick); --tick takes everything off with a new tick.
345      const args = parsed.via === 'tick' ? ['tick', 'remove'] : parsed.via === 'one-click' ? ['one-click', 'remove'] : ['switch', parsed.kind]
346      const r = parsed.via === 'auto' ? await quick($, env, args, steps) : await streamed($, env, args, steps)
347      return said(summary('off', r.exitCode, r.stdout, r.stderr), last(r))
348    }
349    if (parsed.kind !== 'apply') return said('Nothing to do.', 'answered')
350    // A preset: only a name that really is a preset file gets through.
351    const all = await presets($, env.tool).catch(() => [] as Preset[])
352    if (all.length === 0) return said(`No skins found in ${env.tool}/presets.`, 'failed')
353    const p = all.find(x => x.name === parsed.name)
354    if (p === undefined) return said(`No skin called ${parsed.name}. ${listText(all, await macMode($))}`, 'refused')
355    chosen({ name: p.name, mode: p.mode })
356    if (parsed.via === 'auto') {
357      const r = await quick($, env, ['switch', parsed.name], steps)
358      if (!(r.exitCode === 5 && noHelper(r.stdout))) return said(summary(parsed.name, r.exitCode, r.stdout, r.stderr), last(r))
359    }
360    const r = await streamed($, env, [parsed.via === 'one-click' ? 'one-click' : 'tick', parsed.name], steps)
361    return said(summary(parsed.name, r.exitCode, r.stdout, r.stderr), last(r))
362  } finally {
363    isRunning = false
364  }
365}
366
367// The mod's options, kept from register (handle runs outside it).
368let currentOptions: Readonly<Record<string, unknown>> = {}
369
370const run = ($: EngineInterface, argv: string[], timeoutMs = 2_000) => $.process.run(argv, { timeoutMs }).catch(() => undefined)
371const portNow = async ($: EngineInterface): Promise<PortState> => portOf(await run($, ['/usr/sbin/lsof', '-nP', '-iTCP:9229', '-sTCP:LISTEN']))
372const logPath = (home: string) => `${home}/${LOG_DIR}/${LOG_FILE}`
373
374// Adds the entry to the log. Logging never changes or fails a /skin run: any problem here is ignored.
375type Pending = Pick<LogRecord, 'command' | 'tool' | 'preset' | 'steps' | 'shown' | 'result' | 'exitCode' | 'ms' | 'portAfterFrom'> &
376  { t0: number; portBefore: Promise<PortState>; portAfter: PortState | Promise<PortState> }
377// Every lookup has its own limit, and at most a few entries wait to be written at once (a stuck disk can never pile up
378// runs' data); a lookup that does not answer in time is written as unknown.
379const FACT_MS = 2_000
380const MAX_WAITING = 4
381let waiting = 0
382function within<T>($: EngineInterface, p: Promise<T>, fallback: T, ms = FACT_MS): Promise<T> {
383  let timer: { cancel: () => void } | undefined
384  const late = new Promise<T>(r => { try { timer = $.clock.after(ms, () => r(fallback)) } catch {} })
385  return Promise.race([p.catch(() => fallback), late]).finally(() => timer?.cancel())
386}
387async function writeLog($: EngineInterface, r: Pending) {
388  if (waiting >= MAX_WAITING) return
389  waiting++
390  try {
391    await writeEntry($, r)
392  } finally {
393    waiting--
394  }
395}
396async function writeEntry($: EngineInterface, r: Pending) {
397  try {
398    const home = await within($, $.env.get('HOME'), undefined)
399    if (home === undefined || !home.startsWith('/')) return
400    const [portBefore, portAfter, version, mac, when, config, manifest] = await Promise.all([
401      within<PortState>($, r.portBefore, 'unknown'),
402      within<PortState>($, Promise.resolve(r.portAfter), 'unknown'),
403      within($, run($, ['/usr/bin/plutil', '-extract', 'CFBundleShortVersionString', 'raw', '/Applications/Claude.app/Contents/Info.plist']), undefined),
404      within($, run($, ['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle']), undefined),
405      within($, run($, ['/bin/date', '-r', String(Math.floor(r.t0 / 1000)), '+%Y-%m-%d %H:%M:%S %z']), undefined), // when the run started
406      // Only a small regular file is read (never a link or a pipe); only its Appearance word is kept.
407      within($, $.fs.stat(`${home}/Library/Application Support/Claude/config.json`).then(st => st.kind === 'file' && !st.isLink && st.size < 1_000_000
408        ? $.fs.read(`${home}/Library/Application Support/Claude/config.json`) : undefined), undefined),
409      within($, $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`), undefined),
410    ])
411    const v = version?.exitCode === 0 ? version.stdout.trim() : ''
412    const modVersion = typeof manifest === 'string' ? /"version"\s*:\s*"([^"]{1,20})"/.exec(manifest)?.[1] : undefined
413    const rec: LogRecord = {
414      when: when?.exitCode === 0 && when.stdout.trim() !== '' ? when.stdout.trim() : new Date(r.t0).toISOString(),
415      command: r.command, from: `/skin mod ${modVersion ?? '(version unknown)'}`, tool: r.tool,
416      claudeVersion: /^[\w.-]{1,30}$/.test(v) ? v : 'unknown',
417      appearance: appearanceOf(typeof config === 'string' ? config : undefined), mac: macModeOf(mac),
418      preset: r.preset, portBefore, portAfter, portAfterFrom: r.portAfterFrom, steps: r.steps, shown: r.shown, result: r.result, exitCode: r.exitCode,
419      ms: r.ms,
420    }
421    // One shared, locked writer (APPEND_SH): no linked file is followed, rotation happens under the lock, and a failed
422    // rotation writes nothing, so the two files stay bounded.
423    await $.process.run(['/bin/sh', '-c', APPEND_SH, 'sh', `${home}/${LOG_DIR}`, String(LOG_MAX_BYTES), String(ENTRY_MAX_BYTES)],
424      { stdin: formatEntry(rec, home), timeoutMs: 5_000 })
425  } catch {}
426}
427
428// /skin log: the last few entries in plain English, and where the file is. Only small regular files are read, within 3 s,
429// and when the log turned over between the two reads they are read again, so the view never skips or repeats entries.
430const LOG_READ_MS = 3_000
431async function showLog($: EngineInterface): Promise<string> {
432  const shownPath = `~/${LOG_DIR}/${LOG_FILE}`
433  // '' when the file is not there; null when it is there but cannot be read (a link, too big, an error), or when even
434  // whether it is there cannot be told.
435  const readOne = async (f: string): Promise<string | null> => {
436    const there = await $.fs.exists(f).catch(() => null)
437    if (there === null) return null
438    if (!there) return ''
439    const st = await $.fs.stat(f).catch(() => undefined)
440    if (st === undefined || st.kind !== 'file' || st.isLink || st.size > 2 * LOG_MAX_BYTES) return null
441    const t = await $.fs.read(f).catch(() => null)
442    return typeof t === 'string' ? t : null
443  }
444  // Everything, the home folder lookup included, is inside the 3 s limit.
445  const readBoth = async (): Promise<string> => {
446    const home = await $.env.get('HOME').catch(() => undefined)
447    if (home === undefined) return 'Could not find your home folder, so the log could not be read.'
448    const file = logPath(home)
449    for (let i = 0; i < 3; i++) {
450      const current = await readOne(file)
451      const older = await readOne(`${file}.1`)
452      const again = await readOne(file)
453      if (current === null || again === null || older === null) return `The log is there but could not be read (a linked or oversized file, or a read error). The file is ${shownPath}`
454      if (again.startsWith(current)) return logView(older, again, 5, shownPath)
455    }
456    return `The log was changing while it was read; try /skin log again. The file is ${shownPath}`
457  }
458  let timer: { cancel: () => void } | undefined
459  const late = new Promise<string>(r => { try { timer = $.clock.after(LOG_READ_MS, () => r(`The log could not be read within ${LOG_READ_MS / 1000} s. The file is ${shownPath}`)) } catch {} })
460  try { return await Promise.race([readBoth().catch(() => `The log could not be read. The file is ${shownPath}`), late]) } finally { timer?.cancel() }
461}
462
hooks/log.ts 475 lines
1// The /skin troubleshooting log: one plain-text entry per /skin run, in ~/Library/Logs/Reskin/skin.log. The tool writes
2// the same format for runs started without /skin (an agent or a terminal), so one file tells the whole story.
3// Pure helpers only (building, cleaning, reading entries), so they can be tested alone. The rules (redaction, capture,
4// summary, writer) are the same as the tool's skin-log.mjs: change both together; the tests run the same cases on both.
5
6import type { Preset } from './logic'
7
8export const LOG_DIR = 'Library/Logs/Reskin' // under the user's home folder
9export const LOG_FILE = 'skin.log'
10// Past this size the file becomes skin.log.1 (the one older file kept, the one before it dropped) and a new one starts.
11export const LOG_MAX_BYTES = 512 * 1024
12// Per step, only the start of the output is kept (64 KB, far more than a normal run prints), and of a very long line its
13// start. Keeping only starts means a cut can never separate a secret's name from its value; the whole kept text is then
14// cleaned in one pass. At most MAX_STEPS steps, and the chat text and command are capped, so one entry stays under the
15// writer's ENTRY_MAX_BYTES.
16export const RAW_MAX_CHARS = 64 * 1024
17export const LINE_MAX_CHARS = 16 * 1024
18export const MAX_LINES = 2_000 // per stream: many short lines cost more in the entry (time and indent) than their text
19export const MAX_STEPS = 3
20export const SHOWN_MAX_CHARS = 8 * 1024
21export const COMMAND_MAX_CHARS = 500
22export const ENTRY_MAX_BYTES = 256 * 1024
23// An entry starts with this line and ends with END. Raw output is indented, so it can never start a line with either.
24export const HEAD = '==================== '
25export const END = '==================== end'
26
27export type PortState = 'open' | 'closed' | 'unknown'
28// exitCode is null when the tool gave none (ended by a signal, did not answer in time, could not start).
29export type Step = { argv: string[]; via: string; exitCode: number | null; ms: number; output: string; marks?: string }
30export type LogRecord = {
31  when: string // local date and time the run started
32  command: string // what was typed, for example "/skin tokyo-night day"
33  from: string // "/skin mod 0.6.0"
34  tool: string // the tool folder, or what went wrong finding it
35  claudeVersion: string // installed (the app bundle); the running one comes from the tool's own lines
36  appearance: string // Claude's own Settings > Appearance: system, light, dark or unknown
37  mac: string // the Mac's light or dark mode
38  preset: { name: string; mode: Preset['mode'] } | null
39  portBefore: PortState
40  portAfter: PortState
41  portAfterFrom?: string // where the after-reading came from: the tool's own last check, or a check as the run ended
42  steps: Step[]
43  shown: string // what the chat showed
44  result: string // ok, failed, refused, or answered (the mod answered without the tool)
45  exitCode: number | null // the last step's, or null when no step ran
46  ms: number
47}
48
49export const MODE_WORDS: Record<Preset['mode'], string> = { auto: 'light and dark', dark: 'dark only', light: 'light only', unknown: 'mode not recognised' }
50
51// lsof exits 0 with a listener, 1 with none; anything else (or no answer) is unknown.
52export function portOf(r: { exitCode: number; stdout: string; stderr: string } | undefined): PortState {
53  if (r === undefined) return 'unknown'
54  if (r.exitCode === 0 && r.stdout.trim() !== '') return 'open'
55  if (r.exitCode === 1 && r.stdout.trim() === '' && r.stderr.trim() === '') return 'closed'
56  return 'unknown'
57}
58
59// `defaults read -g AppleInterfaceStyle`: "Dark" in dark mode; in light mode the key is missing (exit 1).
60export function macModeOf(r: { exitCode: number; stdout: string; stderr: string } | undefined): 'dark' | 'light' | 'unknown' {
61  if (r === undefined) return 'unknown'
62  if (r.exitCode === 0 && r.stdout.trim() === 'Dark') return 'dark'
63  if (r.exitCode === 1 && /does not exist|Could not find key/.test(r.stderr)) return 'light' // older and newer macOS wording
64  return 'unknown'
65}
66
67// Claude's Appearance setting, from its config.json. That file also holds sign-in tokens: only this one word is taken
68// out, and the rest of the text is never kept or logged.
69export function appearanceOf(configText: string | undefined): string {
70  if (configText === undefined) return 'unknown'
71  const m = /"userThemeMode"\s*:\s*"(system|light|dark)"/.exec(configText)
72  return m === null ? 'unknown' : m[1]!
73}
74
75// Known secret formats never go in the log (same rules as the tool's skin-log.mjs). Every rule is linear: ids, debugger
76// links, URL queries and user:password@ parts, whole Cookie and Authorization values, bearer values, --password style
77// flags, long keys, and the value of anything named like a password, token, key, cookie or credential, also when it sits
78// inside another value (error: password="x"). A quoted value runs to its closing quote, over line breaks; with no
79// closing quote it is redacted to the end. The home folder becomes ~. This is a filter for known formats, not a guarantee.
80export const SECRET_NAME = /^(?:[A-Za-z0-9_.-]*(?:token|passw|secret|auth|cookie|credential|api[_-]?key|apikey|session[_-]?key|private[_-]?key)[A-Za-z0-9_.-]*|key|pass|pwd|sid)$/i
81const VALUE = String.raw`"[^"\\]*(?:\\[\s\S][^"\\]*)*"?|'[^'\\]*(?:\\[\s\S][^'\\]*)*'?`
82// A name and its separator; only a secret name takes the value after it. Any other name is passed over and the scan goes
83// on from its value, so a secret inside it is still found. One scan, each name read once.
84const PAIR_NAME = /(["']?)([A-Za-z0-9_.-]{1,80})\1(\s*[:=]\s*)/g
85const PAIR_VALUE = new RegExp(String.raw`(?:${VALUE}|[^\s&,;"'}]+)`, 'y')
86// The space before a flag is looked at, not taken, so a flag that is not secret never hides the next one.
87const FLAG_NAME = /(?<=^|\s)(--?)([A-Za-z0-9_.-]{1,80})(=|[ \t]+)/g
88const FLAG_VALUE = new RegExp(String.raw`(?:${VALUE}|\S+)`, 'y')
89// "Bearer x", "Basic x", "token x": the value may be quoted and run over lines; "token = x" is a named value instead.
90// The whole run of spaces is taken (it cannot give back a space to dodge the check) before looking for ":" or "=".
91const WORD_NAME = /\b(bearer|basic|token)(?=(\s+))\2(?![:=])/gi
92function scan(text: string, nameRe: RegExp, valueRe: RegExp, nameOf: (m: RegExpExecArray) => string, isSecret = (n: string) => SECRET_NAME.test(n)): string {
93  let out = ''
94  let from = 0
95  nameRe.lastIndex = 0
96  let m: RegExpExecArray | null
97  while ((m = nameRe.exec(text)) !== null) {
98    if (!isSecret(nameOf(m))) continue
99    valueRe.lastIndex = nameRe.lastIndex
100    if (valueRe.exec(text) === null || valueRe.lastIndex === nameRe.lastIndex) continue // no value: left as it is
101    const end = valueRe.lastIndex
102    // The line breaks inside a removed value stay, so each kept line keeps its own time.
103    out += `${text.slice(from, nameRe.lastIndex)}<redacted>${'\n'.repeat((text.slice(nameRe.lastIndex, end).match(/\n/g) ?? []).length)}`
104    from = end
105    nameRe.lastIndex = end
106  }
107  return out + text.slice(from)
108}
109export function redact(text: string, home: string | undefined): string {
110  let t = text
111  if (home && home.length > 1) t = t.split(home).join('~')
112  // Named and quoted values first, whole (a quoted value can hold a web address, an escaped quote or a line break), then
113  // what is left: web addresses, ids, whole header lines, keys.
114  t = scan(t, FLAG_NAME, FLAG_VALUE, m => m[2]!)
115  t = scan(t, WORD_NAME, FLAG_VALUE, m => m[1]!, () => true)
116  t = scan(t, PAIR_NAME, PAIR_VALUE, m => m[2]!)
117  return t
118    .replace(/\b([a-z][a-z0-9+.-]{0,20}:\/\/)[^\s\/@:]{1,256}:[^\s\/@]{1,1024}@/gi, '$1<redacted>@')
119    .replace(/\b(?:wss?|https?):\/\/[^\s"'?#]{0,2048}[?#][^\s"']*/gi, m => m.replace(/[?#].*$/, '?<redacted>'))
120    .replace(/\bwss?:\/\/[^\s"']+/gi, 'ws://<redacted>')
121    .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
122    .replace(/\b((?:set-)?cookie|(?:proxy-)?authorization)(\s*:[ \t]*)[^\n]*/gi, '$1$2<redacted>')
123    .replace(/\b(?:sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{10,}/g, '<redacted>')
124    // A long run of letters and digits with both kinds in it (a key), never a path: "/" is not part of it.
125    .replace(/[A-Za-z0-9+_-]{40,}={0,2}/g, m => /\d/.test(m) && /[A-Za-z]/.test(m) ? '<redacted>' : m)
126}
127// An argument list: the value after a secret flag (--password, then its value as the next argument) is redacted too.
128export function redactArgs(args: readonly string[], home: string | undefined): string[] {
129  const out: string[] = []
130  for (let i = 0; i < args.length; i++) {
131    const a = String(args[i])
132    const m = /^--?([A-Za-z0-9_.-]{1,80})(=?)/.exec(a)
133    if (m && SECRET_NAME.test(m[1]!) && m[2] === '=') { out.push(`${a.slice(0, m[0].length)}<redacted>`); continue } // --password=two words
134    out.push(redact(a, home))
135    if (m && SECRET_NAME.test(m[1]!) && m[0].length === a.length && i + 1 < args.length) { out.push('<redacted>'); i++ }
136  }
137  return out
138}
139
140// One physical line: a field that holds a line break (a pasted command) can never start a fake entry.
141export const oneLine = (s: string) => s.replace(/\\/g, '\\\\').replace(/\r/g, '\\r').replace(/\n/g, '\\n')
142const short = (s: string, n = 300) => s.length > n ? `${s.slice(0, n)} [... cut]` : s
143
144// The chat text: its start and end, after cleaning.
145export function clip(text: string, max: number): string {
146  if (text.length <= max) return text
147  const half = Math.floor(max / 2)
148  return `${text.slice(0, half)}\n[... ${text.length - 2 * half} characters cut from the middle ...]\n${text.slice(-half)}`
149}
150
151// Output with the time each line arrived ([+1.234s] since the step began; "err|" marks stderr). Each stream is kept on its
152// own, as plain text, and only from its start: up to half of `max` characters each, and once anything of a stream is cut
153// (a line over LINE_MAX_CHARS, or the budget reached) nothing more of that stream is kept, only counted. At the end each
154// stream is cleaned whole (so a secret over several lines is seen whole, and no line from the other stream or no time
155// stamp can split it), then the lines are put back in time order with their times.
156type Pending = { at: number; text: string; extra: number }
157type Kept = { lines: string[]; times: number[]; chars: number; stopped: boolean; dropped: number }
158export class Capture {
159  private streams: Record<'stdout' | 'stderr', Kept> = {
160    stdout: { lines: [], times: [], chars: 0, stopped: false, dropped: 0 },
161    stderr: { lines: [], times: [], chars: 0, stopped: false, dropped: 0 },
162  }
163  private pend: Record<'stdout' | 'stderr', Pending | null> = { stdout: null, stderr: null }
164  constructor(private max = RAW_MAX_CHARS) {}
165  add(ms: number, stream: 'stdout' | 'stderr', text: string): void {
166    const parts = text.split('\n')
167    for (let i = 0; i < parts.length; i++) {
168      const cur = this.pend[stream] ?? { at: ms, text: '', extra: 0 }
169      const room = Math.max(0, LINE_MAX_CHARS - cur.text.length)
170      cur.text += parts[i]!.slice(0, room)
171      cur.extra += Math.max(0, parts[i]!.length - room)
172      if (i < parts.length - 1) { this.emit(cur, stream); this.pend[stream] = null } else this.pend[stream] = cur.text === '' && cur.extra === 0 ? null : cur
173    }
174  }
175  finish(clean: (t: string) => string = t => redact(t, undefined)): string {
176    for (const s of ['stdout', 'stderr'] as const) { const c = this.pend[s]; if (c) this.emit(c, s); this.pend[s] = null }
177    const rows: { at: number; s: 'stdout' | 'stderr'; line: string; i: number }[] = []
178    for (const s of ['stdout', 'stderr'] as const) {
179      const st = this.streams[s]
180      const cleaned = st.lines.length ? clean(st.lines.join('\n')).split('\n') : []
181      // Cleaning keeps the line count; if it ever did not, the times are dropped rather than put on the wrong line.
182      const timed = cleaned.length === st.times.length
183      cleaned.forEach((line, i) => rows.push({ at: timed ? st.times[i]! : st.times[0] ?? 0, s, line, i }))
184      if (st.dropped > 0) rows.push({ at: Infinity, s, line: `[... ${st.dropped} more characters of ${s} not kept: only the start of a long output is kept]`, i: Infinity })
185    }
186    rows.sort((a, b) => a.at - b.at || (a.s === b.s ? a.i - b.i : a.s === 'stdout' ? -1 : 1))
187    return rows.map(r => r.at === Infinity ? r.line : `[+${(r.at / 1000).toFixed(3)}s]${r.s === 'stderr' ? ' err|' : ''} ${r.line}`).join('\n')
188  }
189  private emit(cur: Pending, stream: 'stdout' | 'stderr') {
190    const st = this.streams[stream]
191    if (st.stopped) { st.dropped += cur.text.length + cur.extra + 1; return }
192    if (st.chars + cur.text.length + 1 > this.max / 2 || st.lines.length >= MAX_LINES) { st.stopped = true; st.dropped += cur.text.length + cur.extra + 1; return }
193    st.lines.push(cur.text)
194    st.times.push(cur.at)
195    st.chars += cur.text.length + 1
196    if (cur.extra > 0) { st.stopped = true; st.dropped += cur.extra }
197  }
198}
199
200// The raw text a run's answer is read from (the tool's own last lines, the tick prompt). Bounded: its start (512 KB) and
201// its newest end (512 KB to 1 MB) are kept, never more. `newly(marker)` is true once, for the piece in which `marker`
202// first arrives, even when it is split across two pieces.
203export class BoundedText {
204  private head = ''
205  private tail = ''
206  private cut = 0
207  private window = ''
208  private seen = new Set<string>()
209  constructor(private half = 512 * 1024) {}
210  add(t: string): void {
211    this.window = this.window.slice(-256) + t
212    if (this.tail === '' && this.head.length + t.length <= this.half) { this.head += t; return }
213    this.tail += t
214    if (this.tail.length > 2 * this.half) { this.cut += this.tail.length - this.half; this.tail = this.tail.slice(-this.half) }
215  }
216  newly(marker: string): boolean {
217    if (this.seen.has(marker) || !this.window.includes(marker)) return false
218    this.seen.add(marker)
219    return true
220  }
221  get text(): string { return this.cut > 0 ? `${this.head}\n[... ${this.cut} characters not kept ...]\n${this.tail}` : this.head + this.tail }
222  // The text an answer is built from, cleaned: whole when nothing was cut; when the middle was cut, the start is cleaned
223  // and of the end only the tool's own one-word report lines are kept (their context, and any secret's name, may be gone).
224  cleaned(clean: (t: string) => string = t => redact(t, undefined)): string {
225    if (this.cut === 0) return clean(this.head + this.tail)
226    const tail = this.tail.split('\n').filter(l => /^(PORT_STATE: (open|closed|unknown)|RESULT: (ok|failed|refused))$/.test(l.trim())).join('\n')
227    return `${clean(this.head)}\n[... ${this.cut} characters not kept ...]\n${tail}`
228  }
229}
230
231// When a skin is applied but cannot show (it has no half for the mode Claude is showing): which mode Claude shows, and
232// which the skin needs. Read from the tool's own lines.
233export function hiddenInfo(text: string): { claude: 'dark' | 'light'; needs: 'dark' | 'light' } | null {
234  const a = /has no (dark|light) version, so it is not showing now/.exec(text)
235  if (a) { const claude = a[1] as 'dark' | 'light'; return { claude, needs: claude === 'dark' ? 'light' : 'dark' } }
236  const b = /Claude is showing (dark|light) right now and this skin has only a (dark|light) version/.exec(text)
237  if (b) return { claude: b[1] as 'dark' | 'light', needs: b[2] as 'dark' | 'light' }
238  return null
239}
240// The Claude version that was really running, when the tool reported it (the helper line or status).
241export function runningVersionOf(text: string): string | null {
242  return /\(Claude (\d+(?:\.\d+){1,3})\)/.exec(text)?.[1] ?? /\bversion (\d+(?:\.\d+){1,3})\)/.exec(text)?.[1] ?? null
243}
244
245function bare(l: string): string { return l.replace(/^\[\+[\d.]+s\]( err\|)? /, '').replace(/^\[\d+(?:\.\d+)?s\] /, '').trim() }
246
247// How far a step got, read from its own output. "Opened" needs positive evidence; otherwise it says what is not known.
248type StepLike = { via: string; exitCode: number | null; output: string; marks?: string }
249export function stepPath(s: StepLike): string {
250  const o = `${s.output}\n${s.marks ?? ''}`
251  if (s.via.startsWith('debugger')) {
252    if (/debugger port seen|DOOR_MS|Port 9229 closed and confirmed|seen open/.test(o)) return `${s.via} (the debugger opened)`
253    if (/READY_TO_TICK/.test(o)) return `${s.via} (asked for the click; ${/Time is up/.test(o) ? 'no click came in time' : 'the debugger opening was not confirmed'})`
254    if (/DISCLOSED/.test(o)) return `${s.via} (started; the debugger opening was not confirmed)`
255    return `${s.via} (stopped before the debugger was asked for)`
256  }
257  if (s.via === 'colour helper' && /No colour helper is running|helper: none \(already clean\)/.test(o)) return `${s.via} (none running)`
258  return s.via
259}
260const helperLine = (s: StepLike) => { const l = meaningful(s.output).find(x => /^helper: /.test(x)); return l ? short(l, 120) : '' }
261const opened = (s: StepLike) => s.via.startsWith('debugger') && /READY_TO_TICK|DISCLOSED|debugger port seen|DOOR_MS/.test(`${s.output}\n${s.marks ?? ''}`)
262// The run's milestones and final report lines, noted from every raw line even when the kept output is cut, so the path,
263// the port blame and the summary never depend on what fitted.
264export const MARK = /^(READY_TO_TICK|DISCLOSED|PORT_SEEN|DEFERRED_CHECKS)$|debugger port seen|DOOR_MS: \d{1,7}$|Port 9229 closed and confirmed|seen open|Time is up|No colour helper is running|helper: none \(already clean\)|^PORT_STATE: (open|closed|unknown)$|^RESULT: (ok|failed|refused)$|^stopped by (SIGINT|SIGTERM|SIGHUP)$|^ended by (SIGINT|SIGTERM|SIGHUP|SIGKILL|SIGQUIT|SIGABRT|SIGSEGV|SIGBUS|SIGPIPE)$/
265// The milestones, kept while every raw piece goes by (lines split across pieces are joined first): each marker word once
266// (in the order first seen) plus the latest port state and result. Only fixed words and fixed values are kept (never a
267// whole line), so a milestone can never carry part of a secret, and the list can never fill up and lose the final port
268// state, whatever is later cut from the kept output.
269export class Milestones {
270  private seen: string[] = []
271  private port: string | null = null
272  private result: string | null = null
273  private pend: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
274  feed(stream: 'stdout' | 'stderr', text: string): void {
275    const parts = (this.pend[stream] + text).split('\n')
276    this.pend[stream] = parts.pop()!.slice(-512)
277    for (const l of parts) this.add(l)
278  }
279  add(line: string): void {
280    const m = MARK.exec(bare(line))
281    if (!m) return
282    if (m[2]) this.port = m[0]
283    else if (m[3]) this.result = m[0]
284    else { const key = m[0].replace(/ \d+$/, ''); const i = this.seen.findIndex(x => x.replace(/ \d+$/, '') === key); if (i === -1) this.seen.push(m[0]); else this.seen[i] = m[0] }
285  }
286  list(): string[] {
287    for (const s of ['stdout', 'stderr'] as const) { if (this.pend[s]) this.add(this.pend[s]); this.pend[s] = '' }
288    return [...this.seen, ...(this.port ? [this.port] : []), ...(this.result ? [this.result] : [])]
289  }
290}
291// The same, from a finished text (tests and simple uses).
292export function marksOf(text: string): string[] {
293  const m = new Milestones()
294  for (const line of text.split('\n')) m.add(line)
295  return m.list()
296}
297
298// The one plain-English line /skin log shows for an entry, built from the cleaned texts.
299const codeOf = (c: number | null) => c === null ? 'no exit code' : `exit ${c}`
300const NOISE = /^(PORT_STATE|RESULT|READY_TO_TICK|DISCLOSED|SAMPLES|HELPER|DOOR_MS|DEFERRED_CHECKS|CHILD_READY|PORT_SEEN)\b/
301const meaningful = (s: string) => s.slice(0, 50_000).split('\n').map(bare).filter(l => l !== '' && !NOISE.test(l) && !/^(Not started\.|The skin step failed \(exit -?\d+\)\.)$/.test(l))
302// The reason for a failure: the last real WARNING, else the first other telling line. Hints ("If ...") and the report's
303// own "helper: unchanged (... did not finish)" line are never the reason.
304const HINT = /^(WARNING:\s*)?(If |the backup guardian stays)|^helper: |^sampled colour checks: /
305const TELLING = /Stopped|stopped by|ended by|interrupted|Took too long|not confirmed|could not|did not|failed|STILL OPEN|Time is up|refused|unconfirmed|Not started|No preset|No colour helper|^FIX |Unexpected error|ended without/i
306export function outcome(rec: { command: string; steps: readonly StepLike[]; shown: string; result: string; exitCode: number | null; portBefore: PortState; portAfter: PortState }): string {
307  const texts = [rec.shown, ...rec.steps.map(s => `${s.output}\n${s.marks ?? ''}`)]
308  const all = texts.flatMap(meaningful)
309  // The reason comes from the chat text and the last step only: an earlier step's expected refusal (no helper yet) is
310  // never blamed for a later step's failure.
311  const own = [rec.shown, rec.steps.at(-1)?.output ?? ''].flatMap(meaningful)
312  const why = () => {
313    const warn = own.filter(l => /^WARNING:/.test(l) && !HINT.test(l)).at(-1)
314    const line = warn ?? own.find(l => TELLING.test(l) && !HINT.test(l))
315    return line === undefined ? 'no reason line was printed; see the entry' : short(line.replace(/^Not started:\s*/, '').replace(/^WARNING:\s*/, ''))
316  }
317  const hidden = hiddenInfo(texts.join('\n'))
318  const last = rec.steps.at(-1)
319  let what: string
320  if (last === undefined) {
321    const first = short(meaningful(rec.shown)[0] ?? '')
322    what = rec.result === 'refused' ? `refused before anything ran: ${first}` : rec.result === 'failed' ? `FAILED before the tool ran: ${first}` : 'answered without running the tool'
323  } else if (last.via.startsWith('status')) {
324    what = last.via === 'status check (for the list)'
325      ? `listed the skins${last.exitCode !== 0 ? `, but the status check ended with ${codeOf(last.exitCode)}: ${why()}` : ''}`
326      : last.exitCode === 0 ? `status shown${helperLine(last) ? `: ${helperLine(last)}` : ''}` : `status not shown (${codeOf(last.exitCode)}): ${why()}`
327  } else if (last.via.startsWith('health check')) {
328    // The check's own verdict line is the summary, whatever its exit code.
329    const verdict = meaningful(last.output).find(l => /^health: /.test(l))
330    what = verdict ? `health check: ${short(verdict.slice(8))}` : `health check did not finish (${codeOf(last.exitCode)}): ${why()}`
331  } else if (rec.result === 'ok') {
332    what = hidden ? `applied, but not visible: the skin is ${hidden.needs} only and Claude was showing ${hidden.claude}`
333      : /helper: none \(already clean\)/.test(last.output) ? 'nothing to remove: no colour helper was running (if a skin still shows, /skin remove --tick takes it off)'
334      : `worked (${last.via})`
335    if (last.exitCode !== 0) what += `, but its last step ended with ${codeOf(last.exitCode)}: ${why()}`
336    else {
337      // An ok run can still have printed a real warning, or colour checks that did not match: the summary says so.
338      const warn = meaningful(last.output).filter(l => /^WARNING:/.test(l) && !HINT.test(l)).at(-1)
339      if (warn) what += `, with a warning: ${short(warn.replace(/^WARNING:\s*/, ''))}`
340      const misses = /sampled colour checks: \d+ of \d+ passed \(\d+ unknown\); (\d+) did not match/.exec(last.output)
341      if (misses) what += `; colour checks: ${misses[1]} did not match`
342    }
343  } else if (rec.result === 'refused') what = `refused, nothing changed: ${why()}`
344  else what = `FAILED (${codeOf(rec.exitCode)}): ${why()}`
345  // A run is only blamed for the port when it asked for the debugger.
346  const touched = rec.steps.some(opened)
347  const port = rec.portAfter === 'closed' ? (rec.portBefore === 'open' ? 'Port 9229 is now closed (it was open before)' : 'Port 9229 closed')
348    : rec.portAfter === 'open' ? (!touched ? `Port 9229 is open, but this run did not open it${rec.portBefore === 'open' ? ' (it was already open before)' : ''}`
349      : rec.portBefore === 'open' ? 'Port 9229 still OPEN after this run' : 'Port 9229 LEFT OPEN')
350    : touched ? 'Port 9229 state unclear afterwards (no clear answer)' : 'Port 9229 not checked clearly; this run did not open it'
351  return `${rec.command}: ${what.replace(/[\s.;:]+$/, '')}. ${port}.`
352}
353
354const indent = (text: string) => text.split('\n').map(l => `  ${l}`).join('\n')
355
356// Shortens kept outputs (already cleaned, so cutting them can expose nothing) until the whole entry fits the writer's
357// limit, so a long output never makes the whole entry disappear.
358const bytes = (t: string) => new TextEncoder().encode(t).length
359export function fitEntry(render: (outs: string[]) => string, outputs: string[], limit = ENTRY_MAX_BYTES - 4096): string {
360  let outs = outputs
361  for (let i = 0; i < 20; i++) {
362    const text = render(outs)
363    const size = bytes(text)
364    if (size <= limit || outs.length === 0) return text
365    const longest = outs.reduce((a, o, j) => o.length > outs[a]!.length ? j : a, 0)
366    const keep = Math.max(0, Math.floor(outs[longest]!.length * Math.min(0.9, limit / size)) - 200)
367    outs = outs.map((o, j) => j === longest ? `${o.slice(0, keep)}\n[... the rest of this output was shortened to fit the entry limit]` : o)
368  }
369  return render(outs.map(() => '[... output left out to fit the entry limit]'))
370}
371
372export function formatEntry(rec: LogRecord, home: string | undefined): string {
373  const clean = (s: string) => redact(s, home)
374  // Every header field is one line; the raw output and the chat text are indented.
375  const field = (s: string) => oneLine(clean(s))
376  const command = rec.command.length > COMMAND_MAX_CHARS ? `${rec.command.slice(0, COMMAND_MAX_CHARS)} [... cut]` : rec.command
377  // The summary and every other line are built from the cleaned texts only.
378  const steps = rec.steps.slice(-MAX_STEPS).map(s => ({ ...s, output: clean(s.output), marks: clean(s.marks ?? '') }))
379  const shown = clip(clean(rec.shown), SHOWN_MAX_CHARS)
380  const running = runningVersionOf(steps.map(s => `${s.output}\n${s.marks}`).join('\n'))
381  const head = [
382    `${HEAD}${oneLine(rec.when)} (start)`,
383    // Built from cleaned parts only, so it is not cleaned a second time (that would eat its punctuation).
384    `summary: ${oneLine(outcome({ ...rec, command: clean(command), steps, shown }))}`,
385    `command: ${field(command)}`,
386    `from: ${field(rec.from)}`,
387    `tool: ${field(rec.tool)}`,
388    `claude: running ${field(running ?? 'not reported by this run')}; installed ${field(rec.claudeVersion)}; Appearance setting: ${field(rec.appearance)}; Mac: ${field(rec.mac)} (installed version, Appearance and Mac read just after the run)`,
389    `preset: ${rec.preset ? field(`${rec.preset.name} (${MODE_WORDS[rec.preset.mode]})`) : 'none'}`,
390    `port 9229 before: ${rec.portBefore} (checked as the run started)`,
391    `path: ${steps.length === 0 ? 'none (no tool step ran)' : field(steps.map(stepPath).join(', then '))}`,
392    `port 9229 after: ${rec.portAfter} (${rec.portAfterFrom ?? 'checked as the run ended'})`,
393    `result: ${field(rec.result)}`,
394    `exit code: ${rec.exitCode ?? 'none'}`,
395    `duration: ${(rec.ms / 1000).toFixed(2)} s`,
396  ]
397  return fitEntry(outs => {
398    const lines = [...head]
399    steps.forEach((s, i) => {
400      lines.push(`--- step ${i + 1}: ${oneLine(JSON.stringify(redactArgs(s.argv, home)))} (${s.exitCode === null ? 'no exit code' : `exit ${s.exitCode}`}, ${(s.ms / 1000).toFixed(2)} s)`)
401      lines.push(`  milestones: ${s.marks ? oneLine(s.marks.split('\n').join(' | ')) : 'none noted'}`)
402      lines.push(indent(outs[i]!.trimEnd() || '(no output)'))
403    })
404    lines.push('--- shown in chat')
405    lines.push(indent(shown.trimEnd() || '(nothing)'))
406    lines.push(END, '')
407    return lines.join('\n')
408  }, steps.map(s => s.output))
409}
410
411export type LogItem = { when: string; summary: string }
412// Reads entries back (oldest first). A torn or foreign last entry still gives its date and summary when present.
413export function parseLog(text: string): LogItem[] {
414  const items: LogItem[] = []
415  let cur: LogItem | null = null
416  for (const line of text.split('\n')) {
417    if (line === END) { if (cur) items.push(cur); cur = null; continue }
418    if (line.startsWith(HEAD)) { if (cur) items.push(cur); cur = { when: line.slice(HEAD.length).replace(/ \(start\)$/, '').trim(), summary: '' }; continue }
419    if (cur && cur.summary === '' && line.startsWith('summary: ')) cur.summary = line.slice('summary: '.length)
420  }
421  if (cur) items.push(cur)
422  return items
423}
424
425// /skin log: the last few entries, newest first, and where the full file is.
426export function logView(older: string, current: string, count: number, shownPath: string): string {
427  const items = [...parseLog(older), ...parseLog(current)].slice(-count).reverse()
428  if (items.length === 0) return `No /skin runs logged yet. The log will be at ${shownPath}`
429  return [
430    `Last ${items.length} run${items.length === 1 ? '' : 's'}, newest first:`,
431    ...items.map(i => `• ${i.when}  ${i.summary || '(no summary)'}`),
432    '',
433    `Full details (every step's output, timings, versions, port checks): ${shownPath}`,
434    'The older file, if any, is the same name ending in .1. Known secret formats (keys, tokens, passwords, ids) are removed; that is a filter, not a guarantee: read an entry before you share it.',
435  ].join('\n')
436}
437
438// The one writer, run as `/bin/sh -c APPEND_SH sh <dir> <max bytes> <entry max bytes>` with the entry on stdin. It is the
439// same text as the tool's skin-log-append.sh (a tool test checks they match), so both sides follow one protocol. It holds
440// a system lock on an open file (lockf on .skin.log.lock): the lock lasts while any part of a writer is still running and
441// is freed by macOS when the last part ends, so there is no stale lock and no overlap. It refuses a linked or foreign
442// folder, a linked, hard-linked, foreign or non-regular log (and .1), and an entry over the limit; moves the log to
443// skin.log.1 when the new entry would pass the size limit (writing nothing if that move fails); appends; and keeps the
444// folder and file private. It starts every entry on a fresh line (even after a torn one), waits at most 3 s for the lock
445// and removes its own temporary file however it ends.
446export const APPEND_SH = String.raw`umask 077
447d="$1"; max="$2"; emax="$3"; f="$d/skin.log"; lock="$d/.skin.log.lock"; tmp=""
448cleanup() { [ -n "$tmp" ] && /bin/rm -f "$tmp"; }
449trap cleanup EXIT
450trap 'exit 9' INT TERM HUP
451[ -L "$d" ] && exit 3
452/bin/mkdir -p "$d" || exit 3
453[ -d "$d" ] && [ -O "$d" ] || exit 3
454/bin/chmod 700 "$d" 2>/dev/null
455[ -L "$lock" ] && exit 3
456exec 9>>"$lock" || exit 3
457/usr/bin/lockf -s -t 3 9 || exit 4
458/usr/bin/find "$d" -maxdepth 1 -name '.entry.*' -type f -mmin +1 -delete 2>/dev/null
459tmp=$(/usr/bin/mktemp "$d/.entry.XXXXXX") || exit 3
460/usr/bin/head -c $((emax + 1)) > "$tmp"
461add=$(/usr/bin/stat -f %z "$tmp")
462[ "$add" -gt "$emax" ] && exit 8
463mine() { [ -f "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && [ "$(/usr/bin/stat -f %l "$1")" = 1 ]; }
464if [ -e "$f" ] || [ -L "$f" ]; then mine "$f" || exit 5; fi
465size=0; [ -f "$f" ] && size=$(/usr/bin/stat -f %z "$f")
466sep=0; [ "$size" -gt 0 ] && [ -n "$(/usr/bin/tail -c 1 "$f")" ] && sep=1
467if [ $((size + sep + add)) -gt "$max" ] && [ "$size" -gt 0 ]; then
468  if [ -e "$f.1" ] || [ -L "$f.1" ]; then mine "$f.1" || exit 6; fi
469  /bin/mv -f "$f" "$f.1" || exit 6
470  sep=0
471fi
472[ "$sep" = 1 ] && { printf '\n' >> "$f" || exit 7; }
473/bin/cat "$tmp" >> "$f" && /bin/chmod 600 "$f" || exit 7
474exit 0`
475
hooks/logic.ts 274 lines
1// Pure helpers for /skin: no engine calls, so they can be tested alone.
2
3import { hiddenInfo } from './log'
4
5// Letters, digits and single hyphens only: no slash, no dots, no spaces.
6const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
7export const MIN_NODE_MAJOR = 22
8
9// How a skin is applied: `auto` switches through the colour helper when one is running in this Claude, and otherwise asks
10// for one tick ("I tick it"); `tick` always asks for a new tick; `one-click` lets the run tick the menu itself.
11export type Via = 'auto' | 'tick' | 'one-click'
12export type Parsed =
13  | { kind: 'list' }
14  | { kind: 'status' }
15  | { kind: 'check' }
16  | { kind: 'about' }
17  | { kind: 'log' }
18  | { kind: 'off'; via: Via }
19  | { kind: 'remove'; via: Via }
20  | { kind: 'apply'; name: string; via: Via }
21  | { kind: 'bad'; why: string }
22
23// `oneClick` false (a tick-only release): the error texts offer --tick only.
24export function parse(args: string, oneClick = true): Parsed {
25  const words = args.trim().toLowerCase().split(/\s+/).filter(w => w !== '')
26  const flags = words.filter(w => w.startsWith('--'))
27  const rest = words.filter(w => !w.startsWith('--'))
28  const unknown = flags.find(f => f !== '--tick' && f !== '--one-click')
29  if (unknown) return { kind: 'bad', why: `Unknown option ${unknown}. Use --tick${oneClick ? ' or --one-click' : ''}.` }
30  if (flags.includes('--tick') && flags.includes('--one-click')) return { kind: 'bad', why: oneClick ? 'Choose one: --tick or --one-click.' : '--one-click is not in this release. Use --tick.' }
31  const via: Via = flags.includes('--tick') ? 'tick' : flags.includes('--one-click') ? 'one-click' : 'auto'
32  if (rest.length > 1) return { kind: 'bad', why: 'One skin name at a time, for example /skin dracula.' }
33  const word = rest[0] ?? ''
34  if (word === '' || word === 'list') return flags.length ? { kind: 'bad', why: 'Options go with a skin name, off or remove.' } : { kind: 'list' }
35  if (word === 'status') return flags.length ? { kind: 'bad', why: 'status takes no options.' } : { kind: 'status' }
36  if (word === 'check') return flags.length ? { kind: 'bad', why: 'check takes no options.' } : { kind: 'check' }
37  if (word === 'about') return flags.length ? { kind: 'bad', why: 'about takes no options.' } : { kind: 'about' }
38  if (word === 'log') return flags.length ? { kind: 'bad', why: 'log takes no options.' } : { kind: 'log' }
39  if (word === 'off') return { kind: 'off', via }
40  if (word === 'remove') return { kind: 'remove', via }
41  if (!NAME.test(word)) return { kind: 'bad', why: 'A skin name has only letters, digits and hyphens, for example dracula or catppuccin-latte.' }
42  return { kind: 'apply', name: word, via }
43}
44
45// The tool folder: the `toolDir` option first, then beside the mod (the one-repo layout), then one level up.
46export function toolDirCandidates(option: string | undefined, root: string): string[] {
47  const up = root.replace(/\/[^/]+\/?$/, '')
48  return [...(option ? [option.replace(/\/+$/, '')] : []), `${root}/skin-tool`, `${up}/skin-tool`]
49}
50// Node: the `node` option first, then the usual install places, then whatever PATH finds.
51export function nodeCandidates(option: string | undefined): string[][] {
52  return [...(option ? [[option]] : []), ['/opt/homebrew/bin/node'], ['/usr/local/bin/node'], ['/usr/bin/env', 'node']]
53}
54export function nodeMajorOk(versionText: string): boolean {
55  const m = /^v(\d+)\./.exec(versionText.trim())
56  return m !== null && Number(m[1]) >= MIN_NODE_MAJOR
57}
58
59export type Preset = { name: string; mode: 'auto' | 'dark' | 'light' | 'unknown' }
60
61// CSS comments removed in one linear pass (an unclosed "/*" ends the text).
62export function stripComments(css: string): string {
63  let out = ''
64  let i = 0
65  for (;;) {
66    const s = css.indexOf('/*', i)
67    if (s === -1) return out + css.slice(i)
68    out += `${css.slice(i, s)} `
69    const e = css.indexOf('*/', s + 2)
70    if (e === -1) return out
71    i = e + 2
72  }
73}
74
75// A pair holds one dark and one light block: Claude shows whichever matches its own Appearance.
76// The tool refuses colour-scheme queries inside a block, so counting them (outside comments) gives the mode.
77export function modeOf(css: string): Preset['mode'] {
78  const found = [...stripComments(css).matchAll(/prefers-color-scheme\s*:\s*(dark|light)/gi)].map(m => m[1]!.toLowerCase())
79  if (found.length === 2 && found[0] !== found[1]) return 'auto'
80  if (found.length === 1) return found[0] as 'dark' | 'light'
81  return 'unknown'
82}
83
84// First-time step (A1): the Developer menu only exists once Developer Mode is on. Same words everywhere.
85export const DEV_MODE = 'First time only: in Claude choose Help › Troubleshooting › Enable Developer Mode (Claude restarts); the Developer menu then appears.'
86
87export function listText(presets: readonly Preset[], macMode: 'dark' | 'light' | 'unknown'): string {
88  const names = (mode: Preset['mode']) => presets.filter(p => p.mode === mode).map(p => p.name).join(', ') || 'none'
89  // Since 17 Oct 2026 every shipped skin has both halves; the one-mode lines show only when such a file is present.
90  const some = (mode: Preset['mode']) => presets.some(p => p.mode === mode)
91  return [
92    `Light and dark (follow Claude's Appearance): ${names('auto')}`,
93    ...(some('dark') ? [`Dark only: ${names('dark')}`] : []),
94    ...(some('light') ? [`Light only: ${names('light')}`] : []),
95    ...(some('unknown') ? [`Other (mode not recognised): ${names('unknown')}`] : []),
96    `${macMode === 'unknown' ? 'I could not read your Mac\'s appearance' : `Your Mac is in ${macMode} mode`}. Claude follows the Mac only when Settings > Appearance is System${some('dark') || some('light') ? '; a dark-only or light-only skin shows only while Claude looks that way' : ''}.`,
97    '/skin <name> applies one: the first needs one tick of Developer > Enable Main Process Debugger, later switches need none until Claude quits. /skin off, /skin remove, /skin status, /skin check.',
98    DEV_MODE,
99  ].join('\n')
100}
101
102// The report lines every run prints (sampled colour checks, debugger, helper), and the few other lines worth showing.
103const KEEP = [/^sampled colour checks:/, /^debugger:/, /^helper:/, /^switch unconfirmed/, /^If the colours did not change/, /^Reapply /, /^requests:/, /^last skin:/, /^To apply /]
104const keep = (lines: string[]) => lines.filter(l => KEEP.some(re => re.test(l)))
105
106// Turns the script's output into a short plain answer. The script ends with two machine-readable lines
107// (PORT_STATE and RESULT); WARNING and NOTE lines are never hidden, whatever the exit code.
108export function summary(name: string, exitCode: number, stdout: string, stderr: string): string {
109  const lines = `${stdout}\n${stderr}`.split('\n').map(l => l.trim().replace(/^\[\d+(?:\.\d+)?s\] /, '')).filter(l => l !== '')
110  const find = (prefix: string) => lines.find(l => l.startsWith(prefix))
111  const warnings = lines.filter(l => l.includes('WARNING:'))
112  const note = find('NOTE:')
113  const portState = find('PORT_STATE:')?.slice('PORT_STATE:'.length).trim()
114  const result = find('RESULT:')?.slice('RESULT:'.length).trim()
115  const portLine = portState !== undefined && portState !== 'closed' && result !== 'refused'
116    ? `PORT 9229 IS NOT CONFIRMED CLOSED (${portState}). Quit and reopen Claude, or untick Developer > Enable Main Process Debugger.`
117    : undefined
118  const report = keep(lines)
119  // The helper's own answers (switch, status) carry their headline as the first plain line.
120  const headline = lines.find(l => /^(Skin |helper: none \(already clean\)|No colour helper|The helper could not|Removal not confirmed)/.test(l))
121  if (exitCode === 0 && result !== 'failed') {
122    // A skin with no half for the mode Claude is showing is applied but invisible: say so first, and how to see it.
123    const hidden = name === 'off' ? null : hiddenInfo(lines.join('\n'))
124    const head = hidden ? hiddenHead(name, hidden) : headline ?? (name === 'off' ? 'Skin removed.' : `Skin ${name} applied.`)
125    // The head already says how to see it: the note and the checks line that say it again are left out.
126    const shownNote = hidden && note !== undefined && /will not see it/.test(note) ? undefined : note
127    const shownReport = hidden ? report.filter(l => !/is not showing now/.test(l)) : report
128    return [head, shownNote, ...shownReport.filter(l => l !== head), ...warnings, portLine].filter(Boolean).join('\n')
129  }
130  // The click wait ran out with nothing applied: one short line. A port not confirmed closed still shows.
131  if (lines.some(l => /^WARNING: Time is up\b/.test(l)) && lines.some(l => /^helper: unchanged/.test(l))) {
132    return [TIME_UP, portLine].filter(Boolean).join('\n')
133  }
134  const noise = (l: string) => l.startsWith('RESULT:') || l.startsWith('PORT_STATE:') || l.startsWith('[') || l.startsWith('{') || l.startsWith('}') || l.startsWith('"') ||
135    l.startsWith('SAMPLES:') || l.startsWith('HELPER:') || l === 'READY_TO_TICK' || l === 'DISCLOSED' || KEEP.some(re => re.test(l))
136  const detail = lines.filter(l => !noise(l) && !l.includes('WARNING:')).slice(-4).join('\n')
137  const head = result === 'refused' || exitCode === 5 ? 'Not started.' : `The skin step failed (exit ${exitCode}).`
138  return [head, detail, ...report, ...warnings, portLine].filter(Boolean).join('\n')
139}
140
141// 15 Oct: "not showing now" must say plainly how to see the skin.
142export function hiddenHead(name: string, h: { claude: 'dark' | 'light'; needs: 'dark' | 'light' }): string {
143  const Needs = h.needs === 'dark' ? 'Dark' : 'Light'
144  return `Skin ${name} is on, but you cannot see it yet: it only has a ${h.needs} version and Claude is showing ${h.claude}. ` +
145    `To see it, open Claude's Settings and set Appearance to ${Needs}, or pick a light-and-dark skin (see /skin list), which follows Claude.`
146}
147
148export const TIME_UP = 'Time is up and nothing was applied. Do not click the menu item now; if you already did, quit and reopen Claude. No Developer menu? Turn on Help › Troubleshooting › Enable Developer Mode first (Claude restarts). To try again, run the same command.'
149
150// Did a switch find no helper (so the skin needs a tick)?
151export const noHelper = (stdout: string) => /No colour helper is running/.test(stdout)
152
153// The tick card's facts, read from the tool's own disclosure (the tool knows, before the click, what Claude will show:
154// a tab in the running Chrome, Chrome starting, or its "Inspector session is active" message where there is no Chrome).
155export type Where = 'chrome-running' | 'chrome-starts' | 'message'
156export function cardFacts(disclosure: string): { where: Where; helper: boolean } {
157  return {
158    where: /Inspector session is active/.test(disclosure) ? 'message'
159      : /Chrome will open with|may start your Chrome/.test(disclosure) ? 'chrome-starts' : 'chrome-running',
160    helper: /colour helper stays inside Claude/.test(disclosure),
161  }
162}
163
164// The card's text (15 Oct 2026, v4): one signed-off paragraph that runs the full width of the card (no columns). The
165// risk facts live in /skin about. The menu path is the one bold part, in the same grey as the rest.
166export const CARD = {
167  menu: 'Developer › Enable Main Process Debugger',
168  about: 'How it works: /skin about',
169  nudge: 'Still waiting for the click.',
170  timeUp: 'Ran out? Type /skin again.',
171  // A1: shown under the paragraph while waiting for the click.
172  devMode: 'No Developer menu? Turn on Help › Troubleshooting › Enable Developer Mode first (Claude restarts).',
173  applying: 'Got it. Painting now. Undo any time with /skin off.',
174  // After the click, when the colour helper will stay.
175  applyingHelper: 'Painting now. Later skins need no click until Claude quits.',
176} as const
177
178// The paragraph: [before the menu path, after it]. `skin` is the run's preset, or "remove" for a tick that takes the
179// skin away.
180export function cardParagraph(card: { skin: string; where: Where }): [string, string] {
181  const removing = card.skin === 'remove' || card.skin === 'off'
182  const before = removing ? 'To remove the skin, click ' : 'To enable the skin, click '
183  const done = removing ? 'the skin will be removed' : 'the skin will be applied automatically'
184  const what = card.where === 'message'
185    ? ` in the top menu bar. Claude will show a message saying Inspector session is active. Click OK, and ${done} within a second or two. Reskin turns the debugger off again as soon as that message is closed`
186    : ` in the top menu bar. ${card.where === 'chrome-starts' ? 'Chrome will start with an inspect tab' : 'A Chrome inspect tab will open'} and ${done} within a second or two. You can then safely close that tab. Reskin turns the debugger off again almost instantly`
187  const end = removing ? '.' : ', and skins stay on until Claude quits or you type /skin remove.'
188  return [before, `${what}${end}`]
189}
190
191// The countdown ring beside the title: a circle that empties as the seconds run out, with the seconds inside, drawn
192// like usage-line's rings. Colours follow the app's light or dark look; the last 5 s turn the accent colour.
193export const RING_SIZE = 26
194export function countdownSvg(left: number, total: number): { source: string; alt: string } {
195  const r = 10.5
196  const c = 2 * Math.PI * r
197  const mid = RING_SIZE / 2
198  const shown = Math.max(0, Math.min(total, Math.round(left)))
199  const filled = total > 0 ? (c * shown) / total : 0
200  const late = shown <= 5
201  const style = `text{font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;font-size:11px;font-weight:600}` +
202    `.n{fill:${late ? '#d97757' : '#1f2328'}}@media (prefers-color-scheme:dark){.n{fill:${late ? '#d97757' : '#e6e8eb'}}}`
203  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${RING_SIZE}" height="${RING_SIZE}" viewBox="0 0 ${RING_SIZE} ${RING_SIZE}"><style>${style}</style>` +
204    `<circle cx="${mid}" cy="${mid}" r="${r}" fill="none" stroke="#8a8f98" stroke-opacity=".28" stroke-width="2.5"/>` +
205    (shown > 0 ? `<circle cx="${mid}" cy="${mid}" r="${r}" fill="none" stroke="#d97757" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="${filled.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${mid} ${mid})"/>` : '') +
206    `<text class="n" x="${mid}" y="${mid + 4}" text-anchor="middle">${shown}</text></svg>`
207  return { source, alt: `${shown} seconds left to click` }
208}
209
210// "tokyo-night-auto" -> "Tokyo Night Auto" for the card's title.
211export const displayName = (skin: string) => skin.split('-').filter(Boolean).map(w => w[0]!.toUpperCase() + w.slice(1)).join(' ')
212
213// /skin about: how it works, in short lines (the chat shows it as plain text). The risk facts the card leaves out live
214// here; the open time quoted after the short-door build is measured.
215export const ABOUT = [
216  'Reskin: how it works',
217  '',
218  'Claude has no setting to recolour the whole app, and mods only reach the chat area.',
219  'Claude\'s own menu item, Developer › Enable Main Process Debugger, is the one built-in way in.',
220  DEV_MODE,
221  '',
222  'When you click it:',
223  '  • Reskin paints the skin and leaves a small colour helper inside Claude.',
224  '  • Reskin turns the debugger off by itself, usually within a second.',
225  '  • Until then, other apps on this Mac could also reach Claude. Nothing from the internet can.',
226  '  • On a Mac without Chrome, Claude shows a message and the debugger stays on until it is closed with OK.',
227  '  • If Reskin stops part-way, a timer inside Claude closes it within 22 seconds once that timer is set; before that, backup helpers try to close it for several minutes, but only once Reskin has seen it open. If in doubt, quit and reopen Claude: that always closes it.',
228  '',
229  'Every run ends with "debugger: confirmed closed", or a clear warning if that could not be confirmed.',
230  '',
231  'Reskin sends nothing online and changes no Claude files.',
232  'Skins switch with no click until Claude quits. Undo with /skin off, or quit Claude.',
233  '',
234  'Every /skin run is saved for troubleshooting in ~/Library/Logs/Reskin/skin.log (known secret formats removed). /skin log shows the last few.',
235  '',
236  'Reskin is open source: https://github.com/gregts/reskin-for-claude',
237].join('\n')
238
239// Typeahead for /skin. The word at the cursor is completed when it
240// is the skin word or a --flag. Names that start with what was typed come first, then names that merely contain it.
241// Every row is checked by parse() on the whole draft as it would read after taking it, so nothing refused is offered.
242export const VERBS = ['off', 'remove', 'status', 'check', 'list', 'log', 'about'] as const
243const FLAGS = ['--tick', '--one-click'] as const
244export const MAX_SUGGESTIONS = 12
245export type Suggestion = { text: string; description?: string }
246export const isSkinName = (name: string) => NAME.test(name)
247
248// The tool's release switch: a release.json present means tick only unless it says "oneClick": true; no file = the
249// owner's own copy, which keeps one-click.
250export function oneClickOf(releaseJson: string | null): boolean {
251  if (releaseJson === null) return true
252  try { return JSON.parse(releaseJson)?.oneClick === true } catch { return false }
253}
254export const argumentHintOf = (oneClick: boolean) => `[name | off | remove | status | check | list | log | about] [--tick${oneClick ? ' | --one-click' : ''}]`
255
256export function skinSuggestions(text: string, start: number, cursor: number, skins: readonly string[], oneClick = true): Suggestion[] {
257  const head = /^\s*\/skin\s+/i.exec(text)
258  if (head === null || start < head[0].length || cursor < start) return []
259  // Mid-word (more letters right after the cursor): taking a row would leave the tail behind, so offer nothing.
260  if (/^\S/.test(text.slice(cursor))) return []
261  const t = text.slice(start, cursor).toLowerCase()
262  if (t === '') return []
263  const args = (row: string) => `${text.slice(head[0].length, start)}${row}${text.slice(cursor)}`
264  // A flag on its own is fine as long as a name can still follow it.
265  const ok = (row: string) => parse(args(row)).kind !== 'bad' || (row.startsWith('--') && parse(`${args(row)} x`).kind !== 'bad')
266  const rows: Suggestion[] = t.startsWith('-')
267    ? FLAGS.filter(f => oneClick || f !== '--one-click').map(f => ({ text: f }))
268    : [...skins.filter(isSkinName).map(s => ({ text: s, description: 'skin' })), ...VERBS.map(v => ({ text: v }))]
269  const fits = rows.filter(r => r.text !== t && ok(r.text))
270  const starts = fits.filter(r => r.text.startsWith(t))
271  const contains = t.startsWith('-') ? [] : fits.filter(r => !r.text.startsWith(t) && r.text.includes(t))
272  return [...starts, ...contains].slice(0, MAX_SUGGESTIONS)
273}
274
types/index.d.ts 19 lines
1// The tick card above the prompt while a tick run waits for the user's click (null when no run waits).
2// where: what Claude shows after the click (a tab in the running Chrome, Chrome starting, or its "Inspector session is
3// active" message).
4// helper: the run leaves the colour helper inside Claude. nudged: the tool's 10 s "No debugger yet" hint came.
5export type TickCard = {
6  skin: string
7  left: number
8  phase: 'wait' | 'applying'
9  where: 'chrome-running' | 'chrome-starts' | 'message'
10  helper: boolean
11  nudged: boolean
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    skin: { card: TickCard | null }
17  }
18}
19