@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.
- package/README.md +19 -2
- package/dist/commands/agent.js +28 -8
- package/dist/commands/backreel-enquiry.js +171 -0
- package/dist/commands/backreel-episode.js +243 -0
- package/dist/commands/backreel-fund.js +299 -0
- package/dist/commands/backreel.js +201 -18
- package/dist/commands/gen.js +5 -1
- package/dist/commands/npc-album.js +201 -0
- package/dist/commands/npc-live.js +316 -0
- package/dist/commands/npc.js +26 -2
- package/dist/commands/workspace.js +74 -1
- package/dist/core/backreel.js +23 -0
- package/dist/core/client.js +18 -1
- package/dist/core/image.js +106 -0
- package/dist/core/npc.js +1 -1
- package/dist/core/vod.js +149 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/skill/backreel/SKILL.md +171 -0
- package/skill/backreel/reference/crowdfunding.md +82 -0
- package/skill/backreel/reference/enquiries.md +80 -0
- package/skill/backreel/reference/episodes.md +104 -0
- package/skill/backreel/reference/images.md +76 -0
- package/skill/npc-character/SKILL.md +111 -0
- package/skill/npc-character/examples/nagi.character.md +117 -0
- package/skill/npc-character/examples/nagi.stage.md +34 -0
- package/skill/npc-character/examples/yuzu.character.md +110 -0
- package/skill/npc-character/examples/yuzu.stage.md +30 -0
- package/skill/npc-character/reference/character-craft.md +153 -0
- package/skill/npc-character/reference/document-format.md +151 -0
- package/skill/npc-character/reference/interview.md +44 -0
- package/skill/npc-character/reference/life.md +92 -0
- package/skill/npc-character/reference/runtime.md +95 -0
- package/skill/npc-character/reference/stage-craft.md +109 -0
- package/skill/npc-character/reference/visual-kit.md +81 -0
- package/skill/npc-character/reference/voice.md +101 -0
- package/skill/wji/SKILL.md +10 -1
- 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
|
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
|
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('--
|
|
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('--
|
|
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
|
+
}
|