Skip to content

Commit f3f1dfb

Browse files
committed
Implement OpenEnv CLI with init and convert commands, add templates and utilities for environment setup
1 parent e7e1928 commit f3f1dfb

File tree

19 files changed

+1487
-1
lines changed

19 files changed

+1487
-1
lines changed

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ dependencies = [
1212
"requests>=2.25.0",
1313
"fastapi>=0.104.0",
1414
"uvicorn>=0.24.0",
15-
"smolagents>=1.22.0,<2"
15+
"smolagents>=1.22.0,<2",
16+
"typer>=0.9.0",
17+
"rich>=13.0.0"
1618
]
1719

20+
[project.scripts]
21+
openenv = "openenv_cli.__main__:main"
22+
1823
[tool.setuptools]
1924
package-dir = {"" = "src"}
2025

src/openenv_cli/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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 the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""OpenEnv CLI package."""
8+
9+
__version__ = "0.1.0"
10+

src/openenv_cli/__main__.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""
8+
OpenEnv CLI entry point.
9+
10+
This module provides the main entry point for the OpenEnv command-line interface,
11+
following the Hugging Face CLI pattern.
12+
"""
13+
14+
import sys
15+
16+
import typer
17+
18+
from openenv_cli.commands import convert
19+
from openenv_cli.commands import init
20+
21+
# Create the main CLI app
22+
app = typer.Typer(
23+
name="openenv",
24+
help="OpenEnv - HTTP-based agentic environments CLI",
25+
no_args_is_help=True,
26+
)
27+
28+
# Register commands
29+
app.command(name="init", help="Initialize a new OpenEnv environment")(init.init)
30+
app.add_typer(convert.app, name="convert", help="Convert an existing environment to OpenEnv format")
31+
32+
33+
# Entry point for setuptools
34+
def main() -> None:
35+
"""Main entry point for the CLI."""
36+
try:
37+
app()
38+
except KeyboardInterrupt:
39+
print("\nOperation cancelled by user.")
40+
sys.exit(130)
41+
except Exception as e:
42+
print(f"Error: {e}", file=sys.stderr)
43+
sys.exit(1)
44+
45+
46+
if __name__ == "__main__":
47+
main()

