zeph 0.22.4

Lightweight AI agent with hybrid inference, skills-first architecture, and multi-channel I/O
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

use dialoguer::{Confirm, Input, Select};
use zeph_core::config::{McpOAuthConfig, McpServerConfig, McpTrustLevel, OAuthTokenStorage};

use super::WizardState;

#[allow(clippy::too_many_lines)]
pub(super) fn step_mcpls(state: &mut WizardState) -> anyhow::Result<()> {
    println!("== MCP: LSP Code Intelligence ==\n");

    // Detect mcpls by searching PATH — avoids spawning a process that could hang.
    let detected = mcpls_in_path();

    if detected {
        println!("mcpls detected.");
    } else {
        println!("mcpls not found. Install with: cargo install mcpls");
    }

    state.mcpls_enabled = Confirm::new()
        .with_prompt("Enable LSP code intelligence via mcpls?")
        .default(detected)
        .interact()?;

    if state.mcpls_enabled {
        let roots_raw: String = Input::new()
            .with_prompt(
                "Workspace root paths (comma-separated, leave empty for current directory)",
            )
            .default(String::new())
            .interact_text()?;
        state.mcpls_workspace_roots = roots_raw
            .split(',')
            .map(|s| s.trim().to_owned())
            .filter(|s| !s.is_empty())
            .collect();
        // mcpls auto-detects language servers from project files (Cargo.toml, pyproject.toml,
        // tsconfig.json, go.mod). No language selection is needed at wizard time.
    }

    println!();
    Ok(())
}

pub(super) fn mcpls_in_path() -> bool {
    let path_var = std::env::var_os("PATH").unwrap_or_default();
    let exe_name = if cfg!(windows) { "mcpls.exe" } else { "mcpls" };
    std::env::split_paths(&path_var)
        .map(|dir| dir.join(exe_name))
        .any(|p| p.is_file())
}

pub(super) fn write_mcpls_config(
    state: &WizardState,
    config_path: &std::path::Path,
) -> anyhow::Result<()> {
    let base = config_path
        .parent()
        .unwrap_or_else(|| std::path::Path::new("."));
    let zeph_dir = base.join(".zeph");
    std::fs::create_dir_all(&zeph_dir)?;

    let roots = if state.mcpls_workspace_roots.is_empty() {
        vec![".".to_owned()]
    } else {
        state.mcpls_workspace_roots.clone()
    };

    let roots_toml = roots
        .iter()
        .map(|r| format!("\"{}\"", r.replace('\\', "\\\\").replace('"', "\\\"")))
        .collect::<Vec<_>>()
        .join(", ");

    // Include explicit language_extensions to work around mcpls serde default Vec bug
    // where [workspace] with only `roots` results in an empty extension map.
    let content = format!(
        r#"[workspace]
roots = [{roots_toml}]

[[workspace.language_extensions]]
language_id = "rust"
extensions = ["rs"]

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
args = []
file_patterns = ["**/*.rs"]
"#
    );

    let mcpls_path = zeph_dir.join("mcpls.toml");
    zeph_common::fs_secure::write_private(&mcpls_path, content.as_bytes())?;
    println!("mcpls config written to {}", mcpls_path.display());

    Ok(())
}

