plano/cli/test/test_skills.py

394 lines
13 KiB
Python

"""Tests for cli/planoai/skills.py and the config-rendering hooks that
materialize SKILL.md bodies into the rendered plano config.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest import mock
import pytest
from planoai.skills import (
AGENTS_SKILLS_DIR,
PROJECT_SKILLS_DIR,
USER_SKILLS_DIR,
Skill,
discover_skills,
parse_skill_md,
total_catalog_size,
)
@pytest.fixture(autouse=True)
def _isolate_user_scopes(tmp_path, monkeypatch):
"""Default both user-tier scopes to non-existent dirs so the dev's real
~/.plano/skills and ~/.agents/skills cannot bleed into tests.
"""
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "_no_user_skills")
monkeypatch.setattr(
"planoai.skills.AGENTS_SKILLS_DIR", tmp_path / "_no_agents_skills"
)
def _write_skill(
base: Path,
name: str,
description: str = "Process PDFs. Use when handling PDF files.",
body: str = "# Body\n\nDo the thing.",
extra_frontmatter: str = "",
) -> Path:
skill_dir = base / name
skill_dir.mkdir(parents=True, exist_ok=True)
frontmatter = f"name: {name}\ndescription: {description}\n{extra_frontmatter}"
(skill_dir / "SKILL.md").write_text(
f"---\n{frontmatter}---\n\n{body}",
encoding="utf-8",
)
return skill_dir / "SKILL.md"
def test_parse_skill_md_minimal(tmp_path):
skill_md = _write_skill(tmp_path, "pdf-processing")
skill, diagnostics = parse_skill_md(skill_md)
assert skill is not None
assert skill.name == "pdf-processing"
assert skill.description.startswith("Process PDFs")
assert "Do the thing." in skill.body
assert skill.location == skill_md.resolve()
assert skill.base_dir == skill_md.parent.resolve()
# No warnings or errors for a well-formed skill.
assert diagnostics == []
def test_parse_skill_md_lenient_when_name_mismatches_directory(tmp_path):
skill_dir = tmp_path / "some-dir"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: different-name\ndescription: ok\n---\n\nbody",
encoding="utf-8",
)
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
assert skill is not None
assert skill.name == "different-name"
assert any(
"does not match parent directory" in d.message for d in diagnostics
), diagnostics
def test_parse_skill_md_warns_on_invalid_name(tmp_path):
skill_dir = tmp_path / "Bad-Name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: Bad-Name\ndescription: ok\n---\n\nbody",
encoding="utf-8",
)
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
assert skill is not None
assert any("violates spec naming rules" in d.message for d in diagnostics)
def test_parse_skill_md_recovers_from_unquoted_colons(tmp_path):
skill_dir = tmp_path / "code-review"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: code-review\n"
"description: Use this skill when: the user asks about code review\n"
"---\n\nbody",
encoding="utf-8",
)
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
# Lenient parse retries with quoted values and succeeds.
assert skill is not None
assert skill.name == "code-review"
assert "Use this skill when:" in skill.description
def test_parse_skill_md_rejects_when_description_missing(tmp_path):
skill_dir = tmp_path / "broken"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: broken\n---\n\nbody",
encoding="utf-8",
)
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
assert skill is None
assert any(d.severity == "error" for d in diagnostics)
def test_parse_skill_md_rejects_when_frontmatter_missing(tmp_path):
skill_dir = tmp_path / "no-frontmatter"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("just markdown", encoding="utf-8")
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
assert skill is None
assert any("frontmatter" in d.message for d in diagnostics)
def test_discover_skills_project_only(tmp_path, monkeypatch):
(tmp_path / ".plano").mkdir()
project_skills_dir = tmp_path / ".plano" / "skills"
project_skills_dir.mkdir()
_write_skill(project_skills_dir, "pdf-processing")
_write_skill(project_skills_dir, "code-review")
skills, diagnostics = discover_skills(
project_root=tmp_path, include_user_scope=False
)
names = sorted(s.name for s in skills)
assert names == ["code-review", "pdf-processing"]
assert all(s.scope == "project" for s in skills)
def test_discover_skills_picks_up_agents_scope(tmp_path, monkeypatch):
"""`npx skills add` writes into ~/.agents/skills/<name>. That directory
must be discovered as a user-tier (auto-trusted) scope so the upstream
CLI works without Plano-specific awareness.
"""
agents_dir = tmp_path / "fake-home" / ".agents" / "skills"
agents_dir.mkdir(parents=True)
_write_skill(agents_dir, "pdf", description="agents-scope description")
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
(tmp_path / ".plano").mkdir()
(tmp_path / ".plano" / "skills").mkdir()
skills, _ = discover_skills(project_root=tmp_path, include_user_scope=True)
by_name = {s.name: s for s in skills}
assert "pdf" in by_name
assert by_name["pdf"].scope == "agents"
assert by_name["pdf"].description == "agents-scope description"
def test_discover_skills_user_scope_shadows_agents_scope(tmp_path, monkeypatch):
"""When the same skill name exists in both ~/.plano/skills and
~/.agents/skills, the Plano-native one wins and a diagnostic is emitted.
Project root lives in its own subtree (with no .plano/ ancestor) so it
cannot collide with the patched user-scope dir.
"""
home = tmp_path / "fake-home"
home.mkdir()
agents_dir = home / ".agents" / "skills"
agents_dir.mkdir(parents=True)
_write_skill(agents_dir, "pdf", description="agents copy")
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
user_dir = home / ".plano" / "skills"
user_dir.mkdir(parents=True)
_write_skill(user_dir, "pdf", description="user copy")
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", user_dir)
project_root = tmp_path / "elsewhere" / "proj"
project_root.mkdir(parents=True)
skills, diagnostics = discover_skills(
project_root=project_root, include_user_scope=True
)
by_name = {s.name: s for s in skills}
assert by_name["pdf"].scope == "user"
assert by_name["pdf"].description == "user copy"
assert any("shadows ~/.agents/skills" in d.message for d in diagnostics)
def test_discover_skills_project_overrides_user_scope(tmp_path, monkeypatch):
user_skills_dir = tmp_path / "fake-home" / ".plano" / "skills"
user_skills_dir.mkdir(parents=True)
_write_skill(user_skills_dir, "shared", description="user-scope description")
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", user_skills_dir)
(tmp_path / ".plano").mkdir()
project_skills_dir = tmp_path / ".plano" / "skills"
project_skills_dir.mkdir()
_write_skill(project_skills_dir, "shared", description="project-scope description")
_write_skill(project_skills_dir, "only-project")
skills, diagnostics = discover_skills(
project_root=tmp_path, include_user_scope=True
)
by_name = {s.name: s for s in skills}
assert by_name["shared"].scope == "project"
assert by_name["shared"].description == "project-scope description"
assert by_name["only-project"].scope == "project"
assert any("shadows user-scope skill" in d.message for d in diagnostics)
def test_total_catalog_size_counts_name_and_description():
skills = [
Skill(
name="a",
description="d1",
location=Path("/x"),
base_dir=Path("/"),
body="b",
scope="project",
),
Skill(
name="bb",
description="dd",
location=Path("/y"),
base_dir=Path("/"),
body="b",
scope="project",
),
]
assert total_catalog_size(skills) == (1 + 2) + (2 + 2)
def test_materialize_skills_in_config_default_inlines_bodies(tmp_path, monkeypatch):
project_root = tmp_path
(project_root / ".plano").mkdir()
project_skills_dir = project_root / ".plano" / "skills"
project_skills_dir.mkdir()
_write_skill(
project_skills_dir,
"pdf-processing",
description="Process PDFs.",
body="# Body\nfollow these steps.",
)
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
# Mark the project trusted so .plano/skills is loaded.
trusted = fake_home / ".plano" / "trusted_projects.json"
trusted.parent.mkdir(parents=True, exist_ok=True)
trusted.write_text(
json.dumps({"trusted_projects": [str(project_root.resolve())]}),
encoding="utf-8",
)
monkeypatch.setattr(
"planoai.skills.USER_SKILLS_DIR",
fake_home / ".plano" / "skills",
)
from planoai.config_generator import materialize_skills_in_config
config_yaml = {"version": "v0.4.0"}
materialize_skills_in_config(config_yaml, project_root)
assert "skills" in config_yaml
materialized = config_yaml["skills"]
assert len(materialized) == 1
entry = materialized[0]
assert entry["name"] == "pdf-processing"
assert "follow these steps." in entry["body"]
assert entry["scope"] == "project"
def test_materialize_skills_in_config_skips_untrusted_project_skills(
tmp_path, monkeypatch
):
project_root = tmp_path
(project_root / ".plano").mkdir()
project_skills_dir = project_root / ".plano" / "skills"
project_skills_dir.mkdir()
_write_skill(project_skills_dir, "pdf-processing")
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"planoai.skills.USER_SKILLS_DIR",
fake_home / ".plano" / "skills",
)
from planoai.config_generator import materialize_skills_in_config
config_yaml = {"version": "v0.4.0"}
materialize_skills_in_config(config_yaml, project_root)
# Untrusted -> project skills are not loaded.
assert "skills" not in config_yaml
def test_materialize_skills_in_config_loads_agents_scope_without_trust(
tmp_path, monkeypatch
):
"""Even with no project trust, skills installed by `npx skills add` into
~/.agents/skills/<name> must materialize into the rendered config — that
directory is the universal Agent Skills install location and is
user-tier, not project-tier.
"""
project_root = tmp_path / "proj"
(project_root / ".plano").mkdir(parents=True)
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"planoai.skills.USER_SKILLS_DIR",
fake_home / ".plano" / "skills",
)
agents_dir = fake_home / ".agents" / "skills"
agents_dir.mkdir(parents=True)
_write_skill(agents_dir, "pdf", body="# Body\nhandle the pdf.")
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
from planoai.config_generator import materialize_skills_in_config
config_yaml = {"version": "v0.4.0"}
materialize_skills_in_config(config_yaml, project_root)
assert "skills" in config_yaml
materialized = config_yaml["skills"]
assert len(materialized) == 1
assert materialized[0]["name"] == "pdf"
assert materialized[0]["scope"] == "agents"
assert "handle the pdf." in materialized[0]["body"]
def test_materialize_skills_in_config_respects_allow_list(tmp_path, monkeypatch):
project_root = tmp_path
(project_root / ".plano").mkdir()
skills_dir = project_root / ".plano" / "skills"
skills_dir.mkdir()
_write_skill(skills_dir, "skill-a")
_write_skill(skills_dir, "skill-b")
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
trusted = fake_home / ".plano" / "trusted_projects.json"
trusted.parent.mkdir(parents=True, exist_ok=True)
trusted.write_text(
json.dumps({"trusted_projects": [str(project_root.resolve())]}),
encoding="utf-8",
)
monkeypatch.setattr(
"planoai.skills.USER_SKILLS_DIR",
fake_home / ".plano" / "skills",
)
from planoai.config_generator import materialize_skills_in_config
config_yaml = {
"version": "v0.4.0",
"skills": ["skill-a"],
"routing_preferences": [
{
"name": "demo route",
"description": "demo",
"models": ["openai/gpt-4o"],
"skills": ["skill-a", "does-not-exist"],
}
],
}
materialize_skills_in_config(config_yaml, project_root)
assert [s["name"] for s in config_yaml["skills"]] == ["skill-a"]
# Unknown allow-list entries are pruned but the known one is kept.
assert config_yaml["routing_preferences"][0]["skills"] == ["skill-a"]