@llamaventures/cli 2.7.1 → 2.9.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/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@ this project adheres to [Semantic Versioning](https://semver.org).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.9.0] - 2026-11-13
10
+
11
+ ### Changed
12
+
13
+ - `llama auth login` no longer has to be running at the moment you approve in
14
+ the browser. The loopback listener is now a detached helper that stays open
15
+ for 15 minutes, finishes the sign-in when you approve, and stores the
16
+ credentials. If the command stops first (closed terminal, slow approval, an
17
+ agent tool that kills long commands), run `llama auth login` again: it picks
18
+ up the sign-in that is still waiting, or the approval you already gave,
19
+ instead of leaving the browser on a dead `127.0.0.1` page.
20
+ - `llama auth status` reports a sign-in that is waiting for approval.
21
+
22
+ ### Added
23
+
24
+ - `LLAMA_NO_BROWSER=1` makes `llama auth login` print the sign-in link instead
25
+ of opening a browser.
26
+
27
+ ## [2.8.0] - 2026-11-12
28
+
29
+ ### Added
30
+
31
+ - Export the API-backed MCP tool catalog from `lib/mcp-api-tools.mjs`.
32
+ `registerApiTools(server, { request, formatError, version, only })` registers
33
+ the Deal, Deal Memory, bootstrap, Page schema, skills, preference and Wiki
34
+ tools against an injected request function, so a remote MCP host can serve
35
+ the same catalog as the stdio server. The module also exports
36
+ `writeDealCommandSchema`. Tools that need local credentials or files stay in
37
+ `llama-mcp`. No tool name, schema or behavior changed for stdio users.
38
+
39
+ ### Changed
40
+
41
+ - `write_deal` and `wiki_save` descriptions each name the other as the
42
+ alternative destination: a file or HTML about one company is a Deal
43
+ Artifact; knowledge not tied to one company belongs in the Wiki.
44
+
9
45
  ## [2.7.1] - 2026-11-10
10
46
 
11
47
  ### Added
package/README.md CHANGED
@@ -13,6 +13,11 @@ sign-in. Logout, administrator revocation, or account deactivation can end acces
13
13
  sooner. On systems without a credential store, credentials are stored in a local
14
14
  file readable only by your user.
15
15
 
16
+ The sign-in stays open for 15 minutes even if the command stops (a closed
17
+ terminal, or an agent tool that times out): run `llama auth login` again and it
18
+ picks up the same sign-in, or the approval you already gave. Set
19
+ `LLAMA_NO_BROWSER=1` to print the link instead of opening a browser.
20
+
16
21
  ## Install or upgrade
17
22
 
18
23
  ```bash
package/bin/llama-mcp.mjs CHANGED
@@ -1,17 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { buildArtifactReadPath, buildWikiReadPath } from "../lib/content-read.mjs";
4
3
  import { createRequire } from "node:module";
5
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
6
  import { z } from "zod";
7
+ import { registerApiTools, structuredBootstrapManifest, textResult } from "../lib/mcp-api-tools.mjs";
8
8
  import {
9
9
  formatErrorForDisplay,
10
10
  getAuthHeaders,
11
11
  readBriefing,
12
12
  request,
13
13
  setClientRuntime,
14
- structuredBootstrapManifest,
15
14
  } from "../lib/client.mjs";
16
15
  import {
17
16
  clearExternalSession,
@@ -20,18 +19,7 @@ import {
20
19
  startExternalSession,
21
20
  uploadExternalFile,
22
21
  } from "../lib/external.mjs";
23
- import {
24
- buildDealReadPath,
25
- buildDealSearchPath,
26
- verifyDealSearchResult,
27
- informationReadFilter,
28
- verifyInformationReadResult,
29
- compactDealWriteResult,
30
- prepareDealCommand,
31
- } from "../lib/deal-actions.mjs";
32
22
  import { submitFeedback, readFeedback, resolveFeedback } from "../lib/feedback.mjs";
33
- import { buildDealMemoryPath } from "../lib/deal-memory-actions.mjs";
34
- import { buildPageSchemaPath, MAX_PAGE_SCHEMA_FIELDS } from "../lib/page-schema.mjs";
35
23
 
36
24
  const requireFromHere = createRequire(import.meta.url);
37
25
  const { version: PKG_VERSION } = requireFromHere("../package.json");
@@ -39,13 +27,6 @@ setClientRuntime({ client: "mcp" });
39
27
 
40
28
  const server = new McpServer({ name: "llama-mcp", version: PKG_VERSION });
41
29
 
42
- function textResult(text, isError = false) {
43
- return {
44
- content: [{ type: "text", text }],
45
- ...(isError ? { isError: true } : {}),
46
- };
47
- }
48
-
49
30
  async function callApi(method, path, body, transform = (value) => value) {
50
31
  try {
51
32
  const result = transform(await request(method, path, body));
@@ -86,175 +67,9 @@ server.registerTool("feedback_resolve", {
86
67
  catch (error) { return textResult(formatErrorForDisplay(error), true); }
87
68
  });
88
69
 
89
- const originSchema = z.object({
90
- kind: z.enum(["user", "agent", "system"]),
91
- originalUserUtterance: z.string().min(1).optional(),
92
- originatingChatRecordId: z.string().uuid().optional(),
93
- });
94
-
95
- const writeDealCommandSchema = z.discriminatedUnion("operation", [
96
- z.object({
97
- operation: z.literal("input.submit"),
98
- dealId: z.string().uuid(),
99
- format: z.string().min(1).max(120).optional(),
100
- content: z.any(),
101
- source: z.record(z.string(), z.any()).optional(),
102
- origin: originSchema,
103
- idempotencyKey: z.string().min(1).max(240).optional(),
104
- }).strict(),
105
- z.object({
106
- operation: z.literal("information.put"),
107
- dealId: z.string().uuid(),
108
- informationId: z.string().uuid().optional(),
109
- type: z.string().min(1).max(120),
110
- labels: z.array(z.string()).optional(),
111
- subject: z.record(z.string(), z.any()).optional(),
112
- value: z.any(),
113
- expectedVersion: z.number().int().positive().optional(),
114
- origin: originSchema,
115
- idempotencyKey: z.string().min(1).max(240).optional(),
116
- }).strict(),
117
- z.object({
118
- operation: z.literal("page.patch"),
119
- dealId: z.string().uuid(),
120
- patch: z.record(z.string(), z.any()),
121
- expectedRevision: z.number().int().nonnegative().optional(),
122
- origin: originSchema,
123
- idempotencyKey: z.string().min(1).max(240).optional(),
124
- }).strict(),
125
- z.object({
126
- operation: z.literal("artifact.put"),
127
- dealId: z.string().uuid(),
128
- artifactId: z.string().uuid().optional().describe("Reuse the existing ID to append a complete file version, including on rename. Omit for a new document without a title collision, or explicitly use a fresh UUID for a distinct same-name source."),
129
- kind: z.string().min(1).max(120),
130
- title: z.string().min(1).max(500),
131
- mimeType: z.string().min(1).max(240),
132
- contentBase64: z.string().min(1).optional(),
133
- storageKey: z.string().min(1).optional(),
134
- storageUrl: z.string().url().optional(),
135
- byteSize: z.number().int().nonnegative().optional(),
136
- sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
137
- metadata: z.record(z.string(), z.any()).optional(),
138
- origin: originSchema,
139
- idempotencyKey: z.string().min(1).max(240).optional(),
140
- }).strict(),
141
- ]);
142
-
143
- // Deal is deliberately a closed four-tool surface. Do not add split resource
144
- // tools here: one more tool is one more branch every agent must reason about.
145
- server.registerTool(
146
- "search_deals",
147
- {
148
- description: "Search current Deal metadata by company name/key, description, sector/subsector or founder name in either stored language. Use short keywords; matching is a case-insensitive literal substring, not semantic search. Combine q with sector/status/state/limit, or use sector alone for category discovery. status filters by exact pipeline status (e.g. [\"Invested\"]); to list every invested Deal use status [\"Invested\"] with limit 200 and order by investmentDate/markedInvestedAt, instead of guessing from names. Sector filters before limit and requires a supporting Core server. Exact names rank first. Inspect searchMatch fields and search.has_more; truncated results are not exhaustive. On zero matches, try broader or alternative wording before concluding no Deal exists. Read candidates to verify specific categories and claims. Always search before creating a company.",
149
- inputSchema: {
150
- q: z.string().optional(),
151
- sector: z.string().trim().min(1).max(200).optional(),
152
- status: z.array(z.string().min(1).max(60)).min(1).max(10).optional(),
153
- state: z.enum(["active", "archived", "trashed"]).optional(),
154
- limit: z.number().int().min(1).max(2000).optional(),
155
- },
156
- },
157
- // @core-api-operation GET /api/occam/deals
158
- async ({ q, sector, status, state, limit } = {}) => callApi(
159
- "GET", buildDealSearchPath(q, { sector, status, state, limit }), undefined,
160
- (result) => verifyDealSearchResult(result, { sector, status }),
161
- ),
162
- );
163
-
164
- server.registerTool(
165
- "read_deal",
166
- {
167
- description: "Read one Deal. Live Page is current human-visible state; Information is memory and never updates Page automatically. Read Information selectively with informationIds (full values, up to 50), informationTypes, and/or informationQuery (literal text; returns match.snippets, and values over 8,000 characters as { omitted, chars }: then read that id). Prefer these over detail=memory, which returns up to ~2 MB. Use artifactId to read original source text with page/paragraph anchors. Continue with nextOffset and source.sha256. Page field semantics come from agent_bootstrap, not every Deal read.",
168
- inputSchema: {
169
- dealId: z.string().min(1),
170
- detail: z.enum(["overview", "memory", "files", "conversation", "history", "all"]).optional(),
171
- artifactId: z.string().uuid().optional(),
172
- version: z.number().int().min(1).optional(),
173
- offset: z.number().int().min(0).optional(),
174
- limit: z.number().int().min(1).max(50000).optional(),
175
- sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
176
- informationIds: z.array(z.string().uuid()).min(1).max(50).optional(),
177
- informationTypes: z.array(z.string().min(1).max(120)).min(1).max(20).optional(),
178
- informationQuery: z.string().min(2).max(200).optional(),
179
- },
180
- },
181
- async ({ dealId, detail, artifactId, informationIds, informationTypes, informationQuery, ...options }) => {
182
- const filter = informationReadFilter({ ids: informationIds, types: informationTypes, q: informationQuery });
183
- if (artifactId) {
184
- if (detail) throw new Error("Use artifactId or detail, not both");
185
- if (filter) throw new Error("Use artifactId or information filters, not both");
186
- // @core-api-operation GET /api/occam/deals/{dealId}/artifacts/{artifactId}
187
- return callApi("GET", buildArtifactReadPath(dealId, artifactId, options));
188
- }
189
- if (Object.values(options).some(value => value !== undefined)) throw new Error("Content options require artifactId");
190
- // @core-api-operation GET /api/occam/deals/{dealId}
191
- return callApi("GET", buildDealReadPath(dealId, detail || "overview", filter), undefined,
192
- (result) => verifyInformationReadResult(result, filter));
193
- },
194
- );
195
-
196
- server.registerTool(
197
- "create_deal",
198
- {
199
- description: "Create one Deal intent. Core owns Drive, initial resources, idempotency, and Events.",
200
- inputSchema: {
201
- companyName: z.string().min(1).max(240),
202
- companyKey: z.string().min(1).max(240).optional(),
203
- page: z.record(z.string(), z.any()).optional(),
204
- information: z.array(z.record(z.string(), z.any())).optional(),
205
- origin: originSchema,
206
- idempotencyKey: z.string().min(1).max(240).optional(),
207
- },
208
- },
209
- async (input) => callApi("POST", "/api/occam/deals/commands", prepareDealCommand("create", input)),
210
- );
211
-
212
- server.registerTool(
213
- "write_deal",
214
- {
215
- description: "The only Deal mutation tool. Pass one command: input.submit, information.put, page.patch, or artifact.put. Before artifact.put, read Deal files: reuse artifactId for a revision; a same-kind/title collision without an ID returns OCCAM_CONFLICT. Titles are candidates, not identity. Select the intended existing ID from the request/content, or explicitly generate a fresh UUID for a distinct same-name source. Send complete revised bytes, preserve applicable metadata, and verify the ID/version. Core hosts text/html in Command; do not mirror HTML to Drive or require a Drive link. Non-HTML source files, including original transcripts and recordings, go to Drive through artifact.put. Keep source-file bodies out of Input, Information, Page and metadata; save extracted facts and summaries with source references and preserve the user request. page.patch does not edit file bytes. All Page content fields are Agent-writable; author controls attribution, not permission. Before page.patch, use get_live_deal_page_schema for the exact fields being changed and write bilingual Page prose. page.patch is JSON Merge Patch: arrays replace whole arrays, so read-modify-write and preserve sibling slots. A Llama user's own judgment is human_subjective_view.people or human_subjective_view.business and must carry value.speaker, value.rawText quoted verbatim from origin.originalUserUtterance, and value.summary (the agent's restatement); it is rejected otherwise.",
216
- // MCP SDK 1.30 only publishes object schemas at the tool root. Nesting one
217
- // command preserves the real discriminated union instead of flattening four
218
- // incompatible operations into false affordances.
219
- inputSchema: { command: writeDealCommandSchema },
220
- },
221
- async ({ command }) => callApi(
222
- "POST",
223
- "/api/occam/deals/commands",
224
- prepareDealCommand("write", command),
225
- (result) => compactDealWriteResult(command, result),
226
- ),
227
- );
228
-
229
- // Deal Memory is a separate sidecar domain. These do not add an Occam Deal
230
- // business resource or widen the deliberately closed four-tool Deal surface.
231
- server.registerTool(
232
- "get_deal_memory",
233
- {
234
- description: "Read the complete canonical Markdown Deal Story and its opaque concurrency version. A returned placeholder is an existing Story whose version is required for a write; only 404 means no Story exists.",
235
- inputSchema: { dealId: z.string().uuid() },
236
- },
237
- // @core-api-operation GET /api/deal-memory/{dealId}/story
238
- async ({ dealId }) => callApi("GET", buildDealMemoryPath(dealId)),
239
- );
240
-
241
- server.registerTool(
242
- "update_deal_memory",
243
- {
244
- description: "Replace the complete Markdown Deal Story with one coherent current understanding; never append an update log, restate Live Deal Page/Deal Information fields, or write when understanding would not materially improve. Frontmatter must contain matching deal_id plus stable uuid and created timestamps; updated must strictly advance. Pass the version from get_deal_memory for every returned Story, including a placeholder; only 404 permits creation without expected_version.",
245
- inputSchema: {
246
- dealId: z.string().uuid(),
247
- markdown: z.string().min(1).max(1_048_576),
248
- expected_version: z.string().min(1).optional(),
249
- },
250
- },
251
- // @core-api-operation PUT /api/deal-memory/{dealId}/story
252
- async ({ dealId, markdown, expected_version }) => callApi(
253
- "PUT",
254
- buildDealMemoryPath(dealId),
255
- { markdown, ...(expected_version ? { expected_version } : {}) },
256
- ),
257
- );
70
+ // Deal, Deal Memory, bootstrap, Page schema, skills, preferences and Wiki tools
71
+ // are shared with remote MCP hosts; see lib/mcp-api-tools.mjs.
72
+ registerApiTools(server, { request, formatError: formatErrorForDisplay, version: PKG_VERSION });
258
73
 
259
74
  server.registerTool(
260
75
  "auth_status",
@@ -274,185 +89,6 @@ server.registerTool(
274
89
  },
275
90
  );
276
91
 
277
- server.registerTool(
278
- "agent_bootstrap",
279
- {
280
- description: "Mandatory before Llama work: fetch the authenticated two-part private Brain (Investment Framework V3 + Llama Command operating skill), a compact Live Deal Page schema index, runtime contract including Wiki publishing guidance, and visible Llama OS skills. Read exact Page fields separately with get_live_deal_page_schema.",
281
- inputSchema: { limit: z.number().int().min(1).max(100).optional() },
282
- },
283
- async ({ limit } = {}) => {
284
- const params = new URLSearchParams({ clientVersion: PKG_VERSION });
285
- params.set("page_schema", "progressive");
286
- if (limit) params.set("limit", String(limit));
287
- try {
288
- const result = await request("GET", `/api/agent/manifest?${params}`);
289
- return textResult(JSON.stringify(structuredBootstrapManifest(result), null, 2));
290
- } catch (error) {
291
- return textResult(formatErrorForDisplay(error), true);
292
- }
293
- },
294
- );
295
-
296
- server.registerTool(
297
- "get_live_deal_page_schema",
298
- {
299
- description: "Read the Live Deal Page schema progressively. Call with no selector for the compact index; before page.patch, request only exact fields or one section. This is read-only context, not a fifth Deal action.",
300
- inputSchema: {
301
- fields: z.array(z.string().min(1)).min(1).max(MAX_PAGE_SCHEMA_FIELDS).optional(),
302
- section: z.string().min(1).optional(),
303
- },
304
- },
305
- async ({ fields, section } = {}) => {
306
- try {
307
- // @core-api-operation GET /api/agent/page-schema
308
- return await callApi("GET", buildPageSchemaPath({ fields, section }));
309
- } catch (error) {
310
- return textResult(formatErrorForDisplay(error), true);
311
- }
312
- },
313
- );
314
-
315
- server.registerTool(
316
- "skills_search",
317
- {
318
- description: "Search authenticated runtime skills; read only the relevant result.",
319
- inputSchema: { q: z.string().min(1), limit: z.number().int().min(1).max(100).optional() },
320
- },
321
- async ({ q, limit }) => {
322
- const params = new URLSearchParams({ q });
323
- if (limit) params.set("limit", String(limit));
324
- return callApi("GET", `/api/agent/skills?${params}`);
325
- },
326
- );
327
-
328
- server.registerTool(
329
- "skills_read",
330
- {
331
- description: "Read one authenticated runtime skill by slug.",
332
- inputSchema: { slug: z.string().min(1) },
333
- },
334
- async ({ slug }) => callApi("GET", `/api/agent/skills/${encodeURIComponent(slug)}`),
335
- );
336
-
337
- server.registerTool(
338
- "pref_list",
339
- {
340
- description: "List standing agent preferences.",
341
- inputSchema: { status: z.enum(["active", "proposed", "retired", "all"]).optional() },
342
- },
343
- async ({ status } = {}) => {
344
- const params = new URLSearchParams();
345
- if (status) params.set("status", status);
346
- return callApi("GET", `/api/agent/preferences${params.size ? `?${params}` : ""}`);
347
- },
348
- );
349
-
350
- server.registerTool(
351
- "pref_add",
352
- {
353
- description: "Save a durable user or team preference; procedures belong in skills.",
354
- inputSchema: {
355
- key: z.string().min(1),
356
- content: z.string().min(1).max(280),
357
- scope: z.enum(["user", "team"]).optional(),
358
- evidence: z.string().optional(),
359
- },
360
- },
361
- async ({ key, content, scope, evidence }) =>
362
- callApi("POST", "/api/agent/preferences", { key, content, scope, evidence }),
363
- );
364
-
365
- server.registerTool(
366
- "pref_set_status",
367
- {
368
- description: "Activate or retire a standing preference.",
369
- inputSchema: { id: z.number().int().positive(), status: z.enum(["active", "retired"]) },
370
- },
371
- async ({ id, status }) => callApi("PATCH", `/api/agent/preferences/${id}`, { status }),
372
- );
373
-
374
- server.registerTool(
375
- "object_inspect",
376
- {
377
- description: "Explain a Command URL or object before guessing that it is broken.",
378
- inputSchema: {
379
- q: z.string().optional(),
380
- type: z.string().optional(),
381
- id: z.string().optional(),
382
- lang: z.enum(["en", "zh"]).optional(),
383
- },
384
- },
385
- async ({ q, type, id, lang } = {}) => {
386
- const params = new URLSearchParams();
387
- if (q) params.set("q", q);
388
- if (type) params.set("type", type);
389
- if (id) params.set("id", id);
390
- if (lang) params.set("lang", lang);
391
- return callApi("GET", `/api/agent/explain?${params}`);
392
- },
393
- );
394
-
395
- server.registerTool(
396
- "wiki_search",
397
- {
398
- description: "Search institutional knowledge in the internal Wiki.",
399
- inputSchema: { q: z.string().min(1) },
400
- },
401
- async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`),
402
- );
403
-
404
- server.registerTool(
405
- "wiki_read",
406
- {
407
- description: "Read one Wiki article. format=text extracts its original upload or page body. attachment follows an ID listed in attachments. Continuations require offset and sha256.",
408
- inputSchema: {
409
- slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional(),
410
- format: z.literal("text").optional(), attachment: z.string().optional(),
411
- offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(50000).optional(),
412
- sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
413
- },
414
- },
415
- // @core-api-operation GET /api/wiki/{slug}
416
- async ({ slug, ...options }) => callApi("GET", buildWikiReadPath(slug, options)),
417
- );
418
-
419
- server.registerTool(
420
- "wiki_save",
421
- {
422
- description: "Publish institutional knowledge when authorized. Follow agent_bootstrap contract.wiki_publishing. New pages default to complete self-contained HTML with embedded rendering assets and full report content/interactions; set content_type=html. Honor explicit Markdown requests. Read the same slug/lang before edits and preserve its format unless conversion is authorized. Verify the receipt, canonical page and interactions before reporting completion.",
423
- inputSchema: {
424
- slug: z.string().min(1),
425
- title: z.string().min(1),
426
- content: z.string().describe("Complete page source. For HTML, include the full document and embedded rendering dependencies."),
427
- sources: z.array(z.string()).min(1),
428
- content_type: z.enum(["markdown", "html"]).optional().describe("Set html for HTML publications. Use markdown for an explicit Markdown request or an existing Markdown article."),
429
- lang: z.enum(["en", "zh"]).optional().describe("Article language; Core defaults to en when omitted."),
430
- },
431
- },
432
- async ({ slug, title, content, sources, content_type, lang }) =>
433
- callApi("POST", "/api/wiki/save", { slug, title, content, sources, content_type, lang }),
434
- );
435
-
436
- server.registerTool(
437
- "wiki_delete",
438
- {
439
- description: "Soft-delete a Wiki article.",
440
- inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
441
- },
442
- async ({ slug, lang }) =>
443
- callApi("DELETE", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`),
444
- );
445
-
446
- server.registerTool(
447
- "wiki_restore",
448
- {
449
- description: "Restore a soft-deleted Wiki article.",
450
- inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
451
- },
452
- async ({ slug, lang }) =>
453
- callApi("POST", `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang === "zh" ? "zh" : "en"}`),
454
- );
455
-
456
92
  server.registerTool(
457
93
  "pitch_start",
458
94
  {
package/bin/llama.mjs CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  startExternalSession,
29
29
  uploadExternalFile,
30
30
  } from "../lib/external.mjs";
31
- import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
32
- import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
31
+ import { fileURLToPath } from "node:url";
32
+ import { LLAMA_CLI_CLIENT_ID, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
33
+ import { liveLogin, readPending, runLoginListener, startOrResumeLogin } from "../lib/oauth-background.mjs";
34
+ import { deleteBundle, detectBackend, readBundle } from "../lib/oauth-storage.mjs";
33
35
  import { getBuildInfo } from "../lib/build-info.mjs";
34
36
  import { getUpdateNudge, maybeNudgeUpdate } from "../lib/version-check.mjs";
35
37
  import {
@@ -516,6 +518,7 @@ async function handleAuth(area, action, rest) {
516
518
  }
517
519
  }
518
520
  const oauthBackend = oauth ? await detectBackend() : null;
521
+ const pendingLogin = readPending();
519
522
  print({
520
523
  baseUrl: getBaseUrl(),
521
524
  activeMethod: oauth?.access_token ? "oauth" : bearer ? "gcloud-bearer" : token ? "llama-token" : "none",
@@ -524,6 +527,9 @@ async function handleAuth(area, action, rest) {
524
527
  llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
525
528
  llamaTokenSource: process.env.LLAMA_TOKEN ? "$LLAMA_TOKEN" : readCanonicalToken() ? "~/.llama/token" : null,
526
529
  serverCheck,
530
+ ...(liveLogin(pendingLogin, getBaseUrl())
531
+ ? { pendingLogin: { waitingForApproval: true, url: pendingLogin.url, expiresAt: new Date(pendingLogin.expires_at).toISOString() } }
532
+ : {}),
527
533
  });
528
534
  return true;
529
535
  }
@@ -531,18 +537,16 @@ async function handleAuth(area, action, rest) {
531
537
  const { flags } = parseFlags(rest, ["scope"]);
532
538
  const scope = typeof flags.scope === "string" ? flags.scope : "read write";
533
539
  const baseUrl = getBaseUrl();
534
- const bundle = await pkceLoopbackFlow({ baseUrl, scope, resource: baseUrl });
535
- const stored = await writeBundle({
536
- access_token: bundle.access_token,
537
- refresh_token: bundle.refresh_token,
538
- expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
539
- scope: bundle.scope,
540
- client_id: bundle.client_id,
541
- issuer: bundle.issuer,
542
- resource: bundle.resource,
543
- created_at: Date.now(),
544
- });
545
- print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: stored.backend, scope: bundle.scope });
540
+ // The listener is a detached helper, so this login survives this command
541
+ // being closed or killed; see lib/oauth-background.mjs.
542
+ const result = await startOrResumeLogin({ baseUrl, scope, cliEntry: fileURLToPath(import.meta.url) });
543
+ print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: result.storage, scope: result.scope, ...(result.resumed ? { resumed: true } : {}) });
544
+ return true;
545
+ }
546
+ if (area === "auth" && action === "login-listener") {
547
+ // Internal: the detached helper started by `llama auth login`.
548
+ const { flags } = parseFlags(rest, ["scope"]);
549
+ await runLoginListener({ baseUrl: getBaseUrl(), scope: typeof flags.scope === "string" ? flags.scope : "read write" });
546
550
  return true;
547
551
  }
548
552
  if (area === "auth" && action === "logout") {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "format": "llama.cli-build.v1",
3
3
  "packageName": "@llamaventures/cli",
4
- "packageVersion": "2.7.1",
5
- "sourceSha": "18bbea099ee97465f3c0ba4cbc01ff7c0c8a12bf",
4
+ "packageVersion": "2.9.0",
5
+ "sourceSha": "55e2fd7670bb5667cf2c419e884620faa403d5c3",
6
6
  "sourceKind": "github",
7
7
  "sourceDirty": false,
8
8
  "coreApiContract": {
package/lib/client.mjs CHANGED
@@ -594,13 +594,7 @@ export function formatErrorForDisplay(error) {
594
594
  return `Error${code}: ${message}`;
595
595
  }
596
596
 
597
- export function structuredBootstrapManifest(manifest) {
598
- if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
599
- return manifest;
600
- }
601
- const { briefing: _renderedDuplicate, ...structured } = manifest;
602
- return structured;
603
- }
597
+ export { structuredBootstrapManifest } from "./mcp-api-tools.mjs";
604
598
 
605
599
  async function requestWithRetry(method, endpoint, body, opts, allowRetry) {
606
600
  const authHeaders = await getAuthHeaders();
@@ -0,0 +1,414 @@
1
+ // The API-backed Llama tool catalog, independent of transport and of how the
2
+ // caller authenticates. The stdio server (bin/llama-mcp.mjs) passes the local
3
+ // HTTP client; a remote MCP endpoint hosted by Llama Command passes its own
4
+ // in-process request function. Nothing here may touch local credentials,
5
+ // local files, or process state: tools that need those stay in bin/.
6
+
7
+ import { z } from "zod";
8
+ import { buildArtifactReadPath, buildWikiReadPath } from "./content-read.mjs";
9
+ import {
10
+ buildDealReadPath,
11
+ buildDealSearchPath,
12
+ verifyDealSearchResult,
13
+ informationReadFilter,
14
+ verifyInformationReadResult,
15
+ compactDealWriteResult,
16
+ prepareDealCommand,
17
+ } from "./deal-actions.mjs";
18
+ import { buildDealMemoryPath } from "./deal-memory-actions.mjs";
19
+ import { buildPageSchemaPath, MAX_PAGE_SCHEMA_FIELDS } from "./page-schema.mjs";
20
+
21
+ export function textResult(text, isError = false) {
22
+ return {
23
+ content: [{ type: "text", text }],
24
+ ...(isError ? { isError: true } : {}),
25
+ };
26
+ }
27
+
28
+ /** The manifest also carries a rendered duplicate of itself; tools return the structured half. */
29
+ export function structuredBootstrapManifest(manifest) {
30
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
31
+ return manifest;
32
+ }
33
+ const { briefing: _renderedDuplicate, ...structured } = manifest;
34
+ return structured;
35
+ }
36
+
37
+ export const originSchema = z.object({
38
+ kind: z.enum(["user", "agent", "system"]),
39
+ originalUserUtterance: z.string().min(1).optional(),
40
+ originatingChatRecordId: z.string().uuid().optional(),
41
+ });
42
+
43
+ export const writeDealCommandSchema = z.discriminatedUnion("operation", [
44
+ z.object({
45
+ operation: z.literal("input.submit"),
46
+ dealId: z.string().uuid(),
47
+ format: z.string().min(1).max(120).optional(),
48
+ content: z.any(),
49
+ source: z.record(z.string(), z.any()).optional(),
50
+ origin: originSchema,
51
+ idempotencyKey: z.string().min(1).max(240).optional(),
52
+ }).strict(),
53
+ z.object({
54
+ operation: z.literal("information.put"),
55
+ dealId: z.string().uuid(),
56
+ informationId: z.string().uuid().optional(),
57
+ type: z.string().min(1).max(120),
58
+ labels: z.array(z.string()).optional(),
59
+ subject: z.record(z.string(), z.any()).optional(),
60
+ value: z.any(),
61
+ expectedVersion: z.number().int().positive().optional(),
62
+ origin: originSchema,
63
+ idempotencyKey: z.string().min(1).max(240).optional(),
64
+ }).strict(),
65
+ z.object({
66
+ operation: z.literal("page.patch"),
67
+ dealId: z.string().uuid(),
68
+ patch: z.record(z.string(), z.any()),
69
+ expectedRevision: z.number().int().nonnegative().optional(),
70
+ origin: originSchema,
71
+ idempotencyKey: z.string().min(1).max(240).optional(),
72
+ }).strict(),
73
+ z.object({
74
+ operation: z.literal("artifact.put"),
75
+ dealId: z.string().uuid(),
76
+ artifactId: z.string().uuid().optional().describe("Reuse the existing ID to append a complete file version, including on rename. Omit for a new document without a title collision, or explicitly use a fresh UUID for a distinct same-name source."),
77
+ kind: z.string().min(1).max(120),
78
+ title: z.string().min(1).max(500),
79
+ mimeType: z.string().min(1).max(240),
80
+ contentBase64: z.string().min(1).optional(),
81
+ storageKey: z.string().min(1).optional(),
82
+ storageUrl: z.string().url().optional(),
83
+ byteSize: z.number().int().nonnegative().optional(),
84
+ sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
85
+ metadata: z.record(z.string(), z.any()).optional(),
86
+ origin: originSchema,
87
+ idempotencyKey: z.string().min(1).max(240).optional(),
88
+ }).strict(),
89
+ ]);
90
+
91
+ /**
92
+ * Register the API-backed tools on an MCP server.
93
+ *
94
+ * @param server an McpServer
95
+ * @param deps.request async (method, path, body) => parsed JSON; throws on failure
96
+ * @param deps.formatError (error) => string shown to the agent
97
+ * @param deps.version client version reported to bootstrap
98
+ * @param deps.only optional list of tool names; others are not registered
99
+ */
100
+ export function registerApiTools(server, { request, formatError, version, only } = {}) {
101
+ if (typeof request !== "function") throw new Error("registerApiTools requires a request function");
102
+ const describeError = formatError ?? ((error) => `Error: ${error?.message ?? String(error)}`);
103
+ const formatErrorForDisplay = describeError;
104
+ const allowed = only ? new Set(only) : null;
105
+ const registerTool = (name, ...rest) => {
106
+ if (allowed && !allowed.has(name)) return;
107
+ server.registerTool(name, ...rest);
108
+ };
109
+
110
+ async function callApi(method, path, body, transform = (value) => value) {
111
+ try {
112
+ const result = transform(await request(method, path, body));
113
+ return textResult(typeof result === "string" ? result : JSON.stringify(result, null, 2));
114
+ } catch (error) {
115
+ return textResult(describeError(error), true);
116
+ }
117
+ }
118
+
119
+
120
+ // Deal is deliberately a closed four-tool surface. Do not add split resource
121
+ // tools here: one more tool is one more branch every agent must reason about.
122
+ registerTool(
123
+ "search_deals",
124
+ {
125
+ description: "Search current Deal metadata by company name/key, description, sector/subsector or founder name in either stored language. Use short keywords; matching is a case-insensitive literal substring, not semantic search. Combine q with sector/status/state/limit, or use sector alone for category discovery. status filters by exact pipeline status (e.g. [\"Invested\"]); to list every invested Deal use status [\"Invested\"] with limit 200 and order by investmentDate/markedInvestedAt, instead of guessing from names. Sector filters before limit and requires a supporting Core server. Exact names rank first. Inspect searchMatch fields and search.has_more; truncated results are not exhaustive. On zero matches, try broader or alternative wording before concluding no Deal exists. Read candidates to verify specific categories and claims. Always search before creating a company.",
126
+ inputSchema: {
127
+ q: z.string().optional(),
128
+ sector: z.string().trim().min(1).max(200).optional(),
129
+ status: z.array(z.string().min(1).max(60)).min(1).max(10).optional(),
130
+ state: z.enum(["active", "archived", "trashed"]).optional(),
131
+ limit: z.number().int().min(1).max(2000).optional(),
132
+ },
133
+ },
134
+ // @core-api-operation GET /api/occam/deals
135
+ async ({ q, sector, status, state, limit } = {}) => callApi(
136
+ "GET", buildDealSearchPath(q, { sector, status, state, limit }), undefined,
137
+ (result) => verifyDealSearchResult(result, { sector, status }),
138
+ ),
139
+ );
140
+
141
+ registerTool(
142
+ "read_deal",
143
+ {
144
+ description: "Read one Deal. Live Page is current human-visible state; Information is memory and never updates Page automatically. Read Information selectively with informationIds (full values, up to 50), informationTypes, and/or informationQuery (literal text; returns match.snippets, and values over 8,000 characters as { omitted, chars }: then read that id). Prefer these over detail=memory, which returns up to ~2 MB. Use artifactId to read original source text with page/paragraph anchors. Continue with nextOffset and source.sha256. Page field semantics come from agent_bootstrap, not every Deal read.",
145
+ inputSchema: {
146
+ dealId: z.string().min(1),
147
+ detail: z.enum(["overview", "memory", "files", "conversation", "history", "all"]).optional(),
148
+ artifactId: z.string().uuid().optional(),
149
+ version: z.number().int().min(1).optional(),
150
+ offset: z.number().int().min(0).optional(),
151
+ limit: z.number().int().min(1).max(50000).optional(),
152
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
153
+ informationIds: z.array(z.string().uuid()).min(1).max(50).optional(),
154
+ informationTypes: z.array(z.string().min(1).max(120)).min(1).max(20).optional(),
155
+ informationQuery: z.string().min(2).max(200).optional(),
156
+ },
157
+ },
158
+ async ({ dealId, detail, artifactId, informationIds, informationTypes, informationQuery, ...options }) => {
159
+ const filter = informationReadFilter({ ids: informationIds, types: informationTypes, q: informationQuery });
160
+ if (artifactId) {
161
+ if (detail) throw new Error("Use artifactId or detail, not both");
162
+ if (filter) throw new Error("Use artifactId or information filters, not both");
163
+ // @core-api-operation GET /api/occam/deals/{dealId}/artifacts/{artifactId}
164
+ return callApi("GET", buildArtifactReadPath(dealId, artifactId, options));
165
+ }
166
+ if (Object.values(options).some(value => value !== undefined)) throw new Error("Content options require artifactId");
167
+ // @core-api-operation GET /api/occam/deals/{dealId}
168
+ return callApi("GET", buildDealReadPath(dealId, detail || "overview", filter), undefined,
169
+ (result) => verifyInformationReadResult(result, filter));
170
+ },
171
+ );
172
+
173
+ registerTool(
174
+ "create_deal",
175
+ {
176
+ description: "Create one Deal intent. Core owns Drive, initial resources, idempotency, and Events.",
177
+ inputSchema: {
178
+ companyName: z.string().min(1).max(240),
179
+ companyKey: z.string().min(1).max(240).optional(),
180
+ page: z.record(z.string(), z.any()).optional(),
181
+ information: z.array(z.record(z.string(), z.any())).optional(),
182
+ origin: originSchema,
183
+ idempotencyKey: z.string().min(1).max(240).optional(),
184
+ },
185
+ },
186
+ async (input) => callApi("POST", "/api/occam/deals/commands", prepareDealCommand("create", input)),
187
+ );
188
+
189
+ registerTool(
190
+ "write_deal",
191
+ {
192
+ description: "The only Deal mutation tool. Pass one command: input.submit, information.put, page.patch, or artifact.put. A file or HTML about one company belongs here as artifact.put; standalone knowledge not tied to one company belongs in wiki_save. Before artifact.put, read Deal files: reuse artifactId for a revision; a same-kind/title collision without an ID returns OCCAM_CONFLICT. Titles are candidates, not identity. Select the intended existing ID from the request/content, or explicitly generate a fresh UUID for a distinct same-name source. Send complete revised bytes, preserve applicable metadata, and verify the ID/version. Core hosts text/html in Command; do not mirror HTML to Drive or require a Drive link. Non-HTML source files, including original transcripts and recordings, go to Drive through artifact.put. Keep source-file bodies out of Input, Information, Page and metadata; save extracted facts and summaries with source references and preserve the user request. page.patch does not edit file bytes. All Page content fields are Agent-writable; author controls attribution, not permission. Before page.patch, use get_live_deal_page_schema for the exact fields being changed and write bilingual Page prose. page.patch is JSON Merge Patch: arrays replace whole arrays, so read-modify-write and preserve sibling slots. A Llama user's own judgment is human_subjective_view.people or human_subjective_view.business and must carry value.speaker, value.rawText quoted verbatim from origin.originalUserUtterance, and value.summary (the agent's restatement); it is rejected otherwise.",
193
+ // MCP SDK 1.30 only publishes object schemas at the tool root. Nesting one
194
+ // command preserves the real discriminated union instead of flattening four
195
+ // incompatible operations into false affordances.
196
+ inputSchema: { command: writeDealCommandSchema },
197
+ },
198
+ async ({ command }) => callApi(
199
+ "POST",
200
+ "/api/occam/deals/commands",
201
+ prepareDealCommand("write", command),
202
+ (result) => compactDealWriteResult(command, result),
203
+ ),
204
+ );
205
+
206
+ // Deal Memory is a separate sidecar domain. These do not add an Occam Deal
207
+ // business resource or widen the deliberately closed four-tool Deal surface.
208
+ registerTool(
209
+ "get_deal_memory",
210
+ {
211
+ description: "Read the complete canonical Markdown Deal Story and its opaque concurrency version. A returned placeholder is an existing Story whose version is required for a write; only 404 means no Story exists.",
212
+ inputSchema: { dealId: z.string().uuid() },
213
+ },
214
+ // @core-api-operation GET /api/deal-memory/{dealId}/story
215
+ async ({ dealId }) => callApi("GET", buildDealMemoryPath(dealId)),
216
+ );
217
+
218
+ registerTool(
219
+ "update_deal_memory",
220
+ {
221
+ description: "Replace the complete Markdown Deal Story with one coherent current understanding; never append an update log, restate Live Deal Page/Deal Information fields, or write when understanding would not materially improve. Frontmatter must contain matching deal_id plus stable uuid and created timestamps; updated must strictly advance. Pass the version from get_deal_memory for every returned Story, including a placeholder; only 404 permits creation without expected_version.",
222
+ inputSchema: {
223
+ dealId: z.string().uuid(),
224
+ markdown: z.string().min(1).max(1_048_576),
225
+ expected_version: z.string().min(1).optional(),
226
+ },
227
+ },
228
+ // @core-api-operation PUT /api/deal-memory/{dealId}/story
229
+ async ({ dealId, markdown, expected_version }) => callApi(
230
+ "PUT",
231
+ buildDealMemoryPath(dealId),
232
+ { markdown, ...(expected_version ? { expected_version } : {}) },
233
+ ),
234
+ );
235
+
236
+ registerTool(
237
+ "agent_bootstrap",
238
+ {
239
+ description: "Mandatory before Llama work: fetch the authenticated two-part private Brain (Investment Framework V3 + Llama Command operating skill), a compact Live Deal Page schema index, runtime contract including Wiki publishing guidance, and visible Llama OS skills. Read exact Page fields separately with get_live_deal_page_schema.",
240
+ inputSchema: { limit: z.number().int().min(1).max(100).optional() },
241
+ },
242
+ async ({ limit } = {}) => {
243
+ const params = new URLSearchParams({ clientVersion: version });
244
+ params.set("page_schema", "progressive");
245
+ if (limit) params.set("limit", String(limit));
246
+ try {
247
+ const result = await request("GET", `/api/agent/manifest?${params}`);
248
+ return textResult(JSON.stringify(structuredBootstrapManifest(result), null, 2));
249
+ } catch (error) {
250
+ return textResult(formatErrorForDisplay(error), true);
251
+ }
252
+ },
253
+ );
254
+
255
+ registerTool(
256
+ "get_live_deal_page_schema",
257
+ {
258
+ description: "Read the Live Deal Page schema progressively. Call with no selector for the compact index; before page.patch, request only exact fields or one section. This is read-only context, not a fifth Deal action.",
259
+ inputSchema: {
260
+ fields: z.array(z.string().min(1)).min(1).max(MAX_PAGE_SCHEMA_FIELDS).optional(),
261
+ section: z.string().min(1).optional(),
262
+ },
263
+ },
264
+ async ({ fields, section } = {}) => {
265
+ try {
266
+ // @core-api-operation GET /api/agent/page-schema
267
+ return await callApi("GET", buildPageSchemaPath({ fields, section }));
268
+ } catch (error) {
269
+ return textResult(formatErrorForDisplay(error), true);
270
+ }
271
+ },
272
+ );
273
+
274
+ registerTool(
275
+ "skills_search",
276
+ {
277
+ description: "Search authenticated runtime skills; read only the relevant result.",
278
+ inputSchema: { q: z.string().min(1), limit: z.number().int().min(1).max(100).optional() },
279
+ },
280
+ async ({ q, limit }) => {
281
+ const params = new URLSearchParams({ q });
282
+ if (limit) params.set("limit", String(limit));
283
+ return callApi("GET", `/api/agent/skills?${params}`);
284
+ },
285
+ );
286
+
287
+ registerTool(
288
+ "skills_read",
289
+ {
290
+ description: "Read one authenticated runtime skill by slug.",
291
+ inputSchema: { slug: z.string().min(1) },
292
+ },
293
+ async ({ slug }) => callApi("GET", `/api/agent/skills/${encodeURIComponent(slug)}`),
294
+ );
295
+
296
+ registerTool(
297
+ "pref_list",
298
+ {
299
+ description: "List standing agent preferences.",
300
+ inputSchema: { status: z.enum(["active", "proposed", "retired", "all"]).optional() },
301
+ },
302
+ async ({ status } = {}) => {
303
+ const params = new URLSearchParams();
304
+ if (status) params.set("status", status);
305
+ return callApi("GET", `/api/agent/preferences${params.size ? `?${params}` : ""}`);
306
+ },
307
+ );
308
+
309
+ registerTool(
310
+ "pref_add",
311
+ {
312
+ description: "Save a durable user or team preference; procedures belong in skills.",
313
+ inputSchema: {
314
+ key: z.string().min(1),
315
+ content: z.string().min(1).max(280),
316
+ scope: z.enum(["user", "team"]).optional(),
317
+ evidence: z.string().optional(),
318
+ },
319
+ },
320
+ async ({ key, content, scope, evidence }) =>
321
+ callApi("POST", "/api/agent/preferences", { key, content, scope, evidence }),
322
+ );
323
+
324
+ registerTool(
325
+ "pref_set_status",
326
+ {
327
+ description: "Activate or retire a standing preference.",
328
+ inputSchema: { id: z.number().int().positive(), status: z.enum(["active", "retired"]) },
329
+ },
330
+ async ({ id, status }) => callApi("PATCH", `/api/agent/preferences/${id}`, { status }),
331
+ );
332
+
333
+ registerTool(
334
+ "object_inspect",
335
+ {
336
+ description: "Explain a Command URL or object before guessing that it is broken.",
337
+ inputSchema: {
338
+ q: z.string().optional(),
339
+ type: z.string().optional(),
340
+ id: z.string().optional(),
341
+ lang: z.enum(["en", "zh"]).optional(),
342
+ },
343
+ },
344
+ async ({ q, type, id, lang } = {}) => {
345
+ const params = new URLSearchParams();
346
+ if (q) params.set("q", q);
347
+ if (type) params.set("type", type);
348
+ if (id) params.set("id", id);
349
+ if (lang) params.set("lang", lang);
350
+ return callApi("GET", `/api/agent/explain?${params}`);
351
+ },
352
+ );
353
+
354
+ registerTool(
355
+ "wiki_search",
356
+ {
357
+ description: "Search institutional knowledge in the internal Wiki.",
358
+ inputSchema: { q: z.string().min(1) },
359
+ },
360
+ async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`),
361
+ );
362
+
363
+ registerTool(
364
+ "wiki_read",
365
+ {
366
+ description: "Read one Wiki article. format=text extracts its original upload or page body. attachment follows an ID listed in attachments. Continuations require offset and sha256.",
367
+ inputSchema: {
368
+ slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional(),
369
+ format: z.literal("text").optional(), attachment: z.string().optional(),
370
+ offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(50000).optional(),
371
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
372
+ },
373
+ },
374
+ // @core-api-operation GET /api/wiki/{slug}
375
+ async ({ slug, ...options }) => callApi("GET", buildWikiReadPath(slug, options)),
376
+ );
377
+
378
+ registerTool(
379
+ "wiki_save",
380
+ {
381
+ description: "Publish institutional knowledge when authorized. For knowledge not tied to one company; a file or HTML about one company is a Deal Artifact (write_deal artifact.put), unless the user names the Wiki. Follow agent_bootstrap contract.wiki_publishing. New pages default to complete self-contained HTML with embedded rendering assets and full report content/interactions; set content_type=html. Honor explicit Markdown requests. Read the same slug/lang before edits and preserve its format unless conversion is authorized. Verify the receipt, canonical page and interactions before reporting completion.",
382
+ inputSchema: {
383
+ slug: z.string().min(1),
384
+ title: z.string().min(1),
385
+ content: z.string().describe("Complete page source. For HTML, include the full document and embedded rendering dependencies."),
386
+ sources: z.array(z.string()).min(1),
387
+ content_type: z.enum(["markdown", "html"]).optional().describe("Set html for HTML publications. Use markdown for an explicit Markdown request or an existing Markdown article."),
388
+ lang: z.enum(["en", "zh"]).optional().describe("Article language; Core defaults to en when omitted."),
389
+ },
390
+ },
391
+ async ({ slug, title, content, sources, content_type, lang }) =>
392
+ callApi("POST", "/api/wiki/save", { slug, title, content, sources, content_type, lang }),
393
+ );
394
+
395
+ registerTool(
396
+ "wiki_delete",
397
+ {
398
+ description: "Soft-delete a Wiki article.",
399
+ inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
400
+ },
401
+ async ({ slug, lang }) =>
402
+ callApi("DELETE", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`),
403
+ );
404
+
405
+ registerTool(
406
+ "wiki_restore",
407
+ {
408
+ description: "Restore a soft-deleted Wiki article.",
409
+ inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
410
+ },
411
+ async ({ slug, lang }) =>
412
+ callApi("POST", `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang === "zh" ? "zh" : "en"}`),
413
+ );
414
+ }
@@ -0,0 +1,175 @@
1
+ // Browser login that survives the command that started it.
2
+ //
3
+ // The loopback flow needs something listening on 127.0.0.1 at the moment the
4
+ // person clicks Approve. When `llama auth login` itself was that listener, a
5
+ // closed terminal, a slow approval, or an agent tool that kills commands after
6
+ // a couple of minutes left the browser pointing at a dead port.
7
+ //
8
+ // So the listener is a small detached helper process. `llama auth login`
9
+ // starts it, shows the link, and waits; if the command is killed the helper
10
+ // keeps listening, finishes the login when the person approves, and stores
11
+ // the credentials. Running `llama auth login` again picks the same login up
12
+ // instead of starting a second one.
13
+ //
14
+ // The helper's state lives in ~/.llama/oauth-pending.json (0600). It holds the
15
+ // sign-in link and a status, never a token or the PKCE verifier.
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import { openBrowser, pkceLoopbackFlow } from "./oauth-flow.mjs";
22
+ import { writeBundle } from "./oauth-storage.mjs";
23
+
24
+ export const LOGIN_WINDOW_MS = 15 * 60 * 1000;
25
+ /** How long a finished result stays claimable by the next `llama auth login`. */
26
+ const RESULT_TTL_MS = 10 * 60 * 1000;
27
+ const POLL_MS = 400;
28
+
29
+ export function defaultPendingPath() {
30
+ return path.join(os.homedir(), ".llama", "oauth-pending.json");
31
+ }
32
+
33
+ export function readPending(pendingPath = defaultPendingPath()) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(pendingPath, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writePending(pendingPath, state) {
42
+ fs.mkdirSync(path.dirname(pendingPath), { recursive: true, mode: 0o700 });
43
+ const tmp = `${pendingPath}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
45
+ fs.renameSync(tmp, pendingPath);
46
+ }
47
+
48
+ export function clearPending(pendingPath = defaultPendingPath()) {
49
+ try { fs.unlinkSync(pendingPath); } catch { /* not present */ }
50
+ }
51
+
52
+ function processAlive(pid) {
53
+ if (!Number.isInteger(pid) || pid <= 0) return false;
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (error) {
58
+ return error?.code === "EPERM";
59
+ }
60
+ }
61
+
62
+ /** A login someone can still approve: same server, helper alive, inside its window. */
63
+ export function liveLogin(pending, baseUrl, now = Date.now()) {
64
+ return Boolean(
65
+ pending && pending.status === "waiting" && pending.base_url === baseUrl &&
66
+ now < pending.expires_at && processAlive(pending.pid),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The helper process body: listen, finish the login, store credentials.
72
+ * Everything it learns is written to the pending file for whoever asks next.
73
+ */
74
+ export async function runLoginListener({
75
+ baseUrl, scope, pendingPath = defaultPendingPath(), storeBundle = writeBundle, windowMs = LOGIN_WINDOW_MS, flow = pkceLoopbackFlow,
76
+ }) {
77
+ const started = Date.now();
78
+ const base = { pid: process.pid, base_url: baseUrl, scope, started_at: started, expires_at: started + windowMs };
79
+ try {
80
+ const bundle = await flow({
81
+ baseUrl, scope, resource: baseUrl, timeoutMs: windowMs, quiet: true,
82
+ launchBrowser: (url) => writePending(pendingPath, { ...base, status: "waiting", url }),
83
+ });
84
+ const stored = await storeBundle({
85
+ access_token: bundle.access_token,
86
+ refresh_token: bundle.refresh_token,
87
+ expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
88
+ scope: bundle.scope,
89
+ client_id: bundle.client_id,
90
+ issuer: bundle.issuer,
91
+ resource: bundle.resource,
92
+ created_at: Date.now(),
93
+ });
94
+ writePending(pendingPath, { ...base, status: "done", finished_at: Date.now(), storage: stored.backend, scope: bundle.scope, client_id: bundle.client_id });
95
+ } catch (error) {
96
+ writePending(pendingPath, { ...base, status: "error", finished_at: Date.now(), error: String(error?.message ?? error) });
97
+ }
98
+ }
99
+
100
+ function spawnDetachedListener({ cliEntry, scope }) {
101
+ const child = spawn(process.execPath, [cliEntry, "auth", "login-listener", "--scope", scope], {
102
+ detached: true, stdio: "ignore", windowsHide: true, env: process.env,
103
+ });
104
+ child.on("error", () => {});
105
+ child.unref();
106
+ }
107
+
108
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
109
+
110
+ /**
111
+ * Start a browser login, or pick up the one already in progress, and wait.
112
+ *
113
+ * Returns { ok, storage, scope, client_id, resumed } on success. Throws the
114
+ * login's own error (denied, timed out) when it failed. If the caller is
115
+ * killed while waiting, nothing is lost: run it again.
116
+ */
117
+ export async function startOrResumeLogin({
118
+ baseUrl, scope, cliEntry, pendingPath = defaultPendingPath(), waitMs = LOGIN_WINDOW_MS,
119
+ spawnListener = spawnDetachedListener, launchBrowser = openBrowser, log = (line) => console.error(line), now = () => Date.now(),
120
+ }) {
121
+ let pending = readPending(pendingPath);
122
+
123
+ // A login that finished after the previous command stopped waiting.
124
+ if (pending?.base_url === baseUrl && pending.status === "done" && now() - pending.finished_at < RESULT_TTL_MS) {
125
+ clearPending(pendingPath);
126
+ return { ok: true, storage: pending.storage, scope: pending.scope, client_id: pending.client_id, resumed: true };
127
+ }
128
+
129
+ const resumed = liveLogin(pending, baseUrl, now());
130
+ if (resumed) {
131
+ const minutes = Math.max(1, Math.round((pending.expires_at - now()) / 60000));
132
+ log(`A sign-in is already waiting for approval (about ${minutes} min left). Approve it in the browser:`);
133
+ log(` ${pending.url}`);
134
+ log("If you already clicked Approve and saw an error page, reload that tab.");
135
+ } else {
136
+ clearPending(pendingPath);
137
+ spawnListener({ cliEntry, scope, baseUrl, pendingPath });
138
+ const deadline = now() + 15_000;
139
+ while (now() < deadline) {
140
+ pending = readPending(pendingPath);
141
+ if (pending && pending.base_url === baseUrl && pending.status !== undefined) break;
142
+ await sleep(100);
143
+ }
144
+ if (!pending || pending.status === undefined) {
145
+ throw new Error("Error[OAUTH_LISTENER_FAILED]: could not start the sign-in listener. Re-run `llama auth login`.");
146
+ }
147
+ if (pending.status === "waiting") {
148
+ // LLAMA_NO_BROWSER=1: print the link only (SSH, agents, a browser on another machine's profile).
149
+ const openIt = !process.env.LLAMA_NO_BROWSER;
150
+ log(openIt ? `Opening browser to ${baseUrl} for sign-in...` : `Open this link to sign in to ${baseUrl}:`);
151
+ log(openIt ? `(If the browser does not open, visit this URL manually:\n ${pending.url}\n)` : ` ${pending.url}`);
152
+ log("This sign-in stays open for 15 minutes even if this command stops; run `llama auth login` again to pick it up.");
153
+ if (openIt) launchBrowser(pending.url);
154
+ }
155
+ }
156
+
157
+ const waitUntil = now() + waitMs;
158
+ while (now() < waitUntil) {
159
+ pending = readPending(pendingPath);
160
+ if (pending?.status === "done") {
161
+ clearPending(pendingPath);
162
+ return { ok: true, storage: pending.storage, scope: pending.scope, client_id: pending.client_id, resumed };
163
+ }
164
+ if (pending?.status === "error") {
165
+ clearPending(pendingPath);
166
+ throw new Error(pending.error);
167
+ }
168
+ if (!pending || (pending.status === "waiting" && !liveLogin(pending, baseUrl, now()))) {
169
+ clearPending(pendingPath);
170
+ throw new Error("Error[OAUTH_TIMEOUT]: the sign-in was not approved in time. Re-run `llama auth login`.");
171
+ }
172
+ await sleep(POLL_MS);
173
+ }
174
+ throw new Error("Error[OAUTH_STILL_WAITING]: still waiting for approval in the browser. Run `llama auth login` again to keep waiting.");
175
+ }
@@ -48,7 +48,7 @@ export function challengeFor(verifier) {
48
48
  // Browser launcher
49
49
  // ============================================================
50
50
 
51
- function openBrowser(url) {
51
+ export function openBrowser(url) {
52
52
  // Platform-native open. We never block on it (the user closes the
53
53
  // browser when they're done; the loopback server is what we wait for).
54
54
  let cmd, args;
@@ -112,7 +112,7 @@ function respondHtml(res, ok, message) {
112
112
  * @param {string} opts.resource RFC 8707 audience the access token will bind to
113
113
  * @returns {Promise<Object>} {access_token, refresh_token, expires_in, scope, token_type, redirect_uri}
114
114
  */
115
- export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser = openBrowser }) {
115
+ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser = openBrowser, timeoutMs = FLOW_TIMEOUT_MS, quiet = false }) {
116
116
  const verifier = generateVerifier();
117
117
  const challenge = challengeFor(verifier);
118
118
  const state = base64url(randomBytes(16));
@@ -132,9 +132,9 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser
132
132
  try { server.close(); } catch { /* */ }
133
133
  reject(new Error(
134
134
  "Error[OAUTH_TIMEOUT]: Browser flow did not complete within " +
135
- Math.round(FLOW_TIMEOUT_MS / 1000) + "s. Re-run `llama auth login`."
135
+ Math.round(timeoutMs / 1000) + "s. Re-run `llama auth login`."
136
136
  ));
137
- }, FLOW_TIMEOUT_MS);
137
+ }, timeoutMs);
138
138
 
