@wavespeed/cli 0.2.5 → 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
 
@@ -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) +
@@ -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.5",
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"