use clap::{Parser, Subcommand};
use std::process::ExitCode;
use crate::commands;
use crate::output::Envelope;
#[derive(Parser, Debug)]
#[command(
name = "research",
about = "Research workflow CLI — orchestrate postagent + actionbook for reproducible research sessions",
disable_version_flag = true
)]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(long, global = true)]
pub no_color: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
#[command(disable_help_subcommand = true)]
pub enum Commands {
New {
topic: String,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
force: bool,
#[arg(long = "from")]
from: Option<String>,
#[arg(long = "tag", action = clap::ArgAction::Append)]
tag: Vec<String>,
},
List {
#[arg(long)]
tag: Option<String>,
#[arg(long)]
tree: bool,
},
Show { slug: String },
Status { slug: Option<String> },
Audit { slug: Option<String> },
Resume { slug: String },
Add {
url: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
readable: bool,
#[arg(long)]
no_readable: bool,
#[arg(long = "min-bytes")]
min_bytes: Option<u64>,
#[arg(long = "on-short-body")]
on_short_body: Option<String>,
},
#[command(name = "add-local")]
AddLocal {
path: String,
#[arg(long)]
slug: Option<String>,
#[arg(long = "glob", action = clap::ArgAction::Append)]
glob: Vec<String>,
#[arg(long = "max-file-bytes")]
max_file_bytes: Option<u64>,
#[arg(long = "max-total-bytes")]
max_total_bytes: Option<u64>,
},
Sources {
slug: Option<String>,
#[arg(long)]
rejected: bool,
},
Batch {
urls: Vec<String>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
readable: bool,
#[arg(long)]
no_readable: bool,
#[arg(long = "min-bytes")]
min_bytes: Option<u64>,
#[arg(long = "on-short-body")]
on_short_body: Option<String>,
},
Synthesize {
slug: Option<String>,
#[arg(long)]
no_render: bool,
#[arg(long)]
open: bool,
#[arg(long)]
bilingual: bool,
},
Report {
slug: Option<String>,
#[arg(long)]
format: String,
#[arg(long)]
open: bool,
#[arg(long = "no-open")]
no_open: bool,
#[arg(long)]
stdout: bool,
#[arg(long)]
output: Option<String>,
},
Close { slug: Option<String> },
Rm {
slug: String,
#[arg(long)]
force: bool,
},
Route {
url: String,
#[arg(long)]
prefer: Option<String>,
#[arg(long)]
rules: Option<String>,
#[arg(long)]
preset: Option<String>,
},
Series {
tag: String,
#[arg(long)]
open: bool,
},
Diff {
slug: Option<String>,
#[arg(long = "unused-only")]
unused_only: bool,
},
Coverage { slug: Option<String> },
Doctor {
#[arg(long = "provider-smoke")]
provider_smoke: bool,
#[arg(long = "tool-smoke")]
tool_smoke: bool,
#[arg(long = "provider", default_value = "all")]
provider: String,
},
#[cfg(feature = "autoresearch")]
Loop {
slug: Option<String>,
#[arg(long, default_value = "fake")]
provider: String,
#[arg(long)]
iterations: Option<u32>,
#[arg(long = "max-actions")]
max_actions: Option<u32>,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(long = "fake-responses")]
fake_responses: Option<String>,
},
Wiki {
#[command(subcommand)]
sub: WikiCmd,
},
Schema {
#[command(subcommand)]
sub: SchemaCmd,
},
Help,
}
#[derive(Subcommand, Debug)]
pub enum SchemaCmd {
Show {
#[arg(long)]
slug: Option<String>,
},
Edit {
#[arg(long)]
slug: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum WikiCmd {
List {
#[arg(long)]
slug: Option<String>,
},
Show {
page: String,
#[arg(long)]
slug: Option<String>,
},
Rm {
page: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
force: bool,
},
Query {
question: String,
#[arg(long)]
slug: Option<String>,
#[arg(long = "save-as")]
save_as: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, default_value = "claude")]
provider: String,
},
Lint {
#[arg(long)]
slug: Option<String>,
#[arg(long = "stale-days")]
stale_days: Option<i64>,
},
}
pub fn run() -> ExitCode {
let cli = Cli::parse();
let json = cli.json;
let envelope = match cli.command {
None => {
use clap::CommandFactory;
let mut cmd = Cli::command();
let _ = cmd.print_help();
println!();
return ExitCode::SUCCESS;
}
Some(Commands::Help) => {
use clap::CommandFactory;
let mut cmd = Cli::command();
let _ = cmd.print_help();
println!();
return ExitCode::SUCCESS;
}
Some(cmd) => dispatch(cmd),
};
envelope.render(json);
if envelope.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(64)
}
}
fn dispatch(cmd: Commands) -> Envelope {
match cmd {
Commands::New {
topic,
preset,
slug,
force,
from,
tag,
} => commands::new::run(
&topic,
preset.as_deref(),
slug.as_deref(),
force,
from.as_deref(),
&tag,
),
Commands::List { tag, tree } => commands::list::run(tag.as_deref(), tree),
Commands::Show { slug } => commands::show::run(&slug),
Commands::Status { slug } => commands::status::run(slug.as_deref()),
Commands::Audit { slug } => commands::audit::run(slug.as_deref()),
Commands::Resume { slug } => commands::resume::run(&slug),
Commands::Add {
url,
slug,
timeout,
readable,
no_readable,
min_bytes,
on_short_body,
} => commands::add::run(
&url,
slug.as_deref(),
timeout,
readable,
no_readable,
min_bytes,
on_short_body.as_deref(),
),
Commands::AddLocal {
path,
slug,
glob,
max_file_bytes,
max_total_bytes,
} => commands::add_local::run(
&path,
slug.as_deref(),
&glob,
max_file_bytes,
max_total_bytes,
),
Commands::Sources { slug, rejected } => commands::sources::run(slug.as_deref(), rejected),
Commands::Batch {
urls,
slug,
concurrency,
timeout,
readable,
no_readable,
min_bytes,
on_short_body,
} => commands::batch::run(
&urls,
slug.as_deref(),
concurrency,
timeout,
readable,
no_readable,
min_bytes,
on_short_body.as_deref(),
),
Commands::Synthesize {
slug,
no_render,
open,
bilingual,
} => commands::synthesize::run(slug.as_deref(), no_render, open, bilingual),
Commands::Report {
slug,
format,
open,
no_open,
stdout,
output,
} => commands::report::run(
slug.as_deref(),
&format,
open,
no_open,
stdout,
output.as_deref(),
),
Commands::Close { slug } => commands::close::run(slug.as_deref()),
Commands::Rm { slug, force } => commands::rm::run(&slug, force),
Commands::Route {
url,
prefer,
rules,
preset,
} => commands::route::run(&url, prefer.as_deref(), rules.as_deref(), preset.as_deref()),
Commands::Series { tag, open } => commands::series::run(&tag, open),
Commands::Diff { slug, unused_only } => commands::diff::run(slug.as_deref(), unused_only),
Commands::Coverage { slug } => commands::coverage::run(slug.as_deref()),
Commands::Doctor {
provider_smoke,
tool_smoke,
provider,
} => commands::doctor::run(provider_smoke, tool_smoke, &provider),
#[cfg(feature = "autoresearch")]
Commands::Loop {
slug,
provider,
iterations,
max_actions,
dry_run,
fake_responses,
} => commands::loop_cmd::run(
slug.as_deref(),
&provider,
iterations,
max_actions,
dry_run,
fake_responses.as_deref().map(split_fake_responses),
),
Commands::Wiki { sub } => match sub {
WikiCmd::List { slug } => commands::wiki::run_list(slug.as_deref()),
WikiCmd::Show { page, slug } => commands::wiki::run_show(&page, slug.as_deref()),
WikiCmd::Rm { page, slug, force } => {
commands::wiki::run_rm(&page, slug.as_deref(), force)
}
WikiCmd::Query {
question,
slug,
save_as,
format,
provider,
} => commands::wiki_query::run(
&question,
slug.as_deref(),
save_as.as_deref(),
format.as_deref(),
&provider,
),
WikiCmd::Lint { slug, stale_days } => {
commands::wiki_lint::run(slug.as_deref(), stale_days)
}
},
Commands::Schema { sub } => match sub {
SchemaCmd::Show { slug } => commands::schema::run_show(slug.as_deref()),
SchemaCmd::Edit { slug } => commands::schema::run_edit(slug.as_deref()),
},
Commands::Help => unreachable!("Help handled in run()"),
}
}
#[cfg(any(feature = "autoresearch", test))]
fn split_fake_responses(raw: &str) -> Vec<String> {
let delim: char = if raw.contains(';') { ';' } else { '\u{1e}' };
raw.split(delim).map(str::to_string).collect()
}
#[cfg(test)]
mod split_fake_tests {
use super::split_fake_responses;
#[test]
fn splits_on_semicolon_when_present() {
let v = split_fake_responses("resp1;resp2;resp3");
assert_eq!(v, vec!["resp1", "resp2", "resp3"]);
}
#[test]
fn falls_back_to_record_separator() {
let v = split_fake_responses("a\u{1e}b\u{1e}c");
assert_eq!(v, vec!["a", "b", "c"]);
}
#[test]
fn single_payload_yields_one_element() {
let v = split_fake_responses("just-one");
assert_eq!(v, vec!["just-one"]);
}
#[test]
fn semicolon_wins_over_record_separator_if_both_present() {
let v = split_fake_responses("a;b\u{1e}c");
assert_eq!(v, vec!["a", "b\u{1e}c"]);
}
}