@llamaventures/cli 1.2.1
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 +181 -0
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/llama-mcp.mjs +606 -0
- package/bin/llama.mjs +1441 -0
- package/lib/client.mjs +215 -0
- package/lib/external.mjs +386 -0
- package/package.json +42 -0
package/lib/client.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Shared HTTP / auth / token helpers for @llamaventures/cli.
|
|
2
|
+
//
|
|
3
|
+
// Used by:
|
|
4
|
+
// - bin/llama.mjs — the CLI command surface
|
|
5
|
+
// - bin/llama-mcp.mjs — the MCP server (forthcoming, v1.1)
|
|
6
|
+
//
|
|
7
|
+
// Zero deps. Lazy I/O — importing this module performs no network or
|
|
8
|
+
// filesystem work; everything happens at first call.
|
|
9
|
+
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { execFile as _execFile } from "child_process";
|
|
15
|
+
import { promisify } from "util";
|
|
16
|
+
|
|
17
|
+
const execFile = promisify(_execFile);
|
|
18
|
+
|
|
19
|
+
// Package root — the directory that contains package.json. Used to locate
|
|
20
|
+
// bundled assets (AGENT_BRIEFING.md, etc.) regardless of where the CLI
|
|
21
|
+
// runs from. lib/client.mjs sits one level deep, so go up one.
|
|
22
|
+
export const PACKAGE_ROOT = path.resolve(
|
|
23
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
24
|
+
".."
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the bundled AGENT_BRIEFING.md and return it as a string.
|
|
29
|
+
* Used by both `llama agent-onboard` (CLI) and the `agent_briefing` MCP
|
|
30
|
+
* prompt — single source of truth.
|
|
31
|
+
*/
|
|
32
|
+
export function readBriefing() {
|
|
33
|
+
const briefingPath = path.join(PACKAGE_ROOT, "AGENT_BRIEFING.md");
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(briefingPath, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return (
|
|
38
|
+
"AGENT_BRIEFING.md not found at " +
|
|
39
|
+
briefingPath +
|
|
40
|
+
". This shouldn't happen in a published @llamaventures/cli install — " +
|
|
41
|
+
"report at https://github.com/SoujiOkita98/llama-cli/issues."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Canonical entrypoint. `llama-command.onrender.com` also serves the API
|
|
47
|
+
// but its NextAuth callback URL doesn't match, so browser login (needed
|
|
48
|
+
// to mint a token at /settings/tokens) fails there with a server-config
|
|
49
|
+
// error. Always default to canonical — override with $LLAMA_API_URL only
|
|
50
|
+
// if testing.
|
|
51
|
+
export const DEFAULT_BASE_URL = "https://command.llamaventures.vc";
|
|
52
|
+
|
|
53
|
+
// Canonical token location (single line, mode 0600). Aligns with the
|
|
54
|
+
// agent-discovery convention.
|
|
55
|
+
export const TOKEN_DIR = path.join(os.homedir(), ".llama");
|
|
56
|
+
export const TOKEN_FILE = path.join(TOKEN_DIR, "token");
|
|
57
|
+
|
|
58
|
+
// Legacy location used by CLI v0.1. Read for back-compat (silent migrate
|
|
59
|
+
// to canonical on first use); never written for the token, but still the
|
|
60
|
+
// home of the rarely-set `baseUrl` override.
|
|
61
|
+
export const LEGACY_DIR = path.join(os.homedir(), ".llama-command");
|
|
62
|
+
export const LEGACY_FILE = path.join(LEGACY_DIR, "config.json");
|
|
63
|
+
|
|
64
|
+
export function readLegacyConfig() {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(LEGACY_FILE, "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeLegacyConfig(config) {
|
|
73
|
+
fs.mkdirSync(LEGACY_DIR, { recursive: true });
|
|
74
|
+
fs.writeFileSync(LEGACY_FILE, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
75
|
+
fs.chmodSync(LEGACY_FILE, 0o600);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function migrateLegacyTokenIfNeeded(token) {
|
|
79
|
+
if (fs.existsSync(TOKEN_FILE)) return;
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
82
|
+
fs.writeFileSync(TOKEN_FILE, `${token}\n`, { mode: 0o600 });
|
|
83
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
84
|
+
} catch {
|
|
85
|
+
// Migration is best-effort; the env var / legacy fallback still works.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readCanonicalToken() {
|
|
90
|
+
try {
|
|
91
|
+
return fs.readFileSync(TOKEN_FILE, "utf8").trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function writeCanonicalToken(token) {
|
|
98
|
+
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
99
|
+
fs.writeFileSync(TOKEN_FILE, `${token}\n`, { mode: 0o600 });
|
|
100
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getBaseUrl() {
|
|
104
|
+
return (process.env.LLAMA_API_URL || readLegacyConfig().baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getToken() {
|
|
108
|
+
// 1. env var — preferred for CI, cloud agents, sandboxed runners
|
|
109
|
+
if (process.env.LLAMA_TOKEN) return process.env.LLAMA_TOKEN;
|
|
110
|
+
|
|
111
|
+
// 2. canonical file
|
|
112
|
+
const canonical = readCanonicalToken();
|
|
113
|
+
if (canonical) return canonical;
|
|
114
|
+
|
|
115
|
+
// 3. legacy fallback — silently migrate forward so future invocations
|
|
116
|
+
// use the canonical path even if the user never re-runs `token set`.
|
|
117
|
+
const legacy = readLegacyConfig().token;
|
|
118
|
+
if (legacy) {
|
|
119
|
+
migrateLegacyTokenIfNeeded(legacy);
|
|
120
|
+
return legacy;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Try `gcloud auth print-identity-token`. Returns the JWT or null. Zero-config
|
|
127
|
+
// win for any team member who has gcloud + their @llamaventures.vc account
|
|
128
|
+
// already set up — the server's Bearer auth path verifies and auto-creates
|
|
129
|
+
// the user row.
|
|
130
|
+
export async function tryGcloudIdentityToken() {
|
|
131
|
+
try {
|
|
132
|
+
const { stdout } = await execFile("gcloud", ["auth", "print-identity-token"], { timeout: 4000 });
|
|
133
|
+
const t = String(stdout).trim();
|
|
134
|
+
// Crude JWT shape check (header.payload.signature). Avoids passing
|
|
135
|
+
// junk like "ERROR: ..." to the server when gcloud misbehaves.
|
|
136
|
+
return t && t.split(".").length === 3 ? t : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build the auth header set. If both Bearer and X-Llama-Token are available,
|
|
143
|
+
// send both — the server tries Bearer first and falls through to
|
|
144
|
+
// X-Llama-Token on verification failure.
|
|
145
|
+
export async function getAuthHeaders() {
|
|
146
|
+
const headers = {};
|
|
147
|
+
const bearer = await tryGcloudIdentityToken();
|
|
148
|
+
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
|
149
|
+
const token = getToken();
|
|
150
|
+
if (token) headers["X-Llama-Token"] = token;
|
|
151
|
+
return headers;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Structured no-credential error. Format is stable so agents can pattern-match
|
|
155
|
+
// `Error[NO_AUTH]` and trigger a recovery flow.
|
|
156
|
+
function noAuthError() {
|
|
157
|
+
return new Error(
|
|
158
|
+
"Error[NO_AUTH]: No Llama Command credentials found.\n" +
|
|
159
|
+
"\n" +
|
|
160
|
+
" Llama Ventures team member?\n" +
|
|
161
|
+
" Quickest: run `gcloud auth login` with your @llamaventures.vc account.\n" +
|
|
162
|
+
" Or: get a token at https://command.llamaventures.vc/settings/tokens, then\n" +
|
|
163
|
+
" llama token set <llc_...> (saved to ~/.llama/token)\n" +
|
|
164
|
+
" or set $LLAMA_TOKEN in your shell env.\n" +
|
|
165
|
+
"\n" +
|
|
166
|
+
" Founder or external visitor (no Llama account)?\n" +
|
|
167
|
+
" Run `llama pitch start --name \"Your Name\" --email \"you@company.com\"`\n" +
|
|
168
|
+
" to chat with our intake agent — no token required."
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Structured 401 error after a request was attempted. Means the credentials
|
|
173
|
+
// we sent were rejected (revoked / expired / wrong account).
|
|
174
|
+
function unauthorizedError() {
|
|
175
|
+
return new Error(
|
|
176
|
+
"Error[UNAUTHORIZED]: Server rejected our credentials.\n" +
|
|
177
|
+
" If using gcloud: confirm `gcloud config get account` shows your @llamaventures.vc address.\n" +
|
|
178
|
+
" If using X-Llama-Token: the token may be revoked. Regenerate at\n" +
|
|
179
|
+
" https://command.llamaventures.vc/settings/tokens and run `llama token set <llc_...>`."
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function request(method, endpoint, body) {
|
|
184
|
+
const authHeaders = await getAuthHeaders();
|
|
185
|
+
if (Object.keys(authHeaders).length === 0) throw noAuthError();
|
|
186
|
+
const res = await fetch(`${getBaseUrl()}${endpoint}`, {
|
|
187
|
+
method,
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
...authHeaders,
|
|
191
|
+
},
|
|
192
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
193
|
+
});
|
|
194
|
+
if (res.status === 401) throw unauthorizedError();
|
|
195
|
+
const text = await res.text();
|
|
196
|
+
let data;
|
|
197
|
+
try {
|
|
198
|
+
data = text ? JSON.parse(text) : null;
|
|
199
|
+
} catch {
|
|
200
|
+
data = text;
|
|
201
|
+
}
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const message = typeof data === "object" && data?.error ? data.error : `HTTP ${res.status}`;
|
|
204
|
+
throw new Error(message);
|
|
205
|
+
}
|
|
206
|
+
return data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function print(data) {
|
|
210
|
+
if (typeof data === "string") {
|
|
211
|
+
console.log(data);
|
|
212
|
+
} else {
|
|
213
|
+
console.log(JSON.stringify(data, null, 2));
|
|
214
|
+
}
|
|
215
|
+
}
|
package/lib/external.mjs
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// External-agent client for @llamaventures/cli.
|
|
2
|
+
//
|
|
3
|
+
// Talks to /api/external/* endpoints (founder pitch intake) — no Llama
|
|
4
|
+
// token required. Session is bootstrapped via PoW + email/name + cookie;
|
|
5
|
+
// subsequent calls reuse the cookie persisted to ~/.llama/external-session.json.
|
|
6
|
+
//
|
|
7
|
+
// All anti-abuse (PoW age, per-IP/email rate limits, disposable-domain block,
|
|
8
|
+
// global daily caps, message/token caps, idle timeout) is enforced server-side.
|
|
9
|
+
// CLI is a bug-for-bug equivalent of the web /external-agent flow — no extra
|
|
10
|
+
// trust given.
|
|
11
|
+
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import crypto from "crypto";
|
|
16
|
+
import { getBaseUrl } from "./client.mjs";
|
|
17
|
+
|
|
18
|
+
const SESSION_DIR = path.join(os.homedir(), ".llama");
|
|
19
|
+
const SESSION_FILE = path.join(SESSION_DIR, "external-session.json");
|
|
20
|
+
|
|
21
|
+
// Server-side proof-of-work prefix. Must agree with
|
|
22
|
+
// llama-command/src/lib/external-pow-client.ts. ~65k iterations average on
|
|
23
|
+
// commodity hardware (~50–500ms in node).
|
|
24
|
+
const POW_DIFFICULTY_PREFIX = "0000";
|
|
25
|
+
|
|
26
|
+
// Server requires ts_rendered to be at least 3s old (anti-replay). We
|
|
27
|
+
// backdate by 4s when computing PoW so the request lands inside the
|
|
28
|
+
// validity window without waiting.
|
|
29
|
+
const POW_BACKDATE_MS = 4_000;
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// Session state — ~/.llama/external-session.json
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
export function readExternalSession() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, "utf8"));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeExternalSession(session) {
|
|
44
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
45
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
46
|
+
fs.chmodSync(SESSION_FILE, 0o600);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearExternalSession() {
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(SESSION_FILE);
|
|
52
|
+
} catch {
|
|
53
|
+
// already gone — fine
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// Proof-of-Work
|
|
59
|
+
// ============================================================
|
|
60
|
+
|
|
61
|
+
function solvePoW(tsRendered, maxIterations = 1_000_000) {
|
|
62
|
+
let nonce = 0;
|
|
63
|
+
while (nonce < maxIterations) {
|
|
64
|
+
const hash = crypto
|
|
65
|
+
.createHash("sha256")
|
|
66
|
+
.update(`${tsRendered}:${nonce}`)
|
|
67
|
+
.digest("hex");
|
|
68
|
+
if (hash.startsWith(POW_DIFFICULTY_PREFIX)) return String(nonce);
|
|
69
|
+
nonce++;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("Could not find proof-of-work nonce within iteration budget.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================
|
|
75
|
+
// Start session
|
|
76
|
+
// ============================================================
|
|
77
|
+
|
|
78
|
+
export async function startExternalSession({ name, email }) {
|
|
79
|
+
if (!name || typeof name !== "string" || name.length > 100) {
|
|
80
|
+
throw new Error("name is required (max 100 chars)");
|
|
81
|
+
}
|
|
82
|
+
if (!email || typeof email !== "string") {
|
|
83
|
+
throw new Error("email is required");
|
|
84
|
+
}
|
|
85
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
86
|
+
throw new Error("email format invalid");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tsRendered = Date.now() - POW_BACKDATE_MS;
|
|
90
|
+
const powNonce = solvePoW(tsRendered);
|
|
91
|
+
|
|
92
|
+
const res = await fetch(`${getBaseUrl()}/api/external/start-session`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
name,
|
|
97
|
+
email,
|
|
98
|
+
ts_rendered: tsRendered,
|
|
99
|
+
hp_field: "",
|
|
100
|
+
pow_nonce: powNonce,
|
|
101
|
+
user_agent: "@llamaventures/cli",
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
// Generic server message — could be PoW fail / disposable email /
|
|
108
|
+
// rate limit / global cap. Server intentionally hides which gate caught.
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Could not start session (HTTP ${res.status}). ${text.slice(0, 200)}\n` +
|
|
111
|
+
` Common causes: rate limit (5 sessions/IP/day, 3/email/day), ` +
|
|
112
|
+
`disposable email domain blocked, or global daily cap reached.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
if (!data?.session_id) {
|
|
118
|
+
throw new Error("start-session response missing session_id");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const session = {
|
|
122
|
+
session_id: data.session_id,
|
|
123
|
+
name,
|
|
124
|
+
email,
|
|
125
|
+
started_at: new Date().toISOString(),
|
|
126
|
+
last_active_at: new Date().toISOString(),
|
|
127
|
+
finalized: false,
|
|
128
|
+
};
|
|
129
|
+
writeExternalSession(session);
|
|
130
|
+
return session;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================
|
|
134
|
+
// Send message — SSE streaming
|
|
135
|
+
// ============================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse SSE events from a fetch ReadableStream. Yields parsed event objects
|
|
139
|
+
* as they arrive. Buffers partial frames across chunks.
|
|
140
|
+
*/
|
|
141
|
+
async function* readSseEvents(stream) {
|
|
142
|
+
const reader = stream.getReader();
|
|
143
|
+
const decoder = new TextDecoder();
|
|
144
|
+
let buffer = "";
|
|
145
|
+
|
|
146
|
+
while (true) {
|
|
147
|
+
const { done, value } = await reader.read();
|
|
148
|
+
if (done) {
|
|
149
|
+
// Flush any trailing data
|
|
150
|
+
if (buffer.trim()) {
|
|
151
|
+
const event = parseSseFrame(buffer);
|
|
152
|
+
if (event) yield event;
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
buffer += decoder.decode(value, { stream: true });
|
|
157
|
+
|
|
158
|
+
// SSE frames are separated by \n\n
|
|
159
|
+
let idx;
|
|
160
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
161
|
+
const frame = buffer.slice(0, idx);
|
|
162
|
+
buffer = buffer.slice(idx + 2);
|
|
163
|
+
const event = parseSseFrame(frame);
|
|
164
|
+
if (event) yield event;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseSseFrame(frame) {
|
|
170
|
+
const dataLines = frame
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter((l) => l.startsWith("data:"))
|
|
173
|
+
.map((l) => l.replace(/^data:\s?/, ""));
|
|
174
|
+
if (dataLines.length === 0) return null;
|
|
175
|
+
const payload = dataLines.join("\n");
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(payload);
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Send a message to the external intake agent. Returns a result object
|
|
185
|
+
* with the collected reply text and finalization state.
|
|
186
|
+
*
|
|
187
|
+
* If `onChunk` is provided, it's called with each text chunk as it arrives
|
|
188
|
+
* (for streaming output to terminal). Returns the same final result.
|
|
189
|
+
*/
|
|
190
|
+
export async function sendExternalMessage(message, { attachments, onChunk } = {}) {
|
|
191
|
+
const session = readExternalSession();
|
|
192
|
+
if (!session) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"No active pitch session. Run `llama pitch start --name \"...\" --email \"...\"` first."
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (session.finalized) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"This pitch session is finalized. Run `llama pitch end` to clear it, then `pitch start` for a new one."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const res = await fetch(`${getBaseUrl()}/api/external/chat`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
Cookie: `external_session=${session.session_id}`,
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
message,
|
|
211
|
+
...(attachments ? { attachments } : {}),
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
if (res.status === 401) {
|
|
217
|
+
clearExternalSession();
|
|
218
|
+
throw new Error(
|
|
219
|
+
"Session expired or invalid. Run `llama pitch start ...` to start a new one."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (res.status === 429) {
|
|
223
|
+
// Cap reached — server has already finalized the session row.
|
|
224
|
+
session.finalized = true;
|
|
225
|
+
writeExternalSession(session);
|
|
226
|
+
throw new Error(
|
|
227
|
+
"Session cap reached (message or token limit). The server has finalized this session. " +
|
|
228
|
+
"Run `llama pitch end` to clear local state, then `pitch start` for a new pitch."
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (res.status === 503) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Llama Ventures intake is at daily capacity — please retry tomorrow."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const text = await res.text();
|
|
237
|
+
throw new Error(`chat failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const collectedText = [];
|
|
241
|
+
let finalized = false;
|
|
242
|
+
let finalizePayload = null;
|
|
243
|
+
let streamError = null;
|
|
244
|
+
|
|
245
|
+
for await (const event of readSseEvents(res.body)) {
|
|
246
|
+
if (typeof event.text === "string") {
|
|
247
|
+
collectedText.push(event.text);
|
|
248
|
+
if (typeof onChunk === "function") onChunk(event.text);
|
|
249
|
+
}
|
|
250
|
+
if (event.finalize === true) {
|
|
251
|
+
finalized = true;
|
|
252
|
+
finalizePayload = event.payload ?? null;
|
|
253
|
+
}
|
|
254
|
+
if (event.error) {
|
|
255
|
+
streamError = event.error;
|
|
256
|
+
}
|
|
257
|
+
// event.done === true → stream end; outer loop exits naturally
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Persist updated state
|
|
261
|
+
session.last_active_at = new Date().toISOString();
|
|
262
|
+
if (finalized) session.finalized = true;
|
|
263
|
+
writeExternalSession(session);
|
|
264
|
+
|
|
265
|
+
if (streamError) {
|
|
266
|
+
throw new Error(`server-side error during chat: ${streamError}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
text: collectedText.join(""),
|
|
271
|
+
finalized,
|
|
272
|
+
finalize_payload: finalizePayload,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================
|
|
277
|
+
// Upload file
|
|
278
|
+
// ============================================================
|
|
279
|
+
|
|
280
|
+
const ALLOWED_MIME_BY_EXT = {
|
|
281
|
+
".pdf": "application/pdf",
|
|
282
|
+
".pptx":
|
|
283
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
284
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
285
|
+
".docx":
|
|
286
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
287
|
+
".doc": "application/msword",
|
|
288
|
+
".xlsx":
|
|
289
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
290
|
+
".xls": "application/vnd.ms-excel",
|
|
291
|
+
".png": "image/png",
|
|
292
|
+
".jpg": "image/jpeg",
|
|
293
|
+
".jpeg": "image/jpeg",
|
|
294
|
+
".webp": "image/webp",
|
|
295
|
+
".heic": "image/heic",
|
|
296
|
+
".heif": "image/heif",
|
|
297
|
+
".txt": "text/plain",
|
|
298
|
+
".md": "text/markdown",
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function guessMimeType(filename) {
|
|
302
|
+
const ext = path.extname(filename).toLowerCase();
|
|
303
|
+
return ALLOWED_MIME_BY_EXT[ext] || "application/octet-stream";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function uploadExternalFile(filePath) {
|
|
307
|
+
const session = readExternalSession();
|
|
308
|
+
if (!session) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"No active pitch session. Run `llama pitch start ...` first."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!fs.existsSync(filePath)) {
|
|
315
|
+
throw new Error(`File not found: ${filePath}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const fileData = fs.readFileSync(filePath);
|
|
319
|
+
const filename = path.basename(filePath);
|
|
320
|
+
const mimetype = guessMimeType(filename);
|
|
321
|
+
|
|
322
|
+
if (mimetype === "application/octet-stream") {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`File extension not in server allowlist. Supported: ${Object.keys(
|
|
325
|
+
ALLOWED_MIME_BY_EXT
|
|
326
|
+
).join(", ")}`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const formData = new FormData();
|
|
331
|
+
const blob = new Blob([fileData], { type: mimetype });
|
|
332
|
+
formData.append("file", blob, filename);
|
|
333
|
+
|
|
334
|
+
const res = await fetch(`${getBaseUrl()}/api/external/upload`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { Cookie: `external_session=${session.session_id}` },
|
|
337
|
+
body: formData,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
if (res.status === 413) {
|
|
342
|
+
throw new Error("File too large (max 50 MB).");
|
|
343
|
+
}
|
|
344
|
+
if (res.status === 415) {
|
|
345
|
+
throw new Error(`MIME type "${mimetype}" not in server allowlist.`);
|
|
346
|
+
}
|
|
347
|
+
if (res.status === 429) {
|
|
348
|
+
throw new Error("Upload cap reached (10 files per session).");
|
|
349
|
+
}
|
|
350
|
+
if (res.status === 401 || res.status === 403) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
"Session expired or inactive. Run `llama pitch start ...` to start a new one."
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const text = await res.text();
|
|
356
|
+
throw new Error(`upload failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return await res.json();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ============================================================
|
|
363
|
+
// Status
|
|
364
|
+
// ============================================================
|
|
365
|
+
|
|
366
|
+
export function getExternalSessionStatus() {
|
|
367
|
+
const session = readExternalSession();
|
|
368
|
+
if (!session) {
|
|
369
|
+
return { active: false };
|
|
370
|
+
}
|
|
371
|
+
const idleMs = Date.now() - new Date(session.last_active_at).getTime();
|
|
372
|
+
const idleMin = Math.floor(idleMs / 60000);
|
|
373
|
+
return {
|
|
374
|
+
active: !session.finalized && idleMin < 30,
|
|
375
|
+
session_id: session.session_id,
|
|
376
|
+
name: session.name,
|
|
377
|
+
email: session.email,
|
|
378
|
+
started_at: session.started_at,
|
|
379
|
+
last_active_at: session.last_active_at,
|
|
380
|
+
idle_minutes: idleMin,
|
|
381
|
+
expired: idleMin >= 30,
|
|
382
|
+
finalized: session.finalized || false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export const EXTERNAL_SESSION_FILE = SESSION_FILE;
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@llamaventures/cli",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Llama Ventures CLI + MCP server. Internal team tool for command.llamaventures.vc.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"llama": "bin/llama.mjs",
|
|
8
|
+
"llama-mcp": "bin/llama-mcp.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"AGENT_BRIEFING.md",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"homepage": "https://github.com/SoujiOkita98/llama-cli#readme",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/SoujiOkita98/llama-cli.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/SoujiOkita98/llama-cli/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"llama-ventures",
|
|
31
|
+
"cli",
|
|
32
|
+
"mcp",
|
|
33
|
+
"modelcontextprotocol"
|
|
34
|
+
],
|
|
35
|
+
"author": "Llama Ventures, Inc.",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
41
|
+
}
|
|
42
|
+
}
|