@vimoxshah/tokenflow 1.1.1 → 1.2.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `tokenflow hooks` — a pre-push git hook that attaches a TokenFlow receipt
3
+ * to the commit being pushed, as a git note, with no server involved.
4
+ *
5
+ * tokenflow hooks install write .git/hooks/pre-push
6
+ * tokenflow hooks uninstall remove it, restoring anything it replaced
7
+ * tokenflow hooks status installed? chained? where?
8
+ * tokenflow hooks pre-push <remote> <url> the hook body (reads stdin)
9
+ *
10
+ * `pre-push` is never invoked by a person: it is what the installed script
11
+ * calls (see renderHookScript()). It must never block a push of its own
12
+ * accord — every failure here is reported on stderr with exit 0, and the
13
+ * installed shell script forces exit 0 after this step regardless, as a
14
+ * second line of defense. A hook this replaced is kept as
15
+ * `pre-push.tokenflow-chained` and always runs first, keeping its own exit
16
+ * code: that gate belongs to whoever configured it, not to TokenFlow.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { repoRootOf } from '../core/repo.js';
22
+ import { buildBranchReceipt, writeNote, pushNotes } from '../core/receipt-note.js';
23
+
24
+ const HOOK_NAME = 'pre-push';
25
+ const CHAINED_NAME = 'pre-push.tokenflow-chained';
26
+ const MARKER = '# tokenflow:pre-push v1 -- installed by `tokenflow hooks install`';
27
+ const ALL_ZERO_SHA = '0'.repeat(40);
28
+
29
+ function cliPath() {
30
+ const here = path.dirname(fileURLToPath(import.meta.url));
31
+ return path.resolve(here, '..', '..', 'bin', 'tokenflow.js');
32
+ }
33
+
34
+ function hooksDirFor(repoPath) {
35
+ const root = repoRootOf(repoPath) || repoPath;
36
+ return path.join(root, '.git', 'hooks');
37
+ }
38
+
39
+ /**
40
+ * The installed hook script's contents. Buffers stdin once (so it can be
41
+ * replayed to a chained hook and to the tokenflow step), runs a chained hook
42
+ * first and keeps its exit code, then runs the tokenflow step and always
43
+ * exits 0 after it.
44
+ * @returns {string}
45
+ */
46
+ export function renderHookScript() {
47
+ const node = process.execPath;
48
+ const cli = cliPath();
49
+ return `#!/bin/sh
50
+ ${MARKER}
51
+ # Re-run \`tokenflow hooks install\` / \`tokenflow hooks uninstall\` to change this file by hand.
52
+
53
+ # The notes push below re-triggers this same hook; stop immediately instead of recursing.
54
+ if [ "$TOKENFLOW_HOOK_NESTED" = "1" ]; then
55
+ exit 0
56
+ fi
57
+
58
+ TOKENFLOW_STDIN="$(mktemp)"
59
+ trap 'rm -f "$TOKENFLOW_STDIN"' EXIT
60
+ cat > "$TOKENFLOW_STDIN"
61
+
62
+ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
63
+ if [ -x "$HOOK_DIR/${CHAINED_NAME}" ]; then
64
+ "$HOOK_DIR/${CHAINED_NAME}" "$@" < "$TOKENFLOW_STDIN"
65
+ chained_status=$?
66
+ if [ "$chained_status" -ne 0 ]; then
67
+ exit "$chained_status"
68
+ fi
69
+ fi
70
+
71
+ if [ -x "${node}" ]; then
72
+ "${node}" "${cli}" hooks pre-push "$@" < "$TOKENFLOW_STDIN"
73
+ else
74
+ echo "tokenflow: node not found at ${node}; skipping receipt" >&2
75
+ fi
76
+ exit 0
77
+ `;
78
+ }
79
+
80
+ /** True when the file at `p` is a hook TokenFlow installed (carries our marker). */
81
+ function isOurs(p) {
82
+ try {
83
+ return fs.readFileSync(p, 'utf8').includes(MARKER);
84
+ } catch {
85
+ return false; // unreadable: treat as foreign so it is never silently overwritten
86
+ }
87
+ }
88
+
89
+ /**
90
+ * `tokenflow hooks install` — write the pre-push hook. A pre-existing hook
91
+ * that is not already ours is kept as `pre-push.tokenflow-chained`.
92
+ * @param {{repo?:string}} [flags]
93
+ * @returns {{path:string, chained:boolean}}
94
+ */
95
+ export function install(flags = {}) {
96
+ const repoPath = flags.repo || process.cwd();
97
+ const dir = hooksDirFor(repoPath);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ const hookPath = path.join(dir, HOOK_NAME);
100
+ const chainedPath = path.join(dir, CHAINED_NAME);
101
+ let chained = fs.existsSync(chainedPath);
102
+ if (fs.existsSync(hookPath) && !isOurs(hookPath)) {
103
+ fs.copyFileSync(hookPath, chainedPath);
104
+ fs.chmodSync(chainedPath, 0o755);
105
+ chained = true;
106
+ }
107
+ fs.writeFileSync(hookPath, renderHookScript());
108
+ fs.chmodSync(hookPath, 0o755);
109
+ return { path: hookPath, chained };
110
+ }
111
+
112
+ /**
113
+ * `tokenflow hooks uninstall` — remove our hook, restoring a chained one.
114
+ * Leaves a foreign (non-tokenflow) hook untouched.
115
+ * @param {{repo?:string}} [flags]
116
+ * @returns {{path:string, restored:boolean}}
117
+ */
118
+ export function uninstall(flags = {}) {
119
+ const repoPath = flags.repo || process.cwd();
120
+ const dir = hooksDirFor(repoPath);
121
+ const hookPath = path.join(dir, HOOK_NAME);
122
+ const chainedPath = path.join(dir, CHAINED_NAME);
123
+ let restored = false;
124
+ if (fs.existsSync(hookPath) && isOurs(hookPath)) {
125
+ fs.unlinkSync(hookPath);
126
+ if (fs.existsSync(chainedPath)) {
127
+ fs.renameSync(chainedPath, hookPath);
128
+ fs.chmodSync(hookPath, 0o755);
129
+ restored = true;
130
+ }
131
+ }
132
+ return { path: hookPath, restored };
133
+ }
134
+
135
+ /**
136
+ * `tokenflow hooks status` — installed? chained? where?
137
+ * @param {{repo?:string}} [flags]
138
+ * @returns {{installed:boolean, chained:boolean, path:string}}
139
+ */
140
+ export function status(flags = {}) {
141
+ const repoPath = flags.repo || process.cwd();
142
+ const dir = hooksDirFor(repoPath);
143
+ const hookPath = path.join(dir, HOOK_NAME);
144
+ const chainedPath = path.join(dir, CHAINED_NAME);
145
+ return {
146
+ installed: fs.existsSync(hookPath) && isOurs(hookPath),
147
+ chained: fs.existsSync(chainedPath),
148
+ path: hookPath,
149
+ };
150
+ }
151
+
152
+ function readStdin(flags) {
153
+ if (typeof flags.stdin === 'string') return flags.stdin;
154
+ try {
155
+ return fs.readFileSync(0, 'utf8');
156
+ } catch {
157
+ return ''; // no stdin piped in (e.g. a manual run outside of git)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The `pre-push` hook body. Reads git's standard stdin lines
163
+ * (`<local ref> <local sha> <remote ref> <remote sha>`), attaches a receipt
164
+ * note to each pushed branch's local sha, then pushes the notes ref once, if
165
+ * anything was written. Never blocks: every failure is reported on stderr
166
+ * with exit 0.
167
+ * @param {{repo?:string, args?:string[], stdin?:string}} [flags]
168
+ * @returns {{stdout:string|null, stderr:string|null, exitCode:number}}
169
+ */
170
+ export function prePush(flags = {}) {
171
+ if (process.env.TOKENFLOW_HOOK_NESTED === '1') {
172
+ // This is the notes push this same command triggers; do not recurse.
173
+ return { stdout: null, stderr: null, exitCode: 0 };
174
+ }
175
+ const repoPath = flags.repo || process.cwd();
176
+ const remote = (flags.args && flags.args[0]) || 'origin';
177
+ const stdin = readStdin(flags);
178
+ const errors = [];
179
+ let wrote = 0;
180
+
181
+ for (const line of stdin.split('\n')) {
182
+ const t = line.trim();
183
+ if (!t) continue;
184
+ const parts = t.split(/\s+/);
185
+ if (parts.length !== 4) continue; // not a well-formed ref-update line
186
+ const [localRef, localSha] = parts;
187
+ if (!localRef.startsWith('refs/heads/')) continue; // tags and other refs carry no branch receipt
188
+ if (localSha === ALL_ZERO_SHA) continue; // a delete: nothing to attach a receipt to
189
+ const branch = localRef.slice('refs/heads/'.length);
190
+ try {
191
+ const receipt = buildBranchReceipt({ repoPath, branch, sha: localSha });
192
+ if (!receipt) continue; // no local sessions for this branch: nothing to attach
193
+ writeNote({ repoPath, sha: localSha, receipt });
194
+ wrote += 1;
195
+ } catch (err) {
196
+ errors.push(`${branch}: ${err.message}`);
197
+ }
198
+ }
199
+
200
+ if (wrote > 0) {
201
+ try {
202
+ pushNotes({ repoPath, remote });
203
+ } catch (err) {
204
+ errors.push(`push refs/notes/tokenflow: ${err.message}`);
205
+ }
206
+ }
207
+
208
+ if (errors.length) return { stdout: null, stderr: `tokenflow: ${errors.join('; ')}`, exitCode: 0 };
209
+ return { stdout: null, stderr: null, exitCode: 0 };
210
+ }
211
+
212
+ /**
213
+ * CLI entry. Dispatches on `flags.action` (`install` / `uninstall` /
214
+ * `status` / `pre-push`), set by the bin from the `hooks <action>` argv.
215
+ * @param {object} flags
216
+ * @returns {{stdout:string|null, stderr:string|null, exitCode:number}}
217
+ */
218
+ export function run(flags = {}) {
219
+ switch (flags.action) {
220
+ case 'install': {
221
+ const r = install(flags);
222
+ const chainedNote = r.chained ? ` (chained the existing hook as ${CHAINED_NAME})` : '';
223
+ return { stdout: `installed ${r.path}${chainedNote}`, stderr: null, exitCode: 0 };
224
+ }
225
+ case 'uninstall': {
226
+ const r = uninstall(flags);
227
+ return { stdout: `uninstalled${r.restored ? `; restored the previous hook at ${r.path}` : ''}`, stderr: null, exitCode: 0 };
228
+ }
229
+ case 'status': {
230
+ const s = status(flags);
231
+ return { stdout: `installed: ${s.installed ? 'yes' : 'no'} chained: ${s.chained ? 'yes' : 'no'} ${s.path}`, stderr: null, exitCode: 0 };
232
+ }
233
+ case 'pre-push':
234
+ return prePush(flags);
235
+ default:
236
+ return { stdout: null, stderr: 'usage: tokenflow hooks <install|uninstall|status|pre-push>', exitCode: 1 };
237
+ }
238
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * `tokenflow pricing diff <file.json|-> [--apply] [--yes]`
3
+ *
4
+ * Compares a candidate price table to the current EFFECTIVE book — the
5
+ * existing overrides file (`paths().pricing`) layered on the built-in table,
6
+ * exactly what `buildPriceBook` resolves for every other command — and prints
7
+ * what would change: models the candidate adds, rates it changes (with the
8
+ * percent change per field), and the current overrides it says nothing about
9
+ * (which `--apply` never touches, because apply merges, it does not replace).
10
+ *
11
+ * The candidate is the same shape the overrides file already uses today
12
+ * (`{ models: { "<model>": { in, out, cacheRead?, cacheWrite?, cacheRefresh?,
13
+ * match? } } }`, see src/core/pricing.js#buildPriceBook), plus two optional
14
+ * top-level fields for provenance: `sources: [string]` and `version: string`.
15
+ * Neither is written to the overrides file on apply — the overrides file's
16
+ * shape stays exactly what `tokenflow pricing --set` already produces
17
+ * (`models` + `updatedAt`), so nothing downstream has to learn a new field.
18
+ *
19
+ * `--apply` always prints the diff first. It then requires either `--yes`,
20
+ * or an interactive terminal that confirms a `y` — a non-interactive run
21
+ * (piped stdin, a script, CI) without `--yes` is refused rather than guessed
22
+ * at.
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import readline from 'node:readline';
27
+ import { paths } from '../core/config.js';
28
+ import { readJson, writeJson } from '../core/store.js';
29
+ import { buildPriceBook, PRICING_SOURCES } from '../core/pricing.js';
30
+ import { usd, signedPct } from '../core/units.js';
31
+
32
+ const RATE_FIELDS = ['in', 'out', 'cacheRead', 'cacheWrite'];
33
+ const RATE_LABELS = { in: 'input', out: 'output', cacheRead: 'cache read', cacheWrite: 'cache write' };
34
+
35
+ function num(v) {
36
+ return v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? null : Number(v);
37
+ }
38
+
39
+ /** A model entry in the overrides-file shape, normalized to plain rate fields. */
40
+ function rateSpecOf(entry) {
41
+ if (!entry || typeof entry !== 'object') return {};
42
+ return {
43
+ in: num(entry.in ?? entry.input),
44
+ out: num(entry.out ?? entry.output),
45
+ cacheRead: num(entry.cacheRead ?? entry.cache_read),
46
+ cacheWrite: num(entry.cacheWrite ?? entry.cache_write),
47
+ cacheRefresh: num(entry.cacheRefresh ?? entry.cache_refresh),
48
+ match: entry.match,
49
+ };
50
+ }
51
+
52
+ /** Only the fields a diff cares about, out of a full `book.lookup()` entry. */
53
+ function pickRates(entry) {
54
+ return { in: entry.in, out: entry.out, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite };
55
+ }
56
+
57
+ /**
58
+ * Compare a candidate price table to the current effective book.
59
+ *
60
+ * Both the current side and the candidate side are resolved through
61
+ * `buildPriceBook` (the same cache-rate fallback, the same "no rate" rule),
62
+ * so a candidate that only states `in`/`out` is compared like-for-like
63
+ * against a current entry that does the same — neither side is favoured by
64
+ * a difference in how missing cache rates are filled in.
65
+ *
66
+ * @param {{models?: Record<string, object>}} current the current overrides file contents
67
+ * @param {{models: Record<string, object>, sources?: string[], version?: string}} candidate
68
+ * @returns {{
69
+ * added: {model:string, to:object}[],
70
+ * removed: {model:string, rates:object}[],
71
+ * changed: {model:string, from:object, to:object, deltas:object, currentSrc:string|null, currentOrigin:string|null}[],
72
+ * unchanged: string[],
73
+ * invalid: {model:string, reason:string}[],
74
+ * candidateVersion: string|null,
75
+ * candidateSources: string[],
76
+ * }}
77
+ */
78
+ export function diffPriceTables(current, candidate) {
79
+ if (!candidate || typeof candidate !== 'object' || typeof candidate.models !== 'object' || candidate.models === null) {
80
+ throw new Error('candidate must be an object with a "models" map, e.g. { "models": { "<model>": { "in": 1, "out": 2 } } }');
81
+ }
82
+ const currentModels = (current && current.models) || {};
83
+ const candidateModels = candidate.models;
84
+
85
+ // A candidate entry without a usable in/out rate would otherwise fall
86
+ // through buildPriceBook's lookup (it skips entries missing in/out) and
87
+ // silently read back as "unchanged" against the builtin table — the worst
88
+ // failure mode for a tool whose whole job is telling a human what changed.
89
+ const invalid = [];
90
+ const validCandidateModels = {};
91
+ for (const [key, spec] of Object.entries(candidateModels)) {
92
+ const r = rateSpecOf(spec);
93
+ if (!Number.isFinite(r.in) || !Number.isFinite(r.out)) {
94
+ invalid.push({ model: key, reason: 'missing or non-numeric "in"/"out" rate' });
95
+ continue;
96
+ }
97
+ validCandidateModels[key] = spec;
98
+ }
99
+
100
+ const currentBook = buildPriceBook({ models: currentModels });
101
+ const candidateBook = buildPriceBook({ models: validCandidateModels });
102
+
103
+ const added = [];
104
+ const changed = [];
105
+ const unchanged = [];
106
+ for (const key of Object.keys(validCandidateModels)) {
107
+ const to = candidateBook.lookup(key, undefined);
108
+ if (!to) {
109
+ // A finite in/out passed validation above, but an explicit `match`
110
+ // override that does not match its own key means the lookup never
111
+ // finds this entry at all — the same "no usable rate" outcome as a
112
+ // missing in/out, just reached a different way.
113
+ invalid.push({ model: key, reason: '"match" pattern does not match its own model key' });
114
+ continue;
115
+ }
116
+ const from = currentBook.lookup(key, undefined);
117
+ if (!from) { added.push({ model: key, to: pickRates(to) }); continue; }
118
+ const deltas = {};
119
+ let any = false;
120
+ for (const f of RATE_FIELDS) {
121
+ const a = from[f] ?? null;
122
+ const b = to[f] ?? null;
123
+ if (a === b) continue;
124
+ any = true;
125
+ deltas[f] = { from: a, to: b, pct: (a === null || a === undefined || a === 0) ? null : (b - a) / a };
126
+ }
127
+ if (any) changed.push({ model: key, from: pickRates(from), to: pickRates(to), deltas, currentSrc: from.src ?? null, currentOrigin: from.origin ?? null });
128
+ else unchanged.push(key);
129
+ }
130
+
131
+ const removed = Object.keys(currentModels)
132
+ .filter((k) => !(k in candidateModels))
133
+ .map((k) => ({ model: k, rates: rateSpecOf(currentModels[k]) }));
134
+
135
+ return {
136
+ added,
137
+ removed,
138
+ changed,
139
+ unchanged,
140
+ invalid,
141
+ candidateVersion: candidate.version ?? null,
142
+ candidateSources: Array.isArray(candidate.sources) ? candidate.sources : [],
143
+ };
144
+ }
145
+
146
+ function fmtRate(n) {
147
+ return n === null || n === undefined ? 'n/a' : usd(n);
148
+ }
149
+
150
+ function rateLine(r) {
151
+ const bits = [`in ${fmtRate(r.in)}`, `out ${fmtRate(r.out)}`];
152
+ if (r.cacheRead !== null && r.cacheRead !== undefined) bits.push(`cacheRead ${fmtRate(r.cacheRead)}`);
153
+ if (r.cacheWrite !== null && r.cacheWrite !== undefined) bits.push(`cacheWrite ${fmtRate(r.cacheWrite)}`);
154
+ return bits.join(', ');
155
+ }
156
+
157
+ /** Render a diff the way a person reviews it before deciding to `--apply`. */
158
+ function renderDiff(diff) {
159
+ const lines = [];
160
+ lines.push(`${diff.candidateVersion ? `candidate table version: ${diff.candidateVersion}` : 'candidate table (no version stated)'}`);
161
+ if (diff.invalid.length) {
162
+ lines.push('');
163
+ lines.push(`${diff.invalid.length} invalid candidate entr${diff.invalid.length === 1 ? 'y' : 'ies'} — skipped, and --apply is refused while these exist:`);
164
+ for (const i of diff.invalid) lines.push(` ✗ ${i.model}: ${i.reason}`);
165
+ }
166
+ if (diff.added.length) {
167
+ lines.push('');
168
+ lines.push(`${diff.added.length} added:`);
169
+ for (const a of diff.added) lines.push(` + ${a.model}: ${rateLine(a.to)}`);
170
+ }
171
+ if (diff.changed.length) {
172
+ lines.push('');
173
+ lines.push(`${diff.changed.length} changed:`);
174
+ for (const c of diff.changed) {
175
+ const parts = RATE_FIELDS.filter((f) => c.deltas[f]).map((f) => {
176
+ const d = c.deltas[f];
177
+ return `${RATE_LABELS[f]} ${fmtRate(d.from)} -> ${fmtRate(d.to)} (${d.pct === null ? 'n/a' : signedPct(d.pct)})`;
178
+ });
179
+ const was = c.currentSrc ? ` [current: ${c.currentOrigin === 'user' ? 'your override' : c.currentSrc}]` : '';
180
+ lines.push(` ~ ${c.model}: ${parts.join(', ')}${was}`);
181
+ }
182
+ }
183
+ if (diff.removed.length) {
184
+ lines.push('');
185
+ lines.push(`${diff.removed.length} in your current overrides but not mentioned by this candidate — untouched by --apply (a merge, not a replace):`);
186
+ for (const r of diff.removed) lines.push(` · ${r.model}: ${rateLine(r.rates)}`);
187
+ }
188
+ if (!diff.added.length && !diff.changed.length && !diff.removed.length && !diff.invalid.length) {
189
+ lines.push('');
190
+ lines.push('no differences from the current effective price book.');
191
+ }
192
+ lines.push('');
193
+ lines.push(`candidate sources: ${diff.candidateSources.length ? diff.candidateSources.join('; ') : '(none stated)'}`);
194
+ lines.push('current built-in sources:');
195
+ for (const [key, src] of Object.entries(PRICING_SOURCES)) {
196
+ lines.push(` ${key}: ${src.confidence} — ${src.url} (fetched ${src.fetched})`);
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+
201
+ /**
202
+ * Merge a candidate's models into the overrides file — never a replace.
203
+ * Any model the candidate does not mention keeps its existing override
204
+ * exactly as it was; a model the candidate does mention is fully replaced by
205
+ * the candidate's rate object for that model. Only `models` and `updatedAt`
206
+ * are written — a candidate's `sources`/`version` are for the diff review,
207
+ * not for the overrides file's own schema.
208
+ *
209
+ * @param {{candidate: {models: Record<string, object>}, overridesPath: string}} opt
210
+ * @returns {{path:string, applied:string[], modelCount:number}}
211
+ */
212
+ export function applyCandidate({ candidate, overridesPath }) {
213
+ if (!candidate || typeof candidate.models !== 'object' || candidate.models === null) {
214
+ throw new Error('candidate must have a "models" map to apply');
215
+ }
216
+ const existing = readJson(overridesPath, { models: {} });
217
+ const mergedModels = { ...(existing.models || {}), ...candidate.models };
218
+ const merged = { ...existing, models: mergedModels, updatedAt: new Date().toISOString() };
219
+ writeJson(overridesPath, merged);
220
+ return { path: overridesPath, applied: Object.keys(candidate.models), modelCount: Object.keys(mergedModels).length };
221
+ }
222
+
223
+ function readCandidateInput(file, stdinReader) {
224
+ const raw = file === '-'
225
+ ? (stdinReader ? stdinReader() : fs.readFileSync(0, 'utf8'))
226
+ : fs.readFileSync(path.resolve(file), 'utf8');
227
+ return JSON.parse(raw);
228
+ }
229
+
230
+ /** Default confirm: a real y/N prompt on the controlling terminal. */
231
+ function defaultConfirm(question) {
232
+ return new Promise((resolve) => {
233
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
234
+ rl.question(question, (answer) => {
235
+ rl.close();
236
+ resolve(/^y(es)?$/i.test(String(answer).trim()));
237
+ });
238
+ });
239
+ }
240
+
241
+ /**
242
+ * CLI entry for `tokenflow pricing diff`. Returns what to print and the exit
243
+ * code; the caller decides the stream (matches src/commands/guard.js's `run`
244
+ * convention).
245
+ *
246
+ * @param {{
247
+ * file?: string,
248
+ * apply?: boolean,
249
+ * yes?: boolean,
250
+ * overridesPath?: string,
251
+ * isTTY?: boolean,
252
+ * confirm?: (question:string) => Promise<boolean>,
253
+ * stdinReader?: () => string,
254
+ * write?: (s: string) => void,
255
+ * }} flags
256
+ * `overridesPath`, `isTTY`, `confirm`, `stdinReader` and `write` are
257
+ * injectable seams for tests; a real CLI invocation only ever sets `file`,
258
+ * `apply` and `yes`. `write` is only used on the interactive-confirm path,
259
+ * where the diff must reach the terminal before the confirm prompt does
260
+ * (defaults to `process.stdout.write`).
261
+ * @returns {Promise<{stdout: string|null, stderr: string|null, exitCode: number}>}
262
+ */
263
+ export async function run(flags = {}) {
264
+ const file = flags.file;
265
+ if (!file) {
266
+ return { stdout: null, stderr: 'usage: tokenflow pricing diff <file.json|-> [--apply] [--yes]', exitCode: 1 };
267
+ }
268
+
269
+ let candidate;
270
+ try {
271
+ candidate = readCandidateInput(file, flags.stdinReader);
272
+ } catch (err) {
273
+ return { stdout: null, stderr: `could not read candidate pricing table: ${err.message}`, exitCode: 1 };
274
+ }
275
+
276
+ const overridesPath = flags.overridesPath || paths().pricing;
277
+ const current = readJson(overridesPath, { models: {} });
278
+
279
+ let diff;
280
+ try {
281
+ diff = diffPriceTables(current, candidate);
282
+ } catch (err) {
283
+ return { stdout: null, stderr: err.message, exitCode: 1 };
284
+ }
285
+ const text = renderDiff(diff);
286
+
287
+ if (!flags.apply) return { stdout: text, stderr: null, exitCode: 0 };
288
+
289
+ if (diff.invalid.length) {
290
+ return { stdout: text, stderr: `refusing to apply: ${diff.invalid.length} invalid candidate entr${diff.invalid.length === 1 ? 'y' : 'ies'}.`, exitCode: 1 };
291
+ }
292
+ const hasChanges = diff.added.length > 0 || diff.changed.length > 0;
293
+ if (!hasChanges) {
294
+ return { stdout: `${text}\n\nnothing to apply — the candidate matches the current effective table.`, stderr: null, exitCode: 0 };
295
+ }
296
+
297
+ if (!flags.yes) {
298
+ const isTTY = flags.isTTY !== undefined ? flags.isTTY : !!(process.stdin && process.stdin.isTTY);
299
+ if (!isTTY) {
300
+ return { stdout: text, stderr: 'refusing to apply non-interactively — re-run with --yes to confirm.', exitCode: 1 };
301
+ }
302
+ // On a real terminal the confirm prompt (readline, or an injected mock)
303
+ // writes straight to stdout the moment it is called — so the diff must
304
+ // already be on screen before that happens, not bundled into a return
305
+ // value the caller would only print afterwards.
306
+ (flags.write || ((s) => process.stdout.write(s)))(`${text}\n\n`);
307
+ const confirmFn = flags.confirm || defaultConfirm;
308
+ const ok = await confirmFn(`Apply ${diff.added.length} added / ${diff.changed.length} changed model rate(s) to ${overridesPath}? [y/N] `);
309
+ if (!ok) return { stdout: 'apply cancelled.', stderr: null, exitCode: 0 };
310
+ const res = applyCandidate({ candidate, overridesPath });
311
+ return { stdout: `✓ applied ${res.applied.length} model rate(s) to ${res.path}`, stderr: null, exitCode: 0 };
312
+ }
313
+
314
+ const res = applyCandidate({ candidate, overridesPath });
315
+ return { stdout: `${text}\n\n✓ applied ${res.applied.length} model rate(s) to ${res.path}`, stderr: null, exitCode: 0 };
316
+ }