스킬 런타임 2차 고도화와 도구 노출 필터 정비

프로젝트 .claude/skills 재귀 로드와 namespaced SKILL.md 파싱을 추가하고 번들/사용자/프로젝트 스킬을 함께 노출하도록 SkillService와 설정 UI를 확장했다.

슬래시 스킬 호출 시 인자 치환, 스킬 폴더 변수 치환, inline shell 실행, when_to_use 기반 자동 스킬 가이드를 실제 ChatWindow 런타임 경로에 연결했다.

blanket deny 권한은 모델 노출 전 활성 도구 목록에서 먼저 제외하도록 AgentLoopService를 보강했고 관련 테스트와 README/DEVELOPMENT 문서를 업데이트했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\ (경고 0 / 오류 0)
검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\ (통과 16, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지)
This commit is contained in:
2026-04-14 18:10:16 +09:00
parent 8cb08576d5
commit b17c865c4e
12 changed files with 805 additions and 81 deletions

View File

@@ -7,6 +7,15 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md`
- 업데이트: 2026-04-14 18:08 (KST)
- 스킬 런타임을 `claude-code` 동등 품질 기준으로 한 단계 더 끌어올렸습니다. 이제 작업 폴더 기준의 프로젝트 `.claude/skills`를 재귀 스캔해 namespaced `SKILL.md`를 로드하고, 번들 스킬/사용자 스킬/프로젝트 스킬을 함께 노출합니다.
- 슬래시 스킬 호출도 실제 실행 경로에 맞게 확장했습니다. `/skill args...` 호출 시 `$ARGUMENTS`, named argument placeholder, 스킬 폴더 변수 치환을 적용하고, inline shell 블록은 호출 시점의 작업 폴더에서만 실행해 프롬프트를 조립합니다.
- 자동/조건부 스킬도 엔진에 연결했습니다. `when_to_use`, `paths`, `user-invocable` 메타데이터를 기준으로 보조 스킬을 선택해 일반 대화 요청에도 필요한 가이드를 붙이고, 오버레이/설정 화면도 같은 분류를 기준으로 직접 호출/자동 스킬을 보여줍니다.
- 도구 노출 단계도 보강했습니다. blanket deny 상태인 도구는 실행 시점 차단만 하는 것이 아니라 모델에 전달하는 활성 도구 목록에서 먼저 제외해 불필요한 도구 제안과 권한 소음을 줄였습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\` 통과 16
- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs`의 nullable 경고 1건은 그대로 남아 있으며, 이번 변경에서 새 경고는 추가하지 않았습니다.
- 업데이트: 2026-04-14 17:46 (KST)
- 도구 이름과 내부설정의 결합도를 낮추기 위해 `AgentToolCatalog` 기반의 중앙 메타데이터 레이어를 추가했습니다. 이제 레거시 도구 이름(`git`, `lsp`, `zip`, `project_rule` 등)은 canonical id로 정규화되어 런타임, 권한, 병렬 분류, 설정 화면이 같은 이름 체계를 사용합니다.
- 설정 화면도 같은 카탈로그를 보도록 정리했습니다. Agent 설정/일반 설정의 도구 카드와 훅 편집기는 canonical 이름을 기준으로 표시하고 저장하며, 기존 `disabledTools`, 훅 대상 도구, 도구 권한 키는 호환 alias를 통해 자동 흡수합니다.

View File

