@clawcipes/recipes 0.1.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/index.ts ADDED
@@ -0,0 +1,747 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import JSON5 from "json5";
5
+ import YAML from "yaml";
6
+
7
+ type RecipesConfig = {
8
+ workspaceRecipesDir?: string;
9
+ workspaceAgentsDir?: string;
10
+ workspaceSkillsDir?: string;
11
+ workspaceTeamsDir?: string;
12
+ autoInstallMissingSkills?: boolean;
13
+ confirmAutoInstall?: boolean;
14
+ };
15
+
16
+ type RecipeFrontmatter = {
17
+ id: string;
18
+ name?: string;
19
+ version?: string;
20
+ description?: string;
21
+ kind?: "agent" | "team";
22
+
23
+ // skill deps (installed into workspace-local skills dir)
24
+ requiredSkills?: string[];
25
+ optionalSkills?: string[];
26
+
27
+ // Team recipe: defines a team workspace + multiple agents.
28
+ team?: {
29
+ teamId: string; // must end with -team
30
+ name?: string;
31
+ description?: string;
32
+ };
33
+ agents?: Array<{
34
+ role: string;
35
+ agentId?: string; // default: <teamId>-<role>
36
+ name?: string; // display name
37
+ // Optional per-role tool policy override (else uses top-level tools)
38
+ tools?: {
39
+ profile?: string;
40
+ allow?: string[];
41
+ deny?: string[];
42
+ };
43
+ }>;
44
+
45
+ // Agent recipe: templates + files to write in the agent folder.
46
+ // For team recipes, templates can be namespaced by role, e.g. "lead.soul", "writer.agents".
47
+ templates?: Record<string, string>;
48
+ files?: Array<{
49
+ path: string;
50
+ template: string; // key in templates map
51
+ mode?: "createOnly" | "overwrite";
52
+ }>;
53
+
54
+ // Tool policy (applies to agent recipe; team recipes can override per agent)
55
+ tools?: {
56
+ profile?: string;
57
+ allow?: string[];
58
+ deny?: string[];
59
+ };
60
+ };
61
+
62
+ function getCfg(api: OpenClawPluginApi): Required<RecipesConfig> {
63
+ const cfg = (api.config.plugins?.entries?.["recipes"]?.config ??
64
+ api.config.plugins?.entries?.recipes?.config ??
65
+ {}) as RecipesConfig;
66
+
67
+ return {
68
+ workspaceRecipesDir: cfg.workspaceRecipesDir ?? "recipes",
69
+ workspaceAgentsDir: cfg.workspaceAgentsDir ?? "agents",
70
+ workspaceSkillsDir: cfg.workspaceSkillsDir ?? "skills",
71
+ workspaceTeamsDir: cfg.workspaceTeamsDir ?? "teams",
72
+ autoInstallMissingSkills: cfg.autoInstallMissingSkills ?? false,
73
+ confirmAutoInstall: cfg.confirmAutoInstall ?? true,
74
+ };
75
+ }
76
+
77
+ function workspacePath(api: OpenClawPluginApi, ...parts: string[]) {
78
+ const root = api.config.agents?.defaults?.workspace;
79
+ if (!root) throw new Error("agents.defaults.workspace is not set in config");
80
+ return path.join(root, ...parts);
81
+ }
82
+
83
+ async function fileExists(p: string) {
84
+ try {
85
+ await fs.stat(p);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ async function listRecipeFiles(api: OpenClawPluginApi, cfg: Required<RecipesConfig>) {
93
+ const builtinDir = path.join(__dirname, "recipes", "default");
94
+ const workspaceDir = workspacePath(api, cfg.workspaceRecipesDir);
95
+
96
+ const out: Array<{ source: "builtin" | "workspace"; path: string }> = [];
97
+
98
+ if (await fileExists(builtinDir)) {
99
+ const files = await fs.readdir(builtinDir);
100
+ for (const f of files) if (f.endsWith(".md")) out.push({ source: "builtin", path: path.join(builtinDir, f) });
101
+ }
102
+
103
+ if (await fileExists(workspaceDir)) {
104
+ const files = await fs.readdir(workspaceDir);
105
+ for (const f of files) if (f.endsWith(".md")) out.push({ source: "workspace", path: path.join(workspaceDir, f) });
106
+ }
107
+
108
+ return out;
109
+ }
110
+
111
+ function parseFrontmatter(md: string): { frontmatter: RecipeFrontmatter; body: string } {
112
+ // very small frontmatter parser: expects ---\nYAML\n---\n
113
+ if (!md.startsWith("---\n")) {
114
+ throw new Error("Recipe markdown must start with YAML frontmatter (---)");
115
+ }
116
+ const end = md.indexOf("\n---\n", 4);
117
+ if (end === -1) throw new Error("Recipe frontmatter not terminated (---)");
118
+ const yamlText = md.slice(4, end + 1); // include trailing newline
119
+ const body = md.slice(end + 5);
120
+ const frontmatter = YAML.parse(yamlText) as RecipeFrontmatter;
121
+ if (!frontmatter?.id) throw new Error("Recipe frontmatter must include id");
122
+ return { frontmatter, body };
123
+ }
124
+
125
+ async function loadRecipeById(api: OpenClawPluginApi, recipeId: string) {
126
+ const cfg = getCfg(api);
127
+ const files = await listRecipeFiles(api, cfg);
128
+ for (const f of files) {
129
+ const md = await fs.readFile(f.path, "utf8");
130
+ const { frontmatter } = parseFrontmatter(md);
131
+ if (frontmatter.id === recipeId) return { file: f, md, ...parseFrontmatter(md) };
132
+ }
133
+ throw new Error(`Recipe not found: ${recipeId}`);
134
+ }
135
+
136
+ function skillInstallCommands(cfg: Required<RecipesConfig>, skills: string[]) {
137
+ // We standardize on clawhub CLI. Workspace-local install path is implicit by running from workspace
138
+ // OR by environment var if clawhub supports it (unknown). For now: cd workspace + install.
139
+ // We'll refine once we lock exact clawhub CLI flags.
140
+ const lines = [
141
+ `cd "${"$WORKSPACE"}" # set WORKSPACE=~/.openclaw/workspace`,
142
+ ...skills.map((s) => `npx clawhub@latest install ${s}`),
143
+ ];
144
+ return lines;
145
+ }
146
+
147
+ async function detectMissingSkills(api: OpenClawPluginApi, cfg: Required<RecipesConfig>, skills: string[]) {
148
+ const missing: string[] = [];
149
+ for (const s of skills) {
150
+ const p = workspacePath(api, cfg.workspaceSkillsDir, s);
151
+ if (!(await fileExists(p))) missing.push(s);
152
+ }
153
+ return missing;
154
+ }
155
+
156
+ async function ensureDir(p: string) {
157
+ await fs.mkdir(p, { recursive: true });
158
+ }
159
+
160
+ function renderTemplate(raw: string, vars: Record<string, string>) {
161
+ // Tiny, safe template renderer: replaces {{key}}.
162
+ // No conditionals, no eval.
163
+ return raw.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => {
164
+ const v = vars[key];
165
+ return typeof v === "string" ? v : "";
166
+ });
167
+ }
168
+
169
+ async function writeFileSafely(p: string, content: string, mode: "createOnly" | "overwrite") {
170
+ if (mode === "createOnly" && (await fileExists(p))) return { wrote: false, reason: "exists" as const };
171
+ await ensureDir(path.dirname(p));
172
+ await fs.writeFile(p, content, "utf8");
173
+ return { wrote: true, reason: "ok" as const };
174
+ }
175
+
176
+ type AgentConfigSnippet = {
177
+ id: string;
178
+ workspace: string;
179
+ identity?: { name?: string };
180
+ tools?: { profile?: string; allow?: string[]; deny?: string[] };
181
+ };
182
+
183
+ function upsertAgentInConfig(cfgObj: any, snippet: AgentConfigSnippet) {
184
+ if (!cfgObj.agents) cfgObj.agents = {};
185
+ if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
186
+
187
+ const list: any[] = cfgObj.agents.list;
188
+ const idx = list.findIndex((a) => a?.id === snippet.id);
189
+ const prev = idx >= 0 ? list[idx] : {};
190
+ const nextAgent = {
191
+ ...prev,
192
+ id: snippet.id,
193
+ workspace: snippet.workspace,
194
+ // identity: merge (safe)
195
+ identity: {
196
+ ...(prev?.identity ?? {}),
197
+ ...(snippet.identity ?? {}),
198
+ },
199
+ // tools: replace when provided (so stale deny/allow don’t linger)
200
+ tools: snippet.tools ? { ...snippet.tools } : prev?.tools,
201
+ };
202
+
203
+ if (idx >= 0) list[idx] = nextAgent;
204
+ else list.push(nextAgent);
205
+ }
206
+
207
+ async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
208
+ // Load the latest config from disk (not the snapshot in api.config).
209
+ const current = (api.runtime as any).config?.loadConfig?.();
210
+ if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
211
+
212
+ // Some loaders return { cfg, ... }. If so, normalize.
213
+ const cfgObj = (current.cfg ?? current) as any;
214
+ for (const s of snippets) upsertAgentInConfig(cfgObj, s);
215
+
216
+ await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
217
+ return { updatedAgents: snippets.map((s) => s.id) };
218
+ }
219
+
220
+ async function scaffoldAgentFromRecipe(api: OpenClawPluginApi, recipe: RecipeFrontmatter, opts: {
221
+ agentId: string;
222
+ agentName?: string;
223
+ update?: boolean;
224
+ vars?: Record<string, string>;
225
+ }) {
226
+ const cfg = getCfg(api);
227
+
228
+ const agentDir = workspacePath(api, cfg.workspaceAgentsDir, opts.agentId);
229
+ await ensureDir(agentDir);
230
+
231
+ const templates = recipe.templates ?? {};
232
+ const files = recipe.files ?? [];
233
+ const vars = opts.vars ?? {};
234
+
235
+ const fileResults: Array<{ path: string; wrote: boolean; reason: string }> = [];
236
+ for (const f of files) {
237
+ const raw = templates[f.template];
238
+ if (typeof raw !== "string") throw new Error(`Missing template: ${f.template}`);
239
+ const rendered = renderTemplate(raw, vars);
240
+ const target = path.join(agentDir, f.path);
241
+ const mode = opts.update ? (f.mode ?? "overwrite") : (f.mode ?? "createOnly");
242
+ const r = await writeFileSafely(target, rendered, mode);
243
+ fileResults.push({ path: target, wrote: r.wrote, reason: r.reason });
244
+ }
245
+
246
+ const configSnippet: AgentConfigSnippet = {
247
+ id: opts.agentId,
248
+ workspace: agentDir,
249
+ identity: { name: opts.agentName ?? recipe.name ?? opts.agentId },
250
+ tools: recipe.tools ?? {},
251
+ };
252
+
253
+ return {
254
+ agentDir,
255
+ fileResults,
256
+ next: {
257
+ configSnippet,
258
+ },
259
+ };
260
+ }
261
+
262
+ const recipesPlugin = {
263
+ id: "recipes",
264
+ name: "Recipes",
265
+ description: "Markdown recipes that scaffold agents and teams.",
266
+ configSchema: {
267
+ type: "object",
268
+ additionalProperties: false,
269
+ properties: {},
270
+ },
271
+ register(api: OpenClawPluginApi) {
272
+ api.registerCli(
273
+ ({ program }) => {
274
+ const cmd = program.command("recipes").description("Manage markdown recipes (scaffold agents/teams)");
275
+
276
+ cmd
277
+ .command("list")
278
+ .description("List available recipes (builtin + workspace)")
279
+ .action(async () => {
280
+ const cfg = getCfg(api);
281
+ const files = await listRecipeFiles(api, cfg);
282
+ const rows: Array<{ id: string; name?: string; kind?: string; source: string }> = [];
283
+ for (const f of files) {
284
+ try {
285
+ const md = await fs.readFile(f.path, "utf8");
286
+ const { frontmatter } = parseFrontmatter(md);
287
+ rows.push({ id: frontmatter.id, name: frontmatter.name, kind: frontmatter.kind, source: f.source });
288
+ } catch (e) {
289
+ rows.push({ id: path.basename(f.path), name: `INVALID: ${(e as Error).message}`, kind: "invalid", source: f.source });
290
+ }
291
+ }
292
+ console.log(JSON.stringify(rows, null, 2));
293
+ });
294
+
295
+ cmd
296
+ .command("show")
297
+ .description("Show a recipe by id")
298
+ .argument("<id>", "Recipe id")
299
+ .action(async (id: string) => {
300
+ const r = await loadRecipeById(api, id);
301
+ console.log(r.md);
302
+ });
303
+
304
+ cmd
305
+ .command("status")
306
+ .description("Check for missing skills for a recipe (or all)")
307
+ .argument("[id]", "Recipe id")
308
+ .action(async (id?: string) => {
309
+ const cfg = getCfg(api);
310
+ const files = await listRecipeFiles(api, cfg);
311
+ const out: any[] = [];
312
+
313
+ for (const f of files) {
314
+ const md = await fs.readFile(f.path, "utf8");
315
+ const { frontmatter } = parseFrontmatter(md);
316
+ if (id && frontmatter.id !== id) continue;
317
+ const req = frontmatter.requiredSkills ?? [];
318
+ const missing = await detectMissingSkills(api, cfg, req);
319
+ out.push({
320
+ id: frontmatter.id,
321
+ requiredSkills: req,
322
+ missingSkills: missing,
323
+ installCommands: missing.length ? skillInstallCommands(cfg, missing) : [],
324
+ });
325
+ }
326
+
327
+ console.log(JSON.stringify(out, null, 2));
328
+ });
329
+
330
+ cmd
331
+ .command("install")
332
+ .description("Install a ClawHub skill into this OpenClaw workspace (confirmation-gated)")
333
+ .argument("<idOrSlug>", "Recipe id OR ClawHub skill slug")
334
+ .option("--yes", "Skip confirmation prompt")
335
+ .action(async (idOrSlug: string, options: any) => {
336
+ const cfg = getCfg(api);
337
+
338
+ // Phase 1: accept skill slug directly.
339
+ // If the arg matches a recipe id and the recipe declares skill deps, we install those deps.
340
+ // (In the future we can add explicit mapping via frontmatter like skillSlug: <slug>.)
341
+ let recipe: RecipeFrontmatter | null = null;
342
+ try {
343
+ const loaded = await loadRecipeById(api, idOrSlug);
344
+ recipe = loaded.frontmatter;
345
+ } catch {
346
+ recipe = null;
347
+ }
348
+
349
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
350
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
351
+
352
+ const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
353
+
354
+ const skillsToInstall = recipe
355
+ ? Array.from(new Set([...(recipe.requiredSkills ?? []), ...(recipe.optionalSkills ?? [])])).filter(Boolean)
356
+ : [idOrSlug];
357
+
358
+ if (!skillsToInstall.length) {
359
+ console.log(JSON.stringify({ ok: true, installed: [], note: "Nothing to install." }, null, 2));
360
+ return;
361
+ }
362
+
363
+ const missing = await detectMissingSkills(api, cfg, skillsToInstall);
364
+ const already = skillsToInstall.filter((s) => !missing.includes(s));
365
+
366
+ if (already.length) {
367
+ console.error(`Already present in workspace skills dir (${installDir}): ${already.join(", ")}`);
368
+ }
369
+
370
+ if (!missing.length) {
371
+ console.log(JSON.stringify({ ok: true, installed: [], alreadyInstalled: already }, null, 2));
372
+ return;
373
+ }
374
+
375
+ const header = recipe
376
+ ? `Install skills for recipe ${recipe.id} into ${installDir}?\n- ${missing.join("\n- ")}`
377
+ : `Install skill into ${installDir}?\n- ${missing.join("\n- ")}`;
378
+
379
+ const requireConfirm = !options.yes;
380
+ if (requireConfirm) {
381
+ if (!process.stdin.isTTY) {
382
+ console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
383
+ process.exitCode = 2;
384
+ return;
385
+ }
386
+
387
+ const readline = await import("node:readline/promises");
388
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
389
+ try {
390
+ const ans = await rl.question(`${header}\nProceed? (y/N) `);
391
+ const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
392
+ if (!ok) {
393
+ console.error("Aborted; nothing installed.");
394
+ return;
395
+ }
396
+ } finally {
397
+ rl.close();
398
+ }
399
+ } else {
400
+ console.error(header);
401
+ }
402
+
403
+ // Use clawhub CLI. Force workspace-local install path.
404
+ const { spawnSync } = await import("node:child_process");
405
+ for (const slug of missing) {
406
+ const res = spawnSync(
407
+ "npx",
408
+ ["clawhub@latest", "--workdir", workspaceRoot, "--dir", cfg.workspaceSkillsDir, "install", slug],
409
+ { stdio: "inherit" },
410
+ );
411
+ if (res.status !== 0) {
412
+ process.exitCode = res.status ?? 1;
413
+ console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
414
+ return;
415
+ }
416
+ }
417
+
418
+ console.log(
419
+ JSON.stringify(
420
+ {
421
+ ok: true,
422
+ installed: missing,
423
+ installDir,
424
+ next: `Try: openclaw skills list (or check ${installDir})`,
425
+ },
426
+ null,
427
+ 2,
428
+ ),
429
+ );
430
+ });
431
+
432
+ cmd
433
+ .command("dispatch")
434
+ .description("Lead/dispatcher: turn a natural-language request into inbox + backlog ticket(s) + assignment stubs")
435
+ .requiredOption("--team-id <teamId>", "Team id (workspace folder under teams/)")
436
+ .option("--request <text>", "Natural-language request (if omitted, will prompt in TTY)")
437
+ .option("--owner <owner>", "Ticket owner: dev|devops|lead", "dev")
438
+ .option("--yes", "Skip review and write files without prompting")
439
+ .action(async (options: any) => {
440
+ const cfg = getCfg(api);
441
+
442
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
443
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
444
+
445
+ const teamId = String(options.teamId);
446
+ const teamDir = workspacePath(api, cfg.workspaceTeamsDir, teamId);
447
+
448
+ const inboxDir = path.join(teamDir, "inbox");
449
+ const backlogDir = path.join(teamDir, "work", "backlog");
450
+ const assignmentsDir = path.join(teamDir, "work", "assignments");
451
+
452
+ const owner = String(options.owner ?? "dev");
453
+ if (!['dev','devops','lead'].includes(owner)) {
454
+ throw new Error("--owner must be one of: dev, devops, lead");
455
+ }
456
+
457
+ const slugify = (s: string) =>
458
+ s
459
+ .toLowerCase()
460
+ .replace(/[^a-z0-9]+/g, "-")
461
+ .replace(/(^-|-$)/g, "")
462
+ .slice(0, 60) || "request";
463
+
464
+ const nowKey = () => {
465
+ const d = new Date();
466
+ const pad = (n: number) => String(n).padStart(2, "0");
467
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
468
+ };
469
+
470
+ const nextTicketNumber = async () => {
471
+ const dirs = [
472
+ backlogDir,
473
+ path.join(teamDir, "work", "in-progress"),
474
+ path.join(teamDir, "work", "done"),
475
+ ];
476
+ let max = 0;
477
+ for (const dir of dirs) {
478
+ if (!(await fileExists(dir))) continue;
479
+ const files = await fs.readdir(dir);
480
+ for (const f of files) {
481
+ const m = f.match(/^(\d{4})-/);
482
+ if (m) max = Math.max(max, Number(m[1]));
483
+ }
484
+ }
485
+ return max + 1;
486
+ };
487
+
488
+ let requestText = typeof options.request === "string" ? options.request.trim() : "";
489
+ if (!requestText) {
490
+ if (!process.stdin.isTTY) {
491
+ throw new Error("Missing --request in non-interactive mode");
492
+ }
493
+ const readline = await import("node:readline/promises");
494
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
495
+ try {
496
+ requestText = (await rl.question("Request: ")).trim();
497
+ } finally {
498
+ rl.close();
499
+ }
500
+ }
501
+ if (!requestText) throw new Error("Request cannot be empty");
502
+
503
+ // Minimal heuristic: one ticket per request.
504
+ const ticketNum = await nextTicketNumber();
505
+ const ticketNumStr = String(ticketNum).padStart(4, '0');
506
+ const title = requestText.length > 80 ? requestText.slice(0, 77) + "…" : requestText;
507
+ const baseSlug = slugify(title);
508
+
509
+ const inboxPath = path.join(inboxDir, `${nowKey()}-${baseSlug}.md`);
510
+ const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
511
+ const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
512
+
513
+ const inboxMd = `# Inbox — ${teamId}\n\nReceived: ${new Date().toISOString()}\n\n## Request\n${requestText}\n\n## Proposed work\n- Ticket: ${ticketNumStr}-${baseSlug}\n- Owner: ${owner}\n`;
514
+
515
+ const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nOwner: ${owner}\nStatus: queued\n\n## Context\n${requestText}\n\n## Requirements\n- (fill in)\n\n## Acceptance criteria\n- (fill in)\n`;
516
+
517
+ const assignmentMd = `# Assignment — ${ticketNumStr}-${baseSlug}\n\nAssigned: ${owner}\n\n## Goal\n${title}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes dispatch\n`;
518
+
519
+ const plan = {
520
+ teamId,
521
+ request: requestText,
522
+ files: [
523
+ { path: inboxPath, kind: "inbox", summary: title },
524
+ { path: ticketPath, kind: "backlog-ticket", summary: title },
525
+ { path: assignmentPath, kind: "assignment", summary: owner },
526
+ ],
527
+ };
528
+
529
+ const doWrite = async () => {
530
+ await ensureDir(inboxDir);
531
+ await ensureDir(backlogDir);
532
+ await ensureDir(assignmentsDir);
533
+
534
+ // createOnly to avoid accidental overwrite
535
+ await writeFileSafely(inboxPath, inboxMd, "createOnly");
536
+ await writeFileSafely(ticketPath, ticketMd, "createOnly");
537
+ await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
538
+ };
539
+
540
+ if (options.yes) {
541
+ await doWrite();
542
+ console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
543
+ return;
544
+ }
545
+
546
+ if (!process.stdin.isTTY) {
547
+ console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
548
+ process.exitCode = 2;
549
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
550
+ return;
551
+ }
552
+
553
+ console.log(JSON.stringify({ plan }, null, 2));
554
+ const readline = await import("node:readline/promises");
555
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
556
+ try {
557
+ const ans = await rl.question("Write these files? (y/N) ");
558
+ const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
559
+ if (!ok) {
560
+ console.error("Aborted; no files written.");
561
+ return;
562
+ }
563
+ } finally {
564
+ rl.close();
565
+ }
566
+
567
+ await doWrite();
568
+ console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
569
+ });
570
+
571
+ cmd
572
+ .command("scaffold")
573
+ .description("Scaffold an agent from a recipe")
574
+ .argument("<recipeId>", "Recipe id")
575
+ .requiredOption("--agent-id <id>", "Agent id")
576
+ .option("--name <name>", "Agent display name")
577
+ .option("--overwrite", "Overwrite existing recipe-managed files")
578
+ .option("--apply-config", "Write the agent into openclaw config (agents.list)")
579
+ .action(async (recipeId: string, options: any) => {
580
+ const loaded = await loadRecipeById(api, recipeId);
581
+ const recipe = loaded.frontmatter;
582
+ if ((recipe.kind ?? "agent") !== "agent") {
583
+ throw new Error(`Recipe is not an agent recipe: kind=${recipe.kind}`);
584
+ }
585
+
586
+ const cfg = getCfg(api);
587
+ const missing = await detectMissingSkills(api, cfg, recipe.requiredSkills ?? []);
588
+ if (missing.length) {
589
+ console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
590
+ console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
591
+ process.exitCode = 2;
592
+ return;
593
+ }
594
+
595
+ const result = await scaffoldAgentFromRecipe(api, recipe, {
596
+ agentId: options.agentId,
597
+ agentName: options.name,
598
+ update: !!options.overwrite,
599
+ vars: {
600
+ agentId: options.agentId,
601
+ agentName: options.name ?? recipe.name ?? options.agentId,
602
+ },
603
+ });
604
+
605
+ if (options.applyConfig) {
606
+ await applyAgentSnippetsToOpenClawConfig(api, [result.next.configSnippet]);
607
+ }
608
+
609
+ console.log(JSON.stringify(result, null, 2));
610
+ });
611
+
612
+ cmd
613
+ .command("scaffold-team")
614
+ .description("Scaffold a team (shared workspace + multiple agents) from a team recipe")
615
+ .argument("<recipeId>", "Recipe id")
616
+ .requiredOption("-t, --team-id <teamId>", "Team id (must end with -team)")
617
+ .option("--overwrite", "Overwrite existing recipe-managed files")
618
+ .option("--apply-config", "Write all team agents into openclaw config (agents.list)")
619
+ .action(async (recipeId: string, options: any) => {
620
+ const loaded = await loadRecipeById(api, recipeId);
621
+ const recipe = loaded.frontmatter;
622
+ if ((recipe.kind ?? "team") !== "team") {
623
+ throw new Error(`Recipe is not a team recipe: kind=${recipe.kind}`);
624
+ }
625
+ const teamId = String(options.teamId);
626
+ if (!teamId.endsWith("-team")) {
627
+ throw new Error("teamId must end with -team");
628
+ }
629
+
630
+ const cfg = getCfg(api);
631
+ const missing = await detectMissingSkills(api, cfg, recipe.requiredSkills ?? []);
632
+ if (missing.length) {
633
+ console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
634
+ console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
635
+ process.exitCode = 2;
636
+ return;
637
+ }
638
+
639
+ const teamDir = workspacePath(api, cfg.workspaceTeamsDir, teamId);
640
+ await ensureDir(teamDir);
641
+ const notesDir = path.join(teamDir, "notes");
642
+ const workDir = path.join(teamDir, "work");
643
+ const backlogDir = path.join(workDir, "backlog");
644
+ const inProgressDir = path.join(workDir, "in-progress");
645
+ const doneDir = path.join(workDir, "done");
646
+ const assignmentsDir = path.join(workDir, "assignments");
647
+
648
+ await Promise.all([
649
+ ensureDir(path.join(teamDir, "shared")),
650
+ ensureDir(path.join(teamDir, "inbox")),
651
+ ensureDir(path.join(teamDir, "outbox")),
652
+ ensureDir(notesDir),
653
+ ensureDir(workDir),
654
+ ensureDir(backlogDir),
655
+ ensureDir(inProgressDir),
656
+ ensureDir(doneDir),
657
+ ensureDir(assignmentsDir),
658
+ ]);
659
+
660
+ // Seed standard team files (createOnly unless --overwrite)
661
+ const overwrite = !!options.overwrite;
662
+ const planPath = path.join(notesDir, "plan.md");
663
+ const statusPath = path.join(notesDir, "status.md");
664
+ const ticketsPath = path.join(teamDir, "TICKETS.md");
665
+
666
+ const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
667
+ const statusMd = `# Status — ${teamId}\n\n- (empty)\n`;
668
+ const ticketsMd = `# Tickets — ${teamId}\n\n## Naming\n- Backlog tickets live in work/backlog/\n- Filename ordering is the queue: 0001-..., 0002-...\n\n## Required fields\nEach ticket should include:\n- Title\n- Context\n- Requirements\n- Acceptance criteria\n- Owner (dev/devops/lead)\n- Status (queued/in_progress/blocked/done)\n\n## Example\n\n\`\`\`md\n# 0001-example-ticket\n\nOwner: dev\nStatus: queued\n\n## Context\n...\n\n## Requirements\n- ...\n\n## Acceptance criteria\n- ...\n\`\`\`\n`;
669
+
670
+ await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
671
+ await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
672
+ await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
673
+
674
+ const agents = recipe.agents ?? [];
675
+ if (!agents.length) throw new Error("Team recipe must include agents[]");
676
+
677
+ const results: any[] = [];
678
+ for (const a of agents) {
679
+ const role = a.role;
680
+ const agentId = a.agentId ?? `${teamId}-${role}`;
681
+ const agentName = a.name ?? `${teamId} ${role}`;
682
+
683
+ // For team recipes, we namespace template keys like: "lead.soul".
684
+ const scopedRecipe: RecipeFrontmatter = {
685
+ id: `${recipe.id}:${role}`,
686
+ name: agentName,
687
+ kind: "agent",
688
+ requiredSkills: recipe.requiredSkills,
689
+ optionalSkills: recipe.optionalSkills,
690
+ templates: recipe.templates,
691
+ files: (recipe.files ?? []).map((f) => ({
692
+ ...f,
693
+ template: f.template.includes(".") ? f.template : `${role}.${f.template}`,
694
+ })),
695
+ tools: a.tools ?? recipe.tools,
696
+ };
697
+
698
+ const r = await scaffoldAgentFromRecipe(api, scopedRecipe, {
699
+ agentId,
700
+ agentName,
701
+ update: !!options.overwrite,
702
+ vars: {
703
+ teamId,
704
+ teamDir,
705
+ role,
706
+ agentId,
707
+ agentName,
708
+ },
709
+ });
710
+ results.push({ role, agentId, ...r });
711
+ }
712
+
713
+ // Create a minimal TEAM.md
714
+ const teamMdPath = path.join(teamDir, "TEAM.md");
715
+ const teamMd = `# ${teamId}\n\nShared workspace for this agent team.\n\n## Folders\n- inbox/ — requests\n- outbox/ — deliverables\n- shared/ — shared artifacts\n- notes/ — notes\n- work/ — working files\n`;
716
+ await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
717
+
718
+ if (options.applyConfig) {
719
+ const snippets: AgentConfigSnippet[] = results.map((x: any) => x.next.configSnippet);
720
+ await applyAgentSnippetsToOpenClawConfig(api, snippets);
721
+ }
722
+
723
+ console.log(
724
+ JSON.stringify(
725
+ {
726
+ teamId,
727
+ teamDir,
728
+ agents: results,
729
+ next: {
730
+ note:
731
+ options.applyConfig
732
+ ? "agents.list[] updated in openclaw config"
733
+ : "Run again with --apply-config to write agents into openclaw config.",
734
+ },
735
+ },
736
+ null,
737
+ 2,
738
+ ),
739
+ );
740
+ });
741
+ },
742
+ { commands: ["recipes"] },
743
+ );
744
+ },
745
+ };
746
+
747
+ export default recipesPlugin;