Editor/InvokeMethodHandlers.cs

Editor bridge handler that calls a public instance method by name on a live scene GameObject component. It resolves a GameObject by GUID, finds a matching public method (by name and argument count, with a case-insensitive fallback), coerces JSON arguments to the method parameter types using shared ClaudeBridge helpers, invokes the method via reflection, and returns a summary including the result string or an error object.

ReflectionFile Access
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
// invoke_method — call a public method BY NAME on a live GameObject component,
// passing ARGUMENTS.
//
// This is the with-args sibling of invoke_button. invoke_button matches a
// [Button] label or a PARAMETERLESS method name on a scene component; this
// handler resolves a specific GameObject by GUID, finds a public method whose
// NAME + ARG-COUNT match, coerces each JSON arg to that method's parameter type
// via the SAME coercion idiom the property setters use
// (ClaudeBridge.CoercePropertyAndSet / ElementToValueString), invokes it, and
// returns the (null-safe) ToString() of the result.
//
// Lives in the SAME assembly as MyEditorMenu.cs, so it reuses the shared
// ClaudeBridge helpers (ResolveGameObject, ElementToValueString,
// CoercePropertyAndSet) and the IBridgeHandler dispatch contract. This is
// UNSANDBOXED editor code: System.* is fine (System.Reflection, etc.) — only
// the C# we WRITE TO DISK has to obey the sandbox (MathX, etc.), and this
// handler writes nothing.
//
// Failure contract: every error path returns `new { error = ... }`, which the
// dispatch envelope (ClaudeBridge.TryGetHandlerError) reports as success=false.
//
// Registration line + mutating note are in the implementation summary —
// MyEditorMenu.cs owns RegisterHandlers (this file stays decoupled).
// ═══════════════════════════════════════════════════════════════════════════

