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}`);
},
};
}
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland