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> },
#[command(name = "github-audit")]
GithubAudit {
repo: String,
#[arg(long, default_value = "stargazers")]
depth: String,
#[arg(long, default_value_t = 200)]
sample: usize,
#[arg(long)]
out: Option<String>,
#[arg(long)]
html: 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>,
#[arg(long = "frame-id", allow_hyphen_values = true, value_parser = parse_frame_id)]
frame_id: Option<u32>,
#[arg(long = "run-code-args", value_parser = parse_run_code_args)]
run_code_args: Option<String>,
#[arg(long)]
reseed: bool,
},
#[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>,
#[arg(long = "original-url")]
original_url: Option<String>,
#[arg(long = "origin-tool")]
origin_tool: Option<String>,
#[arg(long = "origin-note")]
origin_note: Option<String>,
},
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>,
#[arg(long = "frame-id", allow_hyphen_values = true, value_parser = parse_frame_id)]
frame_id: Option<u32>,
#[arg(long = "run-code-args", value_parser = parse_run_code_args)]
run_code_args: Option<String>,
#[arg(long)]
reseed: bool,
},
Synthesize {
slug: Option<String>,
#[arg(long)]
no_render: bool,
#[arg(long)]
open: bool,
#[arg(long)]
bilingual: bool,
#[arg(long)]
pdf: bool,
#[arg(long = "pdf-output")]
pdf_output: Option<String>,
},
Finish {
slug: String,
#[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>,
},
}
fn parse_frame_id(s: &str) -> Result<u32, String> {
let v: i64 = s.parse().map_err(|_| {
format!("'--frame-id' value '{s}' is not a valid integer (frame-id must be >= 0)")
})?;
if v < 0 {
return Err(format!("frame-id must be >= 0 (got {v})"));
}
u32::try_from(v).map_err(|_| format!("frame-id too large: {v}"))
}
fn parse_run_code_args(s: &str) -> Result<String, String> {
let v: serde_json::Value = serde_json::from_str(s)
.map_err(|e| format!("invalid JSON for --run-code-args: {e} (expected JSON array)"))?;
if !v.is_array() {
return Err(format!(
"--run-code-args must be a JSON array (got {})",
json_type_name(&v)
));
}
Ok(s.to_string())
}
fn json_type_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
pub fn run() -> ExitCode {
let cli = Cli::parse();
let json = cli.json;
let (envelope, github_audit_plain) = 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) => {
let github_audit_plain = matches!(cmd, Commands::GithubAudit { .. });
(dispatch(cmd), github_audit_plain)
}
};
if github_audit_plain && !json {
commands::github_audit::render_plain_summary(&envelope);
} else {
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::GithubAudit {
repo,
depth,
sample,
out,
html,
} => commands::github_audit::run(&repo, &depth, sample, out.as_deref(), html.as_deref()),
Commands::Resume { slug } => commands::resume::run(&slug),
Commands::Add {
url,
slug,
timeout,
readable,
no_readable,
min_bytes,
on_short_body,
frame_id,
run_code_args,
reseed,
} => commands::add::run(
&url,
slug.as_deref(),
timeout,
readable,
no_readable,
min_bytes,
on_short_body.as_deref(),
frame_id,
run_code_args.as_deref(),
reseed,
),
Commands::AddLocal {
path,
slug,
glob,
max_file_bytes,
max_total_bytes,
original_url,
origin_tool,
origin_note,
} => commands::add_local::run(
&path,
slug.as_deref(),
&glob,
max_file_bytes,
max_total_bytes,
original_url.as_deref(),
origin_tool.as_deref(),
origin_note.as_deref(),
),
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,
frame_id,
run_code_args,
reseed,
} => commands::batch::run(
&urls,
slug.as_deref(),
concurrency,
timeout,
readable,
no_readable,
min_bytes,
on_short_body.as_deref(),
frame_id,
run_code_args.as_deref(),
reseed,
),
Commands::Synthesize {
slug,
no_render,
open,
bilingual,
pdf,
pdf_output,
} => commands::synthesize::run(
slug.as_deref(),
no_render,
open,
bilingual,
pdf || pdf_output.is_some(),
pdf_output.as_deref(),
),
Commands::Finish {
slug,
open,
bilingual,
} => commands::finish::run(&slug, 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"]);
}
}