/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 >…

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.

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.
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>![]() | ayu<br>![]() |
catppuccin<br>![]() | classic<br>![]() |
dracula<br>![]() | github<br>![]() |
gruvbox<br>![]() | linea<br>![]() |
night-owl<br>![]() | nord<br>![]() |
one<br>![]() | rose-pine<br>![]() |
solarized<br>![]() | tokyo-night<br>![]() |
verc<br>![]() | vs<br>![]() |
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.
| Need | Why |
|---|---|
A Mac with the Claude desktop app in /Applications | Reskin 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 command | Reskin ships as a Claude Code plugin. It has been used from the desktop app's Code tab. |
| Node 22 or newer | The 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.
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.
/skin list to see the skins./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.chrome://inspect tab in it. Close that tab./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.
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.
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.
claude plugin uninstall skin@reskin-for-claude.~/Library/Application Support/claude-skin/ (helper files and the last skin name) and ~/Library/Logs/Reskin/ (the log).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.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.Reskin is a hobby project. When a Claude update breaks it, fixing that comes first; everything else is best effort.
Only what was run by hand on a real Mac is listed. Everything else is untested.
| Claude desktop | Mac | macOS | Chrome | Node | What ran | Result |
|---|---|---|---|---|---|---|
| 3.6556.1 | MacBook Pro 14-inch, M4 Pro | 27.0.1 | installed, not running (Chrome started by the click) | 26.10.0 | this release's code (from a development copy, not from this repo): tick, check in dark and light | passed |
| 2.42304.2 | MacBook Pro 14-inch, M4 Pro | 27.0.1 | installed | 26.10.0 | this release's code (from a development copy, not from this repo): tick, check in dark and light | passed |
| 2.42304.0 | MacBook Pro 14-inch, M4 Pro | 27.0.1 | installed | 26.10.0 | this 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 light | passed |
| 2.42304.0 | MacBook Air 13-inch, M3 | 27.0.1 | not installed | 26.7.0 | tick and switch with an earlier build and the earlier skin set | passed |
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.
| Limit | What you get instead | Where | Test |
|---|---|---|---|
| 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 ones | judgeHealth in skin-lib.mjs | health-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 pass | judgeChecks in skin-lib.mjs | two-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 them | none | none |
| Some blue stays Claude's blue: named blues, charts, tinted tags, the drag-and-drop highlight | Left alone on purpose | accent rules in make-presets.mjs | accent.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 click | liveSession in skin-lib.mjs | two-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 helper | colour helper in skin.mjs | security.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 message | The 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.mjs | shorter-door.test.mjs |
| The Developer menu item is found by its English name | If it cannot be found to clear the tick, the run says so | UNTICK in skin.mjs | security.test.mjs: "UNTICK clears the tick without clicking ..." |
| If the debugger cannot be confirmed closed, Reskin stops | Quit and reopen Claude | guardian in skin-lib.mjs | skin-tool.test.mjs: "the guardian: closes on unknown or open, ..." |
Running skin.mjs directly has no guardian and no run lock | For developers only; use skin-auto.mjs or /skin | skin.mjs | none |
| 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 use | Measured 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 it | colours set on every page element (* rule) in make-presets.mjs | none |
| The log filter removes known secret formats only | Read an entry before you share it | skin-log.mjs | run-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).
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.
hooks/register.tsx 462 lines1import { 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}
462hooks/log.ts 475 lines1// 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`
475hooks/logic.ts 274 lines1// 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}
274types/index.d.ts 19 lines1// 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