magi-code 0.63.3

Repository-aware CLI coding agent for terminal work
Documentation
use crate::{
    commands::CommandRegistry,
    config::EffectiveConfig,
    output::{OutputMode, output_mode},
    sessions::{Session, SessionManager},
    shell::{ShellState, run_repl, run_tty_repl},
};
use anyhow::Result;
use std::io::{self, IsTerminal, Write};

pub(super) fn run_interactive(
    config: EffectiveConfig,
    instructions: Vec<crate::instructions::InstructionFile>,
    skills: crate::skills::SkillDiscovery,
    commands: CommandRegistry,
    manager: SessionManager,
    active_session: Option<Session>,
    cwd: std::path::PathBuf,
) -> Result<()> {
    let stdin = io::stdin();
    let stdin_is_tty = stdin.is_terminal();
    let stdout = io::stdout();
    let stdout_is_tty = stdout.is_terminal();
    let use_line_editor = stdin_is_tty && stdout_is_tty;
    let mode = output_mode(stdout_is_tty, !config.no_color, stdout_is_tty);
    let model = config
        .model
        .clone()
        .unwrap_or_else(|| crate::providers::DEFAULT_CODEX_MODEL.to_string());
    let mut state = ShellState::new(
        manager,
        active_session,
        cwd.clone(),
        model,
        config.auth_state(),
    )
    .with_config(config.clone());
    let mut prompt_context = InteractivePromptContext {
        fallback_config: &config,
        instructions: &instructions,
        skills: &skills,
        mode,
        rich_header_rendered: false,
    };
    if use_line_editor {
        let mut writer = stdout;
        run_tty_repl(
            &mut writer,
            &commands,
            &mut state,
            |state, prompt, writer| prompt_context.run(state, prompt, writer),
        )
    } else {
        let mut reader = stdin.lock();
        let mut writer = stdout.lock();
        run_repl(
            &mut reader,
            &mut writer,
            &commands,
            &mut state,
            |state, prompt, writer| prompt_context.run(state, prompt, writer),
        )
    }
}

struct InteractivePromptContext<'a> {
    fallback_config: &'a EffectiveConfig,
    instructions: &'a [crate::instructions::InstructionFile],
    skills: &'a crate::skills::SkillDiscovery,
    mode: OutputMode,
    rich_header_rendered: bool,
}

