@llamaventures/cli 1.4.4 → 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,7 +33,7 @@ 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. |
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
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). |
38
38
  | Insights, decisions, framework improvements | Wiki | `llama wiki save` (with attribution — see below) |
39
39
  | Large files (deck / PDF / transcript) | Drive deal folder | the deal's `folder_url` (from `llama deal show`) → upload via your filesystem / Drive tool |
@@ -108,13 +108,33 @@ llama brief add-text <dealId> --heading "..." --body "..."
108
108
  llama brief add-link <dealId> --url "..." --label "..."
109
109
  llama brief add-callout <dealId> --tone insight|warning|info|success --heading "..." --body "..."
110
110
 
111
- # Deal HTML — native deploy to /deals/<id>/browse/<slug> (PR #81)
111
+ # Deal HTML — native deploy to /deals/<id>/browse/<slug>
112
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)
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.
118
138
 
119
139
  # Wiki (knowledge base)
120
140
  llama wiki search "<query>"
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,14 +335,30 @@ 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
 
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
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
273
362
 
274
363
  Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
275
364
  triggers SSE push — any browser viewing /deals/<id>/browse refreshes
@@ -1954,15 +2043,169 @@ https://command.llamaventures.vc/settings/tokens, run
1954
2043
  const dealId = rest[0];
1955
2044
  if (!dealId) {
1956
2045
  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]",
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.",
1959
2056
  );
1960
2057
  }
1961
- const { flags } = parseFlags(rest.slice(1));
1962
- const slug =
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 =
1963
2076
  typeof flags.doc === "string" && flags.doc.trim()
1964
2077
  ? flags.doc.trim()
1965
- : "main";
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
+
1966
2209
  let html;
1967
2210
  if (flags.file) {
1968
2211
  const { readFileSync } = await import("fs");
@@ -1992,6 +2235,7 @@ https://command.llamaventures.vc/settings/tokens, run
1992
2235
  });
1993
2236
  print({
1994
2237
  ok: true,
2238
+ mode,
1995
2239
  document_slug: slug,
1996
2240
  version: data?.version,
1997
2241
  bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
@@ -2104,6 +2348,7 @@ https://command.llamaventures.vc/settings/tokens, run
2104
2348
  }
2105
2349
  print({
2106
2350
  ok: true,
2351
+ mode,
2107
2352
  document_slug: slug,
2108
2353
  version: body.version,
2109
2354
  asset_count: body.asset_count,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "1.4.4",
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": {