aiblueprint-cli 1.4.108 → 1.4.110
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.
- package/README.md +10 -0
- package/agents-config/claude-config/mods/agent4everything/.claude-plugin/plugin.json +5 -0
- package/agents-config/claude-config/mods/agent4everything/hooks/hooks.json +1 -0
- package/agents-config/claude-config/mods/agent4everything/hooks/register.ts +96 -0
- package/agents-config/claude-config/mods/aiblueprint/.claude-plugin/plugin.json +71 -0
- package/agents-config/claude-config/mods/aiblueprint/hooks/hooks.json +1 -0
- package/agents-config/claude-config/mods/aiblueprint/hooks/register.tsx +309 -0
- package/agents-config/claude-config/mods/aiblueprint/tests/band.test.tsx +74 -0
- package/agents-config/claude-config/mods/aiblueprint/types/index.d.ts +14 -0
- package/agents-config/claude-config/scripts/.claude/skills/fix-on-my-computer/SKILL.md +8 -74
- package/agents-config/claude-config/scripts/statusline/src/index.ts +1 -0
- package/agents-config/scripts/.claude/skills/fix-on-my-computer/SKILL.md +8 -74
- package/agents-config/scripts/statusline/src/index.ts +1 -0
- package/agents-config/skills/agents-manager/SKILL.md +15 -453
- package/agents-config/skills/apex/SKILL.md +14 -110
- package/agents-config/skills/app-icon/SKILL.md +22 -117
- package/agents-config/skills/appstore-connect/SKILL.md +22 -85
- package/agents-config/skills/arena/LICENSE +21 -0
- package/agents-config/skills/arena/SKILL.md +371 -0
- package/agents-config/skills/arena/agents/openai.yaml +9 -0
- package/agents-config/skills/arena/assets/codex-icon.svg +22 -0
- package/agents-config/skills/arena/bracket.py +1235 -0
- package/agents-config/skills/arena/rubric.md +79 -0
- package/agents-config/skills/arena/strategies.json +205 -0
- package/agents-config/skills/audit-memories/SKILL.md +13 -139
- package/agents-config/skills/audit-skills/SKILL.md +6 -65
- package/agents-config/skills/environments-manager/SKILL.md +14 -238
- package/agents-config/skills/grill-me/SKILL.md +8 -22
- package/agents-config/skills/hooks-manager/SKILL.md +8 -54
- package/agents-config/skills/rules-manager/SKILL.md +11 -192
- package/agents-config/skills/skill-manager/SKILL.md +19 -158
- package/agents-config/skills/skill-manager/references/description-recommandation.md +15 -85
- package/agents-config/skills/test-audit/CAMPAIGN.md +117 -0
- package/agents-config/skills/test-audit/LICENSE +23 -0
- package/agents-config/skills/test-audit/SKILL.md +169 -0
- package/agents-config/skills/test-audit/agents/openai.yaml +10 -0
- package/agents-config/skills/test-audit/assets/codex-icon.svg +1 -0
- package/agents-config/skills/use-style/SKILL.md +8 -48
- package/dist/cli.js +125 -78
- 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
|
|
@@ -140,6 +148,7 @@ npx skills add Melvynx/aiblueprint --skill skill-manager
|
|
|
140
148
|
| `apex` | Structured implementation workflow |
|
|
141
149
|
| `app-icon` | Generate and prepare app icons |
|
|
142
150
|
| `appstore-connect` | Manage App Store Connect workflows |
|
|
151
|
+
| `arena` | Make sub-agents compete until one solution survives |
|
|
143
152
|
| `audit-memories` | Manually audit and clean project agent documentation |
|
|
144
153
|
| `audit-skills` | Manually audit skill usage, invocation controls, duplicates, and scope |
|
|
145
154
|
| `grill-me` | Stress-test a plan with focused questions |
|
|
@@ -147,6 +156,7 @@ npx skills add Melvynx/aiblueprint --skill skill-manager
|
|
|
147
156
|
| `environments-manager` | Set up per-worktree agent environments |
|
|
148
157
|
| `rules-manager` | Create and maintain agent rule files |
|
|
149
158
|
| `skill-manager` | Manage skills and rules across Claude Code, Codex, and Cursor |
|
|
159
|
+
| `test-audit` | Gate new tests and prune low-value, implementation-coupled ones |
|
|
150
160
|
| `use-style` | Apply named UI style guides before implementation |
|
|
151
161
|
|
|
152
162
|
## 🤖 Assistant Pro
|
|
@@ -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
|
+
}
|