Files

17 KiB

Creating authoring commands

Authoring commands are the subset of commands that create or mutate project content — assets, scenes, GameObjects, components — on behalf of an agent. They build on the base command API in Creating commands; this page covers the four concerns that are specific to content authoring:

  • The authoring root — the sandbox that bare paths resolve against and writes are confined to.
  • ObjectRef — how a command receives a reference to an existing object.
  • AuthoringResult — how a command returns the identity of an object it created or touched.
  • Undo/redo — grouping a command's scene mutations into a single, revertible step.

Together these let one command's output feed the next command's input, so an agent can chain calls (create_gameobjectadd_componentset_component_propertiescreate_prefab) without ever handling a raw Unity object itself. If you want to support a content type the package doesn't cover yet (materials, audio, lighting, terrain, …), this is the pattern to follow.

Read Creating commands first — this page assumes you know how [CliCommand], [CliArg], MainThreadRequired, and the response envelope work.

Authoring vs. management commands

Not every state-changing command is an authoring command. This page — and the checklist at the end — applies to commands that create or mutate project content (assets, scenes, GameObjects, components) and hand the agent back an object identity.

Management commands change configuration or drive tooling rather than author content: project settings (Editor/Commands/ProjectSettings/), builds (Build/), and packages (PackageManager/). They are a different category and deliberately do not follow the full authoring contract. They share only the cross-cutting safety conventions (the confirm/dry_run gate and, where relevant, ObjectResolver/ProjectPaths) — see Safety & mutations.

Concern Authoring command Management command
Object input ObjectRefObjectResolver.TryResolve same, when it references an object (e.g. a scene in the build list)
Path input ProjectPaths.Resolve (confined to the authoring root) as needed; may be unconfined by design (e.g. a build output path)
Undo scene/object mutations wrapped in AuthoringUndoScope + registered Undo APIs none — settings / AssetDatabase / UPM writes don't participate in Undo, so no scope; note Not undoable via Ctrl+Z. in the description instead
Destructive gate confirm/dry_run where destructive confirm/dry_run where destructive (same convention)
Return value AuthoringResult via ObjectResolver.Describe a domain model (e.g. ProjectSettingsResponse, PackageMutationResponse) or a plain result object
Failure throw (ArgumentException / InvalidOperationException) throw, or return a structured { success = false, code, error } object — consistent within the area

If you're adding a config/build/package command, follow the management column and the shared safety conventions; the checklist below is for content-authoring commands.

The building blocks

Type Namespace Role
ProjectPaths Unity.Pipeline.Editor.Authoring Resolve/confine agent-supplied paths to the authoring root.
ObjectRef Unity.Pipeline.Models Input handle to an existing object (asset or scene object).
AuthoringResult Unity.Pipeline.Models Output identity of an object your command created or acted on.
ObjectResolver Unity.Pipeline.Editor.Authoring TryResolve(ObjectRef …) (handle → object) and Describe(object) (object → AuthoringResult).
AuthoringUndoScope Unity.Pipeline.Editor.Authoring Collapses all Undo-registered mutations in its lifetime into one editor undo step.

Authoring commands are Editor-only. Put them under Editor/Commands/<Area>/ in the Unity.Pipeline.Editor.Commands.<Area> namespace (see Editor/Commands/Authoring/AuthoringConfigCommands.cs).

The authoring root (set_authoring_root)

Agents pass bare, relative paths ("Materials/Stone"), not full project paths. ProjectPaths resolves those against a configurable authoring root and confines every write to it. The root defaults to Assets (full project access); an agent can narrow it to a sub-folder to sandbox itself:

get_authoring_root                      → { "root": "Assets" }
set_authoring_root --root Assets/AgentWork

These are just thin commands over ProjectPaths.AuthoringRoot (Editor/Commands/Authoring/AuthoringConfigCommands.cs):

[CliCommand("set_authoring_root", "Set the base folder (under Assets/) that bare authoring paths resolve against and are confined to. Use 'Assets' for full project access.")]
public static object SetAuthoringRoot(
    [CliArg("root", "Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project.", Required = true)] string root)
{
    // Throws ArgumentException for invalid roots (outside Assets/ or containing ".."); the
    // server surfaces that message to the caller.
    ProjectPaths.AuthoringRoot = root;
    return new { root = ProjectPaths.AuthoringRoot };
}

Every path parameter your command accepts must go through ProjectPaths.Resolve. This is the sandbox boundary — it rejects .. traversal and anything that escapes the root, and it lets callers omit the Assets/ prefix. Do not build asset paths by string concatenation.

