openenv/coding_env
21
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""Commands to manage OpenEnv CLI skills for AI assistants."""8 9from __future__ import annotations10 11import os12import shutil13from pathlib import Path14from typing import Annotated15 16import typer17 18DEFAULT_SKILL_ID = "openenv-cli"19 20_SKILL_YAML_PREFIX = """\21---22name: openenv-cli23description: "OpenEnv CLI (`openenv`) for scaffolding, validating, building, and pushing OpenEnv environments."24---25 26Install: `pip install openenv-core`27 28The OpenEnv CLI command `openenv` is available.29Use `openenv --help` to view available commands.30"""31 32_SKILL_TIPS = """33## Tips34 35- Start with `openenv init <env_name>` to scaffold a new environment36- Validate projects with `openenv validate`37- Build and deploy with `openenv build` and `openenv push`38- Use `openenv <command> --help` for command-specific options39"""40 41CENTRAL_LOCAL = Path(".agents/skills")42CENTRAL_GLOBAL = Path("~/.agents/skills")43 44GLOBAL_TARGETS = {45 "codex": Path("~/.codex/skills"),46 "claude": Path("~/.claude/skills"),47 "cursor": Path("~/.cursor/skills"),48 "opencode": Path("~/.config/opencode/skills"),49}50 51LOCAL_TARGETS = {52 "codex": Path(".codex/skills"),53 "claude": Path(".claude/skills"),54 "cursor": Path(".cursor/skills"),55 "opencode": Path(".opencode/skills"),56}57 58app = typer.Typer(help="Manage OpenEnv skills for AI assistants")59 60 61def _build_skill_md() -> str:62 """Generate SKILL.md content for the OpenEnv CLI skill."""63 from openenv import __version__64 65 lines = _SKILL_YAML_PREFIX.splitlines()66 lines.append("")67 lines.append(68 f"Generated with `openenv-core v{__version__}`. Run `openenv skills add --force` to regenerate."69 )70 lines.extend(_SKILL_TIPS.splitlines())71 return "\n".join(lines).strip() + "\n"72 73 74def _remove_existing(path: Path, force: bool) -> None:75 """Remove existing file/directory/symlink if force is True, else fail."""76 if not (path.exists() or path.is_symlink()):77 return78 if not force:79 raise typer.Exit(code=1)80 81 if path.is_dir() and not path.is_symlink():82 shutil.rmtree(path)83 else:84 path.unlink()85 86 87def _install_to(skills_dir: Path, force: bool) -> Path:88 """Install the OpenEnv skill in a skills directory."""89 skills_dir = skills_dir.expanduser().resolve()90 skills_dir.mkdir(parents=True, exist_ok=True)91 dest = skills_dir / DEFAULT_SKILL_ID92 93 if dest.exists() or dest.is_symlink():94 if not force:95 typer.echo(96 f"Skill already exists at {dest}. Re-run with --force to overwrite."97 )98 raise typer.Exit(code=1)99 _remove_existing(dest, force=True)100 101 dest.mkdir()102 (dest / "SKILL.md").write_text(_build_skill_md(), encoding="utf-8")103 return dest104 105 106def _create_symlink(107 agent_skills_dir: Path, central_skill_path: Path, force: bool108) -> Path:109 """Create a relative symlink from agent directory to central skill location."""110 agent_skills_dir = agent_skills_dir.expanduser().resolve()111 agent_skills_dir.mkdir(parents=True, exist_ok=True)112 link_path = agent_skills_dir / DEFAULT_SKILL_ID113 114 if link_path.exists() or link_path.is_symlink():115 if not force:116 typer.echo(117 f"Skill already exists at {link_path}. Re-run with --force to overwrite."118 )119 raise typer.Exit(code=1)120 _remove_existing(link_path, force=True)121 122 link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir))123 return link_path124 125 126@app.command("preview")127def skills_preview() -> None:128 """Print generated SKILL.md content."""129 typer.echo(_build_skill_md())130 131 132@app.command("add")133def skills_add(134 claude: Annotated[135 bool,136 typer.Option("--claude", help="Install for Claude."),137 ] = False,138 codex: Annotated[139 bool,140 typer.Option("--codex", help="Install for Codex."),141 ] = False,142 cursor: Annotated[143 bool,144 typer.Option("--cursor", help="Install for Cursor."),145 ] = False,146 opencode: Annotated[147 bool,148 typer.Option("--opencode", help="Install for OpenCode."),149 ] = False,150 global_: Annotated[151 bool,152 typer.Option(153 "--global",154 "-g",155 help=(156 "Install globally (user-level) instead of in the current project directory."157 ),158 ),159 ] = False,160 dest: Annotated[161 Path | None,162 typer.Option(help="Install into a custom destination (skills directory path)."),163 ] = None,164 force: Annotated[165 bool,166 typer.Option("--force", help="Overwrite existing skills in the destination."),167 ] = False,168) -> None:169 """Install OpenEnv CLI skill for AI assistants."""170 if dest:171 if claude or codex or cursor or opencode or global_:172 typer.echo(173 "--dest cannot be combined with --claude, --codex, --cursor, --opencode, or --global."174 )175 raise typer.Exit(code=1)176 skill_dest = _install_to(dest, force)177 typer.echo(f"Installed '{DEFAULT_SKILL_ID}' to {skill_dest}")178 return179 180 central_path = CENTRAL_GLOBAL if global_ else CENTRAL_LOCAL181 central_skill_path = _install_to(central_path, force)182 typer.echo(183 f"Installed '{DEFAULT_SKILL_ID}' to central location: {central_skill_path}"184 )185 186 targets = GLOBAL_TARGETS if global_ else LOCAL_TARGETS187 agent_targets: list[Path] = []188 189 if claude:190 agent_targets.append(targets["claude"])191 if codex:192 agent_targets.append(targets["codex"])193 if cursor:194 agent_targets.append(targets["cursor"])195 if opencode:196 agent_targets.append(targets["opencode"])197 198 for agent_target in agent_targets:199 link_path = _create_symlink(agent_target, central_skill_path, force)200 typer.echo(f"Created symlink: {link_path}")201 