From 484d6ec66b0c27808341c05eb71416a39517ad03 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 4 May 2026 19:59:09 -0400 Subject: [PATCH 1/5] cli: add an ssh-wrapping +ssh action Add a drop-in `ssh` wrapper that sets up the remote environment for Ghostty. Anything not consumed as one of our own flags is forwarded to the real, wrapped `ssh` binary. It can be used directly (`ghostty +ssh user@host`), aliased (`alias ssh='ghostty +ssh --'`), or invoked through Ghostty's shell integration. Before exec'ing ssh, `+ssh`: - Forwards Ghostty environment to the remote (`--forward-env`): sets TERM=xterm-256color and requests SendEnv forwarding of COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. - Installs Ghostty's terminfo on the remote (`--terminfo`), informed by our existing `ssh-cache` system and using our internal xterm-ghostty terminfo representation. A third flag, `--cache`, controls cache use; `--cache=false` bypasses both read and write, which is useful for scripting and for debugging install failures without polluting the cache. For shell integration, this replaces the per-shell logic (which made up roughly a third of our shell integration scripts) with a simple wrapper function that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. This commit only migrates the bash integration; the other shells will follow separately. --- src/cli/ghostty.zig | 6 + src/cli/ssh.zig | 635 ++++++++++++++++++++++++ src/shell-integration/bash/ghostty.bash | 71 +-- 3 files changed, 649 insertions(+), 63 deletions(-) create mode 100644 src/cli/ssh.zig diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index 3acb90043..d86394daa 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const ssh = @import("ssh.zig"); const ssh_cache = @import("ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); @@ -46,6 +47,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Wrap `ssh` to configure Ghostty terminal integration on remote hosts + ssh, + /// Manage SSH terminfo cache for automatic remote host setup @"ssh-cache", @@ -144,6 +148,7 @@ pub const Action = enum { .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), .@"ssh-cache" => try ssh_cache.run(alloc), + .ssh => try ssh.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"explain-config" => try explain_config.run(alloc), @@ -184,6 +189,7 @@ pub const Action = enum { .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, .@"ssh-cache" => ssh_cache.Options, + .ssh => ssh.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"explain-config" => explain_config.Options, diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig new file mode 100644 index 000000000..7f808a6cd --- /dev/null +++ b/src/cli/ssh.zig @@ -0,0 +1,635 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli_args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const Action = @import("ghostty.zig").Action; +const DiskCache = @import("ssh_cache.zig").DiskCache; +const internal_os = @import("../os/main.zig"); +const ghostty_terminfo = @import("../terminfo/main.zig").ghostty; + +const log = std.log.scoped(.ssh); + +const usage = + \\Usage: ghostty +ssh [flags] [--] + \\ + \\Flags: + \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true. + \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true. + \\ --cache[=bool] Use the terminfo install cache. Default: true. + \\ --ssh= Path to the ssh binary. Default: first `ssh` on PATH. + \\ --verbose Print +ssh status lines to stderr. + \\ --help Show full help. + \\ + \\ssh flags and the destination go after +ssh's own flags (or after `--`). + \\ +; + +pub const Options = struct { + /// Set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// Maps to the `ssh-env` shell integration feature. + @"forward-env": bool = true, + + /// Maps to the `ssh-terminfo` shell integration feature. + terminfo: bool = true, + + /// When false, both cache read and write are bypassed. + cache: bool = true, + + /// The wrapped `ssh` binary. + /// `/`-containing values are treated as paths; otherwise resolved via PATH. + ssh: []const u8 = "ssh", + + /// When true, print verbose output to stderr. + verbose: bool = false, + + /// Arguments passed through to `ssh` verbatim. Populated by + /// `parseManuallyHook` when we reach the first non-flag argument (or + /// an explicit `--`). + _ssh_args: std.ArrayList([]const u8) = .empty, + + /// Enables arg parsing diagnostics so unknown flags become + /// diagnostics rather than fatal errors. + _diagnostics: diagnostics.DiagnosticList = .{}, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables `-h` and `--help` to work. + pub fn help(_: Options) !void { + return Action.help_error; + } + + /// Manual parse hook. For each argument: + /// - If it's a literal `--`, consume everything after it as ssh + /// args and stop parsing. + /// - If it doesn't start with `--`, this is the start of the ssh + /// argv. Consume this arg and everything after as ssh args and + /// stop parsing. + /// - Otherwise (a `--foo` arg), return true so the generic parser + /// handles it as one of our own flags. + pub fn parseManuallyHook( + self: *Options, + alloc: Allocator, + arg: []const u8, + iter: anytype, + ) Allocator.Error!bool { + if (std.mem.eql(u8, arg, "--")) { + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + if (!std.mem.startsWith(u8, arg, "--")) { + try self._ssh_args.append(alloc, try alloc.dupe(u8, arg)); + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + return true; + } +}; + +/// Wrap `ssh` to automatically configure Ghostty terminal integration on +/// remote hosts. +/// +/// Any arguments that aren't recognized as `+ssh` flags are passed to +/// the real `ssh` binary unchanged. You can use `--` as an explicit +/// disambiguator if needed, though it's almost never required: `ssh` +/// has no long flags, and `+ssh` defines no short flags, so there's +/// nothing to collide. +/// +/// This is typically called via Ghostty's shell integration. When +/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`, +/// each shell defines an `ssh` function that runs: +/// +/// ghostty +ssh -- "$@" +/// +/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g. +/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell +/// integration. +/// +/// `+ssh` performs up to two pieces of setup before launching `ssh`: +/// +/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to +/// `xterm-256color` and requests `SendEnv` forwarding of +/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the +/// remote shell can still detect that it's running inside Ghostty. +/// The remote `sshd_config` must list these in `AcceptEnv` for +/// forwarding to succeed. +/// +/// 2. **Terminfo install** (`--terminfo`). On the first connection to a +/// given destination, installs Ghostty's terminfo entry on the remote +/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a +/// shared `ControlMaster` connection. Successful installs are cached +/// (see `ghostty +ssh-cache`) so subsequent connections skip this +/// step. When terminfo is successfully installed or already cached, +/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`. +/// +/// If `--terminfo` install fails (e.g. `tic` not available on the +/// remote, filesystem permissions), a warning is logged and the +/// connection continues with `TERM=xterm-256color`. +/// +/// Flags: +/// +/// * `--forward-env=`: Enable `TERM` / `SendEnv` environment +/// forwarding. Default: `true`. +/// +/// * `--terminfo=`: Enable automatic terminfo install on first +/// connection. Default: `true`. +/// +/// * `--cache=`: Use the terminfo install cache. Default: `true`. +/// When `false`, both the cache read (skip-if-installed) and the +/// cache write (record-on-success) are bypassed, and every +/// connection performs the install. To one-shot reinstall a single +/// host while keeping the cache in use, prefer `ghostty +ssh-cache +/// --remove=` followed by a normal connection. +/// +/// * `--ssh=`: Path to the `ssh` binary to execute. Default: the +/// first `ssh` found on `PATH`. +/// +/// * `--verbose`: Print +ssh status lines to stderr, and surface +/// remote stderr during the terminfo install. +/// +/// Examples: +/// +/// # Basic invocation using defaults: +/// ghostty +ssh user@example.com +/// +/// # Forward Ghostty env vars but skip the terminfo install: +/// ghostty +ssh --terminfo=false user@example.com +/// +/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: +/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com +/// +/// # Use `--` explicitly if your ssh args might collide with our flags: +/// ghostty +ssh -- --some-rare-ssh-arg user@example.com +/// +/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection +/// and management, see `ghostty +ssh-cache`. +/// +/// Available since: 1.4.0 +pub fn run(alloc_gpa: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try cli_args.argsIterator(alloc_gpa); + defer iter.deinit(); + try cli_args.parse(Options, alloc_gpa, &opts, &iter); + } + + var stderr_buffer: [1024]u8 = undefined; + var stderr_file: std.fs.File = .stderr(); + var stderr_writer = stderr_file.writer(&stderr_buffer); + const stderr = &stderr_writer.interface; + + // Any diagnostic from the arg parser is an unknown flag or bad + // value. Reject loudly — silently forwarding `--typo` to ssh would + // produce confusing downstream errors. + if (!opts._diagnostics.empty()) { + for (opts._diagnostics.items()) |diag| { + if (diag.key.len > 0) { + stderr.print( + "Error: unknown flag `--{s}`.\n", + .{diag.key}, + ) catch {}; + } else { + stderr.print("Error: {s}\n", .{diag.message}) catch {}; + } + } + stderr.print("\n{s}", .{usage}) catch {}; + stderr.flush() catch {}; + return 2; + } + + const result = runInner(alloc_gpa, &opts, stderr); + + stderr.flush() catch {}; + return result; +} + +fn runInner( + gpa: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !u8 { + var arena = ArenaAllocator.init(gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + if (opts._ssh_args.items.len == 0) { + try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage}); + return 2; + } + + const session: struct { + term: []const u8, + to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null, + } = session: { + if (!opts.terminfo) break :session .{ .term = "xterm-256color" }; + + const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse { + warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{}); + break :session .{ .term = "xterm-256color" }; + }; + + const cache: ?DiskCache = if (opts.cache) cache: { + const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| { + warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :cache .{ .path = path }; + } else null; + + if (cache) |c| { + if (c.contains(alloc, dest) catch false) { + verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest}); + break :session .{ .term = "xterm-ghostty" }; + } else { + verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest}); + } + } else { + verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest}); + } + + stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {}; + stderr.flush() catch {}; + + installRemoteTerminfo(alloc, opts, stderr) catch |err| { + warnPrint(stderr, "failed to install terminfo: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :session .{ + .term = "xterm-ghostty", + .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null, + }; + }; + + // Build the full argv: [ssh, ...our opts, ...user args] + const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: { + const set_term = try std.fmt.allocPrint( + alloc, + "SetEnv=TERM={s}", + .{session.term}, + ); + break :env_opts &.{ + "-o", set_term, + "-o", "SendEnv=COLORTERM", + "-o", "SendEnv=TERM_PROGRAM", + "-o", "SendEnv=TERM_PROGRAM_VERSION", + }; + } else &.{}; + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + env_opts, + opts._ssh_args.items, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + const exit_code = childExec(alloc, argv) catch |err| { + try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err }); + return 1; + }; + verbosePrint(opts, stderr, "exit: {d}", .{exit_code}); + + // Attempt to cache (if needed) on a successful ssh execution. + if (exit_code == 0) if (session.to_cache) |entry| { + if (entry.cache.add(alloc, entry.dest)) |_| { + verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); + } else |err| { + log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); + } + }; + + return exit_code; +} + +/// Log to `.ssh` and, if `--verbose`, also print to stderr. +fn verbosePrint( + opts: *const Options, + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.debug(fmt, args); + if (!opts.verbose) return; + stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Log a warning and also print a `Warning: ` line to stderr. +fn warnPrint( + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.warn(fmt, args); + stderr.print("Warning: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Space-joined items, formattable as `{f}`. +const Joined = struct { + items: []const []const u8, + + pub fn format(self: Joined, writer: *std.Io.Writer) !void { + for (self.items, 0..) |a, i| { + if (i > 0) try writer.writeByte(' '); + try writer.writeAll(a); + } + } + + test { + const testing = std.testing; + var buf: [128]u8 = undefined; + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{} }}); + try testing.expectEqualStrings("", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{"only"} }}); + try testing.expectEqualStrings("only", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }}); + try testing.expectEqualStrings("a b c", buf[0..w.end]); + } + } +}; + +fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void { + switch (term) { + .Exited => |rc| if (rc != 0) { + log.warn("{s} exited with non-zero status: {d}", .{ label, rc }); + return error.ChildFailed; + }, + else => { + log.warn("{s} terminated abnormally: {}", .{ label, term }); + return error.ChildFailed; + }, + } +} + +/// Run `ssh -G ` and parse the output for `user` and `hostname`. +/// Returns the resolved `user@hostname`, or null if the destination +/// could not be resolved. +fn resolveDestination( + alloc: Allocator, + ssh: []const u8, + args: []const []const u8, +) ?[]const u8 { + const argv = std.mem.concat(alloc, []const u8, &.{ + &.{ ssh, "-G" }, + args, + }) catch return null; + const result = std.process.Child.run(.{ + .allocator = alloc, + .argv = argv, + }) catch |err| { + log.warn("ssh -G spawn failed: {}", .{err}); + return null; + }; + checkExit(result.term, "ssh -G") catch return null; + return parseDestination(alloc, result.stdout); +} + +/// Parse `ssh -G` output for `user` and `hostname` and return the +/// formatted `user@hostname`. Returns null if either key is missing +/// or formatting fails. +fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 { + var user: []const u8 = ""; + var host: []const u8 = ""; + var it = std.mem.tokenizeScalar(u8, stdout, '\n'); + while (it.next()) |line| { + const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue; + const key = line[0..space]; + const value = line[space + 1 ..]; + if (std.mem.eql(u8, key, "user")) { + user = value; + } else if (std.mem.eql(u8, key, "hostname")) { + host = value; + } + if (user.len > 0 and host.len > 0) break; + } + + if (user.len == 0) { + log.warn("ssh -G output missing user", .{}); + return null; + } + if (host.len == 0) { + log.warn("ssh -G output missing hostname", .{}); + return null; + } + + return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null; +} + +/// Install Ghostty's terminfo on the remote host over a short-lived SSH +/// ControlMaster connection. The master tears down with the client +/// (`ControlPersist=no`) so no socket lingers. +fn installRemoteTerminfo( + alloc: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !void { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try ghostty_terminfo.encode(&buf.writer); + const terminfo = buf.written(); + + // ControlPath is in TMPDIR with a short, random basename. ssh uses + // ControlPath as the bind address for a Unix domain socket; macOS + // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path + // short leaves margin. + const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-"); + const control_path_opt = try std.fmt.allocPrint( + alloc, + "ControlPath={s}", + .{control_path}, + ); + + // Under --verbose, let remote stderr through (the `tic` step is + // the most common failure source) and inherit ssh's stderr so it + // reaches the user's terminal. Other steps stay quiet either way. + const remote_script = if (opts.verbose) + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 + \\exit 1 + else + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + \\exit 1 + ; + + // Set up an SSH ControlMaster scoped to this single install: + // - ControlMaster=yes makes our client also act as the master, + // so `infocmp | ssh tic` runs over a single connection. + // - ControlPersist=no tears the master down when our client + // exits; no socket lingers on the remote side. + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + &.{ + "-o", "ControlMaster=yes", + "-o", "ControlPersist=no", + "-o", control_path_opt, + }, + opts._ssh_args.items, + &.{remote_script}, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Ignore; + child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore; + + child.spawn() catch |err| { + log.warn("terminfo install spawn failed: {}", .{err}); + return error.InstallFailed; + }; + + if (child.stdin) |stdin| { + stdin.writeAll(terminfo) catch {}; + stdin.close(); + child.stdin = null; + } + + const term = child.wait() catch |err| { + log.warn("terminfo install wait failed: {}", .{err}); + return error.InstallFailed; + }; + checkExit(term, "terminfo install") catch return error.InstallFailed; +} + +/// Returns `128 + signum` for signal-killed children, matching shell convention. +fn childExec(alloc: Allocator, argv: []const []const u8) !u8 { + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + try child.spawn(); + const term = try child.wait(); + return switch (term) { + .Exited => |rc| rc, + .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))), + .Stopped, .Unknown => 1, + }; +} + +fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void { + var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line); + defer iter.deinit(); + try cli_args.parse(Options, alloc, opts, &iter); +} + +test "parseManuallyHook: bare destination starts ssh args" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com"); + try testing.expectEqual(false, opts.terminfo); + try testing.expectEqual(true, opts.@"forward-env"); + try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]); +} + +test "parseManuallyHook: short ssh flags pass through verbatim" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com"); + try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len); + try testing.expectEqualStrings("-p", opts._ssh_args.items[0]); + try testing.expectEqualStrings("2222", opts._ssh_args.items[1]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]); +} + +test "parseManuallyHook: explicit -- separator" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs( + testing.allocator, + &opts, + "--verbose -- --some-rare-ssh-arg user@example.com", + ); + try testing.expectEqual(true, opts.verbose); + try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len); + try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]); +} + +test "parseDestination: typical ssh -G output" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\user alice + \\hostname example.com + \\port 22 + \\identityfile ~/.ssh/id_ed25519 + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: hostname before user" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\hostname example.com + \\port 22 + \\user alice + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: missing hostname returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: missing user returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "hostname example.com\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: empty input returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), "")); +} + +test "parseDestination: IPv6 hostname" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nhostname ::1\n"; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@::1", result.?); +} diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 7eaf1397b..729951ab9 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -115,71 +115,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi # SSH Integration +# +# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration +# feature flags into command options. if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then function ssh() { - builtin local ssh_term ssh_opts - ssh_term="xterm-256color" - ssh_opts=() - - # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") - fi - - # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - builtin local ssh_user ssh_hostname - - while IFS=' ' read -r ssh_key ssh_value; do - case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; - esac - [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done < <(builtin command ssh -G "$@" 2>/dev/null) - - if [[ -n "$ssh_hostname" ]]; then - builtin local ssh_target="${ssh_user}@${ssh_hostname}" - - # Check if terminfo is already cached - if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then - ssh_term="xterm-ghostty" - elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local ssh_terminfo ssh_cpath_dir ssh_cpath - - ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - - if [[ -n "$ssh_terminfo" ]]; then - builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") - - # Cache successful installation - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else - builtin echo "Warning: Failed to install terminfo." >&2 - fi - else - builtin echo "Warning: Could not generate terminfo data." >&2 - fi - else - builtin echo "Warning: ghostty command not available for cache management." >&2 - fi - fi - fi - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor builtin command ssh "${ssh_opts[@]}" "$@" + builtin local -a flags + flags=() + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false) + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false) + "$GHOSTTY_BIN_DIR/ghostty" +ssh "${flags[@]}" -- "$@" } fi From 2d112059a7e87104a483866ff472638e3ebd81b1 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:09:58 -0400 Subject: [PATCH 2/5] zsh: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. The shell wrapper no longer carries terminfo install, ControlMaster wiring, or cache bookkeeping; it just maps the feature flags to flags on `+ssh` and forwards everything else. --- src/shell-integration/zsh/ghostty-integration | 73 ++----------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 76c5ce246..a96ff4bb0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -311,74 +311,15 @@ _ghostty_deferred_init() { fi # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then function ssh() { - emulate -L zsh - setopt local_options no_glob_subst - - local ssh_term ssh_opts - ssh_term="xterm-256color" - ssh_opts=() - - # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") - fi - - # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - local ssh_user ssh_hostname - - while IFS=' ' read -r ssh_key ssh_value; do - case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; - esac - [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done < <(command ssh -G "$@" 2>/dev/null) - - if [[ -n "$ssh_hostname" ]]; then - local ssh_target="${ssh_user}@${ssh_hostname}" - - # Check if terminfo is already cached - if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then - ssh_term="xterm-ghostty" - elif (( $+commands[infocmp] )); then - local ssh_terminfo ssh_cpath_dir ssh_cpath - - ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - - if [[ -n "$ssh_terminfo" ]]; then - print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - if builtin print -r "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") - - # Cache successful installation - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else - print "Warning: Failed to install terminfo." >&2 - fi - else - print "Warning: Could not generate terminfo data." >&2 - fi - else - print "Warning: ghostty command not available for cache management." >&2 - fi - fi - fi - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor command ssh "${ssh_opts[@]}" "$@" + local flags=() + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false) + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false) + "$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- "$@" } fi From 283dca130e02945aed1d3f2eae43676d37782717 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:10:30 -0400 Subject: [PATCH 3/5] fish: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. --- .../ghostty-shell-integration.fish | 81 ++----------------- 1 file changed, 7 insertions(+), 74 deletions(-) diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index f8bfe0910..e0360b5ac 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -120,84 +120,17 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") if contains ssh-env $features; or contains ssh-terminfo $features function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") - set -l ssh_term xterm-256color - set -l ssh_opts - - # Configure environment variables for remote session - if contains ssh-env $features - set -a ssh_opts -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" - end - - # Install terminfo on remote host if needed - if contains ssh-terminfo $features - set -l ssh_user - set -l ssh_hostname - - for line in (command ssh -G $argv 2>/dev/null) - set -l parts (string split ' ' -- $line) - if test (count $parts) -ge 2 - switch $parts[1] - case user - set ssh_user $parts[2] - case hostname - set ssh_hostname $parts[2] - end - if test -n "$ssh_user"; and test -n "$ssh_hostname" - break - end - end - end - - if test -n "$ssh_hostname" - set -l ssh_target "$ssh_user@$ssh_hostname" - - # Check if terminfo is already cached - if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1 - set ssh_term xterm-ghostty - else if command -q infocmp - set -l ssh_terminfo - set -l ssh_cpath_dir - set -l ssh_cpath - - set ssh_terminfo "$(infocmp -0 -x xterm-ghostty 2>/dev/null)" - - if test -n "$ssh_terminfo" - echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) - set ssh_cpath "$ssh_cpath_dir/socket" - - if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null - set ssh_term xterm-ghostty - set -a ssh_opts -o "ControlPath=$ssh_cpath" - - # Cache successful installation - if test -x "$GHOSTTY_BIN_DIR/ghostty" - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true - end - else - echo "Warning: Failed to install terminfo." >&2 - end - else - echo "Warning: Could not generate terminfo data." >&2 - end - else - echo "Warning: ghostty command not available for cache management." >&2 - end - end - end - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor command ssh $ssh_opts $argv + set -l flags + contains ssh-env $features; or set -a flags --forward-env=false + contains ssh-terminfo $features; or set -a flags --terminfo=false + "$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- $argv end end From e5378107eb8ffff72ba98eab45092b569f56bcf0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:11:22 -0400 Subject: [PATCH 4/5] elvish: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. --- .../elvish/lib/ghostty-integration.elv | 82 +++---------------- 1 file changed, 11 insertions(+), 71 deletions(-) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 31ebf0941..fc334c378 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -76,80 +76,20 @@ (external sudo) $@args } + # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. fn ssh-integration {|@args| - var ssh-term = "xterm-256color" - var ssh-opts = [] - - # Configure environment variables for remote session - if (has-value $features ssh-env) { - set ssh-opts = (conj $ssh-opts ^ - -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") + var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" + var flags = [] + if (not (has-value $features ssh-env)) { + set flags = (conj $flags --forward-env=false) } - - if (has-value $features ssh-terminfo) { - var ssh-user = "" - var ssh-hostname = "" - - # Parse ssh config - for line [((external ssh) -G $@args)] { - var parts = [(str:fields $line)] - if (> (count $parts) 1) { - var ssh-key = $parts[0] - var ssh-value = $parts[1] - if (eq $ssh-key user) { - set ssh-user = $ssh-value - } elif (eq $ssh-key hostname) { - set ssh-hostname = $ssh-value - } - if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { - break - } - } - } - - if (not-eq $ssh-hostname "") { - var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" - var ssh-target = $ssh-user"@"$ssh-hostname - - # Check if terminfo is already cached - if (bool ?($ghostty +ssh-cache --host=$ssh-target)) { - set ssh-term = "xterm-ghostty" - } elif (has-external infocmp) { - var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp) - - if (not-eq $ssh-terminfo "") { - echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2 - - use os - var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*") - var ssh-cpath = $ssh-cpath-dir"/socket" - - if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null)) { - set ssh-term = "xterm-ghostty" - set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) - - # Cache successful installation - $ghostty +ssh-cache --add=$ssh-target >/dev/null - } else { - echo "Warning: Failed to install terminfo." >&2 - } - } else { - echo "Warning: Could not generate terminfo data." >&2 - } - } else { - echo "Warning: ghostty command not available for cache management." >&2 - } - } - } - - with [E:TERM = $ssh-term E:COLORTERM = truecolor] { - (external ssh) $@ssh-opts $@args + if (not (has-value $features ssh-terminfo)) { + set flags = (conj $flags --terminfo=false) } + $ghostty +ssh $@flags -- $@args } defer { From ac103b8f75f5206af3cc98deecc2b7d0f9705683 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:12:07 -0400 Subject: [PATCH 5/5] nushell: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. When no ssh-* feature is enabled, the wrapper falls through to the real `ssh` binary unchanged so nushell users without ssh integration get plain ssh behavior. --- .../nushell/vendor/autoload/ghostty.nu | 80 +++---------------- 1 file changed, 12 insertions(+), 68 deletions(-) diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 17970f513..b2731f8a8 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -4,79 +4,23 @@ export module ghostty { $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') } - # Wrap `ssh` with Ghostty TERMINFO support + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. export def --wrapped ssh [...args] { - mut ssh_env = {} - mut ssh_opts = [] - - # `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars - if (has_feature "ssh-env") { - $ssh_env.TERM = "xterm-256color" - $ssh_env.COLORTERM = "truecolor" - $ssh_opts = [ - "-o" "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" - ] + if not ((has_feature "ssh-env") or (has_feature "ssh-terminfo")) { + ^ssh ...$args + return } - # `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts - if (has_feature "ssh-terminfo") { - let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty" - - let ssh_cfg = ^ssh -G ...$args - | lines - | parse "{key} {value}" - | where key in ["user" "hostname"] - | select key value - | transpose -rd - | default {user: $env.USER hostname: "localhost"} - let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" - - if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) { - $ssh_env.TERM = "xterm-ghostty" - } else { - $ssh_env.TERM = "xterm-256color" - - let terminfo = try { - ^infocmp -0 -x xterm-ghostty - } catch { - print -e "infocmp failed, using xterm-256color" - } - - if ($terminfo | is-not-empty) { - print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." - - let ctrl_path = ( - mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" - | path join "socket" - ) - - let remote_args = $ssh_opts ++ [ - "-o" "ControlMaster=yes" - "-o" $"ControlPath=($ctrl_path)" - "-o" "ControlPersist=60s" - ] ++ $args - - $terminfo | ^ssh ...$remote_args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1' - | complete - | if $in.exit_code == 0 { - ^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e - $ssh_env.TERM = "xterm-ghostty" - $ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"]) - } else { - print -e "terminfo install failed, using xterm-256color" - } - } - } + let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty" + mut flags = [] + if not (has_feature "ssh-env") { + $flags = ($flags ++ ["--forward-env=false"]) } - - let ssh_args = $ssh_opts ++ $args - with-env $ssh_env { - ^ssh ...$ssh_args + if not (has_feature "ssh-terminfo") { + $flags = ($flags ++ ["--terminfo=false"]) } + ^$ghostty "+ssh" ...$flags "--" ...$args } # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable