feat: prebundle and auto-enable document, self-improving, search skills (#413)

This commit is contained in:
Felix
2026-03-11 18:40:46 +08:00
committed by GitHub
Unverified
parent ce7e890509
commit baa551b30c
12 changed files with 520 additions and 22 deletions

View File

@@ -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) });

View File

@@ -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');

View File

@@ -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 }) => {

View File

@@ -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);
}
}
}