feat(cli): port ghostty +action CLI commands

Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with
per-action --help, and the actions help, version, list-actions,
list-colors, list-keybinds, list-themes (plain listing plus an
interactive ratatui browser mirroring Ghostty's libvaxis UI),
show-config, validate-config, edit-config, crash-report (listing),
ssh-cache and boo (235 embedded frames).

Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout,
stderr and exit codes of most actions, incl. tty output in a pty at
widths 20-200. Known differences: +list-colors --plain prints all 782
colors (Ghostty truncates at 617 because it never flushes), +version
has placeholder build info until build_info::set is wired up.

Deferred: list-fonts, show-face (need font discovery integration) and
new-window (needs GTK IPC); they return NotYetAvailable for now.

Test Plan:
- cargo test -p ghostty-cli (86 tests)
- cargo run -p ghostty-cli --example cli -- +list-keybinds --plain
  and diff against `ghostty +list-keybinds --plain`
This commit is contained in:
ddidderr committed 2026-10-11 22:22:01 +02:00
1 parent 5462bd8e3e
commit f2c4b40062
46 files changed
+7135 -6

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
//! `+edit-config` (ZIG/src/cli/edit_config.zig).
use std::io::Write;
use std::os::unix::process::CommandExt;
use ghostty_config::config::Config;
use crate::error::Error;
pub use crate::options::NoOptions as Options;
/// The `edit-config` command opens the Ghostty configuration file in the
/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
///
/// IMPORTANT: This command will not reload the configuration after
/// editing. You will need to manually reload the configuration using the
/// application menu, configured keybind, or by restarting Ghostty. We
/// plan to auto-reload in the future, but Ghostty isn't capable of
/// this yet.
///
/// The filepath opened is the default user-specific configuration
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`.
/// On macOS, this may also be located at
/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.
/// On macOS, whichever path exists and is non-empty will be prioritized,
/// prioritizing the Application Support directory if neither are
/// non-empty.
///
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
/// if both are set. If neither are set, it will print an error
/// and exit.
pub fn run(args: &[String]) -> Result<u8, Error> {
let mut stderr = std::io::stderr().lock();
let mut opts = Options;
crate::parse_options(&mut opts, args)?;
let result = run_inner(&mut stderr);
// Flushing *shouldn't* fail but...
let _ = stderr.flush();
result
}
/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped).
#[must_use]
pub fn find_editor() -> Option<String> {
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
for var in ["VISUAL", "EDITOR"] {
if let Some(v) = ghostty_os::getenv(var) {
if !v.is_empty() {
return Some(v);
}
}
}
None
}
fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
// We load the configuration once because that will write our
// default configuration files to disk. We don't use the config.
let _config = Config::load()?;
// Find the preferred path.
let path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))?;
// Get our editor
let Some(editor) = find_editor() else {
// If we don't have `$EDITOR` set then we can't do anything
// but we can still print a helpful message.
write!(
stderr,
"The $EDITOR or $VISUAL environment variable is not set or is empty.\n\
This environment variable is required to edit the Ghostty configuration\n\
via this CLI command.\n\
\n\
Please set the environment variable to your preferred terminal\n\
text editor and try again.\n\
\n\
If you prefer to edit the configuration file another way,\n\
you can find the configuration file at the following path:\n\
\n"
)?;
// Output the path using the OSC8 sequence so that it is linked.
writeln!(stderr, "\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\")?;
return Ok(1);
};
// Flush before exec so no output is lost.
let _ = stderr.flush();
let err = std::process::Command::new(&editor).arg(&path).exec();
// If we reached this point then exec failed.
let err = match err.kind() {
std::io::ErrorKind::NotFound => "error.FileNotFound".to_string(),
std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(),
_ => err.to_string(),
};
write!(
stderr,
"Failed to execute the editor. Error code={err}.\n\
\n\
This is usually due to the executable path not existing, invalid\n\
permissions, or the shell environment not being set up\n\
correctly.\n\
\n\
Editor: {editor}\n\
Path: {path}\n"
)?;
Ok(1)
}