@trazum/cli 1.45.0 → 1.46.0

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/index.js CHANGED
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { open, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { join, resolve as resolvePath } from 'node:path';
4
4
  import { gunzipSync } from 'node:zlib';
5
- import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
5
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, matchLocale, proposeInit, MIN_RATE_DAYS, parseConfig, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
6
6
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
7
7
  import { dayOf, formatGap, median, spanDays } from './time.js';
8
8
  // Everything that reads the filesystem, on its own entry point so the web
9
9
  // bundle cannot reach it. See packages/core/src/node.ts.
10
10
  import { CONFIG_FILENAME, DEFAULT_EXTENSIONS, budgetFor, BUNDLED_CATALOGUE, SAFE_FETCH_INIT, applyPricingOverlay, catalogueFromOverlay, checkedEndpoint, openrouterOverlay, detectHost, loadConfig, walkPrompts, } from '@trazum/core/node';
11
11
  import { contentAt, gitAvailable, namesByRevision, pathInRepository, repositoryRoot, revisionsFor, } from './git.js';
12
- import { fetchProviderUsage } from './connect.js';
12
+ import { fetchProviderUsage, findCredential } from './connect.js';
13
13
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
14
14
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
15
15
  import { WATCH_STATE_VERSION, checkWebhook, postWebhook, readWatchState, writeWatchState, } from './watch-run.js';
16
- import { detectLocale, getCliMessages } from './i18n/index.js';
16
+ import { LOCALE_ENV_VARS, detectLocale, getCliMessages } from './i18n/index.js';
17
17
  import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
18
18
  // --------------------------------------------------------------------------
19
19
  // Presentation
@@ -309,6 +309,7 @@ const COMMAND_FLAGS = {
309
309
  diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
310
310
  models: [],
311
311
  rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
312
+ init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
312
313
  where: [],
313
314
  rules: [],
314
315
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -1007,6 +1008,378 @@ async function commandWhere(args, config, pricing, t) {
1007
1008
  console.log(` ${getModel(effective).displayName} ${c.dim(reason)}`);
1008
1009
  console.log();
1009
1010
  }
1011
+ /**
1012
+ * Where `init` looks for a usage log before it gives up and says so.
1013
+ *
1014
+ * A short list of the names people actually use, checked in order — not a
1015
+ * glob over the whole tree. A first run that finds a log by searching two
1016
+ * thousand directories has spent the patience it was given, and a log found
1017
+ * in `vendor/fixtures/` is more likely to be somebody's test data than their
1018
+ * bill.
1019
+ */
1020
+ const INIT_LOG_CANDIDATES = [
1021
+ 'usage.jsonl',
1022
+ 'usage.ndjson',
1023
+ 'usage.log',
1024
+ 'logs/usage.jsonl',
1025
+ 'logs',
1026
+ '.trazum/usage.jsonl',
1027
+ ];
1028
+ /**
1029
+ * Extensions worth reading for a provider sighting.
1030
+ *
1031
+ * `SOURCE_EXTENSIONS`, the same list `rank` and `doctor` walk, rather than a
1032
+ * second copy that drifts — a language added for extraction is a language
1033
+ * `init` should be able to detect a provider in, and one list is how that
1034
+ * stays true. Documentation is deliberately not on it: a `.md` file quoting
1035
+ * `from 'openai'` inside a code fence would be read as evidence, and `where`
1036
+ * answers for a file somebody named while this answers for a repository
1037
+ * nobody has vouched for.
1038
+ */
1039
+ /** How many source files, and how large each may be. Both reported when they bite. */
1040
+ const INIT_MAX_SOURCE_FILES = 400;
1041
+ const INIT_MAX_SOURCE_BYTES = 256 * 1024;
1042
+ /**
1043
+ * The first run, printed.
1044
+ *
1045
+ * **The arithmetic comes before the figure**, everywhere below. A tool that
1046
+ * opens with a dollar amount nobody can check gets closed, and the reader has
1047
+ * no reason yet to believe anything this command says — so the headline shows
1048
+ * the calls, the model and the rate it is being compared against, and only
1049
+ * then the money.
1050
+ */
1051
+ function renderInit(proposal, ctx) {
1052
+ const { t } = ctx;
1053
+ const n = (value) => value.toLocaleString(t.numberLocale);
1054
+ console.log();
1055
+ console.log(c.bold(t.init.heading()));
1056
+ console.log();
1057
+ // 1. Where this is running.
1058
+ console.log(` ${t.init.host(ctx.host.displayName)}`);
1059
+ if (ctx.host.billing === 'subscription') {
1060
+ console.log(` ${c.yellow(t.where.subscription(ctx.host.displayName))}`);
1061
+ }
1062
+ // 2. The prompts.
1063
+ console.log(` ${ctx.prompts.files.length === 0 ? c.dim(t.init.noPrompts()) : t.init.prompts(ctx.prompts.files.length)}`);
1064
+ if (ctx.truncated)
1065
+ console.log(` ${c.dim(t.init.sourcesTruncated(INIT_MAX_SOURCE_FILES))}`);
1066
+ // 3. The usage, or the two ways there is none.
1067
+ if (ctx.unreadable !== null) {
1068
+ console.log(` ${c.red(t.init.usageUnreadable(ctx.unreadable.where, ctx.unreadable.because))}`);
1069
+ }
1070
+ else if (ctx.usage.length === 0) {
1071
+ console.log(` ${c.dim(t.init.noUsage())}`);
1072
+ }
1073
+ else {
1074
+ for (const sighting of ctx.usage) {
1075
+ console.log(` ${t.init.usageFound(sighting.kind, sighting.where)}`);
1076
+ }
1077
+ }
1078
+ console.log();
1079
+ // 4. What the config would say, and what it will not.
1080
+ console.log(c.bold(t.init.configHeading()));
1081
+ if (proposal.justified.length === 0) {
1082
+ console.log(` ${c.dim(t.init.nothingJustified())}`);
1083
+ }
1084
+ for (const why of proposal.justified) {
1085
+ console.log(` ${c.green('+')} ${c.bold(why.key)} ${initJustification(why, t)}`);
1086
+ }
1087
+ for (const decline of proposal.declined) {
1088
+ console.log(` ${c.dim('·')} ${c.dim(decline.key)} ${c.dim(initDecline(decline, t))}`);
1089
+ }
1090
+ if (proposal.overwrites !== null && proposal.overwrites.keys.length > 0) {
1091
+ console.log();
1092
+ console.log(` ${c.yellow(t.init.wouldOverwrite(proposal.overwrites.keys.join(', ')))}`);
1093
+ }
1094
+ console.log();
1095
+ // 5. The single most valuable thing found — arithmetic first.
1096
+ console.log(c.bold(t.init.findingHeading()));
1097
+ if (proposal.headline === null) {
1098
+ console.log(` ${c.dim(t.init.noFinding(proposal.noHeadline ?? 'nothing-measured'))}`);
1099
+ console.log();
1100
+ return;
1101
+ }
1102
+ const { slice, lever, savingUsd, days } = proposal.headline;
1103
+ console.log(` ${t.init.findingCalls(n(slice.calls), slice.label, slice.modelName, days)}`);
1104
+ console.log(` ${t.init.findingSpent(slice.spentUsd.toFixed(2))}`);
1105
+ if (lever !== 'batch' && slice.route !== null) {
1106
+ console.log(` ${t.init.findingRoute(slice.route.candidate.displayName)}`);
1107
+ }
1108
+ if (lever !== 'route' && slice.batch !== null) {
1109
+ console.log(` ${t.init.findingBatch()}`);
1110
+ }
1111
+ console.log(` ${c.bold(t.init.findingTotal(savingUsd.toFixed(2), days))}`);
1112
+ console.log(` ${c.dim(t.init.findingNext())}`);
1113
+ console.log();
1114
+ }
1115
+ /** Why a key was written, in one line a person reads. */
1116
+ function initJustification(why, t) {
1117
+ switch (why.key) {
1118
+ case 'locale':
1119
+ return c.dim(t.init.whyLocale(why.value));
1120
+ case 'extensions':
1121
+ return c.dim(t.init.whyExtensions(why.value.join(' '), why.files));
1122
+ case 'usage.model':
1123
+ return c.dim(why.from === 'measured'
1124
+ ? t.init.whyModelMeasured(why.value, Math.round(why.share * 100))
1125
+ : t.init.whyModelSource(why.value, why.file, why.line));
1126
+ case 'usage.callsPerMonth':
1127
+ return c.dim(t.init.whyCalls(why.value, why.calls, why.days));
1128
+ case 'usage.avgOutputTokens':
1129
+ return c.dim(t.init.whyOutput(why.value, why.outputTokens, why.calls));
1130
+ case 'usage.cacheHitRate':
1131
+ return c.dim(t.init.whyCache(why.value, why.cacheReadTokens, why.inputTokens));
1132
+ }
1133
+ }
1134
+ /** Why a key was not written, and what would settle it. */
1135
+ function initDecline(decline, t) {
1136
+ switch (decline.why) {
1137
+ case 'no-evidence':
1138
+ return t.init.noModelEvidence();
1139
+ case 'conflicting-evidence':
1140
+ return t.init.modelConflict(decline.files.join(', '));
1141
+ case 'provider-only':
1142
+ return t.init.modelProviderOnly(decline.provider, decline.file);
1143
+ case 'nothing-measured':
1144
+ return t.init.nothingMeasured();
1145
+ case 'window-too-short':
1146
+ return t.init.windowTooShort(decline.days, MIN_RATE_DAYS);
1147
+ case 'undated-calls':
1148
+ return t.init.undatedCalls(decline.undated, decline.calls);
1149
+ case 'not-recorded':
1150
+ return t.init.cacheNotRecorded();
1151
+ case 'only-you-know':
1152
+ return t.init.batchOnlyYouKnow();
1153
+ case 'unprovable':
1154
+ return t.init.labelsUnprovable(decline.labels);
1155
+ case 'a-budget-is-a-policy':
1156
+ return decline.measuredUsd === null
1157
+ ? t.init.budgetIsPolicy()
1158
+ : t.init.budgetIsPolicyMeasured(decline.measuredUsd.toFixed(2), decline.days ?? 0);
1159
+ }
1160
+ }
1161
+ /**
1162
+ * `trazum init [dir]` — the first five minutes.
1163
+ *
1164
+ * The floor, not the ceiling. Everything else in this tool assumes you know
1165
+ * which of twenty-two commands answers your question; this one assumes you
1166
+ * have just typed `npx @trazum/cli` and have thirty seconds of patience left.
1167
+ *
1168
+ * It is a **detection, not a wizard**. Nothing is asked. Each step reports
1169
+ * what it found and moves on, and the only decision is whether to write the
1170
+ * file — which `--yes` skips and `--dry-run` refuses. A first run that
1171
+ * interrogates somebody is a first run that gets abandoned halfway.
1172
+ *
1173
+ * The judgement lives in `proposeInit`, in the core, with no filesystem
1174
+ * anywhere near it. This function's whole job is to *look*: walk for prompts,
1175
+ * read a few source files, notice a log or a credential, and hand the lot over
1176
+ * as data. That split is why `--dry-run` cannot drift from the real thing —
1177
+ * they are the same call, and one of them stops before `writeFile`.
1178
+ */
1179
+ async function commandInit(args, config, pricing, t) {
1180
+ const root = args.positional[0] ?? '.';
1181
+ const dryRun = args.flags.get('dry-run') === true;
1182
+ const asJson = args.flags.get('json') === true;
1183
+ // --- what is here -------------------------------------------------------
1184
+ const host = detectHost();
1185
+ const prompts = await walkPrompts(root, {
1186
+ extensions: config.extensions ?? DEFAULT_EXTENSIONS,
1187
+ });
1188
+ /**
1189
+ * Source files, read only to be asked which provider they call.
1190
+ *
1191
+ * Capped hard and deliberately low. `where` reads one file because somebody
1192
+ * named it; this reads whatever is lying around, and a first run that spends
1193
+ * forty seconds walking a monorepo has already lost. The cap is reported
1194
+ * when it bites, because "no provider found" and "stopped looking" are
1195
+ * different sentences.
1196
+ */
1197
+ const sourceWalk = await walkPrompts(root, {
1198
+ extensions: SOURCE_EXTENSIONS,
1199
+ maxFiles: INIT_MAX_SOURCE_FILES,
1200
+ });
1201
+ const sightings = [];
1202
+ for (const relative of sourceWalk.files) {
1203
+ const path = join(root, relative);
1204
+ let source;
1205
+ /**
1206
+ * Measured and read through **one open handle**, not by path twice.
1207
+ *
1208
+ * A bundle or a lockfile named `.js` is not worth reading, and reading it
1209
+ * is how this command becomes slow on exactly the repositories that need
1210
+ * it most — so the size is checked first. Checking it with `stat(path)`
1211
+ * and then reading `path` is two lookups of the same name, and what
1212
+ * arrives the second time need not be what was measured the first: the
1213
+ * bound would be enforced against a file that is no longer there. One
1214
+ * handle, stat'ed and read, is the same inode by construction.
1215
+ */
1216
+ let handle;
1217
+ try {
1218
+ handle = await open(path, 'r');
1219
+ }
1220
+ catch {
1221
+ continue;
1222
+ }
1223
+ try {
1224
+ const info = await handle.stat();
1225
+ if (info.size > INIT_MAX_SOURCE_BYTES)
1226
+ continue;
1227
+ source = await handle.readFile('utf8');
1228
+ }
1229
+ catch {
1230
+ continue;
1231
+ }
1232
+ finally {
1233
+ await handle.close();
1234
+ }
1235
+ const detection = detectFromSource(source, { models: pricing.models });
1236
+ if (detection.provider !== null || detection.model !== null || detection.conflicts.length > 0) {
1237
+ sightings.push({ file: relative, detection });
1238
+ }
1239
+ }
1240
+ // --- where the usage is, if it is anywhere ------------------------------
1241
+ const usage = [];
1242
+ for (const candidate of INIT_LOG_CANDIDATES) {
1243
+ /**
1244
+ * An existence check and nothing more — what is recorded is the *name*
1245
+ * that was tried, and whether it is a file or a directory. Anything read
1246
+ * later is opened then, on its own terms, so there is no measurement here
1247
+ * for a later read to disagree with.
1248
+ */
1249
+ try {
1250
+ const info = await stat(join(root, candidate));
1251
+ usage.push({
1252
+ kind: info.isDirectory() ? 'log-directory' : 'log-file',
1253
+ where: candidate,
1254
+ provider: null,
1255
+ });
1256
+ }
1257
+ catch {
1258
+ // Absent is the common case and not an error.
1259
+ }
1260
+ }
1261
+ try {
1262
+ const info = await stat(join(root, STORE_DIR));
1263
+ if (info.isDirectory()) {
1264
+ usage.push({ kind: 'store', where: STORE_DIR, provider: null });
1265
+ }
1266
+ }
1267
+ catch {
1268
+ // No store yet.
1269
+ }
1270
+ /**
1271
+ * A credential is named by its **variable**, never read.
1272
+ *
1273
+ * `findCredential` returns the value as well because the connector needs it;
1274
+ * this takes the name and drops the rest on the floor. A first-run summary
1275
+ * is the single most likely output in this product to be pasted into a chat
1276
+ * window, and the rule that has held since 1.41 holds here.
1277
+ */
1278
+ for (const connector of CONNECTORS) {
1279
+ const found = findCredential(connector, process.env);
1280
+ if (found !== null) {
1281
+ usage.push({
1282
+ kind: 'connector-credential',
1283
+ where: found.source.variable,
1284
+ provider: connector.id,
1285
+ });
1286
+ }
1287
+ }
1288
+ // --- read what can be read ----------------------------------------------
1289
+ let measured = null;
1290
+ let unreadable = null;
1291
+ const readable = usage.find((u) => u.kind === 'log-file' || u.kind === 'log-directory');
1292
+ if (readable !== undefined) {
1293
+ try {
1294
+ const files = readable.kind === 'log-file'
1295
+ ? [join(root, readable.where)]
1296
+ : (await readdir(join(root, readable.where)))
1297
+ .filter((name) => LOG_EXTENSIONS.some((extension) => name.endsWith(extension)))
1298
+ .sort()
1299
+ .map((name) => join(root, readable.where, name));
1300
+ if (files.length > 0) {
1301
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
1302
+ measured = profileUsage(texts.join('\n'), { catalogue: pricing });
1303
+ }
1304
+ }
1305
+ catch (error) {
1306
+ // Named, never swallowed. A log that is there and cannot be read is the
1307
+ // single most useful thing this command can tell somebody, and treating
1308
+ // it as "no usage found" would send them to configure a connector they
1309
+ // do not need.
1310
+ unreadable = {
1311
+ where: readable.where,
1312
+ because: error instanceof Error ? error.message : String(error),
1313
+ };
1314
+ }
1315
+ }
1316
+ // --- the config already there -------------------------------------------
1317
+ const configPath = join(root, CONFIG_FILENAME);
1318
+ let existing = null;
1319
+ try {
1320
+ existing = { path: configPath, config: parseConfig(await readFile(configPath, 'utf8'), configPath) };
1321
+ }
1322
+ catch {
1323
+ // Absent, or unparseable. Either way there is nothing to compare against,
1324
+ // and `init` refuses to overwrite below rather than reasoning about it.
1325
+ }
1326
+ let unparseable = false;
1327
+ if (existing === null) {
1328
+ try {
1329
+ await stat(configPath);
1330
+ unparseable = true;
1331
+ }
1332
+ catch {
1333
+ // Genuinely absent.
1334
+ }
1335
+ }
1336
+ const askedLocale = matchLocale(LOCALE_ENV_VARS.map((name) => process.env[name]).find((value) => matchLocale(value)));
1337
+ const proposal = proposeInit({
1338
+ host,
1339
+ sightings,
1340
+ promptFiles: prompts.files,
1341
+ usage,
1342
+ measured,
1343
+ locale: askedLocale ?? null,
1344
+ existing,
1345
+ }, { catalogue: pricing });
1346
+ if (asJson) {
1347
+ console.log(JSON.stringify({ ...proposal, unreadable, truncated: sourceWalk.truncated }, null, 2));
1348
+ return;
1349
+ }
1350
+ renderInit(proposal, { host, prompts, usage, unreadable, truncated: sourceWalk.truncated, t, pricing });
1351
+ // --- writing it ---------------------------------------------------------
1352
+ //
1353
+ // Three ways this ends and they are kept apart: nothing to write, refused to
1354
+ // overwrite, written. "Nothing happened" with no reason is the output that
1355
+ // makes somebody run the command twice.
1356
+ if (Object.keys(proposal.config).length === 0) {
1357
+ console.log(c.dim(t.init.nothingToWrite()));
1358
+ console.log();
1359
+ return;
1360
+ }
1361
+ const body = `${JSON.stringify(proposal.config, null, 2)}\n`;
1362
+ if (dryRun) {
1363
+ console.log(c.bold(t.init.wouldWrite(configPath)));
1364
+ console.log();
1365
+ console.log(body.trimEnd());
1366
+ console.log();
1367
+ return;
1368
+ }
1369
+ if (unparseable) {
1370
+ console.log(c.yellow(t.init.existingUnparseable(configPath)));
1371
+ console.log();
1372
+ return;
1373
+ }
1374
+ if (existing !== null && args.flags.get('yes') !== true) {
1375
+ console.log(c.yellow(t.init.existingRefused(configPath)));
1376
+ console.log();
1377
+ return;
1378
+ }
1379
+ await writeFile(configPath, body, 'utf8');
1380
+ console.log(c.green(t.init.wrote(configPath)));
1381
+ console.log();
1382
+ }
1010
1383
  function commandModels(t, pricing) {
1011
1384
  const n = (value) => value.toLocaleString(t.numberLocale);
1012
1385
  const col = t.models.columns;
@@ -5651,7 +6024,35 @@ async function main() {
5651
6024
  // flag validation so a typo is reported before any file is touched. An
5652
6025
  // invalid config throws here rather than quietly reverting to defaults —
5653
6026
  // "defaults" for a budget means "no budget", which means a green build.
5654
- const loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
6027
+ /**
6028
+ * `init` is the exception, and finding out why was worth the release.
6029
+ *
6030
+ * A malformed `trazum.config.json` throws here — correctly, for every other
6031
+ * command, because "defaults" for a budget means "no budget" and a silent
6032
+ * revert to defaults is a green build that should have been red. But `init`
6033
+ * is the command somebody runs *because* their setup is broken, and it was
6034
+ * the one command a broken setup could stop from running. The refusal to
6035
+ * overwrite an unparseable config, written two hours earlier in this same
6036
+ * release, was unreachable code standing behind a throw.
6037
+ *
6038
+ * So `init` loads the config the same way and survives the failure, with
6039
+ * nothing carried forward: no keys, no budgets, no locale. It then refuses
6040
+ * to write over the file it could not read, and says so.
6041
+ */
6042
+ let loaded;
6043
+ try {
6044
+ loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
6045
+ }
6046
+ catch (error) {
6047
+ if (args.command !== 'init')
6048
+ throw error;
6049
+ loaded = {
6050
+ config: {},
6051
+ path: null,
6052
+ pricing: BUNDLED_CATALOGUE,
6053
+ pricingPath: null,
6054
+ };
6055
+ }
5655
6056
  const { config } = loaded;
5656
6057
  const pricing = await pricingFor(args, loaded, t);
5657
6058
  // The config only gets to choose the locale when nothing more explicit did.
@@ -5708,6 +6109,9 @@ async function main() {
5708
6109
  case 'models':
5709
6110
  commandModels(t, pricing);
5710
6111
  break;
6112
+ case 'init':
6113
+ await commandInit(args, config, pricing, t);
6114
+ break;
5711
6115
  case 'where':
5712
6116
  await commandWhere(args, config, pricing, t);
5713
6117
  break;