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

+331
View File
@@ -0,0 +1,331 @@
//! The CLI root (ZIG/src/cli/ghostty.zig): the [`Action`] enum, `+action`
//! dispatch and per-action `--help` output.
use std::io::Write;
use crate::action::{ActionEnum, DetectError, SpecialCase, detect_iter};
use crate::error::Error;
/// Special commands that can be invoked via CLI flags. These are all
/// invoked by using `+<action>` as a CLI flag. The only exception is
/// "version" which can be invoked additionally with `--version`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
/// Output the version and exit
Version,
/// Output help information for the CLI or configuration
Help,
/// List available fonts
ListFonts,
/// List available keybinds
ListKeybinds,
/// List available themes
ListThemes,
/// List named RGB colors
ListColors,
/// List keybind actions
ListActions,
/// Manage SSH terminfo cache for automatic remote host setup
SshCache,
/// Edit the config file in the configured terminal editor.
EditConfig,
/// Dump the config to stdout
ShowConfig,
// Validate passed config file
ValidateConfig,
// Show which font face Ghostty loads a codepoint from.
ShowFace,
// List, (eventually) view, and (eventually) send crash reports.
CrashReport,
// Boo!
Boo,
// Use IPC to tell the running Ghostty to open a new window.
NewWindow,
}
impl Action {
/// All actions in declaration order.
pub const ALL: &'static [Action] = &[
Action::Version,
Action::Help,
Action::ListFonts,
Action::ListKeybinds,
Action::ListThemes,
Action::ListColors,
Action::ListActions,
Action::SshCache,
Action::EditConfig,
Action::ShowConfig,
Action::ValidateConfig,
Action::ShowFace,
Action::CrashReport,
Action::Boo,
Action::NewWindow,
];
/// The CLI name of the action (`@tagName`), e.g. `list-fonts`.
#[must_use]
pub fn name(self) -> &'static str {
match self {
Action::Version => "version",
Action::Help => "help",
Action::ListFonts => "list-fonts",
Action::ListKeybinds => "list-keybinds",
Action::ListThemes => "list-themes",
Action::ListColors => "list-colors",
Action::ListActions => "list-actions",
Action::SshCache => "ssh-cache",
Action::EditConfig => "edit-config",
Action::ShowConfig => "show-config",
Action::ValidateConfig => "validate-config",
Action::ShowFace => "show-face",
Action::CrashReport => "crash-report",
Action::Boo => "boo",
Action::NewWindow => "new-window",
}
}
/// `std.meta.stringToEnum`.
#[must_use]
pub fn from_name(name: &str) -> Option<Action> {
Action::ALL.iter().copied().find(|a| a.name() == name)
}
/// Returns the filename associated with an action. This is a relative
/// path from the root src/ directory of the Zig sources.
#[must_use]
pub fn file(self) -> String {
format!("cli/{}.zig", self.name().replace('-', "_"))
}
/// The help text of the action (the doc comment of the Zig `run`
/// function, i.e. `help_strings.Action.<name>`), without trailing newline.
#[must_use]
pub fn help_text(self) -> &'static str {
match self {
Action::Version => include_str!("help/version.txt"),
Action::Help => include_str!("help/help.txt"),
Action::ListFonts => include_str!("help/list-fonts.txt"),
Action::ListKeybinds => include_str!("help/list-keybinds.txt"),
Action::ListThemes => include_str!("help/list-themes.txt"),
Action::ListColors => include_str!("help/list-colors.txt"),
Action::ListActions => include_str!("help/list-actions.txt"),
Action::SshCache => include_str!("help/ssh-cache.txt"),
Action::EditConfig => include_str!("help/edit-config.txt"),
Action::ShowConfig => include_str!("help/show-config.txt"),
Action::ValidateConfig => include_str!("help/validate-config.txt"),
Action::ShowFace => include_str!("help/show-face.txt"),
Action::CrashReport => include_str!("help/crash-report.txt"),
Action::Boo => include_str!("help/boo.txt"),
Action::NewWindow => include_str!("help/new-window.txt"),
}
}
/// Run the action. `args` is the full argument vector (including
/// `argv[0]`). This returns the exit code to exit with.
///
/// If the action requests its help text (`--help`/`-h`), the help text
/// is printed to stdout and 0 is returned.
pub fn run(self, args: &[String]) -> Result<u8, Error> {
match self.run_main(args) {
Err(Error::ActionHelpRequested) => {
// Future note: for now we just output the help text directly
// to stdout. In the future we can style this much prettier
// for all commands by just changing this one place.
let text = format!("{}\n", self.help_text());
let mut stdout = std::io::stdout().lock();
if let Err(e) = stdout.write_all(text.as_bytes()) {
log::warn!("failed to write help text: {e}\n");
return Ok(1);
}
if let Err(e) = stdout.flush() {
log::warn!("failed to flush help text: {e}\n");
return Ok(1);
}
Ok(0)
}
other => other,
}
}
fn run_main(self, args: &[String]) -> Result<u8, Error> {
match self {
Action::Version => crate::version::run(args),
Action::Help => crate::help::run(args),
Action::ListFonts => crate::list_fonts::run(args),
Action::ListKeybinds => crate::list_keybinds::run(args),
Action::ListThemes => crate::list_themes::run(args),
Action::ListColors => crate::list_colors::run(args),
Action::ListActions => crate::list_actions::run(args),
Action::SshCache => crate::ssh_cache::run(args),
Action::EditConfig => crate::edit_config::run(args),
Action::ShowConfig => crate::show_config::run(args),
Action::ValidateConfig => crate::validate_config::run(args),
Action::CrashReport => crate::crash_report::run(args),
Action::ShowFace => crate::show_face::run(args),
Action::Boo => crate::boo::run(args),
Action::NewWindow => crate::new_window::run(args),
}
}
}
impl ActionEnum for Action {
fn from_name(name: &str) -> Option<Self> {
Action::from_name(name)
}
fn detect_special_case(arg: &str) -> Option<SpecialCase<Self>> {
// If we see a "-e" and we haven't seen a command yet, then
// we are done looking for commands. This special case enables
// `ghostty -e ghostty +command`. If we've seen a command we
// still want to keep looking because
// `ghostty +command -e +command` is invalid.
if arg == "-e" {
return Some(SpecialCase::AbortIfNoAction);
}
// Special case, --version always outputs the version no
// matter what, no matter what other args exist.
if arg == "--version" {
return Some(SpecialCase::Action(Action::Version));
}
// --help matches "help" but if a subcommand is specified
// then we match the subcommand.
if arg == "--help" || arg == "-h" {
return Some(SpecialCase::Fallback(Action::Help));
}
None
}
}
impl std::fmt::Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
/// Detect the CLI action from `args` (full argv, `argv[0]` included).
pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
detect_iter::<Action, _, _>(args.iter())
}
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
/// action from `args` (the full argv), print the errors Ghostty prints for
/// invalid invocations and run the action.
///
/// Returns `None` if no action was requested (the caller should launch the
/// terminal), or the exit code the process should terminate with.
#[must_use]
pub fn run_from_args(args: &[String]) -> Option<u8> {
let action = match detect(args) {
Ok(a) => a?,
Err(err) => {
let mut stderr = std::io::stderr().lock();
let _ = match err {
DetectError::MultipleActions => stderr.write_all(
b"Error: multiple CLI actions specified. You must specify only one\n\
action starting with the `+` character.\n",
),
DetectError::InvalidAction => stderr.write_all(
b"Error: unknown CLI action specified. CLI actions are specified with\n\
the '+' character.\n\n\
All valid CLI actions can be listed with `ghostty +help`\n",
),
};
let _ = stderr.flush();
return Some(1);
}
};
log::info!("executing CLI action={action}");
Some(match action.run(args) {
Ok(code) => code,
Err(err) => {
log::error!("CLI action failed error={err}");
1
}
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use ghostty_config::cli_args::split_args;
fn detect_str(s: &str) -> Result<Option<Action>, DetectError> {
detect_iter::<Action, _, _>(split_args(s))
}
#[test]
fn parse_action_none() {
assert_eq!(None, detect_str("--a=42 --b --b-f=false").unwrap());
}
#[test]
fn parse_action_version() {
for s in [
"--a=42 --b --b-f=false --version",
"--version --a=42 --b --b-f=false",
"--c=84 --d --version --a=42 --b --b-f=false",
] {
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
}
}
#[test]
fn parse_action_plus() {
for s in [
"--a=42 --b --b-f=false +version",
"+version --a=42 --b --b-f=false",
"--c=84 --d +version --a=42 --b --b-f=false",
] {
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
}
}
#[test]
fn parse_action_plus_ignores_e() {
assert_eq!(None, detect_str("--a=42 -e +version").unwrap());
assert_eq!(
Err(DetectError::MultipleActions),
detect_str("+list-fonts --a=42 -e +version")
);
}
#[test]
fn help_fallback() {
assert_eq!(Some(Action::Help), detect_str("--help").unwrap());
assert_eq!(Some(Action::Help), detect_str("-h").unwrap());
assert_eq!(
Some(Action::ListThemes),
detect_str("+list-themes --help").unwrap()
);
}
#[test]
fn names_round_trip() {
for a in Action::ALL {
assert_eq!(Some(*a), Action::from_name(a.name()));
assert_ne!("", a.help_text());
assert!(a.file().starts_with("cli/"));
}
assert_eq!("cli/list_fonts.zig", Action::ListFonts.file());
}
}