claude-setup 1.1.4 → 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/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,24 +85,61 @@ 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);
91
92
  writeNodeData(cwd, nodeId, data);
92
93
  return node;
93
94
  }
95
+ /**
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.
104
+ */
105
+ export function buildCumulativeState(cwd, nodeId, timeline) {
106
+ const targetIndex = timeline.nodes.findIndex(n => n.id === nodeId);
107
+ if (targetIndex < 0)
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
116
+ const cumulative = {};
117
+ for (let i = 0; i <= targetIndex; i++) {
118
+ const data = readNodeData(cwd, timeline.nodes[i].id);
119
+ if (data) {
120
+ for (const [filePath, content] of Object.entries(data.files)) {
121
+ cumulative[filePath] = content;
122
+ }
123
+ }
124
+ }
125
+ return cumulative;
126
+ }
94
127
  /**
95
128
  * Restore files from a snapshot node.
96
- * Writes stored file contents back to disk.
129
+ * Accumulates all file states from node 0 through the target node,
130
+ * then writes them to disk. This reconstructs the full project state
131
+ * at that point in time, not just the delta.
97
132
  * Does NOT delete other nodes — all nodes are preserved (like git).
98
133
  */
99
- export function restoreSnapshot(cwd, nodeId) {
100
- const data = readNodeData(cwd, nodeId);
101
- if (!data)
102
- return { restored: [], failed: [nodeId] };
134
+ export function restoreSnapshot(cwd, nodeId, timeline) {
135
+ const tl = timeline ?? readTimeline(cwd);
136
+ const cumulativeFiles = buildCumulativeState(cwd, nodeId, tl);
137
+ if (!cumulativeFiles)
138
+ return { restored: [], failed: [nodeId], deleted: [], stale: [] };
139
+ // Step 1: Write all snapshot files to disk
103
140
  const restored = [];
104
141
  const failed = [];
105
- for (const [filePath, content] of Object.entries(data.files)) {
142
+ for (const [filePath, content] of Object.entries(cumulativeFiles)) {
106
143
  const fullPath = join(cwd, filePath);
107
144
  try {
108
145
  const dir = dirname(fullPath);
@@ -115,7 +152,36 @@ export function restoreSnapshot(cwd, nodeId) {
115
152
  failed.push(filePath);
116
153
  }
117
154
  }
118
- return { restored, failed };
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 = [];
163
+ const stale = [];
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);
171
+ }
172
+ catch {
173
+ stale.push(f.path); // couldn't delete (permissions etc.)
174
+ }
175
+ }
176
+ return { restored, failed, deleted, stale };
177
+ }
178
+ /**
179
+ * Record the last restored node in the timeline (for display purposes).
180
+ */
181
+ export function updateRestoredNode(cwd, nodeId) {
182
+ const timeline = readTimeline(cwd);
183
+ timeline.restoredTo = nodeId;
184
+ writeTimeline(cwd, timeline);
119
185
  }
120
186
  /**
121
187
  * Compare two snapshot nodes. Returns files that differ between them.
@@ -156,40 +222,164 @@ export function compareSnapshots(cwd, nodeIdA, nodeIdB) {
156
222
  }
157
223
  return { onlyInA, onlyInB, changed, identical };
158
224
  }
159
- /**
160
- * Collect current file contents for snapshot.
161
- * Reads tracked files + CLI-managed files from disk.
162
- */
163
- export function collectFilesForSnapshot(cwd, trackedPaths) {
164
- const files = [];
165
- const seen = new Set();
166
- for (const filePath of trackedPaths) {
167
- if (filePath === "__digest__" || filePath === ".env")
168
- continue;
169
- if (seen.has(filePath))
170
- continue;
171
- const fullPath = join(cwd, filePath);
172
- if (!existsSync(fullPath))
173
- continue;
174
- try {
175
- files.push({ path: filePath, content: readFileSync(fullPath, "utf8") });
176
- seen.add(filePath);
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;
177
299
  }
178
- catch { /* skip unreadable */ }
179
300
  }
180
- // Also capture CLI-managed files if not already included
181
- const managed = ["CLAUDE.md", ".mcp.json", ".claude/settings.json"];
182
- for (const m of managed) {
183
- if (seen.has(m))
184
- continue;
185
- const fullPath = join(cwd, m);
186
- if (!existsSync(fullPath))
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)
187
325
  continue;
188
- try {
189
- files.push({ path: m, content: readFileSync(fullPath, "utf8") });
190
- seen.add(m);
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
+ }
191
371
  }
192
- catch { /* skip */ }
193
372
  }
