Colour skins for all of Claude Code (Noir, Tokyo Night, Dracula, Catppuccin, Gruvbox, Everforest, Nordic, Monokai) as custom themes, picked from a pane

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What it does |
|---|---|
cheap-worker | /worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage). |
agent-progress | /progress: animated progress bar per agent (main, subagents, workers) from their task lists. |
agent-map | /map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review. |
context-bar | Context-window breakdown above the prompt. |
usage-meter | 5-hour / weekly usage in the footer. |
quick-settings | ⚙ footer button for model and effort. |
skins | /skins: colour themes (Tokyo Night, Dracula, Gruvbox, …). |
cw-pane, driver-seat-guard, evidence-band | For the driver-seat workflow; skip them elsewhere. |
/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods
On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were). |
Work laptop: provider = bedrock, launch = tabs.
crewThe mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).
crew # list running workers, pick one by number
crew ab12c # attach to cw-ab12c
If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.
Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { SKINS, skinOf, themeFile, themeRef } from './skins'
5import type { Skin } from './skins'
6
7const PANE = 'skins'
8const applied = atom({ plugin: 'skins', key: 'applied' } as const, null as string | null)
9
10// Claude Code reads custom themes from ~/.claude/themes/<slug>.json and watches the folder.
11async function installThemes($: EngineInterface) {
12 const home = await $.env.get('HOME')
13 if (!home) return
14 for (const s of SKINS) {
15 const path = `${home}/.claude/themes/skin-${s.slug}.json`
16 const want = themeFile(s)
17 if ((await $.fs.read(path).catch(() => null)) !== want) await $.fs.write(path, want)
18 }
19}
20
21// /config's theme row only takes the built-in themes, so a custom one is set the way the
22// settings schema allows it: "theme": "custom:<slug>" in ~/.claude/settings.json.
23async function settingsPath($: EngineInterface) {
24 const home = await $.env.get('HOME')
25 return home ? `${home}/.claude/settings.json` : null
26}
27
28async function currentTheme($: EngineInterface): Promise<string | undefined> {
29 const path = await settingsPath($)
30 const raw = path ? await $.fs.read(path).catch(() => null) : null
31 try {
32 const t = typeof raw === 'string' ? (JSON.parse(raw) as { theme?: unknown }).theme : undefined
33 if (typeof t === 'string') return t
34 } catch {
35 // unreadable settings: fall back to /config's view
36 }
37 const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
38 return typeof row?.value === 'string' ? row.value : undefined
39}
40
41/** `value` null removes the key (back to the default theme). Only the theme key changes. */
42export function withTheme(settingsJson: string, value: string | null): string {
43 const d = JSON.parse(settingsJson) as Record<string, unknown>
44 if (value === null) delete d.theme
45 else d.theme = value
46 return `${JSON.stringify(d, null, 2)}\n`
47}
48
49async function apply($: EngineInterface, value: string | null, label: string) {
50 const path = await settingsPath($)
51 const raw = path ? await $.fs.read(path).catch(() => null) : null
52 try {
53 if (!path || typeof raw !== 'string') throw new Error('~/.claude/settings.json not readable')
54 await $.fs.write(path, withTheme(raw, value))
55 await update($, applied, () => value ?? 'dark')
56 $.ui.toast(`Skin: ${label}`)
57 } catch (err) {
58 $.ui.toast(`Couldn't switch skin: ${err instanceof Error ? err.message : String(err)}`)
59 }
60}
61
62export const register: Register = on => {
63 on('session.start', async ($, e, next) => {
64 await $.command.register({ name: 'skins', description: 'Pick a colour skin for Claude Code' })
65 await installThemes($).catch(() => undefined)
66 return next(e)
67 })
68
69 on('command.run', { command: 'skins' }, async $ => {
70 await installThemes($).catch(() => undefined)
71 await $.ui.open({ id: PANE, title: 'Skins' })
72 return { text: 'Skins open.' }
73 })
74
75 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
76 const { Box, Button, Text } = $.ui.resolve(e)
77 const last = await read($, applied) // read every draw, so applying a skin redraws the pane
78 const theme = (await currentTheme($)) ?? last ?? undefined
79 const inUse = skinOf(theme)
80
81 const card = (s: Skin) => {
82 const isOn = inUse?.slug === s.slug
83 return (
84 <Box key={`skin-${s.slug}`} flexDirection="column" width="48%" marginBottom={1}>
85 <Box
86 flexDirection="column"
87 borderStyle="round"
88 borderColor={isOn ? s.accent : s.subtle}
89 backgroundColor={s.bg}
90 paddingX={1}
91 >
92 <Box justifyContent="space-between">
93 <Text bold color={s.fg}>{s.name}</Text>
94 {isOn && <Text color={s.accent}>✓</Text>}
95 </Box>
96 <Text color={s.fg}>
97 <Text color={s.orange}>✎ </Text>
98 <Text bold color={s.orange}>Edit</Text> units.js
99 </Text>
100 <Text color={s.fg}>
101 <Text color={s.yellow}>›_ </Text>
102 <Text bold color={s.yellow}>Run</Text> tests
103 </Text>
104 <Text>
105 {[s.cyan, s.orange, s.yellow, s.purple, s.green, s.red].map((col, i) => (
106 <Text key={`dot-${s.slug}-${i}`} color={col}>● </Text>
107 ))}
108 </Text>
109 </Box>
110 <Button
111 key={`use-${s.slug}`}
112 label={isOn ? 'In use' : `Use ${s.name}`}
113 variant={isOn ? 'secondary' : 'primary'}
114 onPress={() => (isOn ? undefined : apply($, themeRef(s), s.name))}
115 />
116 </Box>
117 )
118 }
119
120 return (
121 <Box flexDirection="column" paddingX={1}>
122 <Text bold>Skins</Text>
123 <Text dimColor>Pick one to recolour tool rows, borders, diffs and the spinner.</Text>
124 <Box flexWrap="wrap" justifyContent="space-between" marginTop={1}>
125 {SKINS.map(card)}
126 </Box>
127 <Box key="default-row" columnGap={1}>
128 <Button
129 key="use-default"
130 plain
131 dimColor={!inUse}
132 label={inUse ? '↺ back to the default dark theme' : `current: ${theme ?? 'default'}`}
133 onPress={() => (inUse ? apply($, null, 'default') : undefined)}
134 />
135 </Box>
136 </Box>
137 )
138 })
139}
140hooks/skins.ts 61 lines1/** One skin: a palette that `overridesFor` spreads over every key of Claude Code's theme. */
2export type Skin = {
3 slug: string
4 name: string
5 bg: string; panel: string; fg: string; muted: string; subtle: string; accent: string
6 blue: string; green: string; red: string; yellow: string; purple: string; cyan: string; orange: string; pink: string
7}
8
9export const SKINS: readonly Skin[] = [
10 { slug: 'noir', name: 'Noir', bg: '#121212', panel: '#1f1f1f', fg: '#e6e6e6', muted: '#8a8a8a', subtle: '#4a4a4a', accent: '#ffffff', blue: '#cfcfcf', green: '#d6d6d6', red: '#ff6b6b', yellow: '#e6e6e6', purple: '#bdbdbd', cyan: '#dedede', orange: '#e0e0e0', pink: '#ff8a8a' },
11 { slug: 'tokyo-night', name: 'Tokyo Night', bg: '#1a1b26', panel: '#24283b', fg: '#c0caf5', muted: '#565f89', subtle: '#3b4261', accent: '#ff9e64', blue: '#7aa2f7', green: '#9ece6a', red: '#f7768e', yellow: '#e0af68', purple: '#bb9af7', cyan: '#7dcfff', orange: '#ff9e64', pink: '#ff007c' },
12 { slug: 'dracula', name: 'Dracula', bg: '#282a36', panel: '#343746', fg: '#f8f8f2', muted: '#6272a4', subtle: '#44475a', accent: '#ffb86c', blue: '#8be9fd', green: '#50fa7b', red: '#ff5555', yellow: '#f1fa8c', purple: '#bd93f9', cyan: '#8be9fd', orange: '#ffb86c', pink: '#ff79c6' },
13 { slug: 'catppuccin', name: 'Catppuccin', bg: '#1e1e2e', panel: '#313244', fg: '#cdd6f4', muted: '#6c7086', subtle: '#45475a', accent: '#cba6f7', blue: '#89b4fa', green: '#a6e3a1', red: '#f38ba8', yellow: '#f9e2af', purple: '#cba6f7', cyan: '#89dceb', orange: '#fab387', pink: '#f5c2e7' },
14 { slug: 'gruvbox', name: 'Gruvbox', bg: '#282828', panel: '#32302f', fg: '#ebdbb2', muted: '#928374', subtle: '#504945', accent: '#fe8019', blue: '#83a598', green: '#b8bb26', red: '#fb4934', yellow: '#fabd2f', purple: '#d3869b', cyan: '#8ec07c', orange: '#fe8019', pink: '#d3869b' },
15 { slug: 'everforest', name: 'Everforest', bg: '#2d353b', panel: '#343f44', fg: '#d3c6aa', muted: '#859289', subtle: '#4f585e', accent: '#a7c080', blue: '#7fbbb3', green: '#a7c080', red: '#e67e80', yellow: '#dbbc7f', purple: '#d699b6', cyan: '#83c092', orange: '#e69875', pink: '#d699b6' },
16 { slug: 'nordic', name: 'Nordic', bg: '#2e3440', panel: '#3b4252', fg: '#d8dee9', muted: '#616e88', subtle: '#434c5e', accent: '#88c0d0', blue: '#81a1c1', green: '#a3be8c', red: '#bf616a', yellow: '#ebcb8b', purple: '#b48ead', cyan: '#88c0d0', orange: '#d08770', pink: '#b48ead' },
17 { slug: 'monokai', name: 'Monokai', bg: '#272822', panel: '#3e3d32', fg: '#f8f8f2', muted: '#75715e', subtle: '#49483e', accent: '#a6e22e', blue: '#66d9ef', green: '#a6e22e', red: '#f92672', yellow: '#e6db74', purple: '#ae81ff', cyan: '#66d9ef', orange: '#fd971f', pink: '#f92672' },
18]
19
20const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
21const rgb = ([r, g, b]: number[]) => `rgb(${r},${g},${b})`
22/** `amount` of `a` over `b`, as Claude Code's theme writes colours. */
23export const mix = (a: string, b: string, amount: number) => {
24 const [x, y] = [hex(a), hex(b)]
25 return rgb(x.map((v, i) => Math.round(v * amount + y[i]! * (1 - amount))))
26}
27const c = (h: string) => rgb(hex(h))
28
29/** Every theme key Claude Code's base theme has, from one palette. */
30export function overridesFor(s: Skin): Record<string, string> {
31 const t: Record<string, string> = {
32 text: c(s.fg), inverseText: c(s.bg), inactive: c(s.muted), inactiveShimmer: mix(s.fg, s.muted, 0.4), subtle: c(s.muted),
33 promptBorder: c(s.muted), promptBorderShimmer: mix(s.fg, s.muted, 0.5),
34 claude: c(s.accent), claudeShimmer: mix(s.fg, s.accent, 0.35), clawd_body: c(s.accent), clawd_background: c(s.bg), briefLabelClaude: c(s.accent),
35 claudeBlue_FOR_SYSTEM_SPINNER: c(s.blue), claudeBlueShimmer_FOR_SYSTEM_SPINNER: mix(s.fg, s.blue, 0.35),
36 permission: c(s.blue), permissionShimmer: mix(s.fg, s.blue, 0.35), suggestion: c(s.blue), ide: c(s.blue), professionalBlue: c(s.blue), briefLabelYou: c(s.blue),
37 remember: c(s.purple), autoAccept: c(s.purple), autoAcceptShimmer: mix(s.fg, s.purple, 0.35), skill: c(s.purple), merged: c(s.purple), effortUltra: c(s.purple),
38 planMode: c(s.cyan), background: c(s.cyan), bashBorder: c(s.pink),
39 success: c(s.green), error: c(s.red), warning: c(s.yellow), warningShimmer: mix(s.fg, s.yellow, 0.35), chromeYellow: c(s.yellow),
40 fastMode: c(s.orange), fastModeShimmer: mix(s.fg, s.orange, 0.35),
41 diffAdded: mix(s.green, s.bg, 0.28), diffRemoved: mix(s.red, s.bg, 0.28), diffAddedDimmed: mix(s.green, s.bg, 0.14), diffRemovedDimmed: mix(s.red, s.bg, 0.14),
42 diffAddedWord: mix(s.green, s.bg, 0.55), diffRemovedWord: mix(s.red, s.bg, 0.55),
43 userMessageBackground: c(s.panel), userMessageBackgroundHover: mix(s.fg, s.panel, 0.06), composerSidebarBackground: c(s.panel),
44 bashMessageBackgroundColor: c(s.panel), memoryBackgroundColor: c(s.panel), selectionBg: mix(s.blue, s.bg, 0.35),
45 rate_limit_fill: c(s.blue), rate_limit_empty: c(s.subtle),
46 red_FOR_SUBAGENTS_ONLY: c(s.red), blue_FOR_SUBAGENTS_ONLY: c(s.blue), green_FOR_SUBAGENTS_ONLY: c(s.green), yellow_FOR_SUBAGENTS_ONLY: c(s.yellow),
47 purple_FOR_SUBAGENTS_ONLY: c(s.purple), orange_FOR_SUBAGENTS_ONLY: c(s.orange), pink_FOR_SUBAGENTS_ONLY: c(s.pink), cyan_FOR_SUBAGENTS_ONLY: c(s.cyan),
48 }
49 const rainbow = { red: s.red, orange: s.orange, yellow: s.yellow, green: s.green, blue: s.blue, indigo: s.purple, violet: s.pink }
50 for (const [k, v] of Object.entries(rainbow)) {
51 t[`rainbow_${k}`] = c(v)
52 t[`rainbow_${k}_shimmer`] = mix(s.fg, v, 0.35)
53 }
54 return t
55}
56
57/** The file Claude Code reads from ~/.claude/themes/<slug>.json. */
58export const themeFile = (s: Skin) => JSON.stringify({ name: s.name, base: 'dark', overrides: overridesFor(s) }, null, 2)
59export const themeRef = (s: Skin) => `custom:skin-${s.slug}`
60export const skinOf = (theme: unknown) => SKINS.find(s => themeRef(s) === theme) ?? null
61types/index.d.ts 9 lines1/** The theme last applied from the pane; writing it redraws the pane (a settings change alone doesn't). */
2export type Applied = string | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 skins: { applied: Applied }
7 }
8}
9