feat: prebundle and auto-enable document, self-improving, search skills (#413)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user