using System.IO; using System.Text; using AxCopilot.Services; namespace AxCopilot.Services.Agent; /// /// 마크다운 기반 스킬 정의를 로드/관리하는 서비스. /// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다. /// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다. /// public static partial class SkillService { private static List _skills = new(); private static string _lastFolder = ""; /// 로드된 스킬 목록. public static IReadOnlyList Skills => _skills; /// 스킬 폴더에서 *.skill.md 파일을 로드합니다. public static void LoadSkills(string? customFolder = null) { var folders = new List(); // 1) 앱 기본 스킬 폴더 var defaultFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills"); if (Directory.Exists(defaultFolder)) folders.Add(defaultFolder); // 2) 사용자 스킬 폴더 (%APPDATA%\AxCopilot\skills\) var appDataFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (Directory.Exists(appDataFolder)) folders.Add(appDataFolder); // 3) 사용자 지정 폴더 if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder)) folders.Add(customFolder); var allSkills = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var folder in folders) { // 1) 기존 형식: *.skill.md 파일 foreach (var file in Directory.GetFiles(folder, "*.skill.md")) { try { var skill = ParseSkillFile(file); if (skill != null && seen.Add(skill.Name)) allSkills.Add(skill); } catch (Exception ex) { LogService.Warn($"스킬 로드 실패 [{file}]: {ex.Message}"); } } // 2) SKILL.md 표준: 하위폴더/SKILL.md 구조 (재귀 + 콜론 네임스페이싱) // Phase 24: database/migrate/SKILL.md → "database:migrate" try { LoadSkillsRecursive(folder, folder, allSkills, seen); } catch (Exception) { /* 폴더 접근 오류 무시 */ } } // 런타임 의존성 검증 foreach (var skill in allSkills) { if (!string.IsNullOrEmpty(skill.Requires)) { var runtimes = skill.Requires.Split(',').Select(r => r.Trim()); skill.IsAvailable = runtimes.All(r => RuntimeDetector.IsAvailable(r)); } } _skills = allSkills; _lastFolder = customFolder ?? ""; var unavailCount = allSkills.Count(s => !s.IsAvailable); LogService.Info($"스킬 {allSkills.Count}개 로드 완료" + (unavailCount > 0 ? $" (런타임 미충족 {unavailCount}개)" : "")); } /// 스킬 이름으로 검색합니다. public static SkillDefinition? Find(string name) => _skills.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); /// /// Phase 19-D-EXT: 스킬 본문에 인수 치환 + 인라인 명령 처리를 적용합니다. /// 호출 시점: 스킬이 로드된 후, LLM 프롬프트로 전달하기 직전. /// /// 실행할 스킬. /// 사용자가 전달한 인수 (예: /skill arg1 arg2). /// 셸 명령 실행 작업 디렉토리. /// 취소 토큰. /// 인수 치환 + 인라인 명령이 처리된 프롬프트. public static async Task PrepareSkillBodyAsync( SkillDefinition skill, string arguments, string workFolder, CancellationToken ct) { var body = skill.SystemPrompt; // 1) 인수 치환 ($ARGUMENTS, $name, {0}) var namedArgs = skill.Arguments.Count > 0 ? skill.Arguments : null; body = SkillArgumentSubstitution.Substitute(body, namedArgs, arguments); // 2) 인라인 셸 커맨드 (!`command`) body = await SkillInlineCommandProcessor.ProcessAsync(body, workFolder, ct); return body; } /// 슬래시 명령어 매칭용: /로 시작하는 텍스트에 매칭되는 스킬 목록. public static List MatchSlashCommand(string input) { if (!input.StartsWith('/')) return new(); return _skills .Where(s => s.UserInvocable) // Phase 24: user-invocable: false 스킬 제외 .Where(s => ("/" + s.Name).StartsWith(input, StringComparison.OrdinalIgnoreCase)) .ToList(); } /// 스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다. public static void EnsureSkillFolder() { var folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); // 예제 스킬이 없으면 생성 CreateExampleSkill(folder, "daily-standup.skill.md", "daily-standup", "데일리 스탠드업", "작업 폴더의 최근 변경사항을 요약하여 데일리 스탠드업 보고서를 생성합니다.", """ 작업 폴더의 Git 상태와 최근 커밋을 분석하여 데일리 스탠드업 보고서를 작성하세요. 다음 도구를 사용하세요: 1. git_tool (action: log, args: "--oneline -10") — 최근 커밋 확인 2. git_tool (action: status) — 현재 변경사항 확인 3. git_tool (action: diff, args: "--stat") — 변경 파일 통계 보고서 형식: ## 📋 데일리 스탠드업 보고서 ### ✅ 완료한 작업 - 최근 커밋 기반으로 정리 ### 🔄 진행 중인 작업 - 현재 수정 중인 파일 기반 ### ⚠️ 블로커/이슈 - TODO/FIXME가 있으면 표시 한국어로 작성하세요. """); CreateExampleSkill(folder, "bug-hunt.skill.md", "bug-hunt", "버그 탐색", "작업 폴더에서 잠재적 버그 패턴을 검색합니다.", """ 작업 폴더의 코드에서 잠재적 버그 패턴을 찾아 보고하세요. 다음 도구를 사용하세요: 1. grep_tool — 위험 패턴 검색: - 빈 catch 블록: catch\s*\{\s*\} - TODO/FIXME: (TODO|FIXME|HACK|XXX) - .Result/.Wait(): \.(Result|Wait\(\)) - 하드코딩된 자격증명: (password|secret|apikey)\s*=\s*" 2. code_review (action: diff_review, focus: bugs) — 최근 변경사항 버그 검사 결과를 심각도별로 분류하여 보고하세요: - 🔴 CRITICAL: 즉시 수정 필요 - 🟡 WARNING: 검토 필요 - 🔵 INFO: 개선 권장 한국어로 작성하세요. """); CreateExampleSkill(folder, "code-explain.skill.md", "code-explain", "코드 설명", "지정한 파일의 코드를 상세히 설명합니다.", """ 사용자가 지정한 파일 또는 작업 폴더의 주요 파일을 읽고 상세히 설명하세요. 다음 도구를 사용하세요: 1. file_read — 파일 내용 읽기 2. folder_map — 프로젝트 구조 파악 (필요시) 설명 포함 사항: - 파일의 역할과 책임 - 주요 클래스/함수의 목적 - 데이터 흐름 - 외부 의존성 - 개선 포인트 (있다면) 한국어로 쉽게 설명하세요. 코드 블록을 활용하여 핵심 부분을 인용하세요. """); } // ─── 내부 메서드 ───────────────────────────────────────────────────────── /// /// Phase 24: 하위 디렉토리를 재귀 탐색하여 SKILL.md를 찾고, /// 경로를 콜론(:)으로 구분된 네임스페이스 이름으로 변환합니다. /// 예: skills/database/migrate/SKILL.md → "database:migrate" /// private static void LoadSkillsRecursive( string rootFolder, string currentFolder, List allSkills, HashSet seen) { foreach (var subDir in Directory.GetDirectories(currentFolder)) { var skillMd = Path.Combine(subDir, "SKILL.md"); if (File.Exists(skillMd)) { try { // 네임스페이스: rootFolder로부터의 상대경로를 콜론으로 치환 var relativePath = Path.GetRelativePath(rootFolder, subDir).Replace('\\', '/'); var namespacedName = relativePath.Replace('/', ':'); var skill = ParseSkillFile(skillMd, namespacedName); if (skill != null && seen.Add(skill.Name)) allSkills.Add(skill); } catch (Exception ex) { LogService.Warn($"스킬 로드 ��패 [{skillMd}]: {ex.Message}"); } } else { // SKILL.md가 없는 중간 디렉토리 → 더 깊이 ���색 LoadSkillsRecursive(rootFolder, subDir, allSkills, seen); } } } private static void CreateExampleSkill(string folder, string fileName, string name, string label, string description, string body) { var path = Path.Combine(folder, fileName); if (File.Exists(path)) return; var content = $""" --- name: {name} label: {label} description: {description} icon: \uE768 --- {body.Trim()} """; // 들여쓰기 정리 (raw string literal의 인덴트 제거) var lines = content.Split('\n').Select(l => l.TrimStart()).ToArray(); File.WriteAllText(path, string.Join('\n', lines), Encoding.UTF8); } /// *.skill.md 파일을 파싱합니다. private static SkillDefinition? ParseSkillFile(string filePath) => ParseSkillFile(filePath, null); /// *.skill.md 파일을 파싱합니다. namespacedName이 주어지면 이름 오버라이드. private static SkillDefinition? ParseSkillFile(string filePath, string? namespacedName) { var content = File.ReadAllText(filePath, Encoding.UTF8); if (!content.TrimStart().StartsWith("---")) return null; // 프론트매터 추출 var firstSep = content.IndexOf("---", StringComparison.Ordinal); var secondSep = content.IndexOf("---", firstSep + 3, StringComparison.Ordinal); if (secondSep < 0) return null; var frontmatter = content[(firstSep + 3)..secondSep].Trim(); var body = content[(secondSep + 3)..].Trim(); // 키-값 파싱 (YAML 1단계 + metadata 맵 지원) var meta = new Dictionary(StringComparer.OrdinalIgnoreCase); string? currentMap = null; foreach (var line in frontmatter.Split('\n')) { // 들여쓰기된 줄 → 현재 맵의 하위 키 if (currentMap != null && (line.StartsWith(" ") || line.StartsWith("\t"))) { var trimmed = line.TrimStart(); var colonIdx = trimmed.IndexOf(':'); if (colonIdx > 0) { var subKey = trimmed[..colonIdx].Trim(); var subVal = trimmed[(colonIdx + 1)..].Trim().Trim('"', '\''); meta[$"{currentMap}.{subKey}"] = subVal; } continue; } currentMap = null; var ci = line.IndexOf(':'); if (ci > 0) { var key = line[..ci].Trim(); var value = line[(ci + 1)..].Trim().Trim('"', '\''); if (string.IsNullOrEmpty(value)) { // 빈 값 = 맵 시작 (metadata:) currentMap = key; } else { meta[key] = value; } } } // 폴더명을 기본 이름으로 사용 (SKILL.md 표준: 폴더명 = name) var dirName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? ""); var fileName = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", ""); var fallbackName = filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? dirName : fileName; // Phase 24: 네임스페이스 이름 우선, 그 다음 프론트매터, 그 다음 폴더명 var name = namespacedName ?? meta.GetValueOrDefault("name", fallbackName); if (string.IsNullOrEmpty(name)) return null; // SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음 var label = meta.GetValueOrDefault("label", "") ?? ""; var icon = meta.GetValueOrDefault("icon", "") ?? ""; // metadata.label / metadata.icon 지원 (SKILL.md 표준) if (string.IsNullOrEmpty(label) && meta.TryGetValue("metadata.label", out var ml)) label = ml ?? ""; if (string.IsNullOrEmpty(icon) && meta.TryGetValue("metadata.icon", out var mi)) icon = mi ?? ""; // Phase 24: arguments 필드 파싱 (YAML 리스트 또는 쉼표 구분) var argsRaw = meta.GetValueOrDefault("arguments", "") ?? ""; var arguments = string.IsNullOrWhiteSpace(argsRaw) ? Array.Empty() : argsRaw.Trim('[', ']').Split(',').Select(a => a.Trim().Trim('"', '\'')).Where(a => a.Length > 0).ToArray(); // user-invocable: 기본 true, "false"면 false var userInvocable = !string.Equals( meta.GetValueOrDefault("user-invocable", "true"), "false", StringComparison.OrdinalIgnoreCase); return new SkillDefinition { Id = name, Name = name, Label = string.IsNullOrEmpty(label) ? name : label, Description = meta.GetValueOrDefault("description", "") ?? "", Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon), SystemPrompt = MapToolNames(body), FilePath = filePath, License = meta.GetValueOrDefault("license", "") ?? "", Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "", AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "", Requires = meta.GetValueOrDefault("requires", "") ?? "", Tabs = meta.GetValueOrDefault("tabs", "all") ?? "all", // Phase 24: 고급 프론트매터 필드 Context = meta.GetValueOrDefault("context", "") ?? "", ModelOverride = meta.GetValueOrDefault("model", "") ?? "", UserInvocable = userInvocable, Paths = meta.GetValueOrDefault("paths", "") ?? "", ScopedHooks = meta.GetValueOrDefault("hooks", "") ?? "", Arguments = arguments, WhenToUse = meta.GetValueOrDefault("when_to_use", meta.GetValueOrDefault("when-to-use", "") ?? "") ?? "", Version = meta.GetValueOrDefault("version", "") ?? "", }; } /// YAML의 \uXXXX 이스케이프를 실제 유니코드 문자로 변환합니다. private static string ConvertUnicodeEscape(string value) { if (string.IsNullOrEmpty(value)) return value; return System.Text.RegularExpressions.Regex.Replace( value, @"\\u([0-9a-fA-F]{4})", m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); } }