Makes what needs you impossible to miss: `> [!ACTION]`, `> [!ASK]`, `> [!INPUT]` and `> [!SECRET]` blocks in a reply draw as bright yellow cards with a flag…

New Ward skills: agent skills and slash commands for Claude Code that I use on every project.
Most are my own. Some started as other people's skills and now carry my changes, mostly an unlazy completion gate and plain-writing rules wired in. Each one is marked in the tables below.
claude plugin marketplace add michaelezehi/nw-skills
claude plugin install nw-skills@nw-skills
Plugin skills and commands are namespaced, so /research becomes /nw-skills:research.
npx skills@latest add michaelezehi/nw-skills
This copies editable skill folders into your project or ~/.claude/skills.
Copy any folder under skills/<category>/ into ~/.claude/skills/, and any file under commands/ into ~/.claude/commands/.
Several skills point at ~/.claude/skills/unlazy/scripts/gate-check.mjs and ~/.claude/skills/unslop/SKILL.md. Install unlazy and unslop first if you take only a few.
| Skill | What it does | Origin |
|---|---|---|
app-user-journey | Scan a codebase for every user role and map each one's real journey, registration, onboarding, the day-one surface, and a proposed leaner path, then ship it as an... | Original |
build-speed | Audit and fix any project's build + deploy pipeline for speed, kill cache-busting, move builds off the deploy box into CI, wire registry pull mode, and prove every... | Original |
convex-backup-cron | Install a 4x-daily production backup cron for Convex + DigitalOcean Spaces projects. | Original |
og | Install a dynamic Open Graph / Twitter social card route into a Next.js project. | Original |
own-goal | Run a goal or a whole PRD to completion: a verifiable completion condition worked in a loop until met, with an accountability record and a /reviewer gate. | Original |
project-optimisation | Find and fix why a JS/TS monorepo is slow, from dev server memory to first-load JS per route: measure, fan out by cause, fix in parallel, re-measure, ship through staging. | Original |
qa-cases | Generate a shareable QA test case .md document for the current project. | Original |
terminal-upgrade | Turn pnpm dev / npm run dev / yarn dev / bun dev in every repo on the machine into Devdeck, a calm live terminal dashboard. | Original |
ts-upgrade | Move a repo's type checking to TypeScript 7, the native compiler, without breaking ESLint, tsup, Next, Expo, Remotion, Convex, CI or Docker builds, and measure before and after. | Original |
unlazy | Anti-laziness execution discipline, required on every task sized L or XL (the /prd-ch scale) and run on request for anything smaller. | Modified from Leonxlnx/unlazy |
vercel-react-best-practices | React and Next.js performance optimization guidelines from Vercel Engineering. | Modified from vercel-labs/agent-skills |
walkthrough | Add a guided walkthrough to any web app, one story per user role: a story bar with arrow keys, a journey reader with "show me how to get there", split prompt cards, and an optional demo tier... | Original |
| Skill | What it does | Origin |
|---|---|---|
apple-design | Apple's approach to interface design and fluid, physical motion, translated for the web. | Modified from emilkowalski/skill |
brandkit | Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. | Modified from Leonxlnx/taste-skill |
click-burst | Adds a performant click-triggered full-viewport color burst transition (scale + opacity only, no animated blur). | Original |
design-taste-frontend | Anti-slop frontend rules for LANDING PAGES, PORTFOLIOS, and MARKETING REDESIGNS: reads the brief, infers direction, ships interfaces that do not look templated, with... | Modified from Leonxlnx/taste-skill |
emil-design-eng | Emil Kowalski's design-engineering philosophy, animation decisions, springs, gestures, component feel, and the invisible details that make software feel great. | Modified from emilkowalski/skill |
fed | Alias for frontend-design. | Original |
find-animation-opportunities | Search a codebase or UI for places that don't animate but should, and reject everything that shouldn't. | Modified from emilkowalski/skill |
frontend-design | The main frontend skill. Distinctive, production-grade UI with high craft in typography, layout, colour and motion. | Original |
iconize | Extract individual icons from sprite sheets into named, square PNG files organized by category. | Original |
image-to-code | Alias for imagegen-frontend-web (image-first web flow: generate section images, then code them). | Modified from Leonxlnx/taste-skill |
imagegen-frontend-mobile | Premium mobile app screen concept images, iOS, Android, and cross-platform screens and multi-screen flows (onboarding, auth, home, profile, settings, chat, commerce,... | Modified from Leonxlnx/taste-skill |
imagegen-frontend-web | The image-first WEB flow, generate one premium horizontal reference image per section (hero, landing page, marketing site, product page, portfolio), deeply analyze... | Modified from Leonxlnx/taste-skill |
impeccable | Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise... | Modified from pbakaus/impeccable |
improve-animations | Survey a codebase's animation and motion code as a senior motion advisor, then produce a prioritized audit and self-contained implementation plans for other agents... | Modified from emilkowalski/skill |
industrial-brutalist-ui | OPT-IN STYLE PRESET, use ONLY when the user explicitly asks for brutalist / industrial / Swiss-print / terminal aesthetics, or names this skill. | Modified from Leonxlnx/taste-skill |
minimalist-ui | OPT-IN STYLE PRESET, use ONLY when the user explicitly asks for a minimalist / Notion-like / warm-monochrome editorial look, or names this skill. | Modified from Leonxlnx/taste-skill |
pick-ui-library | Pick the right library for a given frontend task from a curated, opinionated list, numbers, OTP inputs, charts, command menus, virtualization, drag and drop, toasts,... | Modified from emilkowalski/skill |
prototype | Build multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one that... | Modified from emilkowalski/skill |
redesign-existing-projects | Upgrades existing websites and apps to premium quality. | Modified from Leonxlnx/taste-skill |
review-animations | Reviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. | Modified from emilkowalski/skill |
sketches | Hand-drawn SVG sketch/doodle system with a growing, brand-themeable repertoire. | Original |
stitch-design-taste | Semantic Design System Skill for Google Stitch. | Modified from Leonxlnx/taste-skill |
the-broadside | Install the broadside editorial slide-deck design system into the current React/Next.js project. | Original |
the-stripe-modal | Install the editorial-press design system (surface card + slide-in modal flow + hero empty state) into the current React/Next.js project. | Original |
ui-ux-pro-max | UI/UX design intelligence for web and mobile. | Modified from nextlevelbuilder/ui-ux-pro-max-skill |
| Skill | What it does | Origin |
|---|---|---|
humanizer | Rewrite AI-sounding text so it reads like the writer without changing what it says. | Modified from blader/humanizer |
rnd | Create a shareable internal R&D page from a team brief, a votable consensus spec with a participant intro gate (--consensus), a screen-by-screen product build deck... | Original |
unslop | Cut AI tells from any writing. | Original |
writing-clearly-and-concisely | Strunk companion for prose humans will read. | Modified from softaworks/agent-toolkit |
| Command | What it does |
|---|---|
/cf | Force-continue a context-exhausted session. |
/ch | Handoff continuity mode: numbered task list, accountability record with every question and decision, optional HTML summary. |
/fd-ch | Frontend design run with reconnaissance, a quality gate and an accountability record, built on the frontend-design skill. |
/pr | Read every PR review comment, fix what is actionable, and reply to each one. |
/prd-ch | Create a PRD fast and well. |
/research | Deep research on any topic. |
/reviewer | Full audit and fix pass on the current task's changes, using any review skills installed on your machine (never Greptile or CodeRabbit). |
Claude Code mods (plugins with function hooks) in mods/. Each one installs on its own from this marketplace:
claude plugin marketplace add michaelezehi/nw-skills
claude plugin install savvy-progress@nw-skills
| Mod | What it does | Origin |
|---|---|---|
savvy-progress | Progress bar above the prompt and an agents panel (/agents-info) with each subagent's model, context, cost and time. Draws in 24-bit colour in any truecolor terminal (Warp, iTerm2, Ghostty, WezTerm, kitty) as well as on the desktop. | Modified from johnnyvizz/claude-kit |
agent-bar | Turns the savvy-progress bar on for every task: todos, tasks and subagent launches become its rows. Steps aside when a skill reports its own plan. Needs savvy-progress. | Original |
guard-rails | Denies git commands that sweep other agents' work (git add -A, commit -a, stash, reset, clean, restore, checkout) and dashes in commit or PR text. | Original |
session-pane | /session-pane opens a pane with the files changed this session as a tree and the latest 50 tool calls. | Original |
turn-chime | A toast and a chime when a turn that ran a minute or longer finishes. | Original |
The skills and commands report to the progress panel by the rules in references/progress-panel.md. Copy it to ~/.claude/references/. Without savvy-progress installed they skip it.
/ch, /fd-ch and /prd-ch hand work over to a fresh session when context runs low. That needs three hooks in hooks/:
context-monitor.sh warns at about 70 and 90 percent context use.precompact.sh writes .claude/handoffs/current.md (todos and modified files) before compaction.session-start.sh feeds that handoff back in when the next session starts.They are not switched on by installing the plugin. To use them, copy the scripts to ~/.claude/hooks/ and add this to ~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/context-monitor.sh" }] }],
"PreCompact": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/precompact.sh" }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/session-start.sh" }] }]
}
}
Without them the commands still run; you just lose the automatic handoff.
My own skills and commands are MIT, see LICENSE. Modified skills keep their upstream licence and credit; see NOTICE.md.
hooks/register.tsx 156 lines1// callouts: what a reply marks for the person stands out. `> [!ACTION]`, `> [!ASK]`,
2// `> [!INPUT]` and `> [!SECRET]` blocks draw as yellow cards in the transcript and stay
3// listed above the prompt, each with its answer, until answered or dismissed; a key typed
4// into a SECRET goes straight into its .env file. `> [!DONE]` draws as a finished banner.
5// Every function that takes `$` lives in this file: the engine follows `$` across no import.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Callout } from '../types'
10
11import { band, calloutBlock } from './draw'
12import { DEFAULT_PALETTE, parsePalette, themePath } from './palette'
13import { openOf, parseCallouts, upsertEnv } from './parse'
14
15const openAtom = atom({ plugin: 'callouts', key: 'open' } as const, [])
16const paletteAtom = atom({ plugin: 'callouts', key: 'palette' } as const, DEFAULT_PALETTE)
17const typedAtom = atom({ plugin: 'callouts', key: 'typed' } as const, {})
18
19/**
20 * What the person has typed into each SECRET field, by item key. Module memory only: never
21 * in $.state, the transcript or a message, and dropped once saved or dismissed.
22 */
23const secrets = new Map<string, string>()
24const keyOf = (c: Callout): string => `${c.kind}:${c.title}`
25
26/** The field's text after an edit, from the masked value the field now holds. */
27export function nextSecret(current: string, fieldValue: string): string {
28 const dots = (fieldValue.match(/•/g) ?? []).length
29 return current.slice(0, dots) + fieldValue.replace(/•/g, '')
30}
31
32/** Read the Devdeck theme, so the cards share the palette the person picked. */
33async function loadPalette($: EngineInterface): Promise<void> {
34 const xdg = await $.env.get('XDG_CONFIG_HOME').catch(() => undefined)
35 const home = await $.env.get('HOME').catch(() => undefined)
36 const path = themePath(xdg, home)
37 const text = path === null ? null : await $.fs.read(path).then(out => (typeof out === 'string' ? out : null), () => null)
38 await update($, paletteAtom, () => parsePalette(text))
39}
40
41/** Drop item `c`, and anything typed into it. */
42async function drop($: EngineInterface, c: Callout): Promise<void> {
43 secrets.delete(keyOf(c))
44 await update($, typedAtom, typed => Object.fromEntries(Object.entries(typed).filter(([k]) => k !== keyOf(c))))
45 await update($, openAtom, list => list.filter(x => keyOf(x) !== keyOf(c)))
46}
47
48/** Write the typed key into its .env file and tell the chat where it went, without the value. */
49async function saveSecret($: EngineInterface, c: Callout): Promise<void> {
50 const value = secrets.get(keyOf(c)) ?? ''
51 if (!c.secret || !value || /[\r\n]/.test(value)) {
52 $.ui.toast(value ? 'A key cannot hold a line break.' : 'Paste the key first.')
53 return
54 }
55 const { name } = c.secret
56 // Anchored to the session's folder, so the key lands in this repo whatever the process cwd.
57 const cwd = await $.session.cwd()
58 if (!cwd.startsWith('/')) throw new Error('no working directory')
59 const path = `${cwd.replace(/\/+$/, '')}/${c.secret.path}`
60 const before = await $.fs.read(path).then(out => (typeof out === 'string' ? out : ''), () => '')
61 await $.fs.write(path, upsertEnv(before, name, value))
62 await drop($, c)
63 await $.prompt.submit({ text: `Saved ${name} to ${c.secret.path} (${value.length} characters; the value is not in this chat).` })
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 const started = await next(e)
69 secrets.clear()
70 await update($, openAtom, () => [])
71 await update($, typedAtom, () => ({}))
72 await loadPalette($).catch(() => undefined)
73 return started
74 })
75
76 // A reply that marks something replaces the list; a reply that marks nothing leaves it, so
77 // an open question survives an unrelated command. The theme is read again with it.
78 on('turn.complete', async ($, e, next) => {
79 const done = await next(e)
80 // A subagent's turn is not the reply the person reads.
81 if (e.agentId === undefined && !e.isAborted) {
82 const open = openOf(parseCallouts(e.answer))
83 if (open.length > 0) {
84 secrets.clear()
85 await update($, typedAtom, () => ({}))
86 await update($, openAtom, () => open)
87 }
88 await loadPalette($).catch(() => undefined)
89 }
90 return done
91 })
92
93 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
94 const segments = parseCallouts(e.props.text)
95 if (!segments.some(s => s.kind === 'callout')) return next(e)
96 const ui = $.ui.resolve(e)
97 const palette = await read($, paletteAtom)
98 const parts: JSX.Element[] = []
99 for (const [i, s] of segments.entries()) {
100 // Markdown between the cards keeps the engine's own drawing: tables, code, lists.
101 if (s.kind === 'markdown') parts.push(<ui.Box key={`md-${i}`}>{await next({ ...e, props: { ...e.props, text: s.text } })}</ui.Box>)
102 else parts.push(calloutBlock(ui, s.callout, palette, `callout-${i}`))
103 }
104 return (
105 <ui.Box key="callouts-message" flexDirection="column">
106 {parts}
107 </ui.Box>
108 )
109 })
110
111 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
112 const open = await read($, openAtom)
113 if (open.length === 0 || e.props.hasSurvey) return next(e)
114 const ui = $.ui.resolve(e)
115 const palette = await read($, paletteAtom)
116 const typed = await read($, typedAtom)
117 const at = (index: number): Callout | undefined => open[index]
118 const mine = band({
119 ui,
120 open,
121 palette,
122 columns: e.props.bodyColumns,
123 masked: Object.fromEntries(open.map((c, i) => [i, typed[keyOf(c)] ?? 0])),
124 answer: (index, text) => {
125 const c = at(index)
126 if (!c) return
127 void drop($, c)
128 .then(() => $.prompt.submit({ text }))
129 .catch(() => undefined)
130 },
131 dismiss: index => {
132 const c = at(index)
133 if (c) void drop($, c).catch(() => undefined)
134 },
135 secretInput: (index, value) => {
136 const c = at(index)
137 if (!c) return
138 const typedNow = nextSecret(secrets.get(keyOf(c)) ?? '', value)
139 secrets.set(keyOf(c), typedNow)
140 void update($, typedAtom, t => ({ ...t, [keyOf(c)]: typedNow.length })).catch(() => undefined)
141 },
142 secretSubmit: index => {
143 const c = at(index)
144 if (c) void saveSecret($, c).catch(() => $.ui.toast('The key could not be saved.'))
145 },
146 })
147 if (mine === null) return next(e)
148 return (
149 <ui.Box key="callouts-above" flexDirection="column">
150 {mine}
151 {await next(e)}
152 </ui.Box>
153 )
154 })
155}
156hooks/draw.tsx 233 lines1// How callouts look: a yellow card in the transcript for what needs the person, a finished
2// banner for DONE, and the band above the prompt that holds the open ones with their answers.
3import type { Elements } from 'claude-code'
4
5import type { Callout, CalloutKind, Palette } from '../types'
6
7import { INK } from './palette'
8
9type Table = Elements['terminal'] | Elements['desktop'] | Elements['vscode'] | Elements['mobile']
10
11export const LABELS: Record<CalloutKind, string> = {
12 action: '⚑ N E E D S Y O U',
13 ask: '? Y O U R C A L L',
14 input: '✎ N E E D S I N P U T',
15 secret: '✱ K E Y N E E D E D',
16 done: '✓ D O N E',
17}
18
19/** One callout as it sits in the reply. */
20export function calloutBlock(ui: Table, c: Callout, palette: Palette, key: string): JSX.Element {
21 const { Box, Text } = ui
22 if (c.kind === 'done') {
23 return (
24 <Box key={key} flexDirection="column" marginTop={1}>
25 <Text key={`${key}-label`} color={palette.accent} bold>
26 {LABELS.done}
27 </Text>
28 {c.title && (
29 <Text key={`${key}-title`} color={palette.text} bold>
30 {c.title}
31 </Text>
32 )}
33 {c.body.map((line, i) => (
34 <Text key={`${key}-body-${i}`} color={palette.text}>
35 {line || ' '}
36 </Text>
37 ))}
38 </Box>
39 )
40 }
41 const hint =
42 c.kind === 'action'
43 ? 'Answer above the prompt: Done'
44 : c.kind === 'ask'
45 ? 'Pick an answer above the prompt'
46 : c.kind === 'secret'
47 ? 'Paste it above the prompt. It goes into the file, never into the chat.'
48 : 'Type it above the prompt'
49 return (
50 <Box key={key} flexDirection="column" marginTop={1} paddingX={1} backgroundColor={palette.accent}>
51 <Text key={`${key}-label`} color={INK} backgroundColor={palette.accent} bold>
52 {LABELS[c.kind]}
53 </Text>
54 <Text key={`${key}-title`} color={INK} backgroundColor={palette.accent} bold>
55 {c.title}
56 </Text>
57 {c.body.map((line, i) => (
58 <Text key={`${key}-body-${i}`} color={INK} backgroundColor={palette.accent}>
59 {line || ' '}
60 </Text>
61 ))}
62 {c.options.map((option, i) => (
63 <Text key={`${key}-opt-${i}`} color={INK} backgroundColor={palette.accent}>
64 {` ${String.fromCharCode(97 + i)} ${option}`}
65 </Text>
66 ))}
67 <Text key={`${key}-hint`} color={INK} backgroundColor={palette.accent} italic>
68 {hint}
69 </Text>
70 </Box>
71 )
72}
73
74export type BandInput = {
75 ui: Table
76 open: Callout[]
77 palette: Palette
78 columns: number
79 /** Dots to show for each SECRET's field, by its index in `open`. */
80 masked: Record<number, number>
81 /** Send `text` as the person's message for item `index`. */
82 answer: (index: number, text: string) => void
83 /** Drop item `index` without sending anything. */
84 dismiss: (index: number) => void
85 secretInput: (index: number, value: string) => void
86 secretSubmit: (index: number) => void
87}
88
89const ICONS: Record<CalloutKind, string> = { action: '⚑', ask: '?', input: '✎', secret: '✱', done: '✓' }
90
91/** `text` broken at spaces into lines of at most `width` cells; a word longer than that is split. */
92export function wrapText(text: string, width: number): string[] {
93 const room = Math.max(8, width)
94 const lines: string[] = []
95 let line = ''
96 for (const word of text.split(/\s+/).filter(Boolean)) {
97 let rest = word
98 while (rest.length > room) {
99 if (line) lines.push(line)
100 lines.push(rest.slice(0, room))
101 line = ''
102 rest = rest.slice(room)
103 }
104 if (!line) line = rest
105 else if (line.length + 1 + rest.length <= room) line = `${line} ${rest}`
106 else {
107 lines.push(line)
108 line = rest
109 }
110 }
111 if (line) lines.push(line)
112 return lines.length > 0 ? lines : ['']
113}
114
115/**
116 * Everything still open, above the prompt: a yellow header with the count, then each item
117 * in full, its question wrapped onto as many rows as it needs and each option on its own
118 * row. Nothing is cut: a band taller than the room scrolls. The first item's buttons take
119 * the hotkeys: letters, which fire only while the band holds the focus (ctrl+x tab or a
120 * click). Never digits: the engine lets a bare digit in an empty prompt press a band
121 * Button, so a message starting "1." would answer the band instead of reaching the chat.
122 */
123export function band(p: BandInput): JSX.Element | null {
124 const { Box, Text, Button, Input } = p.ui
125 if (p.open.length === 0) return null
126 const count = p.open.length
127 const head = `⚑ ${count === 1 ? '1 THING NEEDS YOU' : `${count} THINGS NEED YOU`}`
128 const width = Math.max(20, p.columns - 2)
129 const chip = (text: string, strong = true): JSX.Element => (
130 <Text color={strong ? INK : p.palette.muted} backgroundColor={strong ? p.palette.accent : undefined} bold={strong}>
131 {` ${text} `}
132 </Text>
133 )
134 const rows: JSX.Element[] = []
135 for (const [i, c] of p.open.entries()) {
136 const first = i === 0
137 const hot = (key: string): { hotkey?: string } => (first ? { hotkey: key } : {})
138 const later = (
139 <Button key={`later-${i}`} {...hot('x')} onPress={() => p.dismiss(i)}>
140 {chip(first ? 'x Not now' : 'Not now', false)}
141 </Button>
142 )
143 for (const [n, text] of wrapText(c.title, width - 2).entries()) {
144 rows.push(
145 <Box key={`title-${i}-${n}`} flexDirection="row" gap={1}>
146 <Text color={p.palette.accent} bold>
147 {n === 0 ? ICONS[c.kind] : ' '}
148 </Text>
149 <Text color={p.palette.text} bold={first}>
150 {text}
151 </Text>
152 </Box>,
153 )
154 }
155 if (c.kind === 'action') {
156 rows.push(
157 <Box key={`answers-${i}`} flexDirection="row" gap={2} paddingLeft={2}>
158 <Button key={`done-${i}`} {...hot('d')} onPress={() => p.answer(i, `Done: ${c.title}`)}>
159 {chip(first ? 'd Done' : 'Done')}
160 </Button>
161 {later}
162 </Box>,
163 )
164 } else if (c.kind === 'ask') {
165 for (const [n, option] of c.options.slice(0, 9).entries()) {
166 const label = String.fromCharCode(97 + n)
167 const lines = wrapText(option, width - 2 - (label.length + 2) - 1)
168 rows.push(
169 <Box key={`opt-${i}-${n}`} flexDirection="row" gap={1} paddingLeft={2}>
170 <Button key={`opt-btn-${i}-${n}`} label={option} {...hot(label)} onPress={() => p.answer(i, option)}>
171 {chip(label)}
172 </Button>
173 <Text color={p.palette.text}>{lines[0]}</Text>
174 </Box>,
175 )
176 for (const [k, more] of lines.slice(1).entries()) {
177 rows.push(
178 <Box key={`opt-${i}-${n}-${k}`} flexDirection="row" paddingLeft={2 + label.length + 3}>
179 <Text color={p.palette.text}>{more}</Text>
180 </Box>,
181 )
182 }
183 }
184 rows.push(
185 <Box key={`answers-${i}`} flexDirection="row" paddingLeft={2}>
186 {later}
187 </Box>,
188 )
189 } else if (c.kind === 'input') {
190 rows.push(
191 <Box key={`answers-${i}`} flexDirection="row" gap={2} paddingLeft={2}>
192 <Input key={`input-${i}`} placeholder="type here" submitLabel="send" onSubmit={value => value.trim() && p.answer(i, value.trim())} />
193 {later}
194 </Box>,
195 )
196 } else if (c.kind === 'secret') {
197 const where = c.secret ? `saved to ${c.secret.path}, never sent to the chat` : 'the title names no .env file, so it cannot be saved'
198 rows.push(
199 <Box key={`answers-${i}`} flexDirection="row" gap={2} paddingLeft={2}>
200 {c.secret ? (
201 <Input
202 key={`secret-${i}`}
203 placeholder="paste the key"
204 value={'•'.repeat(p.masked[i] ?? 0)}
205 submitLabel="save"
206 onInput={value => p.secretInput(i, value)}
207 onSubmit={() => p.secretSubmit(i)}
208 />
209 ) : null}
210 {later}
211 </Box>,
212 )
213 for (const [k, text] of wrapText(where, width - 2).entries()) {
214 rows.push(
215 <Box key={`where-${i}-${k}`} flexDirection="row" paddingLeft={2}>
216 <Text color={p.palette.muted}>{text}</Text>
217 </Box>,
218 )
219 }
220 }
221 }
222 return (
223 <Box key="callouts-band" flexDirection="column">
224 <Box key="callout-head" flexDirection="row" paddingX={1} backgroundColor={p.palette.accent}>
225 <Text color={INK} backgroundColor={p.palette.accent} bold>
226 {head}
227 </Text>
228 </Box>
229 {rows}
230 </Box>
231 )
232}
233hooks/palette.ts 43 lines1// The colours callouts draw with: the palette picked in Devdeck, read from its theme.json,
2// each token checked on its own. A copy of the devdeck mod's reader, so neither needs the other.
3import type { Palette } from '../types'
4
5export const DEFAULT_PALETTE: Palette = {
6 accent: '#fde047',
7 activity: '#f5a524',
8 text: '#ecebe4',
9 muted: '#8a887f',
10 line: '#3d3c38',
11 error: '#f87171',
12}
13
14/** Text on the accent fill: near black, so yellow cards read at a glance. */
15export const INK = '#1c1b19'
16
17const TOKENS = Object.keys(DEFAULT_PALETTE) as (keyof Palette)[]
18const HEX = /^#[0-9a-fA-F]{6}$/
19const THEME_FILE = 'devdeck/theme.json'
20
21export function themePath(xdgConfigHome: string | undefined, home: string | undefined): string | null {
22 if (xdgConfigHome?.startsWith('/')) return `${xdgConfigHome.replace(/\/+$/, '')}/${THEME_FILE}`
23 if (home?.startsWith('/')) return `${home.replace(/\/+$/, '')}/.config/${THEME_FILE}`
24 return null
25}
26
27export function parsePalette(text: string | null): Palette {
28 let raw: unknown = null
29 try {
30 raw = text === null ? null : JSON.parse(text)
31 } catch {
32 raw = null
33 }
34 const colors = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>).colors : null
35 const given = typeof colors === 'object' && colors !== null ? (colors as Record<string, unknown>) : {}
36 const out = { ...DEFAULT_PALETTE }
37 for (const token of TOKENS) {
38 const value = given[token]
39 if (typeof value === 'string' && HEX.test(value)) out[token] = value.toLowerCase()
40 }
41 return out
42}
43hooks/parse.ts 86 lines1// Finds callout blocks in a reply's markdown. A block is a run of quoted lines whose first
2// reads `> [!ACTION] title` (or ASK, INPUT, DONE). Everything else stays markdown, in order.
3import type { Callout, CalloutKind } from '../types'
4
5export type Segment = { kind: 'markdown'; text: string } | { kind: 'callout'; callout: Callout }
6
7const MARKER = /^\s*>\s*\[!(ACTION|ASK|INPUT|SECRET|DONE)\][ \t]*(.*)$/i
8const QUOTED = /^\s*>/
9const ITEM = /^[-*]\s+(.+)$/
10
11/** The reply split into markdown and callouts. One markdown segment when it holds none. */
12export function parseCallouts(text: string): Segment[] {
13 const lines = text.split('\n')
14 const out: Segment[] = []
15 let prose: string[] = []
16 const flush = (): void => {
17 const md = prose.join('\n').replace(/^\n+|\n+$/g, '')
18 if (md.trim()) out.push({ kind: 'markdown', text: md })
19 prose = []
20 }
21 for (let i = 0; i < lines.length; i++) {
22 const m = MARKER.exec(lines[i] ?? '')
23 if (!m) {
24 prose.push(lines[i] ?? '')
25 continue
26 }
27 flush()
28 const callout: Callout = { kind: (m[1] ?? 'action').toLowerCase() as CalloutKind, title: (m[2] ?? '').trim(), body: [], options: [] }
29 while (i + 1 < lines.length && QUOTED.test(lines[i + 1] ?? '') && !MARKER.test(lines[i + 1] ?? '')) {
30 i++
31 const line = (lines[i] ?? '').replace(/^\s*>\s?/, '').trimEnd()
32 const item = ITEM.exec(line.trim())
33 if (callout.kind === 'ask' && item) callout.options.push((item[1] ?? '').trim())
34 else if (line.trim() || callout.body.length) callout.body.push(line)
35 }
36 while (callout.body.length && !callout.body[callout.body.length - 1]?.trim()) callout.body.pop()
37 if (!callout.title && callout.body.length) callout.title = (callout.body.shift() ?? '').trim()
38 if (callout.kind === 'secret') {
39 const target = secretTarget(callout.title)
40 if (target) callout.secret = target
41 }
42 out.push({ kind: 'callout', callout })
43 }
44 flush()
45 return out
46}
47
48const SECRET_TITLE = /^`?([A-Z][A-Z0-9_]{1,63})`?\s+(?:in|to|into)\s+`?([^\s`]+)`?/
49
50/**
51 * The variable and file a SECRET names: `OPENROUTER_API_KEY in apps/web/.env.local`. Only a
52 * `.env` file inside the working directory: no absolute path, no `..`, so a reply can never
53 * point a key at another file.
54 */
55export function secretTarget(title: string): { name: string; path: string } | undefined {
56 const m = SECRET_TITLE.exec(title.trim())
57 if (!m) return undefined
58 const path = m[2] ?? ''
59 const base = path.split('/').pop() ?? ''
60 if (path.startsWith('/') || path.startsWith('~') || path.split('/').includes('..') || !/^\.env(\.[\w.-]+)?$/.test(base)) return undefined
61 return { name: m[1] ?? '', path }
62}
63
64/** `text` with the line for `name` set to `value`, added at the end when there is none. */
65export function upsertEnv(text: string, name: string, value: string): string {
66 const line = `${name}=${/[\s#"'\\$`]/.test(value) ? JSON.stringify(value) : value}`
67 const lines = text.split('\n')
68 const at = lines.findIndex(l => new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`).test(l))
69 if (at >= 0) lines[at] = line
70 else {
71 if (lines.length && lines[lines.length - 1] === '') lines.pop()
72 lines.push(line, '')
73 }
74 return lines.join('\n')
75}
76
77/** The blocks that wait on the person: every ACTION, ASK, INPUT and SECRET. */
78export const openOf = (segments: readonly Segment[]): Callout[] =>
79 segments.flatMap(s => (s.kind === 'callout' && s.callout.kind !== 'done' ? [s.callout] : []))
80
81/** The message a press sends back for this callout. */
82export function answerText(c: Callout, answer?: string): string {
83 if (c.kind === 'action') return `Done: ${c.title}`
84 return answer ?? ''
85}
86types/index.d.ts 38 lines1// Callouts: the blocks a reply marks for the person, and the colours they draw in.
2
3export type CalloutKind = 'action' | 'ask' | 'input' | 'secret' | 'done'
4
5export type Callout = {
6 kind: CalloutKind
7 /** The first line after the marker. */
8 title: string
9 /** The quoted lines under it, list items of an ASK excluded. */
10 body: string[]
11 /** An ASK's list items: the answers to offer. */
12 options: string[]
13 /** A SECRET's variable and the .env file it goes in; absent when the title names neither. */
14 secret?: { name: string; path: string }
15}
16
17/** The Devdeck theme's colours (theme.json `colors`), every token `#rrggbb`. */
18export type Palette = {
19 accent: string
20 activity: string
21 text: string
22 muted: string
23 line: string
24 error: string
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 callouts: {
30 /** ACTION, ASK and INPUT blocks of the last reply, until answered or dismissed. */
31 open: Callout[]
32 palette: Palette
33 /** How many characters each SECRET's field holds, by item key; never the characters. */
34 typed: Record<string, number>
35 }
36 }
37}
38