@llamaventures/cli 1.2.1

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.
package/bin/llama.mjs ADDED
@@ -0,0 +1,1441 @@
1
+ #!/usr/bin/env node
2
+
3
+ import readline from "readline";
4
+ import {
5
+ DEFAULT_BASE_URL,
6
+ LEGACY_DIR,
7
+ LEGACY_FILE,
8
+ TOKEN_DIR,
9
+ TOKEN_FILE,
10
+ getAuthHeaders,
11
+ getBaseUrl,
12
+ getToken,
13
+ print,
14
+ readBriefing,
15
+ readCanonicalToken,
16
+ readLegacyConfig,
17
+ request,
18
+ tryGcloudIdentityToken,
19
+ writeCanonicalToken,
20
+ writeLegacyConfig,
21
+ } from "../lib/client.mjs";
22
+ import {
23
+ clearExternalSession,
24
+ EXTERNAL_SESSION_FILE,
25
+ getExternalSessionStatus,
26
+ readExternalSession,
27
+ sendExternalMessage,
28
+ startExternalSession,
29
+ uploadExternalFile,
30
+ } from "../lib/external.mjs";
31
+
32
+ function parseFlags(args) {
33
+ const flags = {};
34
+ const positional = [];
35
+ for (let i = 0; i < args.length; i++) {
36
+ const arg = args[i];
37
+ if (arg.startsWith("--")) {
38
+ const key = arg.slice(2);
39
+ const next = args[i + 1];
40
+ if (!next || next.startsWith("--")) {
41
+ flags[key] = true;
42
+ } else {
43
+ flags[key] = next;
44
+ i++;
45
+ }
46
+ } else {
47
+ positional.push(arg);
48
+ }
49
+ }
50
+ return { flags, positional };
51
+ }
52
+
53
+ // Client-side fuzzy match — used as a fallback when the server hasn't yet
54
+ // shipped the search/filter API (Fix B, 2026-04-25). Once the server
55
+ // returns the `{deals,total,limit,offset}` envelope, this path is never
56
+ // taken.
57
+ function clientSideMatch(deal, filters) {
58
+ const incl = (haystack, needle) =>
59
+ !!haystack && String(haystack).toLowerCase().includes(needle.toLowerCase());
60
+ const eq = (haystack, needle) =>
61
+ String(haystack ?? "").toLowerCase() === String(needle).toLowerCase();
62
+
63
+ if (filters.q) {
64
+ const fields = [
65
+ deal.companyName, deal.founders, deal.founderInfo,
66
+ deal.description, deal.notes, deal.dealOwner,
67
+ deal.source, deal.location,
68
+ ];
69
+ if (!fields.some((f) => incl(f, filters.q))) return false;
70
+ }
71
+ if (filters.companyName && !incl(deal.companyName, filters.companyName)) return false;
72
+ if (filters.founder && !(incl(deal.founders, filters.founder) || incl(deal.founderInfo, filters.founder))) return false;
73
+ if (filters.owner && !incl(deal.dealOwner, filters.owner)) return false;
74
+ if (filters.status && !eq(deal.status, filters.status)) return false;
75
+ if (filters.theirStage && !eq(deal.theirStage, filters.theirStage)) return false;
76
+ if (filters.stage && !eq(deal.stage, filters.stage)) return false;
77
+ return true;
78
+ }
79
+
80
+ // Build the `?...` query string for /api/deals from CLI flags + positional q.
81
+ function buildDealsQuery(q, flags) {
82
+ const params = new URLSearchParams();
83
+ if (q) params.set("q", q);
84
+ for (const key of ["companyName", "founder", "owner", "status", "theirStage", "stage", "limit", "offset"]) {
85
+ if (flags[key] !== undefined && flags[key] !== true) {
86
+ params.set(key, String(flags[key]));
87
+ }
88
+ }
89
+ return params;
90
+ }
91
+
92
+ // Hit /api/deals with the given filters. Handles both response shapes:
93
+ // - bare array (old API or no params) → client-side filter, return envelope
94
+ // - {deals,total,limit,offset} (new API) → return as-is
95
+ async function searchDeals(q, flags) {
96
+ const params = buildDealsQuery(q, flags);
97
+ const qs = params.toString();
98
+ const result = await request("GET", `/api/deals${qs ? `?${qs}` : ""}`);
99
+
100
+ if (Array.isArray(result)) {
101
+ // Fix B not deployed yet, OR no params sent. Filter locally so the
102
+ // CLI behavior is consistent regardless of server version.
103
+ const filters = {
104
+ q,
105
+ companyName: flags.companyName,
106
+ founder: flags.founder,
107
+ owner: flags.owner,
108
+ status: flags.status,
109
+ theirStage: flags.theirStage,
110
+ stage: flags.stage,
111
+ };
112
+ const filtered = result.filter((d) => clientSideMatch(d, filters));
113
+ const limit = Number(flags.limit) > 0 ? Number(flags.limit) : 200;
114
+ const offset = Number(flags.offset) > 0 ? Number(flags.offset) : 0;
115
+ return {
116
+ deals: filtered.slice(offset, offset + limit),
117
+ total: filtered.length,
118
+ limit,
119
+ offset,
120
+ _source: "client-filter",
121
+ };
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function usage() {
127
+ console.log(`Llama Command CLI
128
+
129
+ Agent onboarding (run once on first install):
130
+ llama agent-onboard # print AGENT_BRIEFING.md — the workflow contract for AI agents
131
+
132
+ External pitch — talk to Llama Ventures' intake agent (no token required):
133
+ llama pitch start --name "Jane Doe" --email "jane@acme.ai"
134
+ llama pitch say "We're building X..." # single message, prints reply
135
+ llama pitch upload ./deck.pdf # attach a file
136
+ llama pitch # interactive REPL (existing session)
137
+ llama pitch status # session info
138
+ llama pitch end # clear local session
139
+
140
+ Setup:
141
+ llama auth status # show current credentials + verify with server
142
+ llama token set <llc_token> [--base https://command.llamaventures.vc]
143
+ llama token show
144
+
145
+ Zero-config: if you've already run \`gcloud auth login\` with your
146
+ @llamaventures.vc account, you don't need to set anything — the CLI
147
+ auto-detects \`gcloud auth print-identity-token\` and uses Bearer auth.
148
+ Manually-set \`llc_\` tokens are used as a fallback.
149
+
150
+ Deals:
151
+ llama deal create "Company" --source <name> --description "..." --website https://...
152
+ llama deal show <dealId>
153
+ llama deal update <dealId> <field> <value>
154
+ llama deal search <query> [--founder name] [--owner <user-key>] [--status Diligence]
155
+ [--theirStage Raising] [--stage Seed]
156
+ [--limit 200] [--offset 0]
157
+ llama deal list [--owner ...] [--status ...] [...same flags as search]
158
+
159
+ Collaborators (besides owner — attribution candidates, no approval):
160
+ llama deal collab list <dealId>
161
+ llama deal collab add <dealId> --user <userId|email>
162
+ llama deal collab remove <dealId> --user <userId|email> # soft-delete
163
+ llama deal collab restore <dealId> --user <userId|email>
164
+
165
+ Soft-delete:
166
+ All UI/CLI deletes are soft. Real delete = direct DB only. Each
167
+ removal/restore writes a deal_events row so the timeline records who
168
+ did what when. Trash views via ?include_deleted=1 on read endpoints.
169
+
170
+ Brief blocks (text/link/embed/callout):
171
+ llama brief blocks <dealId> # list (excludes trashed)
172
+ llama brief block <dealId> <blockId> # fetch single block (with body)
173
+ llama brief delete <dealId> <blockId> # soft-delete
174
+ llama brief restore <dealId> <blockId>
175
+
176
+ Deal links (separate from brief link blocks — these live in deal_links):
177
+ llama deal link list <dealId> [--include-deleted]
178
+ llama deal link add <dealId> --url <url> [--label "..."]
179
+ llama deal link delete <dealId> <linkId> # soft-delete
180
+ llama deal link restore <dealId> <linkId>
181
+
182
+ Ownership:
183
+ llama claim <dealId> # propose self as owner
184
+ llama nominate <dealId> --user <userId> # partner nominates someone else
185
+ llama nominations list # pending nominations for me
186
+ llama nominations decide <approvalId> accepted|declined # accept/decline a nomination
187
+
188
+ Approvals (partner queue — self-claim approvals):
189
+ llama approvals list
190
+ llama approvals decide <approvalId> approved|rejected [--note "..."]
191
+
192
+ Timeline / Posts:
193
+ llama timeline <dealId> # full unified feed
194
+ llama post <dealId> "message body" [--link url] [--link-name "name"]
195
+
196
+ Brief blocks:
197
+ llama brief blocks <dealId> # list current block array
198
+ llama brief block <dealId> <blockId> # fetch one block's body (manifest in 'llama deal show')
199
+ llama brief add-text <dealId> --heading "..." --body "..."
200
+ llama brief add-link <dealId> --url "..." --label "..." [--description "..."]
201
+ llama brief add-embed <dealId> --url "..." [--label "..."]
202
+ llama brief add-callout <dealId> --tone insight|info|warning|success --heading "..." --body "..."
203
+ llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--tone ...]
204
+ [--source-section <key>] [--lock|--unlock] [--hide|--unhide]
205
+ llama brief delete <dealId> <blockId>
206
+ llama brief history <dealId> <blockId> [--limit 50] # prior versions of this block (newest first)
207
+ llama brief restore-version <dealId> <blockId> <historyId> # restore from a history entry; the outgoing
208
+ # version is itself snapshotted (reversible)
209
+
210
+ Common flags on every add-*:
211
+ --source-section <key> Target a structured section (team, highlights, recommendation,
212
+ <persona>_analysis, ...). Without this, blocks land in "_other"
213
+ at the bottom of the TOC. AI writers want this.
214
+ --reply-to <blockId> Make the block a reply to <blockId>. Snapshots parent's heading
215
+ + 200-char excerpt into meta so the back-link survives parent
216
+ edits/deletes. Renders as an amber strip with a jump-link.
217
+ --position top|bottom Where to insert. Default: top (matches UI behavior since
218
+ 2026-05-03). Use bottom for batched writes that need to
219
+ preserve insertion order.
220
+
221
+ Brief / persona refresh + agent-run revert:
222
+ llama deal refresh-brief <dealId> [--force] # re-eval stale sections
223
+ # --force = every unlocked watcher-managed section
224
+ llama deal refresh-persona <dealId> <persona-key> # server validates persona key
225
+ llama deal revert-run <dealId> <runId> --section <key> # legacy 4-section model only
226
+ # section: company|team|highlights|recommendation
227
+
228
+ Deal soft-delete / restore / trash list:
229
+ llama deal delete <dealId> # soft (audit-logged via deal_events)
230
+ llama deal restore <dealId> # ⚠ session-only on server today (token → 401)
231
+ llama deal trash # list deleted deals
232
+
233
+ Deal facts (AI-extracted or human-asserted, with verification):
234
+ llama deal fact list <dealId> # ⚠ session-only on server today
235
+ llama deal fact add <dealId> --category <cat> --claim "<text>" [--source <url>] [--confidence high|medium|low]
236
+ llama deal fact verify <dealId> <factId> --status confirmed|disputed [--corrected-value "..."]
237
+
238
+ Skill corrections (persona-owner pushback — read by persona-watcher):
239
+ llama skill-correction list <skill-slug> [--include-deleted]
240
+ llama skill-correction add <skill-slug> "<correction text>" [--deal <uuid>] [--block <blockId>]
241
+ llama skill-correction delete <id>
242
+ Server enforces persona owner OR system admin on POST/DELETE; GET is open.
243
+ External personas (owner_email=null, e.g. virtual-liu-yi) are admin-only for write.
244
+
245
+ Mentions / Inbox:
246
+ llama mentions # default: my unresolved cues
247
+ llama mentions list [--everyone] [--all] # --everyone = team-wide; --all = include resolved
248
+ llama mentions show <mentionId> # full row
249
+ llama mentions resolve <mentionId> # mark thread resolved (idempotent)
250
+ llama mentions unread # just the badge count
251
+
252
+ Wiki:
253
+ llama wiki search <query>
254
+ llama wiki read <slug>
255
+ llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"]
256
+
257
+ Admin (system admin only — server returns 403 for non-admin tokens):
258
+ llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
259
+ llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
260
+ llama admin agent-events [--kind tool_call|loop_stalled|max_turns_reached] [--agent-kind deal|secretary|main|inbox]
261
+ [--actor email] [--tool name] [--deal <uuid>] [--errors-only] [--since 24h] [--limit 100]
262
+
263
+ Same data as the /admin web console tabs (Auth events / Deal Activity / Agent Activity)
264
+ but scriptable. Pipe through jq / grep for monitoring & forensics.
265
+
266
+ Token discovery (in order):
267
+ 1. $LLAMA_TOKEN env var
268
+ 2. ~/.llama/token (canonical, single line)
269
+ 3. ~/.llama-command/config.json (legacy v0.1 — auto-migrated forward on first read)
270
+
271
+ Env:
272
+ LLAMA_TOKEN token override
273
+ LLAMA_API_URL API base URL override
274
+ `);
275
+ }
276
+
277
+ // ============================================================
278
+ // `llama pitch` family — external founder-pitch intake
279
+ // ============================================================
280
+ //
281
+ // No Llama Command token required. Bootstraps a session against
282
+ // /api/external/* via PoW + cookie. Subcommands:
283
+ //
284
+ // llama pitch → REPL (requires existing session)
285
+ // llama pitch start --name X --email Y
286
+ // llama pitch say "<msg>"
287
+ // llama pitch upload <path>
288
+ // llama pitch status
289
+ // llama pitch end
290
+
291
+ async function handlePitch(action, rest) {
292
+ if (!action || action === "help" || action === "--help" || action === "-h") {
293
+ console.log(`Llama Ventures pitch intake — chat with our intake agent (no token required).
294
+
295
+ Setup:
296
+ llama pitch start --name "Your Name" --email "you@company.com"
297
+
298
+ Single message (non-interactive):
299
+ llama pitch say "We're building an AI dev tool for X..."
300
+
301
+ Upload a file (deck / pitch / one-pager):
302
+ llama pitch upload ./deck.pdf
303
+
304
+ Interactive REPL (requires existing session):
305
+ llama pitch
306
+
307
+ Inspect / clean up:
308
+ llama pitch status # session id, idle minutes, finalized?
309
+ llama pitch end # clear local session state
310
+
311
+ Caps (server-enforced):
312
+ 5 sessions per IP per day, 3 per email per day, 30min idle timeout,
313
+ 100 messages per session, 1M tokens per session.
314
+ `);
315
+ return;
316
+ }
317
+
318
+ if (action === "start") {
319
+ const { flags } = parseFlags(rest);
320
+ if (!flags.name || !flags.email) {
321
+ throw new Error(
322
+ "pitch start: --name and --email are required.\n" +
323
+ " Example: llama pitch start --name \"Jane Doe\" --email \"jane@acme.ai\""
324
+ );
325
+ }
326
+ const existing = readExternalSession();
327
+ if (existing && !existing.finalized) {
328
+ const status = getExternalSessionStatus();
329
+ if (status.active) {
330
+ throw new Error(
331
+ `An active pitch session already exists (started ${existing.started_at}, idle ${status.idle_minutes}min).\n` +
332
+ ` Run \`llama pitch end\` to clear it, or \`llama pitch say "..."\` to continue.`
333
+ );
334
+ }
335
+ }
336
+ process.stderr.write("Computing proof-of-work + opening session...\n");
337
+ const session = await startExternalSession({
338
+ name: String(flags.name),
339
+ email: String(flags.email),
340
+ });
341
+ print({
342
+ session_id: session.session_id,
343
+ name: session.name,
344
+ email: session.email,
345
+ started_at: session.started_at,
346
+ hint: 'Now run `llama pitch say "..."` to chat, or just `llama pitch` for interactive REPL.',
347
+ });
348
+ return;
349
+ }
350
+
351
+ if (action === "say") {
352
+ const message = rest.join(" ").trim();
353
+ if (!message) {
354
+ throw new Error('pitch say: message required. Example: llama pitch say "We\'re building X"');
355
+ }
356
+ const result = await sendExternalMessage(message);
357
+ process.stdout.write(result.text + "\n");
358
+ if (result.finalized) {
359
+ process.stderr.write("\n--- Pitch session finalized by the agent ---\n");
360
+ if (result.finalize_payload) {
361
+ process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
362
+ }
363
+ }
364
+ return;
365
+ }
366
+
367
+ if (action === "upload") {
368
+ const filePath = rest[0];
369
+ if (!filePath) {
370
+ throw new Error("pitch upload: file path required. Example: llama pitch upload ./deck.pdf");
371
+ }
372
+ process.stderr.write(`Uploading ${filePath}...\n`);
373
+ const result = await uploadExternalFile(filePath);
374
+ print(result);
375
+ return;
376
+ }
377
+
378
+ if (action === "status") {
379
+ print(getExternalSessionStatus());
380
+ return;
381
+ }
382
+
383
+ if (action === "end") {
384
+ const had = readExternalSession();
385
+ clearExternalSession();
386
+ print({
387
+ ok: true,
388
+ cleared: !!had,
389
+ session_file: EXTERNAL_SESSION_FILE,
390
+ note: had
391
+ ? "Local session state cleared. Server-side session may still be active until idle timeout (30min)."
392
+ : "No local session was active.",
393
+ });
394
+ return;
395
+ }
396
+
397
+ // No action → REPL mode (requires existing session)
398
+ if (action === undefined || (rest.length === 0 && !["start", "say", "upload", "status", "end"].includes(action))) {
399
+ // Treat any unknown bare action as "join existing session in REPL mode"
400
+ const session = readExternalSession();
401
+ if (!session) {
402
+ throw new Error(
403
+ "No active pitch session. Start one with:\n" +
404
+ ' llama pitch start --name "Your Name" --email "you@company.com"'
405
+ );
406
+ }
407
+ if (session.finalized) {
408
+ throw new Error(
409
+ "This pitch session is finalized. Run `llama pitch end` then `pitch start` for a new one."
410
+ );
411
+ }
412
+ await runPitchRepl();
413
+ return;
414
+ }
415
+
416
+ throw new Error(`Unknown pitch subcommand: ${action}. Run \`llama pitch help\` for the full list.`);
417
+ }
418
+
419
+ async function runPitchRepl() {
420
+ const rl = readline.createInterface({
421
+ input: process.stdin,
422
+ output: process.stdout,
423
+ prompt: "you> ",
424
+ });
425
+
426
+ console.log("Connected to Llama Ventures intake agent. Type your pitch — :q to exit, :upload <path> to attach a file.");
427
+ console.log("");
428
+
429
+ const send = async (msg) => {
430
+ process.stdout.write("\nllama> ");
431
+ let buffered = "";
432
+ const result = await sendExternalMessage(msg, {
433
+ onChunk: (chunk) => {
434
+ process.stdout.write(chunk);
435
+ buffered += chunk;
436
+ },
437
+ });
438
+ if (!buffered) process.stdout.write(result.text);
439
+ process.stdout.write("\n\n");
440
+ if (result.finalized) {
441
+ console.log("--- Pitch session finalized ---");
442
+ if (result.finalize_payload) {
443
+ console.log(JSON.stringify(result.finalize_payload, null, 2));
444
+ }
445
+ rl.close();
446
+ return true;
447
+ }
448
+ return false;
449
+ };
450
+
451
+ rl.prompt();
452
+ rl.on("line", async (line) => {
453
+ const trimmed = line.trim();
454
+ if (trimmed === ":q" || trimmed === ":quit" || trimmed === ":exit") {
455
+ rl.close();
456
+ return;
457
+ }
458
+ if (trimmed.startsWith(":upload ")) {
459
+ const filePath = trimmed.slice(8).trim();
460
+ try {
461
+ process.stdout.write("uploading...\n");
462
+ const result = await uploadExternalFile(filePath);
463
+ console.log(`uploaded: ${result.filename} (${result.drive_file_id})`);
464
+ } catch (err) {
465
+ console.error("upload error:", err.message);
466
+ }
467
+ rl.prompt();
468
+ return;
469
+ }
470
+ if (!trimmed) {
471
+ rl.prompt();
472
+ return;
473
+ }
474
+ try {
475
+ const finalized = await send(trimmed);
476
+ if (finalized) return;
477
+ } catch (err) {
478
+ console.error("error:", err.message);
479
+ }
480
+ rl.prompt();
481
+ });
482
+
483
+ await new Promise((resolve) => rl.on("close", resolve));
484
+ }
485
+
486
+ async function main() {
487
+ const [area, action, ...rest] = process.argv.slice(2);
488
+ if (!area || area === "help" || area === "--help" || area === "-h") {
489
+ usage();
490
+ return;
491
+ }
492
+
493
+ // `llama agent-onboard` — print the bundled AGENT_BRIEFING.md so an AI
494
+ // agent reads it once and internalises the Llama Ventures workflow
495
+ // contract. Same content the `agent_briefing` MCP prompt returns.
496
+ // Also: `llama agent onboard` (two-word form) for symmetry.
497
+ //
498
+ // Gated behind /api/me — without valid credentials we print a short
499
+ // bootstrap stub instead. Stops unauthenticated callers from harvesting
500
+ // internal command surface / workflow conventions just by running the
501
+ // public CLI.
502
+ if (
503
+ area === "agent-onboard" ||
504
+ (area === "agent" && (action === "onboard" || action === "briefing"))
505
+ ) {
506
+ const headers = await getAuthHeaders();
507
+ if (Object.keys(headers).length === 0) {
508
+ console.log(
509
+ `Llama Ventures team onboarding requires credentials.
510
+
511
+ Team member?
512
+ - Run \`gcloud auth login\` with your @llamaventures.vc account, OR
513
+ - Mint a token at https://command.llamaventures.vc/settings/tokens
514
+ then \`llama token set <llc_...>\`.
515
+ Re-run \`llama agent-onboard\` after — the workflow contract will print.
516
+
517
+ Founder or external visitor (no Llama account)?
518
+ Run \`llama pitch start --name "Your Name" --email "you@company.com"\`
519
+ to chat with our intake agent — no token required.`
520
+ );
521
+ return;
522
+ }
523
+ try {
524
+ await request("GET", "/api/me");
525
+ } catch (e) {
526
+ const msg = e?.message || "";
527
+ if (msg.includes("Error[UNAUTHORIZED]") || msg.includes("Error[NO_AUTH]")) {
528
+ console.log(
529
+ `Llama Ventures team onboarding requires valid credentials.
530
+
531
+ Server rejected the credentials we sent. Re-mint at
532
+ https://command.llamaventures.vc/settings/tokens, run
533
+ \`llama token set <llc_...>\`, then re-run \`llama agent-onboard\`.`
534
+ );
535
+ process.exitCode = 1;
536
+ return;
537
+ }
538
+ throw e;
539
+ }
540
+ process.stdout.write(readBriefing());
541
+ return;
542
+ }
543
+
544
+ // `llama pitch ...` — external founder-pitch family. No Llama token
545
+ // required; bootstraps a session against /api/external/* via PoW + cookie.
546
+ // See lib/external.mjs and AGENT_BRIEFING.md for the full surface.
547
+ if (area === "pitch") {
548
+ await handlePitch(action, rest);
549
+ return;
550
+ }
551
+
552
+ if (area === "token" && action === "set") {
553
+ const { flags, positional } = parseFlags(rest);
554
+ const token = positional[0];
555
+ if (!token?.startsWith("llc_")) throw new Error("Expected a token starting with llc_");
556
+ if (token.length !== 36) {
557
+ throw new Error(
558
+ `Token has length ${token.length}; expected 36 (llc_ + 32 hex chars).\n` +
559
+ ` This usually means you copied the masked preview ("llc_xxxx…yyyy") from\n` +
560
+ ` the token list instead of the full string from the mint response.\n` +
561
+ ` Re-mint at https://command.llamaventures.vc/settings/tokens and use the\n` +
562
+ ` Copy button — it captures the full value.`
563
+ );
564
+ }
565
+ if (flags.base) {
566
+ // baseUrl still lives in legacy config — rarely overridden. Keep the
567
+ // file there so we don't introduce a second config surface.
568
+ const legacy = readLegacyConfig();
569
+ legacy.baseUrl = String(flags.base).replace(/\/$/, "");
570
+ writeLegacyConfig(legacy);
571
+ }
572
+ // Round-trip the token against /api/me before persisting. Catches the
573
+ // pasted-preview / wrong-token / wrong-host cases at "set" time instead
574
+ // of letting them fester until the next CLI call (or worse, a CI run).
575
+ // --skip-verify is an escape hatch for offline / pre-deploy testing.
576
+ if (!flags["skip-verify"]) {
577
+ try {
578
+ const res = await fetch(`${getBaseUrl()}/api/me`, {
579
+ headers: { "X-Llama-Token": token },
580
+ });
581
+ if (res.status === 401 || res.status === 403) {
582
+ const body = await res.text();
583
+ throw new Error(
584
+ `Server rejected this token (HTTP ${res.status}). Not saving.\n` +
585
+ ` Response: ${body.slice(0, 200)}\n` +
586
+ ` Base URL: ${getBaseUrl()}\n` +
587
+ ` Re-check that you copied the full token from the mint dialog\n` +
588
+ ` ("Shown once") and not the masked preview from the list view.\n` +
589
+ ` Override with --skip-verify if you know the server is unreachable.`
590
+ );
591
+ }
592
+ if (!res.ok) {
593
+ throw new Error(`Verify call failed: HTTP ${res.status}. Not saving.`);
594
+ }
595
+ } catch (e) {
596
+ if (e instanceof Error && e.message.startsWith("Server rejected") || e.message.startsWith("Verify call failed")) {
597
+ throw e;
598
+ }
599
+ // Network / DNS failure — surface but let the user override.
600
+ throw new Error(
601
+ `Could not reach ${getBaseUrl()} to verify token: ${e.message}\n` +
602
+ ` Add --skip-verify if you want to save anyway.`
603
+ );
604
+ }
605
+ }
606
+ writeCanonicalToken(token);
607
+ console.log(`Saved token to ~/.llama/token (mode 0600).`);
608
+ console.log(`Base URL: ${getBaseUrl()}`);
609
+ if (!flags["skip-verify"]) console.log(`Verified against ${getBaseUrl()}/api/me — token works.`);
610
+ return;
611
+ }
612
+
613
+ if (area === "token" && action === "show") {
614
+ const token = getToken();
615
+ if (!token) {
616
+ console.log("No token set.");
617
+ return;
618
+ }
619
+ console.log(`${token.slice(0, 8)}...${token.slice(-4)} @ ${getBaseUrl()}`);
620
+ return;
621
+ }
622
+
623
+ // Self-diagnosis for agents and humans — what credentials do we have, and
624
+ // are they accepted by the server right now? Designed so an agent can
625
+ // parse the output and decide whether to drive a recovery flow.
626
+ if (area === "auth" && action === "status") {
627
+ const bearer = await tryGcloudIdentityToken();
628
+ const token = getToken();
629
+ const tokenSrc = process.env.LLAMA_TOKEN
630
+ ? "$LLAMA_TOKEN"
631
+ : readCanonicalToken()
632
+ ? "~/.llama/token"
633
+ : readLegacyConfig().token
634
+ ? "~/.llama-command/config.json (legacy)"
635
+ : null;
636
+
637
+ let serverCheck = "skipped (no credentials)";
638
+ if (bearer || token) {
639
+ try {
640
+ const me = await request("GET", "/api/me");
641
+ serverCheck = `ok — authenticated as ${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
642
+ } catch (e) {
643
+ serverCheck = `failed — ${e.message.split("\n")[0]}`;
644
+ }
645
+ }
646
+
647
+ print({
648
+ baseUrl: getBaseUrl(),
649
+ gcloudIdentityToken: bearer ? "present" : "absent",
650
+ llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
651
+ llamaTokenSource: tokenSrc,
652
+ serverCheck,
653
+ });
654
+ return;
655
+ }
656
+
657
+ if (area === "deal" && action === "create") {
658
+ const { flags, positional } = parseFlags(rest);
659
+ const companyName = positional.join(" ").trim();
660
+ if (!companyName) throw new Error("Usage: llama deal create \"Company\" [--source Name]");
661
+ const body = {
662
+ companyName,
663
+ source: flags.source,
664
+ description: flags.description,
665
+ website: flags.website,
666
+ notes: flags.notes,
667
+ status: flags.status,
668
+ theirStage: flags["their-stage"],
669
+ stage: flags.stage,
670
+ proposedAmount: flags["proposed-amount"],
671
+ roundSize: flags["round-size"],
672
+ valuation: flags.valuation,
673
+ founders: flags.founders,
674
+ location: flags.location,
675
+ };
676
+ print(await request("POST", "/api/deals/create", body));
677
+ return;
678
+ }
679
+
680
+ if (area === "deal" && action === "show") {
681
+ const dealId = rest[0];
682
+ if (!dealId) throw new Error("Usage: llama deal show <dealId>");
683
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/command-center`));
684
+ return;
685
+ }
686
+
687
+ if (area === "deal" && action === "update") {
688
+ const [dealId, field, ...valueParts] = rest;
689
+ const value = valueParts.join(" ");
690
+ if (!dealId || !field) throw new Error("Usage: llama deal update <dealId> <field> <value>");
691
+ print(await request("POST", "/api/deals/update", { dealId, field, value }));
692
+ return;
693
+ }
694
+
695
+ if (area === "deal" && action === "search") {
696
+ const { flags, positional } = parseFlags(rest);
697
+ const q = positional.join(" ").trim();
698
+ if (!q && Object.keys(flags).length === 0) {
699
+ throw new Error(
700
+ `Usage: llama deal search <query> [--founder ...] [--owner ...] [--status ...] [--stage ...] [--limit N]`
701
+ );
702
+ }
703
+ print(await searchDeals(q, flags));
704
+ return;
705
+ }
706
+
707
+ if (area === "deal" && action === "list") {
708
+ const { flags } = parseFlags(rest);
709
+ print(await searchDeals("", flags));
710
+ return;
711
+ }
712
+
713
+ // ----- Collaborators (deal team — non-owner contributors) -----
714
+ // Accepts --user as numeric id OR @llamaventures.vc email; emails are
715
+ // resolved to id via /api/users so the CLI matches how the web picker
716
+ // works (you don't need to memorize ids).
717
+ if (area === "deal" && action === "collab") {
718
+ const sub = rest[0];
719
+ const dealId = rest[1];
720
+ const { flags } = parseFlags(rest.slice(2));
721
+
722
+ if (!sub || !dealId) {
723
+ throw new Error(
724
+ "Usage: llama deal collab list|add|remove <dealId> [--user <userId|email>]"
725
+ );
726
+ }
727
+
728
+ if (sub === "list") {
729
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/collaborators`));
730
+ return;
731
+ }
732
+
733
+ if (sub !== "add" && sub !== "remove" && sub !== "restore") {
734
+ throw new Error(`Unknown collab sub-command "${sub}". Use list, add, remove, or restore.`);
735
+ }
736
+
737
+ if (!flags.user) {
738
+ throw new Error(`Usage: llama deal collab ${sub} <dealId> --user <userId|email>`);
739
+ }
740
+
741
+ let userId = Number(flags.user);
742
+ if (!Number.isFinite(userId)) {
743
+ const email = String(flags.user).toLowerCase();
744
+ const usersPayload = await request("GET", "/api/users");
745
+ const list = Array.isArray(usersPayload) ? usersPayload : usersPayload.users ?? [];
746
+ const match = list.find((u) => String(u.email).toLowerCase() === email);
747
+ if (!match) throw new Error(`No active user with email "${flags.user}"`);
748
+ userId = match.id;
749
+ }
750
+
751
+ if (sub === "add") {
752
+ print(await request(
753
+ "POST",
754
+ `/api/deals/${encodeURIComponent(dealId)}/collaborators`,
755
+ { userId }
756
+ ));
757
+ } else if (sub === "remove") {
758
+ print(await request(
759
+ "DELETE",
760
+ `/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}`
761
+ ));
762
+ } else {
763
+ print(await request(
764
+ "POST",
765
+ `/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}/restore`
766
+ ));
767
+ }
768
+ return;
769
+ }
770
+
771
+ // ----- Deal links (URLs attached to a deal — separate from brief link blocks) -----
772
+ // Soft-delete: removal sets deleted_at, restore clears it. List excludes
773
+ // trashed by default; pass --include-deleted to see them.
774
+ if (area === "deal" && action === "link") {
775
+ const sub = rest[0];
776
+ const dealId = rest[1];
777
+ if (!sub || !dealId) {
778
+ throw new Error(
779
+ "Usage: llama deal link list|add|delete|restore <dealId> [...flags|<linkId>]"
780
+ );
781
+ }
782
+
783
+ if (sub === "list") {
784
+ const { flags } = parseFlags(rest.slice(2));
785
+ const qs = flags["include-deleted"] ? "?include_deleted=1" : "";
786
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/links${qs}`));
787
+ return;
788
+ }
789
+
790
+ if (sub === "add") {
791
+ const { flags } = parseFlags(rest.slice(2));
792
+ if (!flags.url) throw new Error("Usage: llama deal link add <dealId> --url <url> [--label \"...\"]");
793
+ print(await request(
794
+ "POST",
795
+ `/api/deals/${encodeURIComponent(dealId)}/links`,
796
+ { url: String(flags.url), label: flags.label ? String(flags.label) : "" }
797
+ ));
798
+ return;
799
+ }
800
+
801
+ if (sub === "delete" || sub === "restore") {
802
+ const linkId = rest[2];
803
+ if (!linkId) throw new Error(`Usage: llama deal link ${sub} <dealId> <linkId>`);
804
+ const path = `/api/deals/${encodeURIComponent(dealId)}/links/${encodeURIComponent(linkId)}`;
805
+ print(await request(
806
+ sub === "delete" ? "DELETE" : "POST",
807
+ sub === "delete" ? path : `${path}/restore`
808
+ ));
809
+ return;
810
+ }
811
+
812
+ throw new Error(`Unknown link sub-command "${sub}". Use list, add, delete, or restore.`);
813
+ }
814
+
815
+ // ----- Deal soft-delete / restore / trash list -----
816
+ // Server side: DELETE /api/deals/:id uses authenticate() (token works).
817
+ // POST /restore currently uses session-only auth() — known asymmetry,
818
+ // pending server fix to swap to authenticate() for parity.
819
+ if (area === "deal" && action === "delete") {
820
+ const dealId = rest[0];
821
+ if (!dealId) throw new Error("Usage: llama deal delete <dealId>");
822
+ print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}`));
823
+ return;
824
+ }
825
+
826
+ if (area === "deal" && action === "restore") {
827
+ const dealId = rest[0];
828
+ if (!dealId) throw new Error("Usage: llama deal restore <dealId>");
829
+ // NOTE: server uses session-only auth() today — token callers get 401
830
+ // until server is updated. CLI surface is forward-compatible.
831
+ print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/restore`));
832
+ return;
833
+ }
834
+
835
+ if (area === "deal" && action === "trash") {
836
+ print(await request("GET", "/api/deals/deleted"));
837
+ return;
838
+ }
839
+
840
+ // ----- Deal facts (AI-extracted or human-asserted, with verification) -----
841
+ // NOTE: server routes currently use session-only auth() — token-only
842
+ // callers will get 401 until /api/deals/:id/facts and
843
+ // /api/deals/:id/facts/:factId switch to authenticate(). CLI surface
844
+ // is forward-compatible.
845
+ if (area === "deal" && action === "fact") {
846
+ const sub = rest[0];
847
+ const dealId = rest[1];
848
+ const { flags } = parseFlags(rest.slice(2));
849
+
850
+ if (!sub || !dealId) {
851
+ throw new Error("Usage: llama deal fact list|add|verify <dealId> [...]");
852
+ }
853
+
854
+ if (sub === "list") {
855
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/facts`));
856
+ return;
857
+ }
858
+
859
+ if (sub === "add") {
860
+ if (!flags.category || !flags.claim) {
861
+ throw new Error(
862
+ `Usage: llama deal fact add <dealId> --category <cat> --claim "<text>" ` +
863
+ `[--source <url>] [--confidence high|medium|low]`
864
+ );
865
+ }
866
+ print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/facts`, {
867
+ category: String(flags.category),
868
+ claim: String(flags.claim),
869
+ source: flags.source ? String(flags.source) : "",
870
+ confidence: flags.confidence ? String(flags.confidence) : "medium",
871
+ }));
872
+ return;
873
+ }
874
+
875
+ if (sub === "verify") {
876
+ const factId = rest[2];
877
+ if (!factId) {
878
+ throw new Error(
879
+ `Usage: llama deal fact verify <dealId> <factId> ` +
880
+ `--status confirmed|disputed [--corrected-value "..."]`
881
+ );
882
+ }
883
+ if (!flags.status || !["confirmed", "disputed"].includes(String(flags.status))) {
884
+ throw new Error("--status must be 'confirmed' or 'disputed'");
885
+ }
886
+ const body = { status: String(flags.status) };
887
+ if (flags["corrected-value"] !== undefined && flags["corrected-value"] !== true) {
888
+ body.correctedValue = String(flags["corrected-value"]);
889
+ }
890
+ print(await request(
891
+ "PATCH",
892
+ `/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(factId)}`,
893
+ body
894
+ ));
895
+ return;
896
+ }
897
+
898
+ throw new Error(`Unknown fact sub-command "${sub}". Use list, add, or verify.`);
899
+ }
900
+
901
+ // ----- Brief refresh: trigger stale-section re-eval watcher run -----
902
+ // Server only fires for unlocked sections that are stale per the
903
+ // freshness policy; --force runs every unlocked watcher-managed section.
904
+ if (area === "deal" && action === "refresh-brief") {
905
+ const { flags } = parseFlags(rest);
906
+ const dealId = rest[0];
907
+ if (!dealId) throw new Error("Usage: llama deal refresh-brief <dealId> [--force]");
908
+ const qs = flags.force ? "?force=true" : "";
909
+ print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/refresh-brief${qs}`));
910
+ return;
911
+ }
912
+
913
+ // ----- Persona refresh: re-run a single persona-watcher -----
914
+ // Persona keys are validated server-side. Returns runId or null
915
+ // (debounced / deal inactive). Used by /admin and the per-block
916
+ // "重新生成" flow.
917
+ if (area === "deal" && action === "refresh-persona") {
918
+ const dealId = rest[0];
919
+ const persona = rest[1];
920
+ if (!dealId || !persona) {
921
+ throw new Error(`Usage: llama deal refresh-persona <dealId> <persona-key>`);
922
+ }
923
+ print(await request(
924
+ "POST",
925
+ `/api/deals/${encodeURIComponent(dealId)}/refresh-persona`,
926
+ { persona }
927
+ ));
928
+ return;
929
+ }
930
+
931
+ // ----- Agent-run revert (legacy 4-section brief model) -----
932
+ // Reverts a single section to the `before` value snapshotted by the
933
+ // watcher run. Does NOT re-fire the watcher (intentional: human action).
934
+ // Logs `brief_reverted` in deal_events. Section keys are the legacy
935
+ // top-level sections, not block ids.
936
+ if (area === "deal" && action === "revert-run") {
937
+ const { flags } = parseFlags(rest);
938
+ const dealId = rest[0];
939
+ const runId = rest[1];
940
+ const valid = ["company", "team", "highlights", "recommendation"];
941
+ if (!dealId || !runId || !flags.section) {
942
+ throw new Error(
943
+ `Usage: llama deal revert-run <dealId> <runId> --section ${valid.join("|")}`
944
+ );
945
+ }
946
+ if (!valid.includes(String(flags.section))) {
947
+ throw new Error(`--section must be one of ${valid.join(", ")}`);
948
+ }
949
+ print(await request(
950
+ "POST",
951
+ `/api/deals/${encodeURIComponent(dealId)}/agent-runs/${encodeURIComponent(runId)}/revert`,
952
+ { section: String(flags.section) }
953
+ ));
954
+ return;
955
+ }
956
+
957
+ if (area === "approvals" && action === "list") {
958
+ print(await request("GET", "/api/partner/approvals"));
959
+ return;
960
+ }
961
+
962
+ if (area === "approvals" && action === "decide") {
963
+ const { flags, positional } = parseFlags(rest);
964
+ const approvalId = Number(positional[0]);
965
+ const decision = positional[1];
966
+ if (!Number.isFinite(approvalId) || !["approved", "rejected"].includes(decision)) {
967
+ throw new Error("Usage: llama approvals decide <approvalId> approved|rejected [--note ...]");
968
+ }
969
+ print(await request("POST", "/api/partner/approvals", {
970
+ approvalId,
971
+ decision,
972
+ note: flags.note || "",
973
+ }));
974
+ return;
975
+ }
976
+
977
+ // ----- Ownership: self-claim -----
978
+ if (area === "claim") {
979
+ const dealId = action; // second positional
980
+ if (!dealId) throw new Error("Usage: llama claim <dealId>");
981
+ const me = await request("GET", "/api/me");
982
+ print(await request(
983
+ "POST",
984
+ `/api/deals/${encodeURIComponent(dealId)}/propose-owner`,
985
+ { userId: me.id }
986
+ ));
987
+ return;
988
+ }
989
+
990
+ // ----- Ownership: partner nominates someone else -----
991
+ if (area === "nominate") {
992
+ const dealId = action;
993
+ const { flags } = parseFlags(rest);
994
+ const userId = Number(flags.user);
995
+ if (!dealId || !Number.isFinite(userId)) {
996
+ throw new Error("Usage: llama nominate <dealId> --user <userId>");
997
+ }
998
+ print(await request(
999
+ "POST",
1000
+ `/api/deals/${encodeURIComponent(dealId)}/propose-owner`,
1001
+ { userId }
1002
+ ));
1003
+ return;
1004
+ }
1005
+
1006
+ // ----- Nominations inbox (for the nominee) -----
1007
+ if (area === "nominations" && action === "list") {
1008
+ print(await request("GET", "/api/me/nominations"));
1009
+ return;
1010
+ }
1011
+ if (area === "nominations" && action === "decide") {
1012
+ const approvalId = Number(rest[0]);
1013
+ const decision = rest[1];
1014
+ if (!Number.isFinite(approvalId) || !["accepted", "declined"].includes(decision)) {
1015
+ throw new Error("Usage: llama nominations decide <approvalId> accepted|declined");
1016
+ }
1017
+ print(await request("POST", `/api/nominations/${approvalId}`, { decision }));
1018
+ return;
1019
+ }
1020
+
1021
+ // ----- Timeline -----
1022
+ if (area === "timeline") {
1023
+ const dealId = action;
1024
+ if (!dealId) throw new Error("Usage: llama timeline <dealId>");
1025
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/timeline`));
1026
+ return;
1027
+ }
1028
+
1029
+ // ----- Post to timeline -----
1030
+ if (area === "post") {
1031
+ const dealId = action;
1032
+ const { flags, positional } = parseFlags(rest);
1033
+ const body = positional[0];
1034
+ if (!dealId || !body) {
1035
+ throw new Error(`Usage: llama post <dealId> "message body" [--link url] [--link-name "name"]`);
1036
+ }
1037
+ const attachments = flags.link
1038
+ ? [{ url: String(flags.link), name: flags["link-name"] ? String(flags["link-name"]) : String(flags.link) }]
1039
+ : [];
1040
+ print(await request(
1041
+ "POST",
1042
+ `/api/deals/${encodeURIComponent(dealId)}/posts`,
1043
+ { body, attachments }
1044
+ ));
1045
+ return;
1046
+ }
1047
+
1048
+ // ----- Wiki: search -----
1049
+ if (area === "wiki" && action === "search") {
1050
+ const { positional } = parseFlags(rest);
1051
+ const q = positional.join(" ").trim();
1052
+ if (!q) throw new Error("Usage: llama wiki search <query>");
1053
+ print(await request("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`));
1054
+ return;
1055
+ }
1056
+
1057
+ // ----- Wiki: read a single article (EN by default) -----
1058
+ if (area === "wiki" && action === "read") {
1059
+ const slug = rest[0];
1060
+ if (!slug) throw new Error("Usage: llama wiki read <slug>");
1061
+ const results = await request("GET", `/api/wiki/search?q=${encodeURIComponent(slug)}`);
1062
+ const match = Array.isArray(results) ? results.find((r) => r.slug === slug) : null;
1063
+ print(match || { error: `Article "${slug}" not found.` });
1064
+ return;
1065
+ }
1066
+
1067
+ // ----- Wiki: save (create or update) -----
1068
+ if (area === "wiki" && action === "save") {
1069
+ const { flags, positional } = parseFlags(rest);
1070
+ const slug = positional[0];
1071
+ const title = flags.title;
1072
+ const content = flags.content;
1073
+ const sourcesRaw = flags.sources;
1074
+ if (!slug || !title || !content || !sourcesRaw) {
1075
+ throw new Error(
1076
+ `Usage: llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh]`
1077
+ );
1078
+ }
1079
+ const splitCsv = (v) => String(v).split(/[;|]/).map((s) => s.trim()).filter(Boolean);
1080
+ const payload = {
1081
+ slug,
1082
+ title: String(title),
1083
+ content: String(content),
1084
+ sources: splitCsv(sourcesRaw),
1085
+ type: flags.type ? String(flags.type) : undefined,
1086
+ related: flags.related ? splitCsv(flags.related) : undefined,
1087
+ lang: flags.lang === "zh" ? "zh" : "en",
1088
+ status: flags.status ? String(flags.status) : undefined,
1089
+ };
1090
+ print(await request("POST", "/api/wiki/save", payload));
1091
+ return;
1092
+ }
1093
+
1094
+ // ----- Brief blocks: list / add-* / edit / delete -----
1095
+ // The block-based deal brief stores an ordered array of typed blocks
1096
+ // (text / link / embed / callout) per deal. These commands wrap the
1097
+ // /api/deals/:id/blocks{,/:id} endpoints. To add a block we read +
1098
+ // append + PUT (two roundtrips); single-block edit + delete have
1099
+ // dedicated PATCH/DELETE endpoints.
1100
+ if (area === "brief" && action === "blocks") {
1101
+ const dealId = rest[0];
1102
+ if (!dealId) throw new Error("Usage: llama brief blocks <dealId>");
1103
+ print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`));
1104
+ return;
1105
+ }
1106
+
1107
+ // Per-block read — pairs with the manifest returned by /command-center
1108
+ // (i.e. `llama deal show`). Agent flow: read manifest → pick blocks
1109
+ // by id → fetch only those bodies, instead of pulling the full array.
1110
+ if (area === "brief" && action === "block") {
1111
+ const dealId = rest[0];
1112
+ const blockId = rest[1];
1113
+ if (!dealId || !blockId) throw new Error("Usage: llama brief block <dealId> <blockId>");
1114
+ print(await request(
1115
+ "GET",
1116
+ `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`
1117
+ ));
1118
+ return;
1119
+ }
1120
+
1121
+ if (area === "brief" && action?.startsWith("add-")) {
1122
+ const type = action.slice(4); // "add-text" → "text"
1123
+ if (!["text", "link", "embed", "callout"].includes(type)) {
1124
+ throw new Error(`Unknown block type "${type}". Use add-text, add-link, add-embed, or add-callout.`);
1125
+ }
1126
+ const { flags } = parseFlags(rest);
1127
+ const dealId = rest[0];
1128
+ if (!dealId) throw new Error(`Usage: llama brief add-${type} <dealId> [...flags]`);
1129
+
1130
+ const id = (typeof crypto !== "undefined" && "randomUUID" in crypto)
1131
+ ? crypto.randomUUID()
1132
+ : `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1133
+ const meta = { updated_at: new Date().toISOString(), updated_by: "cli", by_agent: false };
1134
+
1135
+ // --source-section <key>: target a structured section (e.g. team /
1136
+ // highlights / <persona>_analysis). Without this, blocks land in the
1137
+ // "_other" group at the bottom of the TOC. AI writers want this
1138
+ // virtually always — without it they cannot contribute to existing
1139
+ // structured sections.
1140
+ if (flags["source-section"]) {
1141
+ meta.sourceSection = String(flags["source-section"]);
1142
+ }
1143
+
1144
+ // --reply-to <blockId>: snapshot the parent block's heading + a
1145
+ // 200-char excerpt into meta so the back-link survives parent edits
1146
+ // or deletion. CLI only — replies are always text in the UI; allow
1147
+ // any add-* type here for symmetry but the UI only renders the
1148
+ // back-link on text + callout blocks today.
1149
+ let cur = null;
1150
+ if (flags["reply-to"]) {
1151
+ const replyTo = String(flags["reply-to"]);
1152
+ cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
1153
+ const parent = (cur.blocks ?? []).find((b) => b.id === replyTo);
1154
+ if (!parent) throw new Error(`--reply-to: block ${replyTo} not found on deal ${dealId}`);
1155
+ meta.reply_to = parent.id;
1156
+ // heading: text/callout → heading; link/embed → label; fallback "(untitled block)"
1157
+ meta.reply_to_heading =
1158
+ parent.heading || parent.label || "(untitled block)";
1159
+ // excerpt: text/callout → heading + body; link → label + description; embed → label
1160
+ const excerptParts =
1161
+ parent.type === "text" || parent.type === "callout"
1162
+ ? [parent.heading, parent.body]
1163
+ : parent.type === "link"
1164
+ ? [parent.label, parent.description]
1165
+ : [parent.label];
1166
+ const excerpt = excerptParts.filter(Boolean).join("\n\n").slice(0, 200);
1167
+ meta.reply_to_excerpt = excerpt;
1168
+ }
1169
+
1170
+ let block;
1171
+ if (type === "text") {
1172
+ block = { id, type, heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
1173
+ } else if (type === "link") {
1174
+ if (!flags.url || !flags.label) throw new Error("add-link requires --url and --label");
1175
+ block = { id, type, url: String(flags.url), label: String(flags.label), description: flags.description ? String(flags.description) : undefined, meta };
1176
+ } else if (type === "embed") {
1177
+ if (!flags.url) throw new Error("add-embed requires --url");
1178
+ block = { id, type, url: String(flags.url), label: flags.label ? String(flags.label) : undefined, meta };
1179
+ } else {
1180
+ block = { id, type, tone: flags.tone ? String(flags.tone) : "insight", heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
1181
+ }
1182
+
1183
+ // --position top|bottom (default: top). Top matches the UI behavior
1184
+ // changed 2026-05-03 — newly added blocks land at the top of the
1185
+ // brief so the writer (or reader) sees the contribution without
1186
+ // scrolling. Pass `--position bottom` to append, e.g. for batched
1187
+ // AI writes that should preserve insertion order.
1188
+ const position = flags.position ? String(flags.position) : "top";
1189
+ if (position !== "top" && position !== "bottom") {
1190
+ throw new Error(`--position must be "top" or "bottom" (got "${position}")`);
1191
+ }
1192
+
1193
+ if (!cur) cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
1194
+ const existing = cur.blocks ?? [];
1195
+ const next = position === "top" ? [block, ...existing] : [...existing, block];
1196
+ print(await request("PUT", `/api/deals/${encodeURIComponent(dealId)}/blocks`, { blocks: next }));
1197
+ console.log(`Created block ${id}`);
1198
+ return;
1199
+ }
1200
+
1201
+ if (area === "brief" && action === "edit") {
1202
+ const { flags } = parseFlags(rest);
1203
+ const dealId = rest[0];
1204
+ const blockId = rest[1];
1205
+ if (!dealId || !blockId) {
1206
+ throw new Error("Usage: llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--description ...] [--tone ...] [--source-section ...] [--lock|--unlock] [--hide|--unhide]");
1207
+ }
1208
+ const patch = {};
1209
+ for (const k of ["heading", "body", "url", "label", "description", "tone"]) {
1210
+ if (flags[k] !== undefined && flags[k] !== true) patch[k] = String(flags[k]);
1211
+ }
1212
+
1213
+ // Meta toggles. The PATCH endpoint accepts a meta object that gets
1214
+ // merged with the existing block.meta server-side, so we only need
1215
+ // to send the keys we want to change. lock/hide flags are pure
1216
+ // toggles (no value); source-section takes a key.
1217
+ const metaPatch = {};
1218
+ if (flags.lock === true) metaPatch.locked = true;
1219
+ if (flags.unlock === true) metaPatch.locked = false;
1220
+ if (flags.hide === true) metaPatch.hidden = true;
1221
+ if (flags.unhide === true) metaPatch.hidden = false;
1222
+ if (flags["source-section"] !== undefined && flags["source-section"] !== true) {
1223
+ metaPatch.sourceSection = String(flags["source-section"]);
1224
+ }
1225
+ if (Object.keys(metaPatch).length > 0) patch.meta = metaPatch;
1226
+
1227
+ if (Object.keys(patch).length === 0) throw new Error("at least one field flag required");
1228
+ print(await request("PATCH", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`, patch));
1229
+ return;
1230
+ }
1231
+
1232
+ if (area === "brief" && action === "delete") {
1233
+ const dealId = rest[0];
1234
+ const blockId = rest[1];
1235
+ if (!dealId || !blockId) throw new Error("Usage: llama brief delete <dealId> <blockId>");
1236
+ print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`));
1237
+ return;
1238
+ }
1239
+
1240
+ if (area === "brief" && action === "restore") {
1241
+ const dealId = rest[0];
1242
+ const blockId = rest[1];
1243
+ if (!dealId || !blockId) throw new Error("Usage: llama brief restore <dealId> <blockId>");
1244
+ print(await request(
1245
+ "POST",
1246
+ `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/restore`
1247
+ ));
1248
+ return;
1249
+ }
1250
+
1251
+ // Per-block content history (Wikipedia model — every overwrite snapshots
1252
+ // the prev full block JSON). Two sub-actions: list versions, and restore
1253
+ // a specific version. Restore is itself reversible — when you restore
1254
+ // version N, the OUTGOING version (the one being replaced) gets snapshotted
1255
+ // into history, so undoing a wrong restore is one more `restore-version`
1256
+ // call away.
1257
+ if (area === "brief" && action === "history") {
1258
+ const { flags } = parseFlags(rest);
1259
+ const dealId = rest[0];
1260
+ const blockId = rest[1];
1261
+ if (!dealId || !blockId) {
1262
+ throw new Error("Usage: llama brief history <dealId> <blockId> [--limit 50]");
1263
+ }
1264
+ const params = new URLSearchParams();
1265
+ if (flags.limit) params.set("limit", String(flags.limit));
1266
+ const qs = params.toString() ? `?${params.toString()}` : "";
1267
+ print(await request(
1268
+ "GET",
1269
+ `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history${qs}`
1270
+ ));
1271
+ return;
1272
+ }
1273
+
1274
+ if (area === "brief" && action === "restore-version") {
1275
+ const dealId = rest[0];
1276
+ const blockId = rest[1];
1277
+ const historyId = rest[2];
1278
+ if (!dealId || !blockId || !historyId) {
1279
+ throw new Error("Usage: llama brief restore-version <dealId> <blockId> <historyId>\n" +
1280
+ " Find <historyId> via `llama brief history <dealId> <blockId>`");
1281
+ }
1282
+ const idNum = Number(historyId);
1283
+ if (!Number.isFinite(idNum)) throw new Error(`<historyId> must be a number, got "${historyId}"`);
1284
+ print(await request(
1285
+ "POST",
1286
+ `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history`,
1287
+ { history_id: idNum }
1288
+ ));
1289
+ return;
1290
+ }
1291
+
1292
+ // ----- Admin (system admin only) -----
1293
+ // System-admin gated commands — server enforces via isSystemAdmin()
1294
+ // checking LLAMA_COMMAND_ADMIN_EMAILS env. Non-admin tokens get 403.
1295
+ // CLI doesn't pre-check; if you can't run these, ask the system admin
1296
+ // to mint you an admin token (rare — most ops should never need this surface).
1297
+ //
1298
+ // The three event feeds map 1:1 to the /admin web console tabs:
1299
+ // - auth-events : signin / token / impersonation audit (security)
1300
+ // - deal-events : every field/owner/brief change cross-deal (business)
1301
+ // - agent-events : every AI tool call / loop_stalled / max_turns (AI ops)
1302
+ if (area === "admin") {
1303
+ const sub = action;
1304
+ const valid = ["auth-events", "deal-events", "agent-events"];
1305
+ if (!valid.includes(sub)) {
1306
+ throw new Error(
1307
+ `Unknown admin sub-command "${sub || ""}". Use: ${valid.join(", ")}`
1308
+ );
1309
+ }
1310
+ const { flags } = parseFlags(rest);
1311
+ const params = new URLSearchParams();
1312
+ // Common filters across all three.
1313
+ if (flags.kind) params.set("kind", String(flags.kind));
1314
+ if (flags.actor) params.set("actor", String(flags.actor));
1315
+ if (flags.subject) params.set("subject", String(flags.subject));
1316
+ if (flags.since) params.set("since", String(flags.since));
1317
+ if (flags.limit) params.set("limit", String(flags.limit));
1318
+ if (flags.offset) params.set("offset", String(flags.offset));
1319
+ // Per-feed extras.
1320
+ if (sub === "deal-events" && flags.deal) params.set("deal", String(flags.deal));
1321
+ if (sub === "agent-events") {
1322
+ if (flags["agent-kind"]) params.set("agent_kind", String(flags["agent-kind"]));
1323
+ if (flags.tool) params.set("tool", String(flags.tool));
1324
+ if (flags.deal) params.set("deal", String(flags.deal));
1325
+ if (flags["errors-only"]) params.set("errors_only", "1");
1326
+ }
1327
+ const qs = params.toString() ? `?${params.toString()}` : "";
1328
+ print(await request("GET", `/api/admin/${sub}${qs}`));
1329
+ return;
1330
+ }
1331
+
1332
+ // ----- Mentions / Inbox -----
1333
+ // Server stores @-cues parsed out of brief blocks and posts in the
1334
+ // `deal_mentions` table. UNIQUE per (source_kind, source_id, user)
1335
+ // means re-saving a block that already cued someone won't re-fire
1336
+ // the email; resolution is mutual-observability (anyone can mark a
1337
+ // thread resolved, we record who).
1338
+ //
1339
+ // The CLI has no direct create — to "mention someone", write
1340
+ // `@FirstName` (or `@email@llamaventures.vc`) inside a brief block
1341
+ // body or a deal post. Hooks server-side do the rest.
1342
+ if (area === "mentions") {
1343
+ const sub = action || "list";
1344
+ if (sub === "list" || sub === undefined) {
1345
+ const { flags } = parseFlags(rest);
1346
+ const params = new URLSearchParams();
1347
+ if (flags.everyone) params.set("everyone", "1");
1348
+ else params.set("for_me", "1");
1349
+ if (!flags.all) params.set("unresolved", "1");
1350
+ print(await request("GET", `/api/mentions?${params.toString()}`));
1351
+ return;
1352
+ }
1353
+ if (sub === "show") {
1354
+ const id = rest[0];
1355
+ if (!id) throw new Error("Usage: llama mentions show <mentionId>");
1356
+ // No dedicated single-row endpoint — fetch all and filter. Cheap
1357
+ // (mentions table is small) and avoids a roundtrip endpoint.
1358
+ const data = await request("GET", "/api/mentions?everyone=1");
1359
+ const row = (data.mentions ?? []).find((m) => String(m.id) === String(id));
1360
+ if (!row) throw new Error(`mention ${id} not found`);
1361
+ print(row);
1362
+ return;
1363
+ }
1364
+ if (sub === "resolve") {
1365
+ const id = rest[0];
1366
+ if (!id) throw new Error("Usage: llama mentions resolve <mentionId>");
1367
+ print(await request("POST", `/api/mentions/${encodeURIComponent(id)}/resolve`));
1368
+ return;
1369
+ }
1370
+ if (sub === "unread") {
1371
+ print(await request("GET", "/api/mentions/unread-count"));
1372
+ return;
1373
+ }
1374
+ throw new Error(`Unknown mentions subcommand "${sub}". Use: list / show / resolve / unread.`);
1375
+ }
1376
+
1377
+ // ----- Skill corrections (persona-owner pushback workflow) -----
1378
+ // Persona owners (or system admins) record long-term rules each
1379
+ // persona-DD skill must obey. Read by the persona-watcher and prepended
1380
+ // to the system prompt at run time. Soft-delete is non-cascading —
1381
+ // deleting a row does NOT auto-fire watcher; the next natural run
1382
+ // (manual / stale_re_eval) just stops including the deleted rule.
1383
+ //
1384
+ // Permissions enforced server-side: only the persona owner (per
1385
+ // PERSONA_SKILLS in src/lib/persona-skills.ts) or a system admin can
1386
+ // POST or DELETE. Anyone can GET. External personas (owner_email=null)
1387
+ // are admin-only for write.
1388
+ if (area === "skill-correction") {
1389
+ const sub = action;
1390
+
1391
+ if (sub === "list") {
1392
+ const skillSlug = rest[0];
1393
+ if (!skillSlug) {
1394
+ throw new Error("Usage: llama skill-correction list <skill-slug> [--include-deleted]");
1395
+ }
1396
+ const { flags } = parseFlags(rest.slice(1));
1397
+ const params = new URLSearchParams({ skill: skillSlug });
1398
+ if (flags["include-deleted"]) params.set("include_deleted", "1");
1399
+ print(await request("GET", `/api/skill-corrections?${params.toString()}`));
1400
+ return;
1401
+ }
1402
+
1403
+ if (sub === "add") {
1404
+ const { flags, positional } = parseFlags(rest);
1405
+ const skillSlug = positional[0];
1406
+ const correctionText = positional.slice(1).join(" ").trim();
1407
+ if (!skillSlug || !correctionText) {
1408
+ throw new Error(
1409
+ `Usage: llama skill-correction add <skill-slug> "<correction text>" ` +
1410
+ `[--deal <uuid>] [--block <blockId>]`
1411
+ );
1412
+ }
1413
+ print(await request("POST", "/api/skill-corrections", {
1414
+ skill_slug: skillSlug,
1415
+ correction_text: correctionText,
1416
+ triggered_in_deal_uuid: flags.deal ? String(flags.deal) : null,
1417
+ triggered_in_block_id: flags.block ? String(flags.block) : null,
1418
+ }));
1419
+ return;
1420
+ }
1421
+
1422
+ if (sub === "delete") {
1423
+ const id = rest[0];
1424
+ if (!id) throw new Error("Usage: llama skill-correction delete <id>");
1425
+ print(await request("DELETE", `/api/skill-corrections/${encodeURIComponent(id)}`));
1426
+ return;
1427
+ }
1428
+
1429
+ throw new Error(
1430
+ `Unknown skill-correction subcommand "${sub || ""}". Use: list / add / delete.`
1431
+ );
1432
+ }
1433
+
1434
+ usage();
1435
+ process.exitCode = 1;
1436
+ }
1437
+
1438
+ main().catch((error) => {
1439
+ console.error(`Error: ${error.message}`);
1440
+ process.exit(1);
1441
+ });