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,363 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const files = new Map<string, string>();
const dirs = new Set<string>(['/']);
let tempCounter = 0;
const normalize = (input: string): string => {
const raw = String(input || '').replace(/\\/g, '/');
const absolute = raw.startsWith('/');
const parts = raw.split('/').filter(Boolean);
const stack: string[] = [];
for (const part of parts) {
if (part === '.') continue;
if (part === '..') {
if (stack.length > 0) {
stack.pop();
}
continue;
}
stack.push(part);
}
const normalized = `${absolute ? '/' : ''}${stack.join('/')}`;
return normalized || (absolute ? '/' : '.');
};
const dirname = (input: string): string => {
const normalized = normalize(input);
if (normalized === '/' || normalized === '.') return normalized;
const parts = normalized.split('/').filter(Boolean);
parts.pop();
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
};
const basename = (input: string): string => {
const normalized = normalize(input);
if (normalized === '/' || normalized === '.') return normalized;
const parts = normalized.split('/').filter(Boolean);
return parts[parts.length - 1] || normalized;
};
const ensureDir = (input: string): string => {
const normalized = normalize(input);
const parts = normalized.split('/').filter(Boolean);
let current = normalized.startsWith('/') ? '/' : '';
if (normalized === '/' || normalized === '.') {
dirs.add(normalized);
return normalized;
}
for (const part of parts) {
current = current === '/' ? `/${part}` : `${current}/${part}`;
dirs.add(current);
}
return normalized;
};
const writeFile = (input: string, content: string): void => {
const normalized = normalize(input);
ensureDir(dirname(normalized));
files.set(normalized, content);
};
const existsSync = vi.fn((input: string) => {
const normalized = normalize(input);
return dirs.has(normalized) || files.has(normalized);
});
const mkdir = vi.fn(async (input: string) => {
ensureDir(input);
});
const mkdtemp = vi.fn(async (prefix: string) => {
tempCounter += 1;
const dir = normalize(`${prefix}${tempCounter}`);
ensureDir(dir);
return dir;
});
const readFile = vi.fn(async (input: string) => {
const normalized = normalize(input);
const value = files.get(normalized);
if (typeof value !== 'string') {
throw new Error(`ENOENT: ${normalized}`);
}
return value;
});
const rm = vi.fn(async (input: string) => {
const normalized = normalize(input);
for (const key of [...files.keys()]) {
if (key === normalized || key.startsWith(`${normalized}/`)) {
files.delete(key);
}
}
for (const key of [...dirs]) {
if (key === normalized || key.startsWith(`${normalized}/`)) {
dirs.delete(key);
}
}
dirs.add('/');
});
const cp = vi.fn(async (source: string, destination: string) => {
const src = normalize(source);
const dest = normalize(destination);
ensureDir(dest);
for (const dir of [...dirs]) {
if (dir === src || dir.startsWith(`${src}/`)) {
const relative = dir === src ? '' : dir.slice(src.length + 1);
ensureDir(relative ? `${dest}/${relative}` : dest);
}
}
for (const [filePath, content] of [...files.entries()]) {
if (filePath === src || filePath.startsWith(`${src}/`)) {
const relative = filePath === src ? basename(filePath) : filePath.slice(src.length + 1);
writeFile(relative ? `${dest}/${relative}` : dest, content);
}
}
});
const reset = () => {
files.clear();
dirs.clear();
dirs.add('/');
tempCounter = 0;
existsSync.mockClear();
mkdir.mockClear();
mkdtemp.mockClear();
readFile.mockClear();
rm.mockClear();
cp.mockClear();
};
return {
files,
dirs,
normalize,
dirname,
basename,
ensureDir,
writeFile,
existsSync,
mkdir,
mkdtemp,
readFile,
rm,
cp,
reset,
};
});
vi.mock('node:fs', () => ({
createWriteStream: vi.fn(() => ({ on: vi.fn() })),
existsSync: mocks.existsSync,
}));
vi.mock('node:fs/promises', () => ({
cp: mocks.cp,
mkdir: mocks.mkdir,
mkdtemp: mocks.mkdtemp,
readFile: mocks.readFile,
readdir: vi.fn(async () => []),
rm: mocks.rm,
}));
vi.mock('node:path', () => ({
basename: mocks.basename,
dirname: mocks.dirname,
join: (...parts: string[]) => mocks.normalize(parts.join('/')),
resolve: (...parts: string[]) => mocks.normalize(`/${parts.join('/')}`),
sep: '/',
}));
vi.mock('node:os', () => ({
tmpdir: () => '/tmp',
}));
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
vi.mock('node:stream/promises', () => ({
pipeline: vi.fn(async () => undefined),
}));
vi.mock('axios', () => ({
default: {
get: vi.fn(),
},
}));
vi.mock('extract-zip', () => ({
default: vi.fn(async () => undefined),
}));
vi.mock('@electron/utils/skill-config', () => ({
updateSkillConfig: vi.fn(async () => ({ success: true })),
}));
vi.mock('@electron/utils/paths', () => ({
ensureDir: (input: string) => mocks.ensureDir(input),
getOpenClawConfigDir: () => '/openclaw',
}));
import {
SkillInstallService,
SkillInstallServiceError,
parseGitHubSkillUrl,
} from '../electron/service/skill-install-service';
describe('SkillInstallService', () => {
beforeEach(() => {
mocks.reset();
});
it('parses GitHub blob and tree skill URLs', () => {
expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md')).toMatchObject({
owner: 'MiniMax-AI',
repo: 'skills',
ref: 'main',
skillPath: 'skills/minimax-xlsx',
defaultSlug: 'minimax-xlsx',
});
expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/tree/main/skills/minimax-xlsx')).toMatchObject({
owner: 'MiniMax-AI',
repo: 'skills',
ref: 'main',
skillPath: 'skills/minimax-xlsx',
defaultSlug: 'minimax-xlsx',
});
});
it('installs marketplace skills and enables them', async () => {
const enableSkill = vi.fn(async () => undefined);
const install = vi.fn(async ({ slug }: { slug: string }) => {
mocks.writeFile(`/workspace/skills/${slug}/SKILL.md`, '---\nname: demo-skill\n---\n');
});
const service = new SkillInstallService({
clawHubService: { install } as any,
configDir: '/workspace',
skillsRootDir: '/workspace/skills',
enableSkill,
});
const result = await service.install({ kind: 'marketplace', slug: 'demo-skill', version: '1.2.3' });
expect(install).toHaveBeenCalledWith({
slug: 'demo-skill',
version: '1.2.3',
force: undefined,
});
expect(enableSkill).toHaveBeenCalledWith('demo-skill');
expect(result).toMatchObject({
success: true,
slug: 'demo-skill',
source: 'marketplace',
enabled: true,
});
});
it('installs GitHub skill directories, prefers frontmatter name, and enables them', async () => {
const enableSkill = vi.fn(async () => undefined);
mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\ndescription: Spreadsheet helper\n---\n');
const service = new SkillInstallService({
clawHubService: { install: vi.fn() } as any,
configDir: '/workspace',
skillsRootDir: '/workspace/skills',
enableSkill,
createTempDir: async () => '/workspace/temp-1',
resolveGitHubSkillDirectory: async () => '/workspace/source-skill',
});
const result = await service.install({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
});
expect(result).toMatchObject({
success: true,
slug: 'minimax-xlsx',
source: 'github-url',
enabled: true,
});
expect(enableSkill).toHaveBeenCalledWith('minimax-xlsx');
expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('Spreadsheet helper');
});
it('rejects GitHub installs when SKILL.md is missing', async () => {
mocks.writeFile('/workspace/source-skill/README.md', '# not a skill\n');
const service = new SkillInstallService({
clawHubService: { install: vi.fn() } as any,
configDir: '/workspace',
skillsRootDir: '/workspace/skills',
enableSkill: async () => undefined,
createTempDir: async () => '/workspace/temp-1',
resolveGitHubSkillDirectory: async () => '/workspace/source-skill',
});
await expect(service.install({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/tree/main/skills/missing-skill',
})).rejects.toThrow('SKILL.md not found');
});
it('rejects GitHub installs when the target directory already exists and force is false', async () => {
mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\n');
mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\n');
const service = new SkillInstallService({
clawHubService: { install: vi.fn() } as any,
configDir: '/workspace',
skillsRootDir: '/workspace/skills',
enableSkill: async () => undefined,
createTempDir: async () => '/workspace/temp-1',
resolveGitHubSkillDirectory: async () => '/workspace/source-skill',
});
await expect(service.install({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
})).rejects.toMatchObject<Partial<SkillInstallServiceError>>({
status: 409,
code: 'skill_exists',
});
});
it('overwrites GitHub installs when force is true', async () => {
mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\nnew-content');
mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\nold-content');
const service = new SkillInstallService({
clawHubService: { install: vi.fn() } as any,
configDir: '/workspace',
skillsRootDir: '/workspace/skills',
enableSkill: async () => undefined,
createTempDir: async () => '/workspace/temp-1',
resolveGitHubSkillDirectory: async () => '/workspace/source-skill',
});
await service.install({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
force: true,
});
expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('new-content');
});
});

