import { randomUUID } from "node:crypto"; import { Type } from "typebox"; import { jsonResult, type OpenClawPluginApi } from "../api.js"; import type { SkillWorkshopConfig } from "./config.js"; import { applyProposalToWorkspace, normalizeSkillName, writeSupportFile } from "./skills.js"; import type { SkillChange, SkillProposal, SkillWorkshopStatus } from "./types.js"; import { applyOrStoreProposal, createStoreForContext, resolveWorkspaceDir } from "./workshop.js"; type ToolParams = { action?: string; id?: string; status?: SkillWorkshopStatus; skillName?: string; title?: string; reason?: string; description?: string; body?: string; section?: string; oldText?: string; newText?: string; relativePath?: string; apply?: boolean; }; function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function buildProposal(params: { workspaceDir: string; raw: ToolParams; source: "tool"; }): SkillProposal { const skillName = normalizeSkillName(readString(params.raw.skillName) ?? ""); if (!skillName) { throw new Error("skillName required"); } const now = Date.now(); const title = readString(params.raw.title) ?? `Skill update: ${skillName}`; const reason = readString(params.raw.reason) ?? "Tool-created skill update"; const body = readString(params.raw.body); const description = readString(params.raw.description) ?? title; let change: SkillChange; if (params.raw.oldText !== undefined || params.raw.newText !== undefined) { const oldText = readString(params.raw.oldText); const newText = readString(params.raw.newText); if (!oldText || !newText) { throw new Error("oldText and newText required for replace"); } change = { kind: "replace", oldText, newText }; } else if (readString(params.raw.section)) { if (!body) { throw new Error("body required"); } change = { kind: "append", section: readString(params.raw.section) ?? "Workflow", body, description, }; } else { if (!body) { throw new Error("body required"); } change = { kind: "create", description, body }; } return { id: randomUUID(), createdAt: now, updatedAt: now, workspaceDir: params.workspaceDir, skillName, title, reason, source: params.source, status: "pending", change, }; } export function createSkillWorkshopTool(params: { api: OpenClawPluginApi; config: SkillWorkshopConfig; ctx: { workspaceDir?: string }; }) { return { name: "skill_workshop", label: "Skill Workshop", description: "Create, queue, inspect, approve, or safely apply workspace skill updates for repeatable workflows.", parameters: Type.Object({ action: Type.String({ enum: [ "status", "list_pending", "list_quarantine", "inspect", "suggest", "apply", "reject", "write_support_file", ], }), id: Type.Optional(Type.String()), status: Type.Optional( Type.String({ enum: ["pending", "applied", "rejected", "quarantined"] }), ), skillName: Type.Optional(Type.String()), title: Type.Optional(Type.String()), reason: Type.Optional(Type.String()), description: Type.Optional(Type.String()), body: Type.Optional(Type.String()), section: Type.Optional(Type.String()), oldText: Type.Optional(Type.String()), newText: Type.Optional(Type.String()), relativePath: Type.Optional(Type.String()), apply: Type.Optional(Type.Boolean()), }), async execute(_toolCallId: string, rawParams: Record) { const raw = rawParams as ToolParams; const action = raw.action ?? "status"; const workspaceDir = resolveWorkspaceDir(params); const store = createStoreForContext(params); if (action === "status") { const all = await store.list(); return jsonResult({ workspaceDir, pending: all.filter((item) => item.status === "pending").length, quarantined: all.filter((item) => item.status === "quarantined").length, applied: all.filter((item) => item.status === "applied").length, rejected: all.filter((item) => item.status === "rejected").length, }); } if (action === "list_pending") { return jsonResult(await store.list(raw.status ?? "pending")); } if (action === "list_quarantine") { return jsonResult(await store.list("quarantined")); } if (action === "inspect") { if (!raw.id) { throw new Error("id required"); } return jsonResult(await store.get(raw.id)); } if (action === "suggest") { const proposal = buildProposal({ workspaceDir, raw, source: "tool" }); const result = await applyOrStoreProposal({ proposal, store, config: params.config, workspaceDir, skipAutoApply: raw.apply === false, }); return jsonResult(result); } if (action === "apply") { if (!raw.id) { throw new Error("id required"); } const proposal = await store.get(raw.id); if (!proposal) { throw new Error(`proposal not found: ${raw.id}`); } if (proposal.status === "quarantined") { throw new Error("quarantined proposal cannot be applied"); } const applied = await applyProposalToWorkspace({ proposal, maxSkillBytes: params.config.maxSkillBytes, }); const updated = await store.updateStatus(raw.id, "applied"); return jsonResult({ status: "applied", skillPath: applied.skillPath, proposal: updated }); } if (action === "reject") { if (!raw.id) { throw new Error("id required"); } return jsonResult(await store.updateStatus(raw.id, "rejected")); } if (action === "write_support_file") { const skillName = readString(raw.skillName); const relativePath = readString(raw.relativePath); const body = raw.body; if (!skillName || !relativePath || typeof body !== "string") { throw new Error("skillName, relativePath, and body required"); } const filePath = await writeSupportFile({ workspaceDir, skillName, relativePath, content: body, maxBytes: params.config.maxSkillBytes, }); return jsonResult({ status: "written", filePath }); } throw new Error(`unknown action: ${action}`); }, }; }