@wji.ai/cli 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +63 -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/ads/SKILL.md +176 -0
  13. package/skill/ads/reference/ai-consistency.md +109 -0
  14. package/skill/ads/reference/brief-template.md +103 -0
  15. package/skill/ads/reference/compliance.md +66 -0
  16. package/skill/ads/reference/formats-image.md +108 -0
  17. package/skill/ads/reference/formats-video.md +121 -0
  18. package/skill/ads/reference/models.md +90 -0
  19. package/skill/ads/reference/product-photography.md +119 -0
  20. package/skill/ads/reference/styles.md +70 -0
  21. package/skill/short-drama/SKILL.md +90 -8
  22. package/skill/short-drama/reference/action-bullet-time.md +47 -0
  23. package/skill/short-drama/reference/action-car-chase.md +51 -0
  24. package/skill/short-drama/reference/action-gun-fu.md +52 -0
  25. package/skill/short-drama/reference/action-historical-realist.md +50 -0
  26. package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
  27. package/skill/short-drama/reference/action-street-brawl.md +49 -0
  28. package/skill/short-drama/reference/action-superhero.md +50 -0
  29. package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
  30. package/skill/short-drama/reference/action.md +94 -0
  31. package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
  32. package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
  33. package/skill/short-drama/reference/cinematography-low-key.md +115 -0
  34. package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
  35. package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
  36. package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
  37. package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
  38. package/skill/short-drama/reference/cinematography.md +83 -0
  39. package/skill/short-drama/reference/composition-art-first.md +74 -0
  40. package/skill/short-drama/reference/craft-shots.md +3 -1
  41. package/skill/short-drama/reference/craft-story.md +4 -1
  42. package/skill/short-drama/reference/format.md +94 -0
  43. package/skill/short-drama/reference/grammar-comedy.md +105 -0
  44. package/skill/short-drama/reference/grammar-faceslap.md +67 -0
  45. package/skill/short-drama/reference/grammar.md +103 -0
  46. package/skill/short-drama/reference/operations-cli.md +1 -1
  47. package/skill/short-drama/reference/schema.md +34 -3
  48. package/skill/short-drama/reference/stage-art.md +1 -1
  49. package/skill/short-drama/reference/stage-outline.md +21 -0
  50. package/skill/short-drama/reference/stage-script.md +10 -0
  51. package/skill/short-drama/reference/stage-storyboard.md +15 -0
  52. package/skill/short-drama/reference/story-angst.md +117 -0
  53. package/skill/short-drama/reference/story-comedy.md +136 -0
  54. package/skill/short-drama/reference/story-cultivation.md +109 -0
  55. package/skill/short-drama/reference/story-family.md +119 -0
  56. package/skill/short-drama/reference/story-horror.md +124 -0
  57. package/skill/short-drama/reference/story-mystery.md +127 -0
  58. package/skill/short-drama/reference/story-palace.md +103 -0
  59. package/skill/short-drama/reference/story-revenge.md +124 -0
  60. package/skill/short-drama/reference/story-scifi.md +112 -0
  61. package/skill/short-drama/reference/story-sweet.md +128 -0
  62. package/skill/short-drama/reference/story.md +127 -0
  63. package/skill/short-drama/scripts/selftest.mjs +809 -1
  64. package/skill/short-drama/scripts/short-drama.mjs +1608 -16
  65. package/skill/social-media/SKILL.md +152 -0
  66. package/skill/social-media/reference/content-craft.md +124 -0
  67. package/skill/social-media/reference/plan-template.md +171 -0
  68. package/skill/social-media/reference/platforms-china.md +94 -0
  69. package/skill/social-media/reference/platforms-global.md +122 -0
  70. package/skill/social-media/reference/ratio-map.md +100 -0
  71. package/skill/social-media/reference/repurpose.md +119 -0
  72. package/skill/wji/SKILL.md +66 -2
  73. package/skill/wji/reference/commands.md +76 -15
  74. package/skill/wji-classroom/SKILL.md +161 -0
