claude-setup 1.1.6 → 1.1.8

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.
@@ -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,64 @@ 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 marketplace install instructions in the updated file. Do NOT ask clarifying questions — go straight to searching and installing.
25
+
26
+ **IMPORTANT — marketplace-first approach:**
27
+ - After Step 3, the regenerated file contains a 4-step marketplace search flow. Follow it AUTOMATICALLY.
28
+ - Do NOT ask the user "do you mean X or Y?" — search the marketplace for ALL interpretations.
29
+ - Do NOT suggest manual installation steps — execute the curl/install commands yourself.
30
+ - The goal is ZERO friction: user says what they want → you search → you install → done.
31
+ - Only ask the user if the marketplace returns multiple equally-valid matches and you need to pick one.
32
+ `,
33
+ "stack-status.md": `<!-- stack-status: show project state -->
34
+
35
+ Run this command and display the output to the user:
36
+ \`\`\`bash
37
+ npx claude-setup status
38
+ \`\`\`
39
+ No further action needed — the output IS the status.
40
+ `,
41
+ "stack-doctor.md": `<!-- stack-doctor: validate environment -->
42
+
43
+ > **Step 1:** Run: \`npx claude-setup doctor\`
44
+ > **Step 2:** Show the output to the user.
45
+ > **Step 3:** If any issues are found (lines with red or warning markers), ask: "Want me to auto-fix these?"
46
+ > **Step 4:** If yes, run: \`npx claude-setup doctor --fix\`
47
+ `,
48
+ "stack-restore.md": `<!-- stack-restore: time-travel to any snapshot -->
49
+
50
+ > **Step 1:** Run: \`npx claude-setup restore --list\` to show the snapshot timeline.
51
+ > **Step 2:** Show the timeline to the user and ask: "Which snapshot do you want to restore to? (enter the ID)"
52
+ > **Step 3:** After the user picks an ID, run: \`npx claude-setup restore --id "<snapshot-id>"\`
53
+ > **Step 4:** Show the result.
54
+ `,
55
+ "stack-remove.md": `<!-- stack-remove: bootstrap — regenerated by npx claude-setup remove -->
56
+
57
+ > **Step 1:** Ask the user: "What do you want to remove from your Claude Code setup?"
58
+ > **Step 2:** After the user responds, run: \`npx claude-setup remove "<their exact answer>"\`
59
+ > **Step 3:** Re-read this file (\`.claude/commands/stack-remove.md\`) — it was just regenerated.
60
+ > **Step 4:** Follow the instructions in the updated file.
61
+ `,
62
+ };
63
+ for (const [filename, content] of Object.entries(cmds)) {
64
+ const filepath = join(dir, filename);
65
+ // Don't overwrite if already populated with real content (e.g., after an add/remove run)
66
+ if (existsSync(filepath)) {
67
+ const existing = readFileSync(filepath, "utf8");
68
+ if (!existing.includes("bootstrap"))
69
+ continue;
70
+ }
71
+ writeFileSync(filepath, content, "utf8");
72
+ }
73
+ }
16
74
  function installTokenHook(cwd = process.cwd()) {
17
75
  // Write the hook script
18
76
  const hooksDir = join(cwd, ".claude", "hooks");
@@ -80,6 +138,7 @@ export async function runInit(opts = {}) {
80
138
  ensureDir(".claude/commands");
81
139
  writeFileSync(".claude/commands/stack-init.md", content, "utf8");
82
140
  writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
141
+ installBootstrapCommands(".claude/commands");
83
142
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
84
143
  installTokenHook();
85
144
  // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
@@ -94,16 +153,6 @@ Open Claude Code and run:
94
153
 
95
154
  Claude Code will ask 3 questions, then set up your environment.
96
155
  `);
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
156
  return;
108
157
  }
109
158
  // Standard init — atomic steps + orchestrator
@@ -131,6 +180,7 @@ Claude Code will ask 3 questions, then set up your environment.
131
180
  }
132
181
  writeFileSync(".claude/commands/stack-init.md", orchestrator, "utf8");
133
182
  writeFileSync(".claude/commands/stack-sync.md", buildBootstrapSync(), "utf8");
183
+ installBootstrapCommands(".claude/commands");
134
184
  await updateManifest("init", collected, { estimatedTokens: tokens, estimatedCost: cost });
135
185
  installTokenHook();
136
186
  // Create initial snapshot — collectFilesForSnapshot scans all .claude/ automatically
@@ -145,14 +195,4 @@ ${c.green("✅")} Ready. Open Claude Code and run:
145
195
 
146
196
  Runs ${steps.length - 1} atomic steps. If one fails, re-run only that step.
147
197
  `);
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
198
  }
