@wavespeed/cli 0.4.16 → 0.4.18

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,7 +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
+ wavespeed setup [agent] Point claude, codex, opencode, openclaw, hermes, cline, qwen or miniagent at the WaveSpeed LLM gateway (--dry-run, --undo, --model)
145
145
 
146
146
  # generation
147
147
  wavespeed run [model|alias] -p "…" Run any model or alias (uses defaultModel if omitted)
@@ -2,16 +2,24 @@ import chalk from 'chalk';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { confirm } from '@inquirer/prompts';
7
+ import JSON5 from 'json5';
6
8
  import { getApiKey, userConfig } from '../lib/config.js';
7
9
  import { clientAttributionHeaders } from '../lib/client-headers.js';
8
10
  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';
11
+ import { DEFAULT_MODEL, KEY_ENV, LLM_BASE_URL, applyCodexToml, applyHermesYaml, applyJson, applyYamlScalars, claudeEdits, miniAgentValues, openclawEdits, opencodeEdits, qwenEdits, revertCodexToml, revertHermesYaml, revertJson, revertYamlScalars, validateModel, } from '../lib/setup-agents.js';
10
12
  const home = os.homedir();
11
13
  const AGENTS = [
12
14
  { id: 'claude', name: 'Claude Code', file: path.join(home, '.claude', 'settings.json'), command: 'claude', kind: 'json', keyInFile: true },
13
15
  { id: 'codex', name: 'Codex', file: path.join(home, '.codex', 'config.toml'), command: 'codex', kind: 'toml', keyInFile: false },
14
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
+ { id: 'qwen', name: 'Qwen Code', file: path.join(home, '.qwen', 'settings.json'), command: 'qwen', kind: 'json', keyInFile: false },
20
+ { id: 'miniagent', name: 'Mini-Agent', file: path.join(home, '.mini-agent', 'config', 'config.yaml'), command: 'mini-agent', kind: 'yamlscalars', keyInFile: true },
21
+ // Cline's CLI keeps provider settings (including the key) in this file; we write it only through `cline auth`.
22
+ { id: 'cline', name: 'Cline', file: path.join(home, '.cline', 'data', 'settings', 'providers.json'), command: 'cline', kind: 'cline', keyInFile: true },
15
23
  ];
16
24
  const shown = (p) => p.replace(home, '~');
17
25
  function onPath(cmd) {
@@ -43,14 +51,14 @@ export async function selfCheck(apiKey, model) {
43
51
  return { ok: false, detail: err.message ?? String(err) };
44
52
  }
45
53
  }
