nilometer 0.0.0-stage → 0.1.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 (78) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/dist/cli/main.cli.js +39 -0
  37. package/dist/cli/program.js +801 -0
  38. package/dist/core/db/database.js +182 -0
  39. package/dist/core/db/fingerprint.js +71 -0
  40. package/dist/core/ingest/attribution.js +131 -0
  41. package/dist/core/ingest/classify.js +382 -0
  42. package/dist/core/ingest/command.js +95 -0
  43. package/dist/core/ingest/derive.js +212 -0
  44. package/dist/core/ingest/discover.js +126 -0
  45. package/dist/core/ingest/ingest.js +160 -0
  46. package/dist/core/ingest/reader.js +89 -0
  47. package/dist/core/ingest/reset-time.js +97 -0
  48. package/dist/core/ingest/results.js +201 -0
  49. package/dist/core/ingest/spool.js +165 -0
  50. package/dist/core/ingest/store.js +99 -0
  51. package/dist/core/install/install.js +185 -0
  52. package/dist/core/install/locations.js +89 -0
  53. package/dist/core/install/private-files.js +259 -0
  54. package/dist/core/install/record.js +103 -0
  55. package/dist/core/plans/plan-prices.js +128 -0
  56. package/dist/core/plans/repository-aliases.js +136 -0
  57. package/dist/core/pricing/prices.js +147 -0
  58. package/dist/core/settings/settings-file.js +215 -0
  59. package/dist/core/settings/statusline.js +240 -0
  60. package/dist/core/util/commands.js +91 -0
  61. package/dist/core/util/node-version.js +58 -0
  62. package/dist/core/util/open-page.js +69 -0
  63. package/dist/core/util/paths.js +36 -0
  64. package/dist/core/verify/compare.js +77 -0
  65. package/dist/core/verify/verify.js +220 -0
  66. package/dist/viewer/explain.js +574 -0
  67. package/dist/viewer/format.js +150 -0
  68. package/dist/viewer/html-charts.js +1292 -0
  69. package/dist/viewer/html-contract.js +2 -0
  70. package/dist/viewer/html-data.js +718 -0
  71. package/dist/viewer/html.js +1654 -0
  72. package/dist/viewer/queries.js +128 -0
  73. package/dist/viewer/render.js +602 -0
  74. package/dist/viewer/report.js +120 -0
  75. package/dist/viewer/save.js +110 -0
  76. package/docs/requirements.md +113 -0
  77. package/hooks/statusline.sh +104 -0
  78. package/package.json +61 -4
