@llamaventures/cli 1.4.0 → 1.4.3
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/bin/llama-mcp.mjs +81 -23
- package/bin/llama.mjs +11 -5
- package/lib/external.mjs +36 -0
- package/package.json +1 -1
package/bin/llama-mcp.mjs
CHANGED
|
@@ -40,6 +40,34 @@ async function callApi(method, path, body) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Append a block to a deal brief. The /blocks route only accepts atomic
|
|
44
|
+
// full-array PUTs (no POST), so we GET current blocks, prepend the new
|
|
45
|
+
// one (matches UI default since 2026-05-03), and PUT the merged array.
|
|
46
|
+
// Server stamps identity meta on PUT; we don't send any.
|
|
47
|
+
async function addBriefBlock(dealId, block) {
|
|
48
|
+
try {
|
|
49
|
+
const id = globalThis.crypto.randomUUID();
|
|
50
|
+
const cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
|
|
51
|
+
const existing = Array.isArray(cur?.blocks) ? cur.blocks : [];
|
|
52
|
+
const result = await request(
|
|
53
|
+
"PUT",
|
|
54
|
+
`/api/deals/${encodeURIComponent(dealId)}/blocks`,
|
|
55
|
+
{ blocks: [{ id, ...block }, ...existing] }
|
|
56
|
+
);
|
|
57
|
+
const text = JSON.stringify(
|
|
58
|
+
{ ok: result?.ok ?? true, id, count: result?.count ?? existing.length + 1 },
|
|
59
|
+
null,
|
|
60
|
+
2
|
|
61
|
+
);
|
|
62
|
+
return { content: [{ type: "text", text }] };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
const server = new McpServer({
|
|
44
72
|
name: "llama-mcp",
|
|
45
73
|
version: PKG_VERSION,
|
|
@@ -202,7 +230,7 @@ server.registerTool(
|
|
|
202
230
|
"brief_add_text",
|
|
203
231
|
{
|
|
204
232
|
description:
|
|
205
|
-
"
|
|
233
|
+
"Prepend a markdown text block to a deal brief. Supports markdown + mermaid diagrams.",
|
|
206
234
|
inputSchema: {
|
|
207
235
|
dealId: z.string(),
|
|
208
236
|
heading: z.string().optional().describe("optional block heading"),
|
|
@@ -210,18 +238,14 @@ server.registerTool(
|
|
|
210
238
|
},
|
|
211
239
|
},
|
|
212
240
|
async ({ dealId, heading, body }) =>
|
|
213
|
-
|
|
214
|
-
type: "text",
|
|
215
|
-
heading,
|
|
216
|
-
body,
|
|
217
|
-
})
|
|
241
|
+
addBriefBlock(dealId, { type: "text", heading: heading ?? "", body })
|
|
218
242
|
);
|
|
219
243
|
|
|
220
244
|
server.registerTool(
|
|
221
245
|
"brief_add_link",
|
|
222
246
|
{
|
|
223
247
|
description:
|
|
224
|
-
"
|
|
248
|
+
"Prepend a link block to a deal brief. Server fetches og:image + title via /api/link-preview.",
|
|
225
249
|
inputSchema: {
|
|
226
250
|
dealId: z.string(),
|
|
227
251
|
url: z.string(),
|
|
@@ -229,18 +253,14 @@ server.registerTool(
|
|
|
229
253
|
},
|
|
230
254
|
},
|
|
231
255
|
async ({ dealId, url, label }) =>
|
|
232
|
-
|
|
233
|
-
type: "link",
|
|
234
|
-
url,
|
|
235
|
-
label,
|
|
236
|
-
})
|
|
256
|
+
addBriefBlock(dealId, { type: "link", url, label: label ?? "" })
|
|
237
257
|
);
|
|
238
258
|
|
|
239
259
|
server.registerTool(
|
|
240
260
|
"brief_add_callout",
|
|
241
261
|
{
|
|
242
262
|
description:
|
|
243
|
-
"
|
|
263
|
+
"Prepend a callout block to a deal brief. Use for emphasized insights or warnings.",
|
|
244
264
|
inputSchema: {
|
|
245
265
|
dealId: z.string(),
|
|
246
266
|
tone: z.string().describe("insight | warning | info | success"),
|
|
@@ -249,12 +269,7 @@ server.registerTool(
|
|
|
249
269
|
},
|
|
250
270
|
},
|
|
251
271
|
async ({ dealId, tone, heading, body }) =>
|
|
252
|
-
|
|
253
|
-
type: "callout",
|
|
254
|
-
tone,
|
|
255
|
-
heading,
|
|
256
|
-
body,
|
|
257
|
-
})
|
|
272
|
+
addBriefBlock(dealId, { type: "callout", tone, heading: heading ?? "", body })
|
|
258
273
|
);
|
|
259
274
|
|
|
260
275
|
// ============================================================
|
|
@@ -266,7 +281,8 @@ server.registerTool(
|
|
|
266
281
|
{
|
|
267
282
|
description:
|
|
268
283
|
"Search the Llama Ventures internal wiki — deal context, company profiles, " +
|
|
269
|
-
"industry frameworks, partner-curated knowledge."
|
|
284
|
+
"industry frameworks, partner-curated knowledge. Returns excerpts. " +
|
|
285
|
+
"For full article content use `wiki_read`.",
|
|
270
286
|
inputSchema: {
|
|
271
287
|
q: z.string().describe("search query"),
|
|
272
288
|
},
|
|
@@ -274,20 +290,62 @@ server.registerTool(
|
|
|
274
290
|
async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`)
|
|
275
291
|
);
|
|
276
292
|
|
|
293
|
+
server.registerTool(
|
|
294
|
+
"wiki_read",
|
|
295
|
+
{
|
|
296
|
+
description:
|
|
297
|
+
"Read a single wiki article from the configured Llama Command " +
|
|
298
|
+
"deployment by exact slug. Returns title, frontmatter, full " +
|
|
299
|
+
"markdown content, and rendered HTML.\n\n" +
|
|
300
|
+
"USE THIS — DO NOT WebFetch — whenever the user gives you a " +
|
|
301
|
+
"wiki URL whose path is `/wiki/<slug>`. Extract the slug from " +
|
|
302
|
+
"the URL path and call this tool with it. WebFetch against the " +
|
|
303
|
+
"browser URL goes through session-cookie auth — your agent " +
|
|
304
|
+
"doesn't have one — so it will look like a permission denial " +
|
|
305
|
+
"even though your token is fine.\n\n" +
|
|
306
|
+
"If you only have a topic name, use `wiki_search` first to " +
|
|
307
|
+
"find the slug.",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
slug: z
|
|
310
|
+
.string()
|
|
311
|
+
.describe(
|
|
312
|
+
"exact kebab-case slug — the last path segment of the wiki URL"
|
|
313
|
+
),
|
|
314
|
+
lang: z
|
|
315
|
+
.enum(["en", "zh"])
|
|
316
|
+
.optional()
|
|
317
|
+
.describe("article language (default 'en')"),
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
async ({ slug, lang }) =>
|
|
321
|
+
callApi(
|
|
322
|
+
"GET",
|
|
323
|
+
`/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`
|
|
324
|
+
)
|
|
325
|
+
);
|
|
326
|
+
|
|
277
327
|
server.registerTool(
|
|
278
328
|
"wiki_save",
|
|
279
329
|
{
|
|
280
330
|
description:
|
|
281
331
|
"Create or update a wiki page. Content should be markdown with attribution " +
|
|
282
|
-
"blocks (**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability."
|
|
332
|
+
"blocks (**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability. " +
|
|
333
|
+
"`sources` is a separate citation list (URLs, doc names, or meeting references) " +
|
|
334
|
+
"— at least one is required; URLs embedded inside `content` do not count.",
|
|
283
335
|
inputSchema: {
|
|
284
336
|
slug: z.string().describe("kebab-case slug"),
|
|
285
337
|
title: z.string(),
|
|
286
338
|
content: z.string().describe("markdown content"),
|
|
339
|
+
sources: z
|
|
340
|
+
.array(z.string())
|
|
341
|
+
.min(1)
|
|
342
|
+
.describe(
|
|
343
|
+
"citation list — URLs, doc names, or meeting references. At least one required."
|
|
344
|
+
),
|
|
287
345
|
},
|
|
288
346
|
},
|
|
289
|
-
async ({ slug, title, content }) =>
|
|
290
|
-
callApi("POST", "/api/wiki/save", { slug, title, content })
|
|
347
|
+
async ({ slug, title, content, sources }) =>
|
|
348
|
+
callApi("POST", "/api/wiki/save", { slug, title, content, sources })
|
|
291
349
|
);
|
|
292
350
|
|
|
293
351
|
// ============================================================
|
package/bin/llama.mjs
CHANGED
|
@@ -1218,12 +1218,18 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1218
1218
|
}
|
|
1219
1219
|
|
|
1220
1220
|
// ----- Wiki: read a single article (EN by default) -----
|
|
1221
|
+
// Hits /api/wiki/<slug> directly. Earlier versions did a fuzzy
|
|
1222
|
+
// /api/wiki/search call and filtered for an exact slug match — that
|
|
1223
|
+
// 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.
|
|
1221
1226
|
if (area === "wiki" && action === "read") {
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
+
const { flags, positional } = parseFlags(rest);
|
|
1228
|
+
const slug = positional[0];
|
|
1229
|
+
if (!slug) throw new Error("Usage: llama wiki read <slug> [--lang en|zh]");
|
|
1230
|
+
const lang = flags.lang === "zh" ? "zh" : "en";
|
|
1231
|
+
const path = `/api/wiki/${encodeURIComponent(slug)}?lang=${lang}`;
|
|
1232
|
+
print(await request("GET", path));
|
|
1227
1233
|
return;
|
|
1228
1234
|
}
|
|
1229
1235
|
|
package/lib/external.mjs
CHANGED
|
@@ -317,6 +317,40 @@ function guessMimeType(filename) {
|
|
|
317
317
|
return ALLOWED_MIME_BY_EXT[ext] || "application/octet-stream";
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// Paths the upload helper refuses to read. The intent is "user must point
|
|
321
|
+
// at a real, intentional document" — symlinks and well-known config
|
|
322
|
+
// directories are rejected so an automated caller cannot ferry an
|
|
323
|
+
// unintended file into the upload by handing over a misleading path.
|
|
324
|
+
const DISALLOWED_PATH_PREFIXES = [
|
|
325
|
+
".ssh",
|
|
326
|
+
".llama",
|
|
327
|
+
".aws",
|
|
328
|
+
".config/gcloud",
|
|
329
|
+
".gnupg",
|
|
330
|
+
".kube",
|
|
331
|
+
".docker",
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
function assertSafeUploadPath(filePath) {
|
|
335
|
+
const lstat = fs.lstatSync(filePath);
|
|
336
|
+
if (lstat.isSymbolicLink()) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"Upload path is a symbolic link; pass the real file path instead."
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (!lstat.isFile()) {
|
|
342
|
+
throw new Error("Upload path is not a regular file.");
|
|
343
|
+
}
|
|
344
|
+
const resolved = fs.realpathSync(filePath);
|
|
345
|
+
const home = os.homedir();
|
|
346
|
+
for (const prefix of DISALLOWED_PATH_PREFIXES) {
|
|
347
|
+
const denied = path.join(home, prefix);
|
|
348
|
+
if (resolved === denied || resolved.startsWith(denied + path.sep)) {
|
|
349
|
+
throw new Error("Upload path is not allowed.");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
export async function uploadExternalFile(filePath) {
|
|
321
355
|
const session = readExternalSession();
|
|
322
356
|
if (!session) {
|
|
@@ -329,6 +363,8 @@ export async function uploadExternalFile(filePath) {
|
|
|
329
363
|
throw new Error(`File not found: ${filePath}`);
|
|
330
364
|
}
|
|
331
365
|
|
|
366
|
+
assertSafeUploadPath(filePath);
|
|
367
|
+
|
|
332
368
|
const fileData = fs.readFileSync(filePath);
|
|
333
369
|
const filename = path.basename(filePath);
|
|
334
370
|
const mimetype = guessMimeType(filename);
|