Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion crates/bashkit-cli/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// Decision: Ctrl-C during execution via signal-hook + cancellation_token.
// Decision: tab completion from builtins + VFS paths + functions + variables.
// Decision: PS1 prompt with bash-compatible escapes (\u, \h, \w, \$).
// Decision: exit via ExitSignal event fired by the interpreter — REPL polls
// the signal after each exec() call.
// See specs/018-interactive-shell.md

use anyhow::Result;
Expand Down Expand Up @@ -414,7 +416,22 @@ fn test_bash() -> bashkit::Bash {
.build()
}

pub async fn run(mut bash: bashkit::Bash) -> Result<i32> {
/// Shared exit state passed from the builder's `on_exit` hook to the REPL.
pub struct ExitState {
pub requested: AtomicBool,
pub code: std::sync::atomic::AtomicI32,
}

impl ExitState {
pub fn new() -> Self {
Self {
requested: AtomicBool::new(false),
code: std::sync::atomic::AtomicI32::new(0),
}
}
}

pub async fn run(mut bash: bashkit::Bash, exit_state: Arc<ExitState>) -> Result<i32> {
// Set up interactive environment
set_interactive_env(&mut bash).await;

Expand Down Expand Up @@ -558,6 +575,12 @@ pub async fn run(mut bash: bashkit::Bash) -> Result<i32> {
};

last_exit_code = result.exit_code;

// The on_exit hook (registered via builder) fires when `exit` runs.
if exit_state.requested.load(Ordering::Acquire) {
last_exit_code = exit_state.code.load(std::sync::atomic::Ordering::Relaxed);
break;
}
}

Ok(last_exit_code)
Expand Down Expand Up @@ -797,6 +820,62 @@ mod tests {
assert!(r.stderr.is_empty());
}

/// Build a Bash with an on_exit hook that stores the exit code.
fn test_bash_with_exit_hook() -> (bashkit::Bash, Arc<std::sync::atomic::AtomicI32>) {
use std::sync::atomic::{AtomicI32, Ordering};
let code = Arc::new(AtomicI32::new(-1));
let c = Arc::clone(&code);
let bash = bashkit::Bash::builder()
.tty(0, true)
.tty(1, true)
.tty(2, true)
.limits(bashkit::ExecutionLimits::cli())
.session_limits(bashkit::SessionLimits::unlimited())
.on_exit(Box::new(move |event| {
c.store(event.code, Ordering::Relaxed);
bashkit::hooks::HookAction::Continue(event)
}))
.build();
(bash, code)
}

#[tokio::test(flavor = "current_thread")]
async fn on_exit_hook_fires() {
let (mut bash, code) = test_bash_with_exit_hook();
bash.exec("exit").await.unwrap();
assert_eq!(code.load(std::sync::atomic::Ordering::Relaxed), 0);
}

#[tokio::test(flavor = "current_thread")]
async fn on_exit_hook_carries_code() {
let (mut bash, code) = test_bash_with_exit_hook();
bash.exec("exit 42").await.unwrap();
assert_eq!(code.load(std::sync::atomic::Ordering::Relaxed), 42);
}

#[tokio::test(flavor = "current_thread")]
async fn on_exit_hook_fires_in_command_list() {
let (mut bash, code) = test_bash_with_exit_hook();
bash.exec("echo bye; exit 1").await.unwrap();
assert_eq!(code.load(std::sync::atomic::Ordering::Relaxed), 1);
}

#[tokio::test(flavor = "current_thread")]
async fn on_exit_hook_not_fired_for_normal_commands() {
let (mut bash, code) = test_bash_with_exit_hook();
bash.exec("echo hello").await.unwrap();
// Hook should not have been called — code stays at initial -1.
assert_eq!(code.load(std::sync::atomic::Ordering::Relaxed), -1);
}

#[tokio::test(flavor = "current_thread")]
async fn on_exit_hook_code_truncated_to_byte() {
let (mut bash, code) = test_bash_with_exit_hook();
bash.exec("exit 256").await.unwrap();
// exit truncates to 0-255 via & 0xFF
assert_eq!(code.load(std::sync::atomic::Ordering::Relaxed), 0);
}

// --- Tab completion: helpers ---

/// Build a BashkitHelper wired to the given Bash instance's VFS and state.
Expand Down
22 changes: 20 additions & 2 deletions crates/bashkit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ struct RunOutput {
}

fn build_bash(args: &Args, mode: CliMode) -> bashkit::Bash {
configure_bash(args, mode).build()
}

fn configure_bash(args: &Args, mode: CliMode) -> bashkit::BashBuilder {
let mut builder = bashkit::Bash::builder();

if !args.no_http {
Expand Down Expand Up @@ -169,7 +173,7 @@ fn build_bash(args: &Args, mode: CliMode) -> bashkit::Bash {
builder = builder.tty(0, true).tty(1, true).tty(2, true);
}

builder.build()
builder
}

fn cli_mode(args: &Args) -> CliMode {
Expand Down Expand Up @@ -260,11 +264,25 @@ fn main() -> Result<()> {

#[cfg(feature = "interactive")]
fn run_interactive(args: Args, mode: CliMode) -> Result<i32> {
use std::sync::Arc;

let exit_state = Arc::new(interactive::ExitState::new());
let es = Arc::clone(&exit_state);
let bash = configure_bash(&args, mode)
.on_exit(Box::new(move |event| {
es.code
.store(event.code, std::sync::atomic::Ordering::Relaxed);
es.requested
.store(true, std::sync::atomic::Ordering::Release);
bashkit::hooks::HookAction::Continue(event)
}))
.build();

Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to build interactive runtime")?
.block_on(interactive::run(build_bash(&args, mode)))
.block_on(interactive::run(bash, exit_state))
}

fn run_mcp(args: Args, mode: CliMode) -> Result<()> {
Expand Down
69 changes: 69 additions & 0 deletions crates/bashkit/src/hooks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Interceptor hooks for the Bash execution pipeline.
//
// Decision: all hooks are interceptors (can inspect, modify, or cancel).
// Decision: sync callbacks — async consumers bridge via channels.
// Decision: zero cost when no hooks registered (Vec::is_empty check).
// Decision: hooks registered via BashBuilder, frozen at build() — no mutex.
//
// Only `on_exit` is wired up now. Other hooks (before_exec, after_exec,
// before_tool, after_tool, before_http, after_http, on_error) will be
// added as needed — the infrastructure is ready. See issue #1235.

/// Result returned by an interceptor hook.
///
/// Every hook receives owned data and must return it (possibly modified)
/// via `Continue`, or abort the operation via `Cancel`.
pub enum HookAction<T> {
/// Proceed with the (possibly modified) value.
Continue(T),
/// Abort the operation with a reason.
Cancel(String),
}

/// An interceptor hook: receives owned data, returns [`HookAction`].
///
/// Must be `Send + Sync` so hooks can be registered from any thread
/// and fired from the async interpreter.
pub type Interceptor<T> = Box<dyn Fn(T) -> HookAction<T> + Send + Sync>;

/// Payload for `on_exit` hooks.
#[derive(Debug, Clone)]
pub struct ExitEvent {
/// Exit code passed to the `exit` builtin (0–255).
pub code: i32,
}

/// Frozen registry of interceptor hooks.
///
/// Built via [`BashBuilder::on_exit`](crate::BashBuilder::on_exit) and
/// immutable after construction — no mutex needed.
#[derive(Default)]
pub struct Hooks {
pub(crate) on_exit: Vec<Interceptor<ExitEvent>>,
}

impl Hooks {
/// Fire `on_exit` hooks. Returns the (possibly modified) event,
/// or `None` if a hook cancelled the exit.
pub(crate) fn fire_on_exit(&self, event: ExitEvent) -> Option<ExitEvent> {
if self.on_exit.is_empty() {
return Some(event);
}
let mut current = event;
for hook in &self.on_exit {
match hook(current) {
HookAction::Continue(e) => current = e,
HookAction::Cancel(_) => return None,
}
}
Some(current)
}
}

impl std::fmt::Debug for Hooks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Hooks")
.field("on_exit", &format!("{} hook(s)", self.on_exit.len()))
.finish()
}
}
16 changes: 16 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,8 @@ pub struct Interpreter {
/// Cancellation token: when set to `true`, execution aborts at the next
/// command boundary with `Error::Cancelled`.
cancelled: Arc<AtomicBool>,
/// Interceptor hooks registry (shared with Bash callers).
hooks: crate::hooks::Hooks,
/// Deferred output process substitutions: after a command writes to the
/// virtual file path, run these commands with the file content as stdin.
/// Each entry is (virtual_path, commands_to_run).
Expand Down Expand Up @@ -842,6 +844,7 @@ impl Interpreter {
pending_fd_output: HashMap::new(),
pending_fd_targets: Vec::new(),
cancelled: Arc::new(AtomicBool::new(false)),
hooks: crate::hooks::Hooks::default(),
deferred_proc_subs: Vec::new(),
random_state: AtomicU32::new(random_seed),
}
Expand All @@ -853,6 +856,16 @@ impl Interpreter {
Arc::clone(&self.cancelled)
}

/// Return a reference to the hooks registry.
pub fn hooks(&self) -> &crate::hooks::Hooks {
&self.hooks
}

/// Replace the hooks registry (called from BashBuilder::build).
pub(crate) fn set_hooks(&mut self, hooks: crate::hooks::Hooks) {
self.hooks = hooks;
}

/// Check if cancellation has been requested.
fn check_cancelled(&self) -> Result<()> {
if self.cancelled.load(Ordering::Relaxed) {
Expand Down Expand Up @@ -1264,6 +1277,9 @@ impl Interpreter {

// Stop on control flow (e.g. nounset error uses Return to abort)
if result.control_flow != ControlFlow::None {
if let ControlFlow::Exit(code) = result.control_flow {
self.hooks.fire_on_exit(crate::hooks::ExitEvent { code });
}
break;
}

Expand Down
53 changes: 51 additions & 2 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ mod builtins;
mod error;
mod fs;
mod git;
/// Interceptor hooks for the execution pipeline.
pub mod hooks;
mod interpreter;
mod limits;
#[cfg(feature = "logging")]
Expand Down Expand Up @@ -795,6 +797,15 @@ impl Bash {
self.interpreter.cancellation_token()
}

/// Return the hooks registry (read-only after build).
///
/// Hooks are registered via [`BashBuilder::on_exit`] and frozen
/// at build time. Currently supports `on_exit`; more hooks will
/// be added (see issue #1235).
pub fn hooks(&self) -> &hooks::Hooks {
self.interpreter.hooks()
}

/// Get a clone of the underlying filesystem.
///
/// Provides direct access to the virtual filesystem for:
Expand Down Expand Up @@ -1076,6 +1087,8 @@ pub struct BashBuilder {
real_mounts: Vec<MountedRealDir>,
/// Optional VFS path for persistent history
history_file: Option<PathBuf>,
/// Interceptor hooks
hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
}

impl BashBuilder {
Expand Down Expand Up @@ -1653,6 +1666,33 @@ impl BashBuilder {
self
}

/// Register an `on_exit` interceptor hook.
///
/// Fired when the `exit` builtin runs. The hook can inspect or
/// modify the [`ExitEvent`](hooks::ExitEvent), or cancel the exit.
/// Multiple hooks run in registration order.
///
/// # Example
///
/// ```rust
/// use bashkit::hooks::{HookAction, ExitEvent};
/// use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
///
/// let exited = Arc::new(AtomicBool::new(false));
/// let flag = exited.clone();
///
/// let bash = bashkit::Bash::builder()
/// .on_exit(Box::new(move |event: ExitEvent| {
/// flag.store(true, Ordering::Relaxed);
/// HookAction::Continue(event)
/// }))
/// .build();
/// ```
pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
self.hooks_on_exit.push(hook);
self
}

/// Mount a text file in the virtual filesystem.
///
/// This creates a regular file (mode `0o644`) with the specified content at
Expand Down Expand Up @@ -1955,7 +1995,7 @@ impl BashBuilder {
let mountable = Arc::new(MountableFs::new(base_fs));
let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;

Self::build_with_fs(
let mut result = Self::build_with_fs(
fs,
mountable,
self.env,
Expand Down Expand Up @@ -1984,7 +2024,16 @@ impl BashBuilder {
self.ssh_config,
#[cfg(feature = "ssh")]
self.ssh_handler,
)
);

// Set hooks after build — avoids adding another arg to build_with_fs.
if !self.hooks_on_exit.is_empty() {
result.interpreter.set_hooks(hooks::Hooks {
on_exit: self.hooks_on_exit,
});
}

result
}

/// Apply real filesystem mounts to the base filesystem.
Expand Down
11 changes: 10 additions & 1 deletion specs/018-interactive-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ bashkit --mount-rw /path/to/work # REPL with real filesystem access
| Multiline input (continuation) | Implemented | 1 |
| Ctrl-C clears current line | Implemented | 1 |
| Ctrl-D exits shell | Implemented | 1 |
| `exit [N]` builtin | Implemented (pre-existing) | 1 |
| `exit [N]` builtin | Implemented (on_exit hook) | 1 |
| Streaming output | Implemented | 1 |
| TTY detection (`[ -t 0 ]`) | Implemented | 1 |
| Readline editing (emacs/vi keys) | Implemented (rustyline) | 1 |
Expand Down Expand Up @@ -111,6 +111,15 @@ Uses `signal-hook` to register a SIGINT handler that sets bashkit's
every 50ms and propagates to the cancel token. After cancellation,
the token is reset for the next command.

#### Exit Handling

The `exit` builtin fires an `on_exit` hook registered via
`BashBuilder::on_exit()`. The interactive REPL registers a hook at
build time that sets an atomic flag. After each `exec()` call, the
REPL checks the flag and breaks the loop if set. This works through
the normal execution pipeline — `echo bye; exit 1`, conditionals,
and scripts all terminate the session correctly.

#### Multiline Detection

When a command fails to parse with known incomplete-input errors,
Expand Down
Loading