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