diff --git a/Server/pyproject.toml b/Server/pyproject.toml index c86d3ef9..5389e7ff 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "tomli>=2.3.0", "fastapi>=0.104.0", "uvicorn>=0.35.0", + "click>=8.1.0", ] [project.optional-dependencies] @@ -51,6 +52,7 @@ Issues = "https://github.com/CoplayDev/unity-mcp/issues" [project.scripts] mcp-for-unity = "main:main" +unity-mcp = "cli.main:main" [build-system] requires = ["setuptools>=64.0.0", "wheel"] diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md new file mode 100644 index 00000000..2a8bfb25 --- /dev/null +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -0,0 +1,727 @@ +# Unity MCP CLI Usage Guide + +> **For AI Assistants and Developers**: This document explains the correct syntax and common pitfalls when using the Unity MCP CLI. + +## Table of Contents + +1. [Installation](#installation) +2. [Quick Start](#quick-start) +3. [Command Structure](#command-structure) +4. [Global Options](#global-options) +5. [Argument vs Option Syntax](#argument-vs-option-syntax) +6. [Common Mistakes and Corrections](#common-mistakes-and-corrections) +7. [Output Formats](#output-formats) +8. [Command Reference by Category](#command-reference-by-category) + +--- + +## Installation + +### Prerequisites + +- **Python 3.10+** installed +- **Unity Editor** running with the MCP plugin enabled +- **MCP Server** running (HTTP transport on port 8080) + +### Install via pip (from source) + +```bash +# Navigate to the Server directory +cd /path/to/unity-mcp/Server + +# Install in development mode +pip install -e . + +# Or install with uv (recommended) +uv pip install -e . +``` + +### Install via uv tool + +```bash +# Run directly without installing +uvx --from /path/to/unity-mcp/Server unity-mcp --help + +# Or install as a tool +uv tool install /path/to/unity-mcp/Server +``` + +### Verify Installation + +```bash +# Check version +unity-mcp --version + +# Check help +unity-mcp --help + +# Test connection to Unity +unity-mcp status +``` + +--- + +## Quick Start + +### 1. Start the MCP Server + +Make sure the Unity MCP server is running with HTTP transport: + +```bash +# The server is typically started via the Unity-MCP window, select HTTP local, and start server, or try this manually: +cd /path/to/unity-mcp/Server +uv run mcp-for-unity --transport http --http-url http://localhost:8080 +``` + +### 2. Verify Connection + +```bash +unity-mcp status +``` + +Expected output: +``` +Checking connection to 127.0.0.1:8080... +✅ Connected to Unity MCP server at 127.0.0.1:8080 + +Connected Unity instances: + • MyProject (Unity 6000.2.10f1) [09abcc51] +``` + +### 3. Run Your First Commands + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy + +# Create a cube +unity-mcp gameobject create "MyCube" --primitive Cube + +# Move the cube +unity-mcp gameobject modify "MyCube" --position 0 2 0 + +# Take a screenshot +unity-mcp scene screenshot + +# Enter play mode +unity-mcp editor play +``` + +### 4. Get Help on Any Command + +```bash +# List all commands +unity-mcp --help + +# Help for a command group +unity-mcp gameobject --help + +# Help for a specific command +unity-mcp gameobject create --help +``` + +--- + +## Command Structure + +The CLI follows this general pattern: + +``` +unity-mcp [GLOBAL_OPTIONS] COMMAND_GROUP [SUBCOMMAND] [ARGUMENTS] [OPTIONS] +``` + +**Example breakdown:** +```bash +unity-mcp -f json gameobject create "MyCube" --primitive Cube --position 0 1 0 +# ^^^^^^^ ^^^^^^^^^^^ ^^^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ +# global cmd group subcmd argument option multi-value option +``` + +--- + +## Global Options + +Global options come **BEFORE** the command group: + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--host` | `-h` | MCP server host | `127.0.0.1` | +| `--port` | `-p` | MCP server port | `8080` | +| `--format` | `-f` | Output format: `text`, `json`, `table` | `text` | +| `--timeout` | `-t` | Command timeout in seconds | `30` | +| `--instance` | `-i` | Target Unity instance (hash or Name@hash) | auto | +| `--verbose` | `-v` | Enable verbose output | `false` | + +**✅ Correct:** +```bash +unity-mcp -f json scene hierarchy +unity-mcp --format json --timeout 60 gameobject find "Player" +``` + +**❌ Wrong:** +```bash +unity-mcp scene hierarchy -f json # Global option after command +``` + +--- + +## Argument vs Option Syntax + +### Arguments (Positional) +Arguments are **required values** that come in a specific order, **without** flags. + +```bash +unity-mcp gameobject find "Player" +# ^^^^^^^^ This is an ARGUMENT (positional) +``` + +### Options (Named) +Options use `--name` or `-n` flags and can appear in any order after arguments. + +```bash +unity-mcp gameobject create "MyCube" --primitive Cube +# ^^^^^^^^^^^ ^^^^ This is an OPTION with value +``` + +### Multi-Value Options +Some options accept multiple values. **Do NOT use commas** - use spaces: + +**✅ Correct:** +```bash +unity-mcp gameobject modify "Cube" --position 1 2 3 +unity-mcp gameobject modify "Cube" --rotation 0 45 0 +unity-mcp gameobject modify "Cube" --scale 2 2 2 +``` + +**❌ Wrong:** +```bash +unity-mcp gameobject modify "Cube" --position "1,2,3" # Wrong: comma-separated string +unity-mcp gameobject modify "Cube" --position 1,2,3 # Wrong: comma-separated +unity-mcp gameobject modify "Cube" -pos "1 2 3" # Wrong: quoted as single string +``` + +--- + +## Common Mistakes and Corrections + +### 1. Multi-Value Options (Position, Rotation, Scale, Color) + +These options expect **separate float arguments**, not comma-separated strings: + +| Option | ❌ Wrong | ✅ Correct | +|--------|----------|-----------| +| `--position` | `--position "2,1,0"` | `--position 2 1 0` | +| `--rotation` | `--rotation "0,45,0"` | `--rotation 0 45 0` | +| `--scale` | `--scale "1,1,1"` | `--scale 1 1 1` | +| Color args | `1,0,0,1` | `1 0 0 1` | + +**Example - Moving a GameObject:** +```bash +# Wrong - will error "requires 3 arguments" +unity-mcp gameobject modify "Cube" --position "2,1,0" + +# Correct +unity-mcp gameobject modify "Cube" --position 2 1 0 +``` + +**Example - Setting material color:** +```bash +# Wrong +unity-mcp material set-color "Assets/Mat.mat" 1,0,0,1 + +# Correct (R G B or R G B A as separate args) +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 1 +``` + +### 2. Argument Order Matters + +Some commands have multiple positional arguments. Check `--help` to see the order: + +**Material assign:** +```bash +# Wrong - arguments in wrong order +unity-mcp material assign "TestCube" "Assets/Materials/Red.mat" + +# ✅ Correct - MATERIAL_PATH comes before TARGET +unity-mcp material assign "Assets/Materials/Red.mat" "TestCube" +``` + +**Prefab create:** +```bash +# Wrong - using --path option that doesn't exist +unity-mcp prefab create "Cube" --path "Assets/Prefabs/Cube.prefab" + +# Correct - PATH is a positional argument +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" +``` + +### 3. Using Options That Don't Exist + +Always check `--help` before assuming an option exists: + +```bash +# Check available options for any command +unity-mcp gameobject modify --help +unity-mcp material assign --help +unity-mcp prefab create --help +``` + +### 4. Property Names for Materials + +Different shaders use different property names. Use `material info` to discover them: + +```bash +# First, check what properties exist +unity-mcp material info "Assets/Materials/MyMat.mat" + +# Then use the correct property name +# For URP shaders, often "_BaseColor" instead of "_Color" +unity-mcp material set-color "Assets/Mat.mat" 1 0 0 --property "_BaseColor" +``` + +### 5. Search Methods + +When targeting GameObjects, specify how to search: + +```bash +# By name (default) +unity-mcp gameobject modify "Player" --position 0 0 0 + +# By instance ID (use --search-method) +unity-mcp gameobject modify "-81840" --search-method by_id --position 0 0 0 + +# By path +unity-mcp gameobject modify "/Canvas/Panel/Button" --search-method by_path --active + +# By tag +unity-mcp gameobject find "Player" --search-method by_tag +``` + +--- + +## Output Formats + +### Text (Default) +Human-readable nested format: +```bash +unity-mcp scene active +# Output: +# status: success +# result: +# name: New Scene +# path: Assets/Scenes/New Scene.unity +# ... +``` + +### JSON +Machine-readable JSON: +```bash +unity-mcp -f json scene active +# Output: {"status": "success", "result": {...}} +``` + +### Table +Key-value table format: +```bash +unity-mcp -f table scene active +# Output: +# Key | Value +# -------+------ +# status | success +# ... +``` + +--- + +## Command Reference by Category + +### Status & Connection + +```bash +# Check server connection and Unity instances +unity-mcp status + +# List connected Unity instances +unity-mcp instances +``` + +### Scene Commands + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy + +# Get active scene info +unity-mcp scene active + +# Get build settings +unity-mcp scene build-settings + +# Create new scene +unity-mcp scene create "MyScene" + +# Load scene +unity-mcp scene load "Assets/Scenes/MyScene.unity" + +# Save current scene +unity-mcp scene save + +# Take screenshot +unity-mcp scene screenshot +unity-mcp scene screenshot --filename "my_screenshot" --supersize 2 +``` + +### GameObject Commands + +```bash +# Find GameObjects +unity-mcp gameobject find "Player" +unity-mcp gameobject find "Enemy" --method by_tag +unity-mcp gameobject find "-81840" --method by_id +unity-mcp gameobject find "Rigidbody" --method by_component + +# Create GameObject +unity-mcp gameobject create "Empty" # Empty object +unity-mcp gameobject create "MyCube" --primitive Cube # Primitive +unity-mcp gameobject create "MyObj" --position 0 5 0 # With position +unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider" # With components + +# Modify GameObject +unity-mcp gameobject modify "Cube" --position 1 2 3 +unity-mcp gameobject modify "Cube" --rotation 0 45 0 +unity-mcp gameobject modify "Cube" --scale 2 2 2 +unity-mcp gameobject modify "Cube" --name "NewName" +unity-mcp gameobject modify "Cube" --active # Enable +unity-mcp gameobject modify "Cube" --inactive # Disable +unity-mcp gameobject modify "Cube" --tag "Player" +unity-mcp gameobject modify "Cube" --parent "Parent" + +# Delete GameObject +unity-mcp gameobject delete "Cube" +unity-mcp gameobject delete "Cube" --force # Skip confirmation + +# Duplicate GameObject +unity-mcp gameobject duplicate "Cube" + +# Move relative to another object +unity-mcp gameobject move "Cube" --reference "Player" --direction up --distance 2 +``` + +### Component Commands + +```bash +# Add component +unity-mcp component add "Cube" Rigidbody +unity-mcp component add "Cube" BoxCollider + +# Remove component +unity-mcp component remove "Cube" Rigidbody +unity-mcp component remove "Cube" Rigidbody --force # Skip confirmation + +# Set single property +unity-mcp component set "Cube" Rigidbody mass 5 +unity-mcp component set "Cube" Rigidbody useGravity false +unity-mcp component set "Cube" Light intensity 2.5 + +# Set multiple properties at once +unity-mcp component modify "Cube" Rigidbody --properties '{"mass": 5, "drag": 0.5}' +``` + +### Asset Commands + +```bash +# Search assets +unity-mcp asset search "Player" +unity-mcp asset search "t:Material" # By type +unity-mcp asset search "t:Prefab Player" # Combined + +# Get asset info +unity-mcp asset info "Assets/Materials/Red.mat" + +# Create asset +unity-mcp asset create "Assets/Materials/New.mat" Material + +# Delete asset +unity-mcp asset delete "Assets/Materials/Old.mat" +unity-mcp asset delete "Assets/Materials/Old.mat" --force # Skip confirmation + +# Move/Rename asset +unity-mcp asset move "Assets/Old/Mat.mat" "Assets/New/Mat.mat" +unity-mcp asset rename "Assets/Materials/Old.mat" "New" + +# Create folder +unity-mcp asset mkdir "Assets/NewFolder" + +# Import/reimport +unity-mcp asset import "Assets/Textures/image.png" +``` + +### Script Commands + +```bash +# Create script +unity-mcp script create "MyScript" --path "Assets/Scripts" +unity-mcp script create "MyScript" --path "Assets/Scripts" --type MonoBehaviour + +# Read script +unity-mcp script read "Assets/Scripts/MyScript.cs" + +# Delete script +unity-mcp script delete "Assets/Scripts/MyScript.cs" + +# Validate script +unity-mcp script validate "Assets/Scripts/MyScript.cs" +``` + +### Material Commands + +```bash +# Create material +unity-mcp material create "Assets/Materials/New.mat" +unity-mcp material create "Assets/Materials/New.mat" --shader "Standard" + +# Get material info +unity-mcp material info "Assets/Materials/Mat.mat" + +# Set color (R G B or R G B A) +unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0 +unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0 --property "_BaseColor" + +# Set shader property +unity-mcp material set-property "Assets/Materials/Mat.mat" "_Metallic" 0.5 + +# Assign to GameObject +unity-mcp material assign "Assets/Materials/Mat.mat" "Cube" +unity-mcp material assign "Assets/Materials/Mat.mat" "Cube" --slot 1 + +# Set renderer color directly +unity-mcp material set-renderer-color "Cube" 1 0 0 1 +``` + +### Editor Commands + +```bash +# Play mode control +unity-mcp editor play +unity-mcp editor pause +unity-mcp editor stop + +# Console +unity-mcp editor console # Read console +unity-mcp editor console --count 20 # Last 20 entries +unity-mcp editor console --clear # Clear console +unity-mcp editor console --types error,warning # Filter by type + +# Menu items +unity-mcp editor menu "Edit/Preferences" +unity-mcp editor menu "GameObject/Create Empty" + +# Tags and Layers +unity-mcp editor add-tag "Enemy" +unity-mcp editor remove-tag "Enemy" +unity-mcp editor add-layer "Interactable" +unity-mcp editor remove-layer "Interactable" + +# Editor tool +unity-mcp editor tool View +unity-mcp editor tool Move +unity-mcp editor tool Rotate + +# Run tests +unity-mcp editor tests +unity-mcp editor tests --mode PlayMode +``` + +### Prefab Commands + +```bash +# Create prefab from scene object +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" +unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" --overwrite + +# Open prefab for editing +unity-mcp prefab open "Assets/Prefabs/Player.prefab" + +# Save open prefab +unity-mcp prefab save + +# Close prefab stage +unity-mcp prefab close +``` + +### UI Commands + +```bash +# Create a Canvas (adds Canvas, CanvasScaler, GraphicRaycaster) +unity-mcp ui create-canvas "MainCanvas" +unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace + +# Create UI elements (must have a parent Canvas) +unity-mcp ui create-text "TitleText" --parent "MainCanvas" --text "Hello World" +unity-mcp ui create-button "StartButton" --parent "MainCanvas" --text "Click Me" +unity-mcp ui create-image "Background" --parent "MainCanvas" +``` + +### Lighting Commands + +```bash +# Create lights with type, color, intensity +unity-mcp lighting create "Sun" --type Directional +unity-mcp lighting create "Lamp" --type Point --intensity 2 --position 0 5 0 +unity-mcp lighting create "Spot" --type Spot --color 1 0 0 --intensity 3 +unity-mcp lighting create "GreenLight" --type Point --color 0 1 0 +``` + +### Audio Commands + +```bash +# Control AudioSource (target must have AudioSource component) +unity-mcp audio play "MusicPlayer" +unity-mcp audio stop "MusicPlayer" +unity-mcp audio volume "MusicPlayer" 0.5 +``` + +### Animation Commands + +```bash +# Control Animator (target must have Animator component) +unity-mcp animation play "Character" "Walk" +unity-mcp animation set-parameter "Character" "Speed" 1.5 --type float +unity-mcp animation set-parameter "Character" "IsRunning" true --type bool +unity-mcp animation set-parameter "Character" "Jump" "" --type trigger +``` + +### Code Commands + +```bash +# Read source files +unity-mcp code read "Assets/Scripts/Player.cs" +unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 +``` + +### Raw Commands + +For advanced usage, send raw tool calls: + +```bash +# Send any MCP tool directly +unity-mcp raw manage_scene '{"action": "get_active"}' +unity-mcp raw manage_gameobject '{"action": "create", "name": "Test"}' +unity-mcp raw manage_components '{"action": "add", "target": "Test", "componentType": "Rigidbody"}' +unity-mcp raw manage_editor '{"action": "play"}' +``` + +--- + +## Known Behaviors + +### Component Creation + +When creating GameObjects with components, the CLI creates the object first, then adds components separately. This is the correct workflow for Unity MCP. + +```bash +# This works correctly - creates object then adds components +unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider" + +# Equivalent to: +unity-mcp gameobject create "Player" +unity-mcp component add "Player" Rigidbody +unity-mcp component add "Player" BoxCollider +``` + +### Light Creation + +The `lighting create` command creates a complete light with the specified type, color, and intensity: + +```bash +# Creates Point light with green color and intensity 5 +unity-mcp lighting create "GreenLight" --type Point --color 0 1 0 --intensity 5 +``` + +### UI Element Creation + +UI commands automatically add the required components: + +```bash +# create-canvas adds: Canvas, CanvasScaler, GraphicRaycaster +unity-mcp ui create-canvas "MainUI" + +# create-button adds: Image, Button +unity-mcp ui create-button "MyButton" --parent "MainUI" +``` + +--- + +## Quick Reference Card + +### Multi-Value Syntax + +```bash +--position X Y Z # not "X,Y,Z" +--rotation X Y Z # not "X,Y,Z" +--scale X Y Z # not "X,Y,Z" +--color R G B # not "R,G,B" +``` + +### Argument Order (check --help) + +```bash +material assign MATERIAL_PATH TARGET +prefab create TARGET PATH +component set TARGET COMPONENT PROPERTY VALUE +``` + +### Search Methods + +```bash +--method by_name # default for gameobject find +--method by_id +--method by_path +--method by_tag +--method by_component +``` + +### Global Options Position + +```bash +unity-mcp [GLOBAL_OPTIONS] command subcommand [ARGS] [OPTIONS] +# ^^^^^^^^^^^^^^^^ +# Must come BEFORE command! +``` + +--- + +## Debugging Tips + +1. **Always check `--help`** for any command: + + ```bash + unity-mcp gameobject --help + unity-mcp gameobject modify --help + ``` + +2. **Use verbose mode** to see what's happening: + + ```bash + unity-mcp -v scene hierarchy + ``` + +3. **Use JSON output** for programmatic parsing: + + ```bash + unity-mcp -f json gameobject find "Player" | jq '.result' + ``` + +4. **Check connection first**: + + ```bash + unity-mcp status + ``` + +5. **When in doubt about properties**, use info commands: + + ```bash + unity-mcp material info "Assets/Materials/Mat.mat" + unity-mcp asset info "Assets/Prefabs/Player.prefab" + ``` diff --git a/Server/src/cli/__init__.py b/Server/src/cli/__init__.py new file mode 100644 index 00000000..6252f494 --- /dev/null +++ b/Server/src/cli/__init__.py @@ -0,0 +1,3 @@ +"""Unity MCP Command Line Interface.""" + +__version__ = "1.0.0" diff --git a/Server/src/cli/commands/__init__.py b/Server/src/cli/commands/__init__.py new file mode 100644 index 00000000..0ea06247 --- /dev/null +++ b/Server/src/cli/commands/__init__.py @@ -0,0 +1,3 @@ +"""CLI command modules.""" + +# Commands will be registered in main.py diff --git a/Server/src/cli/commands/animation.py b/Server/src/cli/commands/animation.py new file mode 100644 index 00000000..19de4b2b --- /dev/null +++ b/Server/src/cli/commands/animation.py @@ -0,0 +1,86 @@ +"""Animation CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def animation(): + """Animation operations - control Animator, play animations.""" + pass + + +@animation.command("play") +@click.argument("target") +@click.argument("state_name") +@click.option( + "--layer", "-l", + default=0, + type=int, + help="Animator layer(TODO)." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def play(target: str, state_name: str, layer: int, search_method: Optional[str]): + """Play an animation state on a target's Animator. + + \b + Examples: + unity-mcp animation play "Player" "Walk" + unity-mcp animation play "Enemy" "Attack" --layer 1 + """ + config = get_config() + + # Set Animator parameter to trigger state + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "Animator", + "property": "Play", + "value": state_name, + "layer": layer, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@animation.command("set-parameter") +@click.argument("target") +@click.argument("param_name") +@click.argument("value") +@click.option( + "--type", "-t", + "param_type", + type=click.Choice(["float", "int", "bool", "trigger"]), + default="float", + help="Parameter type." +) +def set_parameter(target: str, param_name: str, value: str, param_type: str): + """Set an Animator parameter. + + \b + Examples: + unity-mcp animation set-parameter "Player" "Speed" 5.0 + unity-mcp animation set-parameter "Player" "IsRunning" true --type bool + unity-mcp animation set-parameter "Player" "Jump" "" --type trigger + """ + config = get_config() + print_info("Animation parameter command - requires custom Unity implementation") + click.echo(f"Would set {param_name}={value} ({param_type}) on {target}") diff --git a/Server/src/cli/commands/asset.py b/Server/src/cli/commands/asset.py new file mode 100644 index 00000000..b807bc4c --- /dev/null +++ b/Server/src/cli/commands/asset.py @@ -0,0 +1,307 @@ +"""Asset CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def asset(): + """Asset operations - search, import, create, delete assets.""" + pass + + +@asset.command("search") +@click.argument("pattern", default="*") +@click.option( + "--path", "-p", + default="Assets", + help="Folder path to search in." +) +@click.option( + "--type", "-t", + "filter_type", + default=None, + help="Filter by asset type (e.g., Material, Prefab, MonoScript)." +) +@click.option( + "--limit", "-l", + default=25, + type=int, + help="Maximum results per page." +) +@click.option( + "--page", + default=1, + type=int, + help="Page number (1-based)." +) +def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page: int): + """Search for assets. + + \b + Examples: + unity-mcp asset search "*.prefab" + unity-mcp asset search "Player*" --path "Assets/Characters" + unity-mcp asset search "*" --type Material + unity-mcp asset search "t:MonoScript" --path "Assets/Scripts" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "search", + "path": path, + "searchPattern": pattern, + "pageSize": limit, + "pageNumber": page, + } + + if filter_type: + params["filterType"] = filter_type + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("info") +@click.argument("path") +@click.option( + "--preview", + is_flag=True, + help="Generate preview thumbnail (may be large)." +) +def info(path: str, preview: bool): + """Get detailed information about an asset. + + \b + Examples: + unity-mcp asset info "Assets/Materials/Red.mat" + unity-mcp asset info "Assets/Prefabs/Player.prefab" --preview + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_info", + "path": path, + "generatePreview": preview, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("create") +@click.argument("path") +@click.argument("asset_type") +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON.' +) +def create(path: str, asset_type: str, properties: Optional[str]): + """Create a new asset. + + \b + Examples: + unity-mcp asset create "Assets/Materials/Blue.mat" Material + unity-mcp asset create "Assets/NewFolder" Folder + unity-mcp asset create "Assets/Materials/Custom.mat" Material --properties '{"color": [0,0,1,1]}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "path": path, + "assetType": asset_type, + } + + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created {asset_type}: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(path: str, force: bool): + """Delete an asset. + + \b + Examples: + unity-mcp asset delete "Assets/OldMaterial.mat" + unity-mcp asset delete "Assets/Unused" --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete asset '{path}'?", abort=True) + + try: + result = run_command("manage_asset", {"action": "delete", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("duplicate") +@click.argument("source") +@click.argument("destination") +def duplicate(source: str, destination: str): + """Duplicate an asset. + + \b + Examples: + unity-mcp asset duplicate "Assets/Materials/Red.mat" "Assets/Materials/RedCopy.mat" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "duplicate", + "path": source, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Duplicated to: {destination}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("move") +@click.argument("source") +@click.argument("destination") +def move(source: str, destination: str): + """Move an asset to a new location. + + \b + Examples: + unity-mcp asset move "Assets/Old/Material.mat" "Assets/New/Material.mat" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "move", + "path": source, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Moved to: {destination}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("rename") +@click.argument("path") +@click.argument("new_name") +def rename(path: str, new_name: str): + """Rename an asset. + + \b + Examples: + unity-mcp asset rename "Assets/Materials/Old.mat" "New.mat" + """ + config = get_config() + + # Construct destination path + import os + dir_path = os.path.dirname(path) + destination = os.path.join(dir_path, new_name).replace("\\", "/") + + params: dict[str, Any] = { + "action": "rename", + "path": path, + "destination": destination, + } + + try: + result = run_command("manage_asset", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Renamed to: {new_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("import") +@click.argument("path") +def import_asset(path: str): + """Import/reimport an asset. + + \b + Examples: + unity-mcp asset import "Assets/Textures/NewTexture.png" + """ + config = get_config() + + try: + result = run_command("manage_asset", {"action": "import", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Imported: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@asset.command("mkdir") +@click.argument("path") +def mkdir(path: str): + """Create a folder. + + \b + Examples: + unity-mcp asset mkdir "Assets/NewFolder" + unity-mcp asset mkdir "Assets/Levels/Chapter1" + """ + config = get_config() + + try: + result = run_command("manage_asset", {"action": "create_folder", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created folder: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/audio.py b/Server/src/cli/commands/audio.py new file mode 100644 index 00000000..7ee47227 --- /dev/null +++ b/Server/src/cli/commands/audio.py @@ -0,0 +1,133 @@ +"""Audio CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def audio(): + """Audio operations - AudioSource control, audio settings.""" + pass + + +@audio.command("play") +@click.argument("target") +@click.option( + "--clip", "-c", + default=None, + help="Audio clip path to play." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def play(target: str, clip: Optional[str], search_method: Optional[str]): + """Play audio on a target's AudioSource. + + \b + Examples: + unity-mcp audio play "MusicPlayer" + unity-mcp audio play "SFXSource" --clip "Assets/Audio/explosion.wav" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "Play", + "value": True, + } + + if clip: + params["clip"] = clip + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@audio.command("stop") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def stop(target: str, search_method: Optional[str]): + """Stop audio on a target's AudioSource. + + \b + Examples: + unity-mcp audio stop "MusicPlayer" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "Stop", + "value": True, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@audio.command("volume") +@click.argument("target") +@click.argument("level", type=float) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_id"]), + default=None, + help="How to find the target." +) +def volume(target: str, level: float, search_method: Optional[str]): + """Set audio volume on a target's AudioSource. + + \b + Examples: + unity-mcp audio volume "MusicPlayer" 0.5 + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": "AudioSource", + "property": "volume", + "value": level, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py new file mode 100644 index 00000000..86e06e7f --- /dev/null +++ b/Server/src/cli/commands/code.py @@ -0,0 +1,72 @@ +"""Code CLI commands - read source code. search might be implemented later (but can be totally achievable with AI).""" + +import sys +import os +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def code(): + """Code operations - read source files.""" + pass + + +@code.command("read") +@click.argument("path") +@click.option( + "--start-line", "-s", + default=None, + type=int, + help="Starting line number (1-based)." +) +@click.option( + "--line-count", "-n", + default=None, + type=int, + help="Number of lines to read." +) +def read(path: str, start_line: Optional[int], line_count: Optional[int]): + """Read a source file. + + \b + Examples: + unity-mcp code read "Assets/Scripts/Player.cs" + unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 + """ + config = get_config() + + # Extract name and directory from path + parts = path.replace("\\", "/").split("/") + filename = os.path.splitext(parts[-1])[0] + directory = "/".join(parts[:-1]) or "Assets" + + params: dict[str, Any] = { + "action": "read", + "name": filename, + "path": directory, + } + + if start_line: + params["startLine"] = start_line + if line_count: + params["lineCount"] = line_count + + try: + result = run_command("manage_script", params, config) + # For read, output content directly if available + if result.get("success") and result.get("data"): + data = result.get("data", {}) + if isinstance(data, dict) and "contents" in data: + click.echo(data["contents"]) + else: + click.echo(format_output(result, config.format)) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/component.py b/Server/src/cli/commands/component.py new file mode 100644 index 00000000..13e53940 --- /dev/null +++ b/Server/src/cli/commands/component.py @@ -0,0 +1,212 @@ +"""Component CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def component(): + """Component operations - add, remove, modify components on GameObjects.""" + pass + + +@component.command("add") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON (e.g., \'{"mass": 5.0}\').' +) +def add(target: str, component_type: str, search_method: Optional[str], properties: Optional[str]): + """Add a component to a GameObject. + + \b + Examples: + unity-mcp component add "Player" Rigidbody + unity-mcp component add "-81840" BoxCollider --search-method by_id + unity-mcp component add "Enemy" Rigidbody --properties '{"mass": 5.0, "useGravity": true}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "add", + "target": target, + "componentType": component_type, + } + + if search_method: + params["searchMethod"] = search_method + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added {component_type} to '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("remove") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def remove(target: str, component_type: str, search_method: Optional[str], force: bool): + """Remove a component from a GameObject. + + \b + Examples: + unity-mcp component remove "Player" Rigidbody + unity-mcp component remove "-81840" BoxCollider --search-method by_id --force + """ + config = get_config() + + if not force: + click.confirm(f"Remove {component_type} from '{target}'?", abort=True) + + params: dict[str, Any] = { + "action": "remove", + "target": target, + "componentType": component_type, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed {component_type} from '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("set") +@click.argument("target") +@click.argument("component_type") +@click.argument("property_name") +@click.argument("value") +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +def set_property(target: str, component_type: str, property_name: str, value: str, search_method: Optional[str]): + """Set a single property on a component. + + \b + Examples: + unity-mcp component set "Player" Rigidbody mass 5.0 + unity-mcp component set "Enemy" Transform position "[0, 5, 0]" + unity-mcp component set "-81840" Light intensity 2.5 --search-method by_id + """ + config = get_config() + + # Try to parse value as JSON for complex types + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + # Keep as string if not valid JSON + parsed_value = value + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": component_type, + "property": property_name, + "value": parsed_value, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set {component_type}.{property_name} = {value}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@component.command("modify") +@click.argument("target") +@click.argument("component_type") +@click.option( + "--properties", "-p", + required=True, + help='Properties to set as JSON (e.g., \'{"mass": 5.0, "useGravity": false}\').' +) +@click.option( + "--search-method", + type=click.Choice(["by_id", "by_name", "by_path"]), + default=None, + help="How to find the target GameObject." +) +def modify(target: str, component_type: str, properties: str, search_method: Optional[str]): + """Set multiple properties on a component at once. + + \b + Examples: + unity-mcp component modify "Player" Rigidbody --properties '{"mass": 5.0, "useGravity": false}' + unity-mcp component modify "Light" Light --properties '{"intensity": 2.0, "color": [1, 0, 0, 1]}' + """ + config = get_config() + + try: + props_dict = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "action": "set_property", + "target": target, + "componentType": component_type, + "properties": props_dict, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_components", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Modified {component_type} on '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py new file mode 100644 index 00000000..ea35f3dc --- /dev/null +++ b/Server/src/cli/commands/editor.py @@ -0,0 +1,299 @@ +"""Editor CLI commands.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def editor(): + """Editor operations - play mode, console, tags, layers.""" + pass + + +@editor.command("play") +def play(): + """Enter play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "play"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Entered play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("pause") +def pause(): + """Pause play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "pause"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Paused play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("stop") +def stop(): + """Stop play mode.""" + config = get_config() + + try: + result = run_command("manage_editor", {"action": "stop"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Stopped play mode") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("console") +@click.option( + "--type", "-t", + "log_types", + multiple=True, + type=click.Choice(["error", "warning", "log", "all"]), + default=["error", "warning", "log"], + help="Message types to retrieve." +) +@click.option( + "--count", "-n", + default=10, + type=int, + help="Number of messages to retrieve." +) +@click.option( + "--filter", "-f", + "filter_text", + default=None, + help="Filter messages containing this text." +) +@click.option( + "--stacktrace", "-s", + is_flag=True, + help="Include stack traces." +) +@click.option( + "--clear", + is_flag=True, + help="Clear the console instead of reading." +) +def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace: bool, clear: bool): + """Read or clear the Unity console. + + \b + Examples: + unity-mcp editor console + unity-mcp editor console --type error --count 20 + unity-mcp editor console --filter "NullReference" --stacktrace + unity-mcp editor console --clear + """ + config = get_config() + + if clear: + try: + result = run_command("read_console", {"action": "clear"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Console cleared") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + return + + params: dict[str, Any] = { + "action": "get", + "types": list(log_types), + "count": count, + "include_stacktrace": stacktrace, + } + + if filter_text: + params["filter_text"] = filter_text + + try: + result = run_command("read_console", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("add-tag") +@click.argument("tag_name") +def add_tag(tag_name: str): + """Add a new tag. + + \b + Examples: + unity-mcp editor add-tag "Enemy" + unity-mcp editor add-tag "Collectible" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "add_tag", "tagName": tag_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added tag: {tag_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("remove-tag") +@click.argument("tag_name") +def remove_tag(tag_name: str): + """Remove a tag. + + \b + Examples: + unity-mcp editor remove-tag "OldTag" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "remove_tag", "tagName": tag_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed tag: {tag_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("add-layer") +@click.argument("layer_name") +def add_layer(layer_name: str): + """Add a new layer. + + \b + Examples: + unity-mcp editor add-layer "Interactable" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "add_layer", "layerName": layer_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Added layer: {layer_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("remove-layer") +@click.argument("layer_name") +def remove_layer(layer_name: str): + """Remove a layer. + + \b + Examples: + unity-mcp editor remove-layer "OldLayer" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "remove_layer", "layerName": layer_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Removed layer: {layer_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("tool") +@click.argument("tool_name") +def set_tool(tool_name: str): + """Set the active editor tool. + + \b + Examples: + unity-mcp editor tool "Move" + unity-mcp editor tool "Rotate" + unity-mcp editor tool "Scale" + """ + config = get_config() + + try: + result = run_command("manage_editor", {"action": "set_active_tool", "toolName": tool_name}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set active tool: {tool_name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("menu") +@click.argument("menu_path") +def execute_menu(menu_path: str): + """Execute a menu item. + + \b + Examples: + unity-mcp editor menu "File/Save" + unity-mcp editor menu "Edit/Undo" + unity-mcp editor menu "GameObject/Create Empty" + """ + config = get_config() + + try: + result = run_command("execute_menu_item", {"menu_path": menu_path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Executed: {menu_path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("tests") +@click.option( + "--mode", "-m", + type=click.Choice(["EditMode", "PlayMode"]), + default="EditMode", + help="Test mode to run." +) +@click.option( + "--timeout", "-t", + default=None, + type=int, + help="Timeout in seconds." +) +def run_tests(mode: str, timeout: Optional[int]): + """Run Unity tests. + + \b + Examples: + unity-mcp editor tests + unity-mcp editor tests --mode PlayMode + unity-mcp editor tests --timeout 60 + """ + config = get_config() + + params: dict[str, Any] = {"mode": mode} + if timeout: + params["timeout_seconds"] = timeout + + try: + result = run_command("run_tests", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/gameobject.py b/Server/src/cli/commands/gameobject.py new file mode 100644 index 00000000..083ba447 --- /dev/null +++ b/Server/src/cli/commands/gameobject.py @@ -0,0 +1,503 @@ +"""GameObject CLI commands.""" + +import sys +import json +import click +from typing import Optional, Tuple, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success, print_warning +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def gameobject(): + """GameObject operations - create, find, modify, delete GameObjects.""" + pass + + +@gameobject.command("find") +@click.argument("search_term") +@click.option( + "--method", "-m", + type=click.Choice(["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"]), + default="by_name", + help="Search method." +) +@click.option( + "--include-inactive", "-i", + is_flag=True, + help="Include inactive GameObjects." +) +@click.option( + "--limit", "-l", + default=50, + type=int, + help="Maximum results to return." +) +@click.option( + "--cursor", "-c", + default=0, + type=int, + help="Pagination cursor (offset)." +) +def find(search_term: str, method: str, include_inactive: bool, limit: int, cursor: int): + """Find GameObjects by search criteria. + + \b + Examples: + unity-mcp gameobject find "Player" + unity-mcp gameobject find "Enemy" --method by_tag + unity-mcp gameobject find "-81840" --method by_id + unity-mcp gameobject find "Rigidbody" --method by_component + unity-mcp gameobject find "/Canvas/Panel" --method by_path + """ + config = get_config() + + try: + result = run_command("find_gameobjects", { + "searchMethod": method, + "searchTerm": search_term, + "includeInactive": include_inactive, + "pageSize": limit, + "cursor": cursor, + }, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("create") +@click.argument("name") +@click.option( + "--primitive", "-p", + type=click.Choice(["Cube", "Sphere", "Cylinder", "Plane", "Capsule", "Quad"]), + help="Create a primitive type." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=None, + help="Position as X Y Z." +) +@click.option( + "--rotation", "-rot", + nargs=3, + type=float, + default=None, + help="Rotation as X Y Z (euler angles)." +) +@click.option( + "--scale", "-s", + nargs=3, + type=float, + default=None, + help="Scale as X Y Z." +) +@click.option( + "--parent", + default=None, + help="Parent GameObject name or path." +) +@click.option( + "--tag", "-t", + default=None, + help="Tag to assign." +) +@click.option( + "--layer", + default=None, + help="Layer to assign." +) +@click.option( + "--components", + default=None, + help="Comma-separated list of components to add." +) +@click.option( + "--save-prefab", + is_flag=True, + help="Save as prefab after creation." +) +@click.option( + "--prefab-path", + default=None, + help="Path for prefab (e.g., Assets/Prefabs/MyPrefab.prefab)." +) +def create( + name: str, + primitive: Optional[str], + position: Optional[Tuple[float, float, float]], + rotation: Optional[Tuple[float, float, float]], + scale: Optional[Tuple[float, float, float]], + parent: Optional[str], + tag: Optional[str], + layer: Optional[str], + components: Optional[str], + save_prefab: bool, + prefab_path: Optional[str], +): + """Create a new GameObject. + + \b + Examples: + unity-mcp gameobject create "MyCube" --primitive Cube + unity-mcp gameobject create "Player" --position 0 1 0 + unity-mcp gameobject create "Enemy" --primitive Sphere --tag Enemy + unity-mcp gameobject create "Child" --parent "ParentObject" + unity-mcp gameobject create "Item" --components "Rigidbody,BoxCollider" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + } + + if primitive: + params["primitiveType"] = primitive + if position: + params["position"] = list(position) + if rotation: + params["rotation"] = list(rotation) + if scale: + params["scale"] = list(scale) + if parent: + params["parent"] = parent + if tag: + params["tag"] = tag + if layer: + params["layer"] = layer + if save_prefab: + params["saveAsPrefab"] = True + if prefab_path: + params["prefabPath"] = prefab_path + + try: + result = run_command("manage_gameobject", params, config) + + # Add components separately since componentsToAdd doesn't work + if components and (result.get("success") or result.get("data") or result.get("result")): + component_list = [c.strip() for c in components.split(",")] + failed_components = [] + for component in component_list: + try: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + except UnityConnectionError: + failed_components.append(component) + if failed_components: + print_warning(f"Failed to add components: {', '.join(failed_components)}") + + click.echo(format_output(result, config.format)) + if result.get("success") or result.get("result"): + print_success(f"Created GameObject '{name}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("modify") +@click.argument("target") +@click.option( + "--name", "-n", + default=None, + help="New name for the GameObject." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=None, + help="New position as X Y Z." +) +@click.option( + "--rotation", "-rot", + nargs=3, + type=float, + default=None, + help="New rotation as X Y Z (euler angles)." +) +@click.option( + "--scale", "-s", + nargs=3, + type=float, + default=None, + help="New scale as X Y Z." +) +@click.option( + "--parent", + default=None, + help="New parent GameObject." +) +@click.option( + "--tag", "-t", + default=None, + help="New tag." +) +@click.option( + "--layer", + default=None, + help="New layer." +) +@click.option( + "--active/--inactive", + default=None, + help="Set active state." +) +@click.option( + "--add-components", + default=None, + help="Comma-separated list of components to add." +) +@click.option( + "--remove-components", + default=None, + help="Comma-separated list of components to remove." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def modify( + target: str, + name: Optional[str], + position: Optional[Tuple[float, float, float]], + rotation: Optional[Tuple[float, float, float]], + scale: Optional[Tuple[float, float, float]], + parent: Optional[str], + tag: Optional[str], + layer: Optional[str], + active: Optional[bool], + add_components: Optional[str], + remove_components: Optional[str], + search_method: Optional[str], +): + """Modify an existing GameObject. + + TARGET can be a name, path, instance ID, or tag depending on --search-method. + + \b + Examples: + unity-mcp gameobject modify "Player" --position 0 5 0 + unity-mcp gameobject modify "Enemy" --name "Boss" --tag "Boss" + unity-mcp gameobject modify "-81840" --search-method by_id --active + unity-mcp gameobject modify "/Canvas/Panel" --search-method by_path --inactive + unity-mcp gameobject modify "Cube" --add-components "Rigidbody,BoxCollider" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "modify", + "target": target, + } + + if name: + params["name"] = name + if position: + params["position"] = list(position) + if rotation: + params["rotation"] = list(rotation) + if scale: + params["scale"] = list(scale) + if parent: + params["parent"] = parent + if tag: + params["tag"] = tag + if layer: + params["layer"] = layer + if active is not None: + params["setActive"] = active + if add_components: + params["componentsToAdd"] = [c.strip() for c in add_components.split(",")] + if remove_components: + params["componentsToRemove"] = [c.strip() for c in remove_components.split(",")] + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("delete") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(target: str, search_method: Optional[str], force: bool): + """Delete a GameObject. + + \b + Examples: + unity-mcp gameobject delete "OldObject" + unity-mcp gameobject delete "-81840" --search-method by_id + unity-mcp gameobject delete "TempObjects" --search-method by_tag --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete GameObject '{target}'?", abort=True) + + params = { + "action": "delete", + "target": target, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted GameObject '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("duplicate") +@click.argument("target") +@click.option( + "--name", "-n", + default=None, + help="Name for the duplicate (default: OriginalName_Copy)." +) +@click.option( + "--offset", + nargs=3, + type=float, + default=None, + help="Position offset from original as X Y Z." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def duplicate( + target: str, + name: Optional[str], + offset: Optional[Tuple[float, float, float]], + search_method: Optional[str], +): + """Duplicate a GameObject. + + \b + Examples: + unity-mcp gameobject duplicate "Player" + unity-mcp gameobject duplicate "Enemy" --name "Enemy2" --offset 5 0 0 + unity-mcp gameobject duplicate "-81840" --search-method by_id + """ + config = get_config() + + params: dict[str, Any] = { + "action": "duplicate", + "target": target, + } + + if name: + params["new_name"] = name + if offset: + params["offset"] = list(offset) + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Duplicated GameObject '{target}'") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@gameobject.command("move") +@click.argument("target") +@click.option( + "--reference", "-r", + required=True, + help="Reference object for relative movement." +) +@click.option( + "--direction", "-d", + type=click.Choice(["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"]), + required=True, + help="Direction to move." +) +@click.option( + "--distance", + type=float, + default=1.0, + help="Distance to move (default: 1.0)." +) +@click.option( + "--local", + is_flag=True, + help="Use reference object's local space instead of world space." +) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_id"]), + default=None, + help="How to find the target GameObject." +) +def move( + target: str, + reference: str, + direction: str, + distance: float, + local: bool, + search_method: Optional[str], +): + """Move a GameObject relative to another object. + + \b + Examples: + unity-mcp gameobject move "Chair" --reference "Table" --direction right --distance 2 + unity-mcp gameobject move "Light" --reference "Player" --direction up --distance 3 + unity-mcp gameobject move "NPC" --reference "Player" --direction forward --local + """ + config = get_config() + + params: dict[str, Any] = { + "action": "move_relative", + "target": target, + "reference_object": reference, + "direction": direction, + "distance": distance, + "world_space": not local, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_gameobject", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Moved '{target}' {direction} of '{reference}' by {distance} units") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py new file mode 100644 index 00000000..4010bdd0 --- /dev/null +++ b/Server/src/cli/commands/lighting.py @@ -0,0 +1,128 @@ +"""Lighting CLI commands.""" + +import sys +import click +from typing import Optional, Tuple + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def lighting(): + """Lighting operations - create, modify lights and lighting settings.""" + pass + + +@lighting.command("create") +@click.argument("name") +@click.option( + "--type", "-t", + "light_type", + type=click.Choice(["Directional", "Point", "Spot", "Area"]), + default="Point", + help="Type of light to create." +) +@click.option( + "--position", "-pos", + nargs=3, + type=float, + default=(0, 3, 0), + help="Position as X Y Z." +) +@click.option( + "--color", "-c", + nargs=3, + type=float, + default=None, + help="Color as R G B (0-1)." +) +@click.option( + "--intensity", "-i", + default=None, + type=float, + help="Light intensity." +) +def create(name: str, light_type: str, position: Tuple[float, float, float], color: Optional[Tuple[float, float, float]], intensity: Optional[float]): + """Create a new light. + + \b + Examples: + unity-mcp lighting create "MainLight" --type Directional + unity-mcp lighting create "PointLight1" --position 0 5 0 --intensity 2 + unity-mcp lighting create "RedLight" --type Spot --color 1 0 0 + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with position + create_result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "position": list(position), + }, config) + + if not (create_result.get("success")): + click.echo(format_output(create_result, config.format)) + return + + # Step 2: Add Light component using manage_components + add_result = run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "Light", + }, config) + + if not add_result.get("success"): + click.echo(format_output(add_result, config.format)) + return + + # Step 3: Set light type using manage_components set_property + type_result = run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "type", + "value": light_type, + }, config) + + if not type_result.get("success"): + click.echo(format_output(type_result, config.format)) + return + + # Step 4: Set color if provided + if color: + color_result = run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "color", + "value": {"r": color[0], "g": color[1], "b": color[2], "a": 1}, + }, config) + + if not color_result.get("success"): + click.echo(format_output(color_result, config.format)) + return + + # Step 5: Set intensity if provided + if intensity is not None: + intensity_result = run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Light", + "property": "intensity", + "value": intensity, + }, config) + + if not intensity_result.get("success"): + click.echo(format_output(intensity_result, config.format)) + return + + # Output the result + click.echo(format_output(create_result, config.format)) + print_success(f"Created {light_type} light: {name}") + + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/material.py b/Server/src/cli/commands/material.py new file mode 100644 index 00000000..d1d0d4d5 --- /dev/null +++ b/Server/src/cli/commands/material.py @@ -0,0 +1,266 @@ +"""Material CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any, Tuple + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def material(): + """Material operations - create, modify, assign materials.""" + pass + + +@material.command("info") +@click.argument("path") +def info(path: str): + """Get information about a material. + + \b + Examples: + unity-mcp material info "Assets/Materials/Red.mat" + """ + config = get_config() + + try: + result = run_command("manage_material", { + "action": "get_material_info", + "materialPath": path, + }, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("create") +@click.argument("path") +@click.option( + "--shader", "-s", + default="Standard", + help="Shader to use (default: Standard)." +) +@click.option( + "--properties", "-p", + default=None, + help='Initial properties as JSON.' +) +def create(path: str, shader: str, properties: Optional[str]): + """Create a new material. + + \b + Examples: + unity-mcp material create "Assets/Materials/NewMat.mat" + unity-mcp material create "Assets/Materials/Red.mat" --shader "Universal Render Pipeline/Lit" + unity-mcp material create "Assets/Materials/Blue.mat" --properties '{"_Color": [0,0,1,1]}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "materialPath": path, + "shader": shader, + } + + if properties: + try: + params["properties"] = json.loads(properties) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for properties: {e}") + sys.exit(1) + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created material: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-color") +@click.argument("path") +@click.argument("r", type=float) +@click.argument("g", type=float) +@click.argument("b", type=float) +@click.argument("a", type=float, default=1.0, show_default=True) +@click.option( + "--property", "-p", + default="_Color", + help="Color property name (default: _Color)." +) +def set_color(path: str, r: float, g: float, b: float, a: float, property: str): + """Set a material's color. + + \b + Examples: + unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0 + unity-mcp material set-color "Assets/Materials/Blue.mat" 0 0 1 0.5 + unity-mcp material set-color "Assets/Materials/Mat.mat" 1 1 0 --property "_BaseColor" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_material_color", + "materialPath": path, + "property": property, + "color": [r, g, b, a], + } + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set color on: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-property") +@click.argument("path") +@click.argument("property_name") +@click.argument("value") +def set_property(path: str, property_name: str, value: str): + """Set a shader property on a material. + + \b + Examples: + unity-mcp material set-property "Assets/Materials/Mat.mat" _Metallic 0.5 + unity-mcp material set-property "Assets/Materials/Mat.mat" _Smoothness 0.8 + unity-mcp material set-property "Assets/Materials/Mat.mat" _MainTex "Assets/Textures/Tex.png" + """ + config = get_config() + + # Try to parse value as JSON for complex types + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + # Try to parse as number + try: + parsed_value = float(value) + except ValueError: + parsed_value = value + + params: dict[str, Any] = { + "action": "set_material_shader_property", + "materialPath": path, + "property": property_name, + "value": parsed_value, + } + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set {property_name} on: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("assign") +@click.argument("material_path") +@click.argument("target") +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--slot", "-s", + default=0, + type=int, + help="Material slot index (default: 0)." +) +@click.option( + "--mode", "-m", + type=click.Choice(["shared", "instance", "property_block"]), + default="shared", + help="Assignment mode." +) +def assign(material_path: str, target: str, search_method: Optional[str], slot: int, mode: str): + """Assign a material to a GameObject's renderer. + + \b + Examples: + unity-mcp material assign "Assets/Materials/Red.mat" "Cube" + unity-mcp material assign "Assets/Materials/Blue.mat" "Player" --mode instance + unity-mcp material assign "Assets/Materials/Mat.mat" "-81840" --search-method by_id --slot 1 + """ + config = get_config() + + params: dict[str, Any] = { + "action": "assign_material_to_renderer", + "materialPath": material_path, + "target": target, + "slot": slot, + "mode": mode, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Assigned material to: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@material.command("set-renderer-color") +@click.argument("target") +@click.argument("r", type=float) +@click.argument("g", type=float) +@click.argument("b", type=float) +@click.argument("a", type=float, default=1.0) +@click.option( + "--search-method", + type=click.Choice(["by_name", "by_path", "by_tag", "by_layer", "by_component"]), + default=None, + help="How to find the target GameObject." +) +@click.option( + "--mode", "-m", + type=click.Choice(["shared", "instance", "property_block"]), + default="property_block", + help="Modification mode." +) +def set_renderer_color(target: str, r: float, g: float, b: float, a: float, search_method: Optional[str], mode: str): + """Set a renderer's material color directly. + + \b + Examples: + unity-mcp material set-renderer-color "Cube" 1 0 0 + unity-mcp material set-renderer-color "Player" 0 1 0 --mode instance + """ + config = get_config() + + params: dict[str, Any] = { + "action": "set_renderer_color", + "target": target, + "color": [r, g, b, a], + "mode": mode, + } + + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_material", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set renderer color on: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py new file mode 100644 index 00000000..3191c11e --- /dev/null +++ b/Server/src/cli/commands/prefab.py @@ -0,0 +1,143 @@ +"""Prefab CLI commands.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def prefab(): + """Prefab operations - open, save, create prefabs.""" + pass + + +@prefab.command("open") +@click.argument("path") +@click.option( + "--mode", "-m", + default="InIsolation", + help="Prefab stage mode (InIsolation)." +) +def open_stage(path: str, mode: str): + """Open a prefab in the prefab stage for editing. + + \b + Examples: + unity-mcp prefab open "Assets/Prefabs/Player.prefab" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "open_stage", + "prefabPath": path, + "mode": mode, + } + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Opened prefab: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("close") +@click.option( + "--save", "-s", + is_flag=True, + help="Save the prefab before closing." +) +def close_stage(save: bool): + """Close the current prefab stage. + + \b + Examples: + unity-mcp prefab close + unity-mcp prefab close --save + """ + config = get_config() + + params: dict[str, Any] = { + "action": "close_stage", + } + if save: + params["saveBeforeClose"] = True + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Closed prefab stage") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("save") +def save_stage(): + """Save the currently open prefab stage. + + \b + Examples: + unity-mcp prefab save + """ + config = get_config() + + try: + result = run_command("manage_prefabs", {"action": "save_open_stage"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Saved prefab") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("create") +@click.argument("target") +@click.argument("path") +@click.option( + "--overwrite", + is_flag=True, + help="Overwrite existing prefab at path." +) +@click.option( + "--include-inactive", + is_flag=True, + help="Include inactive objects when finding target." +) +def create(target: str, path: str, overwrite: bool, include_inactive: bool): + """Create a prefab from a scene GameObject. + + \b + Examples: + unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab" + unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create_from_gameobject", + "target": target, + "prefabPath": path, + } + + if overwrite: + params["allowOverwrite"] = True + if include_inactive: + params["searchInactive"] = True + + try: + result = run_command("manage_prefabs", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created prefab: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py new file mode 100644 index 00000000..a04b2044 --- /dev/null +++ b/Server/src/cli/commands/scene.py @@ -0,0 +1,254 @@ +"""Scene CLI commands.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def scene(): + """Scene operations - hierarchy, load, save, create scenes.""" + pass + + +@scene.command("hierarchy") +@click.option( + "--parent", + default=None, + help="Parent GameObject to list children of (name, path, or instance ID)." +) +@click.option( + "--max-depth", "-d", + default=None, + type=int, + help="Maximum depth to traverse." +) +@click.option( + "--include-transform", "-t", + is_flag=True, + help="Include transform data for each node." +) +@click.option( + "--limit", "-l", + default=50, + type=int, + help="Maximum nodes to return." +) +@click.option( + "--cursor", "-c", + default=0, + type=int, + help="Pagination cursor." +) +def hierarchy( + parent: Optional[str], + max_depth: Optional[int], + include_transform: bool, + limit: int, + cursor: int, +): + """Get the scene hierarchy. + + \b + Examples: + unity-mcp scene hierarchy + unity-mcp scene hierarchy --max-depth 3 + unity-mcp scene hierarchy --parent "Canvas" --include-transform + unity-mcp scene hierarchy --format json + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_hierarchy", + "pageSize": limit, + "cursor": cursor, + } + + if parent: + params["parent"] = parent + if max_depth is not None: + params["maxDepth"] = max_depth + if include_transform: + params["includeTransform"] = True + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("active") +def active(): + """Get information about the active scene.""" + config = get_config() + + try: + result = run_command("manage_scene", {"action": "get_active"}, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("load") +@click.argument("scene") +@click.option( + "--by-index", "-i", + is_flag=True, + help="Load by build index instead of path/name." +) +def load(scene: str, by_index: bool): + """Load a scene. + + \b + Examples: + unity-mcp scene load "Assets/Scenes/Main.unity" + unity-mcp scene load "MainScene" + unity-mcp scene load 0 --by-index + """ + config = get_config() + + params: dict[str, Any] = {"action": "load"} + + if by_index: + try: + params["buildIndex"] = int(scene) + except ValueError: + print_error(f"Invalid build index: {scene}") + sys.exit(1) + else: + if scene.endswith(".unity"): + params["path"] = scene + else: + params["name"] = scene + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Loaded scene: {scene}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("save") +@click.option( + "--path", + default=None, + help="Path to save the scene to (for new scenes)." +) +def save(path: Optional[str]): + """Save the current scene. + + \b + Examples: + unity-mcp scene save + unity-mcp scene save --path "Assets/Scenes/NewScene.unity" + """ + config = get_config() + + params: dict[str, Any] = {"action": "save"} + if path: + params["path"] = path + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Scene saved") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("create") +@click.argument("name") +@click.option( + "--path", + default=None, + help="Path to create the scene at." +) +def create(name: str, path: Optional[str]): + """Create a new scene. + + \b + Examples: + unity-mcp scene create "NewLevel" + unity-mcp scene create "TestScene" --path "Assets/Scenes/Test" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + } + if path: + params["path"] = path + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created scene: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("build-settings") +def build_settings(): + """Get scenes in build settings.""" + config = get_config() + + try: + result = run_command("manage_scene", {"action": "get_build_settings"}, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@scene.command("screenshot") +@click.option( + "--filename", "-f", + default=None, + help="Output filename (default: timestamp)." +) +@click.option( + "--supersize", "-s", + default=1, + type=int, + help="Supersize multiplier (1-4)." +) +def screenshot(filename: Optional[str], supersize: int): + """Capture a screenshot of the scene. + + \b + Examples: + unity-mcp scene screenshot + unity-mcp scene screenshot --filename "level_preview" + unity-mcp scene screenshot --supersize 2 + """ + config = get_config() + + params: dict[str, Any] = {"action": "screenshot"} + if filename: + params["fileName"] = filename + if supersize > 1: + params["superSize"] = supersize + + try: + result = run_command("manage_scene", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Screenshot captured") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/script.py b/Server/src/cli/commands/script.py new file mode 100644 index 00000000..d58d87d1 --- /dev/null +++ b/Server/src/cli/commands/script.py @@ -0,0 +1,239 @@ +"""Script CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def script(): + """Script operations - create, read, edit C# scripts.""" + pass + + +@script.command("create") +@click.argument("name") +@click.option( + "--path", "-p", + default="Assets/Scripts", + help="Directory to create the script in." +) +@click.option( + "--type", "-t", + "script_type", + type=click.Choice(["MonoBehaviour", "ScriptableObject", "Editor", "EditorWindow", "Plain"]), + default="MonoBehaviour", + help="Type of script to create." +) +@click.option( + "--namespace", "-n", + default=None, + help="Namespace for the script." +) +@click.option( + "--contents", "-c", + default=None, + help="Full script contents (overrides template)." +) +def create(name: str, path: str, script_type: str, namespace: Optional[str], contents: Optional[str]): + """Create a new C# script. + + \b + Examples: + unity-mcp script create "PlayerController" + unity-mcp script create "GameManager" --path "Assets/Scripts/Managers" + unity-mcp script create "EnemyData" --type ScriptableObject + unity-mcp script create "CustomEditor" --type Editor --namespace "MyGame.Editor" + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "name": name, + "path": path, + "scriptType": script_type, + } + + if namespace: + params["namespace"] = namespace + if contents: + params["contents"] = contents + + try: + result = run_command("manage_script", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created script: {name}.cs") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("read") +@click.argument("path") +@click.option( + "--start-line", "-s", + default=None, + type=int, + help="Starting line number (1-based)." +) +@click.option( + "--line-count", "-n", + default=None, + type=int, + help="Number of lines to read." +) +def read(path: str, start_line: Optional[int], line_count: Optional[int]): + """Read a C# script file. + + \b + Examples: + unity-mcp script read "Assets/Scripts/Player.cs" + unity-mcp script read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 + """ + config = get_config() + + parts = path.rsplit("/", 1) + filename = parts[-1] + directory = parts[0] if len(parts) > 1 else "Assets" + name = filename[:-3] if filename.endswith(".cs") else filename + + params: dict[str, Any] = { + "action": "read", + "name": name, + "path": directory, + } + + if start_line: + params["startLine"] = start_line + if line_count: + params["lineCount"] = line_count + + try: + result = run_command("manage_script", params, config) + # For read, just output the content directly + if result.get("success") and result.get("data"): + data = result.get("data", {}) + if isinstance(data, dict) and "contents" in data: + click.echo(data["contents"]) + else: + click.echo(format_output(result, config.format)) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete(path: str, force: bool): + """Delete a C# script. + + \b + Examples: + unity-mcp script delete "Assets/Scripts/OldScript.cs" + """ + config = get_config() + + if not force: + click.confirm(f"Delete script '{path}'?", abort=True) + + parts = path.rsplit("/", 1) + filename = parts[-1] + directory = parts[0] if len(parts) > 1 else "Assets" + name = filename[:-3] if filename.endswith(".cs") else filename + + params: dict[str, Any] = { + "action": "delete", + "name": name, + "path": directory, + } + + try: + result = run_command("manage_script", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("edit") +@click.argument("path") +@click.option( + "--edits", "-e", + required=True, + help='Edits as JSON array of {startLine, startCol, endLine, endCol, newText}.' +) +def edit(path: str, edits: str): + """Apply text edits to a script. + + \b + Examples: + unity-mcp script edit "Assets/Scripts/Player.cs" --edits '[{"startLine": 10, "startCol": 1, "endLine": 10, "endCol": 20, "newText": "// Modified"}]' + """ + config = get_config() + + try: + edits_list = json.loads(edits) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for edits: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "uri": path, + "edits": edits_list, + } + + try: + result = run_command("apply_text_edits", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Applied edits to: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@script.command("validate") +@click.argument("path") +@click.option( + "--level", "-l", + type=click.Choice(["basic", "standard"]), + default="basic", + help="Validation level." +) +def validate(path: str, level: str): + """Validate a C# script for errors. + + \b + Examples: + unity-mcp script validate "Assets/Scripts/Player.cs" + unity-mcp script validate "Assets/Scripts/Player.cs" --level standard + """ + config = get_config() + + params: dict[str, Any] = { + "uri": path, + "level": level, + "include_diagnostics": True, + } + + try: + result = run_command("validate_script", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/commands/ui.py b/Server/src/cli/commands/ui.py new file mode 100644 index 00000000..c61bf176 --- /dev/null +++ b/Server/src/cli/commands/ui.py @@ -0,0 +1,261 @@ +"""UI CLI commands - placeholder for future implementation.""" + +import sys +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def ui(): + """UI operations - create and modify UI elements.""" + pass + + +@ui.command("create-canvas") +@click.argument("name") +@click.option( + "--render-mode", + type=click.Choice(["ScreenSpaceOverlay", "ScreenSpaceCamera", "WorldSpace"]), + default="ScreenSpaceOverlay", + help="Canvas render mode." +) +def create_canvas(name: str, render_mode: str): + """Create a new Canvas. + + \b + Examples: + unity-mcp ui create-canvas "MainUI" + unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace + """ + config = get_config() + + try: + # Step 1: Create empty GameObject + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Canvas components + for component in ["Canvas", "CanvasScaler", "GraphicRaycaster"]: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + + # Step 3: Set render mode + render_mode_value = {"ScreenSpaceOverlay": 0, "ScreenSpaceCamera": 1, "WorldSpace": 2}.get(render_mode, 0) + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Canvas", + "property": "renderMode", + "value": render_mode_value, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Canvas: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-text") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--text", "-t", + default="New Text", + help="Initial text content." +) +@click.option( + "--position", + nargs=2, + type=float, + default=(0, 0), + help="Anchored position X Y." +) +def create_text(name: str, parent: str, text: str, position: tuple): + """Create a UI Text element (TextMeshPro). + + \b + Examples: + unity-mcp ui create-text "TitleText" --parent "MainUI" --text "Hello World" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + "position": list(position), + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add RectTransform and TextMeshProUGUI + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "TextMeshProUGUI", + }, config) + + # Step 3: Set text content + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "TextMeshProUGUI", + "property": "text", + "value": text, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Text: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-button") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--text", "-t", + default="Button", + help="Button label text." +) +def create_button(name: str, parent: str, text: str): #text current placeholder + """Create a UI Button. + + \b + Examples: + unity-mcp ui create-button "StartButton" --parent "MainUI" --text "Start Game" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Button and Image components + for component in ["Image", "Button"]: + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": component, + }, config) + + # Step 3: Create child label GameObject + label_name = f"{name}_Label" + label_result = run_command("manage_gameobject", { + "action": "create", + "name": label_name, + "parent": name, + }, config) + + # Step 4: Add TextMeshProUGUI to label and set text + run_command("manage_components", { + "action": "add", + "target": label_name, + "componentType": "TextMeshProUGUI", + }, config) + run_command("manage_components", { + "action": "set_property", + "target": label_name, + "componentType": "TextMeshProUGUI", + "property": "text", + "value": text, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Button: {name} (with label '{text}')") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@ui.command("create-image") +@click.argument("name") +@click.option( + "--parent", "-p", + required=True, + help="Parent Canvas or UI element." +) +@click.option( + "--sprite", "-s", + default=None, + help="Sprite asset path." +) +def create_image(name: str, parent: str, sprite: Optional[str]): + """Create a UI Image. + + \b + Examples: + unity-mcp ui create-image "Background" --parent "MainUI" + unity-mcp ui create-image "Icon" --parent "MainUI" --sprite "Assets/Sprites/icon.png" + """ + config = get_config() + + try: + # Step 1: Create empty GameObject with parent + result = run_command("manage_gameobject", { + "action": "create", + "name": name, + "parent": parent, + }, config) + + if not (result.get("success") or result.get("data") or result.get("result")): + click.echo(format_output(result, config.format)) + return + + # Step 2: Add Image component + run_command("manage_components", { + "action": "add", + "target": name, + "componentType": "Image", + }, config) + + # Step 3: Set sprite if provided + if sprite: + run_command("manage_components", { + "action": "set_property", + "target": name, + "componentType": "Image", + "property": "sprite", + "value": sprite, + }, config) + + click.echo(format_output(result, config.format)) + print_success(f"Created Image: {name}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py new file mode 100644 index 00000000..cdfa6413 --- /dev/null +++ b/Server/src/cli/main.py @@ -0,0 +1,273 @@ +"""Unity MCP Command Line Interface - Main Entry Point.""" + +import sys +import click +from typing import Optional + +from cli import __version__ +from cli.utils.config import CLIConfig, set_config, get_config +from cli.utils.output import format_output, print_error, print_success, print_info +from cli.utils.connection import ( + run_command, + run_check_connection, + run_list_instances, + UnityConnectionError, + warn_if_remote_host, +) + + +# Context object to pass configuration between commands +class Context: + def __init__(self): + self.config: Optional[CLIConfig] = None + self.verbose: bool = False + + +pass_context = click.make_pass_decorator(Context, ensure=True) + + +@click.group() +@click.version_option(version=__version__, prog_name="unity-mcp") +@click.option( + "--host", "-h", + default="127.0.0.1", + envvar="UNITY_MCP_HOST", + help="MCP server host address." +) +@click.option( + "--port", "-p", + default=8080, + type=int, + envvar="UNITY_MCP_HTTP_PORT", + help="MCP server port." +) +@click.option( + "--timeout", "-t", + default=30, + type=int, + envvar="UNITY_MCP_TIMEOUT", + help="Command timeout in seconds." +) +@click.option( + "--format", "-f", + type=click.Choice(["text", "json", "table"]), + default="text", + envvar="UNITY_MCP_FORMAT", + help="Output format." +) +@click.option( + "--instance", "-i", + default=None, + envvar="UNITY_MCP_INSTANCE", + help="Target Unity instance (hash or Name@hash)." +) +@click.option( + "--verbose", "-v", + is_flag=True, + help="Enable verbose output." +) +@pass_context +def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: Optional[str], verbose: bool): + """Unity MCP Command Line Interface. + + Control Unity Editor directly from the command line using the Model Context Protocol. + + \b + Examples: + unity-mcp status + unity-mcp gameobject find "Player" + unity-mcp scene hierarchy --format json + unity-mcp editor play + + \b + Environment Variables: + UNITY_MCP_HOST Server host (default: 127.0.0.1) + UNITY_MCP_HTTP_PORT Server port (default: 8080) + UNITY_MCP_TIMEOUT Timeout in seconds (default: 30) + UNITY_MCP_FORMAT Output format (default: text) + UNITY_MCP_INSTANCE Target Unity instance + """ + config = CLIConfig( + host=host, + port=port, + timeout=timeout, + format=format, + unity_instance=instance, + ) + + # Security warning for non-localhost connections + warn_if_remote_host(config) + + set_config(config) + ctx.config = config + ctx.verbose = verbose + + +@cli.command("status") +@pass_context +def status(ctx: Context): + """Check connection status to Unity MCP server.""" + config = ctx.config or get_config() + + click.echo(f"Checking connection to {config.host}:{config.port}...") + + if run_check_connection(config): + print_success(f"Connected to Unity MCP server at {config.host}:{config.port}") + + # Try to get Unity instances + try: + result = run_list_instances(config) + instances = result.get("instances", []) if isinstance(result, dict) else [] + if instances: + click.echo("\nConnected Unity instances:") + for inst in instances: + project = inst.get("project", "Unknown") + version = inst.get("unity_version", "Unknown") + hash_id = inst.get("hash", "")[:8] + click.echo(f" • {project} (Unity {version}) [{hash_id}]") + else: + print_info("No Unity instances currently connected") + except UnityConnectionError as e: + print_info(f"Could not retrieve Unity instances: {e}") + else: + print_error(f"Cannot connect to Unity MCP server at {config.host}:{config.port}") + sys.exit(1) + + +@cli.command("instances") +@pass_context +def list_instances(ctx: Context): + """List available Unity instances.""" + config = ctx.config or get_config() + + try: + instances = run_list_instances(config) + click.echo(format_output(instances, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@cli.command("raw") +@click.argument("command_type") +@click.argument("params", required=False, default="{}") +@pass_context +def raw_command(ctx: Context, command_type: str, params: str): + """Send a raw command to Unity. + + \b + Examples: + unity-mcp raw manage_scene '{"action": "get_hierarchy"}' + unity-mcp raw read_console '{"count": 10}' + """ + import json + config = ctx.config or get_config() + + try: + params_dict = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON params: {e}") + sys.exit(1) + + try: + result = run_command(command_type, params_dict, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# Import and register command groups +# These will be implemented in subsequent TODOs +def register_commands(): + """Register all command groups.""" + try: + from cli.commands.gameobject import gameobject + cli.add_command(gameobject) + except ImportError: + pass + + try: + from cli.commands.component import component + cli.add_command(component) + except ImportError: + pass + + try: + from cli.commands.scene import scene + cli.add_command(scene) + except ImportError: + pass + + try: + from cli.commands.asset import asset + cli.add_command(asset) + except ImportError: + pass + + try: + from cli.commands.script import script + cli.add_command(script) + except ImportError: + pass + + try: + from cli.commands.code import code + cli.add_command(code) + except ImportError: + pass + + try: + from cli.commands.editor import editor + cli.add_command(editor) + except ImportError: + pass + + try: + from cli.commands.prefab import prefab + cli.add_command(prefab) + except ImportError: + pass + + try: + from cli.commands.material import material + cli.add_command(material) + except ImportError: + pass + + try: + from cli.commands.lighting import lighting + cli.add_command(lighting) + except ImportError: + pass + + try: + from cli.commands.animation import animation + cli.add_command(animation) + except ImportError: + pass + + try: + from cli.commands.audio import audio + cli.add_command(audio) + except ImportError: + pass + + try: + from cli.commands.ui import ui + cli.add_command(ui) + except ImportError: + pass + + +# Register commands on import +register_commands() + + +def main(): + """Main entry point for the CLI.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/Server/src/cli/utils/__init__.py b/Server/src/cli/utils/__init__.py new file mode 100644 index 00000000..622ccc4f --- /dev/null +++ b/Server/src/cli/utils/__init__.py @@ -0,0 +1,31 @@ +"""CLI utility modules.""" + +from cli.utils.config import CLIConfig, get_config, set_config +from cli.utils.connection import ( + run_command, + run_check_connection, + run_list_instances, + UnityConnectionError, +) +from cli.utils.output import ( + format_output, + print_success, + print_error, + print_warning, + print_info, +) + +__all__ = [ + "CLIConfig", + "get_config", + "set_config", + "run_command", + "run_check_connection", + "run_list_instances", + "UnityConnectionError", + "format_output", + "print_success", + "print_error", + "print_warning", + "print_info", +] diff --git a/Server/src/cli/utils/config.py b/Server/src/cli/utils/config.py new file mode 100644 index 00000000..d6878250 --- /dev/null +++ b/Server/src/cli/utils/config.py @@ -0,0 +1,56 @@ +"""CLI Configuration utilities.""" + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CLIConfig: + """Configuration for CLI connection to Unity.""" + + host: str = "127.0.0.1" + port: int = 8080 + timeout: int = 30 + format: str = "text" # text, json, table + unity_instance: Optional[str] = None + + @classmethod + def from_env(cls) -> "CLIConfig": + port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080") + try: + port = int(port_raw) + except (ValueError, TypeError): + raise ValueError(f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}") + + timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30") + try: + timeout = int(timeout_raw) + except (ValueError, TypeError): + raise ValueError(f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}") + + return cls( + host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"), + port=port, + timeout=timeout, + format=os.environ.get("UNITY_MCP_FORMAT", "text"), + unity_instance=os.environ.get("UNITY_MCP_INSTANCE"), + ) + + +# Global config instance +_config: Optional[CLIConfig] = None + + +def get_config() -> CLIConfig: + """Get the current CLI configuration.""" + global _config + if _config is None: + _config = CLIConfig.from_env() + return _config + + +def set_config(config: CLIConfig) -> None: + """Set the CLI configuration.""" + global _config + _config = config diff --git a/Server/src/cli/utils/connection.py b/Server/src/cli/utils/connection.py new file mode 100644 index 00000000..e107c5a8 --- /dev/null +++ b/Server/src/cli/utils/connection.py @@ -0,0 +1,190 @@ +"""Connection utilities for CLI to communicate with Unity via MCP server.""" + +import asyncio +import json +import sys +from typing import Any, Dict, Optional + +import httpx + +from cli.utils.config import get_config, CLIConfig + + +class UnityConnectionError(Exception): + """Raised when connection to Unity fails.""" + pass + + +def warn_if_remote_host(config: CLIConfig) -> None: + """Warn user if connecting to a non-localhost server. + + This is a security measure to alert users that connecting to remote + servers exposes Unity control to potential network attacks. + + Args: + config: CLI configuration with host setting + """ + import click + + local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0") + if config.host.lower() not in local_hosts: + click.echo( + "⚠️ Security Warning: Connecting to non-localhost server.\n" + " The MCP CLI has no authentication. Anyone on the network could\n" + " intercept commands or send unauthorized commands to Unity.\n" + " Only proceed if you trust this network.\n", + err=True + ) + + +async def send_command( + command_type: str, + params: Dict[str, Any], + config: Optional[CLIConfig] = None, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + """Send a command to Unity via the MCP HTTP server. + + Args: + command_type: The command type (e.g., 'manage_gameobject', 'manage_scene') + params: Command parameters + config: Optional CLI configuration + timeout: Optional timeout override + + Returns: + Response dict from Unity + + Raises: + UnityConnectionError: If connection fails + """ + cfg = config or get_config() + url = f"http://{cfg.host}:{cfg.port}/api/command" + + payload = { + "type": command_type, + "params": params, + } + + if cfg.unity_instance: + payload["unity_instance"] = cfg.unity_instance + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=payload, + timeout=timeout or cfg.timeout, + ) + response.raise_for_status() + return response.json() + except httpx.ConnectError as e: + raise UnityConnectionError( + f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. " + f"Make sure the server is running and Unity is connected.\n" + f"Error: {e}" + ) + except httpx.TimeoutException: + raise UnityConnectionError( + f"Connection to Unity timed out after {timeout or cfg.timeout}s. " + f"Unity may be busy or unresponsive." + ) + except httpx.HTTPStatusError as e: + raise UnityConnectionError( + f"HTTP error from server: {e.response.status_code} - {e.response.text}" + ) + except Exception as e: + raise UnityConnectionError(f"Unexpected error: {e}") + + +def run_command( + command_type: str, + params: Dict[str, Any], + config: Optional[CLIConfig] = None, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + """Synchronous wrapper for send_command. + + Args: + command_type: The command type + params: Command parameters + config: Optional CLI configuration + timeout: Optional timeout override + + Returns: + Response dict from Unity + """ + return asyncio.run(send_command(command_type, params, config, timeout)) + + +async def check_connection(config: Optional[CLIConfig] = None) -> bool: + """Check if we can connect to the Unity MCP server. + + Args: + config: Optional CLI configuration + + Returns: + True if connection successful, False otherwise + """ + cfg = config or get_config() + url = f"http://{cfg.host}:{cfg.port}/health" + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=5) + return response.status_code == 200 + except Exception: + return False + + +def run_check_connection(config: Optional[CLIConfig] = None) -> bool: + """Synchronous wrapper for check_connection.""" + return asyncio.run(check_connection(config)) + + +async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """List available Unity instances. + + Args: + config: Optional CLI configuration + + Returns: + Dict with list of Unity instances + """ + cfg = config or get_config() + + # Try the new /api/instances endpoint first, fall back to /plugin/sessions + urls_to_try = [ + f"http://{cfg.host}:{cfg.port}/api/instances", + f"http://{cfg.host}:{cfg.port}/plugin/sessions", + ] + + async with httpx.AsyncClient() as client: + for url in urls_to_try: + try: + response = await client.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + # Normalize response format + if "instances" in data: + return data + elif "sessions" in data: + # Convert sessions format to instances format + instances = [] + for session_id, details in data["sessions"].items(): + instances.append({ + "session_id": session_id, + "project": details.get("project", "Unknown"), + "hash": details.get("hash", ""), + "unity_version": details.get("unity_version", "Unknown"), + "connected_at": details.get("connected_at", ""), + }) + return {"success": True, "instances": instances} + except Exception: + continue + + raise UnityConnectionError("Failed to list Unity instances: No working endpoint found") + + +def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]: + """Synchronous wrapper for list_unity_instances.""" + return asyncio.run(list_unity_instances(config)) diff --git a/Server/src/cli/utils/output.py b/Server/src/cli/utils/output.py new file mode 100644 index 00000000..ad43493f --- /dev/null +++ b/Server/src/cli/utils/output.py @@ -0,0 +1,188 @@ +"""Output formatting utilities for CLI.""" + +import json +import sys +from typing import Any, Dict, List, Optional, Union + + +def format_output(data: Any, format_type: str = "text") -> str: + """Format output based on requested format type. + + Args: + data: Data to format + format_type: One of 'text', 'json', 'table' + + Returns: + Formatted string + """ + if format_type == "json": + return format_as_json(data) + elif format_type == "table": + return format_as_table(data) + else: + return format_as_text(data) + + +def format_as_json(data: Any) -> str: + """Format data as pretty-printed JSON.""" + try: + return json.dumps(data, indent=2, default=str) + except (TypeError, ValueError) as e: + return json.dumps({"error": f"JSON serialization failed: {e}", "raw": str(data)}) + + +def format_as_text(data: Any, indent: int = 0) -> str: + """Format data as human-readable text.""" + prefix = " " * indent + + if data is None: + return f"{prefix}(none)" + + if isinstance(data, dict): + # Check for error response + if "success" in data and not data.get("success"): + error = data.get("error") or data.get("message") or "Unknown error" + return f"{prefix}❌ Error: {error}" + + # Check for success response with data + if "success" in data and data.get("success"): + result = data.get("data") or data.get("result") or data + if result != data: + return format_as_text(result, indent) + + lines = [] + for key, value in data.items(): + if key in ("success", "error", "message") and "success" in data: + continue # Skip meta fields + if isinstance(value, dict): + lines.append(f"{prefix}{key}:") + lines.append(format_as_text(value, indent + 1)) + elif isinstance(value, list): + lines.append(f"{prefix}{key}: [{len(value)} items]") + if len(value) <= 10: + for i, item in enumerate(value): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + else: + for i, item in enumerate(value[:5]): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + lines.append(f"{prefix} ... ({len(value) - 10} more)") + for i, item in enumerate(value[-5:], len(value) - 5): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + else: + lines.append(f"{prefix}{key}: {value}") + return "\n".join(lines) + + if isinstance(data, list): + if not data: + return f"{prefix}(empty list)" + lines = [f"{prefix}[{len(data)} items]"] + for i, item in enumerate(data[:20]): + lines.append(f"{prefix} [{i}] {_format_list_item(item)}") + if len(data) > 20: + lines.append(f"{prefix} ... ({len(data) - 20} more)") + return "\n".join(lines) + + return f"{prefix}{data}" + + +def _format_list_item(item: Any) -> str: + """Format a single list item.""" + if isinstance(item, dict): + # Try to find a name/id field for display + name = item.get("name") or item.get("Name") or item.get("id") or item.get("Id") + if name: + extra = "" + if "instanceID" in item: + extra = f" (ID: {item['instanceID']})" + elif "path" in item: + extra = f" ({item['path']})" + return f"{name}{extra}" + # Fallback to compact representation + return json.dumps(item, default=str)[:80] + return str(item)[:80] + + +def format_as_table(data: Any) -> str: + """Format data as an ASCII table.""" + if isinstance(data, dict): + # Check for success response with data + if "success" in data and data.get("success"): + result = data.get("data") or data.get("result") or data.get("items") + if isinstance(result, list): + return _build_table(result) + + # Single dict as key-value table + rows = [[str(k), str(v)[:60]] for k, v in data.items()] + return _build_table(rows, headers=["Key", "Value"]) + + if isinstance(data, list): + return _build_table(data) + + return str(data) + + +def _build_table(data: List[Any], headers: Optional[List[str]] = None) -> str: + """Build an ASCII table from list data.""" + if not data: + return "(no data)" + + # Convert list of dicts to rows + if isinstance(data[0], dict): + if headers is None: + headers = list(data[0].keys()) + rows = [[str(item.get(h, ""))[:40] for h in headers] for item in data] + elif isinstance(data[0], (list, tuple)): + rows = [[str(cell)[:40] for cell in row] for row in data] + if headers is None: + headers = [f"Col{i}" for i in range(len(data[0]))] + else: + rows = [[str(item)[:60]] for item in data] + headers = headers or ["Value"] + + # Calculate column widths + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = max(col_widths[i], len(cell)) + + # Build table + lines = [] + + # Header + header_line = " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + lines.append(header_line) + lines.append("-+-".join("-" * w for w in col_widths)) + + # Rows + for row in rows[:50]: # Limit rows + row_line = " | ".join( + (row[i] if i < len(row) else "").ljust(col_widths[i]) + for i in range(len(headers)) + ) + lines.append(row_line) + + if len(rows) > 50: + lines.append(f"... ({len(rows) - 50} more rows)") + + return "\n".join(lines) + + +def print_success(message: str) -> None: + """Print a success message.""" + print(f"✅ {message}") + + +def print_error(message: str) -> None: + """Print an error message to stderr.""" + print(f"❌ {message}", file=sys.stderr) + + +def print_warning(message: str) -> None: + """Print a warning message.""" + print(f"⚠️ {message}") + + +def print_info(message: str) -> None: + """Print an info message.""" + print(f"ℹ️ {message}") diff --git a/Server/src/main.py b/Server/src/main.py index c46a4765..92b483ab 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -296,6 +296,68 @@ async def health_http(_: Request) -> JSONResponse: }) +@mcp.custom_route("/api/command", methods=["POST"]) +async def cli_command_route(request: Request) -> JSONResponse: + """REST endpoint for CLI commands to Unity.""" + try: + body = await request.json() + + command_type = body.get("type") + params = body.get("params", {}) + unity_instance = body.get("unity_instance") + + if not command_type: + return JSONResponse({"success": False, "error": "Missing 'type' field"}, status_code=400) + + # Get available sessions + sessions = await PluginHub.get_sessions() + if not sessions.sessions: + return JSONResponse({ + "success": False, + "error": "No Unity instances connected. Make sure Unity is running with MCP plugin." + }, status_code=503) + + # Find target session + session_id = None + if unity_instance: + # Try to match by hash or project name + for sid, details in sessions.sessions.items(): + if details.hash == unity_instance or details.project == unity_instance: + session_id = sid + break + + if not session_id: + # Use first available session + session_id = next(iter(sessions.sessions.keys())) + + # Send command to Unity + result = await PluginHub.send_command(session_id, command_type, params) + return JSONResponse(result) + + except Exception as e: + logger.error(f"CLI command error: {e}") + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + + +@mcp.custom_route("/api/instances", methods=["GET"]) +async def cli_instances_route(_: Request) -> JSONResponse: + """REST endpoint to list connected Unity instances.""" + try: + sessions = await PluginHub.get_sessions() + instances = [] + for session_id, details in sessions.sessions.items(): + instances.append({ + "session_id": session_id, + "project": details.project, + "hash": details.hash, + "unity_version": details.unity_version, + "connected_at": details.connected_at, + }) + return JSONResponse({"success": True, "instances": instances}) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + + @mcp.custom_route("/plugin/sessions", methods=["GET"]) async def plugin_sessions_route(_: Request) -> JSONResponse: data = await PluginHub.get_sessions() @@ -429,8 +491,16 @@ def main(): # Allow individual host/port to override URL components http_host = args.http_host or os.environ.get( "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost" - http_port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get( - "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080 + + # Safely parse optional environment port (may be None or non-numeric) + _env_port_str = os.environ.get("UNITY_MCP_HTTP_PORT") + try: + _env_port = int(_env_port_str) if _env_port_str is not None else None + except ValueError: + logger.warning("Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str) + _env_port = None + + http_port = args.http_port or _env_port or parsed_url.port or 8080 os.environ["UNITY_MCP_HTTP_HOST"] = http_host os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port) @@ -465,8 +535,7 @@ def main(): parsed_url = urlparse(http_url) host = args.http_host or os.environ.get( "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost" - port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get( - "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080 + port = args.http_port or _env_port or parsed_url.port or 8080 logger.info(f"Starting FastMCP with HTTP transport on {host}:{port}") mcp.run(transport=transport, host=host, port=port) else: diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py new file mode 100644 index 00000000..a4154896 --- /dev/null +++ b/Server/tests/test_cli.py @@ -0,0 +1,877 @@ +"""Unit tests for Unity MCP CLI.""" + +import json +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from click.testing import CliRunner + +from cli.main import cli +from cli.utils.config import CLIConfig, get_config, set_config +from cli.utils.output import format_output, format_as_json, format_as_text, format_as_table +from cli.utils.connection import ( + send_command, + check_connection, + list_unity_instances, + UnityConnectionError, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def runner(): + """Create a CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_config(): + """Create a mock CLI configuration.""" + return CLIConfig( + host="127.0.0.1", + port=8080, + timeout=30, + format="text", + unity_instance=None, + ) + + +@pytest.fixture +def mock_unity_response(): + """Standard successful Unity response.""" + return { + "success": True, + "message": "Operation successful", + "data": {"test": "data"} + } + + +@pytest.fixture +def mock_instances_response(): + """Mock Unity instances response.""" + return { + "success": True, + "instances": [ + { + "session_id": "test-session-123", + "project": "TestProject", + "hash": "abc123def456", + "unity_version": "2022.3.10f1", + "connected_at": "2024-01-01T00:00:00Z", + } + ] + } + + +@pytest.fixture +def mock_sessions_response(): + """Mock plugin sessions response (legacy format).""" + return { + "sessions": { + "test-session-123": { + "project": "TestProject", + "hash": "abc123def456", + "unity_version": "2022.3.10f1", + "connected_at": "2024-01-01T00:00:00Z", + } + } + } + + +# ============================================================================= +# Config Tests +# ============================================================================= + +class TestConfig: + """Tests for CLI configuration.""" + + def test_default_config(self): + """Test default configuration values.""" + config = CLIConfig() + assert config.host == "127.0.0.1" + assert config.port == 8080 + assert config.timeout == 30 + assert config.format == "text" + assert config.unity_instance is None + + def test_config_from_env(self, monkeypatch): + """Test configuration from environment variables.""" + monkeypatch.setenv("UNITY_MCP_HOST", "192.168.1.100") + monkeypatch.setenv("UNITY_MCP_HTTP_PORT", "9090") + monkeypatch.setenv("UNITY_MCP_TIMEOUT", "60") + monkeypatch.setenv("UNITY_MCP_FORMAT", "json") + monkeypatch.setenv("UNITY_MCP_INSTANCE", "MyProject") + + config = CLIConfig.from_env() + assert config.host == "192.168.1.100" + assert config.port == 9090 + assert config.timeout == 60 + assert config.format == "json" + assert config.unity_instance == "MyProject" + + def test_set_and_get_config(self, mock_config): + """Test setting and getting global config.""" + set_config(mock_config) + retrieved = get_config() + assert retrieved.host == mock_config.host + assert retrieved.port == mock_config.port + + +# ============================================================================= +# Output Formatting Tests +# ============================================================================= + +class TestOutputFormatting: + """Tests for output formatting utilities.""" + + def test_format_as_json(self): + """Test JSON formatting.""" + data = {"key": "value", "number": 42} + result = format_as_json(data) + parsed = json.loads(result) + assert parsed == data + + def test_format_as_json_with_complex_types(self): + """Test JSON formatting with complex types.""" + from datetime import datetime + data = {"timestamp": datetime(2024, 1, 1)} + result = format_as_json(data) + assert "2024" in result + + def test_format_as_text_success_response(self): + """Test text formatting for success response.""" + data = { + "success": True, + "message": "OK", + "data": {"name": "Player", "id": 123} + } + result = format_as_text(data) + assert "name" in result + assert "Player" in result + + def test_format_as_text_error_response(self): + """Test text formatting for error response.""" + data = {"success": False, "error": "Something went wrong"} + result = format_as_text(data) + assert "Error" in result + assert "Something went wrong" in result + + def test_format_as_text_list(self): + """Test text formatting for lists.""" + data = [{"name": "Item1"}, {"name": "Item2"}] + result = format_as_text(data) + assert "2 items" in result + + def test_format_as_table(self): + """Test table formatting.""" + data = [ + {"name": "Player", "id": 1}, + {"name": "Enemy", "id": 2}, + ] + result = format_as_table(data) + assert "name" in result + assert "Player" in result + assert "Enemy" in result + + def test_format_output_dispatch(self): + """Test format_output dispatches correctly.""" + data = {"key": "value"} + + json_result = format_output(data, "json") + assert json.loads(json_result) == data + + text_result = format_output(data, "text") + assert "key" in text_result + + table_result = format_output(data, "table") + assert "key" in table_result.lower() or "Key" in table_result + + +# ============================================================================= +# Connection Tests +# ============================================================================= + +class TestConnection: + """Tests for connection utilities.""" + + @pytest.mark.asyncio + async def test_check_connection_success(self): + """Test successful connection check.""" + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + result = await check_connection() + assert result is True + + @pytest.mark.asyncio + async def test_check_connection_failure(self): + """Test failed connection check.""" + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + side_effect=Exception("Connection refused") + ) + result = await check_connection() + assert result is False + + @pytest.mark.asyncio + async def test_send_command_success(self, mock_unity_response): + """Test successful command sending.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_unity_response + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + mock_response.raise_for_status = MagicMock() + + result = await send_command("test_command", {"param": "value"}) + assert result == mock_unity_response + + @pytest.mark.asyncio + async def test_send_command_connection_error(self): + """Test command sending with connection error.""" + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + side_effect=Exception("Connection refused") + ) + + with pytest.raises(UnityConnectionError): + await send_command("test_command", {}) + + @pytest.mark.asyncio + async def test_list_instances_from_sessions(self, mock_sessions_response): + """Test listing instances from /plugin/sessions endpoint.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_sessions_response + + with patch("httpx.AsyncClient") as mock_client: + # First call (api/instances) returns 404, second (plugin/sessions) succeeds + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await list_unity_instances() + assert result["success"] is True + assert len(result["instances"]) == 1 + assert result["instances"][0]["project"] == "TestProject" + + +# ============================================================================= +# CLI Command Tests +# ============================================================================= + +class TestCLICommands: + """Tests for CLI commands.""" + + def test_cli_help(self, runner): + """Test CLI help command.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Unity MCP Command Line Interface" in result.output + + def test_cli_version(self, runner): + """Test CLI version command.""" + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + + def test_status_connected(self, runner, mock_instances_response): + """Test status command when connected.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value=mock_instances_response): + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + assert "Connected" in result.output + + def test_status_disconnected(self, runner): + """Test status command when disconnected.""" + with patch("cli.main.run_check_connection", return_value=False): + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 1 + assert "Cannot connect" in result.output + + def test_instances_command(self, runner, mock_instances_response): + """Test instances command.""" + with patch("cli.main.run_list_instances", return_value=mock_instances_response): + result = runner.invoke(cli, ["instances"]) + assert result.exit_code == 0 + + def test_raw_command(self, runner, mock_unity_response): + """Test raw command.""" + with patch("cli.main.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["raw", "test_command", '{"param": "value"}']) + assert result.exit_code == 0 + + def test_raw_command_invalid_json(self, runner): + """Test raw command with invalid JSON.""" + result = runner.invoke(cli, ["raw", "test_command", "invalid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + +# ============================================================================= +# GameObject Command Tests +# ============================================================================= + +class TestGameObjectCommands: + """Tests for GameObject CLI commands.""" + + def test_gameobject_find(self, runner, mock_unity_response): + """Test gameobject find command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "find", "Player"]) + assert result.exit_code == 0 + + def test_gameobject_find_with_options(self, runner, mock_unity_response): + """Test gameobject find with options.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "find", "Enemy", + "--method", "by_tag", + "--include-inactive", + "--limit", "100" + ]) + assert result.exit_code == 0 + + def test_gameobject_create(self, runner, mock_unity_response): + """Test gameobject create command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "create", "NewObject"]) + assert result.exit_code == 0 + + def test_gameobject_create_with_primitive(self, runner, mock_unity_response): + """Test gameobject create with primitive.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "create", "MyCube", + "--primitive", "Cube", + "--position", "0", "1", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_modify(self, runner, mock_unity_response): + """Test gameobject modify command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "modify", "Player", + "--position", "0", "5", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_delete(self, runner, mock_unity_response): + """Test gameobject delete command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "delete", "OldObject", "--force"]) + assert result.exit_code == 0 + + def test_gameobject_delete_confirmation(self, runner, mock_unity_response): + """Test gameobject delete with confirmation prompt.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["gameobject", "delete", "OldObject"], input="y\n") + assert result.exit_code == 0 + + def test_gameobject_duplicate(self, runner, mock_unity_response): + """Test gameobject duplicate command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "duplicate", "Player", + "--name", "Player2", + "--offset", "5", "0", "0" + ]) + assert result.exit_code == 0 + + def test_gameobject_move(self, runner, mock_unity_response): + """Test gameobject move command.""" + with patch("cli.commands.gameobject.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "gameobject", "move", "Chair", + "--reference", "Table", + "--direction", "right", + "--distance", "2" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Component Command Tests +# ============================================================================= + +class TestComponentCommands: + """Tests for Component CLI commands.""" + + def test_component_add(self, runner, mock_unity_response): + """Test component add command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "add", "Player", "Rigidbody"]) + assert result.exit_code == 0 + + def test_component_remove(self, runner, mock_unity_response): + """Test component remove command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "remove", "Player", "Rigidbody", "--force"]) + assert result.exit_code == 0 + + def test_component_set(self, runner, mock_unity_response): + """Test component set command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["component", "set", "Player", "Rigidbody", "mass", "5.0"]) + assert result.exit_code == 0 + + def test_component_modify(self, runner, mock_unity_response): + """Test component modify command.""" + with patch("cli.commands.component.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "component", "modify", "Player", "Rigidbody", + "--properties", '{"mass": 5.0, "useGravity": false}' + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Scene Command Tests +# ============================================================================= + +class TestSceneCommands: + """Tests for Scene CLI commands.""" + + def test_scene_hierarchy(self, runner, mock_unity_response): + """Test scene hierarchy command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 0 + + def test_scene_hierarchy_with_options(self, runner, mock_unity_response): + """Test scene hierarchy with options.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "scene", "hierarchy", + "--max-depth", "5", + "--include-transform" + ]) + assert result.exit_code == 0 + + def test_scene_active(self, runner, mock_unity_response): + """Test scene active command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "active"]) + assert result.exit_code == 0 + + def test_scene_load(self, runner, mock_unity_response): + """Test scene load command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "load", "Assets/Scenes/Main.unity"]) + assert result.exit_code == 0 + + def test_scene_save(self, runner, mock_unity_response): + """Test scene save command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "save"]) + assert result.exit_code == 0 + + def test_scene_create(self, runner, mock_unity_response): + """Test scene create command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "create", "NewLevel"]) + assert result.exit_code == 0 + + def test_scene_screenshot(self, runner, mock_unity_response): + """Test scene screenshot command.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["scene", "screenshot", "--filename", "test"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Asset Command Tests +# ============================================================================= + +class TestAssetCommands: + """Tests for Asset CLI commands.""" + + def test_asset_search(self, runner, mock_unity_response): + """Test asset search command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "search", "*.prefab"]) + assert result.exit_code == 0 + + def test_asset_info(self, runner, mock_unity_response): + """Test asset info command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "info", "Assets/Materials/Red.mat"]) + assert result.exit_code == 0 + + def test_asset_create(self, runner, mock_unity_response): + """Test asset create command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "create", "Assets/Materials/New.mat", "Material"]) + assert result.exit_code == 0 + + def test_asset_delete(self, runner, mock_unity_response): + """Test asset delete command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "delete", "Assets/Old.mat", "--force"]) + assert result.exit_code == 0 + + def test_asset_duplicate(self, runner, mock_unity_response): + """Test asset duplicate command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "asset", "duplicate", + "Assets/Materials/Red.mat", + "Assets/Materials/RedCopy.mat" + ]) + assert result.exit_code == 0 + + def test_asset_move(self, runner, mock_unity_response): + """Test asset move command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "asset", "move", + "Assets/Old/Mat.mat", + "Assets/New/Mat.mat" + ]) + assert result.exit_code == 0 + + def test_asset_mkdir(self, runner, mock_unity_response): + """Test asset mkdir command.""" + with patch("cli.commands.asset.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["asset", "mkdir", "Assets/NewFolder"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Editor Command Tests +# ============================================================================= + +class TestEditorCommands: + """Tests for Editor CLI commands.""" + + def test_editor_play(self, runner, mock_unity_response): + """Test editor play command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "play"]) + assert result.exit_code == 0 + + def test_editor_pause(self, runner, mock_unity_response): + """Test editor pause command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "pause"]) + assert result.exit_code == 0 + + def test_editor_stop(self, runner, mock_unity_response): + """Test editor stop command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "stop"]) + assert result.exit_code == 0 + + def test_editor_console(self, runner, mock_unity_response): + """Test editor console command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "console"]) + assert result.exit_code == 0 + + def test_editor_console_clear(self, runner, mock_unity_response): + """Test editor console clear command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "console", "--clear"]) + assert result.exit_code == 0 + + def test_editor_add_tag(self, runner, mock_unity_response): + """Test editor add-tag command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "add-tag", "Enemy"]) + assert result.exit_code == 0 + + def test_editor_add_layer(self, runner, mock_unity_response): + """Test editor add-layer command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "add-layer", "Interactable"]) + assert result.exit_code == 0 + + def test_editor_menu(self, runner, mock_unity_response): + """Test editor menu command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "menu", "File/Save"]) + assert result.exit_code == 0 + + def test_editor_tests(self, runner, mock_unity_response): + """Test editor tests command.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "tests", "--mode", "EditMode"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Prefab Command Tests +# ============================================================================= + +class TestPrefabCommands: + """Tests for Prefab CLI commands.""" + + def test_prefab_open(self, runner, mock_unity_response): + """Test prefab open command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "open", "Assets/Prefabs/Player.prefab"]) + assert result.exit_code == 0 + + def test_prefab_close(self, runner, mock_unity_response): + """Test prefab close command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "close"]) + assert result.exit_code == 0 + + def test_prefab_save(self, runner, mock_unity_response): + """Test prefab save command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["prefab", "save"]) + assert result.exit_code == 0 + + def test_prefab_create(self, runner, mock_unity_response): + """Test prefab create command.""" + with patch("cli.commands.prefab.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "prefab", "create", "Player", "Assets/Prefabs/Player.prefab" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Material Command Tests +# ============================================================================= + +class TestMaterialCommands: + """Tests for Material CLI commands.""" + + def test_material_info(self, runner, mock_unity_response): + """Test material info command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["material", "info", "Assets/Materials/Red.mat"]) + assert result.exit_code == 0 + + def test_material_create(self, runner, mock_unity_response): + """Test material create command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["material", "create", "Assets/Materials/New.mat"]) + assert result.exit_code == 0 + + def test_material_set_color(self, runner, mock_unity_response): + """Test material set-color command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "set-color", "Assets/Materials/Red.mat", + "1", "0", "0" + ]) + assert result.exit_code == 0 + + def test_material_set_property(self, runner, mock_unity_response): + """Test material set-property command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "set-property", "Assets/Materials/Mat.mat", + "_Metallic", "0.5" + ]) + assert result.exit_code == 0 + + def test_material_assign(self, runner, mock_unity_response): + """Test material assign command.""" + with patch("cli.commands.material.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "material", "assign", "Assets/Materials/Red.mat", "Cube" + ]) + assert result.exit_code == 0 + + +# ============================================================================= +# Script Command Tests +# ============================================================================= + +class TestScriptCommands: + """Tests for Script CLI commands.""" + + def test_script_create(self, runner, mock_unity_response): + """Test script create command.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["script", "create", "PlayerController"]) + assert result.exit_code == 0 + + def test_script_create_with_options(self, runner, mock_unity_response): + """Test script create with options.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "script", "create", "EnemyData", + "--type", "ScriptableObject", + "--namespace", "MyGame" + ]) + assert result.exit_code == 0 + + def test_script_read(self, runner): + """Test script read command.""" + mock_response = { + "success": True, + "data": {"content": "using UnityEngine;\n\npublic class Test {}"} + } + with patch("cli.commands.script.run_command", return_value=mock_response): + result = runner.invoke(cli, ["script", "read", "Assets/Scripts/Test.cs"]) + assert result.exit_code == 0 + + def test_script_delete(self, runner, mock_unity_response): + """Test script delete command.""" + with patch("cli.commands.script.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["script", "delete", "Assets/Scripts/Old.cs", "--force"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Global Options Tests +# ============================================================================= + +class TestGlobalOptions: + """Tests for global CLI options.""" + + def test_custom_host(self, runner, mock_unity_response): + """Test custom host option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--host", "192.168.1.100", "status"]) + assert result.exit_code == 0 + + def test_custom_port(self, runner, mock_unity_response): + """Test custom port option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--port", "9090", "status"]) + assert result.exit_code == 0 + + def test_json_format(self, runner, mock_unity_response): + """Test JSON output format.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "json", "scene", "active"]) + assert result.exit_code == 0 + + def test_table_format(self, runner, mock_unity_response): + """Test table output format.""" + with patch("cli.commands.scene.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "table", "scene", "active"]) + assert result.exit_code == 0 + + def test_timeout_option(self, runner, mock_unity_response): + """Test timeout option.""" + with patch("cli.main.run_check_connection", return_value=True): + with patch("cli.main.run_list_instances", return_value={"instances": []}): + result = runner.invoke(cli, ["--timeout", "60", "status"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + +class TestErrorHandling: + """Tests for error handling.""" + + def test_connection_error_handling(self, runner): + """Test connection error is handled gracefully.""" + with patch("cli.commands.scene.run_command", side_effect=UnityConnectionError("Connection failed")): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 1 + assert "Connection failed" in result.output or "Error" in result.output + + def test_invalid_json_params(self, runner): + """Test invalid JSON parameters are handled.""" + result = runner.invoke(cli, [ + "component", "modify", "Player", "Rigidbody", + "--properties", "not valid json" + ]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_missing_required_argument(self, runner): + """Test missing required argument.""" + result = runner.invoke(cli, ["gameobject", "find"]) + assert result.exit_code != 0 + assert "Missing argument" in result.output + + +# ============================================================================= +# Integration-style Tests (with mocked responses) +# ============================================================================= + +class TestIntegration: + """Integration-style tests with realistic response data.""" + + def test_full_gameobject_workflow(self, runner): + """Test a full GameObject workflow.""" + create_response = { + "success": True, + "message": "GameObject created", + "data": {"instanceID": -12345, "name": "TestObject"} + } + modify_response = { + "success": True, + "message": "GameObject modified" + } + delete_response = { + "success": True, + "message": "GameObject deleted" + } + + # Create + with patch("cli.commands.gameobject.run_command", return_value=create_response): + result = runner.invoke(cli, ["gameobject", "create", "TestObject", "--primitive", "Cube"]) + assert result.exit_code == 0 + assert "Created" in result.output + + # Modify + with patch("cli.commands.gameobject.run_command", return_value=modify_response): + result = runner.invoke(cli, ["gameobject", "modify", "TestObject", "--position", "0", "5", "0"]) + assert result.exit_code == 0 + + # Delete + with patch("cli.commands.gameobject.run_command", return_value=delete_response): + result = runner.invoke(cli, ["gameobject", "delete", "TestObject", "--force"]) + assert result.exit_code == 0 + assert "Deleted" in result.output + + def test_scene_hierarchy_with_data(self, runner): + """Test scene hierarchy with realistic data.""" + hierarchy_response = { + "success": True, + "data": { + "nodes": [ + {"name": "Main Camera", "instanceID": -100, "childCount": 0}, + {"name": "Directional Light", "instanceID": -200, "childCount": 0}, + {"name": "Player", "instanceID": -300, "childCount": 2}, + ] + } + } + + with patch("cli.commands.scene.run_command", return_value=hierarchy_response): + result = runner.invoke(cli, ["scene", "hierarchy"]) + assert result.exit_code == 0 + + def test_find_gameobjects_with_results(self, runner): + """Test finding GameObjects with results.""" + find_response = { + "success": True, + "message": "Found 3 GameObjects", + "data": { + "instanceIDs": [-100, -200, -300], + "count": 3, + "hasMore": False + } + } + + with patch("cli.commands.gameobject.run_command", return_value=find_response): + result = runner.invoke(cli, ["gameobject", "find", "Camera"]) + assert result.exit_code == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Server/uv.lock b/Server/uv.lock index 40c43970..6af0a2b0 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -915,6 +915,7 @@ name = "mcpforunityserver" version = "9.0.3" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, @@ -933,6 +934,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "fastmcp", specifier = "==2.14.1" }, { name = "httpx", specifier = ">=0.27.2" },