@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
package/README.md CHANGED
@@ -96,17 +96,21 @@ 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 |
107
110
  | `wji agent` | browse and manage AI agents |
108
111
  | `wji preset` | prompt presets: yours, public, workspace; fork and copy |
109
112
  | `wji skill` | agent skills: system list, push/pull SKILL.md bundles, import zip, update, rm |
113
+ | `wji crew` | install crew packs (skills + agent seats) into Claude Code, Codex, OpenClaw, or export for Grok bot |
110
114
  | `wji profile` | account profile, password, wallet, login history |
111
115
 
112
116
  Run `wji <group> --help` for the full option list.
@@ -161,6 +165,7 @@ wji canvas status # what is linked and selecte
161
165
  wji canvas add video --from <img1> <img2> -m seedance-2.0-fast-ref -r 16:9 -d 8 \
162
166
  -p "@image1 paddles across the lake while @image2 waits on the shore"
163
167
  wji canvas set <node> --resolution 720p --set seed=42
168
+ wji canvas add text --text-file prompt.txt --plain --size 600,900 # text nodes are Markdown; --plain keeps line breaks
164
169
  wji canvas gen <node> --yes --wait # spends credits
165
170
  ```
166
171
 
@@ -169,6 +174,44 @@ canvas that is not linked (`wji canvas list`); `wji canvas create <name>` adds
169
174
  an empty canvas, `wji canvas add image --asset <id>` shows an existing asset
170
175
  and `wji canvas add dataset --dataset <id>` places a dataset card.
171
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
+
172
215
  ## Skills
173
216
 
174
217
  Skills are SKILL.md bundles (markdown only — scripts don't persist server-side).
@@ -249,3 +292,34 @@ stages are checked by `skill/short-drama/scripts/short-drama.mjs validate`,
249
292
  with the data stored as workspace datasets and the work laid out on canvases.
250
293
  Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
251
294
  next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
295
+
296
+ `skill/wji-classroom/` is a teacher's skill on top of `wji`: set up a class
297
+ workspace, invite students, give each a canvas, hand out prompts and starter
298
+ nodes, and check or collect their work. Install it the same way
299
+ (`cp -r skill/wji-classroom ~/.claude/skills/wji-classroom`).
300
+
301
+ ## Crews (`wji crew`)
302
+
303
+ A **crew pack** is a repository of Agent Skills plus agent seats (`crew.json` +
304
+ `agents/<seat>.md`) — the multi-agent form of a pipeline. `wji crew install`
305
+ puts one where each host discovers it, writing that host's agent format from
306
+ the same persona files:
307
+
308
+ ```bash
309
+ wji crew install short-drama --for claude # ~/.claude/skills + ~/.claude/agents/<seat>.md
310
+ wji crew install short-drama --for codex # ~/.agents/skills + ~/.codex/agents/<seat>.toml
311
+ wji crew install short-drama --for openclaw # ~/.agents/skills + ~/.openclaw/workspaces/<crew>/<seat>/{AGENTS,SOUL}.md
312
+ wji crew install short-drama --for grok # ./<crew>-export.zip in the Grok bot import layout
313
+ wji crew install short-drama --for all --dry-run # show the plan only
314
+ wji crew install --from ../short-drama-crew --for claude --link # symlink a local checkout
315
+ wji crew status # what was written, and whether it is still there
316
+ wji crew uninstall short-drama-crew --for codex # remove exactly the recorded paths
317
+ wji crew check ./short-drama-crew # validate a pack before publishing it
318
+ ```
319
+
320
+ Known crews: `short-drama` ([ultra-instinct-ai/short-drama-crew](https://github.com/ultra-instinct-ai/short-drama-crew)).
321
+ `--from` also takes a `.zip`, a `.tar.gz` or a URL; `--ref` picks a branch or tag;
322
+ `--project <dir>` installs into a project instead of your home directory. The
323
+ installer never edits `~/.openclaw/openclaw.json` — it writes a JSON5 fragment
324
+ to merge — and it warns that the Codex `wji-operator` seat needs
325
+ `[sandbox_workspace_write] network_access = true`.
@@ -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
+ }