netsky 0.1.4

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 every agent in the constellation
//! (agent0 + N clones). 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 crate::cli::AgentType;

pub fn run(n: u32, agent_type: AgentType) -> netsky_core::Result<()> {
    // Pin spawn cwd to the resolved netsky root regardless of where
    // the operator's shell cwd happens to be. Same justification as
    // in cmd/agent.rs (briefs/clone-cwd-pin.md by agent5).
    let cwd = netsky_core::paths::netsky_root_or_cwd()?;
    if !netsky_core::paths::is_netsky_source_tree(&cwd) {
        eprintln!(
            "[netsky up] NOTE: no source tree found; running from binary mode. skills, addenda, and notes live at ~/.netsky/"
        );
    }
    netsky_core::paths::ensure_netsky_dir()?;

    for i in 0..=n {
        let agent = AgentId::from_number(i);
        let runtime = runtime_for(agent, agent_type);
        let opts = SpawnOptions {
            runtime,
            cwd: cwd.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, cwd.clone());
        announce(agent, spawn::spawn(agent, &opts)?, &opts);
    }

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

fn runtime_for(agent: AgentId, agent_type: AgentType) -> Runtime {
    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_yields_codex_runtime_for_agent0() {
        // The whole point of this flag: N==0 (agent0) must be spawnable
        // under codex, closing the work-machine-without-claude gap.
        let rt = runtime_for(AgentId::Agent0, AgentType::Codex);
        assert_eq!(rt.name(), "codex");
    }

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