package main import ( "errors" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "strings" "github.com/chzyer/readline" "github.com/google/shlex" ) // ErrNoPath is returned when 'cd' was called without a second argument. var ErrNoPath = errors.New("path required") // previousDir tracks the previous working directory for 'cd -' support var previousDir string // getHomeDir returns the user's home directory func getHomeDir() string { usr, err := user.Current() if err != nil { // Fallback to HOME environment variable return os.Getenv("HOME") } return usr.HomeDir } // expandTilde expands ~ to the home directory and ~user to user's home func expandTilde(path string) string { if len(path) == 0 { return path } // Handle ~ at the start if strings.HasPrefix(path, "~") { home := getHomeDir() if home == "" { return path // Can't expand, return as-is } // Handle ~ (just home) if len(path) == 1 { return home } // Handle ~/path or ~user/path // For now, just handle ~/path if path[1] == '/' { return filepath.Join(home, path[2:]) } } return path } // expandEnv expands environment variables in a string // Supports $VAR, ${VAR}, and ${VAR:-default} syntax func expandEnv(s string) string { // Use os.ExpandEnv for simple ${VAR} and $VAR expansion return os.ExpandEnv(s) } // getPrompt returns a dynamic prompt string like "user@host:dir $" func getPrompt() string { // Get current working directory wd, err := os.Getwd() if err != nil { wd = "?" } // Get username usr, err := user.Current() username := "?" if err == nil && usr.Username != "" { username = usr.Username } // Get hostname hostname, err := os.Hostname() if err != nil { hostname = "unknown" } // Shorten the path for display // Use only the last component if in home directory home := getHomeDir() if home != "" && strings.HasPrefix(wd, home) { // Replace home with ~ displayPath := "~" + strings.TrimPrefix(wd, home) return fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath) } return fmt.Sprintf("%s@%s:%s $ ", username, hostname, wd) } // shellCompleter implements readline.AutoCompleter for contextual completion type shellCompleter struct { commands []string } func newShellCompleter() *shellCompleter { return &shellCompleter{ commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"}, } } // Do implements readline.AutoCompleter interface func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { str := string(line[:pos]) // Find the start of the current word being completed lastSpace := strings.LastIndex(str, " ") var prefixStart int var prefix string var isFirstWord bool if lastSpace == -1 { // No space found - could be first word (command) or a path without space like ls/file prefix = str prefixStart = 0 // Check if it contains a slash - if so, it's a path, not a command if strings.Contains(prefix, "/") || strings.HasPrefix(prefix, "~") { isFirstWord = false } else { isFirstWord = true } } else { prefix = str[lastSpace+1:] prefixStart = lastSpace + 1 isFirstWord = false } // Remove surrounding quotes from the prefix for matching unquotedPrefix := strings.Trim(prefix, `"'`) if isFirstWord { // Complete commands var candidates [][]rune for _, cmd := range c.commands { if strings.HasPrefix(cmd, unquotedPrefix) { candidates = append(candidates, []rune(cmd)) } } return candidates, prefixStart } // Complete file paths matches := completeFilePath(str, unquotedPrefix) if len(matches) == 0 { return nil, prefixStart } // Build candidates: for each match, construct the full replacement candidates := make([][]rune, len(matches)) for i, m := range matches { var fullPath string if strings.HasSuffix(prefix, "/") { // If prefix ends with /, just append the match fullPath = prefix + m } else if strings.HasPrefix(prefix, "/") { // Absolute path: /usr/lo + local/ -> /usr/local/ fullPath = filepath.Join(filepath.Dir(prefix), m) } else if strings.HasPrefix(prefix, "~") { // Tilde path: ~/Doc + uments/ -> ~/Documents/ fullPath = filepath.Join(prefix, m) } else { // Relative path fullPath = prefix + m } candidates[i] = []rune(fullPath) } return candidates, prefixStart } // completeFilePath handles file path completion // This is exported for testing purposes func completeFilePath(line, prefix string) []string { // Get the current working directory wd, err := os.Getwd() if err != nil { return nil } // Determine the directory to search and the pattern to match var dir string var pattern string // Check if prefix ends with / - if so, we're completing inside that directory endsWithSlash := strings.HasSuffix(prefix, "/") // Handle absolute paths if strings.HasPrefix(prefix, "/") { if endsWithSlash { // prefix is like "/path/to/dir/" - complete inside dir dir = prefix pattern = "" } else { // prefix is like "/path/to/dir" or "/path/to/file_" dir = filepath.Dir(prefix) pattern = filepath.Base(prefix) } } else if strings.HasPrefix(prefix, "~") { // Handle tilde expansion home := getHomeDir() if home == "" { return nil } // Expand the tilde path fullPrefix := expandTilde(prefix) if endsWithSlash { dir = fullPrefix pattern = "" } else { dir = filepath.Dir(fullPrefix) pattern = filepath.Base(prefix) } } else { // Relative path - join with current directory if endsWithSlash { // prefix is like "subdir/" - complete inside subdir fullPrefix := filepath.Join(wd, prefix) dir = fullPrefix pattern = "" } else { fullPrefix := filepath.Join(wd, prefix) dir = filepath.Dir(fullPrefix) pattern = filepath.Base(prefix) } } // Read the directory contents entries, err := os.ReadDir(dir) if err != nil { return nil } // Collect matching entries var matches []string for _, entry := range entries { name := entry.Name() if pattern == "" || strings.HasPrefix(name, pattern) { // Determine if this is a directory var suffix string if entry.IsDir() { suffix = "/" } // Build the completion - return only the part to be appended // For paths with trailing slash, return just the matched name // For partial paths without trailing slash, return the full matching path part var completion string if endsWithSlash { // Prefix ends with /, so just append the matched name + suffix completion = name + suffix } else if strings.HasPrefix(prefix, "/") { // Absolute path without trailing slash - return remaining part completion = name + suffix } else if strings.HasPrefix(prefix, "~") { // Tilde path - return remaining part completion = name + suffix } else { // Relative path completion = name + suffix } matches = append(matches, completion) } } return matches } func main() { // Create readline instance l, err := readline.NewEx(&readline.Config{ Prompt: getPrompt(), HistoryFile: "./gosh_history", AutoComplete: newShellCompleter(), InterruptPrompt: "^C", EOFPrompt: "exit", }) if err != nil { fmt.Fprintln(os.Stderr, "Error initializing readline:", err) os.Exit(1) } defer l.Close() // Main loop for { // Update prompt dynamically (in case directory changed) l.SetPrompt(getPrompt()) line, err := l.Readline() if err != nil { // Handle EOF (Ctrl+D) if err == io.EOF || err == readline.ErrInterrupt { fmt.Println() break } fmt.Fprintln(os.Stderr, "Error reading input:", err) continue } // Skip empty input if strings.TrimSpace(line) == "" { continue } // Add to history l.SaveHistory(line) // Execute the input if err := execInput(line); err != nil { fmt.Fprintln(os.Stderr, err) } } } func execInput(input string) error { // Split the input separate the command and the arguments. args, err := shlex.Split(input) if err != nil { return fmt.Errorf("invalid input: %w", err) } if len(args) == 0 { return nil } // Expand environment variables in all arguments for i, arg := range args { args[i] = expandEnv(arg) } // Expand tilde in all arguments for i, arg := range args { args[i] = expandTilde(arg) } // Check for built-in commands. switch args[0] { case "cd": // Save current directory before changing (for 'cd -') currentDir, _ := os.Getwd() // Handle different cd cases if len(args) < 2 { // cd with no args: go to home directory previousDir = currentDir return os.Chdir(getHomeDir()) } path := args[1] // Handle 'cd -' : go to previous directory if path == "-" { if previousDir == "" { return fmt.Errorf("no previous directory") } // Save current directory as the new previous // so that next 'cd -' will come back here prevDir := previousDir previousDir = currentDir return os.Chdir(prevDir) } // Expand tilde in the path path = expandTilde(path) // Save current directory as previous before changing previousDir = currentDir // Change the directory and return the error. return os.Chdir(path) case "pwd": wd, err := os.Getwd() fmt.Println(wd) return err case "exit": // exit with optional status code if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) os.Exit(exitCode) } os.Exit(0) case "quit": // quit with optional status code if len(args) > 1 { var exitCode int fmt.Sscanf(args[1], "%d", &exitCode) os.Exit(exitCode) } os.Exit(0) case "set": // set VAR=value - set environment variable if len(args) < 2 { return fmt.Errorf("usage: set VAR=value") } // Parse VAR=value parts := strings.SplitN(args[1], "=", 2) if len(parts) != 2 { return fmt.Errorf("usage: set VAR=value") } os.Setenv(parts[0], parts[1]) return nil case "export": // export VAR=value - same as set but also marks for child processes if len(args) < 2 { return fmt.Errorf("usage: export VAR=value") } // Parse VAR=value parts := strings.SplitN(args[1], "=", 2) if len(parts) != 2 { return fmt.Errorf("usage: export VAR=value") } os.Setenv(parts[0], parts[1]) return nil case "help": fmt.Println(`gosh - Go Shell Built-in commands: cd [dir] - Change directory (cd with no args goes to home) pwd - Print working directory echo [args] - Echo arguments exit [code] - Exit shell with optional status code quit [code] - Quit shell with optional status code set VAR=val - Set environment variable export VAR=val - Export environment variable help - Show this help Features: - Argument parsing with quotes and escapes - Ctrl+D to exit, Ctrl+C to cancel - Command history (up/down arrows) - Tab completion for commands and file paths - Tilde expansion (~, ~/path) - Environment variable expansion ($VAR, ${VAR})`) return nil } // Prepare the command to execute. cmd := exec.Command(args[0], args[1:]...) // Set the correct output device. cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout // Execute the command return the error. return cmd.Run() }