mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 21:10:45 +00:00
143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
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: 0o700 });
|
|
await fs.mkdir(skillsDir, { recursive: true, mode: 0o700 });
|
|
|
|
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: 0o600,
|
|
},
|
|
);
|
|
|
|
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 });
|
|
},
|
|
};
|
|
}
|