@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
@@ -0,0 +1,299 @@
1
+ import { Command } from 'commander';
2
+ import { POSTS, backreelClient, idOf, loadPost, oneOf } from '../core/backreel.js';
3
+ import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
4
+ import { confirm } from '../core/prompt.js';
5
+ const CURRENCIES = ['hkd', 'usd', 'twd', 'sgd', 'eur', 'gbp'];
6
+ const MAX_TIERS = 10;
7
+ const num = (value, flag, min = 0) => {
8
+ const n = Number(value);
9
+ if (!Number.isFinite(n) || n < min)
10
+ throw new Error(`${flag} must be a number ≥ ${min}`);
11
+ return n;
12
+ };
13
+ /** The server rejects a tier below the minimum donation (and below 1) with a bare 500. */
14
+ function tierAmount(value, minAmount) {
15
+ const floor = Math.max(1, minAmount || 0);
16
+ const n = num(value, '--amount');
17
+ if (n < floor)
18
+ throw new Error(`--amount must be at least ${floor} (the minimum donation)`);
19
+ return Math.round(n);
20
+ }
21
+ function itemIndex(n, count, what) {
22
+ const i = Number(n);
23
+ if (!Number.isInteger(i) || i < 1 || i > count)
24
+ throw new Error(`${what} ${n} does not exist — there are ${count}`);
25
+ return i - 1;
26
+ }
27
+ /** campaign as the update DTO takes it: the owner view plus avatar ids, minus derived totals. */
28
+ function campaignDto(c) {
29
+ return {
30
+ goalAmount: c?.goalAmount ?? 0,
31
+ offlineRaisedAmount: c?.offlineRaisedAmount ?? 0,
32
+ deadline: c?.deadline ?? null,
33
+ offlineSupporters: (c?.offlineSupporters || []).map((s, i) => ({
34
+ name: s.name,
35
+ amount: s.amount ?? 0,
36
+ sort: i,
37
+ avatar: idOf(s.avatar) ?? null,
38
+ avatarUrl: s.avatarUrl ?? null,
39
+ })),
40
+ };
41
+ }
42
+ /** donation config as the update DTO takes it (the read view adds `remaining` per tier). */
43
+ function donationDto(d) {
44
+ return {
45
+ enabled: !!d?.enabled,
46
+ currency: d?.currency || 'hkd',
47
+ minAmount: d?.minAmount ?? 0,
48
+ tiers: (d?.tiers || []).map((t) => ({ id: t.id, title: t.title, amount: t.amount, perks: t.perks || '', limit: t.limit ?? null })),
49
+ };
50
+ }
51
+ function requireCampaign(post) {
52
+ if (post.kind !== 'crowdfunding') {
53
+ throw new Error(`"${post.title}" is a ${post.kind} post — campaign fields need --kind crowdfunding (which drops its episodes)`);
54
+ }
55
+ }
56
+ const put = (client, id, body) => client.put(`${POSTS}/list/${id}`, body);
57
+ function printFund(post) {
58
+ const c = post.campaign;
59
+ const d = post.donation || {};
60
+ // The campaign view reports the stats currency, which reads HKD until the first donation;
61
+ // the configured donation currency is the real one (it cannot change after that).
62
+ const cur = (d.currency || c?.currency || 'hkd').toUpperCase();
63
+ if (c) {
64
+ const pct = c.goalAmount ? ` (${Math.round(((c.raisedAmount || 0) / c.goalAmount) * 100)}%)` : '';
65
+ detail([
66
+ ['goal', `${c.goalAmount} ${cur}`],
67
+ ['raised', `${c.raisedAmount} ${cur}${pct} = offline ${c.offlineRaisedAmount ?? 0} + online ${(c.raisedAmount || 0) - (c.offlineRaisedAmount || 0)}`],
68
+ ['supporters', c.supporterCount],
69
+ ['deadline', c.deadline || color.dim('none')],
70
+ ]);
71
+ process.stdout.write('\n');
72
+ }
73
+ else {
74
+ info(color.dim(`${post.kind} post — no campaign (goal / offline supporters are crowdfunding-only); donations still work`));
75
+ }
76
+ detail([
77
+ ['donations', d.enabled ? color.green('on') : 'off'],
78
+ ['currency', (d.currency || 'hkd').toUpperCase()],
79
+ ['minimum', d.minAmount ?? 0],
80
+ ]);
81
+ if ((d.tiers || []).length) {
82
+ process.stdout.write('\n');
83
+ table(d.tiers.map((t, i) => ({ ...t, n: i + 1 })), [
84
+ { header: '#', value: (t) => t.n },
85
+ { header: 'tier', value: (t) => truncate(t.title, 24) },
86
+ { header: 'amount', value: (t) => t.amount },
87
+ { header: 'left', value: (t) => (t.limit ? `${t.remaining}/${t.limit}` : '∞') },
88
+ { header: 'perks', value: (t) => truncate(t.perks, 40) },
89
+ ]);
90
+ }
91
+ }
92
+ function printSupporters(post) {
93
+ table((post.campaign?.offlineSupporters || []).map((s, i) => ({ ...s, n: i + 1 })), [
94
+ { header: '#', value: (s) => s.n },
95
+ { header: 'name', value: (s) => s.name },
96
+ { header: 'amount', value: (s) => s.amount },
97
+ { header: 'avatar', value: (s) => idOf(s.avatar) || '' },
98
+ ]);
99
+ }
100
+ export function fundCommand(globals) {
101
+ const fund = new Command('fund')
102
+ .aliases(['funding', 'campaign'])
103
+ .description('Crowdfunding campaign, donation settings and tiers, offline supporters, donation records');
104
+ fund.command('show <postId>', { isDefault: true })
105
+ .description('Campaign progress, donation settings and tiers')
106
+ .action(async (postId) => {
107
+ const client = backreelClient(globals);
108
+ const post = await loadPost(client, postId);
109
+ render({ campaign: post.campaign, donation: post.donation, kind: post.kind }, () => printFund(post));
110
+ });
111
+ fund.command('set <postId>')
112
+ .description('Campaign goal / offline total / deadline and donation switches; only what you pass changes')
113
+ .option('--goal <amount>', 'campaign goal (≥1 to publish a crowdfunding post)')
114
+ .option('--offline-raised <amount>', 'money raised outside Backreel, added to the progress bar')
115
+ .option('--deadline <date>', 'ISO date (e.g. 2026-12-31); "none" clears — donations stop after it')
116
+ .option('--donations <on|off>', 'accept online donations (Stripe)')
117
+ .option('--currency <code>', `${CURRENCIES.join(' | ')} — locked once a donation exists`)
118
+ .option('--min <amount>', 'minimum donation')
119
+ .action(async (postId, opts) => {
120
+ const client = backreelClient(globals);
121
+ const post = await loadPost(client, postId);
122
+ const body = {};
123
+ if (opts.goal !== undefined || opts.offlineRaised !== undefined || opts.deadline !== undefined) {
124
+ requireCampaign(post);
125
+ const campaign = campaignDto(post.campaign);
126
+ if (opts.goal !== undefined)
127
+ campaign.goalAmount = num(opts.goal, '--goal');
128
+ if (opts.offlineRaised !== undefined)
129
+ campaign.offlineRaisedAmount = num(opts.offlineRaised, '--offline-raised');
130
+ if (opts.deadline !== undefined) {
131
+ if (['none', ''].includes(String(opts.deadline).toLowerCase()))
132
+ campaign.deadline = null;
133
+ else {
134
+ const date = new Date(opts.deadline);
135
+ if (Number.isNaN(date.getTime()))
136
+ throw new Error('--deadline must be a date like 2026-12-31');
137
+ campaign.deadline = date.toISOString();
138
+ }
139
+ }
140
+ body.campaign = campaign;
141
+ }
142
+ if (opts.donations !== undefined || opts.currency !== undefined || opts.min !== undefined) {
143
+ const donation = donationDto(post.donation);
144
+ if (opts.donations !== undefined)
145
+ donation.enabled = oneOf(String(opts.donations).toLowerCase(), ['on', 'off'], '--donations') === 'on';
146
+ if (opts.currency !== undefined)
147
+ donation.currency = oneOf(String(opts.currency).toLowerCase(), CURRENCIES, '--currency');
148
+ if (opts.min !== undefined)
149
+ donation.minAmount = Math.round(num(opts.min, '--min'));
150
+ body.donation = donation;
151
+ }
152
+ if (!Object.keys(body).length)
153
+ throw new Error('Nothing to change — see `wji backreel fund set --help`');
154
+ const updated = await put(client, postId, body);
155
+ render(updated, (p) => {
156
+ success(`Updated ${Object.keys(body).join(' and ')} of "${p.title}"`);
157
+ printFund(p);
158
+ });
159
+ });
160
+ // ── donation tiers ─────────────────────────────────────────────────────────
161
+ const tier = fund.command('tier').description(`Donation tiers (≤${MAX_TIERS}): a title, an amount, perks, an optional cap`);
162
+ tier.command('add <postId>')
163
+ .requiredOption('--title <text>', 'tier name (≤60)')
164
+ .requiredOption('--amount <n>', 'price, ≥ the minimum donation')
165
+ .option('--perks <text>', 'what supporters get (≤1000)')
166
+ .option('--limit <n>', 'number of slots (default unlimited)')
167
+ .action(async (postId, opts) => {
168
+ const client = backreelClient(globals);
169
+ const post = await loadPost(client, postId);
170
+ const donation = donationDto(post.donation);
171
+ if (donation.tiers.length >= MAX_TIERS)
172
+ throw new Error(`Already ${MAX_TIERS} tiers`);
173
+ donation.tiers.push({ id: undefined, title: opts.title, amount: tierAmount(opts.amount, donation.minAmount), perks: opts.perks || '', limit: opts.limit ? num(opts.limit, '--limit', 1) : null });
174
+ const updated = await put(client, postId, { donation });
175
+ render(updated.donation, () => {
176
+ success(`Added tier "${opts.title}"${donation.enabled ? '' : color.dim(' — donations are off; `fund set --donations on`')}`);
177
+ printFund(updated);
178
+ });
179
+ });
180
+ tier.command('set <postId> <n>')
181
+ .option('--title <text>', 'tier name')
182
+ .option('--amount <n>', 'price')
183
+ .option('--perks <text>', 'perks')
184
+ .option('--limit <n>', 'slots; 0 = unlimited')
185
+ .action(async (postId, n, opts) => {
186
+ const client = backreelClient(globals);
187
+ const post = await loadPost(client, postId);
188
+ const donation = donationDto(post.donation);
189
+ const t = donation.tiers[itemIndex(n, donation.tiers.length, 'Tier')];
190
+ if (opts.title !== undefined)
191
+ t.title = opts.title;
192
+ if (opts.amount !== undefined)
193
+ t.amount = tierAmount(opts.amount, donation.minAmount);
194
+ if (opts.perks !== undefined)
195
+ t.perks = opts.perks;
196
+ if (opts.limit !== undefined)
197
+ t.limit = Number(opts.limit) > 0 ? num(opts.limit, '--limit', 1) : null;
198
+ const updated = await put(client, postId, { donation });
199
+ render(updated.donation, () => {
200
+ success(`Updated tier ${n}`);
201
+ printFund(updated);
202
+ });
203
+ });
204
+ tier.command('rm <postId> <n...>')
205
+ .option('-y, --yes', 'skip confirmation')
206
+ .action(async (postId, ns, opts) => {
207
+ const client = backreelClient(globals);
208
+ const post = await loadPost(client, postId);
209
+ const donation = donationDto(post.donation);
210
+ const drop = new Set(ns.map((n) => itemIndex(n, donation.tiers.length, 'Tier')));
211
+ const names = [...drop].map((i) => donation.tiers[i].title).join(', ');
212
+ if (!(await confirm(`Remove tier(s) ${names}? Past donations keep their snapshot.`, opts.yes)))
213
+ return info('Aborted');
214
+ donation.tiers = donation.tiers.filter((_, i) => !drop.has(i));
215
+ const updated = await put(client, postId, { donation });
216
+ render(updated.donation, () => {
217
+ success(`Removed ${drop.size} tier(s)`);
218
+ printFund(updated);
219
+ });
220
+ });
221
+ // ── offline supporters ─────────────────────────────────────────────────────
222
+ const sup = fund.command('supporter').aliases(['supporters']).description('Supporters who paid outside Backreel (crowdfunding posts)');
223
+ sup.command('list <postId>', { isDefault: true }).action(async (postId) => {
224
+ const client = backreelClient(globals);
225
+ const post = await loadPost(client, postId);
226
+ requireCampaign(post);
227
+ render(post.campaign?.offlineSupporters || [], () => printSupporters(post));
228
+ });
229
+ sup.command('add <postId>')
230
+ .requiredOption('--name <name>', 'display name')
231
+ .option('--amount <n>', 'amount (shown next to the name)', '0')
232
+ .option('--avatar <assetId>', 'square image asset (see `backreel image --slot photo`)')
233
+ .action(async (postId, opts) => {
234
+ const client = backreelClient(globals);
235
+ const post = await loadPost(client, postId);
236
+ requireCampaign(post);
237
+ const campaign = campaignDto(post.campaign);
238
+ campaign.offlineSupporters.push({ name: opts.name, amount: num(opts.amount, '--amount'), sort: campaign.offlineSupporters.length, avatar: opts.avatar ?? null, avatarUrl: null });
239
+ const updated = await put(client, postId, { campaign });
240
+ render(updated.campaign, () => {
241
+ success(`Added supporter ${opts.name}`);
242
+ info(color.dim('The supporter count rises; offline money only counts once you raise --offline-raised'));
243
+ printSupporters(updated);
244
+ });
245
+ });
246
+ sup.command('rm <postId> <n...>')
247
+ .option('-y, --yes', 'skip confirmation')
248
+ .action(async (postId, ns, opts) => {
249
+ const client = backreelClient(globals);
250
+ const post = await loadPost(client, postId);
251
+ requireCampaign(post);
252
+ const campaign = campaignDto(post.campaign);
253
+ const drop = new Set(ns.map((n) => itemIndex(n, campaign.offlineSupporters.length, 'Supporter')));
254
+ const names = [...drop].map((i) => campaign.offlineSupporters[i].name).join(', ');
255
+ if (!(await confirm(`Remove ${names}?`, opts.yes)))
256
+ return info('Aborted');
257
+ campaign.offlineSupporters = campaign.offlineSupporters.filter((_, i) => !drop.has(i));
258
+ const updated = await put(client, postId, { campaign });
259
+ render(updated.campaign, () => {
260
+ success(`Removed ${drop.size} supporter(s)`);
261
+ printSupporters(updated);
262
+ });
263
+ });
264
+ // ── donation records ───────────────────────────────────────────────────────
265
+ fund.command('donations <postId>')
266
+ .description('Online donations, balance and payouts (anonymous donors stay hidden)')
267
+ .option('--page <n>', 'page number', '1')
268
+ .option('--limit <n>', 'page size (≤200)', '50')
269
+ .action(async (postId, opts) => {
270
+ const client = backreelClient(globals);
271
+ const r = await client.get(`/backreel/donation/post/${postId}/manage`, { page: Number(opts.page), limit: Number(opts.limit) });
272
+ render(r, () => {
273
+ const s = r.summary || {};
274
+ const cur = (s.currency || '').toUpperCase();
275
+ detail([
276
+ ['raised', `${s.raised} ${cur}`],
277
+ ['paid out', `${s.paidOut} ${cur}`],
278
+ ['balance', `${s.balance} ${cur}`],
279
+ ['supporters', r.stats?.supporterCount ?? 0],
280
+ ['donations', r.stats?.donationCount ?? 0],
281
+ ]);
282
+ process.stdout.write('\n');
283
+ table(r.data || [], [
284
+ { header: 'when', value: (d) => relativeTime(d.paidAt || d.createdAt) },
285
+ { header: 'donor', value: (d) => (d.isAnonymous ? color.dim('anonymous') : truncate(d.donor?.name, 20)) },
286
+ { header: 'amount', value: (d) => `${d.amount} ${(d.currency || '').toUpperCase()}` },
287
+ { header: 'tier', value: (d) => truncate(d.tier?.title, 16) },
288
+ { header: 'status', value: (d) => d.status + (d.refundedAmount ? ` (-${d.refundedAmount})` : '') + (d.disputed ? color.red(' disputed') : '') },
289
+ { header: 'message', value: (d) => truncate(d.message, 30) },
290
+ ]);
291
+ const p = r.pagination;
292
+ if (p)
293
+ info(color.dim(`${p.total} donations · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
294
+ if ((r.payouts || []).length)
295
+ info(color.dim(`${r.payouts.length} payout(s) — payouts and refunds are handled by Backreel staff`));
296
+ });
297
+ });
298
+ return fund;
299
+ }
@@ -1,12 +1,21 @@
1
1
  import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
2
+ import { POSTS, backreelClient, idOf, oneOf } from '../core/backreel.js';
3
+ import { IMAGE_SLOTS, checkImage } from '../core/image.js';
4
+ import { episodeCommand } from './backreel-episode.js';
5
+ import { enquiryCommand } from './backreel-enquiry.js';
6
+ import { fundCommand } from './backreel-fund.js';
3
7
  import { confirm, readJson, readValue } from '../core/prompt.js';
4
8
  import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
5
9
  import { NPC_JOB_TYPES, npcLabel, parseJob, parsePositions, resolveNpc, today, updateJobs } from '../core/npc.js';
6
- const POSTS = '/backreel/post';
7
10
  const KINDS = ['showcase', 'crowdfunding'];
8
11
  const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
9
12
  const CREDIT_KINDS = ['creative', 'crew', 'cast'];
13
+ /** The web's LookBook slot limit (compose/store.ts MEDIA_SLOT_LIMIT); the API itself has no cap. */
14
+ const MEDIA_MAX = 9;
15
+ /** Backend LOGLINE_MAX (src/backreel/post/descriptionHtml.ts). */
16
+ const LOGLINE_MAX = 200;
17
+ /** BackreelVerificationKind — what `creators --role` filters on. */
18
+ const VERIFICATION_KINDS = ['investor', 'production_company', 'brand', 'distributor', 'sales_agent'];
10
19
  /** Credit fields the create / update DTO accepts; the rest of a read-back credit is dropped. */
11
20
  const CREDIT_FIELDS = ['kind', 'name', 'role', 'user', 'photo', 'avatarUrl', 'link', 'source', 'npcId', 'order'];
12
21
  export function backreelCommand(globals) {
@@ -20,7 +29,7 @@ export function backreelCommand(globals) {
20
29
  .option('--page <n>', 'page number', '1')
21
30
  .option('--limit <n>', 'page size', '20')
22
31
  .action(async (opts) => {
23
- const client = new WjiClient(globals());
32
+ const client = backreelClient(globals);
24
33
  const result = await client.get(`${POSTS}/${opts.all ? 'search' : 'search/my'}`, {
25
34
  keyword: opts.keyword,
26
35
  page: Number(opts.page),
@@ -44,12 +53,12 @@ export function backreelCommand(globals) {
44
53
  br.command('get <postId>')
45
54
  .description('Show a post and its credits')
46
55
  .action(async (id) => {
47
- const client = new WjiClient(globals());
56
+ const client = backreelClient(globals);
48
57
  const post = await client.getFresh(`${POSTS}/list/${id}`);
49
58
  render(post, () => printPost(post));
50
59
  });
51
60
  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());
61
+ const client = backreelClient(globals);
53
62
  // The create DTO validates kind and description although the entity has defaults.
54
63
  const body = { kind: 'showcase', description: '', ...(await postBody(opts)), title };
55
64
  const created = await client.post(`${POSTS}/create`, body);
@@ -58,7 +67,7 @@ export function backreelCommand(globals) {
58
67
  postOptions(br.command('set <postId>')
59
68
  .description('Update a post; only the fields you pass change')
60
69
  .option('--title <text>', 'title')).action(async (id, opts) => {
61
- const client = new WjiClient(globals());
70
+ const client = backreelClient(globals);
62
71
  const body = await postBody(opts);
63
72
  if (opts.title)
64
73
  body.title = opts.title;
@@ -71,7 +80,7 @@ export function backreelCommand(globals) {
71
80
  br.command(`${action} <postId>`)
72
81
  .description(action === 'publish' ? 'Publish a post' : 'Take a post offline')
73
82
  .action(async (id) => {
74
- const client = new WjiClient(globals());
83
+ const client = backreelClient(globals);
75
84
  const post = await client.post(`${POSTS}/list/${id}/${action}`);
76
85
  render(post, (p) => success(`"${p.title}" is now ${p.publishStatus}`));
77
86
  });
@@ -80,20 +89,173 @@ export function backreelCommand(globals) {
80
89
  .description('Delete a post')
81
90
  .option('-y, --yes', 'skip confirmation')
82
91
  .action(async (id, opts) => {
83
- const client = new WjiClient(globals());
92
+ const client = backreelClient(globals);
84
93
  const post = await client.getFresh(`${POSTS}/list/${id}`);
85
94
  if (!(await confirm(`Delete post "${post.title}" (${id})?`, opts.yes)))
86
95
  return info('Aborted');
87
96
  const result = await client.delete(`${POSTS}/list/${id}`);
88
97
  render(result, () => success(`Deleted "${post.title}"`));
89
98
  });
99
+ br.command('creators')
100
+ .description('Browse Backreel creators: editor picks, then ranked by recent likes / favorites')
101
+ .option('-k, --keyword <text>', 'match name or @handle')
102
+ .option('--role <kind>', `only creators verified as: ${VERIFICATION_KINDS.join(' | ')}`)
103
+ .option('--verified', 'only verified creators')
104
+ .option('--no-featured', 'skip the editor-picked creators')
105
+ .option('--page <n>', 'page number', '1')
106
+ .option('--limit <n>', 'page size (max 48)', '20')
107
+ .action(async (opts) => {
108
+ const client = backreelClient(globals);
109
+ if (opts.role)
110
+ oneOf(opts.role, VERIFICATION_KINDS, '--role');
111
+ const result = await client.get('/backreel/creator/list', {
112
+ keyword: opts.keyword,
113
+ role: opts.role,
114
+ verifiedOnly: opts.verified ? 'true' : undefined,
115
+ withFeatured: opts.featured ? undefined : 'false',
116
+ page: Number(opts.page),
117
+ limit: Number(opts.limit),
118
+ });
119
+ render(result, (r) => {
120
+ const columns = [
121
+ { header: 'id', value: (c) => c.id },
122
+ { header: 'name', value: (c) => truncate(c.name, 24) },
123
+ { header: 'handle', value: (c) => (c.handle ? `@${c.handle}` : '') },
124
+ { header: 'reels', value: (c) => c.reelCount },
125
+ { header: 'followers', value: (c) => c.followerCount },
126
+ { header: 'likes', value: (c) => c.likeCount },
127
+ { header: 'verified', value: (c) => (c.verifiedKinds || []).join(', ') },
128
+ ];
129
+ if (r?.featured?.length) {
130
+ info(color.bold('Featured'));
131
+ table(r.featured, [...columns, { header: 'note', value: (c) => truncate(c.featuredNote, 30) }]);
132
+ process.stdout.write('\n');
133
+ info(color.bold('Hot'));
134
+ }
135
+ table(r?.data || [], columns);
136
+ const p = r?.pagination;
137
+ if (p) {
138
+ const more = p.page * p.limit < p.total ? ` · more: --page ${p.page + 1}` : '';
139
+ info(color.dim(`${p.total} ranked · page ${p.page}${more}`));
140
+ }
141
+ });
142
+ });
143
+ br.command('image <file...>')
144
+ .description('Check image size / ratio for a slot, upload it, and optionally put it on a post')
145
+ .requiredOption('--slot <slot>', `${Object.keys(IMAGE_SLOTS).join(' | ')} (see \`backreel image-specs\`)`)
146
+ .option('--post <postId>', 'cover / portrait: set it on the post; media: append to the LookBook')
147
+ .option('--check', 'only check, do not upload')
148
+ .action(async (files, opts) => {
149
+ const slot = oneOf(opts.slot, Object.keys(IMAGE_SLOTS), '--slot');
150
+ if (files.length > 1 && slot !== 'media')
151
+ throw new Error(`--slot ${slot} takes one image`);
152
+ const checks = await Promise.all(files.map((f) => checkImage(f, slot)));
153
+ for (const c of checks) {
154
+ const dims = c.dims ? `${c.dims.width}×${c.dims.height} ${c.dims.format}` : '?';
155
+ info(`${c.file}: ${dims}, ${(c.bytes / 1048576).toFixed(1)} MB → ${slot} (${c.spec.label}, ideal ${c.spec.ideal})`);
156
+ for (const e of c.errors)
157
+ info(color.red(` ✗ ${e}`));
158
+ for (const w of c.warnings)
159
+ info(color.yellow(` ! ${w}`));
160
+ if (!c.errors.length && !c.warnings.length)
161
+ info(color.green(' ✓ fits'));
162
+ }
163
+ if (checks.some((c) => c.errors.length))
164
+ throw new Error('Fix the errors above first');
165
+ if (opts.check)
166
+ return render(checks, () => { });
167
+ const client = backreelClient(globals);
168
+ const ids = [];
169
+ for (const c of checks) {
170
+ const asset = await client.upload('/assets/v2/upload', c.file, {
171
+ width: c.dims ? String(c.dims.width) : undefined,
172
+ height: c.dims ? String(c.dims.height) : undefined,
173
+ });
174
+ ids.push(asset.id);
175
+ success(`${c.file} → ${asset.id}`);
176
+ }
177
+ if (!opts.post)
178
+ return render({ assets: ids }, () => { });
179
+ const field = { cover: 'cover', portrait: 'coverPortrait', media: 'assets' }[slot];
180
+ if (!field)
181
+ throw new Error('--post works with cover, portrait and media; use the photo id with `credit add --photo` or `fund supporter add --avatar`');
182
+ let value = ids[0];
183
+ if (field === 'assets') {
184
+ const post = await client.getFresh(`${POSTS}/list/${opts.post}`);
185
+ value = [...(post.assets || []).map(idOf), ...ids];
186
+ if (value.length > MEDIA_MAX)
187
+ throw new Error(`The LookBook would have ${value.length} items; the limit is ${MEDIA_MAX}`);
188
+ }
189
+ const updated = await client.put(`${POSTS}/list/${opts.post}`, { [field]: value });
190
+ render(updated, (p) => success(`Set ${field} on "${p.title}"${field === 'assets' ? ` (${value.length} items)` : ''}`));
191
+ });
192
+ br.command('image-specs')
193
+ .description('Recommended image sizes for every Backreel slot')
194
+ .action(() => {
195
+ render(IMAGE_SLOTS, () => {
196
+ table(Object.entries(IMAGE_SLOTS).map(([slot, s]) => ({ slot, ...s })), [
197
+ { header: 'slot', value: (r) => r.slot },
198
+ { header: 'ratio', value: (r) => r.label },
199
+ { header: 'ideal', value: (r) => r.ideal },
200
+ { header: 'min', value: (r) => r.min.join('×') },
201
+ { header: 'note', value: (r) => r.note },
202
+ ]);
203
+ info(color.dim('JPEG / PNG / WebP, ≤25 MB. Long edge over 3840 px is downsized, files over 10 MB are re-encoded as JPEG.'));
204
+ });
205
+ });
206
+ br.command('check <postId>')
207
+ .description('Publish readiness and health: required fields, images, episodes, campaign')
208
+ .action(async (id) => {
209
+ const client = backreelClient(globals);
210
+ const p = await client.getFresh(`${POSTS}/list/${id}`);
211
+ const blockers = [];
212
+ const advice = [];
213
+ const hasText = !!(p.descriptionText || '').trim() || /<img\b/i.test(p.description || '');
214
+ const episodes = p.episodes || [];
215
+ if (!(p.title || '').trim())
216
+ blockers.push('title is empty');
217
+ if (!hasText)
218
+ blockers.push('description is empty (needs text or an image) — `backreel set -d @synopsis.md`');
219
+ if (!p.cover && !(p.assets || []).length && !episodes.length)
220
+ blockers.push('needs a cover, LookBook media or an episode');
221
+ if (p.kind === 'crowdfunding' && !(p.campaign?.goalAmount >= 1))
222
+ blockers.push('crowdfunding goal is 0 — `backreel fund set --goal`');
223
+ if (!p.cover)
224
+ advice.push('no 16:9 cover — cards and shares fall back to a blank frame');
225
+ if (!p.coverPortrait)
226
+ advice.push('no 9:16 portrait cover — mobile crops the 16:9 cover');
227
+ if (!(p.logline || '').trim())
228
+ advice.push('no logline — the page falls back to the first sentence of the description');
229
+ if (!(p.credits || []).length)
230
+ advice.push('no credits');
231
+ const pending = episodes.filter((e) => e.vodVid && e.vodStatus !== 'ready');
232
+ if (pending.length)
233
+ advice.push(`${pending.length} episode(s) not ready on VOD (${pending.map((e) => `#${(e.order ?? 0) + 1} ${e.vodStatus || '?'}`).join(', ')}) — \`backreel episode status\``);
234
+ if (p.trailerDurationSec && p.trailerDurationSec > 31)
235
+ advice.push(`trailer is ${p.trailerDurationSec}s; the limit is 30s`);
236
+ if (p.donation?.enabled && p.kind === 'crowdfunding' && p.campaign?.deadline && new Date(p.campaign.deadline) < new Date()) {
237
+ advice.push('the campaign deadline has passed — donations are closed');
238
+ }
239
+ render({ id: p.id, publishStatus: p.publishStatus, blockers, advice }, () => {
240
+ info(`"${p.title}" — ${p.kind}, ${p.publishStatus}`);
241
+ for (const b of blockers)
242
+ info(color.red(` ✗ ${b}`));
243
+ for (const a of advice)
244
+ info(color.yellow(` ! ${a}`));
245
+ if (!blockers.length)
246
+ info(color.green(p.publishStatus === 'published' ? ' ✓ published' : ' ✓ ready to publish — `wji backreel publish`'));
247
+ });
248
+ });
249
+ br.addCommand(episodeCommand(globals));
250
+ br.addCommand(enquiryCommand(globals));
251
+ br.addCommand(fundCommand(globals));
90
252
  // ── credits ────────────────────────────────────────────────────────────
91
253
  const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
92
254
  credit
93
255
  .command('list <postId>', { isDefault: true })
94
256
  .description('Numbered credits')
95
257
  .action(async (id) => {
96
- const client = new WjiClient(globals());
258
+ const client = backreelClient(globals);
97
259
  const post = await client.getFresh(`${POSTS}/list/${id}`);
98
260
  render(post.credits || [], (credits) => printCredits(credits));
99
261
  });
@@ -106,6 +268,7 @@ export function backreelCommand(globals) {
106
268
  .option('--npc <npc>', 'link an NPC City NPC (id, @handle or name)')
107
269
  .option('--user <userId>', 'link a WJI user (their name and avatar win)')
108
270
  .option('--link <url>', 'profile link')
271
+ .option('--photo <assetId>', 'square photo asset (see `backreel image --slot photo`)')
109
272
  .option('--job <type>', `also add a work-history job to the NPC: ${NPC_JOB_TYPES.join(' | ')}`)
110
273
  .action(async (id, opts) => {
111
274
  if (!CREDIT_KINDS.includes(opts.kind))
@@ -115,7 +278,7 @@ export function backreelCommand(globals) {
115
278
  if (opts.job && !NPC_JOB_TYPES.includes(opts.job)) {
116
279
  throw new Error(`--job must be one of ${NPC_JOB_TYPES.join(', ')}`);
117
280
  }
118
- const client = new WjiClient(globals());
281
+ const client = backreelClient(globals);
119
282
  const npc = opts.npc ? await resolveNpc(client, opts.npc) : undefined;
120
283
  // Any public NPC can be credited, but only your own gets a job. The soul
121
284
  // route is owner-only, so it checks that before anything is written.
@@ -132,6 +295,8 @@ export function backreelCommand(globals) {
132
295
  entry.user = opts.user;
133
296
  if (opts.link)
134
297
  entry.link = opts.link;
298
+ if (opts.photo)
299
+ entry.photo = opts.photo;
135
300
  if (npc) {
136
301
  entry.npcId = npc.id;
137
302
  if (npc.image)
@@ -155,7 +320,7 @@ export function backreelCommand(globals) {
155
320
  .description('Remove credits by number')
156
321
  .option('-y, --yes', 'skip confirmation')
157
322
  .action(async (id, ns, opts) => {
158
- const client = new WjiClient(globals());
323
+ const client = backreelClient(globals);
159
324
  const post = await client.getFresh(`${POSTS}/list/${id}`);
160
325
  const credits = post.credits || [];
161
326
  const positions = parsePositions(ns, credits.length);
@@ -176,19 +341,28 @@ export function backreelCommand(globals) {
176
341
  function postOptions(cmd) {
177
342
  return cmd
178
343
  .option('--kind <kind>', KINDS.join(' | '))
344
+ .option('--logline <text>', `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`)
179
345
  .option('--category <category>', 'category')
180
346
  .option('--project-status <status>', PROJECT_STATUSES.join(' | '))
181
347
  .option('--tags <tags>', 'comma-separated tags (replaces the list)')
182
348
  .option('-d, --description <text>', 'description, plain text or HTML (@file / - supported)')
183
349
  .option('--cover <assetId>', 'landscape cover asset')
184
350
  .option('--cover-portrait <assetId>', 'portrait cover asset')
185
- .option('--trailer <assetId>', 'trailer video asset')
351
+ .option('--trailer <assetId>', 'trailer video asset (≤30 s)')
352
+ .option('--media <assetId...>', `LookBook gallery, images or videos in order (replaces it; ≤${MEDIA_MAX}; "none" clears)`)
186
353
  .option('--body <json>', 'any other post fields as JSON (@file / - supported); flags win');
187
354
  }
188
355
  async function postBody(opts) {
189
356
  const body = { ...((await readJson(opts.body)) || {}) };
190
357
  if (opts.kind)
191
358
  body.kind = oneOf(opts.kind, KINDS, '--kind');
359
+ if (opts.logline !== undefined) {
360
+ // Same normalisation the server applies: plain text, one line.
361
+ const logline = String(await readValue(opts.logline)).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
362
+ if (logline.length > LOGLINE_MAX)
363
+ throw new Error(`--logline is ${logline.length} chars; the limit is ${LOGLINE_MAX}`);
364
+ body.logline = logline;
365
+ }
192
366
  if (opts.category)
193
367
  body.category = opts.category;
194
368
  if (opts.projectStatus)
@@ -203,6 +377,12 @@ async function postBody(opts) {
203
377
  body.coverPortrait = opts.coverPortrait;
204
378
  if (opts.trailer)
205
379
  body.trailer = opts.trailer;
380
+ if (opts.media) {
381
+ const media = opts.media.filter((m) => m !== 'none');
382
+ if (media.length > MEDIA_MAX)
383
+ throw new Error(`--media takes at most ${MEDIA_MAX} items (the web's LookBook limit)`);
384
+ body.assets = media;
385
+ }
206
386
  return body;
207
387
  }
208
388
  /** Descriptions are stored as sanitized HTML; plain text becomes paragraphs. */
@@ -225,11 +405,7 @@ async function updateCredits(client, id, fn) {
225
405
  throw new Error('A post can have at most 30 credits');
226
406
  return client.put(`${POSTS}/list/${id}`, { credits: next });
227
407
  }
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
- }
408
+ const assetLabel = (a) => (a ? a.url || a.id || a : undefined);
233
409
  function printPost(p) {
234
410
  detail([
235
411
  ['id', p.id],
@@ -239,8 +415,15 @@ function printPost(p) {
239
415
  ['project', p.projectStatus],
240
416
  ['category', p.category],
241
417
  ['tags', (p.tags || []).join(', ')],
418
+ ['logline', p.logline],
242
419
  ['description', truncate(p.descriptionText || p.description, 120)],
243
- ['cover', p.cover?.url || p.cover?.id || p.cover],
420
+ ['cover', assetLabel(p.cover)],
421
+ ['coverPortrait', assetLabel(p.coverPortrait)],
422
+ ['media', (p.assets || []).length ? `${p.assets.length} item(s)` : undefined],
423
+ ['episodes', (p.episodes || []).length ? `${p.episodes.length} (\`backreel episode list\`)` : undefined],
424
+ ['campaign', p.campaign ? `${p.campaign.raisedAmount}/${p.campaign.goalAmount} ${(p.campaign.currency || '').toUpperCase()}` : undefined],
425
+ ['donations', p.donation?.enabled ? 'on' : undefined],
426
+ ['trailer', p.trailer ? `${assetLabel(p.trailer)}${p.trailerDurationSec ? ` (${p.trailerDurationSec}s)` : ''}` : undefined],
244
427
  ['creator', p.creator?.name],
245
428
  ['likes', p.likeCount],
246
429
  ['updated', relativeTime(p.updatedAt)],