@blockrun/clawrouter 0.12.264 → 0.12.265

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -226835,6 +226835,7 @@ var index_exports = {};
226835
226835
  __export(index_exports, {
226836
226836
  BALANCE_THRESHOLDS: () => BALANCE_THRESHOLDS,
226837
226837
  BLOCKRUN_MODELS: () => BLOCKRUN_MODELS,
226838
+ BLOCKRUN_PLUGIN_ID: () => BLOCKRUN_PLUGIN_ID,
226838
226839
  BalanceMonitor: () => BalanceMonitor,
226839
226840
  CAIP2_BASE: () => CAIP2_BASE,
226840
226841
  CAIP2_SOLANA_MAINNET: () => CAIP2_SOLANA_MAINNET,
@@ -227149,6 +227150,35 @@ function injectModelsConfig(logger48, options = {}) {
227149
227150
  logger48.info(`Added ${addedCount} models to allowlist (${TOP_MODELS.length} total)`);
227150
227151
  }
227151
227152
  }
227153
+ const pluginEntries = config.plugins?.entries;
227154
+ if (pluginEntries && pluginEntries.clawrouter && !pluginEntries[BLOCKRUN_PLUGIN_ID]) {
227155
+ pluginEntries[BLOCKRUN_PLUGIN_ID] = pluginEntries.clawrouter;
227156
+ delete pluginEntries.clawrouter;
227157
+ needsWrite = true;
227158
+ logger48.info(
227159
+ "Migrated plugins.entries.clawrouter -> blockrun-clawrouter (OpenClaw bundles its own `clawrouter` plugin; see #305)"
227160
+ );
227161
+ }
227162
+ const pluginsCfg = config.plugins;
227163
+ const allow = pluginsCfg?.allow;
227164
+ if (Array.isArray(allow) && allow.includes("clawrouter") && !allow.includes(BLOCKRUN_PLUGIN_ID)) {
227165
+ allow.push(BLOCKRUN_PLUGIN_ID);
227166
+ needsWrite = true;
227167
+ logger48.info(`Added ${BLOCKRUN_PLUGIN_ID} to plugins.allow (renamed from clawrouter; see #305)`);
227168
+ }
227169
+ const deny = pluginsCfg?.deny;
227170
+ if (Array.isArray(deny) && deny.includes("clawrouter") && !deny.includes(BLOCKRUN_PLUGIN_ID)) {
227171
+ deny.push(BLOCKRUN_PLUGIN_ID);
227172
+ needsWrite = true;
227173
+ logger48.info(`Added ${BLOCKRUN_PLUGIN_ID} to plugins.deny (renamed from clawrouter; see #305)`);
227174
+ }
227175
+ const installs = pluginsCfg?.installs;
227176
+ if (installs && installs.clawrouter && !installs[BLOCKRUN_PLUGIN_ID]) {
227177
+ installs[BLOCKRUN_PLUGIN_ID] = installs.clawrouter;
227178
+ delete installs.clawrouter;
227179
+ needsWrite = true;
227180
+ logger48.info(`Migrated plugins.installs.clawrouter -> ${BLOCKRUN_PLUGIN_ID} (see #305)`);
227181
+ }
227152
227182
  if (!config.tools || typeof config.tools !== "object" || Array.isArray(config.tools)) {
227153
227183
  config.tools = {};
227154
227184
  needsWrite = true;
@@ -228141,7 +228171,7 @@ Run \`openclaw plugins install @blockrun/clawrouter\` to generate a wallet.`,
228141
228171
  }
228142
228172
  };
228143
228173
  }
