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:
363
tests/skill-install-service.test.ts
Normal file
363
tests/skill-install-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user