using System; using System.IO; using System.Text; using System.Text.RegularExpressions; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Models; using UnityEditor; namespace Unity.Pipeline.Editor.Commands.Scripts { /// /// Authoring command that writes a new C# MonoBehaviour (or other base-class) script from a /// template into the project under the authoring root (CLI-195). /// /// IMPORTANT — the compile/domain-reload boundary: /// Writing a .cs file does NOT make the type available. Unity must import and compile the new /// file, which triggers a domain reload, before the type exists and can be attached. The agent /// workflow is therefore: /// 1. create_script (this command — writes the file) /// 2. recompile (triggers compilation; see ) /// 3. poll recompile_status (wait until "completed" / "up_to_date") /// 4. attach_script (now the type exists; see ) /// This command intentionally does its part only: it creates the file and returns the asset /// identity. It does not trigger a recompile itself — the agent owns that step so it can batch /// multiple authoring writes before paying the domain-reload cost once. /// public static class CreateScriptCommand { /// /// Default class body (the Start/Update stubs) written inside the generated class, matching /// Unity's own new-MonoBehaviour template so the output reads like a hand-authored script. /// wraps it with the using/class declaration and an optional namespace. /// private const string DefaultBody = " // Use this for initialization\n" + " void Start()\n" + " {\n\n" + " }\n\n" + " // Update is called once per frame\n" + " void Update()\n" + " {\n\n" + " }\n"; [CliCommand("create_script", "Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. " + "NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.")] public static AuthoringResult CreateScript( [CliArg("name", "Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier.", Required = true)] string name, [CliArg("path", "Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root.")] string path = null, [CliArg("namespace", "Optional namespace to wrap the class in. Omit for the global namespace.")] string @namespace = null, [CliArg("base_class", "Base class to derive from. Defaults to MonoBehaviour.")] string baseClass = "MonoBehaviour", [CliArg("overwrite", "Overwrite the file if it already exists. Defaults to false (an existing file is an error).")] bool overwrite = false) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Script 'name' is required."); var className = name.Trim(); if (className.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) className = className.Substring(0, className.Length - 3); if (!IsValidIdentifier(className)) throw new ArgumentException($"Script name '{className}' is not a valid C# class identifier."); if (string.IsNullOrWhiteSpace(baseClass)) baseClass = "MonoBehaviour"; // Resolve & sandbox the destination folder through the project-path policy. A null/empty // path means "the authoring root itself" — Resolve treats an empty path as an error, so // fall back to the configured root in that case (it is already a confined, valid path). string folder; if (string.IsNullOrWhiteSpace(path)) { folder = ProjectPaths.AuthoringRoot; } else { folder = ProjectPaths.Resolve(path, out var error); if (folder == null) throw new ArgumentException(error); } if (!AssetDatabase.IsValidFolder(folder)) throw new ArgumentException( $"Destination folder '{folder}' does not exist. Create it first with create_folder."); var assetPath = $"{folder}/{className}.cs"; if (File.Exists(ToAbsolute(assetPath)) && !overwrite) throw new ArgumentException( $"A script already exists at '{assetPath}'. Pass overwrite=true to replace it."); var source = BuildSource(className, @namespace, baseClass); // Write through the filesystem then import: AssetDatabase has no "create text asset" API, // and CreateAsset is for UnityEngine.Object instances, not source files. File.WriteAllText(ToAbsolute(assetPath), source, new UTF8Encoding(false)); AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); // The MonoScript may already be loadable (its asset entry exists), but its compiled Type // will not exist until the next domain reload — see the class doc. var script = AssetDatabase.LoadAssetAtPath(assetPath); var result = ObjectResolver.Describe(script) ?? new AuthoringResult { Type = "MonoScript" }; result.AssetPath = assetPath; return result; } /// /// Build the script source from the template, substituting the class name, optional namespace /// and base class. When a namespace is supplied the class body is indented one extra level. /// private static string BuildSource(string className, string @namespace, string baseClass) { var usings = "using UnityEngine;\n\n"; var classDecl = $"public class {className} : {baseClass}\n"; var sb = new StringBuilder(); sb.Append(usings); if (!string.IsNullOrWhiteSpace(@namespace)) { sb.Append($"namespace {@namespace}\n{{\n"); sb.Append(Indent(classDecl, " ")); sb.Append(" {\n"); sb.Append(Indent(DefaultBody, " ")); sb.Append(" }\n"); sb.Append("}\n"); } else { sb.Append(classDecl); sb.Append("{\n"); sb.Append(DefaultBody); sb.Append("}\n"); } return sb.ToString(); } private static string Indent(string text, string prefix) { var sb = new StringBuilder(); foreach (var line in text.Split('\n')) sb.Append(line.Length == 0 ? "\n" : prefix + line + "\n"); // Split adds a trailing empty element for a trailing newline; trim the extra newline we // appended for it. if (text.EndsWith("\n") && sb.Length > 0) sb.Length -= 1; return sb.ToString(); } private static bool IsValidIdentifier(string value) { // A C# identifier: starts with a letter or underscore, then letters/digits/underscores. return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$"); } private static string ToAbsolute(string assetPath) { // assetPath is project-relative ("Assets/..."); ProjectRoot is the folder containing Assets/. return Path.Combine(ProjectPaths.ProjectRoot, assetPath).Replace('\\', '/'); } } }