@@ -0,0 +1,801 @@
1
+ /**
2
+ * @file Command-line program: the `init`, `ingest`, `report`, `explain`, `plan-price`, and
3
+ * `uninstall` commands and their messages.
4
+ *
5
+ * Implements docs/development.md P1.3, P4.8, P7.1, P7.2, the plan price entry D-027 needs, and
6
+ * `report --save --html` (step G1.4, D-070). Logic lives in core/install/install.ts; this module only parses flags,
7
+ * calls it, and turns outcomes into words. All wording here follows CLAUDE.md "Wording is part of
8
+ * correctness": messages state what happened, never advice or judgment.
9
+ */
10
+ import { dirname } from "node:path";
11
+ import { Command, CommanderError } from "commander";
12
+ import { transaction } from "../core/db/database.js";
13
+ import { runIngestCommand } from "../core/ingest/command.js";
14
+ import { DiscoveryError } from "../core/ingest/discover.js";
15
+ import { deleteDataFiles, describeTightened, } from "../core/install/private-files.js";
16
+ import { packageVersion } from "../core/install/locations.js";
17
+ import { InstallRecordError } from "../core/install/record.js";
18
+ import { PlanPriceError, listPlanPrices, setPlanPrice, validatePlanPrice, withPlanDatabase, } from "../core/plans/plan-prices.js";
19
+ import { PriceTableError } from "../core/pricing/prices.js";
20
+ import { runInit, runUninstall, } from "../core/install/install.js";
21
+ import { SettingsError, locateSettings } from "../core/settings/settings-file.js";
22
+ import { CCUSAGE_VERSION, runCcusage, runVerify, } from "../core/verify/verify.js";
23
+ import { openPage } from "../core/util/open-page.js";
24
+ import { RepositoryAliasError, listAliases, normalizeRepositoryPath, removeAlias, setAlias, validateAlias, } from "../core/plans/repository-aliases.js";
25
+ import { METRIC_NAMES, UnknownMetricError, explain, renderExplanation } from "../viewer/explain.js";
26
+ import { formatNumber } from "../viewer/format.js";
27
+ import { NoDatabaseError, readReport, summarizeStoredData, withReportDatabase, } from "../viewer/report.js";
28
+ import { loadHtmlData } from "../viewer/html-data.js";
29
+ import { renderHtmlReport } from "../viewer/html.js";
30
+ import { saveReport } from "../viewer/save.js";
31
+ import { displayPath, plural, renderReport, reportJson } from "../viewer/render.js";
32
+ /** The ccusage release `verify` compares against; pinned so two runs compare the same way. */
33
+ const CCUSAGE_PIN = CCUSAGE_VERSION;
34
+ /** Readings need a terminal session (D-029); said at install so empty usage windows aren't a surprise. */
35
+ export const TERMINAL_ONLY_NOTE = "Readings are recorded only when Claude Code runs in a terminal; the VS Code extension doesn't run the status line.";
36
+ /**
37
+ * Refusal for `--open` without both `--save` and `--html`: only a saved page exists to open (D-082).
38
+ * Checked first, so it names everything `--open` needs.
39
+ */
40
+ export const OPEN_NEEDS_HTML = "--open needs --save --html: only the saved HTML page can be opened. Nothing was changed.";
41
+ /**
42
+ * Refusal for `--html` without `--save`. The page is only ever written as a saved copy, never to
43
+ * stdout, so the flag alone has nowhere to go (D-070).
44
+ */
45
+ export const HTML_NEEDS_SAVE = "--html needs --save: the HTML page is written only as a saved copy, beside the text and JSON. Nothing was changed.";
46
+ /** Events listed per number by `explain` without `--all`. */
47
+ export const EXPLAIN_EVENT_LIMIT = 20;
48
+ /**
49
+ * Builds the options for opening the plan price database.
50
+ * @param flags - Parsed `plan-price` flags.
51
+ * @param deps - Program dependencies.
52
+ * @returns Options for `withPlanDatabase`.
53
+ */
54
+ export function toPlanDatabaseOptions(flags, deps) {
55
+ return {
56
+ home: deps.home,
57
+ env: {
58
+ NILOMETER_HOME: deps.env["NILOMETER_HOME"],
59
+ XDG_DATA_HOME: deps.env["XDG_DATA_HOME"],
60
+ },
61
+ dataDirOverride: flags.dataDir,
62
+ packageRoot: deps.packageRoot,
63
+ };
64
+ }
65
+ /**
66
+ * Turns an optional message into zero or one output lines.
67
+ * @param line - The message, or null.
68
+ * @returns An array holding the message, or an empty array.
69
+ */
70
+ function optionalLine(line) {
71
+ return line === null ? [] : [line];
72
+ }
73
+ /**
74
+ * Tells the user, on stderr so stdout stays clean, that data paths were made owner-only (D-043).
75
+ * @param deps - Program dependencies.
76
+ * @param tightened - Paths that were tightened; nothing is printed when empty.
77
+ */
78
+ export function noteTightened(deps, tightened) {
79
+ const note = describeTightened(tightened);
80
+ if (note !== null) {
81
+ deps.printError(note);
82
+ }
83
+ }
84
+ /**
85
+ * Formats a USD amount for plan price messages.
86
+ * @param usd - Dollars.
87
+ * @returns E.g. `$200.00`.
88
+ */
89
+ function formatUsd(usd) {
90
+ return `$${usd.toFixed(2)}`;
91
+ }
92
+ /**
93
+ * Describes a stored plan price.
94
+ * @param entry - What was stored.
95
+ * @param previous - The row it replaced, if any.
96
+ * @param databasePath - Where it was stored.
97
+ * @returns Lines for stdout.
98
+ */
99
+ export function describePlanPriceSet(entry, previous, databasePath) {
100
+ return [
101
+ `Plan price from ${entry.month}: ${entry.planName}, ${formatUsd(entry.usdPerMonth)} per month (USD list price).`,
102
+ ...(previous === null
103
+ ? []
104
+ : [
105
+ `Replaced the entry for ${previous.month}: ${previous.planName}, ${formatUsd(previous.usdPerMonth)} per month.`,
106
+ ]),
107
+ `Stored in ${databasePath}`,
108
+ ];
109
+ }
110
+ /**
111
+ * Describes a stored alias (D-092).
112
+ * @param entry - The alias entered.
113
+ * @param previous - The alias it replaced, if any.
114
+ * @param databasePath - Where it's stored.
115
+ * @param home - Home directory, so paths print as `~/...`.
116
+ * @returns Lines for stdout.
117
+ */
118
+ export function describeAliasSet(entry, previous, databasePath, home) {
119
+ return [
120
+ `${displayPath(entry.from, home)} now counts under ${displayPath(entry.to, home)}.`,
121
+ ...(previous === null || previous.to === entry.to
122
+ ? []
123
+ : [`Before, it counted under ${displayPath(previous.to, home)}.`]),
124
+ `Stored in ${displayPath(databasePath, home)}`,
125
+ ];
126
+ }
127
+ /**
128
+ * Describes the stored aliases.
129
+ * @param rows - Rows from `listAliases`.
130
+ * @param home - Home directory, so paths print as `~/...`.
131
+ * @returns Lines for stdout.
132
+ */
133
+ export function describeAliasList(rows, home) {
134
+ if (rows.length === 0) {
135
+ return ["No aliases entered."];
136
+ }
137
+ return rows.map((row) => `${displayPath(row.from, home)} counts under ${displayPath(row.to, home)}`);
138
+ }
139
+ /**
140
+ * Describes the stored plan prices.
141
+ * @param rows - Rows from `listPlanPrices`.
142
+ * @returns Lines for stdout.
143
+ */
144
+ export function describePlanPriceList(rows) {
145
+ if (rows.length === 0) {
146
+ return ["No plan prices entered."];
147
+ }
148
+ return rows.map((row) => `From ${row.month}: ${row.planName}, ${formatUsd(row.usdPerMonth)} per month`);
149
+ }
150
+ /**
151
+ * Builds ingest options from flags and dependencies.
152
+ * @param flags - Parsed `ingest` flags (or `init`'s location flags for the backfill).
153
+ * @param deps - Program dependencies.
154
+ * @returns Options for `runIngestCommand`.
155
+ */
156
+ export function toIngestOptions(flags, deps) {
157
+ return {
158
+ home: deps.home,
159
+ env: {
160
+ CLAUDE_CONFIG_DIR: deps.env["CLAUDE_CONFIG_DIR"],
161
+ XDG_CONFIG_HOME: deps.env["XDG_CONFIG_HOME"],
162
+ NILOMETER_HOME: deps.env["NILOMETER_HOME"],
163
+ XDG_DATA_HOME: deps.env["XDG_DATA_HOME"],
164
+ },
165
+ dataDirOverride: flags.dataDir,
166
+ full: flags.full === true,
167
+ packageRoot: deps.packageRoot,
168
+ now: deps.now,
169
+ };
170
+ }
171
+ /**
172
+ * Turns an ingest outcome into output lines. Counts only: what was read and what is stored.
173
+ * @param outcome - Result of `runIngestCommand`.
174
+ * @returns Lines for stdout.
175
+ */
176
+ export function describeIngest(outcome) {
177
+ const { run, totals } = outcome;
178
+ const span = totals.firstRequestUtc === null || totals.lastRequestUtc === null
179
+ ? "no requests stored yet"
180
+ : `requests stored from ${totals.firstRequestUtc} to ${totals.lastRequestUtc} (UTC)`;
181
+ return [
182
+ `Log roots read: ${outcome.roots.length === 0 ? "none found" : outcome.roots.join(", ")}`,
183
+ // Logs and the spool are named apart: one log plus the spool read as "2 files" and looked
184
+ // like a miscount to someone who had a single session log.
185
+ `This run: ${run.logFiles} session ${run.logFiles === 1 ? "log" : "logs"}${run.spoolRead ? " and the status line spool" : ""}, ${run.linesRead} complete lines read, ${run.linesStored} new, ${run.filesRewritten} rewritten files reread.`,
186
+ `Stored: ${totals.requests} requests (${totals.unkeyedRequests} without IDs), ${totals.limitHits} limit hits, ${totals.otherEvents} other error or retry events, ${totals.modelFallbacks} ${totals.modelFallbacks === 1 ? "request" : "requests"} refused and retried with another model; ${span}.`,
187
+ `Status line: ${totals.statusReadings} readings, ${totals.malformedReadings} undecodable spool lines, ${totals.invalidWindows} flagged window values, ${totals.hookErrors} hook append failures.`,
188
+ `Reported for review: ${totals.malformedLines} malformed log lines${run.unreadable === 0 ? "" : `, ${run.unreadable} files or folders that couldn't be read this run (skipped)`}.`,
189
+ ...optionalLine(describeTightened(outcome.permissionsTightened)),
190
+ `Database: ${outcome.databasePath}`,
191
+ ];
192
+ }
193
+ /**
194
+ * Builds install options from flags and dependencies.
195
+ * @param flags - Parsed command flags.
196
+ * @param deps - Program dependencies.
197
+ * @returns Options for `runInit` or `runUninstall`.
198
+ */
199
+ export function toInstallOptions(flags, deps) {
200
+ return {
201
+ home: deps.home,
202
+ env: {
203
+ CLAUDE_CONFIG_DIR: deps.env["CLAUDE_CONFIG_DIR"],
204
+ NILOMETER_HOME: deps.env["NILOMETER_HOME"],
205
+ XDG_DATA_HOME: deps.env["XDG_DATA_HOME"],
206
+ },
207
+ settingsOverride: flags.settings,
208
+ dataDirOverride: flags.dataDir,
209
+ packageRoot: deps.packageRoot,
210
+ now: deps.now(),
211
+ };
212
+ }
213
+ /**
214
+ * Turns an `init` outcome into output lines and an exit code.
215
+ * @param outcome - Result of `runInit`.
216
+ * @returns Lines for stdout and the process exit code.
217
+ */
218
+ export function describeInit(outcome) {
219
+ const { settingsPath, dataDir, backupPath, wrappedCommand } = outcome;
220
+ switch (outcome.action) {
221
+ case "installed":
222
+ return {
223
+ exitCode: 0,
224
+ lines: [
225
+ `Installed the status line hook in ${settingsPath}.`,
226
+ backupPath === null
227
+ ? "There was no settings file before; it was created."
228
+ : `Backup of the previous settings: ${backupPath}`,
229
+ wrappedCommand === null
230
+ ? "There was no status line command before; the hook shows the model name."
231
+ : `Your status line command still runs, unchanged: ${wrappedCommand}`,
232
+ `Status line readings are recorded in ${dataDir}.`,
233
+ TERMINAL_ONLY_NOTE,
234
+ ],
235
+ };
236
+ case "updated":
237
+ return {
238
+ exitCode: 0,
239
+ lines: [
240
+ `Updated the hook command in ${settingsPath}: the hook's location had changed.`,
241
+ `Backup of the previous settings: ${backupPath ?? "none"}`,
242
+ ],
243
+ };
244
+ case "already-installed":
245
+ return {
246
+ exitCode: 0,
247
+ lines: outcome.hookRefreshed
248
+ ? [
249
+ `The hook is already installed in ${settingsPath}.`,
250
+ // Pulling a new version leaves the settings command right but the installed copy old.
251
+ `The hook script in ${dataDir} was updated to this version's.`,
252
+ ]
253
+ : [`The hook is already installed in ${settingsPath}. Nothing was changed.`],
254
+ };
255
+ case "refused-unsupported":
256
+ return {
257
+ exitCode: 1,
258
+ lines: [
259
+ `statusLine in ${settingsPath} is not a command entry, so it was left unchanged.`,
260
+ "Only command status lines can be wrapped.",
261
+ ],
262
+ };
263
+ case "refused-other-install":
264
+ return {
265
+ exitCode: 1,
266
+ lines: [
267
+ `The hook in ${settingsPath} was installed with a different data directory than ${dataDir}.`,
268
+ "Nothing was changed. Run uninstall with the original --data-dir first.",
269
+ ],
270
+ };
271
+ }
272
+ }
273
+ /**
274
+ * Says what `--delete-data` removed, and what it left alone.
275
+ *
276
+ * The counts are printed because the deletion can't be undone: the database holds copies of session
277
+ * logs Claude Code removes after 30 days, so this output is the only remaining record of what was
278
+ * there (R2.5).
279
+ * @param dataDir - The data directory.
280
+ * @param deleted - What was removed, and what the database held first.
281
+ * @returns Lines for stdout.
282
+ */
283
+ export function describeDeletion(dataDir, deleted) {
284
+ const { deletion, summary } = deleted;
285
+ if (deletion.removed.length === 0 && deletion.failed.length === 0) {
286
+ return [`No recorded data was found in ${dataDir}.`];
287
+ }
288
+ const lines = deletion.removed.length === 0
289
+ ? [`Nothing in ${dataDir} could be deleted.`]
290
+ : [`Deleted the recorded data in ${dataDir}: ${deletion.removed.join(", ")}.`];
291
+ if (summary !== null) {
292
+ const span = summary.firstRequestUtc === null || summary.lastRequestUtc === null
293
+ ? "no requests were stored"
294
+ : `requests ran from ${summary.firstRequestUtc} to ${summary.lastRequestUtc} (UTC)`;
295
+ lines.push(`It held ${plural(summary.requests, "request", "requests")} and ${plural(summary.readings, "status line reading", "status line readings")}; ${span}.`, "Session logs older than Claude Code's own 30-day cleanup were only in there. This can't be undone.");
296
+ }
297
+ if (deletion.failed.length > 0) {
298
+ // The defect this exists for: files were reported as deleted while still on disk (D-061).
299
+ lines.push(`Still there, though Nilometer wrote them and tried to remove them: ${deletion.failed.join(", ")}.`, "Remove those by hand, and please report it: nothing above claims they are gone.");
300
+ }
301
+ if (deletion.directoryRemoved) {
302
+ lines.push("The directory is gone: nothing else was in it.");
303
+ }
304
+ else if (deletion.kept.length > 0) {
305
+ lines.push(`Left alone, because Nilometer didn't write them: ${deletion.kept.join(", ")}.`);
306
+ }
307
+ return lines;
308
+ }
309
+ /**
310
+ * Turns a `verify` result into output lines and an exit code.
311
+ *
312
+ * Every line here is safe to send to someone else: days, model names, field names and token counts,
313
+ * and nothing that a path, a repository name, a session id, or a prompt could be in (D-064).
314
+ * @param result - What the comparison found.
315
+ * @returns Lines for stdout and the process exit code.
316
+ */
317
+ export function describeVerify(result) {
318
+ const lines = [
319
+ // Both sides count days in UTC (ccusage runs with -z UTC), which isn't the local date in the
320
+ // evening west of Greenwich; saying so keeps "today" from reading as a wrong date (D-085).
321
+ `Compared ${plural(result.comparedDays, "UTC day", "UTC days")} against ccusage ${CCUSAGE_PIN}, token counts only.`,
322
+ ];
323
+ if (result.comparedDays === 0) {
324
+ lines.push("No day could be compared: ccusage and this database have no day in common.", "Run `nilometer ingest` first, and check that Claude Code has written logs.");
325
+ return { lines, exitCode: 1 };
326
+ }
327
+ if (result.differences.length === 0) {
328
+ lines.push(`Every one matched, across ${plural(result.comparedKeys, "day and model", "day-and-model pairs")}.`);
329
+ }
330
+ else {
331
+ lines.push(`${plural(result.differences.length, "difference", "differences")} found:`, ...result.differences.map((d) => ` ${d.key.replace("|", " ")} ${d.field}: this tool ${formatNumber(d.ours, "count")}, ccusage ${formatNumber(d.theirs, "count")}`));
332
+ }
333
+ lines.push(`${result.skippedToday} (the current UTC day) was not compared: both sides are still being written to, so a difference there would be the clock.`);
334
+ if (result.fromDeletedLogs > 0) {
335
+ lines.push(`${plural(result.fromDeletedLogs, "request", "requests")} were left out: the logs they came from are gone from disk, so ccusage cannot see them. Keeping them is the point of the database.`);
336
+ }
337
+ if (result.daysOnlyOurs > 0) {
338
+ lines.push(`${plural(result.daysOnlyOurs, "day", "days")} only this tool has, and could not be compared: its logs are gone from disk, and the database keeps them on purpose.`);
339
+ }
340
+ if (result.daysOnlyTheirs > 0) {
341
+ lines.push(`${plural(result.daysOnlyTheirs, "day", "days")} only ccusage reported. That is worth reporting: it means log lines this tool did not read.`);
342
+ }
343
+ if (result.unpricedModels.length > 0) {
344
+ lines.push(`Models with no price row here, which a token comparison doesn't depend on: ${result.unpricedModels.join(", ")}.`);
345
+ }
346
+ // A day ccusage sees and this tool doesn't is a reading fault; the rest is reported, not failed.
347
+ return {
348
+ lines,
349
+ exitCode: result.differences.length === 0 && result.daysOnlyTheirs === 0 ? 0 : 1,
350
+ };
351
+ }
352
+ /**
353
+ * Turns an `uninstall` outcome into output lines and an exit code.
354
+ * @param outcome - Result of `runUninstall`.
355
+ * @param deleted - What `--delete-data` removed, or null when the data was kept.
356
+ * @returns Lines for stdout and the process exit code.
357
+ */
358
+ export function describeUninstall(outcome, deleted = null) {
359
+ const { settingsPath, dataDir, backupPath, recordMissing, restoredCommand } = outcome;
360
+ // Without --delete-data nothing is removed, and the flag is named so it can be found.
361
+ const kept = deleted === null
362
+ ? `Recorded data in ${dataDir} was kept. Run uninstall --delete-data to remove it.`
363
+ : describeDeletion(dataDir, deleted).join("\n");
364
+ // What `--delete-data` did, for the branches that change nothing in the settings file. Saying
365
+ // "nothing was changed" while the data directory has gone is the same fault as D-061, pointing
366
+ // the other way, and it loses the counts that were the only record of what was in there (D-062).
367
+ const alsoDeleted = deleted === null ? [] : [kept];
368
+ // The command comes from the install record on disk, so show what's going back in (D-050).
369
+ const restored = restoredCommand === null ? [] : [`Status line command restored: ${restoredCommand}`];
370
+ // A removal that didn't happen is reported and fails, never reported as done (D-061).
371
+ const notRemoved = outcome.notRemoved.length === 0
372
+ ? []
373
+ : [
374
+ `Could not remove ${outcome.notRemoved.join(", ")}: still there after trying.`,
375
+ "The hook may still be registered there, and would keep recording. Remove it by hand.",
376
+ ];
377
+ switch (outcome.action) {
378
+ case "restored":
379
+ return {
380
+ exitCode: 0,
381
+ lines: [
382
+ recordMissing
383
+ ? `Removed the hook from ${settingsPath}. No install record was found, so no earlier status line could be restored.`
384
+ : `Removed the hook from ${settingsPath} and restored the earlier status line setting.`,
385
+ ...restored,
386
+ `Backup of the settings before this change: ${backupPath ?? "none"}`,
387
+ kept,
388
+ ],
389
+ };
390
+ case "removed-settings-file":
391
+ return {
392
+ exitCode: outcome.notRemoved.length === 0 ? 0 : 1,
393
+ lines: [
394
+ outcome.notRemoved.length === 0
395
+ ? `Removed ${settingsPath}: init had created it and it held nothing else.`
396
+ : `${settingsPath} was created by init and holds nothing else, but could not be removed.`,
397
+ ...notRemoved,
398
+ `Backup of the removed file: ${backupPath ?? "none"}`,
399
+ kept,
400
+ ],
401
+ };
402
+ case "not-installed":
403
+ return {
404
+ exitCode: 0,
405
+ lines: [
406
+ deleted === null
407
+ ? `The hook is not installed in ${settingsPath}. Nothing was changed.`
408
+ : `The hook is not installed in ${settingsPath}, so the settings file was not changed.`,
409
+ ...alsoDeleted,
410
+ ],
411
+ };
412
+ case "replaced-by-user":
413
+ return {
414
+ exitCode: 0,
415
+ lines: [
416
+ `statusLine in ${settingsPath} is no longer the hook; it was changed after install.`,
417
+ "It was left unchanged.",
418
+ ...alsoDeleted,
419
+ ],
420
+ };
421
+ }
422
+ }
423
+ /**
424
+ * Tells a known, safe-to-report error (bad input or environment) from a bug.
425
+ * @param error - Anything thrown.
426
+ * @returns True for a `SettingsError`, `InstallRecordError`, `DiscoveryError`, `PriceTableError`,
427
+ * `PlanPriceError`, `NoDatabaseError`, or `UnknownMetricError`.
428
+ */
429
+ export function isReportedError(error) {
430
+ return (error instanceof SettingsError ||
431
+ error instanceof InstallRecordError ||
432
+ error instanceof DiscoveryError ||
433
+ error instanceof PriceTableError ||
434
+ error instanceof PlanPriceError ||
435
+ error instanceof RepositoryAliasError ||
436
+ error instanceof NoDatabaseError ||
437
+ error instanceof UnknownMetricError);
438
+ }
439
+ /**
440
+ * Describes a backfill that failed after `init` had already installed the hook.
441
+ * @param error - The reported error from the ingest.
442
+ * @returns Lines for stderr. They never say "Nothing was changed": the settings file was.
443
+ */
444
+ export function describeBackfillFailure(error) {
445
+ return [
446
+ `Session logs weren't imported: ${error.message}.`,
447
+ "The hook is installed. Fix that, then run `nilometer ingest`.",
448
+ ];
449
+ }
450
+ /**
451
+ * Runs an operation and converts known, safe-to-report errors into an exit code.
452
+ * @param deps - Program dependencies, for error output.
453
+ * @param operation - Returns stdout lines, an exit code, and optionally stderr lines printed after them.
454
+ * @returns The exit code: the operation's, or 1 for a reported error.
455
+ * @throws {unknown} Any error {@link isReportedError} doesn't recognize: those are bugs, and hiding
456
+ * their stack would hide the bug.
457
+ */
458
+ export function report(deps, operation) {
459
+ try {
460
+ const { lines, exitCode, errors = [] } = operation();
461
+ lines.forEach((line) => {
462
+ deps.print(line);
463
+ });
464
+ errors.forEach((line) => {
465
+ deps.printError(line);
466
+ });
467
+ return exitCode;
468
+ }
469
+ catch (error) {
470
+ if (isReportedError(error)) {
471
+ deps.printError(`${error.message}. Nothing was changed.`);
472
+ return 1;
473
+ }
474
+ throw error;
475
+ }
476
+ }
477
+ /**
478
+ * Opens the page `report --save --html --open` saved (D-082).
479
+ * @param deps - Program dependencies; `openPage` defaults to the platform's opener.
480
+ * @param htmlPath - The saved page; always set when `--open` passed the flag check.
481
+ * @returns Nothing when it opened; otherwise one line saying why and where the page is.
482
+ */
483
+ function openSaved(deps, htmlPath) {
484
+ if (htmlPath === undefined) {
485
+ return [];
486
+ }
487
+ const opened = (deps.openPage ?? openPage)(htmlPath);
488
+ return opened.opened
489
+ ? []
490
+ : [
491
+ `Couldn't open the page (${opened.reason}). It's saved at ${displayPath(htmlPath, deps.home)}`,
492
+ ];
493
+ }
494
+ /**
495
+ * Builds the command-line program.
496
+ * @param deps - Program dependencies.
497
+ * @param setExitCode - Receives the exit code chosen by the command that ran.
498
+ * @returns A commander program that never calls `process.exit` itself.
499
+ */
500
+ export function buildProgram(deps, setExitCode) {
501
+ const program = new Command("nilometer")
502
+ .description("Reports the observed cost of a Claude subscription.")
503
+ // Lower-case -v, as npm users expect; commander's default is -V.
504
+ .version(packageVersion(deps.packageRoot), "-v, --version", "print the installed version")
505
+ // Tests and embedding need errors as exceptions, not an exiting process.
506
+ .exitOverride()
507
+ .configureOutput({
508
+ writeOut: (text) => {
509
+ deps.print(text.replace(/\n$/, ""));
510
+ },
511
+ writeErr: (text) => {
512
+ deps.printError(text.replace(/\n$/, ""));
513
+ },
514
+ });
515
+ program
516
+ .command("init")
517
+ .description("Register the status line hook in Claude Code's settings, keeping any existing status line, then backfill from session logs.")
518
+ .option("--settings <path>", "settings file to change (default: ~/.claude/settings.json)")
519
+ .option("--data-dir <path>", "where readings are recorded (default: ~/.local/share/nilometer)")
520
+ .action((flags) => {
521
+ setExitCode(report(deps, () => {
522
+ const described = describeInit(runInit(toInstallOptions(flags, deps)));
523
+ if (described.exitCode !== 0) {
524
+ return described;
525
+ }
526
+ // Backfill right away: session logs are deleted after 30 days, so waiting loses history.
527
+ try {
528
+ const backfill = runIngestCommand(toIngestOptions({ dataDir: flags.dataDir }, deps));
529
+ return {
530
+ exitCode: 0,
531
+ lines: [
532
+ ...described.lines,
533
+ "Backfill from session logs:",
534
+ ...describeIngest(backfill),
535
+ ],
536
+ };
537
+ }
538
+ catch (error) {
539
+ // The hook is already installed, so report()'s "Nothing was changed" would be false.
540
+ // Show what init did, then why the backfill didn't run; exit 1 because it didn't finish.
541
+ if (!isReportedError(error)) {
542
+ throw error;
543
+ }
544
+ return { exitCode: 1, lines: described.lines, errors: describeBackfillFailure(error) };
545
+ }
546
+ }));
547
+ });
548
+ program
549
+ .command("ingest")
550
+ .description("Read new session log lines and status line readings into the database.")
551
+ .option("--data-dir <path>", "data directory holding the database and spool")
552
+ .option("--full", "reread every file from the start instead of resuming")
553
+ .option("--json", "print the outcome as JSON on stdout")
554
+ .action((flags) => {
555
+ setExitCode(report(deps, () => {
556
+ const outcome = runIngestCommand(toIngestOptions(flags, deps));
557
+ return {
558
+ exitCode: 0,
559
+ lines: flags.json === true ? [JSON.stringify(outcome, null, 2)] : describeIngest(outcome),
560
+ };
561
+ }));
562
+ });
563
+ program
564
+ .command("report")
565
+ .description("Print what was observed, then projections in a separate section, from the database. Run ingest first for the latest data.")
566
+ .option("--data-dir <path>", "data directory holding the database")
567
+ .option("--json", "print unrounded rows and the report's labels as JSON")
568
+ .option("--save", "also save a dated copy (text and JSON) in the data directory's reports folder")
569
+ .option("--html", "with --save, also write the report as a self-contained HTML page")
570
+ .option("--open", "with --save --html, open the saved page in your browser")
571
+ .action((flags) => {
572
+ setExitCode(report(deps, () => {
573
+ // Refused before the database is opened, so the bad combination changes nothing: not even
574
+ // the owner-only tightening a read would do (D-043). Same exit code and stderr as any
575
+ // other rejected input.
576
+ if (flags.open === true && (flags.html !== true || flags.save !== true)) {
577
+ return { exitCode: 1, lines: [], errors: [OPEN_NEEDS_HTML] };
578
+ }
579
+ if (flags.html === true && flags.save !== true) {
580
+ return { exitCode: 1, lines: [], errors: [HTML_NEEDS_SAVE] };
581
+ }
582
+ /**
583
+ * Says on stderr that data paths were made owner-only.
584
+ * @param tightened - Paths that were tightened.
585
+ */
586
+ const onTightened = (tightened) => {
587
+ noteTightened(deps, tightened);
588
+ };
589
+ // One instant is the report's time (D-083: a window is open only until its reset, as
590
+ // of now), names the saved files, and dates the page, so none of them can disagree.
591
+ const reportedAt = deps.now();
592
+ const reportOptions = {
593
+ ...toPlanDatabaseOptions(flags, deps),
594
+ timeZone: deps.timeZone,
595
+ reportedAt,
596
+ // Where init would look, so the warning checks the same file it would fix (D-081).
597
+ settingsPath: locateSettings({
598
+ home: deps.home,
599
+ claudeConfigDir: deps.env["CLAUDE_CONFIG_DIR"],
600
+ }),
601
+ };
602
+ const at = flags.save === true ? reportedAt : undefined;
603
+ const { input, html } = withReportDatabase(reportOptions, (db, databasePath) =>
604
+ // One read transaction for the text and the page: they come from one snapshot of the
605
+ // database, so an ingest finishing between the two reads can't make them disagree
606
+ // (D-070: "the same views, so the two can't disagree").
607
+ transaction(db, () => {
608
+ const read = readReport(db, databasePath, reportOptions);
609
+ return {
610
+ input: read,
611
+ html: at !== undefined && flags.html === true
612
+ ? renderHtmlReport(
613
+ // Reuses what readReport just read from this snapshot, so the costliest
614
+ // views are computed once (D-070).
615
+ // The home directory shortens repository paths to ~/..., as in the text report.
616
+ loadHtmlData(db, {
617
+ timeZone: deps.timeZone,
618
+ generatedAt: at,
619
+ home: deps.home,
620
+ missingHookPath: read.missingHookPath,
621
+ }, read))
622
+ : undefined,
623
+ };
624
+ }), onTightened);
625
+ const printed = flags.json === true ? JSON.stringify(reportJson(input), null, 2) : renderReport(input);
626
+ if (at === undefined) {
627
+ return { exitCode: 0, lines: [printed] };
628
+ }
629
+ const saved = saveReport(dirname(input.databasePath), input, at, html);
630
+ const textShown = displayPath(saved.textPath, deps.home);
631
+ const jsonShown = displayPath(saved.jsonPath, deps.home);
632
+ const note = saved.htmlPath === undefined
633
+ ? `Saved: ${textShown} and ${jsonShown}`
634
+ : `Saved: ${textShown}, ${jsonShown}, and ${displayPath(saved.htmlPath, deps.home)}`;
635
+ // The page is saved either way; a machine with no browser (SSH, a script) isn't an error.
636
+ // Printed last, on stderr, so it follows the Saved line and --json stdout stays pure (D-082).
637
+ const notOpened = flags.open === true ? openSaved(deps, saved.htmlPath) : [];
638
+ if (flags.json === true) {
639
+ // stdout must stay pure JSON for tools reading it.
640
+ deps.printError(note);
641
+ return { exitCode: 0, lines: [printed], errors: notOpened };
642
+ }
643
+ return { exitCode: 0, lines: [printed, "", note], errors: notOpened };
644
+ }));
645
+ });
646
+ program
647
+ .command("explain <metric>")
648
+ .description(`List the events behind a number in the report, with where each was read from, and re-add them. Metrics: ${METRIC_NAMES.join(", ")}.`)
649
+ .option("--data-dir <path>", "data directory holding the database")
650
+ .option("--all", `list every event, not only the first ${EXPLAIN_EVENT_LIMIT} per number`)
651
+ .action((metric, flags) => {
652
+ setExitCode(report(deps, () => {
653
+ const options = toPlanDatabaseOptions(flags, deps);
654
+ // An unknown name fails before the database is opened.
655
+ if (!METRIC_NAMES.includes(metric)) {
656
+ throw new UnknownMetricError(metric);
657
+ }
658
+ const explanation = withReportDatabase(options, (db) => explain(db, metric), (tightened) => {
659
+ noteTightened(deps, tightened);
660
+ });
661
+ return {
662
+ exitCode: 0,
663
+ lines: renderExplanation(explanation, deps.timeZone, deps.home, flags.all === true ? Number.POSITIVE_INFINITY : EXPLAIN_EVENT_LIMIT),
664
+ };
665
+ }));
666
+ });
667
+ const planPrice = program
668
+ .command("plan-price")
669
+ .description("Enter or list the plan's USD list price, shown beside API list price.");
670
+ planPrice
671
+ .command("set <month> <usd>")
672
+ .description("Record the plan's USD list price per month from <month> (YYYY-MM) on. Re-entering a month replaces it.")
673
+ .requiredOption("--name <plan>", "the plan's name")
674
+ .option("--data-dir <path>", "data directory holding the database")
675
+ .action((month, usd, flags) => {
676
+ setExitCode(report(deps, () => {
677
+ // Validate before opening the database, so a typo creates nothing.
678
+ const entry = validatePlanPrice(month, usd, flags.name ?? "");
679
+ const { databasePath, result, tightened } = withPlanDatabase(toPlanDatabaseOptions(flags, deps), (db) => setPlanPrice(db, entry, deps.now));
680
+ noteTightened(deps, tightened);
681
+ return { exitCode: 0, lines: describePlanPriceSet(entry, result, databasePath) };
682
+ }));
683
+ });
684
+ planPrice
685
+ .command("list")
686
+ .description("List the entered plan prices.")
687
+ .option("--data-dir <path>", "data directory holding the database")
688
+ .action((flags) => {
689
+ setExitCode(report(deps, () => {
690
+ const { result, tightened } = withPlanDatabase(toPlanDatabaseOptions(flags, deps), listPlanPrices);
691
+ noteTightened(deps, tightened);
692
+ return { exitCode: 0, lines: describePlanPriceList(result) };
693
+ }));
694
+ });
695
+ // D-092: a renamed or moved project's old path counts under its new one.
696
+ const alias = program
697
+ .command("alias")
698
+ .description("Count a renamed or moved project's old path under its new one.");
699
+ alias
700
+ .command("set <old-path> <new-path>")
701
+ .description("Count every request under <old-path> as <new-path>. Re-entering an old path replaces its target.")
702
+ .option("--data-dir <path>", "data directory holding the database")
703
+ .action((from, to, flags) => {
704
+ setExitCode(report(deps, () => {
705
+ // Validate before opening the database, so a typo creates nothing.
706
+ const entry = validateAlias(from, to, deps.home);
707
+ const { databasePath, result, tightened } = withPlanDatabase(toPlanDatabaseOptions(flags, deps), (db) => setAlias(db, entry, deps.now));
708
+ noteTightened(deps, tightened);
709
+ return {
710
+ exitCode: 0,
711
+ lines: describeAliasSet(entry, result, databasePath, deps.home),
712
+ };
713
+ }));
714
+ });
715
+ alias
716
+ .command("list")
717
+ .description("List the entered aliases.")
718
+ .option("--data-dir <path>", "data directory holding the database")
719
+ .action((flags) => {
720
+ setExitCode(report(deps, () => {
721
+ const { result, tightened } = withPlanDatabase(toPlanDatabaseOptions(flags, deps), listAliases);
722
+ noteTightened(deps, tightened);
723
+ return { exitCode: 0, lines: describeAliasList(result, deps.home) };
724
+ }));
725
+ });
726
+ alias
727
+ .command("remove <old-path>")
728
+ .description("Stop counting <old-path> under another path.")
729
+ .option("--data-dir <path>", "data directory holding the database")
730
+ .action((from, flags) => {
731
+ setExitCode(report(deps, () => {
732
+ const path = normalizeRepositoryPath(from, deps.home);
733
+ const { result, tightened } = withPlanDatabase(toPlanDatabaseOptions(flags, deps), (db) => removeAlias(db, path));
734
+ noteTightened(deps, tightened);
735
+ return {
736
+ exitCode: 0,
737
+ lines: [
738
+ `${displayPath(result.from, deps.home)} no longer counts under ${displayPath(result.to, deps.home)}.`,
739
+ ],
740
+ };
741
+ }));
742
+ });
743
+ program
744
+ .command("verify")
745
+ .description("Compare this tool's token totals against ccusage, on your own logs. Prints a verdict and any differing days, never a path, repository name, or prompt. Downloads ccusage through npx, so it needs the network.")
746
+ .option("--data-dir <path>", "data directory holding the database")
747
+ .action((flags) => {
748
+ setExitCode(report(deps, () => withReportDatabase(toPlanDatabaseOptions(flags, deps), (db) => describeVerify(runVerify({ db, runCcusage: deps.runCcusage ?? runCcusage })))));
749
+ });
750
+ program
751
+ .command("uninstall")
752
+ .description("Remove the hook and restore the status line setting it replaced. Recorded data is kept unless --delete-data is given.")
753
+ .option("--settings <path>", "settings file to change, if no install record names it")
754
+ .option("--data-dir <path>", "data directory holding the install record")
755
+ .option("--delete-data", "also delete the recorded data: the database, the status line spool, and saved reports. This can't be undone")
756
+ .action((flags) => {
757
+ setExitCode(report(deps, () => {
758
+ const outcome = runUninstall(toInstallOptions(flags, deps));
759
+ if (flags.deleteData !== true) {
760
+ return describeUninstall(outcome);
761
+ }
762
+ // Read what is there before removing it: afterwards there is nothing left to ask.
763
+ const summary = summarizeStoredData(toPlanDatabaseOptions(flags, deps));
764
+ const deletion = deleteDataFiles(outcome.dataDir);
765
+ const described = describeUninstall(outcome, { summary, deletion });
766
+ // A file Nilometer wrote and could not remove is a failure, whatever else went right.
767
+ return deletion.failed.length === 0 ? described : { ...described, exitCode: 1 };
768
+ }));
769
+ });
770
+ return program;
771
+ }
772
+ /**
773
+ * Parses arguments and runs the chosen command.
774
+ * @param argv - User arguments, without the node binary and script path.
775
+ * @param deps - Program dependencies.
776
+ * @returns The process exit code.
777
+ * @throws {unknown} Errors from commands that aren't reportable (see {@link report}).
778
+ */
779
+ export function runCli(argv, deps) {
780
+ let exitCode = 0;
781
+ const program = buildProgram(deps, (code) => {
782
+ exitCode = code;
783
+ });
784
+ // With no command, show help rather than doing nothing silently.
785
+ if (argv.length === 0) {
786
+ program.outputHelp();
787
+ return 0;
788
+ }
789
+ try {
790
+ program.parse([...argv], { from: "user" });
791
+ }
792
+ catch (error) {
793
+ // commander signals --help and --version as errors with exit code 0 under exitOverride.
794
+ if (error instanceof CommanderError) {
795
+ return error.exitCode;
796
+ }
797
+ throw error;
798
+ }
799
+ return exitCode;
800
+ }
801
+ //# sourceMappingURL=program.js.map