mirror of
https://github.com/katanemo/plano.git
synced 2026-06-23 15:38:07 +02:00
feat(skills): add Agent Skills support with orchestrator-driven activation
This commit is contained in:
parent
5a4487fc6e
commit
7f5bf641bb
24 changed files with 2777 additions and 97 deletions
235
cli/test/test_skills_cmd.py
Normal file
235
cli/test/test_skills_cmd.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""CLI tests for the `planoai skills` command group."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from planoai.skills_cmd import skills
|
||||
|
||||
|
||||
def _seed_project(tmp_path: Path) -> Path:
|
||||
"""Create a project that find_project_root will pick up via .plano/."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".plano").mkdir()
|
||||
return project
|
||||
|
||||
|
||||
@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 the test sandbox.
|
||||
Individual tests can override these via further monkeypatching.
|
||||
"""
|
||||
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 = "demo skill") -> None:
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\nbody",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_list_empty(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
# Isolate user-scope skills dir.
|
||||
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No skills installed" in result.output
|
||||
|
||||
|
||||
def test_list_shows_project_skills(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||
|
||||
_write_skill(project / ".plano" / "skills", "pdf-processing")
|
||||
_write_skill(project / ".plano" / "skills", "code-review")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["list", "--no-user-scope"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pdf-processing" in result.output
|
||||
assert "code-review" in result.output
|
||||
|
||||
|
||||
def test_remove_deletes_skill_dir(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||
|
||||
skills_dir = project / ".plano" / "skills"
|
||||
_write_skill(skills_dir, "pdf-processing")
|
||||
(skills_dir / ".skills.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"skills": {
|
||||
"pdf-processing": {
|
||||
"source": "git",
|
||||
"repo": "owner/pdf-processing",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["remove", "pdf-processing"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (skills_dir / "pdf-processing").exists()
|
||||
manifest = json.loads((skills_dir / ".skills.json").read_text(encoding="utf-8"))
|
||||
assert "pdf-processing" not in manifest["skills"]
|
||||
|
||||
|
||||
def test_remove_unknown_skill_errors(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||
(project / ".plano" / "skills").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["remove", "nope"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_add_falls_back_to_git_when_no_npx(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
# Force the npx branch off and stub git clone to create a SKILL.md.
|
||||
monkeypatch.setattr("planoai.skills_cmd._has_npx", lambda: False)
|
||||
monkeypatch.setattr("planoai.skills_cmd._has_git", lambda: True)
|
||||
|
||||
def fake_subprocess_run(cmd, **kwargs):
|
||||
# cmd is like ["git", "clone", ..., url, dest]
|
||||
dest = Path(cmd[-1])
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: example\n---\n\nbody",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dest / ".git").mkdir()
|
||||
return mock.Mock(returncode=0)
|
||||
|
||||
monkeypatch.setattr("planoai.skills_cmd.subprocess.run", fake_subprocess_run)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["add", "owner/my-skill"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".plano" / "skills" / "my-skill" / "SKILL.md").exists()
|
||||
# Trust hint should be shown for untrusted projects with project-scope installs.
|
||||
assert "planoai skills trust" in result.output
|
||||
|
||||
|
||||
def test_add_discovers_skill_installed_into_agents_scope_by_npx(tmp_path, monkeypatch):
|
||||
"""`npx skills add` writes to ~/.agents/skills/<name>; planoai must
|
||||
pick it up from that universal scope and *not* nag about trust.
|
||||
"""
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
agents_dir = tmp_path / "agents" / "skills"
|
||||
agents_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||
|
||||
# Pretend npx is on $PATH and succeeds, dropping the skill in ~/.agents/skills
|
||||
# rather than .plano/skills (which is what the upstream CLI actually does).
|
||||
monkeypatch.setattr("planoai.skills_cmd._has_npx", lambda: True)
|
||||
|
||||
def fake_install_via_npx(target, project_root, console):
|
||||
skill_dir = agents_dir / "pdf"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: pdf\ndescription: process pdfs\n---\n\nbody",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("planoai.skills_cmd._install_via_npx", fake_install_via_npx)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["add", "openai/skills"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "scope=agents" in result.output
|
||||
assert "planoai skills trust" not in result.output
|
||||
|
||||
|
||||
def test_list_includes_agents_scope_entries(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
agents_dir = tmp_path / "agents-skills"
|
||||
agents_dir.mkdir()
|
||||
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||
_write_skill(agents_dir, "pdf")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pdf" in result.output
|
||||
assert "agents" in result.output
|
||||
|
||||
|
||||
def test_remove_rejects_agents_scope_skill(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
(project / ".plano" / "skills").mkdir()
|
||||
|
||||
agents_dir = tmp_path / "agents-skills"
|
||||
agents_dir.mkdir()
|
||||
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||
_write_skill(agents_dir, "pdf")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["remove", "pdf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "npx skills remove" in result.output
|
||||
|
||||
|
||||
def test_trust_marks_project(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["trust"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
trusted_file = fake_home / ".plano" / "trusted_projects.json"
|
||||
assert trusted_file.exists()
|
||||
data = json.loads(trusted_file.read_text(encoding="utf-8"))
|
||||
assert str(project.resolve()) in data["trusted_projects"]
|
||||
|
||||
|
||||
def test_add_rejects_invalid_target(tmp_path, monkeypatch):
|
||||
project = _seed_project(tmp_path)
|
||||
monkeypatch.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(skills, ["add", "not-a-spec"])
|
||||
assert result.exit_code != 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue