@wji.ai/cli 0.7.0 → 0.7.1

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.
@@ -1,356 +0,0 @@
1
- import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
3
- import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
4
- import { color, detail, detailLevel, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
5
- const READY = 1;
6
- const CANCELLED = -1;
7
- /** `--ref abc123`, `--ref abc123:hero`, `--ref abc123:hero:0.6` */
8
- function parseReference(token) {
9
- const [assetId, label, weight] = token.split(':');
10
- if (!assetId)
11
- throw new Error(`Invalid reference "${token}"`);
12
- return {
13
- assetId,
14
- label: label || undefined,
15
- weight: weight ? Number(weight) : undefined,
16
- };
17
- }
18
- const refs = (tokens) => (tokens?.length ? tokens.map(parseReference) : undefined);
19
- function assetIdsOf(history) {
20
- const images = history?.images;
21
- const list = Array.isArray(images) ? images : Array.isArray(images?.items) ? images.items : [];
22
- return list.map((a) => (typeof a === 'string' ? a : a?.id)).filter(Boolean);
23
- }
24
- /**
25
- * A history entry only carries asset *ids* — `images` is `[{id}]` with no
26
- * status or url — so the real state has to be fetched separately. Polling on
27
- * the history alone would report "complete" the instant the entry exists.
28
- */
29
- export async function resolveAssets(client, history) {
30
- const ids = assetIdsOf(history);
31
- if (!ids.length)
32
- return [];
33
- return client.checkAssetStatus(ids);
34
- }
35
- export const stateOf = (a) => a?.status === CANCELLED || a?.error ? 'failed' : a?.status === READY ? 'ready' : 'pending';
36
- export async function pollHistory(client, id, { timeoutMs, intervalMs }) {
37
- const started = Date.now();
38
- let lastReport = 0;
39
- while (Date.now() - started < timeoutMs) {
40
- const history = await client.get(`/ai/assets/history/list/${id}`);
41
- const assets = await resolveAssets(client, history);
42
- const pending = assets.filter((a) => stateOf(a) === 'pending');
43
- if (assets.length && !pending.length) {
44
- const failed = assets.filter((a) => stateOf(a) === 'failed');
45
- if (failed.length === assets.length) {
46
- throw new Error(`Generation failed: ${failed
47
- .map((a) => a.error?.message || a.error || 'cancelled')
48
- .join('; ')}`);
49
- }
50
- if (failed.length) {
51
- warn(`${failed.length}/${assets.length} outputs failed`);
52
- }
53
- return { history, assets };
54
- }
55
- const elapsed = Math.round((Date.now() - started) / 1000);
56
- if (elapsed - lastReport >= 10) {
57
- lastReport = elapsed;
58
- const ready = assets.length - pending.length;
59
- info(color.dim(` …${elapsed}s (${ready}/${assets.length || '?'} ready)`));
60
- }
61
- await new Promise((r) => setTimeout(r, intervalMs));
62
- }
63
- throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s. The task may still finish — check \`wji history get ${id}\`.`);
64
- }
65
- export function reportHistory(history, assets) {
66
- detail([
67
- ['history', history.id],
68
- ['type', history.type],
69
- ['prompt', truncate(history.prompt, 70)],
70
- ['credits', history.chargedCredits],
71
- ['assets', assets.length || assetIdsOf(history).length],
72
- ]);
73
- if (!assets.length)
74
- return;
75
- table(assets, [
76
- { header: 'assetId', value: (a) => a.id },
77
- { header: 'state', value: (a) => stateOf(a) },
78
- { header: 'name', value: (a) => truncate(a.name, 28) },
79
- { header: 'url', value: (a) => a.url || a.error?.message },
80
- ]);
81
- }
82
- async function submit(client, kind, body, opts) {
83
- // Always the history endpoint: the raw one silently drops fsnode, scope,
84
- // scopeId and tags, and returns no id to poll with.
85
- const result = await client.post(`/ai/assets/history/${kind}/generations`, body);
86
- if (!opts.wait) {
87
- if (isJsonMode())
88
- return printJson(result);
89
- success(`Submitted ${kind} generation`);
90
- reportHistory(result, await resolveAssets(client, result));
91
- info(color.dim(`Poll with: wji history get ${result.id} --wait`));
92
- return;
93
- }
94
- info(color.dim(`Submitted ${result.id}, waiting…`));
95
- const { history, assets } = await pollHistory(client, result.id, {
96
- timeoutMs: Number(opts.timeout) * 1000,
97
- intervalMs: Number(opts.interval) * 1000,
98
- });
99
- if (isJsonMode())
100
- return printJson({ ...history, images: assets });
101
- success(`${kind} generation complete`);
102
- reportHistory(history, assets);
103
- }
104
- function withCommonOptions(cmd) {
105
- return cmd
106
- .requiredOption('-m, --model <name>', 'model name (see `wji models list`)')
107
- .option('-p, --prompt <text>', 'prompt text (@file / - for stdin)')
108
- .option('-n, --n <count>', 'number of outputs', '1')
109
- .option('-w, --workspace <id>', 'workspace to bill and file the result under')
110
- .option('--fsnode <id>', 'file the history entry under an fsnode (does not add the asset to its images)')
111
- .option('--scope <scope>', 'history scope, e.g. workspace | canvas')
112
- .option('--scope-id <id>', 'history scope id')
113
- .option('-r, --ratio <ratio>', 'aspect ratio, e.g. 16:9')
114
- .option('--tags <tag...>', 'tags for the history entry')
115
- .option('--values <json>', 'model-specific form values (@file / - supported)')
116
- .option('--set <key=value...>', 'model-specific form values as key=value')
117
- .option('--wait', 'block until the task finishes')
118
- .option('--timeout <seconds>', 'wait timeout', '900')
119
- .option('--interval <seconds>', 'poll interval', '5');
120
- }
121
- export function genCommand(globals) {
122
- const gen = new Command('gen').description('Generate images, video and audio');
123
- withCommonOptions(gen
124
- .command('image')
125
- .description('Generate images')
126
- .option('--ref <ref...>', 'reference images as assetId[:label[:weight]]')
127
- .option('--omni <ref...>', 'omni reference images as assetId[:label[:weight]]')
128
- .option('--style-ref <ref...>', 'style reference images as assetId[:label[:weight]]')
129
- .option('--reference-weight <n>', 'weight for --ref')
130
- .option('--stylize <n>', 'stylize strength')
131
- .option('--model-options <json>', 'raw modelOptions JSON')).action(async (opts) => {
132
- const client = new WjiClient(globals());
133
- await submit(client, 'image', {
134
- model: opts.model,
135
- prompt: opts.prompt ? await readValue(opts.prompt) : '',
136
- n: Number(opts.n),
137
- workspace: client.workspace(opts.workspace),
138
- fsnode: opts.fsnode,
139
- scope: opts.scope,
140
- scopeId: opts.scopeId,
141
- aspectRatio: opts.ratio,
142
- tags: opts.tags,
143
- referenceImages: refs(opts.ref),
144
- omniReferenceImages: refs(opts.omni),
145
- styleReferenceImages: refs(opts.styleRef),
146
- referenceWeight: opts.referenceWeight ? Number(opts.referenceWeight) : undefined,
147
- stylize: opts.stylize ? Number(opts.stylize) : undefined,
148
- modelOptions: await readJson(opts.modelOptions),
149
- values: { ...((await readJson(opts.values)) || {}), ...parseKeyValues(opts.set) },
150
- }, opts);
151
- });
152
- withCommonOptions(gen
153
- .command('video')
154
- .description('Generate video')
155
- .option('-d, --duration <seconds>', 'clip duration')
156
- .option('--start-image <ref>', 'first frame as assetId[:label]')
157
- .option('--end-image <ref>', 'last frame as assetId[:label]')
158
- .option('--ref <ref...>', 'reference images as assetId[:label[:weight]]')
159
- .option('--ref-video <ref...>', 'motion reference videos as assetId[:label[:weight]]')
160
- .option('--ref-audio <ref...>', 'audio references as assetId[:label[:weight]]')).action(async (opts) => {
161
- const client = new WjiClient(globals());
162
- await submit(client, 'video', {
163
- model: opts.model,
164
- prompt: opts.prompt ? await readValue(opts.prompt) : '',
165
- n: Number(opts.n),
166
- workspace: client.workspace(opts.workspace),
167
- fsnode: opts.fsnode,
168
- scope: opts.scope,
169
- scopeId: opts.scopeId,
170
- aspectRatio: opts.ratio,
171
- tags: opts.tags,
172
- duration: opts.duration ? Number(opts.duration) : undefined,
173
- startImage: opts.startImage ? parseReference(opts.startImage) : undefined,
174
- endImage: opts.endImage ? parseReference(opts.endImage) : undefined,
175
- referenceImages: refs(opts.ref),
176
- referenceVideos: refs(opts.refVideo),
177
- referenceAudios: refs(opts.refAudio),
178
- values: { ...((await readJson(opts.values)) || {}), ...parseKeyValues(opts.set) },
179
- }, opts);
180
- });
181
- withCommonOptions(gen
182
- .command('audio')
183
- .description('Generate audio')
184
- .option('-d, --duration <seconds>', 'duration')
185
- .option('--ref-audio <ref...>', 'audio references as assetId[:label[:weight]]')
186
- .option('--ref <ref...>', 'reference image, e.g. a headshot for a Seed Audio voice sample (not with --ref-audio)')).action(async (opts) => {
187
- if (opts.ref?.length && opts.refAudio?.length)
188
- throw new Error('Use --ref or --ref-audio, not both');
189
- const client = new WjiClient(globals());
190
- await submit(client, 'audio', {
191
- model: opts.model,
192
- prompt: opts.prompt ? await readValue(opts.prompt) : '',
193
- n: Number(opts.n),
194
- workspace: client.workspace(opts.workspace),
195
- fsnode: opts.fsnode,
196
- scope: opts.scope,
197
- scopeId: opts.scopeId,
198
- tags: opts.tags,
199
- referenceAudios: refs(opts.refAudio),
200
- referenceImages: refs(opts.ref),
201
- values: {
202
- ...((await readJson(opts.values)) || {}),
203
- ...parseKeyValues(opts.set),
204
- ...(opts.duration ? { duration: Number(opts.duration) } : {}),
205
- },
206
- }, opts);
207
- });
208
- gen
209
- .command('cost')
210
- .description('Estimate the credit cost of a generation before running it')
211
- .requiredOption('-t, --type <type>', 'image | video | audio')
212
- .requiredOption('-m, --model <name>', 'model name')
213
- .option('-n, --n <count>', 'number of outputs', '1')
214
- .option('-p, --prompt <text>', 'prompt (some models price by length)')
215
- .option('-d, --duration <seconds>', 'duration for video/audio')
216
- .option('-r, --ratio <ratio>', 'aspect ratio')
217
- .option('--refs <n>', 'number of reference images')
218
- .option('--values <json>', 'model-specific form values')
219
- .action(async (opts) => {
220
- const client = new WjiClient(globals());
221
- const result = await client.post('/ai/assets/preComputeCost', {
222
- type: opts.type,
223
- model: opts.model,
224
- n: Number(opts.n),
225
- prompt: opts.prompt,
226
- duration: opts.duration ? Number(opts.duration) : undefined,
227
- aspectRatio: opts.ratio,
228
- referenceImageCount: opts.refs ? Number(opts.refs) : undefined,
229
- values: await readJson(opts.values),
230
- });
231
- render(result, (r) => typeof r === 'object' ? detail(Object.entries(r)) : process.stdout.write(`${r}\n`));
232
- });
233
- return gen;
234
- }
235
- export function historyCommand(globals) {
236
- const history = new Command('history').description('Browse generation history');
237
- history
238
- .command('list', { isDefault: true })
239
- .description('Search generation history')
240
- .option('-w, --workspace <id>', 'workspace scope')
241
- .option('--mode <mode>', 'image | video | audio')
242
- .option('--fsnode <id>', 'filter by fsnode')
243
- .option('--scope <scope>', 'workspace | canvas | …')
244
- .option('--scope-id <id>', 'scope id')
245
- .option('--tags <tag...>', 'filter by tags')
246
- .option('--bookmarked', 'only bookmarked entries')
247
- .option('--page <n>', 'page number', '1')
248
- .option('--limit <n>', 'page size', '20')
249
- .action(async (opts) => {
250
- const client = new WjiClient(globals());
251
- const result = await client.get('/ai/assets/history/search', {
252
- workspace: client.workspace(opts.workspace),
253
- mode: opts.mode,
254
- fsnodeId: opts.fsnode,
255
- scope: opts.scope,
256
- scopeId: opts.scopeId,
257
- tags: opts.tags,
258
- bookmarked: opts.bookmarked ? true : undefined,
259
- page: Number(opts.page),
260
- limit: Number(opts.limit),
261
- detail: detailLevel(),
262
- });
263
- render(result, (r) => {
264
- table(r.data || [], [
265
- { header: 'id', value: (h) => h.id },
266
- { header: 'type', value: (h) => h.type },
267
- { header: 'assets', value: (h) => assetIdsOf(h).length },
268
- { header: 'prompt', value: (h) => truncate(h.prompt, 48) },
269
- { header: 'created', value: (h) => h.createdAt?.slice?.(0, 19)?.replace('T', ' ') },
270
- ]);
271
- info(color.dim(`${r.pagination?.total ?? 0} total`));
272
- });
273
- });
274
- history
275
- .command('get <id>')
276
- .description('Show one history entry')
277
- .option('--wait', 'poll until the task finishes')
278
- .option('--timeout <seconds>', 'wait timeout', '900')
279
- .option('--interval <seconds>', 'poll interval', '5')
280
- .action(async (id, opts) => {
281
- const client = new WjiClient(globals());
282
- let history;
283
- let assets;
284
- if (opts.wait) {
285
- ;
286
- ({ history, assets } = await pollHistory(client, id, {
287
- timeoutMs: Number(opts.timeout) * 1000,
288
- intervalMs: Number(opts.interval) * 1000,
289
- }));
290
- }
291
- else {
292
- history = await client.get(`/ai/assets/history/list/${id}`, { detail: detailLevel() });
293
- assets = await resolveAssets(client, history);
294
- }
295
- render({ ...history, images: assets }, () => reportHistory(history, assets));
296
- });
297
- history
298
- .command('bookmark <id>')
299
- .description('Bookmark or unbookmark an entry')
300
- .option('--off', 'remove the bookmark')
301
- .action(async (id, opts) => {
302
- const client = new WjiClient(globals());
303
- const result = await client.put(`/ai/assets/history/list/${id}/bookmark`, {
304
- bookmarked: !opts.off,
305
- });
306
- render(result, () => success(opts.off ? 'Bookmark removed' : 'Bookmarked'));
307
- });
308
- history
309
- .command('rm <id>')
310
- .description('Delete a history entry')
311
- .option('-y, --yes', 'skip confirmation')
312
- .action(async (id, opts) => {
313
- const client = new WjiClient(globals());
314
- const h = await client.get(`/ai/assets/history/list/${id}`).catch(() => null);
315
- const about = [h?.mode, h?.model, h?.prompt ? `"${truncate(h.prompt, 40)}"` : undefined]
316
- .filter(Boolean)
317
- .join(' · ');
318
- if (!(await confirm(`Delete history entry ${id}${about ? ` (${about})` : ''}?`, opts.yes))) {
319
- return info('Aborted');
320
- }
321
- const result = await client.delete(`/ai/assets/history/list/${id}`);
322
- render(result, () => success(`Deleted ${id}`));
323
- });
324
- history
325
- .command('download <id>')
326
- .description('Download every finished asset in a history entry')
327
- .option('-o, --out <dir>', 'output directory', '.')
328
- .action(async (id, opts) => {
329
- const client = new WjiClient(globals());
330
- const entry = await client.get(`/ai/assets/history/list/${id}`);
331
- const ready = (await resolveAssets(client, entry)).filter((a) => a.url && stateOf(a) === 'ready');
332
- if (!ready.length)
333
- return warn('Nothing to download yet');
334
- const { createWriteStream } = await import('node:fs');
335
- const { Readable } = await import('node:stream');
336
- const { pipeline } = await import('node:stream/promises');
337
- const { join } = await import('node:path');
338
- const saved = [];
339
- for (const a of ready) {
340
- const res = await fetch(a.url);
341
- if (!res.ok || !res.body) {
342
- warn(`Skipped ${a.id}: ${res.status}`);
343
- continue;
344
- }
345
- const ext = /\.([a-z0-9]{2,5})(?:\?|$)/i.exec(a.url)?.[1] || 'bin';
346
- const target = join(opts.out, `${a.id}.${ext}`);
347
- await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
348
- saved.push(target);
349
- if (!isJsonMode())
350
- success(`Saved ${target}`);
351
- }
352
- if (isJsonMode())
353
- printJson({ id, files: saved });
354
- });
355
- return history;
356
- }
@@ -1,136 +0,0 @@
1
- import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
3
- import { color, detail, detailLevel, info, render, table, truncate, warn } from '../core/output.js';
4
- const FEATURES = [
5
- 'text',
6
- 'image',
7
- 'video',
8
- 'audio',
9
- 'tts',
10
- 'web',
11
- 'thinking',
12
- 'function_calling',
13
- 'image_vision',
14
- 'video_vision',
15
- 'structured_output',
16
- 'digital_host',
17
- 'upscale_video',
18
- ];
19
- /**
20
- * The endpoint filters with `features.some(...)`, so an empty feature list
21
- * matches nothing. Asking for every feature is the only way to "list all".
22
- */
23
- export async function fetchModels(client, opts) {
24
- const features = opts.features?.length ? opts.features : FEATURES;
25
- const list = await client.get(`/ai/model/list/${features.join(',')}`, {
26
- workspaceId: client.workspace(opts.workspace),
27
- detail: opts.detail,
28
- });
29
- return Array.isArray(list) ? list : list?.data || [];
30
- }
31
- export function modelCommand(globals) {
32
- const models = new Command('models').aliases(['model']).description('List available AI models');
33
- models
34
- .command('list', { isDefault: true })
35
- .description('List models, optionally filtered by feature')
36
- .option('-f, --feature <feature...>', `filter: ${FEATURES.join(', ')}`)
37
- .option('-w, --workspace <id>', 'resolve pricing/access for a workspace')
38
- .option('-k, --keyword <text>', 'match name, nickname or description')
39
- .option('--all', 'include hidden and disabled models')
40
- .action(async (opts) => {
41
- const client = new WjiClient(globals());
42
- let rows = await fetchModels(client, {
43
- features: opts.feature,
44
- workspace: opts.workspace,
45
- detail: detailLevel(),
46
- });
47
- if (!opts.all)
48
- rows = rows.filter((m) => m.enabled !== false && !m.hidden);
49
- if (opts.keyword) {
50
- const needle = opts.keyword.toLowerCase();
51
- rows = rows.filter((m) => [m.name, m.nickname, m.description].some((v) => String(v || '').toLowerCase().includes(needle)));
52
- }
53
- // The client endpoint returns a curated model view — no rate, tags or id —
54
- // so the useful columns come out of the form schema instead.
55
- render(rows, () => {
56
- table(rows, [
57
- { header: 'model', value: (m) => m.nickname },
58
- { header: 'name', value: (m) => (m.name === m.nickname ? '' : m.name) },
59
- { header: 'features', value: (m) => truncate((m.features || []).join(','), 22) },
60
- { header: 'ratios', value: (m) => truncate((m.form?.aspectRatio || []).join(' '), 30) },
61
- { header: 'max n', value: (m) => m.form?.batch?.max },
62
- { header: 'refs', value: (m) => (m.form?.reference?.enable ? m.form.reference.max : '') },
63
- { header: 'duration', value: (m) => durationLabel(m.form?.video?.duration) },
64
- { header: 'params', value: (m) => truncate(Object.keys(m.form?.other || {}).join(','), 34) },
65
- ]);
66
- info(color.dim(`${rows.length} models · pass the MODEL column to \`-m\``));
67
- });
68
- });
69
- models
70
- .command('get <name>')
71
- .description('Show a model, including its form parameter schema')
72
- .option('-w, --workspace <id>', 'resolve pricing/access for a workspace')
73
- .action(async (name, opts) => {
74
- const client = new WjiClient(globals());
75
- const rows = await fetchModels(client, { workspace: opts.workspace, detail: detailLevel() });
76
- // `name` is not unique across providers; `nickname` is, so prefer it.
77
- const matches = rows.filter((m) => m.nickname === name || m.name === name);
78
- if (!matches.length)
79
- throw new Error(`No model named "${name}". Try \`wji models list\`.`);
80
- const model = matches.find((m) => m.nickname === name) ?? matches[0];
81
- if (matches.length > 1) {
82
- warn(`${matches.length} models share the name "${name}" — showing "${model.nickname}". ` +
83
- `Others: ${matches
84
- .filter((m) => m !== model)
85
- .map((m) => m.nickname)
86
- .join(', ')}`);
87
- }
88
- render(model, (m) => {
89
- const form = m.form || {};
90
- detail([
91
- ['name', m.name],
92
- ['nickname', m.nickname],
93
- ['description', m.description],
94
- ['features', (m.features || []).join(', ')],
95
- ['aspectRatio', (form.aspectRatio || []).join(' ')],
96
- ['n (--n)', form.batch ? `${form.batch.min}-${form.batch.max} (default ${form.batch.default})` : null],
97
- ['references', form.reference?.enable ? `up to ${form.reference.max}` : 'not supported'],
98
- ['duration (-d)', durationLabel(form.video?.duration)],
99
- ['video frames', (form.video?.support || []).join(', ')],
100
- ['accepted', (form.props?.filter || []).join(', ')],
101
- ['required', (form.props?.required || []).join(', ')],
102
- ]);
103
- printOtherParams(form.other);
104
- });
105
- });
106
- models
107
- .command('features')
108
- .description('List the feature flags accepted by --feature')
109
- .action(() => render(FEATURES, (f) => f.forEach((x) => process.stdout.write(x + '\n'))));
110
- return models;
111
- }
112
- function durationLabel(duration) {
113
- if (!duration?.enable)
114
- return null;
115
- return `${duration.min}-${duration.max}s (default ${duration.default})`;
116
- }
117
- /** `form.other` holds the model-specific knobs reachable through `--set`. */
118
- function printOtherParams(other) {
119
- const entries = Object.entries(other || {});
120
- if (!entries.length)
121
- return;
122
- info('');
123
- info(color.bold('model parameters (pass as --set <key>=<value>):'));
124
- for (const [key, raw] of entries) {
125
- const spec = raw;
126
- const options = spec?.options
127
- ? ` [${spec.options.map((o) => o?.value ?? o).join(' | ')}]`
128
- : spec?.type === 'boolean'
129
- ? ' [true | false]'
130
- : '';
131
- const fallback = spec?.default != null ? ` (default ${spec.default})` : '';
132
- process.stdout.write(` ${color.cyan(key)}${options}${color.dim(fallback)}\n`);
133
- if (spec?.description)
134
- process.stdout.write(` ${color.dim(spec.description)}\n`);
135
- }
136
- }