claude-setup 1.1.6 → 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/builder.js CHANGED
@@ -196,8 +196,7 @@ export function buildBootstrapSync() {
196
196
 
197
197
  ## Your job
198
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.
199
+ For EACH changed file, update the Claude Code setup. New source files (routes, services, etc.) MUST be reflected in CLAUDE.md. Config changes may require .mcp.json or settings.json updates. Surgical edits only.
201
200
  `;
202
201
  }
203
202
  export function buildRemoveCommand(input, state) {
@@ -1 +1,3 @@
1
- export declare function runAdd(): Promise<void>;
1
+ export declare function runAdd(opts?: {
2
+ input?: string;
3
+ }): Promise<void>;
@@ -4,8 +4,8 @@ import { collectProjectFiles } from "../collect.js";
4
4
  import { readState } from "../state.js";
5
5
  import { updateManifest } from "../manifest.js";
6
6
  import { buildAddCommand } from "../builder.js";
7
- import { estimateTokens, estimateCost, formatCost } from "../tokens.js";
8
- import { c, section } from "../output.js";
7
+ import { estimateTokens, estimateCost } from "../tokens.js";
8
+ import { c } from "../output.js";
9
9
  function ensureDir(dir) {
10
10
  if (!existsSync(dir))
11
11
  mkdirSync(dir, { recursive: true });
@@ -19,16 +19,13 @@ async function promptFreeText(question) {
19
19
  });
20
20
  });
21
21
  }
22
- // Conservative — only redirect when unambiguously single-file
23
- // False negatives (multi-step for single-file request) are fine
24
- // False positives (redirecting a genuinely multi-file request) are bad
25
22
  function isSingleFileOperation(input) {
26
23
  return (/to \.mcp\.json\s*$/i.test(input) ||
27
24
  /to settings\.json\s*$/i.test(input) ||
28
25
  /to claude\.md\s*$/i.test(input));
29
26
  }
30
- export async function runAdd() {
31
- const userInput = await promptFreeText("What do you want to add to your Claude Code setup?");
27
+ export async function runAdd(opts = {}) {
28
+ const userInput = opts.input ?? await promptFreeText("What do you want to add to your Claude Code setup?");
32
29
  if (!userInput) {
33
30
  console.log("No input provided.");
34
31
  return;
@@ -44,10 +41,8 @@ capabilities that need documentation, MCP servers, skills, and hooks together.
44
41
  return;
45
42
  }
46
43
  const state = await readState();
47
- // add only needs config files — source files are irrelevant and waste tokens
48
44
  const collected = await collectProjectFiles(process.cwd(), "configOnly");
49
45
  const content = buildAddCommand(userInput, collected, state);
50
- // Token tracking
51
46
  const tokens = estimateTokens(content);
52
47
  const cost = estimateCost(tokens);
53
48
  ensureDir(".claude/commands");
@@ -57,8 +52,5 @@ capabilities that need documentation, MCP servers, skills, and hooks together.
57
52
  estimatedTokens: tokens,
58
53
  estimatedCost: cost,
59
54
  });
60
- console.log(`\n${c.green("✅")} Ready. Open Claude Code and run:\n ${c.cyan("/stack-add")}`);
61
- section("Token cost");
62
- console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
63
- console.log("");
55
+ console.log(`\n${c.green("✅")} Ready. Open Claude Code and run:\n ${c.cyan("/stack-add")}\n`);
64
56
  }
@@ -5,7 +5,7 @@ import { readState } from "../state.js";
5
5
  import { updateManifest } from "../manifest.js";
6
6
  import { buildEmptyProjectCommand, buildAtomicSteps, buildOrchestratorCommand, buildBootstrapSync, } from "../builder.js";
7
7
  import { createSnapshot, collectFilesForSnapshot } from "../snapshot.js";
8
- import { estimateTokens, estimateCost, formatCost, getTokenHookScript, formatRealCostSummary } from "../tokens.js";
8
+ import { estimateTokens, estimateCost, formatCost, getTokenHookScript } from "../tokens.js";
9
9
  import { c, section } from "../output.js";
10
10
  import { ensureConfig } from "../config.js";
11
11
  import { applyTemplate } from "./export.js";
@@ -13,6 +13,57 @@ function ensureDir(dir) {
13
13
  if (!existsSync(dir))
14
14
  mkdirSync(dir, { recursive: true });
15
15
  }
16
+ /** Install bootstrap slash commands so all operations work from within Claude Code */
17
+ function installBootstrapCommands(dir) {
18
+ const cmds = {
19
+ "stack-add.md": `<!-- stack-add: bootstrap — regenerated by npx claude-setup add -->
20
+
21
+ > **Step 1:** Ask the user: "What do you want to add to your Claude Code setup?"
22
+ > **Step 2:** After the user responds, run: \`npx claude-setup add "<their exact answer>"\`
23
+ > **Step 3:** Re-read this file (\`.claude/commands/stack-add.md\`) — it was just regenerated with full marketplace instructions.
24
+ > **Step 4:** Follow the instructions in the updated file.
25
+ `,
26
+ "stack-status.md": `<!-- stack-status: show project state -->
27
+
28
+ Run this command and display the output to the user:
29
+ \`\`\`bash
30
+ npx claude-setup status
31
+ \`\`\`
32
+ No further action needed — the output IS the status.
33
+ `,
34
+ "stack-doctor.md": `<!-- stack-doctor: validate environment -->
35
+
36
+ > **Step 1:** Run: \`npx claude-setup doctor\`
37
+ > **Step 2:** Show the output to the user.
38
+ > **Step 3:** If any issues are found (lines with red or warning markers), ask: "Want me to auto-fix these?"
39
+ > **Step 4:** If yes, run: \`npx claude-setup doctor --fix\`
40
+ `,
41
+ "stack-restore.md": `<!-- stack-restore: time-travel to any snapshot -->
42
+
43
+ > **Step 1:** Run: \`npx claude-setup restore --list\` to show the snapshot timeline.
44
+ > **Step 2:** Show the timeline to the user and ask: "Which snapshot do you want to restore to? (enter the ID)"
45
+ > **Step 3:** After the user picks an ID, run: \`npx claude-setup restore --id "<snapshot-id>"\`
46
+ > **Step 4:** Show the result.
47
+ `,
48
+ "stack-remove.md": `<!-- stack-remove: bootstrap — regenerated by npx claude-setup remove -->
49
+
50
+ > **Step 1:** Ask the user: "What do you want to remove from your Claude Code setup?"
51
+ > **Step 2:** After the user responds, run: \`npx claude-setup remove "<their exact answer>"\`
52
+ > **Step 3:** Re-read this file (\`.claude/commands/stack-remove.md\`) — it was just regenerated.
53
+ > **Step 4:** Follow the instructions in the updated file.
54
+ `,
55
+ };
56
+ for (const [filename, content] of Object.entries(cmds)) {
57
+ const filepath = join(dir, filename);
58
+ // Don't overwrite if already populated with real content (e.g., after an add/remove run)
59
+ if (existsSync(filepath)) {
60
+ const existing = readFileSync(filepath, "utf8");
61
+ if (!existing.includes("bootstrap"))
62
+ continue;
63
+ }
64
+ writeFileSync(filepath, content, "utf8");
65
+ }
66
+ }
16
67
  function installTokenHook(cwd = process.cwd()) {
17
68
  // Write the hook script
18
69
  const hooksDir = join(cwd, ".claude", "hooks");
@@ -80,6 +131,7 @@ export async function runInit(opts = {}) {
80
131
  ensureDir(".claude/commands");
81
132
  writeFileSync(".claude/commands/stack-init.md", content, "utf8");
82
133
  writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
134
+ installBootstrapCommands(".claude/commands");
83
135
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
84
136
  installTokenHook();
85
137
  // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
@@ -94,16 +146,6 @@ Open Claude Code and run:
94
146
 
95
147
  Claude Code will ask 3 questions, then set up your environment.
96
148
  `);
97
- section("Token cost");
98
- const realSummary1 = formatRealCostSummary(cwd);
99
- if (realSummary1) {
100
- console.log(realSummary1);
101
- }
102
- else {
103
- console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
104
- console.log(` ${c.dim("Estimates only — real costs tracked after first Claude Code session")}`);
105
- }
106
- console.log("");
107
149
  return;
108
150
  }
109
151
  // Standard init — atomic steps + orchestrator
@@ -131,6 +173,7 @@ Claude Code will ask 3 questions, then set up your environment.
131
173
  }
132
174
  writeFileSync(".claude/commands/stack-init.md", orchestrator, "utf8");
133
175
  writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
176
+ installBootstrapCommands(".claude/commands");
134
177
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
135
178
  installTokenHook();
136
179
  // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
@@ -145,14 +188,4 @@ ${c.green("✅")} Ready. Open Claude Code and run:
145
188
 
146
189
  Runs ${steps.length - 1} atomic steps. If one fails, re-run only that step.
147
190
  `);
148
- section("Token cost");
149
- const realSummary2 = formatRealCostSummary(cwd);
150
- if (realSummary2) {
151
- console.log(realSummary2);
152
- }
153
- else {
154
- console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
155
- console.log(` ${c.dim("Estimates only — real costs tracked after first Claude Code session")}`);
156
- }
157
- console.log("");
158
191
  }
