claude-setup 1.1.5 → 1.1.7

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/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,21 +85,35 @@ 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);
91
+ delete timeline.restoredTo; // User is at latest — clear any restore marker
90
92
  writeTimeline(cwd, timeline);
91
93
  writeNodeData(cwd, nodeId, data);
92
94
  return node;
93
95
  }
94
96
  /**
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.
97
+ * Build the complete file state at a given node.
98
+ *
99
+ * Full snapshots (fullSnapshot: true) store the entire project state used directly.
100
+ * Legacy delta snapshots accumulate from node 0 to target (last-write-wins).
101
+ *
102
+ * Why the distinction matters: with delta snapshots, if a file was deleted between A→B,
103
+ * it would wrongly appear in cumulative state at B (still present from A). Full snapshots
104
+ * avoid this because the target node's data IS the complete truth at that point.
98
105
  */
99
106
  export function buildCumulativeState(cwd, nodeId, timeline) {
100
107
  const targetIndex = timeline.nodes.findIndex(n => n.id === nodeId);
101
108
  if (targetIndex < 0)
102
109
  return null;
110
+ const targetNode = timeline.nodes[targetIndex];
111
+ // Full snapshot: the node's own data is already the complete project state
112
+ if (targetNode.fullSnapshot) {
113
+ const data = readNodeData(cwd, nodeId);
114
+ return data ? { ...data.files } : null;
115
+ }
116
+ // Legacy delta snapshot: accumulate from beginning to target
103
117
  const cumulative = {};
104
118
  for (let i = 0; i <= targetIndex; i++) {
105
119
  const data = readNodeData(cwd, timeline.nodes[i].id);
@@ -119,12 +133,11 @@ export function buildCumulativeState(cwd, nodeId, timeline) {
119
133
  * Does NOT delete other nodes — all nodes are preserved (like git).
120
134
  */
121
135
  export function restoreSnapshot(cwd, nodeId, timeline) {
122
- // If no timeline provided, read it
123
136
  const tl = timeline ?? readTimeline(cwd);
124
- // Build cumulative state up to this node
125
137
  const cumulativeFiles = buildCumulativeState(cwd, nodeId, tl);
126
138
  if (!cumulativeFiles)
127
- return { restored: [], failed: [nodeId], stale: [] };
139
+ return { restored: [], failed: [nodeId], deleted: [], stale: [] };
140
+ // Step 1: Write all snapshot files to disk
128
141
  const restored = [];
129
142
  const failed = [];
130
143
  for (const [filePath, content] of Object.entries(cumulativeFiles)) {
@@ -140,29 +153,28 @@ export function restoreSnapshot(cwd, nodeId, timeline) {
140
153
  failed.push(filePath);
141
154
  }
142
155
  }
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
156
+ // Step 2: Scan the project NOW (using the just-restored .gitignore)
157
+ // and delete any file that isn't part of the snapshot.
158
+ // This makes restore a true time machine — the directory looks exactly
159
+ // like it did at this snapshot.
160
+ const rules = loadGitignoreRules(cwd); // uses restored .gitignore if it was snapshotted
161
+ const currentFiles = [];
162
+ scanProject(cwd, "", rules, currentFiles);
163
+ const deleted = [];
145
164
  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
- }
165
+ for (const f of currentFiles) {
166
+ if (cumulativeFiles[f.path])
167
+ continue; // in snapshot already restored
168
+ // Not in snapshot → delete
169
+ try {
170
+ unlinkSync(join(cwd, f.path));
171
+ deleted.push(f.path);
157
172
  }
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
- }
173
+ catch {
174
+ stale.push(f.path); // couldn't delete (permissions etc.)
163
175
  }
164
176
  }
165
- return { restored, failed, stale };
177
+ return { restored, failed, deleted, stale };
166
178
  }
167
179
  /**
168
180
  * Record the last restored node in the timeline (for display purposes).
@@ -211,40 +223,164 @@ export function compareSnapshots(cwd, nodeIdA, nodeIdB) {
211
223
  }
212
224
  return { onlyInA, onlyInB, changed, identical };
213
225
  }
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);
226
+ // ── Full-project file scanner (git-like coverage) ──────────────────────
227
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file
228
+ /** Directory names always excluded (regardless of location in tree) */
229
+ const EXCLUDE_DIRS = new Set([
230
+ ".git", "node_modules",
231
+ "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".remix",
232
+ "__pycache__", "target", ".gradle", ".mvn", "vendor",
233
+ "coverage", ".nyc_output", ".c8",
234
+ ".cache", ".parcel-cache", ".turbo", ".vercel", ".netlify",
235
+ "tmp", "temp", ".tmp",
236
+ ]);
237
+ /** Relative paths always excluded */
238
+ const EXCLUDE_REL = new Set([
239
+ ".claude/snapshots",
240
+ ".claude/token-usage.json",
241
+ ".claude/claude-setup.json",
242
+ ]);
243
+ /** Filenames always excluded (sensitive or OS noise) */
244
+ const EXCLUDE_NAMES = new Set([
245
+ ".env", ".DS_Store", "Thumbs.db", "desktop.ini",
246
+ ]);
247
+ /** Binary file extensions — skip */
248
+ const BINARY_EXT = new Set([
249
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico", ".avif",
250
+ ".pdf",
251
+ ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz",
252
+ ".exe", ".dll", ".so", ".dylib", ".bin",
253
+ ".wasm",
254
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
255
+ ".mp3", ".mp4", ".wav", ".ogg", ".flac", ".avi", ".mov", ".mkv",
256
+ ".class", ".jar", ".war",
257
+ ".pyc", ".pyo", ".pyd",
258
+ ".o", ".a", ".lib",
259
+ ".db", ".sqlite", ".sqlite3",
260
+ ]);
261
+ function parseGitignoreLine(line) {
262
+ const trimmed = line.trim();
263
+ if (!trimmed || trimmed.startsWith("#"))
264
+ return null;
265
+ let pattern = trimmed;
266
+ const negated = pattern.startsWith("!");
267
+ if (negated)
268
+ pattern = pattern.slice(1);
269
+ const anchored = pattern.startsWith("/");
270
+ if (anchored)
271
+ pattern = pattern.slice(1);
272
+ const dirOnly = pattern.endsWith("/");
273
+ if (dirOnly)
274
+ pattern = pattern.slice(0, -1);
275
+ // Convert glob to regex
276
+ let regexStr = "";
277
+ for (let i = 0; i < pattern.length; i++) {
278
+ const ch = pattern[i];
279
+ if (ch === "*" && pattern[i + 1] === "*") {
280
+ if (pattern[i + 2] === "/") {
281
+ regexStr += "(?:.+/)?";
282
+ i += 2;
283
+ }
284
+ else {
285
+ regexStr += ".*";
286
+ i++;
287
+ }
288
+ }
289
+ else if (ch === "*") {
290
+ regexStr += "[^/]*";
291
+ }
292
+ else if (ch === "?") {
293
+ regexStr += "[^/]";
294
+ }
295
+ else if (".+^${}()|[]\\".includes(ch)) {
296
+ regexStr += "\\" + ch;
297
+ }
298
+ else {
299
+ regexStr += ch;
232
300
  }
233
- catch { /* skip unreadable */ }
234
301
  }
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))
302
+ const full = (anchored || pattern.includes("/"))
303
+ ? `^${regexStr}(?:/.*)?$`
304
+ : `(?:^|/)${regexStr}(?:/.*)?$`;
305
+ try {
306
+ return { negated, dirOnly, regex: new RegExp(full) };
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ function loadGitignoreRules(cwd) {
313
+ try {
314
+ return readFileSync(join(cwd, ".gitignore"), "utf8")
315
+ .split("\n").map(parseGitignoreLine)
316
+ .filter((r) => r !== null);
317
+ }
318
+ catch {
319
+ return [];
320
+ }
321
+ }
322
+ function matchesAnyRule(relPath, isDir, rules) {
323
+ let excluded = false;
324
+ for (const rule of rules) {
325
+ if (rule.dirOnly && !isDir)
242
326
  continue;
243
- try {
244
- files.push({ path: m, content: readFileSync(fullPath, "utf8") });
245
- seen.add(m);
327
+ if (rule.regex.test(relPath))
328
+ excluded = !rule.negated;
329
+ }
330
+ return excluded;
331
+ }
332
+ function tryReadText(absPath) {
333
+ try {
334
+ const st = statSync(absPath);
335
+ if (!st.isFile() || st.size > MAX_FILE_BYTES)
336
+ return null;
337
+ const content = readFileSync(absPath, "utf8");
338
+ if (content.includes("\0"))
339
+ return null; // binary
340
+ return content;
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ }
346
+ function scanProject(cwd, relBase, rules, out) {
347
+ const abs = relBase ? join(cwd, relBase) : cwd;
348
+ try {
349
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
350
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
351
+ const isDir = entry.isDirectory();
352
+ // Hard excludes
353
+ if (isDir && EXCLUDE_DIRS.has(entry.name))
354
+ continue;
355
+ if (EXCLUDE_REL.has(relPath) || relPath.startsWith(".claude/snapshots/"))
356
+ continue;
357
+ if (!isDir && EXCLUDE_NAMES.has(entry.name))
358
+ continue;
359
+ if (!isDir && BINARY_EXT.has(extname(entry.name).toLowerCase()))
360
+ continue;
361
+ // Gitignore
362
+ if (matchesAnyRule(relPath, isDir, rules))
363
+ continue;
364
+ if (isDir) {
365
+ scanProject(cwd, relPath, rules, out);
366
+ }
367
+ else {
368
+ const content = tryReadText(join(cwd, relPath));
369
+ if (content !== null)
370
+ out.push({ path: relPath, content });
371
+ }
246
372
  }
247
- catch { /* skip */ }
248
373
  }
249
- return files;
374
+ catch { /* skip unreadable */ }
375
+ }
376
+ /**
377
+ * Collect ALL project files for snapshot — full git-like coverage.
378
+ * Respects .gitignore + hard exclusions (node_modules, .git, binaries, .env).
379
+ * The trackedPaths param is kept for API compat but ignored.
380
+ */
381
+ export function collectFilesForSnapshot(cwd, _trackedPaths) {
382
+ const rules = loadGitignoreRules(cwd);
383
+ const out = [];
384
+ scanProject(cwd, "", rules, out);
385
+ return out;
250
386
  }
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.7",
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
@@ -34,16 +38,20 @@ Skills: {{SKILLS_LIST}} | Commands: {{COMMANDS_LIST}} | Workflows: {{WORKFLOWS_L
34
38
 
35
39
  ## Your job
36
40
 
37
- For EACH changed file: does this change have any implication for the Claude Code setup?
41
+ For EACH changed file, update the Claude Code setup:
42
+
43
+ **Source files added/removed/modified — ALWAYS update CLAUDE.md:**
44
+ - New source directories or modules → add to key dirs section
45
+ - New routes, services, controllers → document the new endpoints/patterns
46
+ - New dependencies or frameworks → update runtime section
47
+ - Renamed or restructured files → update stale paths
48
+ - CLAUDE.md must reflect the CURRENT project structure, not just config files
38
49
 
39
- Reason about the signal:
50
+ **Config and infrastructure changes:**
40
51
  - New dependency → new MCP server needed? New hook justified?
41
52
  - New docker-compose service → new MCP entry? Env vars changed?
42
- - Source file added/removed → CLAUDE.md paths stale? Skill still applies?
43
53
  - Config deleted → remove its MCP/hook reference if it was the only evidence?
44
54
 
45
- Update ONLY what the change demands.
46
- Do NOT update things that did not change.
47
55
  Do NOT rewrite files — surgical edits only.
48
56
  If unsure about a change's implication: flag it, don't guess.
49
57