@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/index.ts +253 -0
  3. 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)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawcipes/recipes",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Clawcipes recipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",