Files

405 lines
14 KiB
C#

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<SkillDefinition> _skills = new List<SkillDefinition>();
private static string _lastFolder = "";
private static readonly Dictionary<string, string> ToolNameMap = new Dictionary<string, string>(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<SkillDefinition> Skills => _skills;
public static void LoadSkills(string? customFolder = null)
{
List<string> list = new List<string>();
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<SkillDefinition> list2 = new List<SkillDefinition>();
HashSet<string> hashSet = new HashSet<string>(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<string> 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<SkillDefinition> MatchSlashCommand(string input)
{
if (!input.StartsWith('/'))
{
return new List<SkillDefinition>();
}
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<string> hashSet = new HashSet<string>(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<ZipArchiveEntry> 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<string, string> 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<string, string> dictionary = new Dictionary<string, string>(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());
}
}