From b17c865c4ea272652bd96a63467985d5264634b7 Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 18:10:16 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=EC=8A=A4=ED=82=AC=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=202=EC=B0=A8=20=EA=B3=A0=EB=8F=84=ED=99=94?= =?UTF-8?q?=EC=99=80=20=EB=8F=84=EA=B5=AC=20=EB=85=B8=EC=B6=9C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 .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건 유지) --- README.md | 9 + docs/DEVELOPMENT.md | 8 + .../SkillServiceRuntimePolicyTests.cs | 105 +++- .../Services/Agent/AgentLoopService.cs | 46 ++ .../Services/Agent/SkillManagerTool.cs | 32 +- src/AxCopilot/Services/Agent/SkillService.cs | 533 ++++++++++++++++-- .../Views/AgentSettingsWindow.xaml.cs | 30 +- .../ChatWindow.OverlaySettingsPresentation.cs | 13 +- .../ChatWindow.SlashCommandPresentation.cs | 2 + src/AxCopilot/Views/ChatWindow.xaml.cs | 44 +- src/AxCopilot/Views/SettingsWindow.xaml | 7 +- src/AxCopilot/Views/SettingsWindow.xaml.cs | 57 +- 12 files changed, 805 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 5bb7739..237e1d9 100644 --- a/README.md +++ b/README.md @@ -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를 통해 자동 흡수합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 893c6bd..a55b64c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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건은 유지됩니다. diff --git a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs index 6d755ca..c6f6bac 100644 --- a/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs @@ -78,7 +78,7 @@ public class SkillServiceRuntimePolicyTests var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static); method.Should().NotBeNull(); - var parsed = method!.Invoke(null, [skillPath]) 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: + --- + + 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 { } + } + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 3ad47fe..841db4a 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -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 ApplyPermissionExposureFilter(IReadOnlyCollection tools) + { + var blanketDeniedTools = GetBlanketDeniedToolNames(); + if (blanketDeniedTools.Count == 0) + return tools; + + if (blanketDeniedTools.Contains("*")) + return Array.Empty(); + + return tools + .Where(tool => !blanketDeniedTools.Contains(tool.Name)) + .ToList() + .AsReadOnly(); + } + + private HashSet GetBlanketDeniedToolNames() + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + var permissionMap = AgentToolCatalog.CanonicalizePermissionMap(_settings.Settings.Llm.ToolPermissions ?? new Dictionary()); + foreach (var kv in permissionMap) + { + if (!PermissionModeCatalog.IsDeny(kv.Value)) + continue; + + var key = kv.Key?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(key)) + continue; + + if (key.IndexOfAny(['@', '|', '(', ')']) >= 0) + continue; + + if (string.Equals(key, "*", StringComparison.Ordinal)) + { + result.Add("*"); + return result; + } + + var canonical = AgentToolCatalog.Canonicalize(key); + if (!string.IsNullOrWhiteSpace(canonical)) + result.Add(canonical); + } + + return result; + } + private IEnumerable MergeDisabledTools(IEnumerable? disabledToolNames) { var disabled = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs index 272372a..31c9195 100644 --- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs +++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs @@ -1,6 +1,8 @@ using System.Text; using System.Text.Json; +using System.IO; + namespace AxCopilot.Services.Agent; /// @@ -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; + } } diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index dad4d74..bd228a7 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -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 _skills = new(); private static string _lastFolder = ""; + private static string _lastProjectRoot = ""; + private static string _lastLoadSignature = ""; private static readonly HashSet _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase); /// 로드된 스킬 목록. public static IReadOnlyList Skills => _skills; - /// 스킬 폴더에서 *.skill.md 파일을 로드합니다. - public static void LoadSkills(string? customFolder = null) + /// 스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다. + 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(); // 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(); + // 4) 프로젝트 스킬 폴더 (.claude/skills) + if (!string.IsNullOrEmpty(normalizedProjectRoot)) + { + var projectSkillsFolder = Path.Combine(normalizedProjectRoot, ".claude", "skills"); + if (Directory.Exists(projectSkillsFolder)) + folders.Add(projectSkillsFolder); + } + + var allSkills = GetBundledSkills().ToList(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var bundled in allSkills) + seen.Add(bundled.Name); foreach (var folder in folders) { - // 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 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 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(); + 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 GetAutoSkills(string activeTab) + { + return _skills + .Where(skill => skill.IsAvailable) + .Where(skill => skill.IsVisibleInTab(activeTab)) + .Where(IsPotentialAutoSkill) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + /// /// 스킬 메타데이터(context/agent/effort/model 등)를 /// 런타임 시스템 지시문으로 변환합니다. @@ -216,6 +279,116 @@ public static class SkillService /// 조건부 스킬 활성화 상태를 초기화합니다. public static void ResetConditionalSkillActivation() => _activeConditionalSkillNames.Clear(); + private static IEnumerable EnumerateSkillFiles(string folder) + { + IEnumerable standardFiles = []; + IEnumerable 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(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 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, + }; + } + /// 스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다. public static void EnsureSkillFolder() { @@ -537,8 +710,8 @@ public static class SkillService File.WriteAllText(path, string.Join('\n', lines), Encoding.UTF8); } - /// *.skill.md 파일을 파싱합니다. - private static SkillDefinition? ParseSkillFile(string filePath) + /// *.skill.md / SKILL.md 파일을 파싱합니다. + 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 ParseArgumentNames(string argumentHint) + { + if (string.IsNullOrWhiteSpace(argumentHint)) + return []; + + var placeholders = Regex.Matches(argumentHint, @"<([^>]+)>|\{([^}]+)\}") + .Cast() + .Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value) + .Select(static name => name.Trim()) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (placeholders.Count > 0) + return placeholders; + + 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 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 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 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 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 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 TokenizeArguments(string rawArgs) + { + if (string.IsNullOrWhiteSpace(rawArgs)) + return []; + + var matches = Regex.Matches(rawArgs, "\"([^\"]*)\"|'([^']*)'|(\\S+)"); + var tokens = new List(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 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().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 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 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); diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs index 5cb5224..9bbfe61 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs @@ -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) diff --git a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs index 5945be4..fffd194 100644 --- a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs @@ -1284,7 +1284,7 @@ public partial class ChatWindow return; _settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath; - SkillService.LoadSkills(dlg.SelectedPath); + 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); } diff --git a/src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs b/src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs index 95fd0df..df0385d 100644 --- a/src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs @@ -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}"; } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index a307415..3a9eb13 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -524,7 +524,7 @@ public partial class ChatWindow : Window if (_settings.Settings.Llm.EnableSkillSystem) { SkillService.EnsureSkillFolder(); - SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath); + 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()); + } + /// /// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로 /// 조건부 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 } /// 슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다. - 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)) { diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 3373e33..126c690 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4309,16 +4309,17 @@ 마크다운 기반 재사용 워크플로우 시스템입니다. - 사용자 스킬 폴더와 추가 폴더의 스킬을 로드해 슬래시 명령어(/)와 런타임 정책에 연결합니다. + 번들 스킬, 사용자 스킬 폴더, 프로젝트 `.claude/skills`의 스킬을 함께 로드해 슬래시 명령어(/)와 보조 런타임 지침에 연결합니다. 기본 스킬 폴더: %APPDATA%\AxCopilot\skills\ - 예: /daily-standup, /bug-hunt, /code-explain + 프로젝트 폴더: [작업폴더]\.claude\skills\ + 예: /daily-standup, /verify-change, /handoff-note - + diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs index 0c3cbbb..54e4ff7 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs @@ -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);