hacklab 0.6.7 → 0.7.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.
Files changed (80) hide show
  1. package/README.md +36 -6
  2. package/dist/card.d.ts +66 -0
  3. package/dist/card.d.ts.map +1 -0
  4. package/dist/card.js +151 -0
  5. package/dist/card.js.map +1 -0
  6. package/dist/commands/brag.d.ts.map +1 -1
  7. package/dist/commands/brag.js +5 -0
  8. package/dist/commands/brag.js.map +1 -1
  9. package/dist/commands/chat.d.ts +21 -1
  10. package/dist/commands/chat.d.ts.map +1 -1
  11. package/dist/commands/chat.js +109 -14
  12. package/dist/commands/chat.js.map +1 -1
  13. package/dist/commands/config.d.ts.map +1 -1
  14. package/dist/commands/config.js +21 -9
  15. package/dist/commands/config.js.map +1 -1
  16. package/dist/commands/drop.d.ts.map +1 -1
  17. package/dist/commands/drop.js +5 -0
  18. package/dist/commands/drop.js.map +1 -1
  19. package/dist/commands/essay.d.ts +24 -0
  20. package/dist/commands/essay.d.ts.map +1 -0
  21. package/dist/commands/essay.js +434 -0
  22. package/dist/commands/essay.js.map +1 -0
  23. package/dist/commands/hacker.d.ts +2 -0
  24. package/dist/commands/hacker.d.ts.map +1 -0
  25. package/dist/commands/hacker.js +119 -0
  26. package/dist/commands/hacker.js.map +1 -0
  27. package/dist/commands/join.d.ts.map +1 -1
  28. package/dist/commands/join.js +153 -11
  29. package/dist/commands/join.js.map +1 -1
  30. package/dist/commands/login.d.ts.map +1 -1
  31. package/dist/commands/login.js +13 -0
  32. package/dist/commands/login.js.map +1 -1
  33. package/dist/commands/logout.d.ts.map +1 -1
  34. package/dist/commands/logout.js +2 -0
  35. package/dist/commands/logout.js.map +1 -1
  36. package/dist/commands/org.d.ts +0 -2
  37. package/dist/commands/org.d.ts.map +1 -1
  38. package/dist/commands/org.js +1 -17
  39. package/dist/commands/org.js.map +1 -1
  40. package/dist/commands/sync.d.ts.map +1 -1
  41. package/dist/commands/sync.js +25 -1
  42. package/dist/commands/sync.js.map +1 -1
  43. package/dist/commands/update.d.ts +2 -0
  44. package/dist/commands/update.d.ts.map +1 -0
  45. package/dist/commands/update.js +92 -0
  46. package/dist/commands/update.js.map +1 -0
  47. package/dist/config.d.ts +26 -0
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +31 -0
  50. package/dist/config.js.map +1 -1
  51. package/dist/index.js +44 -21
  52. package/dist/index.js.map +1 -1
  53. package/dist/posthog.d.ts +9 -0
  54. package/dist/posthog.d.ts.map +1 -0
  55. package/dist/posthog.js +20 -0
  56. package/dist/posthog.js.map +1 -0
  57. package/dist/registry.d.ts.map +1 -1
  58. package/dist/registry.js +20 -0
  59. package/dist/registry.js.map +1 -1
  60. package/dist/scanners/index.d.ts +24 -2
  61. package/dist/scanners/index.d.ts.map +1 -1
  62. package/dist/scanners/index.js +103 -15
  63. package/dist/scanners/index.js.map +1 -1
  64. package/dist/session.d.ts +1 -0
  65. package/dist/session.d.ts.map +1 -1
  66. package/dist/session.js +16 -0
  67. package/dist/session.js.map +1 -1
  68. package/dist/ui.d.ts +12 -0
  69. package/dist/ui.d.ts.map +1 -1
  70. package/dist/ui.js +56 -0
  71. package/dist/ui.js.map +1 -1
  72. package/dist/utils/npmGlobal.d.ts +30 -0
  73. package/dist/utils/npmGlobal.d.ts.map +1 -0
  74. package/dist/utils/npmGlobal.js +69 -0
  75. package/dist/utils/npmGlobal.js.map +1 -0
  76. package/dist/utils/updateCheck.d.ts +6 -0
  77. package/dist/utils/updateCheck.d.ts.map +1 -1
  78. package/dist/utils/updateCheck.js +7 -2
  79. package/dist/utils/updateCheck.js.map +1 -1
  80. package/package.json +3 -1
package/README.md CHANGED
@@ -39,15 +39,21 @@ scan local AI usage → see your rank → pick a username → sign in with GitHu
39
39
 
40
40
  1. **Scan** — reads your local AI token usage from Claude Code, Codex, Cursor,
41
41
  OpenClaw, Hermes, and OpenCode. Nothing leaves your machine yet.
