AX Agent ?? ?? ??? MCP ?? ??? ???? ??? ??? ??

- MCP ?? ?????? synthetic skill? ???? McpSkillCatalog? ???? ToolRegistry ?? snapshot ?? ??? ???
- managed/user/additional/project/plugin/mcp/legacy ?? source ??, plugin-only ??, source? inline shell trust boundary? SkillService/AppSettings/Settings UI? ???
- SlashCommandCatalog? ChatWindow?? builtin command? skill? ???? ???? ??? ?? ? ????? dedupe?? MCP ???? ? synthetic skill ?? ??? SkillGallery/AgentSettings? ???
- README.md? docs/DEVELOPMENT.md? 2026-04-14 19:13 (KST) ?? ?? ??? ?? ??? ???
- ??: 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, ?? WorkspaceContextGeneratorTests.cs nullable ?? 1? ??)
This commit is contained in:
2026-04-14 19:15:12 +09:00
parent 3747a92c12
commit 946c31e275
17 changed files with 956 additions and 81 deletions

View File

@@ -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일을 넘지 않게 제한합니다.

View File

@@ -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` 같은 예전 이름도 런타임에서 자동 정규화됩니다.

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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"));
}
}

View File

@@ -1296,18 +1296,39 @@ public class LlmSettings
[JsonPropertyName("additionalSkillFolders")]
public List<string> 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;

View File

@@ -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<string, McpServerSnapshot> s_snapshots = new(StringComparer.OrdinalIgnoreCase);
public static void RefreshFromClients(IEnumerable<McpClientService> 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<SkillDefinition> BuildSyntheticSkills(IEnumerable<McpServerEntry>? servers)
{
if (servers == null)
return [];
var snapshots = GetSnapshotMap();
var skills = new List<SkillDefinition>();
var usedNames = new HashSet<string>(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<string>(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<McpServerEntry>? servers)
{
var snapshots = GetSnapshotMap();
var parts = new List<string>();
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<string, McpServerSnapshot> GetSnapshotMap()
{
lock (s_gate)
return new Dictionary<string, McpServerSnapshot>(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<string>
{
$"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<string> 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<McpToolDefinition> Tools,
IReadOnlyList<McpResourceDefinition> Resources,
DateTime UpdatedAtUtc);
}

View File

@@ -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");

View File

@@ -33,18 +33,25 @@ public static class SkillService
/// <summary>스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable<string>? 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<string>(StringComparer.OrdinalIgnoreCase);
foreach (var bundled in allSkills)
seen.Add(bundled.Name);
var candidates = new List<SkillLoadCandidate>();
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");
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<SkillSourceDescriptor> sources)
private static string ComputeLoadSignature(
IEnumerable<SkillSourceDescriptor> sources,
Models.LlmSettings? llm,
IReadOnlyList<SkillDefinition> mcpSkills)
{
var parts = new List<string>();
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<SkillDefinition> BuildMcpSkills(
IEnumerable<Models.McpServerEntry>? 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<string> 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<Match>().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

View File

@@ -53,6 +53,7 @@ public class ToolRegistry : IDisposable
}
}
McpSkillCatalog.RefreshFromClients(_mcpClients.Values);
return registered;
}

View File

@@ -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<string>());
_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;

View File

@@ -869,6 +869,42 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="기본 제공 스킬 소스"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkEnableManagedSkillSource"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="사용자 스킬 소스"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkEnableUserSkillSource"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="보조 스킬 폴더 탐색"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkEnableAdditionalSkillDiscovery"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -893,6 +929,30 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="MCP 스킬 탐색"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkEnableMcpSkillDiscovery"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="플러그인 전용 모드"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkEnablePluginOnlySkillMode"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -917,6 +977,30 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="플러그인 inline shell 허용"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkAllowPluginSkillInlineShell"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="MCP inline shell 허용"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"/>
<CheckBox x:Name="ChkAllowMcpSkillInlineShell"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<StackPanel Margin="0,12,0,0">
<TextBlock Text="보조 스킬 폴더 목록"
Foreground="{DynamicResource PrimaryText}"/>
@@ -1053,7 +1137,7 @@
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="현재 AX Agent에서 사용할 수 있는 슬래시 스킬 목록입니다."
<TextBlock Text="현재 AX Agent에서 사용할 수 있는 슬래시 스킬 목록입니다. 프로젝트 `.claude/skills`와 MCP 스킬 소스도 함께 반영됩니다."
Margin="0,4,0,8"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"/>

View File

@@ -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)

View File

@@ -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;

View File

@@ -4364,6 +4364,39 @@
</StackPanel>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="기본 제공 스킬 소스"/>
<TextBlock Style="{StaticResource RowHint}" Text="앱에 함께 배포되는 관리형 스킬 파일 소스를 로드합니다. 번들 스킬은 항상 유지됩니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableManagedSkillSource, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="사용자 스킬 소스"/>
<TextBlock Style="{StaticResource RowHint}" Text="%APPDATA%\AxCopilot\skills\와 직접 지정한 기본 스킬 폴더를 함께 로드합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableUserSkillSource, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="보조 스킬 폴더 탐색"/>
<TextBlock Style="{StaticResource RowHint}" Text="보조 스킬 폴더 목록에 입력한 공용/팀 스킬 폴더를 실제 로딩 대상에 포함합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableAdditionalSkillDiscovery, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
@@ -4386,6 +4419,28 @@
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="MCP 스킬 탐색"/>
<TextBlock Style="{StaticResource RowHint}" Text="연결된 MCP 서버의 리소스/도구 메타데이터를 보조 스킬처럼 노출합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableMcpSkillDiscovery, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="플러그인 전용 스킬 모드"/>
<TextBlock Style="{StaticResource RowHint}" Text="활성화하면 기본 제공/플러그인 스킬만 노출하고 다른 소스는 숨깁니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnablePluginOnlySkillMode, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
@@ -4408,6 +4463,28 @@
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="플러그인 inline shell 허용"/>
<TextBlock Style="{StaticResource RowHint}" Text="플러그인 소스 스킬에 한해 inline shell 실행을 별도로 허용합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding AllowPluginSkillInlineShell, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="MCP inline shell 허용"/>
<TextBlock Style="{StaticResource RowHint}" Text="MCP 소스 스킬에는 기본적으로 inline shell을 막고, 필요 시에만 별도 허용합니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding AllowMcpSkillInlineShell, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left">

View File

@@ -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,6 +271,8 @@ public partial class SkillGalleryWindow : Window
};
// 편집
if (isUser || hasBackingFile)
{
actions.Children.Add(MakeActionBtn("\uE70F", "#3B82F6", isUser ? "편집 (시각적 편집기)" : "편집 (파일 열기)",
() =>
{
@@ -285,8 +292,11 @@ public partial class SkillGalleryWindow : Window
catch (Exception ex) { CustomMessageBox.Show($"파일을 열 수 없습니다: {ex.Message}", "편집"); }
}
}));
}
// 복제 (사용자 스킬/폴더 스킬만)
// 복제 (파일 기반 스킬만)
if (hasBackingFile)
{
actions.Children.Add(MakeActionBtn("\uE8C8", "#10B981", "복제",
() =>
{
@@ -322,6 +332,7 @@ public partial class SkillGalleryWindow : Window
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)
{

View File

@@ -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);
}