import fs from "node:fs/promises" ;
import path from "node:path" ;
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js" ;
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js" ;
import type { SkillSnapshot } from "../skills.js" ;
import { cliBackendLog } from "./log.js" ;
const CLAUDE_CLI_BACKEND_ID = "claude-cli" ;
const OPENCLAW_CLAUDE_PLUGIN_NAME = "openclaw-skills" ;
type MaterializedSkill = {
name: string;
sourceDir: string;
targetDirName: string;
};
function sanitizeSkillDirName(name: string, used: Set<string>): string {
const base =
name
.trim()
.replace(/[^A-Za-z0-9 ._-]+/g, "-" )
.replace(/^-+|-+$/g, "" )
.slice(0 , 80 ) || "skill" ;
const safeBase = base.startsWith("." ) ? `skill-${base.replace(/^\.+/, "" ) || "skill" }` : base;
let candidate = safeBase;
for (let index = 2 ; used.has(candidate); index += 1 ) {
candidate = `${safeBase}-${index}`;
}
used.add(candidate);
return candidate;
}
async function collectClaudePluginSkills(snapshot?: SkillSnapshot): Promise<MaterializedSkill[]> {
const skills = snapshot?.resolvedSkills ?? [];
if (skills.length === 0 ) {
return [];
}
const usedTargetNames = new Set<string>();
const materialized: MaterializedSkill[] = [];
for (const skill of skills) {
const name = skill.name?.trim();
const skillFilePath = skill.filePath?.trim();
if (!name || !skillFilePath) {
continue ;
}
try {
await fs.access(skillFilePath);
} catch {
cliBackendLog.warn(`claude skill plugin skipped missing skill file: ${skillFilePath}`);
continue ;
}
materialized.push({
name,
sourceDir: path.dirname(skillFilePath),
targetDirName: sanitizeSkillDirName(name, usedTargetNames),
});
}
return materialized;
}
async function linkOrCopySkillDir(params: { sourceDir: string; targetDir: string }) {
try {
await fs.symlink(
params.sourceDir,
params.targetDir,
process.platform === "win32" ? "junction" : "dir" ,
);
} catch {
await fs.cp(params.sourceDir, params.targetDir, {
recursive: true ,
force: true ,
verbatimSymlinks: true ,
});
}
}
export async function prepareClaudeCliSkillsPlugin(params: {
backendId: string;
skillsSnapshot?: SkillSnapshot;
}): Promise<{ args: string[]; cleanup: () => Promise<void >; pluginDir?: string }> {
if (normalizeLowercaseStringOrEmpty(params.backendId) !== CLAUDE_CLI_BACKEND_ID) {
return { args: [], cleanup: async () => {} };
}
const skills = await collectClaudePluginSkills(params.skillsSnapshot);
if (skills.length === 0 ) {
return { args: [], cleanup: async () => {} };
}
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-claude-skills-" ),
);
const pluginDir = path.join(tempDir, OPENCLAW_CLAUDE_PLUGIN_NAME);
const manifestDir = path.join(pluginDir, ".claude-plugin" );
const skillsDir = path.join(pluginDir, "skills" );
await fs.mkdir(manifestDir, { recursive: true , mode: 0 o700 });
await fs.mkdir(skillsDir, { recursive: true , mode: 0 o700 });
const manifest = {
name: OPENCLAW_CLAUDE_PLUGIN_NAME,
version: "0.0.0" ,
description: "Session-scoped OpenClaw skills selected for this agent run." ,
skills: "./skills" ,
};
await fs.writeFile(
path.join(manifestDir, "plugin.json" ),
`${JSON.stringify(manifest, null , 2 )}\n`,
{
encoding: "utf-8" ,
mode: 0 o600,
},
);
let linkedSkillCount = 0 ;
for (const skill of skills) {
try {
await linkOrCopySkillDir({
sourceDir: skill.sourceDir,
targetDir: path.join(skillsDir, skill.targetDirName),
});
linkedSkillCount += 1 ;
} catch (error) {
cliBackendLog.warn(
`claude skill plugin skipped ${skill.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (linkedSkillCount === 0 ) {
await fs.rm(tempDir, { recursive: true , force: true });
return { args: [], cleanup: async () => {} };
}
return {
args: ["--plugin-dir" , pluginDir],
pluginDir,
cleanup: async () => {
await fs.rm(tempDir, { recursive: true , force: true });
},
};
}
Messung V0.5 in Prozent C=100 H=91 G=95
¤ Dauer der Verarbeitung: 0.10 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland