@llamaventures/cli 1.25.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_BRIEFING.md +125 -336
- package/CHANGELOG.md +32 -0
- package/README.md +116 -213
- package/README.zh-CN.md +112 -205
- package/bin/llama-mcp.mjs +209 -1992
- package/bin/llama.mjs +388 -3609
- package/contracts/core-api.json +4 -4
- package/contracts/required-operations.json +27 -356
- package/lib/build-manifest.json +4 -4
- package/lib/client.mjs +12 -5
- package/lib/deal-actions.mjs +102 -0
- package/package.json +3 -3
- package/contracts/investment-workflow-v2.md +0 -19
- package/lib/workflow-audit.mjs +0 -13
- package/lib/workflow-remediation.mjs +0 -45
package/bin/llama.mjs
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { createRequire } from "module";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import readline from "readline";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import readline from "node:readline";
|
|
7
6
|
import {
|
|
8
|
-
DEFAULT_BASE_URL,
|
|
9
|
-
LEGACY_DIR,
|
|
10
|
-
LEGACY_FILE,
|
|
11
|
-
TOKEN_DIR,
|
|
12
|
-
TOKEN_FILE,
|
|
13
7
|
getAuthHeaders,
|
|
14
8
|
getBaseUrl,
|
|
15
9
|
getToken,
|
|
@@ -18,7 +12,6 @@ import {
|
|
|
18
12
|
readCanonicalToken,
|
|
19
13
|
readLegacyConfig,
|
|
20
14
|
request,
|
|
21
|
-
requestSse,
|
|
22
15
|
tryGcloudIdentityToken,
|
|
23
16
|
writeCanonicalToken,
|
|
24
17
|
writeLegacyConfig,
|
|
@@ -34,3773 +27,559 @@ import {
|
|
|
34
27
|
} from "../lib/external.mjs";
|
|
35
28
|
import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
|
|
36
29
|
import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
|
|
37
|
-
import { maybeNudgeUpdate, getUpdateNudge } from "../lib/version-check.mjs";
|
|
38
30
|
import { getBuildInfo } from "../lib/build-info.mjs";
|
|
39
|
-
import {
|
|
31
|
+
import { getUpdateNudge, maybeNudgeUpdate } from "../lib/version-check.mjs";
|
|
40
32
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
DEAL_ACTIONS,
|
|
34
|
+
buildDealReadPath,
|
|
35
|
+
buildDealSearchPath,
|
|
36
|
+
prepareDealCommand,
|
|
37
|
+
readJsonInput,
|
|
38
|
+
} from "../lib/deal-actions.mjs";
|
|
44
39
|
|
|
45
40
|
const requireFromHere = createRequire(import.meta.url);
|
|
46
41
|
const { version: PKG_VERSION } = requireFromHere("../package.json");
|
|
47
42
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
const HELP_ROOT = `Llama Command CLI 2 — small authenticated tools for agents.
|
|
44
|
+
|
|
45
|
+
Deal has exactly four actions:
|
|
46
|
+
llama deal search "<company or founder>" [--state active|archived|trashed] [--limit 10]
|
|
47
|
+
llama deal read <dealId> [--detail overview|memory|files|conversation|history|all]
|
|
48
|
+
llama deal create --json <file|->
|
|
49
|
+
llama deal write --json <file|->
|
|
50
|
+
|
|
51
|
+
Separate preserved domains:
|
|
52
|
+
llama auth status|login|logout
|
|
53
|
+
llama token set|show
|
|
54
|
+
llama agent bootstrap
|
|
55
|
+
llama skills list|search|show
|
|
56
|
+
llama pref list|add|approve|retire
|
|
57
|
+
llama explain <url-or-object>
|
|
58
|
+
llama wiki search|read|save|delete|restore
|
|
59
|
+
llama admin auth-events|deal-events|agent-events
|
|
60
|
+
llama pitch start|say|upload|status|finalize|end
|
|
61
|
+
|
|
62
|
+
Run \`llama help deal\` for the mutation contract.`;
|
|
63
|
+
|
|
64
|
+
const HELP_DEAL = `Llama Command CLI 2 — Deal contract
|
|
65
|
+
|
|
66
|
+
Exactly four actions:
|
|
67
|
+
llama deal search [query] [--state active|archived|trashed] [--limit 10]
|
|
68
|
+
llama deal read <dealId> [--detail overview|memory|files|conversation|history|all]
|
|
69
|
+
llama deal create --json <file|->
|
|
70
|
+
llama deal write --json <file|->
|
|
71
|
+
|
|
72
|
+
create JSON:
|
|
73
|
+
{"companyName":"Acme","page":{},"information":[],"origin":{"kind":"user","originalUserUtterance":"..."}}
|
|
74
|
+
|
|
75
|
+
write operation:
|
|
76
|
+
input.submit | information.put | page.patch | artifact.put
|
|
77
|
+
|
|
78
|
+
Core owns Chat Records, append-only Deal Events, Drive provisioning, audit,
|
|
79
|
+
and idempotency. User-originated work must preserve exact wording in
|
|
80
|
+
origin.originalUserUtterance or reference origin.originatingChatRecordId.`;
|
|
81
|
+
|
|
82
|
+
const RETIRED_DEAL_AREAS = new Set([
|
|
83
|
+
"activity",
|
|
84
|
+
"approvals",
|
|
85
|
+
"brief",
|
|
86
|
+
"claim",
|
|
87
|
+
"html",
|
|
88
|
+
"memo",
|
|
89
|
+
"mentions",
|
|
90
|
+
"nominate",
|
|
91
|
+
"nominations",
|
|
92
|
+
"post",
|
|
93
|
+
"timeline",
|
|
94
|
+
"workflow",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function parseFlags(args, allowed = null) {
|
|
62
98
|
const flags = {};
|
|
63
99
|
const positional = [];
|
|
64
|
-
for (let
|
|
65
|
-
const arg = args[
|
|
66
|
-
if (arg.startsWith("--")) {
|
|
67
|
-
const key = arg.slice(2);
|
|
68
|
-
const next = args[i + 1];
|
|
69
|
-
if (!next || next.startsWith("--")) {
|
|
70
|
-
flags[key] = true;
|
|
71
|
-
} else {
|
|
72
|
-
flags[key] = next;
|
|
73
|
-
i++;
|
|
74
|
-
}
|
|
75
|
-
} else {
|
|
100
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
101
|
+
const arg = args[index];
|
|
102
|
+
if (!arg.startsWith("--")) {
|
|
76
103
|
positional.push(arg);
|
|
104
|
+
continue;
|
|
77
105
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
for (const key of Object.keys(flags)) {
|
|
86
|
-
if (!known.has(key)) {
|
|
87
|
-
const suggestion = closestKnownFlag(key, knownFlags);
|
|
88
|
-
process.stderr.write(
|
|
89
|
-
suggestion
|
|
90
|
-
? `warning: unknown flag --${key} (did you mean --${suggestion}?)\n`
|
|
91
|
-
: `warning: unknown flag --${key}\n`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
106
|
+
const key = arg.slice(2);
|
|
107
|
+
const next = args[index + 1];
|
|
108
|
+
if (!next || next.startsWith("--")) {
|
|
109
|
+
flags[key] = true;
|
|
110
|
+
} else {
|
|
111
|
+
flags[key] = next;
|
|
112
|
+
index += 1;
|
|
94
113
|
}
|
|
95
114
|
}
|
|
115
|
+
if (allowed) {
|
|
116
|
+
const unknown = Object.keys(flags).filter((key) => !allowed.includes(key));
|
|
117
|
+
if (unknown.length) throw new Error(`Unknown flag(s): ${unknown.map((key) => `--${key}`).join(", ")}`);
|
|
118
|
+
}
|
|
96
119
|
return { flags, positional };
|
|
97
120
|
}
|
|
98
121
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Team member?
|
|
103
|
-
- Run \`gcloud auth login\` with your @llamaventures.vc account, OR
|
|
104
|
-
- Mint a token at https://command.llamaventures.vc/settings/tokens
|
|
105
|
-
then \`llama token set <llc_...>\`.
|
|
106
|
-
Re-run \`llama agent-onboard\` after — the workflow contract will print.
|
|
107
|
-
|
|
108
|
-
Founder or external visitor (no Llama account)?
|
|
109
|
-
Run \`llama pitch start --name "Your Name" --email "you@company.com"\`
|
|
110
|
-
to chat with our intake agent — no token required.`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function agentOnboardRejectedMessage() {
|
|
114
|
-
return `Llama Ventures team onboarding requires valid credentials.
|
|
115
|
-
|
|
116
|
-
Server rejected the credentials we sent. Re-mint at
|
|
117
|
-
https://command.llamaventures.vc/settings/tokens, run
|
|
118
|
-
\`llama token set <llc_...>\`, then re-run \`llama agent-onboard\`.`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function fetchServerAgentBriefing() {
|
|
122
|
-
const params = new URLSearchParams({ clientVersion: PKG_VERSION });
|
|
123
|
-
const result = await request("GET", `/api/agent/briefing?${params}`);
|
|
124
|
-
return result?.briefing || "";
|
|
122
|
+
function usage(area) {
|
|
123
|
+
console.log(area === "deal" ? HELP_DEAL : HELP_ROOT);
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (d < bestScore && d <= tolerance) {
|
|
134
|
-
best = c;
|
|
135
|
-
bestScore = d;
|
|
136
|
-
}
|
|
126
|
+
function assertActiveSurface(area, action) {
|
|
127
|
+
if (area === "deal" && !DEAL_ACTIONS.includes(action)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`DEAL_COMMAND_RETIRED: \`llama deal ${action || "<missing>"}\` is not part of CLI 2. ` +
|
|
130
|
+
"Use only search, read, create --json, or write --json.",
|
|
131
|
+
);
|
|
137
132
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const m = a.length;
|
|
144
|
-
const n = b.length;
|
|
145
|
-
if (m === 0) return n;
|
|
146
|
-
if (n === 0) return m;
|
|
147
|
-
const dp = Array(n + 1).fill(0).map((_, i) => i);
|
|
148
|
-
for (let i = 1; i <= m; i++) {
|
|
149
|
-
let prev = dp[0];
|
|
150
|
-
dp[0] = i;
|
|
151
|
-
for (let j = 1; j <= n; j++) {
|
|
152
|
-
const tmp = dp[j];
|
|
153
|
-
dp[j] = a[i - 1] === b[j - 1]
|
|
154
|
-
? prev
|
|
155
|
-
: 1 + Math.min(prev, dp[j - 1], dp[j]);
|
|
156
|
-
prev = tmp;
|
|
157
|
-
}
|
|
133
|
+
if (RETIRED_DEAL_AREAS.has(area)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`DEAL_COMMAND_RETIRED: \`llama ${area}\` belonged to the split legacy Deal model. ` +
|
|
136
|
+
"Use `llama deal read` or `llama deal write --json <file|->`.",
|
|
137
|
+
);
|
|
158
138
|
}
|
|
159
|
-
return dp[n];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Slug shape used by deal_documents.slug (matches server-side SLUG_RE).
|
|
163
|
-
function isValidDocSlug(s) {
|
|
164
|
-
return typeof s === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(s);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Best-effort title → slug. Strips diacritics, lowercases, collapses
|
|
168
|
-
// non-alnum to single hyphens, trims, caps at 64. Returns null if the
|
|
169
|
-
// result wouldn't pass `isValidDocSlug` (caller must then require --doc).
|
|
170
|
-
function slugifyTitle(title) {
|
|
171
|
-
if (typeof title !== "string") return null;
|
|
172
|
-
const slug = title
|
|
173
|
-
.toLowerCase()
|
|
174
|
-
.normalize("NFKD")
|
|
175
|
-
.replace(/[̀-ͯ]/g, "")
|
|
176
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
177
|
-
.replace(/^-+|-+$/g, "")
|
|
178
|
-
.slice(0, 64);
|
|
179
|
-
if (!slug || !/^[a-z0-9]/.test(slug)) return null;
|
|
180
|
-
return slug;
|
|
181
139
|
}
|
|
182
140
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// returns the `{deals,total,limit,offset}` envelope, this path is never
|
|
186
|
-
// taken.
|
|
187
|
-
function clientSideMatch(deal, filters) {
|
|
188
|
-
const incl = (haystack, needle) =>
|
|
189
|
-
!!haystack && String(haystack).toLowerCase().includes(needle.toLowerCase());
|
|
190
|
-
const eq = (haystack, needle) =>
|
|
191
|
-
String(haystack ?? "").toLowerCase() === String(needle).toLowerCase();
|
|
192
|
-
|
|
193
|
-
if (filters.q) {
|
|
194
|
-
const fields = [
|
|
195
|
-
deal.companyName, deal.founders, deal.founderInfo,
|
|
196
|
-
deal.description, deal.notes, deal.dealOwner,
|
|
197
|
-
deal.source, deal.sourceDirection, deal.location,
|
|
198
|
-
];
|
|
199
|
-
if (!fields.some((f) => incl(f, filters.q))) return false;
|
|
200
|
-
}
|
|
201
|
-
if (filters.companyName && !incl(deal.companyName, filters.companyName)) return false;
|
|
202
|
-
if (filters.founder && !(incl(deal.founders, filters.founder) || incl(deal.founderInfo, filters.founder))) return false;
|
|
203
|
-
if (filters.owner && !incl(deal.dealOwner, filters.owner)) return false;
|
|
204
|
-
if (filters.status && !eq(deal.status, filters.status)) return false;
|
|
205
|
-
if (filters.theirStage && !eq(deal.theirStage, filters.theirStage)) return false;
|
|
206
|
-
if (filters.stage && !eq(deal.stage, filters.stage)) return false;
|
|
207
|
-
if (filters.sourceDirection && !eq(deal.sourceDirection, filters.sourceDirection)) return false;
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
141
|
+
function onboardingNoAuth() {
|
|
142
|
+
return `Llama team onboarding requires credentials.
|
|
210
143
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const params = new URLSearchParams();
|
|
214
|
-
if (q) params.set("q", q);
|
|
215
|
-
for (const key of ["companyName", "founder", "owner", "status", "theirStage", "stage", "sourceDirection", "limit", "offset"]) {
|
|
216
|
-
if (flags[key] !== undefined && flags[key] !== true) {
|
|
217
|
-
params.set(key, String(flags[key]));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (flags["source-direction"] !== undefined && flags["source-direction"] !== true) {
|
|
221
|
-
params.set("sourceDirection", String(flags["source-direction"]));
|
|
222
|
-
}
|
|
223
|
-
return params;
|
|
144
|
+
Run \`llama auth login\`, or mint a token in Llama Command and run
|
|
145
|
+
\`llama token set <llc_...>\`. External founders use \`llama pitch\`.`;
|
|
224
146
|
}
|
|
225
147
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
companyName: flags.companyName,
|
|
240
|
-
founder: flags.founder,
|
|
241
|
-
owner: flags.owner,
|
|
242
|
-
status: flags.status,
|
|
243
|
-
theirStage: flags.theirStage,
|
|
244
|
-
stage: flags.stage,
|
|
245
|
-
sourceDirection: flags.sourceDirection || flags["source-direction"],
|
|
148
|
+
async function readTokenQuietly() {
|
|
149
|
+
if (!process.stdin.isTTY) {
|
|
150
|
+
let value = "";
|
|
151
|
+
for await (const chunk of process.stdin) value += chunk;
|
|
152
|
+
return value.trim().split(/\s+/)[0] || "";
|
|
153
|
+
}
|
|
154
|
+
process.stderr.write("Paste token (input hidden): ");
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
let value = "";
|
|
157
|
+
const cleanup = () => {
|
|
158
|
+
process.stdin.setRawMode(false);
|
|
159
|
+
process.stdin.pause();
|
|
160
|
+
process.stdin.off("data", onData);
|
|
246
161
|
};
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
offset,
|
|
255
|
-
_source: "client-filter",
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
return result;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function buildActivityQuery(kind, flags) {
|
|
262
|
-
const params = new URLSearchParams({ kind });
|
|
263
|
-
const mappings = [
|
|
264
|
-
["since", "since"],
|
|
265
|
-
["limit", "limit"],
|
|
266
|
-
["cursor", "cursor"],
|
|
267
|
-
["before-id", "before_id"],
|
|
268
|
-
["before_id", "before_id"],
|
|
269
|
-
["deal", "deal"],
|
|
270
|
-
["deal-id", "deal_id"],
|
|
271
|
-
["deal_id", "deal_id"],
|
|
272
|
-
["entity", "entity"],
|
|
273
|
-
["min-sig", "min_sig"],
|
|
274
|
-
["min_sig", "min_sig"],
|
|
275
|
-
["min-significance", "min_sig"],
|
|
276
|
-
];
|
|
277
|
-
for (const [flag, param] of mappings) {
|
|
278
|
-
if (flags[flag] !== undefined && flags[flag] !== true) {
|
|
279
|
-
params.set(param, String(flags[flag]));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
for (const verb of splitCsvFlag(flags.verb) ?? []) {
|
|
283
|
-
params.append("verb", verb);
|
|
284
|
-
}
|
|
285
|
-
return params;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function fetchActivity(kind, flags) {
|
|
289
|
-
const params = buildActivityQuery(kind, flags);
|
|
290
|
-
return request("GET", `/api/agent/activity?${params}`);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function splitCsvFlag(value) {
|
|
294
|
-
if (!value || value === true) return undefined;
|
|
295
|
-
return String(value)
|
|
296
|
-
.split(",")
|
|
297
|
-
.map((s) => s.trim())
|
|
298
|
-
.filter(Boolean);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function boolFlag(flags, ...names) {
|
|
302
|
-
return names.some((name) => flags[name] === true || flags[name] === "true" || flags[name] === "1");
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function buildEnrichmentAgentMessage(flags) {
|
|
306
|
-
if (flags.message && flags.message !== true) return String(flags.message);
|
|
307
|
-
const sources = splitCsvFlag(flags.sources) ?? [
|
|
308
|
-
"website",
|
|
309
|
-
"github",
|
|
310
|
-
"linkedin",
|
|
311
|
-
"yc",
|
|
312
|
-
"launch",
|
|
313
|
-
"web",
|
|
314
|
-
"monid",
|
|
315
|
-
];
|
|
316
|
-
const budget = flags["budget-cents"] || flags.budgetCents || "50";
|
|
317
|
-
const memo = boolFlag(flags, "memo", "generate-memo", "generateMemo")
|
|
318
|
-
? "Generate memo only after enrichment because the caller explicitly requested it."
|
|
319
|
-
: "Do not generate memo.";
|
|
320
|
-
return [
|
|
321
|
-
"Run server-side deal enrichment for this deal.",
|
|
322
|
-
`Use sources: ${sources.join(", ")}.`,
|
|
323
|
-
`Private Monid budget cap: ${budget} cents.`,
|
|
324
|
-
"Read the enrichment harness first, then collect current company/founder evidence.",
|
|
325
|
-
"Write canonical evidence links, sourced deal facts, stable deal fields, and typed factual values where supported.",
|
|
326
|
-
"For typed factual values, call read_typed_factual_layer first and use upsert_typed_fact for queryable fields.",
|
|
327
|
-
"Search snippets alone are not high-confidence evidence; fetch direct sources where possible.",
|
|
328
|
-
memo,
|
|
329
|
-
"End with what was written, what was skipped, and open questions.",
|
|
330
|
-
].join(" ");
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async function runDealAgentViaThread(dealId, message, title = "CLI agent run") {
|
|
334
|
-
const thread = await request("POST", `/api/deals/${encodeURIComponent(dealId)}/threads`, { title });
|
|
335
|
-
if (!thread?.id) throw new Error("Thread creation did not return an id");
|
|
336
|
-
process.stderr.write(`Running Deal Agent in thread ${thread.id}\n`);
|
|
337
|
-
const result = await requestSse(
|
|
338
|
-
"POST",
|
|
339
|
-
`/api/deals/${encodeURIComponent(dealId)}/threads/${encodeURIComponent(thread.id)}`,
|
|
340
|
-
{ message },
|
|
341
|
-
{
|
|
342
|
-
onEvent(event) {
|
|
343
|
-
if (event.tool_use?.name) {
|
|
344
|
-
process.stderr.write(`[tool] ${event.tool_use.name}\n`);
|
|
345
|
-
}
|
|
346
|
-
if (event.tool_result?.name) {
|
|
347
|
-
const status = event.tool_result.ok ? "ok" : "error";
|
|
348
|
-
process.stderr.write(
|
|
349
|
-
`[tool] ${event.tool_result.name}: ${status} — ${event.tool_result.summary ?? ""}\n`,
|
|
350
|
-
);
|
|
162
|
+
const onData = (chunk) => {
|
|
163
|
+
for (const char of chunk) {
|
|
164
|
+
if (["\r", "\n", "\u0004"].includes(char)) {
|
|
165
|
+
cleanup();
|
|
166
|
+
process.stderr.write("\n");
|
|
167
|
+
resolve(value.trim());
|
|
168
|
+
return;
|
|
351
169
|
}
|
|
352
|
-
if (
|
|
353
|
-
|
|
170
|
+
if (char === "\u0003") {
|
|
171
|
+
cleanup();
|
|
172
|
+
process.stderr.write("\n");
|
|
173
|
+
reject(new Error("Aborted"));
|
|
174
|
+
return;
|
|
354
175
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
Agent onboarding (run once on first install):
|
|
365
|
-
llama agent-onboard # print AGENT_BRIEFING.md — the workflow contract for AI agents
|
|
366
|
-
llama agent bootstrap # fetch live Command + Llama OS skill manifest
|
|
367
|
-
llama skills search "pipeline update" # discover relevant runtime skills
|
|
368
|
-
llama skills show llama-pipeline # read a skill from Command
|
|
369
|
-
llama activity new-deals --since 24h # recent deal creations for agents
|
|
370
|
-
llama activity updated-deals --since 7d # meaningful deal updates, grouped
|
|
371
|
-
llama explain <url-or-object> # explain Command URL/object status + lifecycle
|
|
372
|
-
llama pref list [--status proposed] # standing agent preferences (injected every turn)
|
|
373
|
-
llama pref add reply-style "Lead with the conclusion." [--team]
|
|
374
|
-
|
|
375
|
-
External pitch — talk to Llama Ventures' intake agent (no token required):
|
|
376
|
-
llama pitch start --name "Jane Doe" --email "jane@acme.ai"
|
|
377
|
-
llama pitch say "We're building X..." # single message, prints reply
|
|
378
|
-
llama pitch upload ./deck.pdf # attach a file
|
|
379
|
-
llama pitch # interactive REPL (existing session)
|
|
380
|
-
llama pitch status # session info
|
|
381
|
-
llama pitch end # clear local session
|
|
382
|
-
|
|
383
|
-
Setup:
|
|
384
|
-
llama auth status # show current credentials + verify with server
|
|
385
|
-
llama token set <llc_token> [--base https://command.llamaventures.vc]
|
|
386
|
-
llama token show
|
|
387
|
-
|
|
388
|
-
Zero-config: if you've already run \`gcloud auth login\` with your
|
|
389
|
-
@llamaventures.vc account, you don't need to set anything — the CLI
|
|
390
|
-
auto-detects \`gcloud auth print-identity-token\` and uses Bearer auth.
|
|
391
|
-
Manually-set \`llc_\` tokens are used as a fallback.
|
|
392
|
-
|
|
393
|
-
Deals:
|
|
394
|
-
llama deal create "Company" --source <name> --deal-owner <name|email|userId> --source-direction Inbound|Outbound --description "..." --status Interested|Outreached|Sourced --website https://...
|
|
395
|
-
llama deal founders set <dealId> --json '[{"name":"Ada","email":"ada@example.com","linkedin_url":"https://linkedin.com/in/ada"}]'
|
|
396
|
-
llama deal show <dealId>
|
|
397
|
-
llama deal feed <dealId> # every contribution (facts + notes), human-typed or assistant-drafted, newest first
|
|
398
|
-
llama deal update <dealId> <field> <value>
|
|
399
|
-
Writable fields: theirStage, stage, notes, source, sourceDirection,
|
|
400
|
-
description, website, location, founders, founderInfo, proposedAmount,
|
|
401
|
-
roundSize, valuation, deckLink, folderUrl, sector, subsector,
|
|
402
|
-
foundedYear, leadInvestor, investors, agentActive.
|
|
403
|
-
'notes' is the ONE-LINE judgment shown as the Summary headline at the top of
|
|
404
|
-
the deal page (~280 chars). Meeting notes and narrative go in a comment
|
|
405
|
-
(llama post); verifiable claims go in facts (llama deal fact add).
|
|
406
|
-
e.g. llama deal update <dealId> website https://acme.ai
|
|
407
|
-
llama deal update <dealId> sector "Developer Tools"
|
|
408
|
-
llama deal update <dealId> foundedYear 2024
|
|
409
|
-
llama deal update <dealId> leadInvestor "Acme Capital"
|
|
410
|
-
llama deal enrich <dealId> [--dry-run] [--apply] [--executor server_agent|external_agent|planner]
|
|
411
|
-
[--sources website,github,linkedin,yc,monid] [--budget-cents 50]
|
|
412
|
-
[--memo] [--prompt] [--harness-only]
|
|
413
|
-
dry-run returns the harness; --apply --executor server_agent runs the server Deal Agent.
|
|
414
|
-
llama deal agent run <dealId> --message "collect founder evidence and update typed facts"
|
|
415
|
-
llama deal extra set <dealId> <key> <value> # system-admin only
|
|
416
|
-
Patch one top-level key in deals.extra JSONB. Value is parsed as
|
|
417
|
-
JSON when possible ('{"a":1}', 'true', '3'), else stored as a
|
|
418
|
-
string. Audited to deal_events as field_change "extra.<key>".
|
|
419
|
-
llama deal extra unset <dealId> <key> # delete the key (admin)
|
|
420
|
-
llama deal search <query> [--founder name] [--owner <user-key>] [--status Interested]
|
|
421
|
-
[--theirStage Raising] [--stage Seed] [--source-direction Inbound]
|
|
422
|
-
[--limit 200] [--offset 0]
|
|
423
|
-
llama deal list [--owner ...] [--status ...] [...same flags as search]
|
|
424
|
-
|
|
425
|
-
Investment Workflow V2 (the only stage-write surface):
|
|
426
|
-
llama workflow show <dealId>
|
|
427
|
-
llama workflow initialize <dealId> --reason "..." # audited legacy migration/bootstrap
|
|
428
|
-
llama workflow request-support <dealId> --partner <userId> --reason "..."
|
|
429
|
-
llama workflow decide-support <dealId> support|need_more|pass --reason "..."
|
|
430
|
-
llama workflow proceed <dealId> --transition <key> --reason "..."
|
|
431
|
-
llama workflow waive <dealId> --guard <key> --reason "..."
|
|
432
|
-
llama workflow control <dealId> hold|resume|pass|restore|return --reason "..." [--disposition stalled|future]
|
|
433
|
-
llama workflow organize-ic <dealId> --note "..."
|
|
434
|
-
llama workflow vote <dealId> yes|no --reason "..."
|
|
435
|
-
llama workflow reassign-owner <dealId> --owner <userId> --reason "..."
|
|
436
|
-
llama workflow execution-status <dealId> term-sheet|verbal-commit|invested --reason "..."
|
|
437
|
-
|
|
438
|
-
Agent activity (read-only, cheap read model over append-only activity):
|
|
439
|
-
llama activity new-deals [--since 24h|7d|<ISO>] [--limit 50]
|
|
440
|
-
llama activity updated-deals [--since 24h|7d|<ISO>] [--limit 50] [--deal <uuid>]
|
|
441
|
-
llama activity events [--since 24h] [--verb fact.added,brief.revised] [--entity deal|wiki|all]
|
|
442
|
-
Use this before scanning raw timelines or event-bus payloads. It returns
|
|
443
|
-
JSON from Command's curated activity_events projection: source ids included.
|
|
444
|
-
|
|
445
|
-
Collaborators (besides owner — attribution candidates, no approval):
|
|
446
|
-
llama deal collab list <dealId>
|
|
447
|
-
llama deal collab add <dealId> --user <userId|email>
|
|
448
|
-
llama deal collab remove <dealId> --user <userId|email> # soft-delete
|
|
449
|
-
llama deal collab restore <dealId> --user <userId|email>
|
|
450
|
-
|
|
451
|
-
Soft-delete:
|
|
452
|
-
All UI/CLI deletes are soft. Real delete = direct DB only. Each
|
|
453
|
-
removal/restore writes a deal_events row so the timeline records who
|
|
454
|
-
did what when. Trash views via ?include_deleted=1 on read endpoints.
|
|
455
|
-
|
|
456
|
-
Brief blocks (text/link/embed/callout):
|
|
457
|
-
llama brief blocks <dealId> # list (excludes trashed)
|
|
458
|
-
llama brief block <dealId> <blockId> # fetch single block (with body)
|
|
459
|
-
llama brief delete <dealId> <blockId> # soft-delete
|
|
460
|
-
llama brief restore <dealId> <blockId>
|
|
461
|
-
|
|
462
|
-
Deal links (separate from brief link blocks — these live in deal_links):
|
|
463
|
-
llama deal link list <dealId> [--include-deleted]
|
|
464
|
-
llama deal link add <dealId> --url <url> [--label "..."]
|
|
465
|
-
llama deal link delete <dealId> <linkId> # soft-delete
|
|
466
|
-
llama deal link restore <dealId> <linkId>
|
|
467
|
-
|
|
468
|
-
Ownership:
|
|
469
|
-
llama claim <dealId> --reason "..." # set yourself as Owner
|
|
470
|
-
llama nominate <dealId> --user <userId> --reason "..." # set another teammate as Owner
|
|
471
|
-
Owner is an audited responsibility label, not a permission boundary. Any
|
|
472
|
-
internal teammate can change it after Partner Support; reason is mandatory.
|
|
473
|
-
|
|
474
|
-
Approvals (Partner queue — contribution decisions):
|
|
475
|
-
llama approvals list
|
|
476
|
-
llama approvals decide <approvalId> approved|rejected [--note "..."]
|
|
477
|
-
|
|
478
|
-
Timeline / Posts:
|
|
479
|
-
llama timeline <dealId> # full unified feed
|
|
480
|
-
llama post <dealId> "message body" [--link url] [--link-name "name"] [--cue]
|
|
481
|
-
# --cue only after explicit user approval
|
|
482
|
-
|
|
483
|
-
Brief blocks:
|
|
484
|
-
llama brief blocks <dealId> # list current block array
|
|
485
|
-
llama brief block <dealId> <blockId> # fetch one block's body (manifest in 'llama deal show')
|
|
486
|
-
llama brief add-text <dealId> --heading "..." --body "..." [--cue]
|
|
487
|
-
llama brief add-link <dealId> --url "..." --label "..." [--description "..."] [--cue]
|
|
488
|
-
llama brief add-embed <dealId> --url "..." [--label "..."] [--cue]
|
|
489
|
-
llama brief add-callout <dealId> --tone insight|info|warning|success --heading "..." --body "..." [--cue]
|
|
490
|
-
llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--tone ...] [--cue]
|
|
491
|
-
[--source-section <key>] [--lock|--unlock] [--hide|--unhide]
|
|
492
|
-
llama brief delete <dealId> <blockId>
|
|
493
|
-
llama brief history <dealId> <blockId> [--limit 50] # prior versions of this block (newest first)
|
|
494
|
-
llama brief restore-version <dealId> <blockId> <historyId> # restore from a history entry; the outgoing
|
|
495
|
-
# version is itself snapshotted (reversible)
|
|
496
|
-
|
|
497
|
-
Common flags on every add-*:
|
|
498
|
-
--source-section <key> Target a structured section (team, highlights, recommendation,
|
|
499
|
-
landscape_map, competitors). Without this, blocks land in "_other"
|
|
500
|
-
at the bottom of the TOC. AI writers want this.
|
|
501
|
-
--reply-to <blockId> Make the block a reply to <blockId>. Snapshots parent's heading
|
|
502
|
-
+ 200-char excerpt into meta so the back-link survives parent
|
|
503
|
-
edits/deletes. Renders as an amber strip with a jump-link.
|
|
504
|
-
--position top|bottom Where to insert. Default: top (matches UI behavior since
|
|
505
|
-
2026-05-03). Use bottom for batched writes that need to
|
|
506
|
-
preserve insertion order.
|
|
507
|
-
|
|
508
|
-
Brief refresh + agent-run revert:
|
|
509
|
-
llama deal refresh-brief <dealId> [--force] # re-eval stale sections
|
|
510
|
-
# --force = every unlocked watcher-managed section
|
|
511
|
-
llama deal revert-run <dealId> <runId> --section <key> # legacy 4-section model only
|
|
512
|
-
# section: company|team|highlights|recommendation
|
|
513
|
-
|
|
514
|
-
Deal soft-delete / restore / trash list:
|
|
515
|
-
llama deal delete <dealId> # soft (audit-logged via deal_events)
|
|
516
|
-
llama deal restore <dealId> # ⚠ session-only on server today (token → 401)
|
|
517
|
-
llama deal trash # list deleted deals
|
|
518
|
-
|
|
519
|
-
Deal facts (AI-extracted or human-asserted, with verification):
|
|
520
|
-
llama deal ingest <dealId> --file <packet.json> [--idempotency-key <key>] # atomic facts + optional Feed note
|
|
521
|
-
packet: {"source":{"kind":"meeting_note","title":"Office visit"},"facts":[{"category":"team","claim":"..."}],"note":"..."}
|
|
522
|
-
categories: company_basics | team | product | market | financials | fundraise | risk | milestone | meta
|
|
523
|
-
llama deal fact list <dealId>
|
|
524
|
-
llama deal fact add <dealId> --category <cat> --claim "<text>" [--source "..."] [--source-url <url>] [--confidence high|medium|low] [--attested]
|
|
525
|
-
llama deal fact verify <dealId> <factId> --status confirmed|disputed [--corrected-value "..."]
|
|
526
|
-
llama deal fact uncontest <dealId> <factId> --reason "<why the contest was wrong>"
|
|
527
|
-
contest and trust are separate axes: 'verify --status confirmed' does NOT lift a contest,
|
|
528
|
-
and a contested fact stays excluded from what the Deal Agent treats as current.
|
|
529
|
-
|
|
530
|
-
Mentions / Inbox:
|
|
531
|
-
llama mentions # default: my unresolved cues
|
|
532
|
-
llama mentions list [--everyone] [--all] # --everyone = team-wide; --all = include resolved
|
|
533
|
-
llama mentions show <mentionId> # full row
|
|
534
|
-
llama mentions resolve <mentionId> # mark thread resolved (idempotent)
|
|
535
|
-
llama mentions unread # just the badge count
|
|
536
|
-
|
|
537
|
-
Where does this HTML / thesis / artifact go?
|
|
538
|
-
About ONE specific deal? ........ llama html publish <deal-id-or-name> --file <path> --title "..."
|
|
539
|
-
(renders at /deals/<id>/browse/<slug>; see "Deal page HTML" below)
|
|
540
|
-
Cross-deal / institutional? ..... llama wiki save <slug> --title "..." --file <path>.html --sources "..."
|
|
541
|
-
(renders at /wiki/<slug>; see "Wiki" below)
|
|
542
|
-
Founder-facing public share? .... Netlify (with netlify-access-guard skill), only when user explicitly
|
|
543
|
-
says "share publicly". Llama Command outranks Netlify for everything
|
|
544
|
-
internal — don't reach for Netlify by default.
|
|
545
|
-
|
|
546
|
-
Wiki:
|
|
547
|
-
llama wiki search <query>
|
|
548
|
-
llama wiki read <slug>
|
|
549
|
-
Markdown entry (default):
|
|
550
|
-
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"]
|
|
551
|
-
HTML entry — standalone HTML page at /wiki/<slug> (full-viewport sandboxed iframe):
|
|
552
|
-
llama wiki save <slug> --title "..." --file path.html --sources "..." [--content-type html]
|
|
553
|
-
(.html / .htm extension auto-implies content_type=html)
|
|
554
|
-
Native comments + working in-page (#) links are added automatically — just upload self-contained HTML.
|
|
555
|
-
➜ Use Wiki when the artifact is NOT tied to one specific deal — sector landscape, market map,
|
|
556
|
-
thesis, framework, methodology. For deal-specific HTML use "llama html publish <deal>" instead.
|
|
557
|
-
Delete / restore (soft — reversible):
|
|
558
|
-
llama wiki delete <slug> [--lang en|zh]
|
|
559
|
-
llama wiki restore <slug> [--lang en|zh]
|
|
560
|
-
|
|
561
|
-
Memo (long-form HTML investment memo — Memo tab in the UI):
|
|
562
|
-
llama memo show <dealId> [--out <path>] [--json] # default: html → stdout (pipeable to file / browser)
|
|
563
|
-
llama memo regenerate <dealId> [--opus] [--instructions "..."] # --instructions steers THIS run (e.g. "focus on team risk"); progress → stderr
|
|
564
|
-
llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
|
|
565
|
-
llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
|
|
566
|
-
|
|
567
|
-
Deal page HTML (hand-authored sandboxed pages on /deals/<id>/browse/<slug>):
|
|
568
|
-
➜ Use this for DEAL-SPECIFIC artifacts: IC memo for X, dashboard for X, 2×2 for X.
|
|
569
|
-
For cross-deal / institutional pages (sector landscape, market map, thesis) use
|
|
570
|
-
"llama wiki save <slug> --file ..." instead — see "Wiki" above.
|
|
571
|
-
Each deal can host many HTML artifacts (IC report, dashboard, market map, …).
|
|
572
|
-
Each one has a stable slug. UPLOAD must declare intent — update an existing
|
|
573
|
-
artifact or add a new one — to avoid silent overwrites.
|
|
574
|
-
|
|
575
|
-
Agent-safe publish path (recommended for Claude Code / Codex / Cursor):
|
|
576
|
-
llama html publish <deal-id-or-name> --file <path> [--title "..."] [--doc <slug>]
|
|
577
|
-
# Defaults to NEW doc unless --doc points at an existing slug; verifies version/bytes/sha256 after upload.
|
|
578
|
-
# Auto-detects sibling *_files asset folders unless --no-auto-assets is set.
|
|
579
|
-
|
|
580
|
-
List existing artifacts:
|
|
581
|
-
llama html docs <dealId> # who-has-what
|
|
582
|
-
llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot
|
|
583
|
-
llama html docs archive <dealId> <slug> # soft-archive (browse hides)
|
|
584
|
-
|
|
585
|
-
Link a card to a wiki article (one file, multiple entrances — the wiki
|
|
586
|
-
stays canonical, the deal card is a live, read-only pointer):
|
|
587
|
-
llama html link <dealId> --wiki <slug> [--lang en|zh] [--title "..."]
|
|
588
|
-
llama html unlink <dealId> <slug> # revert to a normal self-hosted doc
|
|
589
|
-
|
|
590
|
-
Update an EXISTING artifact (slug must exist):
|
|
591
|
-
llama html upload <dealId> --doc <slug> --file <path> [--assets DIR]
|
|
592
|
-
|
|
593
|
-
Add a NEW artifact (slug must NOT already exist):
|
|
594
|
-
llama html upload <dealId> --new --title "..." --file <path> [--doc <slug>] [--assets DIR]
|
|
595
|
-
(omit --doc → CLI slugifies the title; appends -2 / -3 on collision)
|
|
596
|
-
|
|
597
|
-
Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'
|
|
598
|
-
already has content — pass --doc main or --new --title "..." explicitly.
|
|
599
|
-
|
|
600
|
-
llama html show <dealId> [--doc <slug>] [--out <path>] [--json] # default: current html → stdout
|
|
601
|
-
llama html versions <dealId> [--doc <slug>] # list version history
|
|
602
|
-
llama html restore <dealId> <version> [--doc <slug>] # promote an old version to new latest
|
|
603
|
-
llama html reset <dealId> [--doc <slug>] # soft-delete latest; /browse reverts to empty
|
|
604
|
-
|
|
605
|
-
Caps: HTML 5 MB, each asset 50 MB, total bundle 100 MB. Every write
|
|
606
|
-
triggers SSE push — any browser viewing /deals/<id>/browse refreshes
|
|
607
|
-
automatically. Same write path as the in-app deal agent's
|
|
608
|
-
update_deal_browse_html tool and the MCP html_upload_file tool.
|
|
609
|
-
|
|
610
|
-
Admin (system admin only — server returns 403 for non-admin tokens):
|
|
611
|
-
llama admin workflow audit --deal <uuid> | --all
|
|
612
|
-
llama admin workflow remediate --deal <uuid> --guard intake.reason_why[,intake.founder_identity]
|
|
613
|
-
[--apply --expected-revision <n> --reason "..."]
|
|
614
|
-
llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
|
|
615
|
-
llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
|
|
616
|
-
llama admin agent-events [--kind tool_call|loop_stalled|max_turns_reached] [--agent-kind deal|secretary|main|inbox]
|
|
617
|
-
[--actor email] [--tool name] [--deal <uuid>] [--errors-only] [--since 24h] [--limit 100]
|
|
618
|
-
|
|
619
|
-
Same data as the /admin web console tabs (Auth events / Deal Activity / Agent Activity)
|
|
620
|
-
but scriptable. Pipe through jq / grep for monitoring & forensics.
|
|
621
|
-
|
|
622
|
-
Token discovery (in order):
|
|
623
|
-
1. $LLAMA_TOKEN env var
|
|
624
|
-
2. ~/.llama/token (canonical, single line)
|
|
625
|
-
3. ~/.llama-command/config.json (legacy v0.1 — auto-migrated forward on first read)
|
|
626
|
-
|
|
627
|
-
Env:
|
|
628
|
-
LLAMA_TOKEN token override
|
|
629
|
-
LLAMA_API_URL API base URL override
|
|
630
|
-
`;
|
|
631
|
-
|
|
632
|
-
// ── Progressive help (Constitution §1) ──
|
|
633
|
-
// Default `llama` / `llama --help` prints a SHORT root: the command groups +
|
|
634
|
-
// a few starters. Drill into one group with `llama help <area>` (or
|
|
635
|
-
// `llama <area> --help`); `llama help all` prints the full reference above.
|
|
636
|
-
const HELP_ROOT = `Llama Command CLI — the \`llama\` command for the Llama Ventures workbench.
|
|
637
|
-
|
|
638
|
-
Common:
|
|
639
|
-
llama deal search "<name>" find a deal in the pipeline
|
|
640
|
-
llama deal show <dealId> full deal record
|
|
641
|
-
llama deal feed <dealId> every contribution (facts + notes), newest first
|
|
642
|
-
llama post <dealId> "..." add a note to a deal
|
|
643
|
-
llama activity new-deals --since 24h recent deal creations
|
|
644
|
-
llama activity updated-deals --since 7d meaningful deal updates
|
|
645
|
-
llama agent-onboard print the AI-agent workflow contract
|
|
646
|
-
llama agent bootstrap live Llama OS skill manifest from Command
|
|
647
|
-
llama skills search "<query>" discover which skill to read
|
|
648
|
-
llama explain <url-or-object> explain Command URLs, 404s, deleted objects
|
|
649
|
-
|
|
650
|
-
Command groups — run \`llama help <group>\` for that group's commands:
|
|
651
|
-
deal create · show · feed · update · enrich · search · collaborators · links · delete
|
|
652
|
-
activity new-deals · updated-deals · events for agent read models
|
|
653
|
-
brief brief blocks: list · add · edit · history · refresh
|
|
654
|
-
facts deal facts — the sourced, trust-rated layer
|
|
655
|
-
timeline timeline · posts · mentions
|
|
656
|
-
wiki cross-deal knowledge entries (markdown or HTML)
|
|
657
|
-
pref standing agent preferences: list · add · retire · approve
|
|
658
|
-
memo long-form HTML investment memo
|
|
659
|
-
html deal-specific HTML artifacts (/deals/<id>/browse/<slug>)
|
|
660
|
-
pitch external founder intake (no token needed)
|
|
661
|
-
ownership claim · nominate · approvals
|
|
662
|
-
admin audit events (system admin only)
|
|
663
|
-
agent bootstrap · skills · explain for AI agents
|
|
664
|
-
skills search · show runtime Llama OS skills
|
|
665
|
-
auth setup · tokens · auth status
|
|
666
|
-
|
|
667
|
-
llama help all the full command reference (everything at once)
|
|
668
|
-
|
|
669
|
-
Auth: if you've run \`gcloud auth login\` with your @llamaventures.vc account,
|
|
670
|
-
the CLI auto-detects it — no token needed (\`llc_\` tokens are a fallback).`;
|
|
671
|
-
|
|
672
|
-
// Area → which top-level sections of HELP_FULL belong to it.
|
|
673
|
-
const HELP_AREA_MATCH = {
|
|
674
|
-
deal: [
|
|
675
|
-
/^Deals/,
|
|
676
|
-
/^Collaborators/,
|
|
677
|
-
/^Soft-delete/,
|
|
678
|
-
/^Deal links/,
|
|
679
|
-
/^Deal soft-delete/,
|
|
680
|
-
// `llama deal refresh-brief` / `revert-run` live under this heading. Without
|
|
681
|
-
// it here the section matched no area and was reachable only via `help all`,
|
|
682
|
-
// which read as "the command does not exist".
|
|
683
|
-
/^Brief refresh/,
|
|
684
|
-
],
|
|
685
|
-
activity: [/^Agent activity/],
|
|
686
|
-
brief: [/^Brief blocks/, /^Brief refresh/],
|
|
687
|
-
facts: [/^Deal facts/],
|
|
688
|
-
timeline: [/^Timeline/, /^Mentions/],
|
|
689
|
-
wiki: [/^Wiki/, /^Where does this HTML/],
|
|
690
|
-
memo: [/^Memo/],
|
|
691
|
-
html: [/^Deal page HTML/],
|
|
692
|
-
pitch: [/^External pitch/],
|
|
693
|
-
ownership: [/^Ownership/, /^Approvals/],
|
|
694
|
-
admin: [/^Admin/],
|
|
695
|
-
agent: [/^Agent onboarding/],
|
|
696
|
-
skills: [/^Agent onboarding/],
|
|
697
|
-
auth: [/^Setup/, /^Zero-config/, /^Token discovery/, /^Env/],
|
|
698
|
-
};
|
|
699
|
-
|
|
700
|
-
// Slice HELP_FULL into sections: a top-level (non-indented) header line plus
|
|
701
|
-
// the indented/blank lines that follow it, until the next header.
|
|
702
|
-
function helpSections() {
|
|
703
|
-
const out = [];
|
|
704
|
-
let cur = null;
|
|
705
|
-
for (const line of HELP_FULL.split("\n")) {
|
|
706
|
-
if (/^[A-Za-z]/.test(line)) {
|
|
707
|
-
cur = { head: line, lines: [line] };
|
|
708
|
-
out.push(cur);
|
|
709
|
-
} else if (cur) {
|
|
710
|
-
cur.lines.push(line);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
return out;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
function usage(area) {
|
|
717
|
-
if (area === "all") {
|
|
718
|
-
console.log(HELP_FULL);
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
const matchers = area && HELP_AREA_MATCH[area];
|
|
722
|
-
if (matchers) {
|
|
723
|
-
const blocks = helpSections()
|
|
724
|
-
.filter((s) => matchers.some((re) => re.test(s.head)))
|
|
725
|
-
.map((s) => s.lines.join("\n").replace(/\s+$/, ""));
|
|
726
|
-
if (blocks.length) {
|
|
727
|
-
console.log(blocks.join("\n\n"));
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
console.log(HELP_ROOT);
|
|
176
|
+
if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
|
|
177
|
+
else value += char;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
process.stdin.setRawMode(true);
|
|
181
|
+
process.stdin.resume();
|
|
182
|
+
process.stdin.setEncoding("utf8");
|
|
183
|
+
process.stdin.on("data", onData);
|
|
184
|
+
});
|
|
732
185
|
}
|
|
733
186
|
|
|
734
|
-
// ============================================================
|
|
735
|
-
// `llama pitch` family — external founder-pitch intake
|
|
736
|
-
// ============================================================
|
|
737
|
-
//
|
|
738
|
-
// No Llama Command token required. Bootstraps a session against
|
|
739
|
-
// /api/external/* via PoW + cookie. Subcommands:
|
|
740
|
-
//
|
|
741
|
-
// llama pitch → REPL (requires existing session)
|
|
742
|
-
// llama pitch start --name X --email Y
|
|
743
|
-
// llama pitch say "<msg>"
|
|
744
|
-
// llama pitch upload <path>
|
|
745
|
-
// llama pitch status
|
|
746
|
-
// llama pitch end
|
|
747
|
-
|
|
748
187
|
async function handlePitch(action, rest) {
|
|
749
|
-
if (!action ||
|
|
750
|
-
console.log(`
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
llama pitch start --name "Your Name" --email "you@company.com"
|
|
754
|
-
|
|
755
|
-
Single message (non-interactive):
|
|
756
|
-
llama pitch say 'We have $8k MRR and 5 design partners'
|
|
757
|
-
|
|
758
|
-
⚠ Tip: wrap pitch text in SINGLE quotes ('...') if it contains
|
|
759
|
-
characters like $, \`, or !. Double quotes let the shell expand
|
|
760
|
-
variables — e.g. "$8k MRR" becomes "k MRR" because $8 is empty.
|
|
761
|
-
Interactive REPL (\`llama pitch\`) doesn't have this problem.
|
|
762
|
-
|
|
763
|
-
Upload a file (deck / pitch / one-pager):
|
|
188
|
+
if (!action || ["help", "--help", "-h"].includes(action)) {
|
|
189
|
+
console.log(`External founder pitch intake (no internal token required):
|
|
190
|
+
llama pitch start --name "Jane Doe" --email "jane@acme.ai"
|
|
191
|
+
llama pitch say 'We are building X'
|
|
764
192
|
llama pitch upload ./deck.pdf
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
llama pitch
|
|
768
|
-
|
|
769
|
-
Wrap up the pitch (asks the agent to call finalize_intake immediately):
|
|
770
|
-
llama pitch finalize # use when you're done — agent stops asking
|
|
771
|
-
|
|
772
|
-
Inspect / clean up:
|
|
773
|
-
llama pitch status # session id, idle minutes, finalized?
|
|
774
|
-
llama pitch end # clear local session state
|
|
775
|
-
|
|
776
|
-
Caps:
|
|
777
|
-
Server-enforced per-IP / per-email / per-session rate limits apply.
|
|
778
|
-
The CLI surfaces server messages if a limit is hit.
|
|
779
|
-
|
|
780
|
-
Environment:
|
|
781
|
-
LLAMA_API_URL override base URL (dev: http://localhost:3000)
|
|
782
|
-
`);
|
|
193
|
+
llama pitch finalize
|
|
194
|
+
llama pitch status
|
|
195
|
+
llama pitch end
|
|
196
|
+
llama pitch interactive session`);
|
|
783
197
|
return;
|
|
784
198
|
}
|
|
785
|
-
|
|
786
199
|
if (action === "start") {
|
|
787
|
-
const { flags } = parseFlags(rest);
|
|
788
|
-
if (
|
|
789
|
-
throw new Error(
|
|
790
|
-
"pitch start: --name and --email are required.\n" +
|
|
791
|
-
" Example: llama pitch start --name \"Jane Doe\" --email \"jane@acme.ai\""
|
|
792
|
-
);
|
|
200
|
+
const { flags } = parseFlags(rest, ["name", "email"]);
|
|
201
|
+
if (typeof flags.name !== "string" || typeof flags.email !== "string") {
|
|
202
|
+
throw new Error('Usage: llama pitch start --name "Jane Doe" --email "jane@acme.ai"');
|
|
793
203
|
}
|
|
794
204
|
const existing = readExternalSession();
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
throw new Error(
|
|
799
|
-
`An active pitch session already exists (started ${existing.started_at}, idle ${status.idle_minutes}min).\n` +
|
|
800
|
-
` Run \`llama pitch end\` to clear it, or \`llama pitch say "..."\` to continue.`
|
|
801
|
-
);
|
|
802
|
-
}
|
|
205
|
+
const status = existing ? getExternalSessionStatus() : null;
|
|
206
|
+
if (existing && !existing.finalized && status?.active) {
|
|
207
|
+
throw new Error("An active pitch session already exists. Continue it or run `llama pitch end`.");
|
|
803
208
|
}
|
|
804
|
-
|
|
805
|
-
const session = await startExternalSession({
|
|
806
|
-
name: String(flags.name),
|
|
807
|
-
email: String(flags.email),
|
|
808
|
-
});
|
|
809
|
-
print({
|
|
810
|
-
session_id: session.session_id,
|
|
811
|
-
name: session.name,
|
|
812
|
-
email: session.email,
|
|
813
|
-
started_at: session.started_at,
|
|
814
|
-
hint: 'Now run `llama pitch say "..."` to chat, or just `llama pitch` for interactive REPL.',
|
|
815
|
-
});
|
|
209
|
+
print(await startExternalSession({ name: flags.name, email: flags.email }));
|
|
816
210
|
return;
|
|
817
211
|
}
|
|
818
|
-
|
|
819
212
|
if (action === "say") {
|
|
820
213
|
const message = rest.join(" ").trim();
|
|
821
|
-
if (!message)
|
|
822
|
-
throw new Error('pitch say: message required. Example: llama pitch say "We\'re building X"');
|
|
823
|
-
}
|
|
214
|
+
if (!message) throw new Error("Usage: llama pitch say <message>");
|
|
824
215
|
const result = await sendExternalMessage(message);
|
|
825
|
-
process.stdout.write(result.text
|
|
826
|
-
if (result.finalized) {
|
|
827
|
-
process.stderr.write("\n--- Pitch session finalized by the agent ---\n");
|
|
828
|
-
if (result.finalize_payload) {
|
|
829
|
-
process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
|
|
830
|
-
}
|
|
831
|
-
}
|
|
216
|
+
process.stdout.write(`${result.text}\n`);
|
|
832
217
|
return;
|
|
833
218
|
}
|
|
834
|
-
|
|
835
219
|
if (action === "upload") {
|
|
836
|
-
const { flags, positional } = parseFlags(rest);
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
}
|
|
841
|
-
process.stderr.write(`Uploading ${filePath}...\n`);
|
|
842
|
-
const result = await uploadExternalFile(filePath);
|
|
843
|
-
if (flags.json) {
|
|
844
|
-
print(result);
|
|
845
|
-
} else {
|
|
846
|
-
// Friendly default — drop server-internal fields (drive_file_id /
|
|
847
|
-
// sha256 / file_id). Founders just want "did it work + what does
|
|
848
|
-
// the agent do next." Pass --json for the full payload.
|
|
849
|
-
const sizeKb = (result.size / 1024).toFixed(1);
|
|
850
|
-
console.log(`✓ Uploaded ${result.filename} (${sizeKb} KB).`);
|
|
851
|
-
console.log(` The intake agent can now reference this file in your pitch.`);
|
|
852
|
-
}
|
|
220
|
+
const { flags, positional } = parseFlags(rest, ["json"]);
|
|
221
|
+
if (!positional[0]) throw new Error("Usage: llama pitch upload <file>");
|
|
222
|
+
const result = await uploadExternalFile(positional[0]);
|
|
223
|
+
if (flags.json) print(result);
|
|
224
|
+
else console.log(`Uploaded ${result.filename} (${(result.size / 1024).toFixed(1)} KB).`);
|
|
853
225
|
return;
|
|
854
226
|
}
|
|
855
|
-
|
|
856
227
|
if (action === "status") {
|
|
857
228
|
print(getExternalSessionStatus());
|
|
858
229
|
return;
|
|
859
230
|
}
|
|
860
|
-
|
|
861
231
|
if (action === "end") {
|
|
862
|
-
const
|
|
232
|
+
const previous = readExternalSession();
|
|
863
233
|
clearExternalSession();
|
|
864
|
-
print({
|
|
865
|
-
ok: true,
|
|
866
|
-
cleared: !!had,
|
|
867
|
-
session_file: EXTERNAL_SESSION_FILE,
|
|
868
|
-
note: had
|
|
869
|
-
? "Local session state cleared. Server-side session may still be active until idle timeout."
|
|
870
|
-
: "No local session was active.",
|
|
871
|
-
});
|
|
234
|
+
print({ ok: true, cleared: Boolean(previous), session_file: EXTERNAL_SESSION_FILE });
|
|
872
235
|
return;
|
|
873
236
|
}
|
|
874
|
-
|
|
875
237
|
if (action === "finalize") {
|
|
876
|
-
// Founder-initiated finalize: send a sentinel token in the chat
|
|
877
|
-
// stream that the system prompt recognizes as "wrap up now." The
|
|
878
|
-
// intake agent calls finalize_intake on this turn with whatever
|
|
879
|
-
// fields are recorded — no extra questions, no confirmation prompt.
|
|
880
|
-
// Local session is left as-is; on next read its `finalized=true`
|
|
881
|
-
// reflects the server's status.
|
|
882
238
|
const session = readExternalSession();
|
|
883
|
-
if (!session)
|
|
884
|
-
throw new Error(
|
|
885
|
-
"No active pitch session. Run `llama pitch start --name \"...\" --email \"...\"` first."
|
|
886
|
-
);
|
|
887
|
-
}
|
|
888
|
-
if (session.finalized) {
|
|
889
|
-
throw new Error(
|
|
890
|
-
"This pitch session is already finalized. Run `llama pitch end` to clear local state."
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
process.stderr.write("Asking the agent to wrap up...\n");
|
|
239
|
+
if (!session || session.finalized) throw new Error("No active unfinalized pitch session.");
|
|
894
240
|
const result = await sendExternalMessage("[FOUNDER_FINALIZE_REQUEST]");
|
|
895
|
-
process.stdout.write(result.text
|
|
896
|
-
if (result.finalized) {
|
|
897
|
-
process.stderr.write("\n--- Pitch session finalized ---\n");
|
|
898
|
-
if (result.finalize_payload) {
|
|
899
|
-
process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
|
|
900
|
-
}
|
|
901
|
-
} else {
|
|
902
|
-
process.stderr.write(
|
|
903
|
-
"\n⚠ Agent did not call finalize_intake on this turn. " +
|
|
904
|
-
"Try `llama pitch finalize` once more, or `llama pitch end` to abandon.\n"
|
|
905
|
-
);
|
|
906
|
-
}
|
|
241
|
+
process.stdout.write(`${result.text}\n`);
|
|
907
242
|
return;
|
|
908
243
|
}
|
|
909
|
-
|
|
910
|
-
// No action → REPL mode (requires existing session)
|
|
911
|
-
if (action === undefined || (rest.length === 0 && !["start", "say", "upload", "status", "end", "finalize"].includes(action))) {
|
|
912
|
-
// Treat any unknown bare action as "join existing session in REPL mode"
|
|
913
|
-
const session = readExternalSession();
|
|
914
|
-
if (!session) {
|
|
915
|
-
throw new Error(
|
|
916
|
-
"No active pitch session. Start one with:\n" +
|
|
917
|
-
' llama pitch start --name "Your Name" --email "you@company.com"'
|
|
918
|
-
);
|
|
919
|
-
}
|
|
920
|
-
if (session.finalized) {
|
|
921
|
-
throw new Error(
|
|
922
|
-
"This pitch session is finalized. Run `llama pitch end` then `pitch start` for a new one."
|
|
923
|
-
);
|
|
924
|
-
}
|
|
244
|
+
if (action === "repl") {
|
|
925
245
|
await runPitchRepl();
|
|
926
246
|
return;
|
|
927
247
|
}
|
|
928
|
-
|
|
929
|
-
throw new Error(`Unknown pitch subcommand: ${action}. Run \`llama pitch help\` for the full list.`);
|
|
248
|
+
throw new Error(`Unknown pitch subcommand: ${action}`);
|
|
930
249
|
}
|
|
931
250
|
|
|
932
251
|
async function runPitchRepl() {
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
},
|
|
950
|
-
});
|
|
951
|
-
if (!buffered) process.stdout.write(result.text);
|
|
952
|
-
process.stdout.write("\n\n");
|
|
953
|
-
if (result.finalized) {
|
|
954
|
-
console.log("--- Pitch session finalized ---");
|
|
955
|
-
if (result.finalize_payload) {
|
|
956
|
-
console.log(JSON.stringify(result.finalize_payload, null, 2));
|
|
252
|
+
const session = readExternalSession();
|
|
253
|
+
if (!session || session.finalized) throw new Error("Start an active pitch session first.");
|
|
254
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
|
|
255
|
+
console.log("Connected to Llama Ventures intake agent. :q exits; :upload <path> attaches a file.");
|
|
256
|
+
rl.prompt();
|
|
257
|
+
rl.on("line", async (line) => {
|
|
258
|
+
const input = line.trim();
|
|
259
|
+
if ([":q", ":quit", ":exit"].includes(input)) return rl.close();
|
|
260
|
+
try {
|
|
261
|
+
if (input.startsWith(":upload ")) {
|
|
262
|
+
const result = await uploadExternalFile(input.slice(8).trim());
|
|
263
|
+
console.log(`uploaded: ${result.filename}`);
|
|
264
|
+
} else if (input) {
|
|
265
|
+
const result = await sendExternalMessage(input);
|
|
266
|
+
console.log(`llama> ${result.text}`);
|
|
267
|
+
if (result.finalized) return rl.close();
|
|
957
268
|
}
|
|
958
|
-
|
|
959
|
-
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`error: ${error.message}`);
|
|
960
271
|
}
|
|
961
|
-
|
|
962
|
-
};
|
|
272
|
+
rl.prompt();
|
|
273
|
+
});
|
|
274
|
+
await new Promise((resolve) => rl.on("close", resolve));
|
|
275
|
+
}
|
|
963
276
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
277
|
+
async function handleAuth(area, action, rest) {
|
|
278
|
+
if (area === "token" && action === "set") {
|
|
279
|
+
const { flags, positional } = parseFlags(rest, ["base", "skip-verify"]);
|
|
280
|
+
const token = positional[0] ?? await readTokenQuietly();
|
|
281
|
+
if (!/^llc_[0-9a-f]{32}$/i.test(token)) throw new Error("Expected a full llc_ token (llc_ + 32 hex chars).");
|
|
282
|
+
if (flags.base && flags.base !== true) {
|
|
283
|
+
writeLegacyConfig({ ...readLegacyConfig(), baseUrl: String(flags.base).replace(/\/$/, "") });
|
|
284
|
+
}
|
|
285
|
+
if (!flags["skip-verify"]) {
|
|
286
|
+
const response = await fetch(`${getBaseUrl()}/api/me`, { headers: { "X-Llama-Token": token } });
|
|
287
|
+
if (!response.ok) throw new Error(`Server rejected token (HTTP ${response.status}); not saved.`);
|
|
970
288
|
}
|
|
971
|
-
|
|
972
|
-
|
|
289
|
+
writeCanonicalToken(token);
|
|
290
|
+
console.log("Saved token to ~/.llama/token (mode 0600).");
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (area === "token" && action === "show") {
|
|
294
|
+
const token = getToken();
|
|
295
|
+
console.log(token ? `${token.slice(0, 8)}...${token.slice(-4)} @ ${getBaseUrl()}` : "No token set.");
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
if (area === "auth" && action === "status") {
|
|
299
|
+
const [oauth, bearer] = await Promise.all([readBundle(), tryGcloudIdentityToken()]);
|
|
300
|
+
const token = getToken();
|
|
301
|
+
let serverCheck = "skipped (no credentials)";
|
|
302
|
+
if (oauth?.access_token || bearer || token) {
|
|
973
303
|
try {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
console.error("upload error:", err.message);
|
|
304
|
+
const me = await request("GET", "/api/me");
|
|
305
|
+
serverCheck = `ok — ${me?.email ?? "unknown"} (${me?.role ?? "unknown"})`;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
serverCheck = `failed — ${error.message.split("\n")[0]}`;
|
|
979
308
|
}
|
|
980
|
-
rl.prompt();
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
if (!trimmed) {
|
|
984
|
-
rl.prompt();
|
|
985
|
-
return;
|
|
986
309
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
310
|
+
const oauthBackend = oauth ? await detectBackend() : null;
|
|
311
|
+
print({
|
|
312
|
+
baseUrl: getBaseUrl(),
|
|
313
|
+
activeMethod: oauth?.access_token ? "oauth" : bearer ? "gcloud-bearer" : token ? "llama-token" : "none",
|
|
314
|
+
oauth: oauth ? { storage: oauthBackend, scope: oauth.scope } : "absent",
|
|
315
|
+
gcloudIdentityToken: bearer ? "present" : "absent",
|
|
316
|
+
llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
|
|
317
|
+
llamaTokenSource: process.env.LLAMA_TOKEN ? "$LLAMA_TOKEN" : readCanonicalToken() ? "~/.llama/token" : null,
|
|
318
|
+
serverCheck,
|
|
319
|
+
});
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
if (area === "auth" && action === "login") {
|
|
323
|
+
const { flags } = parseFlags(rest, ["scope"]);
|
|
324
|
+
const scope = typeof flags.scope === "string" ? flags.scope : "read write";
|
|
325
|
+
const baseUrl = getBaseUrl();
|
|
326
|
+
const bundle = await pkceLoopbackFlow({ baseUrl, scope, resource: baseUrl });
|
|
327
|
+
const stored = await writeBundle({
|
|
328
|
+
access_token: bundle.access_token,
|
|
329
|
+
refresh_token: bundle.refresh_token,
|
|
330
|
+
expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
|
|
331
|
+
scope: bundle.scope,
|
|
332
|
+
client_id: bundle.client_id,
|
|
333
|
+
issuer: bundle.issuer,
|
|
334
|
+
resource: bundle.resource,
|
|
335
|
+
created_at: Date.now(),
|
|
336
|
+
});
|
|
337
|
+
print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: stored.backend, scope: bundle.scope });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
if (area === "auth" && action === "logout") {
|
|
341
|
+
const bundle = await readBundle();
|
|
342
|
+
let revoked = false;
|
|
343
|
+
if (bundle) {
|
|
344
|
+
try {
|
|
345
|
+
revoked = await revokeOAuthToken({
|
|
346
|
+
baseUrl: bundle.issuer ?? getBaseUrl(),
|
|
347
|
+
token: bundle.refresh_token,
|
|
348
|
+
tokenTypeHint: "refresh_token",
|
|
349
|
+
});
|
|
350
|
+
} catch {
|
|
351
|
+
revoked = false;
|
|
352
|
+
}
|
|
353
|
+
await deleteBundle();
|
|
992
354
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
355
|
+
print({ ok: true, message: "Local OAuth credentials cleared", serverRevoke: revoked });
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
997
359
|
}
|
|
998
360
|
|
|
999
|
-
async function
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
361
|
+
async function handleWiki(action, rest) {
|
|
362
|
+
const { flags, positional } = parseFlags(rest);
|
|
363
|
+
const slug = positional[0];
|
|
364
|
+
if (action === "search") {
|
|
365
|
+
const q = positional.join(" ").trim();
|
|
366
|
+
if (!q) throw new Error("Usage: llama wiki search <query>");
|
|
367
|
+
print(await request("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (action === "read") {
|
|
371
|
+
if (!slug) throw new Error("Usage: llama wiki read <slug> [--lang en|zh]");
|
|
372
|
+
// @core-api-operation GET /api/wiki/{slug}
|
|
373
|
+
print(await request("GET", `/api/wiki/${encodeURIComponent(slug)}?lang=${flags.lang === "zh" ? "zh" : "en"}`));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (action === "save") {
|
|
377
|
+
if (!slug || typeof flags.title !== "string" || typeof flags.sources !== "string") {
|
|
378
|
+
throw new Error('Usage: llama wiki save <slug> --title "..." --content "..."|--file <path> --sources "url1;url2"');
|
|
1005
379
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
380
|
+
if (flags.content && flags.file) throw new Error("Use either --content or --file, not both.");
|
|
381
|
+
const content = flags.file ? await readFile(String(flags.file), "utf8") : flags.content;
|
|
382
|
+
if (typeof content !== "string") throw new Error("Wiki save requires --content or --file.");
|
|
383
|
+
const inferred = typeof flags.file === "string" && /\.html?$/i.test(flags.file) ? "html" : "markdown";
|
|
384
|
+
print(await request("POST", "/api/wiki/save", {
|
|
385
|
+
slug,
|
|
386
|
+
title: flags.title,
|
|
387
|
+
content,
|
|
388
|
+
sources: flags.sources.split(/[;|]/).map((value) => value.trim()).filter(Boolean),
|
|
389
|
+
type: flags.type,
|
|
390
|
+
related: typeof flags.related === "string" ? flags.related.split(/[;|]/).map((value) => value.trim()).filter(Boolean) : undefined,
|
|
391
|
+
lang: flags.lang === "zh" ? "zh" : "en",
|
|
392
|
+
content_type: typeof flags["content-type"] === "string" ? flags["content-type"] : inferred,
|
|
393
|
+
}));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (["delete", "restore"].includes(action)) {
|
|
397
|
+
if (!slug) throw new Error(`Usage: llama wiki ${action} <slug> [--lang en|zh]`);
|
|
398
|
+
const lang = flags.lang === "zh" ? "zh" : "en";
|
|
399
|
+
if (action === "delete") {
|
|
400
|
+
// @core-api-operation DELETE /api/wiki/{slug}
|
|
401
|
+
print(await request("DELETE", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang}`));
|
|
402
|
+
} else {
|
|
403
|
+
// @core-api-operation POST /api/wiki/{slug}/restore
|
|
404
|
+
print(await request("POST", `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang}`));
|
|
1013
405
|
}
|
|
1014
|
-
console.log(PKG_VERSION);
|
|
1015
406
|
return;
|
|
1016
407
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
408
|
+
throw new Error("Wiki actions: search, read, save, delete, restore.");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function main() {
|
|
412
|
+
const [area, action, ...rest] = process.argv.slice(2);
|
|
413
|
+
|
|
414
|
+
if (["--version", "-v", "version"].includes(area)) {
|
|
415
|
+
if (["--json", "json"].includes(action)) print(getBuildInfo());
|
|
416
|
+
else if (["--check", "check"].includes(action)) console.log(await getUpdateNudge() || `llama CLI ${PKG_VERSION} — up to date`);
|
|
417
|
+
else console.log(PKG_VERSION);
|
|
1019
418
|
return;
|
|
1020
419
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
usage(area);
|
|
420
|
+
if (!area || ["help", "--help", "-h"].includes(area)) {
|
|
421
|
+
usage(area === "help" ? action : undefined);
|
|
1024
422
|
return;
|
|
1025
423
|
}
|
|
1026
|
-
|
|
1027
|
-
// short-circuit, "--help" falls through to the action handler, where rest[0]
|
|
1028
|
-
// can be read as a positional (e.g. dealId="--help") and trigger a REAL write.
|
|
1029
|
-
// Catch --help/-h anywhere in the sub-command args and print group help first.
|
|
1030
|
-
if (rest.includes("--help") || rest.includes("-h")) {
|
|
424
|
+
if (["--help", "-h"].includes(action) || rest.some((value) => ["--help", "-h"].includes(value))) {
|
|
1031
425
|
usage(area);
|
|
1032
426
|
return;
|
|
1033
427
|
}
|
|
1034
428
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
//
|
|
1041
|
-
// Gated behind Command auth — without valid credentials we print a short
|
|
1042
|
-
// bootstrap stub instead. Stops unauthenticated callers from harvesting
|
|
1043
|
-
// internal command surface / workflow conventions just by running the
|
|
1044
|
-
// public CLI.
|
|
1045
|
-
if (
|
|
1046
|
-
area === "agent-onboard" ||
|
|
1047
|
-
(area === "agent" && (action === "onboard" || action === "briefing"))
|
|
1048
|
-
) {
|
|
429
|
+
assertActiveSurface(area, action);
|
|
430
|
+
|
|
431
|
+
if (await handleAuth(area, action, rest)) return;
|
|
432
|
+
|
|
433
|
+
if (area === "agent-onboard" || (area === "agent" && ["onboard", "briefing"].includes(action))) {
|
|
1049
434
|
const headers = await getAuthHeaders();
|
|
1050
|
-
if (Object.keys(headers).length
|
|
1051
|
-
console.log(
|
|
435
|
+
if (!Object.keys(headers).length) {
|
|
436
|
+
console.log(onboardingNoAuth());
|
|
1052
437
|
return;
|
|
1053
438
|
}
|
|
1054
439
|
try {
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
console.log(agentOnboardRejectedMessage());
|
|
1061
|
-
process.exitCode = 1;
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
process.stderr.write(
|
|
1065
|
-
`warning: server agent briefing unavailable (${msg}); using bundled fallback.\n`,
|
|
1066
|
-
);
|
|
440
|
+
const params = new URLSearchParams({ clientVersion: PKG_VERSION });
|
|
441
|
+
const response = await request("GET", `/api/agent/briefing?${params}`);
|
|
442
|
+
process.stdout.write(response?.briefing || readBriefing());
|
|
443
|
+
} catch (error) {
|
|
444
|
+
process.stderr.write(`warning: live briefing unavailable (${error.message}); using bundled fallback.\n`);
|
|
1067
445
|
process.stdout.write(readBriefing());
|
|
1068
446
|
}
|
|
1069
447
|
return;
|
|
1070
448
|
}
|
|
1071
449
|
|
|
1072
|
-
// Live runtime bootstrap from Llama Command. Unlike agent-onboard, this is
|
|
1073
|
-
// not bundled in the public npm package; Command returns the current
|
|
1074
|
-
// authenticated skill manifest and object-inspection contract.
|
|
1075
450
|
if (area === "agent" && action === "bootstrap") {
|
|
1076
451
|
const { flags } = parseFlags(rest, ["json", "limit"]);
|
|
1077
|
-
const params = new URLSearchParams();
|
|
1078
|
-
params.set("clientVersion", PKG_VERSION);
|
|
452
|
+
const params = new URLSearchParams({ clientVersion: PKG_VERSION });
|
|
1079
453
|
if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
|
|
1080
|
-
const manifest = await request("GET", `/api/agent/manifest
|
|
1081
|
-
if (flags.json)
|
|
1082
|
-
|
|
1083
|
-
} else {
|
|
1084
|
-
process.stdout.write(`${manifest.briefing || JSON.stringify(manifest, null, 2)}\n`);
|
|
1085
|
-
}
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (area === "activity") {
|
|
1090
|
-
const sub = action || "events";
|
|
1091
|
-
const normalized = sub.replace(/-/g, "_");
|
|
1092
|
-
const kind =
|
|
1093
|
-
normalized === "new" || normalized === "new_deal" || normalized === "new_deals"
|
|
1094
|
-
? "new_deals"
|
|
1095
|
-
: normalized === "updated" || normalized === "updates" || normalized === "updated_deal" || normalized === "updated_deals"
|
|
1096
|
-
? "updated_deals"
|
|
1097
|
-
: normalized === "event" || normalized === "events" || normalized === "feed" || normalized === "all"
|
|
1098
|
-
? "events"
|
|
1099
|
-
: null;
|
|
1100
|
-
if (!kind) {
|
|
1101
|
-
throw new Error("Usage: llama activity new-deals|updated-deals|events [--since 24h] [--limit 50]");
|
|
1102
|
-
}
|
|
1103
|
-
const { flags } = parseFlags(rest, [
|
|
1104
|
-
"json",
|
|
1105
|
-
"since",
|
|
1106
|
-
"limit",
|
|
1107
|
-
"cursor",
|
|
1108
|
-
"before-id",
|
|
1109
|
-
"before_id",
|
|
1110
|
-
"deal",
|
|
1111
|
-
"deal-id",
|
|
1112
|
-
"deal_id",
|
|
1113
|
-
"entity",
|
|
1114
|
-
"verb",
|
|
1115
|
-
"min-sig",
|
|
1116
|
-
"min_sig",
|
|
1117
|
-
"min-significance",
|
|
1118
|
-
]);
|
|
1119
|
-
print(await fetchActivity(kind, flags));
|
|
454
|
+
const manifest = await request("GET", `/api/agent/manifest?${params}`);
|
|
455
|
+
if (flags.json) print(manifest);
|
|
456
|
+
else process.stdout.write(`${manifest.briefing || JSON.stringify(manifest, null, 2)}\n`);
|
|
1120
457
|
return;
|
|
1121
458
|
}
|
|
1122
459
|
|
|
1123
460
|
if (area === "skills" || (area === "agent" && action === "skills")) {
|
|
1124
461
|
const sub = area === "skills" ? action : rest[0];
|
|
1125
462
|
const args = area === "skills" ? rest : rest.slice(1);
|
|
463
|
+
const { flags, positional } = parseFlags(args, ["json", "limit"]);
|
|
1126
464
|
if (!sub || sub === "list") {
|
|
1127
|
-
const { flags } = parseFlags(args, ["json", "limit"]);
|
|
1128
465
|
const params = new URLSearchParams();
|
|
1129
466
|
if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
|
|
1130
|
-
|
|
1131
|
-
print(result);
|
|
467
|
+
print(await request("GET", `/api/agent/skills${params.size ? `?${params}` : ""}`));
|
|
1132
468
|
return;
|
|
1133
469
|
}
|
|
1134
470
|
if (sub === "search") {
|
|
1135
|
-
const { flags, positional } = parseFlags(args, ["json", "limit"]);
|
|
1136
471
|
const q = positional.join(" ").trim();
|
|
1137
|
-
if (!q) throw new Error("Usage: llama skills search <query>
|
|
472
|
+
if (!q) throw new Error("Usage: llama skills search <query>");
|
|
1138
473
|
const params = new URLSearchParams({ q });
|
|
1139
474
|
if (flags.limit && flags.limit !== true) params.set("limit", String(flags.limit));
|
|
1140
|
-
|
|
1141
|
-
print(result);
|
|
475
|
+
print(await request("GET", `/api/agent/skills?${params}`));
|
|
1142
476
|
return;
|
|
1143
477
|
}
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
1149
|
-
if (flags.json) {
|
|
1150
|
-
print(result);
|
|
1151
|
-
} else {
|
|
1152
|
-
process.stdout.write(`${result.skill?.content || JSON.stringify(result, null, 2)}\n`);
|
|
1153
|
-
}
|
|
478
|
+
if (["show", "read"].includes(sub)) {
|
|
479
|
+
if (!positional[0]) throw new Error("Usage: llama skills show <slug>");
|
|
480
|
+
const result = await request("GET", `/api/agent/skills/${encodeURIComponent(positional[0])}`);
|
|
481
|
+
if (flags.json) print(result);
|
|
482
|
+
else process.stdout.write(`${result.skill?.content || JSON.stringify(result, null, 2)}\n`);
|
|
1154
483
|
return;
|
|
1155
484
|
}
|
|
1156
|
-
throw new Error(
|
|
485
|
+
throw new Error("Skills actions: list, search, show.");
|
|
1157
486
|
}
|
|
1158
487
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
const sub = action;
|
|
1163
|
-
if (!sub || sub === "list") {
|
|
1164
|
-
const { flags } = parseFlags(rest, ["json", "status"]);
|
|
488
|
+
if (["pref", "prefs", "preferences"].includes(area)) {
|
|
489
|
+
if (!action || action === "list") {
|
|
490
|
+
const { flags } = parseFlags(rest, ["status"]);
|
|
1165
491
|
const params = new URLSearchParams();
|
|
1166
492
|
if (flags.status && flags.status !== true) params.set("status", String(flags.status));
|
|
1167
|
-
|
|
1168
|
-
print(result);
|
|
493
|
+
print(await request("GET", `/api/agent/preferences${params.size ? `?${params}` : ""}`));
|
|
1169
494
|
return;
|
|
1170
495
|
}
|
|
1171
|
-
if (
|
|
1172
|
-
const { flags, positional } = parseFlags(rest, ["team", "evidence"
|
|
1173
|
-
const key = positional
|
|
1174
|
-
const content =
|
|
1175
|
-
if (!key || !content)
|
|
1176
|
-
|
|
1177
|
-
}
|
|
1178
|
-
const result = await request("POST", "/api/agent/preferences", {
|
|
496
|
+
if (action === "add") {
|
|
497
|
+
const { flags, positional } = parseFlags(rest, ["team", "evidence"]);
|
|
498
|
+
const [key, ...contentParts] = positional;
|
|
499
|
+
const content = contentParts.join(" ").trim();
|
|
500
|
+
if (!key || !content) throw new Error('Usage: llama pref add <key> "<content>" [--team]');
|
|
501
|
+
print(await request("POST", "/api/agent/preferences", {
|
|
1179
502
|
scope: flags.team ? "team" : "user",
|
|
1180
503
|
key,
|
|
1181
504
|
content,
|
|
1182
|
-
evidence: flags.evidence
|
|
1183
|
-
});
|
|
1184
|
-
print(result);
|
|
505
|
+
evidence: flags.evidence === true ? undefined : flags.evidence,
|
|
506
|
+
}));
|
|
1185
507
|
return;
|
|
1186
508
|
}
|
|
1187
|
-
if (
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
throw new Error(`Usage: llama pref ${sub} <id>`);
|
|
1192
|
-
}
|
|
1193
|
-
const result = await request("PATCH", `/api/agent/preferences/${id}`, {
|
|
1194
|
-
status: sub === "approve" ? "active" : "retired",
|
|
1195
|
-
});
|
|
1196
|
-
print(result);
|
|
509
|
+
if (["approve", "retire"].includes(action)) {
|
|
510
|
+
const id = Number(rest[0]);
|
|
511
|
+
if (!Number.isInteger(id) || id <= 0) throw new Error(`Usage: llama pref ${action} <id>`);
|
|
512
|
+
print(await request("PATCH", `/api/agent/preferences/${id}`, { status: action === "approve" ? "active" : "retired" }));
|
|
1197
513
|
return;
|
|
1198
514
|
}
|
|
1199
|
-
throw new Error(
|
|
515
|
+
throw new Error("Preference actions: list, add, approve, retire.");
|
|
1200
516
|
}
|
|
1201
517
|
|
|
1202
518
|
if (area === "explain" || (area === "agent" && action === "explain")) {
|
|
1203
519
|
const args = area === "explain" ? [action, ...rest].filter(Boolean) : rest;
|
|
1204
520
|
const { flags, positional } = parseFlags(args, ["json", "type", "id", "lang"]);
|
|
1205
521
|
const params = new URLSearchParams();
|
|
1206
|
-
|
|
1207
|
-
if (q) params.set("q", q);
|
|
522
|
+
if (positional.length) params.set("q", positional.join(" "));
|
|
1208
523
|
if (flags.type && flags.type !== true) params.set("type", String(flags.type));
|
|
1209
524
|
if (flags.id && flags.id !== true) params.set("id", String(flags.id));
|
|
1210
525
|
if (flags.lang === "zh") params.set("lang", "zh");
|
|
1211
|
-
if (!params.has("q") && !(params.has("type") && params.has("id")))
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
const result = await request("GET", `/api/agent/explain?${params}`);
|
|
1215
|
-
if (flags.json) {
|
|
1216
|
-
print(result);
|
|
1217
|
-
} else {
|
|
1218
|
-
const target = result.result?.target;
|
|
1219
|
-
const lifecycle = result.result?.lifecycle || [];
|
|
1220
|
-
const lines = [
|
|
1221
|
-
`${target?.objectType || "object"} ${target?.objectId || ""}`,
|
|
1222
|
-
`Status: ${target?.status || "unknown"}`,
|
|
1223
|
-
`Title: ${target?.title || "Untitled"}`,
|
|
1224
|
-
target?.detail ? `Detail: ${target.detail}` : null,
|
|
1225
|
-
target?.url ? `URL: ${target.url}` : null,
|
|
1226
|
-
`Lifecycle events: ${lifecycle.length}`,
|
|
1227
|
-
].filter(Boolean);
|
|
1228
|
-
if (lifecycle[0]) {
|
|
1229
|
-
lines.push(
|
|
1230
|
-
`Latest lifecycle: ${lifecycle[0].action} by ${lifecycle[0].actor_label || "unknown"} at ${lifecycle[0].created_at}`,
|
|
1231
|
-
);
|
|
1232
|
-
if (lifecycle[0].reason) lines.push(`Reason: ${lifecycle[0].reason}`);
|
|
1233
|
-
}
|
|
1234
|
-
process.stdout.write(`${lines.join("\n")}\n`);
|
|
1235
|
-
}
|
|
526
|
+
if (!params.has("q") && !(params.has("type") && params.has("id"))) throw new Error("Usage: llama explain <url-or-object>");
|
|
527
|
+
print(await request("GET", `/api/agent/explain?${params}`));
|
|
1236
528
|
return;
|
|
1237
529
|
}
|
|
1238
530
|
|
|
1239
|
-
// `llama pitch ...` — external founder-pitch family. No Llama token
|
|
1240
|
-
// required; bootstraps a session against /api/external/* via PoW + cookie.
|
|
1241
|
-
// See lib/external.mjs and AGENT_BRIEFING.md for the full surface.
|
|
1242
531
|
if (area === "pitch") {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
if (area === "token" && action === "set") {
|
|
1248
|
-
const { flags, positional } = parseFlags(rest);
|
|
1249
|
-
const token = positional[0];
|
|
1250
|
-
if (!token?.startsWith("llc_")) throw new Error("Expected a token starting with llc_");
|
|
1251
|
-
if (token.length !== 36) {
|
|
1252
|
-
throw new Error(
|
|
1253
|
-
`Token has length ${token.length}; expected 36 (llc_ + 32 hex chars).\n` +
|
|
1254
|
-
` This usually means you copied the masked preview ("llc_xxxx…yyyy") from\n` +
|
|
1255
|
-
` the token list instead of the full string from the mint response.\n` +
|
|
1256
|
-
` Re-mint at https://command.llamaventures.vc/settings/tokens and use the\n` +
|
|
1257
|
-
` Copy button — it captures the full value.`
|
|
1258
|
-
);
|
|
1259
|
-
}
|
|
1260
|
-
if (flags.base) {
|
|
1261
|
-
// baseUrl still lives in legacy config — rarely overridden. Keep the
|
|
1262
|
-
// file there so we don't introduce a second config surface.
|
|
1263
|
-
const legacy = readLegacyConfig();
|
|
1264
|
-
legacy.baseUrl = String(flags.base).replace(/\/$/, "");
|
|
1265
|
-
writeLegacyConfig(legacy);
|
|
1266
|
-
}
|
|
1267
|
-
// Round-trip the token against /api/me before persisting. Catches the
|
|
1268
|
-
// pasted-preview / wrong-token / wrong-host cases at "set" time instead
|
|
1269
|
-
// of letting them fester until the next CLI call (or worse, a CI run).
|
|
1270
|
-
// --skip-verify is an escape hatch for offline / pre-deploy testing.
|
|
1271
|
-
if (!flags["skip-verify"]) {
|
|
1272
|
-
try {
|
|
1273
|
-
const res = await fetch(`${getBaseUrl()}/api/me`, {
|
|
1274
|
-
headers: { "X-Llama-Token": token },
|
|
1275
|
-
});
|
|
1276
|
-
if (res.status === 401 || res.status === 403) {
|
|
1277
|
-
const body = await res.text();
|
|
1278
|
-
throw new Error(
|
|
1279
|
-
`Server rejected this token (HTTP ${res.status}). Not saving.\n` +
|
|
1280
|
-
` Response: ${body.slice(0, 200)}\n` +
|
|
1281
|
-
` Base URL: ${getBaseUrl()}\n` +
|
|
1282
|
-
` Re-check that you copied the full token from the mint dialog\n` +
|
|
1283
|
-
` ("Shown once") and not the masked preview from the list view.\n` +
|
|
1284
|
-
` Override with --skip-verify if you know the server is unreachable.`
|
|
1285
|
-
);
|
|
1286
|
-
}
|
|
1287
|
-
if (!res.ok) {
|
|
1288
|
-
throw new Error(`Verify call failed: HTTP ${res.status}. Not saving.`);
|
|
1289
|
-
}
|
|
1290
|
-
} catch (e) {
|
|
1291
|
-
if (e instanceof Error && (e.message.startsWith("Server rejected") || e.message.startsWith("Verify call failed"))) {
|
|
1292
|
-
throw e;
|
|
1293
|
-
}
|
|
1294
|
-
// Network / DNS failure — surface but let the user override.
|
|
1295
|
-
throw new Error(
|
|
1296
|
-
`Could not reach ${getBaseUrl()} to verify token: ${e.message}\n` +
|
|
1297
|
-
` Add --skip-verify if you want to save anyway.`
|
|
1298
|
-
);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
writeCanonicalToken(token);
|
|
1302
|
-
console.log(`Saved token to ~/.llama/token (mode 0600).`);
|
|
1303
|
-
console.log(`Base URL: ${getBaseUrl()}`);
|
|
1304
|
-
if (!flags["skip-verify"]) console.log(`Verified against ${getBaseUrl()}/api/me — token works.`);
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
if (area === "token" && action === "show") {
|
|
1309
|
-
const token = getToken();
|
|
1310
|
-
if (!token) {
|
|
1311
|
-
console.log("No token set.");
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
console.log(`${token.slice(0, 8)}...${token.slice(-4)} @ ${getBaseUrl()}`);
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// Self-diagnosis for agents and humans — what credentials do we have, and
|
|
1319
|
-
// are they accepted by the server right now? Designed so an agent can
|
|
1320
|
-
// parse the output and decide whether to drive a recovery flow.
|
|
1321
|
-
if (area === "auth" && action === "status") {
|
|
1322
|
-
const bearer = await tryGcloudIdentityToken();
|
|
1323
|
-
const token = getToken();
|
|
1324
|
-
const tokenSrc = process.env.LLAMA_TOKEN
|
|
1325
|
-
? "$LLAMA_TOKEN"
|
|
1326
|
-
: readCanonicalToken()
|
|
1327
|
-
? "~/.llama/token"
|
|
1328
|
-
: readLegacyConfig().token
|
|
1329
|
-
? "~/.llama-command/config.json (legacy)"
|
|
1330
|
-
: null;
|
|
1331
|
-
|
|
1332
|
-
const oauthBundle = await readBundle();
|
|
1333
|
-
const oauthBackend = oauthBundle ? await detectBackend() : null;
|
|
1334
|
-
|
|
1335
|
-
let serverCheck = "skipped (no credentials)";
|
|
1336
|
-
if (oauthBundle?.access_token || bearer || token) {
|
|
1337
|
-
try {
|
|
1338
|
-
const me = await request("GET", "/api/me");
|
|
1339
|
-
serverCheck = `ok — authenticated as ${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
|
|
1340
|
-
} catch (e) {
|
|
1341
|
-
serverCheck = `failed — ${e.message.split("\n")[0]}`;
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
const out = {
|
|
1346
|
-
baseUrl: getBaseUrl(),
|
|
1347
|
-
activeMethod: oauthBundle?.access_token
|
|
1348
|
-
? "oauth"
|
|
1349
|
-
: bearer
|
|
1350
|
-
? "gcloud-bearer"
|
|
1351
|
-
: token
|
|
1352
|
-
? "llama-token"
|
|
1353
|
-
: "none",
|
|
1354
|
-
oauth: oauthBundle
|
|
1355
|
-
? {
|
|
1356
|
-
storage: oauthBackend,
|
|
1357
|
-
client_id: oauthBundle.client_id,
|
|
1358
|
-
scope: oauthBundle.scope,
|
|
1359
|
-
issuer: oauthBundle.issuer,
|
|
1360
|
-
expires_in_seconds: Math.max(0, Math.round((oauthBundle.expires_at - Date.now()) / 1000)),
|
|
1361
|
-
}
|
|
1362
|
-
: "absent (run `llama auth login`)",
|
|
1363
|
-
gcloudIdentityToken: bearer ? "present" : "absent",
|
|
1364
|
-
llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
|
|
1365
|
-
llamaTokenSource: tokenSrc,
|
|
1366
|
-
serverCheck,
|
|
1367
|
-
};
|
|
1368
|
-
print(out);
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// ============================================================
|
|
1373
|
-
// auth login — PKCE + loopback browser flow
|
|
1374
|
-
// ============================================================
|
|
1375
|
-
if (area === "auth" && action === "login") {
|
|
1376
|
-
const { flags } = parseFlags(rest);
|
|
1377
|
-
const requestedScope = typeof flags.scope === "string" && flags.scope.trim()
|
|
1378
|
-
? flags.scope.trim()
|
|
1379
|
-
: "read write";
|
|
1380
|
-
const baseUrl = getBaseUrl();
|
|
1381
|
-
const resource = baseUrl; // general API audience (oauthApiResource on the server)
|
|
1382
|
-
|
|
1383
|
-
console.error(`Signing in to ${baseUrl} as Llama CLI (client_id=${LLAMA_CLI_CLIENT_ID})...`);
|
|
1384
|
-
const bundle = await pkceLoopbackFlow({ baseUrl, scope: requestedScope, resource });
|
|
1385
|
-
const stored = await writeBundle({
|
|
1386
|
-
access_token: bundle.access_token,
|
|
1387
|
-
refresh_token: bundle.refresh_token,
|
|
1388
|
-
expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
|
|
1389
|
-
scope: bundle.scope,
|
|
1390
|
-
client_id: bundle.client_id,
|
|
1391
|
-
issuer: bundle.issuer,
|
|
1392
|
-
resource: bundle.resource,
|
|
1393
|
-
created_at: Date.now(),
|
|
1394
|
-
});
|
|
1395
|
-
|
|
1396
|
-
// Verify by hitting /api/me with the new token.
|
|
1397
|
-
let identity = "(unable to verify — /api/me did not respond)";
|
|
1398
|
-
try {
|
|
1399
|
-
const me = await request("GET", "/api/me");
|
|
1400
|
-
identity = `${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
|
|
1401
|
-
} catch (e) {
|
|
1402
|
-
identity = `verification failed: ${e.message.split("\n")[0]}`;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
print({
|
|
1406
|
-
ok: true,
|
|
1407
|
-
message: "Signed in",
|
|
1408
|
-
identity,
|
|
1409
|
-
storage: stored.backend,
|
|
1410
|
-
scope: bundle.scope,
|
|
1411
|
-
expires_in_seconds: bundle.expires_in,
|
|
1412
|
-
});
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// ============================================================
|
|
1417
|
-
// auth logout — revoke + clear local
|
|
1418
|
-
// ============================================================
|
|
1419
|
-
if (area === "auth" && action === "logout") {
|
|
1420
|
-
const bundle = await readBundle();
|
|
1421
|
-
if (!bundle) {
|
|
1422
|
-
print({ ok: true, message: "No OAuth credentials to clear" });
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
let revoked = false;
|
|
1426
|
-
try {
|
|
1427
|
-
revoked = await revokeOAuthToken({
|
|
1428
|
-
baseUrl: bundle.issuer ?? getBaseUrl(),
|
|
1429
|
-
token: bundle.refresh_token,
|
|
1430
|
-
tokenTypeHint: "refresh_token",
|
|
1431
|
-
});
|
|
1432
|
-
} catch {
|
|
1433
|
-
revoked = false;
|
|
1434
|
-
}
|
|
1435
|
-
await deleteBundle();
|
|
1436
|
-
print({
|
|
1437
|
-
ok: true,
|
|
1438
|
-
message: "Signed out — local credentials cleared",
|
|
1439
|
-
serverRevoke: revoked ? "succeeded" : "failed (server unreachable or token already invalid; local state cleared anyway)",
|
|
1440
|
-
});
|
|
1441
|
-
return;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
if (area === "deal" && action === "create") {
|
|
1445
|
-
const { flags, positional } = parseFlags(rest);
|
|
1446
|
-
const companyName = positional.join(" ").trim();
|
|
1447
|
-
if (!companyName) throw new Error("Usage: llama deal create \"Company\" [--source Name] [--deal-owner name|email|userId]");
|
|
1448
|
-
const body = {
|
|
1449
|
-
companyName,
|
|
1450
|
-
source: flags.source,
|
|
1451
|
-
dealOwner: flags.dealOwner || flags["deal-owner"],
|
|
1452
|
-
sourceDirection: flags.sourceDirection || flags["source-direction"],
|
|
1453
|
-
description: flags.description,
|
|
1454
|
-
website: flags.website,
|
|
1455
|
-
notes: flags.notes,
|
|
1456
|
-
status: flags.status,
|
|
1457
|
-
theirStage: flags["their-stage"],
|
|
1458
|
-
stage: flags.stage,
|
|
1459
|
-
proposedAmount: flags["proposed-amount"],
|
|
1460
|
-
roundSize: flags["round-size"],
|
|
1461
|
-
valuation: flags.valuation,
|
|
1462
|
-
founders: flags.founders,
|
|
1463
|
-
location: flags.location,
|
|
1464
|
-
};
|
|
1465
|
-
print(await request("POST", "/api/deals/create", body));
|
|
1466
|
-
return;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
if (area === "workflow") {
|
|
1470
|
-
const dealId = rest[0];
|
|
1471
|
-
if (!dealId) throw new Error("Usage: llama workflow show|initialize|request-support|decide-support|proceed|waive|control|organize-ic|vote|reassign-owner|execution-status <dealId> [...flags]");
|
|
1472
|
-
if (action === "show") {
|
|
1473
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/workflow`));
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
const { flags, positional } = parseFlags(rest.slice(1));
|
|
1477
|
-
const current = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/workflow`);
|
|
1478
|
-
const expectedRevision = current?.workflow?.revision;
|
|
1479
|
-
if (!Number.isInteger(expectedRevision)) {
|
|
1480
|
-
throw new Error("This deal has no initialized Investment Workflow V2 state. Open its workflow panel once, then retry.");
|
|
1481
|
-
}
|
|
1482
|
-
const base = {
|
|
1483
|
-
requestId: `cli:${action}:${randomUUID()}`,
|
|
1484
|
-
expectedRevision,
|
|
1485
|
-
};
|
|
1486
|
-
let command;
|
|
1487
|
-
if (action === "initialize") {
|
|
1488
|
-
if (!flags.reason) throw new Error('Usage: llama workflow initialize <dealId> --reason "..."');
|
|
1489
|
-
command = { ...base, type: "initialize", reason: String(flags.reason) };
|
|
1490
|
-
} else if (action === "request-support") {
|
|
1491
|
-
if (!flags.partner || !flags.reason) throw new Error('Usage: llama workflow request-support <dealId> --partner <userId> --reason "..."');
|
|
1492
|
-
command = { ...base, type: "request_partner_support", partnerId: String(flags.partner), reason: String(flags.reason) };
|
|
1493
|
-
} else if (action === "decide-support") {
|
|
1494
|
-
const decision = positional[0];
|
|
1495
|
-
if (!["support", "need_more", "pass"].includes(decision) || !flags.reason) throw new Error('Usage: llama workflow decide-support <dealId> support|need_more|pass --reason "..."');
|
|
1496
|
-
command = { ...base, type: "partner_support_decision", decision, reason: String(flags.reason) };
|
|
1497
|
-
} else if (action === "proceed") {
|
|
1498
|
-
if (!flags.transition || !flags.reason) throw new Error('Usage: llama workflow proceed <dealId> --transition <key> --reason "..."');
|
|
1499
|
-
command = { ...base, type: "proceed", transitionKey: String(flags.transition), reason: String(flags.reason) };
|
|
1500
|
-
} else if (action === "waive") {
|
|
1501
|
-
if (!flags.guard || !flags.reason) throw new Error('Usage: llama workflow waive <dealId> --guard <key> --reason "..."');
|
|
1502
|
-
command = { ...base, type: "resolve_guard", guardKey: String(flags.guard), status: "waived", reason: String(flags.reason) };
|
|
1503
|
-
} else if (action === "control") {
|
|
1504
|
-
const control = positional[0];
|
|
1505
|
-
if (!["hold", "resume", "pass", "restore", "return"].includes(control) || !flags.reason) throw new Error('Usage: llama workflow control <dealId> hold|resume|pass|restore|return --reason "..." [--disposition stalled|future]');
|
|
1506
|
-
command = { ...base, type: "control", action: control, reason: String(flags.reason), ...(flags.disposition ? { holdDisposition: String(flags.disposition) } : {}) };
|
|
1507
|
-
} else if (action === "organize-ic") {
|
|
1508
|
-
if (!flags.note) throw new Error('Usage: llama workflow organize-ic <dealId> --note "..."');
|
|
1509
|
-
command = { ...base, type: "organize_ic", note: String(flags.note) };
|
|
1510
|
-
} else if (action === "vote") {
|
|
1511
|
-
const vote = positional[0];
|
|
1512
|
-
if (!["yes", "no"].includes(vote) || !flags.reason) throw new Error('Usage: llama workflow vote <dealId> yes|no --reason "..."');
|
|
1513
|
-
command = { ...base, type: "cast_vote", vote, reason: String(flags.reason) };
|
|
1514
|
-
} else if (action === "reassign-owner") {
|
|
1515
|
-
if (!flags.owner || !flags.reason) throw new Error('Usage: llama workflow reassign-owner <dealId> --owner <userId> --reason "..."');
|
|
1516
|
-
command = { ...base, type: "reassign_owner", ownerId: String(flags.owner), ownerName: "resolved by server", reason: String(flags.reason) };
|
|
1517
|
-
} else if (action === "execution-status") {
|
|
1518
|
-
const status = { "term-sheet": "Term Sheet", "verbal-commit": "Verbal Commit", invested: "Invested" }[positional[0]];
|
|
1519
|
-
if (!status || !flags.reason) throw new Error('Usage: llama workflow execution-status <dealId> term-sheet|verbal-commit|invested --reason "..."');
|
|
1520
|
-
command = { ...base, type: "update_execution_status", executionStatus: status, reason: String(flags.reason) };
|
|
1521
|
-
} else {
|
|
1522
|
-
throw new Error(`Unknown workflow command "${action || ""}".`);
|
|
1523
|
-
}
|
|
1524
|
-
print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/workflow`, command));
|
|
1525
|
-
return;
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
if (area === "deal" && action === "show") {
|
|
1529
|
-
const dealId = rest[0];
|
|
1530
|
-
if (!dealId) throw new Error("Usage: llama deal show <dealId>");
|
|
1531
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/command-center`));
|
|
1532
|
-
return;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
if (area === "deal" && action === "feed") {
|
|
1536
|
-
const dealId = rest[0];
|
|
1537
|
-
if (!dealId) throw new Error("Usage: llama deal feed <dealId>");
|
|
1538
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/feed`));
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
if (area === "deal" && action === "update") {
|
|
1543
|
-
const [dealId, field, ...valueParts] = rest;
|
|
1544
|
-
const value = valueParts.join(" ");
|
|
1545
|
-
if (!dealId || !field) throw new Error("Usage: llama deal update <dealId> <field> <value>");
|
|
1546
|
-
if (field === "status") {
|
|
1547
|
-
throw new Error("Direct status writes are retired. Use `llama workflow show <dealId>` and a formal `llama workflow ...` command.");
|
|
1548
|
-
}
|
|
1549
|
-
print(await request("POST", "/api/deals/update", { dealId, field, value }));
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
if (area === "deal" && action === "founders") {
|
|
1554
|
-
const sub = rest[0];
|
|
1555
|
-
const dealId = rest[1];
|
|
1556
|
-
const { flags } = parseFlags(rest.slice(2), ["json"]);
|
|
1557
|
-
if (sub !== "set" || !dealId || typeof flags.json !== "string") {
|
|
1558
|
-
throw new Error(
|
|
1559
|
-
"Usage: llama deal founders set <dealId> --json '[{\"name\":\"Ada\",\"email\":\"ada@example.com\"}]'"
|
|
1560
|
-
);
|
|
1561
|
-
}
|
|
1562
|
-
let founders;
|
|
1563
|
-
try {
|
|
1564
|
-
founders = JSON.parse(flags.json);
|
|
1565
|
-
} catch {
|
|
1566
|
-
throw new Error("--json must be a valid JSON array");
|
|
1567
|
-
}
|
|
1568
|
-
if (!Array.isArray(founders)) throw new Error("--json must be a JSON array");
|
|
1569
|
-
print(await request("PUT", `/api/deals/${encodeURIComponent(dealId)}/founders`, { founders }));
|
|
532
|
+
if (!action) await runPitchRepl();
|
|
533
|
+
else await handlePitch(action, rest);
|
|
1570
534
|
return;
|
|
1571
535
|
}
|
|
1572
536
|
|
|
1573
|
-
// ----- deals.extra JSONB patches (system-admin only, server-gated) -----
|
|
1574
|
-
// Same endpoint as `deal update`, but `extraKey` instead of `field`.
|
|
1575
|
-
// Server patches one top-level key via jsonb_set and audits the change
|
|
1576
|
-
// to deal_events as field_change with field "extra.<key>". value=null
|
|
1577
|
-
// deletes the key.
|
|
1578
|
-
if (area === "deal" && action === "extra") {
|
|
1579
|
-
const sub = rest[0];
|
|
1580
|
-
const dealId = rest[1];
|
|
1581
|
-
const key = rest[2];
|
|
1582
|
-
if (["stage_gates", "stage4_gate"].includes(key)) {
|
|
1583
|
-
throw new Error(`Legacy ${key} is retired. Use \`llama workflow ...\` commands.`);
|
|
1584
|
-
}
|
|
1585
|
-
if (sub === "set") {
|
|
1586
|
-
const raw = rest.slice(3).join(" ");
|
|
1587
|
-
if (!dealId || !key || !raw) {
|
|
1588
|
-
throw new Error(
|
|
1589
|
-
"Usage: llama deal extra set <dealId> <key> <value> (value parsed as JSON when possible, else stored as string)"
|
|
1590
|
-
);
|
|
1591
|
-
}
|
|
1592
|
-
let value;
|
|
1593
|
-
try {
|
|
1594
|
-
value = JSON.parse(raw);
|
|
1595
|
-
} catch {
|
|
1596
|
-
value = raw;
|
|
1597
|
-
}
|
|
1598
|
-
print(await request("POST", "/api/deals/update", { dealId, extraKey: key, value }));
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
if (sub === "unset") {
|
|
1602
|
-
if (!dealId || !key) throw new Error("Usage: llama deal extra unset <dealId> <key>");
|
|
1603
|
-
print(await request("POST", "/api/deals/update", { dealId, extraKey: key, value: null }));
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
throw new Error("Usage: llama deal extra set|unset <dealId> <key> [value]");
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
537
|
if (area === "deal" && action === "search") {
|
|
1610
|
-
const { flags, positional } = parseFlags(rest);
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
throw new Error(
|
|
1614
|
-
`Usage: llama deal search <query> [--founder ...] [--owner ...] [--status ...] [--stage ...] [--source-direction Inbound|Outbound] [--limit N]`
|
|
1615
|
-
);
|
|
1616
|
-
}
|
|
1617
|
-
print(await searchDeals(q, flags));
|
|
1618
|
-
return;
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
if (area === "deal" && action === "list") {
|
|
1622
|
-
const { flags } = parseFlags(rest);
|
|
1623
|
-
print(await searchDeals("", flags));
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
if (area === "deal" && action === "agent") {
|
|
1628
|
-
const sub = rest[0];
|
|
1629
|
-
const dealId = rest[1];
|
|
1630
|
-
const { flags, positional } = parseFlags(rest.slice(2), ["message"]);
|
|
1631
|
-
const message =
|
|
1632
|
-
flags.message && flags.message !== true ? String(flags.message) : positional.join(" ").trim();
|
|
1633
|
-
if (sub !== "run" || !dealId || !message) {
|
|
1634
|
-
throw new Error(`Usage: llama deal agent run <dealId> --message "what the server agent should do"`);
|
|
1635
|
-
}
|
|
1636
|
-
await runDealAgentViaThread(dealId, message, "CLI agent run");
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
// ----- Deal enrichment: evidence plan + server-side enrichment trigger -----
|
|
1641
|
-
// The server owns Monid credentials and all write/audit behavior. CLI only
|
|
1642
|
-
// passes intent; default is dry-run so agents can inspect the harness before
|
|
1643
|
-
// creating facts/links or touching memo state.
|
|
1644
|
-
if (area === "deal" && action === "enrich") {
|
|
1645
|
-
const dealId = rest[0];
|
|
1646
|
-
if (!dealId) {
|
|
1647
|
-
throw new Error(
|
|
1648
|
-
"Usage: llama deal enrich <dealId> [--dry-run] [--apply] " +
|
|
1649
|
-
"[--executor server_agent|external_agent|planner] " +
|
|
1650
|
-
"[--sources website,github,linkedin,yc,monid] [--budget-cents 50] [--memo] [--prompt]"
|
|
1651
|
-
);
|
|
1652
|
-
}
|
|
1653
|
-
const { flags } = parseFlags(rest.slice(1), [
|
|
1654
|
-
"dry-run",
|
|
1655
|
-
"apply",
|
|
1656
|
-
"executor",
|
|
1657
|
-
"sources",
|
|
1658
|
-
"budget-cents",
|
|
1659
|
-
"memo",
|
|
1660
|
-
"generate-memo",
|
|
1661
|
-
"generateMemo",
|
|
1662
|
-
"prompt",
|
|
1663
|
-
"handoff",
|
|
1664
|
-
"harness-only",
|
|
1665
|
-
"message",
|
|
1666
|
-
]);
|
|
1667
|
-
const sources = splitCsvFlag(flags.sources);
|
|
1668
|
-
const budgetCents =
|
|
1669
|
-
flags["budget-cents"] !== undefined && flags["budget-cents"] !== true
|
|
1670
|
-
? Number(flags["budget-cents"])
|
|
1671
|
-
: undefined;
|
|
1672
|
-
const apply = boolFlag(flags, "apply");
|
|
1673
|
-
const executor = flags.executor && flags.executor !== true ? String(flags.executor) : "server_agent";
|
|
1674
|
-
|
|
1675
|
-
if (apply && executor === "server_agent" && !boolFlag(flags, "harness-only")) {
|
|
1676
|
-
await runDealAgentViaThread(dealId, buildEnrichmentAgentMessage(flags), "CLI enrichment");
|
|
1677
|
-
return;
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
const result = await request(
|
|
1681
|
-
"POST",
|
|
1682
|
-
`/api/deals/${encodeURIComponent(dealId)}/enrich`,
|
|
1683
|
-
{
|
|
1684
|
-
dryRun: apply ? false : true,
|
|
1685
|
-
apply,
|
|
1686
|
-
executor,
|
|
1687
|
-
sources,
|
|
1688
|
-
budgetCents,
|
|
1689
|
-
generateMemo: boolFlag(flags, "memo", "generate-memo", "generateMemo"),
|
|
1690
|
-
}
|
|
1691
|
-
);
|
|
1692
|
-
if (flags.prompt === true || flags.handoff === true) {
|
|
1693
|
-
print(result?.agentHarness?.handoffPrompt || result?.agentHarness?.systemInjection || "");
|
|
1694
|
-
} else {
|
|
1695
|
-
print(result);
|
|
1696
|
-
}
|
|
1697
|
-
return;
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
// ----- Collaborators (deal team — non-owner contributors) -----
|
|
1701
|
-
// Accepts --user as numeric id OR @llamaventures.vc email; emails are
|
|
1702
|
-
// resolved to id via /api/users so the CLI matches how the web picker
|
|
1703
|
-
// works (you don't need to memorize ids).
|
|
1704
|
-
if (area === "deal" && action === "collab") {
|
|
1705
|
-
const sub = rest[0];
|
|
1706
|
-
const dealId = rest[1];
|
|
1707
|
-
const { flags } = parseFlags(rest.slice(2));
|
|
1708
|
-
|
|
1709
|
-
if (!sub || !dealId) {
|
|
1710
|
-
throw new Error(
|
|
1711
|
-
"Usage: llama deal collab list|add|remove <dealId> [--user <userId|email>]"
|
|
1712
|
-
);
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
if (sub === "list") {
|
|
1716
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/collaborators`));
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
if (sub !== "add" && sub !== "remove" && sub !== "restore") {
|
|
1721
|
-
throw new Error(`Unknown collab sub-command "${sub}". Use list, add, remove, or restore.`);
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
if (!flags.user) {
|
|
1725
|
-
throw new Error(`Usage: llama deal collab ${sub} <dealId> --user <userId|email>`);
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
let userId = Number(flags.user);
|
|
1729
|
-
if (!Number.isFinite(userId)) {
|
|
1730
|
-
const email = String(flags.user).toLowerCase();
|
|
1731
|
-
const usersPayload = await request("GET", "/api/users");
|
|
1732
|
-
const list = Array.isArray(usersPayload) ? usersPayload : usersPayload.users ?? [];
|
|
1733
|
-
const match = list.find((u) => String(u.email).toLowerCase() === email);
|
|
1734
|
-
if (!match) throw new Error(`No active user with email "${flags.user}"`);
|
|
1735
|
-
userId = match.id;
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
if (sub === "add") {
|
|
1739
|
-
print(await request(
|
|
1740
|
-
"POST",
|
|
1741
|
-
`/api/deals/${encodeURIComponent(dealId)}/collaborators`,
|
|
1742
|
-
{ userId }
|
|
1743
|
-
));
|
|
1744
|
-
} else if (sub === "remove") {
|
|
1745
|
-
print(await request(
|
|
1746
|
-
"DELETE",
|
|
1747
|
-
`/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}`
|
|
1748
|
-
));
|
|
1749
|
-
} else {
|
|
1750
|
-
print(await request(
|
|
1751
|
-
"POST",
|
|
1752
|
-
`/api/deals/${encodeURIComponent(dealId)}/collaborators/${userId}/restore`
|
|
1753
|
-
));
|
|
1754
|
-
}
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
// ----- Deal links (URLs attached to a deal — separate from brief link blocks) -----
|
|
1759
|
-
// Soft-delete: removal sets deleted_at, restore clears it. List excludes
|
|
1760
|
-
// trashed by default; pass --include-deleted to see them.
|
|
1761
|
-
if (area === "deal" && action === "link") {
|
|
1762
|
-
const sub = rest[0];
|
|
1763
|
-
const dealId = rest[1];
|
|
1764
|
-
if (!sub || !dealId) {
|
|
1765
|
-
throw new Error(
|
|
1766
|
-
"Usage: llama deal link list|add|delete|restore <dealId> [...flags|<linkId>]"
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
if (sub === "list") {
|
|
1771
|
-
const { flags } = parseFlags(rest.slice(2));
|
|
1772
|
-
const qs = flags["include-deleted"] ? "?include_deleted=1" : "";
|
|
1773
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/links${qs}`));
|
|
1774
|
-
return;
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
if (sub === "add") {
|
|
1778
|
-
const { flags } = parseFlags(rest.slice(2));
|
|
1779
|
-
if (!flags.url) throw new Error("Usage: llama deal link add <dealId> --url <url> [--label \"...\"]");
|
|
1780
|
-
print(await request(
|
|
1781
|
-
"POST",
|
|
1782
|
-
`/api/deals/${encodeURIComponent(dealId)}/links`,
|
|
1783
|
-
{ url: String(flags.url), label: flags.label ? String(flags.label) : "" }
|
|
1784
|
-
));
|
|
1785
|
-
return;
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
if (sub === "delete" || sub === "restore") {
|
|
1789
|
-
const linkId = rest[2];
|
|
1790
|
-
if (!linkId) throw new Error(`Usage: llama deal link ${sub} <dealId> <linkId>`);
|
|
1791
|
-
const path = `/api/deals/${encodeURIComponent(dealId)}/links/${encodeURIComponent(linkId)}`;
|
|
1792
|
-
// @core-api-operation DELETE /api/deals/{dealId}/links/{linkId}
|
|
1793
|
-
// @core-api-operation POST /api/deals/{dealId}/links/{linkId}/restore
|
|
1794
|
-
print(await request(
|
|
1795
|
-
sub === "delete" ? "DELETE" : "POST",
|
|
1796
|
-
sub === "delete" ? path : `${path}/restore`
|
|
1797
|
-
));
|
|
1798
|
-
return;
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
throw new Error(`Unknown link sub-command "${sub}". Use list, add, delete, or restore.`);
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// ----- Deal soft-delete / restore / trash list -----
|
|
1805
|
-
// Server side: DELETE /api/deals/:id uses authenticate() (token works).
|
|
1806
|
-
// POST /restore currently uses session-only auth() — known asymmetry,
|
|
1807
|
-
// pending server fix to swap to authenticate() for parity.
|
|
1808
|
-
if (area === "deal" && action === "delete") {
|
|
1809
|
-
const dealId = rest[0];
|
|
1810
|
-
if (!dealId) throw new Error("Usage: llama deal delete <dealId>");
|
|
1811
|
-
print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}`));
|
|
538
|
+
const { flags, positional } = parseFlags(rest, ["state", "limit"]);
|
|
539
|
+
// @core-api-operation GET /api/occam/deals
|
|
540
|
+
print(await request("GET", buildDealSearchPath(positional.join(" ").trim(), flags)));
|
|
1812
541
|
return;
|
|
1813
542
|
}
|
|
1814
|
-
|
|
1815
|
-
if (area === "deal" && action === "restore") {
|
|
543
|
+
if (area === "deal" && action === "read") {
|
|
1816
544
|
const dealId = rest[0];
|
|
1817
|
-
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/restore`));
|
|
1821
|
-
return;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
if (area === "deal" && action === "trash") {
|
|
1825
|
-
print(await request("GET", "/api/deals/deleted"));
|
|
545
|
+
const { flags } = parseFlags(rest.slice(1), ["detail"]);
|
|
546
|
+
// @core-api-operation GET /api/occam/deals/{dealId}
|
|
547
|
+
print(await request("GET", buildDealReadPath(dealId, flags.detail === true ? "overview" : flags.detail || "overview")));
|
|
1826
548
|
return;
|
|
1827
549
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
const { flags } = parseFlags(rest.slice(1), ["file", "idempotency-key"]);
|
|
1833
|
-
if (!dealId || !flags.file || flags.file === true) {
|
|
1834
|
-
throw new Error(
|
|
1835
|
-
"Usage: llama deal ingest <dealId> --file <packet.json> [--idempotency-key <key>]\n" +
|
|
1836
|
-
'Packet: {"source":{"kind":"meeting_note","title":"Office visit"},' +
|
|
1837
|
-
'"facts":[{"category":"team","claim":"..."}],"note":"..."}'
|
|
1838
|
-
);
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
let packet;
|
|
1842
|
-
try {
|
|
1843
|
-
packet = JSON.parse(await readFile(String(flags.file), "utf8"));
|
|
1844
|
-
} catch (error) {
|
|
1845
|
-
throw new Error(`Cannot read ingest packet ${flags.file}: ${error?.message ?? String(error)}`);
|
|
1846
|
-
}
|
|
1847
|
-
if (!packet || Array.isArray(packet) || typeof packet !== "object") {
|
|
1848
|
-
throw new Error("Ingest packet must be a JSON object");
|
|
1849
|
-
}
|
|
1850
|
-
if (flags["idempotency-key"] !== undefined && flags["idempotency-key"] !== true) {
|
|
1851
|
-
packet.idempotencyKey = String(flags["idempotency-key"]);
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
// @core-api-operation POST /api/deals/{dealId}/ingest
|
|
1855
|
-
print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/ingest`, packet));
|
|
550
|
+
if (area === "deal" && ["create", "write"].includes(action)) {
|
|
551
|
+
const { flags } = parseFlags(rest, ["json"]);
|
|
552
|
+
const input = await readJsonInput(flags.json);
|
|
553
|
+
print(await request("POST", "/api/occam/deals/commands", prepareDealCommand(action, input)));
|
|
1856
554
|
return;
|
|
1857
555
|
}
|
|
1858
556
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
const sub = rest[0];
|
|
1862
|
-
const dealId = rest[1];
|
|
1863
|
-
const { flags } = parseFlags(rest.slice(2));
|
|
1864
|
-
|
|
1865
|
-
if (!sub || !dealId) {
|
|
1866
|
-
throw new Error("Usage: llama deal fact list|add|verify <dealId> [...]");
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
if (sub === "list") {
|
|
1870
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/facts`));
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
if (sub === "add") {
|
|
1875
|
-
const claim = flags.claim ?? flags.value;
|
|
1876
|
-
const sourceUrl = flags["source-url"] ?? flags.sourceUrl;
|
|
1877
|
-
if (!flags.category || !claim) {
|
|
1878
|
-
throw new Error(
|
|
1879
|
-
`Usage: llama deal fact add <dealId> --category <cat> --claim "<text>" ` +
|
|
1880
|
-
`[--source "..."] [--source-url <url>] [--confidence high|medium|low] [--attested]`
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1883
|
-
// --attested: the caller takes responsibility that this is accurate
|
|
1884
|
-
// (verified against the source). With it, the fact is recorded as
|
|
1885
|
-
// vouched; without it, it stays unverified. Declare honestly.
|
|
1886
|
-
print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/facts`, {
|
|
1887
|
-
category: String(flags.category),
|
|
1888
|
-
claim: String(claim),
|
|
1889
|
-
source: flags.source ? String(flags.source) : "",
|
|
1890
|
-
...(sourceUrl ? { sourceUrl: String(sourceUrl) } : {}),
|
|
1891
|
-
confidence: flags.confidence ? String(flags.confidence) : "medium",
|
|
1892
|
-
attested: flags.attested === true,
|
|
1893
|
-
}));
|
|
1894
|
-
return;
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
if (sub === "verify") {
|
|
1898
|
-
const factId = rest[2];
|
|
1899
|
-
if (!factId) {
|
|
1900
|
-
throw new Error(
|
|
1901
|
-
`Usage: llama deal fact verify <dealId> <factId> ` +
|
|
1902
|
-
`--status confirmed|disputed [--corrected-value "..."]`
|
|
1903
|
-
);
|
|
1904
|
-
}
|
|
1905
|
-
if (!flags.status || !["confirmed", "disputed"].includes(String(flags.status))) {
|
|
1906
|
-
throw new Error("--status must be 'confirmed' or 'disputed'");
|
|
1907
|
-
}
|
|
1908
|
-
const body = { status: String(flags.status) };
|
|
1909
|
-
if (flags["corrected-value"] !== undefined && flags["corrected-value"] !== true) {
|
|
1910
|
-
body.correctedValue = String(flags["corrected-value"]);
|
|
1911
|
-
}
|
|
1912
|
-
print(await request(
|
|
1913
|
-
"PATCH",
|
|
1914
|
-
`/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(factId)}`,
|
|
1915
|
-
body
|
|
1916
|
-
));
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
// Lift a contest that turned out to be wrong. Contesting a claim removes
|
|
1921
|
-
// it from everything the Deal Agent treats as current; before this existed
|
|
1922
|
-
// there was no way back, so a TRUE claim contested on weak evidence stayed
|
|
1923
|
-
// suppressed in every regenerated brief and memo. `verify --status
|
|
1924
|
-
// confirmed` does NOT do this — trust and contest are separate axes.
|
|
1925
|
-
if (sub === "uncontest") {
|
|
1926
|
-
const factId = rest[2];
|
|
1927
|
-
const reason = flags.reason !== undefined && flags.reason !== true ? String(flags.reason).trim() : "";
|
|
1928
|
-
if (!factId || !reason) {
|
|
1929
|
-
throw new Error(
|
|
1930
|
-
`Usage: llama deal fact uncontest <dealId> <factId> --reason "<why the contest was wrong>"`
|
|
1931
|
-
);
|
|
1932
|
-
}
|
|
1933
|
-
// @core-api-operation POST /api/deals/{dealId}/facts/{factId}/uncontest
|
|
1934
|
-
print(await request(
|
|
1935
|
-
"POST",
|
|
1936
|
-
`/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(factId)}/uncontest`,
|
|
1937
|
-
{ reason }
|
|
1938
|
-
));
|
|
1939
|
-
return;
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
|
-
throw new Error(`Unknown fact sub-command "${sub}". Use list, add, verify, or uncontest.`);
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
// ----- Brief refresh: trigger stale-section re-eval watcher run -----
|
|
1946
|
-
// Server only fires for unlocked sections that are stale per the
|
|
1947
|
-
// freshness policy; --force runs every unlocked watcher-managed section.
|
|
1948
|
-
if (area === "deal" && action === "refresh-brief") {
|
|
1949
|
-
const { flags } = parseFlags(rest);
|
|
1950
|
-
const dealId = rest[0];
|
|
1951
|
-
if (!dealId) throw new Error("Usage: llama deal refresh-brief <dealId> [--force]");
|
|
1952
|
-
const qs = flags.force ? "?force=true" : "";
|
|
1953
|
-
print(await request("POST", `/api/deals/${encodeURIComponent(dealId)}/refresh-brief${qs}`));
|
|
557
|
+
if (area === "wiki") {
|
|
558
|
+
await handleWiki(action, rest);
|
|
1954
559
|
return;
|
|
1955
560
|
}
|
|
1956
561
|
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
// Logs `brief_reverted` in deal_events. Section keys are the legacy
|
|
1961
|
-
// top-level sections, not block ids.
|
|
1962
|
-
if (area === "deal" && action === "revert-run") {
|
|
562
|
+
if (area === "admin") {
|
|
563
|
+
const allowed = ["auth-events", "deal-events", "agent-events"];
|
|
564
|
+
if (!allowed.includes(action)) throw new Error(`Admin actions: ${allowed.join(", ")}.`);
|
|
1963
565
|
const { flags } = parseFlags(rest);
|
|
1964
|
-
const
|
|
1965
|
-
const
|
|
1966
|
-
|
|
1967
|
-
if (!dealId || !runId || !flags.section) {
|
|
1968
|
-
throw new Error(
|
|
1969
|
-
`Usage: llama deal revert-run <dealId> <runId> --section ${valid.join("|")}`
|
|
1970
|
-
);
|
|
1971
|
-
}
|
|
1972
|
-
if (!valid.includes(String(flags.section))) {
|
|
1973
|
-
throw new Error(`--section must be one of ${valid.join(", ")}`);
|
|
1974
|
-
}
|
|
1975
|
-
print(await request(
|
|
1976
|
-
"POST",
|
|
1977
|
-
`/api/deals/${encodeURIComponent(dealId)}/agent-runs/${encodeURIComponent(runId)}/revert`,
|
|
1978
|
-
{ section: String(flags.section) }
|
|
1979
|
-
));
|
|
1980
|
-
return;
|
|
1981
|
-
}
|
|
1982
|
-
|
|
1983
|
-
if (area === "approvals" && action === "list") {
|
|
1984
|
-
print(await request("GET", "/api/partner/approvals"));
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
if (area === "approvals" && action === "decide") {
|
|
1989
|
-
const { flags, positional } = parseFlags(rest);
|
|
1990
|
-
const approvalId = Number(positional[0]);
|
|
1991
|
-
const decision = positional[1];
|
|
1992
|
-
if (!Number.isFinite(approvalId) || !["approved", "rejected"].includes(decision)) {
|
|
1993
|
-
throw new Error("Usage: llama approvals decide <approvalId> approved|rejected [--note ...]");
|
|
1994
|
-
}
|
|
1995
|
-
print(await request("POST", "/api/partner/approvals", {
|
|
1996
|
-
approvalId,
|
|
1997
|
-
decision,
|
|
1998
|
-
note: flags.note || "",
|
|
1999
|
-
}));
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
// ----- Ownership: self-claim -----
|
|
2004
|
-
if (area === "claim") {
|
|
2005
|
-
const dealId = action; // second positional
|
|
2006
|
-
const { flags } = parseFlags(rest, ["reason"]);
|
|
2007
|
-
const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
|
|
2008
|
-
if (!dealId || !reason) {
|
|
2009
|
-
throw new Error('Usage: llama claim <dealId> --reason "<why this person should own it>"');
|
|
2010
|
-
}
|
|
2011
|
-
const me = await request("GET", "/api/me");
|
|
2012
|
-
print(await request(
|
|
2013
|
-
"POST",
|
|
2014
|
-
`/api/deals/${encodeURIComponent(dealId)}/propose-owner`,
|
|
2015
|
-
{ userId: me.id, reason }
|
|
2016
|
-
));
|
|
2017
|
-
return;
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
// ----- Ownership: partner nominates someone else -----
|
|
2021
|
-
if (area === "nominate") {
|
|
2022
|
-
const dealId = action;
|
|
2023
|
-
const { flags } = parseFlags(rest, ["user", "reason"]);
|
|
2024
|
-
const userId = Number(flags.user);
|
|
2025
|
-
const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
|
|
2026
|
-
if (!dealId || !Number.isFinite(userId) || !reason) {
|
|
2027
|
-
throw new Error('Usage: llama nominate <dealId> --user <userId> --reason "<why>"');
|
|
566
|
+
const params = new URLSearchParams();
|
|
567
|
+
for (const key of ["kind", "actor", "subject", "since", "limit", "offset", "deal", "tool"]) {
|
|
568
|
+
if (flags[key] && flags[key] !== true) params.set(key, String(flags[key]));
|
|
2028
569
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
// ----- Nominations inbox (for the nominee) -----
|
|
2038
|
-
if (area === "nominations" && action === "list") {
|
|
2039
|
-
print(await request("GET", "/api/me/nominations"));
|
|
570
|
+
if (flags["agent-kind"] && flags["agent-kind"] !== true) params.set("agent_kind", String(flags["agent-kind"]));
|
|
571
|
+
if (flags["errors-only"]) params.set("errors_only", "1");
|
|
572
|
+
// @core-api-operation GET /api/admin/auth-events
|
|
573
|
+
// @core-api-operation GET /api/admin/deal-events
|
|
574
|
+
// @core-api-operation GET /api/admin/agent-events
|
|
575
|
+
print(await request("GET", `/api/admin/${action}${params.size ? `?${params}` : ""}`));
|
|
2040
576
|
return;
|
|
2041
577
|
}
|
|
2042
|
-
if (area === "nominations" && action === "decide") {
|
|
2043
|
-
const approvalId = Number(rest[0]);
|
|
2044
|
-
const decision = rest[1];
|
|
2045
|
-
if (!Number.isFinite(approvalId) || !["accepted", "declined"].includes(decision)) {
|
|
2046
|
-
throw new Error("Usage: llama nominations decide <approvalId> accepted|declined");
|
|
2047
|
-
}
|
|
2048
|
-
print(await request("POST", `/api/nominations/${approvalId}`, { decision }));
|
|
2049
|
-
return;
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
// ----- Timeline -----
|
|
2053
|
-
if (area === "timeline") {
|
|
2054
|
-
const dealId = action;
|
|
2055
|
-
if (!dealId) throw new Error("Usage: llama timeline <dealId>");
|
|
2056
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/timeline`));
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// ----- Post to timeline -----
|
|
2061
|
-
if (area === "post") {
|
|
2062
|
-
const dealId = action;
|
|
2063
|
-
const { flags, positional } = parseFlags(rest);
|
|
2064
|
-
const body = positional[0];
|
|
2065
|
-
if (!dealId || !body) {
|
|
2066
|
-
throw new Error(`Usage: llama post <dealId> "message body" [--link url] [--link-name "name"] [--cue]`);
|
|
2067
|
-
}
|
|
2068
|
-
const attachments = flags.link
|
|
2069
|
-
? [{ url: String(flags.link), name: flags["link-name"] ? String(flags["link-name"]) : String(flags.link) }]
|
|
2070
|
-
: [];
|
|
2071
|
-
print(await request(
|
|
2072
|
-
"POST",
|
|
2073
|
-
`/api/deals/${encodeURIComponent(dealId)}/posts`,
|
|
2074
|
-
{ body, attachments, cue_authorized: flags.cue === true }
|
|
2075
|
-
));
|
|
2076
|
-
return;
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
// ----- Wiki: search -----
|
|
2080
|
-
if (area === "wiki" && action === "search") {
|
|
2081
|
-
const { positional } = parseFlags(rest);
|
|
2082
|
-
const q = positional.join(" ").trim();
|
|
2083
|
-
if (!q) throw new Error("Usage: llama wiki search <query>");
|
|
2084
|
-
print(await request("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`));
|
|
2085
|
-
return;
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// ----- Wiki: read a single article (EN by default) -----
|
|
2089
|
-
// Hits /api/wiki/<slug> directly. Earlier versions did a fuzzy
|
|
2090
|
-
// /api/wiki/search call and filtered for an exact slug match — that
|
|
2091
|
-
// missed any article whose slug-as-string didn't appear in title or
|
|
2092
|
-
// content (e.g. a slug like "foo-bar" against an article titled "Foo Bar"),
|
|
2093
|
-
// so a real article would print as "not found" even though it existed.
|
|
2094
|
-
if (area === "wiki" && action === "read") {
|
|
2095
|
-
const { flags, positional } = parseFlags(rest);
|
|
2096
|
-
const slug = positional[0];
|
|
2097
|
-
if (!slug) throw new Error("Usage: llama wiki read <slug> [--lang en|zh]");
|
|
2098
|
-
const lang = flags.lang === "zh" ? "zh" : "en";
|
|
2099
|
-
const path = `/api/wiki/${encodeURIComponent(slug)}?lang=${lang}`;
|
|
2100
|
-
// @core-api-operation GET /api/wiki/{slug}
|
|
2101
|
-
print(await request("GET", path));
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
// ----- Wiki: save (create or update) -----
|
|
2106
|
-
// Two body modes:
|
|
2107
|
-
// --content "..." inline markdown OR raw HTML (string)
|
|
2108
|
-
// --file <path> read body from a file; if .html/.htm,
|
|
2109
|
-
// content_type auto-detects to 'html'
|
|
2110
|
-
// --content-type <markdown|html> overrides auto-detect.
|
|
2111
|
-
// Refuses content_type mismatch on existing entries (server-side check;
|
|
2112
|
-
// CLI surfaces the server error verbatim).
|
|
2113
|
-
if (area === "wiki" && action === "save") {
|
|
2114
|
-
const { flags, positional } = parseFlags(rest);
|
|
2115
|
-
const slug = positional[0];
|
|
2116
|
-
const title = flags.title;
|
|
2117
|
-
const inlineContent = flags.content;
|
|
2118
|
-
const filePath = flags.file;
|
|
2119
|
-
const sourcesRaw = flags.sources;
|
|
2120
|
-
if (!slug || !title || !sourcesRaw || (!inlineContent && !filePath)) {
|
|
2121
|
-
throw new Error(
|
|
2122
|
-
`Usage:
|
|
2123
|
-
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
|
|
2124
|
-
or
|
|
2125
|
-
llama wiki save <slug> --title "..." --file path/to/article.{md,html} --sources "url1;url2" [--type company] [--related "A;B"] [--lang en|zh] [--content-type markdown|html]
|
|
2126
|
-
|
|
2127
|
-
Pass either --content (inline) or --file (read from disk). With --file, content_type auto-detects from extension (.html/.htm → html, else markdown). Use --content-type to override.
|
|
2128
|
-
|
|
2129
|
-
Routing — is this the right command?
|
|
2130
|
-
✓ Cross-deal / institutional knowledge (sector landscape, market map, thesis, framework, methodology)
|
|
2131
|
-
→ YES, you're in the right place.
|
|
2132
|
-
✗ Deal-specific HTML (IC memo for X, dashboard for X, 2×2 for one company)
|
|
2133
|
-
→ use \`llama html upload <dealId> --new --title "..." --file <path>\` instead.
|
|
2134
|
-
✗ Founder-facing public share link
|
|
2135
|
-
→ escape to Netlify only when the user explicitly says "share publicly" / "give it to the founder";
|
|
2136
|
-
Llama Command outranks Netlify for everything internal.`
|
|
2137
|
-
);
|
|
2138
|
-
}
|
|
2139
|
-
if (inlineContent && filePath) {
|
|
2140
|
-
throw new Error("Pass either --content OR --file, not both.");
|
|
2141
|
-
}
|
|
2142
|
-
// Read body — either inline or from file.
|
|
2143
|
-
let body;
|
|
2144
|
-
let inferredType = "markdown";
|
|
2145
|
-
if (filePath) {
|
|
2146
|
-
const { readFileSync } = await import("fs");
|
|
2147
|
-
body = readFileSync(String(filePath), "utf-8");
|
|
2148
|
-
const lower = String(filePath).toLowerCase();
|
|
2149
|
-
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
|
|
2150
|
-
inferredType = "html";
|
|
2151
|
-
}
|
|
2152
|
-
} else {
|
|
2153
|
-
body = String(inlineContent);
|
|
2154
|
-
}
|
|
2155
|
-
// Determine content_type: explicit flag wins over file-extension inference.
|
|
2156
|
-
let contentType = inferredType;
|
|
2157
|
-
if (flags["content-type"]) {
|
|
2158
|
-
const v = String(flags["content-type"]).toLowerCase();
|
|
2159
|
-
if (v !== "markdown" && v !== "html") {
|
|
2160
|
-
throw new Error(`--content-type must be 'markdown' or 'html', got "${v}"`);
|
|
2161
|
-
}
|
|
2162
|
-
contentType = v;
|
|
2163
|
-
}
|
|
2164
|
-
const splitCsv = (v) => String(v).split(/[;|]/).map((s) => s.trim()).filter(Boolean);
|
|
2165
|
-
const payload = {
|
|
2166
|
-
slug,
|
|
2167
|
-
title: String(title),
|
|
2168
|
-
content: body,
|
|
2169
|
-
sources: splitCsv(sourcesRaw),
|
|
2170
|
-
type: flags.type ? String(flags.type) : undefined,
|
|
2171
|
-
related: flags.related ? splitCsv(flags.related) : undefined,
|
|
2172
|
-
lang: flags.lang === "zh" ? "zh" : "en",
|
|
2173
|
-
status: flags.status ? String(flags.status) : undefined,
|
|
2174
|
-
content_type: contentType,
|
|
2175
|
-
};
|
|
2176
|
-
print(await request("POST", "/api/wiki/save", payload));
|
|
2177
|
-
return;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// ----- Wiki: delete (soft) / restore -----
|
|
2181
|
-
// Soft-delete (CONSTITUTION §8 reversible). For HTML entries the
|
|
2182
|
-
// sentinel deal_browse_html body + assets are soft-deleted too;
|
|
2183
|
-
// `llama wiki restore <slug>` brings it all back.
|
|
2184
|
-
if (area === "wiki" && (action === "delete" || action === "restore")) {
|
|
2185
|
-
const { flags, positional } = parseFlags(rest);
|
|
2186
|
-
const slug = positional[0];
|
|
2187
|
-
if (!slug) throw new Error(`Usage: llama wiki ${action} <slug> [--lang en|zh]`);
|
|
2188
|
-
const lang = flags.lang === "zh" ? "zh" : "en";
|
|
2189
|
-
const qs = `?lang=${lang}`;
|
|
2190
|
-
if (action === "delete") {
|
|
2191
|
-
print(await request("DELETE", `/api/wiki/${encodeURIComponent(slug)}${qs}`));
|
|
2192
|
-
} else {
|
|
2193
|
-
print(await request("POST", `/api/wiki/${encodeURIComponent(slug)}/restore${qs}`));
|
|
2194
|
-
}
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
// ----- Brief blocks: list / add-* / edit / delete -----
|
|
2199
|
-
// The block-based deal brief stores an ordered array of typed blocks
|
|
2200
|
-
// (text / link / embed / callout) per deal. These commands wrap the
|
|
2201
|
-
// /api/deals/:id/blocks{,/:id} endpoints. To add a block we read +
|
|
2202
|
-
// append + PUT (two roundtrips); single-block edit + delete have
|
|
2203
|
-
// dedicated PATCH/DELETE endpoints.
|
|
2204
|
-
if (area === "brief" && action === "blocks") {
|
|
2205
|
-
const dealId = rest[0];
|
|
2206
|
-
if (!dealId) throw new Error("Usage: llama brief blocks <dealId>");
|
|
2207
|
-
print(await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`));
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
// Per-block read — pairs with the manifest returned by /command-center
|
|
2212
|
-
// (i.e. `llama deal show`). Agent flow: read manifest → pick blocks
|
|
2213
|
-
// by id → fetch only those bodies, instead of pulling the full array.
|
|
2214
|
-
if (area === "brief" && action === "block") {
|
|
2215
|
-
const dealId = rest[0];
|
|
2216
|
-
const blockId = rest[1];
|
|
2217
|
-
if (!dealId || !blockId) throw new Error("Usage: llama brief block <dealId> <blockId>");
|
|
2218
|
-
print(await request(
|
|
2219
|
-
"GET",
|
|
2220
|
-
`/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`
|
|
2221
|
-
));
|
|
2222
|
-
return;
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
if (area === "brief" && action?.startsWith("add-")) {
|
|
2226
|
-
const type = action.slice(4); // "add-text" → "text"
|
|
2227
|
-
if (!["text", "link", "embed", "callout"].includes(type)) {
|
|
2228
|
-
throw new Error(`Unknown block type "${type}". Use add-text, add-link, add-embed, or add-callout.`);
|
|
2229
|
-
}
|
|
2230
|
-
const { flags } = parseFlags(rest);
|
|
2231
|
-
const dealId = rest[0];
|
|
2232
|
-
if (!dealId) throw new Error(`Usage: llama brief add-${type} <dealId> [...flags]`);
|
|
2233
|
-
|
|
2234
|
-
const id = (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
|
2235
|
-
? crypto.randomUUID()
|
|
2236
|
-
: `b_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2237
|
-
const meta = { updated_at: new Date().toISOString(), updated_by: "cli", by_agent: false };
|
|
2238
|
-
|
|
2239
|
-
// --source-section <key>: target a structured section (e.g. team /
|
|
2240
|
-
// highlights / competitors). Without this, blocks land in the
|
|
2241
|
-
// "_other" group at the bottom of the TOC. AI writers want this
|
|
2242
|
-
// virtually always — without it they cannot contribute to existing
|
|
2243
|
-
// structured sections.
|
|
2244
|
-
if (flags["source-section"]) {
|
|
2245
|
-
meta.sourceSection = String(flags["source-section"]);
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
// --reply-to <blockId>: snapshot the parent block's heading + a
|
|
2249
|
-
// 200-char excerpt into meta so the back-link survives parent edits
|
|
2250
|
-
// or deletion. CLI only — replies are always text in the UI; allow
|
|
2251
|
-
// any add-* type here for symmetry but the UI only renders the
|
|
2252
|
-
// back-link on text + callout blocks today.
|
|
2253
|
-
let cur = null;
|
|
2254
|
-
if (flags["reply-to"]) {
|
|
2255
|
-
const replyTo = String(flags["reply-to"]);
|
|
2256
|
-
cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
|
|
2257
|
-
const parent = (cur.blocks ?? []).find((b) => b.id === replyTo);
|
|
2258
|
-
if (!parent) throw new Error(`--reply-to: block ${replyTo} not found on deal ${dealId}`);
|
|
2259
|
-
meta.reply_to = parent.id;
|
|
2260
|
-
// heading: text/callout → heading; link/embed → label; fallback "(untitled block)"
|
|
2261
|
-
meta.reply_to_heading =
|
|
2262
|
-
parent.heading || parent.label || "(untitled block)";
|
|
2263
|
-
// excerpt: text/callout → heading + body; link → label + description; embed → label
|
|
2264
|
-
const excerptParts =
|
|
2265
|
-
parent.type === "text" || parent.type === "callout"
|
|
2266
|
-
? [parent.heading, parent.body]
|
|
2267
|
-
: parent.type === "link"
|
|
2268
|
-
? [parent.label, parent.description]
|
|
2269
|
-
: [parent.label];
|
|
2270
|
-
const excerpt = excerptParts.filter(Boolean).join("\n\n").slice(0, 200);
|
|
2271
|
-
meta.reply_to_excerpt = excerpt;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
let block;
|
|
2275
|
-
if (type === "text") {
|
|
2276
|
-
block = { id, type, heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
|
|
2277
|
-
} else if (type === "link") {
|
|
2278
|
-
if (!flags.url || !flags.label) throw new Error("add-link requires --url and --label");
|
|
2279
|
-
block = { id, type, url: String(flags.url), label: String(flags.label), description: flags.description ? String(flags.description) : undefined, meta };
|
|
2280
|
-
} else if (type === "embed") {
|
|
2281
|
-
if (!flags.url) throw new Error("add-embed requires --url");
|
|
2282
|
-
block = { id, type, url: String(flags.url), label: flags.label ? String(flags.label) : undefined, meta };
|
|
2283
|
-
} else {
|
|
2284
|
-
block = { id, type, tone: flags.tone ? String(flags.tone) : "insight", heading: flags.heading ? String(flags.heading) : "", body: flags.body ? String(flags.body) : "", meta };
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
// --position top|bottom (default: top). Top matches the UI behavior
|
|
2288
|
-
// changed 2026-05-03 — newly added blocks land at the top of the
|
|
2289
|
-
// brief so the writer (or reader) sees the contribution without
|
|
2290
|
-
// scrolling. Pass `--position bottom` to append, e.g. for batched
|
|
2291
|
-
// AI writes that should preserve insertion order.
|
|
2292
|
-
const position = flags.position ? String(flags.position) : "top";
|
|
2293
|
-
if (position !== "top" && position !== "bottom") {
|
|
2294
|
-
throw new Error(`--position must be "top" or "bottom" (got "${position}")`);
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
if (!cur) cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
|
|
2298
|
-
const existing = cur.blocks ?? [];
|
|
2299
|
-
const next = position === "top" ? [block, ...existing] : [...existing, block];
|
|
2300
|
-
print(await request("PUT", `/api/deals/${encodeURIComponent(dealId)}/blocks`, {
|
|
2301
|
-
blocks: next,
|
|
2302
|
-
cue_authorized: flags.cue === true,
|
|
2303
|
-
}));
|
|
2304
|
-
console.log(`Created block ${id}`);
|
|
2305
|
-
return;
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
if (area === "brief" && action === "edit") {
|
|
2309
|
-
const { flags } = parseFlags(rest);
|
|
2310
|
-
const dealId = rest[0];
|
|
2311
|
-
const blockId = rest[1];
|
|
2312
|
-
if (!dealId || !blockId) {
|
|
2313
|
-
throw new Error("Usage: llama brief edit <dealId> <blockId> [--heading ...] [--body ...] [--url ...] [--label ...] [--description ...] [--tone ...] [--source-section ...] [--lock|--unlock] [--hide|--unhide] [--cue]");
|
|
2314
|
-
}
|
|
2315
|
-
const patch = {};
|
|
2316
|
-
for (const k of ["heading", "body", "url", "label", "description", "tone"]) {
|
|
2317
|
-
if (flags[k] !== undefined && flags[k] !== true) patch[k] = String(flags[k]);
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
// Meta toggles. The PATCH endpoint accepts a meta object that gets
|
|
2321
|
-
// merged with the existing block.meta server-side, so we only need
|
|
2322
|
-
// to send the keys we want to change. lock/hide flags are pure
|
|
2323
|
-
// toggles (no value); source-section takes a key.
|
|
2324
|
-
const metaPatch = {};
|
|
2325
|
-
if (flags.lock === true) metaPatch.locked = true;
|
|
2326
|
-
if (flags.unlock === true) metaPatch.locked = false;
|
|
2327
|
-
if (flags.hide === true) metaPatch.hidden = true;
|
|
2328
|
-
if (flags.unhide === true) metaPatch.hidden = false;
|
|
2329
|
-
if (flags["source-section"] !== undefined && flags["source-section"] !== true) {
|
|
2330
|
-
metaPatch.sourceSection = String(flags["source-section"]);
|
|
2331
|
-
}
|
|
2332
|
-
if (Object.keys(metaPatch).length > 0) patch.meta = metaPatch;
|
|
2333
|
-
if (flags.cue === true) patch.cue_authorized = true;
|
|
2334
|
-
|
|
2335
|
-
if (Object.keys(patch).length === 0 || (Object.keys(patch).length === 1 && patch.cue_authorized === true)) {
|
|
2336
|
-
throw new Error("at least one field flag required");
|
|
2337
|
-
}
|
|
2338
|
-
print(await request("PATCH", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`, patch));
|
|
2339
|
-
return;
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
if (area === "brief" && action === "delete") {
|
|
2343
|
-
const dealId = rest[0];
|
|
2344
|
-
const blockId = rest[1];
|
|
2345
|
-
if (!dealId || !blockId) throw new Error("Usage: llama brief delete <dealId> <blockId>");
|
|
2346
|
-
print(await request("DELETE", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`));
|
|
2347
|
-
return;
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
if (area === "brief" && action === "restore") {
|
|
2351
|
-
const dealId = rest[0];
|
|
2352
|
-
const blockId = rest[1];
|
|
2353
|
-
if (!dealId || !blockId) throw new Error("Usage: llama brief restore <dealId> <blockId>");
|
|
2354
|
-
print(await request(
|
|
2355
|
-
"POST",
|
|
2356
|
-
`/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/restore`
|
|
2357
|
-
));
|
|
2358
|
-
return;
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
// Per-block content history (Wikipedia model — every overwrite snapshots
|
|
2362
|
-
// the prev full block JSON). Two sub-actions: list versions, and restore
|
|
2363
|
-
// a specific version. Restore is itself reversible — when you restore
|
|
2364
|
-
// version N, the OUTGOING version (the one being replaced) gets snapshotted
|
|
2365
|
-
// into history, so undoing a wrong restore is one more `restore-version`
|
|
2366
|
-
// call away.
|
|
2367
|
-
if (area === "brief" && action === "history") {
|
|
2368
|
-
const { flags } = parseFlags(rest);
|
|
2369
|
-
const dealId = rest[0];
|
|
2370
|
-
const blockId = rest[1];
|
|
2371
|
-
if (!dealId || !blockId) {
|
|
2372
|
-
throw new Error("Usage: llama brief history <dealId> <blockId> [--limit 50]");
|
|
2373
|
-
}
|
|
2374
|
-
const params = new URLSearchParams();
|
|
2375
|
-
if (flags.limit) params.set("limit", String(flags.limit));
|
|
2376
|
-
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
2377
|
-
print(await request(
|
|
2378
|
-
"GET",
|
|
2379
|
-
`/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history${qs}`
|
|
2380
|
-
));
|
|
2381
|
-
return;
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
if (area === "brief" && action === "restore-version") {
|
|
2385
|
-
const dealId = rest[0];
|
|
2386
|
-
const blockId = rest[1];
|
|
2387
|
-
const historyId = rest[2];
|
|
2388
|
-
if (!dealId || !blockId || !historyId) {
|
|
2389
|
-
throw new Error("Usage: llama brief restore-version <dealId> <blockId> <historyId>\n" +
|
|
2390
|
-
" Find <historyId> via `llama brief history <dealId> <blockId>`");
|
|
2391
|
-
}
|
|
2392
|
-
const idNum = Number(historyId);
|
|
2393
|
-
if (!Number.isFinite(idNum)) throw new Error(`<historyId> must be a number, got "${historyId}"`);
|
|
2394
|
-
print(await request(
|
|
2395
|
-
"POST",
|
|
2396
|
-
`/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history`,
|
|
2397
|
-
{ history_id: idNum }
|
|
2398
|
-
));
|
|
2399
|
-
return;
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
// ----- Admin (system admin only) -----
|
|
2403
|
-
// System-admin gated commands — server enforces via isSystemAdmin()
|
|
2404
|
-
// checking LLAMA_COMMAND_ADMIN_EMAILS env. Non-admin tokens get 403.
|
|
2405
|
-
// CLI doesn't pre-check; if you can't run these, ask the system admin
|
|
2406
|
-
// to mint you an admin token (rare — most ops should never need this surface).
|
|
2407
|
-
//
|
|
2408
|
-
// The three event feeds map 1:1 to the /admin web console tabs:
|
|
2409
|
-
// - auth-events : signin / token / impersonation audit (security)
|
|
2410
|
-
// - deal-events : every field/owner/brief change cross-deal (business)
|
|
2411
|
-
// - agent-events : every AI tool call / loop_stalled / max_turns (AI ops)
|
|
2412
|
-
if (area === "admin") {
|
|
2413
|
-
const sub = action;
|
|
2414
|
-
const valid = ["workflow", "auth-events", "deal-events", "agent-events"];
|
|
2415
|
-
if (!valid.includes(sub)) {
|
|
2416
|
-
throw new Error(
|
|
2417
|
-
`Unknown admin sub-command "${sub || ""}". Use: ${valid.join(", ")}`
|
|
2418
|
-
);
|
|
2419
|
-
}
|
|
2420
|
-
if (sub === "workflow") {
|
|
2421
|
-
if (!["audit", "remediate"].includes(rest[0])) {
|
|
2422
|
-
throw new Error("Usage: llama admin workflow audit|remediate ...");
|
|
2423
|
-
}
|
|
2424
|
-
if (rest[0] === "audit") {
|
|
2425
|
-
const { flags } = parseFlags(rest.slice(1), ["deal", "all"]);
|
|
2426
|
-
// @core-api-operation GET /api/admin/workflow-audit
|
|
2427
|
-
print(await request("GET", workflowAuditPath(flags)));
|
|
2428
|
-
return;
|
|
2429
|
-
}
|
|
2430
|
-
const { flags } = parseFlags(rest.slice(1), [
|
|
2431
|
-
"deal",
|
|
2432
|
-
"guard",
|
|
2433
|
-
"apply",
|
|
2434
|
-
"expected-revision",
|
|
2435
|
-
"reason",
|
|
2436
|
-
]);
|
|
2437
|
-
const body = workflowRemediationBody(
|
|
2438
|
-
flags,
|
|
2439
|
-
flags.apply === true ? `cli-workflow-remediation-${randomUUID()}` : undefined,
|
|
2440
|
-
);
|
|
2441
|
-
// @core-api-operation POST /api/admin/workflow-remediation
|
|
2442
|
-
print(await request("POST", WORKFLOW_REMEDIATION_PATH, body));
|
|
2443
|
-
return;
|
|
2444
|
-
}
|
|
2445
|
-
const { flags } = parseFlags(rest);
|
|
2446
|
-
const params = new URLSearchParams();
|
|
2447
|
-
// Common filters across all three.
|
|
2448
|
-
if (flags.kind) params.set("kind", String(flags.kind));
|
|
2449
|
-
if (flags.actor) params.set("actor", String(flags.actor));
|
|
2450
|
-
if (flags.subject) params.set("subject", String(flags.subject));
|
|
2451
|
-
if (flags.since) params.set("since", String(flags.since));
|
|
2452
|
-
if (flags.limit) params.set("limit", String(flags.limit));
|
|
2453
|
-
if (flags.offset) params.set("offset", String(flags.offset));
|
|
2454
|
-
// Per-feed extras.
|
|
2455
|
-
if (sub === "deal-events" && flags.deal) params.set("deal", String(flags.deal));
|
|
2456
|
-
if (sub === "agent-events") {
|
|
2457
|
-
if (flags["agent-kind"]) params.set("agent_kind", String(flags["agent-kind"]));
|
|
2458
|
-
if (flags.tool) params.set("tool", String(flags.tool));
|
|
2459
|
-
if (flags.deal) params.set("deal", String(flags.deal));
|
|
2460
|
-
if (flags["errors-only"]) params.set("errors_only", "1");
|
|
2461
|
-
}
|
|
2462
|
-
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
2463
|
-
// @core-api-operation GET /api/admin/auth-events
|
|
2464
|
-
// @core-api-operation GET /api/admin/deal-events
|
|
2465
|
-
// @core-api-operation GET /api/admin/agent-events
|
|
2466
|
-
print(await request("GET", `/api/admin/${sub}${qs}`));
|
|
2467
|
-
return;
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
// ----- Mentions / Inbox -----
|
|
2471
|
-
// Server stores @-cues parsed out of brief blocks and posts in the
|
|
2472
|
-
// `deal_mentions` table. UNIQUE per (source_kind, source_id, user)
|
|
2473
|
-
// means re-saving a block that already cued someone won't re-fire
|
|
2474
|
-
// the email; resolution is mutual-observability (anyone can mark a
|
|
2475
|
-
// thread resolved, we record who).
|
|
2476
|
-
//
|
|
2477
|
-
// The CLI has no direct create — to "mention someone", write
|
|
2478
|
-
// `@FirstName` (or `@email@llamaventures.vc`) inside a brief block
|
|
2479
|
-
// body or a deal post. Hooks server-side do the rest.
|
|
2480
|
-
if (area === "mentions") {
|
|
2481
|
-
const sub = action || "list";
|
|
2482
|
-
if (sub === "list" || sub === undefined) {
|
|
2483
|
-
const { flags } = parseFlags(rest);
|
|
2484
|
-
const params = new URLSearchParams();
|
|
2485
|
-
if (flags.everyone) params.set("everyone", "1");
|
|
2486
|
-
else params.set("for_me", "1");
|
|
2487
|
-
if (!flags.all) params.set("unresolved", "1");
|
|
2488
|
-
print(await request("GET", `/api/mentions?${params.toString()}`));
|
|
2489
|
-
return;
|
|
2490
|
-
}
|
|
2491
|
-
if (sub === "show") {
|
|
2492
|
-
const id = rest[0];
|
|
2493
|
-
if (!id) throw new Error("Usage: llama mentions show <mentionId>");
|
|
2494
|
-
// No dedicated single-row endpoint — fetch all and filter. Cheap
|
|
2495
|
-
// (mentions table is small) and avoids a roundtrip endpoint.
|
|
2496
|
-
const data = await request("GET", "/api/mentions?everyone=1");
|
|
2497
|
-
const row = (data.mentions ?? []).find((m) => String(m.id) === String(id));
|
|
2498
|
-
if (!row) throw new Error(`mention ${id} not found`);
|
|
2499
|
-
print(row);
|
|
2500
|
-
return;
|
|
2501
|
-
}
|
|
2502
|
-
if (sub === "resolve") {
|
|
2503
|
-
const id = rest[0];
|
|
2504
|
-
if (!id) throw new Error("Usage: llama mentions resolve <mentionId>");
|
|
2505
|
-
print(await request("POST", `/api/mentions/${encodeURIComponent(id)}/resolve`));
|
|
2506
|
-
return;
|
|
2507
|
-
}
|
|
2508
|
-
if (sub === "unread") {
|
|
2509
|
-
print(await request("GET", "/api/mentions/unread-count"));
|
|
2510
|
-
return;
|
|
2511
|
-
}
|
|
2512
|
-
throw new Error(`Unknown mentions subcommand "${sub}". Use: list / show / resolve / unread.`);
|
|
2513
|
-
}
|
|
2514
|
-
|
|
2515
|
-
// ----- Memo (long-form HTML investment memo) -----
|
|
2516
|
-
// The Memo tab in the deal page renders HTML stored in deal_memos.
|
|
2517
|
-
// Two sources of memo content:
|
|
2518
|
-
// - composed: generated by the server-side memo composer on demand
|
|
2519
|
-
// - manual: a hand-written HTML you paste in
|
|
2520
|
-
// Manual always beats composed on read; reset to drop the manual row
|
|
2521
|
-
// and fall back to the composed one.
|
|
2522
|
-
if (area === "memo") {
|
|
2523
|
-
const sub = action;
|
|
2524
|
-
|
|
2525
|
-
// show — fetch the current memo. Default: print HTML to stdout
|
|
2526
|
-
// (pipeable to file or browser). --out writes to a path. --json
|
|
2527
|
-
// returns the full envelope (memo + mode + inflight info).
|
|
2528
|
-
if (sub === "show") {
|
|
2529
|
-
const dealId = rest[0];
|
|
2530
|
-
if (!dealId) {
|
|
2531
|
-
throw new Error("Usage: llama memo show <dealId> [--out <path>] [--json]");
|
|
2532
|
-
}
|
|
2533
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
2534
|
-
const data = await request(
|
|
2535
|
-
"GET",
|
|
2536
|
-
`/api/deals/${encodeURIComponent(dealId)}/memo`
|
|
2537
|
-
);
|
|
2538
|
-
if (flags.json) {
|
|
2539
|
-
print(data);
|
|
2540
|
-
return;
|
|
2541
|
-
}
|
|
2542
|
-
const html = data?.memo?.html;
|
|
2543
|
-
if (!html) {
|
|
2544
|
-
if (data?.requires_compose) {
|
|
2545
|
-
throw new Error(
|
|
2546
|
-
"No memo for this deal yet — run `llama memo regenerate <dealId>` to compose one."
|
|
2547
|
-
);
|
|
2548
|
-
}
|
|
2549
|
-
throw new Error("Memo response missing html field.");
|
|
2550
|
-
}
|
|
2551
|
-
if (flags.out) {
|
|
2552
|
-
const { writeFileSync } = await import("fs");
|
|
2553
|
-
writeFileSync(String(flags.out), html);
|
|
2554
|
-
console.error(`Wrote ${html.length} bytes → ${flags.out}`);
|
|
2555
|
-
return;
|
|
2556
|
-
}
|
|
2557
|
-
// Stdout — supports `llama memo show <id> > memo.html` and piping
|
|
2558
|
-
// to e.g. `open -f -a Safari` for quick preview.
|
|
2559
|
-
process.stdout.write(html);
|
|
2560
|
-
return;
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
// regenerate — kick off the server-side composer. Streams panel
|
|
2564
|
-
// progress events to stderr so you can see live status; prints
|
|
2565
|
-
// final summary JSON (version, model, duration) to stdout.
|
|
2566
|
-
if (sub === "regenerate") {
|
|
2567
|
-
const dealId = rest[0];
|
|
2568
|
-
if (!dealId) {
|
|
2569
|
-
throw new Error(
|
|
2570
|
-
'Usage: llama memo regenerate <dealId> [--opus] [--instructions "..."]'
|
|
2571
|
-
);
|
|
2572
|
-
}
|
|
2573
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
2574
|
-
const tier = flags.opus ? "opus" : "sonnet";
|
|
2575
|
-
const authHeaders = await getAuthHeaders();
|
|
2576
|
-
if (Object.keys(authHeaders).length === 0) {
|
|
2577
|
-
throw new Error(
|
|
2578
|
-
"Not authenticated. Run `gcloud auth login` or `llama token set <llc_...>` first."
|
|
2579
|
-
);
|
|
2580
|
-
}
|
|
2581
|
-
const res = await fetch(
|
|
2582
|
-
`${getBaseUrl()}/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
2583
|
-
{
|
|
2584
|
-
method: "POST",
|
|
2585
|
-
headers: { "Content-Type": "application/json", ...authHeaders },
|
|
2586
|
-
body: JSON.stringify({
|
|
2587
|
-
action: "regenerate",
|
|
2588
|
-
stream: true,
|
|
2589
|
-
model: tier,
|
|
2590
|
-
instructions: flags.instructions
|
|
2591
|
-
? String(flags.instructions)
|
|
2592
|
-
: undefined,
|
|
2593
|
-
}),
|
|
2594
|
-
}
|
|
2595
|
-
);
|
|
2596
|
-
if (!res.ok || !res.body) {
|
|
2597
|
-
const text = await res.text().catch(() => "");
|
|
2598
|
-
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
const reader = res.body.getReader();
|
|
2602
|
-
const decoder = new TextDecoder();
|
|
2603
|
-
let buffer = "";
|
|
2604
|
-
let doneEvent = null;
|
|
2605
|
-
const startedAt = Date.now();
|
|
2606
|
-
const progress = { done: 0, total: 12, placeholders: 0, retries: 0 };
|
|
2607
|
-
|
|
2608
|
-
while (true) {
|
|
2609
|
-
const { value, done: streamDone } = await reader.read();
|
|
2610
|
-
if (streamDone) break;
|
|
2611
|
-
buffer += decoder.decode(value, { stream: true });
|
|
2612
|
-
let idx;
|
|
2613
|
-
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
2614
|
-
const frame = buffer.slice(0, idx);
|
|
2615
|
-
buffer = buffer.slice(idx + 2);
|
|
2616
|
-
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
2617
|
-
if (!dataLine) continue;
|
|
2618
|
-
let event;
|
|
2619
|
-
try {
|
|
2620
|
-
event = JSON.parse(dataLine.replace(/^data:\s?/, ""));
|
|
2621
|
-
} catch {
|
|
2622
|
-
continue;
|
|
2623
|
-
}
|
|
2624
|
-
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
2625
|
-
const phase = event.phase || "?";
|
|
2626
|
-
if (phase === "panel_done") {
|
|
2627
|
-
progress.done = event.panels_completed ?? progress.done + 1;
|
|
2628
|
-
progress.total = event.panels_total ?? progress.total;
|
|
2629
|
-
if (event.status === "placeholder") progress.placeholders += 1;
|
|
2630
|
-
if (event.status === "retry-recovered") progress.retries += 1;
|
|
2631
|
-
const mark =
|
|
2632
|
-
event.status === "ok"
|
|
2633
|
-
? "✓"
|
|
2634
|
-
: event.status === "retry-recovered"
|
|
2635
|
-
? "↻"
|
|
2636
|
-
: "⚠";
|
|
2637
|
-
console.error(
|
|
2638
|
-
`${elapsed}s ${mark} ${event.panel} [${progress.done}/${progress.total}]`
|
|
2639
|
-
);
|
|
2640
|
-
} else if (phase === "anchor_done") {
|
|
2641
|
-
console.error(
|
|
2642
|
-
`${elapsed}s anchor → ${event.verdict_label || event.verdict}`
|
|
2643
|
-
);
|
|
2644
|
-
} else if (phase === "assembling") {
|
|
2645
|
-
console.error(`${elapsed}s assembling…`);
|
|
2646
|
-
} else if (phase === "done") {
|
|
2647
|
-
doneEvent = event;
|
|
2648
|
-
} else if (phase === "error") {
|
|
2649
|
-
throw new Error(`Memo composer error: ${event.error}`);
|
|
2650
|
-
}
|
|
2651
|
-
}
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
if (!doneEvent) {
|
|
2655
|
-
throw new Error("Stream ended without 'done' event.");
|
|
2656
|
-
}
|
|
2657
|
-
print({
|
|
2658
|
-
ok: true,
|
|
2659
|
-
version: doneEvent.version,
|
|
2660
|
-
degraded: doneEvent.degraded,
|
|
2661
|
-
model: doneEvent.model,
|
|
2662
|
-
duration_ms: doneEvent.duration_ms,
|
|
2663
|
-
placeholders: progress.placeholders,
|
|
2664
|
-
retries: progress.retries,
|
|
2665
|
-
});
|
|
2666
|
-
return;
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
// save — upload hand-written HTML as a manual override.
|
|
2670
|
-
if (sub === "save") {
|
|
2671
|
-
const { flags } = parseFlags(rest);
|
|
2672
|
-
const dealId = rest[0];
|
|
2673
|
-
if (!dealId || !flags.file) {
|
|
2674
|
-
throw new Error("Usage: llama memo save <dealId> --file <path>");
|
|
2675
|
-
}
|
|
2676
|
-
const { readFileSync } = await import("fs");
|
|
2677
|
-
const html = readFileSync(String(flags.file), "utf-8");
|
|
2678
|
-
if (!html.trim()) throw new Error(`File ${flags.file} is empty.`);
|
|
2679
|
-
print(
|
|
2680
|
-
await request(
|
|
2681
|
-
"PUT",
|
|
2682
|
-
`/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
2683
|
-
{ html }
|
|
2684
|
-
)
|
|
2685
|
-
);
|
|
2686
|
-
return;
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
// reset — default drops only the manual override (next read returns
|
|
2690
|
-
// the composed row, if any); --all drops every version for this deal.
|
|
2691
|
-
if (sub === "reset") {
|
|
2692
|
-
const dealId = rest[0];
|
|
2693
|
-
if (!dealId) {
|
|
2694
|
-
throw new Error("Usage: llama memo reset <dealId> [--all]");
|
|
2695
|
-
}
|
|
2696
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
2697
|
-
print(
|
|
2698
|
-
await request(
|
|
2699
|
-
"DELETE",
|
|
2700
|
-
`/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
2701
|
-
{ scope: flags.all ? "all" : "override_only" }
|
|
2702
|
-
)
|
|
2703
|
-
);
|
|
2704
|
-
return;
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
|
-
throw new Error(
|
|
2708
|
-
`Unknown memo subcommand "${sub || ""}". Use: show / regenerate / save / reset.`
|
|
2709
|
-
);
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
// ============================================================
|
|
2713
|
-
// `llama html` family — per-deal hand-authored HTML "deal page"
|
|
2714
|
-
// ============================================================
|
|
2715
|
-
//
|
|
2716
|
-
// Each deal can have its own HTML browse view (sandboxed iframe).
|
|
2717
|
-
// Upload via this CLI, or directly via the web UI's drag-drop / paste,
|
|
2718
|
-
// or by the in-app deal agent via the update_deal_browse_html tool.
|
|
2719
|
-
// Every upload creates a new monotonic version; old versions are
|
|
2720
|
-
// soft-deleted on replace and can be restored.
|
|
2721
|
-
//
|
|
2722
|
-
// llama html show <dealId> [--out PATH] [--json]
|
|
2723
|
-
// llama html upload <dealId> --file PATH [--source cli|web|agent]
|
|
2724
|
-
// llama html versions <dealId>
|
|
2725
|
-
// llama html restore <dealId> <version>
|
|
2726
|
-
// llama html reset <dealId>
|
|
2727
|
-
if (area === "html") {
|
|
2728
|
-
const sub = action;
|
|
2729
|
-
|
|
2730
|
-
// --doc <slug> selects which named document on the deal (default 'main').
|
|
2731
|
-
// Slugs match /^[a-z0-9][a-z0-9_-]{0,63}$/. Use `llama html docs <dealId>`
|
|
2732
|
-
// to list available slugs.
|
|
2733
|
-
const MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
2734
|
-
const MAX_ASSET_BYTES = 50 * 1024 * 1024;
|
|
2735
|
-
const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
|
|
2736
|
-
|
|
2737
|
-
function htmlEndpoint(dealId, slug) {
|
|
2738
|
-
return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}/html`;
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
function looksLikeHtml(html) {
|
|
2742
|
-
const head = String(html || "").trim().slice(0, 256).toLowerCase();
|
|
2743
|
-
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
|
2744
|
-
}
|
|
2745
|
-
|
|
2746
|
-
function extractHtmlTitle(html) {
|
|
2747
|
-
const title = String(html || "").match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
2748
|
-
if (!title) return null;
|
|
2749
|
-
const clean = title.replace(/\s+/g, " ").trim();
|
|
2750
|
-
return clean ? clean.slice(0, 200) : null;
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
function docHasHtml(d) {
|
|
2754
|
-
return Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
|
|
2755
|
-
}
|
|
2756
|
-
|
|
2757
|
-
function findDocBySlug(docs, slug) {
|
|
2758
|
-
return docs.find((d) => d && d.slug === slug) || null;
|
|
2759
|
-
}
|
|
2760
|
-
|
|
2761
|
-
function nextAvailableSlug(base, docs) {
|
|
2762
|
-
let candidate = base;
|
|
2763
|
-
let suffix = 2;
|
|
2764
|
-
while (findDocBySlug(docs, candidate)) {
|
|
2765
|
-
candidate = `${base.slice(0, Math.max(1, 64 - String(suffix).length - 1))}-${suffix}`;
|
|
2766
|
-
suffix += 1;
|
|
2767
|
-
}
|
|
2768
|
-
return candidate;
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
function mimeForAsset(path) {
|
|
2772
|
-
const ext = (String(path).split(".").pop() || "").toLowerCase();
|
|
2773
|
-
return (
|
|
2774
|
-
{
|
|
2775
|
-
jpg: "image/jpeg",
|
|
2776
|
-
jpeg: "image/jpeg",
|
|
2777
|
-
png: "image/png",
|
|
2778
|
-
gif: "image/gif",
|
|
2779
|
-
webp: "image/webp",
|
|
2780
|
-
svg: "image/svg+xml",
|
|
2781
|
-
ico: "image/x-icon",
|
|
2782
|
-
avif: "image/avif",
|
|
2783
|
-
css: "text/css",
|
|
2784
|
-
js: "text/javascript",
|
|
2785
|
-
json: "application/json",
|
|
2786
|
-
woff: "font/woff",
|
|
2787
|
-
woff2: "font/woff2",
|
|
2788
|
-
ttf: "font/ttf",
|
|
2789
|
-
otf: "font/otf",
|
|
2790
|
-
mp4: "video/mp4",
|
|
2791
|
-
webm: "video/webm",
|
|
2792
|
-
pdf: "application/pdf",
|
|
2793
|
-
}[ext] || "application/octet-stream"
|
|
2794
|
-
);
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
async function listHtmlDocs(dealId) {
|
|
2798
|
-
const docList = await request(
|
|
2799
|
-
"GET",
|
|
2800
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
2801
|
-
);
|
|
2802
|
-
return Array.isArray(docList?.documents) ? docList.documents : [];
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
async function resolveDealForHtmlPublish(dealRef) {
|
|
2806
|
-
const ref = String(dealRef || "").trim();
|
|
2807
|
-
if (!ref) throw new Error("deal id or name is required");
|
|
2808
|
-
try {
|
|
2809
|
-
await listHtmlDocs(ref);
|
|
2810
|
-
return { dealId: ref, resolvedFrom: "id" };
|
|
2811
|
-
} catch {
|
|
2812
|
-
// Not a readable deal id; fall through to pipeline search.
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
const result = await searchDeals(ref, { limit: 10 });
|
|
2816
|
-
const deals = Array.isArray(result?.deals) ? result.deals : [];
|
|
2817
|
-
if (deals.length === 0) {
|
|
2818
|
-
throw new Error(
|
|
2819
|
-
`No deal matched "${ref}". Run \`llama deal search "${ref}"\` first and pass the exact deal id.`,
|
|
2820
|
-
);
|
|
2821
|
-
}
|
|
2822
|
-
const exact = deals.filter(
|
|
2823
|
-
(d) => String(d.companyName || "").toLowerCase() === ref.toLowerCase(),
|
|
2824
|
-
);
|
|
2825
|
-
const candidates = exact.length > 0 ? exact : deals;
|
|
2826
|
-
if (candidates.length !== 1) {
|
|
2827
|
-
const lines = candidates
|
|
2828
|
-
.slice(0, 8)
|
|
2829
|
-
.map((d) => `- ${d.companyName || "(unnamed)"} — ${d.uuid || d.id}`)
|
|
2830
|
-
.join("\n");
|
|
2831
|
-
throw new Error(
|
|
2832
|
-
`Deal name "${ref}" matched multiple records. Re-run with the exact deal id:\n${lines}`,
|
|
2833
|
-
);
|
|
2834
|
-
}
|
|
2835
|
-
const dealId = candidates[0]?.uuid || candidates[0]?.id;
|
|
2836
|
-
if (!dealId) {
|
|
2837
|
-
throw new Error(`Deal search matched "${ref}" but did not return a deal id.`);
|
|
2838
|
-
}
|
|
2839
|
-
return {
|
|
2840
|
-
dealId,
|
|
2841
|
-
dealName: candidates[0]?.companyName || ref,
|
|
2842
|
-
resolvedFrom: "search",
|
|
2843
|
-
};
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
async function detectSiblingAssetsDir(filePath) {
|
|
2847
|
-
const { existsSync, statSync } = await import("fs");
|
|
2848
|
-
const { dirname, basename, extname, join } = await import("path");
|
|
2849
|
-
const dir = dirname(filePath);
|
|
2850
|
-
const ext = extname(filePath);
|
|
2851
|
-
const stem = basename(filePath, ext);
|
|
2852
|
-
const candidates = [
|
|
2853
|
-
`${stem}_files`,
|
|
2854
|
-
`${stem} files`,
|
|
2855
|
-
`${basename(filePath)}_files`,
|
|
2856
|
-
];
|
|
2857
|
-
for (const name of candidates) {
|
|
2858
|
-
const p = join(dir, name);
|
|
2859
|
-
if (existsSync(p) && statSync(p).isDirectory()) return p;
|
|
2860
|
-
}
|
|
2861
|
-
return null;
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
async function collectAssets(assetsRoot) {
|
|
2865
|
-
const { readFileSync, readdirSync, statSync } = await import("fs");
|
|
2866
|
-
const { join, relative, sep, basename } = await import("path");
|
|
2867
|
-
const rootStat = statSync(assetsRoot);
|
|
2868
|
-
if (!rootStat.isDirectory()) {
|
|
2869
|
-
throw new Error(`assets path must be a directory: ${assetsRoot}`);
|
|
2870
|
-
}
|
|
2871
|
-
const collected = [];
|
|
2872
|
-
const walk = (dir) => {
|
|
2873
|
-
for (const name of readdirSync(dir)) {
|
|
2874
|
-
const absPath = join(dir, name);
|
|
2875
|
-
const st = statSync(absPath);
|
|
2876
|
-
if (st.isDirectory()) {
|
|
2877
|
-
walk(absPath);
|
|
2878
|
-
} else if (st.isFile()) {
|
|
2879
|
-
const relPath = relative(assetsRoot, absPath).split(sep).join("/");
|
|
2880
|
-
collected.push({ absPath, relPath, bytes: st.size });
|
|
2881
|
-
}
|
|
2882
|
-
}
|
|
2883
|
-
};
|
|
2884
|
-
walk(assetsRoot);
|
|
2885
|
-
if (collected.length === 0) {
|
|
2886
|
-
throw new Error(`assets directory is empty: ${assetsRoot}`);
|
|
2887
|
-
}
|
|
2888
|
-
const rootName = basename(assetsRoot);
|
|
2889
|
-
const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
|
|
2890
|
-
const finalPaths = looksLikeSavePageDir
|
|
2891
|
-
? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
|
|
2892
|
-
: collected;
|
|
2893
|
-
let totalBytes = 0;
|
|
2894
|
-
for (const item of finalPaths) {
|
|
2895
|
-
if (item.relPath.split("/").some((seg) => seg === "..")) {
|
|
2896
|
-
throw new Error(`asset path "${item.relPath}" contains "..", refused`);
|
|
2897
|
-
}
|
|
2898
|
-
if (item.bytes > MAX_ASSET_BYTES) {
|
|
2899
|
-
throw new Error(
|
|
2900
|
-
`asset "${item.relPath}" is ${item.bytes} bytes; cap is ${MAX_ASSET_BYTES}`,
|
|
2901
|
-
);
|
|
2902
|
-
}
|
|
2903
|
-
totalBytes += item.bytes;
|
|
2904
|
-
if (totalBytes > MAX_BUNDLE_BYTES) {
|
|
2905
|
-
throw new Error(`total asset bytes exceeds ${MAX_BUNDLE_BYTES}`);
|
|
2906
|
-
}
|
|
2907
|
-
}
|
|
2908
|
-
return {
|
|
2909
|
-
assets: finalPaths.map((item) => ({
|
|
2910
|
-
...item,
|
|
2911
|
-
data: readFileSync(item.absPath),
|
|
2912
|
-
contentType: mimeForAsset(item.relPath),
|
|
2913
|
-
})),
|
|
2914
|
-
totalBytes,
|
|
2915
|
-
};
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
async function uploadHtmlPayload({ dealId, slug, html, source, assetsDir, uploadId }) {
|
|
2919
|
-
if (!assetsDir) {
|
|
2920
|
-
return request("PUT", htmlEndpoint(dealId, slug), {
|
|
2921
|
-
html,
|
|
2922
|
-
source,
|
|
2923
|
-
client_upload_id: uploadId,
|
|
2924
|
-
}, {
|
|
2925
|
-
headers: { "X-Llama-Upload-Id": uploadId },
|
|
2926
|
-
});
|
|
2927
|
-
}
|
|
2928
|
-
const { assets, totalBytes } = await collectAssets(assetsDir);
|
|
2929
|
-
const form = new FormData();
|
|
2930
|
-
form.append("html", html);
|
|
2931
|
-
form.append("source", source);
|
|
2932
|
-
form.append("client_upload_id", uploadId);
|
|
2933
|
-
for (const asset of assets) {
|
|
2934
|
-
form.append(
|
|
2935
|
-
`asset:${asset.relPath}`,
|
|
2936
|
-
new Blob([asset.data], { type: asset.contentType }),
|
|
2937
|
-
asset.relPath,
|
|
2938
|
-
);
|
|
2939
|
-
}
|
|
2940
|
-
console.error(
|
|
2941
|
-
`Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${assets.length} assets (${totalBytes} bytes)`,
|
|
2942
|
-
);
|
|
2943
|
-
const headers = await getAuthHeaders();
|
|
2944
|
-
const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
|
|
2945
|
-
method: "PUT",
|
|
2946
|
-
headers: { ...headers, "X-Llama-Upload-Id": uploadId },
|
|
2947
|
-
body: form,
|
|
2948
|
-
});
|
|
2949
|
-
const body = await res.json().catch(() => ({}));
|
|
2950
|
-
if (!res.ok) {
|
|
2951
|
-
throw new Error(
|
|
2952
|
-
`HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
|
|
2953
|
-
);
|
|
2954
|
-
}
|
|
2955
|
-
return body;
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
async function verifyHtmlUpload({ dealId, slug, expectedVersion, expectedBytes, expectedSha256 }) {
|
|
2959
|
-
const latest = await request("GET", htmlEndpoint(dealId, slug));
|
|
2960
|
-
if (latest?.empty) {
|
|
2961
|
-
throw new Error(`verification failed: ${slug} came back empty after upload`);
|
|
2962
|
-
}
|
|
2963
|
-
if (expectedVersion != null && Number(latest.version) !== Number(expectedVersion)) {
|
|
2964
|
-
throw new Error(
|
|
2965
|
-
`verification failed: expected version ${expectedVersion}, got ${latest.version}`,
|
|
2966
|
-
);
|
|
2967
|
-
}
|
|
2968
|
-
if (
|
|
2969
|
-
expectedBytes != null &&
|
|
2970
|
-
latest.bytes != null &&
|
|
2971
|
-
Number(latest.bytes) !== Number(expectedBytes)
|
|
2972
|
-
) {
|
|
2973
|
-
throw new Error(
|
|
2974
|
-
`verification failed: expected ${expectedBytes} bytes, got ${latest.bytes}`,
|
|
2975
|
-
);
|
|
2976
|
-
}
|
|
2977
|
-
if (
|
|
2978
|
-
expectedSha256 &&
|
|
2979
|
-
latest.sha256 &&
|
|
2980
|
-
String(latest.sha256) !== String(expectedSha256)
|
|
2981
|
-
) {
|
|
2982
|
-
throw new Error(
|
|
2983
|
-
`verification failed: expected sha256 ${expectedSha256}, got ${latest.sha256}`,
|
|
2984
|
-
);
|
|
2985
|
-
}
|
|
2986
|
-
return {
|
|
2987
|
-
ok: true,
|
|
2988
|
-
version: latest.version,
|
|
2989
|
-
bytes: latest.bytes,
|
|
2990
|
-
sha256: latest.sha256,
|
|
2991
|
-
created_at: latest.created_at,
|
|
2992
|
-
};
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
// Surface a clean `linked_wiki` field on linked docs so the listing
|
|
2996
|
-
// reads as "this card points at wiki/<slug>" rather than exposing the
|
|
2997
|
-
// raw source_wiki_* columns. Non-linked docs are returned unchanged.
|
|
2998
|
-
function withLinkedWiki(data) {
|
|
2999
|
-
if (!data || !Array.isArray(data.documents)) return data;
|
|
3000
|
-
return {
|
|
3001
|
-
...data,
|
|
3002
|
-
documents: data.documents.map((d) =>
|
|
3003
|
-
d && d.source_wiki_slug
|
|
3004
|
-
? {
|
|
3005
|
-
...d,
|
|
3006
|
-
linked_wiki: {
|
|
3007
|
-
slug: d.source_wiki_slug,
|
|
3008
|
-
lang: d.source_wiki_lang || "en",
|
|
3009
|
-
},
|
|
3010
|
-
}
|
|
3011
|
-
: d,
|
|
3012
|
-
),
|
|
3013
|
-
};
|
|
3014
|
-
}
|
|
3015
|
-
|
|
3016
|
-
// docs — list / create / archive documents on a deal.
|
|
3017
|
-
//
|
|
3018
|
-
// Forms:
|
|
3019
|
-
// llama html docs <dealId> # list
|
|
3020
|
-
// llama html docs list <dealId> # list (explicit)
|
|
3021
|
-
// llama html docs create <dealId> <slug> [--title "..."]
|
|
3022
|
-
// llama html docs archive <dealId> <slug>
|
|
3023
|
-
if (sub === "docs") {
|
|
3024
|
-
const docSub = rest[0];
|
|
3025
|
-
const isExplicitSubcommand =
|
|
3026
|
-
docSub === "list" ||
|
|
3027
|
-
docSub === "create" ||
|
|
3028
|
-
docSub === "archive";
|
|
3029
|
-
if (!isExplicitSubcommand) {
|
|
3030
|
-
// First positional is the dealId (the common "just list" case).
|
|
3031
|
-
const dealId = rest[0];
|
|
3032
|
-
if (!dealId) {
|
|
3033
|
-
throw new Error(
|
|
3034
|
-
"Usage: llama html docs <dealId>\n" +
|
|
3035
|
-
" llama html docs create <dealId> <slug> --title \"...\"\n" +
|
|
3036
|
-
" llama html docs archive <dealId> <slug>",
|
|
3037
|
-
);
|
|
3038
|
-
}
|
|
3039
|
-
const data = await request(
|
|
3040
|
-
"GET",
|
|
3041
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3042
|
-
);
|
|
3043
|
-
print(withLinkedWiki(data));
|
|
3044
|
-
return;
|
|
3045
|
-
}
|
|
3046
|
-
if (docSub === "list") {
|
|
3047
|
-
const dealId = rest[1];
|
|
3048
|
-
if (!dealId) {
|
|
3049
|
-
throw new Error("Usage: llama html docs list <dealId>");
|
|
3050
|
-
}
|
|
3051
|
-
const data = await request(
|
|
3052
|
-
"GET",
|
|
3053
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3054
|
-
);
|
|
3055
|
-
print(withLinkedWiki(data));
|
|
3056
|
-
return;
|
|
3057
|
-
}
|
|
3058
|
-
if (docSub === "create") {
|
|
3059
|
-
const dealId = rest[1];
|
|
3060
|
-
const slug = rest[2];
|
|
3061
|
-
if (!dealId || !slug) {
|
|
3062
|
-
throw new Error(
|
|
3063
|
-
"Usage: llama html docs create <dealId> <slug> [--title \"...\"]",
|
|
3064
|
-
);
|
|
3065
|
-
}
|
|
3066
|
-
const { flags } = parseFlags(rest.slice(3));
|
|
3067
|
-
const title = flags.title ? String(flags.title) : slug;
|
|
3068
|
-
const data = await request(
|
|
3069
|
-
"POST",
|
|
3070
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3071
|
-
{ slug, title },
|
|
3072
|
-
);
|
|
3073
|
-
print(data);
|
|
3074
|
-
return;
|
|
3075
|
-
}
|
|
3076
|
-
if (docSub === "archive") {
|
|
3077
|
-
const dealId = rest[1];
|
|
3078
|
-
const slug = rest[2];
|
|
3079
|
-
if (!dealId || !slug) {
|
|
3080
|
-
throw new Error("Usage: llama html docs archive <dealId> <slug>");
|
|
3081
|
-
}
|
|
3082
|
-
const data = await request(
|
|
3083
|
-
"DELETE",
|
|
3084
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
|
|
3085
|
-
);
|
|
3086
|
-
print(data);
|
|
3087
|
-
return;
|
|
3088
|
-
}
|
|
3089
|
-
throw new Error(
|
|
3090
|
-
`Unknown html docs subcommand "${docSub}". Use: list / create / archive.`,
|
|
3091
|
-
);
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
|
-
// link — turn a deal doc card into a live, read-only pointer to a wiki
|
|
3095
|
-
// HTML article. "One file, multiple entrances": the wiki stays the
|
|
3096
|
-
// canonical home, the card just renders the wiki's HTML. Edits go to
|
|
3097
|
-
// the wiki source; uploads to a linked slug are refused server-side.
|
|
3098
|
-
//
|
|
3099
|
-
// llama html link <dealId> --wiki <slug> [--lang en|zh] [--title "..."]
|
|
3100
|
-
//
|
|
3101
|
-
// Default deal-side slug = the wiki slug. Default title = the wiki
|
|
3102
|
-
// article's title (fetched from `llama wiki read`).
|
|
3103
|
-
if (sub === "link") {
|
|
3104
|
-
const dealId = rest[0];
|
|
3105
|
-
const { flags } = parseFlags(rest.slice(1), ["wiki", "lang", "title", "slug"]);
|
|
3106
|
-
const wikiSlug =
|
|
3107
|
-
typeof flags.wiki === "string" && flags.wiki.trim()
|
|
3108
|
-
? flags.wiki.trim()
|
|
3109
|
-
: null;
|
|
3110
|
-
if (!dealId || !wikiSlug) {
|
|
3111
|
-
throw new Error(
|
|
3112
|
-
"Usage: llama html link <dealId> --wiki <slug> [--lang en|zh] [--title \"...\"]",
|
|
3113
|
-
);
|
|
3114
|
-
}
|
|
3115
|
-
const lang = flags.lang === "zh" ? "zh" : "en";
|
|
3116
|
-
// Deal-side slug defaults to the wiki slug; --slug overrides.
|
|
3117
|
-
const dealSlug =
|
|
3118
|
-
typeof flags.slug === "string" && flags.slug.trim()
|
|
3119
|
-
? flags.slug.trim()
|
|
3120
|
-
: wikiSlug;
|
|
3121
|
-
// Title defaults to the wiki article's title.
|
|
3122
|
-
let title =
|
|
3123
|
-
typeof flags.title === "string" && flags.title.trim()
|
|
3124
|
-
? flags.title.trim()
|
|
3125
|
-
: null;
|
|
3126
|
-
if (!title) {
|
|
3127
|
-
try {
|
|
3128
|
-
const article = await request(
|
|
3129
|
-
"GET",
|
|
3130
|
-
`/api/wiki/${encodeURIComponent(wikiSlug)}?lang=${lang}`,
|
|
3131
|
-
);
|
|
3132
|
-
title = article?.frontmatter?.title || wikiSlug;
|
|
3133
|
-
} catch {
|
|
3134
|
-
// Fall back to the slug as the title; the server still validates
|
|
3135
|
-
// that the wiki article exists + is HTML on the POST below.
|
|
3136
|
-
title = wikiSlug;
|
|
3137
|
-
}
|
|
3138
|
-
}
|
|
3139
|
-
const data = await request(
|
|
3140
|
-
"POST",
|
|
3141
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3142
|
-
{
|
|
3143
|
-
slug: dealSlug,
|
|
3144
|
-
title,
|
|
3145
|
-
source_wiki_slug: wikiSlug,
|
|
3146
|
-
source_wiki_lang: lang,
|
|
3147
|
-
},
|
|
3148
|
-
);
|
|
3149
|
-
print(data);
|
|
3150
|
-
return;
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3153
|
-
// unlink — revert a linked card back to a normal self-hosted doc.
|
|
3154
|
-
// llama html unlink <dealId> <slug>
|
|
3155
|
-
if (sub === "unlink") {
|
|
3156
|
-
const dealId = rest[0];
|
|
3157
|
-
const slug = rest[1];
|
|
3158
|
-
if (!dealId || !slug) {
|
|
3159
|
-
throw new Error("Usage: llama html unlink <dealId> <slug>");
|
|
3160
|
-
}
|
|
3161
|
-
const data = await request(
|
|
3162
|
-
"PATCH",
|
|
3163
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`,
|
|
3164
|
-
{ source_wiki_slug: null },
|
|
3165
|
-
);
|
|
3166
|
-
print(data);
|
|
3167
|
-
return;
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
// show — fetch the current HTML. Default: print to stdout (pipeable).
|
|
3171
|
-
if (sub === "show") {
|
|
3172
|
-
const dealId = rest[0];
|
|
3173
|
-
if (!dealId) {
|
|
3174
|
-
throw new Error("Usage: llama html show <dealId> [--doc SLUG] [--out PATH] [--json]");
|
|
3175
|
-
}
|
|
3176
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
3177
|
-
const slug = typeof flags.doc === "string" && flags.doc.trim() ? flags.doc.trim() : "main";
|
|
3178
|
-
const data = await request("GET", htmlEndpoint(dealId, slug));
|
|
3179
|
-
if (flags.json) {
|
|
3180
|
-
print(data);
|
|
3181
|
-
return;
|
|
3182
|
-
}
|
|
3183
|
-
if (data?.empty) {
|
|
3184
|
-
throw new Error(
|
|
3185
|
-
`No HTML uploaded for deal ${dealId} yet. Upload via \`llama html upload\`, the web UI, or have the deal agent write it.`,
|
|
3186
|
-
);
|
|
3187
|
-
}
|
|
3188
|
-
const html = data?.html;
|
|
3189
|
-
if (typeof html !== "string") {
|
|
3190
|
-
throw new Error("browse-html response missing html field.");
|
|
3191
|
-
}
|
|
3192
|
-
if (flags.out) {
|
|
3193
|
-
const { writeFileSync } = await import("fs");
|
|
3194
|
-
writeFileSync(String(flags.out), html);
|
|
3195
|
-
console.error(
|
|
3196
|
-
`Wrote ${html.length} bytes (v${data.version}) → ${flags.out}`,
|
|
3197
|
-
);
|
|
3198
|
-
return;
|
|
3199
|
-
}
|
|
3200
|
-
// Stdout — supports `llama html show <id> > page.html` and piping
|
|
3201
|
-
// to e.g. `open -f -a Safari` for quick preview.
|
|
3202
|
-
process.stdout.write(html);
|
|
3203
|
-
return;
|
|
3204
|
-
}
|
|
3205
|
-
|
|
3206
|
-
// publish — agent-safe high-level upload path. The agent gives us a file
|
|
3207
|
-
// path + a deal id/name; the CLI handles search, slug decisions, asset
|
|
3208
|
-
// discovery, upload, and read-after-write verification.
|
|
3209
|
-
if (sub === "publish") {
|
|
3210
|
-
const dealRef = rest[0];
|
|
3211
|
-
const knownFlags = [
|
|
3212
|
-
"file", "title", "doc", "slug", "new", "update",
|
|
3213
|
-
"assets", "no-auto-assets", "source", "no-verify", "upload-id",
|
|
3214
|
-
];
|
|
3215
|
-
const { flags } = parseFlags(rest.slice(1), knownFlags);
|
|
3216
|
-
if (!dealRef || !flags.file || flags.file === true) {
|
|
3217
|
-
throw new Error(
|
|
3218
|
-
"Usage: llama html publish <deal-id-or-name> --file PATH [--title \"...\"] [--doc <slug>] [--update|--new] [--assets DIR]",
|
|
3219
|
-
);
|
|
3220
|
-
}
|
|
3221
|
-
if (flags.slug && !flags.doc) {
|
|
3222
|
-
process.stderr.write("note: --slug accepted as alias for --doc.\n");
|
|
3223
|
-
flags.doc = flags.slug;
|
|
3224
|
-
}
|
|
3225
|
-
const wantsNew = boolFlag(flags, "new");
|
|
3226
|
-
const wantsUpdate = boolFlag(flags, "update");
|
|
3227
|
-
if (wantsNew && wantsUpdate) {
|
|
3228
|
-
throw new Error("Choose only one of --new or --update.");
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
|
-
const filePath = String(flags.file);
|
|
3232
|
-
const { readFileSync, statSync } = await import("fs");
|
|
3233
|
-
const { basename, extname } = await import("path");
|
|
3234
|
-
const fileStat = statSync(filePath);
|
|
3235
|
-
if (!fileStat.isFile()) {
|
|
3236
|
-
throw new Error(`--file must point to a readable HTML file: ${filePath}`);
|
|
3237
|
-
}
|
|
3238
|
-
const html = readFileSync(filePath, "utf8");
|
|
3239
|
-
if (!html.trim()) throw new Error("HTML body is empty.");
|
|
3240
|
-
const htmlBytes = Buffer.byteLength(html, "utf8");
|
|
3241
|
-
if (htmlBytes > MAX_HTML_BYTES) {
|
|
3242
|
-
throw new Error(
|
|
3243
|
-
`HTML body is ${(htmlBytes / 1024 / 1024).toFixed(2)} MB; cap is 5 MB. Put large media in an asset folder or Drive, not inline HTML.`,
|
|
3244
|
-
);
|
|
3245
|
-
}
|
|
3246
|
-
if (!looksLikeHtml(html)) {
|
|
3247
|
-
throw new Error("HTML must start with <!doctype html> or <html.");
|
|
3248
|
-
}
|
|
3249
|
-
|
|
3250
|
-
const resolved = await resolveDealForHtmlPublish(dealRef);
|
|
3251
|
-
const docs = await listHtmlDocs(resolved.dealId);
|
|
3252
|
-
const explicitDoc =
|
|
3253
|
-
typeof flags.doc === "string" && flags.doc.trim()
|
|
3254
|
-
? flags.doc.trim()
|
|
3255
|
-
: null;
|
|
3256
|
-
const title =
|
|
3257
|
-
typeof flags.title === "string" && flags.title.trim()
|
|
3258
|
-
? flags.title.trim()
|
|
3259
|
-
: extractHtmlTitle(html) || basename(filePath, extname(filePath));
|
|
3260
|
-
let slug;
|
|
3261
|
-
let mode;
|
|
3262
|
-
let createdMetadata = false;
|
|
3263
|
-
|
|
3264
|
-
if (explicitDoc) {
|
|
3265
|
-
if (!isValidDocSlug(explicitDoc)) {
|
|
3266
|
-
throw new Error(
|
|
3267
|
-
`slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
|
|
3268
|
-
);
|
|
3269
|
-
}
|
|
3270
|
-
const existingDoc = findDocBySlug(docs, explicitDoc);
|
|
3271
|
-
if (wantsNew && existingDoc) {
|
|
3272
|
-
throw new Error(
|
|
3273
|
-
`--new requested, but document "${explicitDoc}" already exists on this deal.`,
|
|
3274
|
-
);
|
|
3275
|
-
}
|
|
3276
|
-
if (wantsUpdate && !existingDoc) {
|
|
3277
|
-
throw new Error(
|
|
3278
|
-
`--update requested, but document "${explicitDoc}" does not exist on this deal.`,
|
|
3279
|
-
);
|
|
3280
|
-
}
|
|
3281
|
-
slug = explicitDoc;
|
|
3282
|
-
mode = existingDoc && docHasHtml(existingDoc) ? "updated" : "created";
|
|
3283
|
-
if (!existingDoc) createdMetadata = true;
|
|
3284
|
-
} else {
|
|
3285
|
-
const baseSlug = slugifyTitle(title) || slugifyTitle(basename(filePath, extname(filePath)));
|
|
3286
|
-
if (!baseSlug) {
|
|
3287
|
-
throw new Error(
|
|
3288
|
-
"Could not derive a valid slug from the title or filename. Pass --doc <slug>.",
|
|
3289
|
-
);
|
|
3290
|
-
}
|
|
3291
|
-
const existingDoc = findDocBySlug(docs, baseSlug);
|
|
3292
|
-
if (wantsUpdate) {
|
|
3293
|
-
if (!existingDoc) {
|
|
3294
|
-
throw new Error(
|
|
3295
|
-
`--update requested, but derived document "${baseSlug}" does not exist. Pass --doc <existing-slug> or drop --update to create a new doc.`,
|
|
3296
|
-
);
|
|
3297
|
-
}
|
|
3298
|
-
slug = baseSlug;
|
|
3299
|
-
mode = docHasHtml(existingDoc) ? "updated" : "created";
|
|
3300
|
-
} else {
|
|
3301
|
-
slug = existingDoc ? nextAvailableSlug(baseSlug, docs) : baseSlug;
|
|
3302
|
-
mode = "created";
|
|
3303
|
-
createdMetadata = true;
|
|
3304
|
-
if (existingDoc) {
|
|
3305
|
-
process.stderr.write(
|
|
3306
|
-
`note: "${baseSlug}" already exists; publishing as new document "${slug}". Use --update or --doc ${baseSlug} to replace it.\n`,
|
|
3307
|
-
);
|
|
3308
|
-
}
|
|
3309
|
-
}
|
|
3310
|
-
}
|
|
3311
|
-
|
|
3312
|
-
if (createdMetadata) {
|
|
3313
|
-
await request(
|
|
3314
|
-
"POST",
|
|
3315
|
-
`/api/deals/${encodeURIComponent(resolved.dealId)}/documents`,
|
|
3316
|
-
{ slug, title },
|
|
3317
|
-
);
|
|
3318
|
-
}
|
|
3319
|
-
|
|
3320
|
-
let assetsDir =
|
|
3321
|
-
typeof flags.assets === "string" && flags.assets.trim()
|
|
3322
|
-
? flags.assets.trim()
|
|
3323
|
-
: null;
|
|
3324
|
-
if (!assetsDir && !boolFlag(flags, "no-auto-assets")) {
|
|
3325
|
-
assetsDir = await detectSiblingAssetsDir(filePath);
|
|
3326
|
-
if (assetsDir) {
|
|
3327
|
-
process.stderr.write(`note: auto-detected asset folder ${assetsDir}\n`);
|
|
3328
|
-
}
|
|
3329
|
-
}
|
|
3330
|
-
const source =
|
|
3331
|
-
typeof flags.source === "string" && flags.source.trim()
|
|
3332
|
-
? flags.source.trim()
|
|
3333
|
-
: "cli";
|
|
3334
|
-
const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
|
|
3335
|
-
const uploaded = await uploadHtmlPayload({
|
|
3336
|
-
dealId: resolved.dealId,
|
|
3337
|
-
slug,
|
|
3338
|
-
html,
|
|
3339
|
-
source,
|
|
3340
|
-
assetsDir,
|
|
3341
|
-
uploadId,
|
|
3342
|
-
});
|
|
3343
|
-
const verification = boolFlag(flags, "no-verify")
|
|
3344
|
-
? { ok: false, skipped: true }
|
|
3345
|
-
: await verifyHtmlUpload({
|
|
3346
|
-
dealId: resolved.dealId,
|
|
3347
|
-
slug,
|
|
3348
|
-
expectedVersion: uploaded?.version,
|
|
3349
|
-
expectedBytes: uploaded?.bytes,
|
|
3350
|
-
expectedSha256: uploaded?.sha256,
|
|
3351
|
-
});
|
|
3352
|
-
|
|
3353
|
-
print({
|
|
3354
|
-
ok: true,
|
|
3355
|
-
mode,
|
|
3356
|
-
deal_uuid: resolved.dealId,
|
|
3357
|
-
resolved_from: resolved.resolvedFrom,
|
|
3358
|
-
deal_name: resolved.dealName,
|
|
3359
|
-
document_slug: slug,
|
|
3360
|
-
title,
|
|
3361
|
-
version: uploaded?.version,
|
|
3362
|
-
bytes: uploaded?.bytes ?? verification.bytes ?? htmlBytes,
|
|
3363
|
-
sha256: uploaded?.sha256 ?? verification.sha256,
|
|
3364
|
-
client_upload_id: uploaded?.client_upload_id ?? uploadId,
|
|
3365
|
-
idempotent_replay: uploaded?.idempotent_replay,
|
|
3366
|
-
asset_count: uploaded?.asset_count,
|
|
3367
|
-
asset_bytes: uploaded?.asset_bytes,
|
|
3368
|
-
verified: verification,
|
|
3369
|
-
viewer: `${getBaseUrl()}/deals/${encodeURIComponent(resolved.dealId)}/browse/${encodeURIComponent(slug)}`,
|
|
3370
|
-
});
|
|
3371
|
-
return;
|
|
3372
|
-
}
|
|
3373
|
-
|
|
3374
|
-
// upload — PUT a new version. Reads HTML from --file or stdin. With
|
|
3375
|
-
// --assets <dir>, walks the folder, packages as a multipart bundle,
|
|
3376
|
-
// and the server stores HTML + per-asset BYTEA rows atomically
|
|
3377
|
-
// (deal_browse_assets table). Perfect for "Save Page As Complete"
|
|
3378
|
-
// exports — the sibling `_files/` folder maps 1-to-1 to assets.
|
|
3379
|
-
if (sub === "upload") {
|
|
3380
|
-
const dealId = rest[0];
|
|
3381
|
-
if (!dealId) {
|
|
3382
|
-
throw new Error(
|
|
3383
|
-
"Usage:\n" +
|
|
3384
|
-
" Update an existing artifact:\n" +
|
|
3385
|
-
" llama html upload <dealId> --doc <slug> --file PATH [--assets DIR]\n" +
|
|
3386
|
-
" Create a new artifact:\n" +
|
|
3387
|
-
" llama html upload <dealId> --new --title \"...\" --file PATH [--doc <slug>]\n" +
|
|
3388
|
-
" Stream from stdin (either form above with --stdin in place of --file PATH).\n" +
|
|
3389
|
-
"\n" +
|
|
3390
|
-
"Default (no --doc, no --new) targets slug 'main' but REFUSES if 'main'\n" +
|
|
3391
|
-
"already has content — pass --doc main to update it explicitly, or\n" +
|
|
3392
|
-
"--new --title \"...\" to add a NEW artifact alongside.\n" +
|
|
3393
|
-
"\n" +
|
|
3394
|
-
"Routing — is this the right command?\n" +
|
|
3395
|
-
" ✓ DEAL-specific HTML (IC memo for X, dashboard for X, 2×2 for X)\n" +
|
|
3396
|
-
" → YES, you're in the right place. Pass <dealId> + --new / --doc.\n" +
|
|
3397
|
-
" ✗ Cross-deal / institutional knowledge (sector landscape, market map,\n" +
|
|
3398
|
-
" thesis, framework, methodology, anything not tied to one company)\n" +
|
|
3399
|
-
" → use `llama wiki save <slug> --title \"...\" --file <path>.html --sources \"...\"`\n" +
|
|
3400
|
-
" instead (renders at /wiki/<slug>).\n" +
|
|
3401
|
-
" ✗ Founder-facing public share link\n" +
|
|
3402
|
-
" → escape to Netlify only when the user explicitly says \"share publicly\";\n" +
|
|
3403
|
-
" Llama Command outranks Netlify for everything internal.",
|
|
3404
|
-
);
|
|
3405
|
-
}
|
|
3406
|
-
const knownFlags = [
|
|
3407
|
-
"doc", "slug", "new", "title",
|
|
3408
|
-
"file", "stdin", "assets", "source", "upload-id",
|
|
3409
|
-
];
|
|
3410
|
-
const { flags } = parseFlags(rest.slice(1), knownFlags);
|
|
3411
|
-
|
|
3412
|
-
// --slug is the natural agent guess (DB column is `document_slug`).
|
|
3413
|
-
// Accept it as an alias for --doc so the earlier failure mode
|
|
3414
|
-
// (silent fall-through to 'main') can't happen again.
|
|
3415
|
-
if (flags.slug && !flags.doc) {
|
|
3416
|
-
process.stderr.write("note: --slug accepted as alias for --doc.\n");
|
|
3417
|
-
flags.doc = flags.slug;
|
|
3418
|
-
} else if (flags.slug && flags.doc) {
|
|
3419
|
-
process.stderr.write("note: both --doc and --slug given; --doc wins.\n");
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
|
-
const isNew = Boolean(flags.new);
|
|
3423
|
-
const explicitDoc =
|
|
3424
|
-
typeof flags.doc === "string" && flags.doc.trim()
|
|
3425
|
-
? flags.doc.trim()
|
|
3426
|
-
: null;
|
|
3427
|
-
const titleFlag =
|
|
3428
|
-
typeof flags.title === "string" && flags.title.trim()
|
|
3429
|
-
? flags.title.trim()
|
|
3430
|
-
: null;
|
|
3431
|
-
|
|
3432
|
-
// Pre-flight: ask the server what slugs already exist on this deal.
|
|
3433
|
-
// One extra GET round-trip — cheap insurance against silent overwrite.
|
|
3434
|
-
let existing = [];
|
|
3435
|
-
try {
|
|
3436
|
-
const docList = await request(
|
|
3437
|
-
"GET",
|
|
3438
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3439
|
-
);
|
|
3440
|
-
existing = Array.isArray(docList?.documents) ? docList.documents : [];
|
|
3441
|
-
} catch (err) {
|
|
3442
|
-
// If the deal exists but the list endpoint somehow errors, we
|
|
3443
|
-
// shouldn't block the whole upload — surface the warning and
|
|
3444
|
-
// proceed in "no existing docs" mode. The server is still the
|
|
3445
|
-
// ultimate gate for permission failures.
|
|
3446
|
-
process.stderr.write(
|
|
3447
|
-
`warning: could not pre-check existing documents (${err.message}). Continuing.\n`,
|
|
3448
|
-
);
|
|
3449
|
-
}
|
|
3450
|
-
const findDoc = (s) =>
|
|
3451
|
-
existing.find((d) => d && d.slug === s) || null;
|
|
3452
|
-
const docHasHtml = (d) =>
|
|
3453
|
-
Boolean(d && (d.latest_version > 0 || d.latest_updated_at));
|
|
3454
|
-
|
|
3455
|
-
let slug;
|
|
3456
|
-
let mode; // 'created' | 'updated'
|
|
3457
|
-
|
|
3458
|
-
if (isNew) {
|
|
3459
|
-
// Create-new branch. Caller must provide --doc OR --title (we
|
|
3460
|
-
// derive the slug from the title in the latter case).
|
|
3461
|
-
let candidate = explicitDoc || (titleFlag ? slugifyTitle(titleFlag) : null);
|
|
3462
|
-
if (!candidate) {
|
|
3463
|
-
throw new Error(
|
|
3464
|
-
"--new requires --doc <slug> or --title \"...\" so the new artifact has a stable identifier.",
|
|
3465
|
-
);
|
|
3466
|
-
}
|
|
3467
|
-
if (!isValidDocSlug(candidate)) {
|
|
3468
|
-
throw new Error(
|
|
3469
|
-
`slug "${candidate}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
|
|
3470
|
-
);
|
|
3471
|
-
}
|
|
3472
|
-
if (findDoc(candidate)) {
|
|
3473
|
-
if (explicitDoc) {
|
|
3474
|
-
const existingDoc = findDoc(candidate);
|
|
3475
|
-
const meta = docHasHtml(existingDoc)
|
|
3476
|
-
? ` (currently at v${existingDoc.latest_version}, last update ${existingDoc.latest_updated_at})`
|
|
3477
|
-
: "";
|
|
3478
|
-
throw new Error(
|
|
3479
|
-
`--new --doc ${candidate} but a document with slug "${candidate}" already exists${meta}.\n` +
|
|
3480
|
-
`Pick a different slug, or drop --new to UPDATE the existing one.`,
|
|
3481
|
-
);
|
|
3482
|
-
}
|
|
3483
|
-
// Auto-resolve title collisions: foo -> foo-2 -> foo-3 -> ...
|
|
3484
|
-
let suffix = 2;
|
|
3485
|
-
while (findDoc(`${candidate}-${suffix}`)) suffix++;
|
|
3486
|
-
const oldCandidate = candidate;
|
|
3487
|
-
candidate = `${candidate}-${suffix}`;
|
|
3488
|
-
process.stderr.write(
|
|
3489
|
-
`note: slug "${oldCandidate}" already in use; using "${candidate}" instead.\n`,
|
|
3490
|
-
);
|
|
3491
|
-
}
|
|
3492
|
-
slug = candidate;
|
|
3493
|
-
mode = "created";
|
|
3494
|
-
// Stamp the doc metadata first (title, etc.) so the UI selection
|
|
3495
|
-
// page shows a nice name. PUT auto-creates the row too, but
|
|
3496
|
-
// POST gives us a chance to set --title.
|
|
3497
|
-
await request(
|
|
3498
|
-
"POST",
|
|
3499
|
-
`/api/deals/${encodeURIComponent(dealId)}/documents`,
|
|
3500
|
-
{ slug, title: titleFlag || slug },
|
|
3501
|
-
);
|
|
3502
|
-
} else if (explicitDoc) {
|
|
3503
|
-
// Update an existing slug.
|
|
3504
|
-
if (!isValidDocSlug(explicitDoc)) {
|
|
3505
|
-
throw new Error(
|
|
3506
|
-
`slug "${explicitDoc}" must match /^[a-z0-9][a-z0-9_-]{0,63}$/`,
|
|
3507
|
-
);
|
|
3508
|
-
}
|
|
3509
|
-
const target = findDoc(explicitDoc);
|
|
3510
|
-
if (!target) {
|
|
3511
|
-
if (explicitDoc === "main") {
|
|
3512
|
-
// 'main' is the legacy default — fine to auto-init on first
|
|
3513
|
-
// upload to an empty deal.
|
|
3514
|
-
slug = "main";
|
|
3515
|
-
mode = "created";
|
|
3516
|
-
} else {
|
|
3517
|
-
const slugList = existing.length
|
|
3518
|
-
? existing.map((d) => d.slug).join(", ")
|
|
3519
|
-
: "(none)";
|
|
3520
|
-
throw new Error(
|
|
3521
|
-
`No document with slug "${explicitDoc}" exists on this deal.\n` +
|
|
3522
|
-
`To create it: add --new --title "..."\n` +
|
|
3523
|
-
`Or pre-create: llama html docs create ${dealId} ${explicitDoc} --title "..."\n` +
|
|
3524
|
-
`Existing slugs: ${slugList}`,
|
|
3525
|
-
);
|
|
3526
|
-
}
|
|
3527
|
-
} else {
|
|
3528
|
-
slug = explicitDoc;
|
|
3529
|
-
mode = docHasHtml(target) ? "updated" : "created";
|
|
3530
|
-
}
|
|
3531
|
-
} else {
|
|
3532
|
-
// Bare upload — no --doc, no --new. Safe-default to 'main' only
|
|
3533
|
-
// if 'main' is empty / absent. Otherwise refuse, naming the
|
|
3534
|
-
// existing artifact so the caller can pick an explicit intent.
|
|
3535
|
-
const main = findDoc("main");
|
|
3536
|
-
if (docHasHtml(main)) {
|
|
3537
|
-
const versionInfo = main.latest_version
|
|
3538
|
-
? ` (v${main.latest_version}, ${main.latest_updated_at || "last update unknown"})`
|
|
3539
|
-
: "";
|
|
3540
|
-
const slugList = existing.length
|
|
3541
|
-
? existing.map((d) => d.slug).join(", ")
|
|
3542
|
-
: "main";
|
|
3543
|
-
throw new Error(
|
|
3544
|
-
`Refusing to silently overwrite the existing 'main' artifact${versionInfo}.\n` +
|
|
3545
|
-
`\n` +
|
|
3546
|
-
`If you meant to UPDATE 'main': --doc main\n` +
|
|
3547
|
-
`If you meant to add a NEW artifact: --new --title "<name>"\n` +
|
|
3548
|
-
`\n` +
|
|
3549
|
-
`Existing slugs on this deal: ${slugList}\n` +
|
|
3550
|
-
`List details: llama html docs ${dealId}`,
|
|
3551
|
-
);
|
|
3552
|
-
}
|
|
3553
|
-
slug = "main";
|
|
3554
|
-
mode = main ? "updated" : "created";
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
let html;
|
|
3558
|
-
if (flags.file) {
|
|
3559
|
-
const { readFileSync } = await import("fs");
|
|
3560
|
-
html = readFileSync(String(flags.file), "utf8");
|
|
3561
|
-
} else if (flags.stdin) {
|
|
3562
|
-
const chunks = [];
|
|
3563
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3564
|
-
html = Buffer.concat(chunks).toString("utf8");
|
|
3565
|
-
} else {
|
|
3566
|
-
throw new Error(
|
|
3567
|
-
"Pass --file <path> to upload a file, or --stdin to read from stdin.",
|
|
3568
|
-
);
|
|
3569
|
-
}
|
|
3570
|
-
if (!html || !html.trim()) {
|
|
3571
|
-
throw new Error("HTML body is empty.");
|
|
3572
|
-
}
|
|
3573
|
-
const source =
|
|
3574
|
-
typeof flags.source === "string" && flags.source.trim()
|
|
3575
|
-
? flags.source.trim()
|
|
3576
|
-
: "cli";
|
|
3577
|
-
const uploadId = normalizeUploadId(flags["upload-id"]) || newHtmlUploadId();
|
|
3578
|
-
|
|
3579
|
-
// No --assets → JSON path (small, faster).
|
|
3580
|
-
if (!flags.assets) {
|
|
3581
|
-
const data = await request("PUT", htmlEndpoint(dealId, slug), {
|
|
3582
|
-
html,
|
|
3583
|
-
source,
|
|
3584
|
-
client_upload_id: uploadId,
|
|
3585
|
-
}, {
|
|
3586
|
-
headers: { "X-Llama-Upload-Id": uploadId },
|
|
3587
|
-
});
|
|
3588
|
-
print({
|
|
3589
|
-
ok: true,
|
|
3590
|
-
mode,
|
|
3591
|
-
document_slug: slug,
|
|
3592
|
-
version: data?.version,
|
|
3593
|
-
bytes: data?.bytes ?? Buffer.byteLength(html, "utf8"),
|
|
3594
|
-
sha256: data?.sha256,
|
|
3595
|
-
client_upload_id: data?.client_upload_id ?? uploadId,
|
|
3596
|
-
idempotent_replay: data?.idempotent_replay,
|
|
3597
|
-
deal_uuid: dealId,
|
|
3598
|
-
viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
|
|
3599
|
-
});
|
|
3600
|
-
return;
|
|
3601
|
-
}
|
|
3602
|
-
|
|
3603
|
-
// --assets path → multipart bundle. Walk the asset directory,
|
|
3604
|
-
// attach every file as `asset:<relativePath>`, and let the server
|
|
3605
|
-
// rewrite the HTML refs to /api/deals/<id>/asset/<path>?v=N.
|
|
3606
|
-
const { readFileSync, readdirSync, statSync } = await import("fs");
|
|
3607
|
-
const { join, relative, sep, basename } = await import("path");
|
|
3608
|
-
const assetsRoot = String(flags.assets);
|
|
3609
|
-
const assetsRootStat = statSync(assetsRoot);
|
|
3610
|
-
if (!assetsRootStat.isDirectory()) {
|
|
3611
|
-
throw new Error(`--assets must point to a directory: ${assetsRoot}`);
|
|
3612
|
-
}
|
|
3613
|
-
|
|
3614
|
-
// Recursively collect every file under the assets root.
|
|
3615
|
-
const collected = []; // { absPath, relPath, bytes }
|
|
3616
|
-
const walk = (dir) => {
|
|
3617
|
-
for (const name of readdirSync(dir)) {
|
|
3618
|
-
const abs = join(dir, name);
|
|
3619
|
-
const st = statSync(abs);
|
|
3620
|
-
if (st.isDirectory()) {
|
|
3621
|
-
walk(abs);
|
|
3622
|
-
} else if (st.isFile()) {
|
|
3623
|
-
const rel = relative(assetsRoot, abs).split(sep).join("/");
|
|
3624
|
-
collected.push({ absPath: abs, relPath: rel, bytes: st.size });
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
};
|
|
3628
|
-
walk(assetsRoot);
|
|
3629
|
-
if (collected.length === 0) {
|
|
3630
|
-
throw new Error(`--assets directory is empty: ${assetsRoot}`);
|
|
3631
|
-
}
|
|
3632
|
-
|
|
3633
|
-
// Some "Save Page As" exports put assets in a sibling folder named
|
|
3634
|
-
// after the HTML (e.g. "Foo.html" + "Foo_files/"). When the HTML
|
|
3635
|
-
// references "./Foo_files/img.png" but we walk just the inner dir,
|
|
3636
|
-
// the rel paths don't match. Detect this case: if the assets root's
|
|
3637
|
-
// basename is "<something>_files" or "<something> files", the HTML
|
|
3638
|
-
// probably uses that prefix — prepend it to each relPath.
|
|
3639
|
-
const rootName = basename(assetsRoot);
|
|
3640
|
-
const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
|
|
3641
|
-
const finalPaths = looksLikeSavePageDir
|
|
3642
|
-
? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
|
|
3643
|
-
: collected;
|
|
3644
|
-
|
|
3645
|
-
// Mime sniff from extension. Server defaults to
|
|
3646
|
-
// application/octet-stream if blob.type is empty.
|
|
3647
|
-
const mimeFor = (path) => {
|
|
3648
|
-
const ext = (path.split(".").pop() || "").toLowerCase();
|
|
3649
|
-
return (
|
|
3650
|
-
{
|
|
3651
|
-
jpg: "image/jpeg",
|
|
3652
|
-
jpeg: "image/jpeg",
|
|
3653
|
-
png: "image/png",
|
|
3654
|
-
gif: "image/gif",
|
|
3655
|
-
webp: "image/webp",
|
|
3656
|
-
svg: "image/svg+xml",
|
|
3657
|
-
ico: "image/x-icon",
|
|
3658
|
-
avif: "image/avif",
|
|
3659
|
-
css: "text/css",
|
|
3660
|
-
js: "text/javascript",
|
|
3661
|
-
json: "application/json",
|
|
3662
|
-
woff: "font/woff",
|
|
3663
|
-
woff2: "font/woff2",
|
|
3664
|
-
ttf: "font/ttf",
|
|
3665
|
-
otf: "font/otf",
|
|
3666
|
-
mp4: "video/mp4",
|
|
3667
|
-
webm: "video/webm",
|
|
3668
|
-
pdf: "application/pdf",
|
|
3669
|
-
}[ext] || "application/octet-stream"
|
|
3670
|
-
);
|
|
3671
|
-
};
|
|
3672
|
-
|
|
3673
|
-
const form = new FormData();
|
|
3674
|
-
form.append("html", html);
|
|
3675
|
-
form.append("source", source);
|
|
3676
|
-
form.append("client_upload_id", uploadId);
|
|
3677
|
-
let totalBytes = 0;
|
|
3678
|
-
for (const { absPath, relPath } of finalPaths) {
|
|
3679
|
-
const buf = readFileSync(absPath);
|
|
3680
|
-
totalBytes += buf.length;
|
|
3681
|
-
// FormData wants a Blob; in Node 20+ Blob is global and accepts Buffer.
|
|
3682
|
-
form.append(
|
|
3683
|
-
`asset:${relPath}`,
|
|
3684
|
-
new Blob([buf], { type: mimeFor(relPath) }),
|
|
3685
|
-
relPath,
|
|
3686
|
-
);
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
console.error(
|
|
3690
|
-
`Uploading bundle: html ${Buffer.byteLength(html, "utf8")} bytes + ${finalPaths.length} assets (${totalBytes} bytes)`,
|
|
3691
|
-
);
|
|
3692
|
-
|
|
3693
|
-
const headers = await getAuthHeaders();
|
|
3694
|
-
const res = await fetch(`${getBaseUrl()}${htmlEndpoint(dealId, slug)}`, {
|
|
3695
|
-
method: "PUT",
|
|
3696
|
-
headers: {
|
|
3697
|
-
...headers,
|
|
3698
|
-
"X-Llama-Upload-Id": uploadId,
|
|
3699
|
-
/* let fetch set the multipart boundary */
|
|
3700
|
-
},
|
|
3701
|
-
body: form,
|
|
3702
|
-
});
|
|
3703
|
-
const body = await res.json().catch(() => ({}));
|
|
3704
|
-
if (!res.ok) {
|
|
3705
|
-
throw new Error(
|
|
3706
|
-
`HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
|
|
3707
|
-
);
|
|
3708
|
-
}
|
|
3709
|
-
print({
|
|
3710
|
-
ok: true,
|
|
3711
|
-
mode,
|
|
3712
|
-
document_slug: slug,
|
|
3713
|
-
version: body.version,
|
|
3714
|
-
bytes: body.bytes,
|
|
3715
|
-
sha256: body.sha256,
|
|
3716
|
-
client_upload_id: body.client_upload_id ?? uploadId,
|
|
3717
|
-
idempotent_replay: body.idempotent_replay,
|
|
3718
|
-
asset_count: body.asset_count,
|
|
3719
|
-
asset_bytes: body.asset_bytes,
|
|
3720
|
-
deal_uuid: dealId,
|
|
3721
|
-
viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(slug)}`,
|
|
3722
|
-
});
|
|
3723
|
-
return;
|
|
3724
|
-
}
|
|
3725
|
-
|
|
3726
|
-
// versions — list version history (newest first, includes soft-deleted).
|
|
3727
|
-
if (sub === "versions") {
|
|
3728
|
-
const dealId = rest[0];
|
|
3729
|
-
if (!dealId) {
|
|
3730
|
-
throw new Error("Usage: llama html versions <dealId> [--doc SLUG]");
|
|
3731
|
-
}
|
|
3732
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
3733
|
-
const slug =
|
|
3734
|
-
typeof flags.doc === "string" && flags.doc.trim()
|
|
3735
|
-
? flags.doc.trim()
|
|
3736
|
-
: "main";
|
|
3737
|
-
const data = await request("GET", `${htmlEndpoint(dealId, slug)}/history`);
|
|
3738
|
-
print(data);
|
|
3739
|
-
return;
|
|
3740
|
-
}
|
|
3741
|
-
|
|
3742
|
-
// restore — re-promote an old version as the new latest.
|
|
3743
|
-
if (sub === "restore") {
|
|
3744
|
-
const dealId = rest[0];
|
|
3745
|
-
const version = Number(rest[1]);
|
|
3746
|
-
if (!dealId || !Number.isFinite(version)) {
|
|
3747
|
-
throw new Error(
|
|
3748
|
-
"Usage: llama html restore <dealId> <version> [--doc SLUG]",
|
|
3749
|
-
);
|
|
3750
|
-
}
|
|
3751
|
-
const { flags } = parseFlags(rest.slice(2));
|
|
3752
|
-
const slug =
|
|
3753
|
-
typeof flags.doc === "string" && flags.doc.trim()
|
|
3754
|
-
? flags.doc.trim()
|
|
3755
|
-
: "main";
|
|
3756
|
-
const data = await request(
|
|
3757
|
-
"POST",
|
|
3758
|
-
`${htmlEndpoint(dealId, slug)}/restore/${version}`,
|
|
3759
|
-
);
|
|
3760
|
-
print({
|
|
3761
|
-
ok: true,
|
|
3762
|
-
document_slug: slug,
|
|
3763
|
-
restored_from: version,
|
|
3764
|
-
new_version: data?.version,
|
|
3765
|
-
deal_uuid: dealId,
|
|
3766
|
-
});
|
|
3767
|
-
return;
|
|
3768
|
-
}
|
|
3769
|
-
|
|
3770
|
-
// reset — soft-delete the current HTML. /browse page reverts to empty state.
|
|
3771
|
-
if (sub === "reset" || sub === "delete") {
|
|
3772
|
-
const dealId = rest[0];
|
|
3773
|
-
if (!dealId) {
|
|
3774
|
-
throw new Error("Usage: llama html reset <dealId> [--doc SLUG]");
|
|
3775
|
-
}
|
|
3776
|
-
const { flags } = parseFlags(rest.slice(1));
|
|
3777
|
-
const slug =
|
|
3778
|
-
typeof flags.doc === "string" && flags.doc.trim()
|
|
3779
|
-
? flags.doc.trim()
|
|
3780
|
-
: "main";
|
|
3781
|
-
const data = await request("DELETE", htmlEndpoint(dealId, slug));
|
|
3782
|
-
print({
|
|
3783
|
-
ok: true,
|
|
3784
|
-
document_slug: slug,
|
|
3785
|
-
soft_deleted_version: data?.version ?? null,
|
|
3786
|
-
deal_uuid: dealId,
|
|
3787
|
-
});
|
|
3788
|
-
return;
|
|
3789
|
-
}
|
|
3790
|
-
|
|
3791
|
-
throw new Error(
|
|
3792
|
-
`Unknown html subcommand "${sub || ""}". Use: docs / link / unlink / show / publish / upload / versions / restore / reset.`,
|
|
3793
|
-
);
|
|
3794
|
-
}
|
|
3795
578
|
|
|
3796
|
-
|
|
3797
|
-
process.exitCode = 1;
|
|
579
|
+
throw new Error(`Unknown command: llama ${[area, action].filter(Boolean).join(" ")}`);
|
|
3798
580
|
}
|
|
3799
581
|
|
|
3800
582
|
main()
|
|
3801
|
-
// Soft, throttled, TTY-gated update nudge. Runs AFTER the command's own
|
|
3802
|
-
// output and is awaited so the registry check completes before exit — but
|
|
3803
|
-
// it can never fail the command (all errors swallowed internally).
|
|
3804
583
|
.then(() => maybeNudgeUpdate())
|
|
3805
584
|
.catch((error) => {
|
|
3806
585
|
console.error(`Error: ${error.message}`);
|