Initial commit to new repository
This commit is contained in:
661
src/AxCopilot/Services/Agent/SkillService.cs
Normal file
661
src/AxCopilot/Services/Agent/SkillService.cs
Normal file
@@ -0,0 +1,661 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 마크다운 기반 스킬 정의를 로드/관리하는 서비스.
|
||||
/// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다.
|
||||
/// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다.
|
||||
/// </summary>
|
||||
public static 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>
|
||||
/// 스킬을 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>
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <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..]))} 필요)" : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user