package vip.mate.skill.installer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.installer.model.SkillBundle;
import vip.mate.skill.runtime.SkillFrontmatterParser;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Git 仓库 skill 拉取器
*
* 使用 git CLI(ProcessBuilder)进行 shallow clone,不引入 JGit 依赖。
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class GitSkillFetcher {
private static final long CLONE_TIMEOUT_SECONDS = 60;
private final SkillFrontmatterParser frontmatterParser;
public GitSkillFetcher(SkillFrontmatterParser frontmatterParser) {
this.frontmatterParser = frontmatterParser;
}
/**
* 从 GitHub 仓库获取 skill bundle
*
* @param repoUrl GitHub 仓库 URL
* @param ref git ref(branch/tag),null 时使用默认分支
* @param subPath 仓库内子目录路径(如 "skills/my-skill"),null 时使用根目录
* @return 解析后的 SkillBundle,失败返回 null
*/
public SkillBundle fetch(String repoUrl, String ref, String subPath) {
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("mateclaw-skill-install-");
cloneRepo(repoUrl, ref, tempDir);
// 定位 skill 根目录
Path skillRoot = tempDir;
if (subPath != null && !subPath.isBlank()) {
skillRoot = tempDir.resolve(subPath);
if (!Files.exists(skillRoot)) {
log.error("subPath '{}' not found in cloned repo", subPath);
return null;
}
}
// 定位 SKILL.md
Path skillMd = locateSkillMd(skillRoot);
if (skillMd == null) {
log.error("SKILL.md not found in {}", skillRoot);
return null;
}
// 解析内容
String content = Files.readString(skillMd);
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
// 提取 skill 名称
String name = parsed.getName();
if (name == null || name.isBlank()) {
// 从 URL 推导名称
name = extractRepoName(repoUrl);
}
// 收集 references/ 和 scripts/
Map references = collectFiles(skillRoot.resolve("references"));
Map scripts = collectFiles(skillRoot.resolve("scripts"));
return new SkillBundle(
name,
content,
references,
scripts,
"github",
repoUrl,
parsed.getFrontmatter().getOrDefault("version", "1.0.0").toString(),
parsed.getDescription(),
parsed.getFrontmatter().getOrDefault("author", "").toString(),
parsed.getFrontmatter().getOrDefault("icon", "").toString()
);
} catch (Exception e) {
log.error("Failed to fetch skill from GitHub {}: {}", repoUrl, e.getMessage());
return null;
} finally {
cleanup(tempDir);
}
}
/**
* git clone --depth 1 到临时目录
*/
private void cloneRepo(String repoUrl, String ref, Path targetDir) throws IOException, InterruptedException {
var command = new java.util.ArrayList();
command.add("git");
command.add("clone");
command.add("--depth");
command.add("1");
if (ref != null && !ref.isBlank()) {
command.add("--branch");
command.add(ref);
}
command.add(repoUrl);
command.add(targetDir.toString());
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(CLONE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new IOException("git clone timed out after " + CLONE_TIMEOUT_SECONDS + "s");
}
int exitCode = process.exitValue();
if (exitCode != 0) {
String output = new String(process.getInputStream().readAllBytes());
throw new IOException("git clone failed (exit=" + exitCode + "): " + output);
}
log.info("Cloned {} (ref={}) to {}", repoUrl, ref, targetDir);
}
/**
* 在 skill 根目录中定位 SKILL.md
*/
private Path locateSkillMd(Path skillRoot) {
Path direct = skillRoot.resolve("SKILL.md");
if (Files.exists(direct)) {
return direct;
}
// 尝试 skill.md(小写)
Path lower = skillRoot.resolve("skill.md");
if (Files.exists(lower)) {
return lower;
}
return null;
}
/**
* 收集目录下的所有文件为 relativePath → content 映射
*/
private Map collectFiles(Path dir) throws IOException {
Map files = new HashMap<>();
if (!Files.exists(dir) || !Files.isDirectory(dir)) {
return files;
}
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (attrs.isRegularFile() && attrs.size() < 1_000_000) { // 跳过超过 1MB 的文件
String relativePath = dir.relativize(file).toString();
files.put(relativePath, Files.readString(file));
}
return FileVisitResult.CONTINUE;
}
});
return files;
}
/**
* 从 GitHub URL 提取仓库名作为 skill 名称
*/
private String extractRepoName(String repoUrl) {
String url = repoUrl.replaceAll("\\.git$", "");
int lastSlash = url.lastIndexOf('/');
return lastSlash >= 0 ? url.substring(lastSlash + 1) : url;
}
/**
* 清理临时目录
*/
private void cleanup(Path tempDir) {
if (tempDir == null || !Files.exists(tempDir)) {
return;
}
try {
Files.walkFileTree(tempDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
log.warn("Failed to cleanup temp dir {}: {}", tempDir, e.getMessage());
}
}
}