@llamaventures/cli 1.4.3 → 1.4.4
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 +10 -0
- package/bin/llama-mcp.mjs +260 -0
- package/bin/llama.mjs +394 -2
- package/package.json +1 -1
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>`) | **`llama html upload <dealId> --file <path> [--assets <dir>]`** — this is the **default** path when the user says "deploy to llama", "deploy to llama command", "部署到 llama command", "put this HTML on the deal page", "在 deal 里看这个". 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,14 @@ 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> (PR #81)
|
|
112
|
+
# Default path when user says "deploy to llama / 部署到 llama command / put this HTML on the deal page".
|
|
113
|
+
llama html upload <dealId> --file ./report.html [--assets ./assets] # PUT new version (auto-increments)
|
|
114
|
+
llama html show <dealId> [--out path] [--json] # current HTML → stdout
|
|
115
|
+
llama html versions <dealId> # version history (incl. soft-deleted)
|
|
116
|
+
llama html restore <dealId> <version> # promote old version to latest
|
|
117
|
+
llama html reset <dealId> # soft-delete latest (browse reverts to empty)
|
|
118
|
+
|
|
109
119
|
# Wiki (knowledge base)
|
|
110
120
|
llama wiki search "<query>"
|
|
111
121
|
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
|
@@ -262,6 +262,20 @@ Memo (long-form HTML investment memo — Memo tab in the UI):
|
|
|
262
262
|
llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
|
|
263
263
|
llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
|
|
264
264
|
|
|
265
|
+
Deal page HTML (hand-authored sandboxed page on /deals/<id>/browse):
|
|
266
|
+
llama html show <dealId> [--out <path>] [--json] # default: current html → stdout (pipeable)
|
|
267
|
+
llama html upload <dealId> --file <path> [--assets DIR] # PUT a new version (auto increments).
|
|
268
|
+
# --assets bundles a sibling folder of images / fonts / css
|
|
269
|
+
# (e.g. "Save Page As Complete" exports — the _files/ dir)
|
|
270
|
+
llama html versions <dealId> # list version history (incl. soft-deleted)
|
|
271
|
+
llama html restore <dealId> <version> # promote an old version to new latest
|
|
272
|
+
llama html reset <dealId> # soft-delete latest; /browse reverts to empty state
|
|
273
|
+
|
|
274
|
+
Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
|
|
275
|
+
triggers SSE push — any browser viewing /deals/<id>/browse refreshes
|
|
276
|
+
automatically. Same write path as the in-app deal agent's
|
|
277
|
+
update_deal_browse_html tool and the MCP html_upload_bundle tool.
|
|
278
|
+
|
|
265
279
|
Admin (system admin only — server returns 403 for non-admin tokens):
|
|
266
280
|
llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
|
|
267
281
|
llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
|
|
@@ -1221,8 +1235,8 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1221
1235
|
// Hits /api/wiki/<slug> directly. Earlier versions did a fuzzy
|
|
1222
1236
|
// /api/wiki/search call and filtered for an exact slug match — that
|
|
1223
1237
|
// missed any article whose slug-as-string didn't appear in title or
|
|
1224
|
-
// content (e.g. "
|
|
1225
|
-
// article would print as "not found" even though it existed.
|
|
1238
|
+
// content (e.g. a slug like "foo-bar" against an article titled "Foo Bar"),
|
|
1239
|
+
// so a real article would print as "not found" even though it existed.
|
|
1226
1240
|
if (area === "wiki" && action === "read") {
|
|
1227
1241
|
const { flags, positional } = parseFlags(rest);
|
|
1228
1242
|
const slug = positional[0];
|
|
@@ -1792,6 +1806,384 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1792
1806
|
);
|
|
1793
1807
|
}
|
|
1794
1808
|
|
|
1809
|
+
// ============================================================
|
|
1810
|
+
// `llama html` family — per-deal hand-authored HTML "deal page"
|
|
1811
|
+
// ============================================================
|
|
1812
|
+
//
|
|
1813
|
+
// Each deal can have its own HTML browse view (sandboxed iframe).
|
|
1814
|
+
// Upload via this CLI, or directly via the web UI's drag-drop / paste,
|
|
1815
|
+
// or by the in-app deal agent via the update_deal_browse_html tool.
|
|
1816
|
+
// Every upload creates a new monotonic version; old versions are
|
|
1817
|
+
// soft-deleted on replace and can be restored.
|
|
1818
|
+
//
|
|
1819
|
+
// llama html show <dealId> [--out PATH] [--json]
|
|
1820
|
+
// llama html upload <dealId> --file PATH [--source cli|web|agent]
|
|
1821
|
+
// llama html versions <dealId>
|
|
1822
|
+
// llama html restore <dealId> <version>
|
|
1823
|
+
// llama html reset <dealId>
|
|
1824
|
+
if (area === "html") {
|
|
1825
|
+
const sub = action;
|
|
1826
|
+
|
|
1827
|
+
// --doc <slug> selects which named document on the deal (default 'main').
|
|
1828
|
+
// Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
|
|
1829
|
+
// to list available slugs.
|
|
1830
|
+
function htmlEndpoint(dealId, slug) {
|
|
1831
|
+
return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// docs — list / create / archive documents on a deal.
|
|
1835
|
+
//
|
|
1836
|
+
// Forms:
|
|
1837
|
+
// llama html docs <dealId> # list
|
|
1838
|
+
// llama html docs list <dealId> # list (explicit)
|
|
1839
|
+
// llama html docs create <dealId> <slug> [--title "..."]
|
|
1840
|
+
// llama html docs archive <dealId> <slug>
|
|
1841
|
+
if (sub === "docs") {
|
|
1842
|
+
const docSub = rest[0];
|
|
1843
|
+
const isExplicitSubcommand =
|
|
1844
|
+
docSub === "list" ||
|
|
1845
|
+
docSub === "create" ||
|
|
1846
|
+
docSub === "archive";
|
|
1847
|
+
if (!isExplicitSubcommand) {
|
|
1848
|
+
// First positional is the dealId (the common "just list" case).
|
|
1849
|
+
const dealId = rest[0];
|
|
1850
|
+
if (!dealId) {
|
|
1851
|
+
throw new Error(
|
|
1852
|
+
"Usage: llama html docs <dealId>\n" +
|
|
1853
|
+
" llama html docs create <dealId> <slug> --title \"...\"\n" +
|
|
1854
|
+
" llama html docs archive <dealId> <slug>",
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
const data = await request(
|
|
1858
|
+
"GET",
|
|
1859
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
1860
|
+
);
|
|
1861
|
+
print(data);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (docSub === "list") {
|
|
1865
|
+
const dealId = rest[1];
|
|
1866
|
+
if (!dealId) {
|
|
1867
|
+
throw new Error("Usage: llama html docs list <dealId>");
|
|
1868
|
+
}
|
|
1869
|
+
const data = await request(
|
|
1870
|
+
"GET",
|
|
1871
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
1872
|
+
);
|
|
1873
|
+
print(data);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if (docSub === "create") {
|
|
1877
|
+
const dealId = rest[1];
|
|
1878
|
+
const slug = rest[2];
|
|
1879
|
+
if (!dealId || !slug) {
|
|
1880
|
+
throw new Error(
|
|
1881
|
+
"Usage: llama html docs create <dealId> <slug> [--title \"...\"]",
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
const { flags } = parseFlags(rest.slice(3));
|
|
1885
|
+
const title = flags.title ? String(flags.title) : slug;
|
|
1886
|
+
const data = await request(
|
|
1887
|
+
"POST",
|
|
1888
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
1889
|
+
{ slug, title },
|
|
1890
|
+
);
|
|
1891
|
+
print(data);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
if (docSub === "archive") {
|
|
1895
|
+
const dealId = rest[1];
|
|
1896
|
+
const slug = rest[2];
|
|
1897
|
+
if (!dealId || !slug) {
|
|
1898
|
+
throw new Error("Usage: llama html docs archive <dealId> <slug>");
|
|
1899
|
+
}
|
|
1900
|
+
const data = await request(
|
|
1901
|
+
"DELETE",
|
|
1902
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
|
|
1903
|
+
);
|
|
1904
|
+
print(data);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
throw new Error(
|
|
1908
|
+
`Unknown html docs subcommand "${docSub}". Use: list / create / archive.`,
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// show — fetch the current HTML. Default: print to stdout (pipeable).
|
|
1913
|
+
if (sub === "show") {
|
|
1914
|
+
const dealId = rest[0];
|
|
1915
|
+
if (!dealId) {
|
|
1916
|
+
throw new Error("Usage: llama html show <dealId> [--doc SLUG] [--out PATH] [--json]");
|
|
1917
|
+
}
|
|
1918
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
1919
|
+
const slug = typeof flags.doc === "string" && flags.doc.trim() ? flags.doc.trim() : "main";
|
|
1920
|
+
const data = await request("GET", htmlEndpoint(dealId, slug));
|
|
1921
|
+
if (flags.json) {
|
|
1922
|
+
print(data);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (data?.empty) {
|
|
1926
|
+
throw new Error(
|
|
1927
|
+
`No HTML uploaded for deal ${dealId} yet. Upload via \`llama html upload\`, the web UI, or have the deal agent write it.`,
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
const html = data?.html;
|
|
1931
|
+
if (typeof html !== "string") {
|
|
1932
|
+
throw new Error("browse-html response missing html field.");
|
|
1933
|
+
}
|
|
1934
|
+
if (flags.out) {
|
|
1935
|
+
const { writeFileSync } = await import("fs");
|
|
1936
|
+
writeFileSync(String(flags.out), html);
|
|
1937
|
+
console.error(
|
|
1938
|
+
`Wrote ${html.length} bytes (v${data.version}) → ${flags.out}`,
|
|
1939
|
+
);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
// Stdout — supports `llama html show <id> > page.html` and piping
|
|
1943
|
+
// to e.g. `open -f -a Safari` for quick preview.
|
|
1944
|
+
process.stdout.write(html);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// upload — PUT a new version. Reads HTML from --file or stdin. With
|
|
1949
|
+
// --assets <dir>, walks the folder, packages as a multipart bundle,
|
|
1950
|
+
// and the server stores HTML + per-asset BYTEA rows atomically
|
|
1951
|
+
// (deal_browse_assets table). Perfect for "Save Page As Complete"
|
|
1952
|
+
// exports — the sibling `_files/` folder maps 1-to-1 to assets.
|
|
1953
|
+
if (sub === "upload") {
|
|
1954
|
+
const dealId = rest[0];
|
|
1955
|
+
if (!dealId) {
|
|
1956
|
+
throw new Error(
|
|
1957
|
+
"Usage: llama html upload <dealId> --file PATH [--doc SLUG] [--assets DIR] [--source cli|agent]\n" +
|
|
1958
|
+
" echo '<!doctype html>...' | llama html upload <dealId> --stdin [--doc SLUG]",
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
1962
|
+
const slug =
|
|
1963
|
+
typeof flags.doc === "string" && flags.doc.trim()
|
|
1964
|
+
? flags.doc.trim()
|
|
1965
|
+
: "main";
|
|
1966
|
+
let html;
|
|
1967
|
+
if (flags.file) {
|
|
1968
|
+
const { readFileSync } = await import("fs");
|
|
1969
|
+
html = readFileSync(String(flags.file), "utf8");
|
|
1970
|
+
} else if (flags.stdin) {
|
|
1971
|
+
const chunks = [];
|
|
1972
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1973
|
+
html = Buffer.concat(chunks).toString("utf8");
|
|
1974
|
+
} else {
|
|
1975
|
+
throw new Error(
|
|
1976
|
+
"Pass --file <path> to upload a file, or --stdin to read from stdin.",
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
if (!html || !html.trim()) {
|
|
1980
|
+
throw new Error("HTML body is empty.");
|
|
1981
|
+
}
|
|
1982
|
+
const source =
|
|
1983
|
+
typeof flags.source === "string" && flags.source.trim()
|
|
1984
|
+
? flags.source.trim()
|
|
1985
|
+
: "cli";
|
|
1986
|
+
|
|
1987
|
+
// No --assets → JSON path (small, faster).
|
|
1988
|
+
if (!flags.assets) {
|
|
1989
|
+
const data = await request("PUT", htmlEndpoint(dealId, slug), {
|
|
1990
|
+
html,
|
|
1991
|
+
source,
|
|
1992
|
+
});
|
|
1993
|
+
print({
|
|
1994
|
+
ok: true,
|
|
1995
|
+
document_slug: slug,
|
|
1996
|
+
version: data?.version,
|
|
1997
|
+
bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
|
|
1998
|
+
deal_uuid: dealId,
|
|
1999
|
+
viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
|
|
2000
|
+
});
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// --assets path → multipart bundle. Walk the asset directory,
|
|
2005
|
+
// attach every file as `asset:<relativePath>`, and let the server
|
|
2006
|
+
// rewrite the HTML refs to /api/deals/<id>/asset/<path>?v=N.
|
|
2007
|
+
const { readFileSync, readdirSync, statSync } = await import("fs");
|
|
2008
|
+
const { join, relative, sep, basename } = await import("path");
|
|
2009
|
+
const assetsRoot = String(flags.assets);
|
|
2010
|
+
const assetsRootStat = statSync(assetsRoot);
|
|
2011
|
+
if (!assetsRootStat.isDirectory()) {
|
|
2012
|
+
throw new Error(`--assets must point to a directory: ${assetsRoot}`);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Recursively collect every file under the assets root.
|
|
2016
|
+
const collected = []; // { absPath, relPath, bytes }
|
|
2017
|
+
const walk = (dir) => {
|
|
2018
|
+
for (const name of readdirSync(dir)) {
|
|
2019
|
+
const abs = join(dir, name);
|
|
2020
|
+
const st = statSync(abs);
|
|
2021
|
+
if (st.isDirectory()) {
|
|
2022
|
+
walk(abs);
|
|
2023
|
+
} else if (st.isFile()) {
|
|
2024
|
+
const rel = relative(assetsRoot, abs).split(sep).join("/");
|
|
2025
|
+
collected.push({ absPath: abs, relPath: rel, bytes: st.size });
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
walk(assetsRoot);
|
|
2030
|
+
if (collected.length === 0) {
|
|
2031
|
+
throw new Error(`--assets directory is empty: ${assetsRoot}`);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Some "Save Page As" exports put assets in a sibling folder named
|
|
2035
|
+
// after the HTML (e.g. "Foo.html" + "Foo_files/"). When the HTML
|
|
2036
|
+
// references "./Foo_files/img.png" but we walk just the inner dir,
|
|
2037
|
+
// the rel paths don't match. Detect this case: if the assets root's
|
|
2038
|
+
// basename is "<something>_files" or "<something> files", the HTML
|
|
2039
|
+
// probably uses that prefix — prepend it to each relPath.
|
|
2040
|
+
const rootName = basename(assetsRoot);
|
|
2041
|
+
const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
|
|
2042
|
+
const finalPaths = looksLikeSavePageDir
|
|
2043
|
+
? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
|
|
2044
|
+
: collected;
|
|
2045
|
+
|
|
2046
|
+
// Mime sniff from extension. Server defaults to
|
|
2047
|
+
// application/octet-stream if blob.type is empty.
|
|
2048
|
+
const mimeFor = (path) => {
|
|
2049
|
+
const ext = (path.split(".").pop() || "").toLowerCase();
|
|
2050
|
+
return (
|
|
2051
|
+
{
|
|
2052
|
+
jpg: "image/jpeg",
|
|
2053
|
+
jpeg: "image/jpeg",
|
|
2054
|
+
png: "image/png",
|
|
2055
|
+
gif: "image/gif",
|
|
2056
|
+
webp: "image/webp",
|
|
2057
|
+
svg: "image/svg+xml",
|
|
2058
|
+
ico: "image/x-icon",
|
|
2059
|
+
avif: "image/avif",
|
|
2060
|
+
css: "text/css",
|
|
2061
|
+
js: "text/javascript",
|
|
2062
|
+
json: "application/json",
|
|
2063
|
+
woff: "font/woff",
|
|
2064
|
+
woff2: "font/woff2",
|
|
2065
|
+
ttf: "font/ttf",
|
|
2066
|
+
otf: "font/otf",
|
|
2067
|
+
mp4: "video/mp4",
|
|
2068
|
+
webm: "video/webm",
|
|
2069
|
+
pdf: "application/pdf",
|
|
2070
|
+
}[ext] || "application/octet-stream"
|
|
2071
|
+
);
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
const form = new FormData();
|
|
2075
|
+
form.append("html", html);
|
|
2076
|
+
form.append("source", source);
|
|
2077
|
+
let totalBytes = 0;
|
|
2078
|
+
for (const { absPath, relPath } of finalPaths) {
|
|
2079
|
+
const buf = readFileSync(absPath);
|
|
2080
|
+
totalBytes += buf.length;
|
|
2081
|
+
// FormData wants a Blob; in Node 20+ Blob is global and accepts Buffer.
|
|
2082
|
+
form.append(
|
|
2083
|
+
`asset:${relPath}`,
|
|
2084
|
+
new Blob([buf], { type: mimeFor(relPath) }),
|
|
2085
|
+
relPath,
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
console.error(
|
|
2090
|
+
`Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${finalPaths.length} assets (${totalBytes} bytes)`,
|
|
2091
|
+
);
|
|
2092
|
+
|
|
2093
|
+
const headers = await getAuthHeaders();
|
|
2094
|
+
const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
|
|
2095
|
+
method: "PUT",
|
|
2096
|
+
headers: { ...headers /* let fetch set the multipart boundary */ },
|
|
2097
|
+
body: form,
|
|
2098
|
+
});
|
|
2099
|
+
const body = await res.json().catch(() => ({}));
|
|
2100
|
+
if (!res.ok) {
|
|
2101
|
+
throw new Error(
|
|
2102
|
+
`HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
print({
|
|
2106
|
+
ok: true,
|
|
2107
|
+
document_slug: slug,
|
|
2108
|
+
version: body.version,
|
|
2109
|
+
asset_count: body.asset_count,
|
|
2110
|
+
asset_bytes: body.asset_bytes,
|
|
2111
|
+
deal_uuid: dealId,
|
|
2112
|
+
viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
|
|
2113
|
+
});
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// versions — list version history (newest first, includes soft-deleted).
|
|
2118
|
+
if (sub === "versions") {
|
|
2119
|
+
const dealId = rest[0];
|
|
2120
|
+
if (!dealId) {
|
|
2121
|
+
throw new Error("Usage: llama html versions <dealId> [--doc SLUG]");
|
|
2122
|
+
}
|
|
2123
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
2124
|
+
const slug =
|
|
2125
|
+
typeof flags.doc === "string" && flags.doc.trim()
|
|
2126
|
+
? flags.doc.trim()
|
|
2127
|
+
: "main";
|
|
2128
|
+
const data = await request("GET", `${htmlEndpoint(dealId, slug)}/history`);
|
|
2129
|
+
print(data);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// restore — re-promote an old version as the new latest.
|
|
2134
|
+
if (sub === "restore") {
|
|
2135
|
+
const dealId = rest[0];
|
|
2136
|
+
const version = Number(rest[1]);
|
|
2137
|
+
if (!dealId || !Number.isFinite(version)) {
|
|
2138
|
+
throw new Error(
|
|
2139
|
+
"Usage: llama html restore <dealId> <version> [--doc SLUG]",
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
const { flags } = parseFlags(rest.slice(2));
|
|
2143
|
+
const slug =
|
|
2144
|
+
typeof flags.doc === "string" && flags.doc.trim()
|
|
2145
|
+
? flags.doc.trim()
|
|
2146
|
+
: "main";
|
|
2147
|
+
const data = await request(
|
|
2148
|
+
"POST",
|
|
2149
|
+
`${htmlEndpoint(dealId, slug)}/restore/${version}`,
|
|
2150
|
+
);
|
|
2151
|
+
print({
|
|
2152
|
+
ok: true,
|
|
2153
|
+
document_slug: slug,
|
|
2154
|
+
restored_from: version,
|
|
2155
|
+
new_version: data?.version,
|
|
2156
|
+
deal_uuid: dealId,
|
|
2157
|
+
});
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// reset — soft-delete the current HTML. /browse page reverts to empty state.
|
|
2162
|
+
if (sub === "reset" || sub === "delete") {
|
|
2163
|
+
const dealId = rest[0];
|
|
2164
|
+
if (!dealId) {
|
|
2165
|
+
throw new Error("Usage: llama html reset <dealId> [--doc SLUG]");
|
|
2166
|
+
}
|
|
2167
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
2168
|
+
const slug =
|
|
2169
|
+
typeof flags.doc === "string" && flags.doc.trim()
|
|
2170
|
+
? flags.doc.trim()
|
|
2171
|
+
: "main";
|
|
2172
|
+
const data = await request("DELETE", htmlEndpoint(dealId, slug));
|
|
2173
|
+
print({
|
|
2174
|
+
ok: true,
|
|
2175
|
+
document_slug: slug,
|
|
2176
|
+
soft_deleted_version: data?.version ?? null,
|
|
2177
|
+
deal_uuid: dealId,
|
|
2178
|
+
});
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
throw new Error(
|
|
2183
|
+
`Unknown html subcommand "${sub || ""}". Use: docs / show / upload / versions / restore / reset.`,
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
1795
2187
|
usage();
|
|
1796
2188
|
process.exitCode = 1;
|
|
1797
2189
|
}
|