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
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.
207 changes: 207 additions & 0 deletions Packages/src/Cli~/internal/cli/command_help.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 7 additions & 5 deletions Packages/src/Cli~/internal/cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 <command> Print options for a command")
}
66 changes: 66 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,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
Expand Down Expand Up @@ -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 <command>"} {
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
Expand Down
2 changes: 1 addition & 1 deletion Packages/src/Cli~/internal/cli/error_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` before the command.",
"Pass `--project-path <path>` when targeting another Unity project.",
},
}
}
Expand Down
Loading
Loading