@wji.ai/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,7 +165,9 @@ wji canvas gen <node> --yes --wait # spends credits
165
165
  ```
166
166
 
167
167
  Changes appear live in the open canvas. Pass `--canvas <id>` to work on a
168
- canvas that is not linked (`wji canvas list`).
168
+ canvas that is not linked (`wji canvas list`); `wji canvas create <name>` adds
169
+ an empty canvas, `wji canvas add image --asset <id>` shows an existing asset
170
+ and `wji canvas add dataset --dataset <id>` places a dataset card.
169
171
 
170
172
  ## Skills
171
173
 
@@ -240,3 +242,10 @@ cp -r "$(npm root -g)/@wji.ai/cli/skill/wji" ~/.claude/skills/wji
240
242
 
241
243
  From a source checkout: `cp -r skill/wji ~/.claude/skills/wji`. Re-copy after
242
244
  upgrading the CLI so the skill matches the commands.
245
+
246
+ `skill/short-drama/` is a second skill built on top of it: an AI short-drama
247
+ pipeline (outline → characters → scenes/props → script → storyboard) whose
248
+ stages are checked by `skill/short-drama/scripts/short-drama.mjs validate`,
249
+ with the data stored as workspace datasets and the work laid out on canvases.
250
+ Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
251
+ next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
@@ -23,7 +23,8 @@ const NODE_PREFIX = 'canvas/node/';
23
23
  const EDGE_MIMETYPE = 'canvas/edge';
24
24
  const CANVAS_MIMETYPE = 'workspace-v2/canvas';
25
25
  const CANVASES_MIMETYPE = 'workspace-v2/canvases';
26
- const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio'];
26
+ const DATASET_MIMETYPE = 'workspace/dataset';
27
+ const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio', 'dataset'];
27
28
  const MEDIA_KINDS = new Set(['image', 'video', 'audio']);
28
29
  const TEXT_KINDS = new Set(['text', 'title', 'comment']);
29
30
  /** Default names the canvas UI shows localized. */
@@ -34,6 +35,12 @@ const DEFAULT_NAMES = {
34
35
  image: '图片',
35
36
  video: '视频',
36
37
  audio: '音频',
38
+ dataset: '数据集',
39
+ };
40
+ /** What the web writes into a brand-new canvas folder (CanvasAppFileSystem.createCanvas). */
41
+ const INITIAL_CANVAS_CONTENT = {
42
+ data: { viewport: { x: 0, y: 0, zoom: 1 }, backgroundMode: 'dots' },
43
+ metadata: {},
37
44
  };
38
45
  /** The canvas lays media out on a 250px short side; text nodes are wider. */
39
46
  const MEDIA_BASE = 250;
@@ -105,6 +112,19 @@ async function loadCanvas(client, canvasId) {
105
112
  assets,
106
113
  };
107
114
  }
115
+ /** The `canvases` folder of every InfiniteCanvas app in a workspace. */
116
+ async function canvasesFolders(client, wsId) {
117
+ const ws = await client.get(`/workspace/list/${wsId}`);
118
+ const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
119
+ const out = [];
120
+ for (const app of apps) {
121
+ const tree = await client.get(`/workspace/list/${wsId}/fs/${app.id}`);
122
+ const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
123
+ if (folder)
124
+ out.push({ appId: String(app.id), folderId: String(folder.id) });
125
+ }
126
+ return out;
127
+ }
108
128
  function requireNode(canvas, id) {
109
129
  const node = canvas.byId.get(id);
110
130
  if (!node)
@@ -144,6 +164,9 @@ function describe(canvas, node) {
144
164
  assetId: node.file,
145
165
  url: asset?.url || undefined,
146
166
  assetType: asset?.type,
167
+ dataset: node.kind === 'dataset' && node.content.datasetId
168
+ ? { id: String(node.content.datasetId), name: node.content.datasetName, type: node.content.datasetType, folder: node.content.datasetParentName }
169
+ : undefined,
147
170
  status: generate?.historyId ? (pending ? 'generating' : asset?.error ? 'failed' : asset?.url ? 'done' : 'pending') : undefined,
148
171
  error: asset?.error?.message || asset?.error || undefined,
149
172
  generate,
@@ -152,12 +175,16 @@ function describe(canvas, node) {
152
175
  };
153
176
  }
154
177
  // ── models ──────────────────────────────────────────────────────────────────
178
+ /** An audio node is fed by either an `audio` or a `tts` model — the backend tags voice models `tts`. */
179
+ const KIND_FEATURES = { audio: ['audio', 'tts'] };
155
180
  async function findModel(client, kind, name, workspaceId) {
156
- const rows = await fetchModels(client, { features: [kind], workspace: workspaceId });
181
+ const features = KIND_FEATURES[kind] ?? [kind];
182
+ const rows = (await Promise.all(features.map((f) => fetchModels(client, { features: [f], workspace: workspaceId })))).flat();
157
183
  const usable = rows.filter((m) => m.enabled !== false);
158
184
  const model = usable.find((m) => m.nickname === name) ?? usable.find((m) => m.name === name);
159
185
  if (!model) {
160
- throw new Error(`No ${kind} model "${name}". See \`wji models list --feature ${kind}\`.`);
186
+ const hint = features.map((f) => `--feature ${f}`).join(' / ');
187
+ throw new Error(`No ${kind} model "${name}". See \`wji models list ${hint}\`.`);
161
188
  }
