@wavespeed/cli 0.4.15 → 0.4.16

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
@@ -141,6 +141,7 @@ wavespeed logout Clear stored API key
141
141
  wavespeed status Show masked key, base URL, useful links
142
142
  wavespeed config [--default-model …] View / update CLI defaults
143
143
  wavespeed upgrade [--check] [--json] Upgrade to the latest version (--check only reports)
144
+ wavespeed setup [claude|codex|opencode] Point a coding agent at the WaveSpeed LLM gateway (--dry-run, --undo, --model)
144
145
 
145
146
  # generation
146
147
  wavespeed run [model|alias] -p "…" Run any model or alias (uses defaultModel if omitted)
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { registerOpen } from './commands/open.js';
21
21
  import { registerBillings } from './commands/billings.js';
22
22
  import { registerUsage } from './commands/usage.js';
23
23
  import { registerUpgrade } from './commands/upgrade.js';
24
+ import { registerSetup } from './commands/setup.js';
24
25
  import { VERSION } from './lib/version.js';
25
26
  import { detectRunHelp, printDynamicRunHelp } from './lib/dynamic-help.js';
26
27
  const program = new Command();
@@ -62,6 +63,7 @@ registerUsage(program);
62
63
  registerInit(program);
63
64
  registerSkill(program);
64
65
  registerUpgrade(program);
66
+ registerSetup(program);
65
67
  program
66
68
  .command('hello', { hidden: true })
67
69
  .description('Print the WaveSpeed banner')
