commitshow 0.1.2 → 0.1.4

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.
@@ -1,6 +1,6 @@
1
1
  import { resolveTarget, TargetError } from '../lib/target.js';
2
2
  import { findProjectByGithubUrl, fetchLatestSnapshot, fetchStanding, runPreviewAudit, waitForPreviewSnapshot, } from '../lib/api.js';
3
- import { renderAudit, renderMarkdown, renderJson, renderUpsell, renderQuotaFooter, renderRateLimitDeny, writeAuditMarkdown, writeAuditJson, } from '../lib/render.js';
3
+ import { renderAudit, renderMarkdown, renderJson, renderUpsell, renderQuotaFooter, renderRateLimitDeny, renderAuditError, writeAuditMarkdown, writeAuditJson, } from '../lib/render.js';
4
4
  import { c } from '../lib/colors.js';
5
5
  export async function audit(args) {
6
6
  const asJson = args.includes('--json');
@@ -27,6 +27,30 @@ export async function audit(args) {
27
27
  fetchStanding(project.id),
28
28
  ]);
29
29
  const view = { project, snapshot, standing };
30
+ // The snapshot may carry an audit-engine error (Claude quota exceeded,
31
+ // rate limit, etc.). Render the friendly explanation panel and exit
32
+ // 2 so CI scripts can detect "engine unavailable" without conflating
33
+ // it with a genuine low score.
34
+ const auditErr = snapshot?.rich_analysis?.error;
35
+ if (auditErr) {
36
+ if (asJson) {
37
+ process.stdout.write(JSON.stringify({
38
+ error: 'audit_engine_error',
39
+ reason: auditErr.type,
40
+ message: auditErr.message ?? null,
41
+ retry_after: auditErr.retry_after_seconds ?? null,
42
+ project: { id: project.id, name: project.project_name, github_url: project.github_url },
43
+ }) + '\n');
44
+ }
45
+ else {
46
+ console.error('');
47
+ console.error(renderAuditError({ type: auditErr.type, message: auditErr.message ?? undefined,
48
+ retry_after_seconds: auditErr.retry_after_seconds ?? null,
49
+ http_status: auditErr.http_status }, project.project_name, `https://commit.show/projects/${project.id}`));
50
+ console.error('');
51
+ }
52
+ return 2;
53
+ }
30
54
  if (asJson) {
31
55
  process.stdout.write(renderJson(view) + '\n');
32
56
  }
@@ -106,6 +130,30 @@ export async function audit(args) {
106
130
  envelope = result;
107
131
  }
108
132
  const view = { project: envelope.project, snapshot: envelope.snapshot, standing: null };
