mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
Merge pull request #140 from HKUDS/feat/add-godot-cli
feat: add Godot Engine CLI harness
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user