prehello 0.0.0-stage → 1.0.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 (61) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -2
  4. package/THIRD_PARTY_NOTICES.md +33 -0
  5. package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
  6. package/assets/fonts/Inter-Regular.subset.ttf +0 -0
  7. package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
  8. package/assets/fonts/OFL.txt +94 -0
  9. package/dist/adapters/claude-code.js +480 -0
  10. package/dist/adapters/codex.js +184 -0
  11. package/dist/adapters/common.js +157 -0
  12. package/dist/adapters/cursor.js +275 -0
  13. package/dist/adapters/gemini-cli.js +214 -0
  14. package/dist/adapters/github-copilot.js +249 -0
  15. package/dist/adapters/index.js +16 -0
  16. package/dist/adapters/skills.js +165 -0
  17. package/dist/adapters/types.js +2 -0
  18. package/dist/agents.js +54 -0
  19. package/dist/analyze/findings.js +82 -0
  20. package/dist/analyze/grade.js +55 -0
  21. package/dist/analyze/savings.js +111 -0
  22. package/dist/analyze/summary.js +57 -0
  23. package/dist/analyze/totals.js +21 -0
  24. package/dist/calibrate.js +265 -0
  25. package/dist/cli.js +591 -0
  26. package/dist/context.js +329 -0
  27. package/dist/diff.js +182 -0
  28. package/dist/fixtures.js +52 -0
  29. package/dist/fs-safe.js +222 -0
  30. package/dist/mcp/catalog.js +44 -0
  31. package/dist/mcp/configs.js +42 -0
  32. package/dist/model.js +8 -0
  33. package/dist/platform.js +115 -0
  34. package/dist/redact.js +39 -0
  35. package/dist/render/card.js +306 -0
  36. package/dist/render/fix.js +22 -0
  37. package/dist/render/json.js +97 -0
  38. package/dist/render/markdown.js +182 -0
  39. package/dist/render/png.js +162 -0
  40. package/dist/render/sarif.js +472 -0
  41. package/dist/render/terminal.js +318 -0
  42. package/dist/render/treemap.js +58 -0
  43. package/dist/safe-write.js +122 -0
  44. package/dist/scan.js +98 -0
  45. package/dist/spec.js +27 -0
  46. package/dist/text/comments.js +60 -0
  47. package/dist/text/frontmatter.js +90 -0
  48. package/dist/text/imports.js +86 -0
  49. package/dist/tokens/counter.js +41 -0
  50. package/dist/tokens/families.js +79 -0
  51. package/dist/util/fmt.js +102 -0
  52. package/dist/util/secrets.js +35 -0
  53. package/dist/version.js +5 -0
  54. package/package.json +70 -4
  55. package/spec/calibration.json +33 -0
  56. package/spec/claude-code.json +22 -0
  57. package/spec/codex.json +14 -0
  58. package/spec/cursor.json +15 -0
  59. package/spec/gemini-cli.json +17 -0
  60. package/spec/github-copilot.json +14 -0
  61. package/spec/mcp-weights.json +62 -0
