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