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