@connortessaro/pai 0.4.2 → 0.5.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/dist/pai.mjs CHANGED
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
16
  import { createHash, createPublicKey, generateKeyPairSync, verify as cryptoVerify } from 'node:crypto';
17
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
18
  import { createInterface } from 'node:readline/promises';
19
19
  import { execFileSync, spawnSync } from 'node:child_process';
20
20
  import { randomBytes } from 'node:crypto';
@@ -22,11 +22,16 @@ import { address, appendTransactionMessageInstructions, createKeyPairSignerFromB
22
22
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
23
23
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24
24
  import * as z from 'zod';
25
+ /** The Phantom AI API address pai calls when PHANTOM_BASE_URL is unset. */
25
26
  export const DEFAULT_BASE_URL = 'https://phantom.codes/v1';
26
- export const VERSION = '0.4.2';
27
+ /** This release of pai. `npm version` keeps it equal to the version in package.json. */
28
+ export const VERSION = '0.5.0';
27
29
  // ── errors ───────────────────────────────────────────────────────────────────
30
+ /** An error answer from the Phantom AI API. {@link handleError} maps a 401 or 403 to exit code 2. */
28
31
  export class PhantomApiError extends Error {
32
+ /** The HTTP status code of the answer. */
29
33
  status;
34
+ /** The API's error code, or the status code as text when the answer carries none. */
30
35
  code;
31
36
  constructor(status, code, message) {
32
37
  super(message);
@@ -35,8 +40,11 @@ export class PhantomApiError extends Error {
35
40
  this.code = code;
36
41
  }
37
42
  }
43
+ /** An error pai raises itself, such as a missing flag or a refused payment. */
38
44
  export class CliError extends Error {
45
+ /** A short code for the error, such as `wallet_cap_exceeded`. `cli_error` when none is given. */
39
46
  code;
47
+ /** The process exit code {@link run} returns for it. 1 unless given. */
40
48
  exitCode;
41
49
  constructor(message, code = 'cli_error', exitCode = 1) {
42
50
  super(message);
@@ -45,10 +53,27 @@ export class CliError extends Error {
45
53
  this.exitCode = exitCode;
46
54
  }
47
55
  }
56
+ /**
57
+ * Throws a {@link CliError}. Every refusal in pai goes through here.
58
+ * @param msg The message the user sees.
59
+ * @param code The error code, printed in the JSON error.
60
+ * @param exitCode The process exit code.
61
+ */
48
62
  export function die(msg, code = 'cli_error', exitCode = 1) {
49
63
  throw new CliError(msg, code, exitCode);
50
64
  }
51
65
  // ── request ──────────────────────────────────────────────────────────────────
66
+ /**
67
+ * Calls the Phantom AI API with the key as a Bearer token and returns the
68
+ * JSON answer. Every client function in this file goes through it.
69
+ * @param method The HTTP method.
70
+ * @param path The path after the base URL, such as `/key/balance`.
71
+ * @param apiKey The key sent in the Authorization header.
72
+ * @param body Sent as JSON when given.
73
+ * @param baseUrl The API base. Defaults to PHANTOM_BASE_URL, then {@link DEFAULT_BASE_URL}. Must be https ({@link httpsOnly}).
74
+ * @param headers Extra headers to send.
75
+ * @throws {@link PhantomApiError} when the API answers with an error status.
76
+ */
52
77
  export async function request(method, path, apiKey, body, baseUrl, headers = {}) {
53
78
  const url = `${apiBase(baseUrl)}${path}`;
54
79
  const res = await fetch(url, {
@@ -66,10 +91,27 @@ export async function request(method, path, apiKey, body, baseUrl, headers = {})
66
91
  return json;
67
92
  }
68
93
  function apiBase(baseUrl) {
69
- return (baseUrl ||
94
+ return httpsOnly(baseUrl ||
70
95
  (typeof process !== 'undefined' && process.env.PHANTOM_BASE_URL) ||
71
96
  DEFAULT_BASE_URL);
72
97
  }
98
+ /**
99
+ * The API base must be https. The key goes to it on every call, so plain http
100
+ * is refused, localhost included.
101
+ */
102
+ export function httpsOnly(baseUrl) {
103
+ let protocol = '';
104
+ try {
105
+ protocol = new URL(baseUrl).protocol;
106
+ }
107
+ catch {
108
+ die(`PHANTOM_BASE_URL is not a web address: ${baseUrl}`, 'base_url_invalid');
109
+ }
110
+ if (protocol !== 'https:') {
111
+ die(`PHANTOM_BASE_URL must start with https://, not ${baseUrl}. pai sends your API key there, so it refuses plain http, even on localhost.`, 'base_url_insecure');
112
+ }
113
+ return baseUrl;
114
+ }
73
115
  function apiError(status, json) {
74
116
  const err = json.error;
75
117
  let code = String(status);
@@ -90,33 +132,49 @@ function apiError(status, json) {
90
132
  return new PhantomApiError(status, code, msg);
91
133
  }
92
134
  // ── client functions ─────────────────────────────────────────────────────────
135
+ /** Credit left, credit spent and expiry of a key (GET /key/balance). */
93
136
  export function getBalance(apiKey, baseUrl) {
94
137
  return request('GET', '/key/balance', apiKey, undefined, baseUrl);
95
138
  }
139
+ /** A key's caps, plan and pace (GET /key/budget). */
96
140
  export function getBudget(apiKey, baseUrl) {
97
141
  return request('GET', '/key/budget', apiKey, undefined, baseUrl);
98
142
  }
143
+ /**
144
+ * Sets or clears a key's period cap and per-minute cap (PATCH /key/budget).
145
+ * A field left out stays as it is; null clears it. A child key cannot change its caps.
146
+ */
99
147
  export function setBudget(apiKey, opts, baseUrl) {
100
148
  return request('PATCH', '/key/budget', apiKey, opts, baseUrl);
101
149
  }
150
+ /**
151
+ * Sets or clears a plan: a cap of `amount_usd` over `days` (PATCH /key/budget
152
+ * with `budget_usd` and `period_days`). `days` null means one calendar month.
153
+ * Setting an amount starts a fresh period.
154
+ */
102
155
  export function setPlan(apiKey, opts, baseUrl) {
103
156
  const body = { budget_usd: opts.amount_usd };
104
157
  if (opts.days !== undefined)
105
158
  body.period_days = opts.days;
106
159
  return request('PATCH', '/key/budget', apiKey, body, baseUrl);
107
160
  }
161
+ /** The key's route policy (GET /key/route). */
108
162
  export function getRoute(apiKey, baseUrl) {
109
163
  return request('GET', '/key/route', apiKey, undefined, baseUrl);
110
164
  }
165
+ /** Replaces the key's route policy (PUT /key/route). The server checks it before saving. */
111
166
  export function putRoute(apiKey, policy, baseUrl) {
112
167
  return request('PUT', '/key/route', apiKey, policy, baseUrl);
113
168
  }
169
+ /** Changes some top-level fields of the route policy and keeps the rest (PATCH /key/route). A list sent here replaces the whole list. */
114
170
  export function patchRoute(apiKey, fields, baseUrl) {
115
171
  return request('PATCH', '/key/route', apiKey, fields, baseUrl);
116
172
  }
173
+ /** Removes the key's route policy (DELETE /key/route). */
117
174
  export function clearRoute(apiKey, baseUrl) {
118
175
  return request('DELETE', '/key/route', apiKey, undefined, baseUrl);
119
176
  }
177
+ /** Which model a request would run on now, and why (POST /key/route/test). Nothing is charged and no model is called. */
120
178
  export function testRoute(apiKey, opts, baseUrl) {
121
179
  return request('POST', '/key/route/test', apiKey, opts, baseUrl);
122
180
  }
@@ -130,9 +188,15 @@ export function parseCondition(raw) {
130
188
  const value = text === 'true' ? true : text === 'false' ? false : text !== '' && Number.isFinite(Number(text)) ? Number(text) : text;
131
189
  return { [name]: value };
132
190
  }
191
+ /**
192
+ * Makes a child key that spends this key's balance up to `limit_usd`
193
+ * (POST /key/child). No credit moves. `ttl_hours` defaults to 24 on the server.
194
+ * A child key cannot make children.
195
+ */
133
196
  export function createChild(apiKey, opts, baseUrl) {
134
197
  return request('POST', '/key/child', apiKey, opts, baseUrl);
135
198
  }
199
+ /** The child keys this key made (GET /key/children). */
136
200
  export function listChildren(apiKey, baseUrl) {
137
201
  return request('GET', '/key/children', apiKey, undefined, baseUrl);
138
202
  }
@@ -154,6 +218,11 @@ export function parseBuyCoin(coin) {
154
218
  export function requestSolanaPayment(apiKey, opts, baseUrl) {
155
219
  return request('POST', '/purchase/solana', apiKey, { ...opts, target_api_key: apiKey }, baseUrl);
156
220
  }
221
+ /**
222
+ * Where a payment stands (GET /purchase/{id}/status). The server checks the
223
+ * chain on each call. The recovery code, when given, goes in the
224
+ * `x-phantom-recovery-code` header.
225
+ */
157
226
  export function getPaymentStatus(apiKey, paymentId, baseUrl, recoveryCode) {
158
227
  return request('GET', `/purchase/${encodeURIComponent(paymentId)}/status`, apiKey, undefined, baseUrl, recoveryCode ? { 'x-phantom-recovery-code': recoveryCode } : {});
159
228
  }
@@ -180,9 +249,11 @@ export async function waitForPayment(apiKey, paymentId, opts = {}) {
180
249
  await sleep(intervalMs);
181
250
  }
182
251
  }
252
+ /** Issues a new key and retires this one (POST /key/rotate). */
183
253
  export function rotateKey(apiKey, baseUrl) {
184
254
  return request('POST', '/key/rotate', apiKey, undefined, baseUrl);
185
255
  }
256
+ /** Revokes the key it is called with (DELETE /key). Its children stop too, and credit left on it is forfeited. */
186
257
  export function burnKey(apiKey, baseUrl) {
187
258
  return request('DELETE', '/key', apiKey, undefined, baseUrl);
188
259
  }
@@ -194,6 +265,11 @@ export function burnKey(apiKey, baseUrl) {
194
265
  */
195
266
  /** Matches RECEIPT_VERSION in lib/receipts.ts. */
196
267
  const RECEIPT_VERSION = 1;
268
+ /**
269
+ * Checks a compact receipt (`payload.signature`, both base64url) against the
270
+ * public key from GET /receipts/key. Needs no API key.
271
+ * @throws {@link PhantomApiError} when the public key cannot be fetched.
272
+ */
197
273
  export async function checkReceipt(compact, baseUrl) {
198
274
  const res = await fetch(`${apiBase(baseUrl)}/receipts/key`);
199
275
  const json = (await res.json().catch(() => ({})));
@@ -280,10 +356,23 @@ export async function verifyModel(apiKey, model, baseUrl) {
280
356
  * was asked for, so a wrong or hostile PHANTOM_BASE_URL cannot make the wallet
281
357
  * sign more.
282
358
  */
359
+ /** The coins an agent wallet can pay in, with the decimal places of each. */
283
360
  export const WALLET_COINS = {
284
- sol: { decimals: 9 },
285
- usdc: { decimals: 6 },
286
- usdcsol: { decimals: 6 },
361
+ /** SOL, counted in lamports. */
362
+ sol: {
363
+ /** A SOL has 9 decimal places. */
364
+ decimals: 9,
365
+ },
366
+ /** USDC on Solana. */
367
+ usdc: {
368
+ /** A USDC has 6 decimal places. */
369
+ decimals: 6,
370
+ },
371
+ /** The older spelling of usdc. */
372
+ usdcsol: {
373
+ /** A USDC has 6 decimal places. */
374
+ decimals: 6,
375
+ },
287
376
  };
288
377
  const USDC_MINT = {
289
378
  mainnet: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
@@ -299,6 +388,7 @@ const READONLY = 0;
299
388
  const WRITABLE = 1;
300
389
  const READONLY_SIGNER = 2;
301
390
  const WRITABLE_SIGNER = 3;
391
+ /** True when `coin` is a name in {@link WALLET_COINS}. */
302
392
  export function isWalletCoin(coin) {
303
393
  return Object.prototype.hasOwnProperty.call(WALLET_COINS, coin);
304
394
  }
@@ -322,6 +412,93 @@ function stateDir(env) {
322
412
  function walletsDir(env) {
323
413
  return path.join(stateDir(env), 'wallets');
324
414
  }
415
+ // ── secrets ──────────────────────────────────────────────────────────────────
416
+ /**
417
+ * On macOS, saved keys, wallets and the mail login live in the login Keychain
418
+ * rather than in files. The file stays, holding only a pointer
419
+ * (`pai-keychain:<id>`), so listing and existence checks work the same on
420
+ * every system. Elsewhere, or with PAI_KEYCHAIN=0, the file holds the secret,
421
+ * readable by this user only.
422
+ *
423
+ * Items are written through /usr/bin/security, which the Keychain then trusts
424
+ * to read them back, so there is no prompt. That also means any program
425
+ * running as this user can read them the same way: the Keychain keeps secrets
426
+ * out of files, backups and sync folders, not away from local programs.
427
+ *
428
+ * A secret file written before this is moved into the Keychain the first time
429
+ * it is read, and replaced with a pointer only after the Keychain returns it
430
+ * intact.
431
+ */
432
+ const KEYCHAIN_SERVICE = 'pai';
433
+ const KEYCHAIN_POINTER = 'pai-keychain:';
434
+ /** Reads and writes pai's items in the macOS login Keychain through /usr/bin/security, under the service name `pai`. */
435
+ export const keychain = {
436
+ /** The secret saved under `id`, or null when there is none or the Keychain can't be read. */
437
+ get(id) {
438
+ const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', id, '-w'], { encoding: 'utf-8' });
439
+ return r.status === 0 ? Buffer.from(r.stdout.trim(), 'base64').toString('utf-8') : null;
440
+ },
441
+ /** Saves `value` under `id`, replacing what was there. Returns false when the Keychain refuses, as it does when locked. */
442
+ set(id, label, value) {
443
+ // Sent on stdin rather than as an argument, so the secret never shows in
444
+ // the process list. Base64 keeps it one unquoted word.
445
+ const cmd = `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${id} -l "pai ${label}" -w ${Buffer.from(value, 'utf-8').toString('base64')}\n`;
446
+ return spawnSync('/usr/bin/security', ['-i'], { input: cmd, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0;
447
+ },
448
+ /** Removes the item saved under `id`, if any. */
449
+ delete(id) {
450
+ spawnSync('/usr/bin/security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', id], { stdio: 'ignore' });
451
+ },
452
+ };
453
+ function keychainOn(env) {
454
+ const flag = env.PAI_KEYCHAIN ?? process.env.PAI_KEYCHAIN;
455
+ return flag === '1' || (process.platform === 'darwin' && flag !== '0');
456
+ }
457
+ function keychainId(file) {
458
+ if (!existsSync(file))
459
+ return null;
460
+ const raw = readFileSync(file, 'utf-8').trim();
461
+ return raw.startsWith(KEYCHAIN_POINTER) ? raw.slice(KEYCHAIN_POINTER.length) : null;
462
+ }
463
+ function writeSecret(env, file, value, opts = {}) {
464
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
465
+ let contents = value;
466
+ if (keychainOn(env)) {
467
+ const id = keychainId(file) ?? randomBytes(12).toString('hex');
468
+ if (!keychain.set(id, path.relative(stateDir(env), file), value)) {
469
+ die('Could not save to the macOS Keychain. If it is locked (common over SSH), run: security unlock-keychain. Or set PAI_KEYCHAIN=0 to save to a file.', 'keychain_failed');
470
+ }
471
+ contents = KEYCHAIN_POINTER + id + '\n';
472
+ }
473
+ writeFileSync(file, contents, { mode: 0o600, flag: opts.flag });
474
+ // `mode` applies only when the file is created; tighten one that was already there.
475
+ chmodSync(file, 0o600);
476
+ }
477
+ function readSecret(env, file) {
478
+ const raw = readFileSync(file, 'utf-8');
479
+ if (raw.trim().startsWith(KEYCHAIN_POINTER)) {
480
+ const value = keychain.get(raw.trim().slice(KEYCHAIN_POINTER.length));
481
+ if (value === null)
482
+ die(`${file} points to a macOS Keychain item that could not be read. If the Keychain is locked (common over SSH), run: security unlock-keychain`, 'keychain_failed');
483
+ return value;
484
+ }
485
+ if (keychainOn(env)) {
486
+ const id = randomBytes(12).toString('hex');
487
+ if (keychain.set(id, path.relative(stateDir(env), file), raw)) {
488
+ if (keychain.get(id) === raw)
489
+ writeFileSync(file, KEYCHAIN_POINTER + id + '\n', { mode: 0o600 });
490
+ else
491
+ keychain.delete(id);
492
+ }
493
+ }
494
+ return raw;
495
+ }
496
+ function removeSecret(file) {
497
+ const id = keychainId(file);
498
+ if (id)
499
+ keychain.delete(id);
500
+ rmSync(file);
501
+ }
325
502
  // ── saved key ────────────────────────────────────────────────────────────────
326
503
  /**
327
504
  * `login` saves the key to a file only the user can read, so an agent that
@@ -348,6 +525,11 @@ function namedKeyPath(env, name) {
348
525
  export function keyId(key) {
349
526
  return createHash('sha256').update(key.trim()).digest('hex').slice(0, 12);
350
527
  }
528
+ /**
529
+ * Which key a command runs as: the saved key named by PHANTOM_KEY_NAME, then
530
+ * PHANTOM_API_KEY, then the login key. It only looks; {@link resolveApiKey}
531
+ * reads the key.
532
+ */
351
533
  export function keySource(env) {
352
534
  // A named key is the more specific choice, so it wins. A subagent started
353
535
  // with PHANTOM_KEY_NAME=<child> from a shell that also exports the parent's
@@ -358,6 +540,7 @@ export function keySource(env) {
358
540
  return { from: 'env' };
359
541
  return existsSync(keyPath(env)) ? { from: 'login' } : { from: 'none' };
360
542
  }
543
+ /** The key a command runs as, chosen by {@link keySource}. An empty string when there is none. */
361
544
  export function resolveApiKey(env) {
362
545
  const src = keySource(env);
363
546
  if (src.from === 'env')
@@ -365,36 +548,47 @@ export function resolveApiKey(env) {
365
548
  if (src.from === 'named')
366
549
  return readNamedKey(env, src.name);
367
550
  if (src.from === 'login')
368
- return readFileSync(keyPath(env), 'utf-8').trim();
551
+ return readSecret(env, keyPath(env)).trim();
369
552
  return '';
370
553
  }
371
- function writeKeyFile(file, key) {
554
+ function writeKeyFile(env, file, key) {
372
555
  const k = key.trim();
373
556
  if (!k.startsWith('sk-phantom-'))
374
557
  die('That is not a Phantom AI key. Keys start with sk-phantom-');
375
- mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
376
- writeFileSync(file, k + '\n', { mode: 0o600 });
558
+ writeSecret(env, file, k + '\n');
377
559
  }
560
+ /**
561
+ * A key saved by name.
562
+ * @throws {@link CliError} when no key has that name, or the name is not valid.
563
+ */
378
564
  export function readNamedKey(env, name) {
379
565
  const file = namedKeyPath(env, name);
380
566
  if (!existsSync(file))
381
567
  die(`No saved key named ${name}. See: pai key list`);
382
- return readFileSync(file, 'utf-8').trim();
568
+ return readSecret(env, file).trim();
383
569
  }
570
+ /**
571
+ * Saves a key by name, in the Keychain or a mode 600 file (see {@link keychain}).
572
+ * Refuses a name already in use unless `replace` is set, and anything that
573
+ * doesn't start with `sk-phantom-`.
574
+ * @returns The name, and the key's {@link keyId}.
575
+ */
384
576
  export function saveNamedKey(env, name, key, opts = {}) {
385
577
  const file = namedKeyPath(env, name);
386
578
  if (existsSync(file) && !opts.replace)
387
579
  die(`A key named ${name} is already saved. Pick another name, or remove it first.`);
388
- writeKeyFile(file, key);
580
+ writeKeyFile(env, file, key);
389
581
  return { name, id: keyId(key) };
390
582
  }
583
+ /** Forgets a saved key. The key itself keeps working. */
391
584
  export function removeNamedKey(env, name) {
392
585
  const file = namedKeyPath(env, name);
393
586
  if (!existsSync(file))
394
587
  return { name, removed: false };
395
- rmSync(file);
588
+ removeSecret(file);
396
589
  return { name, removed: true };
397
590
  }
591
+ /** Every key saved by name, sorted, with its id. Never returns the keys. */
398
592
  export function listNamedKeys(env) {
399
593
  const dir = path.join(stateDir(env), 'keys');
400
594
  if (!existsSync(dir))
@@ -402,7 +596,7 @@ export function listNamedKeys(env) {
402
596
  const keys = readdirSync(dir)
403
597
  .filter((n) => KEY_NAME.test(n))
404
598
  .sort()
405
- .map((name) => ({ name, id: keyId(readFileSync(path.join(dir, name), 'utf-8')) }));
599
+ .map((name) => ({ name, id: keyId(readSecret(env, path.join(dir, name))) }));
406
600
  return { keys };
407
601
  }
408
602
  /** After a rotate, put the new key where the old one was saved. */
@@ -412,20 +606,22 @@ function replaceSavedKey(env, src, key) {
412
606
  return src.name;
413
607
  }
414
608
  if (src.from === 'login') {
415
- writeKeyFile(keyPath(env), key);
609
+ writeKeyFile(env, keyPath(env), key);
416
610
  return 'login';
417
611
  }
418
612
  return null;
419
613
  }
614
+ /** Saves the login key, the one used when neither PHANTOM_KEY_NAME nor PHANTOM_API_KEY is set. */
420
615
  export function saveApiKey(env, key) {
421
- writeKeyFile(keyPath(env), key);
616
+ writeKeyFile(env, keyPath(env), key);
422
617
  return { saved: keyPath(env) };
423
618
  }
619
+ /** Forgets the login key. */
424
620
  export function removeApiKey(env) {
425
621
  const p = keyPath(env);
426
622
  if (!existsSync(p))
427
623
  return { removed: false };
428
- rmSync(p);
624
+ removeSecret(p);
429
625
  return { removed: true };
430
626
  }
431
627
  function memoryDir(env, space) {
@@ -433,6 +629,7 @@ function memoryDir(env, space) {
433
629
  die(`Memory space names use letters, numbers, - and _ (up to 32): ${space}`);
434
630
  return path.join(stateDir(env), 'memory', space);
435
631
  }
632
+ /** The notebook to use: the `--space` flag, then PAI_MEMORY_SPACE, then PHANTOM_KEY_NAME, then `main`. The browser uses the same name. */
436
633
  export function memorySpace(env, flag) {
437
634
  return flag || env.PAI_MEMORY_SPACE || env.PHANTOM_KEY_NAME || 'main';
438
635
  }
@@ -472,6 +669,10 @@ function readNotes(env, space) {
472
669
  .sort()
473
670
  .map((f) => parseNote(space, f.slice(0, -3), readFileSync(path.join(dir, f), 'utf-8')));
474
671
  }
672
+ /**
673
+ * Saves a note as a markdown file with the title, time and tags at the top.
674
+ * @throws {@link CliError} when the text is empty or the space name is not valid.
675
+ */
475
676
  export function addMemory(env, space, text, opts = {}) {
476
677
  const body = text.trim();
477
678
  if (!body)
@@ -528,10 +729,15 @@ export function searchMemory(env, space, query, opts = {}) {
528
729
  .sort((a, b) => b.score - a.score || b.id.localeCompare(a.id));
529
730
  return hits.slice(0, opts.limit ?? 10);
530
731
  }
732
+ /** The newest notes in a space, optionally with one tag. 50 by default. */
531
733
  export function listMemory(env, space, opts = {}) {
532
734
  const notes = readNotes(env, space).filter((n) => !opts.tag || n.tags.includes(opts.tag));
533
735
  return notes.reverse().slice(0, opts.limit ?? 50);
534
736
  }
737
+ /**
738
+ * One note by id.
739
+ * @throws {@link CliError} when the id is not a note id or no such note exists.
740
+ */
535
741
  export function getMemory(env, space, id) {
536
742
  if (!NOTE_ID.test(id))
537
743
  die(`Not a note id: ${id}`);
@@ -540,6 +746,7 @@ export function getMemory(env, space, id) {
540
746
  die(`No note ${id} in space ${space}`);
541
747
  return parseNote(space, id, readFileSync(file, 'utf-8'));
542
748
  }
749
+ /** Deletes one note by id. */
543
750
  export function removeMemory(env, space, id) {
544
751
  if (!NOTE_ID.test(id))
545
752
  die(`Not a note id: ${id}`);
@@ -549,6 +756,7 @@ export function removeMemory(env, space, id) {
549
756
  rmSync(file);
550
757
  return { id, removed: true };
551
758
  }
759
+ /** Every notebook and how many notes it holds. */
552
760
  export function memorySpaces(env) {
553
761
  const root = path.join(stateDir(env), 'memory');
554
762
  if (!existsSync(root))
@@ -576,6 +784,11 @@ export function memorySpaces(env) {
576
784
  function onPath(bin) {
577
785
  return spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: 'ignore' }).status === 0;
578
786
  }
787
+ /**
788
+ * The environment agent-browser runs with for a space: AGENT_BROWSER_SESSION
789
+ * (default `pai-<space>`) and AGENT_BROWSER_PROFILE (default
790
+ * `browser/<space>` in the state folder). Values set in `env` win.
791
+ */
579
792
  export function browserEnv(env, space) {
580
793
  if (!KEY_NAME.test(space))
581
794
  die(`Browser space names use letters, numbers, - and _ (up to 32): ${space}`);
@@ -584,6 +797,7 @@ export function browserEnv(env, space) {
584
797
  AGENT_BROWSER_PROFILE: env.AGENT_BROWSER_PROFILE || path.join(stateDir(env), 'browser', space),
585
798
  };
586
799
  }
800
+ /** Whether agent-browser is installed, and the session and profile a space would use. */
587
801
  export function browserStatus(env, space) {
588
802
  const installed = onPath('agent-browser');
589
803
  const r = installed ? spawnSync('agent-browser', ['--version'], { encoding: 'utf-8' }) : null;
@@ -609,6 +823,11 @@ export function runBrowser(env, space, args) {
609
823
  return r.status ?? 1;
610
824
  }
611
825
  const SANDBOX_ENGINES = ['docker', 'podman'];
826
+ /**
827
+ * The container engine to run the sandbox with: PAI_SANDBOX_ENGINE if set,
828
+ * else docker, then podman. An engine counts only when it is installed and
829
+ * running. null when none is.
830
+ */
612
831
  export function sandboxEngine(env) {
613
832
  const wanted = env.PAI_SANDBOX_ENGINE;
614
833
  for (const engine of wanted ? [wanted] : SANDBOX_ENGINES) {
@@ -683,6 +902,12 @@ export function parseServer(raw, insecure = false) {
683
902
  const port = m[2] ? Number(m[2]) : 993;
684
903
  return { host: m[1], port, secure: !insecure && (port === 993 || port === 465), ...(insecure ? { insecure: true } : {}) };
685
904
  }
905
+ /**
906
+ * A mailbox login from an address and password. The servers come from
907
+ * `imap` and `smtp` when given, else from the preset for the address's
908
+ * domain (Gmail, Outlook, Hotmail, iCloud, Fastmail).
909
+ * @throws {@link CliError} when there is no preset and no servers were given, or no password.
910
+ */
686
911
  export function buildMailConfig(opts) {
687
912
  const domain = opts.user.split('@')[1]?.toLowerCase() ?? '';
688
913
  const preset = MAIL_PRESETS[domain];
@@ -694,19 +919,24 @@ export function buildMailConfig(opts) {
694
919
  die('mail setup needs the account password (for Gmail, an app password)');
695
920
  return { user: opts.user, pass: opts.pass, imap, smtp };
696
921
  }
922
+ /** Saves a mailbox login to mail.json, in the Keychain on macOS (see {@link keychain}). */
697
923
  export function saveMailConfig(env, config) {
698
- mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
699
- writeFileSync(mailPath(env), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
924
+ writeSecret(env, mailPath(env), JSON.stringify(config, null, 2) + '\n');
700
925
  return { user: config.user, imap: config.imap, smtp: config.smtp, saved: mailPath(env) };
701
926
  }
927
+ /** Builds a mailbox login with {@link buildMailConfig} and saves it, without checking it first. `pai mail setup` checks it before saving. */
702
928
  export function mailSetup(env, opts) {
703
929
  return saveMailConfig(env, buildMailConfig(opts));
704
930
  }
931
+ /**
932
+ * The saved mailbox login.
933
+ * @throws {@link CliError} `mail_missing` when `mail setup` has not run.
934
+ */
705
935
  export function mailConfig(env) {
706
936
  const file = mailPath(env);
707
937
  if (!existsSync(file))
708
938
  die('No mailbox set up. Run: pai mail setup --user you@example.com', 'mail_missing');
709
- return JSON.parse(readFileSync(file, 'utf-8'));
939
+ return JSON.parse(readSecret(env, file));
710
940
  }
711
941
  async function imapClient(cfg) {
712
942
  const { ImapFlow } = await import('imapflow');
@@ -726,6 +956,11 @@ async function imapClient(cfg) {
726
956
  function addr(list) {
727
957
  return (list ?? []).map((a) => (a.name ? `${a.name} <${a.address}>` : a.address ?? '')).join(', ');
728
958
  }
959
+ /**
960
+ * The newest messages in a folder (INBOX by default), newest first, over
961
+ * IMAP. `query` matches words in the subject or body. 20 by default.
962
+ * @param unsaved A login not saved yet, so `mail setup` can check it first.
963
+ */
729
964
  export async function mailList(env, opts = {},
730
965
  // A login not saved yet, so `mail setup` can check it first.
731
966
  unsaved) {
@@ -767,6 +1002,11 @@ unsaved) {
767
1002
  await client.logout();
768
1003
  }
769
1004
  }
1005
+ /**
1006
+ * One message as plain text, by uid. The body is cut at 20,000 characters so
1007
+ * one message can't fill an agent's context.
1008
+ * @throws {@link CliError} when the folder has no message with that uid.
1009
+ */
770
1010
  export async function mailRead(env, uid, opts = {}) {
771
1011
  const cfg = mailConfig(env);
772
1012
  const folder = opts.folder ?? 'INBOX';
@@ -802,7 +1042,7 @@ export async function mailRead(env, uid, opts = {}) {
802
1042
  }
803
1043
  }
804
1044
  async function compose(cfg, msg) {
805
- const { default: MailComposer } = await import('nodemailer/lib/mail-composer/index.js');
1045
+ const { default: MailComposer } = await import('nodemailer/lib/mail-composer');
806
1046
  const mail = new MailComposer({
807
1047
  from: cfg.from ?? cfg.user,
808
1048
  to: msg.to,
@@ -828,6 +1068,7 @@ export async function mailDraft(env, msg) {
828
1068
  await client.logout();
829
1069
  }
830
1070
  }
1071
+ /** Whether sending is on (PAI_MAIL_SEND=1) and the daily recipient cap (PAI_MAIL_MAX_PER_DAY, default 10; 0 when it is not a positive number). */
831
1072
  export function mailSendLimit(env) {
832
1073
  const perDay = Number(env.PAI_MAIL_MAX_PER_DAY ?? 10);
833
1074
  return { allowed: env.PAI_MAIL_SEND === '1', perDay: Number.isFinite(perDay) && perDay > 0 ? perDay : 0 };
@@ -911,11 +1152,39 @@ export async function mailSend(env, msg, now = Date.now()) {
911
1152
  * server is extra, for agents that support MCP; `setup` only prints how to add
912
1153
  * it unless called with --mcp.
913
1154
  */
1155
+ /**
1156
+ * The agents `pai setup` knows. `home` is the folder in the home directory
1157
+ * whose presence means the agent is installed; `skills` is where the skill goes.
1158
+ */
914
1159
  export const SETUP_AGENTS = {
915
- pi: { home: '.pi', skills: '.agents/skills' },
916
- codex: { home: '.codex', skills: '.agents/skills' },
917
- claude: { home: '.claude', skills: '.claude/skills' },
918
- cursor: { home: '.cursor', skills: '.cursor/skills' },
1160
+ /** pi. */
1161
+ pi: {
1162
+ /** ~/.pi */
1163
+ home: '.pi',
1164
+ /** ~/.agents/skills */
1165
+ skills: '.agents/skills',
1166
+ },
1167
+ /** Codex. */
1168
+ codex: {
1169
+ /** ~/.codex */
1170
+ home: '.codex',
1171
+ /** ~/.agents/skills */
1172
+ skills: '.agents/skills',
1173
+ },
1174
+ /** Claude Code. */
1175
+ claude: {
1176
+ /** ~/.claude */
1177
+ home: '.claude',
1178
+ /** ~/.claude/skills */
1179
+ skills: '.claude/skills',
1180
+ },
1181
+ /** Cursor. */
1182
+ cursor: {
1183
+ /** ~/.cursor */
1184
+ home: '.cursor',
1185
+ /** ~/.cursor/skills */
1186
+ skills: '.cursor/skills',
1187
+ },
919
1188
  };
920
1189
  const MCP_ARGS = ['-y', '@connortessaro/pai', 'mcp'];
921
1190
  function mcpHint(agent) {
@@ -961,7 +1230,6 @@ export function claudeProvider(env, opts = {}) {
961
1230
  die(`${file} is not valid JSON; fix it first so setup doesn't overwrite it`);
962
1231
  }
963
1232
  }
964
- const baseUrl = (env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/v1\/?$/, '');
965
1233
  const ours = typeof settings.apiKeyHelper === 'string' && /pai.* key show$/.test(settings.apiKeyHelper);
966
1234
  const vars = { ...(settings.env ?? {}) };
967
1235
  if (opts.off) {
@@ -979,6 +1247,7 @@ export function claudeProvider(env, opts = {}) {
979
1247
  }
980
1248
  if (!resolveApiKey(env))
981
1249
  die('No API key to give Claude Code. Run: pai login', 'no_key');
1250
+ const baseUrl = httpsOnly(env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/v1\/?$/, '');
982
1251
  const onPathFn = opts.onPath ?? onPath;
983
1252
  // `pai` if it's installed, else this very pai by absolute path: Claude Code
984
1253
  // runs the helper outside this shell, so a relative or npx path won't do.
@@ -1000,6 +1269,13 @@ export function claudeProvider(env, opts = {}) {
1000
1269
  warnings.push('auto runs what the key\'s route policy picks; set one with: pai route set --models a,b');
1001
1270
  return { settings: file, on: true, base_url: baseUrl, model: vars.ANTHROPIC_MODEL, warnings };
1002
1271
  }
1272
+ /**
1273
+ * Installs the phantom-ai skill for one agent, or for every agent found
1274
+ * (see {@link SETUP_AGENTS}). With `mcp`, also adds the MCP server to Claude
1275
+ * Code, Codex (through their CLIs) or Cursor (~/.cursor/mcp.json). With
1276
+ * `provider`, also runs {@link claudeProvider}.
1277
+ * @throws {@link CliError} when no agent is found, the agent is unknown, or `provider` is set without claude.
1278
+ */
1003
1279
  export function setupAgents(env, opts = {}) {
1004
1280
  const home = env.HOME || os.homedir();
1005
1281
  const names = Object.keys(SETUP_AGENTS);
@@ -1090,6 +1366,10 @@ export function resolveWalletName(env, name) {
1090
1366
  die(`No saved wallet named ${pick}. Saved: ${saved.join(', ') || 'none'}`, 'wallet_missing');
1091
1367
  return pick;
1092
1368
  }
1369
+ /**
1370
+ * The most one wallet payment may spend, from PHANTOM_WALLET_MAX_USD.
1371
+ * @throws {@link CliError} `wallet_cap_missing` when it is unset or not a positive number, so no payment happens without a cap.
1372
+ */
1093
1373
  export function walletCap(env) {
1094
1374
  const cap = Number(env.PHANTOM_WALLET_MAX_USD);
1095
1375
  if (!env.PHANTOM_WALLET_MAX_USD || !Number.isFinite(cap) || cap <= 0) {
@@ -1223,6 +1503,10 @@ export async function checkPaymentRequest(env, req, amountUsd, coin) {
1223
1503
  die(`The payment request asks for ${Number(units) / 1e9} SOL (about $${usd.toFixed(2)}) for $${amountUsd}`, 'wallet_request_mismatch');
1224
1504
  }
1225
1505
  }
1506
+ /**
1507
+ * A 64-byte Solana secret key from base58 text or a JSON array of numbers (the format solana-keygen writes).
1508
+ * @throws {@link CliError} `wallet_invalid` when the result is not 64 bytes.
1509
+ */
1226
1510
  export function parseWalletSecret(raw) {
1227
1511
  const text = raw.trim();
1228
1512
  const bytes = text.startsWith('[')
@@ -1232,12 +1516,18 @@ export function parseWalletSecret(raw) {
1232
1516
  die('The wallet key must be a 64-byte Solana secret key', 'wallet_invalid');
1233
1517
  return bytes;
1234
1518
  }
1519
+ /**
1520
+ * The wallet that pays, as a signer: PHANTOM_WALLET_KEY, else
1521
+ * PHANTOM_WALLET_FILE, else the saved wallet {@link resolveWalletName} picks.
1522
+ */
1235
1523
  export async function loadWallet(env, name) {
1236
1524
  const which = resolveWalletName(env, name);
1237
1525
  // By the environment, not the name, so a saved wallet may be called env or file.
1238
1526
  const raw = env.PHANTOM_WALLET_KEY
1239
1527
  ? env.PHANTOM_WALLET_KEY
1240
- : readFileSync(env.PHANTOM_WALLET_FILE ? env.PHANTOM_WALLET_FILE : walletPath(env, which), 'utf-8');
1528
+ : env.PHANTOM_WALLET_FILE
1529
+ ? readFileSync(env.PHANTOM_WALLET_FILE, 'utf-8')
1530
+ : readSecret(env, walletPath(env, which));
1241
1531
  return createKeyPairSignerFromBytes(parseWalletSecret(raw));
1242
1532
  }
1243
1533
  /** A new keypair as solana-keygen writes it: 32-byte seed then public key. */
@@ -1267,6 +1557,7 @@ function u64le(value) {
1267
1557
  new DataView(out.buffer).setBigUint64(0, value, true);
1268
1558
  return out;
1269
1559
  }
1560
+ /** A Solana System Program transfer of `lamports` from one address to another. */
1270
1561
  export function solTransferInstruction(from, to, lamports) {
1271
1562
  const data = new Uint8Array(12);
1272
1563
  new DataView(data.buffer).setUint32(0, 2, true); // SystemProgram::Transfer
@@ -1280,6 +1571,7 @@ export function solTransferInstruction(from, to, lamports) {
1280
1571
  data,
1281
1572
  };
1282
1573
  }
1574
+ /** The address of `owner`'s standard token account for `mint`. */
1283
1575
  export async function associatedTokenAddress(owner, mint) {
1284
1576
  const enc = getAddressEncoder();
1285
1577
  const [ata] = await getProgramDerivedAddress({
@@ -1288,6 +1580,7 @@ export async function associatedTokenAddress(owner, mint) {
1288
1580
  });
1289
1581
  return ata;
1290
1582
  }
1583
+ /** Creates `owner`'s token account for `mint`, paid by `payer`. Does nothing if the account exists. */
1291
1584
  export function createTokenAccountInstruction(payer, ata, owner, mint) {
1292
1585
  return {
1293
1586
  programAddress: address(ATA_PROGRAM),
@@ -1302,6 +1595,7 @@ export function createTokenAccountInstruction(payer, ata, owner, mint) {
1302
1595
  data: new Uint8Array([1]), // CreateIdempotent: a no-op if the account exists
1303
1596
  };
1304
1597
  }
1598
+ /** A token transfer of `amount` base units from one token account to another, checked against the mint and its decimals. */
1305
1599
  export function tokenTransferInstruction(source, mint, destination, owner, amount, decimals) {
1306
1600
  const data = new Uint8Array(10);
1307
1601
  data[0] = 12; // TransferChecked
@@ -1327,6 +1621,7 @@ async function tokenUnits(rpc, ata) {
1327
1621
  return BigInt(0); // no token account yet
1328
1622
  }
1329
1623
  }
1624
+ /** A wallet's address and its SOL and USDC balance, read from the Solana RPC. */
1330
1625
  export async function walletStatus(env, name) {
1331
1626
  const which = resolveWalletName(env, name);
1332
1627
  const wallet = await loadWallet(env, which);
@@ -1363,8 +1658,7 @@ export async function createWallet(env, name = 'main') {
1363
1658
  let created = false;
1364
1659
  if (!existsSync(file)) {
1365
1660
  const first = savedWalletNames(env).length === 0;
1366
- mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
1367
- writeFileSync(file, JSON.stringify(Array.from(newSecretKey())), { mode: 0o600, flag: 'wx' });
1661
+ writeSecret(env, file, JSON.stringify(Array.from(newSecretKey())), { flag: 'wx' });
1368
1662
  if (first)
1369
1663
  useWallet(env, name);
1370
1664
  created = true;
@@ -1446,12 +1740,20 @@ function pendingPath(apiKey, env) {
1446
1740
  * money may have left the wallet and paying again is the costly mistake.
1447
1741
  */
1448
1742
  function readPending(apiKey, env) {
1743
+ const file = pendingPath(apiKey, env);
1744
+ if (!existsSync(file))
1745
+ return null;
1746
+ // A file that exists but can't be read may record a payment already sent.
1747
+ // Treating it as "nothing pending" would pay again, so stop instead.
1449
1748
  try {
1450
- return JSON.parse(readFileSync(pendingPath(apiKey, env), 'utf-8'));
1749
+ const p = JSON.parse(readFileSync(file, 'utf-8'));
1750
+ if (p && typeof p === 'object' && typeof p.payment_id === 'string')
1751
+ return p;
1451
1752
  }
1452
1753
  catch {
1453
- return null;
1754
+ /* falls through */
1454
1755
  }
1756
+ die(`The pending payment record at ${file} is unreadable, so pai won't pay again in case it was already sent. Check the wallet's history, then delete the file.`, 'pending_corrupt');
1455
1757
  }
1456
1758
  /** Codes that mean Phantom AI reported the payment dead, so nothing more will be credited. */
1457
1759
  const PAYMENT_DEAD = new Set(['payment_expired', 'payment_failed', 'payment_refunded']);
@@ -1461,8 +1763,9 @@ function writePending(apiKey, env, p) {
1461
1763
  rmSync(file, { force: true });
1462
1764
  return;
1463
1765
  }
1464
- mkdirSync(path.dirname(file), { recursive: true });
1766
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
1465
1767
  writeFileSync(file, JSON.stringify(p), { mode: 0o600 });
1768
+ chmodSync(file, 0o600);
1466
1769
  }
1467
1770
  const COIN_LABEL = { usdc: 'USDC', usdcsol: 'USDC', sol: 'SOL' };
1468
1771
  const coinLabel = (c) => COIN_LABEL[c.toLowerCase()] ?? c.toUpperCase();
@@ -1598,6 +1901,7 @@ export async function autoTopup(apiKey, env, opts) {
1598
1901
  const payment = await buyAndPay(apiKey, env, opts);
1599
1902
  return { balance_usd: payment.balance_after_usd, below_usd: opts.below_usd, bought: true, payment };
1600
1903
  }
1904
+ /** `--table` output for one wallet. */
1601
1905
  export function tableWallet(d) {
1602
1906
  const w = d;
1603
1907
  return [
@@ -1609,6 +1913,7 @@ export function tableWallet(d) {
1609
1913
  ...(w.file ? [`file ${w.file}${w.created ? ' (new, back it up)' : ''}`] : []),
1610
1914
  ].join('\n');
1611
1915
  }
1916
+ /** `--table` output for a list of wallets, numbered. */
1612
1917
  export function tableWallets(d) {
1613
1918
  const { wallets } = d;
1614
1919
  if (wallets.length === 0)
@@ -1617,6 +1922,7 @@ export function tableWallets(d) {
1617
1922
  .map((w, i) => `${i + 1}. ${w.name}${w.default ? ' (default)' : ''} ${w.address} ${w.usdc} USDC ${w.sol} SOL`)
1618
1923
  .join('\n');
1619
1924
  }
1925
+ /** `--table` output for `buy --pay`. */
1620
1926
  export function tableSelfPay(d) {
1621
1927
  const r = d;
1622
1928
  return [
@@ -1641,6 +1947,7 @@ export function progress(io) {
1641
1947
  };
1642
1948
  const draw = () => io.stderr(`\r\x1b[2K${frames[(i = (i + 1) % frames.length)]} ${fit(text)}`);
1643
1949
  return {
1950
+ /** Shows `next` as the current step, and marks the one before it done. */
1644
1951
  step(next) {
1645
1952
  if (next === text)
1646
1953
  return;
@@ -1655,6 +1962,7 @@ export function progress(io) {
1655
1962
  draw();
1656
1963
  timer ??= setInterval(draw, 100);
1657
1964
  },
1965
+ /** Marks the current step done, and prints `final` if given. */
1658
1966
  end(final) {
1659
1967
  if (timer)
1660
1968
  clearInterval(timer);
@@ -1699,6 +2007,7 @@ async function pickWallet(env, io) {
1699
2007
  rl.close();
1700
2008
  }
1701
2009
  }
2010
+ /** `--table` output for `autotopup`. */
1702
2011
  export function tableAutoTopup(d) {
1703
2012
  const r = d;
1704
2013
  return [
@@ -1708,6 +2017,11 @@ export function tableAutoTopup(d) {
1708
2017
  ].join('\n');
1709
2018
  }
1710
2019
  // ── helpers ──────────────────────────────────────────────────────────────────
2020
+ /**
2021
+ * Prints an error to stderr as one line of JSON, `{"error":{...}}`, and
2022
+ * returns the exit code: 2 for a key the API rejected (401 or 403), the
2023
+ * {@link CliError}'s own code, or 1 for anything else.
2024
+ */
1711
2025
  export function handleError(err, stderr = (s) => process.stderr.write(s)) {
1712
2026
  if (err instanceof PhantomApiError) {
1713
2027
  stderr(JSON.stringify({
@@ -1725,6 +2039,11 @@ export function handleError(err, stderr = (s) => process.stderr.write(s)) {
1725
2039
  stderr(JSON.stringify({ error: { code: 'unknown', message: msg } }) + '\n');
1726
2040
  return 1;
1727
2041
  }
2042
+ /**
2043
+ * Reads `--name value`, `--name=value` and bare `--name` (true) from the
2044
+ * arguments. A value that starts with `--` counts as the next flag. Anything
2045
+ * not after a `--name` is skipped.
2046
+ */
1728
2047
  export function parseFlags(args) {
1729
2048
  const flags = {};
1730
2049
  for (let i = 0; i < args.length; i++) {
@@ -1744,6 +2063,10 @@ export function parseFlags(args) {
1744
2063
  }
1745
2064
  return flags;
1746
2065
  }
2066
+ /**
2067
+ * A flag's value as a number, or undefined when the flag is absent.
2068
+ * @throws {@link CliError} when the flag has no value or the value is not a number.
2069
+ */
1747
2070
  export function flagNum(flags, name) {
1748
2071
  const v = flags[name];
1749
2072
  if (v === undefined)
@@ -1756,6 +2079,7 @@ export function flagNum(flags, name) {
1756
2079
  die(`--${name} must be a number`);
1757
2080
  return n;
1758
2081
  }
2082
+ /** Prints a result: JSON by default, or `render(data)` with `--table`. */
1759
2083
  export function out(data, table, render, stdout = (s) => process.stdout.write(s)) {
1760
2084
  if (table) {
1761
2085
  stdout(render(data) + '\n');
@@ -1765,6 +2089,7 @@ export function out(data, table, render, stdout = (s) => process.stdout.write(s)
1765
2089
  }
1766
2090
  }
1767
2091
  // ── table renderers ──────────────────────────────────────────────────────────
2092
+ /** `--table` output for `balance`. */
1768
2093
  export function tableBalance(d) {
1769
2094
  const b = d;
1770
2095
  return [
@@ -1775,6 +2100,7 @@ export function tableBalance(d) {
1775
2100
  `expires_at ${b.expires_at}`,
1776
2101
  ].join('\n');
1777
2102
  }
2103
+ /** `--table` output for `budget`. */
1778
2104
  export function tableBudget(d) {
1779
2105
  const b = d;
1780
2106
  return [
@@ -1787,6 +2113,7 @@ export function tableBudget(d) {
1787
2113
  `rate_exceeded ${b.rate_exceeded}`,
1788
2114
  ].join('\n');
1789
2115
  }
2116
+ /** `--table` output for `plan`. */
1790
2117
  export function tablePlan(d) {
1791
2118
  const b = d;
1792
2119
  if (b.budget_usd === null)
@@ -1801,6 +2128,7 @@ export function tablePlan(d) {
1801
2128
  `ends ${b.period_ends ?? '—'}`,
1802
2129
  ].join('\n');
1803
2130
  }
2131
+ /** `--table` output for `route`. */
1804
2132
  export function tableRoute(d) {
1805
2133
  const p = d.route_policy;
1806
2134
  if (!p)
@@ -1815,6 +2143,7 @@ export function tableRoute(d) {
1815
2143
  ...(rules.length ? rules.map((r, i) => `rule ${String(i + 1).padEnd(11)}if ${Object.entries(r.if).map(([k, v]) => `${k}=${v}`).join(' ')} use ${r.use}`) : ['rules none (auto runs the first model)']),
1816
2144
  ].join('\n');
1817
2145
  }
2146
+ /** `--table` output for `route test`. */
1818
2147
  export function tableRouteTest(d) {
1819
2148
  const t = d;
1820
2149
  return [`model ${t.model}`, `reason ${t.reason}`].join('\n');
@@ -1826,6 +2155,7 @@ function savedChild(env, name, result) {
1826
2155
  delete rest.api_key;
1827
2156
  return { ...rest, saved_as: saved.name, id: saved.id };
1828
2157
  }
2158
+ /** `--table` output for `memory list` and `memory search`. */
1829
2159
  export function tableNotes(d) {
1830
2160
  const { notes } = d;
1831
2161
  if (notes.length === 0)
@@ -1834,10 +2164,12 @@ export function tableNotes(d) {
1834
2164
  .map((n) => `${n.id} ${n.title}${n.tags.length ? ` [${n.tags.join(', ')}]` : ''}`)
1835
2165
  .join('\n');
1836
2166
  }
2167
+ /** `--table` output for `memory show`. */
1837
2168
  export function tableNote(d) {
1838
2169
  const n = d;
1839
2170
  return [`# ${n.title}`, `${n.id} · ${n.space}${n.tags.length ? ` · ${n.tags.join(', ')}` : ''}`, '', n.text].join('\n');
1840
2171
  }
2172
+ /** `--table` output for `key list`. */
1841
2173
  export function tableKeys(d) {
1842
2174
  const { keys } = d;
1843
2175
  if (keys.length === 0)
@@ -1849,6 +2181,7 @@ export function tableKeys(d) {
1849
2181
  })
1850
2182
  .join('\n');
1851
2183
  }
2184
+ /** `--table` output for `child`. */
1852
2185
  export function tableChild(d) {
1853
2186
  const c = d;
1854
2187
  return [
@@ -1859,6 +2192,7 @@ export function tableChild(d) {
1859
2192
  `parent_balance $${c.parent_balance_usd.toFixed(4)}`,
1860
2193
  ].join('\n');
1861
2194
  }
2195
+ /** `--table` output for `children`. */
1862
2196
  export function tableChildren(d) {
1863
2197
  const r = d;
1864
2198
  const cap = (v, unit = '') => (v === null ? 'uncapped' : '$' + v.toFixed(4) + unit);
@@ -1877,6 +2211,7 @@ export function tableChildren(d) {
1877
2211
  ];
1878
2212
  return lines.join('\n');
1879
2213
  }
2214
+ /** `--table` output for `buy` without `--pay`: what to send, and where. */
1880
2215
  export function tableSolanaPayment(d) {
1881
2216
  const p = d;
1882
2217
  return [
@@ -1888,6 +2223,7 @@ export function tableSolanaPayment(d) {
1888
2223
  `expires ${p.expires_at}`,
1889
2224
  ].join('\n');
1890
2225
  }
2226
+ /** `--table` output for `payment`. */
1891
2227
  export function tablePaymentStatus(d) {
1892
2228
  const s = d;
1893
2229
  return [
@@ -1895,6 +2231,7 @@ export function tablePaymentStatus(d) {
1895
2231
  `credit $${s.credit_usd.toFixed(4)}${s.topped_up ? ' added to this key' : ''}`,
1896
2232
  ].join('\n');
1897
2233
  }
2234
+ /** `--table` output for `rotate`. */
1898
2235
  export function tableRotate(d) {
1899
2236
  const r = d;
1900
2237
  return [
@@ -1902,6 +2239,7 @@ export function tableRotate(d) {
1902
2239
  `rotated_at ${r.rotated_at}`,
1903
2240
  ].join('\n');
1904
2241
  }
2242
+ /** `--table` output for `setup`. */
1905
2243
  export function tableSetup(d) {
1906
2244
  const r = d;
1907
2245
  return r.agents
@@ -1920,6 +2258,7 @@ export function tableSetup(d) {
1920
2258
  ].join('\n'))
1921
2259
  .join('\n');
1922
2260
  }
2261
+ /** `--table` output for `verify --model`. */
1923
2262
  export function tableVerifyModel(d) {
1924
2263
  const v = d;
1925
2264
  return [
@@ -1931,6 +2270,7 @@ export function tableVerifyModel(d) {
1931
2270
  ...(v.reason ? [`reason ${v.reason}`] : []),
1932
2271
  ].join('\n');
1933
2272
  }
2273
+ /** `--table` output for `verify --receipt`. */
1934
2274
  export function tableReceiptCheck(d) {
1935
2275
  const c = d;
1936
2276
  return [
@@ -1941,6 +2281,7 @@ export function tableReceiptCheck(d) {
1941
2281
  ...(c.reason ? [`reason ${c.reason}`] : []),
1942
2282
  ].join('\n');
1943
2283
  }
2284
+ /** `--table` output for `burn`. */
1944
2285
  export function tableBurn(d) {
1945
2286
  const b = d;
1946
2287
  return [
@@ -1957,8 +2298,9 @@ export function tableBurn(d) {
1957
2298
  export const ENV_VARS = [
1958
2299
  { name: 'PHANTOM_API_KEY', group: 'general', about: 'your Phantom AI API key (or save one with login)', secret: true },
1959
2300
  { name: 'PHANTOM_KEY_NAME', group: 'general', about: 'run as a key saved with key save or child --save; wins over PHANTOM_API_KEY' },
1960
- { name: 'PHANTOM_BASE_URL', group: 'general', about: `API base (default ${DEFAULT_BASE_URL})` },
2301
+ { name: 'PHANTOM_BASE_URL', group: 'general', about: `API base, https:// only (default ${DEFAULT_BASE_URL})` },
1961
2302
  { name: 'PHANTOM_STATE_DIR', group: 'general', about: 'where pai keeps keys, wallets, notes and mail settings (default ~/.config/phantom-key)' },
2303
+ { name: 'PAI_KEYCHAIN', group: 'general', about: '0 saves keys, wallets and the mail login to files instead of the macOS Keychain; 1 uses the Keychain on any system, which fails where /usr/bin/security is missing' },
1962
2304
  { name: 'PAI_MEMORY_SPACE', group: 'general', about: 'which space memory and browser use (default: the key name, or main)' },
1963
2305
  { name: 'PAI_SANDBOX_ENGINE', group: 'general', about: 'docker or podman (default: whichever is running)' },
1964
2306
  { name: 'PAI_MAIL_PASSWORD', group: 'general', about: 'the mail password for mail setup, instead of the prompt', secret: true },
@@ -1987,13 +2329,15 @@ function envHelp() {
1987
2329
  ' Saved wallets live in ~/.config/phantom-key/wallets/.',
1988
2330
  ].join('\n');
1989
2331
  }
2332
+ /** The text `pai --help` prints. */
1990
2333
  export const HELP = `
1991
2334
  pai — keys, money and subagents for AI agents (Phantom AI)
1992
2335
 
1993
2336
  Commands:
1994
2337
  balance show credit balance and expiry
1995
2338
  budget get show current budget / rate caps
1996
- budget set --budget <usd> set monthly budget cap
2339
+ budget set --budget <usd> set the spending cap per period (a calendar
2340
+ month, unless a plan set another length)
1997
2341
  --rate <usd/min> set per-minute rate cap (can combine)
1998
2342
  budget clear remove all caps
1999
2343
  child --limit <usd|none> mint a child key that spends this key's balance,
@@ -2010,7 +2354,7 @@ Commands:
2010
2354
  plan money for a set period, and how the pace is going
2011
2355
  plan set --amount <usd> [--days n] set a plan (default period: a calendar month)
2012
2356
  plan clear remove the plan
2013
- route show which model "auto" runs, and why
2357
+ route [get] show which model "auto" runs, and why
2014
2358
  route set --models a,b,c models for "auto", first is the default
2015
2359
  [--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
2016
2360
  [--stick-minutes n] keep a conversation's model this long (default 5)
@@ -2043,18 +2387,22 @@ Commands:
2043
2387
  burn [--key-name <name>] revoke this key (or a saved one) and forget the
2044
2388
  saved copy; its children stop too
2045
2389
  memory add <text> [--tag a,b] [--title t] keep a note (or pipe it on stdin)
2046
- memory search <words> [--tag t] [--any] notes that match, best first
2047
- memory list [--tag t] / show <id> / rm <id> newest notes, one note, forget one
2390
+ memory search <words> [--tag t] [--any] [--limit n] notes that match, best first (default 10)
2391
+ memory list [--tag t] [--limit n] / show <id> / rm <id> newest notes (default 50), one note, forget one
2048
2392
  memory spaces every notebook and how many notes it holds
2393
+ (memory takes --space <name>; notes stay on this machine)
2049
2394
  browser setup [--install] check (or install) agent-browser, a browser for agents
2050
- browser <command> drive it: open <url>, snapshot -i, click @e1, fill @e2 "x",
2395
+ browser status the same as browser setup
2396
+ browser <command> [--space <name>] drive it: open <url>, snapshot -i, click @e1, fill @e2 "x",
2051
2397
  screenshot; each space keeps its own session and logins
2052
2398
  mail setup --user <address> [--imap h:p] [--smtp h:p]
2053
2399
  connect your own mailbox with an app password
2054
2400
  (Gmail, Outlook, iCloud, Fastmail are preset)
2401
+ [--insecure] allow a plain-text login, for a local test server only
2055
2402
  mail [status] which mailbox, and whether sending is on
2056
2403
  mail list [--unread] [--from x] [--limit n] / mail search <words>
2057
2404
  mail read <uid> one message as text
2405
+ (list, search, read and --reply take --folder <name>, default INBOX)
2058
2406
  mail draft --to <a> --subject <s> [--reply <uid>] [--body "..."] (or pipe the body)
2059
2407
  save to Drafts; nothing is sent
2060
2408
  mail send (same flags) only with PAI_MAIL_SEND=1, to PAI_MAIL_SEND_TO, up to PAI_MAIL_MAX_PER_DAY recipients
@@ -2062,7 +2410,6 @@ Commands:
2062
2410
  sandbox run [--image i] [--net] [--write] [--timeout s] -- <command>
2063
2411
  run a command in a throwaway container: no network,
2064
2412
  this folder read-only, unless --net / --write
2065
- (all take --space <name>; notes stay on this machine)
2066
2413
  login [key] save your key so you don't need PHANTOM_API_KEY
2067
2414
  (prompts if no key is given)
2068
2415
  logout remove the saved key
@@ -2074,13 +2421,26 @@ Commands:
2074
2421
  verify --model <id> make one tiny call and check its signed receipt
2075
2422
  names the model you asked for (costs a fraction of a cent)
2076
2423
  verify --receipt <receipt> check a receipt you already have
2077
- mcp run as an MCP server over stdio
2424
+ mcp run as an MCP server over stdio
2078
2425
 
2079
2426
  Flags:
2080
- --table human-readable output instead of JSON
2427
+ --table human-readable output instead of JSON
2428
+ --help, -h print this help and run nothing
2429
+ --version, -v print the version
2081
2430
 
2082
2431
  ${envHelp()}
2083
2432
  `.trim();
2433
+ /**
2434
+ * Runs one CLI command and returns its exit code: 0 on success, 2 when the
2435
+ * API rejected the key, 1 for other errors. `verify` returns 1 when the check
2436
+ * fails. `browser` and `sandbox run` return the exit code of the command they
2437
+ * ran (124 when the sandbox times out). Errors go to `io.stderr` as JSON
2438
+ * through {@link handleError}. It never calls `process.exit`, except that
2439
+ * `autotopup --every` loops until the process is stopped.
2440
+ * @param argv The arguments after `pai`.
2441
+ * @param env The environment to read settings from.
2442
+ * @param io Where output goes, and whether stderr is a terminal (for progress lines).
2443
+ */
2084
2444
  export async function run(argv = process.argv.slice(2), env = process.env, io = {
2085
2445
  stdout: (s) => process.stdout.write(s),
2086
2446
  stderr: (s) => process.stderr.write(s),
@@ -2090,7 +2450,10 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
2090
2450
  io.stdout(VERSION + '\n');
2091
2451
  return 0;
2092
2452
  }
2093
- if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
2453
+ // `--help` anywhere before a `--` asks for help, so `pai setup --help`
2454
+ // prints it rather than running setup.
2455
+ const own = argv.includes('--') ? argv.slice(0, argv.indexOf('--')) : argv;
2456
+ if (argv.length === 0 || own.includes('--help') || own.includes('-h')) {
2094
2457
  io.stdout(HELP + '\n');
2095
2458
  return 0;
2096
2459
  }
@@ -2729,7 +3092,8 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
2729
3092
  // ── MCP server ──────────────────────────────────────────────────────────────
2730
3093
  /**
2731
3094
  * The same calls as the commands above, as MCP tools. Each tool reads the key
2732
- * (PHANTOM_API_KEY, else the one saved by login) and PHANTOM_BASE_URL when it
3095
+ * ({@link resolveApiKey}: PHANTOM_KEY_NAME, then PHANTOM_API_KEY, then the one
3096
+ * saved by login) and PHANTOM_BASE_URL when it
2733
3097
  * runs, so the server starts and lists its tools without a key and reports a
2734
3098
  * missing key as a tool error.
2735
3099
  */
@@ -2989,13 +3353,13 @@ export function createMcpServer(env = process.env) {
2989
3353
  annotations: { readOnlyHint: true, openWorldHint: true },
2990
3354
  }, ({ model }) => call((key, base) => testRoute(key, { model: model ?? 'auto' }, base)));
2991
3355
  server.registerTool('get_budget', {
2992
- description: 'Monthly spending cap and per-minute cap of the configured key, and what has been spent against each.',
3356
+ description: 'Spending cap for the period (a calendar month unless a plan set another length) and per-minute cap of the configured key, and what has been spent against each.',
2993
3357
  annotations: { readOnlyHint: true, openWorldHint: true },
2994
3358
  }, () => call(getBudget));
2995
3359
  server.registerTool('set_budget', {
2996
- description: 'Set or remove the monthly cap and per-minute cap on the configured key. Pass null to remove a cap.',
3360
+ description: 'Set or remove the spending cap for the period (a calendar month unless a plan set another length) and the per-minute cap on the configured key. Pass null to remove a cap.',
2997
3361
  inputSchema: {
2998
- budget_usd: usd.nullable().optional().describe('Monthly cap in USD, or null to remove it'),
3362
+ budget_usd: usd.nullable().optional().describe('Cap for the period in USD, or null to remove it'),
2999
3363
  rate_usd_per_min: usd.nullable().optional().describe('Per-minute cap in USD, or null to remove it'),
3000
3364
  },
3001
3365
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
@@ -3054,9 +3418,11 @@ export function createMcpServer(env = process.env) {
3054
3418
  }, (args) => call((key, base) => getPaymentStatus(key, args.payment_id, base)));
3055
3419
  return server;
3056
3420
  }
3421
+ /** Runs {@link createMcpServer} over stdio, as `pai mcp` does. */
3057
3422
  export async function startMcpServer(env = process.env) {
3058
3423
  await createMcpServer(env).connect(new StdioServerTransport());
3059
3424
  }
3425
+ /** The installed entry point: {@link run} with the process's arguments, then `process.exit` with its code when it is not 0. */
3060
3426
  export async function main() {
3061
3427
  const code = await run();
3062
3428
  if (code !== 0) {