@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.
package/LICENSE ADDED
@@ -0,0 +1,81 @@
1
+ Phantom Proprietary License
2
+
3
+ Copyright (c) 2026 Connor Tessaro. All rights reserved.
4
+
5
+ This license covers the Phantom source code, the Phantom website, and the
6
+ Phantom CLI command-line tool ("the CLI"), together "the Software". It
7
+ applies to every copy of the Software that carries this file.
8
+
9
+ 1. Ownership
10
+
11
+ Connor Tessaro owns all right, title and interest in the Software,
12
+ including all copyrights and other intellectual property rights. Nothing
13
+ in this license transfers ownership to you.
14
+
15
+ 2. No grant
16
+
17
+ This license grants no rights in the Phantom source code, including the
18
+ code behind the website. You may not use, copy, modify or distribute any
19
+ part of it unless Connor Tessaro gives you written permission. The only
20
+ permission this license grants is the one in section 3, and it covers the
21
+ CLI alone. Visiting phantom.codes and using the Phantom service are
22
+ governed by the terms of service at phantom.codes/terms, not by this
23
+ license.
24
+
25
+ 3. Limited permission for the Phantom CLI
26
+
27
+ If you got the CLI from Connor Tessaro's official release (the
28
+ @phantomcodes/cli package on npm), you may install and run unmodified
29
+ copies of it, only to access the Phantom service. This permission is
30
+ revocable, non-exclusive, non-transferable and non-sublicensable.
31
+
32
+ 4. Restrictions
33
+
34
+ Except as section 3 allows, you may not:
35
+
36
+ a. copy, modify, translate or create derivative works of the Software;
37
+ b. distribute, sublicense, rent, lease, lend or sell the Software;
38
+ c. reverse engineer, decompile or disassemble the Software, except to the
39
+ extent applicable law allows it despite this limit;
40
+ d. remove or change any copyright, license or other notice in the
41
+ Software;
42
+ e. use the Software, or any knowledge of how it works, to build or train
43
+ a competing product or service, including a model router; or
44
+ f. publish benchmarks or performance tests of the Software.
45
+
46
+ 5. Confidentiality
47
+
48
+ The non-public parts of the Software, including its source code, routing
49
+ logic and design, are Connor Tessaro's trade secrets. If you see them, you
50
+ must keep them confidential and use them only as Connor Tessaro allows in
51
+ writing.
52
+
53
+ 6. Termination
54
+
55
+ This license, and any permission under section 3, ends automatically if
56
+ you break any of its terms. Connor Tessaro may also end the section 3
57
+ permission at any time. When it ends, you must stop using the Software and
58
+ destroy all copies you have.
59
+
60
+ 7. No warranty
61
+
62
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
63
+ IMPLIED, INCLUDING THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
64
+ PARTICULAR PURPOSE AND NONINFRINGEMENT.
65
+
66
+ 8. Limitation of liability
67
+
68
+ TO THE FULLEST EXTENT THE LAW ALLOWS, CONNOR TESSARO IS NOT LIABLE FOR ANY
69
+ INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR
70
+ ANY LOSS OF DATA, PROFITS OR REVENUE, ARISING FROM THE SOFTWARE OR THIS
71
+ LICENSE. HIS TOTAL LIABILITY UNDER THIS LICENSE IS LIMITED TO THE AMOUNT
72
+ YOU PAID FOR THE SOFTWARE, IF ANY.
73
+
74
+ 9. Governing law
75
+
76
+ The laws of the Commonwealth of Massachusetts govern this license, without regard to its conflict
77
+ of laws rules.
78
+
79
+ 10. Contact
80
+
81
+ To ask for permission beyond this license, email support@phantom.codes.
package/README.md CHANGED
@@ -1,3 +1,192 @@
1
- # Temporary Holding Version
1
+ # Your agents have expensive taste.
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect your agents to Phantom AI. Route models, control spending and pay from your terminal.
4
+
5
+ **Phantom CLI** (`phantom`) by [Phantom AI](https://phantom.codes) · [Docs](https://phantom.codes/docs) · [npm](https://www.npmjs.com/package/@phantomcodes/cli)
6
+
7
+ Use Phantom AI's router to send requests to cheaper models when your rules allow
8
+ it, or choose a model yourself. Connect the coding agents you already use, fund
9
+ their inference and control spending—all from your terminal.
10
+
11
+ ## Install the agent skill
12
+
13
+ The `phantom-ai` skill ships in the package. `phantom setup` installs it for
14
+ each agent it finds:
15
+
16
+ ```bash
17
+ npm i -g @phantomcodes/cli
18
+ phantom setup --agent claude
19
+ ```
20
+
21
+ The skill covers workspace setup, funding, spending controls and model routing.
22
+ [Skill docs](docs/guides/agent-skill.md)
23
+
24
+ ## Let your agent set it up
25
+
26
+ Copy this into Claude Code, Codex, Cursor or another agent with terminal access:
27
+
28
+ ```text
29
+ Help me set up phantom for AI inference through Phantom AI.
30
+
31
+ Check that I have Node 24 or newer, then install @phantomcodes/cli if needed.
32
+ Run phantom setup --agent <my agent> to install the phantom-ai skill, and read it.
33
+ Check for an existing workspace and saved keys before changing anything.
34
+ If I need to connect, start phantom login and let me complete the device approval.
35
+ Check the workspace balance. Ask which coding agent I use and what budget I want,
36
+ then create a named workspace key with that cap.
37
+ Ask whether I want to connect my existing agent for future runs or use a temporary
38
+ connection for one run. For saved setup, preview the changes with phantom setup --dry-run
39
+ and show them to me before applying the setup I approve. Make sure any spending cap
40
+ is on the key my agent will actually use. Ask before adding funds or starting paid
41
+ inference. Keep keys and recovery credentials out of chat. Finish with the command
42
+ to launch my agent, and explain how to undo any saved setup.
43
+ ```
44
+
45
+ ## Connect your existing agents
46
+
47
+ Keep using your coding agent, with Phantom AI handling its inference and model
48
+ routing. Connect it using your saved key:
49
+
50
+ ```bash
51
+ npm i -g @phantomcodes/cli
52
+ phantom login
53
+ phantom setup
54
+ ```
55
+
56
+ Used pai, this CLI's old name? Run `phantom setup` once after you install. Until
57
+ then, Claude Code and the shell block pai set up still call `pai`, and phantom
58
+ prints a reminder.
59
+
60
+ The Phantom CLI detects supported agents, previews the configuration changes, then asks before
61
+ applying them. It connects their model calls to Phantom AI and wires up the saved
62
+ key. Restart your agent, or open a new terminal, to use the connection.
63
+
64
+ Codex, Claude Code, opencode, Continue, Goose, Aider, GitHub Copilot CLI and Cline
65
+ support saved setup. Cursor gets instructions for its settings screen; pi gets
66
+ the agent skill. [Agent-specific setup](docs/guides/agent-setup.md).
67
+
68
+ Preview without changing files with `phantom setup --dry-run`. The Phantom CLI keeps backups;
69
+ undo the changes with `phantom setup --restore`.
70
+
71
+ ## Connect for one run
72
+
73
+ ```bash
74
+ npm i -g @phantomcodes/cli
75
+ phantom login
76
+ phantom workspace fund --amount 10 --card
77
+ phantom workspace status
78
+ phantom codex
79
+ ```
80
+
81
+ Both setup options require **Node 24+** and your coding agent installed. Connect, fund your
82
+ workspace, then launch **Codex** (`phantom codex`), **Claude Code** (`phantom claude`),
83
+ **pi** (`phantom pi`), or **any other compatible agent**.
84
+ [Set up other agents](docs/guides/agent-setup.md).
85
+
86
+ Login opens a device approval page and saves a key privately. Funding opens
87
+ checkout; check your workspace balance after payment. Agent runs use temporary
88
+ configuration and print spending afterward. Your existing agent configuration
89
+ stays in place.
90
+
91
+ Already have a Phantom key? Use `phantom login --paste` to save it locally.
92
+
93
+ ## Fund your workspace
94
+
95
+ Add credit first, then let your agents spend from the shared balance:
96
+
97
+ ```bash
98
+ phantom fund --amount 10 --card
99
+ phantom fund --amount 10 --coin usdc
100
+ ```
101
+
102
+ Credit is available after the payment is verified. Check balance anytime with `phantom balance`.
103
+
104
+ [Funding and payment details](docs/guides/buying-credit.md).
105
+
106
+ ## Give each agent a budget
107
+
108
+ Create a key with its own cap, sharing your workspace balance:
109
+
110
+ ```bash
111
+ phantom keys create --save coding --limit 5
112
+ phantom claude --key coding
113
+ phantom keys list
114
+ phantom keys set --id <key-id> --limit 10
115
+ ```
116
+
117
+ Workspace key controls include total limits, periodic budgets and spending
118
+ rates. Use the full key ID from `phantom keys list` when changing or revoking a key.
119
+ Revoking a workspace key preserves workspace funds.
120
+
121
+ ## Choose what answers
122
+
123
+ Let the router choose a model for `auto` requests. Your policy can prefer cheaper
124
+ models and change the choice for tool use, reasoning or spending limits:
125
+
126
+ ```bash
127
+ PHANTOM_KEY_NAME=coding phantom route set --models <model-a>,<model-b>
128
+ PHANTOM_KEY_NAME=coding phantom route test --table
129
+ ```
130
+
131
+ Prefer a specific model? Select it in your agent. Policies that apply only to
132
+ `auto` leave explicit model choices alone.
133
+ [Routing](docs/guides/routing.md) covers conditions, fallback and conversation affinity.
134
+
135
+ ## Commands
136
+
137
+ | Task | Commands |
138
+ | --- | --- |
139
+ | Connect | `phantom login`, `phantom logout` |
140
+ | Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
141
+ | Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
142
+ | Fund and check spending | `phantom workspace fund`, `phantom workspace status`, `phantom balance` |
143
+ | Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
144
+ | Manage local copies | `phantom keys saved`, `phantom keys save`, `phantom keys rm` |
145
+ | Route models | `phantom route` |
146
+
147
+ Put phantom flags before agent arguments; use `--` to separate them:
148
+
149
+ ```bash
150
+ phantom codex --key coding -- exec "explain this repository"
151
+ ```
152
+
153
+ Commands print JSON; `--table` renders supported results for people. Agent
154
+ commands inherit the terminal and print a spending summary on stderr.
155
+ Exit codes: 0 success, 1 error, 2 rejected key; agent runs return the agent's exit code.
156
+
157
+ <details>
158
+ <summary><strong>Advanced tools, integrations and legacy commands</strong></summary>
159
+
160
+ `phantom --help` shows the main workflows. `phantom --help --all` and the
161
+ [interface reference](docs/reference.md) cover every command.
162
+
163
+ - [Signed receipts](docs/guides/receipts.md): check which model answered with `phantom verify`.
164
+ - [Funding credit](docs/guides/buying-credit.md): `phantom fund` and checking payments.
165
+ - [Saved secrets](docs/guides/saved-secrets.md): macOS Keychain or private files; guest recovery uses `phantom workspace export` and `phantom workspace import`.
166
+ - [Key selection](docs/guides/which-key.md) and [API address](docs/guides/base-url.md): environment overrides and shell integration.
167
+
168
+ The older `phantom key` commands still work.
169
+ `phantom keys list` reads the workspace; `phantom keys saved` lists local copies.
170
+ `phantom keys show` prints a secret for shell integration; keep it out of chat and logs.
171
+
172
+ </details>
173
+
174
+ <details>
175
+ <summary><strong>Development and contributing</strong></summary>
176
+
177
+ ```bash
178
+ npm ci
179
+ npm run typecheck
180
+ npm run build
181
+ npm run coverage
182
+ npm run docs
183
+ node scripts/gen-docs.mjs --check
184
+ ```
185
+
186
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
187
+
188
+ </details>
189
+
190
+ ---
191
+
192
+ phantom is proprietary, under the [Phantom Proprietary License](LICENSE). It lets you install and run phantom to use Phantom AI, the hosted API it calls.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The installed entry point.
4
+ *
5
+ * Two reasons this is plain JS rather than the .mts itself. Node refuses to
6
+ * strip types from anything under node_modules at all, so a published
7
+ * TypeScript source cannot run in a dependency; and npm links the bin as an
8
+ * extensionless `phantom`, which Node would not recognise as TypeScript
9
+ * even if it would. So `npm run build` compiles the source to dist/ and this
10
+ * file, which never needs compiling, is what npm points at.
11
+ */
12
+ import { main } from '../dist/phantom.mjs';
13
+
14
+ await main();
@@ -0,0 +1,155 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { die } from "../core/errors.mjs";
6
+ import { DEFAULT_BASE_URL, httpsOnly, request } from "../core/request.mjs";
7
+ import { keyId, readNamedKey, resolveApiKey } from "../keys/saved.mjs";
8
+ import { workspaceLogin } from "../workspace/command.mjs";
9
+ const DEFAULT_MODEL = 'auto';
10
+ async function keyStatus(apiKey, baseUrl) {
11
+ return request('GET', '/key/balance', apiKey, undefined, baseUrl);
12
+ }
13
+ import { proxyToken, startProxy } from "./proxy.mjs";
14
+ const hasModel = (args) => args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model=') || /^model=/.test(a));
15
+ /** The agents phantom can run, by command name. */
16
+ export const AGENTS = {
17
+ claude: {
18
+ bin: 'claude',
19
+ prefix: '/claude-code',
20
+ launch(proxy, args, env, _tmp, token) {
21
+ // --settings outranks settings.json, so an env block or apiKeyHelper
22
+ // `phantom setup` wrote can't send Claude Code past the proxy. The helper
23
+ // reads the token from the environment, so it stays off the command line.
24
+ const vars = { ANTHROPIC_BASE_URL: proxy, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1', ENABLE_TOOL_SEARCH: 'true' };
25
+ const { ANTHROPIC_AUTH_TOKEN: _auth, ...rest } = env;
26
+ return {
27
+ args: ['--settings', JSON.stringify({ apiKeyHelper: 'printenv PHANTOM_AGENT_TOKEN', env: vars }), ...args],
28
+ env: { ...rest, ANTHROPIC_BASE_URL: proxy, ANTHROPIC_API_KEY: token, PHANTOM_AGENT_TOKEN: token },
29
+ };
30
+ },
31
+ },
32
+ codex: {
33
+ bin: 'codex',
34
+ prefix: '/codex',
35
+ launch(proxy, args, env, _tmp, token) {
36
+ const base = `${proxy}/v1`;
37
+ const provider = [
38
+ ['name', 'Phantom AI (phantom)'],
39
+ ['base_url', base],
40
+ ['wire_api', 'responses'],
41
+ ['env_key', 'PHANTOM_AGENT_TOKEN'],
42
+ ['model_catalog_url', `${base}/models`],
43
+ ];
44
+ // phantom_run, not phantom: phantom setup saves [model_providers.phantom],
45
+ // and -c merges into a table, so keys added there by hand would ride along.
46
+ const config = [
47
+ 'model_provider="phantom_run"',
48
+ ...provider.map(([k, v]) => `model_providers.phantom_run.${k}=${JSON.stringify(v)}`),
49
+ 'features.api_key_model_discovery=true',
50
+ ...(hasModel(args) ? [] : [`model=${JSON.stringify(DEFAULT_MODEL)}`]),
51
+ ];
52
+ return { args: [...config.flatMap((c) => ['-c', c]), ...args], env: { ...env, OPENAI_BASE_URL: base, PHANTOM_AGENT_TOKEN: token } };
53
+ },
54
+ },
55
+ pi: {
56
+ bin: 'pi',
57
+ prefix: '/coding-agent',
58
+ launch(proxy, args, env, tmp, token) {
59
+ // pi reads providers from models.json or an extension; an extension
60
+ // for this run leaves the user's own files alone. Its key is read from
61
+ // PHANTOM_AGENT_TOKEN when pi calls, so the file holds no secret.
62
+ const file = path.join(tmp, 'phantom-provider.ts');
63
+ const model = { id: DEFAULT_MODEL, name: 'Phantom AI router (phantom)', reasoning: true, input: ['text', 'image'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, maxTokens: 64_000 };
64
+ writeFileSync(file, `export default function (pi) {\n pi.registerProvider('phantom', ${JSON.stringify({ baseUrl: `${proxy}/v1`, api: 'openai-completions', apiKey: '$PHANTOM_AGENT_TOKEN', models: [model] })});\n}\n`, { mode: 0o600 });
65
+ return { args: ['-e', file, ...(hasModel(args) ? [] : ['--provider', 'phantom', '--model', DEFAULT_MODEL]), ...args], env: { ...env, PHANTOM_AGENT_TOKEN: token } };
66
+ },
67
+ },
68
+ };
69
+ /** Waits for the agent and returns its exit code; a signal counts as 128 plus its number. */
70
+ function waitFor(bin, args, env) {
71
+ return new Promise((resolve, reject) => {
72
+ const child = spawn(bin, args, { stdio: 'inherit', env: env });
73
+ // Ctrl-C reaches the agent itself; phantom stays to stop the proxy and print the spend.
74
+ const ignore = () => { };
75
+ process.on('SIGINT', ignore);
76
+ const done = () => process.off('SIGINT', ignore);
77
+ child.on('error', (err) => {
78
+ done();
79
+ reject(err.code === 'ENOENT' ? new Error(`${bin} is not on your PATH. Install it, then run this again.`) : err);
80
+ });
81
+ child.on('exit', (code, signal) => {
82
+ done();
83
+ resolve(code ?? (signal ? 128 + (os.constants.signals[signal] ?? 0) : 1));
84
+ });
85
+ });
86
+ }
87
+ /**
88
+ * `phantom claude`, `phantom codex` and `phantom pi`: runs the agent through the proxy,
89
+ * passing every other argument through, and returns its exit code. The Phantom CLI's own
90
+ * flags come first and stop at the first other argument or at `--`; they are
91
+ * read here, not with `flags`, because everything after them belongs to the
92
+ * agent. Run by `run()`.
93
+ */
94
+ export async function agentCommand(input) {
95
+ const { argv, env, io, baseUrl } = input;
96
+ const name = argv[0];
97
+ const spec = AGENTS[name];
98
+ let keyName;
99
+ let noBrowser = false;
100
+ let i = 1;
101
+ const value = (flag) => {
102
+ const v = argv[++i];
103
+ if (v === undefined || v.startsWith('--'))
104
+ die(`${flag} needs a value`);
105
+ return v;
106
+ };
107
+ for (; i < argv.length; i++) {
108
+ const a = argv[i];
109
+ if (a === '--') {
110
+ i++;
111
+ break;
112
+ }
113
+ if (a === '--key')
114
+ keyName = value(a);
115
+ else if (a === '--mpp')
116
+ die('--mpp has been removed. Phantom stays prepaid only. Run phantom login or use a saved key.');
117
+ else if (a === '--no-browser')
118
+ noBrowser = true;
119
+ else
120
+ break;
121
+ }
122
+ const agentArgs = argv.slice(i);
123
+ let apiKey = keyName ? readNamedKey(env, keyName) : resolveApiKey(env);
124
+ if (!apiKey) {
125
+ if (!io.isTTY)
126
+ die('Connect a key with phantom login.');
127
+ await workspaceLogin({ ...input, flags: { ...(noBrowser ? { 'no-browser': true } : {}) } });
128
+ apiKey = resolveApiKey(env);
129
+ }
130
+ if (!apiKey)
131
+ die('No API key. Run: phantom login');
132
+ const auth = { kind: 'key', apiKey };
133
+ const before = await keyStatus(apiKey, baseUrl).catch(() => null);
134
+ const report = async () => {
135
+ const after = await keyStatus(apiKey, baseUrl).catch(() => null);
136
+ if (!before || !after)
137
+ return `could not read spend for key ${keyId(apiKey)}`;
138
+ return `charged $${(after.credit_spent_usd - before.credit_spent_usd).toFixed(6)} on key ${keyId(apiKey)}, $${after.credit_balance_usd.toFixed(6)} available`;
139
+ };
140
+ const origin = httpsOnly(baseUrl || env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
141
+ const token = proxyToken();
142
+ const proxy = await startProxy({ upstream: `${origin}${spec.prefix}`, auth, token });
143
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'phantom-agent-'));
144
+ try {
145
+ const { args, env: childEnv } = spec.launch(proxy.url, agentArgs, env, tmp, token);
146
+ const code = await waitFor(spec.bin, args, childEnv);
147
+ await proxy.close();
148
+ io.stderr(`phantom: ${name} made ${proxy.forwarded} request${proxy.forwarded === 1 ? '' : 's'} to Phantom AI${proxy.rejected ? `, and refused ${proxy.rejected} from other programs` : ''}; ${await report()}.\n`);
149
+ return code;
150
+ }
151
+ finally {
152
+ await proxy.close().catch(() => { });
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ }
@@ -0,0 +1,140 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ /**
4
+ * The local proxy `phantom claude`, `phantom codex` and `phantom pi` point the agent at.
5
+ * It listens on 127.0.0.1 only, forwards each request to Phantom AI, and
6
+ * streams the answer back as it arrives, server-sent events included. The
7
+ * agent never holds a key: the proxy drops whatever auth the agent sends and
8
+ * adds its own.
9
+ *
10
+ * Because the proxy spends the user's key or wallet, it serves only the agent
11
+ * it started: each request must carry the run's token ({@link proxyToken}),
12
+ * must name the proxy in its Host header (so a page that rebinds a DNS name
13
+ * to 127.0.0.1 is refused), and must carry no Origin header (so no browser
14
+ * page can call it).
15
+ */
16
+ /** A fresh secret for one run, which the agent sends as its key. */
17
+ export function proxyToken() {
18
+ return randomBytes(32).toString('base64url');
19
+ }
20
+ /** True when `given` is `token`, compared in constant time. */
21
+ function sameToken(given, token) {
22
+ if (!given)
23
+ return false;
24
+ const b = Buffer.from(given);
25
+ return b.length === token.length && timingSafeEqual(b, token);
26
+ }
27
+ const HOP = new Set(['host', 'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'content-length', 'accept-encoding', 'authorization', 'x-api-key', 'te', 'trailer']);
28
+ const HOP_BACK = new Set(['connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length']);
29
+ function readBody(req) {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks = [];
32
+ req.on('data', (c) => chunks.push(c));
33
+ req.on('end', () => resolve(Buffer.concat(chunks)));
34
+ req.on('error', reject);
35
+ });
36
+ }
37
+ function sendJson(res, status, body) {
38
+ res.writeHead(status, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify(body));
40
+ }
41
+ /**
42
+ * Starts the proxy on a free port of 127.0.0.1.
43
+ * @param opts.upstream Where requests go: Phantom AI's address for this agent, such as `https://phantom.codes/claude-code`. The request's path is added to it.
44
+ * @param opts.auth A saved key, or a paying fetch.
45
+ * @param opts.token The run's secret ({@link proxyToken}). A request without it, as `Authorization: Bearer` or `x-api-key`, gets 401.
46
+ */
47
+ export async function startProxy(opts) {
48
+ const upstream = opts.upstream.replace(/\/+$/, '');
49
+ const token = Buffer.from(opts.token);
50
+ const state = { forwarded: 0, rejected: 0 };
51
+ let port = 0;
52
+ const server = createServer(async (req, res) => {
53
+ const method = req.method ?? 'GET';
54
+ const host = req.headers.host;
55
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
56
+ state.rejected++;
57
+ sendJson(res, 403, { error: { type: 'forbidden', message: 'phantom\'s proxy serves only requests addressed to 127.0.0.1.' } });
58
+ return;
59
+ }
60
+ if (req.headers.origin !== undefined) {
61
+ state.rejected++;
62
+ sendJson(res, 403, { error: { type: 'forbidden', message: 'phantom\'s proxy refuses requests from web pages.' } });
63
+ return;
64
+ }
65
+ const authorization = req.headers.authorization ?? '';
66
+ const separator = authorization.search(/\s/);
67
+ const bearer = separator >= 0 && authorization.slice(0, separator).toLowerCase() === 'bearer'
68
+ ? authorization.slice(separator).trim() : undefined;
69
+ const apiKey = req.headers['x-api-key'];
70
+ if (!sameToken(bearer, token) && !sameToken(Array.isArray(apiKey) ? apiKey[0] : apiKey, token)) {
71
+ state.rejected++;
72
+ sendJson(res, 401, { error: { type: 'authentication_error', message: 'phantom\'s proxy serves only the agent phantom started with it.' } });
73
+ return;
74
+ }
75
+ const target = new URL(req.url ?? '/', 'http://127.0.0.1');
76
+ const headers = {};
77
+ for (const [k, v] of Object.entries(req.headers)) {
78
+ if (v === undefined || HOP.has(k.toLowerCase()))
79
+ continue;
80
+ headers[k] = Array.isArray(v) ? v.join(', ') : v;
81
+ }
82
+ if (/\/v1\/messages(\/|$)/.test(target.pathname))
83
+ headers['x-api-key'] = opts.auth.apiKey;
84
+ else
85
+ headers.authorization = `Bearer ${opts.auth.apiKey}`;
86
+ const aborter = new AbortController();
87
+ res.on('close', () => {
88
+ if (!res.writableFinished)
89
+ aborter.abort();
90
+ });
91
+ try {
92
+ const body = method === 'GET' || method === 'HEAD' ? undefined : await readBody(req);
93
+ state.forwarded++;
94
+ const answer = await fetch(`${upstream}${target.pathname}${target.search}`, {
95
+ method,
96
+ headers,
97
+ ...(body && body.length ? { body: new Uint8Array(body) } : {}),
98
+ signal: aborter.signal,
99
+ });
100
+ const back = {};
101
+ answer.headers.forEach((v, k) => {
102
+ if (!HOP_BACK.has(k.toLowerCase()))
103
+ back[k] = v;
104
+ });
105
+ res.writeHead(answer.status, back);
106
+ if (answer.body) {
107
+ for await (const chunk of answer.body)
108
+ res.write(chunk);
109
+ }
110
+ res.end();
111
+ }
112
+ catch (err) {
113
+ if (aborter.signal.aborted)
114
+ return;
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ if (!res.headersSent)
117
+ sendJson(res, 502, { error: { type: 'proxy_error', message: `phantom could not get this from Phantom AI: ${message}` } });
118
+ else
119
+ res.destroy();
120
+ }
121
+ });
122
+ await new Promise((resolve, reject) => {
123
+ server.once('error', reject);
124
+ server.listen(0, '127.0.0.1', () => resolve());
125
+ });
126
+ port = server.address().port;
127
+ return {
128
+ url: `http://127.0.0.1:${port}`,
129
+ get rejected() {
130
+ return state.rejected;
131
+ },
132
+ get forwarded() {
133
+ return state.forwarded;
134
+ },
135
+ close: () => new Promise((resolve) => {
136
+ server.closeAllConnections();
137
+ server.close(() => resolve());
138
+ }),
139
+ };
140
+ }