Mods companion for claude-seo: a spend guard on paid SEO APIs, a live audit band with a receipt, an economy mode, a visual cockpit (Search Console, rankings…

An optional mods companion for claude-seo. It runs inside Claude Code as a mod (a function-hook plugin), so it can enforce things a skill can only ask for.
Tested on Claude Code 2.1.288 and 2.1.289. Mods need Claude Code 2.1.287 or newer; on older builds, install claude-seo alone.
Spend guard. Before a paid SEO API call runs, seo-cockpit checks it against the claude-seo DataForSEO budget (scripts/dataforseo_costs.py):
| The call | What happens |
|---|---|
| DataForSEO MCP tool or the Merchant script, budget says approved | Runs. Afterwards each billed endpoint is logged at its table price, and Claude is told not to log it again |
| DataForSEO, budget says needs approval (warn endpoint, unknown endpoint, above threshold) | You are asked, with the price and today's spend |
| DataForSEO, daily cap would be exceeded | Held. Claude gets the reason |
| Free DataForSEO lookups (locations, languages, filters, categories, model lists) | Run, with no check and nothing logged |
| Ahrefs, Firecrawl, image generation (MCP or script), Moz, Keywords Everywhere, Google Cloud NLP, Indexing API, and curl or WebFetch calls to SE Ranking, Profound or the DataForSEO API | You are asked: hold, run once, or allow until reload |
| Anything goes wrong (claude-seo not found, Python missing, unreadable ledger) | Held, with the fix in the message |
The guard fails closed: if it cannot check a paid call before it runs, the call does not run. A call that already ran keeps its real result.
Shell lines are checked one command at a time, so a cost check chained in front of a paid script does not hide it. The Merchant script is priced by what it bills: search (Google, or Amazon with --marketplace amazon), sellers, or compare (both).
What is logged: only calls that succeeded and have a price in the cost table. A failed call, or an endpoint with no listed price, is not logged; Claude is told to log the real cost from the response instead.
Audit band. While a /seo audit runs, a line above the prompt shows it live:
seo audit example.com 3 running, 9 done findings 9 spend $0.42 4m12s
It starts from the /seo audit <url> prompt or command, or from the first file written into a <domain>-audit/ folder (so an audit asked for in plain words is caught too). Agents count as running when spawned and done when they finish (a background agent when its own turn ends). Other mods' bands stay visible below this one. A reload mid-audit keeps the band. Spend is what the guard logged during the audit. The band yields to Claude Code's surveys, has a hide button, and clears at your next prompt after the audit ends.
Receipt. When the audit writes audit-data.json, one line appears under Claude's answer:
seo audit example.com: score 72/100 | weakest Schema 40, Content 55 | 17 agents | 12 findings files | spend $0.42 | 6m03s | example.com-audit/FULL-AUDIT-REPORT.md
Economy mode (off by default). Runs the five agents that use Opus (content, geo, sxo, cluster, drift) on Sonnet, to cut the cost of an audit. Their analysis may be less thorough. An agent call that names its own model is left alone.
Compaction. If the conversation is compacted mid-audit, the summary is asked to keep the output folder, which agents finished and which are running, and the findings files written.
Visual cockpit (/seo-cockpit). Opens on one Overview for the site this folder is about, with no setup:
claude-seo.md from this folder
1: ● Audit 85/100 · weakest Schema 76
2: ● Vitals good · LCP 703ms · INP 48ms · CLS 0.00
3: ○ Search no Search Console access
4: ○ Rankings no Search Console access
5: ○ Maps no grid yet (/seo maps grid)
6: ● Spend today $0.00 · 30 days $0.18
r: Refresh e: Export
/seo-cockpit <site>), the site of this folder's newest <domain>-audit/, your Default site from /config, then the last site you used. Set a Default site once and the cockpit opens on it from any folder; the Audit row finds that site's audit wherever it was last seen./config. If Search Console says "no access" for a property you own, a service account is being used: set "Google account" to gcloud after gcloud auth application-default login --scopes=https://www.googleapis.com/auth/webmasters.readonly,https://www.googleapis.com/auth/cloud-platform. Core Web Vitals needs a Google API key ("Google API key" in /config).b to go back.ctrl+x tab. r refreshes everything, e exports HTML. Esc or Claude Code's own close mark closes the pane, and /seo-cockpit again toggles it.SEO · claude-seo.md · audit 85/100 · CWV good.Charts are drawn with block characters in the terminal and as SVG on the desktop app. Every view names its source and when it was fetched.
HTML dashboard. e in the pane, or /seo-cockpit export, writes one self-contained page (seo-cockpit-<time>.html in the working folder) with every view as SVG charts and tables, readable in light and dark. Where no pane can be drawn (the VS Code chat panel), /seo-cockpit writes this page instead and says where it is.
Commands that cost no tokens. These answer directly without starting a turn:
/seo-spend: DataForSEO spend today, over 7 and 30 days, by endpoint, with a 30-day spark row./seo-doctor: claude-seo runtime readiness, install location, and guard state./seo-cockpit [site | export]: the visual cockpit (for a given site, remembered), or with export the HTML dashboard./plugin marketplace add AgriciDaniel/claude-seo
/plugin install claude-seo@agricidaniel-claude-seo
/plugin install seo-cockpit@agricidaniel-claude-seo
Auto-update is off by default for third-party marketplaces. Run claude plugin update seo-cockpit@agricidaniel-claude-seo to update.
/config)| Setting | Default | Meaning |
|---|---|---|
| claude-seo folder | empty | Where claude-seo lives. When empty, it looks next to this plugin (a checkout) and in the plugin cache (an install) |
| Python command | python3 | Runs the stdlib-only ledger scripts |
| Spend guard | on | Turn off to let paid calls through unchecked |
| Audit band | on | The live line above the prompt during an audit |
| Economy mode | off | Run the five Opus agents on Sonnet |
| Default site | empty | The site the cockpit opens on when a folder shows none (claude-seo.md, sc-domain:claude-seo.md or https://claude-seo.md/). A site chosen in a folder, or its audit, comes first |
| Page for Core Web Vitals | empty | For the Vitals and drift views. Empty uses the property's site |
| Audits folder | empty | Where you keep your <site>-audit/ folders. The cockpit finds the shown site's audit there from any folder |
| Google account | auto | auto: claude-seo's own order (its sign-in, a service account, then your gcloud account). gcloud: always your own account from gcloud auth application-default login, for properties you own |
| Google API key | empty | For Core Web Vitals (CrUX). Sensitive: kept in Claude Code's secure storage. Empty uses claude-seo's own setup |
dataforseo_costs.py and runtime.py from your claude-seo folder; the cockpit runs gsc_query.py, crux_history.py and drift_history.py through runtime.py, so they use claude-seo's own Google setup./config), after which it asks again.claude -p and headless runs: there is no one to ask, so every call that needs approval is held. Calls the budget approves still run.$.process to run the ledger script. Where that is missing, DataForSEO calls are held and the commands report an error.claude -p. The receipt line is plain text and shows wherever the answer does; the cockpit falls back to the HTML dashboard.Verified on Claude Code 2.1.289, 2026-10-04:
claude plugin test: 85 of 85 kit tests pass (spend guard, Google settings, commands, audit band and receipt, economy mode, compaction, the cockpit Overview and details, site choice, export, and the pure logic)tsc, strict) and claude plugin validate --strict/seo-spend and /seo-doctorNot yet observed live: the audit band during a running /seo audit (covered by kit tests).
# Type-check against the typings Claude Code writes beside the plugin on load
npx -p typescript@5.9 tsc -p plugins/seo-cockpit
claude plugin validate plugins/seo-cockpit --strict
claude plugin test plugins/seo-cockpit
Layout: hooks/register.ts is the only file that calls on(). The rules live in hooks/lib/ (pure, no $), and the tests are in tests/.
hooks/register.ts 1000 lines1import type { EngineInterface, On, PluginOptions, ResultOf } from 'claude-code'
2
3import { auditFromPrompt, bandText, compactInstructions, economyModel, isSeoAgent, noteWrite, receiptText, type Audit } from './lib/audit'
4import { COLORS, htmlPage } from './lib/charts'
5import { doctorText, spendText } from './lib/format'
6import { classify, type PaidCall } from './lib/paid'
7import { candidatesOf, joinPath, latestVersion, MARKER, runtimeEnvOf } from './lib/root'
8import { chooseTarget, hostOf, rowOf, ROWS, siteOfAudit, statusLine } from './lib/overview'
9import { auditModel, emptyModel, gscModel, mapsModel, rankingsModel, spendModel, TABS, vitalsModel, type TabId, type TabModel } from './lib/tabs'
10import { combine, isUnpriced, parseCheck, usd, type Verdict } from './lib/verdict'
11import { paneView, type Kit, type PaneState } from './views/pane'
12
13type ToolResult = ResultOf['tool.call']
14
15/** What one activation knows: its settings, and what it has learned since it loaded. */
16type Ctx = {
17 setting: string
18 python: string
19 isGuardOn: boolean
20 /** Paid tools the person allowed until the plugin reloads. */
21 allowed: Set<string>
22 root: string | null
23 isEconomy: boolean
24 isBandOn: boolean
25 /** The audit being followed, kept after it finishes until the next prompt. */
26 audit: Audit | null
27 isBandHidden: boolean
28 isReceiptShown: boolean
29 /** Stops the once-a-second redraw of the band's clock. */
30 stopTicker: (() => void) | null
31 /** Search Console property and the URL for Core Web Vitals, from /config. */
32 site: string
33 pageUrl: string
34 pane: PaneState & { isOpen: boolean }
35 /** Google settings from /config, handed to claude-seo's scripts only. */
36 googleAccount: string
37 googleApiKey: string
38 /** Where the person keeps their audits (`<site>-audit/` folders), found from any working folder. */
39 auditsFolder: string
40 /** What the person typed in the pane as the site, per working folder. */
41 typed: string | null
42}
43
44const PANE_ID = 'seo-cockpit'
45
46
47const stamp = (): string => new Date().toISOString().slice(0, 16).replace('T', ' ')
48
49const HELD = 'seo-cockpit held this paid call'
50const ALLOW = 'Allow until reload'
51
52/** The tools the guard looks at; `classify` decides which of their calls are paid. */
53const GUARDED_TOOLS = ['Bash', 'WebFetch', /^PowerShell$/, /^mcp__/] as const
54
55/** A string field of a tool call's arguments, whatever the tool. */
56function fieldOf(e: object, key: string): string | undefined {
57 const value = (e as Readonly<Record<string, unknown>>)[key]
58
59 return typeof value === 'string' ? value : undefined
60}
61
62// Every function that takes `$` is declared here at the top of the file and
63// spells each call `$.noun.method(...)`, so `claude plugin validate` can read
64// what the module calls off its source.
65
66async function findRoot($: EngineInterface, ctx: Ctx): Promise<string | null> {
67 if (ctx.root !== null) {
68 return ctx.root
69 }
70
71 const { fixed, cacheDir, cacheRoot } = candidatesOf($.plugin.root, ctx.setting)
72
73 for (const candidate of fixed) {
74 if (await $.fs.exists(joinPath(candidate, MARKER))) {
75 ctx.root = candidate
76
77 return ctx.root
78 }
79 }
80
81 // This plugin's own marketplace first, then any other that carries claude-seo.
82 const cacheDirs = [cacheDir]
83
84 try {
85 for (const entry of await $.fs.list(cacheRoot)) {
86 const dir = joinPath(cacheRoot, entry.name, 'claude-seo')
87
88 if (entry.kind === 'dir' && dir !== cacheDir) {
89 cacheDirs.push(dir)
90 }
91 }
92 } catch {
93 // No plugin cache: not installed from a marketplace.
94 }
95
96 for (const dir of cacheDirs) {
97 try {
98 const version = latestVersion((await $.fs.list(dir)).filter(entry => entry.kind === 'dir').map(entry => entry.name))
99 const candidate = version === null ? null : joinPath(dir, version)
100
101 if (candidate !== null && (await $.fs.exists(joinPath(candidate, MARKER)))) {
102 ctx.root = candidate
103
104 return ctx.root
105 }
106 } catch {
107 // That marketplace has no claude-seo.
108 }
109 }
110
111 return null
112}
113
114/** The environment claude-seo's scripts run with: its own runtime folder, plus the Google settings from /config. */
115function scriptEnv(ctx: Ctx, seoRoot: string): Record<string, string> {
116 return {
117 ...runtimeEnvOf(seoRoot),
118 ...(ctx.googleAccount === 'gcloud' && { CLAUDE_SEO_GOOGLE_AUTH: 'adc' }),
119 ...(ctx.googleApiKey !== '' && { GOOGLE_API_KEY: ctx.googleApiKey }),
120 }
121}
122
123/** Runs one of claude-seo's stdlib-only scripts with the configured Python. */
124async function runScript($: EngineInterface, ctx: Ctx, seoRoot: string, script: string, args: readonly string[]) {
125 return $.process.run([ctx.python, joinPath(seoRoot, 'scripts', script), ...args], { timeoutMs: 20_000, env: scriptEnv(ctx, seoRoot) })
126}
127
128/** Writes a guarded call's cost to the ledger. Never throws: the call already ran. */
129async function logCost($: EngineInterface, ctx: Ctx, seoRoot: string, endpoint: string, cost: number): Promise<boolean> {
130 try {
131 const { exitCode } = await runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['log', endpoint, String(cost), '--note', 'seo-cockpit estimate'])
132
133 return exitCode === 0
134 } catch {
135 return false
136 }
137}
138
139/** Runs the call, then logs each endpoint it billed at its table price. A denied, failed or unpriced call is not logged. */
140async function runAndLog($: EngineInterface, ctx: Ctx, seoRoot: string, checks: ReadonlyArray<{ endpoint: string; verdict: Verdict }>, run: () => Promise<ToolResult>): Promise<ToolResult> {
141 const result = await run()
142
143 if (result.deny !== undefined) {
144 return result
145 }
146
147 const names = checks.map(check => check.endpoint).join(', ')
148
149 if (result.isError === true) {
150 return { ...result, context: [...(result.context ?? []), `seo-cockpit did not log ${names}: the call failed, and DataForSEO does not bill failed tasks.`] }
151 }
152
153 if (checks.some(check => isUnpriced(check.verdict))) {
154 return { ...result, context: [...(result.context ?? []), `seo-cockpit did not log ${names}: it has no price in the cost table. Log the actual cost from the response with dataforseo_costs.py log <endpoint> <cost>.`] }
155 }
156
157 const logged = await Promise.all(checks.map(check => logCost($, ctx, seoRoot, check.endpoint, 'costUsd' in check.verdict ? check.verdict.costUsd : 0)))
158 const total = checks.reduce((sum, check) => sum + ('costUsd' in check.verdict ? check.verdict.costUsd : 0), 0)
159
160 if (ctx.audit !== null && ctx.audit.finishMs === null) {
161 ctx.audit = { ...ctx.audit, spentUsd: ctx.audit.spentUsd + total }
162 }
163 // The skills tell Claude to log each call itself; say it is done so it is not counted twice.
164 const note = logged.every(Boolean)
165 ? `seo-cockpit logged this call to the claude-seo DataForSEO ledger (${names}, about ${usd(total)}). Do not run dataforseo_costs.py log for it.`
166 : `seo-cockpit could not log this call's cost. Log it with dataforseo_costs.py log <endpoint> <actual cost> for: ${names}.`
167
168 return { ...result, context: [...(result.context ?? []), note] }
169}
170
171/** Asks the person; a dismissed dialog, or a run with no one to ask, is a no. The safe answer is listed first. */
172async function askOrNull($: EngineInterface, question: string, choices: readonly string[]): Promise<string | null> {
173 try {
174 return await $.ui.ask(question, choices)
175 } catch {
176 return null
177 }
178}
179
180async function guard($: EngineInterface, ctx: Ctx, paid: PaidCall, run: () => Promise<ToolResult>): Promise<ToolResult> {
181 if (paid.kind === 'ask') {
182 if (ctx.allowed.has(paid.allowKey)) {
183 return run()
184 }
185
186 const answer = await askOrNull($, `${paid.label} bills a paid account. Run this call?`, ['Hold it', 'Run it', ALLOW])
187
188 if (answer === ALLOW) {
189 ctx.allowed.add(paid.allowKey)
190 }
191
192 return answer === 'Run it' || answer === ALLOW ? run() : { deny: `${HELD}: the person did not approve ${paid.label}.` }
193 }
194
195 const seoRoot = await findRoot($, ctx)
196
197 if (seoRoot === null) {
198 return { deny: `${HELD}: claude-seo was not found, so the DataForSEO budget could not be checked. Set "claude-seo folder" in /config, or turn the spend guard off there.` }
199 }
200
201 const checks = await Promise.all(
202 paid.endpoints.map(async endpoint => {
203 const { exitCode, stdout } = await runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['check', endpoint])
204
205 return { endpoint, verdict: parseCheck(exitCode, stdout) }
206 }),
207 )
208 const verdict = combine(checks.map(check => check.verdict))
209
210 switch (verdict.decision) {
211 case 'approved':
212 return runAndLog($, ctx, seoRoot, checks, run)
213 case 'blocked':
214 return { deny: `${HELD}: ${verdict.message}` }
215 case 'error':
216 // Look for claude-seo again next time: an update may have moved it.
217 ctx.root = null
218
219 return { deny: `${HELD}: ${verdict.message}. Run /seo-doctor.` }
220 case 'needs_approval': {
221 const left = verdict.remainingUsd === null ? '' : `, ${usd(verdict.remainingUsd)} left today`
222 const price = isUnpriced(verdict) ? 'has no listed price' : `costs about ${usd(verdict.costUsd)}`
223 const answer = await askOrNull(
224 $,
225 `${paid.label} ${price} (${verdict.reason.replace(/_/g, ' ')}; ${usd(verdict.todayUsd)} spent today${left}). Run it?`,
226 ['Hold it', 'Approve'],
227 )
228
229 return answer === 'Approve' ? runAndLog($, ctx, seoRoot, checks, run) : { deny: `${HELD}: the person did not approve ${paid.label}.` }
230 }
231 }
232}
233
234/** Redraws the band once a second while an audit runs, so its clock moves. */
235function startTicker($: EngineInterface, ctx: Ctx): void {
236 if (ctx.stopTicker === null) {
237 const timer = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
238
239 ctx.stopTicker = () => timer.cancel()
240 }
241}
242
243function stopTicker(ctx: Ctx): void {
244 ctx.stopTicker?.()
245 ctx.stopTicker = null
246}
247
248/** The band's clock needs redrawing only while the band shows a running audit. */
249function syncTicker($: EngineInterface, ctx: Ctx): void {
250 const audit = ctx.audit
251
252 if (ctx.isBandOn && !ctx.isBandHidden && audit !== null && audit.finishMs === null) {
253 startTicker($, ctx)
254 } else {
255 stopTicker(ctx)
256 }
257}
258
259/** Sets the audit, redraws, and keeps a copy in the store so a reload mid-audit does not lose it. */
260function setAudit($: EngineInterface, ctx: Ctx, audit: Audit | null): void {
261 ctx.audit = audit
262 syncTicker($, ctx)
263 $.ui.invalidate('ui.render')
264 void $.store.set('audit', audit).catch(() => undefined)
265}
266
267/** Starts following a new audit and shows the band again. */
268function follow($: EngineInterface, ctx: Ctx, audit: Audit): void {
269 ctx.isBandHidden = false
270 ctx.isReceiptShown = false
271 setAudit($, ctx, audit)
272}
273
274/** An audit older than this with no result is treated as abandoned. */
275const STALE_MS = 3 * 60 * 60 * 1000
276
277/** After a reload, picks up an audit that was still running. */
278async function restoreAudit($: EngineInterface, ctx: Ctx): Promise<void> {
279 if (ctx.audit !== null) {
280 return
281 }
282
283 const saved = (await $.store.get('audit').catch(() => null)) as Audit | null
284
285 if (saved !== null && typeof saved === 'object' && typeof saved.domain === 'string' && saved.finishMs === null && Date.now() - saved.startMs < STALE_MS) {
286 ctx.audit = saved
287 syncTicker($, ctx)
288 $.ui.invalidate('ui.render')
289 }
290}
291
292/** Runs a claude-seo script through its managed runtime (for scripts that need its packages) and parses the JSON it prints. */
293async function runtimeJson($: EngineInterface, ctx: Ctx, seoRoot: string, script: string, args: readonly string[]): Promise<{ data: unknown; error: string | null }> {
294 try {
295 const { exitCode, stdout, stderr } = await $.process.run([ctx.python, joinPath(seoRoot, 'scripts', 'runtime.py'), 'run', script, ...args], { timeoutMs: 90_000, env: scriptEnv(ctx, seoRoot) })
296
297 try {
298 return { data: JSON.parse(stdout), error: null }
299 } catch {
300 const reason = stderr.trim().split('\n').at(-1) ?? ''
301
302 return { data: null, error: exitCode === 3 ? 'claude-seo runtime is not set up: run /seo setup' : reason || `${script} printed no JSON (exit ${exitCode})` }
303 }
304 } catch (error) {
305 return { data: null, error: error instanceof Error ? error.message : String(error) }
306 }
307}
308
309/** The newest file under the working folder's `*<suffix>` folders whose name passes `test`. */
310async function newestFile($: EngineInterface, suffix: string, test: (name: string) => boolean): Promise<string | null> {
311 let best: { path: string; mtime: number } | null = null
312
313 try {
314 const cwd = await $.session.cwd()
315
316 for (const dir of (await $.fs.list(cwd)).filter(entry => entry.kind === 'dir' && entry.name.endsWith(suffix))) {
317 for (const file of (await $.fs.list(joinPath(cwd, dir.name))).filter(entry => entry.kind === 'file' && test(entry.name))) {
318 const path = joinPath(cwd, dir.name, file.name)
319 const { mtimeMs } = await $.fs.stat(path)
320
321 if (best === null || mtimeMs > best.mtime) {
322 best = { path, mtime: mtimeMs }
323 }
324 }
325 }
326 } catch {
327 // An unreadable folder has nothing to show; the tab says how to make some.
328 }
329
330 return best?.path ?? null
331}
332
333/** A path as shown on screen: relative to the working folder when it is inside it. */
334async function shown($: EngineInterface, path: string | null): Promise<string> {
335 if (path === null) {
336 return ''
337 }
338
339 const cwd = await $.session.cwd()
340
341 return path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
342}
343
344/** The store key for a tab's last result: per working folder and property, so one project's data never shows in another. */
345async function cacheKey($: EngineInterface, ctx: Ctx, tab: TabId): Promise<string> {
346 return `tab:${tab}:${await $.session.cwd()}:${ctx.site}`
347}
348
349const isModel = (value: unknown): value is TabModel => {
350 const m = value as Partial<TabModel> | null
351
352 return typeof m === 'object' && m !== null && typeof m.heading === 'string' && Array.isArray(m.kpis) && Array.isArray(m.charts) && Array.isArray(m.tables) && Array.isArray(m.notes)
353}
354
355/** The audit for a site: a live one, this folder's if it is about that site, else the one remembered for it. */
356async function auditFor($: EngineInterface, ctx: Ctx, host: string | null): Promise<[unknown, string]> {
357 if (ctx.audit?.dir != null && (host === null || ctx.audit.domain === host)) {
358 const path = joinPath(ctx.audit.dir, 'audit-data.json')
359
360 return [await readJson($, path), await shown($, path)]
361 }
362
363 const here = await newestFile($, '-audit', name => name === 'audit-data.json')
364
365 if (here !== null) {
366 const data = await readJson($, here)
367
368 if (host === null || siteOfAudit(here.split(/[/\\]/).at(-2) ?? '', data) === host) {
369 return [data, await shown($, here)]
370 }
371 }
372
373 const remembered = host === null ? undefined : await $.store.get(`audit:${host}`).catch(() => undefined)
374
375 if (typeof remembered === 'string') {
376 const data = await readJson($, remembered)
377
378 if (data !== null) {
379 return [data, remembered]
380 }
381 }
382
383 // The audits folder from /config: `<site>-audit/audit-data.json`, with or without www.
384 if (host !== null && ctx.auditsFolder !== '') {
385 for (const name of [`${host}-audit`, `www.${host}-audit`]) {
386 const path = joinPath(ctx.auditsFolder, name, 'audit-data.json')
387 const data = await readJson($, path)
388
389 if (data !== null) {
390 void $.store.set(`audit:${host}`, path).catch(() => undefined)
391
392 return [data, path]
393 }
394 }
395 }
396
397 return [null, '']
398}
399
400async function readJson($: EngineInterface, path: string): Promise<unknown> {
401 try {
402 return JSON.parse(await $.fs.read(path))
403 } catch {
404 return null
405 }
406}
407
408/** The site the cockpit is about: this folder's choice, its audit, the /config default, then the last site used. */
409async function resolveHost($: EngineInterface, ctx: Ctx): Promise<void> {
410 const cwd = await $.session.cwd().catch(() => '')
411
412 if (ctx.typed === null) {
413 const saved = await $.store.get(`target:${cwd}`).catch(() => undefined)
414
415 ctx.typed = typeof saved === 'string' ? saved : null
416 }
417
418 let inferred: string | null = ctx.audit?.domain ?? null
419
420 if (inferred === null) {
421 const path = await newestFile($, '-audit', name => name === 'audit-data.json')
422
423 if (path !== null) {
424 const folder = path.split(/[/\\]/).at(-2) ?? ''
425
426 inferred = siteOfAudit(folder, await readJson($, path))
427
428 // Remember where this site's audit lives, so the Audit row works from any folder.
429 if (inferred !== null) {
430 void $.store.set(`audit:${inferred}`, path).catch(() => undefined)
431 }
432 }
433 }
434
435 const setting = ctx.site || ctx.pageUrl
436 const chosen = chooseTarget(setting, ctx.typed, inferred)
437 let host = chosen.host
438 let source: PaneState['hostSource'] = chosen.source
439
440 // Nothing here says which site: use the last one seen anywhere, and say so.
441 if (host === null) {
442 const last = await $.store.get('target:last').catch(() => undefined)
443
444 host = typeof last === 'string' ? hostOf(last) : null
445 source = host === null ? null : 'last'
446 } else {
447 void $.store.set('target:last', host).catch(() => undefined)
448 }
449
450 ctx.pane.host = host
451 ctx.pane.hostSource = source
452}
453
454/**
455 * The Search Console property for a site. A /config value counts only when it
456 * is a real property (`sc-domain:` or a URL prefix) for that site; a bare name
457 * such as `claude-seo.md` is not a property and becomes `sc-domain:claude-seo.md`.
458 */
459function propertyFor(setting: string, host: string): string {
460 const value = setting.trim()
461
462 return hostOf(value) === host && /^(sc-domain:|https?:\/\/)/i.test(value) ? value : `sc-domain:${host}`
463}
464
465/** Builds one source's model from claude-seo's own scripts and files. Free: no paid API is called. */
466async function buildTab($: EngineInterface, ctx: Ctx, tab: TabId): Promise<TabModel> {
467 const at = stamp()
468 const label = TABS.find(t => t.id === tab)?.label ?? tab
469 const seoRoot = await findRoot($, ctx)
470
471 if (seoRoot === null) {
472 return emptyModel(label, 'claude-seo', at, 'claude-seo was not found. Set "claude-seo folder" in /config.')
473 }
474
475 const host = ctx.pane.host
476 // The /config property and page apply only to the site they name; any other site uses its domain property and home page.
477 const property = host === null ? '' : propertyFor(ctx.site, host)
478 const propertyArgs = property === '' ? [] : ['--property', property]
479 const url = host === null ? '' : hostOf(ctx.pageUrl) === host ? ctx.pageUrl : `https://${host}`
480
481 if (tab === 'gsc' || tab === 'rankings') {
482 // With no site, claude-seo would fall back to its own default property, which may be another site.
483 if (property === '') {
484 return emptyModel(label, 'gsc_query.py', at, 'No URL to measure.')
485 }
486
487 const [byDate, byQuery] = await Promise.all([
488 runtimeJson($, ctx, seoRoot, 'gsc_query.py', ['query', '--dimensions', 'date', '--days', '90', '--limit', '1000', '--json', ...propertyArgs]),
489 runtimeJson($, ctx, seoRoot, 'gsc_query.py', ['query', '--dimensions', 'query', '--days', '28', '--limit', tab === 'gsc' ? '10' : '200', '--json', ...propertyArgs]),
490 ])
491
492 if (byDate.error !== null) {
493 return emptyModel(label, 'gsc_query.py', at, byDate.error)
494 }
495
496 if (tab === 'gsc') {
497 return gscModel(byDate.data, byQuery.data, property, at)
498 }
499
500 const drift = url === '' ? { data: null } : await runtimeJson($, ctx, seoRoot, 'drift_history.py', [url, '--limit', '20'])
501
502 return rankingsModel(byDate.data, byQuery.data, drift.data, property, at)
503 }
504
505 if (tab === 'vitals') {
506 if (url === '') {
507 return emptyModel(label, 'crux_history.py', at, 'No URL to measure.')
508 }
509
510 const crux = await runtimeJson($, ctx, seoRoot, 'crux_history.py', [url, '--json'])
511
512 return crux.error !== null ? emptyModel(label, 'crux_history.py', at, crux.error) : vitalsModel(crux.data, url, at)
513 }
514
515 if (tab === 'audit') {
516 return auditModel(...(await auditFor($, ctx, host)), at)
517 }
518
519 if (tab === 'maps') {
520 const path = await newestFile($, '-maps', name => /^geo-grid-.*\.json$/.test(name))
521
522 return mapsModel(path === null ? null : await readJson($, path), await shown($, path), at)
523 }
524
525 const [today, summary] = await Promise.all([runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['today']), runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['summary', '--days', '30'])])
526
527 try {
528 return spendModel(JSON.parse(today.stdout), JSON.parse(summary.stdout), at)
529 } catch {
530 return emptyModel(label, 'dataforseo_costs.py', at, 'The spend ledger could not be read.')
531 }
532}
533
534/** Loads one source, cache first: the last result shows at once, the fresh one replaces it. */
535async function loadSource($: EngineInterface, ctx: Ctx, tab: TabId): Promise<void> {
536 const key = await cacheKey($, ctx, tab).catch(() => null)
537
538 if (ctx.pane.models[tab] === undefined && key !== null) {
539 const cached = await $.store.get(key).catch(() => undefined)
540
541 if (isModel(cached)) {
542 ctx.pane.models[tab] = cached
543 }
544 }
545
546 ctx.pane.loading = new Set([...ctx.pane.loading, tab])
547 $.ui.invalidate('ui.render')
548
549 try {
550 const model = await buildTab($, ctx, tab)
551
552 ctx.pane.models[tab] = model
553
554 if (key !== null) {
555 await $.store.set(key, model).catch(() => undefined)
556 }
557 } finally {
558 ctx.pane.loading = new Set([...ctx.pane.loading].filter(t => t !== tab))
559 $.ui.invalidate('ui.render')
560 }
561}
562
563/** Loads every source in the background (all free), then pins the one-line summary under the prompt. */
564async function loadAll($: EngineInterface, ctx: Ctx): Promise<void> {
565 // Every row says "checking" from the first frame, not "not loaded".
566 ctx.pane.loading = new Set(ROWS.map(row => row.id))
567 $.ui.invalidate('ui.render')
568 await resolveHost($, ctx)
569 await Promise.allSettled(ROWS.map(row => loadSource($, ctx, row.id)))
570
571 const rows = ROWS.map(row => rowOf(row.id, ctx.pane.models[row.id], false))
572
573 $.ui.status(statusLine(ctx.pane.host, rows))
574}
575
576/** Remembers a site the person gave: for this folder, and as the last one used anywhere. */
577async function rememberTarget($: EngineInterface, ctx: Ctx, value: string): Promise<void> {
578 ctx.typed = value.trim()
579 ctx.pane.models = {}
580 await $.store.set(`target:${await $.session.cwd()}`, ctx.typed).catch(() => undefined)
581 await $.store.set('target:last', hostOf(ctx.typed)).catch(() => undefined)
582}
583
584/** The person typed the site in the pane: remember it and load again. */
585async function setTarget($: EngineInterface, ctx: Ctx, value: string): Promise<void> {
586 if (hostOf(value) === null) {
587 return
588 }
589
590 await rememberTarget($, ctx, value)
591 await loadAll($, ctx)
592}
593
594/** Writes every loaded source into one self-contained HTML page in the working folder; returns its path. */
595async function exportHtml($: EngineInterface, ctx: Ctx, loadAllFirst: boolean): Promise<string> {
596 if (loadAllFirst) {
597 await resolveHost($, ctx)
598
599 for (const tab of TABS) {
600 ctx.pane.models[tab.id] = await buildTab($, ctx, tab.id)
601 }
602 }
603
604 const sections = TABS.flatMap(tab => {
605 const model = ctx.pane.models[tab.id]
606
607 return model === undefined ? [] : [{ ...model, source: `${model.source} · fetched ${model.fetchedAt}`, notes: model.error === null ? model.notes : [model.error, ...model.notes] }]
608 })
609 const cwd = await $.session.cwd()
610 const path = joinPath(cwd, `seo-cockpit-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.html`)
611
612 await $.fs.write(path, htmlPage(`SEO Cockpit${ctx.pane.host === null ? '' : `: ${ctx.pane.host}`}`, `Generated ${stamp()} by seo-cockpit from claude-seo data`, sections))
613 ctx.pane.exported = path
614 $.ui.invalidate('ui.render')
615
616 return path
617}
618
619async function cockpitCommand($: EngineInterface, ctx: Ctx, args: string): Promise<{ text: string }> {
620 // The engine already names the plugin above a command's answer; the text is the outcome only.
621 try {
622 const arg = args.trim()
623
624 if (arg.toLowerCase() === 'export') {
625 return { text: `Dashboard written to ${await exportHtml($, ctx, true)}` }
626 }
627
628 // `/seo-cockpit claude-seo.md`: that site, remembered for this folder and as the last one used.
629 if (arg !== '') {
630 if (hostOf(arg) === null) {
631 return { text: `"${arg}" is not a site. Try /seo-cockpit example.com, or /seo-cockpit export.` }
632 }
633
634 await rememberTarget($, ctx, arg)
635
636 if (ctx.pane.isOpen) {
637 void loadAll($, ctx).catch(() => undefined)
638
639 return { text: `Cockpit switched to ${hostOf(arg)}.` }
640 }
641 } else if (ctx.pane.isOpen) {
642 // A second /seo-cockpit closes it, as the official modernization pane does.
643 await $.ui.close({ id: PANE_ID }).catch(() => undefined)
644 ctx.pane.isOpen = false
645
646 return { text: 'Cockpit closed.' }
647 }
648
649 await resolveHost($, ctx)
650
651 // Ask for room: inline, the engine grants up to what the layout spares (a size the person set wins).
652 // Docked beside the transcript (fullscreen, 110+ columns), the pane is full height anyway.
653 const opened = await $.ui.open({ id: PANE_ID, title: 'SEO Cockpit', focus: true, rows: 40 })
654
655 if (opened.isPlaced) {
656 ctx.pane.isOpen = true
657 ctx.pane.view = 'overview'
658 void loadAll($, ctx).catch(() => undefined)
659
660 return { text: ctx.pane.host === null ? 'Cockpit open. Type the site in the pane to load its data.' : `Cockpit open for ${ctx.pane.host}.` }
661 }
662
663 // No pane here (the VS Code chat panel): the HTML page is the cockpit.
664 return { text: `No pane on this screen (${opened.reason}). Dashboard written to ${await exportHtml($, ctx, true)}` }
665 } catch (error) {
666 return { text: `The cockpit could not open: ${error instanceof Error ? error.message : String(error)}` }
667 }
668}
669
670async function registerCommands($: EngineInterface): Promise<void> {
671 await Promise.all([
672 $.command.register({ name: 'seo-spend', description: 'claude-seo DataForSEO spend: today, 7 and 30 days, by endpoint', immediate: true }).catch(() => undefined),
673 $.command.register({ name: 'seo-doctor', description: 'claude-seo runtime readiness and where it is installed', immediate: true }).catch(() => undefined),
674 $.command.register({ name: 'seo-cockpit', description: 'Charts for Search Console, rankings, Core Web Vitals, the audit, Maps and spend', argumentHint: '[site | export]', immediate: true }).catch(() => undefined),
675 ])
676}
677
678async function spendCommand($: EngineInterface, ctx: Ctx): Promise<{ text: string }> {
679 const seoRoot = await findRoot($, ctx)
680
681 if (seoRoot === null) {
682 return { text: 'claude-seo was not found. Set "claude-seo folder" in /config.' }
683 }
684
685 try {
686 const [today, summary] = await Promise.all([
687 runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['today']),
688 runScript($, ctx, seoRoot, 'dataforseo_costs.py', ['summary', '--days', '30']),
689 ])
690
691 if (today.exitCode !== 0 || summary.exitCode !== 0) {
692 const failed = today.exitCode !== 0 ? today : summary
693 // A ledger error prints its reason as JSON on stdout, not on stderr.
694 const reason = /"message"\s*:\s*"([^"]*)"/.exec(failed.stdout)?.[1] || failed.stderr.trim().split('\n').at(-1) || 'no detail'
695
696 return { text: `The ledger could not be read (${reason}).` }
697 }
698
699 return { text: spendText(JSON.parse(today.stdout), JSON.parse(summary.stdout)) }
700 } catch (error) {
701 return { text: `The ledger could not be read (${error instanceof Error ? error.message : String(error)}). Is "${ctx.python}" on PATH? Set "Python command" in /config.` }
702 }
703}
704
705async function doctorCommand($: EngineInterface, ctx: Ctx): Promise<{ text: string }> {
706 const seoRoot = await findRoot($, ctx)
707
708 if (seoRoot === null) {
709 return { text: 'claude-seo was not found next to this plugin or in the plugin cache. Set "claude-seo folder" in /config.' }
710 }
711
712 try {
713 // Exit 3 means "setup required" and still prints the report.
714 const { stdout } = await runScript($, ctx, seoRoot, 'runtime.py', ['doctor', '--json'])
715
716 return { text: `${doctorText(JSON.parse(stdout), seoRoot)}\n guard ${ctx.isGuardOn ? 'on' : 'off'} (Python: ${ctx.python})` }
717 } catch (error) {
718 return { text: `Doctor failed (${error instanceof Error ? error.message : String(error)}). Is "${ctx.python}" on PATH?` }
719 }
720}
721
722/**
723 * seo-cockpit: a spend guard over paid SEO API calls and zero-token commands
724 * for claude-seo. This is the only file that calls `on()`; the rules live in
725 * ./lib and never touch `$`.
726 *
727 * @param on the engine's registrar
728 * @param options the plugin's userConfig values
729 */
730export function register(on: On, options: PluginOptions) {
731 const ctx: Ctx = {
732 setting: typeof options.claudeSeoRoot === 'string' ? options.claudeSeoRoot : '',
733 python: typeof options.python === 'string' && options.python.trim() !== '' ? options.python.trim() : 'python3',
734 isGuardOn: options.spendGuard !== false,
735 allowed: new Set<string>(),
736 root: null,
737 isEconomy: options.economy === true,
738 isBandOn: options.auditBand !== false,
739 audit: null,
740 isBandHidden: false,
741 isReceiptShown: false,
742 stopTicker: null,
743 site: typeof options.site === 'string' ? options.site : '',
744 pageUrl: typeof options.pageUrl === 'string' ? options.pageUrl.trim() : '',
745 pane: { view: 'overview', models: {}, loading: new Set(), host: null, hostSource: null, exported: null, isOpen: false },
746 typed: null,
747 googleAccount: typeof options.googleAccount === 'string' ? options.googleAccount : 'auto',
748 auditsFolder: typeof options.auditsFolder === 'string' ? options.auditsFolder.trim() : '',
749 googleApiKey: typeof options.googleApiKey === 'string' ? options.googleApiKey.trim() : '',
750 }
751
752 // ------------------------------------------------------------ spend guard
753
754 on('tool.call', { tool: GUARDED_TOOLS }, async ($, e, next) => {
755 if (!ctx.isGuardOn) {
756 return next(e)
757 }
758
759 const paid = classify(e.tool, fieldOf(e, 'command'), fieldOf(e, 'url'))
760
761 return paid === null ? next(e) : guard($, ctx, paid, () => next(e))
762 }).catch(async ($, e, next) =>
763 // After the call ran, its result stands: saying it did not run would invite a paid retry.
764 next.called
765 ? next(e)
766 : { deny: `${HELD}: the spend guard failed, so the call did not run. Run /seo-doctor, or turn the spend guard off in /config.` },
767 )
768
769 // ---------------------------------------------------------- audit progress
770
771 on('prompt.submit', async ($, e, next) => {
772 const audit = auditFromPrompt(e.text, Date.now())
773
774 if (audit !== null) {
775 follow($, ctx, audit)
776 } else if (ctx.audit !== null && (ctx.audit.finishMs !== null || Date.now() - ctx.audit.startMs > STALE_MS)) {
777 // A finished audit's band stays up until the next prompt; an abandoned one goes too.
778 setAudit($, ctx, null)
779 }
780
781 return next(e)
782 })
783
784 // A slash command may reach the engine as a command run rather than prompt text; observe both.
785 on('command.run', { command: ['seo', 'claude-seo:seo'] }, async ($, e, next) => {
786 const audit = auditFromPrompt(`/seo ${e.args}`, Date.now())
787
788 if (audit !== null && (ctx.audit === null || ctx.audit.domain !== audit.domain || ctx.audit.finishMs !== null)) {
789 follow($, ctx, audit)
790 }
791
792 return next(e)
793 })
794
795 on('agent.spawn', async ($, e, next) => {
796 if (!isSeoAgent(e.subagentType)) {
797 return next(e)
798 }
799
800 const isFollowing = ctx.audit !== null && ctx.audit.finishMs === null
801
802 if (ctx.audit !== null && isFollowing) {
803 setAudit($, ctx, { ...ctx.audit, agents: { ...ctx.audit.agents, [e.tool_use_id]: { type: e.subagentType, state: 'running', startMs: Date.now() } } })
804 }
805
806 const model = ctx.isEconomy && e.model === undefined ? economyModel(e.subagentType) : null
807 const result = await next(model === null ? e : { ...e, model })
808 const run = ctx.audit?.agents[e.tool_use_id]
809
810 // The agent's id lets its own turn end close it, which a background agent needs.
811 if (ctx.audit !== null && run !== undefined && result.agentId !== undefined) {
812 setAudit($, ctx, { ...ctx.audit, agents: { ...ctx.audit.agents, [e.tool_use_id]: { ...run, agentId: result.agentId } } })
813 }
814
815 return result
816 })
817
818 // A foreground Agent call returns when its subagent is done; a background one returns at launch.
819 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
820 const result = await next(e)
821 const run = ctx.audit?.agents[e.tool_use_id]
822 const status = (result.result as { status?: unknown } | undefined)?.status
823 const isLaunchOnly = status === 'async_launched' || status === 'remote_launched'
824
825 if (ctx.audit !== null && run !== undefined && run.state === 'running' && !isLaunchOnly) {
826 const state = result.deny !== undefined || result.isError === true ? 'failed' : 'done'
827
828 setAudit($, ctx, { ...ctx.audit, agents: { ...ctx.audit.agents, [e.tool_use_id]: { ...run, state, endMs: Date.now() } } })
829 }
830
831 return result
832 })
833
834 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
835 const result = await next(e)
836 const path = fieldOf(e, 'file_path')
837
838 if (path !== undefined && result.deny === undefined && result.isError !== true) {
839 const before = ctx.audit
840 // A Write carries the whole file; an Edit only a fragment, so its score is not read.
841 const after = noteWrite(before, path, e.tool === 'Write' ? fieldOf(e, 'content') : undefined, Date.now())
842
843 if (after !== null && after !== before) {
844 if (before === null || before.finishMs !== null) {
845 follow($, ctx, after)
846 } else {
847 setAudit($, ctx, after)
848 }
849 }
850 }
851
852 return result
853 })
854
855 on('turn.complete', async ($, e, next) => {
856 const result = await next(e)
857 const audit = ctx.audit
858
859 if (e.agentId !== undefined) {
860 // A subagent finished: close its run (the only signal a background agent gives).
861 const entry = audit === null ? undefined : Object.entries(audit.agents).find(([, run]) => run.agentId === e.agentId && run.state === 'running')
862
863 if (audit !== null && entry !== undefined) {
864 setAudit($, ctx, { ...audit, agents: { ...audit.agents, [entry[0]]: { ...entry[1], state: 'done', endMs: Date.now() } } })
865 }
866
867 return result
868 }
869
870 // The receipt goes under the main answer, once, when the audit's data file exists.
871 if (audit === null || audit.finishMs !== null || audit.score === null || ctx.isReceiptShown) {
872 return result
873 }
874
875 const now = Date.now()
876
877 ctx.isReceiptShown = true
878 setAudit($, ctx, { ...audit, finishMs: now })
879
880 const receipt = receiptText(audit, now)
881 // `next` resolves to the answer itself; any other text is shown beneath it. Keep another mod's line, never the answer.
882 const theirs = result.text !== '' && result.text !== e.answer ? result.text : ''
883
884 return { ...result, text: theirs === '' ? receipt : `${theirs}\n${receipt}` }
885 })
886
887 on('session.compact', async ($, e, next) => {
888 const audit = ctx.audit
889
890 if (e.agentId !== undefined || audit === null || audit.finishMs !== null) {
891 return next(e)
892 }
893
894 return next({ ...e, instructions: [e.instructions, compactInstructions(audit)].filter(Boolean).join('\n\n') })
895 })
896
897 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
898 const audit = ctx.audit
899
900 if (!ctx.isBandOn || audit === null || ctx.isBandHidden || e.props.hasSurvey) {
901 return next(e)
902 }
903
904 const { Box, Text, Button } = $.ui.resolve(e)
905 const columns = Math.max(20, Math.floor(e.props.bodyColumns) - 10)
906 // Other mods' bands sit below ours rather than being replaced.
907 const theirs = await next(e)
908
909 return Box({
910 flexDirection: 'column',
911 children: [
912 Box({
913 flexDirection: 'row',
914 columnGap: 1,
915 children: [
916 Text({ color: audit.finishMs === null ? COLORS.blue : COLORS.good, children: [bandText(audit, Date.now(), columns, ctx.isEconomy)] }),
917 Button({
918 key: 'seo-cockpit-hide',
919 label: 'hide',
920 plain: true,
921 onPress: () => {
922 ctx.isBandHidden = true
923 syncTicker($, ctx)
924 $.ui.invalidate('ui.render')
925 },
926 }),
927 ],
928 }),
929 theirs,
930 ],
931 })
932 })
933
934 // ------------------------------------------------------------------- pane
935
936 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
937 if (e.requestId !== PANE_ID) {
938 return next(e)
939 }
940
941 const table = $.ui.resolve(e)
942 const kit: Kit = {
943 Box: table.Box,
944 Text: table.Text,
945 Button: table.Button,
946 ...('Markdown' in table && { Markdown: table.Markdown }),
947 // An Input exists on every surface but mobile.
948 ...(e.surface !== 'mobile' && 'Input' in table && { Input: table.Input }),
949 // Every table is completed with every element name, so `'Svg' in table` holds on the terminal too, where an Svg draws nothing.
950 ...(e.surface !== 'terminal' && 'Svg' in table && { Svg: table.Svg }),
951 }
952
953 ctx.pane.isOpen = true
954
955 return paneView(kit, ctx.pane, Math.max(24, Math.floor(e.props.bodyColumns) - 1), e.props.isFocused, {
956 open: tab => {
957 ctx.pane.view = tab
958 $.ui.invalidate('ui.render')
959 },
960 back: () => {
961 ctx.pane.view = 'overview'
962 $.ui.invalidate('ui.render')
963 },
964 refresh: () => void loadAll($, ctx).catch(() => undefined),
965 exportHtml: () => void exportHtml($, ctx, false).catch(() => undefined),
966 setTarget: value => void setTarget($, ctx, value).catch(() => undefined),
967 })
968 })
969
970 on('ui.close', async ($, e, next) => {
971 if (e.id === PANE_ID) {
972 ctx.pane.isOpen = false
973 }
974
975 return next(e)
976 })
977
978 // --------------------------------------------------------------- commands
979
980 on('session.start', async ($, e, next) => {
981 await registerCommands($)
982 await restoreAudit($, ctx)
983
984 return next(e)
985 })
986
987 // /clear and /resume skip session.start; registering again replaces the command, so this is safe to repeat.
988 on('classic.SessionStart', async ($, e, next) => {
989 await registerCommands($)
990
991 return next(e)
992 })
993
994 on('command.run', { command: 'seo-spend' }, async ($, e, next) => spendCommand($, ctx))
995
996 on('command.run', { command: 'seo-doctor' }, async ($, e, next) => doctorCommand($, ctx))
997
998 on('command.run', { command: 'seo-cockpit' }, async ($, e, next) => cockpitCommand($, ctx, e.args))
999}
1000hooks/lib/audit.ts 192 lines1/**
2 * Following a claude-seo audit from what the session does: the prompt that
3 * starts it, the agents it spawns, the files it writes. Pure: no `$`.
4 *
5 * An audit writes everything under `{domain}-audit/`: per-agent
6 * `findings/*.md`, then `audit-data.json` with the health score
7 * (skills/seo-audit/SKILL.md, "Structured Audit Data Envelope").
8 */
9
10import { usd } from './verdict'
11
12export type AgentRun = { type: string; state: 'running' | 'done' | 'failed'; startMs: number; endMs?: number; agentId?: string }
13
14export type Audit = {
15 domain: string
16 startMs: number
17 /** By the Agent tool call's id. */
18 agents: Record<string, AgentRun>
19 /** Findings files written, by file name. */
20 findings: string[]
21 /** The `{domain}-audit` folder, once a write shows where it is. */
22 dir: string | null
23 /** What the spend guard logged while the audit ran. */
24 spentUsd: number
25 score: number | null
26 categories: ReadonlyArray<{ name: string; score: number }>
27 finishMs: number | null
28}
29
30/** The five claude-seo agents that run on Opus (agents/*.md frontmatter). */
31export const OPUS_AGENTS: ReadonlySet<string> = new Set(['seo-content', 'seo-geo', 'seo-sxo', 'seo-cluster', 'seo-drift'])
32
33/** An agent type without its plugin namespace (`claude-seo:seo-geo` becomes `seo-geo`). */
34export const bareType = (type: string): string => type.split(':').at(-1) ?? type
35
36export const isSeoAgent = (type: string): boolean => /^seo-[a-z-]+$/.test(bareType(type))
37
38/** The model an agent is routed to in economy mode, or null to leave it alone. */
39export const economyModel = (type: string): string | null => (OPUS_AGENTS.has(bareType(type)) ? 'sonnet' : null)
40
41/** The host of a URL or bare domain, without `www.`; null when there is none. */
42export function domainOf(target: string): string | null {
43 const text = target.trim().replace(/^["'<]+|["'>]+$/g, '')
44
45 try {
46 const host = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`).hostname.toLowerCase()
47
48 return /\./.test(host) ? host.replace(/^www\./, '') : null
49 } catch {
50 return null
51 }
52}
53
54/** The audit a prompt starts (`/seo audit <url>`, also namespaced as `/claude-seo:seo audit`), or null. */
55export function auditFromPrompt(text: string, nowMs: number): Audit | null {
56 const match = /^\s*\/(?:claude-seo:)?seo\s+audit\s+(\S+)/i.exec(text)
57 const domain = match?.[1] === undefined ? null : domainOf(match[1])
58
59 return domain === null ? null : newAudit(domain, nowMs)
60}
61
62export function newAudit(domain: string, nowMs: number): Audit {
63 return { domain, startMs: nowMs, agents: {}, findings: [], dir: null, spentUsd: 0, score: null, categories: [], finishMs: null }
64}
65
66/** Where a written file sits in an audit folder, if it does. */
67export function auditPathOf(path: string): { dir: string; domain: string; kind: 'finding' | 'data' | 'other'; name: string } | null {
68 const match = /^(.*?([^/\\]+)-audit)[/\\](.+)$/.exec(path)
69
70 if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
71 return null
72 }
73
74 const rest = match[3]
75 const name = rest.split(/[/\\]/).at(-1) ?? rest
76 const kind = /^findings[/\\][^/\\]+\.md$/.test(rest) ? 'finding' : rest === 'audit-data.json' ? 'data' : 'other'
77
78 return { dir: match[1], domain: match[2], kind, name }
79}
80
81/** The health score and category scores from an audit-data.json text; nulls when unreadable. */
82export function scoresOf(text: string): { score: number | null; categories: Array<{ name: string; score: number }> } {
83 try {
84 const data = JSON.parse(text) as { summary?: { health_score?: unknown }; categories?: unknown }
85 const raw = data.summary?.health_score
86 const categories = Array.isArray(data.categories)
87 ? data.categories.flatMap(row => {
88 const { name, score } = (row ?? {}) as { name?: unknown; score?: unknown }
89
90 return typeof name === 'string' && typeof score === 'number' && Number.isFinite(score) ? [{ name, score }] : []
91 })
92 : []
93
94 return { score: typeof raw === 'number' && Number.isFinite(raw) ? raw : null, categories }
95 } catch {
96 return { score: null, categories: [] }
97 }
98}
99
100/** Records a write; returns the audit it belongs to (started from the folder when none is running). */
101export function noteWrite(audit: Audit | null, path: string, content: string | undefined, nowMs: number): Audit | null {
102 const where = auditPathOf(path)
103
104 if (where === null) {
105 return audit
106 }
107
108 const current = audit === null || audit.finishMs !== null ? newAudit(domainOf(where.domain) ?? where.domain, nowMs) : audit
109 const next: Audit = { ...current, dir: where.dir }
110
111 if (where.kind === 'finding' && !next.findings.includes(where.name)) {
112 next.findings = [...next.findings, where.name]
113 }
114
115 if (where.kind === 'data' && content !== undefined) {
116 const { score, categories } = scoresOf(content)
117
118 next.score = score
119 next.categories = categories
120 }
121
122 return next
123}
124
125const count = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
126
127export const elapsed = (ms: number): string => {
128 const seconds = Math.max(0, Math.round(ms / 1000))
129
130 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
131}
132
133/** Agent counts: running, done (failed ones count as done, and are named). */
134export function tally(audit: Audit): { running: number; done: number; failed: number } {
135 const runs = Object.values(audit.agents)
136
137 return {
138 running: runs.filter(run => run.state === 'running').length,
139 done: runs.filter(run => run.state !== 'running').length,
140 failed: runs.filter(run => run.state === 'failed').length,
141 }
142}
143
144/** The band's one line, cut to `columns`. */
145export function bandText(audit: Audit, nowMs: number, columns: number, isEconomy: boolean): string {
146 const { running, done, failed } = tally(audit)
147 const parts = [
148 `seo audit ${audit.domain}`,
149 audit.finishMs !== null ? `done${audit.score === null ? '' : `, score ${audit.score}/100`}` : `${running} running, ${done} done${failed > 0 ? ` (${failed} failed)` : ''}`,
150 `findings ${audit.findings.length}`,
151 `spend ${usd(audit.spentUsd)}`,
152 elapsed((audit.finishMs ?? nowMs) - audit.startMs),
153 ...(isEconomy ? ['economy'] : []),
154 ]
155 const line = parts.join(' ')
156
157 return line.length <= columns ? line : `${line.slice(0, Math.max(0, columns - 1))}…`
158}
159
160/** The line shown under the answer once the audit's data file is written. */
161export function receiptText(audit: Audit, nowMs: number): string {
162 const { done, failed } = tally(audit)
163 const report = audit.dir === null ? `${audit.domain}-audit/` : `${audit.dir}/FULL-AUDIT-REPORT.md`
164 const weakest = [...audit.categories].sort((a, b) => a.score - b.score).slice(0, 2).map(row => `${row.name} ${row.score}`)
165
166 return [
167 `seo audit ${audit.domain}: ${audit.score === null ? 'no score' : `score ${audit.score}/100`}`,
168 weakest.length > 0 ? `weakest ${weakest.join(', ')}` : null,
169 `${count(done, 'agent')}${failed > 0 ? ` (${failed} failed)` : ''}`,
170 count(audit.findings.length, 'findings file'),
171 `spend ${usd(audit.spentUsd)}`,
172 elapsed(nowMs - audit.startMs),
173 report,
174 ]
175 .filter((part): part is string => part !== null)
176 .join(' | ')
177}
178
179/** What compaction is asked to keep while an audit runs. */
180export function compactInstructions(audit: Audit): string {
181 const done = Object.values(audit.agents).filter(run => run.state !== 'running').map(run => bareType(run.type))
182 const running = Object.values(audit.agents).filter(run => run.state === 'running').map(run => bareType(run.type))
183
184 return [
185 `A claude-seo audit of ${audit.domain} is in progress. Keep in the summary:`,
186 `- the output folder: ${audit.dir ?? `${audit.domain}-audit/`} (findings in findings/, then audit-data.json, FULL-AUDIT-REPORT.md, ACTION-PLAN.md)`,
187 `- agents finished: ${done.length > 0 ? done.join(', ') : 'none yet'}; still running: ${running.length > 0 ? running.join(', ') : 'none'}`,
188 `- findings files written: ${audit.findings.length > 0 ? audit.findings.join(', ') : 'none yet'}`,
189 '- the business type detected, the crawl scope, and any finding not yet written to a file',
190 ].join('\n')
191}
192hooks/lib/charts.ts 259 lines1/**
2 * Charts as data: text rows for the terminal, SVG strings for the desktop and
3 * VS Code surfaces, and a self-contained HTML page for export. Pure.
4 *
5 * SVG text and axes use `currentColor` so a chart reads on light and dark
6 * backgrounds; series and status colors are fixed mid-tones that hold on both.
7 */
8
9export type Series = { name: string; values: ReadonlyArray<number | null>; color: string }
10
11export type Chart =
12 | { kind: 'line'; title: string; series: readonly Series[]; xLabels: readonly [string, string]; invert?: boolean; unit?: string; bands?: { good: number; poor: number } }
13 | { kind: 'bars'; title: string; rows: ReadonlyArray<{ label: string; value: number; color: string; text?: string }>; max: number }
14 | { kind: 'grid'; title: string; ranks: ReadonlyArray<ReadonlyArray<number | null>> }
15
16export const COLORS = {
17 blue: '#3b82f6',
18 violet: '#8b5cf6',
19 good: '#16a34a',
20 warn: '#d97706',
21 poor: '#dc2626',
22 muted: '#8a8f98',
23} as const
24
25/** Green from 80, amber from 50, red below. */
26export const scoreColor = (score: number): string => (score >= 80 ? COLORS.good : score >= 50 ? COLORS.warn : COLORS.poor)
27
28/** Local-pack style: top 3 green, 4 to 10 amber, beyond red, absent grey. */
29export const rankColor = (rank: number | null): string => (rank === null ? COLORS.muted : rank <= 3 ? COLORS.good : rank <= 10 ? COLORS.warn : COLORS.poor)
30
31/** Good, needs improvement, or poor against a metric's two thresholds. */
32export const vitalColor = (value: number, good: number, poor: number): string => (value <= good ? COLORS.good : value <= poor ? COLORS.warn : COLORS.poor)
33
34const SPARK = '▁▂▃▄▅▆▇█'
35const escape = (text: string): string => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
36
37export const compact = (value: number): string =>
38 Math.abs(value) >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}M` : Math.abs(value) >= 10_000 ? `${Math.round(value / 1000)}k` : Math.abs(value) >= 1000 ? `${(value / 1000).toFixed(1)}k` : `${Math.round(value * 100) / 100}`
39
40/** Squeezes a series to at most `width` points by averaging buckets. */
41export function resample(values: ReadonlyArray<number | null>, width: number): Array<number | null> {
42 if (values.length <= width) {
43 return [...values]
44 }
45
46 return Array.from({ length: width }, (_, i) => {
47 const bucket = values.slice(Math.floor((i * values.length) / width), Math.floor(((i + 1) * values.length) / width)).filter((v): v is number => v !== null)
48
49 return bucket.length === 0 ? null : bucket.reduce((a, b) => a + b, 0) / bucket.length
50 })
51}
52
53/** One block character per value between the series' own min and max; gaps stay blank. */
54export function sparkRow(values: ReadonlyArray<number | null>, width: number, invert = false): string {
55 const points = resample(values, width)
56 const present = points.filter((v): v is number => v !== null)
57 const low = Math.min(...present)
58 const high = Math.max(...present)
59
60 return points
61 .map(value => {
62 if (value === null) {
63 return ' '
64 }
65
66 const share = high === low ? 0.5 : (value - low) / (high - low)
67
68 return SPARK[Math.round((invert ? 1 - share : share) * (SPARK.length - 1))] ?? ' '
69 })
70 .join('')
71}
72
73/** A horizontal bar of `width` cells for `value` out of `max`. */
74export function barRow(value: number, max: number, width: number): string {
75 const cells = max <= 0 ? 0 : Math.max(0, Math.min(width, Math.round((value / max) * width)))
76
77 return '█'.repeat(cells) + '░'.repeat(width - cells)
78}
79
80/** First and last present values, for "from ... to ..." labels. */
81export function endsOf(values: ReadonlyArray<number | null>): { first: number | null; last: number | null } {
82 const present = values.filter((v): v is number => v !== null)
83
84 return { first: present[0] ?? null, last: present.at(-1) ?? null }
85}
86
87// ------------------------------------------------------------------- SVG
88
89const W = 560
90
91function lineSvg(chart: Extract<Chart, { kind: 'line' }>): string {
92 const height = 170
93 const pad = { left: 46, right: 12, top: 24, bottom: 26 }
94 const plotW = W - pad.left - pad.right
95 const plotH = height - pad.top - pad.bottom
96 const all = chart.series.flatMap(s => s.values.filter((v): v is number => v !== null))
97 const bandValues = chart.bands === undefined ? [] : [chart.bands.good, chart.bands.poor]
98 let low = Math.min(...all, ...bandValues)
99 let high = Math.max(...all, ...bandValues)
100
101 if (!Number.isFinite(low) || !Number.isFinite(high)) {
102 low = 0
103 high = 1
104 }
105
106 if (high === low) {
107 high = low + 1
108 }
109
110 const y = (v: number) => {
111 const share = (v - low) / (high - low)
112
113 return pad.top + (chart.invert === true ? share : 1 - share) * plotH
114 }
115
116 const longest = Math.max(1, ...chart.series.map(s => s.values.length))
117 const x = (i: number) => pad.left + (longest === 1 ? plotW / 2 : (i / (longest - 1)) * plotW)
118 const parts: string[] = []
119
120 if (chart.bands !== undefined) {
121 const { good, poor } = chart.bands
122
123 parts.push(
124 `<rect x="${pad.left}" y="${Math.min(y(good), y(low))}" width="${plotW}" height="${Math.abs(y(good) - y(low))}" fill="${COLORS.good}" opacity="0.10"/>`,
125 `<rect x="${pad.left}" y="${Math.min(y(poor), y(good))}" width="${plotW}" height="${Math.abs(y(poor) - y(good))}" fill="${COLORS.warn}" opacity="0.10"/>`,
126 `<rect x="${pad.left}" y="${Math.min(y(high), y(poor))}" width="${plotW}" height="${Math.abs(y(high) - y(poor))}" fill="${COLORS.poor}" opacity="0.10"/>`,
127 // Labelled threshold lines, so the zones read without a legend.
128 `<line x1="${pad.left}" y1="${y(good)}" x2="${pad.left + plotW}" y2="${y(good)}" stroke="${COLORS.good}" stroke-dasharray="4 3"/>`,
129 `<text x="${pad.left + plotW - 4}" y="${y(good) - 4}" text-anchor="end" font-size="10" fill="${COLORS.good}">good ${escape(compact(good))}</text>`,
130 `<line x1="${pad.left}" y1="${y(poor)}" x2="${pad.left + plotW}" y2="${y(poor)}" stroke="${COLORS.poor}" stroke-dasharray="4 3"/>`,
131 `<text x="${pad.left + plotW - 4}" y="${y(poor) + 12}" text-anchor="end" font-size="10" fill="${COLORS.poor}">poor ${escape(compact(poor))}</text>`,
132 )
133 }
134
135 parts.push(
136 `<line x1="${pad.left}" y1="${pad.top + plotH}" x2="${pad.left + plotW}" y2="${pad.top + plotH}" stroke="currentColor" opacity="0.3"/>`,
137 `<text x="${pad.left - 6}" y="${y(high) + 4}" text-anchor="end" font-size="10" fill="currentColor" opacity="0.7">${escape(compact(high))}</text>`,
138 `<text x="${pad.left - 6}" y="${y(low) + 4}" text-anchor="end" font-size="10" fill="currentColor" opacity="0.7">${escape(compact(low))}</text>`,
139 `<text x="${pad.left}" y="${height - 8}" font-size="10" fill="currentColor" opacity="0.7">${escape(chart.xLabels[0])}</text>`,
140 `<text x="${pad.left + plotW}" y="${height - 8}" text-anchor="end" font-size="10" fill="currentColor" opacity="0.7">${escape(chart.xLabels[1])}</text>`,
141 )
142
143 chart.series.forEach((series, index) => {
144 const segments: string[] = []
145 let open = false
146
147 series.values.forEach((value, i) => {
148 if (value === null) {
149 open = false
150
151 return
152 }
153
154 segments.push(`${open ? 'L' : 'M'}${x(i).toFixed(1)},${y(value).toFixed(1)}`)
155 open = true
156 })
157 parts.push(`<path d="${segments.join(' ')}" fill="none" stroke="${series.color}" stroke-width="2" stroke-linejoin="round"/>`)
158 parts.push(`<text x="${pad.left + index * 150}" y="14" font-size="11" fill="${series.color}">● ${escape(series.name)}</text>`)
159 })
160
161 return svgDoc(height, chart.title, parts)
162}
163
164function barsSvg(chart: Extract<Chart, { kind: 'bars' }>): string {
165 const rowH = 22
166 const labelW = 170
167 const height = 12 + chart.rows.length * rowH
168 const plotW = W - labelW - 70
169 const parts = chart.rows.flatMap((row, i) => {
170 const y = 8 + i * rowH
171 const width = chart.max <= 0 ? 0 : Math.max(0, Math.min(plotW, (row.value / chart.max) * plotW))
172
173 return [
174 `<text x="${labelW - 8}" y="${y + 14}" text-anchor="end" font-size="11" fill="currentColor">${escape(row.label.length > 26 ? `${row.label.slice(0, 25)}…` : row.label)}</text>`,
175 `<rect x="${labelW}" y="${y + 3}" width="${plotW}" height="14" rx="3" fill="currentColor" opacity="0.08"/>`,
176 `<rect x="${labelW}" y="${y + 3}" width="${width.toFixed(1)}" height="14" rx="3" fill="${row.color}"/>`,
177 `<text x="${labelW + plotW + 8}" y="${y + 14}" font-size="11" fill="currentColor">${escape(row.text ?? compact(row.value))}</text>`,
178 ]
179 })
180
181 return svgDoc(height, chart.title, parts)
182}
183
184function gridSvg(chart: Extract<Chart, { kind: 'grid' }>): string {
185 const size = Math.max(1, chart.ranks.length)
186 const cell = Math.min(40, Math.floor(300 / size))
187 const height = size * cell + 40
188 const parts = chart.ranks.flatMap((row, r) =>
189 row.flatMap((rank, c) => [
190 `<rect x="${20 + c * cell}" y="${10 + r * cell}" width="${cell - 3}" height="${cell - 3}" rx="4" fill="${rankColor(rank)}"/>`,
191 `<text x="${20 + c * cell + (cell - 3) / 2}" y="${10 + r * cell + (cell - 3) / 2 + 4}" text-anchor="middle" font-size="11" fill="#ffffff">${rank === null ? '-' : rank > 20 ? '20+' : rank}</text>`,
192 ]),
193 )
194 const legendY = 10 + size * cell + 18
195
196 parts.push(
197 ...[
198 ['1-3', COLORS.good],
199 ['4-10', COLORS.warn],
200 ['11+', COLORS.poor],
201 ['not found', COLORS.muted],
202 ].map(([label, color], i) => `<rect x="${20 + i * 90}" y="${legendY - 9}" width="10" height="10" rx="2" fill="${color}"/><text x="${34 + i * 90}" y="${legendY}" font-size="11" fill="currentColor">${label}</text>`),
203 )
204
205 return svgDoc(height, chart.title, parts)
206}
207
208function svgDoc(height: number, title: string, parts: readonly string[]): string {
209 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" width="${W}" height="${height}" font-family="system-ui, sans-serif" role="img"><title>${escape(title)}</title>${parts.join('')}</svg>`
210}
211
212/** The chart as an SVG string (well under the 131,072-character limit for these sizes). */
213export function svgOf(chart: Chart): { source: string; height: number } {
214 const source = chart.kind === 'line' ? lineSvg(chart) : chart.kind === 'bars' ? barsSvg(chart) : gridSvg(chart)
215 const height = Number(/height="(\d+)"/.exec(source)?.[1] ?? 200)
216
217 return { source, height }
218}
219
220// ------------------------------------------------------------------- HTML
221
222export type Section = {
223 heading: string
224 source: string
225 kpis: ReadonlyArray<{ label: string; value: string }>
226 charts: readonly Chart[]
227 tables: ReadonlyArray<{ head: readonly string[]; rows: ReadonlyArray<readonly string[]> }>
228 notes: readonly string[]
229}
230
231/** A self-contained page (inline CSS and SVG, no scripts), readable in light and dark. */
232export function htmlPage(title: string, generated: string, sections: readonly Section[]): string {
233 const body = sections
234 .map(
235 section => `<section><h2>${escape(section.heading)}</h2><p class="src">${escape(section.source)}</p>
236${section.kpis.length > 0 ? `<div class="kpis">${section.kpis.map(k => `<div class="kpi"><span>${escape(k.label)}</span><b>${escape(k.value)}</b></div>`).join('')}</div>` : ''}
237${section.charts.map(chart => `<figure>${svgOf(chart).source}</figure>`).join('\n')}
238${section.tables.map(t => `<table><thead><tr>${t.head.map(h => `<th>${escape(h)}</th>`).join('')}</tr></thead><tbody>${t.rows.map(r => `<tr>${r.map(c => `<td>${escape(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`).join('\n')}
239${section.notes.map(n => `<p class="note">${escape(n)}</p>`).join('')}</section>`,
240 )
241 .join('\n')
242
243 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title>
244<style>
245:root{--bg:#f7f7f5;--fg:#1d1f23;--card:#ffffff;--line:#e3e3df;--muted:#6b7079}
246@media (prefers-color-scheme:dark){:root{--bg:#16181c;--fg:#e8e8e6;--card:#1f2228;--line:#30343b;--muted:#9aa0a8}}
247*{box-sizing:border-box}body{margin:0;padding:24px 16px;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
248main{max-width:760px;margin:0 auto}h1{font-size:24px;margin:0 0 4px}.gen{color:var(--muted);margin:0 0 24px}
249section{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin:0 0 16px}
250h2{font-size:18px;margin:0}.src{color:var(--muted);font-size:13px;margin:2px 0 12px}
251.kpis{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}.kpi{border:1px solid var(--line);border-radius:8px;padding:6px 10px}.kpi span{display:block;color:var(--muted);font-size:12px}.kpi b{font-size:17px}
252figure{margin:0 0 12px;color:var(--fg)}figure svg{max-width:100%;height:auto}
253table{width:100%;border-collapse:collapse;font-size:13px;margin:0 0 12px}th,td{text-align:left;padding:5px 6px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-weight:600}
254.note{color:var(--muted);font-size:13px;margin:4px 0}
255</style></head><body><main><h1>${escape(title)}</h1><p class="gen">${escape(generated)}</p>
256${body}
257</main></body></html>`
258}
259hooks/lib/format.ts 94 lines1/**
2 * Plain-text answers for the zero-token commands. Pure.
3 */
4
5import { usd } from './verdict'
6
7const SPARK = '▁▂▃▄▅▆▇█'
8
9/** One block character per value, scaled to the largest. */
10export function spark(values: readonly number[]): string {
11 const top = Math.max(0, ...values)
12
13 return values
14 .map(value => (top <= 0 || value <= 0 ? ' ' : SPARK[Math.min(SPARK.length - 1, Math.floor((value / top) * (SPARK.length - 1)))]))
15 .join('')
16}
17
18type Today = { date?: unknown; total_usd?: unknown; daily_limit_usd?: unknown; remaining_usd?: unknown; calls?: unknown; by_endpoint?: unknown }
19type Summary = { daily_totals?: unknown; grand_total_usd?: unknown; total_calls?: unknown; period_days?: unknown }
20
21const n = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
22
23/** The last `days` calendar days ending at `today` (YYYY-MM-DD), oldest first. */
24export function lastDays(today: string, days: number): string[] {
25 const end = new Date(`${today}T00:00:00Z`)
26
27 if (Number.isNaN(end.getTime())) {
28 return []
29 }
30
31 return Array.from({ length: days }, (_, i) => {
32 const day = new Date(end.getTime() - (days - 1 - i) * 86_400_000)
33
34 return day.toISOString().slice(0, 10)
35 })
36}
37
38/**
39 * The /seo-spend answer from `dataforseo_costs.py today` and `summary --days 30`.
40 */
41export function spendText(today: Today, summary: Summary): string {
42 const date = typeof today.date === 'string' ? today.date : ''
43 const totals = typeof summary.daily_totals === 'object' && summary.daily_totals !== null ? (summary.daily_totals as Record<string, { total_usd?: unknown; calls?: unknown }>) : {}
44 const days = lastDays(date, 30)
45 const series = days.map(day => n(totals[day]?.total_usd))
46 const sum = (values: readonly number[]) => values.reduce((a, b) => a + b, 0)
47 // The same 30 calendar days for the total, the call count and the spark row
48 // (the script's own window is "now minus 30 days", which reaches into day 31).
49 const monthCalls = sum(days.map(day => n(totals[day]?.calls)))
50 const allRows = typeof today.by_endpoint === 'object' && today.by_endpoint !== null ? Object.entries(today.by_endpoint as Record<string, { cost_usd?: unknown; calls?: unknown }>) : []
51 // A ledger reset leaves a $0 audit row; it is not a call.
52 const resets = n(allRows.find(([endpoint]) => endpoint === '_audit_reset')?.[1].calls)
53 const byEndpoint = allRows.filter(([endpoint]) => endpoint !== '_audit_reset')
54 const lines = [
55 `DataForSEO spend (claude-seo ledger)`,
56 ` today ${usd(n(today.total_usd))} of ${usd(n(today.daily_limit_usd))} cap, ${n(today.calls) - resets} calls, ${usd(n(today.remaining_usd))} left`,
57 ` 7 days ${usd(sum(series.slice(-7)))}`,
58 ` 30 days ${usd(days.length > 0 ? sum(series) : n(summary.grand_total_usd))}, ${days.length > 0 ? monthCalls : n(summary.total_calls)} calls`,
59 ]
60
61 if (days.length > 0) {
62 lines.push(` last 30 days |${spark(series)}| ${days[0]} to ${days.at(-1)}`)
63 }
64
65 if (byEndpoint.length > 0) {
66 lines.push(' today by endpoint:')
67
68 for (const [endpoint, row] of byEndpoint.sort((a, b) => n(b[1].cost_usd) - n(a[1].cost_usd)).slice(0, 8)) {
69 lines.push(` ${usd(n(row.cost_usd)).padStart(8)} ${n(row.calls)}x ${endpoint}`)
70 }
71 }
72
73 return lines.join('\n')
74}
75
76type Doctor = { ready?: unknown; mode?: unknown; plugin_version?: unknown; python_version?: unknown; browser_ready?: unknown; reasons?: unknown }
77
78/** The /seo-doctor answer from `claude-seo doctor --json`. */
79export function doctorText(doctor: Doctor, root: string): string {
80 const reasons = Array.isArray(doctor.reasons) ? doctor.reasons.filter((reason): reason is string => typeof reason === 'string') : []
81 const lines = [
82 `claude-seo ${typeof doctor.plugin_version === 'string' ? doctor.plugin_version : '?'} at ${root}`,
83 ` runtime ${doctor.ready === true ? 'ready' : 'setup required'} (${typeof doctor.mode === 'string' ? doctor.mode : '?'} mode, Python ${typeof doctor.python_version === 'string' ? doctor.python_version : '?'})`,
84 ` Chromium ${doctor.browser_ready === true ? 'ready' : 'not installed'}`,
85 ...reasons.map(reason => ` reason ${reason}`),
86 ]
87
88 if (doctor.ready !== true) {
89 lines.push(` fix run: "${root}/scripts/claude-seo" setup`)
90 }
91
92 return lines.join('\n')
93}
94hooks/lib/paid.ts 150 lines1/**
2 * Which tool calls spend money, and how each one is priced.
3 *
4 * Pure: no `$`. register.ts asks `classify` about every tool call it sees and
5 * only holds the ones it returns a PaidCall for.
6 */
7
8export type PaidCall =
9 /**
10 * Priced by claude-seo's DataForSEO cost table: an MCP tool, or a script
11 * that bills one or more endpoints (`compare` bills two).
12 */
13 | { kind: 'dataforseo'; endpoints: readonly string[]; label: string }
14 /** Anything else that bills: no cost table, so the person decides. */
15 | { kind: 'ask'; label: string; allowKey: string }
16
17/** MCP servers whose calls bill a paid account. Matched on the server segment of `mcp__<server>__<tool>`. */
18const PAID_MCP_SERVERS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
19 { pattern: /ahrefs/i, label: 'Ahrefs' },
20 { pattern: /se-?ranking/i, label: 'SE Ranking' },
21 { pattern: /firecrawl/i, label: 'Firecrawl' },
22 { pattern: /profound/i, label: 'Profound' },
23 { pattern: /banana/i, label: 'Gemini image generation' },
24]
25
26/**
27 * DataForSEO lookups that list reference data and cost nothing: locations,
28 * languages, filters, categories, model lists.
29 */
30const FREE_DATAFORSEO = /(?:_locations|_languages|_filters|_loc_and_lang|_categories|llm_models)$/
31
32/** claude-seo scripts that bill, but have no cost-table row: the person decides. */
33const PAID_SCRIPTS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
34 { pattern: /\bmoz_api\.py\b/, label: 'Moz API' },
35 { pattern: /\bkeywordseverywhere_api\.py\b/, label: 'Keywords Everywhere' },
36 { pattern: /\bnlp_analyze\.py\b/, label: 'Google Cloud Natural Language (billed past the free tier)' },
37 { pattern: /\bindexing_notify\.py\b/, label: 'Google Indexing API (uses daily quota)' },
38 // Either the launcher form (`run --extension banana generate.py`) or a direct path, either slash.
39 { pattern: /--extension\s+banana\s+(?:generate|batch|edit)\.py\b|\bbanana[\\/]scripts[\\/](?:generate|batch|edit)\.py\b/, label: 'Gemini image generation' },
40]
41
42/** Paid APIs Claude reaches with curl or WebFetch, keyed by host (SE Ranking and Profound ship no MCP server). */
43const PAID_HOSTS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
44 { pattern: /\bapi\d?\.seranking\.com\b/i, label: 'SE Ranking API' },
45 { pattern: /\bapi\.tryprofound\.com\b/i, label: 'Profound API' },
46 { pattern: /\bapi\.dataforseo\.com\b/i, label: 'DataForSEO API (direct, not priced)' },
47]
48
49/** Splits `mcp__<server>__<tool>`; null for a tool that is not an MCP tool. */
50export function mcpParts(tool: string): { server: string; name: string } | null {
51 const match = /^mcp__(.+?)__(.+)$/.exec(tool)
52
53 if (match === null || match[1] === undefined || match[2] === undefined) {
54 return null
55 }
56
57 return { server: match[1], name: match[2] }
58}
59
60/** The single commands of a shell line: split on `&&`, `||`, `;`, `|` and newlines. */
61export function segmentsOf(command: string): string[] {
62 return command.split(/&&|\|\||[;|\n]/).map(part => part.trim()).filter(part => part !== '')
63}
64
65/**
66 * The DataForSEO endpoints a `dataforseo_merchant.py` run bills, from its
67 * subcommand and `--marketplace`: `search` bills one (Google unless
68 * `--marketplace amazon`), `sellers` one, `compare` Google then Amazon.
69 */
70export function merchantEndpoints(segment: string): string[] {
71 const sub = /dataforseo_merchant\.py\s+(\w+)/.exec(segment)?.[1]
72
73 if (sub === 'sellers') {
74 return ['merchant_google_sellers_search']
75 }
76
77 if (sub === 'compare') {
78 return ['merchant_google_products_search', 'merchant_amazon_products_search']
79 }
80
81 return /--marketplace[=\s]+["']?amazon\b/i.test(segment) ? ['merchant_amazon_products_search'] : ['merchant_google_products_search']
82}
83
84function classifySegment(segment: string): PaidCall | null {
85 if (/\bdataforseo_merchant\.py\b/.test(segment)) {
86 const endpoints = merchantEndpoints(segment)
87
88 return { kind: 'dataforseo', endpoints, label: `DataForSEO Merchant (${endpoints.join(' + ')})` }
89 }
90
91 const script = PAID_SCRIPTS.find(entry => entry.pattern.test(segment))
92
93 if (script !== undefined) {
94 return { kind: 'ask', label: script.label, allowKey: `script:${script.label}` }
95 }
96
97 const host = PAID_HOSTS.find(entry => entry.pattern.test(segment))
98
99 return host === undefined ? null : { kind: 'ask', label: host.label, allowKey: `host:${host.label}` }
100}
101
102/** Merges the paid parts of one shell line: every DataForSEO endpoint, else the first ask. */
103function merge(calls: readonly PaidCall[]): PaidCall | null {
104 const priced = calls.flatMap(call => (call.kind === 'dataforseo' ? call.endpoints : []))
105 const ask = calls.find(call => call.kind === 'ask')
106
107 // A line that mixes a priced endpoint and an unpriced paid script asks: the person sees the whole line.
108 if (ask !== undefined) {
109 return priced.length === 0 ? ask : { kind: 'ask', label: `${ask.label} and DataForSEO`, allowKey: `line:${ask.label}+dataforseo` }
110 }
111
112 return priced.length === 0 ? null : { kind: 'dataforseo', endpoints: priced, label: calls.map(call => call.label).join(', ') }
113}
114
115/**
116 * The paid call a tool call is, or null when it costs nothing.
117 *
118 * A shell line is classified one command at a time, so a cost check chained
119 * in front of a paid script (`check x && run merchant.py`) never hides it.
120 *
121 * @param tool the tool's name as `tool.call` reports it
122 * @param command the shell command, for Bash and PowerShell
123 * @param url the URL, for WebFetch
124 */
125export function classify(tool: string, command: string | undefined, url?: string): PaidCall | null {
126 const mcp = mcpParts(tool)
127
128 if (mcp !== null) {
129 if (/dataforseo/i.test(mcp.server)) {
130 return FREE_DATAFORSEO.test(mcp.name) ? null : { kind: 'dataforseo', endpoints: [mcp.name], label: `DataForSEO ${mcp.name}` }
131 }
132
133 const paid = PAID_MCP_SERVERS.find(entry => entry.pattern.test(mcp.server))
134
135 return paid === undefined ? null : { kind: 'ask', label: `${paid.label} (${mcp.name})`, allowKey: `mcp:${mcp.server}` }
136 }
137
138 if (tool === 'WebFetch' && url !== undefined) {
139 const host = PAID_HOSTS.find(entry => entry.pattern.test(url))
140
141 return host === undefined ? null : { kind: 'ask', label: host.label, allowKey: `host:${host.label}` }
142 }
143
144 if ((tool !== 'Bash' && tool !== 'PowerShell') || command === undefined) {
145 return null
146 }
147
148 return merge(segmentsOf(command).map(classifySegment).filter((call): call is PaidCall => call !== null))
149}
150hooks/lib/root.ts 99 lines1/**
2 * Finding claude-seo from seo-cockpit's own folder. Pure string work; the
3 * caller checks each candidate on disk.
4 *
5 * Three layouts:
6 * - a setting the person gave (`claudeSeoRoot`);
7 * - a checkout: seo-cockpit is `<claude-seo>/plugins/seo-cockpit`;
8 * - an install: both live in the plugin cache as
9 * `<cache>/<marketplace>/seo-cockpit/<version>` and
10 * `<cache>/<marketplace>/claude-seo/<version>`.
11 */
12
13const trimSlash = (path: string): string => path.replace(/[\\/]+$/, '')
14
15/** The parent folder, or the path itself at a root. */
16export function parentOf(path: string): string {
17 const clean = trimSlash(path)
18 const cut = Math.max(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
19
20 return cut <= 0 ? clean : clean.slice(0, cut)
21}
22
23export const joinPath = (...parts: string[]): string => parts.map((part, i) => (i === 0 ? trimSlash(part) : part.replace(/^[\\/]+|[\\/]+$/g, ''))).join('/')
24
25/** The file whose presence marks a claude-seo folder. */
26export const MARKER = 'scripts/dataforseo_costs.py'
27
28/**
29 * Folders that may hold claude-seo, most specific first. The cache folders
30 * are returned for the caller to list: `cacheDir` is claude-seo under this
31 * plugin's own marketplace, `cacheRoot` holds every marketplace (claude-seo
32 * may come from another one, such as a community mirror).
33 */
34export function candidatesOf(pluginRoot: string, setting: string): { fixed: string[]; cacheDir: string; cacheRoot: string } {
35 const fixed: string[] = []
36
37 if (setting.trim() !== '') {
38 fixed.push(trimSlash(setting.trim()))
39 }
40
41 // Checkout: <claude-seo>/plugins/seo-cockpit
42 const grandparent = parentOf(parentOf(pluginRoot))
43
44 fixed.push(grandparent)
45
46 // Install: <cache>/<marketplace>/seo-cockpit/<version> -> <cache>/<marketplace>/claude-seo
47 return { fixed, cacheDir: joinPath(grandparent, 'claude-seo'), cacheRoot: parentOf(grandparent) }
48}
49
50/** `[major, minor, patch, ...]` and whether a pre-release tag follows (`2.5.0-rc1`). */
51function partsOf(version: string): { numbers: number[]; isPrerelease: boolean } {
52 const [core = '', ...rest] = version.split(/[-+]/)
53
54 return { numbers: core.split('.').map(part => Number.parseInt(part, 10) || 0), isPrerelease: rest.length > 0 && version.includes('-') }
55}
56
57/** Compares dotted versions numerically (`2.10.0` after `2.9.1`); a release ranks above its pre-releases. */
58function compareVersions(a: string, b: string): number {
59 const pa = partsOf(a)
60 const pb = partsOf(b)
61
62 for (let i = 0; i < Math.max(pa.numbers.length, pb.numbers.length); i++) {
63 const diff = (pa.numbers[i] ?? 0) - (pb.numbers[i] ?? 0)
64
65 if (diff !== 0) {
66 return diff
67 }
68 }
69
70 return pa.isPrerelease === pb.isPrerelease ? a.localeCompare(b) : pa.isPrerelease ? -1 : 1
71}
72
73/** The highest version folder name, or null when none looks like a version. */
74export function latestVersion(names: readonly string[]): string | null {
75 const versions = names.filter(name => /^\d+(?:\.\d+)*(?:[-+].*)?$/.test(name))
76
77 return versions.length === 0 ? null : [...versions].sort(compareVersions).at(-1) ?? null
78}
79
80/**
81 * The environment claude-seo's runtime.py must see, so it finds the Python
82 * environment claude-seo set up, not one keyed to whichever plugin is calling.
83 *
84 * runtime.py picks its folder from CLAUDE_PLUGIN_DATA, which Claude Code sets
85 * per plugin; inherited from this mod's process it names the wrong plugin.
86 * - An install (`<home>/.claude/plugins/cache/<mkt>/claude-seo/<ver>`): point
87 * it at claude-seo's own data folder, `<home>/.claude/plugins/data/claude-seo-<mkt>`,
88 * where `/seo setup` puts the environment.
89 * - A checkout or a custom folder: clear both variables, so runtime.py uses
90 * its own standalone rule (the checkout's `.venv`).
91 */
92export function runtimeEnvOf(seoRoot: string): Record<string, string> {
93 const match = /^(.*)[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]claude-seo[\\/][^\\/]+[\\/]?$/.exec(seoRoot)
94
95 return match === null || match[1] === undefined || match[2] === undefined
96 ? { CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' }
97 : { CLAUDE_PLUGIN_DATA: `${match[1]}/plugins/data/claude-seo-${match[2]}`, CLAUDE_PLUGIN_ROOT: seoRoot }
98}
99hooks/lib/overview.ts 164 lines1/**
2 * The cockpit's Overview: which site it is about, and one line per source.
3 * Pure. Built from the tab models, so the Overview and the detail views never
4 * disagree.
5 */
6
7import type { TabId, TabModel } from './tabs'
8
9/** The Overview's rows, in order of what a person checks first. */
10export const ROWS: ReadonlyArray<{ id: TabId; label: string; key: string }> = [
11 { id: 'audit', label: 'Audit', key: '1' },
12 { id: 'vitals', label: 'Vitals', key: '2' },
13 { id: 'gsc', label: 'Search', key: '3' },
14 { id: 'rankings', label: 'Rankings', key: '4' },
15 { id: 'maps', label: 'Maps', key: '5' },
16 { id: 'spend', label: 'Spend', key: '6' },
17]
18
19export type RowState = 'loading' | 'ok' | 'missing' | 'error'
20
21export type Row = { id: TabId; label: string; key: string; state: RowState; line: string }
22
23/** The host of a URL, `sc-domain:` property or bare domain; null when there is none. */
24export function hostOf(value: string): string | null {
25 const text = value.trim().replace(/^sc-domain:/i, '')
26
27 if (text === '') {
28 return null
29 }
30
31 try {
32 const host = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`).hostname.toLowerCase()
33
34 return host.includes('.') ? host.replace(/^www\./, '') : null
35 } catch {
36 return null
37 }
38}
39
40/** The site an audit folder is about: `meta.site` in its audit-data.json, else the folder name. */
41export function siteOfAudit(folder: string, data: unknown): string | null {
42 const meta = (data as { meta?: { site?: unknown } } | null)?.meta
43 const fromData = typeof meta?.site === 'string' ? hostOf(meta.site) : null
44
45 return fromData ?? hostOf(folder.replace(/-audit$/, ''))
46}
47
48/**
49 * The target, most specific first: what the person chose for this folder
50 * (typed in the pane, or `/seo-cockpit <site>`), the site this folder's audit
51 * is about, then the default from /config. The caller falls back to the last
52 * site used when all three are empty.
53 */
54export function chooseTarget(setting: string, typed: string | null, inferred: string | null): { host: string | null; source: 'typed' | 'folder' | 'setting' | null } {
55 const chosen = typed === null ? null : hostOf(typed)
56
57 if (chosen !== null) {
58 return { host: chosen, source: 'typed' }
59 }
60
61 if (inferred !== null) {
62 return { host: inferred, source: 'folder' }
63 }
64
65 const fallback = hostOf(setting)
66
67 return fallback === null ? { host: null, source: null } : { host: fallback, source: 'setting' }
68}
69
70const kpi = (model: TabModel, label: string): string | undefined => model.kpis.find(k => k.label === label)?.value
71
72const short = (text: string, max = 60): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
73
74/** Turns a script's error into a short reason a person can act on. */
75function reasonOf(id: TabId, error: string): string {
76 if (/permission denied/i.test(error)) {
77 return 'no Search Console access'
78 }
79
80 if (/not set up|runtime is not ready/i.test(error)) {
81 return 'claude-seo runtime not set up: run /seo setup'
82 }
83
84 if (/credential|api key|google access|oauth/i.test(error)) {
85 return 'Google not connected: run /seo google setup'
86 }
87
88 if (/no saved geo-grid/i.test(error)) {
89 return 'no grid yet (/seo maps grid)'
90 }
91
92 if (/no audit found/i.test(error)) {
93 return 'no audit yet (/seo audit)'
94 }
95
96 if (/no url to measure/i.test(error)) {
97 return 'needs a site: type it above'
98 }
99
100 return short(error)
101}
102
103/** One Overview line for a source. */
104export function rowOf(id: TabId, model: TabModel | undefined, isLoading: boolean): Row {
105 const base = ROWS.find(row => row.id === id) ?? { id, label: id, key: '' }
106
107 if (model === undefined) {
108 return { ...base, state: isLoading ? 'loading' : 'missing', line: isLoading ? 'checking…' : 'not loaded' }
109 }
110
111 if (model.error !== null) {
112 const isMissing = /no audit found|no saved geo-grid|no url to measure|not connected|permission denied|not set up/i.test(model.error)
113
114 return { ...base, state: isMissing ? 'missing' : 'error', line: reasonOf(id, model.error) }
115 }
116
117 const parts: Array<string | undefined> = (() => {
118 switch (id) {
119 case 'audit': {
120 const bars = model.charts[0]
121 const weakest = bars?.kind === 'bars' ? bars.rows[0] : undefined
122
123 // "Schema / Structured Data" reads as "Schema" on one line.
124 const name = weakest?.label.split(/\s[/(]/)[0]
125
126 return [kpi(model, 'Health score'), weakest === undefined ? undefined : `weakest ${name} ${weakest.value}`]
127 }
128 case 'vitals': {
129 // The verdict first, so a narrow pane keeps it: "good · LCP 703ms · INP 48ms · CLS 0.00".
130 const values = model.kpis.map(k => `${k.label.replace(' p75', '')} ${k.value.replace(/ (good|needs work|poor)$/, '').replace(' ms', 'ms')}`)
131 const verdict = model.kpis.some(k => / poor$/.test(k.value)) ? 'poor' : model.kpis.some(k => / needs work$/.test(k.value)) ? 'needs work' : 'good'
132
133 return [verdict, ...values]
134 }
135 case 'gsc':
136 return [`clicks ${kpi(model, 'Clicks, 28 days') ?? '?'}`, `impr. ${kpi(model, 'Impressions, 28 days') ?? '?'}`]
137 case 'rankings':
138 return [`avg pos ${kpi(model, 'Avg position, now') ?? '?'}`, `was ${kpi(model, '90 days ago') ?? '?'}`, `top 3: ${kpi(model, 'Queries in top 3') ?? '0'}`]
139 case 'maps':
140 return [`SoLV ${kpi(model, 'Share of local voice') ?? '?'}`, `${kpi(model, 'Points in top 3') ?? '?'} in top 3`]
141 case 'spend':
142 return [`today ${(kpi(model, 'Today') ?? '?').split(' of ')[0]}`, `30 days ${kpi(model, '30 days') ?? '?'}`]
143 }
144 })()
145
146 return { ...base, state: 'ok', line: parts.filter((p): p is string => p !== undefined).join(' · ') }
147}
148
149/** The one-line status under the prompt, from what loaded; undefined when nothing did. */
150export function statusLine(host: string | null, rows: readonly Row[]): string | undefined {
151 const audit = rows.find(r => r.id === 'audit' && r.state === 'ok')
152 const vitals = rows.find(r => r.id === 'vitals' && r.state === 'ok')
153
154 if (host === null || (audit === undefined && vitals === undefined)) {
155 return undefined
156 }
157
158 const score = audit?.line.split(' · ')[0]
159 const cwv = vitals === undefined ? undefined : /^(poor|needs work)/.test(vitals.line) ? 'CWV needs work' : 'CWV good'
160
161 // The engine prefixes the plugin's name; the line itself starts with the site.
162 return [host, score === undefined ? undefined : `audit ${score}`, cwv].filter(Boolean).join(' · ')
163}
164hooks/lib/tabs.ts 334 lines1/**
2 * The cockpit's tabs as data: each builder turns one claude-seo script's JSON
3 * into KPIs, charts and tables. Pure. Every model names its source and when
4 * it was fetched, so no number on screen is unlabeled.
5 */
6
7import { COLORS, compact, endsOf, scoreColor, vitalColor, type Chart, type Section } from './charts'
8import { lastDays } from './format'
9import { usd } from './verdict'
10
11export type TabId = 'gsc' | 'rankings' | 'vitals' | 'audit' | 'maps' | 'spend'
12
13export const TABS: ReadonlyArray<{ id: TabId; key: string; label: string }> = [
14 { id: 'gsc', key: '1', label: 'Search Console' },
15 { id: 'rankings', key: '2', label: 'Rankings' },
16 { id: 'vitals', key: '3', label: 'Vitals' },
17 { id: 'audit', key: '4', label: 'Audit' },
18 { id: 'maps', key: '5', label: 'Maps' },
19 { id: 'spend', key: '6', label: 'Spend' },
20]
21
22export type TabModel = Section & { error: string | null; fetchedAt: string }
23
24type Json = Record<string, unknown>
25
26const obj = (value: unknown): Json => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : {})
27const arr = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
28const num = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)
29const str = (value: unknown): string => (typeof value === 'string' ? value : '')
30const sum = (values: ReadonlyArray<number | null>) => values.reduce<number>((a, b) => a + (b ?? 0), 0)
31const mean = (values: ReadonlyArray<number | null>) => {
32 const present = values.filter((v): v is number => v !== null)
33
34 return present.length === 0 ? null : present.reduce((a, b) => a + b, 0) / present.length
35}
36
37const pct = (now: number, before: number): string => (before === 0 ? '' : ` (${now >= before ? '+' : ''}${Math.round(((now - before) / before) * 100)}%)`)
38
39/** A model that says what is missing and how to get it. */
40export function emptyModel(heading: string, source: string, fetchedAt: string, error: string, notes: readonly string[] = []): TabModel {
41 return { heading, source, fetchedAt, error, kpis: [], charts: [], tables: [], notes }
42}
43
44type GscRow = { date: string; query: string; clicks: number; impressions: number; ctr: number; position: number }
45
46function gscRows(raw: unknown): GscRow[] {
47 return arr(obj(raw).rows).map(row => {
48 const r = obj(row)
49 const keys = arr(r.keys).map(str)
50
51 return {
52 date: str(r.date) || (keys[0] ?? ''),
53 query: str(r.query) || (keys[0] ?? ''),
54 clicks: num(r.clicks) ?? 0,
55 impressions: num(r.impressions) ?? 0,
56 ctr: num(r.ctr) ?? 0,
57 position: num(r.position) ?? 0,
58 }
59 })
60}
61
62/** The error a gsc_query.py result carries, or null. */
63export const gscError = (raw: unknown): string | null => (str(obj(raw).error) || null)
64
65/**
66 * Search Console: 90 days by date, plus the top queries of the last 28 days.
67 * Deltas compare the last 28 days with the 28 before them.
68 */
69export function gscModel(byDate: unknown, byQuery: unknown, property: string, fetchedAt: string): TabModel {
70 const heading = 'Search Console'
71 const error = gscError(byDate)
72
73 if (error !== null) {
74 return emptyModel(heading, `gsc_query.py, ${property || 'default property'}`, fetchedAt, error, ['Set up Google access with /seo google setup, then set "Search Console property" in /config.'])
75 }
76
77 const days = gscRows(byDate).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.date)).sort((a, b) => a.date.localeCompare(b.date))
78
79 if (days.length === 0) {
80 return emptyModel(heading, `gsc_query.py, ${property || 'default property'}`, fetchedAt, 'Search Console returned no rows for the last 90 days.')
81 }
82
83 const last28 = days.slice(-28)
84 const prev28 = days.slice(-56, -28)
85 const clicks = sum(last28.map(d => d.clicks))
86 const impressions = sum(last28.map(d => d.impressions))
87 const range = obj(obj(byDate).date_range)
88 const queries = gscRows(byQuery).slice(0, 10)
89
90 return {
91 heading,
92 source: `Google Search Console, ${str(obj(byDate).property) || property}, ${str(range.start) || days[0]?.date} to ${str(range.end) || days.at(-1)?.date} (data lags about 2 days)`,
93 fetchedAt,
94 error: null,
95 kpis: [
96 { label: 'Clicks, 28 days', value: `${compact(clicks)}${pct(clicks, sum(prev28.map(d => d.clicks)))}` },
97 { label: 'Impressions, 28 days', value: `${compact(impressions)}${pct(impressions, sum(prev28.map(d => d.impressions)))}` },
98 { label: 'CTR, 28 days', value: impressions === 0 ? 'n/a' : `${((clicks / impressions) * 100).toFixed(1)}%` },
99 { label: 'Avg position, 28 days', value: (mean(last28.map(d => d.position)) ?? 0).toFixed(1) },
100 ],
101 charts: [
102 { kind: 'line', title: 'Clicks per day', series: [{ name: 'Clicks', values: days.map(d => d.clicks), color: COLORS.blue }], xLabels: [days[0]?.date ?? '', days.at(-1)?.date ?? ''] },
103 { kind: 'line', title: 'Impressions per day', series: [{ name: 'Impressions', values: days.map(d => d.impressions), color: COLORS.violet }], xLabels: [days[0]?.date ?? '', days.at(-1)?.date ?? ''] },
104 ],
105 tables:
106 queries.length === 0
107 ? []
108 : [{ head: ['Top query, 28 days', 'Clicks', 'Impr.', 'CTR', 'Pos.'], rows: queries.map(q => [q.query, compact(q.clicks), compact(q.impressions), `${q.ctr}%`, q.position.toFixed(1)]) }],
109 notes: [],
110 }
111}
112
113/** Rankings: average position over time (lower is better), where the top queries rank, and drift checks. */
114export function rankingsModel(byDate: unknown, byQuery: unknown, drift: unknown, property: string, fetchedAt: string): TabModel {
115 const heading = 'Rankings'
116 const error = gscError(byDate)
117
118 if (error !== null) {
119 return emptyModel(heading, `gsc_query.py, ${property || 'default property'}`, fetchedAt, error, ['Rankings come from Search Console average position, so they need Google access too.'])
120 }
121
122 const days = gscRows(byDate).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.date)).sort((a, b) => a.date.localeCompare(b.date))
123 const queries = gscRows(byQuery)
124 const buckets = [
125 { label: 'Top 3', test: (p: number) => p <= 3, color: COLORS.good },
126 { label: '4 to 10', test: (p: number) => p > 3 && p <= 10, color: COLORS.warn },
127 { label: '11 to 20', test: (p: number) => p > 10 && p <= 20, color: COLORS.poor },
128 { label: '21+', test: (p: number) => p > 20, color: COLORS.muted },
129 ].map(b => ({ label: b.label, value: queries.filter(q => b.test(q.position)).length, color: b.color }))
130 const comparisons = arr(obj(drift).comparisons).map(obj).reverse()
131 const charts: Chart[] = []
132
133 if (days.length > 0) {
134 charts.push({ kind: 'line', title: 'Average position (lower is better)', series: [{ name: 'Avg position', values: days.map(d => d.position), color: COLORS.blue }], xLabels: [days[0]?.date ?? '', days.at(-1)?.date ?? ''], invert: true })
135 }
136
137 if (queries.length > 0) {
138 charts.push({ kind: 'bars', title: 'Top queries by position band', rows: buckets, max: Math.max(1, ...buckets.map(b => b.value)) })
139 }
140
141 if (comparisons.length > 1) {
142 charts.push({
143 kind: 'line',
144 title: 'Drift checks: issues found per comparison',
145 series: [
146 { name: 'Critical', values: comparisons.map(c => num(c.critical)), color: COLORS.poor },
147 { name: 'Warning', values: comparisons.map(c => num(c.warning)), color: COLORS.warn },
148 ],
149 xLabels: [str(comparisons[0]?.timestamp).slice(0, 10), str(comparisons.at(-1)?.timestamp).slice(0, 10)],
150 })
151 }
152
153 const striking = queries.filter(q => q.position > 3 && q.position <= 15 && q.impressions > 0).sort((a, b) => b.impressions - a.impressions).slice(0, 8)
154 const { first, last } = endsOf(days.map(d => d.position))
155
156 return {
157 heading,
158 source: `Google Search Console average position, ${property || str(obj(byDate).property) || 'default property'}; drift: claude-seo baselines`,
159 fetchedAt,
160 error: days.length === 0 && queries.length === 0 ? 'Search Console returned no rows.' : null,
161 kpis: [
162 { label: 'Avg position, now', value: last === null ? 'n/a' : last.toFixed(1) },
163 { label: '90 days ago', value: first === null ? 'n/a' : first.toFixed(1) },
164 { label: 'Queries in top 3', value: String(buckets[0]?.value ?? 0) },
165 { label: 'Queries tracked', value: String(queries.length) },
166 ],
167 charts,
168 tables: striking.length === 0 ? [] : [{ head: ['Striking distance (pos. 4 to 15)', 'Pos.', 'Impr.', 'Clicks'], rows: striking.map(q => [q.query, q.position.toFixed(1), compact(q.impressions), compact(q.clicks)]) }],
169 notes: comparisons.length > 1 ? [] : ['No drift history for this URL yet. Run /seo drift baseline <url> to start tracking on-page changes.'],
170 }
171}
172
173const VITALS: ReadonlyArray<{ key: string; label: string; unit: string; color: string }> = [
174 { key: 'largest_contentful_paint', label: 'LCP', unit: 'ms', color: COLORS.blue },
175 { key: 'interaction_to_next_paint', label: 'INP', unit: 'ms', color: COLORS.violet },
176 { key: 'cumulative_layout_shift', label: 'CLS', unit: '', color: COLORS.blue },
177]
178
179/** Core Web Vitals: CrUX p75 per collection period, against Google's good and poor thresholds. */
180export function vitalsModel(raw: unknown, target: string, fetchedAt: string): TabModel {
181 const heading = 'Core Web Vitals'
182 const data = obj(raw)
183 const error = str(data.error)
184
185 if (error !== '') {
186 return emptyModel(heading, `crux_history.py, ${target}`, fetchedAt, error, ['CrUX needs a Google API key and enough real-user traffic for the URL or origin.'])
187 }
188
189 const periods = arr(data.collection_periods).map(obj)
190 const metrics = obj(data.metrics)
191 const xLabels: [string, string] = [str(periods[0]?.last), str(periods.at(-1)?.last)]
192 const charts: Chart[] = []
193 const kpis: Array<{ label: string; value: string }> = []
194
195 for (const vital of VITALS) {
196 const metric = obj(metrics[vital.key])
197 const values = arr(metric.p75_values).map(num)
198 const good = num(metric.good_threshold)
199 const poor = num(metric.poor_threshold)
200 const { last } = endsOf(values)
201
202 if (values.length === 0 || good === null || poor === null) {
203 continue
204 }
205
206 const shown = (v: number) => (vital.unit === 'ms' ? `${Math.round(v)} ms` : v.toFixed(2))
207 const verdict = last === null ? '' : last <= good ? ' good' : last <= poor ? ' needs work' : ' poor'
208
209 kpis.push({ label: `${vital.label} p75`, value: last === null ? 'n/a' : `${shown(last)}${verdict}` })
210 charts.push({ kind: 'line', title: `${vital.label} p75 (good ≤ ${shown(good)}, poor > ${shown(poor)})`, series: [{ name: vital.label, values, color: last === null ? vital.color : vitalColor(last, good, poor) }], xLabels, bands: { good, poor }, unit: vital.unit })
211 }
212
213 return {
214 heading,
215 source: `Chrome UX Report history, ${str(data.target) || target}, ${str(data.form_factor) || 'ALL'} devices, ${periods.length} weekly periods`,
216 fetchedAt,
217 error: charts.length === 0 ? 'CrUX has no Core Web Vitals history for this target.' : null,
218 kpis,
219 charts,
220 tables: [],
221 notes: ['p75 is the value 75% of real visits beat. Thresholds: LCP 2.5 s, INP 200 ms, CLS 0.1.'],
222 }
223}
224
225/** The audit scorecard from audit-data.json. */
226export function auditModel(raw: unknown, path: string, fetchedAt: string): TabModel {
227 const heading = 'Audit scorecard'
228 const data = obj(raw)
229 const summary = obj(data.summary)
230 const score = num(summary.health_score)
231 const categories = arr(data.categories)
232 .map(obj)
233 .flatMap(c => {
234 const value = num(c.score)
235
236 return value === null ? [] : [{ label: str(c.name), value, color: scoreColor(value), text: `${value}/100` }]
237 })
238
239 if (score === null && categories.length === 0) {
240 return emptyModel(heading, path || 'audit-data.json', fetchedAt, 'No audit found in this folder.', ['Run /seo audit <url>; its audit-data.json appears here.'])
241 }
242
243 const severity = (finding: Json) => str(finding.severity)
244 const findings = arr(data.categories)
245 .map(obj)
246 .flatMap(c => arr(c.findings).map(obj).map(f => ({ category: str(c.name), title: str(f.title), severity: severity(f) })))
247 .filter(f => f.severity === 'Critical' || f.severity === 'High')
248 .slice(0, 8)
249 const wins = arr(summary.quick_wins).map(w => (typeof w === 'string' ? w : str(obj(w).title))).filter(Boolean).slice(0, 5)
250
251 return {
252 heading,
253 source: `${path}${str(summary.business_type) ? `, business type: ${str(summary.business_type)}` : ''}`,
254 fetchedAt,
255 error: null,
256 kpis: [
257 { label: 'Health score', value: score === null ? 'n/a' : `${score}/100` },
258 { label: 'Categories', value: String(categories.length) },
259 { label: 'Critical and high findings', value: String(findings.length) },
260 ],
261 charts: categories.length === 0 ? [] : [{ kind: 'bars', title: 'Score by category', rows: [...categories].sort((a, b) => a.value - b.value), max: 100 }],
262 tables: findings.length === 0 ? [] : [{ head: ['Severity', 'Category', 'Finding'], rows: findings.map(f => [f.severity, f.category, f.title]) }],
263 notes: wins.map(w => `Quick win: ${w}`),
264 }
265}
266
267/** The Maps geo-grid from a saved geo-grid-*.json (skills/seo-maps, step 8). */
268export function mapsModel(raw: unknown, path: string, fetchedAt: string): TabModel {
269 const heading = 'Maps geo-grid'
270 const data = obj(raw)
271 const ranks = arr(data.ranks).map(row => arr(row).map(cell => (typeof cell === 'number' && Number.isFinite(cell) ? cell : null)))
272
273 if (ranks.length === 0) {
274 return emptyModel(heading, path || 'geo-grid-*.json', fetchedAt, 'No saved geo-grid in this folder.', ['Run /seo maps grid <keyword> <location> (uses DataForSEO credits); the grid is saved as <business>-maps/geo-grid-<keyword>-<date>.json.'])
275 }
276
277 const cells = ranks.flat()
278 const found = cells.filter((c): c is number => c !== null)
279 const top3 = found.filter(c => c <= 3).length
280 const solv = num(data.solv) ?? (cells.length === 0 ? 0 : Math.round((top3 / cells.length) * 100))
281
282 return {
283 heading,
284 source: `${path}: "${str(data.keyword)}" near ${str(data.location) || 'the business'}, ${str(data.date)}, ${ranks.length}x${ranks[0]?.length ?? 0} grid${num(data.radius_km) === null ? '' : `, ${num(data.radius_km)} km radius`}`,
285 fetchedAt,
286 error: null,
287 kpis: [
288 { label: 'Share of local voice', value: `${solv}%` },
289 { label: 'Points in top 3', value: `${top3} of ${cells.length}` },
290 { label: 'Average rank where found', value: found.length === 0 ? 'n/a' : (found.reduce((a, b) => a + b, 0) / found.length).toFixed(1) },
291 { label: 'Not found', value: String(cells.length - found.length) },
292 ],
293 charts: [{ kind: 'grid', title: `Rank by grid point: ${str(data.keyword)}`, ranks }],
294 tables: [],
295 notes: [`${str(data.business) || 'The business'}; north is up.`],
296 }
297}
298
299/** Spend: DataForSEO per day for 30 days, and today by endpoint, from the claude-seo ledger. */
300export function spendModel(today: unknown, summary: unknown, fetchedAt: string): TabModel {
301 const t = obj(today)
302 const totals = obj(obj(summary).daily_totals)
303 const days = lastDays(str(t.date), 30)
304 const series = days.map(day => num(obj(totals[day]).total_usd) ?? 0)
305 const byEndpoint = Object.entries(obj(t.by_endpoint))
306 .filter(([endpoint]) => endpoint !== '_audit_reset')
307 .map(([endpoint, row]) => ({ label: endpoint, value: num(obj(row).cost_usd) ?? 0 }))
308 .sort((a, b) => b.value - a.value)
309 .slice(0, 8)
310 const limit = num(t.daily_limit_usd) ?? 0
311 const spent = num(t.total_usd) ?? 0
312
313 return {
314 heading: 'Spend',
315 source: `claude-seo DataForSEO ledger, ${days[0] ?? ''} to ${days.at(-1) ?? ''}`,
316 fetchedAt,
317 error: null,
318 kpis: [
319 { label: 'Today', value: `${usd(spent)} of ${usd(limit)}` },
320 { label: '7 days', value: usd(series.slice(-7).reduce((a, b) => a + b, 0)) },
321 { label: '30 days', value: usd(series.reduce((a, b) => a + b, 0)) },
322 ],
323 charts: [
324 { kind: 'line', title: 'DataForSEO spend per day (USD)', series: [{ name: 'Spend', values: series, color: COLORS.blue }], xLabels: [days[0] ?? '', days.at(-1) ?? ''] },
325 ...(byEndpoint.length === 0
326 ? []
327 : [{ kind: 'bars' as const, title: 'Today by endpoint', rows: byEndpoint.map(row => ({ ...row, color: limit > 0 && spent / limit > 0.8 ? COLORS.warn : COLORS.blue, text: usd(row.value) })), max: Math.max(...byEndpoint.map(row => row.value)) }]),
328 ],
329 tables: [],
330 notes: ['Image generation and other providers keep their own ledgers and are not shown here.'],
331 }
332}
333
334hooks/lib/verdict.ts 103 lines1/**
2 * Reading `dataforseo_costs.py check` output into a decision the guard acts on.
3 *
4 * Pure. Anything that does not parse as a known verdict becomes `error`, and
5 * the guard holds the call: a ledger that cannot be read never waves spend through.
6 */
7
8export type Verdict =
9 | { decision: 'approved'; costUsd: number; todayUsd: number; remainingUsd: number }
10 | { decision: 'needs_approval'; costUsd: number; todayUsd: number; remainingUsd: number | null; reason: string; message: string }
11 | { decision: 'blocked'; message: string }
12 | { decision: 'error'; message: string }
13
14const num = (value: unknown, fallback: number): number =>
15 typeof value === 'number' && Number.isFinite(value) ? value : fallback
16
17/**
18 * @param exitCode the script's exit status
19 * @param stdout what it printed
20 */
21export function parseCheck(exitCode: number, stdout: string): Verdict {
22 if (exitCode !== 0) {
23 // A ledger error exits 1 with its reason as JSON on stdout.
24 const reason = /"message"\s*:\s*"([^"]*)"/.exec(stdout)?.[1]
25
26 return { decision: 'error', message: `cost check exited ${exitCode}${reason ? ` (${reason})` : ''}` }
27 }
28
29 let data: Record<string, unknown>
30
31 try {
32 const parsed: unknown = JSON.parse(stdout)
33
34 if (typeof parsed !== 'object' || parsed === null) {
35 return { decision: 'error', message: 'cost check printed no JSON object' }
36 }
37
38 data = parsed as Record<string, unknown>
39 } catch {
40 return { decision: 'error', message: 'cost check printed no JSON' }
41 }
42
43 const message = typeof data.message === 'string' ? data.message : ''
44
45 switch (data.status) {
46 case 'approved':
47 return {
48 decision: 'approved',
49 costUsd: num(data.total_cost_usd, 0),
50 todayUsd: num(data.today_spend_usd, 0),
51 remainingUsd: num(data.daily_remaining_usd, 0),
52 }
53 case 'needs_approval':
54 return {
55 decision: 'needs_approval',
56 // An unknown endpoint reports only an estimate.
57 costUsd: num(data.total_cost_usd, num(data.estimated_cost_usd, 0)),
58 todayUsd: num(data.today_spend_usd, 0),
59 remainingUsd: typeof data.daily_remaining_usd === 'number' ? data.daily_remaining_usd : null,
60 reason: typeof data.approval_reason === 'string' ? data.approval_reason : 'needs_approval',
61 message,
62 }
63 case 'blocked':
64 return { decision: 'blocked', message: message || 'daily DataForSEO limit reached' }
65 default:
66 return { decision: 'error', message: `cost check returned an unknown status` }
67 }
68}
69
70export const usd = (value: number): string => `$${value.toFixed(value !== 0 && value < 0.1 ? 3 : 2)}`
71
72/** One decision for a call that bills several endpoints: the strictest verdict wins, costs add up. */
73export function combine(verdicts: readonly Verdict[]): Verdict {
74 const first = verdicts.find(v => v.decision === 'error') ?? verdicts.find(v => v.decision === 'blocked')
75
76 if (first !== undefined || verdicts.length === 0) {
77 return first ?? { decision: 'error', message: 'no cost check ran' }
78 }
79
80 const priced = verdicts as ReadonlyArray<Extract<Verdict, { costUsd: number }>>
81 const costUsd = priced.reduce((sum, v) => sum + v.costUsd, 0)
82 const todayUsd = Math.max(...priced.map(v => v.todayUsd))
83 const asks = priced.filter((v): v is Extract<Verdict, { decision: 'needs_approval' }> => v.decision === 'needs_approval')
84
85 if (asks.length === 0) {
86 return { decision: 'approved', costUsd, todayUsd, remainingUsd: Math.min(...priced.map(v => (v.decision === 'approved' ? v.remainingUsd : Infinity))) }
87 }
88
89 const remaining = priced.map(v => v.remainingUsd).filter((r): r is number => r !== null)
90
91 return {
92 decision: 'needs_approval',
93 costUsd,
94 todayUsd,
95 remainingUsd: remaining.length === 0 ? null : Math.min(...remaining),
96 reason: [...new Set(asks.map(v => v.reason))].join(', '),
97 message: asks.map(v => v.message).join(' '),
98 }
99}
100
101/** True when the cost table had no price for the endpoint, so the figure is a guess and is not logged. */
102export const isUnpriced = (verdict: Verdict): boolean => verdict.decision === 'needs_approval' && verdict.reason.split(', ').includes('unknown_endpoint')
103hooks/views/pane.ts 198 lines1/**
2 * The cockpit pane as an element tree. Pure: register.ts hands in the
3 * surface's element constructors and the actions; nothing here touches `$`.
4 *
5 * One Overview (a line per source, in the order a person checks them) and one
6 * detail view per source. Sized to the pane's own width: an inline pane in a
7 * narrow terminal gets the same content in fewer columns, never a wrapped row
8 * of controls. The first row holds no controls, leaving the engine's close
9 * mark at the right edge clear.
10 *
11 * Charts are text (block characters and colored bars) everywhere; where the
12 * surface draws `Svg` (desktop, and VS Code per the 2.1.288 typings), the
13 * detail view draws them as SVG instead.
14 */
15
16import type { Elements, RenderElement } from 'claude-code'
17
18import { barRow, endsOf, rankColor, sparkRow, svgOf, COLORS, type Chart } from '../lib/charts'
19import { rowOf, ROWS, type Row } from '../lib/overview'
20import type { TabId, TabModel } from '../lib/tabs'
21
22export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> &
23 Partial<Pick<Elements['terminal'], 'Markdown' | 'Input'>> & { Svg?: Elements['desktop']['Svg'] }
24
25export type PaneState = {
26 /** The Overview, or one source's detail. */
27 view: 'overview' | TabId
28 models: Partial<Record<TabId, TabModel>>
29 loading: ReadonlySet<TabId>
30 /** The site the cockpit is about, and where that came from. */
31 host: string | null
32 hostSource: 'setting' | 'typed' | 'folder' | 'last' | null
33 /** The last HTML export, as a file path. */
34 exported: string | null
35}
36
37export type PaneActions = {
38 open: (tab: TabId) => void
39 back: () => void
40 refresh: () => void
41 exportHtml: () => void
42 setTarget: (value: string) => void
43}
44
45const cut = (text: string, width: number): string => (text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`)
46const pad = (text: string, width: number): string => cut(text, width).padEnd(width)
47
48function textChart(kit: Kit, chart: Chart, columns: number): RenderElement[] {
49 const { Box, Text } = kit
50 const title = Text({ bold: true, children: [cut(chart.title, columns)] })
51
52 if (chart.kind === 'line') {
53 const width = Math.max(10, Math.min(90, columns - 30))
54 const rows = chart.series.map(series => {
55 const { first, last } = endsOf(series.values)
56 const ends = first === null || last === null ? 'no data' : `${Math.round(first * 100) / 100} to ${Math.round(last * 100) / 100}`
57
58 return Box({ flexDirection: 'row', columnGap: 1, children: [Text({ color: series.color, children: [sparkRow(series.values, width, chart.invert === true)] }), Text({ dimColor: true, children: [cut(`${series.name} ${ends}`, 28)] })] })
59 })
60
61 return [title, ...rows, Text({ dimColor: true, children: [cut(`${chart.xLabels[0]}${' '.repeat(Math.max(1, width - chart.xLabels[0].length - chart.xLabels[1].length))}${chart.xLabels[1]}`, columns)] })]
62 }
63
64 if (chart.kind === 'bars') {
65 const labelW = Math.min(28, Math.max(8, ...chart.rows.map(row => row.label.length)))
66 const barW = Math.max(8, Math.min(40, columns - labelW - 14))
67
68 return [
69 title,
70 ...chart.rows.map(row =>
71 Box({ flexDirection: 'row', columnGap: 1, children: [Text({ children: [pad(row.label, labelW)] }), Text({ color: row.color, children: [barRow(row.value, chart.max, barW)] }), Text({ children: [row.text ?? String(row.value)] })] }),
72 ),
73 ]
74 }
75
76 return [
77 title,
78 ...chart.ranks.map((row, r) =>
79 Box({ key: `grid-${r}`, flexDirection: 'row', children: row.map(rank => Text({ color: rankColor(rank), children: [rank === null ? ' -- ' : ` ${String(Math.min(rank, 99)).padStart(2)} `] })) }),
80 ),
81 Text({ dimColor: true, children: ['1-3 green, 4-10 amber, 11+ red, -- not found; north is up'] }),
82 ]
83}
84
85function chartView(kit: Kit, chart: Chart, columns: number): RenderElement[] {
86 if (kit.Svg !== undefined) {
87 const { source, height } = svgOf(chart)
88 const width = Math.min(560, Math.max(280, columns * 7))
89
90 return [kit.Svg({ source, alt: chart.title, width, height: Math.round((height * width) / 560) })]
91 }
92
93 return textChart(kit, chart, columns)
94}
95
96function tableView(kit: Kit, table: TabModel['tables'][number], columns: number): RenderElement[] {
97 const widths = table.head.map((head, i) => Math.max(head.length, ...table.rows.map(row => (row[i] ?? '').length)))
98 const first = Math.max(12, columns - widths.slice(1).reduce((a, b) => a + b + 2, 0) - 2)
99 const fit = widths.map((w, i) => (i === 0 ? Math.min(w, first) : w))
100 // Numbers right-aligned, words left-aligned.
101 const isNumeric = (i: number) => table.rows.every(row => /^[\d.,%$+\-kM ]*$/.test(row[i] ?? ''))
102 const line = (cells: readonly string[]) => cells.map((cell, i) => (i === 0 ? pad(cell, fit[0] ?? 12) : isNumeric(i) ? cell.padStart(fit[i] ?? 4) : pad(cell, fit[i] ?? 4))).join(' ')
103
104 return [kit.Text({ dimColor: true, children: [cut(line(table.head), columns)] }), ...table.rows.map(row => kit.Text({ children: [cut(line(row), columns)] }))]
105}
106
107const MARK: Record<Row['state'], { mark: string; color?: string }> = {
108 ok: { mark: '●', color: COLORS.good },
109 loading: { mark: '…' },
110 missing: { mark: '○', color: COLORS.muted },
111 error: { mark: '!', color: COLORS.poor },
112}
113
114/** Keys work only while the pane holds focus; say how to get it otherwise. */
115function footer(kit: Kit, state: PaneState, isFocused: boolean, actions: PaneActions, extra: RenderElement[] = []): RenderElement {
116 const { Box, Text, Button } = kit
117 const children: RenderElement[] = isFocused
118 ? [...extra, Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: actions.refresh }), Button({ key: 'export', label: 'Export', hotkey: 'e', plain: true, onPress: actions.exportHtml })]
119 : [Text({ dimColor: true, children: ['ctrl+x tab to use the keys'] })]
120
121 if (state.exported !== null && kit.Markdown !== undefined) {
122 // A Link takes only https (or http://localhost) and refuses the whole tree otherwise; Markdown links may use file:.
123 children.push(kit.Markdown({ text: `[open export](file://${encodeURI(state.exported)})` }))
124 }
125
126 return Box({ flexDirection: 'row', columnGap: 2, marginTop: 1, children })
127}
128
129/** The Overview: which site, then one line per source; missing data stays visible with its fix. */
130export function overviewView(kit: Kit, state: PaneState, columns: number, isFocused: boolean, actions: PaneActions): RenderElement {
131 const { Box, Text, Button } = kit
132 const where = { setting: 'default', typed: 'chosen here', folder: 'from this folder', last: 'last used', none: '' }[state.hostSource ?? 'none']
133 const children: RenderElement[] = [
134 // Row 1 carries no controls: the engine draws its close mark at the right edge.
135 Text({ children: [Text({ bold: true, children: [cut(state.host ?? 'No site yet', Math.max(10, columns - 24))] }), Text({ dimColor: true, children: [where === '' ? '' : ` ${where}`] })] }),
136 ]
137
138 // No site, or only the last one used elsewhere: offer the field to set this folder's own.
139 if ((state.host === null || state.hostSource === 'last') && kit.Input !== undefined) {
140 children.push(kit.Input({ key: 'target', label: 'Site', placeholder: state.host ?? 'example.com', submitLabel: 'go', ...(state.host === null && { autoFocus: true }), onSubmit: value => actions.setTarget(value) }))
141 }
142
143 const labelWidth = Math.max(...ROWS.map(row => row.label.length))
144
145 for (const row of ROWS.map(r => rowOf(r.id, state.models[r.id], state.loading.has(r.id)))) {
146 const { mark } = MARK[row.state]
147 // The hotkey draws as "1: " before a plain label; keep the whole line inside the pane.
148 const label = cut(`${mark} ${row.label.padEnd(labelWidth)} ${row.line}`, Math.max(12, columns - 4))
149
150 children.push(Button({ key: `row-${row.id}`, label, hotkey: row.key, plain: true, onPress: () => actions.open(row.id) }))
151 }
152
153 children.push(footer(kit, state, isFocused, actions))
154
155 return Box({ flexDirection: 'column', children })
156}
157
158/** One source in detail: its numbers, charts and tables, with a way back. */
159export function detailView(kit: Kit, state: PaneState, tab: TabId, columns: number, isFocused: boolean, actions: PaneActions): RenderElement {
160 const { Box, Text, Button } = kit
161 const model = state.models[tab]
162 const label = ROWS.find(row => row.id === tab)?.label ?? tab
163 const body: RenderElement[] = [Text({ children: [Text({ bold: true, children: [model?.heading ?? label] })] })]
164
165 if (model === undefined) {
166 body.push(Text({ dimColor: true, children: [state.loading.has(tab) ? 'checking…' : 'not loaded yet: press r'] }))
167 } else {
168 body.push(Text({ dimColor: true, children: [cut(`${model.source} · ${model.fetchedAt}`, columns)] }))
169
170 if (model.error !== null) {
171 body.push(Text({ color: COLORS.poor, children: [cut(model.error, columns * 3)] }))
172 }
173
174 for (const kpi of model.kpis) {
175 body.push(Text({ children: [`${kpi.label}: `, Text({ bold: true, children: [kpi.value] })] }))
176 }
177
178 for (const chart of model.charts) {
179 body.push(Box({ flexDirection: 'column', marginTop: 1, children: chartView(kit, chart, columns) }))
180 }
181
182 for (const table of model.tables) {
183 body.push(Box({ flexDirection: 'column', marginTop: 1, children: tableView(kit, table, columns) }))
184 }
185
186 body.push(...model.notes.map(note => Text({ dimColor: true, children: [cut(note, columns * 3)] })))
187 }
188
189 const back = Button({ key: 'back', label: 'Back', hotkey: 'b', plain: true, onPress: actions.back })
190
191 return Box({ flexDirection: 'column', children: [...body, footer(kit, state, isFocused, actions, isFocused ? [back] : [])] })
192}
193
194/** The whole pane for its current view. */
195export function paneView(kit: Kit, state: PaneState, columns: number, isFocused: boolean, actions: PaneActions): RenderElement {
196 return state.view === 'overview' ? overviewView(kit, state, columns, isFocused, actions) : detailView(kit, state, state.view, columns, isFocused, actions)
197}
198