diff --git a/crates/webclaw-cli/src/main.rs b/crates/webclaw-cli/src/main.rs index e3434c5..de98438 100644 --- a/crates/webclaw-cli/src/main.rs +++ b/crates/webclaw-cli/src/main.rs @@ -864,6 +864,18 @@ fn collect_urls(cli: &Cli) -> Result)>, String> { Ok(entries) } +/// When `--urls-file` supplies exactly one URL and no positional URL was given, +/// treat it like a single positional URL so a one-line file behaves identically +/// to `webclaw ` (stdout, `--output-dir`, cloud fallback, `--raw-html`, +/// `--diff`, LLM paths). Without this the lone file URL gives `entries.len() == 1`, +/// which the batch gates skip, and the single path then finds an empty `cli.urls` +/// and errors "no input provided". See issue #86. +fn backfill_single_file_url(urls: &mut Vec, entries: &[(String, Option)]) { + if urls.is_empty() && entries.len() == 1 { + urls.push(entries[0].0.clone()); + } +} + /// Result that can be either a local extraction or a cloud API JSON response. enum FetchOutput { Local(Box), @@ -2624,7 +2636,7 @@ async fn run_research(cli: &Cli, query: &str) -> Result<(), String> { async fn main() { dotenvy::dotenv().ok(); - let cli = Cli::parse(); + let mut cli = Cli::parse(); init_logging(cli.verbose); // Subcommand path. Handled before the flag dispatch so a subcommand @@ -2828,6 +2840,11 @@ async fn main() { } }; + // A single URL sourced only from --urls-file must behave like a positional + // URL (issue #86): the batch gates below require len > 1, and the single + // path reads cli.urls, so a one-line file would otherwise hit "no input". + backfill_single_file_url(&mut cli.urls, &entries); + // LLM modes: --extract-json, --extract-prompt, --summarize // When multiple URLs are provided, run batch LLM extraction over all of them. if has_llm_flags(&cli) { @@ -2899,6 +2916,36 @@ mod tests { use super::*; use webclaw_core::Content; + // issue #86: a single URL sourced only from --urls-file must be promoted to + // a positional URL so it takes the single-scrape path (batch gates need >1). + #[test] + fn single_file_url_backfilled_into_positional() { + let mut urls: Vec = Vec::new(); + let entries = vec![("https://example.com".to_string(), None)]; + backfill_single_file_url(&mut urls, &entries); + assert_eq!(urls, vec!["https://example.com".to_string()]); + } + + #[test] + fn positional_urls_left_untouched() { + let mut urls = vec!["https://a.com".to_string()]; + let entries = vec![("https://a.com".to_string(), None)]; + backfill_single_file_url(&mut urls, &entries); + assert_eq!(urls, vec!["https://a.com".to_string()]); + } + + #[test] + fn multiple_file_urls_not_backfilled() { + // 2+ URLs already route to run_batch, so cli.urls stays empty here. + let mut urls: Vec = Vec::new(); + let entries = vec![ + ("https://a.com".to_string(), None), + ("https://b.com".to_string(), None), + ]; + backfill_single_file_url(&mut urls, &entries); + assert!(urls.is_empty()); + } + fn empty_result(title: Option<&str>, url: Option<&str>, markdown: &str) -> ExtractionResult { ExtractionResult { metadata: Metadata {