@jiggai/recipes 0.2.11 → 0.2.12

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 (2) hide show
  1. package/index.ts +219 -80
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -261,45 +261,90 @@ type OpenClawCronJob = {
261
261
  schedule?: any;
262
262
  payload?: any;
263
263
  delivery?: any;
264
- agentId?: string;
264
+ agentId?: string | null;
265
265
  description?: string;
266
266
  };
267
267
 
268
- function spawnOpenClawJson(args: string[]) {
269
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
270
- const res = spawnSync("openclaw", args, { encoding: "utf8" });
271
- if (res.status !== 0) {
272
- const err = new Error(`openclaw ${args.join(" ")} failed (exit=${res.status})`);
273
- (err as any).stdout = res.stdout;
274
- (err as any).stderr = res.stderr;
275
- throw err;
276
- }
268
+ type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
277
269
 
278
- const raw = String(res.stdout ?? "");
270
+ type ToolsInvokeRequest = {
271
+ tool: string;
272
+ action?: string;
273
+ args?: Record<string, unknown>;
274
+ sessionKey?: string;
275
+ dryRun?: boolean;
276
+ };
279
277
 
280
- // OpenClaw may prepend pretty "Config warnings" blocks before JSON output.
281
- // To be resilient, parse the first JSON object/array found in stdout.
282
- const trimmed = raw.trim();
283
- if (!trimmed) return null;
278
+ type ToolsInvokeResponse = {
279
+ ok: boolean;
280
+ result?: unknown;
281
+ error?: { message?: string } | string;
282
+ };
283
+
284
+ async function toolsInvoke<T = unknown>(api: any, req: ToolsInvokeRequest): Promise<T> {
285
+ const port = api.config.gateway?.port ?? 18789;
286
+ const token = api.config.gateway?.auth?.token;
287
+ if (!token) throw new Error("Missing gateway.auth.token in openclaw config (required for tools/invoke)");
284
288
 
285
- const firstObj = trimmed.indexOf("{");
286
- const firstArr = trimmed.indexOf("[");
287
- const start =
288
- firstObj === -1 ? firstArr : firstArr === -1 ? firstObj : Math.min(firstObj, firstArr);
289
+ const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
290
+ method: "POST",
291
+ headers: {
292
+ "content-type": "application/json",
293
+ authorization: `Bearer ${token}`,
294
+ },
295
+ body: JSON.stringify(req),
296
+ });
289
297
 
290
- const jsonText = start >= 0 ? trimmed.slice(start) : trimmed;
298
+ const json = (await res.json()) as ToolsInvokeResponse;
299
+ if (!res.ok || !json.ok) {
300
+ const msg =
301
+ (typeof json.error === "object" && json.error?.message) ||
302
+ (typeof json.error === "string" ? json.error : null) ||
303
+ `tools/invoke failed (${res.status})`;
304
+ throw new Error(msg);
305
+ }
291
306
 
307
+ return json.result as T;
308
+ }
309
+
310
+ function parseToolTextJson(text: string, label: string) {
311
+ const trimmed = String(text ?? "").trim();
312
+ if (!trimmed) return null;
292
313
  try {
293
- return JSON.parse(jsonText) as any;
314
+ return JSON.parse(trimmed) as any;
294
315
  } catch (e) {
295
- const err = new Error(`Failed parsing JSON from: openclaw ${args.join(" ")}`);
296
- (err as any).stdout = raw;
297
- (err as any).stderr = res.stderr;
316
+ const err = new Error(`Failed parsing JSON from tool text (${label})`);
317
+ (err as any).text = text;
298
318
  (err as any).cause = e;
299
319
  throw err;
300
320
  }
301
321
  }
302
322
 
