@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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +79 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +79 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +74 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +423 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +107 -2
- package/src/i18n/es.ts +107 -2
- package/src/i18n/types.ts +73 -1
- package/src/index.ts +477 -15
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 {
|
|
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
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
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
|
-
|
|
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;
|