@phantomcodes/cli 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +81 -0
- package/README.md +191 -2
- package/bin/phantom.mjs +14 -0
- package/dist/agents/command.mjs +155 -0
- package/dist/agents/proxy.mjs +140 -0
- package/dist/cli/commands.mjs +129 -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 +78 -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 +57 -0
- package/dist/receipts/command.mjs +41 -0
- package/dist/receipts/receipts.mjs +82 -0
- package/dist/setup/agents.mjs +437 -0
- package/dist/setup/apply.mjs +218 -0
- package/dist/setup/command.mjs +114 -0
- package/dist/setup/editors.mjs +486 -0
- package/dist/setup/legacy.mjs +36 -0
- package/dist/setup/plan.mjs +142 -0
- package/dist/setup/shared.mjs +89 -0
- package/dist/spend/command.mjs +105 -0
- package/dist/spend/routing.mjs +32 -0
- package/dist/workspace/command.mjs +204 -0
- package/package.json +83 -4
- package/skills/phantom-ai/SKILL.md +117 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { CliError, die } from "../core/errors.mjs";
|
|
6
|
+
import { resolveApiKey } from "../keys/saved.mjs";
|
|
7
|
+
import { readText } from "./shared.mjs";
|
|
8
|
+
import { shellRcFile, withShellBlock } from "./editors.mjs";
|
|
9
|
+
import { SETUP_AGENTS, SETUP_NAMES, orList } from "./agents.mjs";
|
|
10
|
+
function skillSource() {
|
|
11
|
+
// The skill is in the package's skills/ folder, two up from this file in
|
|
12
|
+
// both src/setup/ and dist/setup/.
|
|
13
|
+
const p = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'phantom-ai', 'SKILL.md');
|
|
14
|
+
if (existsSync(p))
|
|
15
|
+
return p;
|
|
16
|
+
die('The phantom-ai skill is missing from this install');
|
|
17
|
+
}
|
|
18
|
+
const isSkip = (err) => err instanceof CliError && err.code === 'setup_unreadable';
|
|
19
|
+
/**
|
|
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
|
|
22
|
+
* settings with `provider` ({@link SetupAgentSpec.provider}), and the shell
|
|
23
|
+
* block ({@link shellBlock}) for agents that read the key from the
|
|
24
|
+
* environment. A config file setup can't edit safely is left out and listed
|
|
25
|
+
* in `skipped`.
|
|
26
|
+
* @throws {@link CliError} when no agent is found, the agent is unknown, the model is not a plain id, `provider` is set without an agent that supports it, or there is no key for `provider`.
|
|
27
|
+
*/
|
|
28
|
+
export function planSetup(env, opts = {}) {
|
|
29
|
+
const home = env.HOME || os.homedir();
|
|
30
|
+
const names = SETUP_NAMES;
|
|
31
|
+
let picked;
|
|
32
|
+
if (!opts.agent || opts.agent === 'all') {
|
|
33
|
+
picked = names.filter((a) => SETUP_AGENTS[a].detect?.(env, home) ?? existsSync(path.join(home, SETUP_AGENTS[a].home)));
|
|
34
|
+
if (picked.length === 0)
|
|
35
|
+
die(`Found no agent to set up. Pass --agent ${names.join('|')}`);
|
|
36
|
+
}
|
|
37
|
+
else if (names.includes(opts.agent)) {
|
|
38
|
+
picked = [opts.agent];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
die(`Unknown agent: ${opts.agent}. Use ${names.join(', ')} or all`);
|
|
42
|
+
}
|
|
43
|
+
// The model goes into shell and config files, so it must be a plain id.
|
|
44
|
+
if (opts.model !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(opts.model))
|
|
45
|
+
die(`Not a model id: ${opts.model}`, 'model_invalid');
|
|
46
|
+
const notes = [];
|
|
47
|
+
let provider = opts.provider ?? false;
|
|
48
|
+
if (provider && !picked.some((a) => SETUP_AGENTS[a].provider)) {
|
|
49
|
+
if (opts.guided)
|
|
50
|
+
provider = false;
|
|
51
|
+
else {
|
|
52
|
+
const able = names.filter((a) => SETUP_AGENTS[a].provider);
|
|
53
|
+
die(`--provider sets up ${orList(able.map((a) => SETUP_AGENTS[a].label))}; add --agent ${orList(able)}`, 'provider_agent');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (provider === true && !resolveApiKey(env)) {
|
|
57
|
+
if (!opts.guided)
|
|
58
|
+
die('No API key to give the agents. Run: phantom login', 'no_key');
|
|
59
|
+
notes.push('No Phantom AI key yet, so only the skills are installed. Run phantom login, then phantom setup again.');
|
|
60
|
+
provider = false;
|
|
61
|
+
}
|
|
62
|
+
const ctx = { env, home, onPath: opts.onPath, model: opts.model, shellRc: shellRcFile(env, home) };
|
|
63
|
+
const files = [];
|
|
64
|
+
const runs = [];
|
|
65
|
+
const mcpAdds = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
const agents = [];
|
|
68
|
+
const add = (agent, file, after, skill = false, mode) => {
|
|
69
|
+
const known = files.find((f) => f.file === file);
|
|
70
|
+
if (known) {
|
|
71
|
+
known.after = after;
|
|
72
|
+
if (mode)
|
|
73
|
+
known.mode = mode;
|
|
74
|
+
known.action = known.before === null ? 'create' : after === null ? 'remove' : 'update';
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const before = readText(file);
|
|
78
|
+
if (before === after)
|
|
79
|
+
return;
|
|
80
|
+
files.push({ agent, file, action: before === null ? 'create' : after === null ? 'remove' : 'update', before, after, ...(skill ? { skill } : {}), ...(mode ? { mode } : {}) });
|
|
81
|
+
};
|
|
82
|
+
const skillText = readFileSync(skillSource(), 'utf-8');
|
|
83
|
+
const shellOn = new Map();
|
|
84
|
+
const shellOff = [];
|
|
85
|
+
for (const agent of picked) {
|
|
86
|
+
const spec = SETUP_AGENTS[agent];
|
|
87
|
+
const skill = spec.skills ? path.join(home, spec.skills, 'phantom-ai', 'SKILL.md') : null;
|
|
88
|
+
if (skill)
|
|
89
|
+
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
|
+
let result;
|
|
110
|
+
if (provider && spec.provider) {
|
|
111
|
+
try {
|
|
112
|
+
const p = spec.provider(ctx, provider === 'off');
|
|
113
|
+
for (const e of p.edits)
|
|
114
|
+
add(agent, e.file, e.after, false, e.mode);
|
|
115
|
+
result = p.result;
|
|
116
|
+
if (spec.shellEnv) {
|
|
117
|
+
if (provider === 'off')
|
|
118
|
+
shellOff.push(agent);
|
|
119
|
+
else
|
|
120
|
+
shellOn.set(agent, spec.shellEnv(ctx));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (!isSkip(err))
|
|
125
|
+
throw err;
|
|
126
|
+
skipped.push({ agent, reason: err.message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
agents.push({ agent, skill, mcp, mcp_added: false, ...(result ? { provider: result } : {}) });
|
|
130
|
+
}
|
|
131
|
+
if (shellOn.size || shellOff.length) {
|
|
132
|
+
try {
|
|
133
|
+
add('shell', ctx.shellRc, withShellBlock(env, ctx.shellRc, readText(ctx.shellRc), shellOn, shellOff, opts.onPath));
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (!isSkip(err))
|
|
137
|
+
throw err;
|
|
138
|
+
skipped.push({ agent: 'shell', reason: err.message });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { agents, files, runs, mcp: mcpAdds, skipped, notes };
|
|
142
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { die } from "../core/errors.mjs";
|
|
5
|
+
import { DEFAULT_BASE_URL, httpsOnly } from "../core/request.mjs";
|
|
6
|
+
/** True when `bin` is a program on PATH. */
|
|
7
|
+
export function onPath(bin) {
|
|
8
|
+
return spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: 'ignore' }).status === 0;
|
|
9
|
+
}
|
|
10
|
+
/** The model setup starts each agent on when `--model` is not given. */
|
|
11
|
+
export const SETUP_MODEL = 'phantom-1';
|
|
12
|
+
/**
|
|
13
|
+
* The address Phantom AI serves a coding agent at, from PHANTOM_BASE_URL
|
|
14
|
+
* without its `/v1`: `/claude-code` (Claude Code adds `/v1` itself), or
|
|
15
|
+
* `/codex/v1`, `/cursor/v1` and `/coding-agent/v1` for the others.
|
|
16
|
+
* @throws {@link CliError} when PHANTOM_BASE_URL is not https ({@link httpsOnly}).
|
|
17
|
+
*/
|
|
18
|
+
export function agentBaseUrl(env, agent) {
|
|
19
|
+
const origin = httpsOnly(env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
20
|
+
return agent === 'claude-code' ? `${origin}/claude-code` : `${origin}/${agent}/v1`;
|
|
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
|
+
/** The end of every line phantom adds to a TOML, YAML or shell file. */
|
|
31
|
+
export const SETUP_MARK = '# phantom setup';
|
|
32
|
+
/** The start of a line phantom turned into a comment because it replaced it. `--provider off` puts it back. */
|
|
33
|
+
export const SETUP_WAS = '# phantom setup was: ';
|
|
34
|
+
/** The marks this CLI wrote under its old name, pai. Setup still finds and removes them. */
|
|
35
|
+
const LEGACY_MARK = '# pai setup';
|
|
36
|
+
const LEGACY_WAS = '# pai setup was: ';
|
|
37
|
+
/** True for a line setup added: it ends with {@link SETUP_MARK}, or the mark pai wrote. */
|
|
38
|
+
export function setupMarked(line) {
|
|
39
|
+
const t = line.trimEnd();
|
|
40
|
+
return t.endsWith(SETUP_MARK) || t.endsWith(LEGACY_MARK);
|
|
41
|
+
}
|
|
42
|
+
/** The line without the setup mark on its end. */
|
|
43
|
+
export function stripMark(line) {
|
|
44
|
+
const t = line.trimEnd();
|
|
45
|
+
const mark = t.endsWith(SETUP_MARK) ? SETUP_MARK : t.endsWith(LEGACY_MARK) ? LEGACY_MARK : '';
|
|
46
|
+
return t.slice(0, t.length - mark.length).trimEnd();
|
|
47
|
+
}
|
|
48
|
+
/** A line setup turned into a comment, put back as it was. Other lines come back unchanged. */
|
|
49
|
+
export function unwas(line) {
|
|
50
|
+
for (const was of [SETUP_WAS, LEGACY_WAS])
|
|
51
|
+
if (line.startsWith(was))
|
|
52
|
+
return line.slice(was.length);
|
|
53
|
+
return line;
|
|
54
|
+
}
|
|
55
|
+
/** A file's text, or null when it does not exist. Other read errors pass through. */
|
|
56
|
+
export function readText(file) {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(file, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === 'ENOENT')
|
|
62
|
+
return null;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Refuses a file setup can't edit safely. Setup skips the agent and says why. */
|
|
67
|
+
export function unreadable(file, why) {
|
|
68
|
+
die(`${file} ${why}; fix it first so setup doesn't overwrite it`, 'setup_unreadable');
|
|
69
|
+
}
|
|
70
|
+
/** How to run this phantom from outside this shell: `phantom` if it's installed, else this node and script by absolute path. */
|
|
71
|
+
export function phantomCommand(onPathFn) {
|
|
72
|
+
return onPathFn('phantom')
|
|
73
|
+
? 'phantom'
|
|
74
|
+
: `${onPathFn('node') ? 'node' : JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve(process.argv[1] ?? 'phantom'))}`;
|
|
75
|
+
}
|
|
76
|
+
/** `$XDG_CONFIG_HOME` when it is an absolute path, else `~/.config`. */
|
|
77
|
+
export function configHome(env, home) {
|
|
78
|
+
return env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(home, '.config');
|
|
79
|
+
}
|
|
80
|
+
/** A writer's `off` result for a file it edits by removing its own lines or entries. */
|
|
81
|
+
export function offResult(label, file, text, after) {
|
|
82
|
+
const changed = text !== null && after !== text;
|
|
83
|
+
return {
|
|
84
|
+
edits: changed ? [{ file, after: after || null }] : [],
|
|
85
|
+
result: { settings: file, on: false, warnings: changed ? [] : [`${label} was not set to Phantom AI by phantom; nothing changed`] },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The context window Phantom AI's catalog gives phantom-1. Goose and Aider size requests by it. */
|
|
89
|
+
export const SETUP_CONTEXT_WINDOW = 1_000_000;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { die } from "../core/errors.mjs";
|
|
3
|
+
import { flagNum, out } from "../core/helpers.mjs";
|
|
4
|
+
import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
|
|
5
|
+
/** `--table` output for `route`. */
|
|
6
|
+
export function tableRoute(d) {
|
|
7
|
+
const p = d.route_policy;
|
|
8
|
+
if (!p)
|
|
9
|
+
return 'route none (model "auto" is refused)';
|
|
10
|
+
const rules = p.rules ?? [];
|
|
11
|
+
return [
|
|
12
|
+
`models ${p.models.join(', ')}`,
|
|
13
|
+
`applies_to ${p.applies_to ?? 'auto'}`,
|
|
14
|
+
`on_empty ${p.on_empty ?? 'stop'}`,
|
|
15
|
+
`fallback ${p.fallback_on_error ?? false}`,
|
|
16
|
+
`sticky ${p.stick_minutes ?? 5} min${(p.stick_by_prompt ?? true) ? '' : ' (x-phantom-session only)'}`,
|
|
17
|
+
...(rules.length ? rules.map((r, i) => `rule ${String(i + 1).padEnd(11)}if ${Object.entries(r.if).map(([k, v]) => `${k}=${v}`).join(' ')} use ${r.use}`) : ['rules none (auto runs the first model)']),
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
/** `--table` output for `route test`. */
|
|
21
|
+
export function tableRouteTest(d) {
|
|
22
|
+
const t = d;
|
|
23
|
+
return [`model ${t.model}`, `reason ${t.reason}`].join('\n');
|
|
24
|
+
}
|
|
25
|
+
/** `phantom route`: the key's route policy, its rules, and a dry run. Run by `run()`. */
|
|
26
|
+
export async function routeCommand({ argv, io, apiKey, subcommand, flags, table, baseUrl }) {
|
|
27
|
+
if (!subcommand || subcommand === 'get') {
|
|
28
|
+
out(await getRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
|
|
29
|
+
}
|
|
30
|
+
else if (subcommand === 'set') {
|
|
31
|
+
const file = flags['file'];
|
|
32
|
+
if (typeof file === 'string') {
|
|
33
|
+
let policy;
|
|
34
|
+
try {
|
|
35
|
+
policy = JSON.parse(readFileSync(file, 'utf-8'));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
die(`Could not read ${file} as JSON: ${e instanceof Error ? e.message : e}`);
|
|
39
|
+
}
|
|
40
|
+
out(await putRoute(apiKey, policy, baseUrl), table, tableRoute, io.stdout);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const fields = {};
|
|
44
|
+
if (typeof flags['models'] === 'string')
|
|
45
|
+
fields.models = flags['models'].split(',').map((m) => m.trim()).filter(Boolean);
|
|
46
|
+
if (typeof flags['applies-to'] === 'string')
|
|
47
|
+
fields.applies_to = flags['applies-to'];
|
|
48
|
+
if (typeof flags['on-empty'] === 'string')
|
|
49
|
+
fields.on_empty = flags['on-empty'];
|
|
50
|
+
if (flags['fallback'] !== undefined)
|
|
51
|
+
fields.fallback_on_error = flags['fallback'] !== 'false';
|
|
52
|
+
const stick = flagNum(flags, 'stick-minutes');
|
|
53
|
+
if (stick !== undefined)
|
|
54
|
+
fields.stick_minutes = stick;
|
|
55
|
+
if (flags['stick-by-prompt'] !== undefined)
|
|
56
|
+
fields.stick_by_prompt = flags['stick-by-prompt'] !== 'false';
|
|
57
|
+
if (Object.keys(fields).length === 0) {
|
|
58
|
+
die('route set needs --models a,b,c, --applies-to, --on-empty, --fallback, --stick-minutes, --stick-by-prompt, or --file policy.json');
|
|
59
|
+
}
|
|
60
|
+
const current = (await getRoute(apiKey, baseUrl)).route_policy;
|
|
61
|
+
const result = current ? await patchRoute(apiKey, fields, baseUrl) : await putRoute(apiKey, fields, baseUrl);
|
|
62
|
+
out(result, table, tableRoute, io.stdout);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (subcommand === 'rule') {
|
|
66
|
+
const action = argv[2];
|
|
67
|
+
const current = (await getRoute(apiKey, baseUrl)).route_policy;
|
|
68
|
+
if (!current)
|
|
69
|
+
die('This key has no route policy. Start with: phantom route set --models a,b');
|
|
70
|
+
const rules = [...(current.rules ?? [])];
|
|
71
|
+
if (action === 'add') {
|
|
72
|
+
const cond = flags['if'];
|
|
73
|
+
const use = flags['use'];
|
|
74
|
+
if (typeof cond !== 'string' || typeof use !== 'string')
|
|
75
|
+
die('route rule add requires --if name=value and --use <model|cheapest|first|next>');
|
|
76
|
+
const rule = { if: parseCondition(cond), use };
|
|
77
|
+
const at = flagNum(flags, 'at');
|
|
78
|
+
if (at !== undefined)
|
|
79
|
+
rules.splice(Math.max(0, at - 1), 0, rule);
|
|
80
|
+
else
|
|
81
|
+
rules.push(rule);
|
|
82
|
+
}
|
|
83
|
+
else if (action === 'rm') {
|
|
84
|
+
const n = Number(argv[3]);
|
|
85
|
+
if (!Number.isInteger(n) || n < 1 || n > rules.length)
|
|
86
|
+
die(`route rule rm takes a rule number from 1 to ${rules.length}`);
|
|
87
|
+
rules.splice(n - 1, 1);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
die('Use: route rule add --if name=value --use <model> [--at n], or route rule rm <n>');
|
|
91
|
+
}
|
|
92
|
+
out(await patchRoute(apiKey, { rules }, baseUrl), table, tableRoute, io.stdout);
|
|
93
|
+
}
|
|
94
|
+
else if (subcommand === 'test') {
|
|
95
|
+
const model = typeof flags['model'] === 'string' ? flags['model'] : 'auto';
|
|
96
|
+
out(await testRoute(apiKey, { model }, baseUrl), table, tableRouteTest, io.stdout);
|
|
97
|
+
}
|
|
98
|
+
else if (subcommand === 'clear') {
|
|
99
|
+
out(await clearRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
die(`Unknown route subcommand: ${subcommand}`);
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { die } from "../core/errors.mjs";
|
|
2
|
+
import { request } from "../core/request.mjs";
|
|
3
|
+
/** The key's route policy (GET /key/route). */
|
|
4
|
+
export function getRoute(apiKey, baseUrl) {
|
|
5
|
+
return request('GET', '/key/route', apiKey, undefined, baseUrl);
|
|
6
|
+
}
|
|
7
|
+
/** Replaces the key's route policy (PUT /key/route). The server checks it before saving. */
|
|
8
|
+
export function putRoute(apiKey, policy, baseUrl) {
|
|
9
|
+
return request('PUT', '/key/route', apiKey, policy, baseUrl);
|
|
10
|
+
}
|
|
11
|
+
/** Changes some top-level fields of the route policy and keeps the rest (PATCH /key/route). A list sent here replaces the whole list. */
|
|
12
|
+
export function patchRoute(apiKey, fields, baseUrl) {
|
|
13
|
+
return request('PATCH', '/key/route', apiKey, fields, baseUrl);
|
|
14
|
+
}
|
|
15
|
+
/** Removes the key's route policy (DELETE /key/route). */
|
|
16
|
+
export function clearRoute(apiKey, baseUrl) {
|
|
17
|
+
return request('DELETE', '/key/route', apiKey, undefined, baseUrl);
|
|
18
|
+
}
|
|
19
|
+
/** Which model a request would run on now, and why (POST /key/route/test). Nothing is charged and no model is called. */
|
|
20
|
+
export function testRoute(apiKey, opts, baseUrl) {
|
|
21
|
+
return request('POST', '/key/route/test', apiKey, opts, baseUrl);
|
|
22
|
+
}
|
|
23
|
+
/** `pace=ahead` or `input_tokens_over=50000` into one rule condition. */
|
|
24
|
+
export function parseCondition(raw) {
|
|
25
|
+
const eq = raw.indexOf('=');
|
|
26
|
+
if (eq < 1)
|
|
27
|
+
die(`Write a condition as name=value, for example pace=ahead`);
|
|
28
|
+
const name = raw.slice(0, eq);
|
|
29
|
+
const text = raw.slice(eq + 1);
|
|
30
|
+
const value = text === 'true' ? true : text === 'false' ? false : text !== '' && Number.isFinite(Number(text)) ? Number(text) : text;
|
|
31
|
+
return { [name]: value };
|
|
32
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { DEFAULT_BASE_URL, httpsOnly, apiError } from "../core/request.mjs";
|
|
6
|
+
import { stateDir } from "../core/state.mjs";
|
|
7
|
+
import { writeSecret, readSecret, removeSecret, keychainOn } from "../core/secrets.mjs";
|
|
8
|
+
import { keyCommand } from "../keys/command.mjs";
|
|
9
|
+
import { die } from "../core/errors.mjs";
|
|
10
|
+
import { openUrl } from "../core/helpers.mjs";
|
|
11
|
+
import { keyPath, namedKeyPath, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
|
|
12
|
+
const credentialPath = (env) => path.join(stateDir(env), 'workspace');
|
|
13
|
+
const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
14
|
+
async function wire(url, method, endpoint, body, token) {
|
|
15
|
+
const response = await fetch(url + endpoint, { method, redirect: 'error', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
if (!response.ok)
|
|
18
|
+
throw apiError(response.status, data);
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
function credentials(input) {
|
|
22
|
+
const file = credentialPath(input.env);
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
die('No workspace connection. Run phantom login.', 'workspace_missing');
|
|
25
|
+
const saved = JSON.parse(readSecret(input.env, file));
|
|
26
|
+
if (saved.base_url !== base(input))
|
|
27
|
+
die('This workspace belongs to a different API address. Run phantom login for this address.', 'workspace_origin');
|
|
28
|
+
return saved;
|
|
29
|
+
}
|
|
30
|
+
/** Device authorization for accounts or guests, saving keys locally without copying a secret through the browser. */
|
|
31
|
+
export async function workspaceLogin(input) {
|
|
32
|
+
const { env, io, flags } = input;
|
|
33
|
+
if (flags.save === true)
|
|
34
|
+
die('--save requires a key name.');
|
|
35
|
+
const name = typeof flags.save === 'string' ? flags.save : 'default';
|
|
36
|
+
const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
|
|
37
|
+
if (existsSync(target))
|
|
38
|
+
die(`Saved key ${name} already exists. Use --save <new-name>; no key was overwritten.`, 'key_exists');
|
|
39
|
+
const url = base(input);
|
|
40
|
+
const start = await wire(url, 'POST', '/auth/device', { client_name: `phantom on ${hostname()}` });
|
|
41
|
+
if (new URL(start.verification_uri_complete).origin !== new URL(url).origin)
|
|
42
|
+
die('The approval URL belongs to another site.', 'device_origin');
|
|
43
|
+
io.stderr(`Open ${start.verification_uri_complete}\nConfirm code ${start.user_code}\n`);
|
|
44
|
+
if (!flags['no-browser'])
|
|
45
|
+
openUrl(start.verification_uri_complete);
|
|
46
|
+
let interval = Math.max(1, start.interval) * 1000;
|
|
47
|
+
const deadline = Date.now() + Math.min(600, start.expires_in) * 1000;
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
50
|
+
let token;
|
|
51
|
+
try {
|
|
52
|
+
token = await wire(url, 'POST', '/auth/device/token', { device_code: start.device_code });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const code = error.code;
|
|
56
|
+
if (code === 'authorization_pending')
|
|
57
|
+
continue;
|
|
58
|
+
if (code === 'slow_down') {
|
|
59
|
+
interval += 5000;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const saved = { version: 1, workspace_id: token.workspace_id, management_credential: token.management_token, base_url: url, recovery: token.recovery };
|
|
65
|
+
try {
|
|
66
|
+
writeSecret(env, credentialPath(env), JSON.stringify(saved));
|
|
67
|
+
if (name === 'default')
|
|
68
|
+
saveApiKey(env, token.api_key);
|
|
69
|
+
else
|
|
70
|
+
saveNamedKey(env, name, token.api_key);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
io.stderr('phantom could not save the connection. Sign in or restore your workspace to revoke the device key and reconnect.\n');
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
io.stdout(JSON.stringify({ saved_as: name, key_name: token.key_name, workspace_id: token.workspace_id, storage: keychainOn(env) ? 'macOS Keychain' : 'private files' }) + '\n');
|
|
77
|
+
io.stderr('Ready. Run phantom claude, phantom codex or phantom pi. Guests can export recovery with phantom workspace export --file <path>.\n');
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
die('Device code expired. Run phantom login again.', 'login_expired');
|
|
81
|
+
}
|
|
82
|
+
/** Manage a workspace from the terminal: balance, keys, funding, recovery and revocation. */
|
|
83
|
+
export async function workspaceCommand(input) {
|
|
84
|
+
const { subcommand, flags, io, env, argv } = input;
|
|
85
|
+
if (argv[0] === 'keys') {
|
|
86
|
+
if (subcommand === 'saved' || subcommand === 'save' || subcommand === 'show' || subcommand === 'rm') {
|
|
87
|
+
return keyCommand({ ...input, flags: { ...(flags.balance ? { balance: flags.balance } : {}) }, subcommand: subcommand === 'saved' ? 'list' : subcommand });
|
|
88
|
+
}
|
|
89
|
+
const remoteCommands = new Set(['list', 'create', 'rotate', 'revoke', 'set', undefined]);
|
|
90
|
+
if (!remoteCommands.has(subcommand)) {
|
|
91
|
+
return keyCommand({ ...input, subcommand });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const command = subcommand ?? 'status';
|
|
95
|
+
if (command === 'import' && argv[0] === 'workspace') {
|
|
96
|
+
if (typeof flags.file !== 'string')
|
|
97
|
+
die('Use phantom workspace import --file <recovery-file>.');
|
|
98
|
+
const recovery = JSON.parse(readFileSync(flags.file, 'utf8'));
|
|
99
|
+
if (!recovery || recovery.version !== 1)
|
|
100
|
+
die('Invalid recovery file.');
|
|
101
|
+
await wire(base(input), 'GET', '/workspaces/' + encodeURIComponent(recovery.workspace_id), undefined, recovery.management_credential);
|
|
102
|
+
writeSecret(env, credentialPath(env), JSON.stringify({ ...recovery, base_url: base(input), recovery }));
|
|
103
|
+
io.stdout('Workspace restored. Use phantom keys create to save a new API key.\n');
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const saved = credentials(input);
|
|
107
|
+
const endpoint = '/workspaces/' + encodeURIComponent(saved.workspace_id);
|
|
108
|
+
if (command === 'export') {
|
|
109
|
+
if (!saved.recovery)
|
|
110
|
+
die('This is an account workspace. Sign in to recover it on another computer.');
|
|
111
|
+
if (typeof flags.file !== 'string')
|
|
112
|
+
die('Use phantom workspace export --file <path>.');
|
|
113
|
+
writeFileSync(flags.file, JSON.stringify(saved.recovery, null, 2), { mode: 0o600, flag: 'wx' });
|
|
114
|
+
io.stdout('Recovery exported. Keep that file private; it grants workspace management.\n');
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
if (command === 'create' || command === 'rotate') {
|
|
118
|
+
if (command === 'rotate' && typeof flags.id !== 'string')
|
|
119
|
+
die('Use --id <key-id> from phantom keys list.');
|
|
120
|
+
const name = typeof flags.save === 'string' ? flags.save : 'default';
|
|
121
|
+
if (flags.save === true)
|
|
122
|
+
die('--save requires a key name.');
|
|
123
|
+
const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
|
|
124
|
+
const replacing = existsSync(target);
|
|
125
|
+
if (replacing && (command !== 'rotate' || createHash('sha256').update(readSecret(env, target).trim()).digest('hex') !== flags.id))
|
|
126
|
+
die('That saved key already exists. Use --save <new-name>.', 'key_exists');
|
|
127
|
+
const result = await wire(saved.base_url, 'POST', command === 'create' ? endpoint + '/keys' : endpoint + '/keys/' + String(flags.id), command === 'create' ? { name, limit_usd: flags.limit === undefined ? null : Number(flags.limit) } : { action: 'rotate' }, saved.management_credential);
|
|
128
|
+
if (name === 'default')
|
|
129
|
+
saveApiKey(env, result.api_key);
|
|
130
|
+
else
|
|
131
|
+
saveNamedKey(env, name, result.api_key, { replace: replacing });
|
|
132
|
+
io.stdout(JSON.stringify({ saved_as: name }) + '\n');
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
if (command === 'revoke') {
|
|
136
|
+
if (typeof flags.id !== 'string')
|
|
137
|
+
die('Use phantom keys revoke --id <key-id>.');
|
|
138
|
+
await wire(saved.base_url, 'DELETE', endpoint + '/keys/' + flags.id, undefined, saved.management_credential);
|
|
139
|
+
io.stdout('Key revoked.\n');
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (command === 'set') {
|
|
143
|
+
if (typeof flags.id !== 'string')
|
|
144
|
+
die('Use phantom keys set --id <key-id>.');
|
|
145
|
+
const body = {};
|
|
146
|
+
const controls = { limit_usd: flags.limit, budget_usd: flags.budget, period_days: flags.period, rate_usd_per_min: flags.rate };
|
|
147
|
+
for (const [field, value] of Object.entries(controls))
|
|
148
|
+
if (value !== undefined)
|
|
149
|
+
body[field] = value === 'none' ? null : Number(value);
|
|
150
|
+
if (typeof flags.name === 'string')
|
|
151
|
+
body.name = flags.name;
|
|
152
|
+
if (typeof flags['policy-file'] === 'string')
|
|
153
|
+
body.route_policy = JSON.parse(readFileSync(flags['policy-file'], 'utf8'));
|
|
154
|
+
if (flags['clear-policy'])
|
|
155
|
+
body.route_policy = null;
|
|
156
|
+
const result = await wire(saved.base_url, 'PATCH', endpoint + '/keys/' + flags.id, body, saved.management_credential);
|
|
157
|
+
io.stdout(JSON.stringify(result) + '\n');
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (command === 'revoke-access') {
|
|
161
|
+
if (typeof flags.id !== 'string')
|
|
162
|
+
die('Use --id <management-id> from phantom workspace status.');
|
|
163
|
+
await wire(saved.base_url, 'DELETE', endpoint + '/access/' + flags.id, undefined, saved.management_credential);
|
|
164
|
+
io.stdout('Management access revoked.\n');
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
if (command === 'payment') {
|
|
168
|
+
if (typeof flags.id !== 'string')
|
|
169
|
+
die('Use --id <payment-id> from phantom workspace fund.');
|
|
170
|
+
io.stdout(JSON.stringify(await wire(saved.base_url, 'GET', endpoint + '/payments/' + flags.id, undefined, saved.management_credential)) + '\n');
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
if (command === 'fund') {
|
|
174
|
+
if (flags.pay)
|
|
175
|
+
die('--pay has been removed. Use --card or --coin.');
|
|
176
|
+
if (!flags.card && !flags.coin)
|
|
177
|
+
die('fund requires --card or --coin.');
|
|
178
|
+
const result = await wire(saved.base_url, 'POST', endpoint + '/fund', { method: flags.card ? 'card' : 'crypto', amount_usd: Number(flags.amount), coin: flags.coin ?? 'usdc' }, saved.management_credential);
|
|
179
|
+
io.stdout(JSON.stringify(result) + '\n');
|
|
180
|
+
if (result.checkout_url && !flags['no-browser'])
|
|
181
|
+
openUrl(result.checkout_url);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
const view = await wire(saved.base_url, 'GET', endpoint, undefined, saved.management_credential);
|
|
185
|
+
io.stdout(JSON.stringify(argv[0] === 'keys' ? view.keys : view, null, 2) + '\n');
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
/** Forget local management credentials and revoke server management access, leaving server-side keys and recovery files valid. */
|
|
189
|
+
export async function forgetWorkspace(env, baseUrl) {
|
|
190
|
+
const file = credentialPath(env);
|
|
191
|
+
if (existsSync(file)) {
|
|
192
|
+
try {
|
|
193
|
+
const saved = JSON.parse(readSecret(env, file));
|
|
194
|
+
if (saved?.workspace_id && saved?.management_credential) {
|
|
195
|
+
const url = httpsOnly(baseUrl || env.PHANTOM_BASE_URL || saved.base_url || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
196
|
+
await wire(url, 'POST', '/workspaces/' + encodeURIComponent(saved.workspace_id) + '/logout', {}, saved.management_credential).catch(() => { });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Ignore read or parse errors during forget
|
|
201
|
+
}
|
|
202
|
+
removeSecret(file);
|
|
203
|
+
}
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phantomcodes/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"phantom",
|
|
7
|
+
"api-key",
|
|
8
|
+
"agent",
|
|
9
|
+
"llm",
|
|
10
|
+
"inference",
|
|
11
|
+
"cli",
|
|
12
|
+
"mcp",
|
|
13
|
+
"pi-package",
|
|
14
|
+
"agent-skill",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex",
|
|
17
|
+
"cursor",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"ai-agents",
|
|
20
|
+
"solana",
|
|
21
|
+
"wallet",
|
|
22
|
+
"subagents"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://phantom.codes/",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/connortessaro/phantom.git",
|
|
28
|
+
"directory": "packages/cli"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"bin": {
|
|
32
|
+
"phantom": "bin/phantom.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"bin",
|
|
36
|
+
"dist",
|
|
37
|
+
"skills",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=24"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"jsonc-parser": "^3.3.1",
|
|
46
|
+
"smol-toml": "^1.9.0",
|
|
47
|
+
"zod": "^4.5.4"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p .",
|
|
51
|
+
"coverage": "vitest run --coverage",
|
|
52
|
+
"dev": "node src/phantom.mts",
|
|
53
|
+
"docs": "node scripts/gen-docs.mjs",
|
|
54
|
+
"prepare": "npm run build",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"typecheck": "tsc -p . --noEmit",
|
|
57
|
+
"version": "node scripts/sync-version.mjs && git add src/core/version.mts"
|
|
58
|
+
},
|
|
59
|
+
"pi": {
|
|
60
|
+
"skills": [
|
|
61
|
+
"./skills"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/connortessaro/phantom/issues"
|
|
66
|
+
},
|
|
67
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
68
|
+
"author": "Connor Tessaro",
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/node": "^24.19.1",
|
|
71
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
72
|
+
"typedoc": "^0.28.20",
|
|
73
|
+
"typescript": "^6.0.3",
|
|
74
|
+
"vitest": "^5.0.2"
|
|
75
|
+
},
|
|
76
|
+
"publishConfig": {
|
|
77
|
+
"access": "public"
|
|
78
|
+
},
|
|
79
|
+
"exports": {
|
|
80
|
+
".": {
|
|
81
|
+
"types": "./dist/phantom.d.mts",
|
|
82
|
+
"import": "./dist/phantom.mjs"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|