@llamaventures/cli 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/llama.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createRequire } from "module";
4
+ import { randomUUID } from "crypto";
4
5
  import readline from "readline";
5
6
  import {
6
7
  DEFAULT_BASE_URL,
@@ -38,6 +39,19 @@ import { maybeNudgeUpdate, getUpdateNudge } from "../lib/version-check.mjs";
38
39
  const requireFromHere = createRequire(import.meta.url);
39
40
  const { version: PKG_VERSION } = requireFromHere("../package.json");
40
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
+
41
55
  function parseFlags(args, knownFlags = null) {
42
56
  const flags = {};
43
57
  const positional = [];
@@ -376,7 +390,7 @@ auto-detects \`gcloud auth print-identity-token\` and uses Bearer auth.
376
390
  Manually-set \`llc_\` tokens are used as a fallback.
377
391
 
378
392
  Deals:
379
- llama deal create "Company" --source <name> --source-direction Inbound|Outbound --description "..." --status Outreached|Sourced --website https://...
393
+ llama deal create "Company" --source <name> --source-direction Inbound|Outbound --description "..." --status Interested|Outreached|Sourced --website https://...
380
394
  llama deal show <dealId>
381
395
  llama deal feed <dealId> # every contribution (facts + notes), human-typed or assistant-drafted, newest first
382
396
  llama deal update <dealId> <field> <value>
@@ -385,7 +399,7 @@ Deals:
385
399
  roundSize, valuation, deckLink, folderUrl, sector, subsector,
386
400
  foundedYear, leadInvestor, investors, agentActive.
387
401
  e.g. llama deal update <dealId> website https://acme.ai
388
- llama deal update <dealId> status Outreached
402
+ llama deal update <dealId> status Interested
389
403
  llama deal update <dealId> sector "Developer Tools"
390
404
  llama deal update <dealId> foundedYear 2024
391
405
  llama deal update <dealId> leadInvestor "Acme Capital"
@@ -399,7 +413,7 @@ Deals:
399
413
  JSON when possible ('{"a":1}', 'true', '3'), else stored as a
400
414
  string. Audited to deal_events as field_change "extra.<key>".
401
415
  llama deal extra unset <dealId> <key> # delete the key (admin)
402
- llama deal search <query> [--founder name] [--owner <user-key>] [--status Diligence]
416
+ llama deal search <query> [--founder name] [--owner <user-key>] [--status Interested]
403
417
  [--theirStage Raising] [--stage Seed] [--source-direction Inbound]
404
418
  [--limit 200] [--offset 0]
405
419
  llama deal list [--owner ...] [--status ...] [...same flags as search]
@@ -498,7 +512,7 @@ Mentions / Inbox:
498
512
  llama mentions unread # just the badge count
499
513
 
500
514
  Where does this HTML / thesis / artifact go?
501
- 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 "..."
502
516
  (renders at /deals/<id>/browse/<slug>; see "Deal page HTML" below)
503
517
  Cross-deal / institutional? ..... llama wiki save <slug> --title "..." --file <path>.html --sources "..."
504
518
  (renders at /wiki/<slug>; see "Wiki" below)
@@ -516,7 +530,7 @@ Wiki:
516
530
  (.html / .htm extension auto-implies content_type=html)
517
531
  Native comments + working in-page (#) links are added automatically — just upload self-contained HTML.
518
532
  ➜ Use Wiki when the artifact is NOT tied to one specific deal — sector landscape, market map,
519
- 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.
520
534
  Delete / restore (soft — reversible):
521
535
  llama wiki delete <slug> [--lang en|zh]
522
536
  llama wiki restore <slug> [--lang en|zh]
@@ -535,6 +549,11 @@ Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
535
549
  Each one has a stable slug. UPLOAD must declare intent — update an existing
536
550
  artifact or add a new one — to avoid silent overwrites.
537
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
+
538
557
  List existing artifacts:
539
558
  llama html docs <dealId> # who-has-what
540
559
  llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
@@ -563,7 +582,7 @@ Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
563
582
  Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
564
583
  triggers SSE push — any browser viewing /deals/<id>/browse refreshes
565
584
  automatically. Same write path as the in-app deal agent's
566
- 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.
567
586
 
568
587
  Admin (system admin only — server returns 403 for non-admin tokens):
569
588
  llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
@@ -2524,10 +2543,268 @@ Routing — is this the right command?
2524
2543
  // --doc <slug> selects which named document on the deal (default 'main').
2525
2544
  // Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
2526
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
+
2527
2550
  function htmlEndpoint(dealId, slug) {
2528
2551
  return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
2529
2552
  }
2530
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
+
2531
2808
  // Surface a clean `linked_wiki` field on linked docs so the listing
2532
2809
  // reads as "this card points at wiki/<slug>" rather than exposing the
2533
2810
  // raw source_wiki_* columns. Non-linked docs are returned unchanged.
@@ -2739,6 +3016,174 @@ Routing — is this the right command?
2739
3016
  return;
2740
3017
  }
2741
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
+
2742
3187
  // upload — PUT a new version. Reads HTML from --file or stdin. With
2743
3188
  // --assets <dir>, walks the folder, packages as a multipart bundle,
2744
3189
  // and the server stores HTML + per-asset BYTEA rows atomically
@@ -2773,7 +3218,7 @@ Routing — is this the right command?
2773
3218
  }
2774
3219
  const knownFlags = [
2775
3220
  "doc", "slug", "new", "title",
2776
- "file", "stdin", "assets", "source",
3221
+ "file", "stdin", "assets", "source", "upload-id",
2777
3222
  ];
2778
3223
  const { flags } = parseFlags(rest.slice(1), knownFlags);
2779
3224
 
@@ -2942,12 +3387,16 @@ Routing — is this the right command?
2942
3387
  typeof flags.source === "string" && flags.source.trim()
2943
3388
  ? flags.source.trim()
2944
3389
  : "cli";
3390
+ const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
2945
3391
 
2946
3392
  // No --assets → JSON path (small, faster).
2947
3393
  if (!flags.assets) {
2948
3394
  const data = await request("PUT", htmlEndpoint(dealId, slug), {
2949
3395
  html,
2950
3396
  source,
3397
+ client_upload_id: uploadId,
3398
+ }, {
3399
+ headers: { "X-Llama-Upload-Id": uploadId },
2951
3400
  });
2952
3401
  print({
2953
3402
  ok: true,
@@ -2955,6 +3404,9 @@ Routing — is this the right command?
2955
3404
  document_slug: slug,
2956
3405
  version: data?.version,
2957
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,
2958
3410
  deal_uuid: dealId,
2959
3411
  viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
2960
3412
  });
@@ -3034,6 +3486,7 @@ Routing — is this the right command?
3034
3486
  const form = new FormData();
3035
3487
  form.append("html", html);
3036
3488
  form.append("source", source);
3489
+ form.append("client_upload_id", uploadId);
3037
3490
  let totalBytes = 0;
3038
3491
  for (const { absPath, relPath } of finalPaths) {
3039
3492
  const buf = readFileSync(absPath);
@@ -3053,7 +3506,11 @@ Routing — is this the right command?
3053
3506
  const headers = await getAuthHeaders();
3054
3507
  const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
3055
3508
  method: "PUT",
3056
- 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
+ },
3057
3514
  body: form,
3058
3515
  });
3059
3516
  const body = await res.json().catch(() => ({}));
@@ -3067,6 +3524,10 @@ Routing — is this the right command?
3067
3524
  mode,
3068
3525
  document_slug: slug,
3069
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,
3070
3531
  asset_count: body.asset_count,
3071
3532
  asset_bytes: body.asset_bytes,
3072
3533
  deal_uuid: dealId,
@@ -3141,7 +3602,7 @@ Routing — is this the right command?
3141
3602
  }
3142
3603
 
3143
3604
  throw new Error(
3144
- `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.`,
3145
3606
  );
3146
3607
  }
3147
3608