46
- function readJson(file) {
54
+ function readJson(file, json5 = false) {
47
55
  if (!fs.existsSync(file))
48
56
  return {};
49
57
  const raw = fs.readFileSync(file, 'utf8');
50
58
  if (raw.trim() === '')
51
59
  return {};
52
60
  try {
53
- const v = JSON.parse(raw);
61
+ const v = json5 ? JSON5.parse(raw) : JSON.parse(raw);
54
62
  if (v && typeof v === 'object' && !Array.isArray(v))
55
63
  return v;
56
64
  }
@@ -76,7 +84,7 @@ function envHint() {
76
84
  export function registerSetup(program) {
77
85
  program
78
86
  .command('setup [agent]')
79
- .description('Point a coding agent (claude, codex, opencode) at the WaveSpeed LLM gateway')
87
+ .description('Point a coding agent (claude, codex, opencode, openclaw, hermes, cline, qwen, miniagent) at the WaveSpeed LLM gateway')
80
88
  .option('--model <id>', `Model to use (default: ${DEFAULT_MODEL})`)
81
89
  .option('--dry-run', 'Show what would change without writing anything')
82
90
  .option('--undo', "Restore the agent's config to what it was before setup")
@@ -108,6 +116,12 @@ export function registerSetup(program) {
108
116
  // ----- undo -----
109
117
  if (opts.undo) {
110
118
  try {
119
+ const state = userConfig.get('setup')?.[agent.id];
120
+ const forget = () => {
121
+ const rest = { ...(userConfig.get('setup') ?? {}) };
122
+ delete rest[agent.id];
123
+ userConfig.set('setup', rest);
124
+ };
111
125
  if (agent.kind === 'toml') {
112
126
  if (!fs.existsSync(agent.file))
113
127
  return fail(`${shown(agent.file)} does not exist; nothing to undo`);
@@ -116,17 +130,43 @@ export function registerSetup(program) {
116
130
  if (!opts.dryRun && after !== before)
117
131
  fs.writeFileSync(agent.file, after);
118
132
  }
133
+ else if (!state) {
134
+ return fail(`No recorded ${agent.name} setup to undo`);
135
+ }
136
+ else if (agent.kind === 'cline') {
137
+ const bak = `${agent.file}.wavespeed.bak`;
138
+ if (!opts.dryRun) {
139
+ if (state.previous.existed && fs.existsSync(bak))
140
+ fs.copyFileSync(bak, agent.file);
141
+ else
142
+ fs.rmSync(agent.file, { force: true });
143
+ fs.rmSync(bak, { force: true });
144
+ forget();
145
+ }
146
+ }
147
+ else if (agent.kind === 'yamlscalars') {
148
+ const after = revertYamlScalars(fs.readFileSync(state.file, 'utf8'), state.previous);
149
+ if (!opts.dryRun) {
150
+ if (after.trim() === '')
151
+ fs.rmSync(state.file, { force: true });
152
+ else
153
+ fs.writeFileSync(state.file, after);
154
+ forget();
155
+ }
156
+ }
157
+ else if (agent.kind === 'yaml') {
158
+ const after = revertHermesYaml(fs.readFileSync(state.file, 'utf8'), state.previous);
159
+ if (!opts.dryRun) {
160
+ fs.writeFileSync(state.file, after);
161
+ forget();
162
+ }
163
+ }
119
164
  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);
165
+ const doc = readJson(state.file, agent.kind === 'json5');
124
166
  const after = JSON.stringify(revertJson(doc, state.previous), null, 2) + '\n';
125
167
  if (!opts.dryRun) {
126
168
  fs.writeFileSync(state.file, after);
127
- const rest = { ...(userConfig.get('setup') ?? {}) };
128
- delete rest[agent.id];
129
- userConfig.set('setup', rest);
169
+ forget();
130
170
  }
131
171
  }
132
172
  }
@@ -149,16 +189,33 @@ export function registerSetup(program) {
149
189
  catch (err) {
150
190
  return fail(err.message);
151
191
  }
152
- let next;
192
+ let next = '';
153
193
  let previous = [];
154
194
  try {
155
195
  if (agent.kind === 'toml') {
156
196
  next = applyCodexToml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
157
197
  }
198
+ else if (agent.kind === 'yaml') {
199
+ const r = applyHermesYaml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
200
+ next = r.text;
201
+ previous = r.previous;
202
+ }
203
+ else if (agent.kind === 'yamlscalars') {
204
+ const r = applyYamlScalars(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', miniAgentValues(model, apiKey));
205
+ next = r.text;
206
+ previous = r.previous;
207
+ }
208
+ else if (agent.kind === 'cline') {
209
+ previous = { existed: fs.existsSync(agent.file) };
210
+ if (!onPath('cline'))
211
+ 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.)');
212
+ }
158
213
  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';
214
+ const base = readJson(agent.file, agent.kind === 'json5');
215
+ const edits = agent.id === 'claude' ? claudeEdits(model, apiKey) : agent.id === 'openclaw' ? openclawEdits(model, base) : agent.id === 'qwen' ? qwenEdits(model) : opencodeEdits(model);
216
+ const r = applyJson(base, edits);
217
+ previous = r.previous;
218
+ next = JSON.stringify(r.doc, null, 2) + '\n';
162
219
  }
163
220
  }
164
221
  catch (err) {
@@ -180,8 +237,21 @@ export function registerSetup(program) {
180
237
  if (!(await confirm({ message: 'Continue?', default: false })))
181
238
  return fail('Cancelled; nothing written.');
182
239
  }
183
- writeFile(agent.file, next, agent.keyInFile);
184
- if (agent.kind === 'json')
240
+ if (agent.kind === 'cline') {
241
+ const bak = `${agent.file}.wavespeed.bak`;
242
+ if (fs.existsSync(agent.file) && !fs.existsSync(bak))
243
+ fs.copyFileSync(agent.file, bak);
244
+ 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' });
245
+ if (r.status !== 0)
246
+ return fail(`cline auth failed: ${(r.stderr || r.error?.message || '').trim().slice(0, 300)}`);
247
+ }
248
+ else {
249
+ const hadComments = agent.kind === 'json5' && exists && /^\s*\/\/|\/\*/m.test(fs.readFileSync(agent.file, 'utf8'));
250
+ writeFile(agent.file, next, agent.keyInFile);
251
+ if (hadComments)
252
+ info(chalk.yellow(`Comments in ${shown(agent.file)} were not kept; the original is saved as ${shown(agent.file)}.wavespeed.bak`));
253
+ }
254
+ if (agent.kind !== 'toml')
185
255
  userConfig.set('setup', { ...(userConfig.get('setup') ?? {}), [agent.id]: { file: agent.file, previous } });
186
256
  const check = opts.check ? await selfCheck(apiKey, model) : null;
187
257
  if (opts.json) {
@@ -12,6 +12,7 @@ export function validateModel(model) {
12
12
  }
13
13
  return model;
14
14
  }
15
+ import { parseDocument } from 'yaml';
15
16
  function isObj(v) {
16
17
  return typeof v === 'object' && v !== null && !Array.isArray(v);
17
18
  }
@@ -86,6 +87,238 @@ export function opencodeEdits(model) {
86
87
  { path: ['model'], value: `wavespeed/${m}` },
87
88
  ];
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
+ export function qwenEdits(model) {
113
+ const m = validateModel(model);
114
+ return [
115
+ { path: ['modelProviders', 'wavespeed'], value: [{ id: m, name: m, envKey: KEY_ENV, baseUrl: LLM_BASE_URL }] },
116
+ { path: ['providerProtocol', 'wavespeed'], value: 'openai' },
117
+ { path: ['security', 'auth', 'selectedType'], value: 'openai' },
118
+ { path: ['model', 'name'], value: m },
119
+ ];
120
+ }
121
+ // ---------- Mini-Agent (flat YAML scalars; the key lives in the file) ----------
122
+ export function miniAgentValues(model, apiKey) {
123
+ return { api_key: apiKey, api_base: LLM_BASE_URL, model: validateModel(model), provider: 'openai' };
124
+ }
125
+ /** Set top-level `key: "value"` pairs in place. Returns the raw text each key held before (null = absent). */
126
+ export function applyYamlScalars(input, values) {
127
+ let text = input;
128
+ if (text !== '' && !text.endsWith('\n'))
129
+ text += '\n';
130
+ const doc = parseDocument(text);
131
+ if (doc.errors.length)
132
+ throw new Error(`config.yaml is not valid YAML: ${doc.errors[0].message}`);
133
+ const root = doc.contents;
134
+ // null (not undefined) marks "absent": the state is stored as JSON, which drops undefined values.
135
+ const previous = {};
136
+ const edits = [];
137
+ let tail = '';
138
+ for (const [key, value] of Object.entries(values)) {
139
+ const node = root?.get?.(key, true);
140
+ const quoted = JSON.stringify(value);
141
+ if (node?.range) {
142
+ previous[key] = text.slice(node.range[0], node.range[1]);
143
+ edits.push({ start: node.range[0], end: node.range[1], text: quoted });
144
+ }
145
+ else {
146
+ previous[key] = null;
147
+ tail += `${key}: ${quoted}\n`;
148
+ }
149
+ }
150
+ return { text: [...edits].sort((a, b) => b.start - a.start).reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), text) + tail, previous };
151
+ }
152
+ export function revertYamlScalars(input, previous) {
153
+ let text = input;
154
+ const doc = parseDocument(text);
155
+ const root = doc.contents;
156
+ const edits = [];
157
+ for (const [key, raw] of Object.entries(previous)) {
158
+ const node = root?.get?.(key, true);
159
+ if (!node?.range)
160
+ continue;
161
+ if (raw !== null) {
162
+ edits.push({ start: node.range[0], end: node.range[1], text: raw });
163
+ }
164
+ else {
165
+ const pair = root.items.find((p) => p.key?.value === key);
166
+ const ks = pair.key.range[0];
167
+ edits.push({ start: lineStart(text, ks), end: lineEnd(text, node.range[1]), text: '' });
168
+ }
169
+ }
170
+ return [...edits].sort((a, b) => b.start - a.start).reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), text);
171
+ }
172
+ const lineStart = (t, off) => t.lastIndexOf('\n', off - 1) + 1;
173
+ const lineEnd = (t, off) => {
174
+ const i = t.indexOf('\n', off);
175
+ return i < 0 ? t.length : i + 1;
176
+ };
177
+ /** End of the line holding the last real character before `off` (a node's range can run over trailing blank lines). */
178
+ const contentLineEnd = (t, off) => lineEnd(t, t.slice(0, off).trimEnd().length - 1);
179
+ function applyEdits(text, edits) {
180
+ return [...edits].sort((x, y) => y.start - x.start).reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), text);
181
+ }
182
+ function entryLines(pad) {
183
+ return [
184
+ `${pad}- name: wavespeed`,
185
+ `${pad} base_url: ${LLM_BASE_URL}`,
186
+ `${pad} key_env: ${KEY_ENV}`,
187
+ `${pad} api_mode: chat_completions`,
188
+ '',
189
+ ].join('\n');
190
+ }
191
+ function dashPad(text, itemStart) {
192
+ const dash = text.lastIndexOf('-', itemStart - 1);
193
+ return { dash, pad: ' '.repeat(dash - lineStart(text, dash)) };
194
+ }
195
+ function parseYaml(text) {
196
+ const doc = parseDocument(text);
197
+ if (doc.errors.length)
198
+ throw new Error(`config.yaml is not valid YAML: ${doc.errors[0].message}`);
199
+ return doc;
200
+ }
201
+ export function applyHermesYaml(input, model) {
202
+ const m = validateModel(model);
203
+ let text = input;
204
+ if (text !== '' && !text.endsWith('\n'))
205
+ text += '\n';
206
+ const doc = parseYaml(text);
207
+ const edits = [];
208
+ let tail = '';
209
+ const previous = { hadModelBlock: false, hadList: false };
210
+ const want = { default: JSON.stringify(m), provider: '"wavespeed"' };
211
+ const root = doc.contents;
212
+ const modelNode = root?.get?.('model', true);
213
+ if (modelNode === undefined || modelNode === null) {
214
+ tail += `model:\n default: ${want.default}\n provider: ${want.provider}\n`;
215
+ }
216
+ else if (modelNode.items) {
217
+ previous.hadModelBlock = true;
218
+ const missing = [];
219
+ for (const key of ['default', 'provider']) {
220
+ const v = modelNode.get?.(key, true);
221
+ if (v && v.range) {
222
+ const raw = text.slice(v.range[0], v.range[1]);
223
+ if (key === 'default')
224
+ previous.defaultRaw = raw;
225
+ else
226
+ previous.providerRaw = raw;
227
+ edits.push({ start: v.range[0], end: v.range[1], text: want[key] });
228
+ }
229
+ else {
230
+ missing.push(key);
231
+ }
232
+ }
233
+ if (missing.length) {
234
+ const first = modelNode.items[0]?.key?.range?.[0];
235
+ if (first === undefined)
236
+ throw new Error('model: block in config.yaml is empty or inline; edit it by hand');
237
+ const pad = ' '.repeat(first - lineStart(text, first));
238
+ edits.push({ start: lineStart(text, first), end: lineStart(text, first), text: missing.map((k) => `${pad}${k}: ${want[k]}\n`).join('') });
239
+ }
240
+ }
241
+ else {
242
+ throw new Error('model: in config.yaml is not a block; edit it by hand');
243
+ }
244
+ const list = root?.get?.('custom_providers', true);
245
+ if (list === undefined || list === null) {
246
+ tail += `custom_providers:\n${entryLines(' ')}`;
247
+ }
248
+ else if (list.items && list.flow !== true) {
249
+ previous.hadList = true;
250
+ const mine = list.items.find((it) => it?.get?.('name') === 'wavespeed');
251
+ if (mine?.range) {
252
+ const { dash, pad } = dashPad(text, mine.range[0]);
253
+ const end = contentLineEnd(text, mine.range[1]);
254
+ previous.entryRaw = text.slice(lineStart(text, dash), end);
255
+ edits.push({ start: lineStart(text, dash), end, text: entryLines(pad) });
256
+ }
257
+ else if (list.items.length > 0) {
258
+ const last = list.items[list.items.length - 1];
259
+ const { pad } = dashPad(text, list.items[0].range[0]);
260
+ const at = contentLineEnd(text, last.range[1]);
261
+ edits.push({ start: at, end: at, text: entryLines(pad) });
262
+ }
263
+ else {
264
+ throw new Error('custom_providers: in config.yaml is empty; edit it by hand');
265
+ }
266
+ }
267
+ else if (list.range && list.value === null) {
268
+ // `custom_providers:` with nothing under it
269
+ previous.hadList = false;
270
+ const at = lineEnd(text, list.range[0]);
271
+ edits.push({ start: at, end: at, text: entryLines(' ') });
272
+ }
273
+ else {
274
+ throw new Error('custom_providers: in config.yaml is not a block list; edit it by hand');
275
+ }
276
+ return { text: applyEdits(text, edits) + tail, previous };
277
+ }
278
+ export function revertHermesYaml(input, previous) {
279
+ let text = input;
280
+ if (!previous.hadModelBlock) {
281
+ text = text.replace(/^model:\n {2}default: "[^\n]*"\n {2}provider: "wavespeed"\n/m, '');
282
+ }
283
+ else {
284
+ const doc = parseYaml(text);
285
+ const modelNode = doc.contents?.get?.('model', true);
286
+ const edits = [];
287
+ for (const [key, raw] of [['default', previous.defaultRaw], ['provider', previous.providerRaw]]) {
288
+ const v = modelNode?.get?.(key, true);
289
+ if (!v?.range)
290
+ continue;
291
+ if (raw !== undefined) {
292
+ edits.push({ start: v.range[0], end: v.range[1], text: raw });
293
+ }
294
+ else {
295
+ const pair = modelNode.items.find((p) => p.key?.value === key);
296
+ const ks = pair.key.range[0];
297
+ edits.push({ start: lineStart(text, ks), end: lineEnd(text, v.range[1]), text: '' });
298
+ }
299
+ }
300
+ text = applyEdits(text, edits);
301
+ }
302
+ const doc = parseYaml(text);
303
+ const list = doc.contents?.get?.('custom_providers', true);
304
+ if (list?.items) {
305
+ const mine = list.items.find((it) => it?.get?.('name') === 'wavespeed');
306
+ if (mine?.range) {
307
+ const { dash } = dashPad(text, mine.range[0]);
308
+ const start = lineStart(text, dash);
309
+ const end = contentLineEnd(text, mine.range[1]);
310
+ text = text.slice(0, start) + (previous.entryRaw ?? '') + text.slice(end);
311
+ }
312
+ if (!previous.hadList) {
313
+ const again = parseYaml(text);
314
+ const l2 = again.contents?.get?.('custom_providers', true);
315
+ if (l2?.items?.length === 0 || (l2 && l2.items === undefined)) {
316
+ text = text.replace(/^custom_providers:[ \t]*\n?/m, '');
317
+ }
318
+ }
319
+ }
320
+ return text;
321
+ }
89
322
  // ---------- Codex (TOML, edited as text so the user's comments survive) ----------
90
323
  const TOP_OPEN = '# >>> wavespeed (managed by `wavespeed setup`) >>>';
91
324
  const TOP_CLOSE = '# <<< wavespeed <<<';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
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",