@llamaventures/cli 1.25.0 → 2.0.0

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 CHANGED
@@ -1,15 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createRequire } from "module";
4
- import { randomUUID } from "crypto";
5
- import { readFile } from "fs/promises";
6
- import readline from "readline";
3
+ import { createRequire } from "node:module";
4
+ import { readFile } from "node:fs/promises";
5
+ import readline from "node:readline";
7
6
  import {
8
- DEFAULT_BASE_URL,
9
- LEGACY_DIR,
10
- LEGACY_FILE,
11
- TOKEN_DIR,
12
- TOKEN_FILE,
13
7
  getAuthHeaders,
14
8
  getBaseUrl,
15
9
  getToken,
@@ -18,7 +12,6 @@ import {
18
12
  readCanonicalToken,
19
13
  readLegacyConfig,
20
14
  request,
21
- requestSse,
22
15
  tryGcloudIdentityToken,
23
16
  writeCanonicalToken,
24
17
  writeLegacyConfig,
@@ -34,3773 +27,559 @@ import {
34
27
  } from "../lib/external.mjs";
35
28
  import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
36
29
  import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
37
- import { maybeNudgeUpdate, getUpdateNudge } from "../lib/version-check.mjs";
38
30
  import { getBuildInfo } from "../lib/build-info.mjs";
39
- import { workflowAuditPath } from "../lib/workflow-audit.mjs";
31
+ import { getUpdateNudge, maybeNudgeUpdate } from "../lib/version-check.mjs";
40
32
  import {
41
- WORKFLOW_REMEDIATION_PATH,
42
- workflowRemediationBody,
43
- } from "../lib/workflow-remediation.mjs";
33
+ DEAL_ACTIONS,
34
+ buildDealReadPath,
35
+ buildDealSearchPath,
36
+ prepareDealCommand,
37
+ readJsonInput,
38
+ } from "../lib/deal-actions.mjs";
44
39
 
45
40
  const requireFromHere = createRequire(import.meta.url);
46
41
  const { version: PKG_VERSION } = requireFromHere("../package.json");
47
42
 
48
- function newHtmlUploadId() {
49
- return `cli-${randomUUID()}`;
50
- }
51
-
52
- function normalizeUploadId(value) {
53
- if (typeof value !== "string" || !value.trim()) return null;
54
- const id = value.trim();
55
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) {
56
- throw new Error("--upload-id must be 1-128 chars: letters, numbers, dot, underscore, colon, or hyphen");
57
- }
58
- return id;
59
- }
60
-
61
- function parseFlags(args, knownFlags = null) {
43
+ const HELP_ROOT = `Llama Command CLI 2 — small authenticated tools for agents.
44
+
45
+ Deal has exactly four actions:
46
+ llama deal search "<company or founder>" [--state active|archived|trashed] [--limit 10]
47
+ llama deal read <dealId> [--detail overview|memory|files|conversation|history|all]
48
+ llama deal create --json <file|->
49
+ llama deal write --json <file|->
50
+
51
+ Separate preserved domains:
52
+ llama auth status|login|logout
53
+ llama token set|show
54
+ llama agent bootstrap
55
+ llama skills list|search|show
56
+ llama pref list|add|approve|retire
57
+ llama explain <url-or-object>
58
+ llama wiki search|read|save|delete|restore
59
+ llama admin auth-events|deal-events|agent-events
60
+ llama pitch start|say|upload|status|finalize|end
61
+
62
+ Run \`llama help deal\` for the mutation contract.`;
63
+
64
+ const HELP_DEAL = `Llama Command CLI 2 — Deal contract
65
+
66
+ Exactly four actions:
67
+ llama deal search [query] [--state active|archived|trashed] [--limit 10]
68
+ llama deal read <dealId> [--detail overview|memory|files|conversation|history|all]
69
+ llama deal create --json <file|->
70
+ llama deal write --json <file|->
71
+
72
+ create JSON:
73
+ {"companyName":"Acme","page":{},"information":[],"origin":{"kind":"user","originalUserUtterance":"..."}}
74
+
75
+ write operation:
76
+ input.submit | information.put | page.patch | artifact.put
77
+
78
+ Core owns Chat Records, append-only Deal Events, Drive provisioning, audit,
79
+ and idempotency. User-originated work must preserve exact wording in
80
+ origin.originalUserUtterance or reference origin.originatingChatRecordId.`;
81
+
82
+ const RETIRED_DEAL_AREAS = new Set([
83
+ "activity",
84
+ "approvals",
85
+ "brief",
86
+ "claim",
87
+ "html",
88
+ "memo",
89
+ "mentions",
90
+ "nominate",
91
+ "nominations",
92
+ "post",
93
+ "timeline",
94
+ "workflow",
95
+ ]);
96
+
97
+ function parseFlags(args, allowed = null) {
62
98
  const flags = {};
63
99
  const positional = [];
64
- for (let i = 0; i < args.length; i++) {
65
- const arg = args[i];
66
- if (arg.startsWith("--")) {
67
- const key = arg.slice(2);
68
- const next = args[i + 1];
69
- if (!next || next.startsWith("--")) {
70
- flags[key] = true;
71
- } else {
72
- flags[key] = next;
73
- i++;
74
- }
75
- } else {
100
+ for (let index = 0; index < args.length; index += 1) {
101
+ const arg = args[index];
102
+ if (!arg.startsWith("--")) {
76
103
  positional.push(arg);
104
+ continue;
77
105
  }
78
- }
79
- // Opt-in unknown-flag warning. Handlers that pass a `knownFlags` array
80
- // get a stderr nudge when they see typos like `--slug` for `--doc`.
81
- // Don't reject — agents wrap legacy options, breaking them silently is
82
- // worse than a one-line warning.
83
- if (Array.isArray(knownFlags)) {
84
- const known = new Set(knownFlags);
85
- for (const key of Object.keys(flags)) {
86
- if (!known.has(key)) {
87
- const suggestion = closestKnownFlag(key, knownFlags);
88
- process.stderr.write(
89
- suggestion
90
- ? `warning: unknown flag --${key} (did you mean --${suggestion}?)\n`
91
- : `warning: unknown flag --${key}\n`,
92
- );
93
- }
106
+ const key = arg.slice(2);
107
+ const next = args[index + 1];
108
+ if (!next || next.startsWith("--")) {
109
+ flags[key] = true;
110
+ } else {
111
+ flags[key] = next;
112
+ index += 1;
94
113
  }
95
114
  }
115
+ if (allowed) {
116
+ const unknown = Object.keys(flags).filter((key) => !allowed.includes(key));
117
+ if (unknown.length) throw new Error(`Unknown flag(s): ${unknown.map((key) => `--${key}`).join(", ")}`);
118
+ }
96
119
  return { flags, positional };
97
120
  }
98
121
 
99
- function agentOnboardNoAuthMessage() {
100
- return `Llama Ventures team onboarding requires credentials.
101
-
102
- Team member?
103
- - Run \`gcloud auth login\` with your @llamaventures.vc account, OR
104
- - Mint a token at https://command.llamaventures.vc/settings/tokens
105
- then \`llama token set <llc_...>\`.
106
- Re-run \`llama agent-onboard\` after — the workflow contract will print.
107
-
108
- Founder or external visitor (no Llama account)?
109
- Run \`llama pitch start --name "Your Name" --email "you@company.com"\`
110
- to chat with our intake agent — no token required.`;
111
- }
112
-
113
- function agentOnboardRejectedMessage() {
114
- return `Llama Ventures team onboarding requires valid credentials.
115
-
116
- Server rejected the credentials we sent. Re-mint at
117
- https://command.llamaventures.vc/settings/tokens, run
118
- \`llama token set <llc_...>\`, then re-run \`llama agent-onboard\`.`;
119
- }
120
-
121
- async function fetchServerAgentBriefing() {
122
- const params = new URLSearchParams({ clientVersion: PKG_VERSION });
123
- const result = await request("GET", `/api/agent/briefing?${params}`);
124
- return result?.briefing || "";
122
+ function usage(area) {
123
+ console.log(area === "deal" ? HELP_DEAL : HELP_ROOT);
125
124
  }
126
125
 
127
- function closestKnownFlag(input, candidates) {
128
- let best = null;
129
- let bestScore = Infinity;
130
- for (const c of candidates) {
131
- const d = levenshtein(input, c);
132
- const tolerance = Math.max(2, Math.floor(c.length / 3));
133
- if (d < bestScore && d <= tolerance) {
134
- best = c;
135
- bestScore = d;
136
- }
126
+ function assertActiveSurface(area, action) {
127
+ if (area === "deal" && !DEAL_ACTIONS.includes(action)) {
128
+ throw new Error(
129
+ `DEAL_COMMAND_RETIRED: \`llama deal ${action || "<missing>"}\` is not part of CLI 2. ` +
130
+ "Use only search, read, create --json, or write --json.",
131
+ );
137
132
  }
138
- return best;
139
- }
140
-
141
- function levenshtein(a, b) {
142
- if (a === b) return 0;
143
- const m = a.length;
144
- const n = b.length;
145
- if (m === 0) return n;
146
- if (n === 0) return m;
147
- const dp = Array(n + 1).fill(0).map((_, i) => i);
148
- for (let i = 1; i <= m; i++) {
149
- let prev = dp[0];
150
- dp[0] = i;
151
- for (let j = 1; j <= n; j++) {
152
- const tmp = dp[j];
153
- dp[j] = a[i - 1] === b[j - 1]
154
- ? prev
155
- : 1 + Math.min(prev, dp[j - 1], dp[j]);
156
- prev = tmp;
157
- }
133
+ if (RETIRED_DEAL_AREAS.has(area)) {
134
+ throw new Error(
135
+ `DEAL_COMMAND_RETIRED: \`llama ${area}\` belonged to the split legacy Deal model. ` +
136
+ "Use `llama deal read` or `llama deal write --json <file|->`.",
137
+ );
158
138
  }
159
- return dp[n];
160
- }
161
-
162
- // Slug shape used by deal_documents.slug (matches server-side SLUG_RE).
163
- function isValidDocSlug(s) {
164
- return typeof s === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(s);
165
- }
166
-
167
- // Best-effort title → slug. Strips diacritics, lowercases, collapses
168
- // non-alnum to single hyphens, trims, caps at 64. Returns null if the
169
- // result wouldn't pass `isValidDocSlug` (caller must then require --doc).
170
- function slugifyTitle(title) {
171
- if (typeof title !== "string") return null;
172
- const slug = title
173
- .toLowerCase()
174
- .normalize("NFKD")
175
- .replace(/[̀-ͯ]/g, "")
176
- .replace(/[^a-z0-9]+/g, "-")
177
- .replace(/^-+|-+$/g, "")
178
- .slice(0, 64);
179
- if (!slug || !/^[a-z0-9]/.test(slug)) return null;
180
- return slug;
181
139
  }
182
140
 
183
- // Client-side fuzzy match — used as a fallback when the server hasn't yet
184
- // shipped the search/filter API (Fix B, 2026-04-25). Once the server
185
- // returns the `{deals,total,limit,offset}` envelope, this path is never
186
- // taken.
187
- function clientSideMatch(deal, filters) {
188
- const incl = (haystack, needle) =>
189
- !!haystack && String(haystack).toLowerCase().includes(needle.toLowerCase());
190
- const eq = (haystack, needle) =>
191
- String(haystack ?? "").toLowerCase() === String(needle).toLowerCase();
192
-
193
- if (filters.q) {
194
- const fields = [
195
- deal.companyName, deal.founders, deal.founderInfo,
196
- deal.description, deal.notes, deal.dealOwner,
197
- deal.source, deal.sourceDirection, deal.location,
198
- ];
199
- if (!fields.some((f) => incl(f, filters.q))) return false;
200
- }
201
- if (filters.companyName && !incl(deal.companyName, filters.companyName)) return false;
202
- if (filters.founder && !(incl(deal.founders, filters.founder) || incl(deal.founderInfo, filters.founder))) return false;
203
- if (filters.owner && !incl(deal.dealOwner, filters.owner)) return false;
204
- if (filters.status && !eq(deal.status, filters.status)) return false;
205
- if (filters.theirStage && !eq(deal.theirStage, filters.theirStage)) return false;
206
- if (filters.stage && !eq(deal.stage, filters.stage)) return false;
207
- if (filters.sourceDirection && !eq(deal.sourceDirection, filters.sourceDirection)) return false;
208
- return true;
209
- }
141
+ function onboardingNoAuth() {
142
+ return `Llama team onboarding requires credentials.
210
143
 
211
- // Build the `?...` query string for /api/deals from CLI flags + positional q.
212
- function buildDealsQuery(q, flags) {
213
- const params = new URLSearchParams();
214
- if (q) params.set("q", q);
215
- for (const key of ["companyName", "founder", "owner", "status", "theirStage", "stage", "sourceDirection", "limit", "offset"]) {
216
- if (flags[key] !== undefined && flags[key] !== true) {
217
- params.set(key, String(flags[key]));
218
- }
219
- }
220
- if (flags["source-direction"] !== undefined && flags["source-direction"] !== true) {
221
- params.set("sourceDirection", String(flags["source-direction"]));
222
- }
223
- return params;
144
+ Run \`llama auth login\`, or mint a token in Llama Command and run
145
+ \`llama token set <llc_...>\`. External founders use \`llama pitch\`.`;
224
146
  }
225
147
 
226
- // Hit /api/deals with the given filters. Handles both response shapes:
227
- // - bare array (old API or no params) → client-side filter, return envelope
228
- // - {deals,total,limit,offset} (new API) → return as-is
229
- async function searchDeals(q, flags) {
230
- const params = buildDealsQuery(q, flags);
231
- const qs = params.toString();
232
- const result = await request("GET", `/api/deals${qs ? `?${qs}` : ""}`);
233
-
234
- if (Array.isArray(result)) {
235
- // Fix B not deployed yet, OR no params sent. Filter locally so the
236
- // CLI behavior is consistent regardless of server version.
237
- const filters = {
238
- q,
239
- companyName: flags.companyName,
240
- founder: flags.founder,
241
- owner: flags.owner,
242
- status: flags.status,
243
- theirStage: flags.theirStage,
244
- stage: flags.stage,
245
- sourceDirection: flags.sourceDirection || flags["source-direction"],
148
+ async function readTokenQuietly() {
149
+ if (!process.stdin.isTTY) {
150
+ let value = "";
151
+ for await (const chunk of process.stdin) value += chunk;
152
+ return value.trim().split(/\s+/)[0] || "";
153
+ }
154
+ process.stderr.write("Paste token (input hidden): ");
155
+ return new Promise((resolve, reject) => {
156
+ let value = "";
157
+ const cleanup = () => {
158
+ process.stdin.setRawMode(false);
159
+ process.stdin.pause();
160
+ process.stdin.off("data", onData);
246
161
  };
247
- const filtered = result.filter((d) => clientSideMatch(d, filters));
248
- const limit = Number(flags.limit) > 0 ? Number(flags.limit) : 200;
249
- const offset = Number(flags.offset) > 0 ? Number(flags.offset) : 0;
250
- return {
251
- deals: filtered.slice(offset, offset + limit),
252
- total: filtered.length,
253
- limit,
254
- offset,
255
- _source: "client-filter",
256
- };
257
- }
258
- return result;
259
- }
260
-
261
- function buildActivityQuery(kind, flags) {
262
- const params = new URLSearchParams({ kind });
263
- const mappings = [
264
- ["since", "since"],
265
- ["limit", "limit"],
266
- ["cursor", "cursor"],
267
- ["before-id", "before_id"],
268
- ["before_id", "before_id"],
269
- ["deal", "deal"],
270
- ["deal-id", "deal_id"],
271
- ["deal_id", "deal_id"],
272
- ["entity", "entity"],
273
- ["min-sig", "min_sig"],
274
- ["min_sig", "min_sig"],
275
- ["min-significance", "min_sig"],
276
- ];
277
- for (const [flag, param] of mappings) {
278
- if (flags[flag] !== undefined && flags[flag] !== true) {
279
- params.set(param, String(flags[flag]));
280
- }
281
- }
282
- for (const verb of splitCsvFlag(flags.verb) ?? []) {
283
- params.append("verb", verb);
284
- }
285
- return params;
286
- }
287
-
288
- async function fetchActivity(kind, flags) {
289
- const params = buildActivityQuery(kind, flags);
290
- return request("GET", `/api/agent/activity?${params}`);
291
- }
292
-
293
- function splitCsvFlag(value) {
294
- if (!value || value === true) return undefined;
295
- return String(value)
296
- .split(",")
297
- .map((s) => s.trim())
298
- .filter(Boolean);
299
- }
300
-
301
- function boolFlag(flags, ...names) {
302
- return names.some((name) => flags[name] === true || flags[name] === "true" || flags[name] === "1");
303
- }
304
-
305
- function buildEnrichmentAgentMessage(flags) {
306
- if (flags.message && flags.message !== true) return String(flags.message);
307
- const sources = splitCsvFlag(flags.sources) ?? [
308
- "website",
309
- "github",
310
- "linkedin",
311
- "yc",
312
- "launch",
313
- "web",
314
- "monid",
315
- ];
316
- const budget = flags["budget-cents"] || flags.budgetCents || "50";
317
- const memo = boolFlag(flags, "memo", "generate-memo", "generateMemo")
318
- ? "Generate memo only after enrichment because the caller explicitly requested it."
319
- : "Do not generate memo.";
320
- return [
321
- "Run server-side deal enrichment for this deal.",
322
- `Use sources: ${sources.join(", ")}.`,
323
- `Private Monid budget cap: ${budget} cents.`,
324
- "Read the enrichment harness first, then collect current company/founder evidence.",
325
- "Write canonical evidence links, sourced deal facts, stable deal fields, and typed factual values where supported.",
326
- "For typed factual values, call read_typed_factual_layer first and use upsert_typed_fact for queryable fields.",
327
- "Search snippets alone are not high-confidence evidence; fetch direct sources where possible.",
328
- memo,
329
- "End with what was written, what was skipped, and open questions.",
330
- ].join(" ");
331
- }
332
-
333
- async function runDealAgentViaThread(dealId, message, title = "CLI agent run") {
334
- const thread = await request("POST", `/api/deals/${encodeURIComponent(dealId)}/threads`, { title });
335
- if (!thread?.id) throw new Error("Thread creation did not return an id");
336
- process.stderr.write(`Running Deal Agent in thread ${thread.id}\n`);
337
- const result = await requestSse(
338
- "POST",
339
- `/api/deals/${encodeURIComponent(dealId)}/threads/${encodeURIComponent(thread.id)}`,
340
- { message },
341
- {
342
- onEvent(event) {
343
- if (event.tool_use?.name) {
344
- process.stderr.write(`[tool] ${event.tool_use.name}\n`);
345
- }
346
- if (event.tool_result?.name) {
347
- const status = event.tool_result.ok ? "ok" : "error";
348
- process.stderr.write(
349
- `[tool] ${event.tool_result.name}: ${status} — ${event.tool_result.summary ?? ""}\n`,
350
- );
162
+ const onData = (chunk) => {
163
+ for (const char of chunk) {
164
+ if (["\r", "\n", "\u0004"].includes(char)) {
165
+ cleanup();
166
+ process.stderr.write("\n");
167
+ resolve(value.trim());
168
+ return;
351
169
  }
352
- if (event.text) {
353
- process.stdout.write(event.text);
170
+ if (char === "\u0003") {
171
+ cleanup();
172
+ process.stderr.write("\n");
173
+ reject(new Error("Aborted"));
174
+ return;
354
175
  }
355
- },
356
- },
357
- );
358
- if (result.text && !result.text.endsWith("\n")) process.stdout.write("\n");
359
- return { thread, ...result };
360
- }
361
-
362
- const HELP_FULL = `Llama Command CLI
363
-
364
- Agent onboarding (run once on first install):
365
- llama agent-onboard # print AGENT_BRIEFING.md — the workflow contract for AI agents
366
- llama agent bootstrap # fetch live Command + Llama OS skill manifest
367
- llama skills search "pipeline update" # discover relevant runtime skills
368
- llama skills show llama-pipeline # read a skill from Command
369
- llama activity new-deals --since 24h # recent deal creations for agents
370
- llama activity updated-deals --since 7d # meaningful deal updates, grouped
371
- llama explain <url-or-object> # explain Command URL/object status + lifecycle
372
- llama pref list [--status proposed] # standing agent preferences (injected every turn)
373
- llama pref add reply-style "Lead with the conclusion." [--team]
374
-
375
- External pitch — talk to Llama Ventures' intake agent (no token required):
376
- llama pitch start --name "Jane Doe" --email "jane@acme.ai"
377
- llama pitch say "We're building X..." # single message, prints reply
378
- llama pitch upload ./deck.pdf # attach a file
379
- llama pitch # interactive REPL (existing session)
380
- llama pitch status # session info
381
- llama pitch end # clear local session
382
-
383
- Setup:
384
- llama auth status # show current credentials + verify with server
385
- llama token set <llc_token> [--base https://command.llamaventures.vc]
386
- llama token show
387
-
388
- Zero-config: if you've already run \`gcloud auth login\` with your
389
- @llamaventures.vc account, you don't need to set anything — the CLI
390
- auto-detects \`gcloud auth print-identity-token\` and uses Bearer auth.
391
- Manually-set \`llc_\` tokens are used as a fallback.
392
-
393
- Deals:
394
- llama deal create "Company" --source <name> --deal-owner <name|email|userId> --source-direction Inbound|Outbound --description "..." --status Interested|Outreached|Sourced --website https://...
395
- llama deal founders set <dealId> --json '[{"name":"Ada","email":"ada@example.com","linkedin_url":"https://linkedin.com/in/ada"}]'
396
- llama deal show <dealId>
397
- llama deal feed <dealId> # every contribution (facts + notes), human-typed or assistant-drafted, newest first
398
- llama deal update <dealId> <field> <value>
399
- Writable fields: theirStage, stage, notes, source, sourceDirection,
400
- description, website, location, founders, founderInfo, proposedAmount,
401
- roundSize, valuation, deckLink, folderUrl, sector, subsector,
402
- foundedYear, leadInvestor, investors, agentActive.
403
- 'notes' is the ONE-LINE judgment shown as the Summary headline at the top of
404
- the deal page (~280 chars). Meeting notes and narrative go in a comment
405
- (llama post); verifiable claims go in facts (llama deal fact add).
406
- e.g. llama deal update <dealId> website https://acme.ai
407
- llama deal update <dealId> sector "Developer Tools"
408
- llama deal update <dealId> foundedYear 2024
409
- llama deal update <dealId> leadInvestor "Acme Capital"
410
- llama deal enrich <dealId> [--dry-run] [--apply] [--executor server_agent|external_agent|planner]
411
- [--sources website,github,linkedin,yc,monid] [--budget-cents 50]
412
- [--memo] [--prompt] [--harness-only]
413
- dry-run returns the harness; --apply --executor server_agent runs the server Deal Agent.
414
- llama deal agent run <dealId> --message "collect founder evidence and update typed facts"
415
- llama deal extra set <dealId> <key> <value> # system-admin only
416
- Patch one top-level key in deals.extra JSONB. Value is parsed as
417
- JSON when possible ('{"a":1}', 'true', '3'), else stored as a
418
- string. Audited to deal_events as field_change "extra.<key>".
419
- llama deal extra unset <dealId> <key> # delete the key (admin)
420
- llama deal search <query> [--founder name] [--owner <user-key>] [--status Interested]
421
- [--theirStage Raising] [--stage Seed] [--source-direction Inbound]
422
- [--limit 200] [--offset 0]
423
- llama deal list [--owner ...] [--status ...] [...same flags as search]
424
-
425
- Investment Workflow V2 (the only stage-write surface):
426
- llama workflow show <dealId>
427
- llama workflow initialize <dealId> --reason "..." # audited legacy migration/bootstrap
428
- llama workflow request-support <dealId> --partner <userId> --reason "..."
429
- llama workflow decide-support <dealId> support|need_more|pass --reason "..."
430
- llama workflow proceed <dealId> --transition <key> --reason "..."
431
- llama workflow waive <dealId> --guard <key> --reason "..."
432
- llama workflow control <dealId> hold|resume|pass|restore|return --reason "..." [--disposition stalled|future]
433
- llama workflow organize-ic <dealId> --note "..."
434
- llama workflow vote <dealId> yes|no --reason "..."
435
- llama workflow reassign-owner <dealId> --owner <userId> --reason "..."
436
- llama workflow execution-status <dealId> term-sheet|verbal-commit|invested --reason "..."
437
-
438
- Agent activity (read-only, cheap read model over append-only activity):
439
- llama activity new-deals [--since 24h|7d|<ISO>] [--limit 50]
440
- llama activity updated-deals [--since 24h|7d|<ISO>] [--limit 50] [--deal <uuid>]
441
- llama activity events [--since 24h] [--verb fact.added,brief.revised] [--entity deal|wiki|all]
442
- Use this before scanning raw timelines or event-bus payloads. It returns
443
- JSON from Command's curated activity_events projection: source ids included.
444
-
445
- Collaborators (besides owner — attribution candidates, no approval):
446
- llama deal collab list <dealId>
447
- llama deal collab add <dealId> --user <userId|email>
448
- llama deal collab remove <dealId> --user <userId|email> # soft-delete
449
- llama deal collab restore <dealId> --user <userId|email>
450
-
451
- Soft-delete:
452
- All UI/CLI deletes are soft. Real delete = direct DB only. Each
453
- removal/restore writes a deal_events row so the timeline records who
454
- did what when. Trash views via ?include_deleted=1 on read endpoints.
455
-
456
- Brief blocks (text/link/embed/callout):
457
- llama brief blocks <dealId> # list (excludes trashed)
458
- llama brief block <dealId> <blockId> # fetch single block (with body)
459
- llama brief delete <dealId> <blockId> # soft-delete
460
- llama brief restore <dealId> <blockId>
461
-
462
- Deal links (separate from brief link blocks — these live in deal_links):
463
- llama deal link list <dealId> [--include-deleted]
464
- llama deal link add <dealId> --url <url> [--label "..."]
465
- llama deal link delete <dealId> <linkId> # soft-delete
466
- llama deal link restore <dealId> <linkId>
467
-
468
- Ownership:
469
- llama claim <dealId> --reason "..." # set yourself as Owner
470
- llama nominate <dealId> --user <userId> --reason "..." # set another teammate as Owner
471
- Owner is an audited responsibility label, not a permission boundary. Any
472
- internal teammate can change it after Partner Support; reason is mandatory.
473
-
474
- Approvals (Partner queue — contribution decisions):
475
- llama approvals list
476
- llama approvals decide <approvalId> approved|rejected [--note "..."]
477
-
478
- Timeline / Posts:
479
- llama timeline <dealId> # full unified feed
480
- llama post <dealId> "message body" [--link url] [--link-name "name"] [--cue]
481
- # --cue only after explicit user approval
482
-
483
- Brief blocks:
484
- llama brief blocks <dealId> # list current block array
485
- llama brief block <dealId> <blockId> # fetch one block's body (manifest in 'llama deal show')
486
- llama brief add-text <dealId> --heading "..." --body "..." [--cue]
487
- llama brief add-link <dealId> --url "..." --label "..." [--description "..."] [--cue]
488
- llama brief add-embed <dealId> --url "..." [--label "..."] [--cue]
489
- llama brief add-callout <dealId> --tone insight|info|warning|success --heading "..." --body "..." [--cue]
490
- llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--tone ...] [--cue]
491
- [--source-section <key>] [--lock|--unlock] [--hide|--unhide]
492
- llama brief delete <dealId> <blockId>
493
- llama brief history <dealId> <blockId> [--limit 50] # prior versions of this block (newest first)
494
- llama brief restore-version <dealId> <blockId> <historyId> # restore from a history entry; the outgoing
495
- # version is itself snapshotted (reversible)
496
-
497
- Common flags on every add-*:
498
- --source-section <key> Target a structured section (team, highlights, recommendation,
499
- landscape_map, competitors). Without this, blocks land in "_other"
500
- at the bottom of the TOC. AI writers want this.
501
- --reply-to <blockId> Make the block a reply to <blockId>. Snapshots parent's heading
502
- + 200-char excerpt into meta so the back-link survives parent
503
- edits/deletes. Renders as an amber strip with a jump-link.
504
- --position top|bottom Where to insert. Default: top (matches UI behavior since
505
- 2026-05-03). Use bottom for batched writes that need to
506
- preserve insertion order.
507
-
508
- Brief refresh + agent-run revert:
509
- llama deal refresh-brief <dealId> [--force] # re-eval stale sections
510
- # --force = every unlocked watcher-managed section
511
- llama deal revert-run <dealId> <runId> --section <key> # legacy 4-section model only
512
- # section: company|team|highlights|recommendation
513
-
514
- Deal soft-delete / restore / trash list:
515
- llama deal delete <dealId> # soft (audit-logged via deal_events)
516
- llama deal restore <dealId> # ⚠ session-only on server today (token → 401)
517
- llama deal trash # list deleted deals
518
-
519
- Deal facts (AI-extracted or human-asserted, with verification):
520
- llama deal ingest <dealId> --file <packet.json> [--idempotency-key <key>] # atomic facts + optional Feed note
521
- packet: {"source":{"kind":"meeting_note","title":"Office visit"},"facts":[{"category":"team","claim":"..."}],"note":"..."}
522
- categories: company_basics | team | product | market | financials | fundraise | risk | milestone | meta
523
- llama deal fact list <dealId>
524
- llama deal fact add <dealId> --category <cat> --claim "<text>" [--source "..."] [--source-url <url>] [--confidence high|medium|low] [--attested]
525
- llama deal fact verify <dealId> <factId> --status confirmed|disputed [--corrected-value "..."]
526
- llama deal fact uncontest <dealId> <factId> --reason "<why the contest was wrong>"
527
- contest and trust are separate axes: 'verify --status confirmed' does NOT lift a contest,
528
- and a contested fact stays excluded from what the Deal Agent treats as current.
529
-
530
- Mentions / Inbox:
531
- llama mentions # default: my unresolved cues
532
- llama mentions list [--everyone] [--all] # --everyone = team-wide; --all = include resolved
533
- llama mentions show <mentionId> # full row
534
- llama mentions resolve <mentionId> # mark thread resolved (idempotent)
535
- llama mentions unread # just the badge count
536
-
537
- Where does this HTML / thesis / artifact go?
538
- About ONE specific deal? ........ llama html publish <deal-id-or-name> --file <path> --title "..."
539
- (renders at /deals/<id>/browse/<slug>; see "Deal page HTML" below)
540
- Cross-deal / institutional? ..... llama wiki save <slug> --title "..." --file <path>.html --sources "..."
541
- (renders at /wiki/<slug>; see "Wiki" below)
542
- Founder-facing public share? .... Netlify (with netlify-access-guard skill), only when user explicitly
543
- says "share publicly". Llama Command outranks Netlify for everything
544
- internal — don't reach for Netlify by default.
545
-
546
- Wiki:
547
- llama wiki search <query>
548
- llama wiki read <slug>
549
- Markdown entry (default):
550
- llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"]
551
- HTML entry — standalone HTML page at /wiki/<slug> (full-viewport sandboxed iframe):
552
- llama wiki save <slug> --title "..." --file path.html --sources "..." [--content-type html]
553
- (.html / .htm extension auto-implies content_type=html)
554
- Native comments + working in-page (#) links are added automatically — just upload self-contained HTML.
555
- ➜ Use Wiki when the artifact is NOT tied to one specific deal — sector landscape, market map,
556
- thesis, framework, methodology. For deal-specific HTML use "llama html publish <deal>" instead.
557
- Delete / restore (soft — reversible):
558
- llama wiki delete <slug> [--lang en|zh]
559
- llama wiki restore <slug> [--lang en|zh]
560
-
561
- Memo (long-form HTML investment memo — Memo tab in the UI):
562
- llama memo show <dealId> [--out <path>] [--json] # default: html → stdout (pipeable to file / browser)
563
- llama memo regenerate <dealId> [--opus] [--instructions "..."] # --instructions steers THIS run (e.g. "focus on team risk"); progress → stderr
564
- llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
565
- llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
566
-
567
- Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
568
- ➜ Use this for DEAL-SPECIFIC artifacts: IC memo for X, dashboard for X, 2×2 for X.
569
- For cross-deal / institutional pages (sector landscape, market map, thesis) use
570
- "llama wiki save <slug> --file ..." instead — see "Wiki" above.
571
- Each deal can host many HTML artifacts (IC report, dashboard, market map, …).
572
- Each one has a stable slug. UPLOAD must declare intent — update an existing
573
- artifact or add a new one — to avoid silent overwrites.
574
-
575
- Agent-safe publish path (recommended for Claude Code / Codex / Cursor):
576
- llama html publish <deal-id-or-name> --file <path> [--title "..."] [--doc <slug>]
577
- # Defaults to NEW doc unless --doc points at an existing slug; verifies version/bytes/sha256 after upload.
578
- # Auto-detects sibling *_files asset folders unless --no-auto-assets is set.
579
-
580
- List existing artifacts:
581
- llama html docs <dealId> # who-has-what
582
- llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
583
- llama html docs archive <dealId> <slug> # soft-archive (browse hides)
584
-
585
- Link a card to a wiki article (one file, multiple entrances — the wiki
586
- stays canonical, the deal card is a live, read-only pointer):
587
- llama html link <dealId> --wiki <slug> [--lang en|zh] [--title "..."]
588
- llama html unlink <dealId> <slug> # revert to a normal self-hosted doc
589
-
590
- Update an EXISTING artifact (slug must exist):
591
- llama html upload <dealId> --doc <slug> --file <path> [--assets DIR]
592
-
593
- Add a NEW artifact (slug must NOT already exist):
594
- llama html upload <dealId> --new --title "..." --file <path> [--doc <slug>] [--assets DIR]
595
- (omit --doc → CLI slugifies the title; appends -2 / -3 on collision)
596
-
597
- Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'
598
- already has content — pass --doc main or --new --title "..." explicitly.
599
-
600
- llama html show <dealId> [--doc <slug>] [--out <path>] [--json] # default: current html → stdout
601
- llama html versions <dealId> [--doc <slug>] # list version history
602
- llama html restore <dealId> <version> [--doc <slug>] # promote an old version to new latest
603
- llama html reset <dealId> [--doc <slug>] # soft-delete latest; /browse reverts to empty
604
-
605
- Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
606
- triggers SSE push — any browser viewing /deals/<id>/browse refreshes
607
- automatically. Same write path as the in-app deal agent's
608
- update_deal_browse_html tool and the MCP html_upload_file tool.
609
-
610
- Admin (system admin only — server returns 403 for non-admin tokens):
611
- llama admin workflow audit --deal <uuid> | --all
612
- llama admin workflow remediate --deal <uuid> --guard intake.reason_why[,intake.founder_identity]
613
- [--apply --expected-revision <n> --reason "..."]
614
- llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
615
- llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
616
- llama admin agent-events [--kind tool_call|loop_stalled|max_turns_reached] [--agent-kind deal|secretary|main|inbox]
617
- [--actor email] [--tool name] [--deal <uuid>] [--errors-only] [--since 24h] [--limit 100]
618
-
619
- Same data as the /admin web console tabs (Auth events / Deal Activity / Agent Activity)
620
- but scriptable. Pipe through jq / grep for monitoring & forensics.
621
-
622
- Token discovery (in order):
623
- 1. $LLAMA_TOKEN env var
624
- 2. ~/.llama/token (canonical, single line)
625
- 3. ~/.llama-command/config.json (legacy v0.1 — auto-migrated forward on first read)
626
-
627
- Env:
628
- LLAMA_TOKEN token override
629
- LLAMA_API_URL API base URL override
630
- `;
631
-
632
- // ── Progressive help (Constitution §1) ──
633
- // Default `llama` / `llama --help` prints a SHORT root: the command groups +
634
- // a few starters. Drill into one group with `llama help <area>` (or
635
- // `llama <area> --help`); `llama help all` prints the full reference above.
636
- const HELP_ROOT = `Llama Command CLI — the \`llama\` command for the Llama Ventures workbench.
637
-
638
- Common:
639
- llama deal search "<name>" find a deal in the pipeline
640
- llama deal show <dealId> full deal record
641
- llama deal feed <dealId> every contribution (facts + notes), newest first
642
- llama post <dealId> "..." add a note to a deal
643
- llama activity new-deals --since 24h recent deal creations
644
- llama activity updated-deals --since 7d meaningful deal updates
645
- llama agent-onboard print the AI-agent workflow contract
646
- llama agent bootstrap live Llama OS skill manifest from Command
647
- llama skills search "<query>" discover which skill to read
648
- llama explain <url-or-object> explain Command URLs, 404s, deleted objects
649
-
650
- Command groups — run \`llama help <group>\` for that group's commands:
651
- deal create · show · feed · update · enrich · search · collaborators · links · delete
652
- activity new-deals · updated-deals · events for agent read models
653
- brief brief blocks: list · add · edit · history · refresh
654
- facts deal facts — the sourced, trust-rated layer
655
- timeline timeline · posts · mentions
656
- wiki cross-deal knowledge entries (markdown or HTML)
657
- pref standing agent preferences: list · add · retire · approve
658
- memo long-form HTML investment memo
659
- html deal-specific HTML artifacts (/deals/<id>/browse/<slug>)
660
- pitch external founder intake (no token needed)
661
- ownership claim · nominate · approvals
662
- admin audit events (system admin only)
663
- agent bootstrap · skills · explain for AI agents
664
- skills search · show runtime Llama OS skills
665
- auth setup · tokens · auth status
666
-
667
- llama help all the full command reference (everything at once)
668
-
669
- Auth: if you've run \`gcloud auth login\` with your @llamaventures.vc account,
670
- the CLI auto-detects it — no token needed (\`llc_\` tokens are a fallback).`;
671
-
672
- // Area → which top-level sections of HELP_FULL belong to it.
673
- const HELP_AREA_MATCH = {
674
- deal: [
675
- /^Deals/,
676
- /^Collaborators/,
677
- /^Soft-delete/,
678
- /^Deal links/,
679
- /^Deal soft-delete/,
680
- // `llama deal refresh-brief` / `revert-run` live under this heading. Without
681
- // it here the section matched no area and was reachable only via `help all`,
682
- // which read as "the command does not exist".
683
- /^Brief refresh/,
684
- ],
685
- activity: [/^Agent activity/],
686
- brief: [/^Brief blocks/, /^Brief refresh/],
687
- facts: [/^Deal facts/],
688
- timeline: [/^Timeline/, /^Mentions/],
689
- wiki: [/^Wiki/, /^Where does this HTML/],
690
- memo: [/^Memo/],
691
- html: [/^Deal page HTML/],
692
- pitch: [/^External pitch/],
693
- ownership: [/^Ownership/, /^Approvals/],
694
- admin: [/^Admin/],
695
- agent: [/^Agent onboarding/],
696
- skills: [/^Agent onboarding/],
697
- auth: [/^Setup/, /^Zero-config/, /^Token discovery/, /^Env/],
698
- };
699
-
700
- // Slice HELP_FULL into sections: a top-level (non-indented) header line plus
701
- // the indented/blank lines that follow it, until the next header.
702
- function helpSections() {
703
- const out = [];
704
- let cur = null;
705
- for (const line of HELP_FULL.split("\n")) {
706
- if (/^[A-Za-z]/.test(line)) {
707
- cur = { head: line, lines: [line] };
708
- out.push(cur);
709
- } else if (cur) {
710
- cur.lines.push(line);
711
- }
712
- }
713
- return out;
714
- }
715
-
716
- function usage(area) {
717
- if (area === "all") {
718
- console.log(HELP_FULL);
719
- return;
720
- }
721
- const matchers = area && HELP_AREA_MATCH[area];
722
- if (matchers) {
723
- const blocks = helpSections()
724
- .filter((s) => matchers.some((re) => re.test(s.head)))
725
- .map((s) => s.lines.join("\n").replace(/\s+$/, ""));
726
- if (blocks.length) {
727
- console.log(blocks.join("\n\n"));
728
- return;
729
- }
730
- }
731
- console.log(HELP_ROOT);
176
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
177
+ else value += char;
178
+ }
179
+ };
180
+ process.stdin.setRawMode(true);
181
+ process.stdin.resume();
182
+ process.stdin.setEncoding("utf8");
183
+ process.stdin.on("data", onData);
184
+ });
732
185
  }
733
186
 
734
- // ============================================================
735
- // `llama pitch` family — external founder-pitch intake
736
- // ============================================================
737
- //
738
- // No Llama Command token required. Bootstraps a session against
739
- // /api/external/* via PoW + cookie. Subcommands:
740
- //
741
- // llama pitch → REPL (requires existing session)
742
- // llama pitch start --name X --email Y
743
- // llama pitch say "<msg>"
744
- // llama pitch upload <path>
745
- // llama pitch status
746
- // llama pitch end
747
-
748
187
  async function handlePitch(action, rest) {
749
- if (!action || action === "help" || action === "--help" || action === "-h") {
750
- console.log(`Llama Ventures pitch intake — chat with our intake agent (no token required).
751
-
752
- Setup:
753
- llama pitch start --name "Your Name" --email "you@company.com"
754
-
755
- Single message (non-interactive):
756
- llama pitch say 'We have $8k MRR and 5 design partners'
757
-
758
- ⚠ Tip: wrap pitch text in SINGLE quotes ('...') if it contains
759
- characters like $, \`, or !. Double quotes let the shell expand
760
- variables — e.g. "$8k MRR" becomes "k MRR" because $8 is empty.
761
- Interactive REPL (\`llama pitch\`) doesn't have this problem.
762
-
763
- Upload a file (deck / pitch / one-pager):
188
+ if (!action || ["help", "--help", "-h"].includes(action)) {
189
+ console.log(`External founder pitch intake (no internal token required):
190
+ llama pitch start --name "Jane Doe" --email "jane@acme.ai"
191
+ llama pitch say 'We are building X'
764
192
  llama pitch upload ./deck.pdf
765
-
766
- Interactive REPL (requires existing session):
767
- llama pitch
768
-
769
- Wrap up the pitch (asks the agent to call finalize_intake immediately):
770
- llama pitch finalize # use when you're done — agent stops asking
771
-
772
- Inspect / clean up:
773
- llama pitch status # session id, idle minutes, finalized?
774
- llama pitch end # clear local session state
775
-
776
- Caps:
777
- Server-enforced per-IP / per-email / per-session rate limits apply.
778
- The CLI surfaces server messages if a limit is hit.
779
-
780
- Environment:
781
- LLAMA_API_URL override base URL (dev: http://localhost:3000)
782
- `);
193
+ llama pitch finalize
194
+ llama pitch status
195
+ llama pitch end
196
+ llama pitch interactive session`);
783
197
  return;
784
198
  }
785
-
786
199
  if (action === "start") {
787
- const { flags } = parseFlags(rest);
788
- if (!flags.name || !flags.email) {
789
- throw new Error(
790
- "pitch start: --name and --email are required.\n" +
791
- " Example: llama pitch start --name \"Jane Doe\" --email \"jane@acme.ai\""
792
- );
200
+ const { flags } = parseFlags(rest, ["name", "email"]);
201
+ if (typeof flags.name !== "string" || typeof flags.email !== "string") {
202
+ throw new Error('Usage: llama pitch start --name "Jane Doe" --email "jane@acme.ai"');
793
203
  }
794
204
  const existing = readExternalSession();
795
- if (existing && !existing.finalized) {
796
- const status = getExternalSessionStatus();
797
- if (status.active) {
798
- throw new Error(
799
- `An active pitch session already exists (started ${existing.started_at}, idle ${status.idle_minutes}min).\n` +
800
- ` Run \`llama pitch end\` to clear it, or \`llama pitch say "..."\` to continue.`
801
- );
802
- }
205
+ const status = existing ? getExternalSessionStatus() : null;
206
+ if (existing && !existing.finalized && status?.active) {
207
+ throw new Error("An active pitch session already exists. Continue it or run `llama pitch end`.");
803
208
  }
804
- process.stderr.write("Computing proof-of-work + opening session...\n");
805
- const session = await startExternalSession({
806
- name: String(flags.name),
807
- email: String(flags.email),
808
- });
809
- print({
810
- session_id: session.session_id,
811
- name: session.name,
812
- email: session.email,
813
- started_at: session.started_at,
814
- hint: 'Now run `llama pitch say "..."` to chat, or just `llama pitch` for interactive REPL.',
815
- });
209
+ print(await startExternalSession({ name: flags.name, email: flags.email }));
816
210
  return;
817
211
  }
818
-
819
212
  if (action === "say") {
820
213
  const message = rest.join(" ").trim();
821
- if (!message) {
822
- throw new Error('pitch say: message required. Example: llama pitch say "We\'re building X"');
823
- }
214
+ if (!message) throw new Error("Usage: llama pitch say <message>");
824
215
  const result = await sendExternalMessage(message);
825
- process.stdout.write(result.text + "\n");
826
- if (result.finalized) {
827
- process.stderr.write("\n--- Pitch session finalized by the agent ---\n");
828
- if (result.finalize_payload) {
829
- process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
830
- }
831
- }
216
+ process.stdout.write(`${result.text}\n`);
832
217
  return;
833
218
  }
834
-
835
219
  if (action === "upload") {
836
- const { flags, positional } = parseFlags(rest);
837
- const filePath = positional[0];
838
- if (!filePath) {
839
- throw new Error("pitch upload: file path required. Example: llama pitch upload ./deck.pdf");
840
- }
841
- process.stderr.write(`Uploading ${filePath}...\n`);
842
- const result = await uploadExternalFile(filePath);
843
- if (flags.json) {
844
- print(result);
845
- } else {
846
- // Friendly default — drop server-internal fields (drive_file_id /
847
- // sha256 / file_id). Founders just want "did it work + what does
848
- // the agent do next." Pass --json for the full payload.
849
- const sizeKb = (result.size / 1024).toFixed(1);
850
- console.log(`✓ Uploaded ${result.filename} (${sizeKb} KB).`);
851
- console.log(` The intake agent can now reference this file in your pitch.`);
852
- }
220
+ const { flags, positional } = parseFlags(rest, ["json"]);
221
+ if (!positional[0]) throw new Error("Usage: llama pitch upload <file>");
222
+ const result = await uploadExternalFile(positional[0]);
223
+ if (flags.json) print(result);
224
+ else console.log(`Uploaded ${result.filename} (${(result.size / 1024).toFixed(1)} KB).`);
853
225
  return;
854
226
  }
855
-
856
227
  if (action === "status") {
857
228
  print(getExternalSessionStatus());
858
229
  return;
859
230
  }
860
-
861
231
  if (action === "end") {
862
- const had = readExternalSession();
232
+ const previous = readExternalSession();
863
233
  clearExternalSession();
864
- print({
865
- ok: true,
866
- cleared: !!had,
867
- session_file: EXTERNAL_SESSION_FILE,
868
- note: had
869
- ? "Local session state cleared. Server-side session may still be active until idle timeout."
870
- : "No local session was active.",
871
- });
234
+ print({ ok: true, cleared: Boolean(previous), session_file: EXTERNAL_SESSION_FILE });
872
235
  return;
873
236
  }
874
-
875
237
  if (action === "finalize") {
876
- // Founder-initiated finalize: send a sentinel token in the chat
877
- // stream that the system prompt recognizes as "wrap up now." The
878
- // intake agent calls finalize_intake on this turn with whatever
879
- // fields are recorded — no extra questions, no confirmation prompt.
880
- // Local session is left as-is; on next read its `finalized=true`
881
- // reflects the server's status.
882
238
  const session = readExternalSession();
883
- if (!session) {
884
- throw new Error(
885
- "No active pitch session. Run `llama pitch start --name \"...\" --email \"...\"` first."
886
- );
887
- }
888
- if (session.finalized) {
889
- throw new Error(
890
- "This pitch session is already finalized. Run `llama pitch end` to clear local state."
891
- );
892
- }
893
- process.stderr.write("Asking the agent to wrap up...\n");
239
+ if (!session || session.finalized) throw new Error("No active unfinalized pitch session.");
894
240
  const result = await sendExternalMessage("[FOUNDER_FINALIZE_REQUEST]");
895
- process.stdout.write(result.text + "\n");
896
- if (result.finalized) {
897
- process.stderr.write("\n--- Pitch session finalized ---\n");
898
- if (result.finalize_payload) {
899
- process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
900
- }
901
- } else {
902
- process.stderr.write(
903
- "\n⚠ Agent did not call finalize_intake on this turn. " +
904
- "Try `llama pitch finalize` once more, or `llama pitch end` to abandon.\n"
905
- );
906
- }
241
+ process.stdout.write(`${result.text}\n`);
907
242
  return;
908
243
  }
909
-
910
- // No action → REPL mode (requires existing session)
911
- if (action === undefined || (rest.length === 0 && !["start", "say", "upload", "status", "end", "finalize"].includes(action))) {
912
- // Treat any unknown bare action as "join existing session in REPL mode"
913
- const session = readExternalSession();
914
- if (!session) {
915
- throw new Error(
916
- "No active pitch session. Start one with:\n" +
917
- ' llama pitch start --name "Your Name" --email "you@company.com"'
918
- );
919
- }
920
- if (session.finalized) {
921
- throw new Error(
922
- "This pitch session is finalized. Run `llama pitch end` then `pitch start` for a new one."
923
- );
924
- }
244
+ if (action === "repl") {
925
245
  await runPitchRepl();
926
246
  return;
927
247
  }
928
-
929
- throw new Error(`Unknown pitch subcommand: ${action}. Run \`llama pitch help\` for the full list.`);
248
+ throw new Error(`Unknown pitch subcommand: ${action}`);
930
249
  }
931
250
 
932
251
  async function runPitchRepl() {
933
- const rl = readline.createInterface({
934
- input: process.stdin,
935
- output: process.stdout,
936
- prompt: "you> ",
937
- });
938
-
939
- console.log("Connected to Llama Ventures intake agent. Type your pitch — :q to exit, :upload <path> to attach a file.");
940
- console.log("");
941
-
942
- const send = async (msg) => {
943
- process.stdout.write("\nllama> ");
944
- let buffered = "";
945
- const result = await sendExternalMessage(msg, {
946
- onChunk: (chunk) => {
947
- process.stdout.write(chunk);
948
- buffered += chunk;
949
- },
950
- });
951
- if (!buffered) process.stdout.write(result.text);
952
- process.stdout.write("\n\n");
953
- if (result.finalized) {
954
- console.log("--- Pitch session finalized ---");
955
- if (result.finalize_payload) {
956
- console.log(JSON.stringify(result.finalize_payload, null, 2));
252
+ const session = readExternalSession();
253
+ if (!session || session.finalized) throw new Error("Start an active pitch session first.");
254
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
255
+ console.log("Connected to Llama Ventures intake agent. :q exits; :upload <path> attaches a file.");
256
+ rl.prompt();
257
+ rl.on("line", async (line) => {
258
+ const input = line.trim();
259
+ if ([":q", ":quit", ":exit"].includes(input)) return rl.close();
260
+ try {
261
+ if (input.startsWith(":upload ")) {
262
+ const result = await uploadExternalFile(input.slice(8).trim());
263
+ console.log(`uploaded: ${result.filename}`);
264
+ } else if (input) {
265
+ const result = await sendExternalMessage(input);
266
+ console.log(`llama> ${result.text}`);
267
+ if (result.finalized) return rl.close();
957
268
  }
958
- rl.close();
959
- return true;
269
+ } catch (error) {
270
+ console.error(`error: ${error.message}`);
960
271
  }
961
- return false;
962
- };
272
+ rl.prompt();
273
+ });
274
+ await new Promise((resolve) => rl.on("close", resolve));
275
+ }
963
276
 
964
- rl.prompt();
965
- rl.on("line", async (line) => {
966
- const trimmed = line.trim();
967
- if (trimmed === ":q" || trimmed === ":quit" || trimmed === ":exit") {
968
- rl.close();
969
- return;
277
+ async function handleAuth(area, action, rest) {
278
+ if (area === "token" && action === "set") {
279
+ const { flags, positional } = parseFlags(rest, ["base", "skip-verify"]);
280
+ const token = positional[0] ?? await readTokenQuietly();
281
+ if (!/^llc_[0-9a-f]{32}$/i.test(token)) throw new Error("Expected a full llc_ token (llc_ + 32 hex chars).");
282
+ if (flags.base && flags.base !== true) {
283
+ writeLegacyConfig({ ...readLegacyConfig(), baseUrl: String(flags.base).replace(/\/$/, "") });
284
+ }
285
+ if (!flags["skip-verify"]) {
286
+ const response = await fetch(`${getBaseUrl()}/api/me`, { headers: { "X-Llama-Token": token } });
287
+ if (!response.ok) throw new Error(`Server rejected token (HTTP ${response.status}); not saved.`);
970
288
  }
971
- if (trimmed.startsWith(":upload ")) {
972
- const filePath = trimmed.slice(8).trim();
289
+ writeCanonicalToken(token);
290
+ console.log("Saved token to ~/.llama/token (mode 0600).");
291
+ return true;
292
+ }
293
+ if (area === "token" && action === "show") {
294
+ const token = getToken();
295
+ console.log(token ? `${token.slice(0, 8)}...${token.slice(-4)} @ ${getBaseUrl()}` : "No token set.");
296
+ return true;
297
+ }
298
+ if (area === "auth" && action === "status") {
299
+ const [oauth, bearer] = await Promise.all([readBundle(), tryGcloudIdentityToken()]);
300
+ const token = getToken();
301
+ let serverCheck = "skipped (no credentials)";
302
+ if (oauth?.access_token || bearer || token) {
973
303
  try {
974
- process.stdout.write("uploading...\n");
975
- const result = await uploadExternalFile(filePath);
976
- console.log(`uploaded: ${result.filename} (${result.drive_file_id})`);
977
- } catch (err) {
978
- console.error("upload error:", err.message);
304
+ const me = await request("GET", "/api/me");
305
+ serverCheck = `ok — ${me?.email ?? "unknown"} (${me?.role ?? "unknown"})`;
306
+ } catch (error) {
307
+ serverCheck = `failed — ${error.message.split("\n")[0]}`;
979
308
  }
980
- rl.prompt();
981
- return;
982
- }
983
- if (!trimmed) {
984
- rl.prompt();
985
- return;
986
309
  }
987
- try {
988
- const finalized = await send(trimmed);
989
- if (finalized) return;
990
- } catch (err) {
991
- console.error("error:", err.message);
310
+ const oauthBackend = oauth ? await detectBackend() : null;
311
+ print({
312
+ baseUrl: getBaseUrl(),
313
+ activeMethod: oauth?.access_token ? "oauth" : bearer ? "gcloud-bearer" : token ? "llama-token" : "none",
314
+ oauth: oauth ? { storage: oauthBackend, scope: oauth.scope } : "absent",
315
+ gcloudIdentityToken: bearer ? "present" : "absent",
316
+ llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
317
+ llamaTokenSource: process.env.LLAMA_TOKEN ? "$LLAMA_TOKEN" : readCanonicalToken() ? "~/.llama/token" : null,
318
+ serverCheck,
319
+ });
320
+ return true;
321
+ }
322
+ if (area === "auth" && action === "login") {
323
+ const { flags } = parseFlags(rest, ["scope"]);
324
+ const scope = typeof flags.scope === "string" ? flags.scope : "read write";
325
+ const baseUrl = getBaseUrl();
326
+ const bundle = await pkceLoopbackFlow({ baseUrl, scope, resource: baseUrl });
327
+ const stored = await writeBundle({
328
+ access_token: bundle.access_token,
329
+ refresh_token: bundle.refresh_token,
330
+ expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
331
+ scope: bundle.scope,
332
+ client_id: bundle.client_id,
333
+ issuer: bundle.issuer,
334
+ resource: bundle.resource,
335
+ created_at: Date.now(),
336
+ });
337
+ print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: stored.backend, scope: bundle.scope });
338
+ return true;
339
+ }
340
+ if (area === "auth" && action === "logout") {
341
+ const bundle = await readBundle();
342
+ let revoked = false;
343
+ if (bundle) {
344
+ try {
345
+ revoked = await revokeOAuthToken({
346
+ baseUrl: bundle.issuer ?? getBaseUrl(),
347
+ token: bundle.refresh_token,
348
+ tokenTypeHint: "refresh_token",
349
+ });
350
+ } catch {
351
+ revoked = false;
352
+ }
353
+ await deleteBundle();
992
354
  }
993
- rl.prompt();
994
- });
995
-
996
- await new Promise((resolve) => rl.on("close", resolve));
355
+ print({ ok: true, message: "Local OAuth credentials cleared", serverRevoke: revoked });
356
+ return true;
357
+ }
358
+ return false;
997
359
  }
998
360
 
999
- async function main() {
1000
- const [area, action, ...rest] = process.argv.slice(2);
1001
- if (area === "--version" || area === "-v" || area === "version") {
1002
- if (action === "--json" || action === "json") {
1003
- print(getBuildInfo());
1004
- return;
361
+ async function handleWiki(action, rest) {
362
+ const { flags, positional } = parseFlags(rest);
363
+ const slug = positional[0];
364
+ if (action === "search") {
365
+ const q = positional.join(" ").trim();
366
+ if (!q) throw new Error("Usage: llama wiki search <query>");
367
+ print(await request("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`));
368
+ return;
369
+ }
370
+ if (action === "read") {
371
+ if (!slug) throw new Error("Usage: llama wiki read <slug> [--lang en|zh]");
372
+ // @core-api-operation GET /api/wiki/{slug}
373
+ print(await request("GET", `/api/wiki/${encodeURIComponent(slug)}?lang=${flags.lang === "zh" ? "zh" : "en"}`));
374
+ return;
375
+ }
376
+ if (action === "save") {
377
+ if (!slug || typeof flags.title !== "string" || typeof flags.sources !== "string") {
378
+ throw new Error('Usage: llama wiki save <slug> --title "..." --content "..."|--file <path> --sources "url1;url2"');
1005
379
  }
1006
- // `llama version --check` explicitly check npm for a newer release and
1007
- // print the upgrade line (or "up to date"). Lets an agent surface the
1008
- // nudge on demand, separate from the throttled, TTY-gated auto-nudge.
1009
- if (action === "--check" || action === "check") {
1010
- const nudge = await getUpdateNudge();
1011
- console.log(nudge || `llama CLI ${PKG_VERSION} — up to date`);
1012
- return;
380
+ if (flags.content && flags.file) throw new Error("Use either --content or --file, not both.");
381
+ const content = flags.file ? await readFile(String(flags.file), "utf8") : flags.content;
382
+ if (typeof content !== "string") throw new Error("Wiki save requires --content or --file.");
383
+ const inferred = typeof flags.file === "string" && /\.html?$/i.test(flags.file) ? "html" : "markdown";
384
+ print(await request("POST", "/api/wiki/save", {
385
+ slug,
386
+ title: flags.title,
387
+ content,
388
+ sources: flags.sources.split(/[;|]/).map((value) => value.trim()).filter(Boolean),
389
+ type: flags.type,
390
+ related: typeof flags.related === "string" ? flags.related.split(/[;|]/).map((value) => value.trim()).filter(Boolean) : undefined,
391
+ lang: flags.lang === "zh" ? "zh" : "en",
392
+ content_type: typeof flags["content-type"] === "string" ? flags["content-type"] : inferred,
393
+ }));
394
+ return;
395
+ }
396
+ if (["delete", "restore"].includes(action)) {
397
+ if (!slug) throw new Error(`Usage: llama wiki ${action} <slug> [--lang en|zh]`);
398
+ const lang = flags.lang === "zh" ? "zh" : "en";
399
+ if (action === "delete") {
400
+ // @core-api-operation DELETE /api/wiki/{slug}
401
+ print(await request("DELETE", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang}`));
402
+ } else {
403
+ // @core-api-operation POST /api/wiki/{slug}/restore
404
+ print(await request("POST", `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang}`));
1013
405
  }
1014
- console.log(PKG_VERSION);
1015
406
  return;
1016
407
  }
1017
- if (!area || area === "help" || area === "--help" || area === "-h") {
1018
- usage(area === "help" ? action : undefined);
408
+ throw new Error("Wiki actions: search, read, save, delete, restore.");
409
+ }
410
+
411
+ async function main() {
412
+ const [area, action, ...rest] = process.argv.slice(2);
413
+
414
+ if (["--version", "-v", "version"].includes(area)) {
415
+ if (["--json", "json"].includes(action)) print(getBuildInfo());
416
+ else if (["--check", "check"].includes(action)) console.log(await getUpdateNudge() || `llama CLI ${PKG_VERSION} — up to date`);
417
+ else console.log(PKG_VERSION);
1019
418
  return;
1020
419
  }
1021
- // `llama <area> --help` / `-h` → just that group's commands
1022
- if (action === "--help" || action === "-h") {
1023
- usage(area);
420
+ if (!area || ["help", "--help", "-h"].includes(area)) {
421
+ usage(area === "help" ? action : undefined);
1024
422
  return;
1025
423
  }
1026
- // `llama <area> <action> --help` (e.g. `brief add-text --help`). Without this
1027
- // short-circuit, "--help" falls through to the action handler, where rest[0]
1028
- // can be read as a positional (e.g. dealId="--help") and trigger a REAL write.
1029
- // Catch --help/-h anywhere in the sub-command args and print group help first.
1030
- if (rest.includes("--help") || rest.includes("-h")) {
424
+ if (["--help", "-h"].includes(action) || rest.some((value) => ["--help", "-h"].includes(value))) {
1031
425
  usage(area);
1032
426
  return;
1033
427
  }
1034
428
 
1035
- // `llama agent-onboard` — fetch the server-owned Agent Runtime Contract
1036
- // so an AI agent reads the current Llama Ventures workflow contract. The
1037
- // bundled AGENT_BRIEFING.md is now only a fallback when the server route
1038
- // is unavailable during rollout.
1039
- // Also: `llama agent onboard` (two-word form) for symmetry.
1040
- //
1041
- // Gated behind Command auth — without valid credentials we print a short
1042
- // bootstrap stub instead. Stops unauthenticated callers from harvesting
1043
- // internal command surface / workflow conventions just by running the
1044
- // public CLI.
1045
- if (
1046
- area === "agent-onboard" ||
1047
- (area === "agent" && (action === "onboard" || action === "briefing"))
1048
- ) {
429
+ assertActiveSurface(area, action);
430
+
431
+ if (await handleAuth(area, action, rest)) return;
432
+
433
+ if (area === "agent-onboard" || (area === "agent" && ["onboard", "briefing"].includes(action))) {
1049
434
  const headers = await getAuthHeaders();
1050
- if (Object.keys(headers).length === 0) {
1051
- console.log(agentOnboardNoAuthMessage());
435
+ if (!Object.keys(headers).length) {
436
+ console.log(onboardingNoAuth());
1052
437
  return;
1053
438
  }
1054
439
  try {
1055
- const briefing = await fetchServerAgentBriefing();
1056
- process.stdout.write(briefing || readBriefing());
1057
- } catch (e) {
1058
- const msg = e?.message || "";
1059
- if (msg.includes("Error[UNAUTHORIZED]") || msg.includes("Error[NO_AUTH]")) {
1060
- console.log(agentOnboardRejectedMessage());
1061
- process.exitCode = 1;
1062
- return;
1063
- }
1064
- process.stderr.write(
1065
- `warning: server agent briefing unavailable (${msg}); using bundled fallback.\n`,
1066
- );
440
+ const params = new URLSearchParams({ clientVersion: PKG_VERSION });
441
+ const response = await request("GET", `/api/agent/briefing?${params}`);
442
+ process.stdout.write(response?.briefing || readBriefing());
443
+ } catch (error) {
444
+ process.stderr.write(`warning: live briefing unavailable (${error.message}); using bundled fallback.\n`);
1067
445
  process.stdout.write(readBriefing());
1068
446
  }
1069
447
  return;
1070
448
  }
1071
449
 
1072
- // Live runtime bootstrap from Llama Command. Unlike agent-onboard, this is
1073
- // not bundled in the public npm package; Command returns the current
1074
- // authenticated skill manifest and object-inspection contract.
1075
450
  if (area === "agent" && action === "bootstrap") {
1076
451
  const { flags } = parseFlags(rest, ["json", "limit"]);
1077
- const params = new URLSearchParams();
1078
- params.set("clientVersion", PKG_VERSION);
452
+ const params = new URLSearchParams({ clientVersion: PKG_VERSION });
1079
453
  if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
1080
- const manifest = await request("GET", `/api/agent/manifest${params.toString() ? `?${params}` : ""}`);
1081
- if (flags.json) {
1082
- print(manifest);
1083
- } else {
1084
- process.stdout.write(`${manifest.briefing || JSON.stringify(manifest, null, 2)}\n`);
1085
- }
1086
- return;
1087
- }
1088
-
1089
- if (area === "activity") {
1090
- const sub = action || "events";
1091
- const normalized = sub.replace(/-/g, "_");
1092
- const kind =
1093
- normalized === "new" || normalized === "new_deal" || normalized === "new_deals"
1094
- ? "new_deals"
1095
- : normalized === "updated" || normalized === "updates" || normalized === "updated_deal" || normalized === "updated_deals"
1096
- ? "updated_deals"
1097
- : normalized === "event" || normalized === "events" || normalized === "feed" || normalized === "all"
1098
- ? "events"
1099
- : null;
1100
- if (!kind) {
1101
- throw new Error("Usage: llama activity new-deals|updated-deals|events [--since 24h] [--limit 50]");
1102
- }
1103
- const { flags } = parseFlags(rest, [
1104
- "json",
1105
- "since",
1106
- "limit",
1107
- "cursor",
1108
- "before-id",
1109
- "before_id",
1110
- "deal",
1111
- "deal-id",
1112
- "deal_id",
1113
- "entity",
1114
- "verb",
1115
- "min-sig",
1116
- "min_sig",
1117
- "min-significance",
1118
- ]);
1119
- print(await fetchActivity(kind, flags));
454
+ const manifest = await request("GET", `/api/agent/manifest?${params}`);
455
+ if (flags.json) print(manifest);
456
+ else process.stdout.write(`${manifest.briefing || JSON.stringify(manifest, null, 2)}\n`);
1120
457
  return;
1121
458
  }
1122
459
 
1123
460
  if (area === "skills" || (area === "agent" && action === "skills")) {
1124
461
  const sub = area === "skills" ? action : rest[0];
1125
462
  const args = area === "skills" ? rest : rest.slice(1);
463
+ const { flags, positional } = parseFlags(args, ["json", "limit"]);
1126
464
  if (!sub || sub === "list") {
1127
- const { flags } = parseFlags(args, ["json", "limit"]);
1128
465
  const params = new URLSearchParams();
1129
466
  if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
1130
- const result = await request("GET", `/api/agent/skills${params.toString() ? `?${params}` : ""}`);
1131
- print(result);
467
+ print(await request("GET", `/api/agent/skills${params.size ? `?${params}` : ""}`));
1132
468
  return;
1133
469
  }
1134
470
  if (sub === "search") {
1135
- const { flags, positional } = parseFlags(args, ["json", "limit"]);
1136
471
  const q = positional.join(" ").trim();
1137
- if (!q) throw new Error("Usage: llama skills search <query> [--limit 20]");
472
+ if (!q) throw new Error("Usage: llama skills search <query>");
1138
473
  const params = new URLSearchParams({ q });
1139
474
  if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
1140
- const result = await request("GET", `/api/agent/skills?${params}`);
1141
- print(result);
475
+ print(await request("GET", `/api/agent/skills?${params}`));
1142
476
  return;
1143
477
  }
1144
- if (sub === "show" || sub === "read") {
1145
- const { flags, positional } = parseFlags(args, ["json"]);
1146
- const slug = positional[0];
1147
- if (!slug) throw new Error("Usage: llama skills show <slug> [--json]");
1148
- const result = await request("GET", `/api/agent/skills/${encodeURIComponent(slug)}`);
1149
- if (flags.json) {
1150
- print(result);
1151
- } else {
1152
- process.stdout.write(`${result.skill?.content || JSON.stringify(result, null, 2)}\n`);
1153
- }
478
+ if (["show", "read"].includes(sub)) {
479
+ if (!positional[0]) throw new Error("Usage: llama skills show <slug>");
480
+ const result = await request("GET", `/api/agent/skills/${encodeURIComponent(positional[0])}`);
481
+ if (flags.json) print(result);
482
+ else process.stdout.write(`${result.skill?.content || JSON.stringify(result, null, 2)}\n`);
1154
483
  return;
1155
484
  }
1156
- throw new Error(`Unknown skills subcommand "${sub}". Use: list / search / show.`);
485
+ throw new Error("Skills actions: list, search, show.");
1157
486
  }
1158
487
 
1159
- // `llama pref ...` — standing agent preferences (learning-domain v1).
1160
- // Own user scope activates immediately; team scope needs a system admin.
1161
- if (area === "pref" || area === "prefs" || area === "preferences") {
1162
- const sub = action;
1163
- if (!sub || sub === "list") {
1164
- const { flags } = parseFlags(rest, ["json", "status"]);
488
+ if (["pref", "prefs", "preferences"].includes(area)) {
489
+ if (!action || action === "list") {
490
+ const { flags } = parseFlags(rest, ["status"]);
1165
491
  const params = new URLSearchParams();
1166
492
  if (flags.status && flags.status !== true) params.set("status", String(flags.status));
1167
- const result = await request("GET", `/api/agent/preferences${params.toString() ? `?${params}` : ""}`);
1168
- print(result);
493
+ print(await request("GET", `/api/agent/preferences${params.size ? `?${params}` : ""}`));
1169
494
  return;
1170
495
  }
1171
- if (sub === "add") {
1172
- const { flags, positional } = parseFlags(rest, ["team", "evidence", "json"]);
1173
- const key = positional[0];
1174
- const content = positional.slice(1).join(" ").trim();
1175
- if (!key || !content) {
1176
- throw new Error('Usage: llama pref add <key> "<content, max 280 chars>" [--team] [--evidence "..."]');
1177
- }
1178
- const result = await request("POST", "/api/agent/preferences", {
496
+ if (action === "add") {
497
+ const { flags, positional } = parseFlags(rest, ["team", "evidence"]);
498
+ const [key, ...contentParts] = positional;
499
+ const content = contentParts.join(" ").trim();
500
+ if (!key || !content) throw new Error('Usage: llama pref add <key> "<content>" [--team]');
501
+ print(await request("POST", "/api/agent/preferences", {
1179
502
  scope: flags.team ? "team" : "user",
1180
503
  key,
1181
504
  content,
1182
- evidence: flags.evidence && flags.evidence !== true ? String(flags.evidence) : undefined,
1183
- });
1184
- print(result);
505
+ evidence: flags.evidence === true ? undefined : flags.evidence,
506
+ }));
1185
507
  return;
1186
508
  }
1187
- if (sub === "retire" || sub === "approve") {
1188
- const { positional } = parseFlags(rest, ["json"]);
1189
- const id = Number(positional[0]);
1190
- if (!Number.isInteger(id) || id <= 0) {
1191
- throw new Error(`Usage: llama pref ${sub} <id>`);
1192
- }
1193
- const result = await request("PATCH", `/api/agent/preferences/${id}`, {
1194
- status: sub === "approve" ? "active" : "retired",
1195
- });
1196
- print(result);
509
+ if (["approve", "retire"].includes(action)) {
510
+ const id = Number(rest[0]);
511
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`Usage: llama pref ${action} <id>`);
512
+ print(await request("PATCH", `/api/agent/preferences/${id}`, { status: action === "approve" ? "active" : "retired" }));
1197
513
  return;
1198
514
  }
1199
- throw new Error(`Unknown pref subcommand "${sub}". Use: list / add / retire / approve.`);
515
+ throw new Error("Preference actions: list, add, approve, retire.");
1200
516
  }
1201
517
 
1202
518
  if (area === "explain" || (area === "agent" && action === "explain")) {
1203
519
  const args = area === "explain" ? [action, ...rest].filter(Boolean) : rest;
1204
520
  const { flags, positional } = parseFlags(args, ["json", "type", "id", "lang"]);
1205
521
  const params = new URLSearchParams();
1206
- const q = positional.join(" ").trim();
1207
- if (q) params.set("q", q);
522
+ if (positional.length) params.set("q", positional.join(" "));
1208
523
  if (flags.type && flags.type !== true) params.set("type", String(flags.type));
1209
524
  if (flags.id && flags.id !== true) params.set("id", String(flags.id));
1210
525
  if (flags.lang === "zh") params.set("lang", "zh");
1211
- if (!params.has("q") && !(params.has("type") && params.has("id"))) {
1212
- throw new Error("Usage: llama explain <url-or-object> OR llama explain --type <type> --id <id>");
1213
- }
1214
- const result = await request("GET", `/api/agent/explain?${params}`);
1215
- if (flags.json) {
1216
- print(result);
1217
- } else {
1218
- const target = result.result?.target;
1219
- const lifecycle = result.result?.lifecycle || [];
1220
- const lines = [
1221
- `${target?.objectType || "object"} ${target?.objectId || ""}`,
1222
- `Status: ${target?.status || "unknown"}`,
1223
- `Title: ${target?.title || "Untitled"}`,
1224
- target?.detail ? `Detail: ${target.detail}` : null,
1225
- target?.url ? `URL: ${target.url}` : null,
1226
- `Lifecycle events: ${lifecycle.length}`,
1227
- ].filter(Boolean);
1228
- if (lifecycle[0]) {
1229
- lines.push(
1230
- `Latest lifecycle: ${lifecycle[0].action} by ${lifecycle[0].actor_label || "unknown"} at ${lifecycle[0].created_at}`,
1231
- );
1232
- if (lifecycle[0].reason) lines.push(`Reason: ${lifecycle[0].reason}`);
1233
- }
1234
- process.stdout.write(`${lines.join("\n")}\n`);
1235
- }
526
+ if (!params.has("q") && !(params.has("type") && params.has("id"))) throw new Error("Usage: llama explain <url-or-object>");
527
+ print(await request("GET", `/api/agent/explain?${params}`));
1236
528
  return;
1237
529
  }
1238
530
 
1239
- // `llama pitch ...` — external founder-pitch family. No Llama token
1240
- // required; bootstraps a session against /api/external/* via PoW + cookie.
1241
- // See lib/external.mjs and AGENT_BRIEFING.md for the full surface.
1242
531
  if (area === "pitch") {
1243
- await handlePitch(action, rest);
1244
- return;
1245
- }
1246
-
1247
- if (area === "token" && action === "set") {
1248
- const { flags, positional } = parseFlags(rest);
1249
- const token = positional[0];
1250
- if (!token?.startsWith("llc_")) throw new Error("Expected a token starting with llc_");
1251
- if (token.length !== 36) {
1252
- throw new Error(
1253
- `Token has length ${token.length}; expected 36 (llc_ + 32 hex chars).\n` +
1254
- ` This usually means you copied the masked preview ("llc_xxxx…yyyy") from\n` +
1255
- ` the token list instead of the full string from the mint response.\n` +
1256
- ` Re-mint at https://command.llamaventures.vc/settings/tokens and use the\n` +
1257
- ` Copy button — it captures the full value.`
1258
- );
1259
- }
1260
- if (flags.base) {
1261
- // baseUrl still lives in legacy config — rarely overridden. Keep the
1262
- // file there so we don't introduce a second config surface.
1263
- const legacy = readLegacyConfig();
1264
- legacy.baseUrl = String(flags.base).replace(/\/$/, "");
1265
- writeLegacyConfig(legacy);
1266
- }
1267
- // Round-trip the token against /api/me before persisting. Catches the
1268
- // pasted-preview / wrong-token / wrong-host cases at "set" time instead
1269
- // of letting them fester until the next CLI call (or worse, a CI run).
1270
- // --skip-verify is an escape hatch for offline / pre-deploy testing.
1271
- if (!flags["skip-verify"]) {
1272
- try {
1273
- const res = await fetch(`${getBaseUrl()}/api/me`, {
1274
- headers: { "X-Llama-Token": token },
1275
- });
1276
- if (res.status === 401 || res.status === 403) {
1277
- const body = await res.text();
1278
- throw new Error(
1279
- `Server rejected this token (HTTP ${res.status}). Not saving.\n` +
1280
- ` Response: ${body.slice(0, 200)}\n` +
1281
- ` Base URL: ${getBaseUrl()}\n` +
1282
- ` Re-check that you copied the full token from the mint dialog\n` +
1283
- ` ("Shown once") and not the masked preview from the list view.\n` +
1284
- ` Override with --skip-verify if you know the server is unreachable.`
1285
- );
1286
- }
1287
- if (!res.ok) {
1288
- throw new Error(`Verify call failed: HTTP ${res.status}. Not saving.`);
1289
- }
1290
- } catch (e) {
1291
- if (e instanceof Error && (e.message.startsWith("Server rejected") || e.message.startsWith("Verify call failed"))) {
1292
- throw e;
1293
- }
1294
- // Network / DNS failure — surface but let the user override.
1295
- throw new Error(
1296
- `Could not reach ${getBaseUrl()} to verify token: ${e.message}\n` +
1297
- ` Add --skip-verify if you want to save anyway.`
1298
- );
1299
- }
1300
- }
1301
- writeCanonicalToken(token);
1302
- console.log(`Saved token to ~/.llama/token (mode 0600).`);
1303
- console.log(`Base URL: ${getBaseUrl()}`);
1304
- if (!flags["skip-verify"]) console.log(`Verified against ${getBaseUrl()}/api/me — token works.`);
1305
- return;
1306
- }
1307
-
1308
- if (area === "token" && action === "show") {
1309
- const token = getToken();
1310
- if (!token) {
1311
- console.log("No token set.");
1312
- return;
1313
- }
1314
- console.log(`${token.slice(0, 8)}...${token.slice(-4)} @ ${getBaseUrl()}`);
1315
- return;
1316
- }
1317
-
1318
- // Self-diagnosis for agents and humans — what credentials do we have, and
1319
- // are they accepted by the server right now? Designed so an agent can
1320
- // parse the output and decide whether to drive a recovery flow.
1321
- if (area === "auth" && action === "status") {
1322
- const bearer = await tryGcloudIdentityToken();
1323
- const token = getToken();
1324
- const tokenSrc = process.env.LLAMA_TOKEN
1325
- ? "$LLAMA_TOKEN"
1326
- : readCanonicalToken()
1327
- ? "~/.llama/token"
1328
- : readLegacyConfig().token
1329
- ? "~/.llama-command/config.json (legacy)"
1330
- : null;
1331
-
1332
- const oauthBundle = await readBundle();
1333
- const oauthBackend = oauthBundle ? await detectBackend() : null;
1334
-
1335
- let serverCheck = "skipped (no credentials)";
1336
- if (oauthBundle?.access_token || bearer || token) {
1337
- try {
1338
- const me = await request("GET", "/api/me");
1339
- serverCheck = `ok — authenticated as ${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
1340
- } catch (e) {
1341
- serverCheck = `failed — ${e.message.split("\n")[0]}`;
1342
- }
1343
- }
1344
-
1345
- const out = {
1346
- baseUrl: getBaseUrl(),
1347
- activeMethod: oauthBundle?.access_token
1348
- ? "oauth"
1349
- : bearer
1350
- ? "gcloud-bearer"
1351
- : token
1352
- ? "llama-token"
1353
- : "none",
1354
- oauth: oauthBundle
1355
- ? {
1356
- storage: oauthBackend,
1357
- client_id: oauthBundle.client_id,
1358
- scope: oauthBundle.scope,
1359
- issuer: oauthBundle.issuer,
1360
- expires_in_seconds: Math.max(0, Math.round((oauthBundle.expires_at - Date.now()) / 1000)),
1361
- }
1362
- : "absent (run `llama auth login`)",
1363
- gcloudIdentityToken: bearer ? "present" : "absent",
1364
- llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
1365
- llamaTokenSource: tokenSrc,
1366
- serverCheck,
1367
- };
1368
- print(out);
1369
- return;
1370
- }
1371
-
1372
- // ============================================================
1373
- // auth login — PKCE + loopback browser flow
1374
- // ============================================================
1375
- if (area === "auth" && action === "login") {
1376
- const { flags } = parseFlags(rest);
1377
- const requestedScope = typeof flags.scope === "string" && flags.scope.trim()
1378
- ? flags.scope.trim()
1379
- : "read write";
1380
- const baseUrl = getBaseUrl();
1381
- const resource = baseUrl; // general API audience (oauthApiResource on the server)
1382
-
1383
- console.error(`Signing in to ${baseUrl} as Llama CLI (client_id=${LLAMA_CLI_CLIENT_ID})...`);
1384
- const bundle = await pkceLoopbackFlow({ baseUrl, scope: requestedScope, resource });
1385
- const stored = await writeBundle({
1386
- access_token: bundle.access_token,
1387
- refresh_token: bundle.refresh_token,
1388
- expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
1389
- scope: bundle.scope,
1390
- client_id: bundle.client_id,
1391
- issuer: bundle.issuer,
1392
- resource: bundle.resource,
1393
- created_at: Date.now(),
1394
- });
1395
-
1396
- // Verify by hitting /api/me with the new token.
1397
- let identity = "(unable to verify — /api/me did not respond)";
1398
- try {
1399
- const me = await request("GET", "/api/me");
1400
- identity = `${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
1401
- } catch (e) {
1402
- identity = `verification failed: ${e.message.split("\n")[0]}`;
1403
- }
1404
-
1405
- print({
1406
- ok: true,
1407
- message: "Signed in",
1408
- identity,
1409
- storage: stored.backend,
1410
- scope: bundle.scope,
1411
- expires_in_seconds: bundle.expires_in,
1412
- });
1413
- return;
1414
- }
1415
-
1416
- // ============================================================
1417
- // auth logout — revoke + clear local
1418
- // ============================================================
1419
- if (area === "auth" && action === "logout") {
1420
- const bundle = await readBundle();
1421
- if (!bundle) {
1422
- print({ ok: true, message: "No OAuth credentials to clear" });
1423
- return;
1424
- }
1425
- let revoked = false;
1426
- try {
1427
- revoked = await revokeOAuthToken({
1428
- baseUrl: bundle.issuer ?? getBaseUrl(),
1429
- token: bundle.refresh_token,
1430
- tokenTypeHint: "refresh_token",
1431
- });
1432
- } catch {
1433
- revoked = false;
1434
- }
1435
- await deleteBundle();
1436
- print({
1437
- ok: true,
1438
- message: "Signed out — local credentials cleared",
1439
- serverRevoke: revoked ? "succeeded" : "failed (server unreachable or token already invalid; local state cleared anyway)",
1440
- });
1441
- return;
1442
- }
1443
-
1444
- if (area === "deal" && action === "create") {
1445
- const { flags, positional } = parseFlags(rest);
1446
- const companyName = positional.join(" ").trim();
1447
- if (!companyName) throw new Error("Usage: llama deal create \"Company\" [--source Name] [--deal-owner name|email|userId]");
1448
- const body = {
1449
- companyName,
1450
- source: flags.source,
1451
- dealOwner: flags.dealOwner || flags["deal-owner"],
1452
- sourceDirection: flags.sourceDirection || flags["source-direction"],
1453
- description: flags.description,
1454
- website: flags.website,
1455
- notes: flags.notes,
1456
- status: flags.status,
1457
- theirStage: flags["their-stage"],
1458
- stage: flags.stage,
1459
- proposedAmount: flags["proposed-amount"],
1460
- roundSize: flags["round-size"],
1461
- valuation: flags.valuation,
1462
- founders: flags.founders,
1463
- location: flags.location,
1464
- };
1465
- print(await request("POST", "/api/deals/create", body));
1466
- return;
1467
- }
1468
-
1469
- if (area === "workflow") {
1470
- const dealId = rest[0];
1471
- if (!dealId) throw new Error("Usage: llama workflow show|initialize|request-support|decide-support|proceed|waive|control|organize-ic|vote|reassign-owner|execution-status <dealId> [...flags]");
1472
- if (action === "show") {
1473
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/workflow`));
1474
- return;
1475
- }
1476
- const { flags, positional } = parseFlags(rest.slice(1));
1477
- const current = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/workflow`);
1478
- const expectedRevision = current?.workflow?.revision;
1479
- if (!Number.isInteger(expectedRevision)) {
1480
- throw new Error("This deal has no initialized Investment Workflow V2 state. Open its workflow panel once, then retry.");
1481
- }
1482
- const base = {
1483
- requestId: `cli:${action}:${randomUUID()}`,
1484
- expectedRevision,
1485
- };
1486
- let command;
1487
- if (action === "initialize") {
1488
- if (!flags.reason) throw new Error('Usage: llama workflow initialize <dealId> --reason "..."');
1489
- command = { ...base, type: "initialize", reason: String(flags.reason) };
1490
- } else if (action === "request-support") {
1491
- if (!flags.partner || !flags.reason) throw new Error('Usage: llama workflow request-support <dealId> --partner <userId> --reason "..."');
1492
- command = { ...base, type: "request_partner_support", partnerId: String(flags.partner), reason: String(flags.reason) };
1493
- } else if (action === "decide-support") {
1494
- const decision = positional[0];
1495
- if (!["support", "need_more", "pass"].includes(decision) || !flags.reason) throw new Error('Usage: llama workflow decide-support <dealId> support|need_more|pass --reason "..."');
1496
- command = { ...base, type: "partner_support_decision", decision, reason: String(flags.reason) };
1497
- } else if (action === "proceed") {
1498
- if (!flags.transition || !flags.reason) throw new Error('Usage: llama workflow proceed <dealId> --transition <key> --reason "..."');
1499
- command = { ...base, type: "proceed", transitionKey: String(flags.transition), reason: String(flags.reason) };
1500
- } else if (action === "waive") {
1501
- if (!flags.guard || !flags.reason) throw new Error('Usage: llama workflow waive <dealId> --guard <key> --reason "..."');
1502
- command = { ...base, type: "resolve_guard", guardKey: String(flags.guard), status: "waived", reason: String(flags.reason) };
1503
- } else if (action === "control") {
1504
- const control = positional[0];
1505
- if (!["hold", "resume", "pass", "restore", "return"].includes(control) || !flags.reason) throw new Error('Usage: llama workflow control <dealId> hold|resume|pass|restore|return --reason "..." [--disposition stalled|future]');
1506
- command = { ...base, type: "control", action: control, reason: String(flags.reason), ...(flags.disposition ? { holdDisposition: String(flags.disposition) } : {}) };
1507
- } else if (action === "organize-ic") {
1508
- if (!flags.note) throw new Error('Usage: llama workflow organize-ic <dealId> --note "..."');
1509
- command = { ...base, type: "organize_ic", note: String(flags.note) };
1510
- } else if (action === "vote") {
1511
- const vote = positional[0];
1512
- if (!["yes", "no"].includes(vote) || !flags.reason) throw new Error('Usage: llama workflow vote <dealId> yes|no --reason "..."');
1513
- command = { ...base, type: "cast_vote", vote, reason: String(flags.reason) };
1514
- } else if (action === "reassign-owner") {
1515
- if (!flags.owner || !flags.reason) throw new Error('Usage: llama workflow reassign-owner <dealId> --owner <userId> --reason "..."');
1516
- command = { ...base, type: "reassign_owner", ownerId: String(flags.owner), ownerName: "resolved by server", reason: String(flags.reason) };
1517
- } else if (action === "execution-status") {
1518
- const status = { "term-sheet": "Term Sheet", "verbal-commit": "Verbal Commit", invested: "Invested" }[positional[0]];
1519
- if (!status || !flags.reason) throw new Error('Usage: llama workflow execution-status <dealId> term-sheet|verbal-commit|invested --reason "..."');
1520
- command = { ...base, type: "update_execution_status", executionStatus: status, reason: String(flags.reason) };
1521
- } else {
1522
- throw new Error(`Unknown workflow command "${action || ""}".`);
1523
- }
1524
- print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/workflow`, command));
1525
- return;
1526
- }
1527
-
1528
- if (area === "deal" && action === "show") {
1529
- const dealId = rest[0];
1530
- if (!dealId) throw new Error("Usage: llama deal show <dealId>");
1531
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/command-center`));
1532
- return;
1533
- }
1534
-
1535
- if (area === "deal" && action === "feed") {
1536
- const dealId = rest[0];
1537
- if (!dealId) throw new Error("Usage: llama deal feed <dealId>");
1538
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/feed`));
1539
- return;
1540
- }
1541
-
1542
- if (area === "deal" && action === "update") {
1543
- const [dealId, field, ...valueParts] = rest;
1544
- const value = valueParts.join(" ");
1545
- if (!dealId || !field) throw new Error("Usage: llama deal update <dealId> <field> <value>");
1546
- if (field === "status") {
1547
- throw new Error("Direct status writes are retired. Use `llama workflow show <dealId>` and a formal `llama workflow ...` command.");
1548
- }
1549
- print(await request("POST", "/api/deals/update", { dealId, field, value }));
1550
- return;
1551
- }
1552
-
1553
- if (area === "deal" && action === "founders") {
1554
- const sub = rest[0];
1555
- const dealId = rest[1];
1556
- const { flags } = parseFlags(rest.slice(2), ["json"]);
1557
- if (sub !== "set" || !dealId || typeof flags.json !== "string") {
1558
- throw new Error(
1559
- "Usage: llama deal founders set <dealId> --json '[{\"name\":\"Ada\",\"email\":\"ada@example.com\"}]'"
1560
- );
1561
- }
1562
- let founders;
1563
- try {
1564
- founders = JSON.parse(flags.json);
1565
- } catch {
1566
- throw new Error("--json must be a valid JSON array");
1567
- }
1568
- if (!Array.isArray(founders)) throw new Error("--json must be a JSON array");
1569
- print(await request("PUT", `/api/deals/${encodeURIComponent(dealId)}/founders`, { founders }));
532
+ if (!action) await runPitchRepl();
533
+ else await handlePitch(action, rest);
1570
534
  return;
1571
535
  }
1572
536
 
1573
- // ----- deals.extra JSONB patches (system-admin only, server-gated) -----
1574
- // Same endpoint as `deal update`, but `extraKey` instead of `field`.
1575
- // Server patches one top-level key via jsonb_set and audits the change
1576
- // to deal_events as field_change with field "extra.<key>". value=null
1577
- // deletes the key.
1578
- if (area === "deal" && action === "extra") {
1579
- const sub = rest[0];
1580
- const dealId = rest[1];
1581
- const key = rest[2];
1582
- if (["stage_gates", "stage4_gate"].includes(key)) {
1583
- throw new Error(`Legacy ${key} is retired. Use \`llama workflow ...\` commands.`);
1584
- }
1585
- if (sub === "set") {
1586
- const raw = rest.slice(3).join(" ");
1587
- if (!dealId || !key || !raw) {
1588
- throw new Error(
1589
- "Usage: llama deal extra set <dealId> <key> <value> (value parsed as JSON when possible, else stored as string)"
1590
- );
1591
- }
1592
- let value;
1593
- try {
1594
- value = JSON.parse(raw);
1595
- } catch {
1596
- value = raw;
1597
- }
1598
- print(await request("POST", "/api/deals/update", { dealId, extraKey: key, value }));
1599
- return;
1600
- }
1601
- if (sub === "unset") {
1602
- if (!dealId || !key) throw new Error("Usage: llama deal extra unset <dealId> <key>");
1603
- print(await request("POST", "/api/deals/update", { dealId, extraKey: key, value: null }));
1604
- return;
1605
- }
1606
- throw new Error("Usage: llama deal extra set|unset <dealId> <key> [value]");
1607
- }
1608
-
1609
537
  if (area === "deal" && action === "search") {
1610
- const { flags, positional } = parseFlags(rest);
1611
- const q = positional.join(" ").trim();
1612
- if (!q && Object.keys(flags).length === 0) {
1613
- throw new Error(
1614
- `Usage: llama deal search <query> [--founder ...] [--owner ...] [--status ...] [--stage ...] [--source-direction Inbound|Outbound] [--limit N]`
1615
- );
1616
- }
1617
- print(await searchDeals(q, flags));
1618
- return;
1619
- }
1620
-
1621
- if (area === "deal" && action === "list") {
1622
- const { flags } = parseFlags(rest);
1623
- print(await searchDeals("", flags));
1624
- return;
1625
- }
1626
-
1627
- if (area === "deal" && action === "agent") {
1628
- const sub = rest[0];
1629
- const dealId = rest[1];
1630
- const { flags, positional } = parseFlags(rest.slice(2), ["message"]);
1631
- const message =
1632
- flags.message && flags.message !== true ? String(flags.message) : positional.join(" ").trim();
1633
- if (sub !== "run" || !dealId || !message) {
1634
- throw new Error(`Usage: llama deal agent run <dealId> --message "what the server agent should do"`);
1635
- }
1636
- await runDealAgentViaThread(dealId, message, "CLI agent run");
1637
- return;
1638
- }
1639
-
1640
- // ----- Deal enrichment: evidence plan + server-side enrichment trigger -----
1641
- // The server owns Monid credentials and all write/audit behavior. CLI only
1642
- // passes intent; default is dry-run so agents can inspect the harness before
1643
- // creating facts/links or touching memo state.
1644
- if (area === "deal" && action === "enrich") {
1645
- const dealId = rest[0];
1646
- if (!dealId) {
1647
- throw new Error(
1648
- "Usage: llama deal enrich <dealId> [--dry-run] [--apply] " +
1649
- "[--executor server_agent|external_agent|planner] " +
1650
- "[--sources website,github,linkedin,yc,monid] [--budget-cents 50] [--memo] [--prompt]"
1651
- );
1652
- }
1653
- const { flags } = parseFlags(rest.slice(1), [
1654
- "dry-run",
1655
- "apply",
1656
- "executor",
1657
- "sources",
1658
- "budget-cents",
1659
- "memo",
1660
- "generate-memo",
1661
- "generateMemo",
1662
- "prompt",
1663
- "handoff",
1664
- "harness-only",
1665
- "message",
1666
- ]);
1667
- const sources = splitCsvFlag(flags.sources);
1668
- const budgetCents =
1669
- flags["budget-cents"] !== undefined && flags["budget-cents"] !== true
1670
- ? Number(flags["budget-cents"])
1671
- : undefined;
1672
- const apply = boolFlag(flags, "apply");
1673
- const executor = flags.executor && flags.executor !== true ? String(flags.executor) : "server_agent";
1674
-
1675
- if (apply && executor === "server_agent" && !boolFlag(flags, "harness-only")) {
1676
- await runDealAgentViaThread(dealId, buildEnrichmentAgentMessage(flags), "CLI enrichment");
1677
- return;
1678
- }
1679
-
1680
- const result = await request(
1681
- "POST",
1682
- `/api/deals/${encodeURIComponent(dealId)}/enrich`,
1683
- {
1684
- dryRun: apply ? false : true,
1685
- apply,
1686
- executor,
1687
- sources,
1688
- budgetCents,
1689
- generateMemo: boolFlag(flags, "memo", "generate-memo", "generateMemo"),
1690
- }
1691
- );
1692
- if (flags.prompt === true || flags.handoff === true) {
1693
- print(result?.agentHarness?.handoffPrompt || result?.agentHarness?.systemInjection || "");
1694
- } else {
1695
- print(result);
1696
- }
1697
- return;
1698
- }
1699
-
1700
- // ----- Collaborators (deal team — non-owner contributors) -----
1701
- // Accepts --user as numeric id OR @llamaventures.vc email; emails are
1702
- // resolved to id via /api/users so the CLI matches how the web picker
1703
- // works (you don't need to memorize ids).
1704
- if (area === "deal" && action === "collab") {
1705
- const sub = rest[0];
1706
- const dealId = rest[1];
1707
- const { flags } = parseFlags(rest.slice(2));
1708
-
1709
- if (!sub || !dealId) {
1710
- throw new Error(
1711
- "Usage: llama deal collab list|add|remove <dealId> [--user <userId|email>]"
1712
- );
1713
- }
1714
-
1715
- if (sub === "list") {
1716
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/collaborators`));
1717
- return;
1718
- }
1719
-
1720
- if (sub !== "add" && sub !== "remove" && sub !== "restore") {
1721
- throw new Error(`Unknown collab sub-command "${sub}". Use list, add, remove, or restore.`);
1722
- }
1723
-
1724
- if (!flags.user) {
1725
- throw new Error(`Usage: llama deal collab ${sub} <dealId> --user <userId|email>`);
1726
- }
1727
-
1728
- let userId = Number(flags.user);
1729
- if (!Number.isFinite(userId)) {
1730
- const email = String(flags.user).toLowerCase();
1731
- const usersPayload = await request("GET", "/api/users");
1732
- const list = Array.isArray(usersPayload) ? usersPayload : usersPayload.users ?? [];
1733
- const match = list.find((u) => String(u.email).toLowerCase() === email);
1734
- if (!match) throw new Error(`No active user with email "${flags.user}"`);
1735
- userId = match.id;
1736
- }
1737
-
1738
- if (sub === "add") {
1739
- print(await request(
1740
- "POST",
1741
- `/api/deals/${encodeURIComponent(dealId)}/collaborators`,
1742
- { userId }
1743
- ));
1744
- } else if (sub === "remove") {
1745
- print(await request(
1746
- "DELETE",
1747
- `/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}`
1748
- ));
1749
- } else {
1750
- print(await request(
1751
- "POST",
1752
- `/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}/restore`
1753
- ));
1754
- }
1755
- return;
1756
- }
1757
-
1758
- // ----- Deal links (URLs attached to a deal — separate from brief link blocks) -----
1759
- // Soft-delete: removal sets deleted_at, restore clears it. List excludes
1760
- // trashed by default; pass --include-deleted to see them.
1761
- if (area === "deal" && action === "link") {
1762
- const sub = rest[0];
1763
- const dealId = rest[1];
1764
- if (!sub || !dealId) {
1765
- throw new Error(
1766
- "Usage: llama deal link list|add|delete|restore <dealId> [...flags|<linkId>]"
1767
- );
1768
- }
1769
-
1770
- if (sub === "list") {
1771
- const { flags } = parseFlags(rest.slice(2));
1772
- const qs = flags["include-deleted"] ? "?include_deleted=1" : "";
1773
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/links${qs}`));
1774
- return;
1775
- }
1776
-
1777
- if (sub === "add") {
1778
- const { flags } = parseFlags(rest.slice(2));
1779
- if (!flags.url) throw new Error("Usage: llama deal link add <dealId> --url <url> [--label \"...\"]");
1780
- print(await request(
1781
- "POST",
1782
- `/api/deals/${encodeURIComponent(dealId)}/links`,
1783
- { url: String(flags.url), label: flags.label ? String(flags.label) : "" }
1784
- ));
1785
- return;
1786
- }
1787
-
1788
- if (sub === "delete" || sub === "restore") {
1789
- const linkId = rest[2];
1790
- if (!linkId) throw new Error(`Usage: llama deal link ${sub} <dealId> <linkId>`);
1791
- const path = `/api/deals/${encodeURIComponent(dealId)}/links/${encodeURIComponent(linkId)}`;
1792
- // @core-api-operation DELETE /api/deals/{dealId}/links/{linkId}
1793
- // @core-api-operation POST /api/deals/{dealId}/links/{linkId}/restore
1794
- print(await request(
1795
- sub === "delete" ? "DELETE" : "POST",
1796
- sub === "delete" ? path : `${path}/restore`
1797
- ));
1798
- return;
1799
- }
1800
-
1801
- throw new Error(`Unknown link sub-command "${sub}". Use list, add, delete, or restore.`);
1802
- }
1803
-
1804
- // ----- Deal soft-delete / restore / trash list -----
1805
- // Server side: DELETE /api/deals/:id uses authenticate() (token works).
1806
- // POST /restore currently uses session-only auth() — known asymmetry,
1807
- // pending server fix to swap to authenticate() for parity.
1808
- if (area === "deal" && action === "delete") {
1809
- const dealId = rest[0];
1810
- if (!dealId) throw new Error("Usage: llama deal delete <dealId>");
1811
- print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}`));
538
+ const { flags, positional } = parseFlags(rest, ["state", "limit"]);
539
+ // @core-api-operation GET /api/occam/deals
540
+ print(await request("GET", buildDealSearchPath(positional.join(" ").trim(), flags)));
1812
541
  return;
1813
542
  }
1814
-
1815
- if (area === "deal" && action === "restore") {
543
+ if (area === "deal" && action === "read") {
1816
544
  const dealId = rest[0];
1817
- if (!dealId) throw new Error("Usage: llama deal restore <dealId>");
1818
- // NOTE: server uses session-only auth() today — token callers get 401
1819
- // until server is updated. CLI surface is forward-compatible.
1820
- print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/restore`));
1821
- return;
1822
- }
1823
-
1824
- if (area === "deal" && action === "trash") {
1825
- print(await request("GET", "/api/deals/deleted"));
545
+ const { flags } = parseFlags(rest.slice(1), ["detail"]);
546
+ // @core-api-operation GET /api/occam/deals/{dealId}
547
+ print(await request("GET", buildDealReadPath(dealId, flags.detail === true ? "overview" : flags.detail || "overview")));
1826
548
  return;
1827
549
  }
1828
-
1829
- // ----- Canonical source-packet ingest (atomic facts + optional Feed note) -----
1830
- if (area === "deal" && action === "ingest") {
1831
- const dealId = rest[0];
1832
- const { flags } = parseFlags(rest.slice(1), ["file", "idempotency-key"]);
1833
- if (!dealId || !flags.file || flags.file === true) {
1834
- throw new Error(
1835
- "Usage: llama deal ingest <dealId> --file <packet.json> [--idempotency-key <key>]\n" +
1836
- 'Packet: {"source":{"kind":"meeting_note","title":"Office visit"},' +
1837
- '"facts":[{"category":"team","claim":"..."}],"note":"..."}'
1838
- );
1839
- }
1840
-
1841
- let packet;
1842
- try {
1843
- packet = JSON.parse(await readFile(String(flags.file), "utf8"));
1844
- } catch (error) {
1845
- throw new Error(`Cannot read ingest packet ${flags.file}: ${error?.message ?? String(error)}`);
1846
- }
1847
- if (!packet || Array.isArray(packet) || typeof packet !== "object") {
1848
- throw new Error("Ingest packet must be a JSON object");
1849
- }
1850
- if (flags["idempotency-key"] !== undefined && flags["idempotency-key"] !== true) {
1851
- packet.idempotencyKey = String(flags["idempotency-key"]);
1852
- }
1853
-
1854
- // @core-api-operation POST /api/deals/{dealId}/ingest
1855
- print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/ingest`, packet));
550
+ if (area === "deal" && ["create", "write"].includes(action)) {
551
+ const { flags } = parseFlags(rest, ["json"]);
552
+ const input = await readJsonInput(flags.json);
553
+ print(await request("POST", "/api/occam/deals/commands", prepareDealCommand(action, input)));
1856
554
  return;
1857
555
  }
1858
556
 
1859
- // ----- Deal facts (AI-extracted or human-asserted, with verification) -----
1860
- if (area === "deal" && action === "fact") {
1861
- const sub = rest[0];
1862
- const dealId = rest[1];
1863
- const { flags } = parseFlags(rest.slice(2));
1864
-
1865
- if (!sub || !dealId) {
1866
- throw new Error("Usage: llama deal fact list|add|verify <dealId> [...]");
1867
- }
1868
-
1869
- if (sub === "list") {
1870
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/facts`));
1871
- return;
1872
- }
1873
-
1874
- if (sub === "add") {
1875
- const claim = flags.claim ?? flags.value;
1876
- const sourceUrl = flags["source-url"] ?? flags.sourceUrl;
1877
- if (!flags.category || !claim) {
1878
- throw new Error(
1879
- `Usage: llama deal fact add <dealId> --category <cat> --claim "<text>" ` +
1880
- `[--source "..."] [--source-url <url>] [--confidence high|medium|low] [--attested]`
1881
- );
1882
- }
1883
- // --attested: the caller takes responsibility that this is accurate
1884
- // (verified against the source). With it, the fact is recorded as
1885
- // vouched; without it, it stays unverified. Declare honestly.
1886
- print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/facts`, {
1887
- category: String(flags.category),
1888
- claim: String(claim),
1889
- source: flags.source ? String(flags.source) : "",
1890
- ...(sourceUrl ? { sourceUrl: String(sourceUrl) } : {}),
1891
- confidence: flags.confidence ? String(flags.confidence) : "medium",
1892
- attested: flags.attested === true,
1893
- }));
1894
- return;
1895
- }
1896
-
1897
- if (sub === "verify") {
1898
- const factId = rest[2];
1899
- if (!factId) {
1900
- throw new Error(
1901
- `Usage: llama deal fact verify <dealId> <factId> ` +
1902
- `--status confirmed|disputed [--corrected-value "..."]`
1903
- );
1904
- }
1905
- if (!flags.status || !["confirmed", "disputed"].includes(String(flags.status))) {
1906
- throw new Error("--status must be 'confirmed' or 'disputed'");
1907
- }
1908
- const body = { status: String(flags.status) };
1909
- if (flags["corrected-value"] !== undefined && flags["corrected-value"] !== true) {
1910
- body.correctedValue = String(flags["corrected-value"]);
1911
- }
1912
- print(await request(
1913
- "PATCH",
1914
- `/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(factId)}`,
1915
- body
1916
- ));
1917
- return;
1918
- }
1919
-
1920
- // Lift a contest that turned out to be wrong. Contesting a claim removes
1921
- // it from everything the Deal Agent treats as current; before this existed
1922
- // there was no way back, so a TRUE claim contested on weak evidence stayed
1923
- // suppressed in every regenerated brief and memo. `verify --status
1924
- // confirmed` does NOT do this — trust and contest are separate axes.
1925
- if (sub === "uncontest") {
1926
- const factId = rest[2];
1927
- const reason = flags.reason !== undefined && flags.reason !== true ? String(flags.reason).trim() : "";
1928
- if (!factId || !reason) {
1929
- throw new Error(
1930
- `Usage: llama deal fact uncontest <dealId> <factId> --reason "<why the contest was wrong>"`
1931
- );
1932
- }
1933
- // @core-api-operation POST /api/deals/{dealId}/facts/{factId}/uncontest
1934
- print(await request(
1935
- "POST",
1936
- `/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(factId)}/uncontest`,
1937
- { reason }
1938
- ));
1939
- return;
1940
- }
1941
-
1942
- throw new Error(`Unknown fact sub-command "${sub}". Use list, add, verify, or uncontest.`);
1943
- }
1944
-
1945
- // ----- Brief refresh: trigger stale-section re-eval watcher run -----
1946
- // Server only fires for unlocked sections that are stale per the
1947
- // freshness policy; --force runs every unlocked watcher-managed section.
1948
- if (area === "deal" && action === "refresh-brief") {
1949
- const { flags } = parseFlags(rest);
1950
- const dealId = rest[0];
1951
- if (!dealId) throw new Error("Usage: llama deal refresh-brief <dealId> [--force]");
1952
- const qs = flags.force ? "?force=true" : "";
1953
- print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/refresh-brief${qs}`));
557
+ if (area === "wiki") {
558
+ await handleWiki(action, rest);
1954
559
  return;
1955
560
  }
1956
561
 
1957
- // ----- Agent-run revert (legacy 4-section brief model) -----
1958
- // Reverts a single section to the `before` value snapshotted by the
1959
- // watcher run. Does NOT re-fire the watcher (intentional: human action).
1960
- // Logs `brief_reverted` in deal_events. Section keys are the legacy
1961
- // top-level sections, not block ids.
1962
- if (area === "deal" && action === "revert-run") {
562
+ if (area === "admin") {
563
+ const allowed = ["auth-events", "deal-events", "agent-events"];
564
+ if (!allowed.includes(action)) throw new Error(`Admin actions: ${allowed.join(", ")}.`);
1963
565
  const { flags } = parseFlags(rest);
1964
- const dealId = rest[0];
1965
- const runId = rest[1];
1966
- const valid = ["company", "team", "highlights", "recommendation"];
1967
- if (!dealId || !runId || !flags.section) {
1968
- throw new Error(
1969
- `Usage: llama deal revert-run <dealId> <runId> --section ${valid.join("|")}`
1970
- );
1971
- }
1972
- if (!valid.includes(String(flags.section))) {
1973
- throw new Error(`--section must be one of ${valid.join(", ")}`);
1974
- }
1975
- print(await request(
1976
- "POST",
1977
- `/api/deals/${encodeURIComponent(dealId)}/agent-runs/${encodeURIComponent(runId)}/revert`,
1978
- { section: String(flags.section) }
1979
- ));
1980
- return;
1981
- }
1982
-
1983
- if (area === "approvals" && action === "list") {
1984
- print(await request("GET", "/api/partner/approvals"));
1985
- return;
1986
- }
1987
-
1988
- if (area === "approvals" && action === "decide") {
1989
- const { flags, positional } = parseFlags(rest);
1990
- const approvalId = Number(positional[0]);
1991
- const decision = positional[1];
1992
- if (!Number.isFinite(approvalId) || !["approved", "rejected"].includes(decision)) {
1993
- throw new Error("Usage: llama approvals decide <approvalId> approved|rejected [--note ...]");
1994
- }
1995
- print(await request("POST", "/api/partner/approvals", {
1996
- approvalId,
1997
- decision,
1998
- note: flags.note || "",
1999
- }));
2000
- return;
2001
- }
2002
-
2003
- // ----- Ownership: self-claim -----
2004
- if (area === "claim") {
2005
- const dealId = action; // second positional
2006
- const { flags } = parseFlags(rest, ["reason"]);
2007
- const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
2008
- if (!dealId || !reason) {
2009
- throw new Error('Usage: llama claim <dealId> --reason "<why this person should own it>"');
2010
- }
2011
- const me = await request("GET", "/api/me");
2012
- print(await request(
2013
- "POST",
2014
- `/api/deals/${encodeURIComponent(dealId)}/propose-owner`,
2015
- { userId: me.id, reason }
2016
- ));
2017
- return;
2018
- }
2019
-
2020
- // ----- Ownership: partner nominates someone else -----
2021
- if (area === "nominate") {
2022
- const dealId = action;
2023
- const { flags } = parseFlags(rest, ["user", "reason"]);
2024
- const userId = Number(flags.user);
2025
- const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
2026
- if (!dealId || !Number.isFinite(userId) || !reason) {
2027
- throw new Error('Usage: llama nominate <dealId> --user <userId> --reason "<why>"');
566
+ const params = new URLSearchParams();
567
+ for (const key of ["kind", "actor", "subject", "since", "limit", "offset", "deal", "tool"]) {
568
+ if (flags[key] && flags[key] !== true) params.set(key, String(flags[key]));
2028
569
  }
2029
- print(await request(
2030
- "POST",
2031
- `/api/deals/${encodeURIComponent(dealId)}/propose-owner`,
2032
- { userId, reason }
2033
- ));
2034
- return;
2035
- }
2036
-
2037
- // ----- Nominations inbox (for the nominee) -----
2038
- if (area === "nominations" && action === "list") {
2039
- print(await request("GET", "/api/me/nominations"));
570
+ if (flags["agent-kind"] && flags["agent-kind"] !== true) params.set("agent_kind", String(flags["agent-kind"]));
571
+ if (flags["errors-only"]) params.set("errors_only", "1");
572
+ // @core-api-operation GET /api/admin/auth-events
573
+ // @core-api-operation GET /api/admin/deal-events
574
+ // @core-api-operation GET /api/admin/agent-events
575
+ print(await request("GET", `/api/admin/${action}${params.size ? `?${params}` : ""}`));
2040
576
  return;
2041
577
  }
2042
- if (area === "nominations" && action === "decide") {
2043
- const approvalId = Number(rest[0]);
2044
- const decision = rest[1];
2045
- if (!Number.isFinite(approvalId) || !["accepted", "declined"].includes(decision)) {
2046
- throw new Error("Usage: llama nominations decide <approvalId> accepted|declined");
2047
- }
2048
- print(await request("POST", `/api/nominations/${approvalId}`, { decision }));
2049
- return;
2050
- }
2051
-
2052
- // ----- Timeline -----
2053
- if (area === "timeline") {
2054
- const dealId = action;
2055
- if (!dealId) throw new Error("Usage: llama timeline <dealId>");
2056
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/timeline`));
2057
- return;
2058
- }
2059
-
2060
- // ----- Post to timeline -----
2061
- if (area === "post") {
2062
- const dealId = action;
2063
- const { flags, positional } = parseFlags(rest);
2064
- const body = positional[0];
2065
- if (!dealId || !body) {
2066
- throw new Error(`Usage: llama post <dealId> "message body" [--link url] [--link-name "name"] [--cue]`);
2067
- }
2068
- const attachments = flags.link
2069
- ? [{ url: String(flags.link), name: flags["link-name"] ? String(flags["link-name"]) : String(flags.link) }]
2070
- : [];
2071
- print(await request(
2072
- "POST",
2073
- `/api/deals/${encodeURIComponent(dealId)}/posts`,
2074
- { body, attachments, cue_authorized: flags.cue === true }
2075
- ));
2076
- return;
2077
- }
2078
-
2079
- // ----- Wiki: search -----
2080
- if (area === "wiki" && action === "search") {
2081
- const { positional } = parseFlags(rest);
2082
- const q = positional.join(" ").trim();
2083
- if (!q) throw new Error("Usage: llama wiki search <query>");
2084
- print(await request("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`));
2085
- return;
2086
- }
2087
-
2088
- // ----- Wiki: read a single article (EN by default) -----
2089
- // Hits /api/wiki/<slug> directly. Earlier versions did a fuzzy
2090
- // /api/wiki/search call and filtered for an exact slug match — that
2091
- // missed any article whose slug-as-string didn't appear in title or
2092
- // content (e.g. a slug like "foo-bar" against an article titled "Foo Bar"),
2093
- // so a real article would print as "not found" even though it existed.
2094
- if (area === "wiki" && action === "read") {
2095
- const { flags, positional } = parseFlags(rest);
2096
- const slug = positional[0];
2097
- if (!slug) throw new Error("Usage: llama wiki read <slug> [--lang en|zh]");
2098
- const lang = flags.lang === "zh" ? "zh" : "en";
2099
- const path = `/api/wiki/${encodeURIComponent(slug)}?lang=${lang}`;
2100
- // @core-api-operation GET /api/wiki/{slug}
2101
- print(await request("GET", path));
2102
- return;
2103
- }
2104
-
2105
- // ----- Wiki: save (create or update) -----
2106
- // Two body modes:
2107
- // --content "..." inline markdown OR raw HTML (string)
2108
- // --file <path> read body from a file; if .html/.htm,
2109
- // content_type auto-detects to 'html'
2110
- // --content-type <markdown|html> overrides auto-detect.
2111
- // Refuses content_type mismatch on existing entries (server-side check;
2112
- // CLI surfaces the server error verbatim).
2113
- if (area === "wiki" && action === "save") {
2114
- const { flags, positional } = parseFlags(rest);
2115
- const slug = positional[0];
2116
- const title = flags.title;
2117
- const inlineContent = flags.content;
2118
- const filePath = flags.file;
2119
- const sourcesRaw = flags.sources;
2120
- if (!slug || !title || !sourcesRaw || (!inlineContent && !filePath)) {
2121
- throw new Error(
2122
- `Usage:
2123
- llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
2124
- or
2125
- llama wiki save <slug> --title "..." --file path/to/article.{md,html} --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
2126
-
2127
- Pass either --content (inline) or --file (read from disk). With --file, content_type auto-detects from extension (.html/.htm → html, else markdown). Use --content-type to override.
2128
-
2129
- Routing — is this the right command?
2130
- ✓ Cross-deal / institutional knowledge (sector landscape, market map, thesis, framework, methodology)
2131
- → YES, you're in the right place.
2132
- ✗ Deal-specific HTML (IC memo for X, dashboard for X, 2×2 for one company)
2133
- → use \`llama html upload <dealId> --new --title "..." --file <path>\` instead.
2134
- ✗ Founder-facing public share link
2135
- → escape to Netlify only when the user explicitly says "share publicly" / "give it to the founder";
2136
- Llama Command outranks Netlify for everything internal.`
2137
- );
2138
- }
2139
- if (inlineContent && filePath) {
2140
- throw new Error("Pass either --content OR --file, not both.");
2141
- }
2142
- // Read body — either inline or from file.
2143
- let body;
2144
- let inferredType = "markdown";
2145
- if (filePath) {
2146
- const { readFileSync } = await import("fs");
2147
- body = readFileSync(String(filePath), "utf-8");
2148
- const lower = String(filePath).toLowerCase();
2149
- if (lower.endsWith(".html") || lower.endsWith(".htm")) {
2150
- inferredType = "html";
2151
- }
2152
- } else {
2153
- body = String(inlineContent);
2154
- }
2155
- // Determine content_type: explicit flag wins over file-extension inference.
2156
- let contentType = inferredType;
2157
- if (flags["content-type"]) {
2158
- const v = String(flags["content-type"]).toLowerCase();
2159
- if (v !== "markdown" && v !== "html") {
2160
- throw new Error(`--content-type must be 'markdown' or 'html', got "${v}"`);
2161
- }
2162
- contentType = v;
2163
- }
2164
- const splitCsv = (v) => String(v).split(/[;|]/).map((s) => s.trim()).filter(Boolean);
2165
- const payload = {
2166
- slug,
2167
- title: String(title),
2168
- content: body,
2169
- sources: splitCsv(sourcesRaw),
2170
- type: flags.type ? String(flags.type) : undefined,
2171
- related: flags.related ? splitCsv(flags.related) : undefined,
2172
- lang: flags.lang === "zh" ? "zh" : "en",
2173
- status: flags.status ? String(flags.status) : undefined,
2174
- content_type: contentType,
2175
- };
2176
- print(await request("POST", "/api/wiki/save", payload));
2177
- return;
2178
- }
2179
-
2180
- // ----- Wiki: delete (soft) / restore -----
2181
- // Soft-delete (CONSTITUTION §8 reversible). For HTML entries the
2182
- // sentinel deal_browse_html body + assets are soft-deleted too;
2183
- // `llama wiki restore <slug>` brings it all back.
2184
- if (area === "wiki" && (action === "delete" || action === "restore")) {
2185
- const { flags, positional } = parseFlags(rest);
2186
- const slug = positional[0];
2187
- if (!slug) throw new Error(`Usage: llama wiki ${action} <slug> [--lang en|zh]`);
2188
- const lang = flags.lang === "zh" ? "zh" : "en";
2189
- const qs = `?lang=${lang}`;
2190
- if (action === "delete") {
2191
- print(await request("DELETE", `/api/wiki/${encodeURIComponent(slug)}${qs}`));
2192
- } else {
2193
- print(await request("POST", `/api/wiki/${encodeURIComponent(slug)}/restore${qs}`));
2194
- }
2195
- return;
2196
- }
2197
-
2198
- // ----- Brief blocks: list / add-* / edit / delete -----
2199
- // The block-based deal brief stores an ordered array of typed blocks
2200
- // (text / link / embed / callout) per deal. These commands wrap the
2201
- // /api/deals/:id/blocks{,/:id} endpoints. To add a block we read +
2202
- // append + PUT (two roundtrips); single-block edit + delete have
2203
- // dedicated PATCH/DELETE endpoints.
2204
- if (area === "brief" && action === "blocks") {
2205
- const dealId = rest[0];
2206
- if (!dealId) throw new Error("Usage: llama brief blocks <dealId>");
2207
- print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`));
2208
- return;
2209
- }
2210
-
2211
- // Per-block read — pairs with the manifest returned by /command-center
2212
- // (i.e. `llama deal show`). Agent flow: read manifest → pick blocks
2213
- // by id → fetch only those bodies, instead of pulling the full array.
2214
- if (area === "brief" && action === "block") {
2215
- const dealId = rest[0];
2216
- const blockId = rest[1];
2217
- if (!dealId || !blockId) throw new Error("Usage: llama brief block <dealId> <blockId>");
2218
- print(await request(
2219
- "GET",
2220
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`
2221
- ));
2222
- return;
2223
- }
2224
-
2225
- if (area === "brief" && action?.startsWith("add-")) {
2226
- const type = action.slice(4); // "add-text" → "text"
2227
- if (!["text", "link", "embed", "callout"].includes(type)) {
2228
- throw new Error(`Unknown block type "${type}". Use add-text, add-link, add-embed, or add-callout.`);
2229
- }
2230
- const { flags } = parseFlags(rest);
2231
- const dealId = rest[0];
2232
- if (!dealId) throw new Error(`Usage: llama brief add-${type} <dealId> [...flags]`);
2233
-
2234
- const id = (typeof crypto !== "undefined" && "randomUUID" in crypto)
2235
- ? crypto.randomUUID()
2236
- : `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2237
- const meta = { updated_at: new Date().toISOString(), updated_by: "cli", by_agent: false };
2238
-
2239
- // --source-section <key>: target a structured section (e.g. team /
2240
- // highlights / competitors). Without this, blocks land in the
2241
- // "_other" group at the bottom of the TOC. AI writers want this
2242
- // virtually always — without it they cannot contribute to existing
2243
- // structured sections.
2244
- if (flags["source-section"]) {
2245
- meta.sourceSection = String(flags["source-section"]);
2246
- }
2247
-
2248
- // --reply-to <blockId>: snapshot the parent block's heading + a
2249
- // 200-char excerpt into meta so the back-link survives parent edits
2250
- // or deletion. CLI only — replies are always text in the UI; allow
2251
- // any add-* type here for symmetry but the UI only renders the
2252
- // back-link on text + callout blocks today.
2253
- let cur = null;
2254
- if (flags["reply-to"]) {
2255
- const replyTo = String(flags["reply-to"]);
2256
- cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
2257
- const parent = (cur.blocks ?? []).find((b) => b.id === replyTo);
2258
- if (!parent) throw new Error(`--reply-to: block ${replyTo} not found on deal ${dealId}`);
2259
- meta.reply_to = parent.id;
2260
- // heading: text/callout → heading; link/embed → label; fallback "(untitled block)"
2261
- meta.reply_to_heading =
2262
- parent.heading || parent.label || "(untitled block)";
2263
- // excerpt: text/callout → heading + body; link → label + description; embed → label
2264
- const excerptParts =
2265
- parent.type === "text" || parent.type === "callout"
2266
- ? [parent.heading, parent.body]
2267
- : parent.type === "link"
2268
- ? [parent.label, parent.description]
2269
- : [parent.label];
2270
- const excerpt = excerptParts.filter(Boolean).join("\n\n").slice(0, 200);
2271
- meta.reply_to_excerpt = excerpt;
2272
- }
2273
-
2274
- let block;
2275
- if (type === "text") {
2276
- block = { id, type, heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
2277
- } else if (type === "link") {
2278
- if (!flags.url || !flags.label) throw new Error("add-link requires --url and --label");
2279
- block = { id, type, url: String(flags.url), label: String(flags.label), description: flags.description ? String(flags.description) : undefined, meta };
2280
- } else if (type === "embed") {
2281
- if (!flags.url) throw new Error("add-embed requires --url");
2282
- block = { id, type, url: String(flags.url), label: flags.label ? String(flags.label) : undefined, meta };
2283
- } else {
2284
- block = { id, type, tone: flags.tone ? String(flags.tone) : "insight", heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
2285
- }
2286
-
2287
- // --position top|bottom (default: top). Top matches the UI behavior
2288
- // changed 2026-05-03 — newly added blocks land at the top of the
2289
- // brief so the writer (or reader) sees the contribution without
2290
- // scrolling. Pass `--position bottom` to append, e.g. for batched
2291
- // AI writes that should preserve insertion order.
2292
- const position = flags.position ? String(flags.position) : "top";
2293
- if (position !== "top" && position !== "bottom") {
2294
- throw new Error(`--position must be "top" or "bottom" (got "${position}")`);
2295
- }
2296
-
2297
- if (!cur) cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
2298
- const existing = cur.blocks ?? [];
2299
- const next = position === "top" ? [block, ...existing] : [...existing, block];
2300
- print(await request("PUT", `/api/deals/${encodeURIComponent(dealId)}/blocks`, {
2301
- blocks: next,
2302
- cue_authorized: flags.cue === true,
2303
- }));
2304
- console.log(`Created block ${id}`);
2305
- return;
2306
- }
2307
-
2308
- if (area === "brief" && action === "edit") {
2309
- const { flags } = parseFlags(rest);
2310
- const dealId = rest[0];
2311
- const blockId = rest[1];
2312
- if (!dealId || !blockId) {
2313
- throw new Error("Usage: llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--description ...] [--tone ...] [--source-section ...] [--lock|--unlock] [--hide|--unhide] [--cue]");
2314
- }
2315
- const patch = {};
2316
- for (const k of ["heading", "body", "url", "label", "description", "tone"]) {
2317
- if (flags[k] !== undefined && flags[k] !== true) patch[k] = String(flags[k]);
2318
- }
2319
-
2320
- // Meta toggles. The PATCH endpoint accepts a meta object that gets
2321
- // merged with the existing block.meta server-side, so we only need
2322
- // to send the keys we want to change. lock/hide flags are pure
2323
- // toggles (no value); source-section takes a key.
2324
- const metaPatch = {};
2325
- if (flags.lock === true) metaPatch.locked = true;
2326
- if (flags.unlock === true) metaPatch.locked = false;
2327
- if (flags.hide === true) metaPatch.hidden = true;
2328
- if (flags.unhide === true) metaPatch.hidden = false;
2329
- if (flags["source-section"] !== undefined && flags["source-section"] !== true) {
2330
- metaPatch.sourceSection = String(flags["source-section"]);
2331
- }
2332
- if (Object.keys(metaPatch).length > 0) patch.meta = metaPatch;
2333
- if (flags.cue === true) patch.cue_authorized = true;
2334
-
2335
- if (Object.keys(patch).length === 0 || (Object.keys(patch).length === 1 && patch.cue_authorized === true)) {
2336
- throw new Error("at least one field flag required");
2337
- }
2338
- print(await request("PATCH", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`, patch));
2339
- return;
2340
- }
2341
-
2342
- if (area === "brief" && action === "delete") {
2343
- const dealId = rest[0];
2344
- const blockId = rest[1];
2345
- if (!dealId || !blockId) throw new Error("Usage: llama brief delete <dealId> <blockId>");
2346
- print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`));
2347
- return;
2348
- }
2349
-
2350
- if (area === "brief" && action === "restore") {
2351
- const dealId = rest[0];
2352
- const blockId = rest[1];
2353
- if (!dealId || !blockId) throw new Error("Usage: llama brief restore <dealId> <blockId>");
2354
- print(await request(
2355
- "POST",
2356
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/restore`
2357
- ));
2358
- return;
2359
- }
2360
-
2361
- // Per-block content history (Wikipedia model — every overwrite snapshots
2362
- // the prev full block JSON). Two sub-actions: list versions, and restore
2363
- // a specific version. Restore is itself reversible — when you restore
2364
- // version N, the OUTGOING version (the one being replaced) gets snapshotted
2365
- // into history, so undoing a wrong restore is one more `restore-version`
2366
- // call away.
2367
- if (area === "brief" && action === "history") {
2368
- const { flags } = parseFlags(rest);
2369
- const dealId = rest[0];
2370
- const blockId = rest[1];
2371
- if (!dealId || !blockId) {
2372
- throw new Error("Usage: llama brief history <dealId> <blockId> [--limit 50]");
2373
- }
2374
- const params = new URLSearchParams();
2375
- if (flags.limit) params.set("limit", String(flags.limit));
2376
- const qs = params.toString() ? `?${params.toString()}` : "";
2377
- print(await request(
2378
- "GET",
2379
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history${qs}`
2380
- ));
2381
- return;
2382
- }
2383
-
2384
- if (area === "brief" && action === "restore-version") {
2385
- const dealId = rest[0];
2386
- const blockId = rest[1];
2387
- const historyId = rest[2];
2388
- if (!dealId || !blockId || !historyId) {
2389
- throw new Error("Usage: llama brief restore-version <dealId> <blockId> <historyId>\n" +
2390
- " Find <historyId> via `llama brief history <dealId> <blockId>`");
2391
- }
2392
- const idNum = Number(historyId);
2393
- if (!Number.isFinite(idNum)) throw new Error(`<historyId> must be a number, got "${historyId}"`);
2394
- print(await request(
2395
- "POST",
2396
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history`,
2397
- { history_id: idNum }
2398
- ));
2399
- return;
2400
- }
2401
-
2402
- // ----- Admin (system admin only) -----
2403
- // System-admin gated commands — server enforces via isSystemAdmin()
2404
- // checking LLAMA_COMMAND_ADMIN_EMAILS env. Non-admin tokens get 403.
2405
- // CLI doesn't pre-check; if you can't run these, ask the system admin
2406
- // to mint you an admin token (rare — most ops should never need this surface).
2407
- //
2408
- // The three event feeds map 1:1 to the /admin web console tabs:
2409
- // - auth-events : signin / token / impersonation audit (security)
2410
- // - deal-events : every field/owner/brief change cross-deal (business)
2411
- // - agent-events : every AI tool call / loop_stalled / max_turns (AI ops)
2412
- if (area === "admin") {
2413
- const sub = action;
2414
- const valid = ["workflow", "auth-events", "deal-events", "agent-events"];
2415
- if (!valid.includes(sub)) {
2416
- throw new Error(
2417
- `Unknown admin sub-command "${sub || ""}". Use: ${valid.join(", ")}`
2418
- );
2419
- }
2420
- if (sub === "workflow") {
2421
- if (!["audit", "remediate"].includes(rest[0])) {
2422
- throw new Error("Usage: llama admin workflow audit|remediate ...");
2423
- }
2424
- if (rest[0] === "audit") {
2425
- const { flags } = parseFlags(rest.slice(1), ["deal", "all"]);
2426
- // @core-api-operation GET /api/admin/workflow-audit
2427
- print(await request("GET", workflowAuditPath(flags)));
2428
- return;
2429
- }
2430
- const { flags } = parseFlags(rest.slice(1), [
2431
- "deal",
2432
- "guard",
2433
- "apply",
2434
- "expected-revision",
2435
- "reason",
2436
- ]);
2437
- const body = workflowRemediationBody(
2438
- flags,
2439
- flags.apply === true ? `cli-workflow-remediation-${randomUUID()}` : undefined,
2440
- );
2441
- // @core-api-operation POST /api/admin/workflow-remediation
2442
- print(await request("POST", WORKFLOW_REMEDIATION_PATH, body));
2443
- return;
2444
- }
2445
- const { flags } = parseFlags(rest);
2446
- const params = new URLSearchParams();
2447
- // Common filters across all three.
2448
- if (flags.kind) params.set("kind", String(flags.kind));
2449
- if (flags.actor) params.set("actor", String(flags.actor));
2450
- if (flags.subject) params.set("subject", String(flags.subject));
2451
- if (flags.since) params.set("since", String(flags.since));
2452
- if (flags.limit) params.set("limit", String(flags.limit));
2453
- if (flags.offset) params.set("offset", String(flags.offset));
2454
- // Per-feed extras.
2455
- if (sub === "deal-events" && flags.deal) params.set("deal", String(flags.deal));
2456
- if (sub === "agent-events") {
2457
- if (flags["agent-kind"]) params.set("agent_kind", String(flags["agent-kind"]));
2458
- if (flags.tool) params.set("tool", String(flags.tool));
2459
- if (flags.deal) params.set("deal", String(flags.deal));
2460
- if (flags["errors-only"]) params.set("errors_only", "1");
2461
- }
2462
- const qs = params.toString() ? `?${params.toString()}` : "";
2463
- // @core-api-operation GET /api/admin/auth-events
2464
- // @core-api-operation GET /api/admin/deal-events
2465
- // @core-api-operation GET /api/admin/agent-events
2466
- print(await request("GET", `/api/admin/${sub}${qs}`));
2467
- return;
2468
- }
2469
-
2470
- // ----- Mentions / Inbox -----
2471
- // Server stores @-cues parsed out of brief blocks and posts in the
2472
- // `deal_mentions` table. UNIQUE per (source_kind, source_id, user)
2473
- // means re-saving a block that already cued someone won't re-fire
2474
- // the email; resolution is mutual-observability (anyone can mark a
2475
- // thread resolved, we record who).
2476
- //
2477
- // The CLI has no direct create — to "mention someone", write
2478
- // `@FirstName` (or `@email@llamaventures.vc`) inside a brief block
2479
- // body or a deal post. Hooks server-side do the rest.
2480
- if (area === "mentions") {
2481
- const sub = action || "list";
2482
- if (sub === "list" || sub === undefined) {
2483
- const { flags } = parseFlags(rest);
2484
- const params = new URLSearchParams();
2485
- if (flags.everyone) params.set("everyone", "1");
2486
- else params.set("for_me", "1");
2487
- if (!flags.all) params.set("unresolved", "1");
2488
- print(await request("GET", `/api/mentions?${params.toString()}`));
2489
- return;
2490
- }
2491
- if (sub === "show") {
2492
- const id = rest[0];
2493
- if (!id) throw new Error("Usage: llama mentions show <mentionId>");
2494
- // No dedicated single-row endpoint — fetch all and filter. Cheap
2495
- // (mentions table is small) and avoids a roundtrip endpoint.
2496
- const data = await request("GET", "/api/mentions?everyone=1");
2497
- const row = (data.mentions ?? []).find((m) => String(m.id) === String(id));
2498
- if (!row) throw new Error(`mention ${id} not found`);
2499
- print(row);
2500
- return;
2501
- }
2502
- if (sub === "resolve") {
2503
- const id = rest[0];
2504
- if (!id) throw new Error("Usage: llama mentions resolve <mentionId>");
2505
- print(await request("POST", `/api/mentions/${encodeURIComponent(id)}/resolve`));
2506
- return;
2507
- }
2508
- if (sub === "unread") {
2509
- print(await request("GET", "/api/mentions/unread-count"));
2510
- return;
2511
- }
2512
- throw new Error(`Unknown mentions subcommand "${sub}". Use: list / show / resolve / unread.`);
2513
- }
2514
-
2515
- // ----- Memo (long-form HTML investment memo) -----
2516
- // The Memo tab in the deal page renders HTML stored in deal_memos.
2517
- // Two sources of memo content:
2518
- // - composed: generated by the server-side memo composer on demand
2519
- // - manual: a hand-written HTML you paste in
2520
- // Manual always beats composed on read; reset to drop the manual row
2521
- // and fall back to the composed one.
2522
- if (area === "memo") {
2523
- const sub = action;
2524
-
2525
- // show — fetch the current memo. Default: print HTML to stdout
2526
- // (pipeable to file or browser). --out writes to a path. --json
2527
- // returns the full envelope (memo + mode + inflight info).
2528
- if (sub === "show") {
2529
- const dealId = rest[0];
2530
- if (!dealId) {
2531
- throw new Error("Usage: llama memo show <dealId> [--out <path>] [--json]");
2532
- }
2533
- const { flags } = parseFlags(rest.slice(1));
2534
- const data = await request(
2535
- "GET",
2536
- `/api/deals/${encodeURIComponent(dealId)}/memo`
2537
- );
2538
- if (flags.json) {
2539
- print(data);
2540
- return;
2541
- }
2542
- const html = data?.memo?.html;
2543
- if (!html) {
2544
- if (data?.requires_compose) {
2545
- throw new Error(
2546
- "No memo for this deal yet — run `llama memo regenerate <dealId>` to compose one."
2547
- );
2548
- }
2549
- throw new Error("Memo response missing html field.");
2550
- }
2551
- if (flags.out) {
2552
- const { writeFileSync } = await import("fs");
2553
- writeFileSync(String(flags.out), html);
2554
- console.error(`Wrote ${html.length} bytes → ${flags.out}`);
2555
- return;
2556
- }
2557
- // Stdout — supports `llama memo show <id> > memo.html` and piping
2558
- // to e.g. `open -f -a Safari` for quick preview.
2559
- process.stdout.write(html);
2560
- return;
2561
- }
2562
-
2563
- // regenerate — kick off the server-side composer. Streams panel
2564
- // progress events to stderr so you can see live status; prints
2565
- // final summary JSON (version, model, duration) to stdout.
2566
- if (sub === "regenerate") {
2567
- const dealId = rest[0];
2568
- if (!dealId) {
2569
- throw new Error(
2570
- 'Usage: llama memo regenerate <dealId> [--opus] [--instructions "..."]'
2571
- );
2572
- }
2573
- const { flags } = parseFlags(rest.slice(1));
2574
- const tier = flags.opus ? "opus" : "sonnet";
2575
- const authHeaders = await getAuthHeaders();
2576
- if (Object.keys(authHeaders).length === 0) {
2577
- throw new Error(
2578
- "Not authenticated. Run `gcloud auth login` or `llama token set <llc_...>` first."
2579
- );
2580
- }
2581
- const res = await fetch(
2582
- `${getBaseUrl()}/api/deals/${encodeURIComponent(dealId)}/memo`,
2583
- {
2584
- method: "POST",
2585
- headers: { "Content-Type": "application/json", ...authHeaders },
2586
- body: JSON.stringify({
2587
- action: "regenerate",
2588
- stream: true,
2589
- model: tier,
2590
- instructions: flags.instructions
2591
- ? String(flags.instructions)
2592
- : undefined,
2593
- }),
2594
- }
2595
- );
2596
- if (!res.ok || !res.body) {
2597
- const text = await res.text().catch(() => "");
2598
- throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
2599
- }
2600
-
2601
- const reader = res.body.getReader();
2602
- const decoder = new TextDecoder();
2603
- let buffer = "";
2604
- let doneEvent = null;
2605
- const startedAt = Date.now();
2606
- const progress = { done: 0, total: 12, placeholders: 0, retries: 0 };
2607
-
2608
- while (true) {
2609
- const { value, done: streamDone } = await reader.read();
2610
- if (streamDone) break;
2611
- buffer += decoder.decode(value, { stream: true });
2612
- let idx;
2613
- while ((idx = buffer.indexOf("\n\n")) !== -1) {
2614
- const frame = buffer.slice(0, idx);
2615
- buffer = buffer.slice(idx + 2);
2616
- const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
2617
- if (!dataLine) continue;
2618
- let event;
2619
- try {
2620
- event = JSON.parse(dataLine.replace(/^data:\s?/, ""));
2621
- } catch {
2622
- continue;
2623
- }
2624
- const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
2625
- const phase = event.phase || "?";
2626
- if (phase === "panel_done") {
2627
- progress.done = event.panels_completed ?? progress.done + 1;
2628
- progress.total = event.panels_total ?? progress.total;
2629
- if (event.status === "placeholder") progress.placeholders += 1;
2630
- if (event.status === "retry-recovered") progress.retries += 1;
2631
- const mark =
2632
- event.status === "ok"
2633
- ? "✓"
2634
- : event.status === "retry-recovered"
2635
- ? "↻"
2636
- : "⚠";
2637
- console.error(
2638
- `${elapsed}s ${mark} ${event.panel} [${progress.done}/${progress.total}]`
2639
- );
2640
- } else if (phase === "anchor_done") {
2641
- console.error(
2642
- `${elapsed}s anchor → ${event.verdict_label || event.verdict}`
2643
- );
2644
- } else if (phase === "assembling") {
2645
- console.error(`${elapsed}s assembling…`);
2646
- } else if (phase === "done") {
2647
- doneEvent = event;
2648
- } else if (phase === "error") {
2649
- throw new Error(`Memo composer error: ${event.error}`);
2650
- }
2651
- }
2652
- }
2653
-
2654
- if (!doneEvent) {
2655
- throw new Error("Stream ended without 'done' event.");
2656
- }
2657
- print({
2658
- ok: true,
2659
- version: doneEvent.version,
2660
- degraded: doneEvent.degraded,
2661
- model: doneEvent.model,
2662
- duration_ms: doneEvent.duration_ms,
2663
- placeholders: progress.placeholders,
2664
- retries: progress.retries,
2665
- });
2666
- return;
2667
- }
2668
-
2669
- // save — upload hand-written HTML as a manual override.
2670
- if (sub === "save") {
2671
- const { flags } = parseFlags(rest);
2672
- const dealId = rest[0];
2673
- if (!dealId || !flags.file) {
2674
- throw new Error("Usage: llama memo save <dealId> --file <path>");
2675
- }
2676
- const { readFileSync } = await import("fs");
2677
- const html = readFileSync(String(flags.file), "utf-8");
2678
- if (!html.trim()) throw new Error(`File ${flags.file} is empty.`);
2679
- print(
2680
- await request(
2681
- "PUT",
2682
- `/api/deals/${encodeURIComponent(dealId)}/memo`,
2683
- { html }
2684
- )
2685
- );
2686
- return;
2687
- }
2688
-
2689
- // reset — default drops only the manual override (next read returns
2690
- // the composed row, if any); --all drops every version for this deal.
2691
- if (sub === "reset") {
2692
- const dealId = rest[0];
2693
- if (!dealId) {
2694
- throw new Error("Usage: llama memo reset <dealId> [--all]");
2695
- }
2696
- const { flags } = parseFlags(rest.slice(1));
2697
- print(
2698
- await request(
2699
- "DELETE",
2700
- `/api/deals/${encodeURIComponent(dealId)}/memo`,
2701
- { scope: flags.all ? "all" : "override_only" }
2702
- )
2703
- );
2704
- return;
2705
- }
2706
-
2707
- throw new Error(
2708
- `Unknown memo subcommand "${sub || ""}". Use: show / regenerate / save / reset.`
2709
- );
2710
- }
2711
-
2712
- // ============================================================
2713
- // `llama html` family — per-deal hand-authored HTML "deal page"
2714
- // ============================================================
2715
- //
2716
- // Each deal can have its own HTML browse view (sandboxed iframe).
2717
- // Upload via this CLI, or directly via the web UI's drag-drop / paste,
2718
- // or by the in-app deal agent via the update_deal_browse_html tool.
2719
- // Every upload creates a new monotonic version; old versions are
2720
- // soft-deleted on replace and can be restored.
2721
- //
2722
- // llama html show <dealId> [--out PATH] [--json]
2723
- // llama html upload <dealId> --file PATH [--source cli|web|agent]
2724
- // llama html versions <dealId>
2725
- // llama html restore <dealId> <version>
2726
- // llama html reset <dealId>
2727
- if (area === "html") {
2728
- const sub = action;
2729
-
2730
- // --doc <slug> selects which named document on the deal (default 'main').
2731
- // Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
2732
- // to list available slugs.
2733
- const MAX_HTML_BYTES = 5 * 1024 * 1024;
2734
- const MAX_ASSET_BYTES = 50 * 1024 * 1024;
2735
- const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
2736
-
2737
- function htmlEndpoint(dealId, slug) {
2738
- return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
2739
- }
2740
-
2741
- function looksLikeHtml(html) {
2742
- const head = String(html || "").trim().slice(0, 256).toLowerCase();
2743
- return head.startsWith("<!doctype html") || head.startsWith("<html");
2744
- }
2745
-
2746
- function extractHtmlTitle(html) {
2747
- const title = String(html || "").match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
2748
- if (!title) return null;
2749
- const clean = title.replace(/\s+/g, " ").trim();
2750
- return clean ? clean.slice(0, 200) : null;
2751
- }
2752
-
2753
- function docHasHtml(d) {
2754
- return Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
2755
- }
2756
-
2757
- function findDocBySlug(docs, slug) {
2758
- return docs.find((d) => d && d.slug === slug) || null;
2759
- }
2760
-
2761
- function nextAvailableSlug(base, docs) {
2762
- let candidate = base;
2763
- let suffix = 2;
2764
- while (findDocBySlug(docs, candidate)) {
2765
- candidate = `${base.slice(0, Math.max(1, 64 - String(suffix).length - 1))}-${suffix}`;
2766
- suffix += 1;
2767
- }
2768
- return candidate;
2769
- }
2770
-
2771
- function mimeForAsset(path) {
2772
- const ext = (String(path).split(".").pop() || "").toLowerCase();
2773
- return (
2774
- {
2775
- jpg: "image/jpeg",
2776
- jpeg: "image/jpeg",
2777
- png: "image/png",
2778
- gif: "image/gif",
2779
- webp: "image/webp",
2780
- svg: "image/svg+xml",
2781
- ico: "image/x-icon",
2782
- avif: "image/avif",
2783
- css: "text/css",
2784
- js: "text/javascript",
2785
- json: "application/json",
2786
- woff: "font/woff",
2787
- woff2: "font/woff2",
2788
- ttf: "font/ttf",
2789
- otf: "font/otf",
2790
- mp4: "video/mp4",
2791
- webm: "video/webm",
2792
- pdf: "application/pdf",
2793
- }[ext] || "application/octet-stream"
2794
- );
2795
- }
2796
-
2797
- async function listHtmlDocs(dealId) {
2798
- const docList = await request(
2799
- "GET",
2800
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
2801
- );
2802
- return Array.isArray(docList?.documents) ? docList.documents : [];
2803
- }
2804
-
2805
- async function resolveDealForHtmlPublish(dealRef) {
2806
- const ref = String(dealRef || "").trim();
2807
- if (!ref) throw new Error("deal id or name is required");
2808
- try {
2809
- await listHtmlDocs(ref);
2810
- return { dealId: ref, resolvedFrom: "id" };
2811
- } catch {
2812
- // Not a readable deal id; fall through to pipeline search.
2813
- }
2814
-
2815
- const result = await searchDeals(ref, { limit: 10 });
2816
- const deals = Array.isArray(result?.deals) ? result.deals : [];
2817
- if (deals.length === 0) {
2818
- throw new Error(
2819
- `No deal matched "${ref}". Run \`llama deal search "${ref}"\` first and pass the exact deal id.`,
2820
- );
2821
- }
2822
- const exact = deals.filter(
2823
- (d) => String(d.companyName || "").toLowerCase() === ref.toLowerCase(),
2824
- );
2825
- const candidates = exact.length > 0 ? exact : deals;
2826
- if (candidates.length !== 1) {
2827
- const lines = candidates
2828
- .slice(0, 8)
2829
- .map((d) => `- ${d.companyName || "(unnamed)"} — ${d.uuid || d.id}`)
2830
- .join("\n");
2831
- throw new Error(
2832
- `Deal name "${ref}" matched multiple records. Re-run with the exact deal id:\n${lines}`,
2833
- );
2834
- }
2835
- const dealId = candidates[0]?.uuid || candidates[0]?.id;
2836
- if (!dealId) {
2837
- throw new Error(`Deal search matched "${ref}" but did not return a deal id.`);
2838
- }
2839
- return {
2840
- dealId,
2841
- dealName: candidates[0]?.companyName || ref,
2842
- resolvedFrom: "search",
2843
- };
2844
- }
2845
-
2846
- async function detectSiblingAssetsDir(filePath) {
2847
- const { existsSync, statSync } = await import("fs");
2848
- const { dirname, basename, extname, join } = await import("path");
2849
- const dir = dirname(filePath);
2850
- const ext = extname(filePath);
2851
- const stem = basename(filePath, ext);
2852
- const candidates = [
2853
- `${stem}_files`,
2854
- `${stem} files`,
2855
- `${basename(filePath)}_files`,
2856
- ];
2857
- for (const name of candidates) {
2858
- const p = join(dir, name);
2859
- if (existsSync(p) && statSync(p).isDirectory()) return p;
2860
- }
2861
- return null;
2862
- }
2863
-
2864
- async function collectAssets(assetsRoot) {
2865
- const { readFileSync, readdirSync, statSync } = await import("fs");
2866
- const { join, relative, sep, basename } = await import("path");
2867
- const rootStat = statSync(assetsRoot);
2868
- if (!rootStat.isDirectory()) {
2869
- throw new Error(`assets path must be a directory: ${assetsRoot}`);
2870
- }
2871
- const collected = [];
2872
- const walk = (dir) => {
2873
- for (const name of readdirSync(dir)) {
2874
- const absPath = join(dir, name);
2875
- const st = statSync(absPath);
2876
- if (st.isDirectory()) {
2877
- walk(absPath);
2878
- } else if (st.isFile()) {
2879
- const relPath = relative(assetsRoot, absPath).split(sep).join("/");
2880
- collected.push({ absPath, relPath, bytes: st.size });
2881
- }
2882
- }
2883
- };
2884
- walk(assetsRoot);
2885
- if (collected.length === 0) {
2886
- throw new Error(`assets directory is empty: ${assetsRoot}`);
2887
- }
2888
- const rootName = basename(assetsRoot);
2889
- const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
2890
- const finalPaths = looksLikeSavePageDir
2891
- ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
2892
- : collected;
2893
- let totalBytes = 0;
2894
- for (const item of finalPaths) {
2895
- if (item.relPath.split("/").some((seg) => seg === "..")) {
2896
- throw new Error(`asset path "${item.relPath}" contains "..", refused`);
2897
- }
2898
- if (item.bytes > MAX_ASSET_BYTES) {
2899
- throw new Error(
2900
- `asset "${item.relPath}" is ${item.bytes} bytes; cap is ${MAX_ASSET_BYTES}`,
2901
- );
2902
- }
2903
- totalBytes += item.bytes;
2904
- if (totalBytes > MAX_BUNDLE_BYTES) {
2905
- throw new Error(`total asset bytes exceeds ${MAX_BUNDLE_BYTES}`);
2906
- }
2907
- }
2908
- return {
2909
- assets: finalPaths.map((item) => ({
2910
- ...item,
2911
- data: readFileSync(item.absPath),
2912
- contentType: mimeForAsset(item.relPath),
2913
- })),
2914
- totalBytes,
2915
- };
2916
- }
2917
-
2918
- async function uploadHtmlPayload({ dealId, slug, html, source, assetsDir, uploadId }) {
2919
- if (!assetsDir) {
2920
- return request("PUT", htmlEndpoint(dealId, slug), {
2921
- html,
2922
- source,
2923
- client_upload_id: uploadId,
2924
- }, {
2925
- headers: { "X-Llama-Upload-Id": uploadId },
2926
- });
2927
- }
2928
- const { assets, totalBytes } = await collectAssets(assetsDir);
2929
- const form = new FormData();
2930
- form.append("html", html);
2931
- form.append("source", source);
2932
- form.append("client_upload_id", uploadId);
2933
- for (const asset of assets) {
2934
- form.append(
2935
- `asset:${asset.relPath}`,
2936
- new Blob([asset.data], { type: asset.contentType }),
2937
- asset.relPath,
2938
- );
2939
- }
2940
- console.error(
2941
- `Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${assets.length} assets (${totalBytes} bytes)`,
2942
- );
2943
- const headers = await getAuthHeaders();
2944
- const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
2945
- method: "PUT",
2946
- headers: { ...headers, "X-Llama-Upload-Id": uploadId },
2947
- body: form,
2948
- });
2949
- const body = await res.json().catch(() => ({}));
2950
- if (!res.ok) {
2951
- throw new Error(
2952
- `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
2953
- );
2954
- }
2955
- return body;
2956
- }
2957
-
2958
- async function verifyHtmlUpload({ dealId, slug, expectedVersion, expectedBytes, expectedSha256 }) {
2959
- const latest = await request("GET", htmlEndpoint(dealId, slug));
2960
- if (latest?.empty) {
2961
- throw new Error(`verification failed: ${slug} came back empty after upload`);
2962
- }
2963
- if (expectedVersion != null && Number(latest.version) !== Number(expectedVersion)) {
2964
- throw new Error(
2965
- `verification failed: expected version ${expectedVersion}, got ${latest.version}`,
2966
- );
2967
- }
2968
- if (
2969
- expectedBytes != null &&
2970
- latest.bytes != null &&
2971
- Number(latest.bytes) !== Number(expectedBytes)
2972
- ) {
2973
- throw new Error(
2974
- `verification failed: expected ${expectedBytes} bytes, got ${latest.bytes}`,
2975
- );
2976
- }
2977
- if (
2978
- expectedSha256 &&
2979
- latest.sha256 &&
2980
- String(latest.sha256) !== String(expectedSha256)
2981
- ) {
2982
- throw new Error(
2983
- `verification failed: expected sha256 ${expectedSha256}, got ${latest.sha256}`,
2984
- );
2985
- }
2986
- return {
2987
- ok: true,
2988
- version: latest.version,
2989
- bytes: latest.bytes,
2990
- sha256: latest.sha256,
2991
- created_at: latest.created_at,
2992
- };
2993
- }
2994
-
2995
- // Surface a clean `linked_wiki` field on linked docs so the listing
2996
- // reads as "this card points at wiki/<slug>" rather than exposing the
2997
- // raw source_wiki_* columns. Non-linked docs are returned unchanged.
2998
- function withLinkedWiki(data) {
2999
- if (!data || !Array.isArray(data.documents)) return data;
3000
- return {
3001
- ...data,
3002
- documents: data.documents.map((d) =>
3003
- d && d.source_wiki_slug
3004
- ? {
3005
- ...d,
3006
- linked_wiki: {
3007
- slug: d.source_wiki_slug,
3008
- lang: d.source_wiki_lang || "en",
3009
- },
3010
- }
3011
- : d,
3012
- ),
3013
- };
3014
- }
3015
-
3016
- // docs — list / create / archive documents on a deal.
3017
- //
3018
- // Forms:
3019
- // llama html docs <dealId> # list
3020
- // llama html docs list <dealId> # list (explicit)
3021
- // llama html docs create <dealId> <slug> [--title "..."]
3022
- // llama html docs archive <dealId> <slug>
3023
- if (sub === "docs") {
3024
- const docSub = rest[0];
3025
- const isExplicitSubcommand =
3026
- docSub === "list" ||
3027
- docSub === "create" ||
3028
- docSub === "archive";
3029
- if (!isExplicitSubcommand) {
3030
- // First positional is the dealId (the common "just list" case).
3031
- const dealId = rest[0];
3032
- if (!dealId) {
3033
- throw new Error(
3034
- "Usage: llama html docs <dealId>\n" +
3035
- " llama html docs create <dealId> <slug> --title \"...\"\n" +
3036
- " llama html docs archive <dealId> <slug>",
3037
- );
3038
- }
3039
- const data = await request(
3040
- "GET",
3041
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3042
- );
3043
- print(withLinkedWiki(data));
3044
- return;
3045
- }
3046
- if (docSub === "list") {
3047
- const dealId = rest[1];
3048
- if (!dealId) {
3049
- throw new Error("Usage: llama html docs list <dealId>");
3050
- }
3051
- const data = await request(
3052
- "GET",
3053
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3054
- );
3055
- print(withLinkedWiki(data));
3056
- return;
3057
- }
3058
- if (docSub === "create") {
3059
- const dealId = rest[1];
3060
- const slug = rest[2];
3061
- if (!dealId || !slug) {
3062
- throw new Error(
3063
- "Usage: llama html docs create <dealId> <slug> [--title \"...\"]",
3064
- );
3065
- }
3066
- const { flags } = parseFlags(rest.slice(3));
3067
- const title = flags.title ? String(flags.title) : slug;
3068
- const data = await request(
3069
- "POST",
3070
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3071
- { slug, title },
3072
- );
3073
- print(data);
3074
- return;
3075
- }
3076
- if (docSub === "archive") {
3077
- const dealId = rest[1];
3078
- const slug = rest[2];
3079
- if (!dealId || !slug) {
3080
- throw new Error("Usage: llama html docs archive <dealId> <slug>");
3081
- }
3082
- const data = await request(
3083
- "DELETE",
3084
- `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
3085
- );
3086
- print(data);
3087
- return;
3088
- }
3089
- throw new Error(
3090
- `Unknown html docs subcommand "${docSub}". Use: list / create / archive.`,
3091
- );
3092
- }
3093
-
3094
- // link — turn a deal doc card into a live, read-only pointer to a wiki
3095
- // HTML article. "One file, multiple entrances": the wiki stays the
3096
- // canonical home, the card just renders the wiki's HTML. Edits go to
3097
- // the wiki source; uploads to a linked slug are refused server-side.
3098
- //
3099
- // llama html link <dealId> --wiki <slug> [--lang en|zh] [--title "..."]
3100
- //
3101
- // Default deal-side slug = the wiki slug. Default title = the wiki
3102
- // article's title (fetched from `llama wiki read`).
3103
- if (sub === "link") {
3104
- const dealId = rest[0];
3105
- const { flags } = parseFlags(rest.slice(1), ["wiki", "lang", "title", "slug"]);
3106
- const wikiSlug =
3107
- typeof flags.wiki === "string" && flags.wiki.trim()
3108
- ? flags.wiki.trim()
3109
- : null;
3110
- if (!dealId || !wikiSlug) {
3111
- throw new Error(
3112
- "Usage: llama html link <dealId> --wiki <slug> [--lang en|zh] [--title \"...\"]",
3113
- );
3114
- }
3115
- const lang = flags.lang === "zh" ? "zh" : "en";
3116
- // Deal-side slug defaults to the wiki slug; --slug overrides.
3117
- const dealSlug =
3118
- typeof flags.slug === "string" && flags.slug.trim()
3119
- ? flags.slug.trim()
3120
- : wikiSlug;
3121
- // Title defaults to the wiki article's title.
3122
- let title =
3123
- typeof flags.title === "string" && flags.title.trim()
3124
- ? flags.title.trim()
3125
- : null;
3126
- if (!title) {
3127
- try {
3128
- const article = await request(
3129
- "GET",
3130
- `/api/wiki/${encodeURIComponent(wikiSlug)}?lang=${lang}`,
3131
- );
3132
- title = article?.frontmatter?.title || wikiSlug;
3133
- } catch {
3134
- // Fall back to the slug as the title; the server still validates
3135
- // that the wiki article exists + is HTML on the POST below.
3136
- title = wikiSlug;
3137
- }
3138
- }
3139
- const data = await request(
3140
- "POST",
3141
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3142
- {
3143
- slug: dealSlug,
3144
- title,
3145
- source_wiki_slug: wikiSlug,
3146
- source_wiki_lang: lang,
3147
- },
3148
- );
3149
- print(data);
3150
- return;
3151
- }
3152
-
3153
- // unlink — revert a linked card back to a normal self-hosted doc.
3154
- // llama html unlink <dealId> <slug>
3155
- if (sub === "unlink") {
3156
- const dealId = rest[0];
3157
- const slug = rest[1];
3158
- if (!dealId || !slug) {
3159
- throw new Error("Usage: llama html unlink <dealId> <slug>");
3160
- }
3161
- const data = await request(
3162
- "PATCH",
3163
- `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
3164
- { source_wiki_slug: null },
3165
- );
3166
- print(data);
3167
- return;
3168
- }
3169
-
3170
- // show — fetch the current HTML. Default: print to stdout (pipeable).
3171
- if (sub === "show") {
3172
- const dealId = rest[0];
3173
- if (!dealId) {
3174
- throw new Error("Usage: llama html show <dealId> [--doc SLUG] [--out PATH] [--json]");
3175
- }
3176
- const { flags } = parseFlags(rest.slice(1));
3177
- const slug = typeof flags.doc === "string" && flags.doc.trim() ? flags.doc.trim() : "main";
3178
- const data = await request("GET", htmlEndpoint(dealId, slug));
3179
- if (flags.json) {
3180
- print(data);
3181
- return;
3182
- }
3183
- if (data?.empty) {
3184
- throw new Error(
3185
- `No HTML uploaded for deal ${dealId} yet. Upload via \`llama html upload\`, the web UI, or have the deal agent write it.`,
3186
- );
3187
- }
3188
- const html = data?.html;
3189
- if (typeof html !== "string") {
3190
- throw new Error("browse-html response missing html field.");
3191
- }
3192
- if (flags.out) {
3193
- const { writeFileSync } = await import("fs");
3194
- writeFileSync(String(flags.out), html);
3195
- console.error(
3196
- `Wrote ${html.length} bytes (v${data.version}) → ${flags.out}`,
3197
- );
3198
- return;
3199
- }
3200
- // Stdout — supports `llama html show <id> > page.html` and piping
3201
- // to e.g. `open -f -a Safari` for quick preview.
3202
- process.stdout.write(html);
3203
- return;
3204
- }
3205
-
3206
- // publish — agent-safe high-level upload path. The agent gives us a file
3207
- // path + a deal id/name; the CLI handles search, slug decisions, asset
3208
- // discovery, upload, and read-after-write verification.
3209
- if (sub === "publish") {
3210
- const dealRef = rest[0];
3211
- const knownFlags = [
3212
- "file", "title", "doc", "slug", "new", "update",
3213
- "assets", "no-auto-assets", "source", "no-verify", "upload-id",
3214
- ];
3215
- const { flags } = parseFlags(rest.slice(1), knownFlags);
3216
- if (!dealRef || !flags.file || flags.file === true) {
3217
- throw new Error(
3218
- "Usage: llama html publish <deal-id-or-name> --file PATH [--title \"...\"] [--doc <slug>] [--update|--new] [--assets DIR]",
3219
- );
3220
- }
3221
- if (flags.slug && !flags.doc) {
3222
- process.stderr.write("note: --slug accepted as alias for --doc.\n");
3223
- flags.doc = flags.slug;
3224
- }
3225
- const wantsNew = boolFlag(flags, "new");
3226
- const wantsUpdate = boolFlag(flags, "update");
3227
- if (wantsNew && wantsUpdate) {
3228
- throw new Error("Choose only one of --new or --update.");
3229
- }
3230
-
3231
- const filePath = String(flags.file);
3232
- const { readFileSync, statSync } = await import("fs");
3233
- const { basename, extname } = await import("path");
3234
- const fileStat = statSync(filePath);
3235
- if (!fileStat.isFile()) {
3236
- throw new Error(`--file must point to a readable HTML file: ${filePath}`);
3237
- }
3238
- const html = readFileSync(filePath, "utf8");
3239
- if (!html.trim()) throw new Error("HTML body is empty.");
3240
- const htmlBytes = Buffer.byteLength(html, "utf8");
3241
- if (htmlBytes > MAX_HTML_BYTES) {
3242
- throw new Error(
3243
- `HTML body is ${(htmlBytes / 1024 / 1024).toFixed(2)} MB; cap is 5 MB. Put large media in an asset folder or Drive, not inline HTML.`,
3244
- );
3245
- }
3246
- if (!looksLikeHtml(html)) {
3247
- throw new Error("HTML must start with <!doctype html> or <html.");
3248
- }
3249
-
3250
- const resolved = await resolveDealForHtmlPublish(dealRef);
3251
- const docs = await listHtmlDocs(resolved.dealId);
3252
- const explicitDoc =
3253
- typeof flags.doc === "string" && flags.doc.trim()
3254
- ? flags.doc.trim()
3255
- : null;
3256
- const title =
3257
- typeof flags.title === "string" && flags.title.trim()
3258
- ? flags.title.trim()
3259
- : extractHtmlTitle(html) || basename(filePath, extname(filePath));
3260
- let slug;
3261
- let mode;
3262
- let createdMetadata = false;
3263
-
3264
- if (explicitDoc) {
3265
- if (!isValidDocSlug(explicitDoc)) {
3266
- throw new Error(
3267
- `slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
3268
- );
3269
- }
3270
- const existingDoc = findDocBySlug(docs, explicitDoc);
3271
- if (wantsNew && existingDoc) {
3272
- throw new Error(
3273
- `--new requested, but document "${explicitDoc}" already exists on this deal.`,
3274
- );
3275
- }
3276
- if (wantsUpdate && !existingDoc) {
3277
- throw new Error(
3278
- `--update requested, but document "${explicitDoc}" does not exist on this deal.`,
3279
- );
3280
- }
3281
- slug = explicitDoc;
3282
- mode = existingDoc && docHasHtml(existingDoc) ? "updated" : "created";
3283
- if (!existingDoc) createdMetadata = true;
3284
- } else {
3285
- const baseSlug = slugifyTitle(title) || slugifyTitle(basename(filePath, extname(filePath)));
3286
- if (!baseSlug) {
3287
- throw new Error(
3288
- "Could not derive a valid slug from the title or filename. Pass --doc <slug>.",
3289
- );
3290
- }
3291
- const existingDoc = findDocBySlug(docs, baseSlug);
3292
- if (wantsUpdate) {
3293
- if (!existingDoc) {
3294
- throw new Error(
3295
- `--update requested, but derived document "${baseSlug}" does not exist. Pass --doc <existing-slug> or drop --update to create a new doc.`,
3296
- );
3297
- }
3298
- slug = baseSlug;
3299
- mode = docHasHtml(existingDoc) ? "updated" : "created";
3300
- } else {
3301
- slug = existingDoc ? nextAvailableSlug(baseSlug, docs) : baseSlug;
3302
- mode = "created";
3303
- createdMetadata = true;
3304
- if (existingDoc) {
3305
- process.stderr.write(
3306
- `note: "${baseSlug}" already exists; publishing as new document "${slug}". Use --update or --doc ${baseSlug} to replace it.\n`,
3307
- );
3308
- }
3309
- }
3310
- }
3311
-
3312
- if (createdMetadata) {
3313
- await request(
3314
- "POST",
3315
- `/api/deals/${encodeURIComponent(resolved.dealId)}/documents`,
3316
- { slug, title },
3317
- );
3318
- }
3319
-
3320
- let assetsDir =
3321
- typeof flags.assets === "string" && flags.assets.trim()
3322
- ? flags.assets.trim()
3323
- : null;
3324
- if (!assetsDir && !boolFlag(flags, "no-auto-assets")) {
3325
- assetsDir = await detectSiblingAssetsDir(filePath);
3326
- if (assetsDir) {
3327
- process.stderr.write(`note: auto-detected asset folder ${assetsDir}\n`);
3328
- }
3329
- }
3330
- const source =
3331
- typeof flags.source === "string" && flags.source.trim()
3332
- ? flags.source.trim()
3333
- : "cli";
3334
- const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
3335
- const uploaded = await uploadHtmlPayload({
3336
- dealId: resolved.dealId,
3337
- slug,
3338
- html,
3339
- source,
3340
- assetsDir,
3341
- uploadId,
3342
- });
3343
- const verification = boolFlag(flags, "no-verify")
3344
- ? { ok: false, skipped: true }
3345
- : await verifyHtmlUpload({
3346
- dealId: resolved.dealId,
3347
- slug,
3348
- expectedVersion: uploaded?.version,
3349
- expectedBytes: uploaded?.bytes,
3350
- expectedSha256: uploaded?.sha256,
3351
- });
3352
-
3353
- print({
3354
- ok: true,
3355
- mode,
3356
- deal_uuid: resolved.dealId,
3357
- resolved_from: resolved.resolvedFrom,
3358
- deal_name: resolved.dealName,
3359
- document_slug: slug,
3360
- title,
3361
- version: uploaded?.version,
3362
- bytes: uploaded?.bytes ?? verification.bytes ?? htmlBytes,
3363
- sha256: uploaded?.sha256 ?? verification.sha256,
3364
- client_upload_id: uploaded?.client_upload_id ?? uploadId,
3365
- idempotent_replay: uploaded?.idempotent_replay,
3366
- asset_count: uploaded?.asset_count,
3367
- asset_bytes: uploaded?.asset_bytes,
3368
- verified: verification,
3369
- viewer: `${getBaseUrl()}/deals/${encodeURIComponent(resolved.dealId)}/browse/${encodeURIComponent(slug)}`,
3370
- });
3371
- return;
3372
- }
3373
-
3374
- // upload — PUT a new version. Reads HTML from --file or stdin. With
3375
- // --assets <dir>, walks the folder, packages as a multipart bundle,
3376
- // and the server stores HTML + per-asset BYTEA rows atomically
3377
- // (deal_browse_assets table). Perfect for "Save Page As Complete"
3378
- // exports — the sibling `_files/` folder maps 1-to-1 to assets.
3379
- if (sub === "upload") {
3380
- const dealId = rest[0];
3381
- if (!dealId) {
3382
- throw new Error(
3383
- "Usage:\n" +
3384
- " Update an existing artifact:\n" +
3385
- " llama html upload <dealId> --doc <slug> --file PATH [--assets DIR]\n" +
3386
- " Create a new artifact:\n" +
3387
- " llama html upload <dealId> --new --title \"...\" --file PATH [--doc <slug>]\n" +
3388
- " Stream from stdin (either form above with --stdin in place of --file PATH).\n" +
3389
- "\n" +
3390
- "Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'\n" +
3391
- "already has content — pass --doc main to update it explicitly, or\n" +
3392
- "--new --title \"...\" to add a NEW artifact alongside.\n" +
3393
- "\n" +
3394
- "Routing — is this the right command?\n" +
3395
- " ✓ DEAL-specific HTML (IC memo for X, dashboard for X, 2×2 for X)\n" +
3396
- " → YES, you're in the right place. Pass <dealId> + --new / --doc.\n" +
3397
- " ✗ Cross-deal / institutional knowledge (sector landscape, market map,\n" +
3398
- " thesis, framework, methodology, anything not tied to one company)\n" +
3399
- " → use `llama wiki save <slug> --title \"...\" --file <path>.html --sources \"...\"`\n" +
3400
- " instead (renders at /wiki/<slug>).\n" +
3401
- " ✗ Founder-facing public share link\n" +
3402
- " → escape to Netlify only when the user explicitly says \"share publicly\";\n" +
3403
- " Llama Command outranks Netlify for everything internal.",
3404
- );
3405
- }
3406
- const knownFlags = [
3407
- "doc", "slug", "new", "title",
3408
- "file", "stdin", "assets", "source", "upload-id",
3409
- ];
3410
- const { flags } = parseFlags(rest.slice(1), knownFlags);
3411
-
3412
- // --slug is the natural agent guess (DB column is `document_slug`).
3413
- // Accept it as an alias for --doc so the earlier failure mode
3414
- // (silent fall-through to 'main') can't happen again.
3415
- if (flags.slug && !flags.doc) {
3416
- process.stderr.write("note: --slug accepted as alias for --doc.\n");
3417
- flags.doc = flags.slug;
3418
- } else if (flags.slug && flags.doc) {
3419
- process.stderr.write("note: both --doc and --slug given; --doc wins.\n");
3420
- }
3421
-
3422
- const isNew = Boolean(flags.new);
3423
- const explicitDoc =
3424
- typeof flags.doc === "string" && flags.doc.trim()
3425
- ? flags.doc.trim()
3426
- : null;
3427
- const titleFlag =
3428
- typeof flags.title === "string" && flags.title.trim()
3429
- ? flags.title.trim()
3430
- : null;
3431
-
3432
- // Pre-flight: ask the server what slugs already exist on this deal.
3433
- // One extra GET round-trip — cheap insurance against silent overwrite.
3434
- let existing = [];
3435
- try {
3436
- const docList = await request(
3437
- "GET",
3438
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3439
- );
3440
- existing = Array.isArray(docList?.documents) ? docList.documents : [];
3441
- } catch (err) {
3442
- // If the deal exists but the list endpoint somehow errors, we
3443
- // shouldn't block the whole upload — surface the warning and
3444
- // proceed in "no existing docs" mode. The server is still the
3445
- // ultimate gate for permission failures.
3446
- process.stderr.write(
3447
- `warning: could not pre-check existing documents (${err.message}). Continuing.\n`,
3448
- );
3449
- }
3450
- const findDoc = (s) =>
3451
- existing.find((d) => d && d.slug === s) || null;
3452
- const docHasHtml = (d) =>
3453
- Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
3454
-
3455
- let slug;
3456
- let mode; // 'created' | 'updated'
3457
-
3458
- if (isNew) {
3459
- // Create-new branch. Caller must provide --doc OR --title (we
3460
- // derive the slug from the title in the latter case).
3461
- let candidate = explicitDoc || (titleFlag ? slugifyTitle(titleFlag) : null);
3462
- if (!candidate) {
3463
- throw new Error(
3464
- "--new requires --doc <slug> or --title \"...\" so the new artifact has a stable identifier.",
3465
- );
3466
- }
3467
- if (!isValidDocSlug(candidate)) {
3468
- throw new Error(
3469
- `slug "${candidate}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
3470
- );
3471
- }
3472
- if (findDoc(candidate)) {
3473
- if (explicitDoc) {
3474
- const existingDoc = findDoc(candidate);
3475
- const meta = docHasHtml(existingDoc)
3476
- ? ` (currently at v${existingDoc.latest_version}, last update ${existingDoc.latest_updated_at})`
3477
- : "";
3478
- throw new Error(
3479
- `--new --doc ${candidate} but a document with slug "${candidate}" already exists${meta}.\n` +
3480
- `Pick a different slug, or drop --new to UPDATE the existing one.`,
3481
- );
3482
- }
3483
- // Auto-resolve title collisions: foo -> foo-2 -> foo-3 -> ...
3484
- let suffix = 2;
3485
- while (findDoc(`${candidate}-${suffix}`)) suffix++;
3486
- const oldCandidate = candidate;
3487
- candidate = `${candidate}-${suffix}`;
3488
- process.stderr.write(
3489
- `note: slug "${oldCandidate}" already in use; using "${candidate}" instead.\n`,
3490
- );
3491
- }
3492
- slug = candidate;
3493
- mode = "created";
3494
- // Stamp the doc metadata first (title, etc.) so the UI selection
3495
- // page shows a nice name. PUT auto-creates the row too, but
3496
- // POST gives us a chance to set --title.
3497
- await request(
3498
- "POST",
3499
- `/api/deals/${encodeURIComponent(dealId)}/documents`,
3500
- { slug, title: titleFlag || slug },
3501
- );
3502
- } else if (explicitDoc) {
3503
- // Update an existing slug.
3504
- if (!isValidDocSlug(explicitDoc)) {
3505
- throw new Error(
3506
- `slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
3507
- );
3508
- }
3509
- const target = findDoc(explicitDoc);
3510
- if (!target) {
3511
- if (explicitDoc === "main") {
3512
- // 'main' is the legacy default — fine to auto-init on first
3513
- // upload to an empty deal.
3514
- slug = "main";
3515
- mode = "created";
3516
- } else {
3517
- const slugList = existing.length
3518
- ? existing.map((d) => d.slug).join(", ")
3519
- : "(none)";
3520
- throw new Error(
3521
- `No document with slug "${explicitDoc}" exists on this deal.\n` +
3522
- `To create it: add --new --title "..."\n` +
3523
- `Or pre-create: llama html docs create ${dealId} ${explicitDoc} --title "..."\n` +
3524
- `Existing slugs: ${slugList}`,
3525
- );
3526
- }
3527
- } else {
3528
- slug = explicitDoc;
3529
- mode = docHasHtml(target) ? "updated" : "created";
3530
- }
3531
- } else {
3532
- // Bare upload — no --doc, no --new. Safe-default to 'main' only
3533
- // if 'main' is empty / absent. Otherwise refuse, naming the
3534
- // existing artifact so the caller can pick an explicit intent.
3535
- const main = findDoc("main");
3536
- if (docHasHtml(main)) {
3537
- const versionInfo = main.latest_version
3538
- ? ` (v${main.latest_version}, ${main.latest_updated_at || "last update unknown"})`
3539
- : "";
3540
- const slugList = existing.length
3541
- ? existing.map((d) => d.slug).join(", ")
3542
- : "main";
3543
- throw new Error(
3544
- `Refusing to silently overwrite the existing 'main' artifact${versionInfo}.\n` +
3545
- `\n` +
3546
- `If you meant to UPDATE 'main': --doc main\n` +
3547
- `If you meant to add a NEW artifact: --new --title "<name>"\n` +
3548
- `\n` +
3549
- `Existing slugs on this deal: ${slugList}\n` +
3550
- `List details: llama html docs ${dealId}`,
3551
- );
3552
- }
3553
- slug = "main";
3554
- mode = main ? "updated" : "created";
3555
- }
3556
-
3557
- let html;
3558
- if (flags.file) {
3559
- const { readFileSync } = await import("fs");
3560
- html = readFileSync(String(flags.file), "utf8");
3561
- } else if (flags.stdin) {
3562
- const chunks = [];
3563
- for await (const chunk of process.stdin) chunks.push(chunk);
3564
- html = Buffer.concat(chunks).toString("utf8");
3565
- } else {
3566
- throw new Error(
3567
- "Pass --file <path> to upload a file, or --stdin to read from stdin.",
3568
- );
3569
- }
3570
- if (!html || !html.trim()) {
3571
- throw new Error("HTML body is empty.");
3572
- }
3573
- const source =
3574
- typeof flags.source === "string" && flags.source.trim()
3575
- ? flags.source.trim()
3576
- : "cli";
3577
- const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
3578
-
3579
- // No --assets → JSON path (small, faster).
3580
- if (!flags.assets) {
3581
- const data = await request("PUT", htmlEndpoint(dealId, slug), {
3582
- html,
3583
- source,
3584
- client_upload_id: uploadId,
3585
- }, {
3586
- headers: { "X-Llama-Upload-Id": uploadId },
3587
- });
3588
- print({
3589
- ok: true,
3590
- mode,
3591
- document_slug: slug,
3592
- version: data?.version,
3593
- bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
3594
- sha256: data?.sha256,
3595
- client_upload_id: data?.client_upload_id ?? uploadId,
3596
- idempotent_replay: data?.idempotent_replay,
3597
- deal_uuid: dealId,
3598
- viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
3599
- });
3600
- return;
3601
- }
3602
-
3603
- // --assets path → multipart bundle. Walk the asset directory,
3604
- // attach every file as `asset:<relativePath>`, and let the server
3605
- // rewrite the HTML refs to /api/deals/<id>/asset/<path>?v=N.
3606
- const { readFileSync, readdirSync, statSync } = await import("fs");
3607
- const { join, relative, sep, basename } = await import("path");
3608
- const assetsRoot = String(flags.assets);
3609
- const assetsRootStat = statSync(assetsRoot);
3610
- if (!assetsRootStat.isDirectory()) {
3611
- throw new Error(`--assets must point to a directory: ${assetsRoot}`);
3612
- }
3613
-
3614
- // Recursively collect every file under the assets root.
3615
- const collected = []; // { absPath, relPath, bytes }
3616
- const walk = (dir) => {
3617
- for (const name of readdirSync(dir)) {
3618
- const abs = join(dir, name);
3619
- const st = statSync(abs);
3620
- if (st.isDirectory()) {
3621
- walk(abs);
3622
- } else if (st.isFile()) {
3623
- const rel = relative(assetsRoot, abs).split(sep).join("/");
3624
- collected.push({ absPath: abs, relPath: rel, bytes: st.size });
3625
- }
3626
- }
3627
- };
3628
- walk(assetsRoot);
3629
- if (collected.length === 0) {
3630
- throw new Error(`--assets directory is empty: ${assetsRoot}`);
3631
- }
3632
-
3633
- // Some "Save Page As" exports put assets in a sibling folder named
3634
- // after the HTML (e.g. "Foo.html" + "Foo_files/"). When the HTML
3635
- // references "./Foo_files/img.png" but we walk just the inner dir,
3636
- // the rel paths don't match. Detect this case: if the assets root's
3637
- // basename is "<something>_files" or "<something> files", the HTML
3638
- // probably uses that prefix — prepend it to each relPath.
3639
- const rootName = basename(assetsRoot);
3640
- const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
3641
- const finalPaths = looksLikeSavePageDir
3642
- ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
3643
- : collected;
3644
-
3645
- // Mime sniff from extension. Server defaults to
3646
- // application/octet-stream if blob.type is empty.
3647
- const mimeFor = (path) => {
3648
- const ext = (path.split(".").pop() || "").toLowerCase();
3649
- return (
3650
- {
3651
- jpg: "image/jpeg",
3652
- jpeg: "image/jpeg",
3653
- png: "image/png",
3654
- gif: "image/gif",
3655
- webp: "image/webp",
3656
- svg: "image/svg+xml",
3657
- ico: "image/x-icon",
3658
- avif: "image/avif",
3659
- css: "text/css",
3660
- js: "text/javascript",
3661
- json: "application/json",
3662
- woff: "font/woff",
3663
- woff2: "font/woff2",
3664
- ttf: "font/ttf",
3665
- otf: "font/otf",
3666
- mp4: "video/mp4",
3667
- webm: "video/webm",
3668
- pdf: "application/pdf",
3669
- }[ext] || "application/octet-stream"
3670
- );
3671
- };
3672
-
3673
- const form = new FormData();
3674
- form.append("html", html);
3675
- form.append("source", source);
3676
- form.append("client_upload_id", uploadId);
3677
- let totalBytes = 0;
3678
- for (const { absPath, relPath } of finalPaths) {
3679
- const buf = readFileSync(absPath);
3680
- totalBytes += buf.length;
3681
- // FormData wants a Blob; in Node 20+ Blob is global and accepts Buffer.
3682
- form.append(
3683
- `asset:${relPath}`,
3684
- new Blob([buf], { type: mimeFor(relPath) }),
3685
- relPath,
3686
- );
3687
- }
3688
-
3689
- console.error(
3690
- `Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${finalPaths.length} assets (${totalBytes} bytes)`,
3691
- );
3692
-
3693
- const headers = await getAuthHeaders();
3694
- const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
3695
- method: "PUT",
3696
- headers: {
3697
- ...headers,
3698
- "X-Llama-Upload-Id": uploadId,
3699
- /* let fetch set the multipart boundary */
3700
- },
3701
- body: form,
3702
- });
3703
- const body = await res.json().catch(() => ({}));
3704
- if (!res.ok) {
3705
- throw new Error(
3706
- `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
3707
- );
3708
- }
3709
- print({
3710
- ok: true,
3711
- mode,
3712
- document_slug: slug,
3713
- version: body.version,
3714
- bytes: body.bytes,
3715
- sha256: body.sha256,
3716
- client_upload_id: body.client_upload_id ?? uploadId,
3717
- idempotent_replay: body.idempotent_replay,
3718
- asset_count: body.asset_count,
3719
- asset_bytes: body.asset_bytes,
3720
- deal_uuid: dealId,
3721
- viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
3722
- });
3723
- return;
3724
- }
3725
-
3726
- // versions — list version history (newest first, includes soft-deleted).
3727
- if (sub === "versions") {
3728
- const dealId = rest[0];
3729
- if (!dealId) {
3730
- throw new Error("Usage: llama html versions <dealId> [--doc SLUG]");
3731
- }
3732
- const { flags } = parseFlags(rest.slice(1));
3733
- const slug =
3734
- typeof flags.doc === "string" && flags.doc.trim()
3735
- ? flags.doc.trim()
3736
- : "main";
3737
- const data = await request("GET", `${htmlEndpoint(dealId, slug)}/history`);
3738
- print(data);
3739
- return;
3740
- }
3741
-
3742
- // restore — re-promote an old version as the new latest.
3743
- if (sub === "restore") {
3744
- const dealId = rest[0];
3745
- const version = Number(rest[1]);
3746
- if (!dealId || !Number.isFinite(version)) {
3747
- throw new Error(
3748
- "Usage: llama html restore <dealId> <version> [--doc SLUG]",
3749
- );
3750
- }
3751
- const { flags } = parseFlags(rest.slice(2));
3752
- const slug =
3753
- typeof flags.doc === "string" && flags.doc.trim()
3754
- ? flags.doc.trim()
3755
- : "main";
3756
- const data = await request(
3757
- "POST",
3758
- `${htmlEndpoint(dealId, slug)}/restore/${version}`,
3759
- );
3760
- print({
3761
- ok: true,
3762
- document_slug: slug,
3763
- restored_from: version,
3764
- new_version: data?.version,
3765
- deal_uuid: dealId,
3766
- });
3767
- return;
3768
- }
3769
-
3770
- // reset — soft-delete the current HTML. /browse page reverts to empty state.
3771
- if (sub === "reset" || sub === "delete") {
3772
- const dealId = rest[0];
3773
- if (!dealId) {
3774
- throw new Error("Usage: llama html reset <dealId> [--doc SLUG]");
3775
- }
3776
- const { flags } = parseFlags(rest.slice(1));
3777
- const slug =
3778
- typeof flags.doc === "string" && flags.doc.trim()
3779
- ? flags.doc.trim()
3780
- : "main";
3781
- const data = await request("DELETE", htmlEndpoint(dealId, slug));
3782
- print({
3783
- ok: true,
3784
- document_slug: slug,
3785
- soft_deleted_version: data?.version ?? null,
3786
- deal_uuid: dealId,
3787
- });
3788
- return;
3789
- }
3790
-
3791
- throw new Error(
3792
- `Unknown html subcommand "${sub || ""}". Use: docs / link / unlink / show / publish / upload / versions / restore / reset.`,
3793
- );
3794
- }
3795
578
 
3796
- usage();
3797
- process.exitCode = 1;
579
+ throw new Error(`Unknown command: llama ${[area, action].filter(Boolean).join(" ")}`);
3798
580
  }
3799
581
 
3800
582
  main()
3801
- // Soft, throttled, TTY-gated update nudge. Runs AFTER the command's own
3802
- // output and is awaited so the registry check completes before exit — but
3803
- // it can never fail the command (all errors swallowed internally).
3804
583
  .then(() => maybeNudgeUpdate())
3805
584
  .catch((error) => {
3806
585
  console.error(`Error: ${error.message}`);