Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,16 @@ jobs:
- name: Build
run: npm run build

# The unit suite (713 tests) was never wired in here — only the lifecycle
# integration test ran, so a failing unit test could merge unnoticed.
- name: Unit tests
run: npm test

# Fails when a number in the docs disagrees with brand-numbers.json.
# Offline by design: it reads the committed snapshot and never fetches, so
# a blockrun.ai deploy in progress cannot fail this repo's CI.
- name: Brand numbers
run: node scripts/sync-brand-numbers.mjs --check

- name: Integration tests (lifecycle)
run: npx vitest run --config vitest.integration.config.ts test/integration/lifecycle.test.ts
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
dist/
node_modules/
package-lock.json

# Vendored byte-for-byte from BlockRunAI/blockrun:brand/sync-brand-numbers.mjs.
# Formatting it here would fork the copy from the source and from the other 36
# repos that carry it, and CI compares them.
scripts/sync-brand-numbers.mjs
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ClawRouter

Smart LLM router for autonomous agents. 55+ models. Wallet-based auth. USDC micropayments via x402.
Smart LLM router for autonomous agents. <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models. Wallet-based auth. USDC micropayments via x402.

## Commands

Expand Down Expand Up @@ -56,4 +56,4 @@ src/
- Node >= 22
- MIT license
- npm registry: `@blockrun/clawrouter`
- 15-dimension scoring for model routing (all local, < 1ms)
- <!-- br:clawrouter.dimensions -->15<!-- /br:clawrouter.dimensions -->-dimension scoring for model routing (all local, < 1ms)
85 changes: 46 additions & 39 deletions README.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions brand-numbers.json
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@blockrun/clawrouter",
"version": "0.12.235",
"description": "Smart LLM router — save 85% on inference costs. 55+ models (8 free), one wallet, x402 micropayments.",
"description": "Smart LLM router — save 87% on inference costs. 66 models (8 free), one wallet, x402 micropayments.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
256 changes: 256 additions & 0 deletions scripts/sync-brand-numbers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
#!/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: <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible -->
* and wrap the WHOLE token, so a badge URL, its alt text and the prose number
* can all regenerate from one key.
*/
import { readFileSync, writeFileSync, readdirSync, statSync } 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");
Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject --check --refresh together.

This combination takes the refresh path, fetching from the network and overwriting brand-numbers.json, despite --check being documented as offline and non-mutating.

Proposed fix
 const check = argv.has("--check");
 const refresh = argv.has("--refresh");
+if (check && refresh) fail("--check and --refresh cannot be combined");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const argv = new Set(process.argv.slice(2));
const check = argv.has("--check");
const refresh = argv.has("--refresh");
const argv = new Set(process.argv.slice(2));
const check = argv.has("--check");
const refresh = argv.has("--refresh");
if (check && refresh) fail("--check and --refresh cannot be combined");
🧰 Tools
🪛 ESLint

[error] 38-38: 'process' is not defined.

(no-undef)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync-brand-numbers.mjs` around lines 38 - 40, Update the argument
handling in the sync script around the check and refresh flags to reject when
both --check and --refresh are supplied, before any refresh or network work
begins. Emit a clear usage error and terminate without modifying
brand-numbers.json; preserve the existing behavior for each flag when used
independently.


const SKIP_DIRS = new Set([
"node_modules", ".git", "dist", "build", "out", ".next", "coverage",
"vendor", "target", "__pycache__", ".venv", "venv",
]);
const TEXT_EXT = new Set([".md", ".mdx"]);

/* ── 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` — `<!-- br:mcp.tools@badge -->` — 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) =>
`<img src="https://img.shields.io/badge/${label}-${n}-5B9BF6?style=flat-square&labelColor=0B0A0F" alt="${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 = /<!--\s*br:([A-Za-z0-9_.@]+)\s*-->/g;
const CLOSE_ANY = /<!--\s*\/br:([A-Za-z0-9_.@]+)\s*-->/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) => `<!-- br:${n} -->`],
[CLOSE_ANY, (n) => `<!-- /br:${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*br:${esc(marker)}\\s*-->)([\\s\\S]*?)(<!--\\s*/br:${esc(marker)}\\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 (/<!--\s*\/?br:/.test(inner)) {
problems.push(`${rel}: nested marker inside br:${marker}`);
return whole;
}
used.add(marker);
return open + render(marker, value) + close;
});

// An opening tag with no partner silently swallows the rest of the file on
// a naive regex, so catch it explicitly.
const opens = [...before.matchAll(new RegExp(`<!--\\s*br:${esc(marker)}\\s*-->`, "g"))]
.filter((m) => !inFence(m.index)).length;
const closes = [...before.matchAll(new RegExp(`<!--\\s*/br:${esc(marker)}\\s*-->`, "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);
const s = statSync(p);
if (s.isDirectory()) yield* walk(p);
else if (TEXT_EXT.has(extname(name))) yield p;
}
Comment on lines +193 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not follow symlinks during repository traversal.

statSync() follows source-controlled symlinks. A symlinked directory can recurse outside the repository or loop indefinitely; a symlinked Markdown file can cause normal sync mode to overwrite its target.

Proposed fix
-import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
+import { readFileSync, writeFileSync, readdirSync, lstatSync } from "node:fs";

-    const s = statSync(p);
+    const s = lstatSync(p);
+    if (s.isSymbolicLink()) continue;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function* walk(dir) {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) yield* walk(p);
else if (TEXT_EXT.has(extname(name))) yield p;
}
function* walk(dir) {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const s = lstatSync(p);
if (s.isSymbolicLink()) continue;
if (s.isDirectory()) yield* walk(p);
else if (TEXT_EXT.has(extname(name))) yield p;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync-brand-numbers.mjs` around lines 193 - 200, Update the walk
generator to use non-following symlink metadata when inspecting each path, and
skip symlink entries before recursing or yielding files. Preserve traversal for
regular directories and supported text files while ensuring symlinked
directories and files are never followed or modified.

}

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);
}
Comment on lines +216 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate marker problems before writing any files.

The loop writes each changed file before Lines 224-227 check problems. A nested or unbalanced marker therefore exits with failure after partially updating documentation. Collect drift first, fail on problems, then write only when validation succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync-brand-numbers.mjs` around lines 216 - 222, Update the main
file-processing loop around syncFile so changed content is only collected in
drifted and not written immediately. After all files are processed, validate
problems and exit on failure before any writes; then perform writeFileSync for
the collected changes only when validation succeeds, preserving check-mode
behavior.


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)`,
);
Loading
Loading