claude-setup 1.1.2 → 1.1.4

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.
@@ -1,6 +1,10 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { basename, join } from "path";
1
3
  import { readManifest } from "../manifest.js";
2
4
  import { readState } from "../state.js";
3
5
  import { detectOS } from "../os.js";
6
+ import { readTimeline } from "../snapshot.js";
7
+ import { computeCumulativeStats, formatCost } from "../tokens.js";
4
8
  import { c, statusLine, section } from "../output.js";
5
9
  function safeJsonParse(content) {
6
10
  try {
@@ -23,10 +27,13 @@ export async function runStatus() {
23
27
  // --- Header ---
24
28
  console.log(c.bold("status") + ` — ${new Date().toISOString().split("T")[0]}\n`);
25
29
  // --- Project info ---
26
- const projectType = inferProjectType(state);
27
- console.log(`Project : ${projectType}`);
28
- console.log(`OS : ${os}`);
29
- console.log(`Version : claude-setup v${version}`);
30
+ // BUG 2 FIX: Use directory name or package manifest name, not language
31
+ const projectName = getProjectName();
32
+ const language = inferLanguage(state);
33
+ console.log(`Project : ${projectName}`);
34
+ console.log(`Language : ${language}`);
35
+ console.log(`OS : ${os}`);
36
+ console.log(`Version : claude-setup v${version}`);
30
37
  // --- Setup files ---
31
38
  section("Setup files");
32
39
  // CLAUDE.md
@@ -50,14 +57,7 @@ export async function runStatus() {
50
57
  // settings.json
51
58
  if (state.settings.exists && state.settings.content) {
52
59
  const settings = safeJsonParse(state.settings.content);
53
- let hookCount = 0;
54
- if (settings) {
55
- for (const key of ["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop"]) {
56
- const hooks = settings[key];
57
- if (Array.isArray(hooks))
58
- hookCount += hooks.length;
59
- }
60
- }
60
+ const hookCount = countHooks(settings);
61
61
  statusLine("✅", "settings.json", `${hookCount} hook(s)`);
62
62
  }
63
63
  else {
@@ -67,11 +67,64 @@ export async function runStatus() {
67
67
  console.log(` Skills : ${state.skills.length ? state.skills.map(s => s.split("/").at(-2) ?? s).join(", ") : "none"}`);
68
68
  console.log(` Commands : ${state.commands.length ? state.commands.map(s => s.split("/").pop()?.replace(".md", "") ?? s).join(", ") : "none"}`);
69
69
  console.log(` Workflows : ${state.workflows.length ? state.workflows.map(s => s.split("/").pop() ?? s).join(", ") : "none"}`);
70
+ // --- Feature A/B: Snapshot timeline ---
71
+ const cwd = process.cwd();
72
+ const timeline = readTimeline(cwd);
73
+ if (timeline.nodes.length > 0) {
74
+ section("Snapshot timeline");
75
+ const displayNodes = timeline.nodes.slice(-8); // Show last 8
76
+ if (timeline.nodes.length > 8) {
77
+ console.log(` ${c.dim(`... +${timeline.nodes.length - 8} older snapshots`)}`);
78
+ }
79
+ for (let i = 0; i < displayNodes.length; i++) {
80
+ const node = displayNodes[i];
81
+ const date = new Date(node.timestamp).toLocaleDateString();
82
+ const time = new Date(node.timestamp).toLocaleTimeString();
83
+ const isLatest = i === displayNodes.length - 1;
84
+ const marker = isLatest ? ` ${c.green("← current")}` : "";
85
+ const inputStr = node.input ? ` "${node.input}"` : "";
86
+ console.log(` ${c.cyan(node.id)} ${node.command}${inputStr} ${c.dim(`${date} ${time}`)} ${node.summary}${marker}`);
87
+ }
88
+ console.log(`\n ${c.dim("Use")} ${c.cyan("npx claude-setup restore")} ${c.dim("to jump to any snapshot")}`);
89
+ console.log(` ${c.dim("Use")} ${c.cyan("npx claude-setup compare")} ${c.dim("to diff two snapshots")}`);
90
+ }
91
+ // --- Feature I: Token usage stats ---
92
+ const runsWithTokens = manifest.runs.filter(r => r.estimatedTokens !== undefined);
93
+ if (runsWithTokens.length > 0) {
94
+ section("Token usage");
95
+ const stats = computeCumulativeStats(manifest.runs);
96
+ console.log(` Total tokens : ~${stats.totalTokens.toLocaleString()} across ${stats.runCount} run(s)`);
97
+ console.log(` Total cost : ${formatCost(stats.totalCost)}`);
98
+ // Average by command type
99
+ const avgEntries = Object.entries(stats.avgByCommand);
100
+ if (avgEntries.length > 0) {
101
+ console.log(` Avg by type :`);
102
+ for (const [cmd, avg] of avgEntries) {
103
+ console.log(` ${cmd}: ~${avg.toLocaleString()} tokens/run`);
104
+ }
105
+ }
106
+ // Cost trend (last 3 vs previous 3)
107
+ if (runsWithTokens.length >= 6) {
108
+ const recent3 = runsWithTokens.slice(-3);
109
+ const prev3 = runsWithTokens.slice(-6, -3);
110
+ const recentAvg = recent3.reduce((s, r) => s + (r.estimatedTokens ?? 0), 0) / 3;
111
+ const prevAvg = prev3.reduce((s, r) => s + (r.estimatedTokens ?? 0), 0) / 3;
112
+ const change = ((recentAvg - prevAvg) / prevAvg) * 100;
113
+ if (Math.abs(change) > 10) {
114
+ const trend = change > 0 ? c.yellow(`↑ +${change.toFixed(0)}%`) : c.green(`↓ ${change.toFixed(0)}%`);
115
+ console.log(` Trend : ${trend} (recent vs previous)`);
116
+ }
117
+ else {
118
+ console.log(` Trend : ${c.green("→ stable")}`);
119
+ }
120
+ }
121
+ }
70
122
  // --- Run history ---
71
123
  section("Run history (last 5)");
72
124
  for (const r of manifest.runs.slice(-5)) {
73
125
  const inputStr = r.input ? ` — "${r.input}"` : "";
74
- console.log(` ${c.dim(r.at)} ${r.command}${inputStr}`);
126
+ const tokenStr = r.estimatedTokens ? ` (${r.estimatedTokens.toLocaleString()} tokens)` : "";
127
+ console.log(` ${c.dim(r.at)} ${r.command}${inputStr}${tokenStr}`);
75
128
  }
76
129
  // --- Health hint ---
77
130
  const hint = getHealthHint(manifest, state);
@@ -87,9 +140,130 @@ export async function runStatus() {
87
140
  }
88
141
  console.log("");
89
142
  }
90
- function inferProjectType(state) {
143
+ /**
144
+ * BUG 3 FIX: Count hooks across all event types, handling both formats:
145
+ * - Correct format: { "hooks": { "PostToolUse": [{ "matcher": "...", "hooks": [{ "type": "command", "command": "..." }] }] } }
146
+ * - Legacy/flat format: { "PostToolUse": [{ "command": "...", "args": [...] }] }
147
+ */
148
+ function countHooks(settings) {
149
+ if (!settings)
150
+ return 0;
151
+ let count = 0;
152
+ const HOOK_EVENTS = [
153
+ "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionStart",
154
+ "Notification", "SubagentStart", "SubagentStop", "UserPromptSubmit",
155
+ "PermissionRequest", "ConfigChange", "InstructionsLoaded", "TaskCompleted",
156
+ "TeammateIdle", "StopFailure", "SessionEnd",
157
+ "PreCompact", "PostCompact", "WorktreeCreate", "WorktreeRemove",
158
+ "Elicitation", "ElicitationResult",
159
+ ];
160
+ // Check inside "hooks" key first (correct Claude Code format)
161
+ const hooksObj = settings["hooks"];
162
+ if (hooksObj && typeof hooksObj === "object") {
163
+ for (const event of HOOK_EVENTS) {
164
+ const eventHooks = hooksObj[event];
165
+ if (Array.isArray(eventHooks)) {
166
+ for (const entry of eventHooks) {
167
+ if (typeof entry === "object" && entry !== null) {
168
+ const e = entry;
169
+ if (Array.isArray(e.hooks)) {
170
+ count += e.hooks.length;
171
+ }
172
+ else {
173
+ count++;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ return count;
180
+ }
181
+ // Fallback: check top-level keys (legacy flat format)
182
+ for (const event of HOOK_EVENTS) {
183
+ const hooks = settings[event];
184
+ if (Array.isArray(hooks)) {
185
+ for (const entry of hooks) {
186
+ if (typeof entry === "object" && entry !== null) {
187
+ const e = entry;
188
+ if (Array.isArray(e.hooks)) {
189
+ count += e.hooks.length;
190
+ }
191
+ else {
192
+ count++;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ return count;
199
+ }
200
+ /** BUG 2 FIX: Get project name from package manifest or directory name */
201
+ function getProjectName() {
202
+ const cwd = process.cwd();
203
+ // Try package.json name
204
+ try {
205
+ const pkgPath = join(cwd, "package.json");
206
+ if (existsSync(pkgPath)) {
207
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
208
+ if (pkg.name)
209
+ return pkg.name;
210
+ }
211
+ }
212
+ catch { /* skip */ }
213
+ // Try pom.xml artifactId
214
+ try {
215
+ const pomPath = join(cwd, "pom.xml");
216
+ if (existsSync(pomPath)) {
217
+ const pom = readFileSync(pomPath, "utf8");
218
+ const match = pom.match(/<artifactId>([^<]+)<\/artifactId>/);
219
+ if (match)
220
+ return match[1];
221
+ }
222
+ }
223
+ catch { /* skip */ }
224
+ // Try Cargo.toml name
225
+ try {
226
+ const cargoPath = join(cwd, "Cargo.toml");
227
+ if (existsSync(cargoPath)) {
228
+ const cargo = readFileSync(cargoPath, "utf8");
229
+ const match = cargo.match(/^name\s*=\s*"([^"]+)"/m);
230
+ if (match)
231
+ return match[1];
232
+ }
233
+ }
234
+ catch { /* skip */ }
235
+ // Try pyproject.toml name
236
+ try {
237
+ const pyPath = join(cwd, "pyproject.toml");
238
+ if (existsSync(pyPath)) {
239
+ const py = readFileSync(pyPath, "utf8");
240
+ const match = py.match(/^name\s*=\s*"([^"]+)"/m);
241
+ if (match)
242
+ return match[1];
243
+ }
244
+ }
245
+ catch { /* skip */ }
246
+ // Fallback: directory name
247
+ return basename(cwd);
248
+ }
249
+ /** Detect language/runtime as a separate field */
250
+ function inferLanguage(state) {
251
+ const cwd = process.cwd();
252
+ if (existsSync(join(cwd, "package.json")))
253
+ return "Node.js / TypeScript";
254
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "requirements.txt")))
255
+ return "Python";
256
+ if (existsSync(join(cwd, "go.mod")))
257
+ return "Go";
258
+ if (existsSync(join(cwd, "Cargo.toml")))
259
+ return "Rust";
260
+ if (existsSync(join(cwd, "pom.xml")) || existsSync(join(cwd, "build.gradle")))
261
+ return "Java";
262
+ if (existsSync(join(cwd, "Gemfile")))
263
+ return "Ruby";
264
+ if (existsSync(join(cwd, "composer.json")))
265
+ return "PHP";
91
266
  if (state.claudeMd.content) {
92
- // Try to infer from CLAUDE.md content
93
267
  const content = state.claudeMd.content.toLowerCase();
94
268
  if (content.includes("typescript") || content.includes("node"))
95
269
  return "Node.js / TypeScript";
@@ -111,7 +285,6 @@ function getHealthHint(manifest, _state) {
111
285
  if (!last)
112
286
  return `${c.yellow("⚠️")} No runs recorded. Run: ${c.cyan("npx claude-setup init")}`;
113
287
  const daysSince = Math.floor((Date.now() - new Date(last.at).getTime()) / (1000 * 60 * 60 * 24));
114
- // Check for recent deletions
115
288
  if (last.command === "sync") {
116
289
  const snapshot = last.snapshot;
117
290
  const deletionCount = Object.keys(snapshot).filter(k => k.startsWith("[deleted]")).length;
@@ -1,3 +1,4 @@
1
1
  export declare function runSync(opts?: {
2
2
  dryRun?: boolean;
3
+ budget?: number;
3
4
  }): Promise<void>;
@@ -1,9 +1,13 @@
1
- import { writeFileSync, mkdirSync, existsSync } from "fs";
1
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
2
3
  import { collectProjectFiles } from "../collect.js";
3
4
  import { readState } from "../state.js";
4
5
  import { readManifest, sha256, updateManifest } from "../manifest.js";
5
6
  import { buildSyncCommand } from "../builder.js";
6
- import { c } from "../output.js";
7
+ import { createSnapshot, collectFilesForSnapshot } from "../snapshot.js";
8
+ import { estimateTokens, estimateCost, formatTokenReport, buildTokenEstimate, generateHints } from "../tokens.js";
9
+ import { loadConfig } from "../config.js";
10
+ import { c, section } from "../output.js";
7
11
  function ensureDir(dir) {
8
12
  if (!existsSync(dir))
9
13
  mkdirSync(dir, { recursive: true });
@@ -13,7 +17,7 @@ function truncate(content, maxChars) {
13
17
  return content;
14
18
  return content.slice(0, maxChars) + "\n[... truncated for sync diff]";
15
19
  }
16
- function computeDiff(snapshot, collected) {
20
+ function computeDiff(snapshot, collected, cwd) {
17
21
  const current = {
18
22
  ...collected.configs,
19
23
  ...Object.fromEntries(collected.source.map(f => [f.path, f.content])),
@@ -33,12 +37,38 @@ function computeDiff(snapshot, collected) {
33
37
  changed.push({ path, current: truncate(content, 2000) });
34
38
  }
35
39
  }
40
+ // BUG 1 FIX: Verify file existence on disk before reporting deletions.
41
+ // Files may appear "deleted" because they weren't in the current collection set
42
+ // (different collect mode, or CLI-managed files like CLAUDE.md/settings.json).
43
+ // If the file still exists on disk, it was "modified outside the CLI", not deleted.
36
44
  for (const path of Object.keys(snapshot)) {
37
45
  // Skip virtual keys
38
46
  if (path === "__digest__")
39
47
  continue;
40
- if (!current[path])
41
- deleted.push(path);
48
+ if (!current[path]) {
49
+ // Check if file actually exists on disk
50
+ const fullPath = join(cwd, path);
51
+ if (existsSync(fullPath)) {
52
+ // File exists but wasn't in our collection — it was modified outside CLI
53
+ // Read it and check if its hash changed
54
+ try {
55
+ const diskContent = readFileSync(fullPath, "utf8");
56
+ const diskHash = sha256(diskContent);
57
+ if (snapshot[path] !== diskHash) {
58
+ changed.push({ path, current: truncate(diskContent, 2000) });
59
+ }
60
+ // If hash matches, file is unchanged — don't report anything
61
+ }
62
+ catch {
63
+ // Can't read — treat as changed
64
+ changed.push({ path, current: "[file exists but could not be read]" });
65
+ }
66
+ }
67
+ else {
68
+ // File genuinely does not exist on disk — truly deleted
69
+ deleted.push(path);
70
+ }
71
+ }
42
72
  }
43
73
  return { added, changed, deleted };
44
74
  }
@@ -50,14 +80,47 @@ export async function runSync(opts = {}) {
50
80
  return;
51
81
  }
52
82
  const lastRun = manifest.runs.at(-1);
53
- const collected = await collectProjectFiles(process.cwd(), "normal");
54
- const diff = computeDiff(lastRun.snapshot, collected);
55
- if (!diff.added.length && !diff.changed.length && !diff.deleted.length) {
83
+ const cwd = process.cwd();
84
+ const config = loadConfig(cwd);
85
+ // Apply --budget override if provided
86
+ if (opts.budget) {
87
+ config.tokenBudget.sync = opts.budget;
88
+ }
89
+ // --- Out-of-band edit detection ---
90
+ const managedFiles = [
91
+ { label: "CLAUDE.md", path: join(cwd, "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
92
+ { label: ".mcp.json", path: join(cwd, ".mcp.json"), snapshotKey: ".mcp.json" },
93
+ { label: "settings.json", path: join(cwd, ".claude", "settings.json"), snapshotKey: ".claude/settings.json" },
94
+ ];
95
+ let oobDetected = false;
96
+ for (const mf of managedFiles) {
97
+ if (!existsSync(mf.path))
98
+ continue;
99
+ const currentContent = readFileSync(mf.path, "utf8");
100
+ const currentHash = sha256(currentContent);
101
+ const snapshotHash = lastRun.snapshot[mf.snapshotKey];
102
+ if (snapshotHash && currentHash !== snapshotHash) {
103
+ if (!oobDetected) {
104
+ oobDetected = true;
105
+ console.log("");
106
+ }
107
+ console.log(`${c.yellow("⚠️")} OUT-OF-BAND EDIT — ${mf.label} was modified outside the CLI`);
108
+ console.log(` Re-snapshotting. Run ${c.cyan("npx claude-setup doctor")} to validate the new state.`);
109
+ }
110
+ }
111
+ if (oobDetected)
112
+ console.log("");
113
+ const collected = await collectProjectFiles(cwd, "normal");
114
+ const diff = computeDiff(lastRun.snapshot, collected, cwd);
115
+ if (!diff.added.length && !diff.changed.length && !diff.deleted.length && !oobDetected) {
56
116
  console.log(`${c.green("✅")} No changes since ${c.dim(lastRun.at)}. Setup is current.`);
57
117
  return;
58
118
  }
59
119
  const state = await readState();
60
120
  const content = buildSyncCommand(diff, collected, state);
121
+ // Token tracking
122
+ const tokens = estimateTokens(content);
123
+ const cost = estimateCost(tokens);
61
124
  if (dryRun) {
62
125
  console.log(c.bold("[DRY RUN] Changes detected:\n"));
63
126
  if (diff.added.length) {
@@ -75,12 +138,26 @@ export async function runSync(opts = {}) {
75
138
  for (const f of diff.deleted)
76
139
  console.log(` ${f}`);
77
140
  }
78
- console.log(`\n Would write: .claude/commands/stack-sync.md (~${Math.ceil(content.length / 4)} tokens)`);
141
+ console.log(`\n Would write: .claude/commands/stack-sync.md (~${tokens.toLocaleString()} tokens)`);
142
+ // Token cost display
143
+ section("Token cost estimate");
144
+ const estimate = buildTokenEstimate([{ label: "sync command", content }]);
145
+ console.log(formatTokenReport(estimate));
79
146
  return;
80
147
  }
81
148
  ensureDir(".claude/commands");
82
149
  writeFileSync(".claude/commands/stack-sync.md", content, "utf8");
83
- await updateManifest("sync", collected);
150
+ await updateManifest("sync", collected, { estimatedTokens: tokens, estimatedCost: cost });
151
+ // Feature A: Create snapshot node
152
+ const allPaths = [
153
+ ...Object.keys(collected.configs),
154
+ ...collected.source.map(s => s.path),
155
+ ];
156
+ const snapshotFiles = collectFilesForSnapshot(cwd, allPaths);
157
+ const changeCount = diff.added.length + diff.changed.length + diff.deleted.length;
158
+ createSnapshot(cwd, "sync", snapshotFiles, {
159
+ summary: `+${diff.added.length} added, ~${diff.changed.length} modified, -${diff.deleted.length} deleted`,
160
+ });
84
161
  console.log(`
85
162
  Changes since ${c.dim(lastRun.at)}:
86
163
  ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted
@@ -88,4 +165,17 @@ Changes since ${c.dim(lastRun.at)}:
88
165
  ${c.green("✅")} Ready. Open Claude Code and run:
89
166
  ${c.cyan("/stack-sync")}
90
167
  `);
168
+ // Token cost display
169
+ section("Token cost");
170
+ console.log(` ~${tokens.toLocaleString()} input tokens (${c.dim(`Opus $${cost.opus.toFixed(4)} | Sonnet $${cost.sonnet.toFixed(4)} | Haiku $${cost.haiku.toFixed(4)}`)})`);
171
+ // Optimization hints
172
+ const runs = manifest.runs.map(r => ({ command: r.command, estimatedTokens: r.estimatedTokens }));
173
+ const hints = generateHints(runs, tokens, config.tokenBudget.sync);
174
+ if (hints.length) {
175
+ section("Optimization hints");
176
+ for (const hint of hints) {
177
+ console.log(` ${c.yellow("💡")} ${hint}`);
178
+ }
179
+ }
180
+ console.log("");
91
181
  }
package/dist/doctor.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function runDoctor(verbose?: boolean): Promise<void>;
1
+ export declare function runDoctor(verbose?: boolean, fix?: boolean, testHooks?: boolean): Promise<void>;