const std = @import("std"); const fs = std.fs; const Allocator = std.mem.Allocator; const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, host: ?[]const u8 = null, @"expire-days": ?u32 = null, pub fn deinit(self: *Options) void { _ = self; } pub fn help(self: Options) !void { _ = self; return Action.help_error; } }; /// Manage the SSH terminfo cache for automatic remote host setup. /// /// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, /// Ghostty automatically installs its terminfo on remote hosts. This command /// manages the cache of successful installations to avoid redundant uploads. /// /// The cache stores hostnames (or user@hostname combinations) along with timestamps. /// Entries older than the expiration period are automatically removed during cache /// operations. By default, entries never expire. /// /// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. /// If multiple are specified, one of the actions will be executed but /// it isn't guaranteed which one. This is entirely unsafe so you should split /// multiple actions into separate commands. /// /// Examples: /// ghostty +ssh-cache # List all cached hosts /// ghostty +ssh-cache --host=example.com # Check if host is cached /// ghostty +ssh-cache --add=example.com # Manually add host to cache /// ghostty +ssh-cache --add=user@example.com # Add user@host combination /// ghostty +ssh-cache --remove=example.com # Remove host from cache /// ghostty +ssh-cache --clear # Clear entire cache /// ghostty +ssh-cache --expire-days=30 # Set custom expiration period pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); const alloc = arena.allocator(); var opts: Options = .{}; defer opts.deinit(); { var iter = try args.argsIterator(alloc_gpa); defer iter.deinit(); try args.parse(Options, alloc_gpa, &opts, &iter); } var stdout_buffer: [1024]u8 = undefined; var stdout_file: std.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); const stdout = &stdout_writer.interface; 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; const result = runInner(alloc, opts, stdout, stderr); // Flushing *shouldn't* fail but... stdout.flush() catch {}; stderr.flush() catch {}; return result; } pub fn runInner( alloc: Allocator, opts: Options, stdout: *std.Io.Writer, stderr: *std.Io.Writer, ) !u8 { // Setup our disk cache to the standard location const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); const cache: DiskCache = .{ .path = cache_path }; if (opts.clear) { try cache.clear(); try stdout.print("Cache cleared.\n", .{}); return 0; } if (opts.add) |host| { const result = cache.add(alloc, host) catch |err| switch (err) { DiskCache.Error.HostnameIsInvalid => { try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); try stderr.print("Expected format: hostname or user@hostname\n", .{}); return 1; }, DiskCache.Error.CacheIsLocked => { try stderr.print("Error: Cache is busy, try again\n", .{}); return 1; }, else => { try stderr.print( "Error: Unable to add '{s}' to cache. Error: {}\n", .{ host, err }, ); return 1; }, }; switch (result) { .added => try stdout.print("Added '{s}' to cache.\n", .{host}), .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), } return 0; } if (opts.remove) |host| { cache.remove(alloc, host) catch |err| switch (err) { DiskCache.Error.HostnameIsInvalid => { try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); try stderr.print("Expected format: hostname or user@hostname\n", .{}); return 1; }, DiskCache.Error.CacheIsLocked => { try stderr.print("Error: Cache is busy, try again\n", .{}); return 1; }, else => { try stderr.print( "Error: Unable to remove '{s}' from cache. Error: {}\n", .{ host, err }, ); return 1; }, }; try stdout.print("Removed '{s}' from cache.\n", .{host}); return 0; } if (opts.host) |host| { const cached = cache.contains(alloc, host) catch |err| switch (err) { error.HostnameIsInvalid => { try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); try stderr.print("Expected format: hostname or user@hostname\n", .{}); return 1; }, else => { try stderr.print( "Error: Unable to check host '{s}' in cache. Error: {}\n", .{ host, err }, ); return 1; }, }; if (cached) { try stdout.print( "'{s}' has Ghostty terminfo installed.\n", .{host}, ); return 0; } else { try stdout.print( "'{s}' does not have Ghostty terminfo installed.\n", .{host}, ); return 1; } } // Default action: list all hosts var entries = try cache.list(alloc); defer DiskCache.deinitEntries(alloc, &entries); try listEntries(alloc, &entries, stdout); return 0; } fn listEntries( alloc: Allocator, entries: *const std.StringHashMap(Entry), writer: *std.Io.Writer, ) !void { if (entries.count() == 0) { try writer.print("No hosts in cache.\n", .{}); return; } // Sort entries by hostname for consistent output var items: std.ArrayList(Entry) = .empty; defer items.deinit(alloc); var iter = entries.iterator(); while (iter.next()) |kv| { try items.append(alloc, kv.value_ptr.*); } std.mem.sort(Entry, items.items, {}, struct { fn lessThan(_: void, a: Entry, b: Entry) bool { return std.mem.lessThan(u8, a.hostname, b.hostname); } }.lessThan); try writer.print("Cached hosts ({d}):\n", .{items.items.len}); const now = std.time.timestamp(); for (items.items) |entry| { const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); if (age_days == 0) { try writer.print(" {s} (today)\n", .{entry.hostname}); } else if (age_days == 1) { try writer.print(" {s} (yesterday)\n", .{entry.hostname}); } else { try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); } } } test { _ = DiskCache; _ = Entry; }