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