use clap::{Args, 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,
},
#[command(args_conflicts_with_subcommands = true)]
Restart {
#[command(subcommand)]
command: Option<RestartCommand>,
#[arg(value_name = "N")]
n: Option<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,
},
Ai(AiCli),
#[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)]
Loop(LoopCommand),
#[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,
#[arg(long, short = 'w')]
watch: bool,
#[arg(long, default_value_t = 2, value_name = "SECS")]
interval: u64,
},
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),
#[command(subcommand)]
Sync(SyncCommand),
#[command(subcommand)]
Skill(SkillCommand),
#[command(subcommand)]
Workspace(cmd::workspace::WorkspaceCommand),
#[command(subcommand)]
Clone(CloneCommand),
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,
},
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)]
Imessage(cmd::imessage::ImessageCommand),
#[command(subcommand)]
Email(cmd::email::EmailCommand),
#[command(subcommand)]
Calendar(cmd::calendar::CalendarCommand),
#[command(subcommand)]
Drive(cmd::drive::DriveCommand),
#[command(subcommand)]
Iroh(cmd::iroh::IrohCommand),
#[command(subcommand)]
Channel(ChannelCommand),
#[command(subcommand, visible_alias = "comms")]
Io(IoCommand),
#[command(subcommand)]
Config(ConfigCommand),
#[command(subcommand)]
Prompts(PromptsCommand),
#[command(subcommand, hide = true)]
Observe(ObserveCommand),
#[command(subcommand, hide = true)]
Dev(DevCommand),
#[command(subcommand, hide = true)]
Admin(AdminCommand),
}
#[derive(Args, Debug)]
#[command(args_conflicts_with_subcommands = true)]
pub struct AiCli {
#[command(subcommand)]
pub command: Option<AiCommand>,
#[command(flatten)]
pub run: AiRunArgs,
}
#[derive(Subcommand, Debug)]
pub enum AiCommand {
Run(AiRunArgs),
Status {
#[arg(value_name = "HANDLE")]
handle: String,
},
Wait {
#[arg(value_name = "HANDLE")]
handle: String,
#[arg(long, default_value_t = 600, value_name = "SECS")]
timeout: u64,
},
Cat {
#[arg(value_name = "HANDLE")]
handle: String,
},
}
#[derive(Args, Debug, Clone)]
pub struct AiRunArgs {
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
#[arg(long, default_value = "codex-gpt-5.4")]
pub model: String,
#[arg(long = "skill", value_name = "CSV")]
pub skill: Vec<String>,
#[arg(long, default_value_t = 120, value_name = "SECS")]
pub timeout: u64,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub detach: bool,
#[arg(long, value_name = "DIR")]
pub cwd: Option<std::path::PathBuf>,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum ObserveCommand {
Status {
#[arg(long)]
json: bool,
#[arg(long, short = 'w')]
watch: bool,
#[arg(long, default_value_t = 2, value_name = "SECS")]
interval: u64,
},
#[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)]
#[command(subcommand_required = true, arg_required_else_help = true)]
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),
#[command(hide = true)]
RecordGitOp {
#[arg(long)]
operation: String,
#[arg(long)]
repo: String,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
remote: Option<String>,
#[arg(long = "from-sha")]
from_sha: Option<String>,
#[arg(long = "to-sha")]
to_sha: Option<String>,
#[arg(long)]
status: String,
#[arg(long = "detail-json")]
detail_json: Option<String>,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum AdminCommand {
Down {
#[arg(long)]
force: bool,
},
Onboard,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum ConfigCommand {
Show {
#[arg(long)]
json: bool,
},
Check {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum SkillCommand {
#[command(visible_alias = "list")]
Ls {
#[arg(long)]
json: bool,
#[arg(long)]
verbose: bool,
},
Cat {
#[arg(value_name = "NAME")]
name: String,
},
Inject {
#[arg(value_name = "CSV")]
csv: String,
#[arg(long, value_enum, default_value_t = SkillInjectFormat::Plain)]
format: SkillInjectFormat,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum SkillInjectFormat {
Plain,
Claude,
Codex,
Json,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum PromptOutputFormat {
Text,
Json,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum PromptsCommand {
#[command(visible_alias = "list")]
Ls {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
cwd: Option<std::path::PathBuf>,
#[arg(long, value_enum, default_value_t = PromptOutputFormat::Text)]
format: PromptOutputFormat,
},
Cat {
#[arg(value_name = "LAYER")]
layer: String,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
cwd: Option<std::path::PathBuf>,
},
Diff {
#[arg(value_name = "LAYER")]
layer: String,
#[arg(value_name = "COMMIT")]
commit: String,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
cwd: Option<std::path::PathBuf>,
},
Compose {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
cwd: Option<std::path::PathBuf>,
#[arg(long = "skill")]
skills: Vec<String>,
#[arg(long, value_enum, default_value_t = PromptOutputFormat::Text)]
format: PromptOutputFormat,
},
Audit {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
cwd: Option<std::path::PathBuf>,
#[arg(long = "skill")]
skills: Vec<String>,
#[arg(long, value_enum, default_value_t = PromptOutputFormat::Text)]
format: PromptOutputFormat,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum AgentType {
Claude,
Codex,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum WatchdogCommand {
Start,
Listen {
#[arg(long, default_value_t = netsky_core::consts::RESTART_CONFIRM_TIMEOUT_S)]
confirm_timeout: u64,
#[arg(long, default_value_t = netsky_core::consts::RESTART_LISTEN_POLL_MS)]
poll_ms: u64,
},
Tick,
Events {
#[arg(long)]
since: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum RestartCommand {
Request {
#[arg(long)]
handoff: String,
#[arg(long, default_value_t = netsky_core::consts::RESTART_REQUEST_TIMEOUT_S)]
timeout: u64,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum TickCommand {
Enable {
#[arg(value_name = "SECONDS")]
seconds: u64,
},
Disable,
Request,
Ticker,
#[command(alias = "ticker-start")]
Start,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
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)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum LoopCommand {
Create {
#[arg(value_name = "INTERVAL_OR_PROMPT")]
interval_or_prompt: String,
#[arg(value_name = "PROMPT")]
prompt: Option<String>,
#[arg(long)]
json: bool,
},
List {
#[arg(long)]
json: bool,
},
Delete {
#[arg(value_name = "ID")]
id: String,
#[arg(long)]
json: bool,
},
Tick {
#[arg(long)]
json: bool,
},
Reschedule {
#[arg(value_name = "ID")]
id: String,
#[arg(value_name = "DELAY_SECS")]
delay_secs: u64,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum LaunchdCommand {
Install,
Uninstall,
Status {
#[arg(long)]
json: bool,
},
Reinstall,
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum SetupCommand {
Email {
account: Option<String>,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
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(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum SyncCommand {
#[command(subcommand)]
Tasks(cmd::tasks::TasksCommand),
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum CloneCommand {
Brief {
#[arg(value_name = "PATH")]
path: std::path::PathBuf,
#[arg(long = "type", value_enum, default_value_t = AgentType::Codex)]
agent_type: AgentType,
#[arg(long, value_name = "NAME")]
workspace: String,
#[arg(long, value_name = "N")]
agent: Option<u32>,
},
Status {
#[arg(value_name = "N")]
n: u32,
},
Wait {
#[arg(value_name = "N")]
n: u32,
#[arg(long, default_value_t = 60, value_name = "SECS")]
timeout: u64,
},
Kill {
#[arg(value_name = "N")]
n: u32,
},
#[command(visible_alias = "list")]
Ls,
}
#[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)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum SessionCommand {
Salvage {
#[arg(value_name = "TARGET")]
target: String,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
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 {
command,
n,
handoff,
}) => match command {
Some(RestartCommand::Request { handoff, timeout }) => {
cmd::restart_request::run(&handoff, timeout)
}
None => cmd::restart::run(
n.unwrap_or(netsky_core::consts::DEFAULT_CLONE_COUNT),
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::Ai(ai)) => match ai.command {
Some(AiCommand::Run(args)) => cmd::ai::run(
args.prompt.as_deref(),
&args.model,
&args.skill,
args.timeout,
args.json,
args.detach,
args.cwd,
),
Some(AiCommand::Status { handle }) => cmd::ai::status(&handle),
Some(AiCommand::Wait { handle, timeout }) => cmd::ai::wait(&handle, timeout),
Some(AiCommand::Cat { handle }) => cmd::ai::cat(&handle),
None => cmd::ai::run(
ai.run.prompt.as_deref(),
&ai.run.model,
&ai.run.skill,
ai.run.timeout,
ai.run.json,
ai.run.detach,
ai.run.cwd,
),
},
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::Listen {
confirm_timeout,
poll_ms,
})) => cmd::watchdog::listen(confirm_timeout, poll_ms),
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::Loop(sub)) => cmd::loop_cmd::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,
watch,
interval,
}) => cmd::status::run(json, watch, interval),
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::Sync(sub)) => cmd::sync::run(sub),
Some(Command::Skill(sub)) => cmd::skill::run(sub),
Some(Command::Workspace(sub)) => cmd::workspace::run(sub),
Some(Command::Clone(sub)) => cmd::clone::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::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::Imessage(sub)) => cmd::imessage::run(sub),
Some(Command::Email(sub)) => cmd::email::run(sub),
Some(Command::Calendar(sub)) => cmd::calendar::run(sub),
Some(Command::Drive(sub)) => cmd::drive::run(sub),
Some(Command::Iroh(sub)) => cmd::iroh::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::Prompts(sub)) => cmd::prompts::run(sub),
Some(Command::Observe(sub)) => match sub {
ObserveCommand::Status {
json,
watch,
interval,
} => cmd::status::run(json, watch, interval),
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),
DevCommand::RecordGitOp {
operation,
repo,
branch,
remote,
from_sha,
to_sha,
status,
detail_json,
} => cmd::dev::record_git_op(
&operation,
&repo,
branch.as_deref(),
remote.as_deref(),
from_sha.as_deref(),
to_sha.as_deref(),
&status,
detail_json.as_deref(),
),
},
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 { command: None, .. }) => Some("restart"),
Some(Command::Restart {
command: Some(RestartCommand::Request { .. }),
..
}) => Some("restart request"),
Some(Command::Agent { .. }) => Some("agent"),
Some(Command::Drill { .. }) => Some("drill"),
Some(Command::Workspace(..)) => Some("workspace"),
Some(Command::Clone(..)) => Some("clone"),
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 {
command: None,
n: Some(0),
handoff: None,
})),
Some("restart")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Restart {
command: Some(RestartCommand::Request {
handoff: "/tmp/handoff.txt".into(),
timeout: 15,
}),
n: None,
handoff: None,
})),
Some("restart request")
);
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::Drill { n: 1 })),
Some("drill")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Workspace(
cmd::workspace::WorkspaceCommand::Ls,
))),
Some("workspace")
);
assert_eq!(
command_requires_netsky_cwd(&Some(Command::Clone(CloneCommand::Ls))),
Some("clone")
);
}
#[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_loop_create_fixed() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "loop", "create", "5m", "/notes"]);
let Some(Command::Loop(LoopCommand::Create {
interval_or_prompt,
prompt,
..
})) = cli.command
else {
panic!("expected loop create command");
};
assert_eq!(interval_or_prompt, "5m");
assert_eq!(prompt.as_deref(), Some("/notes"));
}
#[test]
fn parses_loop_create_dynamic() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "loop", "create", "/notes"]);
let Some(Command::Loop(LoopCommand::Create {
interval_or_prompt,
prompt,
..
})) = cli.command
else {
panic!("expected loop create command");
};
assert_eq!(interval_or_prompt, "/notes");
assert!(prompt.is_none());
}
#[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_restart_request_subcommand() {
use clap::Parser;
let cli = Cli::parse_from([
"netsky",
"restart",
"request",
"--handoff",
"/tmp/handoff.txt",
"--timeout",
"21",
]);
let Some(Command::Restart {
command: Some(RestartCommand::Request { handoff, timeout }),
..
}) = cli.command
else {
panic!("expected restart request command");
};
assert_eq!(handoff, "/tmp/handoff.txt");
assert_eq!(timeout, 21);
}
#[test]
fn parses_clone_brief() {
use clap::Parser;
let cli = Cli::parse_from([
"netsky",
"clone",
"brief",
"brief.md",
"--type",
"codex",
"--workspace",
"demo",
"--agent",
"7",
]);
let Some(Command::Clone(CloneCommand::Brief {
path,
agent_type,
workspace,
agent,
})) = cli.command
else {
panic!("expected clone brief command");
};
assert_eq!(path, std::path::PathBuf::from("brief.md"));
assert_eq!(agent_type, AgentType::Codex);
assert_eq!(workspace, "demo");
assert_eq!(agent, Some(7));
}
#[test]
fn parses_clone_wait() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "clone", "wait", "9", "--timeout", "12"]);
let Some(Command::Clone(CloneCommand::Wait { n, timeout })) = cli.command else {
panic!("expected clone wait command");
};
assert_eq!(n, 9);
assert_eq!(timeout, 12);
}
#[test]
fn parses_sync_tasks_list() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "sync", "tasks", "ls", "cody@dkdc.dev"]);
let Some(Command::Sync(SyncCommand::Tasks(cmd::tasks::TasksCommand::List { account }))) =
cli.command
else {
panic!("expected sync tasks list command");
};
assert_eq!(account, "cody@dkdc.dev");
}
#[test]
fn ai_legacy_run_form_still_parses() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "ai", "--detach", "hello"]);
let Some(Command::Ai(AiCli {
command: None,
run:
AiRunArgs {
prompt,
detach,
model,
..
},
})) = cli.command
else {
panic!("expected ai legacy run");
};
assert_eq!(prompt.as_deref(), Some("hello"));
assert!(detach);
assert_eq!(model, "codex-gpt-5.4");
}
#[test]
fn ai_subcommands_parse() {
use clap::Parser;
let cli = Cli::parse_from(["netsky", "ai", "status", "h1"]);
let Some(Command::Ai(AiCli {
command: Some(AiCommand::Status { handle }),
..
})) = cli.command
else {
panic!("expected ai status");
};
assert_eq!(handle, "h1");
let cli = Cli::parse_from(["netsky", "ai", "run", "--detach", "hello"]);
let Some(Command::Ai(AiCli {
command: Some(AiCommand::Run(AiRunArgs { prompt, detach, .. })),
..
})) = cli.command
else {
panic!("expected ai run");
};
assert_eq!(prompt.as_deref(), Some("hello"));
assert!(detach);
}
#[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);
}
}