Files
gosh/main.go
T
Florian WaltherandClaude Opus 5.5 7afcecd053 add colored prompt
- user@host in bold green (bold red for root), directory in bold blue
- colors are disabled with NO_COLOR, TERM=dumb or when stdout is not a terminal
- parts are sanitized before adding the color codes
- only replace the home directory with ~ on a path boundary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:31:09 +02:00

717 lines
18 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"github.com/chzyer/readline"
)
// ErrNoPath is returned when 'cd' was called without a second argument.
var ErrNoPath = errors.New("path required")
// version is set at build time via -ldflags "-X main.version=..."
var version = "dev"
// 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
}
// splitWords splits input into words like a (very) simple POSIX shell:
// - words are separated by unquoted blanks, an unquoted # starts a comment
// - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
// - outside quotes \ escapes any character
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR
// - ~ and ~/... are expanded at the start of an unquoted word
//
// Expanded values are never split into several words.
func splitWords(input string) ([]string, error) {
var (
words []string
b strings.Builder
inWord bool // a word has been started (even if it is still empty)
quote byte
escaped bool
)
endWord := func() {
if inWord {
words = append(words, b.String())
}
b.Reset()
inWord = false
}
for i := 0; i < len(input); i++ {
ch := input[i]
switch {
case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
b.WriteByte('\\')
}
b.WriteByte(ch)
inWord = true
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
} else {
b.WriteByte(ch)
}
case ch == '\\':
escaped = true
case ch == '$':
value, n, err := expandVar(input[i:])
if err != nil {
return nil, err
}
b.WriteString(value)
// A literal "$" counts as content, an expansion that is empty does
// not (unless quoted), like in sh
if n == 1 || value != "" {
inWord = true
}
i += n - 1
case quote == '"':
if ch == '"' {
quote = 0
} else {
b.WriteByte(ch)
}
case ch == '"' || ch == '\'':
quote = ch
inWord = true
case ch == ' ' || ch == '\t' || ch == '\n':
endWord()
case ch == '#' && !inWord && b.Len() == 0:
// Comment until the end of the line
i = len(input)
case ch == '~' && !inWord && b.Len() == 0 &&
(i+1 == len(input) || strings.IndexByte("/ \t\n", input[i+1]) >= 0):
home := getHomeDir()
if home == "" {
home = "~"
}
b.WriteString(home)
inWord = true
default:
b.WriteByte(ch)
inWord = true
}
}
switch {
case escaped:
return nil, errors.New("unexpected end of input after \\")
case quote != 0:
return nil, fmt.Errorf("unterminated %c quote", quote)
}
endWord()
return words, nil
}
// expandVar expands the variable reference at the start of s (which starts
// with "$"). It returns the value and the number of bytes consumed. A "$" that
// is not followed by a variable name is returned literally.
func expandVar(s string) (string, int, error) {
if len(s) > 1 && s[1] == '{' {
end := strings.IndexByte(s, '}')
if end < 0 {
return "", 0, errors.New("missing } in variable reference")
}
name, def, hasDef := strings.Cut(s[2:end], ":-")
if !isVarName(name) {
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
}
value, ok := os.LookupEnv(name)
if hasDef && value == "" {
return def, end + 1, nil
}
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", name)
}
return value, end + 1, nil
}
n := 1
for n < len(s) && isVarNameByte(s[n], n == 1) {
n++
}
if n == 1 {
return "$", 1, nil
}
value, ok := os.LookupEnv(s[1:n])
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", s[1:n])
}
return value, n, nil
}
func isVarName(name string) bool {
if name == "" {
return false
}
for i := 0; i < len(name); i++ {
if !isVarNameByte(name[i], i == 0) {
return false
}
}
return true
}
func isVarNameByte(c byte, first bool) bool {
return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
!first && c >= '0' && c <= '9'
}
// sanitize replaces non-printable characters (e.g. terminal escape sequences
// in directory names) with a visible escape so they cannot manipulate the
// terminal.
func sanitize(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
switch {
case r == utf8.RuneError && size == 1:
fmt.Fprintf(&b, "\\x%02x", s[i])
case !unicode.IsPrint(r):
if r < 0x100 {
fmt.Fprintf(&b, "\\x%02x", r)
} else {
fmt.Fprintf(&b, "\\u%04x", r)
}
default:
b.WriteRune(r)
}
i += size
}
return b.String()
}
// isPrintable reports whether s only consists of printable characters
func isPrintable(s string) bool {
return sanitize(s) == s
}
// historyFile returns the path of the history file: $GOSH_HISTFILE if set,
// otherwise $XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history).
// An empty string disables the history.
func historyFile() string {
if path, ok := os.LookupEnv("GOSH_HISTFILE"); ok {
return path
}
state := os.Getenv("XDG_STATE_HOME")
if state == "" {
home := getHomeDir()
if home == "" {
return ""
}
state = filepath.Join(home, ".local", "state")
}
return filepath.Join(state, "gosh", "history")
}
// prepareHistoryFile makes sure that the history file and its directory are
// only accessible by the current user. readline itself creates the file (and
// a temporary file next to it) with mode 0666 & ~umask.
func prepareHistoryFile(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
// Only restrict the directory if it is ours (and not e.g. $HOME itself)
if filepath.Base(dir) == "gosh" {
if err := os.Chmod(dir, 0700); err != nil {
return err
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer f.Close()
return f.Chmod(0600)
}
// 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"
}
// Replace the home directory with ~
home := getHomeDir()
if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) {
wd = "~" + strings.TrimPrefix(wd, home)
}
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, useColor())
}
// ANSI escape sequences used for the prompt
const (
colorReset = "\x1b[0m"
colorUser = "\x1b[1;32m" // bold green
colorRoot = "\x1b[1;31m" // bold red
colorPath = "\x1b[1;34m" // bold blue
)
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized
// before the color codes are added, so only our own escape sequences end up
// in the prompt.
func buildPrompt(username, hostname, path string, root, color bool) string {
userHost := sanitize(username + "@" + hostname)
path = sanitize(path)
if !color {
return userHost + ":" + path + " $ "
}
userColor := colorUser
if root {
userColor = colorRoot
}
return userColor + userHost + colorReset + ":" + colorPath + path + colorReset + " $ "
}
// useColor reports whether the prompt should be colored: stdout must be a
// terminal, TERM must not be "dumb" and NO_COLOR (https://no-color.org) must
// not be set.
func useColor() bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
return false
}
return readline.IsTerminal(int(os.Stdout.Fd()))
}
// 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) || !isPrintable(name) {
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
}
// Never insert control characters (e.g. escape sequences) into the line
if !isPrintable(name) {
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() {
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-V") {
fmt.Println("gosh", version)
return
}
// Ctrl+C sends SIGINT to the whole foreground process group. Catch it so
// that it only stops the running command and not the shell. (signal.Ignore
// would be inherited by the child processes.)
signal.Notify(make(chan os.Signal, 1), os.Interrupt)
histFile := historyFile()
if histFile != "" {
if err := prepareHistoryFile(histFile); err != nil {
fmt.Fprintln(os.Stderr, "History disabled:", err)
histFile = ""
}
}
// Create readline instance
l, err := readline.NewEx(&readline.Config{
Prompt: getPrompt(),
HistoryFile: histFile,
DisableAutoSaveHistory: true,
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 {
// Ctrl+C discards the current line
if err == readline.ErrInterrupt {
continue
}
// Handle EOF (Ctrl+D)
if err == io.EOF {
fmt.Println()
break
}
fmt.Fprintln(os.Stderr, "Error reading input:", err)
continue
}
// Skip empty input
if strings.TrimSpace(line) == "" {
continue
}
// Add to history, unless the line starts with a space (like bash's
// HISTCONTROL=ignorespace) so that secrets can be kept out of it
if !strings.HasPrefix(line, " ") {
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 into the command and its arguments, expanding
// variables and ~ on the way.
args, err := splitWords(input)
if err != nil {
return fmt.Errorf("invalid input: %w", err)
}
if len(args) == 0 {
return nil
}
// 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)
}
// 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 ('...' is literal,
"..." expands variables, \ escapes the next character)
- Ctrl+D to exit, Ctrl+C to cancel the current line or command
- Command history (up/down arrows), stored in
$XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history,
override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words
- Colored prompt (user@host green, red for root; directory blue),
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default`)
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()
}