@jiggai/recipes 0.2.22 → 0.2.24
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/README.md +10 -7
- package/docs/AGENTS_AND_SKILLS.md +21 -9
- package/docs/BUNDLED_RECIPES.md +12 -7
- package/docs/CLEANUP_TODO.md +31 -0
- package/docs/CODE_SMELLS_TRACKER.md +42 -0
- package/docs/SMELLS_TODO.md +23 -0
- package/docs/TEAM_WORKFLOW.md +3 -1
- package/docs/TEST_COVERAGE_PROGRESS.md +37 -0
- package/index.ts +386 -2156
- package/package.json +21 -2
- package/recipes/default/business-team.md +166 -51
- package/recipes/default/clinic-team.md +161 -29
- package/recipes/default/construction-team.md +167 -39
- package/recipes/default/crypto-trader-team.md +161 -29
- package/recipes/default/customer-support-team.md +140 -69
- package/recipes/default/development-team.md +50 -118
- package/recipes/default/financial-planner-team.md +167 -38
- package/recipes/default/law-firm-team.md +167 -40
- package/recipes/default/marketing-team.md +420 -123
- package/recipes/default/product-team.md +160 -93
- package/recipes/default/research-team.md +140 -66
- package/recipes/default/researcher.md +1 -1
- package/recipes/default/social-team.md +649 -60
- package/recipes/default/stock-trader-team.md +189 -38
- package/recipes/default/writing-team.md +143 -55
- package/src/handlers/cron.ts +309 -0
- package/src/handlers/install.ts +160 -0
- package/src/handlers/recipes.ts +119 -0
- package/src/handlers/scaffold.ts +141 -0
- package/src/handlers/team.ts +395 -0
- package/src/handlers/tickets.ts +304 -0
- package/src/lib/agent-config.ts +48 -0
- package/src/lib/bindings.ts +9 -59
- package/src/lib/cleanup-workspaces.ts +4 -4
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +11 -0
- package/src/lib/cron-utils.ts +54 -0
- package/src/lib/fs-utils.ts +33 -0
- package/src/lib/json-utils.ts +17 -0
- package/src/lib/lanes.ts +14 -12
- package/src/lib/prompt.ts +47 -0
- package/src/lib/recipe-frontmatter.ts +65 -21
- package/src/lib/recipe-id.ts +49 -0
- package/src/lib/recipes-config.ts +166 -0
- package/src/lib/recipes.ts +57 -0
- package/src/lib/remove-team.ts +17 -23
- package/src/lib/scaffold-utils.ts +95 -0
- package/src/lib/skill-install.ts +22 -0
- package/src/lib/stable-stringify.ts +21 -0
- package/src/lib/template.ts +10 -0
- package/src/lib/ticket-finder.ts +40 -23
- package/src/lib/ticket-workflow.ts +32 -65
- package/src/lib/workspace.ts +33 -0
- package/src/marketplaceFetch.ts +1 -1
- package/src/toolsInvoke.ts +41 -32
package/index.ts
CHANGED
|
@@ -1,759 +1,54 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import crypto from "node:crypto";
|
|
5
3
|
import JSON5 from "json5";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// skill deps (installed into workspace-local skills dir)
|
|
55
|
-
requiredSkills?: string[];
|
|
56
|
-
optionalSkills?: string[];
|
|
57
|
-
|
|
58
|
-
// Team recipe: defines a team workspace + multiple agents.
|
|
59
|
-
team?: {
|
|
60
|
-
teamId: string; // must end with -team
|
|
61
|
-
name?: string;
|
|
62
|
-
description?: string;
|
|
63
|
-
};
|
|
64
|
-
agents?: Array<{
|
|
65
|
-
role: string;
|
|
66
|
-
agentId?: string; // default: <teamId>-<role>
|
|
67
|
-
name?: string; // display name
|
|
68
|
-
// Optional per-role tool policy override (else uses top-level tools)
|
|
69
|
-
tools?: {
|
|
70
|
-
profile?: string;
|
|
71
|
-
allow?: string[];
|
|
72
|
-
deny?: string[];
|
|
73
|
-
};
|
|
74
|
-
}>;
|
|
75
|
-
|
|
76
|
-
// Agent recipe: templates + files to write in the agent folder.
|
|
77
|
-
// For team recipes, templates can be namespaced by role, e.g. "lead.soul", "writer.agents".
|
|
78
|
-
templates?: Record<string, string>;
|
|
79
|
-
files?: Array<{
|
|
80
|
-
path: string;
|
|
81
|
-
template: string; // key in templates map
|
|
82
|
-
mode?: "createOnly" | "overwrite";
|
|
83
|
-
}>;
|
|
84
|
-
|
|
85
|
-
// Tool policy (applies to agent recipe; team recipes can override per agent)
|
|
86
|
-
tools?: {
|
|
87
|
-
profile?: string;
|
|
88
|
-
allow?: string[];
|
|
89
|
-
deny?: string[];
|
|
90
|
-
};
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
function getCfg(api: OpenClawPluginApi): Required<RecipesConfig> {
|
|
94
|
-
const cfg = (api.config.plugins?.entries?.["recipes"]?.config ??
|
|
95
|
-
api.config.plugins?.entries?.recipes?.config ??
|
|
96
|
-
{}) as RecipesConfig;
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
workspaceRecipesDir: cfg.workspaceRecipesDir ?? "recipes",
|
|
100
|
-
workspaceAgentsDir: cfg.workspaceAgentsDir ?? "agents",
|
|
101
|
-
workspaceSkillsDir: cfg.workspaceSkillsDir ?? "skills",
|
|
102
|
-
workspaceTeamsDir: cfg.workspaceTeamsDir ?? "teams",
|
|
103
|
-
autoInstallMissingSkills: cfg.autoInstallMissingSkills ?? false,
|
|
104
|
-
confirmAutoInstall: cfg.confirmAutoInstall ?? true,
|
|
105
|
-
cronInstallation: cfg.cronInstallation ?? "prompt",
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function workspacePath(api: OpenClawPluginApi, ...parts: string[]) {
|
|
110
|
-
const root = api.config.agents?.defaults?.workspace;
|
|
111
|
-
if (!root) throw new Error("agents.defaults.workspace is not set in config");
|
|
112
|
-
return path.join(root, ...parts);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function fileExists(p: string) {
|
|
116
|
-
try {
|
|
117
|
-
await fs.stat(p);
|
|
118
|
-
return true;
|
|
119
|
-
} catch {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function listRecipeFiles(api: OpenClawPluginApi, cfg: Required<RecipesConfig>) {
|
|
125
|
-
const builtinDir = path.join(__dirname, "recipes", "default");
|
|
126
|
-
const workspaceDir = workspacePath(api, cfg.workspaceRecipesDir);
|
|
127
|
-
|
|
128
|
-
const out: Array<{ source: "builtin" | "workspace"; path: string }> = [];
|
|
129
|
-
|
|
130
|
-
if (await fileExists(builtinDir)) {
|
|
131
|
-
const files = await fs.readdir(builtinDir);
|
|
132
|
-
for (const f of files) if (f.endsWith(".md")) out.push({ source: "builtin", path: path.join(builtinDir, f) });
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (await fileExists(workspaceDir)) {
|
|
136
|
-
const files = await fs.readdir(workspaceDir);
|
|
137
|
-
for (const f of files) if (f.endsWith(".md")) out.push({ source: "workspace", path: path.join(workspaceDir, f) });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return out;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function parseFrontmatter(md: string): { frontmatter: RecipeFrontmatter; body: string } {
|
|
144
|
-
// very small frontmatter parser: expects ---\nYAML\n---\n
|
|
145
|
-
if (!md.startsWith("---\n")) {
|
|
146
|
-
throw new Error("Recipe markdown must start with YAML frontmatter (---)");
|
|
147
|
-
}
|
|
148
|
-
const end = md.indexOf("\n---\n", 4);
|
|
149
|
-
if (end === -1) throw new Error("Recipe frontmatter not terminated (---)");
|
|
150
|
-
const yamlText = md.slice(4, end + 1); // include trailing newline
|
|
151
|
-
const body = md.slice(end + 5);
|
|
152
|
-
const frontmatter = YAML.parse(yamlText) as RecipeFrontmatter;
|
|
153
|
-
if (!frontmatter?.id) throw new Error("Recipe frontmatter must include id");
|
|
154
|
-
return { frontmatter, body };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function loadRecipeById(api: OpenClawPluginApi, recipeId: string) {
|
|
158
|
-
const cfg = getCfg(api);
|
|
159
|
-
const files = await listRecipeFiles(api, cfg);
|
|
160
|
-
for (const f of files) {
|
|
161
|
-
const md = await fs.readFile(f.path, "utf8");
|
|
162
|
-
const { frontmatter } = parseFrontmatter(md);
|
|
163
|
-
if (frontmatter.id === recipeId) return { file: f, md, ...parseFrontmatter(md) };
|
|
164
|
-
}
|
|
165
|
-
throw new Error(`Recipe not found: ${recipeId}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function skillInstallCommands(cfg: Required<RecipesConfig>, skills: string[]) {
|
|
169
|
-
// We standardize on clawhub CLI. Workspace-local install path is implicit by running from workspace
|
|
170
|
-
// OR by environment var if clawhub supports it (unknown). For now: cd workspace + install.
|
|
171
|
-
// We'll refine once we lock exact clawhub CLI flags.
|
|
172
|
-
const lines = [
|
|
173
|
-
`cd "${"$WORKSPACE"}" # set WORKSPACE=~/.openclaw/workspace`,
|
|
174
|
-
...skills.map((s) => `npx clawhub@latest install ${s}`),
|
|
175
|
-
];
|
|
176
|
-
return lines;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function detectMissingSkills(installDir: string, skills: string[]) {
|
|
180
|
-
const missing: string[] = [];
|
|
181
|
-
for (const s of skills) {
|
|
182
|
-
const p = path.join(installDir, s);
|
|
183
|
-
if (!(await fileExists(p))) missing.push(s);
|
|
184
|
-
}
|
|
185
|
-
return missing;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function ensureDir(p: string) {
|
|
189
|
-
await fs.mkdir(p, { recursive: true });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function ticketStageDir(teamDir: string, stage: "backlog" | "in-progress" | "testing" | "done" | "assignments") {
|
|
193
|
-
return stage === "assignments"
|
|
194
|
-
? path.join(teamDir, "work", "assignments")
|
|
195
|
-
: path.join(teamDir, "work", stage);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function ensureTicketStageDirs(teamDir: string) {
|
|
199
|
-
// Idempotent. Used to harden ticket commands for older team workspaces.
|
|
200
|
-
// NOTE: creating these directories is safe even if empty.
|
|
201
|
-
await Promise.all([
|
|
202
|
-
ensureDir(path.join(teamDir, "work")),
|
|
203
|
-
ensureDir(ticketStageDir(teamDir, "backlog")),
|
|
204
|
-
ensureDir(ticketStageDir(teamDir, "in-progress")),
|
|
205
|
-
ensureDir(ticketStageDir(teamDir, "testing")),
|
|
206
|
-
ensureDir(ticketStageDir(teamDir, "done")),
|
|
207
|
-
ensureDir(ticketStageDir(teamDir, "assignments")),
|
|
208
|
-
]);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
type CronInstallMode = "off" | "prompt" | "on";
|
|
212
|
-
|
|
213
|
-
type CronMappingStateV1 = {
|
|
214
|
-
version: 1;
|
|
215
|
-
entries: Record<
|
|
216
|
-
string,
|
|
217
|
-
{
|
|
218
|
-
installedCronId: string;
|
|
219
|
-
specHash: string;
|
|
220
|
-
orphaned?: boolean;
|
|
221
|
-
updatedAtMs: number;
|
|
222
|
-
}
|
|
223
|
-
>;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
function cronKey(scope: { kind: "team"; teamId: string; recipeId: string } | { kind: "agent"; agentId: string; recipeId: string }, cronJobId: string) {
|
|
227
|
-
return scope.kind === "team"
|
|
228
|
-
? `team:${scope.teamId}:recipe:${scope.recipeId}:cron:${cronJobId}`
|
|
229
|
-
: `agent:${scope.agentId}:recipe:${scope.recipeId}:cron:${cronJobId}`;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function hashSpec(spec: unknown) {
|
|
233
|
-
const json = stableStringify(spec);
|
|
234
|
-
return crypto.createHash("sha256").update(json, "utf8").digest("hex");
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async function readJsonFile<T>(p: string): Promise<T | null> {
|
|
238
|
-
try {
|
|
239
|
-
const raw = await fs.readFile(p, "utf8");
|
|
240
|
-
return JSON.parse(raw) as T;
|
|
241
|
-
} catch {
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function writeJsonFile(p: string, data: unknown) {
|
|
247
|
-
await ensureDir(path.dirname(p));
|
|
248
|
-
await fs.writeFile(p, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function loadCronMappingState(statePath: string): Promise<CronMappingStateV1> {
|
|
252
|
-
const existing = await readJsonFile<CronMappingStateV1>(statePath);
|
|
253
|
-
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === "object") return existing;
|
|
254
|
-
return { version: 1, entries: {} };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
type OpenClawCronJob = {
|
|
258
|
-
id: string;
|
|
259
|
-
name?: string;
|
|
260
|
-
enabled?: boolean;
|
|
261
|
-
schedule?: any;
|
|
262
|
-
payload?: any;
|
|
263
|
-
delivery?: any;
|
|
264
|
-
agentId?: string | null;
|
|
265
|
-
description?: string;
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
import { toolsInvoke, type ToolTextResult, type ToolsInvokeRequest } from "./src/toolsInvoke";
|
|
269
|
-
|
|
270
|
-
function parseToolTextJson(text: string, label: string) {
|
|
271
|
-
const trimmed = String(text ?? "").trim();
|
|
272
|
-
if (!trimmed) return null;
|
|
273
|
-
try {
|
|
274
|
-
return JSON.parse(trimmed) as any;
|
|
275
|
-
} catch (e) {
|
|
276
|
-
const err = new Error(`Failed parsing JSON from tool text (${label})`);
|
|
277
|
-
(err as any).text = text;
|
|
278
|
-
(err as any).cause = e;
|
|
279
|
-
throw err;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async function cronList(api: any) {
|
|
284
|
-
const result = await toolsInvoke<ToolTextResult>(api, {
|
|
285
|
-
tool: "cron",
|
|
286
|
-
args: { action: "list", includeDisabled: true },
|
|
287
|
-
});
|
|
288
|
-
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
289
|
-
const parsed = text ? (parseToolTextJson(text, "cron.list") as { jobs?: OpenClawCronJob[] }) : null;
|
|
290
|
-
return { jobs: parsed?.jobs ?? [] };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function cronAdd(api: any, job: any) {
|
|
294
|
-
const result = await toolsInvoke<ToolTextResult>(api, { tool: "cron", args: { action: "add", job } });
|
|
295
|
-
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
296
|
-
return text ? parseToolTextJson(text, "cron.add") : null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async function cronUpdate(api: any, jobId: string, patch: any) {
|
|
300
|
-
const result = await toolsInvoke<ToolTextResult>(api, {
|
|
301
|
-
tool: "cron",
|
|
302
|
-
args: { action: "update", jobId, patch },
|
|
303
|
-
});
|
|
304
|
-
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
305
|
-
return text ? parseToolTextJson(text, "cron.update") : null;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
|
|
309
|
-
const raw = frontmatter.cronJobs;
|
|
310
|
-
if (!raw) return [];
|
|
311
|
-
if (!Array.isArray(raw)) throw new Error("frontmatter.cronJobs must be an array");
|
|
312
|
-
|
|
313
|
-
const out: CronJobSpec[] = [];
|
|
314
|
-
const seen = new Set<string>();
|
|
315
|
-
for (const j of raw as any[]) {
|
|
316
|
-
if (!j || typeof j !== "object") throw new Error("cronJobs entries must be objects");
|
|
317
|
-
const id = String((j as any).id ?? "").trim();
|
|
318
|
-
if (!id) throw new Error("cronJobs[].id is required");
|
|
319
|
-
if (seen.has(id)) throw new Error(`Duplicate cronJobs[].id: ${id}`);
|
|
320
|
-
seen.add(id);
|
|
321
|
-
|
|
322
|
-
const schedule = String((j as any).schedule ?? "").trim();
|
|
323
|
-
const message = String((j as any).message ?? (j as any).task ?? (j as any).prompt ?? "").trim();
|
|
324
|
-
if (!schedule) throw new Error(`cronJobs[${id}].schedule is required`);
|
|
325
|
-
if (!message) throw new Error(`cronJobs[${id}].message is required`);
|
|
326
|
-
|
|
327
|
-
out.push({
|
|
328
|
-
id,
|
|
329
|
-
schedule,
|
|
330
|
-
message,
|
|
331
|
-
name: (j as any).name ? String((j as any).name) : undefined,
|
|
332
|
-
description: (j as any).description ? String((j as any).description) : undefined,
|
|
333
|
-
timezone: (j as any).timezone ? String((j as any).timezone) : undefined,
|
|
334
|
-
channel: (j as any).channel ? String((j as any).channel) : undefined,
|
|
335
|
-
to: (j as any).to ? String((j as any).to) : undefined,
|
|
336
|
-
agentId: (j as any).agentId ? String((j as any).agentId) : undefined,
|
|
337
|
-
enabledByDefault: Boolean((j as any).enabledByDefault ?? false),
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
return out;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async function promptYesNo(header: string) {
|
|
344
|
-
if (!process.stdin.isTTY) return false;
|
|
345
|
-
const readline = await import("node:readline/promises");
|
|
346
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
347
|
-
try {
|
|
348
|
-
const ans = await rl.question(`${header}\nProceed? (y/N) `);
|
|
349
|
-
return ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
350
|
-
} finally {
|
|
351
|
-
rl.close();
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function reconcileRecipeCronJobs(opts: {
|
|
356
|
-
api: OpenClawPluginApi;
|
|
357
|
-
recipe: RecipeFrontmatter;
|
|
358
|
-
scope: { kind: "team"; teamId: string; recipeId: string; stateDir: string } | { kind: "agent"; agentId: string; recipeId: string; stateDir: string };
|
|
359
|
-
cronInstallation: CronInstallMode;
|
|
360
|
-
}) {
|
|
361
|
-
const desired = normalizeCronJobs(opts.recipe);
|
|
362
|
-
if (!desired.length) return { ok: true, changed: false, note: "no-cron-jobs" as const };
|
|
363
|
-
|
|
364
|
-
const mode = opts.cronInstallation;
|
|
365
|
-
if (mode === "off") {
|
|
366
|
-
return { ok: true, changed: false, note: "cron-installation-off" as const, desiredCount: desired.length };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Decide whether jobs should be enabled on creation. Default is conservative.
|
|
370
|
-
let userOptIn = mode === "on";
|
|
371
|
-
if (mode === "prompt") {
|
|
372
|
-
const header = `Recipe ${opts.scope.recipeId} defines ${desired.length} cron job(s).\nThese run automatically on a schedule. Install them?`;
|
|
373
|
-
userOptIn = await promptYesNo(header);
|
|
374
|
-
|
|
375
|
-
// If the user declines, skip all cron reconciliation entirely. This avoids a
|
|
376
|
-
// potentially slow gateway cron.list call and matches user intent.
|
|
377
|
-
if (!userOptIn) {
|
|
378
|
-
return { ok: true, changed: false, note: "cron-installation-declined" as const, desiredCount: desired.length };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (!process.stdin.isTTY) {
|
|
382
|
-
console.error("Non-interactive mode: defaulting cron install to disabled.");
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
|
|
387
|
-
const state = await loadCronMappingState(statePath);
|
|
388
|
-
|
|
389
|
-
// Fast path: if we have no prior installed ids for these desired jobs, skip cron.list.
|
|
390
|
-
// cron.list can be slow/hang on some setups; we can still create jobs and record ids.
|
|
391
|
-
const desiredKeys = desired.map((j) => cronKey(opts.scope as any, j.id));
|
|
392
|
-
const hasAnyInstalled = desiredKeys.some((k) => Boolean(state.entries[k]?.installedCronId));
|
|
393
|
-
|
|
394
|
-
const list = hasAnyInstalled ? await cronList(opts.api) : { jobs: [] };
|
|
395
|
-
const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
|
|
396
|
-
|
|
397
|
-
const now = Date.now();
|
|
398
|
-
const desiredIds = new Set(desired.map((j) => j.id));
|
|
399
|
-
|
|
400
|
-
const results: any[] = [];
|
|
401
|
-
|
|
402
|
-
for (const j of desired) {
|
|
403
|
-
const key = cronKey(opts.scope as any, j.id);
|
|
404
|
-
const name = j.name ?? `${opts.scope.kind === "team" ? (opts.scope as any).teamId : (opts.scope as any).agentId} • ${opts.scope.recipeId} • ${j.id}`;
|
|
405
|
-
|
|
406
|
-
const desiredSpec = {
|
|
407
|
-
schedule: j.schedule,
|
|
408
|
-
message: j.message,
|
|
409
|
-
timezone: j.timezone ?? "",
|
|
410
|
-
channel: j.channel ?? "last",
|
|
411
|
-
to: j.to ?? "",
|
|
412
|
-
agentId: j.agentId ?? "",
|
|
413
|
-
name,
|
|
414
|
-
description: j.description ?? "",
|
|
415
|
-
};
|
|
416
|
-
const specHash = hashSpec(desiredSpec);
|
|
417
|
-
|
|
418
|
-
const prev = state.entries[key];
|
|
419
|
-
const installedId = prev?.installedCronId;
|
|
420
|
-
const existing = installedId ? byId.get(installedId) : undefined;
|
|
421
|
-
|
|
422
|
-
const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
|
|
423
|
-
|
|
424
|
-
if (!existing) {
|
|
425
|
-
// Create new job.
|
|
426
|
-
const sessionTarget = j.agentId ? "isolated" : "main";
|
|
427
|
-
const job = {
|
|
428
|
-
name,
|
|
429
|
-
agentId: j.agentId ?? null,
|
|
430
|
-
description: j.description ?? "",
|
|
431
|
-
enabled: wantEnabled,
|
|
432
|
-
wakeMode: "next-heartbeat",
|
|
433
|
-
sessionTarget,
|
|
434
|
-
schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
|
|
435
|
-
payload: j.agentId
|
|
436
|
-
? { kind: "agentTurn", message: j.message }
|
|
437
|
-
: { kind: "systemEvent", text: j.message },
|
|
438
|
-
...(j.channel || j.to
|
|
439
|
-
? {
|
|
440
|
-
delivery: {
|
|
441
|
-
mode: "announce",
|
|
442
|
-
...(j.channel ? { channel: j.channel } : {}),
|
|
443
|
-
...(j.to ? { to: j.to } : {}),
|
|
444
|
-
bestEffort: true,
|
|
445
|
-
},
|
|
446
|
-
}
|
|
447
|
-
: {}),
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const created = await cronAdd(opts.api, job);
|
|
451
|
-
const newId = (created as any)?.id ?? (created as any)?.job?.id;
|
|
452
|
-
if (!newId) throw new Error("Failed to parse cron add output (missing id)");
|
|
453
|
-
|
|
454
|
-
state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
|
|
455
|
-
results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Update existing job if spec changed.
|
|
460
|
-
if (prev?.specHash !== specHash) {
|
|
461
|
-
const patch: any = {
|
|
462
|
-
name,
|
|
463
|
-
agentId: j.agentId ?? null,
|
|
464
|
-
description: j.description ?? "",
|
|
465
|
-
sessionTarget: j.agentId ? "isolated" : "main",
|
|
466
|
-
wakeMode: "next-heartbeat",
|
|
467
|
-
schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
|
|
468
|
-
payload: j.agentId ? { kind: "agentTurn", message: j.message } : { kind: "systemEvent", text: j.message },
|
|
469
|
-
};
|
|
470
|
-
if (j.channel || j.to) {
|
|
471
|
-
patch.delivery = {
|
|
472
|
-
mode: "announce",
|
|
473
|
-
...(j.channel ? { channel: j.channel } : {}),
|
|
474
|
-
...(j.to ? { to: j.to } : {}),
|
|
475
|
-
bestEffort: true,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
await cronUpdate(opts.api, existing.id, patch);
|
|
480
|
-
results.push({ action: "updated", key, installedCronId: existing.id });
|
|
481
|
-
} else {
|
|
482
|
-
results.push({ action: "unchanged", key, installedCronId: existing.id });
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
|
|
486
|
-
if (!userOptIn) {
|
|
487
|
-
if (existing.enabled) {
|
|
488
|
-
await cronUpdate(opts.api, existing.id, { enabled: false });
|
|
489
|
-
results.push({ action: "disabled", key, installedCronId: existing.id });
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
state.entries[key] = { installedCronId: existing.id, specHash, updatedAtMs: now, orphaned: false };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Handle removed jobs: disable safely.
|
|
497
|
-
for (const [key, entry] of Object.entries(state.entries)) {
|
|
498
|
-
if (!key.includes(`:recipe:${opts.scope.recipeId}:cron:`)) continue;
|
|
499
|
-
const cronId = key.split(":cron:")[1] ?? "";
|
|
500
|
-
if (!cronId || desiredIds.has(cronId)) continue;
|
|
501
|
-
|
|
502
|
-
const job = byId.get(entry.installedCronId);
|
|
503
|
-
if (job && job.enabled) {
|
|
504
|
-
await cronUpdate(api, job.id, { enabled: false });
|
|
505
|
-
results.push({ action: "disabled-removed", key, installedCronId: job.id });
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
state.entries[key] = { ...entry, orphaned: true, updatedAtMs: now };
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
await writeJsonFile(statePath, state);
|
|
512
|
-
|
|
513
|
-
const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
|
|
514
|
-
return { ok: true, changed, results };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function renderTemplate(raw: string, vars: Record<string, string>) {
|
|
518
|
-
// Tiny, safe template renderer: replaces {{key}}.
|
|
519
|
-
// No conditionals, no eval.
|
|
520
|
-
return raw.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => {
|
|
521
|
-
const v = vars[key];
|
|
522
|
-
return typeof v === "string" ? v : "";
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function writeFileSafely(p: string, content: string, mode: "createOnly" | "overwrite") {
|
|
527
|
-
if (mode === "createOnly" && (await fileExists(p))) return { wrote: false, reason: "exists" as const };
|
|
528
|
-
await ensureDir(path.dirname(p));
|
|
529
|
-
await fs.writeFile(p, content, "utf8");
|
|
530
|
-
return { wrote: true, reason: "ok" as const };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
type AgentConfigSnippet = {
|
|
534
|
-
id: string;
|
|
535
|
-
workspace: string;
|
|
536
|
-
identity?: { name?: string };
|
|
537
|
-
tools?: { profile?: string; allow?: string[]; deny?: string[] };
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
type BindingMatch = {
|
|
541
|
-
channel: string;
|
|
542
|
-
accountId?: string;
|
|
543
|
-
// OpenClaw config schema uses: dm | group | channel
|
|
544
|
-
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
|
545
|
-
guildId?: string;
|
|
546
|
-
teamId?: string;
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
type BindingSnippet = {
|
|
550
|
-
agentId: string;
|
|
551
|
-
match: BindingMatch;
|
|
552
|
-
};
|
|
553
|
-
|
|
554
|
-
function upsertAgentInConfig(cfgObj: any, snippet: AgentConfigSnippet) {
|
|
555
|
-
if (!cfgObj.agents) cfgObj.agents = {};
|
|
556
|
-
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
557
|
-
|
|
558
|
-
const list: any[] = cfgObj.agents.list;
|
|
559
|
-
const idx = list.findIndex((a) => a?.id === snippet.id);
|
|
560
|
-
const prev = idx >= 0 ? list[idx] : {};
|
|
561
|
-
const nextAgent = {
|
|
562
|
-
...prev,
|
|
563
|
-
id: snippet.id,
|
|
564
|
-
workspace: snippet.workspace,
|
|
565
|
-
// identity: merge (safe)
|
|
566
|
-
identity: {
|
|
567
|
-
...(prev?.identity ?? {}),
|
|
568
|
-
...(snippet.identity ?? {}),
|
|
569
|
-
},
|
|
570
|
-
// tools: replace when provided (so stale deny/allow don’t linger)
|
|
571
|
-
tools: snippet.tools ? { ...snippet.tools } : prev?.tools,
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
if (idx >= 0) {
|
|
575
|
-
list[idx] = nextAgent;
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// New agent: append to end of list.
|
|
580
|
-
// (We still separately enforce that main exists and stays first/default.)
|
|
581
|
-
list.push(nextAgent);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function ensureMainFirstInAgentsList(cfgObj: any, api: OpenClawPluginApi) {
|
|
585
|
-
if (!cfgObj.agents) cfgObj.agents = {};
|
|
586
|
-
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
587
|
-
|
|
588
|
-
const list: any[] = cfgObj.agents.list;
|
|
589
|
-
|
|
590
|
-
const workspaceRoot =
|
|
591
|
-
cfgObj.agents?.defaults?.workspace ??
|
|
592
|
-
api.config.agents?.defaults?.workspace ??
|
|
593
|
-
"~/.openclaw/workspace";
|
|
594
|
-
|
|
595
|
-
const idx = list.findIndex((a) => a?.id === "main");
|
|
596
|
-
const prevMain = idx >= 0 ? list[idx] : {};
|
|
597
|
-
|
|
598
|
-
// Enforce: main exists, is first, and is the default.
|
|
599
|
-
const main = {
|
|
600
|
-
...prevMain,
|
|
601
|
-
id: "main",
|
|
602
|
-
default: true,
|
|
603
|
-
workspace: prevMain?.workspace ?? workspaceRoot,
|
|
604
|
-
sandbox: prevMain?.sandbox ?? { mode: "off" },
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
// Ensure only one default.
|
|
608
|
-
for (const a of list) {
|
|
609
|
-
if (a?.id !== "main" && a?.default) a.default = false;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
if (idx >= 0) list.splice(idx, 1);
|
|
613
|
-
list.unshift(main);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
function stableStringify(x: any) {
|
|
617
|
-
const seen = new WeakSet();
|
|
618
|
-
const sortObj = (v: any): any => {
|
|
619
|
-
if (v && typeof v === "object") {
|
|
620
|
-
if (seen.has(v)) return "[Circular]";
|
|
621
|
-
seen.add(v);
|
|
622
|
-
if (Array.isArray(v)) return v.map(sortObj);
|
|
623
|
-
const out: any = {};
|
|
624
|
-
for (const k of Object.keys(v).sort()) out[k] = sortObj(v[k]);
|
|
625
|
-
return out;
|
|
626
|
-
}
|
|
627
|
-
return v;
|
|
628
|
-
};
|
|
629
|
-
return JSON.stringify(sortObj(x));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
633
|
-
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
634
|
-
const list: any[] = cfgObj.bindings;
|
|
635
|
-
|
|
636
|
-
const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
637
|
-
const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
|
|
638
|
-
|
|
639
|
-
if (idx >= 0) {
|
|
640
|
-
// Update in place (preserve ordering)
|
|
641
|
-
list[idx] = { ...list[idx], ...binding };
|
|
642
|
-
return { changed: false, note: "already-present" as const };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
646
|
-
// Otherwise append.
|
|
647
|
-
if (binding.match?.peer) list.unshift(binding);
|
|
648
|
-
else list.push(binding);
|
|
649
|
-
|
|
650
|
-
return { changed: true, note: "added" as const };
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
|
|
654
|
-
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
655
|
-
const list: any[] = cfgObj.bindings;
|
|
656
|
-
|
|
657
|
-
const targetMatchSig = stableStringify(opts.match);
|
|
658
|
-
|
|
659
|
-
const before = list.length;
|
|
660
|
-
const kept: any[] = [];
|
|
661
|
-
const removed: any[] = [];
|
|
662
|
-
|
|
663
|
-
for (const b of list) {
|
|
664
|
-
const sameAgent = opts.agentId ? String(b?.agentId ?? "") === opts.agentId : true;
|
|
665
|
-
const sameMatch = stableStringify(b?.match ?? {}) === targetMatchSig;
|
|
666
|
-
if (sameAgent && sameMatch) removed.push(b);
|
|
667
|
-
else kept.push(b);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
cfgObj.bindings = kept;
|
|
671
|
-
return { removedCount: before - kept.length, removed };
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
|
|
675
|
-
// Load the latest config from disk (not the snapshot in api.config).
|
|
676
|
-
const current = (api.runtime as any).config?.loadConfig?.();
|
|
677
|
-
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
678
|
-
|
|
679
|
-
// Some loaders return { cfg, ... }. If so, normalize.
|
|
680
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
681
|
-
|
|
682
|
-
// Always keep main first/default when multi-agent workflows are in play.
|
|
683
|
-
ensureMainFirstInAgentsList(cfgObj, api);
|
|
684
|
-
|
|
685
|
-
for (const s of snippets) upsertAgentInConfig(cfgObj, s);
|
|
686
|
-
|
|
687
|
-
// Re-assert ordering/default after upserts.
|
|
688
|
-
ensureMainFirstInAgentsList(cfgObj, api);
|
|
689
|
-
|
|
690
|
-
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
691
|
-
return { updatedAgents: snippets.map((s) => s.id) };
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
async function applyBindingSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: BindingSnippet[]) {
|
|
695
|
-
const current = (api.runtime as any).config?.loadConfig?.();
|
|
696
|
-
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
697
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
698
|
-
|
|
699
|
-
const results: any[] = [];
|
|
700
|
-
for (const s of snippets) {
|
|
701
|
-
results.push({ ...s, result: upsertBindingInConfig(cfgObj, s) });
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
705
|
-
return { updatedBindings: results };
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
async function scaffoldAgentFromRecipe(
|
|
709
|
-
api: OpenClawPluginApi,
|
|
710
|
-
recipe: RecipeFrontmatter,
|
|
711
|
-
opts: {
|
|
712
|
-
agentId: string;
|
|
713
|
-
agentName?: string;
|
|
714
|
-
update?: boolean;
|
|
715
|
-
vars?: Record<string, string>;
|
|
716
|
-
|
|
717
|
-
// Where to write the scaffolded files (may be a shared team workspace role folder)
|
|
718
|
-
filesRootDir: string;
|
|
719
|
-
|
|
720
|
-
// What to set in agents.list[].workspace (may be shared team workspace root)
|
|
721
|
-
workspaceRootDir: string;
|
|
722
|
-
},
|
|
723
|
-
) {
|
|
724
|
-
await ensureDir(opts.filesRootDir);
|
|
725
|
-
|
|
726
|
-
const templates = recipe.templates ?? {};
|
|
727
|
-
const files = recipe.files ?? [];
|
|
728
|
-
const vars = opts.vars ?? {};
|
|
729
|
-
|
|
730
|
-
const fileResults: Array<{ path: string; wrote: boolean; reason: string }> = [];
|
|
731
|
-
for (const f of files) {
|
|
732
|
-
const raw = templates[f.template];
|
|
733
|
-
if (typeof raw !== "string") throw new Error(`Missing template: ${f.template}`);
|
|
734
|
-
const rendered = renderTemplate(raw, vars);
|
|
735
|
-
const target = path.join(opts.filesRootDir, f.path);
|
|
736
|
-
const mode = opts.update ? (f.mode ?? "overwrite") : (f.mode ?? "createOnly");
|
|
737
|
-
const r = await writeFileSafely(target, rendered, mode);
|
|
738
|
-
fileResults.push({ path: target, wrote: r.wrote, reason: r.reason });
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const configSnippet: AgentConfigSnippet = {
|
|
742
|
-
id: opts.agentId,
|
|
743
|
-
workspace: opts.workspaceRootDir,
|
|
744
|
-
identity: { name: opts.agentName ?? recipe.name ?? opts.agentId },
|
|
745
|
-
tools: recipe.tools ?? {},
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
return {
|
|
749
|
-
filesRootDir: opts.filesRootDir,
|
|
750
|
-
workspaceRootDir: opts.workspaceRootDir,
|
|
751
|
-
fileResults,
|
|
752
|
-
next: {
|
|
753
|
-
configSnippet,
|
|
754
|
-
},
|
|
755
|
-
};
|
|
756
|
-
}
|
|
4
|
+
import {
|
|
5
|
+
applyAgentSnippetsToOpenClawConfig,
|
|
6
|
+
ensureMainFirstInAgentsList,
|
|
7
|
+
loadOpenClawConfig,
|
|
8
|
+
removeBindingsInConfig,
|
|
9
|
+
type BindingMatch,
|
|
10
|
+
upsertBindingInConfig,
|
|
11
|
+
writeOpenClawConfig,
|
|
12
|
+
} from "./src/lib/recipes-config";
|
|
13
|
+
import { stableStringify } from "./src/lib/stable-stringify";
|
|
14
|
+
import { promptConfirmWithPlan, promptYesNo } from "./src/lib/prompt";
|
|
15
|
+
import {
|
|
16
|
+
handleRecipesBind,
|
|
17
|
+
handleRecipesBindings,
|
|
18
|
+
handleRecipesList,
|
|
19
|
+
handleRecipesShow,
|
|
20
|
+
handleRecipesStatus,
|
|
21
|
+
handleRecipesUnbind,
|
|
22
|
+
} from "./src/handlers/recipes";
|
|
23
|
+
import {
|
|
24
|
+
handleInstallMarketplaceRecipe,
|
|
25
|
+
handleInstallSkill,
|
|
26
|
+
} from "./src/handlers/install";
|
|
27
|
+
import {
|
|
28
|
+
handleAssign,
|
|
29
|
+
handleDispatch,
|
|
30
|
+
handleHandoff,
|
|
31
|
+
handleMoveTicket,
|
|
32
|
+
handleTake,
|
|
33
|
+
handleTickets,
|
|
34
|
+
patchTicketField,
|
|
35
|
+
patchTicketOwner,
|
|
36
|
+
patchTicketStatus,
|
|
37
|
+
} from "./src/handlers/tickets";
|
|
38
|
+
import {
|
|
39
|
+
handleMigrateTeamPlan,
|
|
40
|
+
handleRemoveTeam,
|
|
41
|
+
handleScaffoldTeam,
|
|
42
|
+
executeMigrateTeamPlan,
|
|
43
|
+
} from "./src/handlers/team";
|
|
44
|
+
import { handleScaffold, scaffoldAgentFromRecipe } from "./src/handlers/scaffold";
|
|
45
|
+
import { reconcileRecipeCronJobs } from "./src/handlers/cron";
|
|
46
|
+
import { listRecipeFiles, loadRecipeById, workspacePath } from "./src/lib/recipes";
|
|
47
|
+
import {
|
|
48
|
+
executeWorkspaceCleanup,
|
|
49
|
+
planWorkspaceCleanup,
|
|
50
|
+
} from "./src/lib/cleanup-workspaces";
|
|
51
|
+
import { resolveWorkspaceRoot } from "./src/lib/workspace";
|
|
757
52
|
|
|
758
53
|
const recipesPlugin = {
|
|
759
54
|
id: "recipes",
|
|
@@ -769,16 +64,13 @@ const recipesPlugin = {
|
|
|
769
64
|
// This is idempotent and only writes if a change is required.
|
|
770
65
|
(async () => {
|
|
771
66
|
try {
|
|
772
|
-
const
|
|
773
|
-
if (!current) return;
|
|
774
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
775
|
-
|
|
67
|
+
const cfgObj = await loadOpenClawConfig(api);
|
|
776
68
|
const before = JSON.stringify(cfgObj.agents?.list ?? null);
|
|
777
69
|
ensureMainFirstInAgentsList(cfgObj, api);
|
|
778
70
|
const after = JSON.stringify(cfgObj.agents?.list ?? null);
|
|
779
71
|
|
|
780
72
|
if (before !== after) {
|
|
781
|
-
await (api
|
|
73
|
+
await writeOpenClawConfig(api, cfgObj);
|
|
782
74
|
console.error("[recipes] ensured agents.list includes main as first/default");
|
|
783
75
|
}
|
|
784
76
|
} catch (e) {
|
|
@@ -796,18 +88,7 @@ const recipesPlugin = {
|
|
|
796
88
|
.command("list")
|
|
797
89
|
.description("List available recipes (builtin + workspace)")
|
|
798
90
|
.action(async () => {
|
|
799
|
-
const
|
|
800
|
-
const files = await listRecipeFiles(api, cfg);
|
|
801
|
-
const rows: Array<{ id: string; name?: string; kind?: string; source: string }> = [];
|
|
802
|
-
for (const f of files) {
|
|
803
|
-
try {
|
|
804
|
-
const md = await fs.readFile(f.path, "utf8");
|
|
805
|
-
const { frontmatter } = parseFrontmatter(md);
|
|
806
|
-
rows.push({ id: frontmatter.id, name: frontmatter.name, kind: frontmatter.kind, source: f.source });
|
|
807
|
-
} catch (e) {
|
|
808
|
-
rows.push({ id: path.basename(f.path), name: `INVALID: ${(e as Error).message}`, kind: "invalid", source: f.source });
|
|
809
|
-
}
|
|
810
|
-
}
|
|
91
|
+
const rows = await handleRecipesList(api);
|
|
811
92
|
console.log(JSON.stringify(rows, null, 2));
|
|
812
93
|
});
|
|
813
94
|
|
|
@@ -816,8 +97,8 @@ const recipesPlugin = {
|
|
|
816
97
|
.description("Show a recipe by id")
|
|
817
98
|
.argument("<id>", "Recipe id")
|
|
818
99
|
.action(async (id: string) => {
|
|
819
|
-
const
|
|
820
|
-
console.log(
|
|
100
|
+
const md = await handleRecipesShow(api, id);
|
|
101
|
+
console.log(md);
|
|
821
102
|
});
|
|
822
103
|
|
|
823
104
|
cmd
|
|
@@ -825,31 +106,21 @@ const recipesPlugin = {
|
|
|
825
106
|
.description("Check for missing skills for a recipe (or all)")
|
|
826
107
|
.argument("[id]", "Recipe id")
|
|
827
108
|
.action(async (id?: string) => {
|
|
828
|
-
const
|
|
829
|
-
const files = await listRecipeFiles(api, cfg);
|
|
830
|
-
const out: any[] = [];
|
|
831
|
-
|
|
832
|
-
for (const f of files) {
|
|
833
|
-
const md = await fs.readFile(f.path, "utf8");
|
|
834
|
-
const { frontmatter } = parseFrontmatter(md);
|
|
835
|
-
if (id && frontmatter.id !== id) continue;
|
|
836
|
-
const req = frontmatter.requiredSkills ?? [];
|
|
837
|
-
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
838
|
-
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
839
|
-
const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
|
|
840
|
-
const missing = await detectMissingSkills(installDir, req);
|
|
841
|
-
out.push({
|
|
842
|
-
id: frontmatter.id,
|
|
843
|
-
requiredSkills: req,
|
|
844
|
-
missingSkills: missing,
|
|
845
|
-
installCommands: missing.length ? skillInstallCommands(cfg, missing) : [],
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
|
|
109
|
+
const out = await handleRecipesStatus(api, id);
|
|
849
110
|
console.log(JSON.stringify(out, null, 2));
|
|
850
111
|
});
|
|
851
112
|
|
|
852
|
-
|
|
113
|
+
type BindOptions = {
|
|
114
|
+
match?: string;
|
|
115
|
+
channel?: string;
|
|
116
|
+
accountId?: string;
|
|
117
|
+
guildId?: string;
|
|
118
|
+
teamId?: string;
|
|
119
|
+
peerKind?: string;
|
|
120
|
+
peerId?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const parseMatchFromOptions = (options: BindOptions): BindingMatch => {
|
|
853
124
|
if (options.match) {
|
|
854
125
|
return JSON5.parse(String(options.match)) as BindingMatch;
|
|
855
126
|
}
|
|
@@ -888,12 +159,10 @@ const recipesPlugin = {
|
|
|
888
159
|
.option("--guild-id <guildId>", "Discord guildId")
|
|
889
160
|
.option("--team-id <teamId>", "Slack teamId")
|
|
890
161
|
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
891
|
-
.action(async (options:
|
|
892
|
-
|
|
162
|
+
.action(async (options: BindOptions & { agentId?: string }) => {
|
|
163
|
+
if (!options.agentId) throw new Error("--agent-id is required");
|
|
893
164
|
const match = parseMatchFromOptions(options);
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const res = await applyBindingSnippetsToOpenClawConfig(api, [{ agentId, match }]);
|
|
165
|
+
const res = await handleRecipesBind(api, { agentId: options.agentId, match });
|
|
897
166
|
console.log(JSON.stringify(res, null, 2));
|
|
898
167
|
console.error("Binding written. Restart gateway if required for changes to take effect.");
|
|
899
168
|
});
|
|
@@ -909,18 +178,9 @@ const recipesPlugin = {
|
|
|
909
178
|
.option("--guild-id <guildId>", "Discord guildId")
|
|
910
179
|
.option("--team-id <teamId>", "Slack teamId")
|
|
911
180
|
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
912
|
-
.action(async (options:
|
|
913
|
-
const agentId = typeof options.agentId === "string" ? String(options.agentId) : undefined;
|
|
181
|
+
.action(async (options: BindOptions & { agentId?: string }) => {
|
|
914
182
|
const match = parseMatchFromOptions(options);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const current = (api.runtime as any).config?.loadConfig?.();
|
|
918
|
-
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
919
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
920
|
-
|
|
921
|
-
const res = removeBindingsInConfig(cfgObj, { agentId, match });
|
|
922
|
-
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
923
|
-
|
|
183
|
+
const res = await handleRecipesUnbind(api, { agentId: typeof options.agentId === "string" ? options.agentId : undefined, match });
|
|
924
184
|
console.log(JSON.stringify({ ok: true, ...res }, null, 2));
|
|
925
185
|
console.error("Binding(s) removed. Restart gateway if required for changes to take effect.");
|
|
926
186
|
});
|
|
@@ -929,10 +189,8 @@ const recipesPlugin = {
|
|
|
929
189
|
.command("bindings")
|
|
930
190
|
.description("Show current bindings from openclaw config")
|
|
931
191
|
.action(async () => {
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
935
|
-
console.log(JSON.stringify(cfgObj.bindings ?? [], null, 2));
|
|
192
|
+
const bindings = await handleRecipesBindings(api);
|
|
193
|
+
console.log(JSON.stringify(bindings, null, 2));
|
|
936
194
|
});
|
|
937
195
|
|
|
938
196
|
cmd
|
|
@@ -942,129 +200,65 @@ const recipesPlugin = {
|
|
|
942
200
|
.option("--mode <mode>", "move|copy", "move")
|
|
943
201
|
.option("--dry-run", "Print the plan without writing anything", false)
|
|
944
202
|
.option("--overwrite", "Allow merging into an existing destination (dangerous)", false)
|
|
945
|
-
.action(async (options:
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
const mode = String(options.mode ?? "move");
|
|
950
|
-
if (mode !== "move" && mode !== "copy") throw new Error("--mode must be move|copy");
|
|
951
|
-
|
|
952
|
-
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
953
|
-
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
954
|
-
|
|
955
|
-
const legacyTeamDir = path.resolve(baseWorkspace, "teams", teamId);
|
|
956
|
-
const legacyAgentsDir = path.resolve(baseWorkspace, "agents");
|
|
957
|
-
|
|
958
|
-
const destTeamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
|
|
959
|
-
const destRolesDir = path.join(destTeamDir, "roles");
|
|
960
|
-
|
|
961
|
-
const exists = async (p: string) => fileExists(p);
|
|
962
|
-
|
|
963
|
-
// Build migration plan
|
|
964
|
-
const plan: any = {
|
|
965
|
-
teamId,
|
|
966
|
-
mode,
|
|
967
|
-
legacy: { teamDir: legacyTeamDir, agentsDir: legacyAgentsDir },
|
|
968
|
-
dest: { teamDir: destTeamDir, rolesDir: destRolesDir },
|
|
969
|
-
steps: [] as any[],
|
|
970
|
-
agentIds: [] as string[],
|
|
971
|
-
};
|
|
972
|
-
|
|
973
|
-
const legacyTeamExists = await exists(legacyTeamDir);
|
|
974
|
-
if (!legacyTeamExists) {
|
|
975
|
-
throw new Error(`Legacy team directory not found: ${legacyTeamDir}`);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const destExists = await exists(destTeamDir);
|
|
979
|
-
if (destExists && !options.overwrite) {
|
|
980
|
-
throw new Error(`Destination already exists: ${destTeamDir} (re-run with --overwrite to merge)`);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// 1) Move/copy team shared workspace
|
|
984
|
-
plan.steps.push({ kind: "teamDir", from: legacyTeamDir, to: destTeamDir });
|
|
985
|
-
|
|
986
|
-
// 2) Move/copy each role agent directory into roles/<role>/
|
|
987
|
-
const legacyAgentsExist = await exists(legacyAgentsDir);
|
|
988
|
-
let legacyAgentFolders: string[] = [];
|
|
989
|
-
if (legacyAgentsExist) {
|
|
990
|
-
legacyAgentFolders = (await fs.readdir(legacyAgentsDir)).filter((x) => x.startsWith(`${teamId}-`));
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
for (const folder of legacyAgentFolders) {
|
|
994
|
-
const agentId = folder;
|
|
995
|
-
const role = folder.slice((teamId + "-").length);
|
|
996
|
-
const from = path.join(legacyAgentsDir, folder);
|
|
997
|
-
const to = path.join(destRolesDir, role);
|
|
998
|
-
plan.agentIds.push(agentId);
|
|
999
|
-
plan.steps.push({ kind: "roleDir", agentId, role, from, to });
|
|
1000
|
-
}
|
|
1001
|
-
|
|
203
|
+
.action(async (options: { teamId?: string; mode?: string; dryRun?: boolean; overwrite?: boolean }) => {
|
|
204
|
+
if (!options.teamId) throw new Error("--team-id is required");
|
|
205
|
+
const plan = await handleMigrateTeamPlan(api, { teamId: options.teamId, mode: options.mode, overwrite: options.overwrite });
|
|
1002
206
|
const dryRun = !!options.dryRun;
|
|
1003
207
|
if (dryRun) {
|
|
1004
208
|
console.log(JSON.stringify({ ok: true, dryRun: true, plan }, null, 2));
|
|
1005
209
|
return;
|
|
1006
210
|
}
|
|
211
|
+
const result = await executeMigrateTeamPlan(api, plan);
|
|
212
|
+
console.log(JSON.stringify(result, null, 2));
|
|
213
|
+
});
|
|
1007
214
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
215
|
+
cmd
|
|
216
|
+
.command("cleanup-workspaces")
|
|
217
|
+
.description(
|
|
218
|
+
"List (dry-run, default) or delete (with --yes) temporary test/scaffold team workspaces under your OpenClaw home directory"
|
|
219
|
+
)
|
|
220
|
+
.option("--yes", "Actually delete eligible workspaces")
|
|
221
|
+
.option("--prefix <prefix>", "Allowed team id prefix (repeatable)", (v: string, acc: string[]) => [...(acc ?? []), v], [])
|
|
222
|
+
.option("--json", "Output JSON")
|
|
223
|
+
.action(async (options: { yes?: boolean; prefix?: string[]; json?: boolean }) => {
|
|
224
|
+
const workspaceRoot = resolveWorkspaceRoot(api);
|
|
225
|
+
const rootDir = path.resolve(workspaceRoot, "..");
|
|
226
|
+
const prefixes = Array.isArray(options.prefix) && options.prefix.length
|
|
227
|
+
? options.prefix
|
|
228
|
+
: undefined;
|
|
229
|
+
const plan = await planWorkspaceCleanup({ rootDir, prefixes });
|
|
230
|
+
const yes = !!options.yes;
|
|
231
|
+
const result = await executeWorkspaceCleanup(plan, { yes });
|
|
232
|
+
if (options.json) {
|
|
233
|
+
console.log(JSON.stringify(result, null, 2));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (result.dryRun) {
|
|
237
|
+
const candidates = result.candidates;
|
|
238
|
+
const skipped = result.skipped;
|
|
239
|
+
if (candidates.length === 0 && skipped.length === 0) {
|
|
240
|
+
console.log("No workspace-* directories found matching cleanup criteria.");
|
|
241
|
+
return;
|
|
1023
242
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
const moveDir = async (src: string, dst: string) => {
|
|
1032
|
-
await ensureDir(path.dirname(dst));
|
|
1033
|
-
try {
|
|
1034
|
-
await fs.rename(src, dst);
|
|
1035
|
-
} catch {
|
|
1036
|
-
// cross-device or existing: fallback to copy+remove
|
|
1037
|
-
await copyDirRecursive(src, dst);
|
|
1038
|
-
await removeDirRecursive(src);
|
|
243
|
+
if (candidates.length) {
|
|
244
|
+
console.log(`Would delete (${candidates.length}):`);
|
|
245
|
+
for (const c of candidates) console.log(` - ${c.dirName}`);
|
|
246
|
+
}
|
|
247
|
+
if (skipped.length) {
|
|
248
|
+
console.log(`Skipped (${skipped.length}):`);
|
|
249
|
+
for (const s of skipped) console.log(` - ${s.dirName}: ${s.reason}`);
|
|
1039
250
|
}
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1042
|
-
// Execute plan
|
|
1043
|
-
if (mode === "copy") {
|
|
1044
|
-
await copyDirRecursive(legacyTeamDir, destTeamDir);
|
|
1045
251
|
} else {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
else await moveDir(step.from, step.to);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// Update config: set each team agent's workspace to destTeamDir (shared)
|
|
1059
|
-
const agentSnippets: AgentConfigSnippet[] = plan.agentIds.map((agentId: string) => ({
|
|
1060
|
-
id: agentId,
|
|
1061
|
-
workspace: destTeamDir,
|
|
1062
|
-
}));
|
|
1063
|
-
if (agentSnippets.length) {
|
|
1064
|
-
await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
|
|
252
|
+
if (result.deleted?.length) {
|
|
253
|
+
console.log(`Deleted: ${result.deleted.join(", ")}`);
|
|
254
|
+
}
|
|
255
|
+
if (result.deleteErrors?.length) {
|
|
256
|
+
for (const e of result.deleteErrors) {
|
|
257
|
+
console.error(`Error deleting ${e.path}: ${e.error}`);
|
|
258
|
+
}
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
}
|
|
1065
261
|
}
|
|
1066
|
-
|
|
1067
|
-
console.log(JSON.stringify({ ok: true, migrated: teamId, destTeamDir, agentIds: plan.agentIds }, null, 2));
|
|
1068
262
|
});
|
|
1069
263
|
|
|
1070
264
|
cmd
|
|
@@ -1077,157 +271,49 @@ const recipesPlugin = {
|
|
|
1077
271
|
.option("--global", "Install into global shared skills (~/.openclaw/skills) (default when no scope flags)")
|
|
1078
272
|
.option("--agent-id <agentId>", "Install into a specific agent workspace (workspace-<agentId>)")
|
|
1079
273
|
.option("--team-id <teamId>", "Install into a team workspace (workspace-<teamId>)")
|
|
1080
|
-
.action(async (idOrSlug: string, options:
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const loaded = await loadRecipeById(api, idOrSlug);
|
|
1089
|
-
recipe = loaded.frontmatter;
|
|
1090
|
-
} catch {
|
|
1091
|
-
recipe = null;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
1095
|
-
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
1096
|
-
|
|
1097
|
-
const stateDir = path.resolve(baseWorkspace, ".."); // ~/.openclaw
|
|
1098
|
-
|
|
1099
|
-
const scopeFlags = [options.global ? "global" : null, options.agentId ? "agent" : null, options.teamId ? "team" : null].filter(Boolean);
|
|
1100
|
-
if (scopeFlags.length > 1) {
|
|
1101
|
-
throw new Error("Use only one of: --global, --agent-id, --team-id");
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const agentIdOpt = typeof options.agentId === "string" ? options.agentId.trim() : "";
|
|
1105
|
-
const teamIdOpt = typeof options.teamId === "string" ? options.teamId.trim() : "";
|
|
1106
|
-
|
|
1107
|
-
// Default is global install when no scope is provided.
|
|
1108
|
-
const scope = scopeFlags[0] ?? "global";
|
|
1109
|
-
|
|
1110
|
-
let workdir: string;
|
|
1111
|
-
let dirName: string;
|
|
1112
|
-
let installDir: string;
|
|
1113
|
-
|
|
1114
|
-
if (scope === "agent") {
|
|
1115
|
-
if (!agentIdOpt) throw new Error("--agent-id cannot be empty");
|
|
1116
|
-
const agentWorkspace = path.resolve(stateDir, `workspace-${agentIdOpt}`);
|
|
1117
|
-
workdir = agentWorkspace;
|
|
1118
|
-
dirName = cfg.workspaceSkillsDir;
|
|
1119
|
-
installDir = path.join(agentWorkspace, dirName);
|
|
1120
|
-
} else if (scope === "team") {
|
|
1121
|
-
if (!teamIdOpt) throw new Error("--team-id cannot be empty");
|
|
1122
|
-
const teamWorkspace = path.resolve(stateDir, `workspace-${teamIdOpt}`);
|
|
1123
|
-
workdir = teamWorkspace;
|
|
1124
|
-
dirName = cfg.workspaceSkillsDir;
|
|
1125
|
-
installDir = path.join(teamWorkspace, dirName);
|
|
1126
|
-
} else {
|
|
1127
|
-
workdir = stateDir;
|
|
1128
|
-
dirName = "skills";
|
|
1129
|
-
installDir = path.join(stateDir, dirName);
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
await ensureDir(installDir);
|
|
1133
|
-
|
|
1134
|
-
const skillsToInstall = recipe
|
|
1135
|
-
? Array.from(new Set([...(recipe.requiredSkills ?? []), ...(recipe.optionalSkills ?? [])])).filter(Boolean)
|
|
1136
|
-
: [idOrSlug];
|
|
274
|
+
.action(async (idOrSlug: string, options: { yes?: boolean; global?: boolean; agentId?: string; teamId?: string }) => {
|
|
275
|
+
const res = await handleInstallSkill(api, {
|
|
276
|
+
idOrSlug,
|
|
277
|
+
yes: options.yes,
|
|
278
|
+
global: options.global,
|
|
279
|
+
agentId: options.agentId,
|
|
280
|
+
teamId: options.teamId,
|
|
281
|
+
});
|
|
1137
282
|
|
|
1138
|
-
if (
|
|
1139
|
-
console.log(JSON.stringify(
|
|
283
|
+
if (res.ok) {
|
|
284
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1140
285
|
return;
|
|
1141
286
|
}
|
|
1142
287
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
console.error(`Already present in skills dir (${installDir}): ${already.join(", ")}`);
|
|
288
|
+
if (res.aborted === "non-interactive") {
|
|
289
|
+
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
290
|
+
process.exitCode = 2;
|
|
291
|
+
return;
|
|
1148
292
|
}
|
|
1149
293
|
|
|
1150
|
-
if (
|
|
1151
|
-
console.
|
|
294
|
+
if (res.aborted === "user-declined") {
|
|
295
|
+
console.error("Aborted; nothing installed.");
|
|
1152
296
|
return;
|
|
1153
297
|
}
|
|
1154
298
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
const requireConfirm = !options.yes;
|
|
1161
|
-
if (requireConfirm) {
|
|
1162
|
-
if (!process.stdin.isTTY) {
|
|
1163
|
-
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
1164
|
-
process.exitCode = 2;
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
const readline = await import("node:readline/promises");
|
|
1169
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1170
|
-
try {
|
|
1171
|
-
const ans = await rl.question(`${header}\nProceed? (y/N) `);
|
|
1172
|
-
const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
1173
|
-
if (!ok) {
|
|
1174
|
-
console.error("Aborted; nothing installed.");
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
} finally {
|
|
1178
|
-
rl.close();
|
|
299
|
+
if (res.needCli) {
|
|
300
|
+
console.error("\nSkill install requires the ClawHub CLI. Run the following then re-run this command:\n");
|
|
301
|
+
for (const cmd of res.installCommands) {
|
|
302
|
+
console.error(" " + cmd);
|
|
1179
303
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// Avoid spawning subprocesses from plugins (triggers OpenClaw dangerous-pattern warnings).
|
|
1185
|
-
// For now, print the exact commands the user should run.
|
|
1186
|
-
console.error("\nSkill install requires the ClawHub CLI. Run the following then re-run this command:\n");
|
|
1187
|
-
for (const slug of missing) {
|
|
1188
|
-
console.error(
|
|
1189
|
-
` npx clawhub@latest --workdir ${JSON.stringify(workdir)} --dir ${JSON.stringify(dirName)} install ${JSON.stringify(slug)}`,
|
|
1190
|
-
);
|
|
304
|
+
process.exitCode = 2;
|
|
305
|
+
return;
|
|
1191
306
|
}
|
|
1192
|
-
process.exitCode = 2;
|
|
1193
|
-
return;
|
|
1194
|
-
|
|
1195
|
-
console.log(
|
|
1196
|
-
JSON.stringify(
|
|
1197
|
-
{
|
|
1198
|
-
ok: true,
|
|
1199
|
-
installed: missing,
|
|
1200
|
-
installDir,
|
|
1201
|
-
next: `Try: openclaw skills list (or check ${installDir})`,
|
|
1202
|
-
},
|
|
1203
|
-
null,
|
|
1204
|
-
2,
|
|
1205
|
-
),
|
|
1206
|
-
);
|
|
1207
307
|
});
|
|
1208
308
|
|
|
1209
|
-
async
|
|
1210
|
-
const
|
|
1211
|
-
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
1212
|
-
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
1213
|
-
|
|
1214
|
-
// Avoid network calls living in this file (it also reads files), since `openclaw security audit`
|
|
1215
|
-
// heuristics can flag "file read + network send".
|
|
1216
|
-
const { fetchMarketplaceRecipeMarkdown } = await import("./src/marketplaceFetch");
|
|
1217
|
-
const { md, metaUrl, sourceUrl } = await fetchMarketplaceRecipeMarkdown({
|
|
1218
|
-
registryBase: options.registryBase,
|
|
309
|
+
const runInstallRecipe = async (slug: string, opts: { registryBase?: string; overwrite?: boolean }) => {
|
|
310
|
+
const res = await handleInstallMarketplaceRecipe(api, {
|
|
1219
311
|
slug,
|
|
312
|
+
registryBase: opts.registryBase,
|
|
313
|
+
overwrite: opts.overwrite,
|
|
1220
314
|
});
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const recipesDir = path.join(baseWorkspace, cfg.workspaceRecipesDir);
|
|
1224
|
-
await ensureDir(recipesDir);
|
|
1225
|
-
const destPath = path.join(recipesDir, `${s}.md`);
|
|
1226
|
-
|
|
1227
|
-
await writeFileSafely(destPath, md, options.overwrite ? "overwrite" : "createOnly");
|
|
1228
|
-
|
|
1229
|
-
console.log(JSON.stringify({ ok: true, slug: s, wrote: destPath, sourceUrl, metaUrl }, null, 2));
|
|
1230
|
-
}
|
|
315
|
+
console.log(JSON.stringify(res, null, 2));
|
|
316
|
+
};
|
|
1231
317
|
|
|
1232
318
|
cmd
|
|
1233
319
|
.command("install")
|
|
@@ -1235,7 +321,9 @@ const recipesPlugin = {
|
|
|
1235
321
|
.argument("<idOrSlug>", "Marketplace recipe slug (e.g. development-team)")
|
|
1236
322
|
.option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
|
|
1237
323
|
.option("--overwrite", "Overwrite existing recipe file")
|
|
1238
|
-
.action(
|
|
324
|
+
.action((slug: string, options: { registryBase?: string; overwrite?: boolean }) =>
|
|
325
|
+
runInstallRecipe(slug, options)
|
|
326
|
+
);
|
|
1239
327
|
|
|
1240
328
|
cmd
|
|
1241
329
|
.command("install-recipe")
|
|
@@ -1243,7 +331,9 @@ const recipesPlugin = {
|
|
|
1243
331
|
.argument("<slug>", "Marketplace recipe slug (e.g. development-team)")
|
|
1244
332
|
.option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
|
|
1245
333
|
.option("--overwrite", "Overwrite existing recipe file")
|
|
1246
|
-
.action(
|
|
334
|
+
.action((slug: string, options: { registryBase?: string; overwrite?: boolean }) =>
|
|
335
|
+
runInstallRecipe(slug, options)
|
|
336
|
+
);
|
|
1247
337
|
|
|
1248
338
|
cmd
|
|
1249
339
|
.command("dispatch")
|
|
@@ -1252,57 +342,8 @@ const recipesPlugin = {
|
|
|
1252
342
|
.option("--request <text>", "Natural-language request (if omitted, will prompt in TTY)")
|
|
1253
343
|
.option("--owner <owner>", "Ticket owner: dev|devops|lead|test", "dev")
|
|
1254
344
|
.option("--yes", "Skip review and write files without prompting")
|
|
1255
|
-
.action(async (options:
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1259
|
-
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1260
|
-
|
|
1261
|
-
const teamId = String(options.teamId);
|
|
1262
|
-
// Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
|
|
1263
|
-
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1264
|
-
|
|
1265
|
-
const inboxDir = path.join(teamDir, "inbox");
|
|
1266
|
-
const backlogDir = path.join(teamDir, "work", "backlog");
|
|
1267
|
-
const assignmentsDir = path.join(teamDir, "work", "assignments");
|
|
1268
|
-
|
|
1269
|
-
const owner = String(options.owner ?? "dev");
|
|
1270
|
-
if (!['dev','devops','lead','test'].includes(owner)) {
|
|
1271
|
-
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const slugify = (s: string) =>
|
|
1275
|
-
s
|
|
1276
|
-
.toLowerCase()
|
|
1277
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1278
|
-
.replace(/(^-|-$)/g, "")
|
|
1279
|
-
.slice(0, 60) || "request";
|
|
1280
|
-
|
|
1281
|
-
const nowKey = () => {
|
|
1282
|
-
const d = new Date();
|
|
1283
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1284
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
1285
|
-
};
|
|
1286
|
-
|
|
1287
|
-
const nextTicketNumber = async () => {
|
|
1288
|
-
const dirs = [
|
|
1289
|
-
backlogDir,
|
|
1290
|
-
path.join(teamDir, "work", "in-progress"),
|
|
1291
|
-
path.join(teamDir, "work", "testing"),
|
|
1292
|
-
path.join(teamDir, "work", "done"),
|
|
1293
|
-
];
|
|
1294
|
-
let max = 0;
|
|
1295
|
-
for (const dir of dirs) {
|
|
1296
|
-
if (!(await fileExists(dir))) continue;
|
|
1297
|
-
const files = await fs.readdir(dir);
|
|
1298
|
-
for (const f of files) {
|
|
1299
|
-
const m = f.match(/^(\d{4})-/);
|
|
1300
|
-
if (m) max = Math.max(max, Number(m[1]));
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
return max + 1;
|
|
1304
|
-
};
|
|
1305
|
-
|
|
345
|
+
.action(async (options: { teamId?: string; request?: string; owner?: string; yes?: boolean }) => {
|
|
346
|
+
if (!options.teamId) throw new Error("--team-id is required");
|
|
1306
347
|
let requestText = typeof options.request === "string" ? options.request.trim() : "";
|
|
1307
348
|
if (!requestText) {
|
|
1308
349
|
if (!process.stdin.isTTY) {
|
|
@@ -1316,95 +357,39 @@ const recipesPlugin = {
|
|
|
1316
357
|
rl.close();
|
|
1317
358
|
}
|
|
1318
359
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
const inboxMd = `# Inbox — ${teamId}\n\nReceived: ${receivedIso}\n\n## Request\n${requestText}\n\n## Proposed work\n- Ticket: ${ticketNumStr}-${baseSlug}\n- Owner: ${owner}\n\n## Links\n- Ticket: ${path.relative(teamDir, ticketPath)}\n- Assignment: ${path.relative(teamDir, assignmentPath)}\n`;
|
|
1334
|
-
|
|
1335
|
-
const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nOwner: ${owner}\nStatus: queued\nInbox: ${path.relative(teamDir, inboxPath)}\nAssignment: ${path.relative(teamDir, assignmentPath)}\n\n## Context\n${requestText}\n\n## Requirements\n- (fill in)\n\n## Acceptance criteria\n- (fill in)\n\n## Tasks\n- [ ] (fill in)\n\n## Comments\n- (use this section for @mentions, questions, decisions, and dated replies)\n`;
|
|
1336
|
-
|
|
1337
|
-
const assignmentMd = `# Assignment — ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\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`;
|
|
1338
|
-
|
|
1339
|
-
const plan = {
|
|
1340
|
-
teamId,
|
|
1341
|
-
request: requestText,
|
|
1342
|
-
files: [
|
|
1343
|
-
{ path: inboxPath, kind: "inbox", summary: title },
|
|
1344
|
-
{ path: ticketPath, kind: "backlog-ticket", summary: title },
|
|
1345
|
-
{ path: assignmentPath, kind: "assignment", summary: owner },
|
|
1346
|
-
],
|
|
1347
|
-
};
|
|
1348
|
-
|
|
1349
|
-
const doWrite = async () => {
|
|
1350
|
-
await ensureDir(inboxDir);
|
|
1351
|
-
await ensureDir(backlogDir);
|
|
1352
|
-
await ensureDir(assignmentsDir);
|
|
1353
|
-
|
|
1354
|
-
// createOnly to avoid accidental overwrite
|
|
1355
|
-
await writeFileSafely(inboxPath, inboxMd, "createOnly");
|
|
1356
|
-
await writeFileSafely(ticketPath, ticketMd, "createOnly");
|
|
1357
|
-
await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
|
|
1358
|
-
|
|
1359
|
-
// Best-effort nudge: enqueue a system event for the team lead session.
|
|
1360
|
-
// This does not spawn the lead; it ensures that when the lead session runs next,
|
|
1361
|
-
// it sees the dispatch immediately.
|
|
1362
|
-
try {
|
|
1363
|
-
const leadAgentId = `${teamId}-lead`;
|
|
1364
|
-
api.runtime.system.enqueueSystemEvent(
|
|
1365
|
-
[
|
|
1366
|
-
`Dispatch created new intake for team: ${teamId}`,
|
|
1367
|
-
`- Inbox: ${path.relative(teamDir, inboxPath)}`,
|
|
1368
|
-
`- Backlog: ${path.relative(teamDir, ticketPath)}`,
|
|
1369
|
-
`- Assignment: ${path.relative(teamDir, assignmentPath)}`,
|
|
1370
|
-
`Action: please triage/normalize the ticket (fill Requirements/AC/tasks) and move it through the workflow.`,
|
|
1371
|
-
].join("\n"),
|
|
1372
|
-
{ sessionKey: `agent:${leadAgentId}:main` },
|
|
1373
|
-
);
|
|
1374
|
-
} catch {
|
|
1375
|
-
// ignore: dispatch should still succeed even if system event enqueue fails
|
|
1376
|
-
}
|
|
1377
|
-
};
|
|
1378
|
-
|
|
1379
|
-
if (options.yes) {
|
|
1380
|
-
await doWrite();
|
|
1381
|
-
console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
if (!process.stdin.isTTY) {
|
|
1386
|
-
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
1387
|
-
process.exitCode = 2;
|
|
1388
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
1393
|
-
const readline = await import("node:readline/promises");
|
|
1394
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1395
|
-
try {
|
|
1396
|
-
const ans = await rl.question("Write these files? (y/N) ");
|
|
1397
|
-
const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
1398
|
-
if (!ok) {
|
|
360
|
+
const dry = await handleDispatch(api, {
|
|
361
|
+
teamId: options.teamId,
|
|
362
|
+
requestText,
|
|
363
|
+
owner: options.owner,
|
|
364
|
+
dryRun: true,
|
|
365
|
+
});
|
|
366
|
+
const plan = (dry as { plan: unknown }).plan;
|
|
367
|
+
const ok = await promptConfirmWithPlan(plan, "Write these files? (y/N)", { yes: options.yes });
|
|
368
|
+
if (!ok) {
|
|
369
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
370
|
+
process.exitCode = 2;
|
|
371
|
+
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
372
|
+
} else {
|
|
1399
373
|
console.error("Aborted; no files written.");
|
|
1400
|
-
return;
|
|
1401
374
|
}
|
|
1402
|
-
|
|
1403
|
-
rl.close();
|
|
375
|
+
return;
|
|
1404
376
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
377
|
+
const res = await handleDispatch(api, {
|
|
378
|
+
teamId: options.teamId,
|
|
379
|
+
requestText,
|
|
380
|
+
owner: options.owner,
|
|
381
|
+
});
|
|
382
|
+
if (res.nudgeQueued) {
|
|
383
|
+
console.error(`[dispatch] Nudge queued: system event → agent:${options.teamId}-lead:main`);
|
|
384
|
+
} else {
|
|
385
|
+
console.error(`[dispatch] NOTE: Could not auto-nudge ${options.teamId}-lead (best-effort). Next steps:`);
|
|
386
|
+
console.error(`- Option A (recommended): ensure the lead triage cron job is installed/enabled (lead-triage-loop).`);
|
|
387
|
+
console.error(` - If you declined cron installation during scaffold, re-run scaffold with cron installation enabled, or enable it in settings.`);
|
|
388
|
+
console.error(`- Option B: manually run/open the lead once so it sees inbox/backlog updates.`);
|
|
389
|
+
console.error(`- Option C (advanced): allow subagent messaging (if you want direct pings). Add allowAgents in config and restart gateway.`);
|
|
390
|
+
console.error(` { agents: { list: [ { id: "main", subagents: { allowAgents: ["${options.teamId}-lead"] } } ] } }`);
|
|
391
|
+
}
|
|
392
|
+
console.log(JSON.stringify({ ok: true, wrote: res.wrote }, null, 2));
|
|
1408
393
|
});
|
|
1409
394
|
|
|
1410
395
|
cmd
|
|
@@ -1415,70 +400,24 @@ const recipesPlugin = {
|
|
|
1415
400
|
.option("--json", "Output JSON")
|
|
1416
401
|
.option("--yes", "Skip confirmation (apply destructive changes)")
|
|
1417
402
|
.option("--include-ambiguous", "Also remove cron jobs that only loosely match the team (dangerous)")
|
|
1418
|
-
.action(async (options:
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
const current = (api.runtime as any).config?.loadConfig?.();
|
|
1427
|
-
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
1428
|
-
const cfgObj = (current.cfg ?? current) as any;
|
|
1429
|
-
|
|
1430
|
-
const cronStore = await loadCronStore(cronJobsPath);
|
|
1431
|
-
|
|
1432
|
-
const plan = await buildRemoveTeamPlan({
|
|
1433
|
-
teamId,
|
|
1434
|
-
workspaceRoot,
|
|
1435
|
-
openclawConfigPath: "(managed by api.runtime.config)",
|
|
1436
|
-
cronJobsPath,
|
|
1437
|
-
cfgObj,
|
|
1438
|
-
cronStore,
|
|
403
|
+
.action(async (options: { teamId?: string; plan?: boolean; yes?: boolean; includeAmbiguous?: boolean }) => {
|
|
404
|
+
if (!options.teamId) throw new Error("--team-id is required");
|
|
405
|
+
const out = await handleRemoveTeam(api, {
|
|
406
|
+
teamId: options.teamId,
|
|
407
|
+
plan: options.plan,
|
|
408
|
+
yes: options.yes,
|
|
409
|
+
includeAmbiguous: options.includeAmbiguous,
|
|
1439
410
|
});
|
|
1440
|
-
|
|
1441
|
-
const wantsJson = Boolean(options.json);
|
|
1442
|
-
|
|
1443
|
-
if (options.plan) {
|
|
1444
|
-
console.log(JSON.stringify({ ok: true, plan }, null, 2));
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
if (!options.yes && !process.stdin.isTTY) {
|
|
411
|
+
if (out.ok === false && out.aborted === "non-interactive") {
|
|
1449
412
|
console.error("Refusing to prompt (non-interactive). Re-run with --yes or --plan.");
|
|
1450
413
|
process.exitCode = 2;
|
|
1451
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1452
|
-
return;
|
|
1453
414
|
}
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
1457
|
-
const ok = await promptYesNo(
|
|
1458
|
-
`This will DELETE workspace-${teamId}, remove matching agents from openclaw config, and remove stamped cron jobs.`,
|
|
1459
|
-
);
|
|
1460
|
-
if (!ok) {
|
|
1461
|
-
console.error("Aborted; no changes made.");
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
415
|
+
if (out.ok === false && out.aborted === "user-declined") {
|
|
416
|
+
console.error("Aborted; no changes made.");
|
|
1464
417
|
}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const result = await executeRemoveTeamPlan({
|
|
1469
|
-
plan,
|
|
1470
|
-
includeAmbiguous,
|
|
1471
|
-
cfgObj,
|
|
1472
|
-
cronStore,
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
1476
|
-
await saveCronStore(cronJobsPath, cronStore);
|
|
1477
|
-
|
|
1478
|
-
if (wantsJson) {
|
|
1479
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1480
|
-
} else {
|
|
1481
|
-
console.log(JSON.stringify(result, null, 2));
|
|
418
|
+
const payload = "result" in out ? out.result : out;
|
|
419
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
420
|
+
if (out.ok && "result" in out) {
|
|
1482
421
|
console.error("Restart required: openclaw gateway restart");
|
|
1483
422
|
}
|
|
1484
423
|
});
|
|
@@ -1488,151 +427,24 @@ const recipesPlugin = {
|
|
|
1488
427
|
.description("List tickets for a team (backlog / in-progress / testing / done)")
|
|
1489
428
|
.requiredOption("--team-id <teamId>", "Team id")
|
|
1490
429
|
.option("--json", "Output JSON")
|
|
1491
|
-
.action(async (options:
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
const teamId = String(options.teamId);
|
|
1495
|
-
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1496
|
-
|
|
1497
|
-
await ensureTicketStageDirs(teamDir);
|
|
1498
|
-
|
|
1499
|
-
const dirs = {
|
|
1500
|
-
backlog: path.join(teamDir, "work", "backlog"),
|
|
1501
|
-
inProgress: path.join(teamDir, "work", "in-progress"),
|
|
1502
|
-
testing: path.join(teamDir, "work", "testing"),
|
|
1503
|
-
done: path.join(teamDir, "work", "done"),
|
|
1504
|
-
} as const;
|
|
1505
|
-
|
|
1506
|
-
const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
|
|
1507
|
-
if (!(await fileExists(dir))) return [] as any[];
|
|
1508
|
-
const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
1509
|
-
return files.map((f) => {
|
|
1510
|
-
const m = f.match(/^(\d{4})-(.+)\.md$/);
|
|
1511
|
-
return {
|
|
1512
|
-
stage,
|
|
1513
|
-
number: m ? Number(m[1]) : null,
|
|
1514
|
-
id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
|
|
1515
|
-
file: path.join(dir, f),
|
|
1516
|
-
};
|
|
1517
|
-
});
|
|
1518
|
-
};
|
|
1519
|
-
|
|
1520
|
-
const backlog = await readTickets(dirs.backlog, "backlog");
|
|
1521
|
-
const inProgress = await readTickets(dirs.inProgress, "in-progress");
|
|
1522
|
-
const testing = await readTickets(dirs.testing, "testing");
|
|
1523
|
-
const done = await readTickets(dirs.done, "done");
|
|
1524
|
-
|
|
1525
|
-
const out = {
|
|
1526
|
-
teamId,
|
|
1527
|
-
// Stable, machine-friendly list for consumers (watchers, dashboards)
|
|
1528
|
-
// Keep the per-lane arrays for backwards-compat.
|
|
1529
|
-
tickets: [...backlog, ...inProgress, ...testing, ...done],
|
|
1530
|
-
backlog,
|
|
1531
|
-
inProgress,
|
|
1532
|
-
testing,
|
|
1533
|
-
done,
|
|
1534
|
-
};
|
|
1535
|
-
|
|
430
|
+
.action(async (options: { teamId?: string; json?: boolean }) => {
|
|
431
|
+
if (!options.teamId) throw new Error("--team-id is required");
|
|
432
|
+
const out = await handleTickets(api, { teamId: options.teamId });
|
|
1536
433
|
if (options.json) {
|
|
1537
434
|
console.log(JSON.stringify(out, null, 2));
|
|
1538
435
|
return;
|
|
1539
436
|
}
|
|
1540
|
-
|
|
1541
|
-
const print = (label: string, items: any[]) => {
|
|
437
|
+
const print = (label: string, items: Array<{ id: string }>) => {
|
|
1542
438
|
console.log(`\n${label} (${items.length})`);
|
|
1543
439
|
for (const t of items) console.log(`- ${t.id}`);
|
|
1544
440
|
};
|
|
1545
|
-
console.log(`Team: ${teamId}`);
|
|
441
|
+
console.log(`Team: ${out.teamId}`);
|
|
1546
442
|
print("Backlog", out.backlog);
|
|
1547
443
|
print("In progress", out.inProgress);
|
|
1548
444
|
print("Testing", out.testing);
|
|
1549
445
|
print("Done", out.done);
|
|
1550
446
|
});
|
|
1551
447
|
|
|
1552
|
-
async function moveTicketCore(options: any) {
|
|
1553
|
-
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1554
|
-
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1555
|
-
const teamId = String(options.teamId);
|
|
1556
|
-
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1557
|
-
|
|
1558
|
-
await ensureTicketStageDirs(teamDir);
|
|
1559
|
-
|
|
1560
|
-
const dest = String(options.to);
|
|
1561
|
-
if (!["backlog", "in-progress", "testing", "done"].includes(dest)) {
|
|
1562
|
-
throw new Error("--to must be one of: backlog, in-progress, testing, done");
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
const ticketArgRaw = String(options.ticket);
|
|
1566
|
-
const ticketArg = ticketArgRaw.match(/^\d+$/) && ticketArgRaw.length < 4 ? ticketArgRaw.padStart(4, "0") : ticketArgRaw;
|
|
1567
|
-
const ticketNum = ticketArg.match(/^\d{4}$/)
|
|
1568
|
-
? ticketArg
|
|
1569
|
-
: ticketArg.match(/^(\d{4})-/)?.[1] ?? null;
|
|
1570
|
-
|
|
1571
|
-
const stageDir = (stage: string) => {
|
|
1572
|
-
if (stage === "backlog") return path.join(teamDir, "work", "backlog");
|
|
1573
|
-
if (stage === "in-progress") return path.join(teamDir, "work", "in-progress");
|
|
1574
|
-
if (stage === "testing") return path.join(teamDir, "work", "testing");
|
|
1575
|
-
if (stage === "done") return path.join(teamDir, "work", "done");
|
|
1576
|
-
throw new Error(`Unknown stage: ${stage}`);
|
|
1577
|
-
};
|
|
1578
|
-
|
|
1579
|
-
const searchDirs = [stageDir("backlog"), stageDir("in-progress"), stageDir("testing"), stageDir("done")];
|
|
1580
|
-
|
|
1581
|
-
const findTicketFile = async () => {
|
|
1582
|
-
for (const dir of searchDirs) {
|
|
1583
|
-
if (!(await fileExists(dir))) continue;
|
|
1584
|
-
const files = await fs.readdir(dir);
|
|
1585
|
-
for (const f of files) {
|
|
1586
|
-
if (!f.endsWith(".md")) continue;
|
|
1587
|
-
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1588
|
-
if (!ticketNum && f.replace(/\.md$/, "") === ticketArg) return path.join(dir, f);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
return null;
|
|
1592
|
-
};
|
|
1593
|
-
|
|
1594
|
-
const srcPath = await findTicketFile();
|
|
1595
|
-
if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1596
|
-
|
|
1597
|
-
const destDir = stageDir(dest);
|
|
1598
|
-
await ensureDir(destDir);
|
|
1599
|
-
const filename = path.basename(srcPath);
|
|
1600
|
-
const destPath = path.join(destDir, filename);
|
|
1601
|
-
|
|
1602
|
-
const patchStatus = (md: string) => {
|
|
1603
|
-
const nextStatus =
|
|
1604
|
-
dest === "backlog"
|
|
1605
|
-
? "queued"
|
|
1606
|
-
: dest === "in-progress"
|
|
1607
|
-
? "in-progress"
|
|
1608
|
-
: dest === "testing"
|
|
1609
|
-
? "testing"
|
|
1610
|
-
: "done";
|
|
1611
|
-
|
|
1612
|
-
let out = md;
|
|
1613
|
-
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
|
|
1614
|
-
else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
|
|
1615
|
-
|
|
1616
|
-
if (dest === "done" && options.completed) {
|
|
1617
|
-
const completed = new Date().toISOString();
|
|
1618
|
-
if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
|
|
1619
|
-
else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
return out;
|
|
1623
|
-
};
|
|
1624
|
-
|
|
1625
|
-
const md = await fs.readFile(srcPath, "utf8");
|
|
1626
|
-
const patched = patchStatus(md);
|
|
1627
|
-
await fs.writeFile(srcPath, patched, "utf8");
|
|
1628
|
-
|
|
1629
|
-
if (srcPath !== destPath) {
|
|
1630
|
-
await fs.rename(srcPath, destPath);
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
return { ok: true, from: srcPath, to: destPath };
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
448
|
cmd
|
|
1637
449
|
.command("move-ticket")
|
|
1638
450
|
.description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
|
|
@@ -1641,110 +453,36 @@ const recipesPlugin = {
|
|
|
1641
453
|
.requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
|
|
1642
454
|
.option("--completed", "When moving to done, add Completed: timestamp")
|
|
1643
455
|
.option("--yes", "Skip confirmation")
|
|
1644
|
-
.action(async (options:
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
|
|
1662
|
-
if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
|
|
1663
|
-
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1664
|
-
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1665
|
-
throw new Error(`Unknown stage: ${stage}`);
|
|
1666
|
-
};
|
|
1667
|
-
|
|
1668
|
-
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1669
|
-
|
|
1670
|
-
const findTicketFile = async () => {
|
|
1671
|
-
for (const dir of searchDirs) {
|
|
1672
|
-
if (!(await fileExists(dir))) continue;
|
|
1673
|
-
const files = await fs.readdir(dir);
|
|
1674
|
-
for (const f of files) {
|
|
1675
|
-
if (!f.endsWith('.md')) continue;
|
|
1676
|
-
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1677
|
-
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
return null;
|
|
1681
|
-
};
|
|
1682
|
-
|
|
1683
|
-
const srcPath = await findTicketFile();
|
|
1684
|
-
if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1685
|
-
|
|
1686
|
-
const destDir = stageDir(dest);
|
|
1687
|
-
await ensureDir(destDir);
|
|
1688
|
-
const filename = path.basename(srcPath);
|
|
1689
|
-
const destPath = path.join(destDir, filename);
|
|
1690
|
-
|
|
1691
|
-
const patchStatus = (md: string) => {
|
|
1692
|
-
const nextStatus =
|
|
1693
|
-
dest === 'backlog'
|
|
1694
|
-
? 'queued'
|
|
1695
|
-
: dest === 'in-progress'
|
|
1696
|
-
? 'in-progress'
|
|
1697
|
-
: dest === 'testing'
|
|
1698
|
-
? 'testing'
|
|
1699
|
-
: 'done';
|
|
1700
|
-
|
|
1701
|
-
let out = md;
|
|
1702
|
-
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
|
|
1703
|
-
else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
|
|
1704
|
-
|
|
1705
|
-
if (dest === 'done' && options.completed) {
|
|
1706
|
-
const completed = new Date().toISOString();
|
|
1707
|
-
if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
|
|
1708
|
-
else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
return out;
|
|
1712
|
-
};
|
|
1713
|
-
|
|
1714
|
-
const plan = { from: srcPath, to: destPath };
|
|
1715
|
-
|
|
1716
|
-
if (!options.yes && process.stdin.isTTY) {
|
|
1717
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
1718
|
-
const readline = await import('node:readline/promises');
|
|
1719
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1720
|
-
try {
|
|
1721
|
-
const ans = await rl.question(`Move ticket to ${dest}? (y/N) `);
|
|
1722
|
-
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1723
|
-
if (!ok) {
|
|
1724
|
-
console.error('Aborted; no changes made.');
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
} finally {
|
|
1728
|
-
rl.close();
|
|
456
|
+
.action(async (options: { teamId?: string; ticket?: string; to?: string; completed?: boolean; yes?: boolean }) => {
|
|
457
|
+
if (!options.teamId || !options.ticket || !options.to) throw new Error("--team-id, --ticket, and --to are required");
|
|
458
|
+
const dry = await handleMoveTicket(api, {
|
|
459
|
+
teamId: options.teamId,
|
|
460
|
+
ticket: options.ticket,
|
|
461
|
+
to: options.to,
|
|
462
|
+
completed: options.completed,
|
|
463
|
+
dryRun: true,
|
|
464
|
+
});
|
|
465
|
+
const plan = (dry as { plan: { from: string; to: string } }).plan;
|
|
466
|
+
const ok = await promptConfirmWithPlan(plan, `Move ticket to ${options.to}? (y/N)`, { yes: options.yes });
|
|
467
|
+
if (!ok) {
|
|
468
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
469
|
+
process.exitCode = 2;
|
|
470
|
+
console.error("Refusing to move without confirmation in non-interactive mode. Re-run with --yes.");
|
|
471
|
+
} else {
|
|
472
|
+
console.error("Aborted; no changes made.");
|
|
1729
473
|
}
|
|
1730
|
-
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1731
|
-
console.error('Refusing to move without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1732
|
-
process.exitCode = 2;
|
|
1733
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1734
474
|
return;
|
|
1735
475
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
|
|
476
|
+
const res = await handleMoveTicket(api, {
|
|
477
|
+
teamId: options.teamId,
|
|
478
|
+
ticket: options.ticket,
|
|
479
|
+
to: options.to,
|
|
480
|
+
completed: options.completed,
|
|
481
|
+
});
|
|
482
|
+
console.log(JSON.stringify({ ok: true, moved: { from: res.from, to: res.to } }, null, 2));
|
|
1746
483
|
});
|
|
1747
484
|
|
|
485
|
+
|
|
1748
486
|
cmd
|
|
1749
487
|
.command("assign")
|
|
1750
488
|
.description("Assign a ticket to an owner (writes assignment stub + updates Owner: in ticket)")
|
|
@@ -1753,92 +491,32 @@ const recipesPlugin = {
|
|
|
1753
491
|
.requiredOption("--owner <owner>", "Owner: dev|devops|lead|test")
|
|
1754
492
|
.option("--overwrite", "Overwrite existing assignment file")
|
|
1755
493
|
.option("--yes", "Skip confirmation")
|
|
1756
|
-
.action(async (options:
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1773
|
-
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1774
|
-
throw new Error(`Unknown stage: ${stage}`);
|
|
1775
|
-
};
|
|
1776
|
-
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1777
|
-
|
|
1778
|
-
const ticketArg = String(options.ticket);
|
|
1779
|
-
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1780
|
-
|
|
1781
|
-
const findTicketFile = async () => {
|
|
1782
|
-
for (const dir of searchDirs) {
|
|
1783
|
-
if (!(await fileExists(dir))) continue;
|
|
1784
|
-
const files = await fs.readdir(dir);
|
|
1785
|
-
for (const f of files) {
|
|
1786
|
-
if (!f.endsWith('.md')) continue;
|
|
1787
|
-
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1788
|
-
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
return null;
|
|
1792
|
-
};
|
|
1793
|
-
|
|
1794
|
-
const ticketPath = await findTicketFile();
|
|
1795
|
-
if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1796
|
-
|
|
1797
|
-
const filename = path.basename(ticketPath);
|
|
1798
|
-
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
1799
|
-
const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
|
|
1800
|
-
const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
|
|
1801
|
-
|
|
1802
|
-
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
1803
|
-
await ensureDir(assignmentsDir);
|
|
1804
|
-
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
1805
|
-
|
|
1806
|
-
const patchOwner = (md: string) => {
|
|
1807
|
-
if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
|
|
1808
|
-
return md.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
const plan = { ticketPath, assignmentPath, owner };
|
|
1812
|
-
|
|
1813
|
-
if (!options.yes && process.stdin.isTTY) {
|
|
1814
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
1815
|
-
const readline = await import('node:readline/promises');
|
|
1816
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1817
|
-
try {
|
|
1818
|
-
const ans = await rl.question(`Assign ticket to ${owner}? (y/N) `);
|
|
1819
|
-
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1820
|
-
if (!ok) {
|
|
1821
|
-
console.error('Aborted; no changes made.');
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
} finally {
|
|
1825
|
-
rl.close();
|
|
494
|
+
.action(async (options: { teamId?: string; ticket?: string; owner?: string; overwrite?: boolean; yes?: boolean }) => {
|
|
495
|
+
if (!options.teamId || !options.ticket || !options.owner) throw new Error("--team-id, --ticket, and --owner are required");
|
|
496
|
+
const { plan } = await handleAssign(api, {
|
|
497
|
+
teamId: options.teamId,
|
|
498
|
+
ticket: options.ticket,
|
|
499
|
+
owner: options.owner,
|
|
500
|
+
overwrite: options.overwrite,
|
|
501
|
+
dryRun: true,
|
|
502
|
+
});
|
|
503
|
+
const ok = await promptConfirmWithPlan(plan, `Assign ticket to ${options.owner}? (y/N)`, { yes: options.yes });
|
|
504
|
+
if (!ok) {
|
|
505
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
506
|
+
process.exitCode = 2;
|
|
507
|
+
console.error("Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.");
|
|
508
|
+
} else {
|
|
509
|
+
console.error("Aborted; no changes made.");
|
|
1826
510
|
}
|
|
1827
|
-
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1828
|
-
console.error('Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1829
|
-
process.exitCode = 2;
|
|
1830
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1831
511
|
return;
|
|
1832
512
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
console.log(JSON.stringify({ ok: true, plan }, null, 2));
|
|
513
|
+
const res = await handleAssign(api, {
|
|
514
|
+
teamId: options.teamId,
|
|
515
|
+
ticket: options.ticket,
|
|
516
|
+
owner: options.owner,
|
|
517
|
+
overwrite: options.overwrite,
|
|
518
|
+
});
|
|
519
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1842
520
|
});
|
|
1843
521
|
|
|
1844
522
|
cmd
|
|
@@ -1848,106 +526,43 @@ const recipesPlugin = {
|
|
|
1848
526
|
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
1849
527
|
.option("--owner <owner>", "Owner: dev|devops|lead|test", "dev")
|
|
1850
528
|
.option("--yes", "Skip confirmation")
|
|
1851
|
-
.action(async (options:
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1869
|
-
throw new Error(`Unknown stage: ${stage}`);
|
|
1870
|
-
};
|
|
1871
|
-
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1872
|
-
|
|
1873
|
-
const ticketArg = String(options.ticket);
|
|
1874
|
-
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1875
|
-
|
|
1876
|
-
const findTicketFile = async () => {
|
|
1877
|
-
for (const dir of searchDirs) {
|
|
1878
|
-
if (!(await fileExists(dir))) continue;
|
|
1879
|
-
const files = await fs.readdir(dir);
|
|
1880
|
-
for (const f of files) {
|
|
1881
|
-
if (!f.endsWith('.md')) continue;
|
|
1882
|
-
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1883
|
-
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
return null;
|
|
1887
|
-
};
|
|
1888
|
-
|
|
1889
|
-
const srcPath = await findTicketFile();
|
|
1890
|
-
if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1891
|
-
|
|
1892
|
-
const destDir = stageDir('in-progress');
|
|
1893
|
-
await ensureDir(destDir);
|
|
1894
|
-
const filename = path.basename(srcPath);
|
|
1895
|
-
const destPath = path.join(destDir, filename);
|
|
1896
|
-
|
|
1897
|
-
const patch = (md: string) => {
|
|
1898
|
-
let out = md;
|
|
1899
|
-
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
|
|
1900
|
-
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
|
|
1901
|
-
|
|
1902
|
-
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
|
|
1903
|
-
else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
|
|
1904
|
-
|
|
1905
|
-
return out;
|
|
1906
|
-
};
|
|
1907
|
-
|
|
1908
|
-
const plan = { from: srcPath, to: destPath, owner };
|
|
1909
|
-
|
|
1910
|
-
if (!options.yes && process.stdin.isTTY) {
|
|
1911
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
1912
|
-
const readline = await import('node:readline/promises');
|
|
1913
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1914
|
-
try {
|
|
1915
|
-
const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
|
|
1916
|
-
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1917
|
-
if (!ok) {
|
|
1918
|
-
console.error('Aborted; no changes made.');
|
|
1919
|
-
return;
|
|
1920
|
-
}
|
|
1921
|
-
} finally {
|
|
1922
|
-
rl.close();
|
|
529
|
+
.action(async (options: { teamId?: string; ticket?: string; owner?: string; overwrite?: boolean; yes?: boolean }) => {
|
|
530
|
+
if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
|
|
531
|
+
const dry = await handleTake(api, {
|
|
532
|
+
teamId: options.teamId,
|
|
533
|
+
ticket: options.ticket,
|
|
534
|
+
owner: options.owner,
|
|
535
|
+
overwrite: options.overwrite,
|
|
536
|
+
dryRun: true,
|
|
537
|
+
});
|
|
538
|
+
const plan = (dry as { plan: { from: string; to: string; owner: string } }).plan;
|
|
539
|
+
const ok = await promptConfirmWithPlan(plan, `Assign to ${plan.owner} and move to in-progress? (y/N)`, { yes: options.yes });
|
|
540
|
+
if (!ok) {
|
|
541
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
542
|
+
process.exitCode = 2;
|
|
543
|
+
console.error("Refusing to take without confirmation in non-interactive mode. Re-run with --yes.");
|
|
544
|
+
} else {
|
|
545
|
+
console.error("Aborted; no changes made.");
|
|
1923
546
|
}
|
|
1924
|
-
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1925
|
-
console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1926
|
-
process.exitCode = 2;
|
|
1927
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1928
547
|
return;
|
|
1929
548
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
1945
|
-
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes take\n`;
|
|
1946
|
-
await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
|
|
1947
|
-
|
|
1948
|
-
console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
|
|
549
|
+
const res = await handleTake(api, {
|
|
550
|
+
teamId: options.teamId,
|
|
551
|
+
ticket: options.ticket,
|
|
552
|
+
owner: options.owner,
|
|
553
|
+
overwrite: options.overwrite,
|
|
554
|
+
});
|
|
555
|
+
if (!("srcPath" in res)) throw new Error("Unexpected take result");
|
|
556
|
+
console.log(
|
|
557
|
+
JSON.stringify(
|
|
558
|
+
{ ok: true, plan: { from: res.srcPath, to: res.destPath, owner: options.owner ?? "dev" }, assignmentPath: res.assignmentPath },
|
|
559
|
+
null,
|
|
560
|
+
2
|
|
561
|
+
)
|
|
562
|
+
);
|
|
1949
563
|
});
|
|
1950
564
|
|
|
565
|
+
|
|
1951
566
|
cmd
|
|
1952
567
|
.command("handoff")
|
|
1953
568
|
.description("QA handoff: move ticket to testing + assign to tester")
|
|
@@ -1956,282 +571,100 @@ const recipesPlugin = {
|
|
|
1956
571
|
.option("--tester <owner>", "Tester owner (default: test)", "test")
|
|
1957
572
|
.option("--overwrite", "Overwrite destination ticket file / assignment stub if they already exist")
|
|
1958
573
|
.option("--yes", "Skip confirmation")
|
|
1959
|
-
.action(async (options:
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
if (
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
const ticketArg = String(options.ticket);
|
|
1977
|
-
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1978
|
-
|
|
1979
|
-
const findTicketFile = async (dir: string) => {
|
|
1980
|
-
if (!(await fileExists(dir))) return null;
|
|
1981
|
-
const files = await fs.readdir(dir);
|
|
1982
|
-
for (const f of files) {
|
|
1983
|
-
if (!f.endsWith('.md')) continue;
|
|
1984
|
-
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1985
|
-
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1986
|
-
}
|
|
1987
|
-
return null;
|
|
1988
|
-
};
|
|
1989
|
-
|
|
1990
|
-
const inProgressDir = stageDir('in-progress');
|
|
1991
|
-
const testingDir = stageDir('testing');
|
|
1992
|
-
await ensureDir(testingDir);
|
|
1993
|
-
|
|
1994
|
-
const srcInProgress = await findTicketFile(inProgressDir);
|
|
1995
|
-
const srcTesting = await findTicketFile(testingDir);
|
|
1996
|
-
|
|
1997
|
-
if (!srcInProgress && !srcTesting) {
|
|
1998
|
-
throw new Error(`Ticket not found in in-progress/testing: ${ticketArg}`);
|
|
1999
|
-
}
|
|
2000
|
-
if (!srcInProgress && srcTesting) {
|
|
2001
|
-
// already in testing (idempotent path)
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
const srcPath = srcInProgress ?? srcTesting!;
|
|
2005
|
-
const filename = path.basename(srcPath);
|
|
2006
|
-
const destPath = path.join(testingDir, filename);
|
|
2007
|
-
|
|
2008
|
-
const patch = (md: string) => {
|
|
2009
|
-
let out = md;
|
|
2010
|
-
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${tester}`);
|
|
2011
|
-
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${tester}\n`);
|
|
2012
|
-
|
|
2013
|
-
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: testing`);
|
|
2014
|
-
else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
|
|
2015
|
-
|
|
2016
|
-
return out;
|
|
2017
|
-
};
|
|
2018
|
-
|
|
2019
|
-
const plan = {
|
|
2020
|
-
from: srcPath,
|
|
2021
|
-
to: destPath,
|
|
2022
|
-
tester,
|
|
2023
|
-
note: srcTesting ? 'already-in-testing' : 'move-to-testing',
|
|
2024
|
-
};
|
|
2025
|
-
|
|
2026
|
-
if (!options.yes && process.stdin.isTTY) {
|
|
2027
|
-
console.log(JSON.stringify({ plan }, null, 2));
|
|
2028
|
-
const readline = await import('node:readline/promises');
|
|
2029
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2030
|
-
try {
|
|
2031
|
-
const ans = await rl.question(`Move to testing + assign to ${tester}? (y/N) `);
|
|
2032
|
-
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
2033
|
-
if (!ok) {
|
|
2034
|
-
console.error('Aborted; no changes made.');
|
|
2035
|
-
return;
|
|
2036
|
-
}
|
|
2037
|
-
} finally {
|
|
2038
|
-
rl.close();
|
|
574
|
+
.action(async (options: { teamId?: string; ticket?: string; tester?: string; overwrite?: boolean; yes?: boolean }) => {
|
|
575
|
+
if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
|
|
576
|
+
const dry = await handleHandoff(api, {
|
|
577
|
+
teamId: options.teamId,
|
|
578
|
+
ticket: options.ticket,
|
|
579
|
+
tester: options.tester,
|
|
580
|
+
overwrite: options.overwrite,
|
|
581
|
+
dryRun: true,
|
|
582
|
+
});
|
|
583
|
+
const plan = (dry as { plan: { from: string; to: string; tester: string; note?: string } }).plan;
|
|
584
|
+
const ok = await promptConfirmWithPlan(plan, `Move to testing + assign to ${plan.tester}? (y/N)`, { yes: options.yes });
|
|
585
|
+
if (!ok) {
|
|
586
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
587
|
+
process.exitCode = 2;
|
|
588
|
+
console.error("Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.");
|
|
589
|
+
} else {
|
|
590
|
+
console.error("Aborted; no changes made.");
|
|
2039
591
|
}
|
|
2040
|
-
} else if (!options.yes && !process.stdin.isTTY) {
|
|
2041
|
-
console.error('Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.');
|
|
2042
|
-
process.exitCode = 2;
|
|
2043
|
-
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
2044
592
|
return;
|
|
2045
593
|
}
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
}
|
|
2061
|
-
await fs.rename(srcPath, destPath);
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
2065
|
-
const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
|
|
2066
|
-
const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
|
|
2067
|
-
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
2068
|
-
await ensureDir(assignmentsDir);
|
|
2069
|
-
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${tester}.md`);
|
|
2070
|
-
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${tester}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes handoff\n`;
|
|
2071
|
-
await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
|
|
2072
|
-
|
|
2073
|
-
console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
|
|
594
|
+
const res = await handleHandoff(api, {
|
|
595
|
+
teamId: options.teamId,
|
|
596
|
+
ticket: options.ticket,
|
|
597
|
+
tester: options.tester,
|
|
598
|
+
overwrite: options.overwrite,
|
|
599
|
+
});
|
|
600
|
+
if (!("srcPath" in res)) throw new Error("Unexpected handoff result");
|
|
601
|
+
console.log(
|
|
602
|
+
JSON.stringify(
|
|
603
|
+
{ ok: true, plan: { from: res.srcPath, to: res.destPath, tester: options.tester ?? "test" }, assignmentPath: res.assignmentPath },
|
|
604
|
+
null,
|
|
605
|
+
2
|
|
606
|
+
)
|
|
607
|
+
);
|
|
2074
608
|
});
|
|
2075
609
|
|
|
2076
610
|
cmd
|
|
2077
611
|
.command("complete")
|
|
2078
|
-
.description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp)")
|
|
612
|
+
.description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp). No confirmation prompt.")
|
|
2079
613
|
.requiredOption("--team-id <teamId>", "Team id")
|
|
2080
614
|
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
2081
|
-
.option("--yes", "
|
|
2082
|
-
.action(async (options:
|
|
2083
|
-
|
|
2084
|
-
'recipes',
|
|
2085
|
-
'move-ticket',
|
|
2086
|
-
'--team-id',
|
|
2087
|
-
String(options.teamId),
|
|
2088
|
-
'--ticket',
|
|
2089
|
-
String(options.ticket),
|
|
2090
|
-
'--to',
|
|
2091
|
-
'done',
|
|
2092
|
-
'--completed',
|
|
2093
|
-
];
|
|
2094
|
-
if (options.yes) args.push('--yes');
|
|
2095
|
-
|
|
615
|
+
.option("--yes", "No-op for backward compatibility (complete has no confirmation)")
|
|
616
|
+
.action(async (options: { teamId?: string; ticket?: string }) => {
|
|
617
|
+
if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
|
|
2096
618
|
try {
|
|
2097
|
-
await
|
|
619
|
+
const res = await handleMoveTicket(api, {
|
|
2098
620
|
teamId: options.teamId,
|
|
2099
621
|
ticket: options.ticket,
|
|
2100
622
|
to: "done",
|
|
2101
623
|
completed: true,
|
|
2102
|
-
yes: options.yes,
|
|
2103
624
|
});
|
|
625
|
+
console.log(JSON.stringify({ ok: true, moved: { from: res.from, to: res.to } }, null, 2));
|
|
2104
626
|
} catch (e) {
|
|
2105
627
|
process.exitCode = 1;
|
|
2106
628
|
throw e;
|
|
2107
629
|
}
|
|
2108
630
|
});
|
|
2109
631
|
|
|
632
|
+
const logScaffoldResult = (
|
|
633
|
+
res: { ok: boolean; missingSkills?: string[]; installCommands?: string[] },
|
|
634
|
+
recipeId: string
|
|
635
|
+
) => {
|
|
636
|
+
if (res.ok === false && res.missingSkills && res.installCommands) {
|
|
637
|
+
console.error(`Missing skills for recipe ${recipeId}: ${res.missingSkills.join(", ")}`);
|
|
638
|
+
console.error(`Install commands (workspace-local):\n${res.installCommands.join("\n")}`);
|
|
639
|
+
process.exitCode = 2;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
console.log(JSON.stringify(res, null, 2));
|
|
643
|
+
};
|
|
644
|
+
|
|
2110
645
|
cmd
|
|
2111
646
|
.command("scaffold")
|
|
2112
647
|
.description("Scaffold an agent from a recipe")
|
|
2113
648
|
.argument("<recipeId>", "Recipe id")
|
|
2114
649
|
.requiredOption("--agent-id <id>", "Agent id")
|
|
2115
650
|
.option("--name <name>", "Agent display name")
|
|
2116
|
-
.option("--recipe-id <recipeId>", "
|
|
651
|
+
.option("--recipe-id <recipeId>", "Custom workspace recipe id to write (default: <agentId>)")
|
|
2117
652
|
.option("--overwrite", "Overwrite existing recipe-managed files")
|
|
2118
653
|
.option("--overwrite-recipe", "Overwrite the generated workspace recipe file (workspace/recipes/<agentId>.md) if it already exists")
|
|
2119
654
|
.option("--auto-increment", "If the workspace recipe id is taken, pick the next available <agentId>-2/-3/...")
|
|
2120
655
|
.option("--apply-config", "Write the agent into openclaw config (agents.list)")
|
|
2121
|
-
.action(async (recipeId: string, options:
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
|
|
2132
|
-
const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
|
|
2133
|
-
if (missing.length) {
|
|
2134
|
-
console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
|
|
2135
|
-
console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
|
|
2136
|
-
process.exitCode = 2;
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
const agentId = String(options.agentId);
|
|
2141
|
-
const baseWorkspace = api.config.agents?.defaults?.workspace ?? "~/.openclaw/workspace";
|
|
2142
|
-
// Put standalone agent workspaces alongside the default workspace (same parent dir).
|
|
2143
|
-
const resolvedWorkspaceRoot = path.resolve(baseWorkspace, "..", `workspace-${agentId}`);
|
|
2144
|
-
|
|
2145
|
-
// Also create a workspace recipe file for this installed agent.
|
|
2146
|
-
// This establishes a stable, editable recipe id that matches the agent id (no custom- prefix).
|
|
2147
|
-
const recipesDir = path.join(workspaceRoot, "recipes");
|
|
2148
|
-
await ensureDir(recipesDir);
|
|
2149
|
-
|
|
2150
|
-
const overwriteRecipe = !!options.overwriteRecipe;
|
|
2151
|
-
const autoIncrement = !!options.autoIncrement;
|
|
2152
|
-
|
|
2153
|
-
function suggestedRecipeIds(baseId: string) {
|
|
2154
|
-
return [`${baseId}-2`, `${baseId}-3`, `${baseId}-4`];
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
async function recipeIdTaken(id: string) {
|
|
2158
|
-
const filePath = path.join(recipesDir, `${id}.md`);
|
|
2159
|
-
if (await fileExists(filePath)) return true;
|
|
2160
|
-
try {
|
|
2161
|
-
await loadRecipeById(api, id);
|
|
2162
|
-
return true;
|
|
2163
|
-
} catch {
|
|
2164
|
-
return false;
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
async function pickRecipeId(baseId: string) {
|
|
2169
|
-
if (!(await recipeIdTaken(baseId))) return baseId;
|
|
2170
|
-
if (overwriteRecipe) {
|
|
2171
|
-
const basePath = path.join(recipesDir, `${baseId}.md`);
|
|
2172
|
-
if (!(await fileExists(basePath))) {
|
|
2173
|
-
throw new Error(
|
|
2174
|
-
`Recipe id is already taken by a non-workspace recipe: ${baseId}. Choose a different id (e.g. ${baseId}-2) or pass --auto-increment.`,
|
|
2175
|
-
);
|
|
2176
|
-
}
|
|
2177
|
-
return baseId;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
if (autoIncrement) {
|
|
2181
|
-
let n = 2;
|
|
2182
|
-
while (n < 1000) {
|
|
2183
|
-
const candidate = `${baseId}-${n}`;
|
|
2184
|
-
if (!(await recipeIdTaken(candidate))) return candidate;
|
|
2185
|
-
n += 1;
|
|
2186
|
-
}
|
|
2187
|
-
throw new Error(`No available recipe id found for ${baseId} (tried up to -999)`);
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
const suggestions = suggestedRecipeIds(baseId);
|
|
2191
|
-
const msg = [
|
|
2192
|
-
`Recipe id already exists: ${baseId}`,
|
|
2193
|
-
`Refusing to overwrite workspace recipe: recipes/${baseId}.md`,
|
|
2194
|
-
`Pick a different recipe id and re-run with --recipe-id. Suggestions: ${suggestions.join(", ")}`,
|
|
2195
|
-
...suggestions.map((s) => ` openclaw recipes scaffold ${recipeId} --agent-id ${agentId} --recipe-id ${s}`),
|
|
2196
|
-
`Or re-run with --auto-increment to pick ${baseId}-2/-3/... automatically, or pass --overwrite-recipe to overwrite the existing workspace recipe file.`,
|
|
2197
|
-
].join("\n");
|
|
2198
|
-
throw new Error(msg);
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
const explicitRecipeId = typeof options.recipeId === "string" ? String(options.recipeId).trim() : "";
|
|
2202
|
-
const baseRecipeId = explicitRecipeId || agentId;
|
|
2203
|
-
const workspaceRecipeId = await pickRecipeId(baseRecipeId);
|
|
2204
|
-
|
|
2205
|
-
const recipeFilePath = path.join(recipesDir, `${workspaceRecipeId}.md`);
|
|
2206
|
-
const parsed = parseFrontmatter(loaded.md);
|
|
2207
|
-
const fm = { ...parsed.frontmatter, id: workspaceRecipeId, name: parsed.frontmatter.name ?? recipe.name ?? workspaceRecipeId };
|
|
2208
|
-
const nextMd = `---\n${YAML.stringify(fm)}---\n${parsed.body}`;
|
|
2209
|
-
await writeFileSafely(recipeFilePath, nextMd, overwriteRecipe ? "overwrite" : "createOnly");
|
|
2210
|
-
|
|
2211
|
-
const result = await scaffoldAgentFromRecipe(api, recipe, {
|
|
2212
|
-
agentId,
|
|
2213
|
-
agentName: options.name,
|
|
2214
|
-
update: !!options.overwrite,
|
|
2215
|
-
filesRootDir: resolvedWorkspaceRoot,
|
|
2216
|
-
workspaceRootDir: resolvedWorkspaceRoot,
|
|
2217
|
-
vars: {
|
|
2218
|
-
agentId,
|
|
2219
|
-
agentName: options.name ?? recipe.name ?? agentId,
|
|
2220
|
-
},
|
|
2221
|
-
});
|
|
2222
|
-
|
|
2223
|
-
if (options.applyConfig) {
|
|
2224
|
-
await applyAgentSnippetsToOpenClawConfig(api, [result.next.configSnippet]);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
const cron = await reconcileRecipeCronJobs({
|
|
2228
|
-
api,
|
|
2229
|
-
recipe,
|
|
2230
|
-
scope: { kind: "agent", agentId: String(options.agentId), recipeId: recipe.id, stateDir: resolvedWorkspaceRoot },
|
|
2231
|
-
cronInstallation: getCfg(api).cronInstallation,
|
|
656
|
+
.action(async (recipeId: string, options: { agentId: string; name?: string; recipeId?: string; overwrite?: boolean; overwriteRecipe?: boolean; autoIncrement?: boolean; applyConfig?: boolean }) => {
|
|
657
|
+
const res = await handleScaffold(api, {
|
|
658
|
+
recipeId,
|
|
659
|
+
agentId: options.agentId,
|
|
660
|
+
name: options.name,
|
|
661
|
+
recipeIdExplicit: options.recipeId,
|
|
662
|
+
overwrite: options.overwrite,
|
|
663
|
+
overwriteRecipe: options.overwriteRecipe,
|
|
664
|
+
autoIncrement: options.autoIncrement,
|
|
665
|
+
applyConfig: options.applyConfig,
|
|
2232
666
|
});
|
|
2233
|
-
|
|
2234
|
-
console.log(JSON.stringify({ ...result, cron }, null, 2));
|
|
667
|
+
logScaffoldResult(res, recipeId);
|
|
2235
668
|
});
|
|
2236
669
|
|
|
2237
670
|
cmd
|
|
@@ -2244,235 +677,17 @@ const recipesPlugin = {
|
|
|
2244
677
|
.option("--overwrite-recipe", "Overwrite the generated workspace recipe file (workspace/recipes/<teamId>.md) if it already exists")
|
|
2245
678
|
.option("--auto-increment", "If the workspace recipe id is taken, pick the next available <teamId>-2/-3/...")
|
|
2246
679
|
.option("--apply-config", "Write all team agents into openclaw config (agents.list)")
|
|
2247
|
-
.action(async (recipeId: string, options:
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
2257
|
-
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
2258
|
-
const installDir = path.join(baseWorkspace, cfg.workspaceSkillsDir);
|
|
2259
|
-
const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
|
|
2260
|
-
if (missing.length) {
|
|
2261
|
-
console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
|
|
2262
|
-
console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
|
|
2263
|
-
process.exitCode = 2;
|
|
2264
|
-
return;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
// Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
|
|
2268
|
-
const teamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
|
|
2269
|
-
await ensureDir(teamDir);
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
// Also create a workspace recipe file for this installed team.
|
|
2273
|
-
// This establishes a stable, editable recipe id that matches the team id (no custom- prefix).
|
|
2274
|
-
const recipesDir = path.join(baseWorkspace, "recipes");
|
|
2275
|
-
await ensureDir(recipesDir);
|
|
2276
|
-
|
|
2277
|
-
const overwriteRecipe = !!options.overwriteRecipe;
|
|
2278
|
-
const autoIncrement = !!options.autoIncrement;
|
|
2279
|
-
|
|
2280
|
-
function suggestedRecipeIds(baseId: string) {
|
|
2281
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
2282
|
-
return [`${baseId}-v2`, `${baseId}-${today}`, `${baseId}-alt`];
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
async function pickRecipeId(baseId: string) {
|
|
2286
|
-
const basePath = path.join(recipesDir, `${baseId}.md`);
|
|
2287
|
-
if (!(await fileExists(basePath))) return baseId;
|
|
2288
|
-
if (overwriteRecipe) return baseId;
|
|
2289
|
-
if (autoIncrement) {
|
|
2290
|
-
let n = 2;
|
|
2291
|
-
while (n < 1000) {
|
|
2292
|
-
const candidate = `${baseId}-${n}`;
|
|
2293
|
-
const candidatePath = path.join(recipesDir, `${candidate}.md`);
|
|
2294
|
-
if (!(await fileExists(candidatePath))) return candidate;
|
|
2295
|
-
n += 1;
|
|
2296
|
-
}
|
|
2297
|
-
throw new Error(`No available recipe id found for ${baseId} (tried up to -999)`);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
const suggestions = suggestedRecipeIds(baseId);
|
|
2301
|
-
const msg = [
|
|
2302
|
-
`Workspace recipe already exists: recipes/${baseId}.md`,
|
|
2303
|
-
`Choose a different recipe id (recommended) and re-run with --recipe-id, for example:`,
|
|
2304
|
-
...suggestions.map((s) => ` openclaw recipes scaffold-team ${recipeId} -t ${teamId} --recipe-id ${s}`),
|
|
2305
|
-
`Or re-run with --auto-increment to pick ${baseId}-2/-3/... automatically, or --overwrite-recipe to overwrite the existing file.`,
|
|
2306
|
-
].join("\n");
|
|
2307
|
-
throw new Error(msg);
|
|
2308
|
-
}
|
|
2309
|
-
|
|
2310
|
-
const explicitRecipeId = typeof options.recipeId === "string" ? String(options.recipeId).trim() : "";
|
|
2311
|
-
const baseRecipeId = explicitRecipeId || teamId;
|
|
2312
|
-
const workspaceRecipeId = await pickRecipeId(baseRecipeId);
|
|
2313
|
-
|
|
2314
|
-
// Write the recipe file, copying the source recipe markdown but forcing frontmatter.id to the chosen id.
|
|
2315
|
-
// Default: createOnly; overwrite only when --overwrite-recipe is set.
|
|
2316
|
-
const recipeFilePath = path.join(recipesDir, `${workspaceRecipeId}.md`);
|
|
2317
|
-
const parsed = parseFrontmatter(loaded.md);
|
|
2318
|
-
const fm = { ...parsed.frontmatter, id: workspaceRecipeId, name: parsed.frontmatter.name ?? recipe.name ?? workspaceRecipeId };
|
|
2319
|
-
const nextMd = `---\n${YAML.stringify(fm)}---\n${parsed.body}`;
|
|
2320
|
-
await writeFileSafely(recipeFilePath, nextMd, overwriteRecipe ? "overwrite" : "createOnly");
|
|
2321
|
-
|
|
2322
|
-
const rolesDir = path.join(teamDir, "roles");
|
|
2323
|
-
await ensureDir(rolesDir);
|
|
2324
|
-
const notesDir = path.join(teamDir, "notes");
|
|
2325
|
-
const workDir = path.join(teamDir, "work");
|
|
2326
|
-
const backlogDir = path.join(workDir, "backlog");
|
|
2327
|
-
const inProgressDir = path.join(workDir, "in-progress");
|
|
2328
|
-
const testingDir = path.join(workDir, "testing");
|
|
2329
|
-
const doneDir = path.join(workDir, "done");
|
|
2330
|
-
const assignmentsDir = path.join(workDir, "assignments");
|
|
2331
|
-
|
|
2332
|
-
// Seed standard team files (createOnly unless --overwrite)
|
|
2333
|
-
const overwrite = !!options.overwrite;
|
|
2334
|
-
|
|
2335
|
-
const sharedContextDir = path.join(teamDir, "shared-context");
|
|
2336
|
-
const sharedContextOutputsDir = path.join(sharedContextDir, "agent-outputs");
|
|
2337
|
-
const sharedContextFeedbackDir = path.join(sharedContextDir, "feedback");
|
|
2338
|
-
const sharedContextKpisDir = path.join(sharedContextDir, "kpis");
|
|
2339
|
-
const sharedContextCalendarDir = path.join(sharedContextDir, "calendar");
|
|
2340
|
-
|
|
2341
|
-
await Promise.all([
|
|
2342
|
-
// Back-compat: keep existing shared/ folder, but shared-context/ is canonical going forward.
|
|
2343
|
-
ensureDir(path.join(teamDir, "shared")),
|
|
2344
|
-
ensureDir(sharedContextDir),
|
|
2345
|
-
ensureDir(sharedContextOutputsDir),
|
|
2346
|
-
ensureDir(sharedContextFeedbackDir),
|
|
2347
|
-
ensureDir(sharedContextKpisDir),
|
|
2348
|
-
ensureDir(sharedContextCalendarDir),
|
|
2349
|
-
ensureDir(path.join(teamDir, "inbox")),
|
|
2350
|
-
ensureDir(path.join(teamDir, "outbox")),
|
|
2351
|
-
ensureDir(notesDir),
|
|
2352
|
-
ensureDir(workDir),
|
|
2353
|
-
ensureDir(backlogDir),
|
|
2354
|
-
ensureDir(inProgressDir),
|
|
2355
|
-
ensureDir(testingDir),
|
|
2356
|
-
ensureDir(doneDir),
|
|
2357
|
-
ensureDir(assignmentsDir),
|
|
2358
|
-
]);
|
|
2359
|
-
|
|
2360
|
-
// Seed shared-context starter schema (createOnly unless --overwrite)
|
|
2361
|
-
const sharedPrioritiesPath = path.join(sharedContextDir, "priorities.md");
|
|
2362
|
-
const prioritiesMd = `# Priorities — ${teamId}\n\n- (empty)\n\n## Notes\n- Lead curates this file.\n- Non-lead roles should append updates to shared-context/agent-outputs/ instead.\n`;
|
|
2363
|
-
await writeFileSafely(sharedPrioritiesPath, prioritiesMd, overwrite ? "overwrite" : "createOnly");
|
|
2364
|
-
|
|
2365
|
-
const planPath = path.join(notesDir, "plan.md");
|
|
2366
|
-
const statusPath = path.join(notesDir, "status.md");
|
|
2367
|
-
const goalsIndexPath = path.join(notesDir, "GOALS.md");
|
|
2368
|
-
const goalsDir = path.join(notesDir, "goals");
|
|
2369
|
-
const goalsReadmePath = path.join(goalsDir, "README.md");
|
|
2370
|
-
const ticketsPath = path.join(teamDir, "TICKETS.md");
|
|
2371
|
-
|
|
2372
|
-
const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
|
|
2373
|
-
const statusMd = `# Status — ${teamId}\n\n- (empty)\n`;
|
|
2374
|
-
const goalsIndexMd = `# Goals — ${teamId}\n\nThis folder is the canonical home for goals.\n\n## How to use\n- Create one markdown file per goal under: notes/goals/\n- Add a link here for discoverability\n\n## Goals\n- (empty)\n`;
|
|
2375
|
-
const goalsReadmeMd = `# Goals folder — ${teamId}\n\nCreate one markdown file per goal in this directory.\n\nRecommended file naming:\n- short, kebab-case, no leading numbers (e.g. \`reduce-support-backlog.md\`)\n\nLink goals from:\n- notes/GOALS.md\n`;
|
|
2376
|
-
const ticketsMd = `# Tickets — ${teamId}\n\n## Naming\n- Backlog tickets live in work/backlog/\n- In-progress tickets live in work/in-progress/\n- Testing tickets live in work/testing/\n- Done tickets live in work/done/\n- Filename ordering is the queue: 0001-..., 0002-...\n\n## Stages\n- backlog → in-progress → testing → done\n\n## QA handoff\n- When work is ready for QA: move the ticket to \`work/testing/\` and assign to test.\n\n## Required fields\nEach ticket should include:\n- Title\n- Context\n- Requirements\n- Acceptance criteria\n- Owner (dev/devops/lead/test)\n- Status (queued/in-progress/testing/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`;
|
|
2377
|
-
|
|
2378
|
-
await ensureDir(goalsDir);
|
|
2379
|
-
await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
|
|
2380
|
-
await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
|
|
2381
|
-
await writeFileSafely(goalsIndexPath, goalsIndexMd, overwrite ? "overwrite" : "createOnly");
|
|
2382
|
-
await writeFileSafely(goalsReadmePath, goalsReadmeMd, overwrite ? "overwrite" : "createOnly");
|
|
2383
|
-
await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
|
|
2384
|
-
|
|
2385
|
-
const agents = recipe.agents ?? [];
|
|
2386
|
-
if (!agents.length) throw new Error("Team recipe must include agents[]");
|
|
2387
|
-
|
|
2388
|
-
const results: any[] = [];
|
|
2389
|
-
for (const a of agents) {
|
|
2390
|
-
const role = a.role;
|
|
2391
|
-
const agentId = a.agentId ?? `${teamId}-${role}`;
|
|
2392
|
-
const agentName = a.name ?? `${teamId} ${role}`;
|
|
2393
|
-
|
|
2394
|
-
// For team recipes, we namespace template keys like: "lead.soul".
|
|
2395
|
-
const scopedRecipe: RecipeFrontmatter = {
|
|
2396
|
-
id: `${recipe.id}:${role}`,
|
|
2397
|
-
name: agentName,
|
|
2398
|
-
kind: "agent",
|
|
2399
|
-
requiredSkills: recipe.requiredSkills,
|
|
2400
|
-
optionalSkills: recipe.optionalSkills,
|
|
2401
|
-
templates: recipe.templates,
|
|
2402
|
-
files: (recipe.files ?? []).map((f) => ({
|
|
2403
|
-
...f,
|
|
2404
|
-
template: f.template.includes(".") ? f.template : `${role}.${f.template}`,
|
|
2405
|
-
})),
|
|
2406
|
-
tools: a.tools ?? recipe.tools,
|
|
2407
|
-
};
|
|
2408
|
-
|
|
2409
|
-
const roleDir = path.join(rolesDir, role);
|
|
2410
|
-
const r = await scaffoldAgentFromRecipe(api, scopedRecipe, {
|
|
2411
|
-
agentId,
|
|
2412
|
-
agentName,
|
|
2413
|
-
update: !!options.overwrite,
|
|
2414
|
-
// Write role-specific files under roles/<role>/
|
|
2415
|
-
filesRootDir: roleDir,
|
|
2416
|
-
// But set the agent workspace root to the shared team workspace
|
|
2417
|
-
workspaceRootDir: teamDir,
|
|
2418
|
-
vars: {
|
|
2419
|
-
teamId,
|
|
2420
|
-
teamDir,
|
|
2421
|
-
role,
|
|
2422
|
-
agentId,
|
|
2423
|
-
agentName,
|
|
2424
|
-
roleDir,
|
|
2425
|
-
},
|
|
2426
|
-
});
|
|
2427
|
-
results.push({ role, agentId, ...r });
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
// Create a minimal TEAM.md
|
|
2431
|
-
const teamMdPath = path.join(teamDir, "TEAM.md");
|
|
2432
|
-
const teamMd = `# ${teamId}\n\nShared workspace for this agent team.\n\n## Folders\n- inbox/ — requests\n- outbox/ — deliverables\n- shared-context/ — curated shared context + append-only agent outputs\n- shared/ — legacy shared artifacts (back-compat)\n- notes/ — plan + status\n- work/ — working files\n`;
|
|
2433
|
-
await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
|
|
2434
|
-
|
|
2435
|
-
// Persist provenance (parent recipe) for UIs like ClawKitchen.
|
|
2436
|
-
// This avoids brittle heuristics like teamId==recipeId guessing.
|
|
2437
|
-
const teamMetaPath = path.join(teamDir, "team.json");
|
|
2438
|
-
const teamMeta = {
|
|
2439
|
-
teamId,
|
|
2440
|
-
recipeId: recipe.id,
|
|
2441
|
-
recipeName: recipe.name ?? "",
|
|
2442
|
-
scaffoldedAt: new Date().toISOString(),
|
|
2443
|
-
};
|
|
2444
|
-
await writeJsonFile(teamMetaPath, teamMeta);
|
|
2445
|
-
|
|
2446
|
-
if (options.applyConfig) {
|
|
2447
|
-
const snippets: AgentConfigSnippet[] = results.map((x: any) => x.next.configSnippet);
|
|
2448
|
-
await applyAgentSnippetsToOpenClawConfig(api, snippets);
|
|
2449
|
-
}
|
|
2450
|
-
|
|
2451
|
-
const cron = await reconcileRecipeCronJobs({
|
|
2452
|
-
api,
|
|
2453
|
-
recipe,
|
|
2454
|
-
scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
|
|
2455
|
-
cronInstallation: getCfg(api).cronInstallation,
|
|
680
|
+
.action(async (recipeId: string, options: { teamId: string; recipeId?: string; overwrite?: boolean; overwriteRecipe?: boolean; autoIncrement?: boolean; applyConfig?: boolean }) => {
|
|
681
|
+
const res = await handleScaffoldTeam(api, {
|
|
682
|
+
recipeId,
|
|
683
|
+
teamId: String(options.teamId),
|
|
684
|
+
recipeIdExplicit: options.recipeId,
|
|
685
|
+
overwrite: !!options.overwrite,
|
|
686
|
+
overwriteRecipe: !!options.overwriteRecipe,
|
|
687
|
+
autoIncrement: !!options.autoIncrement,
|
|
688
|
+
applyConfig: !!options.applyConfig,
|
|
2456
689
|
});
|
|
2457
|
-
|
|
2458
|
-
console.log(
|
|
2459
|
-
JSON.stringify(
|
|
2460
|
-
{
|
|
2461
|
-
teamId,
|
|
2462
|
-
teamDir,
|
|
2463
|
-
agents: results,
|
|
2464
|
-
cron,
|
|
2465
|
-
next: {
|
|
2466
|
-
note:
|
|
2467
|
-
options.applyConfig
|
|
2468
|
-
? "agents.list[] updated in openclaw config"
|
|
2469
|
-
: "Run again with --apply-config to write agents into openclaw config.",
|
|
2470
|
-
},
|
|
2471
|
-
},
|
|
2472
|
-
null,
|
|
2473
|
-
2,
|
|
2474
|
-
),
|
|
2475
|
-
);
|
|
690
|
+
logScaffoldResult(res, recipeId);
|
|
2476
691
|
});
|
|
2477
692
|
},
|
|
2478
693
|
{ commands: ["recipes"] },
|
|
@@ -2486,20 +701,35 @@ export const __internal = {
|
|
|
2486
701
|
upsertBindingInConfig,
|
|
2487
702
|
removeBindingsInConfig,
|
|
2488
703
|
stableStringify,
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
704
|
+
workspacePath,
|
|
705
|
+
listRecipeFiles,
|
|
706
|
+
loadRecipeById,
|
|
707
|
+
handleRecipesList,
|
|
708
|
+
handleRecipesShow,
|
|
709
|
+
handleRecipesStatus,
|
|
710
|
+
handleRecipesBind,
|
|
711
|
+
handleRecipesUnbind,
|
|
712
|
+
handleRecipesBindings,
|
|
713
|
+
handleTickets,
|
|
714
|
+
handleMoveTicket,
|
|
715
|
+
handleMigrateTeamPlan,
|
|
716
|
+
executeMigrateTeamPlan,
|
|
717
|
+
handleRemoveTeam,
|
|
718
|
+
handleAssign,
|
|
719
|
+
handleTake,
|
|
720
|
+
handleHandoff,
|
|
721
|
+
handleDispatch,
|
|
722
|
+
handleInstallSkill,
|
|
723
|
+
handleInstallMarketplaceRecipe,
|
|
724
|
+
handleScaffold,
|
|
725
|
+
handleScaffoldTeam,
|
|
726
|
+
scaffoldAgentFromRecipe,
|
|
727
|
+
promptYesNo,
|
|
728
|
+
reconcileRecipeCronJobs,
|
|
729
|
+
applyAgentSnippetsToOpenClawConfig,
|
|
730
|
+
patchTicketField,
|
|
731
|
+
patchTicketOwner,
|
|
732
|
+
patchTicketStatus,
|
|
2503
733
|
};
|
|
2504
734
|
|
|
2505
735
|
export default recipesPlugin;
|