feat(dispatch): launch interactive menu on no-arg TTY invocation

This commit is contained in:
2026-06-16 08:32:27 +02:00
parent 011a41e90a
commit 2d5d4ffe2e
2 changed files with 66 additions and 2 deletions
+47 -2
View File
@@ -17,6 +17,7 @@ import (
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/app" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/app"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu"
) )
// Exit codes follow the sysexits(3) convention so CI can distinguish // Exit codes follow the sysexits(3) convention so CI can distinguish
@@ -103,8 +104,52 @@ func find(name string) (Builder, bool) {
return Builder{}, false return Builder{}, false
} }
// Main is the `crucible` dispatcher entrypoint. // Main is the `crucible` dispatcher entrypoint. With no arguments on an
func Main(args []string) int { return run(args, os.Stdout, os.Stderr) } // interactive terminal it launches the menu; otherwise (pipes, CI) it prints
// usage, preserving scriptable behavior.
func Main(args []string) int {
if len(args) == 0 && interactive(os.Stdout) {
return runMenu(os.Stdout, os.Stderr, os.Stdin)
}
return run(args, os.Stdout, os.Stderr)
}
func interactive(f *os.File) bool {
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func menuItems() []menu.Item {
var items []menu.Item
for _, b := range Registry {
if !b.Wired {
continue
}
for _, sub := range b.subcommands() {
items = append(items, menu.Item{
Label: b.Name + " " + sub,
Desc: b.Summary,
Args: []string{b.Name, sub},
})
}
}
return items
}
func runMenu(out, errw io.Writer, in io.Reader) int {
chosen, err := menu.Select(out, in, menuItems())
if err != nil {
fmt.Fprintln(errw, "crucible:", err)
return exitUsage
}
if chosen == nil {
return exitOK
}
return run(chosen, out, errw)
}
// RunBuilder is the standalone `crucible-<name>` entrypoint. // RunBuilder is the standalone `crucible-<name>` entrypoint.
func RunBuilder(name string, args []string) int { func RunBuilder(name string, args []string) int {
+19
View File
@@ -135,3 +135,22 @@ func TestLegacyCommandsHaveExactlyOneHome(t *testing.T) {
} }
} }
} }
func TestMenuItemsSkipsUnwiredIncludesWired(t *testing.T) {
items := menuItems()
if len(items) == 0 {
t.Fatal("expected menu items")
}
sawModuleBuild := false
for _, it := range items {
if it.Args[0] == "depot" {
t.Errorf("unwired builder %q must not appear in the menu", it.Args[0])
}
if len(it.Args) == 2 && it.Args[0] == "module" && it.Args[1] == "build" {
sawModuleBuild = true
}
}
if !sawModuleBuild {
t.Error("expected 'module build' in the menu")
}
}