feat: add OpenProse plugin skills

This commit is contained in:
Peter Steinberger
2026-01-23 00:49:32 +00:00
parent db0235a26a
commit 51a9053387
102 changed files with 23315 additions and 5 deletions

View File

@@ -39,4 +39,82 @@ describe("loadWorkspaceSkillEntries", () => {
expect(entries).toEqual([]);
});
it("includes plugin-shipped skills when the plugin is enabled", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const pluginRoot = path.join(workspaceDir, ".clawdbot", "extensions", "open-prose");
await fs.mkdir(path.join(pluginRoot, "skills", "prose"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, "clawdbot.plugin.json"),
JSON.stringify(
{
id: "open-prose",
skills: ["./skills"],
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
`---\nname: prose\ndescription: test\n---\n`,
"utf-8",
);
const entries = loadWorkspaceSkillEntries(workspaceDir, {
config: {
plugins: {
entries: { "open-prose": { enabled: true } },
},
},
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
expect(entries.map((entry) => entry.skill.name)).toContain("prose");
});
it("excludes plugin-shipped skills when the plugin is not allowed", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const pluginRoot = path.join(workspaceDir, ".clawdbot", "extensions", "open-prose");
await fs.mkdir(path.join(pluginRoot, "skills", "prose"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, "clawdbot.plugin.json"),
JSON.stringify(
{
id: "open-prose",
skills: ["./skills"],
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
`---\nname: prose\ndescription: test\n---\n`,
"utf-8",
);
const entries = loadWorkspaceSkillEntries(workspaceDir, {
config: {
plugins: {
allow: ["something-else"],
},
},
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
expect(entries.map((entry) => entry.skill.name)).not.toContain("prose");
});
});

View File

@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
} from "../../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
const log = createSubsystemLogger("skills");
export function resolvePluginSkillDirs(params: {
workspaceDir: string;
config?: ClawdbotConfig;
}): string[] {
const workspaceDir = params.workspaceDir.trim();
if (!workspaceDir) return [];
const registry = loadPluginManifestRegistry({
workspaceDir,
config: params.config,
});
if (registry.plugins.length === 0) return [];
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const memorySlot = normalizedPlugins.slots.memory;
let selectedMemoryPluginId: string | null = null;
const seen = new Set<string>();
const resolved: string[] = [];
for (const record of registry.plugins) {
if (!record.skills || record.skills.length === 0) continue;
const enableState = resolveEnableState(record.id, record.origin, normalizedPlugins);
if (!enableState.enabled) continue;
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) continue;
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
for (const raw of record.skills) {
const trimmed = raw.trim();
if (!trimmed) continue;
const candidate = path.resolve(record.rootDir, trimmed);
if (!fs.existsSync(candidate)) {
log.warn(`plugin skill path not found (${record.id}): ${candidate}`);
continue;
}
if (seen.has(candidate)) continue;
seen.add(candidate);
resolved.push(candidate);
}
}
return resolved;
}

View File

@@ -5,6 +5,7 @@ import chokidar, { type FSWatcher } from "chokidar";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
type SkillsChangeEvent = {
workspaceDir?: string;
@@ -59,6 +60,8 @@ function resolveWatchPaths(workspaceDir: string, config?: ClawdbotConfig): strin
.filter(Boolean)
.map((dir) => resolveUserPath(dir));
paths.push(...extraDirs);
const pluginSkillDirs = resolvePluginSkillDirs({ workspaceDir, config });
paths.push(...pluginSkillDirs);
return paths;
}

View File

@@ -17,6 +17,7 @@ import {
resolveClawdbotMetadata,
resolveSkillInvocationPolicy,
} from "./frontmatter.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
import { serializeByKey } from "./serialize.js";
import type {
ParsedSkillFrontmatter,
@@ -120,6 +121,11 @@ function loadSkillEntries(
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
const pluginSkillDirs = resolvePluginSkillDirs({
workspaceDir,
config: opts?.config,
});
const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs];
const bundledSkills = bundledSkillsDir
? loadSkills({
@@ -127,7 +133,7 @@ function loadSkillEntries(
source: "clawdbot-bundled",
})
: [];
const extraSkills = extraDirs.flatMap((dir) => {
const extraSkills = mergedExtraDirs.flatMap((dir) => {
const resolved = resolveUserPath(dir);
return loadSkills({
dir: resolved,

View File

@@ -15,6 +15,7 @@ export type PluginManifestRecord = {
kind?: PluginKind;
channels: string[];
providers: string[];
skills: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
@@ -86,6 +87,7 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
skills: params.manifest.skills ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,

View File

@@ -11,6 +11,7 @@ export type PluginManifest = {
kind?: PluginKind;
channels?: string[];
providers?: string[];
skills?: string[];
name?: string;
description?: string;
version?: string;
@@ -67,6 +68,7 @@ export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
const skills = normalizeStringList(raw.skills);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
@@ -81,6 +83,7 @@ export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
kind,
channels,
providers,
skills,
name,
description,
version,