@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 +28 -0
- package/dist/commands/canvas.js +169 -4
- package/dist/commands/crew.js +205 -0
- package/dist/core/archive.js +95 -0
- package/dist/core/crew.js +546 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/skill/wji/SKILL.md +18 -0
- package/skill/wji/reference/commands.md +32 -3
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`.
|
package/dist/commands/canvas.js
CHANGED
|
@@ -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
|
|
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
|
|
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('--
|
|
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
|
|
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
package/skill/wji/SKILL.md
CHANGED
|
@@ -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
|