194
- return files;
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;
195
385
  }
package/dist/tokens.d.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Token cost tracking — visibility into what every command costs.
3
3
  *
4
- * Zero extra API calls. Token count is computed from content length.
5
- * Estimates are based on ~4 chars per token approximation.
4
+ * Two data sources:
5
+ * 1. Estimates: computed from content length (~4 chars/token)
6
+ * 2. Real usage: parsed from Claude Code JSONL session transcripts
7
+ * stored at ~/.config/claude/projects/ and ~/.claude/projects/
6
8
  *
7
- * Supports all pricing models:
8
- * - Opus: $15/M input
9
- * - Sonnet: $3/M input
10
- * - Haiku: $0.25/M input
9
+ * Pricing engine inspired by ccusage (github.com/syunmoca/ccusage):
10
+ * - Per-model pricing with tiered rates (200k token threshold)
11
+ * - Cache creation/read tokens tracked separately
12
+ * - Per-session, per-project, per-model breakdowns
13
+ *
14
+ * Current pricing (per million tokens):
15
+ * Opus 4.6: $15 input / $75 output / $18.75 cache-write / $1.50 cache-read
16
+ * Sonnet 4.6: $3 input / $15 output / $3.75 cache-write / $0.30 cache-read
17
+ * Haiku 4.5: $0.80 input / $4 output / $1.00 cache-write / $0.08 cache-read
11
18
  */
12
19
  export interface CostBreakdown {
13
20
  opus: number;
@@ -22,6 +29,8 @@ export interface TokenEstimate {
22
29
  tokens: number;
23
30
  }>;
24
31
  }
32
+ /** Calculate real cost for a set of token counts using a specific model */
33
+ export declare function calculateRealCost(inputTokens: number, outputTokens: number, cacheCreate: number, cacheRead: number, modelName: string): number;
25
34
  export declare function estimateTokens(content: string): number;
26
35
  export declare function estimateCost(tokens: number): CostBreakdown;
27
36
  export declare function formatCost(cost: CostBreakdown): string;
@@ -43,6 +52,66 @@ export declare function generateHints(runs: Array<{
43
52
  command: string;
44
53
  estimatedTokens?: number;
45
54
  }>, currentTokens: number, budget: number): string[];
55
+ export interface RealTokenRecord {
56
+ sessionId: string;
57
+ timestamp: string;
58
+ model: string;
59
+ inputTokens: number;
60
+ outputTokens: number;
61
+ cacheCreate: number;
62
+ cacheRead: number;
63
+ cost: number;
64
+ }
65
+ export declare function readRealTokenUsage(cwd: string): RealTokenRecord[];
66
+ export interface ModelBreakdown {
67
+ model: string;
68
+ inputTokens: number;
69
+ outputTokens: number;
70
+ cacheCreateTokens: number;
71
+ cacheReadTokens: number;
72
+ cost: number;
73
+ totalTokens: number;
74
+ }
75
+ export interface SessionSummary {
76
+ sessionId: string;
77
+ project: string;
78
+ timestamp: string;
79
+ models: ModelBreakdown[];
80
+ inputTokens: number;
81
+ outputTokens: number;
82
+ cacheCreateTokens: number;
83
+ cacheReadTokens: number;
84
+ totalTokens: number;
85
+ totalCost: number;
86
+ }
87
+ export interface ProjectSummary {
88
+ project: string;
89
+ sessions: number;
90
+ models: ModelBreakdown[];
91
+ inputTokens: number;
92
+ outputTokens: number;
93
+ cacheCreateTokens: number;
94
+ cacheReadTokens: number;
95
+ totalTokens: number;
96
+ totalCost: number;
97
+ }
98
+ /**
99
+ * Read all JSONL session files for a given project directory.
100
+ * Scans Claude's data directories for matching project paths.
101
+ * Returns per-session summaries with per-model breakdowns.
102
+ */
103
+ export declare function readProjectSessions(projectCwd: string): SessionSummary[];
104
+ /**
105
+ * Aggregate all sessions for a project into a single summary.
106
+ */
107
+ export declare function getProjectUsageSummary(projectCwd: string): ProjectSummary | null;
108
+ export declare function getTokenHookScript(): string;
109
+ /**
110
+ * Format a real-cost summary. Prefers JSONL transcript data (ccusage-style),
111
+ * falls back to Stop hook data if no JSONL sessions found.
112
+ * Returns null if no real data is available from either source.
113
+ */
114
+ export declare function formatRealCostSummary(cwd: string): string | null;
46
115
  /**
47
116
  * Compute cumulative stats for status dashboard.
48
117
  */