fix: 兼容 Skill 展示名与 slug 分离

This commit is contained in:
Wenjie Zhang 2026-06-03 20:29:04 +08:00
parent f54abbd631
commit dc6443755f
4 changed files with 289 additions and 38 deletions

View File

@ -531,14 +531,23 @@ async def update_skill_dependencies(
)
def _validate_skill_name(name: str) -> str:
def _validate_skill_slug_value(slug: str, *, field_name: str) -> str:
slug = slug.strip()
if not slug:
raise ValueError(f"SKILL.md frontmatter 缺少 {field_name}")
if len(slug) > 128:
raise ValueError(f"SKILL.md frontmatter.{field_name} 长度不能超过 128")
if not SKILL_NAME_PATTERN.match(slug):
raise ValueError(f"SKILL.md frontmatter.{field_name} 必须是小写字母/数字/短横线,且不能连续短横线")
return slug
def _validate_skill_display_name(name: str) -> str:
name = name.strip()
if not name:
raise ValueError("SKILL.md frontmatter 缺少 name")
if len(name) > 128:
raise ValueError("skill name 长度不能超过 128")
if not SKILL_NAME_PATTERN.match(name):
raise ValueError("skill name 必须是小写字母/数字/短横线,且不能连续短横线")
raise ValueError("SKILL.md frontmatter.name 长度不能超过 128")
return name
@ -565,7 +574,7 @@ def _split_frontmatter(content: str) -> tuple[str, str]:
return frontmatter_raw, body
def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]:
def _parse_skill_markdown(content: str) -> tuple[str, str, str, dict[str, Any]]:
frontmatter_raw, _body = _split_frontmatter(content)
try:
data = yaml.safe_load(frontmatter_raw)
@ -575,20 +584,27 @@ def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]:
if not isinstance(data, dict):
raise ValueError("SKILL.md frontmatter 必须是对象")
name = _validate_skill_name(str(data.get("name", "")))
name = _validate_skill_display_name(str(data.get("name", "")))
raw_slug = str(data.get("slug", "")).strip()
slug = _validate_skill_slug_value(raw_slug, field_name="slug") if raw_slug else _validate_skill_slug_value(
name, field_name="name"
)
description = str(data.get("description", "")).strip()
if not description:
raise ValueError("SKILL.md frontmatter 缺少 description")
return name, description, data
return slug, name, description, data
def _rewrite_frontmatter_name(content: str, new_name: str) -> str:
def _rewrite_frontmatter_slug(content: str, new_slug: str) -> str:
frontmatter_raw, body = _split_frontmatter(content)
data = yaml.safe_load(frontmatter_raw)
if not isinstance(data, dict):
raise ValueError("SKILL.md frontmatter 必须是对象")
data["name"] = new_name
if data.get("slug"):
data["slug"] = new_slug
else:
data["name"] = new_slug
dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
return f"---\n{dumped}\n---\n{body}"
@ -621,8 +637,9 @@ def _parse_skill_dir_metadata(source_skill_dir: Path) -> dict[str, Any]:
raise ValueError("技能目录缺少根级 SKILL.md")
content = skill_md_path.read_text(encoding="utf-8")
parsed_name, parsed_desc, meta = _parse_skill_markdown(content)
parsed_slug, parsed_name, parsed_desc, meta = _parse_skill_markdown(content)
return {
"slug": parsed_slug,
"name": parsed_name,
"description": parsed_desc,
"tool_dependencies": normalize_string_list(meta.get("tool_dependencies")),
@ -641,19 +658,19 @@ async def _stage_skill_draft_item(
item_dir = draft_items_dir / item_id
shutil.copytree(source_skill_dir, item_dir, symlinks=False)
parsed = _parse_skill_dir_metadata(item_dir)
final_slug = await _generate_available_slug(repo, parsed["name"])
final_slug = await _generate_available_slug(repo, parsed["slug"])
return {
"draft_item_id": item_id,
"source_dir": f"items/{item_id}",
"slug": final_slug,
"name": final_slug,
"original_name": parsed["name"],
"name": parsed["name"],
"original_name": parsed["slug"],
"description": parsed["description"],
"tool_dependencies": parsed["tool_dependencies"],
"mcp_dependencies": parsed["mcp_dependencies"],
"skill_dependencies": parsed["skill_dependencies"],
"warnings": [f"原始名称 {parsed['name']} 已存在,将安装为 {final_slug}"]
if final_slug != parsed["name"]
"warnings": [f"原始 slug {parsed['slug']} 已存在,将安装为 {final_slug}"]
if final_slug != parsed["slug"]
else [],
"success": True,
}
@ -683,14 +700,14 @@ async def _import_skill_dir_impl(
repo = SkillRepository(db)
skills_root = get_skills_root_dir()
parsed = _parse_skill_dir_metadata(source_skill_dir)
final_slug = await _generate_available_slug(repo, parsed["name"])
final_slug = await _generate_available_slug(repo, parsed["slug"])
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
stage_dir = Path(temp_root) / "stage"
shutil.copytree(source_skill_dir, stage_dir)
if final_slug != parsed["name"]:
if final_slug != parsed["slug"]:
content = (stage_dir / "SKILL.md").read_text(encoding="utf-8")
(stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_name(content, final_slug), encoding="utf-8")
(stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_slug(content, final_slug), encoding="utf-8")
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
if temp_target.exists():
@ -706,7 +723,7 @@ async def _import_skill_dir_impl(
try:
item = await repo.create(
slug=final_slug,
name=final_slug,
name=parsed["name"],
description=parsed["description"],
source_type=source_type,
tool_dependencies=parsed["tool_dependencies"],
@ -942,9 +959,9 @@ async def confirm_skill_install_draft(
with tempfile.TemporaryDirectory(prefix=".skill-confirm-", dir=str(skills_root.parent)) as temp_root:
stage_dir = Path(temp_root) / "stage"
shutil.copytree(source_dir, stage_dir)
if parsed["name"] != slug:
if parsed["slug"] != slug:
content = (stage_dir / "SKILL.md").read_text(encoding="utf-8")
(stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_name(content, slug), encoding="utf-8")
(stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_slug(content, slug), encoding="utf-8")
temp_target = skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}"
shutil.move(str(stage_dir), str(temp_target))
@ -958,7 +975,7 @@ async def confirm_skill_install_draft(
try:
item = await repo.create(
slug=slug,
name=slug,
name=parsed["name"],
description=parsed["description"],
source_type=source_type,
tool_dependencies=parsed["tool_dependencies"],
@ -1135,9 +1152,9 @@ async def _update_skill_metadata_if_skills_md(
) -> None:
"""如果目标文件是 SKILL.md则解析并更新元数据"""
if target.name == "SKILL.md" and target.parent == skill_dir:
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
if parsed_name != item.slug:
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
parsed_slug, parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
if parsed_slug != item.slug:
raise ValueError("SKILL.md frontmatter.slug 必须与 skill slug 一致")
repo = SkillRepository(db)
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
@ -1267,14 +1284,14 @@ def list_builtin_skill_specs() -> list[dict[str, Any]]:
raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}")
content = skill_md.read_text(encoding="utf-8")
parsed_name, parsed_desc, meta = _parse_skill_markdown(content)
if parsed_name != slug:
raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}")
parsed_slug, parsed_name, parsed_desc, meta = _parse_skill_markdown(content)
if parsed_slug != slug:
raise ValueError(f"内置 skill frontmatter.slug 必须等于 slug: {slug}")
specs.append(
{
"slug": slug,
"name": slug,
"name": parsed_name,
"description": configured_description or parsed_desc,
"version": version,
"tool_dependencies": configured_tools or normalize_string_list(meta.get("tool_dependencies")),

View File

@ -74,8 +74,14 @@ async def test_list_visible_skills_for_management_includes_owned_disabled_and_en
@pytest.mark.parametrize(
"skill,operator",
[
(Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False), _user("root", role="user")),
(Skill(slug="admin-disabled", name="admin-disabled", description="", created_by="other", enabled=False), _user("root", role="admin")),
(
Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False),
_user("root", role="user"),
),
(
Skill(slug="admin-disabled", name="admin-disabled", description="", created_by="other", enabled=False),
_user("root", role="admin"),
),
(
Skill(
slug="shared-enabled",
@ -233,12 +239,32 @@ async def test_normal_user_confirm_skill_draft_rejects_wider_share_scope(
def test_parse_skill_markdown_ok():
content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n"
name, desc, meta = svc._parse_skill_markdown(content)
slug, name, desc, meta = svc._parse_skill_markdown(content)
assert slug == "demo-skill"
assert name == "demo-skill"
assert desc == "demo description"
assert meta["name"] == "demo-skill"
def test_parse_skill_markdown_supports_display_name_with_slug():
content = (
"---\n"
"name: Word / DOCX\n"
"slug: word-docx\n"
"version: 1.0.2\n"
"homepage: https://clawic.com/skills/word-docx\n"
"description: Create, inspect, and edit Microsoft Word documents.\n"
'metadata: {"clawdbot":{"emoji":"📘","os":["linux","darwin","win32"]}}\n'
"---\n"
"# Word / DOCX\n"
)
slug, name, desc, meta = svc._parse_skill_markdown(content)
assert slug == "word-docx"
assert name == "Word / DOCX"
assert desc == "Create, inspect, and edit Microsoft Word documents."
assert meta["version"] == "1.0.2"
def test_parse_skill_markdown_requires_frontmatter():
with pytest.raises(ValueError, match="frontmatter"):
svc._parse_skill_markdown("# missing")
@ -385,6 +411,211 @@ async def test_skill_upload_prepare_confirm_rewrites_conflicting_name(tmp_path:
assert "name: demo-v2" in skill_md
@pytest.mark.asyncio
async def test_skill_zip_import_uses_skill_md_name_not_zip_or_root_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
created_item: Skill | None = None
def __init__(self, _db):
pass
async def exists_slug(self, _slug: str) -> bool:
return False
async def create(self, **kwargs) -> Skill:
item = Skill(**kwargs, updated_by=kwargs["created_by"])
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"Bad--Archive-Name/SKILL.md": (
"---\nname: valid-skill\ndescription: this is valid\n---\n# Valid\n"
),
"Bad--Archive-Name/prompts/system.md": "Use valid skill metadata.",
}
)
operator = _user("root")
draft = await svc.prepare_skill_upload(
None,
filename="Bad--Archive-Name.zip",
file_bytes=zip_bytes,
operator=operator,
)
results = await svc.confirm_skill_install_draft(
None,
draft_id=draft["draft_id"],
share_config=draft["default_share_config"],
operator=operator,
)
assert draft["items"][0]["original_name"] == "valid-skill"
assert draft["items"][0]["slug"] == "valid-skill"
assert results[0]["success"] is True
assert results[0]["slug"] == "valid-skill"
assert FakeRepo.created_item.slug == "valid-skill"
assert (tmp_path / "skills" / "valid-skill" / "SKILL.md").exists()
@pytest.mark.asyncio
async def test_skill_zip_import_validates_skill_md_name_not_zip_filename(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
def __init__(self, _db):
pass
async def exists_slug(self, _slug: str) -> bool:
return False
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"valid-archive/SKILL.md": (
"---\nname: invalid--skill\ndescription: invalid name\n---\n# Invalid\n"
),
}
)
with pytest.raises(ValueError, match="SKILL.md frontmatter.name 必须是小写字母/数字/短横线"):
await svc.prepare_skill_upload(
None,
filename="valid-archive.zip",
file_bytes=zip_bytes,
operator=_user("root"),
)
@pytest.mark.asyncio
async def test_skill_zip_import_uses_frontmatter_slug_and_keeps_display_name(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
created_item: Skill | None = None
def __init__(self, _db):
pass
async def exists_slug(self, _slug: str) -> bool:
return False
async def create(self, **kwargs) -> Skill:
item = Skill(**kwargs, updated_by=kwargs["created_by"])
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"Word Skill/SKILL.md": (
"---\n"
"name: Word / DOCX\n"
"slug: word-docx\n"
"version: 1.0.2\n"
"homepage: https://clawic.com/skills/word-docx\n"
"description: Create, inspect, and edit Microsoft Word documents.\n"
"changelog: Tightened review workflows.\n"
'metadata: {"clawdbot":{"emoji":"📘","os":["linux","darwin","win32"]}}\n'
"---\n"
"# Word / DOCX\n"
)
}
)
operator = _user("root")
draft = await svc.prepare_skill_upload(
None,
filename="Word Skill.zip",
file_bytes=zip_bytes,
operator=operator,
)
results = await svc.confirm_skill_install_draft(
None,
draft_id=draft["draft_id"],
share_config=draft["default_share_config"],
operator=operator,
)
assert draft["items"][0]["slug"] == "word-docx"
assert draft["items"][0]["name"] == "Word / DOCX"
assert results[0]["success"] is True
assert results[0]["slug"] == "word-docx"
assert FakeRepo.created_item.slug == "word-docx"
assert FakeRepo.created_item.name == "Word / DOCX"
@pytest.mark.asyncio
async def test_skill_zip_import_rewrites_conflicting_slug_not_display_name(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
existing_slugs = {"word-docx"}
created_item: Skill | None = None
def __init__(self, _db):
pass
async def exists_slug(self, slug: str) -> bool:
return slug in self.__class__.existing_slugs
async def create(self, **kwargs) -> Skill:
item = Skill(**kwargs, updated_by=kwargs["created_by"])
self.__class__.existing_slugs.add(item.slug)
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip(
{
"Word Skill/SKILL.md": (
"---\n"
"name: Word / DOCX\n"
"slug: word-docx\n"
"description: Create, inspect, and edit Microsoft Word documents.\n"
"---\n"
"# Word / DOCX\n"
)
}
)
operator = _user("root")
draft = await svc.prepare_skill_upload(
None,
filename="Word Skill.zip",
file_bytes=zip_bytes,
operator=operator,
)
results = await svc.confirm_skill_install_draft(
None,
draft_id=draft["draft_id"],
share_config=draft["default_share_config"],
operator=operator,
)
assert results[0]["slug"] == "word-docx-v2"
assert results[0]["success"] is True
assert FakeRepo.created_item.name == "Word / DOCX"
skill_md = (tmp_path / "skills" / "word-docx-v2" / "SKILL.md").read_text(encoding="utf-8")
assert "name: Word / DOCX" in skill_md
assert "slug: word-docx-v2" in skill_md
@pytest.mark.asyncio
async def test_skill_md_prepare_confirm_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))

View File

@ -77,7 +77,8 @@ my-awesome-skill/
```markdown
---
name: my-awesome-skill
name: My Awesome Skill
slug: my-awesome-skill
description: 这是一个用于处理特定任务的技能
---
@ -99,7 +100,8 @@ description: 这是一个用于处理特定任务的技能
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | Skill 名称,必须是小写字母、数字、短横线的组合(如 `my-skill` |
| `name` | 是 | Skill 展示名称,可使用更易读的名称(如 `Word / DOCX` |
| `slug` | 否 | Skill 唯一标识,必须是小写字母、数字、短横线的组合,且不能连续短横线(如 `my-skill`)。未填写时兼容旧格式,系统会使用 `name` 作为 slug此时 `name` 也必须满足 slug 规则 |
| `description` | 是 | Skill 的功能描述,会在 Agent 配置时展示 |
### 导入 Skill
@ -237,9 +239,10 @@ Skills 管理采用基于角色的权限控制:
### Skill 命名规范
- 使用小写字母、数字和短横线
- 具有描述性,如 `weather-query`、`sql-reporter`
- 避免过长的名称
- `slug` 使用小写字母、数字和短横线,不能连续短横线
- `slug` 应具有描述性,如 `weather-query`、`sql-reporter`
- `name` 用于展示,可比 `slug` 更自然,例如 `Word / DOCX`
- 避免过长的 `name``slug`
### 依赖管理建议

View File

@ -49,7 +49,7 @@
- 新增 Milvus 图谱检索链路Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity读取 Neo4j 2-hop 子图后用 igraph 执行 PPR最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。
- 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。
- 调整 Agent 资源默认选择与运行时上下文未显式配置工具、知识库、MCP、Skills 时默认启用当前用户可访问/可用的全部资源子智能体默认不启用显式保存空列表仍表示不启用对应资源Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。
- 重构 Skills 权限与安装流程Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态支持启停但不允许删除上传和远程添加统一为解析草稿后确认生效范围管理端支持编辑生效范围与启停Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活Skills prompt 改为模型请求级注入以避免污染 runtime context。
- 重构 Skills 权限与安装流程Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,安装 slug 优先读取 `SKILL.md``slug` 字段并保留 `name` 展示名,压缩包名称不参与 slug 校验;管理端支持编辑生效范围与启停Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活Skills prompt 改为模型请求级注入以避免污染 runtime context。
- 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。
- 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。