@llamaventures/cli 1.15.1 → 1.17.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,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from "module";
4
+ import { randomUUID } from "crypto";
3
5
  import readline from "readline";
4
6
  import {
5
7
  DEFAULT_BASE_URL,
@@ -9,6 +11,7 @@ import {
9
11
  TOKEN_FILE,
10
12
  getAuthHeaders,
11
13
  getBaseUrl,
14
+ getLastAgentEvent,
12
15
  getToken,
13
16
  print,
14
17
  readBriefing,
@@ -33,6 +36,22 @@ import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken
33
36
  import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
34
37
  import { maybeNudgeUpdate, getUpdateNudge } from "../lib/version-check.mjs";
35
38
 
39
+ const requireFromHere = createRequire(import.meta.url);
40
+ const { version: PKG_VERSION } = requireFromHere("../package.json");
41
+
42
+ function newHtmlUploadId() {
43
+ return `cli-${randomUUID()}`;
44
+ }
45
+
46
+ function normalizeUploadId(value) {
47
+ if (typeof value !== "string" || !value.trim()) return null;
48
+ const id = value.trim();
49
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) {
50
+ throw new Error("--upload-id must be 1-128 chars: letters, numbers, dot, underscore, colon, or hyphen");
51
+ }
52
+ return id;
53
+ }
54
+
36
55
  function parseFlags(args, knownFlags = null) {
37
56
  const flags = {};
38
57
  const positional = [];
@@ -71,6 +90,34 @@ function parseFlags(args, knownFlags = null) {
71
90
  return { flags, positional };
72
91
  }
73
92
 
93
+ function agentOnboardNoAuthMessage() {
94
+ return `Llama Ventures team onboarding requires credentials.
95
+
96
+ Team member?
97
+ - Run \`gcloud auth login\` with your @llamaventures.vc account, OR
98
+ - Mint a token at https://command.llamaventures.vc/settings/tokens
99
+ then \`llama token set <llc_...>\`.
100
+ Re-run \`llama agent-onboard\` after — the workflow contract will print.
101
+
102
+ Founder or external visitor (no Llama account)?
103
+ Run \`llama pitch start --name "Your Name" --email "you@company.com"\`
104
+ to chat with our intake agent — no token required.`;
105
+ }
106
+
107
+ function agentOnboardRejectedMessage() {
108
+ return `Llama Ventures team onboarding requires valid credentials.
109
+
110
+ Server rejected the credentials we sent. Re-mint at
111
+ https://command.llamaventures.vc/settings/tokens, run
112
+ \`llama token set <llc_...>\`, then re-run \`llama agent-onboard\`.`;
113
+ }
114
+
115
+ async function fetchServerAgentBriefing() {
116
+ const params = new URLSearchParams({ clientVersion: PKG_VERSION });
117
+ const result = await request("GET", `/api/agent/briefing?${params}`);
118
+ return result?.briefing || "";
119
+ }
120
+
74
121
  function closestKnownFlag(input, candidates) {
75
122
  let best = null;
76
123
  let bestScore = Infinity;
@@ -127,6 +174,45 @@ function slugifyTitle(title) {
127
174
  return slug;
128
175
  }
129
176
 
177
+ function parseExpectedIds(raw) {
178
+ const text = typeof raw === "string" ? raw : "";
179
+ const expected = { dealIds: [], wikiSlugs: [], raw: [] };
180
+ for (const item of text.split(",").map((s) => s.trim()).filter(Boolean)) {
181
+ const [kind, ...rest] = item.split(":");
182
+ const value = rest.join(":").trim();
183
+ if (kind === "deal" && value) expected.dealIds.push(value);
184
+ else if ((kind === "wiki" || kind === "slug") && value) expected.wikiSlugs.push(value);
185
+ else expected.raw.push(item);
186
+ }
187
+ return expected;
188
+ }
189
+
190
+ async function submitEvalFeedback(action, flags, queryText = "") {
191
+ const useLast = flags.last !== false;
192
+ const last = useLast ? getLastAgentEvent() : null;
193
+ const eventId =
194
+ flags.event && flags.event !== true
195
+ ? Number(flags.event)
196
+ : last?.lastEventId ?? null;
197
+ if ((action === "good" || action === "bad") && !eventId && !queryText) {
198
+ throw new Error(`Usage: llama eval ${action} [--last] [--reason "..."]`);
199
+ }
200
+ const body = {
201
+ action,
202
+ eventId: Number.isFinite(eventId) ? eventId : undefined,
203
+ query: queryText || undefined,
204
+ surface: flags.surface && flags.surface !== true ? String(flags.surface) : last?.lastSurface ?? undefined,
205
+ expected:
206
+ flags.expect && flags.expect !== true
207
+ ? parseExpectedIds(String(flags.expect))
208
+ : {},
209
+ reason: flags.reason && flags.reason !== true ? String(flags.reason) : undefined,
210
+ privacyLevel:
211
+ flags.privacy && flags.privacy !== true ? String(flags.privacy) : "internal",
212
+ };
213
+ return request("POST", "/api/agent/eval-feedback", body);
214
+ }
215
+
130
216
  // Client-side fuzzy match — used as a fallback when the server hasn't yet
131
217
  // shipped the search/filter API (Fix B, 2026-04-25). Once the server
132
218
  // returns the `{deals,total,limit,offset}` envelope, this path is never
@@ -282,6 +368,8 @@ Agent onboarding (run once on first install):
282
368
  llama skills search "pipeline update" # discover relevant runtime skills
283
369
  llama skills show llama-pipeline # read a skill from Command
284
370
  llama explain <url-or-object> # explain Command URL/object status + lifecycle
371
+ llama eval good|bad --last # mark the latest CLI/MCP result for eval
372
+ llama eval add "<query>" --expect wiki:<slug>|deal:<uuid>
285
373
 
286
374
  External pitch — talk to Llama Ventures' intake agent (no token required):
287
375
  llama pitch start --name "Jane Doe" --email "jane@acme.ai"
@@ -424,7 +512,7 @@ Mentions / Inbox:
424
512
  llama mentions unread # just the badge count
425
513
 
426
514
  Where does this HTML / thesis / artifact go?
427
- About ONE specific deal? ........ llama html upload <dealId> --new --title "..." --file <path>
515
+ About ONE specific deal? ........ llama html publish <deal-id-or-name> --file <path> --title "..."
428
516
  (renders at /deals/<id>/browse/<slug>; see "Deal page HTML" below)
429
517
  Cross-deal / institutional? ..... llama wiki save <slug> --title "..." --file <path>.html --sources "..."
430
518
  (renders at /wiki/<slug>; see "Wiki" below)
@@ -442,7 +530,7 @@ Wiki:
442
530
  (.html / .htm extension auto-implies content_type=html)
443
531
  Native comments + working in-page (#) links are added automatically — just upload self-contained HTML.
444
532
  ➜ Use Wiki when the artifact is NOT tied to one specific deal — sector landscape, market map,
445
- thesis, framework, methodology. For deal-specific HTML use "llama html upload <dealId>" instead.
533
+ thesis, framework, methodology. For deal-specific HTML use "llama html publish <deal>" instead.
446
534
  Delete / restore (soft — reversible):
447
535
  llama wiki delete <slug> [--lang en|zh]
448
536
  llama wiki restore <slug> [--lang en|zh]
@@ -461,6 +549,11 @@ Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
461
549
  Each one has a stable slug. UPLOAD must declare intent — update an existing
462
550
  artifact or add a new one — to avoid silent overwrites.
463
551
 
552
+ Agent-safe publish path (recommended for Claude Code / Codex / Cursor):
553
+ llama html publish <deal-id-or-name> --file <path> [--title "..."] [--doc <slug>]
554
+ # Defaults to NEW doc unless --doc points at an existing slug; verifies version/bytes/sha256 after upload.
555
+ # Auto-detects sibling *_files asset folders unless --no-auto-assets is set.
556
+
464
557
  List existing artifacts:
465
558
  llama html docs <dealId> # who-has-what
466
559
  llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
@@ -489,7 +582,7 @@ Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
489
582
  Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
490
583
  triggers SSE push — any browser viewing /deals/<id>/browse refreshes
491
584
  automatically. Same write path as the in-app deal agent's
492
- update_deal_browse_html tool and the MCP html_upload_bundle tool.
585
+ update_deal_browse_html tool and the MCP html_upload_file tool.
493
586
 
494
587
  Admin (system admin only — server returns 403 for non-admin tokens):
495
588
  llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
@@ -525,6 +618,7 @@ Common:
525
618
  llama agent bootstrap live Llama OS skill manifest from Command
526
619
  llama skills search "<query>" discover which skill to read
527
620
  llama explain <url-or-object> explain Command URLs, 404s, deleted objects
621
+ llama eval bad --last mark latest CLI/MCP result as an eval candidate
528
622
 
529
623
  Command groups — run \`llama help <group>\` for that group's commands:
530
624
  deal create · show · feed · update · enrich · search · collaborators · links · delete
@@ -532,6 +626,7 @@ Command groups — run \`llama help <group>\` for that group's commands:
532
626
  facts deal facts + skill corrections (the sourced, trust-rated layer)
533
627
  timeline timeline · posts · mentions
534
628
  wiki cross-deal knowledge entries (markdown or HTML)
629
+ eval mark real CLI/MCP searches good/bad or add a golden-query candidate
535
630
  memo long-form HTML investment memo
536
631
  html deal-specific HTML artifacts (/deals/<id>/browse/<slug>)
537
632
  pitch external founder intake (no token needed)
@@ -865,18 +960,15 @@ async function runPitchRepl() {
865
960
  async function main() {
866
961
  const [area, action, ...rest] = process.argv.slice(2);
867
962
  if (area === "--version" || area === "-v" || area === "version") {
868
- const { createRequire } = await import("module");
869
- const requireFromHere = createRequire(import.meta.url);
870
- const { version } = requireFromHere("../package.json");
871
963
  // `llama version --check` — explicitly check npm for a newer release and
872
964
  // print the upgrade line (or "up to date"). Lets an agent surface the
873
965
  // nudge on demand, separate from the throttled, TTY-gated auto-nudge.
874
966
  if (action === "--check" || action === "check") {
875
967
  const nudge = await getUpdateNudge();
876
- console.log(nudge || `llama CLI ${version} — up to date`);
968
+ console.log(nudge || `llama CLI ${PKG_VERSION} — up to date`);
877
969
  return;
878
970
  }
879
- console.log(version);
971
+ console.log(PKG_VERSION);
880
972
  return;
881
973
  }
882
974
  if (!area || area === "help" || area === "--help" || area === "-h") {
@@ -897,12 +989,13 @@ async function main() {
897
989
  return;
898
990
  }
899
991
 
900
- // `llama agent-onboard` — print the bundled AGENT_BRIEFING.md so an AI
901
- // agent reads it once and internalises the Llama Ventures workflow
902
- // contract. Same content the `agent_briefing` MCP prompt returns.
992
+ // `llama agent-onboard` — fetch the server-owned Agent Runtime Contract
993
+ // so an AI agent reads the current Llama Ventures workflow contract. The
994
+ // bundled AGENT_BRIEFING.md is now only a fallback when the server route
995
+ // is unavailable during rollout.
903
996
  // Also: `llama agent onboard` (two-word form) for symmetry.
904
997
  //
905
- // Gated behind /api/me — without valid credentials we print a short
998
+ // Gated behind Command auth — without valid credentials we print a short
906
999
  // bootstrap stub instead. Stops unauthenticated callers from harvesting
907
1000
  // internal command surface / workflow conventions just by running the
908
1001
  // public CLI.
@@ -912,39 +1005,24 @@ async function main() {
912
1005
  ) {
913
1006
  const headers = await getAuthHeaders();
914
1007
  if (Object.keys(headers).length === 0) {
915
- console.log(
916
- `Llama Ventures team onboarding requires credentials.
917
-
918
- Team member?
919
- - Run \`gcloud auth login\` with your @llamaventures.vc account, OR
920
- - Mint a token at https://command.llamaventures.vc/settings/tokens
921
- then \`llama token set <llc_...>\`.
922
- Re-run \`llama agent-onboard\` after — the workflow contract will print.
923
-
924
- Founder or external visitor (no Llama account)?
925
- Run \`llama pitch start --name "Your Name" --email "you@company.com"\`
926
- to chat with our intake agent — no token required.`
927
- );
1008
+ console.log(agentOnboardNoAuthMessage());
928
1009
  return;
929
1010
  }
930
1011
  try {
931
- await request("GET", "/api/me");
1012
+ const briefing = await fetchServerAgentBriefing();
1013
+ process.stdout.write(briefing || readBriefing());
932
1014
  } catch (e) {
933
1015
  const msg = e?.message || "";
934
1016
  if (msg.includes("Error[UNAUTHORIZED]") || msg.includes("Error[NO_AUTH]")) {
935
- console.log(
936
- `Llama Ventures team onboarding requires valid credentials.
937
-
938
- Server rejected the credentials we sent. Re-mint at
939
- https://command.llamaventures.vc/settings/tokens, run
940
- \`llama token set <llc_...>\`, then re-run \`llama agent-onboard\`.`
941
- );
1017
+ console.log(agentOnboardRejectedMessage());
942
1018
  process.exitCode = 1;
943
1019
  return;
944
1020
  }
945
- throw e;
1021
+ process.stderr.write(
1022
+ `warning: server agent briefing unavailable (${msg}); using bundled fallback.\n`,
1023
+ );
1024
+ process.stdout.write(readBriefing());
946
1025
  }
947
- process.stdout.write(readBriefing());
948
1026
  return;
949
1027
  }
950
1028
 
@@ -954,6 +1032,7 @@ https://command.llamaventures.vc/settings/tokens, run
954
1032
  if (area === "agent" && action === "bootstrap") {
955
1033
  const { flags } = parseFlags(rest, ["json", "limit"]);
956
1034
  const params = new URLSearchParams();
1035
+ params.set("clientVersion", PKG_VERSION);
957
1036
  if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
958
1037
  const manifest = await request("GET", `/api/agent/manifest${params.toString() ? `?${params}` : ""}`);
959
1038
  if (flags.json) {
@@ -1037,6 +1116,43 @@ https://command.llamaventures.vc/settings/tokens, run
1037
1116
  return;
1038
1117
  }
1039
1118
 
1119
+ if (area === "eval") {
1120
+ const sub = action;
1121
+ if (sub === "good" || sub === "bad") {
1122
+ const { flags, positional } = parseFlags(rest, [
1123
+ "last",
1124
+ "event",
1125
+ "reason",
1126
+ "expect",
1127
+ "surface",
1128
+ "privacy",
1129
+ ]);
1130
+ const q = positional.join(" ").trim();
1131
+ print(await submitEvalFeedback(sub, flags, q));
1132
+ return;
1133
+ }
1134
+ if (sub === "add") {
1135
+ const { flags, positional } = parseFlags(rest, [
1136
+ "event",
1137
+ "expect",
1138
+ "reason",
1139
+ "surface",
1140
+ "privacy",
1141
+ ]);
1142
+ const q = positional.join(" ").trim();
1143
+ if (!q && !flags.event) {
1144
+ throw new Error(
1145
+ `Usage: llama eval add "<query>" --surface deal|wiki --expect wiki:<slug>|deal:<uuid>`,
1146
+ );
1147
+ }
1148
+ print(await submitEvalFeedback("add", flags, q));
1149
+ return;
1150
+ }
1151
+ throw new Error(
1152
+ "Usage: llama eval good|bad [--last] [--reason ...] OR llama eval add \"<query>\" --expect wiki:<slug>|deal:<uuid>",
1153
+ );
1154
+ }
1155
+
1040
1156
  // `llama pitch ...` — external founder-pitch family. No Llama token
1041
1157
  // required; bootstraps a session against /api/external/* via PoW + cookie.
1042
1158
  // See lib/external.mjs and AGENT_BRIEFING.md for the full surface.
@@ -2427,10 +2543,268 @@ Routing — is this the right command?
2427
2543
  // --doc <slug> selects which named document on the deal (default 'main').
2428
2544
  // Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
2429
2545
  // to list available slugs.
2546
+ const MAX_HTML_BYTES = 5 * 1024 * 1024;
2547
+ const MAX_ASSET_BYTES = 50 * 1024 * 1024;
2548
+ const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
2549
+
2430
2550
  function htmlEndpoint(dealId, slug) {
2431
2551
  return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
2432
2552
  }
2433
2553
 
2554
+ function looksLikeHtml(html) {
2555
+ const head = String(html || "").trim().slice(0, 256).toLowerCase();
2556
+ return head.startsWith("<!doctype html") || head.startsWith("<html");
2557
+ }
2558
+
2559
+ function extractHtmlTitle(html) {
2560
+ const title = String(html || "").match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
2561
+ if (!title) return null;
2562
+ const clean = title.replace(/\s+/g, " ").trim();
2563
+ return clean ? clean.slice(0, 200) : null;
2564
+ }
2565
+
2566
+ function docHasHtml(d) {
2567
+ return Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
2568
+ }
2569
+
2570
+ function findDocBySlug(docs, slug) {
2571
+ return docs.find((d) => d && d.slug === slug) || null;
2572
+ }
2573
+
2574
+ function nextAvailableSlug(base, docs) {
2575
+ let candidate = base;
2576
+ let suffix = 2;
2577
+ while (findDocBySlug(docs, candidate)) {
2578
+ candidate = `${base.slice(0, Math.max(1, 64 - String(suffix).length - 1))}-${suffix}`;
2579
+ suffix += 1;
2580
+ }
2581
+ return candidate;
2582
+ }
2583
+
2584
+ function mimeForAsset(path) {
2585
+ const ext = (String(path).split(".").pop() || "").toLowerCase();
2586
+ return (
2587
+ {
2588
+ jpg: "image/jpeg",
2589
+ jpeg: "image/jpeg",
2590
+ png: "image/png",
2591
+ gif: "image/gif",
2592
+ webp: "image/webp",
2593
+ svg: "image/svg+xml",
2594
+ ico: "image/x-icon",
2595
+ avif: "image/avif",
2596
+ css: "text/css",
2597
+ js: "text/javascript",
2598
+ json: "application/json",
2599
+ woff: "font/woff",
2600
+ woff2: "font/woff2",
2601
+ ttf: "font/ttf",
2602
+ otf: "font/otf",
2603
+ mp4: "video/mp4",
2604
+ webm: "video/webm",
2605
+ pdf: "application/pdf",
2606
+ }[ext] || "application/octet-stream"
2607
+ );
2608
+ }
2609
+
2610
+ async function listHtmlDocs(dealId) {
2611
+ const docList = await request(
2612
+ "GET",
2613
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
2614
+ );
2615
+ return Array.isArray(docList?.documents) ? docList.documents : [];
2616
+ }
2617
+
2618
+ async function resolveDealForHtmlPublish(dealRef) {
2619
+ const ref = String(dealRef || "").trim();
2620
+ if (!ref) throw new Error("deal id or name is required");
2621
+ try {
2622
+ await listHtmlDocs(ref);
2623
+ return { dealId: ref, resolvedFrom: "id" };
2624
+ } catch {
2625
+ // Not a readable deal id; fall through to pipeline search.
2626
+ }
2627
+
2628
+ const result = await searchDeals(ref, { limit: 10 });
2629
+ const deals = Array.isArray(result?.deals) ? result.deals : [];
2630
+ if (deals.length === 0) {
2631
+ throw new Error(
2632
+ `No deal matched "${ref}". Run \`llama deal search "${ref}"\` first and pass the exact deal id.`,
2633
+ );
2634
+ }
2635
+ const exact = deals.filter(
2636
+ (d) => String(d.companyName || "").toLowerCase() === ref.toLowerCase(),
2637
+ );
2638
+ const candidates = exact.length > 0 ? exact : deals;
2639
+ if (candidates.length !== 1) {
2640
+ const lines = candidates
2641
+ .slice(0, 8)
2642
+ .map((d) => `- ${d.companyName || "(unnamed)"} — ${d.uuid || d.id}`)
2643
+ .join("\n");
2644
+ throw new Error(
2645
+ `Deal name "${ref}" matched multiple records. Re-run with the exact deal id:\n${lines}`,
2646
+ );
2647
+ }
2648
+ const dealId = candidates[0]?.uuid || candidates[0]?.id;
2649
+ if (!dealId) {
2650
+ throw new Error(`Deal search matched "${ref}" but did not return a deal id.`);
2651
+ }
2652
+ return {
2653
+ dealId,
2654
+ dealName: candidates[0]?.companyName || ref,
2655
+ resolvedFrom: "search",
2656
+ };
2657
+ }
2658
+
2659
+ async function detectSiblingAssetsDir(filePath) {
2660
+ const { existsSync, statSync } = await import("fs");
2661
+ const { dirname, basename, extname, join } = await import("path");
2662
+ const dir = dirname(filePath);
2663
+ const ext = extname(filePath);
2664
+ const stem = basename(filePath, ext);
2665
+ const candidates = [
2666
+ `${stem}_files`,
2667
+ `${stem} files`,
2668
+ `${basename(filePath)}_files`,
2669
+ ];
2670
+ for (const name of candidates) {
2671
+ const p = join(dir, name);
2672
+ if (existsSync(p) && statSync(p).isDirectory()) return p;
2673
+ }
2674
+ return null;
2675
+ }
2676
+
2677
+ async function collectAssets(assetsRoot) {
2678
+ const { readFileSync, readdirSync, statSync } = await import("fs");
2679
+ const { join, relative, sep, basename } = await import("path");
2680
+ const rootStat = statSync(assetsRoot);
2681
+ if (!rootStat.isDirectory()) {
2682
+ throw new Error(`assets path must be a directory: ${assetsRoot}`);
2683
+ }
2684
+ const collected = [];
2685
+ const walk = (dir) => {
2686
+ for (const name of readdirSync(dir)) {
2687
+ const absPath = join(dir, name);
2688
+ const st = statSync(absPath);
2689
+ if (st.isDirectory()) {
2690
+ walk(absPath);
2691
+ } else if (st.isFile()) {
2692
+ const relPath = relative(assetsRoot, absPath).split(sep).join("/");
2693
+ collected.push({ absPath, relPath, bytes: st.size });
2694
+ }
2695
+ }
2696
+ };
2697
+ walk(assetsRoot);
2698
+ if (collected.length === 0) {
2699
+ throw new Error(`assets directory is empty: ${assetsRoot}`);
2700
+ }
2701
+ const rootName = basename(assetsRoot);
2702
+ const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
2703
+ const finalPaths = looksLikeSavePageDir
2704
+ ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
2705
+ : collected;
2706
+ let totalBytes = 0;
2707
+ for (const item of finalPaths) {
2708
+ if (item.relPath.split("/").some((seg) => seg === "..")) {
2709
+ throw new Error(`asset path "${item.relPath}" contains "..", refused`);
2710
+ }
2711
+ if (item.bytes > MAX_ASSET_BYTES) {
2712
+ throw new Error(
2713
+ `asset "${item.relPath}" is ${item.bytes} bytes; cap is ${MAX_ASSET_BYTES}`,
2714
+ );
2715
+ }
2716
+ totalBytes += item.bytes;
2717
+ if (totalBytes > MAX_BUNDLE_BYTES) {
2718
+ throw new Error(`total asset bytes exceeds ${MAX_BUNDLE_BYTES}`);
2719
+ }
2720
+ }
2721
+ return {
2722
+ assets: finalPaths.map((item) => ({
2723
+ ...item,
2724
+ data: readFileSync(item.absPath),
2725
+ contentType: mimeForAsset(item.relPath),
2726
+ })),
2727
+ totalBytes,
2728
+ };
2729
+ }
2730
+
2731
+ async function uploadHtmlPayload({ dealId, slug, html, source, assetsDir, uploadId }) {
2732
+ if (!assetsDir) {
2733
+ return request("PUT", htmlEndpoint(dealId, slug), {
2734
+ html,
2735
+ source,
2736
+ client_upload_id: uploadId,
2737
+ }, {
2738
+ headers: { "X-Llama-Upload-Id": uploadId },
2739
+ });
2740
+ }
2741
+ const { assets, totalBytes } = await collectAssets(assetsDir);
2742
+ const form = new FormData();
2743
+ form.append("html", html);
2744
+ form.append("source", source);
2745
+ form.append("client_upload_id", uploadId);
2746
+ for (const asset of assets) {
2747
+ form.append(
2748
+ `asset:${asset.relPath}`,
2749
+ new Blob([asset.data], { type: asset.contentType }),
2750
+ asset.relPath,
2751
+ );
2752
+ }
2753
+ console.error(
2754
+ `Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${assets.length} assets (${totalBytes} bytes)`,
2755
+ );
2756
+ const headers = await getAuthHeaders();
2757
+ const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
2758
+ method: "PUT",
2759
+ headers: { ...headers, "X-Llama-Upload-Id": uploadId },
2760
+ body: form,
2761
+ });
2762
+ const body = await res.json().catch(() => ({}));
2763
+ if (!res.ok) {
2764
+ throw new Error(
2765
+ `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
2766
+ );
2767
+ }
2768
+ return body;
2769
+ }
2770
+
2771
+ async function verifyHtmlUpload({ dealId, slug, expectedVersion, expectedBytes, expectedSha256 }) {
2772
+ const latest = await request("GET", htmlEndpoint(dealId, slug));
2773
+ if (latest?.empty) {
2774
+ throw new Error(`verification failed: ${slug} came back empty after upload`);
2775
+ }
2776
+ if (expectedVersion != null && Number(latest.version) !== Number(expectedVersion)) {
2777
+ throw new Error(
2778
+ `verification failed: expected version ${expectedVersion}, got ${latest.version}`,
2779
+ );
2780
+ }
2781
+ if (
2782
+ expectedBytes != null &&
2783
+ latest.bytes != null &&
2784
+ Number(latest.bytes) !== Number(expectedBytes)
2785
+ ) {
2786
+ throw new Error(
2787
+ `verification failed: expected ${expectedBytes} bytes, got ${latest.bytes}`,
2788
+ );
2789
+ }
2790
+ if (
2791
+ expectedSha256 &&
2792
+ latest.sha256 &&
2793
+ String(latest.sha256) !== String(expectedSha256)
2794
+ ) {
2795
+ throw new Error(
2796
+ `verification failed: expected sha256 ${expectedSha256}, got ${latest.sha256}`,
2797
+ );
2798
+ }
2799
+ return {
2800
+ ok: true,
2801
+ version: latest.version,
2802
+ bytes: latest.bytes,
2803
+ sha256: latest.sha256,
2804
+ created_at: latest.created_at,
2805
+ };
2806
+ }
2807
+
2434
2808
  // Surface a clean `linked_wiki` field on linked docs so the listing
2435
2809
  // reads as "this card points at wiki/<slug>" rather than exposing the
2436
2810
  // raw source_wiki_* columns. Non-linked docs are returned unchanged.
@@ -2642,6 +3016,174 @@ Routing — is this the right command?
2642
3016
  return;
2643
3017
  }
2644
3018
 
3019
+ // publish — agent-safe high-level upload path. The agent gives us a file
3020
+ // path + a deal id/name; the CLI handles search, slug decisions, asset
3021
+ // discovery, upload, and read-after-write verification.
3022
+ if (sub === "publish") {
3023
+ const dealRef = rest[0];
3024
+ const knownFlags = [
3025
+ "file", "title", "doc", "slug", "new", "update",
3026
+ "assets", "no-auto-assets", "source", "no-verify", "upload-id",
3027
+ ];
3028
+ const { flags } = parseFlags(rest.slice(1), knownFlags);
3029
+ if (!dealRef || !flags.file || flags.file === true) {
3030
+ throw new Error(
3031
+ "Usage: llama html publish <deal-id-or-name> --file PATH [--title \"...\"] [--doc <slug>] [--update|--new] [--assets DIR]",
3032
+ );
3033
+ }
3034
+ if (flags.slug && !flags.doc) {
3035
+ process.stderr.write("note: --slug accepted as alias for --doc.\n");
3036
+ flags.doc = flags.slug;
3037
+ }
3038
+ const wantsNew = boolFlag(flags, "new");
3039
+ const wantsUpdate = boolFlag(flags, "update");
3040
+ if (wantsNew && wantsUpdate) {
3041
+ throw new Error("Choose only one of --new or --update.");
3042
+ }
3043
+
3044
+ const filePath = String(flags.file);
3045
+ const { readFileSync, statSync } = await import("fs");
3046
+ const { basename, extname } = await import("path");
3047
+ const fileStat = statSync(filePath);
3048
+ if (!fileStat.isFile()) {
3049
+ throw new Error(`--file must point to a readable HTML file: ${filePath}`);
3050
+ }
3051
+ const html = readFileSync(filePath, "utf8");
3052
+ if (!html.trim()) throw new Error("HTML body is empty.");
3053
+ const htmlBytes = Buffer.byteLength(html, "utf8");
3054
+ if (htmlBytes > MAX_HTML_BYTES) {
3055
+ throw new Error(
3056
+ `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.`,
3057
+ );
3058
+ }
3059
+ if (!looksLikeHtml(html)) {
3060
+ throw new Error("HTML must start with <!doctype html> or <html.");
3061
+ }
3062
+
3063
+ const resolved = await resolveDealForHtmlPublish(dealRef);
3064
+ const docs = await listHtmlDocs(resolved.dealId);
3065
+ const explicitDoc =
3066
+ typeof flags.doc === "string" && flags.doc.trim()
3067
+ ? flags.doc.trim()
3068
+ : null;
3069
+ const title =
3070
+ typeof flags.title === "string" && flags.title.trim()
3071
+ ? flags.title.trim()
3072
+ : extractHtmlTitle(html) || basename(filePath, extname(filePath));
3073
+ let slug;
3074
+ let mode;
3075
+ let createdMetadata = false;
3076
+
3077
+ if (explicitDoc) {
3078
+ if (!isValidDocSlug(explicitDoc)) {
3079
+ throw new Error(
3080
+ `slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
3081
+ );
3082
+ }
3083
+ const existingDoc = findDocBySlug(docs, explicitDoc);
3084
+ if (wantsNew && existingDoc) {
3085
+ throw new Error(
3086
+ `--new requested, but document "${explicitDoc}" already exists on this deal.`,
3087
+ );
3088
+ }
3089
+ if (wantsUpdate && !existingDoc) {
3090
+ throw new Error(
3091
+ `--update requested, but document "${explicitDoc}" does not exist on this deal.`,
3092
+ );
3093
+ }
3094
+ slug = explicitDoc;
3095
+ mode = existingDoc && docHasHtml(existingDoc) ? "updated" : "created";
3096
+ if (!existingDoc) createdMetadata = true;
3097
+ } else {
3098
+ const baseSlug = slugifyTitle(title) || slugifyTitle(basename(filePath, extname(filePath)));
3099
+ if (!baseSlug) {
3100
+ throw new Error(
3101
+ "Could not derive a valid slug from the title or filename. Pass --doc <slug>.",
3102
+ );
3103
+ }
3104
+ const existingDoc = findDocBySlug(docs, baseSlug);
3105
+ if (wantsUpdate) {
3106
+ if (!existingDoc) {
3107
+ throw new Error(
3108
+ `--update requested, but derived document "${baseSlug}" does not exist. Pass --doc <existing-slug> or drop --update to create a new doc.`,
3109
+ );
3110
+ }
3111
+ slug = baseSlug;
3112
+ mode = docHasHtml(existingDoc) ? "updated" : "created";
3113
+ } else {
3114
+ slug = existingDoc ? nextAvailableSlug(baseSlug, docs) : baseSlug;
3115
+ mode = "created";
3116
+ createdMetadata = true;
3117
+ if (existingDoc) {
3118
+ process.stderr.write(
3119
+ `note: "${baseSlug}" already exists; publishing as new document "${slug}". Use --update or --doc ${baseSlug} to replace it.\n`,
3120
+ );
3121
+ }
3122
+ }
3123
+ }
3124
+
3125
+ if (createdMetadata) {
3126
+ await request(
3127
+ "POST",
3128
+ `/api/deals/${encodeURIComponent(resolved.dealId)}/documents`,
3129
+ { slug, title },
3130
+ );
3131
+ }
3132
+
3133
+ let assetsDir =
3134
+ typeof flags.assets === "string" && flags.assets.trim()
3135
+ ? flags.assets.trim()
3136
+ : null;
3137
+ if (!assetsDir && !boolFlag(flags, "no-auto-assets")) {
3138
+ assetsDir = await detectSiblingAssetsDir(filePath);
3139
+ if (assetsDir) {
3140
+ process.stderr.write(`note: auto-detected asset folder ${assetsDir}\n`);
3141
+ }
3142
+ }
3143
+ const source =
3144
+ typeof flags.source === "string" && flags.source.trim()
3145
+ ? flags.source.trim()
3146
+ : "cli";
3147
+ const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
3148
+ const uploaded = await uploadHtmlPayload({
3149
+ dealId: resolved.dealId,
3150
+ slug,
3151
+ html,
3152
+ source,
3153
+ assetsDir,
3154
+ uploadId,
3155
+ });
3156
+ const verification = boolFlag(flags, "no-verify")
3157
+ ? { ok: false, skipped: true }
3158
+ : await verifyHtmlUpload({
3159
+ dealId: resolved.dealId,
3160
+ slug,
3161
+ expectedVersion: uploaded?.version,
3162
+ expectedBytes: uploaded?.bytes,
3163
+ expectedSha256: uploaded?.sha256,
3164
+ });
3165
+
3166
+ print({
3167
+ ok: true,
3168
+ mode,
3169
+ deal_uuid: resolved.dealId,
3170
+ resolved_from: resolved.resolvedFrom,
3171
+ deal_name: resolved.dealName,
3172
+ document_slug: slug,
3173
+ title,
3174
+ version: uploaded?.version,
3175
+ bytes: uploaded?.bytes ?? verification.bytes ?? htmlBytes,
3176
+ sha256: uploaded?.sha256 ?? verification.sha256,
3177
+ client_upload_id: uploaded?.client_upload_id ?? uploadId,
3178
+ idempotent_replay: uploaded?.idempotent_replay,
3179
+ asset_count: uploaded?.asset_count,
3180
+ asset_bytes: uploaded?.asset_bytes,
3181
+ verified: verification,
3182
+ viewer: `${getBaseUrl()}/deals/${encodeURIComponent(resolved.dealId)}/browse/${encodeURIComponent(slug)}`,
3183
+ });
3184
+ return;
3185
+ }
3186
+
2645
3187
  // upload — PUT a new version. Reads HTML from --file or stdin. With
