claude-setup 1.1.3 → 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", "PostToolUseFailure", "Stop", "SessionStart"]) {
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>;
@@ -4,7 +4,10 @@ import { collectProjectFiles } from "../collect.js";
4
4
  import { readState } from "../state.js";
5
5
  import { readManifest, sha256, updateManifest } from "../manifest.js";
6
6
  import { buildSyncCommand } from "../builder.js";
7
- 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";
8
11
  function ensureDir(dir) {
9
12
  if (!existsSync(dir))
10
13
  mkdirSync(dir, { recursive: true });
@@ -14,7 +17,7 @@ function truncate(content, maxChars) {
14
17
  return content;
15
18
  return content.slice(0, maxChars) + "\n[... truncated for sync diff]";
16
19
  }
17
- function computeDiff(snapshot, collected) {
20
+ function computeDiff(snapshot, collected, cwd) {
18
21
  const current = {
19
22
  ...collected.configs,
20
23
  ...Object.fromEntries(collected.source.map(f => [f.path, f.content])),
@@ -34,12 +37,38 @@ function computeDiff(snapshot, collected) {
34
37
  changed.push({ path, current: truncate(content, 2000) });
35
38
  }
36
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.
37
44
  for (const path of Object.keys(snapshot)) {
38
45
  // Skip virtual keys
39
46
  if (path === "__digest__")
40
47
  continue;
41
- if (!current[path])
42
- 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
+ }
43
72
  }
44
73
  return { added, changed, deleted };
45
74
  }
@@ -52,8 +81,12 @@ export async function runSync(opts = {}) {
52
81
  }
53
82
  const lastRun = manifest.runs.at(-1);
54
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
+ }
55
89
  // --- Out-of-band edit detection ---
56
- // Check if CLI-managed files were modified outside the CLI (e.g. by Claude Code directly)
57
90
  const managedFiles = [
58
91
  { label: "CLAUDE.md", path: join(cwd, "CLAUDE.md"), snapshotKey: "CLAUDE.md" },
59
92
  { label: ".mcp.json", path: join(cwd, ".mcp.json"), snapshotKey: ".mcp.json" },
@@ -78,13 +111,16 @@ export async function runSync(opts = {}) {
78
111
  if (oobDetected)
79
112
  console.log("");
80
113
  const collected = await collectProjectFiles(cwd, "normal");
81
- const diff = computeDiff(lastRun.snapshot, collected);
114
+ const diff = computeDiff(lastRun.snapshot, collected, cwd);
82
115
  if (!diff.added.length && !diff.changed.length && !diff.deleted.length && !oobDetected) {
83
116
  console.log(`${c.green("✅")} No changes since ${c.dim(lastRun.at)}. Setup is current.`);
84
117
  return;
85
118
  }
86
119
  const state = await readState();
87
120
  const content = buildSyncCommand(diff, collected, state);
121
+ // Token tracking
122
+ const tokens = estimateTokens(content);
123
+ const cost = estimateCost(tokens);
88
124
  if (dryRun) {
89
125
  console.log(c.bold("[DRY RUN] Changes detected:\n"));
90
126
  if (diff.added.length) {
@@ -102,12 +138,26 @@ export async function runSync(opts = {}) {
102
138
  for (const f of diff.deleted)
103
139
  console.log(` ${f}`);
104
140
  }
105
- 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));
106
146
  return;
107
147
  }
108
148
  ensureDir(".claude/commands");
109
149
  writeFileSync(".claude/commands/stack-sync.md", content, "utf8");
110
- 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
+ });
111
161
  console.log(`
112
162
  Changes since ${c.dim(lastRun.at)}:
113
163
  ${c.green(`+${diff.added.length}`)} added ${c.yellow(`~${diff.changed.length}`)} modified ${c.red(`-${diff.deleted.length}`)} deleted
@@ -115,4 +165,17 @@ Changes since ${c.dim(lastRun.at)}:
115
165
  ${c.green("✅")} Ready. Open Claude Code and run:
116
166
  ${c.cyan("/stack-sync")}
117
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("");
118
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>;