@ankurhalder/codebase-snapshot 4.0.6 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadConfigFileDetailed, resolveConfig, validateSnapshot, explainPath, compareManifests, loadManifest, renderDiffText, generateSnapshot, GENERATOR_VERSION, ARTIFACT_NAMES, getPreset, unknownPresetMessage, GENERATOR_NAME, runInit, PromptCancelled, MIN_MAX_PART_BYTES, SCHEMA_VERSION, PRESETS, parseByteSize, renderExplainText } from './chunk-GAY7W5MK.js';
1
+ import { GENERATOR_VERSION, loadConfigFileDetailed, resolveConfig, validateSnapshot, explainPath, compareManifests, loadManifest, renderDiffText, generateSnapshot, isDiscoveryMode, ARTIFACT_NAMES, getPreset, unknownPresetMessage, GENERATOR_NAME, runInit, PromptCancelled, MIN_MAX_PART_BYTES, SCHEMA_VERSION, PRESETS, parseByteSize, escapeControls, renderExplainText } from './chunk-6SMZR5EM.js';
2
2
  import * as fs from 'fs';
3
3
  import * as path2 from 'path';
4
4
  import { parseArgs } from 'util';
@@ -16,6 +16,7 @@ var COMMAND_OPTION_SCOPE = {
16
16
  "skip-artifacts",
17
17
  "only-artifacts",
18
18
  "no-config",
19
+ "discovery",
19
20
  "format",
20
21
  "mode",
21
22
  "dry-run",
@@ -34,6 +35,7 @@ var COMMAND_OPTION_SCOPE = {
34
35
  "skip-artifacts",
35
36
  "only-artifacts",
36
37
  "no-config",
38
+ "discovery",
37
39
  "format",
38
40
  "mode",
39
41
  "help",
@@ -66,12 +68,13 @@ var COMMAND_OPTION_SCOPE = {
66
68
  "skip-artifacts",
67
69
  "only-artifacts",
68
70
  "no-config",
71
+ "discovery",
69
72
  "format",
70
73
  "mode",
71
74
  "help",
72
75
  "version"
73
76
  ],
74
- diff: ["format", "mode", "help", "version"],
77
+ diff: ["format", "mode", "no-config", "help", "version"],
75
78
  describe: ["format", "mode", "help", "version"],
76
79
  init: [
77
80
  "cwd",
@@ -116,6 +119,7 @@ function optionAppliesTo(option) {
116
119
  // src/agent/contract.ts
117
120
  var CONTRACT_VERSION = "1.0.0";
118
121
  var PACKAGE_NAME = "@ankurhalder/codebase-snapshot";
122
+ var MAJOR_VERSION = GENERATOR_VERSION.split(".")[0];
119
123
  var ARTIFACT_SUMMARY = {
120
124
  readme: "README.md describing the snapshot at the output root.",
121
125
  manifest: "manifest.json: machine-readable index of every file, part, hash and config.",
@@ -215,8 +219,11 @@ function buildAgentContract() {
215
219
  step: 1,
216
220
  id: "verify",
217
221
  run: `npx ${PACKAGE_NAME}@latest --version`,
218
- expect: `prints v${GENERATOR_VERSION} on stdout and exits 0`,
219
- notes: "If it prints nothing, clear the npm cache and retry, or use `npx -p <pkg> codebase-snapshot`."
222
+ // The requirement is the major version; the exact build is context. A
223
+ // floating tag may print 4.0.7 today and 4.0.9 next week, and an agent
224
+ // must not conclude that a working tool is broken because of a patch.
225
+ 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)`,
226
+ 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\`.`
220
227
  },
221
228
  {
222
229
  step: 2,
@@ -229,7 +236,7 @@ function buildAgentContract() {
229
236
  step: 3,
230
237
  id: "configure",
231
238
  run: `npx ${PACKAGE_NAME}@latest init --yes`,
232
- expect: "writes snapshot.config.ts listing every setting at its current value",
239
+ 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',
233
240
  notes: "Non-interactive. The detected preset is chosen automatically; override with --preset <name>. Skip this step to run on built-in defaults."
234
241
  },
235
242
  {
@@ -278,7 +285,7 @@ function buildAgentContract() {
278
285
  {
279
286
  name: "init",
280
287
  aliases: [],
281
- summary: "Write a commented snapshot.config.ts (interactive, or --yes for CI).",
288
+ 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). Interactive, or --yes for CI.",
282
289
  positionals: [],
283
290
  exits: [0, 1, 2]
284
291
  },
@@ -359,6 +366,13 @@ function buildAgentContract() {
359
366
  appliesTo: optionAppliesTo("include-sensitive-files"),
360
367
  summary: "Also emit .env / *.pem / .npmrc style files. Off by default; they are recorded as skipped."
361
368
  },
369
+ {
370
+ flag: "--discovery",
371
+ type: "string",
372
+ default: "auto",
373
+ appliesTo: optionAppliesTo("discovery"),
374
+ 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)."
375
+ },
362
376
  {
363
377
  flag: "--skip-artifacts",
364
378
  type: "string",
@@ -501,6 +515,7 @@ function buildAgentContract() {
501
515
  { key: "includeSensitiveFiles", type: "boolean", default: false, summary: "Emit .env / *.pem / credential files." },
502
516
  { key: "include", type: "string[]", default: "[]", summary: "Allow-list of globs; empty means everything." },
503
517
  { key: "excludePatterns", type: "string[]", default: "[]", summary: "Extra exclude globs." },
518
+ { 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." },
504
519
  { key: "artifacts", type: "object", default: "all on except monolith", summary: "Per-artifact opt-out." },
505
520
  { key: "budgets", type: "object", default: "{ default, byLayer }", summary: "Per-layer byte budgets." },
506
521
  { key: "layersMode", type: '"merge" | "replace"', default: "merge", summary: "Whether custom layers extend or replace the preset's." },
@@ -522,7 +537,7 @@ function buildAgentContract() {
522
537
  ],
523
538
  notes: [
524
539
  "Parts are ordered and budget-limited; a single file larger than the budget becomes a head/tail preview.",
525
- "manifest.json.files[] carries relativePath, layer, kind, language, part, inclusionMode, hash and a structural summary.",
540
+ "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.",
526
541
  "manifest.skipped[] carries the path and the exact reason it was excluded.",
527
542
  `The output directory is replaced atomically; ${MIN_MAX_PART_BYTES} bytes is the smallest legal budget.`
528
543
  ]
@@ -565,6 +580,7 @@ function buildAgentContract() {
565
580
 
566
581
  // src/agent/skill.ts
567
582
  var PACKAGE_NAME2 = "@ankurhalder/codebase-snapshot";
583
+ var MAJOR_VERSION2 = GENERATOR_VERSION.split(".")[0];
568
584
  var SKILL_NAME = "codebase-snapshot";
569
585
  var AGENTS_MARKER_START = "<!-- codebase-snapshot:skill:start -->";
570
586
  var AGENTS_MARKER_END = "<!-- codebase-snapshot:skill:end -->";
@@ -589,15 +605,20 @@ another model, and reading files one by one would not fit in context.
589
605
  This tool is a Node CLI. It needs **Node.js 18 or newer**. You do not need to install it
590
606
  globally, and you do not need to clone anything.
591
607
 
592
- **Pin the tag** \u2014 \`npx ${PACKAGE_NAME2}@latest <command>\` \u2014 when you need the same version on
593
- every machine. The bare package name works too: this package ships exactly one executable,
594
- which is what lets npm pick it. If a command exits 0 printing nothing, the executable was
595
- not resolved \u2014 clear the npm cache and retry; do not assume the repository is broken.
608
+ **Pin a version** \u2014 \`npx ${PACKAGE_NAME2}@${MAJOR_VERSION2} <command>\` (or an exact release) \u2014 when you
609
+ need the same version on every machine. The bare package name works too: this package ships
610
+ one executable file, published under two names (\`codebase-snapshot\` and \`snap\`), which is what
611
+ lets npm pick it. If a command exits 0 printing nothing,
612
+ the executable was not resolved \u2014 clear the npm cache and retry; do not assume the repository
613
+ is broken.
596
614
 
597
615
  ## Step by step
598
616
 
599
- 1. **Verify the tool.** Run \`npx ${PACKAGE_NAME2}@latest --version\`. Expect \`v${GENERATOR_VERSION}\` or
600
- newer on stdout. Empty output means you hit the bare-spec problem above.
617
+ 1. **Verify the tool.** Run \`npx ${PACKAGE_NAME2}@latest --version\`. Expect \`v${MAJOR_VERSION2}\` or
618
+ newer on stdout \u2014 compare the major version, not the patch, because the
619
+ \`@latest\` tag can lag this release. If the printed major is older, pin the
620
+ major (\`@${MAJOR_VERSION2}\`) instead of retrying. Empty output means you hit the
621
+ bare-spec problem above.
601
622
 
602
623
  2. **Read the contract, not the prose.** Run
603
624
  \`npx ${PACKAGE_NAME2}@latest describe --format json\`. It returns the full machine
@@ -611,11 +632,12 @@ not resolved \u2014 clear the npm cache and retry; do not assume the repository
611
632
  \`--format json\` if you want to parse it.
612
633
 
613
634
  4. **Configure (recommended).** Run \`npx ${PACKAGE_NAME2}@latest init --yes\`. It detects
614
- the project shape, then writes \`snapshot.config.ts\` (or \`.mjs\` when the project is
615
- not an ES module) listing **every** setting at its
616
- current value, commented. Pass \`--preset <name>\` only if detection is wrong; the
617
- preset names are in the contract. This step is optional: without a config the tool
618
- runs on the same defaults.
635
+ the project shape, then writes a commented config file listing **every** setting at its
636
+ current value. The filename depends on the project: \`snapshot.config.ts\` only when
637
+ the running Node can strip types (Node >= 22.6) **and** the project is an ES module
638
+ (\`"type": "module"\` in package.json); otherwise \`snapshot.config.mjs\`. Pass
639
+ \`--preset <name>\` only if detection is wrong; the preset names are in the contract.
640
+ This step is optional: without a config the tool runs on the same defaults.
619
641
 
620
642
  5. **Generate.** Run \`npx ${PACKAGE_NAME2}@latest generate\`. Expect exit 0 and a
621
643
  \`./snapshot/\` directory. If you need a different location use \`--out-dir <path>\`; if
@@ -626,9 +648,13 @@ not resolved \u2014 clear the npm cache and retry; do not assume the repository
626
648
 
627
649
  7. **Read the output in this order:**
628
650
  - \`snapshot/parts/part-*.md\` \u2014 the code itself, each file tagged with its layer, kind,
629
- language and a structural summary (imports, exports, classes, functions).
651
+ language and, when it fits the budget, a structural summary (imports, exports,
652
+ classes, functions). A dropped summary appears as a \`Summary: (omitted \u2026)\` line.
630
653
  - \`snapshot/manifest.json\` \u2014 the index. \`files[]\` has \`relativePath\`, \`layer\`,
631
- \`kind\`, \`language\`, \`part\`, \`inclusionMode\`, \`hashSha256\` and \`summary\`.
654
+ \`kind\`, \`language\`, \`part\`, \`inclusionMode\` and \`hashSha256\`, plus \`encoding\`
655
+ when the file was not plain UTF-8. \`summary\` is **optional**: it is absent for
656
+ languages with no structural summary, and \`summaryOmitted: true\` marks one dropped
657
+ to fit the part budget \u2014 never assume it exists.
632
658
  \`skipped[]\` records every excluded file **and why**. Prefer querying this over
633
659
  re-reading the parts.
634
660
  - \`snapshot/reports/audit.md\` \u2014 secrets found, large files, parse diagnostics, git
@@ -667,12 +693,16 @@ any of this to get a good first snapshot.
667
693
  Run \`npx ${PACKAGE_NAME2}@latest skill --install\`. It writes this skill into the
668
694
  repository in the places agents look for it by default: an \`AGENTS.md\` section,
669
695
  \`.claude/skills/${SKILL_NAME}/SKILL.md\`, and \`.cursor/rules/${SKILL_NAME}.mdc\`. The command
670
- is idempotent and only replaces its own marked section.
696
+ is idempotent: it replaces its own marked section, and repairs a malformed one (missing,
697
+ duplicated or nested markers) into a single block while leaving the rest of the file alone.
698
+ It refuses a target that resolves outside the repository, so a linked \`.claude/\` cannot
699
+ be used to write elsewhere.
671
700
  `;
672
701
  }
673
702
 
674
703
  // src/agent/install-skill.ts
675
704
  var SKILL_TARGETS = ["claude", "agents", "cursor", "all"];
705
+ var REFUSED = "Refusing to install the agent skill";
676
706
  function targetPath(cwd, target) {
677
707
  switch (target) {
678
708
  case "claude":
@@ -683,6 +713,41 @@ function targetPath(cwd, target) {
683
713
  return path2.join(cwd, "AGENTS.md");
684
714
  }
685
715
  }
716
+ function assertWritableInsideRepository(cwd, filePath) {
717
+ const root = fs.realpathSync(cwd);
718
+ const shown = path2.relative(root, filePath) || filePath;
719
+ let stat = null;
720
+ try {
721
+ stat = fs.lstatSync(filePath);
722
+ } catch {
723
+ stat = null;
724
+ }
725
+ if (stat !== null && stat.isSymbolicLink()) {
726
+ throw new Error(
727
+ `${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.`
728
+ );
729
+ }
730
+ if (stat !== null && stat.isFile() && stat.nlink > 1) {
731
+ throw new Error(
732
+ `${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.`
733
+ );
734
+ }
735
+ let existing = filePath;
736
+ const missing = [];
737
+ while (!fs.existsSync(existing)) {
738
+ missing.unshift(path2.basename(existing));
739
+ const parent = path2.dirname(existing);
740
+ if (parent === existing) break;
741
+ existing = parent;
742
+ }
743
+ const realAncestor = fs.realpathSync(existing);
744
+ const resolved = missing.length === 0 ? realAncestor : path2.join(realAncestor, ...missing);
745
+ if (resolved !== root && !resolved.startsWith(`${root}${path2.sep}`)) {
746
+ throw new Error(
747
+ `${REFUSED}: ${shown} resolves outside the repository (${resolved}). Move it back inside ${root}, or remove the link, and run again.`
748
+ );
749
+ }
750
+ }
686
751
  function writeIfChanged(target, filePath, content) {
687
752
  const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
688
753
  if (existing === content) return { target, path: filePath, action: "unchanged" };
@@ -704,34 +769,60 @@ function agentsBlock(skill) {
704
769
  ""
705
770
  ].join("\n");
706
771
  }
772
+ function ownSpans(existing) {
773
+ const marks = [];
774
+ let cursor = 0;
775
+ while (cursor < existing.length) {
776
+ const start = existing.indexOf(AGENTS_MARKER_START, cursor);
777
+ const end = existing.indexOf(AGENTS_MARKER_END, cursor);
778
+ if (start === -1 && end === -1) break;
779
+ if (start !== -1 && (end === -1 || start < end)) {
780
+ marks.push({ kind: "start", start, end: start + AGENTS_MARKER_START.length });
781
+ cursor = start + AGENTS_MARKER_START.length;
782
+ } else {
783
+ marks.push({ kind: "end", start: end, end: end + AGENTS_MARKER_END.length });
784
+ cursor = end + AGENTS_MARKER_END.length;
785
+ }
786
+ }
787
+ const spans = [];
788
+ let open = -1;
789
+ for (const mark of marks) {
790
+ if (mark.kind === "start") {
791
+ if (open === -1) open = mark.start;
792
+ } else if (open !== -1) {
793
+ spans.push({ start: open, end: mark.end });
794
+ open = -1;
795
+ } else {
796
+ spans.push({ start: mark.start, end: mark.end });
797
+ }
798
+ }
799
+ if (open !== -1) spans.push({ start: open, end: open + AGENTS_MARKER_START.length });
800
+ return spans;
801
+ }
802
+ function mergeMarkedSection(existing, block) {
803
+ const spans = ownSpans(existing);
804
+ if (spans.length === 0) return `${existing.trimEnd()}
805
+
806
+ ${block}`;
807
+ let stripped = "";
808
+ let from = 0;
809
+ let insertAt = -1;
810
+ for (const span of spans) {
811
+ if (span.start < from) continue;
812
+ stripped += existing.slice(from, span.start);
813
+ if (insertAt === -1) insertAt = stripped.length;
814
+ from = span.end;
815
+ }
816
+ stripped += existing.slice(from);
817
+ return `${stripped.slice(0, insertAt)}${block.trimEnd()}${stripped.slice(insertAt)}`;
818
+ }
707
819
  function mergeAgentsFile(cwd, skill) {
708
820
  const filePath = targetPath(cwd, "agents");
709
821
  const block = agentsBlock(skill);
710
822
  const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
711
- let next;
712
- if (existing === null) {
713
- next = `# AGENTS.md
714
-
715
- ${block}`;
716
- } else {
717
- const starts = existing.split(AGENTS_MARKER_START).length - 1;
718
- const ends = existing.split(AGENTS_MARKER_END).length - 1;
719
- const start = existing.indexOf(AGENTS_MARKER_START);
720
- const end = existing.indexOf(AGENTS_MARKER_END) + AGENTS_MARKER_END.length;
721
- if (starts === 1 && ends === 1 && start < end) {
722
- next = `${existing.slice(0, start)}${block.trimEnd()}${existing.slice(end)}`;
723
- } else if (starts > 0 && ends === 0) {
724
- next = `${existing.slice(0, start)}${block.trimEnd()}${existing.slice(
725
- start + AGENTS_MARKER_START.length
726
- )}`;
727
- } else if (existing.includes(block.trimEnd())) {
728
- next = existing;
729
- } else {
730
- next = `${existing.trimEnd()}
823
+ const next = existing === null ? `# AGENTS.md
731
824
 
732
- ${block}`;
733
- }
734
- }
825
+ ${block}` : mergeMarkedSection(existing, block);
735
826
  const action = existing === null ? "created" : existing === next ? "unchanged" : "updated";
736
827
  if (action !== "unchanged") {
737
828
  fs.mkdirSync(path2.dirname(filePath), { recursive: true });
@@ -743,6 +834,7 @@ function installSkill(cwd, target = "all") {
743
834
  const skill = renderSkill();
744
835
  const written = [];
745
836
  const wanted = target === "all" ? ["agents", "claude", "cursor"] : [target];
837
+ for (const item of wanted) assertWritableInsideRepository(cwd, targetPath(cwd, item));
746
838
  for (const item of wanted) {
747
839
  if (item === "agents") {
748
840
  written.push(mergeAgentsFile(cwd, skill));
@@ -794,6 +886,7 @@ var CLI_OPTIONS = {
794
886
  "dry-run": { type: "boolean" },
795
887
  watch: { type: "boolean" },
796
888
  "no-config": { type: "boolean" },
889
+ discovery: { type: "string" },
797
890
  preset: { type: "string" },
798
891
  include: { type: "string" },
799
892
  exclude: { type: "string" },
@@ -841,6 +934,9 @@ Options:
841
934
  --dry-run Compute the snapshot without writing anything
842
935
  --watch Re-generate whenever the repository changes
843
936
  --no-config Ignore snapshot.config.* and use the built-in defaults
937
+ --discovery <mode> auto | git | walk \u2014 how files are discovered.
938
+ auto: git when usable. git: require git. walk: read
939
+ .gitignore directly (escape hatch for a broken .git)
844
940
 
845
941
  init only:
846
942
  --preset <name> Force a project preset instead of detecting one
@@ -970,6 +1066,19 @@ function parseCliArgs(argv) {
970
1066
  }
971
1067
  command = resolved;
972
1068
  }
1069
+ if (explicitMode !== void 0) {
1070
+ const resolvedMode = COMMAND_ALIASES.get(explicitMode);
1071
+ if (!resolvedMode) {
1072
+ throw new UsageError(
1073
+ `Unknown --mode "${explicitMode}". Expected one of: ${Array.from(COMMAND_ALIASES.keys()).join(", ")}.`
1074
+ );
1075
+ }
1076
+ if (firstPositional !== void 0 && resolvedMode !== command) {
1077
+ throw new UsageError(
1078
+ `--mode ${explicitMode} contradicts the command "${firstPositional}". Pass one or the other.`
1079
+ );
1080
+ }
1081
+ }
973
1082
  const expectedArgs = COMMAND_ARITY[command];
974
1083
  if (args.length !== expectedArgs) {
975
1084
  if (command === "explain") {
@@ -988,6 +1097,13 @@ function parseCliArgs(argv) {
988
1097
  if (rawFormat !== "text" && rawFormat !== "json") {
989
1098
  throw new UsageError(`--format must be "text" or "json", received "${rawFormat}".`);
990
1099
  }
1100
+ const rawDiscovery = values.discovery === void 0 ? void 0 : String(values.discovery);
1101
+ if (rawDiscovery !== void 0 && !isDiscoveryMode(rawDiscovery)) {
1102
+ throw new UsageError(
1103
+ `--discovery must be one of: auto, git, walk (received "${rawDiscovery}").`
1104
+ );
1105
+ }
1106
+ let onlyNamesMonolith = false;
991
1107
  const rawSkip = values["skip-artifacts"];
992
1108
  const rawOnly = values["only-artifacts"];
993
1109
  if (rawSkip !== void 0 && rawOnly !== void 0) {
@@ -996,6 +1112,7 @@ function parseCliArgs(argv) {
996
1112
  let skipArtifacts;
997
1113
  if (rawOnly !== void 0) {
998
1114
  const keep = parseArtifactList(String(rawOnly), "--only-artifacts");
1115
+ onlyNamesMonolith = keep.includes("monolith");
999
1116
  skipArtifacts = ARTIFACT_NAMES.filter(
1000
1117
  (name) => !keep.includes(name)
1001
1118
  );
@@ -1050,7 +1167,7 @@ function parseCliArgs(argv) {
1050
1167
  target: values.target === void 0 ? void 0 : String(values.target),
1051
1168
  cwd: values.cwd === void 0 ? process.cwd() : path2.resolve(String(values.cwd)),
1052
1169
  outDir: values["out-dir"] === void 0 ? void 0 : String(values["out-dir"]),
1053
- includeMonolith: values["include-monolith"] === true ? true : void 0,
1170
+ includeMonolith: values["include-monolith"] === true || onlyNamesMonolith === true ? true : void 0,
1054
1171
  includeSensitiveFiles: values["include-sensitive-files"] === true ? true : void 0,
1055
1172
  maxPartBytes: parseByteOption(
1056
1173
  values["max-part-bytes"],
@@ -1067,17 +1184,21 @@ function parseCliArgs(argv) {
1067
1184
  "max-monolith-bytes",
1068
1185
  1
1069
1186
  ),
1070
- skipArtifacts
1187
+ skipArtifacts,
1188
+ discovery: isDiscoveryMode(rawDiscovery) ? rawDiscovery : void 0
1071
1189
  };
1072
1190
  }
1073
1191
  var HelpRequested = class extends Error {
1074
1192
  };
1193
+ function escapeForTerminal(message) {
1194
+ return message.split("\n").map((line) => escapeControls(line)).join("\n");
1195
+ }
1075
1196
  function info(message) {
1076
- process.stderr.write(`${message}
1197
+ process.stderr.write(`${escapeForTerminal(message)}
1077
1198
  `);
1078
1199
  }
1079
1200
  function warn(message) {
1080
- process.stderr.write(`${message}
1201
+ process.stderr.write(`${escapeForTerminal(message)}
1081
1202
  `);
1082
1203
  }
1083
1204
  function emit(format, payload, textRenderer) {
@@ -1094,8 +1215,8 @@ function splitList(value) {
1094
1215
  }
1095
1216
  function displayOutPath(cwd, outDir, ...segments) {
1096
1217
  const absolute = path2.resolve(cwd, outDir, ...segments);
1097
- const relative2 = path2.relative(cwd, absolute);
1098
- if (relative2 && !relative2.startsWith("..") && !path2.isAbsolute(relative2)) return relative2;
1218
+ const relative3 = path2.relative(cwd, absolute);
1219
+ if (relative3 && !relative3.startsWith("..") && !path2.isAbsolute(relative3)) return relative3;
1099
1220
  return absolute;
1100
1221
  }
1101
1222
  function describeManifestShapeProblem(manifest) {
@@ -1107,11 +1228,32 @@ function describeManifestShapeProblem(manifest) {
1107
1228
  if (!counts || typeof counts !== "object") return "missing `counts`";
1108
1229
  for (const key of ["totalFiles", "totalLines", "totalSizeBytes", "partsCount"]) {
1109
1230
  if (typeof counts[key] !== "number") return `missing numeric \`counts.${key}\``;
1231
+ if (!Number.isSafeInteger(counts[key]) || counts[key] < 0) {
1232
+ return `\`counts.${key}\` must be a non-negative integer`;
1233
+ }
1234
+ }
1235
+ if (candidate.fingerprint !== void 0 && !/^[0-9a-f]{64}$/.test(String(candidate.fingerprint))) {
1236
+ return "`fingerprint` must be a 64-character sha256 hex digest";
1110
1237
  }
1111
1238
  if (!candidate.layers || typeof candidate.layers !== "object") return "missing `layers`";
1112
1239
  if (!Array.isArray(candidate.files)) return "missing `files` array";
1113
1240
  return null;
1114
1241
  }
1242
+ function nextStepHint(cwd, scriptsAddedNow) {
1243
+ let hasScript = scriptsAddedNow;
1244
+ if (!hasScript) {
1245
+ try {
1246
+ const parsed = JSON.parse(fs.readFileSync(path2.join(cwd, "package.json"), "utf8"));
1247
+ hasScript = typeof parsed.scripts?.snapshot === "string";
1248
+ } catch {
1249
+ hasScript = false;
1250
+ }
1251
+ }
1252
+ if (!hasScript) return `npx ${GENERATOR_NAME} generate`;
1253
+ const bin = path2.join(cwd, "node_modules", ".bin", "codebase-snapshot");
1254
+ const installed = fs.existsSync(bin) || fs.existsSync(`${bin}.cmd`);
1255
+ return installed ? "npm run snapshot" : "npm install && npm run snapshot";
1256
+ }
1115
1257
  async function runInitCommand(invocation) {
1116
1258
  try {
1117
1259
  const result = await runInit({
@@ -1144,11 +1286,7 @@ async function runInitCommand(invocation) {
1144
1286
  }
1145
1287
  for (const note of result.notes) info(` \u26A0 ${note}`);
1146
1288
  info("");
1147
- if (result.scripts.length > 0) {
1148
- info(" Next: npm run snapshot");
1149
- } else {
1150
- info(` Next: ${GENERATOR_NAME} generate`);
1151
- }
1289
+ info(` Next: ${nextStepHint(invocation.cwd, result.scripts.length > 0)}`);
1152
1290
  info(
1153
1291
  ` Tip: \`${GENERATOR_NAME} skill --install\` makes this repo discoverable by AI agents.`
1154
1292
  );
@@ -1200,6 +1338,9 @@ function renderValidateText(result) {
1200
1338
  } else {
1201
1339
  warn("\u274C Snapshot validation failed:");
1202
1340
  for (const error of result.errors) warn(` - ${error}`);
1341
+ for (const unreadable of result.drift.unreadable.slice(0, 20)) {
1342
+ warn(` - unreadable: ${unreadable}`);
1343
+ }
1203
1344
  for (const drift of result.drift.hashMismatches.slice(0, 20)) {
1204
1345
  warn(` - modified: ${drift.path}`);
1205
1346
  }
@@ -1247,7 +1388,8 @@ ${USAGE}
1247
1388
  if (invocation.command === "skill") {
1248
1389
  return runSkillCommand(invocation);
1249
1390
  }
1250
- const loaded = invocation.noConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1391
+ const skipConfig = invocation.noConfig || invocation.command === "diff";
1392
+ const loaded = skipConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1251
1393
  if (invocation.noConfig) info("\u2699 --no-config: using built-in defaults, config file ignored");
1252
1394
  for (const warning of loaded.warnings) warn(`\u26A0 Config: ${warning}`);
1253
1395
  if (loaded.errors.length > 0) {
@@ -1268,12 +1410,27 @@ ${USAGE}
1268
1410
  maxPartBytes: invocation.maxPartBytes,
1269
1411
  largeFileBytes: invocation.largeFileBytes,
1270
1412
  maxMonolithBytes: invocation.maxMonolithBytes,
1271
- skipArtifacts: invocation.skipArtifacts
1413
+ skipArtifacts: invocation.skipArtifacts,
1414
+ discovery: invocation.discovery
1272
1415
  },
1273
1416
  invocation.cwd
1274
1417
  );
1275
1418
  if (invocation.command === "validate") {
1276
1419
  const result = validateSnapshot(resolved);
1420
+ if (result.valid) {
1421
+ const manifestPath = path2.resolve(resolved.cwd, resolved.outDir, "manifest.json");
1422
+ let manifest;
1423
+ try {
1424
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1425
+ } catch {
1426
+ manifest = void 0;
1427
+ }
1428
+ const shapeError = manifest === void 0 ? null : describeManifestShapeProblem(manifest);
1429
+ if (shapeError) {
1430
+ warn(`\u274C ${manifestPath} is not a snapshot manifest: ${shapeError}`);
1431
+ return 1;
1432
+ }
1433
+ }
1277
1434
  emit(invocation.format, result, () => renderValidateText(result));
1278
1435
  return result.valid ? 0 : 1;
1279
1436
  }
@@ -1423,24 +1580,79 @@ ${USAGE}
1423
1580
  return 1;
1424
1581
  }
1425
1582
  }
1583
+ var WATCH_DEBOUNCE_MS = 250;
1584
+ var WATCH_SETTLE_MS = 1500;
1585
+ var VCS_SEGMENTS = /* @__PURE__ */ new Set([".git", ".hg", ".svn", ".bzr"]);
1586
+ function initialWatchState() {
1587
+ return { running: false, pending: false, settleUntil: 0 };
1588
+ }
1589
+ function beginWatchRun(state) {
1590
+ return { ...state, running: true, pending: false };
1591
+ }
1592
+ function finishWatchRun(state, now) {
1593
+ return {
1594
+ state: { running: false, pending: false, settleUntil: now + WATCH_SETTLE_MS },
1595
+ schedule: state.pending
1596
+ };
1597
+ }
1598
+ function comparablePath(value) {
1599
+ const absolute = path2.resolve(value);
1600
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
1601
+ }
1602
+ function isSameOrInside(parent, candidate) {
1603
+ const relative3 = path2.relative(parent, candidate);
1604
+ return relative3 === "" || relative3 !== ".." && !relative3.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative3);
1605
+ }
1606
+ function isGeneratedWatchPath(candidate, paths) {
1607
+ const absolute = comparablePath(path2.resolve(paths.cwd, candidate));
1608
+ const outDir = comparablePath(path2.resolve(paths.cwd, paths.outDir));
1609
+ const tmpDir = comparablePath(path2.resolve(paths.cwd, paths.tmpDir));
1610
+ if (isSameOrInside(outDir, absolute) || isSameOrInside(tmpDir, absolute)) return true;
1611
+ const parent = path2.dirname(absolute);
1612
+ if (parent === path2.dirname(outDir) && path2.basename(absolute).startsWith(`${path2.basename(outDir)}.backup-`)) {
1613
+ return true;
1614
+ }
1615
+ return absolute.split(/[\\/]+/).some((segment) => VCS_SEGMENTS.has(segment));
1616
+ }
1617
+ function decideWatchEvent(state, event, paths) {
1618
+ if (event.path !== null) {
1619
+ if (isGeneratedWatchPath(event.path, paths)) return { state, action: "ignore" };
1620
+ } else if (state.running || event.now < state.settleUntil) {
1621
+ return { state, action: "ignore" };
1622
+ }
1623
+ if (state.running) {
1624
+ return { state: { ...state, pending: true }, action: "pending" };
1625
+ }
1626
+ return { state, action: "schedule" };
1627
+ }
1426
1628
  async function runWatchLoop(invocation, resolved) {
1427
1629
  const outDir = path2.resolve(resolved.cwd, resolved.outDir);
1428
1630
  const tmpDir = path2.resolve(resolved.cwd, resolved.tmpDir);
1429
- const VCS_SEGMENTS = /* @__PURE__ */ new Set([".git", ".hg", ".svn", ".bzr"]);
1430
- const outName = path2.basename(outDir).toLowerCase();
1431
- const tmpName = path2.basename(tmpDir).toLowerCase();
1432
- const isGenerated = (candidate) => candidate.toLowerCase().split(/[\\/]/).some(
1433
- (segment) => segment === outName || segment === tmpName || segment.startsWith(`${outName}.backup-`) || VCS_SEGMENTS.has(segment)
1434
- );
1435
- let running = false;
1436
- let quietUntil = 0;
1437
- const QUIET_MS = 1500;
1631
+ const paths = { cwd: resolved.cwd, outDir, tmpDir };
1632
+ let state = initialWatchState();
1633
+ let timer = null;
1634
+ let stopped = false;
1635
+ const schedule = (delay = WATCH_DEBOUNCE_MS) => {
1636
+ if (stopped) return;
1637
+ if (timer) clearTimeout(timer);
1638
+ timer = setTimeout(() => {
1639
+ timer = null;
1640
+ void regenerate();
1641
+ }, delay);
1642
+ };
1438
1643
  const regenerate = async () => {
1439
- if (running) return;
1440
- running = true;
1644
+ if (state.running) {
1645
+ state = { ...state, pending: true };
1646
+ if (timer) {
1647
+ clearTimeout(timer);
1648
+ timer = null;
1649
+ }
1650
+ return;
1651
+ }
1652
+ state = beginWatchRun(state);
1441
1653
  const started = performance.now();
1442
1654
  try {
1443
- const loaded = await loadConfigFileDetailed(invocation.cwd);
1655
+ const loaded = invocation.noConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1444
1656
  if (loaded.errors.length > 0) {
1445
1657
  for (const error of loaded.errors) warn(`\u26A0 Config: ${error}`);
1446
1658
  warn("\u274C The config file could not be read. Fix it, or delete it to use the defaults.");
@@ -1460,7 +1672,8 @@ async function runWatchLoop(invocation, resolved) {
1460
1672
  maxPartBytes: invocation.maxPartBytes,
1461
1673
  largeFileBytes: invocation.largeFileBytes,
1462
1674
  maxMonolithBytes: invocation.maxMonolithBytes,
1463
- skipArtifacts: invocation.skipArtifacts
1675
+ skipArtifacts: invocation.skipArtifacts,
1676
+ discovery: invocation.discovery
1464
1677
  },
1465
1678
  invocation.cwd
1466
1679
  );
@@ -1472,24 +1685,35 @@ async function runWatchLoop(invocation, resolved) {
1472
1685
  } catch (err) {
1473
1686
  warn(`\u274C ${err.message}`);
1474
1687
  } finally {
1475
- quietUntil = performance.now() + QUIET_MS;
1476
- running = false;
1688
+ const finished = finishWatchRun(state, performance.now());
1689
+ state = finished.state;
1690
+ if (finished.schedule) schedule(0);
1477
1691
  }
1478
1692
  };
1479
1693
  info("\u{1F440} Watching for changes. Press Ctrl+C to stop.");
1480
1694
  void regenerate();
1481
- let timer = null;
1482
1695
  const watcher = fs.watch(resolved.cwd, { recursive: true }, (_event, filename) => {
1483
- if (performance.now() < quietUntil) return;
1484
- if (filename) {
1485
- const changed = path2.resolve(resolved.cwd, filename.toString());
1486
- if (isGenerated(changed)) return;
1696
+ const decision = decideWatchEvent(
1697
+ state,
1698
+ {
1699
+ path: filename ? path2.resolve(resolved.cwd, filename.toString()) : null,
1700
+ now: performance.now()
1701
+ },
1702
+ paths
1703
+ );
1704
+ state = decision.state;
1705
+ if (decision.action === "pending") {
1706
+ if (timer) {
1707
+ clearTimeout(timer);
1708
+ timer = null;
1709
+ }
1710
+ return;
1487
1711
  }
1488
- if (timer) clearTimeout(timer);
1489
- timer = setTimeout(() => void regenerate(), 250);
1712
+ if (decision.action === "schedule") schedule();
1490
1713
  });
1491
1714
  await new Promise((resolve2) => {
1492
1715
  const stop = () => {
1716
+ stopped = true;
1493
1717
  if (timer) clearTimeout(timer);
1494
1718
  watcher.close();
1495
1719
  resolve2();
@@ -1515,6 +1739,6 @@ if (invokedDirectly) {
1515
1739
  );
1516
1740
  }
1517
1741
 
1518
- export { UsageError, main, parseByteOption, parseCliArgs, parsePositiveInteger };
1742
+ export { UsageError, WATCH_DEBOUNCE_MS, WATCH_SETTLE_MS, beginWatchRun, decideWatchEvent, finishWatchRun, initialWatchState, isGeneratedWatchPath, main, parseByteOption, parseCliArgs, parsePositiveInteger };
1519
1743
  //# sourceMappingURL=cli.js.map
1520
1744
  //# sourceMappingURL=cli.js.map