@@ -1 +1,3 @@
1
- export declare function runRemove(): Promise<void>;
1
+ export declare function runRemove(opts?: {
2
+ input?: string;
3
+ }): Promise<void>;
@@ -4,8 +4,8 @@ import { collectProjectFiles } from "../collect.js";
4
4
  import { readState } from "../state.js";
5
5
  import { updateManifest } from "../manifest.js";
6
6
  import { buildRemoveCommand } from "../builder.js";
7
- import { estimateTokens, estimateCost, formatCost } from "../tokens.js";
8
- import { c, section } from "../output.js";
7
+ import { estimateTokens, estimateCost } from "../tokens.js";
8
+ import { c } from "../output.js";
9
9
  function ensureDir(dir) {
10
10
  if (!existsSync(dir))
11
11
  mkdirSync(dir, { recursive: true });
@@ -19,8 +19,8 @@ async function promptFreeText(question) {
19
19
  });
20
20
  });
21
21
  }
22
- export async function runRemove() {
23
- const userInput = await promptFreeText("What do you want to remove from your Claude Code setup?");
22
+ export async function runRemove(opts = {}) {
23
+ const userInput = opts.input ?? await promptFreeText("What do you want to remove from your Claude Code setup?");
24
24
  if (!userInput) {
25
25
  console.log("No input provided.");
26
26
  return;
@@ -28,7 +28,6 @@ export async function runRemove() {
28
28
  const state = await readState();
29
29
  const collected = await collectProjectFiles(process.cwd(), "configOnly");
30
30
  const content = buildRemoveCommand(userInput, state);
31
- // Token tracking
32
31
  const tokens = estimateTokens(content);
33
32
  const cost = estimateCost(tokens);
34
33
  ensureDir(".claude/commands");
@@ -38,8 +37,5 @@ export async function runRemove() {
38
37
  estimatedTokens: tokens,
39
38
  estimatedCost: cost,
40
39
  });
41
- console.log(`\n${c.green("✅")} Ready. Open Claude Code and run:\n ${c.cyan("/stack-remove")}`);
42
- section("Token cost");
43
- console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`${formatCost(cost)}`)})`);
44
- console.log("");
40
+ console.log(`\n${c.green("✅")} Ready. Open Claude Code and run:\n ${c.cyan("/stack-remove")}\n`);
45
41
  }
@@ -1 +1,4 @@
1
- export declare function runRestore(): Promise<void>;
1
+ export declare function runRestore(opts?: {
2
+ list?: boolean;
3
+ id?: string;
4
+ }): Promise<void>;
@@ -101,20 +101,58 @@ async function promptSelectSnapshot(items) {
101
101
  render();
102
102
  });
103
103
  }
104
- export async function runRestore() {
104
+ export async function runRestore(opts = {}) {
105
105
  const cwd = process.cwd();
106
106
  const timeline = readTimeline(cwd);
107
107
  if (!timeline.nodes.length) {
108
108
  console.log(`${c.yellow("⚠️")} No snapshots found. Run ${c.cyan("npx claude-setup sync")} to create one.`);
109
109
  return;
110
110
  }
111
- // Display timeline
112
- section("Snapshot timeline");
113
- console.log(` ${c.dim("All snapshots are always preserved — you can go back or forward freely.")}\n`);
114
111
  const restoredIdx = timeline.restoredTo
115
112
  ? timeline.nodes.findIndex(n => n.id === timeline.restoredTo)
116
113
  : timeline.nodes.length - 1;
117
114
  const latestIdx = timeline.nodes.length - 1;
115
+ // --list: just print timeline and exit (for use by Claude Code slash commands)
116
+ if (opts.list) {
117
+ for (let i = 0; i < timeline.nodes.length; i++) {
118
+ const node = timeline.nodes[i];
119
+ const date = new Date(node.timestamp).toLocaleString();
120
+ const isLatest = i === latestIdx;
121
+ const isHere = i === restoredIdx && timeline.restoredTo;
122
+ const marker = (isHere && !isLatest) ? " ← current"
123
+ : (isLatest && !timeline.restoredTo) ? " ← current"
124
+ : "";
125
+ const inputStr = node.input ? ` "${node.input}"` : "";
126
+ console.log(`${node.id} ${node.command}${inputStr} ${date} ${node.summary}${marker}`);
127
+ }
128
+ return;
129
+ }
130
+ // --id: restore directly to a specific snapshot (for use by Claude Code slash commands)
131
+ if (opts.id) {
132
+ const node = timeline.nodes.find(n => n.id === opts.id);
133
+ if (!node) {
134
+ console.log(`${c.red("🔴")} Snapshot "${opts.id}" not found.`);
135
+ return;
136
+ }
137
+ console.log(`Restoring to snapshot ${c.cyan(node.id)}...`);
138
+ const result = restoreSnapshot(cwd, opts.id, timeline);
139
+ updateRestoredNode(cwd, opts.id);
140
+ const parts = [];
141
+ if (result.restored.length)
142
+ parts.push(`${result.restored.length} restored`);
143
+ if (result.deleted.length)
144
+ parts.push(`${result.deleted.length} removed`);
145
+ if (parts.length) {
146
+ console.log(`${c.green("✅")} ${parts.join(", ")} → project at snapshot ${c.cyan(node.id)}.`);
147
+ }
148
+ else {
149
+ console.log(`${c.yellow("⚠️")} Snapshot captured 0 files — nothing to restore.`);
150
+ }
151
+ return;
152
+ }
153
+ // Interactive mode — display timeline
154
+ section("Snapshot timeline");
155
+ console.log(` ${c.dim("All snapshots are always preserved — you can go back or forward freely.")}\n`);
118
156
  for (let i = 0; i < timeline.nodes.length; i++) {
119
157
  const node = timeline.nodes[i];
120
158
  const date = new Date(node.timestamp).toLocaleString();
@@ -1,12 +1,11 @@
1
1
  import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
- import { glob } from "glob";
4
3
  import { collectProjectFiles } from "../collect.js";
5
4
  import { readState } from "../state.js";
6
5
  import { readManifest, sha256, updateManifest } from "../manifest.js";
7
6
  import { buildSyncCommand } from "../builder.js";
8
- import { createSnapshot, collectFilesForSnapshot } from "../snapshot.js";
9
- import { estimateTokens, estimateCost, formatCost, formatTokenReport, buildTokenEstimate, generateHints, getTokenHookScript, formatRealCostSummary } from "../tokens.js";
7
+ import { createSnapshot, collectFilesForSnapshot, readTimeline, readNodeData } from "../snapshot.js";
8
+ import { estimateTokens, estimateCost, formatTokenReport, buildTokenEstimate, getTokenHookScript } from "../tokens.js";
10
9
  import { loadConfig } from "../config.js";
11
10
  import { c, section } from "../output.js";
12
11
  function ensureDir(dir) {
@@ -14,12 +13,10 @@ function ensureDir(dir) {
14
13
  mkdirSync(dir, { recursive: true });
15
14
  }
16
15
  function installTokenHook(cwd = process.cwd()) {
17
- // Write the hook script
18
16
  const hooksDir = join(cwd, ".claude", "hooks");
19
17
  if (!existsSync(hooksDir))
20
18
  mkdirSync(hooksDir, { recursive: true });
21
19
  writeFileSync(join(hooksDir, "track-tokens.cjs"), getTokenHookScript(), "utf8");
22
- // Merge Stop hook into settings.json
23
20
  const settingsPath = join(cwd, ".claude", "settings.json");
24
21
  let settings = {};
25
22
  if (existsSync(settingsPath)) {
@@ -31,13 +28,11 @@ function installTokenHook(cwd = process.cwd()) {
31
28
  const hookEntry = {
32
29
  hooks: [{ type: "command", command: "node \".claude/hooks/track-tokens.cjs\"" }]
33
30
  };
34
- // Merge into settings.hooks.Stop
35
31
  if (!settings.hooks)
36
32
  settings.hooks = {};
37
33
  const hooks = settings.hooks;
38
34
  if (!Array.isArray(hooks.Stop))
39
35
  hooks.Stop = [];
40
- // Only add if not already present
41
36
  const alreadyPresent = hooks.Stop.some(e => Array.isArray(e.hooks) && e.hooks.some((h) => typeof h.command === "string" && h.command.includes("track-tokens")));
42
37
  if (!alreadyPresent) {
43
38
  hooks.Stop.push(hookEntry);
@@ -51,7 +46,11 @@ function truncate(content, maxChars) {
51
46
  return content;
52
47
  return content.slice(0, maxChars) + "\n[... truncated for sync diff]";
53
48
  }
54
- function computeDiff(snapshot, collected, cwd) {
49
+ /**
50
+ * Legacy diff — compares manifest hashes against collected files.
51
+ * Only used when no snapshot data is available (e.g., old projects).
52
+ */
53
+ function computeLegacyDiff(snapshot, collected, cwd) {
55
54
  const current = {
56
55
  ...collected.configs,
57
56
  ...Object.fromEntries(collected.source.map(f => [f.path, f.content])),
@@ -60,7 +59,6 @@ function computeDiff(snapshot, collected, cwd) {
60
59
  const changed = [];
61
60
  const deleted = [];
62
61
  for (const [path, content] of Object.entries(current)) {
63
- // Skip virtual keys — they're not real files
64
62
  if (path === "__digest__")
65
63
  continue;
66
64
  const hash = sha256(content);
@@ -71,57 +69,58 @@ function computeDiff(snapshot, collected, cwd) {
71
69
  changed.push({ path, current: truncate(content, 2000) });
72
70
  }
73
71
  }
74
- // BUG 1 FIX: Verify file existence on disk before reporting deletions.
75
- // Files may appear "deleted" because they weren't in the current collection set
76
- // (different collect mode, or CLI-managed files like CLAUDE.md/settings.json).
77
- // If the file still exists on disk, it was "modified outside the CLI", not deleted.
78
72
  for (const path of Object.keys(snapshot)) {
79
- // Skip virtual keys
80
73
  if (path === "__digest__")
81
74
  continue;
82
75
  if (!current[path]) {
83
- // Check if file actually exists on disk
84
76
  const fullPath = join(cwd, path);
85
77
  if (existsSync(fullPath)) {
86
- // File exists but wasn't in our collection — it was modified outside CLI
87
- // Read it and check if its hash changed
88
78
  try {
89
79
  const diskContent = readFileSync(fullPath, "utf8");
90
80
  const diskHash = sha256(diskContent);
91
81
  if (snapshot[path] !== diskHash) {
92
82
  changed.push({ path, current: truncate(diskContent, 2000) });
93
83
  }
94
- // If hash matches, file is unchanged — don't report anything
95
84
  }
96
85
  catch {
97
- // Can't read — treat as changed
98
86
  changed.push({ path, current: "[file exists but could not be read]" });
99
87
  }
100
88
  }
101
89
  else {
102
- // File genuinely does not exist on disk — truly deleted
103
90
  deleted.push(path);
104
91
  }
105
92
  }
106
93
  }
107
94
  return { added, changed, deleted };
108
95
  }
109
- async function collectClaudeInternalFiles(cwd) {
110
- const files = [];
111
- try {
112
- const skillFiles = await glob(".claude/skills/**/*.md", { cwd, posix: true });
113
- const allCmds = await glob(".claude/commands/*.md", { cwd, posix: true });
114
- const commandFiles = allCmds.filter(f => !f.split("/").pop().startsWith("stack-"));
115
- for (const f of [...skillFiles, ...commandFiles]) {
116
- try {
117
- const content = readFileSync(join(cwd, f), "utf8");
118
- files.push({ path: f, content });
96
+ /**
97
+ * Full-scan diff — compares every file on disk against a reference snapshot.
98
+ * This is the authoritative diff: catches ALL file changes, no sampling.
99
+ */
100
+ function computeFullDiff(currentFiles, referenceFiles) {
101
+ const added = [];
102
+ const changed = [];
103
+ const deleted = [];
104
+ const currentPathSet = new Set();
105
+ for (const f of currentFiles) {
106
+ currentPathSet.add(f.path);
107
+ if (!referenceFiles[f.path]) {
108
+ added.push({ path: f.path, content: truncate(f.content, 2000) });
109
+ }
110
+ else {
111
+ const currentHash = sha256(f.content);
112
+ const refHash = sha256(referenceFiles[f.path]);
113
+ if (currentHash !== refHash) {
114
+ changed.push({ path: f.path, current: truncate(f.content, 2000) });
119
115
  }
120
- catch { /* skip unreadable */ }
121
116
  }
122
117
  }
123
- catch { /* skip */ }
124
- return files;
118
+ for (const path of Object.keys(referenceFiles)) {
119
+ if (!currentPathSet.has(path)) {
120
+ deleted.push(path);
121
+ }
122
+ }
123
+ return { added, changed, deleted };
125
124
  }
126
125
  export async function runSync(opts = {}) {
127
126
  const dryRun = opts.dryRun ?? false;
@@ -133,11 +132,10 @@ export async function runSync(opts = {}) {
133
132
  const lastRun = manifest.runs.at(-1);
134
133
  const cwd = process.cwd();
135
134
  const config = loadConfig(cwd);
136
- // Apply --budget override if provided
137
135
  if (opts.budget) {
138
136
  config.tokenBudget.sync = opts.budget;
139
137
  }
140
- // --- Out-of-band edit detection ---
138
+ // --- Out-of-band edit detection (early warning) ---
141
139
  const managedFiles = [
142
140
  { label: "CLAUDE.md", path: join(cwd, "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
143
141
  { label: ".mcp.json", path: join(cwd, ".mcp.json"), snapshotKey: ".mcp.json" },
@@ -161,40 +159,39 @@ export async function runSync(opts = {}) {
161
159
  }
162
160
  if (oobDetected)
163
161
  console.log("");
164
- const collected = await collectProjectFiles(cwd, "normal");
165
- const diff = computeDiff(lastRun.snapshot, collected, cwd);
166
- // Bug 3 fix: Also detect changes inside .claude/ (skills, commands)
167
- const claudeInternalFiles = await collectClaudeInternalFiles(cwd);
168
- for (const f of claudeInternalFiles) {
169
- const hash = sha256(f.content);
170
- if (!lastRun.snapshot[f.path]) {
171
- diff.added.push({ path: f.path, content: truncate(f.content, 2000) });
172
- }
173
- else if (lastRun.snapshot[f.path] !== hash) {
174
- diff.changed.push({ path: f.path, current: truncate(f.content, 2000) });
175
- }
162
+ // --- Full project scan (single scan, used for both diff and snapshot) ---
163
+ const currentFiles = collectFilesForSnapshot(cwd, []);
164
+ // --- Determine reference snapshot ---
165
+ // After restore: compare against the restored-to snapshot
166
+ // Normal: compare against the latest snapshot
167
+ const timeline = readTimeline(cwd);
168
+ const referenceNodeId = timeline.restoredTo ?? timeline.nodes.at(-1)?.id;
169
+ let referenceFiles = null;
170
+ if (referenceNodeId) {
171
+ const data = readNodeData(cwd, referenceNodeId);
172
+ if (data)
173
+ referenceFiles = data.files;
176
174
  }
177
- // Also detect deleted .claude/ files (were in snapshot but no longer exist)
178
- for (const path of Object.keys(lastRun.snapshot)) {
179
- if ((path.startsWith(".claude/skills/") || (path.startsWith(".claude/commands/") && !path.split("/").pop().startsWith("stack-"))) && !path.includes("__digest__")) {
180
- const alreadyInDiff = diff.added.some(f => f.path === path) ||
181
- diff.changed.some(f => f.path === path) ||
182
- diff.deleted.includes(path);
183
- if (!alreadyInDiff && !claudeInternalFiles.some(f => f.path === path)) {
184
- if (!existsSync(join(cwd, path))) {
185
- diff.deleted.push(path);
186
- }
187
- }
188
- }
175
+ // --- Compute diff ---
176
+ let diff;
177
+ if (referenceFiles) {
178
+ // Full-scan comparison (authoritative catches ALL changes)
179
+ diff = computeFullDiff(currentFiles, referenceFiles);
180
+ }
181
+ else {
182
+ // Legacy fallback — no snapshot data available
183
+ const collected = await collectProjectFiles(cwd, "normal");
184
+ diff = computeLegacyDiff(lastRun.snapshot, collected, cwd);
189
185
  }
190
186
  const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.deleted.length > 0 || oobDetected;
191
187
  if (!hasChanges) {
192
188
  console.log(`${c.green("✅")} No changes since ${c.dim(lastRun.at)}. Setup is current.`);
193
- // Still regenerate the command file so /stack-sync self-refresh always gets an up-to-date "no changes" state
189
+ return;
194
190
  }
191
+ // --- Build sync command (needs collected project context for template) ---
192
+ const collected = await collectProjectFiles(cwd, "normal");
195
193
  const state = await readState();
196
194
  const content = buildSyncCommand(diff, collected, state);
197
- // Token tracking
198
195
  const tokens = estimateTokens(content);
199
196
  const cost = estimateCost(tokens);
200
197
  if (dryRun) {
@@ -215,57 +212,23 @@ export async function runSync(opts = {}) {
215
212
  console.log(` ${f}`);
216
213
  }
217
214
  console.log(`\n Would write: .claude/commands/stack-sync.md (~${tokens.toLocaleString()} tokens)`);
218
- // Token cost display
219
215
  section("Token cost estimate");
220
216
  const estimate = buildTokenEstimate([{ label: "sync command", content }]);
221
217
  console.log(formatTokenReport(estimate));
222
218
  return;
223
219
  }
224
- // Add .claude/ internal files to snapshot
225
- for (const f of claudeInternalFiles) {
226
- collected.configs[f.path] = f.content;
227
- }
228
220
  ensureDir(".claude/commands");
229
221
  writeFileSync(".claude/commands/stack-sync.md", content, "utf8");
230
222
  await updateManifest("sync", collected, { estimatedTokens: tokens, estimatedCost: cost });
231
223
  installTokenHook();
232
- // Create snapshot node collectFilesForSnapshot scans all .claude/ automatically
233
- const allPaths = [
234
- ...Object.keys(collected.configs),
235
- ...collected.source.map(s => s.path),
236
- ];
237
- const snapshotFiles = collectFilesForSnapshot(cwd, allPaths);
238
- createSnapshot(cwd, "sync", snapshotFiles, {
224
+ // Create snapshot — reuse the full scan data (no second scan needed)
225
+ createSnapshot(cwd, "sync", currentFiles, {
239
226
  summary: `+${diff.added.length} added, ~${diff.changed.length} modified, -${diff.deleted.length} deleted`,
240
227
  });
241
- if (hasChanges) {
242
- console.log(`
228
+ console.log(`
243
229
  Changes since ${c.dim(lastRun.at)}:
244
230
  ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted
245
231
 
246
232
  ${c.green("✅")} Run ${c.cyan("/stack-sync")} in Claude Code to apply.
247
- `);
248
- }
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")}`);
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("");
270
- }
233
+ `);
271
234
  }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { createInterface } from "readline";
3
4
  import { createRequire } from "module";
4
5
  import { runInit } from "./commands/init.js";
5
6
  import { runAdd } from "./commands/add.js";
@@ -10,6 +11,7 @@ import { runRemove } from "./commands/remove.js";
10
11
  import { runRestore } from "./commands/restore.js";
11
12
  import { runCompare } from "./commands/compare.js";
12
13
  import { runExport } from "./commands/export.js";
14
+ import { c } from "./output.js";
13
15
  const require = createRequire(import.meta.url);
14
16
  const pkg = require("../package.json");
15
17
  const program = new Command();
@@ -24,9 +26,9 @@ program
24
26
  .option("--template <path>", "Apply a template instead of scanning (local path or URL)")
25
27
  .action((opts) => runInit({ dryRun: opts.dryRun, template: opts.template }));
26
28
  program
27
- .command("add")
29
+ .command("add [input...]")
28
30
  .description("Add a multi-file capability")
29
- .action(runAdd);
31
+ .action((input) => runAdd({ input: input?.length ? input.join(" ") : undefined }));
30
32
  program
31
33
  .command("sync")
32
34
  .description("Update setup after project changes")
@@ -49,23 +51,51 @@ program
49
51
  testHooks: opts.testHooks,
50
52
  }));
51
53
  program
52
- .command("remove")
54
+ .command("remove [input...]")
53
55
  .description("Remove a capability cleanly")
54
- .action(runRemove);
55
- // Feature A: Time-travel snapshot commands
56
+ .action((input) => runRemove({ input: input?.length ? input.join(" ") : undefined }));
56
57
  program
57
58
  .command("restore")
58
59
  .description("Jump to any snapshot node, restore files to that state")
59
- .action(runRestore);
60
+ .option("--list", "Show snapshot timeline without prompting")
61
+ .option("--id <snapshotId>", "Restore directly to a specific snapshot ID")
62
+ .action((opts) => runRestore({ list: opts.list, id: opts.id }));
60
63
  program
61
64
  .command("compare")
62
65
  .description("Diff between any two snapshot nodes to see what changed")
63
66
  .action(runCompare);
64
- // Feature H: Config template export
65
67
  program
66
68
  .command("export")
67
69
  .description("Save current project config as a reusable template")
68
70
  .action(runExport);
69
- // Default action when no command given
70
- program.action(() => runInit({}));
71
+ // Default action — interactive menu when no command given
72
+ program.action(async () => {
73
+ const choices = [
74
+ { key: "1", label: "init", desc: "Full project setup", run: () => runInit({}) },
75
+ { key: "2", label: "add", desc: "Add a capability", run: () => runAdd({}) },
76
+ { key: "3", label: "sync", desc: "Update after changes", run: () => runSync({}) },
77
+ { key: "4", label: "status", desc: "Show current state", run: () => runStatus() },
78
+ { key: "5", label: "doctor", desc: "Validate environment", run: () => runDoctorCommand({}) },
79
+ { key: "6", label: "restore", desc: "Time-travel to snapshot", run: () => runRestore({}) },
80
+ { key: "7", label: "compare", desc: "Diff between snapshots", run: () => runCompare() },
81
+ { key: "8", label: "remove", desc: "Remove a capability", run: () => runRemove({}) },
82
+ { key: "9", label: "export", desc: "Save as template", run: () => runExport() },
83
+ ];
84
+ console.log(`\n${c.bold("Claude Setup")} ${c.dim(`v${pkg.version}`)}\n`);
85
+ for (const ch of choices) {
86
+ console.log(` ${c.cyan(ch.key)} ${ch.label.padEnd(10)} ${c.dim(ch.desc)}`);
87
+ }
88
+ console.log("");
89
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
90
+ const answer = await new Promise(resolve => {
91
+ rl.question(` ${c.bold("Choose (1-9):")} `, a => { rl.close(); resolve(a.trim()); });
92
+ });
93
+ const choice = choices.find(ch => ch.key === answer || ch.label === answer.toLowerCase());
94
+ if (!choice) {
95
+ console.log(`\n Invalid choice. Run ${c.cyan("npx claude-setup <command>")} or pick 1-9.\n`);
96
+ return;
97
+ }
98
+ console.log("");
99
+ await choice.run();
100
+ });
71
101
  program.parse();
@@ -138,11 +138,12 @@ export function buildMarketplaceInstructions(input) {
138
138
  lines.push(``);
139
139
  lines.push(`NOTE: /plugin marketplace add FAILS for this repo (one entry has broken schema).`);
140
140
  lines.push(`Use the DIRECT FETCH approach below instead — no marketplace add needed.`);
141
+ lines.push(`If the curl command fails or returns an error, skip to STEP 3 immediately — do NOT stop.`);
141
142
  lines.push(``);
142
143
  lines.push(`**2a. Fetch catalog and find matching plugin:**`);
143
144
  lines.push(`\`\`\`bash`);
