"""`planoai skills` command group. Installs Agent Skills (https://agentskills.io) and surfaces them to Plano. Three discovery scopes are supported, in descending precedence: * ``/.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/`` 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 '/' (optionally suffixed with '@'), 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 ` 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]" )