@llamaventures/cli 1.4.4 → 1.6.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 +96 -10
- package/bin/llama-mcp.mjs +49 -8
- package/bin/llama.mjs +341 -19
- package/package.json +1 -1
package/AGENT_BRIEFING.md
CHANGED
|
@@ -29,13 +29,69 @@ Don't:
|
|
|
29
29
|
|
|
30
30
|
Conversation produces value → that value flows somewhere. This is not optional.
|
|
31
31
|
|
|
32
|
+
### Where does this HTML / thesis / artifact go? (decision tree)
|
|
33
|
+
|
|
34
|
+
When the user hands you an HTML page, thesis write-up, market map, dashboard, IC memo, sector landscape — anything that isn't a one-off note — pick the destination in this order. **Llama Command native (the workbench) outranks Netlify for everything internal.** Only escape to Netlify when the page is truly going to a public / founder-facing URL.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
HTML / thesis / artifact in hand
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
┌─────────────────────────────────────────────────┐
|
|
41
|
+
│ Is it about ONE specific company or deal? │
|
|
42
|
+
│ (deal IC memo · dashboard for X · X 的 thesis │
|
|
43
|
+
│ · founder briefing for X · X 的 2×2 …) │
|
|
44
|
+
└──────────────┬──────────────────────────────────┘
|
|
45
|
+
│
|
|
46
|
+
yes ────►│ → Llama Command DEAL page
|
|
47
|
+
│ `llama html upload <dealId> --new --title "..." --file <path>`
|
|
48
|
+
│ Renders at /deals/<id>/browse/<slug>.
|
|
49
|
+
│ Use --doc <slug> + --file to update an existing one.
|
|
50
|
+
│
|
|
51
|
+
no ────►│ Is it cross-deal / institutional knowledge?
|
|
52
|
+
│ (sector landscape · market map · framework · firm-level
|
|
53
|
+
│ thesis · methodology · "AI infra in 2026" …)
|
|
54
|
+
│
|
|
55
|
+
│ yes ──► → Llama Command WIKI entry
|
|
56
|
+
│ Markdown body:
|
|
57
|
+
│ `llama wiki save <slug> --title "..." \`
|
|
58
|
+
│ ` --content "..." --sources "..."`
|
|
59
|
+
│ Standalone HTML page (full-viewport iframe):
|
|
60
|
+
│ `llama wiki save <slug> --title "..." \`
|
|
61
|
+
│ ` --file <path>.html --sources "..."`
|
|
62
|
+
│ Renders at /wiki/<slug>. Sources mandatory.
|
|
63
|
+
│
|
|
64
|
+
│ no ──► Likely doesn't need to live anywhere
|
|
65
|
+
│ durable — confirm with the user before
|
|
66
|
+
│ inventing a destination.
|
|
67
|
+
▼
|
|
68
|
+
┌─────────────────────────────────────────────────┐
|
|
69
|
+
│ Does the user EXPLICITLY say "share with │
|
|
70
|
+
│ founder" / "public share link" / "give it to │
|
|
71
|
+
│ <external person>" / "publish publicly"? │
|
|
72
|
+
└──────────────┬──────────────────────────────────┘
|
|
73
|
+
│
|
|
74
|
+
no ────►│ → STAY on Llama Command. Don't reach for Netlify.
|
|
75
|
+
│
|
|
76
|
+
yes ────►│ → Netlify (only this case).
|
|
77
|
+
│ Use the `netlify-access-guard` workflow:
|
|
78
|
+
│ server-side password + edge 401 verification.
|
|
79
|
+
│ Verify enforcement is at the Netlify edge, not a
|
|
80
|
+
│ browser-side JS fake.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Default bias:** when in doubt, route to Llama Command. It has auth, audit, search, backlinks, and lives next to the rest of the team's context. Netlify is the escape hatch for genuinely-external surfaces — not "where pretty HTML goes."
|
|
84
|
+
|
|
85
|
+
The table below details the exact CLI for each destination.
|
|
86
|
+
|
|
32
87
|
| Type | Destination | How |
|
|
33
88
|
|---|---|---|
|
|
34
89
|
| Deal metadata (status, stage, valuation, founders, notes, etc.) | Pipeline (Postgres) | `llama deal create` / `llama deal update` |
|
|
35
90
|
| 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>`) |
|
|
91
|
+
| **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
92
|
| 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). |
|
|
38
|
-
| Insights, decisions, framework improvements | Wiki | `llama wiki save` (with attribution — see below) |
|
|
93
|
+
| Insights, decisions, framework improvements | Wiki (markdown) | `llama wiki save <slug> --content "..."` (with attribution — see below) |
|
|
94
|
+
| **HTML wiki entry — standalone HTML page hosted at `/wiki/<slug>`** (sector landscape, market map, dashboard, hand-styled thesis page) | **Wiki (HTML)** | `llama wiki save <slug> --title "..." --file <path.html> --sources "..."`. Auto-detects content_type=html from extension. Public page is full-viewport sandboxed iframe takeover (no wiki chrome). Sources/status/title still required; appears in `wiki search` + backlinks. Use when the user says "deploy this HTML to wiki", "wiki 词条", "make this page a wiki entry". HTML must be self-contained (inline CSS/JS, image data URIs or external URLs) — asset bundles aren't supported on wiki yet. |
|
|
39
95
|
| Large files (deck / PDF / transcript) | Drive deal folder | the deal's `folder_url` (from `llama deal show`) → upload via your filesystem / Drive tool |
|
|
40
96
|
| Cross-team mentions | Inbox + email | `llama post <dealId> "@<teammate> ..."` — server fires email + UI badge to the recipient |
|
|
41
97
|
|
|
@@ -108,17 +164,47 @@ llama brief add-text <dealId> --heading "..." --body "..."
|
|
|
108
164
|
llama brief add-link <dealId> --url "..." --label "..."
|
|
109
165
|
llama brief add-callout <dealId> --tone insight|warning|info|success --heading "..." --body "..."
|
|
110
166
|
|
|
111
|
-
# Deal HTML — native deploy to /deals/<id>/browse/<slug>
|
|
167
|
+
# Deal HTML — native deploy to /deals/<id>/browse/<slug>
|
|
112
168
|
# Default path when user says "deploy to llama / 部署到 llama command / put this HTML on the deal page".
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
llama html
|
|
116
|
-
llama html
|
|
117
|
-
llama html
|
|
169
|
+
# Each deal can host many slug-scoped artifacts. ALWAYS declare intent: new vs update.
|
|
170
|
+
|
|
171
|
+
llama html docs <dealId> # list slugs currently on this deal
|
|
172
|
+
llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot (optional; upload --new also creates)
|
|
173
|
+
llama html docs archive <dealId> <slug> # soft-archive a doc
|
|
174
|
+
|
|
175
|
+
# Add a NEW artifact (slug must NOT already exist):
|
|
176
|
+
llama html upload <dealId> --new --title "Consumer-Facing Thesis" --file ./thesis.html
|
|
177
|
+
llama html upload <dealId> --new --doc thesis --title "Consumer-Facing Thesis" --file ./thesis.html
|
|
178
|
+
|
|
179
|
+
# Update an EXISTING artifact (slug must already exist):
|
|
180
|
+
llama html upload <dealId> --doc <slug> --file ./report.html [--assets ./assets]
|
|
181
|
+
|
|
182
|
+
# Common helpers (all accept --doc <slug>; default 'main'):
|
|
183
|
+
llama html show <dealId> [--doc <slug>] [--out path] [--json] # current HTML → stdout
|
|
184
|
+
llama html versions <dealId> [--doc <slug>] # version history (incl. soft-deleted)
|
|
185
|
+
llama html restore <dealId> <version> [--doc <slug>] # promote old version to latest
|
|
186
|
+
llama html reset <dealId> [--doc <slug>] # soft-delete latest (browse reverts to empty)
|
|
187
|
+
|
|
188
|
+
# Safety contract (since 1.5.0):
|
|
189
|
+
# - Bare `llama html upload <id> --file X` REFUSES if 'main' already has content.
|
|
190
|
+
# The error names the existing artifact and suggests --doc main / --new --title "...".
|
|
191
|
+
# - --slug is silently accepted as an alias for --doc (agent-confusion mitigation).
|
|
192
|
+
# - Unknown flags print a warning to stderr suggesting a likely match.
|
|
193
|
+
# - JSON output gains `mode: 'created' | 'updated'` so callers can branch.
|
|
118
194
|
|
|
119
195
|
# Wiki (knowledge base)
|
|
120
196
|
llama wiki search "<query>"
|
|
121
|
-
llama wiki
|
|
197
|
+
llama wiki read <slug> [--lang en|zh]
|
|
198
|
+
|
|
199
|
+
# Markdown entry (default):
|
|
200
|
+
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2"
|
|
201
|
+
|
|
202
|
+
# HTML entry — standalone page at /wiki/<slug>, full-viewport sandboxed iframe:
|
|
203
|
+
llama wiki save <slug> --title "..." --file path.html --sources "..." [--content-type html]
|
|
204
|
+
# .html / .htm extension auto-implies content_type=html.
|
|
205
|
+
# --content-type html (or markdown) overrides the inference.
|
|
206
|
+
# Refuses to switch content_type on an existing slug; delete + re-create
|
|
207
|
+
# if you really mean to change format.
|
|
122
208
|
|
|
123
209
|
# Timeline + posts
|
|
124
210
|
llama timeline <dealId>
|
|
@@ -145,7 +231,7 @@ Tools available:
|
|
|
145
231
|
- `auth_status` — verify creds + identity (call first if anything 401s)
|
|
146
232
|
- `deal_search` / `deal_show` / `deal_create` / `deal_update`
|
|
147
233
|
- `brief_blocks` / `brief_add_text` / `brief_add_link` / `brief_add_callout`
|
|
148
|
-
- `wiki_search` / `wiki_save`
|
|
234
|
+
- `wiki_search` / `wiki_save` (accepts `content_type: 'markdown' | 'html'` — HTML entries render as full-viewport sandboxed iframe at `/wiki/<slug>`)
|
|
149
235
|
- `timeline` / `post`
|
|
150
236
|
- `mentions_list`
|
|
151
237
|
- `pitch_start` / `pitch_send_message` / `pitch_upload_file` / `pitch_status` / `pitch_finalize` — public intake (no Llama token needed; for founders / EAs / external agents)
|
package/bin/llama-mcp.mjs
CHANGED
|
@@ -328,24 +328,56 @@ server.registerTool(
|
|
|
328
328
|
"wiki_save",
|
|
329
329
|
{
|
|
330
330
|
description:
|
|
331
|
-
"Create or update a wiki page
|
|
332
|
-
"
|
|
333
|
-
"
|
|
334
|
-
"
|
|
331
|
+
"Create or update a wiki page — Llama's CROSS-DEAL / institutional " +
|
|
332
|
+
"knowledge surface (sector landscape · market map · thesis · framework · " +
|
|
333
|
+
"methodology · anything not tied to ONE specific deal). Renders at " +
|
|
334
|
+
"/wiki/<slug>. " +
|
|
335
|
+
"**Routing — decide BEFORE calling:** " +
|
|
336
|
+
"(a) Deal-specific HTML (IC memo for X, dashboard for X) → use " +
|
|
337
|
+
"`html_upload` instead, NOT this. " +
|
|
338
|
+
"(b) Cross-deal / institutional (this tool) → /wiki/<slug>. " +
|
|
339
|
+
"(c) Founder-facing public share → Netlify only when user explicitly " +
|
|
340
|
+
"says so; Llama Command outranks Netlify for everything internal. " +
|
|
341
|
+
"By default `content` is markdown with attribution blocks " +
|
|
342
|
+
"(**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability. " +
|
|
343
|
+
"Set `content_type: 'html'` to deploy a standalone HTML page as the " +
|
|
344
|
+
"wiki entry (full-viewport sandboxed iframe takeover on /wiki/<slug>; " +
|
|
345
|
+
"the HTML itself is the page — no wiki chrome). `sources` is a " +
|
|
346
|
+
"separate citation list (URLs, doc names, or meeting references) — " +
|
|
347
|
+
"at least one required; URLs inside `content` do not count. For HTML " +
|
|
348
|
+
"asset bundles use the `llama wiki save --file ... --assets ...` CLI " +
|
|
349
|
+
"path; MCP only supports single-file HTML.",
|
|
335
350
|
inputSchema: {
|
|
336
351
|
slug: z.string().describe("kebab-case slug"),
|
|
337
352
|
title: z.string(),
|
|
338
|
-
content: z
|
|
353
|
+
content: z
|
|
354
|
+
.string()
|
|
355
|
+
.describe(
|
|
356
|
+
"body — markdown source by default, or raw HTML when content_type='html'"
|
|
357
|
+
),
|
|
339
358
|
sources: z
|
|
340
359
|
.array(z.string())
|
|
341
360
|
.min(1)
|
|
342
361
|
.describe(
|
|
343
362
|
"citation list — URLs, doc names, or meeting references. At least one required."
|
|
344
363
|
),
|
|
364
|
+
content_type: z
|
|
365
|
+
.enum(["markdown", "html"])
|
|
366
|
+
.optional()
|
|
367
|
+
.describe(
|
|
368
|
+
"'markdown' (default) renders via the wiki markdown pipeline. " +
|
|
369
|
+
"'html' stores the body as a standalone HTML page (sandboxed iframe)."
|
|
370
|
+
),
|
|
345
371
|
},
|
|
346
372
|
},
|
|
347
|
-
async ({ slug, title, content, sources }) =>
|
|
348
|
-
callApi("POST", "/api/wiki/save", {
|
|
373
|
+
async ({ slug, title, content, sources, content_type }) =>
|
|
374
|
+
callApi("POST", "/api/wiki/save", {
|
|
375
|
+
slug,
|
|
376
|
+
title,
|
|
377
|
+
content,
|
|
378
|
+
sources,
|
|
379
|
+
...(content_type ? { content_type } : {}),
|
|
380
|
+
})
|
|
349
381
|
);
|
|
350
382
|
|
|
351
383
|
// ============================================================
|
|
@@ -707,7 +739,16 @@ server.registerTool(
|
|
|
707
739
|
"html_upload",
|
|
708
740
|
{
|
|
709
741
|
description:
|
|
710
|
-
"Upload (PUT) a new HTML version for a
|
|
742
|
+
"Upload (PUT) a new HTML version for a SPECIFIC DEAL's /browse page " +
|
|
743
|
+
"(deal-scoped artifact: IC memo for X · dashboard for X · 2×2 for X). " +
|
|
744
|
+
"Renders at /deals/<id>/browse/<slug>. " +
|
|
745
|
+
"**Routing — pick the right destination BEFORE calling this:** " +
|
|
746
|
+
"(a) Deal-specific HTML (this tool) → /deals/<id>/browse/<slug>. " +
|
|
747
|
+
"(b) Cross-deal / institutional / thesis / sector landscape → use " +
|
|
748
|
+
"`wiki_save` with content_type='html' instead (/wiki/<slug>). " +
|
|
749
|
+
"(c) Founder-facing public share link → escape to Netlify only when " +
|
|
750
|
+
"the user explicitly says 'share with founder' / 'publish publicly'; " +
|
|
751
|
+
"Llama Command outranks Netlify for everything internal. " +
|
|
711
752
|
"Creates a NEW version row — the previous version is retained " +
|
|
712
753
|
"and restorable. Triggers SSE push so any open viewer auto- " +
|
|
713
754
|
"refreshes. Constraints: HTML body MUST start with " +
|
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
|
|
@@ -251,10 +324,25 @@ Mentions / Inbox:
|
|
|
251
324
|
llama mentions resolve <mentionId> # mark thread resolved (idempotent)
|
|
252
325
|
llama mentions unread # just the badge count
|
|
253
326
|
|
|
327
|
+
Where does this HTML / thesis / artifact go?
|
|
328
|
+
About ONE specific deal? ........ llama html upload <dealId> --new --title "..." --file <path>
|
|
329
|
+
(renders at /deals/<id>/browse/<slug>; see "Deal page HTML" below)
|
|
330
|
+
Cross-deal / institutional? ..... llama wiki save <slug> --title "..." --file <path>.html --sources "..."
|
|
331
|
+
(renders at /wiki/<slug>; see "Wiki" below)
|
|
332
|
+
Founder-facing public share? .... Netlify (with netlify-access-guard skill), only when user explicitly
|
|
333
|
+
says "share publicly". Llama Command outranks Netlify for everything
|
|
334
|
+
internal — don't reach for Netlify by default.
|
|
335
|
+
|
|
254
336
|
Wiki:
|
|
255
337
|
llama wiki search <query>
|
|
256
338
|
llama wiki read <slug>
|
|
257
|
-
|
|
339
|
+
Markdown entry (default):
|
|
340
|
+
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"]
|
|
341
|
+
HTML entry — standalone HTML page at /wiki/<slug> (full-viewport sandboxed iframe):
|
|
342
|
+
llama wiki save <slug> --title "..." --file path.html --sources "..." [--content-type html]
|
|
343
|
+
(.html / .htm extension auto-implies content_type=html)
|
|
344
|
+
➜ Use Wiki when the artifact is NOT tied to one specific deal — sector landscape, market map,
|
|
345
|
+
thesis, framework, methodology. For deal-specific HTML use "llama html upload <dealId>" instead.
|
|
258
346
|
|
|
259
347
|
Memo (long-form HTML investment memo — Memo tab in the UI):
|
|
260
348
|
llama memo show <dealId> [--out <path>] [--json] # default: html → stdout (pipeable to file / browser)
|
|
@@ -262,14 +350,33 @@ Memo (long-form HTML investment memo — Memo tab in the UI):
|
|
|
262
350
|
llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
|
|
263
351
|
llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
|
|
264
352
|
|
|
265
|
-
Deal page HTML (hand-authored sandboxed
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
353
|
+
Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
|
|
354
|
+
➜ Use this for DEAL-SPECIFIC artifacts: IC memo for X, dashboard for X, 2×2 for X.
|
|
355
|
+
For cross-deal / institutional pages (sector landscape, market map, thesis) use
|
|
356
|
+
"llama wiki save <slug> --file ..." instead — see "Wiki" above.
|
|
357
|
+
Each deal can host many HTML artifacts (IC report, dashboard, market map, …).
|
|
358
|
+
Each one has a stable slug. UPLOAD must declare intent — update an existing
|
|
359
|
+
artifact or add a new one — to avoid silent overwrites.
|
|
360
|
+
|
|
361
|
+
List existing artifacts:
|
|
362
|
+
llama html docs <dealId> # who-has-what
|
|
363
|
+
llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
|
|
364
|
+
llama html docs archive <dealId> <slug> # soft-archive (browse hides)
|
|
365
|
+
|
|
366
|
+
Update an EXISTING artifact (slug must exist):
|
|
367
|
+
llama html upload <dealId> --doc <slug> --file <path> [--assets DIR]
|
|
368
|
+
|
|
369
|
+
Add a NEW artifact (slug must NOT already exist):
|
|
370
|
+
llama html upload <dealId> --new --title "..." --file <path> [--doc <slug>] [--assets DIR]
|
|
371
|
+
(omit --doc → CLI slugifies the title; appends -2 / -3 on collision)
|
|
372
|
+
|
|
373
|
+
Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'
|
|
374
|
+
already has content — pass --doc main or --new --title "..." explicitly.
|
|
375
|
+
|
|
376
|
+
llama html show <dealId> [--doc <slug>] [--out <path>] [--json] # default: current html → stdout
|
|
377
|
+
llama html versions <dealId> [--doc <slug>] # list version history
|
|
378
|
+
llama html restore <dealId> <version> [--doc <slug>] # promote an old version to new latest
|
|
379
|
+
llama html reset <dealId> [--doc <slug>] # soft-delete latest; /browse reverts to empty
|
|
273
380
|
|
|
274
381
|
Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
|
|
275
382
|
triggers SSE push — any browser viewing /deals/<id>/browse refreshes
|
|
@@ -1248,27 +1355,75 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1248
1355
|
}
|
|
1249
1356
|
|
|
1250
1357
|
// ----- Wiki: save (create or update) -----
|
|
1358
|
+
// Two body modes:
|
|
1359
|
+
// --content "..." inline markdown OR raw HTML (string)
|
|
1360
|
+
// --file <path> read body from a file; if .html/.htm,
|
|
1361
|
+
// content_type auto-detects to 'html'
|
|
1362
|
+
// --content-type <markdown|html> overrides auto-detect.
|
|
1363
|
+
// Refuses content_type mismatch on existing entries (server-side check;
|
|
1364
|
+
// CLI surfaces the server error verbatim).
|
|
1251
1365
|
if (area === "wiki" && action === "save") {
|
|
1252
1366
|
const { flags, positional } = parseFlags(rest);
|
|
1253
1367
|
const slug = positional[0];
|
|
1254
1368
|
const title = flags.title;
|
|
1255
|
-
const
|
|
1369
|
+
const inlineContent = flags.content;
|
|
1370
|
+
const filePath = flags.file;
|
|
1256
1371
|
const sourcesRaw = flags.sources;
|
|
1257
|
-
if (!slug || !title || !
|
|
1372
|
+
if (!slug || !title || !sourcesRaw || (!inlineContent && !filePath)) {
|
|
1258
1373
|
throw new Error(
|
|
1259
|
-
`Usage:
|
|
1374
|
+
`Usage:
|
|
1375
|
+
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
|
|
1376
|
+
or
|
|
1377
|
+
llama wiki save <slug> --title "..." --file path/to/article.{md,html} --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
|
|
1378
|
+
|
|
1379
|
+
Pass either --content (inline) or --file (read from disk). With --file, content_type auto-detects from extension (.html/.htm → html, else markdown). Use --content-type to override.
|
|
1380
|
+
|
|
1381
|
+
Routing — is this the right command?
|
|
1382
|
+
✓ Cross-deal / institutional knowledge (sector landscape, market map, thesis, framework, methodology)
|
|
1383
|
+
→ YES, you're in the right place.
|
|
1384
|
+
✗ Deal-specific HTML (IC memo for X, dashboard for X, 2×2 for one company)
|
|
1385
|
+
→ use \`llama html upload <dealId> --new --title "..." --file <path>\` instead.
|
|
1386
|
+
✗ Founder-facing public share link
|
|
1387
|
+
→ escape to Netlify only when the user explicitly says "share publicly" / "give it to the founder";
|
|
1388
|
+
Llama Command outranks Netlify for everything internal.`
|
|
1260
1389
|
);
|
|
1261
1390
|
}
|
|
1391
|
+
if (inlineContent && filePath) {
|
|
1392
|
+
throw new Error("Pass either --content OR --file, not both.");
|
|
1393
|
+
}
|
|
1394
|
+
// Read body — either inline or from file.
|
|
1395
|
+
let body;
|
|
1396
|
+
let inferredType = "markdown";
|
|
1397
|
+
if (filePath) {
|
|
1398
|
+
const { readFileSync } = await import("fs");
|
|
1399
|
+
body = readFileSync(String(filePath), "utf-8");
|
|
1400
|
+
const lower = String(filePath).toLowerCase();
|
|
1401
|
+
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
|
|
1402
|
+
inferredType = "html";
|
|
1403
|
+
}
|
|
1404
|
+
} else {
|
|
1405
|
+
body = String(inlineContent);
|
|
1406
|
+
}
|
|
1407
|
+
// Determine content_type: explicit flag wins over file-extension inference.
|
|
1408
|
+
let contentType = inferredType;
|
|
1409
|
+
if (flags["content-type"]) {
|
|
1410
|
+
const v = String(flags["content-type"]).toLowerCase();
|
|
1411
|
+
if (v !== "markdown" && v !== "html") {
|
|
1412
|
+
throw new Error(`--content-type must be 'markdown' or 'html', got "${v}"`);
|
|
1413
|
+
}
|
|
1414
|
+
contentType = v;
|
|
1415
|
+
}
|
|
1262
1416
|
const splitCsv = (v) => String(v).split(/[;|]/).map((s) => s.trim()).filter(Boolean);
|
|
1263
1417
|
const payload = {
|
|
1264
1418
|
slug,
|
|
1265
1419
|
title: String(title),
|
|
1266
|
-
content:
|
|
1420
|
+
content: body,
|
|
1267
1421
|
sources: splitCsv(sourcesRaw),
|
|
1268
1422
|
type: flags.type ? String(flags.type) : undefined,
|
|
1269
1423
|
related: flags.related ? splitCsv(flags.related) : undefined,
|
|
1270
1424
|
lang: flags.lang === "zh" ? "zh" : "en",
|
|
1271
1425
|
status: flags.status ? String(flags.status) : undefined,
|
|
1426
|
+
content_type: contentType,
|
|
1272
1427
|
};
|
|
1273
1428
|
print(await request("POST", "/api/wiki/save", payload));
|
|
1274
1429
|
return;
|
|
@@ -1954,15 +2109,180 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1954
2109
|
const dealId = rest[0];
|
|
1955
2110
|
if (!dealId) {
|
|
1956
2111
|
throw new Error(
|
|
1957
|
-
"Usage
|
|
1958
|
-
"
|
|
2112
|
+
"Usage:\n" +
|
|
2113
|
+
" Update an existing artifact:\n" +
|
|
2114
|
+
" llama html upload <dealId> --doc <slug> --file PATH [--assets DIR]\n" +
|
|
2115
|
+
" Create a new artifact:\n" +
|
|
2116
|
+
" llama html upload <dealId> --new --title \"...\" --file PATH [--doc <slug>]\n" +
|
|
2117
|
+
" Stream from stdin (either form above with --stdin in place of --file PATH).\n" +
|
|
2118
|
+
"\n" +
|
|
2119
|
+
"Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'\n" +
|
|
2120
|
+
"already has content — pass --doc main to update it explicitly, or\n" +
|
|
2121
|
+
"--new --title \"...\" to add a NEW artifact alongside.\n" +
|
|
2122
|
+
"\n" +
|
|
2123
|
+
"Routing — is this the right command?\n" +
|
|
2124
|
+
" ✓ DEAL-specific HTML (IC memo for X, dashboard for X, 2×2 for X)\n" +
|
|
2125
|
+
" → YES, you're in the right place. Pass <dealId> + --new / --doc.\n" +
|
|
2126
|
+
" ✗ Cross-deal / institutional knowledge (sector landscape, market map,\n" +
|
|
2127
|
+
" thesis, framework, methodology, anything not tied to one company)\n" +
|
|
2128
|
+
" → use `llama wiki save <slug> --title \"...\" --file <path>.html --sources \"...\"`\n" +
|
|
2129
|
+
" instead (renders at /wiki/<slug>).\n" +
|
|
2130
|
+
" ✗ Founder-facing public share link\n" +
|
|
2131
|
+
" → escape to Netlify only when the user explicitly says \"share publicly\";\n" +
|
|
2132
|
+
" Llama Command outranks Netlify for everything internal.",
|
|
1959
2133
|
);
|
|
1960
2134
|
}
|
|
1961
|
-
const
|
|
1962
|
-
|
|
2135
|
+
const knownFlags = [
|
|
2136
|
+
"doc", "slug", "new", "title",
|
|
2137
|
+
"file", "stdin", "assets", "source",
|
|
2138
|
+
];
|
|
2139
|
+
const { flags } = parseFlags(rest.slice(1), knownFlags);
|
|
2140
|
+
|
|
2141
|
+
// --slug is the natural agent guess (DB column is `document_slug`).
|
|
2142
|
+
// Accept it as an alias for --doc so the failure mode that bit
|
|
2143
|
+
// Gavin (silent fall-through to 'main') can't happen again.
|
|
2144
|
+
if (flags.slug && !flags.doc) {
|
|
2145
|
+
process.stderr.write("note: --slug accepted as alias for --doc.\n");
|
|
2146
|
+
flags.doc = flags.slug;
|
|
2147
|
+
} else if (flags.slug && flags.doc) {
|
|
2148
|
+
process.stderr.write("note: both --doc and --slug given; --doc wins.\n");
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
const isNew = Boolean(flags.new);
|
|
2152
|
+
const explicitDoc =
|
|
1963
2153
|
typeof flags.doc === "string" && flags.doc.trim()
|
|
1964
2154
|
? flags.doc.trim()
|
|
1965
|
-
:
|
|
2155
|
+
: null;
|
|
2156
|
+
const titleFlag =
|
|
2157
|
+
typeof flags.title === "string" && flags.title.trim()
|
|
2158
|
+
? flags.title.trim()
|
|
2159
|
+
: null;
|
|
2160
|
+
|
|
2161
|
+
// Pre-flight: ask the server what slugs already exist on this deal.
|
|
2162
|
+
// One extra GET round-trip — cheap insurance against silent overwrite.
|
|
2163
|
+
let existing = [];
|
|
2164
|
+
try {
|
|
2165
|
+
const docList = await request(
|
|
2166
|
+
"GET",
|
|
2167
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
2168
|
+
);
|
|
2169
|
+
existing = Array.isArray(docList?.documents) ? docList.documents : [];
|
|
2170
|
+
} catch (err) {
|
|
2171
|
+
// If the deal exists but the list endpoint somehow errors, we
|
|
2172
|
+
// shouldn't block the whole upload — surface the warning and
|
|
2173
|
+
// proceed in "no existing docs" mode. The server is still the
|
|
2174
|
+
// ultimate gate for permission failures.
|
|
2175
|
+
process.stderr.write(
|
|
2176
|
+
`warning: could not pre-check existing documents (${err.message}). Continuing.\n`,
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
const findDoc = (s) =>
|
|
2180
|
+
existing.find((d) => d && d.slug === s) || null;
|
|
2181
|
+
const docHasHtml = (d) =>
|
|
2182
|
+
Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
|
|
2183
|
+
|
|
2184
|
+
let slug;
|
|
2185
|
+
let mode; // 'created' | 'updated'
|
|
2186
|
+
|
|
2187
|
+
if (isNew) {
|
|
2188
|
+
// Create-new branch. Caller must provide --doc OR --title (we
|
|
2189
|
+
// derive the slug from the title in the latter case).
|
|
2190
|
+
let candidate = explicitDoc || (titleFlag ? slugifyTitle(titleFlag) : null);
|
|
2191
|
+
if (!candidate) {
|
|
2192
|
+
throw new Error(
|
|
2193
|
+
"--new requires --doc <slug> or --title \"...\" so the new artifact has a stable identifier.",
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
if (!isValidDocSlug(candidate)) {
|
|
2197
|
+
throw new Error(
|
|
2198
|
+
`slug "${candidate}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
if (findDoc(candidate)) {
|
|
2202
|
+
if (explicitDoc) {
|
|
2203
|
+
const existingDoc = findDoc(candidate);
|
|
2204
|
+
const meta = docHasHtml(existingDoc)
|
|
2205
|
+
? ` (currently at v${existingDoc.latest_version}, last update ${existingDoc.latest_updated_at})`
|
|
2206
|
+
: "";
|
|
2207
|
+
throw new Error(
|
|
2208
|
+
`--new --doc ${candidate} but a document with slug "${candidate}" already exists${meta}.\n` +
|
|
2209
|
+
`Pick a different slug, or drop --new to UPDATE the existing one.`,
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
// Auto-resolve title collisions: foo -> foo-2 -> foo-3 -> ...
|
|
2213
|
+
let suffix = 2;
|
|
2214
|
+
while (findDoc(`${candidate}-${suffix}`)) suffix++;
|
|
2215
|
+
const oldCandidate = candidate;
|
|
2216
|
+
candidate = `${candidate}-${suffix}`;
|
|
2217
|
+
process.stderr.write(
|
|
2218
|
+
`note: slug "${oldCandidate}" already in use; using "${candidate}" instead.\n`,
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
slug = candidate;
|
|
2222
|
+
mode = "created";
|
|
2223
|
+
// Stamp the doc metadata first (title, etc.) so the UI selection
|
|
2224
|
+
// page shows a nice name. PUT auto-creates the row too, but
|
|
2225
|
+
// POST gives us a chance to set --title.
|
|
2226
|
+
await request(
|
|
2227
|
+
"POST",
|
|
2228
|
+
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
2229
|
+
{ slug, title: titleFlag || slug },
|
|
2230
|
+
);
|
|
2231
|
+
} else if (explicitDoc) {
|
|
2232
|
+
// Update an existing slug.
|
|
2233
|
+
if (!isValidDocSlug(explicitDoc)) {
|
|
2234
|
+
throw new Error(
|
|
2235
|
+
`slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
const target = findDoc(explicitDoc);
|
|
2239
|
+
if (!target) {
|
|
2240
|
+
if (explicitDoc === "main") {
|
|
2241
|
+
// 'main' is the legacy default — fine to auto-init on first
|
|
2242
|
+
// upload to an empty deal.
|
|
2243
|
+
slug = "main";
|
|
2244
|
+
mode = "created";
|
|
2245
|
+
} else {
|
|
2246
|
+
const slugList = existing.length
|
|
2247
|
+
? existing.map((d) => d.slug).join(", ")
|
|
2248
|
+
: "(none)";
|
|
2249
|
+
throw new Error(
|
|
2250
|
+
`No document with slug "${explicitDoc}" exists on this deal.\n` +
|
|
2251
|
+
`To create it: add --new --title "..."\n` +
|
|
2252
|
+
`Or pre-create: llama html docs create ${dealId} ${explicitDoc} --title "..."\n` +
|
|
2253
|
+
`Existing slugs: ${slugList}`,
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
} else {
|
|
2257
|
+
slug = explicitDoc;
|
|
2258
|
+
mode = docHasHtml(target) ? "updated" : "created";
|
|
2259
|
+
}
|
|
2260
|
+
} else {
|
|
2261
|
+
// Bare upload — no --doc, no --new. Safe-default to 'main' only
|
|
2262
|
+
// if 'main' is empty / absent. Otherwise refuse, naming the
|
|
2263
|
+
// existing artifact so the caller can pick an explicit intent.
|
|
2264
|
+
const main = findDoc("main");
|
|
2265
|
+
if (docHasHtml(main)) {
|
|
2266
|
+
const versionInfo = main.latest_version
|
|
2267
|
+
? ` (v${main.latest_version}, ${main.latest_updated_at || "last update unknown"})`
|
|
2268
|
+
: "";
|
|
2269
|
+
const slugList = existing.length
|
|
2270
|
+
? existing.map((d) => d.slug).join(", ")
|
|
2271
|
+
: "main";
|
|
2272
|
+
throw new Error(
|
|
2273
|
+
`Refusing to silently overwrite the existing 'main' artifact${versionInfo}.\n` +
|
|
2274
|
+
`\n` +
|
|
2275
|
+
`If you meant to UPDATE 'main': --doc main\n` +
|
|
2276
|
+
`If you meant to add a NEW artifact: --new --title "<name>"\n` +
|
|
2277
|
+
`\n` +
|
|
2278
|
+
`Existing slugs on this deal: ${slugList}\n` +
|
|
2279
|
+
`List details: llama html docs ${dealId}`,
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
slug = "main";
|
|
2283
|
+
mode = main ? "updated" : "created";
|
|
2284
|
+
}
|
|
2285
|
+
|
|
1966
2286
|
let html;
|
|
1967
2287
|
if (flags.file) {
|
|
1968
2288
|
const { readFileSync } = await import("fs");
|
|
@@ -1992,6 +2312,7 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1992
2312
|
});
|
|
1993
2313
|
print({
|
|
1994
2314
|
ok: true,
|
|
2315
|
+
mode,
|
|
1995
2316
|
document_slug: slug,
|
|
1996
2317
|
version: data?.version,
|
|
1997
2318
|
bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
|
|
@@ -2104,6 +2425,7 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
2104
2425
|
}
|
|
2105
2426
|
print({
|
|
2106
2427
|
ok: true,
|
|
2428
|
+
mode,
|
|
2107
2429
|
document_slug: slug,
|
|
2108
2430
|
version: body.version,
|
|
2109
2431
|
asset_count: body.asset_count,
|