diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index 2e6bac940..2f8683356 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index f45876ec1..c4e54f596 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index ba1642199..81e2ae427 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/cli/command_help.go b/Packages/src/Cli~/internal/cli/command_help.go new file mode 100644 index 000000000..e27edb19e --- /dev/null +++ b/Packages/src/Cli~/internal/cli/command_help.go @@ -0,0 +1,207 @@ +package cli + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/internal/project" +) + +type optionHelpEntry struct { + name string + usage string + description string +} + +func tryHandleCommandHelp(command string, startPath string, projectPath string, stdout io.Writer, stderr io.Writer) (bool, int) { + if isNativeCommandName(command) { + printNativeSingleCommandHelp(command, stdout) + return true, 0 + } + if tool, ok := findDefaultTool(command); ok { + printToolHelp(tool, stdout) + return true, 0 + } + + connection, err := project.ResolveConnection(startPath, projectPath) + if err != nil { + writeClassifiedError(stderr, err, errorContext{command: command}) + return true, 1 + } + tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: connection.ProjectRoot, command: command}) + return true, 1 + } + if !ok { + writeErrorEnvelope(stderr, unknownCommandError(command, cache, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + })) + return true, 1 + } + + printToolHelp(tool, stdout) + return true, 0 +} + +func printNativeSingleCommandHelp(command string, stdout io.Writer) { + writeLine(stdout, "Usage:") + writeFormat(stdout, " uloop %s", command) + if options, ok := nativeCommandOptions[command]; ok && len(options) > 0 { + writeLine(stdout, " [options]") + writeLine(stdout, "") + writeLine(stdout, "Options:") + for _, option := range sortedStrings(options) { + writeFormat(stdout, " %s\n", option) + } + if nativeCommandUsesProject(command) { + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) + } + return + } + + writeLine(stdout, "") + if description, ok := nativeCommandDescription(command); ok { + writeLine(stdout, "") + writeLine(stdout, description) + } + if nativeCommandUsesProject(command) { + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) + } +} + +func printToolHelp(tool toolDefinition, stdout io.Writer) { + writeLine(stdout, "Usage:") + writeFormat(stdout, " uloop %s", tool.Name) + if len(visibleOptionHelpEntriesForTool(tool)) > 0 { + writeLine(stdout, " [options]") + } else { + writeLine(stdout, "") + } + + if description := firstHelpLine(tool.Description); description != "" { + writeLine(stdout, "") + writeLine(stdout, description) + } + + entries := visibleOptionHelpEntriesForTool(tool) + if len(entries) > 0 { + writeLine(stdout, "") + writeLine(stdout, "Options:") + for _, entry := range entries { + writeFormat(stdout, " %-32s %s\n", entry.usage, entry.description) + } + } + + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) +} + +func visibleOptionHelpEntriesForTool(tool toolDefinition) []optionHelpEntry { + schema := tool.EffectiveInputSchema() + entries := make([]optionHelpEntry, 0, len(schema.Properties)) + for propertyName, property := range schema.Properties { + if property.Hidden { + continue + } + + optionName := "--" + optionNameForProperty(propertyName, property) + entries = append(entries, optionHelpEntry{ + name: optionName, + usage: optionUsage(optionName, property), + description: optionDescription(propertyName, property), + }) + } + + sort.Slice(entries, func(i int, j int) bool { + return entries[i].name < entries[j].name + }) + return entries +} + +func optionUsage(optionName string, property toolProperty) string { + if isBooleanProperty(property) { + return optionName + } + return optionName + " <" + optionValueName(property) + ">" +} + +func optionValueName(property toolProperty) string { + switch strings.ToLower(property.Type) { + case "integer": + return "integer" + case "number": + return "number" + case "array": + return "value[,value]" + case "object": + return "json" + default: + return "value" + } +} + +func optionDescription(propertyName string, property toolProperty) string { + parts := []string{} + if description := optionSummary(propertyName, property); description != "" { + parts = append(parts, description) + } + if propertyDefault := property.EffectiveDefault(); propertyDefault != nil { + parts = append(parts, "default: "+defaultValueText(propertyDefault)) + } + if len(property.Enum) > 0 { + parts = append(parts, "values: "+strings.Join(property.Enum, "|")) + } + return strings.Join(parts, "; ") +} + +func optionSummary(propertyName string, property toolProperty) string { + if isNegatedBooleanProperty(property) { + return "Disable " + pascalToWords(propertyName) + } + return firstHelpLine(property.Description) +} + +func defaultValueText(value any) string { + if boolValue, ok := value.(bool); ok { + if boolValue { + return "enabled" + } + return "disabled" + } + return fmt.Sprint(value) +} + +func pascalToWords(value string) string { + kebabName := pascalToKebab(value) + return strings.ReplaceAll(kebabName, "-", " ") +} + +func nativeCommandDescription(command string) (string, bool) { + for _, entry := range nativeCommands { + if entry.name == command { + return entry.description, true + } + } + return "", false +} + +func nativeCommandUsesProject(command string) bool { + switch command { + case launchCommandName, "list", "sync", "focus-window", "fix", skillsCommandName: + return true + default: + return false + } +} + +func sortedStrings(values []string) []string { + result := append([]string{}, values...) + sort.Strings(result) + return result +} diff --git a/Packages/src/Cli~/internal/cli/completion.go b/Packages/src/Cli~/internal/cli/completion.go index df05ca854..4c4bff9c1 100644 --- a/Packages/src/Cli~/internal/cli/completion.go +++ b/Packages/src/Cli~/internal/cli/completion.go @@ -82,7 +82,7 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write return true, 0 } - if len(args) == 2 && isHelpRequest(args[1:]) { + if containsHelpRequest(args[1:]) { printCompletionHelp(stdout) return true, 0 } @@ -246,10 +246,8 @@ 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) - } + if tool, ok := findDefaultTool(command); ok { + printOptionsForTool(tool, stdout) return } @@ -490,4 +488,8 @@ func isPowerShellShell(shellName string) bool { func printCompletionHelp(stdout io.Writer) { writeLine(stdout, "Usage:") writeLine(stdout, " uloop completion [--shell bash|zsh|powershell|pwsh] [--install]") + writeLine(stdout, "") + writeLine(stdout, "Completion helpers:") + writeLine(stdout, " uloop --list-commands Print command names for completion") + writeLine(stdout, " uloop --list-options Print options for a command") } diff --git a/Packages/src/Cli~/internal/cli/completion_test.go b/Packages/src/Cli~/internal/cli/completion_test.go index 25e5f0c12..fc3de34af 100644 --- a/Packages/src/Cli~/internal/cli/completion_test.go +++ b/Packages/src/Cli~/internal/cli/completion_test.go @@ -57,6 +57,47 @@ func TestCompletionListOptionsUsesToolSchema(t *testing.T) { } } +func TestCompletionListOptionsUsesEmbeddedFirstPartyToolSchema(t *testing.T) { + // Verifies stale project caches do not re-expose removed first-party options. + var stdout bytes.Buffer + cache := toolsCache{ + Tools: []toolDefinition{ + { + Name: "compile", + InputSchema: inputSchema{ + Type: "object", + Properties: map[string]toolProperty{ + "ForceRecompile": {Type: "boolean"}, + "WaitForDomainReload": {Type: "boolean", Default: false}, + }, + }, + }, + }, + } + + handled, code := tryHandleCompletionRequest( + []string{"--list-options", "compile"}, + 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 compile options were not used: %s", output) + } + if strings.Contains(output, "--wait-for-domain-reload") { + t.Fatalf("stale wait option should not be listed: %s", output) + } +} + func TestCompletionListOptionsUsesExecuteDynamicCodeNoWaitFlag(t *testing.T) { // Verifies shell completion exposes the default-on reload wait as a negated flag. var stdout bytes.Buffer @@ -193,6 +234,31 @@ func TestCompletionCommandListOptionsUsesNativeCompletionOptions(t *testing.T) { } } +func TestCompletionHelpDocumentsMachineReadableHelpers(t *testing.T) { + // Verifies completion-specific probes are documented outside the main help surface. + var stdout bytes.Buffer + handled, code := tryHandleCompletionRequest( + []string{completionCommand, "--help"}, + 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() + for _, expected := range []string{"uloop --list-commands", "uloop --list-options "} { + if !strings.Contains(output, expected) { + t.Fatalf("completion help missing %q:\n%s", expected, output) + } + } +} + func TestCompletionListOptionsIgnoresCachedToolSchemaForNativeCommand(t *testing.T) { // Verifies native commands keep priority when a cached Unity tool has the same name. var stdout bytes.Buffer diff --git a/Packages/src/Cli~/internal/cli/error_envelope.go b/Packages/src/Cli~/internal/cli/error_envelope.go index 8c33ce888..2adbda472 100644 --- a/Packages/src/Cli~/internal/cli/error_envelope.go +++ b/Packages/src/Cli~/internal/cli/error_envelope.go @@ -202,7 +202,7 @@ func classifyError(err error, context errorContext) cliError { Command: context.command, NextActions: []string{ "Run the command from inside a Unity project.", - "Pass `--project-path ` before the command.", + "Pass `--project-path ` when targeting another Unity project.", }, } } diff --git a/Packages/src/Cli~/internal/cli/help_test.go b/Packages/src/Cli~/internal/cli/help_test.go index b1ec656ed..52e4eabee 100644 --- a/Packages/src/Cli~/internal/cli/help_test.go +++ b/Packages/src/Cli~/internal/cli/help_test.go @@ -25,7 +25,9 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) { "uloop list", "--project-path ", "uloop --project-path /path/to/project list", - "uloop --list-options ", + "uloop --help", + "Show help for native and Unity tool commands", + "uloop completion --help", } { if !strings.Contains(output, expected) { t.Fatalf("help output missing %q:\n%s", expected, output) @@ -35,6 +37,8 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) { " compile", " get-logs", " run-tests", + "uloop --list-commands", + "uloop --list-options ", } { if strings.Contains(output, unexpected) { t.Fatalf("help output should not include baked-in Unity tool %q:\n%s", unexpected, output) @@ -60,6 +64,7 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. "--project-path ", "uloop --project-path /path/to/project list", "uloop list", + "uloop completion --help", } { if !strings.Contains(output, expected) { t.Fatalf("help output missing %q:\n%s", expected, output) @@ -69,6 +74,8 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. " compile", " get-logs", " run-tests", + "uloop --list-commands", + "uloop --list-options ", } { if strings.Contains(output, unexpected) { t.Fatalf("help output should not include baked-in Unity tool %q:\n%s", unexpected, output) @@ -140,3 +147,211 @@ func TestRunProjectLocalHelpWithProjectPathShowsCachedProjectTools(t *testing.T) } } } + +// Tests that project help keeps cached tool descriptions concise. +func TestRunProjectLocalHelpShowsConciseProjectToolDescriptions(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "long-tool", + "description": "First sentence. Second sentence with operational details that belong in command help.", + "inputSchema": {"type": "object", "properties": {}} + } + ] +}`) + t.Chdir(projectRoot) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, "First sentence.") { + t.Fatalf("help output missing concise summary:\n%s", output) + } + if strings.Contains(output, "Second sentence") { + t.Fatalf("help output should not include long command details:\n%s", output) + } +} + +// Tests that native project commands show the shared project path option. +func TestRunProjectLocalListHelpShowsGlobalOptions(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"list", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("list help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{ + "Usage:", + "uloop list", + "Global options:", + "--project-path ", + } { + if !strings.Contains(output, expected) { + t.Fatalf("list help missing %q:\n%s", expected, output) + } + } +} + +// Tests that launch help documents both positional and global project selection. +func TestRunProjectLocalLaunchHelpShowsGlobalOptions(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"launch", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("launch help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{ + "Usage:", + "uloop launch [options] [project-path]", + "Global options:", + "--project-path ", + } { + if !strings.Contains(output, expected) { + t.Fatalf("launch help missing %q:\n%s", expected, output) + } + } +} + +// Tests that first-party tool help is available without Unity project resolution. +func TestRunProjectLocalCompileHelpDoesNotRequireUnityProject(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"compile", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("compile help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{ + "Usage:", + "uloop compile", + "--force-recompile", + "--no-wait-for-domain-reload", + } { + if !strings.Contains(output, expected) { + t.Fatalf("compile help missing %q:\n%s", expected, output) + } + } + if strings.Contains(output, "--wait-for-domain-reload") { + t.Fatalf("compile help exposed removed wait flag:\n%s", output) + } +} + +// Tests that command help wins even after other tool options. +func TestRunProjectLocalCompileHelpWinsAfterOtherOptions(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"compile", "--force-recompile", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("compile help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{ + "Usage:", + "uloop compile", + "--force-recompile", + } { + if !strings.Contains(output, expected) { + t.Fatalf("compile help missing %q:\n%s", expected, output) + } + } +} + +// Tests that unknown leading options are reported as global option errors. +func TestRunProjectLocalRejectsUnknownGlobalOption(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"--project-pathology"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code mismatch: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "Unknown global option: --project-pathology") { + t.Fatalf("stderr missing unknown option error:\n%s", stderr.String()) + } +} + +// Tests that first-party test help lists options without contacting Unity. +func TestRunProjectLocalRunTestsHelpDoesNotRequireUnityProject(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"run-tests", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("run-tests help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{ + "Usage:", + "uloop run-tests", + "--test-mode", + "--filter-type", + "--filter-value", + "--save-before-run", + } { + if !strings.Contains(output, expected) { + t.Fatalf("run-tests help missing %q:\n%s", expected, output) + } + } +} + +// Tests that update help is available before installer execution. +func TestRunProjectLocalUpdateHelpDoesNotExecuteInstaller(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"update", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("update help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"Usage:", "uloop update", "--to-version "} { + if !strings.Contains(output, expected) { + t.Fatalf("update help missing %q:\n%s", expected, output) + } + } +} + +// Tests that skills subcommand help is available before project resolution. +func TestRunProjectLocalSkillsSubcommandHelpDoesNotRequireUnityProject(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"skills", "install", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("skills install help failed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"Usage:", "uloop skills install", "--claude", "--codex"} { + if !strings.Contains(output, expected) { + t.Fatalf("skills install help missing %q:\n%s", expected, output) + } + } +} diff --git a/Packages/src/Cli~/internal/cli/launch.go b/Packages/src/Cli~/internal/cli/launch.go index 278c5a075..9eb3a1c4c 100644 --- a/Packages/src/Cli~/internal/cli/launch.go +++ b/Packages/src/Cli~/internal/cli/launch.go @@ -48,7 +48,7 @@ func tryHandleLaunchRequest( if len(args) == 0 || args[0] != launchCommandName { return false, 0 } - if len(args) == 2 && isHelpRequest(args[1:]) { + if containsHelpRequest(args[1:]) { printLaunchHelp(stdout) return true, 0 } @@ -418,4 +418,6 @@ func printLaunchHelp(stdout io.Writer) { writeLine(stdout, " -d, --delete-recovery Delete Assets/_Recovery before launch") writeLine(stdout, " -p, --platform Pass Unity -buildTarget when launching") writeLine(stdout, " --max-depth Accepted for compatibility when searching from the current directory") + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) } diff --git a/Packages/src/Cli~/internal/cli/run.go b/Packages/src/Cli~/internal/cli/run.go index e18431c1b..6f54a69e8 100644 --- a/Packages/src/Cli~/internal/cli/run.go +++ b/Packages/src/Cli~/internal/cli/run.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/hatayama/unity-cli-loop/Packages/src/Cli/internal/project" @@ -44,6 +45,14 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return code } } + if isUnknownLeadingOption(command) { + writeClassifiedError(stderr, &argumentError{ + message: "Unknown global option: " + command, + option: command, + nextActions: []string{"Run `uloop --help` to inspect supported global options."}, + }, errorContext{}) + return 1 + } if handled, code := tryHandleUpdateRequest(ctx, remainingArgs, stdout, stderr); handled { return code } @@ -56,6 +65,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder if handled, code := tryHandleSkillsRequest(remainingArgs, startPath, projectPath, stdout, stderr); handled { return code } + if containsHelpRequest(commandArgs) { + if handled, code := tryHandleCommandHelp(command, startPath, projectPath, stdout, stderr); handled { + return code + } + } connection, err := project.ResolveConnection(startPath, projectPath) if err != nil { @@ -102,11 +116,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder } if nestedProjectPath != "" && nestedProjectPath != connection.ProjectRoot { writeErrorEnvelope(stderr, (&argumentError{ - message: "--project-path must be passed before the command in the native CLI", + message: "--project-path must target the same Unity project for this command", option: "--project-path", expectedType: "path", command: command, - nextActions: []string{"Move `--project-path ` before the command name."}, + nextActions: []string{"Use one `--project-path ` value for the target Unity project."}, }).toCLIError(errorContext{projectRoot: connection.ProjectRoot, command: command})) return 1 } @@ -123,6 +137,10 @@ func shouldWaitForServerReadinessBeforeCommand(command string) bool { } } +func isUnknownLeadingOption(command string) bool { + return strings.HasPrefix(command, "-") +} + func runTool(ctx context.Context, connection unityipc.Connection, command string, params map[string]any, stdout io.Writer, stderr io.Writer) int { if shouldWaitForCompileDomainReload(command, params) { return runCompileWithDomainReloadWait(ctx, connection, params, stdout, stderr) diff --git a/Packages/src/Cli~/internal/cli/run_help.go b/Packages/src/Cli~/internal/cli/run_help.go index 569ed1c21..8c57acbc6 100644 --- a/Packages/src/Cli~/internal/cli/run_help.go +++ b/Packages/src/Cli~/internal/cli/run_help.go @@ -8,7 +8,10 @@ import ( "github.com/hatayama/unity-cli-loop/Packages/src/Cli/internal/project" ) -const nativeCLIDescription = "Native CLI. Runs uloop commands and dispatches live Unity tool commands." +const ( + nativeCLIDescription = "Native CLI. Runs uloop commands and dispatches live Unity tool commands." + maxCommandListDescriptionLength = 96 +) func isVersionRequest(args []string) bool { return len(args) == 1 && (args[0] == "--version" || args[0] == "-v") @@ -18,6 +21,15 @@ func isHelpRequest(args []string) bool { return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") } +func containsHelpRequest(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + return false +} + func printHelp(stdout io.Writer) { printMainHelp( stdout, @@ -67,9 +79,8 @@ func printMainHelp(stdout io.Writer, description string, cache toolsCache, hasPr writeLine(stdout, "More:") writeLine(stdout, " uloop list Show the live Unity tool list") writeLine(stdout, " uloop --project-path /path/to/project list Show tools for another Unity project") - writeLine(stdout, " uloop --help Show help for native commands that support it") - writeLine(stdout, " uloop --list-commands Print command names for completion") - writeLine(stdout, " uloop --list-options Print options for a Unity tool command") + writeLine(stdout, " uloop --help Show help for native and Unity tool commands") + writeLine(stdout, " uloop completion --help Show shell completion setup and helpers") } func printNativeCommandHelp(stdout io.Writer) { @@ -102,7 +113,7 @@ func printUnityToolCommandHelp(stdout io.Writer, cache toolsCache, hasProjectToo if isNativeCommandName(tool.Name) { continue } - writeFormat(stdout, " %-22s %s\n", tool.Name, firstHelpLine(tool.Description)) + writeFormat(stdout, " %-22s %s\n", tool.Name, commandListDescription(tool.Description)) } writeLine(stdout, " Run `uloop sync` after the Editor tool set changes to refresh this list.") } @@ -126,6 +137,21 @@ func firstHelpLine(description string) string { return "" } +func commandListDescription(description string) string { + line := firstHelpLine(description) + for index, value := range line { + if value == '.' || value == '!' || value == '?' { + return strings.TrimSpace(line[:index+len(string(value))]) + } + } + + runes := []rune(line) + if len(runes) <= maxCommandListDescriptionLength { + return line + } + return strings.TrimSpace(string(runes[:maxCommandListDescriptionLength-3])) + "..." +} + func loadCompletionTools(startPath string, projectPath string) toolsCache { connection, err := project.ResolveConnection(startPath, projectPath) if err != nil { diff --git a/Packages/src/Cli~/internal/cli/skills.go b/Packages/src/Cli~/internal/cli/skills.go index a7b87480d..d5834b3c4 100644 --- a/Packages/src/Cli~/internal/cli/skills.go +++ b/Packages/src/Cli~/internal/cli/skills.go @@ -108,6 +108,10 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s writeErrorEnvelope(stderr, unknownSkillsSubcommandError(subcommand, errorContext{command: skillsCommandName})) return true, 1 } + if containsHelpRequest(args[2:]) { + printSkillsSubcommandHelp(subcommand, stdout) + return true, 0 + } options, err := parseSkillsOptions(args[2:]) if err != nil { writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) diff --git a/Packages/src/Cli~/internal/cli/skills_display.go b/Packages/src/Cli~/internal/cli/skills_display.go index e13f0192c..61c80ece5 100644 --- a/Packages/src/Cli~/internal/cli/skills_display.go +++ b/Packages/src/Cli~/internal/cli/skills_display.go @@ -56,6 +56,26 @@ func printSkillsHelp(stdout io.Writer) { writeLine(stdout, " uloop skills list [options]") writeLine(stdout, " uloop skills install [options]") writeLine(stdout, " uloop skills uninstall [options]") + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) +} + +func printSkillsSubcommandHelp(command string, stdout io.Writer) { + writeLine(stdout, "Usage:") + writeFormat(stdout, " uloop skills %s [options]\n", command) + writeLine(stdout, "") + writeLine(stdout, "Options:") + writeLine(stdout, " -g, --global") + writeLine(stdout, " --flat") + writeLine(stdout, " --claude") + writeLine(stdout, " --codex") + writeLine(stdout, " --cursor") + writeLine(stdout, " --gemini") + writeLine(stdout, " --agents") + writeLine(stdout, " --windsurf") + writeLine(stdout, " --antigravity") + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) } func printSkillsTargetGuidance(command string, stdout io.Writer) { diff --git a/Packages/src/Cli~/internal/cli/tools.go b/Packages/src/Cli~/internal/cli/tools.go index d64cc6d88..388053e95 100644 --- a/Packages/src/Cli~/internal/cli/tools.go +++ b/Packages/src/Cli~/internal/cli/tools.go @@ -41,6 +41,10 @@ func findTool(cache toolsCache, name string) (toolDefinition, bool) { return tools.Find(cache, name) } +func findDefaultTool(name string) (toolDefinition, bool) { + return findTool(loadDefaultTools(), name) +} + func findToolForCommand(projectRoot string, command string) (toolDefinition, toolsCache, bool, error) { return findToolForCommandWithInternalToolNames(projectRoot, command, collectInternalSkillToolNames) } @@ -50,8 +54,9 @@ func findToolForCommandWithInternalToolNames( command string, collectInternalToolNames func(string) map[string]bool, ) (toolDefinition, toolsCache, bool, error) { - if command == executeDynamicCodeCommandName { - return tools.FindForCommand(projectRoot, command, nil) + defaultCache := loadDefaultTools() + if tool, ok := findTool(defaultCache, command); ok { + return tool, defaultCache, true, nil } return tools.FindForCommand(projectRoot, command, collectInternalToolNames(projectRoot)) @@ -174,7 +179,7 @@ func parseGlobalProjectPath(args []string) ([]string, string, error) { for index := 0; index < len(args); index++ { arg := args[index] - if !strings.HasPrefix(arg, "--"+projectPathFlagName) { + if arg != "--"+projectPathFlagName && !strings.HasPrefix(arg, "--"+projectPathFlagName+"=") { remaining = append(remaining, arg) continue } diff --git a/Packages/src/Cli~/internal/cli/tools_test.go b/Packages/src/Cli~/internal/cli/tools_test.go index 90558f77b..a2b832089 100644 --- a/Packages/src/Cli~/internal/cli/tools_test.go +++ b/Packages/src/Cli~/internal/cli/tools_test.go @@ -85,6 +85,19 @@ func TestBuildToolParamsConvertsExecuteDynamicCodeNoWaitFlag(t *testing.T) { } } +func TestBuildToolParamsRejectsCompileWaitForDomainReloadFlag(t *testing.T) { + // Verifies the removed positive domain-reload wait flag is not accepted by the public CLI parser. + tool, ok := findTool(loadDefaultTools(), compileCommandName) + if !ok { + t.Fatal("compile was not found in default tools") + } + + _, _, err := buildToolParams([]string{"--wait-for-domain-reload"}, tool) + if err == nil { + t.Fatal("expected removed wait flag to be rejected") + } +} + // Tests that hidden execute-dynamic-code options remain available for internal callers. func TestBuildToolParamsAcceptsHiddenExecuteDynamicCodeCompileOnlyFlag(t *testing.T) { tool, ok := findTool(loadDefaultTools(), executeDynamicCodeCommandName) @@ -438,6 +451,27 @@ func TestParseGlobalProjectPathAcceptsLeadingOption(t *testing.T) { } } +// Tests that similarly prefixed option names are not consumed as --project-path. +func TestParseGlobalProjectPathRequiresExactFlagName(t *testing.T) { + remaining, projectPath, err := parseGlobalProjectPath([]string{"--project-pathology"}) + if err != nil { + t.Fatalf("parseGlobalProjectPath failed: %v", err) + } + + if projectPath != "" { + t.Fatalf("project path should be empty, got %q", projectPath) + } + expected := []string{"--project-pathology"} + if len(remaining) != len(expected) { + t.Fatalf("remaining length mismatch: %#v", remaining) + } + for index, value := range expected { + if remaining[index] != value { + t.Fatalf("remaining mismatch: %#v", remaining) + } + } +} + func writeToolCache(t *testing.T, projectRoot string, content string) { t.Helper() cachePath := filepath.Join(projectRoot, cacheDirectoryName, cacheFileName) diff --git a/Packages/src/Cli~/internal/cli/uninstall.go b/Packages/src/Cli~/internal/cli/uninstall.go index 694d8b11c..d16b63d64 100644 --- a/Packages/src/Cli~/internal/cli/uninstall.go +++ b/Packages/src/Cli~/internal/cli/uninstall.go @@ -23,7 +23,7 @@ func tryHandleUninstallRequest(ctx context.Context, args []string, stdout io.Wri if len(args) == 0 || args[0] != uninstallCommandName { return false, 0 } - if len(args) == 2 && isHelpRequest(args[1:]) { + if containsHelpRequest(args[1:]) { printUninstallHelp(stdout) return true, 0 } diff --git a/Packages/src/Cli~/internal/cli/update.go b/Packages/src/Cli~/internal/cli/update.go index d52516585..9458d8ecb 100644 --- a/Packages/src/Cli~/internal/cli/update.go +++ b/Packages/src/Cli~/internal/cli/update.go @@ -24,6 +24,10 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer if len(args) == 0 || args[0] != updateCommandName { return false, 0 } + if containsHelpRequest(args[1:]) { + printUpdateHelp(stdout) + return true, 0 + } options, err := parseUpdateOptions(args[1:]) if err != nil { writeClassifiedError(stderr, err, errorContext{command: updateCommandName}) @@ -77,6 +81,11 @@ func updateCommandForOSWithOptions(goos string, options updateOptions) (string, return command.Name, command.Args, nil } +func printUpdateHelp(stdout io.Writer) { + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop update [--to-version ]") +} + func parseUpdateOptions(args []string) (updateOptions, error) { options := updateOptions{} for index := 0; index < len(args); index++ { diff --git a/README.md b/README.md index b46c45074..3f4ee5542 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Dedicated tools exist only for operations that dynamic code execution cannot han ### 1. compile - Execute Compilation Performs AssetDatabase.Refresh() and then compiles, returning the results after Domain Reload completes. Can detect errors and warnings that built-in linters cannot find. You can choose between incremental compilation and forced full compilation. -Use `WaitForDomainReload=false` only when you need the fire-and-forget path. +Use `--no-wait-for-domain-reload` only when you need the fire-and-forget path. ```text → Execute compile, analyze error and warning content → Automatically fix relevant files diff --git a/README_ja.md b/README_ja.md index 15feb07ee..afd4902e9 100644 --- a/README_ja.md +++ b/README_ja.md @@ -268,7 +268,7 @@ Unity CLI Loop はツールの数を追い求めません。C#コードの動的 ### 1. compile - コンパイルの実行 AssetDatabase.Refresh()をした後、Domain Reload完了まで待ってコンパイル結果を返却します。内蔵のLinterでは発見できないエラー・警告を見つける事ができます。 差分コンパイルと強制全体コンパイルを選択できます。 -即時に戻したい場合だけ `WaitForDomainReload=false` を指定します。 +即時に戻したい場合だけ `--no-wait-for-domain-reload` を指定します。 ```text → compile実行、エラー・警告内容を解析 → 該当ファイルを自動修正