@phantomcodes/cli 1.2.3 → 1.2.4

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/README.md CHANGED
@@ -74,7 +74,7 @@ undo the changes with `phantom setup --restore`.
74
74
  ```bash
75
75
  npm i -g @phantomcodes/cli
76
76
  phantom login
77
- phantom workspace fund --amount 10 --card
77
+ phantom fund --amount 10 --card
78
78
  phantom workspace status
79
79
  phantom codex
80
80
  ```
@@ -175,7 +175,7 @@ it cancels. A named model, not `phantom-1`, still spends credit.
175
175
  | Connect | `phantom login`, `phantom logout` |
176
176
  | Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
177
177
  | Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
178
- | Fund and check spending | `phantom workspace fund`, `phantom workspace status`, `phantom balance`, `phantom spend` |
178
+ | Fund and check spending | `phantom fund`, `phantom balance`, `phantom spend`, `phantom workspace status` |
179
179
  | Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
180
180
  | Manage local copies | `phantom keys saved`, `phantom keys save`, `phantom keys rm` |
181
181
  | Route models | `phantom route` |
@@ -187,9 +187,14 @@ Put phantom flags before agent arguments; use `--` to separate them:
187
187
  phantom codex --key coding -- exec "explain this repository"
188
188
  ```
189
189
 
190
- Commands print JSON; `--table` renders supported results for people. Agent
191
- commands inherit the terminal and print a spending summary on stderr.
192
- Exit codes: 0 success, 1 error, 2 rejected key; agent runs return the agent's exit code.
190
+ In a terminal, commands draw a screen for people, and choices are pickers
191
+ (arrow keys, Enter, Esc to go back). Piped, as scripts and agents run them,
192
+ they print JSON on stdout and errors on stderr; `--json` forces JSON in a
193
+ terminal, and `--table` the screen in a pipe. Unknown flags are refused.
194
+ Agent commands inherit the terminal and print a spending summary on stderr.
195
+ Exit codes: 0 success (and Esc), 1 error, 2 rejected key, 130 Ctrl+C;
196
+ `phantom setup` returns 1 when it could only install the skills (no key yet,
197
+ `"complete": false`). Agent runs return the agent's exit code.
193
198
 
194
199
  <details>
195
200
  <summary><strong>Advanced tools, integrations and legacy commands</strong></summary>
@@ -2,7 +2,7 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
- import { die } from "../core/errors.mjs";
5
+ import { die, NO_KEY } from "../core/errors.mjs";
6
6
  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";
@@ -78,7 +78,7 @@ function waitFor(bin, args, env) {
78
78
  const done = () => process.off('SIGINT', ignore);
79
79
  child.on('error', (err) => {
80
80
  done();
81
- reject(err.code === 'ENOENT' ? new Error(`${bin} is not on your PATH. Install it, then run this again.`) : err);
81
+ reject(err.code === 'ENOENT' ? new Error(notInstalled(bin)) : err);
82
82
  });
83
83
  child.on('exit', (code, signal) => {
84
84
  done();
@@ -115,7 +115,7 @@ export async function agentCommand(input) {
115
115
  if (a === '--key')
116
116
  keyName = value(a);
117
117
  else if (a === '--mpp')
118
- die('--mpp has been removed. Phantom stays prepaid only. Run phantom login or use a saved key.');
118
+ die('--mpp has been removed. Phantom stays prepaid only. Run phantom login to sign in, or use a saved key.');
119
119
  else if (a === '--no-browser')
120
120
  noBrowser = true;
121
121
  else
@@ -125,12 +125,12 @@ export async function agentCommand(input) {
125
125
  let apiKey = keyName ? readNamedKey(env, keyName) : resolveApiKey(env);
126
126
  if (!apiKey) {
127
127
  if (!io.isTTY)
128
- die('Connect a key with phantom login.');
128
+ die(NO_KEY);
129
129
  await workspaceLogin({ ...input, flags: { ...(noBrowser ? { 'no-browser': true } : {}) } });
130
130
  apiKey = resolveApiKey(env);
131
131
  }
132
132
  if (!apiKey)
133
- die('No API key. Run: phantom login');
133
+ die(NO_KEY);
134
134
  const auth = { kind: 'key', apiKey };
135
135
  const before = await keyStatus(apiKey, baseUrl).catch(() => null);
136
136
  const report = async () => {
@@ -155,3 +155,13 @@ export async function agentCommand(input) {
155
155
  rmSync(tmp, { recursive: true, force: true });
156
156
  }
157
157
  }
158
+ /** Where to get each agent phantom starts, for when it isn't installed. */
159
+ const INSTALL = {
160
+ claude: 'Claude Code: https://code.claude.com/docs/en/setup',
161
+ codex: 'Codex: npm install -g @openai/codex',
162
+ };
163
+ /** The message when an agent's program isn't installed, in plain words, with where to get it. */
164
+ export function notInstalled(bin) {
165
+ const how = INSTALL[bin];
166
+ return `I can't find the ${bin} program on this computer, so it isn't installed yet (or isn't on your PATH).${how ? ` Install ${how}, then run this again.` : ' Install it, then run this again.'}`;
167
+ }
@@ -0,0 +1,162 @@
1
+ import { colorOn, paint } from "../core/style.mjs";
2
+ import { BackToMenu, Cancelled, choose } from "../core/pick.mjs";
3
+ import { onPath } from "../setup/shared.mjs";
4
+ import { isSetUp } from "../setup/apply.mjs";
5
+ import { resolveApiKey } from "../keys/saved.mjs";
6
+ import { hideHints } from "./screens.mjs";
7
+ import { startHeader } from "./start.mjs";
8
+ import { COMMANDS } from "./commands.mjs";
9
+ /** The agents the menu can start, and the program each needs on this computer. */
10
+ const AGENTS = [
11
+ { label: 'Start Claude Code', bin: 'claude', run: ['claude'] },
12
+ { label: 'Start Codex', bin: 'codex', run: ['codex'] },
13
+ { label: 'Start pi', bin: 'pi', run: ['pi'] },
14
+ ];
15
+ /** The main menu for the state this computer is in. */
16
+ export function mainMenu(s) {
17
+ const quit = { label: 'Quit', value: { run: [] } };
18
+ if (!s.signedIn) {
19
+ return [
20
+ { label: 'Set up my coding agents', hint: 'sign in and connect them; phantom-1 is free to start', value: { run: ['setup'] } },
21
+ { label: 'Sign in only', hint: 'or save a Phantom key you already have', value: { run: ['login'] } },
22
+ { label: 'Every command', hint: 'pick one to run', value: { open: 'commands' } },
23
+ quit,
24
+ ];
25
+ }
26
+ // Only agents that are installed here, so the menu never offers one that can't start.
27
+ const agents = s.setUp ? AGENTS.filter((a) => s.installed(a.bin)).map((a) => ({ label: a.label, hint: 'on Phantom AI', value: { run: a.run } })) : [];
28
+ return [
29
+ ...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint: "you're signed in, but no agent uses Phantom yet", value: { run: ['setup'] } }]),
30
+ ...agents,
31
+ { label: 'Balance', hint: 'what this key can spend', value: { run: ['balance'] } },
32
+ { label: 'Spend', hint: 'what you spent, and what phantom-1 saved', value: { run: ['spend'] } },
33
+ { label: 'Add credit', hint: 'by card or crypto', value: { run: ['fund'] } },
34
+ { label: 'Choose models', hint: 'the model phantom-1 runs in each tier', value: { run: ['lineup', 'set'] } },
35
+ { label: 'Keys', hint: 'list, new, limits, revoke', value: { open: 'keys' } },
36
+ { label: 'Plan', hint: 'Pro or Max, instead of credit', value: { run: ['plan'] } },
37
+ { label: 'Workspace', hint: 'balance, keys and latest calls together', value: { run: ['workspace'] } },
38
+ { label: 'More', hint: 'setup, undo, routing, sign out, every command', value: { open: 'more' } },
39
+ quit,
40
+ ];
41
+ }
42
+ /** The menus a main-menu row opens. */
43
+ function subMenu(which, s) {
44
+ if (which === 'keys') {
45
+ return [
46
+ { label: 'See my keys', value: { run: ['keys'] } },
47
+ { label: 'New key', hint: 'for a teammate, CI or another computer', value: { run: ['keys', 'create'] } },
48
+ { label: 'Set a spending limit', value: { run: ['keys', 'set'] } },
49
+ { label: 'Revoke a key', hint: 'it stops working at once; its credit stays', value: { run: ['keys', 'revoke'] } },
50
+ { label: 'Rotate a key', hint: 'a new key in its place; the old one stops working', value: { run: ['keys', 'rotate'] } },
51
+ ];
52
+ }
53
+ if (which === 'commands') {
54
+ return COMMANDS.filter((c) => !c.advanced).map((c) => ({ label: c.name, hint: c.summary, value: { run: [c.name] } }));
55
+ }
56
+ return [
57
+ { label: s.setUp ? 'Set up more agents' : 'Set up my agents', hint: 'or change how they run', value: { run: ['setup'] } },
58
+ ...(s.setUp ? [{ label: 'Undo setup', hint: 'put your agents back how they were', value: { run: ['setup', '--restore'] } }] : []),
59
+ { label: 'Routing rules', hint: 'how model "auto" picks', value: { run: ['route'] } },
60
+ { label: 'Sign out', hint: 'on this computer', value: { run: ['logout'] } },
61
+ { label: 'Every command', hint: 'pick one to run', value: { open: 'commands' } },
62
+ ];
63
+ }
64
+ /**
65
+ * Next steps that change or reveal something (copying a key, signing out):
66
+ * the marker starts on "Back to the menu" instead, so a stray Enter is safe.
67
+ */
68
+ const RISKY = new Set(['keys show --copy', 'logout --all']);
69
+ /** After a command's result: the next steps that fit it, as choices, so nobody types the "→" line. */
70
+ export function nextSteps(ran) {
71
+ const key = ran.join(' ');
72
+ const steps = {
73
+ balance: [['Spend', ['spend']], ['Add credit', ['fund']]],
74
+ spend: [['Every key in the workspace', ['spend', '--workspace']], ['Choose models', ['lineup', 'set']]],
75
+ 'spend --workspace': [['Keys', ['keys']]],
76
+ fund: [['Balance', ['balance']]],
77
+ workspace: [['See my keys', ['keys']], ['Spend by key', ['spend', '--workspace']]],
78
+ keys: [['New key', ['keys', 'create']], ['Set a spending limit', ['keys', 'set']], ['Revoke a key', ['keys', 'revoke']]],
79
+ plan: [['Start Pro', ['plan', 'pro']], ['Start Max', ['plan', 'max']], ['Manage billing', ['plan', 'manage']]],
80
+ route: [['See what a request would get', ['route', 'test']], ['Choose models', ['lineup', 'set']]],
81
+ 'setup --restore': [['Sign out, and forget saved keys', ['logout', '--all']]],
82
+ login: [['Set up my agents', ['setup']]],
83
+ 'keys create': [['See my keys', ['keys']]],
84
+ 'keys revoke': [['See my keys', ['keys']]],
85
+ 'keys set': [['See my keys', ['keys']]],
86
+ 'keys rotate': [['See my keys', ['keys']]],
87
+ // Cursor and other editors take the key in their own settings: copy it without showing it.
88
+ setup: [['Copy my key (for Cursor or another app)', ['keys', 'show', '--copy']]],
89
+ };
90
+ return (steps[key] ?? []).map(([label, run]) => ({ label, value: run }));
91
+ }
92
+ /**
93
+ * Runs the app until Quit, Esc on the main menu, or Ctrl+C. `runCommand` runs
94
+ * one command the way `phantom <args>` would and returns its exit code; the app
95
+ * passes it `fromMenu`, so a command's first Esc comes back here.
96
+ */
97
+ export async function runApp(env, pick, write, runCommand, installed = onPath) {
98
+ const color = colorOn(env, true);
99
+ const p = paint(env, color);
100
+ const state = () => ({ signedIn: Boolean(resolveApiKey(env)), setUp: isSetUp(env), installed });
101
+ // The "→ type this next" lines would repeat the choices offered after each result, so they're hidden here.
102
+ const hintsWere = hideHints(true);
103
+ try {
104
+ for (;;) {
105
+ // A clean screen for each visit to the menu: a title, then where you are.
106
+ write('\x1b[2J\x1b[H' + startHeader(env, color) + '\n');
107
+ const s = state();
108
+ write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n\n`);
109
+ let action = await choose({ ...pick, esc: 'quit' }, 'What would you like to do?', mainMenu(s));
110
+ if (action === null)
111
+ return 0;
112
+ // A row that opens another menu; Esc there comes back to the main menu.
113
+ while (action && 'open' in action) {
114
+ const sub = subMenu(action.open, s);
115
+ action = await choose({ ...pick, esc: 'back' }, action.open === 'keys' ? 'Keys' : action.open === 'commands' ? 'Which command?' : 'More', sub);
116
+ }
117
+ if (!action)
118
+ continue;
119
+ if (!action.run.length)
120
+ return 0;
121
+ let ran = action.run;
122
+ // Run it, then offer what fits next; Back to the menu, or Quit, end the chain.
123
+ while (ran) {
124
+ try {
125
+ // Ctrl+C inside a command (exit 130) stops the app too, as it would anywhere else.
126
+ if ((await runCommand(ran)) === 130)
127
+ return 130;
128
+ }
129
+ catch (err) {
130
+ if (err instanceof Cancelled)
131
+ return 130;
132
+ if (!(err instanceof BackToMenu))
133
+ throw err;
134
+ break;
135
+ }
136
+ write('\n');
137
+ const steps = nextSteps(ran);
138
+ // A risky step first in the list would run on a stray Enter, so the marker starts on Back then.
139
+ const safe = steps.some((s) => RISKY.has(s.value.join(' '))) ? steps.length : 0;
140
+ const next = await choose({ ...pick, esc: 'back' }, 'What next?', [
141
+ ...steps,
142
+ { label: 'Back to the menu', value: ['menu'] },
143
+ { label: 'Quit', value: [] },
144
+ ], safe);
145
+ if (next === null || next[0] === 'menu')
146
+ ran = null;
147
+ else if (!next.length)
148
+ return 0;
149
+ else
150
+ ran = next;
151
+ }
152
+ }
153
+ }
154
+ catch (err) {
155
+ if (err instanceof Cancelled)
156
+ return 130;
157
+ throw err;
158
+ }
159
+ finally {
160
+ hideHints(hintsWere);
161
+ }
162
+ }
@@ -8,13 +8,18 @@ import { verifyCommand } from "../receipts/command.mjs";
8
8
  import { setupCommand } from "../setup/command.mjs";
9
9
  import { SETUP_MODEL } from "../setup/shared.mjs";
10
10
  import { SETUP_NAMES, orList } from "../setup/agents.mjs";
11
+ /** The short `--help`'s headings, in the order they print. */
12
+ export const HELP_GROUPS = ['Start', 'Run', 'Money', 'Keys', 'Routing', 'More'];
11
13
  async function loginDispatch(input) {
12
14
  const { subcommand, flags } = input;
15
+ // `phantom login google` or `login github`: a word that isn't a key means the usual browser sign-in, not "that is not a key".
16
+ if (subcommand && !subcommand.startsWith('sk-'))
17
+ return workspaceLogin({ ...input, subcommand: undefined });
13
18
  return subcommand || flags.paste ? loginCommand(input) : workspaceLogin(input);
14
19
  }
15
20
  async function logoutDispatch(input) {
16
- await forgetWorkspace(input.env, input.baseUrl);
17
- return logoutCommand(input);
21
+ const forgot = await forgetWorkspace(input.env, input.baseUrl);
22
+ return logoutCommand(input, forgot);
18
23
  }
19
24
  async function fundDispatch(input) {
20
25
  const { flags } = input;
@@ -28,15 +33,14 @@ async function fundDispatch(input) {
28
33
  export const COMMANDS = [
29
34
  {
30
35
  name: 'login', handler: loginDispatch, keyless: true,
31
- help: ` login [--save <name>] [--no-browser] connect an account or guest workspace by device code
32
- login --paste validate and save an existing key
33
- login [key] save your key so you don't need PHANTOM_API_KEY
34
- (use --paste to prompt for an existing key)`,
36
+ group: 'Start', summary: 'sign in only, or save a Phantom key you already have',
37
+ help: ` login [--save <name>] [--no-browser] sign in by device code: an account or a guest workspace
38
+ login --paste save a Phantom key you already have (it asks for it)`,
35
39
  },
36
40
  {
37
41
  name: 'workspace', handler: workspaceCommand, keyless: true,
42
+ group: 'More', summary: 'the whole workspace: balance, recovery and access',
38
43
  help: ` workspace [status] shared balance, keys, recent usage and management access
39
- workspace fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
40
44
  workspace payment --id <payment-id> check verified funding status
41
45
  workspace export --file <path> export private guest recovery; refuses overwrites
42
46
  workspace import --file <path> restore a guest, then issue replacement keys
@@ -44,56 +48,68 @@ export const COMMANDS = [
44
48
  },
45
49
  {
46
50
  name: 'fund', handler: fundDispatch, keyless: true,
51
+ group: 'Money', summary: 'add credit by card or crypto',
47
52
  help: ` fund --amount <USD> [--card | --coin usdc|usdt|sol] [--no-browser]
48
53
  fund your workspace balance with card (plus Stripe's fee) or crypto`,
49
54
  },
50
55
  {
51
56
  name: 'keys', handler: workspaceCommand, keyless: true,
57
+ group: 'Keys', summary: "your workspace's keys: list, create, set limits, revoke",
52
58
  help: ` keys [list] remote workspace key metadata, never secrets
53
59
  keys create [--save <name>] [--limit <USD>]
54
60
  keys set --id <id> [--limit <USD|none>] [--budget <USD|none>]
55
61
  [--period <days|none>] [--rate <USD|none>] [--name <name>]
56
62
  [--policy-file <path> | --clear-policy]
57
63
  keys rotate --id <id> [--save <name>] replace only the corresponding saved key
58
- keys revoke --id <id> revoke access, keeping workspace credit
64
+ keys revoke --id <id> [--yes] revoke access, keeping workspace credit (--yes: don't ask)
59
65
  keys saved [--balance] local saved key metadata
60
66
  keys save <name> [key] validate and save an existing key (prompts if omitted)
61
67
  keys show [name] print a secret for shell integration only
68
+ keys show [name] --copy copy it to the clipboard instead (Mac), for an app's settings
62
69
  keys rm <name> forget a local copy; remote key keeps working`,
63
70
  },
64
71
  {
65
72
  name: 'claude', handler: agentCommand, keyless: true,
73
+ group: 'Run', summary: 'start Claude Code on Phantom AI',
66
74
  help: ` claude [--key <name>] [--no-browser] -- <agent arguments>`,
67
75
  },
68
76
  {
69
77
  name: 'codex', handler: agentCommand, keyless: true,
78
+ group: 'Run', summary: 'start Codex on Phantom AI',
70
79
  help: ` codex [--key <name>] [--no-browser] -- <agent arguments>`,
71
80
  },
72
81
  {
73
82
  name: 'pi', handler: agentCommand, keyless: true,
83
+ group: 'Run', summary: 'start pi on Phantom AI',
74
84
  help: ` pi [--key <name>] [--no-browser] -- <agent arguments>
75
85
  run with a saved key`,
76
86
  },
77
87
  {
78
88
  name: 'balance', handler: balanceCommand,
89
+ group: 'Money', summary: 'your credit, and when it expires',
79
90
  help: ` balance show credit balance and expiry`,
80
91
  },
81
92
  {
82
93
  name: 'spend', handler: spendCommand, keyless: ['--workspace'],
94
+ group: 'Money', summary: 'what you spent, and what phantom-1 saved',
83
95
  help: ` spend [--from <date>] [--to <date>] what this key spent, by model and day, and what phantom-1
84
96
  saved; dates are UTC days as YYYY-MM-DD, default this month
85
97
  spend --workspace every key in the workspace by name (needs phantom login)`,
86
98
  },
87
99
  {
88
100
  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
101
+ group: 'Money', summary: 'your Pro or Max plan: status, buy, manage',
102
+ help: ` plan [status] this workspace's Pro or Max plan, how much of the week is used,
103
+ and what each plan costs
104
+ plan pro | max [--no-browser] open Stripe Checkout to put the plan on this workspace (every
105
+ key in it); at a terminal, it says the price and asks first
92
106
  plan manage [--no-browser] the same for Stripe's billing page: card, switch plan, cancel`,
93
107
  },
94
108
  {
95
109
  name: 'key', advanced: true, handler: keyCommand, keyless: true,
96
- help: ` key list [--balance] saved keys, by name and id
110
+ group: 'More', summary: 'older names for keys saved, save, show and rm',
111
+ help: ` key list | save | show | rm older names for keys saved, keys save, keys show, keys rm
112
+ key list [--balance] saved keys, by name and id
97
113
  key save <name> [key] save a key by name (prompts if no key is given)
98
114
  key show [name] print a saved key, for PHANTOM_API_KEY=$(...);
99
115
  no name prints the key phantom is using
@@ -101,6 +117,7 @@ export const COMMANDS = [
101
117
  },
102
118
  {
103
119
  name: 'route', handler: routeCommand,
120
+ group: 'Routing', summary: 'which models "auto" runs, and rules for picking',
104
121
  help: ` route [get] show which model "auto" runs, and why
105
122
  route set --models a,b,c models for "auto", first is the default
106
123
  [--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
@@ -117,6 +134,7 @@ export const COMMANDS = [
117
134
  },
118
135
  {
119
136
  name: 'lineup', handler: lineupCommand,
137
+ group: 'Routing', summary: 'the model phantom-1 runs in each tier',
120
138
  help: ` lineup [get] show the model phantom-1 runs in each tier, and the choices
121
139
  lineup set --economy <model> pick the model for a tier; "default" goes back to the default
122
140
  [--standard <model>] [--frontier <model>]
@@ -127,17 +145,21 @@ export const COMMANDS = [
127
145
  },
128
146
  {
129
147
  name: 'logout', handler: logoutDispatch, keyless: true,
130
- help: ` logout forget workspace access and the default saved key`,
148
+ group: 'More', summary: 'sign out on this computer',
149
+ help: ` logout sign out: forget workspace access and the default saved key
150
+ logout --all also forget every key saved here by name`,
131
151
  },
132
152
  {
133
153
  name: 'setup', handler: setupCommand, keyless: true,
154
+ group: 'Start', summary: 'sign in and connect your coding agents',
134
155
  help: ` setup set up every agent found: install the skill and
135
156
  point its model calls at Phantom AI; shows each
136
157
  file's changes and asks first. With no key yet,
137
158
  signs in first (in a terminal)
138
159
  [--yes] don't ask
139
160
  [--no-browser] print the sign-in link instead of opening it
140
- [--dry-run] show the changes, write nothing
161
+ [--dry-run] preview the changes without making them
162
+ [--diff] show every line each file gains or loses
141
163
  [--json] print JSON in a terminal too (a pipe always gets JSON)
142
164
  [--model <id>] the model each agent starts on (default ${SETUP_MODEL})
143
165
  setup --agent <name> install the skill for one agent, without asking:
@@ -150,6 +172,7 @@ export const COMMANDS = [
150
172
  },
151
173
  {
152
174
  name: 'verify', advanced: true, handler: verifyCommand, keyless: ['--receipt'],
175
+ group: 'More', summary: 'check a signed receipt',
153
176
  help: ` verify --model <id> make one tiny call and check its signed receipt
154
177
  names the model you asked for (costs a fraction of a cent)
155
178
  verify --receipt <receipt> check a receipt you already have`,
package/dist/cli/help.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_BASE_URL } from "../core/request.mjs";
2
- import { COMMANDS } from "./commands.mjs";
2
+ import { COMMANDS, HELP_GROUPS } from "./commands.mjs";
3
3
  /**
4
4
  * Every environment variable phantom reads. The Environment section of HELP is
5
5
  * built from this list.
@@ -19,7 +19,7 @@ function envHelp() {
19
19
  }
20
20
  /** Full command reference, printed by `phantom --help --all`. */
21
21
  export const HELP = `
22
- phantom — AI inference for your coding agents
22
+ phantom — points your coding agents at Phantom AI
23
23
 
24
24
  Commands:
25
25
  ${COMMANDS.map((command) => command.help).join('\n')}
@@ -32,6 +32,27 @@ Flags:
32
32
 
33
33
  ${envHelp()}
34
34
  `.trim();
35
- /** Workflow help printed by default; command definitions remain the source of usage. */
36
- export const PRIMARY_HELP = HELP.replace(COMMANDS.map((command) => command.help).join('\n'), COMMANDS.filter((command) => !command.advanced).map((command) => command.help).join('\n') +
37
- '\n\nAdvanced: phantom --help --all (receipts and verification)');
35
+ /** The order commands take inside their group in the short help: the one most people need first. Others follow in list order. */
36
+ const FIRST = ['setup', 'login', 'claude', 'codex', 'pi', 'balance', 'fund', 'spend', 'plan'];
37
+ const rank = (name) => (FIRST.includes(name) ? FIRST.indexOf(name) : FIRST.length);
38
+ /** One row of the short help: a command name in its column, then what it does. */
39
+ const helpRow = (name, summary) => ` ${name.padEnd(20)}${summary}`;
40
+ /**
41
+ * The help printed by default: each command in one line, under a heading for
42
+ * what it's for, so it fits on one screen. The three agents share one row.
43
+ * `phantom <command> --help` and `--help --all` keep the full usage.
44
+ */
45
+ export const PRIMARY_HELP = [
46
+ 'phantom — points your coding agents at Phantom AI',
47
+ '',
48
+ ...HELP_GROUPS.flatMap((group) => {
49
+ const shown = COMMANDS.filter((command) => command.group === group && !command.advanced).sort((a, b) => rank(a.name) - rank(b.name));
50
+ const agents = shown.filter((command) => command.group === 'Run');
51
+ const rows = group === 'Run'
52
+ ? [helpRow(agents.map((command) => command.name).join(', '), 'start that agent on Phantom AI')]
53
+ : shown.map((command) => helpRow(command.name, command.summary));
54
+ return rows.length ? [group.toUpperCase(), ...rows, ''] : [];
55
+ }),
56
+ 'phantom <command> --help shows its flags. phantom --help --all lists every flag,',
57
+ 'the advanced commands (receipts and verification) and the environment variables.',
58
+ ].join('\n');
package/dist/cli/run.mjs CHANGED
@@ -1,7 +1,11 @@
1
1
  import { VERSION } from "../core/version.mjs";
2
2
  import { nodeTrustWarning } from "../core/node-trust.mjs";
3
- import { die } from "../core/errors.mjs";
4
- import { handleError, parseFlags } from "../core/helpers.mjs";
3
+ import { die, NO_KEY } from "../core/errors.mjs";
4
+ import { colorOn } from "../core/style.mjs";
5
+ import { ALIASES, closestCommand, commandFromWords, startScreen } from "./start.mjs";
6
+ import { BackToMenu } from "../core/pick.mjs";
7
+ import { runApp } from "./app.mjs";
8
+ import { handleError, parseFlags, pickIo } from "../core/helpers.mjs";
5
9
  import { resolveApiKey } from "../keys/saved.mjs";
6
10
  import { COMMANDS } from "./commands.mjs";
7
11
  import { HELP, PRIMARY_HELP } from "./help.mjs";
@@ -17,6 +21,16 @@ import { LEGACY_SETUP_HINT, legacySetupFound } from "../setup/legacy.mjs";
17
21
  * @param node The Node version it runs on; one too old to reject forged
18
22
  * eMudhra certificates gets a warning on stderr first.
19
23
  */
24
+ /**
25
+ * Every flag phantom reads: each one a command's help names, plus the flags
26
+ * every command takes and two retired ones that explain themselves. A typo
27
+ * such as --yse is refused instead of quietly ignored, which in CI would
28
+ * otherwise drop the flag without a word.
29
+ */
30
+ const KNOWN_FLAGS = new Set([
31
+ ...COMMANDS.flatMap((command) => [...command.help.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1])),
32
+ 'table', 'json', 'help', 'all', 'version', 'mcp', 'pay', 'mpp',
33
+ ]);
20
34
  export async function run(argv = process.argv.slice(2), env = process.env, io = {
21
35
  stdout: (s) => process.stdout.write(s),
22
36
  stderr: (s) => process.stderr.write(s),
@@ -30,23 +44,12 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
30
44
  io.stdout(VERSION + '\n');
31
45
  return 0;
32
46
  }
33
- const THREE_STEPS_GUIDE = `
34
- phantom — prepaid AI inference for coding agents
35
-
36
- Get started in three steps:
37
- 1. Connect your account:
38
- phantom login
39
-
40
- 2. Fund your balance:
41
- phantom fund --amount 10 --card
42
- (or phantom fund --amount 10 --coin usdc)
43
-
44
- 3. Run your coding agent:
45
- phantom claude
46
- (or phantom codex, phantom pi)
47
-
48
- Run phantom --help for all commands.
49
- `.trim();
47
+ // Words people type for a command run as that command: `phantom undo` is setup --restore,
48
+ // `phantom help setup` is setup --help, and `phantom start` opens the menu.
49
+ if (argv[0] === 'help' && COMMANDS.some((c) => c.name === argv[1]))
50
+ argv = [argv[1], '--help'];
51
+ else if (argv[0] && Object.hasOwn(ALIASES, argv[0]))
52
+ argv = [...ALIASES[argv[0]], ...(argv[0] === 'help' ? [] : argv.slice(1))];
50
53
  // `--help` anywhere before a `--` asks for help, so `phantom setup --help`
51
54
  // prints it rather than running setup.
52
55
  const own = argv.includes('--') ? argv.slice(0, argv.indexOf('--')) : argv;
@@ -56,32 +59,56 @@ Run phantom --help for all commands.
56
59
  io.stderr(LEGACY_SETUP_HINT + '\n');
57
60
  }
58
61
  if (argv.length === 0) {
59
- io.stdout(THREE_STEPS_GUIDE + '\n');
60
- return 0;
62
+ const signedIn = Boolean(resolveApiKey(env));
63
+ const pick = pickIo({ io, env });
64
+ if (!pick) {
65
+ io.stdout(startScreen(env, colorOn(env, io.stdoutIsTTY), signedIn) + '\n');
66
+ return 0;
67
+ }
68
+ // At a terminal, phantom alone is an app you stay in: a menu, each command's result, and what to do next.
69
+ return runApp(env, pick, io.stderr, (next) => run(next, env, { ...io, fromMenu: true }, node));
61
70
  }
62
71
  if (own.includes('--help') || own.includes('-h')) {
63
72
  const commandHelp = COMMANDS.find((entry) => entry.name === own[0])?.help;
64
- io.stdout((own.includes('--all') ? HELP : commandHelp?.trim() ?? PRIMARY_HELP) + '\n');
73
+ io.stdout((own.includes('--all') ? HELP : commandHelp?.replace(/\s+$/, '') ?? PRIMARY_HELP) + '\n');
65
74
  return 0;
66
75
  }
67
76
  try {
68
77
  const command = COMMANDS.find((entry) => entry.name === argv[0]);
69
- if (!command)
70
- die(`Unknown command: ${argv[0]}. Run with --help for usage.`);
78
+ if (!command) {
79
+ // A near miss ("stup"), then a word in a sentence ("how do i add money"), then the way in.
80
+ const guess = closestCommand(argv[0], COMMANDS.map((entry) => entry.name)) ?? commandFromWords(argv);
81
+ die(guess ? `Unknown command "${argv[0]}". Did you mean phantom ${guess}?` : `Unknown command "${argv[0]}". Try phantom (a menu), phantom setup, or phantom --help for every command.`);
82
+ }
71
83
  const keyless = command.keyless === true ||
72
84
  (command.keyless?.some((flag) => own.slice(1).some((arg) => arg === flag || arg.startsWith(flag + '='))) ?? false);
73
85
  const apiKey = keyless ? '' : resolveApiKey(env);
74
86
  if (!apiKey && !keyless)
75
- die('No API key. Set PHANTOM_API_KEY or run: phantom login');
87
+ die(NO_KEY);
76
88
  const subcommand = argv[1] && !argv[1].startsWith('--') ? argv[1] : undefined;
77
89
  const flagArgs = subcommand ? argv.slice(2) : argv.slice(1);
90
+ // Flags after -- belong to the agent phantom starts, so only the ones before it are checked.
78
91
  const flags = parseFlags(flagArgs);
79
- const table = Boolean(flags['table']);
92
+ const ownFlags = parseFlags(flagArgs.includes('--') ? flagArgs.slice(0, flagArgs.indexOf('--')) : flagArgs);
93
+ // claude, codex and pi hand their arguments to the agent, so they check their own.
94
+ const passesArgs = command.handler === COMMANDS.find((c) => c.name === 'claude')?.handler;
95
+ for (const name of passesArgs ? [] : Object.keys(ownFlags)) {
96
+ if (KNOWN_FLAGS.has(name))
97
+ continue;
98
+ const guess = closestCommand(name, [...KNOWN_FLAGS]);
99
+ die(`Unknown flag --${name} for phantom ${argv[0]}.${guess ? ` Did you mean --${guess}?` : ''} See phantom ${argv[0]} --help.`, 'unknown_flag');
100
+ }
101
+ // A person at a terminal reads each command's screen; a pipe (scripts, agents) or --json gets JSON.
102
+ const table = Boolean(flags['table']) || (Boolean(io.stdoutIsTTY) && !flags['json']);
80
103
  const baseUrl = env.PHANTOM_BASE_URL;
81
104
  const input = { argv, env, io, apiKey, subcommand, flags, table, baseUrl };
82
105
  return await command.handler(input);
83
106
  }
84
107
  catch (err) {
85
- return handleError(err, io.stderr);
108
+ // Esc on the first step of a command the menu started: let the menu show again.
109
+ if (err instanceof BackToMenu && io.fromMenu)
110
+ throw err;
111
+ // A person at a terminal gets a sentence; scripts and agents reading stderr get JSON.
112
+ return handleError(err, io.stderr, { human: io.isTTY, env });
86
113
  }
87
114
  }