Files
zn-ai/electron/gateway/skill-capability-registry.ts
DEV_DSW 4c61e93c3e Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
2026-04-24 17:02:59 +08:00

229 lines
6.0 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
getAllSkillConfigs,
type SkillConfigRecord,
} from '@electron/utils/skill-config';
import { getOpenClawConfigDir } from '@electron/utils/paths';
import {
parseSkillCapability,
type SkillCapability,
} from './skill-capability-parser';
type SkillConfigEntry = {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
type RegistrySnapshot = {
capabilities: SkillCapability[];
loadedAt: string;
source: 'disk-sync' | 'skill-config';
};
const OPENCLAW_CONFIG_FILE = 'openclaw.json';
const OPENCLAW_SKILLS_DIR = 'skills';
const SKILL_MANIFEST_FILE = 'SKILL.md';
let registrySnapshot: RegistrySnapshot | null = null;
let refreshPromise: Promise<SkillCapability[]> | null = null;
function uniq(values: string[]): string[] {
return Array.from(new Set(values));
}
function cloneCapability(capability: SkillCapability): SkillCapability {
return {
...capability,
allowedTools: [...capability.allowedTools],
operationHints: [...capability.operationHints],
triggerHints: [...capability.triggerHints],
inputExtensions: [...capability.inputExtensions],
requiredEnvVars: [...capability.requiredEnvVars],
commandExamples: capability.commandExamples ? [...capability.commandExamples] : undefined,
renderHints: capability.renderHints
? {
...capability.renderHints,
metadata: capability.renderHints.metadata
? { ...capability.renderHints.metadata }
: undefined,
}
: undefined,
};
}
function sortCapabilities(capabilities: SkillCapability[]): SkillCapability[] {
return [...capabilities].sort((left, right) => {
if (left.enabled !== right.enabled) {
return left.enabled ? -1 : 1;
}
return left.slug.localeCompare(right.slug);
});
}
function readManifestContent(manifestPath?: string): string {
if (!manifestPath || !existsSync(manifestPath)) {
return '';
}
try {
return readFileSync(manifestPath, 'utf-8');
} catch {
return '';
}
}
function setRegistrySnapshot(
capabilities: SkillCapability[],
source: RegistrySnapshot['source'],
): SkillCapability[] {
registrySnapshot = {
capabilities: sortCapabilities(capabilities),
loadedAt: new Date().toISOString(),
source,
};
return registrySnapshot.capabilities.map(cloneCapability);
}
function buildCapabilityFromConfig(
skillKey: string,
config: Partial<SkillConfigRecord> & {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
},
): SkillCapability {
const manifestPath = config.filePath || (config.baseDir ? join(config.baseDir, SKILL_MANIFEST_FILE) : undefined);
const manifestContent = readManifestContent(manifestPath);
return parseSkillCapability({
skillKey,
config: {
...config,
filePath: manifestPath,
},
manifestContent,
});
}
function readSkillConfigEntriesSync(): Record<string, SkillConfigEntry> {
const configPath = join(getOpenClawConfigDir(), OPENCLAW_CONFIG_FILE);
if (!existsSync(configPath)) {
return {};
}
try {
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as {
skills?: {
entries?: Record<string, SkillConfigEntry>;
};
};
return parsed.skills?.entries || {};
} catch {
return {};
}
}
function readInstalledSkillsSync(): Record<string, Partial<SkillConfigRecord>> {
const skillsDir = join(getOpenClawConfigDir(), OPENCLAW_SKILLS_DIR);
if (!existsSync(skillsDir)) {
return {};
}
const result: Record<string, Partial<SkillConfigRecord>> = {};
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const baseDir = join(skillsDir, entry.name);
const manifestPath = join(baseDir, SKILL_MANIFEST_FILE);
if (!existsSync(manifestPath)) {
continue;
}
result[entry.name] = {
slug: entry.name,
name: entry.name,
baseDir,
filePath: manifestPath,
};
}
return result;
}
function buildSynchronousSnapshot(): SkillCapability[] {
const installedSkills = readInstalledSkillsSync();
const configEntries = readSkillConfigEntriesSync();
const skillKeys = uniq([
...Object.keys(installedSkills),
...Object.keys(configEntries),
]);
const capabilities = skillKeys.map((skillKey) => {
const installedSkill = installedSkills[skillKey] || {};
const configEntry = configEntries[skillKey] || {};
return buildCapabilityFromConfig(skillKey, {
...installedSkill,
enabled: configEntry.enabled,
apiKey: configEntry.apiKey,
env: configEntry.env,
});
});
return setRegistrySnapshot(capabilities, 'disk-sync');
}
function ensureRegistrySnapshot(): RegistrySnapshot {
if (!registrySnapshot) {
buildSynchronousSnapshot();
}
return registrySnapshot || {
capabilities: [],
loadedAt: new Date().toISOString(),
source: 'disk-sync',
};
}
export function hydrateSkillCapabilityRegistry(
configs: Record<string, SkillConfigRecord>,
): SkillCapability[] {
const capabilities = Object.entries(configs).map(([skillKey, config]) =>
buildCapabilityFromConfig(skillKey, config),
);
return setRegistrySnapshot(capabilities, 'skill-config');
}
export async function refreshSkillCapabilityRegistry(): Promise<SkillCapability[]> {
if (!refreshPromise) {
refreshPromise = getAllSkillConfigs()
.then((configs) => hydrateSkillCapabilityRegistry(configs))
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
export function listSkillCapabilities(options?: { enabledOnly?: boolean }): SkillCapability[] {
const snapshot = ensureRegistrySnapshot();
const capabilities = options?.enabledOnly
? snapshot.capabilities.filter((capability) => capability.enabled)
: snapshot.capabilities;
return capabilities.map(cloneCapability);
}
export function getEnabledSkillCapabilities(): SkillCapability[] {
return listSkillCapabilities({ enabledOnly: true });
}