228144
- var activeProxyHandle, pendingConfiguredStartupApi, IMAGE_DIR2, AUDIO_DIR2, VIDEO_DIR2, plugin, index_default2;
228174
+ var activeProxyHandle, pendingConfiguredStartupApi, IMAGE_DIR2, AUDIO_DIR2, VIDEO_DIR2, BLOCKRUN_PLUGIN_ID, plugin, index_default2;
228145
228175
  var init_index = __esm({
228146
228176
  "src/index.ts"() {
228147
228177
  "use strict";
@@ -228184,9 +228214,16 @@ var init_index = __esm({
228184
228214
  IMAGE_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "images");
228185
228215
  AUDIO_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "audio");
228186
228216
  VIDEO_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "videos");
228217
+ BLOCKRUN_PLUGIN_ID = "blockrun-clawrouter";
228187
228218
  plugin = {
228188
- id: "clawrouter",
228189
- name: "ClawRouter",
228219
+ // NOT "clawrouter". OpenClaw bundles its own plugin under that id since the
228220
+ // 2026.7.1 line (vendored at dist/extensions/clawrouter, provider `clawrouter/*`,
228221
+ // CLAWROUTER_API_KEY, clawrouter.openclaw.ai). A duplicate id loses to the
228222
+ // bundled one — "global plugin will be overridden by bundled plugin" — so this
228223
+ // plugin silently never loaded and the local proxy never started. Different
228224
+ // product, different id. See #305.
228225
+ id: BLOCKRUN_PLUGIN_ID,
228226
+ name: "BlockRun ClawRouter",
228190
228227
  description: "Smart LLM router \u2014 55+ models, x402 micropayments, 78% cost savings",
228191
228228
  version: VERSION,
228192
228229
  register(api) {
@@ -228595,13 +228632,18 @@ ${errText}`
228595
228632
  delete config.models.providers.blockrun;
228596
228633
  }
228597
228634
  removeManagedBlockrunMcpServerConfig(config);
228598
- for (const key2 of ["clawrouter", "ClawRouter", "@blockrun/clawrouter"]) {
228635
+ for (const key2 of [
228636
+ BLOCKRUN_PLUGIN_ID,
228637
+ "clawrouter",
228638
+ "ClawRouter",
228639
+ "@blockrun/clawrouter"
228640
+ ]) {
228599
228641
  if (config.plugins?.entries?.[key2]) delete config.plugins.entries[key2];
228600
228642
  if (config.plugins?.installs?.[key2]) delete config.plugins.installs[key2];
228601
228643
  }
228602
228644
  if (Array.isArray(config.plugins?.allow)) {
228603
228645
  config.plugins.allow = config.plugins.allow.filter(
228604
- (p) => p !== "clawrouter" && p !== "ClawRouter" && p !== "@blockrun/clawrouter"
228646
+ (p) => p !== BLOCKRUN_PLUGIN_ID && p !== "clawrouter" && p !== "ClawRouter" && p !== "@blockrun/clawrouter"
228605
228647
  );
228606
228648
  }
228607
228649
  if (config.agents?.defaults?.models) {
package/dist/index.d.ts CHANGED
@@ -1729,6 +1729,13 @@ declare function parseCallArgs(raw: string): {
1729
1729
  * Delegates to the local proxy (which handles x402 payment).
1730
1730
  */
1731
1731
  declare function buildImageGenerationProvider(): ImageGenerationProviderPlugin;
1732
+ /**
1733
+ * This plugin's OpenClaw id. NOT "clawrouter" — OpenClaw bundles its own plugin
1734
+ * under that id since the 2026.7.1 line, and a duplicate loses to the bundled
1735
+ * one. Kept as one constant so the manifest, the plugin definition and the
1736
+ * config migration can never drift apart. See #305.
1737
+ */
1738
+ declare const BLOCKRUN_PLUGIN_ID = "blockrun-clawrouter";
1732
1739
  declare const plugin: OpenClawPluginDefinition;
1733
1740
 
1734
- export { type AggregatedStats, BALANCE_THRESHOLDS, BLOCKRUN_MODELS, type BalanceInfo, BalanceMonitor, CAIP2_BASE, CAIP2_SOLANA_MAINNET, type CachedLLMResponse, type CachedResponse, type CheckResult, type CounterpartyInfo, DEFAULT_RETRY_CONFIG, DEFAULT_SESSION_CONFIG, type DailyStats, type DerivedKeys, EmptyWalletError, FileSpendControlStorage, InMemorySpendControlStorage, InsufficientFundsError, type InsufficientFundsInfo, type LowBalanceInfo, MODEL_ALIASES, MalformedSpendPolicyError, OPENCLAW_MODELS, PARTNER_SERVICES, type PartnerServiceDefinition, type PartnerToolDefinition, type PaymentChain, type PolicyList, type ProxyHandle, type ProxyOptions, RequestDeduplicator, ResponseCache, type ResponseCacheConfig, type RetryConfig, RpcError, type SessionConfig, type SessionEntry, SessionStore, type SolanaBalanceInfo, SolanaBalanceMonitor, SpendControl, type SpendControlOptions, type SpendControlStorage, type SpendLimits, SpendPolicyError, type SpendRecord, type SpendWindow, type SpendingStatus, type SufficiencyResult, type UsageEntry, VISIBLE_OPENCLAW_MODELS, type WalletConfig, type WalletResolution, blockrunProvider, buildImageGenerationProvider, buildPartnerTools, buildProviderModels, clearStats, plugin as default, deriveAllKeys, deriveEvmKey, deriveSolanaKeyBytes, fetchWithRetry, formatDuration, formatStatsAscii, generateWalletMnemonic, getAgenticModels, getModelContextWindow, getPartnerService, getProxyPort, getSessionId, getStats, hashRequestContent, injectAuthProfile, injectModelsConfig, isAgenticModel, isBalanceError, isBlockrunWebSearchDisabled, isEmptyWalletError, isInsufficientFundsError, isRetryable, isRpcError, isValidMnemonic, loadPaymentChain, logUsage, parseCallArgs, registerSpendPolicyHook, resolveModelAlias, resolvePaymentChain, savePaymentChain, setupSolana, startProxy, syncAgentModelCache };
1741
+ export { type AggregatedStats, BALANCE_THRESHOLDS, BLOCKRUN_MODELS, BLOCKRUN_PLUGIN_ID, type BalanceInfo, BalanceMonitor, CAIP2_BASE, CAIP2_SOLANA_MAINNET, type CachedLLMResponse, type CachedResponse, type CheckResult, type CounterpartyInfo, DEFAULT_RETRY_CONFIG, DEFAULT_SESSION_CONFIG, type DailyStats, type DerivedKeys, EmptyWalletError, FileSpendControlStorage, InMemorySpendControlStorage, InsufficientFundsError, type InsufficientFundsInfo, type LowBalanceInfo, MODEL_ALIASES, MalformedSpendPolicyError, OPENCLAW_MODELS, PARTNER_SERVICES, type PartnerServiceDefinition, type PartnerToolDefinition, type PaymentChain, type PolicyList, type ProxyHandle, type ProxyOptions, RequestDeduplicator, ResponseCache, type ResponseCacheConfig, type RetryConfig, RpcError, type SessionConfig, type SessionEntry, SessionStore, type SolanaBalanceInfo, SolanaBalanceMonitor, SpendControl, type SpendControlOptions, type SpendControlStorage, type SpendLimits, SpendPolicyError, type SpendRecord, type SpendWindow, type SpendingStatus, type SufficiencyResult, type UsageEntry, VISIBLE_OPENCLAW_MODELS, type WalletConfig, type WalletResolution, blockrunProvider, buildImageGenerationProvider, buildPartnerTools, buildProviderModels, clearStats, plugin as default, deriveAllKeys, deriveEvmKey, deriveSolanaKeyBytes, fetchWithRetry, formatDuration, formatStatsAscii, generateWalletMnemonic, getAgenticModels, getModelContextWindow, getPartnerService, getProxyPort, getSessionId, getStats, hashRequestContent, injectAuthProfile, injectModelsConfig, isAgenticModel, isBalanceError, isBlockrunWebSearchDisabled, isEmptyWalletError, isInsufficientFundsError, isRetryable, isRpcError, isValidMnemonic, loadPaymentChain, logUsage, parseCallArgs, registerSpendPolicyHook, resolveModelAlias, resolvePaymentChain, savePaymentChain, setupSolana, startProxy, syncAgentModelCache };
package/dist/index.js CHANGED
@@ -226895,6 +226895,35 @@ function injectModelsConfig(logger48, options = {}) {
226895
226895
  logger48.info(`Added ${addedCount} models to allowlist (${TOP_MODELS.length} total)`);
226896
226896
  }
226897
226897
  }
226898
+ const pluginEntries = config.plugins?.entries;
226899
+ if (pluginEntries && pluginEntries.clawrouter && !pluginEntries[BLOCKRUN_PLUGIN_ID]) {
226900
+ pluginEntries[BLOCKRUN_PLUGIN_ID] = pluginEntries.clawrouter;
226901
+ delete pluginEntries.clawrouter;
226902
+ needsWrite = true;
226903
+ logger48.info(
226904
+ "Migrated plugins.entries.clawrouter -> blockrun-clawrouter (OpenClaw bundles its own `clawrouter` plugin; see #305)"
226905
+ );
226906
+ }
226907
+ const pluginsCfg = config.plugins;
226908
+ const allow = pluginsCfg?.allow;
226909
+ if (Array.isArray(allow) && allow.includes("clawrouter") && !allow.includes(BLOCKRUN_PLUGIN_ID)) {
226910
+ allow.push(BLOCKRUN_PLUGIN_ID);
226911
+ needsWrite = true;
226912
+ logger48.info(`Added ${BLOCKRUN_PLUGIN_ID} to plugins.allow (renamed from clawrouter; see #305)`);
226913
+ }
226914
+ const deny = pluginsCfg?.deny;
226915
+ if (Array.isArray(deny) && deny.includes("clawrouter") && !deny.includes(BLOCKRUN_PLUGIN_ID)) {
226916
+ deny.push(BLOCKRUN_PLUGIN_ID);
226917
+ needsWrite = true;
226918
+ logger48.info(`Added ${BLOCKRUN_PLUGIN_ID} to plugins.deny (renamed from clawrouter; see #305)`);
226919
+ }
226920
+ const installs = pluginsCfg?.installs;
226921
+ if (installs && installs.clawrouter && !installs[BLOCKRUN_PLUGIN_ID]) {
226922
+ installs[BLOCKRUN_PLUGIN_ID] = installs.clawrouter;
226923
+ delete installs.clawrouter;
226924
+ needsWrite = true;
226925
+ logger48.info(`Migrated plugins.installs.clawrouter -> ${BLOCKRUN_PLUGIN_ID} (see #305)`);
226926
+ }
226898
226927
  if (!config.tools || typeof config.tools !== "object" || Array.isArray(config.tools)) {
226899
226928
  config.tools = {};
226900
226929
  needsWrite = true;
@@ -227887,7 +227916,7 @@ Run \`openclaw plugins install @blockrun/clawrouter\` to generate a wallet.`,
227887
227916
  }
227888
227917
  };
227889
227918
  }
227890
- var activeProxyHandle, pendingConfiguredStartupApi, IMAGE_DIR2, AUDIO_DIR2, VIDEO_DIR2, plugin, index_default2;
227919
+ var activeProxyHandle, pendingConfiguredStartupApi, IMAGE_DIR2, AUDIO_DIR2, VIDEO_DIR2, BLOCKRUN_PLUGIN_ID, plugin, index_default2;
227891
227920
  var init_index = __esm({
227892
227921
  "src/index.ts"() {
227893
227922
  init_provider();
@@ -227929,9 +227958,16 @@ var init_index = __esm({
227929
227958
  IMAGE_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "images");
227930
227959
  AUDIO_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "audio");
227931
227960
  VIDEO_DIR2 = join16(homedir13(), ".openclaw", "blockrun", "videos");
227961
+ BLOCKRUN_PLUGIN_ID = "blockrun-clawrouter";
227932
227962
  plugin = {
227933
- id: "clawrouter",
227934
- name: "ClawRouter",
227963
+ // NOT "clawrouter". OpenClaw bundles its own plugin under that id since the
227964
+ // 2026.7.1 line (vendored at dist/extensions/clawrouter, provider `clawrouter/*`,
227965
+ // CLAWROUTER_API_KEY, clawrouter.openclaw.ai). A duplicate id loses to the
227966
+ // bundled one — "global plugin will be overridden by bundled plugin" — so this
227967
+ // plugin silently never loaded and the local proxy never started. Different
227968
+ // product, different id. See #305.
227969
+ id: BLOCKRUN_PLUGIN_ID,
227970
+ name: "BlockRun ClawRouter",
227935
227971
  description: "Smart LLM router \u2014 55+ models, x402 micropayments, 78% cost savings",
227936
227972
  version: VERSION,
227937
227973
  register(api) {
@@ -228340,13 +228376,18 @@ ${errText}`
228340
228376
  delete config.models.providers.blockrun;
228341
228377
  }
228342
228378
  removeManagedBlockrunMcpServerConfig(config);
228343
- for (const key2 of ["clawrouter", "ClawRouter", "@blockrun/clawrouter"]) {
228379
+ for (const key2 of [
228380
+ BLOCKRUN_PLUGIN_ID,
228381
+ "clawrouter",
228382
+ "ClawRouter",
228383
+ "@blockrun/clawrouter"
228384
+ ]) {
228344
228385
  if (config.plugins?.entries?.[key2]) delete config.plugins.entries[key2];
228345
228386
  if (config.plugins?.installs?.[key2]) delete config.plugins.installs[key2];
228346
228387
  }
228347
228388
  if (Array.isArray(config.plugins?.allow)) {
228348
228389
  config.plugins.allow = config.plugins.allow.filter(
228349
- (p) => p !== "clawrouter" && p !== "ClawRouter" && p !== "@blockrun/clawrouter"
228390
+ (p) => p !== BLOCKRUN_PLUGIN_ID && p !== "clawrouter" && p !== "ClawRouter" && p !== "@blockrun/clawrouter"
228350
228391
  );
228351
228392
  }
228352
228393
  if (config.agents?.defaults?.models) {
@@ -228397,6 +228438,7 @@ init_index();
228397
228438
  export {
228398
228439
  BALANCE_THRESHOLDS,
228399
228440
  BLOCKRUN_MODELS,
228441
+ BLOCKRUN_PLUGIN_ID,
228400
228442
  BalanceMonitor,
228401
228443
  CAIP2_BASE,
228402
228444
  CAIP2_SOLANA_MAINNET,
@@ -1,7 +1,7 @@
1
1
  {
2
- "id": "clawrouter",
3
- "name": "ClawRouter",
4
- "description": "Smart LLM router — 55+ models, x402 micropayments, 92% cost savings. Phone lookup + AI voice calls (Twilio + Bland.ai). Surf crypto-data API (84 endpoints, on-chain SQL, 100M+ labeled wallets) via skill.",
2
+ "id": "blockrun-clawrouter",
3
+ "name": "BlockRun ClawRouter",
4
+ "description": "Smart LLM router \u2014 55+ models, x402 micropayments, 92% cost savings. Phone lookup + AI voice calls (Twilio + Bland.ai). Surf crypto-data API (84 endpoints, on-chain SQL, 100M+ labeled wallets) via skill.",
5
5
  "skills": ["./skills"],
6
6
  "activation": {
7
7
  "onStartup": true
@@ -44,7 +44,7 @@
44
44
  "properties": {
45
45
  "walletKey": {
46
46
  "type": "string",
47
- "description": "EVM wallet private key (0x...). Optional — auto-generated if not set."
47
+ "description": "EVM wallet private key (0x...). Optional \u2014 auto-generated if not set."
48
48
  },
49
49
  "routing": {
50
50
  "type": "object",
@@ -56,7 +56,7 @@
56
56
  "walletKey": {
57
57
  "label": "Wallet Private Key",
58
58
  "sensitive": true,
59
- "placeholder": "0x... (optional — auto-generated)"
59
+ "placeholder": "0x... (optional \u2014 auto-generated)"
60
60
  }
61
61
  }
62
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/clawrouter",
3
- "version": "0.12.264",
3
+ "version": "0.12.265",
4
4
  "description": "Smart LLM router \u2014 save 84% on inference costs. 76 models (7 free), one wallet, x402 micropayments.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,6 +34,7 @@
34
34
  "docs/*.png",
35
35
  "scripts",
36
36
  "!scripts/smoke-dist.mjs",
37
+ "!scripts/sync-brand-numbers.mjs",
37
38
  "skills",
38
39
  "src/top-models.json",
39
40
  "openclaw.plugin.json"
@@ -1,270 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Sync marketing numbers from BlockRun's canonical brand artifact.
4
- *
5
- * This file is copied byte-for-byte into every public repo as
6
- * scripts/sync-brand-numbers.mjs. It is a copy rather than an npm package on
7
- * purpose: a package would mean 37 dependency bumps, and several consuming
8
- * repos have no package.json at all. Zero dependencies, plain Node.
9
- *
10
- * node scripts/sync-brand-numbers.mjs rewrite markers in place
11
- * node scripts/sync-brand-numbers.mjs --check exit 1 on drift, write nothing
12
- * node scripts/sync-brand-numbers.mjs --refresh re-fetch the artifact first
13
- *
14
- * --check NEVER touches the network. PR CI must be deterministic and offline:
15
- * if it fetched, a deploy in progress would fail every repo in the org at once.
16
- * Freshness is the fan-out job's problem, not the pull request's.
17
- *
18
- * Markers look like: <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible -->
19
- * and wrap the WHOLE token, so a badge URL, its alt text and the prose number
20
- * can all regenerate from one key.
21
- */
22
- import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
23
- import { join, relative, extname } from "node:path";
24
-
25
- const ROOT = process.cwd();
26
- const SNAPSHOT = join(ROOT, "brand-numbers.json");
27
- // ORIGIN is tried first because it IS the truth — the mirror can only ever be
28
- // as fresh as the last time someone refreshed it. The mirror exists so a repo
29
- // can still sync while blockrun.ai is down, not to front the origin.
30
- //
31
- // The mirror is awesome-blockrun's own brand-numbers.json: that repo consumes
32
- // the artifact like every other, and its snapshot doubles as the org's copy.
33
- // One file, one role per repo, nothing to keep in step by hand.
34
- const ORIGIN = "https://blockrun.ai/brand/numbers.json";
35
- const MIRROR =
36
- "https://raw.githubusercontent.com/BlockRunAI/awesome-blockrun/main/brand-numbers.json";
37
-
38
- const argv = new Set(process.argv.slice(2));
39
- const check = argv.has("--check");
40
- const refresh = argv.has("--refresh");
41
-
42
- const SKIP_DIRS = new Set([
43
- "node_modules", ".git", "dist", "build", "out", ".next", "coverage",
44
- "vendor", "target", "__pycache__", ".venv", "venv",
45
- ]);
46
- // .txt is here for llms.txt, which is a first-class marketing surface: it is
47
- // what agents read to find out what BlockRun serves. Scanning other .txt files
48
- // costs a read and changes nothing — only files with markers are ever written.
49
- const TEXT_EXT = new Set([".md", ".mdx", ".txt"]);
50
-
51
- /* ── 1. numbers ──────────────────────────────────────────────────────────── */
52
-
53
- async function loadNumbers() {
54
- if (!refresh) {
55
- try {
56
- return JSON.parse(readFileSync(SNAPSHOT, "utf8"));
57
- } catch {
58
- fail(
59
- `no brand-numbers.json in ${ROOT}\n` +
60
- ` run with --refresh once to seed it from ${ORIGIN}`,
61
- );
62
- }
63
- }
64
- for (const url of [ORIGIN, MIRROR]) {
65
- try {
66
- const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
67
- if (!res.ok) continue;
68
- const json = await res.json();
69
- writeFileSync(SNAPSHOT, `${JSON.stringify(json, null, 2)}\n`);
70
- return json;
71
- } catch {
72
- /* try the next source */
73
- }
74
- }
75
- fail(`could not refresh from ${MIRROR} or ${ORIGIN}`);
76
- }
77
-
78
- /** Flatten nested numbers into dotted keys, ignoring $comment / rationale prose. */
79
- function flatten(obj, prefix = "") {
80
- return Object.entries(obj).flatMap(([k, v]) => {
81
- if (k.startsWith("$")) return [];
82
- const key = `${prefix}${k}`;
83
- if (v && typeof v === "object" && !Array.isArray(v)) return flatten(v, `${key}.`);
84
- if (v === null) return [];
85
- return [[key, v]];
86
- });
87
- }
88
-
89
- /* ── 2. renderers ────────────────────────────────────────────────────────── */
90
-
91
- /**
92
- * How a key becomes text. Default is the bare value.
93
- *
94
- * A marker may carry an `@modifier` — `<!-- br:mcp.tools@badge -->` — which
95
- * selects a renderer without changing which number is looked up. The modifier
96
- * is what makes a key reusable: the same mcp.tools appears as a shields badge
97
- * at the top of a README and as a bare "19 tools" in a table two screens down,
98
- * and one marker still keeps the badge URL, its alt text and the label in step.
99
- *
100
- * Renderers are registered under the FULL marker name so a badge's label is
101
- * written out rather than guessed from the key.
102
- */
103
- const badge = (label) => (n) =>
104
- `<img src="https://img.shields.io/badge/${label}-${n}-5B9BF6?style=flat-square&labelColor=0B0A0F" alt="${n} ${label}">`;
105
-
106
- const RENDER = {
107
- "mcp.tools@badge": badge("tools"),
108
- "models.totalVisible@badge": badge("models"),
109
- "models.chatVisible@badge": badge("models"),
110
- };
111
- const render = (marker, value) => (RENDER[marker] ?? String)(value);
112
-
113
- /** `mcp.tools@badge` looks up `mcp.tools`. Unmodified markers are unaffected. */
114
- const keyOf = (marker) => marker.split("@")[0];
115
-
116
- /* ── 3. marker rewriting ─────────────────────────────────────────────────── */
117
-
118
- const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
- const OPEN_ANY = /<!--\s*br:([A-Za-z0-9_.@]+)\s*-->/g;
120
- const CLOSE_ANY = /<!--\s*\/br:([A-Za-z0-9_.@]+)\s*-->/g;
121
-
122
- /** Byte ranges of fenced code blocks — markers inside them are documentation. */
123
- function fencedRanges(text) {
124
- const ranges = [];
125
- const fence = /^(\s*)(`{3,}|~{3,})[^\n]*$/gm;
126
- let open = null;
127
- for (let m; (m = fence.exec(text)); ) {
128
- if (open === null) open = m.index;
129
- else {
130
- ranges.push([open, m.index + m[0].length]);
131
- open = null;
132
- }
133
- }
134
- return ranges;
135
- }
136
-
137
- function syncFile(file, numbers, problems) {
138
- const before = readFileSync(file, "utf8");
139
- const rel = relative(ROOT, file);
140
- const fenced = fencedRanges(before);
141
- const inFence = (i) => fenced.some(([a, b]) => i >= a && i < b);
142
- const known = new Map(numbers);
143
- const used = new Set();
144
-
145
- // Markers actually present, so a file is only ever rewritten for what it uses
146
- // and an @modifier is carried through to the renderer verbatim.
147
- const markers = new Set();
148
- // A marker naming a key that does not exist is an error, never a silent
149
- // no-op: a typo'd marker would otherwise sit there looking synced forever.
150
- for (const [re, shown] of [
151
- [OPEN_ANY, (n) => `<!-- br:${n} -->`],
152
- [CLOSE_ANY, (n) => `<!-- /br:${n} -->`],
153
- ]) {
154
- for (const m of before.matchAll(re)) {
155
- if (inFence(m.index)) continue;
156
- markers.add(m[1]);
157
- if (!known.has(keyOf(m[1]))) problems.push(`${rel}: unknown key ${shown(m[1])}`);
158
- }
159
- }
160
-
161
- let after = before;
162
- for (const marker of markers) {
163
- const key = keyOf(marker);
164
- if (!known.has(key)) continue;
165
- const value = known.get(key);
166
- const pair = new RegExp(
167
- `(<!--\\s*br:${esc(marker)}\\s*-->)([\\s\\S]*?)(<!--\\s*/br:${esc(marker)}\\s*-->)`,
168
- "g",
169
- );
170
- after = after.replace(pair, (whole, open, inner, close, offset) => {
171
- if (inFence(offset)) return whole;
172
- // Nesting means the closing tag of an inner marker would be consumed by
173
- // the outer one. Refuse rather than produce mangled output.
174
- if (/<!--\s*\/?br:/.test(inner)) {
175
- problems.push(`${rel}: nested marker inside br:${marker}`);
176
- return whole;
177
- }
178
- used.add(marker);
179
- return open + render(marker, value) + close;
180
- });
181
-
182
- // An opening tag with no partner silently swallows the rest of the file on
183
- // a naive regex, so catch it explicitly.
184
- const opens = [...before.matchAll(new RegExp(`<!--\\s*br:${esc(marker)}\\s*-->`, "g"))]
185
- .filter((m) => !inFence(m.index)).length;
186
- const closes = [...before.matchAll(new RegExp(`<!--\\s*/br:${esc(marker)}\\s*-->`, "g"))]
187
- .filter((m) => !inFence(m.index)).length;
188
- if (opens !== closes) problems.push(`${rel}: unbalanced marker br:${marker} (${opens} open, ${closes} close)`);
189
- }
190
-
191
- return { before, after, changed: before !== after, used };
192
- }
193
-
194
- /* ── 4. walk ─────────────────────────────────────────────────────────────── */
195
-
196
- function* walk(dir) {
197
- for (const name of readdirSync(dir)) {
198
- if (SKIP_DIRS.has(name)) continue;
199
- const p = join(dir, name);
200
- // lstat, not stat: a symlinked directory is reached by its real path or not
201
- // at all. blockrun's docs/ -> awesome-blockrun/docs is exactly the case that
202
- // matters — following it would edit a submodule's files behind the skip
203
- // below, and a link pointing at an ancestor would recurse forever.
204
- const s = lstatSync(p);
205
- if (s.isSymbolicLink()) continue;
206
- if (s.isDirectory()) {
207
- // A nested repo is a submodule or vendored checkout: it carries its own
208
- // brand-numbers.json and syncs itself. Rewriting its markers from THIS
209
- // repo's snapshot would dirty a submodule nobody asked us to touch, and
210
- // would report drift that belongs to another repo's CI.
211
- if (existsSync(join(p, ".git"))) continue;
212
- yield* walk(p);
213
- } else if (TEXT_EXT.has(extname(name))) yield p;
214
- }
215
- }
216
-
217
- function fail(msg) {
218
- console.error(`brand-numbers: ${msg}`);
219
- process.exit(1);
220
- }
221
-
222
- /* ── 5. run ──────────────────────────────────────────────────────────────── */
223
-
224
- const raw = await loadNumbers();
225
- const numbers = flatten(raw);
226
- const problems = [];
227
- const drifted = [];
228
- const everUsed = new Set();
229
-
230
- for (const file of walk(ROOT)) {
231
- const { before, after, changed, used } = syncFile(file, numbers, problems);
232
- used.forEach((k) => everUsed.add(k));
233
- if (!changed) continue;
234
- drifted.push({ file: relative(ROOT, file), before, after });
235
- if (!check) writeFileSync(file, after);
236
- }
237
-
238
- if (problems.length) {
239
- for (const p of problems) console.error(` ${p}`);
240
- fail(`${problems.length} marker problem(s)`);
241
- }
242
-
243
- if (check) {
244
- if (drifted.length === 0) {
245
- console.log(`brand-numbers: up to date (${everUsed.size} keys in use)`);
246
- process.exit(0);
247
- }
248
- console.error("brand-numbers: these files disagree with brand-numbers.json\n");
249
- for (const { file, before, after } of drifted) {
250
- const b = before.split("\n");
251
- const a = after.split("\n");
252
- for (let i = 0; i < Math.max(b.length, a.length); i++) {
253
- if (b[i] !== a[i]) {
254
- console.error(` ${file}:${i + 1}`);
255
- console.error(` - ${(b[i] ?? "").trim()}`);
256
- console.error(` + ${(a[i] ?? "").trim()}`);
257
- }
258
- }
259
- }
260
- console.error(
261
- "\n fix with: node scripts/sync-brand-numbers.mjs && git commit -am 'chore: sync brand numbers'",
262
- );
263
- process.exit(1);
264
- }
265
-
266
- console.log(
267
- drifted.length
268
- ? `brand-numbers: updated ${drifted.length} file(s)`
269
- : `brand-numbers: already up to date (${everUsed.size} keys in use)`,
270
- );