- Implemented functionality to install skills from GitHub URLs. - Updated API to handle new installation requests from GitHub. - Enhanced UI to allow users to input GitHub skill URLs for installation. - Added translations for new GitHub installation features in English, Thai, and Chinese. - Created tests for the new skill installation service and API routes to ensure proper functionality.
459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
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<ClawHubService, 'install'>;
|
|
|
|
type EnableSkillFn = (skillKey: string) => Promise<void>;
|
|
type ResolveGitHubSkillDirFn = (source: ParsedGitHubSkillUrl, tempRoot: string) => Promise<string>;
|
|
|
|
interface SkillInstallServiceOptions {
|
|
clawHubService: MarketplaceInstaller;
|
|
configDir?: string;
|
|
skillsRootDir?: string;
|
|
createTempDir?: () => Promise<string>;
|
|
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<string>;
|
|
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<SkillInstallResult> {
|
|
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<SkillInstallRequest, { kind: 'marketplace' }>,
|
|
): Promise<SkillInstallResult> {
|
|
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<SkillInstallRequest, { kind: 'github-url' }>,
|
|
): Promise<SkillInstallResult> {
|
|
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/<ref>/.../SKILL.md or /tree/<ref>/....',
|
|
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/<ref>/.../SKILL.md or /tree/<ref>/....',
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
const response = await axios.get<NodeJS.ReadableStream>(archiveUrl, {
|
|
responseType: 'stream',
|
|
headers: {
|
|
'User-Agent': 'zn-ai-skill-installer',
|
|
},
|
|
});
|
|
|
|
await pipeline(response.data, createWriteStream(archivePath));
|
|
}
|
|
|
|
async function resolveExtractedRepoRoot(extractedRoot: string): Promise<string> {
|
|
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<void> {
|
|
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<void> {
|
|
await new Promise<void>((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);
|
|
}
|