@llamaventures/cli 1.4.3 → 1.5.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 CHANGED
@@ -33,6 +33,8 @@ Conversation produces value → that value flows somewhere. This is not optional
33
33
  |---|---|---|
34
34
  | Deal metadata (status, stage, valuation, founders, notes, etc.) | Pipeline (Postgres) | `llama deal create` / `llama deal update` |
35
35
  | Brief blocks (text / link / embed / callout) | Pipeline | `llama brief add-text` / `add-link` / `add-callout` |
36
+ | **HTML artifact, internal — IC report, dashboard, market map, 2×2, any hand-authored page** | **Llama Command native** (Postgres + sandboxed iframe at `/deals/<id>/browse/<slug>`) | Default path when the user says "deploy to llama", "deploy to llama command", "部署到 llama command", "put this HTML on the deal page", "在 deal 里看这个". **You MUST declare intent — "new artifact" vs "update existing":**<br><br>**New artifact:** `llama html upload <dealId> --new --title "<artifact name>" --file <path>` (CLI slugifies the title; pass `--doc <slug>` to override).<br>**Update existing:** `llama html upload <dealId> --doc <slug> --file <path>` (slug must already exist — run `llama html docs <dealId>` first to see what's there).<br><br>The bare form `llama html upload <id> --file <path>` REFUSES if `main` already has content. Do NOT default to Netlify for internal pages. |
37
+ | HTML artifact, external — founder-facing share link | Netlify | Only when the user explicitly says "share link", "give it to the founder", "publish publicly". Use the `netlify-access-guard` workflow (server-side password + edge 401 verification). |
36
38
  | Insights, decisions, framework improvements | Wiki | `llama wiki save` (with attribution — see below) |
37
39
  | Large files (deck / PDF / transcript) | Drive deal folder | the deal's `folder_url` (from `llama deal show`) → upload via your filesystem / Drive tool |
38
40
  | Cross-team mentions | Inbox + email | `llama post <dealId> "@<teammate> ..."` — server fires email + UI badge to the recipient |
@@ -106,6 +108,34 @@ llama brief add-text <dealId> --heading "..." --body "..."
106
108
  llama brief add-link <dealId> --url "..." --label "..."
107
109
  llama brief add-callout <dealId> --tone insight|warning|info|success --heading "..." --body "..."
108
110
 
111
+ # Deal HTML — native deploy to /deals/<id>/browse/<slug>
112
+ # Default path when user says "deploy to llama / 部署到 llama command / put this HTML on the deal page".
113
+ # Each deal can host many slug-scoped artifacts. ALWAYS declare intent: new vs update.
114
+
115
+ llama html docs <dealId> # list slugs currently on this deal
116
+ llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot (optional; upload --new also creates)
117
+ llama html docs archive <dealId> <slug> # soft-archive a doc
118
+
119
+ # Add a NEW artifact (slug must NOT already exist):
120
+ llama html upload <dealId> --new --title "Consumer-Facing Thesis" --file ./thesis.html
121
+ llama html upload <dealId> --new --doc thesis --title "Consumer-Facing Thesis" --file ./thesis.html
122
+
123
+ # Update an EXISTING artifact (slug must already exist):
124
+ llama html upload <dealId> --doc <slug> --file ./report.html [--assets ./assets]
125
+
126
+ # Common helpers (all accept --doc <slug>; default 'main'):
127
+ llama html show <dealId> [--doc <slug>] [--out path] [--json] # current HTML → stdout
128
+ llama html versions <dealId> [--doc <slug>] # version history (incl. soft-deleted)
129
+ llama html restore <dealId> <version> [--doc <slug>] # promote old version to latest
130
+ llama html reset <dealId> [--doc <slug>] # soft-delete latest (browse reverts to empty)
131
+
132
+ # Safety contract (since 1.5.0):
133
+ # - Bare `llama html upload <id> --file X` REFUSES if 'main' already has content.
134
+ # The error names the existing artifact and suggests --doc main / --new --title "...".
135
+ # - --slug is silently accepted as an alias for --doc (agent-confusion mitigation).
136
+ # - Unknown flags print a warning to stderr suggesting a likely match.
137
+ # - JSON output gains `mode: 'created' | 'updated'` so callers can branch.
138
+
109
139
  # Wiki (knowledge base)
110
140
  llama wiki search "<query>"
111
141
  llama wiki save <slug> --title "..." --content "..."
package/bin/llama-mcp.mjs CHANGED
@@ -661,6 +661,266 @@ server.registerTool(
661
661
  })
662
662
  );
663
663
 
