@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.
- package/index.ts +219 -80
- 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
|
-
|
|
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
|
-
|
|
270
|
+
type ToolsInvokeRequest = {
|
|
271
|
+
tool: string;
|
|
272
|
+
action?: string;
|
|
273
|
+
args?: Record<string, unknown>;
|
|
274
|
+
sessionKey?: string;
|
|
275
|
+
dryRun?: boolean;
|
|
276
|
+
};
|
|
279
277
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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(
|
|
314
|
+
return JSON.parse(trimmed) as any;
|
|
294
315
|
} catch (e) {
|
|
295
|
-
const err = new Error(`Failed parsing JSON from
|
|
296
|
-
(err as any).
|
|
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 =
|
|
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
|
|
409
|
-
|
|
410
|
-
"add",
|
|
411
|
-
"--json",
|
|
412
|
-
"--name",
|
|
454
|
+
const sessionTarget = j.agentId ? "isolated" : "main";
|
|
455
|
+
const job = {
|
|
413
456
|
name,
|
|
414
|
-
|
|
415
|
-
j.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
439
|
-
"cron",
|
|
440
|
-
"edit",
|
|
441
|
-
existing.id,
|
|
442
|
-
"--name",
|
|
489
|
+
const patch: any = {
|
|
443
490
|
name,
|
|
444
|
-
|
|
445
|
-
j.
|
|
446
|
-
"
|
|
447
|
-
|
|
448
|
-
"
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (j.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1160
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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,
|