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

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

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