Editor/Tools/CodeTools.cs

Editor toolset for listing, reading, writing project source files, invoking public static parameterless methods from compiled project code, and retrieving recent compiler diagnostics from the editor log.

File AccessReflection
using System;
using System.IO;
using System.Linq;
using SboxMcp.Integration;
using SboxMcp.Registry;
using static SboxMcp.Tools.AssetTools;

namespace SboxMcp.Tools;

public static class CodeTools
{
	static readonly string[] SkippedDirs = { "\\obj\\", "\\bin\\", "\\.git\\", "/obj/", "/bin/", "/.git/" };
	static readonly string[] SourceExtensions = { ".cs", ".razor", ".scss", ".shader", ".hlsl" };

	[McpTool( "code_list_files", "Lists source files in the project: C# (.cs), UI (.razor/.scss) and shaders. Saving a file hot-reloads automatically.", ToolCategory.Code )]
	public static object ListFiles(
		[Desc( "Subdirectory filter relative to project root, e.g. 'Code/Player'" )] string subdir = null,
		[Desc( "Include files from installed Libraries" )] bool includeLibraries = false )
	{
		var root = ProjectRoot;
		var searchRoot = subdir is null ? root : ResolveInProject( subdir );

		if ( !Directory.Exists( searchRoot ) )
			throw new InvalidOperationException( $"No directory '{subdir}' in the project" );

		var files = Directory.EnumerateFiles( searchRoot, "*.*", SearchOption.AllDirectories )
			.Where( f => SourceExtensions.Contains( Path.GetExtension( f ), StringComparer.OrdinalIgnoreCase ) )
			.Where( f => !SkippedDirs.Any( s => f.Contains( s, StringComparison.OrdinalIgnoreCase ) ) )
			.Where( f => includeLibraries || !f.Contains( Path.DirectorySeparatorChar + "Libraries" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
			.Select( f => Path.GetRelativePath( root, f ).Replace( '\\', '/' ) )
			.OrderBy( f => f )
			.ToArray();

		return new { count = files.Length, files };
	}

	[McpTool( "code_read_file", "Reads a project source file.", ToolCategory.Code )]
	public static object ReadFile( [Desc( "Path relative to project root, e.g. 'Code/Player.cs'" )] string path )
	{
		var absolute = ResolveInProject( path );

		if ( !File.Exists( absolute ) )
			throw new InvalidOperationException( $"No file at '{path}' - use code_list_files" );

		return new { path, content = File.ReadAllText( absolute ) };
	}

	[McpTool( "code_write_file", "Writes a project source file (creating it if missing). The editor hot-reloads changed code automatically; check editor_get_logs / code_get_compile_errors afterwards.", ToolCategory.Code, Writes = true )]
	public static object WriteFile(
		[Desc( "Path relative to project root, e.g. 'Code/Player.cs'" )] string path,
		[Desc( "Full new file content" )] string content )
	{
		var absolute = ResolveInProject( path );

		Directory.CreateDirectory( Path.GetDirectoryName( absolute ) );
		File.WriteAllText( absolute, content );

		return new { written = path, note = "hot-reload triggers automatically; verify with code_get_compile_errors" };
	}

	[McpTool( "code_run_static_method", "Invokes a public static parameterless method from project code - write a method with code_write_file, wait for hot-reload, then call it to test or inspect game state. Returns the method's ToString'd result.", ToolCategory.Code, Writes = true )]
	public static object RunStaticMethod(
		[Desc( "Type name, e.g. 'MyGame.DebugHelpers'" )] string typeName,
		[Desc( "Public static method with no parameters" )] string methodName )
	{
		var type = Sandbox.Internal.GlobalToolsNamespace.EditorTypeLibrary.GetType( typeName )
			?? throw new InvalidOperationException( $"No type '{typeName}' - is it compiled? Check code_get_compile_errors" );

		var method = type.Methods.FirstOrDefault( m => m.IsStatic && m.Name == methodName && m.Parameters.Length == 0 )
			?? throw new InvalidOperationException( $"'{typeName}' has no public static parameterless method '{methodName}'" );

		var result = method.InvokeWithReturn<object>( null, Array.Empty<object>() );
		return new { invoked = $"{typeName}.{methodName}", result = result?.ToString() ?? "null" };
	}

	[McpTool( "code_get_compile_errors", "Gets recent compiler errors and warnings from the editor console.", ToolCategory.Code )]
	public static object GetCompileErrors( int max = 50 )
	{
		var entries = LogCapture.Recent( max, "warning", diagnosticsOnly: true )
			.Select( l => new { time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message } )
			.ToArray();

		// fall back to error-looking log lines if no tagged diagnostics are buffered
		if ( entries.Length == 0 )
		{
			entries = LogCapture.Recent( max, "error" )
				.Where( l => l.Message is not null )
				.Select( l => new { time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message } )
				.ToArray();
		}

		return new
		{
			count = entries.Length,
			note = "Entries come from the editor console log stream. An empty list right after code_write_file may mean compilation has not finished - wait a moment and call again.",
			entries
		};
	}
}