codebase-snapshot 0.0.0-stage → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1931 @@
1
+ import { GENERATOR_VERSION, loadConfigFileDetailed, resolveConfig, validateSnapshot, describeManifestShapeProblem, explainPath, compareManifests, loadManifest, renderDiffText, generateSnapshot, formatNumber, isDiscoveryMode, ARTIFACT_NAMES, getPreset, unknownPresetMessage, GENERATOR_NAME, runInit, PromptCancelled, MIN_MAX_PART_BYTES, SCHEMA_VERSION, PRESETS, parseByteSize, gitignorePath, appendGitignoreEntries, escapeControls, renderExplainText } from './chunk-SSCFLILW.js';
2
+ import { spawnSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as path2 from 'path';
5
+ import { parseArgs } from 'util';
6
+
7
+ // src/agent/option-scope.ts
8
+ var VALIDATE_OPTIONS = [
9
+ "cwd",
10
+ "out-dir",
11
+ "max-part-bytes",
12
+ "large-file-bytes",
13
+ "max-monolith-bytes",
14
+ "include-monolith",
15
+ "include-sensitive-files",
16
+ "skip-artifacts",
17
+ "only-artifacts",
18
+ "no-config",
19
+ "discovery",
20
+ "format",
21
+ "mode",
22
+ "help",
23
+ "version"
24
+ ];
25
+ var COMMAND_OPTION_SCOPE = {
26
+ generate: [
27
+ "cwd",
28
+ "out-dir",
29
+ "max-part-bytes",
30
+ "large-file-bytes",
31
+ "max-monolith-bytes",
32
+ "include-monolith",
33
+ "include-sensitive-files",
34
+ "skip-artifacts",
35
+ "only-artifacts",
36
+ "no-config",
37
+ "discovery",
38
+ "format",
39
+ "mode",
40
+ "dry-run",
41
+ "watch",
42
+ "help",
43
+ "version"
44
+ ],
45
+ validate: VALIDATE_OPTIONS,
46
+ report: [
47
+ "cwd",
48
+ "out-dir",
49
+ "max-part-bytes",
50
+ "large-file-bytes",
51
+ "max-monolith-bytes",
52
+ "include-monolith",
53
+ "include-sensitive-files",
54
+ "skip-artifacts",
55
+ "only-artifacts",
56
+ "no-config",
57
+ "format",
58
+ "mode",
59
+ "help",
60
+ "version"
61
+ ],
62
+ explain: [
63
+ "cwd",
64
+ "out-dir",
65
+ "max-part-bytes",
66
+ "large-file-bytes",
67
+ "max-monolith-bytes",
68
+ "include-monolith",
69
+ "include-sensitive-files",
70
+ "skip-artifacts",
71
+ "only-artifacts",
72
+ "no-config",
73
+ "discovery",
74
+ "format",
75
+ "mode",
76
+ "help",
77
+ "version"
78
+ ],
79
+ diff: ["format", "mode", "no-config", "help", "version"],
80
+ describe: ["format", "mode", "help", "version"],
81
+ init: [
82
+ "cwd",
83
+ "out-dir",
84
+ "max-part-bytes",
85
+ "include-sensitive-files",
86
+ "skip-artifacts",
87
+ "only-artifacts",
88
+ "preset",
89
+ "include",
90
+ "exclude",
91
+ "yes",
92
+ "force",
93
+ "dry-run",
94
+ "format",
95
+ "mode",
96
+ "help",
97
+ "version"
98
+ ],
99
+ skill: ["cwd", "format", "mode", "install", "target", "help", "version"],
100
+ setup: [
101
+ "cwd",
102
+ "out-dir",
103
+ "max-part-bytes",
104
+ "include-sensitive-files",
105
+ "skip-artifacts",
106
+ "only-artifacts",
107
+ "preset",
108
+ "include",
109
+ "exclude",
110
+ "yes",
111
+ "force",
112
+ "no-install",
113
+ "dry-run",
114
+ "format",
115
+ "mode",
116
+ "help",
117
+ "version"
118
+ ]
119
+ };
120
+ var COMMAND_ORDER = [
121
+ "generate",
122
+ "validate",
123
+ "report",
124
+ "explain",
125
+ "init",
126
+ "setup",
127
+ "diff",
128
+ "describe",
129
+ "skill"
130
+ ];
131
+ function commandsForOption(option) {
132
+ return COMMAND_ORDER.filter((command) => COMMAND_OPTION_SCOPE[command].includes(option));
133
+ }
134
+ function optionAppliesTo(option) {
135
+ return commandsForOption(option).join("/");
136
+ }
137
+
138
+ // src/agent/contract.ts
139
+ var CONTRACT_VERSION = "1.0.0";
140
+ var PACKAGE_NAME = "codebase-snapshot";
141
+ var MAJOR_VERSION = GENERATOR_VERSION.split(".")[0];
142
+ var ARTIFACT_SUMMARY = {
143
+ readme: "README.md describing the snapshot at the output root.",
144
+ manifest: "manifest.json: machine-readable index of every file, part, hash and config.",
145
+ parts: "parts/*.md: the packed, budget-limited markdown parts that carry the code.",
146
+ navigation: "reports/navigation.md: links between parts and layers.",
147
+ projectTree: "reports/project-tree.md: the directory tree of included files.",
148
+ audit: "reports/audit.md: secret findings, large files, parse diagnostics, git state.",
149
+ monolith: "full-codebase.json and full-codebase.md: the whole repository in one file (off by default)."
150
+ };
151
+ var ARTIFACT_DEFAULTS = {
152
+ readme: true,
153
+ manifest: true,
154
+ parts: true,
155
+ navigation: true,
156
+ projectTree: true,
157
+ audit: true,
158
+ monolith: false
159
+ };
160
+ function renderContractText(contract) {
161
+ const lines = [];
162
+ lines.push(`${contract.package} v${contract.version}`);
163
+ lines.push(contract.summary);
164
+ lines.push("");
165
+ lines.push(`Node : ${contract.requires.node}`);
166
+ lines.push(`Schema : ${contract.schemaVersion} (contract ${contract.contractVersion})`);
167
+ lines.push(`One-shot : ${contract.invocation.oneShot} <command>`);
168
+ lines.push(`Installed : ${contract.invocation.installed} <command>`);
169
+ lines.push("");
170
+ lines.push("Workflow:");
171
+ for (const step of contract.workflow) {
172
+ lines.push(` ${step.step}. ${step.id}`);
173
+ lines.push(` run : ${step.run}`);
174
+ lines.push(` expect : ${step.expect}`);
175
+ if (step.notes) lines.push(` note : ${step.notes}`);
176
+ }
177
+ lines.push("");
178
+ lines.push("Commands:");
179
+ for (const command of contract.commands) {
180
+ const args = command.positionals.length > 0 ? ` ${command.positionals.join(" ")}` : "";
181
+ lines.push(` ${(command.name + args).padEnd(26)} ${command.summary}`);
182
+ }
183
+ lines.push("");
184
+ lines.push("Exit codes:");
185
+ for (const [code, meaning] of Object.entries(contract.exitCodes)) {
186
+ lines.push(` ${code} ${meaning}`);
187
+ }
188
+ lines.push("");
189
+ lines.push(`Presets (${contract.presets.length}): ${contract.presets.map((p) => p.name).join(", ")}`);
190
+ lines.push("");
191
+ lines.push("Artifacts:");
192
+ for (const artifact of contract.artifacts) {
193
+ lines.push(` ${artifact.name.padEnd(12)} ${artifact.default ? "on " : "off"} ${artifact.summary}`);
194
+ }
195
+ lines.push("");
196
+ lines.push("Options:");
197
+ for (const option of contract.options) {
198
+ const fallback = option.default === void 0 ? "" : ` [${JSON.stringify(option.default)}]`;
199
+ lines.push(` ${option.flag.padEnd(24)} ${option.appliesTo.padEnd(32)}${fallback}`);
200
+ lines.push(` ${"".padEnd(24)} ${option.summary}`);
201
+ }
202
+ lines.push("");
203
+ lines.push("Safety:");
204
+ for (const note of contract.safety) lines.push(` - ${note}`);
205
+ lines.push("");
206
+ lines.push("Troubleshooting:");
207
+ for (const item of contract.troubleshooting) {
208
+ lines.push(` ${item.symptom}`);
209
+ lines.push(` cause: ${item.cause}`);
210
+ lines.push(` fix : ${item.fix}`);
211
+ }
212
+ lines.push("");
213
+ lines.push("This is the human rendering. Agents should use: describe --format json");
214
+ return lines.join("\n");
215
+ }
216
+ function buildAgentContract() {
217
+ return {
218
+ generator: GENERATOR_NAME,
219
+ package: PACKAGE_NAME,
220
+ version: GENERATOR_VERSION,
221
+ schemaVersion: SCHEMA_VERSION,
222
+ contractVersion: CONTRACT_VERSION,
223
+ summary: "Turns a repository into an LLM-ready snapshot: code packed into size-budgeted markdown parts, plus a manifest, a project tree, navigation and a secret audit. Auto-detects the project shape (framework, language, monorepo, docs, infra).",
224
+ requires: { node: ">=18.0.0", dependencies: ["typescript"] },
225
+ invocation: {
226
+ oneShot: `npx ${PACKAGE_NAME}@latest`,
227
+ installed: "npx codebase-snapshot",
228
+ bins: ["codebase-snapshot", "snap"],
229
+ notes: [
230
+ `The bare package name also works: npx ${PACKAGE_NAME} <command>.`,
231
+ "Pin @latest (or @4) when you need the exact same version on every machine.",
232
+ "Both bin names point at the same file. npm resolves a package by its bin entries, so they must stay aliases of one executable.",
233
+ "Human progress goes to stderr. With --format json the payload on stdout is pure JSON.",
234
+ "A failing run also writes pure JSON to stdout: { error: { message, exitCode } }. The exit code and the stderr line are unchanged."
235
+ ]
236
+ },
237
+ workflow: [
238
+ {
239
+ step: 1,
240
+ id: "verify",
241
+ run: `npx ${PACKAGE_NAME}@latest --version`,
242
+ // The requirement is the major version; the exact build is context. A
243
+ // floating tag may print 4.0.7 today and 4.0.9 next week, and an agent
244
+ // must not conclude that a working tool is broken because of a patch.
245
+ expect: `prints v${MAJOR_VERSION} or newer on stdout and exits 0 (this build is v${GENERATOR_VERSION}: compare the major version, not the patch)`,
246
+ notes: `If it prints an older major than this contract's, the @latest tag is behind the release: pin the major with \`npx ${PACKAGE_NAME}@${MAJOR_VERSION} --version\` instead of retrying @latest. If it prints nothing, clear the npm cache and retry, or use \`npx -p <pkg> codebase-snapshot\`.`
247
+ },
248
+ {
249
+ step: 2,
250
+ id: "understand",
251
+ run: `npx ${PACKAGE_NAME}@latest describe --format json`,
252
+ expect: "the full contract as JSON: commands, options, presets, exit codes",
253
+ notes: "Read this before acting if you have no prior knowledge of the tool."
254
+ },
255
+ {
256
+ step: 3,
257
+ id: "configure",
258
+ run: `npx ${PACKAGE_NAME}@latest init --yes`,
259
+ expect: 'writes a commented config file listing every setting at its current value: snapshot.config.ts only when the running Node can strip types (>= 22.6) and the project is an ES module (`"type": "module"` in package.json); snapshot.config.mjs otherwise',
260
+ notes: "Non-interactive. The detected preset is chosen automatically; override with --preset <name>. Skip this step to run on built-in defaults."
261
+ },
262
+ {
263
+ step: 4,
264
+ id: "generate",
265
+ run: `npx ${PACKAGE_NAME}@latest generate`,
266
+ expect: "exit 0, and ./snapshot/ written with parts, reports and manifest.json"
267
+ },
268
+ {
269
+ step: 5,
270
+ id: "verify-output",
271
+ run: `npx ${PACKAGE_NAME}@latest validate`,
272
+ expect: "exit 0 means the snapshot matches the files on disk",
273
+ notes: "Exit 1 means drift or a damaged snapshot; re-run generate."
274
+ },
275
+ {
276
+ step: 6,
277
+ id: "consume",
278
+ run: "read ./snapshot/parts/*.md, then ./snapshot/manifest.json, then ./snapshot/reports/audit.md",
279
+ expect: "the code, the index of what was included, and the risk findings",
280
+ notes: "manifest.json.files[] lists every included file with layer, kind, language, part and summary. manifest.skipped[] explains every file that was left out and why."
281
+ }
282
+ ],
283
+ commands: [
284
+ {
285
+ name: "generate",
286
+ aliases: ["default"],
287
+ summary: "Write the snapshot. This is the command you normally want.",
288
+ positionals: [],
289
+ exits: [0, 1]
290
+ },
291
+ {
292
+ name: "validate",
293
+ aliases: ["check"],
294
+ summary: "Compare the snapshot on disk with the repository; exit 1 on drift. `check` is an alias of `validate`.",
295
+ positionals: [],
296
+ exits: [0, 1]
297
+ },
298
+ {
299
+ name: "report",
300
+ aliases: [],
301
+ summary: "Print snapshot health, layer metrics and the secret audit.",
302
+ positionals: [],
303
+ exits: [0, 1]
304
+ },
305
+ {
306
+ name: "setup",
307
+ aliases: [],
308
+ summary: "Everything a repository needs in one command, with defaults: write the config when there is none (an existing one is kept), add the npm scripts, devDependency and .gitignore entries, install dependencies with the project's package manager, then generate the snapshot. --no-install skips the install; --dry-run writes nothing.",
309
+ positionals: [],
310
+ exits: [0, 1, 2]
311
+ },
312
+ {
313
+ name: "init",
314
+ aliases: [],
315
+ summary: "Write a commented config file (snapshot.config.ts only when Node can strip types and the project is an ES module; snapshot.config.mjs otherwise), add the snapshot scripts and the devDependency, and ignore the snapshot directories. Nothing else is written. Interactive, or --yes for CI.",
316
+ positionals: [],
317
+ exits: [0, 1, 2]
318
+ },
319
+ {
320
+ name: "explain",
321
+ aliases: [],
322
+ summary: "Explain how one path would be treated: layer, kind, part, skip reason. Always exits 0 when it can report; read `included` and `reason` instead of the exit code.",
323
+ positionals: ["<path>"],
324
+ exits: [0]
325
+ },
326
+ {
327
+ name: "diff",
328
+ aliases: [],
329
+ summary: "Compare two snapshots (directories or manifest.json files).",
330
+ positionals: ["<before>", "<after>"],
331
+ exits: [0, 1]
332
+ },
333
+ {
334
+ name: "describe",
335
+ aliases: [],
336
+ summary: "Print this contract. --format json for machines, text for humans.",
337
+ positionals: [],
338
+ exits: [0]
339
+ },
340
+ {
341
+ name: "skill",
342
+ aliases: [],
343
+ summary: "Print the agent skill, or --install it into the tool registries this repository's agent reads (.claude/skills, .cursor/rules). Files it creates are added to .gitignore. It never writes AGENTS.md: that file belongs to the repository.",
344
+ positionals: [],
345
+ exits: [0, 1, 2]
346
+ }
347
+ ],
348
+ exitCodes: {
349
+ "0": "success (and validate: no drift)",
350
+ "1": "drift detected, or generation refused/failed (nothing was written)",
351
+ "2": "usage error: unknown flag/command, bad value, wrong argument count"
352
+ },
353
+ options: [
354
+ {
355
+ flag: "--out-dir",
356
+ type: "string",
357
+ default: "snapshot",
358
+ appliesTo: optionAppliesTo("out-dir"),
359
+ summary: "Where the snapshot is written. Must not be the repo root or a directory the tool did not create."
360
+ },
361
+ {
362
+ flag: "--max-part-bytes",
363
+ type: "string",
364
+ default: 307200,
365
+ appliesTo: optionAppliesTo("max-part-bytes"),
366
+ summary: 'Byte budget per markdown part. Accepts "120k" / "1.5mb". Minimum 4096.'
367
+ },
368
+ {
369
+ flag: "--large-file-bytes",
370
+ type: "string",
371
+ default: 204800,
372
+ appliesTo: optionAppliesTo("large-file-bytes"),
373
+ summary: "Above this size a file gets a head/tail preview instead of the full body."
374
+ },
375
+ {
376
+ flag: "--max-monolith-bytes",
377
+ type: "string",
378
+ default: "unset",
379
+ appliesTo: optionAppliesTo("max-monolith-bytes"),
380
+ summary: "Skip the monolith artifacts when they would exceed this size."
381
+ },
382
+ {
383
+ flag: "--include-monolith",
384
+ type: "boolean",
385
+ default: false,
386
+ appliesTo: optionAppliesTo("include-monolith"),
387
+ summary: "Also write full-codebase.json and full-codebase.md."
388
+ },
389
+ {
390
+ flag: "--include-sensitive-files",
391
+ type: "boolean",
392
+ default: false,
393
+ appliesTo: optionAppliesTo("include-sensitive-files"),
394
+ summary: "Also emit .env / *.pem / .npmrc style files. Off by default; they are recorded as skipped."
395
+ },
396
+ {
397
+ flag: "--discovery",
398
+ type: "string",
399
+ default: "auto",
400
+ appliesTo: optionAppliesTo("discovery"),
401
+ summary: "How files are discovered: auto (git when usable), git (require git), walk (read the root .gitignore directly; the escape hatch for a .git that cannot be listed)."
402
+ },
403
+ {
404
+ flag: "--skip-artifacts",
405
+ type: "string",
406
+ default: "unset",
407
+ appliesTo: optionAppliesTo("skip-artifacts"),
408
+ summary: "Comma-separated artifacts to omit (readme, manifest, parts, navigation, projectTree, audit, monolith)."
409
+ },
410
+ {
411
+ flag: "--only-artifacts",
412
+ type: "string",
413
+ default: "unset",
414
+ appliesTo: optionAppliesTo("only-artifacts"),
415
+ summary: "Write only these artifacts. Cannot be combined with --skip-artifacts."
416
+ },
417
+ {
418
+ flag: "--format",
419
+ type: "string",
420
+ default: "text",
421
+ // `optionAppliesTo` always returns the explicit slash list, so this is the same scoped form
422
+ // as every other option — no prose sentinel a machine consumer could misread.
423
+ appliesTo: optionAppliesTo("format"),
424
+ summary: 'Output format: "text" or "json". JSON goes to stdout, progress to stderr.'
425
+ },
426
+ {
427
+ flag: "--mode",
428
+ type: "string",
429
+ default: "generate",
430
+ appliesTo: optionAppliesTo("mode"),
431
+ summary: "Select the command when no command word is given. Values: generate, validate, check, report, init, setup, explain, diff, describe, skill. A command word and --mode must name the same command; a contradiction is a usage error (exit 2)."
432
+ },
433
+ {
434
+ flag: "--cwd",
435
+ type: "string",
436
+ default: "process.cwd()",
437
+ appliesTo: optionAppliesTo("cwd"),
438
+ summary: "Repository to operate on. Must be an existing directory."
439
+ },
440
+ {
441
+ flag: "--dry-run",
442
+ type: "boolean",
443
+ default: false,
444
+ appliesTo: optionAppliesTo("dry-run"),
445
+ summary: "Compute everything and print the result without writing."
446
+ },
447
+ {
448
+ flag: "--watch",
449
+ type: "boolean",
450
+ default: false,
451
+ appliesTo: optionAppliesTo("watch"),
452
+ summary: "Regenerate on every change until interrupted."
453
+ },
454
+ {
455
+ flag: "--no-config",
456
+ type: "boolean",
457
+ default: false,
458
+ appliesTo: optionAppliesTo("no-config"),
459
+ summary: "Ignore the config file and run on the built-in defaults."
460
+ },
461
+ {
462
+ flag: "--preset",
463
+ type: "string",
464
+ default: "detected",
465
+ appliesTo: optionAppliesTo("preset"),
466
+ summary: "Force a project preset instead of detecting one. Unknown names are a usage error."
467
+ },
468
+ {
469
+ flag: "--include",
470
+ type: "string",
471
+ default: "unset",
472
+ appliesTo: optionAppliesTo("include"),
473
+ summary: "Comma-separated allow-list of globs to record in the generated config."
474
+ },
475
+ {
476
+ flag: "--exclude",
477
+ type: "string",
478
+ default: "unset",
479
+ appliesTo: optionAppliesTo("exclude"),
480
+ summary: "Comma-separated globs to record in the generated config."
481
+ },
482
+ {
483
+ flag: "--yes",
484
+ type: "boolean",
485
+ default: false,
486
+ appliesTo: optionAppliesTo("yes"),
487
+ summary: "Accept every default without prompting. Required in a non-TTY."
488
+ },
489
+ {
490
+ flag: "--force",
491
+ type: "boolean",
492
+ default: false,
493
+ appliesTo: optionAppliesTo("force"),
494
+ summary: "Overwrite an existing config file."
495
+ },
496
+ {
497
+ flag: "--install",
498
+ type: "boolean",
499
+ default: false,
500
+ appliesTo: optionAppliesTo("install"),
501
+ summary: "Write the skill into the repository instead of printing it."
502
+ },
503
+ {
504
+ flag: "--target",
505
+ type: "string",
506
+ default: "all",
507
+ appliesTo: optionAppliesTo("target"),
508
+ summary: 'Where to install: "claude", "cursor" or "all".'
509
+ },
510
+ {
511
+ flag: "--no-install",
512
+ type: "boolean",
513
+ default: false,
514
+ appliesTo: optionAppliesTo("no-install"),
515
+ summary: "setup only: skip installing the dependency before generating (CI, offline, or a lockfile you do not want a package manager to touch)."
516
+ }
517
+ ],
518
+ presets: PRESETS.map((preset) => ({
519
+ name: preset.name,
520
+ priority: preset.priority,
521
+ recommendedBudget: preset.recommendedBudget ?? "300k",
522
+ excludes: preset.exclude ? [...preset.exclude] : []
523
+ })),
524
+ artifacts: ARTIFACT_NAMES.map((name) => ({
525
+ name,
526
+ default: ARTIFACT_DEFAULTS[name] ?? true,
527
+ summary: ARTIFACT_SUMMARY[name] ?? name
528
+ })),
529
+ config: {
530
+ fileNames: [
531
+ "snapshot.config.ts",
532
+ "snapshot.config.js",
533
+ "snapshot.config.mjs",
534
+ "snapshot.config.cjs",
535
+ "snapshot.config.json",
536
+ ".snapshotrc.json",
537
+ ".snapshotrc",
538
+ 'package.json \u2192 "snapshot" key'
539
+ ],
540
+ settings: [
541
+ { key: "outDir", type: "string", default: "snapshot", summary: "Snapshot output directory." },
542
+ { key: "tmpDir", type: "string", default: "snapshot.tmp", summary: "Scratch directory used for the atomic swap." },
543
+ { key: "branch", type: "string", default: "main", summary: "Branch recorded when git cannot report one." },
544
+ { key: "preset", type: "string", default: "detected", summary: "Force a preset. Unknown names are an error." },
545
+ { key: "maxPartBytes", type: "number | string", default: 307200, summary: "Per-part byte budget." },
546
+ { key: "largeFileBytes", type: "number | string", default: 204800, summary: "Preview threshold for large files." },
547
+ { key: "previewHeadLines", type: "number", default: 80, summary: "Lines kept from the head of a large file." },
548
+ { key: "previewTailLines", type: "number", default: 40, summary: "Lines kept from the tail of a large file." },
549
+ { key: "includeMonolith", type: "boolean", default: false, summary: "Also write the single-file monolith." },
550
+ { key: "maxMonolithBytes", type: "number | string | null", default: null, summary: "Skip the monolith above this size." },
551
+ { key: "includeSensitiveFiles", type: "boolean", default: false, summary: "Emit .env / *.pem / credential files." },
552
+ { key: "include", type: "string[]", default: "[]", summary: "Allow-list of globs; empty means everything." },
553
+ { key: "excludePatterns", type: "string[]", default: "[]", summary: "Extra exclude globs." },
554
+ { key: "discovery", type: '"auto" | "git" | "walk"', default: "auto", summary: "How files are discovered; `walk` scans the tree and reads .gitignore directly when a .git directory cannot be listed." },
555
+ { key: "artifacts", type: "object", default: "all on except monolith", summary: "Per-artifact opt-out." },
556
+ { key: "budgets", type: "object", default: "{ default, byLayer }", summary: "Per-layer byte budgets." },
557
+ { key: "layersMode", type: '"merge" | "replace"', default: "merge", summary: "Whether custom layers extend or replace the preset's." },
558
+ { key: "layers", type: "array", default: "preset layers", summary: "Custom layer rules (glob or RegExp)." },
559
+ { key: "kinds", type: "array", default: "preset kinds", summary: "Custom kind rules." },
560
+ { key: "secrets", type: "object", default: "{}", summary: "Custom secret patterns, or disable the defaults." },
561
+ { key: "hooks", type: "object", default: "{}", summary: "afterGenerate(manifest) runs after a successful generate." }
562
+ ]
563
+ },
564
+ output: {
565
+ layout: [
566
+ "<outDir>/README.md",
567
+ "<outDir>/manifest.json",
568
+ "<outDir>/parts/part-NNN.md",
569
+ "<outDir>/reports/project-tree.md",
570
+ "<outDir>/reports/navigation.md",
571
+ "<outDir>/reports/audit.md",
572
+ "<outDir>/.codebase-snapshot (ownership marker, always written)"
573
+ ],
574
+ notes: [
575
+ "Parts are ordered and budget-limited; a single file larger than the budget becomes a head/tail preview.",
576
+ "manifest.json.files[] carries relativePath, layer, kind, language, part, inclusionMode and hash; `summary` is conditional \u2014 it is absent for languages with no structural summary, and `summaryOmitted: true` marks a summary dropped to fit the part budget; encoding appears when the file was not plain UTF-8.",
577
+ "manifest.skipped[] carries the path and the exact reason it was excluded.",
578
+ `The output directory is replaced atomically; ${MIN_MAX_PART_BYTES} bytes is the smallest legal budget.`
579
+ ]
580
+ },
581
+ safety: [
582
+ "Sensitive files (.env, *.pem, .npmrc, .ssh/, .tfstate) are excluded by default and listed in manifest.skipped[].",
583
+ "Secret-shaped strings in ordinary source files are redacted in the emitted parts.",
584
+ "The tool refuses to replace an outDir that it did not create, so a mistyped outDir cannot delete source code.",
585
+ "Nothing is ever written above the repository root; --out-dir may not be the root or an ancestor."
586
+ ],
587
+ troubleshooting: [
588
+ {
589
+ symptom: "Command prints nothing and exits 0",
590
+ cause: "A stale global install of this package (often a leftover `npm link`) is shadowing npx: npm runs the global binary instead of installing the published one.",
591
+ fix: "Check `npm ls -g --depth=0` for codebase-snapshot and remove it with `npm rm -g codebase-snapshot`. If it is not installed globally, clear the npx cache with `npm cache clean --force` and retry."
592
+ },
593
+ {
594
+ symptom: "Exit 1 with 'Refusing to replace outDir'",
595
+ cause: "The outDir exists and contains files this tool did not write.",
596
+ fix: "Choose a new/empty --out-dir, or delete the existing directory yourself."
597
+ },
598
+ {
599
+ symptom: "Exit 1 with 'The config file could not be read'",
600
+ cause: "snapshot.config.* exists but does not parse.",
601
+ fix: "Fix the syntax, or delete the config to use the defaults."
602
+ },
603
+ {
604
+ symptom: "Exit 1 from validate",
605
+ cause: "The snapshot no longer matches the files on disk.",
606
+ fix: "Re-run generate."
607
+ },
608
+ {
609
+ symptom: "A directory you expected is missing from the snapshot",
610
+ cause: "It is excluded by the preset, by default ignores, or by your own globs.",
611
+ fix: "Run `explain <path>`; manifest.skipped[] also records the reason for every file."
612
+ }
613
+ ]
614
+ };
615
+ }
616
+
617
+ // src/agent/skill.ts
618
+ var PACKAGE_NAME2 = "codebase-snapshot";
619
+ var MAJOR_VERSION2 = GENERATOR_VERSION.split(".")[0];
620
+ var SKILL_NAME = "codebase-snapshot";
621
+ function renderSkill() {
622
+ return `---
623
+ name: ${SKILL_NAME}
624
+ description: Create an LLM-ready snapshot of a repository (size-budgeted markdown parts, a JSON manifest, project tree, navigation and a secret audit) when a whole codebase has to be read, summarized or handed to a model that cannot clone it. Use when asked to snapshot a codebase, prepare repository context, or analyze a project's structure.
625
+ version: ${GENERATOR_VERSION}
626
+ ---
627
+
628
+ # codebase-snapshot
629
+
630
+ Turns a repository into an LLM-ready snapshot under \`./snapshot/\`: code packed into
631
+ size-budgeted markdown parts, plus a manifest that indexes every file, a project tree, a
632
+ navigation index and a secret audit.
633
+
634
+ Use it when you need to understand a whole repository, or produce repository context for
635
+ another model, and reading files one by one would not fit in context.
636
+
637
+ ## Before you start
638
+
639
+ This tool is a Node CLI. It needs **Node.js 18 or newer**. You do not need to install it
640
+ globally, and you do not need to clone anything.
641
+
642
+ **Pin a version** \u2014 \`npx ${PACKAGE_NAME2}@${MAJOR_VERSION2} <command>\` (or an exact release) \u2014 when you
643
+ need the same version on every machine. The bare package name works too: this package ships
644
+ one executable file, published under two names (\`codebase-snapshot\` and \`snap\`), which is what
645
+ lets npm pick it. If a command exits 0 printing nothing,
646
+ the executable was not resolved \u2014 clear the npm cache and retry; do not assume the repository
647
+ is broken.
648
+
649
+ ## Step by step
650
+
651
+ 1. **Verify the tool.** Run \`npx ${PACKAGE_NAME2}@latest --version\`. Expect \`v${MAJOR_VERSION2}\` or
652
+ newer on stdout \u2014 compare the major version, not the patch, because the
653
+ \`@latest\` tag can lag this release. If the printed major is older, pin the
654
+ major (\`@${MAJOR_VERSION2}\`) instead of retrying. Empty output means you hit the
655
+ bare-spec problem above.
656
+
657
+ 2. **Read the contract, not the prose.** Run
658
+ \`npx ${PACKAGE_NAME2}@latest describe --format json\`. It returns the full machine
659
+ contract: every command, every option with its default, the exit-code meanings, the
660
+ 25 project presets, the artifact list, the config settings and a troubleshooting
661
+ table. Read it before you make decisions; do not guess option names.
662
+
663
+ 3. **Look before you leap (optional but cheap).** To see how a specific file or directory
664
+ would be treated, run \`npx ${PACKAGE_NAME2}@latest explain <path>\`. It reports the
665
+ layer, kind, language, target part, and the exact reason a path would be skipped.
666
+ \`--format json\` if you want to parse it.
667
+
668
+ 3b. **One command, if the user just wants it working.** \`npx ${PACKAGE_NAME2}@latest setup\`
669
+ does steps 4\u20136 in one go with defaults: it writes the config (an existing one is kept),
670
+ adds the npm scripts and the devDependency, installs with the project's package manager,
671
+ and generates. Use \`--no-install\` when the machine is offline or the user does not want
672
+ their lockfile touched. Prefer the explicit steps below when you need to control any of
673
+ those, or when something fails and you want to see which part.
674
+
675
+ 4. **Configure (recommended).** Run \`npx ${PACKAGE_NAME2}@latest init --yes\`. It detects
676
+ the project shape, then writes a commented config file listing **every** setting at its
677
+ current value. The filename depends on the project: \`snapshot.config.ts\` only when
678
+ the running Node can strip types (Node >= 22.6) **and** the project is an ES module
679
+ (\`"type": "module"\` in package.json); otherwise \`snapshot.config.mjs\`. Pass
680
+ \`--preset <name>\` only if detection is wrong; the preset names are in the contract.
681
+ This step is optional: without a config the tool runs on the same defaults.
682
+
683
+ 5. **Generate.** Run \`npx ${PACKAGE_NAME2}@latest generate\`. Expect exit 0 and a
684
+ \`./snapshot/\` directory. If you need a different location use \`--out-dir <path>\`; if
685
+ you need smaller or larger parts use \`--max-part-bytes 120k\`.
686
+
687
+ 6. **Verify.** Run \`npx ${PACKAGE_NAME2}@latest validate\`. Exit 0 means the snapshot
688
+ matches the files on disk. Exit 1 means drift \u2014 re-run step 5.
689
+
690
+ 7. **Read the output in this order:**
691
+ - \`snapshot/parts/part-*.md\` \u2014 the code itself, each file tagged with its layer, kind,
692
+ language and, when it fits the budget, a structural summary (imports, exports,
693
+ classes, functions). A dropped summary appears as a \`Summary: (omitted \u2026)\` line.
694
+ - \`snapshot/manifest.json\` \u2014 the index. \`files[]\` has \`relativePath\`, \`layer\`,
695
+ \`kind\`, \`language\`, \`part\`, \`inclusionMode\` and \`hashSha256\`, plus \`encoding\`
696
+ when the file was not plain UTF-8. \`summary\` is **optional**: it is absent for
697
+ languages with no structural summary, and \`summaryOmitted: true\` marks one dropped
698
+ to fit the part budget \u2014 never assume it exists.
699
+ \`skipped[]\` records every excluded file **and why**. Prefer querying this over
700
+ re-reading the parts.
701
+ - \`snapshot/reports/audit.md\` \u2014 secrets found, large files, parse diagnostics, git
702
+ state. Read this before you quote code back to a user.
703
+ - \`snapshot/reports/project-tree.md\` and \`navigation.md\` \u2014 structure and cross-links.
704
+
705
+ ## Project types
706
+
707
+ Detection is automatic and covers Next.js, Nuxt, SvelteKit, Remix, Astro, Angular, Solid,
708
+ Vue+React with Vite, Nest, Express/Fastify/Koa, any Node library, monorepos, Django,
709
+ FastAPI/Flask, plain Python, Go, Rust, Java/Spring, .NET, PHP/Laravel, Ruby/Rails,
710
+ Terraform/Docker infra, docs-only repositories, and a generic fallback. It sets sensible
711
+ layers, kinds, ecosystem ignore patterns and a recommended part budget. For non-TypeScript
712
+ languages it extracts imports, exports, classes and functions structurally; Vue, Svelte and
713
+ Astro script blocks go through the real TypeScript compiler. You do not need to configure
714
+ any of this to get a good first snapshot.
715
+
716
+ ## Things that will surprise you
717
+
718
+ - **Sensitive files are excluded by default** (\`.env\`, \`*.pem\`, \`.npmrc\`, \`.ssh/\`,
719
+ \`.tfstate\`) and are listed in \`manifest.skipped[]\`. Add \`--include-sensitive-files\` only
720
+ if the user asks for them explicitly.
721
+ - **Secret-shaped strings inside normal source files are redacted** in the emitted parts.
722
+ If the user needs the raw value, say so rather than working around it.
723
+ - **The output directory is owned by the tool.** It is replaced atomically, and the tool
724
+ refuses to replace a directory it did not create. Never point \`--out-dir\` at source
725
+ code; if you see \`Refusing to replace outDir\`, choose a new directory rather than
726
+ deleting anything.
727
+ - **Exit 2 is a usage error**, not a failure of the repository. Re-read the contract
728
+ before retrying.
729
+ - **\`--format json\` keeps stdout pure.** Progress goes to stderr, so \`stdout\` is safe to
730
+ pipe into a JSON parser.
731
+
732
+ ## Making this available to other agents
733
+
734
+ Run \`npx ${PACKAGE_NAME2}@latest skill --install\`. It writes this skill into the two
735
+ registries agent tools read by name \u2014 \`.claude/skills/${SKILL_NAME}/SKILL.md\` and
736
+ \`.cursor/rules/${SKILL_NAME}.mdc\` \u2014 and appends exactly those files to \`.gitignore\`, so the
737
+ install leaves nothing untracked. It is idempotent, and it refuses a target that resolves
738
+ outside the repository, so a linked \`.claude/\` cannot be used to write elsewhere.
739
+
740
+ It never writes \`AGENTS.md\`. That file is the repository's own agent contract \u2014 build
741
+ commands, conventions, house rules \u2014 and this tool has no business editing it. If you want
742
+ this skill in there, run \`npx ${PACKAGE_NAME2}@latest skill\` and paste the text yourself: the
743
+ diff, and the commit, are then yours.
744
+ `;
745
+ }
746
+
747
+ // src/agent/install-skill.ts
748
+ var SKILL_TARGETS = ["claude", "cursor", "all"];
749
+ var REFUSED = "Refusing to install the agent skill";
750
+ var LEGACY_AGENTS_MARKER = "<!-- codebase-snapshot:skill:start -->";
751
+ function targetPath(cwd, target) {
752
+ switch (target) {
753
+ case "claude":
754
+ return path2.join(cwd, ".claude", "skills", SKILL_NAME, "SKILL.md");
755
+ case "cursor":
756
+ return path2.join(cwd, ".cursor", "rules", `${SKILL_NAME}.mdc`);
757
+ }
758
+ }
759
+ function assertWritableInsideRepository(cwd, filePath) {
760
+ const root = fs.realpathSync(cwd);
761
+ const shown = path2.relative(root, filePath) || filePath;
762
+ let stat = null;
763
+ try {
764
+ stat = fs.lstatSync(filePath);
765
+ } catch {
766
+ stat = null;
767
+ }
768
+ if (stat !== null && stat.isSymbolicLink()) {
769
+ throw new Error(
770
+ `${REFUSED}: ${shown} is a symbolic link or junction. Remove the link, or write the skill into the directory it points at yourself, and run again.`
771
+ );
772
+ }
773
+ if (stat !== null && stat.isFile() && stat.nlink > 1) {
774
+ throw new Error(
775
+ `${REFUSED}: ${shown} has ${stat.nlink} hard links, so writing it would also rewrite a file outside the repository. Replace it with an ordinary file and run again.`
776
+ );
777
+ }
778
+ let existing = filePath;
779
+ const missing = [];
780
+ while (!fs.existsSync(existing)) {
781
+ missing.unshift(path2.basename(existing));
782
+ const parent = path2.dirname(existing);
783
+ if (parent === existing) break;
784
+ existing = parent;
785
+ }
786
+ const realAncestor = fs.realpathSync(existing);
787
+ const resolved = missing.length === 0 ? realAncestor : path2.join(realAncestor, ...missing);
788
+ if (resolved !== root && !resolved.startsWith(`${root}${path2.sep}`)) {
789
+ throw new Error(
790
+ `${REFUSED}: ${shown} resolves outside the repository (${resolved}). Move it back inside ${root}, or remove the link, and run again.`
791
+ );
792
+ }
793
+ }
794
+ function writeIfChanged(target, filePath, content) {
795
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
796
+ if (existing === content) return { target, path: filePath, action: "unchanged" };
797
+ fs.mkdirSync(path2.dirname(filePath), { recursive: true });
798
+ fs.writeFileSync(filePath, content, "utf8");
799
+ return { target, path: filePath, action: existing === null ? "created" : "updated" };
800
+ }
801
+ function legacyAgentsNote(cwd) {
802
+ const filePath = path2.join(cwd, "AGENTS.md");
803
+ try {
804
+ if (!fs.existsSync(filePath)) return null;
805
+ if (!fs.readFileSync(filePath, "utf8").includes(LEGACY_AGENTS_MARKER)) return null;
806
+ } catch {
807
+ return null;
808
+ }
809
+ return `AGENTS.md still contains the section an earlier version installed (between the "${LEGACY_AGENTS_MARKER}" markers). This command no longer edits that file: it is your project's own agent instructions. Delete the block if you do not want it.`;
810
+ }
811
+ function installSkill(cwd, target = "all") {
812
+ const skill = renderSkill();
813
+ const written = [];
814
+ const notes = [];
815
+ const wanted = target === "all" ? ["claude", "cursor"] : [target];
816
+ for (const item of wanted) assertWritableInsideRepository(cwd, targetPath(cwd, item));
817
+ const createsSomething = wanted.some((item) => !fs.existsSync(targetPath(cwd, item)));
818
+ if (createsSomething) {
819
+ assertWritableInsideRepository(cwd, path2.join(cwd, ".gitignore"));
820
+ }
821
+ for (const item of wanted) {
822
+ written.push(writeIfChanged(item, targetPath(cwd, item), skill));
823
+ }
824
+ const created = written.filter((item) => item.action === "created").map((item) => gitignorePath(cwd, item.path));
825
+ const ignore = appendGitignoreEntries(cwd, created);
826
+ if (ignore.note !== null) notes.push(ignore.note);
827
+ const legacy = legacyAgentsNote(cwd);
828
+ if (legacy !== null) notes.push(legacy);
829
+ return { cwd, written, gitignored: ignore.added, notes };
830
+ }
831
+ function isSkillTarget(value) {
832
+ return SKILL_TARGETS.includes(value);
833
+ }
834
+
835
+ // src/cli.ts
836
+ var VERSION = GENERATOR_VERSION;
837
+ var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
838
+ var COMMAND_ALIASES = /* @__PURE__ */ new Map([
839
+ ["generate", "generate"],
840
+ ["validate", "validate"],
841
+ ["check", "validate"],
842
+ ["report", "report"],
843
+ ["init", "init"],
844
+ ["setup", "setup"],
845
+ ["explain", "explain"],
846
+ ["diff", "diff"],
847
+ ["describe", "describe"],
848
+ ["skill", "skill"]
849
+ ]);
850
+ var COMMAND_ARITY = {
851
+ generate: 0,
852
+ validate: 0,
853
+ report: 0,
854
+ init: 0,
855
+ setup: 0,
856
+ explain: 1,
857
+ diff: 2,
858
+ describe: 0,
859
+ skill: 0
860
+ };
861
+ var CLI_OPTIONS = {
862
+ cwd: { type: "string" },
863
+ "out-dir": { type: "string" },
864
+ "max-part-bytes": { type: "string" },
865
+ "large-file-bytes": { type: "string" },
866
+ "max-monolith-bytes": { type: "string" },
867
+ "include-monolith": { type: "boolean" },
868
+ "include-sensitive-files": { type: "boolean" },
869
+ "skip-artifacts": { type: "string" },
870
+ "only-artifacts": { type: "string" },
871
+ format: { type: "string" },
872
+ mode: { type: "string" },
873
+ "dry-run": { type: "boolean" },
874
+ watch: { type: "boolean" },
875
+ "no-config": { type: "boolean" },
876
+ discovery: { type: "string" },
877
+ preset: { type: "string" },
878
+ include: { type: "string" },
879
+ exclude: { type: "string" },
880
+ yes: { type: "boolean" },
881
+ force: { type: "boolean" },
882
+ install: { type: "boolean" },
883
+ "no-install": { type: "boolean" },
884
+ target: { type: "string" },
885
+ help: { type: "boolean", short: "h" },
886
+ version: { type: "boolean", short: "v" }
887
+ };
888
+ var UsageError = class extends Error {
889
+ };
890
+ var USAGE = `
891
+ ${GENERATOR_NAME} v${VERSION}
892
+ Production-grade LLM codebase snapshot generator, auditor, and AST partitioner
893
+
894
+ Usage:
895
+ codebase-snapshot [command] [options]
896
+
897
+ Commands:
898
+ setup Everything at once: config, scripts, dependency, install, generate
899
+ generate (default) Generate a complete partitioned codebase snapshot
900
+ validate, check Validate snapshot integrity and check for drift
901
+ init Generate a starter snapshot.config file
902
+ report Print codebase health, layer metrics, and secret audit
903
+ explain <path> Show how one path would be treated: layer, kind, part, skips
904
+ diff <a> <b> Compare two snapshots (directories or manifest.json files)
905
+ describe Print the machine-readable contract (--format json for agents)
906
+ skill Print the bundled agent skill; --install writes it into the repo
907
+
908
+ Options:
909
+ --cwd <path> Target repository directory (default: current directory)
910
+ --out-dir <path> Snapshot output directory (default: snapshot)
911
+ --include-monolith Also generate full-codebase.json and full-codebase.md
912
+ --max-monolith-bytes <n> Skip monolith artefacts larger than n bytes
913
+ --max-part-bytes <n> Maximum byte budget per markdown part (default: 307200).
914
+ Accepts units: 120k, 1.5mb
915
+ --large-file-bytes <n> Threshold for head/tail truncation (default: 204800)
916
+ --include-sensitive-files Emit .env / *.pem / .npmrc / credential files
917
+ --skip-artifacts <names> Comma-separated artifacts to omit
918
+ (readme, manifest, parts, navigation,
919
+ projectTree, audit, monolith)
920
+ --only-artifacts <names> Write *only* these artifacts
921
+ --format <text|json> CLI output format (default: text)
922
+ --dry-run Compute the snapshot without writing anything
923
+ --watch Re-generate whenever the repository changes
924
+ --no-config Ignore snapshot.config.* and use the built-in defaults
925
+ --discovery <mode> auto | git | walk \u2014 how files are discovered.
926
+ auto: git when usable. git: require git. walk: read
927
+ .gitignore directly (escape hatch for a broken .git)
928
+
929
+ init only:
930
+ --preset <name> Force a project preset instead of detecting one
931
+ --include <globs> Comma-separated allow-list
932
+ --exclude <globs> Comma-separated exclude patterns
933
+ --yes Accept every default without prompting
934
+ --force Overwrite an existing config
935
+
936
+ setup only:
937
+ --no-install Skip installing the dependency before generating
938
+ --preset / --include / --exclude / --force As for init
939
+
940
+ skill only:
941
+ --install Write the skill into the repository instead of printing
942
+ --target <name> claude | cursor | all (default: all)
943
+
944
+ -h, --help Display this help message
945
+ -v, --version Display version
946
+
947
+ All value-taking options also accept the --option=value form.
948
+ Human-readable progress is written to stderr; payloads (--format json) go to stdout.
949
+ Agents with no prior knowledge of this tool should start with: describe --format json
950
+ `.trim();
951
+ function parsePositiveInteger(raw, optionName, minimum) {
952
+ if (raw === void 0) return void 0;
953
+ const trimmed = raw.trim();
954
+ if (!/^\d+$/.test(trimmed)) {
955
+ throw new UsageError(`--${optionName} expects a whole number, received "${raw}".`);
956
+ }
957
+ const value = Number(trimmed);
958
+ if (!Number.isSafeInteger(value) || value < minimum) {
959
+ throw new UsageError(`--${optionName} must be at least ${minimum}, received "${raw}".`);
960
+ }
961
+ return value;
962
+ }
963
+ function parseByteOption(raw, optionName, minimum) {
964
+ if (raw === void 0) return void 0;
965
+ const value = parseByteSize(raw);
966
+ if (value === void 0 || !Number.isSafeInteger(value)) {
967
+ throw new UsageError(
968
+ `--${optionName} expects a byte count such as 120000 or "120k", received "${raw}".`
969
+ );
970
+ }
971
+ if (value < minimum) {
972
+ throw new UsageError(`--${optionName} must be at least ${minimum}, received "${raw}".`);
973
+ }
974
+ return value;
975
+ }
976
+ function parseArtifactList(raw, flag) {
977
+ const names = raw.split(",").map((name) => name.trim()).filter(Boolean);
978
+ if (names.length === 0) {
979
+ throw new UsageError(
980
+ `${flag} expects at least one artifact name (${ARTIFACT_NAMES.join(", ")}).`
981
+ );
982
+ }
983
+ const unknown = names.filter((name) => !ARTIFACT_NAMES.includes(name));
984
+ if (unknown.length > 0) {
985
+ throw new UsageError(
986
+ `Unknown artifact(s): ${unknown.join(", ")}. Expected one of: ${ARTIFACT_NAMES.join(", ")}.`
987
+ );
988
+ }
989
+ return names;
990
+ }
991
+ function parseCliArgs(argv) {
992
+ let parsed;
993
+ try {
994
+ parsed = parseArgs({
995
+ args: argv,
996
+ options: CLI_OPTIONS,
997
+ allowPositionals: true,
998
+ strict: true
999
+ });
1000
+ } catch (err) {
1001
+ const message = err.message;
1002
+ const ambiguous = /^Option '(--[^']+)' argument is ambiguous/i.exec(message);
1003
+ if (ambiguous) {
1004
+ throw new UsageError(
1005
+ `${ambiguous[1]} received a value that looks like another flag (for example a negative number). Use the "--option=value" form, and note that sizes must be positive.`
1006
+ );
1007
+ }
1008
+ throw new UsageError(message);
1009
+ }
1010
+ const values = parsed.values;
1011
+ for (const [name, spec] of Object.entries(CLI_OPTIONS)) {
1012
+ if (spec.type !== "string") continue;
1013
+ const raw = values[name];
1014
+ if (raw !== void 0 && String(raw).trim() === "") {
1015
+ throw new UsageError(`--${name} requires a value (received an empty string).`);
1016
+ }
1017
+ }
1018
+ if (values.cwd !== void 0) {
1019
+ const target = String(values.cwd);
1020
+ if (!fs.existsSync(target)) {
1021
+ throw new UsageError(`--cwd directory does not exist: ${target}`);
1022
+ }
1023
+ if (!fs.statSync(target).isDirectory()) {
1024
+ throw new UsageError(`--cwd is not a directory: ${target}`);
1025
+ }
1026
+ }
1027
+ if (values.help) {
1028
+ process.stdout.write(`${USAGE}
1029
+ `);
1030
+ throw new HelpRequested();
1031
+ }
1032
+ if (values.version) {
1033
+ process.stdout.write(`v${VERSION}
1034
+ `);
1035
+ throw new HelpRequested();
1036
+ }
1037
+ const positionals = parsed.positionals;
1038
+ let command = "generate";
1039
+ let args = [];
1040
+ const explicitMode = values.mode ? String(values.mode) : void 0;
1041
+ const firstPositional = positionals[0];
1042
+ if (firstPositional !== void 0) {
1043
+ const resolved = COMMAND_ALIASES.get(firstPositional);
1044
+ if (!resolved) {
1045
+ throw new UsageError(
1046
+ `Unknown command "${firstPositional}". Expected one of: ${Array.from(COMMAND_ALIASES.keys()).join(", ")}.`
1047
+ );
1048
+ }
1049
+ command = resolved;
1050
+ args = positionals.slice(1);
1051
+ } else if (explicitMode !== void 0) {
1052
+ const resolved = COMMAND_ALIASES.get(explicitMode);
1053
+ if (!resolved) {
1054
+ throw new UsageError(
1055
+ `Unknown --mode "${explicitMode}". Expected one of: ${Array.from(COMMAND_ALIASES.keys()).join(", ")}.`
1056
+ );
1057
+ }
1058
+ command = resolved;
1059
+ }
1060
+ if (explicitMode !== void 0) {
1061
+ const resolvedMode = COMMAND_ALIASES.get(explicitMode);
1062
+ if (!resolvedMode) {
1063
+ throw new UsageError(
1064
+ `Unknown --mode "${explicitMode}". Expected one of: ${Array.from(COMMAND_ALIASES.keys()).join(", ")}.`
1065
+ );
1066
+ }
1067
+ if (firstPositional !== void 0 && resolvedMode !== command) {
1068
+ throw new UsageError(
1069
+ `--mode ${explicitMode} contradicts the command "${firstPositional}". Pass one or the other.`
1070
+ );
1071
+ }
1072
+ }
1073
+ const expectedArgs = COMMAND_ARITY[command];
1074
+ if (args.length !== expectedArgs) {
1075
+ if (command === "explain") {
1076
+ throw new UsageError("explain expects exactly one path.");
1077
+ }
1078
+ if (command === "diff") {
1079
+ throw new UsageError(
1080
+ "diff expects exactly two arguments: two snapshot directories or manifest.json files."
1081
+ );
1082
+ }
1083
+ throw new UsageError(
1084
+ `Unexpected argument(s): ${args.join(" ")}. ${command} takes no positional arguments.`
1085
+ );
1086
+ }
1087
+ const rawFormat = values.format === void 0 ? "text" : String(values.format);
1088
+ if (rawFormat !== "text" && rawFormat !== "json") {
1089
+ throw new UsageError(`--format must be "text" or "json", received "${rawFormat}".`);
1090
+ }
1091
+ const rawDiscovery = values.discovery === void 0 ? void 0 : String(values.discovery);
1092
+ if (rawDiscovery !== void 0 && !isDiscoveryMode(rawDiscovery)) {
1093
+ throw new UsageError(
1094
+ `--discovery must be one of: auto, git, walk (received "${rawDiscovery}").`
1095
+ );
1096
+ }
1097
+ let onlyNamesMonolith = false;
1098
+ const rawSkip = values["skip-artifacts"];
1099
+ const rawOnly = values["only-artifacts"];
1100
+ if (rawSkip !== void 0 && rawOnly !== void 0) {
1101
+ throw new UsageError("--skip-artifacts and --only-artifacts cannot be combined.");
1102
+ }
1103
+ let skipArtifacts;
1104
+ if (rawOnly !== void 0) {
1105
+ const keep = parseArtifactList(String(rawOnly), "--only-artifacts");
1106
+ onlyNamesMonolith = keep.includes("monolith");
1107
+ skipArtifacts = ARTIFACT_NAMES.filter(
1108
+ (name) => !keep.includes(name)
1109
+ );
1110
+ } else if (rawSkip !== void 0) {
1111
+ skipArtifacts = parseArtifactList(String(rawSkip), "--skip-artifacts");
1112
+ }
1113
+ const scope = COMMAND_OPTION_SCOPE[command];
1114
+ const ignored = [];
1115
+ for (const name of Object.keys(CLI_OPTIONS)) {
1116
+ if (scope.includes(name)) continue;
1117
+ const value = values[name];
1118
+ if (value === void 0 || value === false) continue;
1119
+ ignored.push(`--${name}`);
1120
+ }
1121
+ if (ignored.length > 0) {
1122
+ const owners = Array.from(
1123
+ new Set(
1124
+ ignored.flatMap(
1125
+ (flag) => commandsForOption(flag.replace(/^--/, "")).map((owner) => `\`${owner}\``)
1126
+ )
1127
+ )
1128
+ );
1129
+ const hint = ignored.some((flag) => ["--preset", "--include", "--exclude"].includes(flag)) ? " To scope a run, set include/excludePatterns/preset in snapshot.config.ts (or run `codebase-snapshot init` to generate one)." : "";
1130
+ throw new UsageError(
1131
+ `${ignored.join(", ")} ${ignored.length === 1 ? "does" : "do"} not apply to \`${command}\` and ${ignored.length === 1 ? "was" : "were"} not applied. ${ignored.length === 1 ? "It is" : "They are"} accepted by: ${owners.join(", ")}.${hint}`
1132
+ );
1133
+ }
1134
+ if (command === "init" && values.preset !== void 0) {
1135
+ const requested = String(values.preset);
1136
+ if (!getPreset(requested)) throw new UsageError(unknownPresetMessage(requested));
1137
+ }
1138
+ if (command === "skill" && values.target !== void 0 && !isSkillTarget(String(values.target))) {
1139
+ throw new UsageError(
1140
+ `--target must be one of: ${SKILL_TARGETS.join(", ")} (received "${String(values.target)}").`
1141
+ );
1142
+ }
1143
+ return {
1144
+ command,
1145
+ args,
1146
+ format: rawFormat,
1147
+ dryRun: values["dry-run"] === true,
1148
+ watch: values.watch === true,
1149
+ noConfig: values["no-config"] === true,
1150
+ preset: values.preset === void 0 ? void 0 : String(values.preset),
1151
+ maxPartBytesRaw: values["max-part-bytes"] === void 0 ? void 0 : String(values["max-part-bytes"]),
1152
+ include: values.include === void 0 ? void 0 : String(values.include),
1153
+ exclude: values.exclude === void 0 ? void 0 : String(values.exclude),
1154
+ yes: values.yes === true,
1155
+ force: values.force === true,
1156
+ install: values.install === true,
1157
+ noInstall: values["no-install"] === true,
1158
+ target: values.target === void 0 ? void 0 : String(values.target),
1159
+ cwd: values.cwd === void 0 ? process.cwd() : path2.resolve(String(values.cwd)),
1160
+ outDir: values["out-dir"] === void 0 ? void 0 : String(values["out-dir"]),
1161
+ includeMonolith: values["include-monolith"] === true || onlyNamesMonolith === true ? true : void 0,
1162
+ includeSensitiveFiles: values["include-sensitive-files"] === true ? true : void 0,
1163
+ maxPartBytes: parseByteOption(
1164
+ values["max-part-bytes"],
1165
+ "max-part-bytes",
1166
+ MIN_MAX_PART_BYTES
1167
+ ),
1168
+ largeFileBytes: parseByteOption(
1169
+ values["large-file-bytes"],
1170
+ "large-file-bytes",
1171
+ 1
1172
+ ),
1173
+ maxMonolithBytes: parseByteOption(
1174
+ values["max-monolith-bytes"],
1175
+ "max-monolith-bytes",
1176
+ 1
1177
+ ),
1178
+ skipArtifacts,
1179
+ discovery: isDiscoveryMode(rawDiscovery) ? rawDiscovery : void 0
1180
+ };
1181
+ }
1182
+ var HelpRequested = class extends Error {
1183
+ };
1184
+ function escapeForTerminal(message) {
1185
+ return message.split("\n").map((line) => escapeControls(line)).join("\n");
1186
+ }
1187
+ function info(message) {
1188
+ process.stderr.write(`${escapeForTerminal(message)}
1189
+ `);
1190
+ }
1191
+ function warn(message) {
1192
+ process.stderr.write(`${escapeForTerminal(message)}
1193
+ `);
1194
+ }
1195
+ function emit(format, payload, textRenderer) {
1196
+ if (format === "json") {
1197
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
1198
+ `);
1199
+ return;
1200
+ }
1201
+ textRenderer();
1202
+ }
1203
+ function fail(format, message, exitCode = 1) {
1204
+ warn(`\u274C ${message}`);
1205
+ if (format === "json") {
1206
+ process.stdout.write(`${JSON.stringify({ error: { message, exitCode } }, null, 2)}
1207
+ `);
1208
+ }
1209
+ return exitCode;
1210
+ }
1211
+ function splitList(value) {
1212
+ if (value === void 0) return void 0;
1213
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
1214
+ }
1215
+ function displayOutPath(cwd, outDir, ...segments) {
1216
+ const absolute = path2.resolve(cwd, outDir, ...segments);
1217
+ const relative3 = path2.relative(cwd, absolute);
1218
+ if (relative3 && !relative3.startsWith("..") && !path2.isAbsolute(relative3)) return relative3;
1219
+ return absolute;
1220
+ }
1221
+ function nextStepHint(cwd, scriptsAddedNow) {
1222
+ let hasScript = scriptsAddedNow;
1223
+ if (!hasScript) {
1224
+ try {
1225
+ const parsed = JSON.parse(fs.readFileSync(path2.join(cwd, "package.json"), "utf8"));
1226
+ hasScript = typeof parsed.scripts?.snapshot === "string";
1227
+ } catch {
1228
+ hasScript = false;
1229
+ }
1230
+ }
1231
+ if (!hasScript) return `npx ${GENERATOR_NAME} generate`;
1232
+ const bin = path2.join(cwd, "node_modules", ".bin", "codebase-snapshot");
1233
+ const installed = fs.existsSync(bin) || fs.existsSync(`${bin}.cmd`);
1234
+ return installed ? "npm run snapshot" : "npm install && npm run snapshot";
1235
+ }
1236
+ async function runInitCommand(invocation) {
1237
+ try {
1238
+ const result = await runInit({
1239
+ cwd: invocation.cwd,
1240
+ preset: invocation.preset,
1241
+ outDir: invocation.outDir,
1242
+ maxPartBytes: invocation.maxPartBytesRaw ?? invocation.maxPartBytes,
1243
+ include: splitList(invocation.include),
1244
+ exclude: splitList(invocation.exclude),
1245
+ skipArtifacts: invocation.skipArtifacts,
1246
+ includeSensitiveFiles: invocation.includeSensitiveFiles,
1247
+ yes: invocation.yes,
1248
+ force: invocation.force,
1249
+ dryRun: invocation.dryRun
1250
+ });
1251
+ if (invocation.format === "json") {
1252
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1253
+ `);
1254
+ return 0;
1255
+ }
1256
+ if (!result.written) {
1257
+ info(result.configText);
1258
+ return 0;
1259
+ }
1260
+ info(`\u2705 Created ${path2.basename(result.configPath)} (preset: ${result.preset})`);
1261
+ if (result.scripts.length > 0) info(` Added npm scripts: ${result.scripts.join(", ")}`);
1262
+ if (result.dependency.added && result.dependency.range !== null) {
1263
+ info(` Added devDependency: ${result.dependency.name} ${result.dependency.range}`);
1264
+ }
1265
+ if (result.gitignoreEntries.length > 0) {
1266
+ info(` Added to .gitignore: ${result.gitignoreEntries.join(", ")}`);
1267
+ }
1268
+ for (const note of result.notes) info(` \u26A0 ${note}`);
1269
+ info("");
1270
+ info(` Next: ${nextStepHint(invocation.cwd, result.scripts.length > 0)}`);
1271
+ return 0;
1272
+ } catch (err) {
1273
+ if (err instanceof PromptCancelled) {
1274
+ return fail(invocation.format, "Cancelled.");
1275
+ }
1276
+ return fail(invocation.format, err.message);
1277
+ }
1278
+ }
1279
+ function detectPackageManager(cwd) {
1280
+ const has = (name) => fs.existsSync(path2.join(cwd, name));
1281
+ if (has("bun.lockb") || has("bun.lock")) return { name: "bun", command: "bun", args: ["install"] };
1282
+ if (has("pnpm-lock.yaml")) return { name: "pnpm", command: "pnpm", args: ["install"] };
1283
+ if (has("yarn.lock")) return { name: "yarn", command: "yarn", args: ["install"] };
1284
+ return { name: "npm", command: "npm", args: ["install"] };
1285
+ }
1286
+ function cleanInstallEnv(env = process.env) {
1287
+ const cleaned = {};
1288
+ for (const [key, value] of Object.entries(env)) {
1289
+ if (/^npm_config_/i.test(key)) continue;
1290
+ if (key === "npm_lifecycle_event" || key === "npm_lifecycle_script") continue;
1291
+ cleaned[key] = value;
1292
+ }
1293
+ return cleaned;
1294
+ }
1295
+ function installDependencies(cwd) {
1296
+ const manager = detectPackageManager(cwd);
1297
+ const command = `${manager.command} ${manager.args.join(" ")}`;
1298
+ const started = performance.now();
1299
+ const result = spawnSync(manager.command, manager.args, {
1300
+ cwd,
1301
+ encoding: "utf8",
1302
+ // npm, pnpm and yarn ship as `.cmd` shims on Windows, which spawnSync cannot exec directly.
1303
+ shell: process.platform === "win32",
1304
+ timeout: INSTALL_TIMEOUT_MS,
1305
+ env: cleanInstallEnv()
1306
+ });
1307
+ const seconds = `${((performance.now() - started) / 1e3).toFixed(1)}s`;
1308
+ if (result.error) {
1309
+ return { manager: manager.name, command, ok: false, detail: `${result.error.message} (${seconds})` };
1310
+ }
1311
+ if (result.status !== 0) {
1312
+ const tail = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim().split(/\r?\n/).slice(-3).join(" | ");
1313
+ return { manager: manager.name, command, ok: false, detail: `exit ${result.status}: ${tail} (${seconds})` };
1314
+ }
1315
+ return { manager: manager.name, command, ok: true, detail: seconds };
1316
+ }
1317
+ async function captureStdout(fn) {
1318
+ const original = process.stdout.write.bind(process.stdout);
1319
+ let text = "";
1320
+ process.stdout.write = ((chunk) => {
1321
+ text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
1322
+ return true;
1323
+ });
1324
+ try {
1325
+ const code = await fn();
1326
+ return { code, text };
1327
+ } finally {
1328
+ process.stdout.write = original;
1329
+ }
1330
+ }
1331
+ async function runSetupCommand(invocation) {
1332
+ try {
1333
+ const init = await runInit({
1334
+ cwd: invocation.cwd,
1335
+ preset: invocation.preset,
1336
+ outDir: invocation.outDir,
1337
+ maxPartBytes: invocation.maxPartBytesRaw ?? invocation.maxPartBytes,
1338
+ include: splitList(invocation.include),
1339
+ exclude: splitList(invocation.exclude),
1340
+ skipArtifacts: invocation.skipArtifacts,
1341
+ includeSensitiveFiles: invocation.includeSensitiveFiles,
1342
+ yes: true,
1343
+ force: invocation.force,
1344
+ reuseConfig: true,
1345
+ dryRun: invocation.dryRun
1346
+ });
1347
+ if (invocation.dryRun) {
1348
+ const manager = invocation.noInstall ? null : detectPackageManager(invocation.cwd).name;
1349
+ if (invocation.format === "json") {
1350
+ process.stdout.write(
1351
+ `${JSON.stringify(
1352
+ {
1353
+ dryRun: true,
1354
+ configPath: init.configPath,
1355
+ configReused: init.reused,
1356
+ preset: init.preset,
1357
+ install: manager,
1358
+ configText: init.configText
1359
+ },
1360
+ null,
1361
+ 2
1362
+ )}
1363
+ `
1364
+ );
1365
+ return 0;
1366
+ }
1367
+ info("\n\u{1F9EA} Dry run \u2014 setup would:");
1368
+ info(
1369
+ ` 1. ${init.reused ? `keep ${path2.basename(init.configPath)}` : `write ${path2.basename(init.configPath)} (preset: ${init.preset})`}`
1370
+ );
1371
+ info(" 2. add the npm scripts, the devDependency and the .gitignore entries");
1372
+ if (manager !== null) info(` 3. install with ${manager}`);
1373
+ info(" 4. generate the snapshot");
1374
+ info("");
1375
+ info(init.configText);
1376
+ return 0;
1377
+ }
1378
+ if (invocation.format !== "json") {
1379
+ info(`\u{1F680} Setup for: ${invocation.cwd}`);
1380
+ if (init.reused) {
1381
+ info(`\u267B Keeping ${path2.basename(init.configPath)} (preset: ${init.preset})`);
1382
+ } else {
1383
+ info(`\u2705 Created ${path2.basename(init.configPath)} (preset: ${init.preset})`);
1384
+ }
1385
+ if (init.scripts.length > 0) info(` Added npm scripts: ${init.scripts.join(", ")}`);
1386
+ if (init.dependency.added && init.dependency.range !== null) {
1387
+ info(` Added devDependency: ${init.dependency.name} ${init.dependency.range}`);
1388
+ }
1389
+ if (init.gitignoreEntries.length > 0) {
1390
+ info(` Added to .gitignore: ${init.gitignoreEntries.join(", ")}`);
1391
+ }
1392
+ }
1393
+ let install = null;
1394
+ if (invocation.noInstall) {
1395
+ if (invocation.format !== "json") info(" Skipped the install (--no-install)");
1396
+ } else {
1397
+ if (invocation.format !== "json") info("\u23F3 Installing dependencies\u2026");
1398
+ install = installDependencies(invocation.cwd);
1399
+ if (invocation.format !== "json") {
1400
+ if (install.ok) info(`\u2705 Installed dependencies (${install.command}, ${install.detail})`);
1401
+ else info(`\u26A0 ${install.command} failed (${install.detail}); generating anyway`);
1402
+ }
1403
+ }
1404
+ const generateArgs = ["generate", "--cwd", invocation.cwd, "--format", invocation.format];
1405
+ if (invocation.outDir !== void 0) generateArgs.push("--out-dir", invocation.outDir);
1406
+ if (invocation.includeSensitiveFiles === true) generateArgs.push("--include-sensitive-files");
1407
+ const budget = invocation.maxPartBytesRaw ?? invocation.maxPartBytes;
1408
+ if (budget !== void 0) generateArgs.push("--max-part-bytes", String(budget));
1409
+ if ((invocation.skipArtifacts ?? []).length > 0) {
1410
+ generateArgs.push("--skip-artifacts", (invocation.skipArtifacts ?? []).join(","));
1411
+ }
1412
+ if (invocation.format === "json") {
1413
+ const generated = await captureStdout(() => main(generateArgs));
1414
+ let manifest = null;
1415
+ try {
1416
+ manifest = JSON.parse(generated.text);
1417
+ } catch {
1418
+ manifest = null;
1419
+ }
1420
+ process.stdout.write(
1421
+ `${JSON.stringify(
1422
+ {
1423
+ setup: {
1424
+ configPath: init.configPath,
1425
+ configReused: init.reused,
1426
+ preset: init.preset,
1427
+ scripts: init.scripts,
1428
+ dependency: init.dependency,
1429
+ gitignoreEntries: init.gitignoreEntries,
1430
+ install,
1431
+ notes: init.notes
1432
+ },
1433
+ manifest
1434
+ },
1435
+ null,
1436
+ 2
1437
+ )}
1438
+ `
1439
+ );
1440
+ return generated.code;
1441
+ }
1442
+ const code = await main(generateArgs);
1443
+ info("");
1444
+ info(` Next: ${nextStepHint(invocation.cwd, false)}`);
1445
+ return code;
1446
+ } catch (err) {
1447
+ if (err instanceof PromptCancelled) return fail(invocation.format, "Cancelled.");
1448
+ return fail(invocation.format, err.message);
1449
+ }
1450
+ }
1451
+ function runSkillCommand(invocation) {
1452
+ if (!invocation.install) {
1453
+ if (invocation.format === "json") {
1454
+ process.stdout.write(
1455
+ `${JSON.stringify({ name: SKILL_NAME, version: GENERATOR_VERSION, skill: renderSkill() }, null, 2)}
1456
+ `
1457
+ );
1458
+ return 0;
1459
+ }
1460
+ process.stdout.write(renderSkill());
1461
+ return 0;
1462
+ }
1463
+ try {
1464
+ const result = installSkill(invocation.cwd, invocation.target);
1465
+ if (invocation.format === "json") {
1466
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1467
+ `);
1468
+ return 0;
1469
+ }
1470
+ info(`\u2705 Agent skill installed (${SKILL_NAME}):`);
1471
+ for (const item of result.written) {
1472
+ const shown = path2.relative(invocation.cwd, item.path) || item.path;
1473
+ info(` - ${item.action.padEnd(9)} ${shown}`);
1474
+ }
1475
+ if (result.gitignored.length > 0) {
1476
+ info("");
1477
+ info(` Added to .gitignore: ${result.gitignored.join(", ")}`);
1478
+ info(" These files are this tool's own documentation, so they stay out of git.");
1479
+ }
1480
+ for (const note of result.notes) info(` \u26A0 ${note}`);
1481
+ info("");
1482
+ info(" Agents that read .claude/skills or .cursor/rules will pick this up automatically.");
1483
+ return 0;
1484
+ } catch (err) {
1485
+ return fail(invocation.format, `Could not install the skill: ${err.message}`);
1486
+ }
1487
+ }
1488
+ function renderValidateText(result) {
1489
+ if (result.valid) {
1490
+ info("\u2705 Snapshot is valid and up to date with disk.");
1491
+ } else {
1492
+ warn("\u274C Snapshot validation failed:");
1493
+ for (const error of result.errors) warn(` - ${error}`);
1494
+ for (const unreadable of result.drift.unreadable.slice(0, 20)) {
1495
+ warn(` - unreadable: ${unreadable}`);
1496
+ }
1497
+ for (const drift of result.drift.hashMismatches.slice(0, 20)) {
1498
+ warn(` - modified: ${drift.path}`);
1499
+ }
1500
+ for (const issue of result.drift.partIssues.slice(0, 20)) {
1501
+ warn(` - part: ${issue.part} (${issue.issue})`);
1502
+ }
1503
+ for (const mismatch of result.drift.configMismatches.slice(0, 20)) {
1504
+ warn(` - config: ${mismatch}`);
1505
+ }
1506
+ for (const missing of result.drift.missingInSnapshot.slice(0, 20)) {
1507
+ warn(` - new file: ${missing}`);
1508
+ }
1509
+ for (const missing of result.drift.missingOnDisk.slice(0, 20)) {
1510
+ warn(` - deleted: ${missing}`);
1511
+ }
1512
+ for (const mismatch of result.drift.totalsMismatches.slice(0, 20)) {
1513
+ warn(` - counts: ${mismatch}`);
1514
+ }
1515
+ }
1516
+ for (const warning of result.warnings) warn(` \u26A0 ${warning}`);
1517
+ }
1518
+ function requestsJsonFormat(argv) {
1519
+ let format;
1520
+ for (let index = 0; index < argv.length; index += 1) {
1521
+ const argument = argv[index];
1522
+ if (argument === "--") break;
1523
+ if (argument === "--format") {
1524
+ const next = argv[index + 1];
1525
+ if (next !== void 0 && !next.startsWith("-")) {
1526
+ format = next;
1527
+ index += 1;
1528
+ }
1529
+ continue;
1530
+ }
1531
+ if (argument.startsWith("--format=")) format = argument.slice("--format=".length);
1532
+ }
1533
+ return format === "json";
1534
+ }
1535
+ async function main(argv = process.argv.slice(2)) {
1536
+ let invocation;
1537
+ try {
1538
+ invocation = parseCliArgs(argv);
1539
+ } catch (err) {
1540
+ if (err instanceof HelpRequested) return 0;
1541
+ if (err instanceof UsageError) {
1542
+ process.stderr.write(`\u274C ${err.message}
1543
+
1544
+ ${USAGE}
1545
+ `);
1546
+ if (requestsJsonFormat(argv)) {
1547
+ process.stdout.write(
1548
+ `${JSON.stringify({ error: { message: err.message, exitCode: 2 } }, null, 2)}
1549
+ `
1550
+ );
1551
+ }
1552
+ return 2;
1553
+ }
1554
+ throw err;
1555
+ }
1556
+ if (invocation.command === "init") {
1557
+ return await runInitCommand(invocation);
1558
+ }
1559
+ if (invocation.command === "describe") {
1560
+ const contract = buildAgentContract();
1561
+ emit(invocation.format, contract, () => info(renderContractText(contract)));
1562
+ return 0;
1563
+ }
1564
+ if (invocation.command === "skill") {
1565
+ return runSkillCommand(invocation);
1566
+ }
1567
+ if (invocation.command === "setup") {
1568
+ return runSetupCommand(invocation);
1569
+ }
1570
+ const skipConfig = invocation.noConfig || invocation.command === "diff";
1571
+ const loaded = skipConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1572
+ if (invocation.noConfig) info("\u2699 --no-config: using built-in defaults, config file ignored");
1573
+ for (const warning of loaded.warnings) warn(`\u26A0 Config: ${warning}`);
1574
+ if (loaded.errors.length > 0) {
1575
+ warn("\u274C The config file could not be read. Fix it, or delete it to use the defaults.");
1576
+ return 1;
1577
+ }
1578
+ if (loaded.file) info(`\u2699 Using config ${path2.relative(process.cwd(), loaded.file) || loaded.file}`);
1579
+ const resolved = resolveConfig(
1580
+ loaded.config,
1581
+ {
1582
+ mode: invocation.command,
1583
+ format: invocation.format,
1584
+ dryRun: invocation.dryRun,
1585
+ cwd: invocation.cwd,
1586
+ outDir: invocation.outDir,
1587
+ includeMonolith: invocation.includeMonolith,
1588
+ includeSensitiveFiles: invocation.includeSensitiveFiles,
1589
+ maxPartBytes: invocation.maxPartBytes,
1590
+ largeFileBytes: invocation.largeFileBytes,
1591
+ maxMonolithBytes: invocation.maxMonolithBytes,
1592
+ skipArtifacts: invocation.skipArtifacts,
1593
+ discovery: invocation.discovery
1594
+ },
1595
+ invocation.cwd
1596
+ );
1597
+ if (invocation.command === "validate") {
1598
+ const result = validateSnapshot(resolved);
1599
+ if (result.valid) {
1600
+ const manifestPath = path2.resolve(resolved.cwd, resolved.outDir, "manifest.json");
1601
+ let manifest;
1602
+ try {
1603
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1604
+ } catch {
1605
+ manifest = void 0;
1606
+ }
1607
+ const shapeError = manifest === void 0 ? null : describeManifestShapeProblem(manifest);
1608
+ if (shapeError) {
1609
+ return fail(invocation.format, `${manifestPath} is not a snapshot manifest: ${shapeError}`);
1610
+ }
1611
+ }
1612
+ emit(invocation.format, result, () => renderValidateText(result));
1613
+ return result.valid ? 0 : 1;
1614
+ }
1615
+ if (invocation.command === "report") {
1616
+ const manifestPath = path2.resolve(resolved.cwd, resolved.outDir, "manifest.json");
1617
+ if (!fs.existsSync(manifestPath)) {
1618
+ return fail(invocation.format, `Manifest not found at ${manifestPath}. Run generate first.`);
1619
+ }
1620
+ let manifest;
1621
+ try {
1622
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1623
+ } catch (err) {
1624
+ return fail(invocation.format, `Could not read ${manifestPath}: ${err.message}`);
1625
+ }
1626
+ const shapeError = describeManifestShapeProblem(manifest);
1627
+ if (shapeError) {
1628
+ return fail(invocation.format, `${manifestPath} is not a snapshot manifest: ${shapeError}`);
1629
+ }
1630
+ emit(invocation.format, { manifest, validation: null }, () => {
1631
+ info(`
1632
+ \u{1F4CA} Codebase Snapshot Report (${resolved.cwd})`);
1633
+ info(` - Generated at: ${manifest.generatedAt}`);
1634
+ info(` - Schema: ${manifest.schemaVersion}`);
1635
+ info(` - Fingerprint: ${manifest.fingerprint}`);
1636
+ info(` - Files included: ${manifest.counts.totalFiles}`);
1637
+ info(` - Total lines: ${formatNumber(manifest.counts.totalLines)}`);
1638
+ info(
1639
+ ` - Total size: ${(manifest.counts.totalSizeBytes / (1024 * 1024)).toFixed(2)} MB`
1640
+ );
1641
+ info(` - Total parts: ${manifest.counts.partsCount}`);
1642
+ info(" - Integrity: not checked (run `codebase-snapshot validate`)");
1643
+ info(` - Warnings: ${manifest.warnings?.length ?? 0}`);
1644
+ if ((manifest.totals.redactedLines ?? 0) > 0) {
1645
+ info(` - Redacted: ${manifest.totals.redactedLines} line(s)`);
1646
+ }
1647
+ info("\n Layers:");
1648
+ for (const [layer, count] of Object.entries(manifest.layers)) {
1649
+ info(` - ${layer.padEnd(16)}: ${count} files`);
1650
+ }
1651
+ });
1652
+ return 0;
1653
+ }
1654
+ if (invocation.command === "explain") {
1655
+ const result = explainPath(resolved, invocation.args[0]);
1656
+ emit(invocation.format, result, () => info(renderExplainText(result)));
1657
+ return 0;
1658
+ }
1659
+ if (invocation.command === "diff") {
1660
+ try {
1661
+ const [beforePath, afterPath] = invocation.args;
1662
+ const diff = compareManifests(loadManifest(beforePath), loadManifest(afterPath));
1663
+ emit(invocation.format, diff, () => info(renderDiffText(diff)));
1664
+ return 0;
1665
+ } catch (err) {
1666
+ return fail(invocation.format, err.message);
1667
+ }
1668
+ }
1669
+ const started = performance.now();
1670
+ info(`\u{1F4F8} ${invocation.dryRun ? "Planning snapshot (dry run)" : "Generating snapshot"} for: ${resolved.cwd}`);
1671
+ info(
1672
+ ` Branch: ${resolved.branch} | Part budget: ${(resolved.maxPartBytes / 1024).toFixed(0)} KB`
1673
+ );
1674
+ try {
1675
+ const manifest = generateSnapshot(resolved, { dryRun: invocation.dryRun });
1676
+ const elapsed = ((performance.now() - started) / 1e3).toFixed(2);
1677
+ if (invocation.dryRun) {
1678
+ info("\n\u{1F9EA} Dry run \u2014 nothing was written:");
1679
+ info(` - Would track: ${manifest.counts.totalFiles} files`);
1680
+ info(
1681
+ ` - Would write: ${manifest.counts.partsCount} part(s) under ${displayOutPath(resolved.cwd, resolved.outDir, "parts")}`
1682
+ );
1683
+ info(` - Skipped: ${manifest.counts.skippedCount} file(s)`);
1684
+ info(` - Warnings: ${manifest.warnings.length}`);
1685
+ if ((manifest.totals.redactedLines ?? 0) > 0) {
1686
+ info(
1687
+ ` - Redacted: ${manifest.totals.redactedLines} line(s) \u2014 a real run lists them in ` + displayOutPath(resolved.cwd, resolved.outDir, "reports/audit.md")
1688
+ );
1689
+ }
1690
+ info(` - Integrity: ${manifest.validation.status}`);
1691
+ for (const error of manifest.validation.errors.slice(0, 10)) info(` - ${error}`);
1692
+ } else {
1693
+ info(`
1694
+ \u2705 Snapshot generated in ${elapsed}s:`);
1695
+ info(` - Tracked files: ${manifest.counts.totalFiles}`);
1696
+ info(` - Total lines: ${formatNumber(manifest.counts.totalLines)}`);
1697
+ info(
1698
+ ` - Total size: ${(manifest.counts.totalSizeBytes / (1024 * 1024)).toFixed(2)} MB`
1699
+ );
1700
+ const { artifacts } = resolved;
1701
+ if (artifacts.parts) {
1702
+ info(
1703
+ ` - Parts written: ${manifest.counts.partsCount} under ${displayOutPath(resolved.cwd, resolved.outDir, "parts")}`
1704
+ );
1705
+ }
1706
+ const reports = [
1707
+ artifacts.projectTree && "project-tree",
1708
+ artifacts.navigation && "navigation",
1709
+ artifacts.audit && "audit"
1710
+ ].filter(Boolean);
1711
+ if (reports.length > 0) {
1712
+ info(
1713
+ ` - Reports: ${displayOutPath(resolved.cwd, resolved.outDir, "reports")} (${reports.join(", ")})`
1714
+ );
1715
+ }
1716
+ if (artifacts.manifest) {
1717
+ info(` - Manifest: ${displayOutPath(resolved.cwd, resolved.outDir, "manifest.json")}`);
1718
+ }
1719
+ if (artifacts.monolith) {
1720
+ const monolithFiles = [
1721
+ manifest.artifacts?.fullJson ? "full-codebase.json" : null,
1722
+ manifest.artifacts?.fullMarkdown ? "full-codebase.md" : null
1723
+ ].filter((name) => name !== null);
1724
+ if (monolithFiles.length === 2) {
1725
+ info(
1726
+ ` - Monolith: ${displayOutPath(resolved.cwd, resolved.outDir, "full-codebase.{json,md}")}`
1727
+ );
1728
+ } else if (monolithFiles.length === 1) {
1729
+ info(
1730
+ ` - Monolith: ${displayOutPath(resolved.cwd, resolved.outDir, monolithFiles[0])} (the other was skipped by --max-monolith-bytes)`
1731
+ );
1732
+ }
1733
+ }
1734
+ if (reports.length === 0 && !artifacts.parts && !artifacts.manifest && !artifacts.monolith) {
1735
+ info(` - Artifacts: all disabled (only the ownership marker was written)`);
1736
+ }
1737
+ info(` - Fingerprint: ${manifest.fingerprint}`);
1738
+ info(` - Integrity: ${manifest.validation.status}`);
1739
+ if ((manifest.totals.redactedLines ?? 0) > 0) {
1740
+ const where = artifacts.audit ? `see ${displayOutPath(resolved.cwd, resolved.outDir, "reports/audit.md")}` : "reports/audit.md is disabled";
1741
+ info(` - Redacted: ${manifest.totals.redactedLines} line(s) \u2014 values masked; ${where}`);
1742
+ }
1743
+ if (manifest.warnings.length > 0) {
1744
+ info(` - Warnings: ${manifest.warnings.length}`);
1745
+ for (const warning of manifest.warnings.slice(0, 10)) info(` - ${warning}`);
1746
+ if (manifest.warnings.length > 10) {
1747
+ info(` \u2026 and ${manifest.warnings.length - 10} more in manifest.warnings[]`);
1748
+ }
1749
+ }
1750
+ }
1751
+ if (invocation.format === "json") {
1752
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}
1753
+ `);
1754
+ }
1755
+ if (invocation.watch) {
1756
+ return await runWatchLoop(invocation, resolved);
1757
+ }
1758
+ return manifest.validation.status === "passed" ? 0 : 1;
1759
+ } catch (err) {
1760
+ const code = err.code;
1761
+ if (code === "EPERM" || code === "EACCES") {
1762
+ return fail(
1763
+ invocation.format,
1764
+ `Cannot write the snapshot: permission denied under ${resolved.outDir}. Check the directory's permissions or choose another --out-dir. (${err.message})`
1765
+ );
1766
+ }
1767
+ return fail(invocation.format, `Failed to generate snapshot: ${err.message}`);
1768
+ }
1769
+ }
1770
+ var WATCH_DEBOUNCE_MS = 250;
1771
+ var WATCH_SETTLE_MS = 1500;
1772
+ var VCS_SEGMENTS = /* @__PURE__ */ new Set([".git", ".hg", ".svn", ".bzr"]);
1773
+ function initialWatchState() {
1774
+ return { running: false, pending: false, settleUntil: 0 };
1775
+ }
1776
+ function beginWatchRun(state) {
1777
+ return { ...state, running: true, pending: false };
1778
+ }
1779
+ function finishWatchRun(state, now) {
1780
+ return {
1781
+ state: { running: false, pending: false, settleUntil: now + WATCH_SETTLE_MS },
1782
+ schedule: state.pending
1783
+ };
1784
+ }
1785
+ function comparablePath(value) {
1786
+ const absolute = path2.resolve(value);
1787
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
1788
+ }
1789
+ function isSameOrInside(parent, candidate) {
1790
+ const relative3 = path2.relative(parent, candidate);
1791
+ return relative3 === "" || relative3 !== ".." && !relative3.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative3);
1792
+ }
1793
+ function isGeneratedWatchPath(candidate, paths) {
1794
+ const absolute = comparablePath(path2.resolve(paths.cwd, candidate));
1795
+ const outDir = comparablePath(path2.resolve(paths.cwd, paths.outDir));
1796
+ const tmpDir = comparablePath(path2.resolve(paths.cwd, paths.tmpDir));
1797
+ if (isSameOrInside(outDir, absolute) || isSameOrInside(tmpDir, absolute)) return true;
1798
+ const parent = path2.dirname(absolute);
1799
+ if (parent === path2.dirname(outDir) && path2.basename(absolute).startsWith(`${path2.basename(outDir)}.backup-`)) {
1800
+ return true;
1801
+ }
1802
+ return absolute.split(/[\\/]+/).some((segment) => VCS_SEGMENTS.has(segment));
1803
+ }
1804
+ function decideWatchEvent(state, event, paths) {
1805
+ if (event.path !== null) {
1806
+ if (isGeneratedWatchPath(event.path, paths)) return { state, action: "ignore" };
1807
+ } else if (state.running || event.now < state.settleUntil) {
1808
+ return { state, action: "ignore" };
1809
+ }
1810
+ if (state.running) {
1811
+ return { state: { ...state, pending: true }, action: "pending" };
1812
+ }
1813
+ return { state, action: "schedule" };
1814
+ }
1815
+ async function runWatchLoop(invocation, resolved) {
1816
+ const outDir = path2.resolve(resolved.cwd, resolved.outDir);
1817
+ const tmpDir = path2.resolve(resolved.cwd, resolved.tmpDir);
1818
+ const paths = { cwd: resolved.cwd, outDir, tmpDir };
1819
+ let state = initialWatchState();
1820
+ let timer = null;
1821
+ let stopped = false;
1822
+ const schedule = (delay = WATCH_DEBOUNCE_MS) => {
1823
+ if (stopped) return;
1824
+ if (timer) clearTimeout(timer);
1825
+ timer = setTimeout(() => {
1826
+ timer = null;
1827
+ void regenerate();
1828
+ }, delay);
1829
+ };
1830
+ const regenerate = async () => {
1831
+ if (state.running) {
1832
+ state = { ...state, pending: true };
1833
+ if (timer) {
1834
+ clearTimeout(timer);
1835
+ timer = null;
1836
+ }
1837
+ return;
1838
+ }
1839
+ state = beginWatchRun(state);
1840
+ const started = performance.now();
1841
+ try {
1842
+ const loaded = invocation.noConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1843
+ if (loaded.errors.length > 0) {
1844
+ for (const error of loaded.errors) warn(`\u26A0 Config: ${error}`);
1845
+ warn("\u274C The config file could not be read. Fix it, or delete it to use the defaults.");
1846
+ return;
1847
+ }
1848
+ for (const warning of loaded.warnings) warn(`\u26A0 Config: ${warning}`);
1849
+ const next = resolveConfig(
1850
+ loaded.config,
1851
+ {
1852
+ mode: invocation.command,
1853
+ format: invocation.format,
1854
+ dryRun: invocation.dryRun,
1855
+ cwd: invocation.cwd,
1856
+ outDir: invocation.outDir,
1857
+ includeMonolith: invocation.includeMonolith,
1858
+ includeSensitiveFiles: invocation.includeSensitiveFiles,
1859
+ maxPartBytes: invocation.maxPartBytes,
1860
+ largeFileBytes: invocation.largeFileBytes,
1861
+ maxMonolithBytes: invocation.maxMonolithBytes,
1862
+ skipArtifacts: invocation.skipArtifacts,
1863
+ discovery: invocation.discovery
1864
+ },
1865
+ invocation.cwd
1866
+ );
1867
+ const manifest = generateSnapshot(next, { dryRun: invocation.dryRun });
1868
+ const elapsed = ((performance.now() - started) / 1e3).toFixed(2);
1869
+ info(
1870
+ `\u267B\uFE0F ${(/* @__PURE__ */ new Date()).toISOString()} ${manifest.counts.totalFiles} file(s), ${manifest.counts.partsCount} part(s), ${manifest.validation.status} (${elapsed}s)`
1871
+ );
1872
+ } catch (err) {
1873
+ warn(`\u274C ${err.message}`);
1874
+ } finally {
1875
+ const finished = finishWatchRun(state, performance.now());
1876
+ state = finished.state;
1877
+ if (finished.schedule) schedule(0);
1878
+ }
1879
+ };
1880
+ info("\u{1F440} Watching for changes. Press Ctrl+C to stop.");
1881
+ void regenerate();
1882
+ const watcher = fs.watch(resolved.cwd, { recursive: true }, (_event, filename) => {
1883
+ const decision = decideWatchEvent(
1884
+ state,
1885
+ {
1886
+ path: filename ? path2.resolve(resolved.cwd, filename.toString()) : null,
1887
+ now: performance.now()
1888
+ },
1889
+ paths
1890
+ );
1891
+ state = decision.state;
1892
+ if (decision.action === "pending") {
1893
+ if (timer) {
1894
+ clearTimeout(timer);
1895
+ timer = null;
1896
+ }
1897
+ return;
1898
+ }
1899
+ if (decision.action === "schedule") schedule();
1900
+ });
1901
+ await new Promise((resolve2) => {
1902
+ const stop = () => {
1903
+ stopped = true;
1904
+ if (timer) clearTimeout(timer);
1905
+ watcher.close();
1906
+ resolve2();
1907
+ };
1908
+ process.once("SIGINT", stop);
1909
+ process.once("SIGTERM", stop);
1910
+ });
1911
+ return 0;
1912
+ }
1913
+ var entryFile = process.argv[1] ?? "";
1914
+ var entryName = path2.basename(entryFile).toLowerCase();
1915
+ var invokedDirectly = entryName === "cli.js" || entryName === "cli.cjs";
1916
+ if (invokedDirectly) {
1917
+ void main().then(
1918
+ (code) => {
1919
+ process.exitCode = code;
1920
+ },
1921
+ (err) => {
1922
+ process.stderr.write(`\u274C ${err.message}
1923
+ `);
1924
+ process.exitCode = 1;
1925
+ }
1926
+ );
1927
+ }
1928
+
1929
+ export { UsageError, WATCH_DEBOUNCE_MS, WATCH_SETTLE_MS, beginWatchRun, cleanInstallEnv, decideWatchEvent, finishWatchRun, initialWatchState, isGeneratedWatchPath, main, parseByteOption, parseCliArgs, parsePositiveInteger };
1930
+ //# sourceMappingURL=cli.js.map
1931
+ //# sourceMappingURL=cli.js.map