package/README.md CHANGED
@@ -96,11 +96,14 @@ wji --no-org ws list # one-off personal call
96
96
  | `wji auth` | login / signup / token / whoami / refresh / logout, `auth key` for API keys |
97
97
  | `wji config` | profiles, endpoint, defaults |
98
98
  | `wji org` | organization context, members |
99
- | `wji ws` | workspaces, apps, members, quota, stats, templates |
99
+ | `wji ws` | workspaces, apps, members and invitations (email, share link), quota, stats, templates |
100
100
  | `wji fs` | fsnode tree: ls, tree, get, mkdir, touch, set, describe, rm, sort, batch |
101
101
  | `wji dataset` | dataset nodes (character / location / prop / …) and dataset records |
102
102
  | `wji asset` | upload, import from URL, search, status, download, summary |
103
- | `wji canvas` | Infinite Canvas: linked canvas + selection, nodes, references, generate nodes, submit |
103
+ | `wji canvas` | Infinite Canvas: linked canvas + selection, nodes, references, generate nodes, submit, owners, color tags, copy between canvases |
104
+ | `wji class` | teach with a workspace: roster, one canvas per student, copy nodes to every student, progress, collect results |
105
+ | `wji npc` | NPC City NPCs you own: profile, publish, work history (jobs), soul, character document, transfers |
106
+ | `wji backreel` | Backreel posts: drafts, publish, cast and crew credits (NPCs included) |
104
107
  | `wji gen` | `image` / `video` / `audio` generation plus `cost` estimation |
105
108
  | `wji history` | generation history: search, poll, bookmark, download |
106
109
  | `wji models` | list models and their form parameters |
@@ -171,6 +174,44 @@ canvas that is not linked (`wji canvas list`); `wji canvas create <name>` adds
171
174
  an empty canvas, `wji canvas add image --asset <id>` shows an existing asset
172
175
  and `wji canvas add dataset --dataset <id>` places a dataset card.
173
176
 
177
+ ## Classes (`wji class`)
178
+
179
+ A workspace you own can be a class: students are members, each with a
180
+ private canvas of their own.
181
+
182
+ ```bash
183
+ wji ws create "Art 101" -a canvas && wji ws use <id>
184
+ wji ws member invite alice@school.edu bob@school.edu # registered users join at once
185
+ wji ws member link # or share a join link (replaces the old one)
186
+ wji class canvases --create # one private canvas per student
187
+ wji canvas tag red <nodeIds…> -l HW1 # mark the assignment on your canvas
188
+ wji class copy --selected -y # copy your selected nodes to every student
189
+ wji class status --tag HW1 --sort progress # who is done, running, failed, not started
190
+ wji class results --tag HW1 -d ./hw1 # collect finished work, one folder per student
191
+ ```
192
+
193
+ Workspace prompt presets (`wji preset create <name> -p … -w <id>`) show up for
194
+ students when they press `/` on the canvas. The `wji-classroom` skill walks an
195
+ agent through the whole flow.
196
+
197
+ ## NPC City and Backreel
198
+
199
+ ```bash
200
+ wji npc list # your NPCs (org ones with --org)
201
+ wji npc create "Mika" --role Singer --genre "City pop" --handle mika.npc
202
+ wji npc job add @mika -t concert --title "Neon Night" --date 2026-10-01
203
+ wji npc doc export @mika -o mika.md # edit, then:
204
+ wji npc doc apply @mika @mika.md --dry-run
205
+ wji npc publish @mika
206
+
207
+ wji backreel create "Harbour" -d @synopsis.txt --cover <assetId>
208
+ wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
209
+ wji backreel publish <postId>
210
+ ```
211
+
212
+ Creating NPCs needs NPC City spawn permission on your account. Admin actions
213
+ (spawn permission, featured slots, moderation) are not part of `wji`.
214
+
174
215
  ## Skills
175
216
 
