@wji.ai/cli 0.4.0 → 0.5.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.
Files changed (58) hide show
  1. package/README.md +48 -2
  2. package/dist/commands/backreel.js +260 -0
  3. package/dist/commands/canvas.js +588 -88
  4. package/dist/commands/class.js +395 -0
  5. package/dist/commands/npc.js +413 -0
  6. package/dist/commands/workspace.js +109 -24
  7. package/dist/core/members.js +54 -0
  8. package/dist/core/npc.js +108 -0
  9. package/dist/core/output.js +4 -2
  10. package/dist/index.js +8 -0
  11. package/package.json +1 -1
  12. package/skill/short-drama/SKILL.md +86 -8
  13. package/skill/short-drama/reference/action-bullet-time.md +47 -0
  14. package/skill/short-drama/reference/action-car-chase.md +51 -0
  15. package/skill/short-drama/reference/action-gun-fu.md +52 -0
  16. package/skill/short-drama/reference/action-historical-realist.md +50 -0
  17. package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
  18. package/skill/short-drama/reference/action-street-brawl.md +49 -0
  19. package/skill/short-drama/reference/action-superhero.md +50 -0
  20. package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
  21. package/skill/short-drama/reference/action.md +94 -0
  22. package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
  23. package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
  24. package/skill/short-drama/reference/cinematography-low-key.md +115 -0
  25. package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
  26. package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
  27. package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
  28. package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
  29. package/skill/short-drama/reference/cinematography.md +83 -0
  30. package/skill/short-drama/reference/composition-art-first.md +74 -0
  31. package/skill/short-drama/reference/craft-shots.md +3 -1
  32. package/skill/short-drama/reference/craft-story.md +4 -1
  33. package/skill/short-drama/reference/format.md +94 -0
  34. package/skill/short-drama/reference/grammar-comedy.md +105 -0
  35. package/skill/short-drama/reference/grammar-faceslap.md +67 -0
  36. package/skill/short-drama/reference/grammar.md +103 -0
  37. package/skill/short-drama/reference/operations-cli.md +1 -1
  38. package/skill/short-drama/reference/schema.md +34 -3
  39. package/skill/short-drama/reference/stage-art.md +1 -1
  40. package/skill/short-drama/reference/stage-outline.md +21 -0
  41. package/skill/short-drama/reference/stage-script.md +10 -0
  42. package/skill/short-drama/reference/stage-storyboard.md +15 -0
  43. package/skill/short-drama/reference/story-angst.md +117 -0
  44. package/skill/short-drama/reference/story-comedy.md +136 -0
  45. package/skill/short-drama/reference/story-cultivation.md +109 -0
  46. package/skill/short-drama/reference/story-family.md +119 -0
  47. package/skill/short-drama/reference/story-horror.md +124 -0
  48. package/skill/short-drama/reference/story-mystery.md +127 -0
  49. package/skill/short-drama/reference/story-palace.md +103 -0
  50. package/skill/short-drama/reference/story-revenge.md +124 -0
  51. package/skill/short-drama/reference/story-scifi.md +112 -0
  52. package/skill/short-drama/reference/story-sweet.md +128 -0
  53. package/skill/short-drama/reference/story.md +127 -0
  54. package/skill/short-drama/scripts/selftest.mjs +809 -1
  55. package/skill/short-drama/scripts/short-drama.mjs +1608 -16
  56. package/skill/wji/SKILL.md +66 -2
  57. package/skill/wji/reference/commands.md +76 -15
  58. package/skill/wji-classroom/SKILL.md +161 -0