@@ -799,3 +799,11 @@ UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복
| `docs/TOOL_PARITY_REPORT.md` | 도구 호환성 리포트 |
| `docs/AX_AGENT_UI_CHECKLIST.md` | 에이전트 UI 체크리스트 |
| `docs/UI_UX_CHECKLIST.md` | UI/UX 체크리스트 |
> 업데이트: 2026-04-14 18:08 (KST)
> - 스킬 시스템 Phase 2 1~6번을 반영했습니다. `SkillService`는 프로젝트 `.claude/skills` 재귀 로드, namespaced `SKILL.md`, 번들 스킬 주입, `$ARGUMENTS`/named args/스킬 폴더 변수 치환, inline shell block 실행까지 지원하도록 확장했습니다.
> - `ChatWindow` 런타임 경로도 함께 정리했습니다. 슬래시 호출은 `BuildSlashInvocationAsync`를 통해 컴파일된 스킬 프롬프트를 사용하고, 일반 대화는 `when_to_use`/`paths`/`user-invocable` 메타데이터를 바탕으로 선택된 자동 스킬 가이드를 보조 시스템 프롬프트로 붙입니다.
> - 설정/UI 연결도 새 스킬 모델 기준으로 맞췄습니다. Agent 설정, 일반 설정, 오버레이, 스킬 관리자 도구는 번들/프로젝트/사용자 스킬 분류와 프로젝트 `.claude/skills` 경로를 반영해 설명과 리스트를 구성합니다.
> - 도구 노출 단계는 `AgentLoopService.GetRuntimeActiveTools()`에서 blanket deny 권한을 먼저 적용하도록 보강했습니다. 패턴 기반 규칙은 call-time 검사를 유지하고, 단순 deny 도구는 모델 노출 전 필터링으로 정리했습니다.
> - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\` 경고 0 / 오류 0
> - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\` 통과 16
> - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다.

View File

@@ -78,7 +78,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Hooks.Should().Contain("lint-pre");
parsed.Hooks.Should().Contain("verify-pre");
@@ -119,7 +119,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Hooks.Should().Contain("lint-pre");
parsed.Hooks.Should().Contain("verify-post");
@@ -153,7 +153,7 @@ public class SkillServiceRuntimePolicyTests
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
var parsed = method!.Invoke(null, [skillPath, tempDir]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.IsSample.Should().BeTrue();
}
@@ -162,4 +162,103 @@ public class SkillServiceRuntimePolicyTests
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public void ParseSkillFile_AssignsNamespacedName_ForProjectSkillFolder()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-namespace-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "release", "notes");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
description: release notes helper
---
body
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var parsed = method!.Invoke(null, [skillPath, Path.Combine(tempDir, ".claude", "skills")]) as SkillDefinition;
parsed.Should().NotBeNull();
parsed!.Name.Should().Be("release:notes");
parsed.SourceScope.Should().Be("project");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public async Task BuildSlashInvocationAsync_ReplacesArguments_AndSkillDirectoryTokens()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-args-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "release");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
argument-hint: <version> <audience>
---
Release $version for $audience from ${AX_SKILL_DIR}
Raw: $ARGUMENTS
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
SkillService.LoadSkills(projectRoot: tempDir);
var compiled = await SkillService.BuildSlashInvocationAsync("/release 1.2.3 qa-team", tempDir);
compiled.Should().NotBeNull();
compiled!.Skill.Name.Should().Be("release");
compiled.SystemPrompt.Should().Contain("Release 1.2.3 for qa-team");
compiled.SystemPrompt.Should().Contain(skillDir.Replace('\\', '/'));
compiled.SystemPrompt.Should().Contain("Raw: 1.2.3 qa-team");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public void GetAutoSkills_IncludesWhenToUseDrivenSkills()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-auto-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "triage");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
user-invocable: false
tabs: code
when_to_use: build failure test error
---
Investigate failures.
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
SkillService.LoadSkills(projectRoot: tempDir);
var autoSkills = SkillService.GetAutoSkills("Code");
autoSkills.Should().Contain(skill => skill.Name == "triage");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
}

View File

@@ -2010,6 +2010,7 @@ public partial class AgentLoopService
var mergedDisabled = MergeDisabledTools(disabledToolNames);
// 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약)
var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled);
active = ApplyPermissionExposureFilter(active);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active;
@@ -2019,6 +2020,51 @@ public partial class AgentLoopService
.AsReadOnly();
}
private IReadOnlyCollection<IAgentTool> ApplyPermissionExposureFilter(IReadOnlyCollection<IAgentTool> tools)
{
var blanketDeniedTools = GetBlanketDeniedToolNames();
if (blanketDeniedTools.Count == 0)
return tools;
if (blanketDeniedTools.Contains("*"))
return Array.Empty<IAgentTool>();
return tools
.Where(tool => !blanketDeniedTools.Contains(tool.Name))
.ToList()
.AsReadOnly();
}
private HashSet<string> GetBlanketDeniedToolNames()
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permissionMap = AgentToolCatalog.CanonicalizePermissionMap(_settings.Settings.Llm.ToolPermissions ?? new Dictionary<string, string>());
foreach (var kv in permissionMap)
{
if (!PermissionModeCatalog.IsDeny(kv.Value))
continue;
var key = kv.Key?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(key))
continue;
if (key.IndexOfAny(['@', '|', '(', ')']) >= 0)
continue;
if (string.Equals(key, "*", StringComparison.Ordinal))
{
result.Add("*");
return result;
}
var canonical = AgentToolCatalog.Canonicalize(key);
if (!string.IsNullOrWhiteSpace(canonical))
result.Add(canonical);
}
return result;
}
private IEnumerable<string> MergeDisabledTools(IEnumerable<string>? disabledToolNames)
{
var disabled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

View File

@@ -1,6 +1,8 @@
using System.Text;
using System.Text.Json;
using System.IO;
namespace AxCopilot.Services.Agent;
/// <summary>
@@ -58,7 +60,7 @@ public class SkillManagerTool : IAgentTool
{
var skills = SkillService.Skills;
if (skills.Count == 0)
return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요.");
return ToolResult.Ok("로드된 스킬이 없습니다. 번들 스킬, %APPDATA%\\AxCopilot\\skills\\, 추가 스킬 폴더, 프로젝트 .claude\\skills\\를 확인하세요.");
var sb = new StringBuilder();
sb.AppendLine($"사용 가능한 스킬 ({skills.Count}개):\n");
@@ -79,7 +81,7 @@ public class SkillManagerTool : IAgentTool
sb.AppendLine($" hook-filters: {skill.HookFilters}");
sb.AppendLine();
}
sb.AppendLine("슬래시 명령어(/{name})로 호출하거나, 대화에서 해당 워크플로우를 요청할 수 있습니다.");
sb.AppendLine("슬래시 명령어(/{name})로 직접 호출하거나, 조건에 맞는 자동/보조 스킬로 함께 활용할 수 있습니다.");
return ToolResult.Ok(sb.ToString());
}
@@ -125,8 +127,30 @@ public class SkillManagerTool : IAgentTool
private static ToolResult ReloadSkills(App? app)
{
var customFolder = app?.SettingsService?.Settings.Llm.SkillsFolderPath ?? "";
SkillService.LoadSkills(customFolder);
var llm = app?.SettingsService?.Settings.Llm;
var customFolder = llm?.SkillsFolderPath ?? "";
SkillService.LoadSkills(customFolder, ResolveProjectRoot(llm));
return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨.");
}
private static string? ResolveProjectRoot(Models.LlmSettings? llm)
{
if (llm == null)
return null;
var candidates = new[]
{
llm.CodeWorkFolder,
llm.CoworkWorkFolder,
llm.WorkFolder
};
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate))
return candidate.Trim();
}
return null;
}
}

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
@@ -14,14 +15,22 @@ public static class SkillService
{
private static List<SkillDefinition> _skills = new();
private static string _lastFolder = "";
private static string _lastProjectRoot = "";
private static string _lastLoadSignature = "";
private static readonly HashSet<string> _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase);
/// <summary>로드된 스킬 목록.</summary>
public static IReadOnlyList<SkillDefinition> Skills => _skills;
/// <summary>스킬 폴더에서 *.skill.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null)
/// <summary>스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null, string? projectRoot = null)
{
var normalizedCustomFolder = NormalizeExistingDirectory(customFolder);
var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot);
var loadSignature = $"{normalizedCustomFolder}|{normalizedProjectRoot}";
if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase))
return;
var folders = new List<string>();
// 1) 앱 기본 스킬 폴더
@@ -35,20 +44,29 @@ public static class SkillService
if (Directory.Exists(appDataFolder)) folders.Add(appDataFolder);
// 3) 사용자 지정 폴더
if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder))
folders.Add(customFolder);
if (!string.IsNullOrEmpty(normalizedCustomFolder))
folders.Add(normalizedCustomFolder);
var allSkills = new List<SkillDefinition>();
// 4) 프로젝트 스킬 폴더 (.claude/skills)
if (!string.IsNullOrEmpty(normalizedProjectRoot))
{
var projectSkillsFolder = Path.Combine(normalizedProjectRoot, ".claude", "skills");
if (Directory.Exists(projectSkillsFolder))
folders.Add(projectSkillsFolder);
}
var allSkills = GetBundledSkills().ToList();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var bundled in allSkills)
seen.Add(bundled.Name);
foreach (var folder in folders)
{
// 1) 기존 형식: *.skill.md 파일
foreach (var file in Directory.GetFiles(folder, "*.skill.md"))
foreach (var file in EnumerateSkillFiles(folder))
{
try
{
var skill = ParseSkillFile(file);
var skill = ParseSkillFile(file, folder);
if (skill != null && seen.Add(skill.Name))
allSkills.Add(skill);
}
@@ -57,27 +75,6 @@ public static class SkillService
LogService.Warn($"스킬 로드 실패 [{file}]: {ex.Message}");
}
}
// 2) SKILL.md 표준: 하위폴더/SKILL.md 구조
try
{
foreach (var subDir in Directory.GetDirectories(folder))
{
var skillMd = Path.Combine(subDir, "SKILL.md");
if (!File.Exists(skillMd)) continue;
try
{
var skill = ParseSkillFile(skillMd);
if (skill != null && seen.Add(skill.Name))
allSkills.Add(skill);
}
catch (Exception ex)
{
LogService.Warn($"스킬 로드 실패 [{skillMd}]: {ex.Message}");
}
}
}
catch { /* 폴더 접근 오류 무시 */ }
}
// 런타임 의존성 검증
@@ -91,7 +88,9 @@ public static class SkillService
}
_skills = allSkills;
_lastFolder = customFolder ?? "";
_lastFolder = normalizedCustomFolder ?? "";
_lastProjectRoot = normalizedProjectRoot ?? "";
_lastLoadSignature = loadSignature;
_activeConditionalSkillNames.Clear();
var unavailCount = allSkills.Count(s => !s.IsAvailable);
LogService.Info($"스킬 {allSkills.Count}개 로드 완료" +
@@ -129,6 +128,70 @@ public static class SkillService
return null;
}
public static async Task<CompiledSkillInvocation?> BuildSlashInvocationAsync(
string input,
string workFolder,
CancellationToken ct = default)
{
var matchedSkill = MatchSlashInvocation(input);
if (matchedSkill == null)
return null;
var slashCmd = "/" + matchedSkill.Name;
var rawArgs = input[slashCmd.Length..].Trim();
var compiledPrompt = await CompileSkillPromptAsync(matchedSkill, rawArgs, workFolder, ct).ConfigureAwait(false);
var runtimePolicy = BuildRuntimeDirective(matchedSkill);
var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy)
? compiledPrompt
: $"{compiledPrompt}\n\n{runtimePolicy}";
return new CompiledSkillInvocation(
matchedSkill,
mergedPrompt,
string.IsNullOrWhiteSpace(rawArgs) ? matchedSkill.Label : rawArgs);
}
public static async Task<string?> BuildProactiveSkillSystemPromptAsync(
string userText,
string activeTab,
string workFolder,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(userText))
return null;
var selected = SelectProactiveSkills(userText, activeTab)
.Take(2)
.ToList();
if (selected.Count == 0)
return null;
var sections = new List<string>();
foreach (var skill in selected)
{
var compiledPrompt = await CompileSkillPromptAsync(skill, "", workFolder, ct).ConfigureAwait(false);
var runtimePolicy = BuildRuntimeDirective(skill);
var payload = string.IsNullOrWhiteSpace(runtimePolicy)
? compiledPrompt
: $"{compiledPrompt}\n\n{runtimePolicy}";
sections.Add($"[Auto Skill: {skill.Name}]\n{payload}");
}
return sections.Count == 0
? null
: "[Auto Skill Guidance]\nUse the following reusable guidance only when it directly helps the current task.\n\n" +
string.Join("\n\n", sections);
}
public static IReadOnlyList<SkillDefinition> GetAutoSkills(string activeTab)
{
return _skills
.Where(skill => skill.IsAvailable)
.Where(skill => skill.IsVisibleInTab(activeTab))
.Where(IsPotentialAutoSkill)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// 스킬 메타데이터(context/agent/effort/model 등)를
/// 런타임 시스템 지시문으로 변환합니다.
@@ -216,6 +279,116 @@ public static class SkillService
/// <summary>조건부 스킬 활성화 상태를 초기화합니다.</summary>
public static void ResetConditionalSkillActivation() => _activeConditionalSkillNames.Clear();
private static IEnumerable<string> EnumerateSkillFiles(string folder)
{
IEnumerable<string> standardFiles = [];
IEnumerable<string> legacyFiles = [];
try
{
standardFiles = Directory.EnumerateFiles(folder, "SKILL.md", SearchOption.AllDirectories);
legacyFiles = Directory.EnumerateFiles(folder, "*.skill.md", SearchOption.AllDirectories);
}
catch
{
yield break;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in standardFiles.Concat(legacyFiles))
{
if (seen.Add(file))
yield return file;
}
}
private static string? NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
var normalized = Path.GetFullPath(path.Trim());
return Directory.Exists(normalized) ? normalized : null;
}
catch
{
return null;
}
}
private static IReadOnlyList<SkillDefinition> GetBundledSkills()
{
return
[
CreateBundledSkill(
"verify-change",
"변경 검증",
"최근 변경을 빠르게 점검하고 검증 근거를 정리합니다.",
"""
최근 변경을 검증 관점에서 빠르게 점검하세요.
필요하면 file_read, grep, git_tool, build_run, test_loop를 사용해 근거를 수집하고
최종에는 변경 요약, 위험 요소, 검증 결과를 간결하게 정리하세요.
""",
whenToUse: "코드 수정 후 검증이 필요하거나 build, test, diff 근거를 빠르게 정리해야 할 때",
allowedTools: "file_read, grep, git_tool, build_run, test_loop"),
CreateBundledSkill(
"debug-trace",
"디버그 트레이스",
"오류 흐름을 따라가며 원인 후보와 확인 순서를 정리합니다.",
"""
오류 메시지, 호출 흐름, 관련 파일을 좁혀가며 원인 후보를 정리하세요.
필요한 경우 lsp_code_intel, grep, file_read를 우선 사용하고
근거가 모이면 수정 후보와 재현/검증 절차를 함께 제시하세요.
""",
whenToUse: "예외, 실패 로그, 재현 이슈, 원인 추적이 필요한 디버깅 작업",
allowedTools: "lsp_code_intel, grep, file_read, build_run"),
CreateBundledSkill(
"doc-brief",
"문서 브리프",
"문서 초안 전 구조와 핵심 메시지를 정리합니다.",
"""
요청된 문서의 목적, 독자, 핵심 메시지, 권장 섹션 구조를 먼저 정리한 뒤
실제 문서 생성 도구로 이어질 수 있도록 브리프를 작성하세요.
""",
whenToUse: "새 보고서, 제안서, 분석서, 운영 문서의 구조를 먼저 잡아야 할 때",
allowedTools: "document_plan, html_create, markdown_create, docx_create"),
CreateBundledSkill(
"handoff-note",
"핸드오프 노트",
"진행 상황과 다음 액션을 다음 작업자가 바로 이어받을 수 있게 정리합니다.",
"""
현재 상태를 다음 작업자가 바로 이어받을 수 있도록 정리하세요.
완료한 작업, 남은 이슈, 주의할 파일, 다음 권장 액션을 분리해서 작성하세요.
""",
whenToUse: "작업 마무리, 인수인계, 세션 요약, 다음 액션 정리가 필요할 때",
allowedTools: "git_tool, file_read, task_list")
];
}
private static SkillDefinition CreateBundledSkill(
string name,
string label,
string description,
string body,
string whenToUse = "",
string allowedTools = "")
{
return new SkillDefinition
{
Id = name,
Name = name,
Label = label,
Description = description,
SystemPrompt = body.Trim(),
FilePath = $"[bundled]/{name}",
SourceScope = "bundled",
WhenToUse = whenToUse,
AllowedTools = allowedTools,
IsAvailable = true,
};
}
/// <summary>스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다.</summary>
public static void EnsureSkillFolder()
{
@@ -537,8 +710,8 @@ public static class SkillService
File.WriteAllText(path, string.Join('\n', lines), Encoding.UTF8);
}
/// <summary>*.skill.md 파일을 파싱합니다.</summary>
private static SkillDefinition? ParseSkillFile(string filePath)
/// <summary>*.skill.md / SKILL.md 파일을 파싱합니다.</summary>
private static SkillDefinition? ParseSkillFile(string filePath, string? skillRoot = null)
{
var content = File.ReadAllText(filePath, Encoding.UTF8);
if (!content.TrimStart().StartsWith("---"))
@@ -607,13 +780,16 @@ public static class SkillService
}
}
// 폴더명을 기본 이름으로 사용 (SKILL.md 표준: 폴더명 = name)
var normalizedSkillRoot = NormalizeExistingDirectory(skillRoot) ?? "";
var dirName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? "");
var fileName = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", "");
var fallbackName = filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? dirName : fileName;
var fallbackName = filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase)
? BuildNamespacedSkillName(filePath, normalizedSkillRoot, dirName)
: fileName;
var name = meta.GetValueOrDefault("name", fallbackName);
if (string.IsNullOrEmpty(name)) return null;
var argumentNames = ParseArgumentNames(meta.GetValueOrDefault("argument-hint", ""));
// SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음
var label = meta.GetValueOrDefault("label", "") ?? "";
@@ -636,6 +812,10 @@ public static class SkillService
Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon),
SystemPrompt = MapToolNames(body),
FilePath = filePath,
SkillRoot = string.IsNullOrWhiteSpace(normalizedSkillRoot)
? (Path.GetDirectoryName(filePath) ?? "")
: Path.GetDirectoryName(filePath) ?? normalizedSkillRoot,
SourceScope = ResolveSourceScope(filePath, normalizedSkillRoot),
License = meta.GetValueOrDefault("license", "") ?? "",
Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "",
AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "",
@@ -643,6 +823,7 @@ public static class SkillService
Tabs = meta.GetValueOrDefault("tabs", "all") ?? "all",
WhenToUse = meta.GetValueOrDefault("when_to_use", "") ?? "",
ArgumentHint = meta.GetValueOrDefault("argument-hint", "") ?? "",
ArgumentNames = argumentNames,
Model = meta.GetValueOrDefault("model", "") ?? "",
DisableModelInvocation = ParseBooleanMeta(meta.GetValueOrDefault("disable-model-invocation", "")),
UserInvocable = !meta.ContainsKey("user-invocable") || ParseBooleanMeta(meta.GetValueOrDefault("user-invocable", "true")),
@@ -658,6 +839,74 @@ public static class SkillService
};
}
private static string BuildNamespacedSkillName(string filePath, string skillRoot, string fallbackName)
{
if (string.IsNullOrWhiteSpace(skillRoot))
return fallbackName;
try
{
var relativeDir = Path.GetRelativePath(skillRoot, Path.GetDirectoryName(filePath) ?? skillRoot)
.Replace('\\', '/')
.Trim();
if (string.IsNullOrWhiteSpace(relativeDir) || relativeDir == ".")
return fallbackName;
var segments = relativeDir
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static segment => segment.Trim())
.Where(static segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
return segments.Length == 0 ? fallbackName : string.Join(':', segments);
}
catch
{
return fallbackName;
}
}
private static string ResolveSourceScope(string filePath, string skillRoot)
{
if (filePath.StartsWith("[bundled]/", StringComparison.OrdinalIgnoreCase))
return "bundled";
if (!string.IsNullOrWhiteSpace(skillRoot)
&& skillRoot.Replace('\\', '/').Contains("/.claude/skills", StringComparison.OrdinalIgnoreCase))
return "project";
var appDataSkills = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot",
"skills");
if (filePath.StartsWith(appDataSkills, StringComparison.OrdinalIgnoreCase))
return "user";
return "custom";
}
private static IReadOnlyList<string> ParseArgumentNames(string argumentHint)
{
if (string.IsNullOrWhiteSpace(argumentHint))
return [];
var placeholders = Regex.Matches(argumentHint, @"<([^>]+)>|\{([^}]+)\}")
.Cast<Match>()
.Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value)
.Select(static name => name.Trim())
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (placeholders.Count > 0)
return placeholders;
return argumentHint
.Split([' ', ',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static token => token.Trim('<', '>', '{', '}', '[', ']', '(', ')').Trim())
.Where(static token => !string.IsNullOrWhiteSpace(token))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool ParseBooleanMeta(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -678,6 +927,214 @@ public static class SkillService
return _activeConditionalSkillNames.Contains(skill.Name);
}
private static bool IsPotentialAutoSkill(SkillDefinition skill)
{
return !skill.UserInvocable
|| !string.IsNullOrWhiteSpace(skill.Paths)
|| !string.IsNullOrWhiteSpace(skill.WhenToUse);
}
private static IEnumerable<SkillDefinition> SelectProactiveSkills(string userText, string activeTab)
{
var queryTokens = ExtractSearchTokens(userText);
return _skills
.Where(skill => skill.IsAvailable)
.Where(skill => skill.IsVisibleInTab(activeTab))
.Where(IsPotentialAutoSkill)
.Select(skill => new
{
Skill = skill,
Score = ScoreSkill(skill, userText, queryTokens)
})
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(x => x.Skill);
}
private static int ScoreSkill(SkillDefinition skill, string userText, HashSet<string> queryTokens)
{
var score = 0;
if (!string.IsNullOrWhiteSpace(skill.Paths) && _activeConditionalSkillNames.Contains(skill.Name))
score += 4;
if (!skill.UserInvocable)
score += 1;
var haystack = $"{skill.Name} {skill.Label} {skill.Description} {skill.WhenToUse}".ToLowerInvariant();
foreach (var token in queryTokens)
{
if (haystack.Contains(token, StringComparison.Ordinal))
score += 2;
}
if (!string.IsNullOrWhiteSpace(skill.WhenToUse)
&& userText.Contains(skill.WhenToUse, StringComparison.OrdinalIgnoreCase))
score += 3;
return score;
}
private static HashSet<string> ExtractSearchTokens(string text)
{
return text
.Split([' ', '\t', '\r', '\n', ',', '.', ':', ';', '/', '\\', '-', '_', '(', ')', '[', ']', '{', '}', '?', '!'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(token => token.Trim().ToLowerInvariant())
.Where(token => token.Length >= 2)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
internal static async Task<string> CompileSkillPromptAsync(
SkillDefinition skill,
string rawArgs,
string workFolder,
CancellationToken ct = default)
{
var compiled = skill.SystemPrompt;
compiled = SubstituteArguments(compiled, rawArgs, skill.ArgumentNames);
compiled = SubstituteSkillDirectory(compiled, skill.SkillRoot);
compiled = await InjectInlineShellBlocksAsync(compiled, skill, workFolder, ct).ConfigureAwait(false);
return compiled;
}
private static string SubstituteArguments(string input, string rawArgs, IReadOnlyList<string> argumentNames)
{
rawArgs ??= "";
var result = input.Replace("$ARGUMENTS", rawArgs, StringComparison.OrdinalIgnoreCase);
if (argumentNames.Count == 0)
return result;
var tokens = TokenizeArguments(rawArgs);
for (var i = 0; i < argumentNames.Count; i++)
{
var value = i < tokens.Count ? tokens[i] : "";
var pattern = $@"\$(?:{Regex.Escape(argumentNames[i])})\b";
result = Regex.Replace(result, pattern, value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
return result;
}
private static IReadOnlyList<string> TokenizeArguments(string rawArgs)
{
if (string.IsNullOrWhiteSpace(rawArgs))
return [];
var matches = Regex.Matches(rawArgs, "\"([^\"]*)\"|'([^']*)'|(\\S+)");
var tokens = new List<string>(matches.Count);
foreach (Match match in matches)
{
var value = match.Groups[1].Success
? match.Groups[1].Value
: match.Groups[2].Success
? match.Groups[2].Value
: match.Groups[3].Value;
if (!string.IsNullOrWhiteSpace(value))
tokens.Add(value);
}
return tokens;
}
private static string SubstituteSkillDirectory(string input, string skillRoot)
{
if (string.IsNullOrWhiteSpace(skillRoot))
return input;
var normalized = skillRoot.Replace('\\', '/');
return input
.Replace("${AX_SKILL_DIR}", normalized, StringComparison.OrdinalIgnoreCase)
.Replace("${CLAUDE_SKILL_DIR}", normalized, StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> InjectInlineShellBlocksAsync(
string input,
SkillDefinition skill,
string workFolder,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input) || !input.Contains("!`", StringComparison.Ordinal))
return input;
var shellKind = NormalizeShellKind(skill.Shell);
var matches = Regex.Matches(input, @"!\`([^`\r\n]+)\`");
if (matches.Count == 0)
return input;
var result = input;
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);
result = result.Remove(match.Index, match.Length).Insert(match.Index, output);
}
return result;
}
private static string NormalizeShellKind(string shell)
{
if (string.IsNullOrWhiteSpace(shell))
return "cmd";
return shell.Trim().ToLowerInvariant() switch
{
"powershell" or "pwsh" or "ps" => "powershell",
_ => "cmd",
};
}
private static async Task<string> ExecuteInlineShellAsync(
string command,
string shellKind,
string workFolder,
CancellationToken ct)
{
try
{
var startInfo = shellKind == "powershell"
? new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -Command \"{command.Replace("\"", "\\\"")}\"",
WorkingDirectory = string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)
? Environment.CurrentDirectory
: workFolder,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
: new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{command}\"",
WorkingDirectory = string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)
? Environment.CurrentDirectory
: workFolder,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new System.Diagnostics.Process { StartInfo = startInfo };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
var output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout;
if (string.IsNullOrWhiteSpace(output))
return "[inline-shell: no output]";
output = output.Trim();
return output.Length > 4000 ? output[..4000] : output;
}
catch (Exception ex)
{
return $"[inline-shell error] {ex.Message}";
}
}
private static string NormalizeEffort(string raw)
{
var effort = raw.Trim().ToLowerInvariant();
@@ -899,6 +1356,9 @@ public class SkillDefinition
public string Icon { get; init; } = "\uE768";
public string SystemPrompt { get; init; } = "";
public string FilePath { get; init; } = "";
public string SkillRoot { get; init; } = "";
public string SourceScope { get; init; } = "";
public IReadOnlyList<string> ArgumentNames { get; init; } = [];
// SKILL.md 표준 확장 필드
public string License { get; init; } = "";
@@ -978,3 +1438,8 @@ public class SkillDefinition
}
}
}
public sealed record CompiledSkillInvocation(
SkillDefinition Skill,
string SystemPrompt,
string DisplayText);

