hacklab 0.7.2 → 0.7.5

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 (82) hide show
  1. package/README.md +16 -1
  2. package/dist/api-client.d.ts +18 -0
  3. package/dist/api-client.d.ts.map +1 -0
  4. package/dist/api-client.js +40 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/card.d.ts +66 -0
  7. package/dist/card.d.ts.map +1 -0
  8. package/dist/card.js +151 -0
  9. package/dist/card.js.map +1 -0
  10. package/dist/commands/chat.d.ts +21 -1
  11. package/dist/commands/chat.d.ts.map +1 -1
  12. package/dist/commands/chat.js +109 -14
  13. package/dist/commands/chat.js.map +1 -1
  14. package/dist/commands/drop.d.ts +1 -1
  15. package/dist/commands/drop.d.ts.map +1 -1
  16. package/dist/commands/drop.js +6 -1
  17. package/dist/commands/drop.js.map +1 -1
  18. package/dist/commands/essay.d.ts.map +1 -1
  19. package/dist/commands/essay.js +13 -0
  20. package/dist/commands/essay.js.map +1 -1
  21. package/dist/commands/hacker.d.ts +2 -0
  22. package/dist/commands/hacker.d.ts.map +1 -0
  23. package/dist/commands/hacker.js +104 -0
  24. package/dist/commands/hacker.js.map +1 -0
  25. package/dist/commands/join.d.ts.map +1 -1
  26. package/dist/commands/join.js +22 -0
  27. package/dist/commands/join.js.map +1 -1
  28. package/dist/commands/login.d.ts.map +1 -1
  29. package/dist/commands/login.js +13 -0
  30. package/dist/commands/login.js.map +1 -1
  31. package/dist/commands/logout.d.ts.map +1 -1
  32. package/dist/commands/logout.js +2 -0
  33. package/dist/commands/logout.js.map +1 -1
  34. package/dist/commands/profile.d.ts +60 -0
  35. package/dist/commands/profile.d.ts.map +1 -0
  36. package/dist/commands/profile.js +478 -0
  37. package/dist/commands/profile.js.map +1 -0
  38. package/dist/commands/project.d.ts +5 -0
  39. package/dist/commands/project.d.ts.map +1 -0
  40. package/dist/commands/project.js +387 -0
  41. package/dist/commands/project.js.map +1 -0
  42. package/dist/commands/sync.d.ts.map +1 -1
  43. package/dist/commands/sync.js +14 -0
  44. package/dist/commands/sync.js.map +1 -1
  45. package/dist/commands/update.d.ts +2 -0
  46. package/dist/commands/update.d.ts.map +1 -0
  47. package/dist/commands/update.js +92 -0
  48. package/dist/commands/update.js.map +1 -0
  49. package/dist/index.js +6 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/posthog.d.ts +9 -0
  52. package/dist/posthog.d.ts.map +1 -0
  53. package/dist/posthog.js +20 -0
  54. package/dist/posthog.js.map +1 -0
  55. package/dist/project-infer.d.ts +50 -0
  56. package/dist/project-infer.d.ts.map +1 -0
  57. package/dist/project-infer.js +211 -0
  58. package/dist/project-infer.js.map +1 -0
  59. package/dist/registry.d.ts.map +1 -1
  60. package/dist/registry.js +25 -5
  61. package/dist/registry.js.map +1 -1
  62. package/dist/ui.d.ts +12 -0
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +56 -0
  65. package/dist/ui.js.map +1 -1
  66. package/dist/utils/npmGlobal.d.ts +30 -0
  67. package/dist/utils/npmGlobal.d.ts.map +1 -0
  68. package/dist/utils/npmGlobal.js +69 -0
  69. package/dist/utils/npmGlobal.js.map +1 -0
  70. package/dist/utils/updateCheck.d.ts +6 -0
  71. package/dist/utils/updateCheck.d.ts.map +1 -1
  72. package/dist/utils/updateCheck.js +7 -2
  73. package/dist/utils/updateCheck.js.map +1 -1
  74. package/package.json +3 -1
  75. package/dist/commands/brag.d.ts +0 -5
  76. package/dist/commands/brag.d.ts.map +0 -1
  77. package/dist/commands/brag.js +0 -87
  78. package/dist/commands/brag.js.map +0 -1
  79. package/dist/project-brag.d.ts +0 -58
  80. package/dist/project-brag.d.ts.map +0 -1
  81. package/dist/project-brag.js +0 -270
  82. package/dist/project-brag.js.map +0 -1
package/README.md CHANGED
@@ -74,12 +74,27 @@ scan local AI usage → see your rank → pick a username → sign in with GitHu
74
74
  - `hacklab logout` — clear your saved session on this machine.
75
75
  - `hacklab config <key> <value>` — set config (`cursor-api-key`, `cursor-email`).
76
76
  Bare `hacklab config` prints the effective values and where each came from.
77
- - `hacklab brag [path]` — sync a `.hacklab/project.yaml` to your profile.
77
+ - `hacklab project` — publish the repo you're standing in as a project. No
78
+ manifest: it reads the git remote, README, and package.json, shows a preview,
79
+ and publishes on confirm (`--yes` to skip, `--json` for agents). Re-running
80
+ refreshes the same project (slug derives from the repo name) without losing
81
+ its publish date or screenshots; if the live site (package.json `homepage` or
82
+ `--live <url>`) has an `og:image`, that becomes the screenshot. Override any
83
+ inferred field with `--title`, `--description`, `--live`, `--tags a,b,c`,
84
+ `--slug`. Manage the set with `hacklab project list` and
85
+ `hacklab project delete <slug>`.
78
86
  - `hacklab org` — hub for company management. If you already own a company, pick
