@ankurhalder/codebase-snapshot 4.0.7 → 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-VRJTXNJV.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
@@ -1001,6 +1097,12 @@ function parseCliArgs(argv) {
1001
1097
  if (rawFormat !== "text" && rawFormat !== "json") {
1002
1098
  throw new UsageError(`--format must be "text" or "json", received "${rawFormat}".`);
1003
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
+ }
1004
1106
  let onlyNamesMonolith = false;
1005
1107
  const rawSkip = values["skip-artifacts"];
1006
1108
  const rawOnly = values["only-artifacts"];
@@ -1082,17 +1184,21 @@ function parseCliArgs(argv) {
1082
1184
  "max-monolith-bytes",
1083
1185
  1
1084
1186
  ),
1085
- skipArtifacts
1187
+ skipArtifacts,
1188
+ discovery: isDiscoveryMode(rawDiscovery) ? rawDiscovery : void 0
1086
1189
  };
1087
1190
  }
1088
1191
  var HelpRequested = class extends Error {
1089
1192
  };
1193
+ function escapeForTerminal(message) {
1194
+ return message.split("\n").map((line) => escapeControls(line)).join("\n");
1195
+ }
1090
1196
  function info(message) {
1091
- process.stderr.write(`${message}
1197
+ process.stderr.write(`${escapeForTerminal(message)}
1092
1198
  `);
1093
1199
  }
1094
1200
  function warn(message) {
1095
- process.stderr.write(`${message}
1201
+ process.stderr.write(`${escapeForTerminal(message)}
1096
1202
  `);
1097
1203
  }
1098
1204
  function emit(format, payload, textRenderer) {
@@ -1109,8 +1215,8 @@ function splitList(value) {
1109
1215
  }
1110
1216
  function displayOutPath(cwd, outDir, ...segments) {
1111
1217
  const absolute = path2.resolve(cwd, outDir, ...segments);
1112
- const relative2 = path2.relative(cwd, absolute);
1113
- 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;
1114
1220
  return absolute;
1115
1221
  }
1116
1222
  function describeManifestShapeProblem(manifest) {
@@ -1122,11 +1228,32 @@ function describeManifestShapeProblem(manifest) {
1122
1228
  if (!counts || typeof counts !== "object") return "missing `counts`";
1123
1229
  for (const key of ["totalFiles", "totalLines", "totalSizeBytes", "partsCount"]) {
1124
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";
1125
1237
  }
1126
1238
  if (!candidate.layers || typeof candidate.layers !== "object") return "missing `layers`";
1127
1239
  if (!Array.isArray(candidate.files)) return "missing `files` array";
1128
1240
  return null;
1129
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
+ }
1130
1257
  async function runInitCommand(invocation) {
1131
1258
  try {
1132
1259
  const result = await runInit({
@@ -1159,11 +1286,7 @@ async function runInitCommand(invocation) {
1159
1286
  }
1160
1287
  for (const note of result.notes) info(` \u26A0 ${note}`);
1161
1288
  info("");
1162
- if (result.scripts.length > 0) {
1163
- info(" Next: npm run snapshot");
1164
- } else {
1165
- info(` Next: ${GENERATOR_NAME} generate`);
1166
- }
1289
+ info(` Next: ${nextStepHint(invocation.cwd, result.scripts.length > 0)}`);
1167
1290
  info(
1168
1291
  ` Tip: \`${GENERATOR_NAME} skill --install\` makes this repo discoverable by AI agents.`
1169
1292
  );
@@ -1215,6 +1338,9 @@ function renderValidateText(result) {
1215
1338
  } else {
1216
1339
  warn("\u274C Snapshot validation failed:");
1217
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
+ }
1218
1344
  for (const drift of result.drift.hashMismatches.slice(0, 20)) {
1219
1345
  warn(` - modified: ${drift.path}`);
1220
1346
  }
@@ -1262,7 +1388,8 @@ ${USAGE}
1262
1388
  if (invocation.command === "skill") {
1263
1389
  return runSkillCommand(invocation);
1264
1390
  }
1265
- 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);
1266
1393
  if (invocation.noConfig) info("\u2699 --no-config: using built-in defaults, config file ignored");
1267
1394
  for (const warning of loaded.warnings) warn(`\u26A0 Config: ${warning}`);
1268
1395
  if (loaded.errors.length > 0) {
@@ -1283,12 +1410,27 @@ ${USAGE}
1283
1410
  maxPartBytes: invocation.maxPartBytes,
1284
1411
  largeFileBytes: invocation.largeFileBytes,
1285
1412
  maxMonolithBytes: invocation.maxMonolithBytes,
1286
- skipArtifacts: invocation.skipArtifacts
1413
+ skipArtifacts: invocation.skipArtifacts,
1414
+ discovery: invocation.discovery
1287
1415
  },
1288
1416
  invocation.cwd
1289
1417
  );
1290
1418
  if (invocation.command === "validate") {
1291
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
+ }
1292
1434
  emit(invocation.format, result, () => renderValidateText(result));
1293
1435
  return result.valid ? 0 : 1;
1294
1436
  }
@@ -1438,24 +1580,79 @@ ${USAGE}
1438
1580
  return 1;
1439
1581
  }
1440
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
+ }
1441
1628
  async function runWatchLoop(invocation, resolved) {
1442
1629
  const outDir = path2.resolve(resolved.cwd, resolved.outDir);
1443
1630
  const tmpDir = path2.resolve(resolved.cwd, resolved.tmpDir);
1444
- const VCS_SEGMENTS = /* @__PURE__ */ new Set([".git", ".hg", ".svn", ".bzr"]);
1445
- const outName = path2.basename(outDir).toLowerCase();
1446
- const tmpName = path2.basename(tmpDir).toLowerCase();
1447
- const isGenerated = (candidate) => candidate.toLowerCase().split(/[\\/]/).some(
1448
- (segment) => segment === outName || segment === tmpName || segment.startsWith(`${outName}.backup-`) || VCS_SEGMENTS.has(segment)
1449
- );
1450
- let running = false;
1451
- let quietUntil = 0;
1452
- 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
+ };
1453
1643
  const regenerate = async () => {
1454
- if (running) return;
1455
- 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);
1456
1653
  const started = performance.now();
1457
1654
  try {
1458
- const loaded = await loadConfigFileDetailed(invocation.cwd);
1655
+ const loaded = invocation.noConfig ? { config: null, warnings: [], errors: [], file: null } : await loadConfigFileDetailed(invocation.cwd);
1459
1656
  if (loaded.errors.length > 0) {
1460
1657
  for (const error of loaded.errors) warn(`\u26A0 Config: ${error}`);
1461
1658
  warn("\u274C The config file could not be read. Fix it, or delete it to use the defaults.");
@@ -1475,7 +1672,8 @@ async function runWatchLoop(invocation, resolved) {
1475
1672
  maxPartBytes: invocation.maxPartBytes,
1476
1673
  largeFileBytes: invocation.largeFileBytes,
1477
1674
  maxMonolithBytes: invocation.maxMonolithBytes,
1478
- skipArtifacts: invocation.skipArtifacts
1675
+ skipArtifacts: invocation.skipArtifacts,
1676
+ discovery: invocation.discovery
1479
1677
  },
1480
1678
  invocation.cwd
1481
1679
  );
@@ -1487,24 +1685,35 @@ async function runWatchLoop(invocation, resolved) {
1487
1685
  } catch (err) {
1488
1686
  warn(`\u274C ${err.message}`);
1489
1687
  } finally {
1490
- quietUntil = performance.now() + QUIET_MS;
1491
- running = false;
1688
+ const finished = finishWatchRun(state, performance.now());
1689
+ state = finished.state;
1690
+ if (finished.schedule) schedule(0);
1492
1691
  }
1493
1692
  };
1494
1693
  info("\u{1F440} Watching for changes. Press Ctrl+C to stop.");
1495
1694
  void regenerate();
1496
- let timer = null;
1497
1695
  const watcher = fs.watch(resolved.cwd, { recursive: true }, (_event, filename) => {
1498
- if (performance.now() < quietUntil) return;
1499
- if (filename) {
1500
- const changed = path2.resolve(resolved.cwd, filename.toString());
1501
- 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;
1502
1711
  }
1503
- if (timer) clearTimeout(timer);
1504
- timer = setTimeout(() => void regenerate(), 250);
1712
+ if (decision.action === "schedule") schedule();
1505
1713
  });
1506
1714
  await new Promise((resolve2) => {
1507
1715
  const stop = () => {
1716
+ stopped = true;
1508
1717
  if (timer) clearTimeout(timer);
1509
1718
  watcher.close();
1510
1719
  resolve2();
@@ -1530,6 +1739,6 @@ if (invokedDirectly) {
1530
1739
  );
1531
1740
  }
1532
1741
 
1533
- export { UsageError, main, parseByteOption, parseCliArgs, parsePositiveInteger };
1742
+ export { UsageError, WATCH_DEBOUNCE_MS, WATCH_SETTLE_MS, beginWatchRun, decideWatchEvent, finishWatchRun, initialWatchState, isGeneratedWatchPath, main, parseByteOption, parseCliArgs, parsePositiveInteger };
1534
1743
  //# sourceMappingURL=cli.js.map
1535
1744
  //# sourceMappingURL=cli.js.map