SLOPSHOPPER

seo-cockpit

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…

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · seo-cockpit
│ ┃ SEO Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ No site yet │ ┃ Site: example.com ⏎ go ⏺ Read(src/auth.ts) │ ┃ 1: … Audit checking… ⎿ Read 6 lines │ ┃ 2: … Vitals checking… ⏺ Update(src/auth.ts) │ ┃ 3: … Search checking… ⎿ Added 2 lines, removed 1 line │ ┃ 4: … Rankings checking… ⏺ Bash(bun test) │ ┃ 5: … Maps checking… ⎿ 3 pass, 1 fail │ ┃ 6: … Spend checking… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ r: Refresh e: Export │ ✻ Worked for 42s · done 4:20 PM │ │ › /seo-spend │ ⎿ seo-cockpit: claude-seo was not found. Set "claude-seo folder" i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SEO Cockpit · while holding a tool call
No site yet Site: example.com ⏎ go 1: … Audit checking… 2: … Vitals checking… 3: … Search checking… 4: … Rankings checking… 5: … Maps checking… 6: … Spend checking… r: Refresh e: Export
README

seo-cockpit

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.

What it does (0.3.0)

Spend guard. Before a paid SEO API call runs, seo-cockpit checks it against the claude-seo DataForSEO budget (scripts/dataforseo_costs.py):

The callWhat happens
DataForSEO MCP tool or the Merchant script, budget says approvedRuns. 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 exceededHeld. 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 APIYou 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
  • The site, most specific first: one you chose in this folder (typed in the pane, or /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.
  • Google access uses claude-seo's own setup, or the two Google settings in /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).
  • Where it sits: in a normal terminal the pane opens above the prompt and asks for as many rows as the screen can spare (drag it to resize; your size is kept). In Claude Code's fullscreen layout, 110 columns or wider, it docks beside the conversation at full height.
  • Everything loads on open, from free sources only (the audit and grid files, the cost ledger, and Search Console and CrUX through claude-seo's own Google setup). The last result shows at once and is replaced when the fresh one arrives.
  • Missing data stays visible as a dim line saying what to do, instead of an error screen.
  • A row opens its detail (Enter, a click, or its number): charts and tables for that source, b to go back.
  • Keys work while the pane has focus; otherwise the footer says 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.
  • A status line under the prompt keeps the summary in view: 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.

Install

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

Settings (/config)

SettingDefaultMeaning
claude-seo folderemptyWhere claude-seo lives. When empty, it looks next to this plugin (a checkout) and in the plugin cache (an install)
Python commandpython3Runs the stdlib-only ledger scripts
Spend guardonTurn off to let paid calls through unchecked
Audit bandonThe live line above the prompt during an audit
Economy modeoffRun the five Opus agents on Sonnet
Default siteemptyThe 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 VitalsemptyFor the Vitals and drift views. Empty uses the property's site
Audits folderemptyWhere you keep your <site>-audit/ folders. The cockpit finds the shown site's audit there from any folder
Google accountautoauto: 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 keyemptyFor Core Web Vitals (CrUX). Sensitive: kept in Claude Code's secure storage. Empty uses claude-seo's own setup

What it can and cannot see

  • It reads no credentials and no environment variables. The Python scripts handle auth.
  • It runs only 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.
  • It writes one kind of file: the HTML dashboard, in the working folder, when you ask for it.
  • Spend is checked for DataForSEO only. Other providers have no cost table, so you decide.
  • "Allow until reload" is forgotten when the plugin reloads (a restart, or a change in /config), after which it asks again.

Known limits

  • It matches what it can see. A paid API reached by some route it does not recognise (a new script, an unknown host, a custom MCP server name) is not held.
  • Parallel calls can overshoot the cap a little. Each call is checked against the ledger before the others are logged, so several approved calls made at once can together pass the daily cap.
  • 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.
  • CLI only: it needs $.process to run the ledger script. Where that is missing, DataForSEO calls are held and the commands report an error.
  • Ask before rules: a call your permission rules would deny can still raise its cost question first.
  • The band and the pane draw in the terminal and the desktop app, not in the VS Code chat panel or claude -p. The receipt line is plain text and shows wherever the answer does; the cockpit falls back to the HTML dashboard.
  • The Maps view needs a saved grid. Grids from before this release were only drawn in the chat; run the scan again to save one.

Status

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)
  • type-check (tsc, strict) and claude plugin validate --strict
  • a static security scan (reach L2, no critical or high flags)
  • live in Claude Code on a real site: the cockpit inline and docked, every Overview row with live CrUX and Search Console data, /seo-spend and /seo-doctor
  • the HTML export rendered in Chromium, light and dark

Not yet observed live: the audit band during a running /seo audit (covered by kit tests).

Development

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

Source 10 files
hooks/register.ts 1000 lines
1import 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}
1000
hooks/lib/audit.ts 192 lines
1/**
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}
192
hooks/lib/charts.ts 259 lines
1/**
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
259
hooks/lib/format.ts 94 lines
1/**
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}
94
hooks/lib/paid.ts 150 lines
1/**
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}
150
hooks/lib/root.ts 99 lines
1/**
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}
99
hooks/lib/overview.ts 164 lines
1/**
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}
164
hooks/lib/tabs.ts 334 lines
1/**
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
334
hooks/lib/verdict.ts 103 lines
1/**
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')
103
hooks/views/pane.ts 198 lines
1/**
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