feat: prebundle and auto-enable document, self-improving, search skills (#413)
This commit is contained in:
@@ -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 サブスクリプション)の両方に対応しています。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 订阅)登录。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<boolean> {
|
||||
const skillDir = path.join(this.workDir, 'skills', slug);
|
||||
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string): Promise<boolean> {
|
||||
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');
|
||||
|
||||
@@ -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<void> {
|
||||
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 }) => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
@@ -57,6 +83,25 @@ async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
}
|
||||
|
||||
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
|
||||
if (skillKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const config = await readConfig();
|
||||
if (!config.skills) {
|
||||
config.skills = {};
|
||||
}
|
||||
if (!config.skills.entries) {
|
||||
config.skills.entries = {};
|
||||
}
|
||||
for (const skillKey of skillKeys) {
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
entry.enabled = enabled;
|
||||
config.skills.entries[skillKey] = entry;
|
||||
}
|
||||
await writeConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill config
|
||||
*/
|
||||
@@ -177,3 +222,163 @@ export async function ensureBuiltinSkillsInstalled(): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PREINSTALLED_MANIFEST_NAME = 'preinstalled-manifest.json';
|
||||
const PREINSTALLED_MARKER_NAME = '.clawx-preinstalled.json';
|
||||
|
||||
async function readPreinstalledManifest(): Promise<PreinstalledSkillSpec[]> {
|
||||
const candidates = [
|
||||
join(getResourcesDir(), 'skills', PREINSTALLED_MANIFEST_NAME),
|
||||
join(process.cwd(), 'resources', 'skills', PREINSTALLED_MANIFEST_NAME),
|
||||
];
|
||||
|
||||
const manifestPath = candidates.find((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<Map<string, string>> {
|
||||
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<string, string>();
|
||||
for (const entry of parsed.skills || []) {
|
||||
const slug = entry.slug?.trim();
|
||||
const version = entry.version?.trim();
|
||||
if (slug && version) {
|
||||
versions.set(slug, version);
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read preinstalled-skills lock file:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function tryReadMarker(markerPath: string): Promise<PreinstalledMarker | null> {
|
||||
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/<slug>/ 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
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",
|
||||
|
||||
76
resources/skills/preinstalled-manifest.json
Normal file
76
resources/skills/preinstalled-manifest.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
118
scripts/bundle-preinstalled-skills.mjs
Normal file
118
scripts/bundle-preinstalled-skills.mjs
Normal file
@@ -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}`;
|
||||
@@ -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 ? (
|
||||
<Puzzle className="h-3 w-3 text-blue-500/70" />
|
||||
) : null}
|
||||
{skill.slug && skill.slug !== skill.name ? (
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded border border-black/10 dark:border-white/10 text-muted-foreground">
|
||||
{skill.slug}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[13.5px] text-muted-foreground line-clamp-1 pr-6 leading-relaxed">
|
||||
{skill.description}
|
||||
|
||||
Reference in New Issue
Block a user