tokenoscopy 0.0.0-stage → 0.3.0
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/README.md +36 -2
- package/dist/chunk-L62TVAWB.js +752 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +716 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +22 -0
- package/package.json +59 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ALLOW_CACHE_MS,
|
|
4
|
+
GUARD_TIMEOUT_MS,
|
|
5
|
+
detectAuth,
|
|
6
|
+
detectAuthMode,
|
|
7
|
+
naturalDedupKey,
|
|
8
|
+
parseTranscript,
|
|
9
|
+
redactValue,
|
|
10
|
+
truncatePayload,
|
|
11
|
+
withRelPath
|
|
12
|
+
} from "./chunk-L62TVAWB.js";
|
|
13
|
+
|
|
14
|
+
// src/commands/doctor.ts
|
|
15
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
16
|
+
import { homedir as homedir2 } from "os";
|
|
17
|
+
import { join as join2 } from "path";
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
21
|
+
import { homedir } from "os";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
var DEFAULT_API_URL = "https://tokenoscopy.com";
|
|
24
|
+
function configDir() {
|
|
25
|
+
return process.env.TOKENOSCOPY_CONFIG_DIR ?? join(homedir(), ".config", "tokenoscopy");
|
|
26
|
+
}
|
|
27
|
+
function configPath() {
|
|
28
|
+
return join(configDir(), "config.json");
|
|
29
|
+
}
|
|
30
|
+
function loadConfig() {
|
|
31
|
+
const envKey = process.env.TOKENOSCOPY_KEY;
|
|
32
|
+
const envUrl = process.env.TOKENOSCOPY_API_URL;
|
|
33
|
+
if (envKey) return { apiUrl: envUrl ?? DEFAULT_API_URL, key: envKey };
|
|
34
|
+
try {
|
|
35
|
+
const raw = readFileSync(configPath(), "utf8");
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (typeof parsed.key !== "string" || !parsed.key.startsWith("tok_")) return null;
|
|
38
|
+
return { apiUrl: parsed.apiUrl ?? DEFAULT_API_URL, key: parsed.key };
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function saveConfig(config) {
|
|
44
|
+
if (!existsSync(configDir())) mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
45
|
+
writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}
|
|
46
|
+
`, { mode: 384 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/commands/doctor.ts
|
|
50
|
+
async function runDoctor() {
|
|
51
|
+
const out = (line) => process.stdout.write(`${line}
|
|
52
|
+
`);
|
|
53
|
+
let failures = 0;
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
if (config) {
|
|
56
|
+
out(`\u2713 config found (${process.env.TOKENOSCOPY_KEY ? "env" : configPath()})`);
|
|
57
|
+
out(` api: ${config.apiUrl} \xB7 key: ${config.key.slice(0, 12)}\u2026`);
|
|
58
|
+
} else {
|
|
59
|
+
out("\u2717 no config \u2014 run `npx tokenoscopy init`");
|
|
60
|
+
failures++;
|
|
61
|
+
}
|
|
62
|
+
const settingsPath = join2(homedir2(), ".claude", "settings.json");
|
|
63
|
+
if (existsSync2(settingsPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const settings = JSON.parse(readFileSync2(settingsPath, "utf8"));
|
|
66
|
+
const raw = JSON.stringify(settings.hooks ?? {});
|
|
67
|
+
const hasHttp = raw.includes("/api/ingest/hook");
|
|
68
|
+
const hasFlush = raw.includes("tokenoscopy flush");
|
|
69
|
+
const hasGuard = raw.includes("tokenoscopy guard");
|
|
70
|
+
out(`${hasHttp ? "\u2713" : "\u2717"} live hooks ${hasHttp ? "installed" : "missing"}`);
|
|
71
|
+
out(`${hasFlush ? "\u2713" : "\u2717"} transcript flush ${hasFlush ? "installed" : "missing"}`);
|
|
72
|
+
out(`${hasGuard ? "\u2713" : "\u2717"} budget guard ${hasGuard ? "installed" : "missing"}`);
|
|
73
|
+
if (!hasHttp || !hasFlush) failures++;
|
|
74
|
+
} catch {
|
|
75
|
+
out(`\u2717 ${settingsPath} is not valid JSON`);
|
|
76
|
+
failures++;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
out(`\u2717 no Claude Code settings at ${settingsPath}`);
|
|
80
|
+
failures++;
|
|
81
|
+
}
|
|
82
|
+
const auth = detectAuth();
|
|
83
|
+
const laneLabel = {
|
|
84
|
+
api_key: "API billing \u2014 figures are real money",
|
|
85
|
+
subscription: "Claude subscription \u2014 figures are not billed",
|
|
86
|
+
unknown: "unclassified \u2014 figures can't be called money or not"
|
|
87
|
+
}[auth.mode];
|
|
88
|
+
out(`${auth.mode === "unknown" ? "\u26A0" : "\u2713"} billing lane: ${laneLabel}`);
|
|
89
|
+
out(` evidence: ${auth.evidence}`);
|
|
90
|
+
const projectsDir = join2(homedir2(), ".claude", "projects");
|
|
91
|
+
if (existsSync2(projectsDir)) {
|
|
92
|
+
out(`\u2713 transcript directory readable (${projectsDir})`);
|
|
93
|
+
} else {
|
|
94
|
+
out(`\u26A0 no transcript directory yet (${projectsDir}) \u2014 appears after the first session`);
|
|
95
|
+
}
|
|
96
|
+
if (config) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(`${config.apiUrl}/api/health`, {
|
|
99
|
+
signal: AbortSignal.timeout(5e3)
|
|
100
|
+
});
|
|
101
|
+
out(res.ok ? "\u2713 API reachable" : `\u2717 API returned ${res.status}`);
|
|
102
|
+
if (!res.ok) failures++;
|
|
103
|
+
} catch {
|
|
104
|
+
out(`\u2717 cannot reach ${config.apiUrl}`);
|
|
105
|
+
failures++;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`${config.apiUrl}/api/ingest/hook`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"X-Tokenoscopy-Key": config.key
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
hook_event_name: "DoctorPing",
|
|
116
|
+
session_id: "00000000-doctor-ping"
|
|
117
|
+
}),
|
|
118
|
+
signal: AbortSignal.timeout(5e3)
|
|
119
|
+
});
|
|
120
|
+
if (res.status === 401) {
|
|
121
|
+
out("\u2717 ingest key rejected \u2014 rotate it in Settings \u2192 Ingest keys");
|
|
122
|
+
failures++;
|
|
123
|
+
} else {
|
|
124
|
+
out("\u2713 ingest key accepted");
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
out("\u26A0 key check skipped (network)");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out(failures === 0 ? "\nAll clear \u2014 you're recording." : `
|
|
131
|
+
${failures} problem(s) found.`);
|
|
132
|
+
return failures === 0 ? 0 : 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/commands/flush.ts
|
|
136
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
137
|
+
|
|
138
|
+
// src/identity.ts
|
|
139
|
+
import { execFileSync } from "child_process";
|
|
140
|
+
import { hostname, userInfo } from "os";
|
|
141
|
+
function git(args, cwd) {
|
|
142
|
+
try {
|
|
143
|
+
const out = execFileSync("git", args, {
|
|
144
|
+
cwd,
|
|
145
|
+
encoding: "utf8",
|
|
146
|
+
timeout: 2e3,
|
|
147
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
148
|
+
});
|
|
149
|
+
const value = out.trim();
|
|
150
|
+
return value.length > 0 ? value : void 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function detectIdentity(cwd) {
|
|
156
|
+
const dir = cwd && cwd.length > 0 ? cwd : process.cwd();
|
|
157
|
+
const envEmail = process.env.TOKENOSCOPY_ACTOR_EMAIL?.trim();
|
|
158
|
+
const envName = process.env.TOKENOSCOPY_ACTOR_NAME?.trim();
|
|
159
|
+
const email = envEmail || git(["config", "--get", "user.email"], dir) || git(["config", "--global", "--get", "user.email"]);
|
|
160
|
+
let name = envName || git(["config", "--get", "user.name"], dir) || git(["config", "--global", "--get", "user.name"]);
|
|
161
|
+
if (!name) {
|
|
162
|
+
try {
|
|
163
|
+
name = userInfo().username;
|
|
164
|
+
} catch {
|
|
165
|
+
name = void 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { email, name, machine: hostname() };
|
|
169
|
+
}
|
|
170
|
+
function detectRepoSlug(cwd) {
|
|
171
|
+
const dir = cwd && cwd.length > 0 ? cwd : process.cwd();
|
|
172
|
+
const remote = git(["config", "--get", "remote.origin.url"], dir);
|
|
173
|
+
if (!remote) return void 0;
|
|
174
|
+
const match = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
175
|
+
return match?.[1];
|
|
176
|
+
}
|
|
177
|
+
function detectRepoRoot(cwd) {
|
|
178
|
+
const dir = cwd && cwd.length > 0 ? cwd : process.cwd();
|
|
179
|
+
return git(["rev-parse", "--show-toplevel"], dir);
|
|
180
|
+
}
|
|
181
|
+
function detectBranchDrift(cwd, expectedBranch) {
|
|
182
|
+
const dir = cwd && cwd.length > 0 ? cwd : process.cwd();
|
|
183
|
+
if (expectedBranch && git(["rev-parse", "--abbrev-ref", "HEAD"], dir) !== expectedBranch) {
|
|
184
|
+
return void 0;
|
|
185
|
+
}
|
|
186
|
+
const head = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], dir);
|
|
187
|
+
const candidates = [head, "origin/main", "origin/master"].filter(
|
|
188
|
+
(c) => typeof c === "string" && c.length > 0
|
|
189
|
+
);
|
|
190
|
+
for (const base of candidates) {
|
|
191
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], dir);
|
|
192
|
+
if (!counts) continue;
|
|
193
|
+
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
|
194
|
+
if (Number.isInteger(behind) && Number.isInteger(ahead)) {
|
|
195
|
+
return { base, behind, ahead };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/chunk.ts
|
|
202
|
+
var CHUNK_MAX_BYTES = 35e5;
|
|
203
|
+
var CHUNK_MAX_EVENTS = 5e3;
|
|
204
|
+
var bytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
|
|
205
|
+
function chunkUpload(upload, { maxBytes = CHUNK_MAX_BYTES, maxEvents = CHUNK_MAX_EVENTS } = {}) {
|
|
206
|
+
const events = upload.events.map((e) => ({
|
|
207
|
+
...e,
|
|
208
|
+
payload: truncatePayload(e.payload)
|
|
209
|
+
}));
|
|
210
|
+
const groups = [];
|
|
211
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
212
|
+
for (const e of events) {
|
|
213
|
+
const key = naturalDedupKey(e);
|
|
214
|
+
const existing = key ? byKey.get(key) : void 0;
|
|
215
|
+
if (existing) {
|
|
216
|
+
existing.push(e);
|
|
217
|
+
} else {
|
|
218
|
+
const group = [e];
|
|
219
|
+
groups.push(group);
|
|
220
|
+
if (key) byKey.set(key, group);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const envelope = bytes({ ...upload, events: [] });
|
|
224
|
+
const chunks = [];
|
|
225
|
+
let current = [];
|
|
226
|
+
let size = envelope;
|
|
227
|
+
for (const group of groups) {
|
|
228
|
+
const groupBytes = group.reduce((n, e) => n + bytes(e) + 1, 0);
|
|
229
|
+
const wouldOverflow = current.length > 0 && (size + groupBytes > maxBytes || current.length + group.length > maxEvents);
|
|
230
|
+
if (wouldOverflow) {
|
|
231
|
+
chunks.push(current);
|
|
232
|
+
current = [];
|
|
233
|
+
size = envelope;
|
|
234
|
+
}
|
|
235
|
+
current.push(...group);
|
|
236
|
+
size += groupBytes;
|
|
237
|
+
}
|
|
238
|
+
if (current.length > 0 || chunks.length === 0) chunks.push(current);
|
|
239
|
+
return chunks.map((chunk) => ({ ...upload, events: chunk }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/commands/flush.ts
|
|
243
|
+
async function readStdin(timeoutMs = 3e3) {
|
|
244
|
+
if (process.stdin.isTTY) return "";
|
|
245
|
+
return await new Promise((resolve) => {
|
|
246
|
+
let data = "";
|
|
247
|
+
const timer = setTimeout(() => resolve(data), timeoutMs);
|
|
248
|
+
process.stdin.setEncoding("utf8");
|
|
249
|
+
process.stdin.on("data", (c) => data += c);
|
|
250
|
+
process.stdin.on("end", () => {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
resolve(data);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async function runFlush(args) {
|
|
257
|
+
const config = loadConfig();
|
|
258
|
+
if (!config) {
|
|
259
|
+
process.stderr.write(
|
|
260
|
+
"tokenoscopy: not configured \u2014 run `npx tokenoscopy init`\n"
|
|
261
|
+
);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
let transcriptPath = "";
|
|
265
|
+
const flagIdx = args.indexOf("--transcript");
|
|
266
|
+
if (flagIdx >= 0) transcriptPath = args[flagIdx + 1] ?? "";
|
|
267
|
+
if (!transcriptPath) {
|
|
268
|
+
const stdin = await readStdin();
|
|
269
|
+
if (stdin) {
|
|
270
|
+
try {
|
|
271
|
+
const hook = JSON.parse(stdin);
|
|
272
|
+
transcriptPath = hook.transcript_path ?? "";
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!transcriptPath || !existsSync3(transcriptPath)) {
|
|
278
|
+
process.stderr.write("tokenoscopy flush: no transcript to flush\n");
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
let upload;
|
|
282
|
+
try {
|
|
283
|
+
const { summary, events, warnings } = parseTranscript(
|
|
284
|
+
readFileSync3(transcriptPath, "utf8")
|
|
285
|
+
);
|
|
286
|
+
const identity = detectIdentity(summary.cwd);
|
|
287
|
+
summary.actor.machine_id = identity.machine;
|
|
288
|
+
summary.actor.email = identity.email;
|
|
289
|
+
summary.actor.name = identity.name;
|
|
290
|
+
summary.git_repo = detectRepoSlug(summary.cwd) ?? summary.git_repo;
|
|
291
|
+
summary.auth_mode = detectAuthMode();
|
|
292
|
+
const drift = detectBranchDrift(summary.cwd, summary.git_branch);
|
|
293
|
+
if (drift) {
|
|
294
|
+
summary.git_base = drift.base;
|
|
295
|
+
summary.git_behind = drift.behind;
|
|
296
|
+
summary.git_ahead = drift.ahead;
|
|
297
|
+
}
|
|
298
|
+
const root = detectRepoRoot(summary.cwd);
|
|
299
|
+
const redactedEvents = events.map((e) => {
|
|
300
|
+
const located = withRelPath(e, root);
|
|
301
|
+
return { ...located, payload: redactValue(located.payload) };
|
|
302
|
+
});
|
|
303
|
+
upload = { schema_version: 1, summary, events: redactedEvents };
|
|
304
|
+
if (warnings.length > 0) {
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
`tokenoscopy: parser warnings: ${warnings.join(", ")}
|
|
307
|
+
`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
process.stderr.write(`tokenoscopy flush: parse failed (${String(err)})
|
|
312
|
+
`);
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
const chunks = chunkUpload(upload);
|
|
316
|
+
let sent = 0;
|
|
317
|
+
let inserted = 0;
|
|
318
|
+
for (const [i, chunk] of chunks.entries()) {
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${config.apiUrl}/api/ingest/session`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: {
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
"X-Tokenoscopy-Key": config.key
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify(chunk),
|
|
327
|
+
signal: AbortSignal.timeout(3e4)
|
|
328
|
+
});
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
process.stderr.write(
|
|
331
|
+
`tokenoscopy flush: upload ${i + 1}/${chunks.length} failed (${res.status})
|
|
332
|
+
`
|
|
333
|
+
);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const body = await res.json();
|
|
337
|
+
sent += chunk.events.length;
|
|
338
|
+
inserted += body.inserted ?? 0;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
process.stderr.write(
|
|
341
|
+
`tokenoscopy flush: network error on upload ${i + 1}/${chunks.length} (${String(err)})
|
|
342
|
+
`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (sent > 0) {
|
|
347
|
+
process.stderr.write(
|
|
348
|
+
`tokenoscopy: session flushed (${sent} events${chunks.length > 1 ? ` in ${chunks.length} uploads` : ""}, ${inserted} new) \xB7 $${upload.summary.estimated_cost_usd.toFixed(4)} est
|
|
349
|
+
`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/commands/guard.ts
|
|
356
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
357
|
+
import { tmpdir } from "os";
|
|
358
|
+
import { join as join3 } from "path";
|
|
359
|
+
function cachePath(sessionId) {
|
|
360
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 64);
|
|
361
|
+
return join3(tmpdir(), `tokenoscopy-guard-${safe}.json`);
|
|
362
|
+
}
|
|
363
|
+
function readAllowCache(sessionId) {
|
|
364
|
+
try {
|
|
365
|
+
const raw = JSON.parse(readFileSync4(cachePath(sessionId), "utf8"));
|
|
366
|
+
return typeof raw.until === "number" && raw.until > Date.now();
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function writeAllowCache(sessionId) {
|
|
372
|
+
try {
|
|
373
|
+
writeFileSync2(
|
|
374
|
+
cachePath(sessionId),
|
|
375
|
+
JSON.stringify({ until: Date.now() + ALLOW_CACHE_MS })
|
|
376
|
+
);
|
|
377
|
+
} catch {
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function readStdin2(timeoutMs = 1e3) {
|
|
381
|
+
if (process.stdin.isTTY) return "";
|
|
382
|
+
return await new Promise((resolve) => {
|
|
383
|
+
let data = "";
|
|
384
|
+
const timer = setTimeout(() => resolve(data), timeoutMs);
|
|
385
|
+
process.stdin.setEncoding("utf8");
|
|
386
|
+
process.stdin.on("data", (c) => data += c);
|
|
387
|
+
process.stdin.on("end", () => {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
resolve(data);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async function runGuard() {
|
|
394
|
+
const config = loadConfig();
|
|
395
|
+
if (!config) return 0;
|
|
396
|
+
let sessionId = "";
|
|
397
|
+
let toolName = "";
|
|
398
|
+
let cwd = "";
|
|
399
|
+
const stdin = await readStdin2();
|
|
400
|
+
if (stdin) {
|
|
401
|
+
try {
|
|
402
|
+
const hook = JSON.parse(stdin);
|
|
403
|
+
sessionId = hook.session_id ?? "";
|
|
404
|
+
toolName = hook.tool_name ?? "";
|
|
405
|
+
cwd = hook.cwd ?? "";
|
|
406
|
+
} catch {
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (!sessionId) return 0;
|
|
411
|
+
if (readAllowCache(sessionId)) return 0;
|
|
412
|
+
const repo = cwd.split("/").filter(Boolean).pop();
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetch(`${config.apiUrl}/api/guard/check`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: {
|
|
417
|
+
"Content-Type": "application/json",
|
|
418
|
+
"X-Tokenoscopy-Key": config.key
|
|
419
|
+
},
|
|
420
|
+
body: JSON.stringify({
|
|
421
|
+
session_id: sessionId,
|
|
422
|
+
tool_name: toolName || "unknown",
|
|
423
|
+
git_repo: repo
|
|
424
|
+
}),
|
|
425
|
+
signal: AbortSignal.timeout(GUARD_TIMEOUT_MS)
|
|
426
|
+
});
|
|
427
|
+
if (!res.ok) return 0;
|
|
428
|
+
const body = await res.json();
|
|
429
|
+
if (body.decision === "allow" || !body.decision) {
|
|
430
|
+
writeAllowCache(sessionId);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
if (body.decision === "deny" || body.decision === "ask") {
|
|
434
|
+
process.stdout.write(
|
|
435
|
+
`${JSON.stringify({
|
|
436
|
+
hookSpecificOutput: {
|
|
437
|
+
hookEventName: "PreToolUse",
|
|
438
|
+
permissionDecision: body.decision,
|
|
439
|
+
permissionDecisionReason: body.reason ?? "Tokenoscopy budget policy"
|
|
440
|
+
}
|
|
441
|
+
})}
|
|
442
|
+
`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return 0;
|
|
446
|
+
} catch {
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/commands/init.ts
|
|
452
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
453
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, copyFileSync } from "fs";
|
|
454
|
+
import { homedir as homedir3 } from "os";
|
|
455
|
+
import { join as join4 } from "path";
|
|
456
|
+
import { createInterface } from "readline/promises";
|
|
457
|
+
var MARKER = "tokenoscopy";
|
|
458
|
+
function isOurs(group) {
|
|
459
|
+
return JSON.stringify(group).toLowerCase().includes(MARKER);
|
|
460
|
+
}
|
|
461
|
+
function resolveInvocation() {
|
|
462
|
+
const override = process.env.TOKENOSCOPY_BIN?.trim();
|
|
463
|
+
if (override) return override;
|
|
464
|
+
try {
|
|
465
|
+
const found = execFileSync2("command", ["-v", "tokenoscopy"], {
|
|
466
|
+
encoding: "utf8",
|
|
467
|
+
shell: "/bin/sh",
|
|
468
|
+
timeout: 2e3,
|
|
469
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
470
|
+
}).trim();
|
|
471
|
+
if (found) return "tokenoscopy";
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
return "npx -y tokenoscopy";
|
|
475
|
+
}
|
|
476
|
+
function buildHookGroups(apiUrl, key) {
|
|
477
|
+
const bin = resolveInvocation();
|
|
478
|
+
const http = (name) => ({
|
|
479
|
+
hooks: [
|
|
480
|
+
{
|
|
481
|
+
type: "http",
|
|
482
|
+
url: `${apiUrl}/api/ingest/hook`,
|
|
483
|
+
headers: { "X-Tokenoscopy-Key": key },
|
|
484
|
+
async: true,
|
|
485
|
+
// marker for idempotent re-init
|
|
486
|
+
_tokenoscopy: true,
|
|
487
|
+
_event: name
|
|
488
|
+
}
|
|
489
|
+
]
|
|
490
|
+
});
|
|
491
|
+
const cmd = (command, async, timeout) => ({
|
|
492
|
+
hooks: [
|
|
493
|
+
{
|
|
494
|
+
type: "command",
|
|
495
|
+
command,
|
|
496
|
+
...async ? { async: true } : {},
|
|
497
|
+
...timeout ? { timeout } : {},
|
|
498
|
+
_tokenoscopy: true
|
|
499
|
+
}
|
|
500
|
+
]
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
SessionStart: [http("SessionStart")],
|
|
504
|
+
UserPromptSubmit: [http("UserPromptSubmit")],
|
|
505
|
+
PostToolUse: [{ matcher: ".*", ...http("PostToolUse") }],
|
|
506
|
+
PostToolUseFailure: [http("PostToolUseFailure")],
|
|
507
|
+
SubagentStop: [http("SubagentStop")],
|
|
508
|
+
Stop: [cmd(`${bin} flush`, true)],
|
|
509
|
+
SessionEnd: [cmd(`${bin} flush`)],
|
|
510
|
+
PreToolUse: [{ matcher: "Bash|mcp__.*", ...cmd(`${bin} guard`, false, 10) }]
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function mergeHooks(existing, ours) {
|
|
514
|
+
const merged = { ...existing ?? {} };
|
|
515
|
+
for (const [event, groups] of Object.entries(ours)) {
|
|
516
|
+
const kept = (merged[event] ?? []).filter((g) => !isOurs(g));
|
|
517
|
+
merged[event] = [...kept, ...groups];
|
|
518
|
+
}
|
|
519
|
+
return merged;
|
|
520
|
+
}
|
|
521
|
+
async function runInit(args) {
|
|
522
|
+
const flags = /* @__PURE__ */ new Map();
|
|
523
|
+
for (let i = 0; i < args.length; i++) {
|
|
524
|
+
const a = args[i];
|
|
525
|
+
if (a === "--project" || a === "--print") flags.set(a, true);
|
|
526
|
+
else if (a.startsWith("--")) flags.set(a, args[++i] ?? "");
|
|
527
|
+
}
|
|
528
|
+
const apiUrl = flags.get("--url") || process.env.TOKENOSCOPY_API_URL || DEFAULT_API_URL;
|
|
529
|
+
let key = flags.get("--key") || process.env.TOKENOSCOPY_KEY || "";
|
|
530
|
+
if (!key) {
|
|
531
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
532
|
+
key = (await rl.question("Paste your ingest key (tok_\u2026): ")).trim();
|
|
533
|
+
rl.close();
|
|
534
|
+
}
|
|
535
|
+
if (!key.startsWith("tok_")) {
|
|
536
|
+
process.stderr.write("\u2717 That doesn't look like a Tokenoscopy ingest key (tok_\u2026).\n");
|
|
537
|
+
return 1;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const res = await fetch(`${apiUrl}/api/ingest/hook`, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: { "Content-Type": "application/json", "X-Tokenoscopy-Key": key },
|
|
543
|
+
body: JSON.stringify({ hook_event_name: "DoctorPing", session_id: "00000000-doctor-ping" }),
|
|
544
|
+
signal: AbortSignal.timeout(8e3)
|
|
545
|
+
});
|
|
546
|
+
if (res.status === 401) {
|
|
547
|
+
process.stderr.write("\u2717 Key rejected by the API \u2014 check it in Settings \u2192 Ingest keys.\n");
|
|
548
|
+
return 1;
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
process.stderr.write(`\u26A0 Could not reach ${apiUrl} \u2014 writing config anyway (doctor will re-check).
|
|
552
|
+
`);
|
|
553
|
+
}
|
|
554
|
+
const config = { apiUrl, key };
|
|
555
|
+
saveConfig(config);
|
|
556
|
+
const settingsPath = flags.get("--project") ? join4(process.cwd(), ".claude", "settings.json") : join4(homedir3(), ".claude", "settings.json");
|
|
557
|
+
let settings = {};
|
|
558
|
+
if (existsSync4(settingsPath)) {
|
|
559
|
+
try {
|
|
560
|
+
settings = JSON.parse(readFileSync5(settingsPath, "utf8"));
|
|
561
|
+
copyFileSync(settingsPath, `${settingsPath}.tokenoscopy-backup`);
|
|
562
|
+
} catch {
|
|
563
|
+
process.stderr.write(`\u2717 ${settingsPath} exists but isn't valid JSON \u2014 fix it first.
|
|
564
|
+
`);
|
|
565
|
+
return 1;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
settings.hooks = mergeHooks(
|
|
569
|
+
settings.hooks,
|
|
570
|
+
buildHookGroups(apiUrl, key)
|
|
571
|
+
);
|
|
572
|
+
if (flags.get("--print")) {
|
|
573
|
+
process.stdout.write(`${JSON.stringify({ hooks: settings.hooks }, null, 2)}
|
|
574
|
+
`);
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
const dir = join4(settingsPath, "..");
|
|
578
|
+
if (!existsSync4(dir)) {
|
|
579
|
+
const { mkdirSync: mkdirSync2 } = await import("fs");
|
|
580
|
+
mkdirSync2(dir, { recursive: true });
|
|
581
|
+
}
|
|
582
|
+
writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
583
|
+
`);
|
|
584
|
+
process.stderr.write(
|
|
585
|
+
[
|
|
586
|
+
"\u2713 config written",
|
|
587
|
+
`\u2713 hooks installed \u2192 ${settingsPath} (backup saved)`,
|
|
588
|
+
"\u2713 redaction on (secrets scrubbed before upload)",
|
|
589
|
+
"\u2713 recording \u2014 run any Claude Code session and watch it land in the dashboard",
|
|
590
|
+
""
|
|
591
|
+
].join("\n")
|
|
592
|
+
);
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/commands/tail.ts
|
|
597
|
+
import { watch, existsSync as existsSync5, readdirSync, statSync } from "fs";
|
|
598
|
+
import { homedir as homedir4 } from "os";
|
|
599
|
+
import { join as join5 } from "path";
|
|
600
|
+
var DEBOUNCE_MS = 3e3;
|
|
601
|
+
async function runTail(args) {
|
|
602
|
+
const projectsDir = process.env.TOKENOSCOPY_PROJECTS_DIR ?? join5(homedir4(), ".claude", "projects");
|
|
603
|
+
if (!existsSync5(projectsDir)) {
|
|
604
|
+
process.stderr.write(`tokenoscopy tail: ${projectsDir} does not exist
|
|
605
|
+
`);
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
if (args.includes("--backfill")) {
|
|
609
|
+
const sinceIdx = args.indexOf("--since");
|
|
610
|
+
const sinceDays = sinceIdx >= 0 ? Number.parseInt(args[sinceIdx + 1] ?? "", 10) : 7;
|
|
611
|
+
const days = Number.isFinite(sinceDays) && sinceDays > 0 ? sinceDays : 7;
|
|
612
|
+
const cutoff = Date.now() - days * 24 * 3600 * 1e3;
|
|
613
|
+
let count = 0;
|
|
614
|
+
for (const dir of readdirSync(projectsDir)) {
|
|
615
|
+
const full = join5(projectsDir, dir);
|
|
616
|
+
let entries;
|
|
617
|
+
try {
|
|
618
|
+
entries = readdirSync(full);
|
|
619
|
+
} catch {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
for (const f of entries) {
|
|
623
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
624
|
+
const p = join5(full, f);
|
|
625
|
+
try {
|
|
626
|
+
if (statSync(p).mtimeMs >= cutoff) {
|
|
627
|
+
await runFlush(["--transcript", p]);
|
|
628
|
+
count++;
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
process.stderr.write(`tokenoscopy tail: backfilled ${count} transcript(s)
|
|
635
|
+
`);
|
|
636
|
+
if (!args.includes("--watch")) return 0;
|
|
637
|
+
}
|
|
638
|
+
process.stderr.write(`tokenoscopy tail: watching ${projectsDir}
|
|
639
|
+
`);
|
|
640
|
+
const pending = /* @__PURE__ */ new Map();
|
|
641
|
+
watch(projectsDir, { recursive: true }, (_event, filename) => {
|
|
642
|
+
if (!filename || !filename.endsWith(".jsonl")) return;
|
|
643
|
+
const full = join5(projectsDir, filename);
|
|
644
|
+
const existing = pending.get(full);
|
|
645
|
+
if (existing) clearTimeout(existing);
|
|
646
|
+
pending.set(
|
|
647
|
+
full,
|
|
648
|
+
setTimeout(() => {
|
|
649
|
+
pending.delete(full);
|
|
650
|
+
if (existsSync5(full)) {
|
|
651
|
+
void runFlush(["--transcript", full]);
|
|
652
|
+
}
|
|
653
|
+
}, DEBOUNCE_MS)
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
await new Promise(() => {
|
|
657
|
+
});
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/cli.ts
|
|
662
|
+
var HELP = `tokenoscopy \u2014 flight recorder & spend control for coding-agent fleets
|
|
663
|
+
|
|
664
|
+
Usage: tokenoscopy <command> [options]
|
|
665
|
+
|
|
666
|
+
Commands:
|
|
667
|
+
init Wire Claude Code hooks + transcript flush to your workspace
|
|
668
|
+
--key tok_\u2026 ingest key (or TOKENOSCOPY_KEY)
|
|
669
|
+
--url https://\u2026 API base (default https://tokenoscopy.com)
|
|
670
|
+
--project write hooks to ./.claude/settings.json (CI)
|
|
671
|
+
--print print the hook config instead of writing
|
|
672
|
+
flush Parse + upload the current session transcript (called by hooks)
|
|
673
|
+
--transcript <path> flush a specific transcript file
|
|
674
|
+
guard Budget check for PreToolUse (called by hooks; always fail-open)
|
|
675
|
+
tail Watch ~/.claude/projects and flush transcripts as they change
|
|
676
|
+
--backfill flush the last 7 days first
|
|
677
|
+
--since <days> with --backfill: how far back (relabels lanes)
|
|
678
|
+
--watch keep watching after backfill
|
|
679
|
+
doctor Verify hook wiring, connectivity, key validity, and billing lane
|
|
680
|
+
|
|
681
|
+
Docs: https://tokenoscopy.com
|
|
682
|
+
`;
|
|
683
|
+
async function main() {
|
|
684
|
+
const [command, ...args] = process.argv.slice(2);
|
|
685
|
+
switch (command) {
|
|
686
|
+
case "init":
|
|
687
|
+
return runInit(args);
|
|
688
|
+
case "flush":
|
|
689
|
+
return runFlush(args);
|
|
690
|
+
case "guard":
|
|
691
|
+
return runGuard();
|
|
692
|
+
case "tail":
|
|
693
|
+
return runTail(args);
|
|
694
|
+
case "doctor":
|
|
695
|
+
return runDoctor();
|
|
696
|
+
case void 0:
|
|
697
|
+
case "help":
|
|
698
|
+
case "--help":
|
|
699
|
+
case "-h":
|
|
700
|
+
process.stdout.write(HELP);
|
|
701
|
+
return 0;
|
|
702
|
+
default:
|
|
703
|
+
process.stderr.write(`tokenoscopy: unknown command '${command}'
|
|
704
|
+
|
|
705
|
+
${HELP}`);
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
main().then(
|
|
710
|
+
(code) => process.exit(code),
|
|
711
|
+
(err) => {
|
|
712
|
+
process.stderr.write(`tokenoscopy: ${String(err)}
|
|
713
|
+
`);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
);
|