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.
- package/README.md +88 -107
- package/dist/builder.d.ts +6 -0
- package/dist/builder.js +134 -78
- package/dist/commands/add.d.ts +3 -1
- package/dist/commands/add.js +5 -13
- package/dist/commands/init.js +61 -21
- package/dist/commands/remove.d.ts +3 -1
- package/dist/commands/remove.js +5 -9
- package/dist/commands/restore.d.ts +4 -1
- package/dist/commands/restore.js +42 -4
- package/dist/commands/sync.js +130 -103
- package/dist/index.js +39 -9
- package/dist/marketplace.js +6 -4
- package/dist/os.d.ts +33 -4
- package/dist/os.js +238 -3
- package/dist/snapshot.js +1 -0
- package/package.json +1 -1
- package/templates/add.md +33 -1
- package/templates/sync.md +15 -7
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -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
|
|
8
|
-
import { c
|
|
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
|
}
|
package/dist/commands/restore.js
CHANGED
|
@@ -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();
|
package/dist/commands/sync.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
165
|
-
const
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
}
|