42
- 2. **Rank** — shows the rank that usage would hold (`you'd be #15 of N`), with no
42
+ 2. **Cursor key** (Cursor users only) — if the scan finds Cursor on your machine
43
+ and no key is configured, you're offered the chance to paste one. Cursor's
44
+ local data only supports a rough estimate; a key buys exact per-event counts
45
+ and real daily history. Skip it and the estimate stands — you can add a key
46
+ later with `hacklab config cursor-api-key <key>`. Nobody without Cursor is
47
+ asked.
48
+ 3. **Rank** — shows the rank that usage would hold (`you'd be #15 of N`), with no
43
49
  account required.
44
- 3. **Username** — pick your Hacklab handle (checked for availability live).
45
- 4. **GitHub** — sign in to authenticate and link your profile + repos.
46
- 5. **Claim** — your profile goes live at `hacklab.so/<username>`. Your usage
50
+ 4. **Username** — pick your Hacklab handle (checked for availability live).
51
+ 5. **GitHub** — sign in to authenticate and link your profile + repos.
52
+ 6. **Claim** — your profile goes live at `hacklab.so/<username>`. Your usage
47
53
  uploads in the background while you get a shareable stats card.
48
- 6. **Share** — a stats thumbnail (belt, level, rank, token breakdown) is rendered
54
+ 7. **Share** — a stats thumbnail (belt, level, rank, token breakdown) is rendered
49
55
  locally, copied to your clipboard, and you can post it to X in one keystroke.
50
- 7. **Chat** — finally, you're offered the live channel (default yes). Decline and
56
+ 8. **Chat** — finally, you're offered the live channel (default yes). Decline and
51
57
  you can jump in anytime with `hacklab chat`.
52
58
 
53
59
  ## Commands
@@ -67,6 +73,7 @@ scan local AI usage → see your rank → pick a username → sign in with GitHu
67
73
  - `hacklab login` — re-authenticate with GitHub.
68
74
  - `hacklab logout` — clear your saved session on this machine.
69
75
  - `hacklab config <key> <value>` — set config (`cursor-api-key`, `cursor-email`).
76
+ Bare `hacklab config` prints the effective values and where each came from.
70
77
  - `hacklab brag [path]` — sync a `.hacklab/project.yaml` to your profile.
71
78
  - `hacklab org` — hub for company management. If you already own a company, pick
72
79
  a field, type the new value, and it saves as you go. If you don't own one yet,
@@ -75,6 +82,29 @@ scan local AI usage → see your rank → pick a username → sign in with GitHu
75
82
  email) and `hacklab org create` (register a brand-new company from the CLI).
76
83
  - `hacklab --version` / `hacklab --help`.
77
84
 
85
+ ## Cursor usage
86
+
87
+ Cursor is the one tool whose local data can't give exact numbers. Without a key
88
+ the scanner estimates from Cursor's commit-tracking database (AI lines written
89
+ × 30, all attributed to today, so no real daily history). With a key it reads
90
+ exact per-event token counts and real dates from Cursor's API.
91
+
92
+ Supply a key three ways, highest priority first:
93
+
94
+ ```sh
95
+ hacklab --cursor-api-key <key> sync # flag: one run
96
+ CURSOR_API_KEY=<key> hacklab sync # env var: one shell / CI
97
+ hacklab config cursor-api-key <key> # config file: persists
98
+ ```
99
+
100
+ `--cursor-email <email>` / `CURSOR_EMAIL` / `hacklab config cursor-email` set the
101
+ account email, which resolves independently of the key. **If your key is a team
102
+ key, set the email.** Cursor's usage endpoint returns every team member's events
103
+ otherwise, and all of it would land on your profile as your own.
104
+
105
+ If a key is set but Cursor rejects it, the scan says so and falls back to the
106
+ local estimate — it won't quietly hand you an estimate you think is exact.
107
+
78
108
  ## Choosing a backend
79
109
 
80
110
  Every command resolves which backend to talk to by the same precedence:
