stynx-code 3.12.1

stynx-code — interactive AI coding assistant
mod infrastructure;

use std::io;
use std::sync::Arc;
use std::sync::atomic::AtomicU8;

use clap::Parser;

use stynx_code_config::load_config;
use stynx_code_engine::{QueryEngine, run_session_start_hooks};
use stynx_code_memory::FileSessionRepository;
use stynx_code_permission::ConfigAwarePermissionChecker;
use stynx_code_provider::{AnthropicProvider, OpenAiProvider};
use stynx_code_tools::{
    AskUserTool, BashTool, ExitPlanModeTool, FileEditTool, FileWriteTool,
    GlobTool, GrepTool, ReadTool, TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
    WebSearchTool,
};
use stynx_code_types::{Conversation, PermissionMode};

use infrastructure::agent_tool::{AgentTool, AllInternsTool, ExploreAgentTool, InternTool};
use infrastructure::app_display::print_session_history;
use infrastructure::app_loop::run_loop;
use infrastructure::cli::Cli;
use infrastructure::conductor::AgentManager;
use infrastructure::conductor_tools::{ListAgentsTool, SpawnAgentTool, WaitAgentTool};
use infrastructure::event_renderer::render_error_box;
use infrastructure::interns::build_intern_tools;
use infrastructure::oneshot::run_oneshot;
use infrastructure::pipe::run_pipe;
use infrastructure::skills::load_skills;
use infrastructure::terminal::{DIM, RESET, build_env_info, make_system_prompt, print_banner, prompt_resume};

