@wji.ai/cli 0.6.0 → 0.7.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 (38) hide show
  1. package/README.md +19 -2
  2. package/dist/commands/agent.js +28 -8
  3. package/dist/commands/backreel-enquiry.js +171 -0
  4. package/dist/commands/backreel-episode.js +243 -0
  5. package/dist/commands/backreel-fund.js +299 -0
  6. package/dist/commands/backreel.js +201 -18
  7. package/dist/commands/gen.js +5 -1
  8. package/dist/commands/npc-album.js +201 -0
  9. package/dist/commands/npc-live.js +316 -0
  10. package/dist/commands/npc.js +26 -2
  11. package/dist/commands/workspace.js +74 -1
  12. package/dist/core/backreel.js +23 -0
  13. package/dist/core/client.js +18 -1
  14. package/dist/core/image.js +106 -0
  15. package/dist/core/npc.js +1 -1
  16. package/dist/core/vod.js +149 -0
  17. package/dist/index.js +6 -0
  18. package/package.json +1 -1
  19. package/skill/backreel/SKILL.md +171 -0
  20. package/skill/backreel/reference/crowdfunding.md +82 -0
  21. package/skill/backreel/reference/enquiries.md +80 -0
  22. package/skill/backreel/reference/episodes.md +104 -0
  23. package/skill/backreel/reference/images.md +76 -0
  24. package/skill/npc-character/SKILL.md +111 -0
  25. package/skill/npc-character/examples/nagi.character.md +117 -0
  26. package/skill/npc-character/examples/nagi.stage.md +34 -0
  27. package/skill/npc-character/examples/yuzu.character.md +110 -0
  28. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  29. package/skill/npc-character/reference/character-craft.md +153 -0
  30. package/skill/npc-character/reference/document-format.md +151 -0
  31. package/skill/npc-character/reference/interview.md +44 -0
  32. package/skill/npc-character/reference/life.md +92 -0
  33. package/skill/npc-character/reference/runtime.md +95 -0
  34. package/skill/npc-character/reference/stage-craft.md +109 -0
  35. package/skill/npc-character/reference/visual-kit.md +81 -0
  36. package/skill/npc-character/reference/voice.md +101 -0
  37. package/skill/wji/SKILL.md +10 -1
  38. package/skill/wji/reference/commands.md +43 -6
package/README.md CHANGED
@@ -102,8 +102,8 @@ wji --no-org ws list # one-off personal call
102
102
  | `wji asset` | upload, import from URL, search, status, download, summary |
103
103
  | `wji canvas` | Infinite Canvas: linked canvas + selection, nodes, references, generate nodes, submit, owners, color tags, copy between canvases |
104
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) |
105
+ | `wji npc` | NPC City NPCs you own: profile, publish, work history (jobs), soul, character document, transfers; memories, live readiness, stage documents |
106
+ | `wji backreel` | Backreel posts: drafts, publish, images per slot, episodes on VOD, crowdfunding and donations, enquiries, cast and crew credits (NPCs included) |
107
107
  | `wji gen` | `image` / `video` / `audio` generation plus `cost` estimation |
108
108
  | `wji history` | generation history: search, poll, bookmark, download |
109
109
  | `wji models` | list models and their form parameters |
@@ -210,7 +210,11 @@ wji npc publish @mika
210
210
 
211
211
  wji backreel create "Harbour" -d @synopsis.txt --cover <assetId>
212
212
  wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
213
+ wji backreel image poster.png --slot portrait --post <postId> # size check + upload + set
214
+ wji backreel episode add <postId> E01.mp4 -t "第一集" # VOD upload
215
+ wji backreel check <postId>
213
216
  wji backreel publish <postId>
