diff --git a/README.md b/README.md index db04781..7f40e52 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 19:13 (KST) +- `claude-code` 기준 Phase 4 범위를 이어서 반영했습니다. MCP 서버 메타데이터를 `mcp` 스코프의 synthetic skill로 노출하는 [McpSkillCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/McpSkillCatalog.cs)를 추가했고, 스킬 source 정책은 `managed/user/additional/project/plugin/mcp/legacy` 단위로 켜고 끌 수 있게 확장했습니다. +- 슬래시 명령 합성도 정리했습니다. [SlashCommandCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SlashCommandCatalog.cs)와 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)가 이제 builtin command와 skill을 우선순위 기반으로 dedupe해 같은 `/명령`이 겹칠 때 더 안정적으로 하나만 노출합니다. +- 일반 설정과 AX Agent 내부 설정의 스킬 섹션에도 source 정책 토글을 추가했고, [SkillGalleryWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs)는 MCP 카테고리/배지를 지원하도록 보강했습니다. synthetic MCP 스킬처럼 실제 파일이 없는 항목은 편집/복제/내보내기 액션을 숨겨 런타임 오류도 피합니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4\\ -p:IntermediateOutputPath=obj\\verify_phase4\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SkillServiceRuntimePolicyTests|SlashCommandCatalogTests|McpSkillCatalogTests" -p:OutputPath=bin\\verify_phase4_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4_tests\\` 통과 17 +- 참고: 테스트 프로젝트의 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` nullable 경고 1건은 유지됩니다. + - 업데이트: 2026-04-14 19:16 (KST) - 분석용 로그 저장 방식을 롤링 형태로 정리했습니다. `app`, `perf`, `audit`, `workflow` 로그는 이제 날짜별 파일을 유지하되 각 파일이 최대 1MB를 넘지 않도록 오래된 내용부터 밀어내며 새 로그를 이어 붙입니다. - 보관 정책도 같이 정리했습니다. 공통 로그/성능 로그/감사 로그는 14일까지만 유지하고, 워크플로우 상세 로그는 기존 설정값을 따르되 최대 14일을 넘지 않게 제한합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a55054f..d726782 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,9 +1,19 @@ # AX Copilot - 개발 문서 -> 최종 업데이트: 2026-04-13 · 버전 0.7.3 +> 최종 업데이트: 2026-04-14 19:13 (KST) · 버전 0.7.3 ## 업데이트 로그 +- 업데이트: 2026-04-14 19:13 (KST) +- `claude-code` 기준 Phase 4를 이어서 반영했습니다. `src/AxCopilot/Services/Agent/McpSkillCatalog.cs`를 추가해 MCP 서버 메타데이터를 `mcp` source scope의 synthetic skill로 변환하고, `ToolRegistry.RegisterMcpToolsAsync()` 이후 snapshot을 갱신하도록 연결했습니다. +- `src/AxCopilot/Services/Agent/SkillService.cs`는 source policy를 `managed/user/additional/project/plugin/mcp/legacy` 단위로 판단하도록 확장했고, source 우선순위 기반 dedupe와 inline shell trust boundary를 함께 적용합니다. plugin-only mode가 켜져 있으면 managed/plugin/bundled만 유지하고 나머지 source는 숨깁니다. +- 슬래시 명령 합성은 `src/AxCopilot/Views/SlashCommandCatalog.cs`와 `src/AxCopilot/Views/ChatWindow.xaml.cs`에서 재구성했습니다. builtin command와 skill을 공통 priority로 합성해 충돌 시 한 항목만 노출하고, builtin `/review` 같은 예약 명령이 project skill보다 안정적으로 우선합니다. +- 설정/UI는 `src/AxCopilot/Views/SettingsWindow.xaml`, `src/AxCopilot/Views/AgentSettingsWindow.xaml`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`, `src/AxCopilot/Views/SkillGalleryWindow.xaml.cs`에 연결했습니다. MCP 스킬 source 토글, plugin-only mode, source별 inline shell 허용 범위, MCP 카테고리/배지, synthetic skill의 파일 액션 차단을 함께 반영했습니다. +- 테스트는 `src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs`, `src/AxCopilot.Tests/Services/McpSkillCatalogTests.cs`, `src/AxCopilot.Tests/Views/SlashCommandCatalogTests.cs`에 추가했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4\\ -p:IntermediateOutputPath=obj\\verify_phase4\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SkillServiceRuntimePolicyTests|SlashCommandCatalogTests|McpSkillCatalogTests" -p:OutputPath=bin\\verify_phase4_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4_tests\\` 통과 17 +- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)`의 nullable 경고 1건은 유지됩니다. + - 업데이트: 2026-04-14 17:46 (KST) - 도구 이름 정합성 문제를 줄이기 위해 `src/AxCopilot/Services/Agent/AgentToolCatalog.cs`를 추가했습니다. canonical id, legacy alias, 탭 노출, 설정 카테고리, 병렬 read-only 분류를 한곳에서 관리하도록 정리했습니다. - `ToolRegistry`, `AgentLoopService`, `AgentLoopParallelExecution`, `IAgentTool`, `AgentHookRunner`, `SkillService`가 모두 같은 카탈로그를 사용하도록 연결했습니다. 이에 따라 `git/lsp/zip/project_rule/snippet_run` 같은 예전 이름도 런타임에서 자동 정규화됩니다. diff --git a/src/AxCopilot.Tests/Services/McpSkillCatalogTests.cs b/src/AxCopilot.Tests/Services/McpSkillCatalogTests.cs new file mode 100644 index 0000000..c8e6972 --- /dev/null +++ b/src/AxCopilot.Tests/Services/McpSkillCatalogTests.cs @@ -0,0 +1,59 @@ +using AxCopilot.Models; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class McpSkillCatalogTests +{ + [Fact] + public void BuildSyntheticSkills_ShouldCreateMcpScopedSkill_ForEnabledServer() + { + var skills = McpSkillCatalog.BuildSyntheticSkills( + [ + new McpServerEntry + { + Name = "chrome-devtools", + Enabled = true, + Transport = "stdio", + Command = "npx" + }, + new McpServerEntry + { + Name = "disabled-server", + Enabled = false + } + ]); + + skills.Should().ContainSingle(); + var skill = skills[0]; + skill.Name.Should().Be("mcp:chrome-devtools"); + skill.SourceScope.Should().Be("mcp"); + skill.Label.Should().Be("chrome-devtools MCP"); + skill.AllowedTools.Should().Contain("mcp_list_resources"); + skill.AllowedTools.Should().Contain("mcp_read_resource"); + skill.SystemPrompt.Should().Contain("Use the MCP server"); + } + + [Fact] + public void ComputeSignature_ShouldReflectEnabledServerConfiguration() + { + var servers = new[] + { + new McpServerEntry + { + Name = "docs", + Enabled = true, + Transport = "sse", + Url = "https://intra.example.local/mcp/sse" + } + }; + + var signature = McpSkillCatalog.ComputeSignature(servers); + + signature.Should().Contain("docs"); + signature.Should().Contain("sse"); + signature.Should().Contain("https://intra.example.local/mcp/sse"); + } +} diff --git a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs index 1cc3eab..f0cd572 100644 --- a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs @@ -1,4 +1,5 @@ using AxCopilot.Services.Agent; +using AxCopilot.Models; using FluentAssertions; using Xunit; using System; @@ -303,4 +304,60 @@ public class SkillServiceRuntimePolicyTests try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } + + [Fact] + public void IsSkillSourceEnabled_ShouldRespectSourceFlags_AndPluginOnlyMode() + { + var llm = new LlmSettings + { + EnableManagedSkillSource = true, + EnableUserSkillSource = false, + EnableAdditionalSkillDiscovery = false, + EnableProjectSkillDiscovery = true, + EnablePluginSkillDiscovery = true, + EnableMcpSkillDiscovery = false, + EnableLegacyCommandSkills = false, + EnablePluginOnlySkillMode = false, + }; + + SkillService.IsSkillSourceEnabled("managed", llm).Should().BeTrue(); + SkillService.IsSkillSourceEnabled("user", llm).Should().BeFalse(); + SkillService.IsSkillSourceEnabled("additional", llm).Should().BeFalse(); + SkillService.IsSkillSourceEnabled("project", llm).Should().BeTrue(); + SkillService.IsSkillSourceEnabled("mcp", llm).Should().BeFalse(); + SkillService.IsSkillSourceEnabled("legacy", llm).Should().BeFalse(); + + llm.EnablePluginOnlySkillMode = true; + + SkillService.IsSkillSourceEnabled("managed", llm).Should().BeTrue(); + SkillService.IsSkillSourceEnabled("plugin", llm).Should().BeTrue(); + SkillService.IsSkillSourceEnabled("project", llm).Should().BeFalse(); + SkillService.IsSkillSourceEnabled("mcp", llm).Should().BeFalse(); + SkillService.IsSkillSourceEnabled("bundled", llm).Should().BeTrue(); + } + + [Fact] + public void GetInlineShellBlockedReason_ShouldRespectSourceTrustBoundaries() + { + var llm = new LlmSettings + { + EnableSkillInlineShell = true, + AllowPluginSkillInlineShell = false, + AllowMcpSkillInlineShell = false, + }; + + var pluginSkill = new SkillDefinition { Name = "plugin-a", SourceScope = "plugin" }; + var mcpSkill = new SkillDefinition { Name = "mcp-a", SourceScope = "mcp" }; + var projectSkill = new SkillDefinition { Name = "project-a", SourceScope = "project" }; + + SkillService.GetInlineShellBlockedReason(pluginSkill, llm).Should().Be("[inline-shell blocked for plugin skills]"); + SkillService.GetInlineShellBlockedReason(mcpSkill, llm).Should().Be("[inline-shell blocked for mcp skills]"); + SkillService.GetInlineShellBlockedReason(projectSkill, llm).Should().BeNull(); + + llm.AllowPluginSkillInlineShell = true; + llm.AllowMcpSkillInlineShell = true; + + SkillService.GetInlineShellBlockedReason(pluginSkill, llm).Should().BeNull(); + SkillService.GetInlineShellBlockedReason(mcpSkill, llm).Should().BeNull(); + } } diff --git a/src/AxCopilot.Tests/Views/SlashCommandCatalogTests.cs b/src/AxCopilot.Tests/Views/SlashCommandCatalogTests.cs index 454f46f..fc4b749 100644 --- a/src/AxCopilot.Tests/Views/SlashCommandCatalogTests.cs +++ b/src/AxCopilot.Tests/Views/SlashCommandCatalogTests.cs @@ -28,4 +28,25 @@ public class SlashCommandCatalogTests SlashCommandCatalog.TryGetEntry("/mcp", out var mcpEntry).Should().BeTrue(); mcpEntry.SystemPrompt.Should().Be("__MCP__"); } + + [Fact] + public void ComposeMatches_ShouldPreferHigherPriorityEntry_WhenCommandCollides() + { + var matches = SlashCommandCatalog.ComposeMatches( + [ + ("/review", "Project Review Skill", true, 900), + ("/review", "Code Review", false, 2100), + ("/verify-change", "Verify Skill", true, 600) + ]); + + matches.Should().ContainSingle(x => x.Cmd == "/review"); + matches.Should().Contain(x => x.Cmd == "/review" && x.IsSkill == false && x.Label == "Code Review"); + matches.Should().Contain(x => x.Cmd == "/verify-change" && x.IsSkill); + } + + [Fact] + public void GetBuiltInCommandPriority_ShouldGiveDevCommandsHigherPriority() + { + SlashCommandCatalog.GetBuiltInCommandPriority("/review").Should().BeGreaterThan(SlashCommandCatalog.GetBuiltInCommandPriority("/clear")); + } } diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 8c153e0..c12eb8a 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -1296,18 +1296,39 @@ public class LlmSettings [JsonPropertyName("additionalSkillFolders")] public List AdditionalSkillFolders { get; set; } = new(); + [JsonPropertyName("enableManagedSkillSource")] + public bool EnableManagedSkillSource { get; set; } = true; + + [JsonPropertyName("enableUserSkillSource")] + public bool EnableUserSkillSource { get; set; } = true; + + [JsonPropertyName("enableAdditionalSkillDiscovery")] + public bool EnableAdditionalSkillDiscovery { get; set; } = true; + [JsonPropertyName("enableProjectSkillDiscovery")] public bool EnableProjectSkillDiscovery { get; set; } = true; [JsonPropertyName("enablePluginSkillDiscovery")] public bool EnablePluginSkillDiscovery { get; set; } = true; + [JsonPropertyName("enableMcpSkillDiscovery")] + public bool EnableMcpSkillDiscovery { get; set; } = true; + + [JsonPropertyName("enablePluginOnlySkillMode")] + public bool EnablePluginOnlySkillMode { get; set; } = false; + [JsonPropertyName("enableLegacyCommandSkills")] public bool EnableLegacyCommandSkills { get; set; } = true; [JsonPropertyName("enableSkillInlineShell")] public bool EnableSkillInlineShell { get; set; } = true; + [JsonPropertyName("allowPluginSkillInlineShell")] + public bool AllowPluginSkillInlineShell { get; set; } = false; + + [JsonPropertyName("allowMcpSkillInlineShell")] + public bool AllowMcpSkillInlineShell { get; set; } = false; + [JsonPropertyName("skillInlineShellTimeoutSeconds")] public int SkillInlineShellTimeoutSeconds { get; set; } = 8; diff --git a/src/AxCopilot/Services/Agent/McpSkillCatalog.cs b/src/AxCopilot/Services/Agent/McpSkillCatalog.cs new file mode 100644 index 0000000..7ccf43e --- /dev/null +++ b/src/AxCopilot/Services/Agent/McpSkillCatalog.cs @@ -0,0 +1,257 @@ +using System.Text; +using System.Text.RegularExpressions; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +internal static class McpSkillCatalog +{ + private static readonly object s_gate = new(); + private static readonly Dictionary s_snapshots = new(StringComparer.OrdinalIgnoreCase); + + public static void RefreshFromClients(IEnumerable clients) + { + if (clients == null) + return; + + lock (s_gate) + { + foreach (var client in clients) + { + if (client == null || string.IsNullOrWhiteSpace(client.ServerName)) + continue; + + var serverName = client.ServerName.Trim(); + s_snapshots[serverName] = new McpServerSnapshot( + serverName, + client.Tools + .Select(tool => new McpToolDefinition + { + Name = tool.Name, + Description = tool.Description, + ServerName = tool.ServerName, + Parameters = tool.Parameters.ToDictionary( + pair => pair.Key, + pair => new McpParameterDef + { + Type = pair.Value.Type, + Description = pair.Value.Description, + Required = pair.Value.Required, + }, + StringComparer.OrdinalIgnoreCase), + }) + .ToList() + .AsReadOnly(), + client.Resources + .Select(resource => new McpResourceDefinition + { + Uri = resource.Uri, + Name = resource.Name, + Description = resource.Description, + MimeType = resource.MimeType, + ServerName = resource.ServerName, + }) + .ToList() + .AsReadOnly(), + DateTime.UtcNow); + } + } + } + + public static IReadOnlyList BuildSyntheticSkills(IEnumerable? servers) + { + if (servers == null) + return []; + + var snapshots = GetSnapshotMap(); + var skills = new List(); + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var server in servers + .Where(server => server != null && server.Enabled) + .OrderBy(server => server.Name, StringComparer.OrdinalIgnoreCase)) + { + var displayName = string.IsNullOrWhiteSpace(server.Name) ? "mcp-server" : server.Name.Trim(); + var skillName = BuildUniqueSkillName(displayName, usedNames); + snapshots.TryGetValue(displayName, out var snapshot); + + var allowedTools = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "mcp_list_resources", + "mcp_read_resource" + }; + + if (snapshot != null) + { + foreach (var tool in snapshot.Tools) + allowedTools.Add($"mcp_{tool.ServerName}_{tool.Name}"); + } + + skills.Add(new SkillDefinition + { + Id = skillName, + Name = skillName, + Label = $"{displayName} MCP", + Description = BuildDescription(displayName, snapshot), + SystemPrompt = BuildPromptBody(displayName, server, snapshot), + FilePath = $"[mcp]/{displayName}", + SkillRoot = "", + SourceScope = "mcp", + AllowedTools = string.Join(", ", allowedTools.OrderBy(tool => tool, StringComparer.OrdinalIgnoreCase)), + WhenToUse = BuildWhenToUse(displayName, snapshot), + Tabs = "all", + UserInvocable = true, + IsAvailable = true, + }); + } + + return skills; + } + + public static string ComputeSignature(IEnumerable? servers) + { + var snapshots = GetSnapshotMap(); + var parts = new List(); + var orderedServers = (servers ?? []) + .Where(server => server != null) + .OrderBy(server => server.Name, StringComparer.OrdinalIgnoreCase); + foreach (var server in orderedServers) + { + var name = string.IsNullOrWhiteSpace(server.Name) ? "(unnamed)" : server.Name.Trim(); + snapshots.TryGetValue(name, out var snapshot); + parts.Add(string.Join(":", + name, + server.Enabled ? "1" : "0", + server.Transport ?? "stdio", + server.Command ?? "", + server.Url ?? "", + snapshot?.Tools.Count ?? 0, + snapshot?.Resources.Count ?? 0, + snapshot?.UpdatedAtUtc.Ticks ?? 0)); + } + + return string.Join("|", parts); + } + + private static IReadOnlyDictionary GetSnapshotMap() + { + lock (s_gate) + return new Dictionary(s_snapshots, StringComparer.OrdinalIgnoreCase); + } + + private static string BuildDescription(string displayName, McpServerSnapshot? snapshot) + { + if (snapshot == null) + return $"{displayName} MCP 서버의 도구와 리소스를 탐색하는 보조 스킬입니다."; + + var toolCount = snapshot.Tools.Count; + var resourceCount = snapshot.Resources.Count; + if (toolCount == 0 && resourceCount == 0) + return $"{displayName} MCP 서버에 연결되었지만 아직 노출된 도구나 리소스가 없습니다."; + + return $"{displayName} MCP 서버의 도구 {toolCount}개와 리소스 {resourceCount}개를 우선 활용하도록 돕습니다."; + } + + private static string BuildWhenToUse(string displayName, McpServerSnapshot? snapshot) + { + if (snapshot == null || (snapshot.Tools.Count == 0 && snapshot.Resources.Count == 0)) + return $"{displayName} MCP 서버가 제공하는 리소스나 도구를 먼저 확인해야 하는 작업"; + + var keywords = snapshot.Resources + .SelectMany(resource => new[] { resource.Name, resource.Description, resource.MimeType }) + .Concat(snapshot.Tools.SelectMany(tool => new[] { tool.Name, tool.Description })) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!.Trim()) + .Take(4) + .ToList(); + + return keywords.Count == 0 + ? $"{displayName} MCP 서버의 도구와 리소스가 필요한 작업" + : $"{displayName} MCP 서버의 {string.Join(", ", keywords)} 관련 정보를 찾거나 실행해야 하는 작업"; + } + + private static string BuildPromptBody(string displayName, McpServerEntry server, McpServerSnapshot? snapshot) + { + var lines = new List + { + $"Use the MCP server \"{displayName}\" as the first source of truth when it is relevant.", + $"Transport: {NormalizeTransport(server.Transport)}", + $"1. Start with `mcp_list_resources(server_name: \"{displayName}\")` to discover available resources.", + "2. Read only the specific resources you need with `mcp_read_resource`.", + "3. If server-specific MCP tools are available, prefer the smallest relevant call before broader exploration.", + "4. Summarize the MCP evidence you used and clearly note any missing data." + }; + + if (snapshot == null) + { + lines.Add(string.Empty); + lines.Add("This session does not yet have a cached MCP metadata snapshot, so inspect the live resource list first."); + return string.Join(Environment.NewLine, lines); + } + + if (snapshot.Tools.Count > 0) + { + lines.Add(string.Empty); + lines.Add("Available server-specific MCP tools:"); + foreach (var tool in snapshot.Tools.Take(8)) + { + var description = string.IsNullOrWhiteSpace(tool.Description) ? "" : $" - {tool.Description}"; + lines.Add($"- mcp_{tool.ServerName}_{tool.Name}{description}"); + } + } + + if (snapshot.Resources.Count > 0) + { + lines.Add(string.Empty); + lines.Add("Known MCP resources:"); + foreach (var resource in snapshot.Resources.Take(8)) + { + var descriptor = new StringBuilder(); + descriptor.Append($"- {resource.Name}"); + if (!string.IsNullOrWhiteSpace(resource.MimeType)) + descriptor.Append($" ({resource.MimeType})"); + if (!string.IsNullOrWhiteSpace(resource.Description)) + descriptor.Append($" - {resource.Description}"); + lines.Add(descriptor.ToString()); + } + } + + return string.Join(Environment.NewLine, lines); + } + + private static string BuildUniqueSkillName(string displayName, ISet usedNames) + { + var baseName = $"mcp:{NormalizeToken(displayName)}"; + var current = baseName; + var suffix = 2; + while (!usedNames.Add(current)) + { + current = $"{baseName}-{suffix}"; + suffix++; + } + + return current; + } + + private static string NormalizeToken(string input) + { + var normalized = Regex.Replace((input ?? "").Trim().ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-'); + return string.IsNullOrWhiteSpace(normalized) ? "server" : normalized; + } + + private static string NormalizeTransport(string? transport) + { + return (transport ?? "stdio").Trim().ToLowerInvariant() switch + { + "sse" => "server-sent events", + _ => "stdio", + }; + } + + private sealed record McpServerSnapshot( + string ServerName, + IReadOnlyList Tools, + IReadOnlyList Resources, + DateTime UpdatedAtUtc); +} diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs index 67ad2a5..0d35678 100644 --- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs +++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs @@ -60,7 +60,7 @@ public class SkillManagerTool : IAgentTool { var skills = SkillService.Skills; if (skills.Count == 0) - return ToolResult.Ok("로드된 스킬이 없습니다. 번들 스킬, %APPDATA%\\AxCopilot\\skills\\, 추가 스킬 폴더, 프로젝트 .claude\\skills\\를 확인하세요."); + return ToolResult.Ok("로드된 스킬이 없습니다. 번들 스킬, %APPDATA%\\AxCopilot\\skills\\, 추가 스킬 폴더, 프로젝트 .claude\\skills\\, MCP 스킬 소스를 확인하세요."); var sb = new StringBuilder(); sb.AppendLine($"사용 가능한 스킬 ({skills.Count}개):\n"); diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index bc25321..05452cb 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -33,18 +33,25 @@ public static class SkillService /// 스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다. public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable? additionalFolders = null) { + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; var normalizedCustomFolder = NormalizeExistingDirectory(customFolder); var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot); var normalizedAdditionalFolders = NormalizeDistinctDirectories(additionalFolders); var sources = BuildSkillSources(normalizedCustomFolder, normalizedProjectRoot, normalizedAdditionalFolders).ToList(); - var loadSignature = ComputeLoadSignature(sources); + var mcpSkills = BuildMcpSkills(llm?.McpServers, llm); + var loadSignature = ComputeLoadSignature(sources, llm, mcpSkills); if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase)) return; - var allSkills = GetBundledSkills().ToList(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var bundled in allSkills) - seen.Add(bundled.Name); + var candidates = new List(); + var order = 0; + + foreach (var bundled in GetBundledSkills()) + candidates.Add(new SkillLoadCandidate(order++, bundled)); + + foreach (var mcpSkill in mcpSkills) + candidates.Add(new SkillLoadCandidate(order++, mcpSkill)); foreach (var source in sources) { @@ -57,8 +64,8 @@ public static class SkillService 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); + if (skill != null) + candidates.Add(new SkillLoadCandidate(order++, skill)); } catch (Exception ex) { @@ -67,6 +74,17 @@ public static class SkillService } } + var allSkills = candidates + .GroupBy(candidate => candidate.Skill.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(candidate => GetSkillSourcePriority(candidate.Skill.SourceScope)) + .ThenBy(candidate => candidate.LoadOrder) + .Select(candidate => candidate.Skill) + .First()) + .OrderByDescending(skill => GetSkillSourcePriority(skill.SourceScope)) + .ThenBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + // 런타임 의존성 검증 foreach (var skill in allSkills) { @@ -338,6 +356,8 @@ public static class SkillService void AddSource(string? directory, string scope, SkillSourceKind kind = SkillSourceKind.SkillFolder) { + if (!IsSkillSourceEnabled(scope, llm)) + return; if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) return; @@ -350,8 +370,11 @@ public static class SkillService AddSource(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"), "user"); AddSource(customFolder, "custom"); - foreach (var folder in additionalFolders) - AddSource(folder, "additional"); + if (llm?.EnableAdditionalSkillDiscovery ?? true) + { + foreach (var folder in additionalFolders) + AddSource(folder, "additional"); + } if (llm?.EnablePluginSkillDiscovery ?? true) { @@ -374,7 +397,10 @@ public static class SkillService return sources; } - private static string ComputeLoadSignature(IEnumerable sources) + private static string ComputeLoadSignature( + IEnumerable sources, + Models.LlmSettings? llm, + IReadOnlyList mcpSkills) { var parts = new List(); foreach (var source in sources) @@ -388,9 +414,68 @@ public static class SkillService parts.Add($"{source.Kind}:{source.Scope}:{source.Directory}:{files.Count}:{latestWrite}"); } + parts.Add($"skill-policy:{llm?.EnableManagedSkillSource ?? true}:{llm?.EnableUserSkillSource ?? true}:{llm?.EnableAdditionalSkillDiscovery ?? true}:{llm?.EnableProjectSkillDiscovery ?? true}:{llm?.EnablePluginSkillDiscovery ?? true}:{llm?.EnableMcpSkillDiscovery ?? true}:{llm?.EnablePluginOnlySkillMode ?? false}:{llm?.EnableLegacyCommandSkills ?? true}"); + parts.Add($"mcp:{McpSkillCatalog.ComputeSignature(llm?.McpServers)}:{mcpSkills.Count}"); return string.Join("|", parts); } + private static IReadOnlyList BuildMcpSkills( + IEnumerable? servers, + Models.LlmSettings? llm) + { + if (!IsSkillSourceEnabled("mcp", llm)) + return []; + + return McpSkillCatalog.BuildSyntheticSkills(servers); + } + + internal static bool IsSkillSourceEnabled(string scope, Models.LlmSettings? llm) + { + if (string.IsNullOrWhiteSpace(scope)) + return true; + + var normalized = scope.Trim().ToLowerInvariant(); + if (normalized == "bundled") + return true; + + if (llm == null) + return true; + + if (llm.EnablePluginOnlySkillMode) + { + return normalized is "managed" or "plugin"; + } + + return normalized switch + { + "managed" => llm.EnableManagedSkillSource, + "user" or "custom" => llm.EnableUserSkillSource, + "additional" => llm.EnableAdditionalSkillDiscovery, + "project" => llm.EnableProjectSkillDiscovery, + "plugin" => llm.EnablePluginSkillDiscovery, + "mcp" => llm.EnableMcpSkillDiscovery, + "legacy" => llm.EnableLegacyCommandSkills, + _ => true, + }; + } + + internal static int GetSkillSourcePriority(string? sourceScope) + { + return (sourceScope ?? "").Trim().ToLowerInvariant() switch + { + "project" => 900, + "mcp" => 850, + "plugin" => 800, + "additional" => 760, + "custom" => 740, + "user" => 700, + "managed" => 650, + "bundled" => 600, + "legacy" => 550, + _ => 500, + }; + } + private static IEnumerable EnumerateProjectSkillFolders(string? projectRoot) { foreach (var root in EnumerateAncestorDirectories(projectRoot)) @@ -527,7 +612,57 @@ public static class SkillService 완료한 작업, 남은 이슈, 주의할 파일, 다음 권장 액션을 분리해서 작성하세요. """, whenToUse: "작업 마무리, 인수인계, 세션 요약, 다음 액션 정리가 필요할 때", - allowedTools: "git_tool, file_read, task_list") + allowedTools: "git_tool, file_read, task_list"), + CreateBundledSkill( + "stuck-recovery", + "막힘 복구", + "막힌 작업을 다시 전진시키기 위한 원인 정리와 우회 경로를 제안합니다.", + """ + 현재 작업이 멈춘 이유를 입력, 환경, 도구, 정책, 검증 결과 관점에서 나눠 분석하세요. + 즉시 시도할 수 있는 우회 경로와, 사용자의 결정을 받아야 하는 항목을 분리해 정리하세요. + """, + whenToUse: "반복 실패, 권한 차단, 환경 문제, 막힘 복구가 필요한 작업", + allowedTools: "file_read, grep, build_run, process, task_list"), + CreateBundledSkill( + "context-memory", + "컨텍스트 메모리", + "지속적으로 참고해야 할 사실과 후속 체크포인트를 짧게 정리합니다.", + """ + 현재 작업에서 다음 반복이나 다음 세션에도 유지해야 할 핵심 사실만 추려 정리하세요. + 파일 경로, 결정 사항, 미해결 리스크, 다음 확인 지점을 짧은 메모 형식으로 요약하세요. + """, + whenToUse: "세션이 길어지거나, 다음 반복을 위해 핵심 사실을 압축해 남겨야 할 때", + allowedTools: "memory, file_read, task_list"), + CreateBundledSkill( + "config-tune", + "설정 튜닝", + "현재 설정/정책 상태를 바탕으로 실행 품질과 안전성을 조정합니다.", + """ + 현재 작업 목적에 맞춰 설정을 점검하고, 변경이 필요한 항목과 그대로 둬야 하는 항목을 구분하세요. + 성능, 권한, 스킬 source, 도구 노출 관점에서 추천 변경안을 우선순위로 제시하세요. + """, + whenToUse: "권한, 스킬 source, 도구 노출, 자동화 설정을 조정해야 할 때", + allowedTools: "skill_manager, env_tool, mcp_list_resources"), + CreateBundledSkill( + "change-guard", + "변경 가드", + "수정 전에 영향 범위와 회귀 가능성을 빠르게 짚어줍니다.", + """ + 변경 대상의 영향 범위, 연관 파일, 테스트 누락 가능성을 먼저 정리하세요. + 실제 수정 전에 어떤 확인이 필요한지와 수정 후 어떤 검증을 해야 하는지를 함께 제시하세요. + """, + whenToUse: "수정 전에 영향 범위와 회귀 리스크를 빠르게 훑어야 할 때", + allowedTools: "file_read, grep, lsp_code_intel, folder_map"), + CreateBundledSkill( + "delivery-pack", + "전달 패키지", + "배포/공유 직전 필요한 결과물과 커뮤니케이션 패키지를 정리합니다.", + """ + 배포 또는 공유 직전에 필요한 요약, 변경 포인트, 검증 상태, 사용자 전달 문구를 정리하세요. + 문서, PPT, 보고서가 함께 필요한 경우 어떤 산출물을 어떤 순서로 만들지 제안하세요. + """, + whenToUse: "배포 직전, 보고 직전, 문서/PPT와 함께 전달 패키지를 정리해야 할 때", + allowedTools: "document_plan, document_assemble, pptx_create, docx_create, markdown_create") ]; } @@ -1172,13 +1307,7 @@ public static class SkillService score += 4; if (!skill.UserInvocable) score += 1; - score += skill.SourceScope switch - { - "project" => 3, - "plugin" => 2, - "bundled" => 1, - _ => 0, - }; + score += Math.Max(0, GetSkillSourcePriority(skill.SourceScope) / 200); if (skill.DisableModelInvocation) score += 1; @@ -1338,11 +1467,14 @@ public static class SkillService return input; var result = input; + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; + var blockedReason = GetInlineShellBlockedReason(skill, llm); foreach (Match match in matches.Cast().Reverse()) { ct.ThrowIfCancellationRequested(); var command = match.Groups[1].Value.Trim(); - var output = await ExecuteInlineShellAsync(command, shellKind, workFolder, ct).ConfigureAwait(false); + var output = blockedReason ?? await ExecuteInlineShellAsync(command, shellKind, workFolder, ct).ConfigureAwait(false); result = result.Remove(match.Index, match.Length).Insert(match.Index, output); } @@ -1425,6 +1557,19 @@ public static class SkillService } } + internal static string? GetInlineShellBlockedReason(SkillDefinition skill, Models.LlmSettings? llm) + { + if (llm is { EnableSkillInlineShell: false }) + return "[inline-shell disabled by settings]"; + + return (skill.SourceScope ?? "").Trim().ToLowerInvariant() switch + { + "plugin" when !(llm?.AllowPluginSkillInlineShell ?? false) => "[inline-shell blocked for plugin skills]", + "mcp" when !(llm?.AllowMcpSkillInlineShell ?? false) => "[inline-shell blocked for mcp skills]", + _ => null, + }; + } + private static string NormalizeEffort(string raw) { var effort = raw.Trim().ToLowerInvariant(); @@ -1734,6 +1879,8 @@ public sealed record CompiledSkillInvocation( string SystemPrompt, string DisplayText); +internal sealed record SkillLoadCandidate(int LoadOrder, SkillDefinition Skill); + internal sealed record SkillSourceDescriptor(string Directory, string Scope, SkillSourceKind Kind); internal enum SkillSourceKind diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs index 0f23a5b..fec1a4a 100644 --- a/src/AxCopilot/Services/Agent/ToolRegistry.cs +++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs @@ -53,6 +53,7 @@ public class ToolRegistry : IDisposable } } + McpSkillCatalog.RefreshFromClients(_mcpClients.Values); return registered; } diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 5a24a38..797d30a 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -546,6 +546,27 @@ public class SettingsViewModel : INotifyPropertyChanged set { _additionalSkillFoldersText = value; OnPropertyChanged(); } } + private bool _enableManagedSkillSource = true; + public bool EnableManagedSkillSource + { + get => _enableManagedSkillSource; + set { _enableManagedSkillSource = value; OnPropertyChanged(); } + } + + private bool _enableUserSkillSource = true; + public bool EnableUserSkillSource + { + get => _enableUserSkillSource; + set { _enableUserSkillSource = value; OnPropertyChanged(); } + } + + private bool _enableAdditionalSkillDiscovery = true; + public bool EnableAdditionalSkillDiscovery + { + get => _enableAdditionalSkillDiscovery; + set { _enableAdditionalSkillDiscovery = value; OnPropertyChanged(); } + } + private bool _enableProjectSkillDiscovery = true; public bool EnableProjectSkillDiscovery { @@ -560,6 +581,20 @@ public class SettingsViewModel : INotifyPropertyChanged set { _enablePluginSkillDiscovery = value; OnPropertyChanged(); } } + private bool _enableMcpSkillDiscovery = true; + public bool EnableMcpSkillDiscovery + { + get => _enableMcpSkillDiscovery; + set { _enableMcpSkillDiscovery = value; OnPropertyChanged(); } + } + + private bool _enablePluginOnlySkillMode; + public bool EnablePluginOnlySkillMode + { + get => _enablePluginOnlySkillMode; + set { _enablePluginOnlySkillMode = value; OnPropertyChanged(); } + } + private bool _enableLegacyCommandSkills = true; public bool EnableLegacyCommandSkills { @@ -574,6 +609,20 @@ public class SettingsViewModel : INotifyPropertyChanged set { _enableSkillInlineShell = value; OnPropertyChanged(); } } + private bool _allowPluginSkillInlineShell; + public bool AllowPluginSkillInlineShell + { + get => _allowPluginSkillInlineShell; + set { _allowPluginSkillInlineShell = value; OnPropertyChanged(); } + } + + private bool _allowMcpSkillInlineShell; + public bool AllowMcpSkillInlineShell + { + get => _allowMcpSkillInlineShell; + set { _allowMcpSkillInlineShell = value; OnPropertyChanged(); } + } + private int _skillInlineShellTimeoutSeconds = 8; public int SkillInlineShellTimeoutSeconds { @@ -1259,10 +1308,17 @@ public class SettingsViewModel : INotifyPropertyChanged _enableForkSkillDelegationEnforcement = llm.EnableForkSkillDelegationEnforcement; _skillsFolderPath = llm.SkillsFolderPath; _additionalSkillFoldersText = string.Join(Environment.NewLine, llm.AdditionalSkillFolders ?? new List()); + _enableManagedSkillSource = llm.EnableManagedSkillSource; + _enableUserSkillSource = llm.EnableUserSkillSource; + _enableAdditionalSkillDiscovery = llm.EnableAdditionalSkillDiscovery; _enableProjectSkillDiscovery = llm.EnableProjectSkillDiscovery; _enablePluginSkillDiscovery = llm.EnablePluginSkillDiscovery; + _enableMcpSkillDiscovery = llm.EnableMcpSkillDiscovery; + _enablePluginOnlySkillMode = llm.EnablePluginOnlySkillMode; _enableLegacyCommandSkills = llm.EnableLegacyCommandSkills; _enableSkillInlineShell = llm.EnableSkillInlineShell; + _allowPluginSkillInlineShell = llm.AllowPluginSkillInlineShell; + _allowMcpSkillInlineShell = llm.AllowMcpSkillInlineShell; _skillInlineShellTimeoutSeconds = Math.Clamp(llm.SkillInlineShellTimeoutSeconds, 1, 30); _skillInlineShellMaxOutputChars = Math.Clamp(llm.SkillInlineShellMaxOutputChars, 200, 20000); _slashPopupPageSize = llm.SlashPopupPageSize > 0 ? Math.Clamp(llm.SlashPopupPageSize, 3, 10) : 6; @@ -1716,10 +1772,17 @@ public class SettingsViewModel : INotifyPropertyChanged s.Llm.EnableForkSkillDelegationEnforcement = _enableForkSkillDelegationEnforcement; s.Llm.SkillsFolderPath = _skillsFolderPath; s.Llm.AdditionalSkillFolders = ParseAdditionalSkillFolders(_additionalSkillFoldersText); + s.Llm.EnableManagedSkillSource = _enableManagedSkillSource; + s.Llm.EnableUserSkillSource = _enableUserSkillSource; + s.Llm.EnableAdditionalSkillDiscovery = _enableAdditionalSkillDiscovery; s.Llm.EnableProjectSkillDiscovery = _enableProjectSkillDiscovery; s.Llm.EnablePluginSkillDiscovery = _enablePluginSkillDiscovery; + s.Llm.EnableMcpSkillDiscovery = _enableMcpSkillDiscovery; + s.Llm.EnablePluginOnlySkillMode = _enablePluginOnlySkillMode; s.Llm.EnableLegacyCommandSkills = _enableLegacyCommandSkills; s.Llm.EnableSkillInlineShell = _enableSkillInlineShell; + s.Llm.AllowPluginSkillInlineShell = _allowPluginSkillInlineShell; + s.Llm.AllowMcpSkillInlineShell = _allowMcpSkillInlineShell; s.Llm.SkillInlineShellTimeoutSeconds = _skillInlineShellTimeoutSeconds; s.Llm.SkillInlineShellMaxOutputChars = _skillInlineShellMaxOutputChars; s.Llm.SlashPopupPageSize = _slashPopupPageSize; diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml b/src/AxCopilot/Views/AgentSettingsWindow.xaml index 0f5e6bb..9946fff 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml @@ -869,6 +869,42 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> + + + + + + + + + + + + + + + + + + + + + + + + @@ -893,6 +929,30 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> + + + + + + + + + + + + + + + + @@ -917,6 +977,30 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> + + + + + + + + + + + + + + + + @@ -1053,7 +1137,7 @@ FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource PrimaryText}"/> - diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs index ba9ebd6..12c98fa 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs @@ -66,10 +66,17 @@ public partial class AgentSettingsWindow : Window ChkEnableToolHooks.IsChecked = _llm.EnableToolHooks; ChkEnableHookInputMutation.IsChecked = _llm.EnableHookInputMutation; ChkEnableHookPermissionUpdate.IsChecked = _llm.EnableHookPermissionUpdate; + ChkEnableManagedSkillSource.IsChecked = _llm.EnableManagedSkillSource; + ChkEnableUserSkillSource.IsChecked = _llm.EnableUserSkillSource; + ChkEnableAdditionalSkillDiscovery.IsChecked = _llm.EnableAdditionalSkillDiscovery; ChkEnableProjectSkillDiscovery.IsChecked = _llm.EnableProjectSkillDiscovery; ChkEnablePluginSkillDiscovery.IsChecked = _llm.EnablePluginSkillDiscovery; + ChkEnableMcpSkillDiscovery.IsChecked = _llm.EnableMcpSkillDiscovery; + ChkEnablePluginOnlySkillMode.IsChecked = _llm.EnablePluginOnlySkillMode; ChkEnableLegacyCommandSkills.IsChecked = _llm.EnableLegacyCommandSkills; ChkEnableSkillInlineShell.IsChecked = _llm.EnableSkillInlineShell; + ChkAllowPluginSkillInlineShell.IsChecked = _llm.AllowPluginSkillInlineShell; + ChkAllowMcpSkillInlineShell.IsChecked = _llm.AllowMcpSkillInlineShell; ChkEnableCoworkVerification.IsChecked = _llm.EnableCoworkVerification; ChkEnableProjectRules.IsChecked = _llm.EnableProjectRules; ChkEnableAgentMemory.IsChecked = _llm.EnableAgentMemory; @@ -550,10 +557,17 @@ public partial class AgentSettingsWindow : Window _llm.EnableToolHooks = ChkEnableToolHooks.IsChecked == true; _llm.EnableHookInputMutation = ChkEnableHookInputMutation.IsChecked == true; _llm.EnableHookPermissionUpdate = ChkEnableHookPermissionUpdate.IsChecked == true; + _llm.EnableManagedSkillSource = ChkEnableManagedSkillSource.IsChecked == true; + _llm.EnableUserSkillSource = ChkEnableUserSkillSource.IsChecked == true; + _llm.EnableAdditionalSkillDiscovery = ChkEnableAdditionalSkillDiscovery.IsChecked == true; _llm.EnableProjectSkillDiscovery = ChkEnableProjectSkillDiscovery.IsChecked == true; _llm.EnablePluginSkillDiscovery = ChkEnablePluginSkillDiscovery.IsChecked == true; + _llm.EnableMcpSkillDiscovery = ChkEnableMcpSkillDiscovery.IsChecked == true; + _llm.EnablePluginOnlySkillMode = ChkEnablePluginOnlySkillMode.IsChecked == true; _llm.EnableLegacyCommandSkills = ChkEnableLegacyCommandSkills.IsChecked == true; _llm.EnableSkillInlineShell = ChkEnableSkillInlineShell.IsChecked == true; + _llm.AllowPluginSkillInlineShell = ChkAllowPluginSkillInlineShell.IsChecked == true; + _llm.AllowMcpSkillInlineShell = ChkAllowMcpSkillInlineShell.IsChecked == true; _llm.EnableCoworkVerification = ChkEnableCoworkVerification.IsChecked == true; _llm.EnableProjectRules = ChkEnableProjectRules.IsChecked == true; _llm.EnableAgentMemory = ChkEnableAgentMemory.IsChecked == true; @@ -656,7 +670,7 @@ public partial class AgentSettingsWindow : Window Padding = new Thickness(12, 10, 12, 10), Child = new TextBlock { - Text = "로드된 스킬이 없습니다. 기본/추가 스킬 폴더 또는 프로젝트 `.claude/skills` 아래에 `.skill.md`나 `SKILL.md`를 추가한 뒤 저장하면 다시 불러옵니다.", + Text = "로드된 스킬이 없습니다. 기본/추가 스킬 폴더, 프로젝트 `.claude/skills`, 연결된 MCP 스킬 소스를 확인한 뒤 다시 불러오세요.", FontSize = 11, TextWrapping = TextWrapping.Wrap, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, @@ -671,10 +685,11 @@ public partial class AgentSettingsWindow : Window new { Title = "기본 제공 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "managed", 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, "plugin", StringComparison.OrdinalIgnoreCase)).ToList() }, + new { Title = "MCP 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "mcp", 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, "managed", 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() }, + new { Title = "사용자 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "managed", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "mcp", 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) && !string.Equals(s.SourceScope, "mcp", StringComparison.OrdinalIgnoreCase)).ToList() }, }; foreach (var group in groups) diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 71f0884..7ed6d4b 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -3529,8 +3529,13 @@ public partial class ChatWindow : Window // 탭별 필터링: Chat → "all"만, Cowork/Code → "all" + "dev" bool isDev = _activeTab is "Cowork" or "Code"; - // 내장 슬래시 명령어 매칭 (탭 필터) - var matches = SlashCommandCatalog.MatchBuiltinCommands(text, isDev); + var slashEntries = SlashCommandCatalog.MatchBuiltinCommands(text, isDev) + .Select(match => ( + match.Cmd, + match.Label, + match.IsSkill, + Priority: SlashCommandCatalog.GetBuiltInCommandPriority(match.Cmd))) + .ToList(); // 스킬 슬래시 명령어 매칭 (탭별 필터) if (_settings.Settings.Llm.EnableSkillSystem) @@ -3540,11 +3545,12 @@ public partial class ChatWindow : Window .Where(s => s.IsVisibleInTab(_activeTab)) .Select(s => (Cmd: "/" + s.Name, Label: BuildSlashSkillLabel(s), - IsSkill: true, Available: s.IsAvailable)); - foreach (var sm in skillMatches) - matches.Add((sm.Cmd, sm.Label, sm.IsSkill)); + IsSkill: true, + Priority: SkillService.GetSkillSourcePriority(s.SourceScope))); + slashEntries.AddRange(skillMatches); } + var matches = SlashCommandCatalog.ComposeMatches(slashEntries); if (matches.Count > 0) { _slashPalette.Matches = matches; diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 6f0efb6..8f48bce 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4364,6 +4364,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4386,6 +4419,28 @@ + + + + + + + + + + + + + + + + + + + + @@ -4408,6 +4463,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs index 3e0e58d..c2c3b36 100644 --- a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs @@ -147,6 +147,7 @@ public partial class SkillGalleryWindow : Window "기본 제공" => skills.Where(IsBuiltInSkill).ToList(), "프로젝트" => skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList(), "플러그인" => skills.Where(s => string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)).ToList(), + "MCP" => skills.Where(s => string.Equals(s.SourceScope, "mcp", StringComparison.OrdinalIgnoreCase)).ToList(), "고급 (런타임)" => skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList(), "사용자" => skills.Where(IsUserOwnedSkill).ToList(), _ => skills.ToList(), @@ -164,6 +165,8 @@ public partial class SkillGalleryWindow : Window var isBuiltIn = IsBuiltInSkill(skill); var isProject = string.Equals(skill.SourceScope, "project", StringComparison.OrdinalIgnoreCase); var isPlugin = string.Equals(skill.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase); + var isMcp = string.Equals(skill.SourceScope, "mcp", StringComparison.OrdinalIgnoreCase); + var hasBackingFile = !string.IsNullOrWhiteSpace(skill.FilePath) && File.Exists(skill.FilePath); var card = new Border { @@ -231,6 +234,8 @@ public partial class SkillGalleryWindow : Window nameRow.Children.Add(MakeBadge("프로젝트", "#2563EB")); else if (isPlugin) nameRow.Children.Add(MakeBadge("플러그인", "#EC4899")); + else if (isMcp) + nameRow.Children.Add(MakeBadge("MCP", "#14B8A6")); else if (isUser) nameRow.Children.Add(MakeBadge("사용자", "#34D399")); else if (isAdvanced) @@ -266,62 +271,68 @@ public partial class SkillGalleryWindow : Window }; // 편집 - actions.Children.Add(MakeActionBtn("\uE70F", "#3B82F6", isUser ? "편집 (시각적 편집기)" : "편집 (파일 열기)", - () => - { - if (isUser) + if (isUser || hasBackingFile) + { + actions.Children.Add(MakeActionBtn("\uE70F", "#3B82F6", isUser ? "편집 (시각적 편집기)" : "편집 (파일 열기)", + () => { - var editor = new SkillEditorWindow(skill) { Owner = this }; - if (editor.ShowDialog() == true) + if (isUser) { + var editor = new SkillEditorWindow(skill) { Owner = this }; + if (editor.ShowDialog() == true) + { + SkillService.ReloadFromCurrentSettings(); + BuildCategoryFilter(); + RenderSkills(); + } + } + else + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(skill.FilePath) { UseShellExecute = true }); } + catch (Exception ex) { CustomMessageBox.Show($"파일을 열 수 없습니다: {ex.Message}", "편집"); } + } + })); + } + + // 복제 (파일 기반 스킬만) + if (hasBackingFile) + { + actions.Children.Add(MakeActionBtn("\uE8C8", "#10B981", "복제", + () => + { + try + { + var destFolder = Path.Combine(userFolder); + if (!Directory.Exists(destFolder)) Directory.CreateDirectory(destFolder); + + var srcName = Path.GetFileNameWithoutExtension(skill.FilePath); + var destPath = Path.Combine(destFolder, $"{srcName}_copy.skill.md"); + var counter = 2; + while (File.Exists(destPath)) + destPath = Path.Combine(destFolder, $"{srcName}_copy{counter++}.skill.md"); + + File.Copy(skill.FilePath, destPath); SkillService.ReloadFromCurrentSettings(); BuildCategoryFilter(); RenderSkills(); } - } - else + catch (Exception ex) { CustomMessageBox.Show($"복제 실패: {ex.Message}", "복제"); } + })); + + // 내보내기 + actions.Children.Add(MakeActionBtn("\uEDE1", "#F59E0B", "내보내기 (.zip)", + () => { - try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(skill.FilePath) { UseShellExecute = true }); } - catch (Exception ex) { CustomMessageBox.Show($"파일을 열 수 없습니다: {ex.Message}", "편집"); } - } - })); - - // 복제 (사용자 스킬/폴더 스킬만) - actions.Children.Add(MakeActionBtn("\uE8C8", "#10B981", "복제", - () => - { - try - { - var destFolder = Path.Combine(userFolder); - if (!Directory.Exists(destFolder)) Directory.CreateDirectory(destFolder); - - var srcName = Path.GetFileNameWithoutExtension(skill.FilePath); - var destPath = Path.Combine(destFolder, $"{srcName}_copy.skill.md"); - var counter = 2; - while (File.Exists(destPath)) - destPath = Path.Combine(destFolder, $"{srcName}_copy{counter++}.skill.md"); - - File.Copy(skill.FilePath, destPath); - SkillService.ReloadFromCurrentSettings(); - BuildCategoryFilter(); - RenderSkills(); - } - catch (Exception ex) { CustomMessageBox.Show($"복제 실패: {ex.Message}", "복제"); } - })); - - // 내보내기 - actions.Children.Add(MakeActionBtn("\uEDE1", "#F59E0B", "내보내기 (.zip)", - () => - { - var folderDlg = new System.Windows.Forms.FolderBrowserDialog - { Description = "내보낼 폴더를 선택하세요" }; - if (folderDlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; - var result = SkillService.ExportSkill(skill, folderDlg.SelectedPath); - if (result != null) - CustomMessageBox.Show($"내보내기 완료:\n{result}", "내보내기"); - else - CustomMessageBox.Show("내보내기에 실패했습니다.", "내보내기"); - })); + var folderDlg = new System.Windows.Forms.FolderBrowserDialog + { Description = "내보낼 폴더를 선택하세요" }; + if (folderDlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + var result = SkillService.ExportSkill(skill, folderDlg.SelectedPath); + if (result != null) + CustomMessageBox.Show($"내보내기 완료:\n{result}", "내보내기"); + else + CustomMessageBox.Show("내보내기에 실패했습니다.", "내보내기"); + })); + } // 삭제 (사용자 스킬만) if (isUser) @@ -405,6 +416,8 @@ public partial class SkillGalleryWindow : Window yield return "프로젝트"; if (skills.Any(s => string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase))) yield return "플러그인"; + if (skills.Any(s => string.Equals(s.SourceScope, "mcp", StringComparison.OrdinalIgnoreCase))) + yield return "MCP"; if (skills.Any(s => !string.IsNullOrEmpty(s.Requires))) yield return "고급 (런타임)"; if (skills.Any(IsUserOwnedSkill)) @@ -422,7 +435,8 @@ public partial class SkillGalleryWindow : Window string.Equals(skill.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase) || (!IsBuiltInSkill(skill) && !string.Equals(skill.SourceScope, "project", StringComparison.OrdinalIgnoreCase) - && !string.Equals(skill.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)); + && !string.Equals(skill.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase) + && !string.Equals(skill.SourceScope, "mcp", StringComparison.OrdinalIgnoreCase)); private Border MakeActionBtn(string icon, string colorHex, string tooltip, Action action) { diff --git a/src/AxCopilot/Views/SlashCommandCatalog.cs b/src/AxCopilot/Views/SlashCommandCatalog.cs index a674100..922d4bf 100644 --- a/src/AxCopilot/Views/SlashCommandCatalog.cs +++ b/src/AxCopilot/Views/SlashCommandCatalog.cs @@ -1,4 +1,8 @@ -namespace AxCopilot.Views; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AxCopilot.Views; internal static class SlashCommandCatalog { @@ -108,6 +112,37 @@ internal static class SlashCommandCatalog .ToList(); } + internal static int GetBuiltInCommandPriority(string commandToken) + { + if (!Commands.TryGetValue(commandToken, out var entry)) + return 2000; + + return string.Equals(entry.Tab, "dev", StringComparison.OrdinalIgnoreCase) + ? 2100 + : 2000; + } + + internal static List<(string Cmd, string Label, bool IsSkill)> ComposeMatches( + IEnumerable<(string Cmd, string Label, bool IsSkill, int Priority)> entries) + { + if (entries == null) + return []; + + return entries + .Where(entry => !string.IsNullOrWhiteSpace(entry.Cmd)) + .GroupBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(entry => entry.Priority) + .ThenBy(entry => entry.IsSkill ? 1 : 0) + .ThenBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) + .First()) + .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill, entry.Priority)) + .OrderByDescending(entry => entry.Priority) + .ThenBy(entry => entry.Cmd, StringComparer.OrdinalIgnoreCase) + .Select(entry => (entry.Cmd, entry.Label, entry.IsSkill)) + .ToList(); + } + internal static bool TryGetEntry(string commandToken, out (string Label, string SystemPrompt, string Tab) entry) => Commands.TryGetValue(commandToken, out entry); }