@@ -864,6 +864,18 @@ fn collect_urls(cli: &Cli) -> Result<Vec<(String, Option<String>)>, String> {
864864 Ok ( entries)
865865}
866866
867+ /// When `--urls-file` supplies exactly one URL and no positional URL was given,
868+ /// treat it like a single positional URL so a one-line file behaves identically
869+ /// to `webclaw <url>` (stdout, `--output-dir`, cloud fallback, `--raw-html`,
870+ /// `--diff`, LLM paths). Without this the lone file URL gives `entries.len() == 1`,
871+ /// which the batch gates skip, and the single path then finds an empty `cli.urls`
872+ /// and errors "no input provided". See issue #86.
873+ fn backfill_single_file_url ( urls : & mut Vec < String > , entries : & [ ( String , Option < String > ) ] ) {
874+ if urls. is_empty ( ) && entries. len ( ) == 1 {
875+ urls. push ( entries[ 0 ] . 0 . clone ( ) ) ;
876+ }
877+ }
878+
867879/// Result that can be either a local extraction or a cloud API JSON response.
868880enum FetchOutput {
869881 Local ( Box < ExtractionResult > ) ,
@@ -2633,7 +2645,7 @@ async fn run_research(cli: &Cli, query: &str) -> Result<(), String> {
26332645async fn main ( ) {
26342646 dotenvy:: dotenv ( ) . ok ( ) ;
26352647
2636- let cli = Cli :: parse ( ) ;
2648+ let mut cli = Cli :: parse ( ) ;
26372649 init_logging ( cli. verbose ) ;
26382650
26392651 // Subcommand path. Handled before the flag dispatch so a subcommand
@@ -2776,6 +2788,20 @@ async fn main() {
27762788 return ;
27772789 }
27782790
2791+ // Collect URLs from args + --urls-file up front, and backfill a lone
2792+ // --urls-file URL into cli.urls so a one-line file behaves like a positional
2793+ // URL across ALL modes below (crawl / watch / diff / brand / batch / single).
2794+ // See issue #86 — the mode gates and single path read cli.urls, which is
2795+ // empty when the URL came only from --urls-file.
2796+ let entries = match collect_urls ( & cli) {
2797+ Ok ( u) => u,
2798+ Err ( e) => {
2799+ eprintln ! ( "error: {e}" ) ;
2800+ process:: exit ( 1 ) ;
2801+ }
2802+ } ;
2803+ backfill_single_file_url ( & mut cli. urls , & entries) ;
2804+
27792805 // --crawl: recursive crawl mode
27802806 if cli. crawl {
27812807 if let Err ( e) = run_crawl ( & cli) . await {
@@ -2787,13 +2813,7 @@ async fn main() {
27872813
27882814 // --watch: poll URL(s) for changes
27892815 if cli. watch {
2790- let watch_urls: Vec < String > = match collect_urls ( & cli) {
2791- Ok ( entries) => entries. into_iter ( ) . map ( |( url, _) | url) . collect ( ) ,
2792- Err ( e) => {
2793- eprintln ! ( "error: {e}" ) ;
2794- process:: exit ( 1 ) ;
2795- }
2796- } ;
2816+ let watch_urls: Vec < String > = entries. iter ( ) . map ( |( url, _) | url. clone ( ) ) . collect ( ) ;
27972817 if let Err ( e) = run_watch ( & cli, & watch_urls) . await {
27982818 eprintln ! ( "error: {e}" ) ;
27992819 process:: exit ( 1 ) ;
@@ -2828,15 +2848,6 @@ async fn main() {
28282848 return ;
28292849 }
28302850
2831- // Collect all URLs from args + --urls-file
2832- let entries = match collect_urls ( & cli) {
2833- Ok ( u) => u,
2834- Err ( e) => {
2835- eprintln ! ( "error: {e}" ) ;
2836- process:: exit ( 1 ) ;
2837- }
2838- } ;
2839-
28402851 // LLM modes: --extract-json, --extract-prompt, --summarize
28412852 // When multiple URLs are provided, run batch LLM extraction over all of them.
28422853 if has_llm_flags ( & cli) {
@@ -2908,6 +2919,36 @@ mod tests {
29082919 use super :: * ;
29092920 use webclaw_core:: Content ;
29102921
2922+ // issue #86: a single URL sourced only from --urls-file must be promoted to
2923+ // a positional URL so it takes the single-scrape path (batch gates need >1).
2924+ #[ test]
2925+ fn single_file_url_backfilled_into_positional ( ) {
2926+ let mut urls: Vec < String > = Vec :: new ( ) ;
2927+ let entries = vec ! [ ( "https://example.com" . to_string( ) , None ) ] ;
2928+ backfill_single_file_url ( & mut urls, & entries) ;
2929+ assert_eq ! ( urls, vec![ "https://example.com" . to_string( ) ] ) ;
2930+ }
2931+
2932+ #[ test]
2933+ fn positional_urls_left_untouched ( ) {
2934+ let mut urls = vec ! [ "https://a.com" . to_string( ) ] ;
2935+ let entries = vec ! [ ( "https://a.com" . to_string( ) , None ) ] ;
2936+ backfill_single_file_url ( & mut urls, & entries) ;
2937+ assert_eq ! ( urls, vec![ "https://a.com" . to_string( ) ] ) ;
2938+ }
2939+
2940+ #[ test]
2941+ fn multiple_file_urls_not_backfilled ( ) {
2942+ // 2+ URLs already route to run_batch, so cli.urls stays empty here.
2943+ let mut urls: Vec < String > = Vec :: new ( ) ;
2944+ let entries = vec ! [
2945+ ( "https://a.com" . to_string( ) , None ) ,
2946+ ( "https://b.com" . to_string( ) , None ) ,
2947+ ] ;
2948+ backfill_single_file_url ( & mut urls, & entries) ;
2949+ assert ! ( urls. is_empty( ) ) ;
2950+ }
2951+
29112952 fn empty_result ( title : Option < & str > , url : Option < & str > , markdown : & str ) -> ExtractionResult {
29122953 ExtractionResult {
29132954 metadata : Metadata {
0 commit comments