스킬 런타임 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:
@@ -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를 통해 자동 흡수합니다.
|
||||
|
||||
@@ -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건은 유지됩니다.
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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}"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user