@clawcipes/recipes 0.2.5 → 0.2.6

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 +162 -113
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
4
  import crypto from "node:crypto";
5
+ import os from "node:os";
5
6
  import JSON5 from "json5";
6
7
  import YAML from "yaml";
7
8
 
@@ -285,17 +286,73 @@ type OpenClawCronJob = {
285
286
  description?: string;
286
287
  };
287
288
 
288
- function spawnOpenClawJson(args: string[]) {
289
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
290
- const res = spawnSync("openclaw", args, { encoding: "utf8" });
291
- if (res.status !== 0) {
292
- const err = new Error(`openclaw ${args.join(" ")} failed (exit=${res.status})`);
293
- (err as any).stdout = res.stdout;
294
- (err as any).stderr = res.stderr;
295
- throw err;
289
+ type CronStoreV1 = {
290
+ version: 1;
291
+ jobs: any[];
292
+ };
293
+
294
+ function cronStorePath() {
295
+ // Gateway cron store (default): ~/.openclaw/cron/jobs.json
296
+ return path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
297
+ }
298
+
299
+ async function loadCronStore(): Promise<CronStoreV1> {
300
+ try {
301
+ const raw = await fs.readFile(cronStorePath(), "utf8");
302
+ const parsed = JSON.parse(raw) as CronStoreV1;
303
+ if (parsed && parsed.version === 1 && Array.isArray(parsed.jobs)) return parsed;
304
+ } catch {
305
+ // ignore
296
306
  }
297
- const out = String(res.stdout ?? "").trim();
298
- return out ? (JSON.parse(out) as any) : null;
307
+ return { version: 1, jobs: [] };
308
+ }
309
+
310
+ async function saveCronStore(store: CronStoreV1) {
311
+ const p = cronStorePath();
312
+ await ensureDir(path.dirname(p));
313
+ await fs.writeFile(p, JSON.stringify(store, null, 2) + "\n", "utf8");
314
+ }
315
+
316
+ function buildCronJobPayloadAgentTurn(message: string) {
317
+ return {
318
+ kind: "agentTurn",
319
+ message,
320
+ timeoutSeconds: 60,
321
+ };
322
+ }
323
+
324
+ function buildCronJobFromSpec(opts: {
325
+ name: string;
326
+ description?: string;
327
+ scheduleExpr: string;
328
+ tz?: string;
329
+ message: string;
330
+ enabled: boolean;
331
+ agentId: string;
332
+ delivery?: { channel?: string; to?: string };
333
+ }) {
334
+ const now = Date.now();
335
+ const delivery =
336
+ opts.delivery?.channel && opts.delivery?.to
337
+ ? { mode: "announce", channel: opts.delivery.channel, to: opts.delivery.to, bestEffort: true }
338
+ : { mode: "announce" };
339
+
340
+ return {
341
+ id: crypto.randomUUID(),
342
+ agentId: opts.agentId || "main",
343
+ name: opts.name,
344
+ description: opts.description,
345
+ enabled: opts.enabled,
346
+ deleteAfterRun: false,
347
+ createdAtMs: now,
348
+ updatedAtMs: now,
349
+ schedule: { kind: "cron", expr: opts.scheduleExpr, tz: opts.tz },
350
+ sessionTarget: "isolated",
351
+ wakeMode: "next-heartbeat",
352
+ payload: buildCronJobPayloadAgentTurn(opts.message),
353
+ delivery,
354
+ state: {},
355
+ };
299
356
  }
300
357
 
301
358
  function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
@@ -381,25 +438,8 @@ async function reconcileRecipeCronJobs(opts: {
381
438
  const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
382
439
  const state = await loadCronMappingState(statePath);
383
440
 
384
- let list: { jobs: OpenClawCronJob[] } | null = null;
385
- try {
386
- list = spawnOpenClawJson(["cron", "list", "--all", "--json"]) as { jobs: OpenClawCronJob[] };
387
- } catch (err: any) {
388
- // Cron reconciliation should not prevent scaffolding from completing.
389
- // If the gateway is restarting or cron RPC is temporarily unavailable, skip reconciliation.
390
- console.error(`Warning: failed to list cron jobs; skipping cron reconciliation for ${opts.scope.kind} ${
391
- (opts.scope as any).teamId ?? (opts.scope as any).agentId
392
- } (${opts.scope.recipeId}).`);
393
- if (err?.stderr) console.error(String(err.stderr).trim());
394
- return {
395
- ok: false,
396
- changed: false,
397
- note: "cron-list-failed" as const,
398
- desiredCount: desired.length,
399
- error: { message: String(err?.message ?? err) },
400
- };
401
- }
402
- const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
441
+ const store = await loadCronStore();
442
+ const byId = new Map((store.jobs ?? []).map((j: any) => [j.id, j] as const));
403
443
 
404
444
  const now = Date.now();
405
445
  const desiredIds = new Set(desired.map((j) => j.id));
@@ -431,65 +471,43 @@ async function reconcileRecipeCronJobs(opts: {
431
471
  const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
432
472
 
433
473
  if (!existing) {
434
- // Create new job.
435
- const args = [
436
- "cron",
437
- "add",
438
- "--json",
439
- "--name",
474
+ // Create new job in cron store.
475
+ const newJob = buildCronJobFromSpec({
440
476
  name,
441
- "--cron",
442
- j.schedule,
443
- "--message",
444
- j.message,
445
- "--announce",
446
- ];
447
- if (!wantEnabled) args.push("--disabled");
448
- if (j.description) args.push("--description", j.description);
449
- if (j.timezone) args.push("--tz", j.timezone);
450
- if (j.channel) args.push("--channel", j.channel);
451
- if (j.to) args.push("--to", j.to);
452
- if (desiredSpec.agentId) args.push("--agent", desiredSpec.agentId);
453
-
454
- const created = spawnOpenClawJson(args) as any;
455
- const newId = created?.id ?? created?.job?.id;
456
- if (!newId) throw new Error("Failed to parse cron add output (missing id)");
457
-
458
- state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
459
- results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
477
+ description: j.description,
478
+ scheduleExpr: j.schedule,
479
+ tz: j.timezone,
480
+ message: j.message,
481
+ enabled: wantEnabled,
482
+ agentId: desiredSpec.agentId || "main",
483
+ delivery: j.channel && j.to ? { channel: j.channel, to: j.to } : undefined,
484
+ });
485
+
486
+ store.jobs.push(newJob);
487
+ state.entries[key] = { installedCronId: newJob.id, specHash, updatedAtMs: now, orphaned: false };
488
+ results.push({ action: "created", key, installedCronId: newJob.id, enabled: wantEnabled });
460
489
  continue;
461
490
  }
462
491
 
463
492
  // Update existing job if spec changed.
464
493
  if (prev?.specHash !== specHash) {
465
- const editArgs = [
466
- "cron",
467
- "edit",
468
- existing.id,
469
- "--name",
470
- name,
471
- "--cron",
472
- j.schedule,
473
- "--message",
474
- j.message,
475
- "--announce",
476
- ];
477
- if (j.description) editArgs.push("--description", j.description);
478
- if (j.timezone) editArgs.push("--tz", j.timezone);
479
- if (j.channel) editArgs.push("--channel", j.channel);
480
- if (j.to) editArgs.push("--to", j.to);
481
- if (desiredSpec.agentId) editArgs.push("--agent", desiredSpec.agentId);
482
-
483
- spawnOpenClawJson(editArgs);
494
+ existing.name = name;
495
+ existing.description = j.description ?? existing.description;
496
+ existing.schedule = { kind: "cron", expr: j.schedule, tz: j.timezone };
497
+ existing.payload = buildCronJobPayloadAgentTurn(j.message);
498
+ existing.agentId = desiredSpec.agentId || existing.agentId || "main";
499
+ if (j.channel && j.to) existing.delivery = { mode: "announce", channel: j.channel, to: j.to, bestEffort: true };
500
+ existing.updatedAtMs = now;
484
501
  results.push({ action: "updated", key, installedCronId: existing.id });
485
502
  } else {
486
503
  results.push({ action: "unchanged", key, installedCronId: existing.id });
487
504
  }
488
505
 
489
- // Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
506
+ // Enabled precedence: if user did not opt in, force disabled.
490
507
  if (!userOptIn) {
491
508
  if (existing.enabled) {
492
- spawnOpenClawJson(["cron", "edit", existing.id, "--disable"]);
509
+ existing.enabled = false;
510
+ existing.updatedAtMs = now;
493
511
  results.push({ action: "disabled", key, installedCronId: existing.id });
494
512
  }
495
513
  }
@@ -505,7 +523,8 @@ async function reconcileRecipeCronJobs(opts: {
505
523
 
506
524
  const job = byId.get(entry.installedCronId);
507
525
  if (job && job.enabled) {
508
- spawnOpenClawJson(["cron", "edit", job.id, "--disable"]);
526
+ job.enabled = false;
527
+ job.updatedAtMs = now;
509
528
  results.push({ action: "disabled-removed", key, installedCronId: job.id });
510
529
  }
511
530
 
@@ -513,6 +532,7 @@ async function reconcileRecipeCronJobs(opts: {
513
532
  }
514
533
 
515
534
  await writeJsonFile(statePath, state);
535
+ await saveCronStore(store);
516
536
 
517
537
  const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
518
538
  return { ok: true, changed, results };
@@ -1166,28 +1186,22 @@ const recipesPlugin = {
1166
1186
  console.error(header);
1167
1187
  }
1168
1188
 
1169
- // Use clawhub CLI. Force install path based on scope.
1170
- const { spawnSync } = await import("node:child_process");
1171
- for (const slug of missing) {
1172
- const res = spawnSync(
1173
- "npx",
1174
- ["clawhub@latest", "--workdir", workdir, "--dir", dirName, "install", slug],
1175
- { stdio: "inherit" },
1176
- );
1177
- if (res.status !== 0) {
1178
- process.exitCode = res.status ?? 1;
1179
- console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
1180
- return;
1181
- }
1182
- }
1189
+ // SECURITY NOTE: avoid shelling out from plugins.
1190
+ // We intentionally do NOT auto-run `npx clawhub ...` here.
1191
+ // Instead, print the exact commands to run manually.
1192
+ const commands = missing.map(
1193
+ (slug) => `npx clawhub@latest --workdir "${workdir}" --dir "${dirName}" install "${slug}"`,
1194
+ );
1183
1195
 
1184
1196
  console.log(
1185
1197
  JSON.stringify(
1186
1198
  {
1187
- ok: true,
1188
- installed: missing,
1199
+ ok: false,
1200
+ reason: "manual-install-required",
1201
+ missing,
1189
1202
  installDir,
1190
- next: `Try: openclaw skills list (or check ${installDir})`,
1203
+ commands,
1204
+ next: "Run the commands above, then: openclaw gateway restart",
1191
1205
  },
1192
1206
  null,
1193
1207
  2,
@@ -1743,26 +1757,61 @@ const recipesPlugin = {
1743
1757
  .requiredOption("--team-id <teamId>", "Team id")
1744
1758
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1745
1759
  .option("--yes", "Skip confirmation")
1746
- .action(async (options: any) => {
1747
- const args = [
1748
- 'recipes',
1749
- 'move-ticket',
1750
- '--team-id',
1751
- String(options.teamId),
1752
- '--ticket',
1753
- String(options.ticket),
1754
- '--to',
1755
- 'done',
1756
- '--completed',
1757
- ];
1758
- if (options.yes) args.push('--yes');
1759
-
1760
- const { spawnSync } = await import('node:child_process');
1761
- const res = spawnSync('openclaw', args, { stdio: 'inherit' });
1762
- if (res.status !== 0) {
1763
- process.exitCode = res.status ?? 1;
1764
- }
1765
- });
1760
+ .action(async (options: any) =>
1761
+ runRecipesCommand("openclaw recipes complete", async () => {
1762
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1763
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1764
+ const teamId = String(options.teamId);
1765
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1766
+
1767
+ const ticketArg = String(options.ticket);
1768
+ const srcPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1769
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1770
+
1771
+ const destDir = path.join(teamDir, 'work', 'done');
1772
+ await ensureDir(destDir);
1773
+
1774
+ const filename = path.basename(srcPath);
1775
+ const destPath = path.join(destDir, filename);
1776
+
1777
+ const plan = { from: srcPath, to: destPath, completed: true };
1778
+
1779
+ if (!options.yes && process.stdin.isTTY) {
1780
+ console.log(JSON.stringify({ plan }, null, 2));
1781
+ const readline = await import('node:readline/promises');
1782
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1783
+ try {
1784
+ const ans = await rl.question(`Move to done and mark completed? (y/N) `);
1785
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1786
+ if (!ok) {
1787
+ console.error('Aborted; no changes made.');
1788
+ return;
1789
+ }
1790
+ } finally {
1791
+ rl.close();
1792
+ }
1793
+ } else if (!options.yes && !process.stdin.isTTY) {
1794
+ console.error('Refusing to complete without confirmation in non-interactive mode. Re-run with --yes.');
1795
+ process.exitCode = 2;
1796
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1797
+ return;
1798
+ }
1799
+
1800
+ const md = await fs.readFile(srcPath, 'utf8');
1801
+ let out = md;
1802
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: done`);
1803
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: done\n`);
1804
+
1805
+ const completedIso = new Date().toISOString();
1806
+ if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completedIso}`);
1807
+ else out = out.replace(/^(# .+\n)/, `$1\nCompleted: ${completedIso}\n`);
1808
+
1809
+ await fs.writeFile(srcPath, out, 'utf8');
1810
+ if (srcPath !== destPath) await fs.rename(srcPath, destPath);
1811
+
1812
+ console.log(JSON.stringify({ ok: true, moved: { from: srcPath, to: destPath }, completed: completedIso }, null, 2));
1813
+ }),
1814
+ );
1766
1815
 
1767
1816
  cmd
1768
1817
  .command("scaffold")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawcipes/recipes",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Clawcipes recipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",