Skip to content

Commit 6f68fe2

Browse files
committed
feat: zigpty - zig-based pty library with node.js napi addon
Cross-platform PTY library (Linux, macOS, Windows) with pure Zig core and thin NAPI wrapper. Supports ConPTY on Windows, forkpty on Unix.
0 parents  commit 6f68fe2

36 files changed

Lines changed: 6797 additions & 0 deletions

.editorconfig

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 2
6+
end_of_line = lf
7+
charset = utf-8
8+
trim_trailing_whitespace = true
9+
insert_final_newline = true
10+
11+
[*.zig]
12+
indent_size = 4
13+
14+
[*.md]
15+
trim_trailing_whitespace = false

.github/workflows/ci.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
jobs:
9+
build:
10+
runs-on: ${{ matrix.os }}
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
os: [ubuntu-latest, macos-latest, windows-latest]
15+
permissions:
16+
contents: read
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: oven-sh/setup-bun@v2
21+
22+
- name: Detect Zig version
23+
id: zig-version
24+
shell: bash
25+
run: echo "version=$(sed -n 's/.*minimum_zig_version = "\([^"]*\)".*/\1/p' build.zig.zon)" >> "$GITHUB_OUTPUT"
26+
27+
- uses: mlugg/setup-zig@v2
28+
with:
29+
version: ${{ steps.zig-version.outputs.version }}
30+
31+
- name: Install JS dependencies
32+
run: bun install
33+
34+
- name: Build (Zig — native only)
35+
if: runner.os == 'Windows'
36+
run: zig build -Dtarget=x86_64-windows
37+
38+
- name: Build (Zig — all targets)
39+
if: runner.os != 'Windows'
40+
run: zig build
41+
42+
- name: Build (TypeScript)
43+
run: bunx --bun obuild
44+
45+
- name: Test
46+
run: bun vitest run

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
*.log
3+
dist
4+
zig-out
5+
prebuilds
6+
.zig-cache

.oxfmtrc.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
3+
"semi": true,
4+
"quoteStyle": "double",
5+
"indentStyle": "space",
6+
"indentWidth": 2,
7+
"lineWidth": 100
8+
}

AGENTS.md

Lines changed: 247 additions & 0 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Changelog
2+
3+
4+
## v0.0.2
5+
6+
[compare changes](https://github.com/pithings/zigpty/compare/v0.0.1...v0.0.2)
7+
8+
### 📦 Build
9+
10+
- Update release script ([f203aae](https://github.com/pithings/zigpty/commit/f203aae))
11+
12+
### ❤️ Contributors
13+
14+
- Pooya Parsa ([@pi0](https://github.com/pi0))
15+
16+
## v0.0.1
17+
18+
19+
### 🚀 Enhancements
20+
21+
- Add macOS support ([e4c3a18](https://github.com/pithings/zigpty/commit/e4c3a18))
22+
23+
### 🏡 Chore
24+
25+
- Update ci ([e12391c](https://github.com/pithings/zigpty/commit/e12391c))
26+
- Update ci ([27e0272](https://github.com/pithings/zigpty/commit/27e0272))
27+
- Switch to oxfmt formatter with editorconfig ([60d909b](https://github.com/pithings/zigpty/commit/60d909b))
28+
- Apply oxfmt formatting, update docs and ci ([fd3139e](https://github.com/pithings/zigpty/commit/fd3139e))
29+
30+
### ❤️ Contributors
31+
32+
- Pooya Parsa ([@pi0](https://github.com/pi0))
33+

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Pooya Parsa <pooya@pi0.io>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# zigpty
2+
3+
Tiny, cross-platform PTY library for Node.js, built in Zig, also usable as a standalone Zig package. Supports Linux, macOS, and Windows (via ConPTY).
4+
5+
Drop-in replacement for [`node-pty`](https://github.com/microsoft/node-pty). **350x smaller** (43 KB vs 15.5 MB packed, 176 KB vs 64.4 MB installed), no `node-gyp` or C++ compiler needed, and ships musl prebuilds for Alpine.
6+
7+
## Use cases
8+
9+
Regular `child_process.spawn()` runs programs without a terminal attached. That means no colors, no cursor control, no prompts — programs like `vim`, `top`, `htop`, or interactive shells simply don't work. A **PTY** (pseudo-terminal) makes the subprocess think it's connected to a real terminal. Colors, line editing, full-screen TUIs, and terminal resizing all work as expected.
10+
11+
- **Terminal emulators** — embed a terminal in Electron, Tauri, or a web app
12+
- **Remote shells** — stream a PTY over WebSocket from a Node.js server
13+
- **CI / automation** — run programs that require a TTY (interactive installers, REPLs)
14+
- **Testing** — test CLI tools that use colors, prompts, or cursor movement
15+
- **AI agents** — give LLM agents a real shell to run commands, observe output, and interact with CLIs
16+
17+
## Usage
18+
19+
```ts
20+
import { spawn } from "zigpty";
21+
22+
// auto-detects default shell ($SHELL on Unix, %COMSPEC% on Windows)
23+
const pty = spawn(undefined, [], {
24+
cols: 80,
25+
rows: 24,
26+
terminal: {
27+
data(terminal, data: Uint8Array) {
28+
process.stdout.write(data);
29+
},
30+
},
31+
onExit(exitCode, signal) {
32+
console.log("exited:", exitCode);
33+
},
34+
});
35+
36+
pty.write("echo hello\n");
37+
pty.resize(120, 40);
38+
await pty.exited; // Promise<number>
39+
```
40+
41+
Terminal callbacks bypass Node.js streams and deliver raw `Uint8Array` directly from native code. You can also use the `onData`/`onExit` event listeners instead:
42+
43+
```ts
44+
pty.onData((data) => process.stdout.write(data));
45+
pty.onExit(({ exitCode }) => console.log("exited:", exitCode));
46+
```
47+
48+
The `Terminal` class can be reused across multiple spawns and supports `AsyncDisposable`:
49+
50+
```ts
51+
import { spawn, Terminal } from "zigpty";
52+
53+
await using terminal = new Terminal({
54+
data(term, data) { process.stdout.write(data); },
55+
});
56+
57+
const pty = spawn("/bin/sh", ["-c", "echo hello"], { terminal });
58+
await pty.exited;
59+
// terminal.close() called automatically by `await using`
60+
```
61+
62+
## API
63+
64+
### `spawn(file, args?, options?)`
65+
66+
Spawn a process inside a new PTY.
67+
68+
**Options:**
69+
70+
```ts
71+
interface IPtyOptions {
72+
cols?: number; // Default: 80
73+
rows?: number; // Default: 24
74+
cwd?: string; // Default: process.cwd()
75+
env?: Record<string, string>; // Default: process.env
76+
name?: string; // Sets TERM (e.g. "xterm-256color")
77+
encoding?: BufferEncoding | null; // Default: "utf8", null for raw Buffer
78+
uid?: number; // Unix user ID
79+
gid?: number; // Unix group ID
80+
handleFlowControl?: boolean; // Intercept XON/XOFF (default: false)
81+
terminal?: TerminalOptions | Terminal; // Bun-compatible terminal callbacks
82+
onExit?: (exitCode: number, signal: number) => void;
83+
}
84+
```
85+
86+
**Returns:**
87+
88+
```ts
89+
interface IPty {
90+
pid: number;
91+
cols: number;
92+
rows: number;
93+
readonly process: string; // Foreground process name
94+
readonly exited: Promise<number>; // Resolves with exit code
95+
readonly exitCode: number | null; // Exit code or null if running
96+
97+
onData: (cb: (data: string | Buffer) => void) => IDisposable;
98+
onExit: (cb: (e: { exitCode: number; signal: number }) => void) => IDisposable;
99+
100+
write(data: string): void;
101+
resize(cols: number, rows: number): void;
102+
kill(signal?: string): void; // Default: SIGHUP
103+
pause(): void;
104+
resume(): void;
105+
close(): void;
106+
}
107+
108+
### `open(options?)`
109+
110+
Create a PTY pair without spawning a processuseful when you need to control the child process yourself.
111+
112+
```ts
113+
import { open } from "zigpty";
114+
115+
const { master, slave, pty } = open({ cols: 80, rows: 24 });
116+
```
117+
118+
## Platform support
119+
120+
| Platform | Status |
121+
| -------------------- | ------- |
122+
| Linux x64 (glibc) ||
123+
| Linux x64 (musl) ||
124+
| Linux arm64 (glibc) ||
125+
| Linux arm64 (musl) ||
126+
| macOS x64 ||
127+
| macOS arm64 ||
128+
| Windows x64 ||
129+
| Windows arm64 ||
130+
131+
All 8 platform binaries are prebuiltno compiler needed at install time. On Linux, the native loader tries glibc first and falls back to musl automatically.
132+
133+
## Zig package
134+
135+
The PTY core is a standalone Zig package with no Node.js or NAPI dependency.
136+
137+
```sh
138+
zig fetch --save git+https://github.com/pithings/zigpty.git
139+
```
140+
141+
Wire it up in `build.zig`:
142+
143+
```zig
144+
const zigpty = b.dependency("zigpty", .{ .target = target, .optimize = optimize });
145+
exe.root_module.addImport("zigpty", zigpty.module("zigpty"));
146+
```
147+
148+
API:
149+
150+
```zig
151+
const pty = @import("zigpty");
152+
153+
// Fork a process with a PTY
154+
const result = try pty.forkPty(.{
155+
.file = "/bin/bash",
156+
.argv = &.{ "/bin/bash", null },
157+
.envp = &.{ "TERM=xterm-256color", null },
158+
.cwd = "/home/user",
159+
.cols = 120,
160+
.rows = 40,
161+
});
162+
// result.fd — PTY file descriptor (read/write)
163+
// result.pid — child process ID
164+
165+
// Open a bare PTY pair (no process spawned)
166+
const pair = try pty.openPty(80, 24);
167+
// pair.master, pair.slave
168+
169+
// Resize
170+
try pty.resize(result.fd, 80, 24, 0, 0);
171+
172+
// Foreground process name
173+
var buf: [4096]u8 = undefined;
174+
const name: ?[]const u8 = pty.getProcessName(result.fd, &buf);
175+
176+
// Block until child exits
177+
const exit_info = pty.waitForExit(result.pid);
178+
// exit_info.exit_code, exit_info.signal_code
179+
```
180+
181+
## Building from source
182+
183+
Requires [Zig](https://ziglang.org/) 0.15.1+.
184+
185+
```sh
186+
zig build # Build prebuilds (all targets)
187+
zig build --release # Release build
188+
bun run build # Build + bundle TypeScript
189+
bun vitest run # Run tests
190+
```
191+
192+
## Credits
193+
194+
API-compatible with [node-pty](https://github.com/microsoft/node-pty). Terminal API inspired by [Bun](https://bun.sh/docs/runtime/child-process#terminal-pty-support).
195+
196+
## License
197+
198+
MIT

build.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// import { execSync } from "node:child_process";
2+
import { defineBuildConfig } from "obuild/config";
3+
4+
export default defineBuildConfig({
5+
entries: [
6+
{
7+
type: "bundle",
8+
input: "./node/index.ts",
9+
},
10+
],
11+
// hooks: {
12+
// end() {
13+
// execSync("zig build --release", { stdio: "inherit" });
14+
// },
15+
// },
16+
});

0 commit comments

Comments
 (0)