use clap::{Parser, Subcommand, ValueEnum};
use crate::cmd;
use netsky_io::IoCommand;
#[derive(Parser, Debug)]
#[command(name = "netsky", version, about = "Netsky constellation launcher.")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Up {
#[arg(default_value_t = netsky_core::consts::DEFAULT_CLONE_COUNT)]
n: u32,
#[arg(long = "type", value_enum, default_value_t = AgentType::Codex)]
agent_type: AgentType,
},
Down {
#[arg(long)]
force: bool,
},
Restart {
#[arg(default_value_t = netsky_core::consts::DEFAULT_CLONE_COUNT)]
n: u32,
#[arg(long)]
handoff: Option<String>,
},
Agent {
#[arg(value_name = "N")]
n: u32,
#[arg(long = "type", value_enum, default_value_t = AgentType::Codex)]
agent_type: AgentType,
#[arg(long)]
fresh: bool,
},
Codex {
#[arg(value_name = "N")]
n: u32,
#[arg(long)]
fresh: bool,
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
drain: bool,
#[arg(long)]
model: Option<String>,
},
#[command(alias = "a")]
Attach {
#[arg(value_name = "TARGET")]
target: String,
},
Agentinfinity,
Agentinit {
#[arg(default_value = netsky_core::consts::AGENTINFINITY_NAME)]
session: String,
},
#[command(subcommand)]
Watchdog(WatchdogCommand),
#[command(subcommand)]
Tick(TickCommand),
#[command(subcommand)]
Cron(CronCommand),
#[command(subcommand)]
Launchd(LaunchdCommand),
Handoffs {
#[arg(value_name = "WHICH", default_value = "list")]
which: String,
#[arg(value_name = "ARG")]
arg: Option<String>,
#[arg(short = 'n', long, alias = "keep", default_value_t = 50)]
limit: usize,
#[arg(long)]
json: bool,
},
Escalate {
subject: String,
body: Option<String>,
},
Init {
#[arg(long)]
update: bool,
#[arg(long, value_name = "DIR")]
path: Option<std::path::PathBuf>,
},
Onboard,
Status {
#[arg(long)]
json: bool,
},
Query {
#[arg(value_name = "SQL")]
sql: String,
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Analytics(cmd::analytics::AnalyticsCommand),
Events {
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
Gates {
#[arg(long)]
json: bool,
#[arg(value_name = "NAME")]
name: Option<String>,
},
#[command(subcommand)]
Ingest(cmd::ingest::IngestCommand),
#[command(subcommand)]
Task(cmd::task::TaskCommand),
Doctor {
#[arg(long, conflicts_with_all = ["quiet", "json"])]
brief: bool,
#[arg(long, conflicts_with_all = ["brief", "json"])]
quiet: bool,
#[arg(long, conflicts_with_all = ["brief", "quiet"])]
json: bool,
},
Morning {
#[arg(long)]
send: bool,
#[arg(long, conflicts_with = "send")]
json: bool,
},
Quiet {
#[arg(value_name = "SECONDS")]
seconds: u64,
#[arg(long)]
reason: Option<String>,
},
Nap {
#[arg(value_name = "SECONDS")]
seconds: u64,
#[arg(long)]
reason: Option<String>,
},
Drill {
#[arg(value_name = "N")]
n: u8,
},
Test {
#[arg(default_value = "all")]
suite: String,
},
#[command(subcommand)]
Session(SessionCommand),
#[command(subcommand)]
Setup(SetupCommand),
#[command(subcommand)]
Hooks(HooksCommand),
#[command(subcommand)]
Channel(ChannelCommand),
#[command(subcommand, visible_alias = "comms")]
Io(IoCommand),
#[command(subcommand)]
Config(ConfigCommand),
#[command(subcommand, hide = true)]
Observe(ObserveCommand),
#[command(subcommand, hide = true)]
Dev(DevCommand),
#[command(subcommand, hide = true)]
Admin(AdminCommand),
}
#[derive(Subcommand, Debug)]
pub enum ObserveCommand {
Status {
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Analytics(cmd::analytics::AnalyticsCommand),
Events {
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
Gates {
#[arg(long)]
json: bool,
#[arg(value_name = "NAME")]
name: Option<String>,
},
#[command(subcommand)]
Session(SessionCommand),
}
#[derive(Subcommand, Debug)]
pub enum DevCommand {
Init {
#[arg(long)]
update: bool,
#[arg(long, value_name = "DIR")]
path: Option<std::path::PathBuf>,
},
Agentinit {
#[arg(default_value = netsky_core::consts::AGENTINFINITY_NAME)]
session: String,
},
#[command(subcommand)]
Cron(CronCommand),
}
#[derive(Subcommand, Debug)]
pub enum AdminCommand {
Down {
#[arg(long)]
force: bool,
},
Onboard,
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
Show {
#[arg(long)]
json: bool,
},
Check {
#[arg(long)]
json: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum AgentType {
Claude,
Codex,
}
#[derive(Subcommand, Debug)]
pub enum WatchdogCommand {
Start,
Tick,
Events {
#[arg(long)]
since: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum TickCommand {
Enable {
#[arg(value_name = "SECONDS")]
seconds: u64,
},
Disable,
Request,
Ticker,
#[command(alias = "ticker-start")]
Start,
}
#[derive(Subcommand, Debug)]
pub enum CronCommand {
Add {
#[arg(value_name = "LABEL")]
label: String,
#[arg(value_name = "SCHEDULE")]
schedule: String,
#[arg(value_name = "TARGET")]
target: String,
#[arg(value_name = "PROMPT")]
prompt: String,
#[arg(long)]
json: bool,
},
List {
#[arg(long)]
json: bool,
},
Remove {
#[arg(value_name = "LABEL")]
label: String,
#[arg(long)]
json: bool,
},
Tick {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum LaunchdCommand {
Install,
Uninstall,
Status {
#[arg(long)]
json: bool,
},
Reinstall,
}
#[derive(Subcommand, Debug)]
pub enum SetupCommand {
Email {
account: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum ChannelCommand {
Drain {
#[arg(value_name = "AGENT")]
agent: String,
#[arg(long)]
json: bool,
},
Watch {
#[arg(value_name = "AGENT")]
agent: String,
#[arg(long, value_name = "SESSION")]
tmux: String,
},
Send {
#[arg(value_name = "TARGET")]
target: String,
#[arg(value_name = "TEXT")]
text: String,
#[arg(long)]
from: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
thread: Option<String>,
#[arg(long)]
json: bool,
},
ForwardOutbox {
#[arg(value_name = "AGENT")]
agent: String,
},
Ack {
#[arg(value_name = "MESSAGE_ID")]
message_id: String,
#[arg(long, value_enum)]
status: AckStatus,
#[arg(long)]
note: Option<String>,
#[arg(long)]
json: bool,
},
Quarantine {
#[arg(value_name = "AGENT")]
agent: String,
#[arg(long)]
list: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum AckStatus {
Received,
Started,
Done,
Blocker,
}
impl AckStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Received => "received",
Self::Started => "started",
Self::Done => "done",
Self::Blocker => "blocker",
}
}
}
#[derive(Subcommand, Debug)]
pub enum SessionCommand {
Salvage {
#[arg(value_name = "TARGET")]
target: String,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum HooksCommand {
Install {
#[arg(long)]
force: bool,
},
Uninstall,
Status {
#[arg(long)]
json: bool,
},
}
pub fn dispatch(cli: Cli) -> netsky_core::Result<()> {
if let Some(name) = command_requires_netsky_cwd(&cli.command) {
netsky_core::paths::require_netsky_cwd(name)?;
}
match cli.command {
None => {
use clap::CommandFactory;
Cli::command().print_help()?;
println!();
Ok(())
}
Some(Command::Up { n, agent_type }) => cmd::up::run(n, agent_type),
Some(Command::Down { force }) => cmd::down::run(force),
Some(Command::Restart { n, handoff }) => cmd::restart::run(n, handoff.as_deref()),
Some(Command::Agent {
n,
agent_type,
fresh,
}) => {
if n == 0 {
cmd::agent::run(n, fresh)
} else {
match agent_type {
AgentType::Claude => cmd::agent::run(n, fresh),
AgentType::Codex => cmd::agent::run_codex_resident(n, fresh),
}
}
}
Some(Command::Codex {
n,
fresh,
prompt,
drain,
model,
}) => cmd::codex_agent::run(n, prompt.as_deref(), drain, model.as_deref(), fresh),
Some(Command::Attach { target }) => cmd::attach::run(&target),
Some(Command::Agentinfinity) => cmd::agentinfinity::run(),
Some(Command::Agentinit { session }) => cmd::agentinit::run(&session),
Some(Command::Watchdog(WatchdogCommand::Start)) => cmd::agentinfinity::run(),
Some(Command::Watchdog(WatchdogCommand::Tick)) => cmd::watchdog::tick(),
Some(Command::Watchdog(WatchdogCommand::Events { since, limit, json })) => {
cmd::watchdog_events::run(since.as_deref(), limit, json)
}
Some(Command::Tick(sub)) => cmd::tick::run(sub),
Some(Command::Cron(sub)) => cmd::cron::run(sub),
Some(Command::Launchd(sub)) => cmd::launchd::run(sub),
Some(Command::Handoffs {
which,
arg,
limit,
json,
}) => cmd::handoffs::run(&which, arg.as_deref(), limit, json),
Some(Command::Escalate { subject, body }) => cmd::escalate::run(&subject, body.as_deref()),
Some(Command::Init { update, path }) => cmd::init::run(path, update),
Some(Command::Onboard) => cmd::onboard::run(),
Some(Command::Status { json }) => cmd::status::run(json),
Some(Command::Query { sql, json }) => cmd::query::run(&sql, json),
Some(Command::Analytics(sub)) => cmd::analytics::run(sub),
Some(Command::Events {
since,
until,
agent,
kind,
limit,
json,
}) => cmd::events::run(
since.as_deref(),
until.as_deref(),
agent.as_deref(),
kind.as_deref(),
limit,
json,
),
Some(Command::Gates { json, name }) => cmd::gates::run(json, name.as_deref()),
Some(Command::Ingest(sub)) => cmd::ingest::run(sub),
Some(Command::Task(sub)) => cmd::task::run(sub),
Some(Command::Doctor { brief, quiet, json }) => cmd::doctor::run(brief, quiet, json),
Some(Command::Morning { send, json }) => cmd::morning::run(send, json),
Some(Command::Quiet { seconds, reason }) => cmd::quiet::run(seconds, reason.as_deref()),
Some(Command::Nap { seconds, reason }) => cmd::nap::run(seconds, reason.as_deref()),
Some(Command::Drill { n }) => cmd::drill::run(n),
Some(Command::Test { suite }) => cmd::test::run(&suite),
Some(Command::Session(SessionCommand::Salvage { target, json })) => {
cmd::session::salvage(&target, json)
}
Some(Command::Setup(sub)) => cmd::setup::run(sub),
Some(Command::Hooks(sub)) => cmd::hooks::run(sub),
Some(Command::Channel(sub)) => cmd::channel::run(sub),
Some(Command::Io(sub)) => cmd::io::run(sub),
Some(Command::Config(sub)) => cmd::config::run(sub),
Some(Command::Observe(sub)) => match sub {
ObserveCommand::Status { json } => cmd::status::run(json),
ObserveCommand::Analytics(sub) => cmd::analytics::run(sub),
ObserveCommand::Events {
since,
until,
agent,
kind,
limit,
json,
} => cmd::events::run(
since.as_deref(),
until.as_deref(),
agent.as_deref(),
kind.as_deref(),
limit,
json,
),
ObserveCommand::Gates { json, name } => cmd::gates::run(json, name.as_deref()),
ObserveCommand::Session(SessionCommand::Salvage { target, json }) => {
cmd::session::salvage(&target, json)
}
},
Some(Command::Dev(sub)) => match sub {
DevCommand::Init { update, path } => cmd::init::run(path, update),
DevCommand::Agentinit { session } => cmd::agentinit::run(&session),
DevCommand::Cron(sub) => cmd::cron::run(sub),
},
Some(Command::Admin(sub)) => match sub {
AdminCommand::Down { force } => cmd::down::run(force),
AdminCommand::Onboard => cmd::onboard::run(),
},
}
}
fn command_requires_netsky_cwd(cmd: &Option<Command>) -> Option<&'static str> {
match cmd {
Some(Command::Up { .. }) => Some("up"),
Some(Command::Down { .. }) => Some("down"),
Some(Command::Restart { .. }) => Some("restart"),
Some(Command::Agent { .. }) => Some("agent"),
Some(Command::Codex { .. }) => Some("codex"),
Some(Command::Drill { .. }) => Some("drill"),
Some(Command::Admin(AdminCommand::Down { .. })) => Some("down"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gated_commands_return_their_name() {
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Up {
n: 0,
agent_type: AgentType::Claude,
})),
Some("up")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Down { force: false })),
Some("down")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Restart {
n: 0,
handoff: None,
})),
Some("restart")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Agent {
n: 1,
agent_type: AgentType::Claude,
fresh: false,
})),
Some("agent")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Agent {
n: 1,
agent_type: AgentType::Codex,
fresh: false,
})),
Some("agent")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Codex {
n: 1,
fresh: false,
prompt: None,
drain: true,
model: None,
})),
Some("codex")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Drill { n: 1 })),
Some("drill")
);
}
#[test]
fn ungated_commands_pass() {
assert_eq!(command_requires_netsky_cwd(&None), None);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Doctor {
brief: false,
quiet: true,
json: false,
})),
None
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Morning {
send: false,
json: false,
})),
None
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Watchdog(WatchdogCommand::Tick))),
None
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Escalate {
subject: "x".into(),
body: None,
})),
None
);
}
#[test]
fn parses_channel_ack() {
use clap::Parser;
let cli = Cli::parse_from([
"netsky",
"channel",
"ack",
"message-1",
"--status",
"done",
"--note",
"landed",
]);
let Some(Command::Channel(ChannelCommand::Ack {
message_id,
status,
note,
..
})) = cli.command
else {
panic!("expected channel ack command");
};
assert_eq!(message_id, "message-1");
assert_eq!(status, AckStatus::Done);
assert_eq!(note.as_deref(), Some("landed"));
}
#[test]
fn parses_cron_add() {
use clap::Parser;
let cli = Cli::parse_from([
"netsky",
"cron",
"add",
"morning-brief",
"0 7 * * *",
"agent0",
"/morning-brief",
]);
let Some(Command::Cron(CronCommand::Add {
label,
schedule,
target,
prompt,
..
})) = cli.command
else {
panic!("expected cron add command");
};
assert_eq!(label, "morning-brief");
assert_eq!(schedule, "0 7 * * *");
assert_eq!(target, "agent0");
assert_eq!(prompt, "/morning-brief");
}
#[test]
fn parses_channel_send_kind_and_thread() {
use clap::Parser;
let cli = Cli::parse_from([
"netsky",
"channel",
"send",
"agent0",
"/morning-brief",
"--from",
"agentcron",
"--kind",
"cron",
"--thread",
"morning-brief",
]);
let Some(Command::Channel(ChannelCommand::Send {
target,
text,
from,
kind,
thread,
..
})) = cli.command
else {
panic!("expected channel send command");
};
assert_eq!(target, "agent0");
assert_eq!(text, "/morning-brief");
assert_eq!(from.as_deref(), Some("agentcron"));
assert_eq!(kind.as_deref(), Some("cron"));
assert_eq!(thread.as_deref(), Some("morning-brief"));
}
#[test]
fn parses_drive_empty_trash() {
use clap::Parser;
use netsky_io::{DriveCmd, IoCommand};
let cli = Cli::parse_from([
"netsky",
"io",
"drive",
"empty-trash",
"cody@dkdc.dev",
"--confirm",
]);
let Some(Command::Io(IoCommand::Drive(DriveCmd::EmptyTrash { account, confirm }))) =
cli.command
else {
panic!("expected drive empty-trash command");
};
assert_eq!(account, "cody@dkdc.dev");
assert!(confirm);
}
#[test]
fn parses_io_serve_agent() {
use clap::Parser;
use netsky_io::{IoCommand, SourceKind};
let cli = Cli::parse_from(["netsky", "io", "serve", "-s", "agent"]);
let Some(Command::Io(IoCommand::Serve { source })) = cli.command else {
panic!("expected io serve command");
};
assert_eq!(source, SourceKind::Agent);
}
#[test]
fn parses_comms_alias_serve_agent() {
use clap::Parser;
use netsky_io::{IoCommand, SourceKind};
let cli = Cli::parse_from(["netsky", "comms", "serve", "-s", "agent"]);
let Some(Command::Io(IoCommand::Serve { source })) = cli.command else {
panic!("expected comms alias to parse as io serve command");
};
assert_eq!(source, SourceKind::Agent);
}
#[test]
fn parses_down_force() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "down", "--force"]);
let Some(Command::Down { force }) = cli.command else {
panic!("expected down command");
};
assert!(force);
}
}