323
+ async function cronList(api: any) {
324
+ const result = await toolsInvoke<ToolTextResult>(api, {
325
+ tool: "cron",
326
+ args: { action: "list", includeDisabled: true },
327
+ });
328
+ const text = result?.content?.find((c) => c.type === "text")?.text;
329
+ const parsed = text ? (parseToolTextJson(text, "cron.list") as { jobs?: OpenClawCronJob[] }) : null;
330
+ return { jobs: parsed?.jobs ?? [] };
331
+ }
332
+
333
+ async function cronAdd(api: any, job: any) {
334
+ const result = await toolsInvoke<ToolTextResult>(api, { tool: "cron", args: { action: "add", job } });
335
+ const text = result?.content?.find((c) => c.type === "text")?.text;
336
+ return text ? parseToolTextJson(text, "cron.add") : null;
337
+ }
338
+
339
+ async function cronUpdate(api: any, jobId: string, patch: any) {
340
+ const result = await toolsInvoke<ToolTextResult>(api, {
341
+ tool: "cron",
342
+ args: { action: "update", jobId, patch },
343
+ });
344
+ const text = result?.content?.find((c) => c.type === "text")?.text;
345
+ return text ? parseToolTextJson(text, "cron.update") : null;
346
+ }
347
+
303
348
  function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
304
349
  const raw = frontmatter.cronJobs;
305
350
  if (!raw) return [];
@@ -348,6 +393,7 @@ async function promptYesNo(header: string) {
348
393
  }
349
394
 
