@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 +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/short-drama/SKILL.md +41 -24
- package/skill/short-drama/reference/canvas-recipes.md +47 -27
- package/skill/short-drama/reference/operations-cli.md +43 -0
- package/skill/short-drama/reference/operations-wji.md +82 -0
- package/skill/short-drama/reference/schema.md +1 -1
- package/skill/short-drama/reference/stage-art.md +2 -1
- package/skill/short-drama/reference/stage-storyboard.md +7 -4
- package/skill/short-drama/reference/workspace-layout.md +18 -6
- package/skill/short-drama/scripts/selftest.mjs +4 -0
- package/skill/short-drama/scripts/short-drama.mjs +30 -14
- 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
|
+
}
|