79
87
  a field, type the new value, and it saves as you go. If you don't own one yet,
80
88
  it offers to claim or create. Subcommands: `hacklab org claim` (take ownership
81
89
  of a YC-seeded company you're a member of, or whose domain matches your login
82
90
  email) and `hacklab org create` (register a brand-new company from the CLI).
91
+ - `hacklab profile` — view and edit your own profile. Bare `profile` (or
92
+ `profile view`) shows it; `profile edit` is an org-style autosave editor;
93
+ `profile set <field> <value>` writes one field (`--clear` unsets, handles like
94
+ `x mattbratos` become full links); `profile apply profile.yaml` writes many
95
+ fields in one shot — handy for agents setting up your profile. Fields: `name`,
96
+ `bio`, `website`, `blog`, `x`, `youtube`, `instagram`, `rss`, `open-to-work`.
97
+ Add `--json` to `view`/`set`/`apply` for machine-readable output.
83
98
  - `hacklab --version` / `hacklab --help`.
84
99
 
85
100
  ## Cursor usage
@@ -0,0 +1,18 @@
1
+ import { type Session } from './session.js';
2
+ /** Print the versioned JSON error envelope and exit 1 (agent mode). */
3
+ export declare function emitJsonError(code: string, message: string): never;
4
+ /** Login gate: returns the session or exits with mode-appropriate copy. */
5
+ export declare function requireSession(json: boolean): Promise<Session>;
6
+ /**
7
+ * Human-readable message for a non-2xx envelope body. 401 gets the per-backend
8
+ * re-login hint (an --env mismatch names the exact login command), 429 the
9
+ * slow-down copy; anything else relays the server's message.
10
+ */
11
+ export declare function apiErrorMessage(status: number, body: {
12
+ error?: {
13
+ message?: string;
14
+ };
15
+ } | null, session: Session): string;
16
+ /** Read a failed Response's envelope and shape it via apiErrorMessage. */
17
+ export declare function readApiError(res: Response, session: Session): Promise<string>;
18
+ //# sourceMappingURL=api-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,OAAO,EAAoB,MAAM,cAAc,CAAA;AAO1E,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAGlE;AAED,2EAA2E;AAC3E,wBAAsB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CASpE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,EAC7C,OAAO,EAAE,OAAO,GACf,MAAM,CAIR;AAED,0EAA0E;AAC1E,wBAAsB,YAAY,CAChC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,MAAM,CAAC,CAKjB"}
@@ -0,0 +1,40 @@
1
+ import { loadSession, unauthorizedHint } from './session.js';
2
+ import { dim, error, info } from './ui.js';
3
+ // Shared plumbing for authenticated agent-friendly commands (profile, hacker):
4
+ // one login gate, one JSON error envelope, one reader for the server's
5
+ // {schemaVersion, error} shape — so the 401/429 copy can't drift per command.
6
+ /** Print the versioned JSON error envelope and exit 1 (agent mode). */
7
+ export function emitJsonError(code, message) {
8
+ console.log(JSON.stringify({ schemaVersion: 1, error: { code, message } }));
9
+ process.exit(1);
10
+ }
11
+ /** Login gate: returns the session or exits with mode-appropriate copy. */
12
+ export async function requireSession(json) {
13
+ const session = await loadSession();
14
+ if (!session) {
15
+ if (json)
16
+ emitJsonError('unauthorized', 'not logged in');
17
+ error('not logged in');
18
+ info(`run ${dim('hacklab login')} first`);
19
+ process.exit(1);
20
+ }
21
+ return session;
22
+ }
23
+ /**
24
+ * Human-readable message for a non-2xx envelope body. 401 gets the per-backend
25
+ * re-login hint (an --env mismatch names the exact login command), 429 the
26
+ * slow-down copy; anything else relays the server's message.
27
+ */
28
+ export function apiErrorMessage(status, body, session) {
29
+ if (status === 401)
30
+ return unauthorizedHint(session);
31
+ if (status === 429)
32
+ return 'slow down. try again in a minute.';
33
+ return body?.error?.message ?? `request failed (${status})`;
34
+ }
35
+ /** Read a failed Response's envelope and shape it via apiErrorMessage. */
36
+ export async function readApiError(res, session) {
37
+ const body = (await res.json().catch(() => null));
38
+ return apiErrorMessage(res.status, body, session);
39
+ }
40
+ //# sourceMappingURL=api-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAgB,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAC1E,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAE1C,+EAA+E;AAC/E,uEAAuE;AACvE,8EAA8E;AAE9E,uEAAuE;AACvE,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,OAAe;IACzD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,CAAA;IAC3E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAED,2EAA2E;AAC3E,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAa;IAChD,MAAM,OAAO,GAAG,MAAM,WAAW,EAAE,CAAA;IACnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,IAAI,IAAI;YAAE,aAAa,CAAC,cAAc,EAAE,eAAe,CAAC,CAAA;QACxD,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;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAc,EACd,IAA6C,EAC7C,OAAgB;IAEhB,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAA;IACpD,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,mCAAmC,CAAA;IAC9D,OAAO,IAAI,EAAE,KAAK,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAA;AAC7D,CAAC;AAED,0EAA0E;AAC1E,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,GAAa,EACb,OAAgB;IAEhB,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAExC,CAAA;IACR,OAAO,eAAe,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;AACnD,CAAC"}
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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"}
@@ -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));