Files
openclaw/extensions/skill-workshop/src/tool.ts
2026-04-23 04:59:42 +01:00

257 lines
8.5 KiB
TypeScript

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,
prepareProposalWrite,
writeSupportFile,
} from "./skills.js";
import type { SkillChange, SkillProposal, SkillWorkshopStatus } from "./types.js";
import { 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<string, unknown>) {
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 shouldApply =
raw.apply === true || (raw.apply !== false && params.config.approvalPolicy === "auto");
if (shouldApply) {
const prepared = await prepareProposalWrite({
proposal,
maxSkillBytes: params.config.maxSkillBytes,
});
const critical = prepared.findings.find((finding) => finding.severity === "critical");
if (critical) {
const stored = await store.add(
{
...proposal,
status: "quarantined",
updatedAt: Date.now(),
scanFindings: prepared.findings,
quarantineReason: critical.message,
},
params.config.maxPending,
);
return jsonResult({ status: "quarantined", proposal: stored });
}
const applied = await applyProposalToWorkspace({
proposal,
maxSkillBytes: params.config.maxSkillBytes,
});
const stored = await store.add(
{
...proposal,
status: "applied",
updatedAt: Date.now(),
scanFindings: applied.findings,
},
params.config.maxPending,
);
return jsonResult({ status: "applied", skillPath: applied.skillPath, proposal: stored });
}
const prepared = await prepareProposalWrite({
proposal,
maxSkillBytes: params.config.maxSkillBytes,
});
const critical = prepared.findings.find((finding) => finding.severity === "critical");
if (critical) {
const stored = await store.add(
{
...proposal,
status: "quarantined",
updatedAt: Date.now(),
scanFindings: prepared.findings,
quarantineReason: critical.message,
},
params.config.maxPending,
);
return jsonResult({ status: "quarantined", proposal: stored });
}
const stored = await store.add(
{ ...proposal, scanFindings: prepared.findings },
params.config.maxPending,
);
return jsonResult({ status: "pending", proposal: stored });
}
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}`);
},
};
}