@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,413 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { Command } from 'commander';
3
+ import { isObjectId, WjiClient } from '../core/client.js';
4
+ import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
5
+ import { color, detail, info, isJsonMode, printJson, render, relativeTime, success, table, truncate, warn } from '../core/output.js';
6
+ import { NPC_JOB_TYPES, NPC_STATUSES, NPC_VISIBILITIES, RESIDENTS, npcLabel, parseJob, parsePositions, resolveNpc, updateJobs, } from '../core/npc.js';
7
+ const TRANSFERS = '/npc-city/transfers';
8
+ export function npcCommand(globals) {
9
+ const npc = new Command('npc')
10
+ .aliases(['npc-city'])
11
+ .description('Manage your NPC City residents (NPCs): profile, jobs, soul, character doc, transfers');
12
+ // ── residents ──────────────────────────────────────────────────────────
13
+ npc
14
+ .command('list', { isDefault: true })
15
+ .description('Your NPCs (org NPCs with --org); --public for the public directory')
16
+ .option('--public', 'list the public directory instead of yours')
17
+ .option('-k, --keyword <text>', 'search text')
18
+ .option('--status <status>', NPC_STATUSES.join(' | '))
19
+ .option('--visibility <v>', NPC_VISIBILITIES.join(' | '))
20
+ .option('--page <n>', 'page number', '1')
21
+ .option('--limit <n>', 'page size', '20')
22
+ .action(async (opts) => {
23
+ const client = new WjiClient(globals());
24
+ const result = await client.get(opts.public ? RESIDENTS : `${RESIDENTS}/mine`, {
25
+ keyword: opts.keyword,
26
+ status: opts.status,
27
+ visibility: opts.visibility,
28
+ page: Number(opts.page),
29
+ limit: Number(opts.limit),
30
+ });
31
+ render(result, (r) => {
32
+ table(r?.data || [], [
33
+ { header: 'id', value: (n) => n.id },
34
+ { header: 'name', value: (n) => truncate(n.name, 20) },
35
+ { header: 'handle', value: (n) => n.handle },
36
+ { header: 'role', value: (n) => truncate(n.role, 16) },
37
+ { header: 'genre', value: (n) => truncate(n.genre, 16) },
38
+ { header: 'status', value: (n) => n.status },
39
+ { header: 'vis', value: (n) => n.visibility },
40
+ { header: 'jobs', value: (n) => (n.jobs || []).length },
41
+ { header: 'updated', value: (n) => relativeTime(n.updatedAt) },
42
+ ]);
43
+ pageFooter(r);
44
+ });
45
+ });
46
+ npc
47
+ .command('get <npc>')
48
+ .description('Show an NPC and its jobs (id, @handle or name)')
49
+ .action(async (token) => {
50
+ const client = new WjiClient(globals());
51
+ const r = await resolveNpc(client, token);
52
+ render(r, () => printNpc(r));
53
+ });
54
+ residentOptions(npc
55
+ .command('create <name>')
56
+ .description('Create an NPC (private until you publish it). Needs NPC City spawn permission.')
57
+ .requiredOption('--role <role>', 'role, e.g. Singer')
58
+ .requiredOption('--genre <genre>', 'genre, e.g. City pop')).action(async (name, opts) => {
59
+ const client = new WjiClient(globals());
60
+ const body = await residentBody(client, opts);
61
+ if (body.handle)
62
+ await assertHandle(client, body.handle);
63
+ const created = await client.post(RESIDENTS, { name, ...body });
64
+ render(created, (r) => success(`Created ${npcLabel(r)} — ${r.id} (${r.visibility})`));
65
+ });
66
+ residentOptions(npc
67
+ .command('set <npc>')
68
+ .description('Update NPC fields; only the flags you pass change')
69
+ .option('-n, --name <name>', 'rename')
70
+ .option('--role <role>', 'role')
71
+ .option('--genre <genre>', 'genre')).action(async (token, opts) => {
72
+ const client = new WjiClient(globals());
73
+ const current = await resolveNpc(client, token);
74
+ const body = await residentBody(client, opts, current);
75
+ if (opts.name)
76
+ body.name = opts.name;
77
+ if (!Object.keys(body).length)
78
+ throw new Error('Nothing to change — pass at least one field flag');
79
+ if (body.handle)
80
+ await assertHandle(client, body.handle, current.id);
81
+ const updated = await client.patch(`${RESIDENTS}/${current.id}`, body);
82
+ render(updated, (r) => success(`Updated ${npcLabel(r)}: ${Object.keys(body).join(', ')}`));
83
+ });
84
+ for (const [cmd, visibility] of [['publish', 'public'], ['unpublish', 'private']]) {
85
+ npc
86
+ .command(`${cmd} <npc>`)
87
+ .description(`Make an NPC ${visibility}`)
88
+ .action(async (token) => {
89
+ const client = new WjiClient(globals());
90
+ const r = await resolveNpc(client, token);
91
+ const updated = await client.patch(`${RESIDENTS}/${r.id}`, { visibility });
92
+ render(updated, (u) => success(`${npcLabel(u)} is now ${u.visibility}`));
93
+ });
94
+ }
95
+ npc
96
+ .command('rm <npc>')
97
+ .description('Delete an NPC')
98
+ .option('-y, --yes', 'skip confirmation')
99
+ .action(async (token, opts) => {
100
+ const client = new WjiClient(globals());
101
+ const r = await resolveNpc(client, token);
102
+ if (!(await confirm(`Delete NPC ${npcLabel(r)} (${r.id})?`, opts.yes)))
103
+ return info('Aborted');
104
+ const result = await client.delete(`${RESIDENTS}/${r.id}`);
105
+ render(result, () => success(`Deleted ${npcLabel(r)}`));
106
+ });
107
+ npc
108
+ .command('handle <handle>')
109
+ .description('Check whether a @handle is free')
110
+ .option('--exclude <npc>', "ignore this NPC's own handle")
111
+ .action(async (handle, opts) => {
112
+ const client = new WjiClient(globals());
113
+ const excludeId = opts.exclude ? (await resolveNpc(client, opts.exclude)).id : undefined;
114
+ const result = await client.get(`${RESIDENTS}/handle-available`, { handle, excludeId });
115
+ render(result, (r) => r.available ? success(`${r.handle} is available`) : warn(`${r.handle} is not available (${r.reason})`));
116
+ });
117
+ // ── jobs (work history) ────────────────────────────────────────────────
118
+ const job = npc.command('job').aliases(['jobs']).description("An NPC's work history: concerts, releases, ads…");
119
+ job
120
+ .command('list <npc>', { isDefault: true })
121
+ .description('Numbered work history')
122
+ .action(async (token) => {
123
+ const client = new WjiClient(globals());
124
+ const r = await resolveNpc(client, token);
125
+ render(r.jobs || [], (jobs) => printJobs(jobs));
126
+ });
127
+ jobOptions(job
128
+ .command('add <npc>')
129
+ .description('Add a job')
130
+ .requiredOption('-t, --type <type>', NPC_JOB_TYPES.join(' | '))
131
+ .requiredOption('--title <text>', 'title')
132
+ .requiredOption('--date <YYYY-MM-DD>', 'date')).action(async (token, opts) => {
133
+ const client = new WjiClient(globals());
134
+ const r = await resolveNpc(client, token);
135
+ const entry = parseJob(opts);
136
+ const updated = await updateJobs(client, r.id, (jobs) => [...jobs, entry]);
137
+ render(updated.jobs, () => success(`Added "${entry.title}" to ${npcLabel(r)} (#${updated.jobs.length})`));
138
+ });
139
+ jobOptions(job
140
+ .command('set <npc> <n>')
141
+ .description('Change job #n (see `npc job list`)')
142
+ .option('-t, --type <type>', NPC_JOB_TYPES.join(' | '))
143
+ .option('--title <text>', 'title')
144
+ .option('--date <YYYY-MM-DD>', 'date')).action(async (token, n, opts) => {
145
+ const client = new WjiClient(globals());
146
+ const r = await resolveNpc(client, token);
147
+ const updated = await updateJobs(client, r.id, (jobs) => {
148
+ const [i] = parsePositions([n], jobs.length);
149
+ jobs[i] = parseJob(opts, jobs[i]);
150
+ return jobs;
151
+ });
152
+ render(updated.jobs, () => success(`Updated job #${n} of ${npcLabel(r)}`));
153
+ });
154
+ job
155
+ .command('rm <npc> <n...>')
156
+ .description('Remove jobs by number')
157
+ .option('-y, --yes', 'skip confirmation')
158
+ .action(async (token, ns, opts) => {
159
+ const client = new WjiClient(globals());
160
+ const r = await resolveNpc(client, token);
161
+ const positions = parsePositions(ns, (r.jobs || []).length);
162
+ const titles = positions.map((i) => `#${i + 1} ${r.jobs[i].title}`).join(', ');
163
+ if (!(await confirm(`Remove ${titles} from ${npcLabel(r)}?`, opts.yes)))
164
+ return info('Aborted');
165
+ // Re-check against a fresh read so a concurrent edit can't shift the numbers.
166
+ const updated = await updateJobs(client, r.id, (jobs) => {
167
+ if (JSON.stringify(jobs) !== JSON.stringify(r.jobs)) {
168
+ throw new Error('The job list changed since it was read — list it again and retry');
169
+ }
170
+ return jobs.filter((_, i) => !positions.includes(i));
171
+ });
172
+ render(updated.jobs, () => success(`Removed ${positions.length} job(s); ${updated.jobs.length} left`));
173
+ });
174
+ // ── soul ───────────────────────────────────────────────────────────────
175
+ const soul = npc.command('soul').description("An NPC's soul config (persona, protocols, voice, assets)");
176
+ soul
177
+ .command('get <npc>', { isDefault: true })
178
+ .description('Print the soul config as JSON')
179
+ .action(async (token) => {
180
+ const client = new WjiClient(globals());
181
+ const r = await resolveNpc(client, token);
182
+ printJson(await client.getFresh(`${RESIDENTS}/${r.id}/soul`));
183
+ });
184
+ soul
185
+ .command('set <npc> <json>')
186
+ .description('Merge top-level soul keys (inline JSON, @file or -); nested objects are replaced whole')
187
+ .action(async (token, json) => {
188
+ const client = new WjiClient(globals());
189
+ const r = await resolveNpc(client, token);
190
+ const soulConfig = await readJson(json);
191
+ if (!soulConfig || typeof soulConfig !== 'object' || Array.isArray(soulConfig)) {
192
+ throw new Error('Soul config must be a JSON object');
193
+ }
194
+ const updated = await client.patch(`${RESIDENTS}/${r.id}/soul`, { soulConfig });
195
+ render(updated, () => success(`Updated soul of ${npcLabel(r)}: ${Object.keys(soulConfig).join(', ')}`));
196
+ });
197
+ // ── character document ────────────────────────────────────────────────
198
+ const doc = npc.command('doc').description('Export / apply the character document (profile + soul in one file)');
199
+ doc
200
+ .command('export <npc>')
201
+ .description('Print or save the character document')
202
+ .option('--format <format>', 'md | json', 'md')
203
+ .option('-o, --out <file>', 'write to a file instead of stdout')
204
+ .action(async (token, opts) => {
205
+ const client = new WjiClient(globals());
206
+ const r = await resolveNpc(client, token);
207
+ const result = await client.getFresh(`${RESIDENTS}/${r.id}/document`, { format: opts.format });
208
+ if (opts.out) {
209
+ await writeFile(opts.out, result.text);
210
+ return render({ file: opts.out, format: result.format }, () => success(`Wrote ${opts.out}`));
211
+ }
212
+ render(result, () => process.stdout.write(result.text.endsWith('\n') ? result.text : `${result.text}\n`));
213
+ });
214
+ doc
215
+ .command('apply <npc> <text>')
216
+ .description('Preview the changes a document makes (@file or -), confirm, then apply')
217
+ .option('--dry-run', 'only show the changes')
218
+ .option('-y, --yes', 'skip confirmation')
219
+ .action(async (token, source, opts) => {
220
+ const client = new WjiClient(globals());
221
+ const r = await resolveNpc(client, token);
222
+ const text = await readValue(source);
223
+ const base = `${RESIDENTS}/${r.id}/document`;
224
+ const preview = await client.post(`${base}/preview`, { text });
225
+ const changes = preview.changes || [];
226
+ if (opts.dryRun || !changes.length) {
227
+ return render(preview, () => {
228
+ printChanges(changes, preview.warnings);
229
+ if (!changes.length)
230
+ info('Nothing to apply');
231
+ });
232
+ }
233
+ if (!isJsonMode())
234
+ printChanges(changes, preview.warnings);
235
+ if (!(await confirm(`Apply ${changes.length} change(s) to ${npcLabel(r)}?`, opts.yes)))
236
+ return info('Aborted');
237
+ const applied = await client.post(base, { text });
238
+ render(applied, (a) => {
239
+ for (const w of a.warnings || [])
240
+ warn(w);
241
+ success(`Applied ${(a.applied || []).length} change(s) to ${npcLabel(r)}`);
242
+ });
243
+ });
244
+ // ── transfers ─────────────────────────────────────────────────────────
245
+ const transfer = npc
246
+ .command('transfer')
247
+ .description('Hand an NPC to another user; they accept within 7 days');
248
+ transfer
249
+ .command('send <npc>')
250
+ .description('Offer an NPC to another user')
251
+ .requiredOption('--to <user>', 'recipient email or user id')
252
+ .option('-y, --yes', 'skip confirmation')
253
+ .action(async (token, opts) => {
254
+ const client = new WjiClient(globals());
255
+ const r = await resolveNpc(client, token);
256
+ if (!(await confirm(`Offer ${npcLabel(r)} to ${opts.to}? You lose it once they accept.`, opts.yes))) {
257
+ return info('Aborted');
258
+ }
259
+ const target = isObjectId(opts.to) ? { toUserId: opts.to } : { email: opts.to };
260
+ const result = await client.post(`${RESIDENTS}/${r.id}/transfer`, target);
261
+ render(result, (t) => success(`Transfer ${t.id} to ${t.toOwner?.name || opts.to} pending until ${t.expiresAt}`));
262
+ });
263
+ transfer
264
+ .command('list', { isDefault: true })
265
+ .description('Pending transfers, incoming and outgoing')
266
+ .action(async () => {
267
+ const client = new WjiClient(globals());
268
+ const [incoming, outgoing] = await Promise.all([
269
+ client.getFresh(`${TRANSFERS}/incoming`),
270
+ client.getFresh(`${TRANSFERS}/outgoing`),
271
+ ]);
272
+ const rows = [
273
+ ...(incoming || []).map((t) => ({ ...t, direction: 'in' })),
274
+ ...(outgoing || []).map((t) => ({ ...t, direction: 'out' })),
275
+ ];
276
+ render(rows, () => table(rows, [
277
+ { header: 'id', value: (t) => t.id },
278
+ { header: 'dir', value: (t) => t.direction },
279
+ { header: 'npc', value: (t) => (t.resident ? npcLabel(t.resident) : null) },
280
+ { header: 'from', value: (t) => t.fromOwner?.name },
281
+ { header: 'to', value: (t) => t.toOwner?.name },
282
+ { header: 'expires', value: (t) => relativeTime(t.expiresAt) },
283
+ ]));
284
+ });
285
+ for (const action of ['accept', 'reject', 'cancel']) {
286
+ transfer
287
+ .command(`${action} <transferId>`)
288
+ .description(action === 'cancel' ? 'Withdraw a transfer you sent' : `${action[0].toUpperCase()}${action.slice(1)} a transfer sent to you`)
289
+ .action(async (id) => {
290
+ const client = new WjiClient(globals());
291
+ const result = await client.post(`${TRANSFERS}/${id}/${action}`);
292
+ render(result, (t) => success(`Transfer ${id}: ${t.status}${t.resident ? ` (${npcLabel(t.resident)})` : ''}`));
293
+ });
294
+ }
295
+ return npc;
296
+ }
297
+ // ── helpers ──────────────────────────────────────────────────────────────
298
+ function residentOptions(cmd) {
299
+ return cmd
300
+ .option('--handle <handle>', '@handle (a-z 0-9 . _ -, 2–32)')
301
+ .option('--tagline <text>', 'one-line tagline')
302
+ .option('--bio <text>', 'bio (@file / - supported)')
303
+ .option('--image <url|assetId>', 'avatar image')
304
+ .option('--hero-image <url|assetId>', 'hero image')
305
+ .option('--color <hex>', 'accent color')
306
+ .option('--status <status>', NPC_STATUSES.join(' | '))
307
+ .option('--visibility <v>', NPC_VISIBILITIES.join(' | '))
308
+ .option('--social <key=url...>', 'ig / yt / tw / web')
309
+ .option('--tag <key=value...>', 'card tags: rating, type, role, gender, tier, language…')
310
+ .option('--set <key=value...>', 'any other field, e.g. liveEnabled=true');
311
+ }
312
+ function jobOptions(cmd) {
313
+ return cmd
314
+ .option('--detail <text>', 'what happened')
315
+ .option('--location <text>', 'where')
316
+ .option('--url <url>', 'link');
317
+ }
318
+ /** The resident fields a create / set carries, from flags. `current` merges socials / tags. */
319
+ async function residentBody(client, opts, current) {
320
+ const body = {};
321
+ if (opts.role)
322
+ body.role = opts.role;
323
+ if (opts.genre)
324
+ body.genre = opts.genre;
325
+ if (opts.handle)
326
+ body.handle = opts.handle;
327
+ if (opts.tagline !== undefined)
328
+ body.tagline = opts.tagline;
329
+ if (opts.bio !== undefined)
330
+ body.bio = await readValue(opts.bio);
331
+ if (opts.image)
332
+ body.image = await imageUrl(client, opts.image);
333
+ if (opts.heroImage)
334
+ body.heroImage = await imageUrl(client, opts.heroImage);
335
+ if (opts.color)
336
+ body.color = opts.color;
337
+ if (opts.status)
338
+ body.status = oneOf(opts.status, NPC_STATUSES, '--status');
339
+ if (opts.visibility)
340
+ body.visibility = oneOf(opts.visibility, NPC_VISIBILITIES, '--visibility');
341
+ if (opts.social)
342
+ body.socials = { ...(current?.socials || {}), ...parseKeyValues(opts.social) };
343
+ if (opts.tag)
344
+ body.tags = { ...(current?.tags || {}), ...parseKeyValues(opts.tag) };
345
+ Object.assign(body, parseKeyValues(opts.set));
346
+ return body;
347
+ }
348
+ /** Residents store image URLs; an asset id is swapped for its ready URL. */
349
+ async function imageUrl(client, value) {
350
+ if (!isObjectId(value))
351
+ return value;
352
+ const [asset] = await client.checkAssetStatus([value]);
353
+ if (!asset?.url)
354
+ throw new Error(`Asset ${value} has no URL yet (missing, not yours, or still pending)`);
355
+ return asset.url;
356
+ }
357
+ async function assertHandle(client, handle, excludeId) {
358
+ const r = await client.get(`${RESIDENTS}/handle-available`, { handle, excludeId });
359
+ if (!r.available)
360
+ throw new Error(`Handle ${r.handle || handle} is not available (${r.reason})`);
361
+ }
362
+ function oneOf(value, allowed, flag) {
363
+ if (!allowed.includes(value))
364
+ throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
365
+ return value;
366
+ }
367
+ function pageFooter(r) {
368
+ const p = r?.pagination;
369
+ if (!p)
370
+ return;
371
+ info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
372
+ }
373
+ function printNpc(r) {
374
+ detail([
375
+ ['id', r.id],
376
+ ['name', r.name],
377
+ ['handle', r.handle],
378
+ ['role', r.role],
379
+ ['genre', r.genre],
380
+ ['status', r.status],
381
+ ['visibility', r.visibility],
382
+ ['tagline', r.tagline],
383
+ ['bio', truncate(r.bio, 120)],
384
+ ['image', r.image],
385
+ ['owner', r.owner?.name || r.owner],
386
+ ['organization', r.organization?.name || r.organization],
387
+ ['tags', r.tags && Object.keys(r.tags).length ? r.tags : null],
388
+ ['socials', r.socials && Object.keys(r.socials).length ? r.socials : null],
389
+ ['live', r.liveEnabled ? (r.livePublic ? 'enabled, public' : 'enabled') : null],
390
+ ['updated', relativeTime(r.updatedAt)],
391
+ ]);
392
+ process.stdout.write('\n');
393
+ printJobs(r.jobs || []);
394
+ }
395
+ function printJobs(jobs) {
396
+ table(jobs.map((j, i) => ({ ...j, n: i + 1 })), [
397
+ { header: '#', value: (j) => j.n },
398
+ { header: 'date', value: (j) => j.date },
399
+ { header: 'type', value: (j) => j.type },
400
+ { header: 'title', value: (j) => truncate(j.title, 32) },
401
+ { header: 'detail', value: (j) => truncate(j.detail, 32) },
402
+ { header: 'location', value: (j) => truncate(j.location, 20) },
403
+ ]);
404
+ }
405
+ function printChanges(changes, warnings = []) {
406
+ for (const w of warnings)
407
+ warn(w);
408
+ table(changes, [
409
+ { header: 'path', value: (c) => c.path },
410
+ { header: 'from', value: (c) => truncate(JSON.stringify(c.from ?? null), 40) },
411
+ { header: 'to', value: (c) => truncate(JSON.stringify(c.to ?? null), 40) },
412
+ ]);
413
+ }
@@ -3,6 +3,7 @@ import { WjiClient } from '../core/client.js';
3
3
  import { updateProfile } from '../core/config.js';
