diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1a3a58708..2739b9eb6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -32,19 +32,6 @@ jobs: - name: Check native Go CLI run: scripts/check-go-cli.sh - - name: Build native Go CLI - run: scripts/build-go-cli.sh - - - name: Verify checked-in native Go CLI binaries - run: | - git diff --exit-code -- \ - Packages/src/GoCli~/dist/darwin-arm64/uloop-core \ - Packages/src/GoCli~/dist/darwin-amd64/uloop-core \ - Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe \ - Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher \ - Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher \ - Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe - - name: Package native Go CLI installers run: scripts/package-go-cli.sh diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index 3c28d6eb5..2cc28aa48 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -43,9 +43,6 @@ jobs: - name: Check native CLI run: scripts/check-go-cli.sh - - name: Build native CLI binaries - run: scripts/build-go-cli.sh - - name: Package native CLI release assets run: scripts/package-go-cli.sh diff --git a/AGENTS.md b/AGENTS.md index 9016d88b6..be072918a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,11 @@ Comments in the code, commit messages, PR titles, and PR descriptions must all b Do not directly edit skill files under the project-root `.agents/` or `.claude/` directories. These files are generated copies. Update the source skill definitions instead, then regenerate the copies through the normal workflow. +## Native Go CLI Validation + +When changing files under `Packages/src/GoCli~` or any checked-in native CLI binary under `Packages/src/GoCli~/dist`, run `scripts/check-go-cli.sh` before opening or updating a pull request. +This script is the local equivalent of the Go CLI CI validation: it runs formatting checks, vet, lint, tests, rebuilds the checked-in native binaries, and fails if the rebuilt binaries differ from the committed files. + ## Unity Freeze Prevention Do not add or keep Unity EditMode tests that can freeze the Editor. diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core index 899514ef4..d6c966019 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher index bdb5eb44a..39f46fd7e 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core index 1d2ffe1d4..9ff7639cb 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher index 5b6800025..cdcbd37df 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe index 34f43df0f..c1e876455 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe index 885e52029..11cacf28b 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe differ diff --git a/Packages/src/GoCli~/internal/cli/argument_error.go b/Packages/src/GoCli~/internal/cli/argument_error.go new file mode 100644 index 000000000..5907cd62f --- /dev/null +++ b/Packages/src/GoCli~/internal/cli/argument_error.go @@ -0,0 +1,66 @@ +package cli + +import "fmt" + +type argumentError struct { + message string + option string + received string + expectedType string + command string + nextActions []string +} + +func (err *argumentError) Error() string { + return err.message +} + +func (err *argumentError) toCLIError(context errorContext) cliError { + command := firstNonEmpty(err.command, context.command) + details := map[string]any{} + if err.option != "" { + details["option"] = err.option + } + if err.received != "" { + details["received"] = err.received + } + if err.expectedType != "" { + details["expectedType"] = err.expectedType + } + + nextActions := err.nextActions + if len(nextActions) == 0 { + nextActions = []string{"Correct the command arguments and retry."} + } + + return cliError{ + ErrorCode: errorCodeInvalidArgument, + Phase: errorPhaseArgumentParsing, + Message: err.message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: command, + NextActions: nextActions, + Details: details, + } +} + +func missingValueArgumentError(option string) *argumentError { + return &argumentError{ + message: fmt.Sprintf("%s requires a value", option), + option: option, + expectedType: "string", + nextActions: []string{fmt.Sprintf("Pass a value after `%s` or use `%s=`.", option, option)}, + } +} + +func invalidValueArgumentError(option string, received string, expectedType string) *argumentError { + return &argumentError{ + message: fmt.Sprintf("Invalid %s value for %s: %s", expectedType, option, received), + option: option, + received: received, + expectedType: expectedType, + nextActions: []string{fmt.Sprintf("Pass a valid %s value for `%s`.", expectedType, option)}, + } +} diff --git a/Packages/src/GoCli~/internal/cli/completion.go b/Packages/src/GoCli~/internal/cli/completion.go index dd7beaf9f..a305f50ed 100644 --- a/Packages/src/GoCli~/internal/cli/completion.go +++ b/Packages/src/GoCli~/internal/cli/completion.go @@ -46,7 +46,12 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write if args[0] == listOptionsFlag { if len(args) < 2 { - writeLine(stderr, "--list-options requires a command name") + writeErrorEnvelope(stderr, (&argumentError{ + message: "--list-options requires a command name", + option: listOptionsFlag, + command: completionCommand, + nextActions: []string{"Pass the command name after `--list-options`."}, + }).toCLIError(errorContext{command: completionCommand})) return true, 1 } printOptionsForCommand(args[1], cache, stdout) @@ -64,7 +69,7 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write request, err := parseCompletionRequest(args[1:]) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: completionCommand}) return true, 1 } @@ -73,7 +78,15 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write shellName = detectShell() } if shellName == "" { - writeLine(stderr, "Could not detect shell. Use --shell bash, --shell zsh, --shell powershell, or --shell pwsh.") + writeErrorEnvelope(stderr, cliError{ + ErrorCode: errorCodeInvalidArgument, + Phase: errorPhaseArgumentParsing, + Message: "Could not detect shell.", + Retryable: false, + SafeToRetry: false, + Command: completionCommand, + NextActions: []string{"Pass `--shell bash`, `--shell zsh`, `--shell powershell`, or `--shell pwsh`."}, + }) return true, 1 } @@ -85,11 +98,11 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write configPath, err := getShellConfigPath(shellName) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: completionCommand}) return true, 1 } if err := installCompletionScript(configPath, shellName, script); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: completionCommand}) return true, 1 } @@ -127,7 +140,7 @@ func parseCompletionRequest(args []string) (completionRequest, error) { if arg == shellFlag { if index+1 >= len(args) { - return completionRequest{}, fmt.Errorf("%s requires a value", shellFlag) + return completionRequest{}, missingValueArgumentError(shellFlag) } normalized, err := normalizeShell(args[index+1]) if err != nil { @@ -138,7 +151,12 @@ func parseCompletionRequest(args []string) (completionRequest, error) { continue } - return completionRequest{}, fmt.Errorf("unknown completion option: %s", arg) + return completionRequest{}, &argumentError{ + message: "Unknown completion option: " + arg, + option: arg, + command: completionCommand, + nextActions: []string{"Run `uloop completion --help` to inspect supported completion options."}, + } } return request, nil } @@ -151,7 +169,14 @@ func normalizeShell(value string) (string, error) { if normalized == "powershell-core" { return "pwsh", nil } - return "", fmt.Errorf("unknown shell: %s. Supported: bash, zsh, powershell, pwsh", value) + return "", &argumentError{ + message: "Unknown shell: " + value, + option: shellFlag, + received: value, + expectedType: "bash|zsh|powershell|pwsh", + command: completionCommand, + nextActions: []string{"Use one of: bash, zsh, powershell, pwsh."}, + } } func printCommandNames(cache toolsCache, stdout io.Writer) { diff --git a/Packages/src/GoCli~/internal/cli/error_envelope.go b/Packages/src/GoCli~/internal/cli/error_envelope.go new file mode 100644 index 000000000..de7fde47f --- /dev/null +++ b/Packages/src/GoCli~/internal/cli/error_envelope.go @@ -0,0 +1,298 @@ +package cli + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" +) + +const ( + errorCodeInvalidArgument = "INVALID_ARGUMENT" + errorCodeUnknownCommand = "UNKNOWN_COMMAND" + errorCodeProjectNotFound = "PROJECT_NOT_FOUND" + errorCodeProjectLocalCLIMissing = "PROJECT_LOCAL_CLI_MISSING" + errorCodeUnityNotReachable = "UNITY_NOT_REACHABLE" + errorCodeUnityDisconnectedAfterDispatch = "UNITY_DISCONNECTED_AFTER_DISPATCH" + errorCodeUnityRPCError = "UNITY_RPC_ERROR" + errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" + errorCodeInternalError = "INTERNAL_ERROR" + + errorPhaseArgumentParsing = "argument_parsing" + errorPhaseProjectResolve = "project_resolution" + errorPhaseDispatch = "dispatch" + errorPhaseConnection = "connection" + errorPhaseResponseWaiting = "response_waiting" + errorPhaseUnityRPC = "unity_rpc" + errorPhaseCompileWaiting = "compile_waiting" + errorPhaseExecution = "execution" +) + +type cliError struct { + ErrorCode string `json:"errorCode"` + Phase string `json:"phase"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + SafeToRetry bool `json:"safeToRetry"` + ProjectRoot string `json:"projectRoot,omitempty"` + Command string `json:"command,omitempty"` + NextActions []string `json:"nextActions"` + Details map[string]any `json:"details,omitempty"` +} + +func (err cliError) Error() string { + return err.Message +} + +type cliErrorEnvelope struct { + Success bool `json:"success"` + Error cliError `json:"error"` +} + +type errorContext struct { + projectRoot string + command string +} + +func writeErrorEnvelope(writer io.Writer, err cliError) { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + _ = encoder.Encode(cliErrorEnvelope{ + Success: false, + Error: err, + }) +} + +func writeClassifiedError(writer io.Writer, err error, context errorContext) { + writeErrorEnvelope(writer, classifyError(err, context)) +} + +func writeToolFailure(writer io.Writer, err error, outcome unity.SendOutcome, context errorContext) { + if err != nil && outcome.RequestDispatched && isTransportDisconnectError(err) { + writeErrorEnvelope(writer, disconnectedAfterDispatchError(err, context)) + return + } + writeClassifiedError(writer, err, context) +} + +func classifyError(err error, context errorContext) cliError { + if err == nil { + return internalCLIError("unknown CLI error", context) + } + + var argumentErr *argumentError + if errors.As(err, &argumentErr) { + return argumentErr.toCLIError(context) + } + + var connectionErr *unity.ConnectionAttemptError + if errors.As(err, &connectionErr) { + return cliError{ + ErrorCode: errorCodeUnityNotReachable, + Phase: errorPhaseConnection, + Message: "The Unity CLI Loop server is not reachable for this project.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: firstNonEmpty(context.projectRoot, connectionErr.ProjectRoot), + Command: context.command, + NextActions: []string{ + "If Unity is closed, run `uloop launch`.", + "If Unity is starting, compiling, or reloading scripts, wait and retry.", + "Confirm that the command targets the intended Unity project.", + }, + Details: map[string]any{ + "endpoint": connectionErr.Endpoint, + "cause": connectionErr.Unwrap().Error(), + }, + } + } + + var rpcErr *unity.RPCError + if errors.As(err, &rpcErr) { + details := map[string]any{ + "code": rpcErr.Code, + "message": rpcErr.Message, + } + if len(rpcErr.Data) > 0 { + var data any + if json.Unmarshal(rpcErr.Data, &data) == nil { + details["data"] = data + } else { + details["data"] = string(rpcErr.Data) + } + } + return cliError{ + ErrorCode: errorCodeUnityRPCError, + Phase: errorPhaseUnityRPC, + Message: rpcErr.Message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Read the Unity error details and fix the request or project state before retrying.", + }, + Details: details, + } + } + + message := err.Error() + if message == "unity project not found. Use --project-path option to specify the target" || + strings.HasPrefix(message, "not a Unity project:") || + strings.HasPrefix(message, "--project-path does not point to a Unity project:") { + return cliError{ + ErrorCode: errorCodeProjectNotFound, + Phase: errorPhaseProjectResolve, + Message: message, + Retryable: false, + SafeToRetry: false, + Command: context.command, + NextActions: []string{ + "Run the command from inside a Unity project.", + "Pass `--project-path ` before the command.", + }, + } + } + + if message == updateUnsupportedOSMessage { + return cliError{ + ErrorCode: errorCodeInvalidArgument, + Phase: errorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + Command: context.command, + NextActions: []string{ + "Run `uloop update` on macOS or Windows.", + "Install the latest uloop launcher manually on this platform.", + }, + } + } + + return internalCLIError(message, context) +} + +func disconnectedAfterDispatchError(err error, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeUnityDisconnectedAfterDispatch, + Phase: errorPhaseResponseWaiting, + Message: "Unity disconnected after the CLI dispatched the request.", + Retryable: true, + SafeToRetry: isSafeRetryCommand(context.command), + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Check Unity Console logs if the command may have changed project or scene state.", + "Retry after Unity finishes compiling, reloading scripts, or restarting the bridge.", + }, + Details: map[string]any{ + "cause": err.Error(), + }, + } +} + +func unknownCommandError(command string, cache toolsCache, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeUnknownCommand, + Phase: errorPhaseDispatch, + Message: "Unknown command: " + command, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: command, + NextActions: []string{ + "Run `uloop list` to inspect available commands.", + "Run `uloop sync` if the local tool cache may be stale.", + }, + Details: map[string]any{ + "availableCommands": availableCommandNames(cache), + }, + } +} + +func projectLocalCLIMissingError(localPath string, projectRoot string, command string) cliError { + return cliError{ + ErrorCode: errorCodeProjectLocalCLIMissing, + Phase: errorPhaseDispatch, + Message: "Project-local uloop-core CLI was not found.", + Retryable: false, + SafeToRetry: false, + ProjectRoot: projectRoot, + Command: command, + NextActions: []string{ + "Open the Unity project so the package can install the project-local CLI.", + "Reinstall or update Unity CLI Loop in this project if the file is still missing.", + }, + Details: map[string]any{ + "path": localPath, + }, + } +} + +func compileWaitTimeoutError(projectRoot string) cliError { + return cliError{ + ErrorCode: errorCodeCompileWaitTimeout, + Phase: errorPhaseCompileWaiting, + Message: "Compile wait timed out after 90000ms.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + Command: compileCommandName, + NextActions: []string{ + "Run `uloop fix` to remove stale lock files.", + "Retry `uloop compile --wait-for-domain-reload true` after Unity becomes responsive.", + }, + } +} + +func internalCLIError(message string, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeInternalError, + Phase: errorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Read the message and fix the local environment or command input before retrying.", + }, + } +} + +func availableCommandNames(cache toolsCache) []string { + seen := map[string]bool{} + names := []string{} + for _, name := range []string{"list", "sync", "focus-window", "fix"} { + seen[name] = true + names = append(names, name) + } + for _, tool := range cache.Tools { + if seen[tool.Name] { + continue + } + seen[tool.Name] = true + names = append(names, tool.Name) + } + return names +} + +func isSafeRetryCommand(command string) bool { + switch command { + case "list", "sync", "get-version", "get-logs", "get-tool-details": + return true + default: + return false + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/Packages/src/GoCli~/internal/cli/error_envelope_test.go b/Packages/src/GoCli~/internal/cli/error_envelope_test.go new file mode 100644 index 000000000..f26b94495 --- /dev/null +++ b/Packages/src/GoCli~/internal/cli/error_envelope_test.go @@ -0,0 +1,223 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" +) + +func TestWriteErrorEnvelopeWritesMachineReadableJSON(t *testing.T) { + var stderr bytes.Buffer + + writeErrorEnvelope(&stderr, cliError{ + ErrorCode: errorCodeInvalidArgument, + Phase: errorPhaseArgumentParsing, + Message: "Invalid boolean value for --enabled: maybe", + Retryable: false, + SafeToRetry: false, + ProjectRoot: "/tmp/MyProject", + Command: "sample", + NextActions: []string{"Pass a valid boolean value for `--enabled`."}, + Details: map[string]any{ + "option": "--enabled", + "received": "maybe", + "expectedType": "boolean", + }, + }) + + var envelope cliErrorEnvelope + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String()) + } + if envelope.Success { + t.Fatal("error envelope reported success") + } + if envelope.Error.ErrorCode != errorCodeInvalidArgument { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Details["option"] != "--enabled" { + t.Fatalf("details mismatch: %#v", envelope.Error.Details) + } +} + +func TestBuildToolParamsReturnsStructuredInvalidBooleanError(t *testing.T) { + tool := toolDefinition{ + Name: "sample-tool", + InputSchema: inputSchema{ + Properties: map[string]toolProperty{ + "Enabled": {Type: "boolean"}, + }, + }, + } + + _, _, err := buildToolParams([]string{"--enabled", "maybe"}, tool) + if err == nil { + t.Fatal("expected argument error") + } + + var argumentErr *argumentError + if !errors.As(err, &argumentErr) { + t.Fatalf("expected argumentError, got %T", err) + } + cliErr := argumentErr.toCLIError(errorContext{projectRoot: "/tmp/MyProject", command: "sample-tool"}) + if cliErr.ErrorCode != errorCodeInvalidArgument { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Details["expectedType"] != "boolean" { + t.Fatalf("details mismatch: %#v", cliErr.Details) + } +} + +func TestClassifyConnectionAttemptError(t *testing.T) { + err := &unity.ConnectionAttemptError{ + ProjectRoot: "/tmp/MyProject", + Endpoint: "/tmp/uloop/uLoopMCP-sample.sock", + Cause: errors.New("connect: no such file or directory"), + } + + cliErr := classifyError(err, errorContext{command: "get-logs"}) + if cliErr.ErrorCode != errorCodeUnityNotReachable { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if !cliErr.Retryable || !cliErr.SafeToRetry { + t.Fatalf("retry flags mismatch: %#v", cliErr) + } + if cliErr.ProjectRoot != "/tmp/MyProject" { + t.Fatalf("project root mismatch: %#v", cliErr) + } +} + +func TestClassifyRPCErrorKeepsData(t *testing.T) { + err := &unity.RPCError{ + Code: -32000, + Message: "Tool blocked by security settings", + Data: json.RawMessage(`{"type":"security_blocked","reason":"disabled"}`), + } + + cliErr := classifyError(err, errorContext{projectRoot: "/tmp/MyProject", command: "execute-dynamic-code"}) + if cliErr.ErrorCode != errorCodeUnityRPCError { + t.Fatalf("error code mismatch: %#v", cliErr) + } + data, ok := cliErr.Details["data"].(map[string]any) + if !ok { + t.Fatalf("rpc data missing: %#v", cliErr.Details) + } + if data["type"] != "security_blocked" { + t.Fatalf("rpc data mismatch: %#v", data) + } +} + +func TestWriteToolFailureClassifiesDispatchedDisconnect(t *testing.T) { + var stderr bytes.Buffer + + writeToolFailure( + &stderr, + errors.New("EOF"), + unity.SendOutcome{RequestDispatched: true}, + errorContext{projectRoot: "/tmp/MyProject", command: "execute-dynamic-code"}, + ) + + var envelope cliErrorEnvelope + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String()) + } + if envelope.Error.ErrorCode != errorCodeUnityDisconnectedAfterDispatch { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.SafeToRetry { + t.Fatalf("stateful command should not be safe to retry: %#v", envelope.Error) + } +} + +func TestProjectLocalCLIMissingError(t *testing.T) { + cliErr := projectLocalCLIMissingError( + "/tmp/MyProject/.uloop/bin/uloop-core", + "/tmp/MyProject", + "compile", + ) + + if cliErr.ErrorCode != errorCodeProjectLocalCLIMissing { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Details["path"] != "/tmp/MyProject/.uloop/bin/uloop-core" { + t.Fatalf("details mismatch: %#v", cliErr.Details) + } +} + +func TestUnknownCommandErrorIncludesAvailableCommands(t *testing.T) { + cliErr := unknownCommandError( + "missing", + toolsCache{Tools: []toolDefinition{{Name: "compile"}}}, + errorContext{projectRoot: "/tmp/MyProject"}, + ) + + if cliErr.ErrorCode != errorCodeUnknownCommand { + t.Fatalf("error code mismatch: %#v", cliErr) + } + available, ok := cliErr.Details["availableCommands"].([]string) + if !ok { + t.Fatalf("available commands missing: %#v", cliErr.Details) + } + if len(available) == 0 || available[len(available)-1] != "compile" { + t.Fatalf("available commands mismatch: %#v", available) + } +} + +func TestClassifyProjectNotFound(t *testing.T) { + cliErr := classifyError( + errors.New("unity project not found. Use --project-path option to specify the target"), + errorContext{command: "compile"}, + ) + + if cliErr.ErrorCode != errorCodeProjectNotFound { + t.Fatalf("error code mismatch: %#v", cliErr) + } +} + +func TestCompileWaitTimeoutError(t *testing.T) { + cliErr := compileWaitTimeoutError("/tmp/MyProject") + + if cliErr.ErrorCode != errorCodeCompileWaitTimeout { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if !cliErr.Retryable || !cliErr.SafeToRetry { + t.Fatalf("retry flags mismatch: %#v", cliErr) + } + if cliErr.ProjectRoot != "/tmp/MyProject" { + t.Fatalf("project root mismatch: %#v", cliErr) + } +} + +func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { + err := &unity.ConnectionAttemptError{ + Endpoint: "/tmp/uloop/uLoopMCP-sample.sock", + Cause: errors.New("connect failed"), + } + + cliErr := classifyError(err, errorContext{projectRoot: "/tmp/ContextProject", command: "compile"}) + if cliErr.ProjectRoot != "/tmp/ContextProject" { + t.Fatalf("project root mismatch: %#v", cliErr) + } +} + +func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { + names := availableCommandNames(toolsCache{}) + expectedBuiltIns := []string{"list", "sync", "focus-window", "fix"} + for index, expected := range expectedBuiltIns { + if names[index] != expected { + t.Fatalf("built-in command mismatch: %#v", names) + } + } +} + +func TestSafeRetryCommand(t *testing.T) { + if !isSafeRetryCommand("get-logs") { + t.Fatal("get-logs should be safe to retry") + } + if isSafeRetryCommand("execute-dynamic-code") { + t.Fatal("execute-dynamic-code should not be safe to retry") + } +} diff --git a/Packages/src/GoCli~/internal/cli/fix.go b/Packages/src/GoCli~/internal/cli/fix.go index bf5c6a1c0..7cd916930 100644 --- a/Packages/src/GoCli~/internal/cli/fix.go +++ b/Packages/src/GoCli~/internal/cli/fix.go @@ -16,7 +16,7 @@ var staleLockFileNames = []string{ func runFix(projectRoot string, stdout io.Writer, stderr io.Writer) int { cleaned, err := cleanupStaleLockFiles(projectRoot) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: "fix"}) return 1 } diff --git a/Packages/src/GoCli~/internal/cli/launch.go b/Packages/src/GoCli~/internal/cli/launch.go index ce866d56f..8fd33e8ef 100644 --- a/Packages/src/GoCli~/internal/cli/launch.go +++ b/Packages/src/GoCli~/internal/cli/launch.go @@ -54,7 +54,7 @@ func tryHandleLaunchRequest( options, err := parseLaunchOptions(args[1:], globalProjectPath) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: launchCommandName}) return true, 1 } @@ -78,7 +78,12 @@ func parseLaunchOptions(args []string, globalProjectPath string) (launchOptions, case arg == "-d" || arg == "--delete-recovery": options.deleteRecovery = true case arg == "-a" || arg == "-f" || arg == "--add-unity-hub" || arg == "--favorite" || arg == "--unity-hub-entry": - return launchOptions{}, fmt.Errorf("native launch does not support Unity Hub registration options") + return launchOptions{}, &argumentError{ + message: "Native launch does not support Unity Hub registration options.", + option: arg, + command: launchCommandName, + nextActions: []string{"Remove the Unity Hub registration option and retry `uloop launch`."}, + } case arg == "-p" || arg == "--platform": value, consumed, err := readLaunchOptionValue(arg, args, index) if err != nil { @@ -97,7 +102,7 @@ func parseLaunchOptions(args []string, globalProjectPath string) (launchOptions, } maxDepth, err := strconv.Atoi(value) if err != nil { - return launchOptions{}, fmt.Errorf("invalid --max-depth value: %s", value) + return launchOptions{}, invalidValueArgumentError("--max-depth", value, "integer") } options.maxDepth = maxDepth if consumed { @@ -107,14 +112,24 @@ func parseLaunchOptions(args []string, globalProjectPath string) (launchOptions, value := strings.TrimPrefix(arg, "--max-depth=") maxDepth, err := strconv.Atoi(value) if err != nil { - return launchOptions{}, fmt.Errorf("invalid --max-depth value: %s", value) + return launchOptions{}, invalidValueArgumentError("--max-depth", value, "integer") } options.maxDepth = maxDepth case strings.HasPrefix(arg, "-"): - return launchOptions{}, fmt.Errorf("unknown launch option: %s", arg) + return launchOptions{}, &argumentError{ + message: "Unknown launch option: " + arg, + option: arg, + command: launchCommandName, + nextActions: []string{"Run `uloop launch --help` to inspect supported launch options."}, + } default: if options.projectPath != "" { - return launchOptions{}, fmt.Errorf("unexpected extra launch argument: %s", arg) + return launchOptions{}, &argumentError{ + message: "Unexpected extra launch argument: " + arg, + received: arg, + command: launchCommandName, + nextActions: []string{"Pass only one project path to `uloop launch`."}, + } } options.projectPath = arg } @@ -127,12 +142,12 @@ func readLaunchOptionValue(option string, args []string, index int) (string, boo if strings.Contains(option, "=") { parts := strings.SplitN(option, "=", 2) if parts[1] == "" { - return "", false, fmt.Errorf("%s requires a value", parts[0]) + return "", false, missingValueArgumentError(parts[0]) } return parts[1], false, nil } if index+1 >= len(args) || isInvalidLaunchOptionValue(option, args[index+1]) { - return "", false, fmt.Errorf("%s requires a value", option) + return "", false, missingValueArgumentError(option) } return args[index+1], true, nil } @@ -147,20 +162,20 @@ func isInvalidLaunchOptionValue(option string, value string) bool { func runLaunch(ctx context.Context, options launchOptions, startPath string, stdout io.Writer, stderr io.Writer) int { projectRoot, err := resolveLaunchProjectRoot(startPath, options) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: launchCommandName}) return 1 } if options.deleteRecovery { if err := os.RemoveAll(filepath.Join(projectRoot, recoveryDirectoryPath)); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } } runningProcess, err := findRunningUnityProcess(ctx, projectRoot) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } @@ -171,7 +186,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std return 0 } if err := killUnityProcess(runningProcess.pid); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } if options.quit { @@ -187,7 +202,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std unityPath, err := resolveUnityExecutablePath(projectRoot) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } @@ -198,13 +213,13 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std command := exec.CommandContext(ctx, unityPath, launchArgs...) if err := command.Start(); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } writeFormat(stdout, "Unity launch started for %s (PID: %d)\n", projectRoot, command.Process.Pid) if err := waitForLaunchReady(ctx, projectRoot); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } writeLine(stdout, "Unity is ready.") diff --git a/Packages/src/GoCli~/internal/cli/run.go b/Packages/src/GoCli~/internal/cli/run.go index 036dd3909..ee39a381c 100644 --- a/Packages/src/GoCli~/internal/cli/run.go +++ b/Packages/src/GoCli~/internal/cli/run.go @@ -23,7 +23,7 @@ const ( func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { remainingArgs, projectPath, err := parseGlobalProjectPath(args) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{}) return 1 } @@ -41,7 +41,7 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder startPath, err := os.Getwd() if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: command}) return 1 } @@ -61,13 +61,13 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder connection, err := project.ResolveConnection(startPath, projectPath) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: command}) return 1 } cache, err := loadTools(connection.ProjectRoot) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: command}) return 1 } @@ -83,17 +83,29 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder default: tool, ok := findTool(cache, command) if !ok { - writeFormat(stderr, "Unknown command: %s\n", command) + writeErrorEnvelope(stderr, unknownCommandError(command, cache, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + })) return 1 } params, nestedProjectPath, err := buildToolParams(commandArgs, tool) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + }) return 1 } if nestedProjectPath != "" && nestedProjectPath != connection.ProjectRoot { - writeLine(stderr, "--project-path must be passed before the command in the native CLI") + writeErrorEnvelope(stderr, (&argumentError{ + message: "--project-path must be passed before the command in the native CLI", + option: "--project-path", + expectedType: "path", + command: command, + nextActions: []string{"Move `--project-path ` before the command name."}, + }).toCLIError(errorContext{projectRoot: connection.ProjectRoot, command: command})) return 1 } return runTool(ctx, connection, command, params, stdout, stderr) @@ -118,13 +130,13 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io startPath, err := os.Getwd() if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{}) return 1 } remainingArgs, explicitProjectPath, err := parseGlobalProjectPath(args) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{}) return 1 } if handled, code := tryHandleLaunchRequest(ctx, remainingArgs, startPath, explicitProjectPath, stdout, stderr); handled { @@ -136,7 +148,7 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io projectRoot, err := resolveLauncherProjectRoot(startPath, explicitProjectPath) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{}) return 1 } @@ -145,7 +157,11 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io localPath = filepath.Join(projectRoot, projectLocalWindowsPath) } if _, err := os.Stat(localPath); err != nil { - writeFormat(stderr, "Project-local uloop-core CLI was not found at %s\n", localPath) + command := "" + if len(remainingArgs) > 0 { + command = remainingArgs[0] + } + writeErrorEnvelope(stderr, projectLocalCLIMissingError(localPath, projectRoot, command)) return 1 } @@ -162,22 +178,28 @@ func runTool(ctx context.Context, connection project.Connection, command string, } spinner := newToolSpinner(stderr, command) - result, err := unity.NewClient(connection).SendWithProgress(ctx, command, params, func(string) { + outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, command, params, func(string) { spinner.Update(fmt.Sprintf("Executing %s...", command)) }) spinner.Stop() if err != nil { - writeLine(stderr, err.Error()) + writeToolFailure(stderr, err, outcome, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + }) return 1 } - writeJSON(stdout, result) + writeJSON(stdout, outcome.Result) return 0 } func runCompileWithDomainReloadWait(ctx context.Context, connection project.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { requestID, err := ensureCompileRequestID(params) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: compileCommandName, + }) return 1 } @@ -190,7 +212,10 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn } if !shouldWaitForCompileResult(err, outcome) { spinner.Stop() - writeLine(stderr, err.Error()) + writeToolFailure(stderr, err, outcome, errorContext{ + projectRoot: connection.ProjectRoot, + command: compileCommandName, + }) return 1 } @@ -204,11 +229,14 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn }) spinner.Stop() if waitErr != nil { - writeLine(stderr, waitErr.Error()) + writeClassifiedError(stderr, waitErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: compileCommandName, + }) return 1 } if !completed { - writeLine(stderr, "Compile wait timed out after 90000ms. Run 'uloop fix' and retry.") + writeErrorEnvelope(stderr, compileWaitTimeoutError(connection.ProjectRoot)) return 1 } writeJSON(stdout, result) @@ -217,36 +245,42 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn func runList(ctx context.Context, connection project.Connection, stdout io.Writer, stderr io.Writer) int { spinner := newToolSpinner(stderr, "list") - result, err := unity.NewClient(connection).SendWithProgress(ctx, "get-tool-details", map[string]any{}, func(string) { + outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, "get-tool-details", map[string]any{}, func(string) { spinner.Update("Fetching tool list...") }) spinner.Stop() if err != nil { - writeLine(stderr, err.Error()) + writeToolFailure(stderr, err, outcome, errorContext{ + projectRoot: connection.ProjectRoot, + command: "list", + }) return 1 } - writeJSON(stdout, result) + writeJSON(stdout, outcome.Result) return 0 } func runSync(ctx context.Context, connection project.Connection, stdout io.Writer, stderr io.Writer) int { spinner := newToolSpinner(stderr, "sync") - result, err := unity.NewClient(connection).SendWithProgress(ctx, "get-tool-details", map[string]any{}, func(string) { + outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, "get-tool-details", map[string]any{}, func(string) { spinner.Update("Syncing tools...") }) spinner.Stop() if err != nil { - writeLine(stderr, err.Error()) + writeToolFailure(stderr, err, outcome, errorContext{ + projectRoot: connection.ProjectRoot, + command: "sync", + }) return 1 } cachePath := filepath.Join(connection.ProjectRoot, cacheDirectoryName, cacheFileName) if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: "sync"}) return 1 } - if err := os.WriteFile(cachePath, result, 0o644); err != nil { - writeLine(stderr, err.Error()) + if err := os.WriteFile(cachePath, outcome.Result, 0o644); err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: "sync"}) return 1 } writeFormat(stdout, "Tools synced to %s\n", cachePath) @@ -282,7 +316,7 @@ func execProjectLocal(ctx context.Context, localPath string, args []string, proj if runtime.GOOS != "windows" { err := syscall.Exec(localPath, append([]string{localPath}, args...), os.Environ()) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot}) return 1 } return 0 diff --git a/Packages/src/GoCli~/internal/cli/skills.go b/Packages/src/GoCli~/internal/cli/skills.go index fb3104a11..105abfde2 100644 --- a/Packages/src/GoCli~/internal/cli/skills.go +++ b/Packages/src/GoCli~/internal/cli/skills.go @@ -60,18 +60,18 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s subcommand := args[1] options, err := parseSkillsOptions(args[2:]) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) return true, 1 } projectRoot, err := resolveSkillsProjectRoot(startPath, globalProjectPath, options.global) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) return true, 1 } skills, err := collectSkillDefinitions(projectRoot) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return true, 1 } @@ -91,7 +91,12 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s } return true, runSkillsUninstall(projectRoot, skills, options, stdout, stderr) default: - writeFormat(stderr, "unknown skills command: %s\n", subcommand) + writeErrorEnvelope(stderr, (&argumentError{ + message: "Unknown skills command: " + subcommand, + received: subcommand, + command: skillsCommandName, + nextActions: []string{"Use `uloop skills list`, `uloop skills install`, or `uloop skills uninstall`."}, + }).toCLIError(errorContext{projectRoot: projectRoot, command: skillsCommandName})) return true, 1 } } @@ -108,7 +113,12 @@ func parseSkillsOptions(args []string) (skillCommandOptions, error) { targetID := strings.TrimPrefix(arg, "--") options.targets = append(options.targets, targetConfigs[targetID]) default: - return skillCommandOptions{}, fmt.Errorf("unknown skills option: %s", arg) + return skillCommandOptions{}, &argumentError{ + message: "Unknown skills option: " + arg, + option: arg, + command: skillsCommandName, + nextActions: []string{"Run `uloop skills --help` to inspect supported skills options."}, + } } } return options, nil @@ -171,7 +181,7 @@ func runSkillsInstall(projectRoot string, skills []skillDefinition, options skil for _, target := range options.targets { result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 } writeFormat(stdout, "%s:\n", target.displayName) @@ -190,7 +200,7 @@ func runSkillsUninstall(projectRoot string, skills []skillDefinition, options sk for _, target := range options.targets { removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) return 1 } writeFormat(stdout, "%s:\n", target.displayName) diff --git a/Packages/src/GoCli~/internal/cli/tools.go b/Packages/src/GoCli~/internal/cli/tools.go index fbae10ba8..a91188057 100644 --- a/Packages/src/GoCli~/internal/cli/tools.go +++ b/Packages/src/GoCli~/internal/cli/tools.go @@ -3,7 +3,6 @@ package cli import ( "embed" "encoding/json" - "fmt" "os" "path/filepath" "strconv" @@ -100,7 +99,12 @@ func buildToolParams(args []string, tool toolDefinition) (map[string]any, string for index := 0; index < len(args); index++ { arg := args[index] if !strings.HasPrefix(arg, "--") { - return nil, "", fmt.Errorf("unexpected argument: %s", arg) + return nil, "", &argumentError{ + message: "Unexpected argument: " + arg, + received: arg, + command: tool.Name, + nextActions: []string{"Pass tool inputs as `--option value` pairs."}, + } } name, value, consumedNext, err := parseFlagValue(arg, args, index) @@ -118,10 +122,15 @@ func buildToolParams(args []string, tool toolDefinition) (map[string]any, string propertyName, property, ok := findProperty(tool, name) if !ok { - return nil, "", fmt.Errorf("unknown option for %s: --%s", tool.Name, name) + return nil, "", &argumentError{ + message: "Unknown option for " + tool.Name + ": --" + name, + option: "--" + name, + command: tool.Name, + nextActions: []string{"Run `uloop completion --list-options " + tool.Name + "` to inspect supported options."}, + } } - converted, err := convertValue(value, property) + converted, err := convertValue(value, property, "--"+name) if err != nil { return nil, "", err } @@ -162,19 +171,23 @@ func parseGlobalProjectPath(args []string) ([]string, string, error) { func parseFlagValue(arg string, args []string, index int) (string, string, bool, error) { trimmed := strings.TrimPrefix(arg, "--") if trimmed == "" { - return "", "", false, fmt.Errorf("invalid option: %s", arg) + return "", "", false, &argumentError{ + message: "Invalid option: " + arg, + option: arg, + nextActions: []string{"Use `--option value` or `--option=value`."}, + } } if strings.Contains(trimmed, "=") { parts := strings.SplitN(trimmed, "=", 2) if parts[1] == "" { - return "", "", false, fmt.Errorf("--%s requires a value", parts[0]) + return "", "", false, missingValueArgumentError("--" + parts[0]) } return parts[0], parts[1], false, nil } if index+1 >= len(args) || isNextOptionToken(args[index+1]) { - return "", "", false, fmt.Errorf("--%s requires a value", trimmed) + return "", "", false, missingValueArgumentError("--" + trimmed) } return trimmed, args[index+1], true, nil @@ -199,7 +212,7 @@ func findProperty(tool toolDefinition, kebabName string) (string, toolProperty, return "", toolProperty{}, false } -func convertValue(value string, property toolProperty) (any, error) { +func convertValue(value string, property toolProperty, option string) (any, error) { switch strings.ToLower(property.Type) { case "boolean": switch strings.ToLower(value) { @@ -208,25 +221,25 @@ func convertValue(value string, property toolProperty) (any, error) { case "false": return false, nil default: - return nil, fmt.Errorf("invalid boolean value: %s", value) + return nil, invalidValueArgumentError(option, value, "boolean") } case "integer": parsed, err := strconv.Atoi(value) if err != nil { - return nil, fmt.Errorf("invalid integer value: %s", value) + return nil, invalidValueArgumentError(option, value, "integer") } return parsed, nil case "number": parsed, err := strconv.ParseFloat(value, 64) if err != nil { - return nil, fmt.Errorf("invalid number value: %s", value) + return nil, invalidValueArgumentError(option, value, "number") } return parsed, nil case "array": if strings.HasPrefix(value, "[") { var parsed []any if err := json.Unmarshal([]byte(value), &parsed); err != nil { - return nil, fmt.Errorf("invalid array value: %s", value) + return nil, invalidValueArgumentError(option, value, "array") } return parsed, nil } @@ -239,7 +252,7 @@ func convertValue(value string, property toolProperty) (any, error) { case "object": var parsed map[string]any if err := json.Unmarshal([]byte(value), &parsed); err != nil { - return nil, fmt.Errorf("invalid object value: %s", value) + return nil, invalidValueArgumentError(option, value, "object") } return parsed, nil default: diff --git a/Packages/src/GoCli~/internal/cli/update.go b/Packages/src/GoCli~/internal/cli/update.go index 42c04fdeb..2150dda43 100644 --- a/Packages/src/GoCli~/internal/cli/update.go +++ b/Packages/src/GoCli~/internal/cli/update.go @@ -22,13 +22,17 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer return false, 0 } if len(args) > 1 { - writeLine(stderr, updateUnsupportedArgMessage) + writeErrorEnvelope(stderr, (&argumentError{ + message: updateUnsupportedArgMessage, + command: "update", + nextActions: []string{"Run `uloop update` without options."}, + }).toCLIError(errorContext{command: "update"})) return true, 1 } commandName, commandArgs, err := updateCommandForOS(runtime.GOOS) if err != nil { - writeLine(stderr, err.Error()) + writeClassifiedError(stderr, err, errorContext{command: "update"}) return true, 1 } @@ -37,7 +41,18 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer command.Stdout = stdout command.Stderr = stderr if err := command.Run(); err != nil { - writeFormat(stderr, "Update failed: %s\n", err.Error()) + writeErrorEnvelope(stderr, cliError{ + ErrorCode: errorCodeInternalError, + Phase: errorPhaseExecution, + Message: "Update failed: " + err.Error(), + Retryable: true, + SafeToRetry: true, + Command: "update", + NextActions: []string{"Retry `uloop update` after checking network access to GitHub."}, + Details: map[string]any{ + "cause": err.Error(), + }, + }) return true, 1 } writeLine(stdout, "uloop launcher update completed.") diff --git a/Packages/src/GoCli~/internal/unity/client.go b/Packages/src/GoCli~/internal/unity/client.go index a1918b81a..a058be626 100644 --- a/Packages/src/GoCli~/internal/unity/client.go +++ b/Packages/src/GoCli~/internal/unity/client.go @@ -46,6 +46,30 @@ type rpcError struct { Data json.RawMessage `json:"data,omitempty"` } +type ConnectionAttemptError struct { + ProjectRoot string + Endpoint string + Cause error +} + +func (err *ConnectionAttemptError) Error() string { + return fmt.Sprintf("the Unity CLI Loop server is not reachable for this project: %s", err.Cause) +} + +func (err *ConnectionAttemptError) Unwrap() error { + return err.Cause +} + +type RPCError struct { + Code int + Message string + Data json.RawMessage +} + +func (err *RPCError) Error() string { + return fmt.Sprintf("unity error: %s", err.Message) +} + func NewClient(connection project.Connection) *Client { return &Client{connection: connection} } @@ -108,7 +132,11 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string return outcome, err } if response.Error != nil { - return outcome, fmt.Errorf("unity error: %s", response.Error.Message) + return outcome, &RPCError{ + Code: response.Error.Code, + Message: response.Error.Message, + Data: response.Error.Data, + } } if len(response.Result) == 0 { return outcome, fmt.Errorf("UNITY_NO_RESPONSE") @@ -119,17 +147,9 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string } func formatConnectionAttemptError(connection project.Connection, err error) error { - return fmt.Errorf( - "the Unity CLI Loop server is not reachable for this project.\n\n"+ - "The CLI could not open the project's IPC endpoint. This is a connection attempt failure before a request was sent; it does not mean an established connection was disconnected.\n\n"+ - "Project: %s\n"+ - "Endpoint: %s\n"+ - "Next steps:\n"+ - " - If Unity is closed, run: uloop launch\n"+ - " - If Unity is starting, compiling, or reloading scripts, wait and retry\n"+ - " - If this project is open in another Unity instance, close the other instance\n\n"+ - "Cause: %w", - connection.ProjectRoot, - connection.Endpoint.Address, - err) + return &ConnectionAttemptError{ + ProjectRoot: connection.ProjectRoot, + Endpoint: connection.Endpoint.Address, + Cause: err, + } } diff --git a/Packages/src/GoCli~/internal/unity/client_test.go b/Packages/src/GoCli~/internal/unity/client_test.go index 1ad13437f..5d2e63a5a 100644 --- a/Packages/src/GoCli~/internal/unity/client_test.go +++ b/Packages/src/GoCli~/internal/unity/client_test.go @@ -2,7 +2,6 @@ package unity import ( "errors" - "strings" "testing" "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" @@ -18,18 +17,17 @@ func TestFormatConnectionAttemptErrorExplainsDialFailureWithoutDisconnectClaim(t } err := formatConnectionAttemptError(connection, errors.New("dial unix /tmp/uloop/uLoopMCP-sample.sock: connect: no such file or directory")) - message := err.Error() - - for _, expected := range []string{ - "the Unity CLI Loop server is not reachable for this project.", - "connection attempt failure before a request was sent", - "does not mean an established connection was disconnected", - "Project: /tmp/MyProject", - "Endpoint: /tmp/uloop/uLoopMCP-sample.sock", - "Cause: dial unix /tmp/uloop/uLoopMCP-sample.sock: connect: no such file or directory", - } { - if !strings.Contains(message, expected) { - t.Fatalf("message missing %q:\n%s", expected, message) - } + connectionErr, ok := err.(*ConnectionAttemptError) + if !ok { + t.Fatalf("expected ConnectionAttemptError, got %T", err) + } + if connectionErr.ProjectRoot != "/tmp/MyProject" { + t.Fatalf("project root mismatch: %s", connectionErr.ProjectRoot) + } + if connectionErr.Endpoint != "/tmp/uloop/uLoopMCP-sample.sock" { + t.Fatalf("endpoint mismatch: %s", connectionErr.Endpoint) + } + if connectionErr.Unwrap().Error() != "dial unix /tmp/uloop/uLoopMCP-sample.sock: connect: no such file or directory" { + t.Fatalf("cause mismatch: %v", connectionErr.Unwrap()) } } diff --git a/scripts/check-go-cli.sh b/scripts/check-go-cli.sh index 1198d12dc..a30376c61 100755 --- a/scripts/check-go-cli.sh +++ b/scripts/check-go-cli.sh @@ -17,3 +17,5 @@ fi golangci-lint run ./... go test ./... ) + +"$ROOT_DIR/scripts/verify-go-cli-dist.sh" diff --git a/scripts/verify-go-cli-dist.sh b/scripts/verify-go-cli-dist.sh new file mode 100755 index 000000000..f4143aa26 --- /dev/null +++ b/scripts/verify-go-cli-dist.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) + +DIST_FILES=" +Packages/src/GoCli~/dist/darwin-arm64/uloop-core +Packages/src/GoCli~/dist/darwin-amd64/uloop-core +Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe +Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher +Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher +Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe +" + +"$ROOT_DIR/scripts/build-go-cli.sh" + +if git -C "$ROOT_DIR" diff --exit-code -- $DIST_FILES; then + exit 0 +fi + +echo "Checked-in Go CLI binaries are out of date." >&2 +echo "Run scripts/build-go-cli.sh and commit the updated dist files." >&2 +exit 1