139
139
  server.on("request", (req, res) => {
140
140
  const url = new URL(req.url, "http://127.0.0.1");
@@ -189,8 +189,10 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser
189
189
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
190
190
  authorizeUrl.searchParams.set("resource", resource);
191
191
 
192
- console.error(`Opening browser to ${baseUrl} for sign-in...`);
193
- console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
192
+ if (!quiet) {
193
+ console.error(`Opening browser to ${baseUrl} for sign-in...`);
194
+ console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
195
+ }
194
196
  launchBrowser(authorizeUrl.toString());
195
197
 
196
198
  const code = await codePromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "2.7.1",
3
+ "version": "2.9.0",
4
4
  "description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,7 +11,7 @@
11
11
  "verify:release": "npm test && npm run verify:artifact && node scripts/verify-tarball-clean.mjs",
12
12
  "prepack": "node scripts/prepare-build-manifest.mjs",
13
13
  "postpack": "node scripts/cleanup-build-manifest.mjs",
14
- "test:auth": "node --test scripts/oauth-refresh.test.mjs scripts/oauth-flow.test.mjs"
14
+ "test:auth": "node --test scripts/oauth-refresh.test.mjs scripts/oauth-flow.test.mjs scripts/oauth-background.test.mjs"
15
15
  },
16
16
  "bin": {
17
17
  "llama": "bin/llama.mjs",