4
4
  import { confirm, parseKeyValues } from '../core/prompt.js';
5
5
  import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
6
+ import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
6
7
  /** App tokens accepted by `--app`; mirrors WorkspaceApplication on the backend. */
7
8
  const APPS = {
8
9
  dataset: 'Dataset',
@@ -212,47 +213,131 @@ export function workspaceCommand(globals) {
212
213
  render(result, () => success(`Removed ${appId}`));
213
214
  });
214
215
  // ── members ────────────────────────────────────────────────────────────────
215
- const members = ws.command('member').description('Manage workspace members');
216
+ const members = ws.command('member').description('Manage workspace members and invitations');
217
+ const who = 'member: name, email, user id or membership id';
216
218
  members
217
219
  .command('list [id]')
218
220
  .description('List members')
219
- .action(async (id) => {
221
+ .option('-r, --role <role>', 'only this role: owner | editor | viewer')
222
+ .action(async (id, opts) => {
220
223
  const client = new WjiClient(globals());
221
- const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`);
222
- render(w.members || [], (rows) => table(rows, [
223
- { header: 'memberId', value: (m) => m.id },
224
- { header: 'user', value: (m) => m.user?.id || m.user },
225
- { header: 'name', value: (m) => m.user?.name },
224
+ const { members: rows } = await loadMembers(client, client.requireWorkspace(id));
225
+ const shown = rows.filter((m) => !opts.role || m.role === opts.role);
226
+ render(shown, () => table(shown, [
227
+ { header: 'name', value: (m) => m.name },
228
+ { header: 'email', value: (m) => m.email },
226
229
  { header: 'role', value: (m) => m.role },
230
+ { header: 'user', value: (m) => color.dim(m.userId) },
231
+ { header: 'memberId', value: (m) => color.dim(m.memberId) },
227
232
  ]));
228
233
  });
229
234
  members
230
- .command('add <userId> [id]')
231
- .description('Add a member (editor by default)')
235
+ .command('add <userIds...>')
236
+ .description('Add registered users by user id (at most 50; existing members are skipped). Invite by email with `member invite`.')
237
+ .option('-r, --role <role>', 'editor | viewer', 'editor')
238
+ .option('-w, --workspace <id>', 'workspace (defaults to the selected one)')
239
+ .action(async (userIds, opts) => {
240
+ if (userIds.length > 50)
241
+ throw new Error('At most 50 users per call');
242
+ const client = new WjiClient(globals());
243
+ const result = await client.post(`/workspace/list/${client.requireWorkspace(opts.workspace)}/member/add-batch`, {
244
+ users: userIds,
245
+ role: opts.role,
246
+ });
247
+ render(result, (r) => {
248
+ success(`Added ${r.added?.length ?? 0} as ${opts.role}`);
249
+ if (r.skipped?.length)
250
+ info(color.dim(`Already members: ${r.skipped.join(', ')}`));
251
+ });
252
+ });
253
+ members
254
+ .command('invite <emails...>')
255
+ .description('Invite by email (at most 50, owner only): registered users join at once, the others get an invitation email. ' +
256
+ 'Emailing an invitation replaces the current share link.')
257
+ .option('-r, --role <role>', 'editor | viewer (students who generate need editor)', 'editor')
258
+ .option('-w, --workspace <id>', 'workspace (defaults to the selected one)')
259
+ .action(async (emails, opts) => {
260
+ const list = emails.flatMap((e) => e.split(/[\s,;]+/)).filter(Boolean);
261
+ if (list.length > 50)
262
+ throw new Error(`At most 50 emails per call (got ${list.length}) — split the list`);
263
+ const client = new WjiClient(globals());
264
+ const r = await client.post(`/invitation/workspace/${client.requireWorkspace(opts.workspace)}/invite-emails`, {
265
+ emails: list,
266
+ role: opts.role,
267
+ });
268
+ render(r, () => {
269
+ detail([
270
+ ['added', r.added?.length ? r.added.join(', ') : undefined],
271
+ ['invited', r.invited?.length ? r.invited.join(', ') : undefined],
272
+ ['skipped', r.skipped?.length ? `${r.skipped.join(', ')} ${color.dim('(already members)')}` : undefined],
273
+ ['invalid', r.invalid?.length ? color.red(r.invalid.join(', ')) : undefined],
274
+ ]);
275
+ if (r.invited?.length)
276
+ info(color.dim('Invited people join once they sign up and open the emailed link.'));
277
+ });
278
+ });
279
+ members
280
+ .command('link [id]')
281
+ .description('Create the workspace share link (owner only, valid 7 days). A new link revokes the previous one; anyone with it can join until it expires.')
232
282
  .option('-r, --role <role>', 'editor | viewer', 'editor')
233
- .action(async (userId, id, opts) => {
283
+ .option('--show', 'show the active link instead of creating a new one')
284
+ .action(async (id, opts) => {
234
285
  const client = new WjiClient(globals());
235
- const result = await client.post(`/workspace/list/${client.requireWorkspace(id)}/member/add`, { user: userId, role: opts.role });
236
- render(result, () => success(`Added ${userId} as ${opts.role}`));
286
+ const wsId = client.requireWorkspace(id);
287
+ if (opts.show) {
288
+ const rows = await client.getFresh(`/invitation/workspace/${wsId}`);
289
+ return render(rows, (list) => list.length
290
+ ? table(list, [
291
+ { header: 'url', value: (it) => it.url },
292
+ { header: 'role', value: (it) => it.role },
293
+ { header: 'expires', value: (it) => relativeTime(it.expiredAt) },
294
+ ])
295
+ : info('No active link. Create one with `wji ws member link`.'));
296
+ }
297
+ if (opts.role === 'owner')
298
+ throw new Error('A share link cannot make owners');
299
+ // No expiredAt: the DTO converts it to a Date before checking it is a string, so any value is
300
+ // rejected (400 "expiredAt must be a string"); the server default is 7 days.
301
+ const it = await client.post('/invitation/create', { targetType: 'workspace', targetId: wsId, role: opts.role });
302
+ render(it, () => {
303
+ success(`Share link (${it.role}, expires ${relativeTime(it.expiredAt)}):`);
304
+ process.stdout.write(`${it.url}\n`);
305
+ });
237
306
  });
238
307
  members
239
- .command('role <memberId> <role> [id]')
240
- .description('Change a member role')
241
- .action(async (memberId, role, id) => {
308
+ .command('role <member> <role>')
309
+ .description(`Change a member role (editor | viewer); ${who}`)
310
+ .option('-w, --workspace <id>', 'workspace (defaults to the selected one)')
311
+ .action(async (token, role, opts) => {
312
+ if (!['editor', 'viewer'].includes(role))
313
+ throw new Error('Role must be editor or viewer (ownership transfers from the web)');
242
314
  const client = new WjiClient(globals());
243
- const result = await client.put(`/workspace/list/${client.requireWorkspace(id)}/member/${memberId}/role`, { role });
244
- render(result, () => success(`${memberId} is now ${role}`));
315
+ const wsId = client.requireWorkspace(opts.workspace);
316
+ const m = resolveMember((await loadMembers(client, wsId)).members, token);
317
+ if (m.role === 'owner')
318
+ throw new Error(`${memberLabel(m)} is the owner; their role cannot change`);
319
+ const result = await client.put(`/workspace/list/${wsId}/member/${m.memberId}/role`, { role });
320
+ render(result, () => success(`${memberLabel(m)} is now ${role}`));
245
321
  });
246
322
  members
247
- .command('rm <memberId> [id]')
248
- .description('Remove a member')
323
+ .command('rm <members...>')
324
+ .description(`Remove members; ${who}. Their canvases stay.`)
325
+ .option('-w, --workspace <id>', 'workspace (defaults to the selected one)')
249
326
  .option('-y, --yes', 'skip confirmation')
250
- .action(async (memberId, id, opts) => {
251
- if (!(await confirm(`Remove member ${memberId}?`, opts.yes)))
252
- return info('Aborted');
327
+ .action(async (tokens, opts) => {
253
328
  const client = new WjiClient(globals());
254
- const result = await client.delete(`/workspace/list/${client.requireWorkspace(id)}/member/${memberId}`);
255
- render(result, () => success(`Removed ${memberId}`));
329
+ const wsId = client.requireWorkspace(opts.workspace);
330
+ const all = (await loadMembers(client, wsId)).members;
331
+ const targets = tokens.map((t) => resolveMember(all, t));
332
+ const owner = targets.find((m) => m.role === 'owner');
333
+ if (owner)
334
+ throw new Error(`${memberLabel(owner)} is the owner and cannot be removed`);
335
+ const names = targets.map((m) => `${memberLabel(m)} (${m.role})`).join(', ');
336
+ if (!(await confirm(`Remove ${names} from the workspace?`, opts.yes)))
337
+ return info('Aborted');
338
+ for (const m of targets)
339
+ await client.delete(`/workspace/list/${wsId}/member/${m.memberId}`);
340
+ render({ removed: targets.map((m) => m.userId) }, () => success(`Removed ${names}`));
256
341
  });
257
342
  // ── quota / stats ──────────────────────────────────────────────────────────
258
343
  ws.command('quota [id]')
@@ -0,0 +1,54 @@
1
+ export async function loadMembers(client, workspaceId) {
2
+ const ws = await client.getFresh(`/workspace/list/${workspaceId}`);
3
+ const members = (ws.members || []).map((m) => ({
4
+ memberId: String(m.id),
5
+ userId: String(m.user?.id ?? m.user ?? ''),
6
+ name: String(m.user?.name || ''),
7
+ email: String(m.user?.email || ''),
8
+ role: m.role,
9
+ }));
10
+ return { workspace: ws, members };
11
+ }
12
+ /** What people call a member in a list: the name, else the email, else the id. */
13
+ export const memberLabel = (m) => m.name || m.email || m.userId;
14
+ /**
15
+ * One member from what a person would type: user id, membership id, email or
16
+ * name (case-insensitive), else a unique name prefix. Ambiguity is an error,
17
+ * never a guess.
18
+ */
19
+ export function resolveMember(members, token) {
20
+ const t = token.trim();
21
+ const lower = t.toLowerCase();
22
+ const exact = members.find((m) => m.userId === t || m.memberId === t);
23
+ if (exact)
24
+ return exact;
25
+ const byEmail = members.filter((m) => m.email.toLowerCase() === lower);
26
+ if (byEmail.length === 1)
27
+ return byEmail[0];
28
+ const byName = members.filter((m) => m.name.toLowerCase() === lower);
29
+ if (byName.length === 1)
30
+ return byName[0];
31
+ if (byName.length > 1) {
32
+ throw new Error(`Several members are called "${t}": ${byName.map((m) => `${m.email || m.userId}`).join(', ')} — use the email or user id`);
33
+ }
34
+ const prefix = members.filter((m) => m.name.toLowerCase().startsWith(lower) || m.email.toLowerCase().startsWith(lower));
35
+ if (prefix.length === 1)
36
+ return prefix[0];
37
+ if (prefix.length > 1) {
38
+ throw new Error(`"${t}" matches several members: ${prefix.map(memberLabel).join(', ')}`);
39
+ }
40
+ throw new Error(`No member "${t}" in this workspace. See \`wji ws member list\`.`);
41
+ }
42
+ /** Run `fn` over `items` with at most `limit` in flight, keeping input order. */
43
+ export async function mapLimit(items, limit, fn) {
44
+ const out = new Array(items.length);
45
+ let next = 0;
46
+ const worker = async () => {
47
+ while (next < items.length) {
48
+ const i = next++;
49
+ out[i] = await fn(items[i], i);
50
+ }
51
+ };
52
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
53
+ return out;
54
+ }