package/dist/cli.js ADDED
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const diff_1 = require("./diff");
8
+ const calibrate_1 = require("./calibrate");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_os_1 = __importDefault(require("node:os"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const scan_1 = require("./scan");
13
+ const model_1 = require("./model");
14
+ const terminal_1 = require("./render/terminal");
15
+ const fix_1 = require("./render/fix");
16
+ const json_1 = require("./render/json");
17
+ const card_1 = require("./render/card");
18
+ const png_1 = require("./render/png");
19
+ const markdown_1 = require("./render/markdown");
20
+ const sarif_1 = require("./render/sarif");
21
+ const platform_1 = require("./platform");
22
+ const fmt_1 = require("./util/fmt");
23
+ const fs_safe_1 = require("./fs-safe");
24
+ const safe_write_1 = require("./safe-write");
25
+ const spec_1 = require("./spec");
26
+ const HELP = `prehello ${scan_1.VERSION} — what your coding agent loads before you say hello.
27
+ Local-only, read-only scan. No network, no telemetry, no API key. Token counts are ESTIMATES.
28
+
29
+ Usage:
30
+ prehello [path] [options]
31
+ prehello explain grade print the Context Grade rubric
32
+ prehello calibrate [path] --file <ctx.txt|->
33
+ compare prehello with Claude Code's /context output
34
+ (--json for JSON, --save <file> writes a redacted record)
35
+ prehello diff <base.json> <head.json> [--markdown[=file]] [--json] [--fail-above <n>]
36
+ token change between two snapshots (exit 2 over the limit)
37
+
38
+ Options:
39
+ --agent <list> comma list of agents to force "in use":
40
+ ${model_1.ALL_AGENTS.join(", ")}
41
+ --window <n|200k|1m> context window for percentages (default 200k)
42
+ --json print the report as JSON to stdout (versioned schema)
43
+ --json=<file> also write the JSON to <file>
44
+ --save <file> save a snapshot (the JSON report) for prehello diff
45
+ --badge <file> write a shields.io endpoint badge JSON (see docs/github-action.md)
46
+ --redact strict JSON: no paths or names (share-safe)
47
+ --card[=<file>] write a share card (no paths or file contents). The file extension picks the format:
48
+ .svg (default name prehello-card.svg) or .png (prehello-card.png)
49
+ name the file with "=" or a space: --card=card.png or --card card.png
50
+ --markdown[=<file>] a shareable Markdown report (title, headline, tables, what to cut, measured vs not measured) for a
51
+ GitHub issue / PR / README / Slack. Without a file it is printed to stdout INSTEAD of the terminal
52
+ report (pipe it: prehello --markdown | pbcopy); with a file (.md or .markdown) it is written there
53
+ and the terminal report is still printed. No paths, file contents or finding texts, ever.
54
+ --markdown-compact the short version for a PR comment: headline, one line per agent, the first things to cut
55
+ (implies --markdown; combine with --markdown=<file> to save it)
56
+ --sarif[=<file>] SARIF 2.1.0 for GitHub code scanning / IDEs (rules PH001-PH011: large always-on files, heavy plugins and MCP servers,
57
+ over --max-tokens, unmeasured items, duplicates, over-long / truncated / ignored files, possible secrets).
58
+ Without a file it is printed to stdout INSTEAD of the terminal report; with a file (.sarif or .sarif.json)
59
+ it is written there and the terminal report is still printed. File locations only for files inside the
60
+ scanned folder (relative to %SRCROOT%: run it from the repository root); never home or user-config paths.
61
+ --card-anonymize show plugin and MCP server names on the card and in the Markdown report as "plugin 1" / "MCP server 1"
62
+ (implied by --redact; --anonymize is the same flag). In SARIF, --redact also drops every file location.
63
+ --card-format svg|png format when --card has no file name (default svg); must match the extension if both are given
64
+ --card-theme dark|light --card-size og|square --card-agent <id>
65
+ --card-scale 1|2 PNG only: 1 = 1200x630 (square 1080x1080), 2 = double that, sharper (default 2)
66
+ --card-headline cut|total (card and Markdown report) lead with "skills/plugins cost ~X before hello; cutting the top N saves ~Y"
67
+ (default; falls back to the total when there is no meaningful saving) or always with
68
+ the always-on total
69
+ --force let --json=<file> / --card=<file> / --markdown=<file> replace an existing file that prehello did not write.
70
+ Symlinks are never written through, with or without --force.
71
+ --max-tokens <n|16k> CI gate: exit 2 if the always-on token count (a lower bound when anything is
72
+ unmeasured) of any agent in use is above n. Compares the central estimate.
73
+ --fix print a dry-run fix plan (changes nothing)
74
+ --tokenizer auto|heuristic --claude-profile current|legacy
75
+ --no-bundled-catalog ignore the measured MCP weights that ship with prehello
76
+ --mcp-catalog <file> extra pre-measured MCP weights (see spec/mcp-weights.json)
77
+ --paste-user-rules <file|-> count text you pasted for rules that live inside an app
78
+ (Cursor "User Rules"). Read-only; the text is counted, never printed.
79
+ --scan-budget-mb <n> stop reading after n MiB in total (default 64); the report is then marked PARTIAL
80
+ --scan-budget-ms <n> stop walking/reading after n ms (default 20000); a safety net, so it is the one
81
+ limit that can vary run to run on a slow disk
82
+ --include-fixtures also look inside test-fixture folders (fixtures, __fixtures__, fixture, testdata, test-data, __mocks__,
83
+ __snapshots__, and snapshots/golden under test/spec/e2e folders) for nested AGENTS.md, CLAUDE.md,
84
+ .cursor/rules and skills. By default they are skipped (no agent loads them) and the report says how many.
85
+ Scanning a folder that is itself inside a fixtures folder never skips anything.
86
+ --cwd <dir> simulate launching the agent from <dir>
87
+ --ascii plain ASCII output (no block characters or arrows); automatic in terminals that cannot draw them
88
+ (Linux console, classic Windows console). PREHELLO_ASCII=1 does the same.
89
+ --no-grade --no-color --quiet --verbose --about-specs --version --help
90
+
91
+ Output files (--json=<file>, --card=<file>, --markdown=<file>, --sarif=<file>): a symlink is never followed; an existing file is only replaced
92
+ if it is a previous prehello output (or with --force).
93
+
94
+ Scan budget: at most ~64 MiB read and ~20 s of walking per run, then the scan stops, warns, and every total is a
95
+ lower bound. Files over 4 MiB are skipped (Claude Code skips them at startup too).
96
+
97
+ PNG cards need the optional package @resvg/resvg-wasm. If it is not installed, prehello prints how to install it, writes no card,
98
+ exits 1, and everything else you asked for (report, --json, --markdown) is still produced. An SVG card never needs it.
99
+
100
+ Windows: the home folder is %USERPROFILE%; use --markdown=<file> or --json=<file> rather than shell redirection (Windows PowerShell 5
101
+ re-encodes redirected output as UTF-16). Works the same on Linux, macOS and Windows.
102
+
103
+ Exit codes: 0 ok · 1 error (incl. PNG engine missing) · 2 --max-tokens exceeded · 3 partial (some sources unreadable/skipped/budget hit; report still printed)
104
+ (2 wins over 3 when both apply)
105
+ `;
106
+ const VALUE_FLAGS = new Set(["agent", "window", "tokenizer", "claude-profile", "card-theme", "card-size", "card-agent", "cwd", "mcp-catalog", "ceiling", "managed-dir", "system-root", "paste-user-rules", "card-headline", "card-format", "card-scale", "png-engine", "max-tokens", "scan-budget-mb", "scan-budget-ms", "file", "context-file", "save", "fail-above", "badge"]);
107
+ const BOOL_FLAGS = new Set(["json", "card", "redact", "fix", "no-grade", "no-color", "quiet", "verbose", "about-specs", "version", "help", "no-redact", "no-bundled-catalog", "force", "card-anonymize", "anonymize", "markdown", "markdown-compact", "sarif", "ascii", "include-fixtures"]);
108
+ function parseArgs(argv) {
109
+ const flags = new Map();
110
+ const positional = [];
111
+ for (let i = 0; i < argv.length; i++) {
112
+ const a = argv[i];
113
+ if (a === "--") {
114
+ positional.push(...argv.slice(i + 1));
115
+ break;
116
+ }
117
+ if (a === "-h") {
118
+ flags.set("help", true);
119
+ continue;
120
+ }
121
+ if (a === "-v" || a === "-V") {
122
+ flags.set("version", true);
123
+ continue;
124
+ }
125
+ if (a.length > 1 && a.startsWith("-") && !a.startsWith("--"))
126
+ return `unknown option ${a} (try --help)`;
127
+ if (!a.startsWith("--")) {
128
+ positional.push(a);
129
+ continue;
130
+ }
131
+ const eq = a.indexOf("=");
132
+ const name = eq === -1 ? a.slice(2) : a.slice(2, eq);
133
+ const inline = eq === -1 ? undefined : a.slice(eq + 1);
134
+ if (VALUE_FLAGS.has(name)) {
135
+ const v = inline ?? argv[++i];
136
+ if (v === undefined || (inline === undefined && v.startsWith("--")))
137
+ return `option --${name} needs a value`;
138
+ if (v === "" && name !== "paste-user-rules")
139
+ return `option --${name} needs a value`;
140
+ flags.set(name, v);
141
+ }
142
+ else if (BOOL_FLAGS.has(name)) {
143
+ if (inline !== undefined && name !== "json" && name !== "card" && name !== "markdown" && name !== "sarif")
144
+ return `option --${name} does not take a value`;
145
+ if (inline === "" && name === "json")
146
+ return "--json= needs a file name (for stdout, write --json with no =)";
147
+ // --card card.png: take the next argument as the file name when it ends in .svg/.png and is not an existing directory
148
+ if (name === "card" && inline === undefined && i + 1 < argv.length && /\.(svg|png)$/i.test(argv[i + 1]) && !argv[i + 1].startsWith("-") && !(0, fs_safe_1.isDir)(node_path_1.default.resolve(argv[i + 1]))) {
149
+ flags.set(name, argv[++i]);
150
+ continue;
151
+ }
152
+ flags.set(name, inline ?? true);
153
+ }
154
+ else
155
+ return `unknown option --${name} (try --help)`;
156
+ }
157
+ return { flags, positional };
158
+ }
159
+ function fail(msg) {
160
+ process.stderr.write(`prehello: ${msg}\n`);
161
+ process.exit(1);
162
+ }
163
+ async function main() {
164
+ const argv = process.argv.slice(2);
165
+ const parsed = parseArgs(argv);
166
+ if (typeof parsed === "string")
167
+ return fail(parsed);
168
+ const { flags, positional } = parsed;
169
+ if (flags.has("help"))
170
+ return void process.stdout.write(HELP);
171
+ if (flags.has("version"))
172
+ return void process.stdout.write(scan_1.VERSION + "\n");
173
+ if (flags.has("about-specs")) {
174
+ for (const id of model_1.ALL_AGENTS) {
175
+ const s = (0, spec_1.loadSpec)(id);
176
+ process.stdout.write(`${model_1.AGENT_NAMES[id]}: verifiedAt ${s.verifiedAt}\n ${s.verifiedNote}\n docs: ${Object.values(s.docs).join(" ")}\n`);
177
+ }
178
+ return;
179
+ }
180
+ if (positional[0] === "explain") {
181
+ if (positional[1] === "grade")
182
+ return void process.stdout.write((0, terminal_1.renderRubric)());
183
+ return fail("usage: prehello explain grade");
184
+ }
185
+ const calibrating = positional[0] === "calibrate";
186
+ if (calibrating)
187
+ positional.shift();
188
+ let contextText;
189
+ if (calibrating) {
190
+ const dash = positional.indexOf("-");
191
+ if (dash >= 0)
192
+ positional.splice(dash, 1);
193
+ const src = (flags.get("file") ?? flags.get("context-file") ?? (dash >= 0 ? "-" : undefined));
194
+ if (src === undefined || src === "-") {
195
+ if (src === undefined && process.stdin.isTTY)
196
+ return fail("usage: prehello calibrate --file ctx.txt (paste the output of /context from Claude Code into ctx.txt), or pipe it: pbpaste | prehello calibrate -");
197
+ contextText = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(0));
198
+ }
199
+ else {
200
+ try {
201
+ contextText = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(src));
202
+ }
203
+ catch {
204
+ return fail(`cannot read /context text from ${src}`);
205
+ }
206
+ }
207
+ }
208
+ else if (flags.has("file") || flags.has("context-file"))
209
+ return fail("--file and --context-file belong to 'prehello calibrate'");
210
+ if (positional[0] === "diff")
211
+ return runDiff(positional.slice(1), flags);
212
+ if (flags.has("fail-above"))
213
+ return fail("--fail-above belongs to 'prehello diff'");
214
+ if (positional[0] === "ci")
215
+ return fail("there is no 'ci' command: use the GitHub Action (docs/github-action.md), or prehello --save + prehello diff");
216
+ if (positional.length > 1)
217
+ return fail("at most one path argument is supported");
218
+ const env = process.env;
219
+ const target = flags.get("cwd") ?? positional[0] ?? process.cwd();
220
+ const cwdAbs = node_path_1.default.resolve(target);
221
+ if (!(0, fs_safe_1.isDir)(cwdAbs)) {
222
+ if (flags.get("card") === true && positional[0] && /\.(svg|png)$/i.test(positional[0]))
223
+ return fail(`not a directory: ${target}. To name the card file write --card=${positional[0]}`);
224
+ return fail(`not a directory: ${target}`);
225
+ }
226
+ const cwd = (0, fs_safe_1.realpathOrSelf)(cwdAbs);
227
+ // Windows: %USERPROFILE% (what the agents use), not HOME (Git Bash sets a POSIX-style "/c/Users/me" Node cannot open).
228
+ const home = (0, fs_safe_1.realpathOrSelf)((0, platform_1.resolveHomeDir)(env, process.platform, node_os_1.default.homedir));
229
+ let window = 200_000;
230
+ if (flags.has("window")) {
231
+ const w = (0, fmt_1.parseWindow)(String(flags.get("window")));
232
+ if (!w)
233
+ return fail("--window must look like 200k, 1m or 128000");
234
+ window = w;
235
+ }
236
+ const tokenizer = String(flags.get("tokenizer") ?? "auto");
237
+ if (tokenizer === "exact")
238
+ return fail("--tokenizer exact (network) is not part of 0.1; this build never makes network calls");
239
+ if (tokenizer !== "auto" && tokenizer !== "heuristic")
240
+ return fail("--tokenizer must be auto or heuristic");
241
+ const cp = String(flags.get("claude-profile") ?? "current");
242
+ if (cp !== "current" && cp !== "legacy")
243
+ return fail("--claude-profile must be current or legacy");
244
+ let agents;
245
+ if (flags.has("agent")) {
246
+ const list = String(flags.get("agent")).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
247
+ if (!list.length)
248
+ return fail("--agent needs at least one of: " + model_1.ALL_AGENTS.join(", "));
249
+ const bad = list.filter((x) => !model_1.ALL_AGENTS.includes(x));
250
+ if (bad.length)
251
+ return fail(`unknown agent '${bad[0]}' (supported: ${model_1.ALL_AGENTS.join(", ")})`);
252
+ agents = list;
253
+ }
254
+ if (calibrating) {
255
+ if (agents && (agents.length !== 1 || agents[0] !== "claude-code"))
256
+ return fail("calibrate reads Claude Code's /context; Codex and Cursor have no per-category reading to compare against (yet)");
257
+ agents = ["claude-code"];
258
+ }
259
+ const cardTheme = String(flags.get("card-theme") ?? "dark");
260
+ const cardSize = String(flags.get("card-size") ?? "og");
261
+ if (!["dark", "light"].includes(cardTheme))
262
+ return fail("--card-theme must be dark or light");
263
+ if (!["og", "square"].includes(cardSize))
264
+ return fail("--card-size must be og or square");
265
+ const cardHeadline = String(flags.get("card-headline") ?? "cut");
266
+ if (!["total", "cut"].includes(cardHeadline))
267
+ return fail("--card-headline must be total or cut");
268
+ const cardScaleRaw = String(flags.get("card-scale") ?? "2");
269
+ if (!["1", "2"].includes(cardScaleRaw))
270
+ return fail("--card-scale must be 1 or 2");
271
+ const cardScale = Number(cardScaleRaw);
272
+ const fmtFlag = flags.has("card-format") ? String(flags.get("card-format")).toLowerCase() : undefined;
273
+ if (fmtFlag !== undefined && !["svg", "png"].includes(fmtFlag))
274
+ return fail("--card-format must be svg or png");
275
+ if (flags.has("png-engine") && flags.get("png-engine") !== "none")
276
+ return fail("--png-engine only accepts 'none' (test hook)");
277
+ let cardFile = "";
278
+ let cardFormat = "svg";
279
+ if (flags.get("card") !== undefined) {
280
+ const cf = flags.get("card");
281
+ if (typeof cf === "string") {
282
+ const ext = /\.(svg|png)$/i.exec(cf)?.[1].toLowerCase();
283
+ if (!ext)
284
+ return fail("--card file name must end in .svg or .png");
285
+ if (fmtFlag && fmtFlag !== ext)
286
+ return fail(`--card-format ${fmtFlag} does not match the file name ${cf}`);
287
+ cardFile = cf;
288
+ cardFormat = ext;
289
+ }
290
+ else {
291
+ cardFormat = fmtFlag ?? "svg";
292
+ cardFile = `prehello-card.${cardFormat}`;
293
+ }
294
+ }
295
+ else if (fmtFlag)
296
+ return fail("--card-format needs --card");
297
+ const mdFlag = flags.get("markdown");
298
+ const mdCompact = flags.has("markdown-compact");
299
+ let mdFile = "";
300
+ if (typeof mdFlag === "string") {
301
+ if (!/\.(md|markdown)$/i.test(mdFlag))
302
+ return fail("--markdown file name must end in .md or .markdown (for stdout, write --markdown with no file name)");
303
+ mdFile = mdFlag;
304
+ }
305
+ const mdStdout = mdFile === "" && (mdFlag !== undefined || mdCompact);
306
+ const sarifFlag = flags.get("sarif");
307
+ let sarifFile = "";
308
+ if (typeof sarifFlag === "string") {
309
+ if (!/\.sarif(\.json)?$/i.test(sarifFlag))
310
+ return fail("--sarif file name must end in .sarif or .sarif.json (for stdout, write --sarif with no file name)");
311
+ sarifFile = sarifFlag;
312
+ }
313
+ const sarifStdout = sarifFlag === true;
314
+ if (mdStdout && flags.get("json") === true)
315
+ return fail("--json and --markdown both want standard output; give one of them a file: --markdown=report.md or --json=report.json");
316
+ if (mdStdout && flags.has("fix"))
317
+ return fail("--fix and --markdown both want standard output; give the Markdown report a file: --markdown=report.md");
318
+ if (sarifStdout && flags.get("json") === true)
319
+ return fail("--json and --sarif both want standard output; give one of them a file: --sarif=report.sarif or --json=report.json");
320
+ if (sarifStdout && mdStdout)
321
+ return fail("--markdown and --sarif both want standard output; give one of them a file: --sarif=report.sarif or --markdown=report.md");
322
+ if (sarifStdout && flags.has("fix"))
323
+ return fail("--fix and --sarif both want standard output; give the SARIF report a file: --sarif=report.sarif");
324
+ let maxTokens;
325
+ if (flags.has("max-tokens")) {
326
+ const m = /^(\d+(?:\.\d+)?)\s*([km]?)$/i.exec(String(flags.get("max-tokens")).trim());
327
+ if (!m)
328
+ return fail("--max-tokens must look like 16000 or 16k");
329
+ maxTokens = Math.round(parseFloat(m[1]) * (m[2].toLowerCase() === "k" ? 1000 : m[2].toLowerCase() === "m" ? 1_000_000 : 1));
330
+ }
331
+ const force = flags.has("force");
332
+ const posNum = (name) => {
333
+ if (!flags.has(name))
334
+ return undefined;
335
+ const raw = String(flags.get(name));
336
+ const n = Number(raw);
337
+ return /^\d+(\.\d+)?$/.test(raw) && n > 0 ? n : `--${name} must be a positive number`;
338
+ };
339
+ const budgetMb = posNum("scan-budget-mb");
340
+ const budgetMs = posNum("scan-budget-ms");
341
+ if (typeof budgetMb === "string")
342
+ return fail(budgetMb);
343
+ if (typeof budgetMs === "string")
344
+ return fail(budgetMs);
345
+ let pastedUserRules;
346
+ if (flags.has("paste-user-rules")) {
347
+ const src = String(flags.get("paste-user-rules"));
348
+ try {
349
+ if (src === "-") {
350
+ if (process.stdin.isTTY)
351
+ return fail("--paste-user-rules - reads standard input; pipe the text in (e.g. pbpaste | prehello --paste-user-rules -) or pass a file");
352
+ pastedUserRules = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(0)).replace(/\r\n/g, "\n");
353
+ }
354
+ else {
355
+ const st = node_fs_1.default.statSync(src);
356
+ if (!st.isFile())
357
+ return fail(`--paste-user-rules: not a file: ${src}`);
358
+ if (st.size > 1024 * 1024)
359
+ return fail("--paste-user-rules: file is larger than 1 MB");
360
+ pastedUserRules = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(src)).replace(/\r\n/g, "\n");
361
+ }
362
+ }
363
+ catch (e) {
364
+ return fail(`--paste-user-rules: could not read ${src === "-" ? "standard input" : src} (${e.code ?? "error"})`);
365
+ }
366
+ if (pastedUserRules.length > 1024 * 1024)
367
+ return fail("--paste-user-rules: text is larger than 1 MB");
368
+ if (!pastedUserRules.trim())
369
+ return fail("--paste-user-rules: the text is empty");
370
+ }
371
+ const report = (0, scan_1.scan)({
372
+ cwd,
373
+ home,
374
+ env,
375
+ platform: process.platform,
376
+ ceiling: flags.has("ceiling") ? (0, fs_safe_1.realpathOrSelf)(String(flags.get("ceiling"))) : undefined,
377
+ managedDir: flags.has("managed-dir") ? String(flags.get("managed-dir")) : undefined,
378
+ noBundledCatalog: flags.has("no-bundled-catalog"),
379
+ systemRoot: flags.has("system-root") ? node_path_1.default.resolve(String(flags.get("system-root"))) : undefined,
380
+ pastedUserRules,
381
+ window,
382
+ tokenizer,
383
+ claudeProfile: cp,
384
+ includeFixtures: flags.has("include-fixtures"),
385
+ agents,
386
+ catalogPath: flags.has("mcp-catalog") ? node_path_1.default.resolve(String(flags.get("mcp-catalog"))) : undefined,
387
+ now: new Date(),
388
+ maxScanBytes: budgetMb === undefined ? undefined : Math.round(budgetMb * 1048576),
389
+ maxScanMs: budgetMs,
390
+ noGrade: flags.has("no-grade"),
391
+ });
392
+ const protectedPaths = new Set(report.agents.flatMap((a) => a.items.map((i) => (0, fs_safe_1.realpathOrSelf)(i.path))));
393
+ const write = (file, content, what, kind) => {
394
+ const abs = node_path_1.default.resolve(file);
395
+ if (protectedPaths.has((0, fs_safe_1.realpathOrSelf)(abs)))
396
+ return fail(`refusing to overwrite a scanned context file with ${what}: ${file}`);
397
+ try {
398
+ (0, safe_write_1.safeWrite)(abs, content, { kind, force });
399
+ }
400
+ catch (e) {
401
+ if (e instanceof safe_write_1.SafeWriteError)
402
+ return fail(`${what} not written to ${file}: ${e.message}`);
403
+ return fail(`could not write ${what} to ${file}: ${(0, safe_write_1.describeWriteError)(e.code)}`);
404
+ }
405
+ };
406
+ if (calibrating) {
407
+ let reading;
408
+ try {
409
+ reading = (0, calibrate_1.parseContext)(contextText ?? "");
410
+ }
411
+ catch (e) {
412
+ if (e instanceof calibrate_1.ContextParseError)
413
+ return fail(e.message);
414
+ throw e;
415
+ }
416
+ const a = report.agents.find((x) => x.id === "claude-code");
417
+ if (!a)
418
+ return fail("no Claude Code report to compare against");
419
+ const cal = (0, calibrate_1.compare)(reading, a, home);
420
+ if (flags.has("save"))
421
+ write(String(flags.get("save")), JSON.stringify((0, calibrate_1.calibrationJson)(cal, report, a, true), null, 2) + "\n", "calibration", "json");
422
+ if (process.exitCode)
423
+ return;
424
+ if (flags.get("json") === true)
425
+ process.stdout.write(JSON.stringify((0, calibrate_1.calibrationJson)(cal, report, a, flags.has("redact")), null, 2) + "\n");
426
+ else
427
+ process.stdout.write((0, calibrate_1.renderCalibration)(cal, report));
428
+ if (flags.has("save"))
429
+ process.stderr.write(`Calibration written to ${flags.get("save")} (no paths or names).\n`);
430
+ return;
431
+ }
432
+ const strict = flags.has("redact");
433
+ const jsonFlag = flags.get("json");
434
+ const color = !flags.has("no-color") && !env.NO_COLOR && env.TERM !== "dumb" && !!process.stdout.isTTY;
435
+ const asciiOut = flags.has("ascii") || env.PREHELLO_ASCII === "1" || (!!process.stdout.isTTY && !(0, platform_1.unicodeSupported)(env, process.platform));
436
+ const term = (t) => (asciiOut ? (0, platform_1.toAscii)(t) : t);
437
+ const width = Math.min(process.stdout.columns || 80, 100);
438
+ const jsonText = jsonFlag !== undefined ? JSON.stringify((0, json_1.toJson)(report, { cwd, home }, strict), null, 2) + "\n" : "";
439
+ if (typeof jsonFlag === "string")
440
+ write(jsonFlag, jsonText, "JSON", "json");
441
+ if (flags.has("save"))
442
+ write(String(flags.get("save")), JSON.stringify((0, json_1.toJson)(report, { cwd, home }, strict), null, 2) + "\n", "snapshot", "json");
443
+ if (flags.has("badge")) {
444
+ // a CI runner has no agent installed: fall back to the agent with the most always-on context in the repository
445
+ const ba = (0, card_1.pickCardAgent)(report, flags.get("card-agent")) ?? [...report.agents].filter((a) => a.items.length).sort((x, y) => y.totals.always.est - x.totals.always.est)[0] ?? null;
446
+ write(String(flags.get("badge")), JSON.stringify(ba ? (0, diff_1.badgeJson)(ba.name, ba.totals.always.measuredItems || !ba.totals.always.unmeasuredItems ? ba.totals.always.est : null, ba.lowerBound) : (0, diff_1.badgeJson)("no agent", null, false)) + "\n", "badge", "badge");
447
+ }
448
+ let cardMsg = "";
449
+ let cardFailed = false;
450
+ const anonymizeNames = flags.has("card-anonymize") || flags.has("anonymize") || strict;
451
+ let mdText = "";
452
+ let mdMsg = "";
453
+ let sarifOut = "";
454
+ if (mdFlag !== undefined || mdCompact) {
455
+ mdText = (0, markdown_1.renderMarkdown)(report, { anonymize: anonymizeNames, compact: mdCompact, headline: cardHeadline });
456
+ if (mdFile) {
457
+ write(mdFile, `${safe_write_1.MD_MARKER_PREFIX}${scan_1.VERSION} -->\n${mdText}`, "Markdown report", "md");
458
+ mdMsg = `Markdown report written to ${mdFile} (no paths, file contents or finding texts; ${anonymizeNames ? "plugin and MCP server names anonymised" : "plugin and MCP server names are shown, add --card-anonymize to hide them"}).\n`;
459
+ }
460
+ }
461
+ if (cardFile) {
462
+ const agent = (0, card_1.pickCardAgent)(report, flags.has("card-agent") ? String(flags.get("card-agent")) : undefined);
463
+ if (!agent)
464
+ cardMsg = "No card written: nothing measurable (or unmeasured) loads before hello for the selected agent(s).\n";
465
+ else {
466
+ const base = { theme: cardTheme, size: cardSize, headline: cardHeadline, anonymize: anonymizeNames };
467
+ if (cardFormat === "svg")
468
+ write(cardFile, (0, card_1.renderCard)(report, agent, base), "card", "svg");
469
+ else {
470
+ try {
471
+ const png = await (0, png_1.svgToPng)((0, card_1.renderCard)(report, agent, { ...base, fontFamily: png_1.PNG_FONT_FAMILY, charEm: png_1.PNG_CHAR_EM }), {
472
+ scale: cardScale,
473
+ version: scan_1.VERSION,
474
+ resolver: flags.get("png-engine") === "none" ? () => { throw new Error("disabled"); } : undefined,
475
+ });
476
+ write(cardFile, png, "card", "png");
477
+ }
478
+ catch (e) {
479
+ if (!(e instanceof png_1.PngUnavailableError))
480
+ throw e;
481
+ cardFailed = true;
482
+ cardMsg = `prehello: ${e.message}\n`;
483
+ }
484
+ }
485
+ if (!cardFailed)
486
+ cardMsg = `Card written to ${cardFile} (${agent.name}; no paths or file contents; ${anonymizeNames ? "plugin and MCP server names anonymised" : "plugin and MCP server names are shown, add --card-anonymize to hide them"}).\n`;
487
+ }
488
+ }
489
+ let sarifMsg = "";
490
+ if (sarifFlag !== undefined) {
491
+ const sarifText = (0, sarif_1.renderSarif)(report, { anonymize: anonymizeNames, strict, maxTokens, cwd, home });
492
+ if (sarifFile) {
493
+ write(sarifFile, sarifText, "SARIF report", "sarif");
494
+ sarifMsg = `SARIF report written to ${sarifFile} (no file contents or finding texts; file locations only for files inside the scanned folder${strict ? ", none with --redact" : ""}).\n`;
495
+ }
496
+ else
497
+ sarifOut = sarifText;
498
+ }
499
+ const docOnStdout = jsonFlag === true || mdStdout || sarifStdout;
500
+ if (jsonFlag === true)
501
+ process.stdout.write(jsonText);
502
+ else if (sarifStdout)
503
+ process.stdout.write(sarifOut);
504
+ else if (mdStdout)
505
+ process.stdout.write(mdText);
506
+ else if (flags.has("fix"))
507
+ process.stdout.write(term((0, fix_1.renderFix)(report)));
508
+ else
509
+ process.stdout.write(term((0, terminal_1.renderTerminal)(report, { color, width, verbose: flags.has("verbose"), quiet: flags.has("quiet"), home, cwd })));
510
+ if (cardMsg)
511
+ (docOnStdout || cardFailed ? process.stderr : process.stdout).write(term(cardMsg));
512
+ if (mdMsg)
513
+ (docOnStdout ? process.stderr : process.stdout).write(term(mdMsg));
514
+ if (sarifMsg)
515
+ (docOnStdout ? process.stderr : process.stdout).write(term(sarifMsg));
516
+ process.exitCode = cardFailed ? 1 : report.warnings.length ? 3 : 0;
517
+ if (maxTokens !== undefined) {
518
+ const over = report.agents.filter((a) => !a.presentInRepoOnly && a.totals.always.est > maxTokens);
519
+ for (const a of over) {
520
+ const lb = a.lowerBound ? "at least " : "";
521
+ process.stderr.write(`prehello: ${a.name} always-on context is ${lb}~${(0, fmt_1.fmtTok)(a.totals.always.est)} tokens (estimate), above --max-tokens ${maxTokens}\n`);
522
+ }
523
+ if (over.length)
524
+ process.exitCode = 2;
525
+ }
526
+ }
527
+ // `prehello --markdown | head` closes the pipe early: that is not an error.
528
+ process.stdout.on("error", (e) => {
529
+ if (e.code === "EPIPE")
530
+ process.exit(process.exitCode ?? 0);
531
+ throw e;
532
+ });
533
+ main().catch((e) => {
534
+ process.stderr.write(`prehello: internal error: ${e.message}\n`);
535
+ process.exitCode = 1;
536
+ });
537
+ function parseTokenCount(v) {
538
+ const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(v.trim());
539
+ if (!m)
540
+ return undefined;
541
+ return Math.round(parseFloat(m[1]) * (m[2].toLowerCase() === "k" ? 1000 : m[2].toLowerCase() === "m" ? 1_000_000 : 1));
542
+ }
543
+ /** `prehello diff base.json head.json [--markdown[=file]] [--json] [--fail-above N]`. Reads two saved reports; no scan, no git. */
544
+ function runDiff(files, flags) {
545
+ if (files.length !== 2)
546
+ return fail("usage: prehello diff <base.json> <head.json> [--markdown] [--json] [--fail-above <tokens>] (make each file with: prehello --save <file>)");
547
+ let threshold;
548
+ if (flags.has("fail-above")) {
549
+ threshold = parseTokenCount(String(flags.get("fail-above")));
550
+ if (threshold === undefined)
551
+ return fail("--fail-above needs a token count, e.g. 2000 or 2k");
552
+ }
553
+ const snaps = [];
554
+ for (const f of files) {
555
+ let text;
556
+ try {
557
+ text = node_fs_1.default.readFileSync(f === "-" ? 0 : f, "utf8");
558
+ }
559
+ catch {
560
+ return fail(`cannot read ${f}`);
561
+ }
562
+ try {
563
+ snaps.push((0, diff_1.parseSnapshot)(text, f));
564
+ }
565
+ catch (e) {
566
+ if (e instanceof diff_1.DiffInputError)
567
+ return fail(e.message);
568
+ throw e;
569
+ }
570
+ }
571
+ const d = (0, diff_1.diffSnapshots)(snaps[0], snaps[1]);
572
+ const md = flags.get("markdown");
573
+ if (typeof md === "string") {
574
+ try {
575
+ (0, safe_write_1.safeWrite)(node_path_1.default.resolve(md), (0, diff_1.renderDiffMarkdown)(d, { threshold }), { kind: "md", force: flags.has("force") });
576
+ }
577
+ catch (e) {
578
+ return fail(`diff Markdown not written to ${md}: ${e.message}`);
579
+ }
580
+ }
581
+ if (flags.get("json") === true)
582
+ process.stdout.write(JSON.stringify((0, diff_1.diffJson)(d, threshold), null, 2) + "\n");
583
+ else if (md === true)
584
+ process.stdout.write((0, diff_1.renderDiffMarkdown)(d, { threshold }));
585
+ else
586
+ process.stdout.write((0, diff_1.renderDiffTerminal)(d));
587
+ const over = threshold !== undefined ? (0, diff_1.overThreshold)(d, threshold) : [];
588
+ for (const a of over)
589
+ process.stderr.write(`prehello: ${a.name} always-on context grew by ${(0, fmt_1.fmtTok)(a.delta)} tokens (estimate), above --fail-above ${(0, fmt_1.fmtTok)(threshold)}\n`);
590
+ process.exitCode = over.length ? 2 : 0;
591
+ }