@llamaventures/cli 1.2.4 → 1.3.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 CHANGED
@@ -76,8 +76,8 @@ Default to action. Ask only for genuine judgment.
76
76
 
77
77
  | Error | What to do |
78
78
  |---|---|
79
- | `Error[NO_AUTH]` | Tell user: mint a token at `command.llamaventures.vc/settings/tokens`, then `llama token set <llc_...>`. |
80
- | `Error[UNAUTHORIZED]` | Credentials rejected (revoked / expired / wrong account). Same recovery re-mint. |
79
+ | `Error[NO_AUTH]` | Tell user: run `llama auth login` (browser sign-in via Google, OAuth tokens stored in OS Keychain). For unattended/CI: mint a long-lived PAT at `command.llamaventures.vc/settings/tokens` and `llama token set <llc_...>`. |
80
+ | `Error[UNAUTHORIZED]` | Credentials rejected (revoked / expired / wrong account). If using OAuth: `llama auth login` again. If using PAT: re-mint. |
81
81
  | HTTP 5xx | Wait 5s, retry once. Two failures → tell the user "Command unavailable, will retry later." |
82
82
  | `Too many failed authentication attempts` (HTTP 429) | IP rate-limit. Wait until next UTC hour, OR switch network (e.g. tether to phone). |
83
83
 
@@ -87,7 +87,9 @@ Default to action. Ask only for genuine judgment.
87
87
 
88
88
  ```bash
89
89
  # Auth
90
- llama auth status
90
+ llama auth login # browser PKCE flow → OAuth tokens in OS Keychain (recommended)
91
+ llama auth logout # revoke + clear local
92
+ llama auth status # show identity + active method
91
93
 
92
94
  # Pipeline — read
93
95
  llama deal search "<name>"
@@ -137,7 +139,6 @@ Tools available:
137
139
  - `timeline` / `post`
138
140
  - `mentions_list`
139
141
  - `pitch_start` / `pitch_send_message` / `pitch_upload_file` / `pitch_status` / `pitch_finalize` — public intake (no Llama token needed; for founders / EAs / external agents)
140
- - `llama_api` — escape hatch for any endpoint not yet wrapped (path must start `/api/`)
141
142
 
142
143
  You can also fetch this exact briefing as an MCP prompt named `agent_briefing`.
143
144
 
package/README.md CHANGED
@@ -103,16 +103,37 @@ The client tries credentials **in this order**, on every call:
103
103
 
104
104
  | # | Source | Header sent | Best for |
105
105
  |---|--------|-------------|----------|
106
- | 1 | `gcloud auth print-identity-token` | `Authorization: Bearer …` | Team members on a workstation (zero config) |
107
- | 2 | `$LLAMA_TOKEN` env var | `X-Llama-Token` | CI runners, sandboxed cloud agents |
108
- | 3 | `~/.llama/token` (mode `0600`) | `X-Llama-Token` | Persistent local install |
109
- | 4 | `~/.llama-command/config.json` | `X-Llama-Token` | Legacy CLI v0.1 auto-migrates to `~/.llama/token` on first read |
106
+ | 1 | `llama auth login` (OAuth 2.1, OS Keychain) | `Authorization: Bearer …` | **Recommended for everyone.** One-shot browser login; tokens auto-refresh and survive reboots. |
107
+ | 2 | `gcloud auth print-identity-token` | `Authorization: Bearer …` | Workstations with gcloud already wired (zero config) |
108
+ | 3 | `$LLAMA_TOKEN` env var | `X-Llama-Token` | CI runners, sandboxed cloud agents |
109
+ | 4 | `~/.llama/token` (mode `0600`) | `X-Llama-Token` | Persistent local install (legacy PATs) |
110
+ | 5 | `~/.llama-command/config.json` | `X-Llama-Token` | CLI v0.1 — auto-migrates to `~/.llama/token` |
110
111
 
111
- Both Bearer and X-Llama-Token are sent if both exist. The server tries Bearer
112
- first; on verification failure it falls through to X-Llama-Token. Inspect the
113
- resolved identity any time with `llama auth status`.
112
+ If both Bearer and X-Llama-Token are present, both are sent — the server tries
113
+ Bearer first and falls through to X-Llama-Token on verification failure.
114
+ Inspect the resolved identity any time with `llama auth status`.
114
115
 
115
- ### Zero-config — recommended for team members
116
+ ### Browser sign-in — recommended
117
+
118
+ ```bash
119
+ llama auth login # opens browser → Google sign-in → consent → done
120
+ llama auth status # → activeMethod=oauth, scope, identity
121
+ llama deal search acme-ai # ready
122
+ ```
123
+
124
+ `llama auth login` runs an OAuth 2.1 PKCE + RFC 8252 loopback flow against
125
+ `https://command.llamaventures.vc`, exchanges the code for an access + refresh
126
+ token pair, and stores them in the OS Keychain (macOS Keychain / Windows
127
+ Credential Manager / Linux Secret Service via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring)).
128
+ Linux containers without libsecret use a 0600-mode file at `~/.llama/oauth.json`
129
+ — same posture `gcloud` / `gh` / `aws` ship with on Linux servers. Refresh
130
+ tokens rotate transparently when the access token nears expiry; a cross-process
131
+ file lock prevents two shells from burning each other's refresh during
132
+ concurrent calls.
133
+
134
+ `llama auth logout` revokes server-side via RFC 7009 and clears local storage.
135
+
136
+ ### gcloud — for machines already wired with `gcloud auth login`
116
137
 
