- Updated MarketplaceDrawer to include security notes and manual installation hints. - Refactored SkillDetailDrawer to display default icons for skills. - Simplified SkillListItem to use default icons for better readability. - Integrated gateway status checks and warnings in SkillsPage for improved user awareness. - Enhanced error handling for skill installation and fetching, providing clearer feedback to users. - Added new translations for error messages and gateway warnings to improve localization support.
616 lines
16 KiB
TypeScript
616 lines
16 KiB
TypeScript
/**
|
|
* Skill Config Utilities
|
|
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
|
* This bypasses the Gateway RPC for faster and more reliable config updates.
|
|
*
|
|
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
|
*/
|
|
import { spawn } from 'child_process';
|
|
import { app } from 'electron';
|
|
import { readFile, writeFile, access, mkdir, readdir, constants, cp } from 'fs/promises';
|
|
import { existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { getOpenClawConfigDir, ensureDir, getResourcesDir } from './paths';
|
|
|
|
const OPENCLAW_CONFIG_PATH = join(getOpenClawConfigDir(), 'openclaw.json');
|
|
const SKILLS_DIR = join(getOpenClawConfigDir(), 'skills');
|
|
|
|
interface SkillEntry {
|
|
enabled?: boolean;
|
|
apiKey?: string;
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
interface OpenClawConfig {
|
|
skills?: {
|
|
entries?: Record<string, SkillEntry>;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface PreinstalledSkillSpec {
|
|
slug: string;
|
|
version?: string;
|
|
autoEnable?: boolean;
|
|
}
|
|
|
|
interface PreinstalledManifest {
|
|
skills?: PreinstalledSkillSpec[];
|
|
}
|
|
|
|
interface PreinstalledLockEntry {
|
|
slug: string;
|
|
version?: string;
|
|
}
|
|
|
|
interface PreinstalledLockFile {
|
|
skills?: PreinstalledLockEntry[];
|
|
}
|
|
|
|
interface PreinstalledMarker {
|
|
source: 'clawx-preinstalled';
|
|
slug: string;
|
|
version: string;
|
|
installedAt: string;
|
|
}
|
|
|
|
export interface SkillConfigRecord {
|
|
slug?: string;
|
|
name?: string;
|
|
description?: string;
|
|
enabled?: boolean;
|
|
icon?: string;
|
|
version?: string;
|
|
author?: string;
|
|
config?: Record<string, unknown>;
|
|
apiKey?: string;
|
|
env?: Record<string, string>;
|
|
isCore?: boolean;
|
|
isBundled?: boolean;
|
|
source?: string;
|
|
baseDir?: string;
|
|
filePath?: string;
|
|
}
|
|
|
|
interface ParsedFrontmatter {
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
author?: string;
|
|
icon?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const PREINSTALLED_MANIFEST_NAME = 'preinstalled-manifest.json';
|
|
const PREINSTALLED_MARKER_NAME = '.clawx-preinstalled.json';
|
|
|
|
async function fileExists(p: string): Promise<boolean> {
|
|
try {
|
|
await access(p, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the current OpenClaw config
|
|
*/
|
|
async function readConfig(): Promise<OpenClawConfig> {
|
|
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
|
return {};
|
|
}
|
|
try {
|
|
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
|
return JSON.parse(raw) as OpenClawConfig;
|
|
} catch (err) {
|
|
console.error('Failed to read openclaw config:', err);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the OpenClaw config
|
|
*/
|
|
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
|
const json = JSON.stringify(config, null, 2);
|
|
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
|
}
|
|
|
|
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
|
|
if (skillKeys.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const config = await readConfig();
|
|
if (!config.skills) {
|
|
config.skills = {};
|
|
}
|
|
if (!config.skills.entries) {
|
|
config.skills.entries = {};
|
|
}
|
|
|
|
for (const skillKey of skillKeys) {
|
|
const entry = config.skills.entries[skillKey] || {};
|
|
entry.enabled = enabled;
|
|
config.skills.entries[skillKey] = entry;
|
|
}
|
|
|
|
await writeConfig(config);
|
|
}
|
|
|
|
/**
|
|
* Parse YAML-like frontmatter from markdown content.
|
|
* Extracts key: value pairs from the --- delimited block at the start.
|
|
*/
|
|
function parseFrontmatter(content: string): ParsedFrontmatter {
|
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
if (!match) {
|
|
return {};
|
|
}
|
|
|
|
const frontmatter = match[1];
|
|
const result: ParsedFrontmatter = {};
|
|
|
|
for (const line of frontmatter.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
continue;
|
|
}
|
|
|
|
// Match key: value (simple scalar values only)
|
|
const colonIndex = trimmed.indexOf(':');
|
|
if (colonIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
const key = trimmed.slice(0, colonIndex).trim();
|
|
let value = trimmed.slice(colonIndex + 1).trim();
|
|
|
|
// Remove surrounding quotes
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
if (key) {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function readPreinstalledManifest(): Promise<PreinstalledSkillSpec[]> {
|
|
const candidates = [
|
|
join(getResourcesDir(), 'skills', PREINSTALLED_MANIFEST_NAME),
|
|
join(process.cwd(), 'resources', 'skills', PREINSTALLED_MANIFEST_NAME),
|
|
];
|
|
|
|
const manifestPath = candidates.find((candidate) => existsSync(candidate));
|
|
if (!manifestPath) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const raw = await readFile(manifestPath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as PreinstalledManifest;
|
|
if (!Array.isArray(parsed.skills)) {
|
|
return [];
|
|
}
|
|
return parsed.skills.filter((skill): skill is PreinstalledSkillSpec => Boolean(skill?.slug));
|
|
} catch (error) {
|
|
console.warn('Failed to read preinstalled skills manifest:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function resolvePreinstalledSkillsSourceRoot(): string | null {
|
|
const candidates = [
|
|
join(getResourcesDir(), 'preinstalled-skills'),
|
|
join(process.cwd(), 'build', 'preinstalled-skills'),
|
|
join(__dirname, '../../build/preinstalled-skills'),
|
|
];
|
|
|
|
const root = candidates.find((candidate) => existsSync(candidate));
|
|
return root || null;
|
|
}
|
|
|
|
async function preparePreinstalledSkillsSourceRootForDev(): Promise<string | null> {
|
|
const existingRoot = resolvePreinstalledSkillsSourceRoot();
|
|
if (existingRoot) {
|
|
return existingRoot;
|
|
}
|
|
|
|
if (app.isPackaged) {
|
|
return null;
|
|
}
|
|
|
|
const appRoot = app.getAppPath();
|
|
const scriptPath = join(appRoot, 'scripts', 'bundle-preinstalled-skills.mjs');
|
|
const zxBinName = process.platform === 'win32' ? 'zx.cmd' : 'zx';
|
|
const zxPath = join(appRoot, 'node_modules', '.bin', zxBinName);
|
|
|
|
if (!existsSync(scriptPath) || !existsSync(zxPath)) {
|
|
console.warn('Preinstalled skills bundle missing and dev prepare tooling is unavailable.');
|
|
return null;
|
|
}
|
|
|
|
console.info('Preinstalled skills bundle missing; preparing it on startup for dev mode...');
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(zxPath, [scriptPath], {
|
|
cwd: appRoot,
|
|
env: {
|
|
...process.env,
|
|
CI: 'true',
|
|
FORCE_COLOR: '0',
|
|
},
|
|
stdio: 'pipe',
|
|
windowsHide: true,
|
|
});
|
|
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
console.info(`[preinstalled-skills] ${String(chunk).trim()}`);
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
const text = String(chunk).trim();
|
|
stderr += text;
|
|
if (text) {
|
|
console.warn(`[preinstalled-skills] ${text}`);
|
|
}
|
|
});
|
|
|
|
child.on('error', (error) => reject(error));
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(new Error(stderr || `bundle-preinstalled-skills exited with code ${code ?? 'unknown'}`));
|
|
});
|
|
});
|
|
|
|
return resolvePreinstalledSkillsSourceRoot();
|
|
}
|
|
|
|
async function readPreinstalledLockVersions(sourceRoot: string): Promise<Map<string, string>> {
|
|
const lockPath = join(sourceRoot, '.preinstalled-lock.json');
|
|
if (!(await fileExists(lockPath))) {
|
|
return new Map();
|
|
}
|
|
|
|
try {
|
|
const raw = await readFile(lockPath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as PreinstalledLockFile;
|
|
const versions = new Map<string, string>();
|
|
|
|
for (const entry of parsed.skills || []) {
|
|
const slug = entry.slug?.trim();
|
|
const version = entry.version?.trim();
|
|
if (slug && version) {
|
|
versions.set(slug, version);
|
|
}
|
|
}
|
|
|
|
return versions;
|
|
} catch (error) {
|
|
console.warn('Failed to read preinstalled skills lock file:', error);
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
async function tryReadPreinstalledMarker(markerPath: string): Promise<PreinstalledMarker | null> {
|
|
if (!(await fileExists(markerPath))) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const raw = await readFile(markerPath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as PreinstalledMarker;
|
|
if (!parsed?.slug || !parsed?.version) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan the skills directory and parse SKILL.md frontmatter for each skill.
|
|
*/
|
|
async function scanSkillsDirectory(): Promise<Record<string, SkillConfigRecord>> {
|
|
const result: Record<string, SkillConfigRecord> = {};
|
|
|
|
if (!(await fileExists(SKILLS_DIR))) {
|
|
return result;
|
|
}
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(SKILLS_DIR, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
for (const dirent of entries) {
|
|
if (!dirent.isDirectory()) {
|
|
continue;
|
|
}
|
|
|
|
const slug = dirent.name;
|
|
const skillDir = join(SKILLS_DIR, slug);
|
|
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
const markerPath = join(skillDir, PREINSTALLED_MARKER_NAME);
|
|
|
|
if (!(await fileExists(skillMdPath))) {
|
|
continue;
|
|
}
|
|
|
|
let content: string;
|
|
try {
|
|
content = await readFile(skillMdPath, 'utf-8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const frontmatter = parseFrontmatter(content);
|
|
const preinstalledMarker = await tryReadPreinstalledMarker(markerPath);
|
|
|
|
result[slug] = {
|
|
slug,
|
|
name: frontmatter.name || slug,
|
|
description: frontmatter.description,
|
|
version: frontmatter.version,
|
|
author: frontmatter.author,
|
|
icon: frontmatter.icon,
|
|
baseDir: skillDir,
|
|
filePath: skillMdPath,
|
|
source: preinstalledMarker ? 'openclaw-bundled' : 'openclaw-managed',
|
|
isBundled: Boolean(preinstalledMarker),
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get skill config (raw entry from config file)
|
|
*/
|
|
export async function getSkillConfig(skillKey: string): Promise<SkillEntry | undefined> {
|
|
const config = await readConfig();
|
|
return config.skills?.entries?.[skillKey];
|
|
}
|
|
|
|
/**
|
|
* Update skill config (apiKey, env, and enabled)
|
|
*/
|
|
export async function updateSkillConfig(
|
|
skillKey: string,
|
|
updates: { apiKey?: string; env?: Record<string, string>; enabled?: boolean }
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const config = await readConfig();
|
|
|
|
// Ensure skills.entries exists
|
|
if (!config.skills) {
|
|
config.skills = {};
|
|
}
|
|
if (!config.skills.entries) {
|
|
config.skills.entries = {};
|
|
}
|
|
|
|
// Get or create skill entry
|
|
const entry = config.skills.entries[skillKey] || {};
|
|
|
|
// Update apiKey
|
|
if (updates.apiKey !== undefined) {
|
|
const trimmed = updates.apiKey.trim();
|
|
if (trimmed) {
|
|
entry.apiKey = trimmed;
|
|
} else {
|
|
delete entry.apiKey;
|
|
}
|
|
}
|
|
|
|
// Update env
|
|
if (updates.env !== undefined) {
|
|
const newEnv: Record<string, string> = {};
|
|
|
|
for (const [key, value] of Object.entries(updates.env)) {
|
|
const trimmedKey = key.trim();
|
|
if (!trimmedKey) continue;
|
|
|
|
const trimmedVal = value.trim();
|
|
if (trimmedVal) {
|
|
newEnv[trimmedKey] = trimmedVal;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(newEnv).length > 0) {
|
|
entry.env = newEnv;
|
|
} else {
|
|
delete entry.env;
|
|
}
|
|
}
|
|
|
|
// Update enabled
|
|
if (updates.enabled !== undefined) {
|
|
entry.enabled = updates.enabled;
|
|
}
|
|
|
|
// Save entry back
|
|
config.skills.entries[skillKey] = entry;
|
|
|
|
await writeConfig(config);
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to update skill config:', err);
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all skill configs (for syncing to frontend)
|
|
* Merges directory-scan metadata with config file entries.
|
|
*/
|
|
export async function getAllSkillConfigs(): Promise<Record<string, SkillConfigRecord>> {
|
|
const [config, scanned] = await Promise.all([readConfig(), scanSkillsDirectory()]);
|
|
const entries = config.skills?.entries || {};
|
|
|
|
// Start with scanned directory data
|
|
const result: Record<string, SkillConfigRecord> = { ...scanned };
|
|
|
|
// Merge config entries into scanned data (or create records for config-only skills)
|
|
for (const [slug, entry] of Object.entries(entries)) {
|
|
if (result[slug]) {
|
|
// Merge config values into scanned metadata
|
|
result[slug] = {
|
|
...result[slug],
|
|
enabled: entry.enabled,
|
|
apiKey: entry.apiKey,
|
|
env: entry.env,
|
|
};
|
|
} else {
|
|
// Config-only skill (no directory found)
|
|
result[slug] = {
|
|
slug,
|
|
name: slug,
|
|
enabled: entry.enabled,
|
|
apiKey: entry.apiKey,
|
|
env: entry.env,
|
|
};
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Built-in skills bundled with zn-ai that should be pre-deployed to
|
|
* ~/.openclaw/skills/ on first launch. These come from the app resources
|
|
* and are available in both dev and packaged builds.
|
|
*/
|
|
const BUILTIN_SKILLS = [] as const;
|
|
|
|
/**
|
|
* Ensure built-in skills are deployed to ~/.openclaw/skills/<slug>/.
|
|
* Skips any skill that already has a SKILL.md present (idempotent).
|
|
* Runs at app startup; all errors are logged and swallowed so they never
|
|
* block the normal startup flow.
|
|
*/
|
|
export async function ensureBuiltinSkillsInstalled(): Promise<void> {
|
|
const skillsRoot = join(getOpenClawConfigDir(), 'skills');
|
|
ensureDir(skillsRoot);
|
|
|
|
for (const { slug, sourceExtension } of BUILTIN_SKILLS) {
|
|
const targetDir = join(skillsRoot, slug);
|
|
const targetManifest = join(targetDir, 'SKILL.md');
|
|
|
|
if (existsSync(targetManifest)) {
|
|
continue; // already installed
|
|
}
|
|
|
|
// In zn-ai, built-in skills would be in app resources or node_modules
|
|
// This is a placeholder path pattern; adjust as needed for your build layout
|
|
const sourceDir = join(process.resourcesPath || process.cwd(), 'skills', sourceExtension, slug);
|
|
|
|
if (!existsSync(join(sourceDir, 'SKILL.md'))) {
|
|
console.warn(`Built-in skill source not found, skipping: ${sourceDir}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await mkdir(targetDir, { recursive: true });
|
|
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
|
console.info(`Installed built-in skill: ${slug} -> ${targetDir}`);
|
|
} catch (error) {
|
|
console.warn(`Failed to install built-in skill ${slug}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure third-party preinstalled skills from the ClawX-compatible manifest
|
|
* are copied into ~/.openclaw/skills in an idempotent, non-destructive way.
|
|
*/
|
|
export async function ensurePreinstalledSkillsInstalled(): Promise<void> {
|
|
const skills = await readPreinstalledManifest();
|
|
if (skills.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const sourceRoot = await preparePreinstalledSkillsSourceRootForDev();
|
|
if (!sourceRoot) {
|
|
console.warn('Preinstalled skills source root not found; skipping preinstall.');
|
|
return;
|
|
}
|
|
|
|
const lockVersions = await readPreinstalledLockVersions(sourceRoot);
|
|
const targetRoot = join(getOpenClawConfigDir(), 'skills');
|
|
await mkdir(targetRoot, { recursive: true });
|
|
|
|
const toEnable: string[] = [];
|
|
|
|
for (const spec of skills) {
|
|
const sourceDir = join(sourceRoot, spec.slug);
|
|
const sourceManifest = join(sourceDir, 'SKILL.md');
|
|
if (!(await fileExists(sourceManifest))) {
|
|
console.warn(`Preinstalled skill source missing SKILL.md, skipping: ${sourceDir}`);
|
|
continue;
|
|
}
|
|
|
|
const targetDir = join(targetRoot, spec.slug);
|
|
const targetManifest = join(targetDir, 'SKILL.md');
|
|
const markerPath = join(targetDir, PREINSTALLED_MARKER_NAME);
|
|
const desiredVersion = lockVersions.get(spec.slug)
|
|
|| (spec.version || 'unknown').trim()
|
|
|| 'unknown';
|
|
const marker = await tryReadPreinstalledMarker(markerPath);
|
|
|
|
if (existsSync(targetManifest)) {
|
|
if (!marker) {
|
|
continue;
|
|
}
|
|
if (marker.version === desiredVersion) {
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await mkdir(targetDir, { recursive: true });
|
|
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
|
|
|
const markerPayload: PreinstalledMarker = {
|
|
source: 'clawx-preinstalled',
|
|
slug: spec.slug,
|
|
version: desiredVersion,
|
|
installedAt: new Date().toISOString(),
|
|
};
|
|
await writeFile(markerPath, `${JSON.stringify(markerPayload, null, 2)}\n`, 'utf-8');
|
|
|
|
if (spec.autoEnable) {
|
|
toEnable.push(spec.slug);
|
|
}
|
|
|
|
console.info(`Installed preinstalled skill: ${spec.slug} -> ${targetDir}`);
|
|
} catch (error) {
|
|
console.warn(`Failed to install preinstalled skill ${spec.slug}:`, error);
|
|
}
|
|
}
|
|
|
|
if (toEnable.length > 0) {
|
|
try {
|
|
await setSkillsEnabled(toEnable, true);
|
|
} catch (error) {
|
|
console.warn('Failed to auto-enable preinstalled skills:', error);
|
|
}
|
|
}
|
|
}
|