package/dist/card.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ export type HackerCardData = {
2
+ handle: string;
3
+ displayName: string | null;
4
+ bio: string | null;
5
+ joinedAt: string;
6
+ claimedAt: string | null;
7
+ level: number;
8
+ tokensTotal: number;
9
+ tokens30d: number;
10
+ estimatedCost: number;
11
+ rank: number | null;
12
+ topModels30d: {
13
+ model: string;
14
+ tokens: number;
15
+ pct: number;
16
+ }[];
17
+ currentStreak: number;
18
+ longestStreak: number;
19
+ activeDays30: number;
20
+ activity30: number[];
21
+ counts: {
22
+ projects: number;
23
+ essays: number;
24
+ drops: number;
25
+ followers: number;
26
+ };
27
+ recent: {
28
+ projects: {
29
+ title: string;
30
+ description: string | null;
31
+ }[];
32
+ essays: {
33
+ title: string;
34
+ publishedAt: string | null;
35
+ }[];
36
+ drops: {
37
+ text: string;
38
+ createdAt: string;
39
+ }[];
40
+ };
41
+ links: {
42
+ profile: string;
43
+ website: string | null;
44
+ github: string | null;
45
+ x: string | null;
46
+ };
47
+ openToWork: boolean;
48
+ };
49
+ export type RenderCardOptions = {
50
+ /** Terminal columns. Clamped to [MIN_WIDTH, DESIGN_WIDTH]. */
51
+ columns?: number;
52
+ now?: Date;
53
+ };
54
+ /**
55
+ * Render the full one-screen card as an array of lines (caller joins with \n).
56
+ * Pure: no I/O, deterministic given `now`. Every user-supplied string is passed
57
+ * through stripControl before it reaches the terminal.
58
+ */
59
+ export declare function renderCard(card: HackerCardData, opts?: RenderCardOptions): string[];
60
+ /**
61
+ * The compact in-chat card (`/who`): 4 lines, no rules, no sparkline. Same
62
+ * strip discipline. Returned as separate lines so the chat renderer can push
63
+ * them as one Entry per row (preserving the one-row-per-entry invariant).
64
+ */
65
+ export declare function renderCompact(card: HackerCardData): string[];
66
+ //# sourceMappingURL=card.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"AAuBA,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,YAAY,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC9D,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9E,MAAM,EAAE;QACN,QAAQ,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,EAAE,CAAA;QACzD,MAAM,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,EAAE,CAAA;QACvD,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAC7C,CAAA;IACD,KAAK,EAAE;QACL,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;QACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;QACrB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KACjB,CAAA;IACD,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAQD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,IAAI,CAAA;CACX,CAAA;AAyFD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,cAAc,EACpB,IAAI,GAAE,iBAAsB,GAC3B,MAAM,EAAE,CAiHV;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,EAAE,CAY5D"}
package/dist/card.js ADDED
@@ -0,0 +1,151 @@
1
+ import { getTitle } from './belt.js';
2
+ import { formatTokens } from './scanners/util.js';
3
+ import { dim, displayWidth, mint, padEndTo, rankColor, stripControl, truncateToWidth, white, } from './ui.js';
4
+ const DESIGN_WIDTH = 68;
5
+ const MIN_WIDTH = 45; // below this the fixed 30d row can't fit; wrapping is accepted
6
+ /** 8 log-scaled buckets for the 30-day sparkline. Zero renders dim, lit cells mint. */
7
+ const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
8
+ function resolveWidth(columns) {
9
+ const cols = columns ?? DESIGN_WIDTH;
10
+ return Math.max(MIN_WIDTH, Math.min(DESIGN_WIDTH, cols));
11
+ }
12
+ /** ISO string → `YYYY-MM-DD`. */
13
+ function ymd(iso) {
14
+ return iso.slice(0, 10);
15
+ }
16
+ function ageDays(iso, now) {
17
+ return Math.max(0, Math.floor((now.getTime() - Date.parse(iso)) / 86_400_000));
18
+ }
19
+ /** A left segment and a right segment on one line, right-aligned to `width`. */
20
+ function justify(width, leftColored, rightColored) {
21
+ const gap = Math.max(1, width - displayWidth(leftColored) - displayWidth(rightColored));
22
+ return leftColored + ' '.repeat(gap) + rightColored;
23
+ }
24
+ function rule(width) {
25
+ return dim('─'.repeat(width));
26
+ }
27
+ /** `████ L32 SHINOBI` — coloured band + dim romaji rank. Belt derived from level. */
28
+ function beltBadge(level) {
29
+ const band = rankColor(level, '████');
30
+ const label = dim(` L${level} ${getTitle(level).toUpperCase()}`);
31
+ return band + label;
32
+ }
33
+ function sparkline(values) {
34
+ const max = Math.max(0, ...values);
35
+ const denom = Math.log2(max + 1);
36
+ return values
37
+ .map((v) => {
38
+ if (v <= 0)
39
+ return dim('▁');
40
+ const frac = denom > 0 ? Math.log2(v + 1) / denom : 0;
41
+ const idx = Math.min(SPARK.length - 1, Math.max(0, Math.round(frac * (SPARK.length - 1))));
42
+ return mint(SPARK[idx] ?? '█');
43
+ })
44
+ .join('');
45
+ }
46
+ /** A `label value` row; the label is dim, the value plain, truncated to width. */
47
+ function labelled(width, label, value) {
48
+ const head = dim(padEndTo(label, 8)); // "joined ", "streak ", …
49
+ const budget = width - displayWidth(head);
50
+ return head + truncateToWidth(value, budget);
51
+ }
52
+ /** A recent-list section: `HEADER N` then rows, or `HEADER 0 nothing yet`. */
53
+ function section(width, label, total, rows) {
54
+ if (total === 0)
55
+ return [dim(`${label} 0 nothing yet`)];
56
+ return [`${label} ${total}`, ...rows.map((r) => truncateToWidth(r, width))];
57
+ }
58
+ /**
59
+ * Projects/essays row-budget rule: ≤3 rows below the header. When more than 3
60
+ * exist, show 2 items + `+N more`; otherwise show them all.
61
+ */
62
+ function recentRows(total, items, row) {
63
+ if (total > 3) {
64
+ return [...items.slice(0, 2).map(row), ` +${total - 2} more`];
65
+ }
66
+ return items.map(row);
67
+ }
68
+ /**
69
+ * Render the full one-screen card as an array of lines (caller joins with \n).
70
+ * Pure: no I/O, deterministic given `now`. Every user-supplied string is passed
71
+ * through stripControl before it reaches the terminal.
72
+ */
73
+ export function renderCard(card, opts = {}) {
74
+ const width = resolveWidth(opts.columns);
75
+ const now = opts.now ?? new Date();
76
+ const lines = [];
77
+ // Line 1 — identity + belt.
78
+ const name = card.displayName ? ` · ${stripControl(card.displayName)}` : '';
79
+ const identity = white(truncateToWidth(`${stripControl(card.handle)}${name}`, width - displayWidth(beltBadge(card.level)) - 1));
80
+ lines.push(justify(width, identity, beltBadge(card.level)));
81
+ // Line 2 — bio (left) + open-to-work dot (right). Skipped when both absent.
82
+ const bio = card.bio
83
+ ? truncateToWidth(stripControl(card.bio), width - 16)
84
+ : '';
85
+ const otw = card.openToWork ? `${mint('●')} open to work` : '';
86
+ if (bio || otw)
87
+ lines.push(justify(width, bio, otw));
88
+ lines.push(rule(width));
89
+ // Identity block.
90
+ const claimed = card.claimedAt
91
+ ? `claimed ${ymd(card.claimedAt)}`
92
+ : 'not claimed yet';
93
+ const rank = card.rank != null ? ` rank #${card.rank}` : '';
94
+ lines.push(labelled(width, 'joined', `${ymd(card.joinedAt)} (${ageDays(card.joinedAt, now)}d) ${claimed}${rank}`));
95
+ // Activity block, with the two null-state collapses.
96
+ if (card.tokensTotal === 0) {
97
+ lines.push(dim('no activity yet. run hacklab sync to bank the proof.'));
98
+ }
99
+ else {
100
+ lines.push(labelled(width, 'streak', `${card.currentStreak}d current · ${card.longestStreak}d longest active ${card.activeDays30}/30d`));
101
+ const cost = `$${Math.round(card.estimatedCost).toLocaleString('en-US')} est. cost`;
102
+ lines.push(labelled(width, 'tokens', `${formatTokens(card.tokensTotal)} burned · ${formatTokens(card.tokens30d)} last 30d · ${cost}`));
103
+ // Models + sparkline collapse when there's no 30-day activity.
104
+ if (card.tokens30d > 0) {
105
+ const models = card.topModels30d
106
+ .slice(0, 2)
107
+ .map((m) => `${m.model} ${m.pct}%`)
108
+ .join(' · ');
109
+ if (models)
110
+ lines.push(labelled(width, 'models', models));
111
+ lines.push(dim(padEndTo('30d', 8)) + sparkline(card.activity30));
112
+ }
113
+ }
114
+ lines.push(rule(width));
115
+ // Recent work.
116
+ lines.push(...section(width, 'PROJECTS', card.counts.projects, recentRows(card.counts.projects, card.recent.projects, (p) => ` ${padEndTo(truncateToWidth(stripControl(p.title), 16), 16)} ${stripControl(p.description ?? '')}`)));
117
+ lines.push(...section(width, 'ESSAYS', card.counts.essays, recentRows(card.counts.essays, card.recent.essays, (e) => ` ${e.publishedAt ? ymd(e.publishedAt) : ' '} ${stripControl(e.title)}`)));
118
+ // Drops: 1 item, no "+N more"; the count lives in the header.
119
+ if (card.counts.drops === 0) {
120
+ lines.push(dim('DROPS 0 nothing yet'));
121
+ }
122
+ else {
123
+ const last = card.recent.drops[0];
124
+ lines.push(`DROPS ${card.counts.drops}${last ? ` · last ${ymd(last.createdAt)}` : ''}`);
125
+ if (last) {
126
+ lines.push(truncateToWidth(` "${stripControl(last.text)}"`, width));
127
+ }
128
+ }
129
+ lines.push(rule(width));
130
+ lines.push(dim(`--json for the full payload · hacklab.so/${card.handle}`));
131
+ return lines;
132
+ }
133
+ /**
134
+ * The compact in-chat card (`/who`): 4 lines, no rules, no sparkline. Same
135
+ * strip discipline. Returned as separate lines so the chat renderer can push
136
+ * them as one Entry per row (preserving the one-row-per-entry invariant).
137
+ */
138
+ export function renderCompact(card) {
139
+ const name = card.displayName ? ` · ${stripControl(card.displayName)}` : '';
140
+ const otw = card.openToWork ? ` · ${mint('●')} open to work` : '';
141
+ const joined = card.claimedAt
142
+ ? `joined ${ageDays(card.claimedAt, new Date())}d ago`
143
+ : 'not claimed yet';
144
+ return [
145
+ `${white(stripControl(card.handle))}${name} · ${beltBadge(card.level)}${otw}`,
146
+ `${formatTokens(card.tokensTotal)} tokens · ${formatTokens(card.tokens30d)}/30d · ${card.currentStreak}d streak · ${joined}`,
147
+ `${card.counts.projects} projects · ${card.counts.essays} essays · ${card.counts.drops} drops`,
148
+ dim(`hacklab.so/${card.handle}`),
149
+ ];
150
+ }
151
+ //# sourceMappingURL=card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card.js","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"brag.d.ts","sourceRoot":"","sources":["../../src/commands/brag.ts"],"names":[],"mappings":"AASA,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAgCvE;AA6BD,wBAAsB,IAAI,CAAC,YAAY,CAAC,EAAE,MAAM,iBA8C/C"}
1
+ {"version":3,"file":"brag.d.ts","sourceRoot":"","sources":["../../src/commands/brag.ts"],"names":[],"mappings":"AAUA,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAgCvE;AA6BD,wBAAsB,IAAI,CAAC,YAAY,CAAC,EAAE,MAAM,iBAmD/C"}
@@ -1,3 +1,4 @@
1
+ import { captureEvent } from '../posthog.js';
1
2
  import { buildBragProjectPayload, DEFAULT_BRAG_MANIFEST_PATH, loadBragManifest, } from '../project-brag.js';