#[tokio::main]
async fn main() {

    let _ = dotenvy::dotenv();

    let cli = Cli::parse();

    let default_level = if cli.verbose { "debug" } else { "warn" };
    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| default_level.into());
    let is_tui = !Cli::is_piped() && cli.prompt.is_none();
    if is_tui {

        let log_dir = stynx_code_config::home_dir()
            .map(|h| h.join(".stynx").join("logs"))
            .unwrap_or_else(|| std::path::PathBuf::from(".stynx/logs"));
        let _ = std::fs::create_dir_all(&log_dir);
        match std::fs::OpenOptions::new()
            .create(true).append(true).open(log_dir.join("stynx.log"))
        {
            Ok(file) => {
                tracing_subscriber::fmt()
                    .with_env_filter(env_filter)
                    .with_writer(std::sync::Mutex::new(file))
                    .with_ansi(false)
                    .init();
            }
            Err(_) => {

            }
        }
    } else {
        tracing_subscriber::fmt()
            .with_env_filter(env_filter)
            .with_writer(io::stderr).init();
    }

    let session_id: String = {
        let mut bytes = [0u8; 8];
        if getrandom::getrandom(&mut bytes).is_err() {
            "unknown".to_string()
        } else {
            bytes.iter().map(|b| format!("{b:02x}")).collect()
        }
    };
    let _entered = tracing::info_span!("stynx", session_id = %session_id).entered();
    tracing::info!(version = %env!("CARGO_PKG_VERSION"), "stynx starting");

    let config = load_config();
    let credential = stynx_code_auth::resolve_credential().ok();

    let mode_flag = Arc::new(AtomicU8::new(PermissionMode::Normal as u8));

    let candidates = infrastructure::main_provider_picker::list_candidates(&config, credential.is_some());
    if candidates.is_empty() {
        render_error_box(
            "No credentials found. Configure Claude (ANTHROPIC_API_KEY / OAuth login) or an intern provider (e.g. MIMO_API_KEY, DEEPSEEK_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY).",
        );
        std::process::exit(1);
    }
    let resolved_idx = infrastructure::main_provider_picker::resolve_choice(
        cli.provider.as_deref(),
        &config,
        &candidates,
    );
    let pick_idx = match resolved_idx {
        Some(i) => i,
        None => {
            let idx = infrastructure::main_provider_picker::prompt_pick(&candidates).unwrap_or(0);
            let label = &candidates[idx].label;
            if let Err(e) = infrastructure::main_provider_picker::persist_choice(label) {
                tracing::warn!("could not persist main_provider choice: {e}");
            }
            idx
        }
    };
    let picked = &candidates[pick_idx];

    use infrastructure::main_provider_picker::MainProviderKind;
    let mut anthropic_handle: Option<Arc<AnthropicProvider>> = None;
    let provider: Arc<dyn stynx_code_types::Provider> = match &picked.kind {
        MainProviderKind::Claude => {
            let cred = credential.clone().expect("claude picked but credential missing");
            let a = Arc::new(AnthropicProvider::new(cred, mode_flag.clone()));
            anthropic_handle = Some(a.clone());
            a
        }
        MainProviderKind::Intern(cfg) => {
            let (default_base, default_key_env, provider_label) = match cfg.provider.trim().to_lowercase().as_str() {
                "deepseek" => ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek"),
                "openrouter" => ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "openrouter"),
                "openai" => ("https://api.openai.com/v1", "OPENAI_API_KEY", "openai"),
                "qwen" => ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "QWEN_API_KEY", "qwen"),
                "mimo" | "custom" => ("https://api.xiaomimimo.com/v1", "MIMO_API_KEY", "custom"),
                _ => ("", "", "custom"),
            };
            let base_url = cfg.base_url.clone().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| default_base.into());
            let key_env = cfg.api_key_env.clone().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| default_key_env.into());
            let api_key = match std::env::var(&key_env).ok().filter(|s| !s.trim().is_empty()) {
                Some(k) => k,
                None => {
                    render_error_box(&format!("Main provider '{}' selected but {} is not set in env.", picked.label, key_env));
                    std::process::exit(1);
                }
            };
            Arc::new(OpenAiProvider::new(provider_label, base_url, api_key, &cfg.model))
        }
    };

    if let Some(ref model) = cli.model { provider.set_model(model); }
    else if let Some(ref model) = config.model { provider.set_model(model); }
    if let Ok(model) = std::env::var("MODEL") { provider.set_model(&model); }
    if let Some(mt) = config.max_tokens { provider.set_max_tokens(mt); }
    if let Some(total) = cli.thinking_budget {
        provider.set_max_tokens(total);
        let budget = total.saturating_sub(16384);
        provider.set_thinking_budget(budget);
    }

    let effort_value = std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok()
        .or_else(|| cli.effort.clone())
        .or_else(|| config.effort.clone());
    if let Some(ref effort) = effort_value {
        let level = effort.to_lowercase();
        if ["low", "medium", "high", "max"].contains(&level.as_str()) {
            provider.set_effort(&level);
            if level == "max" && cli.thinking_budget.is_none() {
                provider.set_max_tokens(64000);
            }
        }
    }

    let pause_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));

    let mut registry = ToolRegistry::new();
    registry.register(Arc::new(BashTool::new()));
    registry.register(Arc::new(ReadTool));
    registry.register(Arc::new(FileWriteTool));
    registry.register(Arc::new(FileEditTool));
    registry.register(Arc::new(GlobTool));
    registry.register(Arc::new(GrepTool));
    let ask_user_tool = Arc::new(AskUserTool::new(pause_flag.clone()));
    let ask_user_bridge_handle = ask_user_tool.bridge_handle();
    registry.register(ask_user_tool.clone());
    registry.register(Arc::new(WebFetchTool));
    registry.register(Arc::new(WebSearchTool));
    registry.register(Arc::new(ExitPlanModeTool::new(pause_flag.clone())));
    registry.register(Arc::new(TodoWriteTool));
    registry.register(Arc::new(TodoReadTool));

    let cwd = std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| ".".into());

    for tool in stynx_code_tools::load_mcp_tools(&cwd).await { registry.register(tool); }

    let permission = Arc::new(ConfigAwarePermissionChecker::new_with_pause(
        config.permissions.clone(), mode_flag.clone(), pause_flag.clone(),
    ));

    let sub_registry = Arc::new(registry.clone_excluding(&["agent", "explore"]));
    let explore_registry = Arc::new(sub_registry.clone_excluding(&[
        "bash", "file_write", "file_edit", "ask_user_question", "web_fetch", "web_search", "todo_write", "todo_read",
    ]));

    let agent_manager = AgentManager::new();
    let mut conductor_reg = sub_registry.clone_excluding(&[]);

    registry.register(Arc::new(AgentTool::new(
        provider.clone(), sub_registry.clone(), permission.clone(), mode_flag.clone(), config.hooks.clone(),
    )));
    registry.register(Arc::new(ExploreAgentTool::new(
        provider.clone(), explore_registry, permission.clone(), mode_flag.clone(), config.hooks.clone(),
    )));

    let intern_manager = infrastructure::intern_manager::InternManager::new();
    let intern_tools: Vec<Arc<InternTool>> = build_intern_tools(
        &config, &sub_registry, &permission, &mode_flag, &config.hooks, &intern_manager,
    );
    for t in &intern_tools {
        registry.register(t.clone());
    }

    if !intern_tools.is_empty() {
        use infrastructure::intern_tools::{InternKillTool, InternStatusTool, InternWaitTool};
        registry.register(Arc::new(InternStatusTool::new(intern_manager.clone())));
        registry.register(Arc::new(InternKillTool::new(intern_manager.clone())));
        registry.register(Arc::new(InternWaitTool::new(intern_manager.clone())));
    }

    if intern_tools.len() >= 2 {
        registry.register(Arc::new(AllInternsTool::new(intern_tools.clone())));
    }
    conductor_reg.register(Arc::new(SpawnAgentTool::new(
        provider.clone(), sub_registry, permission.clone(), mode_flag.clone(), config.hooks.clone(), agent_manager.clone(),
    )));
    conductor_reg.register(Arc::new(WaitAgentTool::new(agent_manager.clone())));
    conductor_reg.register(Arc::new(ListAgentsTool::new(agent_manager)));
    let conductor_engine = Arc::new(QueryEngine::new(
        provider.clone(), Arc::new(conductor_reg), permission.clone(), mode_flag.clone(), config.hooks.clone(),
    ));

    let reflect_engine = Arc::new(QueryEngine::new(
        provider.clone(), Arc::new(ToolRegistry::new()), permission.clone(), mode_flag.clone(), config.hooks.clone(),
    ));

    let tool_names = registry.tool_names();
    let registry = Arc::new(registry);
    let engine = {
        let mut e = QueryEngine::new(provider.clone(), registry, permission.clone(), mode_flag.clone(), config.hooks.clone());
        if let Some(mt) = cli.max_turns { e = e.with_max_turns(mt); }
        else if let Some(mt) = config.max_turns { e = e.with_max_turns(mt); }
        Arc::new(e)
    };

    let env = build_env_info(cwd.clone(), provider.model_name());
    let loaded_skills = load_skills(&cwd);
    let skill_pairs: Vec<(String, String, Option<String>)> = loaded_skills.iter()
        .filter(|s| s.user_invocable)
        .map(|s| (s.name.clone(), s.description.clone(), s.when_to_use.clone()))
        .collect();

    let system_prompt = cli.system.clone().unwrap_or_else(|| {
        make_system_prompt(&env, &tool_names, &skill_pairs, config.commit_attribution)
    });

    if Cli::is_piped() {
        if let Err(e) = run_pipe(&engine, system_prompt, cli.prompt.as_deref(), cli.json).await {
            render_error_box(&e);
            std::process::exit(1);
        }
        return;
    }

    if let Some(ref prompt) = cli.prompt {
        if let Err(e) = run_oneshot(&engine, system_prompt, prompt, cli.json).await {
            render_error_box(&e);
            std::process::exit(1);
        }
        return;
    }

    let session_repo: Arc<dyn stynx_code_memory::SessionRepository> =
        match FileSessionRepository::new(&cwd) {
            Ok(repo) => Arc::new(repo),
            Err(e) => {
                tracing::warn!("failed to init session repository: {e}");
                Arc::new(FileSessionRepository::with_dir(std::path::PathBuf::from(".stynx-code/projects/fallback")))
            }
        };

    let session_start_out = run_session_start_hooks(&config.hooks).await;
    let model_id = provider.model_name();
    print_banner(&cwd, &model_id);
    if !session_start_out.is_empty() { println!("  {DIM}{session_start_out}{RESET}\n"); }

    tracing::debug!("system prompt: {} chars", system_prompt.len());

    let conversation = match stynx_code_memory::load_session(&session_repo).await {
        Ok(Some(prev)) if !prev.messages.is_empty() => {
            if prompt_resume() {
                let mut c = prev;
                if c.system.is_none() { c.system = Some(system_prompt.clone()); }
                println!("  {DIM}↻ Session resumed ({} messages){RESET}\n", c.messages.len());
                print_session_history(&c.messages);
                c
            } else {
                Conversation { system: Some(system_prompt.clone()), ..Default::default() }
            }
        }
        _ => Conversation { system: Some(system_prompt.clone()), ..Default::default() },
    };

    run_loop(engine, conductor_engine, reflect_engine, session_repo, provider, anthropic_handle, config, mode_flag, cwd, system_prompt, conversation, loaded_skills, pause_flag, permission, intern_tools, ask_user_bridge_handle).await;
}