Skip to main content

termish 📺

Virtual terminal with shell-like commands over a pluggable filesystem.

Parses and executes shell scripts (pipelines, redirects, semicolons) against any object that implements the FileSystem protocol. Zero runtime dependencies. Pure Python.

Features

  • Shell parser -- pipes, redirects (>, >>, <, 2>, 2>>, 2>&1), heredocs (<<EOF), semicolons, quoted strings, line continuation
  • Variable expansion -- $? (last exit code), $VAR / ${VAR} from an env dict; expands in unquoted and double-quoted contexts, literal in single quotes
  • Binary-safe pipelines -- pipes and redirects carry bytes, so cat bin.dat > copy.dat is byte-identical and cat file.gz | zcat works; text decoding happens at each command's own boundary, and the returned transcript is decoded once for display
  • Terminal-faithful transcript -- stderr diagnostics appear in the returned output when execution continues past a failure (cmd; next, cmd || rescue), like a real terminal screen; a failure with nothing after it raises TerminalError. Stderr redirects are honored: 2>file captures, 2>/dev/null suppresses, 2>&1 merges into the pipe (cmd 2>&1 | head works)
  • 39 builtins -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, test/[, true, false, basename, dirname, ...
  • Custom commands -- inject your own command handlers alongside builtins; injected commands override builtins and compose in pipelines
  • jq engine -- built-in jq filter parser and evaluator (field access, pipes, functions, conditionals)
  • Pluggable filesystem -- FileSystem is a typing.Protocol; any object with the right methods works
  • MemoryFS included -- in-memory filesystem for testing and lightweight use

Install

pip install termish

Quick example

from termish import execute, MemoryFS

fs = MemoryFS()

execute("mkdir -p src", fs)
execute("echo 'def main(): pass' > src/app.py", fs)
execute("echo 'import os' > src/utils.py", fs)

# Pipelines work
output = execute("grep -r 'def' src | wc -l", fs)
print(output)  # 1

# jq works
execute('echo \'{"name": "alice", "score": 42}\' > data.json', fs)
output = execute('jq -r ".name" data.json', fs)
print(output)  # alice

Variables

$? expands to the last pipeline's exit code. $VAR / ${VAR} read from an optional env dict, which is shared with command handlers via ctx.env -- mutations persist across commands (and across execute() calls if you reuse the dict):

output = execute('cat /missing; echo "exit=$?"', fs)
print(output)  # exit=1

env = {"NAME": "alice"}
output = execute("echo hello $NAME", fs, env=env)
print(output)  # hello alice

Unset variables expand to the empty string. Single quotes suppress expansion ('$?' stays literal). Command substitution $(...) is not supported and raises ParseError rather than mangling silently. Heredoc bodies are never expanded.

Shell control flow is not supported either. A script whose command position holds for, while, until, if, case, one of their partners (do, done, then, else, elif, fi, esac), or a function definition (function f { ... }, f() { ... }) raises a single ParseError naming the word -- rather than reporting each word as a missing command, which is what a for loop used to do three times over. Use xargs or find -exec to iterate and && / || for conditionals, and inject anything more involved as a custom command. Only command position counts: echo for, grep -r done ., a file named for, and quoted 'for' are all ordinary words. Quoting is the way to run a custom command whose name collides with a keyword; a backslash does not do it here (\for is still for), where bash would accept either.

Expansions are never field-split -- this is zsh's behavior, not bash's, and it's deliberate: a value with spaces stays one argument (grep $PAT file with PAT="a b" searches for a b), and a multi-word command name is a visible command not found rather than a silent re-parse. An empty-expanding command word shifts away ($UNSET echo hi runs echo hi), also as in zsh.

Custom commands

Inject your own commands via the commands parameter. They receive a CommandContext and compose naturally with builtins in pipelines:

from termish import execute, MemoryFS, CommandContext, CommandResult

def greet(ctx: CommandContext) -> CommandResult | None:
    name = ctx.args[0] if ctx.args else "world"
    ctx.stdout.write(f"hello {name}\n")
    return None

fs = MemoryFS()
output = execute("greet alice | wc -c", fs, commands={"greet": greet})
print(output)  # 12

# Injected commands override builtins with the same name

All commands — builtin and injected — use the same CommandContext signature. See CommandContext, CommandResult, and CommandFunc in termish.context and termish.errors.

ctx.stdin and ctx.stdout are text streams over the bytes the pipeline carries: ctx.stdout.buffer.write(data) emits bytes that reach the next stage or a > file redirect unchanged, and ctx.stdin.buffer.read() consumes them unchanged. Flush the text side before switching to .buffer on the same stream.

FileSystem protocol

Any object implementing these 16 methods works with termish -- no inheritance required:

class FileSystem(Protocol):
    def getcwd(self) -> str: ...
    def chdir(self, path: str) -> None: ...
    def read(self, path: str, offset: int = 0, size: int = -1) -> bytes: ...
    def write(self, path: str, content: bytes, mode: str = "w") -> None: ...
    def exists(self, path: str) -> bool: ...
    def isfile(self, path: str) -> bool: ...
    def isdir(self, path: str) -> bool: ...
    def stat(self, path: str) -> FileMetadata: ...
    def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None: ...
    def makedirs(self, path: str, exist_ok: bool = True) -> None: ...
    def remove(self, path: str) -> None: ...
    def rmdir(self, path: str) -> None: ...
    def rename(self, src: str, dst: str) -> None: ...
    def list(self, path: str = ".", recursive: bool = False) -> list[str]: ...
    def list_detailed(self, path: str = ".", recursive: bool = False) -> list[FileInfo]: ...
    def glob(self, pattern: str) -> list[str]: ...

read takes an optional byte range. read(path) returns the whole file, exactly as before; read(path, offset, size) returns at most size bytes starting at offset, with size=-1 meaning "to the end". A read at or past the end of the file returns b"", and one that runs past the end is truncated rather than raising. A backend that can fetch a range -- a real file, an HTTP endpoint that answers 206, a block store -- can then serve a caller that wants a parquet footer without moving the parquet.

Conformance

from termish.fs import check_filesystem

check_filesystem(MyFS())   # raises AssertionError, or returns None

check_filesystem takes an empty filesystem and exercises all sixteen methods, including the ranged read, append mode on write, and the FileInfo.path convention; it works relative to whatever getcwd() reports and removes what it created. A wrong answer is an AssertionError naming the method; a method that raises on a valid call raises through with the backend's own traceback, since that points at the failing line and an assertion would not. There is no test framework involved, so a backend author outside termish can run it from a script. A backend that accepts offset and size and discards them is what it is mainly there to catch: whole-file reads still return the right bytes, so nothing else notices.

Part of the agex stack

termish provides shell commands for AI agents in agex, operating over virtual filesystems from monkeyfs.

Compatible filesystems

monkeyfs VirtualFS and IsolatedFS both satisfy the termish FileSystem protocol and can be passed directly to execute().

That is not a coincidence, and it is a promise: termish's FileSystem protocol and monkeyfs's backend protocol are the same sixteen methods with the same signatures, ranged read included. open() is what monkeyfs provides over a backend -- termish never asks for it. The agreement is a convention enforced by tests on both sides rather than a shared import: a common package would cost both libraries their zero-dependency line for twenty lines of protocol.

Builtin commands

Category Commands
Filesystem pwd, cd, mkdir, ls, touch, cp, mv, rm, basename, dirname
I/O echo, printf, cat, head, tail, tee
Search grep, find
Text wc, sort, uniq, cut, sed, tr
Diff diff
Archive tar, gzip, gunzip, zcat/gzcat, zip, unzip
Meta xargs
JSON jq
Inspection file
Control true, false, test / [

Development

uv sync --extra dev
uv run pytest

Metadata

Release files for termish 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for termish 0.2.1
File Size Uploaded
termish-0.2.1.tar.gz 124.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for termish 0.2.1
File Interpreter ABI Platform
termish-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 212.3 kB

Release files / termish-0.2.1.tar.gz

Download URL termish-0.2.1.tar.gz
Size 124.0 kB
Tags Source
SHA-256 checksum
How to use checksums
598d5b44a8e7c2a036a10448d827f925c51943aa33ba943874ff31d941b335f5
BLAKE2b-256 checksum
How to use checksums
590bc43d7af434f8deac0aa5e81cbd27e617f77e5a8353f6ce85650aee638444
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / termish-0.2.1-py3-none-any.whl

Download URL termish-0.2.1-py3-none-any.whl
Size 88.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a049ee6da616611ec9b585d1a0440db45b13b2a1dcc9489e405f96658d55ce02
BLAKE2b-256 checksum
How to use checksums
e1f23be13717f3f0c4f6dd8ff777b80ed8fcdf204f48f7ab6dd6aa7b6db80479
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release history Release notifications | RSS feed

0.2.3

2 release files

0.2.2

2 release files

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page