@wji.ai/cli 0.3.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 (61) hide show
  1. package/README.md +76 -2
  2. package/dist/commands/backreel.js +260 -0
  3. package/dist/commands/canvas.js +751 -86
  4. package/dist/commands/class.js +395 -0
  5. package/dist/commands/crew.js +205 -0
  6. package/dist/commands/npc.js +413 -0
  7. package/dist/commands/workspace.js +109 -24
  8. package/dist/core/archive.js +95 -0
  9. package/dist/core/crew.js +546 -0
  10. package/dist/core/members.js +54 -0
  11. package/dist/core/npc.js +108 -0
  12. package/dist/core/output.js +4 -2
  13. package/dist/index.js +10 -0
  14. package/package.json +1 -1
  15. package/skill/short-drama/SKILL.md +86 -8
  16. package/skill/short-drama/reference/action-bullet-time.md +47 -0
  17. package/skill/short-drama/reference/action-car-chase.md +51 -0
  18. package/skill/short-drama/reference/action-gun-fu.md +52 -0
  19. package/skill/short-drama/reference/action-historical-realist.md +50 -0
  20. package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
  21. package/skill/short-drama/reference/action-street-brawl.md +49 -0
  22. package/skill/short-drama/reference/action-superhero.md +50 -0
  23. package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
  24. package/skill/short-drama/reference/action.md +94 -0
  25. package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
  26. package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
  27. package/skill/short-drama/reference/cinematography-low-key.md +115 -0
  28. package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
  29. package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
  30. package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
  31. package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
  32. package/skill/short-drama/reference/cinematography.md +83 -0
  33. package/skill/short-drama/reference/composition-art-first.md +74 -0
  34. package/skill/short-drama/reference/craft-shots.md +3 -1
  35. package/skill/short-drama/reference/craft-story.md +4 -1
  36. package/skill/short-drama/reference/format.md +94 -0
  37. package/skill/short-drama/reference/grammar-comedy.md +105 -0
  38. package/skill/short-drama/reference/grammar-faceslap.md +67 -0
  39. package/skill/short-drama/reference/grammar.md +103 -0
  40. package/skill/short-drama/reference/operations-cli.md +1 -1
  41. package/skill/short-drama/reference/schema.md +34 -3
  42. package/skill/short-drama/reference/stage-art.md +1 -1
  43. package/skill/short-drama/reference/stage-outline.md +21 -0
  44. package/skill/short-drama/reference/stage-script.md +10 -0
  45. package/skill/short-drama/reference/stage-storyboard.md +15 -0
  46. package/skill/short-drama/reference/story-angst.md +117 -0
  47. package/skill/short-drama/reference/story-comedy.md +136 -0
  48. package/skill/short-drama/reference/story-cultivation.md +109 -0
  49. package/skill/short-drama/reference/story-family.md +119 -0
  50. package/skill/short-drama/reference/story-horror.md +124 -0
  51. package/skill/short-drama/reference/story-mystery.md +127 -0
  52. package/skill/short-drama/reference/story-palace.md +103 -0
  53. package/skill/short-drama/reference/story-revenge.md +124 -0
  54. package/skill/short-drama/reference/story-scifi.md +112 -0
  55. package/skill/short-drama/reference/story-sweet.md +128 -0
  56. package/skill/short-drama/reference/story.md +127 -0
  57. package/skill/short-drama/scripts/selftest.mjs +809 -1
  58. package/skill/short-drama/scripts/short-drama.mjs +1608 -16
  59. package/skill/wji/SKILL.md +84 -2
  60. package/skill/wji/reference/commands.md +96 -6
  61. 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
