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, whosedisplay_stderris what that failure showed on stderr (""when it failed silently or its stderr was redirected). 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
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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| termish-0.2.4.tar.gz | 183.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.