This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add pty wrap/unwrap — auto-wrap commands in pty sessions

pty wrap claude creates ~/.local/pty/bin/claude that runs
pty run -a -- /real/path/to/claude "$@"
pty unwrap claude removes the wrapper
pty wrap --list shows wrapped commands

Uses PTY_BIN_PATH env var (default ~/.local/pty/bin/). Warns
if the directory isn't in PATH.

Nathan Herald (Mar 31, 2026, 11:53 PM +0200) 6360f820 d67c7c10

+123 -1
+1 -1
package.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.2.3", 3 + "version": "0.3.0", 4 4 "description": "Persistent terminal sessions with detach/attach, plus a Playwright-style testing library for TUI apps", 5 5 "type": "module", 6 6 "license": "MIT",
+122
src/cli.ts
··· 1 + import * as fs from "node:fs"; 1 2 import * as os from "node:os"; 2 3 import * as path from "node:path"; 3 4 import * as readline from "node:readline/promises"; ··· 37 38 pty list List active sessions 38 39 pty list --json List sessions as JSON 39 40 pty kill <name> Kill or remove a session 41 + pty wrap <command> Auto-wrap a command in pty sessions 42 + pty unwrap <command> Remove a wrap 43 + pty wrap --list List wrapped commands 40 44 pty test Run tests (vitest) 41 45 pty test watch Watch mode 42 46 pty test -t "pattern" Run matching tests ··· 304 308 break; 305 309 } 306 310 311 + case "wrap": { 312 + if (args[1] === "--list" || args[1] === "-l") { 313 + cmdWrapList(); 314 + } else if (!args[1]) { 315 + console.error("Usage: pty wrap <command>"); 316 + process.exit(1); 317 + } else { 318 + cmdWrap(args[1]); 319 + } 320 + break; 321 + } 322 + 323 + case "unwrap": { 324 + if (!args[1]) { 325 + console.error("Usage: pty unwrap <command>"); 326 + process.exit(1); 327 + } 328 + cmdUnwrap(args[1]); 329 + break; 330 + } 331 + 307 332 case "test": { 308 333 await cmdTest(args.slice(1)); 309 334 break; ··· 618 643 if (hours < 24) return `${hours}h ago`; 619 644 const days = Math.floor(hours / 24); 620 645 return `${days}d ago`; 646 + } 647 + 648 + // ── Wrap/Unwrap ── 649 + 650 + const DEFAULT_WRAP_DIR = path.join(os.homedir(), ".local", "pty", "bin"); 651 + 652 + function getWrapDir(): string { 653 + return process.env.PTY_BIN_PATH ?? DEFAULT_WRAP_DIR; 654 + } 655 + 656 + function ensureWrapDir(): void { 657 + fs.mkdirSync(getWrapDir(), { recursive: true, mode: 0o700 }); 658 + } 659 + 660 + function checkWrapInPath(): void { 661 + const wrapDir = getWrapDir(); 662 + const pathDirs = (process.env.PATH ?? "").split(":"); 663 + if (!pathDirs.includes(wrapDir)) { 664 + console.error(`\nAdd this to your shell profile to use wrapped commands:\n`); 665 + console.error(` export PATH="${wrapDir}:$PATH"\n`); 666 + } 667 + } 668 + 669 + /** 670 + * Resolve the real binary path, skipping our wrap directory. 671 + * Searches PATH with the wrap dir excluded so we find the original binary. 672 + */ 673 + function resolveRealBinary(name: string): string | null { 674 + const wrapDir = getWrapDir(); 675 + const pathDirs = (process.env.PATH ?? "").split(":").filter(d => d !== wrapDir); 676 + for (const dir of pathDirs) { 677 + const candidate = path.join(dir, name); 678 + try { 679 + fs.accessSync(candidate, fs.constants.X_OK); 680 + return candidate; 681 + } catch {} 682 + } 683 + return null; 684 + } 685 + 686 + function cmdWrap(command: string): void { 687 + const cmdName = path.basename(command); 688 + const realPath = resolveRealBinary(cmdName); 689 + if (!realPath) { 690 + console.error(`Command not found in PATH: ${cmdName}`); 691 + process.exit(1); 692 + } 693 + 694 + ensureWrapDir(); 695 + const wrapPath = path.join(getWrapDir(), cmdName); 696 + 697 + // The wrapper script uses pty run -a (create or attach) with auto-naming. 698 + // It resolves pty from PATH (excluding the wrap dir to avoid recursion). 699 + const script = `#!/bin/sh 700 + # Generated by: pty wrap ${cmdName} 701 + # Wraps ${realPath} in a pty session 702 + exec pty run -a -- ${realPath} "$@" 703 + `; 704 + 705 + fs.writeFileSync(wrapPath, script, { mode: 0o755 }); 706 + console.log(`Wrapped: ${cmdName} → ${realPath}`); 707 + console.log(`Wrapper: ${wrapPath}`); 708 + checkWrapInPath(); 709 + } 710 + 711 + function cmdUnwrap(command: string): void { 712 + const cmdName = path.basename(command); 713 + const wrapPath = path.join(getWrapDir(), cmdName); 714 + if (!fs.existsSync(wrapPath)) { 715 + console.error(`Not wrapped: ${cmdName}`); 716 + process.exit(1); 717 + } 718 + fs.unlinkSync(wrapPath); 719 + console.log(`Unwrapped: ${cmdName}`); 720 + } 721 + 722 + function cmdWrapList(): void { 723 + const wrapDir = getWrapDir(); 724 + let files: string[]; 725 + try { 726 + files = fs.readdirSync(wrapDir); 727 + } catch { 728 + console.log("No wrapped commands."); 729 + return; 730 + } 731 + if (files.length === 0) { 732 + console.log("No wrapped commands."); 733 + return; 734 + } 735 + console.log("Wrapped commands:"); 736 + for (const file of files.sort()) { 737 + const content = fs.readFileSync(path.join(wrapDir, file), "utf-8"); 738 + const match = content.match(/exec pty run -a -- (.+) "\$@"/); 739 + const target = match ? match[1] : "?"; 740 + console.log(` ${file} → ${target}`); 741 + } 742 + checkWrapInPath(); 621 743 } 622 744 623 745 main().catch((err) => {