@phantomcodes/cli 0.0.0-stage → 1.1.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/LICENSE +81 -0
- package/README.md +231 -2
- package/bin/phantom.mjs +14 -0
- package/dist/agents/command.mjs +157 -0
- package/dist/agents/proxy.mjs +140 -0
- package/dist/cli/commands.mjs +154 -0
- package/dist/cli/help.mjs +38 -0
- package/dist/cli/run.mjs +79 -0
- package/dist/core/errors.mjs +39 -0
- package/dist/core/helpers.mjs +85 -0
- package/dist/core/request.mjs +108 -0
- package/dist/core/secrets.mjs +94 -0
- package/dist/core/shapes.mjs +1 -0
- package/dist/core/state.mjs +7 -0
- package/dist/core/version.mjs +2 -0
- package/dist/keys/command.mjs +116 -0
- package/dist/keys/saved.mjs +172 -0
- package/dist/phantom.mjs +60 -0
- package/dist/plan/command.mjs +68 -0
- package/dist/receipts/command.mjs +41 -0
- package/dist/receipts/receipts.mjs +82 -0
- package/dist/setup/agents.mjs +426 -0
- package/dist/setup/apply.mjs +210 -0
- package/dist/setup/command.mjs +112 -0
- package/dist/setup/editors.mjs +486 -0
- package/dist/setup/legacy.mjs +36 -0
- package/dist/setup/plan.mjs +121 -0
- package/dist/setup/shared.mjs +81 -0
- package/dist/spend/command.mjs +172 -0
- package/dist/spend/lineup.mjs +20 -0
- package/dist/spend/report.mjs +61 -0
- package/dist/spend/routing.mjs +32 -0
- package/dist/workspace/command.mjs +209 -0
- package/package.json +83 -4
- package/skills/phantom-ai/SKILL.md +155 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { workspaceCommand, workspaceLogin, forgetWorkspace } from "../workspace/command.mjs";
|
|
2
|
+
import { agentCommand } from "../agents/command.mjs";
|
|
3
|
+
import { balanceCommand, keyCommand, loginCommand, logoutCommand } from "../keys/command.mjs";
|
|
4
|
+
import { lineupCommand, routeCommand } from "../spend/command.mjs";
|
|
5
|
+
import { spendCommand } from "../spend/report.mjs";
|
|
6
|
+
import { planCommand } from "../plan/command.mjs";
|
|
7
|
+
import { verifyCommand } from "../receipts/command.mjs";
|
|
8
|
+
import { setupCommand } from "../setup/command.mjs";
|
|
9
|
+
import { SETUP_MODEL } from "../setup/shared.mjs";
|
|
10
|
+
import { SETUP_NAMES, orList } from "../setup/agents.mjs";
|
|
11
|
+
async function loginDispatch(input) {
|
|
12
|
+
const { subcommand, flags } = input;
|
|
13
|
+
return subcommand || flags.paste ? loginCommand(input) : workspaceLogin(input);
|
|
14
|
+
}
|
|
15
|
+
async function logoutDispatch(input) {
|
|
16
|
+
await forgetWorkspace(input.env, input.baseUrl);
|
|
17
|
+
return logoutCommand(input);
|
|
18
|
+
}
|
|
19
|
+
async function fundDispatch(input) {
|
|
20
|
+
const { flags } = input;
|
|
21
|
+
void flags.amount;
|
|
22
|
+
void flags.card;
|
|
23
|
+
void flags.coin;
|
|
24
|
+
void flags['no-browser'];
|
|
25
|
+
return workspaceCommand({ ...input, subcommand: 'fund' });
|
|
26
|
+
}
|
|
27
|
+
/** The definitions used by CLI dispatch, help, and generated documentation. */
|
|
28
|
+
export const COMMANDS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'login', handler: loginDispatch, keyless: true,
|
|
31
|
+
help: ` login [--save <name>] [--no-browser] connect an account or guest workspace by device code
|
|
32
|
+
login --paste validate and save an existing key
|
|
33
|
+
login [key] save your key so you don't need PHANTOM_API_KEY
|
|
34
|
+
(use --paste to prompt for an existing key)`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'workspace', handler: workspaceCommand, keyless: true,
|
|
38
|
+
help: ` workspace [status] shared balance, keys, recent usage and management access
|
|
39
|
+
workspace fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
|
|
40
|
+
workspace payment --id <payment-id> check verified funding status
|
|
41
|
+
workspace export --file <path> export private guest recovery; refuses overwrites
|
|
42
|
+
workspace import --file <path> restore a guest, then issue replacement keys
|
|
43
|
+
workspace revoke-access --id <id> revoke a browser, device or recovery credential`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'fund', handler: fundDispatch, keyless: true,
|
|
47
|
+
help: ` fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
|
|
48
|
+
fund your workspace balance with card (plus Stripe's fee) or crypto`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'keys', handler: workspaceCommand, keyless: true,
|
|
52
|
+
help: ` keys [list] remote workspace key metadata, never secrets
|
|
53
|
+
keys create [--save <name>] [--limit <USD>]
|
|
54
|
+
keys set --id <id> [--limit <USD|none>] [--budget <USD|none>]
|
|
55
|
+
[--period <days|none>] [--rate <USD|none>] [--name <name>]
|
|
56
|
+
[--policy-file <path> | --clear-policy]
|
|
57
|
+
keys rotate --id <id> [--save <name>] replace only the corresponding saved key
|
|
58
|
+
keys revoke --id <id> revoke access, keeping workspace credit
|
|
59
|
+
keys saved [--balance] local saved key metadata
|
|
60
|
+
keys save <name> [key] validate and save an existing key (prompts if omitted)
|
|
61
|
+
keys show [name] print a secret for shell integration only
|
|
62
|
+
keys rm <name> forget a local copy; remote key keeps working`,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'claude', handler: agentCommand, keyless: true,
|
|
66
|
+
help: ` claude [--key <name>] [--no-browser] -- <agent arguments>`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'codex', handler: agentCommand, keyless: true,
|
|
70
|
+
help: ` codex [--key <name>] [--no-browser] -- <agent arguments>`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'pi', handler: agentCommand, keyless: true,
|
|
74
|
+
help: ` pi [--key <name>] [--no-browser] -- <agent arguments>
|
|
75
|
+
run with a saved key`,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'balance', handler: balanceCommand,
|
|
79
|
+
help: ` balance show credit balance and expiry`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'spend', handler: spendCommand, keyless: ['--workspace'],
|
|
83
|
+
help: ` spend [--from <date>] [--to <date>] what this key spent, by model and day, and what phantom-1
|
|
84
|
+
saved; dates are UTC days as YYYY-MM-DD, default this month
|
|
85
|
+
spend --workspace every key in the workspace by name (needs phantom login)`,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'plan', handler: planCommand,
|
|
89
|
+
help: ` plan [status] this key's Pro or Max plan and how much of the week is used
|
|
90
|
+
plan pro | max [--no-browser] print a Stripe Checkout link that puts the plan on this key
|
|
91
|
+
and open it
|
|
92
|
+
plan manage [--no-browser] the same for Stripe's billing page: card, switch plan, cancel`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'key', advanced: true, handler: keyCommand, keyless: true,
|
|
96
|
+
help: ` key list [--balance] saved keys, by name and id
|
|
97
|
+
key save <name> [key] save a key by name (prompts if no key is given)
|
|
98
|
+
key show [name] print a saved key, for PHANTOM_API_KEY=$(...);
|
|
99
|
+
no name prints the key phantom is using
|
|
100
|
+
key rm <name> forget a saved key (the key keeps working)`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'route', handler: routeCommand,
|
|
104
|
+
help: ` route [get] show which model "auto" runs, and why
|
|
105
|
+
route set --models a,b,c models for "auto", first is the default
|
|
106
|
+
[--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
|
|
107
|
+
[--stick-minutes n] keep a conversation's model this long (default 5)
|
|
108
|
+
[--stick-by-prompt false] only x-phantom-session marks a conversation
|
|
109
|
+
route set --file policy.json replace the whole policy
|
|
110
|
+
route rule add --if <name=value> --use <model|cheapest|first|next> [--at n]
|
|
111
|
+
conditions: pace, budget_left_pct_below,
|
|
112
|
+
days_left_below, has_tools, input_tokens_over,
|
|
113
|
+
reasoning_requested
|
|
114
|
+
route rule rm <n> remove rule n
|
|
115
|
+
route test [--model auto] which model a request would get now (free)
|
|
116
|
+
route clear remove the policy`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'lineup', handler: lineupCommand,
|
|
120
|
+
help: ` lineup [get] show the model phantom-1 runs in each tier, and the choices
|
|
121
|
+
lineup set --economy <model> pick the model for a tier; "default" goes back to the default
|
|
122
|
+
[--standard <model>] [--frontier <model>]
|
|
123
|
+
[--economy-fallback <model>] [--standard-fallback <model>]
|
|
124
|
+
[--frontier-fallback <model>]
|
|
125
|
+
the model to try when a tier's model fails
|
|
126
|
+
lineup reset every tier back to its default`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'logout', handler: logoutDispatch, keyless: true,
|
|
130
|
+
help: ` logout forget workspace access and the default saved key`,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'setup', handler: setupCommand, keyless: true,
|
|
134
|
+
help: ` setup set up every agent found: install the skill and
|
|
135
|
+
point its model calls at Phantom AI; shows each
|
|
136
|
+
file's changes and asks first
|
|
137
|
+
[--yes] don't ask
|
|
138
|
+
[--dry-run] show the changes, write nothing
|
|
139
|
+
[--model <id>] the model each agent starts on (default ${SETUP_MODEL})
|
|
140
|
+
setup --agent <name> install the skill for one agent, without asking:
|
|
141
|
+
${orList(SETUP_NAMES)}
|
|
142
|
+
or all
|
|
143
|
+
setup [--agent <name>] --provider point that agent's model calls at Phantom AI;
|
|
144
|
+
--provider off removes only what phantom wrote
|
|
145
|
+
setup --restore [--agent <name>] put back the files setup changed, from the
|
|
146
|
+
.phantom.bak copies it kept`,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'verify', advanced: true, handler: verifyCommand, keyless: ['--receipt'],
|
|
150
|
+
help: ` verify --model <id> make one tiny call and check its signed receipt
|
|
151
|
+
names the model you asked for (costs a fraction of a cent)
|
|
152
|
+
verify --receipt <receipt> check a receipt you already have`,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DEFAULT_BASE_URL } from "../core/request.mjs";
|
|
2
|
+
import { COMMANDS } from "./commands.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Every environment variable phantom reads. The Environment sections of HELP and
|
|
5
|
+
* docs/reference.md are built from this list, and `npm run docs` fails if the
|
|
6
|
+
* code reads a variable that is not here.
|
|
7
|
+
*/
|
|
8
|
+
export const ENV_VARS = [
|
|
9
|
+
{ name: 'PHANTOM_API_KEY', about: 'your Phantom AI API key (or save one with login)', secret: true },
|
|
10
|
+
{ name: 'PHANTOM_KEY_NAME', about: 'run as a key saved with keys save; wins over PHANTOM_API_KEY' },
|
|
11
|
+
{ name: 'PHANTOM_BASE_URL', about: `API base, https:// only (default ${DEFAULT_BASE_URL})` },
|
|
12
|
+
{ name: 'PHANTOM_STATE_DIR', about: 'where phantom keeps keys (default ~/.config/phantom-key)' },
|
|
13
|
+
{ name: 'PHANTOM_KEYCHAIN', about: '0 saves keys to files instead of the macOS Keychain; 1 uses the Keychain on any system, which fails where /usr/bin/security is missing' },
|
|
14
|
+
{ name: 'PAI_KEYCHAIN', about: 'The old name of PHANTOM_KEYCHAIN, read when PHANTOM_KEYCHAIN is unset' },
|
|
15
|
+
];
|
|
16
|
+
function envHelp() {
|
|
17
|
+
const width = Math.max(...ENV_VARS.map((v) => v.name.length)) + 2;
|
|
18
|
+
const lines = ENV_VARS.map((v) => ` ${v.name.padEnd(width)}${v.about}${v.envOnly ? ' (environment only)' : ''}`);
|
|
19
|
+
return ['Environment:', ...lines].join('\n');
|
|
20
|
+
}
|
|
21
|
+
/** Full command reference, printed by `phantom --help --all`. */
|
|
22
|
+
export const HELP = `
|
|
23
|
+
phantom — AI inference for your coding agents
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
${COMMANDS.map((command) => command.help).join('\n')}
|
|
27
|
+
|
|
28
|
+
Flags:
|
|
29
|
+
--table human-readable output instead of JSON
|
|
30
|
+
--help, -h print help and run nothing
|
|
31
|
+
--all include advanced commands in help
|
|
32
|
+
--version, -v print the version
|
|
33
|
+
|
|
34
|
+
${envHelp()}
|
|
35
|
+
`.trim();
|
|
36
|
+
/** Workflow help printed by default; command definitions remain the source of usage. */
|
|
37
|
+
export const PRIMARY_HELP = HELP.replace(COMMANDS.map((command) => command.help).join('\n'), COMMANDS.filter((command) => !command.advanced).map((command) => command.help).join('\n') +
|
|
38
|
+
'\n\nAdvanced: phantom --help --all (receipts and verification)');
|
package/dist/cli/run.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { VERSION } from "../core/version.mjs";
|
|
2
|
+
import { die } from "../core/errors.mjs";
|
|
3
|
+
import { handleError, parseFlags } from "../core/helpers.mjs";
|
|
4
|
+
import { resolveApiKey } from "../keys/saved.mjs";
|
|
5
|
+
import { COMMANDS } from "./commands.mjs";
|
|
6
|
+
import { HELP, PRIMARY_HELP } from "./help.mjs";
|
|
7
|
+
import { LEGACY_SETUP_HINT, legacySetupFound } from "../setup/legacy.mjs";
|
|
8
|
+
/**
|
|
9
|
+
* Runs one CLI command and returns its exit code: 0 on success, 2 when the
|
|
10
|
+
* API rejected the key, 1 for other errors. `verify` returns 1 when the check
|
|
11
|
+
* fails. Errors go to `io.stderr` as JSON. It never calls `process.exit`.
|
|
12
|
+
* @param argv The arguments after `phantom`.
|
|
13
|
+
* @param env The environment to read settings from.
|
|
14
|
+
* @param io Where output goes, and whether stderr is a terminal (for progress lines).
|
|
15
|
+
*/
|
|
16
|
+
export async function run(argv = process.argv.slice(2), env = process.env, io = {
|
|
17
|
+
stdout: (s) => process.stdout.write(s),
|
|
18
|
+
stderr: (s) => process.stderr.write(s),
|
|
19
|
+
isTTY: Boolean(process.stderr.isTTY),
|
|
20
|
+
}) {
|
|
21
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
22
|
+
io.stdout(VERSION + '\n');
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const THREE_STEPS_GUIDE = `
|
|
26
|
+
phantom — prepaid AI inference for coding agents
|
|
27
|
+
|
|
28
|
+
Get started in three steps:
|
|
29
|
+
1. Connect your account:
|
|
30
|
+
phantom login
|
|
31
|
+
|
|
32
|
+
2. Fund your balance:
|
|
33
|
+
phantom fund --amount 10 --card
|
|
34
|
+
(or phantom fund --amount 10 --coin usdc)
|
|
35
|
+
|
|
36
|
+
3. Run your coding agent:
|
|
37
|
+
phantom claude
|
|
38
|
+
(or phantom codex, phantom pi)
|
|
39
|
+
|
|
40
|
+
Run phantom --help for all commands.
|
|
41
|
+
`.trim();
|
|
42
|
+
// `--help` anywhere before a `--` asks for help, so `phantom setup --help`
|
|
43
|
+
// prints it rather than running setup.
|
|
44
|
+
const own = argv.includes('--') ? argv.slice(0, argv.indexOf('--')) : argv;
|
|
45
|
+
// Only a person at a terminal sees the hint: scripts and agents read stderr
|
|
46
|
+
// as JSON errors, and the MCP server and setup itself have no use for it.
|
|
47
|
+
if (io.isTTY && !['setup', 'mcp', 'key', 'keys'].includes(own[0] ?? '') && legacySetupFound(env)) {
|
|
48
|
+
io.stderr(LEGACY_SETUP_HINT + '\n');
|
|
49
|
+
}
|
|
50
|
+
if (argv.length === 0) {
|
|
51
|
+
io.stdout(THREE_STEPS_GUIDE + '\n');
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (own.includes('--help') || own.includes('-h')) {
|
|
55
|
+
const commandHelp = COMMANDS.find((entry) => entry.name === own[0])?.help;
|
|
56
|
+
io.stdout((own.includes('--all') ? HELP : commandHelp?.trim() ?? PRIMARY_HELP) + '\n');
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const command = COMMANDS.find((entry) => entry.name === argv[0]);
|
|
61
|
+
if (!command)
|
|
62
|
+
die(`Unknown command: ${argv[0]}. Run with --help for usage.`);
|
|
63
|
+
const keyless = command.keyless === true ||
|
|
64
|
+
(command.keyless?.some((flag) => own.slice(1).some((arg) => arg === flag || arg.startsWith(flag + '='))) ?? false);
|
|
65
|
+
const apiKey = keyless ? '' : resolveApiKey(env);
|
|
66
|
+
if (!apiKey && !keyless)
|
|
67
|
+
die('No API key. Set PHANTOM_API_KEY or run: phantom login');
|
|
68
|
+
const subcommand = argv[1] && !argv[1].startsWith('--') ? argv[1] : undefined;
|
|
69
|
+
const flagArgs = subcommand ? argv.slice(2) : argv.slice(1);
|
|
70
|
+
const flags = parseFlags(flagArgs);
|
|
71
|
+
const table = Boolean(flags['table']);
|
|
72
|
+
const baseUrl = env.PHANTOM_BASE_URL;
|
|
73
|
+
const input = { argv, env, io, apiKey, subcommand, flags, table, baseUrl };
|
|
74
|
+
return await command.handler(input);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
return handleError(err, io.stderr);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** An error answer from the Phantom AI API: 401 or 403 maps to exit code 2. */
|
|
2
|
+
export class PhantomApiError extends Error {
|
|
3
|
+
/** The HTTP status code of the answer. */
|
|
4
|
+
status;
|
|
5
|
+
/** The API's error code, or the status code as text when the answer carries none. */
|
|
6
|
+
code;
|
|
7
|
+
constructor(status, code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'PhantomApiError';
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** An error phantom raises itself, such as a missing flag or a refused payment. */
|
|
15
|
+
export class CliError extends Error {
|
|
16
|
+
/** A short code for the error, such as `keychain_failed`. `cli_error` when none is given. */
|
|
17
|
+
code;
|
|
18
|
+
/** The process exit code {@link run} returns for it. 1 unless given. */
|
|
19
|
+
exitCode;
|
|
20
|
+
constructor(message, code = 'cli_error', exitCode = 1) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'CliError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.exitCode = exitCode;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Throws a {@link CliError}. Every refusal in phantom goes through here.
|
|
29
|
+
* @param msg The message the user sees.
|
|
30
|
+
* @param code The error code, printed in the JSON error.
|
|
31
|
+
* @param exitCode The process exit code.
|
|
32
|
+
*/
|
|
33
|
+
export function die(msg, code = 'cli_error', exitCode = 1) {
|
|
34
|
+
throw new CliError(msg, code, exitCode);
|
|
35
|
+
}
|
|
36
|
+
/** How to get a key, for every message about a missing one. */
|
|
37
|
+
export const GET_A_KEY = 'Set PHANTOM_API_KEY, or run: phantom login to connect a workspace';
|
|
38
|
+
/** The message for a command that needs a key and has none. */
|
|
39
|
+
export const NO_KEY = `No API key. ${GET_A_KEY}`;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { PhantomApiError, CliError, die } from "./errors.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Prints an error to stderr as one line of JSON, `{"error":{...}}`, and
|
|
5
|
+
* returns the exit code: 2 for a key the API rejected (401 or 403), the
|
|
6
|
+
* {@link CliError}'s own code, or 1 for anything else.
|
|
7
|
+
*/
|
|
8
|
+
export function handleError(err, stderr = (s) => process.stderr.write(s)) {
|
|
9
|
+
if (err instanceof PhantomApiError) {
|
|
10
|
+
stderr(JSON.stringify({
|
|
11
|
+
error: { status: err.status, code: err.code, message: err.message },
|
|
12
|
+
}) + '\n');
|
|
13
|
+
return err.status === 401 || err.status === 403 ? 2 : 1;
|
|
14
|
+
}
|
|
15
|
+
if (err instanceof CliError) {
|
|
16
|
+
stderr(JSON.stringify({
|
|
17
|
+
error: { code: err.code, message: err.message },
|
|
18
|
+
}) + '\n');
|
|
19
|
+
return err.exitCode;
|
|
20
|
+
}
|
|
21
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
22
|
+
stderr(JSON.stringify({ error: { code: 'unknown', message: msg } }) + '\n');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads `--name value`, `--name=value` and bare `--name` (true) from the
|
|
27
|
+
* arguments. A value that starts with `--` counts as the next flag. Anything
|
|
28
|
+
* not after a `--name` is skipped.
|
|
29
|
+
*/
|
|
30
|
+
export function parseFlags(args) {
|
|
31
|
+
const flags = {};
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
const arg = args[i];
|
|
34
|
+
if (arg.startsWith('--')) {
|
|
35
|
+
const eq = arg.indexOf('=');
|
|
36
|
+
if (eq !== -1) {
|
|
37
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
38
|
+
}
|
|
39
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
40
|
+
flags[arg.slice(2)] = args[++i];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
flags[arg.slice(2)] = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return flags;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A flag's value as a number, or undefined when the flag is absent.
|
|
51
|
+
* @throws {@link CliError} when the flag has no value or the value is not a number.
|
|
52
|
+
*/
|
|
53
|
+
export function flagNum(flags, name) {
|
|
54
|
+
const v = flags[name];
|
|
55
|
+
if (v === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (v === true || (typeof v === 'string' && v.trim() === '')) {
|
|
58
|
+
die(`--${name} requires a number`);
|
|
59
|
+
}
|
|
60
|
+
const n = Number(v);
|
|
61
|
+
if (!Number.isFinite(n))
|
|
62
|
+
die(`--${name} must be a number`);
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
/** Opens a URL in the user's default browser. */
|
|
66
|
+
export function openUrl(url) {
|
|
67
|
+
try {
|
|
68
|
+
if (new URL(url).protocol !== 'https:')
|
|
69
|
+
return;
|
|
70
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', new URL(url).href]] : ['xdg-open', [url]];
|
|
71
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => { }).unref();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// The address is on stderr already.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Prints a result: JSON by default, or `render(data)` with `--table`. */
|
|
78
|
+
export function out(data, table, render, stdout = (s) => process.stdout.write(s)) {
|
|
79
|
+
if (table) {
|
|
80
|
+
stdout(render(data) + '\n');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
stdout(JSON.stringify(data, null, 2) + '\n');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { PhantomApiError, die } from "./errors.mjs";
|
|
2
|
+
/** The Phantom AI API address phantom calls when PHANTOM_BASE_URL is unset. */
|
|
3
|
+
export const DEFAULT_BASE_URL = 'https://phantom.codes/v1';
|
|
4
|
+
/** How long {@link request} waits for the whole answer, in milliseconds, before it gives up. */
|
|
5
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* Calls the Phantom AI API with the key as a Bearer token and returns the
|
|
8
|
+
* JSON answer. Every client function in phantom goes through it.
|
|
9
|
+
* @param method The HTTP method.
|
|
10
|
+
* @param path The path after the base URL, such as `/key/balance`.
|
|
11
|
+
* @param apiKey The key sent in the Authorization header.
|
|
12
|
+
* @param body Sent as JSON when given.
|
|
13
|
+
* @param baseUrl The API base. Defaults to PHANTOM_BASE_URL, then {@link DEFAULT_BASE_URL}. Must be https ({@link httpsOnly}).
|
|
14
|
+
* @param headers Extra headers to send.
|
|
15
|
+
* @param timeoutMs How long to wait for the answer. Defaults to {@link REQUEST_TIMEOUT_MS}.
|
|
16
|
+
* @throws {@link PhantomApiError} when the API answers with an error status.
|
|
17
|
+
* @throws {@link CliError} with code `request_timeout` when no answer arrives in time.
|
|
18
|
+
*/
|
|
19
|
+
export async function request(method, path, apiKey, body, baseUrl, headers = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
20
|
+
const url = `${apiBase(baseUrl)}${path}`;
|
|
21
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
22
|
+
let res;
|
|
23
|
+
let json;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(url, {
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${apiKey}`,
|
|
29
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
30
|
+
...headers,
|
|
31
|
+
},
|
|
32
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
33
|
+
signal,
|
|
34
|
+
});
|
|
35
|
+
// The deadline covers the body too: a server can send headers and then stall.
|
|
36
|
+
json = await res.json().catch((err) => {
|
|
37
|
+
if (signal.aborted)
|
|
38
|
+
throw err;
|
|
39
|
+
return {};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (signal.aborted)
|
|
44
|
+
die(`Phantom AI did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)} seconds. Check your connection and try again.`, 'request_timeout');
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw apiError(res.status, json);
|
|
49
|
+
return json;
|
|
50
|
+
}
|
|
51
|
+
export function apiBase(baseUrl) {
|
|
52
|
+
return httpsOnly(baseUrl ||
|
|
53
|
+
(typeof process !== 'undefined' && process.env.PHANTOM_BASE_URL) ||
|
|
54
|
+
DEFAULT_BASE_URL);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The API base must be https. The key goes to it on every call, so plain http
|
|
58
|
+
* is refused, localhost included.
|
|
59
|
+
*/
|
|
60
|
+
export function httpsOnly(baseUrl) {
|
|
61
|
+
let protocol = '';
|
|
62
|
+
try {
|
|
63
|
+
protocol = new URL(baseUrl).protocol;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
die(`PHANTOM_BASE_URL is not a web address: ${baseUrl}`, 'base_url_invalid');
|
|
67
|
+
}
|
|
68
|
+
if (protocol !== 'https:') {
|
|
69
|
+
die(`PHANTOM_BASE_URL must start with https://, not ${baseUrl}. phantom sends your API key there, so it refuses plain http, even on localhost.`, 'base_url_insecure');
|
|
70
|
+
}
|
|
71
|
+
return baseUrl;
|
|
72
|
+
}
|
|
73
|
+
export function apiError(status, json) {
|
|
74
|
+
json = json && typeof json === 'object' ? json : {};
|
|
75
|
+
const err = json.error;
|
|
76
|
+
let code = String(status);
|
|
77
|
+
let msg = `HTTP ${status}`;
|
|
78
|
+
if (err && typeof err === 'object' && !Array.isArray(err)) {
|
|
79
|
+
const e = err;
|
|
80
|
+
code = typeof e.code === 'string' ? e.code : code;
|
|
81
|
+
msg = typeof e.message === 'string' ? e.message : msg;
|
|
82
|
+
}
|
|
83
|
+
else if (typeof err === 'string') {
|
|
84
|
+
// The plan routes answer { error: <code>, detail: <words> }.
|
|
85
|
+
const detail = json.detail;
|
|
86
|
+
msg = typeof detail === 'string' && detail ? detail : err;
|
|
87
|
+
code = err;
|
|
88
|
+
}
|
|
89
|
+
else if (typeof json.detail === 'string') {
|
|
90
|
+
// The purchase routes answer errors as { detail }, some with a { code }.
|
|
91
|
+
msg = json.detail;
|
|
92
|
+
const c = json.code;
|
|
93
|
+
if (typeof c === 'string' && c)
|
|
94
|
+
code = c;
|
|
95
|
+
}
|
|
96
|
+
return new PhantomApiError(status, code, msg);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Credit left, credit spent and expiry of a key (GET /key/balance).
|
|
100
|
+
* @throws {@link CliError} with code `balance_unreadable` when the answer has no numbers, such as a sign-in page that came back with status 200.
|
|
101
|
+
*/
|
|
102
|
+
export async function getBalance(apiKey, baseUrl) {
|
|
103
|
+
const balance = await request('GET', '/key/balance', apiKey, undefined, baseUrl);
|
|
104
|
+
if (!balance || !Number.isFinite(balance.credit_balance_usd) || !Number.isFinite(balance.credit_spent_usd)) {
|
|
105
|
+
die('The balance answer had no credit numbers. Check PHANTOM_BASE_URL, and that the address answers with the Phantom AI API.', 'balance_unreadable');
|
|
106
|
+
}
|
|
107
|
+
return balance;
|
|
108
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { die } from "./errors.mjs";
|
|
6
|
+
import { stateDir } from "./state.mjs";
|
|
7
|
+
/**
|
|
8
|
+
* On macOS, saved keys and wallets live in the login Keychain rather than in
|
|
9
|
+
* files. The file stays, holding only a pointer (`pai-keychain:<id>`), so
|
|
10
|
+
* listing and existence checks work the same on every system. Elsewhere, or with PHANTOM_KEYCHAIN=0, the file holds the secret,
|
|
11
|
+
* readable by this user only.
|
|
12
|
+
*
|
|
13
|
+
* Items are written through /usr/bin/security, which the Keychain then trusts
|
|
14
|
+
* to read them back, so there is no prompt. That also means any program
|
|
15
|
+
* running as this user can read them the same way: the Keychain keeps secrets
|
|
16
|
+
* out of files, backups and sync folders, not away from local programs.
|
|
17
|
+
*
|
|
18
|
+
* A secret file written before this is moved into the Keychain the first time
|
|
19
|
+
* it is read, and replaced with a pointer only after the Keychain returns it
|
|
20
|
+
* intact.
|
|
21
|
+
*/
|
|
22
|
+
// The Keychain service and pointer keep pai, the CLI's old name, so keys saved
|
|
23
|
+
// before the rename still read.
|
|
24
|
+
export const KEYCHAIN_SERVICE = 'pai';
|
|
25
|
+
export const KEYCHAIN_POINTER = 'pai-keychain:';
|
|
26
|
+
/** Reads and writes phantom's items in the macOS login Keychain through /usr/bin/security, under the service name `pai`, the CLI's old name. */
|
|
27
|
+
export const keychain = {
|
|
28
|
+
/** The secret saved under `id`, or null when there is none or the Keychain can't be read. */
|
|
29
|
+
get(id) {
|
|
30
|
+
const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', id, '-w'], { encoding: 'utf-8' });
|
|
31
|
+
return r.status === 0 ? Buffer.from(r.stdout.trim(), 'base64').toString('utf-8') : null;
|
|
32
|
+
},
|
|
33
|
+
/** Saves `value` under `id`, replacing what was there. Returns false when the Keychain refuses, as it does when locked. */
|
|
34
|
+
set(id, label, value) {
|
|
35
|
+
// Sent on stdin rather than as an argument, so the secret never shows in
|
|
36
|
+
// the process list. Base64 keeps it one unquoted word.
|
|
37
|
+
const cmd = `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${id} -l "phantom ${label}" -w ${Buffer.from(value, 'utf-8').toString('base64')}\n`;
|
|
38
|
+
return spawnSync('/usr/bin/security', ['-i'], { input: cmd, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0;
|
|
39
|
+
},
|
|
40
|
+
/** Removes the item saved under `id`, if any. */
|
|
41
|
+
delete(id) {
|
|
42
|
+
spawnSync('/usr/bin/security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', id], { stdio: 'ignore' });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export function keychainOn(env) {
|
|
46
|
+
// PAI_KEYCHAIN is the old name, from when this CLI was pai.
|
|
47
|
+
const flag = env.PHANTOM_KEYCHAIN ?? env.PAI_KEYCHAIN ?? process.env.PHANTOM_KEYCHAIN ?? process.env.PAI_KEYCHAIN;
|
|
48
|
+
return flag === '1' || (process.platform === 'darwin' && flag !== '0');
|
|
49
|
+
}
|
|
50
|
+
function keychainId(file) {
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
return null;
|
|
53
|
+
const raw = readFileSync(file, 'utf-8').trim();
|
|
54
|
+
return raw.startsWith(KEYCHAIN_POINTER) ? raw.slice(KEYCHAIN_POINTER.length) : null;
|
|
55
|
+
}
|
|
56
|
+
export function writeSecret(env, file, value, opts = {}) {
|
|
57
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
58
|
+
let contents = value;
|
|
59
|
+
if (keychainOn(env)) {
|
|
60
|
+
const id = keychainId(file) ?? randomBytes(12).toString('hex');
|
|
61
|
+
if (!keychain.set(id, path.relative(stateDir(env), file), value)) {
|
|
62
|
+
die('Could not save to the macOS Keychain. If it is locked (common over SSH), run: security unlock-keychain. Or set PHANTOM_KEYCHAIN=0 to save to a file.', 'keychain_failed');
|
|
63
|
+
}
|
|
64
|
+
contents = KEYCHAIN_POINTER + id + '\n';
|
|
65
|
+
}
|
|
66
|
+
writeFileSync(file, contents, { mode: 0o600, flag: opts.flag });
|
|
67
|
+
// `mode` applies only when the file is created; tighten one that was already there.
|
|
68
|
+
chmodSync(file, 0o600);
|
|
69
|
+
}
|
|
70
|
+
export function readSecret(env, file) {
|
|
71
|
+
const raw = readFileSync(file, 'utf-8');
|
|
72
|
+
if (raw.trim().startsWith(KEYCHAIN_POINTER)) {
|
|
73
|
+
const value = keychain.get(raw.trim().slice(KEYCHAIN_POINTER.length));
|
|
74
|
+
if (value === null)
|
|
75
|
+
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');
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
if (keychainOn(env)) {
|
|
79
|
+
const id = randomBytes(12).toString('hex');
|
|
80
|
+
if (keychain.set(id, path.relative(stateDir(env), file), raw)) {
|
|
81
|
+
if (keychain.get(id) === raw)
|
|
82
|
+
writeFileSync(file, KEYCHAIN_POINTER + id + '\n', { mode: 0o600 });
|
|
83
|
+
else
|
|
84
|
+
keychain.delete(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
export function removeSecret(file) {
|
|
90
|
+
const id = keychainId(file);
|
|
91
|
+
if (id)
|
|
92
|
+
keychain.delete(id);
|
|
93
|
+
rmSync(file);
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
// Kept at the name the CLI was first published under, so saved keys and
|
|
4
|
+
// wallets survive the rename.
|
|
5
|
+
export function stateDir(env) {
|
|
6
|
+
return env.PHANTOM_STATE_DIR || path.join(os.homedir(), '.config', 'phantom-key');
|
|
7
|
+
}
|