Merge pull request #140 from HKUDS/feat/add-godot-cli

feat: add Godot Engine CLI harness
This commit is contained in:
Yuhao
2026-04-06 21:11:37 +08:00
committed by GitHub
22 changed files with 2848 additions and 107 deletions
+4
View File
@@ -67,6 +67,7 @@
!/zoom/
!/sketch/
!/drawio/
!/godot/
!/mermaid/
!/comfyui/
!/adguardhome/
@@ -111,6 +112,8 @@
/sketch/.*
/drawio/*
/drawio/.*
/godot/*
/godot/.*
/mermaid/*
/mermaid/.*
/comfyui/*
@@ -155,6 +158,7 @@
!/zoom/agent-harness/
!/sketch/agent-harness/
!/drawio/agent-harness/
!/godot/agent-harness/
!/mermaid/agent-harness/
!/comfyui/agent-harness/
!/adguardhome/agent-harness/
+11 -12
View File
@@ -471,7 +471,8 @@ The catalog auto-updates whenever `registry.json` changes — new community CLIs
| **🤖 AI/ML Platforms** | Automate model training, inference pipelines, and hyperparameter tuning through structured commands | Stable Diffusion WebUI, ComfyUI, Ollama, InvokeAI, Text-generation-webui, Open WebUI, Fooocus, Kohya_ss, AnythingLLM, SillyTavern |
| **📊 Data & Analytics** | Enable programmatic data processing, visualization, and statistical analysis workflows | JupyterLab, Apache Superset, Metabase, Redash, DBeaver, KNIME, Orange, OpenSearch Dashboards, Lightdash |
| **💻 Development Tools** | Streamline code editing, building, testing, and deployment processes via command interfaces | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio, **[iTerm2](https://iterm2.com)** |
| **🎨 Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour, VideoCaptioner |
| **🎨 Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour |
| **🎮 Game Development** | Manage game projects, scenes, exports, and scripting through headless engine interfaces | **[Godot Engine](https://godotengine.org)** |
| **🔬 Scientific Computing** | Automate research workflows, simulations, and complex calculations | ImageJ, FreeCAD, QGIS, ParaView, Gephi, LibreCAD, Stellarium, KiCad, JASP, Jamovi |
| **🏢 Enterprise & Office** | Convert business applications and productivity tools into agent-accessible systems | NextCloud, GitLab, Grafana, Mattermost, LibreOffice, AppFlowy, NocoDB, Odoo (Community), Plane, ERPNext |
| **📞 Communication & Collaboration** | Automate meeting scheduling, participant management, recording retrieval, and reporting through structured CLI | Zoom, Jitsi Meet, BigBlueButton, Mattermost |
@@ -827,19 +828,19 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 19</td>
</tr>
<tr>
<td align="center"><strong>🅲🅲 CloudCompare</strong></td>
<td>3D Point Cloud & Mesh</td>
<td><code>cli-anything-cloudcompare</code></td>
<td>CloudCompare CLI (headless)</td>
<td align="center">✅ 88</td>
<td align="center"><strong>🎮 Godot Engine</strong></td>
<td>Game Development</td>
<td><code>cli-anything-godot</code></td>
<td>Godot 4.x headless subprocess</td>
<td align="center">✅ 24</td>
</tr>
<tr>
<td align="center" colspan="4"><strong>Total</strong></td>
<td align="center"><strong>✅ 2,005</strong></td>
<td align="center"><strong>✅ 1,882</strong></td>
</tr>
</table>
> **100% pass rate** across all 2,005 tests — 1,453 unit tests + 533 end-to-end tests + 19 Node.js tests.
> **100% pass rate** across all 1,882 tests — 1,379 unit tests + 484 end-to-end tests + 19 Node.js tests.
---
@@ -946,10 +947,8 @@ cli-anything/
├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests)
├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests)
├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests)
├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests)
└── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
└── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests)
├── 🎮 godot/agent-harness/ # Godot Engine CLI (24 tests)
── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
```
Each `agent-harness/` contains an installable Python package under `cli_anything.<software>/` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests.
+46
View File
@@ -0,0 +1,46 @@
# Godot CLI Harness — Architecture
## Strategy
Godot Engine is a feature-rich open-source game engine with strong CLI support via `--headless`, `--script`, and `--export-*` flags. This harness wraps those capabilities into an agent-friendly interface with structured JSON output.
## Backend
- **Binary**: Godot 4.x executable, discovered via PATH or `GODOT_BIN` env var
- **Interaction**: `subprocess.run()` — no REST API, no socket; pure CLI subprocess
- **Headless**: All operations use `--headless` flag (no GPU/display required)
## Command Map
| CLI Command | Godot Mechanism |
|-------------|-----------------|
| `project create` | Write `project.godot` INI file directly |
| `project info` | Parse `project.godot` |
| `project scenes/scripts/resources` | Filesystem glob (`*.tscn`, `*.gd`, `*.tres`) |
| `project reimport` | `godot --headless --import --quit` |
| `scene create` | Write `.tscn` file (Godot scene format) |
| `scene read` | Parse `.tscn` text format |
| `scene add-node` | Append `[node]` section to `.tscn` |
| `export build` | `godot --headless --export-release <preset>` |
| `export presets` | Parse `export_presets.cfg` |
| `script run` | `godot --headless --script res://path.gd --quit` |
| `script inline` | Write temp `.gd`, run with `--script`, delete |
| `script validate` | `godot --headless --check-only --script` |
| `engine version` | `godot --version --quit` |
## Key Design Decisions
1. **No Godot REST API** — unlike OBS or Ollama, Godot has no built-in HTTP server. All interaction is via subprocess + file I/O.
2. **Scene file I/O**`.tscn` is a human-readable text format. We parse and generate it directly for scene operations instead of requiring Godot to be running.
3. **Project file parsing**`project.godot` is INI-like. We read it with string parsing rather than requiring the engine.
4. **Temp scripts**`script inline` writes a temporary `.gd` file inside the project (required for `res://` resolution), runs it, then cleans up.
## Security
- **`script inline`**: The provided GDScript code is written to a temporary file inside the project directory and executed via `godot --headless --script`. The temp file is deleted after execution. This command executes arbitrary code on the host machine — only use with trusted input. In an agent context, the agent is responsible for ensuring the code it generates is safe.
## Version Compatibility
- **`--export-all`** is available in Godot **4.3+**. Earlier 4.x versions require exporting each preset individually with `--export-release <preset>`.
- **`--check-only`** for script validation may not be available in all Godot 4.x versions.
- Binary discovery searches for `godot`, `godot4`, and common versioned binary names. Set `GODOT_BIN` for non-standard installations.
@@ -0,0 +1,68 @@
# cli-anything-godot
Agent-native CLI harness for the **Godot Engine** (4.x). Provides structured, JSON-capable commands for project management, scene editing, exporting, and GDScript execution — all accessible to AI agents.
## Installation
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=godot/agent-harness
```
## Prerequisites
- **Godot 4.x** on PATH (or set `GODOT_BIN` environment variable)
- Python 3.10+
## Quick Start
```bash
# Check engine status
cli-anything-godot engine status
# Create a new project
cli-anything-godot project create ./my-game --name "My Game"
# List scenes in a project
cli-anything-godot --project ./my-game project scenes
# Create a scene
cli-anything-godot -p ./my-game scene create scenes/Main.tscn --root-type Node2D
# Add a node to a scene
cli-anything-godot -p ./my-game scene add-node scenes/Main.tscn --name Player --type CharacterBody2D
# Run a GDScript
cli-anything-godot -p ./my-game script run tools/generate_map.gd
# Run inline GDScript
cli-anything-godot -p ./my-game script inline 'print("Hello from Godot!")'
# Export the project
cli-anything-godot -p ./my-game export build --preset "Windows Desktop"
# JSON mode for agents
cli-anything-godot --json -p ./my-game project info
# Interactive REPL
cli-anything-godot -p ./my-game session
```
## Command Groups
| Group | Commands | Description |
|-------|----------|-------------|
| `project` | create, info, scenes, scripts, resources, reimport | Project management |
| `scene` | create, read, add-node | Scene file operations |
| `export` | build, presets | Platform export |
| `script` | run, inline, validate | GDScript execution |
| `engine` | version, status | Engine info |
| `session` | (REPL) | Interactive mode |
## Security Note
The `script inline` command writes user-provided GDScript to a temp file and executes it via Godot subprocess. This runs arbitrary code on the host — only use with trusted input.
## Version Compatibility
- `export build` without `--preset` uses `--export-all` (Godot 4.3+). For older 4.x, specify `--preset` explicitly.
- Set `GODOT_BIN` environment variable if your Godot binary has a non-standard name.
@@ -0,0 +1 @@
"""Godot CLI - Game engine project management, scene editing, export and scripting."""
@@ -0,0 +1,3 @@
"""Allow running as python -m cli_anything.godot"""
from cli_anything.godot.godot_cli import main
main()
@@ -0,0 +1,101 @@
"""Godot project export — build game binaries for target platforms.
Note: --export-all is available in Godot 4.3+. Earlier 4.x versions
use --export-release/--export-debug per preset. If export-all fails,
fall back to exporting each preset individually.
"""
from pathlib import Path
from cli_anything.godot.utils.godot_backend import run_godot, validate_project
def export_project(
project_path: str,
preset: str | None = None,
output_path: str | None = None,
debug: bool = False,
) -> dict:
"""Export a Godot project using a configured export preset.
Args:
project_path: Godot project directory.
preset: Export preset name (from export_presets.cfg).
If None, exports all presets.
output_path: Output file path for the exported binary.
debug: If True, use --export-debug instead of --export-release.
Returns:
Dict with status and output details.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"Not a Godot project: {project_path}"}
presets_file = Path(project_path) / "export_presets.cfg"
if not presets_file.exists():
return {
"status": "error",
"message": "No export_presets.cfg found. Configure export presets in the Godot editor first.",
}
if preset is None:
args = ["--export-all", "--quit"]
else:
flag = "--export-debug" if debug else "--export-release"
args = [flag, preset]
if output_path:
args.append(output_path)
args.append("--quit")
result = run_godot(
args,
project_path=project_path,
headless=True,
timeout=300,
)
return {
"status": "ok" if result["returncode"] == 0 else "error",
"preset": preset or "all",
"debug": debug,
"returncode": result["returncode"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
def list_export_presets(project_path: str) -> dict:
"""Parse export_presets.cfg and list available presets.
Returns:
Dict with list of preset names and platforms.
"""
presets_file = Path(project_path) / "export_presets.cfg"
if not presets_file.exists():
return {"status": "ok", "count": 0, "presets": []}
text = presets_file.read_text(encoding="utf-8")
presets = []
current = {}
for line in text.splitlines():
line = line.strip()
if line.startswith("[preset.") and line.endswith("]") and ".options]" not in line:
if current:
presets.append(current)
current = {}
elif "=" in line and current is not None:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"')
if key == "name":
current["name"] = value
elif key == "platform":
current["platform"] = value
elif key == "export_path":
current["export_path"] = value
if current:
presets.append(current)
return {"status": "ok", "count": len(presets), "presets": presets}
@@ -0,0 +1,183 @@
"""Godot project management — create, info, list scenes, validate."""
from pathlib import Path
from cli_anything.godot.utils.godot_backend import (
run_godot, validate_project,
)
def create_project(project_path: str, project_name: str | None = None) -> dict:
"""Create a new Godot project at the given path.
Args:
project_path: Directory to create the project in.
project_name: Display name; defaults to directory name.
Returns:
Dict with status and project path.
"""
path = Path(project_path)
path.mkdir(parents=True, exist_ok=True)
if project_name is None:
project_name = path.name
project_file = path / "project.godot"
if project_file.exists():
return {"status": "error", "message": f"Project already exists at {project_path}"}
content = (
'; Engine configuration file.\n'
'; Do not edit unless you know what you are doing.\n\n'
f'[application]\n\n'
f'config/name="{project_name}"\n'
f'config/features=PackedStringArray("4.4", "GL Compatibility")\n\n'
'[rendering]\n\n'
'renderer/rendering_method="gl_compatibility"\n'
'renderer/rendering_method.mobile="gl_compatibility"\n'
)
project_file.write_text(content, encoding="utf-8")
return {
"status": "ok",
"project_path": str(path.resolve()),
"project_name": project_name,
}
def get_project_info(project_path: str) -> dict:
"""Read project.godot and return parsed project metadata.
Args:
project_path: Path to Godot project directory.
Returns:
Dict with project name, features, settings.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"No project.godot found at {project_path}"}
project_file = Path(project_path) / "project.godot"
text = project_file.read_text(encoding="utf-8")
# Parse key=value from project.godot (INI-like format)
info = {
"status": "ok",
"project_path": str(Path(project_path).resolve()),
"name": "",
"features": [],
"main_scene": "",
"sections": {},
}
current_section = ""
for line in text.splitlines():
line = line.strip()
if not line or line.startswith(";"):
continue
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
if current_section not in info["sections"]:
info["sections"][current_section] = {}
continue
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"')
info["sections"].setdefault(current_section, {})[key] = value
if key == "config/name":
info["name"] = value
elif key == "config/features":
info["features"] = _parse_packed_string_array(value)
elif key == "run/main_scene":
info["main_scene"] = value
return info
def list_scenes(project_path: str) -> dict:
"""List all .tscn and .scn scene files in the project.
Returns:
Dict with list of scene file paths (relative to project root).
"""
if not validate_project(project_path):
return {"status": "error", "message": f"No project.godot found at {project_path}"}
root = Path(project_path)
scenes = []
for ext in ("*.tscn", "*.scn"):
for f in root.rglob(ext):
scenes.append(str(f.relative_to(root).as_posix()))
scenes.sort()
return {"status": "ok", "count": len(scenes), "scenes": scenes}
def list_scripts(project_path: str) -> dict:
"""List all .gd GDScript files in the project.
Returns:
Dict with list of script file paths.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"No project.godot found at {project_path}"}
root = Path(project_path)
scripts = [
str(f.relative_to(root).as_posix())
for f in root.rglob("*.gd")
]
scripts.sort()
return {"status": "ok", "count": len(scripts), "scripts": scripts}
def list_resources(project_path: str) -> dict:
"""List all .tres and .res resource files in the project.
Returns:
Dict with list of resource file paths.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"No project.godot found at {project_path}"}
root = Path(project_path)
resources = []
for ext in ("*.tres", "*.res"):
for f in root.rglob(ext):
resources.append(str(f.relative_to(root).as_posix()))
resources.sort()
return {"status": "ok", "count": len(resources), "resources": resources}
def reimport_project(project_path: str) -> dict:
"""Force re-import of all project resources.
Returns:
Dict with status and Godot output.
"""
result = run_godot(
["--import", "--quit"],
project_path=project_path,
headless=True,
timeout=120,
)
return {
"status": "ok" if result["returncode"] == 0 else "error",
"returncode": result["returncode"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
def _parse_packed_string_array(value: str) -> list[str]:
"""Parse PackedStringArray('a', 'b') into a Python list."""
value = value.strip()
if value.startswith("PackedStringArray(") and value.endswith(")"):
inner = value[len("PackedStringArray("):-1]
return [s.strip().strip('"').strip("'") for s in inner.split(",") if s.strip()]
return []
@@ -0,0 +1,175 @@
"""Godot scene management — create, read, and modify .tscn scene files."""
from pathlib import Path
from cli_anything.godot.utils.godot_backend import validate_project
def create_scene(
project_path: str,
scene_path: str,
root_type: str = "Node2D",
root_name: str | None = None,
) -> dict:
"""Create a new .tscn scene file with a root node.
Args:
project_path: Godot project directory.
scene_path: Relative path for the scene (e.g. 'scenes/Main.tscn').
root_type: Node type for root (Node2D, Node3D, Control, etc.).
root_name: Name for the root node; defaults to filename stem.
Returns:
Dict with status and created file path.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"Not a Godot project: {project_path}"}
full_path = Path(project_path) / scene_path
if full_path.exists():
return {"status": "error", "message": f"Scene already exists: {scene_path}"}
if root_name is None:
root_name = full_path.stem
full_path.parent.mkdir(parents=True, exist_ok=True)
content = (
f'[gd_scene format=3 uid="uid://{_generate_uid()}"]\n\n'
f'[node name="{root_name}" type="{root_type}"]\n'
)
full_path.write_text(content, encoding="utf-8")
return {
"status": "ok",
"scene_path": scene_path,
"root_type": root_type,
"root_name": root_name,
"absolute_path": str(full_path.resolve()),
}
def read_scene(project_path: str, scene_path: str) -> dict:
"""Parse a .tscn file and return its node tree structure.
Args:
project_path: Godot project directory.
scene_path: Relative path to the .tscn file.
Returns:
Dict with scene structure (nodes, resources, connections).
"""
full_path = Path(project_path) / scene_path
if not full_path.exists():
return {"status": "error", "message": f"Scene not found: {scene_path}"}
text = full_path.read_text(encoding="utf-8")
nodes = []
ext_resources = []
sub_resources = []
connections = []
current_section = None
current_attrs = {}
for line in text.splitlines():
line = line.strip()
if not line:
if current_section and current_attrs:
_store_section(current_section, current_attrs,
nodes, ext_resources, sub_resources, connections)
current_section = None
current_attrs = {}
continue
if line.startswith("[") and line.endswith("]"):
if current_section and current_attrs:
_store_section(current_section, current_attrs,
nodes, ext_resources, sub_resources, connections)
tag_content = line[1:-1]
parts = tag_content.split(None, 1)
current_section = parts[0]
current_attrs = _parse_tag_attrs(parts[1] if len(parts) > 1 else "")
continue
if "=" in line and current_section:
key, _, value = line.partition("=")
current_attrs[key.strip()] = value.strip()
if current_section and current_attrs:
_store_section(current_section, current_attrs,
nodes, ext_resources, sub_resources, connections)
return {
"status": "ok",
"scene_path": scene_path,
"nodes": nodes,
"ext_resources": ext_resources,
"sub_resources": sub_resources,
"connections": connections,
}
def add_node(
project_path: str,
scene_path: str,
node_name: str,
node_type: str,
parent: str = ".",
) -> dict:
"""Append a child node to an existing .tscn scene.
Args:
project_path: Godot project directory.
scene_path: Relative path to the .tscn file.
node_name: Name of the new node.
node_type: Type of the node (Sprite2D, CollisionShape2D, etc.).
parent: Parent node path (default '.' = root).
Returns:
Dict with status.
"""
full_path = Path(project_path) / scene_path
if not full_path.exists():
return {"status": "error", "message": f"Scene not found: {scene_path}"}
node_line = f'\n[node name="{node_name}" type="{node_type}" parent="{parent}"]\n'
with open(full_path, "a", encoding="utf-8") as f:
f.write(node_line)
return {
"status": "ok",
"node_name": node_name,
"node_type": node_type,
"parent": parent,
}
# ---------- internal helpers ----------
def _generate_uid() -> str:
"""Generate a UID for scene files using uuid4 for uniqueness."""
import uuid
return uuid.uuid4().hex[:12]
def _parse_tag_attrs(attr_string: str) -> dict:
"""Parse tag attributes like 'name="Foo" type="Node2D"'."""
attrs = {}
import re
for match in re.finditer(r'(\w+)="([^"]*)"', attr_string):
attrs[match.group(1)] = match.group(2)
for match in re.finditer(r'(\w+)=(\d+)', attr_string):
attrs[match.group(1)] = match.group(2)
return attrs
def _store_section(section, attrs, nodes, ext_resources, sub_resources, connections):
"""Store parsed section into the appropriate list."""
if section == "node":
nodes.append(attrs)
elif section == "ext_resource":
ext_resources.append(attrs)
elif section == "sub_resource":
sub_resources.append(attrs)
elif section == "connection":
connections.append(attrs)
@@ -0,0 +1,133 @@
"""Godot script execution — run GDScript files in headless mode."""
import tempfile
from pathlib import Path
from cli_anything.godot.utils.godot_backend import run_godot, validate_project
def run_script(
project_path: str,
script_path: str,
timeout: int = 60,
) -> dict:
"""Execute a GDScript file in headless mode.
The script must extend SceneTree or MainLoop.
Args:
project_path: Godot project directory.
script_path: Path to the .gd file (relative to project or absolute).
timeout: Execution timeout in seconds.
Returns:
Dict with status and script output.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"Not a Godot project: {project_path}"}
full_script = Path(project_path) / script_path
if not full_script.exists():
return {"status": "error", "message": f"Script not found: {script_path}"}
# Godot expects res:// paths
res_path = f"res://{script_path}"
result = run_godot(
["--script", res_path, "--quit"],
project_path=project_path,
headless=True,
timeout=timeout,
)
return {
"status": "ok" if result["returncode"] == 0 else "error",
"script": script_path,
"returncode": result["returncode"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
def run_inline(
project_path: str,
code: str,
timeout: int = 60,
) -> dict:
"""Run inline GDScript code by writing a temporary .gd file.
The code is wrapped in an extends SceneTree boilerplate with _init().
Args:
project_path: Godot project directory.
code: GDScript code to execute (function body).
timeout: Execution timeout in seconds.
Returns:
Dict with status and output.
"""
if not validate_project(project_path):
return {"status": "error", "message": f"Not a Godot project: {project_path}"}
# Wrap user code in SceneTree boilerplate
wrapped = (
"extends SceneTree\n\n"
"func _init():\n"
)
for line in code.splitlines():
wrapped += f"\t{line}\n"
wrapped += "\tquit()\n"
# Write to a temp file inside the project (so res:// can find it)
script_name = "_cli_anything_tmp.gd"
script_path = Path(project_path) / script_name
script_path.write_text(wrapped, encoding="utf-8")
try:
result = run_godot(
["--script", f"res://{script_name}", "--quit"],
project_path=project_path,
headless=True,
timeout=timeout,
)
return {
"status": "ok" if result["returncode"] == 0 else "error",
"code": code,
"returncode": result["returncode"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
finally:
# Clean up temp script
script_path.unlink(missing_ok=True)
def validate_script(project_path: str, script_path: str) -> dict:
"""Check if a GDScript file has valid syntax using Godot's parser.
Args:
project_path: Godot project directory.
script_path: Relative path to the .gd file.
Returns:
Dict with validation results.
"""
full_script = Path(project_path) / script_path
if not full_script.exists():
return {"status": "error", "message": f"Script not found: {script_path}"}
# Use --check-only to validate without running
result = run_godot(
["--check-only", "--script", f"res://{script_path}", "--quit"],
project_path=project_path,
headless=True,
timeout=30,
)
valid = result["returncode"] == 0
return {
"status": "ok",
"script": script_path,
"valid": valid,
"errors": result["stderr"] if not valid else "",
}
@@ -0,0 +1,381 @@
"""cli-anything-godot — Agent-native CLI for the Godot game engine.
Commands:
project create/info/scenes/scripts/resources/reimport
scene create/read/add-node
export build/presets
script run/inline/validate
engine version/status
session start (REPL mode)
"""
import json as json_mod
import os
import shlex
import sys
import click
from cli_anything.godot.utils.godot_backend import (
get_version,
is_available,
find_godot_binary,
)
# ── Output helpers ─────────────────────────────────────────────────────
def _out(ctx, data: dict) -> None:
"""Print result as JSON or human-readable based on context."""
if ctx.obj.get("json"):
click.echo(json_mod.dumps(data, indent=2, ensure_ascii=False))
else:
status = data.get("status", "")
if status == "error":
click.secho(f"Error: {data.get('message', data.get('stderr', 'unknown'))}", fg="red")
return
for key, value in data.items():
if key == "status":
continue
if isinstance(value, list):
click.secho(f"{key} ({len(value)}):", fg="cyan", bold=True)
for item in value:
if isinstance(item, dict):
parts = [f"{k}={v}" for k, v in item.items()]
click.echo(f" - {', '.join(parts)}")
else:
click.echo(f" - {item}")
elif isinstance(value, dict):
click.secho(f"{key}:", fg="cyan", bold=True)
for k, v in value.items():
click.echo(f" {k}: {v}")
else:
click.echo(f"{key}: {value}")
def _handle_error(func):
"""Decorator to catch RuntimeError and format output."""
import functools
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except RuntimeError as e:
ctx = click.get_current_context()
_out(ctx, {"status": "error", "message": str(e)})
if not ctx.obj.get("repl"):
sys.exit(1)
return wrapper
# ── Root CLI group ─────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output JSON for agent consumption.")
@click.option("--project", "-p", "project", default=None, help="Path to Godot project directory.")
@click.pass_context
def cli(ctx, use_json, project):
"""cli-anything-godot — Agent-native CLI for the Godot game engine."""
ctx.ensure_object(dict)
ctx.obj["json"] = use_json
ctx.obj["project"] = os.path.abspath(project) if project else None
ctx.obj["repl"] = ctx.obj.get("repl", False)
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
def _get_project(ctx) -> str:
"""Resolve project path from context or cwd."""
p = ctx.obj.get("project") or os.getcwd()
return os.path.abspath(p)
# ── Project commands ───────────────────────────────────────────────────
@cli.group()
@click.pass_context
def project(ctx):
"""Manage Godot projects — create, inspect, list assets."""
pass
@project.command("create")
@click.argument("path")
@click.option("--name", default=None, help="Project display name.")
@click.pass_context
@_handle_error
def project_create(ctx, path, name):
"""Create a new Godot project at PATH."""
from cli_anything.godot.core.project import create_project
_out(ctx, create_project(os.path.abspath(path), name))
@project.command("info")
@click.pass_context
@_handle_error
def project_info(ctx):
"""Show project metadata from project.godot."""
from cli_anything.godot.core.project import get_project_info
_out(ctx, get_project_info(_get_project(ctx)))
@project.command("scenes")
@click.pass_context
@_handle_error
def project_scenes(ctx):
"""List all scene files (.tscn, .scn) in the project."""
from cli_anything.godot.core.project import list_scenes
_out(ctx, list_scenes(_get_project(ctx)))
@project.command("scripts")
@click.pass_context
@_handle_error
def project_scripts(ctx):
"""List all GDScript files (.gd) in the project."""
from cli_anything.godot.core.project import list_scripts
_out(ctx, list_scripts(_get_project(ctx)))
@project.command("resources")
@click.pass_context
@_handle_error
def project_resources(ctx):
"""List all resource files (.tres, .res) in the project."""
from cli_anything.godot.core.project import list_resources
_out(ctx, list_resources(_get_project(ctx)))
@project.command("reimport")
@click.pass_context
@_handle_error
def project_reimport(ctx):
"""Force re-import of all project resources via Godot."""
from cli_anything.godot.core.project import reimport_project
_out(ctx, reimport_project(_get_project(ctx)))
# ── Scene commands ─────────────────────────────────────────────────────
@cli.group()
@click.pass_context
def scene(ctx):
"""Create and inspect Godot scenes."""
pass
@scene.command("create")
@click.argument("scene_path")
@click.option("--root-type", default="Node2D", help="Root node type (Node2D, Node3D, Control...).")
@click.option("--root-name", default=None, help="Root node name.")
@click.pass_context
@_handle_error
def scene_create(ctx, scene_path, root_type, root_name):
"""Create a new .tscn scene file at SCENE_PATH (relative to project)."""
from cli_anything.godot.core.scene import create_scene
_out(ctx, create_scene(_get_project(ctx), scene_path, root_type, root_name))
@scene.command("read")
@click.argument("scene_path")
@click.pass_context
@_handle_error
def scene_read(ctx, scene_path):
"""Parse and display the node tree of a .tscn scene."""
from cli_anything.godot.core.scene import read_scene
_out(ctx, read_scene(_get_project(ctx), scene_path))
@scene.command("add-node")
@click.argument("scene_path")
@click.option("--name", "node_name", required=True, help="Name of the new node.")
@click.option("--type", "node_type", required=True, help="Node type (Sprite2D, Camera2D, etc.).")
@click.option("--parent", default=".", help="Parent node path (default: root).")
@click.pass_context
@_handle_error
def scene_add_node(ctx, scene_path, node_name, node_type, parent):
"""Add a child node to an existing scene."""
from cli_anything.godot.core.scene import add_node
_out(ctx, add_node(_get_project(ctx), scene_path, node_name, node_type, parent))
# ── Export commands ────────────────────────────────────────────────────
@cli.group("export")
@click.pass_context
def export_group(ctx):
"""Export Godot projects to target platforms."""
pass
@export_group.command("build")
@click.option("--preset", default=None, help="Export preset name. Omit to export all (Godot 4.3+).")
@click.option("--output", default=None, help="Output file path.")
@click.option("--debug", is_flag=True, help="Use debug export instead of release.")
@click.pass_context
@_handle_error
def export_build(ctx, preset, output, debug):
"""Build/export the project using configured presets."""
from cli_anything.godot.core.export import export_project
_out(ctx, export_project(_get_project(ctx), preset, output, debug))
@export_group.command("presets")
@click.pass_context
@_handle_error
def export_presets(ctx):
"""List configured export presets."""
from cli_anything.godot.core.export import list_export_presets
_out(ctx, list_export_presets(_get_project(ctx)))
# ── Script commands ────────────────────────────────────────────────────
@cli.group()
@click.pass_context
def script(ctx):
"""Run and validate GDScript files."""
pass
@script.command("run")
@click.argument("script_path")
@click.option("--timeout", default=60, help="Execution timeout in seconds.")
@click.pass_context
@_handle_error
def script_run(ctx, script_path, timeout):
"""Execute a GDScript file in headless mode. Must extend SceneTree."""
from cli_anything.godot.core.script import run_script
_out(ctx, run_script(_get_project(ctx), script_path, timeout))
@script.command("inline")
@click.argument("code")
@click.option("--timeout", default=60, help="Execution timeout in seconds.")
@click.pass_context
@_handle_error
def script_inline(ctx, code, timeout):
"""Run inline GDScript code (wrapped in SceneTree._init).
Security: The provided code is written to a temp file and executed via
Godot subprocess. Only use with trusted input.
"""
from cli_anything.godot.core.script import run_inline
_out(ctx, run_inline(_get_project(ctx), code, timeout))
@script.command("validate")
@click.argument("script_path")
@click.pass_context
@_handle_error
def script_validate(ctx, script_path):
"""Validate GDScript syntax without executing."""
from cli_anything.godot.core.script import validate_script
_out(ctx, validate_script(_get_project(ctx), script_path))
# ── Engine commands ────────────────────────────────────────────────────
@cli.group()
@click.pass_context
def engine(ctx):
"""Godot engine info — version, status."""
pass
@engine.command("version")
@click.pass_context
@_handle_error
def engine_version(ctx):
"""Show Godot engine version."""
_out(ctx, get_version())
@engine.command("status")
@click.pass_context
@_handle_error
def engine_status(ctx):
"""Check if Godot binary is available."""
available = is_available()
binary = find_godot_binary()
_out(ctx, {
"status": "ok",
"available": available,
"binary": binary or "not found",
})
# ── REPL session ───────────────────────────────────────────────────────
@cli.command()
@click.pass_context
def session(ctx):
"""Start an interactive REPL session."""
ctx.obj["repl"] = True
try:
from cli_anything.godot.utils.repl_skin import ReplSkin
skin = ReplSkin("godot", version="1.0.0")
skin.print_banner()
except ImportError:
skin = None
click.secho("cli-anything-godot REPL", fg="green", bold=True)
click.echo("Type 'help' for commands, 'exit' to quit.\n")
from prompt_toolkit import PromptSession
from prompt_toolkit.history import InMemoryHistory
prompt_session = PromptSession(history=InMemoryHistory())
project_path = ctx.obj.get("project")
project_name = os.path.basename(project_path) if project_path else "no-project"
while True:
try:
if skin:
prompt_text = skin.prompt(project_name=project_name, modified=False)
else:
prompt_text = f"godot ({project_name})> "
line = prompt_session.prompt(prompt_text)
line = line.strip()
if not line:
continue
if line in ("exit", "quit", "q"):
break
if line == "help":
click.echo(cli.get_help(click.Context(cli)))
continue
try:
args = shlex.split(line)
except ValueError as e:
click.secho(f"Parse error: {e}", fg="red")
continue
try:
cli.main(args=args, standalone_mode=False, obj=ctx.obj)
except SystemExit:
pass
except click.exceptions.UsageError as e:
click.secho(str(e), fg="red")
except KeyboardInterrupt:
continue
except EOFError:
break
if skin:
skin.print_goodbye()
else:
click.echo("Goodbye.")
# ── Entry point ────────────────────────────────────────────────────────
def main():
cli()
if __name__ == "__main__":
main()
@@ -0,0 +1,100 @@
# Godot Engine CLI
Agent-native CLI for the Godot game engine. Manage projects, scenes, exports, and GDScript execution from the command line.
## Installation
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=godot/agent-harness
```
## Requirements
- Godot 4.x on PATH (or set GODOT_BIN env var)
## Commands
### Project Management
```bash
# Create a new Godot project
cli-anything-godot project create <path> [--name "My Game"]
# Get project info (name, features, main scene)
cli-anything-godot --json -p <project> project info
# List all scenes
cli-anything-godot --json -p <project> project scenes
# List all scripts
cli-anything-godot --json -p <project> project scripts
# List all resources
cli-anything-godot --json -p <project> project resources
# Re-import project resources
cli-anything-godot -p <project> project reimport
```
### Scene Operations
```bash
# Create a new scene with root node type
cli-anything-godot -p <project> scene create scenes/Level1.tscn --root-type Node3D
# Read scene structure (nodes, resources, connections)
cli-anything-godot --json -p <project> scene read scenes/Level1.tscn
# Add a child node to a scene
cli-anything-godot -p <project> scene add-node scenes/Level1.tscn --name Player --type CharacterBody3D --parent .
```
### GDScript Execution
```bash
# Run a GDScript file (must extend SceneTree)
cli-anything-godot -p <project> script run tools/build_navmesh.gd
# Run inline GDScript code
cli-anything-godot -p <project> script inline 'print(ProjectSettings.get_setting("application/config/name"))'
# Validate GDScript syntax
cli-anything-godot -p <project> script validate scripts/player.gd
```
### Export
```bash
# List configured export presets
cli-anything-godot --json -p <project> export presets
# Export all presets
cli-anything-godot -p <project> export build
# Export a specific preset
cli-anything-godot -p <project> export build --preset "Windows Desktop" --output build/game.exe
```
### Engine
```bash
# Check Godot availability and binary path
cli-anything-godot --json engine status
# Get engine version
cli-anything-godot engine version
```
## JSON Mode
Add `--json` flag to any command for structured JSON output suitable for agent consumption:
```bash
cli-anything-godot --json -p ./my-game project info
```
## Interactive REPL
```bash
cli-anything-godot -p ./my-game session
```
@@ -0,0 +1,295 @@
"""Unit tests for cli-anything-godot — no Godot binary required.
Tests project management, scene I/O, and export preset parsing
using temporary directories and mock files.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest import mock
import pytest
from click.testing import CliRunner
from cli_anything.godot.godot_cli import cli
# ── Fixtures ───────────────────────────────────────────────────────────
@pytest.fixture
def tmp_project(tmp_path):
"""Create a minimal Godot project in a temp directory."""
project_file = tmp_path / "project.godot"
project_file.write_text(
'; Engine configuration file.\n\n'
'[application]\n\n'
'config/name="TestGame"\n'
'config/features=PackedStringArray("4.4", "GL Compatibility")\n'
'run/main_scene="res://scenes/Main.tscn"\n\n'
'[rendering]\n\n'
'renderer/rendering_method="gl_compatibility"\n',
encoding="utf-8",
)
# Create some scene files
scenes_dir = tmp_path / "scenes"
scenes_dir.mkdir()
(scenes_dir / "Main.tscn").write_text(
'[gd_scene format=3 uid="uid://abc123"]\n\n'
'[node name="Main" type="Node2D"]\n',
encoding="utf-8",
)
(scenes_dir / "Level1.tscn").write_text(
'[gd_scene format=3 uid="uid://def456"]\n\n'
'[node name="Level1" type="Node3D"]\n\n'
'[node name="Player" type="CharacterBody3D" parent="."]\n',
encoding="utf-8",
)
# Create script files
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir()
(scripts_dir / "player.gd").write_text(
'extends CharacterBody3D\n\nfunc _ready():\n\tpass\n',
encoding="utf-8",
)
# Create resource files
(tmp_path / "icon.tres").write_text("", encoding="utf-8")
return tmp_path
@pytest.fixture
def runner():
return CliRunner()
# ── Project tests ──────────────────────────────────────────────────────
class TestProjectCreate:
def test_create_new_project(self, runner, tmp_path):
project_dir = tmp_path / "new_game"
result = runner.invoke(cli, ["--json", "project", "create", str(project_dir)])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["project_name"] == "new_game"
assert (project_dir / "project.godot").exists()
def test_create_with_custom_name(self, runner, tmp_path):
project_dir = tmp_path / "my_dir"
result = runner.invoke(cli, [
"--json", "project", "create", str(project_dir), "--name", "Cool Game"
])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["project_name"] == "Cool Game"
def test_create_duplicate_fails(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "project", "create", str(tmp_project)])
data = json.loads(result.output)
assert data["status"] == "error"
class TestProjectInfo:
def test_info_valid_project(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "-p", str(tmp_project), "project", "info"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["name"] == "TestGame"
assert "4.4" in data["features"]
def test_info_invalid_project(self, runner, tmp_path):
result = runner.invoke(cli, ["--json", "-p", str(tmp_path), "project", "info"])
data = json.loads(result.output)
assert data["status"] == "error"
class TestProjectList:
def test_list_scenes(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "-p", str(tmp_project), "project", "scenes"])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["count"] == 2
assert "scenes/Main.tscn" in data["scenes"]
assert "scenes/Level1.tscn" in data["scenes"]
def test_list_scripts(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "-p", str(tmp_project), "project", "scripts"])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["count"] == 1
assert "scripts/player.gd" in data["scripts"]
def test_list_resources(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "-p", str(tmp_project), "project", "resources"])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["count"] == 1
# ── Scene tests ────────────────────────────────────────────────────────
class TestSceneCreate:
def test_create_scene(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project),
"scene", "create", "scenes/NewScene.tscn",
"--root-type", "Node3D",
])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["root_type"] == "Node3D"
assert (tmp_project / "scenes" / "NewScene.tscn").exists()
def test_create_duplicate_scene_fails(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project),
"scene", "create", "scenes/Main.tscn",
])
data = json.loads(result.output)
assert data["status"] == "error"
class TestSceneRead:
def test_read_scene(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project),
"scene", "read", "scenes/Level1.tscn",
])
data = json.loads(result.output)
assert data["status"] == "ok"
assert len(data["nodes"]) == 2
assert data["nodes"][0]["name"] == "Level1"
assert data["nodes"][1]["name"] == "Player"
def test_read_nonexistent_scene(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project),
"scene", "read", "scenes/Nope.tscn",
])
data = json.loads(result.output)
assert data["status"] == "error"
class TestSceneAddNode:
def test_add_node(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project),
"scene", "add-node", "scenes/Main.tscn",
"--name", "Camera",
"--type", "Camera2D",
])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["node_name"] == "Camera"
# Verify the node was added to the file
content = (tmp_project / "scenes" / "Main.tscn").read_text()
assert 'name="Camera"' in content
assert 'type="Camera2D"' in content
# ── Export tests ───────────────────────────────────────────────────────
class TestExportPresets:
def test_no_presets_file(self, runner, tmp_project):
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project), "export", "presets"
])
data = json.loads(result.output)
assert data["status"] == "ok"
assert data["count"] == 0
def test_parse_presets(self, runner, tmp_project):
presets_file = tmp_project / "export_presets.cfg"
presets_file.write_text(
'[preset.0]\n\n'
'name="Windows Desktop"\n'
'platform="Windows Desktop"\n'
'export_path="build/game.exe"\n\n'
'[preset.1]\n\n'
'name="Linux"\n'
'platform="Linux/X11"\n'
'export_path="build/game.x86_64"\n',
encoding="utf-8",
)
result = runner.invoke(cli, [
"--json", "-p", str(tmp_project), "export", "presets"
])
data = json.loads(result.output)
assert data["count"] == 2
assert data["presets"][0]["name"] == "Windows Desktop"
assert data["presets"][1]["platform"] == "Linux/X11"
# ── Engine tests ───────────────────────────────────────────────────────
class TestEngineStatus:
def test_engine_status_no_godot(self, runner):
with mock.patch(
"cli_anything.godot.utils.godot_backend.find_godot_binary",
return_value=None,
):
result = runner.invoke(cli, ["--json", "engine", "status"])
data = json.loads(result.output)
assert data["available"] is False
def test_engine_status_found(self, runner):
with mock.patch(
"cli_anything.godot.godot_cli.find_godot_binary",
return_value="/usr/bin/godot",
):
with mock.patch(
"cli_anything.godot.godot_cli.is_available",
return_value=True,
):
result = runner.invoke(cli, ["--json", "engine", "status"])
data = json.loads(result.output)
assert data["available"] is True
assert data["binary"] == "/usr/bin/godot"
# ── Backend tests ──────────────────────────────────────────────────────
class TestBackend:
def test_validate_project(self, tmp_project):
from cli_anything.godot.utils.godot_backend import validate_project
assert validate_project(str(tmp_project)) is True
def test_validate_non_project(self, tmp_path):
from cli_anything.godot.utils.godot_backend import validate_project
assert validate_project(str(tmp_path)) is False
def test_find_godot_binary_env(self):
from cli_anything.godot.utils.godot_backend import find_godot_binary
with mock.patch.dict(os.environ, {"GODOT_BIN": "python"}):
# python is guaranteed to be on PATH
result = find_godot_binary()
assert result is not None
# ── CLI root tests ─────────────────────────────────────────────────────
class TestCLIRoot:
def test_help(self, runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "cli-anything-godot" in result.output
def test_no_args_shows_help(self, runner):
result = runner.invoke(cli, [])
assert "cli-anything-godot" in result.output
def test_json_flag(self, runner, tmp_project):
result = runner.invoke(cli, ["--json", "-p", str(tmp_project), "project", "info"])
data = json.loads(result.output)
assert isinstance(data, dict)
def test_human_output(self, runner, tmp_project):
result = runner.invoke(cli, ["-p", str(tmp_project), "project", "info"])
assert "TestGame" in result.output
@@ -0,0 +1,623 @@
"""End-to-end tests for cli-anything-godot.
These tests require Godot 4.x to be installed and on PATH.
They are automatically skipped when the binary is not available.
Run explicitly with: pytest -m e2e
"""
import json
import textwrap
import pytest
from click.testing import CliRunner
from cli_anything.godot.godot_cli import cli
from cli_anything.godot.utils.godot_backend import is_available
_godot_missing = not is_available()
skip_no_godot = pytest.mark.skipif(
_godot_missing, reason="Godot binary not found on PATH"
)
# ── helpers ───────────────────────────────────────────────────────────
def _invoke_json(runner, args):
"""Invoke CLI with --json flag and return parsed dict."""
result = runner.invoke(cli, ["--json"] + args)
assert result.exit_code == 0, f"CLI exited {result.exit_code}: {result.output}"
return json.loads(result.output)
def _invoke_project_json(runner, project_path, args):
"""Invoke CLI with --json and -p flags and return parsed dict."""
return _invoke_json(runner, ["-p", str(project_path)] + args)
# ── fixtures ──────────────────────────────────────────────────────────
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def e2e_project(tmp_path, runner):
"""Create a real Godot project for E2E tests."""
project_dir = tmp_path / "e2e_game"
data = _invoke_json(runner, ["project", "create", str(project_dir), "--name", "E2E Game"])
assert data["status"] == "ok"
return project_dir
# ── Engine ────────────────────────────────────────────────────────────
@skip_no_godot
class TestE2EEngineVersion:
def test_version(self, runner):
data = _invoke_json(runner, ["engine", "version"])
assert "version" in data
def test_status(self, runner):
data = _invoke_json(runner, ["engine", "status"])
assert data["available"] is True
assert data["binary"] != "not found"
# ── Project basics ────────────────────────────────────────────────────
@skip_no_godot
class TestE2EProject:
def test_create_and_info(self, runner, tmp_path):
project_dir = tmp_path / "test_game"
data = _invoke_json(runner, ["project", "create", str(project_dir), "--name", "Test Game"])
assert data["status"] == "ok"
data = _invoke_project_json(runner, project_dir, ["project", "info"])
assert data["name"] == "Test Game"
def test_reimport(self, runner, e2e_project):
data = _invoke_project_json(runner, e2e_project, ["project", "reimport"])
assert "status" in data
# ── Scene ─────────────────────────────────────────────────────────────
@skip_no_godot
class TestE2EScene:
def test_create_and_read(self, runner, e2e_project):
data = _invoke_project_json(
runner, e2e_project,
["scene", "create", "scenes/TestScene.tscn", "--root-type", "Node2D"],
)
assert data["status"] == "ok"
data = _invoke_project_json(
runner, e2e_project,
["scene", "read", "scenes/TestScene.tscn"],
)
assert data["status"] == "ok"
assert len(data["nodes"]) >= 1
def test_add_node_and_verify(self, runner, e2e_project):
_invoke_project_json(
runner, e2e_project,
["scene", "create", "scenes/NodeTest.tscn"],
)
data = _invoke_project_json(
runner, e2e_project,
["scene", "add-node", "scenes/NodeTest.tscn",
"--name", "Sprite", "--type", "Sprite2D"],
)
assert data["status"] == "ok"
data = _invoke_project_json(
runner, e2e_project,
["scene", "read", "scenes/NodeTest.tscn"],
)
node_names = [n.get("name") for n in data["nodes"]]
assert "Sprite" in node_names
# ── Script ────────────────────────────────────────────────────────────
@skip_no_godot
class TestE2EScript:
def test_run_script(self, runner, e2e_project):
script_path = e2e_project / "tool_test.gd"
script_path.write_text(
"extends SceneTree\n\n"
"func _init():\n"
'\tprint("Hello from E2E!")\n'
"\tquit()\n",
encoding="utf-8",
)
data = _invoke_project_json(
runner, e2e_project, ["script", "run", "tool_test.gd"],
)
assert "status" in data
if data["status"] == "ok":
assert "Hello from E2E!" in data.get("stdout", "")
def test_inline_script(self, runner, e2e_project):
data = _invoke_project_json(
runner, e2e_project,
["script", "inline", 'print("inline test")'],
)
assert "status" in data
# ── Full demo-game pipeline ──────────────────────────────────────────
#
# This is the key test the maintainer asked for: walk through every step
# of building a small game project — from ``project create`` to export
# config — and verify intermediate state at each stage.
@skip_no_godot
class TestE2EDemoGamePipeline:
"""Build a mini platformer project from scratch and verify each stage."""
@pytest.fixture(autouse=True)
def _setup(self, runner, tmp_path):
self.runner = runner
self.project_dir = tmp_path / "demo_platformer"
# -- helpers local to the pipeline ------------------------------------
def _pj(self, args):
return _invoke_project_json(self.runner, self.project_dir, args)
# -- phase 1: project creation ----------------------------------------
def test_01_create_project(self):
data = _invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
assert data["status"] == "ok"
assert (self.project_dir / "project.godot").exists()
def test_02_project_info(self):
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
data = self._pj(["project", "info"])
assert data["name"] == "Demo Platformer"
# -- phase 2: build scene hierarchy -----------------------------------
def test_03_create_multiple_scenes(self):
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
scenes = [
("scenes/Main.tscn", "Node2D", "Main"),
("scenes/Player.tscn", "CharacterBody2D", "Player"),
("scenes/Level1.tscn", "Node2D", "Level1"),
("scenes/UI.tscn", "Control", "UI"),
]
for path, root_type, root_name in scenes:
data = self._pj([
"scene", "create", path,
"--root-type", root_type,
"--root-name", root_name,
])
assert data["status"] == "ok", f"Failed to create {path}"
assert data["root_type"] == root_type
def test_04_build_node_hierarchy(self):
"""Assemble a player scene with multiple child nodes."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
self._pj([
"scene", "create", "scenes/Player.tscn",
"--root-type", "CharacterBody2D",
"--root-name", "Player",
])
children = [
("Sprite", "Sprite2D"),
("CollisionShape", "CollisionShape2D"),
("AnimPlayer", "AnimationPlayer"),
("Camera", "Camera2D"),
]
for name, node_type in children:
data = self._pj([
"scene", "add-node", "scenes/Player.tscn",
"--name", name, "--type", node_type,
])
assert data["status"] == "ok"
data = self._pj(["scene", "read", "scenes/Player.tscn"])
node_names = {n.get("name") for n in data["nodes"]}
assert {"Player", "Sprite", "CollisionShape", "AnimPlayer", "Camera"} <= node_names
def test_05_nested_node_hierarchy(self):
"""Add nodes under non-root parents to verify parent path handling."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
self._pj([
"scene", "create", "scenes/Level1.tscn",
"--root-type", "Node2D",
"--root-name", "Level1",
])
self._pj([
"scene", "add-node", "scenes/Level1.tscn",
"--name", "Platforms", "--type", "Node2D",
])
self._pj([
"scene", "add-node", "scenes/Level1.tscn",
"--name", "Platform1", "--type", "StaticBody2D",
"--parent", "Platforms",
])
self._pj([
"scene", "add-node", "scenes/Level1.tscn",
"--name", "CollisionShape", "--type", "CollisionShape2D",
"--parent", "Platforms/Platform1",
])
data = self._pj(["scene", "read", "scenes/Level1.tscn"])
nodes_by_name = {n["name"]: n for n in data["nodes"] if "name" in n}
assert "Platform1" in nodes_by_name
assert nodes_by_name["Platform1"].get("parent") == "Platforms"
assert nodes_by_name["CollisionShape"].get("parent") == "Platforms/Platform1"
# -- phase 3: scripting & validation ----------------------------------
def test_06_write_and_validate_script(self):
"""Write a player movement script and validate its syntax."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
scripts_dir = self.project_dir / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "player_movement.gd").write_text(textwrap.dedent("""\
extends CharacterBody2D
const SPEED = 300.0
const JUMP_VELOCITY = -400.0
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
var direction := Input.get_axis("ui_left", "ui_right")
if direction:
velocity.x = direction * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
move_and_slide()
"""), encoding="utf-8")
data = self._pj(["script", "validate", "scripts/player_movement.gd"])
assert data["status"] == "ok"
# Godot's check-only should find no errors in valid GDScript
assert data["valid"] is True, f"Validation errors: {data.get('errors')}"
def test_07_validate_invalid_script(self):
"""Ensure the validator catches syntax errors."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
bad_script = self.project_dir / "bad.gd"
bad_script.write_text(
"extends Node2D\n\nfunc broken(\n # missing closing paren and body\n",
encoding="utf-8",
)
data = self._pj(["script", "validate", "bad.gd"])
assert data["status"] == "ok" # command itself succeeds
assert data["valid"] is False
def test_08_run_procedural_generation_script(self):
"""Run a script that programmatically generates data, simulating
procedural level generation — the kind of thing an agent would do."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
gen_script = self.project_dir / "gen_level.gd"
gen_script.write_text(textwrap.dedent("""\
extends SceneTree
func _init():
var platforms := []
for i in range(5):
platforms.append({
"x": i * 200,
"y": 500 - (i * 30),
"width": 150,
})
print(JSON.stringify(platforms))
quit()
"""), encoding="utf-8")
data = self._pj(["script", "run", "gen_level.gd"])
assert data["status"] == "ok"
# The script should emit valid JSON describing platform positions
stdout = data.get("stdout", "")
platforms = json.loads(stdout.strip().splitlines()[-1])
assert len(platforms) == 5
assert platforms[0]["x"] == 0
assert platforms[4]["x"] == 800
def test_09_inline_script_computes_result(self):
"""Run inline code that performs a computation and verify output."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
data = self._pj([
"script", "inline",
'var total := 0\nfor i in range(1, 11):\n\ttotal += i\nprint(total)',
])
assert data["status"] == "ok"
assert "55" in data.get("stdout", "")
# -- phase 4: asset inventory -----------------------------------------
def test_10_list_project_assets(self):
"""After creating scenes and scripts, verify asset listing commands."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
# Create two scenes
self._pj(["scene", "create", "scenes/Main.tscn", "--root-type", "Node2D"])
self._pj(["scene", "create", "scenes/Player.tscn", "--root-type", "CharacterBody2D"])
# Write a script
scripts_dir = self.project_dir / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "player.gd").write_text(
"extends CharacterBody2D\n", encoding="utf-8",
)
# Write a resource file
(self.project_dir / "icon.tres").write_text(
'[gd_resource type="CompressedTexture2D"]\n', encoding="utf-8",
)
# Verify scene listing
data = self._pj(["project", "scenes"])
scene_paths = [s if isinstance(s, str) else s.get("path", "") for s in data["scenes"]]
assert len(scene_paths) >= 2
# Verify script listing
data = self._pj(["project", "scripts"])
assert data["count"] >= 1
# Verify resource listing
data = self._pj(["project", "resources"])
assert data["count"] >= 1
# -- phase 5: export configuration ------------------------------------
def test_11_export_presets_empty(self):
"""A fresh project has no export presets."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
data = self._pj(["export", "presets"])
assert data["status"] == "ok"
assert data["count"] == 0
def test_12_export_presets_parsed(self):
"""Write an export_presets.cfg and verify it gets parsed correctly."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
(self.project_dir / "export_presets.cfg").write_text(textwrap.dedent("""\
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
export_path="build/game.exe"
[preset.0.options]
[preset.1]
name="Linux/X11"
platform="Linux/X11"
export_path="build/game.x86_64"
[preset.1.options]
"""), encoding="utf-8")
data = self._pj(["export", "presets"])
assert data["status"] == "ok"
assert data["count"] == 2
preset_names = {p["name"] for p in data["presets"]}
assert preset_names == {"Windows Desktop", "Linux/X11"}
preset_platforms = {p["platform"] for p in data["presets"]}
assert preset_platforms == {"Windows Desktop", "Linux/X11"}
def test_13_export_build_without_presets_fails(self):
"""Export build on a project without presets should return an error."""
_invoke_json(
self.runner,
["project", "create", str(self.project_dir), "--name", "Demo Platformer"],
)
result = self.runner.invoke(cli, [
"--json", "-p", str(self.project_dir), "export", "build",
])
data = json.loads(result.output)
assert data["status"] == "error"
assert "export_presets.cfg" in data["message"]
# -- phase 6: full pipeline in a single test --------------------------
def test_14_complete_game_assembly(self):
"""Walk through the entire game-creation pipeline in one test:
create project → build scenes → add nodes → write scripts →
validate → list assets → configure export → verify presets.
This is the true end-to-end rendering-pipeline test: every CLI
command that an agent would invoke to assemble a playable demo.
"""
proj = self.project_dir
# 1. Create project
data = _invoke_json(
self.runner,
["project", "create", str(proj), "--name", "Full Pipeline Game"],
)
assert data["status"] == "ok"
# 2. Create main scene
data = self._pj([
"scene", "create", "scenes/Main.tscn",
"--root-type", "Node2D", "--root-name", "Main",
])
assert data["status"] == "ok"
# 3. Create player scene with full hierarchy
self._pj([
"scene", "create", "scenes/Player.tscn",
"--root-type", "CharacterBody2D", "--root-name", "Player",
])
for name, ntype in [
("Sprite", "Sprite2D"),
("Collision", "CollisionShape2D"),
("Anim", "AnimationPlayer"),
]:
data = self._pj([
"scene", "add-node", "scenes/Player.tscn",
"--name", name, "--type", ntype,
])
assert data["status"] == "ok"
# 4. Create level scene with nested hierarchy
self._pj([
"scene", "create", "scenes/Level1.tscn",
"--root-type", "Node2D", "--root-name", "Level1",
])
self._pj([
"scene", "add-node", "scenes/Level1.tscn",
"--name", "Platforms", "--type", "Node2D",
])
self._pj([
"scene", "add-node", "scenes/Level1.tscn",
"--name", "Ground", "--type", "StaticBody2D",
"--parent", "Platforms",
])
# 5. Verify player scene structure
data = self._pj(["scene", "read", "scenes/Player.tscn"])
node_names = {n["name"] for n in data["nodes"] if "name" in n}
assert {"Player", "Sprite", "Collision", "Anim"} <= node_names
# 6. Write and validate game scripts
scripts_dir = proj / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "player.gd").write_text(textwrap.dedent("""\
extends CharacterBody2D
const SPEED = 300.0
const JUMP_VELOCITY = -400.0
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
var direction := Input.get_axis("ui_left", "ui_right")
velocity.x = direction * SPEED if direction else move_toward(velocity.x, 0, SPEED)
move_and_slide()
"""), encoding="utf-8")
(scripts_dir / "main.gd").write_text(textwrap.dedent("""\
extends Node2D
func _ready() -> void:
print("Game started")
"""), encoding="utf-8")
for script_name in ["scripts/player.gd", "scripts/main.gd"]:
data = self._pj(["script", "validate", script_name])
assert data["status"] == "ok"
assert data["valid"] is True, (
f"{script_name} failed validation: {data.get('errors')}"
)
# 7. Run a tool-script that verifies the project is well-formed
checker = proj / "check_project.gd"
checker.write_text(textwrap.dedent("""\
extends SceneTree
func _init():
var dir := DirAccess.open("res://scenes")
var scenes := []
if dir:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(".tscn"):
scenes.append(file_name)
file_name = dir.get_next()
print(JSON.stringify({"scene_count": scenes.size(), "scenes": scenes}))
quit()
"""), encoding="utf-8")
data = self._pj(["script", "run", "check_project.gd"])
assert data["status"] == "ok"
stdout = data.get("stdout", "")
report = json.loads(stdout.strip().splitlines()[-1])
assert report["scene_count"] == 3 # Main, Player, Level1
# 8. Verify asset inventory
data = self._pj(["project", "scenes"])
assert len(data["scenes"]) >= 3
data = self._pj(["project", "scripts"])
assert data["count"] >= 2
# 9. Configure export and verify presets
(proj / "export_presets.cfg").write_text(textwrap.dedent("""\
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
export_path="build/game.exe"
[preset.0.options]
[preset.1]
name="Linux/X11"
platform="Linux/X11"
export_path="build/game.x86_64"
[preset.1.options]
"""), encoding="utf-8")
data = self._pj(["export", "presets"])
assert data["count"] == 2
# 10. Attempt export build (will fail without export templates,
# but we verify the CLI invokes Godot correctly)
result = self.runner.invoke(cli, [
"--json", "-p", str(proj), "export", "build",
])
data = json.loads(result.output)
assert data["preset"] == "all"
assert "returncode" in data
@@ -0,0 +1,136 @@
"""Godot Engine backend — subprocess wrapper for the Godot binary.
Godot runs as a local binary (godot / godot.exe / Godot_v4*).
All engine operations go through command-line flags:
--headless No GPU / display required
--path <dir> Set project directory
--script <gd> Run a GDScript (must extend SceneTree or MainLoop)
--export-all Export all configured presets
--import Re-import project resources
--quit Quit after completing the command
"""
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
# ---------- binary discovery ----------
_COMMON_NAMES = [
"godot",
"godot4",
"godot.exe",
"Godot_v4.4-stable_win64.exe",
"Godot_v4.4-stable_linux.x86_64",
"Godot_v4.3-stable_win64.exe",
"Godot_v4.3-stable_linux.x86_64",
]
def find_godot_binary() -> str | None:
"""Search PATH and common locations for a Godot 4 binary.
Returns:
Absolute path to the binary, or None if not found.
"""
# 1. Environment variable override
env = os.environ.get("GODOT_BIN")
if env and shutil.which(env):
return shutil.which(env)
# 2. Search PATH for common names
for name in _COMMON_NAMES:
path = shutil.which(name)
if path:
return path
return None
def require_godot() -> str:
"""Return the Godot binary path or raise."""
binary = find_godot_binary()
if binary is None:
raise RuntimeError(
"Godot binary not found. Install Godot 4 and ensure it is on PATH, "
"or set the GODOT_BIN environment variable."
)
return binary
# ---------- low-level runner ----------
def run_godot(
args: list[str],
project_path: str | None = None,
headless: bool = True,
timeout: int = 120,
capture: bool = True,
) -> dict[str, Any]:
"""Execute the Godot binary with the given arguments.
Args:
args: Extra CLI flags (e.g. ['--script', 'res://tool.gd']).
project_path: If set, adds --path <project_path>.
headless: If True, adds --headless flag.
timeout: Subprocess timeout in seconds.
capture: If True, capture stdout/stderr.
Returns:
Dict with 'returncode', 'stdout', 'stderr' keys.
Raises:
RuntimeError: On binary-not-found or subprocess timeout.
"""
binary = require_godot()
cmd = [binary]
if headless:
cmd.append("--headless")
if project_path:
cmd.extend(["--path", str(project_path)])
cmd.extend(args)
try:
result = subprocess.run(
cmd,
capture_output=capture,
text=True,
timeout=timeout,
cwd=project_path,
)
return {
"returncode": result.returncode,
"stdout": result.stdout if capture else "",
"stderr": result.stderr if capture else "",
}
except subprocess.TimeoutExpired as e:
raise RuntimeError(
f"Godot command timed out after {timeout}s: {' '.join(cmd)}"
) from e
except FileNotFoundError as e:
raise RuntimeError(
f"Godot binary not found at {binary}"
) from e
# ---------- convenience helpers ----------
def get_version() -> dict:
"""Return Godot version info."""
result = run_godot(["--version", "--quit"], headless=True, timeout=15)
version_str = result["stdout"].strip().split("\n")[0] if result["stdout"] else "unknown"
return {"version": version_str, "returncode": result["returncode"]}
def is_available() -> bool:
"""Check if Godot binary is reachable."""
return find_godot_binary() is not None
def validate_project(project_path: str) -> bool:
"""Check if a directory is a valid Godot project (has project.godot)."""
return Path(project_path, "project.godot").is_file()
@@ -0,0 +1,521 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects skills/SKILL.md inside the package
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the package's skills/ directory if not provided.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
# Auto-detect skill path from package layout:
# cli_anything/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/skills/SKILL.md (target)
if skill_path is None:
from pathlib import Path
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
if _auto.is_file():
skill_path = str(_auto)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
# Skill path for agent discovery
skill_line = None
if self.skill_path:
skill_icon = self._c(_MAGENTA, "")
skill_label = self._c(_DARK_GRAY, " Skill:")
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
print(top)
print(_box_line(title))
print(_box_line(ver))
if skill_line:
print(_box_line(skill_line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
setup.py for cli-anything-godot
Install with: pip install -e .
Or publish to PyPI: python -m build && twine upload dist/*
"""
from setuptools import setup, find_namespace_packages
with open("cli_anything/godot/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-godot",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI harness for Godot Engine - Game project management, scene editing, export and GDScript execution. Recommended: Godot 4.x on PATH",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Games/Entertainment",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-godot=cli_anything.godot.godot_cli:main",
],
},
package_data={
"cli_anything.godot": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)
+11 -95
View File
@@ -370,102 +370,18 @@
"contributor_url": "https://github.com/voidfreud"
},
{
"name": "slay_the_spire_ii",
"display_name": "Slay the Spire II",
"name": "godot",
"display_name": "Godot Engine",
"version": "1.0.0",
"description": "Control the real Slay the Spire 2 game via local STS2_Bridge HTTP API",
"requires": "Slay the Spire 2 (Steam) with STS2_Bridge mod enabled",
"homepage": "https://github.com/HKUDS/CLI-Anything/tree/main/slay_the_spire_ii",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=slay_the_spire_ii/agent-harness",
"entry_point": "cli-anything-sts2",
"skill_md": "slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md",
"category": "game",
"contributor": "TianyuFan0504",
"contributor_url": "https://github.com/TianyuFan0504"
},
{
"name": "rms",
"display_name": "Teltonika RMS",
"version": "1.0.0",
"description": "Device management and monitoring via Teltonika RMS REST API",
"requires": "RMS_API_TOKEN",
"homepage": "https://rms.teltonika-networks.com",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness",
"entry_point": "cli-anything-rms",
"skill_md": "rms/agent-harness/cli_anything/rms/skills/SKILL.md",
"category": "network",
"contributor": "galke",
"contributor_url": "https://github.com/galke7"
},
{
"name": "renderdoc",
"display_name": "RenderDoc",
"version": "0.1.0",
"description": "GPU frame capture analysis: pipeline state, shader export, texture inspection, draw call browsing",
"requires": "renderdoc (Python bindings from RenderDoc installation)",
"homepage": "https://renderdoc.org",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=renderdoc/agent-harness",
"entry_point": "cli-anything-renderdoc",
"skill_md": "renderdoc/agent-harness/cli_anything/renderdoc/skills/SKILL.md",
"category": "graphics",
"contributor": "levishilf",
"contributor_url": "https://github.com/levishilf"
},
{
"name": "videocaptioner",
"display_name": "VideoCaptioner",
"version": "1.0.0",
"description": "AI-powered video captioning — transcribe speech, optimize/translate subtitles, burn styled subtitles into video",
"requires": "videocaptioner (pip install videocaptioner), ffmpeg",
"homepage": "https://github.com/WEIFENG2333/VideoCaptioner",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=videocaptioner/agent-harness",
"entry_point": "cli-anything-videocaptioner",
"skill_md": "videocaptioner/agent-harness/cli_anything/videocaptioner/skills/SKILL.md",
"category": "video",
"contributor": "WEIFENG2333",
"contributor_url": "https://github.com/WEIFENG2333"
},
{
"name": "intelwatch",
"display_name": "Intelwatch",
"version": "1.0.0",
"description": "Competitive intelligence, M&A due diligence, and OSINT directly from your terminal.",
"requires": "Node.js >=18",
"homepage": "https://github.com/ashroth/intelwatch",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=intelwatch/agent-harness",
"entry_point": "cli-anything-intelwatch",
"skill_md": "intelwatch/agent-harness/cli_anything/intelwatch/skills/SKILL.md",
"category": "osint",
"contributor": "ashroth",
"contributor_url": "https://github.com/ashroth"
},
{
"name": "clibrowser",
"display_name": "clibrowser",
"version": "0.1.0",
"description": "Zero-dependency CLI browser for AI agents with search, extraction, forms, RSS, crawling, auth, and WebMCP support",
"requires": "Rust toolchain (cargo) for source install, or manual binary download from GitHub Releases",
"homepage": "https://github.com/allthingssecurity/clibrowser",
"install_cmd": "cargo install --git https://github.com/allthingssecurity/clibrowser.git --tag v0.1.0 --locked",
"entry_point": "clibrowser",
"skill_md": null,
"category": "web",
"contributor": "allthingssecurity",
"contributor_url": "https://github.com/allthingssecurity"
},
{
"name": "cloudcompare",
"display_name": "CloudCompare",
"version": "1.0.0",
"description": "3D point cloud and mesh processing: load/save, color ops, normal estimation, Delaunay meshing, noise filtering, ICP registration, connected component segmentation",
"requires": "cloudcompare (CloudCompare application installed, e.g. via Flatpak or package manager)",
"homepage": "https://cloudcompare.org",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudcompare/agent-harness",
"entry_point": "cli-anything-cloudcompare",
"skill_md": "cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md",
"category": "graphics",
"contributor": "Taeyoung96",
"contributor_url": "https://github.com/Taeyoung96"
"description": "Game engine project management, scene editing, export and GDScript execution via Godot 4 headless mode",
"requires": "Godot 4.x (godotengine.org)",
"homepage": "https://godotengine.org",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=godot/agent-harness",
"entry_point": "cli-anything-godot",
"skill_md": "godot/agent-harness/cli_anything/godot/skills/SKILL.md",
"category": "gamedev",
"contributor": "omerarslan0",
"contributor_url": "https://github.com/omerarslan0"
}
]
}