@wavespeed/cli 0.4.14 → 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 +6 -0
- package/dist/cli.js +5 -11
- package/dist/commands/setup.js +203 -0
- package/dist/commands/skill.js +7 -2
- package/dist/commands/upgrade.js +209 -0
- package/dist/lib/client-headers.js +2 -17
- package/dist/lib/setup-agents.js +148 -0
- package/dist/lib/version.js +36 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -42,6 +42,10 @@ npm install -g @wavespeed/cli
|
|
|
42
42
|
# friendlier error path)
|
|
43
43
|
curl -fsSL https://raw.githubusercontent.com/WaveSpeedAI/wavespeed-cli/main/install.sh | bash
|
|
44
44
|
|
|
45
|
+
# no Node.js/npm? install.sh falls back to a standalone executable on macOS/Linux;
|
|
46
|
+
# on Windows (PowerShell):
|
|
47
|
+
irm https://raw.githubusercontent.com/WaveSpeedAI/wavespeed-cli/main/install.ps1 | iex
|
|
48
|
+
|
|
45
49
|
# sign in (opens the access-key page, paste to confirm)
|
|
46
50
|
wavespeed login
|
|
47
51
|
```
|
|
@@ -136,6 +140,8 @@ wavespeed login Browser + paste-key wizard (clipboard-a
|
|
|
136
140
|
wavespeed logout Clear stored API key
|
|
137
141
|
wavespeed status Show masked key, base URL, useful links
|
|
138
142
|
wavespeed config [--default-model …] View / update CLI defaults
|
|
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)
|
|
139
145
|
|
|
140
146
|
# generation
|
|
141
147
|
wavespeed run [model|alias] -p "…" Run any model or alias (uses defaultModel if omitted)
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
4
|
import { printBanner } from './lib/banner.js';
|
|
8
5
|
import { registerAuth } from './commands/auth.js';
|
|
9
6
|
import { registerRun } from './commands/run.js';
|
|
@@ -23,15 +20,10 @@ import { registerDelete } from './commands/delete.js';
|
|
|
23
20
|
import { registerOpen } from './commands/open.js';
|
|
24
21
|
import { registerBillings } from './commands/billings.js';
|
|
25
22
|
import { registerUsage } from './commands/usage.js';
|
|
23
|
+
import { registerUpgrade } from './commands/upgrade.js';
|
|
24
|
+
import { registerSetup } from './commands/setup.js';
|
|
25
|
+
import { VERSION } from './lib/version.js';
|
|
26
26
|
import { detectRunHelp, printDynamicRunHelp } from './lib/dynamic-help.js';
|
|
27
|
-
// Read version from package.json at runtime so a bump in package.json is
|
|
28
|
-
// reflected automatically (no second place to keep in sync).
|
|
29
|
-
const VERSION = (() => {
|
|
30
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
-
// dist/cli.js → ../package.json (one level up because files: ["dist", ...])
|
|
32
|
-
const pkgPath = path.resolve(here, '..', 'package.json');
|
|
33
|
-
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
34
|
-
})();
|
|
35
27
|
const program = new Command();
|
|
36
28
|
program
|
|
37
29
|
.name('wavespeed')
|
|
@@ -70,6 +62,8 @@ registerBillings(program);
|
|
|
70
62
|
registerUsage(program);
|
|
71
63
|
registerInit(program);
|
|
72
64
|
registerSkill(program);
|
|
65
|
+
registerUpgrade(program);
|
|
66
|
+
registerSetup(program);
|
|
73
67
|
program
|
|
74
68
|
.command('hello', { hidden: true })
|
|
75
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
|
+
}
|
package/dist/commands/skill.js
CHANGED
|
@@ -2,6 +2,11 @@ import chalk from 'chalk';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
function skillBody() {
|
|
6
|
+
if (typeof __WS_SKILL__ === 'string')
|
|
7
|
+
return __WS_SKILL__;
|
|
8
|
+
return fs.readFileSync(skillSourcePath(), 'utf8');
|
|
9
|
+
}
|
|
5
10
|
function skillSourcePath() {
|
|
6
11
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
12
|
// dist/commands/skill.js -> ../../skills/SKILL.md
|
|
@@ -16,7 +21,7 @@ export function writeSkills(cwd = process.cwd()) {
|
|
|
16
21
|
return SKILL_ROOTS.map((root) => {
|
|
17
22
|
const dest = path.join(cwd, root, 'skills', 'wavespeed', 'SKILL.md');
|
|
18
23
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
19
|
-
fs.
|
|
24
|
+
fs.writeFileSync(dest, skillBody());
|
|
20
25
|
return dest;
|
|
21
26
|
});
|
|
22
27
|
}
|
|
@@ -34,7 +39,7 @@ export function registerSkill(program) {
|
|
|
34
39
|
.option('--print', 'Print the skill body to stdout instead of writing a file')
|
|
35
40
|
.action((opts) => {
|
|
36
41
|
if (opts.print) {
|
|
37
|
-
process.stdout.write(
|
|
42
|
+
process.stdout.write(skillBody());
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
40
45
|
const dests = writeSkills();
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { IS_STANDALONE, PACKAGE_NAME, VERSION, assetName, isNewer } from '../lib/version.js';
|
|
8
|
+
import { emitJson, emitJsonError, info, setJsonMode } from '../lib/log.js';
|
|
9
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
10
|
+
const RELEASES_URL = 'https://github.com/WaveSpeedAI/wavespeed-cli/releases';
|
|
11
|
+
// The standalone executable is distributed through GitHub Releases (the npm
|
|
12
|
+
// publish workflow creates the release right after publishing).
|
|
13
|
+
async function fetchLatestReleaseVersion() {
|
|
14
|
+
const res = await fetch('https://api.github.com/repos/WaveSpeedAI/wavespeed-cli/releases/latest', {
|
|
15
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'wavespeed-cli' },
|
|
16
|
+
signal: AbortSignal.timeout(15_000),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`GitHub releases returned HTTP ${res.status}`);
|
|
20
|
+
const tag = (await res.json()).tag_name;
|
|
21
|
+
if (!tag)
|
|
22
|
+
throw new Error('GitHub release response had no tag');
|
|
23
|
+
return tag.replace(/^v/, '');
|
|
24
|
+
}
|
|
25
|
+
async function download(url) {
|
|
26
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(300_000), redirect: 'follow' });
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new Error(`download of ${url} failed: HTTP ${res.status}`);
|
|
29
|
+
return Buffer.from(await res.arrayBuffer());
|
|
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
|
+
}
|
|
40
|
+
/** Replace the running executable with the release asset, after checking its SHA-256. */
|
|
41
|
+
async function replaceExecutable(version) {
|
|
42
|
+
const asset = assetName();
|
|
43
|
+
const base = `${RELEASES_URL}/download/v${version}`;
|
|
44
|
+
const [bin, sums] = await Promise.all([download(`${base}/${asset}`), download(`${base}/SHA256SUMS`)]);
|
|
45
|
+
const expected = sums
|
|
46
|
+
.toString('utf8')
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((l) => l.trim().split(/\s+/))
|
|
49
|
+
.find((p) => p[1]?.replace(/^\*/, '') === asset)?.[0];
|
|
50
|
+
const actual = crypto.createHash('sha256').update(bin).digest('hex');
|
|
51
|
+
if (!expected || expected.toLowerCase() !== actual)
|
|
52
|
+
throw new Error(`checksum mismatch for ${asset}; not installing`);
|
|
53
|
+
const exe = realExecPath();
|
|
54
|
+
const next = `${exe}.new`;
|
|
55
|
+
fs.writeFileSync(next, bin, { mode: 0o755 });
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
// A running .exe cannot be overwritten but can be renamed out of the way.
|
|
58
|
+
const old = `${exe}.old`;
|
|
59
|
+
fs.rmSync(old, { force: true });
|
|
60
|
+
fs.renameSync(exe, old);
|
|
61
|
+
}
|
|
62
|
+
fs.renameSync(next, exe);
|
|
63
|
+
}
|
|
64
|
+
export async function fetchLatestVersion() {
|
|
65
|
+
if (IS_STANDALONE)
|
|
66
|
+
return fetchLatestReleaseVersion();
|
|
67
|
+
const res = await fetch(REGISTRY_URL, {
|
|
68
|
+
headers: { accept: 'application/json' },
|
|
69
|
+
signal: AbortSignal.timeout(15_000),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new Error(`npm registry returned HTTP ${res.status}`);
|
|
73
|
+
const body = (await res.json());
|
|
74
|
+
if (!body.version)
|
|
75
|
+
throw new Error('npm registry response had no version');
|
|
76
|
+
return body.version;
|
|
77
|
+
}
|
|
78
|
+
function runNpmInstall() {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
// Child output goes to stderr so `--json` keeps stdout clean.
|
|
81
|
+
const child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
82
|
+
stdio: ['ignore', 2, 2],
|
|
83
|
+
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
84
|
+
});
|
|
85
|
+
child.on('error', reject);
|
|
86
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Skill files written by `wavespeed skill install` are a snapshot; flag the ones in this directory. */
|
|
90
|
+
function installedSkillRoots(cwd = process.cwd()) {
|
|
91
|
+
return ['.claude', '.agents']
|
|
92
|
+
.map((root) => path.join(root, 'skills', 'wavespeed', 'SKILL.md'))
|
|
93
|
+
.filter((rel) => fs.existsSync(path.join(cwd, rel)));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Other `wavespeed` executables on PATH besides the one running (e.g. an npm
|
|
97
|
+
* install shadowing a standalone one). Upgrading one of them while the other
|
|
98
|
+
* wins on PATH makes the upgrade look like it did nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function otherInstalls(pathEnv = process.env.PATH ?? '', self = process.argv[1] ?? '') {
|
|
101
|
+
const names = process.platform === 'win32' ? ['wavespeed.exe', 'wavespeed.cmd', 'wavespeed.ps1'] : ['wavespeed'];
|
|
102
|
+
const real = (p) => { try {
|
|
103
|
+
return fs.realpathSync(p);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
} };
|
|
108
|
+
const mine = new Set([real(process.execPath), real(self)].filter(Boolean));
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const full = path.join(dir, name);
|
|
114
|
+
const r = real(full);
|
|
115
|
+
if (!r || seen.has(r))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(r);
|
|
118
|
+
// The npm shim and the package entry both count as "this" install.
|
|
119
|
+
if (!mine.has(r) && !(!IS_STANDALONE && r.includes(`${path.sep}@wavespeed${path.sep}cli${path.sep}`)))
|
|
120
|
+
found.push(full);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
function warnOtherInstalls(json) {
|
|
126
|
+
const others = otherInstalls();
|
|
127
|
+
if (others.length && !json) {
|
|
128
|
+
info(chalk.yellow('Note: another wavespeed is also on your PATH, so the version you run may not be the one just upgraded:'));
|
|
129
|
+
for (const o of others)
|
|
130
|
+
info(chalk.gray(' ' + o.replace(os.homedir(), '~')));
|
|
131
|
+
}
|
|
132
|
+
return others;
|
|
133
|
+
}
|
|
134
|
+
export function registerUpgrade(program) {
|
|
135
|
+
program
|
|
136
|
+
.command('upgrade')
|
|
137
|
+
.alias('update')
|
|
138
|
+
.description('Upgrade the CLI to the latest published version')
|
|
139
|
+
.option('--check', 'Only report whether a newer version exists; do not install')
|
|
140
|
+
.option('--json', 'Emit JSON: {current, latest, update_available, upgraded}')
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
setJsonMode(!!opts.json);
|
|
143
|
+
const fail = (message) => {
|
|
144
|
+
if (opts.json)
|
|
145
|
+
emitJsonError(message, { current: VERSION });
|
|
146
|
+
else
|
|
147
|
+
console.error(chalk.red('Error: ') + message);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
};
|
|
150
|
+
let latest;
|
|
151
|
+
try {
|
|
152
|
+
latest = await fetchLatestVersion();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return fail(`Could not check for updates: ${err.message ?? String(err)}`);
|
|
156
|
+
}
|
|
157
|
+
const updateAvailable = isNewer(latest, VERSION);
|
|
158
|
+
let others = [];
|
|
159
|
+
if (!updateAvailable || opts.check) {
|
|
160
|
+
if (opts.json) {
|
|
161
|
+
emitJson({ current: VERSION, latest, update_available: updateAvailable, upgraded: false, other_installs: otherInstalls() });
|
|
162
|
+
}
|
|
163
|
+
else if (!updateAvailable) {
|
|
164
|
+
info(chalk.green(`Already up to date (v${VERSION}).`));
|
|
165
|
+
warnOtherInstalls(opts.json);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
info(`Update available: v${VERSION} → ${chalk.bold('v' + latest)}. Run ${chalk.cyan('wavespeed upgrade')}.`);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
info(chalk.gray(`Upgrading v${VERSION} → v${latest}…`));
|
|
173
|
+
if (IS_STANDALONE) {
|
|
174
|
+
// Installed by a package manager: let it own the upgrade.
|
|
175
|
+
const managed = /[\\/](Cellar|homebrew|Homebrew|WinGet|scoop)[\\/]/.exec(realExecPath());
|
|
176
|
+
if (managed) {
|
|
177
|
+
return fail(`This copy is managed by ${managed[1]}; upgrade it with your package manager (e.g. brew upgrade wavespeed).`);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await replaceExecutable(latest);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return fail(`Could not replace ${realExecPath()}: ${err.message ?? String(err)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
let code;
|
|
188
|
+
try {
|
|
189
|
+
code = await runNpmInstall();
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return fail(`Could not run npm: ${err.message ?? String(err)}`);
|
|
193
|
+
}
|
|
194
|
+
if (code !== 0) {
|
|
195
|
+
return fail(`npm install -g exited with code ${code}. If it is a permissions error, fix npm's prefix or re-run with sudo: npm install -g ${PACKAGE_NAME}@latest`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
others = warnOtherInstalls(opts.json);
|
|
199
|
+
const skills = installedSkillRoots();
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
emitJson({ current: VERSION, latest, update_available: true, upgraded: true, stale_skill_files: skills, other_installs: others });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
info(chalk.green(`Upgraded to v${latest}.`));
|
|
205
|
+
if (skills.length) {
|
|
206
|
+
info(chalk.gray(' Skill files here are from the old version: ') + chalk.cyan('wavespeed skill install') + chalk.gray(' to refresh.'));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -2,24 +2,9 @@
|
|
|
2
2
|
// for the CLI's direct REST calls, matching the convention wavespeed-desktop
|
|
3
3
|
// uses. Calls that go through the `wavespeed` SDK get the same headers from
|
|
4
4
|
// the SDK itself (via the clientName option in client.ts).
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
5
|
import os from 'node:os';
|
|
8
|
-
import {
|
|
6
|
+
import { VERSION } from './version.js';
|
|
9
7
|
export const CLI_CLIENT_NAME = 'wavespeed-cli';
|
|
10
|
-
// Read version from package.json at runtime so a bump in package.json is
|
|
11
|
-
// reflected automatically (same approach as cli.ts).
|
|
12
|
-
function cliVersion() {
|
|
13
|
-
try {
|
|
14
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
// dist/lib/client-headers.js → ../../package.json
|
|
16
|
-
const pkgPath = path.resolve(here, '..', '..', 'package.json');
|
|
17
|
-
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return '0.0.0';
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
8
|
// Lowercase OS name: darwin / linux / windows (win32 is reported as windows).
|
|
24
9
|
function operatingSystem() {
|
|
25
10
|
const platform = os.platform();
|
|
@@ -34,7 +19,7 @@ export function resolveClientName() {
|
|
|
34
19
|
export function clientAttributionHeaders() {
|
|
35
20
|
return {
|
|
36
21
|
'X-Client-Name': resolveClientName(),
|
|
37
|
-
'X-Client-Version':
|
|
22
|
+
'X-Client-Version': VERSION,
|
|
38
23
|
'X-Client-OS': operatingSystem(),
|
|
39
24
|
};
|
|
40
25
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export const PACKAGE_NAME = '@wavespeed/cli';
|
|
5
|
+
/** True when running as the standalone executable rather than under Node + npm. */
|
|
6
|
+
export const IS_STANDALONE = typeof __WS_VERSION__ === 'string';
|
|
7
|
+
// Otherwise read package.json at runtime so a bump is reflected automatically
|
|
8
|
+
// (no second place to keep in sync).
|
|
9
|
+
export const VERSION = IS_STANDALONE ? __WS_VERSION__ : (() => {
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// dist/lib/version.js (or src/lib/version.ts) → ../../package.json
|
|
12
|
+
const pkgPath = path.resolve(here, '..', '..', 'package.json');
|
|
13
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
14
|
+
})();
|
|
15
|
+
/** Parse "1.2.3" (optionally "v"-prefixed / with a -prerelease or +build tail) into numbers. */
|
|
16
|
+
function parse(v) {
|
|
17
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
18
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
19
|
+
}
|
|
20
|
+
/** True when `latest` is strictly newer than `current`. Unparseable input → false. */
|
|
21
|
+
export function isNewer(latest, current) {
|
|
22
|
+
const a = parse(latest);
|
|
23
|
+
const b = parse(current);
|
|
24
|
+
if (!a || !b)
|
|
25
|
+
return false;
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if (a[i] !== b[i])
|
|
28
|
+
return a[i] > b[i];
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** Release asset name for this platform, e.g. wavespeed-linux-x64, wavespeed-windows-x64.exe. */
|
|
33
|
+
export function assetName(platform = process.platform, arch = process.arch) {
|
|
34
|
+
const os = platform === 'win32' ? 'windows' : platform;
|
|
35
|
+
return `wavespeed-${os}-${arch}${platform === 'win32' ? '.exe' : ''}`;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavespeed/cli",
|
|
3
|
-
"version": "0.4.
|
|
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"
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^20.14.0",
|
|
63
|
+
"esbuild": "^0.28.2",
|
|
64
|
+
"postject": "^1.0.0-alpha.6",
|
|
63
65
|
"prettier": "^3.3.0",
|
|
64
66
|
"tsx": "^4.19.0",
|
|
65
67
|
"typescript": "^5.5.0",
|