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.datis byte-identical andcat file.gz | zcatworks; 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 raisesTerminalError. Stderr redirects are honored:2>filecaptures,2>/dev/nullsuppresses,2>&1merges into the pipe (cmd 2>&1 | headworks) - 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 --
FileSystemis atyping.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.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| termish-0.2.2.tar.gz | 127.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| termish-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 218.2 kB
Release files / termish-0.2.2.tar.gz
| Download URL | termish-0.2.2.tar.gz |
|---|---|
| Size | 127.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c4d5a78f4920f2ef48f3fcf548812595e8ca4b5f2910e78f32cec66e72288578
|
|
BLAKE2b-256 checksum How to use checksums |
6f2bbcc1603955a4d0c802c182efd18d84bef09be9659ea4be74852d6e9976d1
|
| 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.2-py3-none-any.whl
| Download URL | termish-0.2.2-py3-none-any.whl |
|---|---|
| Size | 90.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
081f20e60a558b028441e2eb08db2fe4f38172b83c418bb2107b135649439f5c
|
|
BLAKE2b-256 checksum How to use checksums |
051a6e186603a0206aa6a85ead882b5b0e77196ba75de8dd1044e69d4f8aac81
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|