nilometer 0.0.0-stage → 0.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.
- package/CHANGELOG.md +68 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/core/schema/032_stored_views.sql +233 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +881 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +219 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/ingest/stored-views.js +65 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/core/view/server.js +142 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1717 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- package/package.json +61 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Opening the database and assembling the report's input (docs/development.md P7.1).
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { openDatabase } from "../core/db/database.js";
|
|
7
|
+
import { DATABASE_FILE } from "../core/ingest/command.js";
|
|
8
|
+
import { resolveDataDir } from "../core/install/locations.js";
|
|
9
|
+
import { securePrivateDataDir } from "../core/install/private-files.js";
|
|
10
|
+
import { checkHook } from "../core/settings/statusline.js";
|
|
11
|
+
import { lastIngestAt, loadObserved, loadProjected } from "./queries.js";
|
|
12
|
+
/** There is no database to report on yet. */
|
|
13
|
+
export class NoDatabaseError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* Creates the error.
|
|
16
|
+
* @param databasePath - The path that doesn't exist.
|
|
17
|
+
*/
|
|
18
|
+
constructor(databasePath) {
|
|
19
|
+
super(`No database at ${databasePath}; run init or ingest first`);
|
|
20
|
+
this.name = "NoDatabaseError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Opens the data directory's existing database, runs an operation on it, and closes it.
|
|
25
|
+
* @param options - Where the database lives.
|
|
26
|
+
* @param use - The operation.
|
|
27
|
+
* @param onTightened - Called with any data paths made owner-only before reading (D-043).
|
|
28
|
+
* @returns The operation's result.
|
|
29
|
+
* @throws {NoDatabaseError} If the data directory has no database yet. Nothing is created.
|
|
30
|
+
*/
|
|
31
|
+
export function withReportDatabase(options, use, onTightened = () => undefined) {
|
|
32
|
+
const databasePath = join(resolveDataDir({ home: options.home, env: options.env, override: options.dataDirOverride }), DATABASE_FILE);
|
|
33
|
+
// Reading must not create an empty database and then describe it as "no data yet".
|
|
34
|
+
if (!existsSync(databasePath)) {
|
|
35
|
+
throw new NoDatabaseError(databasePath);
|
|
36
|
+
}
|
|
37
|
+
// Even a read tightens: an install from before D-043 may still be readable by other accounts.
|
|
38
|
+
const tightened = securePrivateDataDir(dirname(databasePath));
|
|
39
|
+
if (tightened.length > 0) {
|
|
40
|
+
onTightened(tightened);
|
|
41
|
+
}
|
|
42
|
+
const db = openDatabase(databasePath, join(options.packageRoot, "core", "schema"));
|
|
43
|
+
try {
|
|
44
|
+
return use(db, databasePath);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads everything the report shows from a database that's already open, so a caller can read more
|
|
52
|
+
* from the same connection (the HTML page, D-070) inside one transaction.
|
|
53
|
+
* @param db - The open, migrated database.
|
|
54
|
+
* @param databasePath - Where it lives, carried into the input for the saved copies.
|
|
55
|
+
* @param options - Display zone and home directory.
|
|
56
|
+
* @returns The report input.
|
|
57
|
+
*/
|
|
58
|
+
export function readReport(db, databasePath, options) {
|
|
59
|
+
return {
|
|
60
|
+
observed: loadObserved(db, options.reportedAt),
|
|
61
|
+
projected: loadProjected(db, options.reportedAt),
|
|
62
|
+
timeZone: options.timeZone,
|
|
63
|
+
lastIngestAt: lastIngestAt(db),
|
|
64
|
+
databasePath,
|
|
65
|
+
home: options.home,
|
|
66
|
+
// Read only: settings are never written here (D-081).
|
|
67
|
+
missingHookPath: missingHook(options.settingsPath),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Checks the status line hook, when there's a settings file to check (D-081).
|
|
72
|
+
* @param settingsPath - The settings file, or undefined to skip the check.
|
|
73
|
+
* @returns The missing hook's path; null when present or not ours; undefined when unchecked.
|
|
74
|
+
*/
|
|
75
|
+
function missingHook(settingsPath) {
|
|
76
|
+
if (settingsPath === undefined) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const check = checkHook(settingsPath);
|
|
80
|
+
return check.kind === "missing" ? check.hookPath : null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Reads everything the report shows.
|
|
84
|
+
* @param options - Data directory and display zone.
|
|
85
|
+
* @param onTightened - Called with any data paths made owner-only before reading (D-043).
|
|
86
|
+
* @returns The report input.
|
|
87
|
+
* @throws {NoDatabaseError} If the data directory has no database yet. Nothing is created.
|
|
88
|
+
*/
|
|
89
|
+
export function loadReport(options, onTightened = () => undefined) {
|
|
90
|
+
return withReportDatabase(options, (db, databasePath) => readReport(db, databasePath, options), onTightened);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Summarizes what is recorded, so a command that deletes it can say what it removed.
|
|
94
|
+
*
|
|
95
|
+
* The same sources `ingest` counts from, so the two agree. Deleting this data is not reversible:
|
|
96
|
+
* it holds copies of session logs Claude Code removes after 30 days, which is the whole reason
|
|
97
|
+
* ingestion copies them (R2.5).
|
|
98
|
+
* @param options - Home, environment, package root, and any `--data-dir` override.
|
|
99
|
+
* @returns The summary, or null when there is no database to read.
|
|
100
|
+
* @throws {unknown} Whatever opening or reading the database throws, other than a missing one.
|
|
101
|
+
*/
|
|
102
|
+
export function summarizeStoredData(options) {
|
|
103
|
+
try {
|
|
104
|
+
return withReportDatabase(options, (db) => db
|
|
105
|
+
.prepare(`SELECT
|
|
106
|
+
(SELECT COUNT(*) FROM requests_dedup) AS requests,
|
|
107
|
+
(SELECT COUNT(*) FROM status_readings WHERE status = 'ok') AS readings,
|
|
108
|
+
(SELECT MIN(timestamp_utc) FROM requests_dedup) AS firstRequestUtc,
|
|
109
|
+
(SELECT MAX(timestamp_utc) FROM requests_dedup) AS lastRequestUtc`)
|
|
110
|
+
.get());
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// Nothing recorded yet is not a failure: there is simply nothing to describe.
|
|
114
|
+
if (error instanceof NoDatabaseError) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Saving a dated copy of the report (D-030), and with `--html` its HTML page (D-070).
|
|
3
|
+
*
|
|
4
|
+
* A report is recomputed from the database on every run, so a saved copy is the only record of what
|
|
5
|
+
* the screen said at a given time, before later price rows, view changes, or repository re-resolution
|
|
6
|
+
* (D-020, D-028) change the numbers. Copies hold personal usage and spend figures, so they go to the
|
|
7
|
+
* data directory with owner-only permissions, never to the working directory (D-043). The HTML page
|
|
8
|
+
* also embeds the events behind every drawn element, session IDs included, so it gets the same
|
|
9
|
+
* treatment.
|
|
10
|
+
*
|
|
11
|
+
* Implements README "Commands" (`nilometer report --save`) and step G1.4.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { renderReport, reportJson } from "./render.js";
|
|
16
|
+
/** Folder inside the data directory that holds saved reports. */
|
|
17
|
+
export const REPORTS_DIR = "reports";
|
|
18
|
+
/** Most name variants tried for one second before giving up. */
|
|
19
|
+
export const MAX_NAME_ATTEMPTS = 100;
|
|
20
|
+
/**
|
|
21
|
+
* Builds a saved report's file name stem in the display zone.
|
|
22
|
+
* @param at - When the report was produced.
|
|
23
|
+
* @param timeZone - IANA zone the report shows times in.
|
|
24
|
+
* @returns `report_YYYY-MM-DD_HHMMSS`, which sorts chronologically within one zone.
|
|
25
|
+
* @example
|
|
26
|
+
* reportStem(new Date("2026-09-14T00:30:05Z"), "America/Chicago"); // "report_2026-09-13_193005"
|
|
27
|
+
*/
|
|
28
|
+
export function reportStem(at, timeZone) {
|
|
29
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
30
|
+
timeZone,
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
hourCycle: "h23",
|
|
38
|
+
}).formatToParts(at);
|
|
39
|
+
/**
|
|
40
|
+
* Finds one part's value.
|
|
41
|
+
* @param type - Part type.
|
|
42
|
+
* @returns The value.
|
|
43
|
+
*/
|
|
44
|
+
const part = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
45
|
+
return `report_${part("year")}-${part("month")}-${part("day")}_${part("hour")}${part("minute")}${part("second")}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Writes one file, failing instead of replacing an existing one.
|
|
49
|
+
* @param path - Target path.
|
|
50
|
+
* @param content - File content.
|
|
51
|
+
* @returns True when written; false when the path already exists.
|
|
52
|
+
* @throws {Error} For any failure other than the file already existing.
|
|
53
|
+
*/
|
|
54
|
+
function writeNew(path, content) {
|
|
55
|
+
try {
|
|
56
|
+
// "wx" fails on an existing file, so two saves in the same second can't overwrite each other.
|
|
57
|
+
writeFileSync(path, content, { flag: "wx", mode: 0o600 });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === "EEXIST") {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Saves the report as text and JSON under the data directory, and optionally as an HTML page.
|
|
69
|
+
*
|
|
70
|
+
* All the files of one save share one stem, so a save's files can be picked out by name alone:
|
|
71
|
+
* the text file claims the stem, and its twins are written under it.
|
|
72
|
+
* @param dataDir - The data directory holding the database.
|
|
73
|
+
* @param input - The report input, as rendered.
|
|
74
|
+
* @param at - When the report was produced.
|
|
75
|
+
* @param html - The rendered HTML page (D-070), written as given; no `.html` file is written when
|
|
76
|
+
* it's omitted.
|
|
77
|
+
* @returns The paths written.
|
|
78
|
+
* @throws {Error} If {@link MAX_NAME_ATTEMPTS} names for this second are all taken, if a twin of a
|
|
79
|
+
* newly claimed text file already exists, or if writing fails.
|
|
80
|
+
*/
|
|
81
|
+
export function saveReport(dataDir, input, at, html) {
|
|
82
|
+
const dir = join(dataDir, REPORTS_DIR);
|
|
83
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
84
|
+
const stem = reportStem(at, input.timeZone);
|
|
85
|
+
const text = `${renderReport(input)}\n`;
|
|
86
|
+
const json = `${JSON.stringify(reportJson(input), null, 2)}\n`;
|
|
87
|
+
for (let attempt = 1; attempt <= MAX_NAME_ATTEMPTS; attempt += 1) {
|
|
88
|
+
const name = attempt === 1 ? stem : `${stem}-${attempt}`;
|
|
89
|
+
const textPath = join(dir, `${name}.txt`);
|
|
90
|
+
const jsonPath = join(dir, `${name}.json`);
|
|
91
|
+
if (writeNew(textPath, text)) {
|
|
92
|
+
// The text file claimed the name; its twins can't exist unless someone made them by hand.
|
|
93
|
+
if (!writeNew(jsonPath, json)) {
|
|
94
|
+
throw new Error(`${jsonPath} already exists though ${textPath} didn't`);
|
|
95
|
+
}
|
|
96
|
+
if (html === undefined) {
|
|
97
|
+
return { textPath, jsonPath };
|
|
98
|
+
}
|
|
99
|
+
const htmlPath = join(dir, `${name}.html`);
|
|
100
|
+
// Same rule as the JSON twin: a page found under a claimed stem is never replaced, since it
|
|
101
|
+
// would then sit beside text it wasn't drawn from.
|
|
102
|
+
if (!writeNew(htmlPath, html)) {
|
|
103
|
+
throw new Error(`${htmlPath} already exists though ${textPath} didn't`);
|
|
104
|
+
}
|
|
105
|
+
return { textPath, jsonPath, htmlPath };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`${MAX_NAME_ATTEMPTS} report names for ${stem} are already taken in ${dir}`);
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=save.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Requirements
|
|
2
|
+
|
|
3
|
+
What Nilometer needs on your computer before you install it, how to check whether you already
|
|
4
|
+
have it, and how to get it if you don't. When everything below checks out, continue with
|
|
5
|
+
[Install and use](../README.md#install-and-use) in the README.
|
|
6
|
+
|
|
7
|
+
| You need | Why | Check with |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **Claude Code**, signed in with a Pro or Max subscription | Nilometer reads what Claude Code records | `claude --version` |
|
|
10
|
+
| **Node.js 24 or 26** (npm comes with it) | Nilometer is built and run with it | `node --version` prints `v24.` or `v26.` followed by more numbers |
|
|
11
|
+
| **git**, only to install from a clone | To download Nilometer's source; `npm install -g nilometer` doesn't need it | `git --version` |
|
|
12
|
+
| **Windows only: Git for Windows** | It provides Git Bash, which the status line hook and every Nilometer command run in | Git Bash is in the Start menu |
|
|
13
|
+
|
|
14
|
+
## First, open a terminal
|
|
15
|
+
|
|
16
|
+
- **macOS:** open **Terminal** (in Applications, then Utilities).
|
|
17
|
+
- **Windows:** open **Git Bash** from the Start menu, once Git for Windows is installed (see
|
|
18
|
+
[git](#git) below). Use Git Bash for every command in this guide and in the README, not
|
|
19
|
+
PowerShell or Command Prompt.
|
|
20
|
+
- **Linux:** open your terminal.
|
|
21
|
+
|
|
22
|
+
Run the checks from the table. If one prints `command not found` (or, on Windows, "is not
|
|
23
|
+
recognized"), follow that section below. After installing anything, close the terminal and open a
|
|
24
|
+
new one, so it sees the new program.
|
|
25
|
+
|
|
26
|
+
## Node.js 24 or 26
|
|
27
|
+
|
|
28
|
+
Nilometer needs **Node.js 24 or 26**, the long-term support releases it's tested on. If you
|
|
29
|
+
already have one of them, skip this section. If you're installing Node.js now, either works; the
|
|
30
|
+
steps below use 24.
|
|
31
|
+
|
|
32
|
+
- **Older versions don't work.** Nilometer uses the SQLite built into Node.js, which older
|
|
33
|
+
versions lack or have only as an experiment, so `nilometer` checks first and stops with a
|
|
34
|
+
message instead:
|
|
35
|
+
`Nilometer needs Node.js 24 or newer, and this is Node.js 22.11.0. ...`
|
|
36
|
+
- **Other versions aren't tested.** Nilometer runs on them (25, or anything newer than 26) and
|
|
37
|
+
prints a one-line warning each time. If anything fails, switch to 24 or 26.
|
|
38
|
+
- **"The latest Node.js" may not be one of them.** nodejs.org also offers newer releases, and
|
|
39
|
+
`brew install node` installs the newest one. Choose 24 or 26 on purpose, using one of the two
|
|
40
|
+
ways below.
|
|
41
|
+
|
|
42
|
+
### The simple way: the official installer (macOS, Windows)
|
|
43
|
+
|
|
44
|
+
1. Open <https://nodejs.org/dist/latest-v24.x/>. This page always lists the newest release of
|
|
45
|
+
Node.js 24.
|
|
46
|
+
2. Download the installer for your computer:
|
|
47
|
+
- **macOS:** the file ending in `.pkg`, for example `node-v24.21.0.pkg`.
|
|
48
|
+
- **Windows:** the file ending in `-x64.msi`, for example `node-v24.21.0-x64.msi`. On a
|
|
49
|
+
Windows computer with an ARM processor, use the one ending in `-arm64.msi`.
|
|
50
|
+
3. Open it and accept the defaults.
|
|
51
|
+
4. Open a new terminal and run `node --version`. It should start with `v24.`
|
|
52
|
+
|
|
53
|
+
If it still shows another version, an earlier Node.js install comes first on your computer.
|
|
54
|
+
Either remove that one, or use a version manager instead.
|
|
55
|
+
|
|
56
|
+
### The flexible way: a version manager (macOS, Linux, Windows)
|
|
57
|
+
|
|
58
|
+
A version manager keeps several Node.js versions side by side and switches between them. Use this
|
|
59
|
+
if you already have a different Node.js that other projects need, or if you're on Linux. These
|
|
60
|
+
steps use [fnm](https://github.com/Schniz/fnm).
|
|
61
|
+
|
|
62
|
+
1. **Install fnm:**
|
|
63
|
+
- **macOS:** `brew install fnm`, or `curl -fsSL https://fnm.vercel.app/install | bash`
|
|
64
|
+
- **Linux:** `curl -fsSL https://fnm.vercel.app/install | bash`
|
|
65
|
+
- **Windows:** in PowerShell, `winget install Schniz.fnm`
|
|
66
|
+
2. **Let your terminal find it.** Add one line to your shell's settings file, unless the install
|
|
67
|
+
already did (look in the file first):
|
|
68
|
+
- **zsh** (the macOS default), in `~/.zshrc`: `eval "$(fnm env --use-on-cd --shell zsh)"`
|
|
69
|
+
- **bash** (Linux, and Git Bash on Windows), in `~/.bashrc`:
|
|
70
|
+
`eval "$(fnm env --use-on-cd --shell bash)"`
|
|
71
|
+
3. **Install Node.js 24** in a new terminal:
|
|
72
|
+
```sh
|
|
73
|
+
fnm install 24
|
|
74
|
+
fnm default 24
|
|
75
|
+
```
|
|
76
|
+
4. Run `node --version`. It should start with `v24.`
|
|
77
|
+
|
|
78
|
+
Nilometer's folder contains a `.nvmrc` file that names version 24, so with the line from step 2,
|
|
79
|
+
fnm switches to it whenever you're in that folder.
|
|
80
|
+
|
|
81
|
+
## git
|
|
82
|
+
|
|
83
|
+
- **macOS:** run `git --version`. If git isn't installed, macOS offers to install the "command
|
|
84
|
+
line developer tools", which include it. Accept, then run `git --version` again.
|
|
85
|
+
- **Windows:** install **Git for Windows** from <https://git-scm.com/downloads/win>. The default
|
|
86
|
+
options are fine. It installs both git and **Git Bash**.
|
|
87
|
+
- **Linux:** use your package manager, for example `sudo apt install git` on Debian or Ubuntu,
|
|
88
|
+
or `sudo dnf install git` on Fedora.
|
|
89
|
+
|
|
90
|
+
## Claude Code
|
|
91
|
+
|
|
92
|
+
Install Claude Code by following Anthropic's guide at <https://code.claude.com/docs/en/setup>.
|
|
93
|
+
Then run `claude` once and sign in with a Pro or Max subscription. `claude --version` should print
|
|
94
|
+
a version number.
|
|
95
|
+
|
|
96
|
+
- **Use it in a terminal for full results.** Nilometer's usage-window numbers (headroom, peak,
|
|
97
|
+
burn rate) come from Claude Code's status line, which only runs when Claude Code runs in a
|
|
98
|
+
terminal. The VS Code extension doesn't run it. Its tokens and limit hits are still read from
|
|
99
|
+
Claude Code's logs.
|
|
100
|
+
- **Windows:** with Git for Windows installed, Claude Code runs the status line through Git Bash,
|
|
101
|
+
which Nilometer's hook needs. Without Git for Windows, it uses PowerShell, and the hook can't
|
|
102
|
+
run.
|
|
103
|
+
|
|
104
|
+
## When something goes wrong
|
|
105
|
+
|
|
106
|
+
| What you see | What it means |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `node: command not found`, or `npm: command not found` | Node.js isn't installed, or the terminal was open before you installed it. Open a new terminal. |
|
|
109
|
+
| `Nilometer needs Node.js 24 or newer, and this is Node.js ...` | Your Node.js is older than 24. Install 24 as above. Nothing was changed. |
|
|
110
|
+
| `Nilometer is tested on Node.js 24 or 26, and this is Node.js ...` | Your Node.js is neither. The command still runs. If it fails, switch to 24 or 26. |
|
|
111
|
+
| `EBADENGINE` warnings during `npm ci`, on a Node.js 24 older than 24.15 | Harmless: three development tools ask for a newer 24. The install still succeeds. |
|
|
112
|
+
| `spawn sh ENOENT`, or `sh` not found, on Windows | The command ran in PowerShell or Command Prompt. Run it in Git Bash. |
|
|
113
|
+
| `git: command not found` | Install git as above, then open a new terminal. |
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# nilometer status line hook.
|
|
3
|
+
#
|
|
4
|
+
# Claude Code runs the configured statusLine command after every assistant turn and passes a JSON
|
|
5
|
+
# payload on stdin. This script is a passive tap on that hook (README § How it works, D-008, D-018):
|
|
6
|
+
#
|
|
7
|
+
# 1. Copy stdin to a temp file, so the exact bytes survive both uses below.
|
|
8
|
+
# 2. Run the user's original status line command, if `init` recorded one, on those bytes.
|
|
9
|
+
# Its stdout, stderr, and exit code pass through unchanged. With no original command,
|
|
10
|
+
# print a minimal default.
|
|
11
|
+
# 3. Append one spool line:
|
|
12
|
+
# {"captured_at_s":<epoch seconds>,"hook_version":1,"payload_b64":"<base64 of stdin>"}
|
|
13
|
+
#
|
|
14
|
+
# Never parse or validate the payload (that happens at ingest), never touch SQLite, never use
|
|
15
|
+
# the network, and never let a failure of this script change what the status bar shows.
|
|
16
|
+
#
|
|
17
|
+
# Usage: statusline.sh <data-dir>
|
|
18
|
+
# <data-dir>/wrapped-command optional: the original statusLine command, written by `init`
|
|
19
|
+
# <data-dir>/statusline.spool.jsonl one line appended per run
|
|
20
|
+
# <data-dir>/hook-errors.log one line per failed or refused append (error kind only, no payload)
|
|
21
|
+
#
|
|
22
|
+
# Everything this script creates is owner-only (umask 077): the spool holds status line payloads,
|
|
23
|
+
# and Claude Code keeps its own transcripts owner-only too (D-043). It refuses to append through a
|
|
24
|
+
# symbolic link, so a link planted at the spool path can't redirect payloads elsewhere.
|
|
25
|
+
#
|
|
26
|
+
# POSIX sh only, plus mktemp, cat, date, base64, tr, sed, head (D-018, portability).
|
|
27
|
+
|
|
28
|
+
# Version of the spool line format. Bump it when the line shape changes, so ingest can tell.
|
|
29
|
+
HOOK_VERSION=1
|
|
30
|
+
|
|
31
|
+
# Files and directories created below (temp file, data dir, spool, error log) are owner-only (D-043).
|
|
32
|
+
umask 077
|
|
33
|
+
|
|
34
|
+
# The data directory is the only argument. Without it there is nowhere to record; still pass through.
|
|
35
|
+
data_dir=${1:-}
|
|
36
|
+
|
|
37
|
+
# Read the original command before touching stdin. $(cat) strips trailing newlines, harmless for a
|
|
38
|
+
# shell command string (unlike the payload).
|
|
39
|
+
#
|
|
40
|
+
# A symlink here is refused (D-050): this file's contents are executed, so following a link would run
|
|
41
|
+
# a command from a file Nilometer doesn't own. `init` only ever writes a regular file.
|
|
42
|
+
wrapped=""
|
|
43
|
+
if [ -n "$data_dir" ] && [ -L "$data_dir/wrapped-command" ]; then
|
|
44
|
+
if [ ! -L "$data_dir/hook-errors.log" ]; then
|
|
45
|
+
{ printf '%s wrapped-command-symlink-refused\n' "$(date +%s)" >>"$data_dir/hook-errors.log"; } 2>/dev/null
|
|
46
|
+
fi
|
|
47
|
+
elif [ -n "$data_dir" ] && [ -f "$data_dir/wrapped-command" ]; then
|
|
48
|
+
wrapped=$(cat "$data_dir/wrapped-command" 2>/dev/null) || wrapped=""
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# The temp file lets one stdin feed both the wrapped command and the spool, byte-exact.
|
|
52
|
+
# Command substitution would strip trailing newlines from the payload (P0.3a spike).
|
|
53
|
+
tmp=$(mktemp "${TMPDIR:-/tmp}/nilometer-hook.XXXXXX" 2>/dev/null) || tmp=""
|
|
54
|
+
if [ -z "$tmp" ]; then
|
|
55
|
+
# No temp file means no way to both pass the payload on and record it. The status bar wins.
|
|
56
|
+
if [ -n "$wrapped" ]; then
|
|
57
|
+
exec /bin/sh -c "$wrapped"
|
|
58
|
+
fi
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
# Remove the temp file however the script ends; a signal exits non-zero after cleanup.
|
|
62
|
+
trap 'rm -f "$tmp"' EXIT
|
|
63
|
+
trap 'rm -f "$tmp"; exit 1' HUP INT TERM
|
|
64
|
+
|
|
65
|
+
cat >"$tmp"
|
|
66
|
+
|
|
67
|
+
status=0
|
|
68
|
+
if [ -n "$wrapped" ]; then
|
|
69
|
+
# statusLine.command is a shell string, so it runs under /bin/sh -c, as a shell-spawned command
|
|
70
|
+
# would. Its output streams straight through; nothing here reads or alters it.
|
|
71
|
+
/bin/sh -c "$wrapped" <"$tmp"
|
|
72
|
+
status=$?
|
|
73
|
+
else
|
|
74
|
+
# Minimal default: the model's display name, if present. Best-effort text extraction; printing
|
|
75
|
+
# nothing is an acceptable result, and parsing JSON properly is ingest's job, not the hook's.
|
|
76
|
+
sed -n 's/.*"display_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$tmp" 2>/dev/null | head -n 1
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
if [ -n "$data_dir" ] && [ -L "$data_dir/statusline.spool.jsonl" ]; then
|
|
80
|
+
# A symlinked spool could send payloads to a file Nilometer doesn't own: record the refusal, skip
|
|
81
|
+
# the append, and leave the status bar untouched. Never log through a symlinked error log either.
|
|
82
|
+
if [ ! -L "$data_dir/hook-errors.log" ]; then
|
|
83
|
+
{ printf '%s spool-symlink-refused\n' "$(date +%s)" >>"$data_dir/hook-errors.log"; } 2>/dev/null
|
|
84
|
+
fi
|
|
85
|
+
elif [ -n "$data_dir" ]; then
|
|
86
|
+
# Build the whole line first so it reaches the spool as one write: concurrent sessions append
|
|
87
|
+
# to the same file, and a line written in pieces can interleave with another hook's line (D-069).
|
|
88
|
+
line="{\"captured_at_s\":$(date +%s),\"hook_version\":$HOOK_VERSION,\"payload_b64\":\"$(base64 <"$tmp" | tr -d '\n')\"}"
|
|
89
|
+
# printf writes a long line in 2048-byte pieces (bash 3.2 as /bin/sh on macOS, observed), and real
|
|
90
|
+
# lines run 2.5 to 3.5 KB. cat copies a small file with one write, so the line goes into the temp
|
|
91
|
+
# file first and cat appends it. The temp file is reused: the wrapped command has finished with
|
|
92
|
+
# the payload and $line holds its base64, so there's no second temp file to create or clean up.
|
|
93
|
+
# The outer 2>/dev/null must wrap the redirections: a failing `>>` reports its error before any
|
|
94
|
+
# redirection written after it takes effect (P0.3a spike). mkdir runs only when the dir is missing.
|
|
95
|
+
if ! { { [ -d "$data_dir" ] || mkdir -p "$data_dir"; } && printf '%s\n' "$line" >"$tmp" && cat "$tmp" >>"$data_dir/statusline.spool.jsonl"; } 2>/dev/null; then
|
|
96
|
+
# Record the failure where the audit can count it. If even this fails, stay silent.
|
|
97
|
+
if [ ! -L "$data_dir/hook-errors.log" ]; then
|
|
98
|
+
{ printf '%s append-failed\n' "$(date +%s)" >>"$data_dir/hook-errors.log"; } 2>/dev/null
|
|
99
|
+
fi
|
|
100
|
+
fi
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# The wrapped command's exit code is the hook's exit code; appending never changes it.
|
|
104
|
+
exit "$status"
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nilometer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reports the observed cost of a Claude subscription: interruptions when the plan is too small, unused headroom when it is too big.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mark Fariñas",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/MVFarinas/Nilometer.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/MVFarinas/Nilometer/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MVFarinas/Nilometer#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"nilometer": "dist/cli/main.cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist/**/*.js",
|
|
21
|
+
"core/schema/*.sql",
|
|
22
|
+
"core/pricing/prices.json",
|
|
23
|
+
"hooks/statusline.sh",
|
|
24
|
+
"docs/requirements.md",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"start": "tsc -p tsconfig.build.json && node dist/cli/main.cli.js",
|
|
33
|
+
"dev": "tsx cli/main.cli.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"lint": "eslint .",
|
|
36
|
+
"format": "prettier --write .",
|
|
37
|
+
"format:check": "prettier --check .",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:coverage": "vitest run --coverage",
|
|
40
|
+
"scan:personal": "tsx scripts/audit/scan-personal.cli.ts",
|
|
41
|
+
"audit": "tsx scripts/audit/run.cli.ts",
|
|
42
|
+
"fixtures": "tsx scripts/fixtures/generate.cli.ts",
|
|
43
|
+
"compare:ccusage": "tsx scripts/compare/ccusage.cli.ts",
|
|
44
|
+
"fidelity": "python3 scripts/fidelity/reference.py --check fixtures",
|
|
45
|
+
"fidelity:loader": "tsx scripts/fidelity/loader-check.cli.ts"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "^10.0.1",
|
|
49
|
+
"@types/node": "^24.13.4",
|
|
50
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
51
|
+
"@vitest/eslint-plugin": "^1.6.27",
|
|
52
|
+
"eslint": "^10.10.0",
|
|
53
|
+
"eslint-plugin-jsdoc": "^64.3.10",
|
|
54
|
+
"prettier": "^3.9.6",
|
|
55
|
+
"tsx": "^4.23.13",
|
|
56
|
+
"typescript": "^6.0.3",
|
|
57
|
+
"typescript-eslint": "^8.70.0",
|
|
58
|
+
"vitest": "^5.0.5"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"commander": "^15.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|