53
tests/skills-api.test.ts Normal file
View File

@@ -0,0 +1,53 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
hostApiFetch: vi.fn(),
gatewayRpc: vi.fn(),
}));
vi.mock('../src/lib/host-api', () => ({
hostApiFetch: mocks.hostApiFetch,
}));
vi.mock('../src/lib/gateway-client', () => ({
gatewayRpc: mocks.gatewayRpc,
}));
import { apiGetSkillsDir, apiInstallSkill } from '../src/lib/skills-api';
describe('skills api helpers', () => {
beforeEach(() => {
mocks.hostApiFetch.mockReset();
mocks.gatewayRpc.mockReset();
});
it('falls back to ~/.openclaw/skills when the host path request fails', async () => {
mocks.hostApiFetch.mockRejectedValue(new Error('offline'));
await expect(apiGetSkillsDir()).resolves.toBe('~/.openclaw/skills');
});
it('posts unified install requests to /api/skills/install', async () => {
mocks.hostApiFetch.mockResolvedValue({
success: true,
slug: 'minimax-xlsx',
baseDir: 'C:/Users/Administrator/.openclaw/skills/minimax-xlsx',
source: 'github-url',
enabled: true,
});
await expect(apiInstallSkill({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
})).resolves.toMatchObject({
success: true,
slug: 'minimax-xlsx',
source: 'github-url',
});
expect(mocks.hostApiFetch).toHaveBeenCalledWith('/api/skills/install', expect.objectContaining({
method: 'POST',
}));
});
});

