feat: add GitHub skill installation support

- 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.
This commit is contained in:
DEV_DSW
2026-04-23 11:41:52 +08:00
parent f80bdc7f11
commit 655e7c51d2
16 changed files with 1818 additions and 51 deletions

View File

@@ -0,0 +1,458 @@
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);
}