View File

@@ -40,7 +40,7 @@ public partial class AgentSettingsWindow : Window
private void LoadFromSettings()
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_llm.SkillsFolderPath);
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
_selectedService = (_llm.Service ?? "ollama").Trim().ToLowerInvariant();
_selectedTheme = (_llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
@@ -564,12 +564,30 @@ public partial class AgentSettingsWindow : Window
_settings.Settings.AiEnabled = true;
_settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode);
_settings.Save();
SkillService.LoadSkills(_llm.SkillsFolderPath);
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
BuildSkillListPanel();
DialogResult = true;
Close();
}
private string? ResolveSkillProjectRoot()
{
var candidates = new[]
{
_llm.CodeWorkFolder,
_llm.CoworkWorkFolder,
_llm.WorkFolder
};
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate) && System.IO.Directory.Exists(candidate))
return candidate.Trim();
}
return null;
}
private void BtnBrowseSkillFolder_Click(object sender, RoutedEventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog
@@ -611,7 +629,7 @@ public partial class AgentSettingsWindow : Window
Padding = new Thickness(12, 10, 12, 10),
Child = new TextBlock
{
Text = "로드된 스킬이 없습니다. 스킬 폴더를 열어 `.skill.md` 또는 `SKILL.md` 파일을 추가한 뒤 저장하면 다시 불러옵니다.",
Text = "로드된 스킬이 없습니다. 기본/추가 스킬 폴더 또는 프로젝트 `.claude/skills` 아래에 `.skill.md` `SKILL.md` 추가한 뒤 저장하면 다시 불러옵니다.",
FontSize = 11,
TextWrapping = TextWrapping.Wrap,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
@@ -622,8 +640,10 @@ public partial class AgentSettingsWindow : Window
var groups = new[]
{
new { Title = "내장 스킬", Items = skills.Where(s => string.IsNullOrWhiteSpace(s.Requires)).ToList() },
new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires)).ToList() },
new { Title = "번들 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "프로젝트 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "사용자/추가 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(s.Requires)).ToList() },
new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() },
};
foreach (var group in groups)

