@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
package/LICENSE
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Phantom Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harbor Financial LLC. 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
|
+
Harbor Financial LLC 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 Harbor Financial LLC 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 Harbor Financial LLC'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 Harbor Financial LLC's trade secrets. If you see them, you
|
|
50
|
+
must keep them confidential and use them only as Harbor Financial LLC 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. Harbor Financial LLC 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, HARBOR FINANCIAL LLC 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. ITS 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,232 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Your agents have expensive taste.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
`phantom-1` picks a tier for each request: economy, standard or frontier. Your key's
|
|
136
|
+
lineup picks the model in each tier, and a fallback model to try when it fails:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
phantom lineup --table
|
|
140
|
+
phantom lineup set --frontier <model> --economy-fallback <model>
|
|
141
|
+
phantom lineup reset
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`lineup` shows the choices for each tier. `default` in place of a model puts that tier
|
|
145
|
+
back to its default.
|
|
146
|
+
|
|
147
|
+
See what a key spent, and what `phantom-1` saved against always running its frontier
|
|
148
|
+
model:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
phantom spend --table
|
|
152
|
+
phantom spend --from 2026-09-01 --to 2026-09-30
|
|
153
|
+
phantom spend --workspace --table
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The dates are UTC days and the default is this month. `--workspace` lists every key in
|
|
157
|
+
your workspace by name; without it you see the key you run as. The saving is an
|
|
158
|
+
estimate: the same tokens at the frontier model's price today.
|
|
159
|
+
|
|
160
|
+
A Pro ($20 a month) or Max ($100 a month) plan pays for `phantom-1` calls up to an amount
|
|
161
|
+
of model cost in any 7 days, then your credit pays:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
phantom plan --table
|
|
165
|
+
phantom plan pro
|
|
166
|
+
phantom plan manage
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`plan` shows the key's plan and how much of the last 7 days it has paid for. `plan pro`
|
|
170
|
+
and `plan max` print a Stripe Checkout link that puts the plan on this key and open it.
|
|
171
|
+
`plan manage` opens Stripe's billing page, where the card changes, the plan switches and
|
|
172
|
+
it cancels. A named model, not `phantom-1`, still spends credit.
|
|
173
|
+
|
|
174
|
+
## Commands
|
|
175
|
+
|
|
176
|
+
| Task | Commands |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| Connect | `phantom login`, `phantom logout` |
|
|
179
|
+
| Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
|
|
180
|
+
| Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
|
|
181
|
+
| Fund and check spending | `phantom workspace fund`, `phantom workspace status`, `phantom balance`, `phantom spend` |
|
|
182
|
+
| Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
|
|
183
|
+
| Manage local copies | `phantom keys saved`, `phantom keys save`, `phantom keys rm` |
|
|
184
|
+
| Route models | `phantom route` |
|
|
185
|
+
| Pick phantom-1's models | `phantom lineup`, `phantom lineup set`, `phantom lineup reset` |
|
|
186
|
+
|
|
187
|
+
Put phantom flags before agent arguments; use `--` to separate them:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
phantom codex --key coding -- exec "explain this repository"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Commands print JSON; `--table` renders supported results for people. Agent
|
|
194
|
+
commands inherit the terminal and print a spending summary on stderr.
|
|
195
|
+
Exit codes: 0 success, 1 error, 2 rejected key; agent runs return the agent's exit code.
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary><strong>Advanced tools, integrations and legacy commands</strong></summary>
|
|
199
|
+
|
|
200
|
+
`phantom --help` shows the main workflows. `phantom --help --all` and the
|
|
201
|
+
[interface reference](docs/reference.md) cover every command.
|
|
202
|
+
|
|
203
|
+
- [Signed receipts](docs/guides/receipts.md): check which model answered with `phantom verify`.
|
|
204
|
+
- [Funding credit](docs/guides/buying-credit.md): `phantom fund` and checking payments.
|
|
205
|
+
- [Saved secrets](docs/guides/saved-secrets.md): macOS Keychain or private files; guest recovery uses `phantom workspace export` and `phantom workspace import`.
|
|
206
|
+
- [Key selection](docs/guides/which-key.md) and [API address](docs/guides/base-url.md): environment overrides and shell integration.
|
|
207
|
+
|
|
208
|
+
The older `phantom key` commands still work.
|
|
209
|
+
`phantom keys list` reads the workspace; `phantom keys saved` lists local copies.
|
|
210
|
+
`phantom keys show` prints a secret for shell integration; keep it out of chat and logs.
|
|
211
|
+
|
|
212
|
+
</details>
|
|
213
|
+
|
|
214
|
+
<details>
|
|
215
|
+
<summary><strong>Development and contributing</strong></summary>
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
npm ci
|
|
219
|
+
npm run typecheck
|
|
220
|
+
npm run build
|
|
221
|
+
npm run coverage
|
|
222
|
+
npm run docs
|
|
223
|
+
node scripts/gen-docs.mjs --check
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
|
227
|
+
|
|
228
|
+
</details>
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
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.
|
package/bin/phantom.mjs
ADDED
|
@@ -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,157 @@
|
|
|
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
|
+
/** The key's spend and balance, or null when the answer has no numbers, such as a sign-in page that came back with status 200. */
|
|
11
|
+
async function keyStatus(apiKey, baseUrl) {
|
|
12
|
+
const s = await request('GET', '/key/balance', apiKey, undefined, baseUrl);
|
|
13
|
+
return Number.isFinite(s.credit_spent_usd) && Number.isFinite(s.credit_balance_usd) ? s : null;
|
|
14
|
+
}
|
|
15
|
+
import { proxyToken, startProxy } from "./proxy.mjs";
|
|
16
|
+
const hasModel = (args) => args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model=') || /^model=/.test(a));
|
|
17
|
+
/** The agents phantom can run, by command name. */
|
|
18
|
+
export const AGENTS = {
|
|
19
|
+
claude: {
|
|
20
|
+
bin: 'claude',
|
|
21
|
+
prefix: '/claude-code',
|
|
22
|
+
launch(proxy, args, env, _tmp, token) {
|
|
23
|
+
// --settings outranks settings.json, so an env block or apiKeyHelper
|
|
24
|
+
// `phantom setup` wrote can't send Claude Code past the proxy. The helper
|
|
25
|
+
// reads the token from the environment, so it stays off the command line.
|
|
26
|
+
const vars = { ANTHROPIC_BASE_URL: proxy, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1', ENABLE_TOOL_SEARCH: 'true' };
|
|
27
|
+
const { ANTHROPIC_AUTH_TOKEN: _auth, ...rest } = env;
|
|
28
|
+
return {
|
|
29
|
+
args: ['--settings', JSON.stringify({ apiKeyHelper: 'printenv PHANTOM_AGENT_TOKEN', env: vars }), ...args],
|
|
30
|
+
env: { ...rest, ANTHROPIC_BASE_URL: proxy, ANTHROPIC_API_KEY: token, PHANTOM_AGENT_TOKEN: token },
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
codex: {
|
|
35
|
+
bin: 'codex',
|
|
36
|
+
prefix: '/codex',
|
|
37
|
+
launch(proxy, args, env, _tmp, token) {
|
|
38
|
+
const base = `${proxy}/v1`;
|
|
39
|
+
const provider = [
|
|
40
|
+
['name', 'Phantom AI (phantom)'],
|
|
41
|
+
['base_url', base],
|
|
42
|
+
['wire_api', 'responses'],
|
|
43
|
+
['env_key', 'PHANTOM_AGENT_TOKEN'],
|
|
44
|
+
['model_catalog_url', `${base}/models`],
|
|
45
|
+
];
|
|
46
|
+
// phantom_run, not phantom: phantom setup saves [model_providers.phantom],
|
|
47
|
+
// and -c merges into a table, so keys added there by hand would ride along.
|
|
48
|
+
const config = [
|
|
49
|
+
'model_provider="phantom_run"',
|
|
50
|
+
...provider.map(([k, v]) => `model_providers.phantom_run.${k}=${JSON.stringify(v)}`),
|
|
51
|
+
'features.api_key_model_discovery=true',
|
|
52
|
+
...(hasModel(args) ? [] : [`model=${JSON.stringify(DEFAULT_MODEL)}`]),
|
|
53
|
+
];
|
|
54
|
+
return { args: [...config.flatMap((c) => ['-c', c]), ...args], env: { ...env, OPENAI_BASE_URL: base, PHANTOM_AGENT_TOKEN: token } };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
pi: {
|
|
58
|
+
bin: 'pi',
|
|
59
|
+
prefix: '/coding-agent',
|
|
60
|
+
launch(proxy, args, env, tmp, token) {
|
|
61
|
+
// pi reads providers from models.json or an extension; an extension
|
|
62
|
+
// for this run leaves the user's own files alone. Its key is read from
|
|
63
|
+
// PHANTOM_AGENT_TOKEN when pi calls, so the file holds no secret.
|
|
64
|
+
const file = path.join(tmp, 'phantom-provider.ts');
|
|
65
|
+
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 };
|
|
66
|
+
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 });
|
|
67
|
+
return { args: ['-e', file, ...(hasModel(args) ? [] : ['--provider', 'phantom', '--model', DEFAULT_MODEL]), ...args], env: { ...env, PHANTOM_AGENT_TOKEN: token } };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
/** Waits for the agent and returns its exit code; a signal counts as 128 plus its number. */
|
|
72
|
+
function waitFor(bin, args, env) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const child = spawn(bin, args, { stdio: 'inherit', env: env });
|
|
75
|
+
// Ctrl-C reaches the agent itself; phantom stays to stop the proxy and print the spend.
|
|
76
|
+
const ignore = () => { };
|
|
77
|
+
process.on('SIGINT', ignore);
|
|
78
|
+
const done = () => process.off('SIGINT', ignore);
|
|
79
|
+
child.on('error', (err) => {
|
|
80
|
+
done();
|
|
81
|
+
reject(err.code === 'ENOENT' ? new Error(`${bin} is not on your PATH. Install it, then run this again.`) : err);
|
|
82
|
+
});
|
|
83
|
+
child.on('exit', (code, signal) => {
|
|
84
|
+
done();
|
|
85
|
+
resolve(code ?? (signal ? 128 + (os.constants.signals[signal] ?? 0) : 1));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `phantom claude`, `phantom codex` and `phantom pi`: runs the agent through the proxy,
|
|
91
|
+
* passing every other argument through, and returns its exit code. The Phantom CLI's own
|
|
92
|
+
* flags come first and stop at the first other argument or at `--`; they are
|
|
93
|
+
* read here, not with `flags`, because everything after them belongs to the
|
|
94
|
+
* agent. Run by `run()`.
|
|
95
|
+
*/
|
|
96
|
+
export async function agentCommand(input) {
|
|
97
|
+
const { argv, env, io, baseUrl } = input;
|
|
98
|
+
const name = argv[0];
|
|
99
|
+
const spec = AGENTS[name];
|
|
100
|
+
let keyName;
|
|
101
|
+
let noBrowser = false;
|
|
102
|
+
let i = 1;
|
|
103
|
+
const value = (flag) => {
|
|
104
|
+
const v = argv[++i];
|
|
105
|
+
if (v === undefined || v.startsWith('--'))
|
|
106
|
+
die(`${flag} needs a value`);
|
|
107
|
+
return v;
|
|
108
|
+
};
|
|
109
|
+
for (; i < argv.length; i++) {
|
|
110
|
+
const a = argv[i];
|
|
111
|
+
if (a === '--') {
|
|
112
|
+
i++;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
if (a === '--key')
|
|
116
|
+
keyName = value(a);
|
|
117
|
+
else if (a === '--mpp')
|
|
118
|
+
die('--mpp has been removed. Phantom stays prepaid only. Run phantom login or use a saved key.');
|
|
119
|
+
else if (a === '--no-browser')
|
|
120
|
+
noBrowser = true;
|
|
121
|
+
else
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
const agentArgs = argv.slice(i);
|
|
125
|
+
let apiKey = keyName ? readNamedKey(env, keyName) : resolveApiKey(env);
|
|
126
|
+
if (!apiKey) {
|
|
127
|
+
if (!io.isTTY)
|
|
128
|
+
die('Connect a key with phantom login.');
|
|
129
|
+
await workspaceLogin({ ...input, flags: { ...(noBrowser ? { 'no-browser': true } : {}) } });
|
|
130
|
+
apiKey = resolveApiKey(env);
|
|
131
|
+
}
|
|
132
|
+
if (!apiKey)
|
|
133
|
+
die('No API key. Run: phantom login');
|
|
134
|
+
const auth = { kind: 'key', apiKey };
|
|
135
|
+
const before = await keyStatus(apiKey, baseUrl).catch(() => null);
|
|
136
|
+
const report = async () => {
|
|
137
|
+
const after = await keyStatus(apiKey, baseUrl).catch(() => null);
|
|
138
|
+
if (!before || !after)
|
|
139
|
+
return `could not read spend for key ${keyId(apiKey)}`;
|
|
140
|
+
return `charged $${(after.credit_spent_usd - before.credit_spent_usd).toFixed(6)} on key ${keyId(apiKey)}, $${after.credit_balance_usd.toFixed(6)} available`;
|
|
141
|
+
};
|
|
142
|
+
const origin = httpsOnly(baseUrl || env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
143
|
+
const token = proxyToken();
|
|
144
|
+
const proxy = await startProxy({ upstream: `${origin}${spec.prefix}`, auth, token });
|
|
145
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'phantom-agent-'));
|
|
146
|
+
try {
|
|
147
|
+
const { args, env: childEnv } = spec.launch(proxy.url, agentArgs, env, tmp, token);
|
|
148
|
+
const code = await waitFor(spec.bin, args, childEnv);
|
|
149
|
+
await proxy.close();
|
|
150
|
+
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`);
|
|
151
|
+
return code;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await proxy.close().catch(() => { });
|
|
155
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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
|
+
}
|