@llamaventures/cli 1.16.0 → 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,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 = [];
@@ -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
 
package/lib/client.mjs CHANGED
@@ -13,7 +13,7 @@ import path from "path";
13
13
  import { fileURLToPath } from "url";
14
14
  import { execFile as _execFile } from "child_process";
15
15
  import { promisify } from "util";
16
- import { randomUUID } from "crypto";
16
+ import { createHash, randomUUID } from "crypto";
17
17
 
18
18
  const execFile = promisify(_execFile);
19
19
 
@@ -217,23 +217,39 @@ function agentClientHeaders(command) {
217
217
  }
218
218
 
219
219
  const SECRET_KEY_RE = /(token|secret|password|authorization|cookie|api[_-]?key|keychain|jwt)/i;
220
+ const CONTENT_PAYLOAD_KEY_RE = /(^|_)(html|body|content|markdown|message|text)$/i;
220
221
 
221
222
  function truncateText(text, max = 2000) {
222
223
  return text.length > max ? `${text.slice(0, max)}...[truncated]` : text;
223
224
  }
224
225
 
225
- function sanitizeTelemetryValue(value, depth = 0) {
226
+ function summarizePayloadText(value) {
227
+ const text = String(value ?? "");
228
+ return {
229
+ redacted: true,
230
+ type: "text_payload",
231
+ chars: text.length,
232
+ bytes: Buffer.byteLength(text, "utf8"),
233
+ sha256: createHash("sha256").update(text).digest("hex"),
234
+ };
235
+ }
236
+
237
+ function sanitizeTelemetryValue(value, depth = 0, keyHint = "") {
226
238
  if (depth > 4) return "[max-depth]";
227
239
  if (value === null || value === undefined) return value;
228
- if (typeof value === "string") return truncateText(value);
240
+ if (typeof value === "string") {
241
+ return CONTENT_PAYLOAD_KEY_RE.test(keyHint)
242
+ ? summarizePayloadText(value)
243
+ : truncateText(value);
244
+ }
229
245
  if (typeof value === "number" || typeof value === "boolean") return value;
230
246
  if (Array.isArray(value)) {
231
- return value.slice(0, 20).map((item) => sanitizeTelemetryValue(item, depth + 1));
247
+ return value.slice(0, 20).map((item) => sanitizeTelemetryValue(item, depth + 1, keyHint));
232
248
  }
233
249
  if (typeof value === "object") {
234
250
  const out = {};
235
251
  for (const [key, val] of Object.entries(value).slice(0, 40)) {
236
- out[key] = SECRET_KEY_RE.test(key) ? "[redacted]" : sanitizeTelemetryValue(val, depth + 1);
252
+ out[key] = SECRET_KEY_RE.test(key) ? "[redacted]" : sanitizeTelemetryValue(val, depth + 1, key);
237
253
  }
238
254
  return out;
239
255
  }
@@ -494,15 +510,15 @@ function unauthorizedError() {
494
510
  );
495
511
  }
496
512
 
497
- export async function request(method, endpoint, body) {
498
- return requestWithRetry(method, endpoint, body, /* allowRetry */ true);
513
+ export async function request(method, endpoint, body, opts = {}) {
514
+ return requestWithRetry(method, endpoint, body, opts, /* allowRetry */ true);
499
515
  }
500
516
 
501
517
  export async function requestSse(method, endpoint, body, opts = {}) {
502
518
  return requestSseWithRetry(method, endpoint, body, opts, /* allowRetry */ true);
503
519
  }
504
520
 
505
- async function requestWithRetry(method, endpoint, body, allowRetry) {
521
+ async function requestWithRetry(method, endpoint, body, opts, allowRetry) {
506
522
  const authHeaders = await getAuthHeaders();
507
523
  if (Object.keys(authHeaders).length === 0) throw noAuthError();
508
524
  const command = inferCommand(method, endpoint);
@@ -513,6 +529,7 @@ async function requestWithRetry(method, endpoint, body, allowRetry) {
513
529
  "Content-Type": "application/json",
514
530
  ...agentClientHeaders(command),
515
531
  ...authHeaders,
532
+ ...(opts.headers || {}),
516
533
  },
517
534
  body: body === undefined ? undefined : JSON.stringify(body),
518
535
  });
@@ -532,7 +549,7 @@ async function requestWithRetry(method, endpoint, body, allowRetry) {
532
549
  refreshed = null;
533
550
  }
534
551
  if (refreshed) {
535
- return requestWithRetry(method, endpoint, body, /* allowRetry */ false);
552
+ return requestWithRetry(method, endpoint, body, opts, /* allowRetry */ false);
536
553
  }
537
554
  throw unauthorizedError();
538
555
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "npm run test:agent-routing",
8
- "test:agent-routing": "node scripts/verify-agent-routing.mjs"
8
+ "test:agent-routing": "node scripts/verify-agent-routing.mjs",
9
+ "verify:release": "npm test && npm pack --dry-run"
9
10
  },
10
11
  "bin": {
11
12
  "llama": "bin/llama.mjs",