152
tests/skills-routes.test.ts Normal file
View File

@@ -0,0 +1,152 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
install: vi.fn(),
getAllSkillConfigs: vi.fn(),
updateSkillConfig: vi.fn(),
openPath: vi.fn(),
MockSkillInstallServiceError: class MockSkillInstallServiceError extends Error {
status: number;
code: string;
constructor(message: string, status: number, code: string) {
super(message);
this.status = status;
this.code = code;
}
},
}));
vi.mock('@service/skill-install-service', () => ({
SkillInstallService: class {
install = mocks.install;
},
SkillInstallServiceError: mocks.MockSkillInstallServiceError,
}));
vi.mock('../electron/utils/skill-config', () => ({
getAllSkillConfigs: mocks.getAllSkillConfigs,
updateSkillConfig: mocks.updateSkillConfig,
}));
vi.mock('../electron/utils/paths', () => ({
getOpenClawConfigDir: () => 'C:/Users/Administrator/.openclaw',
}));
vi.mock('electron', () => ({
shell: {
openPath: mocks.openPath,
},
}));
import { normalizeRequest } from '../electron/api/route-utils';
import { handleSkillRoutes } from '../electron/api/routes/skills';
const ctx = {
gatewayManager: null,
providerApiService: null,
mainWindow: null,
clawHubService: {
getMarketplaceCapability: vi.fn(),
search: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
listInstalled: vi.fn(),
openSkillReadme: vi.fn(),
openSkillPath: vi.fn(),
},
} as any;
describe('skill install routes', () => {
beforeEach(() => {
mocks.install.mockReset();
mocks.getAllSkillConfigs.mockReset();
mocks.updateSkillConfig.mockReset();
mocks.openPath.mockReset();
});
it('keeps /api/clawhub/install compatible with the marketplace payload', async () => {
mocks.install.mockResolvedValue({
success: true,
slug: 'demo-skill',
baseDir: 'C:/Users/Administrator/.openclaw/skills/demo-skill',
source: 'marketplace',
enabled: true,
});
const response = await handleSkillRoutes(normalizeRequest({
path: '/api/clawhub/install',
method: 'POST',
body: JSON.stringify({
slug: 'demo-skill',
version: '1.2.3',
force: true,
}),
}), ctx);
expect(mocks.install).toHaveBeenCalledWith({
kind: 'marketplace',
slug: 'demo-skill',
version: '1.2.3',
force: true,
});
expect(response?.ok).toBe(true);
expect(response?.json).toMatchObject({
success: true,
slug: 'demo-skill',
source: 'marketplace',
});
});
it('handles /api/skills/install for github-url payloads', async () => {
mocks.install.mockResolvedValue({
success: true,
slug: 'minimax-xlsx',
baseDir: 'C:/Users/Administrator/.openclaw/skills/minimax-xlsx',
source: 'github-url',
enabled: true,
});
const response = await handleSkillRoutes(normalizeRequest({
path: '/api/skills/install',
method: 'POST',
body: JSON.stringify({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
}),
}), ctx);
expect(mocks.install).toHaveBeenCalledWith({
kind: 'github-url',
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
});
expect(response?.ok).toBe(true);
expect(response?.json).toMatchObject({
success: true,
slug: 'minimax-xlsx',
source: 'github-url',
});
});
it('returns the service status code when install validation fails', async () => {
mocks.install.mockRejectedValue(new mocks.MockSkillInstallServiceError(
'GitHub skill URL is invalid.',
400,
'invalid_github_url',
));
const response = await handleSkillRoutes(normalizeRequest({
path: '/api/skills/install',
method: 'POST',
body: JSON.stringify({
kind: 'github-url',
url: 'https://example.com/not-supported',
}),
}), ctx);
expect(response?.ok).toBe(false);
expect(response?.status).toBe(400);
expect(response?.error).toBe('GitHub skill URL is invalid.');
});
});