@wavespeed/cli 0.4.16 → 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,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 or cline 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,22 @@ 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, claudeEdits, openclawEdits, opencodeEdits, revertCodexToml, revertHermesYaml, revertJson, 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
+ // 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 },
15
21
  ];
16
22
  const shown = (p) => p.replace(home, '~');
17
23
  function onPath(cmd) {
@@ -43,14 +49,14 @@ export async function selfCheck(apiKey, model) {
43
49
  return { ok: false, detail: err.message ?? String(err) };
44
50
  }
45
51
  }
46
- function readJson(file) {
52
+ function readJson(file, json5 = false) {
47
53
  if (!fs.existsSync(file))
48
54
  return {};
49
55
  const raw = fs.readFileSync(file, 'utf8');
50
56
  if (raw.trim() === '')
51
57
  return {};
52
58
  try {
53
- const v = JSON.parse(raw);
59
+ const v = json5 ? JSON5.parse(raw) : JSON.parse(raw);
54
60
  if (v && typeof v === 'object' && !Array.isArray(v))
55
61
  return v;
56
62
  }
@@ -76,7 +82,7 @@ function envHint() {
76
82
  export function registerSetup(program) {
77
83
  program
78
84
  .command('setup [agent]')
79
- .description('Point a coding agent (claude, codex, opencode) at the WaveSpeed LLM gateway')
85
+ .description('Point a coding agent (claude, codex, opencode, openclaw, hermes, cline) at the WaveSpeed LLM gateway')
80
86
  .option('--model <id>', `Model to use (default: ${DEFAULT_MODEL})`)
81
87
  .option('--dry-run', 'Show what would change without writing anything')
82
88
  .option('--undo', "Restore the agent's config to what it was before setup")
@@ -108,6 +114,12 @@ export function registerSetup(program) {
108
114
  // ----- undo -----
109
115
  if (opts.undo) {
110
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
+ };
111
123
  if (agent.kind === 'toml') {
112
124
  if (!fs.existsSync(agent.file))
113
125
  return fail(`${shown(agent.file)} does not exist; nothing to undo`);
@@ -116,17 +128,33 @@ export function registerSetup(program) {
116
128
  if (!opts.dryRun && after !== before)
117
129
  fs.writeFileSync(agent.file, after);
118
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
+ }
119
152
  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);
153
+ const doc = readJson(state.file, agent.kind === 'json5');
124
154
  const after = JSON.stringify(revertJson(doc, state.previous), null, 2) + '\n';
125
155
  if (!opts.dryRun) {
126
156
  fs.writeFileSync(state.file, after);
127
- const rest = { ...(userConfig.get('setup') ?? {}) };
128
- delete rest[agent.id];
129
- userConfig.set('setup', rest);
157
+ forget();
130
158
  }
131
159
  }
132
160
  }
@@ -149,16 +177,28 @@ export function registerSetup(program) {
149
177
  catch (err) {
150
178
  return fail(err.message);
151
179
  }
152
- let next;
180
+ let next = '';
153
181
  let previous = [];
154
182
  try {
155
183
  if (agent.kind === 'toml') {
156
184
  next = applyCodexToml(fs.existsSync(agent.file) ? fs.readFileSync(agent.file, 'utf8') : '', model);
157
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
+ }
158
196
  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';
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';
162
202
  }
163
203
  }
164
204
  catch (err) {
@@ -180,8 +220,21 @@ export function registerSetup(program) {
180
220
  if (!(await confirm({ message: 'Continue?', default: false })))
181
221
  return fail('Cancelled; nothing written.');
182
222
  }
183
- writeFile(agent.file, next, agent.keyInFile);
184
- if (agent.kind === 'json')
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')
185
238
  userConfig.set('setup', { ...(userConfig.get('setup') ?? {}), [agent.id]: { file: agent.file, previous } });
186
239
  const check = opts.check ? await selfCheck(apiKey, model) : null;
187
240
  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,178 @@ 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
+ 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
+ }
89
262
  // ---------- Codex (TOML, edited as text so the user's comments survive) ----------
90
263
  const TOP_OPEN = '# >>> wavespeed (managed by `wavespeed setup`) >>>';
91
264
  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.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",