스킬 소스 확장과 공통 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:
2026-04-14 18:23:18 +09:00
parent b17c865c4e
commit 1a9b3c4528
18 changed files with 557 additions and 96 deletions

View File

@@ -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);

View File

@@ -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))

View File

@@ -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}개 로드됨.");
}

View File

@@ -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,
}