crates/q_cli/src/cli/mod.rs (207 lines of code) (raw):
//! CLI functionality
pub mod app;
mod completion;
mod debug;
mod diagnostics;
mod doctor;
mod feed;
mod hook;
mod init;
mod inline;
mod installation;
mod integrations;
pub mod internal;
mod issue;
mod settings;
mod telemetry;
mod theme;
mod translate;
mod uninstall;
mod update;
mod user;
use std::io::{
Write as _,
stdout,
};
use std::process::ExitCode;
use anstream::{
eprintln,
println,
};
use clap::{
ArgAction,
CommandFactory,
Parser,
Subcommand,
ValueEnum,
};
use crossterm::style::Stylize;
use eyre::{
Result,
WrapErr,
bail,
};
use feed::Feed;
use fig_auth::is_logged_in;
use fig_ipc::local::open_ui_element;
use fig_log::{
LogArgs,
initialize_logging,
};
use fig_proto::local::UiElement;
use fig_util::{
CLI_BINARY_NAME,
PRODUCT_NAME,
directories,
manifest,
system_info,
};
use internal::InternalSubcommand;
use q_chat::cli::Chat;
use serde::Serialize;
use tracing::{
Level,
debug,
};
use self::integrations::IntegrationsSubcommands;
use self::user::RootUserSubcommand;
use crate::util::CliContext;
use crate::util::desktop::{
LaunchArgs,
launch_fig_desktop,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// Outputs the results as markdown
#[default]
Plain,
/// Outputs the results as JSON
Json,
/// Outputs the results as pretty print JSON
JsonPretty,
}
impl OutputFormat {
pub fn print<T, TFn, J, JFn>(&self, text_fn: TFn, json_fn: JFn)
where
T: std::fmt::Display,
TFn: FnOnce() -> T,
J: Serialize,
JFn: FnOnce() -> J,
{
match self {
OutputFormat::Plain => println!("{}", text_fn()),
OutputFormat::Json => println!("{}", serde_json::to_string(&json_fn()).unwrap()),
OutputFormat::JsonPretty => println!("{}", serde_json::to_string_pretty(&json_fn()).unwrap()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Processes {
/// Desktop Process
App,
}
/// The Amazon Q CLI
#[deny(missing_docs)]
#[derive(Debug, PartialEq, Subcommand)]
pub enum CliRootCommands {
/// Hook commands
#[command(subcommand, hide = true)]
Hook(hook::HookSubcommand),
/// Debug the app
#[command(subcommand)]
Debug(debug::DebugSubcommand),
/// Customize appearance & behavior
#[command(alias("setting"))]
Settings(settings::SettingsArgs),
/// Setup cli components
#[command(alias("install"))]
Setup(internal::InstallArgs),
/// Uninstall Amazon Q
#[command(hide = true)]
Uninstall {
/// Force uninstall
#[arg(long, short = 'y')]
no_confirm: bool,
},
/// Update the Amazon Q application
#[command(alias("upgrade"))]
Update(update::UpdateArgs),
/// Run diagnostic tests
#[command(alias("diagnostics"))]
Diagnostic(diagnostics::DiagnosticArgs),
/// Generate the dotfiles for the given shell
Init(init::InitArgs),
/// Get or set theme
Theme(theme::ThemeArgs),
/// Create a new Github issue
Issue(issue::IssueArgs),
/// Root level user subcommands
#[command(flatten)]
RootUser(user::RootUserSubcommand),
/// Manage your account
#[command(subcommand)]
User(user::UserSubcommand),
/// Fix and diagnose common issues
Doctor(doctor::DoctorArgs),
/// Generate CLI completion spec
#[command(hide = true)]
Completion(completion::CompletionArgs),
/// Internal subcommands
#[command(subcommand, hide = true)]
Internal(internal::InternalSubcommand),
/// Launch the desktop app
Launch,
/// Quit the desktop app
Quit,
/// Restart the desktop app
Restart {
/// The process to restart
#[arg(value_enum, default_value_t = Processes::App, hide = true)]
process: Processes,
},
/// Manage system integrations
#[command(subcommand, alias("integration"))]
Integrations(IntegrationsSubcommands),
/// Natural Language to Shell translation
#[command(alias("ai"))]
Translate(translate::TranslateArgs),
/// Enable/disable telemetry
#[command(subcommand, hide = true)]
Telemetry(telemetry::TelemetrySubcommand),
/// Version
#[command(hide = true)]
Version {
/// Show the changelog (use --changelog=all for all versions, or --changelog=x.x.x for a
/// specific version)
#[arg(long, num_args = 0..=1, default_missing_value = "")]
changelog: Option<String>,
},
/// Open the dashboard
Dashboard,
/// AI assistant in your terminal
#[command(alias("q"))]
Chat(Chat),
/// Inline shell completions
#[command(subcommand)]
Inline(inline::InlineSubcommand),
}
impl CliRootCommands {
fn name(&self) -> &'static str {
match self {
CliRootCommands::Hook(_) => "hook",
CliRootCommands::Debug(_) => "debug",
CliRootCommands::Settings(_) => "settings",
CliRootCommands::Setup(_) => "setup",
CliRootCommands::Uninstall { .. } => "uninstall",
CliRootCommands::Update(_) => "update",
CliRootCommands::Diagnostic(_) => "diagnostics",
CliRootCommands::Init(_) => "init",
CliRootCommands::Theme(_) => "theme",
CliRootCommands::Issue(_) => "issue",
CliRootCommands::RootUser(RootUserSubcommand::Login(_)) => "login",
CliRootCommands::RootUser(RootUserSubcommand::Logout) => "logout",
CliRootCommands::RootUser(RootUserSubcommand::Whoami { .. }) => "whoami",
CliRootCommands::RootUser(RootUserSubcommand::Profile) => "profile",
CliRootCommands::User(_) => "user",
CliRootCommands::Doctor(_) => "doctor",
CliRootCommands::Completion(_) => "completion",
CliRootCommands::Internal(_) => "internal",
CliRootCommands::Launch => "launch",
CliRootCommands::Quit => "quit",
CliRootCommands::Restart { .. } => "restart",
CliRootCommands::Integrations(_) => "integrations",
CliRootCommands::Translate(_) => "translate",
CliRootCommands::Telemetry(_) => "telemetry",
CliRootCommands::Version { .. } => "version",
CliRootCommands::Dashboard => "dashboard",
CliRootCommands::Chat { .. } => "chat",
CliRootCommands::Inline(_) => "inline",
}
}
}
const HELP_TEXT: &str = color_print::cstr! {"
<magenta,em>q</magenta,em> (Amazon Q CLI)
<magenta,em>Popular Subcommands</magenta,em> <black!><em>Usage:</em> q [subcommand]</black!>
╭────────────────────────────────────────────────────╮
│ <em>chat</em> <black!>Chat with Amazon Q</black!> │
│ <em>translate</em> <black!>Natural Language to Shell translation</black!> │
│ <em>doctor</em> <black!>Debug installation issues</black!> │
│ <em>settings</em> <black!>Customize appearance & behavior</black!> │
│ <em>quit</em> <black!>Quit the app</black!> │
╰────────────────────────────────────────────────────╯
<black!>To see all subcommands, use:</black!>
<black!>❯</black!> q --help-all
ㅤ
"};
#[derive(Debug, Parser, PartialEq, Default)]
#[command(version, about, name = CLI_BINARY_NAME, help_template = HELP_TEXT)]
pub struct Cli {
#[command(subcommand)]
pub subcommand: Option<CliRootCommands>,
/// Increase logging verbosity
#[arg(long, short = 'v', action = ArgAction::Count, global = true)]
pub verbose: u8,
/// Print help for all subcommands
#[arg(long)]
help_all: bool,
}
impl Cli {
pub async fn execute(self) -> Result<ExitCode> {
// Initialize our logger and keep around the guard so logging can perform as expected.
let _log_guard = initialize_logging(LogArgs {
log_level: match self.verbose > 0 {
true => Some(
match self.verbose {
1 => Level::WARN,
2 => Level::INFO,
3 => Level::DEBUG,
_ => Level::TRACE,
}
.to_string(),
),
false => None,
},
log_to_stdout: std::env::var_os("Q_LOG_STDOUT").is_some() || self.verbose > 0,
log_file_path: match self.subcommand {
Some(CliRootCommands::Chat { .. }) => Some("chat.log".to_owned()),
Some(CliRootCommands::Translate(..)) => Some("translate.log".to_owned()),
Some(CliRootCommands::Internal(InternalSubcommand::Multiplexer(_))) => Some("mux.log".to_owned()),
_ => match fig_log::get_log_level_max() >= Level::DEBUG {
true => Some("cli.log".to_owned()),
false => None,
},
}
.map(|name| directories::logs_dir().expect("home dir must be set").join(name)),
delete_old_log_file: false,
});
debug!(command =? std::env::args().collect::<Vec<_>>(), "Command ran");
self.send_telemetry().await;
if self.help_all {
return self.print_help_all();
}
let cli_context = CliContext::new();
match self.subcommand {
Some(subcommand) => match subcommand {
CliRootCommands::Setup(args) => {
let no_confirm = args.no_confirm;
let force = args.force;
let global = args.global;
installation::install_cli(args.into(), no_confirm, force, global).await
},
CliRootCommands::Uninstall { no_confirm } => uninstall::uninstall_command(no_confirm).await,
CliRootCommands::Update(args) => args.execute().await,
CliRootCommands::Diagnostic(args) => args.execute().await,
CliRootCommands::Init(args) => args.execute().await,
CliRootCommands::User(user) => user.execute().await,
CliRootCommands::RootUser(root_user) => root_user.execute().await,
CliRootCommands::Doctor(args) => args.execute().await,
CliRootCommands::Hook(hook_subcommand) => hook_subcommand.execute().await,
CliRootCommands::Theme(theme_args) => theme_args.execute().await,
CliRootCommands::Settings(settings_args) => settings_args.execute(&cli_context).await,
CliRootCommands::Debug(debug_subcommand) => debug_subcommand.execute().await,
CliRootCommands::Issue(args) => args.execute().await,
CliRootCommands::Completion(args) => args.execute(),
CliRootCommands::Internal(internal_subcommand) => internal_subcommand.execute().await,
CliRootCommands::Launch => launch_dashboard(false).await,
CliRootCommands::Quit => crate::util::quit_fig(true).await,
CliRootCommands::Restart { .. } => {
app::restart_fig().await?;
launch_dashboard(false).await
},
CliRootCommands::Integrations(subcommand) => subcommand.execute().await,
CliRootCommands::Translate(args) => args.execute().await,
CliRootCommands::Telemetry(subcommand) => subcommand.execute().await,
CliRootCommands::Version { changelog } => Self::print_version(changelog),
CliRootCommands::Dashboard => launch_dashboard(false).await,
CliRootCommands::Chat(args) => q_chat::launch_chat(args).await,
CliRootCommands::Inline(subcommand) => subcommand.execute(&cli_context).await,
},
// Root command
None => q_chat::launch_chat(q_chat::cli::Chat::default()).await,
}
}
async fn send_telemetry(&self) {
match &self.subcommand {
None
| Some(
CliRootCommands::Init(_)
| CliRootCommands::Internal(_)
| CliRootCommands::Completion(_)
| CliRootCommands::Hook(_),
) => {},
Some(subcommand) => {
fig_telemetry::send_cli_subcommand_executed(subcommand.name()).await;
},
}
}
#[allow(clippy::unused_self)]
fn print_help_all(&self) -> Result<ExitCode> {
let mut cmd = Self::command().help_template("{all-args}");
eprintln!();
eprintln!(
"{}\n {CLI_BINARY_NAME} [OPTIONS] [SUBCOMMAND]\n",
"USAGE:".bold().underlined(),
);
cmd.print_long_help()?;
Ok(ExitCode::SUCCESS)
}
fn print_changelog_entry(entry: &feed::Entry) -> Result<()> {
println!("Version {} ({})", entry.version, entry.date);
if entry.changes.is_empty() {
println!(" No changes recorded for this version.");
} else {
for change in &entry.changes {
let type_label = match change.change_type.as_str() {
"added" => "Added",
"fixed" => "Fixed",
"changed" => "Changed",
other => other,
};
println!(" - {}: {}", type_label, change.description);
}
}
println!();
Ok(())
}
#[allow(clippy::unused_self)]
fn print_version(changelog: Option<String>) -> Result<ExitCode> {
// If no changelog is requested, display normal version information
if changelog.is_none() {
let _ = writeln!(stdout(), "{}", Self::command().render_version());
return Ok(ExitCode::SUCCESS);
}
let changelog_value = changelog.unwrap_or_default();
let feed = Feed::load();
// Display changelog for all versions
if changelog_value == "all" {
let entries = feed.get_all_changelogs();
if entries.is_empty() {
println!("No changelog information available.");
} else {
println!("Changelog for all versions:");
for entry in entries {
Self::print_changelog_entry(&entry)?;
}
}
return Ok(ExitCode::SUCCESS);
}
// Display changelog for a specific version (--changelog=x.x.x)
if !changelog_value.is_empty() {
match feed.get_version_changelog(&changelog_value) {
Some(entry) => {
println!("Changelog for version {}:", changelog_value);
Self::print_changelog_entry(&entry)?;
return Ok(ExitCode::SUCCESS);
},
None => {
println!("No changelog information available for version {}.", changelog_value);
return Ok(ExitCode::SUCCESS);
},
}
}
// Display changelog for the current version (--changelog only)
let current_version = env!("CARGO_PKG_VERSION");
match feed.get_version_changelog(current_version) {
Some(entry) => {
println!("Changelog for version {}:", current_version);
Self::print_changelog_entry(&entry)?;
},
None => {
println!("No changelog information available for version {}.", current_version);
},
}
Ok(ExitCode::SUCCESS)
}
}
async fn launch_dashboard(help_fallback: bool) -> Result<ExitCode> {
if manifest::is_minimal() || system_info::is_remote() {
if help_fallback {
Cli::command().print_help()?;
return Ok(ExitCode::SUCCESS);
} else {
bail!("Launching the dashboard is not supported in minimal mode");
}
}
launch_fig_desktop(LaunchArgs {
wait_for_socket: true,
open_dashboard: true,
immediate_update: true,
verbose: true,
})?;
let route = match is_logged_in().await {
true => Some("/".into()),
false => None,
};
println!("Opening {PRODUCT_NAME} dashboard");
open_ui_element(UiElement::MissionControl, route)
.await
.context("Failed to open dashboard")?;
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn debug_assert() {
Cli::command().debug_assert();
}
macro_rules! assert_parse {
(
[ $($args:expr),+ ],
$subcommand:expr
) => {
assert_eq!(
Cli::parse_from([CLI_BINARY_NAME, $($args),*]),
Cli {
subcommand: Some($subcommand),
..Default::default()
}
);
};
}
/// Test flag parsing for the top level [Cli]
#[test]
fn test_flags() {
assert_eq!(Cli::parse_from([CLI_BINARY_NAME, "-v"]), Cli {
subcommand: None,
verbose: 1,
help_all: false,
});
assert_eq!(Cli::parse_from([CLI_BINARY_NAME, "-vvv"]), Cli {
subcommand: None,
verbose: 3,
help_all: false,
});
assert_eq!(Cli::parse_from([CLI_BINARY_NAME, "--help-all"]), Cli {
subcommand: None,
verbose: 0,
help_all: true,
});
assert_eq!(Cli::parse_from([CLI_BINARY_NAME, "chat", "-vv"]), Cli {
subcommand: Some(CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: None,
profile: None,
trust_all_tools: false,
trust_tools: None,
})),
verbose: 2,
help_all: false,
});
}
/// This test validates that the restart command maintains the same CLI facing definition
///
/// If this changes, you must also change how it is called from within fig_install
/// and (possibly) other locations as well
#[test]
fn test_restart() {
assert_parse!(["restart", "app"], CliRootCommands::Restart {
process: Processes::App
});
}
/// This test validates that the internal input method installation command maintains the same
/// CLI facing definition
///
/// If this changes, you must also change how it is called from within
/// fig_integrations::input_method
#[cfg(target_os = "macos")]
#[test]
fn test_input_method_installation() {
use internal::InternalSubcommand;
assert_parse!(
[
"_",
"attempt-to-finish-input-method-installation",
"/path/to/bundle.app"
],
CliRootCommands::Internal(InternalSubcommand::AttemptToFinishInputMethodInstallation {
bundle_path: Some(std::path::PathBuf::from("/path/to/bundle.app"))
})
);
}
#[test]
fn test_inline_shell_completion() {
use internal::InternalSubcommand;
assert_parse!(
["_", "inline-shell-completion", "--buffer", ""],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletion { buffer: "".to_string() })
);
assert_parse!(
["_", "inline-shell-completion", "--buffer", "foo"],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletion {
buffer: "foo".to_string()
})
);
assert_parse!(
["_", "inline-shell-completion", "--buffer", "-"],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletion {
buffer: "-".to_string()
})
);
assert_parse!(
["_", "inline-shell-completion", "--buffer", "--"],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletion {
buffer: "--".to_string()
})
);
assert_parse!(
["_", "inline-shell-completion", "--buffer", "--foo bar"],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletion {
buffer: "--foo bar".to_string()
})
);
assert_parse!(
[
"_",
"inline-shell-completion-accept",
"--buffer",
"abc",
"--suggestion",
"def"
],
CliRootCommands::Internal(InternalSubcommand::InlineShellCompletionAccept {
buffer: "abc".to_string(),
suggestion: "def".to_string()
})
);
}
#[test]
fn test_doctor() {
assert_parse!(
["doctor"],
CliRootCommands::Doctor(doctor::DoctorArgs {
all: false,
strict: false,
})
);
assert_parse!(
["doctor", "--all"],
CliRootCommands::Doctor(doctor::DoctorArgs {
all: true,
strict: false,
})
);
assert_parse!(
["doctor", "--strict"],
CliRootCommands::Doctor(doctor::DoctorArgs {
all: false,
strict: true,
})
);
assert_parse!(
["doctor", "-a", "-s"],
CliRootCommands::Doctor(doctor::DoctorArgs {
all: true,
strict: true,
})
);
}
#[test]
fn test_version_changelog() {
assert_parse!(["version", "--changelog"], CliRootCommands::Version {
changelog: Some("".to_string()),
});
}
#[test]
fn test_version_changelog_all() {
assert_parse!(["version", "--changelog=all"], CliRootCommands::Version {
changelog: Some("all".to_string()),
});
}
#[test]
fn test_version_changelog_specific() {
assert_parse!(["version", "--changelog=1.8.0"], CliRootCommands::Version {
changelog: Some("1.8.0".to_string()),
});
}
#[test]
fn test_chat_with_context_profile() {
assert_parse!(
["chat", "--profile", "my-profile"],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: None,
profile: Some("my-profile".to_string()),
trust_all_tools: false,
trust_tools: None,
})
);
}
#[test]
fn test_chat_with_context_profile_and_input() {
assert_parse!(
["chat", "--profile", "my-profile", "Hello"],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: Some("Hello".to_string()),
profile: Some("my-profile".to_string()),
trust_all_tools: false,
trust_tools: None,
})
);
}
#[test]
fn test_chat_with_context_profile_and_accept_all() {
assert_parse!(
["chat", "--profile", "my-profile", "--accept-all"],
CliRootCommands::Chat(Chat {
accept_all: true,
no_interactive: false,
input: None,
profile: Some("my-profile".to_string()),
trust_all_tools: false,
trust_tools: None,
})
);
}
#[test]
fn test_chat_with_no_interactive() {
assert_parse!(
["chat", "--no-interactive"],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: true,
input: None,
profile: None,
trust_all_tools: false,
trust_tools: None,
})
);
}
#[test]
fn test_chat_with_tool_trust_all() {
assert_parse!(
["chat", "--trust-all-tools"],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: None,
profile: None,
trust_all_tools: true,
trust_tools: None,
})
);
}
#[test]
fn test_chat_with_tool_trust_none() {
assert_parse!(
["chat", "--trust-tools="],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: None,
profile: None,
trust_all_tools: false,
trust_tools: Some(vec!["".to_string()]),
})
);
}
#[test]
fn test_chat_with_tool_trust_some() {
assert_parse!(
["chat", "--trust-tools=fs_read,fs_write"],
CliRootCommands::Chat(Chat {
accept_all: false,
no_interactive: false,
input: None,
profile: None,
trust_all_tools: false,
trust_tools: Some(vec!["fs_read".to_string(), "fs_write".to_string()]),
})
);
}
}