350
395
  async function reconcileRecipeCronJobs(opts: {
396
+ api: OpenClawPluginApi;
351
397
  recipe: RecipeFrontmatter;
352
398
  scope: { kind: "team"; teamId: string; recipeId: string; stateDir: string } | { kind: "agent"; agentId: string; recipeId: string; stateDir: string };
353
399
  cronInstallation: CronInstallMode;
@@ -373,7 +419,7 @@ async function reconcileRecipeCronJobs(opts: {
373
419
  const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
374
420
  const state = await loadCronMappingState(statePath);
375
421
 
376
- const list = spawnOpenClawJson(["cron", "list", "--json"]) as { jobs: OpenClawCronJob[] };
422
+ const list = await cronList(opts.api);
377
423
  const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
378
424
 
379
425
  const now = Date.now();
@@ -405,27 +451,32 @@ async function reconcileRecipeCronJobs(opts: {
405
451
 
406
452
  if (!existing) {
407
453
  // Create new job.
408
- const args = [
409
- "cron",
410
- "add",
411
- "--json",
412
- "--name",
454
+ const sessionTarget = j.agentId ? "isolated" : "main";
455
+ const job = {
413
456
  name,
414
- "--cron",
415
- j.schedule,
416
- "--message",
417
- j.message,
418
- "--announce",
419
- ];
420
- if (!wantEnabled) args.push("--disabled");
421
- if (j.description) args.push("--description", j.description);
422
- if (j.timezone) args.push("--tz", j.timezone);
423
- if (j.channel) args.push("--channel", j.channel);
424
- if (j.to) args.push("--to", j.to);
425
- if (j.agentId) args.push("--agent", j.agentId);
426
-
427
- const created = spawnOpenClawJson(args) as any;
428
- const newId = created?.id ?? created?.job?.id;
457
+ agentId: j.agentId ?? null,
458
+ description: j.description ?? "",
459
+ enabled: wantEnabled,
460
+ wakeMode: "next-heartbeat",
461
+ sessionTarget,
462
+ schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
463
+ payload: j.agentId
464
+ ? { kind: "agentTurn", message: j.message }
465
+ : { kind: "systemEvent", text: j.message },
466
+ ...(j.channel || j.to
467
+ ? {
468
+ delivery: {
469
+ mode: "announce",
470
+ ...(j.channel ? { channel: j.channel } : {}),
471
+ ...(j.to ? { to: j.to } : {}),
472
+ bestEffort: true,
473
+ },
474
+ }
475
+ : {}),
476
+ };
477
+
478
+ const created = await cronAdd(opts.api, job);
479
+ const newId = (created as any)?.id ?? (created as any)?.job?.id;
429
480
  if (!newId) throw new Error("Failed to parse cron add output (missing id)");
430
481
 
431
482
  state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
@@ -435,25 +486,25 @@ async function reconcileRecipeCronJobs(opts: {
435
486
 
436
487
  // Update existing job if spec changed.
437
488
  if (prev?.specHash !== specHash) {
438
- const editArgs = [
439
- "cron",
440
- "edit",
441
- existing.id,
442
- "--name",
489
+ const patch: any = {
443
490
  name,
444
- "--cron",
445
- j.schedule,
446
- "--message",
447
- j.message,
448
- "--announce",
449
- ];
450
- if (j.description) editArgs.push("--description", j.description);
451
- if (j.timezone) editArgs.push("--tz", j.timezone);
452
- if (j.channel) editArgs.push("--channel", j.channel);
453
- if (j.to) editArgs.push("--to", j.to);
454
- if (j.agentId) editArgs.push("--agent", j.agentId);
455
-
456
- spawnOpenClawJson(editArgs);
491
+ agentId: j.agentId ?? null,
492
+ description: j.description ?? "",
493
+ sessionTarget: j.agentId ? "isolated" : "main",
494
+ wakeMode: "next-heartbeat",
495
+ schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
496
+ payload: j.agentId ? { kind: "agentTurn", message: j.message } : { kind: "systemEvent", text: j.message },
497
+ };
498
+ if (j.channel || j.to) {
499
+ patch.delivery = {
500
+ mode: "announce",
501
+ ...(j.channel ? { channel: j.channel } : {}),
502
+ ...(j.to ? { to: j.to } : {}),
503
+ bestEffort: true,
504
+ };
505
+ }
506
+
507
+ await cronUpdate(opts.api, existing.id, patch);
457
508
  results.push({ action: "updated", key, installedCronId: existing.id });
458
509
  } else {
459
510
  results.push({ action: "unchanged", key, installedCronId: existing.id });
@@ -462,7 +513,7 @@ async function reconcileRecipeCronJobs(opts: {
462
513
  // Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
463
514
  if (!userOptIn) {
464
515
  if (existing.enabled) {
465
- spawnOpenClawJson(["cron", "edit", existing.id, "--disable"]);
516
+ await cronUpdate(opts.api, existing.id, { enabled: false });
466
517
  results.push({ action: "disabled", key, installedCronId: existing.id });
467
518
  }
468
519
  }
@@ -478,7 +529,7 @@ async function reconcileRecipeCronJobs(opts: {
478
529
 
479
530
  const job = byId.get(entry.installedCronId);
480
531
  if (job && job.enabled) {
481
- spawnOpenClawJson(["cron", "edit", job.id, "--disable"]);
532
+ await cronUpdate(api, job.id, { enabled: false });
482
533
  results.push({ action: "disabled-removed", key, installedCronId: job.id });
483
534
  }
484
535
 
@@ -1156,20 +1207,16 @@ const recipesPlugin = {
1156
1207
  console.error(header);
1157
1208
  }
1158
1209
 
1159
- // Use clawhub CLI. Force install path based on scope.
1160
- const { spawnSync } = await import("node:child_process");
1210
+ // Avoid spawning subprocesses from plugins (triggers OpenClaw dangerous-pattern warnings).
1211
+ // For now, print the exact commands the user should run.
1212
+ console.error("\nSkill install requires the ClawHub CLI. Run the following then re-run this command:\n");
1161
1213
  for (const slug of missing) {
1162
- const res = spawnSync(
1163
- "npx",
1164
- ["clawhub@latest", "--workdir", workdir, "--dir", dirName, "install", slug],
1165
- { stdio: "inherit" },
1214
+ console.error(
1215
+ ` npx clawhub@latest --workdir ${JSON.stringify(workdir)} --dir ${JSON.stringify(dirName)} install ${JSON.stringify(slug)}`,
1166
1216
  );
1167
- if (res.status !== 0) {
1168
- process.exitCode = res.status ?? 1;
1169
- console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
1170
- return;
1171
- }
1172
1217
  }
1218
+ process.exitCode = 2;
1219
+ return;
1173
1220
 
1174
1221
  console.log(
1175
1222
  JSON.stringify(
@@ -1521,6 +1568,89 @@ const recipesPlugin = {
1521
1568
  print("Done", out.done);
1522
1569
  });
1523
1570
 
1571
+ async function moveTicketCore(options: any) {
1572
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1573
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1574
+ const teamId = String(options.teamId);
1575
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1576
+
1577
+ await ensureTicketStageDirs(teamDir);
1578
+
1579
+ const dest = String(options.to);
1580
+ if (!["backlog", "in-progress", "testing", "done"].includes(dest)) {
1581
+ throw new Error("--to must be one of: backlog, in-progress, testing, done");
1582
+ }
1583
+
1584
+ const ticketArg = String(options.ticket);
1585
+ const ticketNum = ticketArg.match(/^\d{4}$/)
1586
+ ? ticketArg
1587
+ : ticketArg.match(/^(\d{4})-/)?.[1] ?? null;
1588
+
1589
+ const stageDir = (stage: string) => {
1590
+ if (stage === "backlog") return path.join(teamDir, "work", "backlog");
1591
+ if (stage === "in-progress") return path.join(teamDir, "work", "in-progress");
1592
+ if (stage === "testing") return path.join(teamDir, "work", "testing");
1593
+ if (stage === "done") return path.join(teamDir, "work", "done");
1594
+ throw new Error(`Unknown stage: ${stage}`);
1595
+ };
1596
+
1597
+ const searchDirs = [stageDir("backlog"), stageDir("in-progress"), stageDir("testing"), stageDir("done")];
1598
+
1599
+ const findTicketFile = async () => {
1600
+ for (const dir of searchDirs) {
1601
+ if (!(await fileExists(dir))) continue;
1602
+ const files = await fs.readdir(dir);
1603
+ for (const f of files) {
1604
+ if (!f.endsWith(".md")) continue;
1605
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1606
+ if (!ticketNum && f.replace(/\.md$/, "") === ticketArg) return path.join(dir, f);
1607
+ }
1608
+ }
1609
+ return null;
1610
+ };
1611
+
1612
+ const srcPath = await findTicketFile();
1613
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1614
+
1615
+ const destDir = stageDir(dest);
1616
+ await ensureDir(destDir);
1617
+ const filename = path.basename(srcPath);
1618
+ const destPath = path.join(destDir, filename);
1619
+
1620
+ const patchStatus = (md: string) => {
1621
+ const nextStatus =
1622
+ dest === "backlog"
1623
+ ? "queued"
1624
+ : dest === "in-progress"
1625
+ ? "in-progress"
1626
+ : dest === "testing"
1627
+ ? "testing"
1628
+ : "done";
1629
+
1630
+ let out = md;
1631
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
1632
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
1633
+
1634
+ if (dest === "done" && options.completed) {
1635
+ const completed = new Date().toISOString();
1636
+ if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
1637
+ else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
1638
+ }
1639
+
1640
+ return out;
1641
+ };
1642
+
1643
+ const md = await fs.readFile(srcPath, "utf8");
1644
+ const patched = patchStatus(md);
1645
+ await fs.writeFile(srcPath, patched, "utf8");
1646
+
1647
+ if (srcPath !== destPath) {
1648
+ await fs.rename(srcPath, destPath);
1649
+ }
1650
+
1651
+ return { ok: true, from: srcPath, to: destPath };
1652
+ }
1653
+
1524
1654
  cmd
1525
1655
  .command("move-ticket")
1526
1656
  .description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
@@ -1981,10 +2111,17 @@ const recipesPlugin = {
1981
2111
  ];
1982
2112
  if (options.yes) args.push('--yes');
1983
2113
 
1984
- const { spawnSync } = await import('node:child_process');
1985
- const res = spawnSync('openclaw', args, { stdio: 'inherit' });
1986
- if (res.status !== 0) {
1987
- process.exitCode = res.status ?? 1;
2114
+ try {
2115
+ await moveTicketCore({
2116
+ teamId: options.teamId,
2117
+ ticket: options.ticket,
2118
+ to: "done",
2119
+ completed: true,
2120
+ yes: options.yes,
2121
+ });
2122
+ } catch (e) {
2123
+ process.exitCode = 1;
2124
+ throw e;
1988
2125
  }
1989
2126
  });
1990
2127
 
@@ -2036,6 +2173,7 @@ const recipesPlugin = {
2036
2173
  }
2037
2174
 
2038
2175
  const cron = await reconcileRecipeCronJobs({
2176
+ api,
2039
2177
  recipe,
2040
2178
  scope: { kind: "agent", agentId: String(options.agentId), recipeId: recipe.id, stateDir: resolvedWorkspaceRoot },
2041
2179
  cronInstallation: getCfg(api).cronInstallation,
@@ -2189,6 +2327,7 @@ const recipesPlugin = {
2189
2327
  }
2190
2328
 
2191
2329
  const cron = await reconcileRecipeCronJobs({
2330
+ api,
2192
2331
  recipe,
2193
2332
  scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
2194
2333
  cronInstallation: getCfg(api).cronInstallation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",