/** * 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; } interface OpenClawConfig { skills?: { entries?: Record; [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; apiKey?: string; env?: Record; 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 { try { await access(p, constants.F_OK); return true; } catch { return false; } } /** * Read the current OpenClaw config */ async function readConfig(): Promise { 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 { const json = JSON.stringify(config, null, 2); await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8'); } async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise { 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 { 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 { 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((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> { 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(); 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 { 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> { const result: Record = {}; 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 { 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; 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 = {}; 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> { const [config, scanned] = await Promise.all([readConfig(), scanSkillsDirectory()]); const entries = config.skills?.entries || {}; // Start with scanned directory data const result: Record = { ...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//. * 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 { 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 { 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); } } }