@wji.ai/cli 0.3.0 → 0.4.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
@@ -107,6 +107,7 @@ wji --no-org ws list # one-off personal call
107
107
  | `wji agent` | browse and manage AI agents |
108
108
  | `wji preset` | prompt presets: yours, public, workspace; fork and copy |
109
109
  | `wji skill` | agent skills: system list, push/pull SKILL.md bundles, import zip, update, rm |
110
+ | `wji crew` | install crew packs (skills + agent seats) into Claude Code, Codex, OpenClaw, or export for Grok bot |
110
111
  | `wji profile` | account profile, password, wallet, login history |
111
112
 
112
113
  Run `wji <group> --help` for the full option list.
@@ -161,6 +162,7 @@ wji canvas status # what is linked and selecte
161
162
  wji canvas add video --from <img1> <img2> -m seedance-2.0-fast-ref -r 16:9 -d 8 \
162
163
  -p "@image1 paddles across the lake while @image2 waits on the shore"
163
164
  wji canvas set <node> --resolution 720p --set seed=42
165
+ wji canvas add text --text-file prompt.txt --plain --size 600,900 # text nodes are Markdown; --plain keeps line breaks
164
166
  wji canvas gen <node> --yes --wait # spends credits
165
167
  ```
166
168
 
@@ -249,3 +251,29 @@ stages are checked by `skill/short-drama/scripts/short-drama.mjs validate`,
249
251
  with the data stored as workspace datasets and the work laid out on canvases.
250
252
  Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
251
253
  next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
254
+
255
+ ## Crews (`wji crew`)
256
+
257
+ A **crew pack** is a repository of Agent Skills plus agent seats (`crew.json` +
258
+ `agents/<seat>.md`) — the multi-agent form of a pipeline. `wji crew install`
259
+ puts one where each host discovers it, writing that host's agent format from
260
+ the same persona files:
261
+
262
+ ```bash
263
+ wji crew install short-drama --for claude # ~/.claude/skills + ~/.claude/agents/<seat>.md
264
+ wji crew install short-drama --for codex # ~/.agents/skills + ~/.codex/agents/<seat>.toml
265
+ wji crew install short-drama --for openclaw # ~/.agents/skills + ~/.openclaw/workspaces/<crew>/<seat>/{AGENTS,SOUL}.md
266
+ wji crew install short-drama --for grok # ./<crew>-export.zip in the Grok bot import layout
267
+ wji crew install short-drama --for all --dry-run # show the plan only
268
+ wji crew install --from ../short-drama-crew --for claude --link # symlink a local checkout
269
+ wji crew status # what was written, and whether it is still there
270
+ wji crew uninstall short-drama-crew --for codex # remove exactly the recorded paths
271
+ wji crew check ./short-drama-crew # validate a pack before publishing it
272
+ ```
273
+
274
+ Known crews: `short-drama` ([ultra-instinct-ai/short-drama-crew](https://github.com/ultra-instinct-ai/short-drama-crew)).
275
+ `--from` also takes a `.zip`, a `.tar.gz` or a URL; `--ref` picks a branch or tag;
276
+ `--project <dir>` installs into a project instead of your home directory. The
277
+ installer never edits `~/.openclaw/openclaw.json` — it writes a JSON5 fragment
278
+ to merge — and it warns that the Codex `wji-operator` seat needs
279
+ `[sandbox_workspace_write] network_access = true`.
@@ -46,6 +46,22 @@ const INITIAL_CANVAS_CONTENT = {
46
46
  const MEDIA_BASE = 250;
47
47
  const PLACE_GAP = 80;
48
48
  const PENDING = 0;
49
+ /**
50
+ * The canvas's color tags (nodeColorTags.ts on the web). A node stores only
51
+ * `content.colorTag {colorId}`; the label is shared per color on the canvas
52
+ * folder as `content.colorTagNames {<colorId>: label}`. `alias` is the plain
53
+ * color name the tag reads as, so `--tag red` finds `rose`.
54
+ */
55
+ const COLOR_TAGS = [
56
+ { id: 'rose', alias: 'red', hex: '#f43f5e' },
57
+ { id: 'orange', alias: 'orange', hex: '#f97316' },
58
+ { id: 'amber', alias: 'yellow', hex: '#eab308' },
59
+ { id: 'lime', alias: 'green', hex: '#84cc16' },
60
+ { id: 'teal', alias: 'teal', hex: '#14b8a6' },
61
+ { id: 'sky', alias: 'blue', hex: '#0ea5e9' },
62
+ { id: 'violet', alias: 'purple', hex: '#8b5cf6' },
63
+ { id: 'fuchsia', alias: 'pink', hex: '#d946ef' },
64
+ ];
49
65
  const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
50
66
  function toList(result) {
51
67
  return Array.isArray(result) ? result : result?.data || [];
@@ -110,8 +126,62 @@ async function loadCanvas(client, canvasId) {
110
126
  edges,
111
127
  byId: new Map(nodes.map((n) => [n.id, n])),
112
128
  assets,
129
+ tagNames: parseTagNames(folder.content?.colorTagNames),
113
130
  };
114
131
  }
132
+ // ── color tags ──────────────────────────────────────────────────────────────
133
+ function parseTagNames(raw) {
134
+ const out = {};
135
+ if (!raw || typeof raw !== 'object')
136
+ return out;
137
+ for (const { id } of COLOR_TAGS) {
138
+ const value = raw[id];
139
+ if (typeof value === 'string' && value.trim())
140
+ out[id] = value.trim();
141
+ }
142
+ return out;
143
+ }
144
+ /** The node's tag with the canvas label (never the node's own legacy `name`, as on the web). */
145
+ function tagOf(canvas, node) {
146
+ const colorId = node.content.colorTag?.colorId;
147
+ const tag = COLOR_TAGS.find((t) => t.id === colorId);
148
+ if (!tag)
149
+ return undefined;
150
+ return { color: tag.id, alias: tag.alias, hex: tag.hex, label: canvas.tagNames[tag.id] };
151
+ }
152
+ /**
153
+ * Colors a `--tag` value means: a color id (`rose`), its plain name (`red`),
154
+ * or a label (`C01`, case-insensitive), so several colors can match one label.
155
+ */
156
+ function resolveTagFilter(canvas, values) {
157
+ const colors = new Set();
158
+ for (const value of values) {
159
+ const v = value.trim().toLowerCase();
160
+ const matched = COLOR_TAGS.filter((t) => t.id === v || t.alias === v || canvas.tagNames[t.id]?.toLowerCase() === v);
161
+ if (!matched.length) {
162
+ const known = COLOR_TAGS.map((t) => (canvas.tagNames[t.id] ? `${t.alias} "${canvas.tagNames[t.id]}"` : t.alias));
163
+ throw new Error(`No color tag "${value}" on canvas "${canvas.name}". Tags: ${known.join(', ')}`);
164
+ }
165
+ for (const t of matched)
166
+ colors.add(t.id);
167
+ }
168
+ return colors;
169
+ }
170
+ /** `C0[1-3]` is case-insensitive; `/C0[1-3]$/` keeps exactly the flags given. */
171
+ function parseRegex(value, flag) {
172
+ const slashed = /^\/(.*)\/([a-z]*)$/s.exec(value);
173
+ try {
174
+ return slashed ? new RegExp(slashed[1], slashed[2].replace(/[gy]/g, '')) : new RegExp(value, 'i');
175
+ }
176
+ catch (err) {
177
+ throw new Error(`${flag}: invalid regex ${value} — ${err.message}`);
178
+ }
179
+ }
180
+ function tagText(tag) {
181
+ if (!tag)
182
+ return '';
183
+ return tag.label ? `${tag.alias} "${tag.label}"` : tag.alias;
184
+ }
115
185
  /** The `canvases` folder of every InfiniteCanvas app in a workspace. */
116
186
  async function canvasesFolders(client, wsId) {
117
187
  const ws = await client.get(`/workspace/list/${wsId}`);
@@ -158,7 +228,9 @@ function describe(canvas, node) {
158
228
  id: node.id,
159
229
  kind: node.kind,
160
230
  name: node.name,
231
+ tag: tagOf(canvas, node),
161
232
  position: node.content.position,
233
+ size: Number(node.content.width) > 0 && Number(node.content.height) > 0 ? { width: Number(node.content.width), height: Number(node.content.height) } : undefined,
162
234
  mode: MEDIA_KINDS.has(node.kind) ? node.content.mode || 'display' : undefined,
163
235
  text: TEXT_KINDS.has(node.kind) ? node.content.text ?? '' : undefined,
164
236
  assetId: node.file,
@@ -270,6 +342,10 @@ function applyModelDefaults(model, kind, generate, opts) {
270
342
  // ── writes ──────────────────────────────────────────────────────────────────
271
343
  /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
272
344
  function estimateSize(canvas, node) {
345
+ const w = Number(node.content.width);
346
+ const h = Number(node.content.height);
347
+ if (w > 0 && h > 0)
348
+ return { width: w, height: h };
273
349
  if (node.kind === 'dataset')
274
350
  return { width: 480, height: 220 };
275
351
  if (!MEDIA_KINDS.has(node.kind))
@@ -380,6 +456,7 @@ function nodeTable(rows) {
380
456
  { header: 'id', value: (n) => n.id },
381
457
  { header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
382
458
  { header: 'name', value: (n) => truncate(n.name, 20) },
459
+ { header: 'tag', value: (n) => truncate(tagText(n.tag), 16) },
383
460
  { header: 'refs', value: (n) => n.references.map((r) => r.label).join(',') },
384
461
  { header: 'summary', value: (n) => truncate(summary(n), 64) },
385
462
  ]);
@@ -390,7 +467,9 @@ function printNode(n) {
390
467
  ['id', n.id],
391
468
  ['kind', n.mode ? `${n.kind} (${n.mode})` : n.kind],
392
469
  ['name', n.name],
470
+ ['tag', n.tag && `${tagText(n.tag)} ${color.dim(n.tag.color)}`],
393
471
  ['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
472
+ ['size', n.size && `${n.size.width}×${n.size.height}`],
394
473
  ['text', n.text],
395
474
  ['dataset', n.dataset && `${n.dataset.type || 'dataset'} "${n.dataset.name || ''}" ${color.dim(n.dataset.id)}${n.dataset.folder ? ` in ${n.dataset.folder}` : ''}`],
396
475
  ['asset', n.assetId],
@@ -420,6 +499,46 @@ async function literalOrFile(value, file) {
420
499
  return undefined;
421
500
  return value === '-' ? readValue('-') : value;
422
501
  }
502
+ /**
503
+ * Text, title and comment nodes are Markdown on the web (a single line break joins the lines into
504
+ * one paragraph). `--plain` keeps plain text as written: a line break becomes a Markdown hard break
505
+ * (" \n"); blank lines stay paragraph breaks and fenced code blocks are left alone.
506
+ */
507
+ export function plainToMarkdown(text) {
508
+ let fenced = false;
509
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
510
+ return lines
511
+ .map((line, i) => {
512
+ const fence = /^\s*(```|~~~)/.test(line);
513
+ if (fence)
514
+ fenced = !fenced;
515
+ const next = lines[i + 1];
516
+ const hard = !fence && !fenced && i < lines.length - 1 && line.trim() !== '' && next !== undefined && next.trim() !== '' && !/^\s*(```|~~~)/.test(next);
517
+ return hard ? `${line.replace(/\s+$/, '')} ` : line;
518
+ })
519
+ .join('\n');
520
+ }
521
+ /** True when a single line break would be joined away by the web's Markdown rendering. */
522
+ export function hasSoftBreaks(text) {
523
+ return /[^\s\n][ \t]?\n(?![ \t]*\n)[ \t]*\S/.test(text.replace(/\r\n?/g, '\n').replace(/ {2,}\n/g, '\n\n'));
524
+ }
525
+ async function nodeText(kind, opts) {
526
+ const text = await literalOrFile(opts.text, opts.textFile);
527
+ if (text === undefined)
528
+ return undefined;
529
+ if (opts.plain)
530
+ return plainToMarkdown(text);
531
+ if (TEXT_KINDS.has(kind) && hasSoftBreaks(text) && !isJsonMode()) {
532
+ warn('text nodes render as Markdown on the web — single line breaks join into one paragraph; pass --plain to keep them');
533
+ }
534
+ return text;
535
+ }
536
+ function parseSize(value) {
537
+ const [width, height] = value.split(/[,x×]/i).map((v) => Number(v.trim()));
538
+ if (!(width > 0 && height > 0))
539
+ throw new Error('--size must look like width,height (e.g. 600,900)');
540
+ return { width: Math.round(width), height: Math.round(height) };
541
+ }
423
542
  function parsePoint(value, label) {
424
543
  const [x, y] = value.split(',').map((v) => Number(v.trim()));
425
544
  if (!Number.isFinite(x) || !Number.isFinite(y))
@@ -684,15 +803,53 @@ export function canvasCommand(globals) {
684
803
  .description('Nodes with references and a content summary (* = generate node)')
685
804
  .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
686
805
  .option('-k, --kind <kind>', 'only this kind')
806
+ .option('--tag <tags...>', 'only nodes with one of these color tags: label (C01), color (red) or id (rose)')
807
+ .option('--untagged', 'only nodes without a color tag')
808
+ .option('-n, --name <regex>', 'only nodes whose name matches this regex (case-insensitive; /re/flags for your own flags)')
687
809
  .action(async (opts) => {
810
+ if (opts.tag && opts.untagged)
811
+ throw new Error('Pass --tag or --untagged, not both');
812
+ const nameRe = opts.name !== undefined ? parseRegex(opts.name, '--name') : undefined;
688
813
  const c = client();
689
814
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
690
- const rows = cv.nodes.filter((n) => !opts.kind || n.kind === opts.kind).map((n) => describe(cv, n));
815
+ const colors = opts.tag ? resolveTagFilter(cv, opts.tag) : undefined;
816
+ const rows = cv.nodes
817
+ .filter((n) => !opts.kind || n.kind === opts.kind)
818
+ .filter((n) => !nameRe || nameRe.test(n.name))
819
+ .map((n) => describe(cv, n))
820
+ .filter((n) => (colors ? !!n.tag && colors.has(n.tag.color) : opts.untagged ? !n.tag : true));
691
821
  render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows }, () => {
692
822
  info(color.dim(`${cv.name} (${cv.id}) · ${rows.length} node(s) · ${cv.edges.length} edge(s)`));
693
823
  nodeTable(rows);
694
824
  });
695
825
  });
826
+ canvas
827
+ .command('tags')
828
+ .description('The canvas color tags: color, label and how many nodes carry each')
829
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
830
+ .option('-a, --all', 'also colors no node uses and that have no label')
831
+ .action(async (opts) => {
832
+ const c = client();
833
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
834
+ const rows = COLOR_TAGS.map((t) => ({
835
+ color: t.id,
836
+ alias: t.alias,
837
+ hex: t.hex,
838
+ label: cv.tagNames[t.id],
839
+ nodes: cv.nodes.filter((n) => n.content.colorTag?.colorId === t.id).length,
840
+ })).filter((r) => opts.all || r.nodes || r.label);
841
+ render({ canvas: { id: cv.id, name: cv.name }, tags: rows }, () => {
842
+ info(color.dim(`${cv.name} (${cv.id})`));
843
+ if (!rows.length)
844
+ return info('No color tags on this canvas (--all lists the colors)');
845
+ table(rows, [
846
+ { header: 'color', value: (r) => r.alias },
847
+ { header: 'id', value: (r) => color.dim(r.color) },
848
+ { header: 'label', value: (r) => r.label || '' },
849
+ { header: 'nodes', value: (r) => String(r.nodes) },
850
+ ]);
851
+ });
852
+ });
696
853
  canvas
697
854
  .command('get <nodeId>')
698
855
  .description('One node in full, with its references')
@@ -726,6 +883,8 @@ export function canvasCommand(globals) {
726
883
  .option('-n, --name <name>', 'node name')
727
884
  .option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
728
885
  .option('--text-file <path>', 'read the text from a file')
886
+ .option('--plain', 'text is plain, not Markdown: keep every line break (the web renders text nodes as Markdown)')
887
+ .option('--size <w,h>', 'node width and height in canvas pixels (e.g. 600,900)')
729
888
  .option('--asset <assetId>', 'show an existing asset (display node)')
730
889
  .option('--dataset <fsnodeId>', 'for `add dataset`: the workspace dataset node to show on the canvas')
731
890
  .option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
@@ -746,7 +905,7 @@ export function canvasCommand(globals) {
746
905
  };
747
906
  let model;
748
907
  if (TEXT_KINDS.has(kind)) {
749
- const text = await literalOrFile(opts.text, opts.textFile);
908
+ const text = await nodeText(kind, opts);
750
909
  if (text === undefined)
751
910
  throw new Error(`${kind} nodes need --text`);
752
911
  content.text = text;
@@ -792,6 +951,8 @@ export function canvasCommand(globals) {
792
951
  content.generate = applyModelDefaults(model, kind, patch, { workspaceRatio: ws.aspectRatio, explicit });
793
952
  }
794
953
  }
954
+ if (opts.size)
955
+ Object.assign(content, parseSize(opts.size));
795
956
  if (opts.at)
796
957
  content.position = parsePoint(opts.at, '--at');
797
958
  else {
@@ -822,7 +983,9 @@ export function canvasCommand(globals) {
822
983
  .option('-n, --name <name>', 'rename')
823
984
  .option('-t, --text <text>', 'replace the text of a text/title/comment node (- reads stdin)')
824
985
  .option('--text-file <path>', 'read the text from a file')
825
- .option('--move <x,y>', 'move to this position')).action(async (id, opts) => {
986
+ .option('--plain', 'text is plain, not Markdown: keep every line break')
987
+ .option('--move <x,y>', 'move to this position')
988
+ .option('--size <w,h>', 'resize: width and height in canvas pixels')).action(async (id, opts) => {
826
989
  const c = client();
827
990
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
828
991
  const node = requireNode(cv, id);
@@ -831,7 +994,7 @@ export function canvasCommand(globals) {
831
994
  let changed = false;
832
995
  if (opts.name !== undefined)
833
996
  (body.name = opts.name), (changed = true);
834
- const text = await literalOrFile(opts.text, opts.textFile);
997
+ const text = await nodeText(node.kind, opts);
835
998
  if (text !== undefined) {
836
999
  if (!TEXT_KINDS.has(node.kind))
837
1000
  throw new Error(`${node.kind} nodes have no text`);
@@ -840,6 +1003,8 @@ export function canvasCommand(globals) {
840
1003
  }
841
1004
  if (opts.move)
842
1005
  (content.position = parsePoint(opts.move, '--move')), (changed = true);
1006
+ if (opts.size)
1007
+ Object.assign(content, parseSize(opts.size)), (changed = true);
843
1008
  const { patch, explicit } = await generateOptions(opts);
844
1009
  if (Object.keys(patch).length) {
845
1010
  if (!MEDIA_KINDS.has(node.kind))
@@ -0,0 +1,205 @@
1
+ import { Command } from 'commander';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { confirm } from '../core/prompt.js';
5
+ import { color, detail, info, isJsonMode, printJson, render, success, table, warn } from '../core/output.js';
6
+ import { CREW_REGISTRY, TARGETS, applyOps, describeOp, fetchPack, findOpenclaw, grokZip, loadPackFromDir, pathState, planClaude, planCodex, planOpenclaw, readInstalled, relOrAbs, uninstallPaths, validatePack, writeInstalled, } from '../core/crew.js';
7
+ /**
8
+ * `wji crew` — install a crew pack (skills + agent seats) into the hosts that
9
+ * run them: Claude Code, OpenAI Codex, OpenClaw, and the Grok bot platform
10
+ * (as an import zip). One pack, one command per host.
11
+ */
12
+ function parseTargets(value) {
13
+ if (!value || value === 'all')
14
+ return ['claude', 'codex', 'openclaw', 'grok'];
15
+ const out = [];
16
+ for (const raw of value.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)) {
17
+ if (!TARGETS.includes(raw))
18
+ throw new Error(`Unknown target "${raw}". Use: ${TARGETS.join(', ')} or all`);
19
+ out.push(raw);
20
+ }
21
+ return out;
22
+ }
23
+ function requireValid(pack) {
24
+ const errors = validatePack(pack);
25
+ if (errors.length)
26
+ throw new Error(`Pack ${pack.crew.name} is not valid:\n - ${errors.join('\n - ')}`);
27
+ }
28
+ export function crewCommand(_globals) {
29
+ const crew = new Command('crew').description('Install crew packs (skills + agent seats) into Claude Code, Codex, OpenClaw or a Grok bot export');
30
+ crew
31
+ .command('install [name]')
32
+ .description(`Install a crew for one or more hosts (known crews: ${Object.keys(CREW_REGISTRY).join(', ')})`)
33
+ .option('--for <targets>', 'comma-separated: claude, codex, openclaw, grok, or all', 'all')
34
+ .option('--from <source>', 'a local pack folder, a .zip / .tar.gz, or an http(s) URL instead of the registry')
35
+ .option('--ref <ref>', 'git branch or tag when fetching from the registry')
36
+ .option('--project <dir>', 'install into this project (.claude/, .agents/, .codex/, openclaw/) instead of your home directory')
37
+ .option('--link', 'symlink skills and Claude agents to a local --from folder instead of copying')
38
+ .option('--out <zip>', 'where to write the Grok export zip (default ./<crew>-export.zip)')
39
+ .option('--no-register', 'openclaw: only write the workspaces and config fragment; do not run `openclaw agents add`')
40
+ .option('--dry-run', 'print what would be written and stop')
41
+ .option('-y, --yes', 'skip the confirmation')
42
+ .action(async (name, opts) => {
43
+ const targets = parseTargets(opts.for);
44
+ const pack = await fetchPack(name || 'short-drama', { from: opts.from, ref: opts.ref });
45
+ requireValid(pack);
46
+ if (opts.link && (!opts.from || /^https?:/.test(opts.from) || !pack.root.startsWith(resolve(opts.from)))) {
47
+ throw new Error('--link needs a local --from <folder>');
48
+ }
49
+ const io = { project: opts.project, link: !!opts.link, out: opts.out };
50
+ const plans = {};
51
+ for (const t of targets) {
52
+ if (t === 'claude')
53
+ plans.claude = planClaude(pack, io);
54
+ if (t === 'codex')
55
+ plans.codex = planCodex(pack, io);
56
+ if (t === 'openclaw')
57
+ plans.openclaw = planOpenclaw(pack, io, { register: opts.register !== false });
58
+ }
59
+ // codex and openclaw share ~/.agents/skills — write each skill once
60
+ if (plans.codex && plans.openclaw) {
61
+ const seen = new Set(plans.codex.filter((o) => o.kind !== 'exec').map((o) => o.path));
62
+ plans.openclaw = plans.openclaw.filter((o) => o.kind === 'exec' || !seen.has(o.path));
63
+ }
64
+ const grokOut = targets.includes('grok') ? resolve(opts.out || `${pack.crew.name}-export.zip`) : undefined;
65
+ info(`${color.bold(pack.crew.title || pack.crew.name)} — ${Object.keys(pack.agents).length} seat(s), ${pack.skills.size} skill(s) from ${pack.source}${pack.ref ? ` @ ${pack.ref}` : ''}`);
66
+ for (const t of targets) {
67
+ info('');
68
+ info(color.bold(`[${t}]`));
69
+ if (t === 'grok')
70
+ info(` zip ${relOrAbs(grokOut)}`);
71
+ for (const op of plans[t] || [])
72
+ info(` ${describeOp(op).replace(/^(\w+)/, (m) => color.dim(m))}`);
73
+ }
74
+ if (opts.dryRun)
75
+ return info(color.dim('\n--dry-run: nothing written'));
76
+ info('');
77
+ if (!(await confirm('Write these?', opts.yes)))
78
+ return info('Aborted');
79
+ const installed = readInstalled();
80
+ const record = installed[pack.crew.name] || { source: pack.source, ref: pack.ref, installedAt: '', targets: {} };
81
+ record.source = pack.source;
82
+ record.ref = pack.ref;
83
+ record.installedAt = new Date().toISOString();
84
+ const summary = {};
85
+ for (const t of targets) {
86
+ info(color.bold(`[${t}]`));
87
+ if (t === 'grok') {
88
+ const { zip, entries } = grokZip(pack);
89
+ mkdirSync(dirname(grokOut), { recursive: true });
90
+ writeFileSync(grokOut, zip);
91
+ info(` ✓ ${entries} entries → ${relOrAbs(grokOut)}`);
92
+ record.targets.grok = { paths: [grokOut], notes: [] };
93
+ summary.grok = { zip: grokOut, entries };
94
+ continue;
95
+ }
96
+ const previous = record.targets[t]?.paths || [];
97
+ const result = applyOps(plans[t] || [], info);
98
+ // paths from an earlier install of this crew that this run did not touch are stale — drop them
99
+ const stale = previous.filter((p) => !result.paths.includes(p));
100
+ if (stale.length)
101
+ uninstallPaths(stale);
102
+ record.targets[t] = result;
103
+ summary[t] = { written: result.paths.length, notes: result.notes };
104
+ }
105
+ installed[pack.crew.name] = record;
106
+ writeInstalled(installed);
107
+ if (targets.includes('openclaw')) {
108
+ const frag = (record.targets.openclaw?.paths || []).find((p) => p.endsWith('openclaw.agents.json5'));
109
+ warn(`openclaw: merge ${frag} into ~/.openclaw/openclaw.json (skills allowlists, tools, subagents, channel bindings) — the CLI never rewrites that file.`);
110
+ if (!findOpenclaw())
111
+ info(color.dim(' openclaw binary not found; the agents were not registered — run `openclaw agents add <seat> --workspace <dir>` for each seat.'));
112
+ }
113
+ if (targets.includes('codex')) {
114
+ info(color.dim('codex: the wji-operator seat needs network — set [sandbox_workspace_write] network_access = true in ~/.codex/config.toml.'));
115
+ }
116
+ if (isJsonMode())
117
+ return printJson({ crew: pack.crew.name, targets: summary });
118
+ success(`Installed ${pack.crew.name} for ${targets.join(', ')}`);
119
+ });
120
+ crew
121
+ .command('status [name]')
122
+ .description('What `wji crew install` wrote, per host, and whether it is still there')
123
+ .action((name) => {
124
+ const installed = readInstalled();
125
+ const names = name ? [name] : Object.keys(installed);
126
+ const rows = [];
127
+ for (const n of names) {
128
+ const rec = installed[n];
129
+ if (!rec) {
130
+ warn(`${n}: not installed by wji crew`);
131
+ continue;
132
+ }
133
+ for (const [target, data] of Object.entries(rec.targets)) {
134
+ for (const p of data?.paths || [])
135
+ rows.push({ crew: n, target, path: p, state: pathState(p) });
136
+ }
137
+ }
138
+ render({ installed, rows }, () => {
139
+ if (!rows.length)
140
+ return info('Nothing installed by `wji crew`. Try `wji crew install short-drama --for claude`.');
141
+ for (const n of names) {
142
+ const rec = installed[n];
143
+ if (!rec)
144
+ continue;
145
+ detail([
146
+ ['crew', n],
147
+ ['source', `${rec.source}${rec.ref ? ` @ ${rec.ref}` : ''}`],
148
+ ['installed', rec.installedAt],
149
+ ]);
150
+ }
151
+ info('');
152
+ table(rows, [
153
+ { header: 'target', value: (r) => r.target },
154
+ { header: '', value: (r) => (r.state === 'missing' ? color.red('✗') : r.state === 'link' ? color.dim('↗') : color.green('✓')) },
155
+ { header: 'path', value: (r) => relOrAbs(r.path) },
156
+ ]);
157
+ });
158
+ });
159
+ crew
160
+ .command('uninstall <name>')
161
+ .description('Remove what `wji crew install` wrote for a crew (only the recorded paths)')
162
+ .option('--for <targets>', 'comma-separated targets, default all recorded', 'all')
163
+ .option('-y, --yes', 'skip the confirmation')
164
+ .action(async (name, opts) => {
165
+ const installed = readInstalled();
166
+ const rec = installed[name];
167
+ if (!rec)
168
+ throw new Error(`${name} was not installed by wji crew`);
169
+ const targets = parseTargets(opts.for).filter((t) => rec.targets[t]);
170
+ const paths = targets.flatMap((t) => rec.targets[t]?.paths || []);
171
+ if (!paths.length)
172
+ return info('Nothing to remove');
173
+ for (const p of paths)
174
+ info(` rm ${relOrAbs(p)}`);
175
+ if (!(await confirm(`Remove ${paths.length} path(s)?`, opts.yes)))
176
+ return info('Aborted');
177
+ uninstallPaths(paths);
178
+ for (const t of targets)
179
+ delete rec.targets[t];
180
+ if (!Object.keys(rec.targets).length)
181
+ delete installed[name];
182
+ writeInstalled(installed);
183
+ if (targets.includes('openclaw'))
184
+ warn('openclaw: remove the agents.entries / bindings you merged into ~/.openclaw/openclaw.json, and `openclaw agents delete <seat>` for each seat.');
185
+ success(`Removed ${paths.length} path(s)`);
186
+ });
187
+ crew
188
+ .command('check <dir>')
189
+ .description('Validate a local crew pack (crew.json, agents/*.md, skills/*/SKILL.md, links)')
190
+ .action((dir) => {
191
+ const pack = loadPackFromDir(dir);
192
+ const errors = validatePack(pack);
193
+ render({ crew: pack.crew.name, agents: Object.keys(pack.agents), skills: [...pack.skills.keys()], errors }, () => {
194
+ if (errors.length) {
195
+ for (const e of errors)
196
+ info(` ✗ ${e}`);
197
+ throw new Error(`${errors.length} problem(s) in ${dir}`);
198
+ }
199
+ success(`${pack.crew.name}: ${Object.keys(pack.agents).length} seat(s), ${pack.skills.size} skill(s), all links resolve`);
200
+ });
201
+ if (errors.length)
202
+ process.exitCode = 1;
203
+ });
204
+ return crew;
205
+ }
@@ -0,0 +1,95 @@
1
+ import { gunzipSync, inflateRawSync } from 'node:zlib';
2
+ /**
3
+ * Minimal archive readers — enough to open a GitHub source tarball
4
+ * (`codeload.github.com/.../tar.gz/...`) and a plain zip, without a dependency.
5
+ * Both return forward-slash paths → file contents; directories are dropped.
6
+ */
7
+ /** ustar / GNU tar, gzipped. Handles GNU long names (`L`) and skips pax headers. */
8
+ export function readTarGz(buffer) {
9
+ const tar = gunzipSync(buffer);
10
+ const files = new Map();
11
+ let offset = 0;
12
+ let longName;
13
+ const field = (start, len) => tar.subarray(offset + start, offset + start + len).toString('utf8').replace(/\0.*$/s, '');
14
+ while (offset + 512 <= tar.length) {
15
+ const header = tar.subarray(offset, offset + 512);
16
+ if (header.every((b) => b === 0))
17
+ break;
18
+ const size = parseInt(field(124, 12).trim() || '0', 8);
19
+ const type = field(156, 1);
20
+ const prefix = field(345, 155);
21
+ let name = field(0, 100);
22
+ if (prefix)
23
+ name = `${prefix}/${name}`;
24
+ if (longName)
25
+ (name = longName), (longName = undefined);
26
+ const body = tar.subarray(offset + 512, offset + 512 + size);
27
+ offset += 512 + Math.ceil(size / 512) * 512;
28
+ if (type === 'L') {
29
+ longName = body.toString('utf8').replace(/\0.*$/s, '');
30
+ continue;
31
+ }
32
+ if (type === 'x' || type === 'g' || type === '5')
33
+ continue; // pax headers, directories
34
+ if (/(^|\/)\._[^/]*$/.test(name))
35
+ continue; // AppleDouble metadata from macOS tar
36
+ if (type === '0' || type === '' || type === '\0')
37
+ files.set(name, Buffer.from(body));
38
+ }
39
+ return files;
40
+ }
41
+ /** Zip with stored or deflated entries, read from the central directory. */
42
+ export function readZip(buffer) {
43
+ const files = new Map();
44
+ // End of central directory record: signature 0x06054b50, search backwards (comment ≤ 64 KiB)
45
+ let eocd = -1;
46
+ for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65558); i--) {
47
+ if (buffer.readUInt32LE(i) === 0x06054b50) {
48
+ eocd = i;
49
+ break;
50
+ }
51
+ }
52
+ if (eocd < 0)
53
+ throw new Error('Not a zip file (no central directory)');
54
+ const count = buffer.readUInt16LE(eocd + 10);
55
+ let p = buffer.readUInt32LE(eocd + 16);
56
+ for (let n = 0; n < count; n++) {
57
+ if (buffer.readUInt32LE(p) !== 0x02014b50)
58
+ throw new Error('Corrupt zip central directory');
59
+ const method = buffer.readUInt16LE(p + 10);
60
+ const compressed = buffer.readUInt32LE(p + 20);
61
+ const nameLen = buffer.readUInt16LE(p + 28);
62
+ const extraLen = buffer.readUInt16LE(p + 30);
63
+ const commentLen = buffer.readUInt16LE(p + 32);
64
+ const localOffset = buffer.readUInt32LE(p + 42);
65
+ const name = buffer.subarray(p + 46, p + 46 + nameLen).toString('utf8');
66
+ p += 46 + nameLen + extraLen + commentLen;
67
+ if (name.endsWith('/') || /(^|\/)\._[^/]*$/.test(name) || name.startsWith('__MACOSX/'))
68
+ continue;
69
+ // local header: 30 bytes + name + extra (the local extra length can differ from the central one)
70
+ const lNameLen = buffer.readUInt16LE(localOffset + 26);
71
+ const lExtraLen = buffer.readUInt16LE(localOffset + 28);
72
+ const start = localOffset + 30 + lNameLen + lExtraLen;
73
+ const data = buffer.subarray(start, start + compressed);
74
+ if (method === 0)
75
+ files.set(name, Buffer.from(data));
76
+ else if (method === 8)
77
+ files.set(name, inflateRawSync(data));
78
+ else
79
+ throw new Error(`Unsupported zip compression method ${method} for ${name}`);
80
+ }
81
+ return files;
82
+ }
83
+ /**
84
+ * Source archives wrap everything in one top-level folder (`repo-ref/…`,
85
+ * `short-drama-crew-export/…`). Strip it when every path shares it.
86
+ */
87
+ export function stripCommonRoot(files) {
88
+ const roots = new Set([...files.keys()].map((p) => p.split('/')[0]));
89
+ if (roots.size !== 1 || ![...files.keys()].every((p) => p.includes('/')))
90
+ return files;
91
+ const out = new Map();
92
+ for (const [path, content] of files)
93
+ out.set(path.slice(path.indexOf('/') + 1), content);
94
+ return out;
95
+ }
@@ -0,0 +1,546 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { configDir } from './config.js';
6
+ import { createZip } from './zip.js';
7
+ import { readTarGz, readZip, stripCommonRoot } from './archive.js';
8
+ /**
9
+ * A crew pack is a folder (or archive of one) shaped like:
10
+ *
11
+ * crew.json roles, skill allowlists, capabilities, delegation, channels
12
+ * agents/<slug>.md one persona per seat: Claude-agent frontmatter + body
13
+ * skills/<name>/… Agent Skills (SKILL.md + reference/ + scripts/ …)
14
+ * AGENTS.md shared operating rules (optional)
15
+ *
16
+ * `wji crew install` copies the skills where each host discovers them and
17
+ * writes each host's agent format from the same persona files.
18
+ */
19
+ export const TARGETS = ['claude', 'codex', 'openclaw', 'grok'];
20
+ /** Where a named crew is fetched from when no --from is given. */
21
+ export const CREW_REGISTRY = {
22
+ 'short-drama': { repo: 'ultra-instinct-ai/short-drama-crew', defaultRef: 'main' },
23
+ };
24
+ // ── frontmatter ─────────────────────────────────────────────────────────────
25
+ /** The YAML subset the persona files use: `key: value`, `key:` + ` - item` lists, `>-` folded blocks. */
26
+ export function parseFrontmatter(raw) {
27
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
28
+ if (!m)
29
+ return { frontmatter: {}, body: raw };
30
+ const fm = {};
31
+ const lines = m[1].split(/\r?\n/);
32
+ let key;
33
+ let folded = false;
34
+ for (const line of lines) {
35
+ const top = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
36
+ if (top && !/^\s/.test(line)) {
37
+ key = top[1];
38
+ const value = top[2].trim();
39
+ if (value === '>-' || value === '>' || value === '|' || value === '|-') {
40
+ fm[key] = '';
41
+ folded = true;
42
+ }
43
+ else if (value === '') {
44
+ fm[key] = [];
45
+ folded = false;
46
+ }
47
+ else {
48
+ fm[key] = value.replace(/^["'](.*)["']$/, '$1');
49
+ folded = false;
50
+ }
51
+ continue;
52
+ }
53
+ if (!key)
54
+ continue;
55
+ const item = /^\s+-\s+(.*)$/.exec(line);
56
+ if (item && Array.isArray(fm[key]))
57
+ fm[key].push(item[1].trim());
58
+ else if (folded && typeof fm[key] === 'string')
59
+ fm[key] = `${fm[key]}${fm[key] ? ' ' : ''}${line.trim()}`;
60
+ }
61
+ return { frontmatter: fm, body: raw.slice(m[0].length).replace(/^\s*\n/, '') };
62
+ }
63
+ const listOf = (v) => Array.isArray(v) ? v : typeof v === 'string' && v ? v.split(',').map((s) => s.trim()).filter(Boolean) : [];
64
+ // ── loading ─────────────────────────────────────────────────────────────────
65
+ function walk(dir, rel = '') {
66
+ const out = new Map();
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ if (entry.name.startsWith('.') && entry.name !== '.sync-source')
69
+ continue;
70
+ const full = join(dir, entry.name);
71
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
72
+ if (entry.isDirectory())
73
+ for (const [p, c] of walk(full, path))
74
+ out.set(p, c);
75
+ else if (entry.isFile())
76
+ out.set(path, readFileSync(full));
77
+ }
78
+ return out;
79
+ }
80
+ export function loadPackFromDir(dir, source = dir, ref) {
81
+ const root = resolve(dir);
82
+ const manifestPath = join(root, 'crew.json');
83
+ if (!existsSync(manifestPath))
84
+ throw new Error(`${dir} is not a crew pack (no crew.json)`);
85
+ const crew = JSON.parse(readFileSync(manifestPath, 'utf8'));
86
+ const skillsDir = join(root, crew.skillsDir || 'skills');
87
+ const skills = new Map();
88
+ if (existsSync(skillsDir)) {
89
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
90
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
91
+ continue;
92
+ skills.set(entry.name, walk(join(skillsDir, entry.name)));
93
+ }
94
+ }
95
+ const agents = {};
96
+ for (const [slug, spec] of Object.entries(crew.agents || {})) {
97
+ const file = join(root, spec.prompt);
98
+ if (!existsSync(file))
99
+ throw new Error(`crew.json: agent ${slug} points at missing ${spec.prompt}`);
100
+ const raw = readFileSync(file, 'utf8');
101
+ const { frontmatter, body } = parseFrontmatter(raw);
102
+ agents[slug] = { slug, frontmatter, body, raw };
103
+ }
104
+ const agentsMdPath = join(root, 'AGENTS.md');
105
+ return {
106
+ root,
107
+ crew,
108
+ agents,
109
+ skills,
110
+ agentsMd: existsSync(agentsMdPath) ? readFileSync(agentsMdPath, 'utf8') : undefined,
111
+ source,
112
+ ref,
113
+ };
114
+ }
115
+ export function crewCacheDir() {
116
+ return join(configDir(), 'crews');
117
+ }
118
+ function unpackTo(files, dir) {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ for (const [path, content] of files) {
121
+ if (path.includes('..'))
122
+ continue;
123
+ const full = join(dir, path);
124
+ mkdirSync(dirname(full), { recursive: true });
125
+ writeFileSync(full, content);
126
+ }
127
+ }
128
+ /**
129
+ * Resolve `--from` (a folder, a .zip / .tar.gz file, or an http(s) URL) or a
130
+ * registry name into a loaded pack. Archives are unpacked into the cache.
131
+ */
132
+ export async function fetchPack(nameOrFrom, opts = {}) {
133
+ const from = opts.from;
134
+ if (from && !/^https?:\/\//.test(from)) {
135
+ const path = resolve(from);
136
+ if (!existsSync(path))
137
+ throw new Error(`No such file or directory: ${from}`);
138
+ if (statSync(path).isDirectory())
139
+ return loadPackFromDir(path, path);
140
+ const buffer = readFileSync(path);
141
+ const files = stripCommonRoot(/\.zip$/i.test(path) ? readZip(buffer) : readTarGz(buffer));
142
+ const dir = join(crewCacheDir(), basename(path).replace(/\.(zip|tar\.gz|tgz)$/i, ''), 'local');
143
+ unpackTo(files, dir);
144
+ return loadPackFromDir(dir, path);
145
+ }
146
+ let url = from;
147
+ let ref = opts.ref;
148
+ let name = nameOrFrom;
149
+ if (!url) {
150
+ const entry = CREW_REGISTRY[nameOrFrom];
151
+ if (!entry)
152
+ throw new Error(`Unknown crew "${nameOrFrom}". Known: ${Object.keys(CREW_REGISTRY).join(', ')} — or pass --from <dir|zip|url>.`);
153
+ ref = ref || entry.defaultRef;
154
+ const kind = /^v?\d/.test(ref) ? 'tags' : 'heads';
155
+ url = `https://codeload.github.com/${entry.repo}/tar.gz/refs/${kind}/${ref}`;
156
+ }
157
+ else {
158
+ name = basename(new URL(url).pathname).replace(/\.(zip|tar\.gz|tgz)$/i, '') || nameOrFrom;
159
+ ref = ref || 'url';
160
+ }
161
+ const res = await fetch(url);
162
+ if (!res.ok)
163
+ throw new Error(`Download failed: ${res.status} ${res.statusText} — ${url}`);
164
+ const buffer = Buffer.from(await res.arrayBuffer());
165
+ const files = stripCommonRoot(/\.zip($|\?)/i.test(url) ? readZip(buffer) : readTarGz(buffer));
166
+ const dir = join(crewCacheDir(), name, ref);
167
+ unpackTo(files, dir);
168
+ return loadPackFromDir(dir, url, ref);
169
+ }
170
+ // ── validation ──────────────────────────────────────────────────────────────
171
+ export function validatePack(pack) {
172
+ const errors = [];
173
+ const { crew } = pack;
174
+ if (!crew.name || !/^[a-z0-9][a-z0-9-]*$/.test(crew.name))
175
+ errors.push('crew.json: name must be a kebab-case slug');
176
+ if (!crew.agents || !Object.keys(crew.agents).length)
177
+ errors.push('crew.json: no agents');
178
+ for (const [name, files] of pack.skills) {
179
+ const skill = files.get('SKILL.md');
180
+ if (!skill) {
181
+ errors.push(`skills/${name}: no SKILL.md`);
182
+ continue;
183
+ }
184
+ const { frontmatter } = parseFrontmatter(skill.toString('utf8'));
185
+ if (frontmatter.name !== name)
186
+ errors.push(`skills/${name}/SKILL.md: name "${frontmatter.name}" must equal the folder name`);
187
+ const desc = String(frontmatter.description || '');
188
+ if (!desc)
189
+ errors.push(`skills/${name}/SKILL.md: description is required`);
190
+ else if (desc.length > 1024)
191
+ errors.push(`skills/${name}/SKILL.md: description is ${desc.length} chars (max 1024)`);
192
+ // relative links must resolve inside the pack
193
+ const text = skill.toString('utf8');
194
+ for (const m of text.matchAll(/\]\(((?:\.\.\/|reference\/|scripts\/)[^)#]+)(?:#[^)]*)?\)/g)) {
195
+ const target = m[1];
196
+ const parts = target.split('/');
197
+ const resolved = parts[0] === '..' ? pack.skills.get(parts[1])?.has(parts.slice(2).join('/')) : files.has(target);
198
+ if (!resolved)
199
+ errors.push(`skills/${name}/SKILL.md: link ${target} does not resolve`);
200
+ }
201
+ }
202
+ for (const [slug, spec] of Object.entries(crew.agents || {})) {
203
+ const persona = pack.agents[slug];
204
+ if (!persona)
205
+ continue;
206
+ if (persona.frontmatter.name !== slug)
207
+ errors.push(`${spec.prompt}: frontmatter name "${persona.frontmatter.name}" must equal the slug ${slug}`);
208
+ if (!persona.frontmatter.description)
209
+ errors.push(`${spec.prompt}: description is required`);
210
+ const fmSkills = listOf(persona.frontmatter.skills).sort().join(',');
211
+ if (fmSkills !== [...spec.skills].sort().join(','))
212
+ errors.push(`${spec.prompt}: frontmatter skills [${fmSkills}] differ from crew.json [${spec.skills.join(',')}]`);
213
+ for (const s of spec.skills)
214
+ if (!pack.skills.has(s))
215
+ errors.push(`crew.json: agent ${slug} lists unknown skill ${s}`);
216
+ for (const d of spec.delegatesTo || [])
217
+ if (!crew.agents[d])
218
+ errors.push(`crew.json: agent ${slug} delegates to unknown ${d}`);
219
+ const tools = listOf(persona.frontmatter.tools);
220
+ if (!spec.capabilities.write && tools.some((t) => /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t)))
221
+ errors.push(`${spec.prompt}: read-only seat lists a write tool`);
222
+ if (spec.capabilities.spawn && !tools.includes('Agent'))
223
+ errors.push(`${spec.prompt}: a spawning seat needs the Agent tool`);
224
+ }
225
+ for (const [id, ch] of Object.entries(crew.channels || {}))
226
+ for (const m of ch.members)
227
+ if (!crew.agents[m])
228
+ errors.push(`crew.json: channel ${id} has unknown member ${m}`);
229
+ return errors;
230
+ }
231
+ const home = (o) => o.home || process.env.HOME || homedir();
232
+ export function claudeRoot(o) {
233
+ return o.project ? join(resolve(o.project), '.claude') : join(home(o), '.claude');
234
+ }
235
+ export function agentsSkillsRoot(o) {
236
+ return o.project ? join(resolve(o.project), '.agents', 'skills') : join(home(o), '.agents', 'skills');
237
+ }
238
+ export function codexAgentsRoot(o) {
239
+ return o.project ? join(resolve(o.project), '.codex', 'agents') : join(home(o), '.codex', 'agents');
240
+ }
241
+ export function openclawRoot(pack, o) {
242
+ return o.project ? join(resolve(o.project), 'openclaw') : join(home(o), '.openclaw', 'workspaces', pack.crew.name);
243
+ }
244
+ function skillOps(pack, root, o, note) {
245
+ const ops = [];
246
+ for (const [name, files] of pack.skills) {
247
+ const path = join(root, name);
248
+ if (o.link)
249
+ ops.push({ kind: 'link', path, target: join(pack.root, pack.crew.skillsDir || 'skills', name), note: `${note} skill ${name} (symlink)` });
250
+ else
251
+ ops.push({ kind: 'dir', path, files, note: `${note} skill ${name} (${files.size} files)` });
252
+ }
253
+ return ops;
254
+ }
255
+ export function planClaude(pack, o) {
256
+ const root = claudeRoot(o);
257
+ const ops = skillOps(pack, join(root, 'skills'), o, 'claude');
258
+ for (const [slug, persona] of Object.entries(pack.agents)) {
259
+ const path = join(root, 'agents', `${slug}.md`);
260
+ if (o.link)
261
+ ops.push({ kind: 'link', path, target: join(pack.root, pack.crew.agents[slug].prompt), note: `claude agent ${slug} (symlink)` });
262
+ else
263
+ ops.push({ kind: 'file', path, content: persona.raw, note: `claude agent ${slug}` });
264
+ }
265
+ return ops;
266
+ }
267
+ /** Codex `.codex/agents/<slug>.toml` — the persona body becomes developer_instructions. */
268
+ export function codexAgentToml(pack, slug, skillsBase) {
269
+ const spec = pack.crew.agents[slug];
270
+ const persona = pack.agents[slug];
271
+ const q = (s) => JSON.stringify(s);
272
+ const sandbox = spec.capabilities.write ? 'workspace-write' : 'read-only';
273
+ const extra = [
274
+ '',
275
+ `Skills for this seat live under ${skillsBase}/: ${spec.skills.join(', ')}.`,
276
+ spec.delegatesTo.length ? `Delegate with spawn_agent to: ${spec.delegatesTo.join(', ')}.` : 'You do not spawn other agents; report back to the seat that assigned you.',
277
+ spec.capabilities.spend ? 'You may run wji generation commands after the cost has been approved.' : 'You never run wji generation or spend credits.',
278
+ ].join('\n');
279
+ const body = `${persona.body.trimEnd()}\n${extra}\n`.replace(/"""/g, '\\"\\"\\"');
280
+ const lines = [
281
+ `# GENERATED by \`wji crew install\` from ${pack.crew.name}/agents/${slug}.md — edit the pack, not this file.`,
282
+ slug === 'wji-operator' || spec.capabilities.network
283
+ ? `# This seat needs network access: set [sandbox_workspace_write] network_access = true in ~/.codex/config.toml.`
284
+ : '',
285
+ `name = ${q(slug)}`,
286
+ `description = ${q(String(persona.frontmatter.description || spec.name))}`,
287
+ ];
288
+ if (spec.model)
289
+ lines.push(`model = ${q(spec.model)}`);
290
+ lines.push(`sandbox_mode = ${q(sandbox)}`, `developer_instructions = """`, body, `"""`, '');
291
+ for (const s of spec.skills)
292
+ lines.push('[[skills.config]]', `path = ${q(`${skillsBase}/${s}`)}`, 'enabled = true', '');
293
+ return lines.filter((l) => l !== undefined).join('\n');
294
+ }
295
+ export function planCodex(pack, o) {
296
+ const skillsRoot = agentsSkillsRoot(o);
297
+ const ops = skillOps(pack, skillsRoot, o, 'codex');
298
+ const base = o.project ? '.agents/skills' : skillsRoot;
299
+ for (const slug of Object.keys(pack.agents)) {
300
+ ops.push({ kind: 'file', path: join(codexAgentsRoot(o), `${slug}.toml`), content: codexAgentToml(pack, slug, base), note: `codex agent ${slug}` });
301
+ }
302
+ return ops;
303
+ }
304
+ /** OpenClaw tool ids per capability; one table so a rename is a one-line fix. */
305
+ const OPENCLAW_TOOLS = {
306
+ read: ['read'],
307
+ write: ['write', 'edit'],
308
+ exec: ['exec'],
309
+ spawn: ['sessions_spawn', 'sessions_send', 'agents_list'],
310
+ network: ['web_fetch', 'browser'],
311
+ };
312
+ export function openclawSeatAgentsMd(pack, slug) {
313
+ const spec = pack.crew.agents[slug];
314
+ const caps = spec.capabilities;
315
+ const seat = [
316
+ `# Seat: ${slug} (${spec.name})`,
317
+ '',
318
+ `You are seat \`${slug}\` of the ${pack.crew.title || pack.crew.name} crew.`,
319
+ `- Skills allowed: ${spec.skills.join(', ')}`,
320
+ `- May spawn: ${spec.delegatesTo.length ? spec.delegatesTo.join(', ') : 'nobody — report back to the seat that assigned you'}`,
321
+ `- Spend credits / run generation: ${caps.spend ? 'yes, after the approved cost is relayed' : 'never'}`,
322
+ caps.exec === 'readonly' ? '- Shell use is read-only (validate / checkup / manifest / grep / diff); never edit files.' : caps.exec === 'engine' ? '- Shell use is the short-drama engine and read-only wji commands only.' : '',
323
+ !caps.write ? '- You do not write files.' : '',
324
+ '',
325
+ ]
326
+ .filter((l) => l !== '')
327
+ .join('\n');
328
+ return `${seat}\n${pack.agentsMd ? `\n---\n\n${pack.agentsMd.trimEnd()}\n` : ''}`;
329
+ }
330
+ export function openclawFragment(pack, workspacesRoot, repoPlaceholder = true) {
331
+ const ws = (slug) => (repoPlaceholder ? `\${WORKSPACES}/${slug}` : join(workspacesRoot, slug));
332
+ const entries = [];
333
+ for (const [slug, spec] of Object.entries(pack.crew.agents)) {
334
+ const c = spec.capabilities;
335
+ const allow = [...OPENCLAW_TOOLS.read, ...(c.write ? OPENCLAW_TOOLS.write : []), ...(c.exec ? OPENCLAW_TOOLS.exec : []), ...(c.spawn ? OPENCLAW_TOOLS.spawn : [])];
336
+ const deny = [...(c.write ? [] : OPENCLAW_TOOLS.write), ...(c.network ? [] : OPENCLAW_TOOLS.network), ...(c.spawn ? [] : ['sessions_spawn'])];
337
+ const peers = [...new Set([...spec.delegatesTo, ...Object.entries(pack.crew.agents).filter(([, s]) => s.delegatesTo.includes(slug)).map(([id]) => id)])];
338
+ entries.push(` ${JSON.stringify(slug)}: {`, ` name: ${JSON.stringify(spec.name)},`, ` workspace: ${JSON.stringify(ws(slug))},`, spec.model ? ` model: ${JSON.stringify(spec.model)},` : '', ` skills: ${JSON.stringify(spec.skills)},`, ` tools: { allow: ${JSON.stringify(allow)}, deny: ${JSON.stringify(deny)} },`, ` subagents: { allowAgents: ${JSON.stringify(spec.delegatesTo)}${spec.delegatesTo.length ? ', delegationMode: "prefer"' : ''} },`, ` // agent-to-agent messaging peers: ${peers.join(', ') || 'none'}`, ` },`);
339
+ }
340
+ const bindings = [];
341
+ for (const [chId, ch] of Object.entries(pack.crew.channels || {})) {
342
+ bindings.push(` // channel "${ch.name}" (${chId}): members ${ch.members.join(', ')} — fill in the real channel / group id`);
343
+ for (const m of ch.members)
344
+ bindings.push(` { agentId: ${JSON.stringify(m)}, match: { channel: "TODO", peer: { kind: "group", id: "TODO:${chId}" } } },`);
345
+ }
346
+ return [
347
+ `// GENERATED by \`wji crew install\` from the ${pack.crew.name} pack. Merge into ~/.openclaw/openclaw.json`,
348
+ `// (agents.entries + bindings). ${repoPlaceholder ? '${WORKSPACES} = ' + workspacesRoot : ''}`,
349
+ '{',
350
+ ' agents: {',
351
+ ` defaults: { subagents: { allowAgents: ["*"], maxConcurrent: 4, maxSpawnDepth: 2 } },`,
352
+ ' entries: {',
353
+ ...entries.filter(Boolean),
354
+ ' },',
355
+ ' },',
356
+ ` tools: { agentToAgent: { enabled: true, allow: ${JSON.stringify(Object.keys(pack.crew.agents))} } },`,
357
+ ' bindings: [',
358
+ ...bindings,
359
+ ' ],',
360
+ '}',
361
+ '',
362
+ ].join('\n');
363
+ }
364
+ export function findOpenclaw() {
365
+ const candidates = [join(homedir(), '.openclaw', 'bin', 'openclaw')];
366
+ for (const c of candidates)
367
+ if (existsSync(c))
368
+ return c;
369
+ const which = spawnSync('which', ['openclaw'], { encoding: 'utf8' });
370
+ const path = which.status === 0 ? which.stdout.trim() : '';
371
+ return path || undefined;
372
+ }
373
+ export function planOpenclaw(pack, o, opts = {}) {
374
+ const skillsRoot = agentsSkillsRoot(o);
375
+ const ops = skillOps(pack, skillsRoot, o, 'openclaw');
376
+ const root = openclawRoot(pack, o);
377
+ for (const [slug, persona] of Object.entries(pack.agents)) {
378
+ const ws = join(root, slug);
379
+ ops.push({ kind: 'file', path: join(ws, 'SOUL.md'), content: `# ${pack.crew.agents[slug].name}\n\n${persona.body.trimEnd()}\n`, note: `openclaw ${slug}/SOUL.md` });
380
+ ops.push({ kind: 'file', path: join(ws, 'AGENTS.md'), content: openclawSeatAgentsMd(pack, slug), note: `openclaw ${slug}/AGENTS.md` });
381
+ ops.push({ kind: 'link', path: join(ws, 'skills'), target: skillsRoot, note: `openclaw ${slug}/skills → shared skills` });
382
+ }
383
+ ops.push({ kind: 'file', path: join(root, 'openclaw.agents.json5'), content: openclawFragment(pack, root, false), note: 'openclaw config fragment (merge by hand)' });
384
+ const bin = opts.register ? findOpenclaw() : undefined;
385
+ if (bin) {
386
+ const listed = spawnSync(bin, ['agents', 'list', '--json'], { encoding: 'utf8' });
387
+ const existing = new Set();
388
+ try {
389
+ const parsed = JSON.parse(listed.stdout || '[]');
390
+ for (const a of Array.isArray(parsed) ? parsed : parsed.agents || [])
391
+ existing.add(String(a.id || a.name));
392
+ }
393
+ catch {
394
+ /* unreadable list — register everything and let openclaw refuse duplicates */
395
+ }
396
+ for (const slug of Object.keys(pack.agents)) {
397
+ if (existing.has(slug))
398
+ continue;
399
+ ops.push({ kind: 'exec', argv: [bin, 'agents', 'add', slug, '--workspace', join(root, slug), '--non-interactive'], note: `openclaw agents add ${slug}` });
400
+ }
401
+ }
402
+ return ops;
403
+ }
404
+ /** The export layout the Grok bot platform imports: skills/ + bots/*.json + channels/*.json. */
405
+ export function grokZip(pack) {
406
+ const entries = [];
407
+ const rootName = `${pack.crew.name}-export`;
408
+ const rewrite = (text) => text
409
+ // sibling-skill links become the platform's skill links
410
+ .replace(/\]\(\.\.\/([a-z0-9-]+)\/SKILL\.md\)/g, '](sand-workflow:$1)')
411
+ // sibling paths become the `{skill}/…` placeholder the platform's skills use
412
+ .replace(/\.\.\/([a-z0-9-]+)\//g, '{$1}/');
413
+ for (const [name, files] of pack.skills) {
414
+ for (const [path, content] of files) {
415
+ const text = /\.(md|mjs|js|json|txt|sh)$/i.test(path) ? rewrite(content.toString('utf8')) : content;
416
+ entries.push({ path: `${rootName}/skills/${name}/${path}`, content: text });
417
+ }
418
+ }
419
+ const bots = Object.entries(pack.crew.agents).map(([slug, spec]) => {
420
+ const persona = pack.agents[slug];
421
+ const description = `${String(persona.frontmatter.description || '')}\n\n${rewrite(persona.body).trim()}`;
422
+ return { slug, name: spec.name, title: spec.name, description };
423
+ });
424
+ for (const bot of bots)
425
+ entries.push({ path: `${rootName}/bots/${bot.slug}.json`, content: JSON.stringify(bot, null, 2) + '\n' });
426
+ const channels = Object.entries(pack.crew.channels || {}).map(([slug, ch]) => ({ slug, name: ch.name, memberSlugs: ch.members }));
427
+ for (const ch of channels)
428
+ entries.push({ path: `${rootName}/channels/${ch.slug}.json`, content: JSON.stringify(ch, null, 2) + '\n' });
429
+ const manifest = {
430
+ version: 2,
431
+ exportedAt: new Date().toISOString().slice(0, 10),
432
+ bots: bots.map((b) => ({ slug: b.slug, name: b.name, title: b.title, description: b.description })),
433
+ channels,
434
+ skills: [...pack.skills.keys()].sort(),
435
+ notes: `Portable ${pack.crew.title || pack.crew.name} pack built by \`wji crew install --for grok\`. Recreate bots from bots/*.json (name + description), channels from channels/*.json memberSlugs, copy every skills/<name> folder into the shared skills library. Account-local ids are never included.`,
436
+ };
437
+ entries.push({ path: `${rootName}/bots/crew-manifest.json`, content: JSON.stringify(manifest, null, 2) + '\n' });
438
+ const readme = [
439
+ `# ${pack.crew.title || pack.crew.name} export`,
440
+ '',
441
+ 'Portable pack: skill folders + bot personas + channel recipes.',
442
+ '',
443
+ '1. **Skills** — copy each folder under `skills/` into the account\'s shared skills library.',
444
+ '2. **Bots** — create one agent per `bots/*.json` using the same `name` and `description`' + (pack.crew.entry ? ` (start with ${pack.crew.agents[pack.crew.entry]?.name || pack.crew.entry}).` : '.'),
445
+ '3. **Channels** — create groups from `channels/*.json` and add members by the listed slugs.',
446
+ '',
447
+ '| Bot | Owns |',
448
+ '| --- | --- |',
449
+ ...bots.map((b) => `| ${b.name} | ${String(pack.agents[b.slug].frontmatter.description || '').split(/[.。]/)[0]} |`),
450
+ '',
451
+ ].join('\n');
452
+ entries.push({ path: `${rootName}/README.md`, content: readme });
453
+ return { zip: createZip(entries), entries: entries.length };
454
+ }
455
+ export function installedPath() {
456
+ return join(crewCacheDir(), 'installed.json');
457
+ }
458
+ export function readInstalled() {
459
+ const p = installedPath();
460
+ if (!existsSync(p))
461
+ return {};
462
+ try {
463
+ return JSON.parse(readFileSync(p, 'utf8'));
464
+ }
465
+ catch {
466
+ return {};
467
+ }
468
+ }
469
+ export function writeInstalled(data) {
470
+ mkdirSync(dirname(installedPath()), { recursive: true });
471
+ writeFileSync(installedPath(), JSON.stringify(data, null, 2) + '\n');
472
+ }
473
+ function removePath(path) {
474
+ if (!existsSync(path) && !isSymlink(path))
475
+ return;
476
+ if (isSymlink(path) || !lstatSync(path).isDirectory())
477
+ rmSync(path, { force: true });
478
+ else
479
+ rmSync(path, { recursive: true, force: true });
480
+ }
481
+ const isSymlink = (p) => {
482
+ try {
483
+ return lstatSync(p).isSymbolicLink();
484
+ }
485
+ catch {
486
+ return false;
487
+ }
488
+ };
489
+ /** Run the ops; returns every path written plus the notes of executed commands. */
490
+ export function applyOps(ops, log) {
491
+ const paths = [];
492
+ const notes = [];
493
+ for (const op of ops) {
494
+ if (op.kind === 'exec') {
495
+ const res = spawnSync(op.argv[0], op.argv.slice(1), { encoding: 'utf8' });
496
+ const tail = (res.stdout || res.stderr || '').trim().split('\n').slice(-1)[0] || '';
497
+ notes.push(`${op.note}: ${res.status === 0 ? 'ok' : `exit ${res.status}`}${tail ? ` — ${tail}` : ''}`);
498
+ log(` ${res.status === 0 ? '✓' : '✗'} ${op.note}${tail ? ` — ${tail}` : ''}`);
499
+ continue;
500
+ }
501
+ mkdirSync(dirname(op.path), { recursive: true });
502
+ if (op.kind === 'link') {
503
+ removePath(op.path);
504
+ symlinkSync(op.target, op.path);
505
+ }
506
+ else if (op.kind === 'file') {
507
+ writeFileSync(op.path, op.content);
508
+ }
509
+ else {
510
+ removePath(op.path);
511
+ for (const [rel, content] of op.files) {
512
+ const full = join(op.path, rel);
513
+ mkdirSync(dirname(full), { recursive: true });
514
+ writeFileSync(full, content);
515
+ }
516
+ }
517
+ paths.push(op.path);
518
+ log(` ✓ ${op.note} → ${op.path}`);
519
+ }
520
+ return { paths, notes };
521
+ }
522
+ export function describeOp(op) {
523
+ if (op.kind === 'exec')
524
+ return `run ${op.argv.join(' ')}`;
525
+ if (op.kind === 'link')
526
+ return `link ${op.path} → ${op.target}`;
527
+ if (op.kind === 'file')
528
+ return `write ${op.path}`;
529
+ return `copy ${op.path} (${op.files.size} files)`;
530
+ }
531
+ export function pathState(path) {
532
+ if (isSymlink(path))
533
+ return existsSync(path) ? 'link' : 'missing';
534
+ return existsSync(path) ? 'ok' : 'missing';
535
+ }
536
+ export function uninstallPaths(paths) {
537
+ for (const p of paths)
538
+ removePath(p);
539
+ }
540
+ export function relOrAbs(path) {
541
+ const rel = relative(process.cwd(), path);
542
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : path;
543
+ }
544
+ export function linkTarget(path) {
545
+ return isSymlink(path) ? readlinkSync(path) : undefined;
546
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { profileCommand } from './commands/profile.js';
16
16
  import { presetCommand } from './commands/preset.js';
17
17
  import { skillCommand } from './commands/skill.js';
18
18
  import { canvasCommand } from './commands/canvas.js';
19
+ import { crewCommand } from './commands/crew.js';
19
20
  // Read from package.json so `wji --version` cannot drift from the published
20
21
  // version — the skill compares the two to detect a stale install.
21
22
  const VERSION = createRequire(import.meta.url)('../package.json').version;
@@ -67,6 +68,7 @@ program.addCommand(profileCommand(globals));
67
68
  program.addCommand(presetCommand(globals));
68
69
  program.addCommand(skillCommand(globals));
69
70
  program.addCommand(canvasCommand(globals));
71
+ program.addCommand(crewCommand(globals));
70
72
  program.addHelpText('after', `
71
73
  Examples:
72
74
  wji auth login --email you@example.com
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -302,6 +302,15 @@ System keys currently include `image`, `document`, `workspace-dataset`,
302
302
  When authoring, edit a pulled folder and `skill push` it again, or
303
303
  `skill update <id> --dir ./my-skill` to replace its files.
304
304
 
305
+ **Crews** are skill packs with agent seats (a `crew.json` + `agents/<seat>.md`
306
+ next to `skills/`). `wji crew install <name> --for claude|codex|openclaw|grok|all`
307
+ installs one into the host (Claude Code: `~/.claude/skills` + `~/.claude/agents`;
308
+ Codex: `~/.agents/skills` + `~/.codex/agents/*.toml`; OpenClaw: workspaces under
309
+ `~/.openclaw/workspaces/<crew>/` plus a JSON5 fragment to merge by hand; Grok bot:
310
+ an import zip). `--dry-run` shows the plan, `--from <dir|zip|url>` bypasses the
311
+ registry, `wji crew status` / `uninstall` track what was written, and
312
+ `wji crew check <dir>` validates a pack. The `short-drama` crew is the known one.
313
+
305
314
  Bundle limits: at most 200 markdown files, 2MB per file, 20MB in total and a
306
315
  5MB zip; 50 user skills per account. `skill update --dir` sends files in the
307
316
  request body, where each file is capped far lower, at **200,000 characters** —
@@ -332,12 +341,17 @@ open). Every `wji canvas` command then targets that canvas; pass
332
341
  ```bash
333
342
  wji canvas status # linked canvas + selected nodes (ids, asset URLs, settings)
334
343
  wji canvas nodes # every node; * marks generate nodes
344
+ wji canvas nodes --tag C01 # nodes with the color tag labelled C01 (or --tag red, --untagged)
345
+ wji canvas nodes -n '^C0[1-3] ' # nodes whose name matches a regex (case-insensitive); combines with --tag/-k
346
+ wji canvas tags # color tags on the canvas: color, label, node count
335
347
  wji canvas get <nodeId> # full detail incl. references
336
348
  wji asset download <assetId…> -o <dir> # then look at the images yourself
337
349
 
338
350
  wji canvas create E01 -w <workspaceId> # new empty canvas; then --canvas <id>
339
351
  wji canvas add video --from <imgNode> <imgNode> -m <model> -r 16:9 -d 8 -p "@image1 … @image2 …"
340
352
  wji canvas add text -t "shot list…" --at 0,600
353
+ wji canvas add text --text-file prompt.txt --plain --size 600,900 # plain text keeps its line breaks; node 600×900
354
+ wji canvas set <nodeId> --text-file notes.md --size 520,700 # replace the text, resize
341
355
  wji canvas add image --asset <assetId> -n "Ada sheet" # display node for an existing asset
342
356
  wji canvas add dataset --dataset <fsnodeId> # card bound to a workspace dataset
343
357
  wji canvas set <nodeId> -p "new prompt" # merges: other settings stay
@@ -387,6 +401,10 @@ wji canvas rm <nodeId…> # destructive — see Rules
387
401
  - Take new ids from the command's own output (`wji --json canvas add … | jq
388
402
  -r .id`), not by searching names — names repeat on a canvas. In zsh a
389
403
  variable holding several ids is not word-split; pass the ids literally.
404
+ - Text, title and comment nodes are **Markdown** on the web: a single line break
405
+ joins the lines into one paragraph (the CLI warns). For prompts and other plain
406
+ text pass `--plain` (line breaks become Markdown hard breaks); `--size w,h`
407
+ sets the node's box so a long prompt is readable without scrolling.
390
408
  - Changes show up live in the user's open canvas; there's no need to ask them
391
409
  to reload.
392
410
  - `canvas gen` sends exactly what the canvas's own Generate button sends
@@ -162,11 +162,12 @@ updates live.
162
162
  | `canvas unlink` | `DELETE /canvas-link`; the tab turns its button off at its next heartbeat (≤ 60s) |
163
163
  | `canvas list [-w]` | canvases under each InfiniteCanvas app: `GET /workspace/list/:id/fs/:appId` → `workspace-v2/canvases` children |
164
164
  | `canvas create <name> [-w] [--app]` | `POST /fsnode/create` a `workspace-v2/canvas` folder under that `canvases` folder with the web's initial content (`{data:{viewport, backgroundMode:'dots'}, metadata:{}}`) |
165
- | `canvas nodes [-c] [-k kind]` | `GET /fsnode/list/:canvasId/children?limit=-1` + `POST /assets/check-status` |
165
+ | `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) |
166
+ | `canvas tags [-c] [-a]` | the canvas's color tags: color, label, node count (`-a` also unused, unlabelled colors) |
166
167
  | `canvas get <nodeId> [-c]` | same load; one node with references (`@image1 ← id`) and frame slots |
167
168
  | `canvas edges [-c] [--node]` | the `canvas/edge` children |
168
- | `canvas add <kind> [-c] [-n] [-t\|--text-file] [--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` |
169
- | `canvas set <nodeId> [-c] [-n] [-t] [--move x,y] [-p] [-m] [-r] [--resolution] [-d] [--set]` | read → merge → `PATCH /fsnode/list/:id` |
169
+ | `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` |
170
+ | `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` |
170
171
  | `canvas connect <from> <to> [-c]` | `POST /fsnode/create` (`canvas/edge`); a first/last-frame video model gets `videoFrameSlot` start, then end |
171
172
  | `canvas disconnect <from> <to> [-c]` | `DELETE /fsnode/list/:edgeId` |
172
173
  | `canvas rm <nodeIds...> [-c] [-y]` | deletes the nodes' edges, then the nodes; refuses nodes still generating |
@@ -179,6 +180,12 @@ Storage format (what the web canvas writes):
179
180
  `generate` with `content.generate {prompt, model, ratio, resolution,
180
181
  duration, values, improvePromptEnabled, historyId?, pendingAssetId?}`. Width
181
182
  and height are not stored.
183
+ - Color tag: the node stores only `content.colorTag {colorId}`, one of
184
+ `rose` (red), `orange`, `amber` (yellow), `lime` (green), `teal`, `sky`
185
+ (blue), `violet` (purple), `fuchsia` (pink). The label is per color per
186
+ canvas, on the canvas folder: `content.colorTagNames {rose: "C01", …}`, so
187
+ every red node on a canvas shares one label. A `name` left on an old node's
188
+ `colorTag` is ignored, as on the web.
182
189
  - Edge: `mimetype canvas/edge`, name `连线`, `content {fromNodeId, toNodeId,
183
190
  sourceHandle, targetHandle, videoFrameSlot?}`. References are incoming edges
184
191
  sorted by `sort` then id, numbered per source kind.
@@ -339,6 +346,28 @@ Only `.md` files persist (the importer drops everything else); scripts belong to
339
346
  | `skill update <id> [-n] [-d] [--visibility] [--dir] [-y]` | `PUT /ai/skill/:id` — metadata, or replace **all** files from `--dir` (confirms first) |
340
347
  | `skill rm <id> [-y]` | `DELETE /ai/skill/:id` |
341
348
 
349
+ ## crew
350
+
351
+ Crew packs are local or remote folders (`crew.json`, `agents/<seat>.md`, `skills/<name>/SKILL.md`, `AGENTS.md`) — no WJI API involved.
352
+
353
+ | Command | Behaviour |
354
+ | --- | --- |
355
+ | `crew install [name] --for <claude,codex,openclaw,grok\|all> [--from dir\|zip\|tar.gz\|url] [--ref] [--project dir] [--link] [--out zip] [--no-register] [--dry-run] [-y]` | fetches the pack (registry → GitHub tarball, cached under `~/.wji/crews/`), validates it, prints the plan, then per host: **claude** copies `skills/*` → `~/.claude/skills/` and `agents/*.md` → `~/.claude/agents/`; **codex** copies skills → `~/.agents/skills/` and writes `~/.codex/agents/<seat>.toml` (`developer_instructions` = persona body, `sandbox_mode` from capabilities, `[[skills.config]]` per skill); **openclaw** shares `~/.agents/skills/`, writes `~/.openclaw/workspaces/<crew>/<seat>/{AGENTS.md,SOUL.md,skills→}` + `openclaw.agents.json5` and runs `openclaw agents add` when the binary exists; **grok** writes `<crew>-export.zip` (`skills/`, `bots/*.json`, `bots/crew-manifest.json`, `channels/*.json`, sibling links rewritten to `sand-workflow:`). `--project` targets `<dir>/.claude`, `.agents`, `.codex`, `openclaw` instead of `$HOME` |
356
+ | `crew status [name]` | reads `~/.wji/crews/installed.json`; per path ✓ / ↗ symlink / ✗ missing |
357
+ | `crew uninstall <name> [--for targets] [-y]` | removes exactly the recorded paths; reminds you to clean `openclaw.json` by hand |
358
+ | `crew check <dir>` | validates `crew.json` ↔ `agents/*.md` (names, skills, tools vs capabilities), every `SKILL.md` (name == folder, description ≤ 1024) and that relative links resolve |
359
+
360
+ ## crew
361
+
362
+ Crew packs are local/remote folders (`crew.json`, `agents/<seat>.md`, `skills/<name>/SKILL.md`, `AGENTS.md`) — no WJI API involved.
363
+
364
+ | Command | Behaviour |
365
+ | --- | --- |
366
+ | `crew install [name] --for <claude,codex,openclaw,grok\|all> [--from dir\|zip\|tar.gz\|url] [--ref] [--project dir] [--link] [--out zip] [--no-register] [--dry-run] [-y]` | fetches the pack (registry → GitHub tarball, cached under `~/.wji/crews/`), validates it, prints the plan, then per host: **claude** copies `skills/*` → `~/.claude/skills/` and `agents/*.md` → `~/.claude/agents/`; **codex** copies skills → `~/.agents/skills/` and writes `~/.codex/agents/<seat>.toml` (`developer_instructions` = persona body, `sandbox_mode` from capabilities, `[[skills.config]]` per skill); **openclaw** shares `~/.agents/skills/`, writes `~/.openclaw/workspaces/<crew>/<seat>/{AGENTS.md,SOUL.md,skills→}` + `openclaw.agents.json5` and runs `openclaw agents add` when the binary exists; **grok** writes `<crew>-export.zip` (`skills/`, `bots/*.json`, `bots/crew-manifest.json`, `channels/*.json`, sibling links rewritten to `sand-workflow:`). `--project` targets `<dir>/.claude`, `.agents`, `.codex`, `openclaw` instead of `$HOME` |
367
+ | `crew status [name]` | reads `~/.wji/crews/installed.json`; per path ✓ / ↗ symlink / ✗ missing |
368
+ | `crew uninstall <name> [--for targets] [-y]` | removes exactly the recorded paths; reminds you to clean `openclaw.json` by hand |
369
+ | `crew check <dir>` | validates `crew.json` ↔ `agents/*.md` (names, skills, tools vs capabilities), every `SKILL.md` (name == folder, description ≤ 1024) and that relative links resolve |
370
+
342
371
  Import rules: the zip must contain `SKILL.md` at its root (or one folder deep — a
343
372
  single common root folder is stripped). `SKILL.md` frontmatter `name:` is
344
373
  required; `description:` is read too. Visibility is `private` (default) or