@mylocalgpt/shell

A pure TypeScript bash interpreter for AI agents. Zero dependencies, under 40KB gzipped, runs in browser, Node, Deno, Bun, and Cloudflare Workers. Provides 60+ Unix commands, a full jq processor, and an in-memory filesystem for sandboxed execution.

Overview

  • Language: TypeScript
  • Repo: mylocalgpt/shell
  • Install: npm install @mylocalgpt/shell
  • Status: Active (v0.0.2)

Architecture

The codebase follows a classic compiler pipeline: lexer -> parser -> interpreter, with separate subsystems for the filesystem, command registry, jq engine, and security limits.

Module breakdown:

  • src/index.ts - Public API. Exports the Shell class, which wraps everything into a clean interface. The constructor accepts ShellOptions (files, env, limits, custom commands, hooks). The exec() method parses input, runs it through the interpreter, and returns { stdout, stderr, exitCode }. State (env, functions, cwd, filesystem) persists across exec() calls; shell options (set -e, etc.) reset per call.

  • src/parser/lexer.ts - Context-sensitive lexer (~990 lines). Produces tokens for words, operators, redirections, and reserved words. Handles single/double/ANSI-C quoting, $() command substitution, ${} parameter expansion, heredocs (including <<- tab stripping), backtick substitution, and assignment detection. Tracks reservedWordAllowed and commandStart state to disambiguate reserved words used as arguments.

  • src/parser/parser.ts - Recursive descent parser (~1900 lines). Produces a typed AST from the token stream. Supports: simple commands with assignments and redirections, pipelines with ! negation, lists with &&/||/;/&, if/elif/else/fi, for-in and C-style for, while/until loops, case/esac with ;;/;&/;;&, subshells (), brace groups {}, function definitions, [[ ]] conditional expressions, (( )) arithmetic commands, heredocs, here-strings, brace expansion, and glob patterns.

  • src/parser/ast.ts - All AST node type definitions as a discriminated union. 30+ node types covering the full bash grammar.

  • src/interpreter/interpreter.ts - AST walker (~1375 lines). The Interpreter class maintains shell state: env (Map), local variable scope chain, functions, arrays, cwd, exit code, runtime options, positional parameters, PIPESTATUS, and execution counters. Executes each node type: simple commands go through word expansion -> redirection setup -> temp env -> command resolution (functions > builtins > registry) -> execution -> output redirect application. Pipelines pipe stdout between commands sequentially. Supports set -e (errexit) with conditional-depth tracking to suppress it inside if/&&/||.

  • src/interpreter/builtins.ts - 25 shell builtins: :, cd, export, unset, readonly, read, source/., local, set, declare/typeset, eval, shift, test/[, true, false, return, break, continue, exit, type, command, builtin, trap, getopts. Also contains evaluateConditionalExpr() for [[ ]] expressions including regex matching with =~ and BASH_REMATCH.

  • src/interpreter/expansion.ts - Word expansion engine. Handles tilde expansion, variable expansion ($VAR, ${VAR:-default}, ${VAR//pattern/replacement}, ${#VAR}, ${!VAR} indirect, case modification ^^/,,), command substitution, arithmetic expansion, brace expansion, glob expansion, and word splitting by IFS.

  • src/commands/ - 60+ commands, each in its own file. All registered lazily via defaults.ts (loaded on first use via dynamic import). Covers filesystem (cat, cp, mv, rm, mkdir, ls, tree, find, stat, ln, chmod), text processing (grep, sed, awk, head, tail, sort, uniq, wc, cut, tr, rev, tac, paste, fold, column, nl, strings, expand, xargs), data (diff, base64, md5sum, sha256sum, jq, od, expr), navigation (pwd, du, basename, dirname, readlink, realpath), and utility (echo, printf, env, date, seq, hostname, whoami, which, tee, sleep).

  • src/commands/registry.ts - CommandRegistry with lazy loading. Commands are registered as { name, load } definitions. On first get(), the load function fires a dynamic import and caches the result. Also supports defineCommand() for pre-loaded third-party commands and onUnknownCommand callback for fallback resolution. retainOnly() enables command allowlisting.

  • src/fs/memory.ts - InMemoryFs implementation (~590 lines). Flat Map keyed by normalized absolute paths. Supports files, directories, and symlinks. Lazy file content (sync or async functions resolved on first read). Built-in virtual devices (/dev/null, /dev/stdin, /dev/stdout, /dev/stderr). Full POSIX-like operations: readFile, writeFile, appendFile, exists, stat, readdir, mkdir, rmdir, unlink, rename, copyFile, chmod, realpath, symlink, readlink.

  • src/jq/ - Self-contained jq engine, independently importable via @mylocalgpt/shell/jq. Has its own tokenizer, parser, AST, evaluator, and builtins. Supports object/array access, pipes, conditionals, try-catch, reduce, foreach, string interpolation, @base64/@uri/@html/@csv/@tsv formatters, $ENV, --arg/--argjson, --slurp, --null-input, and configurable execution limits.

  • src/security/limits.ts - ExecutionLimits with defaults: 10K loop iterations, 100 call depth, 10K commands per exec, 10MB string/output size, 100K array elements, 100 pipeline depth. All overridable via constructor options.

  • src/security/regex.ts - Guards against ReDoS by checking regex pattern complexity before compilation.

  • src/utils/ - glob matching, diff algorithm, printf formatting.

Key Design Decisions

Zero dependencies: The entire package ships with nothing but its own code. No external parsers, no runtime libraries. This keeps bundle size small and eliminates supply chain risk, which matters when the target audience is AI agent sandboxes.

Lazy command loading: Commands are registered as dynamic import thunks. Only the commands actually used in a script get loaded. This keeps the initial import cost minimal for use cases that only need a few commands.

In-memory filesystem with lazy content: Files can be initialized with functions instead of strings. The function is called on first read and the result is cached. This enables patterns like files: { '/data.csv': () => fetch(url).then(r => r.text()) } without loading everything upfront.

Dual export for jq: The jq engine is available both as a shell command and as a standalone import at @mylocalgpt/shell/jq. This lets consumers use jq without the full shell if they only need JSON processing.

defineCommand() extensibility: Custom commands registered via the constructor or defineCommand() participate fully in pipes, redirections, and all shell features. The onUnknownCommand callback enables fallback resolution for commands not in the registry.

Persistent state, reset options: Environment variables, functions, and cwd persist across exec() calls (like a real shell session), but shell options (set -e, positional params, etc.) reset each call. This prevents accidental state leakage between independent commands while maintaining session continuity.

Security limits by default: Every execution path is bounded. Loop iterations, call depth, command count, string length, array size, output size, and pipeline depth all have configurable limits. Regex patterns are checked for catastrophic backtracking before compilation.

Development

pnpm install
pnpm build             # tsdown (ESM + CJS + DTS)
pnpm test              # vitest unit tests
pnpm test:all          # vitest + smokepod comparison tests + biome lint + typecheck
pnpm test:comparison   # compare output against real /bin/bash via smokepod
pnpm lint              # biome check
pnpm typecheck         # tsc --noEmit
pnpm bundle-size       # check gzipped output size