using System; using System.IO; using System.Reflection; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Models; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; #if UNITY_6000_0_OR_NEWER using PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial; #else using PipelinePhysicsMaterial = UnityEngine.PhysicMaterial; #endif namespace Unity.Pipeline.Editor.Commands.Assets { /// /// Asset lifecycle authoring commands (CLI-191): create / import / move / copy / rename / delete /// project assets. They sit on top of the CLI-190 authoring foundation, so: /// /// - every agent-supplied path is funnelled through (sandboxed /// to the authoring root; rejects "../" and out-of-project writes), /// - results are returned as the canonical envelope so an agent can /// reference the asset in a follow-up call, /// - existing assets are addressed with an resolved by /// . /// /// NOTE: AssetDatabase create/move/copy/rename/delete are NOT part of Unity's Undo system, so /// would have no effect here — these operations are intentionally /// not wrapped in an undo scope, and destructive/overwriting operations instead require an explicit /// confirm argument (and support dry_run) so an agent cannot silently lose data. /// public static class AssetCommands { [CliCommand("create_asset", "Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root.")] public static AuthoringResult CreateAsset( [CliArg("path", "Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional.", Required = true)] string path, [CliArg("type", "Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable.", Required = true)] string type, [CliArg("shader", "Material-only (ignored otherwise): shader name to assign (e.g. Standard, \"Universal Render Pipeline/Lit\"). When omitted, defaults to \"Universal Render Pipeline/Lit\" if a Scriptable Render Pipeline is active, otherwise the built-in \"Standard\" shader (falling back to \"Standard\" if URP/Lit is unavailable).")] string shader = null, [CliArg("confirm", "Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty.")] bool confirm = false, [CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false) { var normalized = ProjectPaths.Resolve(path, out var error); if (normalized == null) throw new ArgumentException(error); if (string.IsNullOrEmpty(Path.GetExtension(normalized))) throw new ArgumentException($"Asset path '{normalized}' must include a file extension (e.g. .asset, .mat)."); var resolvedType = ResolveType(type); if (resolvedType == null) throw new ArgumentException($"Could not resolve type '{type}'. Use a fully-qualified name (e.g. UnityEngine.Material)."); if (!typeof(Object).IsAssignableFrom(resolvedType)) throw new ArgumentException($"Type '{resolvedType.FullName}' does not derive from UnityEngine.Object."); // CLI-222: Unity 6 renamed PhysicMaterial -> PhysicsMaterial, but the on-disk asset // extension is still ".physicMaterial". AssetDatabase.CreateAsset rejects a // ".physicsMaterial" file ("should not be used to create a file of type 'physicsMaterial'"), // so accept either spelling from the agent and normalize to the extension Unity writes. if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType) && normalized.EndsWith(".physicsMaterial", StringComparison.OrdinalIgnoreCase)) { normalized = normalized.Substring(0, normalized.Length - ".physicsMaterial".Length) + ".physicMaterial"; } var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null; if (exists && !confirm) throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it."); // CLI-221: for a Material, resolve (and thereby validate) the shader up front so dry_run // also fails fast on an unknown shader — dry_run is documented as "validate inputs". Shader materialShader = null; if (typeof(Material).IsAssignableFrom(resolvedType)) materialShader = ResolveShader(shader); if (dryRun) return new AuthoringResult { AssetPath = normalized, Type = resolvedType.Name }; EnsureParentFolder(normalized); Object asset; if (typeof(ScriptableObject).IsAssignableFrom(resolvedType)) { asset = ScriptableObject.CreateInstance(resolvedType); } else if (typeof(Material).IsAssignableFrom(resolvedType)) { // CLI-221: Material has no public parameterless ctor (Activator.CreateInstance produces // an invalid Material with a null shader), so construct it explicitly from the shader // resolved (and validated) above. The `shader` arg is Material-specific and ignored for // every other type. asset = new Material(materialShader); } else if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType)) { // CLI-222: PhysicsMaterial has a public parameterless ctor, but create it explicitly so // we can stamp the documented sensible defaults rather than relying on engine defaults. asset = new PipelinePhysicsMaterial { dynamicFriction = 0.6f, staticFriction = 0.6f, bounciness = 0f, }; } else { // Other UnityEngine.Object subclasses with a public parameterless ctor. // Activator.CreateInstance throws for abstract types or types without an accessible // parameterless ctor — surface that as an actionable ArgumentException. try { asset = Activator.CreateInstance(resolvedType) as Object; } catch (Exception ex) { throw new ArgumentException( $"Type '{resolvedType.FullName}' could not be instantiated (it may be abstract or lack a public parameterless constructor): {ex.Message}"); } } if (asset == null) throw new ArgumentException($"Type '{resolvedType.FullName}' could not be instantiated as a creatable asset."); // AssetDatabase.CreateAsset does not reliably overwrite an existing asset at the path, so // explicitly delete it first. The confirm guard above gates that an asset already exists. if (exists) AssetDatabase.DeleteAsset(normalized); AssetDatabase.CreateAsset(asset, normalized); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(normalized); var loaded = AssetDatabase.LoadMainAssetAtPath(normalized); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = resolvedType.Name }; result.AssetPath = normalized; return result; } [CliCommand("import_asset", "Import an external file (e.g. a texture, model, audio clip) into the project by copying it to a path under the authoring root, then importing it.")] public static AuthoringResult ImportAsset( [CliArg("source", "Absolute filesystem path to the external file to import.", Required = true)] string source, [CliArg("path", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path, [CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false, [CliArg("dry_run", "If true, validate inputs and report what would be imported without writing anything.")] bool dryRun = false) { if (string.IsNullOrWhiteSpace(source)) throw new ArgumentException("source is required."); if (!File.Exists(source)) throw new ArgumentException($"Source file '{source}' does not exist."); var normalized = ProjectPaths.Resolve(path, out var error); if (normalized == null) throw new ArgumentException(error); var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null || File.Exists(ToAbsolute(normalized)); if (exists && !confirm) throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it."); if (dryRun) return new AuthoringResult { AssetPath = normalized, Type = "ImportedAsset" }; EnsureParentFolder(normalized); File.Copy(source, ToAbsolute(normalized), overwrite: true); AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate); var loaded = AssetDatabase.LoadMainAssetAtPath(normalized); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult(); result.AssetPath = normalized; return result; } [CliCommand("move_asset", "Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID.")] public static AuthoringResult MoveAsset( [CliArg("asset", "Reference to the asset to move (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination, [CliArg("dry_run", "If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it.")] bool dryRun = false) { var sourcePath = ResolveAssetPath(asset); var dest = ProjectPaths.Resolve(destination, out var error); if (dest == null) throw new ArgumentException(error); // For a real move, create the destination's parent folder *before* validating: Unity's // ValidateMoveAsset reports failure ("move as a subdirectory") when the target folder // doesn't exist yet. Dry-run must not create folders, so it validates against the current // project state only. if (!dryRun) EnsureParentFolder(dest); var validation = AssetDatabase.ValidateMoveAsset(sourcePath, dest); if (!string.IsNullOrEmpty(validation)) throw new ArgumentException($"Cannot move '{sourcePath}' to '{dest}': {validation}"); if (dryRun) return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name }; var moveError = AssetDatabase.MoveAsset(sourcePath, dest); if (!string.IsNullOrEmpty(moveError)) throw new ArgumentException($"Failed to move '{sourcePath}' to '{dest}': {moveError}"); var loaded = AssetDatabase.LoadMainAssetAtPath(dest); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult(); result.AssetPath = dest; return result; } [CliCommand("copy_asset", "Copy an asset to a new path under the authoring root. The copy gets a fresh GUID.")] public static AuthoringResult CopyAsset( [CliArg("asset", "Reference to the asset to copy (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination, [CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false, [CliArg("dry_run", "If true, validate inputs and report what would be copied without writing anything.")] bool dryRun = false) { var sourcePath = ResolveAssetPath(asset); var dest = ProjectPaths.Resolve(destination, out var error); if (dest == null) throw new ArgumentException(error); var exists = AssetDatabase.LoadMainAssetAtPath(dest) != null; if (exists && !confirm) throw new ArgumentException($"An asset already exists at '{dest}'. Pass confirm=true to overwrite it."); if (dryRun) return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name }; EnsureParentFolder(dest); // AssetDatabase.CopyAsset fails if the destination already exists, so delete it first when // the caller has confirmed an overwrite (the confirm guard above gates that). if (exists) AssetDatabase.DeleteAsset(dest); if (!AssetDatabase.CopyAsset(sourcePath, dest)) throw new ArgumentException($"Failed to copy '{sourcePath}' to '{dest}'."); var loaded = AssetDatabase.LoadMainAssetAtPath(dest); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult(); result.AssetPath = dest; return result; } [CliCommand("rename_asset", "Rename an asset in place (keeps it in the same folder, keeps its GUID).")] public static AuthoringResult RenameAsset( [CliArg("asset", "Reference to the asset to rename (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("new_name", "New file name WITHOUT a folder path. The extension is preserved if omitted.", Required = true)] string newName, [CliArg("dry_run", "If true, validate the rename without performing it.")] bool dryRun = false) { if (string.IsNullOrWhiteSpace(newName)) throw new ArgumentException("new_name is required."); if (newName.Contains("/") || newName.Contains("\\")) throw new ArgumentException("new_name must be a file name only, not a path. Use move_asset to relocate an asset."); var sourcePath = ResolveAssetPath(asset); // Preserve the original extension when the agent omits one (AssetDatabase.RenameAsset expects no extension). var bareName = Path.GetFileNameWithoutExtension(newName); var extension = Path.GetExtension(sourcePath); var folder = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/'); var destPath = $"{folder}/{bareName}{extension}"; if (AssetDatabase.LoadMainAssetAtPath(destPath) != null) throw new ArgumentException($"An asset already exists at '{destPath}'."); if (dryRun) return new AuthoringResult { AssetPath = destPath, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name }; var renameError = AssetDatabase.RenameAsset(sourcePath, bareName); if (!string.IsNullOrEmpty(renameError)) throw new ArgumentException($"Failed to rename '{sourcePath}': {renameError}"); var loaded = AssetDatabase.LoadMainAssetAtPath(destPath); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult(); result.AssetPath = destPath; return result; } [CliCommand("delete_asset", "Delete an asset from the project. Destructive: requires confirm=true.")] public static AuthoringResult DeleteAsset( [CliArg("asset", "Reference to the asset to delete (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("confirm", "Must be true to actually delete. Without it the command refuses (destructive guard).")] bool confirm = false, [CliArg("dry_run", "If true, report the asset that would be deleted without deleting it.")] bool dryRun = false) { var sourcePath = ResolveAssetPath(asset); // Capture the identity before deletion so the agent gets a record of what was removed. var loaded = AssetDatabase.LoadMainAssetAtPath(sourcePath); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name }; result.AssetPath = sourcePath; if (dryRun) return result; if (!confirm) throw new ArgumentException($"Refusing to delete '{sourcePath}'. Pass confirm=true to delete it (destructive, not undoable via Unity's Undo)."); if (!AssetDatabase.DeleteAsset(sourcePath)) throw new ArgumentException($"Failed to delete '{sourcePath}'."); return result; } [CliCommand("find_assets", "Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required.")] public static FindAssetsResult FindAssets( [CliArg("type", "Type name to filter by (e.g. Material, GameObject, ScriptableObject, MyGame.GameConfig). Resolved to a System.Type and matched against each asset's actual main type.")] string type = null, [CliArg("name", "Name substring to filter by (AssetDatabase name filter).")] string name = null, [CliArg("label", "Asset label to filter by (AssetDatabase 'l:' filter).")] string label = null, [CliArg("search_in", "Folder to scope the search to, relative to the authoring root (default: the authoring root).")] string searchIn = null, [CliArg("limit", "Maximum number of results to return (default 200).")] int limit = 200) { if (string.IsNullOrWhiteSpace(type) && string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(label)) throw new ArgumentException("At least one of type, name, or label is required."); // The type filter is applied by post-filtering on each asset's actual loaded type rather // than via AssetDatabase's "t:" token: "t:" does NOT reliably match freshly-created/custom // ScriptableObject assets (it can return 0 even for "t:ScriptableObject"), though it works // for built-in types. Name/label searches do work, so build the query from those only. var filterParts = new System.Collections.Generic.List(); if (!string.IsNullOrWhiteSpace(name)) filterParts.Add(name.Trim()); if (!string.IsNullOrWhiteSpace(label)) filterParts.Add($"l:{label.Trim()}"); var filter = string.Join(" ", filterParts); // Default the search scope to the authoring root (not the whole project) so an agent only // ever discovers assets within its sandbox unless an explicit scope is given. var scopeError = (string)null; var scope = !string.IsNullOrWhiteSpace(searchIn) ? ProjectPaths.Resolve(searchIn, out scopeError) : ProjectPaths.AuthoringRoot; if (scope == null) throw new ArgumentException(scopeError); var searchFolders = new[] { scope }; // Resolve the type filter up front so an unresolvable name fails fast with a clear message. Type typeFilter = null; if (!string.IsNullOrWhiteSpace(type)) { typeFilter = ResolveTypeName(type.Trim()); if (typeFilter == null) throw new ArgumentException( $"Could not resolve type '{type}'. Use a short name (e.g. Material) or a fully-qualified name (e.g. MyGame.GameConfig)."); } // An empty name/label filter enumerates every asset under the search folders. var guids = AssetDatabase.FindAssets(filter ?? string.Empty, searchFolders); var result = new FindAssetsResult { Filter = filter }; foreach (var guid in guids) { var assetPath = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(assetPath)) continue; var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath); if (typeFilter != null && (mainType == null || !typeFilter.IsAssignableFrom(mainType))) continue; if (result.Assets.Count >= limit) { result.Truncated = true; break; } result.Assets.Add(new AuthoringResult { AssetPath = assetPath, Guid = guid, Type = mainType?.Name }); } result.Count = result.Assets.Count; return result; } /// /// Resolve a user-supplied type name (short or fully-qualified) to a by /// scanning non-dynamic loaded assemblies. Returns null when no match is found. /// private static Type ResolveTypeName(string typeName) { var direct = Type.GetType(typeName, throwOnError: false); if (direct != null) return direct; var physicsMaterialType = ResolvePhysicsMaterialTypeName(typeName); if (physicsMaterialType != null) return physicsMaterialType; foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) { if (assembly.IsDynamic) continue; var byFullName = assembly.GetType(typeName, throwOnError: false); if (byFullName != null) return byFullName; } foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) { if (assembly.IsDynamic) continue; Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types; } foreach (var candidate in types) { if (candidate != null && candidate.Name == typeName) return candidate; } } return null; } /// /// Resolve an to a project-relative asset path, rejecting handles that /// do not resolve to an on-disk asset (scene objects have no asset path). The resolved path is /// confined to the authoring root so a GUID/globalId handle cannot make a destructive command /// (delete/move/copy/rename) operate on an asset outside the sandbox. /// private static string ResolveAssetPath(ObjectRef asset) { var assetPath = ObjectResolver.TryResolve(asset, out var obj, out var error) ? AssetDatabase.GetAssetPath(obj) : null; // Fallback: a path reference that points at an asset which exists on disk (a GUID is // registered) but whose object cannot be loaded — e.g. a just-moved asset whose object // isn't reloadable this frame, or an asset with an unresolved/broken script. We can still // operate on it by path, so honor the path rather than failing the whole command. if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(asset?.Path)) { var candidate = ProjectPaths.Resolve(asset.Path, out _); if (candidate != null && !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(candidate))) assetPath = candidate; } if (string.IsNullOrEmpty(assetPath)) throw new ArgumentException(error ?? $"Reference '{asset}' does not point at an on-disk asset."); // Confine to the authoring root. ProjectPaths.Resolve treats explicit "Assets/..." paths as // project-relative and rejects anything outside the configured root. var confined = ProjectPaths.Resolve(assetPath, out var confineError); if (confined == null) throw new ArgumentException( $"Asset '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}"); return confined; } /// Create the asset's parent folder chain if it does not yet exist. private static void EnsureParentFolder(string assetPath) { var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/'); if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent)) return; CreateFolderRecursive(parent); } private static void CreateFolderRecursive(string assetsPath) { if (AssetDatabase.IsValidFolder(assetsPath)) return; var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/'); var name = Path.GetFileName(assetsPath); if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name)) throw new ArgumentException($"Invalid folder path '{assetsPath}'."); if (!AssetDatabase.IsValidFolder(parent)) CreateFolderRecursive(parent); AssetDatabase.CreateFolder(parent, name); } /// Convert a project-relative asset path to an absolute filesystem path. private static string ToAbsolute(string projectRelative) { return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}"; } /// /// CLI-221: resolve the shader to assign to a newly-created . /// - An explicit must resolve via ; /// a miss is a clear, actionable error (no null Material / null-ref downstream). /// - When omitted, default to the active render pipeline's lit shader: "Universal Render /// Pipeline/Lit" when an SRP asset is active, else built-in "Standard". If that default /// can't be found, fall back to "Standard"; if even that is missing, error clearly. /// private static Shader ResolveShader(string shaderName) { if (!string.IsNullOrWhiteSpace(shaderName)) { var requested = Shader.Find(shaderName.Trim()); if (requested == null) throw new ArgumentException($"Shader '{shaderName}' not found."); return requested; } var usingSrp = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline != null; var defaultName = usingSrp ? "Universal Render Pipeline/Lit" : "Standard"; var defaultShader = Shader.Find(defaultName); if (defaultShader != null) return defaultShader; // Fall back to the built-in Standard shader (always present in the Built-in RP, and a sane // last resort even under an SRP project that lacks the URP/Lit shader). var standard = Shader.Find("Standard"); if (standard != null) return standard; throw new ArgumentException( $"Could not resolve a default shader ('{defaultName}' or 'Standard'). Pass an explicit shader= argument."); } /// /// Resolve a user-supplied type name to a . Tries the name as-is (covers /// fully-qualified names), then scans loaded assemblies for an exact full-name or short-name /// match (covers "Material" or a user type given without its namespace). /// private static Type ResolveType(string typeName) { if (string.IsNullOrWhiteSpace(typeName)) return null; // Trim once up front so every resolution path below (special-case + assembly/type scans) // sees the same normalized name — leading/trailing whitespace must not be accepted for one // type and rejected for another. typeName = typeName.Trim(); // CLI-222: in Unity 6 the type is UnityEngine.PhysicsMaterial (renamed from PhysicMaterial). // A short name "PhysicsMaterial" resolves fine, but the legacy short name "PhysicMaterial" // matches the obsolete forwarder/alias inconsistently across versions — normalize BOTH the // current and legacy names (short or fully-qualified) to the real type up front so callers // can use either spelling. if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial" || typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial") { return typeof(PipelinePhysicsMaterial); } var direct = Type.GetType(typeName, throwOnError: false); if (direct != null) return direct; foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) { var byFullName = assembly.GetType(typeName, throwOnError: false); if (byFullName != null) return byFullName; } // Fall back to a short-name match across all loaded types. foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) { Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types; } foreach (var candidate in types) { if (candidate != null && candidate.Name == typeName) return candidate; } } return null; } private static Type ResolvePhysicsMaterialTypeName(string typeName) { if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial" || typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial") { return typeof(PipelinePhysicsMaterial); } return null; } } }