import { spawn } from 'node:child_process'; import { createWriteStream, existsSync } from 'node:fs'; import { cp, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve, sep } from 'node:path'; import { pipeline } from 'node:stream/promises'; import axios from 'axios'; import extractZip from 'extract-zip'; import type { ClawHubService } from '@electron/gateway/clawhub'; import { updateSkillConfig } from '@electron/utils/skill-config'; import { ensureDir, getOpenClawConfigDir } from '@electron/utils/paths'; export type SkillInstallRequest = | { kind: 'marketplace'; slug: string; version?: string; force?: boolean } | { kind: 'github-url'; url: string; force?: boolean }; export type SkillInstallSource = 'marketplace' | 'github-url'; export interface SkillInstallResult { success: true; slug: string; baseDir: string; source: SkillInstallSource; enabled: true; } export interface ParsedGitHubSkillUrl { owner: string; repo: string; ref: string; skillPath: string; defaultSlug: string; archiveUrl: string; repositoryUrl: string; originalUrl: string; } type MarketplaceInstaller = Pick; type EnableSkillFn = (skillKey: string) => Promise; type ResolveGitHubSkillDirFn = (source: ParsedGitHubSkillUrl, tempRoot: string) => Promise; interface SkillInstallServiceOptions { clawHubService: MarketplaceInstaller; configDir?: string; skillsRootDir?: string; createTempDir?: () => Promise; enableSkill?: EnableSkillFn; resolveGitHubSkillDirectory?: ResolveGitHubSkillDirFn; } export class SkillInstallServiceError extends Error { constructor( message: string, readonly status: number, readonly code: string, ) { super(message); this.name = 'SkillInstallServiceError'; } } export class SkillInstallService { private readonly clawHubService: MarketplaceInstaller; private readonly configDir: string; private readonly skillsRootDir: string; private readonly createTempDir: () => Promise; private readonly enableSkill: EnableSkillFn; private readonly resolveGitHubSkillDirectory: ResolveGitHubSkillDirFn; constructor(options: SkillInstallServiceOptions) { this.clawHubService = options.clawHubService; this.configDir = options.configDir ?? getOpenClawConfigDir(); this.skillsRootDir = options.skillsRootDir ?? join(this.configDir, 'skills'); this.createTempDir = options.createTempDir ?? (() => mkdtemp(join(tmpdir(), 'zn-ai-skill-install-'))); this.enableSkill = options.enableSkill ?? defaultEnableSkill; this.resolveGitHubSkillDirectory = options.resolveGitHubSkillDirectory ?? defaultResolveGitHubSkillDirectory; } async install(request: SkillInstallRequest): Promise { this.ensureInstallLayout(); if (request.kind === 'marketplace') { return this.installMarketplaceSkill(request); } return this.installGitHubSkill(request); } private ensureInstallLayout(): void { ensureDir(this.configDir); ensureDir(this.skillsRootDir); } private async installMarketplaceSkill( request: Extract, ): Promise { const slug = normalizeSkillSlug(request.slug); const baseDir = join(this.skillsRootDir, slug); if (existsSync(baseDir) && request.force !== true) { throw new SkillInstallServiceError( `Skill "${slug}" is already installed. Use force to overwrite it.`, 409, 'skill_exists', ); } await this.clawHubService.install({ slug, version: request.version, force: request.force, }); const manifestPath = join(baseDir, 'SKILL.md'); if (!existsSync(manifestPath)) { throw new SkillInstallServiceError( `Installed skill "${slug}" is missing SKILL.md.`, 500, 'missing_skill_manifest', ); } await this.enableSkill(slug); return { success: true, slug, baseDir, source: 'marketplace', enabled: true, }; } private async installGitHubSkill( request: Extract, ): Promise { const source = parseGitHubSkillUrl(request.url); const tempRoot = await this.createTempDir(); try { const sourceSkillDir = await this.resolveGitHubSkillDirectory(source, tempRoot); const manifestPath = join(sourceSkillDir, 'SKILL.md'); if (!existsSync(manifestPath)) { throw new SkillInstallServiceError( 'SKILL.md not found in the downloaded skill directory.', 400, 'missing_skill_manifest', ); } const slug = await resolveSkillSlug(manifestPath, source.defaultSlug); const baseDir = join(this.skillsRootDir, slug); await prepareDestination(baseDir, request.force === true); await cp(sourceSkillDir, baseDir, { recursive: true, force: true }); await this.enableSkill(slug); return { success: true, slug, baseDir, source: 'github-url', enabled: true, }; } finally { await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); } } } export function parseGitHubSkillUrl(url: string): ParsedGitHubSkillUrl { const trimmedUrl = String(url || '').trim(); if (!trimmedUrl) { throw new SkillInstallServiceError('GitHub skill URL is required.', 400, 'invalid_github_url'); } let parsed: URL; try { parsed = new URL(trimmedUrl); } catch { throw new SkillInstallServiceError('GitHub skill URL is invalid.', 400, 'invalid_github_url'); } if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') { throw new SkillInstallServiceError( 'Only public github.com skill URLs are supported.', 400, 'invalid_github_url', ); } const segments = parsed.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment)); if (segments.length < 4) { throw new SkillInstallServiceError( 'GitHub skill URL must point to /blob//.../SKILL.md or /tree//....', 400, 'invalid_github_url', ); } const [owner, repo, mode, ref, ...rest] = segments; validateGitHubPathSegment(owner, 'owner'); validateGitHubPathSegment(repo, 'repository'); validateGitHubPathSegment(ref, 'ref'); if (mode !== 'blob' && mode !== 'tree') { throw new SkillInstallServiceError( 'GitHub skill URL must use either /blob//.../SKILL.md or /tree//....', 400, 'invalid_github_url', ); } const normalizedRest = rest.map((segment) => { validateGitHubPathSegment(segment, 'skill path'); return segment; }); if (mode === 'blob') { if (normalizedRest.length === 0 || normalizedRest[normalizedRest.length - 1] !== 'SKILL.md') { throw new SkillInstallServiceError( 'GitHub blob URL must point to a SKILL.md file.', 400, 'invalid_github_url', ); } const directorySegments = normalizedRest.slice(0, -1); const skillPath = directorySegments.join('/'); return { owner, repo, ref, skillPath, defaultSlug: directorySegments[directorySegments.length - 1] || repo, archiveUrl: buildGitHubArchiveUrl(owner, repo, ref), repositoryUrl: `https://github.com/${owner}/${repo}.git`, originalUrl: trimmedUrl, }; } if (normalizedRest.length === 0) { throw new SkillInstallServiceError( 'GitHub tree URL must point to a skill directory.', 400, 'invalid_github_url', ); } return { owner, repo, ref, skillPath: normalizedRest.join('/'), defaultSlug: normalizedRest[normalizedRest.length - 1], archiveUrl: buildGitHubArchiveUrl(owner, repo, ref), repositoryUrl: `https://github.com/${owner}/${repo}.git`, originalUrl: trimmedUrl, }; } function buildGitHubArchiveUrl(owner: string, repo: string, ref: string): string { return `https://api.github.com/repos/${owner}/${repo}/zipball/${encodeURIComponent(ref)}`; } function validateGitHubPathSegment(segment: string, label: string): void { const normalized = String(segment || '').trim(); if (!normalized || normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) { throw new SkillInstallServiceError( `GitHub ${label} segment is invalid.`, 400, 'invalid_github_url', ); } } function normalizeSkillSlug(slug: string): string { const normalized = String(slug || '').trim(); if (!normalized) { throw new SkillInstallServiceError('Skill slug is required.', 400, 'invalid_skill_slug'); } if (normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) { throw new SkillInstallServiceError('Skill slug contains an invalid path segment.', 400, 'invalid_skill_slug'); } return normalized; } async function resolveSkillSlug(manifestPath: string, fallbackSlug: string): Promise { const raw = await readFile(manifestPath, 'utf-8'); const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/); const nameMatch = frontmatterMatch?.[1]?.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m); return normalizeSkillSlug(nameMatch?.[1] || fallbackSlug); } async function defaultEnableSkill(skillKey: string): Promise { const result = await updateSkillConfig(skillKey, { enabled: true }); if (!result.success) { throw new SkillInstallServiceError( result.error || `Failed to enable skill "${skillKey}".`, 500, 'enable_skill_failed', ); } } async function prepareDestination(baseDir: string, force: boolean): Promise { if (existsSync(baseDir)) { if (!force) { throw new SkillInstallServiceError( `Skill "${basename(baseDir)}" is already installed. Use force to overwrite it.`, 409, 'skill_exists', ); } await rm(baseDir, { recursive: true, force: true }); } await mkdir(dirname(baseDir), { recursive: true }); } async function defaultResolveGitHubSkillDirectory( source: ParsedGitHubSkillUrl, tempRoot: string, ): Promise { const archivePath = join(tempRoot, 'github-skill.zip'); const extractedRoot = join(tempRoot, 'zip'); try { await downloadGitHubArchive(source.archiveUrl, archivePath); await mkdir(extractedRoot, { recursive: true }); await extractZip(archivePath, { dir: extractedRoot }); const repoRoot = await resolveExtractedRepoRoot(extractedRoot); return resolveSkillDirectory(repoRoot, source.skillPath); } catch (archiveError) { try { const cloneDir = join(tempRoot, 'sparse'); await sparseCheckoutGitHubSkill(source, cloneDir); return resolveSkillDirectory(cloneDir, source.skillPath); } catch (gitError) { throw new SkillInstallServiceError( `Failed to download GitHub skill. ZIP attempt failed: ${getErrorMessage(archiveError)}. Sparse checkout fallback failed: ${getErrorMessage(gitError)}.`, 502, 'github_download_failed', ); } } } async function downloadGitHubArchive(archiveUrl: string, archivePath: string): Promise { const response = await axios.get(archiveUrl, { responseType: 'stream', headers: { 'User-Agent': 'zn-ai-skill-installer', }, }); await pipeline(response.data, createWriteStream(archivePath)); } async function resolveExtractedRepoRoot(extractedRoot: string): Promise { const entries = await readdir(extractedRoot, { withFileTypes: true }); const directories = entries.filter((entry) => entry.isDirectory()); if (directories.length === 1) { return join(extractedRoot, directories[0].name); } if (existsSync(join(extractedRoot, 'SKILL.md'))) { return extractedRoot; } throw new SkillInstallServiceError( 'Downloaded GitHub archive has an unexpected directory structure.', 502, 'github_archive_invalid', ); } function resolveSkillDirectory(rootDir: string, skillPath: string): string { const targetDir = skillPath ? resolve(rootDir, skillPath) : rootDir; const resolvedRootDir = resolve(rootDir); const normalizedRoot = `${resolvedRootDir}${sep}`; const normalizedTarget = resolve(targetDir); if (normalizedTarget !== resolvedRootDir && !normalizedTarget.startsWith(normalizedRoot)) { throw new SkillInstallServiceError( 'Resolved GitHub skill path escapes the repository root.', 400, 'invalid_github_url', ); } if (!existsSync(normalizedTarget)) { throw new SkillInstallServiceError( `Skill directory "${skillPath || '.'}" was not found in the repository.`, 404, 'github_skill_path_not_found', ); } return normalizedTarget; } async function sparseCheckoutGitHubSkill(source: ParsedGitHubSkillUrl, cloneDir: string): Promise { if (source.skillPath) { await runGitCommand(['clone', '--filter=blob:none', '--sparse', source.repositoryUrl, cloneDir]); await runGitCommand(['-C', cloneDir, 'sparse-checkout', 'set', source.skillPath]); } else { await runGitCommand(['clone', '--filter=blob:none', source.repositoryUrl, cloneDir]); } await runGitCommand(['-C', cloneDir, 'fetch', '--depth', '1', 'origin', source.ref]); await runGitCommand(['-C', cloneDir, 'checkout', 'FETCH_HEAD']); } async function runGitCommand(args: string[]): Promise { await new Promise((resolvePromise, rejectPromise) => { const child = spawn('git', args, { windowsHide: true, env: { ...process.env, CI: 'true', FORCE_COLOR: '0', }, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', (error) => { rejectPromise(error); }); child.on('close', (code) => { if (code === 0) { resolvePromise(); return; } rejectPromise(new Error(stderr.trim() || stdout.trim() || `git exited with code ${code ?? 'unknown'}`)); }); }); } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }