Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/uloop-execute-dynamic-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: uloop-execute-dynamic-code
toolName: execute-dynamic-code
description: "Execute C# code dynamically in Unity Editor. Use find-game-objects first for basic selected GameObject discovery or property inspection. Use when you need to: (1) Wire prefab/material references and AddComponent operations, (2) Edit SerializedObject properties and reference wiring, (3) Perform scene/hierarchy edits and batch operations, (4) Execute Unity Editor menu commands through EditorApplication.ExecuteMenuItem, (5) PlayMode automation (click buttons, invoke methods, tweak runtime state), (6) PlayMode UI controls (InputField, Slider, Toggle, Dropdown), (7) PlayMode inspection that requires custom Unity API code beyond existing tools. NOT for file I/O or script authoring."
context: fork
---
Expand All @@ -23,7 +24,7 @@ For basic selected GameObject discovery or property inspection, use `find-game-o
- `--code '<code>'` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet.
- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`).
- `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string.
- `--compile-only` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code.
- `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows.

## Code Rules

Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/uloop-execute-dynamic-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: uloop-execute-dynamic-code
toolName: execute-dynamic-code
description: "Execute C# code dynamically in Unity Editor. Use find-game-objects first for basic selected GameObject discovery or property inspection. Use when you need to: (1) Wire prefab/material references and AddComponent operations, (2) Edit SerializedObject properties and reference wiring, (3) Perform scene/hierarchy edits and batch operations, (4) Execute Unity Editor menu commands through EditorApplication.ExecuteMenuItem, (5) PlayMode automation (click buttons, invoke methods, tweak runtime state), (6) PlayMode UI controls (InputField, Slider, Toggle, Dropdown), (7) PlayMode inspection that requires custom Unity API code beyond existing tools. NOT for file I/O or script authoring."
context: fork
---
Expand All @@ -23,7 +24,7 @@ For basic selected GameObject discovery or property inspection, use `find-game-o
- `--code '<code>'` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet.
- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`).
- `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string.
- `--compile-only` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code.
- `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows.

## Code Rules

Expand Down
21 changes: 16 additions & 5 deletions Assets/Tests/Editor/CliSetupApplicationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,29 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag()

Assert.That(
nativeCliInstaller.InstalledVersion,
Is.EqualTo(CliConstants.CLI_RELEASE_TAG_PREFIX + CliConstants.MINIMUM_REQUIRED_CLI_VERSION));
Is.EqualTo(CliConstants.MINIMUM_REQUIRED_CLI_RELEASE_TAG));
}

[Test]
public void GetMinimumRequiredCliVersion_RequiresTerminalUninstallCliRelease()
public void GetMinimumRequiredCliVersion_RequiresDynamicCodeDomainReloadWaitCliRelease()
{
// Verifies this package release rejects CLIs older than the terminal uninstall command.
// Verifies this package release rejects CLIs without dynamic-code domain reload waiting.
CliSetupApplicationService service = new(
new FakeCliInstallationDetector(new string[] { null }),
new FakeNativeCliInstaller());

Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.8"));
Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.9"));
}

[Test]
public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag()
{
// Verifies installers target the prefixed CLI GitHub Release tag.
CliSetupApplicationService service = new(
new FakeCliInstallationDetector(new string[] { null }),
new FakeNativeCliInstaller());

Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.9"));
}

[Test]
Expand All @@ -56,7 +67,7 @@ public void GetGlobalCliInstallCommand_UsesMinimumRequiredCliReleaseTag()

Assert.That(
command.ManualCommand,
Is.EqualTo("install " + CliConstants.CLI_RELEASE_TAG_PREFIX + CliConstants.MINIMUM_REQUIRED_CLI_VERSION));
Is.EqualTo("install " + CliConstants.MINIMUM_REQUIRED_CLI_RELEASE_TAG));
}

private sealed class FakeCliInstallationDetector : ICliInstallationDetector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,53 @@ public void ExecuteDynamicCodeResponse_WhenSerializedWithoutTimings_DoesNotExpos
Assert.That(serializedResponse["Timings"], Is.Null);
Assert.That(serializedResponse["EmitTimingsInJsonResponse"], Is.Null);
Assert.That(serializedResponse["EmitsTimingsInJsonResponse"], Is.Null);
Assert.That(serializedResponse["DomainReloadWaitRequired"], Is.Null);
}

[Test]
public void DynamicCodeDomainReloadWaitSignal_WhenEditorReportsReloadWork_ShouldRequestWait()
{
// Tests that native CLI waits are driven by explicit Unity reload signals.
ExecuteDynamicCodeSchema schema = new()
{
WaitForDomainReload = true,
CompileOnly = false
};

bool shouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait(
schema,
editorIsCompiling: true,
reloadSignalObserved: false);

Assert.That(shouldWait, Is.True);
}

[Test]
public void DynamicCodeDomainReloadWaitSignal_WhenRequestCannotWait_ShouldNotRequestWait()
{
// Tests that compile-only and explicit no-wait requests keep their fast paths.
ExecuteDynamicCodeSchema compileOnlySchema = new()
{
WaitForDomainReload = true,
CompileOnly = true
};
ExecuteDynamicCodeSchema noWaitSchema = new()
{
WaitForDomainReload = false,
CompileOnly = false
};

bool compileOnlyShouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait(
compileOnlySchema,
editorIsCompiling: true,
reloadSignalObserved: true);
bool noWaitShouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait(
noWaitSchema,
editorIsCompiling: true,
reloadSignalObserved: true);

Assert.That(compileOnlyShouldWait, Is.False);
Assert.That(noWaitShouldWait, Is.False);
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using NUnit.Framework;
using UnityEditor;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.ToolContracts;
using ComponentModelDescriptionAttribute = System.ComponentModel.DescriptionAttribute;

Expand Down Expand Up @@ -43,5 +44,14 @@ public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttr
}
}
}

[Test]
public void ExecuteDynamicCodeSchema_WhenCreated_ShouldWaitForDomainReloadByDefault()
{
// Tests that execute-dynamic-code keeps CLI calls on the safe post-reload path by default.
ExecuteDynamicCodeSchema schema = new();

Assert.That(schema.WaitForDomainReload, Is.True);
}
}
}
2 changes: 1 addition & 1 deletion Packages/src/Cli~/contract.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
"cliVersion": "3.0.0-beta.8"
"cliVersion": "3.0.0-beta.9"
}
Binary file modified Packages/src/Cli~/dist/darwin-amd64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/darwin-arm64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/windows-amd64/uloop.exe
Binary file not shown.
15 changes: 2 additions & 13 deletions Packages/src/Cli~/internal/cli/compile_wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
const (
compileCommandName = "compile"
compileRequestIDParam = "RequestId"
compileWaitParam = "WaitForDomainReload"
compileWaitParam = domainReloadWaitParam
compileResultRelativeDir = "Temp/UnityCliLoop/compile-results"
compileWaitTimeout = 90 * time.Second
compileWaitPollInterval = 50 * time.Millisecond
Expand All @@ -37,18 +37,7 @@ func shouldWaitForCompileDomainReload(command string, params map[string]any) boo
if command != compileCommandName {
return false
}
return compileDomainReloadWaitEnabled(params)
}

func compileDomainReloadWaitEnabled(params map[string]any) bool {
value, ok := params[compileWaitParam].(bool)
if ok {
return value
}

// Why: native CLI compile is a user-facing checkpoint; waiting by default
// ensures the post-compile readiness probe runs after the domain is usable.
return true
return domainReloadWaitEnabled(params)
}

func prepareCompileWaitParams(params map[string]any) (string, error) {
Expand Down
64 changes: 64 additions & 0 deletions Packages/src/Cli~/internal/cli/compile_wait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,70 @@ func TestShouldWaitForCompileDomainReloadRespectsExplicitFalse(t *testing.T) {
}
}

// Verifies that execute-dynamic-code waits for domain reload by default.
func TestShouldWaitForExecuteDynamicCodeDomainReloadDefaultsToExecuteDynamicCode(t *testing.T) {
if !shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, map[string]any{}) {
t.Fatal("execute-dynamic-code should wait for domain reload by default")
}

if shouldWaitForExecuteDynamicCodeDomainReload("get-logs", map[string]any{}) {
t.Fatal("non-execute-dynamic-code commands should not use dynamic-code wait")
}
}

// Verifies that execute-dynamic-code can preserve the fast no-wait path.
func TestShouldWaitForExecuteDynamicCodeDomainReloadRespectsExplicitFalse(t *testing.T) {
params := map[string]any{compileWaitParam: false}

if shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, params) {
t.Fatal("execute-dynamic-code wait should be disabled by an explicit false flag")
}
}

// Verifies that compile-only dynamic-code requests keep the diagnostic path fast.
func TestShouldWaitForExecuteDynamicCodeDomainReloadSkipsCompileOnly(t *testing.T) {
params := map[string]any{"CompileOnly": true}

if shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, params) {
t.Fatal("compile-only execute-dynamic-code should not wait for domain reload")
}
}

// Verifies that execute-dynamic-code waits only when Unity explicitly reports a reload signal.
func TestExecuteDynamicCodeDomainReloadWaitRequiredReadsResponseSignal(t *testing.T) {
result := []byte(`{"Success":true,"DomainReloadWaitRequired":true}`)

if !executeDynamicCodeDomainReloadWaitRequired(result) {
t.Fatal("dynamic-code response should request a reload wait")
}

if executeDynamicCodeDomainReloadWaitRequired([]byte(`{"Success":true}`)) {
t.Fatal("dynamic-code response without a reload signal should not request a wait")
}
}

