@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 +36 -0
- package/README.md +5 -0
- package/bin/llama-mcp.mjs +4 -368
- package/bin/llama.mjs +18 -14
- package/lib/build-manifest.json +2 -2
- package/lib/client.mjs +1 -7
- package/lib/mcp-api-tools.mjs +414 -0
- package/lib/oauth-background.mjs +175 -0
- package/lib/oauth-flow.mjs +8 -6
- package/package.json +2 -2
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 {
|
|
32
|
-
import {
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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") {
|
package/lib/build-manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "llama.cli-build.v1",
|
|
3
3
|
"packageName": "@llamaventures/cli",
|
|
4
|
-
"packageVersion": "2.
|
|
5
|
-
"sourceSha": "
|
|
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
|
|
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
|
+
}
|
package/lib/oauth-flow.mjs
CHANGED
|
@@ -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(
|
|
135
|
+
Math.round(timeoutMs / 1000) + "s. Re-run `llama auth login`."
|
|
136
136
|
));
|
|
137
|
-
},
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
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",
|