#[allow(clippy::too_many_lines)]
pub(super) fn step_mcp_remote(state: &mut WizardState) -> anyhow::Result<()> {
    println!("== MCP: Remote Servers ==\n");
    println!(
        "Configure remote MCP servers that require authentication (static headers or OAuth 2.1)."
    );
    println!("Skip this step if you have no remote MCP servers.\n");

    loop {
        let add = Confirm::new()
            .with_prompt("Add a remote MCP server?")
            .default(false)
            .interact()?;
        if !add {
            break;
        }

        let id: String = Input::new()
            .with_prompt("Server ID (unique slug, e.g. 'todoist')")
            .interact_text()?;
        let url: String = Input::new()
            .with_prompt("Server URL (e.g. https://mcp.example.com)")
            .interact_text()?;

        let auth_choices = [
            "None (no auth)",
            "Static header (Bearer token)",
            "OAuth 2.1 (interactive flow)",
        ];
        let auth_sel = Select::new()
            .with_prompt("Authentication method")
            .items(auth_choices)
            .default(0)
            .interact()?;

        let mut headers = std::collections::HashMap::new();
        let mut oauth: Option<McpOAuthConfig> = None;

        match auth_sel {
            1 => {
                println!("Header value supports vault references: ${{VAULT_KEY}}");
                let header_name: String = Input::new()
                    .with_prompt("Header name")
                    .default("Authorization".into())
                    .interact_text()?;
                let header_value: String = Input::new()
                    .with_prompt("Header value (e.g. 'Bearer ${{MY_TOKEN}}')")
                    .interact_text()?;
                headers.insert(header_name, header_value);
            }
            2 => {
                let storage_choices =
                    ["vault (persisted in age vault)", "memory (lost on restart)"];
                let storage_sel = Select::new()
                    .with_prompt("Token storage")
                    .items(storage_choices)
                    .default(0)
                    .interact()?;
                let token_storage = if storage_sel == 0 {
                    OAuthTokenStorage::Vault
                } else {
                    OAuthTokenStorage::Memory
                };
                let scopes_raw: String = Input::new()
                    .with_prompt("OAuth scopes (space-separated, leave empty for server default)")
                    .default(String::new())
                    .interact_text()?;
                let scopes: Vec<String> =
                    scopes_raw.split_whitespace().map(str::to_owned).collect();
                let callback_port: u16 = Input::new()
                    .with_prompt("Local callback port (0 = auto-assign)")
                    .default(18766)
                    .interact_text()?;
                let client_name: String = Input::new()
                    .with_prompt("OAuth client name")
                    .default("Zeph".into())
                    .interact_text()?;
                oauth = Some(McpOAuthConfig {
                    enabled: true,
                    token_storage,
                    scopes,
                    callback_port,
                    client_name,
                });
            }
            _ => {}
        }

        let trust_choices = ["untrusted (default)", "trusted", "sandboxed"];
        let trust_idx = Select::new()
            .with_prompt("Trust level")
            .items(trust_choices)
            .default(0)
            .interact()?;
        let trust_level = match trust_idx {
            1 => McpTrustLevel::Trusted,
            2 => McpTrustLevel::Sandboxed,
            _ => McpTrustLevel::Untrusted,
        };

        // Sandboxed servers always hard-block media passthrough regardless of the answer
        // (spec-072) — skip the interactive prompt entirely so the wizard doesn't offer a
        // no-op choice; `resolve_media_passthrough` still re-asserts the invariant.
        let prompt_answer = if trust_level == McpTrustLevel::Sandboxed {
            false
        } else {
            Confirm::new()
                .with_prompt(
                    "Enable image passthrough for this server? (images returned by this tool \
                     will be shown to vision-capable models)",
                )
                .default(false)
                .interact()?
        };
        let media_passthrough = resolve_media_passthrough(trust_level, prompt_answer);

        // Untrusted servers with no tool_allowlist fail closed (zero tools exposed) by
        // default (#6474). The wizard never prompts for an allowlist today, so ask about the
        // opt-in escape hatch instead — only relevant for Untrusted (Trusted already exposes
        // everything; Sandboxed fails closed unconditionally regardless of this flag).
        let allow_untrusted_without_allowlist = if trust_level == McpTrustLevel::Untrusted {
            Confirm::new()
                .with_prompt(
                    "Expose all tools from this untrusted server without an explicit \
                     allowlist? (not recommended — leave disabled and add a tool_allowlist \
                     to config.toml later to restrict exposed tools)",
                )
                .default(false)
                .interact()?
        } else {
            false
        };

        state.mcp_remote_servers.push(McpServerConfig {
            id,
            command: None,
            args: Vec::new(),
            env: std::collections::HashMap::new(),
            url: Some(url),
            timeout: 30,
            policy: zeph_config::McpPolicy::default(),
            headers,
            oauth,
            trust_level,
            tool_allowlist: None,
            allow_untrusted_without_allowlist,
            expected_tools: Vec::new(),
            roots: Vec::new(),
            tool_metadata: std::collections::HashMap::new(),
            elicitation_enabled: None,
            env_isolation: None,
            media_passthrough,
        });

        println!("Server added.");
    }

    println!();
    Ok(())
}

#[allow(clippy::too_many_lines)]
pub(super) fn step_mcp_discovery(state: &mut WizardState) -> anyhow::Result<()> {
    println!("== MCP: Tool Discovery ==\n");
    println!("Controls how MCP tools are selected per turn when you have many tools configured.");
    println!("  none      — all tools passed to the LLM every turn (default, safest)");
    println!("  embedding — cosine similarity via embedding; fast, no extra LLM call per turn");
    println!("  llm       — LLM-based pruning via mcp.pruning config\n");

    let strategy_choices = ["none", "embedding", "llm"];
    let default_idx = match state.mcp_discovery_strategy.as_str() {
        "embedding" => 1,
        "llm" => 2,
        _ => 0,
    };
    let idx = Select::new()
        .with_prompt("MCP tool discovery strategy")
        .items(strategy_choices)
        .default(default_idx)
        .interact()?;
    strategy_choices[idx].clone_into(&mut state.mcp_discovery_strategy);

    if state.mcp_discovery_strategy == "embedding" {
        let top_k: usize = Input::new()
            .with_prompt("Max tools to select per turn (top_k)")
            .default(10)
            .interact_text()?;
        state.mcp_discovery_top_k = top_k;

        let provider: String = Input::new()
            .with_prompt("Embedding provider name from [[llm.providers]] (leave empty for default)")
            .default(String::new())
            .interact_text()?;
        state.mcp_discovery_provider = provider;
    }

    println!();
    Ok(())
}

/// Whether `media_passthrough` should end up `true` for a server, given its resolved
/// trust level and the wizard's `prompt_answer` (irrelevant when Sandboxed — the caller
/// should skip asking and pass `false`).
///
/// Sandboxed servers always resolve to `false` regardless of `prompt_answer`: passthrough
/// is hard-blocked for them at runtime by `McpManager::media_passthrough_allowed`
/// (spec-072 C2), so the wizard must never suggest it is actually enabled for one. Extracted
/// as a pure function so the invariant is unit-testable without driving `dialoguer` I/O.
fn resolve_media_passthrough(trust_level: McpTrustLevel, prompt_answer: bool) -> bool {
    trust_level != McpTrustLevel::Sandboxed && prompt_answer
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_media_passthrough_sandboxed_forces_false() {
        assert!(!resolve_media_passthrough(McpTrustLevel::Sandboxed, true));
        assert!(!resolve_media_passthrough(McpTrustLevel::Sandboxed, false));
    }

    #[test]
    fn resolve_media_passthrough_untrusted_follows_prompt_answer() {
        assert!(resolve_media_passthrough(McpTrustLevel::Untrusted, true));
        assert!(!resolve_media_passthrough(McpTrustLevel::Untrusted, false));
    }

    #[test]
    fn resolve_media_passthrough_trusted_follows_prompt_answer() {
        assert!(resolve_media_passthrough(McpTrustLevel::Trusted, true));
        assert!(!resolve_media_passthrough(McpTrustLevel::Trusted, false));
    }
}