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. // readline expects the candidates to contain only the text that still has to // be inserted after the cursor, and the returned length to be the number of // runes of the current word that are already typed. func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { word, start, quote := currentWord(string(line[:pos])) isFirstWord := strings.TrimSpace(string(line[:start])) == "" // Plain "~" -> "~/" if word == "~" { return [][]rune{[]rune("/")}, 1 } if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") { // Complete commands var candidates [][]rune seen := make(map[string]bool) for _, cmd := range append(c.commands, pathExecutables(word)...) { if strings.HasPrefix(cmd, word) && !seen[cmd] { seen[cmd] = true candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" ")) } } return candidates, len([]rune(word)) } // Complete file paths; only the part after the last slash is completed pattern := word[strings.LastIndex(word, "/")+1:] matches := completeFilePath(string(line[:pos]), word) var candidates [][]rune for _, m := range matches { rest := m[len(pattern):] if quote == 0 { rest = escapeWord(rest) } candidates = append(candidates, []rune(rest)) } return candidates, len([]rune(pattern)) } // pathExecutables returns the names of all executables in $PATH starting // with prefix func pathExecutables(prefix string) []string { var names []string for _, dir := range filepath.SplitList(os.Getenv("PATH")) { if dir == "" { dir = "." } entries, err := os.ReadDir(dir) if err != nil { continue } for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, prefix) { continue } // Follow symlinks, only regular files with an executable bit count info, err := os.Stat(filepath.Join(dir, name)) if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { continue } names = append(names, name) } } return names } // currentWord returns the (unescaped, unquoted) word the cursor is in, the // byte offset where it starts in s and the quote character that is still open // (0 if none). func currentWord(s string) (word string, start int, quote byte) { var b strings.Builder escaped := false for i := 0; i < len(s); i++ { ch := s[i] switch { case escaped: b.WriteByte(ch) escaped = false case ch == '\\' && quote != '\'': escaped = true case quote != 0: if ch == quote { quote = 0 } else { b.WriteByte(ch) } case ch == '"' || ch == '\'': quote = ch case ch == ' ' || ch == '\t': b.Reset() start = i + 1 default: b.WriteByte(ch) } } return b.String(), start, quote } // escapeWord escapes characters that would otherwise be interpreted by shlex func escapeWord(s string) string { var b strings.Builder for _, r := range s { if strings.ContainsRune(" \t\\'\"", r) { b.WriteByte('\\') } b.WriteRune(r) } return b.String() } // completeFilePath handles file path completion. // prefix is the unquoted path typed so far. It returns the names (with a // trailing "/" for directories) of all entries in the directory part of // prefix that start with the part after the last slash. func completeFilePath(line, prefix string) []string { // Split textually so that "." or "~" are not normalized away slash := strings.LastIndex(prefix, "/") dir, pattern := prefix[:slash+1], prefix[slash+1:] switch { case dir == "": dir = "." case strings.HasPrefix(dir, "~"): dir = expandTilde(dir) } // Read the directory contents (relative paths resolve against the cwd) entries, err := os.ReadDir(dir) if err != nil { return nil } // Collect matching entries var matches []string for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, pattern) { continue } // Hide dotfiles unless explicitly asked for if strings.HasPrefix(name, ".") && !strings.HasPrefix(pattern, ".") { continue } // Follow symlinks to decide whether this is a directory if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() { name += "/" } matches = append(matches, name) } 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() }