@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,129 @@
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 { routeCommand } from "../spend/command.mjs";
5
+ import { verifyCommand } from "../receipts/command.mjs";
6
+ import { setupCommand } from "../setup/command.mjs";
7
+ import { SETUP_MODEL } from "../setup/shared.mjs";
8
+ import { SETUP_NAMES, orList } from "../setup/agents.mjs";
9
+ async function loginDispatch(input) {
10
+ const { subcommand, flags } = input;
11
+ return subcommand || flags.paste ? loginCommand(input) : workspaceLogin(input);
12
+ }
13
+ async function logoutDispatch(input) {
14
+ await forgetWorkspace(input.env, input.baseUrl);
15
+ return logoutCommand(input);
16
+ }
17
+ async function fundDispatch(input) {
18
+ const { flags } = input;
19
+ void flags.amount;
20
+ void flags.card;
21
+ void flags.coin;
22
+ void flags['no-browser'];
23
+ return workspaceCommand({ ...input, subcommand: 'fund' });
24
+ }
25
+ /** The definitions used by CLI dispatch, help, and generated documentation. */
26
+ export const COMMANDS = [
27
+ {
28
+ name: 'login', handler: loginDispatch, keyless: true,
29
+ help: ` login [--save <name>] [--no-browser] connect an account or guest workspace by device code
30
+ login --paste validate and save an existing key
31
+ login [key] save your key so you don't need PHANTOM_API_KEY
32
+ (use --paste to prompt for an existing key)`,
33
+ },
34
+ {
35
+ name: 'workspace', handler: workspaceCommand, keyless: true,
36
+ help: ` workspace [status] shared balance, keys, recent usage and management access
37
+ workspace fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
38
+ workspace payment --id <payment-id> check verified funding status
39
+ workspace export --file <path> export private guest recovery; refuses overwrites
40
+ workspace import --file <path> restore a guest, then issue replacement keys
41
+ workspace revoke-access --id <id> revoke a browser, device or recovery credential`,
42
+ },
43
+ {
44
+ name: 'fund', handler: fundDispatch, keyless: true,
45
+ help: ` fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
46
+ fund your workspace balance with card or crypto`,
47
+ },
48
+ {
49
+ name: 'keys', handler: workspaceCommand, keyless: true,
50
+ help: ` keys [list] remote workspace key metadata, never secrets
51
+ keys create [--save <name>] [--limit <USD>]
52
+ keys set --id <id> [--limit <USD|none>] [--budget <USD|none>]
53
+ [--period <days|none>] [--rate <USD|none>] [--name <name>]
54
+ [--policy-file <path> | --clear-policy]
55
+ keys rotate --id <id> [--save <name>] replace only the corresponding saved key
56
+ keys revoke --id <id> revoke access, keeping workspace credit
57
+ keys saved [--balance] local saved key metadata
58
+ keys save <name> [key] validate and save an existing key (prompts if omitted)
59
+ keys show [name] print a secret for shell integration only
60
+ keys rm <name> forget a local copy; remote key keeps working`,
61
+ },
62
+ {
63
+ name: 'claude', handler: agentCommand, keyless: true,
64
+ help: ` claude [--key <name>] [--no-browser] -- <agent arguments>`,
65
+ },
66
+ {
67
+ name: 'codex', handler: agentCommand, keyless: true,
68
+ help: ` codex [--key <name>] [--no-browser] -- <agent arguments>`,
69
+ },
70
+ {
71
+ name: 'pi', handler: agentCommand, keyless: true,
72
+ help: ` pi [--key <name>] [--no-browser] -- <agent arguments>
73
+ run with a saved key`,
74
+ },
75
+ {
76
+ name: 'balance', handler: balanceCommand,
77
+ help: ` balance show credit balance and expiry`,
78
+ },
79
+ {
80
+ name: 'key', advanced: true, handler: keyCommand, keyless: true,
81
+ help: ` key list [--balance] saved keys, by name and id
82
+ key save <name> [key] save a key by name (prompts if no key is given)
83
+ key show [name] print a saved key, for PHANTOM_API_KEY=$(...);
84
+ no name prints the key phantom is using
85
+ key rm <name> forget a saved key (the key keeps working)`,
86
+ },
87
+ {
88
+ name: 'route', handler: routeCommand,
89
+ help: ` route [get] show which model "auto" runs, and why
90
+ route set --models a,b,c models for "auto", first is the default
91
+ [--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
92
+ [--stick-minutes n] keep a conversation's model this long (default 5)
93
+ [--stick-by-prompt false] only x-phantom-session marks a conversation
94
+ route set --file policy.json replace the whole policy
95
+ route rule add --if <name=value> --use <model|cheapest|first|next> [--at n]
96
+ conditions: pace, budget_left_pct_below,
97
+ days_left_below, has_tools, input_tokens_over,
98
+ reasoning_requested
99
+ route rule rm <n> remove rule n
100
+ route test [--model auto] which model a request would get now (free)
101
+ route clear remove the policy`,
102
+ },
103
+ {
104
+ name: 'logout', handler: logoutDispatch, keyless: true,
105
+ help: ` logout forget workspace access and the default saved key`,
106
+ },
107
+ {
108
+ name: 'setup', handler: setupCommand, keyless: true,
109
+ help: ` setup set up every agent found: install the skill and
110
+ point its model calls at Phantom AI; shows each
111
+ file's changes and asks first
112
+ [--yes] don't ask
113
+ [--dry-run] show the changes, write nothing
114
+ [--model <id>] the model each agent starts on (default ${SETUP_MODEL})
115
+ setup --agent <name> [--mcp] install the skill for one agent, without asking:
116
+ ${orList(SETUP_NAMES)}
117
+ or all; --mcp also adds the MCP server
118
+ setup [--agent <name>] --provider point that agent's model calls at Phantom AI;
119
+ --provider off removes only what phantom wrote
120
+ setup --restore [--agent <name>] put back the files setup changed, from the
121
+ .phantom.bak copies it kept`,
122
+ },
123
+ {
124
+ name: 'verify', advanced: true, handler: verifyCommand, keyless: ['--receipt'],
125
+ help: ` verify --model <id> make one tiny call and check its signed receipt
126
+ names the model you asked for (costs a fraction of a cent)
127
+ verify --receipt <receipt> check a receipt you already have`,
128
+ },
129
+ ];
@@ -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)');
@@ -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,78 @@
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
+ /**
5
+ * Calls the Phantom AI API with the key as a Bearer token and returns the
6
+ * JSON answer. Every client function in phantom goes through it.
7
+ * @param method The HTTP method.
8
+ * @param path The path after the base URL, such as `/key/balance`.
9
+ * @param apiKey The key sent in the Authorization header.
10
+ * @param body Sent as JSON when given.
11
+ * @param baseUrl The API base. Defaults to PHANTOM_BASE_URL, then {@link DEFAULT_BASE_URL}. Must be https ({@link httpsOnly}).
12
+ * @param headers Extra headers to send.
13
+ * @throws {@link PhantomApiError} when the API answers with an error status.
14
+ */
15
+ export async function request(method, path, apiKey, body, baseUrl, headers = {}) {
16
+ const url = `${apiBase(baseUrl)}${path}`;
17
+ const res = await fetch(url, {
18
+ method,
19
+ headers: {
20
+ Authorization: `Bearer ${apiKey}`,
21
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
22
+ ...headers,
23
+ },
24
+ body: body !== undefined ? JSON.stringify(body) : undefined,
25
+ });
26
+ const json = await res.json().catch(() => ({}));
27
+ if (!res.ok)
28
+ throw apiError(res.status, json);
29
+ return json;
30
+ }
31
+ export function apiBase(baseUrl) {
32
+ return httpsOnly(baseUrl ||
33
+ (typeof process !== 'undefined' && process.env.PHANTOM_BASE_URL) ||
34
+ DEFAULT_BASE_URL);
35
+ }
36
+ /**
37
+ * The API base must be https. The key goes to it on every call, so plain http
38
+ * is refused, localhost included.
39
+ */
40
+ export function httpsOnly(baseUrl) {
41
+ let protocol = '';
42
+ try {
43
+ protocol = new URL(baseUrl).protocol;
44
+ }
45
+ catch {
46
+ die(`PHANTOM_BASE_URL is not a web address: ${baseUrl}`, 'base_url_invalid');
47
+ }
48
+ if (protocol !== 'https:') {
49
+ 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');
50
+ }
51
+ return baseUrl;
52
+ }
53
+ export function apiError(status, json) {
54
+ const err = json.error;
55
+ let code = String(status);
56
+ let msg = `HTTP ${status}`;
57
+ if (err && typeof err === 'object' && !Array.isArray(err)) {
58
+ const e = err;
59
+ code = typeof e.code === 'string' ? e.code : code;
60
+ msg = typeof e.message === 'string' ? e.message : msg;
61
+ }
62
+ else if (typeof err === 'string') {
63
+ msg = err;
64
+ code = err;
65
+ }
66
+ else if (typeof json.detail === 'string') {
67
+ // The purchase routes answer errors as { detail }, some with a { code }.
68
+ msg = json.detail;
69
+ const c = json.code;
70
+ if (typeof c === 'string' && c)
71
+ code = c;
72
+ }
73
+ return new PhantomApiError(status, code, msg);
74
+ }
75
+ /** Credit left, credit spent and expiry of a key (GET /key/balance). */
76
+ export function getBalance(apiKey, baseUrl) {
77
+ return request('GET', '/key/balance', apiKey, undefined, baseUrl);
78
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** This release of phantom. `npm version` keeps it equal to the version in package.json. */
2
+ export const VERSION = '1.0.0';