"""Tests for skill loading.""" from __future__ import annotations import textwrap from pathlib import Path from openharness.config.settings import Settings from openharness.skills import get_user_skills_dir, load_skill_registry from openharness.skills.loader import discover_project_skill_dirs, get_user_skill_dirs from openharness.skills.bundled import _parse_frontmatter as parse_bundled_frontmatter from openharness.skills.loader import _parse_skill_markdown as parse_skill_markdown def test_load_skill_registry_includes_bundled(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) registry = load_skill_registry() names = [skill.name for skill in registry.list_skills()] assert "simplify" in names assert "review" in names assert "skill-creator" in names skill_creator = registry.get("skill-creator") assert skill_creator is not None assert skill_creator.source == "bundled" assert "Create, improve, and verify OpenHarness skills" in skill_creator.description def _write_skill(root: Path, name: str, body: str | None = None) -> Path: skill_dir = root / name skill_dir.mkdir(parents=True, exist_ok=True) skill_file = skill_dir / "SKILL.md" skill_file.write_text(body or f"# {name}\n{name} guidance\n", encoding="utf-8") return skill_file def test_load_skill_registry_includes_user_skills(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) skills_dir = get_user_skills_dir() deploy_dir = skills_dir / "deploy" deploy_dir.mkdir(parents=True) (deploy_dir / "SKILL.md").write_text("# Deploy\nDeployment workflow guidance\n", encoding="utf-8") registry = load_skill_registry() deploy = registry.get("Deploy") assert deploy is not None assert deploy.source == "user" assert "Deployment workflow guidance" in deploy.content def test_load_skill_registry_includes_user_compat_skill_dirs(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") claude_skill = _write_skill(tmp_path / "home" / ".claude" / "skills", "claude-review") agents_skill = _write_skill(tmp_path / "home" / ".agents" / "skills", "agents-plan") registry = load_skill_registry() assert registry.get("claude-review") is not None assert registry.get("agents-plan") is not None assert registry.get("claude-review").source == "user" # type: ignore[union-attr] assert registry.get("agents-plan").source == "user" # type: ignore[union-attr] assert str(claude_skill) in (registry.get("claude-review").path or "") # type: ignore[union-attr] assert str(agents_skill) in (registry.get("agents-plan").path or "") # type: ignore[union-attr] def test_get_user_skill_dirs_includes_openharness_claude_and_agents(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") dirs = get_user_skill_dirs() assert tmp_path / "config" / "skills" in dirs assert tmp_path / "home" / ".claude" / "skills" in dirs assert tmp_path / "home" / ".agents" / "skills" in dirs def test_user_skill_metadata_tracks_command_name_and_frontmatter_flags(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) skills_dir = get_user_skills_dir() deploy_dir = skills_dir / "deploy-flow" deploy_dir.mkdir(parents=True) (deploy_dir / "SKILL.md").write_text( textwrap.dedent("""\ --- name: Deploy Flow description: Release deployment workflow. user-invocable: false disable-model-invocation: true model: gpt-5.4 argument-hint: ENV --- # Deploy Flow """), encoding="utf-8", ) registry = load_skill_registry() by_command = registry.get("deploy-flow") by_display = registry.get("Deploy Flow") assert by_command is not None assert by_display is by_command assert by_command.name == "Deploy Flow" assert by_command.command_name == "deploy-flow" assert by_command.display_name == "Deploy Flow" assert by_command.user_invocable is False assert by_command.disable_model_invocation is True assert by_command.model == "gpt-5.4" assert by_command.argument_hint == "ENV" def test_project_skills_load_by_default_from_supported_dirs(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") repo = tmp_path / "repo" (repo / ".git").mkdir(parents=True) _write_skill(repo / ".openharness" / "skills", "oh-project") _write_skill(repo / ".agents" / "skills", "agents-project") _write_skill(repo / ".claude" / "skills", "claude-project") registry = load_skill_registry(repo, settings=Settings()) assert registry.get("oh-project").source == "project" # type: ignore[union-attr] assert registry.get("agents-project").source == "project" # type: ignore[union-attr] assert registry.get("claude-project").source == "project" # type: ignore[union-attr] def test_project_skills_can_be_disabled(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) repo = tmp_path / "repo" (repo / ".git").mkdir(parents=True) _write_skill(repo / ".claude" / "skills", "project-only") registry = load_skill_registry(repo, settings=Settings(allow_project_skills=False)) assert registry.get("project-only") is None def test_project_skill_discovery_walks_up_to_git_root(tmp_path: Path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") repo = tmp_path / "repo" cwd = repo / "packages" / "api" / "src" cwd.mkdir(parents=True) (repo / ".git").mkdir() root_skill_dir = repo / ".claude" / "skills" package_skill_dir = repo / "packages" / ".agents" / "skills" outside_skill_dir = tmp_path / ".claude" / "skills" root_skill_dir.mkdir(parents=True) package_skill_dir.mkdir(parents=True) outside_skill_dir.mkdir(parents=True) dirs = discover_project_skill_dirs(cwd) assert root_skill_dir.resolve() in dirs assert package_skill_dir.resolve() in dirs assert outside_skill_dir.resolve() not in dirs assert dirs.index(root_skill_dir.resolve()) < dirs.index(package_skill_dir.resolve()) def test_project_skill_nearer_cwd_overrides_parent_and_user(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") _write_skill(tmp_path / "home" / ".claude" / "skills", "deploy", "# user deploy\nuser version\n") repo = tmp_path / "repo" cwd = repo / "services" / "api" cwd.mkdir(parents=True) (repo / ".git").mkdir() _write_skill(repo / ".claude" / "skills", "deploy", "# root deploy\nroot version\n") _write_skill(cwd / ".claude" / "skills", "deploy", "# api deploy\napi version\n") registry = load_skill_registry(cwd, settings=Settings()) skill = registry.get("deploy") assert skill is not None assert skill.source == "project" assert "api version" in skill.content def test_unsafe_project_skill_dirs_are_ignored(tmp_path: Path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") repo = tmp_path / "repo" repo.mkdir() escaped = tmp_path / "escaped" / "skills" escaped.mkdir(parents=True) dirs = discover_project_skill_dirs(repo, ["../escaped/skills", str(escaped), ".claude/skills"]) assert escaped.resolve() not in dirs # --- parse_skill_markdown unit tests --- def test_parse_frontmatter_inline_description(): """Inline description: value on the same line as the key.""" content = textwrap.dedent("""\ --- name: my-skill description: A short inline description --- # Body """) name, desc = parse_skill_markdown("fallback", content) assert name == "my-skill" assert desc == "A short inline description" def test_parse_frontmatter_folded_block_scalar(): """YAML folded block scalar (>) must be expanded into a single string.""" content = textwrap.dedent("""\ --- name: NL2SQL Expert description: > Multi-tenant NL2SQL skill for converting natural language questions into SQL queries. Covers the full pipeline: tenant routing, table selection, question enhancement, context retrieval. tags: - nl2sql --- # NL2SQL Expert Skill """) name, desc = parse_skill_markdown("fallback", content) assert name == "NL2SQL Expert" assert "Multi-tenant NL2SQL skill" in desc assert "context retrieval" in desc # Folded scalar joins lines with spaces, not newlines assert "\n" not in desc def test_parse_frontmatter_literal_block_scalar(): """YAML literal block scalar (|) preserves newlines.""" content = textwrap.dedent("""\ --- name: multi-line description: | Line one. Line two. Line three. --- # Body """) name, desc = parse_skill_markdown("fallback", content) assert name == "multi-line" assert "Line one." in desc assert "Line two." in desc def test_parse_frontmatter_quoted_description(): """Quoted description values are handled correctly.""" content = textwrap.dedent("""\ --- name: quoted description: "A quoted description with: colons" --- # Body """) name, desc = parse_skill_markdown("fallback", content) assert name == "quoted" assert desc == "A quoted description with: colons" def test_parse_fallback_heading_and_paragraph(): """Without frontmatter, falls back to heading + first paragraph.""" content = "# My Skill\nThis is the description from the body.\n" name, desc = parse_skill_markdown("fallback", content) assert name == "My Skill" assert desc == "This is the description from the body." def test_parse_no_description_uses_skill_name(): """When nothing provides a description, falls back to 'Skill: '.""" content = "# OnlyHeading\n" name, desc = parse_skill_markdown("fallback", content) assert name == "OnlyHeading" assert desc == "Skill: OnlyHeading" def test_parse_malformed_yaml_falls_back(): """Malformed YAML in frontmatter falls back to body parsing.""" content = textwrap.dedent("""\ --- name: [invalid yaml description: also broken: { --- # Fallback Title Body paragraph here. """) name, desc = parse_skill_markdown("fallback", content) # Fallback scans all lines; frontmatter lines are not excluded, so # the first non-heading, non-delimiter line wins. The important thing # is that a YAMLError doesn't crash the loader. assert isinstance(desc, str) and desc # --- bundled skill frontmatter tests --- # # The bundled skill loader used to use a naive line-by-line parser that did # not understand YAML block scalars (``>`` / ``|``) — a partial fix landed in # #96 only on the user-skill side. These cases pin the bundled loader to the # same behavior so future bundled skills with frontmatter parse correctly. def test_bundled_frontmatter_folded_block_scalar(): """Bundled loader expands folded block scalars the same way user loader does.""" content = textwrap.dedent("""\ --- name: bundled-folded description: > A long folded description spanning multiple lines that should join with spaces. --- # Body """) name, desc = parse_bundled_frontmatter("fallback", content) assert name == "bundled-folded" assert "A long folded description spanning" in desc assert "join with spaces" in desc assert "\n" not in desc def test_bundled_frontmatter_literal_block_scalar(): """Bundled loader preserves literal-scalar newlines.""" content = textwrap.dedent("""\ --- name: bundled-literal description: | Line one. Line two. --- # Body """) name, desc = parse_bundled_frontmatter("fallback", content) assert name == "bundled-literal" assert "Line one." in desc assert "Line two." in desc def test_bundled_frontmatter_inline_description(): """Inline frontmatter description still works on the bundled side.""" content = textwrap.dedent("""\ --- name: bundled-inline description: A short bundled description --- # Body """) name, desc = parse_bundled_frontmatter("fallback", content) assert name == "bundled-inline" assert desc == "A short bundled description" def test_bundled_no_description_uses_bundled_prefix(): """When nothing supplies a description, the bundled fallback prefix is used.""" name, desc = parse_bundled_frontmatter("fallback", "# OnlyHeading\n") assert name == "OnlyHeading" assert desc == "Bundled skill: OnlyHeading" def test_bundled_fallback_heading_and_paragraph(): """Without frontmatter, the bundled loader falls back to heading + first paragraph.""" content = "# Bundled Skill\nThis is a bundled body description.\n" name, desc = parse_bundled_frontmatter("fallback", content) assert name == "Bundled Skill" assert desc == "This is a bundled body description."