@connortessaro/pai 0.4.3 → 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/README.md +108 -69
- package/dist/pai.mjs +402 -44
- package/package.json +7 -7
- package/skills/phantom-ai/SKILL.md +1 -1
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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;
|
|
@@ -1469,8 +1763,9 @@ function writePending(apiKey, env, p) {
|
|
|
1469
1763
|
rmSync(file, { force: true });
|
|
1470
1764
|
return;
|
|
1471
1765
|
}
|
|
1472
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
1766
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
1473
1767
|
writeFileSync(file, JSON.stringify(p), { mode: 0o600 });
|
|
1768
|
+
chmodSync(file, 0o600);
|
|
1474
1769
|
}
|
|
1475
1770
|
const COIN_LABEL = { usdc: 'USDC', usdcsol: 'USDC', sol: 'SOL' };
|
|
1476
1771
|
const coinLabel = (c) => COIN_LABEL[c.toLowerCase()] ?? c.toUpperCase();
|
|
@@ -1606,6 +1901,7 @@ export async function autoTopup(apiKey, env, opts) {
|
|
|
1606
1901
|
const payment = await buyAndPay(apiKey, env, opts);
|
|
1607
1902
|
return { balance_usd: payment.balance_after_usd, below_usd: opts.below_usd, bought: true, payment };
|
|
1608
1903
|
}
|
|
1904
|
+
/** `--table` output for one wallet. */
|
|
1609
1905
|
export function tableWallet(d) {
|
|
1610
1906
|
const w = d;
|
|
1611
1907
|
return [
|
|
@@ -1617,6 +1913,7 @@ export function tableWallet(d) {
|
|
|
1617
1913
|
...(w.file ? [`file ${w.file}${w.created ? ' (new, back it up)' : ''}`] : []),
|
|
1618
1914
|
].join('\n');
|
|
1619
1915
|
}
|
|
1916
|
+
/** `--table` output for a list of wallets, numbered. */
|
|
1620
1917
|
export function tableWallets(d) {
|
|
1621
1918
|
const { wallets } = d;
|
|
1622
1919
|
if (wallets.length === 0)
|
|
@@ -1625,6 +1922,7 @@ export function tableWallets(d) {
|
|
|
1625
1922
|
.map((w, i) => `${i + 1}. ${w.name}${w.default ? ' (default)' : ''} ${w.address} ${w.usdc} USDC ${w.sol} SOL`)
|
|
1626
1923
|
.join('\n');
|
|
1627
1924
|
}
|
|
1925
|
+
/** `--table` output for `buy --pay`. */
|
|
1628
1926
|
export function tableSelfPay(d) {
|
|
1629
1927
|
const r = d;
|
|
1630
1928
|
return [
|
|
@@ -1649,6 +1947,7 @@ export function progress(io) {
|
|
|
1649
1947
|
};
|
|
1650
1948
|
const draw = () => io.stderr(`\r\x1b[2K${frames[(i = (i + 1) % frames.length)]} ${fit(text)}`);
|
|
1651
1949
|
return {
|
|
1950
|
+
/** Shows `next` as the current step, and marks the one before it done. */
|
|
1652
1951
|
step(next) {
|
|
1653
1952
|
if (next === text)
|
|
1654
1953
|
return;
|
|
@@ -1663,6 +1962,7 @@ export function progress(io) {
|
|
|
1663
1962
|
draw();
|
|
1664
1963
|
timer ??= setInterval(draw, 100);
|
|
1665
1964
|
},
|
|
1965
|
+
/** Marks the current step done, and prints `final` if given. */
|
|
1666
1966
|
end(final) {
|
|
1667
1967
|
if (timer)
|
|
1668
1968
|
clearInterval(timer);
|
|
@@ -1707,6 +2007,7 @@ async function pickWallet(env, io) {
|
|
|
1707
2007
|
rl.close();
|
|
1708
2008
|
}
|
|
1709
2009
|
}
|
|
2010
|
+
/** `--table` output for `autotopup`. */
|
|
1710
2011
|
export function tableAutoTopup(d) {
|
|
1711
2012
|
const r = d;
|
|
1712
2013
|
return [
|
|
@@ -1716,6 +2017,11 @@ export function tableAutoTopup(d) {
|
|
|
1716
2017
|
].join('\n');
|
|
1717
2018
|
}
|
|
1718
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
|
+
*/
|
|
1719
2025
|
export function handleError(err, stderr = (s) => process.stderr.write(s)) {
|
|
1720
2026
|
if (err instanceof PhantomApiError) {
|
|
1721
2027
|
stderr(JSON.stringify({
|
|
@@ -1733,6 +2039,11 @@ export function handleError(err, stderr = (s) => process.stderr.write(s)) {
|
|
|
1733
2039
|
stderr(JSON.stringify({ error: { code: 'unknown', message: msg } }) + '\n');
|
|
1734
2040
|
return 1;
|
|
1735
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
|
+
*/
|
|
1736
2047
|
export function parseFlags(args) {
|
|
1737
2048
|
const flags = {};
|
|
1738
2049
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -1752,6 +2063,10 @@ export function parseFlags(args) {
|
|
|
1752
2063
|
}
|
|
1753
2064
|
return flags;
|
|
1754
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
|
+
*/
|
|
1755
2070
|
export function flagNum(flags, name) {
|
|
1756
2071
|
const v = flags[name];
|
|
1757
2072
|
if (v === undefined)
|
|
@@ -1764,6 +2079,7 @@ export function flagNum(flags, name) {
|
|
|
1764
2079
|
die(`--${name} must be a number`);
|
|
1765
2080
|
return n;
|
|
1766
2081
|
}
|
|
2082
|
+
/** Prints a result: JSON by default, or `render(data)` with `--table`. */
|
|
1767
2083
|
export function out(data, table, render, stdout = (s) => process.stdout.write(s)) {
|
|
1768
2084
|
if (table) {
|
|
1769
2085
|
stdout(render(data) + '\n');
|
|
@@ -1773,6 +2089,7 @@ export function out(data, table, render, stdout = (s) => process.stdout.write(s)
|
|
|
1773
2089
|
}
|
|
1774
2090
|
}
|
|
1775
2091
|
// ── table renderers ──────────────────────────────────────────────────────────
|
|
2092
|
+
/** `--table` output for `balance`. */
|
|
1776
2093
|
export function tableBalance(d) {
|
|
1777
2094
|
const b = d;
|
|
1778
2095
|
return [
|
|
@@ -1783,6 +2100,7 @@ export function tableBalance(d) {
|
|
|
1783
2100
|
`expires_at ${b.expires_at}`,
|
|
1784
2101
|
].join('\n');
|
|
1785
2102
|
}
|
|
2103
|
+
/** `--table` output for `budget`. */
|
|
1786
2104
|
export function tableBudget(d) {
|
|
1787
2105
|
const b = d;
|
|
1788
2106
|
return [
|
|
@@ -1795,6 +2113,7 @@ export function tableBudget(d) {
|
|
|
1795
2113
|
`rate_exceeded ${b.rate_exceeded}`,
|
|
1796
2114
|
].join('\n');
|
|
1797
2115
|
}
|
|
2116
|
+
/** `--table` output for `plan`. */
|
|
1798
2117
|
export function tablePlan(d) {
|
|
1799
2118
|
const b = d;
|
|
1800
2119
|
if (b.budget_usd === null)
|
|
@@ -1809,6 +2128,7 @@ export function tablePlan(d) {
|
|
|
1809
2128
|
`ends ${b.period_ends ?? '—'}`,
|
|
1810
2129
|
].join('\n');
|
|
1811
2130
|
}
|
|
2131
|
+
/** `--table` output for `route`. */
|
|
1812
2132
|
export function tableRoute(d) {
|
|
1813
2133
|
const p = d.route_policy;
|
|
1814
2134
|
if (!p)
|
|
@@ -1823,6 +2143,7 @@ export function tableRoute(d) {
|
|
|
1823
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)']),
|
|
1824
2144
|
].join('\n');
|
|
1825
2145
|
}
|
|
2146
|
+
/** `--table` output for `route test`. */
|
|
1826
2147
|
export function tableRouteTest(d) {
|
|
1827
2148
|
const t = d;
|
|
1828
2149
|
return [`model ${t.model}`, `reason ${t.reason}`].join('\n');
|
|
@@ -1834,6 +2155,7 @@ function savedChild(env, name, result) {
|
|
|
1834
2155
|
delete rest.api_key;
|
|
1835
2156
|
return { ...rest, saved_as: saved.name, id: saved.id };
|
|
1836
2157
|
}
|
|
2158
|
+
/** `--table` output for `memory list` and `memory search`. */
|
|
1837
2159
|
export function tableNotes(d) {
|
|
1838
2160
|
const { notes } = d;
|
|
1839
2161
|
if (notes.length === 0)
|
|
@@ -1842,10 +2164,12 @@ export function tableNotes(d) {
|
|
|
1842
2164
|
.map((n) => `${n.id} ${n.title}${n.tags.length ? ` [${n.tags.join(', ')}]` : ''}`)
|
|
1843
2165
|
.join('\n');
|
|
1844
2166
|
}
|
|
2167
|
+
/** `--table` output for `memory show`. */
|
|
1845
2168
|
export function tableNote(d) {
|
|
1846
2169
|
const n = d;
|
|
1847
2170
|
return [`# ${n.title}`, `${n.id} · ${n.space}${n.tags.length ? ` · ${n.tags.join(', ')}` : ''}`, '', n.text].join('\n');
|
|
1848
2171
|
}
|
|
2172
|
+
/** `--table` output for `key list`. */
|
|
1849
2173
|
export function tableKeys(d) {
|
|
1850
2174
|
const { keys } = d;
|
|
1851
2175
|
if (keys.length === 0)
|
|
@@ -1857,6 +2181,7 @@ export function tableKeys(d) {
|
|
|
1857
2181
|
})
|
|
1858
2182
|
.join('\n');
|
|
1859
2183
|
}
|
|
2184
|
+
/** `--table` output for `child`. */
|
|
1860
2185
|
export function tableChild(d) {
|
|
1861
2186
|
const c = d;
|
|
1862
2187
|
return [
|
|
@@ -1867,6 +2192,7 @@ export function tableChild(d) {
|
|
|
1867
2192
|
`parent_balance $${c.parent_balance_usd.toFixed(4)}`,
|
|
1868
2193
|
].join('\n');
|
|
1869
2194
|
}
|
|
2195
|
+
/** `--table` output for `children`. */
|
|
1870
2196
|
export function tableChildren(d) {
|
|
1871
2197
|
const r = d;
|
|
1872
2198
|
const cap = (v, unit = '') => (v === null ? 'uncapped' : '$' + v.toFixed(4) + unit);
|
|
@@ -1885,6 +2211,7 @@ export function tableChildren(d) {
|
|
|
1885
2211
|
];
|
|
1886
2212
|
return lines.join('\n');
|
|
1887
2213
|
}
|
|
2214
|
+
/** `--table` output for `buy` without `--pay`: what to send, and where. */
|
|
1888
2215
|
export function tableSolanaPayment(d) {
|
|
1889
2216
|
const p = d;
|
|
1890
2217
|
return [
|
|
@@ -1896,6 +2223,7 @@ export function tableSolanaPayment(d) {
|
|
|
1896
2223
|
`expires ${p.expires_at}`,
|
|
1897
2224
|
].join('\n');
|
|
1898
2225
|
}
|
|
2226
|
+
/** `--table` output for `payment`. */
|
|
1899
2227
|
export function tablePaymentStatus(d) {
|
|
1900
2228
|
const s = d;
|
|
1901
2229
|
return [
|
|
@@ -1903,6 +2231,7 @@ export function tablePaymentStatus(d) {
|
|
|
1903
2231
|
`credit $${s.credit_usd.toFixed(4)}${s.topped_up ? ' added to this key' : ''}`,
|
|
1904
2232
|
].join('\n');
|
|
1905
2233
|
}
|
|
2234
|
+
/** `--table` output for `rotate`. */
|
|
1906
2235
|
export function tableRotate(d) {
|
|
1907
2236
|
const r = d;
|
|
1908
2237
|
return [
|
|
@@ -1910,6 +2239,7 @@ export function tableRotate(d) {
|
|
|
1910
2239
|
`rotated_at ${r.rotated_at}`,
|
|
1911
2240
|
].join('\n');
|
|
1912
2241
|
}
|
|
2242
|
+
/** `--table` output for `setup`. */
|
|
1913
2243
|
export function tableSetup(d) {
|
|
1914
2244
|
const r = d;
|
|
1915
2245
|
return r.agents
|
|
@@ -1928,6 +2258,7 @@ export function tableSetup(d) {
|
|
|
1928
2258
|
].join('\n'))
|
|
1929
2259
|
.join('\n');
|
|
1930
2260
|
}
|
|
2261
|
+
/** `--table` output for `verify --model`. */
|
|
1931
2262
|
export function tableVerifyModel(d) {
|
|
1932
2263
|
const v = d;
|
|
1933
2264
|
return [
|
|
@@ -1939,6 +2270,7 @@ export function tableVerifyModel(d) {
|
|
|
1939
2270
|
...(v.reason ? [`reason ${v.reason}`] : []),
|
|
1940
2271
|
].join('\n');
|
|
1941
2272
|
}
|
|
2273
|
+
/** `--table` output for `verify --receipt`. */
|
|
1942
2274
|
export function tableReceiptCheck(d) {
|
|
1943
2275
|
const c = d;
|
|
1944
2276
|
return [
|
|
@@ -1949,6 +2281,7 @@ export function tableReceiptCheck(d) {
|
|
|
1949
2281
|
...(c.reason ? [`reason ${c.reason}`] : []),
|
|
1950
2282
|
].join('\n');
|
|
1951
2283
|
}
|
|
2284
|
+
/** `--table` output for `burn`. */
|
|
1952
2285
|
export function tableBurn(d) {
|
|
1953
2286
|
const b = d;
|
|
1954
2287
|
return [
|
|
@@ -1965,8 +2298,9 @@ export function tableBurn(d) {
|
|
|
1965
2298
|
export const ENV_VARS = [
|
|
1966
2299
|
{ name: 'PHANTOM_API_KEY', group: 'general', about: 'your Phantom AI API key (or save one with login)', secret: true },
|
|
1967
2300
|
{ name: 'PHANTOM_KEY_NAME', group: 'general', about: 'run as a key saved with key save or child --save; wins over PHANTOM_API_KEY' },
|
|
1968
|
-
{ 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})` },
|
|
1969
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' },
|
|
1970
2304
|
{ name: 'PAI_MEMORY_SPACE', group: 'general', about: 'which space memory and browser use (default: the key name, or main)' },
|
|
1971
2305
|
{ name: 'PAI_SANDBOX_ENGINE', group: 'general', about: 'docker or podman (default: whichever is running)' },
|
|
1972
2306
|
{ name: 'PAI_MAIL_PASSWORD', group: 'general', about: 'the mail password for mail setup, instead of the prompt', secret: true },
|
|
@@ -1995,13 +2329,15 @@ function envHelp() {
|
|
|
1995
2329
|
' Saved wallets live in ~/.config/phantom-key/wallets/.',
|
|
1996
2330
|
].join('\n');
|
|
1997
2331
|
}
|
|
2332
|
+
/** The text `pai --help` prints. */
|
|
1998
2333
|
export const HELP = `
|
|
1999
2334
|
pai — keys, money and subagents for AI agents (Phantom AI)
|
|
2000
2335
|
|
|
2001
2336
|
Commands:
|
|
2002
2337
|
balance show credit balance and expiry
|
|
2003
2338
|
budget get show current budget / rate caps
|
|
2004
|
-
budget set --budget <usd> set
|
|
2339
|
+
budget set --budget <usd> set the spending cap per period (a calendar
|
|
2340
|
+
month, unless a plan set another length)
|
|
2005
2341
|
--rate <usd/min> set per-minute rate cap (can combine)
|
|
2006
2342
|
budget clear remove all caps
|
|
2007
2343
|
child --limit <usd|none> mint a child key that spends this key's balance,
|
|
@@ -2018,7 +2354,7 @@ Commands:
|
|
|
2018
2354
|
plan money for a set period, and how the pace is going
|
|
2019
2355
|
plan set --amount <usd> [--days n] set a plan (default period: a calendar month)
|
|
2020
2356
|
plan clear remove the plan
|
|
2021
|
-
route
|
|
2357
|
+
route [get] show which model "auto" runs, and why
|
|
2022
2358
|
route set --models a,b,c models for "auto", first is the default
|
|
2023
2359
|
[--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
|
|
2024
2360
|
[--stick-minutes n] keep a conversation's model this long (default 5)
|
|
@@ -2051,18 +2387,22 @@ Commands:
|
|
|
2051
2387
|
burn [--key-name <name>] revoke this key (or a saved one) and forget the
|
|
2052
2388
|
saved copy; its children stop too
|
|
2053
2389
|
memory add <text> [--tag a,b] [--title t] keep a note (or pipe it on stdin)
|
|
2054
|
-
memory search <words> [--tag t] [--any]
|
|
2055
|
-
memory list [--tag t] / show <id> / rm <id>
|
|
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
|
|
2056
2392
|
memory spaces every notebook and how many notes it holds
|
|
2393
|
+
(memory takes --space <name>; notes stay on this machine)
|
|
2057
2394
|
browser setup [--install] check (or install) agent-browser, a browser for agents
|
|
2058
|
-
browser
|
|
2395
|
+
browser status the same as browser setup
|
|
2396
|
+
browser <command> [--space <name>] drive it: open <url>, snapshot -i, click @e1, fill @e2 "x",
|
|
2059
2397
|
screenshot; each space keeps its own session and logins
|
|
2060
2398
|
mail setup --user <address> [--imap h:p] [--smtp h:p]
|
|
2061
2399
|
connect your own mailbox with an app password
|
|
2062
2400
|
(Gmail, Outlook, iCloud, Fastmail are preset)
|
|
2401
|
+
[--insecure] allow a plain-text login, for a local test server only
|
|
2063
2402
|
mail [status] which mailbox, and whether sending is on
|
|
2064
2403
|
mail list [--unread] [--from x] [--limit n] / mail search <words>
|
|
2065
2404
|
mail read <uid> one message as text
|
|
2405
|
+
(list, search, read and --reply take --folder <name>, default INBOX)
|
|
2066
2406
|
mail draft --to <a> --subject <s> [--reply <uid>] [--body "..."] (or pipe the body)
|
|
2067
2407
|
save to Drafts; nothing is sent
|
|
2068
2408
|
mail send (same flags) only with PAI_MAIL_SEND=1, to PAI_MAIL_SEND_TO, up to PAI_MAIL_MAX_PER_DAY recipients
|
|
@@ -2070,7 +2410,6 @@ Commands:
|
|
|
2070
2410
|
sandbox run [--image i] [--net] [--write] [--timeout s] -- <command>
|
|
2071
2411
|
run a command in a throwaway container: no network,
|
|
2072
2412
|
this folder read-only, unless --net / --write
|
|
2073
|
-
(all take --space <name>; notes stay on this machine)
|
|
2074
2413
|
login [key] save your key so you don't need PHANTOM_API_KEY
|
|
2075
2414
|
(prompts if no key is given)
|
|
2076
2415
|
logout remove the saved key
|
|
@@ -2082,13 +2421,26 @@ Commands:
|
|
|
2082
2421
|
verify --model <id> make one tiny call and check its signed receipt
|
|
2083
2422
|
names the model you asked for (costs a fraction of a cent)
|
|
2084
2423
|
verify --receipt <receipt> check a receipt you already have
|
|
2085
|
-
mcp
|
|
2424
|
+
mcp run as an MCP server over stdio
|
|
2086
2425
|
|
|
2087
2426
|
Flags:
|
|
2088
|
-
--table
|
|
2427
|
+
--table human-readable output instead of JSON
|
|
2428
|
+
--help, -h print this help and run nothing
|
|
2429
|
+
--version, -v print the version
|
|
2089
2430
|
|
|
2090
2431
|
${envHelp()}
|
|
2091
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
|
+
*/
|
|
2092
2444
|
export async function run(argv = process.argv.slice(2), env = process.env, io = {
|
|
2093
2445
|
stdout: (s) => process.stdout.write(s),
|
|
2094
2446
|
stderr: (s) => process.stderr.write(s),
|
|
@@ -2098,7 +2450,10 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
|
|
|
2098
2450
|
io.stdout(VERSION + '\n');
|
|
2099
2451
|
return 0;
|
|
2100
2452
|
}
|
|
2101
|
-
|
|
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')) {
|
|
2102
2457
|
io.stdout(HELP + '\n');
|
|
2103
2458
|
return 0;
|
|
2104
2459
|
}
|
|
@@ -2737,7 +3092,8 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
|
|
|
2737
3092
|
// ── MCP server ──────────────────────────────────────────────────────────────
|
|
2738
3093
|
/**
|
|
2739
3094
|
* The same calls as the commands above, as MCP tools. Each tool reads the key
|
|
2740
|
-
* (PHANTOM_API_KEY,
|
|
3095
|
+
* ({@link resolveApiKey}: PHANTOM_KEY_NAME, then PHANTOM_API_KEY, then the one
|
|
3096
|
+
* saved by login) and PHANTOM_BASE_URL when it
|
|
2741
3097
|
* runs, so the server starts and lists its tools without a key and reports a
|
|
2742
3098
|
* missing key as a tool error.
|
|
2743
3099
|
*/
|
|
@@ -2997,13 +3353,13 @@ export function createMcpServer(env = process.env) {
|
|
|
2997
3353
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2998
3354
|
}, ({ model }) => call((key, base) => testRoute(key, { model: model ?? 'auto' }, base)));
|
|
2999
3355
|
server.registerTool('get_budget', {
|
|
3000
|
-
description: '
|
|
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.',
|
|
3001
3357
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3002
3358
|
}, () => call(getBudget));
|
|
3003
3359
|
server.registerTool('set_budget', {
|
|
3004
|
-
description: 'Set or remove the
|
|
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.',
|
|
3005
3361
|
inputSchema: {
|
|
3006
|
-
budget_usd: usd.nullable().optional().describe('
|
|
3362
|
+
budget_usd: usd.nullable().optional().describe('Cap for the period in USD, or null to remove it'),
|
|
3007
3363
|
rate_usd_per_min: usd.nullable().optional().describe('Per-minute cap in USD, or null to remove it'),
|
|
3008
3364
|
},
|
|
3009
3365
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -3062,9 +3418,11 @@ export function createMcpServer(env = process.env) {
|
|
|
3062
3418
|
}, (args) => call((key, base) => getPaymentStatus(key, args.payment_id, base)));
|
|
3063
3419
|
return server;
|
|
3064
3420
|
}
|
|
3421
|
+
/** Runs {@link createMcpServer} over stdio, as `pai mcp` does. */
|
|
3065
3422
|
export async function startMcpServer(env = process.env) {
|
|
3066
3423
|
await createMcpServer(env).connect(new StdioServerTransport());
|
|
3067
3424
|
}
|
|
3425
|
+
/** The installed entry point: {@link run} with the process's arguments, then `process.exit` with its code when it is not 0. */
|
|
3068
3426
|
export async function main() {
|
|
3069
3427
|
const code = await run();
|
|
3070
3428
|
if (code !== 0) {
|