aiblueprint-cli 1.4.108 → 1.4.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -0
  2. package/agents-config/claude-config/mods/agent4everything/.claude-plugin/plugin.json +5 -0
  3. package/agents-config/claude-config/mods/agent4everything/hooks/hooks.json +1 -0
  4. package/agents-config/claude-config/mods/agent4everything/hooks/register.ts +96 -0
  5. package/agents-config/claude-config/mods/aiblueprint/.claude-plugin/plugin.json +71 -0
  6. package/agents-config/claude-config/mods/aiblueprint/hooks/hooks.json +1 -0
  7. package/agents-config/claude-config/mods/aiblueprint/hooks/register.tsx +309 -0
  8. package/agents-config/claude-config/mods/aiblueprint/tests/band.test.tsx +74 -0
  9. package/agents-config/claude-config/mods/aiblueprint/types/index.d.ts +14 -0
  10. package/agents-config/claude-config/scripts/.claude/skills/fix-on-my-computer/SKILL.md +8 -74
  11. package/agents-config/claude-config/scripts/statusline/src/index.ts +1 -0
  12. package/agents-config/scripts/.claude/skills/fix-on-my-computer/SKILL.md +8 -74
  13. package/agents-config/scripts/statusline/src/index.ts +1 -0
  14. package/agents-config/skills/agents-manager/SKILL.md +15 -453
  15. package/agents-config/skills/apex/SKILL.md +14 -110
  16. package/agents-config/skills/app-icon/SKILL.md +22 -117
  17. package/agents-config/skills/appstore-connect/SKILL.md +22 -85
  18. package/agents-config/skills/audit-memories/SKILL.md +13 -139
  19. package/agents-config/skills/audit-skills/SKILL.md +6 -65
  20. package/agents-config/skills/environments-manager/SKILL.md +14 -238
  21. package/agents-config/skills/grill-me/SKILL.md +8 -22
  22. package/agents-config/skills/hooks-manager/SKILL.md +8 -54
  23. package/agents-config/skills/rules-manager/SKILL.md +11 -192
  24. package/agents-config/skills/skill-manager/SKILL.md +19 -158
  25. package/agents-config/skills/skill-manager/references/description-recommandation.md +15 -85
  26. package/agents-config/skills/test-audit/CAMPAIGN.md +117 -0
  27. package/agents-config/skills/test-audit/LICENSE +23 -0
  28. package/agents-config/skills/test-audit/SKILL.md +169 -0
  29. package/agents-config/skills/test-audit/agents/openai.yaml +10 -0
  30. package/agents-config/skills/test-audit/assets/codex-icon.svg +1 -0
  31. package/agents-config/skills/use-style/SKILL.md +8 -48
  32. package/dist/cli.js +115 -72
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -18,6 +18,7 @@ npx aiblueprint-cli@latest agents setup
18
18
  - **🤖 Skills** - 30+ reusable workflow skills
19
19
  - **🎭 Agents** - 3 specialized AI agents for codebase exploration
20
20
  - **⚡ Scripts** - Built-in statusline utilities
21
+ - **🧩 Mods** - Claude Code function-hook mods (usage band, skills sync)
21
22
 
22
23
  ## 📦 Installation
23
24
 
@@ -109,6 +110,13 @@ installs every directory under `agents-config/skills/`.
109
110
  - **Snipper** - Fast code modifications
110
111
  - **websearch** - Quick web research
111
112
 
113
+ ### Mods
114
+
115
+ Installed into `~/.claude/mods/` and enabled through `CLAUDE_CODE_PLUGIN_DIRS`:
116
+
117
+ - **aiblueprint** - Usage band above the prompt: session context and cost, 5-hour and weekly limits with pace and reset time
118
+ - **agent4everything** - Links every `.agents/skills` skill (global and per project) into `.claude/skills`
119
+
112
120
  ### Shell Shortcuts
113
121
 
114
122
  - `cc` - Claude Code with permissions skipped
