Context-window band above the prompt with RTK token savings and a clickable pixel Yemeni companion whose mood follows how much context is left

A Claude Code mod that puts a context-window band above the prompt box. It shows your RTK token savings, and a pixel Yemeni companion stands beside it. His mood follows how much context you have left. Its one-click setup also installs RTK and Matt Pocock's skills.

Pick one.
Paste into a terminal. It installs the plugin, Matt Pocock's skills and RTK, hooks RTK into Claude Code, and adds the Bash-only line to your CLAUDE.md. Run it again any time; it skips what's already done.
Windows (PowerShell):
irm https://raw.githubusercontent.com/iyiob96/Context-Mod-Companion/main/install.ps1 | iex
macOS / Linux:
curl -fsSL https://raw.githubusercontent.com/iyiob96/Context-Mod-Companion/main/install.sh | sh
Then restart Claude Code.
https://github.com/iyiob96/Context-Mod-Companion into the app's plugin settings and install yemeni-context-band./plugin install yemeni-context-band --marketplace iyiob96/Context-Mod-Companion
Then click Finish setup in the band, or run /yemeni.
The band checks four things each time a session starts. If any is missing, it shows a Finish setup row; /yemeni does the same from the prompt.
| Step | How |
|---|---|
| Install RTK | Windows: winget install rtk-ai.rtk; macOS: brew install rtk; Linux: RTK's official install script |
| Hook RTK into Claude Code | rtk init -g |
| Bash-only line in your CLAUDE.md | Adds the line below to ~/.claude/CLAUDE.md once. RTK only compresses output from the Bash tool, and on Windows Claude may use PowerShell instead |
| Matt Pocock's skills | Adds Anthropic's official plugin marketplace if needed, then installs mattpocock-skills |
Use the Bash tool for shell commands (git, npm, builds, tests, linters, docker) so RTK can compress their output. Only use PowerShell for Windows-specific tasks such as winget, .ps1 scripts or the registry.
RTK (rtk-ai/rtk, Apache-2.0) and Matt Pocock's skills are separate projects. Setup installs them from their official sources; this repo doesn't bundle them. Click Later to put setup off for the session. The band works without RTK and shows RTK –.
After setup, run /mattpocock-skills:setup-matt-pocock-skills in a project to set up Matt Pocock's skills there.
| Part | What it shows |
|---|---|
| Context | Tokens used out of your window, the auto-compact point, a % used badge, a bar split by category (system prompt, tools, MCP tools, agents, memory files, skills, messages) and a legend with token counts |
| ⚡ RTK | Total tokens RTK has saved, from rtk gain |
| Companion | A chibi Yemeni man in traditional dress (turban, khanjar, bandoliers) with no background. Click him and he raises his hand and says "Salam" |
The band pops up after the first prompt you send in a session.