@@ -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,30 @@ 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
+ * Compute a simple line-level diff between two strings.
51
+ * Returns added lines (green) and removed lines (red).
52
+ */
53
+ function computeLineDiff(oldContent, newContent, maxLines = 20) {
54
+ const oldLines = oldContent.split("\n");
55
+ const newLines = newContent.split("\n");
56
+ const oldSet = new Set(oldLines);
57
+ const newSet = new Set(newLines);
58
+ const added = newLines.filter(l => !oldSet.has(l) && l.trim() !== "");
59
+ const removed = oldLines.filter(l => !newSet.has(l) && l.trim() !== "");
60
+ const totalChanges = added.length + removed.length;
61
+ const summary = `+${added.length} lines, -${removed.length} lines`;
62
+ return {
63
+ added: added.slice(0, maxLines),
64
+ removed: removed.slice(0, maxLines),
65
+ summary,
66
+ };
67
+ }
68
+ /**
69
+ * Legacy diff — compares manifest hashes against collected files.
70
+ * Only used when no snapshot data is available (e.g., old projects).
71
+ */
72
+ function computeLegacyDiff(snapshot, collected, cwd) {
55
73
  const current = {
56
74
  ...collected.configs,
57
75
  ...Object.fromEntries(collected.source.map(f => [f.path, f.content])),
@@ -60,7 +78,6 @@ function computeDiff(snapshot, collected, cwd) {
60
78
  const changed = [];
61
79
  const deleted = [];
62
80
  for (const [path, content] of Object.entries(current)) {
63
- // Skip virtual keys — they're not real files
64
81
  if (path === "__digest__")
65
82
  continue;
66
83
  const hash = sha256(content);
@@ -71,57 +88,65 @@ function computeDiff(snapshot, collected, cwd) {
71
88
  changed.push({ path, current: truncate(content, 2000) });
72
89
  }
73
90
  }
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
91
  for (const path of Object.keys(snapshot)) {
79
- // Skip virtual keys
80
92
  if (path === "__digest__")
81
93
  continue;
82
94
  if (!current[path]) {
83
- // Check if file actually exists on disk
84
95
  const fullPath = join(cwd, path);
85
96
  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
97
  try {
89
98
  const diskContent = readFileSync(fullPath, "utf8");
90
99
  const diskHash = sha256(diskContent);
91
100
  if (snapshot[path] !== diskHash) {
92
101
  changed.push({ path, current: truncate(diskContent, 2000) });
93
102
  }
94
- // If hash matches, file is unchanged — don't report anything
95
103
  }
96
104
  catch {
97
- // Can't read — treat as changed
98
105
  changed.push({ path, current: "[file exists but could not be read]" });
99
106
  }
100
107
  }
101
108
  else {
102
- // File genuinely does not exist on disk — truly deleted
103
109
  deleted.push(path);
104
110
  }
105
111
  }
106
112
  }
107
113
  return { added, changed, deleted };
108
114
  }
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 });
115
+ /**
116
+ * Full-scan diff — compares every file on disk against a reference snapshot.
117
+ * This is the authoritative diff: catches ALL file changes, no sampling.
118
+ * Now includes line-level diffs for modified files.
119
+ */
120
+ function computeFullDiff(currentFiles, referenceFiles) {
121
+ const added = [];
122
+ const changed = [];
123
+ const deleted = [];
124
+ const currentPathSet = new Set();
125
+ for (const f of currentFiles) {
126
+ currentPathSet.add(f.path);
127
+ if (!referenceFiles[f.path]) {
128
+ added.push({ path: f.path, content: truncate(f.content, 2000) });
129
+ }
130
+ else {
131
+ const currentHash = sha256(f.content);
132
+ const refHash = sha256(referenceFiles[f.path]);
133
+ if (currentHash !== refHash) {
134
+ const lineDiff = computeLineDiff(referenceFiles[f.path], f.content);
135
+ changed.push({
136
+ path: f.path,
137
+ current: truncate(f.content, 2000),
138
+ previous: truncate(referenceFiles[f.path], 2000),
139
+ lineDiff,
140
+ });
119
141
  }
120
- catch { /* skip unreadable */ }
121
142
  }
122
143
  }
123
- catch { /* skip */ }
124
- return files;
144
+ for (const path of Object.keys(referenceFiles)) {
145
+ if (!currentPathSet.has(path)) {
146
+ deleted.push(path);
147
+ }
148
+ }
149
+ return { added, changed, deleted };
125
150
  }
