@trazum/cli 1.45.0 → 1.47.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/src/index.ts CHANGED
@@ -1,5 +1,5 @@
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
5
 
@@ -36,6 +36,11 @@ import {
36
36
  countTokensAnthropic,
37
37
  DEFAULT_USAGE,
38
38
  detectFromSource,
39
+ matchLocale,
40
+ parsePlanDocument,
41
+ proposeInit,
42
+ MIN_RATE_DAYS,
43
+ parseConfig,
39
44
  coverageDrift,
40
45
  driversBetween,
41
46
  explainGateFailure,
@@ -119,6 +124,15 @@ import type {
119
124
  SuggestResult,
120
125
  UsageProfile,
121
126
  } from '@trazum/core';
127
+ import type {
128
+ UsageProfileReport,
129
+ InitDecline,
130
+ InitJustification,
131
+ InitObservations,
132
+ InitProposal,
133
+ ProviderSighting,
134
+ UsageSighting,
135
+ } from '@trazum/core';
122
136
  // Everything that reads the filesystem, on its own entry point so the web
123
137
  // bundle cannot reach it. See packages/core/src/node.ts.
124
138
  import {
@@ -135,7 +149,13 @@ import {
135
149
  loadConfig,
136
150
  walkPrompts,
137
151
  } from '@trazum/core/node';
138
- import type { HostEnvironment, PricingCatalogue, ResolvedBudget, TrazumConfig } from '@trazum/core/node';
152
+ import type {
153
+ HostEnvironment,
154
+ LoadedConfig,
155
+ PricingCatalogue,
156
+ ResolvedBudget,
157
+ TrazumConfig,
158
+ } from '@trazum/core/node';
139
159
 
140
160
  import {
141
161
  contentAt,
@@ -146,7 +166,7 @@ import {
146
166
  revisionsFor,
147
167
  } from './git.js';
148
168
  import type { Revision } from './git.js';
149
- import { fetchProviderUsage } from './connect.js';
169
+ import { fetchProviderUsage, findCredential } from './connect.js';
150
170
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
151
171
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
152
172
  import {
@@ -156,7 +176,7 @@ import {
156
176
  readWatchState,
157
177
  writeWatchState,
158
178
  } from './watch-run.js';
159
- import { detectLocale, getCliMessages } from './i18n/index.js';
179
+ import { LOCALE_ENV_VARS, detectLocale, getCliMessages } from './i18n/index.js';
160
180
  import {
161
181
  MAX_SUMMARY_CHARS,
162
182
  fitWithin,
@@ -523,6 +543,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
523
543
  diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
524
544
  models: [],
525
545
  rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
546
+ init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
526
547
  where: [],
527
548
  rules: [],
528
549
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -1415,6 +1436,414 @@ async function commandWhere(
1415
1436
  console.log();
1416
1437
  }
1417
1438
 
1439
+ /**
1440
+ * Where `init` looks for a usage log before it gives up and says so.
1441
+ *
1442
+ * A short list of the names people actually use, checked in order — not a
1443
+ * glob over the whole tree. A first run that finds a log by searching two
1444
+ * thousand directories has spent the patience it was given, and a log found
1445
+ * in `vendor/fixtures/` is more likely to be somebody's test data than their
1446
+ * bill.
1447
+ */
1448
+ const INIT_LOG_CANDIDATES = [
1449
+ 'usage.jsonl',
1450
+ 'usage.ndjson',
1451
+ 'usage.log',
1452
+ 'logs/usage.jsonl',
1453
+ 'logs',
1454
+ '.trazum/usage.jsonl',
1455
+ ];
1456
+
1457
+ /**
1458
+ * Extensions worth reading for a provider sighting.
1459
+ *
1460
+ * `SOURCE_EXTENSIONS`, the same list `rank` and `doctor` walk, rather than a
1461
+ * second copy that drifts — a language added for extraction is a language
1462
+ * `init` should be able to detect a provider in, and one list is how that
1463
+ * stays true. Documentation is deliberately not on it: a `.md` file quoting
1464
+ * `from 'openai'` inside a code fence would be read as evidence, and `where`
1465
+ * answers for a file somebody named while this answers for a repository
1466
+ * nobody has vouched for.
1467
+ */
1468
+
1469
+ /** How many source files, and how large each may be. Both reported when they bite. */
1470
+ const INIT_MAX_SOURCE_FILES = 400;
1471
+ const INIT_MAX_SOURCE_BYTES = 256 * 1024;
1472
+
1473
+ interface InitRenderContext {
1474
+ host: HostEnvironment;
1475
+ prompts: { files: string[]; truncated: boolean };
1476
+ usage: UsageSighting[];
1477
+ unreadable: { where: string; because: string } | null;
1478
+ truncated: boolean;
1479
+ t: CliMessages;
1480
+ pricing: PricingCatalogue;
1481
+ }
1482
+
1483
+ /**
1484
+ * The first run, printed.
1485
+ *
1486
+ * **The arithmetic comes before the figure**, everywhere below. A tool that
1487
+ * opens with a dollar amount nobody can check gets closed, and the reader has
1488
+ * no reason yet to believe anything this command says — so the headline shows
1489
+ * the calls, the model and the rate it is being compared against, and only
1490
+ * then the money.
1491
+ */
1492
+ function renderInit(proposal: InitProposal, ctx: InitRenderContext): void {
1493
+ const { t } = ctx;
1494
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
1495
+
1496
+ console.log();
1497
+ console.log(c.bold(t.init.heading()));
1498
+ console.log();
1499
+
1500
+ // 1. Where this is running.
1501
+ console.log(` ${t.init.host(ctx.host.displayName)}`);
1502
+ if (ctx.host.billing === 'subscription') {
1503
+ console.log(` ${c.yellow(t.where.subscription(ctx.host.displayName))}`);
1504
+ }
1505
+
1506
+ // 2. The prompts.
1507
+ console.log(
1508
+ ` ${ctx.prompts.files.length === 0 ? c.dim(t.init.noPrompts()) : t.init.prompts(ctx.prompts.files.length)}`,
1509
+ );
1510
+ if (ctx.truncated) console.log(` ${c.dim(t.init.sourcesTruncated(INIT_MAX_SOURCE_FILES))}`);
1511
+
1512
+ // 3. The usage, or the two ways there is none.
1513
+ if (ctx.unreadable !== null) {
1514
+ console.log(` ${c.red(t.init.usageUnreadable(ctx.unreadable.where, ctx.unreadable.because))}`);
1515
+ } else if (ctx.usage.length === 0) {
1516
+ console.log(` ${c.dim(t.init.noUsage())}`);
1517
+ } else {
1518
+ for (const sighting of ctx.usage) {
1519
+ console.log(` ${t.init.usageFound(sighting.kind, sighting.where)}`);
1520
+ }
1521
+ }
1522
+ console.log();
1523
+
1524
+ // 4. What the config would say, and what it will not.
1525
+ console.log(c.bold(t.init.configHeading()));
1526
+ if (proposal.justified.length === 0) {
1527
+ console.log(` ${c.dim(t.init.nothingJustified())}`);
1528
+ }
1529
+ for (const why of proposal.justified) {
1530
+ console.log(` ${c.green('+')} ${c.bold(why.key)} ${initJustification(why, t)}`);
1531
+ }
1532
+ for (const decline of proposal.declined) {
1533
+ console.log(` ${c.dim('·')} ${c.dim(decline.key)} ${c.dim(initDecline(decline, t))}`);
1534
+ }
1535
+ if (proposal.overwrites !== null && proposal.overwrites.keys.length > 0) {
1536
+ console.log();
1537
+ console.log(` ${c.yellow(t.init.wouldOverwrite(proposal.overwrites.keys.join(', ')))}`);
1538
+ }
1539
+ console.log();
1540
+
1541
+ // 5. The single most valuable thing found — arithmetic first.
1542
+ console.log(c.bold(t.init.findingHeading()));
1543
+ if (proposal.headline === null) {
1544
+ console.log(` ${c.dim(t.init.noFinding(proposal.noHeadline ?? 'nothing-measured'))}`);
1545
+ console.log();
1546
+ return;
1547
+ }
1548
+ const { slice, lever, savingUsd, days } = proposal.headline;
1549
+ console.log(` ${t.init.findingCalls(n(slice.calls), slice.label, slice.modelName, days)}`);
1550
+ console.log(` ${t.init.findingSpent(slice.spentUsd.toFixed(2))}`);
1551
+ if (lever !== 'batch' && slice.route !== null) {
1552
+ console.log(` ${t.init.findingRoute(slice.route.candidate.displayName)}`);
1553
+ }
1554
+ if (lever !== 'route' && slice.batch !== null) {
1555
+ console.log(` ${t.init.findingBatch()}`);
1556
+ }
1557
+ console.log(` ${c.bold(t.init.findingTotal(savingUsd.toFixed(2), days))}`);
1558
+ console.log(` ${c.dim(t.init.findingNext())}`);
1559
+ console.log();
1560
+ }
1561
+
1562
+ /** Why a key was written, in one line a person reads. */
1563
+ function initJustification(why: InitJustification, t: CliMessages): string {
1564
+ switch (why.key) {
1565
+ case 'locale':
1566
+ return c.dim(t.init.whyLocale(why.value));
1567
+ case 'extensions':
1568
+ return c.dim(t.init.whyExtensions(why.value.join(' '), why.files));
1569
+ case 'usage.model':
1570
+ return c.dim(
1571
+ why.from === 'measured'
1572
+ ? t.init.whyModelMeasured(why.value, Math.round(why.share * 100))
1573
+ : t.init.whyModelSource(why.value, why.file, why.line),
1574
+ );
1575
+ case 'usage.callsPerMonth':
1576
+ return c.dim(t.init.whyCalls(why.value, why.calls, why.days));
1577
+ case 'usage.avgOutputTokens':
1578
+ return c.dim(t.init.whyOutput(why.value, why.outputTokens, why.calls));
1579
+ case 'usage.cacheHitRate':
1580
+ return c.dim(t.init.whyCache(why.value, why.cacheReadTokens, why.inputTokens));
1581
+ }
1582
+ }
1583
+
1584
+ /** Why a key was not written, and what would settle it. */
1585
+ function initDecline(decline: InitDecline, t: CliMessages): string {
1586
+ switch (decline.why) {
1587
+ case 'no-evidence':
1588
+ return t.init.noModelEvidence();
1589
+ case 'conflicting-evidence':
1590
+ return t.init.modelConflict(decline.files.join(', '));
1591
+ case 'provider-only':
1592
+ return t.init.modelProviderOnly(decline.provider, decline.file);
1593
+ case 'nothing-measured':
1594
+ return t.init.nothingMeasured();
1595
+ case 'window-too-short':
1596
+ return t.init.windowTooShort(decline.days, MIN_RATE_DAYS);
1597
+ case 'undated-calls':
1598
+ return t.init.undatedCalls(decline.undated, decline.calls);
1599
+ case 'not-recorded':
1600
+ return t.init.cacheNotRecorded();
1601
+ case 'only-you-know':
1602
+ return t.init.batchOnlyYouKnow();
1603
+ case 'unprovable':
1604
+ return t.init.labelsUnprovable(decline.labels);
1605
+ case 'a-budget-is-a-policy':
1606
+ return decline.measuredUsd === null
1607
+ ? t.init.budgetIsPolicy()
1608
+ : t.init.budgetIsPolicyMeasured(decline.measuredUsd.toFixed(2), decline.days ?? 0);
1609
+ }
1610
+ }
1611
+
1612
+ /**
1613
+ * `trazum init [dir]` — the first five minutes.
1614
+ *
1615
+ * The floor, not the ceiling. Everything else in this tool assumes you know
1616
+ * which of twenty-two commands answers your question; this one assumes you
1617
+ * have just typed `npx @trazum/cli` and have thirty seconds of patience left.
1618
+ *
1619
+ * It is a **detection, not a wizard**. Nothing is asked. Each step reports
1620
+ * what it found and moves on, and the only decision is whether to write the
1621
+ * file — which `--yes` skips and `--dry-run` refuses. A first run that
1622
+ * interrogates somebody is a first run that gets abandoned halfway.
1623
+ *
1624
+ * The judgement lives in `proposeInit`, in the core, with no filesystem
1625
+ * anywhere near it. This function's whole job is to *look*: walk for prompts,
1626
+ * read a few source files, notice a log or a credential, and hand the lot over
1627
+ * as data. That split is why `--dry-run` cannot drift from the real thing —
1628
+ * they are the same call, and one of them stops before `writeFile`.
1629
+ */
1630
+ async function commandInit(
1631
+ args: Args,
1632
+ config: TrazumConfig,
1633
+ pricing: PricingCatalogue,
1634
+ t: CliMessages,
1635
+ ): Promise<void> {
1636
+ const root = args.positional[0] ?? '.';
1637
+ const dryRun = args.flags.get('dry-run') === true;
1638
+ const asJson = args.flags.get('json') === true;
1639
+
1640
+ // --- what is here -------------------------------------------------------
1641
+ const host = detectHost();
1642
+ const prompts = await walkPrompts(root, {
1643
+ extensions: config.extensions ?? DEFAULT_EXTENSIONS,
1644
+ });
1645
+
1646
+ /**
1647
+ * Source files, read only to be asked which provider they call.
1648
+ *
1649
+ * Capped hard and deliberately low. `where` reads one file because somebody
1650
+ * named it; this reads whatever is lying around, and a first run that spends
1651
+ * forty seconds walking a monorepo has already lost. The cap is reported
1652
+ * when it bites, because "no provider found" and "stopped looking" are
1653
+ * different sentences.
1654
+ */
1655
+ const sourceWalk = await walkPrompts(root, {
1656
+ extensions: SOURCE_EXTENSIONS,
1657
+ maxFiles: INIT_MAX_SOURCE_FILES,
1658
+ });
1659
+ const sightings: ProviderSighting[] = [];
1660
+ for (const relative of sourceWalk.files) {
1661
+ const path = join(root, relative);
1662
+ let source: string;
1663
+ /**
1664
+ * Measured and read through **one open handle**, not by path twice.
1665
+ *
1666
+ * A bundle or a lockfile named `.js` is not worth reading, and reading it
1667
+ * is how this command becomes slow on exactly the repositories that need
1668
+ * it most — so the size is checked first. Checking it with `stat(path)`
1669
+ * and then reading `path` is two lookups of the same name, and what
1670
+ * arrives the second time need not be what was measured the first: the
1671
+ * bound would be enforced against a file that is no longer there. One
1672
+ * handle, stat'ed and read, is the same inode by construction.
1673
+ */
1674
+ let handle;
1675
+ try {
1676
+ handle = await open(path, 'r');
1677
+ } catch {
1678
+ continue;
1679
+ }
1680
+ try {
1681
+ const info = await handle.stat();
1682
+ if (info.size > INIT_MAX_SOURCE_BYTES) continue;
1683
+ source = await handle.readFile('utf8');
1684
+ } catch {
1685
+ continue;
1686
+ } finally {
1687
+ await handle.close();
1688
+ }
1689
+ const detection = detectFromSource(source, { models: pricing.models });
1690
+ if (detection.provider !== null || detection.model !== null || detection.conflicts.length > 0) {
1691
+ sightings.push({ file: relative, detection });
1692
+ }
1693
+ }
1694
+
1695
+ // --- where the usage is, if it is anywhere ------------------------------
1696
+ const usage: UsageSighting[] = [];
1697
+ for (const candidate of INIT_LOG_CANDIDATES) {
1698
+ /**
1699
+ * An existence check and nothing more — what is recorded is the *name*
1700
+ * that was tried, and whether it is a file or a directory. Anything read
1701
+ * later is opened then, on its own terms, so there is no measurement here
1702
+ * for a later read to disagree with.
1703
+ */
1704
+ try {
1705
+ const info = await stat(join(root, candidate));
1706
+ usage.push({
1707
+ kind: info.isDirectory() ? 'log-directory' : 'log-file',
1708
+ where: candidate,
1709
+ provider: null,
1710
+ });
1711
+ } catch {
1712
+ // Absent is the common case and not an error.
1713
+ }
1714
+ }
1715
+ try {
1716
+ const info = await stat(join(root, STORE_DIR));
1717
+ if (info.isDirectory()) {
1718
+ usage.push({ kind: 'store', where: STORE_DIR, provider: null });
1719
+ }
1720
+ } catch {
1721
+ // No store yet.
1722
+ }
1723
+ /**
1724
+ * A credential is named by its **variable**, never read.
1725
+ *
1726
+ * `findCredential` returns the value as well because the connector needs it;
1727
+ * this takes the name and drops the rest on the floor. A first-run summary
1728
+ * is the single most likely output in this product to be pasted into a chat
1729
+ * window, and the rule that has held since 1.41 holds here.
1730
+ */
1731
+ for (const connector of CONNECTORS) {
1732
+ const found = findCredential(connector, process.env);
1733
+ if (found !== null) {
1734
+ usage.push({
1735
+ kind: 'connector-credential',
1736
+ where: found.source.variable,
1737
+ provider: connector.id,
1738
+ });
1739
+ }
1740
+ }
1741
+
1742
+ // --- read what can be read ----------------------------------------------
1743
+ let measured: UsageProfileReport | null = null;
1744
+ let unreadable: { where: string; because: string } | null = null;
1745
+ const readable = usage.find((u) => u.kind === 'log-file' || u.kind === 'log-directory');
1746
+ if (readable !== undefined) {
1747
+ try {
1748
+ const files =
1749
+ readable.kind === 'log-file'
1750
+ ? [join(root, readable.where)]
1751
+ : (await readdir(join(root, readable.where)))
1752
+ .filter((name) => LOG_EXTENSIONS.some((extension) => name.endsWith(extension)))
1753
+ .sort()
1754
+ .map((name) => join(root, readable.where, name));
1755
+ if (files.length > 0) {
1756
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
1757
+ measured = profileUsage(texts.join('\n'), { catalogue: pricing });
1758
+ }
1759
+ } catch (error) {
1760
+ // Named, never swallowed. A log that is there and cannot be read is the
1761
+ // single most useful thing this command can tell somebody, and treating
1762
+ // it as "no usage found" would send them to configure a connector they
1763
+ // do not need.
1764
+ unreadable = {
1765
+ where: readable.where,
1766
+ because: error instanceof Error ? error.message : String(error),
1767
+ };
1768
+ }
1769
+ }
1770
+
1771
+ // --- the config already there -------------------------------------------
1772
+ const configPath = join(root, CONFIG_FILENAME);
1773
+ let existing: InitObservations['existing'] = null;
1774
+ try {
1775
+ existing = { path: configPath, config: parseConfig(await readFile(configPath, 'utf8'), configPath) };
1776
+ } catch {
1777
+ // Absent, or unparseable. Either way there is nothing to compare against,
1778
+ // and `init` refuses to overwrite below rather than reasoning about it.
1779
+ }
1780
+ let unparseable = false;
1781
+ if (existing === null) {
1782
+ try {
1783
+ await stat(configPath);
1784
+ unparseable = true;
1785
+ } catch {
1786
+ // Genuinely absent.
1787
+ }
1788
+ }
1789
+
1790
+ const askedLocale = matchLocale(
1791
+ LOCALE_ENV_VARS.map((name) => process.env[name]).find((value) => matchLocale(value)),
1792
+ );
1793
+
1794
+ const proposal = proposeInit(
1795
+ {
1796
+ host,
1797
+ sightings,
1798
+ promptFiles: prompts.files,
1799
+ usage,
1800
+ measured,
1801
+ locale: askedLocale ?? null,
1802
+ existing,
1803
+ },
1804
+ { catalogue: pricing },
1805
+ );
1806
+
1807
+ if (asJson) {
1808
+ console.log(JSON.stringify({ ...proposal, unreadable, truncated: sourceWalk.truncated }, null, 2));
1809
+ return;
1810
+ }
1811
+
1812
+ renderInit(proposal, { host, prompts, usage, unreadable, truncated: sourceWalk.truncated, t, pricing });
1813
+
1814
+ // --- writing it ---------------------------------------------------------
1815
+ //
1816
+ // Three ways this ends and they are kept apart: nothing to write, refused to
1817
+ // overwrite, written. "Nothing happened" with no reason is the output that
1818
+ // makes somebody run the command twice.
1819
+ if (Object.keys(proposal.config).length === 0) {
1820
+ console.log(c.dim(t.init.nothingToWrite()));
1821
+ console.log();
1822
+ return;
1823
+ }
1824
+ const body = `${JSON.stringify(proposal.config, null, 2)}\n`;
1825
+ if (dryRun) {
1826
+ console.log(c.bold(t.init.wouldWrite(configPath)));
1827
+ console.log();
1828
+ console.log(body.trimEnd());
1829
+ console.log();
1830
+ return;
1831
+ }
1832
+ if (unparseable) {
1833
+ console.log(c.yellow(t.init.existingUnparseable(configPath)));
1834
+ console.log();
1835
+ return;
1836
+ }
1837
+ if (existing !== null && args.flags.get('yes') !== true) {
1838
+ console.log(c.yellow(t.init.existingRefused(configPath)));
1839
+ console.log();
1840
+ return;
1841
+ }
1842
+ await writeFile(configPath, body, 'utf8');
1843
+ console.log(c.green(t.init.wrote(configPath)));
1844
+ console.log();
1845
+ }
1846
+
1418
1847
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
1419
1848
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
1420
1849
  const col = t.models.columns;
@@ -3118,17 +3547,21 @@ async function commandVerify(
3118
3547
  const againstPath = stringFlag(args, 'against');
3119
3548
  if (againstPath === undefined) throw new Error(t.verify.needsAgainst());
3120
3549
 
3121
- let plan: PlanDocument & { createdAt?: string };
3122
- try {
3123
- const parsed = JSON.parse(await readFile(planPath, 'utf8'));
3124
- if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.actions)) {
3125
- throw new Error(t.verify.badPlan(planPath));
3126
- }
3127
- plan = parsed;
3128
- } catch (error) {
3129
- if (error instanceof SyntaxError) throw new Error(t.verify.badPlan(planPath));
3130
- throw error;
3550
+ /**
3551
+ * One validator, shared with the browser since 1.47.
3552
+ *
3553
+ * The check here used to be `schemaVersion === 1 && Array.isArray(actions)`
3554
+ * and nothing more, which accepts a file whose actions are arbitrary
3555
+ * objects — `verifyPlan` would then read `label` off `undefined`, match it
3556
+ * against no slice, and report `cannot-tell: workload-vanished` for every
3557
+ * one. A verification of a document that was never a plan, rendered exactly
3558
+ * like a real one.
3559
+ */
3560
+ const parsed = parsePlanDocument(await readFile(planPath, 'utf8'));
3561
+ if (!parsed.ok) {
3562
+ throw new Error(t.verify.badPlan(planPath, t.verify.planRefusal(parsed.why)));
3131
3563
  }
3564
+ const plan = parsed.plan;
3132
3565
 
3133
3566
  const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
3134
3567
  const READABLE = [...LOG_EXTENSIONS, ...GZ];
@@ -7123,7 +7556,33 @@ async function main(): Promise<void> {
7123
7556
  // flag validation so a typo is reported before any file is touched. An
7124
7557
  // invalid config throws here rather than quietly reverting to defaults —
7125
7558
  // "defaults" for a budget means "no budget", which means a green build.
7126
- const loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
7559
+ /**
7560
+ * `init` is the exception, and finding out why was worth the release.
7561
+ *
7562
+ * A malformed `trazum.config.json` throws here — correctly, for every other
7563
+ * command, because "defaults" for a budget means "no budget" and a silent
7564
+ * revert to defaults is a green build that should have been red. But `init`
7565
+ * is the command somebody runs *because* their setup is broken, and it was
7566
+ * the one command a broken setup could stop from running. The refusal to
7567
+ * overwrite an unparseable config, written two hours earlier in this same
7568
+ * release, was unreachable code standing behind a throw.
7569
+ *
7570
+ * So `init` loads the config the same way and survives the failure, with
7571
+ * nothing carried forward: no keys, no budgets, no locale. It then refuses
7572
+ * to write over the file it could not read, and says so.
7573
+ */
7574
+ let loaded: LoadedConfig;
7575
+ try {
7576
+ loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
7577
+ } catch (error) {
7578
+ if (args.command !== 'init') throw error;
7579
+ loaded = {
7580
+ config: {},
7581
+ path: null,
7582
+ pricing: BUNDLED_CATALOGUE,
7583
+ pricingPath: null,
7584
+ };
7585
+ }
7127
7586
  const { config } = loaded;
7128
7587
  const pricing = await pricingFor(args, loaded, t);
7129
7588
 
@@ -7182,6 +7641,9 @@ async function main(): Promise<void> {
7182
7641
  case 'models':
7183
7642
  commandModels(t, pricing);
7184
7643
  break;
7644
+ case 'init':
7645
+ await commandInit(args, config, pricing, t);
7646
+ break;
7185
7647
  case 'where':
7186
7648
  await commandWhere(args, config, pricing, t);
7187
7649
  break;