664
+ // ============================================================
665
+ // Deal page HTML — hand-authored sandboxed page per deal
666
+ // ============================================================
667
+ //
668
+ // Each deal has its own /deals/<id>/browse page that renders a
669
+ // hand-authored HTML in a sandboxed iframe (allow-scripts, no
670
+ // same-origin). Uploads from any caller (web UI, CLI, agent, MCP)
671
+ // create a new monotonic version + trigger SSE push so any open
672
+ // viewer refreshes in real time. Old versions are soft-deleted on
673
+ // replace and can be restored.
674
+
675
+ // All html_* tools take an optional documentSlug param. Default 'main'.
676
+ // Each deal can hold multiple named documents (different HTMLs); use
677
+ // html_docs_list to discover slugs.
678
+ function htmlUrl(dealId, slug) {
679
+ return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug ?? "main")}/html`;
680
+ }
681
+
682
+ server.registerTool(
683
+ "html_show",
684
+ {
685
+ description:
686
+ "Read the current hand-authored HTML 'deal page' for a deal. " +
687
+ "Returns {empty: true} if no one has uploaded HTML yet, or " +
688
+ "{empty: false, version, html, bytes, uploaded_by, source, " +
689
+ "created_at}. The HTML can be 5-500KB — be deliberate about " +
690
+ "including the body in your reply. Use html_versions if you " +
691
+ "just want the version list without the body. Each deal can " +
692
+ "have multiple named docs — pass documentSlug to target a " +
693
+ "non-'main' one (use html_docs_list to discover them).",
694
+ inputSchema: {
695
+ dealId: z.string().describe("deal uuid"),
696
+ documentSlug: z
697
+ .string()
698
+ .optional()
699
+ .describe("default: 'main'. Use html_docs_list to discover slugs."),
700
+ },
701
+ },
702
+ async ({ dealId, documentSlug }) =>
703
+ callApi("GET", htmlUrl(dealId, documentSlug))
704
+ );
705
+
706
+ server.registerTool(
707
+ "html_upload",
708
+ {
709
+ description:
710
+ "Upload (PUT) a new HTML version for a deal's /browse page. " +
711
+ "Creates a NEW version row — the previous version is retained " +
712
+ "and restorable. Triggers SSE push so any open viewer auto- " +
713
+ "refreshes. Constraints: HTML body MUST start with " +
714
+ "<!doctype html> or <html (case-insensitive); max 5 MB. ALWAYS " +
715
+ "call html_show first if anything exists — replace only the " +
716
+ "relevant section, don't lose unrelated content. Source defaults " +
717
+ "to 'agent' for MCP-originated uploads. Pass documentSlug to " +
718
+ "target a non-'main' doc — auto-creates the doc if it doesn't exist.",
719
+ inputSchema: {
720
+ dealId: z.string().describe("deal uuid"),
721
+ html: z.string().describe("complete HTML document"),
722
+ documentSlug: z
723
+ .string()
724
+ .optional()
725
+ .describe("default: 'main'"),
726
+ source: z
727
+ .enum(["web", "cli", "agent"])
728
+ .optional()
729
+ .describe("default: agent"),
730
+ },
731
+ },
732
+ async ({ dealId, html, documentSlug, source }) =>
733
+ callApi("PUT", htmlUrl(dealId, documentSlug), {
734
+ html,
735
+ source: source ?? "agent",
736
+ })
737
+ );
738
+
739
+ server.registerTool(
740
+ "html_versions",
741
+ {
742
+ description:
743
+ "List version history for a deal's /browse page HTML. Returns " +
744
+ "an array of {version, bytes, uploaded_by, source, created_at, " +
745
+ "deleted_at} — newest first, including soft-deleted versions. " +
746
+ "Use to find a target version for html_restore.",
747
+ inputSchema: {
748
+ dealId: z.string().describe("deal uuid"),
749
+ documentSlug: z.string().optional().describe("default: 'main'"),
750
+ },
751
+ },
752
+ async ({ dealId, documentSlug }) =>
753
+ callApi("GET", `${htmlUrl(dealId, documentSlug)}/history`)
754
+ );
755
+
756
+ server.registerTool(
757
+ "html_restore",
758
+ {
759
+ description:
760
+ "Restore an old HTML version by copying it forward as a new " +
761
+ "version (so the latest pointer moves to the restored content). " +
762
+ "Use html_versions first to discover the version number. " +
763
+ "Triggers SSE push.",
764
+ inputSchema: {
765
+ dealId: z.string().describe("deal uuid"),
766
+ version: z.number().int().positive().describe("version to restore"),
767
+ documentSlug: z.string().optional().describe("default: 'main'"),
768
+ },
769
+ },
770
+ async ({ dealId, version, documentSlug }) =>
771
+ callApi("POST", `${htmlUrl(dealId, documentSlug)}/restore/${version}`)
772
+ );
773
+
774
+ server.registerTool(
775
+ "html_docs_list",
776
+ {
777
+ description:
778
+ "List all documents (HTML 'pages') on a deal. Each deal can " +
779
+ "hold multiple — like a folder of files. Returns an array of " +
780
+ "{slug, title, preview_url, created_by, latest_version, " +
781
+ "latest_bytes, latest_uploaded_by, latest_updated_at}. The " +
782
+ "'main' slug is the default doc; non-main slugs are explicit.",
783
+ inputSchema: {
784
+ dealId: z.string().describe("deal uuid"),
785
+ },
786
+ },
787
+ async ({ dealId }) =>
788
+ callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/documents`)
789
+ );
790
+
791
+ server.registerTool(
792
+ "html_docs_create",
793
+ {
794
+ description:
795
+ "Create a NEW named document slot on a deal (metadata only — " +
796
+ "upload HTML separately via html_upload with the same slug). " +
797
+ "Slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — lowercase alnum + " +
798
+ "hyphen/underscore. Examples: 'ic-onepager', 'founder-brief', " +
799
+ "'market-map'. Title is for display; defaults to the slug.",
800
+ inputSchema: {
801
+ dealId: z.string().describe("deal uuid"),
802
+ slug: z.string().describe("URL-safe id, e.g. 'ic-onepager'"),
803
+ title: z.string().optional().describe("display title; defaults to slug"),
804
+ },
805
+ },
806
+ async ({ dealId, slug, title }) =>
807
+ callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/documents`, {
808
+ slug,
809
+ title: title ?? slug,
810
+ })
811
+ );
812
+
813
+ server.registerTool(
814
+ "html_docs_archive",
815
+ {
816
+ description:
817
+ "Archive a non-'main' doc — hides it from the selection page. " +
818
+ "HTML/asset versions are retained and the doc can be 'un-archived' " +
819
+ "later (currently via direct DB or by ensureDealDocument). The " +
820
+ "'main' doc cannot be archived (it's the default slot).",
821
+ inputSchema: {
822
+ dealId: z.string().describe("deal uuid"),
823
+ slug: z.string().describe("slug to archive (must not be 'main')"),
824
+ },
825
+ },
826
+ async ({ dealId, slug }) =>
827
+ callApi(
828
+ "DELETE",
829
+ `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`
830
+ )
831
+ );
832
+
833
+ server.registerTool(
834
+ "html_upload_bundle",
835
+ {
836
+ description:
837
+ "Upload HTML + binary assets as one atomic version. Use this " +
838
+ "INSTEAD of html_upload when the HTML references local images / " +
839
+ "fonts / CSS files via relative src=/href= attributes (typical " +
840
+ "of 'Save Page As Complete' exports). The server stores HTML + " +
841
+ "each asset as one transactional bundle (deal_browse_assets " +
842
+ "table), rewrites the HTML refs to version-pinned URLs at " +
843
+ "/api/deals/<id>/asset/<path>?v=N, and triggers SSE push. " +
844
+ "Constraints: HTML <= 5 MB; each asset <= 50 MB; total bundle " +
845
+ "<= 100 MB. Asset paths must match the relative refs in the HTML " +
846
+ "(no leading './', no '..' segments).",
847
+ inputSchema: {
848
+ dealId: z.string().describe("deal uuid"),
849
+ html: z.string().describe("complete HTML document"),
850
+ assets: z
851
+ .array(
852
+ z.object({
853
+ path: z
854
+ .string()
855
+ .describe(
856
+ "relative path matching the HTML's src/href ref " +
857
+ "(e.g. 'images/cover.png' or 'Foo_files/img.jpg')",
858
+ ),
859
+ contentType: z
860
+ .string()
861
+ .describe("MIME type, e.g. 'image/jpeg', 'font/woff2'"),
862
+ base64: z
863
+ .string()
864
+ .describe("base64-encoded file bytes (NO data:URI prefix)"),
865
+ }),
866
+ )
867
+ .min(1)
868
+ .describe("at least one asset (use html_upload if no assets)"),
869
+ documentSlug: z
870
+ .string()
871
+ .optional()
872
+ .describe("default: 'main'"),
873
+ source: z
874
+ .enum(["web", "cli", "agent"])
875
+ .optional()
876
+ .describe("default: agent"),
877
+ },
878
+ },
879
+ async ({ dealId, html, assets, documentSlug, source }) => {
880
+ const form = new FormData();
881
+ form.append("html", html);
882
+ form.append("source", source ?? "agent");
883
+ for (const a of assets) {
884
+ const bytes = Buffer.from(a.base64, "base64");
885
+ form.append(
886
+ `asset:${a.path}`,
887
+ new Blob([bytes], { type: a.contentType || "application/octet-stream" }),
888
+ a.path,
889
+ );
890
+ }
891
+ const headers = await getAuthHeaders();
892
+ const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
893
+ method: "PUT",
894
+ headers, // let fetch set multipart Content-Type with boundary
895
+ body: form,
896
+ });
897
+ const body = await res.json().catch(() => ({}));
898
+ if (!res.ok) {
899
+ throw new Error(
900
+ `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
901
+ );
902
+ }
903
+ return body;
904
+ },
905
+ );
906
+
907
+ server.registerTool(
908
+ "html_reset",
909
+ {
910
+ description:
911
+ "Soft-delete the latest HTML version for a deal. The /browse " +
912
+ "page reverts to its empty state (drop / paste / CLI / agent " +
913
+ "invitation). Old versions are retained and restorable via " +
914
+ "html_restore.",
915
+ inputSchema: {
916
+ dealId: z.string().describe("deal uuid"),
917
+ documentSlug: z.string().optional().describe("default: 'main'"),
918
+ },
919
+ },
920
+ async ({ dealId, documentSlug }) =>
921
+ callApi("DELETE", htmlUrl(dealId, documentSlug))
922
+ );
923
+
664
924
  // ============================================================
