feat(skills): add Agent Skills support with orchestrator-driven activation

This commit is contained in:
Spherrrical 2026-05-13 15:44:16 -07:00
parent 5a4487fc6e
commit 7f5bf641bb
24 changed files with 2777 additions and 97 deletions

View file

@ -1,6 +1,7 @@
import json
import os
import uuid
from pathlib import Path
from planoai.utils import convert_legacy_listeners
from jinja2 import Environment, FileSystemLoader
import yaml
@ -8,6 +9,14 @@ from jsonschema import validate, ValidationError
from urllib.parse import urlparse
from copy import deepcopy
from planoai.consts import DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
from planoai.skills import (
MAX_CATALOG_BYTES,
Skill,
discover_skills,
find_project_root,
is_project_trusted,
total_catalog_size,
)
SUPPORTED_PROVIDERS_WITH_BASE_URL = [
"azure_openai",
@ -162,6 +171,127 @@ def _version_tuple(version_string):
return tuple(out)
def materialize_skills_in_config(config_yaml: dict, project_root: Path) -> None:
"""Discover and inline Agent Skills referenced by `config_yaml`.
Mutates `config_yaml` in place. The user's source config may declare
`skills:` as a list of strings (skill names) or omit it entirely. After
this call, `config_yaml["skills"]` is either absent or a list of fully
materialized objects with `name`, `description`, `path`, `body`, etc.
Project-scope skills under `<project_root>/.plano/skills/` are only loaded
when the project has been marked trusted via `planoai skills trust`.
Per-route `routing_preferences[].skills` allow-lists are preserved as-is
so brightstaff can scope the catalog when that route is selected.
"""
requested = config_yaml.get("skills")
user_only = not is_project_trusted(project_root)
discovered, diagnostics = discover_skills(
project_root=project_root, include_user_scope=True
)
for diag in diagnostics:
prefix = "error" if diag.severity == "error" else "warning"
print(f"[skills] {prefix}: {diag.path}: {diag.message}")
if user_only:
project_skills = [s for s in discovered if s.scope == "project"]
if project_skills:
print(
"[skills] note: project-scope skills are present but the project is "
"not trusted yet; run `planoai skills trust` to enable them."
)
# Keep all non-project scopes (user + agents) — both are user-tier and
# auto-trusted, so they always load regardless of project trust state.
discovered = [s for s in discovered if s.scope != "project"]
skills_by_name: dict[str, Skill] = {s.name: s for s in discovered}
if requested is None:
# Default: auto-include every discovered skill.
selected: list[Skill] = list(discovered)
else:
if not isinstance(requested, list):
raise Exception("`skills:` must be a list of strings or skill objects")
selected = []
seen: set[str] = set()
for entry in requested:
if isinstance(entry, str):
name = entry
elif isinstance(entry, dict):
name = entry.get("name")
if not isinstance(name, str):
raise Exception(
"skill entries with object form must include a string `name`"
)
else:
raise Exception(
f"unsupported entry in `skills:` (expected str or mapping, got {type(entry).__name__})"
)
if name in seen:
continue
seen.add(name)
skill = skills_by_name.get(name)
if skill is None:
print(
f"[skills] warning: skill '{name}' is declared in config but no "
f"SKILL.md was discovered under .plano/skills/ or ~/.plano/skills/"
)
continue
selected.append(skill)
if not selected:
config_yaml.pop("skills", None)
_strip_unknown_route_skills(config_yaml, set())
return
catalog_bytes = total_catalog_size(selected)
if catalog_bytes > MAX_CATALOG_BYTES:
print(
f"[skills] warning: skill catalog size is {catalog_bytes} bytes, "
f"above the recommended cap of {MAX_CATALOG_BYTES}. Consider trimming "
f"`routing_preferences[].skills` to the smallest useful set per route."
)
config_yaml["skills"] = [s.to_dict() for s in selected]
_strip_unknown_route_skills(config_yaml, {s.name for s in selected})
def _strip_unknown_route_skills(config_yaml: dict, known: set) -> None:
"""Drop unknown skill names from `routing_preferences[*].skills` allow-lists.
The orchestrator only ever sees skills referenced under some
`routing_preferences[].skills`; an unknown name there would render the
`<skills>` block with a stale entry the runtime can't resolve, so filter
them out here with a warning instead.
"""
routes = config_yaml.get("routing_preferences")
if not isinstance(routes, list):
return
for route in routes:
if not isinstance(route, dict):
continue
allow = route.get("skills")
if not isinstance(allow, list):
continue
filtered = []
for name in allow:
if not isinstance(name, str):
continue
if name in known:
filtered.append(name)
else:
print(
f"[skills] warning: routing_preference '{route.get('name')}' "
f"references unknown skill '{name}'; dropping from allow-list."
)
if filtered:
route["skills"] = filtered
else:
route.pop("skills", None)
def validate_and_render_schema():
ENVOY_CONFIG_TEMPLATE_FILE = os.getenv(
"ENVOY_CONFIG_TEMPLATE_FILE", "envoy.template.yaml"
@ -196,6 +326,13 @@ def validate_and_render_schema():
_ = yaml.safe_load(plano_config_schema)
inferred_clusters = {}
# Materialize Agent Skills before further processing so the rest of the
# pipeline (Jinja2 envoy template, dump to plano_config_rendered.yaml) sees
# the inlined body / description / path.
plano_config_path = Path(PLANO_CONFIG_FILE).resolve()
project_root = find_project_root(plano_config_path.parent)
materialize_skills_in_config(config_yaml, project_root)
# Convert legacy llm_providers to model_providers
if "llm_providers" in config_yaml:
if "model_providers" in config_yaml:

View file

@ -64,6 +64,12 @@ BUILTIN_TEMPLATES: list[Template] = [
description="stateful responses with memory-backed storage",
yaml_text=_load_template_yaml("conversational_state_v1_responses.yaml"),
),
Template(
id="skills_routing",
title="Agent Skills Routing",
description="install Agent Skills (agentskills.io) and let Plano-Orchestrator route to them",
yaml_text=_load_template_yaml("skills_routing.yaml"),
),
]

View file

@ -39,6 +39,7 @@ from planoai.init_cmd import init as init_cmd
from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background
from planoai.chatgpt_cmd import chatgpt as chatgpt_cmd
from planoai.obs_cmd import obs as obs_cmd
from planoai.skills_cmd import skills as skills_cmd
from planoai.consts import (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
@ -746,6 +747,7 @@ main.add_command(init_cmd, name="init")
main.add_command(trace_cmd, name="trace")
main.add_command(chatgpt_cmd, name="chatgpt")
main.add_command(obs_cmd, name="obs")
main.add_command(skills_cmd, name="skills")
if __name__ == "__main__":
main()

420
cli/planoai/skills.py Normal file
View file

@ -0,0 +1,420 @@
"""Agent Skills discovery for Plano.
Parses SKILL.md files from .plano/skills/ (project scope) and ~/.plano/skills/
(user scope) following the Agent Skills specification:
https://agentskills.io/specification.md
The parser is intentionally lenient (per the "Adding skills support" guide):
warn on cosmetic issues but only skip a skill when its YAML is unparseable or
its required `description` field is missing.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
import yaml
from planoai.utils import getLogger
log = getLogger(__name__)
PROJECT_SKILLS_DIR = Path(".plano") / "skills"
USER_SKILLS_DIR = Path(os.path.expanduser("~/.plano/skills"))
# Universal Agent Skills install location used by `npx skills add` (vercel-labs/add-skill).
# Auto-trusted: same security posture as ~/.plano/skills, no project trust needed.
AGENTS_SKILLS_DIR = Path(os.path.expanduser("~/.agents/skills"))
MAX_CATALOG_BYTES = 5 * 1024
MAX_DIRS_SCANNED = 2000
_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
def trusted_projects_file() -> Path:
"""Resolve `~/.plano/trusted_projects.json` at call time.
Lazy so tests can override $HOME and have the new path picked up; module
import time would freeze it to the developer's actual home directory.
"""
return Path(os.path.expanduser("~/.plano/trusted_projects.json"))
def is_project_trusted(project_root: Path) -> bool:
"""Return True if `project_root` is listed in `~/.plano/trusted_projects.json`.
Project-scope skills come from arbitrary repos and are gated on this trust
decision (set with `planoai skills trust`). Single source of truth, shared
between the `skills_cmd` CLI surface and the render pipeline.
"""
path = trusted_projects_file()
if not path.exists():
return False
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return False
trusted = data.get("trusted_projects", []) if isinstance(data, dict) else []
resolved = str(project_root.resolve())
return resolved in {str(Path(p).resolve()) for p in trusted}
@dataclass(frozen=True)
class SkillDiagnostic:
severity: str # "warn" or "error"
message: str
path: Path
@dataclass
class Skill:
name: str
description: str
location: Path
base_dir: Path
body: str
scope: str
compatibility: str | None = None
license: str | None = None
metadata: dict = field(default_factory=dict)
allowed_tools: str | None = None
def to_dict(self) -> dict:
"""Serialize to a YAML-friendly dict for embedding in rendered config."""
return {
"name": self.name,
"description": self.description,
"path": str(self.location),
"base_dir": str(self.base_dir),
"scope": self.scope,
"body": self.body,
"compatibility": self.compatibility,
"license": self.license,
"metadata": dict(self.metadata) if self.metadata else None,
"allowed_tools": self.allowed_tools,
}
def find_project_root(start: Path | None = None) -> Path:
"""Walk up from `start` looking for `.plano/`, then `.git/`.
Falls back to `start` (or cwd) if nothing is found. This matches how
`npx skills add` chooses a project root.
"""
base = Path(start or Path.cwd()).resolve()
cur = base
while cur != cur.parent:
if (cur / ".plano").exists():
return cur
cur = cur.parent
cur = base
while cur != cur.parent:
if (cur / ".git").exists():
return cur
cur = cur.parent
return base
def parse_skill_md(path: Path) -> tuple[Skill | None, list[SkillDiagnostic]]:
"""Parse a single SKILL.md file leniently."""
diagnostics: list[SkillDiagnostic] = []
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
diagnostics.append(
SkillDiagnostic("error", f"failed to read SKILL.md: {exc}", path)
)
return None, diagnostics
frontmatter, body = _split_frontmatter(text)
if frontmatter is None:
diagnostics.append(SkillDiagnostic("error", "missing YAML frontmatter", path))
return None, diagnostics
data = _parse_yaml_lenient(frontmatter, path, diagnostics)
if data is None:
return None, diagnostics
description = data.get("description")
if not isinstance(description, str) or not description.strip():
diagnostics.append(
SkillDiagnostic(
"error", "skill is missing required 'description' field", path
)
)
return None, diagnostics
parent_name = path.parent.name
name = data.get("name")
if not isinstance(name, str) or not name.strip():
diagnostics.append(
SkillDiagnostic(
"warn",
f"missing 'name' field; falling back to parent directory '{parent_name}'",
path,
)
)
name = parent_name
name = name.strip()
if len(name) > 64:
diagnostics.append(
SkillDiagnostic("warn", "skill name exceeds 64 characters", path)
)
if not _NAME_PATTERN.match(name):
diagnostics.append(
SkillDiagnostic(
"warn",
f"skill name '{name}' violates spec naming rules "
"(lowercase alphanumeric + hyphens, no leading/trailing/double hyphens)",
path,
)
)
if name != parent_name:
diagnostics.append(
SkillDiagnostic(
"warn",
f"skill name '{name}' does not match parent directory '{parent_name}'",
path,
)
)
metadata_raw = data.get("metadata")
metadata = {}
if isinstance(metadata_raw, dict):
metadata = {str(k): str(v) for k, v in metadata_raw.items()}
skill = Skill(
name=name,
description=description.strip(),
location=path.resolve(),
base_dir=path.parent.resolve(),
body=body,
scope="project", # may be overridden by caller
compatibility=_string_field(data.get("compatibility")),
license=_string_field(data.get("license")),
metadata=metadata,
allowed_tools=_string_field(data.get("allowed-tools")),
)
return skill, diagnostics
def _split_frontmatter(text: str) -> tuple[str | None, str]:
if not text.startswith("---"):
return None, text
m = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*(?:\r?\n)?(.*)$", text, re.DOTALL)
if not m:
return None, text
return m.group(1), m.group(2).strip("\n")
def _parse_yaml_lenient(
frontmatter: str, path: Path, diagnostics: list[SkillDiagnostic]
) -> dict | None:
try:
data = yaml.safe_load(frontmatter)
except yaml.YAMLError as exc:
retried = _retry_quote_problem_fields(frontmatter)
if retried is None:
diagnostics.append(
SkillDiagnostic("error", f"YAML parse error: {exc}", path)
)
return None
try:
data = yaml.safe_load(retried)
except yaml.YAMLError as exc2:
diagnostics.append(
SkillDiagnostic(
"error", f"YAML parse error (after retry): {exc2}", path
)
)
return None
if not isinstance(data, dict):
diagnostics.append(
SkillDiagnostic("error", "frontmatter is not a YAML mapping", path)
)
return None
return data
_PROBLEM_FIELDS = ("description", "compatibility")
def _retry_quote_problem_fields(frontmatter: str) -> str | None:
"""Wrap unquoted values for fields prone to YAML colon-collisions in quotes."""
lines = frontmatter.splitlines()
out: list[str] = []
changed = False
for line in lines:
m = re.match(r"^(\w[\w-]*)\s*:\s*(.*)$", line)
if m and m.group(1) in _PROBLEM_FIELDS:
key = m.group(1)
value = m.group(2).rstrip()
if value and not (
(value.startswith("'") and value.endswith("'"))
or (value.startswith('"') and value.endswith('"'))
):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
out.append(f'{key}: "{escaped}"')
changed = True
continue
out.append(line)
if not changed:
return None
return "\n".join(out)
def _string_field(value) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _iter_skill_dirs(root: Path) -> Iterable[Path]:
if not root.exists() or not root.is_dir():
return
try:
children = sorted(root.iterdir(), key=lambda p: p.name)
except OSError:
return
count = 0
for child in children:
count += 1
if count > MAX_DIRS_SCANNED:
log.warning(
"exceeded max scan budget (%d) while looking for skills in %s",
MAX_DIRS_SCANNED,
root,
)
break
if not child.is_dir():
continue
if child.name.startswith("."):
continue
yield child
def discover_skills(
project_root: Path | None = None,
include_user_scope: bool = True,
) -> tuple[list[Skill], list[SkillDiagnostic]]:
"""Discover all skills available to the current project.
Precedence (highest first): project > user > agents. Project-scope
skills shadow lower tiers with the same name; user-scope shadows
agents-scope. Both ``~/.plano/skills/`` (Plano-native) and
``~/.agents/skills/`` (the universal Agent Skills install location used
by ``npx skills add``) are treated as auto-trusted user-tier scopes.
Returns ``(skills, diagnostics)`` sorted by name.
"""
project_root = find_project_root(project_root)
project_dir = project_root / PROJECT_SKILLS_DIR
skills_by_name: dict[str, Skill] = {}
diagnostics: list[SkillDiagnostic] = []
if include_user_scope:
# Load lowest precedence first so higher tiers shadow.
for skill_dir in _iter_skill_dirs(AGENTS_SKILLS_DIR):
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
skill, diags = parse_skill_md(skill_md)
diagnostics.extend(diags)
if skill is not None:
skill = _set_scope(skill, "agents")
skills_by_name[skill.name] = skill
for skill_dir in _iter_skill_dirs(USER_SKILLS_DIR):
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
skill, diags = parse_skill_md(skill_md)
diagnostics.extend(diags)
if skill is None:
continue
skill = _set_scope(skill, "user")
existing = skills_by_name.get(skill.name)
if existing is not None and existing.scope == "agents":
diagnostics.append(
SkillDiagnostic(
"warn",
f"user-scope skill '{skill.name}' shadows ~/.agents/skills entry at {existing.location}",
skill.location,
)
)
skills_by_name[skill.name] = skill
for skill_dir in _iter_skill_dirs(project_dir):
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
skill, diags = parse_skill_md(skill_md)
diagnostics.extend(diags)
if skill is None:
continue
skill = _set_scope(skill, "project")
existing = skills_by_name.get(skill.name)
if existing is not None and existing.scope in ("user", "agents"):
diagnostics.append(
SkillDiagnostic(
"warn",
f"project-scope skill '{skill.name}' shadows {existing.scope}-scope skill at {existing.location}",
skill.location,
)
)
skills_by_name[skill.name] = skill
return sorted(skills_by_name.values(), key=lambda s: s.name), diagnostics
def _set_scope(skill: Skill, scope: str) -> Skill:
return Skill(
name=skill.name,
description=skill.description,
location=skill.location,
base_dir=skill.base_dir,
body=skill.body,
scope=scope,
compatibility=skill.compatibility,
license=skill.license,
metadata=skill.metadata,
allowed_tools=skill.allowed_tools,
)
def total_catalog_size(skills: Iterable[Skill]) -> int:
"""Approximate byte size of the catalog the orchestrator will receive."""
return sum(len(s.name) + len(s.description) for s in skills)
def filter_skills_by_allow_list(
skills: Iterable[Skill], allow_list: Iterable[str] | None
) -> list[Skill]:
"""Filter skills to those whose `name` appears in `allow_list`.
If `allow_list` is None, returns all skills. Unknown names are silently
dropped callers warn at config-validation time.
"""
if allow_list is None:
return list(skills)
allowed = set(allow_list)
return [s for s in skills if s.name in allowed]

471
cli/planoai/skills_cmd.py Normal file
View file

@ -0,0 +1,471 @@
"""`planoai skills` command group.
Installs Agent Skills (https://agentskills.io) and surfaces them to Plano.
Three discovery scopes are supported, in descending precedence:
* ``<project>/.plano/skills/`` -- repo-pinned skills. Loaded only when the
project has been marked trusted via ``planoai skills trust`` (skill content
is injected into the orchestrator prompt, so we gate on trust).
* ``~/.plano/skills/`` -- Plano-native user-scope. Always trusted.
* ``~/.agents/skills/`` -- universal Agent Skills location used by
``npx skills add``. Always trusted; lets the upstream skills CLI work
out of the box without any Plano-specific awareness.
``planoai skills add`` tries ``npx skills add`` first (the upstream CLI from
https://github.com/vercel-labs/add-skill), which writes to
``~/.agents/skills/<name>`` and is picked up automatically thanks to the
agents-scope above. Falls back to ``git clone`` into ``.plano/skills/`` when
``npx`` is unavailable.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import rich_click as click
from rich.console import Console
from rich.table import Table
from planoai.consts import PLANO_COLOR
from planoai.skills import (
PROJECT_SKILLS_DIR,
Skill,
discover_skills,
find_project_root,
is_project_trusted,
trusted_projects_file,
)
from planoai.utils import getLogger
log = getLogger(__name__)
_OWNER_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
@dataclass
class _InstallTarget:
owner: str
repo: str
ref: str | None = None # optional branch / tag / commit (e.g. "owner/repo@v1")
@property
def slug(self) -> str:
return f"{self.owner}/{self.repo}"
@property
def url(self) -> str:
return f"https://github.com/{self.owner}/{self.repo}.git"
def _console() -> Console:
return Console()
def _ensure_skills_dir(project_root: Path) -> Path:
skills_dir = project_root / PROJECT_SKILLS_DIR
skills_dir.mkdir(parents=True, exist_ok=True)
return skills_dir
def _parse_install_target(raw: str) -> _InstallTarget:
spec = raw.strip()
ref: str | None = None
if "@" in spec:
spec, _, ref_value = spec.partition("@")
ref = ref_value.strip() or None
if not _OWNER_REPO_PATTERN.match(spec):
raise click.BadParameter(
f"expected '<owner>/<repo>' (optionally suffixed with '@<ref>'), got '{raw}'"
)
owner, repo = spec.split("/", 1)
return _InstallTarget(owner=owner, repo=repo, ref=ref)
def _has_npx() -> bool:
return shutil.which("npx") is not None
def _has_git() -> bool:
return shutil.which("git") is not None
def _mark_project_trusted(project_root: Path) -> None:
path = trusted_projects_file()
path.parent.mkdir(parents=True, exist_ok=True)
existing: dict = {}
if path.exists():
try:
with path.open("r", encoding="utf-8") as fh:
existing = json.load(fh) or {}
except (OSError, json.JSONDecodeError):
existing = {}
trusted = set(existing.get("trusted_projects", []) or [])
trusted.add(str(project_root.resolve()))
existing["trusted_projects"] = sorted(trusted)
with path.open("w", encoding="utf-8") as fh:
json.dump(existing, fh, indent=2)
def _read_manifest(skills_dir: Path) -> dict:
manifest_path = skills_dir / ".skills.json"
if not manifest_path.exists():
return {"skills": {}}
try:
with manifest_path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return {"skills": {}}
if not isinstance(data, dict):
return {"skills": {}}
data.setdefault("skills", {})
return data
def _write_manifest(skills_dir: Path, manifest: dict) -> None:
manifest_path = skills_dir / ".skills.json"
with manifest_path.open("w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2, sort_keys=True)
def _record_install(
skills_dir: Path, name: str, target: _InstallTarget, source: str
) -> None:
manifest = _read_manifest(skills_dir)
manifest["skills"][name] = {
"source": source,
"repo": target.slug,
"ref": target.ref,
"installed_at": datetime.now(timezone.utc).isoformat(),
}
_write_manifest(skills_dir, manifest)
def _remove_from_manifest(skills_dir: Path, name: str) -> None:
manifest = _read_manifest(skills_dir)
manifest["skills"].pop(name, None)
_write_manifest(skills_dir, manifest)
def _install_via_npx(
target: _InstallTarget, project_root: Path, console: Console
) -> bool:
"""Try to install with `npx skills add`. Returns True on success."""
env = os.environ.copy()
env.setdefault("SKILLS_NO_TELEMETRY", "1")
arg = target.slug if target.ref is None else f"{target.slug}@{target.ref}"
cmd = ["npx", "--yes", "skills", "add", arg]
console.print(
f"[dim]Running:[/dim] [cyan]{' '.join(cmd)}[/cyan] [dim](cwd={project_root})[/dim]"
)
try:
result = subprocess.run(
cmd,
cwd=project_root,
env=env,
check=False,
)
except FileNotFoundError:
return False
return result.returncode == 0
def _install_via_git(
target: _InstallTarget,
project_root: Path,
skills_dir: Path,
console: Console,
) -> bool:
if not _has_git():
console.print("[red]X[/red] git is not installed; cannot fall back from npx")
return False
dest = skills_dir / target.repo
if dest.exists():
console.print(
f"[yellow]![/yellow] {dest} already exists. "
"Remove it first with [cyan]planoai skills remove[/cyan] before reinstalling."
)
return False
cmd = ["git", "clone", "--depth", "1"]
if target.ref:
cmd.extend(["--branch", target.ref])
cmd.extend([target.url, str(dest)])
console.print(
f"[dim]Running:[/dim] [cyan]{' '.join(cmd)}[/cyan] [dim](cwd={project_root})[/dim]"
)
try:
result = subprocess.run(cmd, cwd=project_root, check=False)
except FileNotFoundError:
console.print("[red]X[/red] git binary not found")
return False
if result.returncode != 0:
return False
shutil.rmtree(dest / ".git", ignore_errors=True)
if not (dest / "SKILL.md").exists():
console.print(
f"[red]X[/red] {target.slug} does not contain a SKILL.md at its repo root; "
"this does not appear to be a valid Agent Skill."
)
shutil.rmtree(dest, ignore_errors=True)
return False
return True
def _print_skills_table(console: Console, skills: list[Skill]) -> None:
if not skills:
console.print(
f"[dim]No skills installed.[/dim] Try [cyan]planoai skills add owner/repo[/cyan]."
)
return
table = Table(title="Installed Agent Skills", border_style="dim")
table.add_column("Name", style=f"bold {PLANO_COLOR}")
table.add_column("Scope")
table.add_column("Description")
table.add_column("Path", style="dim")
for s in skills:
desc = s.description.splitlines()[0]
if len(desc) > 80:
desc = desc[:77] + "..."
table.add_row(s.name, s.scope, desc, str(s.location))
console.print(table)
@click.group(name="skills")
def skills():
"""Manage Agent Skills (agentskills.io) for this Plano project."""
@skills.command(name="add")
@click.argument("target", required=True)
@click.option(
"--path",
default=".",
help="Project directory (defaults to the directory containing .plano/ or .git/).",
)
def add_cmd(target: str, path: str):
"""Install an Agent Skill from a GitHub repo into .plano/skills/.
TARGET should be `owner/repo` (optionally suffixed with `@ref` for a branch
or tag).
"""
console = _console()
install_target = _parse_install_target(target)
project_root = find_project_root(Path(path).resolve())
skills_dir = _ensure_skills_dir(project_root)
console.print(
f"[bold {PLANO_COLOR}]Installing skill[/bold {PLANO_COLOR}] "
f"[cyan]{install_target.slug}[/cyan] -> [dim]{skills_dir}[/dim]"
)
# Snapshot what's already discoverable so we can diff after install and
# surface every newly-added skill regardless of which scope it landed in
# (project for git fallback, agents for `npx skills add`, etc.) and
# regardless of how the installed skill name maps to the repo name (e.g.
# multi-skill repos like openai/skills -> ~/.agents/skills/pdf).
before, _ = discover_skills(project_root=project_root, include_user_scope=True)
before_keys = {(s.name, str(s.base_dir)) for s in before}
used_source: str
success = False
if _has_npx():
success = _install_via_npx(install_target, project_root, console)
used_source = "npx-skills"
if not success:
console.print(
"[yellow]![/yellow] npx skills add did not succeed; "
"falling back to direct git clone."
)
if not success:
success = _install_via_git(install_target, project_root, skills_dir, console)
used_source = "git"
if not success:
console.print(
f"[red]X[/red] Failed to install [cyan]{install_target.slug}[/cyan]"
)
sys.exit(1)
discovered, diagnostics = discover_skills(
project_root=project_root, include_user_scope=True
)
for diag in diagnostics:
if diag.severity == "error":
console.print(f"[red]X[/red] {diag.path}: {diag.message}")
else:
console.print(f"[yellow]![/yellow] {diag.path}: {diag.message}")
newly_installed = [
s for s in discovered if (s.name, str(s.base_dir)) not in before_keys
]
if newly_installed:
for s in newly_installed:
if s.scope == "project":
_record_install(skills_dir, s.name, install_target, used_source)
try:
display_path = s.location.relative_to(project_root)
except ValueError:
display_path = s.location
console.print(
f"[green]+[/green] Installed [bold]{s.name}[/bold] "
f"[dim]({display_path}, scope={s.scope})[/dim]"
)
if any(
s.scope == "project" for s in newly_installed
) and not is_project_trusted(project_root):
console.print(
"\n[dim]Project-scope skills are not auto-loaded until this project is "
"trusted. Run[/dim] [cyan]planoai skills trust[/cyan] [dim]to enable them.[/dim]"
)
else:
console.print(
"[yellow]![/yellow] Install reported success but no new SKILL.md "
"was discovered under .plano/skills, ~/.plano/skills, or "
"~/.agents/skills. Check the repo structure or pass a "
"single-skill repo."
)
@skills.command(name="list")
@click.option(
"--path",
default=".",
help="Project directory (defaults to the directory containing .plano/ or .git/).",
)
@click.option(
"--no-user-scope",
is_flag=True,
default=False,
help="Skip user-scope skills under ~/.plano/skills and ~/.agents/skills.",
)
def list_cmd(path: str, no_user_scope: bool):
"""List discovered Agent Skills across project / user / agents scopes."""
console = _console()
project_root = find_project_root(Path(path).resolve())
discovered, diagnostics = discover_skills(
project_root=project_root, include_user_scope=not no_user_scope
)
_print_skills_table(console, discovered)
if diagnostics:
console.print()
for diag in diagnostics:
color = "red" if diag.severity == "error" else "yellow"
marker = "X" if diag.severity == "error" else "!"
console.print(f"[{color}]{marker}[/{color}] {diag.path}: {diag.message}")
@skills.command(name="remove")
@click.argument("name", required=True)
@click.option(
"--path",
default=".",
help="Project directory (defaults to the directory containing .plano/ or .git/).",
)
def remove_cmd(name: str, path: str):
"""Remove a project-scope skill from .plano/skills/.
User-scope skills under ~/.plano/skills or ~/.agents/skills must be
removed with their respective installer (`npx skills remove <name>` for
the latter); planoai will not touch directories outside the project.
"""
console = _console()
project_root = find_project_root(Path(path).resolve())
skills_dir = project_root / PROJECT_SKILLS_DIR
if not skills_dir.exists():
console.print(f"[red]X[/red] no skills directory at {skills_dir}")
sys.exit(1)
target_dir = skills_dir / name
if not target_dir.exists():
discovered, _ = discover_skills(
project_root=project_root, include_user_scope=True
)
project_match = next(
(s for s in discovered if s.name == name and s.scope == "project"), None
)
if project_match is None:
other = next((s for s in discovered if s.name == name), None)
if other is not None:
console.print(
f"[red]X[/red] '{name}' is installed in {other.scope} scope at "
f"{other.base_dir}; planoai only removes project-scope skills. "
"Use the upstream installer (e.g. `npx skills remove`) for that one."
)
else:
console.print(
f"[red]X[/red] no project-scope skill named '{name}' under {skills_dir}"
)
sys.exit(1)
target_dir = project_match.base_dir
if not target_dir.resolve().is_relative_to(skills_dir.resolve()):
console.print(
f"[red]X[/red] refusing to delete {target_dir} (outside {skills_dir})"
)
sys.exit(1)
shutil.rmtree(target_dir, ignore_errors=False)
_remove_from_manifest(skills_dir, name)
console.print(f"[green]+[/green] Removed [bold]{name}[/bold]")
@skills.command(name="trust")
@click.option(
"--path",
default=".",
help="Project directory to mark as trusted.",
)
@click.option(
"--revoke",
is_flag=True,
default=False,
help="Revoke trust instead of granting it.",
)
def trust_cmd(path: str, revoke: bool):
"""Mark this project's .plano/skills/ as trusted for auto-loading.
Project-scope skills come from the working directory's repo, which may
be untrusted. Plano refuses to inject their contents into the
orchestrator prompt until you trust the project.
"""
console = _console()
project_root = find_project_root(Path(path).resolve())
if revoke:
path = trusted_projects_file()
if not path.exists():
console.print("[dim]No trusted projects to revoke.[/dim]")
return
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh) or {}
except (OSError, json.JSONDecodeError):
data = {}
trusted = {
str(Path(p).resolve()) for p in data.get("trusted_projects", []) or []
}
trusted.discard(str(project_root.resolve()))
data["trusted_projects"] = sorted(trusted)
with path.open("w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
console.print(f"[green]+[/green] Revoked trust for [bold]{project_root}[/bold]")
return
_mark_project_trusted(project_root)
console.print(
f"[green]+[/green] Trusted [bold]{project_root}[/bold].\n"
f"[dim]Project-scope skills under .plano/skills/ will now be loaded at startup.[/dim]"
)

View file

@ -0,0 +1,71 @@
version: v0.3.0
# This template wires Agent Skills (https://agentskills.io) into Plano so the
# built-in Plano-Orchestrator can decide *per request* which skill(s) to attach
# to the selected route.
#
# 1. Install skills locally:
# planoai skills add owner/pdf-processing
# planoai skills add owner/code-review
# Skills land under .plano/skills/<name>/SKILL.md.
#
# 2. (Required for project-scope skills) Mark the project trusted so its
# skills auto-load at startup:
# planoai skills trust
#
# 3. Reference the installed skills under `routing_preferences[].skills`.
# During the intent step Plano-Orchestrator receives both a <routes> and a
# <skills> XML block; it picks a route and zero or more skills, and the
# SKILL.md bodies of the chosen skills are injected into the upstream
# system prompt for that turn.
model_providers:
- model: anthropic/claude-sonnet-4-5
default: true
access_key: $ANTHROPIC_API_KEY
- model: openai/gpt-4.1-2025-04-14
access_key: $OPENAI_API_KEY
# Catalog of skills available to this project. Entries are skill names
# (resolved against .plano/skills/<name>/) — the CLI inlines each SKILL.md
# body into the rendered config at `planoai up` time.
#
# Omit `skills:` entirely to auto-include every discovered skill.
skills:
- pdf-processing
- code-review
listeners:
- type: model
name: model_listener
port: 12000
# Routing preferences double as Plano-Orchestrator's <routes> catalog. Attach
# `skills:` to a route to make those skills eligible for activation when the
# orchestrator picks that route.
routing_preferences:
- name: code review
description: |
Reviewing pull requests, analyzing diffs, and suggesting improvements
to existing code.
models:
- anthropic/claude-sonnet-4-5
- openai/gpt-4.1-2025-04-14
skills:
- code-review
- name: document understanding
description: |
Summarizing PDFs and other long-form documents, extracting structured
data such as tables, line items, or signatures.
models:
- anthropic/claude-sonnet-4-5
- openai/gpt-4.1-2025-04-14
skills:
- pdf-processing
selection_policy:
prefer: cheapest
tracing:
random_sampling: 100

View file

@ -27,3 +27,8 @@ templates:
template_file: conversational_state_v1_responses.yaml
demo_configs: []
transform: none
- template_id: skills_routing
template_file: skills_routing.yaml
demo_configs: []
transform: none

394
cli/test/test_skills.py Normal file
View file

@ -0,0 +1,394 @@
"""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"]

235
cli/test/test_skills_cmd.py Normal file
View 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