From baa551b30ca6a55aab63f8bf3bd32e11f844deb0 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:40:46 +0800 Subject: [PATCH] feat: prebundle and auto-enable document, self-improving, search skills (#413) --- README.ja-JP.md | 6 + README.md | 7 + README.zh-CN.md | 6 + electron-builder.yml | 3 + electron/api/routes/skills.ts | 4 +- electron/gateway/clawhub.ts | 74 ++++++- electron/main/index.ts | 9 +- electron/utils/skill-config.ts | 207 +++++++++++++++++++- package.json | 13 +- resources/skills/preinstalled-manifest.json | 76 +++++++ scripts/bundle-preinstalled-skills.mjs | 118 +++++++++++ src/pages/Skills/index.tsx | 19 +- 12 files changed, 520 insertions(+), 22 deletions(-) create mode 100644 resources/skills/preinstalled-manifest.json create mode 100644 scripts/bundle-preinstalled-skills.mjs diff --git a/README.ja-JP.md b/README.ja-JP.md index 6d4ceeb3e..7b0483c09 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -109,6 +109,12 @@ AIタスクを自動的に実行するようスケジュール設定できます ### 🧩 拡張可能なスキルシステム 事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。 +ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に `~/.openclaw/skills` へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。 + +主な検索スキルで必要な環境変数: +- `BRAVE_SEARCH_API_KEY`: `brave-web-search` 用 +- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり) +- `BOCHA_API_KEY`: `bocha-skill` 用 ### 🔐 セキュアなプロバイダー統合 複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。 diff --git a/README.md b/README.md index a1ee163f8..af00f2c82 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,13 @@ Schedule AI tasks to run automatically. Define triggers, set intervals, and let ### 🧩 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. +ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to `~/.openclaw/skills` on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`, `brave-web-search`, `bocha-skill`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime. + +Environment variables for bundled search skills: +- `BRAVE_SEARCH_API_KEY` for `brave-web-search` +- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime) +- `BOCHA_API_KEY` for `bocha-skill` +- `find-skills` and `self-improving-agent` do not require API keys ### 🔐 Secure Provider Integration Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5ba03d596..d5325edcc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,6 +110,12 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 ### 🧩 可扩展技能系统 通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。 +ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到 `~/.openclaw/skills`,并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。 + +重点搜索技能所需环境变量: +- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search` +- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth) +- `BOCHA_API_KEY`:用于 `bocha-skill` ### 🔐 安全的供应商集成 连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。 diff --git a/electron-builder.yml b/electron-builder.yml index 4ad359851..24c6ad368 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -26,6 +26,9 @@ extraResources: # because electron-builder respects .gitignore which excludes node_modules/) - from: build/openclaw/ to: openclaw/ + # Pre-bundled third-party skills (full directories, not only SKILL.md) + - from: build/preinstalled-skills/ + to: resources/preinstalled-skills/ # NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the # afterPack hook (after-pack.cjs) directly from node_modules, so they # don't need an extraResources entry here. diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts index 7984d5ad8..dc41ffcf1 100644 --- a/electron/api/routes/skills.ts +++ b/electron/api/routes/skills.ts @@ -77,8 +77,8 @@ export async function handleSkillRoutes( if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') { try { - const body = await parseJsonBody<{ slug: string }>(req); - await ctx.clawHubService.openSkillReadme(body.slug); + const body = await parseJsonBody<{ slug?: string; skillKey?: string }>(req); + await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index e2366349b..ced675e50 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -67,6 +67,55 @@ export class ClawHubService { return line.replace(this.ansiRegex, '').trim(); } + private extractFrontmatterName(skillManifestPath: string): string | null { + try { + const raw = fs.readFileSync(skillManifestPath, 'utf8'); + // Match the first frontmatter block and read `name: ...` + const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/); + if (!frontmatterMatch) return null; + const body = frontmatterMatch[1]; + const nameMatch = body.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m); + if (!nameMatch) return null; + const name = nameMatch[1].trim(); + return name || null; + } catch { + return null; + } + } + + private resolveSkillDirByManifestName(candidates: string[]): string | null { + const skillsRoot = path.join(this.workDir, 'skills'); + if (!fs.existsSync(skillsRoot)) return null; + + const wanted = new Set( + candidates + .map((v) => v.trim().toLowerCase()) + .filter((v) => v.length > 0), + ); + if (wanted.size === 0) return null; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); + } catch { + return null; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = path.join(skillsRoot, entry.name); + const skillManifestPath = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(skillManifestPath)) continue; + + const frontmatterName = this.extractFrontmatterName(skillManifestPath); + if (!frontmatterName) continue; + if (wanted.has(frontmatterName.toLowerCase())) { + return skillDir; + } + } + return null; + } + /** * Run a ClawHub CLI command */ @@ -318,24 +367,33 @@ export class ClawHubService { /** * Open skill README/manual in default editor */ - async openSkillReadme(slug: string): Promise { - const skillDir = path.join(this.workDir, 'skills', slug); + async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string): Promise { + const candidates = [skillKeyOrSlug, fallbackSlug] + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + .map(v => v.trim()); + const uniqueCandidates = [...new Set(candidates)]; + const directSkillDir = uniqueCandidates + .map((id) => path.join(this.workDir, 'skills', id)) + .find((dir) => fs.existsSync(dir)); + const skillDir = directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates); // Try to find documentation file const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md']; let targetFile = ''; - for (const file of possibleFiles) { - const filePath = path.join(skillDir, file); - if (fs.existsSync(filePath)) { - targetFile = filePath; - break; + if (skillDir) { + for (const file of possibleFiles) { + const filePath = path.join(skillDir, file); + if (fs.existsSync(filePath)) { + targetFile = filePath; + break; + } } } if (!targetFile) { // If no md file, just open the directory - if (fs.existsSync(skillDir)) { + if (skillDir) { targetFile = skillDir; } else { throw new Error('Skill directory not found'); diff --git a/electron/main/index.ts b/electron/main/index.ts index bd71e912d..8aa1352e1 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -21,7 +21,7 @@ import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToPro import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; import { getSetting } from '../utils/store'; -import { ensureBuiltinSkillsInstalled } from '../utils/skill-config'; +import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; import { startHostApiServer } from '../api/server'; import { HostEventBus } from '../api/event-bus'; import { deviceOAuthManager } from '../utils/device-oauth'; @@ -236,6 +236,13 @@ async function initialize(): Promise { logger.warn('Failed to install built-in skills:', error); }); + // 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. + void ensurePreinstalledSkillsInstalled().catch((error) => { + logger.warn('Failed to install preinstalled skills:', error); + }); + // Bridge gateway and host-side events before any auto-start logic runs, so // renderer subscribers observe the full startup lifecycle. gatewayManager.on('status', (status: { state: string }) => { diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index 3f67ac200..e3c5742f1 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -10,7 +10,7 @@ import { existsSync } from 'fs'; import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { getOpenClawDir } from './paths'; +import { getOpenClawDir, getResourcesDir } from './paths'; import { logger } from './logger'; const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); @@ -29,6 +29,32 @@ interface OpenClawConfig { [key: string]: unknown; } +interface PreinstalledSkillSpec { + slug: string; + version?: string; + autoEnable?: boolean; +} + +interface PreinstalledManifest { + skills?: PreinstalledSkillSpec[]; +} + +interface PreinstalledLockEntry { + slug: string; + version?: string; +} + +interface PreinstalledLockFile { + skills?: PreinstalledLockEntry[]; +} + +interface PreinstalledMarker { + source: 'clawx-preinstalled'; + slug: string; + version: string; + installedAt: string; +} + async function fileExists(p: string): Promise { try { await access(p, constants.F_OK); return true; } catch { return false; } } @@ -57,6 +83,25 @@ async function writeConfig(config: OpenClawConfig): Promise { await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8'); } +async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise { + if (skillKeys.length === 0) { + return; + } + const config = await readConfig(); + if (!config.skills) { + config.skills = {}; + } + if (!config.skills.entries) { + config.skills.entries = {}; + } + for (const skillKey of skillKeys) { + const entry = config.skills.entries[skillKey] || {}; + entry.enabled = enabled; + config.skills.entries[skillKey] = entry; + } + await writeConfig(config); +} + /** * Get skill config */ @@ -177,3 +222,163 @@ export async function ensureBuiltinSkillsInstalled(): Promise { } } } + +const PREINSTALLED_MANIFEST_NAME = 'preinstalled-manifest.json'; +const PREINSTALLED_MARKER_NAME = '.clawx-preinstalled.json'; + +async function readPreinstalledManifest(): Promise { + const candidates = [ + join(getResourcesDir(), 'skills', PREINSTALLED_MANIFEST_NAME), + join(process.cwd(), 'resources', 'skills', PREINSTALLED_MANIFEST_NAME), + ]; + + const manifestPath = candidates.find((p) => existsSync(p)); + if (!manifestPath) { + return []; + } + + try { + const raw = await readFile(manifestPath, 'utf-8'); + const parsed = JSON.parse(raw) as PreinstalledManifest; + if (!Array.isArray(parsed.skills)) { + return []; + } + return parsed.skills.filter((s): s is PreinstalledSkillSpec => Boolean(s?.slug)); + } catch (error) { + logger.warn('Failed to read preinstalled-skills manifest:', error); + return []; + } +} + +function resolvePreinstalledSkillsSourceRoot(): string | null { + const candidates = [ + join(getResourcesDir(), 'preinstalled-skills'), + join(process.cwd(), 'build', 'preinstalled-skills'), + join(__dirname, '../../build/preinstalled-skills'), + ]; + + const root = candidates.find((dir) => existsSync(dir)); + return root || null; +} + +async function readPreinstalledLockVersions(sourceRoot: string): Promise> { + const lockPath = join(sourceRoot, '.preinstalled-lock.json'); + if (!existsSync(lockPath)) { + return new Map(); + } + try { + const raw = await readFile(lockPath, 'utf-8'); + const parsed = JSON.parse(raw) as PreinstalledLockFile; + const versions = new Map(); + for (const entry of parsed.skills || []) { + const slug = entry.slug?.trim(); + const version = entry.version?.trim(); + if (slug && version) { + versions.set(slug, version); + } + } + return versions; + } catch (error) { + logger.warn('Failed to read preinstalled-skills lock file:', error); + return new Map(); + } +} + +async function tryReadMarker(markerPath: string): Promise { + if (!existsSync(markerPath)) { + return null; + } + try { + const raw = await readFile(markerPath, 'utf-8'); + const parsed = JSON.parse(raw) as PreinstalledMarker; + if (!parsed?.slug || !parsed?.version) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Ensure third-party preinstalled skills (bundled in app resources) are + * deployed to ~/.openclaw/skills// as full directories. + * + * Policy: + * - If skill is missing locally, install it. + * - If local skill exists without our marker, treat as user-managed and never overwrite. + * - If marker exists with same version, skip. + * - If marker exists with a different version, skip by default to avoid overwriting edits. + */ +export async function ensurePreinstalledSkillsInstalled(): Promise { + const skills = await readPreinstalledManifest(); + if (skills.length === 0) { + return; + } + + const sourceRoot = resolvePreinstalledSkillsSourceRoot(); + if (!sourceRoot) { + logger.warn('Preinstalled skills source root not found; skipping preinstall.'); + return; + } + const lockVersions = await readPreinstalledLockVersions(sourceRoot); + + const targetRoot = join(homedir(), '.openclaw', 'skills'); + await mkdir(targetRoot, { recursive: true }); + const toEnable: string[] = []; + + for (const spec of skills) { + const sourceDir = join(sourceRoot, spec.slug); + const sourceManifest = join(sourceDir, 'SKILL.md'); + if (!existsSync(sourceManifest)) { + logger.warn(`Preinstalled skill source missing SKILL.md, skipping: ${sourceDir}`); + continue; + } + + const targetDir = join(targetRoot, spec.slug); + const targetManifest = join(targetDir, 'SKILL.md'); + const markerPath = join(targetDir, PREINSTALLED_MARKER_NAME); + const desiredVersion = lockVersions.get(spec.slug) + || (spec.version || 'unknown').trim() + || 'unknown'; + const marker = await tryReadMarker(markerPath); + + if (existsSync(targetManifest)) { + if (!marker) { + logger.info(`Skipping user-managed skill: ${spec.slug}`); + continue; + } + if (marker.version === desiredVersion) { + continue; + } + logger.info(`Skipping preinstalled skill update for ${spec.slug} (local marker version=${marker.version}, desired=${desiredVersion})`); + continue; + } + + try { + await mkdir(targetDir, { recursive: true }); + await cp(sourceDir, targetDir, { recursive: true, force: true }); + const markerPayload: PreinstalledMarker = { + source: 'clawx-preinstalled', + slug: spec.slug, + version: desiredVersion, + installedAt: new Date().toISOString(), + }; + await writeFile(markerPath, `${JSON.stringify(markerPayload, null, 2)}\n`, 'utf-8'); + if (spec.autoEnable) { + toEnable.push(spec.slug); + } + logger.info(`Installed preinstalled skill: ${spec.slug} -> ${targetDir}`); + } catch (error) { + logger.warn(`Failed to install preinstalled skill ${spec.slug}:`, error); + } + } + + if (toEnable.length > 0) { + try { + await setSkillsEnabled(toEnable, true); + } catch (error) { + logger.warn('Failed to auto-enable preinstalled skills:', error); + } + } +} diff --git a/package.json b/package.json index 773409c7c..ec9d15cf8 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,10 @@ "scripts": { "init": "pnpm install && pnpm run uv:download", "dev": "vite", - "build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder", + "build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", "build:vite": "vite build", "bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs", + "bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs", "lint": "eslint . --fix", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -36,11 +37,11 @@ "uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux", "uv:download:all": "zx scripts/download-bundled-uv.mjs --all", "icons": "zx scripts/generate-icons.mjs", - "package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder", - "package:mac": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --mac", - "package:win": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --win", - "package:linux": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --linux", - "release": "pnpm run uv:download && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --publish always", + "package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", + "package:mac": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --mac", + "package:win": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --win", + "package:linux": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --linux", + "release": "pnpm run uv:download && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --publish always", "version:patch": "pnpm version patch", "version:minor": "pnpm version minor", "version:major": "pnpm version major", diff --git a/resources/skills/preinstalled-manifest.json b/resources/skills/preinstalled-manifest.json new file mode 100644 index 000000000..0b044b14e --- /dev/null +++ b/resources/skills/preinstalled-manifest.json @@ -0,0 +1,76 @@ +{ + "skills": [ + { + "slug": "pdf", + "repo": "anthropics/skills", + "repoPath": "skills/pdf", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "xlsx", + "repo": "anthropics/skills", + "repoPath": "skills/xlsx", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "docx", + "repo": "anthropics/skills", + "repoPath": "skills/docx", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "pptx", + "repo": "anthropics/skills", + "repoPath": "skills/pptx", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "find-skills", + "repo": "vercel-labs/skills", + "repoPath": "skills/find-skills", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "self-improving-agent", + "repo": "openclaw/skills", + "repoPath": "skills/pskoett/self-improving-agent", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "tavily-search", + "repo": "tavily-ai/skills", + "repoPath": "skills/tavily/search", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "brave-web-search", + "repo": "brave/brave-search-skills", + "repoPath": "skills/web-search", + "ref": "main", + "version": "main", + "autoEnable": true + }, + { + "slug": "bocha-skill", + "repo": "openclaw/skills", + "repoPath": "skills/ypw757/bocha-skill", + "ref": "main", + "version": "main", + "autoEnable": true + } + ] +} diff --git a/scripts/bundle-preinstalled-skills.mjs b/scripts/bundle-preinstalled-skills.mjs new file mode 100644 index 000000000..dd84f54e8 --- /dev/null +++ b/scripts/bundle-preinstalled-skills.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env zx + +import 'zx/globals'; +import { readFileSync, existsSync, mkdirSync, rmSync, cpSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const MANIFEST_PATH = join(ROOT, 'resources', 'skills', 'preinstalled-manifest.json'); +const OUTPUT_ROOT = join(ROOT, 'build', 'preinstalled-skills'); +const TMP_ROOT = join(ROOT, 'build', '.tmp-preinstalled-skills'); + +function loadManifest() { + if (!existsSync(MANIFEST_PATH)) { + throw new Error(`Missing manifest: ${MANIFEST_PATH}`); + } + const raw = readFileSync(MANIFEST_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.skills)) { + throw new Error('Invalid preinstalled-skills manifest format'); + } + for (const item of parsed.skills) { + if (!item.slug || !item.repo || !item.repoPath) { + throw new Error(`Invalid manifest entry: ${JSON.stringify(item)}`); + } + } + return parsed.skills; +} + +function groupByRepoRef(entries) { + const grouped = new Map(); + for (const entry of entries) { + const ref = entry.ref || 'main'; + const key = `${entry.repo}#${ref}`; + if (!grouped.has(key)) grouped.set(key, { repo: entry.repo, ref, entries: [] }); + grouped.get(key).entries.push(entry); + } + return [...grouped.values()]; +} + +function createRepoDirName(repo, ref) { + return `${repo.replace(/[\\/]/g, '__')}__${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`; +} + +async function fetchSparseRepo(repo, ref, paths, checkoutDir) { + const remote = `https://github.com/${repo}.git`; + mkdirSync(checkoutDir, { recursive: true }); + + await $`git init ${checkoutDir}`; + await $`git -C ${checkoutDir} remote add origin ${remote}`; + await $`git -C ${checkoutDir} sparse-checkout init --cone`; + await $`git -C ${checkoutDir} sparse-checkout set ${paths}`; + await $`git -C ${checkoutDir} fetch --depth 1 origin ${ref}`; + await $`git -C ${checkoutDir} checkout FETCH_HEAD`; + + const commit = (await $`git -C ${checkoutDir} rev-parse HEAD`).stdout.trim(); + return commit; +} + +echo`Bundling preinstalled skills...`; +const manifestSkills = loadManifest(); + +rmSync(OUTPUT_ROOT, { recursive: true, force: true }); +mkdirSync(OUTPUT_ROOT, { recursive: true }); +rmSync(TMP_ROOT, { recursive: true, force: true }); +mkdirSync(TMP_ROOT, { recursive: true }); + +const lock = { + generatedAt: new Date().toISOString(), + skills: [], +}; + +const groups = groupByRepoRef(manifestSkills); +for (const group of groups) { + const repoDir = join(TMP_ROOT, createRepoDirName(group.repo, group.ref)); + const sparsePaths = [...new Set(group.entries.map((entry) => entry.repoPath))]; + + echo`Fetching ${group.repo} @ ${group.ref}`; + const commit = await fetchSparseRepo(group.repo, group.ref, sparsePaths, repoDir); + echo` commit ${commit}`; + + for (const entry of group.entries) { + const sourceDir = join(repoDir, entry.repoPath); + const targetDir = join(OUTPUT_ROOT, entry.slug); + + if (!existsSync(sourceDir)) { + throw new Error(`Missing source path in repo checkout: ${entry.repoPath}`); + } + + rmSync(targetDir, { recursive: true, force: true }); + cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); + + const skillManifest = join(targetDir, 'SKILL.md'); + if (!existsSync(skillManifest)) { + throw new Error(`Skill ${entry.slug} is missing SKILL.md after copy`); + } + + const requestedVersion = (entry.version || '').trim(); + const resolvedVersion = !requestedVersion || requestedVersion === 'main' + ? commit + : requestedVersion; + lock.skills.push({ + slug: entry.slug, + version: resolvedVersion, + repo: entry.repo, + repoPath: entry.repoPath, + ref: group.ref, + commit, + }); + + echo` OK ${entry.slug}`; + } +} + +writeFileSync(join(OUTPUT_ROOT, '.preinstalled-lock.json'), `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); +rmSync(TMP_ROOT, { recursive: true, force: true }); +echo`Preinstalled skills ready: ${OUTPUT_ROOT}`; diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index d2f52e5ec..798ca6b74 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -82,11 +82,11 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall }: Sk }; const handleOpenEditor = async () => { - if (!skill?.slug) return; + if (!skill?.id) return; try { const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', { method: 'POST', - body: JSON.stringify({ slug: skill.slug }), + body: JSON.stringify({ skillKey: skill.id, slug: skill.slug }), }); if (result.success) { toast.success(t('toast.openedEditor')); @@ -382,8 +382,14 @@ export function Skills() { // Filter skills const safeSkills = Array.isArray(skills) ? skills : []; const filteredSkills = safeSkills.filter((skill) => { - const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || - skill.description.toLowerCase().includes(searchQuery.toLowerCase()); + 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') { @@ -701,6 +707,11 @@ export function Skills() { ) : skill.isBundled ? ( ) : null} + {skill.slug && skill.slug !== skill.name ? ( + + {skill.slug} + + ) : null}

{skill.description}