@@ -0,0 +1,395 @@
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Command } from 'commander';
7
+ import { WjiClient } from '../core/client.js';
8
+ import { confirm } from '../core/prompt.js';
9
+ import { color, info, isJsonMode, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
10
+ import { loadMembers, mapLimit, memberLabel, resolveMember } from '../core/members.js';
11
+ import { COLOR_TAGS, canvasesFolders, copyNodes, copyPlacement, copySource, createCanvasFolder, describe, linkedState, listCanvases, loadCanvas, parseVisibility, tagText, updateCanvasFolder, } from './canvas.js';
12
+ const WORKERS = 4;
13
+ async function loadClass(c, wsId) {
14
+ const [{ workspace, members }, canvases] = await Promise.all([loadMembers(c, wsId), listCanvases(c, wsId)]);
15
+ const students = members.filter((m) => m.role !== 'owner');
16
+ const seats = students.map((member) => ({
17
+ member,
18
+ canvases: canvases.filter((cv) => cv.ownerUserId === member.userId),
19
+ }));
20
+ return { workspace, members, canvases, seats };
21
+ }
22
+ /** `--student` narrows the roster; otherwise every non-owner member. */
23
+ function pickSeats(seats, members, tokens) {
24
+ if (!tokens?.length)
25
+ return seats;
26
+ const wanted = new Set(tokens.map((t) => resolveMember(members, t).userId));
27
+ const picked = seats.filter((s) => wanted.has(s.member.userId));
28
+ const owners = members.filter((m) => m.role === 'owner' && wanted.has(m.userId));
29
+ if (owners.length)
30
+ warn(`${owners.map(memberLabel).join(', ')} is the teacher (workspace owner), not a student — skipped`);
31
+ return picked;
32
+ }
33
+ function seatCanvas(seat) {
34
+ if (seat.canvases.length > 1) {
35
+ warn(`${memberLabel(seat.member)} owns ${seat.canvases.length} canvases — using the most recently updated "${[...seat.canvases].sort(byUpdated)[0].name}"`);
36
+ }
37
+ return [...seat.canvases].sort(byUpdated)[0];
38
+ }
39
+ const byUpdated = (a, b) => Date.parse(b.updatedAt || '0') - Date.parse(a.updatedAt || '0');
40
+ /** Color ids a `--tag` value means on this canvas; a label the canvas lacks matches nothing (no error per student). */
41
+ function tagColors(canvas, tags) {
42
+ if (!tags?.length)
43
+ return undefined;
44
+ const colors = new Set();
45
+ for (const raw of tags) {
46
+ const v = raw.trim().toLowerCase();
47
+ for (const t of COLOR_TAGS) {
48
+ if (t.id === v || t.alias === v || canvas.tagNames[t.id]?.toLowerCase() === v)
49
+ colors.add(t.id);
50
+ }
51
+ }
52
+ return colors;
53
+ }
54
+ function parseSince(value) {
55
+ if (!value)
56
+ return undefined;
57
+ const m = /^(\d+)\s*(m|h|d)$/i.exec(value.trim());
58
+ if (m) {
59
+ const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
60
+ return Date.now() - Number(m[1]) * unit;
61
+ }
62
+ const t = Date.parse(value);
63
+ if (Number.isNaN(t))
64
+ throw new Error('--since takes 30m, 2h, 1d or a date');
65
+ return t;
66
+ }
67
+ function requireStudents(seats, filtered) {
68
+ if (seats.length)
69
+ return;
70
+ if (filtered?.length)
71
+ throw new Error('No student matches --student');
72
+ throw new Error('No students in this workspace yet. Invite them with `wji ws member invite <emails...>`.');
73
+ }
74
+ export function classCommand(globals) {
75
+ const cls = new Command('class')
76
+ .aliases(['classroom'])
77
+ .description('Teach with a workspace: one canvas per student, copy work to everyone, check progress and results');
78
+ const client = () => new WjiClient(globals());
79
+ const workspaceOf = async (c, explicit) => explicit || (await linkedState(c))?.workspaceId || c.requireWorkspace();
80
+ cls
81
+ .command('roster')
82
+ .description('Students (non-owner members) with their role and canvas')
83
+ .option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
84
+ .action(async (opts) => {
85
+ const c = client();
86
+ const wsId = await workspaceOf(c, opts.workspace);
87
+ const { workspace, members, seats } = await loadClass(c, wsId);
88
+ const teacher = members.filter((m) => m.role === 'owner').map(memberLabel).join(', ');
89
+ const rows = seats.map((s) => ({
90
+ userId: s.member.userId,
91
+ name: s.member.name,
92
+ email: s.member.email,
93
+ role: s.member.role,
94
+ canvases: s.canvases.map(({ folder, ...cv }) => cv),
95
+ }));
96
+ render({ workspace: { id: wsId, name: workspace.name }, teacher, students: rows }, () => {
97
+ info(color.dim(`${workspace.name} (${wsId}) · teacher ${teacher} · ${rows.length} student(s)`));
98
+ table(rows, [
99
+ { header: 'name', value: (r) => r.name },
100
+ { header: 'email', value: (r) => r.email },
101
+ { header: 'role', value: (r) => (r.role === 'viewer' ? color.yellow('viewer') : r.role) },
102
+ { header: 'canvas', value: (r) => (r.canvases.length ? r.canvases.map((cv) => cv.name).join(', ') : color.red('none')) },
103
+ { header: 'visibility', value: (r) => r.canvases.map((cv) => (cv.visibility === 'owner' ? 'private' : 'shared')).join(', ') },
104
+ ]);
105
+ if (rows.some((r) => r.role === 'viewer'))
106
+ info(color.dim('Viewers cannot generate; make them editors with `wji ws member role <name> editor`.'));
107
+ if (rows.some((r) => !r.canvases.length))
108
+ info(color.dim('Give everyone a canvas with `wji class canvases --create`.'));
109
+ });
110
+ });
111
+ cls
112
+ .command('canvases')
113
+ .description('Each student\'s own canvas; --create makes the missing ones (named after the student, owned by them)')
114
+ .option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
115
+ .option('--create', 'create a canvas for every student who has none')
116
+ .option('--visibility <v>', 'owner (private: the student and the teacher) | members (the whole class sees it)', 'owner')
117
+ .option('--update', 'also apply --visibility to the students\' existing canvases')
118
+ .option('--name <template>', 'canvas name; {name} and {email} are filled in', '{name}')
119
+ .option('--student <members...>', 'only these students (name, email or user id)')
120
+ .option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
121
+ .action(async (opts) => {
122
+ const c = client();
123
+ const wsId = await workspaceOf(c, opts.workspace);
124
+ const visibility = parseVisibility(opts.visibility);
125
+ const { members, seats: all } = await loadClass(c, wsId);
126
+ const seats = pickSeats(all, members, opts.student);
127
+ requireStudents(seats, opts.student);
128
+ const created = [];
129
+ const updated = [];
130
+ if (opts.create && seats.some((s) => !s.canvases.length)) {
131
+ const folders = await canvasesFolders(c, wsId);
132
+ const target = opts.app ? folders.find((f) => f.appId === opts.app) : folders[0];
133
+ if (!target)
134
+ throw new Error(`Workspace ${wsId} has no InfiniteCanvas app${opts.app ? ` ${opts.app}` : ''}. Add one with \`wji ws app add canvas\`.`);
135
+ for (const seat of seats.filter((s) => !s.canvases.length)) {
136
+ const m = seat.member;
137
+ const name = opts.name.replace(/\{name\}/g, m.name || m.email || m.userId).replace(/\{email\}/g, m.email);
138
+ const raw = await createCanvasFolder(c, target.folderId, name, { ownerUserId: m.userId, visibility });
139
+ seat.canvases.push({ id: raw.id, name: raw.name, appId: target.appId, ownerUserId: m.userId, visibility, updatedAt: raw.updatedAt, folder: raw });
140
+ created.push({ userId: m.userId, canvasId: raw.id, name: raw.name });
141
+ }
142
+ }
143
+ if (opts.update) {
144
+ for (const seat of seats) {
145
+ for (const cv of seat.canvases.filter((x) => x.visibility !== visibility && !created.some((k) => k.canvasId === x.id))) {
146
+ await updateCanvasFolder(c, cv.id, (env) => {
147
+ env.metadata.visibility = visibility;
148
+ });
149
+ cv.visibility = visibility;
150
+ updated.push(cv.id);
151
+ }
152
+ }
153
+ }
154
+ const rows = seats.map((s) => ({ student: memberLabel(s.member), userId: s.member.userId, canvases: s.canvases.map(({ folder, ...cv }) => cv) }));
155
+ render({ created, updated, students: rows }, () => {
156
+ if (created.length)
157
+ success(`Created ${created.length} canvas(es)`);
158
+ if (updated.length)
159
+ success(`Set ${updated.length} canvas(es) to ${visibility === 'owner' ? 'private' : 'shared'}`);
160
+ table(rows, [
161
+ { header: 'student', value: (r) => r.student },
162
+ { header: 'canvas', value: (r) => (r.canvases.length ? r.canvases.map((cv) => cv.name).join(', ') : color.red('none')) },
163
+ { header: 'id', value: (r) => color.dim(r.canvases.map((cv) => cv.id).join(', ')) },
164
+ { header: 'visibility', value: (r) => r.canvases.map((cv) => (cv.visibility === 'owner' ? 'private' : 'shared')).join(', ') },
165
+ ]);
166
+ if (!opts.create && rows.some((r) => !r.canvases.length))
167
+ info(color.dim('Pass --create to give the others a canvas.'));
168
+ });
169
+ });
170
+ cls
171
+ .command('copy [nodeIds...]')
172
+ .description('Copy nodes into every student\'s canvas (default: the nodes selected in the linked browser canvas). ' +
173
+ 'Connections between them come along; finished media is shared, generate nodes keep their prompt and settings.')
174
+ .option('-c, --canvas <id>', 'source canvas (defaults to the linked canvas)')
175
+ .option('--selected', 'add the linked browser selection to the node ids given')
176
+ .option('--student <members...>', 'only these students (name, email or user id)')
177
+ .option('--at <x,y>', 'top-left of the copied group in each canvas; default is right of what is already there')
178
+ .option('--same-position', 'keep the original coordinates')
179
+ .option('-y, --yes', 'skip confirmation')
180
+ .action(async (ids, opts) => {
181
+ const c = client();
182
+ const { source, nodes } = await copySource(c, ids, opts);
183
+ const { members, seats: all } = await loadClass(c, source.workspaceId);
184
+ const seats = pickSeats(all, members, opts.student);
185
+ requireStudents(seats, opts.student);
186
+ const missing = seats.filter((s) => !s.canvases.length);
187
+ if (missing.length)
188
+ warn(`No canvas yet, skipped: ${missing.map((s) => memberLabel(s.member)).join(', ')} (create them with \`wji class canvases --create\`)`);
189
+ const targets = seats
190
+ .filter((s) => s.canvases.length)
191
+ .map((s) => ({ seat: s, canvas: seatCanvas(s) }))
192
+ .filter((t) => t.canvas.id !== source.id);
193
+ if (!targets.length)
194
+ throw new Error('No student canvas to copy into');
195
+ const list = nodes.map((n) => `${n.kind} "${n.name}"`).join(', ');
196
+ const question = `Copy ${nodes.length} node(s) (${truncate(list, 120)}) from "${source.name}" into ${targets.length} student canvas(es)?`;
197
+ if (!(await confirm(question, opts.yes)))
198
+ return info('Aborted');
199
+ const placement = copyPlacement(opts);
200
+ const results = await mapLimit(targets, WORKERS, async ({ seat, canvas }) => {
201
+ try {
202
+ const target = await loadCanvas(c, canvas.id);
203
+ const r = await copyNodes(c, source, nodes, target, placement);
204
+ if (!isJsonMode())
205
+ success(`${memberLabel(seat.member)}: ${r.nodes.length} node(s), ${r.edges} connection(s) → ${canvas.name}`);
206
+ return { student: memberLabel(seat.member), userId: seat.member.userId, ok: true, ...r };
207
+ }
208
+ catch (err) {
209
+ if (!isJsonMode())
210
+ warn(`${memberLabel(seat.member)}: ${err.message}`);
211
+ return { student: memberLabel(seat.member), userId: seat.member.userId, ok: false, canvasId: canvas.id, error: err.message };
212
+ }
213
+ });
214
+ const failed = results.filter((r) => !r.ok);
215
+ render(results, () => {
216
+ const skipped = results.find((r) => r.ok)?.skippedEdges;
217
+ if (skipped)
218
+ info(color.dim(`${skipped} connection(s) from nodes outside the copy were left out`));
219
+ if (failed.length)
220
+ warn(`${failed.length} failed — rerun with --student ${failed.map((r) => JSON.stringify(r.student)).join(' ')}`);
221
+ else
222
+ success(`Copied into ${results.length} canvas(es)`);
223
+ });
224
+ if (failed.length)
225
+ process.exitCode = 1;
226
+ });
227
+ cls
228
+ .command('status')
229
+ .description('Progress per student: generate nodes done / running / failed / not started, generations and credits')
230
+ .option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
231
+ .option('--tag <tags...>', 'only count nodes with these color tags (label like HW1, or a color)')
232
+ .option('-k, --kind <kind>', 'only count this node kind (image | video | audio)')
233
+ .option('--student <members...>', 'only these students')
234
+ .option('-s, --sort <by>', 'name | recent (last generation first) | progress (fewest done first)', 'name')
235
+ .action(async (opts) => {
236
+ const c = client();
237
+ const wsId = await workspaceOf(c, opts.workspace);
238
+ const { members, seats: all } = await loadClass(c, wsId);
239
+ const seats = pickSeats(all, members, opts.student);
240
+ requireStudents(seats, opts.student);
241
+ const usage = await c.get(`/workspace/list/${wsId}/quota-usage`).catch(() => null);
242
+ const credits = new Map((usage?.byUserTotal || []).map((u) => [String(u.userId), Number(u.amount) || 0]));
243
+ const rows = await mapLimit(seats, WORKERS, async (seat) => {
244
+ const base = { student: memberLabel(seat.member), userId: seat.member.userId, email: seat.member.email, credits: credits.get(seat.member.userId) ?? 0 };
245
+ if (!seat.canvases.length)
246
+ return { ...base, canvas: null, nodes: 0, done: 0, running: 0, failed: 0, todo: 0, generations: 0, lastGeneratedAt: null };
247
+ const cvRow = seatCanvas(seat);
248
+ const cv = await loadCanvas(c, cvRow.id);
249
+ const colors = tagColors(cv, opts.tag);
250
+ const nodes = cv.nodes
251
+ .filter((n) => !opts.kind || n.kind === opts.kind)
252
+ .filter((n) => !colors || colors.has(n.content.colorTag?.colorId))
253
+ .map((n) => describe(cv, n));
254
+ const gen = nodes.filter((n) => n.mode === 'generate');
255
+ const history = await c.getFresh('/ai/assets/history/search', {
256
+ workspace: wsId,
257
+ scope: 'canvas',
258
+ scopeId: cv.id,
259
+ page: 1,
260
+ limit: 1,
261
+ sort: { createdAt: 'desc' },
262
+ });
263
+ return {
264
+ ...base,
265
+ canvas: { id: cv.id, name: cv.name },
266
+ nodes: nodes.length,
267
+ done: gen.filter((n) => n.status === 'done').length,
268
+ running: gen.filter((n) => n.status === 'generating' || n.status === 'pending').length,
269
+ failed: gen.filter((n) => n.status === 'failed').length,
270
+ todo: gen.filter((n) => !n.status).length,
271
+ generations: Number(history?.pagination?.total ?? history?.data?.length ?? 0),
272
+ lastGeneratedAt: history?.data?.[0]?.createdAt || null,
273
+ };
274
+ });
275
+ const sorters = {
276
+ name: (a, b) => a.student.localeCompare(b.student),
277
+ recent: (a, b) => Date.parse(b.lastGeneratedAt || '0') - Date.parse(a.lastGeneratedAt || '0'),
278
+ progress: (a, b) => a.done - b.done || a.student.localeCompare(b.student),
279
+ };
280
+ if (!sorters[opts.sort])
281
+ throw new Error('--sort must be name, recent or progress');
282
+ rows.sort(sorters[opts.sort]);
283
+ render(rows, () => {
284
+ if (opts.tag)
285
+ info(color.dim(`Counting nodes tagged ${opts.tag.join(', ')}`));
286
+ table(rows, [
287
+ { header: 'student', value: (r) => r.student },
288
+ { header: 'canvas', value: (r) => (r.canvas ? truncate(r.canvas.name, 20) : color.red('none')) },
289
+ { header: 'nodes', value: (r) => String(r.nodes) },
290
+ { header: 'done', value: (r) => (r.done ? color.green(String(r.done)) : '0') },
291
+ { header: 'running', value: (r) => (r.running ? color.yellow(String(r.running)) : '') },
292
+ { header: 'failed', value: (r) => (r.failed ? color.red(String(r.failed)) : '') },
293
+ { header: 'not run', value: (r) => (r.todo ? String(r.todo) : '') },
294
+ { header: 'gens', value: (r) => String(r.generations) },
295
+ { header: 'last gen', value: (r) => (r.lastGeneratedAt ? relativeTime(r.lastGeneratedAt) : '') },
296
+ { header: 'credits', value: (r) => (r.credits ? String(Math.round(r.credits * 10) / 10) : '') },
297
+ ]);
298
+ if (!usage)
299
+ info(color.dim('Credits hidden: this account cannot read the workspace credit usage.'));
300
+ });
301
+ });
302
+ cls
303
+ .command('results')
304
+ .description('Finished generations across student canvases, newest first; --download saves them into one folder per student')
305
+ .option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
306
+ .option('--student <members...>', 'only these students')
307
+ .option('--tag <tags...>', 'only nodes with these color tags (label like HW1, or a color)')
308
+ .option('-k, --kind <kind>', 'image | video | audio')
309
+ .option('-n, --name <regex>', 'only nodes whose name matches (case-insensitive)')
310
+ .option('--since <when>', 'finished after: 30m, 2h, 1d or a date')
311
+ .option('--all', 'include display nodes (uploaded or copied media), not only generate nodes')
312
+ .option('-s, --sort <by>', 'recent | name (student, then newest)', 'recent')
313
+ .option('-l, --limit <n>', 'at most this many rows (0 = all)', '0')
314
+ .option('-d, --download <dir>', 'save every listed file to <dir>/<student>/')
315
+ .action(async (opts) => {
316
+ const c = client();
317
+ const wsId = await workspaceOf(c, opts.workspace);
318
+ const since = parseSince(opts.since);
319
+ const nameRe = opts.name ? new RegExp(opts.name, 'i') : undefined;
320
+ const { members, seats: all } = await loadClass(c, wsId);
321
+ const seats = pickSeats(all, members, opts.student).filter((s) => s.canvases.length);
322
+ requireStudents(seats, opts.student);
323
+ const perSeat = await mapLimit(seats, WORKERS, async (seat) => {
324
+ const cv = await loadCanvas(c, seatCanvas(seat).id);
325
+ const colors = tagColors(cv, opts.tag);
326
+ return cv.nodes
327
+ .filter((n) => ['image', 'video', 'audio'].includes(n.kind))
328
+ .filter((n) => !opts.kind || n.kind === opts.kind)
329
+ .filter((n) => !colors || colors.has(n.content.colorTag?.colorId))
330
+ .filter((n) => !nameRe || nameRe.test(n.name))
331
+ .map((n) => ({ n: describe(cv, n), asset: n.file ? cv.assets.get(n.file) : undefined }))
332
+ .filter(({ n }) => !!n.url && n.status !== 'generating' && (opts.all || n.mode === 'generate'))
333
+ .map(({ n, asset }) => ({
334
+ student: memberLabel(seat.member),
335
+ userId: seat.member.userId,
336
+ canvasId: cv.id,
337
+ canvasName: cv.name,
338
+ nodeId: n.id,
339
+ kind: n.kind,
340
+ name: n.name,
341
+ tag: n.tag,
342
+ model: n.generate?.model,
343
+ prompt: n.generate?.prompt,
344
+ assetId: n.assetId,
345
+ url: n.url,
346
+ finishedAt: (asset?.updatedAt || asset?.createdAt || null),
347
+ }))
348
+ .filter((r) => !since || Date.parse(r.finishedAt || '0') >= since);
349
+ });
350
+ let rows = perSeat.flat();
351
+ const newest = (a, b) => Date.parse(b.finishedAt || '0') - Date.parse(a.finishedAt || '0');
352
+ rows.sort(opts.sort === 'name' ? (a, b) => a.student.localeCompare(b.student) || newest(a, b) : newest);
353
+ const limit = Number(opts.limit) || 0;
354
+ if (limit > 0)
355
+ rows = rows.slice(0, limit);
356
+ if (opts.download) {
357
+ const isDir = await stat(opts.download).then((s) => s.isDirectory()).catch(() => false);
358
+ if (!isDir)
359
+ await mkdir(opts.download, { recursive: true });
360
+ await mapLimit(rows, WORKERS, async (r) => {
361
+ const dir = join(opts.download, safeName(r.student));
362
+ await mkdir(dir, { recursive: true });
363
+ const res = await fetch(r.url);
364
+ if (!res.ok || !res.body)
365
+ return warn(`${r.student} ${r.name}: download failed (${res.status})`);
366
+ const file = join(dir, `${safeName(r.name || r.kind)}-${r.nodeId.slice(-6)}${extensionOf(r.url)}`);
367
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(file));
368
+ r.path = file;
369
+ });
370
+ }
371
+ render(rows, () => {
372
+ if (!rows.length)
373
+ return info('No finished results match');
374
+ table(rows, [
375
+ { header: 'student', value: (r) => r.student },
376
+ { header: 'kind', value: (r) => r.kind },
377
+ { header: 'name', value: (r) => truncate(r.name, 18) },
378
+ { header: 'tag', value: (r) => truncate(tagText(r.tag), 12) },
379
+ { header: 'when', value: (r) => relativeTime(r.finishedAt) },
380
+ { header: 'model', value: (r) => truncate(r.model || '', 18) },
381
+ { header: opts.download ? 'saved' : 'url', value: (r) => truncate(r.path || r.url, 60) },
382
+ ]);
383
+ if (opts.download)
384
+ success(`Saved ${rows.filter((r) => r.path).length} file(s) under ${opts.download}`);
385
+ });
386
+ });
387
+ return cls;
388
+ }
389
+ function safeName(value) {
390
+ return value.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || 'untitled';
391
+ }
392
+ function extensionOf(url) {
393
+ const match = /\.([a-z0-9]{2,5})(?:\?|$)/i.exec(url);
394
+ return match ? `.${match[1]}` : '';
395
+ }