@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/AGENT_BRIEFING.md +12 -3
- package/CHANGELOG.md +49 -1
- package/bin/llama-mcp.mjs +333 -15
- package/bin/llama.mjs +467 -6
- package/lib/client.mjs +26 -9
- package/package.json +3 -2
- package/scripts/verify-agent-routing.mjs +260 -2
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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
|
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")
|
|
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.
|
|
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",
|