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/builder.d.ts +6 -0
- package/dist/builder.js +41 -0
- package/dist/commands/add.js +2 -2
- package/dist/commands/init.js +62 -13
- package/dist/commands/remove.js +2 -2
- package/dist/commands/restore.js +179 -12
- package/dist/commands/status.js +65 -25
- package/dist/commands/sync.js +110 -20
- package/dist/doctor.js +34 -6
- package/dist/marketplace.d.ts +14 -1
- package/dist/marketplace.js +117 -61
- package/dist/snapshot.d.ts +27 -5
- package/dist/snapshot.js +230 -40
- package/dist/tokens.d.ts +75 -6
- package/dist/tokens.js +542 -9
- package/package.json +1 -1
- package/templates/init-empty.md +49 -7
- package/templates/remove.md +6 -2
- package/templates/sync.md +4 -0
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
|
-
*
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
* Estimates
|
|
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
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
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
|
*/
|