176
217
  Skills are SKILL.md bundles (markdown only — scripts don't persist server-side).
@@ -249,9 +290,29 @@ upgrading the CLI so the skill matches the commands.
249
290
  pipeline (outline → characters → scenes/props → script → storyboard) whose
250
291
  stages are checked by `skill/short-drama/scripts/short-drama.mjs validate`,
251
292
  with the data stored as workspace datasets and the work laid out on canvases.
293
+
294
+ `skill/ads/` and `skill/social-media/` are two more: `ads` makes commercials
295
+ and ad creative (TVC / bumper / social ad formats and durations, product
296
+ photography and studio lighting, visual styles, product identity across
297
+ generations, banner sizes, AI-disclosure compliance); `social-media` holds
298
+ every platform's image / video specs (Instagram, TikTok, YouTube, Facebook, X,
299
+ LinkedIn, Pinterest, Threads, Snapchat, 小红书, 抖音, 快手, 视频号, B站, 微博,
300
+ 公众号) and the repurposing recipes — an episode into 9:16 teasers and posters,
301
+ an ad master into every placement. Install them the same way:
302
+
303
+ ```bash
304
+ cp -r "$(npm root -g)/@wji.ai/cli/skill/short-drama" ~/.claude/skills/short-drama
305
+ cp -r "$(npm root -g)/@wji.ai/cli/skill/ads" ~/.claude/skills/ads
306
+ cp -r "$(npm root -g)/@wji.ai/cli/skill/social-media" ~/.claude/skills/social-media
307
+ ```
252
308
  Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
253
309
  next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
254
310
 
311
+ `skill/wji-classroom/` is a teacher's skill on top of `wji`: set up a class
312
+ workspace, invite students, give each a canvas, hand out prompts and starter
313
+ nodes, and check or collect their work. Install it the same way
314
+ (`cp -r skill/wji-classroom ~/.claude/skills/wji-classroom`).
315
+
255
316
  ## Crews (`wji crew`)
256
317
 
