netsky-core 0.1.7

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Agent runtime: the underlying model + CLI a spawned agent executes.
//!
//! netsky is runtime-agnostic; the agent lifecycle, prompt model, and
//! bus transport stay the same regardless of which flavor drives the
//! pane. Each runtime encapsulates:
//!
//! - its PATH dependencies ([`Runtime::required_deps`])
//! - how to build the shell command a tmux session will run
//!   ([`Runtime::build_command`])
//! - its own per-agent defaults (model, effort, etc.)
//!
//! `Claude` is the default flavor. `Codex` is selected per-invocation
//! via `netsky agent N --type codex` (resident) or via the sidecar
//! path in `cmd/codex_agent.rs` (single-turn).

use std::path::Path;

use crate::agent::AgentId;
use crate::error::Result;

pub mod claude;
pub mod codex;

pub use claude::ClaudeConfig;
pub use codex::CodexConfig;

/// The runtime flavor driving an agent pane.
#[derive(Debug, Clone)]
pub enum Runtime {
    Claude(ClaudeConfig),
    Codex(CodexConfig),
}

impl Runtime {
    /// Stable identifier for logs + config. `"claude"`, `"codex"`.
    pub fn name(&self) -> &'static str {
        match self {
            Self::Claude(_) => "claude",
            Self::Codex(_) => "codex",
        }
    }

    /// Executables the runtime expects on PATH. Checked at spawn time
    /// before any destructive step (teardown, tmux creation).
    pub fn required_deps(&self) -> Vec<&'static str> {
        match self {
            Self::Claude(_) => claude::required_deps(),
            Self::Codex(_) => codex::required_deps(),
        }
    }

    /// Assemble the shell command string a tmux session will run to
    /// start this agent. Runtime owns all per-flavor CLI decoration;
    /// callers supply only the runtime-agnostic inputs.
    pub fn build_command(&self, agent: AgentId, mcp_config: &Path, startup: &str) -> String {
        match self {
            Self::Claude(cfg) => claude::build_command(agent, cfg, mcp_config, startup),
            Self::Codex(cfg) => codex::build_command(agent, cfg, mcp_config, startup),
        }
    }

    /// Post-spawn hook: runs once after the tmux session exists. Claude
    /// is a no-op — startup already landed via the CLI positional arg
    /// assembled in [`Self::build_command`]. Codex has no CLI slot for
    /// a second prompt, so the hook pastes startup into the pane as a
    /// follow-on user turn.
    pub fn post_spawn(&self, session: &str, startup: &str) -> Result<()> {
        match self {
            Self::Claude(_) => Ok(()),
            Self::Codex(_) => codex::post_spawn(session, startup),
        }
    }

    /// Default runtime for `agent`. Claude unless `AGENT_RUNTIME=codex`
    /// picks the codex flavor. Explicit `netsky agent N --type <flavor>`
    /// overrides this and constructs the runtime directly.
    pub fn defaults_for(agent: AgentId) -> Self {
        match std::env::var("AGENT_RUNTIME").ok().as_deref() {
            Some("codex") => Self::Codex(CodexConfig::defaults_for()),
            _ => Self::Claude(ClaudeConfig::defaults_for(agent)),
        }
    }

    /// Human-readable description for log lines. Stable-ish formatting —
    /// tests grep this, but callers are internal.
    pub fn describe(&self) -> String {
        match self {
            Self::Claude(cfg) => format!("claude model={} effort={}", cfg.model, cfg.effort),
            Self::Codex(cfg) => format!(
                "codex model={} sandbox={} approval={}",
                cfg.model, cfg.sandbox, cfg.approval
            ),
        }
    }
}

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

    #[test]
    fn claude_post_spawn_is_noop_without_tmux() {
        // Passing a non-existent session must succeed — claude's
        // post_spawn must not shell out to tmux at all. If it ever did,
        // this call would fail because the session doesn't exist.
        let rt = Runtime::Claude(ClaudeConfig {
            model: "opus".to_string(),
            effort: "high".to_string(),
        });
        rt.post_spawn("nonexistent-session-xyz", "/up")
            .expect("claude post_spawn must be a no-op");
    }
}