From 48d20e72d35dd038d59a8694519ef5bc7f3b5d0e Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 22 May 2026 16:32:40 -0400 Subject: [PATCH] feat: tui command on goose-cli (#9385) --- crates/goose-cli/src/cli.rs | 23 +++++++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-cli/src/commands/tui.rs | 99 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 crates/goose-cli/src/commands/tui.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e8ace59f1b..abc08a7406 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -968,6 +968,27 @@ enum Command { #[command(subcommand)] command: TermCommand, }, + + /// Launch the goose terminal UI (TUI) + #[command( + about = "Launch the goose terminal UI", + long_about = "Launch the goose terminal UI (the @aaif/goose npm package).\n\ + \n\ + Resolution order:\n \ + 1. GOOSE_TUI_SCRIPT, if set to an existing dist/tui.js\n \ + 2. A local checkout's ui/text/dist/tui.js (dev workflow)\n \ + 3. `npx --yes --package -- goose-tui` (deployed installs)\n\ + \n\ + Override the npm spec via GOOSE_TUI_NPM_SPEC (default: @aaif/goose@latest).\n\ + Local script mode requires `node` on PATH; npx mode requires `npx` on PATH.\n\ + Any extra arguments are passed through to the TUI." + )] + Tui { + /// Arguments forwarded to the TUI + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Manage local inference models #[cfg(feature = "local-inference")] #[command(about = "Manage local inference models", visible_alias = "lm")] @@ -1252,6 +1273,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Recipe { .. }) => "recipe", Some(Command::Plugin { .. }) => "plugin", Some(Command::Term { .. }) => "term", + Some(Command::Tui { .. }) => "tui", #[cfg(feature = "local-inference")] Some(Command::LocalModels { .. }) => "local-models", Some(Command::Completion { .. }) => "completion", @@ -2087,6 +2109,7 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Recipe { command }) => handle_recipe_subcommand(command), Some(Command::Plugin { command }) => handle_plugin_subcommand(command), Some(Command::Term { command }) => handle_term_subcommand(command).await, + Some(Command::Tui { args }) => crate::commands::tui::handle_tui(args), #[cfg(feature = "local-inference")] Some(Command::LocalModels { command }) => handle_local_models_command(command).await, Some(Command::Review { diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 43005e401b..76d4d79cbc 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -9,4 +9,5 @@ pub mod review; pub mod schedule; pub mod session; pub mod term; +pub mod tui; pub mod update; diff --git a/crates/goose-cli/src/commands/tui.rs b/crates/goose-cli/src/commands/tui.rs new file mode 100644 index 0000000000..a0ebc3311c --- /dev/null +++ b/crates/goose-cli/src/commands/tui.rs @@ -0,0 +1,99 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const TUI_NPM_SPEC_ENV: &str = "GOOSE_TUI_NPM_SPEC"; +const TUI_REL_PATH: &str = "ui/text/dist/tui.js"; +const DEFAULT_NPM_SPEC: &str = "@aaif/goose@latest"; +const NPM_BIN_NAME: &str = "goose-tui"; + +enum TuiSource { + LocalScript(PathBuf), + Npx(String), +} + +fn find_local_script() -> Option { + let exe = std::env::current_exe().ok()?; + let exe_dir = exe.parent().unwrap_or_else(|| Path::new(".")); + + let mut dir = Some(exe_dir.to_path_buf()); + for _ in 0..6 { + if let Some(d) = dir.clone() { + let candidate = d.join(TUI_REL_PATH); + if candidate.is_file() { + return Some(candidate); + } + dir = d.parent().map(Path::to_path_buf); + } + } + + if let Ok(cwd) = std::env::current_dir() { + let candidate = cwd.join(TUI_REL_PATH); + if candidate.is_file() { + return Some(candidate); + } + } + + None +} + +fn resolve_source() -> TuiSource { + if let Some(script) = find_local_script() { + return TuiSource::LocalScript(script); + } + let spec = std::env::var(TUI_NPM_SPEC_ENV).unwrap_or_else(|_| DEFAULT_NPM_SPEC.to_string()); + TuiSource::Npx(spec) +} + +fn build_command(source: &TuiSource, args: &[String]) -> Result { + match source { + TuiSource::LocalScript(script) => { + let mut cmd = Command::new("node"); + cmd.arg(script).args(args); + Ok(cmd) + } + TuiSource::Npx(spec) => { + let mut cmd = Command::new("npx"); + cmd.arg("--yes") + .arg("--package") + .arg(spec) + .arg("--") + .arg(NPM_BIN_NAME) + .args(args); + Ok(cmd) + } + } +} + +pub fn handle_tui(args: Vec) -> Result<()> { + let source = resolve_source(); + + let goose_binary = std::env::current_exe() + .context("could not determine current goose executable to expose as GOOSE_BINARY")?; + + let mut cmd = build_command(&source, &args)?; + cmd.env("GOOSE_BINARY", &goose_binary); + + let descriptor = match &source { + TuiSource::LocalScript(p) => format!("node {}", p.display()), + TuiSource::Npx(spec) => format!("npx --package {} -- {}", spec, NPM_BIN_NAME), + }; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = cmd.exec(); + Err(anyhow!("failed to exec TUI ({descriptor}): {err}")) + } + + #[cfg(not(unix))] + { + let status = cmd + .status() + .with_context(|| format!("failed to run `{descriptor}`"))?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) + } +}