src/openenv_cli/_cli_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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 the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""CLI utilities for OpenEnv command-line interface."""
8+
9+
from rich.console import Console
10+
11+
# Create a console instance for CLI output
12+
console = Console()
13+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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 the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""OpenEnv CLI commands."""
8+
9+
from openenv_cli.commands import convert, init
10+
11+
__all__ = ["convert", "init"]
12+
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Convert an existing environment to OpenEnv-compatible structure."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import subprocess
8+
from pathlib import Path
9+
from typing import Annotated, List, Tuple
10+
11+
import typer
12+
from importlib import resources
13+
14+
from .._cli_utils import console
15+
16+
17+
app = typer.Typer(help="Convert an existing environment to OpenEnv format")
18+
19+
20+
def _is_git_repo(directory: Path) -> bool:
21+
try:
22+
subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=str(directory), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
23+
return True
24+
except Exception:
25+
return False
26+
27+
28+
def _ensure_git_repo(directory: Path) -> None:
29+
if not _is_git_repo(directory):
30+
subprocess.run(["git", "init"], cwd=str(directory), check=True)
31+
32+
33+
def _snake_to_pascal(snake_str: str) -> str:
34+
"""Convert snake_case to PascalCase (e.g., 'coding_env' -> 'CodingEnv')."""
35+
return "".join(word.capitalize() for word in snake_str.split("_"))
36+
37+
38+
def _snake_to_title(snake_str: str) -> str:
39+
"""Convert snake_case to Title Case (e.g., 'coding_env' -> 'Coding Env')."""
40+
return " ".join(word.capitalize() for word in snake_str.split("_"))
41+
42+
43+
def _copy_template_file(template_pkg: str, template_rel_path: str, dest_path: Path, env_name: str) -> None:
44+
"""Copy template file and replace placeholders with appropriate naming conventions."""
45+
base = resources.files(template_pkg)
46+
src = base.joinpath(template_rel_path)
47+
dest_path.parent.mkdir(parents=True, exist_ok=True)
48+
data = src.read_bytes()
49+
try:
50+
text = data.decode("utf-8")
51+
# Replace placeholders with appropriate naming conventions
52+
env_class_name = _snake_to_pascal(env_name)
53+
env_title_name = _snake_to_title(env_name)
54+
text = text.replace("__ENV_CLASS_NAME__", env_class_name)
55+
text = text.replace("__ENV_TITLE_NAME__", env_title_name)
56+
text = text.replace("__ENV_NAME__", env_name) # Keep snake_case for module names, etc.
57+
dest_path.write_text(text)
58+
except UnicodeDecodeError:
59+
dest_path.write_bytes(data)
60+
61+
62+
def _ensure_files(env_root: Path, env_name: str) -> Tuple[List[Path], List[Path]]:
63+
"""
64+
Ensure required files exist. Returns (created_files, created_dirs).
65+
"""
66+
created_files: List[Path] = []
67+
created_dirs: List[Path] = []
68+
69+
template_pkg = "openenv_cli.templates.openenv_env"
70+
71+
# Manifest
72+
manifest = env_root / "openenv.yaml"
73+
if not manifest.exists():
74+
_copy_template_file(template_pkg, "openenv.yaml", manifest, env_name)
75+
created_files.append(manifest)
76+
77+
# README
78+
readme = env_root / "README.md"
79+
if not readme.exists():
80+
_copy_template_file(template_pkg, "README.md", readme, env_name)
81+
created_files.append(readme)
82+
83+
# client.py and models.py
84+
client_py = env_root / "client.py"
85+
if not client_py.exists():
86+
_copy_template_file(template_pkg, "client.py", client_py, env_name)
87+
created_files.append(client_py)
88+
89+
models_py = env_root / "models.py"
90+
if not models_py.exists():
91+
_copy_template_file(template_pkg, "models.py", models_py, env_name)
92+
created_files.append(models_py)
93+
94+
# server tree
95+
server_dir = env_root / "server"
96+
if not server_dir.exists():
97+
server_dir.mkdir(parents=True, exist_ok=True)
98+
created_dirs.append(server_dir)
99+
100+
server_init = server_dir / "__init__.py"
101+
if not server_init.exists():
102+
_copy_template_file(template_pkg, "server/__init__.py", server_init, env_name)
103+
created_files.append(server_init)
104+
105+
server_app = server_dir / "app.py"
106+
if not server_app.exists():
107+
_copy_template_file(template_pkg, "server/app.py", server_app, env_name)
108+
created_files.append(server_app)
109+
110+
dockerfile = server_dir / "Dockerfile"
111+
if not dockerfile.exists():
112+
_copy_template_file(template_pkg, "server/Dockerfile", dockerfile, env_name)
113+
created_files.append(dockerfile)
114+
115+
reqs = server_dir / "requirements.txt"
116+
if not reqs.exists():
117+
_copy_template_file(template_pkg, "server/requirements.txt", reqs, env_name)
118+
created_files.append(reqs)
119+
120+
return created_files, created_dirs
121+
122+
123+
def _stage_paths(env_root: Path, paths: List[Path]) -> None:
124+
if not paths:
125+
return
126+
rels = [str(p.relative_to(env_root)) for p in paths]
127+
subprocess.run(["git", "add", "-A", *rels], cwd=str(env_root), check=True)
128+
129+
130+
def _unstage_all(env_root: Path) -> None:
131+
subprocess.run(["git", "reset"], cwd=str(env_root), check=True)
132+
133+
134+
def _show_staged_diff(env_root: Path) -> None:
135+
subprocess.run(["git", "diff", "--staged"], cwd=str(env_root), check=True)
136+
137+
138+
def _rollback_created(created_files: List[Path], created_dirs: List[Path]) -> None:
139+
# Remove files first, then empty dirs we created
140+
for f in created_files:
141+
try:
142+
if f.exists():
143+
f.unlink()
144+
except Exception:
145+
pass
146+
for d in sorted(created_dirs, key=lambda p: len(str(p)), reverse=True):
147+
try:
148+
if d.exists() and not any(d.iterdir()):
149+
d.rmdir()
150+
except Exception:
151+
pass
152+
153+
154+
@app.command()
155+
def convert(
156+
env_path: Annotated[
157+
str | None,
158+
typer.Option(
159+
"--env-path",
160+
help="Path to the environment root (defaults to current working directory)",
161+
),
162+
] = None,
163+
yes: Annotated[
164+
bool,
165+
typer.Option("--yes", "-y", help="Accept changes without confirmation"),
166+
] = False,
167+
) -> None:
168+
"""
169+
Convert the current directory (or specified path) to OpenEnv-compatible format.
170+
171+
Unlike 'init', this command works directly on the current working directory without
172+
creating a subdirectory. It ensures all required OpenEnv files exist, stages changes,
173+
shows a git diff, and optionally commits or rolls back the changes.
174+
"""
175+
# Work directly on the current directory or specified path (no subdirectory creation)
176+
env_root = Path(env_path).resolve() if env_path is not None else Path.cwd().resolve()
177+
if not env_root.exists() or not env_root.is_dir():
178+
raise typer.BadParameter(f"Environment path is invalid: {env_root}")
179+
180+
env_name = env_root.name
181+
182+
try:
183+
_ensure_git_repo(env_root)
184+
185+
# Create missing files
186+
created_files, created_dirs = _ensure_files(env_root, env_name)
187+
188+
if not created_files and not created_dirs:
189+
console.print("[bold green]Environment already OpenEnv-compatible. No changes needed.[/bold green]")
190+
return
191+
192+
# Stage new files for preview
193+
_stage_paths(env_root, created_files)
194+
195+
console.print("[bold cyan]Proposed changes:[/bold cyan]")
196+
_show_staged_diff(env_root)
197+
198+
proceed = yes or typer.confirm("Apply these changes and create a commit?", default=True)
199+
if proceed:
200+
subprocess.run(["git", "commit", "-m", "openenv: convert environment to OpenEnv format"], cwd=str(env_root), check=True)
201+
console.print("[bold green]Conversion committed.[/bold green]")
202+
else:
203+
_unstage_all(env_root)
204+
_rollback_created(created_files, created_dirs)
205+
console.print("[bold yellow]Changes discarded.[/bold yellow]")
206+
207+
except Exception as e:
208+
# Best-effort rollback of created files and unstage
209+
try:
210+
_unstage_all(env_root)
211+
except Exception:
212+
pass
213+
# Attempt to clean any obvious template additions if we can detect them
214+
# (No-op if none were created before the exception.)
215+
console.print(f"[bold red]Error:[/bold red] {e}")
216+
raise
217+
218+

0 commit comments

Comments
 (0)