mod auth;
mod compact;
pub(crate) mod runtime;
use self::{
auth::{
handle_login_builtin_interactive, handle_logout_builtin_interactive,
refresh_codex_auth_state,
},
compact::handle_compact_builtin,
};
use crate::{
checkpoints::{
ChangeClassification, ChangesReadModel, CheckpointStore, ParsedRewindTarget, RestorePlan,
RestoreStatus, parse_rewind_target, redacted_path_for_display, rewind_event_payload,
},
commands::{BuiltInCommand, CommandRegistry, ParsedSlashCommand, parse_slash_command},
config::{AuthState, EffectiveConfig},
sessions::{
Session, SessionEventKind, SessionManager, parse_prune_sessions_days, record_session_event,
},
};
use rustyline::{Config, DefaultEditor, error::ReadlineError};
use serde_json::json;
use std::{
io::{BufRead, Write},
path::PathBuf,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellAction {
Empty,
Prompt(String),
Quit,
BuiltIn {
command: BuiltInCommand,
arg: Option<String>,
},
UnknownCommand(String),
}
#[derive(Debug, Clone)]
pub struct ShellState {
pub session_manager: SessionManager,
pub current_session: Option<Session>,
pub cwd: PathBuf,
pub model: String,
pub auth_state: AuthState,
pub config: Option<EffectiveConfig>,
}
impl ShellState {
pub fn new(
session_manager: SessionManager,
current_session: Option<Session>,
cwd: PathBuf,
model: String,
auth_state: AuthState,
) -> Self {
Self {
session_manager,
current_session,
cwd,
model,
auth_state,
config: None,
}
}
pub fn with_config(mut self, config: EffectiveConfig) -> Self {
self.auth_state = config.auth_state();
self.config = Some(config);
self
}
pub fn active_session_id(&self) -> Option<&str> {
self.current_session.as_ref().map(|session| session.id())
}
}
pub fn handle_line(line: &str, commands: &CommandRegistry) -> ShellAction {
match parse_slash_command(line, commands) {
ParsedSlashCommand::Empty => ShellAction::Empty,
ParsedSlashCommand::Prompt(prompt) => ShellAction::Prompt(prompt.to_string()),
ParsedSlashCommand::Unknown { token } => ShellAction::UnknownCommand(token.to_string()),
ParsedSlashCommand::BuiltIn { command, arg } => match command {
BuiltInCommand::Quit => ShellAction::Quit,
command => ShellAction::BuiltIn {
command,
arg: arg.map(str::to_string),
},
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineRead {
Line(String),
Eof,
Interrupted,
}
pub trait LineReader {
fn read_line(&mut self, prompt: &str, writer: &mut dyn Write) -> anyhow::Result<LineRead>;
fn read_sensitive_line(
&mut self,
prompt: &str,
writer: &mut dyn Write,
) -> anyhow::Result<LineRead> {
self.read_line(prompt, writer)
}
}
pub struct BufReadLineReader<'a, R: BufRead> {
reader: &'a mut R,
}
impl<'a, R: BufRead> BufReadLineReader<'a, R> {
pub fn new(reader: &'a mut R) -> Self {
Self { reader }
}
}
impl<R: BufRead> LineReader for BufReadLineReader<'_, R> {
fn read_line(&mut self, prompt: &str, writer: &mut dyn Write) -> anyhow::Result<LineRead> {
write!(writer, "{prompt}")?;
writer.flush()?;
let mut line = String::new();
if self.reader.read_line(&mut line)? == 0 {
return Ok(LineRead::Eof);
}
Ok(LineRead::Line(line))
}
}
pub struct RustylineLineReader {
editor: DefaultEditor,
}
impl RustylineLineReader {
pub fn new() -> anyhow::Result<Self> {
let config = Config::builder()
.auto_add_history(false)
.history_ignore_space(true)
.build();
Ok(Self {
editor: DefaultEditor::with_config(config)?,
})
}
}
impl LineReader for RustylineLineReader {
fn read_line(&mut self, prompt: &str, writer: &mut dyn Write) -> anyhow::Result<LineRead> {
writer.flush()?;
match self.editor.readline(prompt) {
Ok(line) => {
if !line.trim().is_empty() && !line.trim_start().starts_with("/compact") {
self.editor.add_history_entry(line.as_str())?;
}
Ok(LineRead::Line(line))
}
Err(ReadlineError::Eof) => Ok(LineRead::Eof),
Err(ReadlineError::Interrupted) => Ok(LineRead::Interrupted),
Err(error) => Err(error.into()),
}
}
fn read_sensitive_line(
&mut self,
prompt: &str,
writer: &mut dyn Write,
) -> anyhow::Result<LineRead> {
writer.flush()?;
match self.editor.readline(prompt) {
Ok(line) => Ok(LineRead::Line(line)),
Err(ReadlineError::Eof) => Ok(LineRead::Eof),
Err(ReadlineError::Interrupted) => Ok(LineRead::Interrupted),
Err(error) => Err(error.into()),
}
}
}
pub fn run_repl<R: BufRead, W: Write>(
reader: &mut R,
writer: &mut W,
commands: &CommandRegistry,
state: &mut ShellState,
on_prompt: impl FnMut(&mut ShellState, &str, &mut W) -> anyhow::Result<String>,
) -> anyhow::Result<()> {
let mut line_reader = BufReadLineReader::new(reader);
run_repl_with_line_reader(&mut line_reader, writer, commands, state, on_prompt)
}
pub fn run_tty_repl<W: Write>(
writer: &mut W,
commands: &CommandRegistry,
state: &mut ShellState,
on_prompt: impl FnMut(&mut ShellState, &str, &mut W) -> anyhow::Result<String>,
) -> anyhow::Result<()> {
let mut line_reader = RustylineLineReader::new()?;
run_repl_with_line_reader(&mut line_reader, writer, commands, state, on_prompt)
}
pub fn run_repl_with_line_reader<W: Write>(
line_reader: &mut dyn LineReader,
writer: &mut W,
commands: &CommandRegistry,
state: &mut ShellState,
mut on_prompt: impl FnMut(&mut ShellState, &str, &mut W) -> anyhow::Result<String>,
) -> anyhow::Result<()> {
writeln!(writer, "magi-code interactive shell. Type /quit to exit.")?;
loop {
let line = match line_reader.read_line("> ", writer)? {
LineRead::Line(line) => line,
LineRead::Eof => {
writeln!(writer, "bye")?;
break;
}
LineRead::Interrupted => {
writeln!(writer, "^C")?;
continue;
}
};
match handle_line(&line, commands) {
ShellAction::Empty => {}
ShellAction::Quit => {
writeln!(writer, "bye")?;
break;
}
ShellAction::BuiltIn { command, arg } => {
match handle_builtin_interactive(
command,
arg.as_deref(),
state,
line_reader,
writer,
) {
Ok(output) => writeln!(writer, "{output}")?,
Err(error) => {
let message = error.to_string();
append_shell_diagnostic(state, &message)?;
writeln!(writer, "{message}")?;
}
}
}
ShellAction::UnknownCommand(command) => {
let message = format!("unknown command: {command}");
append_shell_diagnostic(state, &message)?;
writeln!(writer, "{message}")?;
}
ShellAction::Prompt(prompt) => {
let response = on_prompt(state, &prompt, writer)?;
if !response.is_empty() {
writeln!(writer, "{response}")?;
}
}
}
}
Ok(())
}
fn append_shell_diagnostic(state: &ShellState, message: &str) -> anyhow::Result<()> {
record_session_event(
state.current_session.as_ref(),
&state.cwd,
SessionEventKind::Diagnostic,
json!({"level":"error", "message": message}),
)
.map(|_| ())
}
fn handle_builtin_interactive(
command: BuiltInCommand,
arg: Option<&str>,
state: &mut ShellState,
line_reader: &mut dyn LineReader,
writer: &mut dyn Write,
) -> anyhow::Result<String> {
if command == BuiltInCommand::Login {
return handle_login_builtin_interactive(arg, state, line_reader, writer);
}
if command == BuiltInCommand::Logout {
return handle_logout_builtin_interactive(arg, state, line_reader, writer);
}
if command == BuiltInCommand::Compact {
writeln!(writer, "compacting session…")?;
}
if command == BuiltInCommand::Rewind {
return handle_rewind_builtin_interactive(arg, state, line_reader, writer);
}
handle_builtin(command, arg, state)
}
pub fn handle_builtin(
command: BuiltInCommand,
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match command {
BuiltInCommand::Compact => handle_compact_builtin(arg, state),
BuiltInCommand::Changes => handle_changes_builtin(state),
BuiltInCommand::Rewind => handle_rewind_builtin_noninteractive(arg, state),
BuiltInCommand::New => {
let session = state.session_manager.create()?;
state.current_session = Some(session);
Ok(format!(
"new session: {}",
state.active_session_id().unwrap_or("<disabled>")
))
}
BuiltInCommand::Login => {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/login requires loaded runtime config"))?;
let Some(provider) = arg else {
return Ok(crate::login::provider_list_text(&config.paths));
};
if provider == crate::login::CUSTOM_PROVIDER_LOGIN_ID || provider == "custom" {
anyhow::bail!("/login custom-provider requires interactive shell prompts")
}
if provider == crate::providers::CLAUDE_CODE_PROVIDER {
return Ok(crate::login::claude_code_login_instructions().to_string());
}
if provider != crate::providers::OPENAI_CODEX_PROVIDER {
anyhow::bail!(
"unsupported login provider '{provider}'; supported providers: openai-codex, claude-code, custom-provider"
);
}
let result = crate::login::login_openai_codex(&config.paths)?;
refresh_codex_auth_state(state);
Ok(result.message)
}
BuiltInCommand::Logout => {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/logout requires loaded runtime config"))?;
let Some(provider_id) = arg else {
return crate::login::logout_provider_list_text(&config.paths);
};
let removal =
crate::commands::runtime::remove_logout_target(&config.paths, provider_id)?;
if removal.removed() {
crate::commands::runtime::clear_runtime_auth_for_provider(
state,
removal.provider_id(),
);
if let Some(config) = &mut state.config {
crate::commands::runtime::sync_effective_config_after_logout(config, &removal);
}
}
Ok(removal.message())
}
BuiltInCommand::Model => {
let Some(model_id) = arg else {
return Ok(crate::model_catalog::model_usage().to_string());
};
let result = crate::commands::runtime::switch_shell_model(state, model_id)?;
let suffix = result
.notice
.map(|notice| format!(" ({notice})"))
.unwrap_or_default();
Ok(format!("model switched to {model_id}{suffix}"))
}
BuiltInCommand::Models => {
anyhow::bail!("/models is only available in Mission Control TUI")
}
BuiltInCommand::Usage => {
anyhow::bail!("/usage is only available in Mission Control TUI")
}
BuiltInCommand::PruneSessions => handle_prune_sessions_builtin(arg, state),
BuiltInCommand::Help => {
anyhow::bail!(
"/help is only available in Mission Control TUI; use README.md for shell commands"
)
}
BuiltInCommand::SystemPrompt => {
anyhow::bail!("/system-prompt is only available in Mission Control TUI")
}
BuiltInCommand::Skills => {
anyhow::bail!("/skills is only available in Mission Control TUI")
}
BuiltInCommand::Tools => {
anyhow::bail!("/tools is only available in Mission Control TUI")
}
BuiltInCommand::Subagents => {
anyhow::bail!("/subagents is only available in Mission Control TUI")
}
BuiltInCommand::Mcp => {
anyhow::bail!("/mcp is only available in Mission Control TUI")
}
BuiltInCommand::Sessions => {
if arg.is_some() {
anyhow::bail!("usage: /sessions (Mission Control TUI only)")
}
anyhow::bail!("/sessions is only available in Mission Control TUI")
}
BuiltInCommand::Quit => Ok("bye".to_string()),
}
}
fn handle_changes_builtin(state: &ShellState) -> anyhow::Result<String> {
let (store, session_id) = checkpoint_store_and_session(state)?;
Ok(format_changes_for_tui(&store.changes(&session_id)))
}
fn handle_rewind_builtin_noninteractive(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match parse_rewind_target(arg).map_err(|usage| anyhow::anyhow!(rewind_usage(&usage)))? {
ParsedRewindTarget::NeedsSelection => {
anyhow::bail!("usage: /rewind --to <turn> [--dry-run]")
}
ParsedRewindTarget::Target {
target_turn,
dry_run,
} => {
let (store, session_id) = checkpoint_store_and_session(state)?;
let plan = store.plan_rewind(&session_id, &state.cwd, target_turn);
let preview = format_rewind_plan_for_tui(&plan);
if dry_run {
Ok(preview)
} else {
anyhow::bail!("/rewind requires interactive confirmation; use --dry-run to preview")
}
}
}
}
fn handle_rewind_builtin_interactive(
arg: Option<&str>,
state: &mut ShellState,
line_reader: &mut dyn LineReader,
writer: &mut dyn Write,
) -> anyhow::Result<String> {
let (store, session_id) = checkpoint_store_and_session(state)?;
let target_turn = match parse_rewind_target(arg)
.map_err(|usage| anyhow::anyhow!(rewind_usage(&usage)))?
{
ParsedRewindTarget::NeedsSelection => {
let changes = store.changes(&session_id);
writeln!(writer, "{}", format_changes_for_tui(&changes))?;
let turns = changes
.turns
.iter()
.filter(|turn| turn.changes.iter().any(|change| change.rewindable))
.map(|turn| turn.user_turn)
.collect::<Vec<_>>();
if turns.is_empty() {
return Ok("no rewindable file changes".to_string());
}
match line_reader.read_line("rewind to turn (empty cancels): ", writer)? {
LineRead::Line(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok("rewind cancelled".to_string());
}
trimmed.parse::<u64>().map_err(|_| {
anyhow::anyhow!("rewind target must be a positive turn number")
})?
}
LineRead::Eof | LineRead::Interrupted => return Ok("rewind cancelled".to_string()),
}
}
ParsedRewindTarget::Target {
target_turn,
dry_run,
} => {
let plan = store.plan_rewind(&session_id, &state.cwd, target_turn);
if dry_run {
return Ok(format_rewind_plan_for_tui(&plan));
}
target_turn
}
};
let plan = store.plan_rewind(&session_id, &state.cwd, target_turn);
writeln!(writer, "{}", format_rewind_plan_for_tui(&plan))?;
match line_reader.read_line("Apply rewind? [y/N] ", writer)? {
LineRead::Line(line) if line.trim().eq_ignore_ascii_case("y") => {
let execution = store.execute_plan(&plan, &state.cwd);
record_session_event(
state.current_session.as_ref(),
&state.cwd,
SessionEventKind::Rewind,
rewind_event_payload(&plan, &execution),
)?;
Ok(format_rewind_execution_for_tui(&execution))
}
LineRead::Line(_) | LineRead::Eof | LineRead::Interrupted => {
Ok("rewind cancelled".to_string())
}
}
}
fn checkpoint_store_and_session(state: &ShellState) -> anyhow::Result<(CheckpointStore, String)> {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require loaded runtime config"))?;
let session_id = state
.active_session_id()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require an active persisted session"))?
.to_string();
Ok((CheckpointStore::from_paths(&config.paths), session_id))
}
fn rewind_usage(reason: &str) -> String {
format!("usage: /rewind [--dry-run] --to <turn>; {reason}")
}
pub(crate) fn format_changes_for_tui(changes: &ChangesReadModel) -> String {
if changes.turns.is_empty() {
return "no rewindable file changes".to_string();
}
let mut lines = vec!["rewindable file changes:".to_string()];
for turn in &changes.turns {
lines.push(format!("turn {}", turn.user_turn));
for change in &turn.changes {
let status = if change.rewindable {
format!("{:?}", change.classification).to_lowercase()
} else {
format!(
"non-rewindable ({})",
change
.unavailable_reason
.as_deref()
.unwrap_or("unavailable")
)
};
let kind = match change.classification {
ChangeClassification::Created => "created",
ChangeClassification::Modified => "modified",
ChangeClassification::Deleted => "deleted",
ChangeClassification::NonRewindable => "non-rewindable",
};
lines.push(format!(
" - {kind}: {} [{status}]",
redacted_path_for_display(&change.relative_path)
));
}
}
lines.extend(
changes
.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_plan_for_tui(plan: &RestorePlan) -> String {
if plan.operations.is_empty() {
return format!("no rewind operations for turn {}", plan.target_turn);
}
let latest = plan.latest_turn.unwrap_or(plan.target_turn);
let mut lines = vec![format!(
"rewind preview: turns {}..{} ({} operations)",
plan.target_turn,
latest,
plan.operations.len()
)];
for operation in &plan.operations {
lines.push(format!(
" - {}: {}",
redacted_path_for_display(&operation.relative_path),
operation.kind.label()
));
}
lines.extend(
plan.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_execution_for_tui(
execution: &crate::checkpoints::RewindExecution,
) -> String {
let mut lines = vec!["rewind result:".to_string()];
for result in &execution.results {
let status = match result.status {
RestoreStatus::Restored => "restored",
RestoreStatus::DeletedCreated => "deleted-created",
RestoreStatus::ResurrectedDeleted => "resurrected-deleted",
RestoreStatus::SkipConflict => "skip-conflict",
RestoreStatus::SkipUnavailable => "skip-unavailable",
};
let reason = result
.reason
.as_deref()
.map(|reason| format!(" ({})", crate::output::redact_sensitive_text(reason)))
.unwrap_or_default();
lines.push(format!(
" - {}: {status}{reason}",
redacted_path_for_display(&result.relative_path)
));
}
lines.join("\n")
}
fn handle_prune_sessions_builtin(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
let retention_days = parse_prune_sessions_days(arg).map_err(|usage| anyhow::anyhow!(usage))?;
let report = state
.session_manager
.prune_sessions(retention_days, state.active_session_id())?;
Ok(report.summary())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
commands::CommandRegistry,
sessions::{SessionEvent, SessionManager},
};
use tempfile::TempDir;
fn test_state(temp: &TempDir) -> ShellState {
ShellState::new(
SessionManager::new(temp.path().join("sessions")),
None,
temp.path().to_path_buf(),
"gpt-test".to_string(),
AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
},
)
}
fn write_codex_auth(paths: &crate::config::McPaths) {
let mut auth = crate::config::Auth::default();
auth.providers.insert(
crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
crate::config::AuthProviderRecord::OAuth {
access: "access".into(),
refresh: Some("refresh".into()),
expires: None,
account_id: Some("acct".into()),
},
);
crate::config::write_auth(paths, &auth).unwrap();
}
fn start_sse_server(summary: &'static str) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
let mut buffer = [0_u8; 4096];
let _ = std::io::Read::read(&mut stream, &mut buffer);
let payload = format!("data: {{\"delta\":\"{summary}\"}}\n\ndata: [DONE]\n\n");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
);
let _ = std::io::Write::write_all(&mut stream, response.as_bytes());
});
format!("http://{addr}/v1")
}
fn write_compaction_override_settings(
paths: &crate::config::McPaths,
provider: &str,
model: &str,
base_url: &str,
) {
let settings = crate::config::Settings {
compaction: crate::config::CompactionSettings {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
..crate::config::CompactionSettings::default()
},
custom_providers: std::collections::BTreeMap::from([(
provider.to_string(),
crate::config::make_custom_provider_config("Local", base_url, "").unwrap(),
)]),
..crate::config::Settings::default()
};
crate::config::write_settings(paths, &settings).unwrap();
}
fn missing_codex_config(paths: crate::config::McPaths) -> crate::config::EffectiveConfig {
crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths,
}
}
fn ready_codex_state(temp: &TempDir, paths: &crate::config::McPaths) -> ShellState {
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: Some(crate::config::ProviderCredential::OAuth {
access: "access".into(),
account_id: Some("acct".into()),
}),
paths: paths.clone(),
};
test_state(temp).with_config(config)
}
fn assert_no_secret_path(text: &str) {
assert!(!text.contains("auth.json"), "leaked auth.json: {text}");
assert!(!text.contains(".env"), "leaked .env: {text}");
assert!(!text.contains("id_rsa"), "leaked id_rsa: {text}");
}
#[test]
fn rewind_formatters_redact_secret_paths() {
use crate::checkpoints::{
ChangeEntry, ChangeTurn, RestoreOperation, RestoreOperationKind,
RestoreOperationResult, RewindExecution,
};
let changes = ChangesReadModel {
turns: vec![ChangeTurn {
user_turn: 1,
changes: vec![ChangeEntry {
tool: crate::checkpoints::SnapshotTool::Edit,
relative_path: "auth.json".into(),
classification: ChangeClassification::NonRewindable,
rewindable: false,
unavailable_reason: Some("excluded".to_string()),
}],
}],
diagnostics: Vec::new(),
};
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: vec![RestoreOperation {
relative_path: ".env".into(),
kind: RestoreOperationKind::SkipUnavailable {
reason: "denied".to_string(),
},
}],
};
let execution = RewindExecution {
results: vec![RestoreOperationResult {
relative_path: "id_rsa".into(),
status: RestoreStatus::SkipUnavailable,
reason: None,
}],
};
assert_no_secret_path(&format_changes_for_tui(&changes));
assert_no_secret_path(&format_rewind_plan_for_tui(&plan));
assert_no_secret_path(&format_rewind_execution_for_tui(&execution));
}
#[test]
fn rewind_execution_formatter_shows_sanitized_reason() {
let execution = crate::checkpoints::RewindExecution {
results: vec![crate::checkpoints::RestoreOperationResult {
relative_path: "safe.txt".into(),
status: RestoreStatus::SkipUnavailable,
reason: Some("write failed: sk-testsecret123456789".to_string()),
}],
};
let output = format_rewind_execution_for_tui(&execution);
assert!(output.contains("write failed"));
assert!(!output.contains("sk-testsecret123456789"));
}
#[test]
fn line_handler_supports_parity_slash_commands() {
let commands = CommandRegistry::mvp();
assert_eq!(handle_line("/quit", &commands), ShellAction::Quit);
assert_eq!(
handle_line("/new", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::New,
arg: None
}
);
assert_eq!(
handle_line("/login openai-codex", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Login,
arg: Some("openai-codex".to_string())
}
);
assert_eq!(
handle_line("/logout openai-codex", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Logout,
arg: Some("openai-codex".to_string())
}
);
assert_eq!(
handle_line("/compact preserve file list", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Compact,
arg: Some("preserve file list".to_string())
}
);
assert_eq!(
handle_line("/sessions", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Sessions,
arg: None
}
);
assert_eq!(
handle_line("/models", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Models,
arg: None
}
);
assert_eq!(
handle_line("/usage", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Usage,
arg: None
}
);
assert_eq!(
handle_line("/mcp", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Mcp,
arg: None
}
);
assert_eq!(
handle_line("/subagents", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Subagents,
arg: None
}
);
assert_eq!(
handle_line("/tools", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Tools,
arg: None
}
);
assert_eq!(
handle_line("/setmodel openai-codex/gpt-next", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::Model,
arg: Some("openai-codex/gpt-next".to_string())
}
);
assert_eq!(
handle_line("/prune-sessions 7", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::PruneSessions,
arg: Some("7".to_string())
}
);
assert_eq!(
handle_line("/prune-sessions", &commands),
ShellAction::BuiltIn {
command: BuiltInCommand::PruneSessions,
arg: None
}
);
assert_eq!(
handle_line("/skill:review", &commands),
ShellAction::UnknownCommand("/skill:review".to_string())
);
assert_eq!(
handle_line("/missing", &commands),
ShellAction::UnknownCommand("/missing".to_string())
);
assert_eq!(
handle_line("hello", &commands),
ShellAction::Prompt("hello".to_string())
);
}
#[test]
fn model_builtin_no_arg_returns_usage() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let output = handle_builtin(BuiltInCommand::Model, None, &mut state).unwrap();
assert!(output.contains("usage: /setmodel <provider>/<model-name>"));
}
#[test]
fn model_builtin_switches_from_fresh_catalog_cache_and_persists() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let output = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap();
assert_eq!(state.model, "gpt-next");
assert_eq!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("gpt-next")
);
assert!(output.contains("model switched to openai-codex/gpt-next"));
}
#[test]
fn model_builtin_rejects_disabled_model_without_changing_selection() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
crate::config::set_model_disabled_for_scope(
&paths,
crate::config::SettingsScope::Global,
"openai-codex/gpt-next",
true,
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let error = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap_err()
.to_string();
assert_eq!(error, "cannot select disabled model: openai-codex/gpt-next");
assert_eq!(state.model, "gpt-test");
assert_ne!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("gpt-next")
);
}
#[test]
fn model_builtin_switches_to_codex_from_openai_and_refreshes_runtime_state() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(
&paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let output = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap();
assert!(output.contains("model switched to openai-codex/gpt-next"));
assert_eq!(state.model, "gpt-next");
assert_eq!(
state.auth_state.provider(),
crate::providers::OPENAI_CODEX_PROVIDER
);
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
}
#[test]
fn new_builtin_creates_and_activates_session() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let new_output = handle_builtin(BuiltInCommand::New, None, &mut state).unwrap();
assert!(new_output.starts_with("new session: "));
assert!(state.active_session_id().is_some());
assert!(!state.current_session.as_ref().unwrap().path().exists());
}
#[test]
fn logout_direct_provider_removes_codex_after_confirmation() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
write_codex_auth(&paths);
let mut state = ready_codex_state(&temp, &paths);
let mut reader = ScriptedLineReader::new(vec![LineRead::Line("yes".into())]);
let mut output = Vec::new();
let message = handle_logout_builtin_interactive(
Some(crate::providers::OPENAI_CODEX_PROVIDER),
&mut state,
&mut reader,
&mut output,
)
.unwrap();
assert!(message.contains("removed local auth"));
assert!(
!crate::config::read_auth(&paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(!state.auth_state.is_ready());
assert!(state.config.as_ref().unwrap().auth.is_none());
}
#[test]
fn logout_cancel_preserves_auth_and_state() {
for cancellation in [
LineRead::Line(String::new()),
LineRead::Line("definitely not yes".into()),
LineRead::Eof,
LineRead::Interrupted,
] {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
write_codex_auth(&paths);
let mut state = ready_codex_state(&temp, &paths);
let mut reader = ScriptedLineReader::new(vec![cancellation]);
let mut output = Vec::new();
let message = handle_logout_builtin_interactive(
Some(crate::providers::OPENAI_CODEX_PROVIDER),
&mut state,
&mut reader,
&mut output,
)
.unwrap();
assert!(message.contains("cancelled"));
assert!(
crate::config::read_auth(&paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(state.auth_state.is_ready());
assert!(state.config.as_ref().unwrap().auth.is_some());
}
}
#[test]
fn non_interactive_login_claude_code_returns_setup_instructions() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
let output = handle_builtin(
BuiltInCommand::Login,
Some(crate::providers::CLAUDE_CODE_PROVIDER),
&mut state,
)
.unwrap();
assert!(output.contains("claude auth login"), "{output}");
assert!(output.contains("Claude Code-credentials"), "{output}");
assert!(output.contains("~/.claude/.credentials.json"), "{output}");
assert!(output.contains("provider-keyed API key"), "{output}");
assert!(output.contains("Anthropic API credits"), "{output}");
}
#[test]
fn interactive_login_claude_code_returns_setup_instructions_without_oauth_prompt() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
let mut reader = ScriptedLineReader::new(vec![LineRead::Line("unexpected".into())]);
let mut writer = Vec::new();
let output = handle_login_builtin_interactive(
Some(crate::providers::CLAUDE_CODE_PROVIDER),
&mut state,
&mut reader,
&mut writer,
)
.unwrap();
assert!(output.contains("claude auth login"), "{output}");
assert!(output.contains("provider-keyed API key"), "{output}");
assert!(String::from_utf8(writer).unwrap().is_empty());
}
#[test]
fn logout_unknown_provider_is_actionable_and_secret_free() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths,
};
let mut state = test_state(&temp).with_config(config);
let error = handle_builtin(BuiltInCommand::Logout, Some("bad-provider"), &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("unsupported logout provider 'bad-provider'"));
assert!(!error.contains("access"));
assert!(!error.contains("refresh"));
}
#[test]
fn shell_sessions_command_reports_unsupported_or_lists_plain_text() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Sessions, None, &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("/sessions is only available in Mission Control TUI"));
}
#[test]
fn shell_sessions_command_rejects_extra_arguments() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Sessions, Some("extra"), &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("usage: /sessions"));
}
#[test]
fn shell_models_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Models, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/models is only available in Mission Control TUI");
}
#[test]
fn shell_usage_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Usage, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/usage is only available in Mission Control TUI");
}
#[test]
fn shell_mcp_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Mcp, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/mcp is only available in Mission Control TUI");
}
#[test]
fn shell_tools_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Tools, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/tools is only available in Mission Control TUI");
}
#[test]
fn shell_subagents_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Subagents, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/subagents is only available in Mission Control TUI");
}
#[test]
fn prune_sessions_builtin_deletes_old_sessions_and_skips_active() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let old = state.session_manager.open("old-shell").unwrap();
let active = state.session_manager.open("active-shell").unwrap();
let mut old_event = SessionEvent::new(
"diagnostic",
old.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
);
old_event.timestamp =
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2020, 1, 1, 0, 0, 0).unwrap();
old.append(&old_event).unwrap();
let mut active_event = SessionEvent::new(
"diagnostic",
active.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
);
active_event.timestamp = old_event.timestamp;
active.append(&active_event).unwrap();
state.current_session = Some(active.clone());
let output = handle_builtin(BuiltInCommand::PruneSessions, Some("0"), &mut state)
.unwrap_err()
.to_string();
assert!(output.contains("usage: /prune-sessions [days]"));
assert!(old.path().exists());
assert!(active.path().exists());
let output = handle_builtin(BuiltInCommand::PruneSessions, Some("1"), &mut state).unwrap();
assert!(output.contains("older than 1 days"));
assert!(output.contains("skipped active session"));
assert!(!old.path().exists());
assert!(active.path().exists());
}
#[test]
fn prune_sessions_repl_does_not_route_command_to_prompt() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let mut input = std::io::Cursor::new(b"/prune-sessions 7\n/quit\n".to_vec());
let mut output = Vec::new();
let mut prompt_calls = 0;
run_repl(
&mut input,
&mut output,
&CommandRegistry::mvp(),
&mut state,
|_, _, _| {
prompt_calls += 1;
Ok("prompt".to_string())
},
)
.unwrap();
assert_eq!(prompt_calls, 0);
let output = String::from_utf8(output).unwrap();
assert!(output.contains("pruned 0 sessions older than 7 days"));
}
#[test]
fn compact_builtin_requires_active_session() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Compact, None, &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("/compact requires an active persisted session"));
}
#[test]
fn compact_builtin_uses_override_without_refreshing_missing_active_codex_auth() {
let _env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let base_url = start_sse_server("shell override summary");
write_compaction_override_settings(&paths, "local-provider", "summary-model", &base_url);
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
state.current_session = Some(state.session_manager.create().unwrap());
let output = handle_builtin(BuiltInCommand::Compact, None, &mut state).unwrap();
assert!(output.contains("compaction complete: session"), "{output}");
assert!(matches!(
state.auth_state,
AuthState::Missing { ref provider } if provider == crate::providers::OPENAI_CODEX_PROVIDER
));
let session = state.current_session.as_ref().unwrap();
let events = session.read_events().unwrap();
assert_eq!(
events.last().unwrap().payload["summary"],
"shell override summary"
);
assert_eq!(events.last().unwrap().payload["provider"], "local-provider");
assert_eq!(events.last().unwrap().payload["model"], "summary-model");
}
#[test]
fn compact_builtin_still_refreshes_auth_when_inheriting_active_codex_provider() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(&paths, &crate::config::Settings::default()).unwrap();
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
state.current_session = Some(state.session_manager.create().unwrap());
let error = handle_builtin(BuiltInCommand::Compact, None, &mut state)
.unwrap_err()
.to_string();
assert!(
error.contains("missing OAuth auth for provider 'openai-codex'"),
"{error}"
);
assert!(
state
.current_session
.unwrap()
.read_events()
.unwrap()
.is_empty()
);
}
#[test]
fn repl_compact_does_not_route_to_prompt_without_active_session() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let input = b"/compact\n/quit\n";
let mut reader = &input[..];
let mut output = Vec::new();
let mut prompts = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, prompt, _writer| {
prompts.push(prompt.to_string());
Ok(String::new())
},
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("/compact requires an active persisted session"));
assert!(prompts.is_empty());
}
#[test]
fn repl_streams_prompt_output_without_duplicate_final_response() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let input = b"hello\n/quit\n";
let mut reader = &input[..];
let mut output = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, _prompt, writer| {
write!(writer, "hel")?;
writer.flush()?;
write!(writer, "lo")?;
writer.flush()?;
writeln!(writer)?;
Ok(String::new())
},
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(text.matches("hello").count(), 1);
assert!(text.contains("hello\n> bye\n"));
}
#[test]
fn repl_streams_tool_block_once_and_keeps_next_prompt_readable() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let input = b"hello\n/quit\n";
let mut reader = &input[..];
let mut output = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, _prompt, writer| {
write!(writer, "before")?;
writeln!(writer)?;
writeln!(writer, "--- TOOL START: read ---")?;
writeln!(writer, "STATUS: success")?;
writeln!(writer, "PATH: file.txt")?;
writeln!(writer, "OUTPUT:")?;
writeln!(writer, "hello")?;
writeln!(writer, "--- TOOL END: read ---")?;
write!(writer, "after")?;
writeln!(writer)?;
Ok(String::new())
},
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(text.matches("--- TOOL START: read ---").count(), 1);
assert_eq!(text.matches("before").count(), 1);
assert_eq!(text.matches("after").count(), 1);
assert!(text.contains("--- TOOL END: read ---\nafter\n> bye\n"));
}
#[test]
fn repl_streaming_error_keeps_next_prompt_readable() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let input = b"hello\n/quit\n";
let mut reader = &input[..];
let mut output = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, _prompt, writer| {
write!(writer, "partial")?;
writer.flush()?;
writeln!(writer)?;
Ok("stream failed".to_string())
},
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("partial\nstream failed\n> bye\n"));
assert_eq!(text.matches("partial").count(), 1);
}
#[test]
fn repl_eof_exits_cleanly() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let input = b"";
let mut reader = &input[..];
let mut output = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, _prompt, _writer| Ok(String::new()),
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(
text,
"magi-code interactive shell. Type /quit to exit.\n> bye\n"
);
}
struct ScriptedLineReader {
lines: std::collections::VecDeque<LineRead>,
}
impl ScriptedLineReader {
fn new(lines: Vec<LineRead>) -> Self {
Self {
lines: lines.into(),
}
}
}
impl LineReader for ScriptedLineReader {
fn read_line(&mut self, prompt: &str, writer: &mut dyn Write) -> anyhow::Result<LineRead> {
write!(writer, "{prompt}")?;
Ok(self.lines.pop_front().unwrap_or(LineRead::Eof))
}
}
#[test]
fn repl_ctrl_c_clears_line_and_continues() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let commands = CommandRegistry::mvp();
let mut reader =
ScriptedLineReader::new(vec![LineRead::Interrupted, LineRead::Line("/quit".into())]);
let mut output = Vec::new();
run_repl_with_line_reader(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, _prompt, _writer| Ok(String::new()),
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("> ^C\n> bye\n"));
}
#[test]
fn repl_handles_model_diagnostics_and_rejects_removed_slash_commands_without_prompting() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths,
};
let mut state = test_state(&temp).with_config(config);
let commands = CommandRegistry::mvp();
let input = b"/setmodel gpt-next\n/skill:review\n/quit\n";
let mut reader = &input[..];
let mut output = Vec::new();
let mut prompts = Vec::new();
run_repl(
&mut reader,
&mut output,
&commands,
&mut state,
|_state, prompt, _writer| {
prompts.push(prompt.to_string());
Ok(String::new())
},
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("model id must use provider/model-name format"));
assert!(text.contains("unknown command: /skill:review"));
assert!(prompts.is_empty());
}
}