feat: prebundle and auto-enable document, self-improving, search skills (#413)
This commit is contained in:
@@ -109,6 +109,12 @@ AIタスクを自動的に実行するようスケジュール設定できます
|
|||||||
|
|
||||||
### 🧩 拡張可能なスキルシステム
|
### 🧩 拡張可能なスキルシステム
|
||||||
事前構築されたスキルで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 サブスクリプション)の両方に対応しています。
|
複数の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
|
### 🧩 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. 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
|
### 🔐 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.
|
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 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
通过预构建的技能扩展 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 订阅)登录。
|
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ extraResources:
|
|||||||
# because electron-builder respects .gitignore which excludes node_modules/)
|
# because electron-builder respects .gitignore which excludes node_modules/)
|
||||||
- from: build/openclaw/
|
- from: build/openclaw/
|
||||||
to: 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
|
# NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the
|
||||||
# afterPack hook (after-pack.cjs) directly from node_modules, so they
|
# afterPack hook (after-pack.cjs) directly from node_modules, so they
|
||||||
# don't need an extraResources entry here.
|
# 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') {
|
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ slug: string }>(req);
|
const body = await parseJsonBody<{ slug?: string; skillKey?: string }>(req);
|
||||||
await ctx.clawHubService.openSkillReadme(body.slug);
|
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, { success: false, error: String(error) });
|
sendJson(res, 500, { success: false, error: String(error) });
|
||||||
|
|||||||
@@ -67,6 +67,55 @@ export class ClawHubService {
|
|||||||
return line.replace(this.ansiRegex, '').trim();
|
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
|
* Run a ClawHub CLI command
|
||||||
*/
|
*/
|
||||||
@@ -318,24 +367,33 @@ export class ClawHubService {
|
|||||||
/**
|
/**
|
||||||
* Open skill README/manual in default editor
|
* Open skill README/manual in default editor
|
||||||
*/
|
*/
|
||||||
async openSkillReadme(slug: string): Promise<boolean> {
|
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string): Promise<boolean> {
|
||||||
const skillDir = path.join(this.workDir, 'skills', slug);
|
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
|
// Try to find documentation file
|
||||||
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
|
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
|
||||||
let targetFile = '';
|
let targetFile = '';
|
||||||
|
|
||||||
for (const file of possibleFiles) {
|
if (skillDir) {
|
||||||
const filePath = path.join(skillDir, file);
|
for (const file of possibleFiles) {
|
||||||
if (fs.existsSync(filePath)) {
|
const filePath = path.join(skillDir, file);
|
||||||
targetFile = filePath;
|
if (fs.existsSync(filePath)) {
|
||||||
break;
|
targetFile = filePath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetFile) {
|
if (!targetFile) {
|
||||||
// If no md file, just open the directory
|
// If no md file, just open the directory
|
||||||
if (fs.existsSync(skillDir)) {
|
if (skillDir) {
|
||||||
targetFile = skillDir;
|
targetFile = skillDir;
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Skill directory not found');
|
throw new Error('Skill directory not found');
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToPro
|
|||||||
import { isQuitting, setQuitting } from './app-state';
|
import { isQuitting, setQuitting } from './app-state';
|
||||||
import { applyProxySettings } from './proxy';
|
import { applyProxySettings } from './proxy';
|
||||||
import { getSetting } from '../utils/store';
|
import { getSetting } from '../utils/store';
|
||||||
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
|
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||||
import { startHostApiServer } from '../api/server';
|
import { startHostApiServer } from '../api/server';
|
||||||
import { HostEventBus } from '../api/event-bus';
|
import { HostEventBus } from '../api/event-bus';
|
||||||
import { deviceOAuthManager } from '../utils/device-oauth';
|
import { deviceOAuthManager } from '../utils/device-oauth';
|
||||||
@@ -236,6 +236,13 @@ async function initialize(): Promise<void> {
|
|||||||
logger.warn('Failed to install built-in skills:', error);
|
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
|
// Bridge gateway and host-side events before any auto-start logic runs, so
|
||||||
// renderer subscribers observe the full startup lifecycle.
|
// renderer subscribers observe the full startup lifecycle.
|
||||||
gatewayManager.on('status', (status: { state: string }) => {
|
gatewayManager.on('status', (status: { state: string }) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { existsSync } from 'fs';
|
|||||||
import { constants } from 'fs';
|
import { constants } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getOpenClawDir } from './paths';
|
import { getOpenClawDir, getResourcesDir } from './paths';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||||
@@ -29,6 +29,32 @@ interface OpenClawConfig {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreinstalledSkillSpec {
|
||||||
|
slug: string;
|
||||||
|
version?: string;
|
||||||
|
autoEnable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreinstalledManifest {
|
||||||
|
skills?: PreinstalledSkillSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreinstalledLockEntry {
|
||||||
|
slug: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreinstalledLockFile {
|
||||||
|
skills?: PreinstalledLockEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreinstalledMarker {
|
||||||
|
source: 'clawx-preinstalled';
|
||||||
|
slug: string;
|
||||||
|
version: string;
|
||||||
|
installedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
async function fileExists(p: string): Promise<boolean> {
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
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');
|
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
|
* 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": {
|
"scripts": {
|
||||||
"init": "pnpm install && pnpm run uv:download",
|
"init": "pnpm install && pnpm run uv:download",
|
||||||
"dev": "vite",
|
"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",
|
"build:vite": "vite build",
|
||||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||||
|
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -36,11 +37,11 @@
|
|||||||
"uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux",
|
"uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux",
|
||||||
"uv:download:all": "zx scripts/download-bundled-uv.mjs --all",
|
"uv:download:all": "zx scripts/download-bundled-uv.mjs --all",
|
||||||
"icons": "zx scripts/generate-icons.mjs",
|
"icons": "zx scripts/generate-icons.mjs",
|
||||||
"package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder",
|
"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 && electron-builder --mac",
|
"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 && electron-builder --win",
|
"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 && electron-builder --linux",
|
"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 && electron-builder --publish always",
|
"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:patch": "pnpm version patch",
|
||||||
"version:minor": "pnpm version minor",
|
"version:minor": "pnpm version minor",
|
||||||
"version:major": "pnpm version major",
|
"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 () => {
|
const handleOpenEditor = async () => {
|
||||||
if (!skill?.slug) return;
|
if (!skill?.id) return;
|
||||||
try {
|
try {
|
||||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
|
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ slug: skill.slug }),
|
body: JSON.stringify({ skillKey: skill.id, slug: skill.slug }),
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(t('toast.openedEditor'));
|
toast.success(t('toast.openedEditor'));
|
||||||
@@ -382,8 +382,14 @@ export function Skills() {
|
|||||||
// Filter skills
|
// Filter skills
|
||||||
const safeSkills = Array.isArray(skills) ? skills : [];
|
const safeSkills = Array.isArray(skills) ? skills : [];
|
||||||
const filteredSkills = safeSkills.filter((skill) => {
|
const filteredSkills = safeSkills.filter((skill) => {
|
||||||
const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const q = searchQuery.toLowerCase().trim();
|
||||||
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
|
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;
|
let matchesSource = true;
|
||||||
if (selectedSource === 'built-in') {
|
if (selectedSource === 'built-in') {
|
||||||
@@ -701,6 +707,11 @@ export function Skills() {
|
|||||||
) : skill.isBundled ? (
|
) : skill.isBundled ? (
|
||||||
<Puzzle className="h-3 w-3 text-blue-500/70" />
|
<Puzzle className="h-3 w-3 text-blue-500/70" />
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
<p className="text-[13.5px] text-muted-foreground line-clamp-1 pr-6 leading-relaxed">
|
<p className="text-[13.5px] text-muted-foreground line-clamp-1 pr-6 leading-relaxed">
|
||||||
{skill.description}
|
{skill.description}
|
||||||
|
|||||||
Reference in New Issue
Block a user