// Verifies that dispatched dynamic-code disconnects still wait for reload recovery.
func TestShouldWaitForExecuteDynamicCodeDisconnectWaitsAfterDispatchedTransportLoss(t *testing.T) {
outcome := unityipc.UnitySendOutcome{RequestDispatched: true}

if !shouldWaitForExecuteDynamicCodeDisconnect(fmt.Errorf("EOF"), outcome) {
t.Fatal("dispatched transport loss should wait for domain reload recovery")
}

if shouldWaitForExecuteDynamicCodeDisconnect(fmt.Errorf("EOF"), unityipc.UnitySendOutcome{}) {
t.Fatal("undispatched transport loss should not use dynamic-code reload wait")
}
}

// Verifies that the CLI does not expose its internal reload-wait response field to users.
func TestStripExecuteDynamicCodeControlResultRemovesReloadSignal(t *testing.T) {
result := stripExecuteDynamicCodeControlResult([]byte(`{"Success":true,"DomainReloadWaitRequired":true}`))

if strings.Contains(string(result), "DomainReloadWaitRequired") {
t.Fatalf("control field leaked into user output: %s", result)
}
}

// Verifies that compile wait preparation creates a request id and enables reload waiting.
func TestPrepareCompileWaitParamsForcesDomainReloadWait(t *testing.T) {
params := map[string]any{}
Expand Down
18 changes: 11 additions & 7 deletions Packages/src/Cli~/internal/cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,19 +246,23 @@ func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer)
writeLine(stdout, "")
return
}
if command == executeDynamicCodeCommandName {
if tool, ok := findTool(loadDefaultTools(), command); ok {
printOptionsForTool(tool, stdout)
}
return
}

tool, ok := findTool(cache, command)
if !ok {
return
}

schema := tool.EffectiveInputSchema()
options := make([]string, 0, len(schema.Properties))
for propertyName, property := range schema.Properties {
options = append(options, "--"+optionNameForProperty(propertyName, property))
}
sort.Strings(options)
writeLine(stdout, strings.Join(options, "\n"))
printOptionsForTool(tool, stdout)
}

func printOptionsForTool(tool toolDefinition, stdout io.Writer) {
writeLine(stdout, strings.Join(visibleOptionNamesForTool(tool), "\n"))
}

func detectShell() string {
Expand Down
65 changes: 65 additions & 0 deletions Packages/src/Cli~/internal/cli/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,71 @@ func TestCompletionListOptionsUsesToolSchema(t *testing.T) {
}
}

func TestCompletionListOptionsUsesExecuteDynamicCodeNoWaitFlag(t *testing.T) {
// Verifies shell completion exposes the default-on reload wait as a negated flag.
var stdout bytes.Buffer
handled, code := tryHandleCompletionRequest(
[]string{"--list-options", executeDynamicCodeCommandName},
loadDefaultTools(),
&stdout,
&bytes.Buffer{},
)

if !handled {
t.Fatal("completion request was not handled")
}
if code != 0 {
t.Fatalf("exit code mismatch: %d", code)
}

output := stdout.String()
if !strings.Contains(output, "--no-wait-for-domain-reload") {
t.Fatalf("execute-dynamic-code no-wait option was not listed: %s", output)
}
if strings.Contains(output, "--wait-for-domain-reload") {
t.Fatalf("execute-dynamic-code wait option should be negated only: %s", output)
}
if strings.Contains(output, "--compile-only") {
t.Fatalf("execute-dynamic-code internal compile-only option should stay hidden: %s", output)
}
}

func TestCompletionListOptionsUsesEmbeddedExecuteDynamicCodeDefinition(t *testing.T) {
// Verifies stale project caches do not hide hot-path execute-dynamic-code options.
var stdout bytes.Buffer
cache := toolsCache{
Tools: []toolDefinition{
{
Name: "execute-dynamic-code",
InputSchema: inputSchema{
Properties: map[string]toolProperty{
"Code": {Type: "string"},
},
},
},
},
}

handled, code := tryHandleCompletionRequest(
[]string{"--list-options", executeDynamicCodeCommandName},
cache,
&stdout,
&bytes.Buffer{},
)

if !handled {
t.Fatal("completion request was not handled")
}
if code != 0 {
t.Fatalf("exit code mismatch: %d", code)
}

output := stdout.String()
if !strings.Contains(output, "--no-wait-for-domain-reload") {
t.Fatalf("embedded execute-dynamic-code options were not used: %s", output)
}
}

func TestCompletionListOptionsUsesNativeLaunchOptions(t *testing.T) {
// Verifies shell completion still suggests native launch flags after CLI unification.
var stdout bytes.Buffer
Expand Down
Loading
Loading