@@ -147,6 +155,7 @@ npx skills add Melvynx/aiblueprint --skill skill-manager
147
155
  | `environments-manager` | Set up per-worktree agent environments |
148
156
  | `rules-manager` | Create and maintain agent rule files |
149
157
  | `skill-manager` | Manage skills and rules across Claude Code, Codex, and Cursor |
158
+ | `test-audit` | Gate new tests and prune low-value, implementation-coupled ones |
150
159
  | `use-style` | Apply named UI style guides before implementation |
151
160
 
152
161
  ## 🤖 Assistant Pro
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "agent4everything",
3
+ "version": "0.1.0",
4
+ "description": "Maps .agents/skills (global and per project) into .claude/skills so every skill is active, without duplicating existing ones"
5
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.ts"] }
@@ -0,0 +1,96 @@
1
+ import type { Register } from 'claude-code'
2
+
3
+ // `$` as a hook receives it.
4
+ type Engine = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
5
+
6
+ type Linked = { root: string; name: string }
7
+
8
+ const SKILL_FILES = ['SKILL.md', 'skill.md']
9
+
10
+ const realPath = async ($: Engine, path: string) =>
11
+ (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
12
+
13
+ const homeDir = async ($: Engine) =>
14
+ (await $.process.run(['printenv', 'HOME'])).stdout.trim()
15
+
16
+ // The session's cwd and every ancestor below $HOME, then $HOME itself (global).
17
+ const rootsFor = (cwd: string, home: string) => {
18
+ const roots: string[] = []
19
+ let dir = cwd.replace(/\/+$/, '')
20
+ while (dir.startsWith(`${home}/`)) {
21
+ roots.push(dir)
22
+ dir = dir.slice(0, dir.lastIndexOf('/'))
23
+ }
24
+ if (!cwd.startsWith(`${home}/`) && cwd !== home) roots.push(cwd)
25
+ roots.push(home)
26
+ return roots
27
+ }
28
+
29
+ const isSkillDir = async ($: Engine, dir: string) => {
30
+ const stat = await $.fs.stat(dir).catch(() => undefined)
31
+ if (stat?.kind !== 'dir') return false
32
+ for (const file of SKILL_FILES) {
33
+ if (await $.fs.exists(`${dir}/${file}`)) return true
34
+ }
35
+ return false
36
+ }
37
+
38
+ // Links each <root>/.agents/skills/<name> missing from <root>/.claude/skills.
39
+ const syncRoot = async ($: Engine, root: string): Promise<Linked[]> => {
40
+ const source = `${root}/.agents/skills`
41
+ const target = `${root}/.claude/skills`
42
+ if (!(await $.fs.exists(source))) return []
43
+
44
+ // .claude/skills already is .agents/skills (a symlinked folder): nothing to map.
45
+ const sourceReal = await realPath($, source)
46
+ if (sourceReal !== undefined && sourceReal === (await realPath($, target))) return []
47
+
48
+ // Every name already in .claude/skills, dangling links included, case-insensitive.
49
+ const existing = new Set(
50
+ (await $.fs.list(target).catch(() => [])).map(entry => entry.name.toLowerCase()),
51
+ )
52
+
53
+ const linked: Linked[] = []
54
+ for (const entry of await $.fs.list(source)) {
55
+ const { name } = entry
56
+ if (name.startsWith('.') || name.startsWith('__')) continue
57
+ if (existing.has(name.toLowerCase())) continue
58
+ if (!(await isSkillDir($, `${source}/${name}`))) continue
59
+
60
+ if (linked.length === 0) await $.process.run(['mkdir', '-p', target])
61
+ const ran = await $.process.run(['ln', '-s', `../../.agents/skills/${name}`, `${target}/${name}`])
62
+ if (ran.exitCode === 0) linked.push({ root, name })
63
+ else $.ui.log(`agent4everything: ln failed for ${name}: ${ran.stderr.trim()}`, { to: 'debug' })
64
+ }
65
+ return linked
66
+ }
67
+
68
+ const syncAll = async ($: Engine, cwd: string) => {
69
+ const home = await homeDir($)
70
+ const linked: Linked[] = []
71
+ for (const root of rootsFor(cwd, home)) linked.push(...(await syncRoot($, root)))
72
+ if (linked.length > 0) {
73
+ $.ui.toast(`agent4everything: linked ${linked.map(l => l.name).join(', ')}`)
74
+ }
75
+ return linked
76
+ }
77
+
78
+ export const register: Register = on => {
79
+ // Awaited before the first prompt: links exist before the session reads skills again.
80
+ on('session.start', async ($, e, next) => {
81
+ await syncAll($, e.cwd).catch(error => $.ui.log(`agent4everything: ${error}`, { to: 'debug' }))
82
+ return next(e)
83
+ })
84
+
85
+ // Startup, resume, /clear, compact: sync again and have the engine re-read skills.
86
+ on('classic.SessionStart', async ($, e, next) => {
87
+ await syncAll($, e.cwd).catch(() => [])
88
+ const result = await next(e)
89
+ return { ...result, reloadSkills: true }
90
+ })
91
+
92
+ on('classic.CwdChanged', async ($, e, next) => {
93
+ await syncAll($, e.new_cwd).catch(() => [])
94
+ return next(e)
95
+ })
96
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "aiblueprint",
3
+ "version": "0.7.0",
4
+ "description": "AIBlueprint usage band above the prompt: session cost, 5-hour and weekly limits with pacing and reset time.",
5
+ "author": {
6
+ "name": "Melvyn"
7
+ },
8
+ "types": "./types/index.d.ts",
9
+ "userConfig": {
10
+ "showSession": {
11
+ "type": "boolean",
12
+ "title": "Session chip",
13
+ "description": "Show the session cost chip.",
14
+ "default": true
15
+ },
16
+ "showFiveHour": {
17
+ "type": "boolean",
18
+ "title": "5h chip",
19
+ "description": "Show the 5-hour session limit.",
20
+ "default": true
21
+ },
22
+ "showWeek": {
23
+ "type": "boolean",
24
+ "title": "Week chip",
25
+ "description": "Show the weekly limit (all models).",
26
+ "default": true
27
+ },
28
+ "showBars": {
29
+ "type": "boolean",
30
+ "title": "Bars",
31
+ "description": "Draw a progress bar in the 5h and Week chips.",
32
+ "default": true
33
+ },
34
+ "showPace": {
35
+ "type": "boolean",
36
+ "title": "Pace",
37
+ "description": "Show the +/- pace: positive means you use less than the time elapsed.",
38
+ "default": true
39
+ },
40
+ "showReset": {
41
+ "type": "boolean",
42
+ "title": "Reset time",
43
+ "description": "Show when each limit resets.",
44
+ "default": true
45
+ },
46
+ "sessionLabel": {
47
+ "type": "string",
48
+ "title": "Session label",
49
+ "description": "Label of the cost chip.",
50
+ "default": "S"
51
+ },
52
+ "fiveHourLabel": {
53
+ "type": "string",
54
+ "title": "5h label",
55
+ "description": "Label of the 5-hour chip.",
56
+ "default": "5h"
57
+ },
58
+ "weekLabel": {
59
+ "type": "string",
60
+ "title": "Week label",
61
+ "description": "Label of the weekly chip.",
62
+ "default": "Week"
63
+ },
64
+ "refreshSeconds": {
65
+ "type": "number",
66
+ "title": "Refresh (seconds)",
67
+ "description": "How often the band reads usage again.",
68
+ "default": 30
69
+ }
70
+ }
71
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,309 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
+
4
+ import type { Limit, Usage } from '../types'
5
+
6
+ const SESSION_MS = 5 * 3600 * 1000
7
+ const WEEK_MS = 7 * 24 * 3600 * 1000
8
+ let limitsCache: Promise<string | undefined> | undefined
9
+ // The status line script's cache under $HOME/.claude; undefined when HOME cannot be read.
10
+ const limitsCachePath = ($: EngineInterface) =>
11
+ (limitsCache ??= $.process
12
+ .run(['printenv', 'HOME'])
13
+ .then(({ stdout }) => stdout.trim() ? `${stdout.trim()}/.claude/scripts/statusline/data/usage-limits-cache.json` : undefined)
14
+ .catch(() => undefined))
15
+
16
+ const GREEN = '#57ab5a'
17
+ const YELLOW = '#d4a72c'
18
+ const ORANGE = '#ff8700'
19
+ const RED = '#e5534b'
20
+ const TRACK = '#8b8b8b'
21
+ const BLUE = '#539bf5'
22
+ const BAR_WIDTH = 36
23
+
24
+ const usageAtom = atom({ plugin: 'aiblueprint', key: 'usage' } as const, null)
25
+ const settingsOpenAtom = atom({ plugin: 'aiblueprint', key: 'settingsOpen' } as const, false)
26
+
27
+ type Options = {
28
+ showSession: boolean
29
+ showFiveHour: boolean
30
+ showWeek: boolean
31
+ showBars: boolean
32
+ showPace: boolean
33
+ showReset: boolean
34
+ sessionLabel: string
35
+ fiveHourLabel: string
36
+ weekLabel: string
37
+ refreshSeconds: number
38
+ }
39
+
40
+ const DEFAULTS: Options = {
41
+ showSession: true,
42
+ showFiveHour: true,
43
+ showWeek: true,
44
+ showBars: true,
45
+ showPace: true,
46
+ showReset: true,
47
+ sessionLabel: 'S',
48
+ fiveHourLabel: '5h',
49
+ weekLabel: 'Week',
50
+ refreshSeconds: 30,
51
+ }
52
+
53
+ // The toggles the ⚙ row shows, in order.
54
+ const TOGGLES = [
55
+ { field: 'showSession', label: 'Session' },
56
+ { field: 'showFiveHour', label: '5h' },
57
+ { field: 'showWeek', label: 'Week' },
58
+ { field: 'showBars', label: 'Barres' },
59
+ { field: 'showPace', label: 'Pace' },
60
+ { field: 'showReset', label: 'Reset' },
61
+ ] as const
62
+
63
+ let isRunning = false
64
+ let isPending = false
65
+
66
+ function fillColor(percent: number) {
67
+ if (percent < 50) return GREEN
68
+ if (percent < 75) return YELLOW
69
+ if (percent < 90) return ORANGE
70
+ return RED
71
+ }
72
+
73
+ // Pace > 0: under the week's pace (budget left), < 0: burning faster than time passes.
74
+ function paceColor(pace: number) {
75
+ if (pace >= 0) return GREEN
76
+ if (pace > -10) return YELLOW
77
+ return RED
78
+ }
79
+
80
+ function formatPace(pace: number) {
81
+ return `${pace >= 0 ? '+' : ''}${pace.toFixed(1)}%`
82
+ }
83
+
84
+ function formatCost(cost: number) {
85
+ return `$${cost < 10 ? cost.toFixed(2) : cost.toFixed(1)}`
86
+ }
87
+
88
+ // One unit only, the biggest that fits: 6j, 13h, 45m.
89
+ function formatResetsIn(ms: number) {
90
+ const minutes = Math.max(0, Math.floor(ms / 60_000))
91
+ if (minutes < 60) return `${minutes}m`
92
+ const hours = Math.floor(minutes / 60)
93
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}j`
94
+ }
95
+
96
+ function barSvg(percent: number, color: string) {
97
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((percent / 100) * BAR_WIDTH)))
98
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_WIDTH}" height="4" viewBox="0 0 ${BAR_WIDTH} 4"><rect width="${BAR_WIDTH}" height="4" rx="2" fill="${TRACK}" fill-opacity="0.25"/><rect width="${filled}" height="4" rx="2" fill="${color}"/></svg>`
99
+ }
100
+
101
+ function barText(percent: number) {
102
+ const filled = Math.max(0, Math.min(5, Math.round((percent / 100) * 5)))
103
+ return `${'█'.repeat(filled)}${'░'.repeat(5 - filled)}`
104
+ }
105
+
106
+ // The same windows as the Plan usage limits panel: Session = the 5-hour limit, Week = the 7-day limit.
107
+ function limitOf(usage: SessionUsage, kind: string, windowMs: number, now: number): Limit | undefined {
108
+ const limit = usage.rateLimits.find(item => item.kind === kind)
109
+ if (!limit) return undefined
110
+ const resetsInMs = limit.resetsAt ? new Date(limit.resetsAt).getTime() - now : undefined
111
+ const elapsedPercent = resetsInMs !== undefined ? ((windowMs - resetsInMs) / windowMs) * 100 : undefined
112
+ return {
113
+ percent: limit.percentUsed,
114
+ pace: elapsedPercent !== undefined ? elapsedPercent - limit.percentUsed : undefined,
115
+ resetsInMs,
116
+ }
117
+ }
118
+
119
+ function contextOf(usage: SessionUsage): Usage['context'] {
120
+ const { tokens, window, percent } = usage.context
121
+ const value = percent ?? (tokens !== undefined && window > 0 ? (tokens / window) * 100 : undefined)
122
+ return value === undefined ? undefined : { percent: value, tokens, window }
123
+ }
124
+
125
+ function formatTokens(count: number) {
126
+ return count >= 1000 ? `${Math.round(count / 1000)}k` : `${count}`
127
+ }
128
+
129
+ function formatContext(context: NonNullable<Usage['context']>) {
130
+ const tokens = context.tokens ?? Math.round((context.percent / 100) * context.window)
131
+ return `${formatTokens(tokens)}/${formatTokens(context.window)}`
132
+ }
133
+
134
+ // A pill with the token count written inside, like the context window bar. A darker fill and a
135
+ // shadowed label keep the text readable over both the filled part and the empty track.
136
+ const PILL_WIDTH = 84
137
+ const PILL_HEIGHT = 16
138
+
139
+ function contextSvg(context: NonNullable<Usage['context']>) {
140
+ const filled = Math.max(0, Math.min(PILL_WIDTH, Math.round((context.percent / 100) * PILL_WIDTH)))
141
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${PILL_WIDTH}" height="${PILL_HEIGHT}" viewBox="0 0 ${PILL_WIDTH} ${PILL_HEIGHT}"><defs><clipPath id="p"><rect width="${PILL_WIDTH}" height="${PILL_HEIGHT}" rx="${PILL_HEIGHT / 2}"/></clipPath></defs><g clip-path="url(#p)"><rect width="${PILL_WIDTH}" height="${PILL_HEIGHT}" fill="#3a3f47"/><rect width="${filled}" height="${PILL_HEIGHT}" fill="#2f6fd1"/></g><text x="${PILL_WIDTH / 2}" y="11.5" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif" font-size="10.5" font-weight="700" fill="#ffffff" stroke="#000000" stroke-opacity="0.35" stroke-width="2" paint-order="stroke">${formatContext(context)}</text></svg>`
142
+ }
143
+
144
+ function compute(usage: SessionUsage, now: number): Usage {
145
+ return {
146
+ cost: usage.cost?.usd ?? 0,
147
+ context: contextOf(usage),
148
+ session: limitOf(usage, 'five_hour', SESSION_MS, now),
149
+ week: limitOf(usage, 'seven_day', WEEK_MS, now),
150
+ }
151
+ }
152
+
153
+ // Keeps the terminal status line script on the engine's own readings, so it never calls the usage API.
154
+ async function seedLimitsCache($: EngineInterface, usage: SessionUsage, now: number) {
155
+ const window = (kind: string) => {
156
+ const limit = usage.rateLimits.find(item => item.kind === kind)
157
+ return limit ? { utilization: limit.percentUsed, resets_at: limit.resetsAt ?? null } : null
158
+ }
159
+ const sevenDay = window('seven_day')
160
+ if (!sevenDay) return
161
+ const path = await limitsCachePath($)
162
+ if (!path) return
163
+ await $.fs
164
+ .write(path, JSON.stringify({ data: { five_hour: window('five_hour'), seven_day: sevenDay }, timestamp: now }, null, 2))
165
+ .catch(() => undefined)
166
+ }
167
+
168
+ async function refresh($: EngineInterface): Promise<void> {
169
+ if (isRunning) {
170
+ isPending = true
171
+ return
172
+ }
173
+ isRunning = true
174
+ try {
175
+ const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
176
+ const next = compute(usage, now)
177
+ await update($, usageAtom, () => next)
178
+ await seedLimitsCache($, usage, now)
179
+ } catch {
180
+ // keep the last band
181
+ } finally {
182
+ isRunning = false
183
+ }
184
+ if (isPending) {
185
+ isPending = false
186
+ await refresh($)
187
+ }
188
+ }
189
+
190
+ // Writes one userConfig field the way /config does; the module reloads with the new options.
191
+ async function setOption($: EngineInterface, field: string, value: boolean) {
192
+ const rows = await $.config.list()
193
+ const row = rows.find(item => item.key.startsWith('aiblueprint') && item.key.endsWith(`.${field}`))
194
+ if (!row) {
195
+ $.ui.toast(`aiblueprint: réglage ${field} introuvable dans /config`)
196
+ return
197
+ }
198
+ const { deny } = await $.config.set({ key: row.key, value })
199
+ if (deny) $.ui.toast(`aiblueprint: ${deny}`)
200
+ }
201
+
202
+ async function toggleSettings($: EngineInterface) {
203
+ await update($, settingsOpenAtom, isOpen => !isOpen)
204
+ }
205
+
206
+ export const register: Register = (on, rawOptions) => {
207
+ const options: Options = { ...DEFAULTS, ...(rawOptions as Partial<Options>) }
208
+ const refreshMs = Math.max(5, options.refreshSeconds) * 1000
209
+
210
+ on('session.start', async ($, e, next) => {
211
+ const started = await next(e)
212
+ $.ui.status(undefined)
213
+ void refresh($)
214
+ $.clock.every(refreshMs, () => refresh($))
215
+ return started
216
+ })
217
+
218
+ on('session.measure', async ($, e, next) => {
219
+ const measured = await next(e)
220
+ void refresh($)
221
+ return measured
222
+ })
223
+
224
+ on('turn.complete', async ($, e, next) => {
225
+ const completed = await next(e)
226
+ void refresh($)
227
+ return completed
228
+ })
229
+
230
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231
+ const [usage, isSettingsOpen] = await Promise.all([read($, usageAtom), read($, settingsOpenAtom)])
232
+ if (e.props.hasSurvey || usage === null) return next(e)
233
+
234
+ const limits = [
235
+ { key: 'five-hour', label: options.fiveHourLabel, limit: options.showFiveHour ? usage.session : undefined },
236
+ { key: 'week', label: options.weekLabel, limit: options.showWeek ? usage.week : undefined },
237
+ ].filter((item): item is { key: string; label: string; limit: Limit } => item.limit !== undefined)
238
+
239
+ const { Box, Text, Button } = $.ui.resolve(e)
240
+ const settingsRow = isSettingsOpen && (
241
+ <Box key="settings" flexDirection="row" gap={1} flexWrap="wrap">
242
+ {TOGGLES.map(({ field, label }) => (
243
+ <Button
244
+ key={`toggle-${field}`}
245
+ label={`${options[field] ? '✓' : '·'} ${label}`}
246
+ dimColor={!options[field]}
247
+ onPress={() => setOption($, field, !options[field])}
248
+ />
249
+ ))}
250
+ </Box>
251
+ )
252
+ const gear = <Button key="gear" label="⚙" plain dimColor={!isSettingsOpen} onPress={() => toggleSettings($)} />
253
+
254
+ if (e.surface === 'terminal') {
255
+ return (
256
+ <Box flexDirection="column">
257
+ <Box flexDirection="row" gap={3}>
258
+ {options.showSession && (
259
+ <Text key="session">
260
+ <Text dimColor>{options.sessionLabel} </Text>
261
+ {usage.context !== undefined && <Text color={BLUE}>{options.showBars ? `${barText(usage.context.percent)} ` : ''}{formatContext(usage.context)} </Text>}
262
+ <Text bold>{formatCost(usage.cost)}</Text>
263
+ </Text>
264
+ )}
265
+ {limits.map(({ key, label, limit }) => (
266
+ <Text key={key}>
267
+ <Text dimColor>{label} </Text>
268
+ {options.showBars && <Text color={fillColor(limit.percent)}>{barText(limit.percent)} </Text>}
269
+ <Text bold>{Math.round(limit.percent)}%</Text>
270
+ {options.showPace && limit.pace !== undefined && <Text color={paceColor(limit.pace)}> {formatPace(limit.pace)}</Text>}
271
+ {options.showReset && limit.resetsInMs !== undefined && <Text dimColor> {formatResetsIn(limit.resetsInMs)}</Text>}
272
+ </Text>
273
+ ))}
274
+ {gear}
275
+ </Box>
276
+ {settingsRow}
277
+ </Box>
278
+ )
279
+ }
280
+
281
+ const { Svg } = $.ui.resolve(e)
282
+ return (
283
+ <Box flexDirection="column" gap={1}>
284
+ <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
285
+ {options.showSession && (
286
+ <Box key="session" flexDirection="row" alignItems="center" gap={1} borderStyle="round" borderDimColor paddingX={1}>
287
+ <Text dimColor>{options.sessionLabel}</Text>
288
+ {usage.context !== undefined && (
289
+ <Svg source={contextSvg(usage.context)} alt={`Contexte ${formatContext(usage.context)}`} width={PILL_WIDTH} height={PILL_HEIGHT} />
290
+ )}
291
+ <Text bold>{formatCost(usage.cost)}</Text>
292
+ </Box>
293
+ )}
294
+ {limits.map(({ key, label, limit }) => (
295
+ <Box key={key} flexDirection="row" alignItems="center" gap={1} borderStyle="round" borderDimColor paddingX={1}>
296
+ <Text dimColor>{label}</Text>
297
+ {options.showBars && <Svg source={barSvg(limit.percent, fillColor(limit.percent))} alt={`${label} ${Math.round(limit.percent)}%`} width={BAR_WIDTH} height={4} />}
298
+ <Text bold>{Math.round(limit.percent)}%</Text>
299
+ {options.showPace && limit.pace !== undefined && <Text bold color={paceColor(limit.pace)}>{formatPace(limit.pace)}</Text>}
300
+ {options.showReset && limit.resetsInMs !== undefined && <Text dimColor>{formatResetsIn(limit.resetsInMs)}</Text>}
301
+ </Box>
302
+ ))}
303
+ {gear}
304
+ </Box>
305
+ {settingsRow}
306
+ </Box>
307
+ )
308
+ })
309
+ }
@@ -0,0 +1,74 @@
1
+ import { expect, mock, test } from 'claude-code/testing'
2
+
3
+ const NOW = Date.parse('2026-10-05T01:00:00Z')
4
+
5
+ test('the band draws Session and Week on terminal and desktop', async ($, on) => {
6
+ const clock = mock.clock(on, { now: NOW })
7
+ on('fs.write', () => undefined)
8
+ on('session.usage', () => ({ value: {
9
+ startedAt: NOW - 60_000,
10
+ context: { tokens: 78_000, window: 200_000, percent: 39 },
11
+ rateLimits: [
12
+ { kind: 'five_hour', percentUsed: 7, resetsAt: '2026-10-05T03:01:00Z' },
13
+ { kind: 'seven_day', percentUsed: 6, resetsAt: '2026-10-11T09:00:00Z' },
14
+ ],
15
+ cost: { usd: 1.234 },
16
+ } }) as never)
17
+ on('session.measure', () => ({ changed: [] }) as never)
18
+ await $.session.measure({ context: { tokens: 78_000, window: 200_000, percent: 39 }, rateLimits: [] } as never)
19
+ await clock.advance(1_000)
20
+
21
+ for (const surface of ['terminal', 'desktop'] as const) {
22
+ const ui = await $.ui.mount({
23
+ plugin: 'aiblueprint',
24
+ surface,
25
+ component: 'AbovePrompt',
26
+ props: { hasSurvey: false, isWorking: false, maxRows: 10 },
27
+ } as never)
28
+ expect(await ui.find({ type: 'Text', text: /^S ?$/ })).toBeDefined()
29
+ expect(await ui.find({ type: 'Text', text: /Week/ })).toBeDefined()
30
+ expect(await ui.find({ type: 'Text', text: /^5h/ })).toBeDefined()
31
+ expect(await ui.find({ type: 'Text', text: /\$1\.23/ })).toBeDefined()
32
+ if (surface === 'terminal') expect(await ui.find({ type: 'Text', text: /78k\/200k/ })).toBeDefined()
33
+ expect(await ui.find({ type: 'Text', text: /^7%$/ })).toBeDefined()
34
+ expect(await ui.find({ type: 'Text', text: /^ ?2h$/ })).toBeDefined()
35
+ expect(await ui.find({ type: 'Text', text: /\+3\.\d%/ })).toBeDefined()
36
+ await ui.unmount()
37
+ }
38
+ })
39
+
40
+ test('the settings hide chips and the ⚙ row writes the option', { options: { showWeek: false, fiveHourLabel: '5H' } } as never, async ($, on) => {
41
+ const clock = mock.clock(on, { now: NOW })
42
+ const writes: unknown[] = []
43
+ on('fs.write', () => undefined)
44
+ on('session.usage', () => ({ value: {
45
+ startedAt: NOW - 60_000,
46
+ context: { tokens: 78_000, window: 200_000, percent: 39 },
47
+ rateLimits: [
48
+ { kind: 'five_hour', percentUsed: 7, resetsAt: '2026-10-05T03:01:00Z' },
49
+ { kind: 'seven_day', percentUsed: 6, resetsAt: '2026-10-11T09:00:00Z' },
50
+ ],
51
+ cost: { usd: 1.234 },
52
+ } }) as never)
53
+ on('session.measure', () => ({ changed: [] }) as never)
54
+ on('config.list', () => ({ value: [{ key: 'aiblueprint.showWeek', label: 'Week chip', kind: 'boolean', value: false }] }) as never)
55
+ on('config.set', ($, e) => {
56
+ writes.push(e)
57
+ return { value: (e as { value: unknown }).value } as never
58
+ })
59
+ await $.session.measure({ context: { tokens: 78_000, window: 200_000, percent: 39 }, rateLimits: [] } as never)
60
+ await clock.advance(1_000)
61
+
62
+ const ui = await $.ui.mount({
63
+ plugin: 'aiblueprint',
64
+ surface: 'desktop',
65
+ component: 'AbovePrompt',
66
+ props: { hasSurvey: false, isWorking: false, maxRows: 10 },
67
+ } as never)
68
+ expect(await ui.find({ type: 'Text', text: /^5H$/ })).toBeDefined()
69
+ expect(await ui.find({ type: 'Text', text: /^Week$/ })).toBeUndefined()
70
+ await ui.press({ key: 'gear' })
71
+ await ui.press({ key: 'toggle-showWeek' })
72
+ expect(writes).toEqual([expect.objectContaining({ key: 'aiblueprint.showWeek', value: true })])
73
+ await ui.unmount()
74
+ })
@@ -0,0 +1,14 @@
1
+ export type Limit = { percent: number; pace?: number; resetsInMs?: number }
2
+
3
+ export type Usage = {
4
+ cost: number
5
+ context?: { percent: number; tokens?: number; window: number }
6
+ session?: Limit
7
+ week?: Limit
8
+ }
9
+
10
+ declare module 'claude-code' {
11
+ interface PluginState {
12
+ aiblueprint: { usage: Usage | null; settingsOpen: boolean }
13
+ }
14
+ }