diff --git a/.gitignore b/.gitignore index 56278bf..64e1b39 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,10 @@ package-lock.json # Generated extension bridges (created by scripts/generate-ext-bridge.mjs) electron/extensions/_ext-bridge.generated.ts src/extensions/_ext-bridge.generated.ts + +# Local playground artifacts +playground/ + +# ClawX-biz bridge workspace artifacts +resources/enterprise-skills/ +resources/openclaw-plugins/skillshub/ diff --git a/README.ja-JP.md b/README.ja-JP.md index b4fc255..73aaf29 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -120,9 +120,9 @@ AIタスクを自動的に実行するようスケジュール設定できます ### 🧩 拡張可能なスキルシステム -事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。 +事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。 ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。 -Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。 +Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。 主な検索スキルで必要な環境変数: - `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり) diff --git a/README.md b/README.md index 795e364..25a1fe4 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig | Process management | Automatic gateway lifecycle management | | App updates | Startup update checks with a prompt before downloading or installing | | Multiple AI providers | Unified provider configuration panel | -| Skill/plugin installation | Built-in skill marketplace and management | +| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace | ### OpenClaw Inside @@ -120,9 +120,9 @@ The Cron page now lets you configure external delivery directly in the task form ### 🧩 Extensible Skill System -Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required. +Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds. ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime. -The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. +The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned. Environment variables for bundled search skills: - `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime) diff --git a/README.zh-CN.md b/README.zh-CN.md index 6676925..2de31d3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,9 +121,9 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan ### 🧩 可扩展技能系统 -通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。 +通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。 ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。 -Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。 +Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。 重点搜索技能所需环境变量: - `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth) diff --git a/clawx-extensions.json b/clawx-extensions.json index 997f68f..d98305a 100644 --- a/clawx-extensions.json +++ b/clawx-extensions.json @@ -1,7 +1,6 @@ { "extensions": { "main": [ - "builtin/clawhub-marketplace", "builtin/diagnostics" ], "renderer": [] diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts index 49559d1..dd193fe 100644 --- a/electron/api/routes/skills.ts +++ b/electron/api/routes/skills.ts @@ -1,10 +1,42 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config'; +import { getAllSkillConfigs, updateSkillConfig, updateSkillConfigs } from '../../utils/skill-config'; import { collectQuickAccessSkills, filterEnabledQuickAccessSkills, type QuickAccessRuntimeSkillStatus } from '../../utils/skill-quick-access'; -import type { ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../../gateway/clawhub'; +import { listLocalSkills } from '../../services/skills/local-skill-service'; +import type { MarketplaceInstallParams, MarketplaceSearchParams, MarketplaceUninstallParams } from '../../gateway/clawhub'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; +async function handleMarketplaceCapability(res: ServerResponse, ctx: HostApiContext): Promise { + sendJson(res, 200, { + success: true, + capability: await ctx.clawHubService.getMarketplaceCapability(), + }); +} + +async function handleMarketplaceSearch(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { + const body = await parseJsonBody(req); + sendJson(res, 200, { + success: true, + results: await ctx.clawHubService.search(body), + }); +} + +async function handleMarketplaceInstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { + const body = await parseJsonBody(req); + await ctx.clawHubService.install(body); + sendJson(res, 200, { success: true }); +} + +async function handleMarketplaceUninstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { + const body = await parseJsonBody(req); + await ctx.clawHubService.uninstall(body); + sendJson(res, 200, { success: true }); +} + +async function handleMarketplaceList(res: ServerResponse, ctx: HostApiContext): Promise { + sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() }); +} + export async function handleSkillRoutes( req: IncomingMessage, res: ServerResponse, @@ -20,10 +52,12 @@ export async function handleSkillRoutes( try { const body = await parseJsonBody<{ skillKey: string; + enabled?: boolean; apiKey?: string; env?: Record; }>(req); sendJson(res, 200, await updateSkillConfig(body.skillKey, { + enabled: body.enabled, apiKey: body.apiKey, env: body.env, })); @@ -33,6 +67,35 @@ export async function handleSkillRoutes( return true; } + if (url.pathname === '/api/skills/configs' && req.method === 'PATCH') { + try { + const body = await parseJsonBody<{ + updates?: Array<{ + skillKey: string; + enabled?: boolean; + apiKey?: string; + env?: Record; + }>; + }>(req); + sendJson(res, 200, await updateSkillConfigs(body.updates || [])); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + + if (url.pathname === '/api/skills/local' && req.method === 'GET') { + try { + sendJson(res, 200, { + success: true, + skills: await listLocalSkills(), + }); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + if (url.pathname === '/api/skills/quick-access' && req.method === 'POST') { try { const body = await parseJsonBody<{ @@ -63,12 +126,54 @@ export async function handleSkillRoutes( return true; } + if (url.pathname === '/api/skills/marketplace/capability' && req.method === 'GET') { + try { + await handleMarketplaceCapability(res, ctx); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + + if (url.pathname === '/api/skills/marketplace/search' && req.method === 'POST') { + try { + await handleMarketplaceSearch(req, res, ctx); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + + if (url.pathname === '/api/skills/marketplace/install' && req.method === 'POST') { + try { + await handleMarketplaceInstall(req, res, ctx); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + + if (url.pathname === '/api/skills/marketplace/uninstall' && req.method === 'POST') { + try { + await handleMarketplaceUninstall(req, res, ctx); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + + if (url.pathname === '/api/skills/marketplace/list' && req.method === 'GET') { + try { + await handleMarketplaceList(res, ctx); + } catch (error) { + sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); + } + return true; + } + if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') { try { - sendJson(res, 200, { - success: true, - capability: await ctx.clawHubService.getMarketplaceCapability(), - }); + await handleMarketplaceCapability(res, ctx); } catch (error) { sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); } @@ -77,11 +182,7 @@ export async function handleSkillRoutes( if (url.pathname === '/api/clawhub/search' && req.method === 'POST') { try { - const body = await parseJsonBody(req); - sendJson(res, 200, { - success: true, - results: await ctx.clawHubService.search(body), - }); + await handleMarketplaceSearch(req, res, ctx); } catch (error) { sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); } @@ -90,9 +191,7 @@ export async function handleSkillRoutes( if (url.pathname === '/api/clawhub/install' && req.method === 'POST') { try { - const body = await parseJsonBody(req); - await ctx.clawHubService.install(body); - sendJson(res, 200, { success: true }); + await handleMarketplaceInstall(req, res, ctx); } catch (error) { sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); } @@ -101,9 +200,7 @@ export async function handleSkillRoutes( if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') { try { - const body = await parseJsonBody(req); - await ctx.clawHubService.uninstall(body); - sendJson(res, 200, { success: true }); + await handleMarketplaceUninstall(req, res, ctx); } catch (error) { sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); } @@ -112,7 +209,7 @@ export async function handleSkillRoutes( if (url.pathname === '/api/clawhub/list' && req.method === 'GET') { try { - sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() }); + await handleMarketplaceList(res, ctx); } catch (error) { sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); } diff --git a/electron/extensions/builtin/clawhub-marketplace.ts b/electron/extensions/builtin/clawhub-marketplace.ts index 6ca203a..f1d4b62 100644 --- a/electron/extensions/builtin/clawhub-marketplace.ts +++ b/electron/extensions/builtin/clawhub-marketplace.ts @@ -5,36 +5,33 @@ import type { MarketplaceCapability, } from '../types'; import type { - ClawHubSearchParams, - ClawHubInstallParams, - ClawHubSkillResult, + MarketplaceSearchParams, + MarketplaceInstallParams, + MarketplaceSkillResult, } from '../../gateway/clawhub'; class ClawHubMarketplaceExtension implements MarketplaceProviderExtension { readonly id = 'builtin/clawhub-marketplace'; setup(_ctx: ExtensionContext): void { - // No setup needed -- search/install delegates to the ClawHubService CLI runner + // Built-in public ClawHub marketplace is disabled in community builds. } async getCapability(): Promise { return { - mode: 'clawhub', - canSearch: true, - canInstall: true, + mode: 'local-only', + canSearch: false, + canInstall: false, + reason: 'marketplace-disabled', }; } - async search(params: ClawHubSearchParams): Promise { - const { ClawHubService } = await import('../../gateway/clawhub'); - const svc = new ClawHubService(); - return svc.search(params); + async search(_params: MarketplaceSearchParams): Promise { + throw new Error('Marketplace search is disabled'); } - async install(params: ClawHubInstallParams): Promise { - const { ClawHubService } = await import('../../gateway/clawhub'); - const svc = new ClawHubService(); - return svc.install(params); + async install(_params: MarketplaceInstallParams): Promise { + throw new Error('Marketplace install is disabled'); } } diff --git a/electron/extensions/builtin/index.ts b/electron/extensions/builtin/index.ts index d1caa02..8a81abd 100644 --- a/electron/extensions/builtin/index.ts +++ b/electron/extensions/builtin/index.ts @@ -1,8 +1,6 @@ import { registerBuiltinExtension } from '../loader'; -import { createClawHubMarketplaceExtension } from './clawhub-marketplace'; import { createDiagnosticsExtension } from './diagnostics'; export function registerAllBuiltinExtensions(): void { - registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension); registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension); } diff --git a/electron/extensions/types.ts b/electron/extensions/types.ts index 10383c6..01bd5f8 100644 --- a/electron/extensions/types.ts +++ b/electron/extensions/types.ts @@ -4,6 +4,9 @@ import type { GatewayManager } from '../gateway/manager'; import type { HostEventBus } from '../api/event-bus'; import type { HostApiContext } from '../api/context'; import type { + MarketplaceSearchParams, + MarketplaceInstallParams, + MarketplaceSkillResult, ClawHubSearchParams, ClawHubInstallParams, ClawHubSkillResult, @@ -41,10 +44,14 @@ export interface MarketplaceCapability { export interface MarketplaceProviderExtension extends Extension { getCapability(): Promise; - search(params: ClawHubSearchParams): Promise; - install(params: ClawHubInstallParams): Promise; + search(params: MarketplaceSearchParams): Promise; + install(params: MarketplaceInstallParams): Promise; } +export type LegacyMarketplaceSearchParams = ClawHubSearchParams; +export type LegacyMarketplaceInstallParams = ClawHubInstallParams; +export type LegacyMarketplaceSkillResult = ClawHubSkillResult; + export interface AuthStatus { authenticated: boolean; expired: boolean; diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index dca3769..0a9a99f 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -1,29 +1,29 @@ /** * ClawHub Service - * Manages interactions with the ClawHub CLI for skills management + * Maintains marketplace-provider compatibility and managed skill uninstall/open helpers. */ -import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; -import { app, shell } from 'electron'; -import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths'; +import { shell } from 'electron'; +import { getOpenClawConfigDir, ensureDir } from '../utils/paths'; +import { removeSkillConfig } from '../utils/skill-config'; -export interface ClawHubSearchParams { +export interface MarketplaceSearchParams { query: string; limit?: number; } -export interface ClawHubInstallParams { +export interface MarketplaceInstallParams { slug: string; version?: string; force?: boolean; } -export interface ClawHubUninstallParams { +export interface MarketplaceUninstallParams { slug: string; } -export interface ClawHubSkillResult { +export interface MarketplaceSkillResult { slug: string; name: string; description: string; @@ -33,6 +33,11 @@ export interface ClawHubSkillResult { stars?: number; } +export type ClawHubSearchParams = MarketplaceSearchParams; +export type ClawHubInstallParams = MarketplaceInstallParams; +export type ClawHubUninstallParams = MarketplaceUninstallParams; +export type ClawHubSkillResult = MarketplaceSkillResult; + export interface ClawHubInstalledSkillResult { slug: string; version: string; @@ -40,41 +45,21 @@ export interface ClawHubInstalledSkillResult { baseDir?: string; } -interface ClawHubApiSearchEntry { - slug?: string; - displayName?: string; - summary?: string | null; - version?: string | null; - latestVersion?: { - version?: string | null; - } | null; -} - -interface ClawHubApiSearchResponse { - results?: ClawHubApiSearchEntry[]; -} - -interface ClawHubApiListResponse { - items?: ClawHubApiSearchEntry[]; -} - export interface MarketplaceProvider { getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>; - search(params: ClawHubSearchParams): Promise; - install(params: ClawHubInstallParams): Promise; + search(params: MarketplaceSearchParams): Promise; + install(params: MarketplaceInstallParams): Promise; } -const CLAWHUB_MARKETPLACE_API_BASE_URL = 'https://clawhub.ai'; -const CLAWHUB_MARKETPLACE_TIMEOUT_MS = 5_000; - export class ClawHubService { private workDir: string; - private cliPath: string; - private cliEntryPath: string; - private useNodeRunner: boolean; - private ansiRegex: RegExp; private marketplaceProvider: MarketplaceProvider | null = null; + constructor() { + this.workDir = getOpenClawConfigDir(); + ensureDir(this.workDir); + } + setMarketplaceProvider(provider: MarketplaceProvider): void { this.marketplaceProvider = provider; } @@ -83,107 +68,131 @@ export class ClawHubService { if (this.marketplaceProvider) { return this.marketplaceProvider.getCapability(); } - return { mode: 'clawhub', canSearch: true, canInstall: true }; - } - - constructor() { - // Use the user's OpenClaw config directory (~/.openclaw) for skill management - // This avoids installing skills into the project's openclaw submodule - this.workDir = getOpenClawConfigDir(); - ensureDir(this.workDir); - - const binPath = getClawHubCliBinPath(); - const entryPath = getClawHubCliEntryPath(); - - this.cliEntryPath = entryPath; - if (!app.isPackaged && fs.existsSync(binPath)) { - this.cliPath = binPath; - this.useNodeRunner = false; - } else { - this.cliPath = process.execPath; - this.useNodeRunner = true; - } - 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'); - } - - private stripAnsi(line: string): string { - return line.replace(this.ansiRegex, '').trim(); - } - - private mapMarketplaceEntry(entry: ClawHubApiSearchEntry): ClawHubSkillResult | null { - const slug = entry.slug?.trim(); - if (!slug) return null; - - const name = entry.displayName?.trim() || slug; - const description = entry.summary?.trim() || name; - const version = entry.version?.trim() || entry.latestVersion?.version?.trim() || 'latest'; - return { - slug, - name, - description, - version, + mode: 'local-only', + canSearch: false, + canInstall: false, + reason: 'marketplace-disabled', }; } - private async fetchMarketplaceJson(url: URL): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), CLAWHUB_MARKETPLACE_TIMEOUT_MS); + /** + * Search for skills via an extension-provided marketplace. + */ + async search(params: MarketplaceSearchParams): Promise { + if (this.marketplaceProvider) { + return this.marketplaceProvider.search(params); + } + throw new Error('Marketplace search is disabled'); + } + + /** + * Explore marketplace skills via the registered marketplace provider. + */ + async explore(params: { limit?: number } = {}): Promise { + if (this.marketplaceProvider) { + return this.marketplaceProvider.search({ query: '', limit: params.limit }); + } + throw new Error('Marketplace search is disabled'); + } + + /** + * Install a skill through an extension-provided marketplace. + */ + async install(params: MarketplaceInstallParams): Promise { + if (this.marketplaceProvider) { + return this.marketplaceProvider.install(params); + } + throw new Error('Marketplace install is disabled'); + } + + /** + * Uninstall a managed skill and remove its stored config. + */ + async uninstall(params: ClawHubUninstallParams): Promise { + const fsPromises = fs.promises; + + const skillDir = path.join(this.workDir, 'skills', params.slug); + if (fs.existsSync(skillDir)) { + console.log(`Deleting skill directory: ${skillDir}`); + await fsPromises.rm(skillDir, { recursive: true, force: true }); + } + + const lockFile = path.join(this.workDir, '.clawhub', 'lock.json'); + if (fs.existsSync(lockFile)) { + try { + const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as { + skills?: Record; + }; + 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); + } + } + + await removeSkillConfig(params.slug); + } + + /** + * List installed managed skills from the filesystem. + */ + async listInstalled(): Promise { + const skillsRoot = path.join(this.workDir, 'skills'); + if (!fs.existsSync(skillsRoot)) { + return []; + } try { - const response = await fetch(url.toString(), { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: controller.signal, - }); + const entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true }); + const items = await Promise.all(entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const skillDir = path.join(skillsRoot, entry.name); + const manifestPath = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(manifestPath)) return null; - if (!response.ok) { - throw new Error(`ClawHub marketplace HTTP ${response.status}`); - } + let version = 'unknown'; + const manifestJsonPath = path.join(skillDir, 'manifest.json'); + if (fs.existsSync(manifestJsonPath)) { + try { + const manifestJson = JSON.parse(await fs.promises.readFile(manifestJsonPath, 'utf8')) as { version?: string }; + version = manifestJson.version?.trim() || version; + } catch { + // Ignore malformed manifest.json + } + } - const contentType = response.headers.get('content-type')?.toLowerCase() || ''; - if (!contentType.includes('application/json')) { - throw new Error(`ClawHub marketplace returned non-JSON content: ${contentType || 'unknown'}`); - } + const originJsonPath = path.join(skillDir, '.clawhub', 'origin.json'); + if (fs.existsSync(originJsonPath)) { + try { + const originJson = JSON.parse(await fs.promises.readFile(originJsonPath, 'utf8')) as { installedVersion?: string }; + version = originJson.installedVersion?.trim() || version; + } catch { + // Ignore malformed origin.json + } + } - return await response.json() as T; - } finally { - clearTimeout(timeout); + return { + slug: entry.name, + version, + source: 'openclaw-managed', + baseDir: skillDir, + }; + })); + return items.filter((item): item is ClawHubInstalledSkillResult => item !== null); + } catch (error) { + console.error('ClawHub list error:', error); + return []; } } - private async searchMarketplaceHttp(params: ClawHubSearchParams): Promise { - const url = new URL('/api/v1/search', CLAWHUB_MARKETPLACE_API_BASE_URL); - url.searchParams.set('q', params.query); - if (params.limit) { - url.searchParams.set('limit', String(params.limit)); - } - - const response = await this.fetchMarketplaceJson(url); - return (response.results || []) - .map((entry) => this.mapMarketplaceEntry(entry)) - .filter((entry): entry is ClawHubSkillResult => entry !== null); - } - - private async exploreMarketplaceHttp(params: { limit?: number } = {}): Promise { - const url = new URL('/api/v1/skills', CLAWHUB_MARKETPLACE_API_BASE_URL); - if (params.limit) { - url.searchParams.set('limit', String(params.limit)); - } - - const response = await this.fetchMarketplaceJson(url); - return (response.items || []) - .map((entry) => this.mapMarketplaceEntry(entry)) - .filter((entry): entry is ClawHubSkillResult => entry !== null); - } - 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]; @@ -229,277 +238,6 @@ export class ClawHubService { return null; } - /** - * Run a ClawHub CLI command - */ - private async runCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - 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 isWin = process.platform === 'win32'; - const useShell = isWin && !this.useNodeRunner; - 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 spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath; - const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs; - const child = spawn(spawnCmd, spawnArgs, { - cwd: this.workDir, - shell: useShell, - 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. Delegates to the marketplace provider if one is set, - * otherwise falls back to the local ClawHub CLI. - */ - async search(params: ClawHubSearchParams): Promise { - if (this.marketplaceProvider) { - return this.marketplaceProvider.search(params); - } - try { - // If query is empty, use 'explore' to show trending skills - if (!params.query || params.query.trim() === '') { - return this.explore({ limit: params.limit }); - } - - try { - return await this.searchMarketplaceHttp(params); - } catch (httpError) { - console.warn('ClawHub HTTP search failed, falling back to CLI:', httpError); - } - - 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 { - try { - try { - return await this.exploreMarketplaceHttp(params); - } catch (httpError) { - console.warn('ClawHub HTTP explore failed, falling back to CLI:', httpError); - } - - 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. Delegates to the marketplace provider if one is set, - * otherwise falls back to the local ClawHub CLI. - */ - async install(params: ClawHubInstallParams): Promise { - if (this.marketplaceProvider) { - return this.marketplaceProvider.install(params); - } - 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 { - const fsPromises = fs.promises; - - // 1. Delete the skill directory - const skillDir = path.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 = path.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 { - 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: path.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) @@ -514,13 +252,9 @@ export class ClawHubService { return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates); } - /** - * Open skill README/manual in default editor - */ async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise { const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir); - // Try to find documentation file const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md']; let targetFile = ''; @@ -535,7 +269,6 @@ export class ClawHubService { } if (!targetFile) { - // If no md file, just open the directory if (skillDir) { targetFile = skillDir; } else { @@ -544,7 +277,6 @@ export class ClawHubService { } try { - // Open file with default application await shell.openPath(targetFile); return true; } catch (error) { @@ -553,9 +285,6 @@ export class ClawHubService { } } - /** - * Open skill path in file explorer - */ async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise { const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir); if (!skillDir) { diff --git a/electron/main/index.ts b/electron/main/index.ts index ee69595..bb17dd1 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -44,7 +44,7 @@ import { import { createSignalQuitHandler } from './signal-quit'; import { acquireProcessInstanceFileLock } from './process-instance-lock'; import { getSetting } from '../utils/store'; -import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; +import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config'; import { startHostApiServer } from '../api/server'; import { HostEventBus } from '../api/event-bus'; @@ -56,6 +56,11 @@ import { syncAllProviderAuthToRuntime } from '../services/providers/provider-run const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop'; const isE2EMode = process.env.CLAWX_E2E === '1'; const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim(); +const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim(); + +if (requestedRemoteDebuggingPort) { + app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort); +} if (isE2EMode && requestedUserDataDir) { app.setPath('userData', requestedUserDataDir); @@ -395,6 +400,21 @@ async function initialize(): Promise { }); } + // Keep community builds aligned with Clawx-biz by physically trimming + // bundled OpenClaw consumer skills on startup (dev + packaged), keeping only + // `skill-creator`. This also prunes stale openclaw.json entries for trimmed + // bundled skills so we do not keep `enabled: false` config for skills that no + // longer exist. + if (!isE2EMode) { + void trimBundledOpenClawSkillsAndConfigs().then(({ removed, removedConfigs, kept }) => { + if (removed > 0 || removedConfigs > 0) { + logger.info( + `Trimmed bundled OpenClaw skills: removed ${removed}, pruned configs ${removedConfigs}, kept ${kept.join(', ')}`, + ); + } + }); + } + // Pre-deploy bundled third-party skills from resources/preinstalled-skills. // This installs full skill directories (not only SKILL.md) in an idempotent, // non-destructive way and never blocks startup. diff --git a/electron/services/skills/local-skill-service.ts b/electron/services/skills/local-skill-service.ts new file mode 100644 index 0000000..d1da7ea --- /dev/null +++ b/electron/services/skills/local-skill-service.ts @@ -0,0 +1,429 @@ +import { access, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { basename, join, relative, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import YAML from 'yaml'; +import { listAgentsSnapshot } from '../../utils/agent-config'; +import { expandPath, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../../utils/paths'; +import { getAllSkillConfigs } from '../../utils/skill-config'; +import type { SkillConfigUpdates } from '../../utils/skill-config'; + +export interface LocalSkillMarketplaceMeta { + provider: string; + slug?: string; + installedVersion?: string; + manifestPath?: string; + originPath?: string; +} + +export interface LocalSkillRecord { + id: string; + slug?: string; + name: string; + description: string; + enabled: boolean; + icon?: string; + version?: string; + author?: string; + config?: SkillConfigUpdates; + isCore?: boolean; + isBundled?: boolean; + source?: string; + baseDir?: string; + filePath?: string; + marketplace?: LocalSkillMarketplaceMeta; +} + +type SourceDescriptor = { + root: string; + source: string; + priority: number; + allowedSkillSlugs?: Set; +}; + +type ParsedSkillManifest = { + id: string; + slug?: string; + name: string; + description: string; + icon?: string; + version?: string; + author?: string; + isCore?: boolean; +}; + +type ScannedSkillRecord = LocalSkillRecord & { + priority: number; +}; + +type OriginMeta = { + provider: string; + slug?: string; + installedVersion?: string; + source?: string; +}; + +type ManifestMeta = { + slug?: string; + version?: string; + author?: string; +}; + +type PreinstalledMeta = { + slug?: string; + version?: string; +}; + +const MAX_SKILL_FILE_BYTES = 256_000; +const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']); + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath, constants.F_OK); + return true; + } catch { + return false; + } +} + +function isInsideRoot(rootPath: string, candidatePath: string): boolean { + const rel = relative(rootPath, candidatePath); + return rel === '' || (!rel.startsWith('..') && rel !== '..'); +} + +function normalizeKey(value?: string | null): string { + return (value || '').trim().toLowerCase(); +} + +function dedupePaths(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const entry of paths) { + const normalized = resolve(entry); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + try { + const parsed = YAML.parse(match[1]); + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +function parseBodyDescription(content: string): string { + const lines = content.split(/\r?\n/); + let inFrontmatter = false; + let frontmatterClosed = false; + + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index] ?? ''; + const trimmed = rawLine.trim(); + + if (index === 0 && trimmed === '---') { + inFrontmatter = true; + continue; + } + if (inFrontmatter) { + if (trimmed === '---') { + inFrontmatter = false; + frontmatterClosed = true; + } + continue; + } + if (!trimmed) continue; + if (frontmatterClosed && trimmed === '---') continue; + if (/^#{1,6}\s+/.test(trimmed)) continue; + return trimmed.replace(/^[-*]\s+/, ''); + } + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (/^#{1,6}\s+/.test(trimmed)) { + return trimmed.replace(/^#{1,6}\s+/, ''); + } + } + + return 'No description available.'; +} + +function toStringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function toBooleanValue(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +async function parseSkillManifest(manifestPath: string, fallbackId: string): Promise { + const fileStat = await stat(manifestPath); + if (fileStat.size > MAX_SKILL_FILE_BYTES) { + return { + id: fallbackId, + name: fallbackId, + description: 'Description unavailable (SKILL.md exceeds size limit).', + }; + } + + const content = await readFile(manifestPath, 'utf-8'); + const frontmatter = parseFrontmatter(content); + const metadata = frontmatter.metadata && typeof frontmatter.metadata === 'object' + ? frontmatter.metadata as Record + : {}; + const openclawMeta = metadata.openclaw && typeof metadata.openclaw === 'object' + ? metadata.openclaw as Record + : {}; + + return { + id: toStringValue(openclawMeta.skillKey) || fallbackId, + slug: undefined, + name: toStringValue(frontmatter.name) || fallbackId, + description: toStringValue(frontmatter.description) || parseBodyDescription(content), + icon: toStringValue(openclawMeta.emoji), + version: toStringValue(frontmatter.version), + author: toStringValue(frontmatter.author), + isCore: toBooleanValue(openclawMeta.always) || false, + }; +} + +async function safeReadJson(filePath: string): Promise { + if (!(await pathExists(filePath))) return null; + try { + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function readOriginMeta(skillDir: string): Promise { + const parsed = await safeReadJson>(join(skillDir, '.clawhub', 'origin.json')); + if (!parsed) return null; + return { + provider: 'clawhub', + slug: toStringValue(parsed.slug), + installedVersion: toStringValue(parsed.installedVersion) || toStringValue(parsed.version), + source: toStringValue(parsed.source), + }; +} + +async function readManifestMeta(skillDir: string): Promise { + const parsed = await safeReadJson>(join(skillDir, 'manifest.json')); + if (!parsed) return null; + return { + slug: toStringValue(parsed.slug) || toStringValue(parsed.name), + version: toStringValue(parsed.version), + author: toStringValue(parsed.author), + }; +} + +async function readPreinstalledMeta(skillDir: string): Promise { + const parsed = await safeReadJson>(join(skillDir, '.clawx-preinstalled.json')); + if (!parsed) return null; + return { + slug: toStringValue(parsed.slug), + version: toStringValue(parsed.version), + }; +} + +async function resolveSafeRoot(root: string): Promise { + if (!(await pathExists(root))) return null; + try { + const rootStat = await stat(root); + if (!rootStat.isDirectory()) return null; + return await realpath(root); + } catch { + return null; + } +} + +async function inspectSkillDir( + descriptor: SourceDescriptor, + rootRealPath: string, + skillDir: string, + configs: Record, +): Promise { + const manifestPath = join(skillDir, 'SKILL.md'); + if (!(await pathExists(manifestPath))) return null; + + try { + const skillDirRealPath = await realpath(skillDir); + if (!isInsideRoot(rootRealPath, skillDirRealPath)) { + return null; + } + + const fallbackId = basename(skillDirRealPath); + const parsedManifest = await parseSkillManifest(manifestPath, fallbackId); + const [originMeta, manifestMeta, preinstalledMeta] = await Promise.all([ + readOriginMeta(skillDirRealPath), + readManifestMeta(skillDirRealPath), + readPreinstalledMeta(skillDirRealPath), + ]); + + const skillKey = parsedManifest.id || manifestMeta?.slug || originMeta?.slug || fallbackId; + const config = configs[skillKey] || {}; + const version = manifestMeta?.version || parsedManifest.version || originMeta?.installedVersion; + const source = descriptor.source; + const isBundled = source === 'openclaw-bundled' || Boolean(preinstalledMeta); + const marketplace = originMeta || manifestMeta + ? { + provider: originMeta?.provider || (manifestMeta ? 'manifest' : source), + slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId, + installedVersion: version, + manifestPath: manifestMeta ? join(skillDirRealPath, 'manifest.json') : undefined, + originPath: originMeta ? join(skillDirRealPath, '.clawhub', 'origin.json') : undefined, + } + : undefined; + + return { + id: skillKey, + slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId, + name: parsedManifest.name, + description: parsedManifest.description, + enabled: config.enabled !== false, + icon: parsedManifest.icon || (isBundled ? '🧩' : '📦'), + version, + author: manifestMeta?.author || parsedManifest.author, + config, + isCore: parsedManifest.isCore, + isBundled, + source, + baseDir: skillDirRealPath, + filePath: manifestPath, + marketplace, + priority: descriptor.priority, + }; + } catch { + return null; + } +} + +async function scanRoot( + descriptor: SourceDescriptor, + configs: Record, +): Promise { + const rootRealPath = await resolveSafeRoot(descriptor.root); + if (!rootRealPath) return []; + + const skillDirs = new Set(); + const rootManifest = join(descriptor.root, 'SKILL.md'); + if (await pathExists(rootManifest)) { + skillDirs.add(descriptor.root); + } + + try { + const entries = await readdir(descriptor.root, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.name === 'node_modules') continue; + const entryPath = join(descriptor.root, entry.name); + if (entry.isDirectory()) { + if (!descriptor.allowedSkillSlugs || descriptor.allowedSkillSlugs.has(entry.name)) { + skillDirs.add(entryPath); + } + continue; + } + if (entry.isSymbolicLink()) { + try { + const symlinkStat = await lstat(entryPath); + if (symlinkStat.isSymbolicLink()) { + const resolved = await stat(entryPath); + if (resolved.isDirectory()) { + skillDirs.add(entryPath); + } + } + } catch { + // Ignore broken symlinks. + } + } + } + } catch { + return []; + } + + const items = await Promise.all([...skillDirs].map((skillDir) => inspectSkillDir(descriptor, rootRealPath, skillDir, configs))); + return items.filter((item): item is ScannedSkillRecord => item != null); +} + +async function buildDescriptors(): Promise { + const agentsSnapshot = await listAgentsSnapshot(); + const workspaces = dedupePaths( + agentsSnapshot.agents + .map((agent) => expandPath(agent.workspace || '')) + .filter(Boolean), + ); + + return [ + ...workspaces.map((workspace) => ({ + root: join(workspace, 'skills'), + source: 'openclaw-workspace', + priority: 0, + })), + ...workspaces.map((workspace) => ({ + root: join(workspace, '.agents', 'skills'), + source: 'agents-skills-project', + priority: 1, + })), + { + root: join(homedir(), '.agents', 'skills'), + source: 'agents-skills-personal', + priority: 2, + }, + { + root: getOpenClawSkillsDir(), + source: 'openclaw-managed', + priority: 3, + }, + { + root: join(getOpenClawResolvedDir(), 'skills'), + source: 'openclaw-bundled', + priority: 4, + allowedSkillSlugs: BUNDLED_OPENCLAW_SKILL_ALLOWLIST, + }, + ]; +} + +function mergeScannedSkills(skills: ScannedSkillRecord[]): LocalSkillRecord[] { + const byKey = new Map(); + + for (const skill of skills) { + const key = normalizeKey(skill.id || skill.slug || skill.name || skill.baseDir); + if (!key) continue; + const existing = byKey.get(key); + if (!existing || skill.priority < existing.priority) { + byKey.set(key, skill); + } + } + + return [...byKey.values()] + .sort((left, right) => { + if (left.enabled !== right.enabled) { + return left.enabled ? -1 : 1; + } + if (left.isCore !== right.isCore) { + return left.isCore ? -1 : 1; + } + if (left.priority !== right.priority) { + return left.priority - right.priority; + } + return left.name.localeCompare(right.name); + }) + .map(({ priority: _priority, ...skill }) => skill); +} + +export async function listLocalSkills(): Promise { + const [descriptors, configs] = await Promise.all([ + buildDescriptors(), + getAllSkillConfigs(), + ]); + + const discovered = await Promise.all(descriptors.map((descriptor) => scanRoot(descriptor, configs))); + return mergeScannedSkills(discovered.flat()); +} diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index b1b696e..4684b61 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -5,24 +5,27 @@ * * All file I/O uses async fs/promises to avoid blocking the main thread. */ -import { readFile, writeFile, access, mkdir } from 'fs/promises'; +import { readFile, writeFile, access, mkdir, readdir, rm } from 'fs/promises'; import { existsSync } from 'fs'; import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { getOpenClawDir, getResourcesDir } from './paths'; +import { getOpenClawDir, getOpenClawResolvedDir, getResourcesDir } from './paths'; import { logger } from './logger'; import { cpAsyncSafe } from './plugin-install'; import { withConfigLock } from './config-mutex'; const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); +const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']); -interface SkillEntry { +export interface SkillConfigUpdates { enabled?: boolean; apiKey?: string; env?: Record; } +type SkillEntry = SkillConfigUpdates; + interface OpenClawConfig { skills?: { entries?: Record; @@ -117,59 +120,98 @@ export async function getSkillConfig(skillKey: string): Promise 0; + const hasEnv = !!entry.env && Object.keys(entry.env).length > 0; + return !hasEnabled && !hasApiKey && !hasEnv; +} + +async function applySkillConfigUpdates( + config: OpenClawConfig, + updates: Array<{ skillKey: string; remove?: boolean } & SkillConfigUpdates>, +): Promise { + if (!config.skills) { + config.skills = {}; + } + if (!config.skills.entries) { + config.skills.entries = {}; + } + + for (const update of updates) { + const skillKey = update.skillKey.trim(); + if (!skillKey) continue; + + if (update.remove) { + delete config.skills.entries[skillKey]; + continue; + } + + const entry = config.skills.entries[skillKey] || {}; + + if (update.enabled !== undefined) { + entry.enabled = update.enabled; + } + + if (update.apiKey !== undefined) { + const trimmed = update.apiKey.trim(); + if (trimmed) { + entry.apiKey = trimmed; + } else { + delete entry.apiKey; + } + } + + if (update.env !== undefined) { + const newEnv: Record = {}; + + for (const [key, value] of Object.entries(update.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; + } + } + + if (isEmptySkillEntry(entry)) { + delete config.skills.entries[skillKey]; + } else { + config.skills.entries[skillKey] = entry; + } + } + + if (config.skills.entries && Object.keys(config.skills.entries).length === 0) { + delete config.skills.entries; + } + if (config.skills && Object.keys(config.skills).length === 0) { + delete config.skills; + } +} + export async function updateSkillConfig( skillKey: string, - updates: { apiKey?: string; env?: Record } + updates: SkillConfigUpdates, +): Promise<{ success: boolean; error?: string }> { + return updateSkillConfigs([{ skillKey, ...updates }]); +} + +export async function updateSkillConfigs( + updates: Array<{ skillKey: string } & SkillConfigUpdates>, ): Promise<{ success: boolean; error?: string }> { try { return await withConfigLock(async () => { 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; - } - } - - // Save entry back - config.skills.entries[skillKey] = entry; - + await applySkillConfigUpdates(config, updates); await writeConfig(config); return { success: true }; }); @@ -179,6 +221,37 @@ export async function updateSkillConfig( } } +export async function removeSkillConfig(skillKey: string): Promise<{ success: boolean; error?: string }> { + return removeSkillConfigs([skillKey]); +} + +export async function removeSkillConfigs(skillKeys: string[]): Promise<{ success: boolean; removed: number; error?: string }> { + try { + return await withConfigLock(async () => { + const config = await readConfig(); + const existingEntries = config.skills?.entries || {}; + const normalizedSkillKeys = skillKeys + .map((skillKey) => skillKey.trim()) + .filter(Boolean); + const removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length; + + if (removed === 0) { + return { success: true, removed: 0 }; + } + + await applySkillConfigUpdates( + config, + normalizedSkillKeys.map((skillKey) => ({ skillKey, remove: true })), + ); + await writeConfig(config); + return { success: true, removed }; + }); + } catch (err) { + console.error('Failed to remove skill configs:', err); + return { success: false, removed: 0, error: String(err) }; + } +} + /** * Get all skill configs (for syncing to frontend) */ @@ -187,6 +260,61 @@ export async function getAllSkillConfigs(): Promise> return config.skills?.entries || {}; } +function getDisallowedBundledOpenClawSkillSlugs(bundledSkillSlugs: string[]): string[] { + return bundledSkillSlugs.filter((slug) => !BUNDLED_OPENCLAW_SKILL_ALLOWLIST.has(slug)); +} + +export async function trimBundledOpenClawSkills(options?: { bundledSkillsRoot?: string }): Promise<{ removed: number; removedSlugs: string[]; kept: string[] }> { + const bundledSkillsRoot = options?.bundledSkillsRoot || join(getOpenClawResolvedDir(), 'skills'); + if (!existsSync(bundledSkillsRoot)) { + return { removed: 0, removedSlugs: [], kept: Array.from(BUNDLED_OPENCLAW_SKILL_ALLOWLIST) }; + } + + try { + const entries = await readdir(bundledSkillsRoot, { withFileTypes: true }); + const disallowed = getDisallowedBundledOpenClawSkillSlugs( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name), + ); + + let removed = 0; + const removedSlugs: string[] = []; + for (const slug of disallowed) { + const skillDir = join(bundledSkillsRoot, slug); + if (!existsSync(join(skillDir, 'SKILL.md'))) { + continue; + } + await rm(skillDir, { recursive: true, force: true }); + removed += 1; + removedSlugs.push(slug); + } + + return { removed, removedSlugs, kept: Array.from(BUNDLED_OPENCLAW_SKILL_ALLOWLIST) }; + } catch (error) { + logger.warn('Failed to trim bundled OpenClaw skills:', error); + return { removed: 0, removedSlugs: [], kept: Array.from(BUNDLED_OPENCLAW_SKILL_ALLOWLIST) }; + } +} + +export async function trimBundledOpenClawSkillsAndConfigs( + options?: { bundledSkillsRoot?: string }, +): Promise<{ removed: number; removedSlugs: string[]; removedConfigs: number; kept: string[] }> { + const trimResult = await trimBundledOpenClawSkills(options); + const removeResult = trimResult.removedSlugs.length > 0 + ? await removeSkillConfigs(trimResult.removedSlugs) + : { success: true, removed: 0 }; + + if (!removeResult.success) { + logger.warn(`Failed to prune stale bundled skill configs: ${removeResult.error || 'unknown error'}`); + } + + return { + ...trimResult, + removedConfigs: removeResult.removed, + }; +} + /** * Built-in skills bundled with ClawX that should be pre-deployed to * ~/.openclaw/skills/ on first launch. These come from the openclaw package's diff --git a/harness/specs/tasks/decouple-skills-page-from-gateway.md b/harness/specs/tasks/decouple-skills-page-from-gateway.md new file mode 100644 index 0000000..063e3da --- /dev/null +++ b/harness/specs/tasks/decouple-skills-page-from-gateway.md @@ -0,0 +1,82 @@ +--- +id: decouple-skills-page-from-gateway +title: Decouple Skills page from gateway RPC and remove built-in public ClawHub marketplace +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Make Skills usable when Gateway is stopped or starting by loading local skills from the Main process first, writing skill enabled state directly to openclaw.json, and removing the built-in public ClawHub marketplace dependency. +touchedAreas: + - .gitignore + - harness/specs/tasks/decouple-skills-page-from-gateway.md + - electron/api/routes/skills.ts + - electron/gateway/clawhub.ts + - electron/main/index.ts + - electron/services/skills/** + - electron/utils/skill-config.ts + - electron/extensions/builtin/index.ts + - electron/extensions/builtin/clawhub-marketplace.ts + - electron/extensions/types.ts + - clawx-extensions.json + - package.json + - pnpm-lock.yaml + - playground/clawhub-skillshub-gateway-analysis.md + - playground/skills-page-decoupling-execution-plan.md + - README.md + - README.zh-CN.md + - README.ja-JP.md + - src/pages/Skills/index.tsx + - src/stores/skills.ts + - src/types/skill.ts + - src/i18n/locales/en/skills.json + - src/i18n/locales/zh/skills.json + - src/i18n/locales/ja/skills.json + - src/i18n/locales/ru/skills.json + - scripts/agent-browser/skills-local-first-smoke.sh + - scripts/bundle-openclaw.mjs + - tests/e2e/skills-gateway-readiness.spec.ts + - tests/unit/clawhub-service.test.ts + - tests/unit/local-skill-service.test.ts + - tests/unit/skill-config-bundled-defaults.test.ts + - tests/unit/skills-errors.test.ts + - tests/unit/skills-page-gateway-readiness.test.tsx + - tests/unit/skills-store-fetch-parallel.test.ts +expectedUserBehavior: + - Skills renders local managed/workspace/.agents skills even when Gateway is stopped. + - Skills refreshes local data immediately and merges Gateway runtime status later when available. + - Enabling and disabling skills updates openclaw.json without requiring Gateway RPC. + - Managed skill config writes go through Host API instead of direct Gateway RPC. + - Community builds do not expose the built-in public ClawHub marketplace. + - Enterprise SkillsHub marketplace providers can still plug into the existing capability/search/install routes. +requiredProfiles: + - fast + - comms +requiredRules: + - gateway-readiness-policy + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy +requiredTests: + - pnpm run typecheck + - tests/e2e/skills-gateway-readiness.spec.ts + - tests/unit/clawhub-service.test.ts + - tests/unit/local-skill-service.test.ts + - tests/unit/skill-config-bundled-defaults.test.ts + - tests/unit/skills-errors.test.ts + - tests/unit/skills-page-gateway-readiness.test.tsx + - tests/unit/skills-store-fetch-parallel.test.ts +acceptance: + - Renderer does not add direct IPC calls. + - Renderer does not fetch Gateway HTTP directly. + - Skills page first loads /api/skills/local and remains usable when Gateway is stopped. + - Gateway skills.status becomes a best-effort runtime merge, not a hard dependency for initial rendering. + - Enabling/disabling skills writes skills.entries..enabled in openclaw.json via Host API. + - Gateway-offline local scan includes the allowlisted bundled OpenClaw skill `skill-creator`. + - Non-allowlisted bundled OpenClaw skills are physically trimmed from the active OpenClaw runtime (dev + packaged), and stale openclaw.json entries for those removed bundled skills are pruned. + - Packaged OpenClaw bundles physically keep only the allowlisted bundled skill `skill-creator`. + - Uninstalling a managed skill removes skills.entries. instead of preserving stale config. + - Without an extension marketplace provider, /api/clawhub/capability reports local-only and install/search are unavailable. + - package.json no longer depends on npm clawhub. +docs: + required: false +--- + +Use this task spec when changing Skills page loading, config writes, or marketplace behavior across renderer/Main/Gateway boundaries. diff --git a/package.json b/package.json index 259d45f..bd607e3 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "postversion": "node scripts/post-version-push.mjs" }, "dependencies": { - "clawhub": "^0.5.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", "node-machine-id": "^1.1.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a46171..0dd8e17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: .: dependencies: - clawhub: - specifier: ^0.5.0 - version: 0.5.0 electron-store: specifier: ^11.0.2 version: 11.0.2 @@ -361,12 +358,6 @@ packages: zod: optional: true - '@ark/schema@0.56.0': - resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} - - '@ark/util@0.56.0': - resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} - '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -589,9 +580,6 @@ packages: '@cacheable/utils@2.4.0': resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} - '@clack/core@0.5.0': - resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} - '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} @@ -599,9 +587,6 @@ packages: resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} engines: {node: '>= 20.12.0'} - '@clack/prompts@0.11.0': - resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@clack/prompts@1.2.0': resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} @@ -2922,12 +2907,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arkregex@0.0.5: - resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} - - arktype@2.2.0: - resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} - asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} @@ -3253,27 +3232,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawhub@0.5.0: - resolution: {integrity: sha512-tIPoup8mY3ojR+fzzf85ft+vrhMd6u6188QzBEOf/f5/0NSoWW0fl7ojw6VgVSLbBtLa5MGQDxSuZkf9TqPwIw==} - engines: {node: '>=20'} - hasBin: true - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-spinners@3.4.0: - resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} - engines: {node: '>=18.20'} - cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -3900,9 +3866,6 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -4377,14 +4340,6 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-network-error@1.3.1: - resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} - engines: {node: '>=16'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4403,10 +4358,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -4601,10 +4552,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - log-symbols@7.0.1: - resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} - engines: {node: '>=18'} - long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4867,11 +4814,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - mime@4.1.0: - resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} - engines: {node: '>=16'} - hasBin: true - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5111,10 +5053,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -5161,10 +5099,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - ora@9.3.0: - resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} - engines: {node: '>=20'} - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -5197,10 +5131,6 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} - p-retry@7.1.1: - resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} - engines: {node: '>=20'} - p-timeout@7.0.1: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} @@ -5716,10 +5646,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -5983,10 +5909,6 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - stdin-discarder@0.3.1: - resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} - engines: {node: '>=18'} - streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -5998,10 +5920,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} - engines: {node: '>=20'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -6687,10 +6605,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -6761,12 +6675,6 @@ snapshots: optionalDependencies: zod: 4.4.3 - '@ark/schema@0.56.0': - dependencies: - '@ark/util': 0.56.0 - - '@ark/util@0.56.0': {} - '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7171,11 +7079,6 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@clack/core@0.5.0': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.2.0': dependencies: fast-wrap-ansi: 0.1.6 @@ -7186,12 +7089,6 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 - '@clack/prompts@0.11.0': - dependencies: - '@clack/core': 0.5.0 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.2.0': dependencies: '@clack/core': 1.2.0 @@ -9675,16 +9572,6 @@ snapshots: aria-query@5.3.2: {} - arkregex@0.0.5: - dependencies: - '@ark/util': 0.56.0 - - arktype@2.2.0: - dependencies: - '@ark/schema': 0.56.0 - '@ark/util': 0.56.0 - arkregex: 0.0.5 - asn1.js@5.4.1: dependencies: bn.js: 4.12.3 @@ -10038,32 +9925,12 @@ snapshots: dependencies: clsx: 2.1.1 - clawhub@0.5.0: - dependencies: - '@clack/prompts': 0.11.0 - arktype: 2.2.0 - commander: 14.0.3 - fflate: 0.8.2 - ignore: 7.0.5 - json5: 2.2.3 - mime: 4.1.0 - ora: 9.3.0 - p-retry: 7.1.1 - semver: 7.7.4 - undici: 7.24.6 - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - cli-spinners@2.9.2: {} - cli-spinners@3.4.0: {} - cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -10786,8 +10653,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - fflate@0.8.2: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -11357,10 +11222,6 @@ snapshots: is-interactive@1.0.0: {} - is-interactive@2.0.0: {} - - is-network-error@1.3.1: {} - is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -11371,8 +11232,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-unicode-supported@2.1.0: {} - isarray@1.0.0: {} isbinaryfile@5.0.7: {} @@ -11601,11 +11460,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - log-symbols@7.0.1: - dependencies: - is-unicode-supported: 2.1.0 - yoctocolors: 2.1.2 - long@5.3.2: {} longest-streak@3.1.0: {} @@ -12106,8 +11960,6 @@ snapshots: mime@3.0.0: {} - mime@4.1.0: {} - mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -12339,10 +12191,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - openai@6.26.0(ws@8.20.1)(zod@4.4.3): optionalDependencies: ws: 8.20.1 @@ -12446,17 +12294,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - ora@9.3.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 3.4.0 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 7.0.1 - stdin-discarder: 0.3.1 - string-width: 8.2.0 - p-cancelable@2.1.1: {} p-limit@2.3.0: @@ -12487,10 +12324,6 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 - p-retry@7.1.1: - dependencies: - is-network-error: 1.3.1 - p-timeout@7.0.1: {} p-try@2.2.0: {} @@ -13008,11 +12841,6 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - retry@0.12.0: {} retry@0.13.1: {} @@ -13328,8 +13156,6 @@ snapshots: std-env@4.0.0: {} - stdin-discarder@0.3.1: {} - streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -13351,11 +13177,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 - string-width@8.2.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -14010,8 +13831,6 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/scripts/agent-browser/skills-local-first-smoke.sh b/scripts/agent-browser/skills-local-first-smoke.sh new file mode 100755 index 0000000..5ec3b06 --- /dev/null +++ b/scripts/agent-browser/skills-local-first-smoke.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +CDP_PORT="${CDP_PORT:-9222}" +OUT_DIR="${OUT_DIR:-playground/agent-browser}" +mkdir -p "$OUT_DIR" + +agent-browser connect "$CDP_PORT" +agent-browser tab > "$OUT_DIR/00-tabs.txt" +agent-browser snapshot -i > "$OUT_DIR/01-initial.snapshot.txt" +agent-browser screenshot "$OUT_DIR/01-initial.png" + +agent-browser find text "技能" click || agent-browser find text "Skills" click || true +agent-browser wait 1000 +agent-browser snapshot -i > "$OUT_DIR/02-skills.snapshot.txt" +agent-browser screenshot "$OUT_DIR/02-skills.png" diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index 1ef5c62..f8912ab 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -23,6 +23,7 @@ import { patchExtensionOpenClawSelfImports } from './openclaw-self-import-patch. const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build', 'openclaw'); const NODE_MODULES = path.join(ROOT, 'node_modules'); +const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']); // On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars). function normWin(p) { @@ -57,6 +58,23 @@ function shouldCopyOpenClawPackageEntry(src) { return true; } +function trimBundledOpenClawSkills(skillsRoot) { + if (!fs.existsSync(skillsRoot)) return { removed: 0, kept: [...BUNDLED_OPENCLAW_SKILL_ALLOWLIST] }; + + let removed = 0; + for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (BUNDLED_OPENCLAW_SKILL_ALLOWLIST.has(entry.name)) continue; + + const skillDir = path.join(skillsRoot, entry.name); + if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) continue; + fs.rmSync(skillDir, { recursive: true, force: true }); + removed += 1; + } + + return { removed, kept: [...BUNDLED_OPENCLAW_SKILL_ALLOWLIST] }; +} + function removeDirRobust(targetDir) { if (!fs.existsSync(targetDir)) return; @@ -97,6 +115,11 @@ fs.cpSync(openclawReal, OUTPUT, { filter: shouldCopyOpenClawPackageEntry, }); +const bundledSkillsTrim = trimBundledOpenClawSkills(path.join(OUTPUT, 'skills')); +if (bundledSkillsTrim.removed > 0) { + echo` Trimmed bundled OpenClaw skills: removed ${bundledSkillsTrim.removed}, kept ${bundledSkillsTrim.kept.join(', ')}`; +} + // 4. Recursively collect ALL transitive dependencies via pnpm virtual store BFS // // pnpm structure example: diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 72d04f3..d60be44 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -3,8 +3,8 @@ "subtitle": "Browse and manage AI capabilities", "refresh": "Refresh", "openFolder": "Open Skills Folder", - "gatewayWarning": "Gateway is not running. Skills cannot be loaded without an active Gateway.", - "gatewayStarting": "Gateway is starting. Skills will load automatically once it is ready.", + "gatewayWarning": "Gateway is not running. Local skills are still available, but runtime status is unavailable.", + "gatewayStarting": "Gateway is starting. Local skills are available now; runtime status will appear once it is ready.", "tabs": { "installed": "Installed", "marketplace": "Marketplace" @@ -12,7 +12,9 @@ "filter": { "all": "All ({{count}})", "builtIn": "Built-in ({{count}})", - "marketplace": "Marketplace ({{count}})" + "marketplace": "Marketplace ({{count}})", + "enabledList": "Enabled ({{count}})", + "disabledList": "Disabled ({{count}})" }, "search": "Search skills...", "searchMarketplace": "Search marketplace...", @@ -73,10 +75,11 @@ }, "source": { "badge": { - "bundled": "Bundled", + "bundled": "Bundled dir", "managed": "Managed", "workspace": "Workspace", "extra": "Extra dirs", + "plugin": "Plugin dir", "agentsPersonal": "Personal .agents", "agentsProject": "Project .agents", "unknown": "Unknown source" diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json index f56dea7..16d4290 100644 --- a/src/i18n/locales/ja/skills.json +++ b/src/i18n/locales/ja/skills.json @@ -3,8 +3,8 @@ "subtitle": "AI機能の閲覧と管理", "refresh": "更新", "openFolder": "スキルフォルダを開く", - "gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。", - "gatewayStarting": "ゲートウェイを起動中です。準備ができしだいスキルを自動で読み込みます。", + "gatewayWarning": "ゲートウェイは停止しています。ローカルスキルは利用できますが、ランタイム状態は取得できません。", + "gatewayStarting": "ゲートウェイを起動しています。ローカルスキルはすでに利用でき、ランタイム状態は準備完了後に反映されます。", "tabs": { "installed": "インストール済み", "marketplace": "マーケットプレイス" @@ -12,7 +12,9 @@ "filter": { "all": "すべて ({{count}})", "builtIn": "内蔵 ({{count}})", - "marketplace": "マーケットプレイス ({{count}})" + "marketplace": "マーケットプレイス ({{count}})", + "enabledList": "有効 ({{count}})", + "disabledList": "無効 ({{count}})" }, "search": "スキルを検索...", "searchMarketplace": "マーケットプレイスを検索...", @@ -73,10 +75,11 @@ }, "source": { "badge": { - "bundled": "内蔵", + "bundled": "内蔵ディレクトリ", "managed": "管理ディレクトリ", "workspace": "ワークスペース", "extra": "追加ディレクトリ", + "plugin": "プラグインディレクトリ", "agentsPersonal": "個人 .agents", "agentsProject": "プロジェクト .agents", "unknown": "不明なソース" diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json index 9dc1458..a1b9a7f 100644 --- a/src/i18n/locales/ru/skills.json +++ b/src/i18n/locales/ru/skills.json @@ -3,8 +3,8 @@ "subtitle": "Просмотр и управление AI-возможностями", "refresh": "Обновить", "openFolder": "Открыть папку навыков", - "gatewayWarning": "Шлюз не запущен. Навыки не могут быть загружены без активного шлюза.", - "gatewayStarting": "Шлюз запускается. Навыки загрузятся автоматически, когда он будет готов.", + "gatewayWarning": "Шлюз не запущен. Локальные навыки по-прежнему доступны, но статус рантайма недоступен.", + "gatewayStarting": "Шлюз запускается. Локальные навыки уже доступны, а статус рантайма появится после готовности шлюза.", "tabs": { "installed": "Установленные", "marketplace": "Маркетплейс" @@ -12,7 +12,9 @@ "filter": { "all": "Все ({{count}})", "builtIn": "Встроенные ({{count}})", - "marketplace": "Маркетплейс ({{count}})" + "marketplace": "Маркетплейс ({{count}})", + "enabledList": "Включено ({{count}})", + "disabledList": "Отключено ({{count}})" }, "search": "Поиск навыков...", "searchMarketplace": "Поиск на маркетплейсе...", @@ -73,10 +75,11 @@ }, "source": { "badge": { - "bundled": "В комплекте", + "bundled": "Встроенный каталог", "managed": "Управляемый", "workspace": "Рабочая область", "extra": "Дополнительные директории", + "plugin": "Каталог плагина", "agentsPersonal": "Личные .agents", "agentsProject": "Проектные .agents", "unknown": "Неизвестный источник" diff --git a/src/i18n/locales/zh/skills.json b/src/i18n/locales/zh/skills.json index 2fd2fc8..c8efcd9 100644 --- a/src/i18n/locales/zh/skills.json +++ b/src/i18n/locales/zh/skills.json @@ -3,8 +3,8 @@ "subtitle": "浏览和管理 AI 能力", "refresh": "刷新", "openFolder": "打开技能文件夹", - "gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。", - "gatewayStarting": "网关正在启动。待其就绪后会自动加载技能。", + "gatewayWarning": "网关未运行。你仍可查看本地技能,但运行时状态暂不可用。", + "gatewayStarting": "网关正在启动。本地技能已可用,运行时状态会在就绪后自动补充。", "tabs": { "installed": "已安装", "marketplace": "市场" @@ -12,7 +12,9 @@ "filter": { "all": "全部 ({{count}})", "builtIn": "内置 ({{count}})", - "marketplace": "市场 ({{count}})" + "marketplace": "市场 ({{count}})", + "enabledList": "已开启 ({{count}})", + "disabledList": "已禁用 ({{count}})" }, "search": "搜索技能...", "searchMarketplace": "搜索市场...", @@ -73,10 +75,11 @@ }, "source": { "badge": { - "bundled": "内置", + "bundled": "内置目录", "managed": "托管目录", "workspace": "工作区", "extra": "额外目录", + "plugin": "插件目录", "agentsPersonal": "个人 .agents", "agentsProject": "项目 .agents", "unknown": "未知来源" diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index 9acbe26..dd85ad7 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -11,7 +11,6 @@ import { X, AlertCircle, Trash2, - RefreshCw, FolderOpen, Copy, } from 'lucide-react'; @@ -26,7 +25,6 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; import { invokeIpc } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; -import { trackUiEvent } from '@/lib/telemetry'; import { toast } from 'sonner'; import type { Skill } from '@/types/skill'; import type { GatewayStatus } from '@/types/gateway'; @@ -90,18 +88,23 @@ interface SkillDetailDialogProps { function resolveSkillSourceLabel(skill: Skill, t: TFunction<'skills'>): string { const source = (skill.source || '').trim().toLowerCase(); if (!source) { - if (skill.isBundled) return t('source.badge.bundled', { defaultValue: 'Bundled' }); + if (skill.isBundled) return t('source.badge.bundled', { defaultValue: 'Bundled dir' }); return t('source.badge.unknown', { defaultValue: 'Unknown source' }); } - if (source === 'openclaw-bundled') return t('source.badge.bundled', { defaultValue: 'Bundled' }); + if (source === 'openclaw-bundled') return t('source.badge.bundled', { defaultValue: 'Bundled dir' }); if (source === 'openclaw-managed') return t('source.badge.managed', { defaultValue: 'Managed' }); if (source === 'openclaw-workspace') return t('source.badge.workspace', { defaultValue: 'Workspace' }); if (source === 'openclaw-extra') return t('source.badge.extra', { defaultValue: 'Extra dirs' }); + if (source === 'openclaw-plugin') return t('source.badge.plugin', { defaultValue: 'Plugin dir' }); if (source === 'agents-skills-personal') return t('source.badge.agentsPersonal', { defaultValue: 'Personal .agents' }); if (source === 'agents-skills-project') return t('source.badge.agentsProject', { defaultValue: 'Project .agents' }); return source; } +function canUninstallSkill(skill: Skill): boolean { + return (skill.source || '').trim().toLowerCase() === 'openclaw-managed'; +} + function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOpenFolder }: SkillDetailDialogProps) { const { t } = useTranslation('skills'); const [openedSkillFile, setOpenedSkillFile] = useState(null); @@ -119,6 +122,8 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp if (!skill) return null; + const uninstallable = canUninstallSkill(skill); + return ( !open && onClose()}> @@ -147,9 +152,11 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp {skill.name}
- - v{skill.version} - + {skill.version && ( + + v{skill.version} + + )} {skill.isCore ? t('detail.coreSystem') : skill.isBundled ? t('detail.bundled') : t('detail.userInstalled')} @@ -224,7 +231,7 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp variant="outline" className="w-full h-[42px] text-meta rounded-full font-semibold shadow-sm bg-transparent border-black/20 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-foreground/80 hover:text-foreground" onClick={() => { - if (!skill.isBundled && onUninstall && skill.slug) { + if (uninstallable && onUninstall && skill.slug) { onUninstall(skill.slug); onClose(); } else { @@ -232,7 +239,7 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp } }} > - {!skill.isBundled && onUninstall + {uninstallable && onUninstall ? t('detail.uninstall') : (skill.enabled ? t('detail.disable') : t('detail.enable'))} @@ -266,7 +273,8 @@ export function Skills() { const [installQuery, setInstallQuery] = useState(''); const [installSheetOpen, setInstallSheetOpen] = useState(false); const [selectedSkill, setSelectedSkill] = useState(null); - const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); + const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); + const [marketplaceAvailable, setMarketplaceAvailable] = useState(false); const gatewayRunning = gatewayStatus.state === 'running'; const gatewayReportedReady = gatewayStatus.gatewayReady !== false; @@ -290,13 +298,6 @@ export function Skills() { }, [gatewayBannerState]); useEffect(() => { - if (!gatewayRunning) { - setSkillsFeatureReady(false); - return; - } - - setSkillsFeatureReady(gatewayReportedReady); - let cancelled = false; let retryTimer: ReturnType | null = null; @@ -310,9 +311,10 @@ export function Skills() { } }; + setSkillsFeatureReady(false); void attemptFetch(); - if (!gatewayReportedReady) { + if (gatewayRunning && !gatewayReportedReady) { retryTimer = setInterval(() => { void attemptFetch(); }, 5_000); @@ -326,25 +328,35 @@ export function Skills() { }; }, [fetchSkills, gatewayReportedReady, gatewayRunning, gatewayRuntimeKey]); + useEffect(() => { + let cancelled = false; + void hostApiFetch<{ success: boolean; capability?: { canSearch?: boolean; canInstall?: boolean } }>('/api/skills/marketplace/capability') + .then((result) => { + if (cancelled) return; + setMarketplaceAvailable(Boolean(result.success && (result.capability?.canInstall || result.capability?.canSearch))); + }) + .catch(() => { + if (!cancelled) setMarketplaceAvailable(false); + }); + return () => { + cancelled = true; + }; + }, []); + const safeSkills = Array.isArray(skills) ? skills : []; + const enabledSkillsCount = safeSkills.filter((skill) => skill.enabled).length; + const disabledSkillsCount = safeSkills.filter((skill) => !skill.enabled).length; const filteredSkills = safeSkills.filter((skill) => { const q = searchQuery.toLowerCase().trim(); - const matchesSearch = - q.length === 0 || - skill.name.toLowerCase().includes(q) || - skill.description.toLowerCase().includes(q) || - skill.id.toLowerCase().includes(q) || - (skill.slug || '').toLowerCase().includes(q) || - (skill.author || '').toLowerCase().includes(q); - - let matchesSource = true; - if (selectedSource === 'built-in') { - matchesSource = !!skill.isBundled; - } else if (selectedSource === 'marketplace') { - matchesSource = !skill.isBundled; - } - - return matchesSearch && matchesSource; + const matchesSearch = q.length === 0 + || skill.name.toLowerCase().includes(q) + || skill.description.toLowerCase().includes(q) + || skill.id.toLowerCase().includes(q) + || (skill.slug || '').toLowerCase().includes(q) + || (skill.author || '').toLowerCase().includes(q); + const matchesStatus = statusFilter === 'all' + || (statusFilter === 'enabled' ? skill.enabled : !skill.enabled); + return matchesSearch && matchesStatus; }).sort((a, b) => { if (a.enabled && !b.enabled) return -1; if (!a.enabled && b.enabled) return 1; @@ -353,40 +365,6 @@ export function Skills() { return a.name.localeCompare(b.name); }); - const sourceStats = { - all: safeSkills.length, - builtIn: safeSkills.filter(s => s.isBundled).length, - marketplace: safeSkills.filter(s => !s.isBundled).length, - }; - - const bulkToggleVisible = useCallback(async (enable: boolean) => { - const candidates = filteredSkills.filter((skill) => !skill.isCore && skill.enabled !== enable); - if (candidates.length === 0) { - toast.info(enable ? t('toast.noBatchEnableTargets') : t('toast.noBatchDisableTargets')); - return; - } - - let succeeded = 0; - for (const skill of candidates) { - try { - if (enable) { - await enableSkill(skill.id); - } else { - await disableSkill(skill.id); - } - succeeded += 1; - } catch { - // Continue to next skill and report final summary. - } - } - - trackUiEvent('skills.batch_toggle', { enable, total: candidates.length, succeeded }); - if (succeeded === candidates.length) { - toast.success(enable ? t('toast.batchEnabled', { count: succeeded }) : t('toast.batchDisabled', { count: succeeded })); - return; - } - toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length })); - }, [disableSkill, enableSkill, filteredSkills, t]); const handleToggle = useCallback(async (skillId: string, enable: boolean) => { try { @@ -404,6 +382,10 @@ export function Skills() { const hasInstalledSkills = safeSkills.some(s => !s.isBundled); + const handleStatusFilterClick = useCallback((nextFilter: 'enabled' | 'disabled') => { + setStatusFilter((current) => (current === nextFilter ? 'all' : nextFilter)); + }, []); + const handleOpenSkillsFolder = useCallback(async () => { try { const skillsDir = await invokeIpc('openclaw:getSkillsDir'); @@ -469,7 +451,6 @@ export function Skills() { const handleInstall = useCallback(async (slug: string) => { try { await installSkill(slug); - await enableSkill(slug); toast.success(t('toast.installed')); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -479,8 +460,7 @@ export function Skills() { toast.error(t('toast.failedInstall') + ': ' + errorMessage); } } - }, [installSkill, enableSkill, t, skillsDirPath]); - + }, [installSkill, t, skillsDirPath]); const handleUninstall = useCallback(async (slug: string) => { try { await uninstallSkill(slug); @@ -557,7 +537,7 @@ export function Skills() { {/* Sub Navigation and Actions */}
-
+
)}
- -
- - - -
+ +
- - - - + {marketplaceAvailable && ( + + )}
@@ -677,13 +640,6 @@ export function Skills() {

{skill.name}

{skill.isCore ? ( - ) : skill.isBundled ? ( - - ) : null} - {skill.slug && skill.slug !== skill.name ? ( - - {skill.slug} - ) : null}

@@ -718,7 +674,7 @@ export function Skills() {

- + normalizeSkillKey(value)) + .filter(Boolean); + + return aliases.some((alias) => BUNDLED_OPENCLAW_SKILL_ALLOWLIST.has(alias)); +} + +function shouldAppendGatewayOnlySkill(status: GatewaySkillStatus): boolean { + return GATEWAY_ONLY_APPENDABLE_SOURCES.has((status.source || '').trim().toLowerCase()); +} + +function mapGatewaySkillToSkill(status: GatewaySkillStatus, existing?: Skill): Skill { + return { + id: status.skillKey, + slug: status.slug || existing?.slug || status.skillKey, + name: status.name || existing?.name || status.skillKey, + description: status.description || existing?.description || '', + enabled: !status.disabled, + icon: status.emoji || existing?.icon || '📦', + version: status.version || existing?.version, + author: status.author || existing?.author, + config: { + ...(existing?.config || {}), + ...(status.config || {}), + }, + isCore: Boolean((status.bundled && status.always) || existing?.isCore), + isBundled: status.bundled ?? existing?.isBundled, + source: status.source || existing?.source, + baseDir: status.baseDir || existing?.baseDir, + filePath: status.filePath || existing?.filePath, + marketplace: existing?.marketplace, + }; +} + +function mergeGatewaySkills(localSkills: Skill[], gatewaySkills?: GatewaySkillStatus[]): Skill[] { + if (!gatewaySkills || gatewaySkills.length === 0) { + return localSkills; + } + + const merged = [...localSkills]; + const index = new Map(); + + localSkills.forEach((skill, position) => { + const aliases = new Set([ + normalizeSkillKey(skill.id), + normalizeSkillKey(skill.slug), + normalizeSkillKey(skill.name), + normalizeSkillPath(skill.baseDir), + ].filter(Boolean)); + aliases.forEach((alias) => index.set(alias, position)); + }); + + for (const gatewaySkill of gatewaySkills) { + if (!isAllowedBundledGatewaySkill(gatewaySkill)) { + continue; + } + const aliases = [ + normalizeSkillKey(gatewaySkill.skillKey), + normalizeSkillKey(gatewaySkill.slug), + normalizeSkillKey(gatewaySkill.name), + normalizeSkillPath(gatewaySkill.baseDir), + ].filter(Boolean); + const existingIndex = aliases.map((alias) => index.get(alias)).find((value): value is number => value !== undefined); + + if (existingIndex !== undefined) { + const nextSkill = mapGatewaySkillToSkill(gatewaySkill, merged[existingIndex]); + merged[existingIndex] = nextSkill; + const nextAliases = new Set([ + ...aliases, + normalizeSkillKey(nextSkill.id), + normalizeSkillKey(nextSkill.slug), + normalizeSkillKey(nextSkill.name), + normalizeSkillPath(nextSkill.baseDir), + ].filter(Boolean)); + nextAliases.forEach((alias) => index.set(alias, existingIndex)); + continue; + } + + if (!shouldAppendGatewayOnlySkill(gatewaySkill)) { + continue; + } + + const nextSkill = mapGatewaySkillToSkill(gatewaySkill); + const nextIndex = merged.push(nextSkill) - 1; + [ + normalizeSkillKey(nextSkill.id), + normalizeSkillKey(nextSkill.slug), + normalizeSkillKey(nextSkill.name), + normalizeSkillPath(nextSkill.baseDir), + ].filter(Boolean).forEach((alias) => index.set(alias, nextIndex)); + } + + return merged.sort((a, b) => { + if (a.enabled && !b.enabled) return -1; + if (!a.enabled && b.enabled) return 1; + if (a.isCore && !b.isCore) return -1; + if (!a.isCore && b.isCore) return 1; + return a.name.localeCompare(b.name); + }); +} + interface SkillsState { skills: Skill[]; searchResults: MarketplaceSkill[]; loading: boolean; searching: boolean; searchError: string | null; - installing: Record; // slug -> boolean + installing: Record; error: string | null; - // Actions fetchSkills: () => Promise; searchSkills: (query: string) => Promise; installSkill: (slug: string, version?: string) => Promise; uninstallSkill: (slug: string) => Promise; enableSkill: (skillId: string) => Promise; disableSkill: (skillId: string) => Promise; + setSkillsEnabled: (skillIds: string[], enabled: boolean) => Promise; setSkills: (skills: Skill[]) => void; updateSkill: (skillId: string, updates: Partial) => void; } @@ -87,128 +201,54 @@ export const useSkillsStore = create((set, get) => ({ error: null, fetchSkills: async () => { - // Only show loading state if we have no skills yet (initial load) if (get().skills.length === 0) { set({ loading: true, error: null }); } + + const gatewayDataPromise = useGatewayStore.getState().rpc('skills.status'); + try { - // Fetch all skill sources in parallel to reduce first-load latency. - const gatewayDataPromise = useGatewayStore.getState().rpc('skills.status'); - const clawhubResultPromise = hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list'); - const configResultPromise = hostApiFetch }>>('/api/skills/configs'); - const [gatewayDataResult, clawhubResult, configResult] = await Promise.allSettled([ - gatewayDataPromise, - clawhubResultPromise, - configResultPromise, - ]); - - if (gatewayDataResult.status !== 'fulfilled') { - throw gatewayDataResult.reason; + const localResult = await hostApiFetch('/api/skills/local'); + if (!localResult.success) { + throw new Error(localResult.error || 'Failed to fetch local skills'); } - const gatewayData = gatewayDataResult.value; - const clawhubData = clawhubResult.status === 'fulfilled' ? clawhubResult.value : undefined; - const configData = configResult.status === 'fulfilled' && configResult.value && typeof configResult.value === 'object' - ? configResult.value - : {}; + const localSkills = Array.isArray(localResult.skills) ? localResult.skills : []; + set({ skills: localSkills, loading: false, error: null }); - let combinedSkills: Skill[] = []; - const currentSkills = get().skills; - - // Map gateway skills info - if (gatewayData.skills) { - combinedSkills = gatewayData.skills.map((s: GatewaySkillStatus) => { - // Merge with direct config if available - const directConfig = configData[s.skillKey] || {}; - - return { - id: s.skillKey, - slug: s.slug || s.skillKey, - name: s.name || s.skillKey, - description: s.description || '', - enabled: !s.disabled, - icon: s.emoji || '📦', - version: s.version || '1.0.0', - author: s.author, - config: { - ...(s.config || {}), - ...directConfig, - }, - isCore: s.bundled && s.always, - isBundled: s.bundled, - source: s.source, - baseDir: s.baseDir, - filePath: s.filePath, - }; + void gatewayDataPromise + .then((gatewayData) => { + set((state) => ({ + skills: mergeGatewaySkills(state.skills, gatewayData.skills), + loading: false, + })); + }) + .catch(() => { + // Local data is already rendered; runtime merge is best-effort only. }); - } else if (currentSkills.length > 0) { - // ... if gateway down ... - combinedSkills = [...currentSkills]; - } - - // Merge with ClawHub results - if (clawhubData?.success && clawhubData.results) { - clawhubData.results.forEach((cs: ClawHubListResult) => { - const existing = combinedSkills.find(s => s.id === cs.slug); - if (existing) { - if (!existing.baseDir && cs.baseDir) { - existing.baseDir = cs.baseDir; - } - if (!existing.source && cs.source) { - existing.source = cs.source; - } - return; - } - const directConfig = configData[cs.slug] || {}; - combinedSkills.push({ - id: cs.slug, - slug: cs.slug, - name: cs.slug, - description: 'Recently installed, initializing...', - enabled: false, - icon: '⌛', - version: cs.version || 'unknown', - author: undefined, - config: directConfig, - isCore: false, - isBundled: false, - source: cs.source || 'openclaw-managed', - baseDir: cs.baseDir, - }); - }); - } - - const partialError = clawhubResult.status === 'rejected' - ? clawhubResult.reason - : configResult.status === 'rejected' - ? configResult.reason - : clawhubData?.success === false - ? new Error(clawhubData.error || 'Failed to fetch marketplace skills') - : null; - - if (partialError) { - const appError = normalizeAppError(partialError, { module: 'skills', operation: 'fetch' }); - const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch'); - set({ skills: combinedSkills, loading: false, error: errorKey ?? appError.message }); - } else { - set({ skills: combinedSkills, loading: false, error: null }); - } return true; } catch (error) { - console.error('Failed to fetch skills:', error); - const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); - const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch'); - // Preserve previous skills on error (stale-while-revalidate). - set((prev) => ({ loading: false, error: errorKey ?? appError.message, skills: prev.skills })); - return false; + console.error('Failed to fetch local skills:', error); + try { + const gatewayData = await gatewayDataPromise; + const gatewaySkills = mergeGatewaySkills([], gatewayData.skills); + set({ skills: gatewaySkills, loading: false, error: null }); + return true; + } catch (gatewayError) { + console.error('Failed to fetch gateway skills fallback:', gatewayError); + const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); + const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch'); + set((prev) => ({ loading: false, error: errorKey ?? appError.message, skills: prev.skills })); + return false; + } } }, searchSkills: async (query: string) => { set({ searching: true, searchError: null }); try { - const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', { + const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/skills/marketplace/search', { method: 'POST', body: JSON.stringify({ query }), }); @@ -232,7 +272,7 @@ export const useSkillsStore = create((set, get) => ({ installSkill: async (slug: string, version?: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', { + const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/marketplace/install', { method: 'POST', body: JSON.stringify({ slug, version }), }); @@ -244,7 +284,7 @@ export const useSkillsStore = create((set, get) => ({ const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'install'); throw new Error(errorKey ?? appError.message); } - // Refresh skills after install + await get().setSkillsEnabled([slug], true); await get().fetchSkills(); } catch (error) { console.error('Install error:', error); @@ -261,14 +301,13 @@ export const useSkillsStore = create((set, get) => ({ uninstallSkill: async (slug: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/uninstall', { + const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/marketplace/uninstall', { method: 'POST', body: JSON.stringify({ slug }), }); if (!result.success) { throw new Error(result.error || 'Uninstall failed'); } - // Refresh skills after uninstall await get().fetchSkills(); } catch (error) { console.error('Uninstall error:', error); @@ -282,12 +321,33 @@ export const useSkillsStore = create((set, get) => ({ } }, - enableSkill: async (skillId) => { - const { updateSkill } = get(); + setSkillsEnabled: async (skillIds, enabled) => { + if (skillIds.length === 0) return; + const { skills, updateSkill } = get(); + if (!enabled) { + const coreSkill = skills.find((skill) => skillIds.includes(skill.id) && skill.isCore); + if (coreSkill) { + throw new Error('Cannot disable core skill'); + } + } + + const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/configs', { + method: 'PATCH', + body: JSON.stringify({ + updates: skillIds.map((skillKey) => ({ skillKey, enabled })), + }), + }); + if (!result.success) { + throw new Error(result.error || 'Failed to update skill config'); + } + + skillIds.forEach((skillId) => updateSkill(skillId, { enabled })); + }, + + enableSkill: async (skillId) => { try { - await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: true }); - updateSkill(skillId, { enabled: true }); + await get().setSkillsEnabled([skillId], true); } catch (error) { console.error('Failed to enable skill:', error); throw error; @@ -295,16 +355,8 @@ export const useSkillsStore = create((set, get) => ({ }, disableSkill: async (skillId) => { - const { updateSkill, skills } = get(); - - const skill = skills.find((s) => s.id === skillId); - if (skill?.isCore) { - throw new Error('Cannot disable core skill'); - } - try { - await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: false }); - updateSkill(skillId, { enabled: false }); + await get().setSkillsEnabled([skillId], false); } catch (error) { console.error('Failed to disable skill:', error); throw error; @@ -316,7 +368,7 @@ export const useSkillsStore = create((set, get) => ({ updateSkill: (skillId, updates) => { set((state) => ({ skills: state.skills.map((skill) => - skill.id === skillId ? { ...skill, ...updates } : skill + skill.id === skillId ? { ...skill, ...updates } : skill, ), })); }, diff --git a/src/types/skill.ts b/src/types/skill.ts index 1ddd983..4f663db 100644 --- a/src/types/skill.ts +++ b/src/types/skill.ts @@ -23,6 +23,13 @@ export interface Skill { source?: string; baseDir?: string; filePath?: string; + marketplace?: { + provider: string; + slug?: string; + installedVersion?: string; + manifestPath?: string; + originPath?: string; + }; } export interface QuickAccessSkill { diff --git a/tests/e2e/skills-gateway-readiness.spec.ts b/tests/e2e/skills-gateway-readiness.spec.ts index e202e3a..40f50d8 100644 --- a/tests/e2e/skills-gateway-readiness.spec.ts +++ b/tests/e2e/skills-gateway-readiness.spec.ts @@ -1,13 +1,124 @@ import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron'; test.describe('Skills page gateway readiness', () => { - test('clears stale startup banner once runtime skills RPC succeeds', async ({ electronApp, page }) => { + test('shows local skills even when gateway is stopped', async ({ electronApp, page }) => { + await completeSetup(page); + + await installIpcMocks(electronApp, { + gatewayStatus: { state: 'stopped', port: 18789 }, + gatewayRpc: { + '["skills.status",null]': { success: false, error: 'Gateway not connected' }, + }, + hostApi: { + '["/api/skills/marketplace/capability","GET"]': { + ok: true, + data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + }, + '["/api/skills/local","GET"]': { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + skills: [{ + id: 'pdf', + slug: 'pdf', + name: 'PDF', + description: 'Local PDF tools', + enabled: true, + source: 'openclaw-managed', + baseDir: '/tmp/.openclaw/skills/pdf', + }, { + id: 'xlsx', + slug: 'xlsx', + name: 'XLSX', + description: 'Local spreadsheet tools', + enabled: false, + source: 'openclaw-managed', + baseDir: '/tmp/.openclaw/skills/xlsx', + }], + }, + }, + }, + }, + }); + + await page.getByTestId('sidebar-nav-skills').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'PDF' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'XLSX' })).toBeVisible(); + await expect(page.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'stopped', { timeout: 3_500 }); + await expect(page.getByRole('button', { name: /Install Skills/i })).toHaveCount(0); + + await page.getByTestId('skills-filter-enabled').click(); + await expect(page.getByRole('heading', { name: 'PDF' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'XLSX' })).toHaveCount(0); + + await page.getByTestId('skills-filter-disabled').click(); + await expect(page.getByRole('heading', { name: 'PDF' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'XLSX' })).toBeVisible(); + }); + + test('hides uninstall for plugin-provided skills', async ({ electronApp, page }) => { + await completeSetup(page); + + await installIpcMocks(electronApp, { + gatewayStatus: { state: 'stopped', port: 18789 }, + gatewayRpc: { + '["skills.status",null]': { success: false, error: 'Gateway not connected' }, + }, + hostApi: { + '["/api/skills/marketplace/capability","GET"]': { + ok: true, + data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + }, + '["/api/skills/local","GET"]': { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + skills: [{ + id: 'browser-automation', + slug: 'browser-automation', + name: 'Browser Automation', + description: 'Plugin skill', + enabled: true, + source: 'openclaw-plugin', + baseDir: '/tmp/.openclaw/plugin-skills/browser-automation', + }], + }, + }, + }, + }, + }); + + await page.getByTestId('sidebar-nav-skills').click(); + await expect(page.getByRole('heading', { name: 'Browser Automation' })).toBeVisible(); + await page.getByText('Browser Automation').click(); + await expect(page.getByRole('button', { name: /Uninstall|卸载|アンインストール|Удалить/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Disable|禁用|無効化|Выключить/i })).toBeVisible(); + }); + + test('clears stale startup banner once local skills load while runtime rpc is still starting', async ({ electronApp, page }) => { await completeSetup(page); await installIpcMocks(electronApp, { gatewayRpc: { '["skills.status",null]': { success: false, error: 'Gateway not connected' }, }, + hostApi: { + '["/api/skills/marketplace/capability","GET"]': { + ok: true, + data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + }, + '["/api/skills/local","GET"]': { + ok: true, + data: { status: 200, ok: true, json: { success: true, skills: [] } }, + }, + }, }); await page.getByTestId('sidebar-nav-skills').click(); @@ -25,25 +136,6 @@ test.describe('Skills page gateway readiness', () => { }); }); - await expect(page.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'starting', { timeout: 3_500 }); - - await installIpcMocks(electronApp, { - gatewayRpc: { - '["skills.status",null]': { success: true, result: { skills: [] } }, - }, - }); - - await electronApp.evaluate(({ BrowserWindow }) => { - const win = BrowserWindow.getAllWindows()[0]; - win?.webContents.send('gateway:status-changed', { - state: 'running', - port: 18789, - pid: 12345, - connectedAt: 2, - gatewayReady: false, - }); - }); - - await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 2_000 }); + await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 3_500 }); }); }); diff --git a/tests/unit/clawhub-service.test.ts b/tests/unit/clawhub-service.test.ts index ce58c63..56f1aa9 100644 --- a/tests/unit/clawhub-service.test.ts +++ b/tests/unit/clawhub-service.test.ts @@ -1,166 +1,77 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ClawHubService } from '@electron/gateway/clawhub'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; -describe('ClawHubService marketplace HTTP lookup', () => { +async function loadServiceForHome(homeDir: string) { + vi.resetModules(); + const paths = await import('@electron/utils/paths'); + vi.spyOn(paths, 'getOpenClawConfigDir').mockReturnValue(join(homeDir, '.openclaw')); + const mod = await import('@electron/gateway/clawhub'); + return mod.ClawHubService; +} + +describe('ClawHubService marketplace compatibility', () => { beforeEach(() => { vi.restoreAllMocks(); - vi.stubGlobal('fetch', vi.fn()); - vi.spyOn(console, 'warn').mockImplementation(() => undefined); }); afterEach(() => { - vi.unstubAllGlobals(); + vi.resetModules(); }); - it('uses the official ClawHub search API before the CLI fallback', async () => { + it('reports local-only capability when no marketplace provider is registered', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'clawx-clawhub-home-')); + const ClawHubService = await loadServiceForHome(homeDir); const service = new ClawHubService(); - const runCommand = vi.spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand'); - const fetchMock = vi.mocked(fetch); - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - results: [{ + await expect(service.getMarketplaceCapability()).resolves.toEqual({ + mode: 'local-only', + canSearch: false, + canInstall: false, + reason: 'marketplace-disabled', + }); + }); + + it('delegates search and install to a registered marketplace provider', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'clawx-clawhub-home-')); + const ClawHubService = await loadServiceForHome(homeDir); + const service = new ClawHubService(); + const provider = { + getCapability: vi.fn().mockResolvedValue({ mode: 'enterprise-marketplace', canSearch: true, canInstall: true }), + search: vi.fn().mockResolvedValue([{ slug: 'pdf', name: 'PDF', description: 'Docs', version: '1.0.0' }]), + install: vi.fn().mockResolvedValue(undefined), + }; + + service.setMarketplaceProvider(provider); + + await expect(service.search({ query: 'pdf' })).resolves.toEqual([ + { slug: 'pdf', name: 'PDF', description: 'Docs', version: '1.0.0' }, + ]); + await expect(service.install({ slug: 'pdf' })).resolves.toBeUndefined(); + expect(provider.search).toHaveBeenCalledWith({ query: 'pdf' }); + expect(provider.install).toHaveBeenCalledWith({ slug: 'pdf' }); + }); + + it('lists installed managed skills from the filesystem without the clawhub CLI', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'clawx-clawhub-home-')); + const openclawDir = join(homeDir, '.openclaw'); + const skillDir = join(openclawDir, 'skills', 'pdf'); + mkdirSync(join(skillDir, '.clawhub'), { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: PDF\n---\n'); + writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify({ version: '1.2.3' })); + writeFileSync(join(skillDir, '.clawhub', 'origin.json'), JSON.stringify({ installedVersion: '1.2.4' })); + + const ClawHubService = await loadServiceForHome(homeDir); + const service = new ClawHubService(); + + await expect(service.listInstalled()).resolves.toEqual([ + { slug: 'pdf', - displayName: 'PDF Tools', - summary: 'Read and transform PDFs', - version: '1.2.3', - score: 4.25, - }], - }), { - headers: { 'content-type': 'application/json' }, - })); - - const result = await service.search({ query: 'pdf', limit: 5 }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const requestedUrl = new URL(String(fetchMock.mock.calls[0][0])); - expect(requestedUrl.origin).toBe('https://clawhub.ai'); - expect(requestedUrl.pathname).toBe('/api/v1/search'); - expect(requestedUrl.searchParams.get('q')).toBe('pdf'); - expect(requestedUrl.searchParams.get('limit')).toBe('5'); - expect(runCommand).not.toHaveBeenCalled(); - expect(result).toEqual([{ - slug: 'pdf', - name: 'PDF Tools', - description: 'Read and transform PDFs', - version: '1.2.3', - }]); - }); - - it('uses the official ClawHub skills API for explore before the CLI fallback', async () => { - const service = new ClawHubService(); - const runCommand = vi.spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand'); - const fetchMock = vi.mocked(fetch); - - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - items: [{ - slug: 'writer', - displayName: 'Writer', - summary: 'Draft structured documents', - latestVersion: { version: '2.0.0' }, - }], - }), { - headers: { 'content-type': 'application/json' }, - })); - - const result = await service.explore({ limit: 3 }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const requestedUrl = new URL(String(fetchMock.mock.calls[0][0])); - expect(requestedUrl.origin).toBe('https://clawhub.ai'); - expect(requestedUrl.pathname).toBe('/api/v1/skills'); - expect(requestedUrl.searchParams.get('limit')).toBe('3'); - expect(runCommand).not.toHaveBeenCalled(); - expect(result).toEqual([{ - slug: 'writer', - name: 'Writer', - description: 'Draft structured documents', - version: '2.0.0', - }]); - }); - - it('falls back to the CLI when the HTTP marketplace response is not JSON', async () => { - const service = new ClawHubService(); - const runCommand = vi - .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') - .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit (3.500)'); - const fetchMock = vi.mocked(fetch); - - fetchMock.mockResolvedValueOnce(new Response('', { - headers: { 'content-type': 'text/html' }, - })); - - const result = await service.search({ query: 'pdf', limit: 1 }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(runCommand).toHaveBeenCalledWith(['search', 'pdf', '--limit', '1']); - expect(result).toEqual([{ - slug: 'pdf', - name: 'pdf', - description: 'PDF toolkit', - version: '1.0.0', - }]); - }); - - it('falls back to the CLI when the HTTP marketplace request fails', async () => { - const service = new ClawHubService(); - const runCommand = vi - .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') - .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); - const fetchMock = vi.mocked(fetch); - - fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); - - const result = await service.search({ query: 'pdf' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); - expect(result[0]).toMatchObject({ - slug: 'pdf', - description: 'PDF toolkit', - version: '1.0.0', - }); - }); - - it('falls back to the CLI when the HTTP marketplace request is aborted', async () => { - const service = new ClawHubService(); - const runCommand = vi - .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') - .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); - const fetchMock = vi.mocked(fetch); - - fetchMock.mockRejectedValueOnce(new DOMException('The operation was aborted', 'AbortError')); - - const result = await service.search({ query: 'pdf' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); - expect(result[0]).toMatchObject({ - slug: 'pdf', - description: 'PDF toolkit', - version: '1.0.0', - }); - }); - - it('falls back to the CLI when the HTTP marketplace returns a server error', async () => { - const service = new ClawHubService(); - const runCommand = vi - .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') - .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); - const fetchMock = vi.mocked(fetch); - - fetchMock.mockResolvedValueOnce(new Response('temporary failure', { - status: 503, - headers: { 'content-type': 'text/plain' }, - })); - - const result = await service.search({ query: 'pdf' }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); - expect(result[0]).toMatchObject({ - slug: 'pdf', - description: 'PDF toolkit', - version: '1.0.0', - }); + version: '1.2.4', + source: 'openclaw-managed', + baseDir: skillDir, + }, + ]); }); }); diff --git a/tests/unit/local-skill-service.test.ts b/tests/unit/local-skill-service.test.ts new file mode 100644 index 0000000..8050e7b --- /dev/null +++ b/tests/unit/local-skill-service.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const listAgentsSnapshotMock = vi.fn(); +const getOpenClawSkillsDirMock = vi.fn(); +const getOpenClawResolvedDirMock = vi.fn(); +const getAllSkillConfigsMock = vi.fn(); + +vi.mock('@electron/utils/agent-config', () => ({ + listAgentsSnapshot: () => listAgentsSnapshotMock(), +})); + +vi.mock('@electron/utils/paths', () => ({ + expandPath: (value: string) => value, + getOpenClawSkillsDir: () => getOpenClawSkillsDirMock(), + getOpenClawResolvedDir: () => getOpenClawResolvedDirMock(), +})); + +vi.mock('@electron/utils/skill-config', () => ({ + getAllSkillConfigs: () => getAllSkillConfigsMock(), +})); + +describe('local skill service', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('includes bundled skill-creator but filters out other bundled openclaw skills', async () => { + const root = mkdtempSync(join(tmpdir(), 'clawx-local-skills-')); + const managedRoot = join(root, 'managed'); + const bundledRoot = join(root, 'openclaw'); + + mkdirSync(join(managedRoot, 'pdf'), { recursive: true }); + writeFileSync(join(managedRoot, 'pdf', 'SKILL.md'), '---\nname: pdf\ndescription: managed pdf\n---\n'); + + mkdirSync(join(bundledRoot, 'skills', 'skill-creator'), { recursive: true }); + writeFileSync(join(bundledRoot, 'skills', 'skill-creator', 'SKILL.md'), '---\nname: skill-creator\ndescription: bundled creator\n---\n'); + + mkdirSync(join(bundledRoot, 'skills', 'other-bundled'), { recursive: true }); + writeFileSync(join(bundledRoot, 'skills', 'other-bundled', 'SKILL.md'), '---\nname: other-bundled\ndescription: should not appear\n---\n'); + + listAgentsSnapshotMock.mockResolvedValue({ agents: [] }); + getOpenClawSkillsDirMock.mockReturnValue(managedRoot); + getOpenClawResolvedDirMock.mockReturnValue(bundledRoot); + getAllSkillConfigsMock.mockResolvedValue({}); + + const { listLocalSkills } = await import('@electron/services/skills/local-skill-service'); + const skills = await listLocalSkills(); + + expect(skills.map((skill) => skill.id)).toEqual(['pdf', 'skill-creator']); + expect(skills.find((skill) => skill.id === 'skill-creator')).toMatchObject({ + source: 'openclaw-bundled', + isBundled: true, + enabled: true, + }); + expect(skills.find((skill) => skill.id === 'other-bundled')).toBeUndefined(); + }); + + it('does not invent a default version when local metadata has no version', async () => { + const root = mkdtempSync(join(tmpdir(), 'clawx-local-skills-versionless-')); + const managedRoot = join(root, 'managed'); + + mkdirSync(join(managedRoot, 'self-improvement'), { recursive: true }); + writeFileSync(join(managedRoot, 'self-improvement', 'SKILL.md'), '---\nname: self-improvement\ndescription: versionless skill\n---\n'); + + listAgentsSnapshotMock.mockResolvedValue({ agents: [] }); + getOpenClawSkillsDirMock.mockReturnValue(managedRoot); + getOpenClawResolvedDirMock.mockReturnValue(join(root, 'openclaw')); + getAllSkillConfigsMock.mockResolvedValue({}); + + const { listLocalSkills } = await import('@electron/services/skills/local-skill-service'); + const skills = await listLocalSkills(); + + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ id: 'self-improvement', version: undefined }); + }); + + it('shows manifest versions and ignores preinstalled hash-only versions', async () => { + const root = mkdtempSync(join(tmpdir(), 'clawx-local-skills-placeholder-version-')); + const managedRoot = join(root, 'managed'); + + mkdirSync(join(managedRoot, 'pdf'), { recursive: true }); + writeFileSync(join(managedRoot, 'pdf', 'SKILL.md'), '---\nname: pdf\ndescription: placeholder version skill\n---\n'); + writeFileSync(join(managedRoot, 'pdf', 'manifest.json'), JSON.stringify({ slug: 'pdf', version: '1.0.0' })); + + mkdirSync(join(managedRoot, 'docx'), { recursive: true }); + writeFileSync(join(managedRoot, 'docx', 'SKILL.md'), '---\nname: docx\ndescription: preinstalled hash version skill\n---\n'); + writeFileSync(join(managedRoot, 'docx', '.clawx-preinstalled.json'), JSON.stringify({ slug: 'docx', version: 'da20c92503b2e8ff1cf28ca81a0df4673debdbf7' })); + + mkdirSync(join(managedRoot, 'custom-skill'), { recursive: true }); + writeFileSync(join(managedRoot, 'custom-skill', 'SKILL.md'), '---\nname: custom-skill\ndescription: custom version skill\n---\n'); + writeFileSync(join(managedRoot, 'custom-skill', 'manifest.json'), JSON.stringify({ slug: 'custom-skill', version: '0.1.3' })); + + listAgentsSnapshotMock.mockResolvedValue({ agents: [] }); + getOpenClawSkillsDirMock.mockReturnValue(managedRoot); + getOpenClawResolvedDirMock.mockReturnValue(join(root, 'openclaw')); + getAllSkillConfigsMock.mockResolvedValue({}); + + const { listLocalSkills } = await import('@electron/services/skills/local-skill-service'); + const skills = await listLocalSkills(); + + expect(skills.find((skill) => skill.id === 'pdf')?.version).toBe('1.0.0'); + expect(skills.find((skill) => skill.id === 'docx')?.version).toBeUndefined(); + expect(skills.find((skill) => skill.id === 'custom-skill')?.version).toBe('0.1.3'); + }); +}); diff --git a/tests/unit/skill-config-bundled-defaults.test.ts b/tests/unit/skill-config-bundled-defaults.test.ts new file mode 100644 index 0000000..2409667 --- /dev/null +++ b/tests/unit/skill-config-bundled-defaults.test.ts @@ -0,0 +1,45 @@ +import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + homeDir: '', + openclawDir: '', +})); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + homedir: () => state.homeDir, + }; +}); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawDir: () => state.openclawDir, + getOpenClawResolvedDir: () => state.openclawDir, + getResourcesDir: () => '', +})); + +describe('bundled OpenClaw skill trimming', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('physically trims non-allowlisted bundled skills from a bundled skills root', async () => { + const root = mkdtempSync(join(tmpdir(), 'clawx-bundled-skills-')); + mkdirSync(join(root, 'skill-creator'), { recursive: true }); + mkdirSync(join(root, 'browser-use'), { recursive: true }); + writeFileSync(join(root, 'skill-creator', 'SKILL.md'), '---\nname: skill-creator\ndescription: keep\n---\n'); + writeFileSync(join(root, 'browser-use', 'SKILL.md'), '---\nname: browser-use\ndescription: remove\n---\n'); + + const { trimBundledOpenClawSkills } = await import('@electron/utils/skill-config'); + const result = await trimBundledOpenClawSkills({ bundledSkillsRoot: root }); + + expect(result).toMatchObject({ removed: 1, removedSlugs: ['browser-use'], kept: ['skill-creator'] }); + expect(existsSync(join(root, 'skill-creator'))).toBe(true); + expect(existsSync(join(root, 'browser-use'))).toBe(false); + }); +}); diff --git a/tests/unit/skills-errors.test.ts b/tests/unit/skills-errors.test.ts index 76769c5..01dca8d 100644 --- a/tests/unit/skills-errors.test.ts +++ b/tests/unit/skills-errors.test.ts @@ -21,8 +21,8 @@ describe('skills store error mapping', () => { vi.clearAllMocks(); }); - it('maps fetchSkills rate-limit error by AppError code', async () => { - rpcMock.mockResolvedValueOnce({ skills: [] }); + it('maps fetchSkills rate-limit error when both local and gateway loading fail', async () => { + rpcMock.mockRejectedValueOnce(new Error('gateway unavailable')); hostApiFetchMock.mockRejectedValueOnce(new Error('rate limit exceeded')); const { useSkillsStore } = await import('@/stores/skills'); @@ -37,6 +37,7 @@ describe('skills store error mapping', () => { const { useSkillsStore } = await import('@/stores/skills'); await useSkillsStore.getState().searchSkills('git'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/marketplace/search', expect.objectContaining({ method: 'POST' })); expect(useSkillsStore.getState().searchError).toBe('searchTimeoutError'); }); @@ -45,5 +46,6 @@ describe('skills store error mapping', () => { const { useSkillsStore } = await import('@/stores/skills'); await expect(useSkillsStore.getState().installSkill('demo-skill')).rejects.toThrow('installTimeoutError'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/marketplace/install', expect.objectContaining({ method: 'POST' })); }); }); diff --git a/tests/unit/skills-page-gateway-readiness.test.tsx b/tests/unit/skills-page-gateway-readiness.test.tsx index 27b48a3..d13094d 100644 --- a/tests/unit/skills-page-gateway-readiness.test.tsx +++ b/tests/unit/skills-page-gateway-readiness.test.tsx @@ -1,16 +1,18 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Skills } from '@/pages/Skills'; const fetchSkillsMock = vi.fn(); const enableSkillMock = vi.fn(); const disableSkillMock = vi.fn(); +const setSkillsEnabledMock = vi.fn(); const searchSkillsMock = vi.fn(); const installSkillMock = vi.fn(); const uninstallSkillMock = vi.fn(); const invokeIpcMock = vi.fn(); +const hostApiFetchMock = vi.fn(); -const { gatewayState } = vi.hoisted(() => ({ +const { gatewayState, skillsState } = vi.hoisted(() => ({ gatewayState: { status: { state: 'running', port: 18789, gatewayReady: true } as { state: string; @@ -18,16 +20,20 @@ const { gatewayState } = vi.hoisted(() => ({ gatewayReady?: boolean; }, }, + skillsState: { + skills: [] as Array>, + }, })); vi.mock('@/stores/skills', () => ({ useSkillsStore: () => ({ - skills: [], + skills: skillsState.skills, loading: false, error: null, fetchSkills: fetchSkillsMock, enableSkill: enableSkillMock, disableSkill: disableSkillMock, + setSkillsEnabled: setSkillsEnabledMock, searchResults: [], searchSkills: searchSkillsMock, installSkill: installSkillMock, @@ -47,7 +53,7 @@ vi.mock('@/lib/api-client', () => ({ })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: vi.fn(), + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); vi.mock('@/lib/telemetry', () => ({ @@ -80,7 +86,17 @@ describe('Skills page gateway readiness', () => { vi.useFakeTimers(); vi.clearAllMocks(); gatewayState.status = { state: 'running', port: 18789, gatewayReady: true }; + skillsState.skills = []; invokeIpcMock.mockResolvedValue('/tmp/.openclaw/skills'); + hostApiFetchMock.mockImplementation((path: unknown) => { + if (path === '/api/skills/marketplace/capability') { + return Promise.resolve({ success: true, capability: { canSearch: false, canInstall: false } }); + } + if (path === '/api/clawhub/open-path') { + return Promise.resolve({ success: true }); + } + return Promise.resolve({ success: true }); + }); fetchSkillsMock.mockResolvedValue(true); }); @@ -88,7 +104,7 @@ describe('Skills page gateway readiness', () => { vi.useRealTimers(); }); - it('keeps loading skills while gatewayReady is false and hides the banner once skills fetch succeeds', async () => { + it('keeps loading skills while gatewayReady is false and hides the banner once local skills fetch succeeds', async () => { gatewayState.status = { state: 'running', port: 18789, gatewayReady: false }; render(); @@ -115,15 +131,118 @@ describe('Skills page gateway readiness', () => { expect(screen.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'starting'); }); - it('shows stopped banner copy when the gateway is stopped', async () => { + it('still fetches local skills when the gateway is stopped', async () => { gatewayState.status = { state: 'stopped', port: 18789 }; render(); await act(async () => { + await Promise.resolve(); await vi.advanceTimersByTimeAsync(1_600); }); - expect(screen.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'stopped'); - expect(fetchSkillsMock).not.toHaveBeenCalled(); + expect(fetchSkillsMock).toHaveBeenCalledTimes(1); + expect(screen.queryByText('actions.installSkill')).not.toBeInTheDocument(); + }); + + it('filters the list via enabled and disabled buttons', async () => { + gatewayState.status = { state: 'stopped', port: 18789 }; + skillsState.skills = [ + { id: 'pdf', name: 'PDF', description: 'enabled skill', enabled: true, source: 'openclaw-managed' }, + { id: 'xlsx', name: 'XLSX', description: 'disabled skill', enabled: false, source: 'openclaw-managed' }, + ]; + + render(); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_600); + }); + + expect(screen.getByText('PDF')).toBeInTheDocument(); + expect(screen.getByText('XLSX')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('skills-filter-enabled')); + expect(screen.getByText('PDF')).toBeInTheDocument(); + expect(screen.queryByText('XLSX')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('skills-filter-disabled')); + expect(screen.queryByText('PDF')).not.toBeInTheDocument(); + expect(screen.getByText('XLSX')).toBeInTheDocument(); + }); + + it('shows manifest versions but still hides slug badges and hash-only preinstalled versions', async () => { + gatewayState.status = { state: 'stopped', port: 18789 }; + skillsState.skills = [ + { + id: 'self-improvement-agent', + slug: 'self-improvement-agent', + name: 'self-improvement', + description: 'versionless local skill', + enabled: true, + source: 'openclaw-managed', + baseDir: '/tmp/self-improvement', + }, + { + id: 'pdf', + slug: 'pdf', + name: 'pdf', + description: 'placeholder version skill', + enabled: true, + version: '1.0.0', + source: 'openclaw-managed', + baseDir: '/tmp/pdf', + }, + { + id: 'docx', + slug: 'docx', + name: 'docx', + description: 'hash version skill', + enabled: true, + source: 'openclaw-managed', + baseDir: '/tmp/docx', + }, + { + id: 'custom-skill', + slug: 'custom-skill', + name: 'custom-skill', + description: 'real version skill', + enabled: true, + version: '0.1.3', + source: 'openclaw-managed', + baseDir: '/tmp/custom-skill', + }, + ]; + + render(); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_600); + }); + + expect(screen.queryByText('self-improvement-agent')).not.toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('v0.1.3')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('docx')); + expect(screen.queryByText(/^v[a-f0-9]{40}$/i)).not.toBeInTheDocument(); + }); + + it('does not show uninstall for plugin-provided skills', async () => { + gatewayState.status = { state: 'stopped', port: 18789 }; + skillsState.skills = [ + { id: 'browser-automation', slug: 'browser-automation', name: 'Browser Automation', description: 'plugin skill', enabled: true, source: 'openclaw-plugin', baseDir: '/tmp/plugin-skill' }, + ]; + + render(); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_600); + }); + + fireEvent.click(screen.getByText('Browser Automation')); + expect(screen.queryByText('detail.uninstall')).not.toBeInTheDocument(); + expect(screen.getByText('detail.disable')).toBeInTheDocument(); }); }); diff --git a/tests/unit/skills-store-fetch-parallel.test.ts b/tests/unit/skills-store-fetch-parallel.test.ts index 91be726..3a9624d 100644 --- a/tests/unit/skills-store-fetch-parallel.test.ts +++ b/tests/unit/skills-store-fetch-parallel.test.ts @@ -25,18 +25,18 @@ function deferred() { return { promise, resolve, reject }; } -describe('skills store fetch parallelization', () => { +describe('skills store local-first fetch', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); - it('starts clawhub and config requests before gateway rpc resolves', async () => { + it('starts local and gateway requests together, then returns after local skills load', async () => { const gatewayDeferred = deferred<{ skills: Array> }>(); + const localDeferred = deferred<{ success: boolean; skills: Array> }>(); rpcMock.mockReturnValueOnce(gatewayDeferred.promise); hostApiFetchMock.mockImplementation((path: unknown) => { - if (path === '/api/clawhub/list') return Promise.resolve({ success: true, results: [] }); - if (path === '/api/skills/configs') return Promise.resolve({}); + if (path === '/api/skills/local') return localDeferred.promise; return Promise.reject(new Error(`Unexpected path: ${String(path)}`)); }); @@ -47,10 +47,74 @@ describe('skills store fetch parallelization', () => { await Promise.resolve(); expect(rpcMock).toHaveBeenCalledWith('skills.status'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/clawhub/list'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/configs'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/local'); - gatewayDeferred.resolve({ skills: [] }); - await fetchPromise; + localDeferred.resolve({ + success: true, + skills: [{ id: 'pdf', name: 'PDF', description: 'local', enabled: true }], + }); + + await expect(fetchPromise).resolves.toBe(true); + expect(useSkillsStore.getState().skills).toHaveLength(1); + expect(useSkillsStore.getState().skills[0]).toMatchObject({ id: 'pdf', description: 'local', enabled: true }); + + gatewayDeferred.resolve({ + skills: [{ skillKey: 'pdf', description: 'runtime', disabled: false, version: '2.0.0' }], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(useSkillsStore.getState().skills[0]).toMatchObject({ + id: 'pdf', + description: 'runtime', + version: '2.0.0', + enabled: true, + }); + }); + + it('does not append bundled gateway skills that are missing from local scan', async () => { + const gatewayDeferred = deferred<{ skills: Array> }>(); + rpcMock.mockReturnValueOnce(gatewayDeferred.promise); + hostApiFetchMock.mockResolvedValueOnce({ success: true, skills: [] }); + + const { useSkillsStore } = await import('@/stores/skills'); + useSkillsStore.setState({ skills: [], loading: false, error: null }); + + const fetchPromise = useSkillsStore.getState().fetchSkills(); + await expect(fetchPromise).resolves.toBe(true); + + gatewayDeferred.resolve({ + skills: [ + { skillKey: 'browser-use', slug: 'browser-use', name: 'browser-use', bundled: true, disabled: false }, + { skillKey: 'skill-creator', slug: 'skill-creator', name: 'skill-creator', bundled: true, disabled: false }, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual([]); + }); + + it('does not resurrect gateway-managed skills that are missing from local scan', async () => { + const gatewayDeferred = deferred<{ skills: Array> }>(); + rpcMock.mockReturnValueOnce(gatewayDeferred.promise); + hostApiFetchMock.mockResolvedValueOnce({ success: true, skills: [] }); + + const { useSkillsStore } = await import('@/stores/skills'); + useSkillsStore.setState({ skills: [], loading: false, error: null }); + + const fetchPromise = useSkillsStore.getState().fetchSkills(); + await expect(fetchPromise).resolves.toBe(true); + + gatewayDeferred.resolve({ + skills: [ + { skillKey: 'agent-browser', slug: 'agent-browser', name: 'agent-browser', source: 'openclaw-managed', disabled: false }, + { skillKey: 'plugin-skill', slug: 'plugin-skill', name: 'plugin-skill', source: 'openclaw-plugin', disabled: false }, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual(['plugin-skill']); }); });