- split input at unquoted ;, && and || and run the commands like sh;
variables are expanded per command when it runs
- $? and ${?} expand to the exit status of the last command,
exit/quit without argument use it
- syntax errors for missing commands around operators (status 2)
- "command not found" message instead of the raw exec error, no more
"exit status N" message when a command fails
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1100 lines
29 KiB
Go
1100 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"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
|
|
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 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", "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
|
|
}
|
|
|
|
const usage = `usage: gosh [-l] [-i] [script]
|
|
gosh [-l] -c command
|
|
gosh -version`
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:]))
|
|
}
|
|
|
|
// 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 {
|
|
// -l/--login is accepted for compatibility (e.g. "su -l", login(1)), but
|
|
// gosh does not read a startup file yet. -i forces interactive mode even
|
|
// if stdin is not a terminal.
|
|
forceInteractive := false
|
|
flags:
|
|
for len(args) > 0 {
|
|
switch args[0] {
|
|
case "-l", "--login":
|
|
case "-i":
|
|
forceInteractive = true
|
|
default:
|
|
break flags
|
|
}
|
|
args = args[1:]
|
|
}
|
|
|
|
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
|
|
}
|
|
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 == "-":
|
|
return runScript(os.Stdin)
|
|
default:
|
|
f, err := os.Open(arg)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "gosh:", err)
|
|
return 127
|
|
}
|
|
defer f.Close()
|
|
return runScript(bufio.NewReader(f))
|
|
}
|
|
}
|
|
|
|
// Without a terminal (e.g. "echo ls | gosh") read the commands from stdin.
|
|
if !forceInteractive && !readline.IsTerminal(int(os.Stdin.Fd())) {
|
|
return runScript(os.Stdin)
|
|
}
|
|
|
|
interactive()
|
|
return 0
|
|
}
|
|
|
|
// 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.
|
|
func runScript(r io.Reader) int {
|
|
status := 0
|
|
for {
|
|
line, err := readLine(r)
|
|
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
|
|
printError(execInput(line))
|
|
status = lastStatus
|
|
}
|
|
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 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
|
|
|
|
// 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
|
|
if err == nil || errors.As(err, &exitErr) {
|
|
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 fmt.Errorf("syntax error near unexpected token `%s'", 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 = 2
|
|
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 single simple command.
|
|
func execCommand(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(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 "help":
|
|
fmt.Println("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
|
|
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),
|
|
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
|
|
- Non-interactive use: gosh -c 'command', gosh script, command | gosh;
|
|
the exit status is the one of the last command`)
|
|
return nil
|
|
}
|
|
|
|
// Prepare the command to execute.
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
|
|
// Connect the command to the terminal. 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 from the command.
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
// Execute the command return the error.
|
|
err = cmd.Run()
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return fmt.Errorf("%s: %w", sanitize(args[0]), errCommandNotFound)
|
|
}
|
|
return err
|
|
}
|