View File

@@ -1284,7 +1284,7 @@ public partial class ChatWindow
return;
_settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath;
SkillService.LoadSkills(dlg.SelectedPath);
SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder());
RefreshOverlayEtcPanels();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
@@ -1887,12 +1887,11 @@ public partial class ChatWindow
.Where(skill => !skill.IsAvailable)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var autoSkills = skills
.Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths)))
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var autoSkills = SkillService.GetAutoSkills(_activeTab).ToList();
var directSkills = skills
.Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths))
.Where(skill => skill.IsAvailable
&& skill.UserInvocable
&& !autoSkills.Any(autoSkill => string.Equals(autoSkill.Name, skill.Name, StringComparison.OrdinalIgnoreCase)))
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -3652,7 +3651,7 @@ public partial class ChatWindow
if (llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(llm.SkillsFolderPath);
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
UpdateConditionalSkillActivation(reset: true);
}

View File

@@ -20,6 +20,8 @@ public partial class ChatWindow
? "[FORK]"
: "[DIRECT]";
var baseLabel = $"{badge} {skill.Label}";
if (!string.IsNullOrWhiteSpace(skill.ArgumentHint))
baseLabel = $"{baseLabel} {skill.ArgumentHint.Trim()}";
return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}";
}

View File

@@ -524,7 +524,7 @@ public partial class ChatWindow : Window
if (_settings.Settings.Llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath);
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
UpdateConditionalSkillActivation(reset: true);
}
@@ -2241,6 +2241,15 @@ public partial class ChatWindow : Window
return _settings.Settings.Llm.WorkFolder;
}
private void EnsureSkillSystemLoadedForCurrentWorkspace()
{
if (!_settings.Settings.Llm.EnableSkillSystem)
return;
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
}
/// <summary>
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
/// 조건부 paths 스킬 활성화를 갱신합니다.
@@ -2248,6 +2257,7 @@ public partial class ChatWindow : Window
private void UpdateConditionalSkillActivation(bool reset = false)
{
if (!_settings.Settings.Llm.EnableSkillSystem) return;
EnsureSkillSystemLoadedForCurrentWorkspace();
var cwd = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd)) return;
if (reset) SkillService.ResetConditionalSkillActivation();
@@ -3460,6 +3470,7 @@ public partial class ChatWindow : Window
// 스킬 슬래시 명령어 매칭 (탭별 필터)
if (_settings.Settings.Llm.EnableSkillSystem)
{
EnsureSkillSystemLoadedForCurrentWorkspace();
var skillMatches = SkillService.MatchSlashCommand(text)
.Where(s => s.IsVisibleInTab(_activeTab))
.Select(s => (Cmd: "/" + s.Name,
@@ -3518,7 +3529,7 @@ public partial class ChatWindow : Window
}
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
private (string? slashSystem, string userText) ParseSlashCommand(string input)
private async Task<(string? slashSystem, string userText)> ParseSlashCommandAsync(string input, CancellationToken ct = default)
{
var trimmed = input.TrimStart();
if (trimmed.StartsWith("/"))
@@ -3535,17 +3546,10 @@ public partial class ChatWindow : Window
}
// 스킬 명령어 매칭
var matchedSkill = SkillService.MatchSlashInvocation(input);
if (matchedSkill != null)
{
var slashCmd = "/" + matchedSkill.Name;
var rest = input[slashCmd.Length..].Trim();
var runtimePolicy = SkillService.BuildRuntimeDirective(matchedSkill);
var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy)
? matchedSkill.SystemPrompt
: $"{matchedSkill.SystemPrompt}\n\n{runtimePolicy}";
return (mergedPrompt, string.IsNullOrEmpty(rest) ? matchedSkill.Label : rest);
}
EnsureSkillSystemLoadedForCurrentWorkspace();
var compiledInvocation = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
if (compiledInvocation != null)
return (compiledInvocation.SystemPrompt, compiledInvocation.DisplayText);
return (null, input);
}
@@ -5004,7 +5008,7 @@ public partial class ChatWindow : Window
{
llm.EnableSkillSystem = true;
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(llm.SkillsFolderPath);
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
UpdateConditionalSkillActivation(reset: true);
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
@@ -5102,7 +5106,17 @@ public partial class ChatWindow : Window
ClearPromptCardPlaceholder();
// 슬래시 명령어 처리
var (slashSystem, displayText) = ParseSlashCommand(text);
var (slashSystem, displayText) = await ParseSlashCommandAsync(text, CancellationToken.None);
if (slashSystem == null
&& _settings.Settings.Llm.EnableSkillSystem
&& !text.TrimStart().StartsWith("/", StringComparison.Ordinal))
{
EnsureSkillSystemLoadedForCurrentWorkspace();
var autoSkillPrompt = await SkillService.BuildProactiveSkillSystemPromptAsync(displayText, _activeTab, GetCurrentWorkFolder(), CancellationToken.None);
if (!string.IsNullOrWhiteSpace(autoSkillPrompt))
slashSystem = autoSkillPrompt;
}
if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal))
{

View File

@@ -4309,16 +4309,17 @@
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18" MaxWidth="320">
마크다운 기반 재사용 워크플로우 시스템입니다.
<LineBreak/>사용자 스킬 폴더와 추가 폴더의 스킬을 로드해 슬래시 명령어(/)와 런타임 정책에 연결합니다.
<LineBreak/>번들 스킬, 사용자 스킬 폴더, 프로젝트 `.claude/skills`의 스킬을 함께 로드해 슬래시 명령어(/)와 보조 런타임 지침에 연결합니다.
<LineBreak/>
<LineBreak/>기본 스킬 폴더: %APPDATA%\AxCopilot\skills\
<LineBreak/>예: /daily-standup, /bug-hunt, /code-explain
<LineBreak/>프로젝트 폴더: [작업폴더]\.claude\skills\
<LineBreak/>예: /daily-standup, /verify-change, /handoff-note
</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Style="{StaticResource RowHint}" Text="마크다운 기반 재사용 워크플로우. 슬래시 명령과 런타임 정책에서 함께 활용됩니다."/>
<TextBlock Style="{StaticResource RowHint}" Text="마크다운 기반 재사용 워크플로우. 슬래시 명령, 프로젝트 스킬, 자동/조건부 보조 지침에서 함께 활용됩니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableSkillSystem, Mode=TwoWay}"/>

View File

@@ -94,7 +94,9 @@ public partial class SettingsWindow : Window
await Task.Run(() =>
{
Services.Agent.SkillService.EnsureSkillFolder();
Services.Agent.SkillService.LoadSkills(app?.SettingsService?.Settings.Llm.SkillsFolderPath);
Services.Agent.SkillService.LoadSkills(
app?.SettingsService?.Settings.Llm.SkillsFolderPath,
ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm));
});
}
@@ -115,6 +117,27 @@ public partial class SettingsWindow : Window
};
}
private static string? ResolveSkillProjectRoot(Models.LlmSettings? llm)
{
if (llm == null)
return null;
var candidates = new[]
{
llm.CodeWorkFolder,
llm.CoworkWorkFolder,
llm.WorkFolder
};
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate))
return candidate.Trim();
}
return null;
}
private void BindIndexProgress()
{
_indexService = (System.Windows.Application.Current as App)?.IndexService;
@@ -616,26 +639,40 @@ public partial class SettingsWindow : Window
// 설명
skillItems.Add(new TextBlock
{
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬 포함됩니다.\n" +
"(직접 호출 가능한 스킬과 런타임 정책에 연결되는 스킬을 함께 표시합니다.)",
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/추가 스킬, 프로젝트 `.claude/skills`가 함께 포함됩니다.\n" +
"(직접 호출 스킬과 자동/조건부 보조 스킬을 함께 표시합니다.)",
FontSize = 11,
Foreground = new SolidColorBrush(subtleText),
Margin = new Thickness(2, 0, 0, 10),
TextWrapping = TextWrapping.Wrap,
});
// 내장 스킬 / 고급 스킬 분류
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
var bundled = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList();
var project = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList();
var custom = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrEmpty(s.Requires)).ToList();
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList();
// 내장 스킬 카드
if (builtIn.Count > 0)
if (bundled.Count > 0)
{
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
var card = CreateSkillGroupCard("번들 스킬", "\uE768", "#34D399", bundled);
skillItems.Add(card);
}
if (project.Count > 0)
{
var card = CreateSkillGroupCard("프로젝트 스킬", "\uE8F1", "#2563EB", project);
skillItems.Add(card);
}
if (custom.Count > 0)
{
var card = CreateSkillGroupCard("사용자/추가 스킬", "\uE70F", "#F59E0B", custom);
skillItems.Add(card);
}
// 고급 스킬 (런타임 의존) 카드
if (advanced.Count > 0)
{
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);