diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-02 09:12:20 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-02 18:12:20 +0100 |
| commit | 4c9180c2755b6457113e8d6a7566c32cf1ad547a (patch) | |
| tree | 8136d818898232d811dbc452bb52a16c38b8f8e3 | |
| parent | fix: regen cargo dist (diff) | |
| download | atuin-4c9180c2755b6457113e8d6a7566c32cf1ad547a.zip | |
chore: Move atuin ai subcommand into core binary (#3212)
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands.rs | 72 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/init.rs | 14 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 32 | ||||
| -rw-r--r-- | crates/atuin-ai/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/main.rs | 7 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-client/Cargo.toml | 5 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 26 | ||||
| -rw-r--r-- | crates/atuin/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/atuin/src/command/client.rs | 8 | ||||
| -rw-r--r-- | docs/docs/ai/introduction.md | 2 | ||||
| -rw-r--r-- | docs/docs/ai/settings.md | 12 | ||||
| -rw-r--r-- | docs/docs/configuration/config.md | 6 |
14 files changed, 105 insertions, 88 deletions
@@ -215,6 +215,7 @@ version = "18.13.0-beta.2" dependencies = [ "arboard", "async-trait", + "atuin-ai", "atuin-client", "atuin-common", "atuin-daemon", diff --git a/crates/atuin-ai/src/commands.rs b/crates/atuin-ai/src/commands.rs index b35cec9e..d04875ea 100644 --- a/crates/atuin-ai/src/commands.rs +++ b/crates/atuin-ai/src/commands.rs @@ -4,7 +4,7 @@ use std::{ }; use atuin_common::shell::Shell; -use clap::{Parser, Subcommand}; +use clap::{Args, Subcommand}; use eyre::Result; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt}; @@ -14,27 +14,23 @@ pub mod debug_render; pub mod init; pub mod inline; -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Cli { +#[derive(Args, Debug)] +pub struct AiArgs { /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, - /// Custom API endpoint - #[arg(long, global = true, env = "ATUIN_AI_API_ENDPOINT")] + /// Custom API endpoint; defaults to reading from the `ai.endpoint` setting. + #[arg(long, global = true)] api_endpoint: Option<String>, - /// Custom API token - #[arg(long, global = true, env = "ATUIN_AI_API_TOKEN")] + /// Custom API token; defaults to reading from the `ai.api_token` setting. + #[arg(long, global = true)] api_token: Option<String>, - - #[command(subcommand)] - command: Commands, } #[derive(Subcommand, Debug)] -enum Commands { +pub enum Commands { /// Initialize shell integration Init { /// Shell to generate integration for; defaults to "auto" @@ -44,20 +40,23 @@ enum Commands { /// Inline completion mode with small TUI overlay Inline { + #[command(flatten)] + args: AiArgs, + /// Current command line to complete #[arg(value_name = "COMMAND")] command: Option<String>, - /// Start in natural language mode - #[arg(long)] - natural_language: bool, - /// Keep TUI output visible after exit (default: erase) #[arg(long)] keep: bool, + /// Use the hook mode + #[arg(long, hide = true)] + hook: bool, + /// Log state changes to file for debugging (dev tool) - #[arg(long, value_name = "FILE")] + #[arg(long, value_name = "FILE", hide = true)] debug_state: Option<String>, }, @@ -74,31 +73,32 @@ enum Commands { }, } -pub async fn run() -> eyre::Result<()> { - let cli = Cli::parse(); - - let settings = atuin_client::settings::Settings::new()?; - - if settings.logs.ai_enabled() { - init_logging(&settings, cli.verbose)?; - } - - match cli.command { +pub async fn run( + command: Commands, + settings: &atuin_client::settings::Settings, +) -> eyre::Result<()> { + match command { Commands::Init { shell } => init::run(shell).await, Commands::Inline { command, - natural_language, keep, debug_state, + hook, + args, + .. } => { + if settings.logs.ai_enabled() { + init_logging(settings, args.verbose)?; + } + inline::run( command, - natural_language, - cli.api_endpoint, - cli.api_token, + args.api_endpoint, + args.api_token, keep, debug_state, - &settings, + settings, + hook, ) .await } @@ -137,12 +137,10 @@ fn init_logging(settings: &atuin_client::settings::Settings, verbose: bool) -> R }; let log_dir = PathBuf::from(&settings.logs.dir); - fs::create_dir_all(&log_dir)?; - - let filename = settings.logs.ai.file.clone(); + let ai_log_filename = settings.logs.ai.file.clone(); // Clean up old log files - cleanup_old_logs(&log_dir, &filename, settings.logs.ai_retention()); + cleanup_old_logs(&log_dir, &ai_log_filename, settings.logs.ai_retention()); let console_layer = if verbose { Some( @@ -156,7 +154,7 @@ fn init_logging(settings: &atuin_client::settings::Settings, verbose: bool) -> R None }; - let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &filename); + let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &ai_log_filename); let base = tracing_subscriber::registry().with( fmt::layer() diff --git a/crates/atuin-ai/src/commands/init.rs b/crates/atuin-ai/src/commands/init.rs index 8174b583..caf4c8d9 100644 --- a/crates/atuin-ai/src/commands/init.rs +++ b/crates/atuin-ai/src/commands/init.rs @@ -38,7 +38,7 @@ _atuin_ai_question_mark() { if [[ -z "$BUFFER" || "$BUFFER" == "?" ]]; then BUFFER="" local output - output=$(atuin-ai inline --natural-language 3>&1 1>&2 2>&3) + output=$(atuin ai inline --hook 3>&1 1>&2 2>&3) # Clean up the inline viewport _atuin_ai_cleanup @@ -84,7 +84,7 @@ _atuin_ai_question_mark() { READLINE_POINT=0 local output - output=$(atuin-ai inline --natural-language 3>&1 1>&2 2>&3) + output=$(atuin ai inline --hook 3>&1 1>&2 2>&3) if [[ $output == __atuin_ai_cancel__ ]]; then # User cancelled, do nothing @@ -142,8 +142,8 @@ function _atuin_ai_question_mark if test -z "$buf" -o "$buf" = "?" commandline -r "" - # Run atuin-ai inline, swapping stdout and stderr - set -l output (atuin-ai inline --natural-language 3>&1 1>&2 2>&3 | string collect) + # Run atuin ai inline, swapping stdout and stderr + set -l output (atuin ai inline --hook 3>&1 1>&2 2>&3 | string collect) if test "$output" = "__atuin_ai_cancel__" # User cancelled, do nothing @@ -187,7 +187,7 @@ mod tests { let result = generate_zsh_integration(); assert!(result.contains("_atuin_ai_question_mark")); assert!(result.contains("bindkey")); - assert!(result.contains("atuin-ai inline")); + assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); @@ -199,7 +199,7 @@ mod tests { assert!(result.contains("_atuin_ai_question_mark")); assert!(result.contains("bind")); assert!(result.contains("READLINE_LINE")); - assert!(result.contains("atuin-ai inline")); + assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); @@ -211,7 +211,7 @@ mod tests { assert!(result.contains("_atuin_ai_question_mark")); assert!(result.contains("bind")); assert!(result.contains("commandline")); - assert!(result.contains("atuin-ai inline")); + assert!(result.contains("atuin ai inline --hook")); assert!(result.contains("__atuin_ai_cancel__")); assert!(result.contains("__atuin_ai_execute__")); assert!(result.contains("__atuin_ai_insert__")); diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index b49bfece..67241574 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -19,12 +19,12 @@ use tracing::{debug, error, info, trace}; pub async fn run( initial_command: Option<String>, - natural_language: bool, api_endpoint: Option<String>, api_token: Option<String>, keep_output: bool, debug_state_file: Option<String>, settings: &atuin_client::settings::Settings, + output_for_hook: bool, ) -> Result<()> { // Install panic hook once at entry point to ensure terminal restoration install_panic_hook(); @@ -36,11 +36,11 @@ pub async fn run( let endpoint = api_endpoint.as_deref().unwrap_or( settings .ai - .ai_endpoint + .endpoint .as_deref() .unwrap_or("https://hub.atuin.sh"), ); - let api_token = api_token.as_deref().or(settings.ai.ai_api_token.as_deref()); + let api_token = api_token.as_deref().or(settings.ai.api_token.as_deref()); let token = if let Some(token) = &api_token { token.to_string() @@ -51,17 +51,13 @@ pub async fn run( let action = run_inline_tui( endpoint.to_string(), token, - if natural_language { - None - } else { - initial_command - }, + initial_command, keep_output, debug_state_file, settings, ) .await?; - emit_shell_result(action.0, &action.1); + emit_shell_result(action.0, &action.1, output_for_hook); Ok(()) } @@ -619,11 +615,19 @@ impl Drop for RawModeGuard { } } -fn emit_shell_result(action: Action, command: &str) { - match action { - Action::Execute => eprintln!("__atuin_ai_execute__:{command}"), - Action::Insert => eprintln!("__atuin_ai_insert__:{command}"), - Action::Cancel => eprintln!("__atuin_ai_cancel__"), +fn emit_shell_result(action: Action, command: &str, output_for_hook: bool) { + if output_for_hook { + match action { + Action::Execute => eprintln!("__atuin_ai_execute__:{command}"), + Action::Insert => eprintln!("__atuin_ai_insert__:{command}"), + Action::Cancel => eprintln!("__atuin_ai_cancel__"), + } + } else { + match action { + Action::Execute => eprintln!("{command}"), + Action::Insert => eprintln!("{command}"), + Action::Cancel => eprintln!(), + } } } diff --git a/crates/atuin-ai/src/lib.rs b/crates/atuin-ai/src/lib.rs new file mode 100644 index 00000000..2d86271d --- /dev/null +++ b/crates/atuin-ai/src/lib.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod tui; diff --git a/crates/atuin-ai/src/main.rs b/crates/atuin-ai/src/main.rs deleted file mode 100644 index fb1e517e..00000000 --- a/crates/atuin-ai/src/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod commands; -pub mod tui; - -#[tokio::main] -async fn main() -> eyre::Result<()> { - commands::run().await -} diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs index 2e0bcbaa..75bfd6e6 100644 --- a/crates/atuin-ai/src/tui/terminal.rs +++ b/crates/atuin-ai/src/tui/terminal.rs @@ -54,7 +54,7 @@ const VIEWPORT_BOTTOM_MARGIN: u16 = 2; /// use atuin_ai::tui::{install_panic_hook, TerminalGuard}; /// /// install_panic_hook(); // Once at program start -/// let mut guard = TerminalGuard::new()?; +/// let mut guard = TerminalGuard::new(true)?; /// let terminal = guard.terminal(); /// // ... use terminal ... /// // Drop automatically cleans up diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index e7380902..22fca174 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -58,7 +58,10 @@ serde_with = "3.8.1" # encryption rusty_paseto = { version = "0.8.0", default-features = false } -rusty_paserk = { version = "0.5.0", default-features = false, features = ["v4", "serde"] } +rusty_paserk = { version = "0.5.0", default-features = false, features = [ + "v4", + "serde", +] } # sync urlencoding = { version = "2.1.0", optional = true } diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 8e874832..bfa94f6e 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -562,11 +562,11 @@ pub struct Logs { pub struct Ai { /// The address of the Atuin AI endpoint. Used for AI features like command generation. /// Only necessary for custom AI endpoints. - pub ai_endpoint: Option<String>, + pub endpoint: Option<String>, /// The API token for the Atuin AI endpoint. Used for AI features like command generation. /// Only necessary for custom AI endpoints. - pub ai_api_token: Option<String>, + pub api_token: Option<String>, /// Whether or not to send the current working directory to the AI endpoint. pub send_cwd: bool, @@ -691,27 +691,21 @@ impl Logs { } /// Returns the full path for the search log file. - /// If `file` is an absolute path, returns it directly. - /// Otherwise, joins it with `dir`. pub fn search_path(&self) -> PathBuf { let path = PathBuf::from(&self.search.file); - if path.is_absolute() { - path - } else { - PathBuf::from(&self.dir).join(path) - } + PathBuf::from(&self.dir).join(path) } /// Returns the full path for the daemon log file. - /// If `file` is an absolute path, returns it directly. - /// Otherwise, joins it with `dir`. pub fn daemon_path(&self) -> PathBuf { let path = PathBuf::from(&self.daemon.file); - if path.is_absolute() { - path - } else { - PathBuf::from(&self.dir).join(path) - } + PathBuf::from(&self.dir).join(path) + } + + /// Returns the full path for the AI log file. + pub fn ai_path(&self) -> PathBuf { + let path = PathBuf::from(&self.ai.file); + PathBuf::from(&self.dir).join(path) } } diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index 7e438b38..ff153631 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -33,14 +33,16 @@ buildflags = ["--release"] atuin = { path = "/usr/bin/atuin" } [features] -default = ["client", "sync", "clipboard", "check-update", "daemon"] +default = ["client", "sync", "clipboard", "check-update", "daemon", "ai"] client = ["atuin-client"] sync = ["atuin-client/sync"] daemon = ["atuin-client/daemon", "atuin-daemon"] +ai = ["atuin-ai"] clipboard = ["arboard"] check-update = ["atuin-client/check-update"] [dependencies] +atuin-ai = { path = "../atuin-ai", version = "18.13.0-beta.2", optional = true, default-features = false } atuin-client = { path = "../atuin-client", version = "18.13.0-beta.2", optional = true, default-features = false } atuin-common = { workspace = true } atuin-dotfiles = { workspace = true } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index ba55466d..6e197604 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -127,6 +127,11 @@ pub enum Cmd { /// Print the default atuin configuration (config.toml) #[command()] DefaultConfig, + + /// Run the AI assistant + #[cfg(feature = "ai")] + #[command(subcommand)] + Ai(atuin_ai::commands::Commands), } impl Cmd { @@ -362,6 +367,9 @@ impl Cmd { Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await, Self::History(_) | Self::Init(_) | Self::Doctor => unreachable!(), + + #[cfg(feature = "ai")] + Self::Ai(cli) => atuin_ai::commands::run(cli, &settings).await, } } } diff --git a/docs/docs/ai/introduction.md b/docs/docs/ai/introduction.md index 39c45eec..0c235428 100644 --- a/docs/docs/ai/introduction.md +++ b/docs/docs/ai/introduction.md @@ -1,6 +1,6 @@ # Atuin AI -Atuin AI is a separate binary that enables command generation and other information lookup via an LLM directly from your terminal. It is completely opt-in, and will not change the behavior of Atuin at all if you choose not to use it. +Atuin AI is a subcommand that enables shell command generation and other information lookup via an LLM directly from your terminal. It is completely opt-in, and will not change the behavior of Atuin at all if you choose not to use it. Atuin AI requires an account on [Atuin Hub](https://hub.atuin.sh/), and you'll be prompted to login upon first use of the binary. diff --git a/docs/docs/ai/settings.md b/docs/docs/ai/settings.md index eb868f91..0a1f5b45 100644 --- a/docs/docs/ai/settings.md +++ b/docs/docs/ai/settings.md @@ -14,3 +14,15 @@ Whether or not to include your current working directory in the context sent to [ai] send_cwd = true ``` + +### endpoint + +Default: `null` + +The address of the Atuin AI endpoint. Used for AI features like command generation. Only necessary for custom AI endpoints. + +### api_token + +Default: `null` + +The API token for the Atuin AI endpoint. Used for AI features like command generation. Only necessary for custom AI endpoints. diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md index 693a528d..6e2595ae 100644 --- a/docs/docs/configuration/config.md +++ b/docs/docs/configuration/config.md @@ -800,7 +800,7 @@ How many days of log files to keep (per file type). Files older than this will b A sub-object with specific options for AI logging: * `enabled` - whether to output AI logs; defaults to `logs.enabled` -* `file` - the filename to use for the AI logs; defaults to `"ai.log"`. Can be absolute, or relative to `logs.dir`. +* `file` - the filename to use for the AI logs; defaults to `"ai.log"`. Always relative to `logs.dir`. * `level` - override the log level for the AI logs; defaults to `logs.level` * `retention` - how many days to store AI logs; defaults to `logs.retention` @@ -809,7 +809,7 @@ A sub-object with specific options for AI logging: A sub-object with specific options for daemon logging: * `enabled` - whether to output daemon logs; defaults to `logs.enabled` -* `file` - the filename to use for the daemon logs; defaults to `"daemon.log"`. Can be absolute, or relative to `logs.dir`. +* `file` - the filename to use for the daemon logs; defaults to `"daemon.log"`. Always relative to `logs.dir`. * `level` - override the log level for the daemon logs; defaults to `logs.level` * `retention` - how many days to store daemon logs; defaults to `logs.retention` @@ -818,7 +818,7 @@ A sub-object with specific options for daemon logging: A sub-object with specific options for search logging: * `enabled` - whether to output search logs; defaults to `logs.enabled` -* `file` - the filename to use for the search logs; defaults to `"search.log"`. Can be absolute, or relative to `logs.dir`. +* `file` - the filename to use for the search logs; defaults to `"search.log"`. Always relative to `logs.dir`. * `level` - override the log level for the search logs; defaults to `logs.level` * `retention` - how many days to store search logs; defaults to `logs.retention` |
