Files
2026-05-08 03:49:02 +00:00

377 lines
13 KiB
Python

"""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: <name>'."""
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."