package vip.mate.skill.installer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import vip.mate.skill.installer.model.*;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceEvent;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import org.springframework.context.ApplicationEventPublisher;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Skill 安装核心服务
*
* 管理从外部源(GitHub / ClawHub)安装 skill 的完整流程:
* URL 解析 → bundle 获取 → workspace 落盘 → 数据库注册 → 运行时刷新。
*
* 支持异步安装(task_id 轮询模式),参考 MateClaw 实现。
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillInstaller {
private final BundleResolver bundleResolver;
private final SkillHubClient skillHubClient;
private final SkillWorkspaceManager workspaceManager;
private final SkillService skillService;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
/** 安装任务追踪 */
private final ConcurrentHashMap tasks = new ConcurrentHashMap<>();
/**
* 启动异步安装任务
*/
public InstallTask startInstall(InstallRequest request) {
String taskId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
InstallTask task = InstallTask.create(taskId, request.getBundleUrl());
tasks.put(taskId, task);
doInstallAsync(taskId, request);
return task;
}
/**
* 获取安装任务状态
*/
public InstallTask getTaskStatus(String taskId) {
return tasks.get(taskId);
}
/**
* 取消安装任务
*/
public void cancelTask(String taskId) {
InstallTask task = tasks.get(taskId);
if (task != null && task.getStatus() == InstallTask.InstallStatus.INSTALLING) {
task.setCancelRequested(true);
task.markCancelled();
log.info("Install task {} cancelled", taskId);
}
}
/**
* 卸载 skill(归档 workspace + 删除数据库记录)
*/
public void uninstall(String skillName) {
// 先在数据库中查找
List skills = skillService.listSkills();
SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName))
.findFirst()
.orElse(null);
if (target != null) {
skillService.deleteSkill(target.getId());
}
// workspace 归档已在 SkillService.deleteSkill 中处理
log.info("Uninstalled skill: {}", skillName);
}
/**
* 搜索 ClawHub 市场(委托给 SkillHubClient)
*/
public List searchHub(String query, int limit) {
try {
return skillHubClient.search(query, limit);
} catch (Exception e) {
log.warn("Hub search failed: {}", e.getMessage());
return Collections.emptyList();
}
}
// ==================== 异步安装流程 ====================
@Async
public CompletableFuture doInstallAsync(String taskId, InstallRequest request) {
InstallTask task = tasks.get(taskId);
if (task == null) return CompletableFuture.completedFuture(null);
task.markInstalling();
try {
// 1. 解析 bundle
SkillBundle bundle = bundleResolver.resolve(request.getBundleUrl(), request.getVersion());
if (bundle == null) {
task.markFailed("Failed to resolve skill bundle from: " + request.getBundleUrl());
return CompletableFuture.completedFuture(null);
}
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 2. 确定 skill 名称
String skillName = request.getTargetName() != null ? request.getTargetName() : bundle.name();
if (skillName == null || skillName.isBlank()) {
task.markFailed("Cannot determine skill name from bundle");
return CompletableFuture.completedFuture(null);
}
// 3. 检查是否已存在
boolean exists = skillService.listSkills().stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !Boolean.TRUE.equals(request.getOverwrite())) {
task.markFailed("Skill '" + skillName + "' already exists. Set overwrite=true to replace.");
return CompletableFuture.completedFuture(null);
}
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 4. 写入 workspace 目录
// overwrite 时先清理旧 references/ 和 scripts/,防止残留过期文件
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
workspaceManager.initWorkspace(skillName, bundle.content());
// 写入 references/
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "references/" + entry.getKey(), entry.getValue());
}
}
// 写入 scripts/
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
workspaceManager.writeWorkspaceFile(skillName, "scripts/" + entry.getKey(), entry.getValue());
}
}
// cancel check: 文件已落盘,但数据库尚未写入 —— 归档已写入的目录后退出
if (task.isCancelRequested()) {
workspaceManager.archiveWorkspace(skillName);
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 5. 注册/更新数据库
SkillEntity skillEntity;
if (exists) {
// 更新已有记录
skillEntity = skillService.listSkills().stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
skillEntity.setDescription(bundle.description());
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setConfigJson(buildConfigJson(bundle));
if (Boolean.TRUE.equals(request.getEnable())) {
skillEntity.setEnabled(true);
}
skillService.updateSkill(skillEntity);
} else {
// 创建新记录
skillEntity = new SkillEntity();
skillEntity.setName(skillName);
skillEntity.setDescription(bundle.description());
skillEntity.setSkillType("dynamic");
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(Boolean.TRUE.equals(request.getEnable()));
skillService.createSkill(skillEntity);
}
// cancel check: DB 已写入,此时取消不再回滚数据库,但标记任务为 cancelled
if (task.isCancelRequested()) {
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
// 6. 发布事件
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
// 7. 完成
task.markCompleted(InstallResult.builder()
.name(skillName)
.enabled(Boolean.TRUE.equals(request.getEnable()))
.sourceUrl(bundle.sourceUrl())
.sourceType(bundle.sourceType())
.build());
log.info("Skill '{}' installed successfully from {}", skillName, bundle.sourceUrl());
} catch (Exception e) {
log.error("Install task {} failed: {}", taskId, e.getMessage(), e);
task.markFailed(e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
/**
* 同步安装 SkillBundle(用于 ZIP 上传等本地解析场景,无需异步任务)
*
* @return 安装结果 Map(skillId, name, version, filesCount)
*/
public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) {
String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name();
if (skillName == null || skillName.isBlank()) {
throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle");
}
boolean exists = skillService.listSkills().stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !overwrite) {
throw new vip.mate.exception.MateClawException("err.skill.name_exists",
"Skill '" + skillName + "' already exists. Enable overwrite to replace.");
}
// 写入 workspace
if (exists) {
workspaceManager.cleanWorkspaceDataDirs(skillName);
}
workspaceManager.initWorkspace(skillName, bundle.content());
if (bundle.references() != null) {
for (var entry : bundle.references().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("references/")) key = "references/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
}
if (bundle.scripts() != null) {
for (var entry : bundle.scripts().entrySet()) {
String key = entry.getKey();
if (!key.startsWith("scripts/")) key = "scripts/" + key;
workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue());
}
}
// 注册/更新 DB
SkillEntity skillEntity;
if (exists) {
skillEntity = skillService.listSkills().stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
skillEntity.setDescription(bundle.description());
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setConfigJson(buildConfigJson(bundle));
if (enable) skillEntity.setEnabled(true);
skillService.updateSkill(skillEntity);
} else {
skillEntity = new SkillEntity();
skillEntity.setName(skillName);
skillEntity.setDescription(bundle.description());
skillEntity.setSkillType("dynamic");
skillEntity.setVersion(bundle.version());
skillEntity.setAuthor(bundle.author());
skillEntity.setIcon(bundle.icon());
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(enable);
skillService.createSkill(skillEntity);
}
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
int filesCount = (bundle.references() != null ? bundle.references().size() : 0)
+ (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1;
log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount);
return Map.of(
"skillId", skillEntity.getId(),
"name", skillName,
"version", bundle.version() != null ? bundle.version() : "",
"filesCount", filesCount
);
}
// ==================== 工具方法 ====================
private String buildConfigJson(SkillBundle bundle) {
try {
Map config = new LinkedHashMap<>();
config.put("upstream", bundle.sourceType());
config.put("entryFile", "SKILL.md");
Map source = new LinkedHashMap<>();
source.put("type", bundle.sourceType());
source.put("url", bundle.sourceUrl());
source.put("installedAt", LocalDateTime.now().toString());
source.put("installedVersion", bundle.version());
config.put("source", source);
return objectMapper.writeValueAsString(config);
} catch (Exception e) {
return "{\"upstream\":\"" + bundle.sourceType() + "\"}";
}
}
}