@@ -0,0 +1,203 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { confirm } from '@inquirer/prompts';
6
+ import { getApiKey, userConfig } from '../lib/config.js';
7
+ import { clientAttributionHeaders } from '../lib/client-headers.js';
8
+ import { emitJson, emitJsonError, info, setJsonMode } from '../lib/log.js';
9
+ import { DEFAULT_MODEL, KEY_ENV, LLM_BASE_URL, applyCodexToml, applyJson, claudeEdits, opencodeEdits, revertCodexToml, revertJson, validateModel, } from '../lib/setup-agents.js';
10
+ const home = os.homedir();
11
+ const AGENTS = [
12
+ { id: 'claude', name: 'Claude Code', file: path.join(home, '.claude', 'settings.json'), command: 'claude', kind: 'json', keyInFile: true },
13
+ { id: 'codex', name: 'Codex', file: path.join(home, '.codex', 'config.toml'), command: 'codex', kind: 'toml', keyInFile: false },
14
+ { id: 'opencode', name: 'OpenCode', file: path.join(home, '.config', 'opencode', 'opencode.json'), command: 'opencode', kind: 'json', keyInFile: false },
15
+ ];
16
+ const shown = (p) => p.replace(home, '~');
17
+ function onPath(cmd) {
18
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
19
+ return (process.env.PATH ?? '').split(path.delimiter).some((d) => d && exts.some((e) => fs.existsSync(path.join(d, cmd + e))));
20
+ }
21
+ /** One streamed request to the same endpoint family the agents use; proves key, model and streaming work. */
22
+ export async function selfCheck(apiKey, model) {
23
+ const base = process.env.WAVESPEED_LLM_BASE_URL || LLM_BASE_URL;
24
+ try {
25
+ const res = await fetch(`${base}/chat/completions`, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() },
28
+ // Reasoning models spend tokens thinking first; leave room for a visible reply.
29
+ body: JSON.stringify({ model, stream: true, max_tokens: 256, messages: [{ role: 'user', content: 'Reply with the single word READY.' }] }),
30
+ signal: AbortSignal.timeout(60_000),
31
+ });
32
+ if (!res.ok)
33
+ return { ok: false, detail: `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}` };
34
+ const text = await res.text();
35
+ const chunks = text.split('\n').filter((l) => l.startsWith('data: ') && !l.includes('[DONE]'));
36
+ if (chunks.length === 0)
37
+ return { ok: false, detail: 'stream returned no data' };
38
+ if (chunks.some((l) => l.includes('"error"')))
39
+ return { ok: false, detail: 'stream contained an error' };
40
+ return { ok: true, detail: `${chunks.length} stream chunks` };
41
+ }
42
+ catch (err) {
43
+ return { ok: false, detail: err.message ?? String(err) };
44
+ }
45
+ }
46
+ function readJson(file) {
47
+ if (!fs.existsSync(file))
48
+ return {};
49
+ const raw = fs.readFileSync(file, 'utf8');
50
+ if (raw.trim() === '')
51
+ return {};
52
+ try {
53
+ const v = JSON.parse(raw);
54
+ if (v && typeof v === 'object' && !Array.isArray(v))
55
+ return v;
56
+ }
57
+ catch {
58
+ /* fall through */
59
+ }
60
+ throw new Error(`${shown(file)} is not plain JSON (comments or trailing commas?). Edit it by hand using the snippet at https://wavespeed.ai/llm/agents`);
61
+ }
62
+ function writeFile(file, content, secret) {
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ const bak = `${file}.wavespeed.bak`;
65
+ if (fs.existsSync(file) && !fs.existsSync(bak))
66
+ fs.copyFileSync(file, bak);
67
+ fs.writeFileSync(file, content, secret ? { mode: 0o600 } : undefined);
68
+ if (secret)
69
+ fs.chmodSync(file, 0o600);
70
+ }
71
+ function envHint() {
72
+ return process.platform === 'win32'
73
+ ? `setx ${KEY_ENV} "<your key>" (then open a new terminal)`
74
+ : `export ${KEY_ENV}="<your key>" # add to ~/.zshrc or ~/.bashrc`;
75
+ }
76
+ export function registerSetup(program) {
77
+ program
78
+ .command('setup [agent]')
79
+ .description('Point a coding agent (claude, codex, opencode) at the WaveSpeed LLM gateway')
80
+ .option('--model <id>', `Model to use (default: ${DEFAULT_MODEL})`)
81
+ .option('--dry-run', 'Show what would change without writing anything')
82
+ .option('--undo', "Restore the agent's config to what it was before setup")
83
+ .option('-y, --yes', 'Do not ask for confirmation')
84
+ .option('--no-check', 'Skip the live request that verifies the setup')
85
+ .option('--json', 'Emit JSON')
86
+ .action(async (agentId, opts) => {
87
+ setJsonMode(!!opts.json);
88
+ const fail = (message) => {
89
+ if (opts.json)
90
+ emitJsonError(message);
91
+ else
92
+ console.error(chalk.red('Error: ') + message);
93
+ process.exit(1);
94
+ };
95
+ if (!agentId) {
96
+ const rows = AGENTS.map((a) => ({ agent: a.id, name: a.name, config: shown(a.file), installed: onPath(a.command) }));
97
+ if (opts.json)
98
+ return emitJson(rows);
99
+ info(chalk.bold('Agents:'));
100
+ for (const r of rows)
101
+ info(` ${chalk.cyan(r.agent.padEnd(9))} ${r.name.padEnd(12)} ${chalk.gray(r.config)} ${r.installed ? chalk.green('found') : chalk.gray('not on PATH')}`);
102
+ info('\nRun ' + chalk.cyan('wavespeed setup <agent>') + ' (add --dry-run to preview, --undo to revert).');
103
+ return;
104
+ }
105
+ const agent = AGENTS.find((a) => a.id === agentId);
106
+ if (!agent)
107
+ return fail(`Unknown agent "${agentId}". Choose one of: ${AGENTS.map((a) => a.id).join(', ')}`);
108
+ // ----- undo -----
109
+ if (opts.undo) {
110
+ try {
111
+ if (agent.kind === 'toml') {
112
+ if (!fs.existsSync(agent.file))
113
+ return fail(`${shown(agent.file)} does not exist; nothing to undo`);
114
+ const before = fs.readFileSync(agent.file, 'utf8');
115
+ const after = revertCodexToml(before);
116
+ if (!opts.dryRun && after !== before)
117
+ fs.writeFileSync(agent.file, after);
118
+ }
119
+ else {
120
+ const state = userConfig.get('setup')?.[agent.id];
121
+ if (!state)
122
+ return fail(`No recorded ${agent.name} setup to undo`);
123
+ const doc = readJson(state.file);
124
+ const after = JSON.stringify(revertJson(doc, state.previous), null, 2) + '\n';
125
+ if (!opts.dryRun) {
126
+ fs.writeFileSync(state.file, after);
127
+ const rest = { ...(userConfig.get('setup') ?? {}) };
128
+ delete rest[agent.id];
129
+ userConfig.set('setup', rest);
130
+ }
131
+ }
132
+ }
133
+ catch (err) {
134
+ return fail(err.message ?? String(err));
135
+ }
136
+ if (opts.json)
137
+ return emitJson({ agent: agent.id, undone: !opts.dryRun, file: agent.file });
138
+ info(chalk.green(opts.dryRun ? `Would restore ${shown(agent.file)}.` : `Restored ${shown(agent.file)}.`));
139
+ return;
140
+ }
141
+ // ----- apply -----
142
+ const apiKey = getApiKey();
143
+ if (!apiKey)
144
+ return fail('No API key. Run `wavespeed login` first.');
145
+ let model;
146
+ try {
147
+ model = validateModel(opts.model ?? DEFAULT_MODEL);
148
+ }
149
+ catch (err) {
150
+ return fail(err.message);
151
+ }
152
+ let next;
153
+ let previous = [];
154
+ try {
155
+ if (agent.kind === 'toml') {
156
+ next = applyCodexToml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
157
+ }
158
+ else {
159
+ const result = applyJson(readJson(agent.file), agent.id === 'claude' ? claudeEdits(model, apiKey) : opencodeEdits(model));
160
+ previous = result.previous;
161
+ next = JSON.stringify(result.doc, null, 2) + '\n';
162
+ }
163
+ }
164
+ catch (err) {
165
+ return fail(err.message ?? String(err));
166
+ }
167
+ const exists = fs.existsSync(agent.file);
168
+ if (opts.dryRun) {
169
+ if (opts.json)
170
+ return emitJson({ agent: agent.id, model, file: agent.file, dry_run: true, will_create: !exists });
171
+ info(chalk.bold(`Would ${exists ? 'update' : 'create'} ${shown(agent.file)}`) + chalk.gray(` (model ${model}${agent.keyInFile ? '; your API key would be stored in this file' : ''})`));
172
+ info(chalk.gray('Nothing written.'));
173
+ return;
174
+ }
175
+ if (agent.keyInFile && !opts.yes) {
176
+ if (!process.stdin.isTTY)
177
+ return fail(`${agent.name} needs your API key stored in ${shown(agent.file)}. Re-run with --yes to allow that.`);
178
+ info(chalk.yellow(`${agent.name} will use WaveSpeed instead of its own login for every session, and your API key will be stored in ${shown(agent.file)}.`));
179
+ info(chalk.gray(`Undo any time with: wavespeed setup ${agent.id} --undo`));
180
+ if (!(await confirm({ message: 'Continue?', default: false })))
181
+ return fail('Cancelled; nothing written.');
182
+ }
183
+ writeFile(agent.file, next, agent.keyInFile);
184
+ if (agent.kind === 'json')
185
+ userConfig.set('setup', { ...(userConfig.get('setup') ?? {}), [agent.id]: { file: agent.file, previous } });
186
+ const check = opts.check ? await selfCheck(apiKey, model) : null;
187
+ if (opts.json) {
188
+ emitJson({ agent: agent.id, model, file: agent.file, written: true, check: check ? { ok: check.ok, detail: check.detail } : 'skipped', needs_env: agent.keyInFile ? null : KEY_ENV });
189
+ if (check && !check.ok)
190
+ process.exit(1);
191
+ return;
192
+ }
193
+ info(chalk.green(`Updated ${shown(agent.file)}`) + chalk.gray(` (model ${model})`));
194
+ if (check)
195
+ info(check.ok ? chalk.green(`Verified: streamed reply from ${model} (${check.detail}).`) : chalk.red(`Check failed: ${check.detail}`));
196
+ if (!agent.keyInFile) {
197
+ info(`\nThe key is read from ${chalk.bold(KEY_ENV)}; set it in the shell that launches ${agent.command}:\n ${chalk.cyan(envHint())}\n Keys: https://wavespeed.ai/accesskey`);
198
+ }
199
+ info(`\nStart it with ${chalk.cyan(agent.command)}. Revert with ${chalk.cyan(`wavespeed setup ${agent.id} --undo`)}.`);
200
+ if (check && !check.ok)
201
+ process.exit(1);
202
+ });
203
+ }
@@ -28,6 +28,15 @@ async function download(url) {
28
28
  throw new Error(`download of ${url} failed: HTTP ${res.status}`);
29
29
  return Buffer.from(await res.arrayBuffer());
30
30
  }