257
318
  A **crew pack** is a repository of Agent Skills plus agent seats (`crew.json` +
@@ -0,0 +1,260 @@
1
+ import { Command } from 'commander';
2
+ import { WjiClient } from '../core/client.js';
3
+ import { confirm, readJson, readValue } from '../core/prompt.js';
4
+ import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
5
+ import { NPC_JOB_TYPES, npcLabel, parseJob, parsePositions, resolveNpc, today, updateJobs } from '../core/npc.js';
6
+ const POSTS = '/backreel/post';
7
+ const KINDS = ['showcase', 'crowdfunding'];
8
+ const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
9
+ const CREDIT_KINDS = ['creative', 'crew', 'cast'];
10
+ /** Credit fields the create / update DTO accepts; the rest of a read-back credit is dropped. */
11
+ const CREDIT_FIELDS = ['kind', 'name', 'role', 'user', 'photo', 'avatarUrl', 'link', 'source', 'npcId', 'order'];
12
+ export function backreelCommand(globals) {
13
+ const br = new Command('backreel')
14
+ .aliases(['br'])
15
+ .description('Manage your Backreel posts (works) and their credits, including NPC cast');
16
+ br.command('list', { isDefault: true })
17
+ .description('Your posts, drafts included; --all for everyone\'s published posts')
18
+ .option('--all', 'search all published posts instead of yours')
19
+ .option('-k, --keyword <text>', 'search text')
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(`${POSTS}/${opts.all ? 'search' : 'search/my'}`, {
25
+ keyword: opts.keyword,
26
+ page: Number(opts.page),
27
+ limit: Number(opts.limit),
28
+ });
29
+ render(result, (r) => {
30
+ table(r?.data || [], [
31
+ { header: 'id', value: (p) => p.id },
32
+ { header: 'title', value: (p) => truncate(p.title, 28) },
33
+ { header: 'kind', value: (p) => p.kind },
34
+ { header: 'publish', value: (p) => p.publishStatus },
35
+ { header: 'project', value: (p) => p.projectStatus },
36
+ { header: 'credits', value: (p) => (p.credits || []).length },
37
+ { header: 'updated', value: (p) => relativeTime(p.updatedAt) },
38
+ ]);
39
+ const p = r?.pagination;
40
+ if (p)
41
+ info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
42
+ });
43
+ });
44
+ br.command('get <postId>')
45
+ .description('Show a post and its credits')
46
+ .action(async (id) => {
47
+ const client = new WjiClient(globals());
48
+ const post = await client.getFresh(`${POSTS}/list/${id}`);
49
+ render(post, () => printPost(post));
50
+ });
51
+ postOptions(br.command('create <title>').description('Create a post (always a draft until `backreel publish`)')).action(async (title, opts) => {
52
+ const client = new WjiClient(globals());
53
+ // The create DTO validates kind and description although the entity has defaults.
54
+ const body = { kind: 'showcase', description: '', ...(await postBody(opts)), title };
55
+ const created = await client.post(`${POSTS}/create`, body);
56
+ render(created, (p) => success(`Created draft "${p.title}" — ${p.id}`));
57
+ });
58
+ postOptions(br.command('set <postId>')
59
+ .description('Update a post; only the fields you pass change')
60
+ .option('--title <text>', 'title')).action(async (id, opts) => {
61
+ const client = new WjiClient(globals());
62
+ const body = await postBody(opts);
63
+ if (opts.title)
64
+ body.title = opts.title;
65
+ if (!Object.keys(body).length)
66
+ throw new Error('Nothing to change — pass at least one field flag');
67
+ const updated = await client.put(`${POSTS}/list/${id}`, body);
68
+ render(updated, () => success(`Updated "${updated.title}": ${Object.keys(body).join(', ')}`));
69
+ });
70
+ for (const action of ['publish', 'unpublish']) {
71
+ br.command(`${action} <postId>`)
72
+ .description(action === 'publish' ? 'Publish a post' : 'Take a post offline')
73
+ .action(async (id) => {
74
+ const client = new WjiClient(globals());
75
+ const post = await client.post(`${POSTS}/list/${id}/${action}`);
76
+ render(post, (p) => success(`"${p.title}" is now ${p.publishStatus}`));
77
+ });
78
+ }
79
+ br.command('rm <postId>')
80
+ .description('Delete a post')
81
+ .option('-y, --yes', 'skip confirmation')
82
+ .action(async (id, opts) => {
83
+ const client = new WjiClient(globals());
84
+ const post = await client.getFresh(`${POSTS}/list/${id}`);
85
+ if (!(await confirm(`Delete post "${post.title}" (${id})?`, opts.yes)))
86
+ return info('Aborted');
87
+ const result = await client.delete(`${POSTS}/list/${id}`);
88
+ render(result, () => success(`Deleted "${post.title}"`));
89
+ });
90
+ // ── credits ────────────────────────────────────────────────────────────
91
+ const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
92
+ credit
93
+ .command('list <postId>', { isDefault: true })
94
+ .description('Numbered credits')
95
+ .action(async (id) => {
96
+ const client = new WjiClient(globals());
97
+ const post = await client.getFresh(`${POSTS}/list/${id}`);
98
+ render(post.credits || [], (credits) => printCredits(credits));
99
+ });
100
+ credit
101
+ .command('add <postId>')
102
+ .description('Add a credit; --npc casts one of your NPCs')
103
+ .option('--name <name>', 'display name (defaults to the NPC or user name)')
104
+ .requiredOption('--role <role>', 'job or character, e.g. 導演 or 飾 阿明')
105
+ .option('--kind <kind>', CREDIT_KINDS.join(' | '), 'cast')
106
+ .option('--npc <npc>', 'link an NPC City NPC (id, @handle or name)')
107
+ .option('--user <userId>', 'link a WJI user (their name and avatar win)')
108
+ .option('--link <url>', 'profile link')
109
+ .option('--job <type>', `also add a work-history job to the NPC: ${NPC_JOB_TYPES.join(' | ')}`)
110
+ .action(async (id, opts) => {
111
+ if (!CREDIT_KINDS.includes(opts.kind))
112
+ throw new Error(`--kind must be one of ${CREDIT_KINDS.join(', ')}`);
113
+ if (opts.job && !opts.npc)
114
+ throw new Error('--job needs --npc');
115
+ if (opts.job && !NPC_JOB_TYPES.includes(opts.job)) {
116
+ throw new Error(`--job must be one of ${NPC_JOB_TYPES.join(', ')}`);
117
+ }
118
+ const client = new WjiClient(globals());
119
+ const npc = opts.npc ? await resolveNpc(client, opts.npc) : undefined;
120
+ // Any public NPC can be credited, but only your own gets a job. The soul
121
+ // route is owner-only, so it checks that before anything is written.
122
+ if (npc && opts.job) {
123
+ await client.get(`/npc-city/residents/${npc.id}/soul`).catch(() => {
124
+ throw new Error(`${npcLabel(npc)} is not yours, so --job can't add to its work history (the credit alone works without --job)`);
125
+ });
126
+ }
127
+ const name = opts.name || npc?.name;
128
+ if (!name && !opts.user)
129
+ throw new Error('Pass --name, --npc or --user');
130
+ const entry = { kind: opts.kind, name: name || '-', role: opts.role };
131
+ if (opts.user)
132
+ entry.user = opts.user;
133
+ if (opts.link)
134
+ entry.link = opts.link;
135
+ if (npc) {
136
+ entry.npcId = npc.id;
137
+ if (npc.image)
138
+ entry.avatarUrl = npc.image;
139
+ }
140
+ const post = await updateCredits(client, id, (credits) => [...credits, entry]);
141
+ // The job is written after the credit, dated today and titled after the post.
142
+ let job;
143
+ if (npc && opts.job) {
144
+ job = parseJob({ type: opts.job, title: post.title, detail: opts.role, date: today() });
145
+ await updateJobs(client, npc.id, (jobs) => [...jobs, job]);
146
+ }
147
+ render({ post: post.id, credits: post.credits, job: job ?? null }, () => {
148
+ success(`Credited ${name || opts.user} as ${opts.role} on "${post.title}" (#${post.credits.length})`);
149
+ if (job)
150
+ success(`Added ${job.type} "${job.title}" to ${npcLabel(npc)}'s work history`);
151
+ });
152
+ });
153
+ credit
154
+ .command('rm <postId> <n...>')
155
+ .description('Remove credits by number')
156
+ .option('-y, --yes', 'skip confirmation')
157
+ .action(async (id, ns, opts) => {
158
+ const client = new WjiClient(globals());
159
+ const post = await client.getFresh(`${POSTS}/list/${id}`);
160
+ const credits = post.credits || [];
161
+ const positions = parsePositions(ns, credits.length);
162
+ const names = positions.map((i) => `#${i + 1} ${credits[i].name}`).join(', ');
163
+ if (!(await confirm(`Remove ${names} from "${post.title}"?`, opts.yes)))
164
+ return info('Aborted');
165
+ const updated = await updateCredits(client, id, (current) => {
166
+ if (JSON.stringify(current) !== JSON.stringify(credits.map(toCreditDto))) {
167
+ throw new Error('The credits changed since they were read — list them again and retry');
168
+ }
169
+ return current.filter((_, i) => !positions.includes(i));
170
+ });
171
+ render(updated.credits, () => success(`Removed ${positions.length} credit(s); ${updated.credits.length} left`));
172
+ });
173
+ return br;
174
+ }
175
+ // ── helpers ──────────────────────────────────────────────────────────────
176
+ function postOptions(cmd) {
177
+ return cmd
178
+ .option('--kind <kind>', KINDS.join(' | '))
179
+ .option('--category <category>', 'category')
180
+ .option('--project-status <status>', PROJECT_STATUSES.join(' | '))
181
+ .option('--tags <tags>', 'comma-separated tags (replaces the list)')
182
+ .option('-d, --description <text>', 'description, plain text or HTML (@file / - supported)')
183
+ .option('--cover <assetId>', 'landscape cover asset')
184
+ .option('--cover-portrait <assetId>', 'portrait cover asset')
185
+ .option('--trailer <assetId>', 'trailer video asset')
186
+ .option('--body <json>', 'any other post fields as JSON (@file / - supported); flags win');
187
+ }
188
+ async function postBody(opts) {
189
+ const body = { ...((await readJson(opts.body)) || {}) };
190
+ if (opts.kind)
191
+ body.kind = oneOf(opts.kind, KINDS, '--kind');
192
+ if (opts.category)
193
+ body.category = opts.category;
194
+ if (opts.projectStatus)
195
+ body.projectStatus = oneOf(opts.projectStatus, PROJECT_STATUSES, '--project-status');
196
+ if (opts.tags !== undefined)
197
+ body.tags = String(opts.tags).split(',').map((t) => t.trim()).filter(Boolean);
198
+ if (opts.description !== undefined)
199
+ body.description = toHtml(await readValue(opts.description));
200
+ if (opts.cover)
201
+ body.cover = opts.cover;
202
+ if (opts.coverPortrait)
203
+ body.coverPortrait = opts.coverPortrait;
204
+ if (opts.trailer)
205
+ body.trailer = opts.trailer;
206
+ return body;
207
+ }
208
+ /** Descriptions are stored as sanitized HTML; plain text becomes paragraphs. */
209
+ function toHtml(text) {
210
+ if (/<\/?(p|br|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|div|span|img|hr)\b[^>]*>/i.test(text))
211
+ return text;
212
+ const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
213
+ return text
214
+ .trim()
215
+ .split(/\n{2,}/)
216
+ .map((para) => `<p>${escape(para).replace(/\n/g, '<br>')}</p>`)
217
+ .join('');
218
+ }
219
+ const toCreditDto = (c) => Object.fromEntries(CREDIT_FIELDS.filter((k) => c[k] !== undefined && c[k] !== null).map((k) => [k, c[k]]));
220
+ /** Credits have no ids: read fresh, change, write the whole array back in order. */
221
+ async function updateCredits(client, id, fn) {
222
+ const post = await client.getFresh(`${POSTS}/list/${id}`);
223
+ const next = fn((post.credits || []).map(toCreditDto)).map((c, i) => ({ ...c, order: i }));
224
+ if (next.length > 30)
225
+ throw new Error('A post can have at most 30 credits');
226
+ return client.put(`${POSTS}/list/${id}`, { credits: next });
227
+ }
228
+ function oneOf(value, allowed, flag) {
229
+ if (!allowed.includes(value))
230
+ throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
231
+ return value;
232
+ }
233
+ function printPost(p) {
234
+ detail([
235
+ ['id', p.id],
236
+ ['title', p.title],
237
+ ['kind', p.kind],
238
+ ['publish', p.publishStatus],
239
+ ['project', p.projectStatus],
240
+ ['category', p.category],
241
+ ['tags', (p.tags || []).join(', ')],
242
+ ['description', truncate(p.descriptionText || p.description, 120)],
243
+ ['cover', p.cover?.url || p.cover?.id || p.cover],
244
+ ['creator', p.creator?.name],
245
+ ['likes', p.likeCount],
246
+ ['updated', relativeTime(p.updatedAt)],
247
+ ]);
248
+ process.stdout.write('\n');
249
+ printCredits(p.credits || []);
250
+ }
251
+ function printCredits(credits) {
252
+ table(credits.map((c, i) => ({ ...c, n: i + 1 })), [
253
+ { header: '#', value: (c) => c.n },
254
+ { header: 'kind', value: (c) => c.kind },
255
+ { header: 'name', value: (c) => truncate(c.name, 20) },
256
+ { header: 'role', value: (c) => truncate(c.role, 24) },
257
+ { header: 'source', value: (c) => c.source },
258
+ { header: 'npc / user', value: (c) => c.npcId || c.user },
259
+ ]);
260
+ }