mateclaw/mateclaw-server/src/main/java/vip/mate/skill/installer/GitSkillFetcher.java
matevip 579d60125b Initial commit: MateClaw — Java + Vue 3 AI Assistant System
Full-stack AI assistant built on Spring AI Alibaba.
Features: ReAct Agent, Plan-and-Execute, MCP Protocol, Multi-Model, Multi-Channel.

Apache-2.0 License
2026-04-04 19:03:49 +08:00

208 lines
7.0 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 拉取器
* <p>
* 使用 git CLIProcessBuilder进行 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 refbranch/tagnull 时使用默认分支
* @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<String, String> references = collectFiles(skillRoot.resolve("references"));
Map<String, String> 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<String>();
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<String, String> collectFiles(Path dir) throws IOException {
Map<String, String> 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());
}
}
}