117
138
  ```bash
118
139
  gcloud auth login # one-time; pick your @llamaventures.vc account
@@ -120,7 +141,7 @@ llama auth status # → role + email
120
141
  llama deal search acme-ai # ready
121
142
  ```
122
143
 
123
- ### Manual token — for machines without `gcloud`, or stable CI
144
+ ### Long-lived PAT — for CI / unattended environments
124
145
 
125
146
  1. Sign in to https://command.llamaventures.vc.
126
147
  2. Open `/settings/tokens` → **Mint Token**.
@@ -321,9 +342,8 @@ llama pitch upload ./deck.pdf
321
342
  llama pitch # interactive REPL
322
343
  ```
323
344
 
324
- Server-enforced caps (same as the web flow): 5 sessions/IP/day,
325
- 3 sessions/email/day, 30 min idle timeout, 100 messages/session,
326
- 1 M tokens/session.
345
+ Server-enforced rate limits apply (per-IP, per-email, per-session). If you
346
+ hit a limit, the CLI surfaces the server's response message.
327
347
 
328
348
  This is genuine **agent-to-agent**: your AI helps you tell the story, our
329
349
  intake agent extracts the structured fields and produces the verdict.
package/bin/llama-mcp.mjs CHANGED
@@ -364,9 +364,8 @@ server.registerTool(
364
364
  // founder's agent talks to ours, structured intake gets captured, and a
365
365
  // 12-dimension verdict is returned.
366
366
  //
367
- // Anti-abuse caps are server-enforced (5 sessions/IP/day, 3/email/day,
368
- // 30min idle, 100 msg cap, 1M token cap, global daily cap). The MCP tools
369
- // surface those rejections as text back to the agent.
367
+ // Anti-abuse rate limits are server-enforced. The MCP tools surface
368
+ // any server-side rejections as text back to the agent.
370
369
 
371
370
  function asTextResult(text, isError = false) {
372
371
  return {
@@ -383,8 +382,8 @@ server.registerTool(
383
382
  "when a founder (the user) wants to pitch their company to Llama. " +
384
383
  "Requires their name + email. Returns a session_id; the conversation " +
385
384
  "is then maintained via pitch_send_message until the agent finalizes. " +
386
- "Caps (server-enforced): 5 sessions/IP/day, 3 sessions/email/day, " +
387
- "30min idle timeout. No Llama Command token needed.",
385
+ "Server-enforced rate limits apply (per-IP, per-email, per-session). " +
386
+ "No Llama Command token needed.",
388
387
  inputSchema: {
389
388
  name: z.string().describe("the founder's full name (max 100 chars)"),
390
389
  email: z.string().describe("the founder's email (deliverable, not a disposable domain)"),
@@ -447,8 +446,9 @@ server.registerTool(
447
446
  description:
448
447
  "Attach a file (deck, one-pager, deck PDF, screenshot, etc.) to the " +
449
448
  "active pitch session. Server allows pdf / pptx / ppt / docx / doc / " +
450
- "xlsx / xls / png / jpg / webp / heic / heif / txt / md, max 50 MB, " +
451
- "10 files per session. Returns a drive_file_id; the intake agent will " +
449
+ "xlsx / xls / png / jpg / webp / heic / heif / txt / md, with " +
450
+ "server-enforced size and per-session count limits. " +
451
+ "Returns a drive_file_id; the intake agent will " +
452
452
  "pick the file up via list_uploaded_files / read_uploaded_file on its " +
453
453
  "next turn (so call pitch_send_message with a one-line note like " +
454
454
  "'I just uploaded our pitch deck' so the agent knows to look).",
@@ -493,7 +493,7 @@ server.registerTool(
493
493
  "server-side intake agent to finalize — the agent decides that on its " +
494
494
  "own once the pitch is sufficient. Use this for cleanup after a session " +
495
495
  "ends, or to abandon a session early. The server-side session will " +
496
- "naturally expire after 30min of idle.",
496
+ "naturally expire after the server's idle timeout.",
497
497
  inputSchema: {},
498
498
  },
499
499
  async () => {
@@ -505,7 +505,7 @@ server.registerTool(
505
505
  {
506
506
  cleared: before.active,
507
507
  previous_session: before.active ? before : null,
508
- note: "Local pitch session state cleared. Server-side session may still be active for ~30min until idle timeout.",
508
+ note: "Local pitch session state cleared. Server-side session may still be active until its idle timeout.",
509
509
  },
510
510
  null,
511
511
  2
package/bin/llama.mjs CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  startExternalSession,
29
29
  uploadExternalFile,
30
30
  } from "../lib/external.mjs";
31
+ import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
32
+ import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
31
33
 
32
34
  function parseFlags(args) {
33
35
  const flags = {};
@@ -240,7 +242,7 @@ Skill corrections (persona-owner pushback — read by persona-watcher):
240
242
  llama skill-correction add <skill-slug> "<correction text>" [--deal <uuid>] [--block <blockId>]
241
243
  llama skill-correction delete <id>
242
244
  Server enforces persona owner OR system admin on POST/DELETE; GET is open.
243
- External personas (owner_email=null, e.g. virtual-liu-yi) are admin-only for write.
245
+ External personas (owner_email=null) are admin-only for write.
244
246
 
245
247
  Mentions / Inbox:
246
248
  llama mentions # default: my unresolved cues
@@ -316,9 +318,9 @@ Inspect / clean up:
316
318
  llama pitch status # session id, idle minutes, finalized?
317
319
  llama pitch end # clear local session state
318
320
 
319
- Caps (server-enforced):
320
- 5 sessions per IP per day, 3 per email per day, 60min idle timeout,
321
- 100 messages per session, 1M tokens per session.
321
+ Caps:
322
+ Server-enforced per-IP / per-email / per-session rate limits apply.
323
+ The CLI surfaces server messages if a limit is hit.
322
324
 
323
325
  Environment:
324
326
  LLAMA_API_URL override base URL (dev: http://localhost:3000)
@@ -409,7 +411,7 @@ Environment:
409
411
  cleared: !!had,
410
412
  session_file: EXTERNAL_SESSION_FILE,
411
413
  note: had
412
- ? "Local session state cleared. Server-side session may still be active until idle timeout (60min)."
414
+ ? "Local session state cleared. Server-side session may still be active until idle timeout."
413
415
  : "No local session was active.",
414
416
  });
415
417
  return;
@@ -697,8 +699,11 @@ https://command.llamaventures.vc/settings/tokens, run
697
699
  ? "~/.llama-command/config.json (legacy)"
698
700
  : null;
699
701
 
702
+ const oauthBundle = await readBundle();
703
+ const oauthBackend = oauthBundle ? await detectBackend() : null;
704
+
700
705
  let serverCheck = "skipped (no credentials)";
701
- if (bearer || token) {
706
+ if (oauthBundle?.access_token || bearer || token) {
702
707
  try {
703
708
  const me = await request("GET", "/api/me");
704
709
  serverCheck = `ok — authenticated as ${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
@@ -707,12 +712,101 @@ https://command.llamaventures.vc/settings/tokens, run
707
712
  }
708
713
  }
709
714
 
710
- print({
715
+ const out = {
711
716
  baseUrl: getBaseUrl(),
717
+ activeMethod: oauthBundle?.access_token
718
+ ? "oauth"
719
+ : bearer
720
+ ? "gcloud-bearer"
721
+ : token
722
+ ? "llama-token"
723
+ : "none",
724
+ oauth: oauthBundle
725
+ ? {
726
+ storage: oauthBackend,
727
+ client_id: oauthBundle.client_id,
728
+ scope: oauthBundle.scope,
729
+ issuer: oauthBundle.issuer,
730
+ expires_in_seconds: Math.max(0, Math.round((oauthBundle.expires_at - Date.now()) / 1000)),
731
+ }
732
+ : "absent (run `llama auth login`)",
712
733
  gcloudIdentityToken: bearer ? "present" : "absent",
713
734
  llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
714
735
  llamaTokenSource: tokenSrc,
715
736
  serverCheck,
737
+ };
738
+ print(out);
739
+ return;
740
+ }
741
+
742
+ // ============================================================
743
+ // auth login — PKCE + loopback browser flow
744
+ // ============================================================
745
+ if (area === "auth" && action === "login") {
746
+ const { flags } = parseFlags(rest);
747
+ const requestedScope = typeof flags.scope === "string" && flags.scope.trim()
748
+ ? flags.scope.trim()
749
+ : "read write";
750
+ const baseUrl = getBaseUrl();
751
+ const resource = baseUrl; // general API audience (oauthApiResource on the server)
752
+
753
+ console.error(`Signing in to ${baseUrl} as Llama CLI (client_id=${LLAMA_CLI_CLIENT_ID})...`);
754
+ const bundle = await pkceLoopbackFlow({ baseUrl, scope: requestedScope, resource });
755
+ const stored = await writeBundle({
756
+ access_token: bundle.access_token,
757
+ refresh_token: bundle.refresh_token,
758
+ expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
759
+ scope: bundle.scope,
760
+ client_id: bundle.client_id,
761
+ issuer: bundle.issuer,
762
+ resource: bundle.resource,
763
+ created_at: Date.now(),
764
+ });
765
+
766
+ // Verify by hitting /api/me with the new token.
767
+ let identity = "(unable to verify — /api/me did not respond)";
768
+ try {
769
+ const me = await request("GET", "/api/me");
770
+ identity = `${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
771
+ } catch (e) {
772
+ identity = `verification failed: ${e.message.split("\n")[0]}`;
773
+ }
774
+
775
+ print({
776
+ ok: true,
777
+ message: "Signed in",
778
+ identity,
779
+ storage: stored.backend,
780
+ scope: bundle.scope,
781
+ expires_in_seconds: bundle.expires_in,
782
+ });
783
+ return;
784
+ }
785
+
786
+ // ============================================================
787
+ // auth logout — revoke + clear local
788
+ // ============================================================
789
+ if (area === "auth" && action === "logout") {
790
+ const bundle = await readBundle();
791
+ if (!bundle) {
792
+ print({ ok: true, message: "No OAuth credentials to clear" });
793
+ return;
794
+ }
795
+ let revoked = false;
796
+ try {
797
+ revoked = await revokeOAuthToken({
798
+ baseUrl: bundle.issuer ?? getBaseUrl(),
799
+ token: bundle.refresh_token,
800
+ tokenTypeHint: "refresh_token",
801
+ });
802
+ } catch {
803
+ revoked = false;
804
+ }
805
+ await deleteBundle();
806
+ print({
807
+ ok: true,
808
+ message: "Signed out — local credentials cleared",
809
+ serverRevoke: revoked ? "succeeded" : "failed (server unreachable or token already invalid; local state cleared anyway)",
716
810
  });
717
811
  return;
718
812
  }
package/lib/client.mjs CHANGED
@@ -139,18 +139,54 @@ export async function tryGcloudIdentityToken() {
139
139
  }
140
140
  }
141
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.
142
+ // Build the auth header set. Priority order (server tries them in this
143
+ // order too and falls through on failure):
144
+ //
145
+ // 1. OAuth access token from Keychain (`llama auth login`) — Bearer
146
+ // header. Auto-refreshes if near expiry. Highest priority because
147
+ // it's scope-aware + revocable.
148
+ // 2. gcloud identity token — Bearer header. Falls back if no OAuth.
149
+ // 3. X-Llama-Token PAT — sent alongside whatever Bearer was set, so
150
+ // server's authenticate() can fall through on Bearer-verify failure.
145
151
  export async function getAuthHeaders() {
146
152
  const headers = {};
147
- const bearer = await tryGcloudIdentityToken();
148
- if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
153
+ // Lazy import keeps zero-OAuth call paths fast and avoids loading
154
+ // @napi-rs/keyring's native binding when the user isn't using OAuth.
155
+ let oauthAccess = null;
156
+ try {
157
+ const { getValidAccessToken } = await import("./oauth-refresh.mjs");
158
+ oauthAccess = await getValidAccessToken();
159
+ } catch {
160
+ // OAuth modules failed to load (e.g. keyring native binding missing
161
+ // on this platform) — fall through to gcloud / PAT silently.
162
+ }
163
+ if (oauthAccess) {
164
+ headers["Authorization"] = `Bearer ${oauthAccess}`;
165
+ } else {
166
+ const bearer = await tryGcloudIdentityToken();
167
+ if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
168
+ }
149
169
  const token = getToken();
150
170
  if (token) headers["X-Llama-Token"] = token;
151
171
  return headers;
152
172
  }
153
173
 
174
+ /**
175
+ * Was the Bearer header on this request set from an OAuth access token?
176
+ * `request()` uses this to decide whether a 401 should trigger a
177
+ * refresh-and-retry-once path (only meaningful when we sent an OAuth
178
+ * token; gcloud / PAT 401s should NOT retry blindly).
179
+ */
180
+ async function bearerCameFromOAuth() {
181
+ try {
182
+ const { readBundle } = await import("./oauth-storage.mjs");
183
+ const bundle = await readBundle();
184
+ return Boolean(bundle?.access_token);
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
154
190
  // Structured no-credential error. Format is stable so agents can pattern-match
155
191
  // `Error[NO_AUTH]` and trigger a recovery flow.
156
192
  function noAuthError() {
@@ -181,6 +217,10 @@ function unauthorizedError() {
181
217
  }
182
218
 
183
219
  export async function request(method, endpoint, body) {
220
+ return requestWithRetry(method, endpoint, body, /* allowRetry */ true);
221
+ }
222
+
223
+ async function requestWithRetry(method, endpoint, body, allowRetry) {
184
224
  const authHeaders = await getAuthHeaders();
185
225
  if (Object.keys(authHeaders).length === 0) throw noAuthError();
186
226
  const res = await fetch(`${getBaseUrl()}${endpoint}`, {
@@ -191,7 +231,29 @@ export async function request(method, endpoint, body) {
191
231
  },
192
232
  body: body === undefined ? undefined : JSON.stringify(body),
193
233
  });
234
+
235
+ // 401 + we sent an OAuth Bearer + this is the first attempt → try a
236
+ // forced refresh once. Covers two cases: (a) clock skew between client
237
+ // and server pushed us past expiry mid-request, (b) server-side
238
+ // revocation occurred between the client cache and now. Either way,
239
+ // the refresh either succeeds (we retry once with the new access
240
+ // token) or fails (refresh token also dead — bubble UNAUTHORIZED).
241
+ if (res.status === 401 && allowRetry && (await bearerCameFromOAuth())) {
242
+ let refreshed = null;
243
+ try {
244
+ const { forceRefresh } = await import("./oauth-refresh.mjs");
245
+ refreshed = await forceRefresh();
246
+ } catch {
247
+ refreshed = null;
248
+ }
249
+ if (refreshed) {
250
+ return requestWithRetry(method, endpoint, body, /* allowRetry */ false);
251
+ }
252
+ throw unauthorizedError();
253
+ }
254
+
194
255
  if (res.status === 401) throw unauthorizedError();
256
+
195
257
  const text = await res.text();
196
258
  let data;
197
259
  try {
package/lib/external.mjs CHANGED
@@ -18,14 +18,11 @@ import { getBaseUrl } from "./client.mjs";
18
18
  const SESSION_DIR = path.join(os.homedir(), ".llama");
19
19
  const SESSION_FILE = path.join(SESSION_DIR, "external-session.json");
20
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).
21
+ // Server-side proof-of-work prefix. Server-validated; tune in tandem
22
+ // with the server policy if changed.
24
23
  const POW_DIFFICULTY_PREFIX = "0000";
25
24
 
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.
25
+ // Backdate offset for the rendered-at timestamp passed to the server.
29
26
  const POW_BACKDATE_MS = 4_000;
30
27
 
31
28
  // ============================================================
@@ -360,13 +357,13 @@ export async function uploadExternalFile(filePath) {
360
357
 
361
358
  if (!res.ok) {
362
359
  if (res.status === 413) {
363
- throw new Error("File too large (max 50 MB).");
360
+ throw new Error("File too large.");
364
361
  }
365
362
  if (res.status === 415) {
366
363
  throw new Error(`MIME type "${mimetype}" not in server allowlist.`);
367
364
  }
368
365
  if (res.status === 429) {
369
- throw new Error("Upload cap reached (10 files per session).");
366
+ throw new Error("Upload cap reached.");
370
367
  }
371
368
  if (res.status === 401 || res.status === 403) {
372
369
  throw new Error(
@@ -0,0 +1,245 @@
1
+ // OAuth 2.1 PKCE + loopback flow for the Llama CLI.
2
+ //
3
+ // Mirrors `gh auth login` / `gcloud auth login`: the CLI binds an
4
+ // ephemeral HTTP server on 127.0.0.1, opens the browser to the
5
+ // authorization endpoint with a PKCE challenge + state, and waits for
6
+ // the user to approve. The browser redirects to the loopback URL
7
+ // carrying the auth code; the local server captures it and shuts down.
8
+ // The CLI then exchanges the code (with the PKCE verifier) for tokens.
9
+ //
10
+ // Pure stdlib: node:crypto for PKCE, node:http for the loopback server,
11
+ // child_process for the platform-specific browser open. No third-party
12
+ // HTTP/OAuth client.
13
+ //
14
+ // RFC compliance: OAuth 2.1 + RFC 7636 PKCE S256 + RFC 8252 native-app
15
+ // loopback flow + RFC 8707 audience parameter.
16
+
17
+ import { createHash, randomBytes } from "crypto";
18
+ import http from "http";
19
+ import { spawn } from "child_process";
20
+
21
+ const CLIENT_ID = "llama-cli-official";
22
+ const REDIRECT_PATH = "/callback";
23
+ const FLOW_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — generous for slow Google sign-in
24
+
25
+ // ============================================================
26
+ // PKCE primitives
27
+ // ============================================================
28
+
29
+ function base64url(buf) {
30
+ return buf
31
+ .toString("base64")
32
+ .replace(/=/g, "")
33
+ .replace(/\+/g, "-")
34
+ .replace(/\//g, "_");
35
+ }
36
+
37
+ export function generateVerifier() {
38
+ // RFC 7636 §4.1: 43-128 chars from unreserved alphabet. 32 random bytes
39
+ // → 43 base64url chars (256 bits entropy).
40
+ return base64url(randomBytes(32));
41
+ }
42
+
43
+ export function challengeFor(verifier) {
44
+ return base64url(createHash("sha256").update(verifier).digest());
45
+ }
46
+
47
+ // ============================================================
48
+ // Browser launcher
49
+ // ============================================================
50
+
51
+ function openBrowser(url) {
52
+ // Platform-native open. We never block on it (the user closes the
53
+ // browser when they're done; the loopback server is what we wait for).
54
+ let cmd, args;
55
+ if (process.platform === "darwin") {
56
+ cmd = "open";
57
+ args = [url];
58
+ } else if (process.platform === "win32") {
59
+ cmd = "cmd";
60
+ args = ["/c", "start", "", url];
61
+ } else {
62
+ cmd = "xdg-open";
63
+ args = [url];
64
+ }
65
+ try {
66
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
67
+ } catch {
68
+ // Best-effort — if we can't open the browser, the user can copy the
69
+ // URL from stderr. The loopback server keeps listening either way.
70
+ }
71
+ }
72
+
73
+ // ============================================================
74
+ // Loopback server response page
75
+ // ============================================================
76
+
77
+ function respondHtml(res, ok, message) {
78
+ const color = ok ? "#16a34a" : "#dc2626";
79
+ const title = ok ? "Llama CLI — Signed in" : "Llama CLI — Sign-in failed";
80
+ res.statusCode = ok ? 200 : 400;
81
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
82
+ res.end(`<!doctype html>
83
+ <html><head><meta charset="utf-8"><title>${title}</title>
84
+ <style>
85
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
86
+ background: #fafaf9; color: #292524; display: grid; place-items: center;
87
+ min-height: 100vh; margin: 0; }
88
+ .card { background: white; border: 1px solid #e7e5e4; border-radius: 8px;
89
+ padding: 32px 40px; max-width: 400px; text-align: center; }
90
+ h1 { margin: 0 0 12px; font-size: 18px; color: ${color}; }
91
+ p { margin: 0; color: #57534e; font-size: 14px; }
92
+ </style></head><body>
93
+ <div class="card"><h1>${title}</h1><p>${message}</p></div>
94
+ </body></html>`);
95
+ }
96
+
97
+ // ============================================================
98
+ // PKCE + loopback driver
99
+ // ============================================================
100
+
101
+ /**
102
+ * Run the full PKCE + loopback OAuth flow.
103
+ *
104
+ * @param {Object} opts
105
+ * @param {string} opts.baseUrl AS issuer (e.g. https://command.llamaventures.vc)
106
+ * @param {string} opts.scope Space-separated scope request (e.g. "read write")
107
+ * @param {string} opts.resource RFC 8707 audience the access token will bind to
108
+ * @returns {Promise<Object>} {access_token, refresh_token, expires_in, scope, token_type, redirect_uri}
109
+ */
110
+ export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
111
+ const verifier = generateVerifier();
112
+ const challenge = challengeFor(verifier);
113
+ const state = base64url(randomBytes(16));
114
+
115
+ // Bind the loopback server FIRST so we know the port for redirect_uri.
116
+ const server = http.createServer();
117
+ await new Promise((resolve, reject) => {
118
+ server.once("error", reject);
119
+ server.listen(0, "127.0.0.1", resolve);
120
+ });
121
+ const { port } = server.address();
122
+ const redirectUri = `http://127.0.0.1:${port}${REDIRECT_PATH}`;
123
+
124
+ // Set up the request handler now that we have the port.
125
+ const codePromise = new Promise((resolve, reject) => {
126
+ const timeoutId = setTimeout(() => {
127
+ try { server.close(); } catch { /* */ }
128
+ reject(new Error(
129
+ "Error[OAUTH_TIMEOUT]: Browser flow did not complete within " +
130
+ Math.round(FLOW_TIMEOUT_MS / 1000) + "s. Re-run `llama auth login`."
131
+ ));
132
+ }, FLOW_TIMEOUT_MS);
133
+
134
+ server.on("request", (req, res) => {
135
+ const url = new URL(req.url, "http://127.0.0.1");
136
+ if (url.pathname !== REDIRECT_PATH) {
137
+ res.statusCode = 404;
138
+ res.end();
139
+ return;
140
+ }
141
+ const code = url.searchParams.get("code");
142
+ const respState = url.searchParams.get("state");
143
+ const error = url.searchParams.get("error");
144
+ const errorDescription = url.searchParams.get("error_description") ?? "";
145
+
146
+ if (error) {
147
+ respondHtml(res, false, `${error}: ${errorDescription}`);
148
+ clearTimeout(timeoutId);
149
+ server.close();
150
+ reject(new Error(`Error[OAUTH_DENIED]: ${error} — ${errorDescription}`));
151
+ return;
152
+ }
153
+ if (respState !== state) {
154
+ respondHtml(res, false, "state parameter mismatch (CSRF defense)");
155
+ clearTimeout(timeoutId);
156
+ server.close();
157
+ reject(new Error("Error[OAUTH_BAD_STATE]: state mismatch — possible CSRF or stale callback"));
158
+ return;
159
+ }
160
+ if (!code) {
161
+ respondHtml(res, false, "missing code parameter");
162
+ clearTimeout(timeoutId);
163
+ server.close();
164
+ reject(new Error("Error[OAUTH_BAD_CALLBACK]: callback missing code parameter"));
165
+ return;
166
+ }
167
+
168
+ respondHtml(res, true, "You can close this window and return to the terminal.");
169
+ clearTimeout(timeoutId);
170
+ server.close();
171
+ resolve(code);
172
+ });
173
+ });
174
+
175
+ // Build authorize URL and open browser.
176
+ const authorizeUrl = new URL(`${baseUrl}/api/oauth/authorize`);
177
+ authorizeUrl.searchParams.set("response_type", "code");
178
+ authorizeUrl.searchParams.set("client_id", CLIENT_ID);
179
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
180
+ authorizeUrl.searchParams.set("scope", scope);
181
+ authorizeUrl.searchParams.set("state", state);
182
+ authorizeUrl.searchParams.set("code_challenge", challenge);
183
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
184
+ authorizeUrl.searchParams.set("resource", resource);
185
+
186
+ console.error(`Opening browser to ${baseUrl} for sign-in...`);
187
+ console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
188
+ openBrowser(authorizeUrl.toString());
189
+
190
+ const code = await codePromise;
191
+
192
+ // Exchange code → tokens.
193
+ const tokenBody = new URLSearchParams({
194
+ grant_type: "authorization_code",
195
+ code,
196
+ redirect_uri: redirectUri,
197
+ client_id: CLIENT_ID,
198
+ code_verifier: verifier,
199
+ resource,
200
+ }).toString();
201
+
202
+ const tokenRes = await fetch(`${baseUrl}/api/oauth/token`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
205
+ body: tokenBody,
206
+ });
207
+ const tokenJson = await tokenRes.json().catch(() => ({}));
208
+ if (!tokenRes.ok) {
209
+ throw new Error(
210
+ `Error[OAUTH_TOKEN_EXCHANGE_FAILED]: ${tokenJson.error ?? tokenRes.status} — ${tokenJson.error_description ?? "no description"}`
211
+ );
212
+ }
213
+
214
+ return {
215
+ access_token: tokenJson.access_token,
216
+ refresh_token: tokenJson.refresh_token,
217
+ expires_in: tokenJson.expires_in ?? 3600,
218
+ scope: tokenJson.scope ?? scope,
219
+ token_type: tokenJson.token_type ?? "Bearer",
220
+ client_id: CLIENT_ID,
221
+ resource,
222
+ issuer: baseUrl,
223
+ };
224
+ }
225
+
226
+ // ============================================================
227
+ // Token revoke (used by `llama auth logout`)
228
+ // ============================================================
229
+
230
+ export async function revokeToken({ baseUrl, token, tokenTypeHint }) {
231
+ const body = new URLSearchParams({
232
+ token,
233
+ client_id: CLIENT_ID,
234
+ ...(tokenTypeHint ? { token_type_hint: tokenTypeHint } : {}),
235
+ }).toString();
236
+ const res = await fetch(`${baseUrl}/api/oauth/revoke`, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
239
+ body,
240
+ });
241
+ // RFC 7009 §2.2: 200 on success OR unknown token. Anything else is unexpected.
242
+ return res.ok;
243
+ }
244
+
245
+ export const LLAMA_CLI_CLIENT_ID = CLIENT_ID;
@@ -0,0 +1,87 @@
1
+ // OAuth refresh-token rotation for the Llama CLI.
2
+ //
3
+ // Called from lib/client.mjs::request when an OAuth-bearing call returns
4
+ // 401. We exchange the stored refresh_token for a new (access, refresh)
5
+ // pair via POST /api/oauth/token, persist the new bundle, and surface
6
+ // the new access_token so the caller can retry once.
7
+ //
8
+ // Cross-process locking via oauth-storage.withRefreshLock so two shells
9
+ // hitting 401 simultaneously don't burn each other's refresh token.
10
+ // After acquiring the lock we re-read the bundle in case the other
11
+ // shell has already refreshed.
12
+
13
+ import { LLAMA_CLI_CLIENT_ID } from "./oauth-flow.mjs";
14
+ import { readBundle, withRefreshLock, writeBundle } from "./oauth-storage.mjs";
15
+
16
+ const ACCESS_TOKEN_SKEW_MS = 30_000; // refresh proactively 30s before expiry
17
+
18
+ /**
19
+ * Returns the current access token if non-expired, else attempts
20
+ * refresh. Returns null if no bundle is stored, refresh fails, or the
21
+ * refresh token itself is expired/revoked (caller should fall through
22
+ * to the next auth method or surface NO_AUTH).
23
+ */
24
+ export async function getValidAccessToken() {
25
+ const bundle = await readBundle();
26
+ if (!bundle?.access_token) return null;
27
+ if (bundle.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) {
28
+ return bundle.access_token;
29
+ }
30
+ // Near or past expiry — refresh under lock.
31
+ return refreshUnderLock();
32
+ }
33
+
34
+ /**
35
+ * Force a refresh regardless of expiry. Used by client.mjs on a 401
36
+ * with an OAuth bundle present (the access token may have been revoked
37
+ * server-side, in which case the refresh might still work).
38
+ */
39
+ export async function forceRefresh() {
40
+ return refreshUnderLock();
41
+ }
42
+
43
+ async function refreshUnderLock() {
44
+ return withRefreshLock(async () => {
45
+ // Re-read inside the lock — another shell may have refreshed already.
46
+ const fresh = await readBundle();
47
+ if (!fresh?.refresh_token) return null;
48
+ if (fresh.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) {
49
+ // Another shell already refreshed; we're good.
50
+ return fresh.access_token;
51
+ }
52
+ return performRefresh(fresh);
53
+ });
54
+ }
55
+
56
+ async function performRefresh(bundle) {
57
+ const body = new URLSearchParams({
58
+ grant_type: "refresh_token",
59
+ refresh_token: bundle.refresh_token,
60
+ client_id: bundle.client_id ?? LLAMA_CLI_CLIENT_ID,
61
+ resource: bundle.resource,
62
+ }).toString();
63
+
64
+ const res = await fetch(`${bundle.issuer}/api/oauth/token`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
67
+ body,
68
+ });
69
+ if (!res.ok) {
70
+ // Refresh failed — most likely refresh expired or grant was revoked.
71
+ // Don't delete the bundle automatically; the user might want to
72
+ // inspect it or `llama auth logout` themselves to clear it.
73
+ return null;
74
+ }
75
+ const json = await res.json().catch(() => null);
76
+ if (!json?.access_token || !json?.refresh_token) return null;
77
+
78
+ const newBundle = {
79
+ ...bundle,
80
+ access_token: json.access_token,
81
+ refresh_token: json.refresh_token,
82
+ expires_at: Date.now() + (json.expires_in ?? 3600) * 1000,
83
+ scope: json.scope ?? bundle.scope,
84
+ };
85
+ await writeBundle(newBundle);
86
+ return newBundle.access_token;
87
+ }
@@ -0,0 +1,191 @@
1
+ // OAuth credential storage for the Llama CLI.
2
+ //
3
+ // Persists the access_token / refresh_token / expires_at bundle returned
4
+ // by the Llama Command authorization server. Two backends, in order:
5
+ //
6
+ // 1. OS Keychain via @napi-rs/keyring — macOS Keychain, Windows
7
+ // Credential Manager, Linux Secret Service (libsecret). Industry
8
+ // standard for desktop CLIs (gh, gcloud, Azure SDK).
9
+ //
10
+ // 2. Plain file `~/.llama/oauth.json` mode 0600 — used when the
11
+ // Keychain backend isn't available (Linux container with no
12
+ // libsecret, headless CI runner). Same posture as the existing
13
+ // `~/.llama/token` for PATs, and the same posture gh/gcloud/aws
14
+ // ship with on Linux servers.
15
+ //
16
+ // Cross-process lock: the refresh-token rotation contract requires that
17
+ // two shells refreshing simultaneously don't burn each other's refresh
18
+ // token. We coordinate via atomic O_CREAT|O_EXCL on `~/.llama/oauth.lock`
19
+ // with a short retry window, and after acquiring re-read the credentials
20
+ // in case the other shell already refreshed.
21
+
22
+ import fs from "fs";
23
+ import os from "os";
24
+ import path from "path";
25
+
26
+ const SERVICE = "com.llamaventures.cli";
27
+ const ACCOUNT = "oauth";
28
+
29
+ const STORE_DIR = path.join(os.homedir(), ".llama");
30
+ const FILE_PATH = path.join(STORE_DIR, "oauth.json");
31
+ const LOCK_PATH = path.join(STORE_DIR, "oauth.lock");
32
+
33
+ // ============================================================
34
+ // Keychain backend (lazy-loaded — keep startup fast)
35
+ // ============================================================
36
+
37
+ let _keychainEntry = null;
38
+ let _keychainTried = false;
39
+
40
+ async function getKeychainEntry() {
41
+ if (_keychainTried) return _keychainEntry;
42
+ _keychainTried = true;
43
+ try {
44
+ const { Entry } = await import("@napi-rs/keyring");
45
+ _keychainEntry = new Entry(SERVICE, ACCOUNT);
46
+ // Probe — if the platform backend is missing (e.g. Linux without
47
+ // libsecret), the Entry methods throw on first use. Surface that
48
+ // here so callers route to the file backend.
49
+ try {
50
+ _keychainEntry.getPassword();
51
+ } catch (err) {
52
+ const msg = String(err?.message ?? err);
53
+ // "no entry" / "not found" is fine — backend works, just empty.
54
+ // Any other error means the backend itself is unavailable.
55
+ if (!/no entry|not found|no such/i.test(msg)) {
56
+ _keychainEntry = null;
57
+ }
58
+ }
59
+ } catch {
60
+ _keychainEntry = null;
61
+ }
62
+ return _keychainEntry;
63
+ }
64
+
65
+ // ============================================================
66
+ // Bundle shape
67
+ // ============================================================
68
+
69
+ /**
70
+ * @typedef {Object} OAuthBundle
71
+ * @property {string} access_token
72
+ * @property {string} refresh_token
73
+ * @property {number} expires_at absolute ms epoch when access_token expires
74
+ * @property {string} scope space-separated, OAuth wire format
75
+ * @property {string} client_id which AS client minted this bundle
76
+ * @property {string} issuer AS issuer URL — bundle is bound to it
77
+ * @property {string} resource RFC 8707 audience the access_token is for
78
+ * @property {number} created_at ms epoch when bundle was first stored
79
+ */
80
+
81
+ // ============================================================
82
+ // Read / write / delete
83
+ // ============================================================
84
+
85
+ export async function readBundle() {
86
+ const entry = await getKeychainEntry();
87
+ if (entry) {
88
+ try {
89
+ const raw = entry.getPassword();
90
+ if (raw) return JSON.parse(raw);
91
+ } catch {
92
+ // fall through to file
93
+ }
94
+ }
95
+ try {
96
+ const raw = fs.readFileSync(FILE_PATH, "utf8");
97
+ return JSON.parse(raw);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export async function writeBundle(bundle) {
104
+ const json = JSON.stringify(bundle);
105
+ const entry = await getKeychainEntry();
106
+ if (entry) {
107
+ try {
108
+ entry.setPassword(json);
109
+ // Best-effort cleanup: if a stale plaintext file exists from a
110
+ // pre-Keychain install, remove it so we don't have two copies of
111
+ // the credential drifting.
112
+ try { fs.unlinkSync(FILE_PATH); } catch { /* not present */ }
113
+ return { backend: "keychain" };
114
+ } catch {
115
+ // fall through to file
116
+ }
117
+ }
118
+ fs.mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
119
+ fs.writeFileSync(FILE_PATH, `${json}\n`, { mode: 0o600 });
120
+ fs.chmodSync(FILE_PATH, 0o600);
121
+ return { backend: "file" };
122
+ }
123
+
124
+ export async function deleteBundle() {
125
+ const entry = await getKeychainEntry();
126
+ if (entry) {
127
+ try { entry.deletePassword(); } catch { /* may not be present */ }
128
+ }
129
+ try { fs.unlinkSync(FILE_PATH); } catch { /* may not be present */ }
130
+ }
131
+
132
+ export async function detectBackend() {
133
+ const entry = await getKeychainEntry();
134
+ return entry ? "keychain" : "file";
135
+ }
136
+
137
+ // ============================================================
138
+ // Cross-process lock
139
+ // ============================================================
140
+ //
141
+ // Refresh rotation requires that only ONE process at a time exchange
142
+ // the current refresh token. Without a lock, two CLI invocations racing
143
+ // on token expiry would both POST /oauth/token; the first wins, the
144
+ // second gets `invalid_grant` (because the first already rotated), and
145
+ // the user sees a confusing failure.
146
+ //
147
+ // Pattern: atomic O_CREAT | O_EXCL on a sentinel file. If we get the
148
+ // fd, we own the lock; on EEXIST, another process owns it — wait briefly
149
+ // and retry. After acquiring, ALWAYS re-read the bundle from storage in
150
+ // case the other process has refreshed in the meantime (then we don't
151
+ // need to refresh ourselves).
152
+
153
+ const LOCK_RETRY_MS = 100;
154
+ const LOCK_TIMEOUT_MS = 5_000;
155
+
156
+ export async function withRefreshLock(fn) {
157
+ fs.mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
158
+ const start = Date.now();
159
+ let fd;
160
+ while (true) {
161
+ try {
162
+ fd = fs.openSync(LOCK_PATH, "wx", 0o600);
163
+ break;
164
+ } catch (err) {
165
+ if (err.code !== "EEXIST") throw err;
166
+ // Stale lock cleanup: if the lock file is older than the timeout,
167
+ // the holding process likely crashed. Remove and retry.
168
+ try {
169
+ const stat = fs.statSync(LOCK_PATH);
170
+ if (Date.now() - stat.mtimeMs > LOCK_TIMEOUT_MS) {
171
+ fs.unlinkSync(LOCK_PATH);
172
+ continue;
173
+ }
174
+ } catch { /* lock disappeared between EEXIST and stat — fine */ }
175
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
176
+ throw new Error(
177
+ "Error[OAUTH_LOCK_TIMEOUT]: Could not acquire OAuth refresh lock at " +
178
+ LOCK_PATH + ". Another `llama` process may be hung. Remove the " +
179
+ "lock file manually if you're sure no other CLI is running."
180
+ );
181
+ }
182
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
183
+ }
184
+ }
185
+ try {
186
+ return await fn();
187
+ } finally {
188
+ try { fs.closeSync(fd); } catch { /* already closed */ }
189
+ try { fs.unlinkSync(LOCK_PATH); } catch { /* already gone */ }
190
+ }
191
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "1.2.4",
4
- "description": "Llama Ventures CLI + MCP server. Internal team tool for command.llamaventures.vc.",
3
+ "version": "1.3.1",
4
+ "description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "llama": "bin/llama.mjs",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "1.29.0",
48
+ "@napi-rs/keyring": "^1.3.0",
48
49
  "zod": "^4.4.3"
49
50
  }
50
51
  }