claude-setup 1.1.5 → 1.1.6

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/builder.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface FileDiff {
15
15
  deleted: string[];
16
16
  }
17
17
  export declare function buildSyncCommand(diff: FileDiff, collected: CollectedFiles, state: ExistingState): string;
18
+ /**
19
+ * Bootstrap stack-sync.md — installed by init so /stack-sync works immediately.
20
+ * When run, it tells Claude to invoke npx claude-setup sync first (which
21
+ * regenerates this file with the real diff), then re-read and process it.
22
+ */
23
+ export declare function buildBootstrapSync(): string;
18
24
  export declare function buildRemoveCommand(input: string, state: ExistingState): string;
19
25
  export interface AtomicStep {
20
26
  filename: string;
package/dist/builder.js CHANGED
@@ -171,6 +171,35 @@ export function buildSyncCommand(diff, collected, state) {
171
171
  DELETED_FILES: deletedStr,
172
172
  }, "sync");
173
173
  }
174
+ /**
175
+ * Bootstrap stack-sync.md — installed by init so /stack-sync works immediately.
176
+ * When run, it tells Claude to invoke npx claude-setup sync first (which
177
+ * regenerates this file with the real diff), then re-read and process it.
178
+ */
179
+ export function buildBootstrapSync() {
180
+ return `<!-- stack-sync: self-refreshing sync command. Last generated: ${new Date().toISOString().split("T")[0]} -->
181
+
182
+ > **Step 1 — always do this first:** Run \`npx claude-setup sync\` to refresh this file with the latest diff.
183
+ > - If it outputs "No changes since…" → respond "Stack already in sync." and STOP.
184
+ > - Otherwise → re-read \`.claude/commands/stack-sync.md\` (Step 2), then process the diff below (Step 3).
185
+
186
+ ## Changes since last setup
187
+
188
+ ### Added files
189
+ (none — run \`npx claude-setup sync\` to populate)
190
+
191
+ ### Modified files
192
+ (none)
193
+
194
+ ### Deleted files
195
+ (none)
196
+
197
+ ## Your job
198
+
199
+ For EACH changed file: does this change have any implication for the Claude Code setup?
200
+ Update ONLY what the change demands. Do NOT rewrite files — surgical edits only.
201
+ `;
202
+ }
174
203
  export function buildRemoveCommand(input, state) {
175
204
  const emptyCollected = { configs: {}, source: [], skipped: [] };
176
205
  return applyTemplate("remove.md", emptyCollected, state, { USER_INPUT: input }, "remove");
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
  import { collectProjectFiles, isEmptyProject } from "../collect.js";
4
4
  import { readState } from "../state.js";
5
5
  import { updateManifest } from "../manifest.js";
6
- import { buildEmptyProjectCommand, buildAtomicSteps, buildOrchestratorCommand, } from "../builder.js";
6
+ import { buildEmptyProjectCommand, buildAtomicSteps, buildOrchestratorCommand, buildBootstrapSync, } from "../builder.js";
7
7
  import { createSnapshot, collectFilesForSnapshot } from "../snapshot.js";
8
8
  import { estimateTokens, estimateCost, formatCost, getTokenHookScript, formatRealCostSummary } from "../tokens.js";
9
9
  import { c, section } from "../output.js";
@@ -79,12 +79,12 @@ export async function runInit(opts = {}) {
79
79
  }
80
80
  ensureDir(".claude/commands");
81
81
  writeFileSync(".claude/commands/stack-init.md", content, "utf8");
82
+ writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
82
83
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
83
84
  installTokenHook();
84
- // Feature A: Create initial snapshot node
85
+ // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
85
86
  const cwd = process.cwd();
86
- const allPaths = [...Object.keys(collected.configs), ...collected.source.map(s => s.path)];
87
- const snapshotFiles = collectFilesForSnapshot(cwd, allPaths);
87
+ const snapshotFiles = collectFilesForSnapshot(cwd, Object.keys(collected.configs));
88
88
  createSnapshot(cwd, "init", snapshotFiles, { summary: "initial setup (empty project)" });
89
89
  console.log(`
90
90
  ${c.green("✅")} New project detected.
@@ -98,7 +98,6 @@ Claude Code will ask 3 questions, then set up your environment.
98
98
  const realSummary1 = formatRealCostSummary(cwd);
99
99
  if (realSummary1) {
100
100
  console.log(realSummary1);
101
- console.log(` ${c.dim(`This command estimate: ~${tokens.toLocaleString()} input tokens (${formatCost(cost)})`)}`);
102
101
  }
103
102
  else {
104
103
  console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
@@ -131,12 +130,12 @@ Claude Code will ask 3 questions, then set up your environment.
131
130
  writeFileSync(join(".claude/commands", step.filename), step.content, "utf8");
132
131
  }
133
132
  writeFileSync(".claude/commands/stack-init.md", orchestrator, "utf8");
133
+ writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
134
134
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
135
135
  installTokenHook();
136
- // Feature A: Create initial snapshot node
136
+ // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
137
137
  const cwd = process.cwd();
138
- const allPaths = [...Object.keys(collected.configs), ...collected.source.map(s => s.path)];
139
- const snapshotFiles = collectFilesForSnapshot(cwd, allPaths);
138
+ const snapshotFiles = collectFilesForSnapshot(cwd, Object.keys(collected.configs));
140
139
  createSnapshot(cwd, "init", snapshotFiles, {
141
140
  summary: `${steps.length - 1} atomic steps generated`,
142
141
  });
@@ -150,7 +149,6 @@ Runs ${steps.length - 1} atomic steps. If one fails, re-run only that step.
150
149
  const realSummary2 = formatRealCostSummary(cwd);
151
150
  if (realSummary2) {
152
151
  console.log(realSummary2);
153
- console.log(` ${c.dim(`This command estimate: ~${tokens.toLocaleString()} input tokens (${formatCost(cost)})`)}`);
154
152
  }
155
153
  else {
156
154
  console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
@@ -10,6 +10,97 @@ async function promptFreeText(question) {
10
10
  });
11
11
  });
12
12
  }
13
+ async function promptSelectSnapshot(items) {
14
+ const allItems = [
15
+ ...items,
16
+ { id: "__custom__", label: c.dim("Type snapshot ID manually…") },
17
+ { id: "__cancel__", label: c.dim("Cancel") },
18
+ ];
19
+ // Non-TTY fallback: numbered list
20
+ if (!process.stdin.isTTY) {
21
+ for (let i = 0; i < allItems.length; i++) {
22
+ console.log(` [${i + 1}] ${allItems[i].label}`);
23
+ }
24
+ const raw = await promptFreeText("\nEnter number (or 'cancel'):");
25
+ if (!raw || raw === "cancel")
26
+ return null;
27
+ const num = parseInt(raw, 10);
28
+ if (!isNaN(num) && num >= 1 && num <= allItems.length) {
29
+ const val = allItems[num - 1].id;
30
+ if (val === "__cancel__")
31
+ return null;
32
+ if (val === "__custom__")
33
+ return (await promptFreeText("Enter snapshot ID:")) || null;
34
+ return val;
35
+ }
36
+ return raw;
37
+ }
38
+ return new Promise((resolve) => {
39
+ let selectedIndex = 0;
40
+ let lineCount = 0;
41
+ const clearLines = () => {
42
+ if (lineCount > 0)
43
+ process.stdout.write(`\x1b[${lineCount}A\x1b[0J`);
44
+ };
45
+ const render = () => {
46
+ clearLines();
47
+ const lines = [];
48
+ for (let i = 0; i < allItems.length; i++) {
49
+ const isSelected = i === selectedIndex;
50
+ const marker = isSelected ? c.cyan("❯") : " ";
51
+ const text = isSelected ? c.bold(allItems[i].label) : allItems[i].label;
52
+ lines.push(` ${marker} ${text}`);
53
+ }
54
+ lines.push(``);
55
+ lines.push(` ${c.dim("↑/↓ navigate · Enter select · Ctrl+C cancel")}`);
56
+ process.stdout.write(lines.join("\n") + "\n");
57
+ lineCount = lines.length;
58
+ };
59
+ let onKey;
60
+ const cleanup = () => {
61
+ process.stdin.removeListener("data", onKey);
62
+ try {
63
+ process.stdin.setRawMode(false);
64
+ }
65
+ catch { }
66
+ process.stdin.pause();
67
+ };
68
+ onKey = (key) => {
69
+ if (key === "\u0003") {
70
+ cleanup();
71
+ process.stdout.write("\n");
72
+ process.exit(0);
73
+ }
74
+ else if (key === "\u001b[A" || key === "\u001bOA") {
75
+ selectedIndex = Math.max(0, selectedIndex - 1);
76
+ render();
77
+ }
78
+ else if (key === "\u001b[B" || key === "\u001bOB") {
79
+ selectedIndex = Math.min(allItems.length - 1, selectedIndex + 1);
80
+ render();
81
+ }
82
+ else if (key === "\r" || key === "\n") {
83
+ const chosen = allItems[selectedIndex];
84
+ cleanup();
85
+ process.stdout.write("\n");
86
+ if (chosen.id === "__cancel__") {
87
+ resolve(null);
88
+ }
89
+ else if (chosen.id === "__custom__") {
90
+ promptFreeText("Enter snapshot ID:").then(id => resolve(id || null));
91
+ }
92
+ else {
93
+ resolve(chosen.id);
94
+ }
95
+ }
96
+ };
97
+ process.stdin.setRawMode(true);
98
+ process.stdin.resume();
99
+ process.stdin.setEncoding("utf8");
100
+ process.stdin.on("data", onKey);
101
+ render();
102
+ });
103
+ }
13
104
  export async function runRestore() {
14
105
  const cwd = process.cwd();
15
106
  const timeline = readTimeline(cwd);
@@ -19,23 +110,49 @@ export async function runRestore() {
19
110
  }
20
111
  // Display timeline
21
112
  section("Snapshot timeline");
22
- console.log("");
23
- const restoredTo = timeline.restoredTo;
113
+ console.log(` ${c.dim("All snapshots are always preserved — you can go back or forward freely.")}\n`);
114
+ const restoredIdx = timeline.restoredTo
115
+ ? timeline.nodes.findIndex(n => n.id === timeline.restoredTo)
116
+ : timeline.nodes.length - 1;
117
+ const latestIdx = timeline.nodes.length - 1;
24
118
  for (let i = 0; i < timeline.nodes.length; i++) {
25
119
  const node = timeline.nodes[i];
26
120
  const date = new Date(node.timestamp).toLocaleString();
27
- const isLatest = i === timeline.nodes.length - 1;
28
- const isRestored = restoredTo === node.id && !isLatest;
29
- const current = isLatest ? ` ${c.green("← current")}` : isRestored ? ` ${c.cyan("← restored here")}` : "";
30
- const connector = i < timeline.nodes.length - 1 ? "──→" : " ";
121
+ const isLatest = i === latestIdx;
122
+ const isHere = i === restoredIdx && timeline.restoredTo;
123
+ let marker = "";
124
+ let prefix = " ";
125
+ if (isHere && !isLatest) {
126
+ marker = ` ${c.cyan("◀ you are here")}`;
127
+ prefix = c.cyan("▶ ");
128
+ }
129
+ else if (isLatest && !timeline.restoredTo) {
130
+ marker = ` ${c.green("◀ you are here")}`;
131
+ prefix = c.green("▶ ");
132
+ }
133
+ else if (i > restoredIdx) {
134
+ marker = ` ${c.dim("(future — reachable)")}`;
135
+ prefix = c.dim(" ");
136
+ }
31
137
  const inputStr = node.input ? ` "${node.input}"` : "";
32
- console.log(` ${c.cyan(node.id)} ${node.command}${inputStr} ${c.dim(date)} ${node.summary}${current}`);
138
+ console.log(` ${prefix}${c.cyan(node.id)} ${node.command}${inputStr} ${c.dim(date)} ${node.summary}${marker}`);
33
139
  if (i < timeline.nodes.length - 1)
34
- console.log(` ${c.dim(connector)}`);
140
+ console.log(` ${c.dim(" ──→")}`);
35
141
  }
36
142
  console.log("");
37
- const input = await promptFreeText("Enter snapshot ID to restore (or 'cancel'):");
38
- if (!input || input === "cancel") {
143
+ const items = timeline.nodes.map((node, i) => {
144
+ const date = new Date(node.timestamp).toLocaleString();
145
+ const isLatest = i === latestIdx;
146
+ const isHere = i === restoredIdx && timeline.restoredTo;
147
+ const tag = isHere && !isLatest ? ` ${c.cyan("← you are here")}`
148
+ : isLatest && !timeline.restoredTo ? ` ${c.green("← you are here")}`
149
+ : i > restoredIdx ? ` ${c.dim("(future)")}`
150
+ : "";
151
+ return { id: node.id, label: `${node.id} ${node.command} ${c.dim(date)} ${node.summary}${tag}` };
152
+ });
153
+ console.log("Select a snapshot to restore to:\n");
154
+ const input = await promptSelectSnapshot(items);
155
+ if (!input) {
39
156
  console.log("Cancelled.");
40
157
  return;
41
158
  }
@@ -45,7 +162,7 @@ export async function runRestore() {
45
162
  return;
46
163
  }
47
164
  console.log(`\nRestoring to snapshot ${c.cyan(node.id)} (${new Date(node.timestamp).toLocaleString()})...`);
48
- console.log(`${c.dim("Other snapshots are preserved you can jump forward or back at any time.")}\n`);
165
+ console.log(`${c.dim("Config files will be rewritten to their state at this snapshot. Other files are untouched.")}\n`);
49
166
  const result = restoreSnapshot(cwd, input, timeline);
50
167
  updateRestoredNode(cwd, input);
51
168
  if (result.restored.length) {
@@ -54,6 +171,12 @@ export async function runRestore() {
54
171
  console.log(` ${c.green("✅")} ${f}`);
55
172
  }
56
173
  }
174
+ if (result.deleted.length) {
175
+ section(`Removed (added after this snapshot — ${result.deleted.length} files)`);
176
+ for (const f of result.deleted) {
177
+ console.log(` ${c.red("🗑")} ${f}`);
178
+ }
179
+ }
57
180
  if (result.failed.length) {
58
181
  section("Failed to restore");
59
182
  for (const f of result.failed) {
@@ -61,36 +184,45 @@ export async function runRestore() {
61
184
  }
62
185
  }
63
186
  if (result.stale.length) {
64
- section("Files not in this snapshot (may be stale)");
65
- console.log(` ${c.dim("These files exist now but were not part of the restored snapshot:")}`);
187
+ section("Could not delete (permission error)");
188
+ console.log(` ${c.dim("These files exist but couldn't be removed delete manually:")}`);
66
189
  for (const f of result.stale) {
67
190
  console.log(` ${c.yellow("⚠️")} ${f}`);
68
191
  }
69
- console.log(` ${c.dim("To fully reset, delete these manually or run sync to update the snapshot.")}`);
70
192
  }
71
- if (result.restored.length === 0 && result.stale.length === 0) {
72
- console.log(`\n${c.yellow("⚠️")} This snapshot captured 0 files — the project was empty at that point.`);
73
- console.log(` Files added since this snapshot have been left in place.`);
193
+ if (result.restored.length === 0 && result.deleted.length === 0) {
194
+ console.log(`\n${c.yellow("⚠️")} This snapshot captured 0 files — nothing to restore.`);
74
195
  }
75
196
  else {
76
- console.log(`\n${c.green("✅")} Restored ${result.restored.length} file(s) to snapshot ${c.cyan(node.id)}.`);
197
+ const parts = [];
198
+ if (result.restored.length)
199
+ parts.push(`${result.restored.length} restored`);
200
+ if (result.deleted.length)
201
+ parts.push(`${result.deleted.length} removed`);
202
+ console.log(`\n${c.green("✅")} ${parts.join(", ")} → project is now at snapshot ${c.cyan(node.id)}.`);
77
203
  }
78
- // Re-read and display updated timeline showing the restored position
204
+ // Re-read and display updated timeline
79
205
  const updatedTimeline = readTimeline(cwd);
206
+ const updatedRestoredIdx = updatedTimeline.restoredTo
207
+ ? updatedTimeline.nodes.findIndex(n => n.id === updatedTimeline.restoredTo)
208
+ : updatedTimeline.nodes.length - 1;
80
209
  console.log("");
81
- section("Updated timeline");
210
+ section("Timeline — you can restore to any node at any time");
82
211
  console.log("");
83
212
  for (let i = 0; i < updatedTimeline.nodes.length; i++) {
84
213
  const n = updatedTimeline.nodes[i];
85
214
  const date = new Date(n.timestamp).toLocaleString();
86
- const isRestored = updatedTimeline.restoredTo === n.id;
87
- const marker = isRestored ? ` ${c.cyan("← restored here")}` : "";
88
- const connector = i < updatedTimeline.nodes.length - 1 ? "──→" : " ";
215
+ const isHere = i === updatedRestoredIdx;
216
+ const isFuture = i > updatedRestoredIdx;
217
+ const marker = isHere ? ` ${c.cyan("◀ you are here")}` : isFuture ? ` ${c.dim("(future — reachable)")}` : "";
218
+ const prefix = isHere ? c.cyan("▶ ") : isFuture ? c.dim(" ") : " ";
89
219
  const inputStr = n.input ? ` "${n.input}"` : "";
90
- console.log(` ${c.cyan(n.id)} ${n.command}${inputStr} ${c.dim(date)} ${n.summary}${marker}`);
220
+ console.log(` ${prefix}${c.cyan(n.id)} ${n.command}${inputStr} ${c.dim(date)} ${n.summary}${marker}`);
91
221
  if (i < updatedTimeline.nodes.length - 1)
92
- console.log(` ${c.dim(connector)}`);
222
+ console.log(` ${c.dim(" ──→")}`);
93
223
  }
94
- console.log(`\nTimeline position updated → snapshot ${c.cyan(node.id)}`);
95
- console.log(`Run ${c.cyan("npx claude-setup sync")} to capture the current state as a new node.\n`);
224
+ console.log(``);
225
+ console.log(` ${c.green("▶")} Run ${c.cyan("claude")} in this directory to start working from this point.`);
226
+ console.log(` ${c.dim("Run npx claude-setup sync to save the current state as a new snapshot.")}`);
227
+ console.log(``);
96
228
  }
@@ -187,9 +187,10 @@ export async function runSync(opts = {}) {
187
187
  }
188
188
  }
189
189
  }
190
- if (!diff.added.length && !diff.changed.length && !diff.deleted.length && !oobDetected) {
190
+ const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.deleted.length > 0 || oobDetected;
191
+ if (!hasChanges) {
191
192
  console.log(`${c.green("✅")} No changes since ${c.dim(lastRun.at)}. Setup is current.`);
192
- return;
193
+ // Still regenerate the command file so /stack-sync self-refresh always gets an up-to-date "no changes" state
193
194
  }
194
195
  const state = await readState();
195
196
  const content = buildSyncCommand(diff, collected, state);
@@ -228,43 +229,43 @@ export async function runSync(opts = {}) {
228
229
  writeFileSync(".claude/commands/stack-sync.md", content, "utf8");
229
230
  await updateManifest("sync", collected, { estimatedTokens: tokens, estimatedCost: cost });
230
231
  installTokenHook();
231
- // Feature A: Create snapshot node
232
+ // Create snapshot node — collectFilesForSnapshot scans all .claude/ automatically
232
233
  const allPaths = [
233
234
  ...Object.keys(collected.configs),
234
235
  ...collected.source.map(s => s.path),
235
- ...claudeInternalFiles.map(f => f.path),
236
236
  ];
237
237
  const snapshotFiles = collectFilesForSnapshot(cwd, allPaths);
238
- const changeCount = diff.added.length + diff.changed.length + diff.deleted.length;
239
238
  createSnapshot(cwd, "sync", snapshotFiles, {
240
239
  summary: `+${diff.added.length} added, ~${diff.changed.length} modified, -${diff.deleted.length} deleted`,
241
240
  });
242
- console.log(`
241
+ if (hasChanges) {
242
+ console.log(`
243
243
  Changes since ${c.dim(lastRun.at)}:
244
244
  ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted
245
245
 
246
- ${c.green("✅")} Ready. Open Claude Code and run:
247
- ${c.cyan("/stack-sync")}
248
- `);
249
- // Token cost display
250
- section("Token cost");
251
- const realSummary = formatRealCostSummary(cwd);
252
- if (realSummary) {
253
- console.log(realSummary);
254
- console.log(` ${c.dim(`This command estimate: ~${tokens.toLocaleString()} input tokens (${formatCost(cost)})`)}`);
255
- }
256
- else {
257
- console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
258
- console.log(` ${c.dim("Estimates only — real costs tracked after first Claude Code session")}`);
246
+ ${c.green("✅")} Run ${c.cyan("/stack-sync")} in Claude Code to apply.
247
+ `);
259
248
  }
260
- // Optimization hints
261
- const runs = manifest.runs.map(r => ({ command: r.command, estimatedTokens: r.estimatedTokens }));
262
- const hints = generateHints(runs, tokens, config.tokenBudget.sync);
263
- if (hints.length) {
264
- section("Optimization hints");
265
- for (const hint of hints) {
266
- console.log(` ${c.yellow("💡")} ${hint}`);
249
+ if (hasChanges) {
250
+ // Token cost display
251
+ section("Token cost");
252
+ const realSummary = formatRealCostSummary(cwd);
253
+ if (realSummary) {
254
+ console.log(realSummary);
255
+ }
256
+ else {
257
+ console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
258
+ console.log(` ${c.dim("Estimates only — real costs tracked after first Claude Code session")}`);
267
259
  }
260
+ // Optimization hints
261
+ const runs = manifest.runs.map(r => ({ command: r.command, estimatedTokens: r.estimatedTokens }));
262
+ const hints = generateHints(runs, tokens, config.tokenBudget.sync);
263
+ if (hints.length) {
264
+ section("Optimization hints");
265
+ for (const hint of hints) {
266
+ console.log(` ${c.yellow("💡")} ${hint}`);
267
+ }
268
+ }
269
+ console.log("");
268
270
  }
269
- console.log("");
270
271
  }
package/dist/doctor.js CHANGED
@@ -375,9 +375,14 @@ export async function runDoctor(verbose = false, fix = false, testHooks = false)
375
375
  counts.warnings++;
376
376
  }
377
377
  else if (result.status === "error") {
378
+ const stderr = result.stderr ?? "";
379
+ const isFileMissing = stderr.includes("Cannot find module") || stderr.includes("MODULE_NOT_FOUND");
380
+ const hint = isFileMissing
381
+ ? `\n Hint: hook file not found — run ${c.cyan("npx claude-setup init")} to reinstall it.`
382
+ : "";
378
383
  statusLine("⚠️ ", label, c.yellow(`FAIL (exit ${result.exitCode}, ${result.timeMs}ms)\n` +
379
- ` Command: ${hook.command.slice(0, 50)}\n` +
380
- ` ${result.stderr ? `stderr: ${result.stderr.slice(0, 100)}` : ""}`));
384
+ ` Command: ${hook.command.slice(0, 60)}\n` +
385
+ ` ${stderr ? `stderr: ${stderr.slice(0, 200)}` : ""}${hint}`));
381
386
  counts.warnings++;
382
387
  }
383
388
  else if (result.status === "permission") {
@@ -409,13 +414,13 @@ export async function runDoctor(verbose = false, fix = false, testHooks = false)
409
414
  counts.healthy++;
410
415
  }
411
416
  else if (isInTemplate) {
412
- statusLine("🔴", `\${${v}}`, c.red(`NOT SET — MCP server will fail at runtime.\n` +
417
+ statusLine("🔴", `\${${v}}`, c.red(`NOT SET — MCP server will fail at runtime and won't appear in /mcp.\n` +
413
418
  ` Documented in .env.example but not loaded into environment.\n` +
414
419
  ` Fix: set ${v} in your shell or .env file, then restart Claude Code.`));
415
420
  counts.critical++;
416
421
  }
417
422
  else {
418
- statusLine("🔴", `\${${v}}`, c.red(`NOT SET — MCP server will fail at runtime.\n` +
423
+ statusLine("🔴", `\${${v}}`, c.red(`NOT SET — MCP server will fail at runtime and won't appear in /mcp.\n` +
419
424
  ` Missing from both environment and .env.example.\n` +
420
425
  ` Fix: add ${v} to .env.example and set its value in your shell or .env file.`));
421
426
  counts.critical++;
@@ -19,6 +19,7 @@ export interface SnapshotNode {
19
19
  input?: string;
20
20
  changedFiles: string[];
21
21
  summary: string;
22
+ fullSnapshot?: boolean;
22
23
  }
23
24
  export interface SnapshotTimeline {
24
25
  nodes: SnapshotNode[];
@@ -41,9 +42,14 @@ export declare function createSnapshot(cwd: string, command: string, changedFile
41
42
  summary?: string;
42
43
  }): SnapshotNode;
43
44
  /**
44
- * Build the cumulative file state at a given node by accumulating
45
- * all files from node 0 through the target node. Later nodes override
46
- * earlier ones (last-write-wins), giving the full state at that point.
45
+ * Build the complete file state at a given node.
46
+ *
47
+ * Full snapshots (fullSnapshot: true) store the entire project state used directly.
48
+ * Legacy delta snapshots accumulate from node 0 to target (last-write-wins).
49
+ *
50
+ * Why the distinction matters: with delta snapshots, if a file was deleted between A→B,
51
+ * it would wrongly appear in cumulative state at B (still present from A). Full snapshots
52
+ * avoid this because the target node's data IS the complete truth at that point.
47
53
  */
48
54
  export declare function buildCumulativeState(cwd: string, nodeId: string, timeline: SnapshotTimeline): Record<string, string> | null;
49
55
  /**
@@ -56,6 +62,7 @@ export declare function buildCumulativeState(cwd: string, nodeId: string, timeli
56
62
  export declare function restoreSnapshot(cwd: string, nodeId: string, timeline?: SnapshotTimeline): {
57
63
  restored: string[];
58
64
  failed: string[];
65
+ deleted: string[];
59
66
  stale: string[];
60
67
  };
61
68
  /**
@@ -76,10 +83,11 @@ export declare function compareSnapshots(cwd: string, nodeIdA: string, nodeIdB:
76
83
  identical: string[];
77
84
  };
78
85
  /**
79
- * Collect current file contents for snapshot.
80
- * Reads tracked files + CLI-managed files from disk.
86
+ * Collect ALL project files for snapshot — full git-like coverage.
87
+ * Respects .gitignore + hard exclusions (node_modules, .git, binaries, .env).
88
+ * The trackedPaths param is kept for API compat but ignored.
81
89
  */
82
- export declare function collectFilesForSnapshot(cwd: string, trackedPaths: string[]): Array<{
90
+ export declare function collectFilesForSnapshot(cwd: string, _trackedPaths: string[]): Array<{
83
91
  path: string;
84
92
  content: string;
85
93
  }>;
package/dist/snapshot.js CHANGED
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * Zero API calls. All local filesystem operations.
14
14
  */
15
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
16
- import { join, dirname } from "path";
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from "fs";
16
+ import { join, dirname, extname } from "path";
17
17
  import { createHash } from "crypto";
18
18
  const SNAPSHOTS_DIR = ".claude/snapshots";
19
19
  const TIMELINE_FILE = "timeline.json";
@@ -85,6 +85,7 @@ export function createSnapshot(cwd, command, changedFiles, opts = {}) {
85
85
  ...(opts.input ? { input: opts.input } : {}),
86
86
  changedFiles: changedFiles.map(f => f.path),
87
87
  summary: opts.summary ?? `${changedFiles.length} file(s) captured`,
88
+ fullSnapshot: true,
88
89
  };
89
90
  timeline.nodes.push(node);
90
91
  writeTimeline(cwd, timeline);
@@ -92,14 +93,26 @@ export function createSnapshot(cwd, command, changedFiles, opts = {}) {
92
93
  return node;
93
94
  }
94
95
  /**
95
- * Build the cumulative file state at a given node by accumulating
96
- * all files from node 0 through the target node. Later nodes override
97
- * earlier ones (last-write-wins), giving the full state at that point.
96
+ * Build the complete file state at a given node.
97
+ *
98
+ * Full snapshots (fullSnapshot: true) store the entire project state used directly.
99
+ * Legacy delta snapshots accumulate from node 0 to target (last-write-wins).
100
+ *
101
+ * Why the distinction matters: with delta snapshots, if a file was deleted between A→B,
102
+ * it would wrongly appear in cumulative state at B (still present from A). Full snapshots
103
+ * avoid this because the target node's data IS the complete truth at that point.
98
104
  */
99
105
  export function buildCumulativeState(cwd, nodeId, timeline) {
100
106
  const targetIndex = timeline.nodes.findIndex(n => n.id === nodeId);
101
107
  if (targetIndex < 0)
102
108
  return null;
109
+ const targetNode = timeline.nodes[targetIndex];
110
+ // Full snapshot: the node's own data is already the complete project state
111
+ if (targetNode.fullSnapshot) {
112
+ const data = readNodeData(cwd, nodeId);
113
+ return data ? { ...data.files } : null;
114
+ }
115
+ // Legacy delta snapshot: accumulate from beginning to target
103
116
  const cumulative = {};
104
117
  for (let i = 0; i <= targetIndex; i++) {
105
118
  const data = readNodeData(cwd, timeline.nodes[i].id);
@@ -119,12 +132,11 @@ export function buildCumulativeState(cwd, nodeId, timeline) {
119
132
  * Does NOT delete other nodes — all nodes are preserved (like git).
120
133
  */
121
134
  export function restoreSnapshot(cwd, nodeId, timeline) {
122
- // If no timeline provided, read it
123
135
  const tl = timeline ?? readTimeline(cwd);
124
- // Build cumulative state up to this node
125
136
  const cumulativeFiles = buildCumulativeState(cwd, nodeId, tl);
126
137
  if (!cumulativeFiles)
127
- return { restored: [], failed: [nodeId], stale: [] };
138
+ return { restored: [], failed: [nodeId], deleted: [], stale: [] };
139
+ // Step 1: Write all snapshot files to disk
128
140
  const restored = [];
129
141
  const failed = [];
130
142
  for (const [filePath, content] of Object.entries(cumulativeFiles)) {
@@ -140,29 +152,28 @@ export function restoreSnapshot(cwd, nodeId, timeline) {
140
152
  failed.push(filePath);
141
153
  }
142
154
  }
143
- // Detect files that exist now but weren't in the cumulative state
144
- // These are files added in later snapshots that may be stale
155
+ // Step 2: Scan the project NOW (using the just-restored .gitignore)
156
+ // and delete any file that isn't part of the snapshot.
157
+ // This makes restore a true time machine — the directory looks exactly
158
+ // like it did at this snapshot.
159
+ const rules = loadGitignoreRules(cwd); // uses restored .gitignore if it was snapshotted
160
+ const currentFiles = [];
161
+ scanProject(cwd, "", rules, currentFiles);
162
+ const deleted = [];
145
163
  const stale = [];
146
- const targetIndex = tl.nodes.findIndex(n => n.id === nodeId);
147
- if (targetIndex >= 0) {
148
- const laterNodes = tl.nodes.slice(targetIndex + 1);
149
- const allLaterFiles = new Set();
150
- for (const node of laterNodes) {
151
- const nodeData = readNodeData(cwd, node.id);
152
- if (nodeData) {
153
- for (const fp of Object.keys(nodeData.files)) {
154
- allLaterFiles.add(fp);
155
- }
156
- }
164
+ for (const f of currentFiles) {
165
+ if (cumulativeFiles[f.path])
166
+ continue; // in snapshot already restored
167
+ // Not in snapshot → delete
168
+ try {
169
+ unlinkSync(join(cwd, f.path));
170
+ deleted.push(f.path);
157
171
  }
158
- // Files in later snapshots but NOT in cumulative state at target
159
- for (const filePath of allLaterFiles) {
160
- if (!cumulativeFiles[filePath] && existsSync(join(cwd, filePath))) {
161
- stale.push(filePath);
162
- }
172
+ catch {
173
+ stale.push(f.path); // couldn't delete (permissions etc.)
163
174
  }
164
175
  }
165
- return { restored, failed, stale };
176
+ return { restored, failed, deleted, stale };
166
177
  }
167
178
  /**
168
179
  * Record the last restored node in the timeline (for display purposes).
@@ -211,40 +222,164 @@ export function compareSnapshots(cwd, nodeIdA, nodeIdB) {
211
222
  }
212
223
  return { onlyInA, onlyInB, changed, identical };
213
224
  }
214
- /**
215
- * Collect current file contents for snapshot.
216
- * Reads tracked files + CLI-managed files from disk.
217
- */
218
- export function collectFilesForSnapshot(cwd, trackedPaths) {
219
- const files = [];
220
- const seen = new Set();
221
- for (const filePath of trackedPaths) {
222
- if (filePath === "__digest__" || filePath === ".env")
223
- continue;
224
- if (seen.has(filePath))
225
- continue;
226
- const fullPath = join(cwd, filePath);
227
- if (!existsSync(fullPath))
228
- continue;
229
- try {
230
- files.push({ path: filePath, content: readFileSync(fullPath, "utf8") });
231
- seen.add(filePath);
225
+ // ── Full-project file scanner (git-like coverage) ──────────────────────
226
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file
227
+ /** Directory names always excluded (regardless of location in tree) */
228
+ const EXCLUDE_DIRS = new Set([
229
+ ".git", "node_modules",
230
+ "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".remix",
231
+ "__pycache__", "target", ".gradle", ".mvn", "vendor",
232
+ "coverage", ".nyc_output", ".c8",
233
+ ".cache", ".parcel-cache", ".turbo", ".vercel", ".netlify",
234
+ "tmp", "temp", ".tmp",
235
+ ]);
236
+ /** Relative paths always excluded */
237
+ const EXCLUDE_REL = new Set([
238
+ ".claude/snapshots",
239
+ ".claude/token-usage.json",
240
+ ".claude/claude-setup.json",
241
+ ]);
242
+ /** Filenames always excluded (sensitive or OS noise) */
243
+ const EXCLUDE_NAMES = new Set([
244
+ ".env", ".DS_Store", "Thumbs.db", "desktop.ini",
245
+ ]);
246
+ /** Binary file extensions — skip */
247
+ const BINARY_EXT = new Set([
248
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico", ".avif",
249
+ ".pdf",
250
+ ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz",
251
+ ".exe", ".dll", ".so", ".dylib", ".bin",
252
+ ".wasm",
253
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
254
+ ".mp3", ".mp4", ".wav", ".ogg", ".flac", ".avi", ".mov", ".mkv",
255
+ ".class", ".jar", ".war",
256
+ ".pyc", ".pyo", ".pyd",
257
+ ".o", ".a", ".lib",
258
+ ".db", ".sqlite", ".sqlite3",
259
+ ]);
260
+ function parseGitignoreLine(line) {
261
+ const trimmed = line.trim();
262
+ if (!trimmed || trimmed.startsWith("#"))
263
+ return null;
264
+ let pattern = trimmed;
265
+ const negated = pattern.startsWith("!");
266
+ if (negated)
267
+ pattern = pattern.slice(1);
268
+ const anchored = pattern.startsWith("/");
269
+ if (anchored)
270
+ pattern = pattern.slice(1);
271
+ const dirOnly = pattern.endsWith("/");
272
+ if (dirOnly)
273
+ pattern = pattern.slice(0, -1);
274
+ // Convert glob to regex
275
+ let regexStr = "";
276
+ for (let i = 0; i < pattern.length; i++) {
277
+ const ch = pattern[i];
278
+ if (ch === "*" && pattern[i + 1] === "*") {
279
+ if (pattern[i + 2] === "/") {
280
+ regexStr += "(?:.+/)?";
281
+ i += 2;
282
+ }
283
+ else {
284
+ regexStr += ".*";
285
+ i++;
286
+ }
287
+ }
288
+ else if (ch === "*") {
289
+ regexStr += "[^/]*";
290
+ }
291
+ else if (ch === "?") {
292
+ regexStr += "[^/]";
293
+ }
294
+ else if (".+^${}()|[]\\".includes(ch)) {
295
+ regexStr += "\\" + ch;
296
+ }
297
+ else {
298
+ regexStr += ch;
232
299
  }
233
- catch { /* skip unreadable */ }
234
300
  }
235
- // Also capture CLI-managed files if not already included
236
- const managed = ["CLAUDE.md", ".mcp.json", ".claude/settings.json"];
237
- for (const m of managed) {
238
- if (seen.has(m))
239
- continue;
240
- const fullPath = join(cwd, m);
241
- if (!existsSync(fullPath))
301
+ const full = (anchored || pattern.includes("/"))
302
+ ? `^${regexStr}(?:/.*)?$`
303
+ : `(?:^|/)${regexStr}(?:/.*)?$`;
304
+ try {
305
+ return { negated, dirOnly, regex: new RegExp(full) };
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
311
+ function loadGitignoreRules(cwd) {
312
+ try {
313
+ return readFileSync(join(cwd, ".gitignore"), "utf8")
314
+ .split("\n").map(parseGitignoreLine)
315
+ .filter((r) => r !== null);
316
+ }
317
+ catch {
318
+ return [];
319
+ }
320
+ }
321
+ function matchesAnyRule(relPath, isDir, rules) {
322
+ let excluded = false;
323
+ for (const rule of rules) {
324
+ if (rule.dirOnly && !isDir)
242
325
  continue;
243
- try {
244
- files.push({ path: m, content: readFileSync(fullPath, "utf8") });
245
- seen.add(m);
326
+ if (rule.regex.test(relPath))
327
+ excluded = !rule.negated;
328
+ }
329
+ return excluded;
330
+ }
331
+ function tryReadText(absPath) {
332
+ try {
333
+ const st = statSync(absPath);
334
+ if (!st.isFile() || st.size > MAX_FILE_BYTES)
335
+ return null;
336
+ const content = readFileSync(absPath, "utf8");
337
+ if (content.includes("\0"))
338
+ return null; // binary
339
+ return content;
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ }
345
+ function scanProject(cwd, relBase, rules, out) {
346
+ const abs = relBase ? join(cwd, relBase) : cwd;
347
+ try {
348
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
349
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
350
+ const isDir = entry.isDirectory();
351
+ // Hard excludes
352
+ if (isDir && EXCLUDE_DIRS.has(entry.name))
353
+ continue;
354
+ if (EXCLUDE_REL.has(relPath) || relPath.startsWith(".claude/snapshots/"))
355
+ continue;
356
+ if (!isDir && EXCLUDE_NAMES.has(entry.name))
357
+ continue;
358
+ if (!isDir && BINARY_EXT.has(extname(entry.name).toLowerCase()))
359
+ continue;
360
+ // Gitignore
361
+ if (matchesAnyRule(relPath, isDir, rules))
362
+ continue;
363
+ if (isDir) {
364
+ scanProject(cwd, relPath, rules, out);
365
+ }
366
+ else {
367
+ const content = tryReadText(join(cwd, relPath));
368
+ if (content !== null)
369
+ out.push({ path: relPath, content });
370
+ }
246
371
  }
247
- catch { /* skip */ }
248
372
  }
249
- return files;
373
+ catch { /* skip unreadable */ }
374
+ }
375
+ /**
376
+ * Collect ALL project files for snapshot — full git-like coverage.
377
+ * Respects .gitignore + hard exclusions (node_modules, .git, binaries, .env).
378
+ * The trackedPaths param is kept for API compat but ignored.
379
+ */
380
+ export function collectFilesForSnapshot(cwd, _trackedPaths) {
381
+ const rules = loadGitignoreRules(cwd);
382
+ const out = [];
383
+ scanProject(cwd, "", rules, out);
384
+ return out;
250
385
  }
package/dist/tokens.js CHANGED
@@ -292,8 +292,8 @@ export function readProjectSessions(projectCwd) {
292
292
  // Encode the CWD the way Claude Code does, then exact-match against project dirs.
293
293
  // This is the only reliable cross-platform approach — decoding is lossy when
294
294
  // folder names contain hyphens (e.g. "Claude-code-documentation").
295
- // Windows: C:\Users\ok\Desktop\my-app → C--Users-ok-Desktop-my-app
296
- // Unix: /Users/ok/dev/my-app → -Users-ok-dev-my-app
295
+ // Windows: C:\Users\alice\Desktop\my-app → C--Users-alice-Desktop-my-app
296
+ // Unix: /Users/alice/dev/my-app → -Users-alice-dev-my-app
297
297
  const encodedCwd = projectCwd
298
298
  .replace(/\\/g, "/") // normalize backslashes to forward slashes
299
299
  .replace(/:\//g, "--") // drive letter: C:/ → C--
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-setup",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Setup layer for Claude Code — reads your project, writes command files, Claude Code does the rest",
5
5
  "type": "module",
6
6
  "bin": {
package/templates/sync.md CHANGED
@@ -1,5 +1,9 @@
1
1
  <!-- claude-setup sync {{DATE}} | last: {{LAST_RUN_DATE}} -->
2
2
 
3
+ > **Step 1 — always do this first:** Run `npx claude-setup sync` to refresh this file with the latest diff.
4
+ > - If it outputs "No changes since…" → respond "Stack already in sync." and STOP.
5
+ > - Otherwise → re-read `.claude/commands/stack-sync.md` (Step 2), then process the diff below (Step 3).
6
+
3
7
  Project changed since last setup. Update ONLY what the changes demand.
4
8
 
5
9
  ## Changes since last setup