스킬 런타임 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user