31
+ // process.execPath may be a symlink (e.g. /usr/local/bin/wavespeed -> Cellar/...); act on the real file.
32
+ function realExecPath() {
33
+ try {
34
+ return fs.realpathSync(process.execPath);
35
+ }
36
+ catch {
37
+ return process.execPath;
38
+ }
39
+ }
31
40
  /** Replace the running executable with the release asset, after checking its SHA-256. */
32
41
  async function replaceExecutable(version) {
33
42
  const asset = assetName();
@@ -41,7 +50,7 @@ async function replaceExecutable(version) {
41
50
  const actual = crypto.createHash('sha256').update(bin).digest('hex');
42
51
  if (!expected || expected.toLowerCase() !== actual)
43
52
  throw new Error(`checksum mismatch for ${asset}; not installing`);
44
- const exe = process.execPath;
53
+ const exe = realExecPath();
45
54
  const next = `${exe}.new`;
46
55
  fs.writeFileSync(next, bin, { mode: 0o755 });
47
56
  if (process.platform === 'win32') {
@@ -163,7 +172,7 @@ export function registerUpgrade(program) {
163
172
  info(chalk.gray(`Upgrading v${VERSION} → v${latest}…`));
164
173
  if (IS_STANDALONE) {
165
174
  // Installed by a package manager: let it own the upgrade.
166
- const managed = /[\\/](Cellar|homebrew|Homebrew|WinGet|scoop)[\\/]/.exec(process.execPath);
175
+ const managed = /[\\/](Cellar|homebrew|Homebrew|WinGet|scoop)[\\/]/.exec(realExecPath());
167
176
  if (managed) {
168
177
  return fail(`This copy is managed by ${managed[1]}; upgrade it with your package manager (e.g. brew upgrade wavespeed).`);
169
178
  }
@@ -171,7 +180,7 @@ export function registerUpgrade(program) {
171
180
  await replaceExecutable(latest);
172
181
  }
173
182
  catch (err) {
174
- return fail(`Could not replace ${process.execPath}: ${err.message ?? String(err)}`);
183
+ return fail(`Could not replace ${realExecPath()}: ${err.message ?? String(err)}`);
175
184
  }
176
185
  }
177
186
  else {
@@ -0,0 +1,148 @@
1
+ // Pure helpers for `wavespeed setup`: build and revert the config edits for each
2
+ // coding agent. No file or network access here, so everything is unit-testable.
3
+ // Endpoint/protocol choices mirror api-dashboard/app/[locale]/llm/agents/data.ts.
4
+ export const LLM_BASE_URL = 'https://llm.wavespeed.ai/v1';
5
+ export const CLAUDE_BASE_URL = 'https://llm.wavespeed.ai';
6
+ export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash-1017';
7
+ export const KEY_ENV = 'WAVESPEED_API_KEY';
8
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
9
+ export function validateModel(model) {
10
+ if (!MODEL_PATTERN.test(model)) {
11
+ throw new Error('Model may only contain letters, numbers, _ . / : - (max 256 characters)');
12
+ }
13
+ return model;
14
+ }
15
+ function isObj(v) {
16
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
17
+ }
18
+ /** Set each path, remembering what was there so `revertJson` can put it back. */
19
+ export function applyJson(doc, edits) {
20
+ const previous = [];
21
+ for (const { path, value } of edits) {
22
+ let cur = doc;
23
+ for (const key of path.slice(0, -1)) {
24
+ if (!isObj(cur[key]))
25
+ cur[key] = {};
26
+ cur = cur[key];
27
+ }
28
+ const last = path[path.length - 1];
29
+ const existed = Object.prototype.hasOwnProperty.call(cur, last);
30
+ previous.push({ path, existed, value: existed ? cur[last] : undefined });
31
+ cur[last] = value;
32
+ }
33
+ return { doc, previous };
34
+ }
35
+ export function revertJson(doc, previous) {
36
+ for (const { path, existed, value } of [...previous].reverse()) {
37
+ const parents = [doc];
38
+ let cur = doc;
39
+ for (const key of path.slice(0, -1)) {
40
+ cur = isObj(cur) ? cur[key] : undefined;
41
+ if (!isObj(cur))
42
+ break;
43
+ parents.push(cur);
44
+ }
45
+ if (parents.length !== path.length)
46
+ continue; // structure changed since setup; leave it
47
+ const holder = parents[parents.length - 1];
48
+ const last = path[path.length - 1];
49
+ if (existed)
50
+ holder[last] = value;
51
+ else
52
+ delete holder[last];
53
+ // Drop containers we created and that are now empty.
54
+ for (let i = parents.length - 1; i > 0; i--) {
55
+ if (Object.keys(parents[i]).length === 0)
56
+ delete parents[i - 1][path[i - 1]];
57
+ else
58
+ break;
59
+ }
60
+ }
61
+ return doc;
62
+ }
63
+ export function claudeEdits(model, apiKey) {
64
+ const m = validateModel(model);
65
+ return [
66
+ ['ANTHROPIC_AUTH_TOKEN', apiKey],
67
+ ['ANTHROPIC_BASE_URL', CLAUDE_BASE_URL],
68
+ ['ANTHROPIC_MODEL', m],
69
+ ['ANTHROPIC_DEFAULT_OPUS_MODEL', m],
70
+ ['ANTHROPIC_DEFAULT_SONNET_MODEL', m],
71
+ ['ANTHROPIC_DEFAULT_HAIKU_MODEL', m],
72
+ ].map(([k, v]) => ({ path: ['env', k], value: v }));
73
+ }
74
+ export function opencodeEdits(model) {
75
+ const m = validateModel(model);
76
+ return [
77
+ {
78
+ path: ['provider', 'wavespeed'],
79
+ value: {
80
+ npm: '@ai-sdk/openai-compatible',
81
+ name: 'WaveSpeedAI',
82
+ options: { baseURL: LLM_BASE_URL, apiKey: `{env:${KEY_ENV}}` },
83
+ models: { [m]: { name: m, tool_call: true } },
84
+ },
85
+ },
86
+ { path: ['model'], value: `wavespeed/${m}` },
87
+ ];
88
+ }
89
+ // ---------- Codex (TOML, edited as text so the user's comments survive) ----------
90
+ const TOP_OPEN = '# >>> wavespeed (managed by `wavespeed setup`) >>>';
91
+ const TOP_CLOSE = '# <<< wavespeed <<<';
92
+ const TAIL_OPEN = '# >>> wavespeed provider >>>';
93
+ const TAIL_CLOSE = '# <<< wavespeed provider <<<';
94
+ const DISABLED = '# [wavespeed-setup disabled] ';
95
+ function stripBlock(text, open, close) {
96
+ const lines = text.split('\n');
97
+ const out = [];
98
+ let skipping = false;
99
+ for (const line of lines) {
100
+ if (line.trim() === open) {
101
+ skipping = true;
102
+ continue;
103
+ }
104
+ if (skipping) {
105
+ if (line.trim() === close)
106
+ skipping = false;
107
+ continue;
108
+ }
109
+ out.push(line);
110
+ }
111
+ return out.join('\n');
112
+ }
113
+ export function revertCodexToml(text) {
114
+ let t = stripBlock(text, TOP_OPEN, TOP_CLOSE);
115
+ t = stripBlock(t, TAIL_OPEN, TAIL_CLOSE);
116
+ t = t.split('\n').map((l) => (l.startsWith(DISABLED) ? l.slice(DISABLED.length) : l)).join('\n');
117
+ t = t.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '');
118
+ return t === '' ? '' : t + '\n';
119
+ }
120
+ export function applyCodexToml(text, model) {
121
+ const m = validateModel(model);
122
+ const base = revertCodexToml(text);
123
+ if (/^\s*\[model_providers\.wavespeed\]/m.test(base)) {
124
+ throw new Error('config.toml already defines [model_providers.wavespeed] outside wavespeed setup; edit it by hand');
125
+ }
126
+ // Top-level keys live before the first table header; comment out the ones we replace.
127
+ const lines = base.split('\n');
128
+ let inTop = true;
129
+ const edited = lines.map((l) => {
130
+ if (/^\s*\[/.test(l))
131
+ inTop = false;
132
+ return inTop && /^\s*(model|model_provider)\s*=/.test(l) ? DISABLED + l : l;
133
+ });
134
+ const top = [TOP_OPEN, `model = ${JSON.stringify(m)}`, 'model_provider = "wavespeed"', TOP_CLOSE, ''];
135
+ const tail = [
136
+ '',
137
+ TAIL_OPEN,
138
+ '[model_providers.wavespeed]',
139
+ 'name = "WaveSpeedAI"',
140
+ `base_url = "${LLM_BASE_URL}"`,
141
+ `env_key = "${KEY_ENV}"`,
142
+ 'wire_api = "responses"',
143
+ TAIL_CLOSE,
144
+ '',
145
+ ];
146
+ const body = edited.join('\n').replace(/\n+$/, '');
147
+ return [...top, body, ...tail].join('\n').replace(/\n{3,}/g, '\n\n');
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"