feat(skills): show YAML load errors for owned custom skills (#6623)

* feat(skills): show YAML load errors for owned custom skills

* docs: capture skill diagnostics with real gateway validation

* fix(skills): clarify YAML diagnostic scope

* docs: remove PR evidence directory

---------

Co-authored-by: kwsYeager <331533331+kwsYegar@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Yeager0204 2026-10-10 23:52:48 -05:00 committed by GitHub
parent 5116434712
commit 782fed9135
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 842 additions and 2 deletions

View File

@ -1291,6 +1291,21 @@ view. Built-in and user-created/imported skills are listed separately. The
Community tab supports importing `.skill` archives into My skills.
General preferences remain in Settings.
Administrators can see **Could not load** warnings in Capability Center > Skills
when their own custom skill's `SKILL.md` contains invalid YAML. Warnings show the
package-relative file and, when available, its line and column, with a quoting
hint for unquoted colon values. Fix the file on disk and select **Reload skills**
to refresh the list and invalidate skill prompt caches for subsequent runs;
active runs retain their existing snapshot. This covers caller-owned custom
skills only, excluding legacy/global, public, integration, and external linked
packages. These warnings cover YAML syntax errors only: missing frontmatter,
non-mapping metadata, missing/invalid name or description, invalid allowed-tools
or required-secrets declarations, non-UTF-8 files, and file-read failures
(including permission denied) are not reported. A missing warning does not
establish that a package is valid or readable. The scope note remains visible
even when there are no YAML warnings; check Gateway logs for other load failures.
Parsing remains strict and invalid skills stay unavailable to agents.
Skills are what make DeerFlow do *almost anything*.
A standard Agent Skill is a structured capability module — a Markdown file that defines a workflow, best practices, and references to supporting resources. DeerFlow ships with built-in skills for research, report generation, slide creation, web pages, image and video generation, and more. But the real power is extensibility: add your own skills, replace the built-in ones, or combine them into compound workflows.

View File

@ -41,6 +41,7 @@ from deerflow.config.extensions_config import (
)
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill
from deerflow.skills.diagnostics import SkillLoadDiagnostic
from deerflow.skills.export import SkillExportError, build_skill_export, export_manifest
from deerflow.skills.installer import SkillAlreadyExistsError, SkillSecurityScanError
from deerflow.skills.security_scanner import scan_skill_content
@ -112,6 +113,10 @@ class SkillsListResponse(BaseModel):
skills: list[SkillResponse]
class SkillDiagnosticsResponse(BaseModel):
diagnostics: list[SkillLoadDiagnostic]
class SkillUpdateRequest(BaseModel):
"""Request model for updating a skill."""
@ -652,6 +657,21 @@ async def list_custom_skills(request: Request, config: AppConfig = Depends(get_c
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
@router.get("/skills/diagnostics/custom", response_model=SkillDiagnosticsResponse, summary="List Owned Custom Skill YAML Errors")
async def list_custom_skill_diagnostics(request: Request, response: Response, config: AppConfig = Depends(get_config)) -> SkillDiagnosticsResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
response.headers["Cache-Control"] = "no-store"
def scan() -> list[SkillLoadDiagnostic]:
return _get_user_skill_storage(config).load_custom_skill_diagnostics()
try:
return SkillDiagnosticsResponse(diagnostics=await asyncio.to_thread(scan))
except Exception as exc:
logger.error("Failed to load custom skill diagnostics (%s)", type(exc).__name__)
raise HTTPException(status_code=500, detail="Failed to load custom skill diagnostics.") from exc
@router.get("/skills/custom/{skill_name}/export-manifest", response_model=SkillExportManifestResponse, response_model_exclude_unset=True, summary="Preview Custom Skill Export")
async def preview_custom_skill_export(skill_name: str, request: Request, response: Response, config: AppConfig = Depends(get_config)) -> SkillExportManifestResponse | Response:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)

View File

@ -1,5 +1,7 @@
### Skills System (`packages/harness/deerflow/skills/`)
- **Management YAML diagnostics**: `parse_skill_file(..., diagnostics=...)` optionally collects `SkillLoadDiagnostic` values separately from valid `Skill` entries. Only `UserScopedSkillStorage.load_custom_skill_diagnostics()` supplies that sink, after validating root ancestors and each resolved file against the caller's custom root. Exclude legacy/shared locations and external symlinks; do not weaken runtime discovery's separate compatibility policy. `GET /api/skills/diagnostics/custom` is admin-only and returns stable codes plus package-relative identifiers and 1-based file positions. Never return `_format_yaml_error`, exceptions or source excerpts. The management UI posts the existing admin-only reload before refetching diagnostics and normal skills; active Agent snapshots are unchanged.
- **Diagnostic scope**: only `yaml.YAMLError` produces a management diagnostic. Missing frontmatter, non-mapping or invalid metadata, non-UTF-8 files and read errors (including permission denied) remain excluded; the UI and README state those limits. An empty diagnostic list is not a package-validity check. `tests/test_skill_load_diagnostics.py` pins these exclusions and permission-error redaction without changing runtime parsing.
- **History JSONL**: split records on LF, not Unicode text separators; preserve U+0085/U+2028/U+2029 in saved fields. Keep malformed JSON rejected. Tests: `tests/test_skill_history_jsonl.py`.
- **SkillScan API tokens**: `secret-cloud-token` accepts `-` and `_` in the `sk-` family, matching the character class used by PII redaction. Legacy, OpenAI project/service-account/admin, and Anthropic keys must trigger `CRITICAL` even in a Bearer header without a secret assignment. Skip placeholder matches until a real credential is found, retaining at most one cloud-token finding per file. Keep evidence fully redacted and preserve the minimum length and word boundaries; synthetic regressions live in `tests/test_skillscan_native.py`. Keep the mirrored `sk-` pattern in `app/scheduler/notification_text.py` aligned so scheduled-run summaries scrub the same key formats before IM delivery.

View File

@ -0,0 +1,35 @@
"""Host-path-free diagnostics for skill management, never the agent catalog."""
from dataclasses import dataclass
from typing import Literal
import yaml
@dataclass(frozen=True)
class SkillLoadDiagnostic:
package: str
path: Literal["SKILL.md"] = "SKILL.md"
code: Literal["invalid_frontmatter"] = "invalid_frontmatter"
hint: Literal["quote_colon_value"] | None = None
line: int | None = None
column: int | None = None
def yaml_load_diagnostic(package: str, exc: yaml.YAMLError, source: str, line_offset: int) -> SkillLoadDiagnostic:
"""Project parser details into stable codes without serializing the exception."""
mark = getattr(exc, "problem_mark", None)
hint = None
lines = source.splitlines()
if mark is not None and 0 <= mark.line < len(lines) and getattr(exc, "problem", "") == "mapping values are not allowed here":
offending = lines[mark.line]
_, separator, value = offending.partition(":")
value = value.strip()
if separator and value and value[0] not in {'"', "'", "|", ">", "[", "{"} and mark.column > offending.index(":") and offending[mark.column : mark.column + 2] == ": ":
hint = "quote_colon_value"
return SkillLoadDiagnostic(
package=package,
hint=hint,
line=mark.line + line_offset + 1 if mark is not None else None,
column=mark.column + 1 if mark is not None else None,
)

View File

@ -4,6 +4,7 @@ from pathlib import Path
import yaml
from .diagnostics import SkillLoadDiagnostic, yaml_load_diagnostic
from .frontmatter import _FRONTMATTER_RE
from .types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
@ -198,7 +199,7 @@ def parse_secrets_autonomous(raw: object, skill_file: Path) -> bool:
return False
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None, *, diagnostics: list[SkillLoadDiagnostic] | None = None) -> Skill | None:
"""Parse a SKILL.md file and extract metadata.
Args:
@ -206,6 +207,8 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
category: Category of the skill.
relative_path: Relative path from the category root to the skill
directory. Defaults to the skill directory name when omitted.
diagnostics: Optional management-only sink for YAML failures. The caller
must authorize the file path before requesting diagnostics.
Returns:
Skill object if parsing succeeds, None otherwise.
@ -225,6 +228,8 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
try:
metadata = yaml.safe_load(front_matter_text)
except yaml.YAMLError as exc:
if diagnostics is not None:
diagnostics.append(yaml_load_diagnostic((relative_path or Path(skill_file.parent.name)).as_posix(), exc, front_matter_text, content.count("\n", 0, front_matter_match.start(1))))
logger.error("%s", _format_yaml_error(skill_file, exc, front_matter_text))
return None
if not isinstance(metadata, dict):

View File

@ -11,6 +11,7 @@ from collections.abc import Iterable
from pathlib import Path
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.diagnostics import SkillLoadDiagnostic
from deerflow.skills.types import SKILL_MD_FILE, Skill, SkillCategory # noqa: F401
from deerflow.utils.host_paths import windows_incompatible_segment
@ -69,6 +70,10 @@ class SkillStorage(ABC):
def __init__(self, container_path: str = DEFAULT_SKILLS_CONTAINER_PATH) -> None:
self._container_root = container_path
def load_custom_skill_diagnostics(self) -> list[SkillLoadDiagnostic]:
"""No diagnostics unless storage explicitly establishes caller ownership."""
return []
# ------------------------------------------------------------------
# Static protocol helpers (not storage-specific)
# ------------------------------------------------------------------

View File

@ -39,6 +39,8 @@ from collections.abc import Iterable
from pathlib import Path
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.diagnostics import SkillLoadDiagnostic
from deerflow.skills.parser import parse_skill_file
from deerflow.skills.permissions import make_skill_written_path_sandbox_readable
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
from deerflow.skills.storage.skill_storage import SKILL_MD_FILE, walk_skill_directories
@ -270,6 +272,38 @@ class UserScopedSkillStorage(LocalSkillStorage):
raise ValueError(f"'{name}' is a managed integration skill and cannot be edited. Create a custom skill with another name if you need a modified workflow.")
raise FileNotFoundError(f"Custom skill '{name}' not found.")
def load_custom_skill_diagnostics(self) -> list[SkillLoadDiagnostic]:
"""Scan only owned packages; legacy/shared and external links are excluded.
Unlike runtime discovery's external-package compatibility, management
diagnostics cannot establish ownership outside the caller's custom root.
Root ancestors must not redirect into another user's storage either.
"""
root = self._user_custom_root
expected = self._paths.base_dir.resolve() / "users" / self._user_id / "skills" / "custom"
if root.resolve() != expected or not root.is_dir():
return []
diagnostics: list[SkillLoadDiagnostic] = []
for current, dirs, files in walk_skill_directories(root):
package_dir = Path(current)
if not package_dir.resolve().is_relative_to(expected):
dirs.clear()
continue
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
if SKILL_MD_FILE not in files:
continue
dirs.clear()
try:
skill_file = (package_dir / SKILL_MD_FILE).resolve(strict=True)
if not skill_file.is_relative_to(expected):
continue
# Preserve the logical SKILL.md name for owned internal links.
parse_skill_file(package_dir / SKILL_MD_FILE, SkillCategory.CUSTOM, package_dir.relative_to(root), diagnostics=diagnostics)
except (OSError, RuntimeError):
# A concurrently removed/retargeted package is not a YAML error.
continue
return diagnostics
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
# 1. Public skills: always from global root
public_path = self._host_root / SkillCategory.PUBLIC.value

View File

@ -0,0 +1,262 @@
"""Management-only YAML diagnostics: scope, redaction and recovery."""
from dataclasses import asdict
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from support.symlinks import symlink_or_skip
from app.gateway.auth.models import User
from app.gateway.routers import skills as routes
from deerflow.config.paths import Paths
from deerflow.skills.storage import reset_skill_storage
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
BAD = "---\nname: broken\ndescription: private-value: detail\n---\n"
GOOD = '---\nname: broken\ndescription: "private-value: detail"\n---\n'
USER_ID = "00000000-0000-0000-0000-000000000001"
def write(root: Path, package: str, content: str = BAD) -> Path:
path = root / package / "SKILL.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
return path
@pytest.fixture
def scope(tmp_path, monkeypatch):
paths = Paths(tmp_path)
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: paths)
config = SimpleNamespace(skills=SimpleNamespace(get_skills_path=lambda: tmp_path / "global", container_path="/mnt/skills"))
storage = UserScopedSkillStorage(USER_ID, app_config=config)
reset_skill_storage()
yield paths, config, storage
reset_skill_storage()
def test_diagnostics_are_separate_and_disappear_after_correction(scope):
paths, _, storage = scope
root = paths.user_custom_skills_dir(USER_ID)
broken = write(root, "namespace/broken")
write(root, "valid", GOOD.replace("broken", "valid"))
write(root, ".hidden")
write(root, "valid/nested")
assert [s.name for s in storage.load_skills()] == ["valid"]
result = [asdict(d) for d in storage.load_custom_skill_diagnostics()]
assert result == [{"package": "namespace/broken", "path": "SKILL.md", "code": "invalid_frontmatter", "hint": "quote_colon_value", "line": 3, "column": 27}]
assert str(paths.base_dir) not in str(result)
assert "private-value" not in str(result)
broken.write_text(GOOD, encoding="utf-8")
assert storage.load_custom_skill_diagnostics() == []
assert {s.name for s in storage.load_skills()} == {"valid", "broken"}
@pytest.mark.parametrize("prefix", ["---\n", "\ufeff---\r\n\r\n"])
def test_locations_match_actual_file_and_other_yaml_errors_have_no_colon_hint(scope, prefix):
paths, _, storage = scope
write(paths.user_custom_skills_dir(USER_ID), "broken", prefix + "name: broken\ndescription: [one, two\n---\n")
result = storage.load_custom_skill_diagnostics()
assert len(result) == 1
assert result[0].code == "invalid_frontmatter"
assert result[0].hint is None
assert result[0].line == prefix.count("\n") + 2
assert result[0].column == 23
@pytest.mark.parametrize(
"content",
[
"# No frontmatter\n",
"---\nname: broken\ndescription: normal\n", # Unclosed frontmatter.
"---\n- not\n- a mapping\n---\n",
"---\nscalar\n---\n",
"---\nnull\n---\n",
"---\nname: broken\n---\n",
"---\ndescription: normal\n---\n",
'---\nname: " "\ndescription: normal\n---\n',
'---\nname: broken\ndescription: " "\n---\n',
"---\nname: 42\ndescription: normal\n---\n",
"---\nname: broken\ndescription: []\n---\n",
GOOD.replace("\n---\n", "\nallowed-tools: 42\n---\n"),
GOOD.replace("\n---\n", "\nallowed-tools: [42]\n---\n"),
GOOD.replace("\n---\n", "\nrequired-secrets: private-value\n---\n"),
],
)
def test_non_yaml_load_failures_remain_outside_diagnostics(scope, content):
"""The UI's YAML-only scope must not imply all omitted skills are reported."""
paths, config, storage = scope
root = paths.user_custom_skills_dir(USER_ID)
write(root, "broken", content)
write(root, "valid", GOOD.replace("broken", "valid"))
assert [s.name for s in storage.load_skills()] == ["valid"]
assert storage.load_custom_skill_diagnostics() == []
with client_for(config) as client:
response = client.get("/api/skills/diagnostics/custom")
assert response.status_code == 200
assert response.json() == {"diagnostics": []}
def test_non_utf8_file_is_not_a_yaml_diagnostic(scope):
paths, config, storage = scope
path = write(paths.user_custom_skills_dir(USER_ID), "broken")
path.write_bytes(GOOD.encode("utf-8") + b"\xff")
assert storage.load_skills() == []
assert storage.load_custom_skill_diagnostics() == []
with client_for(config) as client:
response = client.get("/api/skills/diagnostics/custom")
assert response.status_code == 200
assert response.json() == {"diagnostics": []}
def test_permission_denied_file_is_not_a_yaml_diagnostic(scope, monkeypatch):
paths, config, storage = scope
root = paths.user_custom_skills_dir(USER_ID)
unreadable = write(root, "unreadable", GOOD.replace("broken", "unreadable"))
write(root, "valid", GOOD.replace("broken", "valid"))
original = Path.read_text
denied_reads = []
def deny_owned_file(path, *args, **kwargs):
if path == unreadable:
denied_reads.append(path)
raise PermissionError(f"private-value: cannot read {path}")
return original(path, *args, **kwargs)
# Portable even when tests run as root or on hosts without POSIX chmod.
with monkeypatch.context() as patch:
patch.setattr(Path, "read_text", deny_owned_file)
assert [s.name for s in storage.load_skills()] == ["valid"]
assert storage.load_custom_skill_diagnostics() == []
with client_for(config) as client:
response = client.get("/api/skills/diagnostics/custom")
assert response.status_code == 200
assert response.json() == {"diagnostics": []}
for private in ["private-value", "unreadable", str(paths.base_dir)]:
assert private not in response.text
assert len(denied_reads) == 3
assert {s.name for s in storage.load_skills()} == {"valid", "unreadable"}
def test_other_owners_and_global_sources_are_excluded(scope):
paths, config, storage = scope
write(paths.user_custom_skills_dir("another-user"), "other-private")
for category in ["custom", "public"]:
write(config.skills.get_skills_path() / category, "global-private")
write(paths.integration_skills_dir(), "provider/integration-private")
assert storage.load_custom_skill_diagnostics() == []
def test_comment_colon_does_not_turn_indentation_error_into_quoting_hint(scope):
paths, _, storage = scope
write(paths.user_custom_skills_dir(USER_ID), "broken", "---\nname: broken\ndescription: normal\n bad: value # comment: words\n---\n")
result = storage.load_custom_skill_diagnostics()
assert len(result) == 1
assert result[0].hint is None
def test_owned_file_symlink_preserves_skill_filename(scope):
paths, _, storage = scope
root = paths.user_custom_skills_dir(USER_ID)
source = root / "linked" / "source.md"
source.parent.mkdir(parents=True)
source.write_text(BAD, encoding="utf-8")
symlink_or_skip(source.parent / "SKILL.md", source)
result = storage.load_custom_skill_diagnostics()
assert len(result) == 1
assert result[0].package == "linked"
assert result[0].path == "SKILL.md"
@pytest.mark.parametrize("kind", ["package", "file", "root"])
def test_symlink_escape_is_not_read(scope, monkeypatch, kind):
paths, _, storage = scope
root = paths.user_custom_skills_dir(USER_ID)
target = write(paths.user_custom_skills_dir("another-user"), "private")
if kind == "root":
root.parent.mkdir(parents=True, exist_ok=True)
symlink_or_skip(root, target.parent.parent, target_is_directory=True)
elif kind == "package":
root.mkdir(parents=True)
symlink_or_skip(root / "escape", target.parent, target_is_directory=True)
else:
(root / "escape").mkdir(parents=True)
symlink_or_skip(root / "escape" / "SKILL.md", target)
original = Path.read_text
def guarded_read(path, *args, **kwargs):
assert path.resolve() != target.resolve(), "must authorize before reading another owner's file"
return original(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", guarded_read)
assert storage.load_custom_skill_diagnostics() == []
def client_for(config, role="admin"):
user = User(id=UUID(USER_ID), email="diagnostics@example.com", password_hash="x", system_role=role)
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
app.dependency_overrides[routes.get_config] = lambda: config
app.include_router(routes.router)
return TestClient(app)
def test_management_route_uses_caller_scope(scope):
paths, config, _ = scope
write(paths.user_custom_skills_dir(USER_ID), "mine")
write(paths.user_custom_skills_dir("another-user"), "other-private")
with client_for(config) as client:
response = client.get("/api/skills/diagnostics/custom")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert [d["package"] for d in response.json()["diagnostics"]] == ["mine"]
for private in ["private-value", "other-private", str(paths.base_dir)]:
assert private not in response.text
def test_non_admin_cannot_scan_diagnostics(scope, monkeypatch):
_, config, _ = scope
monkeypatch.setattr(routes, "_get_user_skill_storage", lambda _: pytest.fail("unauthorized scan"))
with client_for(config, "user") as client:
assert client.get("/api/skills/diagnostics/custom").status_code == 403
def test_diagnostic_failure_does_not_expose_exception(scope, monkeypatch):
_, config, storage = scope
def fail():
raise OSError("private /srv/customer/secret SKILL.md source")
monkeypatch.setattr(storage, "load_custom_skill_diagnostics", fail)
monkeypatch.setattr(routes, "_get_user_skill_storage", lambda _: storage)
with client_for(config) as client:
response = client.get("/api/skills/diagnostics/custom")
assert response.status_code == 500
assert response.json() == {"detail": "Failed to load custom skill diagnostics."}
def test_correction_and_reload_restore_runtime_catalog(scope, monkeypatch):
from deerflow.agents.lead_agent import prompt
paths, config, storage = scope
broken = write(paths.user_custom_skills_dir(USER_ID), "broken")
monkeypatch.setattr(prompt, "get_or_new_user_skill_storage", lambda *_args, **_kwargs: storage)
monkeypatch.setattr(prompt, "get_or_new_skill_storage", lambda **_kwargs: storage)
monkeypatch.setattr(prompt, "resolve_shared_config_path", lambda: None)
prompt.clear_skills_system_prompt_cache()
try:
assert prompt.get_enabled_skills_for_config(config, user_id=USER_ID) == []
with client_for(config) as client:
assert len(client.get("/api/skills/diagnostics/custom").json()["diagnostics"]) == 1
broken.write_text(GOOD, encoding="utf-8")
# A management read alone does not update the runtime's cached list.
assert client.get("/api/skills/diagnostics/custom").json()["diagnostics"] == []
assert prompt.get_enabled_skills_for_config(config, user_id=USER_ID) == []
assert client.post("/api/skills/reload").status_code == 200
assert client.get("/api/skills/diagnostics/custom").json()["diagnostics"] == []
assert [s.name for s in prompt.get_enabled_skills_for_config(config, user_id=USER_ID)] == ["broken"]
finally:
prompt.clear_skills_system_prompt_cache()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

View File

@ -231,6 +231,15 @@ delete helper accepts remote 404 (not 403) before retrying local cleanup, and
## Capability Center
`skill-diagnostics.tsx` mounts only for administrators outside static demos. Its
user-keyed diagnostics query is separate from `useSkills()` and every picker.
Reload posts `/api/skills/reload` before invalidating both normal skills and
diagnostics; failures retain the warning and show localized retry feedback.
Never interpret raw parser exception wording in the UI.
Keep the scope note visible even when diagnostics are empty. It must describe
the YAML-syntax-only check and its exclusions (missing frontmatter, metadata
validation, unreadable and non-UTF-8 files); it is not a package-validity check.
`/workspace/capabilities` owns Plugins and Skills navigation. Plugins composes the
MCP manager and a lazily loaded Lark configuration dialog; installation, OAuth,
mutation permissions, and cache ownership remain in the existing hooks. Skill display names/summaries are presentation

View File

@ -0,0 +1,84 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangleIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import { loadSkillDiagnostics, reloadSkills } from "@/core/skills/api";
export function SkillDiagnostics({ userId }: { userId: string }) {
const { t } = useI18n();
const text = t.settings.skills;
const client = useQueryClient();
const queryKey = ["skill-diagnostics", userId];
const diagnostics = useQuery({
queryKey,
queryFn: ({ signal }) => loadSkillDiagnostics(signal),
retry: false,
});
const reload = useMutation({
mutationFn: reloadSkills,
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: ["skills"] }),
client.invalidateQueries({ queryKey }),
]);
},
});
const failures = diagnostics.data ?? [];
return (
<div className="mb-4 space-y-3">
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={reload.isPending || diagnostics.isFetching}
onClick={() => reload.mutate()}
>
<RefreshCwIcon
className={reload.isPending ? "size-4 animate-spin" : "size-4"}
/>
{reload.isPending
? text.diagnosticsRefreshing
: text.diagnosticsRefresh}
</Button>
</div>
<p className="text-muted-foreground text-sm">{text.diagnosticsScope}</p>
{(diagnostics.isError || reload.isError) && (
<p role="alert" className="text-destructive text-sm">
{reload.isError
? text.diagnosticsReloadFailed
: text.diagnosticsFailed}
</p>
)}
{failures.length > 0 && (
<section
aria-label={text.diagnosticsTitle}
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4"
>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangleIcon className="size-4 text-amber-600" />
{text.diagnosticsTitle}
</h3>
<ul className="mt-3 space-y-3">
{failures.map((failure) => (
<li
key={`${failure.package}/${failure.path}`}
className="text-sm"
>
<code className="break-all">{`${failure.package}/${failure.path}${failure.line == null ? "" : `:${failure.line}${failure.column == null ? "" : `:${failure.column}`}`}`}</code>
<p className="mt-1">{text.diagnosticsInvalid}</p>
{failure.hint === "quote_colon_value" && (
<p className="text-muted-foreground">
{text.diagnosticsQuote}
</p>
)}
</li>
))}
</ul>
</section>
)}
</div>
);
}