162
189
  return model;
163
190
  }
@@ -243,6 +270,8 @@ function applyModelDefaults(model, kind, generate, opts) {
243
270
  // ── writes ──────────────────────────────────────────────────────────────────
244
271
  /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
245
272
  function estimateSize(canvas, node) {
273
+ if (node.kind === 'dataset')
274
+ return { width: 480, height: 220 };
246
275
  if (!MEDIA_KINDS.has(node.kind))
247
276
  return { width: 320, height: 200 };
248
277
  const ratioText = node.content.mode === 'generate' ? node.content.generate?.ratio : undefined;
@@ -335,6 +364,8 @@ async function patchNode(client, node, body) {
335
364
  function summary(n) {
336
365
  if (n.text !== undefined)
337
366
  return String(n.text).replace(/\s+/g, ' ');
367
+ if (n.dataset)
368
+ return [n.dataset.type, n.dataset.name, color.dim(n.dataset.id)].filter(Boolean).join(' ');
338
369
  if (n.mode === 'generate') {
339
370
  const g = n.generate || {};
340
371
  const state = n.status === 'done' ? color.green('done') : n.status === 'failed' ? color.red('failed') : n.status ? color.yellow(n.status) : '';
@@ -361,6 +392,7 @@ function printNode(n) {
361
392
  ['name', n.name],
362
393
  ['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
363
394
  ['text', n.text],
395
+ ['dataset', n.dataset && `${n.dataset.type || 'dataset'} "${n.dataset.name || ''}" ${color.dim(n.dataset.id)}${n.dataset.folder ? ` in ${n.dataset.folder}` : ''}`],
364
396
  ['asset', n.assetId],
365
397
  ['url', n.url],
366
398
  ['status', n.status],
@@ -603,17 +635,11 @@ export function canvasCommand(globals) {
603
635
  const c = client();
604
636
  const linked = opts.workspace ? null : await linkedState(c);
605
637
  const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
606
- const ws = await c.get(`/workspace/list/${wsId}`);
607
- const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
608
638
  const rows = [];
609
- for (const app of apps) {
610
- const tree = await c.get(`/workspace/list/${wsId}/fs/${app.id}`);
611
- const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
612
- if (!folder)
613
- continue;
614
- const canvases = toList(await c.getFresh(`/fsnode/list/${folder.id}/children`, { limit: -1 }));
639
+ for (const { appId, folderId } of await canvasesFolders(c, wsId)) {
640
+ const canvases = toList(await c.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
615
641
  for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
616
- rows.push({ id: it.id, name: it.name, app: app.id, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
642
+ rows.push({ id: it.id, name: it.name, app: appId, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
617
643
  }
618
644
  }
619
645
  render(rows, () => table(rows, [
@@ -623,6 +649,36 @@ export function canvasCommand(globals) {
623
649
  { header: 'app', value: (r) => r.app },
624
650
  ]));
625
651
  });
652
+ canvas
653
+ .command('create <name>')
654
+ .description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
655
+ .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
656
+ .option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
657
+ .action(async (name, opts) => {
658
+ const c = client();
659
+ const linked = opts.workspace ? null : await linkedState(c);
660
+ const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
661
+ const folders = await canvasesFolders(c, wsId);
662
+ if (!folders.length)
663
+ throw new Error(`Workspace ${wsId} has no InfiniteCanvas app. Add one with \`wji ws app add canvas\`.`);
664
+ const target = opts.app ? folders.find((f) => f.appId === opts.app) : folders[0];
665
+ if (!target)
666
+ throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
667
+ if (!opts.app && folders.length > 1)
668
+ warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
669
+ const raw = await c.post('/fsnode/create', {
670
+ name,
671
+ type: 'folder',
672
+ mimetype: CANVAS_MIMETYPE,
673
+ parent: target.folderId,
674
+ content: INITIAL_CANVAS_CONTENT,
675
+ });
676
+ const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId };
677
+ render(out, () => {
678
+ success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
679
+ info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
680
+ });
681
+ });
626
682
  canvas
627
683
  .command('nodes')
628
684
  .description('Nodes with references and a content summary (* = generate node)')
@@ -671,6 +727,7 @@ export function canvasCommand(globals) {
671
727
  .option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
672
728
  .option('--text-file <path>', 'read the text from a file')
673
729
  .option('--asset <assetId>', 'show an existing asset (display node)')
730
+ .option('--dataset <fsnodeId>', 'for `add dataset`: the workspace dataset node to show on the canvas')
674
731
  .option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
675
732
  .option('--at <x,y>', 'position; default is right of the --from nodes')).action(async (kind, opts) => {
676
733
  if (!CREATABLE_KINDS.includes(kind)) {
@@ -694,6 +751,28 @@ export function canvasCommand(globals) {
694
751
  throw new Error(`${kind} nodes need --text`);
695
752
  content.text = text;
696
753
  }
754
+ else if (kind === 'dataset') {
755
+ // A dataset node is a card bound to a workspace dataset (CanvasAppFileSystem.createDatasetNodeFromDataset).
756
+ if (!opts.dataset)
757
+ throw new Error('dataset nodes need --dataset <fsnodeId>');
758
+ if (from.length)
759
+ throw new Error('dataset nodes take no references; connect them out to a generate node instead');
760
+ const ds = await c.getFresh(`/fsnode/list/${opts.dataset}`);
761
+ if (ds?.mimetype !== DATASET_MIMETYPE) {
762
+ throw new Error(`${opts.dataset} is not a dataset (mimetype ${ds?.mimetype || 'none'})`);
763
+ }
764
+ const parent = ds.parent ? await c.get(`/fsnode/list/${idOf(ds.parent)}`).catch(() => null) : null;
765
+ const dsName = ds.nickname || ds.name || DEFAULT_NAMES.dataset;
766
+ if (!opts.name)
767
+ body.name = dsName;
768
+ Object.assign(content, {
769
+ datasetId: String(ds.id),
770
+ datasetName: dsName,
771
+ datasetType: String(ds.content?.type || ''),
772
+ datasetParentName: parent ? parent.nickname || parent.name || '' : '',
773
+ description: String(ds.content?.description || ''),
774
+ });
775
+ }
697
776
  else {
698
777
  const { patch, explicit } = await generateOptions(opts);
699
778
  const wantsGenerate = Object.keys(patch).length > 0;
@@ -3,6 +3,15 @@ import { WjiClient } from '../core/client.js';
3
3
  import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
4
4
  import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
5
5
  import { printTree } from './workspace.js';
6
+ /** Nested plain objects merge; arrays and scalars are replaced. */
7
+ function deepMerge(base, patch) {
8
+ const out = { ...base };
9
+ for (const [key, value] of Object.entries(patch)) {
10
+ const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
11
+ out[key] = isObject(value) && isObject(out[key]) ? deepMerge(out[key], value) : value;
12
+ }
13
+ return out;
14
+ }
6
15
  export function fsCommand(globals) {
7
16
  const fs = new Command('fs')
8
17
  .aliases(['fsnode'])
@@ -106,7 +115,9 @@ export function fsCommand(globals) {
106
115
  .option('--icon <assetId>', 'set the icon asset')
107
116
  .option('--asset <assetId>', 'set the attached file asset (use "null" to clear)')
108
117
  .option('--sort <n>', 'sort order')
109
- .option('--set <key=value...>', 'arbitrary field, dotted keys allowed')
118
+ .option('--set <key=value...>', 'arbitrary top-level field, dotted keys allowed (content.* would REPLACE content — use --merge)')
119
+ .option('--merge <key=value...>', 'set fields inside content, merged with the current content; values accept @file')
120
+ .option('--add-images <assetId...>', 'append to the image asset list instead of replacing it')
110
121
  .action(async (id, opts) => {
111
122
  const client = new WjiClient(globals());
112
123
  const body = {
@@ -120,6 +131,24 @@ export function fsCommand(globals) {
120
131
  sort: opts.sort != null ? Number(opts.sort) : undefined,
121
132
  ...parseKeyValues(opts.set),
122
133
  };
134
+ if (opts.merge?.length || opts.addImages?.length) {
135
+ // PATCH replaces `content` and `images` whole, so read first and merge.
136
+ const node = await client.getFresh(`/fsnode/list/${id}`);
137
+ if (opts.merge?.length) {
138
+ const pairs = [];
139
+ for (const pair of opts.merge) {
140
+ const index = pair.indexOf('=');
141
+ if (index === -1)
142
+ throw new Error(`Expected key=value, got "${pair}"`);
143
+ pairs.push(`${pair.slice(0, index)}=${await readValue(pair.slice(index + 1))}`);
144
+ }
145
+ body.content = deepMerge(body.content ?? node.content ?? {}, parseKeyValues(pairs));
146
+ }
147
+ if (opts.addImages?.length) {
148
+ const current = (opts.images ?? node.images ?? []).map((it) => (typeof it === 'string' ? it : it.id));
149
+ body.images = [...current, ...opts.addImages.filter((it) => !current.includes(it))];
150
+ }
151
+ }
123
152
  const updated = await client.patch(`/fsnode/list/${id}`, body);
124
153
  render(updated, (n) => success(`Updated ${n?.id || id}`));
125
154
  });
@@ -2,7 +2,6 @@ import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
3
  import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
4
4
  import { color, detail, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
5
- const PENDING = 0;
6
5
  const READY = 1;
7
6
  const CANCELLED = -1;
8
7
  /** `--ref abc123`, `--ref abc123:hero`, `--ref abc123:hero:0.6` */
@@ -81,22 +80,9 @@ export function reportHistory(history, assets) {
81
80
  ]);
82
81
  }
83
82
  async function submit(client, kind, body, opts) {
84
- const path = opts.history
85
- ? `/ai/assets/history/${kind}/generations`
86
- : `/ai/assets/${kind}/generations`;
87
- const result = await client.post(path, body);
88
- if (!opts.history) {
89
- // Raw mode returns Asset[] directly; there is no history to poll.
90
- return render(result, (assets) => {
91
- if (!Array.isArray(assets))
92
- return detail(Object.entries(assets || {}));
93
- table(assets, [
94
- { header: 'assetId', value: (a) => a.id },
95
- { header: 'state', value: (a) => (a.status === PENDING ? 'pending' : 'ready') },
96
- { header: 'url', value: (a) => a.url },
97
- ]);
98
- });
99
- }
83
+ // Always the history endpoint: the raw one silently drops fsnode, scope,
84
+ // scopeId and tags, and returns no id to poll with.
85
+ const result = await client.post(`/ai/assets/history/${kind}/generations`, body);
100
86
  if (!opts.wait) {
101
87
  if (isJsonMode())
102
88
  return printJson(result);
@@ -121,14 +107,13 @@ function withCommonOptions(cmd) {
121
107
  .option('-p, --prompt <text>', 'prompt text (@file / - for stdin)')
122
108
  .option('-n, --n <count>', 'number of outputs', '1')
123
109
  .option('-w, --workspace <id>', 'workspace to bill and file the result under')
124
- .option('--fsnode <id>', 'attach the result to an fsnode')
110
+ .option('--fsnode <id>', 'file the history entry under an fsnode (does not add the asset to its images)')
125
111
  .option('--scope <scope>', 'history scope, e.g. workspace | canvas')
126
112
  .option('--scope-id <id>', 'history scope id')
127
113
  .option('-r, --ratio <ratio>', 'aspect ratio, e.g. 16:9')
128
114
  .option('--tags <tag...>', 'tags for the history entry')
129
115
  .option('--values <json>', 'model-specific form values (@file / - supported)')
130
116
  .option('--set <key=value...>', 'model-specific form values as key=value')
131
- .option('--no-history', 'call the raw endpoint and return assets instead of a history')
132
117
  .option('--wait', 'block until the task finishes')
133
118
  .option('--timeout <seconds>', 'wait timeout', '900')
134
119
  .option('--interval <seconds>', 'poll interval', '5');
@@ -16,6 +16,8 @@ const APPS = {
16
16
  batch: 'Batch',
17
17
  digitalhost: 'DigitalHost',
18
18
  };
19
+ /** Apps every new workspace gets unless `-a` or `--no-apps` says otherwise — what the drama and canvas workflows need. */
20
+ const DEFAULT_APPS = ['dataset', 'datasetimagegeneration', 'canvas'];
19
21
  const APP_TYPES = {
20
22
  Dataset: 'DATASET',
21
23
  DatasetImageGeneration: 'DATASET_IMAGE_GENERATION',
@@ -103,10 +105,12 @@ export function workspaceCommand(globals) {
103
105
  .option('-r, --aspect-ratio <ratio>', '16:9 | 9:16 | 21:9', '16:9')
104
106
  .option('-l, --language <lang>', 'dialogue language', 'auto')
105
107
  .option('-t, --type <type>', 'workspaceType')
106
- .option('-a, --app <app...>', 'apps to create, e.g. dataset files canvas')
108
+ .option('-a, --app <app...>', `apps to create (default: ${DEFAULT_APPS.join(' ')})`)
109
+ .option('--no-apps', 'create an empty workspace with no apps')
107
110
  .action(async (name, opts) => {
108
111
  const client = new WjiClient(globals());
109
- const apps = (opts.app || []).map((token) => {
112
+ const tokens = opts.app?.length ? opts.app : opts.apps ? DEFAULT_APPS : [];
113
+ const apps = tokens.map((token) => {
110
114
  const { app, type } = resolveApp(token);
111
115
  return { app, type, name: app };
112
116
  });
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
4
  import { ApiError } from './core/client.js';
4
5
  import { color, setJsonMode } from './core/output.js';
@@ -15,7 +16,9 @@ import { profileCommand } from './commands/profile.js';
15
16
  import { presetCommand } from './commands/preset.js';
16
17
  import { skillCommand } from './commands/skill.js';
17
18
  import { canvasCommand } from './commands/canvas.js';
18
- const VERSION = '0.1.0';
19
+ // Read from package.json so `wji --version` cannot drift from the published
20
+ // version — the skill compares the two to detect a stale install.
21
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
19
22
  const program = new Command();
20
23
  program
21
24
  .name('wji')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",