217
+ wji backreel enquiry list --unread
214
218
  ```
215
219
 
216
220
  Creating NPCs needs NPC City spawn permission on your account. Admin actions
@@ -313,6 +317,19 @@ cp -r "$(npm root -g)/@wji.ai/cli/skill/social-media" ~/.claude/skills/social-me
313
317
  Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
314
318
  next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
315
319
 
320
+ `skill/backreel/` is a creator's skill for Backreel: create and publish a
321
+ work, prepare images at the right size per slot (`wji br image`), upload and
322
+ maintain episodes on VOD, run a crowdfunding campaign and donation tiers, and
323
+ answer enquiries. Install it the same way
324
+ (`cp -r skill/backreel ~/.claude/skills/backreel`).
325
+
326
+ `skill/npc-character/` teaches an agent to write an NPC City character that
327
+ feels like a person — want / need / contradiction, memories as scenes, a voice
328
+ the reply model can imitate, protocols, on-camera habits — and its live
329
+ stages, placing each word where the live runtime actually reads it, then
330
+ applying them with `wji npc doc` / `memory` / `stage`. Install it the same way
331
+ (`cp -r skill/npc-character ~/.claude/skills/npc-character`).
332
+
316
333
  `skill/wji-classroom/` is a teacher's skill on top of `wji`: set up a class
317
334
  workspace, invite students, give each a canvas, hand out prompts and starter
318
335
  nodes, and check or collect their work. Install it the same way
@@ -15,6 +15,21 @@ function skillRefLabel(ref) {
15
15
  return String(ref);
16
16
  return ref.kind === 'system' ? `system:${ref.key}` : `user:${ref.id}`;
17
17
  }
18
+ /** Backend DEFAULT_MAX_STEPS / MAX_STEPS_LIMIT (src/ai/_/constant.ts). */
19
+ const DEFAULT_MAX_STEPS = 40;
20
+ const MAX_STEPS_LIMIT = 200;
21
+ /** `--max-steps`: an integer in range, or "default" to clear back to the site default. */
22
+ function parseMaxSteps(value) {
23
+ if (value === undefined)
24
+ return undefined;
25
+ if (['default', 'none', 'null'].includes(value.trim().toLowerCase()))
26
+ return null;
27
+ const n = Number(value);
28
+ if (!Number.isInteger(n) || n < 1 || n > MAX_STEPS_LIMIT) {
29
+ throw new Error(`--max-steps must be an integer from 1 to ${MAX_STEPS_LIMIT} (or "default")`);
30
+ }
31
+ return n;
32
+ }
18
33
  export function agentCommand(globals) {
19
34
  const agent = new Command('agent').description('Manage AI agents');
20
35
  agent
@@ -59,7 +74,7 @@ export function agentCommand(globals) {
59
74
  });
60
75
  agent
61
76
  .command('get <id>')
62
- .description('Show an agent (instructions included when you own it)')
77
+ .description('Show an agent (private agents: creator only; instructions only when you own it)')
63
78
  .action(async (id) => {
64
79
  const client = new WjiClient(globals());
65
80
  const a = await client.get(`/ai/agent/list/${id}`);
@@ -72,6 +87,7 @@ export function agentCommand(globals) {
72
87
  ['category', a.category],
73
88
  ['tags', (a.tags || []).join(', ')],
74
89
  ['greeting', a.greeting],
90
+ ['maxSteps', a.maxSteps ?? color.dim(`default (${DEFAULT_MAX_STEPS})`)],
75
91
  ['creator', a.creator?.name || a.creator?.id || a.creator],
76
92
  ['organization', a.organization?.id || a.organization || 'personal'],
77
93
  ['skills', (a.skills || []).map(skillRefLabel).join(', ')],
@@ -80,7 +96,7 @@ export function agentCommand(globals) {
80
96
  });
81
97
  agent
82
98
  .command('create <name>')
83
- .description('Create an agent')
99
+ .description('Create an agent (needs the agent-create permission on your account)')
84
100
  .requiredOption('-d, --description <text>', 'short description')
85
101
  .option('-i, --instructions <text>', 'system prompt (@file / - supported)')
86
102
  .option('-m, --model <name>', 'backing model')
@@ -89,7 +105,8 @@ export function agentCommand(globals) {
89
105
  .option('-t, --tag <tag...>', 'tags')
90
106
  .option('--greeting <text>', 'opening message')
91
107
  .option('--skill <ref...>', 'attach skills: system:<key> or user:<skillId>')
92
- .option('--set <key=value...>', 'arbitrary field')
108
+ .option('--max-steps <n>', `model calls allowed per chat turn, 1-${MAX_STEPS_LIMIT} (default ${DEFAULT_MAX_STEPS})`)
109
+ .option('--set <key=value...>', 'arbitrary field (admin-only fields are dropped by the server)')
93
110
  .action(async (name, opts) => {
94
111
  const client = new WjiClient(globals());
95
112
  const created = await client.post('/ai/agent/create', {
@@ -102,13 +119,14 @@ export function agentCommand(globals) {
102
119
  tags: opts.tag,
103
120
  greeting: opts.greeting,
104
121
  skills: opts.skill?.length ? opts.skill.map(parseSkillRef) : undefined,
122
+ maxSteps: parseMaxSteps(opts.maxSteps),
105
123
  ...parseKeyValues(opts.set),
106
124
  });
107
125
  render(created, (a) => success(`Created agent ${a.name} (${a.id})`));
108
126
  });
109
127
  agent
110
128
  .command('update <id>')
111
- .description('Update an agent')
129
+ .description('Update an agent (creator only)')
112
130
  .option('-n, --name <name>', 'new name')
113
131
  .option('-d, --description <text>', 'new description')
114
132
  .option('-i, --instructions <text>', 'new system prompt (@file / - supported)')
@@ -117,7 +135,8 @@ export function agentCommand(globals) {
117
135
  .option('-c, --category <name>', 'category')
118
136
  .option('-t, --tag <tag...>', 'replace tags')
119
137
  .option('--greeting <text>', 'opening message')
120
- .option('--set <key=value...>', 'arbitrary field')
138
+ .option('--max-steps <n>', `model calls allowed per chat turn, 1-${MAX_STEPS_LIMIT}; "default" clears it`)
139
+ .option('--set <key=value...>', 'arbitrary field (admin-only fields are dropped by the server)')
121
140
  .action(async (id, opts) => {
122
141
  const client = new WjiClient(globals());
123
142
  const updated = await client.put(`/ai/agent/list/${id}`, {
@@ -129,6 +148,7 @@ export function agentCommand(globals) {
129
148
  category: opts.category,
130
149
  tags: opts.tag,
131
150
  greeting: opts.greeting,
151
+ maxSteps: parseMaxSteps(opts.maxSteps),
132
152
  ...parseKeyValues(opts.set),
133
153
  });
134
154
  render(updated, () => success(`Updated ${id}`));
@@ -147,7 +167,7 @@ export function agentCommand(globals) {
147
167
  });
148
168
  agent
149
169
  .command('files <id>')
150
- .description('List files attached to an agent')
170
+ .description('List files attached to an agent (creator only)')
151
171
  .action(async (id) => {
152
172
  const client = new WjiClient(globals());
153
173
  const files = await client.get(`/ai/agent/list/${id}/files`);
@@ -236,7 +256,7 @@ export function agentCommand(globals) {
236
256
  });
237
257
  agent
238
258
  .command('versions <id>')
239
- .description('List an agent\'s version history')
259
+ .description('List an agent\'s version history (creator only — snapshots hold instructions)')
240
260
  .action(async (id) => {
241
261
  const client = new WjiClient(globals());
242
262
  const versions = await client.get(`/ai/agent/list/${id}/versions`);
@@ -251,7 +271,7 @@ export function agentCommand(globals) {
251
271
  });
252
272
  agent
253
273
  .command('restore <id> <versionId>')
254
- .description('Restore an agent to a previous version')
274
+ .description('Restore an agent to a previous version (creator only)')
255
275
  .action(async (id, versionId) => {
256
276
  const client = new WjiClient(globals());
257
277
  const result = await client.post(`/ai/agent/list/${id}/versions/${versionId}/restore`);
@@ -0,0 +1,171 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { backreelClient, oneOf } from '../core/backreel.js';
5
+ import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
6
+ import { readValue } from '../core/prompt.js';
7
+ const ENQ = '/backreel/enquiry';
8
+ const TYPES = ['collaboration', 'new_project', 'investment', 'licensing', 'access_request', 'general'];
9
+ const STATUSES = ['new', 'in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
10
+ /** What the recipient (post owner) may set; the sender may only withdraw. */
11
+ const SETTABLE = ['in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
12
+ const MAX_ATTACHMENTS = 5;
13
+ const MESSAGE_MAX = 4000;
14
+ const person = (p) => (p ? `${p.name || p.id}${p.verifiedKinds?.length ? color.dim(` ✓${p.verifiedKinds.join(',')}`) : ''}` : color.dim('system'));
15
+ /** Enquiry attachments are private Backreel files (purpose=enquiry: documents and images ≤20 MB). */
16
+ async function uploadAttachments(client, files = []) {
17
+ if (files.length > MAX_ATTACHMENTS)
18
+ throw new Error(`At most ${MAX_ATTACHMENTS} attachments per message`);
19
+ const ids = [];
20
+ for (const file of files) {
21
+ info(color.dim(`Uploading ${basename(file)}…`));
22
+ const uploaded = await client.upload('/backreel/file/upload?purpose=enquiry', file, {});
23
+ ids.push(uploaded.id);
24
+ }
25
+ return ids;
26
+ }
27
+ function printSummaryTable(rows) {
28
+ table(rows, [
29
+ { header: 'id', value: (e) => e.id },
30
+ { header: '', value: (e) => (e.unread ? color.yellow('●') : '') },
31
+ { header: 'type', value: (e) => e.type },
32
+ { header: 'status', value: (e) => e.status + (e.archived && e.status !== 'archived' ? color.dim(' (archived)') : '') },
33
+ { header: 'from', value: (e) => truncate(e.side === 'sender' ? `→ ${e.recipient?.name}` : e.sender?.name, 20) },
34
+ { header: 'post', value: (e) => truncate(e.post?.title, 20) },
35
+ { header: 'last', value: (e) => truncate(e.lastMessagePreview || e.subject, 36) },
36
+ { header: 'when', value: (e) => relativeTime(e.lastMessageAt) },
37
+ ]);
38
+ }
39
+ function printEnquiry(e, messages) {
40
+ const extra = [];
41
+ if (e.projectInvite) {
42
+ const p = e.projectInvite;
43
+ const budget = p.budgetMin || p.budgetMax ? `${p.budgetMin ?? '?'}–${p.budgetMax ?? '?'} ${p.currency || ''}` : '';
44
+ extra.push(['project', [p.projectType, p.title, budget, p.location, p.roleNeeded].filter(Boolean).join(' · ')]);
45
+ if (p.timelineStart || p.timelineEnd)
46
+ extra.push(['timeline', `${p.timelineStart ?? '?'} → ${p.timelineEnd ?? '?'}`]);
47
+ }
48
+ if (e.licensing)
49
+ extra.push(['licensing', [e.licensing.territory, e.licensing.media, e.licensing.term].filter(Boolean).join(' · ')]);
50
+ if (e.investment)
51
+ extra.push(['investment', `${e.investment.ticketSize ?? '?'} ${e.investment.currency || ''}`]);
52
+ if (e.accessPurpose)
53
+ extra.push(['access purpose', e.accessPurpose]);
54
+ const c = e.contact || {};
55
+ detail([
56
+ ['id', e.id],
57
+ ['type', e.type],
58
+ ['status', e.status + (e.archived && e.status !== 'archived' ? ' (archived)' : '')],
59
+ ['subject', e.subject],
60
+ ['post', `${e.post?.title} (${e.post?.id})`],
61
+ ['from', person(e.sender)],
62
+ ['to', `${person(e.recipient)} (${e.recipientRole})`],
63
+ ['contact', [c.name, c.email, c.phone, c.company].filter(Boolean).join(' · ')],
64
+ ...extra,
65
+ ['sender access', e.senderAccess ? `${e.senderAccess.partnerState}${e.senderAccess.supporter ? ', supporter' : ''}` : undefined],
66
+ ['can', Object.entries(e.permissions || {}).filter(([, v]) => v).map(([k]) => k).join(', ')],
67
+ ['attachments', (e.attachments || []).map((f) => `${f.name} (${f.id})`).join(', ')],
68
+ ['created', e.createdAt],
69
+ ]);
70
+ process.stdout.write('\n');
71
+ for (const m of messages) {
72
+ const who = m.kind === 'system' ? color.dim('— system') : color.bold(person(m.sender));
73
+ process.stdout.write(`${who} ${color.dim(relativeTime(m.createdAt) ?? '')}\n`);
74
+ if (m.body)
75
+ process.stdout.write(`${m.body}\n`);
76
+ for (const f of m.attachments || [])
77
+ process.stdout.write(color.dim(` 📎 ${f.name} (${f.id})\n`));
78
+ process.stdout.write('\n');
79
+ }
80
+ }
81
+ export function enquiryCommand(globals) {
82
+ const enq = new Command('enquiry')
83
+ .aliases(['enquiries', 'enq'])
84
+ .description('Enquiries about your posts: read, reply, accept / decline, grant partner access');
85
+ enq.command('list', { isDefault: true })
86
+ .description('Your inbox (received) or what you sent, newest activity first')
87
+ .option('--box <box>', 'received | sent', 'received')
88
+ .option('--post <postId>', 'only this post')
89
+ .option('--status <status>', STATUSES.join(' | '))
90
+ .option('--type <type>', TYPES.join(' | '))
91
+ .option('--unread', 'only unread')
92
+ .option('--archived', 'show the archive instead of the inbox')
93
+ .option('--page <n>', 'page number', '1')
94
+ .option('--limit <n>', 'page size', '20')
95
+ .action(async (opts) => {
96
+ const client = backreelClient(globals);
97
+ const result = await client.get(`${ENQ}/search`, {
98
+ box: oneOf(opts.box, ['received', 'sent'], '--box'),
99
+ post: opts.post,
100
+ status: opts.status ? oneOf(opts.status, STATUSES, '--status') : undefined,
101
+ type: opts.type ? oneOf(opts.type, TYPES, '--type') : undefined,
102
+ unread: opts.unread ? 'true' : undefined,
103
+ archived: opts.archived ? 'true' : undefined,
104
+ page: Number(opts.page),
105
+ limit: Number(opts.limit),
106
+ });
107
+ render(result, (r) => {
108
+ printSummaryTable(r?.data || []);
109
+ const p = r?.pagination;
110
+ if (p)
111
+ info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
112
+ });
113
+ });
114
+ enq.command('unread')
115
+ .description('Unread counts')
116
+ .action(async () => {
117
+ const client = backreelClient(globals);
118
+ const counts = await client.get(`${ENQ}/unread-count`);
119
+ render(counts, (c) => detail([['received', c.received], ['sent', c.sent], ['total', c.total]]));
120
+ });
121
+ enq.command('get <id>')
122
+ .description('Show an enquiry and its whole thread (marks it read)')
123
+ .action(async (id) => {
124
+ const client = backreelClient(globals);
125
+ const e = await client.getFresh(`${ENQ}/list/${id}`);
126
+ const messages = await client.getFresh(`${ENQ}/list/${id}/messages`);
127
+ render({ ...e, messages }, () => printEnquiry(e, messages || []));
128
+ });
129
+ enq.command('reply <id> <message>')
130
+ .description(`Reply in the thread (≤${MESSAGE_MAX} chars; @file / - supported). The other side gets an in-app notice and, at most every 30 min, an email`)
131
+ .option('-a, --attach <file...>', `attach files (≤${MAX_ATTACHMENTS}; documents / images ≤20 MB)`)
132
+ .action(async (id, message, opts) => {
133
+ const client = backreelClient(globals);
134
+ const body = (await readValue(message)).trim();
135
+ if (!body && !opts.attach?.length)
136
+ throw new Error('Empty reply');
137
+ if (body.length > MESSAGE_MAX)
138
+ throw new Error(`Reply is ${body.length} chars; the limit is ${MESSAGE_MAX}`);
139
+ const attachments = await uploadAttachments(client, opts.attach);
140
+ const sent = await client.post(`${ENQ}/list/${id}/messages`, { body, attachments: attachments.length ? attachments : undefined });
141
+ render(sent, () => success('Reply sent'));
142
+ });
143
+ enq.command('status <id> <status>')
144
+ .description(`Move an enquiry: ${SETTABLE.join(' | ')} (accepting an access_request grants partner access)`)
145
+ .action(async (id, status) => {
146
+ const client = backreelClient(globals);
147
+ const updated = await client.post(`${ENQ}/list/${id}/status`, { status: oneOf(status, SETTABLE, 'status') });
148
+ render(updated, (e) => success(`Enquiry ${id} is now ${e.status}`));
149
+ });
150
+ enq.command('grant <id>')
151
+ .description('Give the sender partner access to the post (creator only; also accepts an open enquiry)')
152
+ .action(async (id) => {
153
+ const client = backreelClient(globals);
154
+ const updated = await client.post(`${ENQ}/list/${id}/grant`);
155
+ render(updated, (e) => success(`Granted partner access to ${e.sender?.name || 'the sender'} — status ${e.status}`));
156
+ });
157
+ enq.command('download <fileId>')
158
+ .description('Download an attachment (private file; the signed link lasts 60 s)')
159
+ .option('-o, --out <path>', 'output path (default: the original file name)')
160
+ .action(async (fileId, opts) => {
161
+ const client = backreelClient(globals);
162
+ const link = await client.get(`/backreel/file/${fileId}/download`);
163
+ const res = await fetch(link.url);
164
+ if (!res.ok)
165
+ throw new Error(`Download failed: HTTP ${res.status}`);
166
+ const out = opts.out || basename(link.name || fileId);
167
+ await writeFile(out, Buffer.from(await res.arrayBuffer()));
168
+ render({ ...link, path: out }, () => success(`Saved ${out}`));
169
+ });
170
+ return enq;
171
+ }
@@ -0,0 +1,243 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { ApiError } from '../core/client.js';
5
+ import { POSTS, TIERS, backreelClient, episodeIndex, idOf, loadPost, oneOf } from '../core/backreel.js';
6
+ import { color, info, render, success, table, truncate, warn } from '../core/output.js';
7
+ import { confirm } from '../core/prompt.js';
8
+ import { uploadToVod } from '../core/vod.js';
9
+ const MAX_EPISODES = 30;
10
+ const VIDEO_EXTS = ['.mp4', '.mov', '.m4v', '.webm'];
11
+ /** /assets/v2/upload cap for video — only the --legacy path goes through it. */
12
+ const ASSET_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
13
+ /** The fields the update DTO takes back; vodStatus / poster / duration are server-owned. */
14
+ function toDto(e, order) {
15
+ const base = { title: e.title, order, tier: e.tier || 'public' };
16
+ return e.vodVid ? { ...base, vodVid: e.vodVid } : { ...base, video: idOf(e.video) };
17
+ }
18
+ async function loadEpisodes(client, postId) {
19
+ const post = await loadPost(client, postId);
20
+ if (post.kind !== 'showcase') {
21
+ throw new Error(`"${post.title}" is a ${post.kind} post — only showcase posts have episodes`);
22
+ }
23
+ const episodes = [...(post.episodes || [])]
24
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
25
+ .map((e, i) => toDto(e, i));
26
+ return { post, episodes };
27
+ }
28
+ /** The server re-sorts by `order`, re-indexes, and re-checks every vodVid with VOD. */
29
+ function saveEpisodes(client, postId, episodes) {
30
+ if (episodes.length > MAX_EPISODES)
31
+ throw new Error(`A post can have at most ${MAX_EPISODES} episodes`);
32
+ return client.put(`${POSTS}/list/${postId}`, { episodes: episodes.map((e, i) => ({ ...e, order: i })) });
33
+ }
34
+ /**
35
+ * Turn `[file] / --vid / --asset` into an episode source. A local file goes to
36
+ * Backreel's VOD space by default (signed, expiring play URLs); `--legacy`
37
+ * uploads it as a plain WJI asset with a permanent public URL instead.
38
+ */
39
+ async function resolveSource(client, opts, postId, file) {
40
+ const given = [file, opts.vid, opts.asset].filter(Boolean).length;
41
+ if (given > 1)
42
+ throw new Error('Give one video source: a file, --vid or --asset');
43
+ if (opts.vid)
44
+ return { vodVid: opts.vid };
45
+ if (opts.asset)
46
+ return { video: opts.asset };
47
+ if (!file)
48
+ return null;
49
+ const ext = extname(file).toLowerCase();
50
+ if (!VIDEO_EXTS.includes(ext))
51
+ throw new Error(`${basename(file)}: episodes must be ${VIDEO_EXTS.join(' / ')}`);
52
+ const size = (await stat(file)).size;
53
+ if (opts.legacy) {
54
+ if (size > ASSET_VIDEO_MAX_BYTES)
55
+ throw new Error(`${basename(file)} is over 200 MB — drop --legacy to upload it to VOD`);
56
+ info(color.dim(`Uploading ${basename(file)} as a WJI asset…`));
57
+ const asset = await client.upload('/assets/v2/upload', file, {});
58
+ return { video: asset.id };
59
+ }
60
+ const token = await client.post('/backreel/vod/upload-token');
61
+ const vid = await uploadToVod(token, file, (step) => info(color.dim(`VOD: ${step}…`)));
62
+ // claim checks the space, tags it with the post, publishes the media and starts transcoding.
63
+ const claimed = await client.post('/backreel/vod/claim', { vid, postId, title: opts.title?.slice(0, 128) });
64
+ info(color.dim(`VOD ${vid}: ${claimed?.status ?? 'claimed'}`));
65
+ return { vodVid: vid };
66
+ }
67
+ function printEpisodes(post) {
68
+ const rows = [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
69
+ table(rows, [
70
+ { header: '#', value: (e) => (e.order ?? 0) + 1 },
71
+ { header: 'title', value: (e) => truncate(e.title, 32) },
72
+ { header: 'tier', value: (e) => e.tier },
73
+ { header: 'source', value: (e) => (e.vodVid ? `vod ${e.vodVid}` : e.video ? `asset ${idOf(e.video)}` : '-') },
74
+ { header: 'status', value: (e) => (e.vodVid ? e.vodStatus || '?' : e.video ? 'ready' : '') },
75
+ { header: 'duration', value: (e) => (e.durationSec ? `${Math.round(e.durationSec)}s` : '') },
76
+ ]);
77
+ }
78
+ const sourceOptions = (cmd) => cmd
79
+ .option('--vid <vid>', 'a Vid already in the Backreel VOD space')
80
+ .option('--asset <assetId>', 'an uploaded WJI video asset (legacy path, public URL)')
81
+ .option('--legacy', 'upload the file as a WJI asset (≤200 MB) instead of VOD');
82
+ export function episodeCommand(globals) {
83
+ const ep = new Command('episode')
84
+ .aliases(['episodes', 'ep'])
85
+ .description('Episodes of a showcase post: upload, reorder, gate by tier, check transcoding');
86
+ ep.command('list <postId>', { isDefault: true })
87
+ .description('List the episodes with their tier, source and VOD status')
88
+ .action(async (postId) => {
89
+ const client = backreelClient(globals);
90
+ const post = await loadPost(client, postId);
91
+ render(post.episodes || [], () => {
92
+ if (!(post.episodes || []).length)
93
+ return info(`"${post.title}" has no episodes yet`);
94
+ printEpisodes(post);
95
+ });
96
+ });
97
+ sourceOptions(ep.command('add <postId> [file]')
98
+ .description('Add an episode: upload a local video to VOD, or use --vid / --asset')
99
+ .requiredOption('-t, --title <title>', 'episode title')
100
+ .option('--tier <tier>', `who can watch: ${TIERS.join(' | ')}`, 'public')
101
+ .option('--at <n>', 'insert as episode n (default: last)')).action(async (postId, file, opts) => {
102
+ const client = backreelClient(globals);
103
+ const tier = oneOf(opts.tier, TIERS, '--tier');
104
+ const { post, episodes } = await loadEpisodes(client, postId);
105
+ if (episodes.length >= MAX_EPISODES)
106
+ throw new Error(`"${post.title}" already has ${MAX_EPISODES} episodes`);
107
+ const at = opts.at === undefined ? episodes.length : episodeIndex(opts.at, episodes.length + 1);
108
+ const source = await resolveSource(client, opts, postId, file);
109
+ if (!source)
110
+ throw new Error('Give the video: a file, --vid <vid> or --asset <assetId>');
111
+ episodes.splice(at, 0, { title: opts.title, order: at, tier, ...source });
112
+ const updated = await saveEpisodes(client, postId, episodes);
113
+ render(updated.episodes, () => {
114
+ success(`Added episode ${at + 1} "${opts.title}" to "${updated.title}"`);
115
+ printEpisodes(updated);
116
+ if ('vodVid' in source)
117
+ info(color.dim('VOD transcoding takes a few minutes — `wji backreel episode status` to check'));
118
+ });
119
+ });
120
+ sourceOptions(ep.command('set <postId> <n> [file]')
121
+ .description('Change episode n: title, tier, or replace its video')
122
+ .option('-t, --title <title>', 'new title')
123
+ .option('--tier <tier>', TIERS.join(' | '))).action(async (postId, n, file, opts) => {
124
+ const client = backreelClient(globals);
125
+ const { episodes } = await loadEpisodes(client, postId);
126
+ const i = episodeIndex(n, episodes.length);
127
+ const source = await resolveSource(client, { ...opts, title: opts.title || episodes[i].title }, postId, file);
128
+ if (!opts.title && !opts.tier && !source)
129
+ throw new Error('Nothing to change — pass --title, --tier or a new video');
130
+ const next = { title: opts.title || episodes[i].title, order: i, tier: opts.tier ? oneOf(opts.tier, TIERS, '--tier') : episodes[i].tier };
131
+ episodes[i] = source ? { ...next, ...source } : { ...episodes[i], ...next };
132
+ const updated = await saveEpisodes(client, postId, episodes);
133
+ render(updated.episodes, () => {
134
+ success(`Updated episode ${i + 1}`);
135
+ printEpisodes(updated);
136
+ });
137
+ });
138
+ ep.command('rm <postId> <n...>')
139
+ .description('Remove episodes (later ones move up)')
140
+ .option('-y, --yes', 'skip confirmation')
141
+ .action(async (postId, ns, opts) => {
142
+ const client = backreelClient(globals);
143
+ const { episodes } = await loadEpisodes(client, postId);
144
+ const drop = new Set(ns.map((n) => episodeIndex(n, episodes.length)));
145
+ const names = [...drop].sort((a, b) => a - b).map((i) => `${i + 1}. ${episodes[i].title}`).join(', ');
146
+ if (!(await confirm(`Remove ${names}?`, opts.yes)))
147
+ return info('Aborted');
148
+ const updated = await saveEpisodes(client, postId, episodes.filter((_, i) => !drop.has(i)));
149
+ render(updated.episodes, () => {
150
+ success(`Removed ${drop.size} episode(s)`);
151
+ printEpisodes(updated);
152
+ });
153
+ });
154
+ ep.command('move <postId> <from> <to>')
155
+ .description('Move episode <from> to position <to>')
156
+ .action(async (postId, from, to) => {
157
+ const client = backreelClient(globals);
158
+ const { episodes } = await loadEpisodes(client, postId);
159
+ const a = episodeIndex(from, episodes.length);
160
+ const b = episodeIndex(to, episodes.length);
161
+ const [moved] = episodes.splice(a, 1);
162
+ episodes.splice(b, 0, moved);
163
+ const updated = await saveEpisodes(client, postId, episodes);
164
+ render(updated.episodes, () => {
165
+ success(`Moved "${moved.title}" to episode ${b + 1}`);
166
+ printEpisodes(updated);
167
+ });
168
+ });
169
+ ep.command('tiers <postId>')
170
+ .description('Tier ladder: public up to episode N, members up to M, the rest at --rest')
171
+ .option('--public-to <n>', 'episodes 1..n are public', '0')
172
+ .option('--member-to <n>', 'then up to episode n for signed-in members')
173
+ .option('--rest <tier>', `everything after: ${TIERS.join(' | ')}`, 'supporter')
174
+ .action(async (postId, opts) => {
175
+ const client = backreelClient(globals);
176
+ const { episodes } = await loadEpisodes(client, postId);
177
+ const pub = Number(opts.publicTo);
178
+ const mem = opts.memberTo === undefined ? pub : Number(opts.memberTo);
179
+ if (!Number.isInteger(pub) || pub < 0 || !Number.isInteger(mem) || mem < pub) {
180
+ throw new Error('--public-to and --member-to are episode numbers, with --member-to ≥ --public-to');
181
+ }
182
+ const rest = oneOf(opts.rest, TIERS, '--rest');
183
+ episodes.forEach((e, i) => {
184
+ e.tier = i < pub ? 'public' : i < mem ? 'member' : rest;
185
+ });
186
+ const updated = await saveEpisodes(client, postId, episodes);
187
+ render(updated.episodes, () => {
188
+ success('Episode tiers updated');
189
+ printEpisodes(updated);
190
+ });
191
+ });
192
+ ep.command('status <postId>')
193
+ .description('Refresh VOD transcoding state (it only updates when someone asks to play)')
194
+ .action(async (postId) => {
195
+ const client = backreelClient(globals);
196
+ const post = await loadPost(client, postId);
197
+ const results = [];
198
+ for (const e of [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
199
+ const row = { n: (e.order ?? 0) + 1, title: e.title, source: e.vodVid ? 'vod' : 'asset', status: 'ready', duration: e.durationSec };
200
+ if (e.vodVid) {
201
+ try {
202
+ const play = await client.get(`${POSTS}/list/${postId}/episodes/${e.order}/play`);
203
+ row.duration = play.durationSec ?? row.duration;
204
+ }
205
+ catch (err) {
206
+ if (err instanceof ApiError && err.status === 409)
207
+ row.status = err.body?.status || 'processing';
208
+ else
209
+ row.status = `error: ${err.message}`;
210
+ }
211
+ }
212
+ results.push(row);
213
+ }
214
+ render(results, (rows) => {
215
+ if (!rows.length)
216
+ return info(`"${post.title}" has no episodes yet`);
217
+ table(rows, [
218
+ { header: '#', value: (r) => r.n },
219
+ { header: 'title', value: (r) => truncate(r.title, 32) },
220
+ { header: 'source', value: (r) => r.source },
221
+ { header: 'status', value: (r) => (r.status === 'ready' ? color.green('ready') : color.yellow(r.status)) },
222
+ { header: 'duration', value: (r) => (r.duration ? `${Math.round(r.duration)}s` : '') },
223
+ ]);
224
+ if (rows.some((r) => r.status === 'failed'))
225
+ warn('A failed transcode needs a new upload: `episode set <post> <n> <file>`');
226
+ });
227
+ });
228
+ ep.command('play <postId> <n>')
229
+ .description('Print a short-lived play URL for episode n (as the owner)')
230
+ .action(async (postId, n) => {
231
+ const client = backreelClient(globals);
232
+ const post = await loadPost(client, postId);
233
+ const i = episodeIndex(n, (post.episodes || []).length);
234
+ const order = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i].order ?? i;
235
+ const play = await client.get(`${POSTS}/list/${postId}/episodes/${order}/play`);
236
+ render(play, (p) => {
237
+ process.stdout.write(`${p.url}\n`);
238
+ if (p.expiresAt)
239
+ info(color.dim(`expires ${p.expiresAt}`));
240
+ });
241
+ });
242
+ return ep;
243
+ }