126
151
  export async function runSync(opts = {}) {
127
152
  const dryRun = opts.dryRun ?? false;
@@ -133,11 +158,10 @@ export async function runSync(opts = {}) {
133
158
  const lastRun = manifest.runs.at(-1);
134
159
  const cwd = process.cwd();
135
160
  const config = loadConfig(cwd);
136
- // Apply --budget override if provided
137
161
  if (opts.budget) {
138
162
  config.tokenBudget.sync = opts.budget;
139
163
  }
140
- // --- Out-of-band edit detection ---
164
+ // --- Out-of-band edit detection (early warning) ---
141
165
  const managedFiles = [
142
166
  { label: "CLAUDE.md", path: join(cwd, "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
143
167
  { label: ".mcp.json", path: join(cwd, ".mcp.json"), snapshotKey: ".mcp.json" },
@@ -161,111 +185,114 @@ export async function runSync(opts = {}) {
161
185
  }
162
186
  if (oobDetected)
163
187
  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
- }
188
+ // --- Full project scan (single scan, used for both diff and snapshot) ---
189
+ const currentFiles = collectFilesForSnapshot(cwd, []);
190
+ // --- Determine reference snapshot ---
191
+ // After restore: compare against the restored-to snapshot
192
+ // Normal: compare against the latest snapshot
193
+ const timeline = readTimeline(cwd);
194
+ const referenceNodeId = timeline.restoredTo ?? timeline.nodes.at(-1)?.id;
195
+ let referenceFiles = null;
196
+ if (referenceNodeId) {
197
+ const data = readNodeData(cwd, referenceNodeId);
198
+ if (data)
199
+ referenceFiles = data.files;
176
200
  }
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
- }
201
+ // --- Compute diff ---
202
+ let diff;
203
+ if (referenceFiles) {
204
+ // Full-scan comparison (authoritative catches ALL changes)
205
+ diff = computeFullDiff(currentFiles, referenceFiles);
206
+ }
207
+ else {
208
+ // Legacy fallback — no snapshot data available
209
+ const collected = await collectProjectFiles(cwd, "normal");
210
+ diff = computeLegacyDiff(lastRun.snapshot, collected, cwd);
189
211
  }
190
212
  const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.deleted.length > 0 || oobDetected;
191
213
  if (!hasChanges) {
192
214
  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
215
+ return;
194
216
  }
217
+ // --- Build sync command (needs collected project context for template) ---
218
+ const collected = await collectProjectFiles(cwd, "normal");
195
219
  const state = await readState();
196
220
  const content = buildSyncCommand(diff, collected, state);
197
- // Token tracking
198
221
  const tokens = estimateTokens(content);
199
222
  const cost = estimateCost(tokens);
200
223
  if (dryRun) {
201
224
  console.log(c.bold("[DRY RUN] Changes detected:\n"));
202
225
  if (diff.added.length) {
203
226
  console.log(c.green(` +${diff.added.length} added`));
204
- for (const f of diff.added)
205
- console.log(` ${f.path}`);
227
+ for (const f of diff.added) {
228
+ const lineCount = f.content.split("\n").length;
229
+ console.log(c.green(` + ${f.path}`) + c.dim(` (${lineCount} lines)`));
230
+ }
206
231
  }
207
232
  if (diff.changed.length) {
208
233
  console.log(c.yellow(` ~${diff.changed.length} modified`));
209
- for (const f of diff.changed)
210
- console.log(` ${f.path}`);
234
+ for (const f of diff.changed) {
235
+ const diffInfo = f.lineDiff ? ` (${f.lineDiff.summary})` : "";
236
+ console.log(c.yellow(` ~ ${f.path}`) + c.dim(diffInfo));
237
+ }
211
238
  }
212
239
  if (diff.deleted.length) {
213
240
  console.log(c.red(` -${diff.deleted.length} deleted`));
214
241
  for (const f of diff.deleted)
215
- console.log(` ${f}`);
242
+ console.log(c.red(` - ${f}`));
216
243
  }
217
244
  console.log(`\n Would write: .claude/commands/stack-sync.md (~${tokens.toLocaleString()} tokens)`);
218
- // Token cost display
219
245
  section("Token cost estimate");
220
246
  const estimate = buildTokenEstimate([{ label: "sync command", content }]);
221
247
  console.log(formatTokenReport(estimate));
222
248
  return;
223
249
  }
224
- // Add .claude/ internal files to snapshot
225
- for (const f of claudeInternalFiles) {
226
- collected.configs[f.path] = f.content;
227
- }
228
250
  ensureDir(".claude/commands");
229
251
  writeFileSync(".claude/commands/stack-sync.md", content, "utf8");
230
252
  await updateManifest("sync", collected, { estimatedTokens: tokens, estimatedCost: cost });
231
253
  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, {
254
+ // Create snapshot — reuse the full scan data (no second scan needed)
255
+ createSnapshot(cwd, "sync", currentFiles, {
239
256
  summary: `+${diff.added.length} added, ~${diff.changed.length} modified, -${diff.deleted.length} deleted`,
240
257
  });
241
- if (hasChanges) {
242
- console.log(`
243
- Changes since ${c.dim(lastRun.at)}:
244
- ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted
245
-
246
- ${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")}`);
258
+ // --- Detailed diff output ---
259
+ console.log(`\nChanges since ${c.dim(lastRun.at)}:`);
260
+ console.log(` ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted\n`);
261
+ if (diff.added.length > 0) {
262
+ console.log(c.green(c.bold(" Added files:")));
263
+ for (const f of diff.added) {
264
+ const lineCount = f.content.split("\n").length;
265
+ console.log(c.green(` + ${f.path}`) + c.dim(` (${lineCount} lines)`));
259
266
  }
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
+ console.log("");
268
+ }
269
+ if (diff.changed.length > 0) {
270
+ console.log(c.yellow(c.bold(" Modified files:")));
271
+ for (const f of diff.changed) {
272
+ const diffInfo = f.lineDiff ? ` (${f.lineDiff.summary})` : "";
273
+ console.log(c.yellow(` ~ ${f.path}`) + c.dim(diffInfo));
274
+ if (f.lineDiff) {
275
+ for (const line of f.lineDiff.removed.slice(0, 3)) {
276
+ console.log(c.red(` - ${line.trim().slice(0, 80)}`));
277
+ }
278
+ for (const line of f.lineDiff.added.slice(0, 3)) {
279
+ console.log(c.green(` + ${line.trim().slice(0, 80)}`));
280
+ }
281
+ const totalShown = Math.min(f.lineDiff.removed.length, 3) + Math.min(f.lineDiff.added.length, 3);
282
+ const totalChanges = f.lineDiff.removed.length + f.lineDiff.added.length;
283
+ if (totalChanges > totalShown) {
284
+ console.log(c.dim(` ... +${totalChanges - totalShown} more changes`));
285
+ }
267
286
  }
268
287
  }
269
288
  console.log("");
270
289
  }
290
+ if (diff.deleted.length > 0) {
291
+ console.log(c.red(c.bold(" Deleted files:")));
292
+ for (const f of diff.deleted) {
293
+ console.log(c.red(` - ${f}`));
294
+ }
295
+ console.log("");
296
+ }
297
+ console.log(`${c.green("✅")} Run ${c.cyan("/stack-sync")} in Claude Code to apply.\n`);
271
298
  }