SLOPSHOPPER

callouts

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

newbandrowstoast
v0.2.2MITupdated 2026-10-12michaelezehi/nw-skills/mods/callouts
A shopper browsing a rack in a slop shop
README

NW skills

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.

Install

Claude Code plugin

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.

skills.sh

npx skills@latest add michaelezehi/nw-skills

This copies editable skill folders into your project or ~/.claude/skills.

By hand

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.

Skills (38)

Engineering

SkillWhat it doesOrigin
app-user-journeyScan 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-speedAudit 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-cronInstall a 4x-daily production backup cron for Convex + DigitalOcean Spaces projects.Original
ogInstall a dynamic Open Graph / Twitter social card route into a Next.js project.Original
own-goalRun 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-optimisationFind 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-casesGenerate a shareable QA test case .md document for the current project.Original
terminal-upgradeTurn pnpm dev / npm run dev / yarn dev / bun dev in every repo on the machine into Devdeck, a calm live terminal dashboard.Original
ts-upgradeMove 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
unlazyAnti-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-practicesReact and Next.js performance optimization guidelines from Vercel Engineering.Modified from vercel-labs/agent-skills
walkthroughAdd 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

Design and frontend

SkillWhat it doesOrigin
apple-designApple's approach to interface design and fluid, physical motion, translated for the web.Modified from emilkowalski/skill
brandkitPremium 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-burstAdds a performant click-triggered full-viewport color burst transition (scale + opacity only, no animated blur).Original
design-taste-frontendAnti-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-engEmil Kowalski's design-engineering philosophy, animation decisions, springs, gestures, component feel, and the invisible details that make software feel great.Modified from emilkowalski/skill
fedAlias for frontend-design.Original
find-animation-opportunitiesSearch a codebase or UI for places that don't animate but should, and reject everything that shouldn't.Modified from emilkowalski/skill
frontend-designThe main frontend skill. Distinctive, production-grade UI with high craft in typography, layout, colour and motion.Original
iconizeExtract individual icons from sprite sheets into named, square PNG files organized by category.Original
image-to-codeAlias for imagegen-frontend-web (image-first web flow: generate section images, then code them).Modified from Leonxlnx/taste-skill
imagegen-frontend-mobilePremium 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-webThe 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
impeccableUse 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-animationsSurvey 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-uiOPT-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-uiOPT-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-libraryPick 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
prototypeBuild 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-projectsUpgrades existing websites and apps to premium quality.Modified from Leonxlnx/taste-skill
review-animationsReviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy.Modified from emilkowalski/skill
sketchesHand-drawn SVG sketch/doodle system with a growing, brand-themeable repertoire.Original
stitch-design-tasteSemantic Design System Skill for Google Stitch.Modified from Leonxlnx/taste-skill
the-broadsideInstall the broadside editorial slide-deck design system into the current React/Next.js project.Original
the-stripe-modalInstall 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-maxUI/UX design intelligence for web and mobile.Modified from nextlevelbuilder/ui-ux-pro-max-skill

Writing

SkillWhat it doesOrigin
humanizerRewrite AI-sounding text so it reads like the writer without changing what it says.Modified from blader/humanizer
rndCreate 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
unslopCut AI tells from any writing.Original
writing-clearly-and-conciselyStrunk companion for prose humans will read.Modified from softaworks/agent-toolkit

Commands

CommandWhat it does
/cfForce-continue a context-exhausted session.
/chHandoff continuity mode: numbered task list, accountability record with every question and decision, optional HTML summary.
/fd-chFrontend design run with reconnaissance, a quality gate and an accountability record, built on the frontend-design skill.
/prRead every PR review comment, fix what is actionable, and reply to each one.
/prd-chCreate a PRD fast and well.
/researchDeep research on any topic.
/reviewerFull audit and fix pass on the current task's changes, using any review skills installed on your machine (never Greptile or CodeRabbit).

Mods

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
ModWhat it doesOrigin
savvy-progressProgress 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-barTurns 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-railsDenies 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-chimeA 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.

Handoff hooks (optional)

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

Licence

My own skills and commands are MIT, see LICENSE. Modified skills keep their upstream licence and credit; see NOTICE.md.

Source 5 files
hooks/register.tsx 156 lines
1// 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}
156
hooks/draw.tsx 233 lines
1// 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}
233
hooks/palette.ts 43 lines
1// 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}
43
hooks/parse.ts 86 lines
1// 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}
86
types/index.d.ts 38 lines
1// 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