mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-23 21:30:19 +00:00
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:
@@ -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
635
src/cli/ssh.zig
Normal 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.?);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user