diff --git a/README.md b/README.md index 04c20fa283..573f985cf1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 5a2952ab76..d44a0b5418 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -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) diff --git a/backend/packages/harness/deerflow/skills/AGENTS.md b/backend/packages/harness/deerflow/skills/AGENTS.md index 16e5420e66..a8a3b78a45 100644 --- a/backend/packages/harness/deerflow/skills/AGENTS.md +++ b/backend/packages/harness/deerflow/skills/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/skills/diagnostics.py b/backend/packages/harness/deerflow/skills/diagnostics.py new file mode 100644 index 0000000000..cc157761f4 --- /dev/null +++ b/backend/packages/harness/deerflow/skills/diagnostics.py @@ -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, + ) diff --git a/backend/packages/harness/deerflow/skills/parser.py b/backend/packages/harness/deerflow/skills/parser.py index cc2d71fe4c..e5ae43e56e 100644 --- a/backend/packages/harness/deerflow/skills/parser.py +++ b/backend/packages/harness/deerflow/skills/parser.py @@ -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): diff --git a/backend/packages/harness/deerflow/skills/storage/skill_storage.py b/backend/packages/harness/deerflow/skills/storage/skill_storage.py index d4cf4edeb8..6e27fd5958 100644 --- a/backend/packages/harness/deerflow/skills/storage/skill_storage.py +++ b/backend/packages/harness/deerflow/skills/storage/skill_storage.py @@ -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) # ------------------------------------------------------------------ diff --git a/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py b/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py index 67ae1f862c..980c32e730 100644 --- a/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py +++ b/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py @@ -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 diff --git a/backend/tests/test_skill_load_diagnostics.py b/backend/tests/test_skill_load_diagnostics.py new file mode 100644 index 0000000000..5604eea4c0 --- /dev/null +++ b/backend/tests/test_skill_load_diagnostics.py @@ -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() diff --git a/docs/pr-evidence/agent-knowledge-desktop.png b/docs/pr-evidence/agent-knowledge-desktop.png deleted file mode 100644 index 3807087202..0000000000 Binary files a/docs/pr-evidence/agent-knowledge-desktop.png and /dev/null differ diff --git a/docs/pr-evidence/agent-knowledge-mobile.png b/docs/pr-evidence/agent-knowledge-mobile.png deleted file mode 100644 index ed39faf7f9..0000000000 Binary files a/docs/pr-evidence/agent-knowledge-mobile.png and /dev/null differ diff --git a/docs/pr-evidence/connection-ownership-capability-center.png b/docs/pr-evidence/connection-ownership-capability-center.png deleted file mode 100644 index b9f08201ec..0000000000 Binary files a/docs/pr-evidence/connection-ownership-capability-center.png and /dev/null differ diff --git a/docs/pr-evidence/knowledge-citations-desktop.png b/docs/pr-evidence/knowledge-citations-desktop.png deleted file mode 100644 index c18752ac40..0000000000 Binary files a/docs/pr-evidence/knowledge-citations-desktop.png and /dev/null differ diff --git a/docs/pr-evidence/knowledge-citations-mobile.png b/docs/pr-evidence/knowledge-citations-mobile.png deleted file mode 100644 index 912387bcf3..0000000000 Binary files a/docs/pr-evidence/knowledge-citations-mobile.png and /dev/null differ diff --git a/docs/pr-evidence/model-management.png b/docs/pr-evidence/model-management.png deleted file mode 100644 index 4093841be9..0000000000 Binary files a/docs/pr-evidence/model-management.png and /dev/null differ diff --git a/docs/pr-evidence/session-skill-manage-e2e-20260406-202745.png b/docs/pr-evidence/session-skill-manage-e2e-20260406-202745.png deleted file mode 100644 index 938168d54d..0000000000 Binary files a/docs/pr-evidence/session-skill-manage-e2e-20260406-202745.png and /dev/null differ diff --git a/docs/pr-evidence/skill-manage-e2e-20260406-194030.png b/docs/pr-evidence/skill-manage-e2e-20260406-194030.png deleted file mode 100644 index fe276461d3..0000000000 Binary files a/docs/pr-evidence/skill-manage-e2e-20260406-194030.png and /dev/null differ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5637e17df4..1ce2bbc1d8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/components/workspace/capabilities/skill-diagnostics.tsx b/frontend/src/components/workspace/capabilities/skill-diagnostics.tsx new file mode 100644 index 0000000000..59699ae404 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/skill-diagnostics.tsx @@ -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 ( +
{text.diagnosticsScope}
+ {(diagnostics.isError || reload.isError) && ( ++ {reload.isError + ? text.diagnosticsReloadFailed + : text.diagnosticsFailed} +
+ )} + {failures.length > 0 && ( +{`${failure.package}/${failure.path}${failure.line == null ? "" : `:${failure.line}${failure.column == null ? "" : `:${failure.column}`}`}`}
+ {text.diagnosticsInvalid}
+ {failure.hint === "quote_colon_value" && ( ++ {text.diagnosticsQuote} +
+ )} +