2
3
  import { loadSession, resolveAppUrl } from '../session.js';
3
4
  import { dim, error, info, success } from '../ui.js';
@@ -78,6 +79,10 @@ export async function brag(manifestPath) {
78
79
  if (session.handle) {
79
80
  info(`${resolveAppUrl(session)}/${session.handle}/${payload.slug}`);
80
81
  }
82
+ await captureEvent(session.handle, 'cli_brag_synced', {
83
+ project_slug: payload.slug,
84
+ has_screenshots: (payload.screenshots?.length ?? 0) > 0,
85
+ });
81
86
  }
82
87
  catch (err) {
83
88
  error(err instanceof Error ? err.message : String(err));
@@ -1 +1 @@
1
- {"version":3,"file":"brag.js","sourceRoot":"","sources":["../../src/commands/brag.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,0BAA0B,EAC1B,gBAAgB,GAEjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAgB,MAAM,eAAe,CAAA;AACxE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA;AAEpD,MAAM,UAAU,aAAa,CAAC,IAAc;IAC1C,IAAI,YAAgC,CAAA;IAEpC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QACjD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAE,CAAA;QAExB,IAAI,CAAC,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,QAAQ,CAAC,IAAI,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,CAAC;YAC1D,YAAY,GAAG,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAA;YAC9B,KAAK,EAAE,CAAA;YACP,SAAQ;QACV,CAAC;QAED,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,QAAQ,EAAE,CAAC;YACrC,OAAO,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAA;YACzC,OAAO,CAAC,GAAG,CAAC,kDAAkD,CAAC,CAAA;YAC/D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC;QAED,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,KAAK,CAAC,iBAAiB,GAAG,EAAE,CAAC,CAAA;YACrC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC;QAED,IAAI,YAAY,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,CAAC,4BAA4B,CAAC,CAAA;YAC3C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC;QAED,YAAY,GAAG,GAAG,CAAA;IACpB,CAAC;IAED,OAAO,EAAE,YAAY,EAAE,CAAA;AACzB,CAAC;AAED,KAAK,UAAU,gBAAgB,CAC7B,OAAgB,EAChB,KAA4B;IAE5B,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,aAAa,CAAC,OAAO,CAAC,kBAAkB,EAAE;QACxE,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,OAAO,CAAC,KAAK,EAAE;SACzC;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE,KAAK,CAAC,MAAM;YACnB,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,WAAW,EAAE,KAAK,CAAC,WAAW;SAC/B,CAAC;KACH,CAAC,CAAA;IAEF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;IACpD,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,IAAI,EAAE,KAAK,IAAI,6BAA6B,QAAQ,CAAC,MAAM,GAAG,CAC/D,CAAA;IACH,CAAC;IAED,OAAO,IAAI,CAAC,GAAa,CAAA;AAC3B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,YAAqB;IAC9C,MAAM,OAAO,GAAG,MAAM,WAAW,EAAE,CAAA;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,KAAK,CAAC,eAAe,CAAC,CAAA;QACtB,IAAI,CAAC,OAAO,GAAG,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAA;QACzC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,gBAAgB,CAAC,EAAE,YAAY,EAAE,CAAC,CAAA;QACzD,MAAM,aAAa,GAAG,YAAY,IAAI,0BAA0B,CAAA;QAEhE,IAAI,CAAC,WAAW,aAAa,EAAE,CAAC,CAAA;QAEhC,MAAM,OAAO,GAAG,MAAM,uBAAuB,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;YACtE,IAAI,CAAC,aAAa,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAA;YACnC,OAAO,MAAM,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;QAC/C,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,WAAW,OAAO,CAAC,KAAK,EAAE,CAAC,CAAA;QAEhC,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,aAAa,CAAC,OAAO,CAAC,eAAe,EAAE;YACrE,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,aAAa,EAAE,UAAU,OAAO,CAAC,KAAK,EAAE;aACzC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;SAC9B,CAAC,CAAA;QAEF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QAEpD,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,IAAI,wBAAwB,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;QAC5E,CAAC;QAED,OAAO,CAAC,iBAAiB,CAAC,CAAA;QAE1B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,IAAI,CAAC,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,KAAK,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QACvD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"brag.js","sourceRoot":"","sources":["../../src/commands/brag.ts"],"names":[],"mappings":"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"}
@@ -8,9 +8,29 @@ type RenderableMessage = {
8
8
  content: string;
9
9
  senderLevel?: number | null;
10
10
  };
11
- export declare function stripControl(s: string): string;
12
11
  export declare function formatLine(m: RenderableMessage, now?: Date): string;
13
12
  export declare function formatPendingLine(handle: string, content: string, tag: string, at?: string, now?: Date): string;
13
+ export type LineIntent = {
14
+ kind: 'empty';
15
+ } | {
16
+ kind: 'command';
17
+ cmd: string;
18
+ args: string[];
19
+ } | {
20
+ kind: 'message';
21
+ content: string;
22
+ };
23
+ /**
24
+ * Decide what a typed line means. A single leading slash is a local command and
25
+ * never posts. A DOUBLED leading slash is how you post a message that starts with
26
+ * a slash: it posts (and displays) verbatim, both slashes intact. Nothing is
27
+ * stripped, so the optimistic placeholder, the POST body, and the server echo all
28
+ * agree (reconcile-by-content) — and the server can reject single-leading-slash
29
+ * content (a stale client posting a command it doesn't know is local) without
30
+ * catching the doubled form. Pure + exported so these semantics are unit-tested
31
+ * without driving readline.
32
+ */
33
+ export declare function parseLine(input: string): LineIntent;
14
34
  export declare function chat(args: string[]): Promise<void>;
15
35
  export {};
16
36
  //# sourceMappingURL=chat.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AACA,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,eAAe,CAAA;AA+CxE,wBAAgB,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,MAAM,CAExD;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,CASxD;AAKD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAKR;AAED,KAAK,iBAAiB,GAAG;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAAA;AAKD,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAG9C;AAED,wBAAgB,UAAU,CACxB,CAAC,EAAE,iBAAiB,EACpB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAQR;AAQD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,EAAE,GAAE,MAAiC,EACrC,GAAG,GAAE,IAAiB,GACrB,MAAM,CAIR;AA6bD,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,iBAkCxC"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,eAAe,CAAA;AAgDxE,wBAAgB,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,MAAM,CAExD;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,CASxD;AAKD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAKR;AAED,KAAK,iBAAiB,GAAG;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAAA;AAED,wBAAgB,UAAU,CACxB,CAAC,EAAE,iBAAiB,EACpB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAQR;AAQD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,EAAE,GAAE,MAAiC,EACrC,GAAG,GAAE,IAAiB,GACrB,MAAM,CAIR;AA8MD,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAExC;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CASnD;AA4UD,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,iBAkCxC"}
@@ -1,6 +1,8 @@
1
1
  import readline from 'node:readline';
2
+ import { renderCompact } from '../card.js';
3
+ import { captureEvent } from '../posthog.js';
2
4
  import { loadSession, resolveAppUrl } from '../session.js';
3
- import { bold, dim, error, green, info, rankColor, red, success, } from '../ui.js';
5
+ import { bold, dim, error, green, info, rankColor, red, stripControl, success, } from '../ui.js';
4
6
  // The chat command is CLI-first: every subcommand takes --json so an AI agent
5
7
  // can drive the channel and DMs programmatically, while the default output is a
6
8
  // human-readable terminal view. Thin wrappers over the /api/chat/* routes.
@@ -95,13 +97,6 @@ export function formatTimestamp(when, now = new Date()) {
95
97
  const prefix = datePrefix(d, now);
96
98
  return prefix ? `${prefix} ${time}` : time;
97
99
  }
98
- // Strip C0 control characters + DEL from untrusted message content before
99
- // printing it to the terminal. Without this, a hacker could embed ANSI escape
100
- // sequences in a message to clear/garble or hijack any viewer's terminal.
101
- export function stripControl(s) {
102
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control chars to remove them
103
- return s.replace(/[\x00-\x1f\x7f]/g, '');
104
- }
105
100
  export function formatLine(m, now = new Date()) {
106
101
  const lvl = m.senderLevel != null ? dim(` lv${m.senderLevel}`) : '';
107
102
  // Colour the author's handle in their belt/rank colour (see rankColor).
@@ -173,6 +168,9 @@ async function chatPost(words, json) {
173
168
  return;
174
169
  }
175
170
  success('posted.');
171
+ await captureEvent(session.handle, 'cli_chat_message_posted', {
172
+ content_length: content.length,
173
+ });
176
174
  }
177
175
  async function chatHistory(args, json) {
178
176
  const before = flagValue(args, '--before');
@@ -217,10 +215,13 @@ async function chatDms(json) {
217
215
  }
218
216
  for (const c of data.conversations) {
219
217
  const unread = c.unread_count > 0 ? ` ${green(`(${c.unread_count} new)`)}` : '';
218
+ // Both the partner handle and the last-message preview are untrusted user
219
+ // input wrapped in ANSI before printing — strip control chars so a crafted
220
+ // handle or DM body can't inject escape sequences into the viewer's terminal.
220
221
  const preview = c.last_message_content
221
- ? dim(` — ${c.last_message_content}`)
222
+ ? dim(` — ${stripControl(c.last_message_content)}`)
222
223
  : '';
223
- console.log(`${bold(`@${c.partner_handle}`)}${unread}${preview}`);
224
+ console.log(`${bold(`@${stripControl(c.partner_handle)}`)}${unread}${preview}`);
224
225
  }
225
226
  }
226
227
  async function chatDm(args, json) {
@@ -251,6 +252,9 @@ async function chatDm(args, json) {
251
252
  return;
252
253
  }
253
254
  success(`sent to @${handle}.`);
255
+ await captureEvent(session.handle, 'cli_dm_sent', {
256
+ content_length: message.length,
257
+ });
254
258
  return;
255
259
  }
256
260
  const res = await fetch(base, {
@@ -294,6 +298,28 @@ async function chatFlag(args, json) {
294
298
  }
295
299
  success('flagged for review.');
296
300
  }
301
+ /**
302
+ * Decide what a typed line means. A single leading slash is a local command and
303
+ * never posts. A DOUBLED leading slash is how you post a message that starts with
304
+ * a slash: it posts (and displays) verbatim, both slashes intact. Nothing is
305
+ * stripped, so the optimistic placeholder, the POST body, and the server echo all
306
+ * agree (reconcile-by-content) — and the server can reject single-leading-slash
307
+ * content (a stale client posting a command it doesn't know is local) without
308
+ * catching the doubled form. Pure + exported so these semantics are unit-tested
309
+ * without driving readline.
310
+ */
311
+ export function parseLine(input) {
312
+ const content = input.trim();
313
+ if (!content)
314
+ return { kind: 'empty' };
315
+ if (content.startsWith('//'))
316
+ return { kind: 'message', content };
317
+ if (content.startsWith('/')) {
318
+ const [cmd, ...args] = content.slice(1).trim().split(/\s+/);
319
+ return { kind: 'command', cmd: cmd ?? '', args };
320
+ }
321
+ return { kind: 'message', content };
322
+ }
297
323
  // Interactive live channel: short-poll the public tail and print new messages
298
324
  // above a readline prompt; press enter to post. CLI-first design means this is a
299
325
  // thin loop over the same endpoints the agent subcommands use.
@@ -318,6 +344,8 @@ async function chatLive() {
318
344
  function renderEntry(e, now) {
319
345
  if (e.kind === 'msg')
320
346
  return formatLine(e.m, now);
347
+ if (e.kind === 'text')
348
+ return e.text;
321
349
  const tag = e.failed ? red(`[failed: ${e.failed}]`) : dim('[sending]');
322
350
  return formatPendingLine(myHandle ?? 'you', e.content, tag, e.at, now);
323
351
  }
@@ -340,6 +368,61 @@ async function chatLive() {
340
368
  process.stdout.write(`${renderEntry(e, new Date())}\n`);
341
369
  process.stdout.write(rl.getPrompt() + inProgress);
342
370
  }
371
+ // Append a local one-row line (slash-command output). Goes through appendEntry
372
+ // as a `text` entry, so it counts as exactly one log row.
373
+ function appendText(text) {
374
+ appendEntry({ kind: 'text', text });
375
+ }
376
+ const SLASH_HELP = [
377
+ dim('/who <handle> look up a hacker'),
378
+ dim('/help this list'),
379
+ dim('//<text> post a message that starts with a slash (shown as //)'),
380
+ dim('(dm someone with `hacklab chat dm <handle> <message>`)'),
381
+ ];
382
+ // Dispatch a `/command`. Runs locally, never posts. Async commands (/who)
383
+ // append their rows when the fetch lands — the prompt was already restored by
384
+ // the caller, so the user keeps typing while the lookup is in flight.
385
+ async function handleSlash(cmd, args) {
386
+ if (cmd === 'who')
387
+ return whoLookup(args[0]);
388
+ if (cmd === 'help') {
389
+ for (const line of SLASH_HELP)
390
+ appendText(line);
391
+ return;
392
+ }
393
+ appendText(dim(`unknown command: /${stripControl(cmd)}. try /help`));
394
+ }
395
+ // `/who <handle>` — the compact card, appended one row per line. Prints
396
+ // nothing while the fetch is in flight (no placeholder to rewrite).
397
+ async function whoLookup(rawHandle) {
398
+ const handle = rawHandle?.replace(/^@/, '');
399
+ if (!handle) {
400
+ appendText(dim('usage: /who <handle>'));
401
+ return;
402
+ }
403
+ const safe = stripControl(handle);
404
+ try {
405
+ const res = await fetch(`${base}/api/hackers/${encodeURIComponent(handle)}?src=chat`, { headers: { Authorization: `Bearer ${session.token}` } });
406
+ if (res.status === 404) {
407
+ appendText(dim(`no hacker named "${safe}"`));
408
+ return;
409
+ }
410
+ if (!res.ok) {
411
+ appendText(dim(`could not look up ${safe} (${res.status})`));
412
+ return;
413
+ }
414
+ const data = (await res.json());
415
+ if (!data.hacker) {
416
+ appendText(dim(`no hacker named "${safe}"`));
417
+ return;
418
+ }
419
+ for (const line of renderCompact(data.hacker))
420
+ appendText(line);
421
+ }
422
+ catch {
423
+ appendText(dim('could not reach hacklab'));
424
+ }
425
+ }
343
426
  // Rewrite an entry's existing line in place (it must still be on screen), then
344
427
  // restore the prompt + in-progress input. Used to swap an optimistic
345
428
  // placeholder for the confirmed (or failed) message with no second line.
@@ -435,8 +518,8 @@ async function chatLive() {
435
518
  await poll();
436
519
  const timer = setInterval(poll, 1500);
437
520
  rl.on('line', async (input) => {
438
- const content = input.trim();
439
- if (!content) {
521
+ const intent = parseLine(input);
522
+ if (intent.kind === 'empty') {
440
523
  rl.prompt();
441
524
  return;
442
525
  }
@@ -449,12 +532,24 @@ async function chatLive() {
449
532
  readline.moveCursor(process.stdout, 0, -echoRows);
450
533
  readline.cursorTo(process.stdout, 0);
451
534
  readline.clearScreenDown(process.stdout);
535
+ // A local command never posts. Restore the prompt first, then let the
536
+ // (possibly async) command append its rows via appendEntry — one row per
537
+ // entry, so rowsUpOf stays correct even mid optimistic-send.
538
+ if (intent.kind === 'command') {
539
+ rl.prompt();
540
+ void handleSlash(intent.cmd, intent.args);
541
+ return;
542
+ }
543
+ // parseLine already stripped the escaping slash from a `//` message, so the
544
+ // placeholder and the POST body agree — the poll reconciles optimistic sends
545
+ // by exact content match, and a mismatch sticks the placeholder on [sending].
546
+ const postContent = intent.content;
452
547
  // Faded placeholder, one row above the freshly drawn prompt. Written inline
453
548
  // (not via appendEntry) so we can hand control back with rl.prompt(), which
454
549
  // keeps readline's cursor model correct for the next keystroke.
455
550
  const placeholder = {
456
551
  kind: 'pending',
457
- content,
552
+ content: postContent,
458
553
  at: new Date().toISOString(),
459
554
  };
460
555
  log.push(placeholder);
@@ -467,7 +562,7 @@ async function chatLive() {
467
562
  'Content-Type': 'application/json',
468
563
  Authorization: `Bearer ${session.token}`,
469
564
  },
470
- body: JSON.stringify({ content }),
565
+ body: JSON.stringify({ content: postContent }),
471
566
  });
472
567
  if (!res.ok) {
473
568
  failPending(placeholder, await readError(res));