Current section
Files
Jump to
Current section
Files
lib/cmdc/skill.ex
defmodule CMDC.Skill do
@moduledoc """
Skill(技能)元数据 struct + 发现与加载系统,对标 DeepAgents SkillsMiddleware。
Skill 是通过 SKILL.md 文件定义的领域知识片段,Agent 在启动时自动发现并按需注入到系统提示词。
## SKILL.md 文件格式
---
name: elixir-testing
description: Elixir 项目测试最佳实践
allowed_tools: # 可选,限制 Skill 激活时 Agent 可用工具
- read_file
- grep
- shell
---
## 测试规范
- 使用 ExUnit,describe 按功能分组
- Mock 外部服务,不 Mock 内部模块
...
## 两级目录
- **项目级**:`.cmdc/skills/` — 项目特定,优先级高于全局
- **全局级**:`~/.cmdc/skills/` — 所有项目通用
同名 Skill 以项目级为准(覆盖全局)。
## Skill Identity(`.skill_id` sidecar)
每个 Skill 目录可包含 `.skill_id` 文件,存储该 Skill 的持久唯一标识。
`discover/1` 首次发现时自动生成(格式 `{name}__imp_{uuid8}`),后续读取已有值。
此 ID 可供外部系统(如 cmdc_skill_engine)追踪版本、进化谱系等。
## 使用示例
# 发现所有 Skills
skills = CMDC.Skill.discover(["./skills", "~/.cmdc/skills"])
# 加载完整内容
{:ok, skill_with_content} = CMDC.Skill.load(skill)
# 生成系统提示词注入片段(仅 name + description,不含完整内容)
snippet = CMDC.Skill.to_prompt_snippet(skills)
"""
require Logger
# ==========================================================================
# 常量
# ==========================================================================
@skill_id_filename ".skill_id"
# ==========================================================================
# Struct 定义
# ==========================================================================
@type t :: %__MODULE__{
name: String.t(),
description: String.t(),
path: String.t(),
skill_id: String.t() | nil,
allowed_tools: [String.t()] | nil,
content: String.t() | nil
}
@enforce_keys [:name, :description, :path]
defstruct [
:name,
:description,
:path,
:skill_id,
:content,
allowed_tools: nil
]
# ==========================================================================
# 发现
# ==========================================================================
@doc """
在给定目录列表中发现所有 SKILL.md 文件,返回解析后的 `%Skill{}` 列表。
目录列表按**优先级从低到高**排列(后面的覆盖前面的同名 Skill)。
## 示例
skills = CMDC.Skill.discover(["~/.cmdc/skills", "./.cmdc/skills"])
# => [%CMDC.Skill{name: "elixir-testing", ...}, ...]
"""
@spec discover([String.t()]) :: [t()]
def discover(dirs) when is_list(dirs) do
dirs
|> Enum.flat_map(&discover_in_dir/1)
|> deduplicate_by_name()
end
@doc """
加载单个 Skill 的完整内容(包含 SKILL.md 正文部分)。
如果 Skill 已有 `:content`,直接返回;否则从文件重新读取。
## 示例
{:ok, loaded} = CMDC.Skill.load(skill)
loaded.content # => "## 测试规范\n\n..."
"""
@spec load(t()) :: {:ok, t()} | {:error, String.t()}
def load(%__MODULE__{content: content} = skill) when not is_nil(content) do
{:ok, skill}
end
def load(%__MODULE__{path: path} = skill) do
case File.read(path) do
{:ok, raw} ->
content = extract_content(raw)
{:ok, %{skill | content: content}}
{:error, reason} ->
{:error, "无法读取 Skill 文件 #{path}: #{inspect(reason)}"}
end
end
@doc """
生成系统提示词注入片段(仅 name + description 列表,不含完整内容)。
供 Agent 启动时注入到系统提示词,引导 LLM 了解可用的 Skills。
LLM 可在需要时通过工具加载完整内容。
## 示例
snippet = CMDC.Skill.to_prompt_snippet(skills)
# =>
# ## 可用 Skills
#
# - **elixir-testing**: Elixir 项目测试最佳实践
# - **git-workflow**: Git 工作流约定
"""
@spec to_prompt_snippet([t()]) :: String.t()
def to_prompt_snippet([]), do: ""
def to_prompt_snippet(skills) when is_list(skills) do
items =
Enum.map_join(skills, "\n", fn skill -> "- **#{skill.name}**: #{skill.description}" end)
"## 可用 Skills\n\n#{items}"
end
@doc """
按名称查找 Skill,未找到返回 `nil`。
"""
@spec find([t()], String.t()) :: t() | nil
def find(skills, name) when is_list(skills) do
Enum.find(skills, fn skill -> skill.name == name end)
end
@doc """
按 skill_id 查找 Skill,未找到返回 `nil`。
"""
@spec find_by_id([t()], String.t()) :: t() | nil
def find_by_id(skills, skill_id) when is_list(skills) do
Enum.find(skills, fn skill -> skill.skill_id == skill_id end)
end
@doc """
写入(或覆盖)Skill 目录下的 `.skill_id` sidecar 文件。
供外部系统(如 cmdc_skill_engine)在 FIX/DERIVED/CAPTURED 进化后
更新 Skill 身份标识。
"""
@spec write_skill_id(String.t(), String.t()) :: :ok | {:error, term()}
def write_skill_id(skill_dir, skill_id) when is_binary(skill_dir) and is_binary(skill_id) do
id_file = Path.join(skill_dir, @skill_id_filename)
case File.write(id_file, skill_id <> "\n") do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
# ==========================================================================
# 私有辅助
# ==========================================================================
defp discover_in_dir(dir) do
expanded = Path.expand(dir)
case File.ls(expanded) do
{:ok, entries} ->
Enum.flat_map(entries, &discover_entry(expanded, &1))
{:error, _} ->
[]
end
end
defp discover_entry(expanded, entry) do
entry_path = Path.join(expanded, entry)
cond do
File.dir?(entry_path) ->
skill_file = Path.join(entry_path, "SKILL.md")
if File.exists?(skill_file), do: parse_skill_file(skill_file), else: []
String.downcase(entry) == "skill.md" ->
parse_skill_file(entry_path)
true ->
[]
end
end
defp parse_skill_file(path) do
case File.read(path) do
{:ok, content} ->
case parse_frontmatter(content) do
{:ok, meta, _body} -> build_skill_from_meta(meta, path)
{:error, _} -> []
end
{:error, _} ->
[]
end
end
defp build_skill_from_meta(meta, path) do
name = Map.get(meta, "name")
description = Map.get(meta, "description", "")
if is_binary(name) and name != "" do
skill_dir = Path.dirname(path)
skill_id = read_or_create_skill_id(name, skill_dir)
skill = %__MODULE__{
name: name,
description: description,
path: path,
skill_id: skill_id,
content: nil,
allowed_tools: parse_allowed_tools(Map.get(meta, "allowed_tools"))
}
[skill]
else
[]
end
end
defp parse_frontmatter(content) do
case String.split(content, ~r/^---\s*$/m, parts: 3) do
["", yaml_str, body] ->
case YamlElixir.read_from_string(yaml_str) do
{:ok, meta} when is_map(meta) -> {:ok, meta, String.trim_leading(body)}
{:ok, _} -> {:error, :invalid_frontmatter}
{:error, reason} -> {:error, reason}
end
_ ->
{:error, :no_frontmatter}
end
end
defp extract_content(raw) do
case String.split(raw, ~r/^---\s*$/m, parts: 3) do
["", _yaml, body] -> String.trim_leading(body)
_ -> raw
end
end
defp parse_allowed_tools(nil), do: nil
defp parse_allowed_tools([]), do: nil
defp parse_allowed_tools(tools) when is_list(tools), do: Enum.map(tools, &to_string/1)
defp parse_allowed_tools(_), do: nil
# ==========================================================================
# 私有 — Skill Identity (.skill_id sidecar)
# ==========================================================================
defp read_or_create_skill_id(name, skill_dir) do
id_file = Path.join(skill_dir, @skill_id_filename)
case File.read(id_file) do
{:ok, content} ->
existing = String.trim(content)
if existing != "", do: existing, else: generate_and_write_skill_id(name, id_file)
{:error, _} ->
generate_and_write_skill_id(name, id_file)
end
end
defp generate_and_write_skill_id(name, id_file) do
uuid8 = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
new_id = "#{name}__imp_#{uuid8}"
case File.write(id_file, new_id <> "\n") do
:ok ->
Logger.debug("[CMDC.Skill] 生成 .skill_id: #{new_id}")
{:error, reason} ->
Logger.warning("[CMDC.Skill] 无法写入 #{id_file}: #{inspect(reason)}")
end
new_id
end
# 后面目录的 Skill 覆盖前面同名 Skill(优先级从低到高排列)
defp deduplicate_by_name(skills) do
skills
|> Enum.reduce(%{}, fn skill, acc -> Map.put(acc, skill.name, skill) end)
|> Map.values()
|> Enum.sort_by(& &1.name)
end
end