feat: Enhance Marketplace and Skill Management UI with improved error handling and user feedback
- 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.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { gatewayManager } from '@electron/gateway/manager';
|
||||
import type { providerApiService } from '@electron/service/provider-api-service';
|
||||
import type { ClawHubService } from '@electron/gateway/clawhub';
|
||||
|
||||
export interface HostApiContext {
|
||||
gatewayManager: typeof gatewayManager;
|
||||
providerApiService: typeof providerApiService;
|
||||
mainWindow: BrowserWindow | null;
|
||||
clawHubService: ClawHubService;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { gatewayManager } from '@electron/gateway/manager';
|
||||
import { providerApiService } from '@electron/service/provider-api-service';
|
||||
import { ClawHubService } from '@electron/gateway/clawhub';
|
||||
import type { HostApiContext } from './context';
|
||||
import type { HostApiRequest } from './route-utils';
|
||||
import { normalizeRequest } from './route-utils';
|
||||
@@ -13,6 +14,7 @@ import { handleKnowledgeRoutes } from './routes/knowledge';
|
||||
import { handleModelRoutes } from './routes/models';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
import { handleSkillRoutes } from './routes/skills';
|
||||
|
||||
type RouteHandler = (
|
||||
request: ReturnType<typeof normalizeRequest>,
|
||||
@@ -29,6 +31,7 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleKnowledgeRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
handleSkillRoutes,
|
||||
];
|
||||
|
||||
function createContext(): HostApiContext {
|
||||
@@ -36,6 +39,7 @@ function createContext(): HostApiContext {
|
||||
gatewayManager,
|
||||
providerApiService,
|
||||
mainWindow: BrowserWindow.getAllWindows()[0] ?? null,
|
||||
clawHubService: new ClawHubService(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
152
electron/api/routes/skills.ts
Normal file
152
electron/api/routes/skills.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { HostApiResult } from '@src/types/runtime';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
|
||||
import { shell } from 'electron';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
|
||||
export async function handleSkillRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
): Promise<HostApiResult<unknown> | null> {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/skills/configs' && method === 'GET') {
|
||||
try {
|
||||
return ok(await getAllSkillConfigs());
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/skills/config' && method === 'PUT') {
|
||||
try {
|
||||
const body = parseJsonBody<{
|
||||
skillKey: string;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}>(request.body);
|
||||
if (!body?.skillKey || !String(body.skillKey).trim()) {
|
||||
return fail(400, 'skillKey is required');
|
||||
}
|
||||
const result = await updateSkillConfig(body.skillKey, {
|
||||
apiKey: body.apiKey,
|
||||
env: body.env,
|
||||
enabled: body.enabled,
|
||||
});
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/capability' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
capability: await ctx.clawHubService.getMarketplaceCapability(),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/search' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<Record<string, unknown>>(request.body);
|
||||
return ok({
|
||||
success: true,
|
||||
results: await ctx.clawHubService.search(body as { query: string; limit?: number }),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/install' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<Record<string, unknown>>(request.body);
|
||||
await ctx.clawHubService.install(body as { slug: string; version?: string; force?: boolean });
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/uninstall' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<Record<string, unknown>>(request.body);
|
||||
await ctx.clawHubService.uninstall(body as { slug: string });
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/list' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
results: await ctx.clawHubService.listInstalled(),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/open-readme' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(request.body);
|
||||
await ctx.clawHubService.openSkillReadme(
|
||||
body.skillKey || body.slug || '',
|
||||
body.slug,
|
||||
body.baseDir,
|
||||
);
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/open-path' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(request.body);
|
||||
await ctx.clawHubService.openSkillPath(
|
||||
body.skillKey || body.slug || '',
|
||||
body.slug,
|
||||
body.baseDir,
|
||||
);
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/skills-dir' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
path: `${getOpenClawConfigDir()}/skills`,
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/open-skills-dir' && method === 'POST') {
|
||||
try {
|
||||
const skillsDir = `${getOpenClawConfigDir()}/skills`;
|
||||
const openResult = await shell.openPath(skillsDir);
|
||||
if (openResult) {
|
||||
return fail(500, openResult);
|
||||
}
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
473
electron/gateway/clawhub.ts
Normal file
473
electron/gateway/clawhub.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* ClawHub Service
|
||||
* Manages interactions with the ClawHub CLI for skills management
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { shell } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath } from '../utils/paths';
|
||||
|
||||
export interface ClawHubSearchParams {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ClawHubInstallParams {
|
||||
slug: string;
|
||||
version?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ClawHubUninstallParams {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ClawHubSkillResult {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
author?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
export interface ClawHubInstalledSkillResult {
|
||||
slug: string;
|
||||
version: string;
|
||||
source?: string;
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
private cliEntryPath: string;
|
||||
private useNodeRunner: boolean;
|
||||
private cliAvailable: boolean;
|
||||
private ansiRegex: RegExp;
|
||||
|
||||
constructor() {
|
||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
||||
this.workDir = getOpenClawConfigDir();
|
||||
ensureDir(this.workDir);
|
||||
|
||||
const binPath = getClawHubCliBinPath();
|
||||
const entryPath = getClawHubCliEntryPath();
|
||||
|
||||
this.cliPath = binPath;
|
||||
this.cliEntryPath = entryPath;
|
||||
|
||||
// Detect if CLI is available
|
||||
const binExists = fs.existsSync(binPath);
|
||||
const entryExists = fs.existsSync(entryPath);
|
||||
this.cliAvailable = binExists || entryExists;
|
||||
|
||||
// If bin exists, use it directly; otherwise fall back to node runner
|
||||
this.useNodeRunner = !binExists;
|
||||
|
||||
const esc = String.fromCharCode(27);
|
||||
const csi = String.fromCharCode(155);
|
||||
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
|
||||
this.ansiRegex = new RegExp(pattern, 'g');
|
||||
}
|
||||
|
||||
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
|
||||
if (!this.cliAvailable) {
|
||||
return { mode: 'none', canSearch: false, canInstall: false, reason: 'ClawHub CLI not found' };
|
||||
}
|
||||
return { mode: 'clawhub', canSearch: true, canInstall: true };
|
||||
}
|
||||
|
||||
private stripAnsi(line: string): string {
|
||||
return line.replace(this.ansiRegex, '').trim();
|
||||
}
|
||||
|
||||
private extractFrontmatterName(skillManifestPath: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(skillManifestPath, 'utf8');
|
||||
// Match the first frontmatter block and read `name: ...`
|
||||
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return null;
|
||||
const body = frontmatterMatch[1];
|
||||
const nameMatch = body.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1].trim();
|
||||
return name || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSkillDirByManifestName(candidates: string[]): string | null {
|
||||
const skillsRoot = join(this.workDir, 'skills');
|
||||
if (!fs.existsSync(skillsRoot)) return null;
|
||||
|
||||
const wanted = new Set(
|
||||
candidates
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
.filter((v) => v.length > 0),
|
||||
);
|
||||
if (wanted.size === 0) return null;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillDir = join(skillsRoot, entry.name);
|
||||
const skillManifestPath = join(skillDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillManifestPath)) continue;
|
||||
|
||||
const frontmatterName = this.extractFrontmatterName(skillManifestPath);
|
||||
if (!frontmatterName) continue;
|
||||
if (wanted.has(frontmatterName.toLowerCase())) {
|
||||
return skillDir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a ClawHub CLI command
|
||||
*/
|
||||
private async runCommand(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.cliAvailable) {
|
||||
reject(new Error('ClawHub CLI not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
|
||||
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
|
||||
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
|
||||
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
|
||||
console.log(`Running ClawHub command: ${displayCommand}`);
|
||||
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
CI: 'true',
|
||||
FORCE_COLOR: '0',
|
||||
};
|
||||
if (this.useNodeRunner) {
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
}
|
||||
|
||||
const child = spawn(this.cliPath, commandArgs, {
|
||||
cwd: this.workDir,
|
||||
env: {
|
||||
...env,
|
||||
CLAWHUB_WORKDIR: this.workDir,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error('ClawHub process error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`ClawHub command failed with code ${code}`);
|
||||
console.error('Stderr:', stderr);
|
||||
reject(new Error(`Command failed: ${stderr || stdout}`));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills
|
||||
*/
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
if (!this.cliAvailable) {
|
||||
throw new Error('ClawHub CLI not found');
|
||||
}
|
||||
try {
|
||||
// If query is empty, use 'explore' to show trending skills
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
return this.explore({ limit: params.limit });
|
||||
}
|
||||
|
||||
const args = ['search', params.query];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output || output.includes('No skills found')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
return lines
|
||||
.map((line) => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format could be: slug vversion description (score)
|
||||
// Or sometimes: slug vversion description
|
||||
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
const version = match[2];
|
||||
let description = match[3];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for new clawhub search format without version:
|
||||
// slug name/description (score)
|
||||
match = cleanLine.match(/^(\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
let description = match[2];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version: 'latest', // Fallback version since it's not provided
|
||||
description,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore trending skills
|
||||
*/
|
||||
async explore(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
|
||||
if (!this.cliAvailable) {
|
||||
throw new Error('ClawHub CLI not found');
|
||||
}
|
||||
try {
|
||||
const args = ['explore'];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output) return [];
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
return lines
|
||||
.map((line) => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format: slug vversion time description
|
||||
// Example: my-skill v1.0.0 2 hours ago A great skill
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i);
|
||||
if (match) {
|
||||
return {
|
||||
slug: match[1],
|
||||
name: match[1],
|
||||
version: match[2],
|
||||
description: match[4],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub explore error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill
|
||||
*/
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
if (!this.cliAvailable) {
|
||||
throw new Error('ClawHub CLI not found');
|
||||
}
|
||||
const args = ['install', params.slug];
|
||||
|
||||
if (params.version) {
|
||||
args.push('--version', params.version);
|
||||
}
|
||||
|
||||
if (params.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
await this.runCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a skill
|
||||
*/
|
||||
async uninstall(params: ClawHubUninstallParams): Promise<void> {
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
// 1. Delete the skill directory
|
||||
const skillDir = join(this.workDir, 'skills', params.slug);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
console.log(`Deleting skill directory: ${skillDir}`);
|
||||
await fsPromises.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 2. Remove from lock.json
|
||||
const lockFile = join(this.workDir, '.clawhub', 'lock.json');
|
||||
if (fs.existsSync(lockFile)) {
|
||||
try {
|
||||
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
||||
if (lockData.skills && lockData.skills[params.slug]) {
|
||||
console.log(`Removing ${params.slug} from lock.json`);
|
||||
delete lockData.skills[params.slug];
|
||||
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update ClawHub lock file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
if (!this.cliAvailable) {
|
||||
throw new Error('ClawHub CLI not found');
|
||||
}
|
||||
try {
|
||||
const output = await this.runCommand(['list']);
|
||||
if (!output || output.includes('No installed skills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
return lines
|
||||
.map((line) => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
return {
|
||||
slug,
|
||||
version: match[2],
|
||||
source: 'openclaw-managed',
|
||||
baseDir: join(this.workDir, 'skills', slug),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((s): s is ClawHubInstalledSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub list error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
|
||||
const candidates = [skillKeyOrSlug, fallbackSlug]
|
||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||
.map((v) => v.trim());
|
||||
const uniqueCandidates = [...new Set(candidates)];
|
||||
if (preferredBaseDir && preferredBaseDir.trim() && fs.existsSync(preferredBaseDir.trim())) {
|
||||
return preferredBaseDir.trim();
|
||||
}
|
||||
const directSkillDir = uniqueCandidates
|
||||
.map((id) => join(this.workDir, 'skills', id))
|
||||
.find((dir) => fs.existsSync(dir));
|
||||
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill README/manual in default editor
|
||||
*/
|
||||
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
|
||||
// Try to find documentation file
|
||||
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
|
||||
let targetFile = '';
|
||||
|
||||
if (skillDir) {
|
||||
for (const file of possibleFiles) {
|
||||
const filePath = join(skillDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
targetFile = filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetFile) {
|
||||
// If no md file, just open the directory
|
||||
if (skillDir) {
|
||||
targetFile = skillDir;
|
||||
} else {
|
||||
throw new Error('Skill directory not found');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Open file with default application
|
||||
await shell.openPath(targetFile);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to open skill readme:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill path in file explorer
|
||||
*/
|
||||
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
if (!skillDir) {
|
||||
throw new Error('Skill directory not found');
|
||||
}
|
||||
const openResult = await shell.openPath(skillDir);
|
||||
if (openResult) {
|
||||
throw new Error(openResult);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
45
electron/gateway/handlers/skills.ts
Normal file
45
electron/gateway/handlers/skills.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getAllSkillConfigs, updateSkillConfig } from '@electron/utils/skill-config';
|
||||
import type { GatewayRpcReturns } from '../types';
|
||||
|
||||
export async function handleSkillsStatus(): Promise<GatewayRpcReturns['skills.status']> {
|
||||
const configs = await getAllSkillConfigs();
|
||||
|
||||
return {
|
||||
skills: Object.entries(configs).map(([skillKey, config]) => ({
|
||||
skillKey,
|
||||
slug: config.slug || skillKey,
|
||||
name: config.name || skillKey,
|
||||
description: config.description || '',
|
||||
disabled: config.enabled === false,
|
||||
emoji: config.icon,
|
||||
version: config.version || '1.0.0',
|
||||
author: config.author,
|
||||
config: {
|
||||
...(config.config || {}),
|
||||
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
||||
...(config.env ? { env: config.env } : {}),
|
||||
},
|
||||
bundled: typeof config.isBundled === 'boolean' ? config.isBundled : config.source === 'openclaw-bundled',
|
||||
always: Boolean(config.isCore),
|
||||
source: config.source,
|
||||
baseDir: config.baseDir,
|
||||
filePath: config.filePath,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSkillsUpdate(
|
||||
params: { skillKey: string; enabled?: boolean },
|
||||
): Promise<GatewayRpcReturns['skills.update']> {
|
||||
const { skillKey, enabled } = params;
|
||||
if (!skillKey || !String(skillKey).trim()) {
|
||||
throw new Error('skillKey is required');
|
||||
}
|
||||
|
||||
const result = await updateSkillConfig(String(skillKey).trim(), { enabled });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update skill');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import logManager from '@electron/service/logger';
|
||||
import type { GatewayEvent, RuntimeRefreshTopic } from './types';
|
||||
import * as chatHandlers from './handlers/chat';
|
||||
import * as providerHandlers from './handlers/provider';
|
||||
import * as skillHandlers from './handlers/skills';
|
||||
|
||||
type RuntimeChangeBroadcast = {
|
||||
topics: RuntimeRefreshTopic[];
|
||||
@@ -94,6 +95,10 @@ class GatewayManager {
|
||||
return providerHandlers.handleProviderList();
|
||||
case 'provider.getDefault':
|
||||
return providerHandlers.handleProviderGetDefault();
|
||||
case 'skills.status':
|
||||
return skillHandlers.handleSkillsStatus();
|
||||
case 'skills.update':
|
||||
return skillHandlers.handleSkillsUpdate(params);
|
||||
default:
|
||||
throw new Error(`Unknown gateway RPC method: ${method}`);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface GatewayRpcParams {
|
||||
};
|
||||
'provider.list': Record<string, never>;
|
||||
'provider.getDefault': Record<string, never>;
|
||||
'skills.status': Record<string, never>;
|
||||
'skills.update': {
|
||||
skillKey: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/// Gateway RPC 方法返回值映射
|
||||
@@ -79,4 +84,23 @@ export interface GatewayRpcReturns {
|
||||
'session.delete': { success: boolean };
|
||||
'provider.list': { accounts: any[]; defaultAccountId: string | null };
|
||||
'provider.getDefault': { accountId: string | null };
|
||||
'skills.status': {
|
||||
skills: Array<{
|
||||
skillKey: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
emoji?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
config?: Record<string, unknown>;
|
||||
bundled?: boolean;
|
||||
always?: boolean;
|
||||
source?: string;
|
||||
baseDir?: string;
|
||||
filePath?: string;
|
||||
}>;
|
||||
};
|
||||
'skills.update': { success: boolean };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { onProviderChange } from '@electron/service/provider-api-service';
|
||||
import { gatewayManager } from '@electron/gateway/manager';
|
||||
import { dispatchLocalHostApi } from '@electron/api/router';
|
||||
import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '@electron/utils/skill-config';
|
||||
|
||||
// 初始化 updater,确保在 app ready 之前或者之中注册好 IPC
|
||||
appUpdater.init();
|
||||
@@ -114,6 +115,14 @@ app.whenReady().then(async () => {
|
||||
await configManager.init();
|
||||
await themeManager.init();
|
||||
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
log.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
log.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
|
||||
gatewayManager.init();
|
||||
refreshProviderRuntime();
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@ export function getOpenClawDir(): string {
|
||||
return join(app.getAppPath(), 'node_modules', OPENCLAW_PACKAGE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function getResourcesDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return process.resourcesPath;
|
||||
}
|
||||
return join(app.getAppPath(), 'resources');
|
||||
}
|
||||
|
||||
export function getOpenClawResolvedDir(): string {
|
||||
const dir = getOpenClawDir();
|
||||
if (!existsSync(dir)) {
|
||||
@@ -57,6 +64,15 @@ export function getOpenClawEntryPath(): string {
|
||||
return join(getOpenClawDir(), OPENCLAW_ENTRY_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getClawHubCliBinPath(): string {
|
||||
const binName = process.platform === 'win32' ? 'clawhub.cmd' : 'clawhub';
|
||||
return join(app.getAppPath(), 'node_modules', '.bin', binName);
|
||||
}
|
||||
|
||||
export function getClawHubCliEntryPath(): string {
|
||||
return join(app.getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
|
||||
}
|
||||
|
||||
export function ensureDir(dir: string): string {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
615
electron/utils/skill-config.ts
Normal file
615
electron/utils/skill-config.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user