@ankurhalder/codebase-snapshot 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,8 +1,633 @@
1
- import { parseByteSize, GENERATOR_VERSION, ARTIFACT_NAMES, getPreset, unknownPresetMessage, MIN_MAX_PART_BYTES, loadConfigFileDetailed, resolveConfig, validateSnapshot, explainPath, compareManifests, loadManifest, renderDiffText, generateSnapshot, GENERATOR_NAME, runInit, PromptCancelled, renderExplainText } from './chunk-QEIOXPMU.js';
1
+ import { parseByteSize, GENERATOR_VERSION, ARTIFACT_NAMES, getPreset, unknownPresetMessage, MIN_MAX_PART_BYTES, loadConfigFileDetailed, resolveConfig, validateSnapshot, explainPath, compareManifests, loadManifest, renderDiffText, generateSnapshot, GENERATOR_NAME, runInit, PromptCancelled, SCHEMA_VERSION, PRESETS, renderExplainText } from './chunk-5I564BGX.js';
2
2
  import * as fs from 'fs';
3
- import * as path from 'path';
3
+ import * as path2 from 'path';
4
4
  import { parseArgs } from 'util';
5
5
 
6
+ // src/agent/contract.ts
7
+ var CONTRACT_VERSION = "1.0.0";
8
+ var PACKAGE_NAME = "@ankurhalder/codebase-snapshot";
9
+ var ARTIFACT_SUMMARY = {
10
+ readme: "README.md describing the snapshot at the output root.",
11
+ manifest: "manifest.json: machine-readable index of every file, part, hash and config.",
12
+ parts: "parts/*.md: the packed, budget-limited markdown parts that carry the code.",
13
+ navigation: "reports/navigation.md: links between parts and layers.",
14
+ projectTree: "reports/project-tree.md: the directory tree of included files.",
15
+ audit: "reports/audit.md: secret findings, large files, parse diagnostics, git state.",
16
+ monolith: "full-codebase.json and full-codebase.md: the whole repository in one file (off by default)."
17
+ };
18
+ var ARTIFACT_DEFAULTS = {
19
+ readme: true,
20
+ manifest: true,
21
+ parts: true,
22
+ navigation: true,
23
+ projectTree: true,
24
+ audit: true,
25
+ monolith: false
26
+ };
27
+ function renderContractText(contract) {
28
+ const lines = [];
29
+ lines.push(`${contract.package} v${contract.version}`);
30
+ lines.push(contract.summary);
31
+ lines.push("");
32
+ lines.push(`Node : ${contract.requires.node}`);
33
+ lines.push(`Schema : ${contract.schemaVersion} (contract ${contract.contractVersion})`);
34
+ lines.push(`One-shot : ${contract.invocation.oneShot} <command>`);
35
+ lines.push(`Installed : ${contract.invocation.installed} <command>`);
36
+ lines.push("");
37
+ lines.push("Workflow:");
38
+ for (const step of contract.workflow) {
39
+ lines.push(` ${step.step}. ${step.id}`);
40
+ lines.push(` run : ${step.run}`);
41
+ lines.push(` expect : ${step.expect}`);
42
+ if (step.notes) lines.push(` note : ${step.notes}`);
43
+ }
44
+ lines.push("");
45
+ lines.push("Commands:");
46
+ for (const command of contract.commands) {
47
+ const args = command.positionals.length > 0 ? ` ${command.positionals.join(" ")}` : "";
48
+ lines.push(` ${(command.name + args).padEnd(26)} ${command.summary}`);
49
+ }
50
+ lines.push("");
51
+ lines.push("Exit codes:");
52
+ for (const [code, meaning] of Object.entries(contract.exitCodes)) {
53
+ lines.push(` ${code} ${meaning}`);
54
+ }
55
+ lines.push("");
56
+ lines.push(`Presets (${contract.presets.length}): ${contract.presets.map((p) => p.name).join(", ")}`);
57
+ lines.push("");
58
+ lines.push("Artifacts:");
59
+ for (const artifact of contract.artifacts) {
60
+ lines.push(` ${artifact.name.padEnd(12)} ${artifact.default ? "on " : "off"} ${artifact.summary}`);
61
+ }
62
+ lines.push("");
63
+ lines.push("Options:");
64
+ for (const option of contract.options) {
65
+ const fallback = option.default === void 0 ? "" : ` [${JSON.stringify(option.default)}]`;
66
+ lines.push(` ${option.flag.padEnd(24)} ${option.appliesTo.padEnd(32)}${fallback}`);
67
+ lines.push(` ${"".padEnd(24)} ${option.summary}`);
68
+ }
69
+ lines.push("");
70
+ lines.push("Safety:");
71
+ for (const note of contract.safety) lines.push(` - ${note}`);
72
+ lines.push("");
73
+ lines.push("Troubleshooting:");
74
+ for (const item of contract.troubleshooting) {
75
+ lines.push(` ${item.symptom}`);
76
+ lines.push(` cause: ${item.cause}`);
77
+ lines.push(` fix : ${item.fix}`);
78
+ }
79
+ lines.push("");
80
+ lines.push("This is the human rendering. Agents should use: describe --format json");
81
+ return lines.join("\n");
82
+ }
83
+ function buildAgentContract() {
84
+ return {
85
+ generator: GENERATOR_NAME,
86
+ package: PACKAGE_NAME,
87
+ version: GENERATOR_VERSION,
88
+ schemaVersion: SCHEMA_VERSION,
89
+ contractVersion: CONTRACT_VERSION,
90
+ 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).",
91
+ requires: { node: ">=18.0.0", dependencies: ["typescript"] },
92
+ invocation: {
93
+ oneShot: `npx ${PACKAGE_NAME}@latest`,
94
+ installed: "npx snap",
95
+ bins: ["snap", "codebase-snapshot"],
96
+ notes: [
97
+ `Pin the tag (${PACKAGE_NAME}@latest or @2) instead of the bare package name.`,
98
+ "A bare scoped spec can resolve to a bin-less placeholder on some npm builds and exit 0 with no output; a pinned spec cannot.",
99
+ "Human progress goes to stderr. With --format json the payload on stdout is pure JSON."
100
+ ]
101
+ },
102
+ workflow: [
103
+ {
104
+ step: 1,
105
+ id: "verify",
106
+ run: `npx ${PACKAGE_NAME}@latest --version`,
107
+ expect: "prints v2.x.x on stdout and exits 0",
108
+ notes: "If it prints nothing, re-run with @latest explicitly, or use `npx -p <pkg> snap`."
109
+ },
110
+ {
111
+ step: 2,
112
+ id: "understand",
113
+ run: `npx ${PACKAGE_NAME}@latest describe --format json`,
114
+ expect: "the full contract as JSON: commands, options, presets, exit codes",
115
+ notes: "Read this before acting if you have no prior knowledge of the tool."
116
+ },
117
+ {
118
+ step: 3,
119
+ id: "configure",
120
+ run: `npx ${PACKAGE_NAME}@latest init --yes`,
121
+ expect: "writes snapshot.config.ts listing every setting at its current value",
122
+ notes: "Non-interactive. The detected preset is chosen automatically; override with --preset <name>. Skip this step to run on built-in defaults."
123
+ },
124
+ {
125
+ step: 4,
126
+ id: "generate",
127
+ run: `npx ${PACKAGE_NAME}@latest generate`,
128
+ expect: "exit 0, and ./snapshot/ written with parts, reports and manifest.json"
129
+ },
130
+ {
131
+ step: 5,
132
+ id: "verify-output",
133
+ run: `npx ${PACKAGE_NAME}@latest validate`,
134
+ expect: "exit 0 means the snapshot matches the files on disk",
135
+ notes: "Exit 1 means drift or a damaged snapshot; re-run generate."
136
+ },
137
+ {
138
+ step: 6,
139
+ id: "consume",
140
+ run: "read ./snapshot/parts/*.md, then ./snapshot/manifest.json, then ./snapshot/reports/audit.md",
141
+ expect: "the code, the index of what was included, and the risk findings",
142
+ 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."
143
+ }
144
+ ],
145
+ commands: [
146
+ {
147
+ name: "generate",
148
+ aliases: ["default"],
149
+ summary: "Write the snapshot. This is the command you normally want.",
150
+ positionals: [],
151
+ exits: [0, 1]
152
+ },
153
+ {
154
+ name: "validate",
155
+ aliases: ["check"],
156
+ summary: "Compare the snapshot on disk with the repository; exit 1 on drift.",
157
+ positionals: [],
158
+ exits: [0, 1]
159
+ },
160
+ {
161
+ name: "report",
162
+ aliases: [],
163
+ summary: "Print snapshot health, layer metrics and the secret audit.",
164
+ positionals: [],
165
+ exits: [0, 1]
166
+ },
167
+ {
168
+ name: "init",
169
+ aliases: [],
170
+ summary: "Write a commented snapshot.config.ts (interactive, or --yes for CI).",
171
+ positionals: [],
172
+ exits: [0, 1, 2]
173
+ },
174
+ {
175
+ name: "explain",
176
+ aliases: [],
177
+ summary: "Explain how one path would be treated: layer, kind, part, skip reason.",
178
+ positionals: ["<path>"],
179
+ exits: [0, 1]
180
+ },
181
+ {
182
+ name: "diff",
183
+ aliases: [],
184
+ summary: "Compare two snapshots (directories or manifest.json files).",
185
+ positionals: ["<before>", "<after>"],
186
+ exits: [0, 1]
187
+ },
188
+ {
189
+ name: "describe",
190
+ aliases: [],
191
+ summary: "Print this contract. --format json for machines, text for humans.",
192
+ positionals: [],
193
+ exits: [0]
194
+ },
195
+ {
196
+ name: "skill",
197
+ aliases: [],
198
+ summary: "Print the agent skill, or --install it into the repository (AGENTS.md, .claude/skills, .cursor/rules).",
199
+ positionals: [],
200
+ exits: [0, 1, 2]
201
+ }
202
+ ],
203
+ exitCodes: {
204
+ "0": "success (and validate: no drift)",
205
+ "1": "drift detected, or generation refused/failed (nothing was written)",
206
+ "2": "usage error: unknown flag/command, bad value, wrong argument count"
207
+ },
208
+ options: [
209
+ {
210
+ flag: "--out-dir",
211
+ type: "string",
212
+ default: "snapshot",
213
+ appliesTo: "generate/validate/report/explain",
214
+ summary: "Where the snapshot is written. Must not be the repo root or a directory the tool did not create."
215
+ },
216
+ {
217
+ flag: "--max-part-bytes",
218
+ type: "string",
219
+ default: 307200,
220
+ appliesTo: "generate/validate/report",
221
+ summary: 'Byte budget per markdown part. Accepts "120k" / "1.5mb". Minimum 4096.'
222
+ },
223
+ {
224
+ flag: "--large-file-bytes",
225
+ type: "string",
226
+ default: 204800,
227
+ appliesTo: "generate/validate/report",
228
+ summary: "Above this size a file gets a head/tail preview instead of the full body."
229
+ },
230
+ {
231
+ flag: "--max-monolith-bytes",
232
+ type: "string",
233
+ default: "unset",
234
+ appliesTo: "generate",
235
+ summary: "Skip the monolith artifacts when they would exceed this size."
236
+ },
237
+ {
238
+ flag: "--include-monolith",
239
+ type: "boolean",
240
+ default: false,
241
+ appliesTo: "generate",
242
+ summary: "Also write full-codebase.json and full-codebase.md."
243
+ },
244
+ {
245
+ flag: "--include-sensitive-files",
246
+ type: "boolean",
247
+ default: false,
248
+ appliesTo: "generate/validate/report",
249
+ summary: "Also emit .env / *.pem / .npmrc style files. Off by default; they are recorded as skipped."
250
+ },
251
+ {
252
+ flag: "--skip-artifacts",
253
+ type: "string",
254
+ default: "unset",
255
+ appliesTo: "generate",
256
+ summary: "Comma-separated artifacts to omit (readme, manifest, parts, navigation, projectTree, audit, monolith)."
257
+ },
258
+ {
259
+ flag: "--only-artifacts",
260
+ type: "string",
261
+ default: "unset",
262
+ appliesTo: "generate",
263
+ summary: "Write only these artifacts. Cannot be combined with --skip-artifacts."
264
+ },
265
+ {
266
+ flag: "--format",
267
+ type: "string",
268
+ default: "text",
269
+ appliesTo: "all commands",
270
+ summary: 'Output format: "text" or "json". JSON goes to stdout, progress to stderr.'
271
+ },
272
+ {
273
+ flag: "--cwd",
274
+ type: "string",
275
+ default: "process.cwd()",
276
+ appliesTo: "all commands",
277
+ summary: "Repository to operate on. Must be an existing directory."
278
+ },
279
+ {
280
+ flag: "--dry-run",
281
+ type: "boolean",
282
+ default: false,
283
+ appliesTo: "generate",
284
+ summary: "Compute everything and print the result without writing."
285
+ },
286
+ {
287
+ flag: "--watch",
288
+ type: "boolean",
289
+ default: false,
290
+ appliesTo: "generate",
291
+ summary: "Regenerate on every change until interrupted."
292
+ },
293
+ {
294
+ flag: "--preset",
295
+ type: "string",
296
+ default: "detected",
297
+ appliesTo: "init",
298
+ summary: "Force a project preset instead of detecting one. Unknown names are a usage error."
299
+ },
300
+ {
301
+ flag: "--include",
302
+ type: "string",
303
+ default: "unset",
304
+ appliesTo: "init",
305
+ summary: "Comma-separated allow-list of globs to record in the generated config."
306
+ },
307
+ {
308
+ flag: "--exclude",
309
+ type: "string",
310
+ default: "unset",
311
+ appliesTo: "init",
312
+ summary: "Comma-separated globs to record in the generated config."
313
+ },
314
+ {
315
+ flag: "--yes",
316
+ type: "boolean",
317
+ default: false,
318
+ appliesTo: "init",
319
+ summary: "Accept every default without prompting. Required in a non-TTY."
320
+ },
321
+ {
322
+ flag: "--minimal",
323
+ type: "boolean",
324
+ default: false,
325
+ appliesTo: "init",
326
+ summary: "Write only the settings that differ from the defaults."
327
+ },
328
+ {
329
+ flag: "--force",
330
+ type: "boolean",
331
+ default: false,
332
+ appliesTo: "init",
333
+ summary: "Overwrite an existing config file."
334
+ },
335
+ {
336
+ flag: "--install",
337
+ type: "boolean",
338
+ default: false,
339
+ appliesTo: "skill",
340
+ summary: "Write the skill into the repository instead of printing it."
341
+ },
342
+ {
343
+ flag: "--target",
344
+ type: "string",
345
+ default: "all",
346
+ appliesTo: "skill",
347
+ summary: 'Where to install: "claude", "agents", "cursor" or "all".'
348
+ }
349
+ ],
350
+ presets: PRESETS.map((preset) => ({
351
+ name: preset.name,
352
+ priority: preset.priority,
353
+ recommendedBudget: preset.recommendedBudget ?? "300k",
354
+ excludes: preset.exclude ? [...preset.exclude] : []
355
+ })),
356
+ artifacts: ARTIFACT_NAMES.map((name) => ({
357
+ name,
358
+ default: ARTIFACT_DEFAULTS[name] ?? true,
359
+ summary: ARTIFACT_SUMMARY[name] ?? name
360
+ })),
361
+ config: {
362
+ fileNames: [
363
+ "snapshot.config.ts",
364
+ "snapshot.config.js",
365
+ "snapshot.config.mjs",
366
+ "snapshot.config.cjs",
367
+ "snapshot.config.json",
368
+ ".snapshotrc",
369
+ ".snapshotrc.json",
370
+ 'package.json \u2192 "snapshot" key'
371
+ ],
372
+ settings: [
373
+ { key: "outDir", type: "string", default: "snapshot", summary: "Snapshot output directory." },
374
+ { key: "tmpDir", type: "string", default: "snapshot.tmp", summary: "Scratch directory used for the atomic swap." },
375
+ { key: "branch", type: "string", default: "main", summary: "Branch recorded when git cannot report one." },
376
+ { key: "preset", type: "string", default: "detected", summary: "Force a preset. Unknown names are an error." },
377
+ { key: "maxPartBytes", type: "number | string", default: 307200, summary: "Per-part byte budget." },
378
+ { key: "largeFileBytes", type: "number | string", default: 204800, summary: "Preview threshold for large files." },
379
+ { key: "previewHeadLines", type: "number", default: 80, summary: "Lines kept from the head of a large file." },
380
+ { key: "previewTailLines", type: "number", default: 40, summary: "Lines kept from the tail of a large file." },
381
+ { key: "includeMonolith", type: "boolean", default: false, summary: "Also write the single-file monolith." },
382
+ { key: "maxMonolithBytes", type: "number | string | null", default: null, summary: "Skip the monolith above this size." },
383
+ { key: "includeSensitiveFiles", type: "boolean", default: false, summary: "Emit .env / *.pem / credential files." },
384
+ { key: "include", type: "string[]", default: "[]", summary: "Allow-list of globs; empty means everything." },
385
+ { key: "excludePatterns", type: "string[]", default: "[]", summary: "Extra exclude globs." },
386
+ { key: "artifacts", type: "object", default: "all on except monolith", summary: "Per-artifact opt-out." },
387
+ { key: "budgets", type: "object", default: "{ default, byLayer }", summary: "Per-layer byte budgets." },
388
+ { key: "layersMode", type: '"merge" | "replace"', default: "merge", summary: "Whether custom layers extend or replace the preset's." },
389
+ { key: "layers", type: "array", default: "preset layers", summary: "Custom layer rules (glob or RegExp)." },
390
+ { key: "kinds", type: "array", default: "preset kinds", summary: "Custom kind rules." },
391
+ { key: "secrets", type: "object", default: "{}", summary: "Custom secret patterns, or disable the defaults." },
392
+ { key: "hooks", type: "object", default: "{}", summary: "afterGenerate(manifest) runs after a successful generate." }
393
+ ]
394
+ },
395
+ output: {
396
+ layout: [
397
+ "<outDir>/README.md",
398
+ "<outDir>/manifest.json",
399
+ "<outDir>/parts/part-NNN.md",
400
+ "<outDir>/reports/project-tree.md",
401
+ "<outDir>/reports/navigation.md",
402
+ "<outDir>/reports/audit.md",
403
+ "<outDir>/.codebase-snapshot (ownership marker, always written)"
404
+ ],
405
+ notes: [
406
+ "Parts are ordered and budget-limited; a single file larger than the budget becomes a head/tail preview.",
407
+ "manifest.json.files[] carries relativePath, layer, kind, language, part, inclusionMode, hash and a structural summary.",
408
+ "manifest.skipped[] carries the path and the exact reason it was excluded.",
409
+ `The output directory is replaced atomically; ${MIN_MAX_PART_BYTES} bytes is the smallest legal budget.`
410
+ ]
411
+ },
412
+ safety: [
413
+ "Sensitive files (.env, *.pem, .npmrc, .ssh/, .tfstate) are excluded by default and listed in manifest.skipped[].",
414
+ "Secret-shaped strings in ordinary source files are redacted in the emitted parts.",
415
+ "The tool refuses to replace an outDir that it did not create, so a mistyped outDir cannot delete source code.",
416
+ "Nothing is ever written above the repository root; --out-dir may not be the root or an ancestor."
417
+ ],
418
+ troubleshooting: [
419
+ {
420
+ symptom: "Command prints nothing and exits 0",
421
+ cause: "A bare scoped spec resolved to a bin-less placeholder manifest on this npm build.",
422
+ fix: `Use the pinned form: npx ${PACKAGE_NAME}@latest ...`
423
+ },
424
+ {
425
+ symptom: "Exit 1 with 'Refusing to replace outDir'",
426
+ cause: "The outDir exists and contains files this tool did not write.",
427
+ fix: "Choose a new/empty --out-dir, or delete the existing directory yourself."
428
+ },
429
+ {
430
+ symptom: "Exit 1 with 'The config file could not be read'",
431
+ cause: "snapshot.config.* exists but does not parse.",
432
+ fix: "Fix the syntax, or delete the config to use the defaults."
433
+ },
434
+ {
435
+ symptom: "Exit 1 from validate",
436
+ cause: "The snapshot no longer matches the files on disk.",
437
+ fix: "Re-run generate."
438
+ },
439
+ {
440
+ symptom: "A directory you expected is missing from the snapshot",
441
+ cause: "It is excluded by the preset, by default ignores, or by your own globs.",
442
+ fix: "Run `explain <path>`; manifest.skipped[] also records the reason for every file."
443
+ }
444
+ ]
445
+ };
446
+ }
447
+
448
+ // src/agent/skill.ts
449
+ var PACKAGE_NAME2 = "@ankurhalder/codebase-snapshot";
450
+ var SKILL_NAME = "codebase-snapshot";
451
+ var AGENTS_MARKER_START = "<!-- codebase-snapshot:skill:start -->";
452
+ var AGENTS_MARKER_END = "<!-- codebase-snapshot:skill:end -->";
453
+ function renderSkill() {
454
+ return `---
455
+ name: ${SKILL_NAME}
456
+ 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.
457
+ version: ${GENERATOR_VERSION}
458
+ ---
459
+
460
+ # codebase-snapshot
461
+
462
+ Turns a repository into an LLM-ready snapshot under \`./snapshot/\`: code packed into
463
+ size-budgeted markdown parts, plus a manifest that indexes every file, a project tree, a
464
+ navigation index and a secret audit.
465
+
466
+ Use it when you need to understand a whole repository, or produce repository context for
467
+ another model, and reading files one by one would not fit in context.
468
+
469
+ ## Before you start
470
+
471
+ This tool is a Node CLI. It needs **Node.js 18 or newer**. You do not need to install it
472
+ globally, and you do not need to clone anything.
473
+
474
+ **Always pin the tag** \u2014 \`npx ${PACKAGE_NAME2}@latest <command>\`. A bare scoped spec can
475
+ resolve to a placeholder manifest with no executable on some npm builds and exit 0
476
+ printing nothing; the pinned form cannot. If a command exits 0 with no output, that is
477
+ the cause, not a broken repository.
478
+
479
+ ## Step by step
480
+
481
+ 1. **Verify the tool.** Run \`npx ${PACKAGE_NAME2}@latest --version\`. Expect \`v${GENERATOR_VERSION}\` or
482
+ newer on stdout. Empty output means you hit the bare-spec problem above.
483
+
484
+ 2. **Read the contract, not the prose.** Run
485
+ \`npx ${PACKAGE_NAME2}@latest describe --format json\`. It returns the full machine
486
+ contract: every command, every option with its default, the exit-code meanings, the
487
+ 25 project presets, the artifact list, the config settings and a troubleshooting
488
+ table. Read it before you make decisions; do not guess option names.
489
+
490
+ 3. **Look before you leap (optional but cheap).** To see how a specific file or directory
491
+ would be treated, run \`npx ${PACKAGE_NAME2}@latest explain <path>\`. It reports the
492
+ layer, kind, language, target part, and the exact reason a path would be skipped.
493
+ \`--format json\` if you want to parse it.
494
+
495
+ 4. **Configure (recommended).** Run \`npx ${PACKAGE_NAME2}@latest init --yes\`. It detects
496
+ the project shape, then writes \`snapshot.config.ts\` listing **every** setting at its
497
+ current value, commented. Pass \`--preset <name>\` only if detection is wrong; the
498
+ preset names are in the contract. This step is optional: without a config the tool
499
+ runs on the same defaults.
500
+
501
+ 5. **Generate.** Run \`npx ${PACKAGE_NAME2}@latest generate\`. Expect exit 0 and a
502
+ \`./snapshot/\` directory. If you need a different location use \`--out-dir <path>\`; if
503
+ you need smaller or larger parts use \`--max-part-bytes 120k\`.
504
+
505
+ 6. **Verify.** Run \`npx ${PACKAGE_NAME2}@latest validate\`. Exit 0 means the snapshot
506
+ matches the files on disk. Exit 1 means drift \u2014 re-run step 5.
507
+
508
+ 7. **Read the output in this order:**
509
+ - \`snapshot/parts/part-*.md\` \u2014 the code itself, each file tagged with its layer, kind,
510
+ language and a structural summary (imports, exports, classes, functions).
511
+ - \`snapshot/manifest.json\` \u2014 the index. \`files[]\` has \`relativePath\`, \`layer\`,
512
+ \`kind\`, \`language\`, \`part\`, \`inclusionMode\`, \`hashSha256\` and \`summary\`.
513
+ \`skipped[]\` records every excluded file **and why**. Prefer querying this over
514
+ re-reading the parts.
515
+ - \`snapshot/reports/audit.md\` \u2014 secrets found, large files, parse diagnostics, git
516
+ state. Read this before you quote code back to a user.
517
+ - \`snapshot/reports/project-tree.md\` and \`navigation.md\` \u2014 structure and cross-links.
518
+
519
+ ## Project types
520
+
521
+ Detection is automatic and covers Next.js, Nuxt, SvelteKit, Remix, Astro, Angular, Solid,
522
+ Vue+React with Vite, Nest, Express/Fastify/Koa, any Node library, monorepos, Django,
523
+ FastAPI/Flask, plain Python, Go, Rust, Java/Spring, .NET, PHP/Laravel, Ruby/Rails,
524
+ Terraform/Docker infra, docs-only repositories, and a generic fallback. It sets sensible
525
+ layers, kinds, ecosystem ignore patterns and a recommended part budget. For non-TypeScript
526
+ languages it extracts imports, exports, classes and functions structurally; Vue, Svelte and
527
+ Astro script blocks go through the real TypeScript compiler. You do not need to configure
528
+ any of this to get a good first snapshot.
529
+
530
+ ## Things that will surprise you
531
+
532
+ - **Sensitive files are excluded by default** (\`.env\`, \`*.pem\`, \`.npmrc\`, \`.ssh/\`,
533
+ \`.tfstate\`) and are listed in \`manifest.skipped[]\`. Add \`--include-sensitive-files\` only
534
+ if the user asks for them explicitly.
535
+ - **Secret-shaped strings inside normal source files are redacted** in the emitted parts.
536
+ If the user needs the raw value, say so rather than working around it.
537
+ - **The output directory is owned by the tool.** It is replaced atomically, and the tool
538
+ refuses to replace a directory it did not create. Never point \`--out-dir\` at source
539
+ code; if you see \`Refusing to replace outDir\`, choose a new directory rather than
540
+ deleting anything.
541
+ - **Exit 2 is a usage error**, not a failure of the repository. Re-read the contract
542
+ before retrying.
543
+ - **\`--format json\` keeps stdout pure.** Progress goes to stderr, so \`stdout\` is safe to
544
+ pipe into a JSON parser.
545
+
546
+ ## Making this available to other agents
547
+
548
+ Run \`npx ${PACKAGE_NAME2}@latest skill --install\`. It writes this skill into the
549
+ repository in the places agents look for it by default: an \`AGENTS.md\` section,
550
+ \`.claude/skills/${SKILL_NAME}/SKILL.md\`, and \`.cursor/rules/${SKILL_NAME}.mdc\`. The command
551
+ is idempotent and only replaces its own marked section.
552
+ `;
553
+ }
554
+
555
+ // src/agent/install-skill.ts
556
+ var SKILL_TARGETS = ["claude", "agents", "cursor", "all"];
557
+ function targetPath(cwd, target) {
558
+ switch (target) {
559
+ case "claude":
560
+ return path2.join(cwd, ".claude", "skills", SKILL_NAME, "SKILL.md");
561
+ case "cursor":
562
+ return path2.join(cwd, ".cursor", "rules", `${SKILL_NAME}.mdc`);
563
+ case "agents":
564
+ return path2.join(cwd, "AGENTS.md");
565
+ }
566
+ }
567
+ function writeIfChanged(target, filePath, content) {
568
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
569
+ if (existing === content) return { target, path: filePath, action: "unchanged" };
570
+ fs.mkdirSync(path2.dirname(filePath), { recursive: true });
571
+ fs.writeFileSync(filePath, content, "utf8");
572
+ return { target, path: filePath, action: existing === null ? "created" : "updated" };
573
+ }
574
+ function agentsBlock(skill) {
575
+ return [
576
+ AGENTS_MARKER_START,
577
+ "",
578
+ `## ${SKILL_NAME} (${"@ankurhalder/codebase-snapshot"})`,
579
+ "",
580
+ // Drop the YAML frontmatter: inside AGENTS.md it is noise, and the heading
581
+ // above already names the skill.
582
+ skill.replace(/^---[\s\S]*?---\r?\n/, "").trim(),
583
+ "",
584
+ AGENTS_MARKER_END,
585
+ ""
586
+ ].join("\n");
587
+ }
588
+ function mergeAgentsFile(cwd, skill) {
589
+ const filePath = targetPath(cwd, "agents");
590
+ const block = agentsBlock(skill);
591
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
592
+ let next;
593
+ if (existing === null) {
594
+ next = `# AGENTS.md
595
+
596
+ ${block}`;
597
+ } else if (existing.includes(AGENTS_MARKER_START) && existing.includes(AGENTS_MARKER_END)) {
598
+ const start = existing.indexOf(AGENTS_MARKER_START);
599
+ const end = existing.indexOf(AGENTS_MARKER_END) + AGENTS_MARKER_END.length;
600
+ next = `${existing.slice(0, start)}${block.trimEnd()}${existing.slice(end)}`;
601
+ } else {
602
+ next = `${existing.trimEnd()}
603
+
604
+ ${block}`;
605
+ }
606
+ const action = existing === null ? "created" : existing === next ? "unchanged" : "updated";
607
+ if (action !== "unchanged") {
608
+ fs.mkdirSync(path2.dirname(filePath), { recursive: true });
609
+ fs.writeFileSync(filePath, next, "utf8");
610
+ }
611
+ return { target: "agents", path: filePath, action };
612
+ }
613
+ function installSkill(cwd, target = "all") {
614
+ const skill = renderSkill();
615
+ const written = [];
616
+ const wanted = target === "all" ? ["agents", "claude", "cursor"] : [target];
617
+ for (const item of wanted) {
618
+ if (item === "agents") {
619
+ written.push(mergeAgentsFile(cwd, skill));
620
+ continue;
621
+ }
622
+ written.push(writeIfChanged(item, targetPath(cwd, item), skill));
623
+ }
624
+ return { cwd, written };
625
+ }
626
+ function isSkillTarget(value) {
627
+ return SKILL_TARGETS.includes(value);
628
+ }
629
+
630
+ // src/cli.ts
6
631
  var VERSION = GENERATOR_VERSION;
