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.
@@ -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.30",
4
- "description": "commit.show CLI \u2014 audit any vibe-coded project from your terminal.",
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"