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