From 6e9e6977581f64370a146178b44a47522bf9a56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Fri, 31 Jul 2026 07:28:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(general-skills):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Agent=20Skills=20=E5=AE=98=E6=96=B9=E8=A7=84=E8=8C=83(agents?= =?UTF-8?q?kills.io)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规范层(app/general_skills/standard.py): - name/description 规范校验(1-64 小写连字符/1-1024) - YAML frontmatter 解析与 SKILL.md 组装;可选字段 license/compatibility/metadata/allowed-tools 全支持 - name 恒等于 slug(规范:与目录名一致);存量技能读路径同样输出规范形态 保存与发布: - /import 与 _create_imported_general_skill(zip/GitHub/clawhub)统一归一化: frontmatter 以表单/解析字段重组、正文保留;新建 slug 不合规 400, 导入名不合规自动 _slugify 清洗;published 缺 description 400,草稿宽限 - publish 端点发布前强制规范校验 - 新增 GET /{slug}/export 导出标准 zip(根目录=slug,SKILL.md 规范化) 运行时: - 物化到工作区的 SKILL.md 一律规范化 - allowed-tools 门控:未声明 Bash 时 runtime 仅 python(计划输入约束+ 计划后强制),声明 Bash(...) 或缺省不限制 前端: - 新建模板换标准 SKILL.md skeleton;基本信息新增 license/compatibility/ allowed-tools 三字段(与 frontmatter 同步) - 文件编辑器支持任意路径(scripts/references/assets) - 编辑器新增"导出技能包"按钮 测试:规范层 10 例、保存归一化/非法 slug/发布校验/导出 zip 往返/ 物化标准化/门控等;存量测试按归一化新行为对齐;全量 1243 passed、 ruff 零告警、前端 build 与 i18n 新增文案齐全 --- backend/app/api/general_skills.py | 82 ++++++ backend/app/general_skills/runner.py | 29 ++- backend/app/general_skills/schema.py | 8 + backend/app/general_skills/standard.py | 189 ++++++++++++++ backend/tests/test_agent_permissions.py | 1 + backend/tests/test_general_skill_standard.py | 150 +++++++++++ backend/tests/test_general_skills.py | 233 +++++++++++++++++- .../tests/test_resource_creator_metadata.py | 2 + frontend-enterprise/src/i18n/en.json | 18 +- .../src/pages/GeneralSkillsPage.tsx | 84 ++++++- frontend-enterprise/src/types/index.ts | 3 + 11 files changed, 779 insertions(+), 20 deletions(-) create mode 100644 backend/app/general_skills/standard.py create mode 100644 backend/tests/test_general_skill_standard.py diff --git a/backend/app/api/general_skills.py b/backend/app/api/general_skills.py index 9f11a724..dd283d3d 100644 --- a/backend/app/api/general_skills.py +++ b/backend/app/api/general_skills.py @@ -49,6 +49,14 @@ ) from app.general_skills.runner import GeneralSkillReader, GeneralSkillRunner from app.general_skills.schema import GeneralSkillFile +from app.general_skills.standard import ( + compose_skill_markdown, + frontmatter_for_skill, + split_frontmatter, + standard_package_files, + validate_skill_description, + validate_skill_name, +) from app.llm.model_config_resolver import resolve_model_config_for_runtime from app.security.auth import get_current_user from app.security.permissions import ( @@ -82,6 +90,7 @@ def _agent_id_or_none(agent_id: object | None) -> str | None: def general_skill_read(row: GeneralSkill, status_override: str | None = None) -> GeneralSkillRead: + frontmatter = frontmatter_for_skill(row) return GeneralSkillRead( id=row.id, tenant_id=row.tenant_id, @@ -89,6 +98,9 @@ def general_skill_read(row: GeneralSkill, status_override: str | None = None) -> name=row.name, description=row.description, homepage=row.homepage, + license=frontmatter["license"] or None, + compatibility=frontmatter["compatibility"] or None, + allowed_tools=frontmatter["allowed_tools"] or None, skill_markdown=row.skill_markdown, skill_files=[ GeneralSkillFile.model_validate(item) for item in _skill_files_or_markdown(row) @@ -134,6 +146,29 @@ def import_general_skill( ) _validate_slug(slug) lookup_slug = _optional_text(request.original_slug) + # Agent Skills 规范校验:新建时 slug(即规范 name,与目录名一致)必须合规; + # 存量 slug 不可修改,编辑时宽限(只拦新格式违规的新建) + if not lookup_slug and (error := validate_skill_name(slug)): + raise HTTPException(status_code=400, detail=f"Slug(name):{error}") + if error := validate_skill_description(description, required=request.status == "published"): + raise HTTPException(status_code=400, detail=error) + # SKILL.md 归一化:frontmatter 以表单/规范字段重组(name 恒等于 slug),正文保留 + existing_frontmatter, markdown_body = split_frontmatter(markdown) + markdown = compose_skill_markdown( + name=slug, + description=description or "", + body=markdown_body, + license=_optional_text(request.license) or str(existing_frontmatter.get("license") or ""), + compatibility=_optional_text(request.compatibility) + or str(existing_frontmatter.get("compatibility") or ""), + allowed_tools=_optional_text(request.allowed_tools) + or str(existing_frontmatter.get("allowed-tools") or ""), + metadata=( + existing_frontmatter.get("metadata") + if isinstance(existing_frontmatter.get("metadata"), dict) + else {} + ), + ) agent_id = _agent_id_or_none(request.agent_id) agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user) is_private_agent_scope = bool(agent and not agent.is_overall) @@ -391,6 +426,26 @@ def _create_imported_general_skill( or _clawhub_homepage_from_source(import_source) ) _validate_slug(resolved_slug) + # Agent Skills 规范:slug 必须合规范(name 形态);不合规的导入名经 _slugify 清洗 + if validate_skill_name(resolved_slug): + resolved_slug = _unique_slug(db, tenant_id, _slugify(resolved_slug)) + if error := validate_skill_description(resolved_description, required=status == "published"): + raise HTTPException(status_code=400, detail=error) + # SKILL.md 归一化:frontmatter 以解析/规范字段重组(name 恒等于 slug),正文保留 + existing_frontmatter, markdown_body = split_frontmatter(markdown) + markdown = compose_skill_markdown( + name=resolved_slug, + description=resolved_description or "", + body=markdown_body, + license=str(existing_frontmatter.get("license") or ""), + compatibility=str(existing_frontmatter.get("compatibility") or ""), + allowed_tools=str(existing_frontmatter.get("allowed-tools") or ""), + metadata=( + existing_frontmatter.get("metadata") + if isinstance(existing_frontmatter.get("metadata"), dict) + else {} + ), + ) now = utc_now() resolved_agent_id = _agent_id_or_none(agent_id) agent = ensure_agent_scope_manager(db, tenant_id, resolved_agent_id, current_user) @@ -519,6 +574,28 @@ def get_general_skill( return general_skill_read(row) +@router.get("/{slug}/export", dependencies=[Depends(require_agent_scope_viewer)]) +def export_general_skill( + slug: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + agent_id: str | None = Query(None), +) -> StreamingResponse: + """导出标准 Agent Skills 包(zip):根目录为 slug,SKILL.md 为规范化版本。""" + row = _get_general_skill(db, tenant_id, slug) + _ensure_general_skill_visible(db, tenant_id, row, agent_id) + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for file in standard_package_files(row): + archive.writestr(f"{row.slug}/{file['path']}", file["content"]) + buffer.seek(0) + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{row.slug}.zip"'}, + ) + + @router.post("/{slug}/publish", response_model=GeneralSkillRead) def publish_general_skill( slug: str, @@ -544,6 +621,11 @@ def publish_general_skill( db.commit() return general_skill_read(row, status_override="published") ensure_open_gallery_admin(tenant_id, current_user) + # 发布前必须通过 Agent Skills 规范校验(name/description 必填且合规) + if error := validate_skill_name(row.slug): + raise HTTPException(status_code=400, detail=f"发布失败:slug(name):{error}") + if error := validate_skill_description(row.description, required=True): + raise HTTPException(status_code=400, detail=f"发布失败:{error}") row.status = "published" mark_resource_open_gallery(row, row.metadata_json or {}) row.updated_at = utc_now() diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index 33e9411f..0fba203a 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -17,6 +17,7 @@ from app import paths from app.db.models import GeneralSkill, ModelConfig from app.general_skills.runtime_env import ( + GeneralSkillRuntimeError, ensure_runtime_python, runtime_environment, @@ -31,6 +32,7 @@ from app.harness.artifacts import HarnessArtifactAccessError, normalize_harness_artifact_path from app.harness.command import run_sandboxed_process from app.harness.errors import HarnessExecutionError +from app.general_skills.standard import allowed_tools_list, standard_package_files from app.llm import LLMClient, LLMError from app.llm.model_config_resolver import snapshot_model_config from app.llm.stage_protocol import stage_payload, unified_system_prompt @@ -126,6 +128,24 @@ def decide( return decision.model_copy(update={"use_general_skill": False, "selected_slug": None}) +def _bash_allowed(skill: GeneralSkill) -> bool: + """allowed-tools 声明了 Bash 才放行 bash runtime;未声明 allowed-tools 不限制。""" + tools = allowed_tools_list(skill) + if not tools: + return True + return any(tool.startswith("Bash") for tool in tools) + + +def _runtime_languages(skill: GeneralSkill) -> list[str]: + return ["bash", "python"] if _bash_allowed(skill) else ["python"] + + +def _enforce_allowed_tools_runtime(skill: GeneralSkill, plan: GeneralSkillExecutionPlan) -> None: + """allowed-tools 门控:未声明 Bash 而模型仍给出 bash 计划时拒绝,促使其改用 python。""" + if plan.runtime == "bash" and not _bash_allowed(skill): + raise LLMError("该技能的 allowed-tools 未声明 Bash,请改用 python runtime 重新生成") + + class GeneralSkillReader: """Explain a skill package without generating or executing runner code.""" @@ -433,7 +453,7 @@ def _generate_plan( "package": _skill_package_payload(skill), }, "runtime": { - "languages": ["bash", "python"], + "languages": _runtime_languages(skill), "stdin_json": { "query": query, "skill_slug": skill.slug, @@ -461,6 +481,7 @@ def _generate_plan( ) plan = GeneralSkillExecutionPlan.model_validate(raw) plan.runtime = _plan_runtime(plan) + _enforce_allowed_tools_runtime(skill, plan) if not plan.code.strip(): raise LLMError("General skill runner code is empty") runtime_label = _runtime_label(plan.runtime) @@ -594,7 +615,7 @@ def _repair_plan( "package": _skill_package_payload(skill), }, "runtime": { - "languages": ["bash", "python"], + "languages": _runtime_languages(skill), "stdin_json": { "query": query, "skill_slug": skill.slug, @@ -623,6 +644,7 @@ def _repair_plan( ) plan = GeneralSkillExecutionPlan.model_validate(raw) plan.runtime = _plan_runtime(plan) + _enforce_allowed_tools_runtime(skill, plan) if not plan.code.strip(): raise LLMError("General skill repaired runner code is empty") runtime_label = _runtime_label(plan.runtime) @@ -1037,7 +1059,8 @@ def _materialize_skill_package(skill: GeneralSkill, target_dir: Path) -> None: relative_path = _safe_package_path(str(value or "")) if relative_path: (target_dir / relative_path).mkdir(parents=True, exist_ok=True) - for file in _skill_files(skill): + # SKILL.md 一律物化为规范化版本(frontmatter 齐全),存量无 frontmatter 的技能同样生效 + for file in standard_package_files(skill): relative_path = _safe_package_path(str(file["path"])) output_path = target_dir / relative_path output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/general_skills/schema.py b/backend/app/general_skills/schema.py index a428123e..883ce532 100644 --- a/backend/app/general_skills/schema.py +++ b/backend/app/general_skills/schema.py @@ -21,6 +21,10 @@ class GeneralSkillImportRequest(BaseModel): slug: Optional[str] = None description: Optional[str] = None homepage: Optional[str] = None + # Agent Skills 规范可选 frontmatter 字段(保存时写入 SKILL.md) + license: Optional[str] = None + compatibility: Optional[str] = None + allowed_tools: Optional[str] = None markdown: Optional[str] = None files: list[GeneralSkillFile] = Field(default_factory=list) directories: Optional[list[str]] = None @@ -61,6 +65,10 @@ class GeneralSkillRead(BaseModel): name: str description: Optional[str] = None homepage: Optional[str] = None + # Agent Skills 规范可选 frontmatter 字段(从 SKILL.md 解析) + license: Optional[str] = None + compatibility: Optional[str] = None + allowed_tools: Optional[str] = None skill_markdown: str skill_files: list[GeneralSkillFile] = Field(default_factory=list) skill_directories: list[str] = Field(default_factory=list) diff --git a/backend/app/general_skills/standard.py b/backend/app/general_skills/standard.py new file mode 100644 index 00000000..c6231b62 --- /dev/null +++ b/backend/app/general_skills/standard.py @@ -0,0 +1,189 @@ +"""Agent Skills 官方规范的落地实现(agentskills.io/specification)。 + +规范要点: +- 技能 = 目录,至少含 SKILL.md(YAML frontmatter + Markdown 正文); +- frontmatter:name(必填,1-64,小写字母/数字/连字符,不可首尾/连续连字符, + 须与目录名一致)、description(必填,1-1024,做什么+何时用); + 可选 license / compatibility(≤500) / metadata(键值对) / allowed-tools(空格分隔); +- 可选目录 scripts/ references/ assets/;渐进披露。 + +本模块提供校验、frontmatter 解析与 SKILL.md 组装,后端各处保存/导出/运行统一收口。 +""" + +from __future__ import annotations + +import re +from typing import Any + +# name:1-64,小写字母/数字/连字符,不可首尾连字符,不可连续连字符 +SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +SKILL_NAME_MAX = 64 +SKILL_DESCRIPTION_MAX = 1024 +SKILL_COMPATIBILITY_MAX = 500 +# frontmatter 中透传的可选标量字段 +_OPTIONAL_SCALAR_KEYS = ("license", "compatibility", "allowed-tools") + + +def validate_skill_name(name: str) -> str | None: + """校验规范 name 字段,返回错误信息;合法返回 None。""" + if not name or len(name) > SKILL_NAME_MAX: + return f"name 必填且不超过 {SKILL_NAME_MAX} 字符" + if not SKILL_NAME_PATTERN.fullmatch(name): + return "name 只能包含小写字母、数字和连字符,且不可首尾或连续使用连字符" + return None + + +def validate_skill_description(description: str, *, required: bool) -> str | None: + """校验规范 description 字段;required 时必填,非必填时允许为空但限长。""" + text = (description or "").strip() + if not text: + return "description 必填(描述技能做什么、什么时候使用)" if required else None + if len(text) > SKILL_DESCRIPTION_MAX: + return f"description 不能超过 {SKILL_DESCRIPTION_MAX} 字符" + return None + + +def split_frontmatter(markdown: str) -> tuple[dict[str, Any], str]: + """拆出 YAML frontmatter 与正文;无 frontmatter 时返回 ({}, 原文)。 + + 轻量解析:支持标量、键值嵌套(metadata:)与简单列表,与既有导入解析口径一致。 + """ + lines = (markdown or "").splitlines() + if not lines or lines[0].strip() != "---": + return {}, markdown or "" + metadata: dict[str, Any] = {} + body_start = len(lines) + current_map_key = "" + for index, line in enumerate(lines[1:], start=1): + stripped = line.strip() + if stripped == "---": + body_start = index + 1 + break + if not stripped or stripped.startswith("#"): + continue + # metadata: 下的嵌套键值(缩进) + if line.startswith((" ", "\t")) and current_map_key and ":" in stripped: + key, value = stripped.split(":", 1) + key = key.strip() + if key and isinstance(metadata.get(current_map_key), dict): + metadata[current_map_key][key] = _parse_value(value.strip()) + continue + current_map_key = "" + if ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + if not key: + continue + value = value.strip() + if not value: + # 可能是嵌套映射(如 metadata:)——先占位字典 + metadata[key] = {} + current_map_key = key + else: + metadata[key] = _parse_value(value) + return metadata, "\n".join(lines[body_start:]).lstrip("\n") + + +def _parse_value(value: str) -> Any: + cleaned = value.strip().strip("'\"") + if cleaned.startswith("[") and cleaned.endswith("]"): + return [item.strip().strip("'\"") for item in cleaned[1:-1].split(",") if item.strip()] + return cleaned + + +def _yaml_scalar(value: str) -> str: + """输出安全的 YAML 标量(含特殊字符时加引号)。""" + text = str(value) + if re.search(r"[:#\[\]{}&*!|>'\"%@`\s]", text): + escaped = text.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return text + + +def compose_skill_markdown( + *, + name: str, + description: str, + body: str, + license: str = "", + compatibility: str = "", + allowed_tools: str = "", + metadata: dict[str, Any] | None = None, +) -> str: + """按规范组装 SKILL.md(frontmatter + 正文);metadata 中的保留键被忽略。""" + lines = ["---", f"name: {_yaml_scalar(name)}", f"description: {_yaml_scalar(description)}"] + optional = { + "license": license.strip(), + "compatibility": compatibility.strip(), + "allowed-tools": allowed_tools.strip(), + } + for key in _OPTIONAL_SCALAR_KEYS: + if optional[key]: + lines.append(f"{key}: {_yaml_scalar(optional[key])}") + extra_metadata = { + str(key): value + for key, value in (metadata or {}).items() + if str(key) not in {"name", "description", *_OPTIONAL_SCALAR_KEYS} and str(key).strip() + } + if extra_metadata: + lines.append("metadata:") + for key, value in extra_metadata.items(): + lines.append(f" {key}: {_yaml_scalar(value)}") + lines.append("---") + lines.append("") + lines.append((body or "").strip()) + return "\n".join(lines).rstrip("\n") + "\n" + + +def frontmatter_for_skill(skill: Any) -> dict[str, Any]: + """从 GeneralSkill 行组装规范 frontmatter 字段(name 取 slug,与目录名一致)。""" + metadata, _ = split_frontmatter(getattr(skill, "skill_markdown", "") or "") + return { + "name": skill.slug, + "description": (skill.description or "").strip(), + "license": str(metadata.get("license") or ""), + "compatibility": str(metadata.get("compatibility") or ""), + "allowed_tools": str(metadata.get("allowed-tools") or ""), + "metadata": metadata.get("metadata") if isinstance(metadata.get("metadata"), dict) else {}, + } + + +def standard_skill_markdown(skill: Any) -> str: + """返回规范化后的完整 SKILL.md:frontmatter 以表单/数据库字段为准重组,正文保留。 + + 存量技能(无 frontmatter 或字段缺失)在读路径同样输出规范形态。 + """ + fields = frontmatter_for_skill(skill) + _, body = split_frontmatter(getattr(skill, "skill_markdown", "") or "") + return compose_skill_markdown(body=body, **fields) + + +def standard_package_files(skill: Any) -> list[dict[str, Any]]: + """导出/物化用的标准文件清单:SKILL.md 为规范化版本,其余文件原样(去重 SKILL.md)。""" + files = [ + { + "path": "SKILL.md", + "content": standard_skill_markdown(skill), + "mime_type": "text/markdown", + } + ] + for file in getattr(skill, "skill_files_json", None) or []: + path = str(file.get("path") or "").strip() + if not path or path.upper() == "SKILL.MD": + continue + files.append( + { + "path": path, + "content": str(file.get("content") or ""), + "mime_type": file.get("mime_type") or "text/plain", + } + ) + return files + + +def allowed_tools_list(skill: Any) -> list[str]: + """从 frontmatter 解析 allowed-tools 声明(空格分隔);未声明返回空列表。""" + fields = frontmatter_for_skill(skill) + raw = fields.get("allowed_tools") or "" + return [item for item in raw.split() if item] diff --git a/backend/tests/test_agent_permissions.py b/backend/tests/test_agent_permissions.py index d2d2f414..a474a5d2 100644 --- a/backend/tests/test_agent_permissions.py +++ b/backend/tests/test_agent_permissions.py @@ -907,6 +907,7 @@ def test_private_general_skill_edit_does_not_mutate_open_gallery_skill() -> None original_slug=updated.slug, slug="weather-renamed", name="员工天气技能", + description="员工天气技能描述", markdown="# 员工天气技能\n", ), db=db, diff --git a/backend/tests/test_general_skill_standard.py b/backend/tests/test_general_skill_standard.py new file mode 100644 index 00000000..4eedd242 --- /dev/null +++ b/backend/tests/test_general_skill_standard.py @@ -0,0 +1,150 @@ +"""Agent Skills 规范层单测:name/description 校验、frontmatter 解析与组装、标准化输出。""" + +from types import SimpleNamespace + +from app.general_skills.standard import ( + allowed_tools_list, + compose_skill_markdown, + frontmatter_for_skill, + split_frontmatter, + standard_package_files, + standard_skill_markdown, + validate_skill_description, + validate_skill_name, +) + + +def test_validate_skill_name_spec_rules() -> None: + assert validate_skill_name("pdf-processing") is None + assert validate_skill_name("a") is None + assert validate_skill_name("data-analysis-2") is None + assert validate_skill_name("") is not None + assert validate_skill_name("x" * 65) is not None + assert validate_skill_name("PDF-Processing") is not None # 大写不允许 + assert validate_skill_name("-pdf") is not None # 首连字符 + assert validate_skill_name("pdf-") is not None # 尾连字符 + assert validate_skill_name("pdf--processing") is not None # 连续连字符 + assert validate_skill_name("pdf_processing") is not None # 下划线 + assert validate_skill_name("pdf processing") is not None # 空格 + + +def test_validate_skill_description_rules() -> None: + assert validate_skill_description("做什么、何时用", required=True) is None + assert validate_skill_description("", required=True) is not None + assert validate_skill_description("", required=False) is None + assert validate_skill_description("x" * 1024, required=True) is None + assert validate_skill_description("x" * 1025, required=True) is not None + + +def test_split_frontmatter_full_fields() -> None: + markdown = ( + "---\n" + "name: pdf-processing\n" + "description: Extract PDF text. Use when handling PDFs.\n" + "license: Apache-2.0\n" + "compatibility: Requires git, docker\n" + "allowed-tools: Bash(git:*) Read\n" + "metadata:\n" + " author: example-org\n" + " version: \"1.0\"\n" + "---\n" + "\n" + "# 正文标题\n" + "正文内容\n" + ) + metadata, body = split_frontmatter(markdown) + assert metadata["name"] == "pdf-processing" + assert metadata["description"].startswith("Extract PDF text") + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires git, docker" + assert metadata["allowed-tools"] == "Bash(git:*) Read" + assert metadata["metadata"] == {"author": "example-org", "version": "1.0"} + assert body.startswith("# 正文标题") + + +def test_split_frontmatter_absent_returns_original() -> None: + metadata, body = split_frontmatter("# 没有 frontmatter\n正文") + assert metadata == {} + assert body.startswith("# 没有 frontmatter") + + +def test_compose_and_split_roundtrip() -> None: + composed = compose_skill_markdown( + name="weather-zh", + description="查询天气。当用户问天气时使用。", + body="# 天气技能\n按步骤查询。", + license="Apache-2.0", + compatibility="Requires network", + allowed_tools="Bash(curl:*) Read", + metadata={"author": "staffdeck", "version": "1.0"}, + ) + metadata, body = split_frontmatter(composed) + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "查询天气。当用户问天气时使用。" + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires network" + assert metadata["allowed-tools"] == "Bash(curl:*) Read" + assert metadata["metadata"]["author"] == "staffdeck" + assert body == "# 天气技能\n按步骤查询。" + + +def test_compose_escapes_special_scalars() -> None: + composed = compose_skill_markdown(name="a-b", description='含: 冒号与 "引号"', body="x") + metadata, _ = split_frontmatter(composed) + assert metadata["name"] == "a-b" + assert "冒号" in metadata["description"] + + +def _skill(**overrides) -> SimpleNamespace: + base = { + "slug": "weather-zh", + "description": "查询天气", + "skill_markdown": "# 旧正文\n没有 frontmatter。", + "skill_files_json": [ + {"path": "SKILL.md", "content": "旧的", "mime_type": "text/markdown"}, + {"path": "scripts/run.py", "content": "print(1)", "mime_type": "text/plain"}, + ], + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_standard_skill_markdown_synthesizes_frontmatter() -> None: + skill = _skill() + markdown = standard_skill_markdown(skill) + metadata, body = split_frontmatter(markdown) + # name 恒等于 slug(规范:与目录名一致);正文保留 + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "查询天气" + assert body.startswith("# 旧正文") + + +def test_standard_package_files_replaces_skill_md_and_keeps_rest() -> None: + files = standard_package_files(_skill()) + assert files[0]["path"] == "SKILL.md" + assert "name: weather-zh" in files[0]["content"] + # 旧 SKILL.md 被规范化版本替换,scripts 保留 + assert [file["path"] for file in files] == ["SKILL.md", "scripts/run.py"] + + +def test_allowed_tools_list() -> None: + skill = _skill( + skill_markdown="---\nname: a-b\ndescription: x\nallowed-tools: Bash(git:*) Read\n---\n正文" + ) + assert allowed_tools_list(skill) == ["Bash(git:*)", "Read"] + assert allowed_tools_list(_skill()) == [] + + +def test_frontmatter_for_skill_reads_existing_optional_fields() -> None: + skill = _skill( + skill_markdown=( + "---\nname: old\ndescription: old\nlicense: MIT\ncompatibility: Needs docker\n" + "allowed-tools: Bash\nmetadata:\n author: me\n---\n正文" + ) + ) + fields = frontmatter_for_skill(skill) + assert fields["name"] == "weather-zh" # 以 slug 为准,不采信旧 name + assert fields["license"] == "MIT" + assert fields["compatibility"] == "Needs docker" + assert fields["allowed_tools"] == "Bash" + assert fields["metadata"] == {"author": "me"} diff --git a/backend/tests/test_general_skills.py b/backend/tests/test_general_skills.py index 1d6c7e39..94ec85ae 100644 --- a/backend/tests/test_general_skills.py +++ b/backend/tests/test_general_skills.py @@ -287,7 +287,8 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None: assert rows[0].name == "用户改名天气技能" assert rows[0].description == "用户改写描述" assert rows[0].homepage == "https://example.com/weather-cn" - assert rows[0].skill_markdown.startswith("# 天气 demo") + assert rows[0].skill_markdown.startswith("---\nname: weather-zh") + assert "# 天气 demo" in rows[0].skill_markdown try: import_general_skill( @@ -295,6 +296,7 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None: tenant_id="tenant_demo", name="非法改 slug", slug="weather-cn", + description="中国城市天气查询", original_slug="weather-zh", markdown=WEATHER_SKILL_MD, ), @@ -322,6 +324,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( tenant_id="tenant_demo", name="已有天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -334,6 +337,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( tenant_id="tenant_demo", name="新导入天气技能", slug="weather-zh", + description="中国城市天气查询", markdown="# 新内容", ), db, @@ -348,7 +352,10 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing( assert len(rows) == 1 assert rows[0].id == first.id assert rows[0].name == "已有天气技能" - assert rows[0].skill_markdown == WEATHER_SKILL_MD.strip() + from app.general_skills.standard import split_frontmatter + + _, saved_body = split_frontmatter(rows[0].skill_markdown) + assert saved_body.strip() == WEATHER_SKILL_MD.strip() def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() -> None: @@ -366,6 +373,7 @@ def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() tenant_id="tenant_demo", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -417,6 +425,7 @@ def test_reimport_restores_deleted_private_skill_binding() -> None: agent_id="agent_branch", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -534,7 +543,7 @@ def test_import_general_skill_folder_reads_skill_md_metadata() -> None: assert row.homepage == "https://example.com/weather" assert row.metadata["name"] == "中国城市天气" assert [file.path for file in row.skill_files] == ["SKILL.md", "data/cities.json"] - assert row.skill_markdown.startswith("---\nname: 中国城市天气") + assert row.skill_markdown.startswith("---\nname: weather-zh") def test_import_general_skill_persists_empty_directories_across_updates() -> None: @@ -582,7 +591,7 @@ def test_import_clawhub_skill_reads_zip_package_without_overwriting(monkeypatch) with ZipFile(package, "w") as archive: archive.writestr( "skill-pack-main/weather/SKILL.md", - "---\nname: 天气包\nslug: weather-pack\n---\n\n# 天气包\n", + "---\nname: 天气包\nslug: weather-pack\ndescription: 天气查询技能\n---\n\n# 天气包\n", ) archive.writestr("skill-pack-main/weather/scripts/run.py", "print('ok')\n") archive.writestr("skill-pack-main/weather/data/cities.json", '{"北京": "101010100"}') @@ -617,7 +626,7 @@ def fake_download(url: str): # noqa: ANN001 "scripts/run.py", "data/cities.json", ] - assert first.skill_markdown.startswith("---\nname: 天气包") + assert first.skill_markdown.startswith("---\nname: weather-pack") def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: @@ -625,7 +634,7 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: with ZipFile(package, "w") as archive: archive.writestr( "nuwa-skill-main/skill/SKILL.md", - "---\nname: Nuwa Skill\nslug: nuwa-skill\n---\n\n# Nuwa Skill\n", + "---\nname: Nuwa Skill\nslug: nuwa-skill\ndescription: Nuwa 示例技能\n---\n\n# Nuwa Skill\n", ) archive.writestr("nuwa-skill-main/skill/scripts/run.py", "print('nuwa')\n") archive.writestr("nuwa-skill-main/skill/assets/config.json", '{"mode":"demo"}') @@ -650,11 +659,11 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None: "scripts/run.py", "assets/config.json", ] - assert row.skill_markdown.startswith("---\nname: Nuwa Skill") + assert row.skill_markdown.startswith("---\nname: nuwa-skill") def test_import_general_skill_package_upload_treats_single_markdown_as_skill_md() -> None: - markdown = "---\nname: 单文件技能\nslug: single-file-skill\n---\n\n# 单文件技能\n" + markdown = "---\nname: 单文件技能\nslug: single-file-skill\ndescription: 单文件示例技能\n---\n\n# 单文件技能\n" with _test_session() as db: _seed_minimal_tenant(db) @@ -710,7 +719,7 @@ def fake_json(url: str): # noqa: ANN001 def fake_download(url: str): # noqa: ANN001 content = { - "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\n---\n\n# 天气\n", + "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\ndescription: 目录天气技能\n---\n\n# 天气\n", "https://raw.githubusercontent.com/example/skill-pack/main/weather/scripts/run.py": "print('ok')\n", "https://raw.githubusercontent.com/example/skill-pack/main/weather/data/cities.json": '{"北京":"101010100"}', }.get(url) @@ -749,7 +758,7 @@ def fake_download(url: str): # noqa: ANN001 "text/html", ) content = { - "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\n---\n\n# 天气\n", + "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\ndescription: 页面天气技能\n---\n\n# 天气\n", }.get(url) if content is None: raise AssertionError(f"unexpected url: {url}") @@ -788,7 +797,7 @@ def test_import_clawhub_skill_uses_clawhub_download_api_for_page_url(monkeypatch with ZipFile(package, "w") as archive: archive.writestr( "SKILL.md", - "---\nname: weather\n---\n\n# 天气\n", + "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n", ) archive.writestr("scripts/weather.py", "print('weather')\n") archive.writestr("references/weather_details.md", "# details\n") @@ -827,7 +836,7 @@ def fake_download(url: str): # noqa: ANN001 def test_import_clawhub_skill_accepts_cli_slug(monkeypatch) -> None: package = BytesIO() with ZipFile(package, "w") as archive: - archive.writestr("SKILL.md", "---\nname: weather\n---\n\n# 天气\n") + archive.writestr("SKILL.md", "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n") def fake_download(url: str): # noqa: ANN001 assert url == "https://wry-manatee-359.convex.site/api/v1/download?slug=maomao-weather" @@ -925,6 +934,7 @@ def fake_read(self, skill, query, model_config, **kwargs): # noqa: ANN001 tenant_id="tenant_demo", name="天气", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -1026,6 +1036,7 @@ def test_non_overall_agent_delete_hides_general_skill_only_in_branch() -> None: tenant_id="tenant_demo", name="天气", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -1540,3 +1551,201 @@ def _test_session(): ) SQLModel.metadata.create_all(engine) return Session(engine) + + +# ---------- Agent Skills 规范对齐:归一化/发布校验/导出/allowed-tools 门控 ---------- + + +def _seed_standard_skill_tenant(db: Session) -> None: + _seed_minimal_tenant(db) + db.add(AgentProfile(id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True)) + db.commit() + + +def test_save_normalizes_skill_md_frontmatter_and_preserves_optional_fields() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + row = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="天气技能", + slug="weather-zh", + description="中国城市天气查询", + license="Apache-2.0", + compatibility="Requires network", + allowed_tools="Bash(curl:*) Read", + markdown="# 使用说明\n按城市查询天气。", + ), + db, + _admin_user(), + ) + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(row.skill_markdown) + # frontmatter 以表单字段重组:name 恒等于 slug;可选字段透传;正文保留 + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "中国城市天气查询" + assert metadata["license"] == "Apache-2.0" + assert metadata["compatibility"] == "Requires network" + assert metadata["allowed-tools"] == "Bash(curl:*) Read" + assert body == "# 使用说明\n按城市查询天气。" + # DTO 透出三个可选字段 + assert row.license == "Apache-2.0" + assert row.compatibility == "Requires network" + assert row.allowed_tools == "Bash(curl:*) Read" + + +def test_save_rejects_invalid_slug_and_published_without_description() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + try: + import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="坏技能", + slug="Weather_ZH", + description="x", + markdown="# x", + ), + db, + _admin_user(), + ) + raise AssertionError("expected invalid slug rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "连字符" in error.detail or "小写" in error.detail + + try: + import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="无描述技能", + slug="no-desc", + markdown="# x", + status="published", + ), + db, + _admin_user(), + ) + raise AssertionError("expected published-without-description rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "description 必填" in error.detail + + # 草稿允许无描述;发布时再校验 + draft = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="草稿技能", + slug="draft-skill", + markdown="# x", + status="draft", + ), + db, + _admin_user(), + ) + assert draft.status == "draft" + try: + publish_general_skill(draft.slug, "tenant_demo", db, current_user=_admin_user()) + raise AssertionError("expected publish without description rejected") + except HTTPException as error: + assert error.status_code == 400 + assert "发布失败" in error.detail and "description 必填" in error.detail + + +def test_export_produces_standard_zip_roundtrip() -> None: + with _test_session() as db: + _seed_standard_skill_tenant(db) + row = import_general_skill( + GeneralSkillImportRequest( + tenant_id="tenant_demo", + name="天气技能", + slug="weather-zh", + description="中国城市天气查询", + markdown="# 使用说明", + files=[ + {"path": "SKILL.md", "content": "# 使用说明", "mime_type": "text/markdown"}, + {"path": "scripts/query.py", "content": "print('q')", "mime_type": "text/plain"}, + ], + ), + db, + _admin_user(), + ) + from app.api.general_skills import export_general_skill + + response = export_general_skill(row.slug, "tenant_demo", db) + assert response.media_type == "application/zip" + # StreamingResponse 直接迭代 body_iterator 收集 zip 字节 + import asyncio + + async def _collect() -> bytes: + chunks = [] + async for chunk in response.body_iterator: + chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + return b"".join(chunks) + + data = asyncio.run(_collect()) + with ZipFile(BytesIO(data)) as archive: + names = set(archive.namelist()) + assert names == {"weather-zh/SKILL.md", "weather-zh/scripts/query.py"} + exported_md = archive.read("weather-zh/SKILL.md").decode("utf-8") + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(exported_md) + # 根目录=slug,frontmatter name 与目录名一致(规范硬性要求) + assert metadata["name"] == "weather-zh" + assert metadata["description"] == "中国城市天气查询" + assert body == "# 使用说明" + + +def test_materialized_workspace_skill_md_is_standardized() -> None: + """存量无 frontmatter 的技能:运行时物化到工作区的 SKILL.md 也是规范形态。""" + from app.general_skills.runner import _materialize_skill_package + + legacy = GeneralSkill( + tenant_id="tenant_demo", + slug="legacy-skill", + name="存量技能", + description="存量描述", + skill_markdown="# 旧正文\n没有 frontmatter。", + skill_files_json=[{"path": "data/x.txt", "content": "v", "mime_type": "text/plain"}], + metadata_json={}, + status="published", + ) + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + _materialize_skill_package(legacy, Path(tmp)) + materialized = (Path(tmp) / "SKILL.md").read_text(encoding="utf-8") + assert (Path(tmp) / "data" / "x.txt").exists() + from app.general_skills.standard import split_frontmatter + + metadata, body = split_frontmatter(materialized) + assert metadata["name"] == "legacy-skill" + assert metadata["description"] == "存量描述" + assert body.startswith("# 旧正文") + + +def test_allowed_tools_gates_bash_runtime() -> None: + from app.general_skills.runner import _bash_allowed, _runtime_languages + + with_bash = SimpleNamespace( + slug="a", + description="d", + skill_markdown="---\nname: a\ndescription: d\nallowed-tools: Bash(git:*) Read\n---\n正文", + skill_files_json=[], + ) + without_bash = SimpleNamespace( + slug="b", + description="d", + skill_markdown="---\nname: b\ndescription: d\nallowed-tools: Read\n---\n正文", + skill_files_json=[], + ) + undeclared = SimpleNamespace(slug="c", description="d", skill_markdown="# 正文", skill_files_json=[]) + + assert _bash_allowed(with_bash) is True + assert _runtime_languages(with_bash) == ["bash", "python"] + assert _bash_allowed(without_bash) is False + assert _runtime_languages(without_bash) == ["python"] + # 未声明 allowed-tools:不限制 + assert _bash_allowed(undeclared) is True diff --git a/backend/tests/test_resource_creator_metadata.py b/backend/tests/test_resource_creator_metadata.py index a3ddcb52..de829e2d 100644 --- a/backend/tests/test_resource_creator_metadata.py +++ b/backend/tests/test_resource_creator_metadata.py @@ -79,6 +79,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None: agent_id=agent.id, name="用户通用技能", slug="user-general-skill", + description="用户通用技能描述", markdown="# 用户通用技能\n\n用于测试 creator metadata。", ), db=db, @@ -133,6 +134,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None: agent_id=agent.id, name="更新后的用户通用技能", slug="user-general-skill", + description="用户通用技能描述", original_slug="user-general-skill", markdown="# 更新后的用户通用技能\n\n用于测试 creator metadata。", ), diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 64bd46a2..587cd1a4 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2309,5 +2309,21 @@ "价格查询": "Price lookup", "查询商品价格": "Look up product prices", "/skill weather 查询北京天气": "/skill weather Check Beijing weather", - "/skill 天气": "/skill weather" + "/skill 天气": "/skill weather", + "描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "Describe what this skill does and when to use it (regenerated from the form fields above on save)", + "在这里编写技能的使用说明(步骤、示例、边界情况)。正文会写入 SKILL.md,": "Write the skill instructions here (steps, examples, edge cases). This body goes into SKILL.md,", + "frontmatter 由上方表单生成:name 取 Slug,description 取描述。": "frontmatter is generated from the form above: name from Slug, description from Description.", + "许可证(license)": "License", + "可选,如 Apache-2.0、MIT": "Optional, e.g. Apache-2.0, MIT", + "运行环境要求(compatibility)": "Compatibility", + "可选,如 Requires network、Python 3.12+": "Optional, e.g. Requires network, Python 3.12+", + "预授权工具(allowed-tools)": "Allowed tools", + "可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行": "Optional, space-separated, e.g. Bash(curl:*) Read; without Bash it runs with Python only", + "导出技能包": "Export Skill Package", + "已导出技能包": "Skill package exported", + "导出技能包失败": "Failed to export skill package", + "展开运行结果": "Expand run result", + "收起运行结果": "Collapse run result", + "description: 描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "description: Describe what this skill does and when to use it (regenerated from the form fields above on save)", + "# 技能说明": "# Skill Instructions" } diff --git a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx index 4a26d674..2770d9fa 100644 --- a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx +++ b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx @@ -16,7 +16,7 @@ import { Ban, ChevronRight, CircleCheck, Copy, Eye, EyeOff, FilePlus2, FolderPlu import { ContextMenu } from 'radix-ui'; import { api, streamPost, TENANT_ID } from '../api/client'; -import { isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth'; +import { getEnterpriseAuthSession, isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth'; import AppHeader from '@/components/AppHeader'; import { CapabilityScopeBadge, @@ -99,9 +99,17 @@ const STATUS_BADGE: Record('general'); + const [skillLicense, setSkillLicense] = useState(''); + const [skillCompatibility, setSkillCompatibility] = useState(''); + const [skillAllowedTools, setSkillAllowedTools] = useState(''); const [skillFiles, setSkillFiles] = useState([ { path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }, ]); @@ -1663,6 +1674,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | description: skillDescription.trim() || undefined, homepage: skillHomepage.trim() || undefined, capability_scope: capabilityScope, + license: skillLicense.trim() || undefined, + compatibility: skillCompatibility.trim() || undefined, + allowed_tools: skillAllowedTools.trim() || undefined, markdown, files: skillFiles.length ? skillFiles : [{ path: 'SKILL.md', content: markdown }], directories: skillDirectories, @@ -1678,6 +1692,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); setCapabilityScope(normalizeCapabilityScope(row.capability_scope)); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSkillDirectories(row.skill_directories || []); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); @@ -1697,6 +1714,30 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | } } + // 导出标准 Agent Skills 包(zip):SKILL.md 为后端规范化版本,目录名即 slug + async function exportSkillPackage() { + if (!editingSlug) return; + try { + const session = getEnterpriseAuthSession(); + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch( + `${apiBase}/api/enterprise/general-skills/${encodeURIComponent(editingSlug)}/export?tenant_id=${TENANT_ID}`, + { headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {} }, + ); + if (!response.ok) throw new Error('导出技能包失败'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${editingSlug}.zip`; + link.click(); + URL.revokeObjectURL(url); + notify.success('已导出技能包'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '导出技能包失败'); + } + } + function newSkill() { setMarkdown(EMPTY_SKILL_MARKDOWN); setSkillName(''); @@ -1704,6 +1745,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(''); setSkillHomepage(''); setCapabilityScope('general'); + setSkillLicense(''); + setSkillCompatibility(''); + setSkillAllowedTools(''); setSkillFiles([{ path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }]); setSkillDirectories([]); setSelectedFilePath('SKILL.md'); @@ -1724,6 +1768,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); setCapabilityScope(normalizeCapabilityScope(row.capability_scope)); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSkillDirectories(row.skill_directories || []); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); @@ -2495,6 +2542,11 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | 新建技能 )} + {!isNew && editingSlug && ( + void exportSkillPackage()}> + 导出技能包 + + )} {importMenu} {canManageCurrentScope && ( void importSkill()}> @@ -2541,6 +2593,30 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | placeholder="可选,参考文档或项目主页" /> + + setSkillLicense(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Apache-2.0、MIT" + /> + + + setSkillCompatibility(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Requires network、Python 3.12+" + /> + + + setSkillAllowedTools(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行" + /> +
Date: Fri, 7 Aug 2026 00:08:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20rebase=20=E5=88=B0=20harness=20v2?= =?UTF-8?q?=20=E6=96=B0=E5=9F=BA=E7=BA=BF=E5=B9=B6=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 与 main 的文件树/新建文件夹/能力范围(capability_scope)合并:保留上游实现, 叠加本 PR 的规范字段(license/compatibility/allowed-tools)与导出技能包 - runner 物化:保留上游 skill_directories 目录创建,SKILL.md 仍按规范化物化 - 上游新增测试夹具按规范补 description;en.json 合入上游新文案并补齐渲染切换等翻译 --- backend/tests/test_capability_scope.py | 3 +++ backend/tests/test_general_skills.py | 4 ++++ frontend-enterprise/src/i18n/en.json | 7 ++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_capability_scope.py b/backend/tests/test_capability_scope.py index 80e7d1db..a171f35d 100644 --- a/backend/tests/test_capability_scope.py +++ b/backend/tests/test_capability_scope.py @@ -129,6 +129,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: tenant_id="tenant_demo", slug="sop-helper", name="SOP Helper", + description="SOP 辅助技能", markdown="# SOP Helper", capability_scope="sop_specific", ), @@ -142,6 +143,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: slug="sop-helper", original_slug="sop-helper", name="SOP Helper", + description="SOP 辅助技能", markdown="# SOP Helper v2", ), db, @@ -155,6 +157,7 @@ def test_create_update_and_read_apis_round_trip_capability_scope() -> None: slug="sop-helper", original_slug="sop-helper", name="员工 SOP Helper", + description="SOP 辅助技能", markdown="# Employee SOP Helper", ), db, diff --git a/backend/tests/test_general_skills.py b/backend/tests/test_general_skills.py index 94ec85ae..4335ee63 100644 --- a/backend/tests/test_general_skills.py +++ b/backend/tests/test_general_skills.py @@ -445,6 +445,7 @@ def test_reimport_restores_deleted_private_skill_binding() -> None: agent_id="agent_branch", name="更新后的天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD.replace("中国城市天气查询工具", "更新后的天气工具"), ), db, @@ -480,6 +481,7 @@ def test_private_skill_can_be_published_to_open_gallery() -> None: agent_id="agent_branch", name="天气技能", slug="weather-zh", + description="中国城市天气查询", markdown=WEATHER_SKILL_MD, ), db, @@ -555,6 +557,7 @@ def test_import_general_skill_persists_empty_directories_across_updates() -> Non tenant_id="tenant_demo", name="目录技能", slug="directory-skill", + description="目录技能描述", files=[{"path": "SKILL.md", "content": "# 目录技能\n"}], directories=["references", "references/drafts", "empty"], ), @@ -576,6 +579,7 @@ def test_import_general_skill_persists_empty_directories_across_updates() -> Non tenant_id="tenant_demo", name="目录技能", slug="directory-skill", + description="目录技能描述", original_slug="directory-skill", files=[{"path": "SKILL.md", "content": "# 更新后的目录技能\n"}], ), diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 587cd1a4..ebc111e8 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2325,5 +2325,10 @@ "展开运行结果": "Expand run result", "收起运行结果": "Collapse run result", "description: 描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "description: Describe what this skill does and when to use it (regenerated from the form fields above on save)", - "# 技能说明": "# Skill Instructions" + "# 技能说明": "# Skill Instructions", + "已发布到技能广场": "Published to the skill gallery", + "发布到广场失败": "Failed to publish to gallery", + "切换到渲染": "Switch to rendered view", + "渲染": "Rendered", + "切换到编辑": "Switch to editor" }