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