# 技能系统
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `../../src/skills/`
---
## 概述
技能系统允许用户以 Markdown 文件的形式定义可复用的指令集,通过斜杠命令或提示注入的方式触发。原始实现扫描 `.free-code/skills/` 目录,解析每个文件的 YAML frontmatter 获取元数据,并将 Markdown 正文作为 System Prompt 内容注入查询引擎。
.NET 重写通过 `ISkillLoader` 接口封装这一流程,用 `SkillDefinition` record 描述单条技能,用 `SkillHooks` record 描述生命周期钩子。`SkillLoader` 是默认实现,负责目录扫描、YAML 解析、结果缓存和执行委托。
---
## 16.1 接口与数据模型
```csharp
///
/// 技能加载器 — 从 .free-code/skills/ 目录加载 SKILL.md
/// 对应原始 skill system 的目录扫描 + frontmatter 解析
///
public interface ISkillLoader
{
Task> LoadAllSkillsAsync();
Task LoadSkillAsync(string skillName);
Task ExecuteSkillAsync(SkillDefinition skill, string? args);
}
```
### SkillDefinition
```csharp
///
/// 技能定义 — 对应一个 SKILL.md 文件的完整解析结果
///
public sealed record SkillDefinition
{
/// 技能名称,来自 frontmatter 或文件名
public required string Name { get; init; }
/// 技能描述,用于斜杠命令帮助文本
public string? Description { get; init; }
/// Markdown 正文,作为 System Prompt 注入
public required string Content { get; init; }
/// 技能允许使用的工具名称列表
public IReadOnlyList Tools { get; init; } = [];
/// 覆盖默认模型(如 "claude-haiku-4-5")
public string? Model { get; init; }
/// 技能参数定义(参数名 → 描述)
public IReadOnlyDictionary? Arguments { get; init; }
/// 生命周期钩子
public SkillHooks? Hooks { get; init; }
/// 磁盘路径,仅供调试
public string? FilePath { get; init; }
}
```
### SkillHooks
```csharp
///
/// 技能生命周期钩子
/// 对应原始 skill hooks 机制
///
public sealed record SkillHooks
{
/// 执行前的提示注入片段
public string? PreExecute { get; init; }
/// 执行后的提示注入片段
public string? PostExecute { get; init; }
/// 发生错误时的提示注入片段
public string? OnError { get; init; }
}
```
**设计意图**
`SkillDefinition` 使用 `sealed record` 确保不可变性:一旦从磁盘解析完成,技能定义在整个会话生命周期内不会改变。`Tools` 字段对应原始 frontmatter 中的 `tools` 数组,允许技能声明自己需要哪些工具,`ToolRegistry` 在组装工具池时可据此过滤。
---
## 16.2 SkillLoader 实现
```csharp
public sealed class SkillLoader : ISkillLoader
{
private readonly string[] _skillDirectories;
private List? _cached;
private readonly ILogger _logger;
public SkillLoader(ILogger logger)
{
_logger = logger;
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var cwd = Directory.GetCurrentDirectory();
// 扫描顺序: 项目级 → 用户级
_skillDirectories =
[
Path.Combine(cwd, ".free-code", "skills"),
Path.Combine(home, ".free-code", "skills"),
];
}
public async Task> LoadAllSkillsAsync()
{
if (_cached != null) return _cached;
_cached = [];
foreach (var dir in _skillDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.md", SearchOption.AllDirectories))
{
try
{
var skill = await ParseSkillFileAsync(file);
if (skill != null) _cached.Add(skill);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load skill from {File}", file);
}
}
}
return _cached;
}
public async Task LoadSkillAsync(string skillName)
{
var all = await LoadAllSkillsAsync();
return all.FirstOrDefault(s =>
string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase));
}
///
/// 解析 SKILL.md — YAML frontmatter + Markdown body
///
private static async Task ParseSkillFileAsync(string filePath)
{
var content = await File.ReadAllTextAsync(filePath);
if (!content.StartsWith("---")) return null;
var endIdx = content.IndexOf("\n---", 3);
if (endIdx < 0) return null;
var frontmatter = content[3..endIdx];
var body = content[(endIdx + 4)..].TrimStart();
// 解析 YAML frontmatter
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
var meta = deserializer.Deserialize>(frontmatter);
return new SkillDefinition
{
Name = meta.GetValueOrDefault("name",
Path.GetFileNameWithoutExtension(filePath))?.ToString() ?? "",
Description = meta.GetValueOrDefault("description")?.ToString(),
Content = body,
Tools = (meta.GetValueOrDefault("tools") as List