@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.
@@ -0,0 +1,209 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import path from 'node:path';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { DEFAULT_BASE_URL, httpsOnly, apiError } from "../core/request.mjs";
6
+ import { stateDir } from "../core/state.mjs";
7
+ import { writeSecret, readSecret, removeSecret, keychainOn } from "../core/secrets.mjs";
8
+ import { keyCommand } from "../keys/command.mjs";
9
+ import { die } from "../core/errors.mjs";
10
+ import { openUrl } from "../core/helpers.mjs";
11
+ import { keyPath, namedKeyPath, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
12
+ const credentialPath = (env) => path.join(stateDir(env), 'workspace');
13
+ const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
14
+ async function wire(url, method, endpoint, body, token) {
15
+ const response = await fetch(url + endpoint, { method, redirect: 'error', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
16
+ const data = await response.json();
17
+ if (!response.ok)
18
+ throw apiError(response.status, data);
19
+ return data;
20
+ }
21
+ function credentials(input) {
22
+ const file = credentialPath(input.env);
23
+ if (!existsSync(file))
24
+ die('No workspace connection. Run phantom login.', 'workspace_missing');
25
+ const saved = JSON.parse(readSecret(input.env, file));
26
+ if (saved.base_url !== base(input))
27
+ die('This workspace belongs to a different API address. Run phantom login for this address.', 'workspace_origin');
28
+ return saved;
29
+ }
30
+ /** A read from the saved workspace, such as `/spend`, with its management credential. */
31
+ export async function workspaceGet(input, path) {
32
+ const saved = credentials(input);
33
+ return wire(saved.base_url, 'GET', '/workspaces/' + encodeURIComponent(saved.workspace_id) + path, undefined, saved.management_credential);
34
+ }
35
+ /** Device authorization for accounts or guests, saving keys locally without copying a secret through the browser. */
36
+ export async function workspaceLogin(input) {
37
+ const { env, io, flags } = input;
38
+ if (flags.save === true)
39
+ die('--save requires a key name.');
40
+ const name = typeof flags.save === 'string' ? flags.save : 'default';
41
+ const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
42
+ if (existsSync(target))
43
+ die(`Saved key ${name} already exists. Use --save <new-name>; no key was overwritten.`, 'key_exists');
44
+ const url = base(input);
45
+ const start = await wire(url, 'POST', '/auth/device', { client_name: `phantom on ${hostname()}` });
46
+ if (new URL(start.verification_uri_complete).origin !== new URL(url).origin)
47
+ die('The approval URL belongs to another site.', 'device_origin');
48
+ io.stderr(`Open ${start.verification_uri_complete}\nConfirm code ${start.user_code}\n`);
49
+ if (!flags['no-browser'])
50
+ openUrl(start.verification_uri_complete);
51
+ let interval = Math.max(1, start.interval) * 1000;
52
+ const deadline = Date.now() + Math.min(600, start.expires_in) * 1000;
53
+ while (Date.now() < deadline) {
54
+ await new Promise((r) => setTimeout(r, interval));
55
+ let token;
56
+ try {
57
+ token = await wire(url, 'POST', '/auth/device/token', { device_code: start.device_code });
58
+ }
59
+ catch (error) {
60
+ const code = error.code;
61
+ if (code === 'authorization_pending')
62
+ continue;
63
+ if (code === 'slow_down') {
64
+ interval += 5000;
65
+ continue;
66
+ }
67
+ throw error;
68
+ }
69
+ const saved = { version: 1, workspace_id: token.workspace_id, management_credential: token.management_token, base_url: url, recovery: token.recovery };
70
+ try {
71
+ writeSecret(env, credentialPath(env), JSON.stringify(saved));
72
+ if (name === 'default')
73
+ saveApiKey(env, token.api_key);
74
+ else
75
+ saveNamedKey(env, name, token.api_key);
76
+ }
77
+ catch (error) {
78
+ io.stderr('phantom could not save the connection. Sign in or restore your workspace to revoke the device key and reconnect.\n');
79
+ throw error;
80
+ }
81
+ io.stdout(JSON.stringify({ saved_as: name, key_name: token.key_name, workspace_id: token.workspace_id, storage: keychainOn(env) ? 'macOS Keychain' : 'private files' }) + '\n');
82
+ io.stderr('Ready. Run phantom claude, phantom codex or phantom pi. Guests can export recovery with phantom workspace export --file <path>.\n');
83
+ return 0;
84
+ }
85
+ die('Device code expired. Run phantom login again.', 'login_expired');
86
+ }
87
+ /** Manage a workspace from the terminal: balance, keys, funding, recovery and revocation. */
88
+ export async function workspaceCommand(input) {
89
+ const { subcommand, flags, io, env, argv } = input;
90
+ if (argv[0] === 'keys') {
91
+ if (subcommand === 'saved' || subcommand === 'save' || subcommand === 'show' || subcommand === 'rm') {
92
+ return keyCommand({ ...input, flags: { ...(flags.balance ? { balance: flags.balance } : {}) }, subcommand: subcommand === 'saved' ? 'list' : subcommand });
93
+ }
94
+ const remoteCommands = new Set(['list', 'create', 'rotate', 'revoke', 'set', undefined]);
95
+ if (!remoteCommands.has(subcommand)) {
96
+ return keyCommand({ ...input, subcommand });
97
+ }
98
+ }
99
+ const command = subcommand ?? 'status';
100
+ if (command === 'import' && argv[0] === 'workspace') {
101
+ if (typeof flags.file !== 'string')
102
+ die('Use phantom workspace import --file <recovery-file>.');
103
+ const recovery = JSON.parse(readFileSync(flags.file, 'utf8'));
104
+ if (!recovery || recovery.version !== 1)
105
+ die('Invalid recovery file.');
106
+ await wire(base(input), 'GET', '/workspaces/' + encodeURIComponent(recovery.workspace_id), undefined, recovery.management_credential);
107
+ writeSecret(env, credentialPath(env), JSON.stringify({ ...recovery, base_url: base(input), recovery }));
108
+ io.stdout('Workspace restored. Use phantom keys create to save a new API key.\n');
109
+ return 0;
110
+ }
111
+ const saved = credentials(input);
112
+ const endpoint = '/workspaces/' + encodeURIComponent(saved.workspace_id);
113
+ if (command === 'export') {
114
+ if (!saved.recovery)
115
+ die('This is an account workspace. Sign in to recover it on another computer.');
116
+ if (typeof flags.file !== 'string')
117
+ die('Use phantom workspace export --file <path>.');
118
+ writeFileSync(flags.file, JSON.stringify(saved.recovery, null, 2), { mode: 0o600, flag: 'wx' });
119
+ io.stdout('Recovery exported. Keep that file private; it grants workspace management.\n');
120
+ return 0;
121
+ }
122
+ if (command === 'create' || command === 'rotate') {
123
+ if (command === 'rotate' && typeof flags.id !== 'string')
124
+ die('Use --id <key-id> from phantom keys list.');
125
+ const name = typeof flags.save === 'string' ? flags.save : 'default';
126
+ if (flags.save === true)
127
+ die('--save requires a key name.');
128
+ const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
129
+ const replacing = existsSync(target);
130
+ if (replacing && (command !== 'rotate' || createHash('sha256').update(readSecret(env, target).trim()).digest('hex') !== flags.id))
131
+ die('That saved key already exists. Use --save <new-name>.', 'key_exists');
132
+ const result = await wire(saved.base_url, 'POST', command === 'create' ? endpoint + '/keys' : endpoint + '/keys/' + String(flags.id), command === 'create' ? { name, limit_usd: flags.limit === undefined ? null : Number(flags.limit) } : { action: 'rotate' }, saved.management_credential);
133
+ if (name === 'default')
134
+ saveApiKey(env, result.api_key);
135
+ else
136
+ saveNamedKey(env, name, result.api_key, { replace: replacing });
137
+ io.stdout(JSON.stringify({ saved_as: name }) + '\n');
138
+ return 0;
139
+ }
140
+ if (command === 'revoke') {
141
+ if (typeof flags.id !== 'string')
142
+ die('Use phantom keys revoke --id <key-id>.');
143
+ await wire(saved.base_url, 'DELETE', endpoint + '/keys/' + flags.id, undefined, saved.management_credential);
144
+ io.stdout('Key revoked.\n');
145
+ return 0;
146
+ }
147
+ if (command === 'set') {
148
+ if (typeof flags.id !== 'string')
149
+ die('Use phantom keys set --id <key-id>.');
150
+ const body = {};
151
+ const controls = { limit_usd: flags.limit, budget_usd: flags.budget, period_days: flags.period, rate_usd_per_min: flags.rate };
152
+ for (const [field, value] of Object.entries(controls))
153
+ if (value !== undefined)
154
+ body[field] = value === 'none' ? null : Number(value);
155
+ if (typeof flags.name === 'string')
156
+ body.name = flags.name;
157
+ if (typeof flags['policy-file'] === 'string')
158
+ body.route_policy = JSON.parse(readFileSync(flags['policy-file'], 'utf8'));
159
+ if (flags['clear-policy'])
160
+ body.route_policy = null;
161
+ const result = await wire(saved.base_url, 'PATCH', endpoint + '/keys/' + flags.id, body, saved.management_credential);
162
+ io.stdout(JSON.stringify(result) + '\n');
163
+ return 0;
164
+ }
165
+ if (command === 'revoke-access') {
166
+ if (typeof flags.id !== 'string')
167
+ die('Use --id <management-id> from phantom workspace status.');
168
+ await wire(saved.base_url, 'DELETE', endpoint + '/access/' + flags.id, undefined, saved.management_credential);
169
+ io.stdout('Management access revoked.\n');
170
+ return 0;
171
+ }
172
+ if (command === 'payment') {
173
+ if (typeof flags.id !== 'string')
174
+ die('Use --id <payment-id> from phantom workspace fund.');
175
+ io.stdout(JSON.stringify(await wire(saved.base_url, 'GET', endpoint + '/payments/' + flags.id, undefined, saved.management_credential)) + '\n');
176
+ return 0;
177
+ }
178
+ if (command === 'fund') {
179
+ if (flags.pay)
180
+ die('--pay has been removed. Use --card or --coin.');
181
+ if (!flags.card && !flags.coin)
182
+ die('fund requires --card or --coin.');
183
+ const result = await wire(saved.base_url, 'POST', endpoint + '/fund', { method: flags.card ? 'card' : 'crypto', amount_usd: Number(flags.amount), coin: flags.coin ?? 'usdc' }, saved.management_credential);
184
+ io.stdout(JSON.stringify(result) + '\n');
185
+ if (result.checkout_url && !flags['no-browser'])
186
+ openUrl(result.checkout_url);
187
+ return 0;
188
+ }
189
+ const view = await wire(saved.base_url, 'GET', endpoint, undefined, saved.management_credential);
190
+ io.stdout(JSON.stringify(argv[0] === 'keys' ? view.keys : view, null, 2) + '\n');
191
+ return 0;
192
+ }
193
+ /** Forget local management credentials and revoke server management access, leaving server-side keys and recovery files valid. */
194
+ export async function forgetWorkspace(env, baseUrl) {
195
+ const file = credentialPath(env);
196
+ if (existsSync(file)) {
197
+ try {
198
+ const saved = JSON.parse(readSecret(env, file));
199
+ if (saved?.workspace_id && saved?.management_credential) {
200
+ const url = httpsOnly(baseUrl || env.PHANTOM_BASE_URL || saved.base_url || DEFAULT_BASE_URL).replace(/\/$/, '');
201
+ await wire(url, 'POST', '/workspaces/' + encodeURIComponent(saved.workspace_id) + '/logout', {}, saved.management_credential).catch(() => { });
202
+ }
203
+ }
204
+ catch {
205
+ // Ignore read or parse errors during forget
206
+ }
207
+ removeSecret(file);
208
+ }
209
+ }
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
+ "keywords": [
6
+ "phantom",
7
+ "api-key",
8
+ "agent",
9
+ "llm",
10
+ "inference",
11
+ "cli",
12
+ "mcp",
13
+ "pi-package",
14
+ "agent-skill",
15
+ "claude-code",
16
+ "codex",
17
+ "cursor",
18
+ "model-context-protocol",
19
+ "ai-agents",
20
+ "solana",
21
+ "wallet",
22
+ "subagents"
23
+ ],
24
+ "homepage": "https://phantom.codes/",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/connortessaro/phantom.git",
28
+ "directory": "packages/cli"
29
+ },
30
+ "type": "module",
31
+ "bin": {
32
+ "phantom": "bin/phantom.mjs"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "dist",
37
+ "skills",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=24"
43
+ },
44
+ "dependencies": {
45
+ "jsonc-parser": "^3.3.1",
46
+ "smol-toml": "^1.9.0",
47
+ "zod": "^4.5.4"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p .",
51
+ "coverage": "vitest run --coverage",
52
+ "dev": "node src/phantom.mts",
53
+ "docs": "node scripts/gen-docs.mjs",
54
+ "prepare": "npm run build",
55
+ "test": "vitest run",
56
+ "typecheck": "tsc -p . --noEmit",
57
+ "version": "node scripts/sync-version.mjs && git add src/core/version.mts"
58
+ },
59
+ "pi": {
60
+ "skills": [
61
+ "./skills"
62
+ ]
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/connortessaro/phantom/issues"
66
+ },
67
+ "license": "SEE LICENSE IN LICENSE",
68
+ "author": "Harbor Financial LLC",
69
+ "devDependencies": {
70
+ "@types/node": "^24.19.1",
71
+ "@vitest/coverage-v8": "^5.0.3",
72
+ "typedoc": "^0.28.20",
73
+ "typescript": "^6.0.3",
74
+ "vitest": "^5.0.2"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "exports": {
80
+ ".": {
81
+ "types": "./dist/phantom.d.mts",
82
+ "import": "./dist/phantom.mjs"
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: phantom-ai
3
+ description: Use the phantom CLI to connect and run coding agents through Phantom AI, manage workspace funding and spending controls, and route models. Applies when the user requests Phantom inference or payment workflows.
4
+ ---
5
+
6
+ # Phantom AI
7
+
8
+ Phantom AI serves model inference at `https://phantom.codes/v1`. The Phantom CLI connects
9
+ existing coding agents to that API, manages model routing to control costs, and
10
+ handles workspace funding and controls. Management commands
11
+ print JSON; add `--table` for supported human-readable output.
12
+
13
+ ## Connect and run
14
+
15
+ ```bash
16
+ phantom login
17
+ phantom workspace status
18
+ phantom codex
19
+ phantom claude --key coding
20
+ phantom pi
21
+ ```
22
+
23
+ Login uses browser device approval and saves a key privately; ask the user to
24
+ complete approval themselves. For an existing key, have them run
25
+ `phantom login --paste` locally, never paste it into chat.
26
+ Agent configuration is temporary. The Phantom CLI flags go before agent arguments, or `--`.
27
+
28
+ ```bash
29
+ phantom codex --key coding -- exec "review this repository"
30
+ ```
31
+
32
+ Named-key selection uses `PHANTOM_KEY_NAME`, then `PHANTOM_API_KEY`, then the
33
+ saved login key. An agent's explicit `--key` selects that local named key.
34
+
35
+ ## Connect existing agents for future runs
36
+
37
+ Offer saved setup when the user wants their installed agent to use Phantom AI
38
+ without a phantom launcher. Ask whether they want saved setup or a temporary run;
39
+ when their request already specifies one, follow it.
40
+
41
+ ```bash
42
+ phantom setup --dry-run --table
43
+ phantom setup
44
+ phantom setup --restore
45
+ ```
46
+
47
+ Preview and show the configuration changes before applying the setup the user
48
+ approves. Plain setup detects supported agents and asks before writing. Restart
49
+ the agent or open a new terminal afterward. It keeps backups for restore.
50
+ To connect only Codex, preview the targeted setup first:
51
+
52
+ ```bash
53
+ phantom setup --agent codex --provider --dry-run --table
54
+ phantom setup --agent codex --provider
55
+ ```
56
+
57
+ Targeted provider setup applies without its own confirmation; run it only after
58
+ the user approves the preview. Cursor needs manual settings steps; pi receives
59
+ the skill only. Setup wires up a saved key through helpers or environment
60
+ variables where supported; Cline stores it in a private config file.
61
+ If the user requested a budget, verify that the key the configured agent uses
62
+ has that cap. Creating a separate capped key does not change the default key.
63
+
64
+ ## Fund a workspace
65
+
66
+ ```bash
67
+ phantom fund --amount 10 --card
68
+ phantom fund --amount 10 --coin usdc
69
+ phantom workspace payment --id <payment-id>
70
+ ```
71
+
72
+ Card checkout opens the payment URL in your browser. Crypto funding (`--coin usdc|usdt|sol`)
73
+ provides a Solana pay URL. Credit is available once confirmed. Check pending payments with
74
+ `phantom workspace payment --id <id>`. Check balance anytime with `phantom balance`.
75
+
76
+ ## Control spending and keys
77
+
78
+ ```bash
79
+ phantom keys create --save coding --limit 5
80
+ phantom keys list
81
+ phantom keys set --id <key-id> --budget 20 --period 30 --rate 1
82
+ phantom keys revoke --id <key-id>
83
+ phantom keys saved
84
+ phantom keys rm <name>
85
+ phantom balance
86
+ ```
87
+
88
+ Use the full ID from workspace key metadata. Key controls are enforced by the
89
+ server; the rate is dollars per minute. Revocation preserves shared funds.
90
+ `keys saved` lists local metadata; `keys rm` only forgets the local copy.
91
+
92
+ ## Route models
93
+
94
+ ```bash
95
+ phantom route
96
+ phantom route set --models <model-a>,<model-b>
97
+ phantom route rule add --if has_tools=true --use first
98
+ phantom route test --table
99
+ ```
100
+
101
+ Routing affects `auto` requests. Use `PHANTOM_KEY_NAME=coding` to manage the same
102
+ key used by an agent with `--key coding`. Route tests are free.
103
+
104
+ ## Pick phantom-1's models
105
+
106
+ ```bash
107
+ phantom lineup --table
108
+ phantom lineup set --frontier <model> --economy-fallback <model>
109
+ phantom lineup reset
110
+ ```
111
+
112
+ Each tier (economy, standard, frontier) takes a model from its own choices, which
113
+ `phantom lineup` lists. A fallback is the model to try when the tier's model fails.
114
+ `default` in place of a model puts that tier back to its default. `lineup set`
115
+ keeps the tiers you don't name.
116
+
117
+ ## See what was spent
118
+
119
+ ```bash
120
+ phantom spend --table
121
+ phantom spend --workspace --from 2026-09-01 --to 2026-09-30
122
+ ```
123
+
124
+ `spend` shows what the key spent by model and day, and an estimate of what `phantom-1`
125
+ saved against its frontier model. `--workspace` shows every key in the workspace by
126
+ name. Dates are UTC days (`YYYY-MM-DD`); the default is this month.
127
+
128
+ ## Plans
129
+
130
+ ```bash
131
+ phantom plan --table
132
+ phantom plan pro
133
+ phantom plan manage
134
+ ```
135
+
136
+ A Pro or Max plan pays for `phantom-1` calls up to an amount of model cost in any 7 days,
137
+ then the key's credit pays. `plan` shows the plan and the week's use. `plan pro` and
138
+ `plan max` print a Stripe Checkout link (and open it unless `--no-browser`); the user
139
+ pays there. `plan manage` opens Stripe's billing page. Naming a model, not `phantom-1`,
140
+ still spends credit.
141
+
142
+ ## Advanced tasks
143
+
144
+ Use `phantom --help --all` for receipts, MCP and direct-key purchases.
145
+ Export guest recovery with `phantom workspace export --file
146
+ <path>` only to a private destination. Recovery grants workspace management access.
147
+
148
+ ## Rules
149
+
150
+ - Never print, log or repeat API keys, wallet secrets, management credentials or recovery files. `phantom keys show` is for shell integration, not model context.
151
+ - Use CLI commands to manage state; do not manually edit or delete its files.
152
+ - Ask before payments the user has not requested. Existing task approval covers only the requested payment and amount.
153
+ - Run permanent `phantom setup` changes only when requested; otherwise preview with `--dry-run`.
154
+ - Use a user's existing subscription agent when they request that workflow. Phantom inference requires their intent.
155
+ - Exit code 2 means a rejected key; report it rather than retrying unchanged credentials.