@phantomcodes/cli 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { die } from "../core/errors.mjs";
3
+ import { getBalance } from "../core/request.mjs";
4
+ import { out } from "../core/helpers.mjs";
5
+ import { resolveApiKey, readNamedKey, saveNamedKey, removeNamedKey, listNamedKeys, saveApiKey, removeApiKey } from "./saved.mjs";
6
+ /** `--table` output for `balance`. */
7
+ export function tableBalance(d) {
8
+ const b = d;
9
+ return [
10
+ `active ${b.active}`,
11
+ `kind ${b.kind}`,
12
+ `balance $${b.credit_balance_usd.toFixed(4)}`,
13
+ `spent $${b.credit_spent_usd.toFixed(4)}`,
14
+ `expires_at ${b.expires_at}`,
15
+ ].join('\n');
16
+ }
17
+ /** `--table` output for `key list`. */
18
+ export function tableKeys(d) {
19
+ const { keys } = d;
20
+ if (keys.length === 0)
21
+ return 'no saved keys';
22
+ return keys
23
+ .map((k) => {
24
+ const bal = k.credit_balance_usd === undefined ? '' : ` $${k.credit_balance_usd.toFixed(4)}${k.active === false ? ' inactive' : ''}`;
25
+ return `${k.name.padEnd(16)}${k.id}${bal}`;
26
+ })
27
+ .join('\n');
28
+ }
29
+ /** `phantom balance`: the key's credit and expiry. Run by `run()`. */
30
+ export async function balanceCommand({ io, apiKey, table, baseUrl }) {
31
+ const result = await getBalance(apiKey, baseUrl);
32
+ out(result, table, tableBalance, io.stdout);
33
+ return 0;
34
+ }
35
+ /** `phantom key list|save|show|rm`: keys saved by name. Run by `run()`. */
36
+ export async function keyCommand({ argv, env, io, subcommand, flags, table, baseUrl }) {
37
+ const name = argv[2] && !argv[2].startsWith('--') ? argv[2] : undefined;
38
+ if (!subcommand || subcommand === 'list') {
39
+ const list = listNamedKeys(env);
40
+ if (flags['balance']) {
41
+ const withBalance = await Promise.all(list.keys.map(async (k) => {
42
+ try {
43
+ const b = await getBalance(readNamedKey(env, k.name), baseUrl);
44
+ return { ...k, active: b.active, credit_balance_usd: b.credit_balance_usd, expires_at: b.expires_at };
45
+ }
46
+ catch {
47
+ return { ...k, active: false };
48
+ }
49
+ }));
50
+ out({ keys: withBalance }, table, tableKeys, io.stdout);
51
+ }
52
+ else {
53
+ out(list, table, tableKeys, io.stdout);
54
+ }
55
+ }
56
+ else if (subcommand === 'save') {
57
+ if (!name)
58
+ die('key save requires a name: phantom key save <name> [key]');
59
+ let key = argv[3] && !argv[3].startsWith('--') ? argv[3] : undefined;
60
+ if (!key) {
61
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
62
+ if (io.isTTY)
63
+ io.stderr('Paste the key: ');
64
+ key = (await rl[Symbol.asyncIterator]().next()).value ?? '';
65
+ rl.close();
66
+ }
67
+ key = (key ?? '').trim();
68
+ if (!key.startsWith('sk-phantom-'))
69
+ die('That is not a Phantom AI key. Keys start with sk-phantom-');
70
+ await getBalance(key, baseUrl);
71
+ out(saveNamedKey(env, name, key), table, (d) => `saved ${d.name}`, io.stdout);
72
+ }
73
+ else if (subcommand === 'show') {
74
+ // Raw, for PHANTOM_API_KEY=$(phantom key show <name>). With no name, the
75
+ // key phantom itself would use, which is what Claude Code's apiKeyHelper runs.
76
+ const key = name ? readNamedKey(env, name) : resolveApiKey(env);
77
+ if (!key)
78
+ die('No API key. Set PHANTOM_API_KEY or run: phantom login');
79
+ io.stdout(key + '\n');
80
+ }
81
+ else if (subcommand === 'rm') {
82
+ if (!name)
83
+ die('key rm requires a name');
84
+ out(removeNamedKey(env, name), table, (d) => `removed ${d.removed}`, io.stdout);
85
+ }
86
+ else {
87
+ die(`Unknown key subcommand: ${subcommand}`);
88
+ }
89
+ return 0;
90
+ }
91
+ /** `phantom login`: checks a key and saves it as the login key. Run by `run()`. */
92
+ export async function loginCommand({ env, io, subcommand, table, baseUrl }) {
93
+ let key = subcommand;
94
+ if (!key) {
95
+ // Prompt rather than take the key as an argument, so it stays out of
96
+ // shell history.
97
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
98
+ if (io.isTTY)
99
+ io.stderr('Paste your Phantom AI key: ');
100
+ key = (await rl[Symbol.asyncIterator]().next()).value ?? '';
101
+ rl.close();
102
+ }
103
+ key = (key ?? '').trim();
104
+ if (!key.startsWith('sk-phantom-'))
105
+ die('That is not a Phantom AI key. Keys start with sk-phantom-');
106
+ // Check the key works before saving it.
107
+ const balance = await getBalance(key, baseUrl);
108
+ const saved = saveApiKey(env, key);
109
+ out({ ...saved, ...balance }, table, (d) => `saved ${d.saved}\n${tableBalance(d)}`, io.stdout);
110
+ return 0;
111
+ }
112
+ /** `phantom logout`: forgets the login key. Run by `run()`. */
113
+ export async function logoutCommand({ env, io, table }) {
114
+ out(removeApiKey(env), table, (d) => `removed ${d.removed}`, io.stdout);
115
+ return 0;
116
+ }
@@ -0,0 +1,172 @@
1
+ import path from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readdirSync } from 'node:fs';
4
+ import { die } from "../core/errors.mjs";
5
+ import { stateDir } from "../core/state.mjs";
6
+ import { writeSecret, readSecret, removeSecret } from "../core/secrets.mjs";
7
+ /**
8
+ * `login` saves the key to a file only the user can read, so an agent that
9
+ * reaches the CLI through bash (pi has no MCP) needs no environment set up.
10
+ *
11
+ * Other keys can be saved by name in `keys/`, one file each, so an agent can
12
+ * find them again in a later session. The file holds nothing but the key.
13
+ * `phantom keys create` also keeps a record with no secret in it beside each key
14
+ * it makes (see {@link SessionRecord}).
15
+ *
16
+ * Which key a command runs as: the saved key named by PHANTOM_KEY_NAME, then
17
+ * PHANTOM_API_KEY, then the default key.
18
+ */
19
+ const KEY_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/i;
20
+ export function keyPath(env) {
21
+ return path.join(stateDir(env), 'key');
22
+ }
23
+ export function namedKeyPath(env, name) {
24
+ if (!KEY_NAME.test(name))
25
+ die(`Key names use letters, numbers, - and _ (up to 32): ${name}`);
26
+ return path.join(stateDir(env), 'keys', name);
27
+ }
28
+ /** A key's id: the start of its SHA-256 hash, safe to print. */
29
+ export function keyId(key) {
30
+ return createHash('sha256').update(key.trim()).digest('hex').slice(0, 12);
31
+ }
32
+ /**
33
+ * Which key a command runs as: the saved key named by PHANTOM_KEY_NAME, then
34
+ * PHANTOM_API_KEY, then the default key. It only looks; {@link resolveApiKey}
35
+ * reads the key.
36
+ */
37
+ export function keySource(env) {
38
+ // A named key is the more specific choice, so it wins. A subagent started
39
+ // with PHANTOM_KEY_NAME=<name> from a shell that also exports
40
+ // PHANTOM_API_KEY must run as the named key, not quietly spend the other.
41
+ if (env.PHANTOM_KEY_NAME)
42
+ return { from: 'named', name: env.PHANTOM_KEY_NAME };
43
+ if (env.PHANTOM_API_KEY)
44
+ return { from: 'env' };
45
+ return existsSync(keyPath(env)) ? { from: 'login' } : { from: 'none' };
46
+ }
47
+ /** The key a command runs as, chosen by {@link keySource}. An empty string when there is none. */
48
+ export function resolveApiKey(env) {
49
+ const src = keySource(env);
50
+ if (src.from === 'env')
51
+ return env.PHANTOM_API_KEY ?? '';
52
+ if (src.from === 'named')
53
+ return readNamedKey(env, src.name);
54
+ if (src.from === 'login')
55
+ return readSecret(env, keyPath(env)).trim();
56
+ return '';
57
+ }
58
+ function writeKeyFile(env, file, key) {
59
+ const k = key.trim();
60
+ if (!k.startsWith('sk-phantom-'))
61
+ die('That is not a Phantom AI key. Keys start with sk-phantom-');
62
+ writeSecret(env, file, k + '\n');
63
+ }
64
+ /**
65
+ * A key saved by name.
66
+ * @throws {@link CliError} when no key has that name, or the name is not valid.
67
+ */
68
+ export function readNamedKey(env, name) {
69
+ const file = namedKeyPath(env, name);
70
+ if (!existsSync(file))
71
+ die(`No saved key named ${name}. See: phantom key list`);
72
+ return readSecret(env, file).trim();
73
+ }
74
+ /**
75
+ * Saves a key by name, in the Keychain or a mode 600 file (see {@link keychain}).
76
+ * Refuses a name already in use unless `replace` is set, and anything that
77
+ * doesn't start with `sk-phantom-`.
78
+ * @returns The name, and the key's {@link keyId}.
79
+ */
80
+ export function saveNamedKey(env, name, key, opts = {}) {
81
+ const file = namedKeyPath(env, name);
82
+ if (existsSync(file) && !opts.replace)
83
+ die(`A key named ${name} is already saved. Pick another name, or remove it first.`);
84
+ writeKeyFile(env, file, key);
85
+ return { name, id: keyId(key) };
86
+ }
87
+ /** Forgets a saved key. The key itself keeps working. */
88
+ export function removeNamedKey(env, name) {
89
+ const file = namedKeyPath(env, name);
90
+ if (!existsSync(file))
91
+ return { name, removed: false };
92
+ removeSecret(file);
93
+ return { name, removed: true };
94
+ }
95
+ /** Every key saved by name, sorted, with its id. Never returns the keys. */
96
+ export function listNamedKeys(env) {
97
+ const dir = path.join(stateDir(env), 'keys');
98
+ if (!existsSync(dir))
99
+ return { keys: [] };
100
+ const keys = readdirSync(dir)
101
+ .filter((n) => KEY_NAME.test(n))
102
+ .sort()
103
+ .map((name) => ({ name, id: keyId(readSecret(env, path.join(dir, name))) }));
104
+ return { keys };
105
+ }
106
+ /** The default key, the one `phantom keys create` saves when no name is given, or null when none is saved. */
107
+ export function defaultKey(env) {
108
+ return existsSync(keyPath(env)) ? readSecret(env, keyPath(env)).trim() : null;
109
+ }
110
+ /** Saves the default key, the one used when neither PHANTOM_KEY_NAME nor PHANTOM_API_KEY is set. */
111
+ export function saveApiKey(env, key) {
112
+ writeKeyFile(env, keyPath(env), key);
113
+ return { saved: keyPath(env) };
114
+ }
115
+ /** Forgets the default key. */
116
+ export function removeApiKey(env) {
117
+ const p = keyPath(env);
118
+ if (!existsSync(p))
119
+ return { removed: false };
120
+ removeSecret(p);
121
+ return { removed: true };
122
+ }
123
+ /**
124
+ * Where a new key will be saved: the name in `save` (`--save`), or `default`.
125
+ * Run it before anything is asked for: it refuses a name already in use and,
126
+ * with no name, a default key that is saved already.
127
+ * @throws {@link CliError} `key_exists`, or `cli_error` for `--save` with no name.
128
+ */
129
+ export function saveTarget(env, save) {
130
+ if (save === true)
131
+ die('--save requires a name for the key');
132
+ if (typeof save === 'string' && existsSync(namedKeyPath(env, save))) {
133
+ die(`A key named ${save} is already saved. Pick another name, or remove it first.`, 'key_exists');
134
+ }
135
+ if (save === undefined && existsSync(keyPath(env))) {
136
+ die('A default key is already saved. Name the new key with --save <name>.', 'key_exists');
137
+ }
138
+ return save ?? 'default';
139
+ }
140
+ /** Saves a key under a {@link saveTarget}: `default`, or a name. */
141
+ export function saveKeyAs(env, target, key) {
142
+ if (target === 'default')
143
+ saveApiKey(env, key);
144
+ else
145
+ saveNamedKey(env, target, key);
146
+ }
147
+ /** Forgets a key saved under a {@link saveTarget}. */
148
+ export function forgetKeyAs(env, target) {
149
+ if (target === 'default')
150
+ removeApiKey(env);
151
+ else
152
+ removeNamedKey(env, target);
153
+ }
154
+ /**
155
+ * True when PHANTOM_API_KEY holds the same key as the login. `phantom setup`'s
156
+ * shell block sets it that way, so a rotate or burn must update the login too.
157
+ */
158
+ export function envIsLogin(env) {
159
+ return Boolean(env.PHANTOM_API_KEY) && existsSync(keyPath(env)) && readSecret(env, keyPath(env)).trim() === env.PHANTOM_API_KEY?.trim();
160
+ }
161
+ /** After a rotate, put the new key where the old one was saved. */
162
+ export function replaceSavedKey(env, src, key) {
163
+ if (src.from === 'named') {
164
+ saveNamedKey(env, src.name, key, { replace: true });
165
+ return src.name;
166
+ }
167
+ if (src.from === 'login' || (src.from === 'env' && envIsLogin(env))) {
168
+ writeKeyFile(env, keyPath(env), key);
169
+ return 'login';
170
+ }
171
+ return null;
172
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * phantom: prepaid AI inference for coding agents with Phantom AI.
4
+ *
5
+ * Keys, money and setup for any AI agent: a Phantom AI key, model routing,
6
+ * and receipts that show which model answered. It runs as a CLI and through
7
+ * the phantom-ai skill, so pi, Claude Code, Codex and Cursor can all drive it
8
+ * by prompt.
9
+ *
10
+ * This file is the entry point. The code lives in one folder per feature
11
+ * beside it, and this file exports the public API from them. From a
12
+ * checkout: `node src/phantom.mts`.
13
+ */
14
+ import { fileURLToPath } from 'node:url';
15
+ import { realpathSync } from 'node:fs';
16
+ import { run } from "./cli/run.mjs";
17
+ export { DEFAULT_BASE_URL, request, httpsOnly, getBalance } from "./core/request.mjs";
18
+ export { VERSION } from "./core/version.mjs";
19
+ export { PhantomApiError, CliError, die } from "./core/errors.mjs";
20
+ export { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./spend/routing.mjs";
21
+ export { checkReceipt, modelsMatch, verifyModel } from "./receipts/receipts.mjs";
22
+ export { keychain } from "./core/secrets.mjs";
23
+ export { keyId, keySource, resolveApiKey, readNamedKey, saveNamedKey, removeNamedKey, listNamedKeys, saveApiKey, removeApiKey } from "./keys/saved.mjs";
24
+ // Supported setup interface: plan, preview, apply, and restore.
25
+ export { SETUP_MODEL, agentBaseUrl } from "./setup/shared.mjs";
26
+ export { planSetup } from "./setup/plan.mjs";
27
+ export { applySetup, previewSetup, setupAgents, restoreSetup } from "./setup/apply.mjs";
28
+ export { jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs, shellRcFile, shellBlock } from "./setup/editors.mjs";
29
+ export { SETUP_AGENTS } from "./setup/agents.mjs";
30
+ export { claudeProvider } from "./setup/apply.mjs";
31
+ export { ENV_VARS, HELP } from "./cli/help.mjs";
32
+ export { run } from "./cli/run.mjs";
33
+ /** The installed entry point: {@link run} with the process's arguments, then `process.exit` with its code when it is not 0. */
34
+ export async function main() {
35
+ const code = await run();
36
+ if (code !== 0) {
37
+ process.exit(code);
38
+ }
39
+ }
40
+ /**
41
+ * True for `node src/phantom.mts`. Installed, the entry point is bin/phantom.mjs,
42
+ * which imports `main` and calls it, so this stays false there.
43
+ */
44
+ const isDirectRun = (() => {
45
+ if (!process.argv[1])
46
+ return false;
47
+ // Compare real paths, so a run through a symlink (like /tmp on macOS) counts.
48
+ try {
49
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ })();
55
+ if (isDirectRun) {
56
+ void main();
57
+ }
@@ -0,0 +1,41 @@
1
+ import { die } from "../core/errors.mjs";
2
+ import { out } from "../core/helpers.mjs";
3
+ import { checkReceipt, verifyModel } from "./receipts.mjs";
4
+ /** `--table` output for `verify --model`. */
5
+ export function tableVerifyModel(d) {
6
+ const v = d;
7
+ return [
8
+ `requested ${v.model_requested}`,
9
+ `served ${v.model_served ?? '-'}`,
10
+ `match ${v.match}`,
11
+ `signature ${v.signature_valid ? 'valid' : 'invalid'}`,
12
+ `cost ${v.cost_usd === null ? '-' : `$${v.cost_usd.toFixed(6)}`}`,
13
+ ...(v.reason ? [`reason ${v.reason}`] : []),
14
+ ].join('\n');
15
+ }
16
+ /** `--table` output for `verify --receipt`. */
17
+ export function tableReceiptCheck(d) {
18
+ const c = d;
19
+ return [
20
+ `signature ${c.valid ? 'valid' : 'invalid'}`,
21
+ `requested ${c.receipt?.model_requested ?? '-'}`,
22
+ `served ${c.receipt?.model_served ?? '-'}`,
23
+ `signed_at ${c.receipt?.ts ?? '-'}`,
24
+ ...(c.reason ? [`reason ${c.reason}`] : []),
25
+ ].join('\n');
26
+ }
27
+ /** `phantom verify`: checks a receipt, or makes one small call and checks its receipt. Run by `run()`. */
28
+ export async function verifyCommand({ io, apiKey, flags, table, baseUrl }) {
29
+ const model = flags['model'];
30
+ const receipt = flags['receipt'];
31
+ if (typeof receipt === 'string') {
32
+ const result = await checkReceipt(receipt, baseUrl);
33
+ out(result, table, tableReceiptCheck, io.stdout);
34
+ return result.valid ? 0 : 1;
35
+ }
36
+ if (typeof model !== 'string')
37
+ die('verify requires --model <id> or --receipt <receipt>');
38
+ const result = await verifyModel(apiKey, model, baseUrl);
39
+ out(result, table, tableVerifyModel, io.stdout);
40
+ return result.match ? 0 : 1;
41
+ }
@@ -0,0 +1,82 @@
1
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
2
+ import { apiBase, apiError } from "../core/request.mjs";
3
+ /**
4
+ * Every priced call returns a receipt signed with Phantom AI's Ed25519 key. The
5
+ * check runs here, against the public key from /receipts/key, so the answer
6
+ * never comes from Phantom AI saying it was honest.
7
+ */
8
+ /** Matches RECEIPT_VERSION in lib/receipts.ts. */
9
+ const RECEIPT_VERSION = 1;
10
+ /**
11
+ * Checks a compact receipt (`payload.signature`, both base64url) against the
12
+ * public key from GET /receipts/key. Needs no API key.
13
+ * @throws {@link PhantomApiError} when the public key cannot be fetched.
14
+ */
15
+ export async function checkReceipt(compact, baseUrl) {
16
+ const res = await fetch(`${apiBase(baseUrl)}/receipts/key`);
17
+ const json = (await res.json().catch(() => ({})));
18
+ if (!res.ok)
19
+ throw apiError(res.status, json);
20
+ if (!json.signs || !json.public_key_jwk) {
21
+ return { valid: false, receipt: null, reason: 'This deployment signs no receipts' };
22
+ }
23
+ const parts = compact.trim().split('.');
24
+ if (parts.length !== 2)
25
+ return { valid: false, receipt: null, reason: 'Not a receipt: expected payload.signature' };
26
+ const payload = Buffer.from(parts[0], 'base64url');
27
+ let receipt;
28
+ try {
29
+ receipt = JSON.parse(payload.toString('utf-8'));
30
+ }
31
+ catch {
32
+ return { valid: false, receipt: null, reason: 'Receipt payload is not JSON' };
33
+ }
34
+ if (!receipt || typeof receipt !== 'object')
35
+ return { valid: false, receipt: null, reason: 'Receipt payload is not an object' };
36
+ if (receipt.v !== RECEIPT_VERSION)
37
+ return { valid: false, receipt, reason: `Unknown receipt version ${receipt.v}` };
38
+ const key = createPublicKey({ key: json.public_key_jwk, format: 'jwk' });
39
+ const valid = cryptoVerify(null, payload, key, Buffer.from(parts[1], 'base64url'));
40
+ return valid ? { valid, receipt } : { valid, receipt, reason: 'Signature does not match the published key' };
41
+ }
42
+ /** The served id can drop the provider prefix, so a bare name counts as a match. */
43
+ export function modelsMatch(requested, served) {
44
+ const bare = (id) => id.toLowerCase().split('/').pop();
45
+ return requested.toLowerCase() === served.toLowerCase() || bare(requested) === bare(served);
46
+ }
47
+ /** One small priced call to `model`, then a check of the receipt it returns. */
48
+ export async function verifyModel(apiKey, model, baseUrl) {
49
+ const res = await fetch(`${apiBase(baseUrl)}/chat/completions`, {
50
+ method: 'POST',
51
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ model, max_tokens: 16, messages: [{ role: 'user', content: 'Reply with ok.' }] }),
53
+ });
54
+ const json = await res.json().catch(() => ({}));
55
+ if (!res.ok)
56
+ throw apiError(res.status, json);
57
+ const compact = res.headers.get('x-phantom-receipt');
58
+ if (!compact) {
59
+ return {
60
+ model_requested: model,
61
+ model_served: null,
62
+ match: false,
63
+ signature_valid: false,
64
+ cost_usd: null,
65
+ request_id: null,
66
+ receipt: null,
67
+ reason: 'The response carried no receipt',
68
+ };
69
+ }
70
+ const check = await checkReceipt(compact, baseUrl);
71
+ const r = check.receipt;
72
+ return {
73
+ model_requested: model,
74
+ model_served: r?.model_served ?? null,
75
+ match: check.valid && r !== null && modelsMatch(model, r.model_served),
76
+ signature_valid: check.valid,
77
+ cost_usd: r ? r.cost_micro_usd / 1_000_000 : null,
78
+ request_id: r?.request_id ?? null,
79
+ receipt: compact,
80
+ ...(check.reason ? { reason: check.reason } : {}),
81
+ };
82
+ }