File size: 6,037 Bytes
0f6657d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | # Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""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}")
|