@wji.ai/cli 0.5.1 → 0.6.1

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 CHANGED
@@ -117,8 +117,12 @@ Run `wji <group> --help` for the full option list.
117
117
 
118
118
  ## Conventions
119
119
 
120
- - **`--json`** on any command prints the raw API response and nothing else, so
121
- everything composes with `jq`.
120
+ - **`--json`** on any command prints JSON and nothing else, so everything
121
+ composes with `jq`. List and get commands print the **`standard`** level by
122
+ default (ids, names, status, media urls, long text clipped to 200 chars,
123
+ `content` summarised with `truncated: true`). Add **`--detail full`** (or set
124
+ `WJI_DETAIL=full`) for the raw API response, `--detail brief` for id lists.
125
+ Human table output is never affected.
122
126
  - **`@file` and `-`** work anywhere a long text or JSON value is expected:
123
127
  `-p @prompt.txt`, `--content @node.json`, `-p -` to read stdin.
124
128
  - **`--set key=value`** sends arbitrary fields; dotted keys nest
@@ -244,6 +248,7 @@ Skill refs are `system:<key>` or `user:<skillId>` (a bare id is treated as `user
244
248
  | Generation submissions | image 30 / video 10 / audio 20 per minute, per user or key |
245
249
  | Generation queue | queued + running jobs: FREE 3 / VIP 10 / SVIP 25 → HTTP 429 |
246
250
  | Pagination | `--limit` ≤100; `-1` returns at most 500 except on opt-in endpoints |
251
+ | Detail levels | `?detail=brief\|standard\|full` on workspace, fsnode, model and history reads; the API defaults to `full`, the CLI's `--json` to `standard` |
247
252
  | Batches | `asset status` is sent 100 ids at a time; `asset summary` ≤100 ids |
248
253
  | Skills | zip ≤5MB, ≤200 files, ≤2MB per `.md`, ≤20MB total, 50 per account |
249
254
  | Creation caps | 50 workspaces, 3 organizations per account |
@@ -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`);
@@ -7,6 +7,10 @@ const POSTS = '/backreel/post';
7
7
  const KINDS = ['showcase', 'crowdfunding'];
8
8
  const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
9
9
  const CREDIT_KINDS = ['creative', 'crew', 'cast'];
10
+ /** Backend LOGLINE_MAX (src/backreel/post/descriptionHtml.ts). */
11
+ const LOGLINE_MAX = 200;
12
+ /** BackreelVerificationKind — what `creators --role` filters on. */
13
+ const VERIFICATION_KINDS = ['investor', 'production_company', 'brand', 'distributor', 'sales_agent'];
10
14
  /** Credit fields the create / update DTO accepts; the rest of a read-back credit is dropped. */
11
15
  const CREDIT_FIELDS = ['kind', 'name', 'role', 'user', 'photo', 'avatarUrl', 'link', 'source', 'npcId', 'order'];
12
16
  export function backreelCommand(globals) {
@@ -87,6 +91,50 @@ export function backreelCommand(globals) {
87
91
  const result = await client.delete(`${POSTS}/list/${id}`);
88
92
  render(result, () => success(`Deleted "${post.title}"`));
89
93
  });
94
+ br.command('creators')
95
+ .description('Browse Backreel creators: editor picks, then ranked by recent likes / favorites')
96
+ .option('-k, --keyword <text>', 'match name or @handle')
97
+ .option('--role <kind>', `only creators verified as: ${VERIFICATION_KINDS.join(' | ')}`)
98
+ .option('--verified', 'only verified creators')
99
+ .option('--no-featured', 'skip the editor-picked creators')
100
+ .option('--page <n>', 'page number', '1')
101
+ .option('--limit <n>', 'page size (max 48)', '20')
102
+ .action(async (opts) => {
103
+ const client = new WjiClient(globals());
104
+ if (opts.role)
105
+ oneOf(opts.role, VERIFICATION_KINDS, '--role');
106
+ const result = await client.get('/backreel/creator/list', {
107
+ keyword: opts.keyword,
108
+ role: opts.role,
109
+ verifiedOnly: opts.verified ? 'true' : undefined,
110
+ withFeatured: opts.featured ? undefined : 'false',
111
+ page: Number(opts.page),
112
+ limit: Number(opts.limit),
113
+ });
114
+ render(result, (r) => {
115
+ const columns = [
116
+ { header: 'id', value: (c) => c.id },
117
+ { header: 'name', value: (c) => truncate(c.name, 24) },
118
+ { header: 'handle', value: (c) => (c.handle ? `@${c.handle}` : '') },
119
+ { header: 'reels', value: (c) => c.reelCount },
120
+ { header: 'followers', value: (c) => c.followerCount },
121
+ { header: 'likes', value: (c) => c.likeCount },
122
+ { header: 'verified', value: (c) => (c.verifiedKinds || []).join(', ') },
123
+ ];
124
+ if (r?.featured?.length) {
125
+ info(color.bold('Featured'));
126
+ table(r.featured, [...columns, { header: 'note', value: (c) => truncate(c.featuredNote, 30) }]);
127
+ process.stdout.write('\n');
128
+ info(color.bold('Hot'));
129
+ }
130
+ table(r?.data || [], columns);
131
+ const p = r?.pagination;
132
+ if (p) {
133
+ const more = p.page * p.limit < p.total ? ` · more: --page ${p.page + 1}` : '';
134
+ info(color.dim(`${p.total} ranked · page ${p.page}${more}`));
135
+ }
136
+ });
137
+ });
90
138
  // ── credits ────────────────────────────────────────────────────────────
91
139
  const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
92
140
  credit