impl InteractivePromptContext<'_> {
    fn run<W: Write>(
        &mut self,
        state: &mut ShellState,
        prompt: &str,
        writer: &mut W,
    ) -> Result<String> {
        let mut config = state
            .config
            .clone()
            .unwrap_or_else(|| self.fallback_config.clone());
        config.model = Some(state.model.clone());
        let mut sink = super::CliOutputSink::with_mode(writer, self.mode)
            .suppress_session_header(self.rich_header_rendered);
        let is_bash_mode = crate::bash_mode::parse_bash_mode_prompt(prompt).is_some();
        let result = if is_bash_mode {
            crate::agent::runner::run_bash_mode_once(
                &config,
                self.skills,
                crate::agent::runner::ProviderRunOptions {
                    settings: None,
                    prompt,
                    session: state.current_session.as_ref(),
                    cwd: &state.cwd,
                    output_sink: Some(&mut sink),
                    selected_primary_agent: None,
                    cancellation: None,
                    session_title_notifier: None,
                    invocation_mode: crate::output::InvocationMode::Shell,
                    disabled_tools: None,
                    disabled_subagent_profiles: None,
                    subagent_profile_discovery: None,
                    mcp: None,
                },
            )
        } else {
            if !state.auth_state.is_ready() {
                let message = format!(
                    "provider not configured for '{}'; run /login openai-codex or configure a custom provider with /login custom-provider",
                    state.auth_state.provider()
                );
                super::diagnostics::record_error(
                    state.current_session.as_ref(),
                    &state.cwd,
                    &message,
                )?;
                return Ok(message);
            }
            crate::agent::runner::run_provider_once_streaming(
                &config,
                self.instructions,
                self.skills,
                crate::agent::runner::ProviderRunOptions {
                    settings: None,
                    prompt,
                    session: state.current_session.as_ref(),
                    cwd: &state.cwd,
                    output_sink: Some(&mut sink),
                    selected_primary_agent: None,
                    cancellation: None,
                    session_title_notifier: None,
                    invocation_mode: crate::output::InvocationMode::Shell,
                    disabled_tools: None,
                    disabled_subagent_profiles: None,
                    subagent_profile_discovery: None,
                    mcp: None,
                },
            )
        };
        sink.finish_line()?;
        if matches!(self.mode, OutputMode::Rich(_)) {
            self.rich_header_rendered = true;
        }
        match result {
            Ok(_) => Ok(String::new()),
            Err(error) => {
                if !is_bash_mode {
                    super::diagnostics::record_error(
                        state.current_session.as_ref(),
                        &state.cwd,
                        &error.to_string(),
                    )?;
                }
                Ok(error.to_string())
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        config::{AuthState, McPaths},
        providers::OPENAI_CODEX_PROVIDER,
    };

    #[test]
    fn interactive_missing_auth_returns_actionable_message_and_persists_diagnostic() {
        let temp = tempfile::TempDir::new().unwrap();
        let manager = SessionManager::new(temp.path().join("sessions"));
        let session = manager.create().unwrap();
        let config = EffectiveConfig {
            provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
            model: Some("model".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: McPaths::from_root(temp.path().join("mc")),
        };
        let skills = crate::skills::SkillDiscovery::default();
        let mut state = ShellState::new(
            manager,
            Some(session.clone()),
            temp.path().to_path_buf(),
            "model".to_string(),
            AuthState::Missing {
                provider: OPENAI_CODEX_PROVIDER.to_string(),
            },
        )
        .with_config(config.clone());
        let mut context = InteractivePromptContext {
            fallback_config: &config,
            instructions: &[],
            skills: &skills,
            mode: OutputMode::Plain,
            rich_header_rendered: false,
        };
        let mut output = Vec::new();

        let message = context.run(&mut state, "hello", &mut output).unwrap();

        assert!(message.contains("provider not configured for 'openai-codex'"));
        assert!(message.contains("run /login openai-codex"));
        assert!(output.is_empty());
        let events = session.read_events().unwrap();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, "diagnostic");
        assert_eq!(events[0].payload["level"], "error");
        assert_eq!(events[0].payload["message"], message);
    }

    #[test]
    fn interactive_bash_mode_bypasses_missing_auth_and_does_not_persist() {
        let temp = tempfile::TempDir::new().unwrap();
        let manager = SessionManager::new(temp.path().join("sessions"));
        let session = manager.create().unwrap();
        let config = EffectiveConfig {
            provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
            model: Some("model".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: McPaths::from_root(temp.path().join("mc")),
        };
        let skills = crate::skills::SkillDiscovery::default();
        let mut state = ShellState::new(
            manager,
            Some(session.clone()),
            temp.path().to_path_buf(),
            "model".to_string(),
            AuthState::Missing {
                provider: OPENAI_CODEX_PROVIDER.to_string(),
            },
        )
        .with_config(config.clone());
        let mut context = InteractivePromptContext {
            fallback_config: &config,
            instructions: &[],
            skills: &skills,
            mode: OutputMode::Plain,
            rich_header_rendered: false,
        };
        let mut output = Vec::new();

        let message = context.run(&mut state, "!printf hi", &mut output).unwrap();

        assert_eq!(message, "");
        let rendered = String::from_utf8(output).unwrap();
        assert!(rendered.contains("hi"), "{rendered}");
        let events = session.read_events().unwrap();
        assert!(events.is_empty(), "events: {events:?}");
    }
}