665
925
  // Prompts
666
926
  // ============================================================
package/bin/llama.mjs CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
32
32
  import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
33
33
 
34
- function parseFlags(args) {
34
+ function parseFlags(args, knownFlags = null) {
35
35
  const flags = {};
36
36
  const positional = [];
37
37
  for (let i = 0; i < args.length; i++) {
@@ -49,9 +49,82 @@ function parseFlags(args) {
49
49
  positional.push(arg);
50
50
  }
51
51
  }
52
+ // Opt-in unknown-flag warning. Handlers that pass a `knownFlags` array
53
+ // get a stderr nudge when they see typos like `--slug` for `--doc`.
54
+ // Don't reject — agents wrap legacy options, breaking them silently is
55
+ // worse than a one-line warning.
56
+ if (Array.isArray(knownFlags)) {
57
+ const known = new Set(knownFlags);
58
+ for (const key of Object.keys(flags)) {
59
+ if (!known.has(key)) {
60
+ const suggestion = closestKnownFlag(key, knownFlags);
61
+ process.stderr.write(
62
+ suggestion
63
+ ? `warning: unknown flag --${key} (did you mean --${suggestion}?)\n`
64
+ : `warning: unknown flag --${key}\n`,
65
+ );
66
+ }
67
+ }
68
+ }
52
69
  return { flags, positional };
53
70
  }
54
71
 
72
+ function closestKnownFlag(input, candidates) {
73
+ let best = null;
74
+ let bestScore = Infinity;
75
+ for (const c of candidates) {
76
+ const d = levenshtein(input, c);
77
+ const tolerance = Math.max(2, Math.floor(c.length / 3));
78
+ if (d < bestScore && d <= tolerance) {
79
+ best = c;
80
+ bestScore = d;
81
+ }
82
+ }
83
+ return best;
84
+ }
85
+
86
+ function levenshtein(a, b) {
87
+ if (a === b) return 0;
88
+ const m = a.length;
89
+ const n = b.length;
90
+ if (m === 0) return n;
91
+ if (n === 0) return m;
92
+ const dp = Array(n + 1).fill(0).map((_, i) => i);
93
+ for (let i = 1; i <= m; i++) {
94
+ let prev = dp[0];
95
+ dp[0] = i;
96
+ for (let j = 1; j <= n; j++) {
97
+ const tmp = dp[j];
98
+ dp[j] = a[i - 1] === b[j - 1]
99
+ ? prev
100
+ : 1 + Math.min(prev, dp[j - 1], dp[j]);
101
+ prev = tmp;
102
+ }
103
+ }
104
+ return dp[n];
105
+ }
106
+
107
+ // Slug shape used by deal_documents.slug (matches server-side SLUG_RE).
108
+ function isValidDocSlug(s) {
109
+ return typeof s === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(s);
110
+ }
111
+
112
+ // Best-effort title → slug. Strips diacritics, lowercases, collapses
113
+ // non-alnum to single hyphens, trims, caps at 64. Returns null if the
114
+ // result wouldn't pass `isValidDocSlug` (caller must then require --doc).
115
+ function slugifyTitle(title) {
116
+ if (typeof title !== "string") return null;
117
+ const slug = title
118
+ .toLowerCase()
119
+ .normalize("NFKD")
120
+ .replace(/[̀-ͯ]/g, "")
121
+ .replace(/[^a-z0-9]+/g, "-")
122
+ .replace(/^-+|-+$/g, "")
123
+ .slice(0, 64);
124
+ if (!slug || !/^[a-z0-9]/.test(slug)) return null;
125
+ return slug;
126
+ }
127
+
55
128
  // Client-side fuzzy match — used as a fallback when the server hasn't yet
56
129
  // shipped the search/filter API (Fix B, 2026-04-25). Once the server
57
130
  // returns the `{deals,total,limit,offset}` envelope, this path is never
@@ -262,6 +335,36 @@ Memo (long-form HTML investment memo — Memo tab in the UI):
262
335
  llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
263
336
  llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
264
337
 
338
+ Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
339
+ Each deal can host many HTML artifacts (IC report, dashboard, market map, …).
340
+ Each one has a stable slug. UPLOAD must declare intent — update an existing
341
+ artifact or add a new one — to avoid silent overwrites.
342
+
343
+ List existing artifacts:
344
+ llama html docs <dealId> # who-has-what
345
+ llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
346
+ llama html docs archive <dealId> <slug> # soft-archive (browse hides)
347
+
348
+ Update an EXISTING artifact (slug must exist):
349
+ llama html upload <dealId> --doc <slug> --file <path> [--assets DIR]
350
+
351
+ Add a NEW artifact (slug must NOT already exist):
352
+ llama html upload <dealId> --new --title "..." --file <path> [--doc <slug>] [--assets DIR]
353
+ (omit --doc → CLI slugifies the title; appends -2 / -3 on collision)
354
+
355
+ Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'
356
+ already has content — pass --doc main or --new --title "..." explicitly.
357
+
358
+ llama html show <dealId> [--doc <slug>] [--out <path>] [--json] # default: current html → stdout
359
+ llama html versions <dealId> [--doc <slug>] # list version history
360
+ llama html restore <dealId> <version> [--doc <slug>] # promote an old version to new latest
361
+ llama html reset <dealId> [--doc <slug>] # soft-delete latest; /browse reverts to empty
362
+
363
+ Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
364
+ triggers SSE push — any browser viewing /deals/<id>/browse refreshes
365
+ automatically. Same write path as the in-app deal agent's
366
+ update_deal_browse_html tool and the MCP html_upload_bundle tool.
367
+
265
368
  Admin (system admin only — server returns 403 for non-admin tokens):
266
369
  llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
267
370
  llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
@@ -1221,8 +1324,8 @@ https://command.llamaventures.vc/settings/tokens, run
1221
1324
  // Hits /api/wiki/<slug> directly. Earlier versions did a fuzzy
1222
1325
  // /api/wiki/search call and filtered for an exact slug match — that
1223
1326
  // missed any article whose slug-as-string didn't appear in title or
1224
- // content (e.g. "jack-feng" search vs "Jack Feng" content), so a real
1225
- // article would print as "not found" even though it existed.
1327
+ // content (e.g. a slug like "foo-bar" against an article titled "Foo Bar"),
1328
+ // so a real article would print as "not found" even though it existed.
1226
1329
  if (area === "wiki" && action === "read") {
1227
1330
  const { flags, positional } = parseFlags(rest);
1228
1331
  const slug = positional[0];
@@ -1792,6 +1895,540 @@ https://command.llamaventures.vc/settings/tokens, run
1792
1895
  );
1793
1896
  }