@@ -176,6 +224,7 @@ export function backreelCommand(globals) {
176
224
  function postOptions(cmd) {
177
225
  return cmd
178
226
  .option('--kind <kind>', KINDS.join(' | '))
227
+ .option('--logline <text>', `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`)
179
228
  .option('--category <category>', 'category')
180
229
  .option('--project-status <status>', PROJECT_STATUSES.join(' | '))
181
230
  .option('--tags <tags>', 'comma-separated tags (replaces the list)')
@@ -189,6 +238,13 @@ async function postBody(opts) {
189
238
  const body = { ...((await readJson(opts.body)) || {}) };
190
239
  if (opts.kind)
191
240
  body.kind = oneOf(opts.kind, KINDS, '--kind');
241
+ if (opts.logline !== undefined) {
242
+ // Same normalisation the server applies: plain text, one line.
243
+ const logline = String(await readValue(opts.logline)).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
244
+ if (logline.length > LOGLINE_MAX)
245
+ throw new Error(`--logline is ${logline.length} chars; the limit is ${LOGLINE_MAX}`);
246
+ body.logline = logline;
247
+ }
192
248
  if (opts.category)
193
249
  body.category = opts.category;
194
250
  if (opts.projectStatus)
@@ -225,6 +281,7 @@ async function updateCredits(client, id, fn) {
225
281
  throw new Error('A post can have at most 30 credits');
226
282
  return client.put(`${POSTS}/list/${id}`, { credits: next });
227
283
  }
284
+ const assetLabel = (a) => (a ? a.url || a.id || a : undefined);
228
285
  function oneOf(value, allowed, flag) {
229
286
  if (!allowed.includes(value))
230
287
  throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
@@ -239,8 +296,11 @@ function printPost(p) {
239
296
  ['project', p.projectStatus],
240
297
  ['category', p.category],
241
298
  ['tags', (p.tags || []).join(', ')],
299
+ ['logline', p.logline],
242
300
  ['description', truncate(p.descriptionText || p.description, 120)],
243
- ['cover', p.cover?.url || p.cover?.id || p.cover],
301
+ ['cover', assetLabel(p.cover)],
302
+ ['coverPortrait', assetLabel(p.coverPortrait)],
303
+ ['trailer', p.trailer ? `${assetLabel(p.trailer)}${p.trailerDurationSec ? ` (${p.trailerDurationSec}s)` : ''}` : undefined],
244
304
  ['creator', p.creator?.name],
245
305
  ['likes', p.likeCount],
246
306
  ['updated', relativeTime(p.updatedAt)],
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
3
  import { confirm, parseKeyValues, readValue } from '../core/prompt.js';
4
- import { color, detail, info, isJsonMode, printJson, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
4
+ import { clipText, color, detail, detailLevel, info, isJsonMode, printJson, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
5
5
  import { pollHistory, reportHistory, resolveAssets } from './gen.js';
6
6
  import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
7
7
  import { fetchModels } from './model.js';
@@ -315,6 +315,53 @@ export function describe(canvas, node) {
315
315
  outgoing: canvas.edges.filter((e) => e.fromNodeId === node.id).map((e) => e.toNodeId),
316
316
  };
317
317
  }
318
+ /**
319
+ * The canvas commands always load the full canvas (edits and generation need
320
+ * every field), so --detail is applied here, to the JSON they print. Mirrors
321
+ * the backend's canvas_list_nodes levels.
322
+ */
323
+ export function trimDescribed(row, level = detailLevel()) {
324
+ if (!level || level === 'full')
325
+ return row;
326
+ if (level === 'brief') {
327
+ return compactRow({
328
+ id: row.id,
329
+ kind: row.kind,
330
+ name: row.name,
331
+ tag: row.tag,
332
+ mode: row.mode,
333
+ status: row.status,
334
+ assetId: row.assetId,
335
+ position: row.position,
336
+ references: row.references.map((r) => ({ nodeId: r.nodeId, label: r.label })),
337
+ outgoing: row.outgoing,
338
+ });
339
+ }
340
+ const g = row.generate;
341
+ return compactRow({
342
+ ...row,
343
+ text: row.text === undefined ? undefined : clipText(row.text) ?? '',
344
+ error: typeof row.error === 'string' ? clipText(row.error) : row.error,
345
+ generate: g
346
+ ? compactRow({
347
+ prompt: clipText(g.prompt),
348
+ model: g.model,
349
+ ratio: g.ratio,
350
+ resolution: g.resolution,
351
+ duration: g.duration,
352
+ historyId: g.historyId,
353
+ pendingAssetId: g.pendingAssetId,
354
+ })
355
+ : undefined,
356
+ });
357
+ }
358
+ function compactRow(value) {
359
+ const out = {};
360
+ for (const key of Object.keys(value))
361
+ if (value[key] !== undefined)
362
+ out[key] = value[key];
363
+ return out;
364
+ }
318
365
  // ── models ──────────────────────────────────────────────────────────────────
319
366
  /** An audio node is fed by either an `audio` or a `tts` model — the backend tags voice models `tts`. */
320
367
  const KIND_FEATURES = { audio: ['audio', 'tts'] };
@@ -1022,7 +1069,7 @@ export function canvasCommand(globals) {
1022
1069
  .filter((id) => loaded.byId.has(id))
1023
1070
  .map((id) => describe(loaded, loaded.byId.get(id)));
1024
1071
  }
1025
- const out = { ...res, state: { ...state, canvasName }, selected };
1072
+ const out = { ...res, state: { ...state, canvasName }, selected: selected.map((n) => trimDescribed(n)) };
1026
1073
  render(out, () => {
1027
1074
  const seconds = Math.max(0, Math.round((new Date(state.expiresAt).getTime() - Date.now()) / 1000));
1028
1075
  detail([
@@ -1249,7 +1296,7 @@ export function canvasCommand(globals) {
1249
1296
  .filter((n) => !nameRe || nameRe.test(n.name))
1250
1297
  .map((n) => describe(cv, n))
1251
1298
  .filter((n) => (colors ? !!n.tag && colors.has(n.tag.color) : opts.untagged ? !n.tag : true));
1252
- render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows }, () => {
1299
+ render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows.map((n) => trimDescribed(n)) }, () => {
1253
1300
  info(color.dim(`${cv.name} (${cv.id}) · ${rows.length} node(s) · ${cv.edges.length} edge(s)`));
1254
1301
  nodeTable(rows);
1255
1302
  });
@@ -1283,13 +1330,13 @@ export function canvasCommand(globals) {
1283
1330
  });
1284
1331
  canvas
1285
1332
  .command('get <nodeId>')
1286
- .description('One node in full, with its references')
1333
+ .description('One node with its references (--detail full for the whole generate / text)')
1287
1334
  .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1288
1335
  .action(async (id, opts) => {
1289
1336
  const c = client();
1290
1337
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1291
1338
  const n = describe(cv, requireNode(cv, id));
1292
- render(n, () => printNode(n));
1339
+ render(trimDescribed(n), () => printNode(n));
1293
1340
  });
1294
1341
  canvas
1295
1342
  .command('edges')
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
3
  import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
4
- import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
4
+ import { color, detail, detailLevel, info, relativeTime, render, success, table, truncate } from '../core/output.js';
5
5
  import { printTree } from './workspace.js';
6
6
  /** Nested plain objects merge; arrays and scalars are replaced. */
7
7
  function deepMerge(base, patch) {
@@ -22,7 +22,10 @@ export function fsCommand(globals) {
22
22
  .action(async (key, opts) => {
23
23
  const client = new WjiClient(globals());
24
24
  const path = key ? `/fsnode/root/${key}` : '/fsnode/root';
25
- const node = await client.get(path, opts.depth ? { depth: Number(opts.depth) } : undefined);
25
+ const node = await client.get(path, {
26
+ depth: opts.depth ? Number(opts.depth) : undefined,
27
+ detail: detailLevel(),
28
+ });
26
29
  render(node, (n) => (opts.depth ? printTree(n) : printNode(n)));
27
30
  });
28
31
  fs.command('ls <id>')
@@ -34,6 +37,7 @@ export function fsCommand(globals) {
34
37
  const result = await client.get(`/fsnode/list/${id}/children`, {
35
38
  limit: opts.limit ? Number(opts.limit) : undefined,
36
39
  scope: opts.scope,
40
+ detail: detailLevel(),
37
41
  });
38
42
  const rows = Array.isArray(result) ? result : result?.data || [];
39
43
  render(result, () => table(rows, [
@@ -50,15 +54,18 @@ export function fsCommand(globals) {
50
54
  .option('-d, --depth <n>', 'levels to expand')
51
55
  .action(async (id, opts) => {
52
56
  const client = new WjiClient(globals());
53
- const node = await client.get(`/fsnode/list/${id}/tree`, opts.depth ? { depth: Number(opts.depth) } : undefined);
57
+ const node = await client.get(`/fsnode/list/${id}/tree`, {
58
+ depth: opts.depth ? Number(opts.depth) : undefined,
59
+ detail: detailLevel(),
60
+ });
54
61
  render(node, (n) => printTree(n));
55
62
  });
56
63
  fs.command('get <id>')
57
- .description('Show one node (comma-separate ids for a batch)')
64
+ .description('Show one node (comma-separate ids for a batch; --detail full for the whole content)')
58
65
  .option('--scope <scope>', 'server-side scope filter')
59
66
  .action(async (id, opts) => {
60
67
  const client = new WjiClient(globals());
61
- const node = await client.get(`/fsnode/list/${id}`, { scope: opts.scope });
68
+ const node = await client.get(`/fsnode/list/${id}`, { scope: opts.scope, detail: detailLevel() });
62
69
  render(node, (n) => (Array.isArray(n) ? n.forEach(printNode) : printNode(n)));
63
70
  });
64
71
  fs.command('path <id>')
@@ -232,7 +239,7 @@ export function datasetCommand(globals) {
232
239
  .description('List dataset nodes in a folder')
233
240
  .action(async (folderId) => {
234
241
  const client = new WjiClient(globals());
235
- const result = await client.get(`/fsnode/list/${folderId}/children`);
242
+ const result = await client.get(`/fsnode/list/${folderId}/children`, { detail: detailLevel() });
236
243
  const rows = (Array.isArray(result) ? result : result?.data || []).filter((n) => n.mimetype === 'workspace/dataset' || n.type === 'file');
237
244
  render(rows, () => table(rows, [
238
245
  { header: 'id', value: (n) => n.id },
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
3
  import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
4
- import { color, detail, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
4
+ import { color, detail, detailLevel, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
5
5
  const READY = 1;
6
6
  const CANCELLED = -1;
7
7
  /** `--ref abc123`, `--ref abc123:hero`, `--ref abc123:hero:0.6` */
@@ -254,6 +254,7 @@ export function historyCommand(globals) {
254
254
  bookmarked: opts.bookmarked ? true : undefined,
255
255
  page: Number(opts.page),
256
256
  limit: Number(opts.limit),
257
+ detail: detailLevel(),
257
258
  });
258
259
  render(result, (r) => {
259
260
  table(r.data || [], [
@@ -284,7 +285,7 @@ export function historyCommand(globals) {
284
285
  }));
285
286
  }
286
287
  else {
287
- history = await client.get(`/ai/assets/history/list/${id}`);
288
+ history = await client.get(`/ai/assets/history/list/${id}`, { detail: detailLevel() });
288
289
  assets = await resolveAssets(client, history);
289
290
  }
290
291
  render({ ...history, images: assets }, () => reportHistory(history, assets));
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
- import { color, detail, info, render, table, truncate, warn } from '../core/output.js';
3
+ import { color, detail, detailLevel, info, render, table, truncate, warn } from '../core/output.js';
4
4
  const FEATURES = [
5
5
  'text',
6
6
  'image',
@@ -24,6 +24,7 @@ export async function fetchModels(client, opts) {
24
24
  const features = opts.features?.length ? opts.features : FEATURES;
25
25
  const list = await client.get(`/ai/model/list/${features.join(',')}`, {
26
26
  workspaceId: client.workspace(opts.workspace),
27
+ detail: opts.detail,
27
28
  });
28
29
  return Array.isArray(list) ? list : list?.data || [];
29
30
  }
@@ -41,6 +42,7 @@ export function modelCommand(globals) {
41
42
  let rows = await fetchModels(client, {
42
43
  features: opts.feature,
43
44
  workspace: opts.workspace,
45
+ detail: detailLevel(),
44
46
  });
45
47
  if (!opts.all)
46
48
  rows = rows.filter((m) => m.enabled !== false && !m.hidden);
@@ -70,7 +72,7 @@ export function modelCommand(globals) {
70
72
  .option('-w, --workspace <id>', 'resolve pricing/access for a workspace')
71
73
  .action(async (name, opts) => {
72
74
  const client = new WjiClient(globals());
73
- const rows = await fetchModels(client, { workspace: opts.workspace });
75
+ const rows = await fetchModels(client, { workspace: opts.workspace, detail: detailLevel() });
74
76
  // `name` is not unique across providers; `nickname` is, so prefer it.
75
77
  const matches = rows.filter((m) => m.nickname === name || m.name === name);
76
78
  if (!matches.length)
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
- import { updateProfile } from '../core/config.js';
3
+ import { getProfile, updateProfile } from '../core/config.js';
4
4
  import { confirm, parseKeyValues } from '../core/prompt.js';
5
- import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
5
+ import { color, detail, detailLevel, info, relativeTime, render, success, table, truncate } from '../core/output.js';
6
6
  import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
7
+ import { fetchModels } from './model.js';
7
8
  /** App tokens accepted by `--app`; mirrors WorkspaceApplication on the backend. */
8
9
  const APPS = {
9
10
  dataset: 'Dataset',
@@ -54,6 +55,7 @@ export function workspaceCommand(globals) {
54
55
  scope: opts.scope,
55
56
  page: Number(opts.page),
56
57
  limit: Number(opts.limit),
58
+ detail: detailLevel(),
57
59
  });
58
60
  const current = client.workspace();
59
61
  render(result, (r) => {
@@ -77,7 +79,9 @@ export function workspaceCommand(globals) {
77
79
  .description('Show a workspace (defaults to the selected one)')
78
80
  .action(async (id) => {
79
81
  const client = new WjiClient(globals());
80
- const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`);
82
+ const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`, {
83
+ detail: detailLevel(),
84
+ });
81
85
  render(w, () => detail([
82
86
  ['id', w.id],
83
87
  ['name', w.name],
@@ -173,7 +177,7 @@ export function workspaceCommand(globals) {
173
177
  const path = opts.app
174
178
  ? `/workspace/list/${wsId}/fs/${opts.app}`
175
179
  : `/workspace/list/${wsId}/fs`;
176
- const root = await client.get(path);
180
+ const root = await client.get(path, { detail: detailLevel() });
177
181
  render(root, (node) => printTree(node));
178
182
  });
179
183
  // ── apps ───────────────────────────────────────────────────────────────────
@@ -339,6 +343,56 @@ export function workspaceCommand(globals) {
339
343
  await client.delete(`/workspace/list/${wsId}/member/${m.memberId}`);
340
344
  render({ removed: targets.map((m) => m.userId) }, () => success(`Removed ${names}`));
341
345
  });
346
+ ws.command('leave <id>')
347
+ .description('Leave a workspace you were invited to (owners cannot leave)')
348
+ .option('-y, --yes', 'skip confirmation')
349
+ .action(async (id, opts) => {
350
+ const g = globals();
351
+ const client = new WjiClient(g);
352
+ const w = await client.get(`/workspace/list/${id}/basic`).catch(() => ({ name: id }));
353
+ if (!(await confirm(`Leave workspace "${w.name}" (${id})? You need a new invite to come back.`, opts.yes))) {
354
+ return info('Aborted');
355
+ }
356
+ const result = await client.post(`/workspace/list/${id}/leave`);
357
+ // A left workspace 403s from now on, so stop pointing the profile at it.
358
+ if (getProfile(g.profile).profile.workspace === id)
359
+ updateProfile(g.profile, { workspace: undefined });
360
+ render(result, () => success(`Left ${w.name} (${id})`));
361
+ });
362
+ // ── default models ─────────────────────────────────────────────────────────
363
+ ws.command('defaults [id]')
364
+ .description('Show or set the workspace default models (pass "none" to clear one)')
365
+ .option('--chat <model>', 'default chat model for new conversations')
366
+ .option('--image <model>', 'default image model')
367
+ .option('--video <model>', 'default video model')
368
+ .option('--storyboard <model>', 'default storyboard model')
369
+ .option('--audio <model>', 'default audio model')
370
+ .option('--image-resolution <value>', 'default image resolution, e.g. 2K')
371
+ .option('--video-resolution <value>', 'default video resolution, e.g. 720p')
372
+ .action(async (id, opts) => {
373
+ const client = new WjiClient(globals());
374
+ const wsId = client.requireWorkspace(id);
375
+ // settings only comes back at full detail, and PUT replaces settings whole —
376
+ // read it fresh so prompts / defaultApp / improvePromptDefault survive.
377
+ const w = await client.getFresh(`/workspace/list/${wsId}`, { detail: 'full' });
378
+ const current = { ...(w.settings?.defaultModels || {}) };
379
+ const changes = DEFAULT_MODEL_KEYS.filter((key) => opts[key] !== undefined);
380
+ if (!changes.length) {
381
+ return render(current, () => detail(DEFAULT_MODEL_KEYS.map((key) => [key, current[key] ?? color.dim('—')])));
382
+ }
383
+ const next = { ...current };
384
+ const needsModels = changes.some((key) => !key.endsWith('Resolution') && !isNone(opts[key]));
385
+ const models = needsModels ? await fetchModels(client, { workspace: wsId }) : [];
386
+ for (const key of changes) {
387
+ const raw = String(opts[key]).trim();
388
+ next[key] = isNone(raw) ? null : key.endsWith('Resolution') ? raw : resolveDefaultModel(models, key, raw);
389
+ }
390
+ await client.put(`/workspace/list/${wsId}`, { settings: { ...(w.settings || {}), defaultModels: next } });
391
+ render(next, () => {
392
+ success('Default models updated');
393
+ detail(DEFAULT_MODEL_KEYS.map((key) => [key, next[key] ?? color.dim('—')]));
394
+ });
395
+ });
342
396
  // ── quota / stats ──────────────────────────────────────────────────────────
343
397
  ws.command('quota [id]')
344
398
  .description('Show credit quota usage')
@@ -381,6 +435,28 @@ export function workspaceCommand(globals) {
381
435
  });
382
436
  return ws;
383
437
  }
438
+ const DEFAULT_MODEL_KEYS = [
439
+ 'chat',
440
+ 'image',
441
+ 'video',
442
+ 'storyboard',
443
+ 'audio',
444
+ 'imageResolution',
445
+ 'videoResolution',
446
+ ];
447
+ const isNone = (value) => ['none', 'null', ''].includes(String(value ?? '').trim().toLowerCase());
448
+ /**
449
+ * The web stores the chat default as the model `name` (what conversations carry)
450
+ * and the media defaults as the `nickname` its pickers emit — the value `-m` takes.
451
+ * Accept either spelling and store what the web would.
452
+ */
453
+ function resolveDefaultModel(models, key, value) {
454
+ const model = models.find((m) => m.nickname === value || m.name === value);
455
+ if (!model) {
456
+ throw new Error(`Unknown model "${value}" for --${key}. See \`wji models list\`.`);
457
+ }
458
+ return key === 'chat' ? model.name : model.nickname || model.name;
459
+ }
384
460
  export function printTree(node, prefix = '', connector = '') {
385
461
  if (!node)
386
462
  return;
@@ -17,6 +17,35 @@ export function setJsonMode(on) {
17
17
  export function isJsonMode() {
18
18
  return jsonMode;
19
19
  }
20
+ const DETAIL_LEVELS = ['brief', 'standard', 'full'];
21
+ /**
22
+ * Global --detail (or WJI_DETAIL). Unset means "standard when printing JSON,
23
+ * nothing (server default = full) for human output", so tables are unchanged.
24
+ */
25
+ let detailMode;
26
+ export function setDetailMode(raw) {
27
+ if (raw === undefined || raw === '') {
28
+ detailMode = undefined;
29
+ return;
30
+ }
31
+ const level = String(raw).toLowerCase();
32
+ if (!DETAIL_LEVELS.includes(level)) {
33
+ throw new Error(`--detail must be one of ${DETAIL_LEVELS.join(', ')} (got "${raw}")`);
34
+ }
35
+ detailMode = level;
36
+ }
37
+ export function detailLevel() {
38
+ if (detailMode)
39
+ return detailMode;
40
+ return jsonMode ? 'standard' : undefined;
41
+ }
42
+ /** Clip long text the way the server does at `standard` (200 chars + …). */
43
+ export function clipText(value, max = 200) {
44
+ if (value === undefined || value === null || value === '')
45
+ return undefined;
46
+ const s = String(value);
47
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
48
+ }
20
49
  export function printJson(value) {
21
50
  process.stdout.write(JSON.stringify(value, null, 2) + '\n');
22
51
  }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { Command } from 'commander';
4
4
  import { ApiError } from './core/client.js';
5
- import { color, setJsonMode } from './core/output.js';
5
+ import { color, setDetailMode, setJsonMode } from './core/output.js';
6
6
  import { authCommand } from './commands/auth.js';
7
7
  import { configCommand } from './commands/config.js';
8
8
  import { orgCommand } from './commands/org.js';
@@ -23,6 +23,12 @@ import { backreelCommand } from './commands/backreel.js';
23
23
  // Read from package.json so `wji --version` cannot drift from the published
24
24
  // version — the skill compares the two to detect a stale install.
25
25
  const VERSION = createRequire(import.meta.url)('../package.json').version;
26
+ // `wji … | head` closes the pipe early; that is the reader being done, not a crash.
27
+ process.stdout.on('error', (err) => {
28
+ if (err.code === 'EPIPE')
29
+ process.exit(0);
30
+ throw err;
31
+ });
26
32
  const program = new Command();
27
33
  program
28
34
  .name('wji')
@@ -34,7 +40,8 @@ program
34
40
  .option('--org <orgId>', 'organization context (X-Org-Id)')
35
41
  .option('--no-org', 'force personal context')
36
42
  .option('--locale <locale>', 'response language (cht, chs, en)')
37
- .option('--json', 'print raw JSON instead of tables')
43
+ .option('--json', 'print JSON instead of tables (standard detail unless --detail is given)')
44
+ .option('--detail <level>', 'how much to return: brief | standard | full (default standard for --json; env WJI_DETAIL)')
38
45
  .configureHelp({ sortSubcommands: true });
39
46
  /**
40
47
  * Global flags live on the root command, but subcommand actions run before
@@ -51,10 +58,12 @@ const globals = () => {
51
58
  // commander turns `--no-org` into org === false
52
59
  personal: opts.org === false,
53
60
  locale: opts.locale,
61
+ detail: opts.detail,
54
62
  };
55
63
  };
56
64
  program.hook('preAction', () => {
57
65
  setJsonMode(!!program.opts().json);
66
+ setDetailMode(program.opts().detail ?? process.env.WJI_DETAIL);
58
67
  });
59
68
  program.addCommand(authCommand(globals));
60
69
  program.addCommand(configCommand(globals));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -24,6 +24,9 @@ strengths, not platform guarantees.
24
24
  | `marketing-studio-image` | auto · 1:1 · 3:2 · 2:3 · 4:3 · 3:4 · 16:9 · 9:16 · 21:9 | 1k · 2k · 4k | 8 | fair | tuned for a commercial "marketing" look; cheapest 4K |
25
25
  | `runninghub-midjourney-v7` | 1:1 · 4:3 · 3:2 · 16:9 · 3:4 · 2:3 · 9:16 | fixed; quality 1 / 2 / 4 | 5 image + 5 omni + 5 style, weighted | weak; do not rely on it for labels | mood boards, style frames; negative prompt |
26
26
  | `runninghub-midjourney-v8.1` / `v8.2` | same 7 | native 2K | 8.1: 5; 8.2: 2 (first = image ref, second = style ref) | weak | style exploration only; never the packshot |
27
+ | `z-image-uncensored` / `z-image-pro-uncensored` | 1:1 · 2:3 · 3:2 · 3:4 · 4:3 · 9:16 · 16:9 | long edge ≤ 1536 (pro ≤ 2560) | none | fair | uncensored (無審核); only when the brief needs it |
28
+ | `qwen-image-edit-uncensored` | follows the first reference | follows the first reference | 1–3 (first = image to edit) | fair | uncensored instruction edit |
29
+ | `face-swap-uncensored` / `head-swap-uncensored` | follows the target | follows the target | exactly 2: target, then face / head | — | uncensored; swap a talent's face / head onto a shot |
27
30
 
28
31
  Bold ratios are the only sources of 4:5 and 2:3 on the platform. No image model outputs 1.91:1,
29
32
  6:7 or any banner strip.
@@ -40,6 +43,8 @@ Bold ratios are the only sources of 4:5 and 2:3 on the platform. No image model
40
43
  | `seedance-2.0` family | same six | 4–15 s, or auto | 480p · 720p · 1080p · 4k | ref variants: images + video + audio | `generate_audio` | the 4K option of the family |
41
44
  | `wan-3.0` / `-prime` | 16:9 · 4:3 · 1:1 · 3:4 · 9:16 | 2–30 s, or `autoDuration` | 480P · 720P · 1080P | first / last frame | `audio` on by default | optional "AI Generated" watermark; seed for repeatability |
42
45
  | `wan-3.0-ref` / `-prime-ref` | same five | 2–30 s, or auto | as above | 10 images + 5 videos + 5 audios (≤ 15 s total) | `audio` | |
46
+ | `wan-3.0-*-uncensored` (+ `-pro`, `-prime`, `-prime-pro`; each with a `-ref-` twin) | adaptive + the five | 2–30 s, or auto | 480p–1080p; pro: 1080p · 2k · 4k | as `wan-3.0` / `wan-3.0-ref` | `audio` | uncensored (無審核); Ref twins billed on input video + output seconds |
47
+ | `wan-2.7-i2v-uncensored` / `wan-2.2-i2v-uncensored` | follows the input image | 2.7: 2–15 s; 2.2: 5 or 8 s | 2.7: 720p · 1080p; 2.2: 480p · 720p | first frame (2.2 also last frame); 2.7: 1 driving audio | 2.7 `audio` | uncensored; 2.2 is the cheapest video here |
43
48
  | `veo-3.1` / `-fast` | 16:9 · 9:16 · auto | 4–8 s | 720p · 1080p · 4k | first / last frame | `generate_audio` | negative prompt available |
44
49
  | `veo-3.1-ref` | 16:9 · 9:16 | 8 s fixed | 720p · 1080p · 4k | 1–10 images | `generate_audio` | |
45
50
  | `kling-3` / `-pro` | 16:9 · 9:16 · 1:1 | 3–15 s | model default | 7 images, plus first / last frame | `generate_audio` (off by default) | pro = higher quality |
@@ -4,6 +4,24 @@
4
4
  which row, and in what order the references are connected. Read them as written and substitute the
5
5
  tool calls from `operations-wji.md`; `--from A B C` is `fromNodeIds: ['A','B','C']` and the array
6
6
  order is the reference order. Everything about rows, `@imageN` order and prompts is identical.
7
+ Build each row with **one `canvas_create_nodes` call**: its `nodes` array takes the fields of
8
+ `canvas_create_node` (minus `canvas`), and `"$i"` in `fromNodeIds` or `nextTo` means item i of the
9
+ same batch (0-based, must come earlier), so a headshot → sheet → voice chain is one call and a
10
+ `characters` canvas of twelve characters is one or two calls instead of fifty. Items are created in
11
+ order; if one fails the result says `failedAt` and the earlier nodes stay on the canvas — fix that
12
+ item and resend from it with real ids (up to 60 nodes a call).
13
+
14
+ ```
15
+ canvas_create_nodes { canvas: CV, nodes: [
16
+ { kind: "title", text: "C01 林小夏", position: {x: 0, y: 0} }, # $0
17
+ { kind: "image", name: "C01 headshot", position: {x: 400, y: 0},
18
+ generate: { model: "<image model>", ratio: "1:1", prompt: "<portrait prompt>" } }, # $1
19
+ { kind: "image", name: "C01 sheet", fromNodeIds: ["$1"],
20
+ generate: { model: "<image model>", ratio: "16:9", prompt: "@image1 is the face to match … " } }, # $2
21
+ { kind: "audio", name: "C01 voice", fromNodeIds: ["$1"],
22
+ generate: { model: "seed-audio-1.0", prompt: <voice prompt> } } # $3
23
+ ] } → created[0..3].id = T1 H1 S1 V1
24
+ ```
7
25
 
8
26
  Command sequences for the three canvas types. All ids are placeholders — take real ones from the
9
27
  command output (`wji --json canvas add … | jq -r .id`) and `bind` them at once. `SD` below is
@@ -36,6 +36,7 @@ The `wji` skill governs auth, confirmations and spending. The calls this skill m
36
36
  | preflight | `wji auth whoami --json`, `wji profile wallet` |
37
37
  | workspace | `wji ws create "<title>" -r <ratio>` → `wji ws use <id>` |
38
38
  | datasets | `wji dataset new … --app <appId>`, `wji fs set <id> --merge` (never `--content`), `--add-images` (never `--images` on an existing node) |
39
+ | reads | `wji fs get <id> --json` is the `standard` summary; `--detail full` whenever you need the whole `content` (`--merge` reads full for you) |
39
40
  | canvases | `wji canvas create <name>` · `wji canvas add <kind> -c <cv> …` · `wji canvas connect <a> <b>` · `wji canvas get <node>` · `wji canvas gen <node> [--yes --wait]` · `wji canvas rm` |
40
41
  | assets | `wji asset download <id> -o <dir>` — **look at what you generated** before building on it |
41
42
  | history | `wji history get <id> --wait` |
@@ -43,9 +43,9 @@ write a whole document at a time rather than field by field.
43
43
 
44
44
  | Purpose | Tool |
45
45
  | --- | --- |
46
- | workspace | `workspace_search`, `workspace_get`, `workspace_create`, `workspace_add_app` |
46
+ | workspace | `workspace_get { id }` for the workspace the chat is in, `workspace_create`, `workspace_add_app`; `workspace_search` **only with `limit`** (≤ 10) and a `keyword` — without them it returns every workspace the user can see. Both default to `detail: 'standard'`; `detail: 'full'` only when you need `settings` |
47
47
  | semantic folders | `resolve_dataset_folder { id, key: 'story' \| 'character' \| 'location' \| 'prop' \| 'shot' }` |
48
- | datasets | `fsnode_create_file { parent, name, dataset: true, datasetType, description, content, tags }`, `fsnode_update`, `fsnode_upsert_segment` |
48
+ | datasets | `fsnode_create_file { parent, name, dataset: true, datasetType, description, content, tags }`, `fsnode_update`, `fsnode_upsert_segment`; `fsnode_get { id, detail: 'full' }` before replacing a `content` (the default `standard` read is a summary with `truncated: true`, and writing it back is rejected) |
49
49
  | assets | `asset_get`, `asset_check_status`, `asset_create_from_url` |
50
50
  | canvases | the `canvas_*` tools below |
51
51
 
@@ -59,16 +59,21 @@ substitute:
59
59
  | `canvas-recipes.md` shows | call |
60
60
  | --- | --- |
61
61
  | `wji canvas create <name>` | `canvas_create { workspace, name }` → canvas id |
62
- | `wji canvas add <kind> -c CV -n "<name>" --at x,y …` | `canvas_create_node { canvas, kind, name, position: {x, y}, … }` |
62
+ | `wji canvas add <kind> -c CV -n "<name>" --at x,y …` | `canvas_create_node { canvas, kind, name, position: {x, y}, … }` — one node |
63
+ | a whole row of `wji canvas add …` lines | `canvas_create_nodes { canvas, nodes: [ … ] }` — up to 60 nodes in one call, each item the fields of `canvas_create_node` without `canvas`; `"$i"` in `fromNodeIds` / `nextTo` is item i of the same batch (0-based, must come earlier). Built in order; on a failure the result has `failedAt` and the earlier nodes stay |
63
64
  | `--prompt-file /tmp/x.txt` / `-p "<prompt>"` | `generate: { prompt, model, ratio, resolution, duration }` on `canvas_create_node` |
64
65
  | `--asset <id>` (a display node) | `assetId` on `canvas_create_node` |
65
66
  | `--from A B C` | `fromNodeIds: ['A','B','C']` — **the array order is the reference order** |
66
67
  | `wji canvas connect <a> <b>` | `canvas_connect_nodes { canvas, fromId, toId }` |
67
- | `wji canvas get <node>` | `canvas_get_node { canvas, id }` — references, label, status, asset |
68
+ | `wji canvas get <node>` | `canvas_get_node { canvas, id }` — references, label, status, asset; `detail: 'full'` for the whole `generate` / `text` before editing them |
68
69
  | `wji canvas gen <node>` (cost preview) | `canvas_generate { canvas, nodeIds, estimateOnly: true }` |
69
70
  | `wji canvas gen <node> --yes --wait` | `canvas_generate { canvas, nodeIds, wait: true }` |
70
71
  | `wji canvas rm` | `canvas_delete_nodes { canvas, ids }` |
71
72
 
73
+ Build each row with **one** `canvas_create_nodes` call (title, headshot, sheet `fromNodeIds: ["$1"]`,
74
+ voice `fromNodeIds: ["$1"]`), not four `canvas_create_node` calls — see the batch example at the top
75
+ of `canvas-recipes.md`.
76
+
72
77
  Reference labels (`@image1`, `@audio1` …) are numbered per type in connection order, exactly as the
73
78
  canvas UI numbers them. After connecting, call `canvas_get_node` and check the labels match what the
74
79
  prompt names **before** generating — a mislabelled reference is a wasted generation.
@@ -77,6 +82,24 @@ prompt names **before** generating — a mislabelled reference is a wasted gener
77
82
  first, tell the user the cost, and only generate for real once they say yes. The `wji` skill's
78
83
  spending policy applies here in full.
79
84
 
85
+ ## Steps and context
86
+
87
+ **A turn is capped at 40 steps, not 40 tool calls** (an Agent can set its own cap). One step is one
88
+ model call; every tool call you emit in the same step runs together and counts once. So emit
89
+ independent calls together — all the `short_drama_render` / `fsnode_create_file` of a stage in one
90
+ step, the five `resolve_dataset_folder` calls in one step — and write a whole document per
91
+ `short_drama_put` rather than field by field. When two steps are left you get a system note: stop
92
+ calling tools, report what was written (with ids) and what is still open; the stream ends at the cap
93
+ without letting you reply.
94
+
95
+ **Tool results stay in the context for the rest of the turn.** Read what you need, once: `ai_list_models`
96
+ once per project with `features: 'image'` (it is large); `fsnode_get` on the one dataset you need
97
+ instead of `fsnode_children` on a folder of them; `short_drama_get { section }` for one branch rather
98
+ than the whole project; never `workspace_search` without `limit`. Every list / get tool takes
99
+ `detail: 'brief' | 'standard' | 'full'` and defaults to `standard` (text clipped to 200 chars, media
100
+ as `{ id, url, status }`, `content` summarised); ask for `brief` when you only need ids and for `full`
101
+ only when you need the complete `content` / `form` / `settings`.
102
+
80
103
  **In a canvas chat** (the chat panel beside an open canvas) the browser supplies its own `canvas_*`
81
104
  tools, which always act on the canvas the user is looking at and take no `canvas` argument. A short
82
105
  drama needs several canvases, so do that work in a workspace chat; in a canvas chat, say so.
@@ -345,6 +345,12 @@ segment is rejected, retry, then trim — do not go hunting for a forbidden word
345
345
  To bisect anyway, use a scratch video node at `-d 2`, 480P: free when rejected, ~30 credits when it
346
346
  passes, and sample each fragment several times because one pass proves nothing.
347
347
 
348
+ **Last resort: the uncensored twin.** `wan-3.0-ref-uncensored` (MuleRouter, no green-net) takes the
349
+ same prompt and the same 10 images + 5 videos + 5 audios, at ~1.3× the price; note that it bills the
350
+ reference-video seconds as well as the output. Switch a stuck segment to it only with the
351
+ Producer's approval, and only after retry-then-trim has failed. `short-drama.mjs` does not know it
352
+ as a mode, so set it as the node's model directly.
353
+
348
354
  ## Sheets mode — no storyboard images (the default)
349
355
 
350
356
  `params.references: sheets` drops stage 5's image generation entirely: the video node references
@@ -9,6 +9,12 @@ description: Drive the WJI AI platform from the terminal with the `wji` CLI —
9
9
  `--json`, so prefer `--json` piped through `jq` when you need to read a value,
10
10
  and the human table output when you are showing the user a result.
11
11
 
12
+ `--json` prints the **`standard`** detail level: ids, names, status, media urls,
13
+ long text clipped to 200 chars and `content` reduced to a summary marked
14
+ `truncated: true`. Add `--detail full` whenever you need the whole `content`,
15
+ `form` or `settings` (always before a read-modify-write), and `--detail brief`
16
+ when you only want ids. `WJI_DETAIL=full` sets it for a whole session.
17
+
12
18
  ## Before anything else
13
19
 
14
20
  ```bash
@@ -78,6 +84,9 @@ Most commands take `--workspace`, but setting a default is less error-prone:
78
84
  ```bash
79
85
  wji ws list
80
86
  wji ws use <workspaceId>
87
+ wji ws defaults # default chat / image / video / audio models
88
+ wji ws defaults --chat <model> --video <nickname> # "none" clears one
89
+ wji ws leave <workspaceId> # leave a workspace you were invited to
81
90
  ```
82
91
 
83
92
  After that, `wji ws get`, `wji fs`, `wji gen` and `wji preset list` all default
@@ -94,7 +103,7 @@ wji ws fs # whole workspace tree
94
103
  wji ws fs --app <appId> # one app's tree
95
104
  wji fs tree <nodeId> --depth 2
96
105
  wji fs ls <folderId>
97
- wji fs get <nodeId> --json | jq .content
106
+ wji fs get <nodeId> --json --detail full | jq .content # --detail full: the summary is not the content
98
107
 
99
108
  wji fs mkdir <parentId> characters --nickname 角色
100
109
  wji dataset new <folderId> "Ada" -t character -d "a tired detective"
@@ -211,6 +220,25 @@ balanced default; **Seedance 2.0 Fast / MiniMax H3 Max** for drafts, tests and
211
220
  large batches. The ranking is a guide — quote the real number from
212
221
  `wji gen cost` / `wji canvas gen` before spending.
213
222
 
223
+ **Uncensored (無審核) models.** Use these only when the user asks for
224
+ uncensored output or a censored model refused the content. They carry the
225
+ `uncensored` tag in `wji models list --json`, and their names end in
226
+ `-uncensored`. The Wan 3.0 family has a Ref and a first/last-frame variant of
227
+ each tier, like the censored Wan 3.0.
228
+
229
+ | Job | Model (`name`) | Inputs |
230
+ | --- | --- | --- |
231
+ | Video, Ref | `wan-3.0-ref-uncensored`, `wan-3.0-prime-ref-uncensored` (480p–1080p); `wan-3.0-pro-ref-uncensored`, `wan-3.0-prime-pro-ref-uncensored` (1080p / 2k / 4k) | ≤ 10 images + ≤ 5 videos + ≤ 5 audios, freely mixed (video/audio ≤ 15 s total) |
232
+ | Video, first/last frame | `wan-3.0-uncensored`, `wan-3.0-prime-uncensored`, `wan-3.0-pro-uncensored`, `wan-3.0-prime-pro-uncensored` | text only, or start and/or end image; 2–30 s |
233
+ | Image → video | `wan-2.7-i2v-uncensored` (720p/1080p, 2–15 s; optional 1 driving audio for lip-sync via `--ref-audio`), `wan-2.2-i2v-uncensored` (cheapest; 480p/720p, 5 or 8 s, optional end frame) | start image |
234
+ | Animate a character | `wan-animate-uncensored` (`--set mode=animate\|replace`) | exactly 1 image + 1 video; output is as long as the video |
235
+ | Text → image | `z-image-uncensored` (long edge ≤ 1536), `z-image-pro-uncensored` (≤ 2560, `--set long_edge=`) | prompt + ratio |
236
+ | Edit an image | `qwen-image-edit-uncensored` | 1–3 images: the **first** is the image to edit |
237
+ | Face / head swap | `face-swap-uncensored`, `head-swap-uncensored` | exactly 2 images, **order matters**: 1st = target picture, 2nd = the face / head to put on it |
238
+ | Video upscale | `flashvsr-uncensored` (`--set resolution=720p\|1080p\|2k\|4k`) | 1 MP4 ≤ 120 s; billed per input second |
239
+
240
+ "Prime" variants are faster and cost more; "Pro" variants output 1080p–4K.
241
+
214
242
  ### Limits (per account)
215
243
 
216
244
  | Limit | Value | When exceeded |
@@ -248,7 +276,7 @@ headshot and sheet as image references and the voice as an audio reference to
248
276
  a Ref model.
249
277
 
250
278
  ```bash
251
- wji fs get <datasetId> --scope full --json | jq '{images: [.images[]? | {id, type}], icon, primaryAudio}'
279
+ wji fs get <datasetId> --json --detail full | jq '{images: [.images[]? | {id, type}], icon, primaryAudio}'
252
280
  wji fs set <datasetId> --add-images <headshot> <sheet> <voice> --icon <headshot> --set primaryAudio=<voice>
253
281
  ```
254
282
 
@@ -294,8 +322,12 @@ wji agent skill-add <agentId> system:image user:<skillId>
294
322
  wji agent skills <agentId>
295
323
  wji agent versions <agentId> # every edit auto-snapshots a version
296
324
  wji agent restore <agentId> <versionId>
325
+ wji agent update <agentId> --max-steps 80 # model calls per chat turn (default 40, max 200)
297
326
  ```
298
327
 
328
+ Versions, files, update and restore are creator-only — a 403 `無權操作此 Agent`
329
+ on someone else's agent is expected.
330
+
299
331
  Skill refs are `system:<key>` or `user:<skillId>` (a bare id means `user:`).
300
332
  System keys currently include `image`, `document`, `workspace-dataset`,
301
333
  `canvas` and `seedance-25` — confirm with `wji skill system`.
@@ -468,6 +500,8 @@ wji backreel set <postId> --project-status in_production
468
500
  wji backreel credit add <postId> --kind creative --name "Wayne" --role 導演
469
501
  wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
470
502
  wji backreel publish <postId> # unpublish takes it offline
503
+ wji backreel set <postId> --logline "一句話賣點" # always public, ≤200 chars
504
+ wji backreel creators --role investor # browse creators
471
505
  ```
472
506
 
473
507
  - Credits (max 30) are rewritten whole in order; `credit rm` goes by number.
@@ -549,6 +583,9 @@ wji org list && wji org use <orgId>
549
583
  - **Lists are paginated.** `--limit` is capped at 100; `--limit -1` returns at
550
584
  most 500 on most endpoints. Page with `--page` instead of assuming you got
551
585
  everything.
586
+ - **Lists are trimmed.** `--json` is the `standard` level; read `content`,
587
+ `form` or `settings` with `--detail full`, and never write back a `content`
588
+ that carries `truncated: true`.
552
589
  - **Report failures honestly.** A failed generation surfaces as a non-zero exit
553
590
  with the provider's message — pass it on rather than retrying silently.
554
591
 
@@ -10,7 +10,8 @@ Global flags (accepted before any subcommand):
10
10
  | `--org <orgId>` | organization context (`X-Org-Id`) |
11
11
  | `--no-org` | force personal context for this call |
12
12
  | `--locale <locale>` | response language: `cht`, `chs`, `en` |
13
- | `--json` | print the raw API response instead of tables |
13
+ | `--json` | print JSON instead of tables (`standard` detail unless `--detail` is given) |
14
+ | `--detail <level>` | `brief` / `standard` / `full` for list and get reads; `full` is the raw API response. Env `WJI_DETAIL` |
14
15
 
15
16
  Value conventions: `@file` reads a file, `-` reads stdin, `--set key=value`
16
17
  repeats and nests on dots, references are `assetId[:label[:weight]]`.
@@ -64,13 +65,15 @@ switch endpoint.
64
65
 
65
66
  | Command | API |
66
67
  | --- | --- |
67
- | `ws list [-k keyword] [-s all\|me\|shared\|favorite] [--page] [--limit]` | `GET /workspace/search` — `--limit` max 100, `-1` returns at most 500 |
68
- | `ws get [id]` | `GET /workspace/list/:id` |
68
+ | `ws list [-k keyword] [-s all\|me\|shared\|favorite] [--page] [--limit]` | `GET /workspace/search` — `--limit` max 100, `-1` returns at most 500; sends `detail` |
69
+ | `ws get [id]` | `GET /workspace/list/:id` — sends `detail` (`settings` and full members need `--detail full`) |
69
70
  | `ws use <id>` | stores the default workspace locally |
70
71
  | `ws create <name> [-d] [-r ratio] [-l lang] [-t type] [-a app...] [--no-apps]` | `POST /workspace/create` — without `-a` the workspace gets the default template `dataset datasetimagegeneration canvas`; `--no-apps` creates it empty (the API itself adds nothing) |
71
72
  | `ws update [id] [-n] [-d] [-r] [-l] [--set]` | `PUT /workspace/list/:id` |
72
73
  | `ws rm <id> [-y]` | `DELETE /workspace/list/:id` (owner only) |
73
74
  | `ws favorite <id> [--off]` | `PUT /workspace/list/:id/favorite` |
75
+ | `ws leave <id> [-y]` | `POST /workspace/list/:id/leave` — any member except the owner; coming back needs a new invite. Clears the profile default if it pointed there |
76
+ | `ws defaults [id] [--chat] [--image] [--video] [--storyboard] [--audio] [--image-resolution] [--video-resolution]` | no flags: shows `settings.defaultModels`. With flags: reads the workspace at `detail=full`, merges, `PUT /workspace/list/:id {settings}` (settings is replaced whole, so never send a partial `settings` via `ws update --set`). `none` clears one. Chat is stored as the model **name** (what conversations carry), media as the **nickname**; either spelling is accepted. The chat default is used for new conversations in the workspace |
74
77
  | `ws fs [id] [-a appId]` | `GET /workspace/list/:id/fs[/:appid]` |
75
78
  | `ws app list\|add <app> [-n]\|rm <appId> [-y]` | `POST/DELETE /workspace/list/:id/apps` |
76
79
  | `ws member list [id] [-r role]` | members from `GET /workspace/list/:id` (there is no member-list endpoint): name, email, role, user id, membership id |
@@ -96,10 +99,10 @@ Restriction from the backend: `Dataset`, `Draw`, `DatasetImageGeneration` and
96
99
 
97
100
  | Command | API |
98
101
  | --- | --- |
99
- | `fs root [key] [-d depth]` | `GET /fsnode/root[/:key]` — key defaults to your drive; pass a workspace id for its root |
100
- | `fs ls <id> [--limit] [--scope]` | `GET /fsnode/list/:id/children` |
101
- | `fs tree <id> [-d depth]` | `GET /fsnode/list/:id/tree` |
102
- | `fs get <id> [--scope]` | `GET /fsnode/list/:id` (comma-separated ids allowed) |
102
+ | `fs root [key] [-d depth]` | `GET /fsnode/root[/:key]` — key defaults to your drive; pass a workspace id for its root; sends `detail` |
103
+ | `fs ls <id> [--limit] [--scope]` | `GET /fsnode/list/:id/children` — sends `detail`; at `standard` each `content` is a summary |
104
+ | `fs tree <id> [-d depth]` | `GET /fsnode/list/:id/tree` — sends `detail` |
105
+ | `fs get <id> [--scope]` | `GET /fsnode/list/:id` (comma-separated ids allowed) — sends `detail`; `--detail full` for the whole `content` |
103
106
  | `fs path <id>` | `GET /fsnode/list/:id/path` |
104
107
  | `fs mkdir <parent> <name> [--nickname] [--content]` | `POST /fsnode/create` type=folder |
105
108
  | `fs touch <parent> <name> [--mimetype] [--asset] [--content]` | `POST /fsnode/create` type=file |
@@ -118,7 +121,7 @@ characters, nicknames at 200, and `fs batch` at 100 items per call.
118
121
  | Command | Notes |
119
122
  | --- | --- |
120
123
  | `dataset new <parent> <name> [-t type] [-d description] [--images] [--nickname]` | creates an fsnode with mimetype `workspace/dataset` |
121
- | `dataset list <folderId>` | dataset nodes in a folder |
124
+ | `dataset list <folderId>` | dataset nodes in a folder (sends `detail`) |
122
125
  | `dataset get <key>` | `GET /dataset/list/:key` (vector-store record) |
123
126
  | `dataset rm <key> [-y]` | `DELETE /dataset/list/:key` |
124
127
 
@@ -150,6 +153,11 @@ Model parameters worth knowing (`--set`, see `wji models get`):
150
153
  `gpt-image-2.5-*` `background=transparent` returns a PNG with alpha;
151
154
  `bytedance-seedream-5-pro` `layer_decomposition=true` splits one reference
152
155
  image into separate PNG layer assets (exactly 1 reference, `size=1K|2K`).
156
+ `face-swap-uncensored` / `head-swap-uncensored` take exactly 2 references in
157
+ order (target picture, then the face / head); `qwen-image-edit-uncensored`
158
+ edits the first reference; `z-image-*-uncensored` `long_edge=<px>`;
159
+ `wan-animate-uncensored` `mode=animate|replace`; `flashvsr-uncensored`
160
+ `resolution=720p|1080p|2k|4k`.
153
161
 
154
162
  Uploads of `.html`, `.xhtml`, `.svg`, `.js` and `.xml` are stored as
155
163
  `application/octet-stream` with `Content-Disposition: attachment`.
@@ -171,7 +179,7 @@ updates live.
171
179
  | `canvas copy [nodeIds...] --to <canvasIds...> [-c] [--selected] [--at x,y] [--same-position] [-y]` | the canvas's own copy-paste: new nodes (no position, `generate.historyId/pendingAssetId` dropped, finished asset shared via `file`), edges among them re-created oldest first so `@image1…` numbering holds, color tags kept and a missing label carried to the target. Edges from nodes outside the copy are left out. Default placement: right of what is already on the target |
172
180
  | `canvas nodes [-c] [-k kind] [-n regex] [--tag tags…\|--untagged]` | `GET /fsnode/list/:canvasId/children?limit=-1` + `POST /assets/check-status`. `-n` matches the node name as a case-insensitive regex (`/re/flags` to set flags yourself). `--tag` takes a label (`C01`, case-insensitive), a color (`red`) or a color id (`rose`); several values match any. Filters combine (AND) |
173
181
  | `canvas tags [-c] [-a]` | the canvas's color tags: color, label, node count (`-a` also unused, unlabelled colors) |
174
- | `canvas get <nodeId> [-c]` | same load; one node with references (`@image1 ← id`) and frame slots |
182
+ | `canvas get <nodeId> [-c]` | same load; one node with references (`@image1 ← id`) and frame slots. Canvas commands always load the full canvas; `--detail` trims only the JSON they print (`standard` clips `text` / `generate.prompt` and drops `generate.values`) |
175
183
  | `canvas edges [-c] [--node]` | the `canvas/edge` children |
176
184
  | `canvas add <kind> [-c] [-n] [-t\|--text-file] [--plain] [--size w,h] [--asset] [--dataset] [-p\|--prompt-file] [-m] [-r] [--resolution] [-d] [--set] [--from ids…] [--at x,y]` | `POST /fsnode/create` (`canvas/node/<kind>`), then one `canvas/edge` per `--from`. Kinds: text, title, comment, image, video, audio, dataset. `add dataset --dataset <fsnodeId>` writes `content {datasetId, datasetName, datasetType, datasetParentName, description}` from the dataset node (must be `workspace/dataset`); it takes no `--from`. Text kinds are Markdown on the web; `--plain` turns every single line break into a Markdown hard break (`" \n"`, fenced code untouched), and without it the CLI warns when a single break would be joined. `--size` writes `content.width/height` |
177
185
  | `canvas set <nodeId> [-c] [-n] [-t\|--text-file] [--plain] [--move x,y] [--size w,h] [-p] [-m] [-r] [--resolution] [-d] [--set]` | read → merge → `PATCH /fsnode/list/:id` with `contentPatch` of only the top-level keys that changed, so a concurrent browser edit of another key survives (falls back to a whole-content write on a backend without `contentPatch`) |
@@ -269,16 +277,20 @@ the CLI rejects both up front.
269
277
  | --- | --- |
270
278
  | `backreel list [--all] [-k] [--page] [--limit]` | `GET /backreel/post/search/my` (`--all`: `GET /backreel/post/search`) |
271
279
  | `backreel get <postId>` | `GET /backreel/post/list/:id` |
272
- | `backreel create <title> [--kind showcase\|crowdfunding] [--category] [--project-status] [--tags a,b] [-d text\|@file] [--cover assetId] [--cover-portrait] [--trailer] [--body json]` | `POST /backreel/post/create` (always a draft) |
280
+ | `backreel create <title> [--kind showcase\|crowdfunding] [--logline] [--category] [--project-status] [--tags a,b] [-d text\|@file] [--cover assetId] [--cover-portrait] [--trailer] [--body json]` | `POST /backreel/post/create` (always a draft) |
273
281
  | `backreel set <postId> [--title] [same flags]` | `PUT /backreel/post/list/:id` with only the given fields |
274
282
  | `backreel publish\|unpublish <postId>` | `POST /backreel/post/list/:id/publish\|unpublish` (publish needs a cover, media or an episode) |
275
283
  | `backreel rm <postId> [-y]` | `DELETE /backreel/post/list/:id` |
284
+ | `backreel creators [-k] [--role kind] [--verified] [--no-featured] [--page] [--limit ≤48]` | `GET /backreel/creator/list` — editor-picked `featured` plus a heat-ranked page (recent likes, favorites ×2); creators with no recent activity are not listed. `--role`: investor, production_company, brand, distributor, sales_agent |
276
285
  | `backreel credit list <postId>` | the post's `credits[]`, numbered |
277
286
  | `backreel credit add <postId> --role [--name] [--kind cast\|crew\|creative] [--npc] [--user] [--link] [--job <type>]` | read, append, `PUT {credits}`; `--job` then appends to the NPC's `jobs` (own NPCs only, checked first) |
278
287
  | `backreel credit rm <postId> <n...> [-y]` | read, drop, `PUT {credits}` |
279
288
 
280
289
  Project status: planning, pre_production, in_production, post_production,
281
- completed. `-d` plain text becomes `<p>` paragraphs; HTML passes through and
290
+ completed. `--logline` is a one-line hook (≤200 chars, HTML stripped, `""`
291
+ clears it) shown large on the post page and **always public**, even when the
292
+ description is gated. The post detail no longer carries playable URLs for VOD
293
+ episodes — playback is gated per episode on the web. `-d` plain text becomes `<p>` paragraphs; HTML passes through and
282
294
  is sanitized server-side. Admin routes (`/api/backreel/*`) reject `sk_` keys.
283
295
 
284
296
  ## gen
@@ -320,7 +332,7 @@ parameters are range-checked against the model's `min`/`max`.
320
332
  ## history
321
333
 
322
334
  `history list [-w] [--mode image|video|audio] [--fsnode] [--scope] [--scope-id] [--tags] [--bookmarked]` →
323
- `GET /ai/assets/history/search`
335
+ `GET /ai/assets/history/search` (sends `detail`; `standard` clips `prompt` and summarises `form`)
324
336
 
325
337
  `history get <id> [--wait] [--timeout] [--interval]` ·
326
338
  `history bookmark <id> [--off]` · `history rm <id> [-y]` ·
@@ -329,12 +341,15 @@ parameters are range-checked against the model's `min`/`max`.
329
341
  ## models
330
342
 
331
343
  `models list [-f feature...] [-w] [-k keyword] [--all]` →
332
- `GET /ai/model/list/:features`
344
+ `GET /ai/model/list/:features` (sends `detail`; at `standard` the `form` keeps
345
+ only ratios, batch, reference caps and `other` options — `--detail full` for the
346
+ whole schema)
333
347
 
334
348
  The endpoint matches with `features.some(...)`, so an empty feature list returns
335
349
  nothing; with no `-f` the CLI asks for every feature instead.
336
350
 
337
- The response is a curated view — no `id`, `rate`, `tags` or `protocol` — and it
351
+ The response is a curated view — no `id`, `rate` or `protocol` (`tags` is
352
+ included; uncensored models carry `uncensored`) — and it
338
353
  is de-duplicated by **`nickname`**, which is therefore the unique selector.
339
354
  `name` is not unique (`seedance-2.0` exists twice under different providers).
340
355
  Pass the `MODEL` column (the nickname) to `-m`.
@@ -352,7 +367,7 @@ Features: `text`, `image`, `video`, `audio`, `tts`, `web`, `thinking`,
352
367
  ## agent
353
368
 
354
369
  `agent list` (public marketplace) · `agent mine` · `agent get <id>` ·
355
- `agent create <name> -d [-i instructions] [-m model] [--visibility] [-c] [-t] [--greeting]` ·
370
+ `agent create <name> -d [-i instructions] [-m model] [--visibility] [-c] [-t] [--greeting] [--max-steps n]` ·
356
371
  `agent update <id> …` · `agent rm <id> [-y]` · `agent files <id>` ·
357
372
  `agent categories [--mine]` · `agent tags [--mine]` · `agent recommend`
358
373
 
@@ -371,6 +386,17 @@ takes `--skill <ref...>`.
371
386
  Visibility: `private`, `unlist`, `public`. Instructions are only returned to
372
387
  the agent's creator.
373
388
 
389
+ Ownership (backend since Oct 2026): a private agent is readable only by its
390
+ creator; `update`, `rm`, `files`, `versions` and `restore` are creator-only on
391
+ every agent (403 `無權操作此 Agent` otherwise — not a bug). `agent create`
392
+ needs the account's agent-create permission (without it: 400 `您沒有此功能的訪問權限`). `--set` cannot change `creator` or
393
+ `organization`, and non-admins cannot set `key`, `isSystem`, `isHighligted`,
394
+ `recommendTitle`, `weight` or `store` — the server drops them silently.
395
+
396
+ `--max-steps n` caps model calls per chat turn (1–200; parallel tool calls in
397
+ one step count once). Unset means the site default, 40. `update --max-steps
398
+ default` clears it.
399
+
374
400
  Field caps: name ≤200, model ≤100, description and greeting ≤5,000,
375
401
  instructions ≤200,000 characters.
376
402
 
@@ -467,6 +493,12 @@ Pass `null` to clear a nullable field. Password minimum is 6 characters.
467
493
  asset carrying `error` is also a failure.
468
494
  - Some GETs are cached per user for 10s, keyed on the full URL including the
469
495
  query string.
496
+ - Workspace, fsnode, model and history reads accept `?detail=brief|standard|full`.
497
+ The API defaults to `full` (the website relies on it); the CLI sends the
498
+ `--detail` value, or `standard` under `--json`. `standard` clips long text to
499
+ 200 chars, turns media into `{id, url, thumbnail, status, …}` and reduces an
500
+ fsnode `content` to a summary carrying `truncated: true`; never PATCH such a
501
+ summary back.
470
502
  - Paginated endpoints return `{ data, pagination: { total, page, limit, next,
471
503
  hasNext, hasPrev, totalPages } }`. An explicit `limit` is capped at **100**.
472
504
  `limit: -1` returns everything only on endpoints that opt in (`fs ls` does);