using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; public static class SkillService { private static List _skills = new List(); private static string _lastFolder = ""; private static readonly Dictionary ToolNameMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Bash"] = "process", ["bash"] = "process", ["Read"] = "file_read", ["Write"] = "file_write", ["Edit"] = "file_edit", ["Glob"] = "glob", ["Grep"] = "grep_tool", ["WebSearch"] = "http_tool", ["WebFetch"] = "http_tool", ["execute_command"] = "process", ["read_file"] = "file_read", ["write_file"] = "file_write", ["edit_file"] = "file_edit", ["search_files"] = "glob", ["search_content"] = "grep_tool", ["list_files"] = "folder_map", ["shell"] = "process", ["terminal"] = "process", ["cat"] = "file_read", ["find"] = "glob", ["rg"] = "grep_tool", ["git"] = "git_tool" }; public static IReadOnlyList Skills => _skills; public static void LoadSkills(string? customFolder = null) { List list = new List(); string text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills"); if (Directory.Exists(text)) { list.Add(text); } string text2 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (Directory.Exists(text2)) { list.Add(text2); } if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder)) { list.Add(customFolder); } List list2 = new List(); HashSet hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string item in list) { string[] files = Directory.GetFiles(item, "*.skill.md"); foreach (string text3 in files) { try { SkillDefinition skillDefinition = ParseSkillFile(text3); if (skillDefinition != null && hashSet.Add(skillDefinition.Name)) { list2.Add(skillDefinition); } } catch (Exception ex) { LogService.Warn("스킬 로드 실패 [" + text3 + "]: " + ex.Message); } } try { string[] directories = Directory.GetDirectories(item); foreach (string path in directories) { string text4 = Path.Combine(path, "SKILL.md"); if (!File.Exists(text4)) { continue; } try { SkillDefinition skillDefinition2 = ParseSkillFile(text4); if (skillDefinition2 != null && hashSet.Add(skillDefinition2.Name)) { list2.Add(skillDefinition2); } } catch (Exception ex2) { LogService.Warn("스킬 로드 실패 [" + text4 + "]: " + ex2.Message); } } } catch { } } foreach (SkillDefinition item2 in list2) { if (!string.IsNullOrEmpty(item2.Requires)) { IEnumerable source = from r in item2.Requires.Split(',') select r.Trim(); item2.IsAvailable = source.All((string r) => RuntimeDetector.IsAvailable(r)); } } _skills = list2; _lastFolder = customFolder ?? ""; int num = list2.Count((SkillDefinition s) => !s.IsAvailable); LogService.Info($"스킬 {list2.Count}개 로드 완료" + ((num > 0) ? $" (런타임 미충족 {num}개)" : "")); } public static SkillDefinition? Find(string name) { return _skills.FirstOrDefault((SkillDefinition s) => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } public static List MatchSlashCommand(string input) { if (!input.StartsWith('/')) { return new List(); } return _skills.Where((SkillDefinition s) => ("/" + s.Name).StartsWith(input, StringComparison.OrdinalIgnoreCase)).ToList(); } public static void EnsureSkillFolder() { string text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (!Directory.Exists(text)) { Directory.CreateDirectory(text); } CreateExampleSkill(text, "daily-standup.skill.md", "daily-standup", "데일리 스탠드업", "작업 폴더의 최근 변경사항을 요약하여 데일리 스탠드업 보고서를 생성합니다.", "작업 폴더의 Git 상태와 최근 커밋을 분석하여 데일리 스탠드업 보고서를 작성하세요.\n\n다음 도구를 사용하세요:\n1. git_tool (action: log, args: \"--oneline -10\") — 최근 커밋 확인\n2. git_tool (action: status) — 현재 변경사항 확인\n3. git_tool (action: diff, args: \"--stat\") — 변경 파일 통계\n\n보고서 형식:\n## \ud83d\udccb 데일리 스탠드업 보고서\n\n### ✅ 완료한 작업\n- 최근 커밋 기반으로 정리\n\n### \ud83d\udd04 진행 중인 작업\n- 현재 수정 중인 파일 기반\n\n### ⚠\ufe0f 블로커/이슈\n- TODO/FIXME가 있으면 표시\n\n한국어로 작성하세요."); CreateExampleSkill(text, "bug-hunt.skill.md", "bug-hunt", "버그 탐색", "작업 폴더에서 잠재적 버그 패턴을 검색합니다.", "작업 폴더의 코드에서 잠재적 버그 패턴을 찾아 보고하세요.\n\n다음 도구를 사용하세요:\n1. grep_tool — 위험 패턴 검색:\n - 빈 catch 블록: catch\\s*\\{\\s*\\}\n - TODO/FIXME: (TODO|FIXME|HACK|XXX)\n - .Result/.Wait(): \\.(Result|Wait\\(\\))\n - 하드코딩된 자격증명: (password|secret|apikey)\\s*=\\s*\"\n2. code_review (action: diff_review, focus: bugs) — 최근 변경사항 버그 검사\n\n결과를 심각도별로 분류하여 보고하세요:\n- \ud83d\udd34 CRITICAL: 즉시 수정 필요\n- \ud83d\udfe1 WARNING: 검토 필요\n- \ud83d\udd35 INFO: 개선 권장\n\n한국어로 작성하세요."); CreateExampleSkill(text, "code-explain.skill.md", "code-explain", "코드 설명", "지정한 파일의 코드를 상세히 설명합니다.", "사용자가 지정한 파일 또는 작업 폴더의 주요 파일을 읽고 상세히 설명하세요.\n\n다음 도구를 사용하세요:\n1. file_read — 파일 내용 읽기\n2. folder_map — 프로젝트 구조 파악 (필요시)\n\n설명 포함 사항:\n- 파일의 역할과 책임\n- 주요 클래스/함수의 목적\n- 데이터 흐름\n- 외부 의존성\n- 개선 포인트 (있다면)\n\n한국어로 쉽게 설명하세요. 코드 블록을 활용하여 핵심 부분을 인용하세요."); } public static string? ExportSkill(SkillDefinition skill, string outputDir) { try { if (!File.Exists(skill.FilePath)) { LogService.Warn("스킬 내보내기 실패: 파일 없음 — " + skill.FilePath); return null; } string path = skill.Name + ".skill.zip"; string text = Path.Combine(outputDir, path); if (File.Exists(text)) { File.Delete(text); } using ZipArchive destination = ZipFile.Open(text, ZipArchiveMode.Create); if (skill.IsStandardFormat) { string directoryName = Path.GetDirectoryName(skill.FilePath); if (directoryName != null && Directory.Exists(directoryName)) { string fileName = Path.GetFileName(directoryName); foreach (string item in Directory.EnumerateFiles(directoryName, "*", SearchOption.AllDirectories)) { bool flag; switch (Path.GetExtension(item).ToLowerInvariant()) { case ".exe": case ".dll": case ".bat": case ".cmd": case ".ps1": case ".sh": flag = true; break; default: flag = false; break; } if (!flag) { string entryName = fileName + "/" + Path.GetRelativePath(directoryName, item).Replace('\\', '/'); destination.CreateEntryFromFile(item, entryName, CompressionLevel.Optimal); } } } } else { string entryName2 = skill.Name + "/" + Path.GetFileName(skill.FilePath); destination.CreateEntryFromFile(skill.FilePath, entryName2, CompressionLevel.Optimal); } LogService.Info("스킬 내보내기 완료: " + text); return text; } catch (Exception ex) { LogService.Warn("스킬 내보내기 실패: " + ex.Message); return null; } } public static int ImportSkills(string zipPath) { try { if (!File.Exists(zipPath)) { LogService.Warn("스킬 가져오기 실패: 파일 없음 — " + zipPath); return 0; } string text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"); if (!Directory.Exists(text)) { Directory.CreateDirectory(text); } using ZipArchive zipArchive = ZipFile.OpenRead(zipPath); HashSet hashSet = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" }; foreach (ZipArchiveEntry entry in zipArchive.Entries) { if (hashSet.Contains(Path.GetExtension(entry.Name))) { LogService.Warn("스킬 가져오기 차단: 실행 가능 파일 포함 — " + entry.FullName); return 0; } } List list = zipArchive.Entries.Where((ZipArchiveEntry e) => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) || e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)).ToList(); if (list.Count == 0) { LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음"); return 0; } int num = 0; foreach (ZipArchiveEntry entry2 in zipArchive.Entries) { if (string.IsNullOrEmpty(entry2.Name)) { continue; } string text2 = entry2.FullName.Replace('/', Path.DirectorySeparatorChar); if (!text2.Contains("..")) { string text3 = Path.Combine(text, text2); string directoryName = Path.GetDirectoryName(text3); if (directoryName != null && !Directory.Exists(directoryName)) { Directory.CreateDirectory(directoryName); } entry2.ExtractToFile(text3, overwrite: true); if (entry2.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) || entry2.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) { num++; } } } if (num > 0) { LogService.Info($"스킬 가져오기 완료: {num}개 스킬 ({zipPath})"); LoadSkills(); } return num; } catch (Exception ex) { LogService.Warn("스킬 가져오기 실패: " + ex.Message); return 0; } } public static string MapToolNames(string skillBody) { if (string.IsNullOrEmpty(skillBody)) { return skillBody; } foreach (KeyValuePair item in ToolNameMap) { skillBody = skillBody.Replace("`" + item.Key + "`", "`" + item.Value + "`"); skillBody = skillBody.Replace("(" + item.Key + ")", "(" + item.Value + ")"); } return skillBody; } private static void CreateExampleSkill(string folder, string fileName, string name, string label, string description, string body) { string path = Path.Combine(folder, fileName); if (!File.Exists(path)) { string text = $"---\nname: {name}\nlabel: {label}\ndescription: {description}\nicon: \\uE768\n---\n\n{body.Trim()}"; string[] value = (from l in text.Split('\n') select l.TrimStart()).ToArray(); File.WriteAllText(path, string.Join('\n', value), Encoding.UTF8); } } private static SkillDefinition? ParseSkillFile(string filePath) { string text = File.ReadAllText(filePath, Encoding.UTF8); if (!text.TrimStart().StartsWith("---")) { return null; } int num = text.IndexOf("---", StringComparison.Ordinal); int num2 = text.IndexOf("---", num + 3, StringComparison.Ordinal); if (num2 < 0) { return null; } int num3 = num + 3; string text2 = text.Substring(num3, num2 - num3).Trim(); string text3 = text; num3 = num2 + 3; string skillBody = text3.Substring(num3, text3.Length - num3).Trim(); Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); string text4 = null; string[] array = text2.Split('\n'); foreach (string text5 in array) { if (text4 != null && (text5.StartsWith(" ") || text5.StartsWith("\t"))) { string text6 = text5.TrimStart(); int num4 = text6.IndexOf(':'); if (num4 > 0) { string text7 = text6.Substring(0, num4).Trim(); text3 = text6; num3 = num4 + 1; string value = text3.Substring(num3, text3.Length - num3).Trim().Trim('"', '\''); dictionary[text4 + "." + text7] = value; } continue; } text4 = null; int num5 = text5.IndexOf(':'); if (num5 > 0) { string text8 = text5.Substring(0, num5).Trim(); text3 = text5; num3 = num5 + 1; string value2 = text3.Substring(num3, text3.Length - num3).Trim().Trim('"', '\''); if (string.IsNullOrEmpty(value2)) { text4 = text8; } else { dictionary[text8] = value2; } } } string fileName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? ""); string text9 = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", ""); string defaultValue = (filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? fileName : text9); string valueOrDefault = dictionary.GetValueOrDefault("name", defaultValue); if (string.IsNullOrEmpty(valueOrDefault)) { return null; } string text10 = dictionary.GetValueOrDefault("label", "") ?? ""; string value3 = dictionary.GetValueOrDefault("icon", "") ?? ""; if (string.IsNullOrEmpty(text10) && dictionary.TryGetValue("metadata.label", out var value4)) { text10 = value4 ?? ""; } if (string.IsNullOrEmpty(value3) && dictionary.TryGetValue("metadata.icon", out var value5)) { value3 = value5 ?? ""; } return new SkillDefinition { Id = valueOrDefault, Name = valueOrDefault, Label = (string.IsNullOrEmpty(text10) ? valueOrDefault : text10), Description = (dictionary.GetValueOrDefault("description", "") ?? ""), Icon = (string.IsNullOrEmpty(value3) ? "\ue768" : ConvertUnicodeEscape(value3)), SystemPrompt = MapToolNames(skillBody), FilePath = filePath, License = (dictionary.GetValueOrDefault("license", "") ?? ""), Compatibility = (dictionary.GetValueOrDefault("compatibility", "") ?? ""), AllowedTools = (dictionary.GetValueOrDefault("allowed-tools", "") ?? ""), Requires = (dictionary.GetValueOrDefault("requires", "") ?? ""), Tabs = (dictionary.GetValueOrDefault("tabs", "all") ?? "all") }; } private static string ConvertUnicodeEscape(string value) { if (string.IsNullOrEmpty(value)) { return value; } return Regex.Replace(value, "\\\\u([0-9a-fA-F]{4})", (Match m) => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); } }