@phantomcodes/cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
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}), 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 skipped = [];
65
+ const agents = [];
66
+ const add = (agent, file, after, skill = false, mode) => {
67
+ const known = files.find((f) => f.file === file);
68
+ if (known) {
69
+ known.after = after;
70
+ if (mode)
71
+ known.mode = mode;
72
+ known.action = known.before === null ? 'create' : after === null ? 'remove' : 'update';
73
+ return;
74
+ }
75
+ const before = readText(file);
76
+ if (before === after)
77
+ return;
78
+ files.push({ agent, file, action: before === null ? 'create' : after === null ? 'remove' : 'update', before, after, ...(skill ? { skill } : {}), ...(mode ? { mode } : {}) });
79
+ };
80
+ const skillText = readFileSync(skillSource(), 'utf-8');
81
+ const shellOn = new Map();
82
+ const shellOff = [];
83
+ for (const agent of picked) {
84
+ const spec = SETUP_AGENTS[agent];
85
+ const skill = spec.skills ? path.join(home, spec.skills, 'phantom-ai', 'SKILL.md') : null;
86
+ if (skill)
87
+ add(agent, skill, skillText, true);
88
+ let result;
89
+ if (provider && spec.provider) {
90
+ try {
91
+ const p = spec.provider(ctx, provider === 'off');
92
+ for (const e of p.edits)
93
+ add(agent, e.file, e.after, false, e.mode);
94
+ result = p.result;
95
+ if (spec.shellEnv) {
96
+ if (provider === 'off')
97
+ shellOff.push(agent);
98
+ else
99
+ shellOn.set(agent, spec.shellEnv(ctx));
100
+ }
101
+ }
102
+ catch (err) {
103
+ if (!isSkip(err))
104
+ throw err;
105
+ skipped.push({ agent, reason: err.message });
106
+ }
107
+ }
108
+ agents.push({ agent, skill, ...(result ? { provider: result } : {}) });
109
+ }
110
+ if (shellOn.size || shellOff.length) {
111
+ try {
112
+ add('shell', ctx.shellRc, withShellBlock(env, ctx.shellRc, readText(ctx.shellRc), shellOn, shellOff, opts.onPath));
113
+ }
114
+ catch (err) {
115
+ if (!isSkip(err))
116
+ throw err;
117
+ skipped.push({ agent: 'shell', reason: err.message });
118
+ }
119
+ }
120
+ return { agents, files, skipped, notes };
121
+ }
@@ -0,0 +1,81 @@
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
+ /** The end of every line phantom adds to a TOML, YAML or shell file. */
23
+ export const SETUP_MARK = '# phantom setup';
24
+ /** The start of a line phantom turned into a comment because it replaced it. `--provider off` puts it back. */
25
+ export const SETUP_WAS = '# phantom setup was: ';
26
+ /** The marks this CLI wrote under its old name, pai. Setup still finds and removes them. */
27
+ const LEGACY_MARK = '# pai setup';
28
+ const LEGACY_WAS = '# pai setup was: ';
29
+ /** True for a line setup added: it ends with {@link SETUP_MARK}, or the mark pai wrote. */
30
+ export function setupMarked(line) {
31
+ const t = line.trimEnd();
32
+ return t.endsWith(SETUP_MARK) || t.endsWith(LEGACY_MARK);
33
+ }
34
+ /** The line without the setup mark on its end. */
35
+ export function stripMark(line) {
36
+ const t = line.trimEnd();
37
+ const mark = t.endsWith(SETUP_MARK) ? SETUP_MARK : t.endsWith(LEGACY_MARK) ? LEGACY_MARK : '';
38
+ return t.slice(0, t.length - mark.length).trimEnd();
39
+ }
40
+ /** A line setup turned into a comment, put back as it was. Other lines come back unchanged. */
41
+ export function unwas(line) {
42
+ for (const was of [SETUP_WAS, LEGACY_WAS])
43
+ if (line.startsWith(was))
44
+ return line.slice(was.length);
45
+ return line;
46
+ }
47
+ /** A file's text, or null when it does not exist. Other read errors pass through. */
48
+ export function readText(file) {
49
+ try {
50
+ return readFileSync(file, 'utf-8');
51
+ }
52
+ catch (err) {
53
+ if (err.code === 'ENOENT')
54
+ return null;
55
+ throw err;
56
+ }
57
+ }
58
+ /** Refuses a file setup can't edit safely. Setup skips the agent and says why. */
59
+ export function unreadable(file, why) {
60
+ die(`${file} ${why}; fix it first so setup doesn't overwrite it`, 'setup_unreadable');
61
+ }
62
+ /** How to run this phantom from outside this shell: `phantom` if it's installed, else this node and script by absolute path. */
63
+ export function phantomCommand(onPathFn) {
64
+ return onPathFn('phantom')
65
+ ? 'phantom'
66
+ : `${onPathFn('node') ? 'node' : JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve(process.argv[1] ?? 'phantom'))}`;
67
+ }
68
+ /** `$XDG_CONFIG_HOME` when it is an absolute path, else `~/.config`. */
69
+ export function configHome(env, home) {
70
+ return env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(home, '.config');
71
+ }
72
+ /** A writer's `off` result for a file it edits by removing its own lines or entries. */
73
+ export function offResult(label, file, text, after) {
74
+ const changed = text !== null && after !== text;
75
+ return {
76
+ edits: changed ? [{ file, after: after || null }] : [],
77
+ result: { settings: file, on: false, warnings: changed ? [] : [`${label} was not set to Phantom AI by phantom; nothing changed`] },
78
+ };
79
+ }
80
+ /** The context window Phantom AI's catalog gives phantom-1. Goose and Aider size requests by it. */
81
+ export const SETUP_CONTEXT_WINDOW = 1_000_000;
@@ -0,0 +1,172 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { die } from "../core/errors.mjs";
3
+ import { flagNum, out } from "../core/helpers.mjs";
4
+ import { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./lineup.mjs";
5
+ import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
6
+ /** `--table` output for `route`. */
7
+ export function tableRoute(d) {
8
+ const p = d.route_policy;
9
+ if (!p)
10
+ return 'route none (model "auto" is refused)';
11
+ const rules = p.rules ?? [];
12
+ return [
13
+ `models ${p.models.join(', ')}`,
14
+ `applies_to ${p.applies_to ?? 'auto'}`,
15
+ `on_empty ${p.on_empty ?? 'stop'}`,
16
+ `fallback ${p.fallback_on_error ?? false}`,
17
+ `sticky ${p.stick_minutes ?? 5} min${(p.stick_by_prompt ?? true) ? '' : ' (x-phantom-session only)'}`,
18
+ ...(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)']),
19
+ ].join('\n');
20
+ }
21
+ /** `--table` output for `route test`. */
22
+ export function tableRouteTest(d) {
23
+ const t = d;
24
+ return [`model ${t.model}`, `reason ${t.reason}`].join('\n');
25
+ }
26
+ /** `phantom route`: the key's route policy, its rules, and a dry run. Run by `run()`. */
27
+ export async function routeCommand({ argv, io, apiKey, subcommand, flags, table, baseUrl }) {
28
+ if (!subcommand || subcommand === 'get') {
29
+ out(await getRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
30
+ }
31
+ else if (subcommand === 'set') {
32
+ const file = flags['file'];
33
+ if (typeof file === 'string') {
34
+ let policy;
35
+ try {
36
+ policy = JSON.parse(readFileSync(file, 'utf-8'));
37
+ }
38
+ catch (e) {
39
+ die(`Could not read ${file} as JSON: ${e instanceof Error ? e.message : e}`);
40
+ }
41
+ out(await putRoute(apiKey, policy, baseUrl), table, tableRoute, io.stdout);
42
+ }
43
+ else {
44
+ const fields = {};
45
+ if (typeof flags['models'] === 'string')
46
+ fields.models = flags['models'].split(',').map((m) => m.trim()).filter(Boolean);
47
+ if (typeof flags['applies-to'] === 'string')
48
+ fields.applies_to = flags['applies-to'];
49
+ if (typeof flags['on-empty'] === 'string')
50
+ fields.on_empty = flags['on-empty'];
51
+ if (flags['fallback'] !== undefined)
52
+ fields.fallback_on_error = flags['fallback'] !== 'false';
53
+ const stick = flagNum(flags, 'stick-minutes');
54
+ if (stick !== undefined)
55
+ fields.stick_minutes = stick;
56
+ if (flags['stick-by-prompt'] !== undefined)
57
+ fields.stick_by_prompt = flags['stick-by-prompt'] !== 'false';
58
+ if (Object.keys(fields).length === 0) {
59
+ die('route set needs --models a,b,c, --applies-to, --on-empty, --fallback, --stick-minutes, --stick-by-prompt, or --file policy.json');
60
+ }
61
+ const current = (await getRoute(apiKey, baseUrl)).route_policy;
62
+ const result = current ? await patchRoute(apiKey, fields, baseUrl) : await putRoute(apiKey, fields, baseUrl);
63
+ out(result, table, tableRoute, io.stdout);
64
+ }
65
+ }
66
+ else if (subcommand === 'rule') {
67
+ const action = argv[2];
68
+ const current = (await getRoute(apiKey, baseUrl)).route_policy;
69
+ if (!current)
70
+ die('This key has no route policy. Start with: phantom route set --models a,b');
71
+ const rules = [...(current.rules ?? [])];
72
+ if (action === 'add') {
73
+ const cond = flags['if'];
74
+ const use = flags['use'];
75
+ if (typeof cond !== 'string' || typeof use !== 'string')
76
+ die('route rule add requires --if name=value and --use <model|cheapest|first|next>');
77
+ const rule = { if: parseCondition(cond), use };
78
+ const at = flagNum(flags, 'at');
79
+ if (at !== undefined)
80
+ rules.splice(Math.max(0, at - 1), 0, rule);
81
+ else
82
+ rules.push(rule);
83
+ }
84
+ else if (action === 'rm') {
85
+ const n = Number(argv[3]);
86
+ if (!Number.isInteger(n) || n < 1 || n > rules.length)
87
+ die(`route rule rm takes a rule number from 1 to ${rules.length}`);
88
+ rules.splice(n - 1, 1);
89
+ }
90
+ else {
91
+ die('Use: route rule add --if name=value --use <model> [--at n], or route rule rm <n>');
92
+ }
93
+ out(await patchRoute(apiKey, { rules }, baseUrl), table, tableRoute, io.stdout);
94
+ }
95
+ else if (subcommand === 'test') {
96
+ const model = typeof flags['model'] === 'string' ? flags['model'] : 'auto';
97
+ out(await testRoute(apiKey, { model }, baseUrl), table, tableRouteTest, io.stdout);
98
+ }
99
+ else if (subcommand === 'clear') {
100
+ out(await clearRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
101
+ }
102
+ else {
103
+ die(`Unknown route subcommand: ${subcommand}`);
104
+ }
105
+ return 0;
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
+ }
@@ -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. 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
+ 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
+ }