2646
3188
  // --assets <dir>, walks the folder, packages as a multipart bundle,
2647
3189
  // and the server stores HTML + per-asset BYTEA rows atomically
@@ -2676,7 +3218,7 @@ Routing — is this the right command?
2676
3218
  }
2677
3219
  const knownFlags = [
2678
3220
  "doc", "slug", "new", "title",
2679
- "file", "stdin", "assets", "source",
3221
+ "file", "stdin", "assets", "source", "upload-id",
2680
3222
  ];
2681
3223
  const { flags } = parseFlags(rest.slice(1), knownFlags);
2682
3224
 
@@ -2845,12 +3387,16 @@ Routing — is this the right command?
2845
3387
  typeof flags.source === "string" && flags.source.trim()
2846
3388
  ? flags.source.trim()
2847
3389
  : "cli";
3390
+ const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
2848
3391
 
2849
3392
  // No --assets → JSON path (small, faster).
2850
3393
  if (!flags.assets) {
2851
3394
  const data = await request("PUT", htmlEndpoint(dealId, slug), {
2852
3395
  html,
2853
3396
  source,
3397
+ client_upload_id: uploadId,
3398
+ }, {
3399
+ headers: { "X-Llama-Upload-Id": uploadId },
2854
3400
  });
2855
3401
  print({
2856
3402
  ok: true,
@@ -2858,6 +3404,9 @@ Routing — is this the right command?
2858
3404
  document_slug: slug,
2859
3405
  version: data?.version,
2860
3406
  bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
3407
+ sha256: data?.sha256,
3408
+ client_upload_id: data?.client_upload_id ?? uploadId,
3409
+ idempotent_replay: data?.idempotent_replay,
2861
3410
  deal_uuid: dealId,
2862
3411
  viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
2863
3412
  });
@@ -2937,6 +3486,7 @@ Routing — is this the right command?
2937
3486
  const form = new FormData();
2938
3487
  form.append("html", html);
2939
3488
  form.append("source", source);
3489
+ form.append("client_upload_id", uploadId);
2940
3490
  let totalBytes = 0;
2941
3491
  for (const { absPath, relPath } of finalPaths) {
2942
3492
  const buf = readFileSync(absPath);
@@ -2956,7 +3506,11 @@ Routing — is this the right command?
2956
3506
  const headers = await getAuthHeaders();
2957
3507
  const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
2958
3508
  method: "PUT",
2959
- headers: { ...headers /* let fetch set the multipart boundary */ },
3509
+ headers: {
3510
+ ...headers,
3511
+ "X-Llama-Upload-Id": uploadId,
3512
+ /* let fetch set the multipart boundary */
3513
+ },
2960
3514
  body: form,
2961
3515
  });
2962
3516
  const body = await res.json().catch(() => ({}));
@@ -2970,6 +3524,10 @@ Routing — is this the right command?
2970
3524
  mode,
2971
3525
  document_slug: slug,
2972
3526
  version: body.version,
3527
+ bytes: body.bytes,
3528
+ sha256: body.sha256,
3529
+ client_upload_id: body.client_upload_id ?? uploadId,
3530
+ idempotent_replay: body.idempotent_replay,
2973
3531
  asset_count: body.asset_count,
2974
3532
  asset_bytes: body.asset_bytes,
2975
3533
  deal_uuid: dealId,
@@ -3044,7 +3602,7 @@ Routing — is this the right command?
3044
3602
  }
3045
3603
 
3046
3604
  throw new Error(
3047
- `Unknown html subcommand "${sub || ""}". Use: docs / link / unlink / show / upload / versions / restore / reset.`,
3605
+ `Unknown html subcommand "${sub || ""}". Use: docs / link / unlink / show / publish / upload / versions / restore / reset.`,
3048
3606
  );
3049
3607
  }
3050
3608