/// <summary>
/// invoke_method — call a named public method (with args) on a component of a
/// live scene GameObject. Params:
///   id        : GameObject GUID (required)
///   method    : method name (required)
///   component : component type-name to target (optional; else searches all
///               components on the object for a matching method)
///   args      : JSON array of arguments (optional; defaults to none)
/// </summary>
public class InvokeMethodHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var scene = SceneEditorSession.Active?.Scene;
			if ( scene == null )
				return Task.FromResult<object>( new { error = "No active scene" } );

			// ── id (GameObject GUID) ──────────────────────────────────────────
			var id = p.TryGetProperty( "id", out var idEl ) ? idEl.GetString() : null;
			if ( string.IsNullOrEmpty( id ) )
				return Task.FromResult<object>( new { error = "id is required (the GameObject GUID)" } );

			var go = ClaudeBridge.ResolveGameObject( scene, id );
			if ( go == null )
				return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );

			// ── method name ───────────────────────────────────────────────────
			var methodName = p.TryGetProperty( "method", out var mEl ) ? mEl.GetString() : null;
			if ( string.IsNullOrEmpty( methodName ) )
				return Task.FromResult<object>( new { error = "method is required (the public method name to call)" } );

			// ── args (optional JSON array) ────────────────────────────────────
			JsonElement[] argEls = System.Array.Empty<JsonElement>();
			if ( p.TryGetProperty( "args", out var argsEl ) && argsEl.ValueKind == JsonValueKind.Array )
				argEls = argsEl.EnumerateArray().ToArray();
			int argCount = argEls.Length;

			// ── candidate components ──────────────────────────────────────────
			// Optionally restrict to one component type (case-insensitive short name);
			// otherwise search every component on the object.
			var componentType = p.TryGetProperty( "component", out var cEl ) ? cEl.GetString() : null;

			List<Component> candidates;
			if ( !string.IsNullOrEmpty( componentType ) )
			{
				candidates = go.Components.GetAll()
					.Where( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) )
					.ToList();
				if ( candidates.Count == 0 )
					return Task.FromResult<object>( new { error = $"Component not found on object: {componentType}" } );
			}
			else
			{
				candidates = go.Components.GetAll().ToList();
				if ( candidates.Count == 0 )
					return Task.FromResult<object>( new { error = $"GameObject '{go.Name}' has no components" } );
			}

			// ── find a matching public method: name + arg-count ───────────────
			// Track the names we saw so a wrong arg-count produces a helpful error.
			Component targetComp = null;
			MethodInfo targetMethod = null;
			var nameMatchesWrongArity = new List<string>();

			foreach ( var comp in candidates )
			{
				var compType = comp.GetType();
				foreach ( var method in compType.GetMethods( BindingFlags.Public | BindingFlags.Instance ) )
				{
					if ( !method.Name.Equals( methodName, StringComparison.Ordinal ) )
						continue;
					if ( method.IsGenericMethodDefinition )
						continue; // can't bind type args from JSON
					if ( method.GetParameters().Length != argCount )
					{
						nameMatchesWrongArity.Add( $"{compType.Name}.{method.Name}({method.GetParameters().Length} arg(s))" );
						continue;
					}
					targetComp = comp;
					targetMethod = method;
					break;
				}
				if ( targetMethod != null ) break;
			}

			// Second pass: tolerate a case-insensitive name match if no exact one was found.
			if ( targetMethod == null )
			{
				foreach ( var comp in candidates )
				{
					var compType = comp.GetType();
					foreach ( var method in compType.GetMethods( BindingFlags.Public | BindingFlags.Instance ) )
					{
						if ( !method.Name.Equals( methodName, StringComparison.OrdinalIgnoreCase ) )
							continue;
						if ( method.IsGenericMethodDefinition )
							continue;
						if ( method.GetParameters().Length != argCount )
						{
							nameMatchesWrongArity.Add( $"{compType.Name}.{method.Name}({method.GetParameters().Length} arg(s))" );
							continue;
						}
						targetComp = comp;
						targetMethod = method;
						break;
					}
					if ( targetMethod != null ) break;
				}
			}

			if ( targetMethod == null )
			{
				if ( nameMatchesWrongArity.Count > 0 )
					return Task.FromResult<object>( new
					{
						error = $"Method '{methodName}' exists but not with {argCount} arg(s). Overloads found: {string.Join( ", ", nameMatchesWrongArity.Distinct() )}."
					} );

				var where = string.IsNullOrEmpty( componentType ) ? $"any component on '{go.Name}'" : componentType;
				return Task.FromResult<object>( new
				{
					error = $"No public method named '{methodName}' (with {argCount} arg(s)) found on {where}."
				} );
			}

			// ── coerce each JSON arg to its parameter type ────────────────────
			var ps = targetMethod.GetParameters();
			var callArgs = new object[ps.Length];
			for ( int i = 0; i < ps.Length; i++ )
			{
				var pType = ps[i].ParameterType;

				// Flatten the JSON token to the string form CoercePropertyAndSet expects
				// (scalars as-is, arrays joined "1,2,3", objects as raw JSON), then route
				// through the shared, type-aware coercion. This gives us the SAME support
				// the property setters have: primitives/enums/Vector3/Color/Rotation, asset
				// refs (Model/Material/SoundEvent…) by path, and GameObject/Component refs
				// by GUID — built into the correct typed value rather than a raw string.
				var valStr = ClaudeBridge.ElementToValueString( argEls[i] );

				int idx = i;
				object coerced = null;
				if ( !ClaudeBridge.CoercePropertyAndSet(
						pType,
						v => coerced = v,
						$"{targetMethod.Name} arg[{idx}] ({ps[idx].Name})",
						valStr,
						out var coerceErr ) )
				{
					return Task.FromResult<object>( new
					{
						error = $"Could not coerce arg[{idx}] for {targetComp.GetType().Name}.{targetMethod.Name}: {coerceErr}"
					} );
				}
				callArgs[i] = coerced;
			}

			// ── invoke ────────────────────────────────────────────────────────
			object rawResult;
			try
			{
				rawResult = targetMethod.Invoke( targetComp, callArgs );
			}
			catch ( TargetInvocationException tie )
			{
				// Unwrap so the real exception surfaces, not the reflection wrapper.
				var inner = tie.InnerException ?? tie;
				return Task.FromResult<object>( new
				{
					error = $"{targetComp.GetType().Name}.{targetMethod.Name} threw {inner.GetType().Name}: {inner.Message}"
				} );
			}

			// null-safe result stringification. void methods → null result.
			bool isVoid = targetMethod.ReturnType == typeof( void );
			string resultStr = isVoid ? null : ( rawResult?.ToString() ?? "null" );

			return Task.FromResult<object>( new
			{
				invoked = true,
				id,
				component = targetComp.GetType().Name,
				method = targetMethod.Name,
				argCount,
				returnType = targetMethod.ReturnType.Name,
				result = resultStr
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"invoke_method failed: {ex.Message}" } );
		}
	}
}