commitshow 0.3.30 → 0.3.31
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/commands/extract.js +234 -0
- package/dist/index.js +5 -0
- package/package.json +2 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// commitshow extract · scan ~/.claude/projects/ for the current repo's
|
|
2
|
+
// session JSONL files, sum token usage, copy a paste-able blob to the
|
|
3
|
+
// clipboard. Audition flow on commit.show takes the blob, decodes it
|
|
4
|
+
// server-side, and writes verified rows into audit_token_usage.
|
|
5
|
+
//
|
|
6
|
+
// Privacy · this command reads JSONL files but only extracts the
|
|
7
|
+
// `usage` blocks (token counters). Prompt content NEVER leaves the
|
|
8
|
+
// machine. The blob carries numbers + session UUIDs + first/last
|
|
9
|
+
// timestamps + a content hash for dedupe — nothing else.
|
|
10
|
+
//
|
|
11
|
+
// Why ~/.claude/projects · Claude Code stores per-session JSONL there
|
|
12
|
+
// in `<encoded-cwd>/<session-uuid>.jsonl` form. The encoded-cwd is the
|
|
13
|
+
// abs path of the working directory with `/` → `-`. Files are append-only
|
|
14
|
+
// during sessions and grow linearly with conversation length.
|
|
15
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { homedir, platform } from 'node:os';
|
|
17
|
+
import { join, basename } from 'node:path';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
import { resolveTarget, TargetError } from '../lib/target.js';
|
|
20
|
+
import { c } from '../lib/colors.js';
|
|
21
|
+
const HEADER = `${c.bold(c.gold('commit.show extract'))} ${c.dim('· token receipt for your audition')}`;
|
|
22
|
+
function fmtNumber(n) {
|
|
23
|
+
if (n >= 1_000_000)
|
|
24
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
25
|
+
if (n >= 1_000)
|
|
26
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
27
|
+
return String(n);
|
|
28
|
+
}
|
|
29
|
+
function safeStat(path) {
|
|
30
|
+
try {
|
|
31
|
+
return statSync(path);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function readJsonlSafe(path) {
|
|
38
|
+
const totals = {
|
|
39
|
+
session_id: basename(path).replace(/\.jsonl$/, ''),
|
|
40
|
+
input_tokens: 0,
|
|
41
|
+
output_tokens: 0,
|
|
42
|
+
cache_create_tokens: 0,
|
|
43
|
+
cache_read_tokens: 0,
|
|
44
|
+
message_count: 0,
|
|
45
|
+
first_seen_at: null,
|
|
46
|
+
last_seen_at: null,
|
|
47
|
+
cwd: null,
|
|
48
|
+
};
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = readFileSync(path, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
for (const line of raw.split('\n')) {
|
|
57
|
+
if (!line.trim())
|
|
58
|
+
continue;
|
|
59
|
+
let evt;
|
|
60
|
+
try {
|
|
61
|
+
evt = JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (typeof evt.cwd === 'string')
|
|
67
|
+
totals.cwd = evt.cwd;
|
|
68
|
+
const usage = evt?.message?.usage;
|
|
69
|
+
if (usage && typeof usage === 'object') {
|
|
70
|
+
totals.input_tokens += usage.input_tokens ?? 0;
|
|
71
|
+
totals.output_tokens += usage.output_tokens ?? 0;
|
|
72
|
+
totals.cache_create_tokens += usage.cache_creation_input_tokens ?? 0;
|
|
73
|
+
totals.cache_read_tokens += usage.cache_read_input_tokens ?? 0;
|
|
74
|
+
totals.message_count++;
|
|
75
|
+
const ts = evt.timestamp ?? null;
|
|
76
|
+
if (ts) {
|
|
77
|
+
if (!totals.first_seen_at || ts < totals.first_seen_at)
|
|
78
|
+
totals.first_seen_at = ts;
|
|
79
|
+
if (!totals.last_seen_at || ts > totals.last_seen_at)
|
|
80
|
+
totals.last_seen_at = ts;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return totals;
|
|
85
|
+
}
|
|
86
|
+
function listSessionFiles(rootCwd) {
|
|
87
|
+
// Encoded directory pattern: `/Users/foo/myrepo` → `-Users-foo-myrepo`.
|
|
88
|
+
// Claude Code historically uses this exact transform; we replicate it.
|
|
89
|
+
const dirsRoot = join(homedir(), '.claude', 'projects');
|
|
90
|
+
if (!safeStat(dirsRoot)?.isDirectory())
|
|
91
|
+
return [];
|
|
92
|
+
// Try direct hit first via the canonical encoding.
|
|
93
|
+
const encoded = rootCwd.replace(/\//g, '-');
|
|
94
|
+
const direct = join(dirsRoot, encoded);
|
|
95
|
+
const directOk = safeStat(direct)?.isDirectory();
|
|
96
|
+
// Fallback · scan all subdirs and match by reading the first session's
|
|
97
|
+
// `cwd` field. Claude Code's encoding is OS-dependent and not perfectly
|
|
98
|
+
// round-trippable on all paths, so direct hit + scan covers both cases.
|
|
99
|
+
const candidates = [];
|
|
100
|
+
if (directOk) {
|
|
101
|
+
for (const f of readdirSync(direct)) {
|
|
102
|
+
if (f.endsWith('.jsonl'))
|
|
103
|
+
candidates.push(join(direct, f));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
for (const sub of readdirSync(dirsRoot)) {
|
|
108
|
+
const subPath = join(dirsRoot, sub);
|
|
109
|
+
if (!safeStat(subPath)?.isDirectory())
|
|
110
|
+
continue;
|
|
111
|
+
for (const f of readdirSync(subPath)) {
|
|
112
|
+
if (!f.endsWith('.jsonl'))
|
|
113
|
+
continue;
|
|
114
|
+
const full = join(subPath, f);
|
|
115
|
+
// Cheap pre-check · read first 4KB and look for the cwd we want
|
|
116
|
+
try {
|
|
117
|
+
const head = readFileSync(full, 'utf8').slice(0, 8192);
|
|
118
|
+
if (head.includes(`"cwd":"${rootCwd}"`))
|
|
119
|
+
candidates.push(full);
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip unreadable */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return candidates;
|
|
126
|
+
}
|
|
127
|
+
function copyToClipboard(text) {
|
|
128
|
+
try {
|
|
129
|
+
const cmd = platform() === 'darwin' ? 'pbcopy'
|
|
130
|
+
: platform() === 'win32' ? 'clip'
|
|
131
|
+
: 'xclip -selection clipboard'; // best-effort on linux
|
|
132
|
+
execSync(cmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export async function extract(args) {
|
|
140
|
+
const asJson = args.includes('--json');
|
|
141
|
+
const positional = args.find(a => !a.startsWith('--'));
|
|
142
|
+
let target;
|
|
143
|
+
try {
|
|
144
|
+
target = resolveTarget(positional, { workspace: null });
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (e instanceof TargetError) {
|
|
148
|
+
console.error(c.scarlet(e.message));
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
if (!asJson) {
|
|
154
|
+
console.log();
|
|
155
|
+
console.log(HEADER);
|
|
156
|
+
console.log();
|
|
157
|
+
}
|
|
158
|
+
// Use the local cwd (or the path target) as the lookup key. Remote URL
|
|
159
|
+
// targets fall back to scanning the entire ~/.claude/projects/ for any
|
|
160
|
+
// session whose `cwd` matches a directory containing the same git remote.
|
|
161
|
+
const rootCwd = target.localPath ?? process.cwd();
|
|
162
|
+
const sessionFiles = listSessionFiles(rootCwd);
|
|
163
|
+
if (sessionFiles.length === 0) {
|
|
164
|
+
if (asJson) {
|
|
165
|
+
console.log(JSON.stringify({ ok: false, reason: 'no_sessions_found', searched: rootCwd }));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
console.error(c.muted(` no Claude Code sessions found for ${c.cream(rootCwd)}.`));
|
|
169
|
+
console.error(c.muted(` expected location: ~/.claude/projects/${c.dim(rootCwd.replace(/\//g, '-'))}/*.jsonl`));
|
|
170
|
+
console.error(c.muted(` if you've been running Claude Code from inside the repo, the file should appear after the next assistant turn.`));
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const sessions = [];
|
|
174
|
+
for (const f of sessionFiles) {
|
|
175
|
+
const t = readJsonlSafe(f);
|
|
176
|
+
if (t && (t.input_tokens + t.output_tokens + t.cache_create_tokens + t.cache_read_tokens) > 0) {
|
|
177
|
+
sessions.push(t);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const totals = sessions.reduce((acc, s) => ({
|
|
181
|
+
input_tokens: acc.input_tokens + s.input_tokens,
|
|
182
|
+
output_tokens: acc.output_tokens + s.output_tokens,
|
|
183
|
+
cache_create_tokens: acc.cache_create_tokens + s.cache_create_tokens,
|
|
184
|
+
cache_read_tokens: acc.cache_read_tokens + s.cache_read_tokens,
|
|
185
|
+
}), { input_tokens: 0, output_tokens: 0, cache_create_tokens: 0, cache_read_tokens: 0 });
|
|
186
|
+
const blobJson = {
|
|
187
|
+
v: 1,
|
|
188
|
+
source: 'claude_code',
|
|
189
|
+
tool_version: 'commitshow-cli',
|
|
190
|
+
github_url: target.github_url ?? null,
|
|
191
|
+
extracted_at: new Date().toISOString(),
|
|
192
|
+
sessions,
|
|
193
|
+
};
|
|
194
|
+
// Buffer is Node-only · CLI runs on Node so this is fine.
|
|
195
|
+
const blob = `cs_v1:${Buffer.from(JSON.stringify(blobJson)).toString('base64')}`;
|
|
196
|
+
const totalTokens = totals.input_tokens + totals.output_tokens + totals.cache_create_tokens + totals.cache_read_tokens;
|
|
197
|
+
const copied = copyToClipboard(blob);
|
|
198
|
+
if (asJson) {
|
|
199
|
+
console.log(JSON.stringify({
|
|
200
|
+
ok: true,
|
|
201
|
+
sessions: sessions.length,
|
|
202
|
+
total_tokens: totalTokens,
|
|
203
|
+
totals,
|
|
204
|
+
blob,
|
|
205
|
+
clipboard_copied: copied,
|
|
206
|
+
}, null, 2));
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
// Pretty rendering
|
|
210
|
+
console.log(` ${c.cream(`${sessions.length} session${sessions.length === 1 ? '' : 's'} scanned`)} ${c.muted(`from ~/.claude/projects/`)}`);
|
|
211
|
+
console.log(` ${c.cream(target.github_url ?? rootCwd)}`);
|
|
212
|
+
console.log();
|
|
213
|
+
console.log(` ${c.gold('Token totals')}`);
|
|
214
|
+
console.log(` ${c.muted('input ')} ${c.cream(fmtNumber(totals.input_tokens).padStart(8))}`);
|
|
215
|
+
console.log(` ${c.muted('output ')} ${c.cream(fmtNumber(totals.output_tokens).padStart(8))}`);
|
|
216
|
+
console.log(` ${c.muted('cache write ')} ${c.cream(fmtNumber(totals.cache_create_tokens).padStart(8))}`);
|
|
217
|
+
console.log(` ${c.muted('cache read ')} ${c.cream(fmtNumber(totals.cache_read_tokens).padStart(8))}`);
|
|
218
|
+
console.log(` ${c.muted('────────────────────────')}`);
|
|
219
|
+
console.log(` ${c.bold(c.gold('total '))} ${c.bold(c.gold(fmtNumber(totalTokens).padStart(8)))}`);
|
|
220
|
+
console.log();
|
|
221
|
+
if (copied) {
|
|
222
|
+
console.log(` ${c.cream('✓')} ${c.muted('blob copied to clipboard')} ${c.dim(`(${blob.length} chars)`)}`);
|
|
223
|
+
console.log(` ${c.muted('paste it into the audition form on commit.show')}`);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
console.log(` ${c.muted('clipboard tool unavailable · copy this blob manually:')}`);
|
|
227
|
+
console.log();
|
|
228
|
+
console.log(c.dim(blob));
|
|
229
|
+
}
|
|
230
|
+
console.log();
|
|
231
|
+
console.log(` ${c.muted('privacy · only token COUNTS leave your machine. prompt text stays local.')}`);
|
|
232
|
+
console.log();
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { install } from './commands/install.js';
|
|
|
7
7
|
import { status } from './commands/status.js';
|
|
8
8
|
import { login } from './commands/login.js';
|
|
9
9
|
import { whoami } from './commands/whoami.js';
|
|
10
|
+
import { extract } from './commands/extract.js';
|
|
10
11
|
import { c } from './lib/colors.js';
|
|
11
12
|
import { checkLatestVersion, formatUpdateBanner } from './lib/version-check.js';
|
|
12
13
|
// Read version from package.json at runtime so a hardcoded constant
|
|
@@ -35,6 +36,7 @@ ${c.muted('COMMANDS')}
|
|
|
35
36
|
${c.gold('status')} [target] latest score, no re-run
|
|
36
37
|
${c.gold('submit')} [target] audition a project (requires login · coming soon)
|
|
37
38
|
${c.gold('install')} <slug> install a library pack (e.g. supabase-resend-auth)
|
|
39
|
+
${c.gold('extract')} token receipt for your audition (Claude Code sessions → blob)
|
|
38
40
|
${c.gold('login')} device-flow sign-in (coming soon)
|
|
39
41
|
${c.gold('whoami')} who am I signed in as
|
|
40
42
|
|
|
@@ -91,6 +93,9 @@ export async function main(argv) {
|
|
|
91
93
|
case 'whoami':
|
|
92
94
|
code = await whoami(rest);
|
|
93
95
|
break;
|
|
96
|
+
case 'extract':
|
|
97
|
+
code = await extract(rest);
|
|
98
|
+
break;
|
|
94
99
|
case '-v':
|
|
95
100
|
case '--version':
|
|
96
101
|
console.log(VERSION);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commitshow",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "commit.show CLI
|
|
3
|
+
"version": "0.3.31",
|
|
4
|
+
"description": "commit.show CLI — audit any vibe-coded project from your terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"commitshow": "./bin/commitshow.js"
|