@clawcipes/recipes 0.1.2 → 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 +295 -14
  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,16 +313,37 @@ async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippe
262
313
  return { updatedAgents: snippets.map((s) => s.id) };
263
314
  }
264
315
 
265
- async function scaffoldAgentFromRecipe(api: OpenClawPluginApi, recipe: RecipeFrontmatter, opts: {
266
- agentId: string;
267
- agentName?: string;
268
- update?: boolean;
269
- vars?: Record<string, string>;
270
- }) {
271
- const cfg = getCfg(api);
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;
272
320
 
273
- const agentDir = workspacePath(api, cfg.workspaceAgentsDir, opts.agentId);
274
- await ensureDir(agentDir);
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
+
330
+ async function scaffoldAgentFromRecipe(
331
+ api: OpenClawPluginApi,
332
+ recipe: RecipeFrontmatter,
333
+ opts: {
334
+ agentId: string;
335
+ agentName?: string;
336
+ update?: boolean;
337
+ vars?: Record<string, string>;
338
+
339
+ // Where to write the scaffolded files (may be a shared team workspace role folder)
340
+ filesRootDir: string;
341
+
342
+ // What to set in agents.list[].workspace (may be shared team workspace root)
343
+ workspaceRootDir: string;
344
+ },
345
+ ) {
346
+ await ensureDir(opts.filesRootDir);
275
347
 
276
348
  const templates = recipe.templates ?? {};
277
349
  const files = recipe.files ?? [];
@@ -282,7 +354,7 @@ async function scaffoldAgentFromRecipe(api: OpenClawPluginApi, recipe: RecipeFro
282
354
  const raw = templates[f.template];
283
355
  if (typeof raw !== "string") throw new Error(`Missing template: ${f.template}`);
284
356
  const rendered = renderTemplate(raw, vars);
285
- const target = path.join(agentDir, f.path);
357
+ const target = path.join(opts.filesRootDir, f.path);
286
358
  const mode = opts.update ? (f.mode ?? "overwrite") : (f.mode ?? "createOnly");
287
359
  const r = await writeFileSafely(target, rendered, mode);
288
360
  fileResults.push({ path: target, wrote: r.wrote, reason: r.reason });
@@ -290,13 +362,14 @@ async function scaffoldAgentFromRecipe(api: OpenClawPluginApi, recipe: RecipeFro
290
362
 
291
363
  const configSnippet: AgentConfigSnippet = {
292
364
  id: opts.agentId,
293
- workspace: agentDir,
365
+ workspace: opts.workspaceRootDir,
294
366
  identity: { name: opts.agentName ?? recipe.name ?? opts.agentId },
295
367
  tools: recipe.tools ?? {},
296
368
  };
297
369
 
298
370
  return {
299
- agentDir,
371
+ filesRootDir: opts.filesRootDir,
372
+ workspaceRootDir: opts.workspaceRootDir,
300
373
  fileResults,
301
374
  next: {
302
375
  configSnippet,
@@ -393,6 +466,194 @@ const recipesPlugin = {
393
466
  console.log(JSON.stringify(out, null, 2));
394
467
  });
395
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
+
396
657
  cmd
397
658
  .command("install")
398
659
  .description("Install a ClawHub skill into this OpenClaw workspace (confirmation-gated)")
@@ -509,7 +770,8 @@ const recipesPlugin = {
509
770
  if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
510
771
 
511
772
  const teamId = String(options.teamId);
512
- const teamDir = workspacePath(api, cfg.workspaceTeamsDir, teamId);
773
+ // Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
774
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
513
775
 
514
776
  const inboxDir = path.join(teamDir, "inbox");
515
777
  const backlogDir = path.join(teamDir, "work", "backlog");
@@ -658,10 +920,16 @@ const recipesPlugin = {
658
920
  return;
659
921
  }
660
922
 
923
+ const baseWorkspace = api.config.agents?.defaults?.workspace ?? "~/.openclaw/workspace";
924
+ // Put standalone agent workspaces alongside the default workspace (same parent dir).
925
+ const resolvedWorkspaceRoot = path.resolve(baseWorkspace, "..", `workspace-${options.agentId}`);
926
+
661
927
  const result = await scaffoldAgentFromRecipe(api, recipe, {
662
928
  agentId: options.agentId,
663
929
  agentName: options.name,
664
930
  update: !!options.overwrite,
931
+ filesRootDir: resolvedWorkspaceRoot,
932
+ workspaceRootDir: resolvedWorkspaceRoot,
665
933
  vars: {
666
934
  agentId: options.agentId,
667
935
  agentName: options.name ?? recipe.name ?? options.agentId,
@@ -702,8 +970,15 @@ const recipesPlugin = {
702
970
  return;
703
971
  }
704
972
 
705
- const teamDir = workspacePath(api, cfg.workspaceTeamsDir, teamId);
973
+ const baseWorkspace = api.config.agents?.defaults?.workspace;
974
+ if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
975
+
976
+ // Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
977
+ const teamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
706
978
  await ensureDir(teamDir);
979
+
980
+ const rolesDir = path.join(teamDir, "roles");
981
+ await ensureDir(rolesDir);
707
982
  const notesDir = path.join(teamDir, "notes");
708
983
  const workDir = path.join(teamDir, "work");
709
984
  const backlogDir = path.join(workDir, "backlog");
@@ -761,16 +1036,22 @@ const recipesPlugin = {
761
1036
  tools: a.tools ?? recipe.tools,
762
1037
  };
763
1038
 
1039
+ const roleDir = path.join(rolesDir, role);
764
1040
  const r = await scaffoldAgentFromRecipe(api, scopedRecipe, {
765
1041
  agentId,
766
1042
  agentName,
767
1043
  update: !!options.overwrite,
1044
+ // Write role-specific files under roles/<role>/
1045
+ filesRootDir: roleDir,
1046
+ // But set the agent workspace root to the shared team workspace
1047
+ workspaceRootDir: teamDir,
768
1048
  vars: {
769
1049
  teamId,
770
1050
  teamDir,
771
1051
  role,
772
1052
  agentId,
773
1053
  agentName,
1054
+ roleDir,
774
1055
  },
775
1056
  });
776
1057
  results.push({ role, agentId, ...r });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawcipes/recipes",
3
- "version": "0.1.2",
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",