133
+ // Same audit-engine error check as the cached path. The polled snapshot
134
+ // can carry a Claude failure even though the audit-preview Edge Function
135
+ // returned 202 (the failure happened in the background analyze-project).
136
+ const polledErr = envelope.snapshot?.rich_analysis?.error;
137
+ if (polledErr) {
138
+ if (asJson) {
139
+ process.stdout.write(JSON.stringify({
140
+ error: 'audit_engine_error',
141
+ reason: polledErr.type,
142
+ message: polledErr.message ?? null,
143
+ retry_after: polledErr.retry_after_seconds ?? null,
144
+ project: { id: envelope.project.id, name: envelope.project.project_name, github_url: envelope.project.github_url },
145
+ quota: envelope.quota,
146
+ }) + '\n');
147
+ }
148
+ else {
149
+ console.error('');
150
+ console.error(renderAuditError({ type: polledErr.type, message: polledErr.message ?? undefined,
151
+ retry_after_seconds: polledErr.retry_after_seconds ?? null,
152
+ http_status: polledErr.http_status }, envelope.project.project_name, `https://commit.show/projects/${envelope.project.id}`));
153
+ console.error('');
154
+ }
155
+ return 2;
156
+ }
109
157
  if (asJson) {
110
158
  // Inject quota into the v1 schema as an additive field — schema_version
111
159
  // unchanged because additive-only fields don't bump it.
@@ -60,6 +60,27 @@ function centerPad(s, w) {
60
60
  const left = Math.floor(total / 2);
61
61
  return ' '.repeat(left) + s + ' '.repeat(total - left);
62
62
  }
63
+ // ── Single source of truth for box drawing ─────────────────────
64
+ // Every panel uses 58-char outer width (1 corner + 56 interior + 1 corner)
65
+ // so screenshots line up. Helper takes the *visible* length so colored
66
+ // content (multiple ANSI spans) renders at the right padding without
67
+ // having to strip escape codes at runtime.
68
+ const BOX_W = 58; // outer width including both corners
69
+ const INSIDE_W = BOX_W - 2; // chars between │ and │
70
+ const CONTENT_W = INSIDE_W - 2; // chars between '│ ' and ' │'
71
+ const boxTop = () => c.muted('┌' + '─'.repeat(INSIDE_W) + '┐');
72
+ const boxBottom = () => c.muted('└' + '─'.repeat(INSIDE_W) + '┘');
73
+ const boxBlank = () => c.muted('│' + ' '.repeat(INSIDE_W) + '│');
74
+ /**
75
+ * Render a content row inside the box with proper padding.
76
+ * @param visibleLen number of visible chars in `colored` (for padding math)
77
+ * @param colored the rendered string (may contain ANSI escapes)
78
+ * @param leftMargin extra spaces inside the box, after the leading `│ `
79
+ */
80
+ function boxRow(visibleLen, colored, leftMargin = 0) {
81
+ const padding = Math.max(0, CONTENT_W - leftMargin - visibleLen);
82
+ return c.muted('│ ') + ' '.repeat(leftMargin) + colored + ' '.repeat(padding) + c.muted(' │');
83
+ }
63
84
  /** Strip ANSI for width math on colored strings. */
64
85
  function visibleLength(s) {
65
86
  // eslint-disable-next-line no-control-regex
@@ -82,11 +103,11 @@ export function renderAudit(view) {
82
103
  const { project: p, snapshot, standing } = view;
83
104
  const total = p.score_total ?? 0;
84
105
  // Header
85
- const bar = '─'.repeat(58);
86
106
  const lines = [];
87
- lines.push(c.muted('┌' + bar + '┐'));
88
- lines.push(c.muted('│ ') + c.bold(c.gold('commit.show')) + c.muted(' · ') + c.cream('Audit report') + ' '.repeat(58 - 29) + c.muted('│'));
89
- lines.push(c.muted('' + bar + ''));
107
+ lines.push(boxTop());
108
+ lines.push(boxRow(
109
+ /* visibleLen */ 'commit.show · Audit report'.length, c.bold(c.gold('commit.show')) + c.muted(' · ') + c.cream('Audit report')));
110
+ lines.push(boxBottom());
90
111
  lines.push('');
91
112
  // Project title line
92
113
  const name = p.project_name ?? 'untitled';
@@ -124,14 +145,20 @@ export function renderAudit(view) {
124
145
  const strengths = asStringArray(snapshot?.rich_analysis?.scout_brief?.strengths, 3);
125
146
  const concerns = asStringArray(snapshot?.rich_analysis?.scout_brief?.weaknesses, 2);
126
147
  if (strengths.length > 0 || concerns.length > 0) {
127
- lines.push(' ' + c.muted('┌' + '─'.repeat(56) + '┐'));
148
+ // strengths/concerns each render as `↑ ` (2 visible) + truncated line.
149
+ // Total visible-line budget inside the box is CONTENT_W chars; reserve
150
+ // 2 for the arrow + space, leaving CONTENT_W - 2 for the bullet text.
151
+ const bulletWidth = CONTENT_W - 2;
152
+ lines.push(' ' + boxTop());
128
153
  for (const s of strengths) {
129
- lines.push(' ' + c.muted('│ ') + c.teal('↑ ') + truncate(s, 52) + fill(s, 52) + c.muted(' │'));
154
+ const txt = truncate(s, bulletWidth);
155
+ lines.push(' ' + boxRow(2 + txt.length, c.teal('↑ ') + c.cream(txt)));
130
156
  }
131
157
  for (const s of concerns) {
132
- lines.push(' ' + c.muted('│ ') + c.scarlet('↓ ') + truncate(s, 52) + fill(s, 52) + c.muted(' │'));
158
+ const txt = truncate(s, bulletWidth);
159
+ lines.push(' ' + boxRow(2 + txt.length, c.scarlet('↓ ') + c.cream(txt)));
133
160
  }
134
- lines.push(' ' + c.muted('└' + '─'.repeat(56) + '┘'));
161
+ lines.push(' ' + boxBottom());
135
162
  lines.push('');
136
163
  }
137
164
  // Standings + delta
@@ -301,6 +328,72 @@ export function toAgentShape(view) {
301
328
  export function renderJson(view) {
302
329
  return JSON.stringify(toAgentShape(view), null, 2);
303
330
  }
331
+ // ── Audit-engine error panel ────────────────────────────────────────
332
+ // Rendered when the snapshot exists but rich_analysis.error is set —
333
+ // i.e., the Claude call itself failed (quota, rate limit, network). The
334
+ // project's auto-50 signals may still be valid; we explain that and tell
335
+ // the user when fresh audits will resume.
336
+ const AUDIT_ERROR_LABEL = {
337
+ anthropic_quota_exceeded: 'Daily audit budget reached',
338
+ anthropic_rate_limited: 'Audit engine rate-limited',
339
+ anthropic_overloaded: 'Audit engine overloaded',
340
+ anthropic_auth_error: 'Audit engine auth issue',
341
+ anthropic_other: 'Audit engine error',
342
+ claude_returned_no_data: 'Audit engine returned no data',
343
+ network_error: 'Audit engine network error',
344
+ };
345
+ const AUDIT_ERROR_DETAIL = {
346
+ anthropic_quota_exceeded: "commit.show paused fresh audits until the daily budget refills. " +
347
+ "Cached audits (any repo audited in the last 7 days) still work normally.",
348
+ anthropic_rate_limited: "Too many fresh audits in a short window. Wait a minute and retry. " +
349
+ "Cached results stay available.",
350
+ anthropic_overloaded: "The audit engine is briefly overloaded. Retry in a minute or two.",
351
+ anthropic_auth_error: "commit.show's API key needs attention. Cached results still work.",
352
+ anthropic_other: "Something on the audit engine side blocked this run. Try again later.",
353
+ claude_returned_no_data: "The audit engine ran but returned an empty response. Try again.",
354
+ network_error: "The audit engine couldn't be reached. Check your connection or retry.",
355
+ };
356
+ function untilHuman(seconds) {
357
+ if (seconds < 60)
358
+ return `${seconds}s`;
359
+ if (seconds < 3600)
360
+ return `${Math.round(seconds / 60)}m`;
361
+ if (seconds < 86400)
362
+ return `${Math.round(seconds / 3600)}h`;
363
+ return `${Math.round(seconds / 86400)}d`;
364
+ }
365
+ export function renderAuditError(err, projectName, projectUrl) {
366
+ const label = AUDIT_ERROR_LABEL[err.type] ?? AUDIT_ERROR_LABEL.anthropic_other;
367
+ const detail = AUDIT_ERROR_DETAIL[err.type] ?? AUDIT_ERROR_DETAIL.anthropic_other;
368
+ const lines = [];
369
+ const titleVisible = `commit.show · ${label}`;
370
+ lines.push(' ' + boxTop());
371
+ lines.push(' ' + boxRow(titleVisible.length, c.bold(c.gold('commit.show')) + c.muted(' · ') + c.scarlet(label)));
372
+ lines.push(' ' + boxBlank());
373
+ if (projectName) {
374
+ const repoLine = `Repo: ${projectName}`;
375
+ lines.push(' ' + boxRow(repoLine.length, c.cream(repoLine)));
376
+ lines.push(' ' + boxBlank());
377
+ }
378
+ for (const w of wrapText(detail, CONTENT_W)) {
379
+ lines.push(' ' + boxRow(w.length, c.cream(w)));
380
+ }
381
+ if (err.retry_after_seconds && err.retry_after_seconds > 0) {
382
+ lines.push(' ' + boxBlank());
383
+ const t = `Retry after ~${untilHuman(err.retry_after_seconds)}`;
384
+ lines.push(' ' + boxRow(t.length, c.dim(t)));
385
+ }
386
+ lines.push(' ' + boxBlank());
387
+ const statusLine = `Status check: commitshow status ${projectName ?? '<repo>'}`;
388
+ lines.push(' ' + boxRow(Math.min(statusLine.length, CONTENT_W), c.dim(statusLine.slice(0, CONTENT_W))));
389
+ if (projectUrl) {
390
+ const urlMax = CONTENT_W - 'Web view: '.length;
391
+ const urlText = projectUrl.length > urlMax ? projectUrl.slice(0, urlMax - 1) + '…' : projectUrl;
392
+ lines.push(' ' + boxRow('Web view: '.length + urlText.length, c.dim('Web view: ') + c.cream(urlText)));
393
+ }
394
+ lines.push(' ' + boxBottom());
395
+ return lines.join('\n');
396
+ }
304
397
  function timeUntil(isoTarget) {
305
398
  const ms = Math.max(0, new Date(isoTarget).getTime() - Date.now());
306
399
  const h = Math.floor(ms / 3_600_000);
@@ -344,26 +437,30 @@ function bar(filled, total, width = 20) {
344
437
  }
345
438
  export function renderRateLimitDeny(opts) {
346
439
  const lines = [];
347
- const horiz = '─'.repeat(58);
348
- lines.push(' ' + c.muted('┌' + horiz + '┐'));
349
- lines.push(' ' + c.muted('│ ') + c.bold(c.scarlet('Rate limit')) + c.muted(' · ') + c.cream(REASON_LABEL[opts.reason] ?? opts.reason) + ' '.repeat(Math.max(0, 58 - 14 - (REASON_LABEL[opts.reason]?.length ?? opts.reason.length))) + c.muted('│'));
350
- lines.push(' ' + c.muted('' + ' '.repeat(58) + '│'));
351
- lines.push(' ' + c.muted('│ ') + c.cream(`${opts.count}/${opts.limit} `) + bar(opts.count, opts.limit) + ' '.repeat(58 - 28) + c.muted('│'));
440
+ const reasonLabel = REASON_LABEL[opts.reason] ?? opts.reason;
441
+ const titleVisible = `Rate limit · ${reasonLabel}`;
442
+ lines.push(' ' + boxTop());
443
+ lines.push(' ' + boxRow(titleVisible.length, c.bold(c.scarlet('Rate limit')) + c.muted(' · ') + c.cream(reasonLabel)));
444
+ lines.push(' ' + boxBlank());
445
+ // Count + bar row · "5/5 " (5 chars) + 20-char bar = 25 visible
446
+ const counter = `${opts.count}/${opts.limit} `;
447
+ lines.push(' ' + boxRow(counter.length + 20, c.cream(counter) + bar(opts.count, opts.limit)));
352
448
  if (opts.quota) {
353
- const reset = timeUntil(opts.quota.reset_at);
354
- lines.push(' ' + c.muted('│ ') + c.dim(`resets in ${reset}`) + ' '.repeat(58 - 12 - reset.length - 9 - 2) + c.muted('│'));
449
+ const reset = `resets in ${timeUntil(opts.quota.reset_at)}`;
450
+ lines.push(' ' + boxRow(reset.length, c.dim(reset)));
355
451
  }
356
- // Wrap the message into ~54-char lines.
357
- for (const w of wrapText(opts.message, 54)) {
358
- lines.push(' ' + c.muted('│ ') + c.cream(w) + ' '.repeat(56 - w.length) + c.muted('│'));
452
+ for (const w of wrapText(opts.message, CONTENT_W)) {
453
+ lines.push(' ' + boxRow(w.length, c.cream(w)));
359
454
  }
360
455
  if (opts.reason === 'url_cap') {
361
- lines.push(' ' + c.muted('│ ') + c.dim('Tip: cached audit (< 7d) is free — `commitshow status <repo>`.') + c.muted(' │'));
456
+ const tip = 'Tip: cached audit (< 7d) is free — commitshow status <repo>';
457
+ lines.push(' ' + boxRow(tip.length, c.dim(tip)));
362
458
  }
363
459
  if (opts.reason === 'ip_cap' && opts.quota?.ip.tier === 'anon') {
364
- lines.push(' ' + c.muted('│ ') + c.dim('Sign in (commit.show) for a higher daily cap.') + ' '.repeat(58 - 49) + c.muted('│'));
460
+ const tip = 'Sign in (commit.show) for a higher daily cap.';
461
+ lines.push(' ' + boxRow(tip.length, c.dim(tip)));
365
462
  }
366
- lines.push(' ' + c.muted('└' + horiz + '┘'));
463
+ lines.push(' ' + boxBottom());
367
464
  return lines.join('\n');
368
465
  }
369
466
  function wrapText(s, width) {
@@ -386,24 +483,35 @@ function wrapText(s, width) {
386
483
  }
387
484
  export function renderUpsell() {
388
485
  const lines = [];
389
- const bar = ''.repeat(58);
390
- lines.push(' ' + c.muted('┌' + bar + '┐'));
391
- lines.push(' ' + c.muted('│ ') + c.bold(c.gold('Preview')) + c.muted(' · ') + c.cream('not entered in the season') + ' '.repeat(58 - 35) + c.muted('│'));
392
- lines.push(' ' + c.muted('│' + ' '.repeat(58) + '│'));
393
- lines.push(' ' + c.muted('') + c.cream('Audition to unlock:') + ' '.repeat(58 - 20) + c.muted('│'));
486
+ const titleVisible = 'Preview · not entered in the season';
487
+ const headVisible = 'Audition to unlock:';
488
+ const ctaVisible = ' https://commit.show/submit';
489
+ lines.push(' ' + boxTop());
490
+ lines.push(' ' + boxRow(titleVisible.length, c.bold(c.gold('Preview')) + c.muted(' · ') + c.cream('not entered in the season')));
491
+ lines.push(' ' + boxBlank());
492
+ lines.push(' ' + boxRow(headVisible.length, c.cream(headVisible)));
493
+ // Backstage = our brand for the prompt-extraction analysis (Phase 2 brief).
494
+ // Lead the unlock list with it because it's the most concrete, immediate
495
+ // payoff on signup: see how the project was built (delegation map, failure
496
+ // log, decision archaeology) and unlock +15-20 audit points typical.
497
+ // Tags are 15-char column-aligned so the · separator lines up vertically.
498
+ // Tags column-padded to 16 chars (1 trailing space guaranteed so the · in
499
+ // `rest` always has a visual gap from the tag, even when the tag fills 15).
394
500
  const items = [
395
- 'Scout forecasts · human verdicts (30% of score)',
396
- 'Season ranking · top 20% graduate each 3-week cycle',
397
- 'Hall of Fame · permanent archive + public badge',
398
- 'Live applauds · notifications when reviewers react',
399
- 'Recommit loop · weekly delta + trajectory share card',
501
+ { tag: 'Backstage ', rest: '· build process + prompts · +15 pts', tone: c.gold },
502
+ { tag: 'Scout forecasts ', rest: '· human verdicts (30% of score)', tone: c.teal },
503
+ { tag: 'Season ranking ', rest: '· top 20% graduate per cycle', tone: c.teal },
504
+ { tag: 'Hall of Fame ', rest: '· permanent archive + badge', tone: c.teal },
505
+ { tag: 'Live applauds ', rest: '· notifications on reactions', tone: c.teal },
506
+ { tag: 'Recommit loop ', rest: '· weekly delta + share card', tone: c.teal },
400
507
  ];
401
508
  for (const it of items) {
402
- lines.push(' ' + c.muted('│ ') + c.teal('→ ') + c.cream(pad(it, 54)) + c.muted('│'));
509
+ const visible = `→ ${it.tag}${it.rest}`;
510
+ lines.push(' ' + boxRow(visible.length, it.tone('→ ') + c.cream(it.tag) + c.muted(it.rest)));
403
511
  }
404
- lines.push(' ' + c.muted('│' + ' '.repeat(58) + '│'));
405
- lines.push(' ' + c.muted('│ ') + c.gold('→ https://commit.show/submit') + ' '.repeat(58 - 30) + c.muted('│'));
406
- lines.push(' ' + c.muted('└' + bar + '┘'));
512
+ lines.push(' ' + boxBlank());
513
+ lines.push(' ' + boxRow(ctaVisible.length, c.gold(ctaVisible)));
514
+ lines.push(' ' + boxBottom());
407
515
  return lines.join('\n');
408
516
  }
409
517
  export function writeAuditJson(dir, json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitshow",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "commit.show CLI — audit any vibe-coded project from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {