@wavespeed/cli 0.2.4 → 0.3.0

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
@@ -90,7 +90,9 @@ wavespeed run ... -p "..." --json | jq '.outputs[0]'
90
90
  - **`defaultModel`** — `wavespeed run -p "…"` (no model arg) uses this. Can itself be an alias name.
91
91
  - **`aliases`** — your own shortcuts. `wavespeed run hero -p "…"` expands to model + input. CLI `-i k=v` flags override. `wavespeed run hero -h` shows the resolved schema. List them with `wavespeed aliases`.
92
92
 
93
- Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never mutates the user's prompt or inputs** — what you pass is what hits the API.
93
+ Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never rewrites your prompt or input values**, with one explicit exception: local file paths become hosted URLs.
94
+
95
+ **Local files as inputs.** `-i image=./cat.png` uploads the file first and submits its URL — no separate `wavespeed upload` step. A bare value is only treated as a file when it has a media extension (image/video/audio) *and* exists on disk; prompt fields never auto-detect. Prefix with `@` (`-i image=@./cat.png`) to force an upload of any file — then a missing file is an error instead of a pass-through. The same file referenced twice uploads once.
94
96
 
95
97
  ## Recommended starting models
96
98
 
@@ -33,6 +33,10 @@ async function readClipboardKey() {
33
33
  async function validateKey(apiKey, baseUrl) {
34
34
  // The platform doesn't expose a dedicated /whoami yet, so we ping a known
35
35
  // authenticated endpoint and treat anything that isn't 401/403 as "key works".
36
+ // Note this proves the key is live, not that it can run models: /balance is
37
+ // deliberately open to every key regardless of the creator's org role, so a
38
+ // Billing-created key logs in here and only fails at `run`, which now
39
+ // surfaces the API's explanation of why.
36
40
  let res = null;
37
41
  try {
38
42
  res = await fetch(`${baseUrl}/api/v3/balance`, {
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { requireClient } from '../lib/client.js';
6
6
  import { parseInputs } from '../lib/inputs.js';
7
+ import { resolveLocalFiles } from '../lib/local-files.js';
7
8
  import { downloadOutputs, printOutputs } from '../lib/output.js';
8
9
  import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
9
10
  import { downloadOptsFromFlag } from '../lib/download-flag.js';
@@ -43,6 +44,13 @@ function failMissingModel() {
43
44
  }
44
45
  process.exit(1);
45
46
  }
47
+ function failWith(msg) {
48
+ if (isJsonMode())
49
+ emitJsonError(msg);
50
+ else
51
+ console.error(chalk.red('Error: ') + msg);
52
+ process.exit(1);
53
+ }
46
54
  function failUnknownAlias(token) {
47
55
  const msg = `"${token}" is neither a model ID (no \`/\`) nor a known alias.`;
48
56
  if (isJsonMode())
@@ -59,7 +67,7 @@ export function registerRun(program) {
59
67
  .command('run')
60
68
  .description('Run any Wavespeed model — generic invoke with --input key=value pairs')
61
69
  .argument('[model]', 'Model ID, alias name, or omit to use defaultModel.')
62
- .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). Use dotted keys for nested.', [])
70
+ .option('-i, --input <pair...>', 'Inputs as key=value (repeatable). Local media paths auto-upload; @path forces upload. Dotted keys nest.', [])
63
71
  .option('-p, --prompt <text>', 'Shorthand for --input prompt=<text>')
64
72
  .option('--input-file <path>', 'JSON file with full input object')
65
73
  .option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
@@ -87,6 +95,28 @@ export function registerRun(program) {
87
95
  Object.assign(input, parseInputs(opts.input ?? []));
88
96
  if (opts.prompt)
89
97
  input.prompt = opts.prompt;
98
+ // Local paths become hosted URLs before submission. Uploads go straight
99
+ // to object storage via a presigned PUT, so this is one extra round trip
100
+ // per distinct file rather than a reason to make the user run `upload`
101
+ // first and paste the result back.
102
+ try {
103
+ const uploadSpinner = !isJsonMode() ? ora({ color: 'magenta' }) : null;
104
+ const resolved = await resolveLocalFiles(input, {
105
+ upload: (file) => client.upload(file),
106
+ onUpload: (file, i, total) => {
107
+ uploadSpinner?.start(total > 1
108
+ ? `Uploading ${path.basename(file)} (${i + 1}/${total})…`
109
+ : `Uploading ${path.basename(file)}…`);
110
+ },
111
+ });
112
+ if (resolved.uploaded > 0) {
113
+ uploadSpinner?.succeed(`Uploaded ${resolved.uploaded} file${resolved.uploaded === 1 ? '' : 's'}.`);
114
+ }
115
+ input = resolved.input;
116
+ }
117
+ catch (err) {
118
+ failWith(err.message ?? String(err));
119
+ }
90
120
  log('');
91
121
  log(chalk.bold('▶ ') +
92
122
  chalk.cyan(model) +
package/dist/lib/api.js CHANGED
@@ -47,7 +47,7 @@ export async function fetchModels(opts = {}) {
47
47
  headers: { Authorization: `Bearer ${apiKey}` },
48
48
  });
49
49
  if (!res.ok) {
50
- throw new Error(`GET /api/v3/models failed: ${res.status} ${res.statusText}`);
50
+ throw await httpError(res, 'GET', '/api/v3/models');
51
51
  }
52
52
  const json = (await res.json());
53
53
  if (json.code !== 200) {
@@ -72,10 +72,29 @@ function authHeaders() {
72
72
  throw new Error('No API key configured. Run `wavespeed login`.');
73
73
  return { Authorization: `Bearer ${apiKey}` };
74
74
  }
75
+ // Non-2xx responses still carry the platform's error envelope, and for
76
+ // permission failures that body is the whole point: it names the role that
77
+ // created the key and a role whose key would work. Throwing only the status
78
+ // line turned every 403 into an unactionable "403 Forbidden".
79
+ async function httpError(res, method, path) {
80
+ let detail = '';
81
+ try {
82
+ const body = (await res.json());
83
+ if (body?.message) {
84
+ detail = body.error_code ? `${body.message} [${body.error_code}]` : body.message;
85
+ }
86
+ }
87
+ catch {
88
+ /* non-JSON body (gateway error page, empty response) — fall back below */
89
+ }
90
+ if (detail)
91
+ return new Error(detail);
92
+ return new Error(`${method} ${path} failed: ${res.status} ${res.statusText}`);
93
+ }
75
94
  async function apiGet(path) {
76
95
  const res = await fetch(`${getBaseUrl()}${path}`, { headers: authHeaders() });
77
96
  if (!res.ok)
78
- throw new Error(`GET ${path} failed: ${res.status} ${res.statusText}`);
97
+ throw await httpError(res, 'GET', path);
79
98
  const json = (await res.json());
80
99
  if (json.code !== 200)
81
100
  throw new Error(json.message || `API returned code ${json.code}`);
@@ -88,7 +107,7 @@ async function apiPost(path, body) {
88
107
  body: JSON.stringify(body),
89
108
  });
90
109
  if (!res.ok)
91
- throw new Error(`POST ${path} failed: ${res.status} ${res.statusText}`);
110
+ throw await httpError(res, 'POST', path);
92
111
  const json = (await res.json());
93
112
  if (json.code !== 200)
94
113
  throw new Error(json.message || `API returned code ${json.code}`);
@@ -0,0 +1,116 @@
1
+ // Turn local file paths in a model's input into hosted URLs.
2
+ //
3
+ // Uploading is cheap now that the platform hands out a presigned PUT and the
4
+ // bytes go straight to object storage, so making the user run `upload` first
5
+ // and paste the URL back is pure friction. This module finds the paths and
6
+ // swaps them for URLs; run.ts does it once, right before submitting.
7
+ //
8
+ // Two ways to point at a file:
9
+ //
10
+ // -i image=@./cat.png explicit — always uploaded, missing file is an error
11
+ // -i image=./cat.png implicit — uploaded only if it looks like media AND
12
+ // exists on disk; otherwise passed through untouched
13
+ //
14
+ // The implicit rule is deliberately narrow. A model input that happens to
15
+ // match a filename in the working directory must not be silently replaced by
16
+ // a URL, so a bare value has to clear both the extension check and the
17
+ // existence check. Anything already a URL is left alone.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ // Extensions the platform's models actually accept as inputs. Kept explicit
21
+ // rather than "any file that exists": a bare `-i seed=1.txt` should stay a
22
+ // string even in a directory that happens to contain 1.txt.
23
+ const MEDIA_EXTENSIONS = new Set([
24
+ // images
25
+ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif',
26
+ // video
27
+ '.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v',
28
+ // audio
29
+ '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.opus',
30
+ ]);
31
+ // Free-text fields never auto-detect: `-p ./notes.txt` means that literal
32
+ // string. An explicit `@` still works if someone genuinely wants it.
33
+ const NO_IMPLICIT_DETECTION = new Set(['prompt', 'negative_prompt', 'system_prompt']);
34
+ function isRegularFile(p) {
35
+ try {
36
+ return fs.statSync(p).isFile();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function isUrl(value) {
43
+ return /^(https?:|data:|s3:|gs:)/i.test(value);
44
+ }
45
+ /**
46
+ * Walk the input and list every value that should become an upload. Pure over
47
+ * `exists`, so the decision rules can be tested without touching the disk.
48
+ */
49
+ export function collectLocalFiles(input, opts = {}) {
50
+ const exists = opts.exists ?? isRegularFile;
51
+ const found = [];
52
+ const walk = (node, at, key) => {
53
+ if (Array.isArray(node)) {
54
+ // Arrays inherit the key of their container, so `image_urls.0` is still
55
+ // judged as `image_urls` — element indices are not field names.
56
+ node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i), key));
57
+ return;
58
+ }
59
+ if (node && typeof node === 'object') {
60
+ for (const [k, v] of Object.entries(node)) {
61
+ walk(v, at ? `${at}.${k}` : k, k);
62
+ }
63
+ return;
64
+ }
65
+ if (typeof node !== 'string' || node === '')
66
+ return;
67
+ if (node.startsWith('@')) {
68
+ found.push({ path: node.slice(1), at, explicit: true });
69
+ return;
70
+ }
71
+ if (isUrl(node))
72
+ return;
73
+ if (NO_IMPLICIT_DETECTION.has(key))
74
+ return;
75
+ if (!MEDIA_EXTENSIONS.has(path.extname(node).toLowerCase()))
76
+ return;
77
+ if (!exists(node))
78
+ return;
79
+ found.push({ path: node, at, explicit: false });
80
+ };
81
+ walk(input, '', '');
82
+ return found;
83
+ }
84
+ function setAt(root, dotted, value) {
85
+ const parts = dotted.split('.');
86
+ let cur = root;
87
+ for (let i = 0; i < parts.length - 1; i++)
88
+ cur = cur[parts[i]];
89
+ cur[parts[parts.length - 1]] = value;
90
+ }
91
+ /**
92
+ * Replace local paths in `input` with uploaded URLs. Returns a new object;
93
+ * the caller's input is not mutated. Distinct paths upload once even when
94
+ * referenced several times — the same image passed as two fields is one
95
+ * upload, not two.
96
+ */
97
+ export async function resolveLocalFiles(input, opts) {
98
+ const refs = collectLocalFiles(input, opts);
99
+ if (refs.length === 0)
100
+ return { input, uploaded: 0 };
101
+ const missing = refs.filter((r) => r.explicit && !(opts.exists ?? isRegularFile)(r.path));
102
+ if (missing.length > 0) {
103
+ throw new Error(`File not found: ${missing.map((m) => m.path).join(', ')}`);
104
+ }
105
+ const distinct = [...new Set(refs.map((r) => path.resolve(r.path)))];
106
+ const urls = new Map();
107
+ for (const [i, abs] of distinct.entries()) {
108
+ opts.onUpload?.(abs, i, distinct.length);
109
+ urls.set(abs, await opts.upload(abs));
110
+ }
111
+ const out = JSON.parse(JSON.stringify(input));
112
+ for (const ref of refs) {
113
+ setAt(out, ref.at, urls.get(path.resolve(ref.path)));
114
+ }
115
+ return { input: out, uploaded: distinct.length };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"