View File

@ -45,6 +45,7 @@ import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { CapabilityCard, CapabilityIcon } from "./capability-card";
import { SkillDiagnostics } from "./skill-diagnostics";
import { presentSkill } from "./skill-presentation";
const SkillExportDialog = dynamic(() => import("./skill-export-dialog"), {
@ -53,11 +54,16 @@ const SkillExportDialog = dynamic(() => import("./skill-export-dialog"), {
export function SkillGallery({ query = "" }: { query?: string } = {}) {
const { t } = useI18n();
const { user } = useAuth();
const { skills, isLoading, error } = useSkills();
const adminRequired =
error instanceof SkillRequestError && error.isAdminRequired;
return (
<div>
{user?.system_role === "admin" &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<SkillDiagnostics key={user.id} userId={user.id} />
)}
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : adminRequired ? (

View File

@ -2212,6 +2212,16 @@ export const enUS: Translations = {
},
},
skills: {
diagnosticsTitle: "Could not load",
diagnosticsScope:
"Only YAML syntax errors in your own custom skills are shown. After correcting a SKILL.md file, reload skills. Missing frontmatter, metadata validation errors, and unreadable or non-UTF-8 files are not reported. Shared and externally linked packages are excluded.",
diagnosticsInvalid: "Invalid YAML frontmatter.",
diagnosticsQuote: "Quote values containing a colon followed by a space.",
diagnosticsRefresh: "Reload skills",
diagnosticsRefreshing: "Reloading skills…",
diagnosticsFailed:
"Could not check custom skills for YAML errors. Reload to try again.",
diagnosticsReloadFailed: "Could not reload skills. Try again.",
exportPrevious: "Previous 50 files",
exportNotices: {
skill_export_yaml_alias:

View File

@ -1806,6 +1806,14 @@ export interface Translations {
};
};
skills: {
diagnosticsTitle: string;
diagnosticsScope: string;
diagnosticsInvalid: string;
diagnosticsQuote: string;
diagnosticsRefresh: string;
diagnosticsRefreshing: string;
diagnosticsFailed: string;
diagnosticsReloadFailed: string;
exportPrevious: string;
exportNotices: Record<string, string>;
exportSkill: string;

View File

@ -2082,6 +2082,15 @@ export const zhCN: Translations = {
},
},
skills: {
diagnosticsTitle: "无法加载",
diagnosticsScope:
"仅报告你自己的自定义技能的 YAML 语法错误。修正 SKILL.md 文件后请重新加载。不报告缺少头部、元数据校验错误、无法读取或非 UTF-8 编码的文件,不包含共享目录和外部链接包。",
diagnosticsInvalid: "YAML 头部格式错误。",
diagnosticsQuote: "包含“冒号加空格”的值需要用引号包裹。",
diagnosticsRefresh: "重新加载技能",
diagnosticsRefreshing: "正在重新加载…",
diagnosticsFailed: "无法检查自定义技能的 YAML 错误,请重新加载后重试。",
diagnosticsReloadFailed: "重新加载技能失败,请重试。",
exportPrevious: "上 50 项",
exportNotices: {
skill_export_yaml_alias:

View File

@ -1,7 +1,7 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { Skill } from "./type";
import type { Skill, SkillLoadDiagnostic } from "./type";
// Keep this in lockstep with `_MAX_SKILL_ARCHIVE_UPLOAD_BYTES` in
// `backend/app/gateway/routers/skills.py`; nginx and Ingress allow 101 MiB so
@ -121,6 +121,33 @@ async function readErrorDetail(response: Response): Promise<SkillErrorDetail> {
};
}
export async function loadSkillDiagnostics(
signal?: AbortSignal,
): Promise<SkillLoadDiagnostic[]> {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/diagnostics/custom`,
{ signal, cache: "no-store" },
);
if (!response.ok) {
const detail = await readErrorDetail(response);
throw new SkillRequestError(response.status, detail.message, detail);
}
const data = (await response.json()) as {
diagnostics: SkillLoadDiagnostic[];
};
return data.diagnostics;
}
export async function reloadSkills(): Promise<void> {
const response = await fetch(`${getBackendBaseURL()}/api/skills/reload`, {
method: "POST",
});
if (!response.ok) {
const detail = await readErrorDetail(response);
throw new SkillRequestError(response.status, detail.message, detail);
}
}
export async function loadSkills() {
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
if (!skills.ok) {

View File

@ -6,3 +6,12 @@ export interface Skill {
enabled: boolean;
editable: boolean;
}
export interface SkillLoadDiagnostic {
package: string;
path: "SKILL.md";
code: "invalid_frontmatter";
hint?: "quote_colon_value" | null;
line?: number | null;
column?: number | null;
}

View File

@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test("YAML diagnostics stay separate and reload restores a corrected skill", async ({
page,
}, testInfo) => {
mockLangGraphAPI(page, { skills: [] });
let corrected = false;
let reloads = 0;
await page.route("**/api/skills/diagnostics/custom", (route) =>
route.fulfill({
json: {
diagnostics: corrected
? []
: [
{
package: "collect-startrun",
path: "SKILL.md",
code: "invalid_frontmatter",
hint: "quote_colon_value",
line: 3,
column: 27,
},
],
},
}),
);
await page.route("**/api/skills", (route) =>
route.fulfill({
json: {
skills: corrected
? [
{
name: "collect-startrun",
description: "Collect project updates",
category: "custom",
enabled: true,
editable: true,
},
]
: [],
},
}),
);
await page.route("**/api/skills/reload", (route) => {
reloads++;
expect(route.request().method()).toBe("POST");
if (reloads === 1)
return route.fulfill({ status: 500, json: { detail: "Reload failed" } });
corrected = true;
return route.fulfill({ json: { success: true, scope: "shared_config" } });
});
await page.goto("/workspace/capabilities?tab=skills");
await expect(
page.getByRole("heading", { name: "Could not load" }),
).toBeVisible();
await expect(page.getByText("collect-startrun/SKILL.md:3:27")).toBeVisible();
await expect(
page.getByText("Quote values containing a colon followed by a space."),
).toBeVisible();
await expect(
page.getByRole("button", { name: "View details collect-startrun" }),
).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath("skill-yaml-warning.png"),
fullPage: true,
});
await page
.getByRole("button", { name: "Reload skills", exact: true })
.click();
await expect(
page
.getByRole("alert")
.filter({ hasText: "Could not reload skills. Try again." }),
).toBeVisible();
await expect(page.getByText("collect-startrun/SKILL.md:3:27")).toBeVisible();
await page
.getByRole("button", { name: "Reload skills", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "Could not load" }),
).toHaveCount(0);
await page.getByRole("tab", { name: "My skills", exact: true }).click();
await expect(
page.getByRole("button", {
name: "View details collect-startrun",
exact: true,
}),
).toBeVisible();
});
test("ordinary users never request management diagnostics", async ({
page,
}) => {
mockLangGraphAPI(page, { skills: [] });
let requests = 0;
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: "ordinary",
email: "ordinary@example.com",
system_role: "user",
needs_setup: false,
permissions: [],
},
}),
);
await page.route("**/api/skills/diagnostics/custom", (route) => {
requests++;
return route.fulfill({ json: { diagnostics: [] } });
});
// The local preview starts with an SSR admin. Refresh the real auth
// provider before opening the skill gallery as an ordinary user.
await page.goto("/workspace/capabilities");
await expect(
page.getByRole("textbox", { name: "Search plugins by name or purpose" }),
).toBeEnabled();
const refreshed = page.waitForResponse("**/api/v1/auth/me");
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await refreshed;
await page.getByRole("tab", { name: "Skills", exact: true }).click();
await expect(
page.getByRole("tab", { name: "My skills", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Reload skills", exact: true }),
).toHaveCount(0);
expect(requests).toBe(0);
});

View File

@ -0,0 +1,168 @@
import { afterEach, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
const api = rs.hoisted(() => ({ load: rs.fn(), reload: rs.fn() }));
const i18n = rs.hoisted(() => ({ locale: "en-US" }));
rs.mock("@/core/skills/api", () => ({
loadSkillDiagnostics: api.load,
reloadSkills: api.reload,
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
t: {
common: { loading: "Loading" },
settings: {
skills: {
diagnosticsTitle: "Could not load",
diagnosticsScope:
i18n.locale === "zh-CN"
? zhCN.settings.skills.diagnosticsScope
: enUS.settings.skills.diagnosticsScope,
diagnosticsInvalid: "Invalid YAML",
diagnosticsQuote: "Quote colon values",
diagnosticsRefresh: "Reload skills",
diagnosticsRefreshing: "Reloading skills",
diagnosticsFailed: "Could not load diagnostics",
diagnosticsReloadFailed: "Could not reload skills",
},
},
},
}),
}));
import { SkillDiagnostics } from "@/components/workspace/capabilities/skill-diagnostics";
import { enUS } from "@/core/i18n/locales/en-US";
import { zhCN } from "@/core/i18n/locales/zh-CN";
afterEach(() => {
cleanup();
rs.clearAllMocks();
i18n.locale = "en-US";
});
it.each([
[
"en-US",
/Only YAML syntax errors/,
/Missing frontmatter, metadata validation errors, and unreadable or non-UTF-8 files are not reported/,
[],
],
[
"en-US",
/Only YAML syntax errors/,
/Missing frontmatter, metadata validation errors, and unreadable or non-UTF-8 files are not reported/,
[{ package: "broken", path: "SKILL.md", code: "invalid_frontmatter" }],
],
[
"zh-CN",
/仅报告.*YAML 语法错误/,
/不报告缺少头部、元数据校验错误、无法读取或非 UTF-8 编码的文件/,
[],
],
[
"zh-CN",
/仅报告.*YAML 语法错误/,
/不报告缺少头部、元数据校验错误、无法读取或非 UTF-8 编码的文件/,
[{ package: "broken", path: "SKILL.md", code: "invalid_frontmatter" }],
],
])(
"explains YAML-only diagnostics and exclusions in %s",
async (locale, syntax, exclusions, failures) => {
i18n.locale = locale;
api.load.mockResolvedValue(failures);
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<SkillDiagnostics userId="alice" />
</QueryClientProvider>,
);
await waitFor(() =>
expect(screen.getByRole<HTMLButtonElement>("button").disabled).toBe(
false,
),
);
expect(screen.getByText(syntax)).toBeTruthy();
expect(screen.getByText(exclusions)).toBeTruthy();
expect(screen.queryByText("broken/SKILL.md") === null).toBe(
failures.length === 0,
);
},
);
it("keeps warnings on reload failure, then refetches both lists after success", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["skills"], ["existing"]);
api.load.mockResolvedValue([
{
package: "broken",
path: "SKILL.md",
code: "invalid_frontmatter",
hint: "quote_colon_value",
line: 3,
column: 27,
},
]);
api.reload.mockRejectedValueOnce(new Error("offline"));
render(
<QueryClientProvider client={client}>
<SkillDiagnostics userId="alice" />
</QueryClientProvider>,
);
await screen.findByText("broken/SKILL.md:3:27");
expect(screen.getByText("Quote colon values")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Reload skills" }));
await screen.findByRole("alert");
expect(screen.getByText("broken/SKILL.md:3:27")).toBeTruthy();
expect(client.getQueryState(["skills"])?.isInvalidated).toBe(false);
let finish!: () => void;
api.reload.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
api.load.mockResolvedValue([]);
fireEvent.click(screen.getByRole("button", { name: "Reload skills" }));
await waitFor(() =>
expect(
screen.getByRole<HTMLButtonElement>("button", {
name: "Reloading skills",
}).disabled,
).toBe(true),
);
expect(screen.getByText("broken/SKILL.md:3:27")).toBeTruthy();
await act(async () => finish());
await waitFor(() =>
expect(screen.queryByText("broken/SKILL.md:3:27")).toBeNull(),
);
expect(client.getQueryState(["skills"])?.isInvalidated).toBe(true);
});
it("does not display a colon hint for generic YAML failures", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
api.load.mockResolvedValue([
{ package: "broken", path: "SKILL.md", code: "invalid_frontmatter" },
]);
render(
<QueryClientProvider client={client}>
<SkillDiagnostics userId="bob" />
</QueryClientProvider>,
);
await screen.findByText("broken/SKILL.md");
expect(screen.getByText("Invalid YAML")).toBeTruthy();
expect(screen.queryByText("Quote colon values")).toBeNull();
});