@jiggai/recipes 0.2.11
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 +142 -0
- package/clawcipes_cook.jpg +0 -0
- package/docs/AGENTS_AND_SKILLS.md +232 -0
- package/docs/BUNDLED_RECIPES.md +208 -0
- package/docs/CLAWCIPES_KITCHEN.md +27 -0
- package/docs/COMMANDS.md +266 -0
- package/docs/INSTALLATION.md +80 -0
- package/docs/RECIPE_FORMAT.md +127 -0
- package/docs/TEAM_WORKFLOW.md +62 -0
- package/docs/TUTORIAL_CREATE_RECIPE.md +151 -0
- package/docs/shared-context.md +47 -0
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +2244 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +46 -0
- package/recipes/default/customer-support-team.md +246 -0
- package/recipes/default/developer.md +73 -0
- package/recipes/default/development-team.md +389 -0
- package/recipes/default/editor.md +74 -0
- package/recipes/default/product-team.md +298 -0
- package/recipes/default/project-manager.md +69 -0
- package/recipes/default/research-team.md +243 -0
- package/recipes/default/researcher.md +75 -0
- package/recipes/default/social-team.md +205 -0
- package/recipes/default/writing-team.md +228 -0
- package/src/lib/bindings.ts +59 -0
- package/src/lib/cleanup-workspaces.ts +173 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/lanes.ts +63 -0
- package/src/lib/recipe-frontmatter.ts +59 -0
- package/src/lib/remove-team.ts +200 -0
- package/src/lib/scaffold-templates.ts +7 -0
- package/src/lib/shared-context.ts +52 -0
- package/src/lib/ticket-finder.ts +60 -0
- package/src/lib/ticket-workflow.ts +153 -0
package/index.ts
ADDED
|
@@ -0,0 +1,2244 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import JSON5 from "json5";
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
import { buildRemoveTeamPlan, executeRemoveTeamPlan, loadCronStore, saveCronStore } from "./src/lib/remove-team";
|
|
8
|
+
|
|
9
|
+
type RecipesConfig = {
|
|
10
|
+
workspaceRecipesDir?: string;
|
|
11
|
+
workspaceAgentsDir?: string;
|
|
12
|
+
workspaceSkillsDir?: string;
|
|
13
|
+
workspaceTeamsDir?: string;
|
|
14
|
+
autoInstallMissingSkills?: boolean;
|
|
15
|
+
confirmAutoInstall?: boolean;
|
|
16
|
+
|
|
17
|
+
/** Cron installation behavior during scaffold/scaffold-team. */
|
|
18
|
+
cronInstallation?: "off" | "prompt" | "on";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type CronJobSpec = {
|
|
22
|
+
/** Stable id within the recipe (used for idempotent reconciliation). */
|
|
23
|
+
id: string;
|
|
24
|
+
/** 5-field cron expression */
|
|
25
|
+
schedule: string;
|
|
26
|
+
/** Agent message payload */
|
|
27
|
+
message: string;
|
|
28
|
+
|
|
29
|
+
name?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
timezone?: string;
|
|
32
|
+
|
|
33
|
+
/** Delivery routing (optional; defaults to OpenClaw "last"). */
|
|
34
|
+
channel?: string;
|
|
35
|
+
to?: string;
|
|
36
|
+
|
|
37
|
+
/** Which agent should execute this job (optional). */
|
|
38
|
+
agentId?: string;
|
|
39
|
+
|
|
40
|
+
/** If true, install enabled when cronInstallation=on (or prompt-yes). Default false. */
|
|
41
|
+
enabledByDefault?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type RecipeFrontmatter = {
|
|
45
|
+
id: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
version?: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
kind?: "agent" | "team";
|
|
50
|
+
|
|
51
|
+
/** Optional recipe-defined cron jobs to reconcile during scaffold. */
|
|
52
|
+
cronJobs?: CronJobSpec[];
|
|
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;
|
|
265
|
+
description?: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function spawnOpenClawJson(args: string[]) {
|
|
269
|
+
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
|
270
|
+
const res = spawnSync("openclaw", args, { encoding: "utf8" });
|
|
271
|
+
if (res.status !== 0) {
|
|
272
|
+
const err = new Error(`openclaw ${args.join(" ")} failed (exit=${res.status})`);
|
|
273
|
+
(err as any).stdout = res.stdout;
|
|
274
|
+
(err as any).stderr = res.stderr;
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const raw = String(res.stdout ?? "");
|
|
279
|
+
|
|
280
|
+
// OpenClaw may prepend pretty "Config warnings" blocks before JSON output.
|
|
281
|
+
// To be resilient, parse the first JSON object/array found in stdout.
|
|
282
|
+
const trimmed = raw.trim();
|
|
283
|
+
if (!trimmed) return null;
|
|
284
|
+
|
|
285
|
+
const firstObj = trimmed.indexOf("{");
|
|
286
|
+
const firstArr = trimmed.indexOf("[");
|
|
287
|
+
const start =
|
|
288
|
+
firstObj === -1 ? firstArr : firstArr === -1 ? firstObj : Math.min(firstObj, firstArr);
|
|
289
|
+
|
|
290
|
+
const jsonText = start >= 0 ? trimmed.slice(start) : trimmed;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(jsonText) as any;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
const err = new Error(`Failed parsing JSON from: openclaw ${args.join(" ")}`);
|
|
296
|
+
(err as any).stdout = raw;
|
|
297
|
+
(err as any).stderr = res.stderr;
|
|
298
|
+
(err as any).cause = e;
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
|
|
304
|
+
const raw = frontmatter.cronJobs;
|
|
305
|
+
if (!raw) return [];
|
|
306
|
+
if (!Array.isArray(raw)) throw new Error("frontmatter.cronJobs must be an array");
|
|
307
|
+
|
|
308
|
+
const out: CronJobSpec[] = [];
|
|
309
|
+
const seen = new Set<string>();
|
|
310
|
+
for (const j of raw as any[]) {
|
|
311
|
+
if (!j || typeof j !== "object") throw new Error("cronJobs entries must be objects");
|
|
312
|
+
const id = String((j as any).id ?? "").trim();
|
|
313
|
+
if (!id) throw new Error("cronJobs[].id is required");
|
|
314
|
+
if (seen.has(id)) throw new Error(`Duplicate cronJobs[].id: ${id}`);
|
|
315
|
+
seen.add(id);
|
|
316
|
+
|
|
317
|
+
const schedule = String((j as any).schedule ?? "").trim();
|
|
318
|
+
const message = String((j as any).message ?? (j as any).task ?? (j as any).prompt ?? "").trim();
|
|
319
|
+
if (!schedule) throw new Error(`cronJobs[${id}].schedule is required`);
|
|
320
|
+
if (!message) throw new Error(`cronJobs[${id}].message is required`);
|
|
321
|
+
|
|
322
|
+
out.push({
|
|
323
|
+
id,
|
|
324
|
+
schedule,
|
|
325
|
+
message,
|
|
326
|
+
name: (j as any).name ? String((j as any).name) : undefined,
|
|
327
|
+
description: (j as any).description ? String((j as any).description) : undefined,
|
|
328
|
+
timezone: (j as any).timezone ? String((j as any).timezone) : undefined,
|
|
329
|
+
channel: (j as any).channel ? String((j as any).channel) : undefined,
|
|
330
|
+
to: (j as any).to ? String((j as any).to) : undefined,
|
|
331
|
+
agentId: (j as any).agentId ? String((j as any).agentId) : undefined,
|
|
332
|
+
enabledByDefault: Boolean((j as any).enabledByDefault ?? false),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function promptYesNo(header: string) {
|
|
339
|
+
if (!process.stdin.isTTY) return false;
|
|
340
|
+
const readline = await import("node:readline/promises");
|
|
341
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
342
|
+
try {
|
|
343
|
+
const ans = await rl.question(`${header}\nProceed? (y/N) `);
|
|
344
|
+
return ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
345
|
+
} finally {
|
|
346
|
+
rl.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function reconcileRecipeCronJobs(opts: {
|
|
351
|
+
recipe: RecipeFrontmatter;
|
|
352
|
+
scope: { kind: "team"; teamId: string; recipeId: string; stateDir: string } | { kind: "agent"; agentId: string; recipeId: string; stateDir: string };
|
|
353
|
+
cronInstallation: CronInstallMode;
|
|
354
|
+
}) {
|
|
355
|
+
const desired = normalizeCronJobs(opts.recipe);
|
|
356
|
+
if (!desired.length) return { ok: true, changed: false, note: "no-cron-jobs" as const };
|
|
357
|
+
|
|
358
|
+
const mode = opts.cronInstallation;
|
|
359
|
+
if (mode === "off") {
|
|
360
|
+
return { ok: true, changed: false, note: "cron-installation-off" as const, desiredCount: desired.length };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Decide whether jobs should be enabled on creation. Default is conservative.
|
|
364
|
+
let userOptIn = mode === "on";
|
|
365
|
+
if (mode === "prompt") {
|
|
366
|
+
const header = `Recipe ${opts.scope.recipeId} defines ${desired.length} cron job(s).\nThese run automatically on a schedule. Install them?`;
|
|
367
|
+
userOptIn = await promptYesNo(header);
|
|
368
|
+
if (!userOptIn && !process.stdin.isTTY) {
|
|
369
|
+
console.error("Non-interactive mode: defaulting cron install to disabled.");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
|
|
374
|
+
const state = await loadCronMappingState(statePath);
|
|
375
|
+
|
|
376
|
+
const list = spawnOpenClawJson(["cron", "list", "--json"]) as { jobs: OpenClawCronJob[] };
|
|
377
|
+
const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
|
|
378
|
+
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
const desiredIds = new Set(desired.map((j) => j.id));
|
|
381
|
+
|
|
382
|
+
const results: any[] = [];
|
|
383
|
+
|
|
384
|
+
for (const j of desired) {
|
|
385
|
+
const key = cronKey(opts.scope as any, j.id);
|
|
386
|
+
const name = j.name ?? `${opts.scope.kind === "team" ? (opts.scope as any).teamId : (opts.scope as any).agentId} • ${opts.scope.recipeId} • ${j.id}`;
|
|
387
|
+
|
|
388
|
+
const desiredSpec = {
|
|
389
|
+
schedule: j.schedule,
|
|
390
|
+
message: j.message,
|
|
391
|
+
timezone: j.timezone ?? "",
|
|
392
|
+
channel: j.channel ?? "last",
|
|
393
|
+
to: j.to ?? "",
|
|
394
|
+
agentId: j.agentId ?? "",
|
|
395
|
+
name,
|
|
396
|
+
description: j.description ?? "",
|
|
397
|
+
};
|
|
398
|
+
const specHash = hashSpec(desiredSpec);
|
|
399
|
+
|
|
400
|
+
const prev = state.entries[key];
|
|
401
|
+
const installedId = prev?.installedCronId;
|
|
402
|
+
const existing = installedId ? byId.get(installedId) : undefined;
|
|
403
|
+
|
|
404
|
+
const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
|
|
405
|
+
|
|
406
|
+
if (!existing) {
|
|
407
|
+
// Create new job.
|
|
408
|
+
const args = [
|
|
409
|
+
"cron",
|
|
410
|
+
"add",
|
|
411
|
+
"--json",
|
|
412
|
+
"--name",
|
|
413
|
+
name,
|
|
414
|
+
"--cron",
|
|
415
|
+
j.schedule,
|
|
416
|
+
"--message",
|
|
417
|
+
j.message,
|
|
418
|
+
"--announce",
|
|
419
|
+
];
|
|
420
|
+
if (!wantEnabled) args.push("--disabled");
|
|
421
|
+
if (j.description) args.push("--description", j.description);
|
|
422
|
+
if (j.timezone) args.push("--tz", j.timezone);
|
|
423
|
+
if (j.channel) args.push("--channel", j.channel);
|
|
424
|
+
if (j.to) args.push("--to", j.to);
|
|
425
|
+
if (j.agentId) args.push("--agent", j.agentId);
|
|
426
|
+
|
|
427
|
+
const created = spawnOpenClawJson(args) as any;
|
|
428
|
+
const newId = created?.id ?? created?.job?.id;
|
|
429
|
+
if (!newId) throw new Error("Failed to parse cron add output (missing id)");
|
|
430
|
+
|
|
431
|
+
state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
|
|
432
|
+
results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Update existing job if spec changed.
|
|
437
|
+
if (prev?.specHash !== specHash) {
|
|
438
|
+
const editArgs = [
|
|
439
|
+
"cron",
|
|
440
|
+
"edit",
|
|
441
|
+
existing.id,
|
|
442
|
+
"--name",
|
|
443
|
+
name,
|
|
444
|
+
"--cron",
|
|
445
|
+
j.schedule,
|
|
446
|
+
"--message",
|
|
447
|
+
j.message,
|
|
448
|
+
"--announce",
|
|
449
|
+
];
|
|
450
|
+
if (j.description) editArgs.push("--description", j.description);
|
|
451
|
+
if (j.timezone) editArgs.push("--tz", j.timezone);
|
|
452
|
+
if (j.channel) editArgs.push("--channel", j.channel);
|
|
453
|
+
if (j.to) editArgs.push("--to", j.to);
|
|
454
|
+
if (j.agentId) editArgs.push("--agent", j.agentId);
|
|
455
|
+
|
|
456
|
+
spawnOpenClawJson(editArgs);
|
|
457
|
+
results.push({ action: "updated", key, installedCronId: existing.id });
|
|
458
|
+
} else {
|
|
459
|
+
results.push({ action: "unchanged", key, installedCronId: existing.id });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
|
|
463
|
+
if (!userOptIn) {
|
|
464
|
+
if (existing.enabled) {
|
|
465
|
+
spawnOpenClawJson(["cron", "edit", existing.id, "--disable"]);
|
|
466
|
+
results.push({ action: "disabled", key, installedCronId: existing.id });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
state.entries[key] = { installedCronId: existing.id, specHash, updatedAtMs: now, orphaned: false };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Handle removed jobs: disable safely.
|
|
474
|
+
for (const [key, entry] of Object.entries(state.entries)) {
|
|
475
|
+
if (!key.includes(`:recipe:${opts.scope.recipeId}:cron:`)) continue;
|
|
476
|
+
const cronId = key.split(":cron:")[1] ?? "";
|
|
477
|
+
if (!cronId || desiredIds.has(cronId)) continue;
|
|
478
|
+
|
|
479
|
+
const job = byId.get(entry.installedCronId);
|
|
480
|
+
if (job && job.enabled) {
|
|
481
|
+
spawnOpenClawJson(["cron", "edit", job.id, "--disable"]);
|
|
482
|
+
results.push({ action: "disabled-removed", key, installedCronId: job.id });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
state.entries[key] = { ...entry, orphaned: true, updatedAtMs: now };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await writeJsonFile(statePath, state);
|
|
489
|
+
|
|
490
|
+
const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
|
|
491
|
+
return { ok: true, changed, results };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderTemplate(raw: string, vars: Record<string, string>) {
|
|
495
|
+
// Tiny, safe template renderer: replaces {{key}}.
|
|
496
|
+
// No conditionals, no eval.
|
|
497
|
+
return raw.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => {
|
|
498
|
+
const v = vars[key];
|
|
499
|
+
return typeof v === "string" ? v : "";
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function writeFileSafely(p: string, content: string, mode: "createOnly" | "overwrite") {
|
|
504
|
+
if (mode === "createOnly" && (await fileExists(p))) return { wrote: false, reason: "exists" as const };
|
|
505
|
+
await ensureDir(path.dirname(p));
|
|
506
|
+
await fs.writeFile(p, content, "utf8");
|
|
507
|
+
return { wrote: true, reason: "ok" as const };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
type AgentConfigSnippet = {
|
|
511
|
+
id: string;
|
|
512
|
+
workspace: string;
|
|
513
|
+
identity?: { name?: string };
|
|
514
|
+
tools?: { profile?: string; allow?: string[]; deny?: string[] };
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
type BindingMatch = {
|
|
518
|
+
channel: string;
|
|
519
|
+
accountId?: string;
|
|
520
|
+
// OpenClaw config schema uses: dm | group | channel
|
|
521
|
+
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
|
522
|
+
guildId?: string;
|
|
523
|
+
teamId?: string;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
type BindingSnippet = {
|
|
527
|
+
agentId: string;
|
|
528
|
+
match: BindingMatch;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
function upsertAgentInConfig(cfgObj: any, snippet: AgentConfigSnippet) {
|
|
532
|
+
if (!cfgObj.agents) cfgObj.agents = {};
|
|
533
|
+
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
534
|
+
|
|
535
|
+
const list: any[] = cfgObj.agents.list;
|
|
536
|
+
const idx = list.findIndex((a) => a?.id === snippet.id);
|
|
537
|
+
const prev = idx >= 0 ? list[idx] : {};
|
|
538
|
+
const nextAgent = {
|
|
539
|
+
...prev,
|
|
540
|
+
id: snippet.id,
|
|
541
|
+
workspace: snippet.workspace,
|
|
542
|
+
// identity: merge (safe)
|
|
543
|
+
identity: {
|
|
544
|
+
...(prev?.identity ?? {}),
|
|
545
|
+
...(snippet.identity ?? {}),
|
|
546
|
+
},
|
|
547
|
+
// tools: replace when provided (so stale deny/allow don’t linger)
|
|
548
|
+
tools: snippet.tools ? { ...snippet.tools } : prev?.tools,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
if (idx >= 0) {
|
|
552
|
+
list[idx] = nextAgent;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// New agent: append to end of list.
|
|
557
|
+
// (We still separately enforce that main exists and stays first/default.)
|
|
558
|
+
list.push(nextAgent);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function ensureMainFirstInAgentsList(cfgObj: any, api: OpenClawPluginApi) {
|
|
562
|
+
if (!cfgObj.agents) cfgObj.agents = {};
|
|
563
|
+
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
564
|
+
|
|
565
|
+
const list: any[] = cfgObj.agents.list;
|
|
566
|
+
|
|
567
|
+
const workspaceRoot =
|
|
568
|
+
cfgObj.agents?.defaults?.workspace ??
|
|
569
|
+
api.config.agents?.defaults?.workspace ??
|
|
570
|
+
"~/.openclaw/workspace";
|
|
571
|
+
|
|
572
|
+
const idx = list.findIndex((a) => a?.id === "main");
|
|
573
|
+
const prevMain = idx >= 0 ? list[idx] : {};
|
|
574
|
+
|
|
575
|
+
// Enforce: main exists, is first, and is the default.
|
|
576
|
+
const main = {
|
|
577
|
+
...prevMain,
|
|
578
|
+
id: "main",
|
|
579
|
+
default: true,
|
|
580
|
+
workspace: prevMain?.workspace ?? workspaceRoot,
|
|
581
|
+
sandbox: prevMain?.sandbox ?? { mode: "off" },
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// Ensure only one default.
|
|
585
|
+
for (const a of list) {
|
|
586
|
+
if (a?.id !== "main" && a?.default) a.default = false;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
590
|
+
list.unshift(main);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function stableStringify(x: any) {
|
|
594
|
+
const seen = new WeakSet();
|
|
595
|
+
const sortObj = (v: any): any => {
|
|
596
|
+
if (v && typeof v === "object") {
|
|
597
|
+
if (seen.has(v)) return "[Circular]";
|
|
598
|
+
seen.add(v);
|
|
599
|
+
if (Array.isArray(v)) return v.map(sortObj);
|
|
600
|
+
const out: any = {};
|
|
601
|
+
for (const k of Object.keys(v).sort()) out[k] = sortObj(v[k]);
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
604
|
+
return v;
|
|
605
|
+
};
|
|
606
|
+
return JSON.stringify(sortObj(x));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
610
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
611
|
+
const list: any[] = cfgObj.bindings;
|
|
612
|
+
|
|
613
|
+
const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
614
|
+
const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
|
|
615
|
+
|
|
616
|
+
if (idx >= 0) {
|
|
617
|
+
// Update in place (preserve ordering)
|
|
618
|
+
list[idx] = { ...list[idx], ...binding };
|
|
619
|
+
return { changed: false, note: "already-present" as const };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
623
|
+
// Otherwise append.
|
|
624
|
+
if (binding.match?.peer) list.unshift(binding);
|
|
625
|
+
else list.push(binding);
|
|
626
|
+
|
|
627
|
+
return { changed: true, note: "added" as const };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
|
|
631
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
632
|
+
const list: any[] = cfgObj.bindings;
|
|
633
|
+
|
|
634
|
+
const targetMatchSig = stableStringify(opts.match);
|
|
635
|
+
|
|
636
|
+
const before = list.length;
|
|
637
|
+
const kept: any[] = [];
|
|
638
|
+
const removed: any[] = [];
|
|
639
|
+
|
|
640
|
+
for (const b of list) {
|
|
641
|
+
const sameAgent = opts.agentId ? String(b?.agentId ?? "") === opts.agentId : true;
|
|
642
|
+
const sameMatch = stableStringify(b?.match ?? {}) === targetMatchSig;
|
|
643
|
+
if (sameAgent && sameMatch) removed.push(b);
|
|
644
|
+
else kept.push(b);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
cfgObj.bindings = kept;
|
|
648
|
+
return { removedCount: before - kept.length, removed };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
|
|
652
|
+
// Load the latest config from disk (not the snapshot in api.config).
|
|
653
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
654
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
655
|
+
|
|
656
|
+
// Some loaders return { cfg, ... }. If so, normalize.
|
|
657
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
658
|
+
|
|
659
|
+
// Always keep main first/default when multi-agent workflows are in play.
|
|
660
|
+
ensureMainFirstInAgentsList(cfgObj, api);
|
|
661
|
+
|
|
662
|
+
for (const s of snippets) upsertAgentInConfig(cfgObj, s);
|
|
663
|
+
|
|
664
|
+
// Re-assert ordering/default after upserts.
|
|
665
|
+
ensureMainFirstInAgentsList(cfgObj, api);
|
|
666
|
+
|
|
667
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
668
|
+
return { updatedAgents: snippets.map((s) => s.id) };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function applyBindingSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: BindingSnippet[]) {
|
|
672
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
673
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
674
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
675
|
+
|
|
676
|
+
const results: any[] = [];
|
|
677
|
+
for (const s of snippets) {
|
|
678
|
+
results.push({ ...s, result: upsertBindingInConfig(cfgObj, s) });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
682
|
+
return { updatedBindings: results };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function scaffoldAgentFromRecipe(
|
|
686
|
+
api: OpenClawPluginApi,
|
|
687
|
+
recipe: RecipeFrontmatter,
|
|
688
|
+
opts: {
|
|
689
|
+
agentId: string;
|
|
690
|
+
agentName?: string;
|
|
691
|
+
update?: boolean;
|
|
692
|
+
vars?: Record<string, string>;
|
|
693
|
+
|
|
694
|
+
// Where to write the scaffolded files (may be a shared team workspace role folder)
|
|
695
|
+
filesRootDir: string;
|
|
696
|
+
|
|
697
|
+
// What to set in agents.list[].workspace (may be shared team workspace root)
|
|
698
|
+
workspaceRootDir: string;
|
|
699
|
+
},
|
|
700
|
+
) {
|
|
701
|
+
await ensureDir(opts.filesRootDir);
|
|
702
|
+
|
|
703
|
+
const templates = recipe.templates ?? {};
|
|
704
|
+
const files = recipe.files ?? [];
|
|
705
|
+
const vars = opts.vars ?? {};
|
|
706
|
+
|
|
707
|
+
const fileResults: Array<{ path: string; wrote: boolean; reason: string }> = [];
|
|
708
|
+
for (const f of files) {
|
|
709
|
+
const raw = templates[f.template];
|
|
710
|
+
if (typeof raw !== "string") throw new Error(`Missing template: ${f.template}`);
|
|
711
|
+
const rendered = renderTemplate(raw, vars);
|
|
712
|
+
const target = path.join(opts.filesRootDir, f.path);
|
|
713
|
+
const mode = opts.update ? (f.mode ?? "overwrite") : (f.mode ?? "createOnly");
|
|
714
|
+
const r = await writeFileSafely(target, rendered, mode);
|
|
715
|
+
fileResults.push({ path: target, wrote: r.wrote, reason: r.reason });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const configSnippet: AgentConfigSnippet = {
|
|
719
|
+
id: opts.agentId,
|
|
720
|
+
workspace: opts.workspaceRootDir,
|
|
721
|
+
identity: { name: opts.agentName ?? recipe.name ?? opts.agentId },
|
|
722
|
+
tools: recipe.tools ?? {},
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
filesRootDir: opts.filesRootDir,
|
|
727
|
+
workspaceRootDir: opts.workspaceRootDir,
|
|
728
|
+
fileResults,
|
|
729
|
+
next: {
|
|
730
|
+
configSnippet,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const recipesPlugin = {
|
|
736
|
+
id: "recipes",
|
|
737
|
+
name: "Recipes",
|
|
738
|
+
description: "Markdown recipes that scaffold agents and teams.",
|
|
739
|
+
configSchema: {
|
|
740
|
+
type: "object",
|
|
741
|
+
additionalProperties: false,
|
|
742
|
+
properties: {},
|
|
743
|
+
},
|
|
744
|
+
register(api: OpenClawPluginApi) {
|
|
745
|
+
// On plugin load, ensure multi-agent config has an explicit agents.list with main at top.
|
|
746
|
+
// This is idempotent and only writes if a change is required.
|
|
747
|
+
(async () => {
|
|
748
|
+
try {
|
|
749
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
750
|
+
if (!current) return;
|
|
751
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
752
|
+
|
|
753
|
+
const before = JSON.stringify(cfgObj.agents?.list ?? null);
|
|
754
|
+
ensureMainFirstInAgentsList(cfgObj, api);
|
|
755
|
+
const after = JSON.stringify(cfgObj.agents?.list ?? null);
|
|
756
|
+
|
|
757
|
+
if (before !== after) {
|
|
758
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
759
|
+
console.error("[recipes] ensured agents.list includes main as first/default");
|
|
760
|
+
}
|
|
761
|
+
} catch (e) {
|
|
762
|
+
console.error(`[recipes] warning: failed to ensure main agent in agents.list: ${(e as Error).message}`);
|
|
763
|
+
}
|
|
764
|
+
})();
|
|
765
|
+
|
|
766
|
+
api.registerCli(
|
|
767
|
+
({ program }) => {
|
|
768
|
+
const cmd = program.command("recipes").description("Manage markdown recipes (scaffold agents/teams)");
|
|
769
|
+
|
|
770
|
+
cmd
|
|
771
|
+
.command("list")
|
|
772
|
+
.description("List available recipes (builtin + workspace)")
|
|
773
|
+
.action(async () => {
|
|
774
|
+
const cfg = getCfg(api);
|
|
775
|
+
const files = await listRecipeFiles(api, cfg);
|
|
776
|
+
const rows: Array<{ id: string; name?: string; kind?: string; source: string }> = [];
|
|
777
|
+
for (const f of files) {
|
|
778
|
+
try {
|
|
779
|
+
const md = await fs.readFile(f.path, "utf8");
|
|
780
|
+
const { frontmatter } = parseFrontmatter(md);
|
|
781
|
+
rows.push({ id: frontmatter.id, name: frontmatter.name, kind: frontmatter.kind, source: f.source });
|
|
782
|
+
} catch (e) {
|
|
783
|
+
rows.push({ id: path.basename(f.path), name: `INVALID: ${(e as Error).message}`, kind: "invalid", source: f.source });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
cmd
|
|
790
|
+
.command("show")
|
|
791
|
+
.description("Show a recipe by id")
|
|
792
|
+
.argument("<id>", "Recipe id")
|
|
793
|
+
.action(async (id: string) => {
|
|
794
|
+
const r = await loadRecipeById(api, id);
|
|
795
|
+
console.log(r.md);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
cmd
|
|
799
|
+
.command("status")
|
|
800
|
+
.description("Check for missing skills for a recipe (or all)")
|
|
801
|
+
.argument("[id]", "Recipe id")
|
|
802
|
+
.action(async (id?: string) => {
|
|
803
|
+
const cfg = getCfg(api);
|
|
804
|
+
const files = await listRecipeFiles(api, cfg);
|
|
805
|
+
const out: any[] = [];
|
|
806
|
+
|
|
807
|
+
for (const f of files) {
|
|
808
|
+
const md = await fs.readFile(f.path, "utf8");
|
|
809
|
+
const { frontmatter } = parseFrontmatter(md);
|
|
810
|
+
if (id && frontmatter.id !== id) continue;
|
|
811
|
+
const req = frontmatter.requiredSkills ?? [];
|
|
812
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
813
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
814
|
+
const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
|
|
815
|
+
const missing = await detectMissingSkills(installDir, req);
|
|
816
|
+
out.push({
|
|
817
|
+
id: frontmatter.id,
|
|
818
|
+
requiredSkills: req,
|
|
819
|
+
missingSkills: missing,
|
|
820
|
+
installCommands: missing.length ? skillInstallCommands(cfg, missing) : [],
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
console.log(JSON.stringify(out, null, 2));
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const parseMatchFromOptions = (options: any): BindingMatch => {
|
|
828
|
+
if (options.match) {
|
|
829
|
+
return JSON5.parse(String(options.match)) as BindingMatch;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const match: BindingMatch = {
|
|
833
|
+
channel: String(options.channel),
|
|
834
|
+
};
|
|
835
|
+
if (options.accountId) match.accountId = String(options.accountId);
|
|
836
|
+
if (options.guildId) match.guildId = String(options.guildId);
|
|
837
|
+
if (options.teamId) match.teamId = String(options.teamId);
|
|
838
|
+
|
|
839
|
+
if (options.peerKind || options.peerId) {
|
|
840
|
+
if (!options.peerKind || !options.peerId) {
|
|
841
|
+
throw new Error("--peer-kind and --peer-id must be provided together");
|
|
842
|
+
}
|
|
843
|
+
let kind = String(options.peerKind);
|
|
844
|
+
// Back-compat alias
|
|
845
|
+
if (kind === "direct") kind = "dm";
|
|
846
|
+
if (kind !== "dm" && kind !== "group" && kind !== "channel") {
|
|
847
|
+
throw new Error("--peer-kind must be dm|group|channel (or direct as alias for dm)");
|
|
848
|
+
}
|
|
849
|
+
match.peer = { kind, id: String(options.peerId) };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return match;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
cmd
|
|
856
|
+
.command("bind")
|
|
857
|
+
.description("Add/update a multi-agent routing binding (writes openclaw.json bindings[])")
|
|
858
|
+
.requiredOption("--agent-id <agentId>", "Target agent id")
|
|
859
|
+
.requiredOption("--channel <channel>", "Channel name (telegram|whatsapp|discord|slack|...) ")
|
|
860
|
+
.option("--account-id <accountId>", "Channel accountId (if applicable)")
|
|
861
|
+
.option("--peer-kind <kind>", "Peer kind (dm|group|channel) (aliases: direct->dm)")
|
|
862
|
+
.option("--peer-id <id>", "Peer id (DM number/id, group id, or channel id)")
|
|
863
|
+
.option("--guild-id <guildId>", "Discord guildId")
|
|
864
|
+
.option("--team-id <teamId>", "Slack teamId")
|
|
865
|
+
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
866
|
+
.action(async (options: any) => {
|
|
867
|
+
const agentId = String(options.agentId);
|
|
868
|
+
const match = parseMatchFromOptions(options);
|
|
869
|
+
if (!match?.channel) throw new Error("match.channel is required");
|
|
870
|
+
|
|
871
|
+
const res = await applyBindingSnippetsToOpenClawConfig(api, [{ agentId, match }]);
|
|
872
|
+
console.log(JSON.stringify(res, null, 2));
|
|
873
|
+
console.error("Binding written. Restart gateway if required for changes to take effect.");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
cmd
|
|
877
|
+
.command("unbind")
|
|
878
|
+
.description("Remove routing binding(s) from openclaw.json bindings[]")
|
|
879
|
+
.requiredOption("--channel <channel>", "Channel name")
|
|
880
|
+
.option("--agent-id <agentId>", "Optional agent id; when set, removes only bindings for this agent")
|
|
881
|
+
.option("--account-id <accountId>", "Channel accountId")
|
|
882
|
+
.option("--peer-kind <kind>", "Peer kind (dm|group|channel)")
|
|
883
|
+
.option("--peer-id <id>", "Peer id")
|
|
884
|
+
.option("--guild-id <guildId>", "Discord guildId")
|
|
885
|
+
.option("--team-id <teamId>", "Slack teamId")
|
|
886
|
+
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
887
|
+
.action(async (options: any) => {
|
|
888
|
+
const agentId = typeof options.agentId === "string" ? String(options.agentId) : undefined;
|
|
889
|
+
const match = parseMatchFromOptions(options);
|
|
890
|
+
if (!match?.channel) throw new Error("match.channel is required");
|
|
891
|
+
|
|
892
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
893
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
894
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
895
|
+
|
|
896
|
+
const res = removeBindingsInConfig(cfgObj, { agentId, match });
|
|
897
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
898
|
+
|
|
899
|
+
console.log(JSON.stringify({ ok: true, ...res }, null, 2));
|
|
900
|
+
console.error("Binding(s) removed. Restart gateway if required for changes to take effect.");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
cmd
|
|
904
|
+
.command("bindings")
|
|
905
|
+
.description("Show current bindings from openclaw config")
|
|
906
|
+
.action(async () => {
|
|
907
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
908
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
909
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
910
|
+
console.log(JSON.stringify(cfgObj.bindings ?? [], null, 2));
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
cmd
|
|
914
|
+
.command("migrate-team")
|
|
915
|
+
.description("Migrate a legacy team scaffold into the new workspace-<teamId> layout")
|
|
916
|
+
.requiredOption("--team-id <teamId>", "Team id (must end with -team)")
|
|
917
|
+
.option("--mode <mode>", "move|copy", "move")
|
|
918
|
+
.option("--dry-run", "Print the plan without writing anything", false)
|
|
919
|
+
.option("--overwrite", "Allow merging into an existing destination (dangerous)", false)
|
|
920
|
+
.action(async (options: any) => {
|
|
921
|
+
const teamId = String(options.teamId);
|
|
922
|
+
if (!teamId.endsWith("-team")) throw new Error("teamId must end with -team");
|
|
923
|
+
|
|
924
|
+
const mode = String(options.mode ?? "move");
|
|
925
|
+
if (mode !== "move" && mode !== "copy") throw new Error("--mode must be move|copy");
|
|
926
|
+
|
|
927
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
928
|
+
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
929
|
+
|
|
930
|
+
const legacyTeamDir = path.resolve(baseWorkspace, "teams", teamId);
|
|
931
|
+
const legacyAgentsDir = path.resolve(baseWorkspace, "agents");
|
|
932
|
+
|
|
933
|
+
const destTeamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
|
|
934
|
+
const destRolesDir = path.join(destTeamDir, "roles");
|
|
935
|
+
|
|
936
|
+
const exists = async (p: string) => fileExists(p);
|
|
937
|
+
|
|
938
|
+
// Build migration plan
|
|
939
|
+
const plan: any = {
|
|
940
|
+
teamId,
|
|
941
|
+
mode,
|
|
942
|
+
legacy: { teamDir: legacyTeamDir, agentsDir: legacyAgentsDir },
|
|
943
|
+
dest: { teamDir: destTeamDir, rolesDir: destRolesDir },
|
|
944
|
+
steps: [] as any[],
|
|
945
|
+
agentIds: [] as string[],
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const legacyTeamExists = await exists(legacyTeamDir);
|
|
949
|
+
if (!legacyTeamExists) {
|
|
950
|
+
throw new Error(`Legacy team directory not found: ${legacyTeamDir}`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const destExists = await exists(destTeamDir);
|
|
954
|
+
if (destExists && !options.overwrite) {
|
|
955
|
+
throw new Error(`Destination already exists: ${destTeamDir} (re-run with --overwrite to merge)`);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 1) Move/copy team shared workspace
|
|
959
|
+
plan.steps.push({ kind: "teamDir", from: legacyTeamDir, to: destTeamDir });
|
|
960
|
+
|
|
961
|
+
// 2) Move/copy each role agent directory into roles/<role>/
|
|
962
|
+
const legacyAgentsExist = await exists(legacyAgentsDir);
|
|
963
|
+
let legacyAgentFolders: string[] = [];
|
|
964
|
+
if (legacyAgentsExist) {
|
|
965
|
+
legacyAgentFolders = (await fs.readdir(legacyAgentsDir)).filter((x) => x.startsWith(`${teamId}-`));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
for (const folder of legacyAgentFolders) {
|
|
969
|
+
const agentId = folder;
|
|
970
|
+
const role = folder.slice((teamId + "-").length);
|
|
971
|
+
const from = path.join(legacyAgentsDir, folder);
|
|
972
|
+
const to = path.join(destRolesDir, role);
|
|
973
|
+
plan.agentIds.push(agentId);
|
|
974
|
+
plan.steps.push({ kind: "roleDir", agentId, role, from, to });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const dryRun = !!options.dryRun;
|
|
978
|
+
if (dryRun) {
|
|
979
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, plan }, null, 2));
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Helpers
|
|
984
|
+
const copyDirRecursive = async (src: string, dst: string) => {
|
|
985
|
+
await ensureDir(dst);
|
|
986
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
987
|
+
for (const ent of entries) {
|
|
988
|
+
const s = path.join(src, ent.name);
|
|
989
|
+
const d = path.join(dst, ent.name);
|
|
990
|
+
if (ent.isDirectory()) await copyDirRecursive(s, d);
|
|
991
|
+
else if (ent.isSymbolicLink()) {
|
|
992
|
+
const link = await fs.readlink(s);
|
|
993
|
+
await fs.symlink(link, d);
|
|
994
|
+
} else {
|
|
995
|
+
await ensureDir(path.dirname(d));
|
|
996
|
+
await fs.copyFile(s, d);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const removeDirRecursive = async (p: string) => {
|
|
1002
|
+
// node 25 supports fs.rm
|
|
1003
|
+
await fs.rm(p, { recursive: true, force: true });
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const moveDir = async (src: string, dst: string) => {
|
|
1007
|
+
await ensureDir(path.dirname(dst));
|
|
1008
|
+
try {
|
|
1009
|
+
await fs.rename(src, dst);
|
|
1010
|
+
} catch {
|
|
1011
|
+
// cross-device or existing: fallback to copy+remove
|
|
1012
|
+
await copyDirRecursive(src, dst);
|
|
1013
|
+
await removeDirRecursive(src);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// Execute plan
|
|
1018
|
+
if (mode === "copy") {
|
|
1019
|
+
await copyDirRecursive(legacyTeamDir, destTeamDir);
|
|
1020
|
+
} else {
|
|
1021
|
+
await moveDir(legacyTeamDir, destTeamDir);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Ensure roles dir exists
|
|
1025
|
+
await ensureDir(destRolesDir);
|
|
1026
|
+
|
|
1027
|
+
for (const step of plan.steps.filter((s: any) => s.kind === "roleDir")) {
|
|
1028
|
+
if (!(await exists(step.from))) continue;
|
|
1029
|
+
if (mode === "copy") await copyDirRecursive(step.from, step.to);
|
|
1030
|
+
else await moveDir(step.from, step.to);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Update config: set each team agent's workspace to destTeamDir (shared)
|
|
1034
|
+
const agentSnippets: AgentConfigSnippet[] = plan.agentIds.map((agentId: string) => ({
|
|
1035
|
+
id: agentId,
|
|
1036
|
+
workspace: destTeamDir,
|
|
1037
|
+
}));
|
|
1038
|
+
if (agentSnippets.length) {
|
|
1039
|
+
await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
console.log(JSON.stringify({ ok: true, migrated: teamId, destTeamDir, agentIds: plan.agentIds }, null, 2));
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
cmd
|
|
1046
|
+
.command("install-skill")
|
|
1047
|
+
.description(
|
|
1048
|
+
"Install a skill from ClawHub (confirmation-gated). Default: global (~/.openclaw/skills). Use --agent-id or --team-id for scoped installs.",
|
|
1049
|
+
)
|
|
1050
|
+
.argument("<skill>", "ClawHub skill slug (e.g. github)")
|
|
1051
|
+
.option("--yes", "Skip confirmation prompt")
|
|
1052
|
+
.option("--global", "Install into global shared skills (~/.openclaw/skills) (default when no scope flags)")
|
|
1053
|
+
.option("--agent-id <agentId>", "Install into a specific agent workspace (workspace-<agentId>)")
|
|
1054
|
+
.option("--team-id <teamId>", "Install into a team workspace (workspace-<teamId>)")
|
|
1055
|
+
.action(async (idOrSlug: string, options: any) => {
|
|
1056
|
+
const cfg = getCfg(api);
|
|
1057
|
+
|
|
1058
|
+
// Phase 1: accept skill slug directly.
|
|
1059
|
+
// If the arg matches a recipe id and the recipe declares skill deps, we install those deps.
|
|
1060
|
+
// (In the future we can add explicit mapping via frontmatter like skillSlug: <slug>.)
|
|
1061
|
+
let recipe: RecipeFrontmatter | null = null;
|
|
1062
|
+
try {
|
|
1063
|
+
const loaded = await loadRecipeById(api, idOrSlug);
|
|
1064
|
+
recipe = loaded.frontmatter;
|
|
1065
|
+
} catch {
|
|
1066
|
+
recipe = null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
1070
|
+
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
1071
|
+
|
|
1072
|
+
const stateDir = path.resolve(baseWorkspace, ".."); // ~/.openclaw
|
|
1073
|
+
|
|
1074
|
+
const scopeFlags = [options.global ? "global" : null, options.agentId ? "agent" : null, options.teamId ? "team" : null].filter(Boolean);
|
|
1075
|
+
if (scopeFlags.length > 1) {
|
|
1076
|
+
throw new Error("Use only one of: --global, --agent-id, --team-id");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const agentIdOpt = typeof options.agentId === "string" ? options.agentId.trim() : "";
|
|
1080
|
+
const teamIdOpt = typeof options.teamId === "string" ? options.teamId.trim() : "";
|
|
1081
|
+
|
|
1082
|
+
// Default is global install when no scope is provided.
|
|
1083
|
+
const scope = scopeFlags[0] ?? "global";
|
|
1084
|
+
|
|
1085
|
+
let workdir: string;
|
|
1086
|
+
let dirName: string;
|
|
1087
|
+
let installDir: string;
|
|
1088
|
+
|
|
1089
|
+
if (scope === "agent") {
|
|
1090
|
+
if (!agentIdOpt) throw new Error("--agent-id cannot be empty");
|
|
1091
|
+
const agentWorkspace = path.resolve(stateDir, `workspace-${agentIdOpt}`);
|
|
1092
|
+
workdir = agentWorkspace;
|
|
1093
|
+
dirName = cfg.workspaceSkillsDir;
|
|
1094
|
+
installDir = path.join(agentWorkspace, dirName);
|
|
1095
|
+
} else if (scope === "team") {
|
|
1096
|
+
if (!teamIdOpt) throw new Error("--team-id cannot be empty");
|
|
1097
|
+
const teamWorkspace = path.resolve(stateDir, `workspace-${teamIdOpt}`);
|
|
1098
|
+
workdir = teamWorkspace;
|
|
1099
|
+
dirName = cfg.workspaceSkillsDir;
|
|
1100
|
+
installDir = path.join(teamWorkspace, dirName);
|
|
1101
|
+
} else {
|
|
1102
|
+
workdir = stateDir;
|
|
1103
|
+
dirName = "skills";
|
|
1104
|
+
installDir = path.join(stateDir, dirName);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
await ensureDir(installDir);
|
|
1108
|
+
|
|
1109
|
+
const skillsToInstall = recipe
|
|
1110
|
+
? Array.from(new Set([...(recipe.requiredSkills ?? []), ...(recipe.optionalSkills ?? [])])).filter(Boolean)
|
|
1111
|
+
: [idOrSlug];
|
|
1112
|
+
|
|
1113
|
+
if (!skillsToInstall.length) {
|
|
1114
|
+
console.log(JSON.stringify({ ok: true, installed: [], note: "Nothing to install." }, null, 2));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const missing = await detectMissingSkills(installDir, skillsToInstall);
|
|
1119
|
+
const already = skillsToInstall.filter((s) => !missing.includes(s));
|
|
1120
|
+
|
|
1121
|
+
if (already.length) {
|
|
1122
|
+
console.error(`Already present in skills dir (${installDir}): ${already.join(", ")}`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (!missing.length) {
|
|
1126
|
+
console.log(JSON.stringify({ ok: true, installed: [], alreadyInstalled: already }, null, 2));
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const targetLabel = scope === "agent" ? `agent:${agentIdOpt}` : scope === "team" ? `team:${teamIdOpt}` : "global";
|
|
1131
|
+
const header = recipe
|
|
1132
|
+
? `Install skills for recipe ${recipe.id} into ${installDir} (${targetLabel})?\n- ${missing.join("\n- ")}`
|
|
1133
|
+
: `Install skill into ${installDir} (${targetLabel})?\n- ${missing.join("\n- ")}`;
|
|
1134
|
+
|
|
1135
|
+
const requireConfirm = !options.yes;
|
|
1136
|
+
if (requireConfirm) {
|
|
1137
|
+
if (!process.stdin.isTTY) {
|
|
1138
|
+
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
1139
|
+
process.exitCode = 2;
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const readline = await import("node:readline/promises");
|
|
1144
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1145
|
+
try {
|
|
1146
|
+
const ans = await rl.question(`${header}\nProceed? (y/N) `);
|
|
1147
|
+
const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
1148
|
+
if (!ok) {
|
|
1149
|
+
console.error("Aborted; nothing installed.");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
} finally {
|
|
1153
|
+
rl.close();
|
|
1154
|
+
}
|
|
1155
|
+
} else {
|
|
1156
|
+
console.error(header);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Use clawhub CLI. Force install path based on scope.
|
|
1160
|
+
const { spawnSync } = await import("node:child_process");
|
|
1161
|
+
for (const slug of missing) {
|
|
1162
|
+
const res = spawnSync(
|
|
1163
|
+
"npx",
|
|
1164
|
+
["clawhub@latest", "--workdir", workdir, "--dir", dirName, "install", slug],
|
|
1165
|
+
{ stdio: "inherit" },
|
|
1166
|
+
);
|
|
1167
|
+
if (res.status !== 0) {
|
|
1168
|
+
process.exitCode = res.status ?? 1;
|
|
1169
|
+
console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
console.log(
|
|
1175
|
+
JSON.stringify(
|
|
1176
|
+
{
|
|
1177
|
+
ok: true,
|
|
1178
|
+
installed: missing,
|
|
1179
|
+
installDir,
|
|
1180
|
+
next: `Try: openclaw skills list (or check ${installDir})`,
|
|
1181
|
+
},
|
|
1182
|
+
null,
|
|
1183
|
+
2,
|
|
1184
|
+
),
|
|
1185
|
+
);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
async function installMarketplaceRecipe(slug: string, options: any) {
|
|
1189
|
+
const cfg = getCfg(api);
|
|
1190
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
1191
|
+
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
1192
|
+
|
|
1193
|
+
const base = String(options.registryBase ?? "").replace(/\/+$/, "");
|
|
1194
|
+
const s = String(slug ?? "").trim();
|
|
1195
|
+
if (!s) throw new Error("slug is required");
|
|
1196
|
+
|
|
1197
|
+
const metaUrl = `${base}/api/marketplace/recipes/${encodeURIComponent(s)}`;
|
|
1198
|
+
const metaRes = await fetch(metaUrl);
|
|
1199
|
+
if (!metaRes.ok) {
|
|
1200
|
+
const hint = `Recipe not found: ${s}. Did you mean:\n- openclaw recipes install ${s} # marketplace recipe\n- openclaw recipes install-skill ${s} # ClawHub skill`;
|
|
1201
|
+
throw new Error(`Registry lookup failed (${metaRes.status}): ${metaUrl}\n\n${hint}`);
|
|
1202
|
+
}
|
|
1203
|
+
const metaData = (await metaRes.json()) as any;
|
|
1204
|
+
const recipe = metaData?.recipe;
|
|
1205
|
+
const sourceUrl = String(recipe?.sourceUrl ?? "").trim();
|
|
1206
|
+
if (!metaData?.ok || !sourceUrl) {
|
|
1207
|
+
throw new Error(`Registry response missing recipe.sourceUrl for ${s}`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const mdRes = await fetch(sourceUrl);
|
|
1211
|
+
if (!mdRes.ok) throw new Error(`Failed downloading recipe markdown (${mdRes.status}): ${sourceUrl}`);
|
|
1212
|
+
const md = await mdRes.text();
|
|
1213
|
+
|
|
1214
|
+
const recipesDir = path.join(baseWorkspace, cfg.workspaceRecipesDir);
|
|
1215
|
+
await ensureDir(recipesDir);
|
|
1216
|
+
const destPath = path.join(recipesDir, `${s}.md`);
|
|
1217
|
+
|
|
1218
|
+
await writeFileSafely(destPath, md, options.overwrite ? "overwrite" : "createOnly");
|
|
1219
|
+
|
|
1220
|
+
console.log(JSON.stringify({ ok: true, slug: s, wrote: destPath, sourceUrl, metaUrl }, null, 2));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
cmd
|
|
1224
|
+
.command("install")
|
|
1225
|
+
.description("Install a marketplace recipe into your workspace recipes dir (by slug)")
|
|
1226
|
+
.argument("<idOrSlug>", "Marketplace recipe slug (e.g. development-team)")
|
|
1227
|
+
.option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
|
|
1228
|
+
.option("--overwrite", "Overwrite existing recipe file")
|
|
1229
|
+
.action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
|
|
1230
|
+
|
|
1231
|
+
cmd
|
|
1232
|
+
.command("install-recipe")
|
|
1233
|
+
.description("Alias for: recipes install <slug>")
|
|
1234
|
+
.argument("<slug>", "Marketplace recipe slug (e.g. development-team)")
|
|
1235
|
+
.option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
|
|
1236
|
+
.option("--overwrite", "Overwrite existing recipe file")
|
|
1237
|
+
.action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
|
|
1238
|
+
|
|
1239
|
+
cmd
|
|
1240
|
+
.command("dispatch")
|
|
1241
|
+
.description("Lead/dispatcher: turn a natural-language request into inbox + backlog ticket(s) + assignment stubs")
|
|
1242
|
+
.requiredOption("--team-id <teamId>", "Team id (workspace folder under teams/)")
|
|
1243
|
+
.option("--request <text>", "Natural-language request (if omitted, will prompt in TTY)")
|
|
1244
|
+
.option("--owner <owner>", "Ticket owner: dev|devops|lead|test", "dev")
|
|
1245
|
+
.option("--yes", "Skip review and write files without prompting")
|
|
1246
|
+
.action(async (options: any) => {
|
|
1247
|
+
const cfg = getCfg(api);
|
|
1248
|
+
|
|
1249
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1250
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1251
|
+
|
|
1252
|
+
const teamId = String(options.teamId);
|
|
1253
|
+
// Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
|
|
1254
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1255
|
+
|
|
1256
|
+
const inboxDir = path.join(teamDir, "inbox");
|
|
1257
|
+
const backlogDir = path.join(teamDir, "work", "backlog");
|
|
1258
|
+
const assignmentsDir = path.join(teamDir, "work", "assignments");
|
|
1259
|
+
|
|
1260
|
+
const owner = String(options.owner ?? "dev");
|
|
1261
|
+
if (!['dev','devops','lead','test'].includes(owner)) {
|
|
1262
|
+
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const slugify = (s: string) =>
|
|
1266
|
+
s
|
|
1267
|
+
.toLowerCase()
|
|
1268
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1269
|
+
.replace(/(^-|-$)/g, "")
|
|
1270
|
+
.slice(0, 60) || "request";
|
|
1271
|
+
|
|
1272
|
+
const nowKey = () => {
|
|
1273
|
+
const d = new Date();
|
|
1274
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1275
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
const nextTicketNumber = async () => {
|
|
1279
|
+
const dirs = [
|
|
1280
|
+
backlogDir,
|
|
1281
|
+
path.join(teamDir, "work", "in-progress"),
|
|
1282
|
+
path.join(teamDir, "work", "testing"),
|
|
1283
|
+
path.join(teamDir, "work", "done"),
|
|
1284
|
+
];
|
|
1285
|
+
let max = 0;
|
|
1286
|
+
for (const dir of dirs) {
|
|
1287
|
+
if (!(await fileExists(dir))) continue;
|
|
1288
|
+
const files = await fs.readdir(dir);
|
|
1289
|
+
for (const f of files) {
|
|
1290
|
+
const m = f.match(/^(\d{4})-/);
|
|
1291
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return max + 1;
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
let requestText = typeof options.request === "string" ? options.request.trim() : "";
|
|
1298
|
+
if (!requestText) {
|
|
1299
|
+
if (!process.stdin.isTTY) {
|
|
1300
|
+
throw new Error("Missing --request in non-interactive mode");
|
|
1301
|
+
}
|
|
1302
|
+
const readline = await import("node:readline/promises");
|
|
1303
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1304
|
+
try {
|
|
1305
|
+
requestText = (await rl.question("Request: ")).trim();
|
|
1306
|
+
} finally {
|
|
1307
|
+
rl.close();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (!requestText) throw new Error("Request cannot be empty");
|
|
1311
|
+
|
|
1312
|
+
// Minimal heuristic: one ticket per request.
|
|
1313
|
+
const ticketNum = await nextTicketNumber();
|
|
1314
|
+
const ticketNumStr = String(ticketNum).padStart(4, '0');
|
|
1315
|
+
const title = requestText.length > 80 ? requestText.slice(0, 77) + "…" : requestText;
|
|
1316
|
+
const baseSlug = slugify(title);
|
|
1317
|
+
|
|
1318
|
+
const inboxPath = path.join(inboxDir, `${nowKey()}-${baseSlug}.md`);
|
|
1319
|
+
const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
|
|
1320
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
1321
|
+
|
|
1322
|
+
const receivedIso = new Date().toISOString();
|
|
1323
|
+
|
|
1324
|
+
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`;
|
|
1325
|
+
|
|
1326
|
+
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`;
|
|
1327
|
+
|
|
1328
|
+
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`;
|
|
1329
|
+
|
|
1330
|
+
const plan = {
|
|
1331
|
+
teamId,
|
|
1332
|
+
request: requestText,
|
|
1333
|
+
files: [
|
|
1334
|
+
{ path: inboxPath, kind: "inbox", summary: title },
|
|
1335
|
+
{ path: ticketPath, kind: "backlog-ticket", summary: title },
|
|
1336
|
+
{ path: assignmentPath, kind: "assignment", summary: owner },
|
|
1337
|
+
],
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const doWrite = async () => {
|
|
1341
|
+
await ensureDir(inboxDir);
|
|
1342
|
+
await ensureDir(backlogDir);
|
|
1343
|
+
await ensureDir(assignmentsDir);
|
|
1344
|
+
|
|
1345
|
+
// createOnly to avoid accidental overwrite
|
|
1346
|
+
await writeFileSafely(inboxPath, inboxMd, "createOnly");
|
|
1347
|
+
await writeFileSafely(ticketPath, ticketMd, "createOnly");
|
|
1348
|
+
await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
if (options.yes) {
|
|
1352
|
+
await doWrite();
|
|
1353
|
+
console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (!process.stdin.isTTY) {
|
|
1358
|
+
console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
|
|
1359
|
+
process.exitCode = 2;
|
|
1360
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1365
|
+
const readline = await import("node:readline/promises");
|
|
1366
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1367
|
+
try {
|
|
1368
|
+
const ans = await rl.question("Write these files? (y/N) ");
|
|
1369
|
+
const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
|
|
1370
|
+
if (!ok) {
|
|
1371
|
+
console.error("Aborted; no files written.");
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
} finally {
|
|
1375
|
+
rl.close();
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
await doWrite();
|
|
1379
|
+
console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
cmd
|
|
1383
|
+
.command("remove-team")
|
|
1384
|
+
.description("Safe uninstall: remove a scaffolded team workspace + agents + stamped cron jobs")
|
|
1385
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1386
|
+
.option("--plan", "Print plan and exit")
|
|
1387
|
+
.option("--json", "Output JSON")
|
|
1388
|
+
.option("--yes", "Skip confirmation (apply destructive changes)")
|
|
1389
|
+
.option("--include-ambiguous", "Also remove cron jobs that only loosely match the team (dangerous)")
|
|
1390
|
+
.action(async (options: any) => {
|
|
1391
|
+
const teamId = String(options.teamId);
|
|
1392
|
+
|
|
1393
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1394
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1395
|
+
|
|
1396
|
+
const cronJobsPath = path.resolve(workspaceRoot, "..", "cron", "jobs.json");
|
|
1397
|
+
|
|
1398
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
1399
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
1400
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
1401
|
+
|
|
1402
|
+
const cronStore = await loadCronStore(cronJobsPath);
|
|
1403
|
+
|
|
1404
|
+
const plan = await buildRemoveTeamPlan({
|
|
1405
|
+
teamId,
|
|
1406
|
+
workspaceRoot,
|
|
1407
|
+
openclawConfigPath: "(managed by api.runtime.config)",
|
|
1408
|
+
cronJobsPath,
|
|
1409
|
+
cfgObj,
|
|
1410
|
+
cronStore,
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
const wantsJson = Boolean(options.json);
|
|
1414
|
+
|
|
1415
|
+
if (options.plan) {
|
|
1416
|
+
console.log(JSON.stringify({ ok: true, plan }, null, 2));
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
1421
|
+
console.error("Refusing to prompt (non-interactive). Re-run with --yes or --plan.");
|
|
1422
|
+
process.exitCode = 2;
|
|
1423
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
1428
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1429
|
+
const ok = await promptYesNo(
|
|
1430
|
+
`This will DELETE workspace-${teamId}, remove matching agents from openclaw config, and remove stamped cron jobs.`,
|
|
1431
|
+
);
|
|
1432
|
+
if (!ok) {
|
|
1433
|
+
console.error("Aborted; no changes made.");
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const includeAmbiguous = Boolean(options.includeAmbiguous);
|
|
1439
|
+
|
|
1440
|
+
const result = await executeRemoveTeamPlan({
|
|
1441
|
+
plan,
|
|
1442
|
+
includeAmbiguous,
|
|
1443
|
+
cfgObj,
|
|
1444
|
+
cronStore,
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
1448
|
+
await saveCronStore(cronJobsPath, cronStore);
|
|
1449
|
+
|
|
1450
|
+
if (wantsJson) {
|
|
1451
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1452
|
+
} else {
|
|
1453
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1454
|
+
console.error("Restart required: openclaw gateway restart");
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
cmd
|
|
1459
|
+
.command("tickets")
|
|
1460
|
+
.description("List tickets for a team (backlog / in-progress / testing / done)")
|
|
1461
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1462
|
+
.option("--json", "Output JSON")
|
|
1463
|
+
.action(async (options: any) => {
|
|
1464
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1465
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1466
|
+
const teamId = String(options.teamId);
|
|
1467
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1468
|
+
|
|
1469
|
+
await ensureTicketStageDirs(teamDir);
|
|
1470
|
+
|
|
1471
|
+
const dirs = {
|
|
1472
|
+
backlog: path.join(teamDir, "work", "backlog"),
|
|
1473
|
+
inProgress: path.join(teamDir, "work", "in-progress"),
|
|
1474
|
+
testing: path.join(teamDir, "work", "testing"),
|
|
1475
|
+
done: path.join(teamDir, "work", "done"),
|
|
1476
|
+
} as const;
|
|
1477
|
+
|
|
1478
|
+
const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
|
|
1479
|
+
if (!(await fileExists(dir))) return [] as any[];
|
|
1480
|
+
const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
1481
|
+
return files.map((f) => {
|
|
1482
|
+
const m = f.match(/^(\d{4})-(.+)\.md$/);
|
|
1483
|
+
return {
|
|
1484
|
+
stage,
|
|
1485
|
+
number: m ? Number(m[1]) : null,
|
|
1486
|
+
id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
|
|
1487
|
+
file: path.join(dir, f),
|
|
1488
|
+
};
|
|
1489
|
+
});
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
const backlog = await readTickets(dirs.backlog, "backlog");
|
|
1493
|
+
const inProgress = await readTickets(dirs.inProgress, "in-progress");
|
|
1494
|
+
const testing = await readTickets(dirs.testing, "testing");
|
|
1495
|
+
const done = await readTickets(dirs.done, "done");
|
|
1496
|
+
|
|
1497
|
+
const out = {
|
|
1498
|
+
teamId,
|
|
1499
|
+
// Stable, machine-friendly list for consumers (watchers, dashboards)
|
|
1500
|
+
// Keep the per-lane arrays for backwards-compat.
|
|
1501
|
+
tickets: [...backlog, ...inProgress, ...testing, ...done],
|
|
1502
|
+
backlog,
|
|
1503
|
+
inProgress,
|
|
1504
|
+
testing,
|
|
1505
|
+
done,
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
if (options.json) {
|
|
1509
|
+
console.log(JSON.stringify(out, null, 2));
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const print = (label: string, items: any[]) => {
|
|
1514
|
+
console.log(`\n${label} (${items.length})`);
|
|
1515
|
+
for (const t of items) console.log(`- ${t.id}`);
|
|
1516
|
+
};
|
|
1517
|
+
console.log(`Team: ${teamId}`);
|
|
1518
|
+
print("Backlog", out.backlog);
|
|
1519
|
+
print("In progress", out.inProgress);
|
|
1520
|
+
print("Testing", out.testing);
|
|
1521
|
+
print("Done", out.done);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
cmd
|
|
1525
|
+
.command("move-ticket")
|
|
1526
|
+
.description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
|
|
1527
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1528
|
+
.requiredOption("--ticket <ticket>", "Ticket id or number (e.g. 0007 or 0007-some-slug)")
|
|
1529
|
+
.requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
|
|
1530
|
+
.option("--completed", "When moving to done, add Completed: timestamp")
|
|
1531
|
+
.option("--yes", "Skip confirmation")
|
|
1532
|
+
.action(async (options: any) => {
|
|
1533
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1534
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1535
|
+
const teamId = String(options.teamId);
|
|
1536
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1537
|
+
|
|
1538
|
+
await ensureTicketStageDirs(teamDir);
|
|
1539
|
+
|
|
1540
|
+
const dest = String(options.to);
|
|
1541
|
+
if (!['backlog','in-progress','testing','done'].includes(dest)) {
|
|
1542
|
+
throw new Error("--to must be one of: backlog, in-progress, testing, done");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const ticketArg = String(options.ticket);
|
|
1546
|
+
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1547
|
+
|
|
1548
|
+
const stageDir = (stage: string) => {
|
|
1549
|
+
if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
|
|
1550
|
+
if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
|
|
1551
|
+
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1552
|
+
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1553
|
+
throw new Error(`Unknown stage: ${stage}`);
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1557
|
+
|
|
1558
|
+
const findTicketFile = async () => {
|
|
1559
|
+
for (const dir of searchDirs) {
|
|
1560
|
+
if (!(await fileExists(dir))) continue;
|
|
1561
|
+
const files = await fs.readdir(dir);
|
|
1562
|
+
for (const f of files) {
|
|
1563
|
+
if (!f.endsWith('.md')) continue;
|
|
1564
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1565
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return null;
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
const srcPath = await findTicketFile();
|
|
1572
|
+
if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1573
|
+
|
|
1574
|
+
const destDir = stageDir(dest);
|
|
1575
|
+
await ensureDir(destDir);
|
|
1576
|
+
const filename = path.basename(srcPath);
|
|
1577
|
+
const destPath = path.join(destDir, filename);
|
|
1578
|
+
|
|
1579
|
+
const patchStatus = (md: string) => {
|
|
1580
|
+
const nextStatus =
|
|
1581
|
+
dest === 'backlog'
|
|
1582
|
+
? 'queued'
|
|
1583
|
+
: dest === 'in-progress'
|
|
1584
|
+
? 'in-progress'
|
|
1585
|
+
: dest === 'testing'
|
|
1586
|
+
? 'testing'
|
|
1587
|
+
: 'done';
|
|
1588
|
+
|
|
1589
|
+
let out = md;
|
|
1590
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
|
|
1591
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
|
|
1592
|
+
|
|
1593
|
+
if (dest === 'done' && options.completed) {
|
|
1594
|
+
const completed = new Date().toISOString();
|
|
1595
|
+
if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
|
|
1596
|
+
else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
return out;
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
const plan = { from: srcPath, to: destPath };
|
|
1603
|
+
|
|
1604
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
1605
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1606
|
+
const readline = await import('node:readline/promises');
|
|
1607
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1608
|
+
try {
|
|
1609
|
+
const ans = await rl.question(`Move ticket to ${dest}? (y/N) `);
|
|
1610
|
+
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1611
|
+
if (!ok) {
|
|
1612
|
+
console.error('Aborted; no changes made.');
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
} finally {
|
|
1616
|
+
rl.close();
|
|
1617
|
+
}
|
|
1618
|
+
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1619
|
+
console.error('Refusing to move without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1620
|
+
process.exitCode = 2;
|
|
1621
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const md = await fs.readFile(srcPath, 'utf8');
|
|
1626
|
+
const nextMd = patchStatus(md);
|
|
1627
|
+
await fs.writeFile(srcPath, nextMd, 'utf8');
|
|
1628
|
+
|
|
1629
|
+
if (srcPath !== destPath) {
|
|
1630
|
+
await fs.rename(srcPath, destPath);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
cmd
|
|
1637
|
+
.command("assign")
|
|
1638
|
+
.description("Assign a ticket to an owner (writes assignment stub + updates Owner: in ticket)")
|
|
1639
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1640
|
+
.requiredOption("--ticket <ticket>", "Ticket id or number (e.g. 0007 or 0007-some-slug)")
|
|
1641
|
+
.requiredOption("--owner <owner>", "Owner: dev|devops|lead|test")
|
|
1642
|
+
.option("--overwrite", "Overwrite existing assignment file")
|
|
1643
|
+
.option("--yes", "Skip confirmation")
|
|
1644
|
+
.action(async (options: any) => {
|
|
1645
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1646
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1647
|
+
const teamId = String(options.teamId);
|
|
1648
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1649
|
+
|
|
1650
|
+
await ensureTicketStageDirs(teamDir);
|
|
1651
|
+
|
|
1652
|
+
const owner = String(options.owner);
|
|
1653
|
+
if (!['dev','devops','lead','test'].includes(owner)) {
|
|
1654
|
+
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const stageDir = (stage: string) => {
|
|
1658
|
+
if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
|
|
1659
|
+
if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
|
|
1660
|
+
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1661
|
+
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1662
|
+
throw new Error(`Unknown stage: ${stage}`);
|
|
1663
|
+
};
|
|
1664
|
+
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1665
|
+
|
|
1666
|
+
const ticketArg = String(options.ticket);
|
|
1667
|
+
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1668
|
+
|
|
1669
|
+
const findTicketFile = async () => {
|
|
1670
|
+
for (const dir of searchDirs) {
|
|
1671
|
+
if (!(await fileExists(dir))) continue;
|
|
1672
|
+
const files = await fs.readdir(dir);
|
|
1673
|
+
for (const f of files) {
|
|
1674
|
+
if (!f.endsWith('.md')) continue;
|
|
1675
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1676
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return null;
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
const ticketPath = await findTicketFile();
|
|
1683
|
+
if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1684
|
+
|
|
1685
|
+
const filename = path.basename(ticketPath);
|
|
1686
|
+
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
1687
|
+
const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
|
|
1688
|
+
const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
|
|
1689
|
+
|
|
1690
|
+
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
1691
|
+
await ensureDir(assignmentsDir);
|
|
1692
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
1693
|
+
|
|
1694
|
+
const patchOwner = (md: string) => {
|
|
1695
|
+
if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
|
|
1696
|
+
return md.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
const plan = { ticketPath, assignmentPath, owner };
|
|
1700
|
+
|
|
1701
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
1702
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1703
|
+
const readline = await import('node:readline/promises');
|
|
1704
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1705
|
+
try {
|
|
1706
|
+
const ans = await rl.question(`Assign ticket to ${owner}? (y/N) `);
|
|
1707
|
+
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1708
|
+
if (!ok) {
|
|
1709
|
+
console.error('Aborted; no changes made.');
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
} finally {
|
|
1713
|
+
rl.close();
|
|
1714
|
+
}
|
|
1715
|
+
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1716
|
+
console.error('Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1717
|
+
process.exitCode = 2;
|
|
1718
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
const md = await fs.readFile(ticketPath, 'utf8');
|
|
1723
|
+
const nextMd = patchOwner(md);
|
|
1724
|
+
await fs.writeFile(ticketPath, nextMd, 'utf8');
|
|
1725
|
+
|
|
1726
|
+
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes assign\n`;
|
|
1727
|
+
await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
|
|
1728
|
+
|
|
1729
|
+
console.log(JSON.stringify({ ok: true, plan }, null, 2));
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
cmd
|
|
1733
|
+
.command("take")
|
|
1734
|
+
.description("Shortcut: assign ticket to owner + move to in-progress")
|
|
1735
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1736
|
+
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
1737
|
+
.option("--owner <owner>", "Owner: dev|devops|lead|test", "dev")
|
|
1738
|
+
.option("--yes", "Skip confirmation")
|
|
1739
|
+
.action(async (options: any) => {
|
|
1740
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1741
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1742
|
+
const teamId = String(options.teamId);
|
|
1743
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1744
|
+
|
|
1745
|
+
await ensureTicketStageDirs(teamDir);
|
|
1746
|
+
|
|
1747
|
+
const owner = String(options.owner ?? 'dev');
|
|
1748
|
+
if (!['dev','devops','lead','test'].includes(owner)) {
|
|
1749
|
+
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const stageDir = (stage: string) => {
|
|
1753
|
+
if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
|
|
1754
|
+
if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
|
|
1755
|
+
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1756
|
+
if (stage === 'done') return path.join(teamDir, 'work', 'done');
|
|
1757
|
+
throw new Error(`Unknown stage: ${stage}`);
|
|
1758
|
+
};
|
|
1759
|
+
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
1760
|
+
|
|
1761
|
+
const ticketArg = String(options.ticket);
|
|
1762
|
+
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1763
|
+
|
|
1764
|
+
const findTicketFile = async () => {
|
|
1765
|
+
for (const dir of searchDirs) {
|
|
1766
|
+
if (!(await fileExists(dir))) continue;
|
|
1767
|
+
const files = await fs.readdir(dir);
|
|
1768
|
+
for (const f of files) {
|
|
1769
|
+
if (!f.endsWith('.md')) continue;
|
|
1770
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1771
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return null;
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
const srcPath = await findTicketFile();
|
|
1778
|
+
if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
|
|
1779
|
+
|
|
1780
|
+
const destDir = stageDir('in-progress');
|
|
1781
|
+
await ensureDir(destDir);
|
|
1782
|
+
const filename = path.basename(srcPath);
|
|
1783
|
+
const destPath = path.join(destDir, filename);
|
|
1784
|
+
|
|
1785
|
+
const patch = (md: string) => {
|
|
1786
|
+
let out = md;
|
|
1787
|
+
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
|
|
1788
|
+
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
|
|
1789
|
+
|
|
1790
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
|
|
1791
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
|
|
1792
|
+
|
|
1793
|
+
return out;
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
const plan = { from: srcPath, to: destPath, owner };
|
|
1797
|
+
|
|
1798
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
1799
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1800
|
+
const readline = await import('node:readline/promises');
|
|
1801
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1802
|
+
try {
|
|
1803
|
+
const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
|
|
1804
|
+
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1805
|
+
if (!ok) {
|
|
1806
|
+
console.error('Aborted; no changes made.');
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
} finally {
|
|
1810
|
+
rl.close();
|
|
1811
|
+
}
|
|
1812
|
+
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1813
|
+
console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1814
|
+
process.exitCode = 2;
|
|
1815
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const md = await fs.readFile(srcPath, 'utf8');
|
|
1820
|
+
const nextMd = patch(md);
|
|
1821
|
+
await fs.writeFile(srcPath, nextMd, 'utf8');
|
|
1822
|
+
|
|
1823
|
+
if (srcPath !== destPath) {
|
|
1824
|
+
await fs.rename(srcPath, destPath);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
1828
|
+
const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
|
|
1829
|
+
const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
|
|
1830
|
+
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
1831
|
+
await ensureDir(assignmentsDir);
|
|
1832
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
1833
|
+
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`;
|
|
1834
|
+
await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
|
|
1835
|
+
|
|
1836
|
+
console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
cmd
|
|
1840
|
+
.command("handoff")
|
|
1841
|
+
.description("QA handoff: move ticket to testing + assign to tester")
|
|
1842
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1843
|
+
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
1844
|
+
.option("--tester <owner>", "Tester owner (default: test)", "test")
|
|
1845
|
+
.option("--overwrite", "Overwrite destination ticket file / assignment stub if they already exist")
|
|
1846
|
+
.option("--yes", "Skip confirmation")
|
|
1847
|
+
.action(async (options: any) => {
|
|
1848
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
1849
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
1850
|
+
const teamId = String(options.teamId);
|
|
1851
|
+
const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
|
|
1852
|
+
|
|
1853
|
+
const tester = String(options.tester ?? "test");
|
|
1854
|
+
if (!['dev','devops','lead','test'].includes(tester)) {
|
|
1855
|
+
throw new Error("--tester must be one of: dev, devops, lead, test");
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const stageDir = (stage: string) => {
|
|
1859
|
+
if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
|
|
1860
|
+
if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
|
|
1861
|
+
throw new Error(`Unknown stage: ${stage}`);
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
const ticketArg = String(options.ticket);
|
|
1865
|
+
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
1866
|
+
|
|
1867
|
+
const findTicketFile = async (dir: string) => {
|
|
1868
|
+
if (!(await fileExists(dir))) return null;
|
|
1869
|
+
const files = await fs.readdir(dir);
|
|
1870
|
+
for (const f of files) {
|
|
1871
|
+
if (!f.endsWith('.md')) continue;
|
|
1872
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
1873
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
1874
|
+
}
|
|
1875
|
+
return null;
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
const inProgressDir = stageDir('in-progress');
|
|
1879
|
+
const testingDir = stageDir('testing');
|
|
1880
|
+
await ensureDir(testingDir);
|
|
1881
|
+
|
|
1882
|
+
const srcInProgress = await findTicketFile(inProgressDir);
|
|
1883
|
+
const srcTesting = await findTicketFile(testingDir);
|
|
1884
|
+
|
|
1885
|
+
if (!srcInProgress && !srcTesting) {
|
|
1886
|
+
throw new Error(`Ticket not found in in-progress/testing: ${ticketArg}`);
|
|
1887
|
+
}
|
|
1888
|
+
if (!srcInProgress && srcTesting) {
|
|
1889
|
+
// already in testing (idempotent path)
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const srcPath = srcInProgress ?? srcTesting!;
|
|
1893
|
+
const filename = path.basename(srcPath);
|
|
1894
|
+
const destPath = path.join(testingDir, filename);
|
|
1895
|
+
|
|
1896
|
+
const patch = (md: string) => {
|
|
1897
|
+
let out = md;
|
|
1898
|
+
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${tester}`);
|
|
1899
|
+
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${tester}\n`);
|
|
1900
|
+
|
|
1901
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: testing`);
|
|
1902
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
|
|
1903
|
+
|
|
1904
|
+
return out;
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
const plan = {
|
|
1908
|
+
from: srcPath,
|
|
1909
|
+
to: destPath,
|
|
1910
|
+
tester,
|
|
1911
|
+
note: srcTesting ? 'already-in-testing' : 'move-to-testing',
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
if (!options.yes && process.stdin.isTTY) {
|
|
1915
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
1916
|
+
const readline = await import('node:readline/promises');
|
|
1917
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1918
|
+
try {
|
|
1919
|
+
const ans = await rl.question(`Move to testing + assign to ${tester}? (y/N) `);
|
|
1920
|
+
const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
|
|
1921
|
+
if (!ok) {
|
|
1922
|
+
console.error('Aborted; no changes made.');
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
} finally {
|
|
1926
|
+
rl.close();
|
|
1927
|
+
}
|
|
1928
|
+
} else if (!options.yes && !process.stdin.isTTY) {
|
|
1929
|
+
console.error('Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.');
|
|
1930
|
+
process.exitCode = 2;
|
|
1931
|
+
console.log(JSON.stringify({ ok: false, plan }, null, 2));
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (srcInProgress && srcPath !== destPath) {
|
|
1936
|
+
if (!options.overwrite && (await fileExists(destPath))) {
|
|
1937
|
+
throw new Error(`Destination exists: ${destPath} (re-run with --overwrite to replace)`);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const md = await fs.readFile(srcPath, 'utf8');
|
|
1942
|
+
const nextMd = patch(md);
|
|
1943
|
+
await fs.writeFile(srcPath, nextMd, 'utf8');
|
|
1944
|
+
|
|
1945
|
+
if (srcInProgress && srcPath !== destPath) {
|
|
1946
|
+
if (options.overwrite && (await fileExists(destPath))) {
|
|
1947
|
+
await fs.rm(destPath);
|
|
1948
|
+
}
|
|
1949
|
+
await fs.rename(srcPath, destPath);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
1953
|
+
const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
|
|
1954
|
+
const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
|
|
1955
|
+
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
1956
|
+
await ensureDir(assignmentsDir);
|
|
1957
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${tester}.md`);
|
|
1958
|
+
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`;
|
|
1959
|
+
await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
|
|
1960
|
+
|
|
1961
|
+
console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
cmd
|
|
1965
|
+
.command("complete")
|
|
1966
|
+
.description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp)")
|
|
1967
|
+
.requiredOption("--team-id <teamId>", "Team id")
|
|
1968
|
+
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
1969
|
+
.option("--yes", "Skip confirmation")
|
|
1970
|
+
.action(async (options: any) => {
|
|
1971
|
+
const args = [
|
|
1972
|
+
'recipes',
|
|
1973
|
+
'move-ticket',
|
|
1974
|
+
'--team-id',
|
|
1975
|
+
String(options.teamId),
|
|
1976
|
+
'--ticket',
|
|
1977
|
+
String(options.ticket),
|
|
1978
|
+
'--to',
|
|
1979
|
+
'done',
|
|
1980
|
+
'--completed',
|
|
1981
|
+
];
|
|
1982
|
+
if (options.yes) args.push('--yes');
|
|
1983
|
+
|
|
1984
|
+
const { spawnSync } = await import('node:child_process');
|
|
1985
|
+
const res = spawnSync('openclaw', args, { stdio: 'inherit' });
|
|
1986
|
+
if (res.status !== 0) {
|
|
1987
|
+
process.exitCode = res.status ?? 1;
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
cmd
|
|
1992
|
+
.command("scaffold")
|
|
1993
|
+
.description("Scaffold an agent from a recipe")
|
|
1994
|
+
.argument("<recipeId>", "Recipe id")
|
|
1995
|
+
.requiredOption("--agent-id <id>", "Agent id")
|
|
1996
|
+
.option("--name <name>", "Agent display name")
|
|
1997
|
+
.option("--overwrite", "Overwrite existing recipe-managed files")
|
|
1998
|
+
.option("--apply-config", "Write the agent into openclaw config (agents.list)")
|
|
1999
|
+
.action(async (recipeId: string, options: any) => {
|
|
2000
|
+
const loaded = await loadRecipeById(api, recipeId);
|
|
2001
|
+
const recipe = loaded.frontmatter;
|
|
2002
|
+
if ((recipe.kind ?? "agent") !== "agent") {
|
|
2003
|
+
throw new Error(`Recipe is not an agent recipe: kind=${recipe.kind}`);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const cfg = getCfg(api);
|
|
2007
|
+
const workspaceRoot = api.config.agents?.defaults?.workspace;
|
|
2008
|
+
if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
|
|
2009
|
+
const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
|
|
2010
|
+
const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
|
|
2011
|
+
if (missing.length) {
|
|
2012
|
+
console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
|
|
2013
|
+
console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
|
|
2014
|
+
process.exitCode = 2;
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace ?? "~/.openclaw/workspace";
|
|
2019
|
+
// Put standalone agent workspaces alongside the default workspace (same parent dir).
|
|
2020
|
+
const resolvedWorkspaceRoot = path.resolve(baseWorkspace, "..", `workspace-${options.agentId}`);
|
|
2021
|
+
|
|
2022
|
+
const result = await scaffoldAgentFromRecipe(api, recipe, {
|
|
2023
|
+
agentId: options.agentId,
|
|
2024
|
+
agentName: options.name,
|
|
2025
|
+
update: !!options.overwrite,
|
|
2026
|
+
filesRootDir: resolvedWorkspaceRoot,
|
|
2027
|
+
workspaceRootDir: resolvedWorkspaceRoot,
|
|
2028
|
+
vars: {
|
|
2029
|
+
agentId: options.agentId,
|
|
2030
|
+
agentName: options.name ?? recipe.name ?? options.agentId,
|
|
2031
|
+
},
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
if (options.applyConfig) {
|
|
2035
|
+
await applyAgentSnippetsToOpenClawConfig(api, [result.next.configSnippet]);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const cron = await reconcileRecipeCronJobs({
|
|
2039
|
+
recipe,
|
|
2040
|
+
scope: { kind: "agent", agentId: String(options.agentId), recipeId: recipe.id, stateDir: resolvedWorkspaceRoot },
|
|
2041
|
+
cronInstallation: getCfg(api).cronInstallation,
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
console.log(JSON.stringify({ ...result, cron }, null, 2));
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
cmd
|
|
2048
|
+
.command("scaffold-team")
|
|
2049
|
+
.description("Scaffold a team (shared workspace + multiple agents) from a team recipe")
|
|
2050
|
+
.argument("<recipeId>", "Recipe id")
|
|
2051
|
+
.requiredOption("-t, --team-id <teamId>", "Team id (must end with -team)")
|
|
2052
|
+
.option("--overwrite", "Overwrite existing recipe-managed files")
|
|
2053
|
+
.option("--apply-config", "Write all team agents into openclaw config (agents.list)")
|
|
2054
|
+
.action(async (recipeId: string, options: any) => {
|
|
2055
|
+
const loaded = await loadRecipeById(api, recipeId);
|
|
2056
|
+
const recipe = loaded.frontmatter;
|
|
2057
|
+
if ((recipe.kind ?? "team") !== "team") {
|
|
2058
|
+
throw new Error(`Recipe is not a team recipe: kind=${recipe.kind}`);
|
|
2059
|
+
}
|
|
2060
|
+
const teamId = String(options.teamId);
|
|
2061
|
+
if (!teamId.endsWith("-team")) {
|
|
2062
|
+
throw new Error("teamId must end with -team");
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const cfg = getCfg(api);
|
|
2066
|
+
const baseWorkspace = api.config.agents?.defaults?.workspace;
|
|
2067
|
+
if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
|
|
2068
|
+
const installDir = path.join(baseWorkspace, cfg.workspaceSkillsDir);
|
|
2069
|
+
const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
|
|
2070
|
+
if (missing.length) {
|
|
2071
|
+
console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
|
|
2072
|
+
console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
|
|
2073
|
+
process.exitCode = 2;
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
|
|
2078
|
+
const teamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
|
|
2079
|
+
await ensureDir(teamDir);
|
|
2080
|
+
|
|
2081
|
+
const rolesDir = path.join(teamDir, "roles");
|
|
2082
|
+
await ensureDir(rolesDir);
|
|
2083
|
+
const notesDir = path.join(teamDir, "notes");
|
|
2084
|
+
const workDir = path.join(teamDir, "work");
|
|
2085
|
+
const backlogDir = path.join(workDir, "backlog");
|
|
2086
|
+
const inProgressDir = path.join(workDir, "in-progress");
|
|
2087
|
+
const testingDir = path.join(workDir, "testing");
|
|
2088
|
+
const doneDir = path.join(workDir, "done");
|
|
2089
|
+
const assignmentsDir = path.join(workDir, "assignments");
|
|
2090
|
+
|
|
2091
|
+
// Seed standard team files (createOnly unless --overwrite)
|
|
2092
|
+
const overwrite = !!options.overwrite;
|
|
2093
|
+
|
|
2094
|
+
const sharedContextDir = path.join(teamDir, "shared-context");
|
|
2095
|
+
const sharedContextOutputsDir = path.join(sharedContextDir, "agent-outputs");
|
|
2096
|
+
const sharedContextFeedbackDir = path.join(sharedContextDir, "feedback");
|
|
2097
|
+
const sharedContextKpisDir = path.join(sharedContextDir, "kpis");
|
|
2098
|
+
const sharedContextCalendarDir = path.join(sharedContextDir, "calendar");
|
|
2099
|
+
|
|
2100
|
+
await Promise.all([
|
|
2101
|
+
// Back-compat: keep existing shared/ folder, but shared-context/ is canonical going forward.
|
|
2102
|
+
ensureDir(path.join(teamDir, "shared")),
|
|
2103
|
+
ensureDir(sharedContextDir),
|
|
2104
|
+
ensureDir(sharedContextOutputsDir),
|
|
2105
|
+
ensureDir(sharedContextFeedbackDir),
|
|
2106
|
+
ensureDir(sharedContextKpisDir),
|
|
2107
|
+
ensureDir(sharedContextCalendarDir),
|
|
2108
|
+
ensureDir(path.join(teamDir, "inbox")),
|
|
2109
|
+
ensureDir(path.join(teamDir, "outbox")),
|
|
2110
|
+
ensureDir(notesDir),
|
|
2111
|
+
ensureDir(workDir),
|
|
2112
|
+
ensureDir(backlogDir),
|
|
2113
|
+
ensureDir(inProgressDir),
|
|
2114
|
+
ensureDir(testingDir),
|
|
2115
|
+
ensureDir(doneDir),
|
|
2116
|
+
ensureDir(assignmentsDir),
|
|
2117
|
+
]);
|
|
2118
|
+
|
|
2119
|
+
// Seed shared-context starter schema (createOnly unless --overwrite)
|
|
2120
|
+
const sharedPrioritiesPath = path.join(sharedContextDir, "priorities.md");
|
|
2121
|
+
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`;
|
|
2122
|
+
await writeFileSafely(sharedPrioritiesPath, prioritiesMd, overwrite ? "overwrite" : "createOnly");
|
|
2123
|
+
|
|
2124
|
+
const planPath = path.join(notesDir, "plan.md");
|
|
2125
|
+
const statusPath = path.join(notesDir, "status.md");
|
|
2126
|
+
const ticketsPath = path.join(teamDir, "TICKETS.md");
|
|
2127
|
+
|
|
2128
|
+
const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
|
|
2129
|
+
const statusMd = `# Status — ${teamId}\n\n- (empty)\n`;
|
|
2130
|
+
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`;
|
|
2131
|
+
|
|
2132
|
+
await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
|
|
2133
|
+
await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
|
|
2134
|
+
await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
|
|
2135
|
+
|
|
2136
|
+
const agents = recipe.agents ?? [];
|
|
2137
|
+
if (!agents.length) throw new Error("Team recipe must include agents[]");
|
|
2138
|
+
|
|
2139
|
+
const results: any[] = [];
|
|
2140
|
+
for (const a of agents) {
|
|
2141
|
+
const role = a.role;
|
|
2142
|
+
const agentId = a.agentId ?? `${teamId}-${role}`;
|
|
2143
|
+
const agentName = a.name ?? `${teamId} ${role}`;
|
|
2144
|
+
|
|
2145
|
+
// For team recipes, we namespace template keys like: "lead.soul".
|
|
2146
|
+
const scopedRecipe: RecipeFrontmatter = {
|
|
2147
|
+
id: `${recipe.id}:${role}`,
|
|
2148
|
+
name: agentName,
|
|
2149
|
+
kind: "agent",
|
|
2150
|
+
requiredSkills: recipe.requiredSkills,
|
|
2151
|
+
optionalSkills: recipe.optionalSkills,
|
|
2152
|
+
templates: recipe.templates,
|
|
2153
|
+
files: (recipe.files ?? []).map((f) => ({
|
|
2154
|
+
...f,
|
|
2155
|
+
template: f.template.includes(".") ? f.template : `${role}.${f.template}`,
|
|
2156
|
+
})),
|
|
2157
|
+
tools: a.tools ?? recipe.tools,
|
|
2158
|
+
};
|
|
2159
|
+
|
|
2160
|
+
const roleDir = path.join(rolesDir, role);
|
|
2161
|
+
const r = await scaffoldAgentFromRecipe(api, scopedRecipe, {
|
|
2162
|
+
agentId,
|
|
2163
|
+
agentName,
|
|
2164
|
+
update: !!options.overwrite,
|
|
2165
|
+
// Write role-specific files under roles/<role>/
|
|
2166
|
+
filesRootDir: roleDir,
|
|
2167
|
+
// But set the agent workspace root to the shared team workspace
|
|
2168
|
+
workspaceRootDir: teamDir,
|
|
2169
|
+
vars: {
|
|
2170
|
+
teamId,
|
|
2171
|
+
teamDir,
|
|
2172
|
+
role,
|
|
2173
|
+
agentId,
|
|
2174
|
+
agentName,
|
|
2175
|
+
roleDir,
|
|
2176
|
+
},
|
|
2177
|
+
});
|
|
2178
|
+
results.push({ role, agentId, ...r });
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Create a minimal TEAM.md
|
|
2182
|
+
const teamMdPath = path.join(teamDir, "TEAM.md");
|
|
2183
|
+
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`;
|
|
2184
|
+
await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
|
|
2185
|
+
|
|
2186
|
+
if (options.applyConfig) {
|
|
2187
|
+
const snippets: AgentConfigSnippet[] = results.map((x: any) => x.next.configSnippet);
|
|
2188
|
+
await applyAgentSnippetsToOpenClawConfig(api, snippets);
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const cron = await reconcileRecipeCronJobs({
|
|
2192
|
+
recipe,
|
|
2193
|
+
scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
|
|
2194
|
+
cronInstallation: getCfg(api).cronInstallation,
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
console.log(
|
|
2198
|
+
JSON.stringify(
|
|
2199
|
+
{
|
|
2200
|
+
teamId,
|
|
2201
|
+
teamDir,
|
|
2202
|
+
agents: results,
|
|
2203
|
+
cron,
|
|
2204
|
+
next: {
|
|
2205
|
+
note:
|
|
2206
|
+
options.applyConfig
|
|
2207
|
+
? "agents.list[] updated in openclaw config"
|
|
2208
|
+
: "Run again with --apply-config to write agents into openclaw config.",
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
null,
|
|
2212
|
+
2,
|
|
2213
|
+
),
|
|
2214
|
+
);
|
|
2215
|
+
});
|
|
2216
|
+
},
|
|
2217
|
+
{ commands: ["recipes"] },
|
|
2218
|
+
);
|
|
2219
|
+
},
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
// Internal helpers used by unit tests. Not part of the public plugin API.
|
|
2223
|
+
export const __internal = {
|
|
2224
|
+
ensureMainFirstInAgentsList,
|
|
2225
|
+
upsertBindingInConfig,
|
|
2226
|
+
removeBindingsInConfig,
|
|
2227
|
+
stableStringify,
|
|
2228
|
+
|
|
2229
|
+
patchTicketField(md: string, key: string, value: string) {
|
|
2230
|
+
const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
|
|
2231
|
+
if (md.match(lineRe)) return md.replace(lineRe, `${key}: ${value}`);
|
|
2232
|
+
return md.replace(/^(# .+\n)/, `$1\n${key}: ${value}\n`);
|
|
2233
|
+
},
|
|
2234
|
+
|
|
2235
|
+
patchTicketOwner(md: string, owner: string) {
|
|
2236
|
+
return this.patchTicketField(md, "Owner", owner);
|
|
2237
|
+
},
|
|
2238
|
+
|
|
2239
|
+
patchTicketStatus(md: string, status: string) {
|
|
2240
|
+
return this.patchTicketField(md, "Status", status);
|
|
2241
|
+
},
|
|
2242
|
+
};
|
|
2243
|
+
|
|
2244
|
+
export default recipesPlugin;
|