Metadata-Version: 2.4
Name: termish
Version: 0.2.0
Summary: Virtual terminal with shell-like commands over a pluggable filesystem.
Author: ashenfad
License-Expression: MIT
Project-URL: Homepage, https://github.com/ashenfad/termish
Project-URL: Bug Tracker, https://github.com/ashenfad/termish/issues
Project-URL: Documentation, https://github.com/ashenfad/termish#readme
Project-URL: Source, https://github.com/ashenfad/termish
Keywords: terminal,shell,virtual,filesystem,commands,jq
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Shells
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: ruff; extra == "dev"
Requires-Dist: pre-commit; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: monkeyfs; extra == "dev"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: monkeyfs; extra == "test"
Dynamic: license-file

# 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)
- **37 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, 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

```bash
pip install termish
```

## Quick example

```python
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):

```python
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:

```python
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:

```python
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

```python
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](https://github.com/ashenfad/agex), operating over virtual filesystems from [monkeyfs](https://github.com/ashenfad/monkeyfs).

## Compatible filesystems

[monkeyfs](https://github.com/ashenfad/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` |

## Development

```bash
uv sync --extra dev
uv run pytest
```
