package main import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "syscall" "unsafe" ) const ( appVersion = 0.01 maxInputLen = 16 maxConfigLineLen = 4096 ) // appConfig holds values read from the application config file. type appConfig struct { shell string // shell to use when running commands, e.g. /bin/bash menuPath string // explicit path to the menu config } // loadKV reads a simple key=value file, skipping blank lines and # comments. func loadKV(path string) (map[string]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() result := map[string]string{} scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.IndexByte(line, '=') if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) if key != "" { result[key] = val } } return result, scanner.Err() } // resolveAppConfig loads the application config using the cascade: // 1. $CASCADE_CONF // 2. $HOME/.cascade/config.ini // 3. /opt/cascade/config.ini // 4. built-in defaults (shell=/bin/sh, no menu) func resolveAppConfig() appConfig { cfg := appConfig{shell: "/bin/sh"} var candidates []string if v := os.Getenv("CASCADE_CONF"); v != "" { candidates = append(candidates, v) } if home, err := os.UserHomeDir(); err == nil { candidates = append(candidates, filepath.Join(home, ".cascade", "config.ini")) } candidates = append(candidates, "/opt/cascade/config.ini") for _, path := range candidates { kv, err := loadKV(path) if err != nil { continue // not found or unreadable — try next } if v := kv["shell"]; v != "" { cfg.shell = v } if v := kv["menu"]; v != "" { cfg.menuPath = v } break } return cfg } // resolveMenuPath finds the menu file using the cascade: // 1. $CASCADE_MENU // 2. menu= value from appConfig // 3. $HOME/.cascade/menu.ini // 4. /opt/cascade/menu.ini // 5. error func resolveMenuPath(cfg appConfig) (string, error) { if v := os.Getenv("CASCADE_MENU"); v != "" { return v, nil } if cfg.menuPath != "" { return cfg.menuPath, nil } if home, err := os.UserHomeDir(); err == nil { p := filepath.Join(home, ".cascade", "menu.ini") if _, err := os.Stat(p); err == nil { return p, nil } } if _, err := os.Stat("/opt/cascade/menu.ini"); err == nil { return "/opt/cascade/menu.ini", nil } return "", fmt.Errorf( "menu config not found: set CASCADE_MENU env var, add 'menu=' to app config, " + "or place menu.ini in ~/.cascade/ or /opt/cascade/", ) } // Node represents one level in the menu tree. type Node struct { children map[string]*Node command string } func newNode() *Node { return &Node{children: make(map[string]*Node)} } // loadTree reads the menu file and builds a tree from dot-separated keys. func loadTree(path string) (*Node, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("cannot open menu file %s: %w", path, err) } defer f.Close() root := newNode() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.IndexByte(line, '=') if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) if key == "" || val == "" { continue } parts := strings.Split(key, ".") node := root for i, part := range parts { if _, ok := node.children[part]; !ok { node.children[part] = newNode() } if i == len(parts)-1 { node.children[part].command = val } else { node = node.children[part] } } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading menu: %w", err) } return root, nil } // selfKey is a synthetic sentinel used when a node has both its own command and children. const selfKey = "\x00" func sortedKeys(node *Node) []string { keys := make([]string, 0, len(node.children)) for k := range node.children { keys = append(keys, k) } sort.Strings(keys) if node.command != "" && len(keys) > 0 { keys = append([]string{selfKey}, keys...) } return keys } func navigateTo(root *Node, pathKeys []string) (*Node, []string) { current := root for _, key := range pathKeys { child, ok := current.children[key] if !ok { return root, pathKeys[:0] } current = child } return current, pathKeys } func printMenu(keys []string, pathKeys []string, cursor int, numBuf string) { fmt.Print("\033[H\033[2J") if len(pathKeys) > 0 { fmt.Printf("[ %s ]\n", strings.Join(pathKeys, " > ")) } else { fmt.Printf("[ Cascade v%.2f ]\n", appVersion) } fmt.Printf("\nSelect Option:\n\n") for i, k := range keys { var label string if k == selfKey { label = "(run directly)" } else { label = k } if i == cursor { fmt.Printf(" %d) %s <-\n", i+1, label) } else { fmt.Printf(" %d) %s\n", i+1, label) } } fmt.Println() exitLabel := "Exit" if len(pathKeys) > 0 { exitLabel = "Back" } if cursor == len(keys) { fmt.Printf(" 0) %s <-\n", exitLabel) } else { fmt.Printf(" 0) %s\n", exitLabel) } fmt.Print("\nEnter number: ") if numBuf != "" { fmt.Print(numBuf) } } func isDigitsOnly(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } func runCommand(shell, command string) error { cmd := exec.Command(shell, "-c", command) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // makeRaw switches fd to raw mode (no line buffering, no echo) and returns // the previous terminal state so it can be restored later. func makeRaw(fd int) (syscall.Termios, error) { var old syscall.Termios if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&old))); errno != 0 { return old, errno } raw := old raw.Lflag &^= syscall.ICANON | syscall.ECHO raw.Cc[syscall.VMIN] = 1 raw.Cc[syscall.VTIME] = 0 if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&raw))); errno != 0 { return old, errno } return old, nil } // restoreTerminal restores fd to the given terminal state. func restoreTerminal(fd int, old syscall.Termios) { syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&old))) } // Key kind constants used by readKey. const ( keyUp = iota keyDown keyEnter keyBackspace keyDigit keyOther ) // keyEvent represents a single logical key press. type keyEvent struct { kind int digit byte // valid when kind == keyDigit } // readKey reads one logical key press from r. // It recognises Up/Down arrow escape sequences, Enter, Backspace/Delete, // and digit characters ('0'–'9'). Everything else returns keyOther. func readKey(r *bufio.Reader) keyEvent { b, err := r.ReadByte() if err != nil { return keyEvent{kind: keyOther} } switch b { case '\r', '\n': return keyEvent{kind: keyEnter} case 127, 8: // DEL or BS return keyEvent{kind: keyBackspace} case 0x1B: // start of CSI escape sequence b2, err := r.ReadByte() if err != nil || b2 != '[' { return keyEvent{kind: keyOther} } b3, err := r.ReadByte() if err != nil { return keyEvent{kind: keyOther} } switch b3 { case 'A': return keyEvent{kind: keyUp} case 'B': return keyEvent{kind: keyDown} } return keyEvent{kind: keyOther} default: if b >= '0' && b <= '9' { return keyEvent{kind: keyDigit, digit: b} } return keyEvent{kind: keyOther} } } func main() { reader := bufio.NewReader(os.Stdin) termFd := int(os.Stdin.Fd()) cfg := resolveAppConfig() menuPath, err := resolveMenuPath(cfg) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } var pathKeys []string cursor := 0 numBuf := "" for { root, err := loadTree(menuPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } current, pathKeys2 := navigateTo(root, pathKeys) pathKeys = pathKeys2 keys := sortedKeys(current) if len(keys) == 0 { fmt.Fprintln(os.Stderr, "No variants found in menu file.") os.Exit(1) } if cursor > len(keys) { cursor = len(keys) } printMenu(keys, pathKeys, cursor, numBuf) oldTermios, rawErr := makeRaw(termFd) key := readKey(reader) if rawErr == nil { restoreTerminal(termFd, oldTermios) } switch key.kind { case keyUp: numBuf = "" if cursor > 0 { cursor-- } else { cursor = len(keys) } case keyDown: numBuf = "" if cursor < len(keys) { cursor++ } else { cursor = 0 } case keyDigit: if len(numBuf) < maxInputLen { numBuf += string(key.digit) } if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) { if n == 0 { cursor = len(keys) } else { cursor = n - 1 } } case keyBackspace: if len(numBuf) > 0 { numBuf = numBuf[:len(numBuf)-1] if numBuf != "" { if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) { if n == 0 { cursor = len(keys) } else { cursor = n - 1 } } } } case keyEnter: var n int if numBuf != "" { parsed, err := strconv.Atoi(numBuf) numBuf = "" if err != nil || parsed < 0 || parsed > len(keys) { continue } n = parsed } else { if cursor == len(keys) { n = 0 } else { n = cursor + 1 } } if n == 0 { if len(pathKeys) == 0 { fmt.Print("\033[H\033[2J") fmt.Println("Exiting.") os.Exit(0) } pathKeys = pathKeys[:len(pathKeys)-1] cursor = 0 continue } chosen := keys[n-1] cursor = n - 1 if chosen == selfKey { fmt.Print("\033[H\033[2J") if err := runCommand(cfg.shell, current.command); err != nil { fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err) } continue } child := current.children[chosen] if len(child.children) > 0 { pathKeys = append(pathKeys, chosen) cursor = 0 continue } fmt.Print("\033[H\033[2J") if err := runCommand(cfg.shell, child.command); err != nil { fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err) } } } }