aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-02 09:12:20 -0800
committerGitHub <noreply@github.com>2026-03-02 18:12:20 +0100
commit4c9180c2755b6457113e8d6a7566c32cf1ad547a (patch)
tree8136d818898232d811dbc452bb52a16c38b8f8e3
parentfix: regen cargo dist (diff)
downloadatuin-4c9180c2755b6457113e8d6a7566c32cf1ad547a.zip
chore: Move atuin ai subcommand into core binary (#3212)
-rw-r--r--Cargo.lock1
-rw-r--r--crates/atuin-ai/src/commands.rs72
-rw-r--r--crates/atuin-ai/src/commands/init.rs14
-rw-r--r--crates/atuin-ai/src/commands/inline.rs32
-rw-r--r--crates/atuin-ai/src/lib.rs2
-rw-r--r--crates/atuin-ai/src/main.rs7
-rw-r--r--crates/atuin-ai/src/tui/terminal.rs2
-rw-r--r--crates/atuin-client/Cargo.toml5
-rw-r--r--crates/atuin-client/src/settings.rs26
-rw-r--r--crates/atuin/Cargo.toml4
-rw-r--r--crates/atuin/src/command/client.rs8
-rw-r--r--docs/docs/ai/introduction.md2
-rw-r--r--docs/docs/ai/settings.md12
-rw-r--r--docs/docs/configuration/config.md6
14 files changed, 105 insertions, 88 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 248da058..4cba6ef8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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`