Files
gosh/main.go
T
Florian WaltherandClaude Opus 5.5 4b44cc6e72 add startup files and the source builtin
- login shells (-l or argv[0] starting with -) read /etc/gosh/profile
  and ~/.gosh_profile, interactive shells /etc/gosh/goshrc and ~/.goshrc;
  --noprofile and --norc skip them
- source file / . file runs a file in the current shell, nesting is
  limited to stop files that source themselves
- errors in startup files and scripts are reported with file and line
- ~ and the startup files use $HOME (like sh), the user database is
  only the fallback

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

1706 lines
45 KiB
Go

package main
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"syscall"
"unicode"
"unicode/utf8"
"github.com/chzyer/readline"
)
// ErrNoPath is returned when 'cd' was called without a second argument.
var ErrNoPath = errors.New("path required")
// Build information, set at build time via
// -ldflags "-X main.version=... -X main.commit=... -X main.branch=...".
var (
version = "dev"
commit = ""
branch = ""
)
// versionInfo returns the version including branch and commit, e.g.
// "0.2.1 (branch: main, commit: 03e791c)". Without ldflags (plain go build)
// the commit is taken from the VCS information that Go embeds in the binary.
func versionInfo() string {
c := commit
if c == "" {
if info, ok := debug.ReadBuildInfo(); ok {
var dirty bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
c = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if len(c) > 7 {
c = c[:7]
}
if c != "" && dirty {
c += "-dirty"
}
}
}
var details []string
if branch != "" {
details = append(details, "branch: "+branch)
}
if c != "" {
details = append(details, "commit: "+c)
}
if len(details) == 0 {
return version
}
return version + " (" + strings.Join(details, ", ") + ")"
}
// previousDir tracks the previous working directory for 'cd -' support
var previousDir string
// getHomeDir returns the user's home directory: $HOME like in sh, or the
// home directory from the user database if $HOME is not set
func getHomeDir() string {
if home := os.Getenv("HOME"); home != "" {
return home
}
usr, err := user.Current()
if err != nil {
return ""
}
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
}
// token is a word or an operator of a command line.
type token struct {
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
word string // the word, if op == ""
pattern string // the word as glob pattern (quoted characters escaped)
glob bool // the word contains unquoted * ? or [ and is expanded
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
}
// tokenize splits input into words and operators 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
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
//
// Expanded values are never split into several words.
func tokenize(input string) ([]token, error) {
var (
tokens []token
b strings.Builder
pat strings.Builder // the word as glob pattern, quoted parts escaped
glob bool // the word contains unquoted * ? or [
inWord bool // a word has been started (even if it is still empty)
quote byte
escaped bool
rawStart = -1 // start of the current word in input
)
endWord := func() {
if inWord {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
}
b.Reset()
pat.Reset()
glob = false
inWord = false
rawStart = -1
}
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
startRaw := func(i int) {
if rawStart < 0 {
rawStart = i
}
}
for i := 0; i < len(input); i++ {
ch := input[i]
switch {
case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
literal("\\")
}
literal(string(ch))
inWord = true
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
} else {
literal(string(ch))
}
case ch == '\\':
startRaw(i)
escaped = true
case ch == '$':
startRaw(i)
value, n, err := expandVar(input[i:])
if err != nil {
return nil, err
}
// Expanded values are never patterns (and never split), so that
// e.g. a * in a variable does not match files
literal(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 {
literal(string(ch))
}
case ch == '"' || ch == '\'':
startRaw(i)
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 == '|':
endWord()
tokens = append(tokens, token{op: "|"})
case ch == '&' && i+1 < len(input) && input[i+1] == '>':
// &> and &>> redirect stdout and stderr
endWord()
op := "&>"
if i+2 < len(input) && input[i+2] == '>' {
op = "&>>"
}
tokens = append(tokens, token{op: op, fd: 1})
i += len(op) - 1
case ch == '<' || ch == '>':
// A word consisting only of unquoted digits right before the
// operator is the file descriptor, e.g. 2>file
fd := 0
if ch == '>' {
fd = 1
}
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
fd, _ = strconv.Atoi(input[rawStart:i])
b.Reset()
pat.Reset()
glob = false
inWord = false
rawStart = -1
} else {
endWord()
}
t := token{op: string(ch), fd: fd}
switch {
case ch == '>' && i+1 < len(input) && input[i+1] == '>':
t.op = ">>"
i++
case i+1 < len(input) && input[i+1] == '&':
// N>&M and N<&M duplicate file descriptor M
j := i + 2
for j < len(input) && input[j] >= '0' && input[j] <= '9' {
j++
}
if j == i+2 {
return nil, syntaxError(input[i : i+2])
}
t.op = string(ch) + "&"
t.dup, _ = strconv.Atoi(input[i+2 : j])
i = j - 1
}
tokens = append(tokens, t)
case ch == '~' && !inWord && b.Len() == 0 &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0):
startRaw(i)
home := getHomeDir()
if home == "" {
home = "~"
}
literal(home)
inWord = true
default:
startRaw(i)
b.WriteByte(ch)
switch {
case ch == '*' || ch == '?' || ch == '[':
glob = true
pat.WriteByte(ch)
case ch == '!' && strings.HasSuffix(pat.String(), "[") && !strings.HasSuffix(pat.String(), "\\["):
// POSIX [!...] is [^...] in Go
pat.WriteByte('^')
default:
pat.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 tokens, nil
}
// escapeGlob escapes the characters that are special in a glob pattern.
func escapeGlob(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if strings.IndexByte("*?[]\\", s[i]) >= 0 {
b.WriteByte('\\')
}
b.WriteByte(s[i])
}
return b.String()
}
// hasGlobMeta reports whether pattern contains an unescaped * ? or [.
func hasGlobMeta(pattern string) bool {
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '*', '?', '[':
return true
}
}
return false
}
// unescapeGlob removes the escapes from a pattern without wildcards.
func unescapeGlob(pattern string) string {
var b strings.Builder
for i := 0; i < len(pattern); i++ {
if pattern[i] == '\\' && i+1 < len(pattern) {
i++
}
b.WriteByte(pattern[i])
}
return b.String()
}
// expandGlob returns the paths matching pattern, sorted. Like sh, * and ?
// do not match a leading dot (hidden files), unless the pattern component
// starts with a dot, and a trailing / only matches directories. An invalid
// pattern matches nothing.
func expandGlob(pattern string) []string {
paths := []string{""}
if strings.HasPrefix(pattern, "/") {
paths = []string{"/"}
}
components := strings.Split(strings.Trim(pattern, "/"), "/")
join := func(dir, name string) string {
if dir == "" || strings.HasSuffix(dir, "/") {
return dir + name
}
return dir + "/" + name
}
for _, comp := range components {
if comp == "" {
continue // a//b
}
var next []string
for _, dir := range paths {
if !hasGlobMeta(comp) {
p := join(dir, unescapeGlob(comp))
if _, err := os.Lstat(p); err == nil {
next = append(next, p)
}
continue
}
readDir := dir
if readDir == "" {
readDir = "."
}
entries, err := os.ReadDir(readDir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") {
continue
}
ok, err := filepath.Match(comp, name)
if err != nil {
return nil
}
if ok {
next = append(next, join(dir, name))
}
}
}
paths = next
}
if strings.HasSuffix(pattern, "/") {
var dirs []string
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.IsDir() {
dirs = append(dirs, p+"/")
}
}
paths = dirs
}
sort.Strings(paths)
return paths
}
// expandWord returns the words a token expands to: the matching paths for
// a pattern, or the word itself if it is no pattern or nothing matches (like
// in sh).
func expandWord(t token) []string {
if t.glob {
if matches := expandGlob(t.pattern); len(matches) > 0 {
return matches
}
}
return []string{t.word}
}
// isDigits reports whether s is a non-empty string of ASCII digits.
func isDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// splitWords splits input into words (see tokenize). Operators are an error.
func splitWords(input string) ([]string, error) {
tokens, err := tokenize(input)
if err != nil {
return nil, err
}
var words []string
for _, t := range tokens {
if t.op != "" {
return nil, fmt.Errorf("unexpected operator %s", t.op)
}
words = append(words, t.word)
}
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 name == "?" {
return strconv.Itoa(lastStatus), end + 1, nil
}
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
}
if len(s) > 1 && s[1] == '?' {
return strconv.Itoa(lastStatus), 2, 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)
}
var colors *promptColors
if useColor() {
c := parseColors(os.Getenv("GOSH_COLORS"))
colors = &c
}
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors)
}
// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
// prompt. An empty value means no color for that part.
type promptColors struct {
user string // user@host
root string // user@host when running as root
path string // working directory
}
// defaultColors: bold green user, bold red root, bold blue directory
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"}
// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36"
// and overrides the corresponding defaults. Unknown keys and values that are
// not plain SGR parameters (digits and ';') are ignored, so the variable
// cannot be used to write arbitrary escape sequences to the terminal.
func parseColors(spec string) promptColors {
colors := defaultColors
for _, entry := range strings.Split(spec, ":") {
key, value, ok := strings.Cut(entry, "=")
if !ok || !isSGR(value) {
continue
}
switch key {
case "user":
colors.user = value
case "root":
colors.root = value
case "path":
colors.path = value
}
}
return colors
}
// isSGR reports whether s consists only of digits and ';'. The empty string
// is allowed and disables the color.
func isSGR(s string) bool {
for _, r := range s {
if (r < '0' || r > '9') && r != ';' {
return false
}
}
return true
}
// colorize wraps text in the SGR sequence for sgr, or returns it unchanged if
// sgr is empty.
func colorize(text, sgr string) string {
if sgr == "" {
return text
}
return "\x1b[" + sgr + "m" + text + "\x1b[0m"
}
// 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. colors == nil disables colors.
func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string {
userHost := sanitize(username + "@" + hostname)
path = sanitize(path)
if colors == nil {
return userHost + ":" + path + " $ "
}
userColor := colors.user
if root {
userColor = colors.root
}
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ "
}
// 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", "source", "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]))
// A command is expected at the start and after |, ;, && and ||
before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
// 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' || strings.IndexByte(";|&<>", ch) >= 0:
// Blanks and operators end a word
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
}
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
gosh -version`
// sysConfDir holds the system-wide startup files.
var sysConfDir = "/etc/gosh"
func main() {
args := os.Args[1:]
// login(1), sshd and "su -" start a login shell with a leading - in
// argv[0], e.g. "-gosh"
if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...)
}
os.Exit(run(args))
}
// run parses the command line and runs gosh in the matching mode. It returns
// the exit status of the shell.
func run(args []string) int {
var login, forceInteractive, noRC, noProfile bool
flags:
for len(args) > 0 {
switch args[0] {
case "-l", "--login":
login = true
case "-i":
// Interactive mode even if stdin is not a terminal
forceInteractive = true
case "--norc":
noRC = true
case "--noprofile":
noProfile = true
default:
break flags
}
args = args[1:]
}
// Determine what to run, before the startup files are read
var script func() int
interactiveMode := false
if len(args) > 0 {
switch arg := args[0]; {
case arg == "-version" || arg == "--version" || arg == "-V":
fmt.Println("gosh", versionInfo())
return 0
case arg == "-h" || arg == "--help":
fmt.Println(usage)
return 0
case arg == "-c":
// Like sh, further arguments are allowed (they would become $0,
// $1, ...), but gosh has no positional parameters yet.
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument")
fmt.Fprintln(os.Stderr, usage)
return 2
}
script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage)
return 2
case arg == "-":
script = func() int { return runScript(os.Stdin, "") }
default:
f, err := os.Open(arg)
if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
return 127
}
defer f.Close()
script = func() int { return runScript(bufio.NewReader(f), arg) }
}
} else if forceInteractive || readline.IsTerminal(int(os.Stdin.Fd())) {
interactiveMode = true
} else {
// Without a terminal (e.g. "echo ls | gosh") read the commands from
// stdin
script = func() int { return runScript(os.Stdin, "") }
}
for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) {
sourceStartupFile(file)
}
if !interactiveMode {
return script()
}
interactive()
return 0
}
// startupFiles returns the startup files to read, in order: for login
// shells /etc/gosh/profile and ~/.gosh_profile, for interactive shells
// /etc/gosh/goshrc and ~/.goshrc. Non-interactive shells (gosh -c, scripts)
// read no files unless they are login shells.
func startupFiles(login, interactive bool) []string {
home := getHomeDir()
var files []string
if login {
files = append(files, filepath.Join(sysConfDir, "profile"))
if home != "" {
files = append(files, filepath.Join(home, ".gosh_profile"))
}
}
if interactive {
files = append(files, filepath.Join(sysConfDir, "goshrc"))
if home != "" {
files = append(files, filepath.Join(home, ".goshrc"))
}
}
return files
}
// sourceStartupFile runs a startup file in the current shell. A missing file
// is skipped silently, other errors are reported.
func sourceStartupFile(path string) {
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return
}
if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
return
}
defer f.Close()
runScript(bufio.NewReader(f), path)
}
// sourceDepth counts nested source commands, to stop a file that sources
// itself.
var sourceDepth int
// sourceFile runs the commands of a file in the current shell (the source and
// . builtins) and returns an error carrying the status of the last command.
func sourceFile(cmd, path string) error {
if sourceDepth >= 100 {
return fmt.Errorf("%s: %s: maximum nesting depth exceeded", cmd, sanitize(path))
}
f, err := os.Open(path)
if err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
err = pathErr.Err
}
return fmt.Errorf("%s: %s: %v", cmd, sanitize(path), err)
}
defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
if status := runScript(bufio.NewReader(f), path); status != 0 {
return statusError(status)
}
return nil
}
// runScript executes the commands from r line by line. Like sh, it does not
// stop at a failing command and returns the exit status of the last one.
// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are
// skipped. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int {
status := 0
for lineNo := 1; ; lineNo++ {
line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
cmdErr := execInput(line)
status = lastStatus
if name != "" && cmdErr != nil {
cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr)
}
printError(cmdErr)
}
if err != nil {
if err != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", err)
return 1
}
return status
}
}
}
// readLine reads a single line from r without the trailing newline. It reads
// byte by byte so that nothing after the line is consumed: when the script
// comes from stdin, the commands it starts read the rest of stdin (like in
// sh, "printf 'cat\nhello\n' | gosh" prints hello). Pass a bufio.Reader if
// that does not matter.
func readLine(r io.Reader) (string, error) {
var b strings.Builder
buf := make([]byte, 1)
for {
n, err := r.Read(buf)
if n > 0 {
if buf[0] == '\n' {
return b.String(), nil
}
b.WriteByte(buf[0])
}
if err != nil {
return b.String(), err
}
}
}
// exitStatus converts the result of execInput into a shell exit status: the
// exit code of the command, 128+n if it was killed by signal n, 127 if it was
// not found, 126 if it could not be executed and 1 for other errors.
func exitStatus(err error) int {
if err == nil {
return 0
}
var statusErr statusError
if errors.As(err, &statusErr) {
return int(statusErr)
}
var synErr syntaxError
if errors.As(err, &synErr) {
return 2
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
return 128 + int(ws.Signal())
}
return exitErr.ExitCode()
}
if errors.Is(err, errCommandNotFound) || errors.Is(err, fs.ErrNotExist) {
return 127
}
if errors.Is(err, fs.ErrPermission) {
return 126
}
return 1
}
// interactive runs the interactive shell with line editing, history and
// completion until the user exits.
func interactive() {
// 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
printError(execInput(line))
}
}
// lastStatus is the exit status of the last command ($?).
var lastStatus int
// statusError is an error that only carries an exit status; the problem has
// already been reported.
type statusError int
func (e statusError) Error() string {
return "exit status " + strconv.Itoa(int(e))
}
// syntaxError is a syntax error in the input (exit status 2, like sh).
type syntaxError string
func (e syntaxError) Error() string {
return "syntax error near unexpected token `" + string(e) + "'"
}
// errCommandNotFound is returned when an external command does not exist.
var errCommandNotFound = errors.New("command not found")
// printError prints an error of a command to stderr. Commands that ran and
// failed have already reported the problem themselves, only their exit
// status is kept.
func printError(err error) {
var (
exitErr *exec.ExitError
statusErr statusError
)
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) {
return
}
fmt.Fprintln(os.Stderr, "gosh:", err)
}
// listItem is a command of a command list together with the operator that
// connects it to the previous command ("" for the first one).
type listItem struct {
op string // "", ";", "&&" or "||"
command string
}
// splitList splits input at unquoted ;, && and ||. Quotes, escapes, ${...}
// and comments are handled like in splitWords, but nothing is expanded yet:
// that happens per command when it runs, so "cd /tmp && echo $PWD" works.
func splitList(input string) ([]listItem, error) {
var (
items []listItem
op string
start int
quote byte
escaped bool
wordStart = true // the next character starts a word
)
end := len(input)
add := func(i int, next string) error {
command := input[start:i]
if strings.TrimSpace(command) == "" {
// "a;" is fine, but an operator needs a command on both sides
if next != "" || op == "&&" || op == "||" {
if next == "" {
next = op
}
return syntaxError(next)
}
return nil
}
items = append(items, listItem{op: op, command: command})
return nil
}
scan:
for i := 0; i < len(input); i++ {
ch := input[i]
isWordStart := wordStart
wordStart = false
switch {
case escaped:
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
escaped = true
case ch == '$' && i+1 < len(input) && input[i+1] == '{':
// Skip ${...} as a whole, like expandVar does
if j := strings.IndexByte(input[i:], '}'); j >= 0 {
i += j
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == ' ' || ch == '\t' || ch == '\n':
wordStart = true
case ch == '#' && isWordStart:
end = i
break scan
case ch == ';',
ch == '&' && i+1 < len(input) && input[i+1] == '&',
ch == '|' && i+1 < len(input) && input[i+1] == '|':
next := input[i : i+1]
if ch != ';' {
next = input[i : i+2]
}
if err := add(i, next); err != nil {
return nil, err
}
i += len(next) - 1
op = next
start = i + 1
wordStart = true
}
}
// An unterminated quote or a trailing \ is reported by splitWords
if err := add(end, ""); err != nil {
return nil, err
}
return items, nil
}
// execInput executes a line of input: a list of commands separated by ;, &&
// and ||. It returns the error of the last command that was run; errors of
// earlier commands are printed. $? is updated after each command.
func execInput(input string) error {
items, err := splitList(input)
if err != nil {
lastStatus = exitStatus(err)
return err
}
var lastErr error
for _, item := range items {
if (item.op == "&&" && lastStatus != 0) || (item.op == "||" && lastStatus == 0) {
continue
}
printError(lastErr)
lastErr = execCommand(item.command)
lastStatus = exitStatus(lastErr)
}
return lastErr
}
// execCommand executes a pipeline of simple commands with redirections.
func execCommand(input string) error {
// Split the input into words and operators, expanding variables and ~ on
// the way.
tokens, err := tokenize(input)
var synErr syntaxError
if errors.As(err, &synErr) {
return err
}
if err != nil {
return fmt.Errorf("invalid input: %w", err)
}
stages, err := parsePipeline(tokens)
if err != nil {
return err
}
if len(stages) == 0 {
return nil
}
return runPipeline(stages)
}
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
type redirect struct {
op string // <, >, >>, &>, &>>, >& or <&
fd int // the redirected file descriptor
dup int // the target file descriptor of >& and <&
file string // the file name for all other operators
}
// stage is a simple command of a pipeline.
type stage struct {
args []string
redirects []redirect
}
// parsePipeline groups tokens into the stages of a pipeline.
func parsePipeline(tokens []token) ([]stage, error) {
var (
stages []stage
cur stage
)
isEmpty := func(s stage) bool { return len(s.args) == 0 && len(s.redirects) == 0 }
for i := 0; i < len(tokens); i++ {
t := tokens[i]
switch t.op {
case "":
cur.args = append(cur.args, expandWord(t)...)
case "|":
if isEmpty(cur) {
return nil, syntaxError("|")
}
stages = append(stages, cur)
cur = stage{}
case ">&", "<&":
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, dup: t.dup})
default:
if i+1 == len(tokens) {
return nil, syntaxError("newline")
}
if next := tokens[i+1]; next.op != "" {
return nil, syntaxError(next.op)
}
i++
files := expandWord(tokens[i])
if len(files) > 1 {
return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word))
}
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: files[0]})
}
}
if isEmpty(cur) {
if len(stages) > 0 {
return nil, syntaxError("|")
}
return nil, nil
}
return append(stages, cur), nil
}
// runPipeline runs the stages of a pipeline concurrently, connecting the
// stdout of each stage to the stdin of the next one. It returns the error of
// the last stage (so $? is its exit status, like in sh); errors of the other
// stages are printed.
func runPipeline(stages []stage) error {
waits := make([]func() error, len(stages))
stdin := os.Stdin
for i, st := range stages {
stdout := os.Stdout
var next *os.File
if i < len(stages)-1 {
r, w, err := os.Pipe()
if err != nil {
// Let the stages that already run finish
if stdin != os.Stdin {
stdin.Close()
}
for _, wait := range waits[:i] {
printError(wait())
}
return err
}
stdout, next = w, r
}
waits[i] = startStage(st, stdin, stdout, len(stages) > 1)
stdin = next
}
var err error
for i, wait := range waits {
err = wait()
if i < len(waits)-1 {
printError(err)
}
}
return err
}
// startStage starts a stage of a pipeline with the given stdin and stdout and
// returns a function that waits for it to finish. Pipe ends passed in are
// closed by startStage once the stage no longer needs them, so that the
// neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error {
var owned []*os.File
if stdin != os.Stdin {
owned = append(owned, stdin)
}
if stdout != os.Stdout {
owned = append(owned, stdout)
}
closeOwned := func() {
for _, f := range owned {
f.Close()
}
}
result := func(err error) func() error {
return func() error { return err }
}
// Connect the command to the terminal unless redirected. Without Stdin
// the command would read from /dev/null, so interactive programs (su,
// vim, ssh, ...) would get EOF right away. readline only reads from stdin
// while a line is being edited, so it does not steal input.
fds := [3]*os.File{stdin, stdout, os.Stderr}
opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...)
if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files
closeOwned()
return result(err)
}
if builtin, ok := builtins[st.args[0]]; ok {
if inPipeline && builtin.changesState {
closeOwned()
return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
}
run := func() error {
defer closeOwned()
err := runBuiltin(st.args, fds[1])
if err != nil && fds[2] != os.Stderr {
// Report the error where stderr is redirected to
fmt.Fprintln(fds[2], "gosh:", err)
return statusError(1)
}
return err
}
if !inPipeline {
return result(run())
}
done := make(chan error, 1)
go func() { done <- run() }()
return func() error { return <-done }
}
cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
err = cmd.Start()
// The child has its own copies of the files now
closeOwned()
if errors.Is(err, exec.ErrNotFound) {
err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound)
}
if err != nil {
return result(err)
}
return cmd.Wait
}
// applyRedirects applies the redirections in order to fds (stdin, stdout,
// stderr) and returns the files it opened.
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
var opened []*os.File
fail := func(err error) ([]*os.File, error) {
for _, f := range opened {
f.Close()
}
return nil, err
}
for _, r := range redirects {
if r.fd > 2 {
return fail(fmt.Errorf("%d: bad file descriptor", r.fd))
}
if r.op == ">&" || r.op == "<&" {
if r.dup > 2 {
return fail(fmt.Errorf("%d: bad file descriptor", r.dup))
}
fds[r.fd] = fds[r.dup]
continue
}
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
switch r.op {
case "<":
flags = os.O_RDONLY
case ">>", "&>>":
flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND
}
f, err := os.OpenFile(r.file, flags, 0666)
if err != nil {
// "file: No such file or directory" with exit status 1, like sh
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
err = fmt.Errorf("%s: %v", sanitize(r.file), pathErr.Err)
}
return fail(err)
}
opened = append(opened, f)
fds[r.fd] = f
if r.op == "&>" || r.op == "&>>" {
fds[2] = f
}
}
return opened, nil
}
// builtins lists the built-in commands. Commands that change the state of
// the shell cannot be used in a pipeline, since there they would run
// concurrently (sh runs them in a subshell where they have no effect).
var builtins = map[string]struct{ changesState bool }{
"cd": {changesState: true},
"pwd": {},
"exit": {changesState: true},
"quit": {changesState: true},
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
func runBuiltin(args []string, stdout io.Writer) error {
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.Fprintln(stdout, 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(lastStatus)
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(lastStatus)
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 "source", ".":
// source file - run the commands of file in the current shell
if len(args) < 2 {
return fmt.Errorf("usage: %s file", args[0])
}
return sourceFile(args[0], args[1])
case "help":
fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + `
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 status code (default: $?)
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
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
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue),
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36'
(keys user, root, path; values are ANSI SGR codes, empty = no color);
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
- Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Non-interactive use: gosh -c 'command', gosh script, command | gosh;
the exit status is the one of the last command
- Startup files: login shells (-l or argv[0] starting with -) read
/etc/gosh/profile and ~/.gosh_profile, interactive shells read
/etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)`)
return nil
}
return fmt.Errorf("%s: not a builtin", args[0])
}