| |
| |
| |
| |
| |
|
|
| """Commands to manage OpenEnv CLI skills for AI assistants.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| import shutil |
| from pathlib import Path |
| from typing import Annotated |
|
|
| import typer |
|
|
| DEFAULT_SKILL_ID = "openenv-cli" |
|
|
| _SKILL_YAML_PREFIX = """\ |
| --- |
| name: openenv-cli |
| description: "OpenEnv CLI (`openenv`) for scaffolding, validating, building, and pushing OpenEnv environments." |
| --- |
| |
| Install: `pip install openenv-core` |
| |
| The OpenEnv CLI command `openenv` is available. |
| Use `openenv --help` to view available commands. |
| """ |
|
|
| _SKILL_TIPS = """ |
| ## Tips |
| |
| - Start with `openenv init <env_name>` to scaffold a new environment |
| - Validate projects with `openenv validate` |
| - Build and deploy with `openenv build` and `openenv push` |
| - Use `openenv <command> --help` for command-specific options |
| """ |
|
|
| CENTRAL_LOCAL = Path(".agents/skills") |
| CENTRAL_GLOBAL = Path("~/.agents/skills") |
|
|
| GLOBAL_TARGETS = { |
| "codex": Path("~/.codex/skills"), |
| "claude": Path("~/.claude/skills"), |
| "cursor": Path("~/.cursor/skills"), |
| "opencode": Path("~/.config/opencode/skills"), |
| } |
|
|
| LOCAL_TARGETS = { |
| "codex": Path(".codex/skills"), |
| "claude": Path(".claude/skills"), |
| "cursor": Path(".cursor/skills"), |
| "opencode": Path(".opencode/skills"), |
| } |
|
|
| app = typer.Typer(help="Manage OpenEnv skills for AI assistants") |
|
|
|
|
| def _build_skill_md() -> str: |
| """Generate SKILL.md content for the OpenEnv CLI skill.""" |
| from openenv import __version__ |
|
|
| lines = _SKILL_YAML_PREFIX.splitlines() |
| lines.append("") |
| lines.append( |
| f"Generated with `openenv-core v{__version__}`. Run `openenv skills add --force` to regenerate." |
| ) |
| lines.extend(_SKILL_TIPS.splitlines()) |
| return "\n".join(lines).strip() + "\n" |
|
|
|
|
| def _remove_existing(path: Path, force: bool) -> None: |
| """Remove existing file/directory/symlink if force is True, else fail.""" |
| if not (path.exists() or path.is_symlink()): |
| return |
| if not force: |
| raise typer.Exit(code=1) |
|
|
| if path.is_dir() and not path.is_symlink(): |
| shutil.rmtree(path) |
| else: |
| path.unlink() |
|
|
|
|
| def _install_to(skills_dir: Path, force: bool) -> Path: |
| """Install the OpenEnv skill in a skills directory.""" |
| skills_dir = skills_dir.expanduser().resolve() |
| skills_dir.mkdir(parents=True, exist_ok=True) |
| dest = skills_dir / DEFAULT_SKILL_ID |
|
|
| if dest.exists() or dest.is_symlink(): |
| if not force: |
| typer.echo( |
| f"Skill already exists at {dest}. Re-run with --force to overwrite." |
| ) |
| raise typer.Exit(code=1) |
| _remove_existing(dest, force=True) |
|
|
| dest.mkdir() |
| (dest / "SKILL.md").write_text(_build_skill_md(), encoding="utf-8") |
| return dest |
|
|
|
|
| def _create_symlink( |
| agent_skills_dir: Path, central_skill_path: Path, force: bool |
| ) -> Path: |
| """Create a relative symlink from agent directory to central skill location.""" |
| agent_skills_dir = agent_skills_dir.expanduser().resolve() |
| agent_skills_dir.mkdir(parents=True, exist_ok=True) |
| link_path = agent_skills_dir / DEFAULT_SKILL_ID |
|
|
| if link_path.exists() or link_path.is_symlink(): |
| if not force: |
| typer.echo( |
| f"Skill already exists at {link_path}. Re-run with --force to overwrite." |
| ) |
| raise typer.Exit(code=1) |
| _remove_existing(link_path, force=True) |
|
|
| link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir)) |
| return link_path |
|
|
|
|
| @app.command("preview") |
| def skills_preview() -> None: |
| """Print generated SKILL.md content.""" |
| typer.echo(_build_skill_md()) |
|
|
|
|
| @app.command("add") |
| def skills_add( |
| claude: Annotated[ |
| bool, |
| typer.Option("--claude", help="Install for Claude."), |
| ] = False, |
| codex: Annotated[ |
| bool, |
| typer.Option("--codex", help="Install for Codex."), |
| ] = False, |
| cursor: Annotated[ |
| bool, |
| typer.Option("--cursor", help="Install for Cursor."), |
| ] = False, |
| opencode: Annotated[ |
| bool, |
| typer.Option("--opencode", help="Install for OpenCode."), |
| ] = False, |
| global_: Annotated[ |
| bool, |
| typer.Option( |
| "--global", |
| "-g", |
| help=( |
| "Install globally (user-level) instead of in the current project directory." |
| ), |
| ), |
| ] = False, |
| dest: Annotated[ |
| Path | None, |
| typer.Option(help="Install into a custom destination (skills directory path)."), |
| ] = None, |
| force: Annotated[ |
| bool, |
| typer.Option("--force", help="Overwrite existing skills in the destination."), |
| ] = False, |
| ) -> None: |
| """Install OpenEnv CLI skill for AI assistants.""" |
| if dest: |
| if claude or codex or cursor or opencode or global_: |
| typer.echo( |
| "--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global." |
| ) |
| raise typer.Exit(code=1) |
| skill_dest = _install_to(dest, force) |
| typer.echo(f"Installed '{DEFAULT_SKILL_ID}' to {skill_dest}") |
| return |
|
|
| central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL |
| central_skill_path = _install_to(central_path, force) |
| typer.echo( |
| f"Installed '{DEFAULT_SKILL_ID}' to central location: {central_skill_path}" |
| ) |
|
|
| targets = GLOBAL_TARGETS if global_ else LOCAL_TARGETS |
| agent_targets: list[Path] = [] |
|
|
| if claude: |
| agent_targets.append(targets["claude"]) |
| if codex: |
| agent_targets.append(targets["codex"]) |
| if cursor: |
| agent_targets.append(targets["cursor"]) |
| if opencode: |
| agent_targets.append(targets["opencode"]) |
|
|
| for agent_target in agent_targets: |
| link_path = _create_symlink(agent_target, central_skill_path, force) |
| typer.echo(f"Created symlink: {link_path}") |
|
|