Files
AX-Copilot/src/AxCopilot/Services/Agent/SkillService.cs
lacvet 27bd8de83a [Phase47] 대형 파일 분할 리팩터링 3차 — 8개 신규 파셜 파일 생성
## 분할 대상 및 결과

### ChatWindow.ResponseHandling.cs (741줄 → 269줄)
- ChatWindow.StreamingUI.cs (303줄, 신규): CreateStreamingContainer, FinalizeStreamingContainer, ParseSuggestionChips, FormatTokenCount, EstimateTokenCount, StopGeneration
- ChatWindow.ConversationExport.cs (188줄, 신규): ForkConversation, OpenCommandPalette, ExecuteCommand, ExportConversation, ExportToHtml

### ChatWindow.PreviewAndFiles.cs (709줄 → ~340줄)
- ChatWindow.PreviewPopup.cs (~230줄, 신규): ShowPreviewTabContextMenu, OpenPreviewPopupWindow, _previewTabPopup 필드

### HelpDetailWindow.xaml.cs (673줄 → 254줄)
- HelpDetailWindow.Shortcuts.cs (168줄, 신규): BuildShortcutItems() 정적 메서드 (단축키 항목 160개+ 생성)
- HelpDetailWindow.Navigation.cs (266줄, 신규): 테마 프로퍼티, BuildTopMenu/SwitchTopMenu, BuildCategoryBar, NavigateToPage, 이벤트 핸들러
- partial class 전환: `public partial class HelpDetailWindow : Window`

### SkillService.cs (661줄 → 386줄)
- SkillService.Import.cs (203줄, 신규): ExportSkill, ImportSkills, MapToolNames — 가져오기/내보내기 섹션
- SkillDefinition.cs (81줄, 신규): SkillDefinition 클래스 독립 파일로 분리 (별도 최상위 클래스)
- partial class 전환: `public static partial class SkillService`

## NEXT_ROADMAP.md Phase 46 완료 항목 추가

## 빌드 결과: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:02:53 +09:00

387 lines
16 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.Text;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 마크다운 기반 스킬 정의를 로드/관리하는 서비스.
/// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다.
/// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다.
/// </summary>
public static partial class SkillService
{
private static List<SkillDefinition> _skills = new();
private static string _lastFolder = "";
/// <summary>로드된 스킬 목록.</summary>
public static IReadOnlyList<SkillDefinition> Skills => _skills;
/// <summary>스킬 폴더에서 *.skill.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null)
{
var folders = new List<string>();
// 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<SkillDefinition>();
var seen = new HashSet<string>(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}개)" : ""));
}
/// <summary>스킬 이름으로 검색합니다.</summary>
public static SkillDefinition? Find(string name) =>
_skills.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Phase 19-D-EXT: 스킬 본문에 인수 치환 + 인라인 명령 처리를 적용합니다.
/// 호출 시점: 스킬이 로드된 후, LLM 프롬프트로 전달하기 직전.
/// </summary>
/// <param name="skill">실행할 스킬.</param>
/// <param name="arguments">사용자가 전달한 인수 (예: /skill arg1 arg2).</param>
/// <param name="workFolder">셸 명령 실행 작업 디렉토리.</param>
/// <param name="ct">취소 토큰.</param>
/// <returns>인수 치환 + 인라인 명령이 처리된 프롬프트.</returns>
public static async Task<string> 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;
}
/// <summary>슬래시 명령어 매칭용: /로 시작하는 텍스트에 매칭되는 스킬 목록.</summary>
public static List<SkillDefinition> 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();
}
/// <summary>스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다.</summary>
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 ()
:
-
- /
-
-
- ()
. .
""");
}
// ─── 내부 메서드 ─────────────────────────────────────────────────────────
/// <summary>
/// Phase 24: 하위 디렉토리를 재귀 탐색하여 SKILL.md를 찾고,
/// 경로를 콜론(:)으로 구분된 네임스페이스 이름으로 변환합니다.
/// 예: skills/database/migrate/SKILL.md → "database:migrate"
/// </summary>
private static void LoadSkillsRecursive(
string rootFolder,
string currentFolder,
List<SkillDefinition> allSkills,
HashSet<string> 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($"스킬 로드 <20><>패 [{skillMd}]: {ex.Message}");
}
}
else
{
// SKILL.md가 없는 중간 디렉토리 → 더 깊이 <20><><EFBFBD>
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);
}
/// <summary>*.skill.md 파일을 파싱합니다.</summary>
private static SkillDefinition? ParseSkillFile(string filePath) => ParseSkillFile(filePath, null);
/// <summary>*.skill.md 파일을 파싱합니다. namespacedName이 주어지면 이름 오버라이드.</summary>
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<string, string>(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<string>()
: 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", "") ?? "",
};
}
/// <summary>YAML의 \uXXXX 이스케이프를 실제 유니코드 문자로 변환합니다.</summary>
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());
}
}