144
145
  lines.push(`curl -s "${MARKETPLACE_CATALOG_URL}" \\`);
145
- lines.push(` | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));const q='${categoryFilter}';const r=d.plugins.filter(p=>(!q||p.category.includes(q))&&p.name&&p.source).slice(0,5).map(p=>({name:p.name,source:p.source,desc:p.description}));console.log(JSON.stringify(r,null,2));"`);
146
+ lines.push(` | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const q='${categoryFilter}';const r=d.plugins.filter(p=>(q===''||p.category.includes(q))&&p.name&&p.source).slice(0,10).map(p=>({name:p.name,source:p.source,desc:p.description}));console.log(JSON.stringify(r,null,2));"`);
146
147
  lines.push(`\`\`\``);
147
148
  lines.push(``);
148
149
  lines.push(`**2b. Pick the best match — get its source path (e.g. \`./plugins/productivity/my-skill\`)**`);
@@ -152,7 +153,7 @@ export function buildMarketplaceInstructions(input) {
152
153
  lines.push(`# Replace PLUGIN_SOURCE_PATH with value from step 2b (e.g. plugins/productivity/my-skill)`);
153
154
  lines.push(`PLUGIN_SOURCE_PATH="plugins/productivity/my-skill"`);
154
155
  lines.push(`curl -s "https://api.github.com/repos/${MARKETPLACE_REPO}/contents/\${PLUGIN_SOURCE_PATH}/skills" \\`);
155
- lines.push(` | node -e "const a=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(a.map(x=>x.name).join('\\n'));"`);
156
+ lines.push(` | node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(a.map(x=>x.name).join('\\n'));"`);
156
157
  lines.push(`\`\`\``);
157
158
  lines.push(``);
158
159
  lines.push(`**2d. For each skill listed, download and install it:**`);
package/dist/snapshot.js CHANGED
@@ -88,6 +88,7 @@ export function createSnapshot(cwd, command, changedFiles, opts = {}) {
88
88
  fullSnapshot: true,
89
89
  };
90
90
  timeline.nodes.push(node);
91
+ delete timeline.restoredTo; // User is at latest — clear any restore marker
91
92
  writeTimeline(cwd, timeline);
92
93
  writeNodeData(cwd, nodeId, data);
93
94
  return node;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-setup",
3
- "version": "1.1.6",
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
@@ -38,16 +38,20 @@ Skills: {{SKILLS_LIST}} | Commands: {{COMMANDS_LIST}} | Workflows: {{WORKFLOWS_L
38
38
 
39
39
  ## Your job
40
40
 
41
- 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
42
 
43
- Reason about the signal:
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
49
+
50
+ **Config and infrastructure changes:**
44
51
  - New dependency → new MCP server needed? New hook justified?
45
52
  - New docker-compose service → new MCP entry? Env vars changed?
46
- - Source file added/removed → CLAUDE.md paths stale? Skill still applies?
47
53
  - Config deleted → remove its MCP/hook reference if it was the only evidence?
48
54
 
49
- Update ONLY what the change demands.
50
- Do NOT update things that did not change.
51
55
  Do NOT rewrite files — surgical edits only.
52
56
  If unsure about a change's implication: flag it, don't guess.
53
57