From 7f44e4b53e730f53f1c8ad9e89c324edead17816 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 21 Jan 2026 20:53:13 -0400 Subject: [PATCH] Add CLI (#606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add CLI for Unity MCP server - Add click-based CLI with 15+ command groups - Commands: gameobject, component, scene, asset, script, editor, prefab, material, lighting, ui, audio, animation, code - HTTP transport to communicate with Unity via MCP server - Output formats: text, json, table - Configuration via environment variables or CLI options - Comprehensive usage guide and unit tests * Update based on AI feedback * Fixes main.py error * Update for further error fix * Update based on AI * Update script.py * Update with better coverage and Tool Readme * Log a message with implicit URI changes Small update for #542 * Minor fixes (#602) * Log a message with implicit URI changes Small update for #542 * Log a message with implicit URI changes Small update for #542 * Add helper scripts to update forks * fix: improve HTTP Local URL validation UX and styling specificity - Rename CSS class from generic "error" to "http-local-url-error" for better specificity - Rename "invalid-url" class to "http-local-invalid-url" for clarity - Disable httpServerCommandField when URL is invalid or transport not HTTP Local - Clear field value and tooltip when showing validation errors - Ensure field is re-enabled when URL becomes valid * Docker mcp gateway (#603) * Log a message with implicit URI changes Small update for #542 * Update docker container to default to stdio Replaces #541 * fix: Rider config path and add MCP registry manifest (#604) - Fix RiderConfigurator to use correct GitHub Copilot config path: - Windows: %LOCALAPPDATA%\github-copilot\intellij\mcp.json - macOS: ~/Library/Application Support/github-copilot/intellij/mcp.json - Linux: ~/.config/github-copilot/intellij/mcp.json - Add mcp.json for GitHub MCP Registry support: - Enables users to install via coplaydev/unity-mcp - Uses uvx with mcpforunityserver from PyPI * Use click.echo instead of print statements * Standardize whitespace * Minor tweak in docs * Use `wait` params * Unrelated but project scoped tools should be off by default * Update lock file * Whitespace cleanup * Update custom_tool_service.py to skip global registration for any tool name that already exists as a built‑in. * Avoid silently falling back to the first Unity session when a specific unity_instance was requested but not found. If a client passes a unity_instance that doesn’t match any session, this code will still route the command to the first available session, which can send commands to the wrong project in multi‑instance environments. Instead, when a unity_instance is provided but no matching session_id is found, return an error (e.g. 400/404 with "Unity instance '' not found") and only default to the first session when no unity_instance was specified. Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Update docs/CLI_USAGE.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Updated the CLI command registration to only swallow missing optional modules and to surface real import-time failures, so broken command modules don’t get silently ignored. * Sorted __all__ alphabetically to satisfy RUF022 in __init__.py. * Validate --params is a JSON object before merging. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Co-authored-by: dsarno Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Connection/McpConnectionSection.cs | 2 +- Server/pyproject.toml | 2 + Server/src/cli/CLI_USAGE_GUIDE.md | 727 ++++++++++ Server/src/cli/__init__.py | 3 + Server/src/cli/commands/__init__.py | 3 + Server/src/cli/commands/animation.py | 87 ++ Server/src/cli/commands/asset.py | 310 +++++ Server/src/cli/commands/audio.py | 133 ++ Server/src/cli/commands/batch.py | 184 +++ Server/src/cli/commands/code.py | 189 +++ Server/src/cli/commands/component.py | 212 +++ Server/src/cli/commands/editor.py | 487 +++++++ Server/src/cli/commands/gameobject.py | 510 +++++++ Server/src/cli/commands/instance.py | 101 ++ Server/src/cli/commands/lighting.py | 128 ++ Server/src/cli/commands/material.py | 268 ++++ Server/src/cli/commands/prefab.py | 144 ++ Server/src/cli/commands/scene.py | 255 ++++ Server/src/cli/commands/script.py | 240 ++++ Server/src/cli/commands/shader.py | 238 ++++ Server/src/cli/commands/ui.py | 263 ++++ Server/src/cli/commands/vfx.py | 439 ++++++ Server/src/cli/main.py | 248 ++++ Server/src/cli/utils/__init__.py | 31 + Server/src/cli/utils/config.py | 58 + Server/src/cli/utils/connection.py | 191 +++ Server/src/cli/utils/output.py | 195 +++ Server/src/main.py | 115 +- Server/src/services/custom_tool_service.py | 11 + .../src/transport/legacy/unity_connection.py | 6 +- Server/tests/test_cli.py | 1225 +++++++++++++++++ Server/uv.lock | 4 +- docs/CLI_USAGE.md | 393 ++++++ prune_tool_results.py | 51 +- 34 files changed, 7410 insertions(+), 43 deletions(-) create mode 100644 Server/src/cli/CLI_USAGE_GUIDE.md create mode 100644 Server/src/cli/__init__.py create mode 100644 Server/src/cli/commands/__init__.py create mode 100644 Server/src/cli/commands/animation.py create mode 100644 Server/src/cli/commands/asset.py create mode 100644 Server/src/cli/commands/audio.py create mode 100644 Server/src/cli/commands/batch.py create mode 100644 Server/src/cli/commands/code.py create mode 100644 Server/src/cli/commands/component.py create mode 100644 Server/src/cli/commands/editor.py create mode 100644 Server/src/cli/commands/gameobject.py create mode 100644 Server/src/cli/commands/instance.py create mode 100644 Server/src/cli/commands/lighting.py create mode 100644 Server/src/cli/commands/material.py create mode 100644 Server/src/cli/commands/prefab.py create mode 100644 Server/src/cli/commands/scene.py create mode 100644 Server/src/cli/commands/script.py create mode 100644 Server/src/cli/commands/shader.py create mode 100644 Server/src/cli/commands/ui.py create mode 100644 Server/src/cli/commands/vfx.py create mode 100644 Server/src/cli/main.py create mode 100644 Server/src/cli/utils/__init__.py create mode 100644 Server/src/cli/utils/config.py create mode 100644 Server/src/cli/utils/connection.py create mode 100644 Server/src/cli/utils/output.py create mode 100644 Server/tests/test_cli.py create mode 100644 docs/CLI_USAGE.md diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index f54dcb8..1c391f6 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -132,7 +132,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection { projectScopedToolsToggle.value = EditorPrefs.GetBool( EditorPrefKeys.ProjectScopedToolsLocalHttp, - true + false ); } diff --git a/Server/pyproject.toml b/Server/pyproject.toml index 87f686c..d99b1c5 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 0000000..2a8bfb2 --- /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 0000000..6252f49 --- /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 0000000..0ea0624 --- /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 0000000..4419105 --- /dev/null +++ b/Server/src/cli/commands/animation.py @@ -0,0 +1,87 @@ +"""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 0000000..8eba313 --- /dev/null +++ b/Server/src/cli/commands/asset.py @@ -0,0 +1,310 @@ +"""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 0000000..3c50d17 --- /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/batch.py b/Server/src/cli/commands/batch.py new file mode 100644 index 0000000..436ae28 --- /dev/null +++ b/Server/src/cli/commands/batch.py @@ -0,0 +1,184 @@ +"""Batch CLI commands for executing multiple Unity operations efficiently.""" + +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, print_info +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def batch(): + """Batch operations - execute multiple commands efficiently.""" + pass + + +@batch.command("run") +@click.argument("file", type=click.Path(exists=True)) +@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.") +@click.option("--fail-fast", is_flag=True, help="Stop on first failure.") +def batch_run(file: str, parallel: bool, fail_fast: bool): + """Execute commands from a JSON file. + + The JSON file should contain an array of command objects with 'tool' and 'params' keys. + + \\b + File format: + [ + {"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube1"}}, + {"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube2"}}, + {"tool": "manage_components", "params": {"action": "add", "target": "Cube1", "componentType": "Rigidbody"}} + ] + + \\b + Examples: + unity-mcp batch run commands.json + unity-mcp batch run setup.json --parallel + unity-mcp batch run critical.json --fail-fast + """ + config = get_config() + + try: + with open(file, 'r') as f: + commands = json.load(f) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON in file: {e}") + sys.exit(1) + except IOError as e: + print_error(f"Error reading file: {e}") + sys.exit(1) + + if not isinstance(commands, list): + print_error("JSON file must contain an array of commands") + sys.exit(1) + + if len(commands) > 25: + print_error(f"Maximum 25 commands per batch, got {len(commands)}") + sys.exit(1) + + params: dict[str, Any] = {"commands": commands} + if parallel: + params["parallel"] = True + if fail_fast: + params["failFast"] = True + + click.echo(f"Executing {len(commands)} commands...") + + try: + result = run_command("batch_execute", params, config) + click.echo(format_output(result, config.format)) + + if isinstance(result, dict): + results = result.get("data", {}).get("results", []) + succeeded = sum(1 for r in results if r.get("success")) + failed = len(results) - succeeded + + if failed == 0: + print_success( + f"All {succeeded} commands completed successfully") + else: + print_info(f"{succeeded} succeeded, {failed} failed") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@batch.command("inline") +@click.argument("commands_json") +@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.") +@click.option("--fail-fast", is_flag=True, help="Stop on first failure.") +def batch_inline(commands_json: str, parallel: bool, fail_fast: bool): + """Execute commands from inline JSON. + + \\b + Examples: + unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]' + + unity-mcp batch inline '[ + {"tool": "manage_gameobject", "params": {"action": "create", "name": "A", "primitiveType": "Cube"}}, + {"tool": "manage_gameobject", "params": {"action": "create", "name": "B", "primitiveType": "Sphere"}} + ]' + """ + config = get_config() + + try: + commands = json.loads(commands_json) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON: {e}") + sys.exit(1) + + if not isinstance(commands, list): + print_error("Commands must be an array") + sys.exit(1) + + if len(commands) > 25: + print_error(f"Maximum 25 commands per batch, got {len(commands)}") + sys.exit(1) + + params: dict[str, Any] = {"commands": commands} + if parallel: + params["parallel"] = True + if fail_fast: + params["failFast"] = True + + try: + result = run_command("batch_execute", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@batch.command("template") +@click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)") +def batch_template(output: Optional[str]): + """Generate a sample batch commands file. + + \\b + Examples: + unity-mcp batch template > commands.json + unity-mcp batch template -o my_batch.json + """ + template = [ + { + "tool": "manage_scene", + "params": {"action": "get_active"} + }, + { + "tool": "manage_gameobject", + "params": { + "action": "create", + "name": "BatchCube", + "primitiveType": "Cube", + "position": [0, 1, 0] + } + }, + { + "tool": "manage_components", + "params": { + "action": "add", + "target": "BatchCube", + "componentType": "Rigidbody" + } + }, + { + "tool": "manage_gameobject", + "params": { + "action": "modify", + "target": "BatchCube", + "position": [0, 5, 0] + } + } + ] + + json_output = json.dumps(template, indent=2) + + if output: + with open(output, 'w') as f: + f.write(json_output) + print_success(f"Template written to: {output}") + else: + click.echo(json_output) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py new file mode 100644 index 0000000..7f07bc1 --- /dev/null +++ b/Server/src/cli/commands/code.py @@ -0,0 +1,189 @@ +"""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) + + +@code.command("search") +@click.argument("pattern") +@click.argument("path") +@click.option( + "--max-results", "-n", + default=50, + type=int, + help="Maximum number of results (default: 50)." +) +@click.option( + "--case-sensitive", "-c", + is_flag=True, + help="Make search case-sensitive (default: case-insensitive)." +) +def search(pattern: str, path: str, max_results: int, case_sensitive: bool): + """Search for patterns in Unity scripts using regex. + + PATTERN is a regex pattern to search for. + PATH is the script path (e.g., Assets/Scripts/Player.cs). + + \\b + Examples: + unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" + unity-mcp code search "private.*int" "Assets/Scripts/GameManager.cs" + unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs" + """ + import re + import base64 + + 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" + + # Step 1: Read the file via Unity's manage_script + read_params: dict[str, Any] = { + "action": "read", + "name": filename, + "path": directory, + } + + try: + result = run_command("manage_script", read_params, config) + + # Handle nested response structure: {status, result: {success, data}} + inner_result = result.get("result", result) + + if not inner_result.get("success") and result.get("status") != "success": + click.echo(format_output(result, config.format)) + return + + # Get file contents from nested data + data = inner_result.get("data", {}) + contents = data.get("contents") + + # Handle base64 encoded content + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): + try: + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8", "replace") + except (ValueError, TypeError): + pass + + if not contents: + print_error(f"Could not read file content from {path}") + sys.exit(1) + + # Step 2: Perform regex search locally + flags = re.MULTILINE + if not case_sensitive: + flags |= re.IGNORECASE + + try: + regex = re.compile(pattern, flags) + except re.error as e: + print_error(f"Invalid regex pattern: {e}") + sys.exit(1) + + found = list(regex.finditer(contents)) + + if not found: + print_info(f"No matches found for pattern: {pattern}") + return + + results = [] + for m in found[:max_results]: + start_idx = m.start() + + # Calculate line number + line_num = contents.count('\n', 0, start_idx) + 1 + + # Get line content + line_start = contents.rfind('\n', 0, start_idx) + 1 + line_end = contents.find('\n', start_idx) + if line_end == -1: + line_end = len(contents) + + line_content = contents[line_start:line_end].strip() + + results.append({ + "line": line_num, + "content": line_content, + "match": m.group(0), + }) + + # Display results + click.echo(f"Found {len(results)} matches (total: {len(found)}):\n") + for match in results: + click.echo(f" Line {match['line']}: {match['content']}") + + 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 0000000..51b4492 --- /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 0000000..c76e580 --- /dev/null +++ b/Server/src/cli/commands/editor.py @@ -0,0 +1,487 @@ +"""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( + "--async", "async_mode", + is_flag=True, + help="Run asynchronously and return job ID for polling." +) +@click.option( + "--wait", "-w", + type=int, + default=None, + help="Wait up to N seconds for completion (default: no wait)." +) +@click.option( + "--details", + is_flag=True, + help="Include detailed results for all tests." +) +@click.option( + "--failed-only", + is_flag=True, + help="Include details for failed/skipped tests only." +) +def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool): + """Run Unity tests. + + \b + Examples: + unity-mcp editor tests + unity-mcp editor tests --mode PlayMode + unity-mcp editor tests --async + unity-mcp editor tests --wait 60 --failed-only + """ + config = get_config() + + params: dict[str, Any] = {"mode": mode} + if wait is not None: + params["wait_timeout"] = wait + if details: + params["include_details"] = True + if failed_only: + params["include_failed_tests"] = True + + try: + result = run_command("run_tests", params, config) + + # For async mode, just show job ID + if async_mode and result.get("success"): + job_id = result.get("data", {}).get("job_id") + if job_id: + click.echo(f"Test job started: {job_id}") + print_info("Poll with: unity-mcp editor poll-test " + job_id) + return + + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("poll-test") +@click.argument("job_id") +@click.option( + "--wait", "-w", + type=int, + default=30, + help="Wait up to N seconds for completion (default: 30)." +) +@click.option( + "--details", + is_flag=True, + help="Include detailed results for all tests." +) +@click.option( + "--failed-only", + is_flag=True, + help="Include details for failed/skipped tests only." +) +def poll_test(job_id: str, wait: int, details: bool, failed_only: bool): + """Poll an async test job for status/results. + + \b + Examples: + unity-mcp editor poll-test abc123 + unity-mcp editor poll-test abc123 --wait 60 + unity-mcp editor poll-test abc123 --failed-only + """ + config = get_config() + + params: dict[str, Any] = {"job_id": job_id} + if wait: + params["wait_timeout"] = wait + if details: + params["include_details"] = True + if failed_only: + params["include_failed_tests"] = True + + try: + result = run_command("get_test_job", params, config) + click.echo(format_output(result, config.format)) + + if isinstance(result, dict) and result.get("success"): + data = result.get("data", {}) + status = data.get("status", "unknown") + if status == "succeeded": + print_success("Tests completed successfully") + elif status == "failed": + summary = data.get("result", {}).get("summary", {}) + failed = summary.get("failed", 0) + print_error(f"Tests failed: {failed} failures") + elif status == "running": + progress = data.get("progress", {}) + completed = progress.get("completed", 0) + total = progress.get("total", 0) + print_info(f"Tests running: {completed}/{total}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("refresh") +@click.option( + "--mode", + type=click.Choice(["if_dirty", "force"]), + default="if_dirty", + help="Refresh mode." +) +@click.option( + "--scope", + type=click.Choice(["assets", "scripts", "all"]), + default="all", + help="What to refresh." +) +@click.option( + "--compile", + is_flag=True, + help="Request script compilation." +) +@click.option( + "--no-wait", + is_flag=True, + help="Don't wait for refresh to complete." +) +def refresh(mode: str, scope: str, compile: bool, no_wait: bool): + """Force Unity to refresh assets/scripts. + + \b + Examples: + unity-mcp editor refresh + unity-mcp editor refresh --mode force + unity-mcp editor refresh --compile + unity-mcp editor refresh --scope scripts --compile + """ + config = get_config() + + params: dict[str, Any] = { + "mode": mode, + "scope": scope, + "wait_for_ready": not no_wait, + } + if compile: + params["compile"] = "request" + + try: + click.echo("Refreshing Unity...") + result = run_command("refresh_unity", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Unity refreshed") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@editor.command("custom-tool") +@click.argument("tool_name") +@click.option( + "--params", "-p", + default="{}", + help="Tool parameters as JSON." +) +def custom_tool(tool_name: str, params: str): + """Execute a custom Unity tool. + + Custom tools are registered by Unity projects via the MCP plugin. + + \b + Examples: + unity-mcp editor custom-tool "MyCustomTool" + unity-mcp editor custom-tool "BuildPipeline" --params '{"target": "Android"}' + """ + import json + config = get_config() + + try: + params_dict = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for params: {e}") + sys.exit(1) + + try: + result = run_command("execute_custom_tool", { + "tool_name": tool_name, + "parameters": params_dict, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Executed custom tool: {tool_name}") + 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 0000000..f4994a6 --- /dev/null +++ b/Server/src/cli/commands/gameobject.py @@ -0,0 +1,510 @@ +"""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/instance.py b/Server/src/cli/commands/instance.py new file mode 100644 index 0000000..9ce8d7f --- /dev/null +++ b/Server/src/cli/commands/instance.py @@ -0,0 +1,101 @@ +"""Instance CLI commands for managing Unity instances.""" + +import sys +import click +from typing import Optional + +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, run_list_instances, UnityConnectionError + + +@click.group() +def instance(): + """Unity instance management - list, select, and view instances.""" + pass + + +@instance.command("list") +def list_instances(): + """List available Unity instances. + + \\b + Examples: + unity-mcp instance list + """ + config = get_config() + + try: + result = run_list_instances(config) + instances = result.get("instances", []) if isinstance( + result, dict) else [] + + if not instances: + print_info("No Unity instances currently connected") + return + + click.echo("Available Unity instances:") + for inst in instances: + project = inst.get("project", "Unknown") + version = inst.get("unity_version", "Unknown") + hash_id = inst.get("hash", "") + session_id = inst.get("session_id", "") + + # Format: ProjectName@hash (Unity version) + display_id = f"{project}@{hash_id}" if hash_id else project + click.echo(f" • {display_id} (Unity {version})") + if session_id: + click.echo(f" Session: {session_id[:8]}...") + + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@instance.command("set") +@click.argument("instance_id") +def set_instance(instance_id: str): + """Set the active Unity instance. + + INSTANCE_ID can be Name@hash or just a hash prefix. + + \\b + Examples: + unity-mcp instance set "MyProject@abc123" + unity-mcp instance set abc123 + """ + config = get_config() + + try: + result = run_command("set_active_instance", { + "instance": instance_id, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + data = result.get("data", {}) + active = data.get("instance", instance_id) + print_success(f"Active instance set to: {active}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@instance.command("current") +def current_instance(): + """Show the currently selected Unity instance. + + \\b + Examples: + unity-mcp instance current + """ + config = get_config() + + # The current instance is typically shown in telemetry or needs to be tracked + # For now, we can show the configured instance from CLI options + if config.unity_instance: + click.echo(f"Configured instance: {config.unity_instance}") + else: + print_info( + "No instance explicitly set. Using default (auto-select single instance).") + print_info("Use 'unity-mcp instance list' to see available instances.") + print_info("Use 'unity-mcp instance set ' to select one.") diff --git a/Server/src/cli/commands/lighting.py b/Server/src/cli/commands/lighting.py new file mode 100644 index 0000000..ef0cb87 --- /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 0000000..9949281 --- /dev/null +++ b/Server/src/cli/commands/material.py @@ -0,0 +1,268 @@ +"""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) +@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 0000000..3a005fd --- /dev/null +++ b/Server/src/cli/commands/prefab.py @@ -0,0 +1,144 @@ +"""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 0000000..896bed5 --- /dev/null +++ b/Server/src/cli/commands/scene.py @@ -0,0 +1,255 @@ +"""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 0000000..a7f376c --- /dev/null +++ b/Server/src/cli/commands/script.py @@ -0,0 +1,240 @@ +"""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/shader.py b/Server/src/cli/commands/shader.py new file mode 100644 index 0000000..4b8938a --- /dev/null +++ b/Server/src/cli/commands/shader.py @@ -0,0 +1,238 @@ +"""Shader CLI commands for managing Unity shaders.""" + +import sys +import click +from typing import Optional + +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 shader(): + """Shader operations - create, read, update, delete shaders.""" + pass + + +@shader.command("read") +@click.argument("path") +def read_shader(path: str): + """Read a shader file. + + \\b + Examples: + unity-mcp shader read "Assets/Shaders/MyShader.shader" + """ + config = get_config() + + # Extract name from path + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + try: + result = run_command("manage_shader", { + "action": "read", + "name": name, + "path": directory or "Assets/", + }, config) + + # If successful, display the contents nicely + if result.get("success") and result.get("data", {}).get("contents"): + click.echo(result["data"]["contents"]) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("create") +@click.argument("name") +@click.option( + "--path", "-p", + default="Assets/Shaders", + help="Directory to create shader in." +) +@click.option( + "--contents", "-c", + default=None, + help="Shader code (reads from stdin if not provided)." +) +@click.option( + "--file", "-f", + "file_path", + default=None, + type=click.Path(exists=True), + help="Read shader code from file." +) +def create_shader(name: str, path: str, contents: Optional[str], file_path: Optional[str]): + """Create a new shader. + + \\b + Examples: + unity-mcp shader create "MyShader" --path "Assets/Shaders" + unity-mcp shader create "MyShader" --file local_shader.shader + echo "Shader code..." | unity-mcp shader create "MyShader" + """ + config = get_config() + + # Get contents from file, option, or stdin + if file_path: + with open(file_path, 'r') as f: + shader_contents = f.read() + elif contents: + shader_contents = contents + else: + # Read from stdin if available + import sys + if not sys.stdin.isatty(): + shader_contents = sys.stdin.read() + else: + # Provide default shader template + shader_contents = f'''Shader "Custom/{name}" +{{ + Properties + {{ + _Color ("Color", Color) = (1,1,1,1) + _MainTex ("Albedo (RGB)", 2D) = "white" {{}} + }} + SubShader + {{ + Tags {{ "RenderType"="Opaque" }} + LOD 200 + + CGPROGRAM + #pragma surface surf Standard fullforwardshadows + #pragma target 3.0 + + sampler2D _MainTex; + fixed4 _Color; + + struct Input + {{ + float2 uv_MainTex; + }}; + + void surf (Input IN, inout SurfaceOutputStandard o) + {{ + fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; + o.Albedo = c.rgb; + o.Alpha = c.a; + }} + ENDCG + }} + FallBack "Diffuse" +}} +''' + + try: + result = run_command("manage_shader", { + "action": "create", + "name": name, + "path": path, + "contents": shader_contents, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created shader: {path}/{name}.shader") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("update") +@click.argument("path") +@click.option( + "--contents", "-c", + default=None, + help="New shader code." +) +@click.option( + "--file", "-f", + "file_path", + default=None, + type=click.Path(exists=True), + help="Read shader code from file." +) +def update_shader(path: str, contents: Optional[str], file_path: Optional[str]): + """Update an existing shader. + + \\b + Examples: + unity-mcp shader update "Assets/Shaders/MyShader.shader" --file updated.shader + echo "New shader code" | unity-mcp shader update "Assets/Shaders/MyShader.shader" + """ + config = get_config() + + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + # Get contents from file, option, or stdin + if file_path: + with open(file_path, 'r') as f: + shader_contents = f.read() + elif contents: + shader_contents = contents + else: + import sys + if not sys.stdin.isatty(): + shader_contents = sys.stdin.read() + else: + print_error( + "No shader contents provided. Use --contents, --file, or pipe via stdin.") + sys.exit(1) + + try: + result = run_command("manage_shader", { + "action": "update", + "name": name, + "path": directory or "Assets/", + "contents": shader_contents, + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Updated shader: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@shader.command("delete") +@click.argument("path") +@click.option( + "--force", "-f", + is_flag=True, + help="Skip confirmation prompt." +) +def delete_shader(path: str, force: bool): + """Delete a shader. + + \\b + Examples: + unity-mcp shader delete "Assets/Shaders/OldShader.shader" + unity-mcp shader delete "Assets/Shaders/OldShader.shader" --force + """ + config = get_config() + + if not force: + click.confirm(f"Delete shader '{path}'?", abort=True) + + import os + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + + try: + result = run_command("manage_shader", { + "action": "delete", + "name": name, + "path": directory or "Assets/", + }, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted shader: {path}") + 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 0000000..a8a0d5c --- /dev/null +++ b/Server/src/cli/commands/ui.py @@ -0,0 +1,263 @@ +"""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/commands/vfx.py b/Server/src/cli/commands/vfx.py new file mode 100644 index 0000000..a032330 --- /dev/null +++ b/Server/src/cli/commands/vfx.py @@ -0,0 +1,439 @@ +"""VFX CLI commands for managing Unity visual effects.""" + +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 +from cli.utils.connection import run_command, UnityConnectionError + + +@click.group() +def vfx(): + """VFX operations - particle systems, line renderers, trails.""" + pass + + +# ============================================================================= +# Particle System Commands +# ============================================================================= + +@vfx.group() +def particle(): + """Particle system operations.""" + pass + + +@particle.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_info(target: str, search_method: Optional[str]): + """Get particle system info. + + \\b + Examples: + unity-mcp vfx particle info "Fire" + unity-mcp vfx particle info "-12345" --search-method by_id + """ + config = get_config() + params: dict[str, Any] = {"action": "particle_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("play") +@click.argument("target") +@click.option("--with-children", is_flag=True, help="Also play child particle systems.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_play(target: str, with_children: bool, search_method: Optional[str]): + """Play a particle system. + + \\b + Examples: + unity-mcp vfx particle play "Fire" + unity-mcp vfx particle play "Effects" --with-children + """ + config = get_config() + params: dict[str, Any] = {"action": "particle_play", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Playing particle system: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("stop") +@click.argument("target") +@click.option("--with-children", is_flag=True, help="Also stop child particle systems.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_stop(target: str, with_children: bool, search_method: Optional[str]): + """Stop a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_stop", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Stopped particle system: {target}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("pause") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_pause(target: str, search_method: Optional[str]): + """Pause a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_pause", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("restart") +@click.argument("target") +@click.option("--with-children", is_flag=True) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_restart(target: str, with_children: bool, search_method: Optional[str]): + """Restart a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_restart", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@particle.command("clear") +@click.argument("target") +@click.option("--with-children", is_flag=True) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def particle_clear(target: str, with_children: bool, search_method: Optional[str]): + """Clear all particles from a particle system.""" + config = get_config() + params: dict[str, Any] = {"action": "particle_clear", "target": target} + if with_children: + params["withChildren"] = True + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Line Renderer Commands +# ============================================================================= + +@vfx.group() +def line(): + """Line renderer operations.""" + pass + + +@line.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_info(target: str, search_method: Optional[str]): + """Get line renderer info. + + \\b + Examples: + unity-mcp vfx line info "LaserBeam" + """ + config = get_config() + params: dict[str, Any] = {"action": "line_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("set-positions") +@click.argument("target") +@click.option("--positions", "-p", required=True, help='Positions as JSON array: [[0,0,0], [1,1,1], [2,0,0]]') +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_set_positions(target: str, positions: str, search_method: Optional[str]): + """Set all positions on a line renderer. + + \\b + Examples: + unity-mcp vfx line set-positions "Line" --positions "[[0,0,0], [5,2,0], [10,0,0]]" + """ + config = get_config() + + try: + positions_list = json.loads(positions) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for positions: {e}") + sys.exit(1) + + params: dict[str, Any] = { + "action": "line_set_positions", + "target": target, + "positions": positions_list, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("create-line") +@click.argument("target") +@click.option("--start", nargs=3, type=float, required=True, help="Start point X Y Z") +@click.option("--end", nargs=3, type=float, required=True, help="End point X Y Z") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[float, float, float], search_method: Optional[str]): + """Create a simple line between two points. + + \\b + Examples: + unity-mcp vfx line create-line "MyLine" --start 0 0 0 --end 10 5 0 + """ + config = get_config() + params: dict[str, Any] = { + "action": "line_create_line", + "target": target, + "start": list(start), + "end": list(end), + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("create-circle") +@click.argument("target") +@click.option("--center", nargs=3, type=float, default=(0, 0, 0), help="Center point X Y Z") +@click.option("--radius", type=float, required=True, help="Circle radius") +@click.option("--segments", type=int, default=32, help="Number of segments") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_create_circle(target: str, center: Tuple[float, float, float], radius: float, segments: int, search_method: Optional[str]): + """Create a circle shape. + + \\b + Examples: + unity-mcp vfx line create-circle "Circle" --radius 5 --segments 64 + unity-mcp vfx line create-circle "Ring" --center 0 2 0 --radius 3 + """ + config = get_config() + params: dict[str, Any] = { + "action": "line_create_circle", + "target": target, + "center": list(center), + "radius": radius, + "segments": segments, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@line.command("clear") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def line_clear(target: str, search_method: Optional[str]): + """Clear all positions from a line renderer.""" + config = get_config() + params: dict[str, Any] = {"action": "line_clear", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Trail Renderer Commands +# ============================================================================= + +@vfx.group() +def trail(): + """Trail renderer operations.""" + pass + + +@trail.command("info") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_info(target: str, search_method: Optional[str]): + """Get trail renderer info.""" + config = get_config() + params: dict[str, Any] = {"action": "trail_get_info", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@trail.command("set-time") +@click.argument("target") +@click.argument("duration", type=float) +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_set_time(target: str, duration: float, search_method: Optional[str]): + """Set trail duration. + + \\b + Examples: + unity-mcp vfx trail set-time "PlayerTrail" 2.0 + """ + config = get_config() + params: dict[str, Any] = { + "action": "trail_set_time", + "target": target, + "time": duration, + } + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@trail.command("clear") +@click.argument("target") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def trail_clear(target: str, search_method: Optional[str]): + """Clear a trail renderer.""" + config = get_config() + params: dict[str, Any] = {"action": "trail_clear", "target": target} + if search_method: + params["searchMethod"] = search_method + + try: + result = run_command("manage_vfx", params, config) + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +# ============================================================================= +# Raw Command (escape hatch for all VFX actions) +# ============================================================================= + +@vfx.command("raw") +@click.argument("action") +@click.argument("target", required=False) +@click.option("--params", "-p", default="{}", help="Additional parameters as JSON.") +@click.option("--search-method", type=click.Choice(["by_name", "by_path", "by_id", "by_tag"]), default=None) +def vfx_raw(action: str, target: Optional[str], params: str, search_method: Optional[str]): + """Execute any VFX action directly. + + For advanced users who need access to all 60+ VFX actions. + + \\b + Actions include: + particle_*: particle_set_main, particle_set_emission, particle_set_shape, ... + vfx_*: vfx_set_float, vfx_send_event, vfx_play, ... + line_*: line_create_arc, line_create_bezier, ... + trail_*: trail_set_width, trail_set_color, ... + + \\b + Examples: + unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5, "looping": true}' + unity-mcp vfx raw line_create_arc "Arc" --params '{"radius": 3, "startAngle": 0, "endAngle": 180}' + unity-mcp vfx raw vfx_send_event "Explosion" --params '{"eventName": "OnSpawn"}' + """ + config = get_config() + + try: + extra_params = json.loads(params) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for params: {e}") + sys.exit(1) + if not isinstance(extra_params, dict): + print_error("Invalid JSON for params: expected an object") + sys.exit(1) + + request_params: dict[str, Any] = {"action": action} + if target: + request_params["target"] = target + if search_method: + request_params["searchMethod"] = search_method + + # Merge extra params + request_params.update(extra_params) + try: + result = run_command("manage_vfx", request_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/main.py b/Server/src/cli/main.py new file mode 100644 index 0000000..678d3ab --- /dev/null +++ b/Server/src/cli/main.py @@ -0,0 +1,248 @@ +"""Unity MCP Command Line Interface - Main Entry Point.""" + +import sys +from importlib import import_module + +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.""" + def register_optional_command(module_name: str, command_name: str) -> None: + try: + module = import_module(module_name) + except ModuleNotFoundError as e: + if e.name == module_name: + return + print_error( + f"Failed to load command module '{module_name}': {e}" + ) + return + except Exception as e: + print_error( + f"Failed to load command module '{module_name}': {e}" + ) + return + + command = getattr(module, command_name, None) + if command is None: + print_error( + f"Command '{command_name}' not found in '{module_name}'" + ) + return + + cli.add_command(command) + + optional_commands = [ + ("cli.commands.gameobject", "gameobject"), + ("cli.commands.component", "component"), + ("cli.commands.scene", "scene"), + ("cli.commands.asset", "asset"), + ("cli.commands.script", "script"), + ("cli.commands.code", "code"), + ("cli.commands.editor", "editor"), + ("cli.commands.prefab", "prefab"), + ("cli.commands.material", "material"), + ("cli.commands.lighting", "lighting"), + ("cli.commands.animation", "animation"), + ("cli.commands.audio", "audio"), + ("cli.commands.ui", "ui"), + ("cli.commands.instance", "instance"), + ("cli.commands.shader", "shader"), + ("cli.commands.vfx", "vfx"), + ("cli.commands.batch", "batch"), + ] + + for module_name, command_name in optional_commands: + register_optional_command(module_name, command_name) + + +# 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 0000000..54cdad1 --- /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", + "UnityConnectionError", + "format_output", + "get_config", + "print_error", + "print_info", + "print_success", + "print_warning", + "run_check_connection", + "run_command", + "run_list_instances", + "set_config", +] diff --git a/Server/src/cli/utils/config.py b/Server/src/cli/utils/config.py new file mode 100644 index 0000000..47299bb --- /dev/null +++ b/Server/src/cli/utils/config.py @@ -0,0 +1,58 @@ +"""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 0000000..33924e8 --- /dev/null +++ b/Server/src/cli/utils/connection.py @@ -0,0 +1,191 @@ +"""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 0000000..b7dc2d6 --- /dev/null +++ b/Server/src/cli/utils/output.py @@ -0,0 +1,195 @@ +"""Output formatting utilities for CLI.""" + +import json +from typing import Any + +import click + + +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: list[str] | None = 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.""" + click.echo(f"✅ {message}") + + +def print_error(message: str) -> None: + """Print an error message to stderr.""" + click.echo(f"❌ {message}", err=True) + + +def print_warning(message: str) -> None: + """Print a warning message.""" + click.echo(f"⚠️ {message}") + + +def print_info(message: str) -> None: + """Print an info message.""" + click.echo(f"ℹ️ {message}") diff --git a/Server/src/main.py b/Server/src/main.py index 218cbaf..7755fc6 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -1,3 +1,18 @@ +from starlette.requests import Request +from transport.unity_instance_middleware import ( + UnityInstanceMiddleware, + get_unity_instance_middleware +) +from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool +from services.tools import register_all_tools +from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version +from services.resources import register_all_resources +from transport.plugin_registry import PluginRegistry +from transport.plugin_hub import PluginHub +from services.custom_tool_service import CustomToolService +from core.config import config +from starlette.routing import WebSocketRoute +from starlette.responses import JSONResponse import argparse import asyncio import logging @@ -50,22 +65,7 @@ class WindowsSafeRotatingFileHandler(RotatingFileHandler): # On Windows, another process may have the log file open. # Skip rotation this time - we'll try again on the next rollover. pass -from starlette.requests import Request -from starlette.responses import JSONResponse -from starlette.routing import WebSocketRoute -from core.config import config -from services.custom_tool_service import CustomToolService -from transport.plugin_hub import PluginHub -from transport.plugin_registry import PluginRegistry -from services.resources import register_all_resources -from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version -from services.tools import register_all_tools -from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool -from transport.unity_instance_middleware import ( - UnityInstanceMiddleware, - get_unity_instance_middleware -) # Configure logging using settings from config logging.basicConfig( @@ -325,6 +325,75 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: "message": "MCP for Unity server is running" }) + @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 a specific unity_instance was requested but not found, return an error + if not session_id: + return JSONResponse( + { + "success": False, + "error": f"Unity instance '{unity_instance}' not found", + }, + status_code=404, + ) + else: + # No specific unity_instance requested: 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() @@ -464,8 +533,17 @@ Examples: # 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) @@ -502,8 +580,7 @@ Examples: 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/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index 03a0ded..9dabaad 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -20,6 +20,7 @@ from transport.legacy.unity_connection import ( ) from transport.plugin_hub import PluginHub from services.tools import get_unity_instance_from_context +from services.registry import get_registered_tools logger = logging.getLogger("mcp-for-unity-server") @@ -287,9 +288,19 @@ class CustomToolService: def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None: if self._project_scoped_tools: return + builtin_names = self._get_builtin_tool_names() for tool in tools: + if tool.name in builtin_names: + logger.info( + "Skipping global custom tool registration for built-in tool '%s'", + tool.name, + ) + continue self._register_global_tool(tool) + def _get_builtin_tool_names(self) -> set[str]: + return {tool["name"] for tool in get_registered_tools()} + def _register_global_tool(self, definition: ToolDefinitionModel) -> None: existing = self._global_tools.get(definition.name) if existing: diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index 951c7ec..08e00ca 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -246,7 +246,8 @@ class UnityConnection: raise ValueError("MCP call missing command_type") if params is None: return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)") - attempts = max(config.max_retries, 5) if max_attempts is None else max_attempts + attempts = max(config.max_retries, + 5) if max_attempts is None else max_attempts base_backoff = max(0.5, config.retry_delay) def read_status_file(target_hash: str | None = None) -> dict | None: @@ -781,7 +782,8 @@ def send_command_with_retry( # Commands that trigger compilation/reload shouldn't retry on disconnect send_max_attempts = None if retry_on_reload else 0 - response = conn.send_command(command_type, params, max_attempts=send_max_attempts) + response = conn.send_command( + command_type, params, max_attempts=send_max_attempts) retries = 0 wait_started = None reason = _extract_response_reason(response) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py new file mode 100644 index 0000000..9e1bb47 --- /dev/null +++ b/Server/tests/test_cli.py @@ -0,0 +1,1225 @@ +"""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 + + +# ============================================================================= +# Instance Command Tests +# ============================================================================= + +class TestInstanceCommands: + """Tests for instance management commands.""" + + def test_instance_list(self, runner): + """Test listing Unity instances.""" + mock_instances = { + "instances": [ + {"project": "TestProject", "hash": "abc123", + "unity_version": "2022.3.10f1", "session_id": "sess-1"} + ] + } + with patch("cli.commands.instance.run_list_instances", return_value=mock_instances): + result = runner.invoke(cli, ["instance", "list"]) + assert result.exit_code == 0 + assert "TestProject" in result.output + + def test_instance_set(self, runner, mock_unity_response): + """Test setting active instance.""" + with patch("cli.commands.instance.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["instance", "set", "TestProject@abc123"]) + assert result.exit_code == 0 + + def test_instance_current(self, runner): + """Test showing current instance.""" + result = runner.invoke(cli, ["instance", "current"]) + assert result.exit_code == 0 + # Should show info message about no instance set + assert "instance" in result.output.lower() + + +# ============================================================================= +# Shader Command Tests +# ============================================================================= + +class TestShaderCommands: + """Tests for shader commands.""" + + def test_shader_read(self, runner): + """Test reading a shader.""" + read_response = { + "success": True, + "data": {"contents": "Shader \"Custom/Test\" { ... }"} + } + with patch("cli.commands.shader.run_command", return_value=read_response): + result = runner.invoke( + cli, ["shader", "read", "Assets/Shaders/Test.shader"]) + assert result.exit_code == 0 + + def test_shader_create(self, runner, mock_unity_response): + """Test creating a shader.""" + with patch("cli.commands.shader.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["shader", "create", "NewShader", "--path", "Assets/Shaders"]) + assert result.exit_code == 0 + + def test_shader_delete(self, runner, mock_unity_response): + """Test deleting a shader.""" + with patch("cli.commands.shader.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["shader", "delete", "Assets/Shaders/Old.shader", "--force"]) + assert result.exit_code == 0 + + +# ============================================================================= +# VFX Command Tests +# ============================================================================= + +class TestVfxCommands: + """Tests for VFX commands.""" + + def test_vfx_particle_info(self, runner, mock_unity_response): + """Test getting particle system info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "info", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_particle_play(self, runner, mock_unity_response): + """Test playing a particle system.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "play", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_particle_stop(self, runner, mock_unity_response): + """Test stopping a particle system.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "particle", "stop", "Fire"]) + assert result.exit_code == 0 + + def test_vfx_line_info(self, runner, mock_unity_response): + """Test getting line renderer info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "line", "info", "LaserBeam"]) + assert result.exit_code == 0 + + def test_vfx_line_create_line(self, runner, mock_unity_response): + """Test creating a line.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["vfx", "line", "create-line", "Line", "--start", "0", "0", "0", "--end", "10", "5", "0"]) + assert result.exit_code == 0 + + def test_vfx_line_create_circle(self, runner, mock_unity_response): + """Test creating a circle.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["vfx", "line", "create-circle", "Circle", "--radius", "5"]) + assert result.exit_code == 0 + + def test_vfx_trail_info(self, runner, mock_unity_response): + """Test getting trail renderer info.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["vfx", "trail", "info", "Trail"]) + assert result.exit_code == 0 + + def test_vfx_trail_set_time(self, runner, mock_unity_response): + """Test setting trail time.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["vfx", "trail", "set-time", "Trail", "2.0"]) + assert result.exit_code == 0 + + def test_vfx_raw(self, runner, mock_unity_response): + """Test raw VFX action.""" + with patch("cli.commands.vfx.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", '{"duration": 5}']) + assert result.exit_code == 0 + + def test_vfx_raw_invalid_json(self, runner): + """Test raw VFX action with invalid JSON.""" + result = runner.invoke( + cli, ["vfx", "raw", "particle_set_main", "Fire", "--params", "invalid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + +# ============================================================================= +# Batch Command Tests +# ============================================================================= + +class TestBatchCommands: + """Tests for batch commands.""" + + def test_batch_inline(self, runner, mock_unity_response): + """Test inline batch execution.""" + batch_response = { + "success": True, + "data": {"results": [{"success": True}]} + } + with patch("cli.commands.batch.run_command", return_value=batch_response): + result = runner.invoke( + cli, ["batch", "inline", '[{"tool": "manage_scene", "params": {"action": "get_active"}}]']) + assert result.exit_code == 0 + + def test_batch_inline_invalid_json(self, runner): + """Test inline batch with invalid JSON.""" + result = runner.invoke(cli, ["batch", "inline", "not valid json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_batch_template(self, runner): + """Test generating batch template.""" + result = runner.invoke(cli, ["batch", "template"]) + assert result.exit_code == 0 + # Template should be valid JSON + import json + template = json.loads(result.output) + assert isinstance(template, list) + assert len(template) > 0 + assert "tool" in template[0] + + def test_batch_run_file(self, runner, tmp_path, mock_unity_response): + """Test running batch from file.""" + # Create a temp batch file + batch_file = tmp_path / "commands.json" + batch_file.write_text( + '[{"tool": "manage_scene", "params": {"action": "get_active"}}]') + + batch_response = { + "success": True, + "data": {"results": [{"success": True}]} + } + with patch("cli.commands.batch.run_command", return_value=batch_response): + result = runner.invoke(cli, ["batch", "run", str(batch_file)]) + assert result.exit_code == 0 + + +# ============================================================================= +# Enhanced Editor Command Tests +# ============================================================================= + +class TestEditorEnhancedCommands: + """Tests for new editor subcommands.""" + + def test_editor_refresh(self, runner, mock_unity_response): + """Test editor refresh.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "refresh"]) + assert result.exit_code == 0 + + def test_editor_refresh_with_compile(self, runner, mock_unity_response): + """Test editor refresh with compile flag.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "refresh", "--compile"]) + assert result.exit_code == 0 + + def test_editor_custom_tool(self, runner, mock_unity_response): + """Test executing custom tool.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["editor", "custom-tool", "MyTool"]) + assert result.exit_code == 0 + + def test_editor_custom_tool_with_params(self, runner, mock_unity_response): + """Test executing custom tool with parameters.""" + with patch("cli.commands.editor.run_command", return_value=mock_unity_response): + result = runner.invoke( + cli, ["editor", "custom-tool", "BuildTool", "--params", '{"target": "Android"}']) + assert result.exit_code == 0 + + def test_editor_custom_tool_invalid_json(self, runner): + """Test custom tool with invalid JSON params.""" + result = runner.invoke( + cli, ["editor", "custom-tool", "MyTool", "--params", "bad json"]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_editor_tests_async(self, runner): + """Test async test execution.""" + async_response = { + "success": True, + "data": {"job_id": "test-job-123", "status": "running"} + } + with patch("cli.commands.editor.run_command", return_value=async_response): + result = runner.invoke(cli, ["editor", "tests", "--async"]) + assert result.exit_code == 0 + assert "test-job-123" in result.output + + def test_editor_poll_test(self, runner): + """Test polling test job.""" + poll_response = { + "success": True, + "data": { + "job_id": "test-job-123", + "status": "succeeded", + "result": {"summary": {"total": 10, "passed": 10, "failed": 0}} + } + } + with patch("cli.commands.editor.run_command", return_value=poll_response): + result = runner.invoke( + cli, ["editor", "poll-test", "test-job-123"]) + assert result.exit_code == 0 + + +# ============================================================================= +# Code Search Tests +# ============================================================================= + +class TestCodeSearchCommand: + """Tests for code search command.""" + + def test_code_search(self, runner): + """Test code search.""" + # Mock manage_script response with file contents + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "using UnityEngine;\n\npublic class Player : MonoBehaviour\n{\n void Start() {}\n}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke( + cli, ["code", "search", "class.*Player", "Assets/Scripts/Player.cs"]) + assert result.exit_code == 0 + assert "Line 3" in result.output + assert "class Player" in result.output + + def test_code_search_no_matches(self, runner): + """Test code search with no matches.""" + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "using UnityEngine;\n\npublic class Test : MonoBehaviour {}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke( + cli, ["code", "search", "nonexistent", "Assets/Scripts/Test.cs"]) + assert result.exit_code == 0 + assert "No matches" in result.output + + def test_code_search_with_options(self, runner): + """Test code search with options.""" + read_response = { + "status": "success", + "result": { + "success": True, + "data": { + "contents": "// TODO: implement this\n// FIXME: bug here\nclass Test {}\n", + "contentsEncoded": False, + } + } + } + with patch("cli.commands.code.run_command", return_value=read_response): + result = runner.invoke( + cli, ["code", "search", "TODO", "Assets/Utils.cs", "--max-results", "100", "--case-sensitive"]) + assert result.exit_code == 0 + assert "Line 1" in result.output + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Server/uv.lock b/Server/uv.lock index 40c4397..48e2c56 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -912,9 +912,10 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.0.3" +version = "9.0.8" 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" }, diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md new file mode 100644 index 0000000..ad106e7 --- /dev/null +++ b/docs/CLI_USAGE.md @@ -0,0 +1,393 @@ +# Unity MCP CLI Usage Guide + +The Unity MCP CLI provides command-line access to control the Unity Editor through the Model Context Protocol. It currently only supports local HTTP. + +Note: Some tools are still experimental and might fail under some circumstances. Please submit an issue to help us make it better. + +## Installation + +```bash +cd Server +pip install -e . +# Or with uv: +uv pip install -e . +``` + +## Quick Start + +```bash +# Check connection +unity-mcp status + +# List Unity instances +unity-mcp instance list + +# Get scene hierarchy +unity-mcp scene hierarchy + +# Find a GameObject +unity-mcp gameobject find "Player" +``` + +## Global Options + +| Option | Env Variable | Description | +|--------|--------------|-------------| +| `-h, --host` | `UNITY_MCP_HOST` | Server host (default: 127.0.0.1) | +| `-p, --port` | `UNITY_MCP_HTTP_PORT` | Server port (default: 8080) | +| `-t, --timeout` | `UNITY_MCP_TIMEOUT` | Timeout in seconds (default: 30) | +| `-f, --format` | `UNITY_MCP_FORMAT` | Output format: text, json, table | +| `-i, --instance` | `UNITY_MCP_INSTANCE` | Target Unity instance | + +## Command Reference + +### Instance Management + +```bash +# List connected Unity instances +unity-mcp instance list + +# Set active instance +unity-mcp instance set "ProjectName@abc123" + +# Show current instance +unity-mcp instance current +``` + +### Scene Operations + +```bash +# Get scene hierarchy +unity-mcp scene hierarchy +unity-mcp scene hierarchy --limit 20 --depth 3 + +# Get active scene info +unity-mcp scene active + +# Load/save scenes +unity-mcp scene load "Assets/Scenes/Main.unity" +unity-mcp scene save + +# Take screenshot +unity-mcp scene screenshot --name "capture" +``` + +### GameObject Operations + +```bash +# Find GameObjects +unity-mcp gameobject find "Player" +unity-mcp gameobject find "Enemy" --method by_tag + +# Create GameObjects +unity-mcp gameobject create "NewCube" --primitive Cube +unity-mcp gameobject create "Empty" --position 0 5 0 + +# Modify GameObjects +unity-mcp gameobject modify "Cube" --position 1 2 3 --rotation 0 45 0 + +# Delete/duplicate +unity-mcp gameobject delete "OldObject" --force +unity-mcp gameobject duplicate "Template" +``` + +### Component Operations + +```bash +# Add component +unity-mcp component add "Player" Rigidbody + +# Remove component +unity-mcp component remove "Player" Rigidbody + +# Set property +unity-mcp component set "Player" Rigidbody mass 10 +``` + +### Script Operations + +```bash +# Create script +unity-mcp script create "PlayerController" --path "Assets/Scripts" + +# Read script +unity-mcp script read "Assets/Scripts/Player.cs" + +# Delete script +unity-mcp script delete "Assets/Scripts/Old.cs" --force +``` + +### Code Search + +```bash +# Search with regex +unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" +unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs" +unity-mcp code search "void Update" "Assets/Scripts/Game.cs" --max-results 20 +``` + +### Shader Operations + +```bash +# Create shader +unity-mcp shader create "MyShader" --path "Assets/Shaders" + +# Read shader +unity-mcp shader read "Assets/Shaders/Custom.shader" + +# Update from file +unity-mcp shader update "Assets/Shaders/Custom.shader" --file local.shader + +# Delete shader +unity-mcp shader delete "Assets/Shaders/Old.shader" --force +``` + +### Editor Controls + +```bash +# Play mode +unity-mcp editor play +unity-mcp editor pause +unity-mcp editor stop + +# Refresh assets +unity-mcp editor refresh +unity-mcp editor refresh --compile + +# Console +unity-mcp editor console +unity-mcp editor console --clear + +# Tags and layers +unity-mcp editor add-tag "Enemy" +unity-mcp editor add-layer "Projectiles" + +# Menu items +unity-mcp editor menu "Edit/Project Settings..." + +# Custom tools +unity-mcp editor custom-tool "MyBuildTool" +unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}' +``` + +### Testing + +```bash +# Run tests synchronously +unity-mcp editor tests --mode EditMode + +# Run tests asynchronously +unity-mcp editor tests --mode PlayMode --async + +# Poll test job +unity-mcp editor poll-test +unity-mcp editor poll-test --wait 60 --details +``` + +### Material Operations + +```bash +# Create material +unity-mcp material create "Assets/Materials/Red.mat" + +# Set color +unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0 + +# Assign to object +unity-mcp material assign "Assets/Materials/Red.mat" "Cube" +``` + +### VFX Operations + +```bash +# Particle systems +unity-mcp vfx particle info "Fire" +unity-mcp vfx particle play "Fire" --with-children +unity-mcp vfx particle stop "Fire" + +# Line renderers +unity-mcp vfx line info "LaserBeam" +unity-mcp vfx line create-line "Line" --start 0 0 0 --end 10 5 0 +unity-mcp vfx line create-circle "Circle" --radius 5 + +# Trail renderers +unity-mcp vfx trail info "PlayerTrail" +unity-mcp vfx trail set-time "Trail" 2.0 + +# Raw VFX actions (access all 60+ actions) +unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5}' +``` + +### Batch Operations + +```bash +# Execute from JSON file +unity-mcp batch run commands.json +unity-mcp batch run commands.json --parallel --fail-fast + +# Execute inline JSON +unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]' + +# Generate template +unity-mcp batch template > my_commands.json +``` + +### Prefab Operations + +```bash +# Open prefab for editing +unity-mcp prefab open "Assets/Prefabs/Player.prefab" + +# Save and close +unity-mcp prefab save +unity-mcp prefab close + +# Create from GameObject +unity-mcp prefab create "Player" --path "Assets/Prefabs" +``` + +### Asset Operations + +```bash +# Search assets +unity-mcp asset search --pattern "*.mat" --path "Assets/Materials" + +# Get asset info +unity-mcp asset info "Assets/Materials/Red.mat" + +# Create folder +unity-mcp asset mkdir "Assets/NewFolder" + +# Move/rename +unity-mcp asset move "Assets/Old.mat" "Assets/Materials/" +``` + +### Animation Operations + +```bash +# Play animation state +unity-mcp animation play "Player" "Run" + +# Set animator parameter +unity-mcp animation set-parameter "Player" Speed 1.5 +unity-mcp animation set-parameter "Player" IsRunning true +``` + +### Audio Operations + +```bash +# Play audio +unity-mcp audio play "AudioPlayer" + +# Stop audio +unity-mcp audio stop "AudioPlayer" + +# Set volume +unity-mcp audio volume "AudioPlayer" 0.5 +``` + +### Lighting Operations + +```bash +# Create light +unity-mcp lighting create "NewLight" --type Point --position 0 5 0 +unity-mcp lighting create "Spotlight" --type Spot --intensity 2 +``` + +### UI Operations + +```bash +# Create canvas +unity-mcp ui create-canvas "MainCanvas" + +# Create text +unity-mcp ui create-text "Title" --parent "MainCanvas" --text "Hello World" + +# Create button +unity-mcp ui create-button "StartBtn" --parent "MainCanvas" --text "Start" + +# Create image +unity-mcp ui create-image "Background" --parent "MainCanvas" +``` + +### Raw Commands + +For any MCP tool not covered by dedicated commands: + +```bash +unity-mcp raw manage_scene '{"action": "get_hierarchy", "max_nodes": 100}' +unity-mcp raw read_console '{"count": 20}' +``` + +--- + +## Complete Command Reference + +| Group | Subcommands | +|-------|-------------| +| `instance` | `list`, `set`, `current` | +| `scene` | `hierarchy`, `active`, `load`, `save`, `create`, `screenshot`, `build-settings` | +| `gameobject` | `find`, `create`, `modify`, `delete`, `duplicate`, `move` | +| `component` | `add`, `remove`, `set`, `modify` | +| `script` | `create`, `read`, `delete`, `edit`, `validate` | +| `code` | `read`, `search` | +| `shader` | `create`, `read`, `update`, `delete` | +| `editor` | `play`, `pause`, `stop`, `refresh`, `console`, `menu`, `tool`, `add-tag`, `remove-tag`, `add-layer`, `remove-layer`, `tests`, `poll-test`, `custom-tool` | +| `asset` | `search`, `info`, `create`, `delete`, `duplicate`, `move`, `rename`, `import`, `mkdir` | +| `prefab` | `open`, `close`, `save`, `create` | +| `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | +| `vfx particle` | `info`, `play`, `stop`, `pause`, `restart`, `clear` | +| `vfx line` | `info`, `set-positions`, `create-line`, `create-circle`, `clear` | +| `vfx trail` | `info`, `set-time`, `clear` | +| `vfx` | `raw` (access all 60+ actions) | +| `batch` | `run`, `inline`, `template` | +| `animation` | `play`, `set-parameter` | +| `audio` | `play`, `stop`, `volume` | +| `lighting` | `create` | +| `ui` | `create-canvas`, `create-text`, `create-button`, `create-image` | + +--- + +## Output Formats + +```bash +# Text (default) - human readable +unity-mcp scene hierarchy + +# JSON - for scripting +unity-mcp --format json scene hierarchy + +# Table - structured display +unity-mcp --format table instance list +``` + +## Environment Variables + +Set defaults via environment: + +```bash +export UNITY_MCP_HOST=192.168.1.100 +export UNITY_MCP_HTTP_PORT=8080 +export UNITY_MCP_FORMAT=json +export UNITY_MCP_INSTANCE=MyProject@abc123 +``` + +## Troubleshooting + +### Connection Issues + +```bash +# Check server status +unity-mcp status + +# Verify Unity is running with MCP plugin +# Check Unity console for MCP connection messages +``` + +### Common Errors + +| Error | Solution | +|-------|----------| +| Cannot connect to server | Ensure Unity MCP server is running | +| Unknown command type | Unity plugin may not support this tool | +| Timeout | Increase timeout with `-t 60` | diff --git a/prune_tool_results.py b/prune_tool_results.py index a3c5d7a..a99aae0 100755 --- a/prune_tool_results.py +++ b/prune_tool_results.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -import sys, json +import sys +import json + def summarize(txt): try: @@ -7,52 +9,61 @@ def summarize(txt): except Exception: return f"tool_result: {len(txt)} bytes" data = obj.get("data", {}) or {} - msg = obj.get("message") or obj.get("status") or "" + msg = obj.get("message") or obj.get("status") or "" # Common tool shapes if "sha256" in str(data): - ln = data.get("lengthBytes") or data.get("length") or "" + ln = data.get("lengthBytes") or data.get("length") or "" return f"len={ln}".strip() if "diagnostics" in data: diags = data["diagnostics"] or [] - w = sum(d.get("severity","" ).lower()=="warning" for d in diags) - e = sum(d.get("severity","" ).lower() in ("error","fatal") for d in diags) + w = sum(d.get("severity", "").lower() == "warning" for d in diags) + e = sum(d.get("severity", "").lower() in ("error", "fatal") + for d in diags) ok = "OK" if not e else "FAIL" return f"validate: {ok} (warnings={w}, errors={e})" if "matches" in data: m = data["matches"] or [] if m: first = m[0] - return f"find_in_file: {len(m)} match(es) first@{first.get('line',0)}:{first.get('col',0)}" + return f"find_in_file: {len(m)} match(es) first@{first.get('line', 0)}:{first.get('col', 0)}" return "find_in_file: 0 matches" if "lines" in data: # console lines = data["lines"] or [] - lvls = {"info":0,"warning":0,"error":0} + lvls = {"info": 0, "warning": 0, "error": 0} for L in lines: - lvls[L.get("level","" ).lower()] = lvls.get(L.get("level","" ).lower(),0)+1 - return f"console: {len(lines)} lines (info={lvls.get('info',0)},warn={lvls.get('warning',0)},err={lvls.get('error',0)})" + lvls[L.get("level", "").lower()] = lvls.get( + L.get("level", "").lower(), 0)+1 + return f"console: {len(lines)} lines (info={lvls.get('info', 0)},warn={lvls.get('warning', 0)},err={lvls.get('error', 0)})" # Fallback: short status return (msg or "tool_result")[:80] + def prune_message(msg): - if "content" not in msg: return msg - newc=[] + if "content" not in msg: + return msg + newc = [] for c in msg["content"]: - if c.get("type")=="tool_result" and c.get("content"): - out=[] + if c.get("type") == "tool_result" and c.get("content"): + out = [] for chunk in c["content"]: - if chunk.get("type")=="text": - out.append({"type":"text","text":summarize(chunk.get("text","" ))}) - newc.append({"type":"tool_result","tool_use_id":c.get("tool_use_id"),"content":out}) + if chunk.get("type") == "text": + out.append( + {"type": "text", "text": summarize(chunk.get("text", ""))}) + newc.append({"type": "tool_result", "tool_use_id": c.get( + "tool_use_id"), "content": out}) else: newc.append(c) - msg["content"]=newc + msg["content"] = newc return msg + def main(): - convo=json.load(sys.stdin) + convo = json.load(sys.stdin) if isinstance(convo, dict) and "messages" in convo: - convo["messages"]=[prune_message(m) for m in convo["messages"]] + convo["messages"] = [prune_message(m) for m in convo["messages"]] elif isinstance(convo, list): - convo=[prune_message(m) for m in convo] + convo = [prune_message(m) for m in convo] json.dump(convo, sys.stdout, ensure_ascii=False) + + main()