diff --git a/.github/workflows/command-ref.yml b/.github/workflows/command-ref.yml
new file mode 100644
index 0000000..778c854
--- /dev/null
+++ b/.github/workflows/command-ref.yml
@@ -0,0 +1,71 @@
+name: command reference
+
+# Regenerates skills/unity-pipeline/references/editor-commands.md from the com.unity.pipeline
+# package sources on the public UPM registry. A bare runner with the .NET SDK is the whole
+# requirement — no Unity Editor, no license, no `unity --json command` dump.
+#
+# Triggered by hand, or by the release-watch workflow, which polls the same versions endpoint.
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'com.unity.pipeline version to document, or "latest"'
+ type: string
+ default: latest
+ workflow_call:
+ inputs:
+ version:
+ description: 'com.unity.pipeline version to document, or "latest"'
+ type: string
+ default: latest
+
+permissions:
+ contents: read
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ # Parsing regressions surface here, against the checked-in fixture package, before the
+ # generator is let near the real reference file.
+ - name: Verify the generator against its fixture package
+ run: tools/CommandRefGen/tests/run.sh
+
+ - name: Regenerate the command reference
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ set -o pipefail
+ dotnet run --project tools/CommandRefGen -- --version "$VERSION" 2>&1 | tee gen.log
+
+ - name: Diff summary
+ if: always()
+ run: |
+ {
+ echo '## Command reference'
+ echo
+ echo '```'
+ sed -n '/^diff against /,/commands)$/p' gen.log
+ echo '```'
+ echo
+ echo '### Warnings'
+ echo
+ echo '```'
+ grep '^warning:' gen.log || echo 'none'
+ echo '```'
+ } >>"$GITHUB_STEP_SUMMARY"
+ git diff --stat -- skills/
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: editor-commands
+ path: |
+ skills/unity-pipeline/references/editor-commands.md
+ gen.log
diff --git a/skills/unity-pipeline/references/editor-commands.md b/skills/unity-pipeline/references/editor-commands.md
index b1036f4..e82b141 100644
--- a/skills/unity-pipeline/references/editor-commands.md
+++ b/skills/unity-pipeline/references/editor-commands.md
@@ -1,12 +1,14 @@
# Editor command reference
diff --git a/tools/CommandRefGen/.gitignore b/tools/CommandRefGen/.gitignore
new file mode 100644
index 0000000..cd42ee3
--- /dev/null
+++ b/tools/CommandRefGen/.gitignore
@@ -0,0 +1,2 @@
+bin/
+obj/
diff --git a/tools/CommandRefGen/CommandRefGen.csproj b/tools/CommandRefGen/CommandRefGen.csproj
new file mode 100644
index 0000000..7b09335
--- /dev/null
+++ b/tools/CommandRefGen/CommandRefGen.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ CommandRefGen
+ CommandRefGen
+ true
+ true
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/CommandRefGen/README.md b/tools/CommandRefGen/README.md
new file mode 100644
index 0000000..093bd72
--- /dev/null
+++ b/tools/CommandRefGen/README.md
@@ -0,0 +1,97 @@
+# CommandRefGen
+
+Generates [`skills/unity-pipeline/references/editor-commands.md`](../../skills/unity-pipeline/references/editor-commands.md)
+from the `com.unity.pipeline` package sources. No Unity Editor, no license, no
+`unity --json command` dump — a machine with the .NET 8 SDK is the whole requirement.
+
+The package is public on the UPM registry with no auth, and its C# sources carry every
+`[CliCommand]`/`[CliArg]` declaration, including the `RuntimeOnly` commands the live listing
+filters out and the version-gated ones that only exist on newer Unity. The source is therefore
+a strict superset of the live listing, and the only source that needs no running editor.
+
+## Usage
+
+```bash
+# document the newest version in the registry, updating the reference file in place
+dotnet run --project tools/CommandRefGen -- --version latest
+
+# pin a version
+dotnet run --project tools/CommandRefGen -- --version 0.4.0-exp.1
+
+# CI drift check: exits 3 when the reference file is out of date
+dotnet run --project tools/CommandRefGen -- --version latest --check
+```
+
+A version bump is a rerun with a different `--version`. There is no code to edit.
+
+Every run prints a diff summary against the file already on disk — added, removed and changed
+commands and arguments — plus warnings, to stderr. `--stdout` puts the document on stdout so
+stderr stays a clean log.
+
+Offline, when the registry is unreachable (filtered egress, an air-gapped runner):
+
+```bash
+dotnet run --project tools/CommandRefGen -- --tarball ./com.unity.pipeline-0.4.0-exp.1.tgz
+dotnet run --project tools/CommandRefGen -- --source-dir ./unpacked/package
+```
+
+Both read the version from the package's `package.json`. `--help` lists every option.
+
+## What it owns
+
+The generator owns the output file end to end: the do-not-edit banner, the preamble, the
+Contents list, every category section and the RuntimeOnly section. Nothing in it is
+hand-maintained, which is the drift trap the old two-step pipeline (a dump plus a hand-spliced
+RuntimeOnly section) existed to create.
+
+Field notes that are true but not derivable from the source — behaviour observed in the field,
+links to the other skill files — live in [`annotations.json`](annotations.json) and are merged
+into the matching command's entry. That keeps hand-maintained prose in a file the generator
+reads, never in the file it writes. A note whose command no longer exists is reported as unused.
+
+## Rules it applies
+
+- **Roslyn, not regexes.** Attributes span lines, descriptions are built by concatenation or
+ written as verbatim strings, and defaults live in two places.
+- **Defaults.** The attribute's `DefaultValue` wins; otherwise the C# parameter default applies.
+ Well-known constants are folded, including the package's `float.MinValue` "leave Unity's own
+ value alone" sentinel, which renders as `-3.40282347e+38` exactly as the live listing does.
+- **Required.** An explicit `Required` (or `IsRequired`) on `[CliArg]` decides it. Without one,
+ an argument with no default of any kind is required. The first run against a new package
+ version reports any argument whose required-ness moved, in the diff summary.
+- **Descriptions are never shortened.** Newlines and whitespace runs collapse to single spaces
+ so a multi-line description cannot break the markdown list, and that is the only edit. An
+ emergency ceiling (`--max-description`, 1000 chars) exists so a runaway string cannot wreck
+ the file; hitting it prints a warning naming the command — it is never a silent cut.
+- **Version gates.** A command compiled under `#if UNITY_6000_7_OR_NEWER` is marked with its
+ Unity floor instead of being presented as universally available. Non-version gates
+ (`#if ENABLE_INPUT_SYSTEM`) are reported as conditional. Each file is parsed twice — once with
+ every `#if` symbol defined, once with none — so `#else` and `#if !SYMBOL` branches are reached
+ too; a command declared in both branches is reported as ungated, because it exists either way.
+- **`Tests/` is excluded.** The package's test assembly registers throwaway commands
+ (`log_editor`, `test_types`, `test_structured`) to prove registration works. A
+ `Commands/Tests/` directory is a different thing — that is where `run_tests` and friends live,
+ and it is kept.
+- **Categories come from the source tree**, from the directory under `Commands/`. Display names
+ and section order live in `Categories.cs`; an unmapped directory still gets a section, with its
+ name humanized and a warning naming it, so the mapping is extended on purpose rather than by
+ accident.
+- **Project-defined commands are out of scope.** The reference documents the package surface.
+
+## Tests
+
+```bash
+tools/CommandRefGen/tests/run.sh # compare against the golden file
+tools/CommandRefGen/tests/run.sh --update # accept the current output as the new golden
+```
+
+`tests/fixture-package/` is a small source tree that exercises each rule above — multi-line
+attributes, both default sources, an explicit `Required` on an argument that has a default,
+the `float.MinValue` sentinel, a verbatim multi-line description, a `#if` version gate, a
+compound gate, a command declared in both branches of an `#if`, `RuntimeOnly`,
+`MainThreadRequired = false`, a parameter with no `[CliArg]`, an unmapped category directory,
+and a `Tests/` assembly whose commands must not appear. The script also asserts the warnings
+the fixture is built to provoke, and both `--check` outcomes.
+
+CI runs it before the generator touches the real reference file
+([`.github/workflows/command-ref.yml`](../../.github/workflows/command-ref.yml)).
diff --git a/tools/CommandRefGen/annotations.json b/tools/CommandRefGen/annotations.json
new file mode 100644
index 0000000..6608f40
--- /dev/null
+++ b/tools/CommandRefGen/annotations.json
@@ -0,0 +1,21 @@
+{
+ // Field notes merged into the generated reference by tools/CommandRefGen.
+ //
+ // Everything a command's entry says is read out of the package source, except what is in
+ // here: behaviour observed in the field, and links to the other skill files. Keeping these
+ // notes out of the generated document is what lets the generator own that file end to end.
+ //
+ // Each key is a command name; the value is markdown appended after the command's
+ // description (and after any version-gate note the generator derives from `#if`).
+ // The generator warns about a key that matches no command in the package version it read.
+ "commands": {
+ "simulate_pointer":
+ "**Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.**",
+
+ "capture_runtime_element":
+ "Unlike the other RuntimeOnly commands it does not execute against the editor server; on an editor older than its Unity floor it fails with exit code 6, indistinguishably from an unknown command name.",
+
+ "quit":
+ "Against the editor this is the play-mode/app quit path — for shutting down the editor itself, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md))."
+ }
+}
diff --git a/tools/CommandRefGen/src/Annotations.cs b/tools/CommandRefGen/src/Annotations.cs
new file mode 100644
index 0000000..684190d
--- /dev/null
+++ b/tools/CommandRefGen/src/Annotations.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+
+namespace CommandRefGen;
+
+///
+/// Field notes that are true but not derivable from the package source — observed behaviour,
+/// cross-references to other skill files. They live outside the generated document so the
+/// generator can own that file end to end; the generator merges them into a command's entry.
+///
+internal sealed class Annotations
+{
+ private readonly Dictionary _notes;
+
+ private Annotations(Dictionary notes) => _notes = notes;
+
+ public static Annotations Empty { get; } = new([]);
+
+ /// The note appended after a command's description, or "" when there is none.
+ public string NoteFor(string command) => _notes.GetValueOrDefault(command, "");
+
+ /// Names that no longer match a command — stale notes to clean up.
+ public IEnumerable Orphans(IEnumerable commandNames)
+ {
+ var known = commandNames.ToHashSet(StringComparer.Ordinal);
+ return _notes.Keys.Where(k => !known.Contains(k)).OrderBy(k => k, StringComparer.Ordinal);
+ }
+
+ public static Annotations Load(string path)
+ {
+ if (!File.Exists(path))
+ {
+ Log.Info($"annotations: {path} not found, continuing without field notes");
+ return Empty;
+ }
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+
+ var notes = new Dictionary(StringComparer.Ordinal);
+ if (doc.RootElement.TryGetProperty("commands", out var commands)
+ && commands.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var entry in commands.EnumerateObject())
+ {
+ var note = entry.Value.ValueKind switch
+ {
+ JsonValueKind.String => entry.Value.GetString(),
+ JsonValueKind.Object when entry.Value.TryGetProperty("note", out var n) => n.GetString(),
+ _ => null,
+ };
+
+ if (string.IsNullOrWhiteSpace(note))
+ Log.Warn($"annotations: '{entry.Name}' has no note text; ignoring it");
+ else
+ notes[entry.Name] = note.Trim();
+ }
+ }
+
+ Log.Info($"annotations: {notes.Count} field note(s) from {path}");
+ return new Annotations(notes);
+ }
+}
diff --git a/tools/CommandRefGen/src/Categories.cs b/tools/CommandRefGen/src/Categories.cs
new file mode 100644
index 0000000..2cf05d1
--- /dev/null
+++ b/tools/CommandRefGen/src/Categories.cs
@@ -0,0 +1,168 @@
+using System.Text;
+
+namespace CommandRefGen;
+
+///
+/// Turns a source directory under `Commands/` into a section title, and fixes the order
+/// sections appear in. Only presentation lives here — which commands exist, and what they
+/// do, always comes from the package source.
+///
+internal static class Categories
+{
+ public const string RuntimeOnlyTitle = "RuntimeOnly commands (hidden from the listing)";
+ private const string Fallback = "Other";
+
+ /// Section order. Titles not listed here follow, sorted alphabetically.
+ private static readonly string[] Order =
+ [
+ "Editor & play mode",
+ "Capture",
+ "Console & logs",
+ "Scenes",
+ "GameObjects & components",
+ "Prefabs",
+ "Assets & files",
+ "Scripts & compilation",
+ "Tests",
+ "Build",
+ "Packages (UPM)",
+ "Materials & shaders",
+ "Animation & Timeline",
+ "Lighting bake",
+ "NavMesh bake",
+ "Occlusion bake",
+ "Project settings",
+ ];
+
+ ///
+ /// Directory name (normalized: lowercase, alphanumerics only) to section title.
+ /// A directory that is not here still gets a section — its name is humanized and a
+ /// warning names it, so the mapping can be extended deliberately.
+ ///
+ private static readonly Dictionary Titles = new(StringComparer.Ordinal)
+ {
+ ["editor"] = "Editor & play mode",
+ ["playmode"] = "Editor & play mode",
+ ["editorplaymode"] = "Editor & play mode",
+ ["capture"] = "Capture",
+ ["captures"] = "Capture",
+ ["screenshots"] = "Capture",
+ ["console"] = "Console & logs",
+ ["logs"] = "Console & logs",
+ ["consolelogs"] = "Console & logs",
+ ["scene"] = "Scenes",
+ ["scenes"] = "Scenes",
+ ["gameobject"] = "GameObjects & components",
+ ["gameobjects"] = "GameObjects & components",
+ ["components"] = "GameObjects & components",
+ ["gameobjectscomponents"] = "GameObjects & components",
+ ["hierarchy"] = "GameObjects & components",
+ ["prefab"] = "Prefabs",
+ ["prefabs"] = "Prefabs",
+ ["asset"] = "Assets & files",
+ ["assets"] = "Assets & files",
+ ["files"] = "Assets & files",
+ ["assetsfiles"] = "Assets & files",
+ ["script"] = "Scripts & compilation",
+ ["scripts"] = "Scripts & compilation",
+ ["scripting"] = "Scripts & compilation",
+ ["compilation"] = "Scripts & compilation",
+ ["scriptscompilation"] = "Scripts & compilation",
+ ["test"] = "Tests",
+ ["tests"] = "Tests",
+ ["testing"] = "Tests",
+ ["build"] = "Build",
+ ["builds"] = "Build",
+ ["package"] = "Packages (UPM)",
+ ["packages"] = "Packages (UPM)",
+ ["upm"] = "Packages (UPM)",
+ ["material"] = "Materials & shaders",
+ ["materials"] = "Materials & shaders",
+ ["shaders"] = "Materials & shaders",
+ ["materialsshaders"] = "Materials & shaders",
+ ["animation"] = "Animation & Timeline",
+ ["animations"] = "Animation & Timeline",
+ ["timeline"] = "Animation & Timeline",
+ ["animationtimeline"] = "Animation & Timeline",
+ ["lighting"] = "Lighting bake",
+ ["lightingbake"] = "Lighting bake",
+ ["navmesh"] = "NavMesh bake",
+ ["navmeshbake"] = "NavMesh bake",
+ ["navigation"] = "NavMesh bake",
+ ["occlusion"] = "Occlusion bake",
+ ["occlusionbake"] = "Occlusion bake",
+ ["occlusionculling"] = "Occlusion bake",
+ ["settings"] = "Project settings",
+ ["projectsettings"] = "Project settings",
+ };
+
+ /// Section title for a category directory, warning once per unmapped directory.
+ public static string TitleFor(string? directory)
+ {
+ if (string.IsNullOrEmpty(directory)) return Fallback;
+
+ var key = Normalize(directory);
+ if (Titles.TryGetValue(key, out var title)) return title;
+
+ var humanized = Humanize(directory);
+ if (Unmapped.Add(directory))
+ Log.Warn($"category directory '{directory}' has no mapped section title; using " +
+ $"'{humanized}' and placing it after the known sections " +
+ "(add it to Categories.Titles to control the name and position)");
+ return humanized;
+ }
+
+ private static readonly HashSet Unmapped = new(StringComparer.Ordinal);
+
+ /// Sort key: known sections in , then the rest alphabetically.
+ public static (int Rank, string Title) SortKey(string title)
+ {
+ var index = Array.IndexOf(Order, title);
+ return (index >= 0 ? index : Order.Length, title);
+ }
+
+ /// A GitHub-flavoured heading anchor, for the Contents list.
+ public static string Anchor(string heading)
+ {
+ var sb = new StringBuilder(heading.Length);
+ foreach (var ch in heading.ToLowerInvariant())
+ {
+ if (char.IsLetterOrDigit(ch) || ch is '-' or '_') sb.Append(ch);
+ else if (ch == ' ') sb.Append('-');
+ }
+ return sb.ToString();
+ }
+
+ private static string Normalize(string directory)
+ {
+ var sb = new StringBuilder(directory.Length);
+ foreach (var ch in directory.ToLowerInvariant())
+ if (char.IsLetterOrDigit(ch)) sb.Append(ch);
+ return sb.ToString();
+ }
+
+ /// `AnimationTimeline` / `animation_timeline` -> `Animation timeline`.
+ private static string Humanize(string directory)
+ {
+ var sb = new StringBuilder(directory.Length + 4);
+ for (var i = 0; i < directory.Length; i++)
+ {
+ var ch = directory[i];
+ if (ch is '_' or '-' or '.')
+ {
+ sb.Append(' ');
+ continue;
+ }
+
+ var boundary = i > 0
+ && char.IsUpper(ch)
+ && (char.IsLower(directory[i - 1])
+ || (i + 1 < directory.Length && char.IsLower(directory[i + 1])));
+ if (boundary) sb.Append(' ');
+ sb.Append(sb.Length == 0 ? char.ToUpperInvariant(ch) : char.ToLowerInvariant(ch));
+ }
+
+ var text = sb.ToString().Trim();
+ return text.Length == 0 ? Fallback : text;
+ }
+}
diff --git a/tools/CommandRefGen/src/DiffReporter.cs b/tools/CommandRefGen/src/DiffReporter.cs
new file mode 100644
index 0000000..718586a
--- /dev/null
+++ b/tools/CommandRefGen/src/DiffReporter.cs
@@ -0,0 +1,195 @@
+using System.Text;
+
+namespace CommandRefGen;
+
+///
+/// Compares the generated document against the one already on disk and prints what moved:
+/// added / removed / changed commands and arguments. The generator owns the file format, so
+/// re-reading its own output is enough — no dump and no editor are needed for the comparison.
+///
+internal static class DiffReporter
+{
+ private sealed record ParsedArg(string Name, string Type, string? Default, bool Required, string Description)
+ {
+ public string Signature => $"{Type}|{Default ?? "-"}|{(Required ? "req" : "opt")}";
+ }
+
+ private sealed record ParsedCommand(string Name, string Section, string Description, List Args);
+
+ public static string Report(string? previousMarkdown, string generatedMarkdown, string path)
+ {
+ var generated = Parse(generatedMarkdown);
+
+ if (previousMarkdown is null)
+ return $"diff: {path} does not exist yet — all {generated.Count} commands are new.";
+
+ var previous = Parse(previousMarkdown);
+ var sb = new StringBuilder();
+ sb.Append("diff against ").Append(path).Append(":\n");
+
+ var added = generated.Keys.Except(previous.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal).ToList();
+ var removed = previous.Keys.Except(generated.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal).ToList();
+ var common = generated.Keys.Intersect(previous.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal).ToList();
+
+ var changed = new List();
+ var unchanged = 0;
+
+ foreach (var name in common)
+ {
+ var lines = CommandChanges(previous[name], generated[name]);
+ if (lines.Count == 0)
+ {
+ unchanged++;
+ continue;
+ }
+
+ changed.Add(name);
+ sb.Append(" ~ ").Append(name).Append('\n');
+ foreach (var line in lines) sb.Append(" ").Append(line).Append('\n');
+ }
+
+ foreach (var name in added)
+ sb.Append(" + ").Append(name).Append(" [").Append(generated[name].Section)
+ .Append("], ").Append(generated[name].Args.Count).Append(" argument(s)\n");
+
+ foreach (var name in removed)
+ sb.Append(" - ").Append(name).Append(" [was in ").Append(previous[name].Section).Append("]\n");
+
+ sb.Append($" {added.Count} added, {removed.Count} removed, {changed.Count} changed, {unchanged} unchanged")
+ .Append($" ({previous.Count} -> {generated.Count} commands)");
+
+ return sb.ToString();
+ }
+
+ private static List CommandChanges(ParsedCommand before, ParsedCommand after)
+ {
+ var lines = new List();
+
+ if (!string.Equals(before.Description, after.Description, StringComparison.Ordinal))
+ lines.Add("description changed");
+
+ if (!string.Equals(before.Section, after.Section, StringComparison.Ordinal))
+ lines.Add($"section {before.Section} -> {after.Section}");
+
+ var beforeArgs = before.Args.ToDictionary(a => a.Name, StringComparer.Ordinal);
+ var afterArgs = after.Args.ToDictionary(a => a.Name, StringComparer.Ordinal);
+
+ foreach (var name in afterArgs.Keys.Except(beforeArgs.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal))
+ lines.Add($"+ argument `{name}` {Describe(afterArgs[name])}");
+
+ foreach (var name in beforeArgs.Keys.Except(afterArgs.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal))
+ lines.Add($"- argument `{name}` {Describe(beforeArgs[name])}");
+
+ foreach (var name in afterArgs.Keys.Intersect(beforeArgs.Keys, StringComparer.Ordinal)
+ .OrderBy(k => k, StringComparer.Ordinal))
+ {
+ var a = beforeArgs[name];
+ var b = afterArgs[name];
+ var deltas = new List();
+
+ if (a.Type != b.Type) deltas.Add($"type {a.Type} -> {b.Type}");
+ if (a.Default != b.Default) deltas.Add($"default {a.Default ?? "(none)"} -> {b.Default ?? "(none)"}");
+ if (a.Required != b.Required) deltas.Add(b.Required ? "now required" : "no longer required");
+ if (!string.Equals(a.Description, b.Description, StringComparison.Ordinal)) deltas.Add("description changed");
+
+ if (deltas.Count > 0) lines.Add($"~ argument `{name}`: {string.Join(", ", deltas)}");
+ }
+
+ return lines;
+ }
+
+ private static string Describe(ParsedArg arg)
+ {
+ var text = arg.Type;
+ if (arg.Default is not null) text += $" (default {arg.Default})";
+ return arg.Required ? text + ", required" : text;
+ }
+
+ /// Reads a reference document in this generator's own format.
+ private static Dictionary Parse(string markdown)
+ {
+ var commands = new Dictionary(StringComparer.Ordinal);
+ var section = "";
+ ParsedCommand? current = null;
+ var wantDescription = false;
+
+ foreach (var raw in markdown.Split('\n'))
+ {
+ var line = raw.TrimEnd('\r');
+
+ if (line.StartsWith("## ", StringComparison.Ordinal))
+ {
+ section = line[3..].Trim();
+ current = null;
+ wantDescription = false;
+ continue;
+ }
+
+ if (line.StartsWith("### ", StringComparison.Ordinal))
+ {
+ var name = line[4..].Trim();
+ current = new ParsedCommand(name, section, "", []);
+ commands[name] = current;
+ wantDescription = true;
+ continue;
+ }
+
+ if (current is null) continue;
+
+ if (wantDescription)
+ {
+ wantDescription = false;
+ if (line.Length > 0 && !line.StartsWith("- ", StringComparison.Ordinal))
+ {
+ commands[current.Name] = current = current with { Description = line.Trim() };
+ continue;
+ }
+ }
+
+ if (line.StartsWith("- ", StringComparison.Ordinal) && ParseArg(line) is { } arg)
+ current.Args.Add(arg);
+ }
+
+ return commands;
+ }
+
+ private static ParsedArg? ParseArg(string line)
+ {
+ var body = line[2..];
+ if (!body.StartsWith('`')) return null; // "*(no arguments)*"
+
+ var close = body.IndexOf('`', 1);
+ if (close < 0) return null;
+
+ var name = body[1..close];
+ var rest = body[(close + 1)..];
+
+ var required = rest.StartsWith("\\*", StringComparison.Ordinal);
+ if (required) rest = rest[2..];
+ rest = rest.TrimStart();
+
+ var description = "";
+ var dash = rest.IndexOf(" — ", StringComparison.Ordinal);
+ if (dash >= 0)
+ {
+ description = rest[(dash + 3)..].Trim();
+ rest = rest[..dash];
+ }
+
+ string? @default = null;
+ const string marker = " (default ";
+ var defaultAt = rest.IndexOf(marker, StringComparison.Ordinal);
+ if (defaultAt >= 0 && rest.EndsWith(')'))
+ {
+ @default = rest[(defaultAt + marker.Length)..^1];
+ rest = rest[..defaultAt];
+ }
+
+ return new ParsedArg(name, rest.Trim(), @default, required, description);
+ }
+}
diff --git a/tools/CommandRefGen/src/Literals.cs b/tools/CommandRefGen/src/Literals.cs
new file mode 100644
index 0000000..d490669
--- /dev/null
+++ b/tools/CommandRefGen/src/Literals.cs
@@ -0,0 +1,209 @@
+using System.Globalization;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace CommandRefGen;
+
+///
+/// Folds the constant expressions that appear in `[CliCommand]`/`[CliArg]` attribute
+/// arguments and parameter defaults, and renders types and defaults the way the CLI
+/// listing does (CLR type names, JSON-ish literals).
+///
+internal static class Literals
+{
+ /// A recognized constant. Value may be null for a `null` literal.
+ public static bool TryEvaluate(ExpressionSyntax? expr, out object? value)
+ {
+ value = null;
+ if (expr is null) return false;
+
+ switch (expr)
+ {
+ case ParenthesizedExpressionSyntax p:
+ return TryEvaluate(p.Expression, out value);
+
+ case CastExpressionSyntax c:
+ // `(float)0.5` — the cast target is reflected by the declared parameter type.
+ return TryEvaluate(c.Expression, out value);
+
+ case LiteralExpressionSyntax lit:
+ if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return true; // null, recognized
+ if (lit.IsKind(SyntaxKind.DefaultLiteralExpression)) return false; // needs the type
+ value = lit.Token.Value;
+ return value is not null;
+
+ case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.UnaryMinusExpression):
+ if (!TryEvaluate(u.Operand, out var neg)) return false;
+ value = neg switch
+ {
+ int i => -i,
+ long l => -l,
+ float f => -f,
+ double d => -d,
+ decimal m => -m,
+ _ => null,
+ };
+ return value is not null;
+
+ case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.UnaryPlusExpression):
+ return TryEvaluate(u.Operand, out value);
+
+ case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.LogicalNotExpression):
+ if (!TryEvaluate(u.Operand, out var not) || not is not bool b) return false;
+ value = !b;
+ return true;
+
+ case BinaryExpressionSyntax bin when bin.IsKind(SyntaxKind.AddExpression):
+ // Multi-line descriptions are usually written as concatenated literals.
+ if (!TryEvaluate(bin.Left, out var left) || !TryEvaluate(bin.Right, out var right)) return false;
+ if (left is string ls && right is string rs) { value = ls + rs; return true; }
+ return false;
+
+ case MemberAccessExpressionSyntax ma:
+ return TryWellKnownConstant(ma, out value);
+
+ default:
+ return false;
+ }
+ }
+
+ /// `float.MinValue`, `string.Empty`, `int.MaxValue` and friends.
+ private static bool TryWellKnownConstant(MemberAccessExpressionSyntax ma, out object? value)
+ {
+ value = null;
+ var type = SimpleName(ma.Expression);
+ if (type is null) return false;
+
+ value = (Canonical(type), ma.Name.Identifier.ValueText) switch
+ {
+ ("String", "Empty") => "",
+ ("Single", "MinValue") => float.MinValue,
+ ("Single", "MaxValue") => float.MaxValue,
+ ("Single", "Epsilon") => float.Epsilon,
+ ("Single", "NaN") => float.NaN,
+ ("Single", "PositiveInfinity") => float.PositiveInfinity,
+ ("Single", "NegativeInfinity") => float.NegativeInfinity,
+ ("Double", "MinValue") => double.MinValue,
+ ("Double", "MaxValue") => double.MaxValue,
+ ("Double", "Epsilon") => double.Epsilon,
+ ("Int32", "MinValue") => int.MinValue,
+ ("Int32", "MaxValue") => int.MaxValue,
+ ("Int64", "MinValue") => long.MinValue,
+ ("Int64", "MaxValue") => long.MaxValue,
+ ("Int16", "MinValue") => short.MinValue,
+ ("Int16", "MaxValue") => short.MaxValue,
+ ("Byte", "MinValue") => byte.MinValue,
+ ("Byte", "MaxValue") => byte.MaxValue,
+ _ => null,
+ };
+
+ return value is not null;
+ }
+
+ /// The zero value of a type, for a `default` / `default(T)` parameter default.
+ public static object? DefaultOf(TypeSyntax type)
+ {
+ if (type is NullableTypeSyntax or ArrayTypeSyntax) return null;
+
+ return Canonical(SimpleName(type) ?? "") switch
+ {
+ "Boolean" => false,
+ "Int32" => 0,
+ "Int64" => 0L,
+ "Int16" => (short)0,
+ "Byte" => (byte)0,
+ "Single" => 0f,
+ "Double" => 0d,
+ "Decimal" => 0m,
+ "Char" => '\0',
+ _ => null,
+ };
+ }
+
+ /// Renders a default the way the CLI listing shows it; null means "no default".
+ public static string? FormatDefault(object? value) => value switch
+ {
+ null => null,
+ string s => Quote(s),
+ char c => Quote(c.ToString()),
+ bool b => b ? "true" : "false",
+ // G9 round-trips a float and matches the listing's rendering of the
+ // "leave Unity's value alone" sentinel float.MinValue as -3.40282347e+38.
+ float f => f.ToString("G9", CultureInfo.InvariantCulture).Replace("E", "e"),
+ double d => d.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e"),
+ decimal m => m.ToString(CultureInfo.InvariantCulture),
+ IFormattable n => n.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString(),
+ };
+
+ private static string Quote(string s)
+ {
+ var sb = new StringBuilder(s.Length + 2).Append('"');
+ foreach (var ch in s)
+ {
+ switch (ch)
+ {
+ case '"': sb.Append("\\\""); break;
+ case '\\': sb.Append("\\\\"); break;
+ case '\n': sb.Append("\\n"); break;
+ case '\r': sb.Append("\\r"); break;
+ case '\t': sb.Append("\\t"); break;
+ default:
+ if (char.IsControl(ch)) sb.Append("\\u").Append(((int)ch).ToString("x4"));
+ else sb.Append(ch);
+ break;
+ }
+ }
+ return sb.Append('"').ToString();
+ }
+
+ /// Renders a parameter type as the CLI listing does: `String`, `Int32`, `ObjectId[]`.
+ public static string FormatType(TypeSyntax type) => type switch
+ {
+ // The listing reports the underlying type; nullability is not part of the CLI surface.
+ NullableTypeSyntax n => FormatType(n.ElementType),
+ ArrayTypeSyntax a => FormatType(a.ElementType) + string.Concat(a.RankSpecifiers.Select(_ => "[]")),
+ GenericNameSyntax g when Canonical(g.Identifier.ValueText) is "List" or "IList" or "IEnumerable" or "IReadOnlyList"
+ => FormatType(g.TypeArgumentList.Arguments[0]) + "[]",
+ GenericNameSyntax g
+ => $"{g.Identifier.ValueText}<{string.Join(", ", g.TypeArgumentList.Arguments.Select(FormatType))}>",
+ _ => Canonical(SimpleName(type) ?? type.ToString()),
+ };
+
+ /// The trailing identifier of a possibly qualified or aliased name.
+ public static string? SimpleName(SyntaxNode? node) => node switch
+ {
+ null => null,
+ IdentifierNameSyntax id => id.Identifier.ValueText,
+ GenericNameSyntax g => g.Identifier.ValueText,
+ QualifiedNameSyntax q => SimpleName(q.Right),
+ AliasQualifiedNameSyntax a => SimpleName(a.Name),
+ MemberAccessExpressionSyntax m => m.Name.Identifier.ValueText,
+ PredefinedTypeSyntax p => p.Keyword.ValueText,
+ _ => null,
+ };
+
+ /// C# keyword to CLR type name; anything else is passed through.
+ private static string Canonical(string name) => name switch
+ {
+ "string" => "String",
+ "bool" => "Boolean",
+ "int" => "Int32",
+ "uint" => "UInt32",
+ "long" => "Int64",
+ "ulong" => "UInt64",
+ "short" => "Int16",
+ "ushort" => "UInt16",
+ "byte" => "Byte",
+ "sbyte" => "SByte",
+ "float" => "Single",
+ "double" => "Double",
+ "decimal" => "Decimal",
+ "char" => "Char",
+ "object" => "Object",
+ "void" => "Void",
+ _ => name,
+ };
+}
diff --git a/tools/CommandRefGen/src/Log.cs b/tools/CommandRefGen/src/Log.cs
new file mode 100644
index 0000000..c0b0108
--- /dev/null
+++ b/tools/CommandRefGen/src/Log.cs
@@ -0,0 +1,29 @@
+namespace CommandRefGen;
+
+///
+/// Diagnostics go to stderr so `--stdout` stays a clean document.
+///
+internal static class Log
+{
+ private static readonly HashSet Warnings = new(StringComparer.Ordinal);
+
+ public static int WarningCount => Warnings.Count;
+
+ public static void Info(string message) => Console.Error.WriteLine(message);
+
+ ///
+ /// Warns once per distinct message: a file is parsed twice (once per preprocessor pass),
+ /// so the same finding is reached twice.
+ ///
+ public static void Warn(string message)
+ {
+ if (!Warnings.Add(message)) return;
+ Console.Error.WriteLine($"warning: {message}");
+ }
+
+ public static void Summary()
+ {
+ if (Warnings.Count > 0)
+ Console.Error.WriteLine($"{Warnings.Count} warning(s)");
+ }
+}
diff --git a/tools/CommandRefGen/src/MarkdownWriter.cs b/tools/CommandRefGen/src/MarkdownWriter.cs
new file mode 100644
index 0000000..691c9ee
--- /dev/null
+++ b/tools/CommandRefGen/src/MarkdownWriter.cs
@@ -0,0 +1,154 @@
+using System.Text;
+
+namespace CommandRefGen;
+
+///
+/// Renders the whole reference document. Every line of the output file comes from here:
+/// the do-not-edit banner, the preamble, the Contents list, each category section and the
+/// RuntimeOnly section. Nothing in the output is hand-maintained.
+///
+internal static class MarkdownWriter
+{
+ public static string Render(IReadOnlyList commands, string version, Annotations annotations)
+ {
+ var listed = commands.Where(c => !c.RuntimeOnly).ToList();
+ var runtimeOnly = commands.Where(c => c.RuntimeOnly)
+ .OrderBy(c => c.Name, StringComparer.Ordinal)
+ .ToList();
+
+ var sections = listed
+ .GroupBy(c => Categories.TitleFor(c.CategoryDir))
+ .OrderBy(g => Categories.SortKey(g.Key))
+ .ToList();
+
+ var sb = new StringBuilder();
+
+ Banner(sb);
+ Preamble(sb, version, commands.Count);
+ Contents(sb, sections.Select(s => s.Key), runtimeOnly.Count > 0);
+
+ foreach (var section in sections)
+ {
+ sb.Append("## ").Append(section.Key).Append("\n\n");
+ foreach (var command in section.OrderBy(c => c.Name, StringComparer.Ordinal))
+ Entry(sb, command, annotations);
+ }
+
+ if (runtimeOnly.Count > 0)
+ {
+ RuntimeOnlyPreamble(sb, version, runtimeOnly);
+ foreach (var command in runtimeOnly)
+ Entry(sb, command, annotations);
+ }
+
+ return sb.ToString().TrimEnd() + "\n";
+ }
+
+ private static void Banner(StringBuilder sb) => sb.Append("""
+
+
+ """).Append('\n');
+
+ private static void Preamble(StringBuilder sb, string version, int count)
+ {
+ sb.Append("# Editor command reference\n\n");
+
+ sb.Append("Generated by `tools/CommandRefGen` from the `com.unity.pipeline` package source, version `")
+ .Append(version)
+ .Append("` — ").Append(count)
+ .Append(" commands, read from the `[CliCommand]`/`[CliArg]` attributes of the package's C# sources ")
+ .Append("rather than from a live editor. The file therefore covers every command the package ")
+ .Append("declares, on every Unity version it supports.\n\n");
+
+ sb.Append("**This file is more complete than `unity command`.** The live listing is dynamic ")
+ .Append("(package version, project code, optional packages, project-defined `[CliCommand]` methods), ")
+ .Append("and it filters out commands whose attribute sets `RuntimeOnly = true` — those are designed ")
+ .Append("for Unity **Player** connections (`unity command --runtime `) — yet the editor ")
+ .Append("server still executes them when called by name, in edit mode and play mode alike. The CLI ")
+ .Append("never reveals their schemas; the [")
+ .Append(Categories.RuntimeOnlyTitle.Split(" (")[0])
+ .Append("](#").Append(Categories.Anchor(Categories.RuntimeOnlyTitle))
+ .Append(") section below is the only schema source for them. Absence from the listing is not proof ")
+ .Append("a command does not exist — a genuinely unknown name fails with exit code 6. The reverse also ")
+ .Append("holds: a project's own `[CliCommand]` methods are listed by the CLI but are not documented ")
+ .Append("here, because this reference covers the package surface only.\n\n");
+
+ sb.Append("Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a ")
+ .Append("required argument. Destructive commands need `--confirm true`; most mutating commands accept ")
+ .Append("`--dry_run true`; async commands pair with a `*_status` poll command. A command the package ")
+ .Append("compiles only on newer Unity versions says so in its entry — it does not exist on older ")
+ .Append("ones, and calling it there fails with exit code 6.\n\n");
+ }
+
+ private static void Contents(StringBuilder sb, IEnumerable sections, bool hasRuntimeOnly)
+ {
+ var links = sections.Select(s => $"[{s}](#{Categories.Anchor(s)})").ToList();
+ if (hasRuntimeOnly)
+ links.Add($"[{Categories.RuntimeOnlyTitle.Split(" (")[0]}](#{Categories.Anchor(Categories.RuntimeOnlyTitle)})");
+
+ sb.Append("## Contents\n\n").Append(string.Join(" · ", links)).Append("\n\n");
+ }
+
+ private static void RuntimeOnlyPreamble(StringBuilder sb, string version, IReadOnlyList commands)
+ {
+ sb.Append("## ").Append(Categories.RuntimeOnlyTitle).Append("\n\n");
+
+ sb.Append("The `[CliCommand]` attribute of every command below sets `RuntimeOnly = true`, so ")
+ .Append("`unity command` neither lists them nor shows their schemas — these entries, read out of the ")
+ .Append("`com.unity.pipeline` `").Append(version).Append("` package source, are the only schema source ")
+ .Append("for them. They execute against the editor server despite the flag, and against Player ")
+ .Append("connections (`--runtime ` / `--runtime-path `).");
+
+ if (commands.All(c => c.MainThreadRequired))
+ sb.Append(" All of them require the main thread.");
+
+ sb.Append("\n\n");
+ }
+
+ private static void Entry(StringBuilder sb, CommandInfo command, Annotations annotations)
+ {
+ sb.Append("### ").Append(command.Name).Append('\n');
+
+ var parts = new List();
+ if (command.Description.Length > 0) parts.Add(command.Description);
+
+ if (command.MinUnityVersion is { } minimum)
+ parts.Add($"**Unity {minimum}+ only** — the command is compiled under " +
+ $"`#if {SourceParser.UnitySymbolFor(minimum)}` and does not exist on older versions.");
+
+ foreach (var condition in command.Conditions)
+ parts.Add($"**Conditional** — the command is compiled only where `{condition}` holds.");
+
+ var note = annotations.NoteFor(command.Name);
+ if (note.Length > 0) parts.Add(note);
+
+ if (!command.MainThreadRequired) parts.Add("*(works while main thread is busy)*");
+
+ sb.Append(parts.Count > 0 ? string.Join(' ', parts) : "*(no description)*").Append('\n');
+
+ if (command.Args.Count == 0)
+ {
+ sb.Append("- *(no arguments)*\n\n");
+ return;
+ }
+
+ foreach (var arg in command.Args)
+ {
+ sb.Append("- `").Append(arg.Name).Append('`');
+ if (arg.Required) sb.Append("\\*");
+ sb.Append(' ').Append(arg.Type);
+ if (arg.Default is { } value) sb.Append(" (default ").Append(value).Append(')');
+ if (arg.Description.Length > 0) sb.Append(" — ").Append(arg.Description);
+ sb.Append('\n');
+ }
+
+ sb.Append('\n');
+ }
+}
diff --git a/tools/CommandRefGen/src/Model.cs b/tools/CommandRefGen/src/Model.cs
new file mode 100644
index 0000000..d41ea07
--- /dev/null
+++ b/tools/CommandRefGen/src/Model.cs
@@ -0,0 +1,49 @@
+namespace CommandRefGen;
+
+/// One `[CliArg]`-annotated parameter of a command.
+internal sealed class CliArgInfo
+{
+ public required string Name { get; init; }
+
+ /// CLR-style type name as the CLI listing shows it (`String`, `Int32`, `ObjectId[]`).
+ public required string Type { get; init; }
+
+ /// Rendered default, or null when the argument has none.
+ public string? Default { get; init; }
+
+ public bool Required { get; init; }
+
+ public string Description { get; init; } = "";
+
+ /// Compared by the diff reporter; description text is compared separately.
+ public string Signature => $"{Type}|{Default ?? "-"}|{(Required ? "req" : "opt")}";
+}
+
+/// One `[CliCommand]`-annotated method.
+internal sealed record CommandInfo
+{
+ public required string Name { get; init; }
+ public string Description { get; init; } = "";
+
+ /// False when the attribute sets `MainThreadRequired = false`.
+ public bool MainThreadRequired { get; init; } = true;
+
+ /// True when the attribute sets `RuntimeOnly = true` — these are hidden from the editor listing.
+ public bool RuntimeOnly { get; init; }
+
+ /// Category directory under `Commands/`, or null when the source sits outside one.
+ public string? CategoryDir { get; init; }
+
+ /// Minimum Unity version from an enclosing `#if UNITY_x_y_OR_NEWER`, e.g. "6000.7".
+ public string? MinUnityVersion { get; init; }
+
+ /// Enclosing preprocessor conditions that are not Unity version gates.
+ public IReadOnlyList Conditions { get; init; } = [];
+
+ /// Package-relative source path, for warnings.
+ public required string SourcePath { get; init; }
+
+ public int SourceLine { get; init; }
+
+ public List Args { get; init; } = [];
+}
diff --git a/tools/CommandRefGen/src/Options.cs b/tools/CommandRefGen/src/Options.cs
new file mode 100644
index 0000000..679a457
--- /dev/null
+++ b/tools/CommandRefGen/src/Options.cs
@@ -0,0 +1,116 @@
+namespace CommandRefGen;
+
+/// Thrown for bad command lines; the message is printed without a stack trace.
+internal sealed class UsageException(string message) : Exception(message);
+
+internal sealed class Options
+{
+ public const string DefaultRegistry = "https://packages.unity.com";
+ public const string DefaultPackage = "com.unity.pipeline";
+ public const string DefaultOutput = "skills/unity-pipeline/references/editor-commands.md";
+ public const string DefaultAnnotations = "tools/CommandRefGen/annotations.json";
+
+ public string Registry { get; private set; } = DefaultRegistry;
+ public string Package { get; private set; } = DefaultPackage;
+
+ /// Package version to document, or "latest" for the highest entry in the registry.
+ public string Version { get; private set; } = "latest";
+
+ public string? Output { get; private set; }
+ public string? Annotations { get; private set; }
+
+ /// Offline input: a local package tarball instead of a registry download.
+ public string? Tarball { get; private set; }
+
+ /// Offline input: an already-unpacked package directory.
+ public string? SourceDir { get; private set; }
+
+ /// Report the diff but do not write; exit 3 when the output would change.
+ public bool Check { get; private set; }
+
+ /// Write the document to stdout instead of the output file.
+ public bool ToStdout { get; private set; }
+
+ public bool KeepTemp { get; private set; }
+
+ ///
+ /// Emergency ceiling on a description, in characters. Descriptions are never silently
+ /// truncated: hitting this prints a warning naming the command or argument.
+ ///
+ public int MaxDescription { get; private set; } = 1000;
+
+ public static string Usage => """
+ CommandRefGen — generates the Unity Pipeline command reference from package sources.
+
+ Usage:
+ dotnet run --project tools/CommandRefGen -- [options]
+
+ Input (pick one; the registry is the default):
+ --version Package version to document. "latest" resolves to the highest
+ version in the registry listing. Default: latest.
+ --tarball Read a local package tarball (.tgz) instead of downloading.
+ --source-dir Read an already-unpacked package directory.
+ --package Package id. Default: com.unity.pipeline.
+ --registry UPM registry base URL. Default: https://packages.unity.com.
+
+ Output:
+ --output Markdown file to own. Default: skills/unity-pipeline/references/editor-commands.md.
+ --stdout Write the document to stdout; leave the output file alone.
+ --check Do not write. Exit 3 if the output file is out of date.
+ --annotations Field notes merged into descriptions.
+ Default: tools/CommandRefGen/annotations.json (skipped if absent).
+ --max-description Emergency description ceiling in characters. Default: 1000.
+ --keep-temp Keep the unpacked package directory and print its path.
+ -h, --help Print this help.
+
+ Exit codes: 0 ok · 1 error · 3 --check found the output file out of date.
+ """;
+
+ public static Options Parse(string[] args)
+ {
+ var o = new Options();
+ var sawVersion = false;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var a = args[i];
+ string Next(string name) => ++i < args.Length
+ ? args[i]
+ : throw new UsageException($"{name} needs a value");
+
+ switch (a)
+ {
+ case "--version": o.Version = Next(a); sawVersion = true; break;
+ case "--package": o.Package = Next(a); break;
+ case "--registry": o.Registry = Next(a).TrimEnd('/'); break;
+ case "--output": o.Output = Next(a); break;
+ case "--annotations": o.Annotations = Next(a); break;
+ case "--tarball": o.Tarball = Next(a); break;
+ case "--source-dir": o.SourceDir = Next(a); break;
+ case "--check": o.Check = true; break;
+ case "--stdout": o.ToStdout = true; break;
+ case "--keep-temp": o.KeepTemp = true; break;
+ case "--max-description":
+ var raw = Next(a);
+ if (!int.TryParse(raw, out var max) || max < 1)
+ throw new UsageException($"--max-description needs a positive integer, got '{raw}'");
+ o.MaxDescription = max;
+ break;
+ case "-h" or "--help":
+ throw new UsageException("");
+ default:
+ throw new UsageException($"unknown option '{a}'");
+ }
+ }
+
+ if (o.Tarball is not null && o.SourceDir is not null)
+ throw new UsageException("--tarball and --source-dir are mutually exclusive");
+
+ // Offline inputs carry their own version in package.json; an explicit --version would
+ // silently mislabel the document, so only accept one when it is not the default.
+ if (!sawVersion && (o.Tarball is not null || o.SourceDir is not null))
+ o.Version = "";
+
+ return o;
+ }
+}
diff --git a/tools/CommandRefGen/src/Program.cs b/tools/CommandRefGen/src/Program.cs
new file mode 100644
index 0000000..a60dbc5
--- /dev/null
+++ b/tools/CommandRefGen/src/Program.cs
@@ -0,0 +1,201 @@
+using System.Text;
+
+namespace CommandRefGen;
+
+///
+/// Generates skills/unity-pipeline/references/editor-commands.md from the com.unity.pipeline
+/// package sources on the public UPM registry. No Unity Editor, no `unity --json command` dump.
+///
+internal static class Program
+{
+ private const int ExitError = 1;
+ private const int ExitOutOfDate = 3;
+
+ public static async Task Main(string[] args)
+ {
+ Options options;
+ try
+ {
+ options = Options.Parse(args);
+ }
+ catch (UsageException e)
+ {
+ if (e.Message.Length > 0) Console.Error.WriteLine($"error: {e.Message}\n");
+ Console.Error.WriteLine(Options.Usage);
+ return e.Message.Length > 0 ? ExitError : 0;
+ }
+
+ var temp = (string?)null;
+ try
+ {
+ var repoRoot = FindRepoRoot();
+ var outputPath = Path.GetFullPath(options.Output ?? Path.Combine(repoRoot, Options.DefaultOutput));
+ var annotationsPath = Path.GetFullPath(
+ options.Annotations ?? Path.Combine(repoRoot, Options.DefaultAnnotations));
+
+ var (packageRoot, version) = await AcquirePackageAsync(options, path => temp = path);
+
+ var commands = new SourceParser(options.MaxDescription).Parse(packageRoot);
+ if (commands.Count == 0)
+ throw new InvalidOperationException(
+ $"no [CliCommand] methods found under '{packageRoot}' — is this the right package?");
+
+ var annotations = Annotations.Load(annotationsPath);
+ foreach (var orphan in annotations.Orphans(commands.Select(c => c.Name)))
+ Log.Warn($"annotations: '{orphan}' matches no command in {version}; the note is unused");
+
+ var generated = MarkdownWriter.Render(commands, version, annotations);
+ var previous = File.Exists(outputPath) ? await File.ReadAllTextAsync(outputPath) : null;
+
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(DiffReporter.Report(previous, generated, Relative(outputPath)));
+ Console.Error.WriteLine();
+
+ var upToDate = previous is not null && string.Equals(previous, generated, StringComparison.Ordinal);
+
+ if (options.ToStdout)
+ {
+ Console.Out.Write(generated);
+ }
+ else if (options.Check)
+ {
+ if (upToDate)
+ {
+ Log.Info($"{Relative(outputPath)} is up to date with {version}");
+ }
+ else
+ {
+ Log.Info($"{Relative(outputPath)} is out of date with {version}; " +
+ "rerun without --check to update it");
+ Log.Summary();
+ return ExitOutOfDate;
+ }
+ }
+ else if (upToDate)
+ {
+ Log.Info($"{Relative(outputPath)} already matches {version}; left untouched");
+ }
+ else
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
+ await File.WriteAllTextAsync(outputPath, generated, new UTF8Encoding(false));
+ Log.Info($"wrote {Relative(outputPath)} — {commands.Count} commands from {version}");
+ }
+
+ Log.Summary();
+ return 0;
+ }
+ catch (Exception e) when (e is InvalidOperationException
+ or IOException
+ or HttpRequestException
+ or UnauthorizedAccessException
+ or System.Text.Json.JsonException)
+ {
+ Console.Error.WriteLine($"error: {e.Message}");
+ if (e is HttpRequestException)
+ Console.Error.WriteLine(
+ "hint: the registry is reachable without auth; if egress is filtered, fetch the tarball " +
+ "elsewhere and pass it with --tarball, or unpack it and pass --source-dir.");
+ return ExitError;
+ }
+ finally
+ {
+ if (temp is not null && Directory.Exists(temp))
+ {
+ if (options.KeepTemp) Log.Info($"kept unpacked package at {temp}");
+ else TryDelete(temp);
+ }
+ }
+ }
+
+ /// Resolves the package to read, downloading it unless an offline input was given.
+ private static async Task<(string PackageRoot, string Version)> AcquirePackageAsync(
+ Options options, Action registerTemp)
+ {
+ if (options.SourceDir is { } dir)
+ {
+ if (!Directory.Exists(dir))
+ throw new InvalidOperationException($"--source-dir '{dir}' does not exist");
+
+ var root = UpmRegistry.FindPackageRoot(Path.GetFullPath(dir));
+ return (root, VersionOf(options, root));
+ }
+
+ var temp = Path.Combine(Path.GetTempPath(), "CommandRefGen-" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(temp);
+ registerTemp(temp);
+
+ string tarball;
+ var version = options.Version;
+
+ if (options.Tarball is { } local)
+ {
+ if (!File.Exists(local))
+ throw new InvalidOperationException($"--tarball '{local}' does not exist");
+ tarball = Path.GetFullPath(local);
+ }
+ else
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("CommandRefGen/1.0");
+
+ var resolved = await UpmRegistry.ResolveAsync(
+ http, options.Registry, options.Package, string.IsNullOrEmpty(version) ? "latest" : version);
+ version = resolved.Version;
+ tarball = await UpmRegistry.DownloadTarballAsync(http, resolved.TarballUrl, temp);
+ }
+
+ var unpacked = Path.Combine(temp, "unpacked");
+ UpmRegistry.ExtractTarball(tarball, unpacked);
+
+ var packageRoot = UpmRegistry.FindPackageRoot(unpacked);
+ return (packageRoot, options.Tarball is null ? version : VersionOf(options, packageRoot));
+ }
+
+ /// Prefers package.json for offline inputs; an explicit --version overrides it.
+ private static string VersionOf(Options options, string packageRoot)
+ {
+ var declared = UpmRegistry.ReadPackageVersion(packageRoot);
+
+ if (!string.IsNullOrEmpty(options.Version) && options.Version != "latest")
+ {
+ if (declared is not null && declared != options.Version)
+ Log.Warn($"--version {options.Version} does not match the package.json version {declared}; " +
+ "labelling the document with the requested version");
+ return options.Version;
+ }
+
+ if (declared is not null) return declared;
+
+ Log.Warn($"{packageRoot}/package.json has no version; labelling the document 'unknown'");
+ return "unknown";
+ }
+
+ /// Walks up from the working directory to the repository root.
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ for (var d = dir; d is not null; d = d.Parent)
+ if (Directory.Exists(Path.Combine(d.FullName, ".git")) || File.Exists(Path.Combine(d.FullName, ".git")))
+ return d.FullName;
+ return dir.FullName;
+ }
+
+ private static string Relative(string path)
+ {
+ var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), path);
+ return relative.StartsWith("..", StringComparison.Ordinal) ? path : relative;
+ }
+
+ private static void TryDelete(string dir)
+ {
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (IOException e)
+ {
+ Log.Warn($"could not remove the temp directory {dir}: {e.Message}");
+ }
+ }
+}
diff --git a/tools/CommandRefGen/src/SemVer.cs b/tools/CommandRefGen/src/SemVer.cs
new file mode 100644
index 0000000..d196dae
--- /dev/null
+++ b/tools/CommandRefGen/src/SemVer.cs
@@ -0,0 +1,91 @@
+using System.Globalization;
+
+namespace CommandRefGen;
+
+///
+/// Minimal semver-2.0 precedence, enough to pick the highest entry out of a UPM
+/// version listing (`0.4.0-exp.1` < `0.4.0-exp.2` < `0.4.0`).
+/// Unparseable versions sort below every parseable one, ordered by ordinal string.
+///
+internal sealed class SemVer : IComparable
+{
+ public string Raw { get; }
+ private readonly int[] _core;
+ private readonly string[] _pre;
+ private readonly bool _valid;
+
+ private SemVer(string raw, int[] core, string[] pre, bool valid)
+ {
+ Raw = raw;
+ _core = core;
+ _pre = pre;
+ _valid = valid;
+ }
+
+ public static SemVer Parse(string raw)
+ {
+ var body = raw;
+
+ // Build metadata is ignored for precedence.
+ var plus = body.IndexOf('+');
+ if (plus >= 0) body = body[..plus];
+
+ string[] pre = [];
+ var dash = body.IndexOf('-');
+ if (dash >= 0)
+ {
+ pre = body[(dash + 1)..].Split('.');
+ body = body[..dash];
+ }
+
+ var parts = body.Split('.');
+ var core = new int[3];
+ var valid = parts.Length is >= 1 and <= 3;
+ for (var i = 0; i < parts.Length && valid; i++)
+ {
+ if (int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out var n))
+ core[i] = n;
+ else
+ valid = false;
+ }
+
+ return new SemVer(raw, core, pre, valid);
+ }
+
+ public int CompareTo(SemVer? other)
+ {
+ if (other is null) return 1;
+
+ if (_valid != other._valid) return _valid ? 1 : -1;
+ if (!_valid) return string.CompareOrdinal(Raw, other.Raw);
+
+ for (var i = 0; i < 3; i++)
+ {
+ var c = _core[i].CompareTo(other._core[i]);
+ if (c != 0) return c;
+ }
+
+ // A release outranks any prerelease of the same core version.
+ if (_pre.Length == 0 || other._pre.Length == 0)
+ return (other._pre.Length == 0 ? 1 : 0) - (_pre.Length == 0 ? 1 : 0);
+
+ for (var i = 0; i < Math.Min(_pre.Length, other._pre.Length); i++)
+ {
+ var a = _pre[i];
+ var b = other._pre[i];
+ var aNum = int.TryParse(a, NumberStyles.None, CultureInfo.InvariantCulture, out var an);
+ var bNum = int.TryParse(b, NumberStyles.None, CultureInfo.InvariantCulture, out var bn);
+
+ int c;
+ if (aNum && bNum) c = an.CompareTo(bn);
+ else if (aNum != bNum) c = aNum ? -1 : 1; // numeric identifiers rank below alphanumeric
+ else c = string.CompareOrdinal(a, b);
+
+ if (c != 0) return c;
+ }
+
+ return _pre.Length.CompareTo(other._pre.Length);
+ }
+
+ public override string ToString() => Raw;
+}
diff --git a/tools/CommandRefGen/src/SourceParser.cs b/tools/CommandRefGen/src/SourceParser.cs
new file mode 100644
index 0000000..b1c11c6
--- /dev/null
+++ b/tools/CommandRefGen/src/SourceParser.cs
@@ -0,0 +1,419 @@
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace CommandRefGen;
+
+///
+/// Reads `[CliCommand]` / `[CliArg]` declarations out of the package sources through the
+/// Roslyn syntax tree. Attributes span multiple lines and defaults come from two places
+/// (the attribute's DefaultValue, else the C# parameter default), which is why this is a
+/// parse and not a set of regexes.
+///
+internal sealed class SourceParser(int maxDescription)
+{
+ private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled);
+ private static readonly Regex UnityVersionSymbol =
+ new(@"^UNITY_(\d+)(?:_(\d+))?(?:_(\d+))?_OR_NEWER$", RegexOptions.Compiled);
+ private static readonly Regex Identifier =
+ new(@"[A-Za-z_][A-Za-z0-9_]*", RegexOptions.Compiled);
+
+ /// Parses every command in a package root, keyed by command name.
+ public List Parse(string packageRoot)
+ {
+ var byName = new Dictionary(StringComparer.Ordinal);
+ var files = 0;
+
+ foreach (var file in EnumerateSources(packageRoot))
+ {
+ files++;
+ var rel = Relative(packageRoot, file);
+ var text = File.ReadAllText(file);
+
+ var symbols = CollectPreprocessorSymbols(text);
+ var regions = ConditionRegions(ParseTree(text, []));
+
+ // Pass A defines every symbol seen in an #if, so version-gated commands
+ // (`#if UNITY_6000_7_OR_NEWER`) land in the tree instead of in disabled text.
+ foreach (var c in ReadCommands(ParseTree(text, symbols), rel, regions))
+ Merge(byName, c);
+
+ // Pass B defines nothing, which is the only way to reach `#else` and `#if !SYMBOL`
+ // branches. Commands present in both passes are unconditional.
+ if (symbols.Count > 0)
+ foreach (var c in ReadCommands(ParseTree(text, []), rel, regions))
+ Merge(byName, c);
+ }
+
+ foreach (var fixture in FixtureCommands.Where(byName.ContainsKey))
+ Log.Warn($"'{fixture}' is a test-assembly registration fixture but was found in " +
+ $"{byName[fixture].SourcePath}; check the Tests/ exclusion");
+
+ Log.Info($"parsed {files} source file(s), found {byName.Count} [CliCommand] method(s)");
+ return byName.Values.OrderBy(c => c.Name, StringComparer.Ordinal).ToList();
+ }
+
+ // ---- source discovery -------------------------------------------------
+
+ private static IEnumerable EnumerateSources(string root) =>
+ Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)
+ .Where(f => !IsExcluded(root, f))
+ .OrderBy(f => f, StringComparer.Ordinal);
+
+ private static bool IsExcluded(string root, string file)
+ {
+ var dirs = Segments(Relative(root, file))[..^1];
+
+ for (var i = 0; i < dirs.Length; i++)
+ {
+ // Trailing-~ directories are invisible to Unity's importer.
+ if (dirs[i].EndsWith('~')) return true;
+
+ // The package's Tests/ assembly holds registration fixtures (log_editor,
+ // test_types, test_structured), not real commands. A `Commands/Tests/` category
+ // directory is a different thing — that is where run_tests and friends live.
+ if (!dirs[i].Equals("Tests", StringComparison.OrdinalIgnoreCase)) continue;
+ if (i > 0 && dirs[i - 1].Equals("Commands", StringComparison.Ordinal)) continue;
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Registration fixtures from the package's test assembly; never real commands.
+ private static readonly string[] FixtureCommands = ["log_editor", "test_types", "test_structured"];
+
+ private static string Relative(string root, string file) =>
+ Path.GetRelativePath(root, file).Replace('\\', '/');
+
+ private static string[] Segments(string relPath) => relPath.Split('/');
+
+ ///
+ /// The category directory a source file sits in: the segment right after the last
+ /// `Commands/` directory, e.g. `Editor/Commands/Scenes/SceneCommands.cs` -> `Scenes`.
+ /// Null when the file sits directly in `Commands/` or outside one entirely.
+ ///
+ public static string? CategoryDirOf(string relPath)
+ {
+ var segs = Segments(relPath);
+ for (var i = segs.Length - 2; i >= 0; i--)
+ {
+ if (!segs[i].Equals("Commands", StringComparison.Ordinal)) continue;
+ return i + 1 <= segs.Length - 2 ? segs[i + 1] : null;
+ }
+ return null;
+ }
+
+ // ---- preprocessor -----------------------------------------------------
+
+ private static SyntaxTree ParseTree(string text, IEnumerable symbols) =>
+ CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(
+ LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Regular, symbols));
+
+ private static IEnumerable Directives(SyntaxTree tree)
+ {
+ var d = ((CSharpSyntaxNode)tree.GetRoot()).GetFirstDirective();
+ while (d is not null)
+ {
+ yield return d;
+ d = d.GetNextDirective();
+ }
+ }
+
+ private static List CollectPreprocessorSymbols(string text)
+ {
+ var names = new SortedSet(StringComparer.Ordinal);
+ foreach (var d in Directives(ParseTree(text, [])))
+ {
+ var cond = d switch
+ {
+ IfDirectiveTriviaSyntax f => f.Condition,
+ ElifDirectiveTriviaSyntax e => e.Condition,
+ _ => null,
+ };
+ if (cond is null) continue;
+ foreach (var id in cond.DescendantNodesAndSelf().OfType())
+ names.Add(id.Identifier.ValueText);
+ }
+ return names.ToList();
+ }
+
+ /// Text spans covered by each `#if`/`#elif`/`#else` branch, with its condition.
+ private static List<(TextSpan Span, string Condition)> ConditionRegions(SyntaxTree tree)
+ {
+ var regions = new List<(TextSpan, string)>();
+ var stack = new Stack<(int Start, string Condition)>();
+
+ foreach (var d in Directives(tree))
+ {
+ switch (d)
+ {
+ case IfDirectiveTriviaSyntax f:
+ stack.Push((f.FullSpan.End, f.Condition.ToString().Trim()));
+ break;
+ case ElifDirectiveTriviaSyntax e:
+ Close(e.FullSpan.Start);
+ stack.Push((e.FullSpan.End, e.Condition.ToString().Trim()));
+ break;
+ case ElseDirectiveTriviaSyntax el:
+ var previous = Close(el.FullSpan.Start);
+ stack.Push((el.FullSpan.End, previous is null ? "else" : $"!({previous})"));
+ break;
+ case EndIfDirectiveTriviaSyntax end:
+ Close(end.FullSpan.Start);
+ break;
+ }
+ }
+
+ return regions;
+
+ string? Close(int end)
+ {
+ if (stack.Count == 0) return null;
+ var top = stack.Pop();
+ regions.Add((TextSpan.FromBounds(top.Start, Math.Max(top.Start, end)), top.Condition));
+ return top.Condition;
+ }
+ }
+
+ ///
+ /// Splits enclosing conditions into the highest Unity version gate and the rest.
+ ///
+ private static (string? MinUnityVersion, List Other) ClassifyConditions(IEnumerable conditions)
+ {
+ string? version = null;
+ var other = new List();
+
+ foreach (var condition in conditions)
+ {
+ // `A && B` gates independently; anything with ! or || is reported verbatim.
+ string[] parts = condition.Contains('!') || condition.Contains("||")
+ ? [condition]
+ : condition.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var part in parts)
+ {
+ var m = UnityVersionSymbol.Match(part.Trim('(', ')', ' '));
+ if (!m.Success)
+ {
+ if (!other.Contains(part)) other.Add(part);
+ continue;
+ }
+
+ var v = string.Join('.', m.Groups.Cast().Skip(1).Where(g => g.Success).Select(g => g.Value));
+ if (version is null || SemVer.Parse(v).CompareTo(SemVer.Parse(version)) > 0) version = v;
+ }
+ }
+
+ return (version, other);
+ }
+
+ /// The Unity symbol a version string came from, for the generated note.
+ public static string UnitySymbolFor(string version) =>
+ "UNITY_" + version.Replace('.', '_') + "_OR_NEWER";
+
+ // ---- command extraction ----------------------------------------------
+
+ private IEnumerable ReadCommands(
+ SyntaxTree tree, string relPath, List<(TextSpan Span, string Condition)> regions)
+ {
+ var category = CategoryDirOf(relPath);
+
+ foreach (var method in tree.GetRoot().DescendantNodes().OfType())
+ {
+ var attribute = Attribute(method.AttributeLists, "CliCommand");
+ if (attribute is null) continue;
+
+ var line = tree.GetLineSpan(method.Span).StartLinePosition.Line + 1;
+ var where = $"{relPath}:{line}";
+ var args = new AttributeArgs(attribute);
+
+ if (!Literals.TryEvaluate(args.Get(0, "name"), out var nameValue)
+ || nameValue is not string name || name.Length == 0)
+ {
+ Log.Warn($"{where}: [CliCommand] name is not a literal string; skipping this method");
+ continue;
+ }
+
+ var (minUnity, conditions) = ClassifyConditions(
+ regions.Where(r => r.Span.Contains(method.Span.Start)).Select(r => r.Condition));
+
+ yield return new CommandInfo
+ {
+ Name = name,
+ Description = Describe(args.Get(1, "description"), $"{where}: command '{name}'"),
+ MainThreadRequired = Flag(args.Named("MainThreadRequired"), true, where, name),
+ RuntimeOnly = Flag(args.Named("RuntimeOnly"), false, where, name),
+ CategoryDir = category,
+ MinUnityVersion = minUnity,
+ Conditions = conditions,
+ SourcePath = relPath,
+ SourceLine = line,
+ Args = ReadArgs(method, where, name),
+ };
+ }
+ }
+
+ private List ReadArgs(MethodDeclarationSyntax method, string where, string command)
+ {
+ var result = new List();
+ var unannotated = new List();
+
+ foreach (var parameter in method.ParameterList.Parameters)
+ {
+ var attribute = Attribute(parameter.AttributeLists, "CliArg");
+ if (attribute is null)
+ {
+ unannotated.Add(parameter.Identifier.ValueText);
+ continue;
+ }
+
+ var args = new AttributeArgs(attribute);
+ var name = Literals.TryEvaluate(args.Get(0, "name"), out var n) && n is string s && s.Length > 0
+ ? s
+ : parameter.Identifier.ValueText;
+
+ // The attribute's DefaultValue wins; otherwise the C# parameter default applies.
+ var defaultExpr = args.Named("DefaultValue") ?? parameter.Default?.Value;
+ object? defaultValue = null;
+ if (defaultExpr is not null)
+ {
+ if (defaultExpr.IsKind(SyntaxKind.DefaultLiteralExpression)
+ || defaultExpr is DefaultExpressionSyntax)
+ {
+ defaultValue = parameter.Type is null ? null : Literals.DefaultOf(parameter.Type);
+ }
+ else if (!Literals.TryEvaluate(defaultExpr, out defaultValue))
+ {
+ var text = WhitespaceRun.Replace(defaultExpr.ToString(), " ");
+ Log.Warn($"{where}: '{command}' argument '{name}' has a non-constant default `{text}`; " +
+ "emitting it verbatim");
+ defaultValue = new RawDefault(text);
+ }
+ }
+
+ var required = Literals.TryEvaluate(args.Named("Required") ?? args.Named("IsRequired"), out var r)
+ && r is bool explicitly
+ ? explicitly
+ : defaultExpr is null;
+
+ result.Add(new CliArgInfo
+ {
+ Name = name,
+ Type = parameter.Type is null ? "Object" : Literals.FormatType(parameter.Type),
+ Default = Literals.FormatDefault(defaultValue),
+ Required = required,
+ Description = Describe(args.Get(1, "description"), $"{where}: '{command}' argument '{name}'"),
+ });
+ }
+
+ if (unannotated.Count > 0)
+ Log.Warn($"{where}: '{command}' has parameter(s) without [CliArg]: " +
+ $"{string.Join(", ", unannotated)}; they are left out of the reference");
+
+ return result;
+ }
+
+ /// A default the parser could not fold; rendered verbatim.
+ private sealed record RawDefault(string Text)
+ {
+ public override string ToString() => Text;
+ }
+
+ private static AttributeSyntax? Attribute(SyntaxList lists, string name) =>
+ lists.SelectMany(l => l.Attributes).FirstOrDefault(a =>
+ {
+ var simple = Literals.SimpleName(a.Name) ?? "";
+ if (simple.EndsWith("Attribute", StringComparison.Ordinal))
+ simple = simple[..^"Attribute".Length];
+ return simple.Equals(name, StringComparison.Ordinal);
+ });
+
+ private static bool Flag(ExpressionSyntax? expr, bool fallback, string where, string command)
+ {
+ if (expr is null) return fallback;
+ if (Literals.TryEvaluate(expr, out var v) && v is bool b) return b;
+ Log.Warn($"{where}: '{command}' has a non-constant flag `{expr}`; assuming {fallback}");
+ return fallback;
+ }
+
+ private string Describe(ExpressionSyntax? expr, string what)
+ {
+ if (expr is null) return "";
+
+ if (!Literals.TryEvaluate(expr, out var value) || value is not string text)
+ {
+ Log.Warn($"{what}: description is not a constant string; using its source text");
+ text = expr.ToString();
+ }
+
+ // Collapse newlines and whitespace runs: a multi-line description must not break the
+ // markdown list layout. Never shortened below the emergency ceiling.
+ var description = WhitespaceRun.Replace(text, " ").Trim();
+
+ if (description.Length > maxDescription)
+ {
+ Log.Warn($"{what}: description is {description.Length} chars, over the " +
+ $"{maxDescription}-char ceiling; truncated (raise --max-description to keep it whole)");
+ description = description[..maxDescription].TrimEnd() + "…";
+ }
+
+ return description;
+ }
+
+ // ---- duplicate resolution --------------------------------------------
+
+ private static void Merge(Dictionary map, CommandInfo command)
+ {
+ if (!map.TryGetValue(command.Name, out var existing))
+ {
+ map[command.Name] = command;
+ return;
+ }
+
+ if (existing.SourcePath != command.SourcePath)
+ {
+ Log.Warn($"command '{command.Name}' is declared twice: " +
+ $"{existing.SourcePath}:{existing.SourceLine} and " +
+ $"{command.SourcePath}:{command.SourceLine}; keeping the first");
+ return;
+ }
+
+ // Same declaration seen again by the second preprocessor pass.
+ if (existing.SourceLine == command.SourceLine) return;
+
+ // Two declarations in the same file means two preprocessor branches, so the command
+ // exists whichever branch compiles: drop the gate rather than claim a version floor.
+ if (Gates(command) == 0) map[command.Name] = command;
+ else if (Gates(existing) > 0) map[command.Name] = existing with { MinUnityVersion = null, Conditions = [] };
+
+ static int Gates(CommandInfo c) => c.Conditions.Count + (c.MinUnityVersion is null ? 0 : 1);
+ }
+
+ /// Positional and named attribute arguments, addressable either way.
+ private sealed class AttributeArgs
+ {
+ private readonly List _positional = [];
+ private readonly Dictionary _named = new(StringComparer.OrdinalIgnoreCase);
+
+ public AttributeArgs(AttributeSyntax attribute)
+ {
+ if (attribute.ArgumentList is null) return;
+
+ foreach (var argument in attribute.ArgumentList.Arguments)
+ {
+ var name = argument.NameEquals?.Name.Identifier.ValueText
+ ?? argument.NameColon?.Name.Identifier.ValueText;
+ if (name is null) _positional.Add(argument.Expression);
+ else _named[name] = argument.Expression;
+ }
+ }
+
+ public ExpressionSyntax? Named(string name) => _named.GetValueOrDefault(name);
+
+ public ExpressionSyntax? Get(int index, string name) =>
+ Named(name) ?? (index < _positional.Count ? _positional[index] : null);
+ }
+}
diff --git a/tools/CommandRefGen/src/UpmRegistry.cs b/tools/CommandRefGen/src/UpmRegistry.cs
new file mode 100644
index 0000000..5d233db
--- /dev/null
+++ b/tools/CommandRefGen/src/UpmRegistry.cs
@@ -0,0 +1,111 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Text.Json;
+
+namespace CommandRefGen;
+
+///
+/// Reads the public UPM registry (no auth) and unpacks package tarballs.
+///
+internal static class UpmRegistry
+{
+ /// Resolves a version to its tarball URL. may be "latest".
+ public static async Task<(string Version, string TarballUrl)> ResolveAsync(
+ HttpClient http, string registry, string package, string requested)
+ {
+ var url = $"{registry}/{package}";
+ Log.Info($"registry: GET {url}");
+
+ await using var stream = await http.GetStreamAsync(url);
+ using var doc = await JsonDocument.ParseAsync(stream);
+
+ if (!doc.RootElement.TryGetProperty("versions", out var versions)
+ || versions.ValueKind != JsonValueKind.Object)
+ throw new InvalidOperationException($"{url}: no 'versions' object in the registry response");
+
+ var names = versions.EnumerateObject().Select(p => p.Name).ToList();
+ if (names.Count == 0)
+ throw new InvalidOperationException($"{url}: 'versions' is empty");
+
+ var version = requested is "latest"
+ ? names.OrderBy(SemVer.Parse).Last()
+ : requested;
+
+ if (!versions.TryGetProperty(version, out var entry))
+ throw new InvalidOperationException(
+ $"version '{version}' not found in {url}. Available: {string.Join(", ", names.OrderBy(SemVer.Parse))}");
+
+ if (!entry.TryGetProperty("dist", out var dist)
+ || !dist.TryGetProperty("tarball", out var tarball)
+ || tarball.GetString() is not { Length: > 0 } tarballUrl)
+ throw new InvalidOperationException($"version '{version}' has no dist.tarball in {url}");
+
+ if (requested is "latest")
+ Log.Info($"registry: latest of {names.Count} versions is {version}");
+
+ return (version, tarballUrl);
+ }
+
+ public static async Task DownloadTarballAsync(HttpClient http, string tarballUrl, string destDir)
+ {
+ Log.Info($"registry: GET {tarballUrl}");
+ var path = Path.Combine(destDir, "package.tgz");
+
+ await using var response = await http.GetStreamAsync(tarballUrl);
+ await using var file = File.Create(path);
+ await response.CopyToAsync(file);
+
+ return path;
+ }
+
+ /// Unpacks a gzipped tar into .
+ public static void ExtractTarball(string tarballPath, string destDir)
+ {
+ Directory.CreateDirectory(destDir);
+ using var file = File.OpenRead(tarballPath);
+ using var gzip = new GZipStream(file, CompressionMode.Decompress);
+ // TarFile rejects entries that would escape destDir.
+ TarFile.ExtractToDirectory(gzip, destDir, overwriteFiles: true);
+ }
+
+ ///
+ /// Finds the directory holding the package's package.json. UPM tarballs nest everything
+ /// under `package/`, but an unpacked directory passed via --source-dir may be the root itself.
+ ///
+ public static string FindPackageRoot(string dir)
+ {
+ if (File.Exists(Path.Combine(dir, "package.json")))
+ return dir;
+
+ var nested = Directory.EnumerateDirectories(dir)
+ .Where(d => File.Exists(Path.Combine(d, "package.json")))
+ .OrderBy(d => d, StringComparer.Ordinal)
+ .ToList();
+
+ return nested.Count switch
+ {
+ 1 => nested[0],
+ 0 => throw new InvalidOperationException($"no package.json found in '{dir}' or its immediate subdirectories"),
+ _ => throw new InvalidOperationException(
+ $"several packages found under '{dir}': {string.Join(", ", nested.Select(Path.GetFileName))}"),
+ };
+ }
+
+ /// Reads the version field out of a package root's package.json, if present.
+ public static string? ReadPackageVersion(string packageRoot)
+ {
+ var path = Path.Combine(packageRoot, "package.json");
+ if (!File.Exists(path)) return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
+ return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
+ }
+ catch (JsonException e)
+ {
+ Log.Warn($"{path}: unreadable ({e.Message})");
+ return null;
+ }
+ }
+}
diff --git a/tools/CommandRefGen/tests/expected/editor-commands.md b/tools/CommandRefGen/tests/expected/editor-commands.md
new file mode 100644
index 0000000..c9a36b2
--- /dev/null
+++ b/tools/CommandRefGen/tests/expected/editor-commands.md
@@ -0,0 +1,106 @@
+
+
+# Editor command reference
+
+Generated by `tools/CommandRefGen` from the `com.unity.pipeline` package source, version `9.9.9-fixture.1` — 16 commands, read from the `[CliCommand]`/`[CliArg]` attributes of the package's C# sources rather than from a live editor. The file therefore covers every command the package declares, on every Unity version it supports.
+
+**This file is more complete than `unity command`.** The live listing is dynamic (package version, project code, optional packages, project-defined `[CliCommand]` methods), and it filters out commands whose attribute sets `RuntimeOnly = true` — those are designed for Unity **Player** connections (`unity command --runtime `) — yet the editor server still executes them when called by name, in edit mode and play mode alike. The CLI never reveals their schemas; the [RuntimeOnly commands](#runtimeonly-commands-hidden-from-the-listing) section below is the only schema source for them. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6. The reverse also holds: a project's own `[CliCommand]` methods are listed by the CLI but are not documented here, because this reference covers the package surface only.
+
+Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a required argument. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command. A command the package compiles only on newer Unity versions says so in its entry — it does not exist on older ones, and calling it there fails with exit code 6.
+
+## Contents
+
+[Capture](#capture) · [Scenes](#scenes) · [Tests](#tests) · [Widgets](#widgets) · [RuntimeOnly commands](#runtimeonly-commands-hidden-from-the-listing)
+
+## Capture
+
+### capture_editor_element
+Capture a UI Toolkit VisualElement from an editor panel to a PNG. **Unity 6000.7+ only** — the command is compiled under `#if UNITY_6000_7_OR_NEWER` and does not exist on older versions.
+- `selector`\* String — Element selector: '#name', '.class', or a type name.
+- `output` String (default "") — Output PNG path.
+
+### capture_game_view
+Render a camera to a PNG.
+- `width` Int32 (default 1280) — Output width in px.
+- `height` Int32 (default 720) — Output height in px.
+- `exposure` Single (default -3.40282347e+38) — Exposure override. Defaults to Unity's current value.
+- `save_path` String — Project-relative PNG path.
+
+### capture_pipeline_debug
+Dump render-graph debug data for the active pipeline. **Unity 6000.7+ only** — the command is compiled under `#if UNITY_6000_7_OR_NEWER` and does not exist on older versions. **Conditional** — the command is compiled only where `ENABLE_CAPTURE_PIPELINE` holds.
+- *(no arguments)*
+
+### screenshot
+Write a screenshot of the focused window. *(works while main thread is busy)*
+- `path`\* String — Output path.
+
+## Scenes
+
+### list_open_scenes
+List every open scene. Reports name, path, and isDirty for each.
+- *(no arguments)*
+
+### open_scene
+Open a scene by asset path, replacing the current one.
+- `path`\* String — Project-relative scene path, e.g. Assets/Scenes/Main.unity
+- `additive` Boolean (default false) — Load the scene additively instead of replacing the open one.
+
+### save_scene
+Save the open scene.
+- `path` String (default "") — Where to save. Defaults to the scene's current path.
+- `mode`\* Int32 (default 0) — Save mode.
+
+### scene_status
+Report scene load state. *(works while main thread is busy)*
+- `paths` String[] — Scene paths to report on. Omit for every open scene.
+
+### set_active_scene
+Set the active scene. Affects where new objects are created.
+- `name`\* String — Scene name.
+
+## Tests
+
+### run_tests
+Run the project's tests. Async: poll test_status.
+- `mode` String (default "editmode") — editmode | playmode
+- `filter` String (default "") — Test name filter.
+
+### test_status
+Report the state of the last test run. *(works while main thread is busy)*
+- *(no arguments)*
+
+## Widgets
+
+### widget_poke
+Poke a widget.
+- `id`\* Int32 (default -1) — Widget id.
+
+## RuntimeOnly commands (hidden from the listing)
+
+The `[CliCommand]` attribute of every command below sets `RuntimeOnly = true`, so `unity command` neither lists them nor shows their schemas — these entries, read out of the `com.unity.pipeline` `9.9.9-fixture.1` package source, are the only schema source for them. They execute against the editor server despite the flag, and against Player connections (`--runtime ` / `--runtime-path `). All of them require the main thread.
+
+### quit
+Gracefully quit the Unity application. Against the editor this is the play-mode/app quit path — for shutting down the editor itself, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md)).
+- `exitCode` Int32 (default 0) — Exit code for the application
+
+### set_timescale
+Set the time scale for the application.
+- `scale`\* Single — Time scale multiplier (0.0 to pause, 1.0 for normal speed)
+
+### simulate_key
+Simulate a keyboard key event (Input System).
+- `key`\* String — Input System Key name, e.g. Space, W, Enter, LeftArrow
+- `action` String (default "press") — down | up | press (down+up)
+
+### simulate_pointer
+Simulate a mouse/pointer event at screen coordinates (Input System). **Conditional** — the command is compiled only where `ENABLE_INPUT_SYSTEM` holds. **Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.**
+- `x`\* Single — Screen X in pixels (origin bottom-left)
+- `y`\* Single — Screen Y in pixels (origin bottom-left)
+- `action` String (default "click") — move | down | up | click (down+up)
diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs
new file mode 100644
index 0000000..6fd5fe8
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs
@@ -0,0 +1,39 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Editor.Commands
+{
+ public static class CaptureCommands
+ {
+ // float.MinValue is the package's "leave Unity's own value alone" sentinel; the listing
+ // renders it as -3.40282347e+38, and so must the generator.
+ [CliCommand("capture_game_view", "Render a camera to a PNG.")]
+ public static string CaptureGameView(
+ [CliArg("width", "Output width in px.")] int width = 1280,
+ [CliArg("height", "Output height in px.")] int height = 720,
+ [CliArg("exposure", "Exposure override. Defaults to Unity's current value.")]
+ float exposure = float.MinValue,
+ [CliArg("save_path", "Project-relative PNG path.")] string savePath = null) => "";
+
+#if UNITY_6000_7_OR_NEWER
+ // Version-gated: the generator must mark the Unity floor instead of presenting this as
+ // universally available. Note the argument name falls back to the C# parameter name.
+ [CliCommand("capture_editor_element", "Capture a UI Toolkit VisualElement from an editor panel to a PNG.")]
+ public static string CaptureEditorElement(
+ [CliArg(Description = "Element selector: '#name', '.class', or a type name.")] string selector,
+ [CliArg("output", "Output PNG path.")] string output = "") => "";
+#endif
+
+#if UNITY_6000_7_OR_NEWER && ENABLE_CAPTURE_PIPELINE
+ // Two gates at once: a Unity floor plus a plain feature symbol.
+ [CliCommand("capture_pipeline_debug", "Dump render-graph debug data for the active pipeline.")]
+ public static string CapturePipelineDebug() => "";
+#endif
+
+ // A parameter with no [CliArg] is not part of the CLI surface; the generator leaves it
+ // out and says so on stderr.
+ [CliCommand("screenshot", "Write a screenshot of the focused window.", MainThreadRequired = false)]
+ public static string Screenshot(
+ [CliArg("path", "Output path.")] string path,
+ System.Threading.CancellationToken cancellation = default) => path;
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs
new file mode 100644
index 0000000..7e103a0
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs
@@ -0,0 +1,49 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Editor.Commands
+{
+ public static class SceneCommands
+ {
+ // Baseline: attribute split over several lines, a required argument with no default,
+ // and an optional one whose default comes from the C# parameter.
+ [CliCommand(
+ "open_scene",
+ "Open a scene by asset path, replacing the current one.")]
+ public static string OpenScene(
+ [CliArg("path", "Project-relative scene path, e.g. Assets/Scenes/Main.unity")]
+ string path,
+ [CliArg("additive", "Load the scene additively instead of replacing the open one.")]
+ bool additive = false)
+ {
+ return path + additive;
+ }
+
+ // The attribute's DefaultValue wins over the C# parameter default, and Required is set
+ // explicitly on an argument that nonetheless has a default.
+ [CliCommand("save_scene", "Save the open scene.")]
+ public static string SaveScene(
+ [CliArg("path", "Where to save. Defaults to the scene's current path.", DefaultValue = "")]
+ string path = "unused",
+ [CliArg("mode", "Save mode.", Required = true)] int mode = 0)
+ {
+ return path + mode;
+ }
+
+ // A verbatim, multi-line description must collapse to one line, and a description built
+ // by concatenation must be folded rather than dumped as source text.
+ [CliCommand("list_open_scenes", @"List every open scene.
+ Reports name, path,
+ and isDirty for each.")]
+ public static string ListOpenScenes() => "";
+
+ [CliCommand("set_active_scene", "Set the active scene. " + "Affects where new objects are created.")]
+ public static string SetActiveScene(
+ [CliArg("name", "Scene name.")] string name) => name;
+
+ // MainThreadRequired = false, and an argument list carrying an array type.
+ [CliCommand("scene_status", "Report scene load state.", MainThreadRequired = false)]
+ public static string SceneStatus(
+ [CliArg("paths", "Scene paths to report on. Omit for every open scene.")]
+ string[] paths = null) => "";
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs
new file mode 100644
index 0000000..980301d
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs
@@ -0,0 +1,17 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Editor.Commands
+{
+ // A `Commands/Tests/` directory is a category of real commands (run_tests and friends) and
+ // must survive the Tests/ exclusion, which only targets the package's test assembly.
+ public static class TestRunCommands
+ {
+ [CliCommand("run_tests", "Run the project's tests. Async: poll test_status.")]
+ public static string RunTests(
+ [CliArg("mode", "editmode | playmode")] string mode = "editmode",
+ [CliArg("filter", "Test name filter.")] string filter = "") => "";
+
+ [CliCommand("test_status", "Report the state of the last test run.", MainThreadRequired = false)]
+ public static string TestStatus() => "";
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs
new file mode 100644
index 0000000..9db3461
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs
@@ -0,0 +1,13 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Editor.Commands
+{
+ // An unmapped category directory: the generator humanizes the name, places the section after
+ // the known ones, and warns so the mapping can be extended on purpose.
+ public static class WidgetCommands
+ {
+ [CliCommand("widget_poke", "Poke a widget.")]
+ public static string WidgetPoke(
+ [CliArg("id", "Widget id.", Required = true)] int id = -1) => "";
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs b/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs
new file mode 100644
index 0000000..aaee1f9
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs
@@ -0,0 +1,40 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Runtime.Commands
+{
+ public static class InputCommands
+ {
+ // RuntimeOnly commands are filtered out of the live listing, so they get their own
+ // section rather than their category's.
+ [CliCommand("simulate_key", "Simulate a keyboard key event (Input System).", RuntimeOnly = true)]
+ public static string SimulateKey(
+ [CliArg("key", "Input System Key name, e.g. Space, W, Enter, LeftArrow")] string key,
+ [CliArg("action", "down | up | press (down+up)")] string action = "press") => "";
+
+ [CliCommand("quit", "Gracefully quit the Unity application.", RuntimeOnly = true)]
+ public static string Quit(
+ [CliArg(Description = "Exit code for the application")] int exitCode = 0) => "";
+
+ // A RuntimeOnly command that is not a Unity version gate but a feature-symbol gate.
+#if ENABLE_INPUT_SYSTEM
+ [CliCommand("simulate_pointer", "Simulate a mouse/pointer event at screen coordinates (Input System).",
+ RuntimeOnly = true)]
+ public static string SimulatePointer(
+ [CliArg("x", "Screen X in pixels (origin bottom-left)")] float x,
+ [CliArg("y", "Screen Y in pixels (origin bottom-left)")] float y,
+ [CliArg("action", "move | down | up | click (down+up)")] string action = "click") => "";
+#endif
+
+ // Declared in both preprocessor branches, so it exists either way and must not be
+ // reported as gated.
+#if UNITY_6000_7_OR_NEWER
+ [CliCommand("set_timescale", "Set the time scale for the application.", RuntimeOnly = true)]
+ public static string SetTimeScaleModern(
+ [CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)")] float scale) => "";
+#else
+ [CliCommand("set_timescale", "Set the time scale for the application.", RuntimeOnly = true)]
+ public static string SetTimeScaleLegacy(
+ [CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)")] float scale) => "";
+#endif
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs b/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs
new file mode 100644
index 0000000..668e0ef
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs
@@ -0,0 +1,22 @@
+using Unity.Pipeline.Commands;
+
+namespace Fixture.Tests
+{
+ // The package's test assembly registers throwaway commands to prove registration works.
+ // None of these may reach the reference document.
+ public static class RegistrationFixtures
+ {
+ [CliCommand("log_editor", "Test fixture: log a line from the editor.")]
+ public static string LogEditor(
+ [CliArg("message", "Anything.")] string message = "hi") => message;
+
+ [CliCommand("test_types", "Test fixture: echo one argument of every supported type.")]
+ public static string TestTypes(
+ [CliArg("i", "int")] int i = 0,
+ [CliArg("f", "float")] float f = 0f,
+ [CliArg("b", "bool")] bool b = false) => "";
+
+ [CliCommand("test_structured", "Test fixture: return a structured payload.")]
+ public static string TestStructured() => "";
+ }
+}
diff --git a/tools/CommandRefGen/tests/fixture-package/package.json b/tools/CommandRefGen/tests/fixture-package/package.json
new file mode 100644
index 0000000..df109f6
--- /dev/null
+++ b/tools/CommandRefGen/tests/fixture-package/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "com.unity.pipeline.fixture",
+ "displayName": "CommandRefGen fixture package",
+ "version": "9.9.9-fixture.1",
+ "description": "Not a real package. Exercises the generator's parsing against a checked-in source tree, so the tool can be verified without registry access or a Unity install."
+}
diff --git a/tools/CommandRefGen/tests/run.sh b/tools/CommandRefGen/tests/run.sh
new file mode 100755
index 0000000..d240569
--- /dev/null
+++ b/tools/CommandRefGen/tests/run.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Verifies the generator against the checked-in fixture package: no registry access, no Unity.
+#
+# tools/CommandRefGen/tests/run.sh # compare against the golden file
+# tools/CommandRefGen/tests/run.sh --update # rewrite the golden file from the current output
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+project="$here/.."
+fixture="$here/fixture-package"
+golden="$here/expected/editor-commands.md"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+
+run() {
+ dotnet run --project "$project" -- \
+ --source-dir "$fixture" \
+ --annotations "$project/annotations.json" \
+ "$@"
+}
+
+echo "== generating from the fixture package =="
+run --stdout >"$work/actual.md" 2>"$work/stderr.txt" || {
+ cat "$work/stderr.txt" >&2
+ echo "FAIL: the generator exited non-zero" >&2
+ exit 1
+}
+
+if [[ "${1:-}" == "--update" ]]; then
+ mkdir -p "$(dirname "$golden")"
+ cp "$work/actual.md" "$golden"
+ echo "updated $golden"
+ exit 0
+fi
+
+failures=0
+fail() { echo "FAIL: $*" >&2; failures=$((failures + 1)); }
+
+echo "== comparing against the golden file =="
+if ! diff -u "$golden" "$work/actual.md"; then
+ fail "output does not match $golden (rerun with --update if the change is intended)"
+fi
+
+# Warnings the fixture is built to provoke; silence in any of these means a check stopped working.
+echo "== checking the expected diagnostics =="
+expect_warning() {
+ grep -qF "$1" "$work/stderr.txt" || fail "expected a warning containing: $1"
+}
+expect_warning "'screenshot' has parameter(s) without [CliArg]: cancellation"
+expect_warning "category directory 'Widgets' has no mapped section title"
+
+# Test-assembly fixtures must never reach the document.
+for fixture_command in log_editor test_types test_structured; do
+ grep -qF "### $fixture_command" "$work/actual.md" \
+ && fail "$fixture_command came from the excluded Tests/ assembly"
+done
+
+# The same finding must not be reported twice just because a file is parsed once per
+# preprocessor pass.
+duplicates="$(grep '^warning:' "$work/stderr.txt" | sort | uniq -d || true)"
+[[ -z "$duplicates" ]] || fail "duplicate warnings:"$'\n'"$duplicates"
+
+echo "== checking --check on an up-to-date file =="
+cp "$work/actual.md" "$work/output.md"
+run --output "$work/output.md" --check >/dev/null 2>&1 \
+ || fail "--check reported an up-to-date file as stale"
+
+echo "== checking --check on a stale file =="
+printf '### gone_command\nRemoved upstream.\n- *(no arguments)*\n' >>"$work/output.md"
+set +e
+run --output "$work/output.md" --check >/dev/null 2>"$work/check.txt"
+status=$?
+set -e
+[[ $status -eq 3 ]] || fail "--check on a stale file exited $status, expected 3"
+grep -qF "gone_command" "$work/check.txt" || fail "--check did not report the stale command"
+
+if [[ $failures -eq 0 ]]; then
+ echo "PASS"
+else
+ echo "$failures check(s) failed" >&2
+ exit 1
+fi
diff --git a/tools/gen-commands-md.js b/tools/gen-commands-md.js
deleted file mode 100644
index d5e478d..0000000
--- a/tools/gen-commands-md.js
+++ /dev/null
@@ -1,89 +0,0 @@
-// Generates the categorized part of skills/unity-pipeline/references/editor-commands.md
-// from a `unity --json command` dump. Usage:
-// unity --json command --project-path > cmds.json
-// node tools/gen-commands-md.js cmds.json out.md
-// The "RuntimeOnly commands" section is not generated — those commands are absent
-// from the dump; their schemas come from the com.unity.pipeline package source
-// (Runtime/Commands/*.cs) and are appended manually. Re-attach that section after
-// regenerating, and update the version line in the preamble.
-const fs = require('fs');
-
-const dump = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
-const cmds = dump.data.commands.slice().sort((a, b) => a.name.localeCompare(b.name));
-
-const CATEGORIES = [
- ['Editor & play mode', ['editor_play','editor_pause','editor_stop','editor_status','editor_focus','set_autotick','save_all','menu','get_selection','set_selection']],
- ['Capture', ['capture_scene_view','capture_game_view','screenshot']],
- ['Console & logs', ['get_console_logs','clear_console','console','log']],
- ['Scenes', ['create_scene','open_scene','save_scene','set_active_scene','list_open_scenes','get_scene_hierarchy','add_scene_to_build','remove_scene_from_build']],
- ['GameObjects & components', ['create_gameobject','create_gameobjects','find_gameobjects','delete_gameobject','rename_gameobject','set_parent','set_active','set_transform','set_tag','set_layer','add_component','remove_component','attach_script','get_component_properties','set_component_properties','get_serialized_fields','set_serialized_field']],
- ['Prefabs', ['create_prefab','create_prefab_variant','instantiate_prefab','unpack_prefab','apply_prefab_overrides','revert_prefab_overrides','save_prefab_contents']],
- ['Assets & files', ['create_asset','import_asset','delete_asset','rename_asset','move_asset','copy_asset','find_assets','search','create_folder','read_text_file','write_text_file','get_import_settings','set_import_settings','get_authoring_root','set_authoring_root']],
- ['Scripts & compilation', ['create_script','recompile','recompile_status','eval','eval_file','reload_file','reload_file_override','hotreload_status','cleanup_hotreload']],
- ['Tests', ['run_tests','test_status','list_tests','cancel_tests']],
- ['Build', ['build','build_status','list_build_targets','switch_build_target','switch_build_target_status','list_build_profiles','get_build_settings','set_build_settings','get_player_settings','set_player_settings']],
- ['Packages (UPM)', ['package_add','package_remove','package_list','package_search','package_resolve','package_status']],
- ['Materials & shaders', ['get_material_properties','set_material_properties','list_shaders','get_shader_properties']],
- ['Animation & Timeline', ['create_animation_clip','get_animation_clip','set_animation_curve','remove_animation_curve','create_animator_controller','get_animator_controller','add_animator_parameter','add_animator_state','add_animator_transition','add_animator_layer','create_timeline','get_timeline','add_timeline_track','add_timeline_clip']],
- ['Lighting bake', ['bake_lighting','lighting_bake_status','cancel_lighting_bake','clear_baked_lighting','get_lighting_settings','set_lighting_settings']],
- ['NavMesh bake', ['bake_navmesh','navmesh_bake_status','cancel_navmesh_bake','clear_navmesh','bake_navmesh_surfaces','get_navmesh_settings','set_navmesh_settings']],
- ['Occlusion bake', ['bake_occlusion_culling','occlusion_bake_status','cancel_occlusion_bake','clear_occlusion_culling']],
- ['Project settings', ['get_quality_settings','set_quality_settings','get_time_settings','set_time_settings','get_physics_settings','set_physics_settings','get_audio_settings','set_audio_settings','get_graphics_settings','set_graphics_settings','get_input_settings','set_input_settings','get_tags_layers','set_tags_layers','get_performance_stats']],
-];
-
-const catOf = new Map();
-for (const [cat, names] of CATEGORIES) for (const n of names) catOf.set(n, cat);
-
-const byCat = new Map(CATEGORIES.map(([c]) => [c, []]));
-const uncategorized = [];
-for (const c of cmds) {
- const cat = catOf.get(c.name);
- if (cat) byCat.get(cat).push(c); else uncategorized.push(c);
-}
-if (uncategorized.length) byCat.set('Other', uncategorized);
-
-function fmtDefault(v) {
- if (v === null || v === undefined) return '';
- const s = JSON.stringify(v);
- return ` (default ${s})`;
-}
-function normDesc(s) {
- if (!s) return '';
- return s.replace(/\s+/g, ' ').trim();
-}
-
-let out = [];
-out.push('# Editor command reference');
-out.push('');
-out.push('Generated from `unity --json command` against Unity Pipeline package `0.4.0-exp.1` on Unity `6000.4`. The live list is dynamic (package version, project code, optional packages, project-defined `[CliCommand]` methods) — on a name/argument mismatch, re-check against the live output of `unity --json command`; this file covers the common core.');
-out.push('');
-out.push('**The listing is not exhaustive**: some registered built-in commands (the play-mode input group: `simulate_pointer`, `simulate_key`, `runtime_status`, `set_timescale`, `set_target_framerate`, `quit`) do not appear in the 140-command listing but execute fine when called. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6.');
-out.push('');
-out.push('Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a required argument. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command.');
-out.push('');
-
-for (const [cat, list] of byCat) {
- if (!list.length) continue;
- out.push(`## ${cat}`);
- out.push('');
- for (const c of list) {
- const flags = [];
- if (c.mainThreadRequired === false) flags.push('works while main thread is busy');
- const desc = normDesc(c.description);
- out.push(`### ${c.name}`);
- out.push(desc + (flags.length ? ` *(${flags.join(', ')})*` : ''));
- if (c.parameters && c.parameters.length) {
- for (const p of c.parameters) {
- const req = p.required ? '\\*' : '';
- const d = normDesc(p.description);
- out.push(`- \`${p.name}\`${req} ${p.type}${fmtDefault(p.defaultValue)}${d ? ' — ' + d : ''}`);
- }
- } else {
- out.push('- *(no arguments)*');
- }
- out.push('');
- }
-}
-
-fs.writeFileSync(process.argv[3], out.join('\n'));
-console.log('written', process.argv[3], out.length, 'lines');