refactor(src): split oversized modules
This commit is contained in:
29
src/agents/skills/bundled-dir.ts
Normal file
29
src/agents/skills/bundled-dir.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function resolveBundledSkillsDir(): string | undefined {
|
||||
const override = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR?.trim();
|
||||
if (override) return override;
|
||||
|
||||
// bun --compile: ship a sibling `skills/` next to the executable.
|
||||
try {
|
||||
const execDir = path.dirname(process.execPath);
|
||||
const sibling = path.join(execDir, "skills");
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// npm/dev: resolve `<packageRoot>/skills` relative to this module.
|
||||
try {
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(moduleDir, "..", "..", "..");
|
||||
const candidate = path.join(root, "skills");
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
151
src/agents/skills/config.ts
Normal file
151
src/agents/skills/config.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig, SkillConfig } from "../../config/config.js";
|
||||
import { resolveSkillKey } from "./frontmatter.js";
|
||||
import type { SkillEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
};
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveConfigPath(
|
||||
config: ClawdbotConfig | undefined,
|
||||
pathStr: string,
|
||||
) {
|
||||
const parts = pathStr.split(".").filter(Boolean);
|
||||
let current: unknown = config;
|
||||
for (const part of parts) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function isConfigPathTruthy(
|
||||
config: ClawdbotConfig | undefined,
|
||||
pathStr: string,
|
||||
): boolean {
|
||||
const value = resolveConfigPath(config, pathStr);
|
||||
if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) {
|
||||
return DEFAULT_CONFIG_VALUES[pathStr] === true;
|
||||
}
|
||||
return isTruthy(value);
|
||||
}
|
||||
|
||||
export function resolveSkillConfig(
|
||||
config: ClawdbotConfig | undefined,
|
||||
skillKey: string,
|
||||
): SkillConfig | undefined {
|
||||
const skills = config?.skills?.entries;
|
||||
if (!skills || typeof skills !== "object") return undefined;
|
||||
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function resolveRuntimePlatform(): string {
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
function normalizeAllowlist(input: unknown): string[] | undefined {
|
||||
if (!input) return undefined;
|
||||
if (!Array.isArray(input)) return undefined;
|
||||
const normalized = input.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function isBundledSkill(entry: SkillEntry): boolean {
|
||||
return entry.skill.source === "clawdbot-bundled";
|
||||
}
|
||||
|
||||
export function resolveBundledAllowlist(
|
||||
config?: ClawdbotConfig,
|
||||
): string[] | undefined {
|
||||
return normalizeAllowlist(config?.skills?.allowBundled);
|
||||
}
|
||||
|
||||
export function isBundledSkillAllowed(
|
||||
entry: SkillEntry,
|
||||
allowlist?: string[],
|
||||
): boolean {
|
||||
if (!allowlist || allowlist.length === 0) return true;
|
||||
if (!isBundledSkill(entry)) return true;
|
||||
const key = resolveSkillKey(entry.skill, entry);
|
||||
return allowlist.includes(key) || allowlist.includes(entry.skill.name);
|
||||
}
|
||||
|
||||
export function hasBinary(bin: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const candidate = path.join(part, bin);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// keep scanning
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIncludeSkill(params: {
|
||||
entry: SkillEntry;
|
||||
config?: ClawdbotConfig;
|
||||
}): boolean {
|
||||
const { entry, config } = params;
|
||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
||||
const osList = entry.clawdbot?.os ?? [];
|
||||
|
||||
if (skillConfig?.enabled === false) return false;
|
||||
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
|
||||
if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) {
|
||||
return false;
|
||||
}
|
||||
if (entry.clawdbot?.always === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
|
||||
if (requiredBins.length > 0) {
|
||||
for (const bin of requiredBins) {
|
||||
if (!hasBinary(bin)) return false;
|
||||
}
|
||||
}
|
||||
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
|
||||
if (requiredAnyBins.length > 0) {
|
||||
const anyFound = requiredAnyBins.some((bin) => hasBinary(bin));
|
||||
if (!anyFound) return false;
|
||||
}
|
||||
|
||||
const requiredEnv = entry.clawdbot?.requires?.env ?? [];
|
||||
if (requiredEnv.length > 0) {
|
||||
for (const envName of requiredEnv) {
|
||||
if (process.env[envName]) continue;
|
||||
if (skillConfig?.env?.[envName]) continue;
|
||||
if (skillConfig?.apiKey && entry.clawdbot?.primaryEnv === envName) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
|
||||
if (requiredConfig.length > 0) {
|
||||
for (const configPath of requiredConfig) {
|
||||
if (!isConfigPathTruthy(config, configPath)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
80
src/agents/skills/env-overrides.ts
Normal file
80
src/agents/skills/env-overrides.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSkillConfig } from "./config.js";
|
||||
import { resolveSkillKey } from "./frontmatter.js";
|
||||
import type { SkillEntry, SkillSnapshot } from "./types.js";
|
||||
|
||||
export function applySkillEnvOverrides(params: {
|
||||
skills: SkillEntry[];
|
||||
config?: ClawdbotConfig;
|
||||
}) {
|
||||
const { skills, config } = params;
|
||||
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||
|
||||
for (const entry of skills) {
|
||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
if (!skillConfig) continue;
|
||||
|
||||
if (skillConfig.env) {
|
||||
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||
if (!envValue || process.env[envKey]) continue;
|
||||
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||
process.env[envKey] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
const primaryEnv = entry.clawdbot?.primaryEnv;
|
||||
if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) {
|
||||
updates.push({ key: primaryEnv, prev: process.env[primaryEnv] });
|
||||
process.env[primaryEnv] = skillConfig.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const update of updates) {
|
||||
if (update.prev === undefined) delete process.env[update.key];
|
||||
else process.env[update.key] = update.prev;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function applySkillEnvOverridesFromSnapshot(params: {
|
||||
snapshot?: SkillSnapshot;
|
||||
config?: ClawdbotConfig;
|
||||
}) {
|
||||
const { snapshot, config } = params;
|
||||
if (!snapshot) return () => {};
|
||||
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||
|
||||
for (const skill of snapshot.skills) {
|
||||
const skillConfig = resolveSkillConfig(config, skill.name);
|
||||
if (!skillConfig) continue;
|
||||
|
||||
if (skillConfig.env) {
|
||||
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||
if (!envValue || process.env[envKey]) continue;
|
||||
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||
process.env[envKey] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
skill.primaryEnv &&
|
||||
skillConfig.apiKey &&
|
||||
!process.env[skill.primaryEnv]
|
||||
) {
|
||||
updates.push({
|
||||
key: skill.primaryEnv,
|
||||
prev: process.env[skill.primaryEnv],
|
||||
});
|
||||
process.env[skill.primaryEnv] = skillConfig.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const update of updates) {
|
||||
if (update.prev === undefined) delete process.env[update.key];
|
||||
else process.env[update.key] = update.prev;
|
||||
}
|
||||
};
|
||||
}
|
||||
148
src/agents/skills/frontmatter.ts
Normal file
148
src/agents/skills/frontmatter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import type {
|
||||
ClawdbotSkillMetadata,
|
||||
ParsedSkillFrontmatter,
|
||||
SkillEntry,
|
||||
SkillInstallSpec,
|
||||
} from "./types.js";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||
const frontmatter: ParsedSkillFrontmatter = {};
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return frontmatter;
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return frontmatter;
|
||||
const block = normalized.slice(4, endIndex);
|
||||
for (const line of block.split("\n")) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
if (!key || !value) continue;
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function normalizeStringList(input: unknown): string[] {
|
||||
if (!input) return [];
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
||||
if (!input || typeof input !== "object") return undefined;
|
||||
const raw = input as Record<string, unknown>;
|
||||
const kindRaw =
|
||||
typeof raw.kind === "string"
|
||||
? raw.kind
|
||||
: typeof raw.type === "string"
|
||||
? raw.type
|
||||
: "";
|
||||
const kind = kindRaw.trim().toLowerCase();
|
||||
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const spec: SkillInstallSpec = {
|
||||
kind: kind as SkillInstallSpec["kind"],
|
||||
};
|
||||
|
||||
if (typeof raw.id === "string") spec.id = raw.id;
|
||||
if (typeof raw.label === "string") spec.label = raw.label;
|
||||
const bins = normalizeStringList(raw.bins);
|
||||
if (bins.length > 0) spec.bins = bins;
|
||||
if (typeof raw.formula === "string") spec.formula = raw.formula;
|
||||
if (typeof raw.package === "string") spec.package = raw.package;
|
||||
if (typeof raw.module === "string") spec.module = raw.module;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
function getFrontmatterValue(
|
||||
frontmatter: ParsedSkillFrontmatter,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const raw = frontmatter[key];
|
||||
return typeof raw === "string" ? raw : undefined;
|
||||
}
|
||||
|
||||
export function resolveClawdbotMetadata(
|
||||
frontmatter: ParsedSkillFrontmatter,
|
||||
): ClawdbotSkillMetadata | undefined {
|
||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
||||
const clawdbotObj = clawdbot as Record<string, unknown>;
|
||||
const requiresRaw =
|
||||
typeof clawdbotObj.requires === "object" && clawdbotObj.requires !== null
|
||||
? (clawdbotObj.requires as Record<string, unknown>)
|
||||
: undefined;
|
||||
const installRaw = Array.isArray(clawdbotObj.install)
|
||||
? (clawdbotObj.install as unknown[])
|
||||
: [];
|
||||
const install = installRaw
|
||||
.map((entry) => parseInstallSpec(entry))
|
||||
.filter((entry): entry is SkillInstallSpec => Boolean(entry));
|
||||
const osRaw = normalizeStringList(clawdbotObj.os);
|
||||
return {
|
||||
always:
|
||||
typeof clawdbotObj.always === "boolean"
|
||||
? clawdbotObj.always
|
||||
: undefined,
|
||||
emoji:
|
||||
typeof clawdbotObj.emoji === "string" ? clawdbotObj.emoji : undefined,
|
||||
homepage:
|
||||
typeof clawdbotObj.homepage === "string"
|
||||
? clawdbotObj.homepage
|
||||
: undefined,
|
||||
skillKey:
|
||||
typeof clawdbotObj.skillKey === "string"
|
||||
? clawdbotObj.skillKey
|
||||
: undefined,
|
||||
primaryEnv:
|
||||
typeof clawdbotObj.primaryEnv === "string"
|
||||
? clawdbotObj.primaryEnv
|
||||
: undefined,
|
||||
os: osRaw.length > 0 ? osRaw : undefined,
|
||||
requires: requiresRaw
|
||||
? {
|
||||
bins: normalizeStringList(requiresRaw.bins),
|
||||
anyBins: normalizeStringList(requiresRaw.anyBins),
|
||||
env: normalizeStringList(requiresRaw.env),
|
||||
config: normalizeStringList(requiresRaw.config),
|
||||
}
|
||||
: undefined,
|
||||
install: install.length > 0 ? install : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
|
||||
return entry?.clawdbot?.skillKey ?? skill.name;
|
||||
}
|
||||
12
src/agents/skills/serialize.ts
Normal file
12
src/agents/skills/serialize.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const SKILLS_SYNC_QUEUE = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function serializeByKey<T>(key: string, task: () => Promise<T>) {
|
||||
const prev = SKILLS_SYNC_QUEUE.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(task, task);
|
||||
SKILLS_SYNC_QUEUE.set(key, next);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (SKILLS_SYNC_QUEUE.get(key) === next) SKILLS_SYNC_QUEUE.delete(key);
|
||||
}
|
||||
}
|
||||
46
src/agents/skills/types.ts
Normal file
46
src/agents/skills/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export type SkillInstallSpec = {
|
||||
id?: string;
|
||||
kind: "brew" | "node" | "go" | "uv";
|
||||
label?: string;
|
||||
bins?: string[];
|
||||
formula?: string;
|
||||
package?: string;
|
||||
module?: string;
|
||||
};
|
||||
|
||||
export type ClawdbotSkillMetadata = {
|
||||
always?: boolean;
|
||||
skillKey?: string;
|
||||
primaryEnv?: string;
|
||||
emoji?: string;
|
||||
homepage?: string;
|
||||
os?: string[];
|
||||
requires?: {
|
||||
bins?: string[];
|
||||
anyBins?: string[];
|
||||
env?: string[];
|
||||
config?: string[];
|
||||
};
|
||||
install?: SkillInstallSpec[];
|
||||
};
|
||||
|
||||
export type SkillsInstallPreferences = {
|
||||
preferBrew: boolean;
|
||||
nodeManager: "npm" | "pnpm" | "yarn" | "bun";
|
||||
};
|
||||
|
||||
export type ParsedSkillFrontmatter = Record<string, string>;
|
||||
|
||||
export type SkillEntry = {
|
||||
skill: Skill;
|
||||
frontmatter: ParsedSkillFrontmatter;
|
||||
clawdbot?: ClawdbotSkillMetadata;
|
||||
};
|
||||
|
||||
export type SkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
resolvedSkills?: Skill[];
|
||||
};
|
||||
252
src/agents/skills/workspace.ts
Normal file
252
src/agents/skills/workspace.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
formatSkillsForPrompt,
|
||||
loadSkillsFromDir,
|
||||
type Skill,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
||||
import { shouldIncludeSkill } from "./config.js";
|
||||
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
|
||||
import { serializeByKey } from "./serialize.js";
|
||||
import type {
|
||||
ParsedSkillFrontmatter,
|
||||
SkillEntry,
|
||||
SkillSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
function filterSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdbotConfig,
|
||||
skillFilter?: string[],
|
||||
): SkillEntry[] {
|
||||
let filtered = entries.filter((entry) =>
|
||||
shouldIncludeSkill({ entry, config }),
|
||||
);
|
||||
// If skillFilter is provided, only include skills in the filter list.
|
||||
if (skillFilter !== undefined) {
|
||||
const normalized = skillFilter
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const label = normalized.length > 0 ? normalized.join(", ") : "(none)";
|
||||
console.log(`[skills] Applying skill filter: ${label}`);
|
||||
filtered =
|
||||
normalized.length > 0
|
||||
? filtered.filter((entry) => normalized.includes(entry.skill.name))
|
||||
: [];
|
||||
console.log(
|
||||
`[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function loadSkillEntries(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
const loadSkills = (params: { dir: string; source: string }): Skill[] => {
|
||||
const loaded = loadSkillsFromDir(params);
|
||||
if (Array.isArray(loaded)) return loaded;
|
||||
if (
|
||||
loaded &&
|
||||
typeof loaded === "object" &&
|
||||
"skills" in loaded &&
|
||||
Array.isArray((loaded as { skills?: unknown }).skills)
|
||||
) {
|
||||
return (loaded as { skills: Skill[] }).skills;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const managedSkillsDir =
|
||||
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
||||
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
|
||||
const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? [];
|
||||
const extraDirs = extraDirsRaw
|
||||
.map((d) => (typeof d === "string" ? d.trim() : ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const bundledSkills = bundledSkillsDir
|
||||
? loadSkills({
|
||||
dir: bundledSkillsDir,
|
||||
source: "clawdbot-bundled",
|
||||
})
|
||||
: [];
|
||||
const extraSkills = extraDirs.flatMap((dir) => {
|
||||
const resolved = resolveUserPath(dir);
|
||||
return loadSkills({
|
||||
dir: resolved,
|
||||
source: "clawdbot-extra",
|
||||
});
|
||||
});
|
||||
const managedSkills = loadSkills({
|
||||
dir: managedSkillsDir,
|
||||
source: "clawdbot-managed",
|
||||
});
|
||||
const workspaceSkills = loadSkills({
|
||||
dir: workspaceSkillsDir,
|
||||
source: "clawdbot-workspace",
|
||||
});
|
||||
|
||||
const merged = new Map<string, Skill>();
|
||||
// Precedence: extra < bundled < managed < workspace
|
||||
for (const skill of extraSkills) merged.set(skill.name, skill);
|
||||
for (const skill of bundledSkills) merged.set(skill.name, skill);
|
||||
for (const skill of managedSkills) merged.set(skill.name, skill);
|
||||
for (const skill of workspaceSkills) merged.set(skill.name, skill);
|
||||
|
||||
const skillEntries: SkillEntry[] = Array.from(merged.values()).map(
|
||||
(skill) => {
|
||||
let frontmatter: ParsedSkillFrontmatter = {};
|
||||
try {
|
||||
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
||||
frontmatter = parseFrontmatter(raw);
|
||||
} catch {
|
||||
// ignore malformed skills
|
||||
}
|
||||
return {
|
||||
skill,
|
||||
frontmatter,
|
||||
clawdbot: resolveClawdbotMetadata(frontmatter),
|
||||
};
|
||||
},
|
||||
);
|
||||
return skillEntries;
|
||||
}
|
||||
|
||||
export function buildWorkspaceSkillSnapshot(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
entries?: SkillEntry[];
|
||||
/** If provided, only include skills with these names */
|
||||
skillFilter?: string[];
|
||||
},
|
||||
): SkillSnapshot {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(
|
||||
skillEntries,
|
||||
opts?.config,
|
||||
opts?.skillFilter,
|
||||
);
|
||||
const resolvedSkills = eligible.map((entry) => entry.skill);
|
||||
return {
|
||||
prompt: formatSkillsForPrompt(resolvedSkills),
|
||||
skills: eligible.map((entry) => ({
|
||||
name: entry.skill.name,
|
||||
primaryEnv: entry.clawdbot?.primaryEnv,
|
||||
})),
|
||||
resolvedSkills,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceSkillsPrompt(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
entries?: SkillEntry[];
|
||||
/** If provided, only include skills with these names */
|
||||
skillFilter?: string[];
|
||||
},
|
||||
): string {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(
|
||||
skillEntries,
|
||||
opts?.config,
|
||||
opts?.skillFilter,
|
||||
);
|
||||
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
|
||||
}
|
||||
|
||||
export function resolveSkillsPromptForRun(params: {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
entries?: SkillEntry[];
|
||||
config?: ClawdbotConfig;
|
||||
workspaceDir: string;
|
||||
}): string {
|
||||
const snapshotPrompt = params.skillsSnapshot?.prompt?.trim();
|
||||
if (snapshotPrompt) return snapshotPrompt;
|
||||
if (params.entries && params.entries.length > 0) {
|
||||
const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, {
|
||||
entries: params.entries,
|
||||
config: params.config,
|
||||
});
|
||||
return prompt.trim() ? prompt : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function loadWorkspaceSkillEntries(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
return loadSkillEntries(workspaceDir, opts);
|
||||
}
|
||||
|
||||
export async function syncSkillsToWorkspace(params: {
|
||||
sourceWorkspaceDir: string;
|
||||
targetWorkspaceDir: string;
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
}) {
|
||||
const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
|
||||
const targetDir = resolveUserPath(params.targetWorkspaceDir);
|
||||
if (sourceDir === targetDir) return;
|
||||
|
||||
await serializeByKey(`syncSkills:${targetDir}`, async () => {
|
||||
const targetSkillsDir = path.join(targetDir, "skills");
|
||||
|
||||
const entries = loadSkillEntries(sourceDir, {
|
||||
config: params.config,
|
||||
managedSkillsDir: params.managedSkillsDir,
|
||||
bundledSkillsDir: params.bundledSkillsDir,
|
||||
});
|
||||
|
||||
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(targetSkillsDir, { recursive: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const dest = path.join(targetSkillsDir, entry.skill.name);
|
||||
try {
|
||||
await fsp.cp(entry.skill.baseDir, dest, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
console.warn(
|
||||
`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function filterWorkspaceSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdbotConfig,
|
||||
): SkillEntry[] {
|
||||
return filterSkillEntries(entries, config);
|
||||
}
|
||||
Reference in New Issue
Block a user