use std::collections::HashMap;
use std::collections::HashSet;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
use atuin_scripts::execution::template_script;
use atuin_scripts::{
execution::{build_executable_script, execute_script_interactive, template_variables},
store::{ScriptStore, script::Script},
};
use clap::{Parser, Subcommand};
use eyre::OptionExt;
use eyre::{Result, bail};
use tempfile::NamedTempFile;
use atuin_client::{database::Database, record::sqlite_store::SqliteStore, settings::Settings};
use tracing::debug;
#[derive(Parser, Debug)]
pub struct NewScript {
pub name: String,
#[arg(short, long)]
pub description: Option<String>,
#[arg(short, long)]
pub tags: Vec<String>,
#[arg(short, long)]
pub shebang: Option<String>,
#[arg(long)]
pub script: Option<PathBuf>,
#[allow(clippy::option_option)]
#[arg(long)]
pub last: Option<Option<usize>>,
#[arg(long)]
pub no_edit: bool,
}
#[derive(Parser, Debug)]
pub struct Run {
pub name: String,
#[arg(short, long = "var")]
pub var: Vec<String>,
}
#[derive(Parser, Debug)]
pub struct List {}
#[derive(Parser, Debug)]
pub struct Get {
pub name: String,
#[arg(short, long)]
pub script: bool,
}
#[derive(Parser, Debug)]
pub struct Edit {
pub name: String,
#[arg(short, long)]
pub description: Option<String>,
#[arg(short, long)]
pub tags: Vec<String>,
#[arg(long)]
pub no_tags: bool,
#[arg(long)]
pub rename: Option<String>,
#[arg(short, long)]
pub shebang: Option<String>,
#[arg(long)]
pub script: Option<PathBuf>,
#[allow(clippy::struct_field_names)]
#[arg(long)]
pub no_edit: bool,
}
#[derive(Parser, Debug)]
pub struct Delete {
pub name: String,
#[arg(short, long)]
pub force: bool,
}
#[derive(Subcommand, Debug)]
#[command(infer_subcommands = true)]
pub enum Cmd {
New(NewScript),
Run(Run),
#[command(alias = "ls")]
List(List),
Get(Get),
Edit(Edit),
#[command(alias = "rm")]
Delete(Delete),
}
impl Cmd {
fn open_editor(initial_content: Option<&str>) -> Result<String> {
let temp_file = NamedTempFile::new()?;
let path = temp_file.into_temp_path();
if let Some(content) = initial_content {
std::fs::write(&path, content)?;
}
let editor_str = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let parts = shlex::split(&editor_str).ok_or_eyre("Failed to parse editor command")?;
let (command, args) = parts.split_first().ok_or_eyre("No editor command found")?;
let status = std::process::Command::new(command)
.args(args)
.arg(&path)
.status()?;
if !status.success() {
bail!("failed to open editor");
}
let content = std::fs::read_to_string(&path)?;
path.close()?;
Ok(content)
}
async fn execute_script(script_content: String, shebang: String) -> Result<i32> {
let mut session = execute_script_interactive(script_content, shebang)
.await
.expect("failed to execute script");
let (exit_tx, mut exit_rx) = tokio::sync::oneshot::channel();
let sender = session.stdin_tx.clone();
let stdin_task = tokio::spawn(async move {
use tokio::io::AsyncReadExt;
use tokio::select;
let stdin = tokio::io::stdin();
let mut reader = tokio::io::BufReader::new(stdin);
let mut buffer = vec![0u8; 1024];
loop {
select! {
_ = &mut exit_rx => {
break;
}
read_result = reader.read(&mut buffer) => {
match read_result {
Ok(0) => break, Ok(n) => {
let input = String::from_utf8_lossy(&buffer[0..n]).to_string();
if let Err(e) = sender.send(input).await {
eprintln!("Error sending input to script: {e}");
break;
}
},
Err(e) => {
eprintln!("Error reading from stdin: {e}");
break;
}
}
}
}
}
});
let exit_code = session.wait_for_exit().await;
let _ = exit_tx.send(());
let _ = stdin_task.await;
let code = exit_code.unwrap_or(-1);
if code != 0 {
eprintln!("Script exited with code {code}");
}
Ok(code)
}
async fn handle_new_script(
settings: &Settings,
new_script: NewScript,
script_store: ScriptStore,
script_db: atuin_scripts::database::Database,
history_db: &impl Database,
) -> Result<()> {
let mut stdin = std::io::stdin();
let script_content = if let Some(count_opt) = new_script.last {
let count = count_opt.unwrap_or(1) + 1; let context = atuin_client::database::current_context().await?;
let filters = [settings.default_filter_mode(context.git_root.is_some())];
let mut history = history_db
.list(&filters, &context, Some(count), false, false)
.await?;
history.reverse();
if !history.is_empty() {
history.pop(); }
let commands: Vec<String> = history.iter().map(|h| h.command.clone()).collect();
if commands.is_empty() {
bail!("No commands found in history");
}
let script_text = commands.join("\n");
if new_script.no_edit {
Some(script_text)
} else {
Some(Self::open_editor(Some(&script_text))?)
}
} else if let Some(script_path) = new_script.script {
let script_content = std::fs::read_to_string(script_path)?;
Some(script_content)
} else if !stdin.is_terminal() {
let mut buffer = String::new();
stdin.read_to_string(&mut buffer)?;
Some(buffer)
} else {
Some(Self::open_editor(None)?)
};
let script = Script::builder()
.name(new_script.name)
.description(new_script.description.unwrap_or_default())
.shebang(new_script.shebang.unwrap_or_default())
.tags(new_script.tags)
.script(script_content.unwrap_or_default())
.build();
script_store.create(script).await?;
script_store.build(script_db).await?;
Ok(())
}
async fn handle_run(
_settings: &Settings,
run: Run,
script_db: atuin_scripts::database::Database,
) -> Result<()> {
let script = script_db.get_by_name(&run.name).await?;
if let Some(script) = script {
let variables = template_variables(&script)?;
let mut variable_values: HashMap<String, serde_json::Value> = HashMap::new();
for var_str in &run.var {
if let Some((key, value)) = var_str.split_once('=') {
variable_values.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
debug!("Using CLI variable: {}={}", key, value);
} else {
eprintln!("Warning: Ignoring malformed variable specification: {var_str}");
eprintln!("Variables should be specified as KEY=VALUE");
}
}
let remaining_vars: HashSet<String> = variables
.into_iter()
.filter(|var| !variable_values.contains_key(var))
.collect();
if !remaining_vars.is_empty() {
println!("This script contains template variables that need values:");
let stdin = std::io::stdin();
let mut input = String::new();
for var in remaining_vars {
input.clear();
println!("Enter value for '{var}': ");
if stdin.read_line(&mut input).is_err() {
eprintln!("Failed to read input for variable '{var}'");
variable_values.insert(var, serde_json::Value::String(String::new()));
continue;
}
let value = input.trim().to_string();
variable_values.insert(var, serde_json::Value::String(value));
}
}
let final_script = if variable_values.is_empty() {
script.script.clone()
} else {
debug!("Templating script with variables: {:?}", variable_values);
template_script(&script, &variable_values)?
};
Self::execute_script(final_script, script.shebang.clone()).await?;
} else {
bail!("script not found");
}
Ok(())
}
async fn handle_list(
_settings: &Settings,
_list: List,
script_db: atuin_scripts::database::Database,
) -> Result<()> {
let scripts = script_db.list().await?;
if scripts.is_empty() {
println!("No scripts found");
} else {
println!("Available scripts:");
for script in scripts {
if script.tags.is_empty() {
println!("- {} ", script.name);
} else {
println!("- {} [tags: {}]", script.name, script.tags.join(", "));
}
if !script.description.is_empty() {
println!(" Description: {}", script.description);
}
}
}
Ok(())
}
async fn handle_get(
_settings: &Settings,
get: Get,
script_db: atuin_scripts::database::Database,
) -> Result<()> {
let script = script_db.get_by_name(&get.name).await?;
if let Some(script) = script {
if get.script {
print!(
"{}",
build_executable_script(script.script.clone(), script.shebang)
);
return Ok(());
}
println!("---");
println!("name: {}", script.name);
println!("id: {}", script.id);
if script.description.is_empty() {
println!("description: \"\"");
} else {
println!("description: |");
for line in script.description.lines() {
println!(" {line}");
}
}
if script.tags.is_empty() {
println!("tags: []");
} else {
println!("tags:");
for tag in &script.tags {
println!(" - {tag}");
}
}
println!("shebang: {}", script.shebang);
println!("script: |");
for line in script.script.lines() {
println!(" {line}");
}
Ok(())
} else {
bail!("script '{}' not found", get.name);
}
}
#[allow(clippy::cognitive_complexity)]
async fn handle_edit(
_settings: &Settings,
edit: Edit,
script_store: ScriptStore,
script_db: atuin_scripts::database::Database,
) -> Result<()> {
debug!("editing script {:?}", edit);
let existing_script = script_db.get_by_name(&edit.name).await?;
debug!("existing script {:?}", existing_script);
if let Some(mut script) = existing_script {
if let Some(description) = edit.description {
script.description = description;
}
if let Some(new_name) = edit.rename {
if (script_db.get_by_name(&new_name).await?).is_some() {
bail!("A script named '{}' already exists", new_name);
}
script.name = new_name;
}
if edit.no_tags {
script.tags.clear();
} else if !edit.tags.is_empty() {
script.tags = edit.tags;
}
if let Some(shebang) = edit.shebang {
script.shebang = shebang;
}
let script_content = if let Some(script_path) = edit.script {
std::fs::read_to_string(script_path)?
} else if !edit.no_edit {
Self::open_editor(Some(&script.script))?
} else {
script.script.clone()
};
script.script = script_content;
script_store.update(script).await?;
script_store.build(script_db).await?;
println!("Script '{}' updated successfully!", edit.name);
Ok(())
} else {
bail!("script '{}' not found", edit.name);
}
}
async fn handle_delete(
_settings: &Settings,
delete: Delete,
script_store: ScriptStore,
script_db: atuin_scripts::database::Database,
) -> Result<()> {
let script = script_db.get_by_name(&delete.name).await?;
if let Some(script) = script {
if !delete.force {
println!(
"Are you sure you want to delete script '{}'? [y/N]",
delete.name
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input != "y" && input != "yes" {
println!("Deletion cancelled");
return Ok(());
}
}
script_store.delete(script.id).await?;
script_store.build(script_db).await?;
println!("Script '{}' deleted successfully", delete.name);
Ok(())
} else {
bail!("script '{}' not found", delete.name);
}
}
pub async fn run(
self,
settings: &Settings,
store: SqliteStore,
history_db: &impl Database,
) -> Result<()> {
let host_id = Settings::host_id().await?;
let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings)?.into();
let script_store = ScriptStore::new(store, host_id, encryption_key);
let script_db =
atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?;
match self {
Self::New(new_script) => {
Self::handle_new_script(settings, new_script, script_store, script_db, history_db)
.await
}
Self::Run(run) => Self::handle_run(settings, run, script_db).await,
Self::List(list) => Self::handle_list(settings, list, script_db).await,
Self::Get(get) => Self::handle_get(settings, get, script_db).await,
Self::Edit(edit) => Self::handle_edit(settings, edit, script_store, script_db).await,
Self::Delete(delete) => {
Self::handle_delete(settings, delete, script_store, script_db).await
}
}
}
}