[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>
This commit is contained in:
2026-04-03 21:02:53 +09:00
parent aa907d7b79
commit 27bd8de83a
11 changed files with 1472 additions and 1417 deletions

View File

@@ -1,5 +1,4 @@
using System.IO;
using System.IO.Compression;
using System.Text;
using AxCopilot.Services;
@@ -10,7 +9,7 @@ namespace AxCopilot.Services.Agent;
/// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다.
/// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다.
/// </summary>
public static class SkillService
public static partial class SkillService
{
private static List<SkillDefinition> _skills = new();
private static string _lastFolder = "";
@@ -203,201 +202,6 @@ public static class SkillService
""");
}
// ─── 가져오기/내보내기 ──────────────────────────────────────────────────
/// <summary>
/// 스킬을 zip 파일로 내보냅니다.
/// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더).
/// </summary>
/// <returns>생성된 zip 파일 경로. 실패 시 null.</returns>
public static string? ExportSkill(SkillDefinition skill, string outputDir)
{
try
{
if (!File.Exists(skill.FilePath))
{
LogService.Warn($"스킬 내보내기 실패: 파일 없음 — {skill.FilePath}");
return null;
}
var zipName = $"{skill.Name}.skill.zip";
var zipPath = Path.Combine(outputDir, zipName);
// 기존 파일이 있으면 삭제
if (File.Exists(zipPath)) File.Delete(zipPath);
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
if (skill.IsStandardFormat)
{
// SKILL.md 표준: 전체 폴더를 zip에 추가
var skillDir = Path.GetDirectoryName(skill.FilePath);
if (skillDir != null && Directory.Exists(skillDir))
{
var baseName = Path.GetFileName(skillDir);
foreach (var file in Directory.EnumerateFiles(skillDir, "*", SearchOption.AllDirectories))
{
// 실행 가능 파일 제외
var ext = Path.GetExtension(file).ToLowerInvariant();
if (ext is ".exe" or ".dll" or ".bat" or ".cmd" or ".ps1" or ".sh") continue;
var entryName = baseName + "/" + Path.GetRelativePath(skillDir, file).Replace('\\', '/');
zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal);
}
}
}
else
{
// *.skill.md 파일 단독
var entryName = $"{skill.Name}/{Path.GetFileName(skill.FilePath)}";
zip.CreateEntryFromFile(skill.FilePath, entryName, CompressionLevel.Optimal);
}
LogService.Info($"스킬 내보내기 완료: {zipPath}");
return zipPath;
}
catch (Exception ex)
{
LogService.Warn($"스킬 내보내기 실패: {ex.Message}");
return null;
}
}
/// <summary>
/// zip 파일에서 스킬을 가져옵니다.
/// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다.
/// </summary>
/// <returns>가져온 스킬 수. 0이면 실패.</returns>
public static int ImportSkills(string zipPath)
{
try
{
if (!File.Exists(zipPath))
{
LogService.Warn($"스킬 가져오기 실패: 파일 없음 — {zipPath}");
return 0;
}
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
if (!Directory.Exists(userFolder))
Directory.CreateDirectory(userFolder);
using var zip = ZipFile.OpenRead(zipPath);
// 보안 검증: 실행 가능 파일 차단
var dangerousExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" };
foreach (var entry in zip.Entries)
{
if (dangerousExts.Contains(Path.GetExtension(entry.Name)))
{
LogService.Warn($"스킬 가져오기 차단: 실행 가능 파일 포함 — {entry.FullName}");
return 0;
}
}
// 스킬 파일 존재 여부 확인
var skillEntries = zip.Entries
.Where(e => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|| e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
.ToList();
if (skillEntries.Count == 0)
{
LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음");
return 0;
}
// zip 압축 해제
int importedCount = 0;
foreach (var entry in zip.Entries)
{
if (string.IsNullOrEmpty(entry.Name)) continue; // 디렉토리 항목 건너뛰기
// 상위 경로 이탈 방지
var relativePath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
if (relativePath.Contains("..")) continue;
var destPath = Path.Combine(userFolder, relativePath);
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
entry.ExtractToFile(destPath, overwrite: true);
if (entry.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|| entry.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
importedCount++;
}
if (importedCount > 0)
{
LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})");
// 스킬 목록 리로드
LoadSkills();
}
return importedCount;
}
catch (Exception ex)
{
LogService.Warn($"스킬 가져오기 실패: {ex.Message}");
return 0;
}
}
// ─── 도구 이름 매핑 (외부 스킬 호환) ──────────────────────────────────────
/// <summary>
/// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다.
/// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다.
/// </summary>
private static readonly Dictionary<string, string> ToolNameMap = new(StringComparer.OrdinalIgnoreCase)
{
// Claude Code / Cursor 표준
["Bash"] = "process",
["bash"] = "process",
["Read"] = "file_read",
["Write"] = "file_write",
["Edit"] = "file_edit",
["Glob"] = "glob",
["Grep"] = "grep_tool",
["WebSearch"] = "http_tool",
["WebFetch"] = "http_tool",
// agentskills.io 표준
["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",
};
/// <summary>스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다.</summary>
public static string MapToolNames(string skillBody)
{
if (string.IsNullOrEmpty(skillBody)) return skillBody;
foreach (var kv in ToolNameMap)
{
// 코드 블록 내 도구 참조: `Bash`, `Read` 등을 `process`, `file_read`로 변환
skillBody = skillBody.Replace($"`{kv.Key}`", $"`{kv.Value}`");
// 괄호 내 참조: (Bash), (Read) 패턴
skillBody = skillBody.Replace($"({kv.Key})", $"({kv.Value})");
}
return skillBody;
}
// ─── 내부 메서드 ─────────────────────────────────────────────────────────
/// <summary>
@@ -580,82 +384,3 @@ public static class SkillService
}
}
/// <summary>스킬 정의 (*.skill.md에서 로드).</summary>
public class SkillDefinition
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Label { get; init; } = "";
public string Description { get; init; } = "";
public string Icon { get; init; } = "\uE768";
public string SystemPrompt { get; init; } = "";
public string FilePath { get; init; } = "";
// SKILL.md 표준 확장 필드
public string License { get; init; } = "";
public string Compatibility { get; init; } = "";
public string AllowedTools { get; init; } = "";
/// <summary>런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음.</summary>
public string Requires { get; init; } = "";
/// <summary>표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능.</summary>
public string Tabs { get; init; } = "all";
// ── Phase 24: CC 고급 프론트매터 필드 ──
/// <summary>실행 컨텍스트. "fork"이면 격리된 서브에이전트에서 실행.</summary>
public string Context { get; init; } = "";
/// <summary>이 스킬에 사용할 모델 오버라이드. 빈 문자열이면 기본 모델 사용.</summary>
public string ModelOverride { get; init; } = "";
/// <summary>사용자가 /로 호출 가능한지 여부. false면 AI만 사용.</summary>
public bool UserInvocable { get; init; } = true;
/// <summary>자동 활성화 경로 패턴. "**/*.py" 등 — 매칭 파일 터치 시 스킬 자동 제안.</summary>
public string Paths { get; init; } = "";
/// <summary>스킬 실행 중에만 활성화되는 스코프 훅 정의 (JSON).</summary>
public string ScopedHooks { get; init; } = "";
/// <summary>프론트매터 arguments 필드. 명명된 인수 목록.</summary>
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
/// <summary>자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트.</summary>
public string WhenToUse { get; init; } = "";
/// <summary>스킬 버전.</summary>
public string Version { get; init; } = "";
/// <summary>런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.</summary>
public bool IsAvailable { get; set; } = true;
/// <summary>context:fork 설정인지 여부.</summary>
public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase);
/// <summary>지정 탭에서 이 스킬을 표시할지 판정합니다.</summary>
public bool IsVisibleInTab(string activeTab)
{
if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase))
return true;
var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant());
var tab = activeTab.ToLowerInvariant();
return tabs.Any(t => t == "all" || t == tab);
}
/// <summary>SKILL.md 표준 폴더 형식인지 여부.</summary>
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
/// <summary>비가용 시 사용자에게 표시할 힌트 메시지.</summary>
public string UnavailableHint
{
get
{
if (IsAvailable || string.IsNullOrEmpty(Requires)) return "";
var runtimes = Requires.Split(',').Select(r => r.Trim());
var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray();
return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : "";
}
}
}