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:
@@ -5,12 +5,18 @@ import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
|
||||
import { shell } from 'electron';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import {
|
||||
SkillInstallService,
|
||||
SkillInstallServiceError,
|
||||
type SkillInstallRequest,
|
||||
} from '@service/skill-install-service';
|
||||
|
||||
export async function handleSkillRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
): Promise<HostApiResult<unknown> | null> {
|
||||
const { pathname, method } = request;
|
||||
const installService = new SkillInstallService({ clawHubService: ctx.clawHubService });
|
||||
|
||||
if (pathname === '/api/skills/configs' && method === 'GET') {
|
||||
try {
|
||||
@@ -65,13 +71,26 @@ export async function handleSkillRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/skills/install' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<SkillInstallRequest>(request.body);
|
||||
return ok(await installService.install(body));
|
||||
} catch (error) {
|
||||
return failInstall(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/clawhub/install' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<Record<string, unknown>>(request.body);
|
||||
await ctx.clawHubService.install(body as { slug: string; version?: string; force?: boolean });
|
||||
return ok({ success: true });
|
||||
const body = parseJsonBody<{ slug: string; version?: string; force?: boolean }>(request.body);
|
||||
return ok(await installService.install({
|
||||
kind: 'marketplace',
|
||||
slug: body.slug,
|
||||
version: body.version,
|
||||
force: body.force,
|
||||
}));
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
return failInstall(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,3 +169,11 @@ export async function handleSkillRoutes(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function failInstall(error: unknown): HostApiResult<unknown> {
|
||||
if (error instanceof SkillInstallServiceError) {
|
||||
return fail(error.status, error.message);
|
||||
}
|
||||
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
458
electron/service/skill-install-service.ts
Normal file
458
electron/service/skill-install-service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user