@clawcipes/recipes 0.1.3 → 0.1.4
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 +2 -0
- package/index.ts +253 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<img src="./clawcipes_cook.jpg" alt="Clawcipes logo" width="240" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
+
> **Experimental:** We’re in active development. Installing should not have any negative impacts, but it’s always good to be safe and copy your `~/.openclaw` folder to a backup.
|
|
8
|
+
|
|
7
9
|
Clawcipes is an OpenClaw plugin that provides **CLI-first recipes** for scaffolding specialist agents and teams from Markdown.
|
|
8
10
|
|
|
9
11
|
If you like durable workflows: Clawcipes is built around a **file-first team workspace** (inbox/backlog/in-progress/done) that plays nicely with git.
|
package/index.ts
CHANGED
|
@@ -180,6 +180,20 @@ type AgentConfigSnippet = {
|
|
|
180
180
|
tools?: { profile?: string; allow?: string[]; deny?: string[] };
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
+
type BindingMatch = {
|
|
184
|
+
channel: string;
|
|
185
|
+
accountId?: string;
|
|
186
|
+
// OpenClaw config schema uses: dm | group | channel
|
|
187
|
+
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
|
188
|
+
guildId?: string;
|
|
189
|
+
teamId?: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
type BindingSnippet = {
|
|
193
|
+
agentId: string;
|
|
194
|
+
match: BindingMatch;
|
|
195
|
+
};
|
|
196
|
+
|
|
183
197
|
function upsertAgentInConfig(cfgObj: any, snippet: AgentConfigSnippet) {
|
|
184
198
|
if (!cfgObj.agents) cfgObj.agents = {};
|
|
185
199
|
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
@@ -242,6 +256,43 @@ function ensureMainFirstInAgentsList(cfgObj: any, api: OpenClawPluginApi) {
|
|
|
242
256
|
list.unshift(main);
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
function stableStringify(x: any) {
|
|
260
|
+
const seen = new WeakSet();
|
|
261
|
+
const sortObj = (v: any): any => {
|
|
262
|
+
if (v && typeof v === "object") {
|
|
263
|
+
if (seen.has(v)) return "[Circular]";
|
|
264
|
+
seen.add(v);
|
|
265
|
+
if (Array.isArray(v)) return v.map(sortObj);
|
|
266
|
+
const out: any = {};
|
|
267
|
+
for (const k of Object.keys(v).sort()) out[k] = sortObj(v[k]);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
return v;
|
|
271
|
+
};
|
|
272
|
+
return JSON.stringify(sortObj(x));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
276
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
277
|
+
const list: any[] = cfgObj.bindings;
|
|
278
|
+
|
|
279
|
+
const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
280
|
+
const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
|
|
281
|
+
|
|
282
|
+
if (idx >= 0) {
|
|
283
|
+
// Update in place (preserve ordering)
|
|
284
|
+
list[idx] = { ...list[idx], ...binding };
|
|
285
|
+
return { changed: false, note: "already-present" as const };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
289
|
+
// Otherwise append.
|
|
290
|
+
if (binding.match?.peer) list.unshift(binding);
|
|
291
|
+
else list.push(binding);
|
|
292
|
+
|
|
293
|
+
return { changed: true, note: "added" as const };
|
|
294
|
+
}
|
|
295
|
+
|
|
245
296
|
async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
|
|
246
297
|
// Load the latest config from disk (not the snapshot in api.config).
|
|
247
298
|
const current = (api.runtime as any).config?.loadConfig?.();
|
|
@@ -262,6 +313,20 @@ async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippe
|
|
|
262
313
|
return { updatedAgents: snippets.map((s) => s.id) };
|
|
263
314
|
}
|
|
264
315
|
|
|
316
|
+
async function applyBindingSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: BindingSnippet[]) {
|
|
317
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
318
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
319
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
320
|
+
|
|
321
|
+
const results: any[] = [];
|
|
322
|
+
for (const s of snippets) {
|
|
323
|
+
results.push({ ...s, result: upsertBindingInConfig(cfgObj, s) });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
327
|
+
return { updatedBindings: results };
|
|
328
|
+
}
|
|
329
|
+
|
|
265
330
|
async function scaffoldAgentFromRecipe(
|
|
266
331
|
api: OpenClawPluginApi,
|
|
267
332
|
recipe: RecipeFrontmatter,
|
|
@@ -401,6 +466,194 @@ const recipesPlugin = {
|
|
|
401
466
|
console.log(JSON.stringify(out, null, 2));
|
|
402
467
|
});
|
|
403
468
|
|
|
469
|
+
cmd
|
|
470
|
+
.command("bind")
|
|
471
|
+
.description("Add/update a multi-agent routing binding (writes openclaw.json bindings[])")
|
|
472
|
+
.requiredOption("--agent-id <agentId>", "Target agent id")
|
|
473
|
+
.requiredOption("--channel <channel>", "Channel name (telegram|whatsapp|discord|slack|...) ")
|
|
474
|
+
.option("--account-id <accountId>", "Channel accountId (if applicable)")
|
|
475
|
+
.option("--peer-kind <kind>", "Peer kind (dm|group|channel) (aliases: direct->dm)")
|
|
476
|
+
.option("--peer-id <id>", "Peer id (DM number/id, group id, or channel id)")
|
|
477
|
+
.option("--guild-id <guildId>", "Discord guildId")
|
|
478
|
+
.option("--team-id <teamId>", "Slack teamId")
|
|
479
|
+
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
480
|
+
.action(async (options: any) => {
|
|
481
|
+
const agentId = String(options.agentId);
|
|
482
|
+
let match: BindingMatch;
|
|
483
|
+
|
|
484
|
+
if (options.match) {
|
|
485
|
+
match = JSON5.parse(String(options.match)) as BindingMatch;
|
|
486
|
+
} else {
|
|
487
|
+
match = {
|
|
488
|
+
channel: String(options.channel),
|
|
489
|
+
};
|
|
490
|
+
if (options.accountId) match.accountId = String(options.accountId);
|
|
491
|
+
if (options.guildId) match.guildId = String(options.guildId);
|
|
492
|
+
if (options.teamId) match.teamId = String(options.teamId);
|
|
493
|
+
|
|
494
|
+
if (options.peerKind || options.peerId) {
|
|
495
|
+
if (!options.peerKind || !options.peerId) {
|
|
496
|
+
throw new Error("--peer-kind and --peer-id must be provided together");
|
|
497
|
+
}
|
|
498
|
+
let kind = String(options.peerKind);
|
|
499
|
+
// Back-compat alias
|
|
500
|
+
if (kind === "direct") kind = "dm";
|
|
501
|
+
if (kind !== "dm" && kind !== "group" && kind !== "channel") {
|
|
502
|
+
throw new Error("--peer-kind must be dm|group|channel (or direct as alias for dm)");
|
|
503
|
+
}
|
|
504
|
+
match.peer = { kind, id: String(options.peerId) };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!match?.channel) throw new Error("match.channel is required");
|
|
509
|
+
|
|
510
|
+
const res = await applyBindingSnippetsToOpenClawConfig(api, [{ agentId, match }]);
|
|
511
|
+
console.log(JSON.stringify(res, null, 2));
|
|
512
|
+
console.error("Binding written. Restart gateway if required for changes to take effect.");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
cmd
|
|
516
|
+
.command("bindings")
|
|
517
|
+
.description("Show current bindings from openclaw config")
|
|
518
|
+
.action(async () => {
|
|
519
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
520
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
521
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
522
|
+
console.log(JSON.stringify(cfgObj.bindings ?? [], null, 2));
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
cmd
|
|
526
|
+
.command("migrate-team")
|
|
527
|
+
.description("Migrate a legacy team scaffold into the new workspace-<teamId> layout")
|
|
528
|
+
.requiredOption("--team-id <teamId>", "Team id (must end with -team)")
|
|
529
|
+
.option("--mode <mode>", "move|copy", "move")
|
|
530
|
+
.option("--dry-run", "Print the plan without writing anything", false)
|
|
531
|
+
.option("--overwrite", "Allow merging into an existing destination (dangerous)", false)
|
|
532
|
+
.action(async (options: any) => {
|
|
533
|
+
const teamId = String(options.teamId);
|
|
534
|
+
if (!teamId.endsWith("-team")) throw new Error("teamId must end with -team");
|
|
535
|
+
|
|
536
|
+
const mode = String(options.mode ?? "move");
|
|
537
|
+
if (mode !== "move" && mode !== "copy") throw new Error("--mode must be move|copy");
|
|
538
|
+
|
|
539
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
540
|
+
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
541
|
+
|
|
542
|
+
const legacyTeamDir = path.resolve(baseWorkspace, "teams", teamId);
|
|
543
|
+
const legacyAgentsDir = path.resolve(baseWorkspace, "agents");
|
|
544
|
+
|
|
545
|
+
const destTeamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
|
|
546
|
+
const destRolesDir = path.join(destTeamDir, "roles");
|
|
547
|
+
|
|
548
|
+
const exists = async (p: string) => fileExists(p);
|
|
549
|
+
|
|
550
|
+
// Build migration plan
|
|
551
|
+
const plan: any = {
|
|
552
|
+
teamId,
|
|
553
|
+
mode,
|
|
554
|
+
legacy: { teamDir: legacyTeamDir, agentsDir: legacyAgentsDir },
|
|
555
|
+
dest: { teamDir: destTeamDir, rolesDir: destRolesDir },
|
|
556
|
+
steps: [] as any[],
|
|
557
|
+
agentIds: [] as string[],
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const legacyTeamExists = await exists(legacyTeamDir);
|
|
561
|
+
if (!legacyTeamExists) {
|
|
562
|
+
throw new Error(`Legacy team directory not found: ${legacyTeamDir}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const destExists = await exists(destTeamDir);
|
|
566
|
+
if (destExists && !options.overwrite) {
|
|
567
|
+
throw new Error(`Destination already exists: ${destTeamDir} (re-run with --overwrite to merge)`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 1) Move/copy team shared workspace
|
|
571
|
+
plan.steps.push({ kind: "teamDir", from: legacyTeamDir, to: destTeamDir });
|
|
572
|
+
|
|
573
|
+
// 2) Move/copy each role agent directory into roles/<role>/
|
|
574
|
+
const legacyAgentsExist = await exists(legacyAgentsDir);
|
|
575
|
+
let legacyAgentFolders: string[] = [];
|
|
576
|
+
if (legacyAgentsExist) {
|
|
577
|
+
legacyAgentFolders = (await fs.readdir(legacyAgentsDir)).filter((x) => x.startsWith(`${teamId}-`));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const folder of legacyAgentFolders) {
|
|
581
|
+
const agentId = folder;
|
|
582
|
+
const role = folder.slice((teamId + "-").length);
|
|
583
|
+
const from = path.join(legacyAgentsDir, folder);
|
|
584
|
+
const to = path.join(destRolesDir, role);
|
|
585
|
+
plan.agentIds.push(agentId);
|
|
586
|
+
plan.steps.push({ kind: "roleDir", agentId, role, from, to });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const dryRun = !!options.dryRun;
|
|
590
|
+
if (dryRun) {
|
|
591
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, plan }, null, 2));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Helpers
|
|
596
|
+
const copyDirRecursive = async (src: string, dst: string) => {
|
|
597
|
+
await ensureDir(dst);
|
|
598
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
599
|
+
for (const ent of entries) {
|
|
600
|
+
const s = path.join(src, ent.name);
|
|
601
|
+
const d = path.join(dst, ent.name);
|
|
602
|
+
if (ent.isDirectory()) await copyDirRecursive(s, d);
|
|
603
|
+
else if (ent.isSymbolicLink()) {
|
|
604
|
+
const link = await fs.readlink(s);
|
|
605
|
+
await fs.symlink(link, d);
|
|
606
|
+
} else {
|
|
607
|
+
await ensureDir(path.dirname(d));
|
|
608
|
+
await fs.copyFile(s, d);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const removeDirRecursive = async (p: string) => {
|
|
614
|
+
// node 25 supports fs.rm
|
|
615
|
+
await fs.rm(p, { recursive: true, force: true });
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const moveDir = async (src: string, dst: string) => {
|
|
619
|
+
await ensureDir(path.dirname(dst));
|
|
620
|
+
try {
|
|
621
|
+
await fs.rename(src, dst);
|
|
622
|
+
} catch {
|
|
623
|
+
// cross-device or existing: fallback to copy+remove
|
|
624
|
+
await copyDirRecursive(src, dst);
|
|
625
|
+
await removeDirRecursive(src);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// Execute plan
|
|
630
|
+
if (mode === "copy") {
|
|
631
|
+
await copyDirRecursive(legacyTeamDir, destTeamDir);
|
|
632
|
+
} else {
|
|
633
|
+
await moveDir(legacyTeamDir, destTeamDir);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Ensure roles dir exists
|
|
637
|
+
await ensureDir(destRolesDir);
|
|
638
|
+
|
|
639
|
+
for (const step of plan.steps.filter((s: any) => s.kind === "roleDir")) {
|
|
640
|
+
if (!(await exists(step.from))) continue;
|
|
641
|
+
if (mode === "copy") await copyDirRecursive(step.from, step.to);
|
|
642
|
+
else await moveDir(step.from, step.to);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Update config: set each team agent's workspace to destTeamDir (shared)
|
|
646
|
+
const agentSnippets: AgentConfigSnippet[] = plan.agentIds.map((agentId: string) => ({
|
|
647
|
+
id: agentId,
|
|
648
|
+
workspace: destTeamDir,
|
|
649
|
+
}));
|
|
650
|
+
if (agentSnippets.length) {
|
|
651
|
+
await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
console.log(JSON.stringify({ ok: true, migrated: teamId, destTeamDir, agentIds: plan.agentIds }, null, 2));
|
|
655
|
+
});
|
|
656
|
+
|
|
404
657
|
cmd
|
|
405
658
|
.command("install")
|
|
406
659
|
.description("Install a ClawHub skill into this OpenClaw workspace (confirmation-gated)")
|