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, whose display_stderr is what that failure showed on stderr ("" when it failed silently or its stderr was redirected). 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

An unset variable stops the script with NAME: unbound variable, as under bash's set -u: with no assignment in the language, every variable comes from the host, so an unset one is a mistake, and rm -r $DIR/x must never run as rm -r /x. ${NAME:-word} and ${NAME-word} give a default for one that may be unset. $PWD is always the current directory; a host should pass HOME. Single quotes suppress expansion ('$?' stays literal). Heredoc bodies are never expanded.

Braces expand before variables and globs, as in bash: {a,b} lists (mkdir -p src/{lib,test}) and {1..3}, {a..e}, {01..10}, {1..10..2} sequences. A brace with nothing to expand ({}, {x}) stays literal, so find -exec cmd {} \; keeps its {}.

Command substitution $(...), arithmetic $((...)), variable assignment (N=3) and shell control flow are not supported. 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 ParseError naming the word -- one per block, rather than reporting each word as a missing command. Every unsupported construct in a script is reported at once, each with its line and column (line 2, col 6: arithmetic expansion $((...)) is not supported; ...), and nothing runs. 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 ($EMPTY 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

An injected command can describe itself with a description string attribute on its handler (greet.description = "say hello"); help lists it with that line, beside the builtins.

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, seq, paste, comm, rev
Diff diff
Archive tar, gzip, gunzip, zcat/gzcat, zip, unzip
Meta xargs, which, command, help
JSON jq
Inspection file, stat, du, realpath, md5sum, sha256sum
Time date
Control true, false, test / [

Development

uv sync --extra dev
uv run pytest

Metadata

Release files for termish 0.2.4

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.4
File Size Uploaded Provenance
termish-0.2.4.tar.gz 183.2 kB Without provenance Details

Built distribution (wheel)

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

Total release size: 322.5 kB

Release files / termish-0.2.4.tar.gz

Download URL termish-0.2.4.tar.gz
Size 183.2 kB
Tags Source
SHA-256 checksum
How to use checksums
029f59a68afdacc47f4907a0d6dd2bd3d85fa98dbf564a950915c31ff154b8ca
BLAKE2b-256 checksum
How to use checksums
74b92a176f0c30fb95277360c2463fd3c171f35b2c548b55f23bd20a6389f51a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

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

Download URL termish-0.2.4-py3-none-any.whl
Size 139.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
53932bd164a4b4e9e28146147128a2acbfccac015af4115ae49ec4d8ddde29bf
BLAKE2b-256 checksum
How to use checksums
e8e8b6996b0bc17def89023b2bf2564c70080ef5abb9b5b7d4db218c3e567a5b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Security for termish 0.2.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

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