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>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-03 23:08:23 +02:00
1 parent 1182d8d146
commit 4b44cc6e72
3 files changed
+304 -30

No files matched your search

+129 -22
View File
@@ -74,12 +74,15 @@ func versionInfo() string {
// previousDir tracks the previous working directory for 'cd -' support
var previousDir string
// getHomeDir returns the user's home directory
// 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 {
// Fallback to HOME environment variable
return os.Getenv("HOME")
return ""
}
return usr.HomeDir
}
@@ -699,7 +702,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter {
return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "help"},
}
}
@@ -866,33 +869,48 @@ func completeFilePath(line, prefix string) []string {
return matches
}
const usage = `usage: gosh [-l] [-i] [script]
gosh [-l] -c command
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() {
os.Exit(run(os.Args[1:]))
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 {
// -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
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":
@@ -909,13 +927,13 @@ flags:
fmt.Fprintln(os.Stderr, usage)
return 2
}
return runScript(strings.NewReader(args[1]))
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 == "-":
return runScript(os.Stdin)
script = func() int { return runScript(os.Stdin, "") }
default:
f, err := os.Open(arg)
if err != nil {
@@ -923,30 +941,107 @@ flags:
return 127
}
defer f.Close()
return runScript(bufio.NewReader(f))
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, "") }
}
// 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)
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.
func runScript(r io.Reader) int {
// skipped. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int {
status := 0
for {
for lineNo := 1; ; lineNo++ {
line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
printError(execInput(line))
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 {
@@ -1476,6 +1571,8 @@ var builtins = map[string]struct{ changesState bool }{
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
@@ -1555,6 +1652,12 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
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() + `
@@ -1566,6 +1669,7 @@ Built-in commands:
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:
@@ -1591,7 +1695,10 @@ Features:
- 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`)
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])