commitshow 0.3.30 → 0.3.33

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.
@@ -161,6 +161,39 @@ export async function audit(args) {
161
161
  // Error envelope
162
162
  if ('error' in result) {
163
163
  const err = result;
164
+ // Friendly path for the most common CLI miss: trying to audit a
165
+ // private/missing/typo'd repo. Used to silently produce a 'ghost
166
+ // repo' snapshot scored 4 — confusing because users assumed their
167
+ // project actually scored that. Server now bails early with a
168
+ // dedicated envelope; we render a clear panel instead of a score.
169
+ if (err.error === 'github_inaccessible') {
170
+ if (asJson) {
171
+ process.stdout.write(JSON.stringify({
172
+ error: 'github_inaccessible',
173
+ reason: err.reason,
174
+ slug: err.slug,
175
+ github_url: err.github_url,
176
+ message: err.message,
177
+ hints: err.hints,
178
+ target: target.github_url,
179
+ }) + '\n');
180
+ }
181
+ else {
182
+ console.error('');
183
+ console.error(` ${c.scarlet('✗')} ${c.bold(c.cream("Couldn't reach"))} ${c.gold(err.slug ?? target.github_url)}`);
184
+ console.error('');
185
+ console.error(` ${c.muted(err.message ?? "We can't see this repo.")}`);
186
+ console.error('');
187
+ if (err.hints && err.hints.length > 0) {
188
+ console.error(` ${c.muted('common causes:')}`);
189
+ for (const hint of err.hints) {
190
+ console.error(` ${c.gold('·')} ${c.muted(hint)}`);
191
+ }
192
+ console.error('');
193
+ }
194
+ }
195
+ return 1;
196
+ }
164
197
  if (err.error === 'rate_limited') {
165
198
  if (asJson) {
166
199
  process.stdout.write(JSON.stringify({
@@ -175,8 +208,12 @@ export async function audit(args) {
175
208
  }
176
209
  else {
177
210
  console.error('');
211
+ // err.reason can include non-cap values (e.g. private_or_missing)
212
+ // since PreviewError unions all reasons; renderRateLimitDeny only
213
+ // accepts the cap variants — narrow the input before passing.
214
+ const capReason = err.reason === 'url_cap' || err.reason === 'global_cap' ? err.reason : 'ip_cap';
178
215
  console.error(renderRateLimitDeny({
179
- reason: err.reason ?? 'ip_cap',
216
+ reason: capReason,
180
217
  message: err.message ?? 'Rate limit hit. Try again later.',
181
218
  limit: err.limit ?? 0,
182
219
  count: err.count ?? 0,
@@ -0,0 +1,247 @@
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
+ // Unlike `audit`, extract doesn't NEED a GitHub URL — it just scans
143
+ // ~/.claude/projects/<encoded-cwd>/*.jsonl for token usage. github_url
144
+ // is purely optional metadata in the blob (helps the server match the
145
+ // receipt back to the right project on commit.show). So we try to
146
+ // resolve a target but fall back to a cwd-only target when there's no
147
+ // git remote — instead of bailing with audit's "No git remote" error.
148
+ let target = null;
149
+ try {
150
+ target = resolveTarget(positional, { workspace: null });
151
+ }
152
+ catch (e) {
153
+ if (e instanceof TargetError) {
154
+ // Treat as 'no github_url' rather than fatal · scan still works.
155
+ target = { github_url: null, localPath: positional ? positional : process.cwd() };
156
+ }
157
+ else {
158
+ throw e;
159
+ }
160
+ }
161
+ if (!asJson) {
162
+ console.log();
163
+ console.log(HEADER);
164
+ console.log();
165
+ if (!target.github_url) {
166
+ console.log(c.muted(` no git remote detected · receipt will scan local Claude Code sessions only`));
167
+ console.log(c.muted(` paste the blob into your project's audition form on commit.show — that's where it gets matched`));
168
+ console.log();
169
+ }
170
+ }
171
+ // Use the local cwd (or the path target) as the lookup key. Remote URL
172
+ // targets fall back to scanning the entire ~/.claude/projects/ for any
173
+ // session whose `cwd` matches a directory containing the same git remote.
174
+ const rootCwd = target.localPath ?? process.cwd();
175
+ const sessionFiles = listSessionFiles(rootCwd);
176
+ if (sessionFiles.length === 0) {
177
+ if (asJson) {
178
+ console.log(JSON.stringify({ ok: false, reason: 'no_sessions_found', searched: rootCwd }));
179
+ return 1;
180
+ }
181
+ console.error(c.muted(` no Claude Code sessions found for ${c.cream(rootCwd)}.`));
182
+ console.error(c.muted(` expected location: ~/.claude/projects/${c.dim(rootCwd.replace(/\//g, '-'))}/*.jsonl`));
183
+ console.error(c.muted(` if you've been running Claude Code from inside the repo, the file should appear after the next assistant turn.`));
184
+ return 1;
185
+ }
186
+ const sessions = [];
187
+ for (const f of sessionFiles) {
188
+ const t = readJsonlSafe(f);
189
+ if (t && (t.input_tokens + t.output_tokens + t.cache_create_tokens + t.cache_read_tokens) > 0) {
190
+ sessions.push(t);
191
+ }
192
+ }
193
+ const totals = sessions.reduce((acc, s) => ({
194
+ input_tokens: acc.input_tokens + s.input_tokens,
195
+ output_tokens: acc.output_tokens + s.output_tokens,
196
+ cache_create_tokens: acc.cache_create_tokens + s.cache_create_tokens,
197
+ cache_read_tokens: acc.cache_read_tokens + s.cache_read_tokens,
198
+ }), { input_tokens: 0, output_tokens: 0, cache_create_tokens: 0, cache_read_tokens: 0 });
199
+ const blobJson = {
200
+ v: 1,
201
+ source: 'claude_code',
202
+ tool_version: 'commitshow-cli',
203
+ github_url: target.github_url ?? null,
204
+ extracted_at: new Date().toISOString(),
205
+ sessions,
206
+ };
207
+ // Buffer is Node-only · CLI runs on Node so this is fine.
208
+ const blob = `cs_v1:${Buffer.from(JSON.stringify(blobJson)).toString('base64')}`;
209
+ const totalTokens = totals.input_tokens + totals.output_tokens + totals.cache_create_tokens + totals.cache_read_tokens;
210
+ const copied = copyToClipboard(blob);
211
+ if (asJson) {
212
+ console.log(JSON.stringify({
213
+ ok: true,
214
+ sessions: sessions.length,
215
+ total_tokens: totalTokens,
216
+ totals,
217
+ blob,
218
+ clipboard_copied: copied,
219
+ }, null, 2));
220
+ return 0;
221
+ }
222
+ // Pretty rendering
223
+ console.log(` ${c.cream(`${sessions.length} session${sessions.length === 1 ? '' : 's'} scanned`)} ${c.muted(`from ~/.claude/projects/`)}`);
224
+ console.log(` ${c.cream(target.github_url ?? rootCwd)}`);
225
+ console.log();
226
+ console.log(` ${c.gold('Token totals')}`);
227
+ console.log(` ${c.muted('input ')} ${c.cream(fmtNumber(totals.input_tokens).padStart(8))}`);
228
+ console.log(` ${c.muted('output ')} ${c.cream(fmtNumber(totals.output_tokens).padStart(8))}`);
229
+ console.log(` ${c.muted('cache write ')} ${c.cream(fmtNumber(totals.cache_create_tokens).padStart(8))}`);
230
+ console.log(` ${c.muted('cache read ')} ${c.cream(fmtNumber(totals.cache_read_tokens).padStart(8))}`);
231
+ console.log(` ${c.muted('────────────────────────')}`);
232
+ console.log(` ${c.bold(c.gold('total '))} ${c.bold(c.gold(fmtNumber(totalTokens).padStart(8)))}`);
233
+ console.log();
234
+ if (copied) {
235
+ console.log(` ${c.cream('✓')} ${c.muted('blob copied to clipboard')} ${c.dim(`(${blob.length} chars)`)}`);
236
+ console.log(` ${c.muted('paste it into the audition form on commit.show')}`);
237
+ }
238
+ else {
239
+ console.log(` ${c.muted('clipboard tool unavailable · copy this blob manually:')}`);
240
+ console.log();
241
+ console.log(c.dim(blob));
242
+ }
243
+ console.log();
244
+ console.log(` ${c.muted('privacy · only token COUNTS leave your machine. prompt text stays local.')}`);
245
+ console.log();
246
+ return 0;
247
+ }
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.33",
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"