| Context left | Mood |
|---|---|
| More than 80% | Happy: closed-eye grin, rosy cheeks, gentle bob |
| 60–80% | Normal: calm, slight smile, blinks |
| 40–60% | Worried: raised brows, wavy mouth, sweat drop |
| 40% or less | Devastated: tears, wailing, slumped |
The full drawing (bar chart, animated companion, click to wave) is for the Claude Code desktop app. In a terminal the band falls back to text blocks and a half-block pixel companion, and clicking him does nothing.
claude plugin disable yemeni-context-band@context-mod-companion
claude plugin enable yemeni-context-band@context-mod-companion
hooks/register.tsx the band, the companion's drawing and click, first-run setup
hooks/character.ts the pixel companion: palette, 24x40 grid per mood and pose
hooks/setup.ts setup steps, texts and plain helpers
types/index.d.ts state contract
tests/ claude plugin test suites
install.ps1 / .sh one-line installers
Check your changes with:
claude plugin validate .
claude plugin test .
The companion was drawn from a reference photo of traditional Yemeni dress, in a chunky pixel-art style.
MIT
hooks/register.tsx 589 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Rtk, Segment, Setup, SetupStep, Usage } from '../types'
5import { characterGrid, EYES, HEIGHT, PALETTE, SKIN, WIDTH } from './character'
6import type { Mood } from './character'
7import {
8 hasBashLine,
9 hasMarketplace,
10 isEnabled,
11 isHooked,
12 MATT_ID,
13 missingSummary,
14 OFFICIAL_MARKETPLACE,
15 OFFICIAL_MARKETPLACE_URL,
16 RTK_INSTALL,
17 RTK_MANUAL,
18 SETUP_COMMAND,
19 STEP_LABEL,
20 STEP_ORDER,
21 withBashLine,
22} from './setup'
23
24const isShown = atom({ plugin: 'yemeni-context-band', key: 'isShown' } as const, false)
25const usageAtom = atom({ plugin: 'yemeni-context-band', key: 'usage' } as const, null)
26const rtkAtom = atom({ plugin: 'yemeni-context-band', key: 'rtk' } as const, null)
27const rtkBaseline = atom({ plugin: 'yemeni-context-band', key: 'rtkBaseline' } as const, null)
28const setupAtom = atom({ plugin: 'yemeni-context-band', key: 'setup' } as const, null as Setup | null)
29
30const ACCENT = '#e0875a'
31const RTK = '#7ccf8a'
32const SCALE = 3 // CSS px per character pixel
33const FREE = '#3a3f4b'
34const BUFFER = '#7b8190'
35const SEGMENT_COLORS: [RegExp, string][] = [
36 [/system prompt/i, '#6c8cff'],
37 [/system tools|^tools/i, '#9aa3b5'],
38 [/mcp/i, '#a479f2'],
39 [/agent/i, '#7ccf8a'],
40 [/memory/i, '#f2a65a'],
41 [/skill/i, '#f28ab8'],
42 [/message/i, '#e86a5c'],
43]
44const FALLBACK = ['#5cc8d6', '#d6c35c', '#8fd65c', '#d65c8f']
45
46function colorOf(s: Segment, i: number): string {
47 if (s.kind === 'free') return FREE
48 if (s.kind === 'buffer') return BUFFER
49 return SEGMENT_COLORS.find(([re]) => re.test(s.name))?.[1] ?? FALLBACK[i % FALLBACK.length]!
50}
51
52function fmt(n: number): string {
53 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
54 if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
55 return `${Math.round(n)}`
56}
57
58function moodOf(leftPct: number): Mood {
59 if (leftPct > 80) return 'happy'
60 if (leftPct > 60) return 'normal'
61 if (leftPct > 40) return 'worried'
62 return 'devastated'
63}
64
65// ---------- data ----------
66
67async function refreshUsage($: EngineInterface): Promise<void> {
68 const u = await $.session.usage({ breakdown: 'summary' })
69 const b = u.context.breakdown
70 const usage: Usage = {
71 tokens: u.context.tokens ?? null,
72 window: u.context.window,
73 percent: u.context.percent ?? null,
74 compactAt: b?.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
75 segments: (b?.categories ?? [])
76 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
77 .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
78 barMax: b?.rawMaxTokens ?? u.context.window,
79 }
80 await update($, usageAtom, () => usage)
81}
82
83async function refreshRtk($: EngineInterface): Promise<void> {
84 const bin = await findRtk($)
85 if (!bin) return
86 const { exitCode, stdout } = await $.process.run([bin, 'gain', '--format', 'json'], { timeoutMs: 10_000 })
87 if (exitCode !== 0) return
88 const s = JSON.parse(stdout).summary
89 if (!s || typeof s.total_saved !== 'number') return
90 let base = await read($, rtkBaseline)
91 if (base === null || base > s.total_saved) {
92 base = s.total_saved as number
93 await update($, rtkBaseline, () => base)
94 }
95 const rtk: Rtk = {
96 total: s.total_saved,
97 session: s.total_saved - base,
98 avgPct: s.avg_savings_pct ?? 0,
99 commands: s.total_commands ?? 0,
100 }
101 await update($, rtkAtom, () => rtk)
102}
103
104const quietly = (p: Promise<unknown>) => p.catch(() => undefined)
105
106// ---------- setup (RTK, its hook, the Bash line, Matt Pocock's skills) ----------
107
108async function runQuiet($: EngineInterface, argv: string[], timeoutMs = 60_000) {
109 try {
110 return await $.process.run(argv, { timeoutMs })
111 } catch {
112 return null
113 }
114}
115
116// RTK may be installed but not yet on this process's PATH (winget adds it
117// for new processes only), so look where its installers put it too.
118let rtkPath: string | null = null
119
120async function findRtk($: EngineInterface): Promise<string | null> {
121 if (rtkPath) return rtkPath
122 const local = await $.env.get('LOCALAPPDATA')
123 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
124 const candidates = ['rtk']
125 if (local) {
126 candidates.push(`${local}/Microsoft/WinGet/Links/rtk.exe`)
127 try {
128 for (const d of await $.fs.list(`${local}/Microsoft/WinGet/Packages`))
129 if (d.name.startsWith('rtk-ai.rtk_')) candidates.push(`${local}/Microsoft/WinGet/Packages/${d.name}/rtk.exe`)
130 } catch {
131 // no winget packages folder
132 }
133 }
134 if (home) candidates.push(`${home}/.local/bin/rtk`, `${home}/.cargo/bin/rtk`)
135 candidates.push('/opt/homebrew/bin/rtk', '/usr/local/bin/rtk')
136 for (const c of candidates) {
137 const r = await runQuiet($, [c, '--version'], 10_000)
138 if (r && r.exitCode === 0) return (rtkPath = c)
139 }
140 return null
141}
142
143async function claudeBin($: EngineInterface): Promise<string> {
144 return (await $.env.get('CLAUDE_CODE_EXECPATH')) ?? 'claude'
145}
146
147async function platform($: EngineInterface): Promise<'windows' | 'mac' | 'linux'> {
148 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
149 const r = await runQuiet($, ['uname', '-s'], 5_000)
150 return r?.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
151}
152
153async function claudeMdPath($: EngineInterface): Promise<string | null> {
154 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
155 if (dir) return `${dir}/CLAUDE.md`
156 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
157 return home ? `${home}/.claude/CLAUDE.md` : null
158}
159
160async function readTextQuiet($: EngineInterface, path: string): Promise<string | null> {
161 try {
162 return String(await $.fs.read(path))
163 } catch {
164 return null
165 }
166}
167
168async function checkSetup($: EngineInterface): Promise<SetupStep[]> {
169 const missing: SetupStep[] = []
170 const rtk = await findRtk($)
171 if (!rtk) missing.push('rtk', 'hook')
172 else {
173 const shown = await runQuiet($, [rtk, 'init', '-g', '--show'])
174 if (!shown || !isHooked(shown.stdout)) missing.push('hook')
175 }
176 const md = await claudeMdPath($)
177 if (!hasBashLine(md ? await readTextQuiet($, md) : null)) missing.push('bash')
178 const list = await runQuiet($, [await claudeBin($), 'plugin', 'list', '--json'])
179 if (list && list.exitCode === 0 && !isEnabled(list.stdout, MATT_ID)) missing.push('matt')
180 return missing
181}
182
183async function setProgress($: EngineInterface, progress: string) {
184 await update($, setupAtom, s => (s ? { ...s, progress } : s))
185}
186
187async function doStep($: EngineInterface, step: SetupStep): Promise<string> {
188 if (step === 'rtk') {
189 const os = await platform($)
190 const r = await runQuiet($, RTK_INSTALL[os], 600_000)
191 rtkPath = null
192 return (await findRtk($)) ? '✓ Installed RTK' : `✗ Couldn't install RTK (${r ? (r.stderr || r.stdout).trim().slice(-200) : 'installer not found'}). ${RTK_MANUAL}`
193 }
194 if (step === 'hook') {
195 const rtk = await findRtk($)
196 if (!rtk) return '✗ Skipped the RTK hook: RTK is not installed'
197 const r = await runQuiet($, [rtk, 'init', '-g', '--auto-patch', '--no-trust-filters'])
198 return r && r.exitCode === 0 ? '✓ Hooked RTK into Claude Code' : `✗ \`rtk init -g\` failed: ${(r?.stderr || r?.stdout || '').trim().slice(-200)}`
199 }
200 if (step === 'bash') {
201 const path = await claudeMdPath($)
202 if (!path) return '✗ Could not find your home folder for CLAUDE.md'
203 const md = (await readTextQuiet($, path)) ?? ''
204 if (!hasBashLine(md)) await $.fs.write(path, withBashLine(md))
205 return `✓ Added the Bash-only line to ${path}`
206 }
207 const claude = await claudeBin($)
208 const markets = await runQuiet($, [claude, 'plugin', 'marketplace', 'list', '--json'])
209 if (!markets || !hasMarketplace(markets.stdout, OFFICIAL_MARKETPLACE)) {
210 await runQuiet($, [claude, 'plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL], 300_000)
211 }
212 const r = await runQuiet($, [claude, 'plugin', 'install', MATT_ID, '--scope', 'user'], 300_000)
213 return r && r.exitCode === 0 ? "✓ Installed Matt Pocock's skills" : `✗ Couldn't install Matt Pocock's skills: ${(r?.stderr || r?.stdout || '').trim().slice(-200)}`
214}
215
216// Runs every missing step in order; the band shows progress, then results.
217async function runSetup($: EngineInterface): Promise<string[]> {
218 const current = await read($, setupAtom)
219 if (current?.phase === 'running') return []
220 const missing = await checkSetup($)
221 await update($, setupAtom, () => ({ missing, phase: 'running' as const, progress: 'checking…', results: [] as string[] }))
222 const results: string[] = []
223 for (const step of STEP_ORDER.filter(s => missing.includes(s))) {
224 await setProgress($, `${STEP_LABEL[step]}…`)
225 results.push(await doStep($, step))
226 }
227 const left = await checkSetup($)
228 await update($, setupAtom, () => ({ missing: left, phase: 'done' as const, progress: '', results }))
229 void quietly(refreshRtk($))
230 $.ui.toast(left.length ? `Setup finished with ${left.length} step(s) left` : 'Setup complete. Restart Claude Code to load everything.')
231 return results
232}
233
234// ---------- drawing ----------
235
236function gridRects(grid: string[]): string {
237 const rects: string[] = []
238 grid.forEach((row, y) => {
239 let x = 0
240 while (x < row.length) {
241 const ch = row[x]!
242 let end = x + 1
243 while (end < row.length && row[end] === ch) end++
244 const fill = PALETTE[ch]
245 if (ch !== '.' && fill) rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
246 x = end
247 }
248 })
249 return rects.join('')
250}
251
252// "Salam" in a 5-row pixel font, for the speech bubble.
253const GLYPHS: Record<string, string[]> = {
254 S: ['###', '#..', '###', '..#', '###'],
255 a: ['...', '.##', '#.#', '#.#', '.##'],
256 l: ['#', '#', '#', '#', '#'],
257 m: ['.....', '####.', '#.#.#', '#.#.#', '#.#.#'],
258}
259
260function pixelText(text: string, x0: number, y0: number, fill: string): { svg: string; width: number } {
261 const out: string[] = []
262 let x = x0
263 for (const ch of text) {
264 const g = GLYPHS[ch]
265 if (!g) continue
266 g.forEach((row, y) => [...row].forEach((c, dx) => c === '#' && out.push(`<rect x="${x + dx}" y="${y0 + y}" width="1" height="1" fill="${fill}"/>`)))
267 x += g[0]!.length + 1
268 }
269 return { svg: out.join(''), width: x - x0 - 1 }
270}
271
272// Rows of headroom above him for the bubble.
273const HEADROOM = 11
274const GREET = '2.6s'
275
276function bubbleSvg(): string {
277 const text = pixelText('Salam', 0, 0, '#9b1b1e')
278 const w = text.width + 4 // 1px border + 1px padding each side
279 const x0 = Math.max(0, Math.floor((WIDTH - w) / 2))
280 const ink = '#1a1a1a'
281 const parts = [
282 // body with clipped corners, then border
283 `<rect x="${x0 + 1}" y="1" width="${w - 2}" height="7" fill="#fbf7ee"/>`,
284 `<rect x="${x0 + 1}" y="0" width="${w - 2}" height="1" fill="${ink}"/>`,
285 `<rect x="${x0 + 1}" y="8" width="${w - 2}" height="1" fill="${ink}"/>`,
286 `<rect x="${x0}" y="1" width="1" height="7" fill="${ink}"/>`,
287 `<rect x="${x0 + w - 1}" y="1" width="1" height="7" fill="${ink}"/>`,
288 // tail pointing down at his head
289 `<rect x="${x0 + 8}" y="8" width="3" height="1" fill="#fbf7ee"/>`,
290 `<rect x="${x0 + 7}" y="8" width="1" height="2" fill="${ink}"/>`,
291 `<rect x="${x0 + 8}" y="9" width="2" height="1" fill="#fbf7ee"/>`,
292 `<rect x="${x0 + 10}" y="9" width="1" height="1" fill="${ink}"/>`,
293 `<rect x="${x0 + 8}" y="10" width="2" height="1" fill="${ink}"/>`,
294 `<g transform="translate(${x0 + 2} 2)">${text.svg}</g>`,
295 ]
296 return `<g visibility="hidden">${parts.join('')}<set attributeName="visibility" to="visible" begin="hit.click" dur="${GREET}"/><animateTransform attributeName="transform" type="translate" values="0 2;0 0" dur="0.15s" begin="hit.click" fill="freeze"/></g>`
297}
298
299// Clicking him (desktop draws the Svg in a script-less frame, where SMIL still
300// runs) swaps in the waving pose and pops the bubble for a moment; no state.
301function characterSvg(mood: Mood): string {
302 const blink =
303 mood === 'devastated'
304 ? ''
305 : EYES.map(
306 r =>
307 `<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="${SKIN}" opacity="0"><animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.93;0.96;1" dur="${mood === 'worried' ? 2.2 : 4.5}s" repeatCount="indefinite"/></rect>`,
308 ).join('')
309 const bob =
310 mood === 'happy'
311 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.5;0 0" dur="1.6s" repeatCount="indefinite"/>`
312 : mood === 'worried'
313 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0.3 0;0 0;-0.3 0;0 0" dur="0.5s" repeatCount="indefinite"/>`
314 : ''
315 const rest = `<g>${gridRects(characterGrid(mood, 'rest'))}${blink}<set attributeName="visibility" to="hidden" begin="hit.click" dur="${GREET}"/></g>`
316 const wave = `<g visibility="hidden">${gridRects(characterGrid(mood, 'wave'))}<set attributeName="visibility" to="visible" begin="hit.click" dur="${GREET}"/></g>`
317 const H = HEIGHT + HEADROOM
318 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${H}" width="${WIDTH * SCALE}" height="${H * SCALE}" shape-rendering="crispEdges">${bubbleSvg()}<g transform="translate(0 ${HEADROOM})"><g>${bob}${rest}${wave}</g></g><rect id="hit" x="0" y="${HEADROOM}" width="${WIDTH}" height="${HEIGHT}" fill="transparent" pointer-events="all" style="cursor:pointer"><title>Say salam</title></rect></svg>`
319}
320
321function barSvg(usage: Usage): string {
322 const W = 1000
323 const H = 14
324 const total = Math.max(usage.barMax, 1)
325 const segs = usage.segments.length
326 ? usage.segments
327 : [
328 { name: 'used', tokens: usage.tokens ?? 0, kind: 'used' as const },
329 { name: 'free', tokens: Math.max(usage.window - (usage.tokens ?? 0), 0), kind: 'free' as const },
330 ]
331 let x = 0
332 const parts = segs.map((s, i) => {
333 const w = (s.tokens / total) * W
334 const rect =
335 s.kind === 'buffer'
336 ? `<rect x="${x}" y="0" width="${w}" height="${H}" fill="url(#hatch)"/>`
337 : `<rect x="${x}" y="0" width="${w}" height="${H}" fill="${colorOf(s, i)}"/>`
338 x += w
339 return rect
340 })
341 const marker =
342 usage.compactAt !== null
343 ? `<rect x="${Math.min((usage.compactAt / total) * W, W - 3)}" y="0" width="3" height="${H}" fill="${ACCENT}"/>`
344 : ''
345 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none"><defs><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="8" height="8" fill="${FREE}"/><rect width="3" height="8" fill="${BUFFER}"/></pattern><clipPath id="c"><rect width="${W}" height="${H}" rx="4"/></clipPath></defs><g clip-path="url(#c)"><rect width="${W}" height="${H}" fill="${FREE}"/>${parts.join('')}${marker}</g></svg>`
346}
347
348// Terminal: one Raster of half blocks, two pixel rows per cell.
349function characterRaster(mood: Mood): { cells: string; columns: number; rows: number } {
350 const grid = characterGrid(mood)
351 const rows = Math.ceil(HEIGHT / 2)
352 const words = new Uint32Array(WIDTH * rows * 3)
353 const DEFAULT = 0x01000000
354 const hex = (ch: string | undefined) => {
355 const c = ch && ch !== '.' ? PALETTE[ch] : undefined
356 return c ? parseInt(c.slice(1), 16) : DEFAULT
357 }
358 for (let r = 0; r < rows; r++) {
359 for (let x = 0; x < WIDTH; x++) {
360 const i = (r * WIDTH + x) * 3
361 words[i] = 0x2580 // ▀
362 words[i + 1] = hex(grid[r * 2]?.[x])
363 words[i + 2] = hex(grid[r * 2 + 1]?.[x])
364 }
365 }
366 return { cells: toBase64(new Uint8Array(words.buffer)), columns: WIDTH, rows }
367}
368
369function toBase64(bytes: Uint8Array): string {
370 const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
371 let out = ''
372 for (let i = 0; i < bytes.length; i += 3) {
373 const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2]
374 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
375 out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]! + (b === undefined ? '=' : A[(n >> 6) & 63]!) + (c === undefined ? '=' : A[n & 63]!)
376 }
377 return out
378}
379
380function textBar(usage: Usage, width: number): { color: string; text: string }[] {
381 const total = Math.max(usage.barMax, 1)
382 const segs = usage.segments.length
383 ? usage.segments
384 : [{ name: 'used', tokens: usage.tokens ?? 0, kind: 'used' as const }]
385 const out: { color: string; text: string }[] = []
386 let used = 0
387 segs.forEach((s, i) => {
388 if (s.kind === 'free') return
389 const n = Math.max(1, Math.round((s.tokens / total) * width))
390 if (used + n > width) return
391 used += n
392 out.push({ color: colorOf(s, i), text: (s.kind === 'buffer' ? '▒' : '█').repeat(n) })
393 })
394 if (used < width) out.push({ color: FREE, text: '░'.repeat(width - used) })
395 return out
396}
397
398// ---------- hooks ----------
399
400export const register: Register = on => {
401 on('session.start', async ($, e, next) => {
402 const result = await next(e)
403 await $.command.register({
404 name: SETUP_COMMAND,
405 description: "Set up RTK, its hook, the Bash-only CLAUDE.md line and Matt Pocock's skills",
406 })
407 void quietly(
408 (async () => {
409 const missing = await checkSetup($)
410 const prev = await read($, setupAtom)
411 if (prev?.phase === 'running') return
412 await update($, setupAtom, () => ({ missing, phase: (missing.length ? 'idle' : 'hidden') as Setup['phase'], progress: '', results: [] }))
413 await refreshRtk($)
414 })(),
415 )
416 return result
417 })
418
419 on('command.run', { command: SETUP_COMMAND }, async $ => {
420 const results = await runSetup($)
421 const left = (await read($, setupAtom))?.missing ?? []
422 return {
423 text: results.length
424 ? [...results, '', left.length ? `Still to do: ${missingSummary(left)}.` : 'All set. Restart Claude Code to load RTK\'s hook and Matt Pocock\'s skills.'].join('\n')
425 : 'Everything is already set up.',
426 }
427 })
428
429 // The band pops up once the first prompt of the session is sent.
430 on('prompt.submit', async ($, e, next) => {
431 await update($, isShown, () => true)
432 void quietly(refreshRtk($))
433 return next(e)
434 }).catch(($, e, next) => next(e)) // never hold a prompt back over the band
435
436 on('session.measure', async ($, e, next) => {
437 if (e.changed.includes('context')) await quietly(refreshUsage($))
438 return next(e)
439 })
440
441 on('turn.complete', async ($, e, next) => {
442 void quietly(refreshRtk($))
443 return next(e)
444 })
445
446 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
447 if (e.props.hasSurvey) return next(e)
448 const setup = await read($, setupAtom)
449 const shown = await read($, isShown)
450 const needsSetup = !!setup && (setup.phase === 'running' || setup.phase === 'done' || (setup.phase === 'idle' && setup.missing.length > 0))
451 if (!shown && !needsSetup) return next(e)
452
453 // The first-run setup row: shown until setup is done or put off.
454 const ui = $.ui.resolve(e)
455 const hide = () => update($, setupAtom, s => (s ? { ...s, phase: 'hidden' as const } : s))
456 const setupRow = !needsSetup || !setup ? null : setup.phase === 'running' ? (
457 <ui.Box key="setup" flexDirection="row" gap={1} borderStyle="round" borderColor={ACCENT} paddingX={1}>
458 <ui.Text color={ACCENT} bold>Setting up</ui.Text>
459 <ui.Text dimColor>{setup.progress}</ui.Text>
460 </ui.Box>
461 ) : setup.phase === 'done' ? (
462 <ui.Box key="setup" flexDirection="column" borderStyle="round" borderColor={setup.missing.length ? '#f2c14e' : RTK} paddingX={1}>
463 {setup.results.map(r => (
464 <ui.Text>{r}</ui.Text>
465 ))}
466 <ui.Box flexDirection="row" gap={1} alignItems="center">
467 <ui.Text dimColor>
468 {setup.missing.length
469 ? `Still to do: ${missingSummary(setup.missing)}.`
470 : "Restart Claude Code to load RTK's hook and Matt Pocock's skills."}
471 </ui.Text>
472 {setup.missing.length ? <ui.Button key="retry" label="Try again" onPress={() => void runSetup($)} /> : null}
473 <ui.Button key="ok" label="OK" onPress={() => void hide()} />
474 </ui.Box>
475 </ui.Box>
476 ) : (
477 <ui.Box key="setup" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap" borderStyle="round" borderColor={ACCENT} paddingX={1}>
478 <ui.Text color={ACCENT} bold>Finish setup:</ui.Text>
479 <ui.Text dimColor>{`${missingSummary(setup.missing)}.`}</ui.Text>
480 <ui.Button key="setup-run" variant="primary" label="Finish setup" onPress={() => void runSetup($)} />
481 <ui.Button key="setup-later" label="Later" onPress={() => void hide()} />
482 </ui.Box>
483 )
484 const withSetup = (band: ReturnType<typeof ui.Box>) =>
485 setupRow ? (
486 <ui.Box flexDirection="column" gap={1}>
487 {setupRow}
488 {band}
489 </ui.Box>
490 ) : (
491 band
492 )
493 if (!shown) return setupRow ?? next(e)
494
495 const usage = await read($, usageAtom)
496 const rtk = await read($, rtkAtom)
497 const pct = usage?.percent ?? 0
498 const left = 100 - pct
499 const mood = moodOf(left)
500 const window = usage?.window ?? 0
501
502 const legend = (usage?.segments ?? [])
503 .map((s, i) => ({ s, color: colorOf(s, i) }))
504 .filter(({ s }) => s.kind !== 'buffer')
505
506 const headline = usage
507 ? `${usage.tokens !== null ? fmt(usage.tokens) : '–'} of ${fmt(window)}${usage.compactAt !== null ? ` · compacts at ${fmt(usage.compactAt)}` : ''}`
508 : 'measuring after the first reply…'
509
510 const rtkTotal = rtk ? fmt(rtk.total) : '–'
511
512 if (e.surface === 'terminal') {
513 const { Box, Text, Raster } = $.ui.resolve(e)
514 const art = characterRaster(mood)
515 const barWidth = Math.max(10, e.props.bodyColumns - WIDTH - 8)
516 return withSetup(
517 <Box flexDirection="row" alignItems="flex-end" gap={2}>
518 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderDimColor paddingX={1}>
519 <Box flexDirection="row" justifyContent="space-between">
520 <Box flexDirection="row" gap={1}>
521 <Text color={ACCENT} bold>◆ context</Text>
522 <Text dimColor>{headline}</Text>
523 <Text bold color={ACCENT}>{`[${Math.round(pct)}%]`}</Text>
524 </Box>
525 <Text color={RTK} bold>{`⚡ RTK ${rtkTotal}`}</Text>
526 </Box>
527 <Box flexDirection="row">
528 {usage ? textBar(usage, barWidth).map(p => <Text color={p.color}>{p.text}</Text>) : <Text dimColor>…</Text>}
529 </Box>
530 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
531 {legend.map(({ s, color }) => (
532 <Box flexDirection="row" gap={1}>
533 <Text color={color}>■</Text>
534 <Text dimColor>{`${s.name.toLowerCase()} ${fmt(s.tokens)}`}</Text>
535 </Box>
536 ))}
537 </Box>
538 </Box>
539 <Raster key="yemeni" columns={art.columns} rows={art.rows} cells={art.cells} />
540 </Box>,
541 )
542 }
543
544 const table = $.ui.resolve(e)
545 const { Box, Text } = table
546 const Svg = 'Svg' in table ? table.Svg : undefined
547
548 // Layout A: the band is a framed card; he stands on his own to its right,
549 // feet level with the band's bottom edge, right above the prompt.
550 return withSetup(
551 <Box flexDirection="row" alignItems="flex-end" gap={2}>
552 <Box flexDirection="row" alignItems="center" gap={2} flexGrow={1} flexShrink={1} borderStyle="round" borderDimColor paddingX={1}>
553 <Box flexDirection="column" flexGrow={1} flexShrink={1} gap={1}>
554 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
555 <Text color={ACCENT} bold>◆ context</Text>
556 <Box flexDirection="row" gap={1} alignItems="center">
557 <Text dimColor>{headline}</Text>
558 <Text bold color="#1b1b1b" backgroundColor={left > 40 ? '#9be3a0' : left > 30 ? '#f2c14e' : '#f07a6a'}>
559 {` ${Math.round(pct)}% `}
560 </Text>
561 </Box>
562 </Box>
563 {Svg && usage ? <Svg source={barSvg(usage)} alt={`Context ${Math.round(pct)}% used`} /> : null}
564 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
565 {legend.map(({ s, color }) => (
566 <Box flexDirection="row" gap={1}>
567 <Text color={color}>■</Text>
568 <Text dimColor>{s.name.toLowerCase()}</Text>
569 <Text bold>{fmt(s.tokens)}</Text>
570 </Box>
571 ))}
572 </Box>
573 </Box>
574 <Text bold color={RTK}>{`⚡ RTK ${rtkTotal}`}</Text>
575 </Box>
576 {Svg ? (
577 <Svg
578 source={characterSvg(mood)}
579 alt={`Pixel Yemeni companion, ${mood}`}
580 width={WIDTH * SCALE}
581 height={(HEIGHT + HEADROOM) * SCALE}
582 isInteractive
583 />
584 ) : null}
585 </Box>,
586 )
587 })
588}
589hooks/character.ts 341 lines1// Variant A "chibi": full-body Yemeni man, 24x40, transparent background.
2// Pure data module, no imports.
3
4export type Mood = 'happy' | 'normal' | 'worried' | 'devastated'
5export type Pose = 'rest' | 'wave'
6
7export const WIDTH = 24
8export const HEIGHT = 40
9
10// one char per key -> '#rrggbb'. '.' = transparent (not in PALETTE).
11export const PALETTE: Record<string, string> = {
12 K: '#1a1210', // outline / pupils
13 R: '#9a1f2a', // turban red
14 r: '#651219', // turban shade
15 G: '#f0b03a', // embroidery gold
16 g: '#c46d1c', // embroidery orange
17 H: '#231814', // hair / beard / brows
18 S: '#d29467', // skin
19 s: '#ad6f45', // skin shade
20 n: '#e8786a', // blush
21 W: '#f7f5ef', // white cloth / eye white / teeth
22 w: '#c4c7cf', // white shade
23 L: '#7a3f1f', // leather
24 l: '#4c2412', // dark leather
25 Y: '#f2c94c', // gold studs / brass
26 J: '#3a2418', // jambiya handle
27 A: '#ecd2a8', // jambiya sheath
28 a: '#c09a68', // sheath shade
29 M: '#7c2620', // lips / mouth line
30 m: '#3d0d0d', // mouth interior
31 t: '#e0707a', // tongue
32 D: '#4aa8ee', // tears / sweat
33 d: '#b6e4ff', // tear highlight
34 k: '#34343c', // socks / shoes
35 v: '#6a6a78', // shoe highlight
36}
37
38export const SKIN = PALETTE.S
39
40// Eye rects for the blink overlay (normal head position; devastated head is 1 row lower).
41export const EYES: { x: number; y: number; w: number; h: number }[] = [
42 { x: 7, y: 11, w: 3, h: 2 },
43 { x: 14, y: 11, w: 3, h: 2 },
44]
45
46type Grid = string[][]
47
48function blank(): Grid {
49 const g: Grid = []
50 for (let y = 0; y < HEIGHT; y++) {
51 const row: string[] = []
52 for (let x = 0; x < WIDTH; x++) row.push('.')
53 g.push(row)
54 }
55 return g
56}
57// Drawing coordinates are shifted down 1 row so the outline fits above the turban.
58function px(g: Grid, x: number, y0: number, c: string): void {
59 const y = y0 + 1
60 if (x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT) g[y]![x] = c
61}
62function rect(g: Grid, x0: number, y0: number, x1: number, y1: number, c: string): void {
63 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) px(g, x, y, c)
64}
65function span(g: Grid, y: number, x0: number, x1: number, c: string): void {
66 rect(g, x0, y, x1, y, c)
67}
68function mspan(g: Grid, y: number, x0: number, x1: number, c: string): void {
69 span(g, y, x0, x1, c)
70 span(g, y, WIDTH - 1 - x1, WIDTH - 1 - x0, c)
71}
72function stamp(g: Grid, x0: number, y0: number, rows: string[]): void {
73 for (let y = 0; y < rows.length; y++)
74 for (let x = 0; x < rows[y]!.length; x++) {
75 const c = rows[y]![x]
76 if (c !== '.') px(g, x0 + x, y0 + y, c!)
77 }
78}
79
80function drawLegs(g: Grid): void {
81 // wizar (long white skirt) down to the shins
82 rect(g, 3, 26, 20, 31, 'W')
83 rect(g, 4, 32, 19, 33, 'W')
84 span(g, 33, 4, 19, 'w')
85 for (let y = 27; y <= 32; y++) px(g, 8, y, 'w')
86 for (let y = 29; y <= 32; y++) px(g, 16, y, 'w')
87 // black socks + loafers
88 rect(g, 6, 34, 8, 35, 'k')
89 rect(g, 15, 34, 17, 35, 'k')
90 stamp(g, 4, 36, ['.kkkk', 'kkkkk'])
91 stamp(g, 15, 36, ['kkkk.', 'kkkkk'])
92 px(g, 5, 36, 'v')
93 px(g, 18, 36, 'v')
94}
95
96function drawTorso(g: Grid, slump: boolean, wave: boolean): void {
97 const o = slump ? 1 : 0
98 // puffy raised shoulders (flattened when slumped)
99 if (!slump) {
100 mspan(g, 15, 2, 5, 'W')
101 mspan(g, 16, 1, 6, 'W')
102 mspan(g, 15, 5, 5, 'w')
103 } else {
104 mspan(g, 16, 2, 5, 'W')
105 }
106 mspan(g, 17, 1, 8, 'W')
107 rect(g, 1, 18, 22, 25, 'W')
108 mspan(g, 18 + o, 1, 2, 'w')
109 // arm seams
110 for (let y = 19; y <= 25; y++) {
111 px(g, 3, y, 'w')
112 px(g, 20, y, 'w')
113 }
114 // neck + collar
115 rect(g, 9, 16, 14, 18, 'S')
116 span(g, 17, 9, 14, 's')
117 mspan(g, 18, 8, 9, 'W')
118 span(g, 19, 10, 13, 'w')
119
120 // crossed bandoliers
121 for (let y = 17; y <= 23; y++) {
122 const x0 = 3 + 2 * (y - 17)
123 span(g, y, x0 - 1, x0 + 3, 'l')
124 span(g, y, x0, x0 + 2, 'L')
125 px(g, x0 + 1, y, 'Y')
126 }
127 for (let y = 17; y <= 23; y++) {
128 const x0 = WIDTH - 1 - (3 + 2 * (y - 17)) - 2
129 span(g, y, x0 - 1, x0 + 3, 'l')
130 span(g, y, x0, x0 + 2, 'L')
131 if (y % 2 === 0) px(g, x0 + 1, y, 'Y')
132 }
133
134 // belt with brass cartridges
135 span(g, 23, 1, 22, 'l')
136 span(g, 24, 1, 22, 'L')
137 span(g, 25, 1, 22, 'l')
138 for (let x = 2; x < WIDTH; x += 2) px(g, x, 24, 'Y')
139 for (const x of [2, 4, 6, 17, 19, 21]) {
140 px(g, x, 21, 'g')
141 px(g, x, 22, 'Y')
142 }
143
144 // jambiya
145 stamp(g, 9, 19, [
146 'KKKKKK..',
147 'KYYYYK..',
148 '.KJJK...',
149 '.KJJK...',
150 'KYJJYK..',
151 'KAAAAK..',
152 'KAAAAaK.',
153 '.KAAAAaK',
154 '..KKAAaK',
155 '....KKK.',
156 ])
157
158 if (!slump) {
159 // his right hand gripping the jambiya handle (viewer left)
160 stamp(g, 4, 20, ['.wwwKSSSK', 'wwwwKSSsK', '.KKKKKKK.'])
161 // other arm hanging, hand at the side (raised instead when waving)
162 if (!wave) {
163 rect(g, 21, 26, 22, 27, 'S')
164 px(g, 21, 27, 's')
165 }
166 } else {
167 // both arms hanging limp
168 rect(g, 1, 26, 2, 27, 'S')
169 px(g, 2, 27, 's')
170 if (!wave) {
171 rect(g, 21, 26, 22, 27, 'S')
172 px(g, 21, 27, 's')
173 }
174 }
175}
176
177function drawHead(g: Grid, dy: number): void {
178 const y = (v: number) => v + dy
179 // face
180 rect(g, 5, y(8), 18, y(15), 'S')
181 span(g, y(16), 6, 17, 'S')
182 span(g, y(17), 8, 15, 'S')
183 px(g, 5, y(15), 's')
184 px(g, 18, y(15), 's')
185 px(g, 6, y(16), 's')
186 px(g, 17, y(16), 's')
187 // ears
188 px(g, 4, y(10), 'S')
189 px(g, 4, y(11), 's')
190 px(g, 19, y(10), 'S')
191 px(g, 19, y(11), 's')
192 // nose
193 px(g, 12, y(11), 's')
194 span(g, y(12), 11, 12, 's')
195 // curly hair at the sides
196 stamp(g, 3, y(8), ['HH', 'H.', '..', '..', 'HH', '.H'])
197 stamp(g, 19, y(8), ['H', 'H'])
198 // mustache + beard line + goatee
199 span(g, y(13), 9, 14, 'H')
200 px(g, 9, y(14), 'H')
201 px(g, 14, y(14), 'H')
202 px(g, 9, y(15), 'H')
203 px(g, 14, y(15), 'H')
204 span(g, y(16), 10, 13, 'H')
205 span(g, y(17), 10, 13, 'H')
206 span(g, y(18), 11, 12, 'H')
207
208 // turban
209 span(g, y(0), 8, 15, 'R')
210 span(g, y(1), 6, 17, 'R')
211 rect(g, 4, y(2), 19, y(6), 'R')
212 span(g, y(7), 5, 18, 'r')
213 stamp(g, 7, y(1), [
214 '.gGGggGGg.',
215 'gGgGGGGgGg',
216 'GgGgGGgGgG',
217 '.gGgggGgg.',
218 ])
219 span(g, y(5), 4, 19, 'r')
220 rect(g, 18, y(2), 19, y(4), 'r')
221 for (let x = 5; x <= 18; x += 2) px(g, x, y(6), 'G')
222 // wrap tail draping beside the neck (viewer right)
223 rect(g, 19, y(6), 21, y(15), 'R')
224 rect(g, 18, y(13), 22, y(17), 'R')
225 for (let v = 7; v <= 17; v++) px(g, 21, y(v), 'r')
226 px(g, 20, y(10), 'g')
227 px(g, 19, y(14), 'g')
228 px(g, 21, y(16), 'G')
229}
230
231// His left arm (viewer right) raised with an open palm beside the turban: "salam".
232function drawWave(g: Grid, dy: number): void {
233 stamp(g, 16, 1 + dy, [
234 '..K.K.K.',
235 '.KSKSKSK',
236 '.KSKSKSK',
237 'KKSSSSSK',
238 'KSSSSSsK',
239 '.KKSSSsK',
240 '..KsSSsK',
241 '..KKWWWK',
242 '...KWWWK',
243 '...KWWWK',
244 '...KWWWK',
245 '...KWWWK',
246 '...KwWWK',
247 '...KwWWK',
248 '...KwWWK',
249 '...KwWWK',
250 ])
251}
252
253function drawFace(g: Grid, mood: Mood, dy: number): void {
254 const y = (v: number) => v + dy
255 if (mood === 'happy') {
256 span(g, y(8), 7, 9, 'H')
257 span(g, y(8), 14, 16, 'H')
258 stamp(g, 7, y(10), ['.K.', 'K.K'])
259 stamp(g, 14, y(10), ['.K.', 'K.K'])
260 span(g, y(12), 5, 7, 'n')
261 span(g, y(12), 16, 18, 'n')
262 px(g, 8, y(13), 'H')
263 px(g, 15, y(13), 'H')
264 stamp(g, 9, y(14), ['MWWWWM', '.MmmM.'])
265 } else if (mood === 'normal') {
266 span(g, y(9), 7, 9, 'H')
267 span(g, y(9), 14, 16, 'H')
268 stamp(g, 7, y(10), ['WKK', 'WKK'])
269 stamp(g, 14, y(10), ['KKW', 'KKW'])
270 stamp(g, 10, y(14), ['M..M', '.MM.'])
271 } else if (mood === 'worried') {
272 stamp(g, 7, y(8), ['..H', 'HH.'])
273 stamp(g, 14, y(8), ['H..', '.HH'])
274 stamp(g, 7, y(10), ['WKW', 'WKW'])
275 stamp(g, 14, y(10), ['WKW', 'WKW'])
276 stamp(g, 10, y(14), ['.M.M', 'M.M.'])
277 stamp(g, 17, y(8), ['.d', 'dD', 'DD'])
278 } else {
279 stamp(g, 7, y(8), ['..H', '.H.', 'H..'])
280 stamp(g, 14, y(8), ['H..', '.H.', '..H'])
281 stamp(g, 7, y(10), ['.KK', 'KDD'])
282 stamp(g, 14, y(10), ['KK.', 'DDK'])
283 for (let v = 12; v <= 16; v++) {
284 px(g, 7, y(v), v % 2 ? 'd' : 'D')
285 px(g, 16, y(v), v % 2 ? 'd' : 'D')
286 }
287 stamp(g, 10, y(14), ['MmmM', 'mttm', 'MMMM'])
288 }
289}
290
291// Transparent pixels touching the figure (8-neighbourhood) become outline.
292function outline(g: Grid): void {
293 const out: [number, number][] = []
294 for (let y = 0; y < HEIGHT; y++)
295 for (let x = 0; x < WIDTH; x++) {
296 if (g[y]![x] !== '.') continue
297 let hit = false
298 for (let dy = -1; dy <= 1 && !hit; dy++)
299 for (let dx = -1; dx <= 1 && !hit; dx++) {
300 if (!dx && !dy) continue
301 if (dx && dy) continue // 4-neighbourhood keeps corners crisp
302 const nx = x + dx
303 const ny = y + dy
304 if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue
305 const c = g[ny]![nx]
306 if (c !== '.' && c !== 'K') hit = true
307 }
308 if (hit) out.push([x, y])
309 }
310 for (const [x, y] of out) g[y]![x] = 'K'
311}
312
313export function characterGrid(mood: Mood, pose: Pose = 'rest'): string[] {
314 const g = blank()
315 const slump = mood === 'devastated'
316 const wave = pose === 'wave'
317 const dy = slump ? 1 : 0
318 drawLegs(g)
319 drawTorso(g, slump, wave)
320 drawHead(g, dy)
321 if (wave) drawWave(g, dy)
322 drawFace(g, mood, dy)
323 outline(g)
324 return g.map((row) => row.join(''))
325}
326
327function selfCheck(): void {
328 for (const m of ['happy', 'normal', 'worried', 'devastated'] as Mood[]) {
329 for (const pose of ['rest', 'wave'] as Pose[]) {
330 const rows = characterGrid(m, pose)
331 const id = `${m}/${pose}`
332 if (rows.length !== HEIGHT) throw new Error(`character: ${id} has ${rows.length} rows`)
333 rows.forEach((r, y) => {
334 if (r.length !== WIDTH) throw new Error(`character: ${id} row ${y} has ${r.length} chars`)
335 for (const ch of r) if (ch !== '.' && !(ch in PALETTE)) throw new Error(`character: ${id} row ${y} bad key '${ch}'`)
336 })
337 }
338 }
339}
340selfCheck()
341hooks/setup.ts 71 lines1// First-run setup: everything the band needs besides itself. RTK
2// (https://github.com/rtk-ai/rtk) and Matt Pocock's skills are separate
3// projects; this plugin installs them from their official sources and never
4// bundles them. The engine calls live in register.tsx; this file holds the
5// steps, the words and the plain logic.
6
7import type { SetupStep } from '../types'
8
9export const SETUP_COMMAND = 'yemeni'
10
11export const STEP_LABEL: Record<SetupStep, string> = {
12 rtk: 'install RTK',
13 hook: 'hook RTK into Claude Code',
14 bash: 'add a Bash-only line to your CLAUDE.md',
15 matt: "install Matt Pocock's skills",
16}
17
18export const STEP_ORDER: SetupStep[] = ['rtk', 'hook', 'bash', 'matt']
19
20export const MATT_ID = 'mattpocock-skills@claude-plugins-official'
21export const OFFICIAL_MARKETPLACE = 'claude-plugins-official'
22export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/anthropics/claude-plugins-official.git'
23
24export const BASH_LINE =
25 'Use the Bash tool for shell commands (git, npm, builds, tests, linters, docker) so RTK can compress their output. Only use PowerShell for Windows-specific tasks such as winget, .ps1 scripts or the registry.'
26const BASH_MARK = 'so RTK can compress their output'
27
28export const RTK_INSTALL: Record<'windows' | 'mac' | 'linux', string[]> = {
29 windows: ['winget', 'install', '--id', 'rtk-ai.rtk', '-e', '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements', '--silent'],
30 mac: ['brew', 'install', 'rtk'],
31 linux: ['sh', '-c', 'curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh'],
32}
33
34export const RTK_MANUAL = [
35 'Install RTK yourself, then run /yemeni again:',
36 '- Windows: `winget install rtk-ai.rtk`',
37 '- macOS: `brew install rtk`',
38 '- Linux: `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`',
39].join('\n')
40
41export const isHooked = (showOutput: string) =>
42 /\[ok\] Hook/.test(showOutput) && /\[ok\] settings\.json/.test(showOutput)
43
44export const hasBashLine = (md: string | null) => !!md?.includes(BASH_MARK)
45
46export function withBashLine(md: string): string {
47 const sep = md === '' || md.endsWith('\n\n') ? '' : md.endsWith('\n') ? '\n' : '\n\n'
48 return `${md}${sep}${BASH_LINE}\n`
49}
50
51// `claude plugin list --json`: is the plugin there and enabled?
52export function isEnabled(listJson: string, id: string): boolean {
53 try {
54 const list = JSON.parse(listJson) as { id?: string; enabled?: boolean }[]
55 return list.some(p => p.id === id && p.enabled !== false)
56 } catch {
57 return false
58 }
59}
60
61export function hasMarketplace(listJson: string, name: string): boolean {
62 try {
63 return (JSON.parse(listJson) as { name?: string }[]).some(m => m.name === name)
64 } catch {
65 return false
66 }
67}
68
69export const missingSummary = (missing: SetupStep[]) =>
70 STEP_ORDER.filter(s => missing.includes(s)).map(s => STEP_LABEL[s]).join(', ')
71types/index.d.ts 34 lines1export type Segment = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
2
3export type Usage = {
4 tokens: number | null
5 window: number
6 percent: number | null
7 compactAt: number | null
8 segments: Segment[]
9 barMax: number
10}
11
12export type Rtk = { total: number; session: number; avgPct: number; commands: number }
13
14export type SetupStep = 'rtk' | 'hook' | 'bash' | 'matt'
15
16export type Setup = {
17 missing: SetupStep[]
18 phase: 'idle' | 'running' | 'done' | 'hidden'
19 progress: string
20 results: string[]
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'yemeni-context-band': {
26 isShown: boolean
27 usage: Usage | null
28 rtk: Rtk | null
29 rtkBaseline: number | null
30 setup: Setup | null
31 }
32 }
33}
34