스킬 소스 확장과 공통 deny 필터 고도화
프로젝트 상위 경로의 .claude/skills 탐색, 플러그인 스킬 폴더, 보조 스킬 폴더 목록, .claude/commands 기반 레거시 markdown command를 함께 로드하도록 SkillService를 확장했다. 파일형 스킬은 lazy prompt body 캐시를 사용해 실제 호출/미리보기 시점에만 본문을 읽도록 정리했고 arguments + argument-hint를 함께 해석해 위치 인자 치환과 누락 인자 안내를 보강했다. 도구 blanket deny 규칙은 AgentToolCatalog 공통 메서드로 이동해 AgentLoopService와 설정 UI 도구 목록이 같은 노출 정책을 공유하도록 맞췄다. 일반 설정과 AX Agent 설정에는 여러 공용 스킬 폴더를 줄 단위로 연결할 수 있는 additionalSkillFolders 입력을 추가했고 스킬 목록은 번들/프로젝트/플러그인/공용/레거시 source scope별로 더 세분화했다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\ (통과 18, 기존 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:22 (KST)
|
||||
- 스킬 소스 계층을 더 확장했습니다. 이제 기본/사용자/추가 폴더뿐 아니라 작업 폴더 상위 경로의 `.claude/skills`, 플러그인 내부 스킬 폴더, `.claude/commands` 기반의 레거시 markdown command까지 함께 읽어 번들/프로젝트/플러그인/공용/레거시 스킬로 분류합니다.
|
||||
- 스킬 로딩 방식도 가볍게 정리했습니다. 파일형 스킬은 본문을 처음부터 모두 메모리에 올리지 않고, 실제 호출·미리보기 시점에 body를 읽어 lazy하게 조립합니다. 인자 모델은 `arguments`와 `argument-hint`를 함께 해석해 위치 인자 치환과 누락 인자 안내를 같이 처리합니다.
|
||||
- 설정은 크게 바꾸지 않고 보조 스킬 폴더 목록만 추가했습니다. 일반 설정과 AX Agent 설정에서 여러 공용 스킬 폴더를 줄 단위로 연결할 수 있고, 프로젝트/플러그인/레거시 command 소스도 같은 목록 화면에서 함께 확인할 수 있습니다.
|
||||
- 도구 deny 필터는 공통 카탈로그로 끌어올렸습니다. 런타임뿐 아니라 설정 화면의 도구 목록도 blanket deny 규칙을 먼저 반영해 실제로 보이는 도구와 모델에 전달되는 도구가 더 일치합니다.
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\` 경고 0 / 오류 0
|
||||
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\` 통과 18
|
||||
- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs`의 nullable 경고 1건은 유지됩니다.
|
||||
|
||||
- 업데이트: 2026-04-14 18:08 (KST)
|
||||
- 스킬 런타임을 `claude-code` 동등 품질 기준으로 한 단계 더 끌어올렸습니다. 이제 작업 폴더 기준의 프로젝트 `.claude/skills`를 재귀 스캔해 namespaced `SKILL.md`를 로드하고, 번들 스킬/사용자 스킬/프로젝트 스킬을 함께 노출합니다.
|
||||
- 슬래시 스킬 호출도 실제 실행 경로에 맞게 확장했습니다. `/skill args...` 호출 시 `$ARGUMENTS`, named argument placeholder, 스킬 폴더 변수 치환을 적용하고, inline shell 블록은 호출 시점의 작업 폴더에서만 실행해 프롬프트를 조립합니다.
|
||||
|
||||
@@ -807,3 +807,12 @@ UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복
|
||||
> - 검증: `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건은 유지됩니다.
|
||||
> 업데이트: 2026-04-14 18:22 (KST)
|
||||
> - 스킬 소스 확장 Phase 3을 반영했습니다. `SkillService`는 상위 디렉터리까지 포함한 프로젝트 `.claude/skills` 탐색, 플러그인 스킬 폴더 탐색, 추가 공용 폴더 목록, `.claude/commands` markdown command를 legacy skill로 변환하는 경로를 함께 지원합니다.
|
||||
> - 파일형 스킬은 body를 즉시 메모리에 올리지 않고 필요 시점에만 읽는 lazy prompt body 캐시를 추가했습니다. `SkillManagerTool`, `SkillEditorWindow`, `SkillGalleryWindow`는 이 경로를 통해 실제 본문을 표시합니다.
|
||||
> - 인자 모델도 확장했습니다. `arguments`와 `argument-hint`를 함께 해석해 named placeholder 치환을 강화했고, 인자가 부족하면 usage 가이드를 프롬프트 앞에 붙여 실행 품질을 보완합니다.
|
||||
> - 도구 deny 필터는 `AgentToolCatalog` 공통 메서드로 이동해 런타임과 설정 UI가 같은 blanket deny 규칙을 공유하도록 정리했습니다.
|
||||
> - 설정 저장에는 `additionalSkillFolders`를 추가했고, 일반 설정/AX Agent 설정 UI에 줄 단위 입력 필드를 넣어 여러 공용 스킬 폴더를 연결할 수 있게 했습니다.
|
||||
> - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\` 경고 0 / 오류 0
|
||||
> - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\` 통과 18
|
||||
> - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다.
|
||||
|
||||
@@ -52,4 +52,24 @@ public class AgentToolCatalogTests
|
||||
normalized[0].ToolName.Should().Be("zip_tool");
|
||||
normalized[1].ToolName.Should().Be("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterExposureByPermission_ShouldRemoveBlanketDeniedTools()
|
||||
{
|
||||
var tools = new[]
|
||||
{
|
||||
new { Name = "file_read" },
|
||||
new { Name = "git_tool" },
|
||||
new { Name = "build_run" },
|
||||
};
|
||||
var permissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["git"] = "deny",
|
||||
["build_run@*.sln"] = "deny",
|
||||
};
|
||||
|
||||
var filtered = AgentToolCatalog.FilterExposureByPermission(tools, tool => tool.Name, permissions);
|
||||
|
||||
filtered.Select(tool => tool.Name).Should().BeEquivalentTo(["file_read", "build_run"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, tempDir]) as SkillDefinition;
|
||||
var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) 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, tempDir]) as SkillDefinition;
|
||||
var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) 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, tempDir]) as SkillDefinition;
|
||||
var parsed = method!.Invoke(null, [skillPath, tempDir, "custom"]) as SkillDefinition;
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.IsSample.Should().BeTrue();
|
||||
}
|
||||
@@ -184,7 +184,7 @@ public class SkillServiceRuntimePolicyTests
|
||||
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;
|
||||
var parsed = method!.Invoke(null, [skillPath, Path.Combine(tempDir, ".claude", "skills"), "project"]) as SkillDefinition;
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Name.Should().Be("release:notes");
|
||||
parsed.SourceScope.Should().Be("project");
|
||||
@@ -261,4 +261,46 @@ public class SkillServiceRuntimePolicyTests
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkills_LoadsAncestorProjectSkills_AndLegacyCommandFiles()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-ancestor-" + Guid.NewGuid().ToString("N"));
|
||||
var rootSkills = Path.Combine(tempDir, ".claude", "skills", "shared");
|
||||
var nestedWorktree = Path.Combine(tempDir, "apps", "desktop", "module");
|
||||
var legacyDir = Path.Combine(tempDir, ".claude", "commands", "review");
|
||||
Directory.CreateDirectory(rootSkills);
|
||||
Directory.CreateDirectory(nestedWorktree);
|
||||
Directory.CreateDirectory(legacyDir);
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(rootSkills, "SKILL.md"), """
|
||||
---
|
||||
description: shared helper
|
||||
arguments: branch
|
||||
---
|
||||
|
||||
shared body for $branch
|
||||
""", Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(legacyDir, "lint.md"), """
|
||||
# 린트 실행
|
||||
|
||||
Run build validation and summarize results.
|
||||
""", Encoding.UTF8);
|
||||
|
||||
SkillService.LoadSkills(projectRoot: nestedWorktree);
|
||||
|
||||
SkillService.Find("shared").Should().NotBeNull();
|
||||
SkillService.Find("review:lint").Should().NotBeNull();
|
||||
|
||||
var invocation = await SkillService.BuildSlashInvocationAsync("/shared main", nestedWorktree);
|
||||
invocation.Should().NotBeNull();
|
||||
invocation!.SystemPrompt.Should().Contain("shared body for main");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,6 +1292,10 @@ public class LlmSettings
|
||||
[JsonPropertyName("skillsFolderPath")]
|
||||
public string SkillsFolderPath { get; set; } = "";
|
||||
|
||||
/// <summary>여분 스킬 폴더 목록. 프로젝트 외부 재사용 스킬 폴더를 추가로 연결합니다.</summary>
|
||||
[JsonPropertyName("additionalSkillFolders")]
|
||||
public List<string> AdditionalSkillFolders { get; set; } = new();
|
||||
|
||||
/// <summary>슬래시 명령어 팝업 한 번에 표시할 최대 항목 수 (3~20). 기본 7.</summary>
|
||||
[JsonPropertyName("slashPopupPageSize")]
|
||||
public int SlashPopupPageSize { get; set; } = 7;
|
||||
|
||||
@@ -2022,7 +2022,7 @@ public partial class AgentLoopService
|
||||
|
||||
private IReadOnlyCollection<IAgentTool> ApplyPermissionExposureFilter(IReadOnlyCollection<IAgentTool> tools)
|
||||
{
|
||||
var blanketDeniedTools = GetBlanketDeniedToolNames();
|
||||
var blanketDeniedTools = AgentToolCatalog.GetBlanketDeniedToolNames(_settings.Settings.Llm.ToolPermissions);
|
||||
if (blanketDeniedTools.Count == 0)
|
||||
return tools;
|
||||
|
||||
@@ -2035,36 +2035,6 @@ public partial class AgentLoopService
|
||||
.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);
|
||||
|
||||
@@ -210,6 +210,52 @@ internal static class AgentToolCatalog
|
||||
return result;
|
||||
}
|
||||
|
||||
public static HashSet<string> GetBlanketDeniedToolNames(IDictionary<string, string>? permissions)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var normalized = CanonicalizePermissionMap(permissions);
|
||||
foreach (var kv in normalized)
|
||||
{
|
||||
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 = Canonicalize(key);
|
||||
if (!string.IsNullOrWhiteSpace(canonical))
|
||||
result.Add(canonical);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<T> FilterExposureByPermission<T>(
|
||||
IEnumerable<T> tools,
|
||||
Func<T, string> getName,
|
||||
IDictionary<string, string>? permissions)
|
||||
{
|
||||
var denied = GetBlanketDeniedToolNames(permissions);
|
||||
if (denied.Count == 0)
|
||||
return tools.ToList().AsReadOnly();
|
||||
if (denied.Contains("*"))
|
||||
return Array.Empty<T>();
|
||||
|
||||
return tools
|
||||
.Where(tool => !denied.Contains(Canonicalize(getName(tool))))
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
public static string CanonicalizeHookTarget(string? toolName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolName))
|
||||
|
||||
@@ -71,6 +71,8 @@ public class SkillManagerTool : IAgentTool
|
||||
: "[DIRECT]";
|
||||
sb.AppendLine($" /{skill.Name} {execBadge} — {skill.Label}");
|
||||
sb.AppendLine($" {skill.Description}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.SourceScope))
|
||||
sb.AppendLine($" source: {skill.SourceScope}");
|
||||
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
|
||||
sb.AppendLine(" 실행 방식: 위임 우선 (spawn_agent → wait_agents)");
|
||||
if (!string.IsNullOrWhiteSpace(skill.AllowedTools))
|
||||
@@ -98,6 +100,8 @@ public class SkillManagerTool : IAgentTool
|
||||
sb.AppendLine($"스킬 상세: {skill.Label} (/{skill.Name})");
|
||||
sb.AppendLine($"설명: {skill.Description}");
|
||||
sb.AppendLine($"파일: {skill.FilePath}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.SourceScope))
|
||||
sb.AppendLine($"source: {skill.SourceScope}");
|
||||
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
|
||||
sb.AppendLine("실행 배지: [FORK] · 위임 우선 실행");
|
||||
else
|
||||
@@ -121,7 +125,7 @@ public class SkillManagerTool : IAgentTool
|
||||
var runtimeDirective = SkillService.BuildRuntimeDirective(skill);
|
||||
if (!string.IsNullOrWhiteSpace(runtimeDirective))
|
||||
sb.AppendLine($"\n--- 런타임 정책 ---\n{runtimeDirective}");
|
||||
sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{skill.SystemPrompt}");
|
||||
sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{SkillService.GetSkillPromptForDisplay(skill)}");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ public class SkillManagerTool : IAgentTool
|
||||
{
|
||||
var llm = app?.SettingsService?.Settings.Llm;
|
||||
var customFolder = llm?.SkillsFolderPath ?? "";
|
||||
SkillService.LoadSkills(customFolder, ResolveProjectRoot(llm));
|
||||
SkillService.LoadSkills(customFolder, ResolveProjectRoot(llm), llm?.AdditionalSkillFolders);
|
||||
return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨.");
|
||||
}
|
||||
|
||||
|
||||
@@ -17,56 +17,39 @@ public static class SkillService
|
||||
private static string _lastFolder = "";
|
||||
private static string _lastProjectRoot = "";
|
||||
private static string _lastLoadSignature = "";
|
||||
private static readonly Dictionary<string, string> s_promptBodyCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly HashSet<string> _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>로드된 스킬 목록.</summary>
|
||||
public static IReadOnlyList<SkillDefinition> Skills => _skills;
|
||||
|
||||
/// <summary>스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다.</summary>
|
||||
public static void LoadSkills(string? customFolder = null, string? projectRoot = null)
|
||||
public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable<string>? additionalFolders = null)
|
||||
{
|
||||
var normalizedCustomFolder = NormalizeExistingDirectory(customFolder);
|
||||
var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot);
|
||||
var loadSignature = $"{normalizedCustomFolder}|{normalizedProjectRoot}";
|
||||
var normalizedAdditionalFolders = NormalizeDistinctDirectories(additionalFolders);
|
||||
var sources = BuildSkillSources(normalizedCustomFolder, normalizedProjectRoot, normalizedAdditionalFolders).ToList();
|
||||
var loadSignature = string.Join("|", sources.Select(source => $"{source.Kind}:{source.Scope}:{source.Directory}"));
|
||||
if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var folders = new List<string>();
|
||||
|
||||
// 1) 앱 기본 스킬 폴더
|
||||
var defaultFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills");
|
||||
if (Directory.Exists(defaultFolder)) folders.Add(defaultFolder);
|
||||
|
||||
// 2) 사용자 스킬 폴더 (%APPDATA%\AxCopilot\skills\)
|
||||
var appDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
if (Directory.Exists(appDataFolder)) folders.Add(appDataFolder);
|
||||
|
||||
// 3) 사용자 지정 폴더
|
||||
if (!string.IsNullOrEmpty(normalizedCustomFolder))
|
||||
folders.Add(normalizedCustomFolder);
|
||||
|
||||
// 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)
|
||||
foreach (var source in sources)
|
||||
{
|
||||
foreach (var file in EnumerateSkillFiles(folder))
|
||||
foreach (var file in source.Kind == SkillSourceKind.LegacyCommand
|
||||
? EnumerateLegacyCommandFiles(source.Directory)
|
||||
: EnumerateSkillFiles(source.Directory))
|
||||
{
|
||||
try
|
||||
{
|
||||
var skill = ParseSkillFile(file, folder);
|
||||
var skill = source.Kind == SkillSourceKind.LegacyCommand
|
||||
? ParseLegacyCommandFile(file, source.Directory, source.Scope)
|
||||
: ParseSkillFile(file, source.Directory, source.Scope);
|
||||
if (skill != null && seen.Add(skill.Name))
|
||||
allSkills.Add(skill);
|
||||
}
|
||||
@@ -91,6 +74,7 @@ public static class SkillService
|
||||
_lastFolder = normalizedCustomFolder ?? "";
|
||||
_lastProjectRoot = normalizedProjectRoot ?? "";
|
||||
_lastLoadSignature = loadSignature;
|
||||
s_promptBodyCache.Clear();
|
||||
_activeConditionalSkillNames.Clear();
|
||||
var unavailCount = allSkills.Count(s => !s.IsAvailable);
|
||||
LogService.Info($"스킬 {allSkills.Count}개 로드 완료" +
|
||||
@@ -192,6 +176,8 @@ public static class SkillService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string GetSkillPromptForDisplay(SkillDefinition skill) => GetSkillPromptBody(skill);
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 메타데이터(context/agent/effort/model 등)를
|
||||
/// 런타임 시스템 지시문으로 변환합니다.
|
||||
@@ -317,6 +303,150 @@ public static class SkillService
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeDistinctDirectories(IEnumerable<string>? paths)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (paths == null)
|
||||
return result.ToList().AsReadOnly();
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var normalized = NormalizeExistingDirectory(path);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
result.Add(normalized);
|
||||
}
|
||||
|
||||
return result.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
private static IEnumerable<SkillSourceDescriptor> BuildSkillSources(
|
||||
string? customFolder,
|
||||
string? projectRoot,
|
||||
IEnumerable<string> additionalFolders)
|
||||
{
|
||||
var sources = new List<SkillSourceDescriptor>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void AddSource(string? directory, string scope, SkillSourceKind kind = SkillSourceKind.SkillFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
var normalized = Path.GetFullPath(directory);
|
||||
if (seen.Add($"{kind}|{normalized}"))
|
||||
sources.Add(new SkillSourceDescriptor(normalized, scope, kind));
|
||||
}
|
||||
|
||||
AddSource(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills"), "managed");
|
||||
AddSource(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"), "user");
|
||||
AddSource(customFolder, "custom");
|
||||
|
||||
foreach (var folder in additionalFolders)
|
||||
AddSource(folder, "additional");
|
||||
|
||||
foreach (var folder in EnumeratePluginSkillFolders())
|
||||
AddSource(folder, "plugin");
|
||||
|
||||
foreach (var folder in EnumerateProjectSkillFolders(projectRoot))
|
||||
AddSource(folder, "project");
|
||||
|
||||
foreach (var folder in EnumerateLegacyCommandFolders(projectRoot))
|
||||
AddSource(folder, "legacy", SkillSourceKind.LegacyCommand);
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateProjectSkillFolders(string? projectRoot)
|
||||
{
|
||||
foreach (var root in EnumerateAncestorDirectories(projectRoot))
|
||||
{
|
||||
var candidate = Path.Combine(root, ".claude", "skills");
|
||||
if (Directory.Exists(candidate))
|
||||
yield return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLegacyCommandFolders(string? projectRoot)
|
||||
{
|
||||
foreach (var root in EnumerateAncestorDirectories(projectRoot))
|
||||
{
|
||||
var candidate = Path.Combine(root, ".claude", "commands");
|
||||
if (Directory.Exists(candidate))
|
||||
yield return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateAncestorDirectories(string? startDirectory)
|
||||
{
|
||||
var normalized = NormalizeExistingDirectory(startDirectory);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
yield break;
|
||||
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var current = new DirectoryInfo(normalized);
|
||||
while (current != null)
|
||||
{
|
||||
yield return current.FullName;
|
||||
|
||||
var trimmed = current.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (string.Equals(trimmed, home, StringComparison.OrdinalIgnoreCase))
|
||||
yield break;
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumeratePluginSkillFolders()
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var plugins = app?.SettingsService?.Settings.Plugins;
|
||||
if (plugins == null)
|
||||
yield break;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var plugin in plugins.Where(plugin => plugin.Enabled && !string.IsNullOrWhiteSpace(plugin.Path)))
|
||||
{
|
||||
var pluginDir = NormalizeExistingDirectory(Path.GetDirectoryName(plugin.Path));
|
||||
if (string.IsNullOrWhiteSpace(pluginDir))
|
||||
continue;
|
||||
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(pluginDir, ".claude", "skills"),
|
||||
Path.Combine(pluginDir, ".claude-plugin", "skills"),
|
||||
Path.Combine(pluginDir, "skills")
|
||||
})
|
||||
{
|
||||
var normalized = NormalizeExistingDirectory(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLegacyCommandFiles(string folder)
|
||||
{
|
||||
IEnumerable<string> files = [];
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(folder, "*.md", SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var name = Path.GetFileName(file);
|
||||
if (name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SkillDefinition> GetBundledSkills()
|
||||
{
|
||||
return
|
||||
@@ -601,7 +731,9 @@ public static class SkillService
|
||||
{
|
||||
LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})");
|
||||
// 스킬 목록 리로드
|
||||
LoadSkills();
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var llm = app?.SettingsService?.Settings.Llm;
|
||||
LoadSkills(llm?.SkillsFolderPath, llm?.WorkFolder, llm?.AdditionalSkillFolders);
|
||||
}
|
||||
|
||||
return importedCount;
|
||||
@@ -711,7 +843,7 @@ public static class SkillService
|
||||
}
|
||||
|
||||
/// <summary>*.skill.md / SKILL.md 파일을 파싱합니다.</summary>
|
||||
private static SkillDefinition? ParseSkillFile(string filePath, string? skillRoot = null)
|
||||
private static SkillDefinition? ParseSkillFile(string filePath, string? skillRoot = null, string sourceScope = "custom")
|
||||
{
|
||||
var content = File.ReadAllText(filePath, Encoding.UTF8);
|
||||
if (!content.TrimStart().StartsWith("---"))
|
||||
@@ -723,8 +855,6 @@ public static class SkillService
|
||||
if (secondSep < 0) return null;
|
||||
|
||||
var frontmatter = content[(firstSep + 3)..secondSep].Trim();
|
||||
var body = content[(secondSep + 3)..].Trim();
|
||||
|
||||
// 키-값 파싱 (YAML 1단계 + metadata 맵 지원)
|
||||
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? currentMap = null;
|
||||
@@ -789,7 +919,9 @@ public static class SkillService
|
||||
|
||||
var name = meta.GetValueOrDefault("name", fallbackName);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
var argumentNames = ParseArgumentNames(meta.GetValueOrDefault("argument-hint", ""));
|
||||
var argumentNames = ParseArgumentNames(
|
||||
meta.GetValueOrDefault("arguments", ""),
|
||||
meta.GetValueOrDefault("argument-hint", ""));
|
||||
|
||||
// SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음
|
||||
var label = meta.GetValueOrDefault("label", "") ?? "";
|
||||
@@ -810,12 +942,12 @@ public static class SkillService
|
||||
Label = string.IsNullOrEmpty(label) ? name : label,
|
||||
Description = meta.GetValueOrDefault("description", "") ?? "",
|
||||
Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon),
|
||||
SystemPrompt = MapToolNames(body),
|
||||
SystemPrompt = "",
|
||||
FilePath = filePath,
|
||||
SkillRoot = string.IsNullOrWhiteSpace(normalizedSkillRoot)
|
||||
? (Path.GetDirectoryName(filePath) ?? "")
|
||||
: Path.GetDirectoryName(filePath) ?? normalizedSkillRoot,
|
||||
SourceScope = ResolveSourceScope(filePath, normalizedSkillRoot),
|
||||
SourceScope = ResolveSourceScope(filePath, normalizedSkillRoot, sourceScope),
|
||||
License = meta.GetValueOrDefault("license", "") ?? "",
|
||||
Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "",
|
||||
AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "",
|
||||
@@ -839,6 +971,37 @@ public static class SkillService
|
||||
};
|
||||
}
|
||||
|
||||
private static SkillDefinition? ParseLegacyCommandFile(string filePath, string commandRoot, string sourceScope)
|
||||
{
|
||||
var content = File.ReadAllText(filePath, Encoding.UTF8).Trim();
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
var fileDirectory = Path.GetDirectoryName(filePath) ?? commandRoot;
|
||||
var relativeDir = Path.GetRelativePath(commandRoot, fileDirectory).Replace('\\', '/');
|
||||
var baseName = Path.GetFileNameWithoutExtension(filePath);
|
||||
var namespacePrefix = string.IsNullOrWhiteSpace(relativeDir) || relativeDir == "."
|
||||
? ""
|
||||
: string.Join(':', relativeDir.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
var name = string.IsNullOrWhiteSpace(namespacePrefix) ? baseName : $"{namespacePrefix}:{baseName}";
|
||||
var description = ExtractLegacyCommandDescription(content, baseName);
|
||||
|
||||
return new SkillDefinition
|
||||
{
|
||||
Id = name,
|
||||
Name = name,
|
||||
Label = baseName,
|
||||
Description = description,
|
||||
Icon = "\uE768",
|
||||
SystemPrompt = MapToolNames(content),
|
||||
FilePath = filePath,
|
||||
SkillRoot = fileDirectory,
|
||||
SourceScope = sourceScope,
|
||||
UserInvocable = true,
|
||||
IsAvailable = true,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildNamespacedSkillName(string filePath, string skillRoot, string fallbackName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillRoot))
|
||||
@@ -865,11 +1028,14 @@ public static class SkillService
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSourceScope(string filePath, string skillRoot)
|
||||
private static string ResolveSourceScope(string filePath, string skillRoot, string fallbackScope)
|
||||
{
|
||||
if (filePath.StartsWith("[bundled]/", StringComparison.OrdinalIgnoreCase))
|
||||
return "bundled";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackScope))
|
||||
return fallbackScope;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skillRoot)
|
||||
&& skillRoot.Replace('\\', '/').Contains("/.claude/skills", StringComparison.OrdinalIgnoreCase))
|
||||
return "project";
|
||||
@@ -884,25 +1050,37 @@ public static class SkillService
|
||||
return "custom";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseArgumentNames(string argumentHint)
|
||||
private static IReadOnlyList<string> ParseArgumentNames(string argumentsField, string argumentHint)
|
||||
{
|
||||
var names = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(argumentsField))
|
||||
{
|
||||
names.AddRange(argumentsField
|
||||
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(token => token.Trim('<', '>', '{', '}', '[', ']', '(', ')', '"', '\'').Trim())
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token)));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(argumentHint))
|
||||
return [];
|
||||
return names.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
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;
|
||||
names.AddRange(placeholders);
|
||||
|
||||
return argumentHint
|
||||
names.AddRange(argumentHint
|
||||
.Split([' ', ',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.Trim('<', '>', '{', '}', '[', ']', '(', ')').Trim())
|
||||
.Where(static token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToList());
|
||||
|
||||
return names
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
@@ -959,6 +1137,15 @@ public static class SkillService
|
||||
score += 4;
|
||||
if (!skill.UserInvocable)
|
||||
score += 1;
|
||||
score += skill.SourceScope switch
|
||||
{
|
||||
"project" => 3,
|
||||
"plugin" => 2,
|
||||
"bundled" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
if (skill.DisableModelInvocation)
|
||||
score += 1;
|
||||
|
||||
var haystack = $"{skill.Name} {skill.Label} {skill.Description} {skill.WhenToUse}".ToLowerInvariant();
|
||||
foreach (var token in queryTokens)
|
||||
@@ -990,14 +1177,46 @@ public static class SkillService
|
||||
string workFolder,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var compiled = skill.SystemPrompt;
|
||||
compiled = SubstituteArguments(compiled, rawArgs, skill.ArgumentNames);
|
||||
var compiled = GetSkillPromptBody(skill);
|
||||
compiled = SubstituteArguments(compiled, rawArgs, skill.ArgumentNames, skill.ArgumentHint);
|
||||
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)
|
||||
private static string GetSkillPromptBody(SkillDefinition skill)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(skill.SystemPrompt))
|
||||
return skill.SystemPrompt;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(skill.FilePath) || !File.Exists(skill.FilePath))
|
||||
return "";
|
||||
|
||||
if (s_promptBodyCache.TryGetValue(skill.FilePath, out var cached))
|
||||
return cached;
|
||||
|
||||
var content = File.ReadAllText(skill.FilePath, Encoding.UTF8);
|
||||
var body = ExtractSkillBody(content);
|
||||
body = MapToolNames(body);
|
||||
s_promptBodyCache[skill.FilePath] = body;
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ExtractSkillBody(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return "";
|
||||
|
||||
var trimmed = content.TrimStart();
|
||||
if (!trimmed.StartsWith("---", StringComparison.Ordinal))
|
||||
return content.Trim();
|
||||
|
||||
var firstSep = content.IndexOf("---", StringComparison.Ordinal);
|
||||
var secondSep = content.IndexOf("---", firstSep + 3, StringComparison.Ordinal);
|
||||
return secondSep < 0 ? content.Trim() : content[(secondSep + 3)..].Trim();
|
||||
}
|
||||
|
||||
private static string SubstituteArguments(string input, string rawArgs, IReadOnlyList<string> argumentNames, string argumentHint)
|
||||
{
|
||||
rawArgs ??= "";
|
||||
var result = input.Replace("$ARGUMENTS", rawArgs, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -1005,6 +1224,14 @@ public static class SkillService
|
||||
return result;
|
||||
|
||||
var tokens = TokenizeArguments(rawArgs);
|
||||
if (tokens.Count < argumentNames.Count)
|
||||
{
|
||||
var usage = string.IsNullOrWhiteSpace(argumentHint)
|
||||
? string.Join(' ', argumentNames.Select(name => $"<{name}>"))
|
||||
: argumentHint.Trim();
|
||||
result = $"[Skill Arguments]\nExpected: {usage}\nReceived: {rawArgs}\n\n{result}";
|
||||
}
|
||||
|
||||
for (var i = 0; i < argumentNames.Count; i++)
|
||||
{
|
||||
var value = i < tokens.Count ? tokens[i] : "";
|
||||
@@ -1015,6 +1242,21 @@ public static class SkillService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractLegacyCommandDescription(string content, string fallbackName)
|
||||
{
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
continue;
|
||||
if (trimmed.StartsWith("#"))
|
||||
return trimmed.TrimStart('#', ' ').Trim();
|
||||
return trimmed.Length > 80 ? trimmed[..80] : trimmed;
|
||||
}
|
||||
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TokenizeArguments(string rawArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawArgs))
|
||||
@@ -1443,3 +1685,11 @@ public sealed record CompiledSkillInvocation(
|
||||
SkillDefinition Skill,
|
||||
string SystemPrompt,
|
||||
string DisplayText);
|
||||
|
||||
internal sealed record SkillSourceDescriptor(string Directory, string Scope, SkillSourceKind Kind);
|
||||
|
||||
internal enum SkillSourceKind
|
||||
{
|
||||
SkillFolder,
|
||||
LegacyCommand,
|
||||
}
|
||||
|
||||
@@ -539,6 +539,13 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
set { _skillsFolderPath = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private string _additionalSkillFoldersText = "";
|
||||
public string AdditionalSkillFoldersText
|
||||
{
|
||||
get => _additionalSkillFoldersText;
|
||||
set { _additionalSkillFoldersText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private int _slashPopupPageSize = 6;
|
||||
public int SlashPopupPageSize
|
||||
{
|
||||
@@ -1209,6 +1216,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
_enableSkillSystem = llm.EnableSkillSystem;
|
||||
_enableForkSkillDelegationEnforcement = llm.EnableForkSkillDelegationEnforcement;
|
||||
_skillsFolderPath = llm.SkillsFolderPath;
|
||||
_additionalSkillFoldersText = string.Join(Environment.NewLine, llm.AdditionalSkillFolders ?? new List<string>());
|
||||
_slashPopupPageSize = llm.SlashPopupPageSize > 0 ? Math.Clamp(llm.SlashPopupPageSize, 3, 10) : 6;
|
||||
_maxFavoriteSlashCommands = llm.MaxFavoriteSlashCommands > 0 ? Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30) : 10;
|
||||
_maxRecentSlashCommands = llm.MaxRecentSlashCommands > 0 ? Math.Clamp(llm.MaxRecentSlashCommands, 5, 50) : 20;
|
||||
@@ -1659,6 +1667,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
s.Llm.EnableSkillSystem = _enableSkillSystem;
|
||||
s.Llm.EnableForkSkillDelegationEnforcement = _enableForkSkillDelegationEnforcement;
|
||||
s.Llm.SkillsFolderPath = _skillsFolderPath;
|
||||
s.Llm.AdditionalSkillFolders = ParseAdditionalSkillFolders(_additionalSkillFoldersText);
|
||||
s.Llm.SlashPopupPageSize = _slashPopupPageSize;
|
||||
s.Llm.MaxFavoriteSlashCommands = _maxFavoriteSlashCommands;
|
||||
s.Llm.MaxRecentSlashCommands = _maxRecentSlashCommands;
|
||||
@@ -1842,6 +1851,19 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
SaveCompleted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static List<string> ParseAdditionalSkillFolders(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return new List<string>();
|
||||
|
||||
return raw
|
||||
.Split(['\r', '\n', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(path => path.Trim())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ─── 스니펫 설정 ──────────────────────────────────────────────────────────
|
||||
public ObservableCollection<SnippetRowModel> Snippets { get; } = new();
|
||||
|
||||
|
||||
@@ -869,6 +869,25 @@
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ToggleSwitch}"/>
|
||||
</Grid>
|
||||
<StackPanel Margin="0,12,0,0">
|
||||
<TextBlock Text="보조 스킬 폴더 목록"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="한 줄에 하나씩 입력합니다. 공용 폴더나 팀 공유 스킬 폴더를 함께 로드할 때 사용합니다."
|
||||
Margin="0,4,0,8"
|
||||
FontSize="11.5"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
<TextBox x:Name="TxtAdditionalSkillFolders"
|
||||
AcceptsReturn="True"
|
||||
MinHeight="72"
|
||||
TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Padding="10,7"
|
||||
Background="{DynamicResource ItemBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
FontSize="12"/>
|
||||
</StackPanel>
|
||||
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
|
||||
<TextBlock Text="도구 노출"
|
||||
FontSize="14"
|
||||
|
||||
@@ -40,7 +40,7 @@ public partial class AgentSettingsWindow : Window
|
||||
private void LoadFromSettings()
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot(), _llm.AdditionalSkillFolders);
|
||||
|
||||
_selectedService = (_llm.Service ?? "ollama").Trim().ToLowerInvariant();
|
||||
_selectedTheme = (_llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
|
||||
@@ -76,6 +76,7 @@ public partial class AgentSettingsWindow : Window
|
||||
ChkEnableTeamTools.IsChecked = _llm.Code.EnableTeamTools;
|
||||
ChkEnableCronTools.IsChecked = _llm.Code.EnableCronTools;
|
||||
TxtSkillsFolderPath.Text = _llm.SkillsFolderPath ?? "";
|
||||
TxtAdditionalSkillFolders.Text = string.Join(Environment.NewLine, _llm.AdditionalSkillFolders ?? new());
|
||||
TxtSlashPopupPageSize.Text = Math.Clamp(_llm.SlashPopupPageSize, 3, 20).ToString();
|
||||
ChkEnableDragDropAiActions.IsChecked = _llm.EnableDragDropAiActions;
|
||||
ChkDragDropAutoSend.IsChecked = _llm.DragDropAutoSend;
|
||||
@@ -553,6 +554,7 @@ public partial class AgentSettingsWindow : Window
|
||||
_llm.Code.EnableTeamTools = ChkEnableTeamTools.IsChecked == true;
|
||||
_llm.Code.EnableCronTools = ChkEnableCronTools.IsChecked == true;
|
||||
_llm.SkillsFolderPath = TxtSkillsFolderPath.Text?.Trim() ?? "";
|
||||
_llm.AdditionalSkillFolders = ParseAdditionalSkillFolders(TxtAdditionalSkillFolders.Text);
|
||||
_llm.SlashPopupPageSize = ParseInt(TxtSlashPopupPageSize.Text, 7, 3, 20);
|
||||
_llm.EnableDragDropAiActions = ChkEnableDragDropAiActions.IsChecked == true;
|
||||
_llm.DragDropAutoSend = ChkDragDropAutoSend.IsChecked == true;
|
||||
@@ -564,7 +566,7 @@ public partial class AgentSettingsWindow : Window
|
||||
_settings.Settings.AiEnabled = true;
|
||||
_settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode);
|
||||
_settings.Save();
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot(), _llm.AdditionalSkillFolders);
|
||||
BuildSkillListPanel();
|
||||
DialogResult = true;
|
||||
Close();
|
||||
@@ -588,6 +590,19 @@ public partial class AgentSettingsWindow : Window
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ParseAdditionalSkillFolders(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return new List<string>();
|
||||
|
||||
return raw
|
||||
.Split(['\r', '\n', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(path => path.Trim())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void BtnBrowseSkillFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new System.Windows.Forms.FolderBrowserDialog
|
||||
@@ -642,7 +657,10 @@ public partial class AgentSettingsWindow : Window
|
||||
{
|
||||
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.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
new { Title = "보조/공용 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
new { Title = "레거시 명령 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
new { Title = "사용자/추가 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(s.Requires)).ToList() },
|
||||
new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
};
|
||||
|
||||
@@ -748,7 +766,12 @@ public partial class AgentSettingsWindow : Window
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var categories = new Dictionary<string, List<IAgentTool>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tool in tools.All)
|
||||
var visibleTools = AgentToolCatalog.FilterExposureByPermission(
|
||||
tools.All,
|
||||
tool => tool.Name,
|
||||
_llm.ToolPermissions);
|
||||
|
||||
foreach (var tool in visibleTools)
|
||||
{
|
||||
var category = AgentToolCatalog.GetMetadata(tool.Name).SettingsCategory;
|
||||
if (!categories.ContainsKey(category))
|
||||
|
||||
@@ -1284,7 +1284,7 @@ public partial class ChatWindow
|
||||
return;
|
||||
|
||||
_settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath;
|
||||
SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder());
|
||||
SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
|
||||
RefreshOverlayEtcPanels();
|
||||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||||
}
|
||||
@@ -3651,7 +3651,7 @@ public partial class ChatWindow
|
||||
if (llm.EnableSkillSystem)
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders);
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
}
|
||||
|
||||
|
||||
@@ -524,7 +524,7 @@ public partial class ChatWindow : Window
|
||||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
}
|
||||
|
||||
@@ -2247,7 +2247,7 @@ public partial class ChatWindow : Window
|
||||
return;
|
||||
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -5008,7 +5008,7 @@ public partial class ChatWindow : Window
|
||||
{
|
||||
llm.EnableSkillSystem = true;
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders);
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
ScheduleSettingsSave();
|
||||
_appState.LoadFromSettings(_settings);
|
||||
|
||||
@@ -4351,6 +4351,19 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="보조 스킬 폴더 목록"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="한 줄에 하나씩 입력합니다. 여러 공용 스킬 폴더나 팀 공유 폴더를 함께 연결할 때 사용합니다." Margin="0,0,0,6"/>
|
||||
<TextBox Style="{StaticResource ModernTextBox}"
|
||||
Text="{Binding AdditionalSkillFoldersText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
AcceptsReturn="True"
|
||||
MinHeight="72"
|
||||
TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<StackPanel HorizontalAlignment="Left">
|
||||
|
||||
@@ -96,7 +96,8 @@ public partial class SettingsWindow : Window
|
||||
Services.Agent.SkillService.EnsureSkillFolder();
|
||||
Services.Agent.SkillService.LoadSkills(
|
||||
app?.SettingsService?.Settings.Llm.SkillsFolderPath,
|
||||
ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm));
|
||||
ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm),
|
||||
app?.SettingsService?.Settings.Llm.AdditionalSkillFolders);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -454,7 +455,12 @@ public partial class SettingsWindow : Window
|
||||
if (AgentEtcContent == null) return;
|
||||
|
||||
using var registry = Services.Agent.ToolRegistry.CreateDefault();
|
||||
var toolGroups = registry.All
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var visibleTools = AgentToolCatalog.FilterExposureByPermission(
|
||||
registry.All,
|
||||
tool => tool.Name,
|
||||
app?.SettingsService?.Settings.Llm.ToolPermissions);
|
||||
var toolGroups = visibleTools
|
||||
.Select(tool => new { Tool = tool, Meta = AgentToolCatalog.GetMetadata(tool.Name) })
|
||||
.GroupBy(x => x.Meta.SettingsCategory, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
@@ -639,7 +645,7 @@ public partial class SettingsWindow : Window
|
||||
// 설명
|
||||
skillItems.Add(new TextBlock
|
||||
{
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/추가 스킬, 프로젝트 `.claude/skills`가 함께 포함됩니다.\n" +
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/공용 스킬, 플러그인 스킬, 프로젝트 `.claude/skills`, 레거시 command markdown가 함께 포함됩니다.\n" +
|
||||
"(직접 호출 스킬과 자동/조건부 보조 스킬을 함께 표시합니다.)",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(subtleText),
|
||||
@@ -649,8 +655,14 @@ public partial class SettingsWindow : Window
|
||||
|
||||
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 plugin = skills.Where(s => string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var additional = skills.Where(s => string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var legacy = skills.Where(s => string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var custom = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(s.SourceScope, "plugin", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(s.SourceScope, "additional", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(s.SourceScope, "legacy", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)
|
||||
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
@@ -667,6 +679,24 @@ public partial class SettingsWindow : Window
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (plugin.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("플러그인 스킬", "\uECCA", "#EC4899", plugin);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (additional.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("보조/공용 스킬", "\uE71D", "#0EA5E9", additional);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (legacy.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("레거시 명령 스킬", "\uE8A5", "#8B5CF6", legacy);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (custom.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("사용자/추가 스킬", "\uE70F", "#F59E0B", custom);
|
||||
|
||||
@@ -504,7 +504,7 @@ public partial class SkillEditorWindow : Window
|
||||
TxtName.Text = skill.Name;
|
||||
TxtLabel.Text = skill.Label;
|
||||
TxtDescription.Text = skill.Description;
|
||||
TxtInstructions.Text = skill.SystemPrompt;
|
||||
TxtInstructions.Text = SkillService.GetSkillPromptForDisplay(skill);
|
||||
|
||||
// 아이콘 선택
|
||||
_selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0];
|
||||
|
||||
@@ -619,7 +619,7 @@ public partial class SkillGalleryWindow : Window
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
};
|
||||
var promptText = skill.SystemPrompt;
|
||||
var promptText = SkillService.GetSkillPromptForDisplay(skill);
|
||||
if (promptText.Length > 2000)
|
||||
promptText = promptText[..2000] + "\n\n... (이하 생략)";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user