@wavespeed/cli 0.4.15 → 0.4.17

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 [agent] Point claude, codex, opencode, openclaw, hermes or cline 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,256 @@
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 { spawnSync } from 'node:child_process';
6
+ import { confirm } from '@inquirer/prompts';
7
+ import JSON5 from 'json5';
8
+ import { getApiKey, userConfig } from '../lib/config.js';
9
+ import { clientAttributionHeaders } from '../lib/client-headers.js';
10
+ import { emitJson, emitJsonError, info, setJsonMode } from '../lib/log.js';
11
+ import { DEFAULT_MODEL, KEY_ENV, LLM_BASE_URL, applyCodexToml, applyHermesYaml, applyJson, claudeEdits, openclawEdits, opencodeEdits, revertCodexToml, revertHermesYaml, revertJson, validateModel, } from '../lib/setup-agents.js';
12
+ const home = os.homedir();
13
+ const AGENTS = [
14
+ { id: 'claude', name: 'Claude Code', file: path.join(home, '.claude', 'settings.json'), command: 'claude', kind: 'json', keyInFile: true },
15
+ { id: 'codex', name: 'Codex', file: path.join(home, '.codex', 'config.toml'), command: 'codex', kind: 'toml', keyInFile: false },
16
+ { id: 'opencode', name: 'OpenCode', file: path.join(home, '.config', 'opencode', 'opencode.json'), command: 'opencode', kind: 'json', keyInFile: false },
17
+ { id: 'openclaw', name: 'OpenClaw', file: path.join(home, '.openclaw', 'openclaw.json'), command: 'openclaw', kind: 'json5', keyInFile: false },
18
+ { id: 'hermes', name: 'Hermes', file: path.join(home, '.hermes', 'config.yaml'), command: 'hermes', kind: 'yaml', keyInFile: false },
19
+ // Cline's CLI keeps provider settings (including the key) in this file; we write it only through `cline auth`.
20
+ { id: 'cline', name: 'Cline', file: path.join(home, '.cline', 'data', 'settings', 'providers.json'), command: 'cline', kind: 'cline', keyInFile: true },
21
+ ];
22
+ const shown = (p) => p.replace(home, '~');
23
+ function onPath(cmd) {
24
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
25
+ return (process.env.PATH ?? '').split(path.delimiter).some((d) => d && exts.some((e) => fs.existsSync(path.join(d, cmd + e))));
26
+ }
27
+ /** One streamed request to the same endpoint family the agents use; proves key, model and streaming work. */
28
+ export async function selfCheck(apiKey, model) {
29
+ const base = process.env.WAVESPEED_LLM_BASE_URL || LLM_BASE_URL;
30
+ try {
31
+ const res = await fetch(`${base}/chat/completions`, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() },
34
+ // Reasoning models spend tokens thinking first; leave room for a visible reply.
35
+ body: JSON.stringify({ model, stream: true, max_tokens: 256, messages: [{ role: 'user', content: 'Reply with the single word READY.' }] }),
36
+ signal: AbortSignal.timeout(60_000),
37
+ });
38
+ if (!res.ok)
39
+ return { ok: false, detail: `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}` };
40
+ const text = await res.text();
41
+ const chunks = text.split('\n').filter((l) => l.startsWith('data: ') && !l.includes('[DONE]'));
42
+ if (chunks.length === 0)
43
+ return { ok: false, detail: 'stream returned no data' };
44
+ if (chunks.some((l) => l.includes('"error"')))
45
+ return { ok: false, detail: 'stream contained an error' };
46
+ return { ok: true, detail: `${chunks.length} stream chunks` };
47
+ }
48
+ catch (err) {
49
+ return { ok: false, detail: err.message ?? String(err) };
50
+ }
51
+ }
52
+ function readJson(file, json5 = false) {
53
+ if (!fs.existsSync(file))
54
+ return {};
55
+ const raw = fs.readFileSync(file, 'utf8');
56
+ if (raw.trim() === '')
57
+ return {};
58
+ try {
59
+ const v = json5 ? JSON5.parse(raw) : JSON.parse(raw);
60
+ if (v && typeof v === 'object' && !Array.isArray(v))
61
+ return v;
62
+ }
63
+ catch {
64
+ /* fall through */
65
+ }
66
+ 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`);
67
+ }
68
+ function writeFile(file, content, secret) {
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ const bak = `${file}.wavespeed.bak`;
71
+ if (fs.existsSync(file) && !fs.existsSync(bak))
72
+ fs.copyFileSync(file, bak);
73
+ fs.writeFileSync(file, content, secret ? { mode: 0o600 } : undefined);
74
+ if (secret)
75
+ fs.chmodSync(file, 0o600);
76
+ }
77
+ function envHint() {
78
+ return process.platform === 'win32'
79
+ ? `setx ${KEY_ENV} "<your key>" (then open a new terminal)`
80
+ : `export ${KEY_ENV}="<your key>" # add to ~/.zshrc or ~/.bashrc`;
81
+ }
82
+ export function registerSetup(program) {
83
+ program
84
+ .command('setup [agent]')
85
+ .description('Point a coding agent (claude, codex, opencode, openclaw, hermes, cline) at the WaveSpeed LLM gateway')
86
+ .option('--model <id>', `Model to use (default: ${DEFAULT_MODEL})`)
87
+ .option('--dry-run', 'Show what would change without writing anything')
88
+ .option('--undo', "Restore the agent's config to what it was before setup")
89
+ .option('-y, --yes', 'Do not ask for confirmation')
90
+ .option('--no-check', 'Skip the live request that verifies the setup')
91
+ .option('--json', 'Emit JSON')
92
+ .action(async (agentId, opts) => {
93
+ setJsonMode(!!opts.json);
94
+ const fail = (message) => {
95
+ if (opts.json)
96
+ emitJsonError(message);
97
+ else
98
+ console.error(chalk.red('Error: ') + message);
99
+ process.exit(1);
100
+ };
101
+ if (!agentId) {
102
+ const rows = AGENTS.map((a) => ({ agent: a.id, name: a.name, config: shown(a.file), installed: onPath(a.command) }));
103
+ if (opts.json)
104
+ return emitJson(rows);
105
+ info(chalk.bold('Agents:'));
106
+ for (const r of rows)
107
+ 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')}`);
108
+ info('\nRun ' + chalk.cyan('wavespeed setup <agent>') + ' (add --dry-run to preview, --undo to revert).');
109
+ return;
110
+ }
111
+ const agent = AGENTS.find((a) => a.id === agentId);
112
+ if (!agent)
113
+ return fail(`Unknown agent "${agentId}". Choose one of: ${AGENTS.map((a) => a.id).join(', ')}`);
114
+ // ----- undo -----
115
+ if (opts.undo) {
116
+ try {
117
+ const state = userConfig.get('setup')?.[agent.id];
118
+ const forget = () => {
119
+ const rest = { ...(userConfig.get('setup') ?? {}) };
120
+ delete rest[agent.id];
121
+ userConfig.set('setup', rest);
122
+ };
123
+ if (agent.kind === 'toml') {
124
+ if (!fs.existsSync(agent.file))
125
+ return fail(`${shown(agent.file)} does not exist; nothing to undo`);
126
+ const before = fs.readFileSync(agent.file, 'utf8');
127
+ const after = revertCodexToml(before);
128
+ if (!opts.dryRun && after !== before)
129
+ fs.writeFileSync(agent.file, after);
130
+ }
131
+ else if (!state) {
132
+ return fail(`No recorded ${agent.name} setup to undo`);
133
+ }
134
+ else if (agent.kind === 'cline') {
135
+ const bak = `${agent.file}.wavespeed.bak`;
136
+ if (!opts.dryRun) {
137
+ if (state.previous.existed && fs.existsSync(bak))
138
+ fs.copyFileSync(bak, agent.file);
139
+ else
140
+ fs.rmSync(agent.file, { force: true });
141
+ fs.rmSync(bak, { force: true });
142
+ forget();
143
+ }
144
+ }
145
+ else if (agent.kind === 'yaml') {
146
+ const after = revertHermesYaml(fs.readFileSync(state.file, 'utf8'), state.previous);
147
+ if (!opts.dryRun) {
148
+ fs.writeFileSync(state.file, after);
149
+ forget();
150
+ }
151
+ }
152
+ else {
153
+ const doc = readJson(state.file, agent.kind === 'json5');
154
+ const after = JSON.stringify(revertJson(doc, state.previous), null, 2) + '\n';
155
+ if (!opts.dryRun) {
156
+ fs.writeFileSync(state.file, after);
157
+ forget();
158
+ }
159
+ }
160
+ }
161
+ catch (err) {
162
+ return fail(err.message ?? String(err));
163
+ }
164
+ if (opts.json)
165
+ return emitJson({ agent: agent.id, undone: !opts.dryRun, file: agent.file });
166
+ info(chalk.green(opts.dryRun ? `Would restore ${shown(agent.file)}.` : `Restored ${shown(agent.file)}.`));
167
+ return;
168
+ }
169
+ // ----- apply -----
170
+ const apiKey = getApiKey();
171
+ if (!apiKey)
172
+ return fail('No API key. Run `wavespeed login` first.');
173
+ let model;
174
+ try {
175
+ model = validateModel(opts.model ?? DEFAULT_MODEL);
176
+ }
177
+ catch (err) {
178
+ return fail(err.message);
179
+ }
180
+ let next = '';
181
+ let previous = [];
182
+ try {
183
+ if (agent.kind === 'toml') {
184
+ next = applyCodexToml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
185
+ }
186
+ else if (agent.kind === 'yaml') {
187
+ const r = applyHermesYaml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
188
+ next = r.text;
189
+ previous = r.previous;
190
+ }
191
+ else if (agent.kind === 'cline') {
192
+ previous = { existed: fs.existsSync(agent.file) };
193
+ if (!onPath('cline'))
194
+ throw new Error('Cline CLI not found. Install it with `npm i -g cline`. (The VS Code extension has no config file; set it up in its settings UI.)');
195
+ }
196
+ else {
197
+ const base = readJson(agent.file, agent.kind === 'json5');
198
+ const edits = agent.id === 'claude' ? claudeEdits(model, apiKey) : agent.id === 'openclaw' ? openclawEdits(model, base) : opencodeEdits(model);
199
+ const r = applyJson(base, edits);
200
+ previous = r.previous;
201
+ next = JSON.stringify(r.doc, null, 2) + '\n';
202
+ }
203
+ }
204
+ catch (err) {
205
+ return fail(err.message ?? String(err));
206
+ }
207
+ const exists = fs.existsSync(agent.file);
208
+ if (opts.dryRun) {
209
+ if (opts.json)
210
+ return emitJson({ agent: agent.id, model, file: agent.file, dry_run: true, will_create: !exists });
211
+ 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' : ''})`));
212
+ info(chalk.gray('Nothing written.'));
213
+ return;
214
+ }
215
+ if (agent.keyInFile && !opts.yes) {
216
+ if (!process.stdin.isTTY)
217
+ return fail(`${agent.name} needs your API key stored in ${shown(agent.file)}. Re-run with --yes to allow that.`);
218
+ 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)}.`));
219
+ info(chalk.gray(`Undo any time with: wavespeed setup ${agent.id} --undo`));
220
+ if (!(await confirm({ message: 'Continue?', default: false })))
221
+ return fail('Cancelled; nothing written.');
222
+ }
223
+ if (agent.kind === 'cline') {
224
+ const bak = `${agent.file}.wavespeed.bak`;
225
+ if (fs.existsSync(agent.file) && !fs.existsSync(bak))
226
+ fs.copyFileSync(agent.file, bak);
227
+ const r = spawnSync('cline', ['auth', '-p', 'openai-compatible', '-k', apiKey, '-b', LLM_BASE_URL, '-m', model], { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8', shell: process.platform === 'win32' });
228
+ if (r.status !== 0)
229
+ return fail(`cline auth failed: ${(r.stderr || r.error?.message || '').trim().slice(0, 300)}`);
230
+ }
231
+ else {
232
+ const hadComments = agent.kind === 'json5' && exists && /^\s*\/\/|\/\*/m.test(fs.readFileSync(agent.file, 'utf8'));
233
+ writeFile(agent.file, next, agent.keyInFile);
234
+ if (hadComments)
235
+ info(chalk.yellow(`Comments in ${shown(agent.file)} were not kept; the original is saved as ${shown(agent.file)}.wavespeed.bak`));
236
+ }
237
+ if (agent.kind !== 'toml')
238
+ userConfig.set('setup', { ...(userConfig.get('setup') ?? {}), [agent.id]: { file: agent.file, previous } });
239
+ const check = opts.check ? await selfCheck(apiKey, model) : null;
240
+ if (opts.json) {
241
+ 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 });
242
+ if (check && !check.ok)
243
+ process.exit(1);
244
+ return;
245
+ }
246
+ info(chalk.green(`Updated ${shown(agent.file)}`) + chalk.gray(` (model ${model})`));
247
+ if (check)
248
+ info(check.ok ? chalk.green(`Verified: streamed reply from ${model} (${check.detail}).`) : chalk.red(`Check failed: ${check.detail}`));
249
+ if (!agent.keyInFile) {
250
+ 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`);
251
+ }
252
+ info(`\nStart it with ${chalk.cyan(agent.command)}. Revert with ${chalk.cyan(`wavespeed setup ${agent.id} --undo`)}.`);
253
+ if (check && !check.ok)
254
+ process.exit(1);
255
+ });
256
+ }
@@ -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,321 @@
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
+ import { parseDocument } from 'yaml';
16
+ function isObj(v) {
17
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
18
+ }
19
+ /** Set each path, remembering what was there so `revertJson` can put it back. */
20
+ export function applyJson(doc, edits) {
21
+ const previous = [];
22
+ for (const { path, value } of edits) {
23
+ let cur = doc;
24
+ for (const key of path.slice(0, -1)) {
25
+ if (!isObj(cur[key]))
26
+ cur[key] = {};
27
+ cur = cur[key];
28
+ }
29
+ const last = path[path.length - 1];
30
+ const existed = Object.prototype.hasOwnProperty.call(cur, last);
31
+ previous.push({ path, existed, value: existed ? cur[last] : undefined });
32
+ cur[last] = value;
33
+ }
34
+ return { doc, previous };
35
+ }
36
+ export function revertJson(doc, previous) {
37
+ for (const { path, existed, value } of [...previous].reverse()) {
38
+ const parents = [doc];
39
+ let cur = doc;
40
+ for (const key of path.slice(0, -1)) {
41
+ cur = isObj(cur) ? cur[key] : undefined;
42
+ if (!isObj(cur))
43
+ break;
44
+ parents.push(cur);
45
+ }
46
+ if (parents.length !== path.length)
47
+ continue; // structure changed since setup; leave it
48
+ const holder = parents[parents.length - 1];
49
+ const last = path[path.length - 1];
50
+ if (existed)
51
+ holder[last] = value;
52
+ else
53
+ delete holder[last];
54
+ // Drop containers we created and that are now empty.
55
+ for (let i = parents.length - 1; i > 0; i--) {
56
+ if (Object.keys(parents[i]).length === 0)
57
+ delete parents[i - 1][path[i - 1]];
58
+ else
59
+ break;
60
+ }
61
+ }
62
+ return doc;
63
+ }
64
+ export function claudeEdits(model, apiKey) {
65
+ const m = validateModel(model);
66
+ return [
67
+ ['ANTHROPIC_AUTH_TOKEN', apiKey],
68
+ ['ANTHROPIC_BASE_URL', CLAUDE_BASE_URL],
69
+ ['ANTHROPIC_MODEL', m],
70
+ ['ANTHROPIC_DEFAULT_OPUS_MODEL', m],
71
+ ['ANTHROPIC_DEFAULT_SONNET_MODEL', m],
72
+ ['ANTHROPIC_DEFAULT_HAIKU_MODEL', m],
73
+ ].map(([k, v]) => ({ path: ['env', k], value: v }));
74
+ }
75
+ export function opencodeEdits(model) {
76
+ const m = validateModel(model);
77
+ return [
78
+ {
79
+ path: ['provider', 'wavespeed'],
80
+ value: {
81
+ npm: '@ai-sdk/openai-compatible',
82
+ name: 'WaveSpeedAI',
83
+ options: { baseURL: LLM_BASE_URL, apiKey: `{env:${KEY_ENV}}` },
84
+ models: { [m]: { name: m, tool_call: true } },
85
+ },
86
+ },
87
+ { path: ['model'], value: `wavespeed/${m}` },
88
+ ];
89
+ }
90
+ export function openclawEdits(model, doc) {
91
+ const m = validateModel(model);
92
+ const edits = [
93
+ { path: ['agents', 'defaults', 'model', 'primary'], value: `wavespeed/${m}` },
94
+ { path: ['models', 'mode'], value: 'merge' },
95
+ {
96
+ path: ['models', 'providers', 'wavespeed'],
97
+ value: {
98
+ baseUrl: LLM_BASE_URL,
99
+ apiKey: `\${${KEY_ENV}}`,
100
+ api: 'openai-completions',
101
+ models: [{ id: m, name: m, contextWindow: 200000, maxTokens: 32000 }],
102
+ },
103
+ },
104
+ ];
105
+ // A user-maintained model allowlist would otherwise reject the new model.
106
+ const agents = doc.agents;
107
+ const defaults = agents?.defaults;
108
+ if (isObj(defaults?.models))
109
+ edits.push({ path: ['agents', 'defaults', 'models', `wavespeed/${m}`], value: {} });
110
+ return edits;
111
+ }
112
+ const lineStart = (t, off) => t.lastIndexOf('\n', off - 1) + 1;
113
+ const lineEnd = (t, off) => {
114
+ const i = t.indexOf('\n', off);
115
+ return i < 0 ? t.length : i + 1;
116
+ };
117
+ /** End of the line holding the last real character before `off` (a node's range can run over trailing blank lines). */
118
+ const contentLineEnd = (t, off) => lineEnd(t, t.slice(0, off).trimEnd().length - 1);
119
+ function applyEdits(text, edits) {
120
+ return [...edits].sort((x, y) => y.start - x.start).reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), text);
121
+ }
122
+ function entryLines(pad) {
123
+ return [
124
+ `${pad}- name: wavespeed`,
125
+ `${pad} base_url: ${LLM_BASE_URL}`,
126
+ `${pad} key_env: ${KEY_ENV}`,
127
+ `${pad} api_mode: chat_completions`,
128
+ '',
129
+ ].join('\n');
130
+ }
131
+ function dashPad(text, itemStart) {
132
+ const dash = text.lastIndexOf('-', itemStart - 1);
133
+ return { dash, pad: ' '.repeat(dash - lineStart(text, dash)) };
134
+ }
135
+ function parseYaml(text) {
136
+ const doc = parseDocument(text);
137
+ if (doc.errors.length)
138
+ throw new Error(`config.yaml is not valid YAML: ${doc.errors[0].message}`);
139
+ return doc;
140
+ }
141
+ export function applyHermesYaml(input, model) {
142
+ const m = validateModel(model);
143
+ let text = input;
144
+ if (text !== '' && !text.endsWith('\n'))
145
+ text += '\n';
146
+ const doc = parseYaml(text);
147
+ const edits = [];
148
+ let tail = '';
149
+ const previous = { hadModelBlock: false, hadList: false };
150
+ const want = { default: JSON.stringify(m), provider: '"wavespeed"' };
151
+ const root = doc.contents;
152
+ const modelNode = root?.get?.('model', true);
153
+ if (modelNode === undefined || modelNode === null) {
154
+ tail += `model:\n default: ${want.default}\n provider: ${want.provider}\n`;
155
+ }
156
+ else if (modelNode.items) {
157
+ previous.hadModelBlock = true;
158
+ const missing = [];
159
+ for (const key of ['default', 'provider']) {
160
+ const v = modelNode.get?.(key, true);
161
+ if (v && v.range) {
162
+ const raw = text.slice(v.range[0], v.range[1]);
163
+ if (key === 'default')
164
+ previous.defaultRaw = raw;
165
+ else
166
+ previous.providerRaw = raw;
167
+ edits.push({ start: v.range[0], end: v.range[1], text: want[key] });
168
+ }
169
+ else {
170
+ missing.push(key);
171
+ }
172
+ }
173
+ if (missing.length) {
174
+ const first = modelNode.items[0]?.key?.range?.[0];
175
+ if (first === undefined)
176
+ throw new Error('model: block in config.yaml is empty or inline; edit it by hand');
177
+ const pad = ' '.repeat(first - lineStart(text, first));
178
+ edits.push({ start: lineStart(text, first), end: lineStart(text, first), text: missing.map((k) => `${pad}${k}: ${want[k]}\n`).join('') });
179
+ }
180
+ }
181
+ else {
182
+ throw new Error('model: in config.yaml is not a block; edit it by hand');
183
+ }
184
+ const list = root?.get?.('custom_providers', true);
185
+ if (list === undefined || list === null) {
186
+ tail += `custom_providers:\n${entryLines(' ')}`;
187
+ }
188
+ else if (list.items && list.flow !== true) {
189
+ previous.hadList = true;
190
+ const mine = list.items.find((it) => it?.get?.('name') === 'wavespeed');
191
+ if (mine?.range) {
192
+ const { dash, pad } = dashPad(text, mine.range[0]);
193
+ const end = contentLineEnd(text, mine.range[1]);
194
+ previous.entryRaw = text.slice(lineStart(text, dash), end);
195
+ edits.push({ start: lineStart(text, dash), end, text: entryLines(pad) });
196
+ }
197
+ else if (list.items.length > 0) {
198
+ const last = list.items[list.items.length - 1];
199
+ const { pad } = dashPad(text, list.items[0].range[0]);
200
+ const at = contentLineEnd(text, last.range[1]);
201
+ edits.push({ start: at, end: at, text: entryLines(pad) });
202
+ }
203
+ else {
204
+ throw new Error('custom_providers: in config.yaml is empty; edit it by hand');
205
+ }
206
+ }
207
+ else if (list.range && list.value === null) {
208
+ // `custom_providers:` with nothing under it
209
+ previous.hadList = false;
210
+ const at = lineEnd(text, list.range[0]);
211
+ edits.push({ start: at, end: at, text: entryLines(' ') });
212
+ }
213
+ else {
214
+ throw new Error('custom_providers: in config.yaml is not a block list; edit it by hand');
215
+ }
216
+ return { text: applyEdits(text, edits) + tail, previous };
217
+ }
218
+ export function revertHermesYaml(input, previous) {
219
+ let text = input;
220
+ if (!previous.hadModelBlock) {
221
+ text = text.replace(/^model:\n {2}default: "[^\n]*"\n {2}provider: "wavespeed"\n/m, '');
222
+ }
223
+ else {
224
+ const doc = parseYaml(text);
225
+ const modelNode = doc.contents?.get?.('model', true);
226
+ const edits = [];
227
+ for (const [key, raw] of [['default', previous.defaultRaw], ['provider', previous.providerRaw]]) {
228
+ const v = modelNode?.get?.(key, true);
229
+ if (!v?.range)
230
+ continue;
231
+ if (raw !== undefined) {
232
+ edits.push({ start: v.range[0], end: v.range[1], text: raw });
233
+ }
234
+ else {
235
+ const pair = modelNode.items.find((p) => p.key?.value === key);
236
+ const ks = pair.key.range[0];
237
+ edits.push({ start: lineStart(text, ks), end: lineEnd(text, v.range[1]), text: '' });
238
+ }
239
+ }
240
+ text = applyEdits(text, edits);
241
+ }
242
+ const doc = parseYaml(text);
243
+ const list = doc.contents?.get?.('custom_providers', true);
244
+ if (list?.items) {
245
+ const mine = list.items.find((it) => it?.get?.('name') === 'wavespeed');
246
+ if (mine?.range) {
247
+ const { dash } = dashPad(text, mine.range[0]);
248
+ const start = lineStart(text, dash);
249
+ const end = contentLineEnd(text, mine.range[1]);
250
+ text = text.slice(0, start) + (previous.entryRaw ?? '') + text.slice(end);
251
+ }
252
+ if (!previous.hadList) {
253
+ const again = parseYaml(text);
254
+ const l2 = again.contents?.get?.('custom_providers', true);
255
+ if (l2?.items?.length === 0 || (l2 && l2.items === undefined)) {
256
+ text = text.replace(/^custom_providers:[ \t]*\n?/m, '');
257
+ }
258
+ }
259
+ }
260
+ return text;
261
+ }
262
+ // ---------- Codex (TOML, edited as text so the user's comments survive) ----------
263
+ const TOP_OPEN = '# >>> wavespeed (managed by `wavespeed setup`) >>>';
264
+ const TOP_CLOSE = '# <<< wavespeed <<<';
265
+ const TAIL_OPEN = '# >>> wavespeed provider >>>';
266
+ const TAIL_CLOSE = '# <<< wavespeed provider <<<';
267
+ const DISABLED = '# [wavespeed-setup disabled] ';
268
+ function stripBlock(text, open, close) {
269
+ const lines = text.split('\n');
270
+ const out = [];
271
+ let skipping = false;
272
+ for (const line of lines) {
273
+ if (line.trim() === open) {
274
+ skipping = true;
275
+ continue;
276
+ }
277
+ if (skipping) {
278
+ if (line.trim() === close)
279
+ skipping = false;
280
+ continue;
281
+ }
282
+ out.push(line);
283
+ }
284
+ return out.join('\n');
285
+ }
286
+ export function revertCodexToml(text) {
287
+ let t = stripBlock(text, TOP_OPEN, TOP_CLOSE);
288
+ t = stripBlock(t, TAIL_OPEN, TAIL_CLOSE);
289
+ t = t.split('\n').map((l) => (l.startsWith(DISABLED) ? l.slice(DISABLED.length) : l)).join('\n');
290
+ t = t.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '');
291
+ return t === '' ? '' : t + '\n';
292
+ }
293
+ export function applyCodexToml(text, model) {
294
+ const m = validateModel(model);
295
+ const base = revertCodexToml(text);
296
+ if (/^\s*\[model_providers\.wavespeed\]/m.test(base)) {
297
+ throw new Error('config.toml already defines [model_providers.wavespeed] outside wavespeed setup; edit it by hand');
298
+ }
299
+ // Top-level keys live before the first table header; comment out the ones we replace.
300
+ const lines = base.split('\n');
301
+ let inTop = true;
302
+ const edited = lines.map((l) => {
303
+ if (/^\s*\[/.test(l))
304
+ inTop = false;
305
+ return inTop && /^\s*(model|model_provider)\s*=/.test(l) ? DISABLED + l : l;
306
+ });
307
+ const top = [TOP_OPEN, `model = ${JSON.stringify(m)}`, 'model_provider = "wavespeed"', TOP_CLOSE, ''];
308
+ const tail = [
309
+ '',
310
+ TAIL_OPEN,
311
+ '[model_providers.wavespeed]',
312
+ 'name = "WaveSpeedAI"',
313
+ `base_url = "${LLM_BASE_URL}"`,
314
+ `env_key = "${KEY_ENV}"`,
315
+ 'wire_api = "responses"',
316
+ TAIL_CLOSE,
317
+ '',
318
+ ];
319
+ const body = edited.join('\n').replace(/\n+$/, '');
320
+ return [...top, body, ...tail].join('\n').replace(/\n{3,}/g, '\n\n');
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -54,9 +54,11 @@
54
54
  "clipboardy": "^4.0.0",
55
55
  "commander": "^12.1.0",
56
56
  "conf": "^13.0.0",
57
+ "json5": "^2.2.3",
57
58
  "open": "^10.1.0",
58
59
  "ora": "^8.1.0",
59
- "wavespeed": "^0.3.4"
60
+ "wavespeed": "^0.3.4",
61
+ "yaml": "^2.9.1"
60
62
  },
61
63
  "devDependencies": {
62
64
  "@types/node": "^20.14.0",