var normalized = ProjectPaths.Resolve(path, out var error);
if (normalized == null)
    throw new ArgumentException(error);   // e.g. "Path '../secrets' must not contain '..'."
// normalized is now a project-relative path guaranteed to live under the authoring root.

Resolution rules (Editor/Authoring/ProjectPaths.cs):

  • Bare paths ("Materials/Stone") are taken relative to the root → Assets/AgentWork/Materials/Stone.
  • Explicit Assets/… / Packages/… paths are used as-is (but still confined to the root).
  • Absolute paths must live under the project root and are converted to project-relative.
  • .. anywhere, or a result outside the root, returns null + an error string.

Receiving objects: ObjectRef

When a command needs to act on an object that already exists, take an ObjectRef parameter. The agent supplies one of several forms; ObjectResolver.TryResolve tries them in order — globalId, path, guid (+ optional fileId), instanceId, hierarchyPath — and hands you the live object:

public static Renderer ResolveRenderer(ObjectRef target)
{
    if (!ObjectResolver.TryResolve(target, out var obj, out var error))
        throw new ArgumentException(error);

    var go = obj as GameObject ?? (obj as Component)?.gameObject;
    var renderer = go != null ? go.GetComponent<Renderer>() : null;
    if (renderer == null)
        throw new ArgumentException($"Object '{target}' has no Renderer.");
    return renderer;
}

Resolve outside any undo scope / before mutating, so a bad handle fails before your command changes anything (see create_gameobject, which resolves its parent before entering the scope).

When the handle is a plain string (the usual agent input), a value with a file extension and no leading / — e.g. "Materials/Floor.mat" — is taken as an asset path and normalized under the authoring root, so the Assets/ prefix is optional (mirroring path-taking commands). A leading / or an extension-less value stays a hierarchyPath; a dotted scene name like "Cube.001" still resolves because the path branch falls back to a hierarchy lookup.

Returning objects: AuthoringResult

Any command that creates or modifies an object should return its identity so the agent can reference it in a follow-up call. Don't build this by hand — call ObjectResolver.Describe(obj), which fills in the right fields for the object kind:

  • Assets get assetPath, guid, fileId, type (+ globalId).
  • Scene / loaded objects get instanceId, hierarchyPath, type (+ globalId).
var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(Material) };
result.AssetPath = assetPath;   // ensure the path is set even if Describe returned a fresh result
return result;

AuthoringResult is identity only — no success flag, no message. Success/failure and timing live in the outer CommandExecutionResponse (the server adds them). To report a failure, throw (ArgumentException for bad input, InvalidOperationException for an operation that failed); the server converts the exception into a failure envelope. For a batch, return a model that holds an AuthoringResult[] (see CreateGameObjectsResult).

Undo/redo

Undo is Unity's native UnityEditor.Undo, grouped per command by AuthoringUndoScope so a single call reverts as a single Ctrl+Z step. Register each mutation with the matching Undo API inside the scope:

using (new AuthoringUndoScope("Set Material"))
{
    Undo.RecordObject(renderer, "Set Material");   // record BEFORE mutating
    renderer.sharedMaterial = material;
    EditorSceneManager.MarkSceneDirty(renderer.gameObject.scene);
}

Which Undo call to use:

Mutation Call
New scene object Undo.RegisterCreatedObjectUndo(obj, name)
Change fields on an existing object Undo.RecordObject(obj, name) / RegisterCompleteObjectUndo (before the change)
Add a component Undo.AddComponent(go, type)
Reparent Undo.SetTransformParent(child, parent, name)
Serialized properties SerializedObject + so.ApplyModifiedProperties() (registers undo itself)

Important caveat. AssetDatabase operations are not part of Unity's undo system. Creating a folder or asset, importing, AssetDatabase.CreateAsset, SaveAsPrefabAsset, and file writes are not undone by Ctrl+Z. AuthoringUndoScope only covers scene/object mutations. If your command writes to disk, say so in its description and don't rely on undo to clean up — validate up front and fail before writing.

Worked example — a new content type

Say the package has no material (rendering) commands and you want to add them. Two commands cover the whole pattern: one that creates an asset (path handling + AuthoringResult out, no undo because it's an AssetDatabase op) and one that mutates a scene object (ObjectRef in + undo).

using System.IO;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

namespace Unity.Pipeline.Editor.Commands.Rendering
{
    /// <summary>Authoring commands for materials (rendering).</summary>
    public static class MaterialCommands
    {
        [CliCommand("create_material",
            "Create a Material asset (default shader 'Standard') under the authoring root. " +
            "NOTE: asset creation is not undoable via Ctrl+Z.")]
        public static AuthoringResult CreateMaterial(
            [CliArg("path", "Asset path relative to the authoring root; the Assets/ prefix and the .mat extension are optional. e.g. Materials/Stone", Required = true)] string path,
            [CliArg("shader", "Shader name to assign. Defaults to 'Standard'.")] string shader = "Standard")
        {
            // 1. Resolve + confine the path (the sandbox boundary).
            var normalized = ProjectPaths.Resolve(path, out var error);
            if (normalized == null)
                throw new ArgumentException(error);
            if (!normalized.EndsWith(".mat", System.StringComparison.OrdinalIgnoreCase))
                normalized += ".mat";

            var found = Shader.Find(shader);
            if (found == null)
                throw new ArgumentException($"Shader '{shader}' was not found.");

            // 2. Do the work (AssetDatabase op — no undo scope; it wouldn't be undoable anyway).
            EnsureParentFolder(normalized);
            var material = new Material(found);
            AssetDatabase.CreateAsset(material, normalized);
            AssetDatabase.SaveAssets();

            // 3. Return the created object's identity so the agent can reference it next.
            var result = ObjectResolver.Describe(material) ?? new AuthoringResult { Type = nameof(Material) };
            result.AssetPath = normalized;
            return result;
        }

        [CliCommand("set_material", "Assign a material asset to a Renderer on a scene GameObject.")]
        public static AuthoringResult SetMaterial(
            [CliArg("target", "Handle of the GameObject (or Renderer) to modify.", Required = true)] ObjectRef target,
            [CliArg("material", "Handle of the material asset to assign (path/guid/globalId).", Required = true)] ObjectRef material)
        {
            // Resolve both handles up front, before mutating, so a bad handle changes nothing.
            var renderer = ResolveRenderer(target);
            if (!ObjectResolver.TryResolve(material, out var matObj, out var matError))
                throw new ArgumentException(matError);
            if (matObj is not Material mat)
                throw new ArgumentException($"'{material}' is not a Material.");

            using (new AuthoringUndoScope("Set Material"))
            {
                Undo.RecordObject(renderer, "Set Material");   // record BEFORE the change
                renderer.sharedMaterial = mat;
                EditorSceneManager.MarkSceneDirty(renderer.gameObject.scene);
            }

            return ObjectResolver.Describe(renderer);
        }

        private static Renderer ResolveRenderer(ObjectRef target)
        {
            if (!ObjectResolver.TryResolve(target, out var obj, out var error))
                throw new ArgumentException(error);

            var go = obj as GameObject ?? (obj as Component)?.gameObject;
            var renderer = go != null ? go.GetComponent<Renderer>() : null;
            if (renderer == null)
                throw new ArgumentException($"Object '{target}' has no Renderer.");
            return renderer;
        }

        // Mirrors the shared helper used by the built-in asset commands.
        private static void EnsureParentFolder(string assetPath)
        {
            var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
            if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
                return;
            // Create intermediate folders (see create_folder / CreateFolderRecursive).
            Directory.CreateDirectory(ProjectPaths.ProjectRoot + "/" + parent);
            AssetDatabase.Refresh();
        }
    }
}

An agent chains them by feeding the first result into the second:

create_material --path Materials/Stone --shader Standard
    → { "assetPath": "Assets/AgentWork/Materials/Stone.mat", "guid": "…", "type": "Material" }

set_material --target '{"hierarchyPath":"/Ground"}' --material '{"path":"Assets/AgentWork/Materials/Stone.mat"}'
    → { "instanceId": …, "hierarchyPath": "/Ground", "type": "MeshRenderer" }

Checklist for a new authoring command

For content-authoring commands. Config/build/package commands follow the lighter management-command conventions instead.

  • Editor-only, under Editor/Commands/<Area>/, static (any accessibility — public/internal/private), tagged [CliCommand].
  • Every path parameter resolved through ProjectPaths.Resolve (never concatenated).
  • Existing-object inputs taken as ObjectRef, resolved via ObjectResolver.TryResolve; resolve before mutating.
  • Scene/object mutations wrapped in an AuthoringUndoScope and registered with the matching Undo API.
  • AssetDatabase writes noted as non-undoable in the description; validate before writing.
  • Returns AuthoringResult (via ObjectResolver.Describe) or a model containing AuthoringResult[].
  • Reports failures by throwing; never build the response envelope yourself.

Build & verify

New commands register automatically after the next recompile (see Creating commands → Discovery). Drive the live editor to verify:

command recompile
command recompile_status          # poll until done
command create_material --path Materials/Stone

See also