diff --git a/README.md b/README.md index 237e1d9..9085f8a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 18:22 (KST) +- 스킬 소스 계층을 더 확장했습니다. 이제 기본/사용자/추가 폴더뿐 아니라 작업 폴더 상위 경로의 `.claude/skills`, 플러그인 내부 스킬 폴더, `.claude/commands` 기반의 레거시 markdown command까지 함께 읽어 번들/프로젝트/플러그인/공용/레거시 스킬로 분류합니다. +- 스킬 로딩 방식도 가볍게 정리했습니다. 파일형 스킬은 본문을 처음부터 모두 메모리에 올리지 않고, 실제 호출·미리보기 시점에 body를 읽어 lazy하게 조립합니다. 인자 모델은 `arguments`와 `argument-hint`를 함께 해석해 위치 인자 치환과 누락 인자 안내를 같이 처리합니다. +- 설정은 크게 바꾸지 않고 보조 스킬 폴더 목록만 추가했습니다. 일반 설정과 AX Agent 설정에서 여러 공용 스킬 폴더를 줄 단위로 연결할 수 있고, 프로젝트/플러그인/레거시 command 소스도 같은 목록 화면에서 함께 확인할 수 있습니다. +- 도구 deny 필터는 공통 카탈로그로 끌어올렸습니다. 런타임뿐 아니라 설정 화면의 도구 목록도 blanket deny 규칙을 먼저 반영해 실제로 보이는 도구와 모델에 전달되는 도구가 더 일치합니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\` 통과 18 +- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs`의 nullable 경고 1건은 유지됩니다. + - 업데이트: 2026-04-14 18:08 (KST) - 스킬 런타임을 `claude-code` 동등 품질 기준으로 한 단계 더 끌어올렸습니다. 이제 작업 폴더 기준의 프로젝트 `.claude/skills`를 재귀 스캔해 namespaced `SKILL.md`를 로드하고, 번들 스킬/사용자 스킬/프로젝트 스킬을 함께 노출합니다. - 슬래시 스킬 호출도 실제 실행 경로에 맞게 확장했습니다. `/skill args...` 호출 시 `$ARGUMENTS`, named argument placeholder, 스킬 폴더 변수 치환을 적용하고, inline shell 블록은 호출 시점의 작업 폴더에서만 실행해 프롬프트를 조립합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a55b64c..ec6516d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -807,3 +807,12 @@ UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복 > - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\` 경고 0 / 오류 0 > - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\` 통과 16 > - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다. +> 업데이트: 2026-04-14 18:22 (KST) +> - 스킬 소스 확장 Phase 3을 반영했습니다. `SkillService`는 상위 디렉터리까지 포함한 프로젝트 `.claude/skills` 탐색, 플러그인 스킬 폴더 탐색, 추가 공용 폴더 목록, `.claude/commands` markdown command를 legacy skill로 변환하는 경로를 함께 지원합니다. +> - 파일형 스킬은 body를 즉시 메모리에 올리지 않고 필요 시점에만 읽는 lazy prompt body 캐시를 추가했습니다. `SkillManagerTool`, `SkillEditorWindow`, `SkillGalleryWindow`는 이 경로를 통해 실제 본문을 표시합니다. +> - 인자 모델도 확장했습니다. `arguments`와 `argument-hint`를 함께 해석해 named placeholder 치환을 강화했고, 인자가 부족하면 usage 가이드를 프롬프트 앞에 붙여 실행 품질을 보완합니다. +> - 도구 deny 필터는 `AgentToolCatalog` 공통 메서드로 이동해 런타임과 설정 UI가 같은 blanket deny 규칙을 공유하도록 정리했습니다. +> - 설정 저장에는 `additionalSkillFolders`를 추가했고, 일반 설정/AX Agent 설정 UI에 줄 단위 입력 필드를 넣어 여러 공용 스킬 폴더를 연결할 수 있게 했습니다. +> - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\` 경고 0 / 오류 0 +> - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\` 통과 18 +> - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다. diff --git a/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs b/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs index 54a496c..46a98ed 100644 --- a/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs @@ -52,4 +52,24 @@ public class AgentToolCatalogTests normalized[0].ToolName.Should().Be("zip_tool"); normalized[1].ToolName.Should().Be("*"); } + + [Fact] + public void FilterExposureByPermission_ShouldRemoveBlanketDeniedTools() + { + var tools = new[] + { + new { Name = "file_read" }, + new { Name = "git_tool" }, + new { Name = "build_run" }, + }; + var permissions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["git"] = "deny", + ["build_run@*.sln"] = "deny", + }; + + var filtered = AgentToolCatalog.FilterExposureByPermission(tools, tool => tool.Name, permissions); + + filtered.Select(tool => tool.Name).Should().BeEquivalentTo(["file_read", "build_run"]); + } } diff --git a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs index c6f6bac..1cc3eab 100644 --- a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs @@ -78,7 +78,7 @@ public class SkillServiceRuntimePolicyTests var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static); method.Should().NotBeNull(); - var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition; + var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) as SkillDefinition; parsed.Should().NotBeNull(); parsed!.Hooks.Should().Contain("lint-pre"); parsed.Hooks.Should().Contain("verify-pre"); @@ -119,7 +119,7 @@ public class SkillServiceRuntimePolicyTests var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static); method.Should().NotBeNull(); - var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition; + var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) as SkillDefinition; parsed.Should().NotBeNull(); parsed!.Hooks.Should().Contain("lint-pre"); parsed.Hooks.Should().Contain("verify-post"); @@ -153,7 +153,7 @@ public class SkillServiceRuntimePolicyTests var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static); method.Should().NotBeNull(); - var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition; + var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) as SkillDefinition; parsed.Should().NotBeNull(); parsed!.IsSample.Should().BeTrue(); } @@ -184,7 +184,7 @@ public class SkillServiceRuntimePolicyTests var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static); method.Should().NotBeNull(); - var parsed = method!.Invoke(null, [skillPath, Path.Combine(tempDir, ".claude", "skills")]) as SkillDefinition; + var parsed = method!.Invoke(null, [skillPath, Path.Combine(tempDir, ".claude", "skills"), "project"]) as SkillDefinition; parsed.Should().NotBeNull(); parsed!.Name.Should().Be("release:notes"); parsed.SourceScope.Should().Be("project"); @@ -261,4 +261,46 @@ public class SkillServiceRuntimePolicyTests try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } + + [Fact] + public async Task LoadSkills_LoadsAncestorProjectSkills_AndLegacyCommandFiles() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-ancestor-" + Guid.NewGuid().ToString("N")); + var rootSkills = Path.Combine(tempDir, ".claude", "skills", "shared"); + var nestedWorktree = Path.Combine(tempDir, "apps", "desktop", "module"); + var legacyDir = Path.Combine(tempDir, ".claude", "commands", "review"); + Directory.CreateDirectory(rootSkills); + Directory.CreateDirectory(nestedWorktree); + Directory.CreateDirectory(legacyDir); + + try + { + File.WriteAllText(Path.Combine(rootSkills, "SKILL.md"), """ + --- + description: shared helper + arguments: branch + --- + + shared body for $branch + """, Encoding.UTF8); + File.WriteAllText(Path.Combine(legacyDir, "lint.md"), """ + # 린트 실행 + + Run build validation and summarize results. + """, Encoding.UTF8); + + SkillService.LoadSkills(projectRoot: nestedWorktree); + + SkillService.Find("shared").Should().NotBeNull(); + SkillService.Find("review:lint").Should().NotBeNull(); + + var invocation = await SkillService.BuildSlashInvocationAsync("/shared main", nestedWorktree); + invocation.Should().NotBeNull(); + invocation!.SystemPrompt.Should().Contain("shared body for main"); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } } diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index fe3240a..a049e44 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -1292,6 +1292,10 @@ public class LlmSettings [JsonPropertyName("skillsFolderPath")] public string SkillsFolderPath { get; set; } = ""; + /// 여분 스킬 폴더 목록. 프로젝트 외부 재사용 스킬 폴더를 추가로 연결합니다. + [JsonPropertyName("additionalSkillFolders")] + public List AdditionalSkillFolders { get; set; } = new(); + /// 슬래시 명령어 팝업 한 번에 표시할 최대 항목 수 (3~20). 기본 7. [JsonPropertyName("slashPopupPageSize")] public int SlashPopupPageSize { get; set; } = 7; diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 841db4a..c4be07f 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -2022,7 +2022,7 @@ public partial class AgentLoopService private IReadOnlyCollection ApplyPermissionExposureFilter(IReadOnlyCollection tools) { - var blanketDeniedTools = GetBlanketDeniedToolNames(); + var blanketDeniedTools = AgentToolCatalog.GetBlanketDeniedToolNames(_settings.Settings.Llm.ToolPermissions); if (blanketDeniedTools.Count == 0) return tools; @@ -2035,36 +2035,6 @@ public partial class AgentLoopService .AsReadOnly(); } - private HashSet GetBlanketDeniedToolNames() - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - var permissionMap = AgentToolCatalog.CanonicalizePermissionMap(_settings.Settings.Llm.ToolPermissions ?? new Dictionary()); - foreach (var kv in permissionMap) - { - if (!PermissionModeCatalog.IsDeny(kv.Value)) - continue; - - var key = kv.Key?.Trim() ?? ""; - if (string.IsNullOrWhiteSpace(key)) - continue; - - if (key.IndexOfAny(['@', '|', '(', ')']) >= 0) - continue; - - if (string.Equals(key, "*", StringComparison.Ordinal)) - { - result.Add("*"); - return result; - } - - var canonical = AgentToolCatalog.Canonicalize(key); - if (!string.IsNullOrWhiteSpace(canonical)) - result.Add(canonical); - } - - return result; - } - private IEnumerable MergeDisabledTools(IEnumerable? disabledToolNames) { var disabled = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/AxCopilot/Services/Agent/AgentToolCatalog.cs b/src/AxCopilot/Services/Agent/AgentToolCatalog.cs index aab6aaa..c0334e0 100644 --- a/src/AxCopilot/Services/Agent/AgentToolCatalog.cs +++ b/src/AxCopilot/Services/Agent/AgentToolCatalog.cs @@ -210,6 +210,52 @@ internal static class AgentToolCatalog return result; } + public static HashSet GetBlanketDeniedToolNames(IDictionary? permissions) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + var normalized = CanonicalizePermissionMap(permissions); + foreach (var kv in normalized) + { + if (!PermissionModeCatalog.IsDeny(kv.Value)) + continue; + + var key = kv.Key?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(key)) + continue; + if (key.IndexOfAny(['@', '|', '(', ')']) >= 0) + continue; + + if (string.Equals(key, "*", StringComparison.Ordinal)) + { + result.Add("*"); + return result; + } + + var canonical = Canonicalize(key); + if (!string.IsNullOrWhiteSpace(canonical)) + result.Add(canonical); + } + + return result; + } + + public static IReadOnlyList FilterExposureByPermission( + IEnumerable tools, + Func getName, + IDictionary? permissions) + { + var denied = GetBlanketDeniedToolNames(permissions); + if (denied.Count == 0) + return tools.ToList().AsReadOnly(); + if (denied.Contains("*")) + return Array.Empty(); + + return tools + .Where(tool => !denied.Contains(Canonicalize(getName(tool)))) + .ToList() + .AsReadOnly(); + } + public static string CanonicalizeHookTarget(string? toolName) { if (string.IsNullOrWhiteSpace(toolName)) diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs index 31c9195..67ad2a5 100644 --- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs +++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs @@ -71,6 +71,8 @@ public class SkillManagerTool : IAgentTool : "[DIRECT]"; sb.AppendLine($" /{skill.Name} {execBadge} — {skill.Label}"); sb.AppendLine($" {skill.Description}"); + if (!string.IsNullOrWhiteSpace(skill.SourceScope)) + sb.AppendLine($" source: {skill.SourceScope}"); if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)) sb.AppendLine(" 실행 방식: 위임 우선 (spawn_agent → wait_agents)"); if (!string.IsNullOrWhiteSpace(skill.AllowedTools)) @@ -98,6 +100,8 @@ public class SkillManagerTool : IAgentTool sb.AppendLine($"스킬 상세: {skill.Label} (/{skill.Name})"); sb.AppendLine($"설명: {skill.Description}"); sb.AppendLine($"파일: {skill.FilePath}"); + if (!string.IsNullOrWhiteSpace(skill.SourceScope)) + sb.AppendLine($"source: {skill.SourceScope}"); if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)) sb.AppendLine("실행 배지: [FORK] · 위임 우선 실행"); else @@ -121,7 +125,7 @@ public class SkillManagerTool : IAgentTool var runtimeDirective = SkillService.BuildRuntimeDirective(skill); if (!string.IsNullOrWhiteSpace(runtimeDirective)) sb.AppendLine($"\n--- 런타임 정책 ---\n{runtimeDirective}"); - sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{skill.SystemPrompt}"); + sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{SkillService.GetSkillPromptForDisplay(skill)}"); return ToolResult.Ok(sb.ToString()); } @@ -129,7 +133,7 @@ public class SkillManagerTool : IAgentTool { var llm = app?.SettingsService?.Settings.Llm; var customFolder = llm?.SkillsFolderPath ?? ""; - SkillService.LoadSkills(customFolder, ResolveProjectRoot(llm)); + SkillService.LoadSkills(customFolder, ResolveProjectRoot(llm), llm?.AdditionalSkillFolders); return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨."); } diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index bd228a7..415cdbb 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -17,56 +17,39 @@ public static class SkillService private static string _lastFolder = ""; private static string _lastProjectRoot = ""; private static string _lastLoadSignature = ""; + private static readonly Dictionary s_promptBodyCache = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase); /// 로드된 스킬 목록. public static IReadOnlyList Skills => _skills; /// 스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다. - public static void LoadSkills(string? customFolder = null, string? projectRoot = null) + public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable? additionalFolders = null) { var normalizedCustomFolder = NormalizeExistingDirectory(customFolder); var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot); - var loadSignature = $"{normalizedCustomFolder}|{normalizedProjectRoot}"; + var normalizedAdditionalFolders = NormalizeDistinctDirectories(additionalFolders); + var sources = BuildSkillSources(normalizedCustomFolder, normalizedProjectRoot, normalizedAdditionalFolders).ToList(); + var loadSignature = string.Join("|", sources.Select(source => $"{source.Kind}:{source.Scope}:{source.Directory}")); if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase)) return; - var folders = new List(); - - // 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(normalizedCustomFolder)) - folders.Add(normalizedCustomFolder); - - // 4) 프로젝트 스킬 폴더 (.claude/skills) - if (!string.IsNullOrEmpty(normalizedProjectRoot)) - { - var projectSkillsFolder = Path.Combine(normalizedProjectRoot, ".claude", "skills"); - if (Directory.Exists(projectSkillsFolder)) - folders.Add(projectSkillsFolder); - } - var allSkills = GetBundledSkills().ToList(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var bundled in allSkills) seen.Add(bundled.Name); - foreach (var folder in folders) + foreach (var source in sources) { - foreach (var file in EnumerateSkillFiles(folder)) + foreach (var file in source.Kind == SkillSourceKind.LegacyCommand + ? EnumerateLegacyCommandFiles(source.Directory) + : EnumerateSkillFiles(source.Directory)) { try { - var skill = ParseSkillFile(file, folder); + var skill = source.Kind == SkillSourceKind.LegacyCommand + ? ParseLegacyCommandFile(file, source.Directory, source.Scope) + : ParseSkillFile(file, source.Directory, source.Scope); if (skill != null && seen.Add(skill.Name)) allSkills.Add(skill); } @@ -91,6 +74,7 @@ public static class SkillService _lastFolder = normalizedCustomFolder ?? ""; _lastProjectRoot = normalizedProjectRoot ?? ""; _lastLoadSignature = loadSignature; + s_promptBodyCache.Clear(); _activeConditionalSkillNames.Clear(); var unavailCount = allSkills.Count(s => !s.IsAvailable); LogService.Info($"스킬 {allSkills.Count}개 로드 완료" + @@ -192,6 +176,8 @@ public static class SkillService .ToList(); } + public static string GetSkillPromptForDisplay(SkillDefinition skill) => GetSkillPromptBody(skill); + /// /// 스킬 메타데이터(context/agent/effort/model 등)를 /// 런타임 시스템 지시문으로 변환합니다. @@ -317,6 +303,150 @@ public static class SkillService } } + private static IReadOnlyList NormalizeDistinctDirectories(IEnumerable? paths) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (paths == null) + return result.ToList().AsReadOnly(); + + foreach (var path in paths) + { + var normalized = NormalizeExistingDirectory(path); + if (!string.IsNullOrWhiteSpace(normalized)) + result.Add(normalized); + } + + return result.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList().AsReadOnly(); + } + + private static IEnumerable BuildSkillSources( + string? customFolder, + string? projectRoot, + IEnumerable additionalFolders) + { + var sources = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddSource(string? directory, string scope, SkillSourceKind kind = SkillSourceKind.SkillFolder) + { + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) + return; + + var normalized = Path.GetFullPath(directory); + if (seen.Add($"{kind}|{normalized}")) + sources.Add(new SkillSourceDescriptor(normalized, scope, kind)); + } + + AddSource(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills"), "managed"); + AddSource(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"), "user"); + AddSource(customFolder, "custom"); + + foreach (var folder in additionalFolders) + AddSource(folder, "additional"); + + foreach (var folder in EnumeratePluginSkillFolders()) + AddSource(folder, "plugin"); + + foreach (var folder in EnumerateProjectSkillFolders(projectRoot)) + AddSource(folder, "project"); + + foreach (var folder in EnumerateLegacyCommandFolders(projectRoot)) + AddSource(folder, "legacy", SkillSourceKind.LegacyCommand); + + return sources; + } + + private static IEnumerable EnumerateProjectSkillFolders(string? projectRoot) + { + foreach (var root in EnumerateAncestorDirectories(projectRoot)) + { + var candidate = Path.Combine(root, ".claude", "skills"); + if (Directory.Exists(candidate)) + yield return candidate; + } + } + + private static IEnumerable EnumerateLegacyCommandFolders(string? projectRoot) + { + foreach (var root in EnumerateAncestorDirectories(projectRoot)) + { + var candidate = Path.Combine(root, ".claude", "commands"); + if (Directory.Exists(candidate)) + yield return candidate; + } + } + + private static IEnumerable EnumerateAncestorDirectories(string? startDirectory) + { + var normalized = NormalizeExistingDirectory(startDirectory); + if (string.IsNullOrWhiteSpace(normalized)) + yield break; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = new DirectoryInfo(normalized); + while (current != null) + { + yield return current.FullName; + + var trimmed = current.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.Equals(trimmed, home, StringComparison.OrdinalIgnoreCase)) + yield break; + + current = current.Parent; + } + } + + private static IEnumerable EnumeratePluginSkillFolders() + { + var app = System.Windows.Application.Current as App; + var plugins = app?.SettingsService?.Settings.Plugins; + if (plugins == null) + yield break; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var plugin in plugins.Where(plugin => plugin.Enabled && !string.IsNullOrWhiteSpace(plugin.Path))) + { + var pluginDir = NormalizeExistingDirectory(Path.GetDirectoryName(plugin.Path)); + if (string.IsNullOrWhiteSpace(pluginDir)) + continue; + + foreach (var candidate in new[] + { + Path.Combine(pluginDir, ".claude", "skills"), + Path.Combine(pluginDir, ".claude-plugin", "skills"), + Path.Combine(pluginDir, "skills") + }) + { + var normalized = NormalizeExistingDirectory(candidate); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + yield return normalized; + } + } + } + + private static IEnumerable EnumerateLegacyCommandFiles(string folder) + { + IEnumerable files = []; + try + { + files = Directory.EnumerateFiles(folder, "*.md", SearchOption.AllDirectories); + } + catch + { + yield break; + } + + foreach (var file in files) + { + var name = Path.GetFileName(file); + if (name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)) + continue; + yield return file; + } + } + private static IReadOnlyList GetBundledSkills() { return @@ -601,7 +731,9 @@ public static class SkillService { LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})"); // 스킬 목록 리로드 - LoadSkills(); + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; + LoadSkills(llm?.SkillsFolderPath, llm?.WorkFolder, llm?.AdditionalSkillFolders); } return importedCount; @@ -711,7 +843,7 @@ public static class SkillService } /// *.skill.md / SKILL.md 파일을 파싱합니다. - private static SkillDefinition? ParseSkillFile(string filePath, string? skillRoot = null) + private static SkillDefinition? ParseSkillFile(string filePath, string? skillRoot = null, string sourceScope = "custom") { var content = File.ReadAllText(filePath, Encoding.UTF8); if (!content.TrimStart().StartsWith("---")) @@ -723,8 +855,6 @@ public static class SkillService 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(StringComparer.OrdinalIgnoreCase); string? currentMap = null; @@ -789,7 +919,9 @@ public static class SkillService var name = meta.GetValueOrDefault("name", fallbackName); if (string.IsNullOrEmpty(name)) return null; - var argumentNames = ParseArgumentNames(meta.GetValueOrDefault("argument-hint", "")); + var argumentNames = ParseArgumentNames( + meta.GetValueOrDefault("arguments", ""), + meta.GetValueOrDefault("argument-hint", "")); // SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음 var label = meta.GetValueOrDefault("label", "") ?? ""; @@ -810,12 +942,12 @@ public static class SkillService Label = string.IsNullOrEmpty(label) ? name : label, Description = meta.GetValueOrDefault("description", "") ?? "", Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon), - SystemPrompt = MapToolNames(body), + SystemPrompt = "", FilePath = filePath, SkillRoot = string.IsNullOrWhiteSpace(normalizedSkillRoot) ? (Path.GetDirectoryName(filePath) ?? "") : Path.GetDirectoryName(filePath) ?? normalizedSkillRoot, - SourceScope = ResolveSourceScope(filePath, normalizedSkillRoot), + SourceScope = ResolveSourceScope(filePath, normalizedSkillRoot, sourceScope), License = meta.GetValueOrDefault("license", "") ?? "", Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "", AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "", @@ -839,6 +971,37 @@ public static class SkillService }; } + private static SkillDefinition? ParseLegacyCommandFile(string filePath, string commandRoot, string sourceScope) + { + var content = File.ReadAllText(filePath, Encoding.UTF8).Trim(); + if (string.IsNullOrWhiteSpace(content)) + return null; + + var fileDirectory = Path.GetDirectoryName(filePath) ?? commandRoot; + var relativeDir = Path.GetRelativePath(commandRoot, fileDirectory).Replace('\\', '/'); + var baseName = Path.GetFileNameWithoutExtension(filePath); + var namespacePrefix = string.IsNullOrWhiteSpace(relativeDir) || relativeDir == "." + ? "" + : string.Join(':', relativeDir.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + var name = string.IsNullOrWhiteSpace(namespacePrefix) ? baseName : $"{namespacePrefix}:{baseName}"; + var description = ExtractLegacyCommandDescription(content, baseName); + + return new SkillDefinition + { + Id = name, + Name = name, + Label = baseName, + Description = description, + Icon = "\uE768", + SystemPrompt = MapToolNames(content), + FilePath = filePath, + SkillRoot = fileDirectory, + SourceScope = sourceScope, + UserInvocable = true, + IsAvailable = true, + }; + } + private static string BuildNamespacedSkillName(string filePath, string skillRoot, string fallbackName) { if (string.IsNullOrWhiteSpace(skillRoot)) @@ -865,11 +1028,14 @@ public static class SkillService } } - private static string ResolveSourceScope(string filePath, string skillRoot) + private static string ResolveSourceScope(string filePath, string skillRoot, string fallbackScope) { if (filePath.StartsWith("[bundled]/", StringComparison.OrdinalIgnoreCase)) return "bundled"; + if (!string.IsNullOrWhiteSpace(fallbackScope)) + return fallbackScope; + if (!string.IsNullOrWhiteSpace(skillRoot) && skillRoot.Replace('\\', '/').Contains("/.claude/skills", StringComparison.OrdinalIgnoreCase)) return "project"; @@ -884,25 +1050,37 @@ public static class SkillService return "custom"; } - private static IReadOnlyList ParseArgumentNames(string argumentHint) + private static IReadOnlyList ParseArgumentNames(string argumentsField, string argumentHint) { + var names = new List(); + + if (!string.IsNullOrWhiteSpace(argumentsField)) + { + names.AddRange(argumentsField + .Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => token.Trim('<', '>', '{', '}', '[', ']', '(', ')', '"', '\'').Trim()) + .Where(token => !string.IsNullOrWhiteSpace(token))); + } + if (string.IsNullOrWhiteSpace(argumentHint)) - return []; + return names.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var placeholders = Regex.Matches(argumentHint, @"<([^>]+)>|\{([^}]+)\}") .Cast() .Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value) .Select(static name => name.Trim()) .Where(static name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (placeholders.Count > 0) - return placeholders; + names.AddRange(placeholders); - return argumentHint + names.AddRange(argumentHint .Split([' ', ',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(static token => token.Trim('<', '>', '{', '}', '[', ']', '(', ')').Trim()) .Where(static token => !string.IsNullOrWhiteSpace(token)) + .ToList()); + + return names .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } @@ -959,6 +1137,15 @@ public static class SkillService score += 4; if (!skill.UserInvocable) score += 1; + score += skill.SourceScope switch + { + "project" => 3, + "plugin" => 2, + "bundled" => 1, + _ => 0, + }; + if (skill.DisableModelInvocation) + score += 1; var haystack = $"{skill.Name} {skill.Label} {skill.Description} {skill.WhenToUse}".ToLowerInvariant(); foreach (var token in queryTokens) @@ -990,14 +1177,46 @@ public static class SkillService string workFolder, CancellationToken ct = default) { - var compiled = skill.SystemPrompt; - compiled = SubstituteArguments(compiled, rawArgs, skill.ArgumentNames); + var compiled = GetSkillPromptBody(skill); + compiled = SubstituteArguments(compiled, rawArgs, skill.ArgumentNames, skill.ArgumentHint); compiled = SubstituteSkillDirectory(compiled, skill.SkillRoot); compiled = await InjectInlineShellBlocksAsync(compiled, skill, workFolder, ct).ConfigureAwait(false); return compiled; } - private static string SubstituteArguments(string input, string rawArgs, IReadOnlyList argumentNames) + private static string GetSkillPromptBody(SkillDefinition skill) + { + if (!string.IsNullOrWhiteSpace(skill.SystemPrompt)) + return skill.SystemPrompt; + + if (string.IsNullOrWhiteSpace(skill.FilePath) || !File.Exists(skill.FilePath)) + return ""; + + if (s_promptBodyCache.TryGetValue(skill.FilePath, out var cached)) + return cached; + + var content = File.ReadAllText(skill.FilePath, Encoding.UTF8); + var body = ExtractSkillBody(content); + body = MapToolNames(body); + s_promptBodyCache[skill.FilePath] = body; + return body; + } + + private static string ExtractSkillBody(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return ""; + + var trimmed = content.TrimStart(); + if (!trimmed.StartsWith("---", StringComparison.Ordinal)) + return content.Trim(); + + var firstSep = content.IndexOf("---", StringComparison.Ordinal); + var secondSep = content.IndexOf("---", firstSep + 3, StringComparison.Ordinal); + return secondSep < 0 ? content.Trim() : content[(secondSep + 3)..].Trim(); + } + + private static string SubstituteArguments(string input, string rawArgs, IReadOnlyList argumentNames, string argumentHint) { rawArgs ??= ""; var result = input.Replace("$ARGUMENTS", rawArgs, StringComparison.OrdinalIgnoreCase); @@ -1005,6 +1224,14 @@ public static class SkillService return result; var tokens = TokenizeArguments(rawArgs); + if (tokens.Count < argumentNames.Count) + { + var usage = string.IsNullOrWhiteSpace(argumentHint) + ? string.Join(' ', argumentNames.Select(name => $"<{name}>")) + : argumentHint.Trim(); + result = $"[Skill Arguments]\nExpected: {usage}\nReceived: {rawArgs}\n\n{result}"; + } + for (var i = 0; i < argumentNames.Count; i++) { var value = i < tokens.Count ? tokens[i] : ""; @@ -1015,6 +1242,21 @@ public static class SkillService return result; } + private static string ExtractLegacyCommandDescription(string content, string fallbackName) + { + foreach (var line in content.Split('\n')) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + continue; + if (trimmed.StartsWith("#")) + return trimmed.TrimStart('#', ' ').Trim(); + return trimmed.Length > 80 ? trimmed[..80] : trimmed; + } + + return fallbackName; + } + private static IReadOnlyList TokenizeArguments(string rawArgs) { if (string.IsNullOrWhiteSpace(rawArgs)) @@ -1443,3 +1685,11 @@ public sealed record CompiledSkillInvocation( SkillDefinition Skill, string SystemPrompt, string DisplayText); + +internal sealed record SkillSourceDescriptor(string Directory, string Scope, SkillSourceKind Kind); + +internal enum SkillSourceKind +{ + SkillFolder, + LegacyCommand, +} diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 0ad97b3..6a89432 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -539,6 +539,13 @@ public class SettingsViewModel : INotifyPropertyChanged set { _skillsFolderPath = value; OnPropertyChanged(); } } + private string _additionalSkillFoldersText = ""; + public string AdditionalSkillFoldersText + { + get => _additionalSkillFoldersText; + set { _additionalSkillFoldersText = value; OnPropertyChanged(); } + } + private int _slashPopupPageSize = 6; public int SlashPopupPageSize { @@ -1209,6 +1216,7 @@ public class SettingsViewModel : INotifyPropertyChanged _enableSkillSystem = llm.EnableSkillSystem; _enableForkSkillDelegationEnforcement = llm.EnableForkSkillDelegationEnforcement; _skillsFolderPath = llm.SkillsFolderPath; + _additionalSkillFoldersText = string.Join(Environment.NewLine, llm.AdditionalSkillFolders ?? new List()); _slashPopupPageSize = llm.SlashPopupPageSize > 0 ? Math.Clamp(llm.SlashPopupPageSize, 3, 10) : 6; _maxFavoriteSlashCommands = llm.MaxFavoriteSlashCommands > 0 ? Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30) : 10; _maxRecentSlashCommands = llm.MaxRecentSlashCommands > 0 ? Math.Clamp(llm.MaxRecentSlashCommands, 5, 50) : 20; @@ -1659,6 +1667,7 @@ public class SettingsViewModel : INotifyPropertyChanged s.Llm.EnableSkillSystem = _enableSkillSystem; s.Llm.EnableForkSkillDelegationEnforcement = _enableForkSkillDelegationEnforcement; s.Llm.SkillsFolderPath = _skillsFolderPath; + s.Llm.AdditionalSkillFolders = ParseAdditionalSkillFolders(_additionalSkillFoldersText); s.Llm.SlashPopupPageSize = _slashPopupPageSize; s.Llm.MaxFavoriteSlashCommands = _maxFavoriteSlashCommands; s.Llm.MaxRecentSlashCommands = _maxRecentSlashCommands; @@ -1842,6 +1851,19 @@ public class SettingsViewModel : INotifyPropertyChanged SaveCompleted?.Invoke(this, EventArgs.Empty); } + private static List ParseAdditionalSkillFolders(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new List(); + + return raw + .Split(['\r', '\n', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(path => path.Trim()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + // ─── 스니펫 설정 ────────────────────────────────────────────────────────── public ObservableCollection Snippets { get; } = new(); diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml b/src/AxCopilot/Views/AgentSettingsWindow.xaml index 1f59c75..5bbaf32 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml @@ -869,6 +869,25 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> + + + + + ParseAdditionalSkillFolders(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new List(); + + return raw + .Split(['\r', '\n', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(path => path.Trim()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private void BtnBrowseSkillFolder_Click(object sender, RoutedEventArgs e) { var dlg = new System.Windows.Forms.FolderBrowserDialog @@ -642,7 +657,10 @@ public partial class AgentSettingsWindow : Window { new { Title = "번들 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "프로젝트 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() }, - new { Title = "사용자/추가 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(s.Requires)).ToList() }, + new { Title = "플러그인 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)).ToList() }, + new { Title = "보조/공용 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase)).ToList() }, + new { Title = "레거시 명령 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase)).ToList() }, + new { Title = "사용자/추가 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(s.Requires)).ToList() }, new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() }, }; @@ -748,7 +766,12 @@ public partial class AgentSettingsWindow : Window using var tools = ToolRegistry.CreateDefault(); var categories = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var tool in tools.All) + var visibleTools = AgentToolCatalog.FilterExposureByPermission( + tools.All, + tool => tool.Name, + _llm.ToolPermissions); + + foreach (var tool in visibleTools) { var category = AgentToolCatalog.GetMetadata(tool.Name).SettingsCategory; if (!categories.ContainsKey(category)) diff --git a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs index fffd194..c98d50c 100644 --- a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs @@ -1284,7 +1284,7 @@ public partial class ChatWindow return; _settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath; - SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder()); + SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders); RefreshOverlayEtcPanels(); PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); } @@ -3651,7 +3651,7 @@ public partial class ChatWindow if (llm.EnableSkillSystem) { SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder()); + SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders); UpdateConditionalSkillActivation(reset: true); } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 3a9eb13..40eca5d 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -524,7 +524,7 @@ public partial class ChatWindow : Window if (_settings.Settings.Llm.EnableSkillSystem) { SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder()); + SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders); UpdateConditionalSkillActivation(reset: true); } @@ -2247,7 +2247,7 @@ public partial class ChatWindow : Window return; SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder()); + SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders); } /// @@ -5008,7 +5008,7 @@ public partial class ChatWindow : Window { llm.EnableSkillSystem = true; SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder()); + SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders); UpdateConditionalSkillActivation(reset: true); ScheduleSettingsSave(); _appState.LoadFromSettings(_settings); diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 126c690..0dfc0c1 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4351,6 +4351,19 @@ + + + + + + + + diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs index 54e4ff7..b1f05a7 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs @@ -96,7 +96,8 @@ public partial class SettingsWindow : Window Services.Agent.SkillService.EnsureSkillFolder(); Services.Agent.SkillService.LoadSkills( app?.SettingsService?.Settings.Llm.SkillsFolderPath, - ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm)); + ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm), + app?.SettingsService?.Settings.Llm.AdditionalSkillFolders); }); } @@ -454,7 +455,12 @@ public partial class SettingsWindow : Window if (AgentEtcContent == null) return; using var registry = Services.Agent.ToolRegistry.CreateDefault(); - var toolGroups = registry.All + var app = System.Windows.Application.Current as App; + var visibleTools = AgentToolCatalog.FilterExposureByPermission( + registry.All, + tool => tool.Name, + app?.SettingsService?.Settings.Llm.ToolPermissions); + var toolGroups = visibleTools .Select(tool => new { Tool = tool, Meta = AgentToolCatalog.GetMetadata(tool.Name) }) .GroupBy(x => x.Meta.SettingsCategory, StringComparer.OrdinalIgnoreCase) .Select(group => @@ -639,7 +645,7 @@ public partial class SettingsWindow : Window // 설명 skillItems.Add(new TextBlock { - Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/추가 스킬, 프로젝트 `.claude/skills`가 함께 포함됩니다.\n" + + Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/공용 스킬, 플러그인 스킬, 프로젝트 `.claude/skills`, 레거시 command markdown가 함께 포함됩니다.\n" + "(직접 호출 스킬과 자동/조건부 보조 스킬을 함께 표시합니다.)", FontSize = 11, Foreground = new SolidColorBrush(subtleText), @@ -649,8 +655,14 @@ public partial class SettingsWindow : Window var bundled = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList(); var project = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList(); + var plugin = skills.Where(s => string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)).ToList(); + var additional = skills.Where(s => string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase)).ToList(); + var legacy = skills.Where(s => string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase)).ToList(); var custom = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) + && !string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase) + && !string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase) + && !string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(s.Requires)).ToList(); var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList(); @@ -667,6 +679,24 @@ public partial class SettingsWindow : Window skillItems.Add(card); } + if (plugin.Count > 0) + { + var card = CreateSkillGroupCard("플러그인 스킬", "\uECCA", "#EC4899", plugin); + skillItems.Add(card); + } + + if (additional.Count > 0) + { + var card = CreateSkillGroupCard("보조/공용 스킬", "\uE71D", "#0EA5E9", additional); + skillItems.Add(card); + } + + if (legacy.Count > 0) + { + var card = CreateSkillGroupCard("레거시 명령 스킬", "\uE8A5", "#8B5CF6", legacy); + skillItems.Add(card); + } + if (custom.Count > 0) { var card = CreateSkillGroupCard("사용자/추가 스킬", "\uE70F", "#F59E0B", custom); diff --git a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs index 6b558e6..5d0af9a 100644 --- a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs @@ -504,7 +504,7 @@ public partial class SkillEditorWindow : Window TxtName.Text = skill.Name; TxtLabel.Text = skill.Label; TxtDescription.Text = skill.Description; - TxtInstructions.Text = skill.SystemPrompt; + TxtInstructions.Text = SkillService.GetSkillPromptForDisplay(skill); // 아이콘 선택 _selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0]; diff --git a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs index 946d520..9d446d4 100644 --- a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs @@ -619,7 +619,7 @@ public partial class SkillGalleryWindow : Window CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 10, 12, 10), }; - var promptText = skill.SystemPrompt; + var promptText = SkillService.GetSkillPromptForDisplay(skill); if (promptText.Length > 2000) promptText = promptText[..2000] + "\n\n... (이하 생략)";