Editor/Tools/AssetTools.cs

Editor tool helpers for managing project assets. Provides functions to search assets, get info, compile, create/duplicate resources, read and write raw asset files, and resolve paths jailed to the project.

File Access
using System;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using SboxMcp.Server;

namespace SboxMcp.Tools;

public static class AssetTools
{
	internal static string ProjectRoot =>
		Project.Current?.GetRootPath()
		?? throw new InvalidOperationException( "No project is loaded" );

	internal static string ResolveInProject( string path ) => PathJail.Resolve( ProjectRoot, path );

	/// <summary>Returns an asset's editable source-file path, or throws a clear
	/// error for mounted/engine/compiled-only assets that have none (avoids the
	/// cryptic "path cannot be empty" from File.ReadAllText on a null source).</summary>
	public static string RequireSourceFile( Asset asset )
	{
		var source = asset.GetSourceFile( true );
		if ( string.IsNullOrEmpty( source ) || !File.Exists( source ) )
			throw new InvalidOperationException(
				$"'{asset.Path}' has no editable local source - it's a mounted/engine or compiled-only asset. Duplicate it into your project with asset_duplicate to edit it." );

		return source;
	}

	/// <summary>
	/// Resolves a path for a NEW asset file. Plain asset paths like
	/// 'models/new.vmdl' land in the project's Assets mount (where the asset
	/// system can register them); explicit 'Assets/...'-style or absolute
	/// paths resolve against the project root. Always jailed to the project.
	/// </summary>
	internal static string ResolveNewAssetPath( string path )
	{
		var rootResolved = ResolveInProject( path );

		if ( Path.IsPathRooted( path ) || File.Exists( rootResolved ) )
			return rootResolved;

		var assets = Project.Current?.GetAssetsPath();
		if ( assets is null )
			return rootResolved;

		// already targeting the assets folder explicitly?
		var assetsResolved = PathJail.Resolve( ProjectRoot, Path.Combine( assets, path ) );
		return rootResolved.StartsWith( Path.GetFullPath( assets ), StringComparison.OrdinalIgnoreCase )
			? rootResolved
			: assetsResolved;
	}

	[McpTool( "asset_search", "Searches project assets by name and/or type extension (vmdl, vmat, prefab, scene, vanmgrph, shdrgrph...).", ToolCategory.Asset )]
	public static object Search(
		[Desc( "Name/path substring (case-insensitive); omit for all" )] string query = null,
		[Desc( "File extension filter without dot, e.g. 'vmdl'" )] string assetType = null,
		int max = 50 )
	{
		var results = AssetSystem.All
			.Where( a => query is null || a.Path.Contains( query, StringComparison.OrdinalIgnoreCase ) )
			.Where( a => assetType is null
				|| string.Equals( a.AssetType?.FileExtension, assetType.TrimStart( '.' ), StringComparison.OrdinalIgnoreCase ) )
			.Take( max )
			.Select( a => new
			{
				path = a.Path,
				type = a.AssetType?.FileExtension,
				compiled = a.IsCompiled
			} )
			.ToArray();

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

	[McpTool( "asset_get_info", "Gets details for one asset by path.", ToolCategory.Asset )]
	public static object GetInfo( [Desc( "Asset path, e.g. 'models/crate.vmdl'" )] string path )
	{
		var asset = AssetSystem.FindByPath( path )
			?? throw new InvalidOperationException( $"No asset at '{path}' - use asset_search" );

		return new
		{
			path = asset.Path,
			absolutePath = asset.AbsolutePath,
			type = asset.AssetType?.FriendlyName,
			extension = asset.AssetType?.FileExtension,
			compiled = asset.IsCompiled,
			canRecompile = asset.CanRecompile
		};
	}

	[McpTool( "asset_compile", "Compiles (or recompiles) an asset.", ToolCategory.Asset, Writes = true )]
	public static object Compile( [Desc( "Asset path" )] string path )
	{
		var asset = AssetSystem.FindByPath( path )
			?? throw new InvalidOperationException( $"No asset at '{path}' - use asset_search" );

		asset.Compile( true );
		return new { path = asset.Path, compiled = asset.IsCompiled };
	}

	[McpTool( "asset_create_resource", "Creates a new empty game resource asset (scene, prefab, or any custom GameResource extension).", ToolCategory.Asset, Writes = true )]
	public static object CreateResource(
		[Desc( "Resource type extension without dot, e.g. 'prefab', 'scene'" )] string type,
		[Desc( "Project-relative output path including extension, e.g. 'Assets/prefabs/new.prefab'" )] string path )
	{
		var absolute = ResolveNewAssetPath( path );

		if ( File.Exists( absolute ) )
			throw new InvalidOperationException( $"'{path}' already exists - use asset_write_raw to overwrite, or pick another path" );

		Directory.CreateDirectory( Path.GetDirectoryName( absolute ) );

		var asset = AssetSystem.CreateResource( type.TrimStart( '.' ), absolute )
			?? throw new InvalidOperationException( $"Could not create a '{type}' resource at '{path}' - is '{type}' a known resource extension? Check an existing one with asset_search" );

		return new { created = asset.Path, type = asset.AssetType?.FriendlyName };
	}

	[McpTool( "asset_duplicate", "Copies an existing asset to a new path (e.g. clone a material to tweak it), registers and compiles the copy.", ToolCategory.Asset, Writes = true )]
	public static object Duplicate(
		[Desc( "Source asset path" )] string sourcePath,
		[Desc( "New asset path (same extension), e.g. 'materials/crate_red.vmat'" )] string destPath )
	{
		var source = AssetSystem.FindByPath( sourcePath )
			?? throw new InvalidOperationException( $"No asset at '{sourcePath}' - use asset_search" );

		var sourceFile = source.GetSourceFile( true );
		if ( sourceFile is null || !File.Exists( sourceFile ) )
			throw new InvalidOperationException( $"'{sourcePath}' has no local source file to copy" );

		var absolute = ResolveNewAssetPath( destPath );
		if ( File.Exists( absolute ) )
			throw new InvalidOperationException( $"'{destPath}' already exists - pick another path" );

		Directory.CreateDirectory( Path.GetDirectoryName( absolute ) );
		File.Copy( sourceFile, absolute );

		var asset = AssetSystem.RegisterFile( absolute );
		asset?.Compile( true );

		return new { duplicated = sourcePath, to = destPath, registered = asset is not null, compiled = asset?.IsCompiled ?? false };
	}

	[McpTool( "asset_read_raw", "Reads an asset's source file as text (KV3/JSON formats are text).", ToolCategory.Asset )]
	public static object ReadRaw( [Desc( "Asset path or project-relative file path" )] string path )
	{
		var absolute = AssetSystem.FindByPath( path )?.GetSourceFile( true ) ?? ResolveInProject( path );

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

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

	[McpTool( "asset_write_raw", "Writes text to an asset source file, registers it with the asset system and compiles it. Use for any text-based asset format.", ToolCategory.Asset, Writes = true )]
	public static object WriteRaw(
		[Desc( "Asset path or project-relative file path" )] string path,
		[Desc( "Full new file content" )] string content )
	{
		var existing = AssetSystem.FindByPath( path );
		var absolute = existing?.GetSourceFile( true ) ?? ResolveNewAssetPath( path );

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

		var asset = existing ?? AssetSystem.RegisterFile( absolute );
		asset?.Compile( true );

		return new
		{
			written = path,
			registered = asset is not null,
			compiled = asset?.IsCompiled ?? false
		};
	}
}