mod ai_router;
mod editor;
mod input;
mod investigate;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use reedline::{Reedline, Signal};
use tracing::{info, warn};
use std::sync::atomic::AtomicBool as StaticAtomicBool;
static RESTART_FLAG: StaticAtomicBool = StaticAtomicBool::new(false);
use crate::ai::{ConversationState, JarvisAI};
use crate::cli::prompt::starship::CMD_DURATION_NONE;
use crate::cli::prompt::{ShellPrompt, EXIT_CODE_NONE};
use crate::config::JarvishConfig;
use crate::engine::classifier::InputClassifier;
use crate::engine::expand;
use crate::engine::LoopAction;
use crate::storage::BlackBox;
pub struct Shell {
editor: Reedline,
prompt: ShellPrompt,
ai_client: Option<JarvisAI>,
black_box: Option<BlackBox>,
conversation_state: Option<ConversationState>,
last_exit_code: Arc<AtomicI32>,
cmd_duration_ms: Arc<AtomicU64>,
classifier: Arc<InputClassifier>,
aliases: HashMap<String, String>,
ignore_auto_investigation_cmds: Vec<String>,
dir_stack: Vec<PathBuf>,
farewell_shown: bool,
history_available: bool,
logging_operational: bool,
git_branch_commands: Arc<RwLock<Vec<String>>>,
restart_requested: Arc<AtomicBool>,
}
impl Shell {
pub fn new(logging_operational: bool, session_id: i64) -> Self {
let config = JarvishConfig::load();
Self::apply_exports(&config);
let classifier = Arc::new(InputClassifier::new());
let data_dir = BlackBox::data_dir();
let git_branch_commands =
Arc::new(RwLock::new(config.completion.git_branch_commands.clone()));
let db_path = data_dir.join("history.db");
let (reedline, history_available) = editor::build_editor(
Arc::clone(&classifier),
db_path,
session_id,
Arc::clone(&git_branch_commands),
);
let last_exit_code = Arc::new(AtomicI32::new(EXIT_CODE_NONE));
let cmd_duration_ms = Arc::new(AtomicU64::new(CMD_DURATION_NONE));
let prompt = Self::build_prompt(
&config,
Arc::clone(&last_exit_code),
Arc::clone(&cmd_duration_ms),
);
prompt.refresh_git_status();
let black_box = match BlackBox::open_at(data_dir, session_id) {
Ok(bb) => {
info!("BlackBox initialized successfully");
Some(bb)
}
Err(e) => {
warn!("Failed to initialize BlackBox: {e}");
eprintln!("jarvish: warning: failed to initialize black box: {e}");
None
}
};
let ai_client = match JarvisAI::new(&config.ai) {
Ok(ai) => {
info!("AI client initialized successfully");
Some(ai)
}
Err(e) => {
warn!("AI disabled: {e}");
eprintln!("jarvish: warning: AI disabled: {e}");
None }
};
Self {
editor: reedline,
prompt,
ai_client,
black_box,
conversation_state: None,
last_exit_code,
cmd_duration_ms,
classifier,
aliases: config.alias,
ignore_auto_investigation_cmds: config.ai.ignore_auto_investigation_cmds,
dir_stack: Vec::new(),
farewell_shown: false,
history_available,
logging_operational,
git_branch_commands,
restart_requested: Arc::new(AtomicBool::new(false)),
}
}
fn apply_exports(config: &JarvishConfig) {
for (key, value) in &config.export {
let expanded = expand::expand_token(value);
let display = format!("{key}={expanded}");
let masked = if crate::storage::sanitizer::contains_secrets(&display) {
crate::storage::sanitizer::mask_secrets(&display)
} else {
display
};
info!(masked = %masked, "Applying export from config");
unsafe {
std::env::set_var(key, &expanded);
}
}
}
fn build_prompt(
config: &JarvishConfig,
last_exit_code: Arc<AtomicI32>,
cmd_duration_ms: Arc<AtomicU64>,
) -> ShellPrompt {
if config.prompt.starship {
if let Some(path) = Self::detect_starship() {
info!(starship_path = %path.display(), "Starship prompt enabled");
return ShellPrompt::starship(last_exit_code, cmd_duration_ms, path);
}
eprintln!(
"jarvish: warning: starship = true but starship command or config not found, \
falling back to builtin prompt"
);
}
ShellPrompt::builtin(last_exit_code, config.prompt.clone())
}
fn detect_starship() -> Option<PathBuf> {
let starship_path = which::which("starship").ok()?;
let config_path = std::env::var("STARSHIP_CONFIG")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".config/starship.toml")
});
if config_path.exists() {
Some(starship_path)
} else {
info!(
config_path = %config_path.display(),
"Starship config file not found"
);
None
}
}
pub(super) fn reload_config(&mut self, path: &std::path::Path) -> crate::engine::CommandResult {
use crate::engine::CommandResult;
let config = match JarvishConfig::load_from(path) {
Ok(c) => c,
Err(msg) => {
let err = format!("jarvish: source: {msg}\n");
eprint!("{err}");
return CommandResult::error(err, 1);
}
};
self.aliases = config.alias.clone();
Self::apply_exports(&config);
if let Some(ref mut ai) = self.ai_client {
ai.update_config(&config.ai);
}
self.ignore_auto_investigation_cmds = config.ai.ignore_auto_investigation_cmds.clone();
self.prompt = Self::build_prompt(
&config,
Arc::clone(&self.last_exit_code),
Arc::clone(&self.cmd_duration_ms),
);
self.prompt.refresh_git_status();
if let Ok(mut cmds) = self.git_branch_commands.write() {
*cmds = config.completion.git_branch_commands.clone();
}
let ignore_cmds_display = if config.ai.ignore_auto_investigation_cmds.is_empty() {
"none".to_string()
} else {
format!("{:?}", config.ai.ignore_auto_investigation_cmds)
};
let summary = format!(
"Loaded {}\n\
\x20 [ai]\n\
\x20\x20 model: {}\n\
\x20\x20 max_rounds: {}\n\
\x20\x20 markdown_rendering: {}\n\
\x20\x20 ai_pipe_max_chars: {}\n\
\x20\x20 ai_redirect_max_chars: {}\n\
\x20\x20 temperature: {}\n\
\x20\x20 ignore_auto_investigation_cmds: {}\n\
\x20 [alias] {} {}\n\
\x20 [export] {} {}\n\
\x20 [prompt] nerd_font: {}, starship: {}\n\
\x20 [completion] git_branch_commands: {} {}\n",
path.display(),
config.ai.model,
config.ai.max_rounds,
config.ai.markdown_rendering,
config.ai.ai_pipe_max_chars,
config.ai.ai_redirect_max_chars,
config.ai.temperature,
ignore_cmds_display,
config.alias.len(),
if config.alias.len() == 1 {
"entry"
} else {
"entries"
},
config.export.len(),
if config.export.len() == 1 {
"entry"
} else {
"entries"
},
config.prompt.nerd_font,
config.prompt.starship,
config.completion.git_branch_commands.len(),
if config.completion.git_branch_commands.len() == 1 {
"command"
} else {
"commands"
},
);
print!("{summary}");
CommandResult::success(summary)
}
pub async fn run_command(&mut self, command: &str) -> i32 {
for line in command.lines() {
if !self.handle_input(line).await {
break;
}
}
if let Some(ref bb) = self.black_box {
bb.release_session();
}
let code = self.last_exit_code.load(Ordering::Relaxed);
if code == EXIT_CODE_NONE {
0
} else {
code
}
}
pub async fn run(&mut self) -> (i32, LoopAction) {
let mut offline = Vec::new();
if !self.logging_operational {
offline.push("Logging offline");
}
if !self.history_available {
offline.push("Command History offline");
}
if self.black_box.is_none() {
offline.push("Black Box offline");
}
if self.ai_client.is_none() {
offline.push("AI module offline");
}
crate::cli::banner::print_welcome(&offline);
let update_check = tokio::spawn(crate::cli::update_check::check_for_update_notification());
let mut repl_error = false;
let mut action = LoopAction::Exit;
Self::register_sigusr1_handler(Arc::clone(&self.restart_requested));
if let Ok(Ok(Some(notification))) =
tokio::time::timeout(std::time::Duration::from_secs(1), update_check).await
{
println!("{notification}");
println!();
}
loop {
if let Some(notification) = crate::engine::builtins::update::check_update_flag() {
println!(" {notification}");
println!();
}
if self.restart_requested.load(Ordering::Relaxed) {
info!("Deferred restart triggered (SIGUSR1 received during command execution)");
println!("Restarting jarvish (deferred SIGUSR1)...");
action = LoopAction::Restart;
break;
}
let signal = tokio::task::block_in_place(|| self.editor.read_line(&self.prompt));
if self.restart_requested.load(Ordering::Relaxed) {
info!("SIGUSR1 received during read_line: restarting shell");
println!("\nRestarting jarvish (SIGUSR1)...");
action = LoopAction::Restart;
break;
}
match signal {
Ok(Signal::Success(line)) => {
let result = self.handle_input(&line).await;
if !result {
if self.restart_requested.load(Ordering::Relaxed) {
action = LoopAction::Restart;
}
break;
}
self.prompt.refresh_git_status();
}
Ok(Signal::CtrlC) => {
info!("\n!!!! Ctrl-C received: do it nothing !!!!!\n");
println!(); }
Ok(Signal::CtrlD) => {
info!("\n!!!! Ctrl-D received: exiting shell !!!!!\n");
break;
}
Err(e) => {
warn!(error = %e, "REPL error, exiting");
eprintln!("jarvish: error: {e}");
repl_error = true;
break;
}
}
}
if action != LoopAction::Restart && !self.farewell_shown {
crate::cli::banner::print_goodbye();
}
if let Some(ref bb) = self.black_box {
bb.release_session();
}
let exit_code = if repl_error {
1
} else {
let code = self.last_exit_code.load(Ordering::Relaxed);
if code == EXIT_CODE_NONE {
0
} else {
code
}
};
(exit_code, action)
}
fn register_sigusr1_handler(restart_flag: Arc<AtomicBool>) {
extern "C" fn handle_sigusr1(_: libc::c_int) {
RESTART_FLAG.store(true, Ordering::Relaxed);
}
RESTART_FLAG.store(false, Ordering::Relaxed);
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_millis(100));
if RESTART_FLAG.load(Ordering::Relaxed) {
restart_flag.store(true, Ordering::Relaxed);
break;
}
});
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handle_sigusr1 as *const () as usize;
sa.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut sa.sa_mask);
if libc::sigaction(libc::SIGUSR1, &sa, std::ptr::null_mut()) == 0 {
info!("SIGUSR1 handler registered for self-restart");
} else {
let e = std::io::Error::last_os_error();
warn!(error = %e, "Failed to register SIGUSR1 handler");
eprintln!("jarvish: warning: SIGUSR1 handler unavailable: {e}");
}
}
}
pub fn exec_restart(&mut self) -> std::io::Error {
use std::os::unix::process::CommandExt;
let _ = std::io::Write::flush(&mut std::io::stdout());
let _ = std::io::Write::flush(&mut std::io::stderr());
info!("exec_restart: executing self-restart");
let (exe, args) = match build_restart_command() {
Ok(pair) => pair,
Err(e) => return e,
};
std::process::Command::new(exe).args(&args).exec()
}
}
fn build_restart_command() -> Result<(PathBuf, Vec<String>), std::io::Error> {
let exe = std::env::current_exe().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("failed to get current exe path: {e}"),
)
})?;
let args: Vec<String> = std::env::args().skip(1).collect();
Ok((exe, args))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_restart_command_returns_valid_exe() {
let result = build_restart_command();
assert!(result.is_ok());
let (exe, _args) = result.unwrap();
assert!(exe.exists(), "current_exe path should exist");
}
#[test]
fn build_restart_command_args_exclude_binary_name() {
let (_, args) = build_restart_command().unwrap();
for arg in &args {
assert!(
!arg.contains("jarvish-") && !arg.ends_with("jarvish"),
"args should not contain binary name, got: {arg}"
);
}
}
#[test]
fn restart_flag_initial_state_is_false() {
RESTART_FLAG.store(false, Ordering::Relaxed);
assert!(!RESTART_FLAG.load(Ordering::Relaxed));
}
#[test]
fn restart_flag_can_be_set_and_read() {
RESTART_FLAG.store(true, Ordering::Relaxed);
assert!(RESTART_FLAG.load(Ordering::Relaxed));
RESTART_FLAG.store(false, Ordering::Relaxed);
}
#[test]
fn sigusr1_handler_propagates_to_restart_flag() {
let restart_flag = Arc::new(AtomicBool::new(false));
Shell::register_sigusr1_handler(Arc::clone(&restart_flag));
unsafe {
libc::kill(libc::getpid(), libc::SIGUSR1);
}
for _ in 0..40 {
if restart_flag.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
restart_flag.load(Ordering::Relaxed),
"SIGUSR1 should propagate to restart_flag via polling thread"
);
RESTART_FLAG.store(false, Ordering::Relaxed);
}
#[test]
fn restart_requested_flag_default_is_false() {
let flag = Arc::new(AtomicBool::new(false));
assert!(!flag.load(Ordering::Relaxed));
}
#[test]
fn restart_requested_flag_set_triggers_restart() {
let flag = Arc::new(AtomicBool::new(false));
flag.store(true, Ordering::Relaxed);
assert!(flag.load(Ordering::Relaxed));
}
#[test]
fn check_update_flag_returns_none_without_flag_file() {
use crate::engine::builtins::update;
let _ = update::check_update_flag();
assert!(update::check_update_flag().is_none());
}
#[test]
fn check_update_flag_returns_notification_with_flag_file() {
use crate::engine::builtins::update;
let _ = update::check_update_flag();
update::write_update_flag_for_test("2.0.0");
let msg = update::check_update_flag();
assert!(msg.is_some());
assert!(msg.unwrap().contains("v2.0.0"));
assert!(update::check_update_flag().is_none());
}
}