7
632
  var COMMAND_ALIASES = /* @__PURE__ */ new Map([
8
633
  ["generate", "generate"],
@@ -11,7 +636,9 @@ var COMMAND_ALIASES = /* @__PURE__ */ new Map([
11
636
  ["report", "report"],
12
637
  ["init", "init"],
13
638
  ["explain", "explain"],
14
- ["diff", "diff"]
639
+ ["diff", "diff"],
640
+ ["describe", "describe"],
641
+ ["skill", "skill"]
15
642
  ]);
16
643
  var COMMAND_ARITY = {
17
644
  generate: 0,
@@ -19,7 +646,9 @@ var COMMAND_ARITY = {
19
646
  report: 0,
20
647
  init: 0,
21
648
  explain: 1,
22
- diff: 2
649
+ diff: 2,
650
+ describe: 0,
651
+ skill: 0
23
652
  };
24
653
  var CLI_OPTIONS = {
25
654
  cwd: { type: "string" },
@@ -41,6 +670,8 @@ var CLI_OPTIONS = {
41
670
  yes: { type: "boolean" },
42
671
  minimal: { type: "boolean" },
43
672
  force: { type: "boolean" },
673
+ install: { type: "boolean" },
674
+ target: { type: "string" },
44
675
  help: { type: "boolean", short: "h" },
45
676
  version: { type: "boolean", short: "v" }
46
677
  };
@@ -61,6 +692,8 @@ Commands:
61
692
  report Print codebase health, layer metrics, and secret audit
62
693
  explain <path> Show how one path would be treated: layer, kind, part, skips
63
694
  diff <a> <b> Compare two snapshots (directories or manifest.json files)
695
+ describe Print the machine-readable contract (--format json for agents)
696
+ skill Print the bundled agent skill; --install writes it into the repo
64
697
 
65
698
  Options:
66
699
  --cwd <path> Target repository directory (default: current directory)
@@ -86,11 +719,17 @@ Options:
86
719
  --minimal Write only the settings that differ from defaults
87
720
  --yes Accept every default without prompting
88
721
  --force Overwrite an existing config
722
+
723
+ skill only:
724
+ --install Write the skill into the repository instead of printing
725
+ --target <name> claude | agents | cursor | all (default: all)
726
+
89
727
  -h, --help Display this help message
90
728
  -v, --version Display version
91
729
 
92
730
  All options also accept the --option=value form.
93
731
  Human-readable progress is written to stderr; payloads (--format json) go to stdout.
732
+ Agents with no prior knowledge of this tool should start with: describe --format json
94
733
  `.trim();
95
734
  function parsePositiveInteger(raw, optionName, minimum) {
96
735
  if (raw === void 0) return void 0;
@@ -142,9 +781,32 @@ function parseCliArgs(argv) {
142
781
  strict: true
143
782
  });
144
783
  } catch (err) {
145
- throw new UsageError(err.message);
784
+ const message = err.message;
785
+ const ambiguous = /^Option '(--[^']+)' argument is ambiguous/i.exec(message);
786
+ if (ambiguous) {
787
+ throw new UsageError(
788
+ `${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.`
789
+ );
790
+ }
791
+ throw new UsageError(message);
146
792
  }
147
793
  const values = parsed.values;
794
+ for (const [name, spec] of Object.entries(CLI_OPTIONS)) {
795
+ if (spec.type !== "string") continue;
796
+ const raw = values[name];
797
+ if (raw !== void 0 && String(raw).trim() === "") {
798
+ throw new UsageError(`--${name} requires a value (received an empty string).`);
799
+ }
800
+ }
801
+ if (values.cwd !== void 0) {
802
+ const target = String(values.cwd);
803
+ if (!fs.existsSync(target)) {
804
+ throw new UsageError(`--cwd directory does not exist: ${target}`);
805
+ }
806
+ if (!fs.statSync(target).isDirectory()) {
807
+ throw new UsageError(`--cwd is not a directory: ${target}`);
808
+ }
809
+ }
148
810
  if (values.help) {
149
811
  process.stdout.write(`${USAGE}
150
812
  `);
@@ -228,6 +890,14 @@ function parseCliArgs(argv) {
228
890
  const requested = String(values.preset);
229
891
  if (!getPreset(requested)) throw new UsageError(unknownPresetMessage(requested));
230
892
  }
893
+ if (command !== "skill" && (values.install !== void 0 || values.target !== void 0)) {
894
+ throw new UsageError("--install and --target are only valid with `skill`.");
895
+ }
896
+ if (values.target !== void 0 && !isSkillTarget(String(values.target))) {
897
+ throw new UsageError(
898
+ `--target must be one of: ${SKILL_TARGETS.join(", ")} (received "${String(values.target)}").`
899
+ );
900
+ }
231
901
  return {
232
902
  command,
233
903
  args,
@@ -241,7 +911,9 @@ function parseCliArgs(argv) {
241
911
  yes: values.yes === true,
242
912
  minimal: values.minimal === true,
243
913
  force: values.force === true,
244
- cwd: values.cwd === void 0 ? process.cwd() : path.resolve(String(values.cwd)),
914
+ install: values.install === true,
915
+ target: values.target === void 0 ? void 0 : String(values.target),
916
+ cwd: values.cwd === void 0 ? process.cwd() : path2.resolve(String(values.cwd)),
245
917
  outDir: values["out-dir"] === void 0 ? void 0 : String(values["out-dir"]),
246
918
  includeMonolith: values["include-monolith"] === true ? true : void 0,
247
919
  includeSensitiveFiles: values["include-sensitive-files"] === true ? true : void 0,
@@ -323,7 +995,7 @@ async function runInitCommand(invocation) {
323
995
  info(result.configText);
324
996
  return 0;
325
997
  }
326
- info(`\u2705 Created ${path.basename(result.configPath)} (preset: ${result.preset})`);
998
+ info(`\u2705 Created ${path2.basename(result.configPath)} (preset: ${result.preset})`);
327
999
  if (result.scripts.length > 0) info(` Added npm scripts: ${result.scripts.join(", ")}`);
328
1000
  if (result.gitignoreEntries.length > 0) {
329
1001
  info(` Added to .gitignore: ${result.gitignoreEntries.join(", ")}`);
@@ -331,6 +1003,9 @@ async function runInitCommand(invocation) {
331
1003
  for (const note of result.notes) info(` \u26A0 ${note}`);
332
1004
  info("");
333
1005
  info(" Next: npm run snapshot");
1006
+ info(
1007
+ ` Tip: \`${GENERATOR_NAME} skill --install\` makes this repo discoverable by AI agents.`
1008
+ );
334
1009
  return 0;
335
1010
  } catch (err) {
336
1011
  if (err instanceof PromptCancelled) {
@@ -341,6 +1016,31 @@ async function runInitCommand(invocation) {
341
1016
  return 1;
342
1017
  }
343
1018
  }
1019
+ function runSkillCommand(invocation) {
1020
+ if (!invocation.install) {
1021
+ process.stdout.write(renderSkill());
1022
+ return 0;
1023
+ }
1024
+ try {
1025
+ const result = installSkill(invocation.cwd, invocation.target);
1026
+ if (invocation.format === "json") {
1027
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1028
+ `);
1029
+ return 0;
1030
+ }
1031
+ info(`\u2705 Agent skill installed (${SKILL_NAME}):`);
1032
+ for (const item of result.written) {
1033
+ const shown = path2.relative(invocation.cwd, item.path) || item.path;
1034
+ info(` - ${item.action.padEnd(9)} ${shown}`);
1035
+ }
1036
+ info("");
1037
+ info(" Agents that read AGENTS.md, .claude/skills or .cursor/rules will pick this up automatically.");
1038
+ return 0;
1039
+ } catch (err) {
1040
+ warn(`\u274C Could not install the skill: ${err.message}`);
1041
+ return 1;
1042
+ }
1043
+ }
344
1044
  function renderValidateText(result) {
345
1045
  if (result.valid) {
346
1046
  info("\u2705 Snapshot is valid and up to date with disk.");
@@ -362,6 +1062,9 @@ function renderValidateText(result) {
362
1062
  for (const missing of result.drift.missingOnDisk.slice(0, 20)) {
363
1063
  warn(` - deleted: ${missing}`);
364
1064
  }
1065
+ for (const mismatch of result.drift.totalsMismatches.slice(0, 20)) {
1066
+ warn(` - counts: ${mismatch}`);
1067
+ }
365
1068
  }
366
1069
  for (const warning of result.warnings) warn(` \u26A0 ${warning}`);
367
1070
  }
@@ -383,13 +1086,21 @@ ${USAGE}
383
1086
  if (invocation.command === "init") {
384
1087
  return await runInitCommand(invocation);
385
1088
  }
1089
+ if (invocation.command === "describe") {
1090
+ const contract = buildAgentContract();
1091
+ emit(invocation.format, contract, () => info(renderContractText(contract)));
1092
+ return 0;
1093
+ }
1094
+ if (invocation.command === "skill") {
1095
+ return runSkillCommand(invocation);
1096
+ }
386
1097
  const loaded = await loadConfigFileDetailed(invocation.cwd);
387
1098
  for (const warning of loaded.warnings) warn(`\u26A0 Config: ${warning}`);
388
1099
  if (loaded.errors.length > 0) {
389
1100
  warn("\u274C The config file could not be read. Fix it, or delete it to use the defaults.");
390
1101
  return 1;
391
1102
  }
392
- if (loaded.file) info(`\u2699 Using config ${path.relative(process.cwd(), loaded.file) || loaded.file}`);
1103
+ if (loaded.file) info(`\u2699 Using config ${path2.relative(process.cwd(), loaded.file) || loaded.file}`);
393
1104
  const resolved = resolveConfig(
394
1105
  loaded.config,
395
1106
  {
@@ -413,7 +1124,7 @@ ${USAGE}
413
1124
  return result.valid ? 0 : 1;
414
1125
  }
415
1126
  if (invocation.command === "report") {
416
- const manifestPath = path.resolve(resolved.cwd, resolved.outDir, "manifest.json");
1127
+ const manifestPath = path2.resolve(resolved.cwd, resolved.outDir, "manifest.json");
417
1128
  if (!fs.existsSync(manifestPath)) {
418
1129
  warn(`\u274C Manifest not found at ${manifestPath}. Run generate first.`);
419
1130
  return 1;
@@ -492,9 +1203,23 @@ ${USAGE}
492
1203
  info(
493
1204
  ` - Total size: ${(manifest.counts.totalSizeBytes / (1024 * 1024)).toFixed(2)} MB`
494
1205
  );
495
- info(` - Parts written: ${manifest.counts.partsCount} under ${resolved.outDir}/parts/`);
496
- info(` - Reports: ${resolved.outDir}/reports/ (project-tree, navigation, audit)`);
497
- info(` - Manifest: ${resolved.outDir}/manifest.json`);
1206
+ const { artifacts } = resolved;
1207
+ if (artifacts.parts) {
1208
+ info(` - Parts written: ${manifest.counts.partsCount} under ${resolved.outDir}/parts/`);
1209
+ }
1210
+ const reports = [
1211
+ artifacts.projectTree && "project-tree",
1212
+ artifacts.navigation && "navigation",
1213
+ artifacts.audit && "audit"
1214
+ ].filter(Boolean);
1215
+ if (reports.length > 0) {
1216
+ info(` - Reports: ${resolved.outDir}/reports/ (${reports.join(", ")})`);
1217
+ }
1218
+ if (artifacts.manifest) info(` - Manifest: ${resolved.outDir}/manifest.json`);
1219
+ if (artifacts.monolith) info(` - Monolith: ${resolved.outDir}/full-codebase.{json,md}`);
1220
+ if (reports.length === 0 && !artifacts.parts && !artifacts.manifest && !artifacts.monolith) {
1221
+ info(` - Artifacts: all disabled (only the ownership marker was written)`);
1222
+ }
498
1223
  info(` - Fingerprint: ${manifest.fingerprint}`);
499
1224
  info(` - Integrity: ${manifest.validation.status}`);
500
1225
  if (manifest.warnings.length > 0) {
@@ -511,15 +1236,23 @@ ${USAGE}
511
1236
  }
512
1237
  return manifest.validation.status === "passed" ? 0 : 1;
513
1238
  } catch (err) {
1239
+ const code = err.code;
1240
+ if (code === "EPERM" || code === "EACCES") {
1241
+ warn(
1242
+ `\u274C Cannot write the snapshot: permission denied under ${resolved.outDir}. Check the directory's permissions or choose another --out-dir.`
1243
+ );
1244
+ warn(` (${err.message})`);
1245
+ return 1;
1246
+ }
514
1247
  warn(`\u274C Failed to generate snapshot: ${err.message}`);
515
1248
  return 1;
516
1249
  }
517
1250
  }
518
1251
  async function runWatchLoop(invocation, resolved) {
519
- const outDir = path.resolve(resolved.cwd, resolved.outDir);
520
- const tmpDir = path.resolve(resolved.cwd, resolved.tmpDir);
521
- const outName = path.basename(outDir).toLowerCase();
522
- const tmpName = path.basename(tmpDir).toLowerCase();
1252
+ const outDir = path2.resolve(resolved.cwd, resolved.outDir);
1253
+ const tmpDir = path2.resolve(resolved.cwd, resolved.tmpDir);
1254
+ const outName = path2.basename(outDir).toLowerCase();
1255
+ const tmpName = path2.basename(tmpDir).toLowerCase();
523
1256
  const isGenerated = (candidate) => candidate.toLowerCase().split(/[\\/]/).some(
524
1257
  (segment) => segment === outName || segment === tmpName || segment.startsWith(`${outName}.backup-`)
525
1258
  );
@@ -545,7 +1278,7 @@ async function runWatchLoop(invocation, resolved) {
545
1278
  let timer = null;
546
1279
  const watcher = fs.watch(resolved.cwd, { recursive: true }, (_event, filename) => {
547
1280
  if (filename) {
548
- const changed = path.resolve(resolved.cwd, filename.toString());
1281
+ const changed = path2.resolve(resolved.cwd, filename.toString());
549
1282
  if (isGenerated(changed)) return;
550
1283
  }
551
1284
  if (timer) clearTimeout(timer);