@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.
- package/README.md +76 -2
- package/dist/commands/backreel.js +260 -0
- package/dist/commands/canvas.js +751 -86
- package/dist/commands/class.js +395 -0
- package/dist/commands/crew.js +205 -0
- package/dist/commands/npc.js +413 -0
- package/dist/commands/workspace.js +109 -24
- package/dist/core/archive.js +95 -0
- package/dist/core/crew.js +546 -0
- package/dist/core/members.js +54 -0
- package/dist/core/npc.js +108 -0
- package/dist/core/output.js +4 -2
- package/dist/index.js +10 -0
- package/package.json +1 -1
- package/skill/short-drama/SKILL.md +86 -8
- package/skill/short-drama/reference/action-bullet-time.md +47 -0
- package/skill/short-drama/reference/action-car-chase.md +51 -0
- package/skill/short-drama/reference/action-gun-fu.md +52 -0
- package/skill/short-drama/reference/action-historical-realist.md +50 -0
- package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
- package/skill/short-drama/reference/action-street-brawl.md +49 -0
- package/skill/short-drama/reference/action-superhero.md +50 -0
- package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
- package/skill/short-drama/reference/action.md +94 -0
- package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
- package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
- package/skill/short-drama/reference/cinematography-low-key.md +115 -0
- package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
- package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
- package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
- package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
- package/skill/short-drama/reference/cinematography.md +83 -0
- package/skill/short-drama/reference/composition-art-first.md +74 -0
- package/skill/short-drama/reference/craft-shots.md +3 -1
- package/skill/short-drama/reference/craft-story.md +4 -1
- package/skill/short-drama/reference/format.md +94 -0
- package/skill/short-drama/reference/grammar-comedy.md +105 -0
- package/skill/short-drama/reference/grammar-faceslap.md +67 -0
- package/skill/short-drama/reference/grammar.md +103 -0
- package/skill/short-drama/reference/operations-cli.md +1 -1
- package/skill/short-drama/reference/schema.md +34 -3
- package/skill/short-drama/reference/stage-art.md +1 -1
- package/skill/short-drama/reference/stage-outline.md +21 -0
- package/skill/short-drama/reference/stage-script.md +10 -0
- package/skill/short-drama/reference/stage-storyboard.md +15 -0
- package/skill/short-drama/reference/story-angst.md +117 -0
- package/skill/short-drama/reference/story-comedy.md +136 -0
- package/skill/short-drama/reference/story-cultivation.md +109 -0
- package/skill/short-drama/reference/story-family.md +119 -0
- package/skill/short-drama/reference/story-horror.md +124 -0
- package/skill/short-drama/reference/story-mystery.md +127 -0
- package/skill/short-drama/reference/story-palace.md +103 -0
- package/skill/short-drama/reference/story-revenge.md +124 -0
- package/skill/short-drama/reference/story-scifi.md +112 -0
- package/skill/short-drama/reference/story-sweet.md +128 -0
- package/skill/short-drama/reference/story.md +127 -0
- package/skill/short-drama/scripts/selftest.mjs +809 -1
- package/skill/short-drama/scripts/short-drama.mjs +1608 -16
- package/skill/wji/SKILL.md +84 -2
- package/skill/wji/reference/commands.md +96 -6
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|