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