unity-mcp/Server/src/cli/commands/gameobject.py

511 lines
13 KiB
Python
Raw Normal View History

Add CLI (#606) * 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 <david@lighthaus.us> 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>
2026-01-22 08:53:13 +08:00
"""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)