netsky 0.1.6

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky up [N] [--type claude|codex]` — spawn agent0 + N clones + agentinfinity watchdog.
//! Idempotent. Attaches to agent0 when interactive and not already inside tmux.
//!
//! `--type` picks the runtime for clones. agent0 is always claude, and
//! agentinfinity is always claude — it's the watchdog, not a clone, and
//! its repair logic is pinned to the claude CLI surface (agentinit, TOS
//! dismissal, permissions-watcher). Mixing runtimes across clones is
//! still possible via
//! `netsky agent N --type <other>` after `netsky up` returns.

use std::process::Command;

use netsky_core::agent::AgentId;
use netsky_core::consts::{AGENT0_NAME, TMUX_BIN};
use netsky_core::runtime::CodexConfig;
use netsky_core::runtime::Runtime;
use netsky_core::spawn::{self, SpawnOptions, SpawnOutcome};
use netsky_db::SessionEvent;

use crate::cli::AgentType;
use crate::cmd::init;
use crate::observability;

pub fn run(n: u32, agent_type: AgentType) -> netsky_core::Result<()> {
    let root = require_home_netsky_cwd()?;
    if !root.join(".agents/skills").is_dir() {
        init::run(Some(root.clone()), false)?;
    }

    for i in 0..=n {
        let agent = AgentId::from_number(i);
        let runtime = runtime_for(agent, agent_type);
        let opts = SpawnOptions {
            runtime,
            cwd: root.clone(),
        };
        announce(agent, spawn::spawn(agent, &opts)?, &opts);
    }

    // Ensure the watchdog is up. Idempotent: netsky-restart never kills
    // agentinfinity, so this is a no-op in steady state. Watchdog stays
    // on claude regardless of --type; the repair logic assumes the
    // claude CLI surface.
    {
        let agent = AgentId::Agentinfinity;
        let opts = SpawnOptions::defaults_for(agent, root.clone());
        announce(agent, spawn::spawn(agent, &opts)?, &opts);
    }

    observability::record_session(netsky_core::consts::AGENT0_NAME, 0, SessionEvent::Up);

    if should_attach_agent0() {
        let _ = Command::new(TMUX_BIN)
            .args(["attach", "-t", AGENT0_NAME])
            .status();
    }
    Ok(())
}

fn require_home_netsky_cwd() -> netsky_core::Result<std::path::PathBuf> {
    let home = netsky_core::paths::home();
    let expected = home.join("netsky");
    let cwd = std::env::current_dir()?;
    let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
    let expected_canon = std::fs::canonicalize(&expected).unwrap_or(expected.clone());
    if cwd_canon != expected_canon {
        netsky_core::bail!(
            "netsky up: must run from ~/netsky (got {})",
            cwd_canon.display(),
        );
    }
    Ok(expected_canon)
}

fn runtime_for(agent: AgentId, agent_type: AgentType) -> Runtime {
    if agent.is_agent0() {
        return Runtime::defaults_for(agent);
    }
    match agent_type {
        AgentType::Claude => Runtime::defaults_for(agent),
        AgentType::Codex => Runtime::Codex(CodexConfig::defaults_for()),
    }
}

fn announce(agent: AgentId, outcome: SpawnOutcome, opts: &SpawnOptions) {
    match outcome {
        SpawnOutcome::AlreadyUp => {
            println!("[netsky up] '{}' already up", agent);
        }
        SpawnOutcome::Spawned => {
            println!(
                "[netsky up] spawned '{}' ({})",
                agent,
                opts.runtime.describe()
            );
        }
    }
}

/// True iff we should attach to agent0: interactive stdout + not already
/// inside a tmux client. Matches `bin/netsky` up-path behavior.
fn should_attach_agent0() -> bool {
    use std::io::IsTerminal;
    std::io::stdout().is_terminal() && std::env::var("TMUX").is_err()
}

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

    #[test]
    fn runtime_for_claude_yields_claude_runtime() {
        let rt = runtime_for(AgentId::Agent0, AgentType::Claude);
        assert_eq!(rt.name(), "claude");
    }

    #[test]
    fn runtime_for_codex_keeps_agent0_on_claude() {
        // agent0 stays on Claude even when the default is codex.
        let rt = runtime_for(AgentId::Agent0, AgentType::Codex);
        assert_eq!(rt.name(), "claude");
    }

    #[test]
    fn runtime_for_codex_yields_codex_runtime_for_clones() {
        let rt = runtime_for(AgentId::Clone(4), AgentType::Codex);
        assert_eq!(rt.name(), "codex");
    }
}