package vip.mate.skill.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 技能包解析器
* 将 SkillEntity 解析为 ResolvedSkill(运行时可用的技能包)
*
* 三级解析流程:
*
* - 显式 skillDir(configJson.skillDir)→ source="directory"
* - 约定路径({workspace-root}/{skillName}/)→ source="convention"
* - 数据库 skillContent → source="database"
*
* 解析后依次执行:安全扫描 → 依赖检查
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SkillPackageResolver {
private final SkillFrontmatterParser frontmatterParser;
private final SkillDirectoryScanner directoryScanner;
private final SkillSecurityService securityService;
private final SkillDependencyChecker dependencyChecker;
private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager;
/**
* 解析技能实体为运行时技能包(完整流程)
*/
public ResolvedSkill resolve(SkillEntity entity) {
String configuredDir = extractSkillDirString(entity);
Path skillDir = configuredDir != null ? Paths.get(configuredDir) : null;
ResolvedSkill resolved;
// 三级解析:explicit → convention → database
if (skillDir != null && Files.exists(skillDir) && Files.isDirectory(skillDir)) {
// 1. 显式配置的 skillDir
resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory");
} else {
// 2. 约定路径 {workspace-root}/{skillName}/
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName());
if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) {
resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention");
} else {
// 3. 数据库 skillContent
resolved = resolveFromDatabase(entity, configuredDir);
}
}
// 2. 安全扫描
applySecurity(resolved);
// 3. 依赖检查
applyDependencyCheck(resolved);
// 4. 综合判定 runtimeAvailable
resolveRuntimeAvailability(resolved);
return resolved;
}
// ==================== 阶段 1:内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
Path skillMd = skillDir.resolve("SKILL.md");
String content = "";
String description = entity.getDescription();
if (Files.exists(skillMd)) {
try {
content = Files.readString(skillMd);
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
}
Map references = directoryScanner.buildDirectoryTree(skillDir.resolve("references"));
Map scripts = directoryScanner.buildDirectoryTree(skillDir.resolve("scripts"));
return ResolvedSkill.builder()
.name(entity.getName())
.description(description)
.content(content)
.source(source)
.skillDir(skillDir)
.configuredSkillDir(configuredDir)
.runtimeAvailable(true) // 暂定,后续安全/依赖检查可能改写
.resolutionError(null)
.references(references)
.scripts(scripts)
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.build();
}
private ResolvedSkill resolveFromDatabase(SkillEntity entity, String configuredDir) {
String content = entity.getSkillContent() != null ? entity.getSkillContent() : "";
String description = entity.getDescription();
if (!content.isBlank()) {
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
}
boolean hasContent = !content.isBlank();
String error = null;
if (configuredDir != null) {
error = "Configured skillDir not found: " + configuredDir;
if (hasContent) {
error += " (fallback to database skillContent)";
}
} else if (!hasContent) {
error = "No skillDir configured and no skillContent available";
}
return ResolvedSkill.builder()
.name(entity.getName())
.description(description)
.content(content)
.source("database")
.skillDir(null)
.configuredSkillDir(configuredDir)
.runtimeAvailable(hasContent || (description != null && !description.isBlank()))
.resolutionError(error)
.references(Map.of())
.scripts(Map.of())
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.build();
}
// ==================== 阶段 2:安全扫描 ====================
private void applySecurity(ResolvedSkill resolved) {
try {
SkillValidationResult result = securityService.validate(resolved);
boolean trustedBuiltin = resolved.isBuiltin() && result.isBlocked();
resolved.setSecurityBlocked(result.isBlocked() && !trustedBuiltin);
resolved.setSecuritySeverity(result.getMaxSeverity() != null ? result.getMaxSeverity().name() : null);
if (trustedBuiltin) {
resolved.setSecuritySummary("Builtin skill trusted: " + result.getSummary());
} else {
resolved.setSecuritySummary(result.getSummary());
}
resolved.setSecurityWarnings(result.getWarnings());
// 转换 findings 为 JSON 友好格式
if (result.getFindings() != null && !result.getFindings().isEmpty()) {
List secFindings = result.getFindings().stream()
.map(f -> ResolvedSkill.SecurityFinding.builder()
.ruleId(f.getRuleId())
.severity(f.getSeverity() != null ? f.getSeverity().name() : null)
.category(f.getCategory())
.title(f.getTitle())
.description(f.getDescription())
.filePath(f.getFilePath())
.lineNumber(f.getLineNumber())
.snippet(f.getSnippet())
.remediation(f.getRemediation())
.build())
.collect(Collectors.toList());
resolved.setSecurityFindings(secFindings);
}
if (trustedBuiltin) {
log.warn("Builtin skill '{}' bypassed security block: {}", resolved.getName(), result.getSummary());
} else if (result.isBlocked()) {
log.warn("Skill '{}' blocked by security scan: {}", resolved.getName(), result.getSummary());
} else if (result.getFindings() != null && !result.getFindings().isEmpty()) {
log.info("Skill '{}' security scan: {} finding(s)", resolved.getName(), result.getFindings().size());
}
} catch (Exception e) {
log.error("Security scan failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setSecurityWarnings(List.of("Security scan error: " + e.getMessage()));
}
}
// ==================== 阶段 3:依赖检查 ====================
private void applyDependencyCheck(ResolvedSkill resolved) {
try {
// 解析 frontmatter 获取依赖声明
String content = resolved.getContent();
if (content == null || content.isBlank()) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies();
List platforms = parsed.getPlatforms();
if ((deps == null || deps.isEmpty()) && (platforms == null || platforms.isEmpty())) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillDependencyChecker.DependencyCheckResult result =
dependencyChecker.check(deps, platforms, resolved.getName());
resolved.setDependencyReady(result.isSatisfied());
resolved.setMissingDependencies(result.getMissing());
resolved.setDependencySummary(result.getSummary());
if (!result.isSatisfied()) {
log.info("Skill '{}' dependencies not satisfied: {}", resolved.getName(), result.getSummary());
}
} catch (Exception e) {
log.error("Dependency check failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setDependencyReady(true); // 检查失败不阻断
resolved.setDependencySummary("Dependency check error: " + e.getMessage());
}
}
// ==================== 阶段 4:综合判定 ====================
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
// 安全阻断 → 不可用
if (resolved.isSecurityBlocked()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Security blocked: " + resolved.getSecuritySummary());
}
return;
}
// 依赖不满足 → 不可用
if (!resolved.isDependencyReady()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Dependencies missing: " + resolved.getDependencySummary());
}
}
// 其他情况保留原有 runtimeAvailable 判定
}
// ==================== 工具方法 ====================
private String extractSkillDirString(SkillEntity entity) {
String configJson = entity.getConfigJson();
if (configJson == null || configJson.isBlank()) {
return null;
}
try {
@SuppressWarnings("unchecked")
Map config = objectMapper.readValue(configJson, Map.class);
String pathStr = null;
if (config.containsKey("skillDir")) {
pathStr = config.get("skillDir").toString();
} else if (config.containsKey("path")) {
pathStr = config.get("path").toString();
} else if (config.containsKey("directory")) {
pathStr = config.get("directory").toString();
}
if (pathStr != null && !pathStr.isBlank()) {
return pathStr;
}
} catch (Exception e) {
log.debug("Failed to parse configJson for skill {}: {}", entity.getName(), e.getMessage());
}
return null;
}
}