cli: add an ssh-wrapping +ssh action (#12582)

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 is contained in:
Mitchell Hashimoto
2026-05-22 09:04:36 -07:00
committed by GitHub
7 changed files with 686 additions and 342 deletions

View File

@@ -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");
@@ -47,6 +48,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",
@@ -148,6 +152,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),
@@ -189,6 +194,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,

635
src/cli/ssh.zig Normal file
View File

@@ -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] [--] <ssh args...>
\\
\\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> 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 <flags> -- "$@"
///
/// 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=<bool>`: Enable `TERM` / `SendEnv` environment
/// forwarding. Default: `true`.
///
/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
/// connection. Default: `true`.
///
/// * `--cache=<bool>`: 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=<host>` followed by a normal connection.
///
/// * `--ssh=<path>`: 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: <msg>` 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 <args>` 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.?);
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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