@phantomcodes/cli 1.0.0 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  Phantom Proprietary License
2
2
 
3
- Copyright (c) 2026 Connor Tessaro. All rights reserved.
3
+ Copyright (c) 2026 Harbor Financial LLC. All rights reserved.
4
4
 
5
5
  This license covers the Phantom source code, the Phantom website, and the
6
6
  Phantom CLI command-line tool ("the CLI"), together "the Software". It
@@ -8,7 +8,7 @@ applies to every copy of the Software that carries this file.
8
8
 
9
9
  1. Ownership
10
10
 
11
- Connor Tessaro owns all right, title and interest in the Software,
11
+ Harbor Financial LLC owns all right, title and interest in the Software,
12
12
  including all copyrights and other intellectual property rights. Nothing
13
13
  in this license transfers ownership to you.
14
14
 
@@ -16,7 +16,7 @@ applies to every copy of the Software that carries this file.
16
16
 
17
17
  This license grants no rights in the Phantom source code, including the
18
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
19
+ part of it unless Harbor Financial LLC gives you written permission. The only
20
20
  permission this license grants is the one in section 3, and it covers the
21
21
  CLI alone. Visiting phantom.codes and using the Phantom service are
22
22
  governed by the terms of service at phantom.codes/terms, not by this
@@ -24,7 +24,7 @@ applies to every copy of the Software that carries this file.
24
24
 
25
25
  3. Limited permission for the Phantom CLI
26
26
 
27
- If you got the CLI from Connor Tessaro's official release (the
27
+ If you got the CLI from Harbor Financial LLC's official release (the
28
28
  @phantomcodes/cli package on npm), you may install and run unmodified
29
29
  copies of it, only to access the Phantom service. This permission is
30
30
  revocable, non-exclusive, non-transferable and non-sublicensable.
@@ -46,14 +46,14 @@ applies to every copy of the Software that carries this file.
46
46
  5. Confidentiality
47
47
 
48
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
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
51
  writing.
52
52
 
53
53
  6. Termination
54
54
 
55
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
56
+ you break any of its terms. Harbor Financial LLC may also end the section 3
57
57
  permission at any time. When it ends, you must stop using the Software and
58
58
  destroy all copies you have.
59
59
 
@@ -65,10 +65,10 @@ applies to every copy of the Software that carries this file.
65
65
 
66
66
  8. Limitation of liability
67
67
 
68
- TO THE FULLEST EXTENT THE LAW ALLOWS, CONNOR TESSARO IS NOT LIABLE FOR ANY
68
+ TO THE FULLEST EXTENT THE LAW ALLOWS, HARBOR FINANCIAL LLC IS NOT LIABLE FOR ANY
69
69
  INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR
70
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
71
+ LICENSE. ITS TOTAL LIABILITY UNDER THIS LICENSE IS LIMITED TO THE AMOUNT
72
72
  YOU PAID FOR THE SOFTWARE, IF ANY.
73
73
 
74
74
  9. Governing law
package/README.md CHANGED
@@ -132,6 +132,45 @@ Prefer a specific model? Select it in your agent. Policies that apply only to
132
132
  `auto` leave explicit model choices alone.
133
133
  [Routing](docs/guides/routing.md) covers conditions, fallback and conversation affinity.
134
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
+
135
174
  ## Commands
136
175
 
137
176
  | Task | Commands |
@@ -139,10 +178,11 @@ Prefer a specific model? Select it in your agent. Policies that apply only to
139
178
  | Connect | `phantom login`, `phantom logout` |
140
179
  | Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
141
180
  | Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
142
- | Fund and check spending | `phantom workspace fund`, `phantom workspace status`, `phantom balance` |
181
+ | Fund and check spending | `phantom workspace fund`, `phantom workspace status`, `phantom balance`, `phantom spend` |
143
182
  | Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
144
183
  | Manage local copies | `phantom keys saved`, `phantom keys save`, `phantom keys rm` |
145
184
  | Route models | `phantom route` |
185
+ | Pick phantom-1's models | `phantom lineup`, `phantom lineup set`, `phantom lineup reset` |
146
186
 
147
187
  Put phantom flags before agent arguments; use `--` to separate them:
148
188
 
@@ -7,8 +7,10 @@ import { DEFAULT_BASE_URL, httpsOnly, request } from "../core/request.mjs";
7
7
  import { keyId, readNamedKey, resolveApiKey } from "../keys/saved.mjs";
8
8
  import { workspaceLogin } from "../workspace/command.mjs";
9
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. */
10
11
  async function keyStatus(apiKey, baseUrl) {
11
- return request('GET', '/key/balance', apiKey, undefined, 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;
12
14
  }
13
15
  import { proxyToken, startProxy } from "./proxy.mjs";
14
16
  const hasModel = (args) => args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model=') || /^model=/.test(a));
@@ -1,7 +1,9 @@
1
1
  import { workspaceCommand, workspaceLogin, forgetWorkspace } from "../workspace/command.mjs";
2
2
  import { agentCommand } from "../agents/command.mjs";
3
3
  import { balanceCommand, keyCommand, loginCommand, logoutCommand } from "../keys/command.mjs";
4
- import { routeCommand } from "../spend/command.mjs";
4
+ import { lineupCommand, routeCommand } from "../spend/command.mjs";
5
+ import { spendCommand } from "../spend/report.mjs";
6
+ import { planCommand } from "../plan/command.mjs";
5
7
  import { verifyCommand } from "../receipts/command.mjs";
6
8
  import { setupCommand } from "../setup/command.mjs";
7
9
  import { SETUP_MODEL } from "../setup/shared.mjs";
@@ -43,7 +45,7 @@ export const COMMANDS = [
43
45
  {
44
46
  name: 'fund', handler: fundDispatch, keyless: true,
45
47
  help: ` fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
46
- fund your workspace balance with card or crypto`,
48
+ fund your workspace balance with card (plus Stripe's fee) or crypto`,
47
49
  },
48
50
  {
49
51
  name: 'keys', handler: workspaceCommand, keyless: true,
@@ -76,6 +78,19 @@ export const COMMANDS = [
76
78
  name: 'balance', handler: balanceCommand,
77
79
  help: ` balance show credit balance and expiry`,
78
80
  },
81
+ {
82
+ name: 'spend', handler: spendCommand, keyless: ['--workspace'],
83
+ help: ` spend [--from <date>] [--to <date>] what this key spent, by model and day, and what phantom-1
84
+ saved; dates are UTC days as YYYY-MM-DD, default this month
85
+ spend --workspace every key in the workspace by name (needs phantom login)`,
86
+ },
87
+ {
88
+ name: 'plan', handler: planCommand,
89
+ help: ` plan [status] this key's Pro or Max plan and how much of the week is used
90
+ plan pro | max [--no-browser] print a Stripe Checkout link that puts the plan on this key
91
+ and open it
92
+ plan manage [--no-browser] the same for Stripe's billing page: card, switch plan, cancel`,
93
+ },
79
94
  {
80
95
  name: 'key', advanced: true, handler: keyCommand, keyless: true,
81
96
  help: ` key list [--balance] saved keys, by name and id
@@ -99,6 +114,16 @@ export const COMMANDS = [
99
114
  route rule rm <n> remove rule n
100
115
  route test [--model auto] which model a request would get now (free)
101
116
  route clear remove the policy`,
117
+ },
118
+ {
119
+ name: 'lineup', handler: lineupCommand,
120
+ help: ` lineup [get] show the model phantom-1 runs in each tier, and the choices
121
+ lineup set --economy <model> pick the model for a tier; "default" goes back to the default
122
+ [--standard <model>] [--frontier <model>]
123
+ [--economy-fallback <model>] [--standard-fallback <model>]
124
+ [--frontier-fallback <model>]
125
+ the model to try when a tier's model fails
126
+ lineup reset every tier back to its default`,
102
127
  },
103
128
  {
104
129
  name: 'logout', handler: logoutDispatch, keyless: true,
@@ -112,9 +137,9 @@ export const COMMANDS = [
112
137
  [--yes] don't ask
113
138
  [--dry-run] show the changes, write nothing
114
139
  [--model <id>] the model each agent starts on (default ${SETUP_MODEL})
115
- setup --agent <name> [--mcp] install the skill for one agent, without asking:
140
+ setup --agent <name> install the skill for one agent, without asking:
116
141
  ${orList(SETUP_NAMES)}
117
- or all; --mcp also adds the MCP server
142
+ or all
118
143
  setup [--agent <name>] --provider point that agent's model calls at Phantom AI;
119
144
  --provider off removes only what phantom wrote
120
145
  setup --restore [--agent <name>] put back the files setup changed, from the
@@ -1,6 +1,8 @@
1
1
  import { PhantomApiError, die } from "./errors.mjs";
2
2
  /** The Phantom AI API address phantom calls when PHANTOM_BASE_URL is unset. */
3
3
  export const DEFAULT_BASE_URL = 'https://phantom.codes/v1';
4
+ /** How long {@link request} waits for the whole answer, in milliseconds, before it gives up. */
5
+ export const REQUEST_TIMEOUT_MS = 30_000;
4
6
  /**
5
7
  * Calls the Phantom AI API with the key as a Bearer token and returns the
6
8
  * JSON answer. Every client function in phantom goes through it.
@@ -10,20 +12,38 @@ export const DEFAULT_BASE_URL = 'https://phantom.codes/v1';
10
12
  * @param body Sent as JSON when given.
11
13
  * @param baseUrl The API base. Defaults to PHANTOM_BASE_URL, then {@link DEFAULT_BASE_URL}. Must be https ({@link httpsOnly}).
12
14
  * @param headers Extra headers to send.
15
+ * @param timeoutMs How long to wait for the answer. Defaults to {@link REQUEST_TIMEOUT_MS}.
13
16
  * @throws {@link PhantomApiError} when the API answers with an error status.
17
+ * @throws {@link CliError} with code `request_timeout` when no answer arrives in time.
14
18
  */
15
- export async function request(method, path, apiKey, body, baseUrl, headers = {}) {
19
+ export async function request(method, path, apiKey, body, baseUrl, headers = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
16
20
  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(() => ({}));
21
+ const signal = AbortSignal.timeout(timeoutMs);
22
+ let res;
23
+ let json;
24
+ try {
25
+ res = await fetch(url, {
26
+ method,
27
+ headers: {
28
+ Authorization: `Bearer ${apiKey}`,
29
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
30
+ ...headers,
31
+ },
32
+ body: body !== undefined ? JSON.stringify(body) : undefined,
33
+ signal,
34
+ });
35
+ // The deadline covers the body too: a server can send headers and then stall.
36
+ json = await res.json().catch((err) => {
37
+ if (signal.aborted)
38
+ throw err;
39
+ return {};
40
+ });
41
+ }
42
+ catch (err) {
43
+ if (signal.aborted)
44
+ die(`Phantom AI did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)} seconds. Check your connection and try again.`, 'request_timeout');
45
+ throw err;
46
+ }
27
47
  if (!res.ok)
28
48
  throw apiError(res.status, json);
29
49
  return json;
@@ -51,6 +71,7 @@ export function httpsOnly(baseUrl) {
51
71
  return baseUrl;
52
72
  }
53
73
  export function apiError(status, json) {
74
+ json = json && typeof json === 'object' ? json : {};
54
75
  const err = json.error;
55
76
  let code = String(status);
56
77
  let msg = `HTTP ${status}`;
@@ -60,7 +81,9 @@ export function apiError(status, json) {
60
81
  msg = typeof e.message === 'string' ? e.message : msg;
61
82
  }
62
83
  else if (typeof err === 'string') {
63
- msg = err;
84
+ // The plan routes answer { error: <code>, detail: <words> }.
85
+ const detail = json.detail;
86
+ msg = typeof detail === 'string' && detail ? detail : err;
64
87
  code = err;
65
88
  }
66
89
  else if (typeof json.detail === 'string') {
@@ -72,7 +95,14 @@ export function apiError(status, json) {
72
95
  }
73
96
  return new PhantomApiError(status, code, msg);
74
97
  }
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);
98
+ /**
99
+ * Credit left, credit spent and expiry of a key (GET /key/balance).
100
+ * @throws {@link CliError} with code `balance_unreadable` when the answer has no numbers, such as a sign-in page that came back with status 200.
101
+ */
102
+ export async function getBalance(apiKey, baseUrl) {
103
+ const balance = await request('GET', '/key/balance', apiKey, undefined, baseUrl);
104
+ if (!balance || !Number.isFinite(balance.credit_balance_usd) || !Number.isFinite(balance.credit_spent_usd)) {
105
+ die('The balance answer had no credit numbers. Check PHANTOM_BASE_URL, and that the address answers with the Phantom AI API.', 'balance_unreadable');
106
+ }
107
+ return balance;
78
108
  }
@@ -1,2 +1,2 @@
1
1
  /** This release of phantom. `npm version` keeps it equal to the version in package.json. */
2
- export const VERSION = '1.0.0';
2
+ export const VERSION = '1.1.1';
package/dist/phantom.mjs CHANGED
@@ -14,10 +14,13 @@
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { realpathSync } from 'node:fs';
16
16
  import { run } from "./cli/run.mjs";
17
- export { DEFAULT_BASE_URL, request, httpsOnly, getBalance } from "./core/request.mjs";
17
+ export { DEFAULT_BASE_URL, REQUEST_TIMEOUT_MS, request, httpsOnly, getBalance } from "./core/request.mjs";
18
18
  export { VERSION } from "./core/version.mjs";
19
19
  export { PhantomApiError, CliError, die } from "./core/errors.mjs";
20
+ export { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./spend/lineup.mjs";
20
21
  export { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./spend/routing.mjs";
22
+ export { getSpend, tableSpend } from "./spend/report.mjs";
23
+ export { getPlan, planCheckout, planPortal, tablePlan } from "./plan/command.mjs";
21
24
  export { checkReceipt, modelsMatch, verifyModel } from "./receipts/receipts.mjs";
22
25
  export { keychain } from "./core/secrets.mjs";
23
26
  export { keyId, keySource, resolveApiKey, readNamedKey, saveNamedKey, removeNamedKey, listNamedKeys, saveApiKey, removeApiKey } from "./keys/saved.mjs";
@@ -0,0 +1,73 @@
1
+ import { die } from "../core/errors.mjs";
2
+ import { openUrl, out } from "../core/helpers.mjs";
3
+ import { request } from "../core/request.mjs";
4
+ import { savedManagement } from "../workspace/command.mjs";
5
+ /** The plan of the key's workspace and its week (GET /plan). */
6
+ export function getPlan(apiKey, baseUrl) {
7
+ return request('GET', '/plan', apiKey, undefined, baseUrl);
8
+ }
9
+ /** A Stripe Checkout link that puts `plan` on the key's workspace (POST /plan/checkout). */
10
+ export function planCheckout(apiKey, plan, baseUrl) {
11
+ return request('POST', '/plan/checkout', apiKey, { plan }, baseUrl);
12
+ }
13
+ /**
14
+ * A link to Stripe's billing page for a workspace's plan (POST /plan/portal).
15
+ * It takes the workspace's management credential: an inference key gets 403.
16
+ */
17
+ export function planPortal(credential, baseUrl) {
18
+ return request('POST', '/plan/portal', credential, undefined, baseUrl);
19
+ }
20
+ const dollars = (n) => `$${n.toFixed(2)}`;
21
+ /** `--table` output for `plan`. */
22
+ export function tablePlan(d) {
23
+ const p = d;
24
+ if (!p.plan) {
25
+ return p.available
26
+ ? 'plan none\nsubscribe phantom plan pro or phantom plan max'
27
+ : 'plan none (plans are not available right now)';
28
+ }
29
+ const lines = [
30
+ `plan ${p.plan === 'max' ? 'Max' : 'Pro'}, on this ${p.scope}`,
31
+ `status ${p.status}`,
32
+ `used ${dollars(p.week_used_usd)} of ${dollars(p.week_limit_usd)} in the last 7 days (model cost, before markup)`,
33
+ ];
34
+ if (p.resets_at)
35
+ lines.push(`room starts to free up ${p.resets_at}`);
36
+ if (p.renews_at)
37
+ lines.push(`period ends ${p.renews_at}`);
38
+ return lines.join('\n');
39
+ }
40
+ /** What `phantom plan` accepts after `plan`. */
41
+ const USAGE = 'Use phantom plan, phantom plan pro, phantom plan max or phantom plan manage.';
42
+ /**
43
+ * `phantom plan`: the plan of the key's workspace and how much of the week is
44
+ * used. `phantom plan pro|max` prints a Stripe Checkout link that puts the
45
+ * plan on that workspace, and opens it unless `--no-browser`. `phantom plan
46
+ * manage` does the same for Stripe's billing page, where the card changes, the
47
+ * plan switches and it cancels; it sends the workspace credential `phantom
48
+ * login` saved, since a key can't manage billing. Run by `run()`.
49
+ */
50
+ export async function planCommand(input) {
51
+ const { io, apiKey, table, baseUrl, flags, subcommand } = input;
52
+ const open = (url) => {
53
+ if (!flags['no-browser'])
54
+ openUrl(url);
55
+ };
56
+ if (subcommand === undefined || subcommand === 'status') {
57
+ out(await getPlan(apiKey, baseUrl), table, tablePlan, io.stdout);
58
+ }
59
+ else if (subcommand === 'pro' || subcommand === 'max') {
60
+ const result = await planCheckout(apiKey, subcommand, baseUrl);
61
+ out(result, table, (d) => `Open this link to pay: ${d.checkout_url}`, io.stdout);
62
+ open(result.checkout_url);
63
+ }
64
+ else if (subcommand === 'manage') {
65
+ const result = await planPortal(savedManagement(input) ?? apiKey, baseUrl);
66
+ out(result, table, (d) => `Open this link to manage billing: ${d.portal_url}`, io.stdout);
67
+ open(result.portal_url);
68
+ }
69
+ else {
70
+ die(`Unknown plan command "${subcommand}". ${USAGE}`, 'plan_usage');
71
+ }
72
+ return 0;
73
+ }
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import { die } from "../core/errors.mjs";
6
6
  import { resolveApiKey } from "../keys/saved.mjs";
7
- import { SETUP_MODEL, agentBaseUrl, MCP_ARGS, mcpByCli, readText, configHome, onPath, phantomCommand, offResult, unreadable, SETUP_MARK, SETUP_CONTEXT_WINDOW, } from "./shared.mjs";
7
+ import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand, offResult, unreadable, SETUP_MARK, SETUP_CONTEXT_WINDOW, } from "./shared.mjs";
8
8
  import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
9
9
  // ── Manual Provider Helper ───────────────────────────────────────────────────
10
10
  /** An agent whose model settings live on its own settings screen: setup prints the steps. */
@@ -318,7 +318,7 @@ export function clineProvider(ctx, off) {
318
318
  * @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
319
319
  */
320
320
  export const SETUP_AGENTS = {
321
- /** pi. No MCP; it uses the CLI through the skill. */
321
+ /** pi. It uses the CLI through the skill. */
322
322
  pi: {
323
323
  label: 'pi',
324
324
  /** ~/.pi */
@@ -334,7 +334,6 @@ export const SETUP_AGENTS = {
334
334
  detect: (env, home) => existsSync(codexDir(env, home)),
335
335
  /** ~/.agents/skills */
336
336
  skills: '.agents/skills',
337
- mcp: mcpByCli('codex'),
338
337
  provider: codexProvider,
339
338
  shellEnv: () => ({}),
340
339
  },
@@ -346,7 +345,6 @@ export const SETUP_AGENTS = {
346
345
  detect: (env, home) => existsSync(claudeDir(env, home)),
347
346
  /** ~/.claude/skills */
348
347
  skills: '.claude/skills',
349
- mcp: mcpByCli('claude'),
350
348
  provider: (ctx, off) => claudePlan(ctx.env, { off, model: ctx.model, onPath: ctx.onPath }),
351
349
  },
352
350
  /** Cursor. Its models are set on its settings screen; the shell block gives it PHANTOM_API_KEY to copy. */
@@ -356,15 +354,6 @@ export const SETUP_AGENTS = {
356
354
  home: '.cursor',
357
355
  /** ~/.cursor/skills */
358
356
  skills: '.cursor/skills',
359
- /** Merges the server into ~/.cursor/mcp.json, keeping the others. */
360
- mcp: (ctx, add) => {
361
- const server = { command: 'npx', args: MCP_ARGS };
362
- const mcp = `add to ~/.cursor/mcp.json: ${JSON.stringify({ mcpServers: { phantom: server } })}`;
363
- if (!add)
364
- return { mcp };
365
- const file = path.join(ctx.home, '.cursor', 'mcp.json');
366
- return { mcp, edits: [{ file, after: jsonEdit(file, readText(file), [[['mcpServers', 'phantom'], server]]) }] };
367
- },
368
357
  provider: manualProvider('Cursor Settings, Models', (ctx) => [
369
358
  'Open Cursor Settings (Cmd+Shift+J on macOS), then Models.',
370
359
  `Under OpenAI API Key, paste your Phantom AI key, then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
@@ -1,7 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { execFileSync } from 'node:child_process';
5
4
  import { stateDir } from "../core/state.mjs";
6
5
  import { readText } from "./shared.mjs";
7
6
  import { jsonText } from "./editors.mjs";
@@ -64,8 +63,6 @@ export function setupPreview(plan) {
64
63
  else
65
64
  parts.push(`${f.action} ${f.file}\n${maskKeys(lineDiff(f.before, f.after))}`);
66
65
  }
67
- for (const r of plan.runs)
68
- parts.push(`run ${r.cmd} ${r.args.join(' ')}`);
69
66
  for (const s of plan.skipped)
70
67
  parts.push(`skip ${s.agent}: ${s.reason}`);
71
68
  for (const a of plan.agents) {
@@ -97,11 +94,9 @@ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
97
94
  * Writes a plan from {@link planSetup}. Before it edits a file it copies it
98
95
  * to `<file>.phantom.bak`, unless the file is still as phantom last wrote it: then the
99
96
  * copy from before phantom's first change stays. A file that changed after the
100
- * plan read it is left alone and listed in `skipped`. Then it runs any MCP
101
- * commands.
102
- * @param exec Runs an agent's CLI, such as `claude mcp add`.
97
+ * plan read it is left alone and listed in `skipped`.
103
98
  */
104
- export function applySetup(env, plan, exec = (cmd, args) => execFileSync(cmd, args, { stdio: 'ignore' })) {
99
+ export function applySetup(env, plan) {
105
100
  const backups = readBackups(env);
106
101
  const skipped = [...plan.skipped];
107
102
  const files = [];
@@ -138,10 +133,7 @@ export function applySetup(env, plan, exec = (cmd, args) => execFileSync(cmd, ar
138
133
  }
139
134
  if (plan.files.some((f) => !f.skill))
140
135
  writeBackups(env, backups);
141
- for (const r of plan.runs)
142
- exec(r.cmd, r.args);
143
- const agents = plan.agents.map((a) => ({ ...a, mcp_added: plan.mcp.includes(a.agent) }));
144
- return { agents, files, ...(skipped.length ? { skipped } : {}), ...(plan.notes.length ? { notes: plan.notes } : {}) };
136
+ return { agents: plan.agents, files, ...(skipped.length ? { skipped } : {}), ...(plan.notes.length ? { notes: plan.notes } : {}) };
145
137
  }
146
138
  /**
147
139
  * Points Claude Code at Phantom AI, or removes phantom's settings with `off`.
@@ -155,7 +147,7 @@ export function claudeProvider(env, opts = {}) {
155
147
  const before = readText(edit.file);
156
148
  return { ...edit, before, agent: 'claude', action: before === null ? 'create' : edit.after === null ? 'remove' : 'update' };
157
149
  });
158
- applySetup(env, { agents: [], files, runs: [], mcp: [], skipped: [], notes: [] });
150
+ applySetup(env, { agents: [], files, skipped: [], notes: [] });
159
151
  return provider.result;
160
152
  }
161
153
  /** A plan as `--dry-run` reports it: each file that would change, with its diff, and nothing written. */
@@ -170,12 +162,12 @@ export function previewSetup(plan) {
170
162
  }
171
163
  /**
172
164
  * Installs the phantom-ai skill for one agent, or for every agent found
173
- * (see {@link SETUP_AGENTS}), and with `mcp` and `provider` sets those up
165
+ * (see {@link SETUP_AGENTS}), and with `provider` sets that up
174
166
  * too, without asking: {@link planSetup}, then {@link applySetup}.
175
167
  * @throws {@link CliError} when no agent is found, the agent is unknown, or `provider` is set without an agent that supports it.
176
168
  */
177
169
  export function setupAgents(env, opts = {}) {
178
- return applySetup(env, planSetup(env, opts), opts.exec);
170
+ return applySetup(env, planSetup(env, opts));
179
171
  }
180
172
  /**
181
173
  * Puts back the files `phantom setup` changed, from the copies it kept
@@ -13,7 +13,6 @@ export function tableSetup(d) {
13
13
  const p = a.provider;
14
14
  return [
15
15
  `${a.agent.padEnd(16)}${a.skill ? `skill ${r.dry_run || r.applied === false ? 'goes' : 'installed'} at ${a.skill}` : 'no skill (this agent does not read skills)'}`,
16
- ...(a.mcp_added ? [`${pad}MCP server added`] : a.mcp ? [`${pad}MCP (optional): ${a.mcp}`] : []),
17
16
  ...(!p
18
17
  ? []
19
18
  : p.manual
@@ -74,10 +73,9 @@ export async function setupCommand({ env, io, flags, table }) {
74
73
  }
75
74
  // Plain `phantom setup` sets up everything it finds and asks first. Any of
76
75
  // the older flags runs just what they name, without asking.
77
- const guided = agent === undefined && provider === undefined && !flags['mcp'];
76
+ const guided = agent === undefined && provider === undefined;
78
77
  const plan = planSetup(env, {
79
78
  agent,
80
- mcp: Boolean(flags['mcp']),
81
79
  provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
82
80
  model: typeof model === 'string' ? model : undefined,
83
81
  guided,
@@ -18,7 +18,7 @@ function skillSource() {
18
18
  const isSkip = (err) => err instanceof CliError && err.code === 'setup_unreadable';
19
19
  /**
20
20
  * Works out what setup would change, and writes nothing: the skill for each
21
- * agent ({@link SETUP_AGENTS}), the MCP server with `mcp`, each agent's model
21
+ * agent ({@link SETUP_AGENTS}), each agent's model
22
22
  * settings with `provider` ({@link SetupAgentSpec.provider}), and the shell
23
23
  * block ({@link shellBlock}) for agents that read the key from the
24
24
  * environment. A config file setup can't edit safely is left out and listed
@@ -61,8 +61,6 @@ export function planSetup(env, opts = {}) {
61
61
  }
62
62
  const ctx = { env, home, onPath: opts.onPath, model: opts.model, shellRc: shellRcFile(env, home) };
63
63
  const files = [];
64
- const runs = [];
65
- const mcpAdds = [];
66
64
  const skipped = [];
67
65
  const agents = [];
68
66
  const add = (agent, file, after, skill = false, mode) => {
@@ -87,25 +85,6 @@ export function planSetup(env, opts = {}) {
87
85
  const skill = spec.skills ? path.join(home, spec.skills, 'phantom-ai', 'SKILL.md') : null;
88
86
  if (skill)
89
87
  add(agent, skill, skillText, true);
90
- let mcp = null;
91
- if (spec.mcp) {
92
- try {
93
- const m = spec.mcp(ctx, Boolean(opts.mcp));
94
- mcp = m.mcp;
95
- if (opts.mcp) {
96
- if (m.run)
97
- runs.push({ agent, cmd: m.run[0], args: m.run[1] });
98
- for (const e of m.edits ?? [])
99
- add(agent, e.file, e.after);
100
- mcpAdds.push(agent);
101
- }
102
- }
103
- catch (err) {
104
- if (!isSkip(err))
105
- throw err;
106
- skipped.push({ agent, reason: err.message });
107
- }
108
- }
109
88
  let result;
110
89
  if (provider && spec.provider) {
111
90
  try {
@@ -126,7 +105,7 @@ export function planSetup(env, opts = {}) {
126
105
  skipped.push({ agent, reason: err.message });
127
106
  }
128
107
  }
129
- agents.push({ agent, skill, mcp, mcp_added: false, ...(result ? { provider: result } : {}) });
108
+ agents.push({ agent, skill, ...(result ? { provider: result } : {}) });
130
109
  }
131
110
  if (shellOn.size || shellOff.length) {
132
111
  try {
@@ -138,5 +117,5 @@ export function planSetup(env, opts = {}) {
138
117
  skipped.push({ agent: 'shell', reason: err.message });
139
118
  }
140
119
  }
141
- return { agents, files, runs, mcp: mcpAdds, skipped, notes };
120
+ return { agents, files, skipped, notes };
142
121
  }
@@ -19,14 +19,6 @@ export function agentBaseUrl(env, agent) {
19
19
  const origin = httpsOnly(env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
20
20
  return agent === 'claude-code' ? `${origin}/claude-code` : `${origin}/${agent}/v1`;
21
21
  }
22
- export const MCP_ARGS = ['-y', '@phantomcodes/cli', 'mcp'];
23
- /** MCP for an agent whose CLI adds servers: `<agent> mcp add phantom -- npx ...`. */
24
- export function mcpByCli(agent) {
25
- return () => ({
26
- mcp: `${agent} mcp add phantom -- npx ${MCP_ARGS.join(' ')}`,
27
- run: [agent, ['mcp', 'add', 'phantom', '--', 'npx', ...MCP_ARGS]],
28
- });
29
- }
30
22
  /** The end of every line phantom adds to a TOML, YAML or shell file. */
31
23
  export const SETUP_MARK = '# phantom setup';
32
24
  /** The start of a line phantom turned into a comment because it replaced it. `--provider off` puts it back. */
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { die } from "../core/errors.mjs";
3
3
  import { flagNum, out } from "../core/helpers.mjs";
4
+ import { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./lineup.mjs";
4
5
  import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
5
6
  /** `--table` output for `route`. */
6
7
  export function tableRoute(d) {
@@ -103,3 +104,69 @@ export async function routeCommand({ argv, io, apiKey, subcommand, flags, table,
103
104
  }
104
105
  return 0;
105
106
  }
107
+ /** `--table` output for `lineup`. */
108
+ export function tableLineup(d) {
109
+ const { lineup, tiers, choices } = d;
110
+ return LINEUP_TIERS.flatMap((tier) => {
111
+ const pick = lineup?.[tier];
112
+ const fallback = lineup?.fallbacks?.[tier];
113
+ return [
114
+ `${tier.padEnd(15)} ${tiers[tier].model} ${pick ? '(your pick)' : '(default)'}`,
115
+ ...(fallback ? [`${''.padEnd(15)} falls back to ${fallback}`] : []),
116
+ ...(choices ? choices[tier].map((c) => `${''.padEnd(15)} - ${c.model}${c.default ? ' (default)' : ''}`) : []),
117
+ ];
118
+ }).join('\n');
119
+ }
120
+ /**
121
+ * Changes one tier's entry in `into` from a flag value: a model id sets it,
122
+ * and `default` clears it so the tier runs its default again.
123
+ */
124
+ function applyPick(into, tier, flag, value) {
125
+ if (value === undefined)
126
+ return;
127
+ if (value === true || !value)
128
+ die(`--${flag} needs a model id, or "default" to clear it`);
129
+ if (value === 'default')
130
+ delete into[tier];
131
+ else
132
+ into[tier] = value;
133
+ }
134
+ /** `phantom lineup`: the model phantom-1 runs in each tier, and a fallback for each. Run by `run()`. */
135
+ export async function lineupCommand({ io, apiKey, subcommand, flags, table, baseUrl }) {
136
+ if (!subcommand || subcommand === 'get') {
137
+ out(await getLineup(apiKey, baseUrl), table, tableLineup, io.stdout);
138
+ }
139
+ else if (subcommand === 'set') {
140
+ // PUT replaces the whole lineup, so start from what the key has now.
141
+ const read = await getLineup(apiKey, baseUrl);
142
+ const current = read.lineup ?? {};
143
+ const { fallbacks: currentFallbacks, ...picks } = current;
144
+ const fallbacks = { ...currentFallbacks };
145
+ const model = { economy: flags['economy'], standard: flags['standard'], frontier: flags['frontier'] };
146
+ const fallback = {
147
+ economy: flags['economy-fallback'],
148
+ standard: flags['standard-fallback'],
149
+ frontier: flags['frontier-fallback'],
150
+ };
151
+ for (const tier of LINEUP_TIERS) {
152
+ applyPick(picks, tier, tier, model[tier]);
153
+ applyPick(fallbacks, tier, `${tier}-fallback`, fallback[tier]);
154
+ // A fallback the key already had can't be its tier's own model, which the server refuses. Drop it when this change makes it so.
155
+ const runs = picks[tier] ?? read.choices?.[tier].find((c) => c.default)?.model;
156
+ if (fallback[tier] === undefined && fallbacks[tier] === runs)
157
+ delete fallbacks[tier];
158
+ }
159
+ if (LINEUP_TIERS.every((tier) => model[tier] === undefined && fallback[tier] === undefined)) {
160
+ die('lineup set needs --economy, --standard or --frontier, or --economy-fallback, --standard-fallback or --frontier-fallback, each with a model id or "default"');
161
+ }
162
+ const next = Object.keys(fallbacks).length ? { ...picks, fallbacks } : picks;
163
+ out(await putLineup(apiKey, next, baseUrl), table, tableLineup, io.stdout);
164
+ }
165
+ else if (subcommand === 'reset') {
166
+ out(await resetLineup(apiKey, baseUrl), table, tableLineup, io.stdout);
167
+ }
168
+ else {
169
+ die(`Unknown lineup subcommand: ${subcommand}`);
170
+ }
171
+ return 0;
172
+ }
@@ -0,0 +1,20 @@
1
+ import { request } from "../core/request.mjs";
2
+ /**
3
+ * A key's lineup decides which model `phantom-1` runs in each tier. The server
4
+ * checks every pick against the tier's choices (lib/lineup.ts in the Phantom
5
+ * repo), so the CLI passes model ids through.
6
+ */
7
+ /** The three tiers `phantom-1` picks between, cheapest first. */
8
+ export const LINEUP_TIERS = ['economy', 'standard', 'frontier'];
9
+ /** The key's lineup, the model each tier runs, and each tier's choices (GET /key/lineup). */
10
+ export function getLineup(apiKey, baseUrl) {
11
+ return request('GET', '/key/lineup', apiKey, undefined, baseUrl);
12
+ }
13
+ /** Replaces the key's whole lineup (PUT /key/lineup). The server checks it before saving. */
14
+ export function putLineup(apiKey, lineup, baseUrl) {
15
+ return request('PUT', '/key/lineup', apiKey, lineup, baseUrl);
16
+ }
17
+ /** Puts every tier back to its default (DELETE /key/lineup). */
18
+ export function resetLineup(apiKey, baseUrl) {
19
+ return request('DELETE', '/key/lineup', apiKey, undefined, baseUrl);
20
+ }
@@ -0,0 +1,61 @@
1
+ import { die } from "../core/errors.mjs";
2
+ import { out } from "../core/helpers.mjs";
3
+ import { request } from "../core/request.mjs";
4
+ import { workspaceGet } from "../workspace/command.mjs";
5
+ /** The dates `--from` and `--to` ask for, as a query string. The server checks them. */
6
+ function query(dates) {
7
+ const params = new URLSearchParams();
8
+ for (const [name, value] of Object.entries(dates)) {
9
+ if (value === true)
10
+ die(`--${name} requires a date as YYYY-MM-DD`);
11
+ if (value !== undefined)
12
+ params.set(name, value);
13
+ }
14
+ const text = params.toString();
15
+ return text ? `?${text}` : '';
16
+ }
17
+ /** What the key spent (GET /key/spend). */
18
+ export function getSpend(apiKey, dates = {}, baseUrl) {
19
+ return request('GET', `/key/spend${query(dates)}`, apiKey, undefined, baseUrl);
20
+ }
21
+ const dollars = (n) => `$${n.toFixed(4)}`;
22
+ const calls = (n) => `${n} ${n === 1 ? 'call' : 'calls'}`;
23
+ /** `--table` output for `spend`. */
24
+ export function tableSpend(d) {
25
+ const s = d;
26
+ const lines = [
27
+ `range ${s.from} to ${s.to} (UTC)`,
28
+ `spent ${dollars(s.total.spent_usd)} (${calls(s.total.calls)})`,
29
+ ];
30
+ if (s.total.plan_usd > 0)
31
+ lines.push(`paid by plan ${dollars(s.total.plan_usd)}`);
32
+ if (!s.routing.available)
33
+ lines.push(`phantom-1 saved not available: ${s.routing.reason}`);
34
+ else if (s.routing.calls === 0)
35
+ lines.push('phantom-1 saved no calls went to phantom-1');
36
+ else
37
+ lines.push(`phantom-1 saved ${dollars(s.routing.saved_usd)} (${calls(s.routing.calls)} cost ${dollars(s.routing.spent_usd)}; on ${s.routing.baseline_model} they would cost ${dollars(s.routing.baseline_usd)})`);
38
+ lines.push('', 'by key');
39
+ for (const k of s.keys) {
40
+ const saved = k.saved_usd === null ? '' : ` saved ${dollars(k.saved_usd)}`;
41
+ lines.push(` ${k.name.padEnd(16)}${dollars(k.spent_usd)} ${calls(k.calls)}${saved}${k.active ? '' : ' (revoked or rotated)'}`);
42
+ }
43
+ lines.push('', 'by model');
44
+ for (const m of s.models)
45
+ lines.push(` ${m.model.padEnd(40)}${dollars(m.spent_usd)} ${calls(m.calls)}`);
46
+ return lines.join('\n');
47
+ }
48
+ /**
49
+ * `phantom spend`: what the key spent, by model and day, and what phantom-1
50
+ * saved. `--workspace` shows every key in the workspace by name, with the
51
+ * workspace connection `phantom login` saved. Run by `run()`.
52
+ */
53
+ export async function spendCommand(input) {
54
+ const { io, apiKey, table, baseUrl, flags } = input;
55
+ const dates = { from: flags['from'], to: flags['to'] };
56
+ const result = flags['workspace']
57
+ ? (await workspaceGet(input, `/spend${query(dates)}`))
58
+ : await getSpend(apiKey, dates, baseUrl);
59
+ out(result, table, tableSpend, io.stdout);
60
+ return 0;
61
+ }
@@ -16,7 +16,7 @@ export function patchRoute(apiKey, fields, baseUrl) {
16
16
  export function clearRoute(apiKey, baseUrl) {
17
17
  return request('DELETE', '/key/route', apiKey, undefined, baseUrl);
18
18
  }
19
- /** Which model a request would run on now, and why (POST /key/route/test). Nothing is charged and no model is called. */
19
+ /** Which model a request would run on now, and why (POST /key/route/test). Nothing is charged. A route policy picks without calling a model; `phantom-1`, `phantom-1:zdr`, or `auto` on a key with no policy can call the model that picks the tier. `phantom-1:free` never does. */
20
20
  export function testRoute(apiKey, opts, baseUrl) {
21
21
  return request('POST', '/key/route/test', apiKey, opts, baseUrl);
22
22
  }
@@ -27,6 +27,15 @@ function credentials(input) {
27
27
  die('This workspace belongs to a different API address. Run phantom login for this address.', 'workspace_origin');
28
28
  return saved;
29
29
  }
30
+ /** The management credential `phantom login` saved for this API address, or null when it saved none. */
31
+ export function savedManagement(input) {
32
+ return existsSync(credentialPath(input.env)) ? credentials(input).management_credential : null;
33
+ }
34
+ /** A read from the saved workspace, such as `/spend`, with its management credential. */
35
+ export async function workspaceGet(input, path) {
36
+ const saved = credentials(input);
37
+ return wire(saved.base_url, 'GET', '/workspaces/' + encodeURIComponent(saved.workspace_id) + path, undefined, saved.management_credential);
38
+ }
30
39
  /** Device authorization for accounts or guests, saving keys locally without copying a secret through the browser. */
31
40
  export async function workspaceLogin(input) {
32
41
  const { env, io, flags } = input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",
@@ -65,7 +65,7 @@
65
65
  "url": "https://github.com/connortessaro/phantom/issues"
66
66
  },
67
67
  "license": "SEE LICENSE IN LICENSE",
68
- "author": "Connor Tessaro",
68
+ "author": "Harbor Financial LLC",
69
69
  "devDependencies": {
70
70
  "@types/node": "^24.19.1",
71
71
  "@vitest/coverage-v8": "^5.0.3",
@@ -101,6 +101,44 @@ phantom route test --table
101
101
  Routing affects `auto` requests. Use `PHANTOM_KEY_NAME=coding` to manage the same
102
102
  key used by an agent with `--key coding`. Route tests are free.
103
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
+
104
142
  ## Advanced tasks
105
143
 
106
144
  Use `phantom --help --all` for receipts, MCP and direct-key purchases.