feat(skills): add module for GitHub skill installation
Implement skill download from GitHub repositories: - parseSource() to handle owner/repo, owner/repo/skill, and full URLs - addSkill() with shallow clone and sparse checkout support - removeSkill() to uninstall skills - listInstalledSkills() for listing managed skills - Automatic .git cleanup after clone Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1105a7ac1f
commit
aee807e95a
1 changed files with 546 additions and 0 deletions
546
src/agent/skills/add.ts
Normal file
546
src/agent/skills/add.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
/**
|
||||
* Skills Add Module
|
||||
*
|
||||
* Add skills from GitHub repositories
|
||||
*
|
||||
* Supports formats:
|
||||
* - owner/repo → Clone entire repo to ~/.super-multica/skills/<repo>
|
||||
* - owner/repo/skill → Download single skill directory
|
||||
* - https://github.com/owner/repo
|
||||
*/
|
||||
|
||||
import { mkdir, rm, readdir, stat, rename } from "node:fs/promises";
|
||||
import { join, basename } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { DATA_DIR } from "../../shared/index.js";
|
||||
import { binaryExists } from "./eligibility.js";
|
||||
import { bumpSkillsVersion } from "./watcher.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SkillAddRequest {
|
||||
/** Source identifier (owner/repo, owner/repo/skill, or full URL) */
|
||||
source: string;
|
||||
/** Custom name for the skill (defaults to repo or skill name) */
|
||||
name?: string | undefined;
|
||||
/** Force overwrite if exists */
|
||||
force?: boolean | undefined;
|
||||
/** Timeout in milliseconds (default: 60000) */
|
||||
timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
export interface SkillAddResult {
|
||||
/** Whether addition succeeded */
|
||||
ok: boolean;
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
/** Path where skill was installed */
|
||||
path?: string | undefined;
|
||||
/** Skills found (for multi-skill repos) */
|
||||
skills?: string[] | undefined;
|
||||
}
|
||||
|
||||
interface ParsedSource {
|
||||
/** GitHub owner */
|
||||
owner: string;
|
||||
/** Repository name */
|
||||
repo: string;
|
||||
/** Specific skill path within repo (optional) */
|
||||
skillPath?: string | undefined;
|
||||
/** Branch/tag reference (optional) */
|
||||
ref?: string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Default timeout for git operations (60 seconds) */
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Skills directory: ~/.super-multica/skills */
|
||||
const SKILLS_DIR = join(DATA_DIR, "skills");
|
||||
|
||||
// ============================================================================
|
||||
// Source Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a source identifier into components
|
||||
*
|
||||
* Supported formats:
|
||||
* - owner/repo
|
||||
* - owner/repo/skill-name
|
||||
* - owner/repo@ref
|
||||
* - owner/repo/skill-name@ref
|
||||
* - https://github.com/owner/repo
|
||||
* - https://github.com/owner/repo/tree/main/skill-name
|
||||
*/
|
||||
export function parseSource(source: string): ParsedSource | null {
|
||||
const trimmed = source.trim();
|
||||
|
||||
// Handle full GitHub URLs
|
||||
if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
|
||||
return parseGitHubUrl(trimmed);
|
||||
}
|
||||
|
||||
// Handle owner/repo format
|
||||
return parseShorthand(trimmed);
|
||||
}
|
||||
|
||||
function parseGitHubUrl(url: string): ParsedSource | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only support github.com
|
||||
if (!parsed.hostname.includes("github.com")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse path: /owner/repo or /owner/repo/tree/branch/path
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owner = parts[0]!;
|
||||
// Remove .git suffix if present
|
||||
const repo = parts[1]!.replace(/\.git$/, "");
|
||||
|
||||
// Simple case: /owner/repo
|
||||
if (parts.length === 2) {
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
// /owner/repo/tree/branch/path case
|
||||
if (parts[2] === "tree" && parts.length >= 4) {
|
||||
const ref = parts[3];
|
||||
const skillPath = parts.length > 4 ? parts.slice(4).join("/") : undefined;
|
||||
return { owner, repo, ref, skillPath };
|
||||
}
|
||||
|
||||
// /owner/repo/blob/... - not supported
|
||||
if (parts[2] === "blob") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { owner, repo };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseShorthand(source: string): ParsedSource | null {
|
||||
// Split off @ref if present
|
||||
const [pathPart, ref] = source.split("@") as [string, string | undefined];
|
||||
|
||||
const parts = pathPart.split("/").filter(Boolean);
|
||||
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!;
|
||||
const skillPath = parts.length > 2 ? parts.slice(2).join("/") : undefined;
|
||||
|
||||
return { owner, repo, skillPath, ref };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Run a git command with timeout
|
||||
*/
|
||||
async function runGit(
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string | undefined;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("git", args, {
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killed = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
}, options.timeoutMs);
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code: number | null) => {
|
||||
clearTimeout(timeout);
|
||||
if (killed) {
|
||||
resolve({ ok: false, stdout, stderr: stderr + "\n[Timed out]" });
|
||||
} else {
|
||||
resolve({ ok: code === 0, stdout, stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ ok: false, stdout, stderr: stderr + "\n" + err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repository with sparse checkout for a specific path
|
||||
*/
|
||||
async function sparseClone(params: {
|
||||
repoUrl: string;
|
||||
targetDir: string;
|
||||
sparsePath: string;
|
||||
ref?: string | undefined;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ ok: boolean; message: string }> {
|
||||
const { repoUrl, targetDir, sparsePath, ref, timeoutMs } = params;
|
||||
|
||||
// Initialize empty repo
|
||||
let result = await runGit(["init", targetDir], { timeoutMs });
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git init failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
// Add remote
|
||||
result = await runGit(["remote", "add", "origin", repoUrl], {
|
||||
cwd: targetDir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git remote add failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
// Enable sparse checkout
|
||||
result = await runGit(["config", "core.sparseCheckout", "true"], {
|
||||
cwd: targetDir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git config failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
// Set sparse checkout path
|
||||
result = await runGit(
|
||||
["sparse-checkout", "set", "--no-cone", sparsePath],
|
||||
{ cwd: targetDir, timeoutMs },
|
||||
);
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git sparse-checkout failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
// Fetch and checkout
|
||||
const fetchRef = ref ?? "HEAD";
|
||||
result = await runGit(["fetch", "--depth=1", "origin", fetchRef], {
|
||||
cwd: targetDir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git fetch failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
result = await runGit(["checkout", "FETCH_HEAD"], {
|
||||
cwd: targetDir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git checkout failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
return { ok: true, message: "Sparse clone completed" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow clone an entire repository
|
||||
*/
|
||||
async function shallowClone(params: {
|
||||
repoUrl: string;
|
||||
targetDir: string;
|
||||
ref?: string | undefined;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ ok: boolean; message: string }> {
|
||||
const { repoUrl, targetDir, ref, timeoutMs } = params;
|
||||
|
||||
const args = ["clone", "--depth=1"];
|
||||
|
||||
if (ref) {
|
||||
args.push("--branch", ref);
|
||||
}
|
||||
|
||||
args.push(repoUrl, targetDir);
|
||||
|
||||
const result = await runGit(args, { timeoutMs });
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, message: `git clone failed: ${result.stderr}` };
|
||||
}
|
||||
|
||||
return { ok: true, message: "Clone completed" };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find SKILL.md files in a directory (recursively, max 2 levels)
|
||||
*/
|
||||
async function findSkillFiles(
|
||||
dir: string,
|
||||
maxDepth: number = 2,
|
||||
currentDepth: number = 0,
|
||||
): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
|
||||
if (currentDepth > maxDepth) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.toUpperCase() === "SKILL.MD") {
|
||||
results.push(fullPath);
|
||||
} else if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
||||
const nested = await findSkillFiles(fullPath, maxDepth, currentDepth + 1);
|
||||
results.push(...nested);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory is a valid skill (has SKILL.md)
|
||||
*/
|
||||
async function isSkillDirectory(dir: string): Promise<boolean> {
|
||||
const skillFile = join(dir, "SKILL.md");
|
||||
try {
|
||||
const stats = await stat(skillFile);
|
||||
return stats.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Add Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add a skill from a GitHub repository
|
||||
*/
|
||||
export async function addSkill(request: SkillAddRequest): Promise<SkillAddResult> {
|
||||
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
// Check git is available
|
||||
if (!binaryExists("git")) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "git is not installed. Please install git first.",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse source
|
||||
const parsed = parseSource(request.source);
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid source format: ${request.source}. Use owner/repo or owner/repo/skill-name`,
|
||||
};
|
||||
}
|
||||
|
||||
const { owner, repo, skillPath, ref } = parsed;
|
||||
const repoUrl = `https://github.com/${owner}/${repo}.git`;
|
||||
|
||||
// Determine target name
|
||||
const targetName = request.name ?? (skillPath ? basename(skillPath) : repo);
|
||||
const targetDir = join(SKILLS_DIR, targetName);
|
||||
|
||||
// Check if exists
|
||||
if (existsSync(targetDir) && !request.force) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Skill '${targetName}' already exists at ${targetDir}. Use --force to overwrite.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure skills directory exists
|
||||
await mkdir(SKILLS_DIR, { recursive: true });
|
||||
|
||||
// Remove existing if force
|
||||
if (existsSync(targetDir)) {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Clone
|
||||
let cloneResult: { ok: boolean; message: string };
|
||||
|
||||
if (skillPath) {
|
||||
// Sparse checkout for specific skill path
|
||||
cloneResult = await sparseClone({
|
||||
repoUrl,
|
||||
targetDir,
|
||||
sparsePath: skillPath,
|
||||
ref,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (cloneResult.ok) {
|
||||
// Move skill contents up from nested path
|
||||
const nestedPath = join(targetDir, skillPath);
|
||||
if (existsSync(nestedPath)) {
|
||||
// Create temp dir, move contents, swap
|
||||
const tempDir = `${targetDir}_temp_${Date.now()}`;
|
||||
await rename(nestedPath, tempDir);
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
await rename(tempDir, targetDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full shallow clone
|
||||
cloneResult = await shallowClone({
|
||||
repoUrl,
|
||||
targetDir,
|
||||
ref,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (!cloneResult.ok) {
|
||||
// Clean up on failure
|
||||
if (existsSync(targetDir)) {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: cloneResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove .git directory to save space
|
||||
const gitDir = join(targetDir, ".git");
|
||||
if (existsSync(gitDir)) {
|
||||
await rm(gitDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Find skills in the downloaded content
|
||||
const skillFiles = await findSkillFiles(targetDir);
|
||||
|
||||
if (skillFiles.length === 0) {
|
||||
// Check if this is a multi-skill repo
|
||||
const isSkill = await isSkillDirectory(targetDir);
|
||||
if (!isSkill) {
|
||||
// Clean up - no valid skill found
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
return {
|
||||
ok: false,
|
||||
message: `No SKILL.md found in ${request.source}. Is this a valid skill repository?`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Bump version to trigger reload
|
||||
bumpSkillsVersion("manual", targetDir);
|
||||
|
||||
// Determine skill names found
|
||||
const skillNames = skillFiles.map((f) => {
|
||||
const dir = f.replace(/\/SKILL\.md$/i, "");
|
||||
return dir === targetDir ? targetName : basename(dir);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
skillNames.length === 1
|
||||
? `Added skill '${targetName}' to ${targetDir}`
|
||||
: `Added ${skillNames.length} skills from ${owner}/${repo}`,
|
||||
path: targetDir,
|
||||
skills: skillNames.length > 0 ? skillNames : [targetName],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an installed skill
|
||||
*/
|
||||
export async function removeSkill(name: string): Promise<SkillAddResult> {
|
||||
const targetDir = join(SKILLS_DIR, name);
|
||||
|
||||
if (!existsSync(targetDir)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Skill '${name}' not found at ${targetDir}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
bumpSkillsVersion("manual", targetDir);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `Removed skill '${name}'`,
|
||||
path: targetDir,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
message: `Failed to remove skill: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed skills (in managed directory)
|
||||
*/
|
||||
export async function listInstalledSkills(): Promise<string[]> {
|
||||
if (!existsSync(SKILLS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
|
||||
const skills: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
||||
const hasSkill = await isSkillDirectory(join(SKILLS_DIR, entry.name));
|
||||
if (hasSkill) {
|
||||
skills.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue