This repository has no description
0

Configure Feed

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

Auto-name sessions, fix global install spawn failure (fixes #4)

Sessions are now auto-named from directory + command when no name is
given: `pty run -- node server.js` creates `myapp-node-server`. Use
`--name` for explicit names. Old positional syntax still works with
a hint to migrate.

The interactive wizard auto-generates the name as you type the
command. Manually editing the name stops auto-generation. Filter
results now rank name matches above path/command matches.

Fix: add postinstall script to chmod +x node-pty's spawn-helper
binary. npm strips the executable bit during install, causing
posix_spawnp to fail on global installs.

Fix: move @preact/signals-core from optional peer dependency to
regular dependency — the CLI now uses it since the interactive
list rewrite.

Nathan Herald (Mar 28, 2026, 4:27 AM +0100) d5a54122 3f37bd54

+197 -43
+4 -3
README.md
··· 48 48 49 49 ```sh 50 50 pty # interactive session manager 51 - pty run myserver -- node server.js # start a session and attach 52 - pty run -d myserver -- node server.js # start in the background 53 - pty run -a myserver -- node server.js # create or attach if already running 51 + pty run -- node server.js # start a session (auto-named) 52 + pty run --name myserver -- node server.js # start with an explicit name 53 + pty run -d -- node server.js # start in the background 54 + pty run -a -- node server.js # create or attach if already running 54 55 55 56 pty list # show active sessions 56 57 pty list --json # show as JSON
+1 -1
completions/pty.bash
··· 49 49 done 50 50 # Before --, complete flags 51 51 if [[ "${cur}" == -* ]]; then 52 - COMPREPLY=($(compgen -W "--detach -d --attach -a" -- "${cur}")) 52 + COMPREPLY=($(compgen -W "--detach -d --attach -a --name" -- "${cur}")) 53 53 fi 54 54 ;; 55 55 esac
+1
completions/pty.fish
··· 42 42 # run: flags and file completion for the command argument 43 43 complete -c pty -n '__pty_using_command run' -s d -l detach -d 'Create in background' 44 44 complete -c pty -n '__pty_using_command run' -s a -l attach -d 'Attach if already running' 45 + complete -c pty -n '__pty_using_command run' -l name -x -d 'Session name (auto-generated if omitted)' 45 46 complete -c pty -n '__pty_using_command run' -F 46 47 47 48 # attach: session names and flags
+1 -1
completions/pty.zsh
··· 79 79 _arguments \ 80 80 '(-d --detach)'{-d,--detach}'[Create in background]' \ 81 81 '(-a --attach)'{-a,--attach}'[Attach if already running]' \ 82 - '1:name:' 82 + '--name[Session name]:name:' 83 83 ;; 84 84 esac 85 85 ;;
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-ZE3elAZKDLlig0Pe6KrGvLO8xlmF+9WqYHl3v0KFfXs="; 32 + npmDepsHash = "sha256-7YDvTRV8QFBeqnYRW3mtBgGNWHwf7uCrsysv1O0Lau4="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
+3 -2
package-lock.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@myobie/pty", 9 - "version": "0.1.0", 9 + "version": "0.2.0", 10 + "hasInstallScript": true, 10 11 "license": "MIT", 11 12 "dependencies": { 12 13 "@xterm/addon-serialize": "^0.14.0",
+4 -10
package.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.1.3", 3 + "version": "0.2.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", ··· 41 41 "scripts": { 42 42 "build": "tsc -p tsconfig.build.json", 43 43 "prepublishOnly": "npm run build", 44 - "prepare": "git config core.hooksPath githooks 2>/dev/null || true; chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true; sh scripts/update-nix-hash.sh 2>/dev/null || true", 44 + "postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true", 45 + "prepare": "git config core.hooksPath githooks 2>/dev/null || true; sh scripts/update-nix-hash.sh 2>/dev/null || true", 45 46 "install-completions": "sh scripts/install-completions.sh", 46 47 "typecheck": "tsc", 47 48 "test": "vitest run", ··· 49 50 "verify-docs": "node scripts/verify-docs.ts" 50 51 }, 51 52 "dependencies": { 53 + "@preact/signals-core": "^1.14.0", 52 54 "@xterm/addon-serialize": "^0.14.0", 53 55 "@xterm/headless": "^6.0.0", 54 56 "node-pty": "^1.0.0" 55 - }, 56 - "peerDependencies": { 57 - "@preact/signals-core": "^1.14.0" 58 - }, 59 - "peerDependenciesMeta": { 60 - "@preact/signals-core": { 61 - "optional": true 62 - } 63 57 }, 64 58 "devDependencies": { 65 59 "@preact/signals-core": "^1.14.0",
+78 -25
src/cli.ts
··· 20 20 function usage(): void { 21 21 console.log(`Usage: 22 22 pty Interactive session manager 23 - pty run <name> <command> [args...] Create a session and attach 24 - pty run -d <name> <command> [args...] Create a session in the background 25 - pty run -a <name> <command> [args...] Create or attach if already running 23 + pty run -- <command> [args...] Create a session and attach (auto-named) 24 + pty run --name <n> -- <command> [args...] Create a named session and attach 25 + pty run -d -- <command> [args...] Create in the background 26 + pty run -a -- <command> [args...] Create or attach if already running 26 27 pty attach <name> Attach to an existing session 27 28 pty attach -r <name> Attach, auto-restart if exited 28 29 pty peek <name> Print current screen and exit ··· 43 44 Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 44 45 } 45 46 47 + /** Generate a session name from the cwd and command. */ 48 + function autoName(cmd: string, cmdArgs: string[]): string { 49 + // Directory component: last part of cwd 50 + const dirPart = path.basename(process.cwd()); 51 + 52 + // Command component: base name of the command + first meaningful arg 53 + const cmdBase = path.basename(cmd); 54 + const firstArg = cmdArgs.find(a => !a.startsWith("-") && a.length < 30); 55 + let cmdPart = cmdBase; 56 + if (firstArg) { 57 + // Strip extension and path, keep only alphanumeric/dash/dot 58 + const argBase = path.basename(firstArg).replace(/\.[^.]+$/, ""); 59 + if (argBase && /^[a-zA-Z0-9._-]+$/.test(argBase)) { 60 + cmdPart = `${cmdBase}-${argBase}`; 61 + } 62 + } 63 + 64 + return `${dirPart}-${cmdPart}`; 65 + } 66 + 46 67 async function main(): Promise<void> { 47 68 const args = process.argv.slice(2); 48 69 ··· 61 82 } 62 83 63 84 case "run": { 64 - // Parse flags before positional args 85 + // Parse flags before the -- separator 65 86 let detach = false; 66 87 let attachExisting = false; 88 + let name: string | null = null; 67 89 let i = 1; 68 - while (i < args.length && args[i].startsWith("-") && args[i] !== "--") { 69 - if (args[i] === "-d" || args[i] === "--detach") detach = true; 70 - else if (args[i] === "-a" || args[i] === "--attach") attachExisting = true; 90 + while (i < args.length && args[i] !== "--") { 91 + if (args[i] === "-d" || args[i] === "--detach") { detach = true; i++; } 92 + else if (args[i] === "-a" || args[i] === "--attach") { attachExisting = true; i++; } 93 + else if (args[i] === "--name" && i + 1 < args.length) { name = args[i + 1]; i += 2; } 71 94 else break; 72 - i++; 95 + // Note: unknown flags or positional args before -- break the loop 73 96 } 74 - const runArgs = args.slice(i); 75 97 76 - const dashDash = runArgs.indexOf("--"); 77 - let name: string; 98 + // Everything after -- is the command 99 + const dashDash = args.indexOf("--", i); 78 100 let cmd: string; 79 101 let cmdArgs: string[]; 80 102 81 103 if (dashDash !== -1) { 82 - if (dashDash !== 1) { 83 - console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]"); 84 - process.exit(1); 104 + // Anything between flags and -- that isn't a flag is a legacy positional name 105 + const between = args.slice(i, dashDash); 106 + if (between.length > 0 && !name) { 107 + // Backward compat: pty run myserver -- node server.js 108 + name = between[0]; 109 + console.error(`Hint: use --name instead: pty run --name ${name} -- ...`); 85 110 } 86 - name = runArgs[0]; 87 - cmd = runArgs[dashDash + 1]; 88 - cmdArgs = runArgs.slice(dashDash + 2); 111 + cmd = args[dashDash + 1]; 112 + cmdArgs = args.slice(dashDash + 2); 89 113 } else { 90 - name = runArgs[0]; 91 - cmd = runArgs[1]; 92 - cmdArgs = runArgs.slice(2); 114 + // No -- separator: legacy positional format 115 + // pty run myserver node server.js 116 + const rest = args.slice(i); 117 + if (!name && rest.length >= 2) { 118 + name = rest[0]; 119 + cmd = rest[1]; 120 + cmdArgs = rest.slice(2); 121 + console.error(`Hint: use --name instead: pty run --name ${name} -- ${cmd} ${cmdArgs.join(" ")}`.trimEnd()); 122 + } else { 123 + cmd = rest[0]; 124 + cmdArgs = rest.slice(1); 125 + } 93 126 } 94 127 95 - if (!name || !cmd) { 96 - console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]"); 128 + if (!cmd) { 129 + console.error("Usage: pty run [--name <name>] [-d] [-a] -- <command> [args...]"); 97 130 process.exit(1); 98 131 } 132 + 133 + const displayCmd = cmd; 99 134 try { 100 - validateName(name); 135 + cmd = resolveCommand(cmd); 101 136 } catch (e: any) { 102 137 console.error(e.message); 103 138 process.exit(1); 104 139 } 105 - const displayCmd = cmd; 140 + 141 + // Auto-generate name if not provided 142 + if (!name) { 143 + const sessions = await listSessions(); 144 + const existing = new Set(sessions.map(s => s.name)); 145 + let candidate = autoName(displayCmd, cmdArgs); 146 + // Sanitize: validateName allows [a-zA-Z0-9._-] 147 + candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 148 + // Dedup 149 + if (existing.has(candidate)) { 150 + for (let n = 2; ; n++) { 151 + const c = `${candidate}-${n}`; 152 + if (!existing.has(c)) { candidate = c; break; } 153 + } 154 + } 155 + name = candidate; 156 + } 157 + 106 158 try { 107 - cmd = resolveCommand(cmd); 159 + validateName(name); 108 160 } catch (e: any) { 109 161 console.error(e.message); 110 162 process.exit(1); 111 163 } 164 + 112 165 await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd); 113 166 break; 114 167 }
+38
src/tui/interactive.ts
··· 15 15 import { sortSessions, shortPath, timeAgo } from "./screen-list.ts"; 16 16 import { dedupName, listDirs } from "./screen-create.ts"; 17 17 18 + /** Generate a session name from dir and command. */ 19 + function autoName(dir: string, cmd: string, cmdArgs: string[]): string { 20 + const dirPart = path.basename(dir); 21 + const cmdBase = path.basename(cmd); 22 + const firstArg = cmdArgs.find(a => !a.startsWith("-") && a.length < 30); 23 + let cmdPart = cmdBase; 24 + if (firstArg) { 25 + const argBase = path.basename(firstArg).replace(/\.[^.]+$/, ""); 26 + if (argBase && /^[a-zA-Z0-9._-]+$/.test(argBase)) { 27 + cmdPart = `${cmdBase}-${argBase}`; 28 + } 29 + } 30 + const raw = `${dirPart}-${cmdPart}`; 31 + return raw.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 32 + } 33 + 18 34 // ============================================================ 19 35 // State (signals) 20 36 // ============================================================ ··· 32 48 const createSelectedIndex = signal(0); 33 49 const sessionName = signal(""); 34 50 const sessionCommand = signal(""); 51 + const nameManuallyEdited = signal(false); 35 52 const focusedField = signal<"name" | "command">("command"); 36 53 const existingNames = signal<Set<string>>(new Set()); 37 54 ··· 293 310 batch(() => { 294 311 createStep.set("name-command"); 295 312 sessionName.set(dedupName(path.basename(cwdPath.peek()), existingNames.peek())); 313 + nameManuallyEdited.set(false); 314 + sessionCommand.set(""); 296 315 }); 297 316 } else { 298 317 batch(() => { ··· 323 342 batch(() => { 324 343 createStep.set("name-command"); 325 344 sessionName.set(dedupName(path.basename(browsePath.peek()), existingNames.peek())); 345 + nameManuallyEdited.set(false); 346 + sessionCommand.set(""); 326 347 }); 327 348 } else if (idx === 1) { 328 349 const parent = path.dirname(browsePath.peek()); ··· 395 416 } 396 417 if (key.name === "backspace") { 397 418 if (focusedField.peek() === "name") { 419 + nameManuallyEdited.set(true); 398 420 sessionName.set(sessionName.peek().slice(0, -1)); 399 421 } else { 400 422 sessionCommand.set(sessionCommand.peek().slice(0, -1)); 423 + regenerateNameIfAuto(); 401 424 } 402 425 return true; 403 426 } 404 427 if (key.char && !key.ctrl && !key.alt) { 405 428 if (focusedField.peek() === "name") { 429 + nameManuallyEdited.set(true); 406 430 sessionName.set(sessionName.peek() + key.char); 407 431 } else { 408 432 sessionCommand.set(sessionCommand.peek() + key.char); 433 + regenerateNameIfAuto(); 409 434 } 410 435 return true; 411 436 } 412 437 return true; 438 + } 439 + 440 + function regenerateNameIfAuto(): void { 441 + if (nameManuallyEdited.peek()) return; 442 + const dir = browsePath.peek() !== cwdPath.peek() ? browsePath.peek() : cwdPath.peek(); 443 + const cmd = sessionCommand.peek().trim(); 444 + if (!cmd) { 445 + sessionName.set(dedupName(path.basename(dir), existingNames.peek())); 446 + return; 447 + } 448 + const parts = cmd.split(/\s+/); 449 + const name = autoName(dir, parts[0], parts.slice(1)); 450 + sessionName.set(dedupName(name, existingNames.peek())); 413 451 } 414 452 415 453 // ============================================================
+66
tests/tui.test.ts
··· 432 432 ); 433 433 434 434 it( 435 + "create wizard: name updates as command is typed", 436 + async () => { 437 + const sessionDir = makeSessionDir(); 438 + const tui = createTuiSession(sessionDir); 439 + 440 + await tui.waitForText("Create new session...", 10000); 441 + tui.press("return"); 442 + await tui.waitForText("Choose Directory", 5000); 443 + tui.press("return"); 444 + await tui.waitForText("Name:", 5000); 445 + 446 + // Name should start as the directory basename 447 + let ss = tui.screenshot(); 448 + const nameLine = ss.lines.find(l => l.includes("Name:")); 449 + expect(nameLine).toBeDefined(); 450 + 451 + // Type a command — the name should auto-update to include it 452 + tui.type("node server.js"); 453 + await new Promise(r => setTimeout(r, 300)); 454 + 455 + ss = tui.screenshot(); 456 + const updatedName = ss.lines.find(l => l.includes("Name:")); 457 + expect(updatedName).toBeDefined(); 458 + // Should contain "node-server" somewhere in the name field 459 + expect(updatedName).toMatch(/node-server/i); 460 + }, 461 + 15000 462 + ); 463 + 464 + it( 465 + "create wizard: manually edited name is not overwritten by command typing", 466 + async () => { 467 + const sessionDir = makeSessionDir(); 468 + const tui = createTuiSession(sessionDir); 469 + 470 + await tui.waitForText("Create new session...", 10000); 471 + tui.press("return"); 472 + await tui.waitForText("Choose Directory", 5000); 473 + tui.press("return"); 474 + await tui.waitForText("Name:", 5000); 475 + 476 + // Tab to name field, type a custom name 477 + tui.press("tab"); 478 + await new Promise(r => setTimeout(r, 200)); 479 + // Clear auto-filled name and type custom 480 + for (let i = 0; i < 30; i++) tui.press("backspace"); 481 + await new Promise(r => setTimeout(r, 200)); 482 + tui.type("custom"); 483 + await new Promise(r => setTimeout(r, 200)); 484 + 485 + // Tab back to command and type something 486 + tui.press("tab"); 487 + await new Promise(r => setTimeout(r, 200)); 488 + tui.type("echo hi"); 489 + await new Promise(r => setTimeout(r, 300)); 490 + 491 + // Name should still be "custom", not auto-generated 492 + const ss = tui.screenshot(); 493 + const nameLine = ss.lines.find(l => l.includes("Name:")); 494 + expect(nameLine).toContain("custom"); 495 + expect(nameLine).not.toContain("echo"); 496 + }, 497 + 15000 498 + ); 499 + 500 + it( 435 501 "empty state shows only 'Create new session...'", 436 502 async () => { 437 503 const sessionDir = makeSessionDir();