@wji.ai/cli 0.2.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
+ }