+ }
@@ -0,0 +1,205 @@
1
+ import { Command } from 'commander';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { confirm } from '../core/prompt.js';
5
+ import { color, detail, info, isJsonMode, printJson, render, success, table, warn } from '../core/output.js';
6
+ import { CREW_REGISTRY, TARGETS, applyOps, describeOp, fetchPack, findOpenclaw, grokZip, loadPackFromDir, pathState, planClaude, planCodex, planOpenclaw, readInstalled, relOrAbs, uninstallPaths, validatePack, writeInstalled, } from '../core/crew.js';
7
+ /**
8
+ * `wji crew` — install a crew pack (skills + agent seats) into the hosts that
9
+ * run them: Claude Code, OpenAI Codex, OpenClaw, and the Grok bot platform
10
+ * (as an import zip). One pack, one command per host.
11
+ */
12
+ function parseTargets(value) {
13
+ if (!value || value === 'all')
14
+ return ['claude', 'codex', 'openclaw', 'grok'];
15
+ const out = [];
16
+ for (const raw of value.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)) {
17
+ if (!TARGETS.includes(raw))
18
+ throw new Error(`Unknown target "${raw}". Use: ${TARGETS.join(', ')} or all`);
19
+ out.push(raw);
20
+ }
21
+ return out;
22
+ }
23
+ function requireValid(pack) {
24
+ const errors = validatePack(pack);
25
+ if (errors.length)
26
+ throw new Error(`Pack ${pack.crew.name} is not valid:\n - ${errors.join('\n - ')}`);
27
+ }
28
+ export function crewCommand(_globals) {
29
+ const crew = new Command('crew').description('Install crew packs (skills + agent seats) into Claude Code, Codex, OpenClaw or a Grok bot export');
30
+ crew
31
+ .command('install [name]')
32
+ .description(`Install a crew for one or more hosts (known crews: ${Object.keys(CREW_REGISTRY).join(', ')})`)
33
+ .option('--for <targets>', 'comma-separated: claude, codex, openclaw, grok, or all', 'all')
34
+ .option('--from <source>', 'a local pack folder, a .zip / .tar.gz, or an http(s) URL instead of the registry')
35
+ .option('--ref <ref>', 'git branch or tag when fetching from the registry')
36
+ .option('--project <dir>', 'install into this project (.claude/, .agents/, .codex/, openclaw/) instead of your home directory')
37
+ .option('--link', 'symlink skills and Claude agents to a local --from folder instead of copying')
38
+ .option('--out <zip>', 'where to write the Grok export zip (default ./<crew>-export.zip)')
39
+ .option('--no-register', 'openclaw: only write the workspaces and config fragment; do not run `openclaw agents add`')
40
+ .option('--dry-run', 'print what would be written and stop')
41
+ .option('-y, --yes', 'skip the confirmation')
42
+ .action(async (name, opts) => {
43
+ const targets = parseTargets(opts.for);
44
+ const pack = await fetchPack(name || 'short-drama', { from: opts.from, ref: opts.ref });
45
+ requireValid(pack);
46
+ if (opts.link && (!opts.from || /^https?:/.test(opts.from) || !pack.root.startsWith(resolve(opts.from)))) {
47
+ throw new Error('--link needs a local --from <folder>');
48
+ }
49
+ const io = { project: opts.project, link: !!opts.link, out: opts.out };
50
+ const plans = {};
51
+ for (const t of targets) {
52
+ if (t === 'claude')
53
+ plans.claude = planClaude(pack, io);
54
+ if (t === 'codex')
55
+ plans.codex = planCodex(pack, io);
56
+ if (t === 'openclaw')
57
+ plans.openclaw = planOpenclaw(pack, io, { register: opts.register !== false });
58
+ }
59
+ // codex and openclaw share ~/.agents/skills — write each skill once
60
+ if (plans.codex && plans.openclaw) {
61
+ const seen = new Set(plans.codex.filter((o) => o.kind !== 'exec').map((o) => o.path));
62
+ plans.openclaw = plans.openclaw.filter((o) => o.kind === 'exec' || !seen.has(o.path));
63
+ }
64
+ const grokOut = targets.includes('grok') ? resolve(opts.out || `${pack.crew.name}-export.zip`) : undefined;
65
+ info(`${color.bold(pack.crew.title || pack.crew.name)} — ${Object.keys(pack.agents).length} seat(s), ${pack.skills.size} skill(s) from ${pack.source}${pack.ref ? ` @ ${pack.ref}` : ''}`);
66
+ for (const t of targets) {
67
+ info('');
68
+ info(color.bold(`[${t}]`));
69
+ if (t === 'grok')
70
+ info(` zip ${relOrAbs(grokOut)}`);
71
+ for (const op of plans[t] || [])
72
+ info(` ${describeOp(op).replace(/^(\w+)/, (m) => color.dim(m))}`);
73
+ }
74
+ if (opts.dryRun)
75
+ return info(color.dim('\n--dry-run: nothing written'));
76
+ info('');
77
+ if (!(await confirm('Write these?', opts.yes)))
78
+ return info('Aborted');
79
+ const installed = readInstalled();
80
+ const record = installed[pack.crew.name] || { source: pack.source, ref: pack.ref, installedAt: '', targets: {} };
81
+ record.source = pack.source;
82
+ record.ref = pack.ref;
83
+ record.installedAt = new Date().toISOString();
84
+ const summary = {};
85
+ for (const t of targets) {
86
+ info(color.bold(`[${t}]`));
87
+ if (t === 'grok') {
88
+ const { zip, entries } = grokZip(pack);
89
+ mkdirSync(dirname(grokOut), { recursive: true });
90
+ writeFileSync(grokOut, zip);
91
+ info(` ✓ ${entries} entries → ${relOrAbs(grokOut)}`);
92
+ record.targets.grok = { paths: [grokOut], notes: [] };
93
+ summary.grok = { zip: grokOut, entries };
94
+ continue;
95
+ }
96
+ const previous = record.targets[t]?.paths || [];
97
+ const result = applyOps(plans[t] || [], info);
98
+ // paths from an earlier install of this crew that this run did not touch are stale — drop them
99
+ const stale = previous.filter((p) => !result.paths.includes(p));
100
+ if (stale.length)
101
+ uninstallPaths(stale);
102
+ record.targets[t] = result;
103
+ summary[t] = { written: result.paths.length, notes: result.notes };
104
+ }
105
+ installed[pack.crew.name] = record;
106
+ writeInstalled(installed);
107
+ if (targets.includes('openclaw')) {
108
+ const frag = (record.targets.openclaw?.paths || []).find((p) => p.endsWith('openclaw.agents.json5'));
109
+ warn(`openclaw: merge ${frag} into ~/.openclaw/openclaw.json (skills allowlists, tools, subagents, channel bindings) — the CLI never rewrites that file.`);
110
+ if (!findOpenclaw())
111
+ info(color.dim(' openclaw binary not found; the agents were not registered — run `openclaw agents add <seat> --workspace <dir>` for each seat.'));
112
+ }
113
+ if (targets.includes('codex')) {
114
+ info(color.dim('codex: the wji-operator seat needs network — set [sandbox_workspace_write] network_access = true in ~/.codex/config.toml.'));
115
+ }
116
+ if (isJsonMode())
117
+ return printJson({ crew: pack.crew.name, targets: summary });
118
+ success(`Installed ${pack.crew.name} for ${targets.join(', ')}`);
119
+ });
120
+ crew
121
+ .command('status [name]')
122
+ .description('What `wji crew install` wrote, per host, and whether it is still there')
123
+ .action((name) => {
124
+ const installed = readInstalled();
125
+ const names = name ? [name] : Object.keys(installed);
126
+ const rows = [];
127
+ for (const n of names) {
128
+ const rec = installed[n];
129
+ if (!rec) {
130
+ warn(`${n}: not installed by wji crew`);
131
+ continue;
132
+ }
133
+ for (const [target, data] of Object.entries(rec.targets)) {
134
+ for (const p of data?.paths || [])
135
+ rows.push({ crew: n, target, path: p, state: pathState(p) });
136
+ }
137
+ }
138
+ render({ installed, rows }, () => {
139
+ if (!rows.length)
140
+ return info('Nothing installed by `wji crew`. Try `wji crew install short-drama --for claude`.');
141
+ for (const n of names) {
142
+ const rec = installed[n];
143
+ if (!rec)
144
+ continue;
145
+ detail([
146
+ ['crew', n],
147
+ ['source', `${rec.source}${rec.ref ? ` @ ${rec.ref}` : ''}`],
148
+ ['installed', rec.installedAt],
149
+ ]);
150
+ }
151
+ info('');
152
+ table(rows, [
153
+ { header: 'target', value: (r) => r.target },
154
+ { header: '', value: (r) => (r.state === 'missing' ? color.red('✗') : r.state === 'link' ? color.dim('↗') : color.green('✓')) },
155
+ { header: 'path', value: (r) => relOrAbs(r.path) },
156
+ ]);
157
+ });
158
+ });
159
+ crew
160
+ .command('uninstall <name>')
161
+ .description('Remove what `wji crew install` wrote for a crew (only the recorded paths)')
162
+ .option('--for <targets>', 'comma-separated targets, default all recorded', 'all')
163
+ .option('-y, --yes', 'skip the confirmation')
164
+ .action(async (name, opts) => {
165
+ const installed = readInstalled();
166
+ const rec = installed[name];
167
+ if (!rec)
168
+ throw new Error(`${name} was not installed by wji crew`);
169
+ const targets = parseTargets(opts.for).filter((t) => rec.targets[t]);
170
+ const paths = targets.flatMap((t) => rec.targets[t]?.paths || []);
171
+ if (!paths.length)
172
+ return info('Nothing to remove');
173
+ for (const p of paths)
174
+ info(` rm ${relOrAbs(p)}`);
175
+ if (!(await confirm(`Remove ${paths.length} path(s)?`, opts.yes)))
176
+ return info('Aborted');
177
+ uninstallPaths(paths);
178
+ for (const t of targets)
179
+ delete rec.targets[t];
180
+ if (!Object.keys(rec.targets).length)
181
+ delete installed[name];
182
+ writeInstalled(installed);
183
+ if (targets.includes('openclaw'))
184
+ warn('openclaw: remove the agents.entries / bindings you merged into ~/.openclaw/openclaw.json, and `openclaw agents delete <seat>` for each seat.');
185
+ success(`Removed ${paths.length} path(s)`);
186
+ });
187
+ crew
188
+ .command('check <dir>')
189
+ .description('Validate a local crew pack (crew.json, agents/*.md, skills/*/SKILL.md, links)')
190
+ .action((dir) => {
191
+ const pack = loadPackFromDir(dir);
192
+ const errors = validatePack(pack);
193
+ render({ crew: pack.crew.name, agents: Object.keys(pack.agents), skills: [...pack.skills.keys()], errors }, () => {
194
+ if (errors.length) {
195
+ for (const e of errors)
196
+ info(` ✗ ${e}`);
197
+ throw new Error(`${errors.length} problem(s) in ${dir}`);
198
+ }
199
+ success(`${pack.crew.name}: ${Object.keys(pack.agents).length} seat(s), ${pack.skills.size} skill(s), all links resolve`);
200
+ });
201
+ if (errors.length)
202
+ process.exitCode = 1;
203
+ });
204
+ return crew;
205
+ }