diff --git a/.github/workflows/brand-numbers.yml b/.github/workflows/brand-numbers.yml new file mode 100644 index 00000000..c1f63f9b --- /dev/null +++ b/.github/workflows/brand-numbers.yml @@ -0,0 +1,24 @@ +name: Brand numbers + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Fails when a marketing number in this repo disagrees with brand-numbers.json. +# +# --check is deliberately OFFLINE. It compares against the committed snapshot +# and never fetches, so a blockrun.ai deploy in progress cannot fail this repo's +# CI. Pulling a newer artifact is a separate, deliberate act: +# +# node scripts/sync-brand-numbers.mjs --refresh +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node scripts/sync-brand-numbers.mjs --check diff --git a/AGENTS.md b/AGENTS.md index f05d77ff..cd319f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Franklin Agent -Open-source AI agent with a wallet. 55+ models. Pay per use with USDC via x402. +Open-source AI agent with a wallet. 66 models. Pay per use with USDC via x402. ## Commands diff --git a/CLAUDE.md b/CLAUDE.md index 1495311e..13bd8864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Capability pillars: Built on three layers: 1. **x402 micropayment protocol** โ€” HTTP 402 native payments -2. **BlockRun Gateway** โ€” aggregates 55+ LLMs + paid APIs (Exa, DALL-E, future Runway/Suno/CoinGecko) +2. **BlockRun Gateway** โ€” aggregates 66 LLMs + paid APIs (Exa, DALL-E, future Runway/Suno/CoinGecko) 3. **Franklin Agent** โ€” this repo, the reference client ## Commands diff --git a/CONTEXT.md b/CONTEXT.md index d23505ce..aac2a019 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,7 +19,7 @@ The first, unpaid HTTP request whose 402 response carries the payment terms; Fra _Avoid_: Pre-flight, handshake. **BlockRun Gateway**: -The single upstream service Franklin calls for both LLM completions and paid tools (Exa, ImageGen, VideoGen, MusicGen, market data); aggregates 55+ models and accepts x402. +The single upstream service Franklin calls for both LLM completions and paid tools (Exa, ImageGen, VideoGen, MusicGen, market data); aggregates 66 models and accepts x402. _Avoid_: API, provider, backend, BlockRun (without "Gateway") when referring to the service. **Per-turn spend cap**: diff --git a/README.md b/README.md index 589e68df..51029776 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Every tool call is itemized. Every token is priced. When the wallet hits zero, F ## Smart Router -**55+ models. One decision. Zero guesswork.** +**66 models. One decision. Zero guesswork.** You don't pick models. Franklin picks for you. @@ -316,7 +316,7 @@ You don't subscribe to electricity, you pay for what you use. Franklin brings th ### ๐Ÿง   Multi-model is the future -No single model is best at everything. Sonnet writes better code, Gemini handles longer context, DeepSeek costs 20x less for simple tasks. The Smart Router routes every request to the optimal model in <1ms โ€” up to 89% savings vs. always using Opus. +No single model is best at everything. Sonnet writes better code, Gemini handles longer context, DeepSeek costs 20x less for simple tasks. The Smart Router routes every request to the optimal model in <1ms โ€” 87% cheaper than pinning Claude Opus 5 for every request. @@ -338,7 +338,7 @@ No email. No phone. No KYC. Your Base or Solana address is your account โ€” port | Writes code | โœ… | โœ… | โš ๏ธ | โœ… | | **Spends money for you** | โŒ | โŒ | โŒ | โœ… **USDC wallet, x402** | | **Buys data + APIs + images + search** | โŒ | โŒ | โŒ | โœ… **55+ APIs, one wallet** | -| Picks best model per task | โŒ single-vendor | โŒ plan-tied | โŒ | โœ… **Smart Router, 55+ models** | +| Picks best model per task | โŒ single-vendor | โŒ plan-tied | โŒ | โœ… **Smart Router, 66 models** | | Pricing model | Subscription | Subscription | Subscription | **YOPO** โ€” per outcome, USDC | | Monthly fee | $20โ€“$200 | $20โ€“$40 | $20+ | **$0** | | Rate-limited | Yes | Yes | Yes | No โ€” limited only by wallet | @@ -366,14 +366,14 @@ Ask "what's BTC looking like?" โ€” Franklin fetches live price data, computes RS **๐ŸŽจ AI image generation** Ask "generate a logo" โ€” Franklin calls DALL-E / GPT Image, saves the result locally, paid from your wallet. -**๐Ÿง  55+ models via one wallet** +**๐Ÿง  66 models via one wallet** Anthropic, OpenAI, Google, xAI, DeepSeek, GLM, Kimi, Minimax, NVIDIA free tier. One wallet, one interface, automatic fallback. **๐Ÿ’ณ x402 micropayments (YOPO)** HTTP 402 native. Every paid action is a signed USDC micropayment via EIP-712 โ€” non-custodial, your keys never leave your machine. YOPO: you pay only for outcomes. **๐Ÿง  Learned model router** -Trained on 2M+ real requests. Classifies your task and picks the best model from 55+ LLMs. Four profiles (auto/eco/premium/free). Adapts to your usage over time. +Trained on 2M+ real requests. Classifies your task and picks the best model from 66 LLMs. Four profiles (auto/eco/premium/free). Adapts to your usage over time. @@ -429,13 +429,13 @@ Core is workflow-agnostic. Add new verticals without touching the loop. Discover โ”‚ Intent โ†’ Smart Router โ†’ Tool Use โ†’ Spend Control โ†’ Result โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Learned Router โ”‚ -โ”‚ 2M+ requests ยท 55+ models ยท category detection ยท Elo scores โ”‚ +โ”‚ 2M+ requests ยท 66 models ยท category detection ยท Elo scores โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Agent Loop โ”‚ โ”‚ 16 tools ยท Sessions ยท Compaction ยท Pricing ยท Plugin SDK โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ BlockRun Gateway โ”‚ -โ”‚ 55+ LLMs ยท CoinGecko ยท Search ยท Image APIs ยท paid services โ”‚ +โ”‚ 66 LLMs ยท CoinGecko ยท Search ยท Image APIs ยท paid services โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ x402 Micropayment Protocol โ”‚ โ”‚ HTTP 402 ยท USDC on Base & Solana ยท signed payment payloads โ”‚ @@ -479,7 +479,7 @@ src/ โ”œโ”€โ”€ stats/ Usage tracking + insights engine โ”œโ”€โ”€ ui/ Ink-based terminal UI โ”œโ”€โ”€ proxy/ Payment proxy for external tools -โ”œโ”€โ”€ router/ Learned model router (55+ models, Elo scoring) +โ”œโ”€โ”€ router/ Learned model router (66 models, Elo scoring) โ”œโ”€โ”€ wallet/ Wallet management (Base + Solana) โ”œโ”€โ”€ mcp/ MCP server auto-discovery โ””โ”€โ”€ commands/ CLI subcommands diff --git a/brand-numbers.json b/brand-numbers.json new file mode 100644 index 00000000..312505d8 --- /dev/null +++ b/brand-numbers.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://blockrun.ai/brand/numbers.schema.json", + "version": 1, + "models": { + "chatVisible": 66, + "totalVisible": 86, + "free": 8, + "freeWithheld": 17, + "image": 8, + "video": 5, + "music": 1, + "speech": 5, + "soundfx": 1, + "withFallback": 44, + "withFallbackAllEntries": 75 + }, + "clawrouter": { + "dimensions": 15, + "tiers": 4, + "profiles": 4, + "aliases": 202 + }, + "mcp": { + "tools": 19 + }, + "chains": { + "rpc": 40 + }, + "savings": { + "baselineModel": "anthropic/claude-opus-5", + "ecoVsBaselinePct": 98, + "autoVsBaselinePct": 87 + } +} diff --git a/docs/adr/0002-blockrun-gateway-as-aggregator.md b/docs/adr/0002-blockrun-gateway-as-aggregator.md index 32734b36..31cb9d13 100644 --- a/docs/adr/0002-blockrun-gateway-as-aggregator.md +++ b/docs/adr/0002-blockrun-gateway-as-aggregator.md @@ -2,7 +2,7 @@ **Status:** accepted -Franklin makes every LLM and paid-tool call through a single counterparty โ€” the BlockRun Gateway โ€” instead of integrating directly with each model provider, image/video/audio service, search API, and market-data feed. The gateway aggregates 55+ models and a small set of paid APIs and accepts x402 uniformly. +Franklin makes every LLM and paid-tool call through a single counterparty โ€” the BlockRun Gateway โ€” instead of integrating directly with each model provider, image/video/audio service, search API, and market-data feed. The gateway aggregates 66 models and a small set of paid APIs and accepts x402 uniformly. ## Considered options diff --git a/docs/anatomy-of-an-economic-agent.md b/docs/anatomy-of-an-economic-agent.md index abc3ea4d..5e888432 100644 --- a/docs/anatomy-of-an-economic-agent.md +++ b/docs/anatomy-of-an-economic-agent.md @@ -326,7 +326,7 @@ seamlessly. โ”‚ signed USDC (HTTP 402) โ–ผ BlockRun Gateway - 55+ models / paid APIs + 66 models / paid APIs โ”‚ โ–ผ User Wallet diff --git a/docs/why-ai-agents-need-a-wallet.md b/docs/why-ai-agents-need-a-wallet.md index cb61e478..c90ff091 100644 --- a/docs/why-ai-agents-need-a-wallet.md +++ b/docs/why-ai-agents-need-a-wallet.md @@ -154,7 +154,7 @@ cheap models because cheapness is now something the agent can agents both learn in real time. **3. Single-vendor failure becomes a one-command swap.** A router -with 55+ models across every major provider can route around any +with 66 models across every major provider can route around any single vendor's bad release. The wallet doesn't know or care which model answered โ€” it only pays for the answer that arrived. @@ -175,7 +175,7 @@ filled it. Franklin is the reference implementation of the Economic Agent. It's an AI agent CLI that holds USDC on Base or Solana, routes requests -across 55+ models, and settles every paid action in real time via +across 66 models, and settles every paid action in real time via the [x402](https://x402.org) HTTP-402 micropayment protocol. It is Apache-2.0, written in TypeScript, and ships as one npm package. diff --git a/scripts/sync-brand-numbers.mjs b/scripts/sync-brand-numbers.mjs new file mode 100644 index 00000000..c3717f41 --- /dev/null +++ b/scripts/sync-brand-numbers.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * Sync marketing numbers from BlockRun's canonical brand artifact. + * + * This file is copied byte-for-byte into every public repo as + * scripts/sync-brand-numbers.mjs. It is a copy rather than an npm package on + * purpose: a package would mean 37 dependency bumps, and several consuming + * repos have no package.json at all. Zero dependencies, plain Node. + * + * node scripts/sync-brand-numbers.mjs rewrite markers in place + * node scripts/sync-brand-numbers.mjs --check exit 1 on drift, write nothing + * node scripts/sync-brand-numbers.mjs --refresh re-fetch the artifact first + * + * --check NEVER touches the network. PR CI must be deterministic and offline: + * if it fetched, a deploy in progress would fail every repo in the org at once. + * Freshness is the fan-out job's problem, not the pull request's. + * + * Markers look like: 66 + * and wrap the WHOLE token, so a badge URL, its alt text and the prose number + * can all regenerate from one key. + */ +import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join, relative, extname } from "node:path"; + +const ROOT = process.cwd(); +const SNAPSHOT = join(ROOT, "brand-numbers.json"); +// ORIGIN is tried first because it IS the truth โ€” the mirror can only ever be +// as fresh as the last time someone refreshed it. The mirror exists so a repo +// can still sync while blockrun.ai is down, not to front the origin. +// +// The mirror is awesome-blockrun's own brand-numbers.json: that repo consumes +// the artifact like every other, and its snapshot doubles as the org's copy. +// One file, one role per repo, nothing to keep in step by hand. +const ORIGIN = "https://blockrun.ai/brand/numbers.json"; +const MIRROR = + "https://raw.githubusercontent.com/BlockRunAI/awesome-blockrun/main/brand-numbers.json"; + +const argv = new Set(process.argv.slice(2)); +const check = argv.has("--check"); +const refresh = argv.has("--refresh"); + +const SKIP_DIRS = new Set([ + "node_modules", ".git", "dist", "build", "out", ".next", "coverage", + "vendor", "target", "__pycache__", ".venv", "venv", +]); +// .txt is here for llms.txt, which is a first-class marketing surface: it is +// what agents read to find out what BlockRun serves. Scanning other .txt files +// costs a read and changes nothing โ€” only files with markers are ever written. +const TEXT_EXT = new Set([".md", ".mdx", ".txt"]); + +/* โ”€โ”€ 1. numbers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +async function loadNumbers() { + if (!refresh) { + try { + return JSON.parse(readFileSync(SNAPSHOT, "utf8")); + } catch { + fail( + `no brand-numbers.json in ${ROOT}\n` + + ` run with --refresh once to seed it from ${ORIGIN}`, + ); + } + } + for (const url of [ORIGIN, MIRROR]) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) continue; + const json = await res.json(); + writeFileSync(SNAPSHOT, `${JSON.stringify(json, null, 2)}\n`); + return json; + } catch { + /* try the next source */ + } + } + fail(`could not refresh from ${MIRROR} or ${ORIGIN}`); +} + +/** Flatten nested numbers into dotted keys, ignoring $comment / rationale prose. */ +function flatten(obj, prefix = "") { + return Object.entries(obj).flatMap(([k, v]) => { + if (k.startsWith("$")) return []; + const key = `${prefix}${k}`; + if (v && typeof v === "object" && !Array.isArray(v)) return flatten(v, `${key}.`); + if (v === null) return []; + return [[key, v]]; + }); +} + +/* โ”€โ”€ 2. renderers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +/** + * How a key becomes text. Default is the bare value. + * + * A marker may carry an `@modifier` โ€” `` โ€” which + * selects a renderer without changing which number is looked up. The modifier + * is what makes a key reusable: the same mcp.tools appears as a shields badge + * at the top of a README and as a bare "19 tools" in a table two screens down, + * and one marker still keeps the badge URL, its alt text and the label in step. + * + * Renderers are registered under the FULL marker name so a badge's label is + * written out rather than guessed from the key. + */ +const badge = (label) => (n) => + `${n} ${label}`; + +const RENDER = { + "mcp.tools@badge": badge("tools"), + "models.totalVisible@badge": badge("models"), + "models.chatVisible@badge": badge("models"), +}; +const render = (marker, value) => (RENDER[marker] ?? String)(value); + +/** `mcp.tools@badge` looks up `mcp.tools`. Unmodified markers are unaffected. */ +const keyOf = (marker) => marker.split("@")[0]; + +/* โ”€โ”€ 3. marker rewriting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const OPEN_ANY = //g; +const CLOSE_ANY = //g; + +/** Byte ranges of fenced code blocks โ€” markers inside them are documentation. */ +function fencedRanges(text) { + const ranges = []; + const fence = /^(\s*)(`{3,}|~{3,})[^\n]*$/gm; + let open = null; + for (let m; (m = fence.exec(text)); ) { + if (open === null) open = m.index; + else { + ranges.push([open, m.index + m[0].length]); + open = null; + } + } + return ranges; +} + +function syncFile(file, numbers, problems) { + const before = readFileSync(file, "utf8"); + const rel = relative(ROOT, file); + const fenced = fencedRanges(before); + const inFence = (i) => fenced.some(([a, b]) => i >= a && i < b); + const known = new Map(numbers); + const used = new Set(); + + // Markers actually present, so a file is only ever rewritten for what it uses + // and an @modifier is carried through to the renderer verbatim. + const markers = new Set(); + // A marker naming a key that does not exist is an error, never a silent + // no-op: a typo'd marker would otherwise sit there looking synced forever. + for (const [re, shown] of [ + [OPEN_ANY, (n) => ``], + [CLOSE_ANY, (n) => ``], + ]) { + for (const m of before.matchAll(re)) { + if (inFence(m.index)) continue; + markers.add(m[1]); + if (!known.has(keyOf(m[1]))) problems.push(`${rel}: unknown key ${shown(m[1])}`); + } + } + + let after = before; + for (const marker of markers) { + const key = keyOf(marker); + if (!known.has(key)) continue; + const value = known.get(key); + const pair = new RegExp( + `()([\\s\\S]*?)()`, + "g", + ); + after = after.replace(pair, (whole, open, inner, close, offset) => { + if (inFence(offset)) return whole; + // Nesting means the closing tag of an inner marker would be consumed by + // the outer one. Refuse rather than produce mangled output. + if (/`, "g"))] + .filter((m) => !inFence(m.index)).length; + const closes = [...before.matchAll(new RegExp(``, "g"))] + .filter((m) => !inFence(m.index)).length; + if (opens !== closes) problems.push(`${rel}: unbalanced marker br:${marker} (${opens} open, ${closes} close)`); + } + + return { before, after, changed: before !== after, used }; +} + +/* โ”€โ”€ 4. walk โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +function* walk(dir) { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + // lstat, not stat: a symlinked directory is reached by its real path or not + // at all. blockrun's docs/ -> awesome-blockrun/docs is exactly the case that + // matters โ€” following it would edit a submodule's files behind the skip + // below, and a link pointing at an ancestor would recurse forever. + const s = lstatSync(p); + if (s.isSymbolicLink()) continue; + if (s.isDirectory()) { + // A nested repo is a submodule or vendored checkout: it carries its own + // brand-numbers.json and syncs itself. Rewriting its markers from THIS + // repo's snapshot would dirty a submodule nobody asked us to touch, and + // would report drift that belongs to another repo's CI. + if (existsSync(join(p, ".git"))) continue; + yield* walk(p); + } else if (TEXT_EXT.has(extname(name))) yield p; + } +} + +function fail(msg) { + console.error(`brand-numbers: ${msg}`); + process.exit(1); +} + +/* โ”€โ”€ 5. run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +const raw = await loadNumbers(); +const numbers = flatten(raw); +const problems = []; +const drifted = []; +const everUsed = new Set(); + +for (const file of walk(ROOT)) { + const { before, after, changed, used } = syncFile(file, numbers, problems); + used.forEach((k) => everUsed.add(k)); + if (!changed) continue; + drifted.push({ file: relative(ROOT, file), before, after }); + if (!check) writeFileSync(file, after); +} + +if (problems.length) { + for (const p of problems) console.error(` ${p}`); + fail(`${problems.length} marker problem(s)`); +} + +if (check) { + if (drifted.length === 0) { + console.log(`brand-numbers: up to date (${everUsed.size} keys in use)`); + process.exit(0); + } + console.error("brand-numbers: these files disagree with brand-numbers.json\n"); + for (const { file, before, after } of drifted) { + const b = before.split("\n"); + const a = after.split("\n"); + for (let i = 0; i < Math.max(b.length, a.length); i++) { + if (b[i] !== a[i]) { + console.error(` ${file}:${i + 1}`); + console.error(` - ${(b[i] ?? "").trim()}`); + console.error(` + ${(a[i] ?? "").trim()}`); + } + } + } + console.error( + "\n fix with: node scripts/sync-brand-numbers.mjs && git commit -am 'chore: sync brand numbers'", + ); + process.exit(1); +} + +console.log( + drifted.length + ? `brand-numbers: updated ${drifted.length} file(s)` + : `brand-numbers: already up to date (${everUsed.size} keys in use)`, +);