1794
1897
 
1898
+ // ============================================================
1899
+ // `llama html` family — per-deal hand-authored HTML "deal page"
1900
+ // ============================================================
1901
+ //
1902
+ // Each deal can have its own HTML browse view (sandboxed iframe).
1903
+ // Upload via this CLI, or directly via the web UI's drag-drop / paste,
1904
+ // or by the in-app deal agent via the update_deal_browse_html tool.
1905
+ // Every upload creates a new monotonic version; old versions are
1906
+ // soft-deleted on replace and can be restored.
1907
+ //
1908
+ // llama html show <dealId> [--out PATH] [--json]
1909
+ // llama html upload <dealId> --file PATH [--source cli|web|agent]
1910
+ // llama html versions <dealId>
1911
+ // llama html restore <dealId> <version>
1912
+ // llama html reset <dealId>
1913
+ if (area === "html") {
1914
+ const sub = action;
1915
+
1916
+ // --doc <slug> selects which named document on the deal (default 'main').
1917
+ // Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
1918
+ // to list available slugs.
1919
+ function htmlEndpoint(dealId, slug) {
1920
+ return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
1921
+ }
1922
+
1923
+ // docs — list / create / archive documents on a deal.
1924
+ //
1925
+ // Forms:
1926
+ // llama html docs <dealId> # list
1927
+ // llama html docs list <dealId> # list (explicit)
1928
+ // llama html docs create <dealId> <slug> [--title "..."]
1929
+ // llama html docs archive <dealId> <slug>
1930
+ if (sub === "docs") {
1931
+ const docSub = rest[0];
1932
+ const isExplicitSubcommand =
1933
+ docSub === "list" ||
1934
+ docSub === "create" ||
1935
+ docSub === "archive";
1936
+ if (!isExplicitSubcommand) {
1937
+ // First positional is the dealId (the common "just list" case).
1938
+ const dealId = rest[0];
1939
+ if (!dealId) {
1940
+ throw new Error(
1941
+ "Usage: llama html docs <dealId>\n" +
1942
+ " llama html docs create <dealId> <slug> --title \"...\"\n" +
1943
+ " llama html docs archive <dealId> <slug>",
1944
+ );
1945
+ }
1946
+ const data = await request(
1947
+ "GET",
1948
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
1949
+ );
1950
+ print(data);
1951
+ return;
1952
+ }
1953
+ if (docSub === "list") {
1954
+ const dealId = rest[1];
1955
+ if (!dealId) {
1956
+ throw new Error("Usage: llama html docs list <dealId>");
1957
+ }
1958
+ const data = await request(
1959
+ "GET",
1960
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
1961
+ );
1962
+ print(data);
1963
+ return;
1964
+ }
1965
+ if (docSub === "create") {
1966
+ const dealId = rest[1];
1967
+ const slug = rest[2];
1968
+ if (!dealId || !slug) {
1969
+ throw new Error(
1970
+ "Usage: llama html docs create <dealId> <slug> [--title \"...\"]",
1971
+ );
1972
+ }
1973
+ const { flags } = parseFlags(rest.slice(3));
1974
+ const title = flags.title ? String(flags.title) : slug;
1975
+ const data = await request(
1976
+ "POST",
1977
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
1978
+ { slug, title },
1979
+ );
1980
+ print(data);
1981
+ return;
1982
+ }
1983
+ if (docSub === "archive") {
1984
+ const dealId = rest[1];
1985
+ const slug = rest[2];
1986
+ if (!dealId || !slug) {
1987
+ throw new Error("Usage: llama html docs archive <dealId> <slug>");
1988
+ }
1989
+ const data = await request(
1990
+ "DELETE",
1991
+ `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
1992
+ );
1993
+ print(data);
1994
+ return;
1995
+ }
1996
+ throw new Error(
1997
+ `Unknown html docs subcommand "${docSub}". Use: list / create / archive.`,
1998
+ );
1999
+ }
2000
+
2001
+ // show — fetch the current HTML. Default: print to stdout (pipeable).
2002
+ if (sub === "show") {
2003
+ const dealId = rest[0];
2004
+ if (!dealId) {
2005
+ throw new Error("Usage: llama html show <dealId> [--doc SLUG] [--out PATH] [--json]");
2006
+ }
2007
+ const { flags } = parseFlags(rest.slice(1));
2008
+ const slug = typeof flags.doc === "string" && flags.doc.trim() ? flags.doc.trim() : "main";
2009
+ const data = await request("GET", htmlEndpoint(dealId, slug));
2010
+ if (flags.json) {
2011
+ print(data);
2012
+ return;
2013
+ }
2014
+ if (data?.empty) {
2015
+ throw new Error(
2016
+ `No HTML uploaded for deal ${dealId} yet. Upload via \`llama html upload\`, the web UI, or have the deal agent write it.`,
2017
+ );
2018
+ }
2019
+ const html = data?.html;
2020
+ if (typeof html !== "string") {
2021
+ throw new Error("browse-html response missing html field.");
2022
+ }
2023
+ if (flags.out) {
2024
+ const { writeFileSync } = await import("fs");
2025
+ writeFileSync(String(flags.out), html);
2026
+ console.error(
2027
+ `Wrote ${html.length} bytes (v${data.version}) → ${flags.out}`,
2028
+ );
2029
+ return;
2030
+ }
2031
+ // Stdout — supports `llama html show <id> > page.html` and piping
2032
+ // to e.g. `open -f -a Safari` for quick preview.
2033
+ process.stdout.write(html);
2034
+ return;
2035
+ }
2036
+
2037
+ // upload — PUT a new version. Reads HTML from --file or stdin. With
2038
+ // --assets <dir>, walks the folder, packages as a multipart bundle,
2039
+ // and the server stores HTML + per-asset BYTEA rows atomically
2040
+ // (deal_browse_assets table). Perfect for "Save Page As Complete"
2041
+ // exports — the sibling `_files/` folder maps 1-to-1 to assets.
2042
+ if (sub === "upload") {
2043
+ const dealId = rest[0];
2044
+ if (!dealId) {
2045
+ throw new Error(
2046
+ "Usage:\n" +
2047
+ " Update an existing artifact:\n" +
2048
+ " llama html upload <dealId> --doc <slug> --file PATH [--assets DIR]\n" +
2049
+ " Create a new artifact:\n" +
2050
+ " llama html upload <dealId> --new --title \"...\" --file PATH [--doc <slug>]\n" +
2051
+ " Stream from stdin (either form above with --stdin in place of --file PATH).\n" +
2052
+ "\n" +
2053
+ "Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'\n" +
2054
+ "already has content — pass --doc main to update it explicitly, or\n" +
2055
+ "--new --title \"...\" to add a NEW artifact alongside.",
2056
+ );
2057
+ }
2058
+ const knownFlags = [
2059
+ "doc", "slug", "new", "title",
2060
+ "file", "stdin", "assets", "source",
2061
+ ];
2062
+ const { flags } = parseFlags(rest.slice(1), knownFlags);
2063
+
2064
+ // --slug is the natural agent guess (DB column is `document_slug`).
2065
+ // Accept it as an alias for --doc so the failure mode that bit
2066
+ // Gavin (silent fall-through to 'main') can't happen again.
2067
+ if (flags.slug && !flags.doc) {
2068
+ process.stderr.write("note: --slug accepted as alias for --doc.\n");
2069
+ flags.doc = flags.slug;
2070
+ } else if (flags.slug && flags.doc) {
2071
+ process.stderr.write("note: both --doc and --slug given; --doc wins.\n");
2072
+ }
2073
+
2074
+ const isNew = Boolean(flags.new);
2075
+ const explicitDoc =
2076
+ typeof flags.doc === "string" && flags.doc.trim()
2077
+ ? flags.doc.trim()
2078
+ : null;
2079
+ const titleFlag =
2080
+ typeof flags.title === "string" && flags.title.trim()
2081
+ ? flags.title.trim()
2082
+ : null;
2083
+
2084
+ // Pre-flight: ask the server what slugs already exist on this deal.
2085
+ // One extra GET round-trip — cheap insurance against silent overwrite.
2086
+ let existing = [];
2087
+ try {
2088
+ const docList = await request(
2089
+ "GET",
2090
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
2091
+ );
2092
+ existing = Array.isArray(docList?.documents) ? docList.documents : [];
2093
+ } catch (err) {
2094
+ // If the deal exists but the list endpoint somehow errors, we
2095
+ // shouldn't block the whole upload — surface the warning and
2096
+ // proceed in "no existing docs" mode. The server is still the
2097
+ // ultimate gate for permission failures.
2098
+ process.stderr.write(
2099
+ `warning: could not pre-check existing documents (${err.message}). Continuing.\n`,
2100
+ );
2101
+ }
2102
+ const findDoc = (s) =>
2103
+ existing.find((d) => d && d.slug === s) || null;
2104
+ const docHasHtml = (d) =>
2105
+ Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
2106
+
2107
+ let slug;
2108
+ let mode; // 'created' | 'updated'
2109
+
2110
+ if (isNew) {
2111
+ // Create-new branch. Caller must provide --doc OR --title (we
2112
+ // derive the slug from the title in the latter case).
2113
+ let candidate = explicitDoc || (titleFlag ? slugifyTitle(titleFlag) : null);
2114
+ if (!candidate) {
2115
+ throw new Error(
2116
+ "--new requires --doc <slug> or --title \"...\" so the new artifact has a stable identifier.",
2117
+ );
2118
+ }
2119
+ if (!isValidDocSlug(candidate)) {
2120
+ throw new Error(
2121
+ `slug "${candidate}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
2122
+ );
2123
+ }
2124
+ if (findDoc(candidate)) {
2125
+ if (explicitDoc) {
2126
+ const existingDoc = findDoc(candidate);
2127
+ const meta = docHasHtml(existingDoc)
2128
+ ? ` (currently at v${existingDoc.latest_version}, last update ${existingDoc.latest_updated_at})`
2129
+ : "";
2130
+ throw new Error(
2131
+ `--new --doc ${candidate} but a document with slug "${candidate}" already exists${meta}.\n` +
2132
+ `Pick a different slug, or drop --new to UPDATE the existing one.`,
2133
+ );
2134
+ }
2135
+ // Auto-resolve title collisions: foo -> foo-2 -> foo-3 -> ...
2136
+ let suffix = 2;
2137
+ while (findDoc(`${candidate}-${suffix}`)) suffix++;
2138
+ const oldCandidate = candidate;
2139
+ candidate = `${candidate}-${suffix}`;
2140
+ process.stderr.write(
2141
+ `note: slug "${oldCandidate}" already in use; using "${candidate}" instead.\n`,
2142
+ );
2143
+ }
2144
+ slug = candidate;
2145
+ mode = "created";
2146
+ // Stamp the doc metadata first (title, etc.) so the UI selection
2147
+ // page shows a nice name. PUT auto-creates the row too, but
2148
+ // POST gives us a chance to set --title.
2149
+ await request(
2150
+ "POST",
2151
+ `/api/deals/${encodeURIComponent(dealId)}/documents`,
2152
+ { slug, title: titleFlag || slug },
2153
+ );
2154
+ } else if (explicitDoc) {
2155
+ // Update an existing slug.
2156
+ if (!isValidDocSlug(explicitDoc)) {
2157
+ throw new Error(
2158
+ `slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
2159
+ );
2160
+ }
2161
+ const target = findDoc(explicitDoc);
2162
+ if (!target) {
2163
+ if (explicitDoc === "main") {
2164
+ // 'main' is the legacy default — fine to auto-init on first
2165
+ // upload to an empty deal.
2166
+ slug = "main";
2167
+ mode = "created";
2168
+ } else {
2169
+ const slugList = existing.length
2170
+ ? existing.map((d) => d.slug).join(", ")
2171
+ : "(none)";
2172
+ throw new Error(
2173
+ `No document with slug "${explicitDoc}" exists on this deal.\n` +
2174
+ `To create it: add --new --title "..."\n` +
2175
+ `Or pre-create: llama html docs create ${dealId} ${explicitDoc} --title "..."\n` +
2176
+ `Existing slugs: ${slugList}`,
2177
+ );
2178
+ }
2179
+ } else {
2180
+ slug = explicitDoc;
2181
+ mode = docHasHtml(target) ? "updated" : "created";
2182
+ }
2183
+ } else {
2184
+ // Bare upload — no --doc, no --new. Safe-default to 'main' only
2185
+ // if 'main' is empty / absent. Otherwise refuse, naming the
2186
+ // existing artifact so the caller can pick an explicit intent.
2187
+ const main = findDoc("main");
2188
+ if (docHasHtml(main)) {
2189
+ const versionInfo = main.latest_version
2190
+ ? ` (v${main.latest_version}, ${main.latest_updated_at || "last update unknown"})`
2191
+ : "";
2192
+ const slugList = existing.length
2193
+ ? existing.map((d) => d.slug).join(", ")
2194
+ : "main";
2195
+ throw new Error(
2196
+ `Refusing to silently overwrite the existing 'main' artifact${versionInfo}.\n` +
2197
+ `\n` +
2198
+ `If you meant to UPDATE 'main': --doc main\n` +
2199
+ `If you meant to add a NEW artifact: --new --title "<name>"\n` +
2200
+ `\n` +
2201
+ `Existing slugs on this deal: ${slugList}\n` +
2202
+ `List details: llama html docs ${dealId}`,
2203
+ );
2204
+ }
2205
+ slug = "main";
2206
+ mode = main ? "updated" : "created";
2207
+ }
2208
+
2209
+ let html;
2210
+ if (flags.file) {
2211
+ const { readFileSync } = await import("fs");
2212
+ html = readFileSync(String(flags.file), "utf8");
2213
+ } else if (flags.stdin) {
2214
+ const chunks = [];
2215
+ for await (const chunk of process.stdin) chunks.push(chunk);
2216
+ html = Buffer.concat(chunks).toString("utf8");
2217
+ } else {
2218
+ throw new Error(
2219
+ "Pass --file <path> to upload a file, or --stdin to read from stdin.",
2220
+ );
2221
+ }
2222
+ if (!html || !html.trim()) {
2223
+ throw new Error("HTML body is empty.");
2224
+ }
2225
+ const source =
2226
+ typeof flags.source === "string" && flags.source.trim()
2227
+ ? flags.source.trim()
2228
+ : "cli";
2229
+
2230
+ // No --assets → JSON path (small, faster).
2231
+ if (!flags.assets) {
2232
+ const data = await request("PUT", htmlEndpoint(dealId, slug), {
2233
+ html,
2234
+ source,
2235
+ });
2236
+ print({
2237
+ ok: true,
2238
+ mode,
2239
+ document_slug: slug,
2240
+ version: data?.version,
2241
+ bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
2242
+ deal_uuid: dealId,
2243
+ viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
2244
+ });
2245
+ return;
2246
+ }
2247
+
2248
+ // --assets path → multipart bundle. Walk the asset directory,
2249
+ // attach every file as `asset:<relativePath>`, and let the server
2250
+ // rewrite the HTML refs to /api/deals/<id>/asset/<path>?v=N.
2251
+ const { readFileSync, readdirSync, statSync } = await import("fs");
2252
+ const { join, relative, sep, basename } = await import("path");
2253
+ const assetsRoot = String(flags.assets);
2254
+ const assetsRootStat = statSync(assetsRoot);
2255
+ if (!assetsRootStat.isDirectory()) {
2256
+ throw new Error(`--assets must point to a directory: ${assetsRoot}`);
2257
+ }
2258
+
2259
+ // Recursively collect every file under the assets root.
2260
+ const collected = []; // { absPath, relPath, bytes }
2261
+ const walk = (dir) => {
2262
+ for (const name of readdirSync(dir)) {
2263
+ const abs = join(dir, name);
2264
+ const st = statSync(abs);
2265
+ if (st.isDirectory()) {
2266
+ walk(abs);
2267
+ } else if (st.isFile()) {
2268
+ const rel = relative(assetsRoot, abs).split(sep).join("/");
2269
+ collected.push({ absPath: abs, relPath: rel, bytes: st.size });
2270
+ }
2271
+ }
2272
+ };
2273
+ walk(assetsRoot);
2274
+ if (collected.length === 0) {
2275
+ throw new Error(`--assets directory is empty: ${assetsRoot}`);
2276
+ }
2277
+
2278
+ // Some "Save Page As" exports put assets in a sibling folder named
2279
+ // after the HTML (e.g. "Foo.html" + "Foo_files/"). When the HTML
2280
+ // references "./Foo_files/img.png" but we walk just the inner dir,
2281
+ // the rel paths don't match. Detect this case: if the assets root's
2282
+ // basename is "<something>_files" or "<something> files", the HTML
2283
+ // probably uses that prefix — prepend it to each relPath.
2284
+ const rootName = basename(assetsRoot);
2285
+ const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
2286
+ const finalPaths = looksLikeSavePageDir
2287
+ ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
2288
+ : collected;
2289
+
2290
+ // Mime sniff from extension. Server defaults to
2291
+ // application/octet-stream if blob.type is empty.
2292
+ const mimeFor = (path) => {
2293
+ const ext = (path.split(".").pop() || "").toLowerCase();
2294
+ return (
2295
+ {
2296
+ jpg: "image/jpeg",
2297
+ jpeg: "image/jpeg",
2298
+ png: "image/png",
2299
+ gif: "image/gif",
2300
+ webp: "image/webp",
2301
+ svg: "image/svg+xml",
2302
+ ico: "image/x-icon",
2303
+ avif: "image/avif",
2304
+ css: "text/css",
2305
+ js: "text/javascript",
2306
+ json: "application/json",
2307
+ woff: "font/woff",
2308
+ woff2: "font/woff2",
2309
+ ttf: "font/ttf",
2310
+ otf: "font/otf",
2311
+ mp4: "video/mp4",
2312
+ webm: "video/webm",
2313
+ pdf: "application/pdf",
2314
+ }[ext] || "application/octet-stream"
2315
+ );
2316
+ };
2317
+
2318
+ const form = new FormData();
2319
+ form.append("html", html);
2320
+ form.append("source", source);
2321
+ let totalBytes = 0;
2322
+ for (const { absPath, relPath } of finalPaths) {
2323
+ const buf = readFileSync(absPath);
2324
+ totalBytes += buf.length;
2325
+ // FormData wants a Blob; in Node 20+ Blob is global and accepts Buffer.
2326
+ form.append(
2327
+ `asset:${relPath}`,
2328
+ new Blob([buf], { type: mimeFor(relPath) }),
2329
+ relPath,
2330
+ );
2331
+ }
2332
+
2333
+ console.error(
2334
+ `Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${finalPaths.length} assets (${totalBytes} bytes)`,
2335
+ );
2336
+
2337
+ const headers = await getAuthHeaders();
2338
+ const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
2339
+ method: "PUT",
2340
+ headers: { ...headers /* let fetch set the multipart boundary */ },
2341
+ body: form,
2342
+ });
2343
+ const body = await res.json().catch(() => ({}));
2344
+ if (!res.ok) {
2345
+ throw new Error(
2346
+ `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
2347
+ );
2348
+ }
2349
+ print({
2350
+ ok: true,
2351
+ mode,
2352
+ document_slug: slug,
2353
+ version: body.version,
2354
+ asset_count: body.asset_count,
2355
+ asset_bytes: body.asset_bytes,
2356
+ deal_uuid: dealId,
2357
+ viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
2358
+ });
2359
+ return;
2360
+ }
2361
+
2362
+ // versions — list version history (newest first, includes soft-deleted).
2363
+ if (sub === "versions") {
2364
+ const dealId = rest[0];
2365
+ if (!dealId) {
2366
+ throw new Error("Usage: llama html versions <dealId> [--doc SLUG]");
2367
+ }
2368
+ const { flags } = parseFlags(rest.slice(1));
2369
+ const slug =
2370
+ typeof flags.doc === "string" && flags.doc.trim()
2371
+ ? flags.doc.trim()
2372
+ : "main";
2373
+ const data = await request("GET", `${htmlEndpoint(dealId, slug)}/history`);
2374
+ print(data);
2375
+ return;
2376
+ }
2377
+
2378
+ // restore — re-promote an old version as the new latest.
2379
+ if (sub === "restore") {
2380
+ const dealId = rest[0];
2381
+ const version = Number(rest[1]);
2382
+ if (!dealId || !Number.isFinite(version)) {
2383
+ throw new Error(
2384
+ "Usage: llama html restore <dealId> <version> [--doc SLUG]",
2385
+ );
2386
+ }
2387
+ const { flags } = parseFlags(rest.slice(2));
2388
+ const slug =
2389
+ typeof flags.doc === "string" && flags.doc.trim()
2390
+ ? flags.doc.trim()
2391
+ : "main";
2392
+ const data = await request(
2393
+ "POST",
2394
+ `${htmlEndpoint(dealId, slug)}/restore/${version}`,
2395
+ );
2396
+ print({
2397
+ ok: true,
2398
+ document_slug: slug,
2399
+ restored_from: version,
2400
+ new_version: data?.version,
2401
+ deal_uuid: dealId,
2402
+ });
2403
+ return;
2404
+ }
2405
+
2406
+ // reset — soft-delete the current HTML. /browse page reverts to empty state.
2407
+ if (sub === "reset" || sub === "delete") {
2408
+ const dealId = rest[0];
2409
+ if (!dealId) {
2410
+ throw new Error("Usage: llama html reset <dealId> [--doc SLUG]");
2411
+ }
2412
+ const { flags } = parseFlags(rest.slice(1));
2413
+ const slug =
2414
+ typeof flags.doc === "string" && flags.doc.trim()
2415
+ ? flags.doc.trim()
2416
+ : "main";
2417
+ const data = await request("DELETE", htmlEndpoint(dealId, slug));
2418
+ print({
2419
+ ok: true,
2420
+ document_slug: slug,
2421
+ soft_deleted_version: data?.version ?? null,
2422
+ deal_uuid: dealId,
2423
+ });
2424
+ return;
2425
+ }
2426
+
2427
+ throw new Error(
2428
+ `Unknown html subcommand "${sub || ""}". Use: docs / show / upload / versions / restore / reset.`,
2429
+ );
2430
+ }
2431
+
1795
2432
  usage();
1796
2433
  process.exitCode = 1;
1797
2434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
5
5
  "type": "module",
6
6
  "bin": {