The Shell class is a full-featured POSIX-like command interpreter. It parses and executes shell scripts, handles pipes and redirections, manages background jobs, and provides interactive line editing.
Class Definition
export class Shell {
private terminal: ITerminal;
private vfs: VFS;
private registry: CommandRegistry;
private cwd: string;
private env: Record<string, string>;
private interpreter: Interpreter;
private historyManager: HistoryManager;
private jobTable: JobTable;
private aliases: Map<string, string>;
constructor(
terminal: ITerminal,
vfs: VFS,
registry: CommandRegistry,
env: Record<string, string>
)
// Execution
async execute(cmd: string, options?: ExecuteOptions): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}>
start(): void // Start interactive mode
// Accessors
getCwd(): string
setCwd(cwd: string): void
getEnv(): Record<string, string>
getVfs(): VFS
getRegistry(): CommandRegistry
getJobTable(): JobTable
}
Location: src/shell/Shell.ts:23
Architecture
The shell consists of multiple components working together:
1. Line Editor
Handles interactive input with:
- Cursor movement: Arrow keys, Home/End, Ctrl+A/E
- History navigation: Up/Down arrows
- Tab completion: Files, commands, arguments
- Line editing: Insert, delete, backspace
- Ctrl+C: Interrupt running command
- Ctrl+D: Send EOF to stdin
- Ctrl+U: Clear line
See src/shell/Shell.ts:267-525.
2. Lexer (Tokenizer)
The lexer converts raw input into tokens:
const tokens = lex('ls -la | grep foo > output.txt');
// [
// { kind: TokenKind.Word, value: 'ls' },
// { kind: TokenKind.Word, value: '-la' },
// { kind: TokenKind.Pipe, value: '|' },
// { kind: TokenKind.Word, value: 'grep' },
// { kind: TokenKind.Word, value: 'foo' },
// { kind: TokenKind.RedirectOut, value: '>' },
// { kind: TokenKind.Word, value: 'output.txt' },
// { kind: TokenKind.EOF, value: '' }
// ]
Location: src/shell/lexer.ts:3
Token types:
Word - Command names, arguments, filenames
Pipe - |
And - &&
Or - ||
Amp - & (background)
Semi - ;
RedirectOut - >
RedirectAppend - >>
RedirectIn - <
RedirectErr - 2>
RedirectErrAppend - 2>>
RedirectAll - &>
Newline - \n
LParen / RParen - ( / ) (for subshells and functions)
DoubleSemi - ;; (case statement)
Quote handling:
lex("echo 'hello world'");
// Word with parts: [{ text: 'hello world', quoted: 'single' }]
lex('echo "foo $VAR bar"');
// Word with parts: [{ text: 'foo $VAR bar', quoted: 'double' }]
lex('echo foo\\'bar');
// Word with parts: [{ text: 'foo'bar', quoted: 'none' }]
Each word token has a parts array tracking quoted/unquoted segments. This preserves quoting information for the expander.
See src/shell/lexer.ts:145-282.
3. Parser
The parser builds an Abstract Syntax Tree (AST) from tokens:
const script = parse(tokens);
// ScriptNode {
// type: 'script',
// lists: [
// ListNode {
// type: 'list',
// entries: [
// {
// pipeline: PipelineNode { ... },
// connector: '&&'
// },
// { ... }
// ],
// background: false
// }
// ]
// }
Location: src/shell/parser.ts:27
AST node types:
ScriptNode - Top-level (list of lists)
ListNode - Command sequence with connectors (&&, ||)
PipelineNode - Pipeline of commands (cmd1 | cmd2 | cmd3)
SimpleCommandNode - Single command with args, redirections, assignments
IfNode - if ... then ... elif ... else ... fi
ForNode - for var in words; do ...; done
WhileNode / UntilNode - Loops
CaseNode - Pattern matching
FunctionDefNode - Function definition
GroupNode - { ... } command grouping
See src/shell/types.ts for full AST definitions.
Example parse tree:
Input: if test -f file.txt; then cat file.txt; fi
IfNode {
clauses: [
{
condition: [
ListNode {
entries: [{ pipeline: SimpleCommandNode { words: [['test'], ['-f'], ['file.txt']] } }]
}
],
body: [
ListNode {
entries: [{ pipeline: SimpleCommandNode { words: [['cat'], ['file.txt']] } }]
}
]
}
],
elseBody: null
}
4. Expander
The expander processes word parts before execution:
const words = await expandWords(
[[{ text: '$HOME/file.txt', quoted: 'none' }]],
expandContext
);
// ['/home/user/file.txt']
Location: src/shell/expander.ts
Expansion types:
- Variable expansion:
$VAR, ${VAR}, $1, $@, $?
- Tilde expansion:
~ → /home/user, ~/docs → /home/user/docs
- Command substitution:
$(cmd), \cmd“
- Glob expansion:
*.txt, file[0-9].log
- Brace expansion:
{a,b,c} → a b c
Quoting rules:
- Single quotes (
'...'): No expansion
- Double quotes (
"..."): Variable and command substitution, but no glob
- Unquoted: All expansions
Example:
VAR=world
echo 'Hello $VAR' # Hello $VAR
echo "Hello $VAR" # Hello world
echo Hello $VAR # Hello world
echo *.txt # file1.txt file2.txt
echo "*.txt" # *.txt
5. Interpreter
The interpreter executes the AST:
const exitCode = await interpreter.executeLine('ls -la | wc -l');
Location: src/shell/interpreter.ts:70
Execution flow:
- Traverse AST nodes
- For each simple command:
- Expand words (variables, globs, command substitution)
- Look up command (builtin → function → registry)
- Set up stdin/stdout/stderr (pipes, redirections)
- Execute command
- Capture exit code
- Handle control flow (
&&, ||, exit codes)
- Return final exit code
See src/shell/interpreter.ts:92-106 for executeLine().
Builtins
Builtins are commands implemented directly in the shell (not in the registry):
this.builtins.set('cd', (args, _stdout, stderr) => this.builtinCd(args, stderr));
this.builtins.set('pwd', (_args, stdout) => this.builtinPwd(stdout));
this.builtins.set('echo', (args, stdout) => this.builtinEcho(args, stdout));
this.builtins.set('export', (args) => this.builtinExport(args));
this.builtins.set('alias', (args, stdout) => this.builtinAlias(args, stdout));
this.builtins.set('unalias', (args, _stdout, stderr) => this.builtinUnalias(args, stderr));
this.builtins.set('source', (args, _stdout, stderr) => this.builtinSource(args, stderr));
this.builtins.set('history', (_args, stdout) => this.builtinHistory(stdout));
this.builtins.set('jobs', (_args, stdout) => this.builtinJobs(stdout));
this.builtins.set('fg', (args, stdout, stderr) => this.builtinFg(args, stdout, stderr));
this.builtins.set('bg', (args, stdout, stderr) => this.builtinBg(args, stdout, stderr));
this.builtins.set('clear', () => this.builtinClear());
this.builtins.set('exit', (_args, stdout) => this.builtinExit(stdout));
this.builtins.set('true', () => Promise.resolve(0));
this.builtins.set('false', () => Promise.resolve(1));
this.builtins.set('test', (_args, _stdout, stderr) => ...);
this.builtins.set('[', (_args, _stdout, stderr) => ...);
See src/shell/Shell.ts:98-119.
Why builtins?
Some commands must be builtins because they modify shell state:
cd - Changes cwd (process-local state)
export - Sets environment variables
alias - Defines command aliases
source / . - Executes script in current shell context
exit - Terminates the shell
Pipes and Redirections
Pipes
Pipes connect stdout of one command to stdin of the next:
ls -la | grep txt | wc -l
Implementation (src/shell/interpreter.ts:177-208):
- Create
PipeChannel instances (in-memory streams)
- Run commands in parallel:
- Command 1: stdin=none, stdout=pipe1.writer
- Command 2: stdin=pipe1.reader, stdout=pipe2.writer
- Command 3: stdin=pipe2.reader, stdout=terminal
- Close pipe writers when commands finish
- Wait for all commands to complete
- Return exit code of last command
PipeChannel implementation:
class PipeChannel {
private buffer: string[] = [];
private closed = false;
private waiters: Array<(value: string | null) => void> = [];
readonly writer: CommandOutputStream = {
write: (text: string) => {
if (this.closed) return;
this.buffer.push(text);
this.notifyWaiters();
}
};
readonly reader: CommandInputStream = {
read: async () => {
if (this.buffer.length > 0) {
return this.buffer.shift()!;
}
if (this.closed) {
return null; // EOF
}
// Wait for data
return new Promise(resolve => this.waiters.push(resolve));
},
readAll: async () => { ... }
};
close() {
this.closed = true;
this.notifyWaiters();
}
}
See src/shell/pipe.ts.
Pipes are in-memory only. There’s no buffering to disk, so very large pipes can cause memory pressure.
Redirections
Redirections change stdin/stdout/stderr for a command:
cat < input.txt # Redirect stdin from file
ls > output.txt # Redirect stdout to file (truncate)
ls >> output.txt # Redirect stdout to file (append)
grep foo 2> errors.log # Redirect stderr to file
cmd &> all.log # Redirect both stdout and stderr
Implementation (src/shell/interpreter.ts:435-468):
- Parser extracts redirection nodes from AST
- Interpreter creates file readers/writers:
> / >> → Create CommandOutputStream that writes to VFS
< → Create CommandInputStream that reads from VFS
2> / 2>> → Same for stderr
&> → Redirect both stdout and stderr to same file
- Pass streams to command executor
File reader/writer implementation:
private createFileWriter(path: string): CommandOutputStream {
return {
write: (text: string) => {
this.config.vfs.writeFile(path, text); // Overwrites each time
},
};
}
private createFileAppender(path: string): CommandOutputStream {
return {
write: (text: string) => {
this.config.vfs.appendFile(path, text);
},
};
}
private createFileReader(path: string): CommandInputStream {
const content = this.config.vfs.readFileString(path);
let consumed = false;
return {
read: async () => {
if (consumed) return null;
consumed = true;
return content;
},
readAll: async () => content,
};
}
See src/shell/interpreter.ts:626-653.
Job Control
The shell supports background jobs and job control:
sleep 10 & # Run in background
# [1] (background)
jobs # List jobs
# [1] Running sleep 10
fg 1 # Bring job 1 to foreground
bg 1 # Resume job 1 in background (future)
JobTable
The JobTable manages background jobs:
class JobTable {
private jobs: Map<number, Job> = new Map();
private nextId = 1;
add(command: string, promise: Promise<number>, abort: AbortController): number;
get(id: number): Job | undefined;
remove(id: number): void;
list(): Job[];
collectDone(): Job[]; // Get finished jobs
}
interface Job {
id: number;
command: string;
status: 'running' | 'done';
promise: Promise<number>;
abortController: AbortController;
}
See src/shell/jobs.ts.
Background Execution
When a command ends with &, the interpreter:
- Spawns the command asynchronously
- Adds it to the job table
- Immediately returns (doesn’t wait)
- Prints job ID
See src/shell/interpreter.ts:110-122.
Example:
const list: ListNode = {
type: 'list',
entries: [{ pipeline: ... }],
background: true
};
const promise = this.executeListEntries(list.entries);
const jobId = this.jobTable.add(commandText, promise, abortController);
this.writeToTerminal(`[${jobId}] (background)\n`);
return 0; // Immediately
Finished Job Notification
The shell checks for finished jobs before each prompt:
private printPrompt(): void {
const doneJobs = this.jobTable.collectDone();
for (const job of doneJobs) {
this.writeToTerminal(`[${job.id}] Done ${job.command}\n`);
}
// ... print prompt
}
See src/shell/Shell.ts:247-252.
History
The shell maintains command history with expansion support:
HistoryManager
class HistoryManager {
private entries: string[] = [];
constructor(private vfs: VFS)
load(): void; // Load from ~/.lifo_history
save(): void; // Save to ~/.lifo_history
add(entry: string): void; // Add entry (auto-saves)
getAll(): string[]; // Get all entries
expand(line: string): string | null; // History expansion (!!, !N, !prefix)
}
See src/shell/history.ts.
History Expansion
Supports bash-like history expansion:
history
# 1 ls -la
# 2 cd /tmp
# 3 cat file.txt
!! # Repeat last command (cat file.txt)
!2 # Repeat command 2 (cd /tmp)
!ls # Repeat last command starting with 'ls'
Implementation (src/shell/history.ts:40-79):
- Check if line starts with
!
- Parse expansion type (
!!, !N, !prefix)
- Look up command in history
- Return expanded command
The shell displays the expanded command before executing:
const expanded = this.historyManager.expand(line);
if (expanded !== null) {
this.writeToTerminal(expanded + '\n'); // Show what's being run
}
See src/shell/Shell.ts:631-637.
Tab Completion
The shell provides context-aware tab completion:
ls ~/doc<TAB> # Completes to ~/docs/
cat file<TAB> # Shows: file1.txt file2.txt
gr<TAB> # Completes to grep
Completer
function complete(ctx: CompletionContext): CompletionResult {
// Returns completions for the word at cursor
}
interface CompletionContext {
line: string;
cursorPos: number;
cwd: string;
env: Record<string, string>;
vfs: VFS;
registry: CommandRegistry;
builtinNames: string[];
}
interface CompletionResult {
completions: string[];
replacementStart: number;
replacementEnd: number;
commonPrefix: string;
}
See src/shell/completer.ts.
Completion logic:
- Determine word at cursor (handle quotes, escapes)
- If first word → complete command names (builtins + registry)
- Otherwise → complete file paths
- If multiple matches, find common prefix
- Return matches
Tab behavior:
- First tab: Insert common prefix (if any)
- Second tab: Show all completions
See src/shell/Shell.ts:394-444.
Aliases
Aliases allow defining command shortcuts:
alias ll='ls -la'
alias ..='cd ..'
alias g='git'
ll # Expands to: ls -la
g status # Expands to: git status
Implementation
private aliases = new Map<string, string>();
// Define alias
this.aliases.set('ll', 'ls -la');
// Expand alias during execution
const aliasValue = aliases.get(name);
if (aliasValue !== undefined) {
const expandedLine = aliasValue + (args.length > 0 ? ' ' + args.join(' ') : '');
return this.executeLine(expandedLine);
}
See src/shell/interpreter.ts:407-415.
Aliases are persisted in ~/.liforc:
alias ll='ls -la'
alias g='git'
Control Flow Constructs
The shell supports full POSIX control flow:
If Statements
if test -f file.txt; then
cat file.txt
elif test -d dir; then
ls dir
else
echo "Not found"
fi
See src/shell/interpreter.ts:236-254.
For Loops
for file in *.txt; do
echo "Processing $file"
cat "$file"
done
for i in 1 2 3; do echo $i; done
for arg; do echo $arg; done # Iterate over $@
See src/shell/interpreter.ts:256-288.
While Loops
while test -f flag.txt; do
echo "Waiting..."
sleep 1
done
See src/shell/interpreter.ts:290-313.
Case Statements
case $1 in
start)
echo "Starting..."
;;
stop)
echo "Stopping..."
;;
*)
echo "Usage: $0 {start|stop}"
;;
esac
See src/shell/interpreter.ts:342-359.
Functions
greet() {
echo "Hello, $1!"
}
greet World # Hello, World!
See src/shell/interpreter.ts:362-365 and src/shell/interpreter.ts:552-593.
Programmatic Execution
The shell can be used programmatically (headless mode):
const result = await shell.execute('ls -la', {
cwd: '/home/user',
env: { DEBUG: '1' },
stdin: 'input data',
onStdout: (data) => console.log('OUT:', data),
onStderr: (data) => console.log('ERR:', data),
});
console.log(result.stdout); // Captured output
console.log(result.exitCode); // 0
See src/shell/Shell.ts:149-215.
Features:
- Captured stdout/stderr
- Custom stdin
- Per-call cwd/env overrides
- Streaming callbacks
This is used by the Sandbox API and the npm command.
Configuration Files
The shell sources configuration files on startup:
/etc/profile - System-wide configuration
- First of:
~/.liforc - Lifo shell config (preferred)
~/.bashrc - Bash compatibility
~/.profile - Generic shell config
See src/shell/Shell.ts:226-244.
Special Variables
The shell maintains special variables:
$HOME - User home directory
$USER - Username
$HOSTNAME - System hostname
$SHELL - Shell path
$PATH - Command search path
$TERM - Terminal type
$PWD - Current working directory
$OLDPWD - Previous working directory (for cd -)
$? - Last exit code
$# - Number of positional parameters
$@ - All positional parameters
$1, $2, … - Individual positional parameters