eightstatecli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,597 @@
1
+ """
2
+ escli research — web research tasks via Parallel Task API.
3
+
4
+ Supports the full Task API surface: deep research, data enrichment,
5
+ structured output, source policies, interactions, and all processors.
6
+
7
+ Usage:
8
+ escli research "query" -o report.md Deep research → markdown
9
+ escli research "query" -o report.md -p ultra Higher quality processor
10
+ escli research "query" --schema schema.json -o out.json Structured JSON output
11
+ escli research "query" --include-domains sec.gov,wsj.com Only use specific sources
12
+ escli research status <run-id> Check task status
13
+ escli research result <run-id> -o output.md Fetch completed result
14
+
15
+ Processors (ascending quality/cost/time):
16
+ lite, base, core, core2x, pro, ultra, ultra2x, ultra4x, ultra8x
17
+ Add -fast suffix for speed-optimized variants
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import pathlib
24
+ import sys
25
+ import time
26
+ import urllib.request
27
+ import urllib.error
28
+ from datetime import datetime, timezone
29
+
30
+ from ..services.credentials import get_key_for_service
31
+
32
+ API_BASE = "https://api.parallel.ai"
33
+ SSE_BETA = "events-sse-2025-07-24"
34
+ MAX_POLL_WAIT = 7200 # 2 hours (ultra8x can take up to 2h)
35
+ POLL_INTERVAL = 10
36
+
37
+ PROCESSORS = [
38
+ "lite", "base", "core", "core2x", "pro", "ultra",
39
+ "ultra2x", "ultra4x", "ultra8x",
40
+ "lite-fast", "base-fast", "core-fast", "core2x-fast",
41
+ "pro-fast", "ultra-fast", "ultra2x-fast", "ultra4x-fast", "ultra8x-fast",
42
+ ]
43
+
44
+
45
+ def _get_api_key() -> str:
46
+ key = get_key_for_service("parallel", "PARALLEL_API_KEY")
47
+ if not key:
48
+ print(" ✗ no Parallel API key. Set PARALLEL_API_KEY or add one via the dashboard.", file=sys.stderr)
49
+ sys.exit(1)
50
+ return key
51
+
52
+
53
+ def _api_request(method: str, path: str, api_key: str,
54
+ body: dict | None = None, extra_headers: dict | None = None) -> dict:
55
+ url = f"{API_BASE}{path}"
56
+ hdrs = {"x-api-key": api_key, "Content-Type": "application/json"}
57
+ if extra_headers:
58
+ hdrs.update(extra_headers)
59
+
60
+ data = json.dumps(body).encode() if body else None
61
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
62
+
63
+ try:
64
+ with urllib.request.urlopen(req, timeout=60) as resp:
65
+ return json.loads(resp.read().decode())
66
+ except urllib.error.HTTPError as e:
67
+ try:
68
+ err_body = json.loads(e.read().decode())
69
+ except Exception:
70
+ err_body = {"error": {"message": str(e)}}
71
+
72
+ msg = err_body.get("error", {}).get("message", str(e))
73
+ if e.code == 401:
74
+ print(f" ✗ auth failed (401): {msg}", file=sys.stderr); sys.exit(1)
75
+ elif e.code == 429:
76
+ print(f" ✗ rate limited (429): {msg}", file=sys.stderr); sys.exit(2)
77
+ else:
78
+ print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr); sys.exit(2)
79
+ except urllib.error.URLError as e:
80
+ print(f" ✗ network error: {e.reason}", file=sys.stderr); sys.exit(2)
81
+
82
+
83
+ # ── SSE streaming ────────────────────────────────────────────────
84
+
85
+ def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
86
+ url = f"{API_BASE}/v1beta/tasks/runs/{run_id}/events"
87
+ seen = set()
88
+
89
+ for attempt in range(25):
90
+ if attempt > 0 and not quiet:
91
+ print(f" [sse] reconnecting ({attempt + 1})...", file=sys.stderr)
92
+
93
+ req = urllib.request.Request(url, headers={
94
+ "x-api-key": api_key,
95
+ "Accept": "text/event-stream",
96
+ "parallel-beta": SSE_BETA,
97
+ })
98
+
99
+ try:
100
+ resp = urllib.request.urlopen(req, timeout=600)
101
+ except Exception as e:
102
+ if not quiet:
103
+ print(f" [sse] failed: {e}", file=sys.stderr)
104
+ return None
105
+
106
+ try:
107
+ for raw_line in resp:
108
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
109
+ if not line.startswith("data: "):
110
+ continue
111
+ try:
112
+ event = json.loads(line[6:])
113
+ except json.JSONDecodeError:
114
+ continue
115
+
116
+ etype = event.get("type", "")
117
+
118
+ if etype == "task_run.progress_msg.exec_status" and not quiet:
119
+ msg = event.get("message", "")
120
+ if ("progress", msg) not in seen:
121
+ seen.add(("progress", msg))
122
+ print(f" [progress] {msg}", file=sys.stderr)
123
+
124
+ elif etype == "task_run.progress_stats" and not quiet:
125
+ meter = event.get("progress_meter", "")
126
+ if ("stats", str(meter)) not in seen:
127
+ seen.add(("stats", str(meter)))
128
+ stats = event.get("source_stats", {})
129
+ read = stats.get("num_sources_read", 0)
130
+ print(f" [progress] {meter}% — {read} sources read", file=sys.stderr)
131
+
132
+ elif etype == "task_run.state":
133
+ run = event.get("run", {})
134
+ status = run.get("status", "")
135
+ if status == "completed":
136
+ return event.get("output")
137
+ elif status == "failed":
138
+ print(f" ✗ task failed: {run.get('error', '')}", file=sys.stderr)
139
+ sys.exit(2)
140
+
141
+ elif etype == "error":
142
+ return None
143
+ except Exception:
144
+ pass
145
+ finally:
146
+ resp.close()
147
+
148
+ return None
149
+
150
+
151
+ def _poll_until_complete(api_key: str, run_id: str, quiet: bool = False) -> dict:
152
+ start = time.time()
153
+ while time.time() - start < MAX_POLL_WAIT:
154
+ result = _api_request("GET", f"/v1/tasks/runs/{run_id}", api_key)
155
+ status = result.get("status", "")
156
+ if not quiet:
157
+ print(f" [poll] status={status}", file=sys.stderr)
158
+
159
+ if status == "completed":
160
+ result = _api_request("GET", f"/v1/tasks/runs/{run_id}/result", api_key)
161
+ return result.get("output")
162
+ elif status in ("failed", "cancelled"):
163
+ print(f" ✗ task {status}: {result.get('error', '')}", file=sys.stderr)
164
+ sys.exit(2)
165
+
166
+ time.sleep(POLL_INTERVAL)
167
+
168
+ print(" ✗ timed out.", file=sys.stderr); sys.exit(3)
169
+
170
+
171
+ # ── Markdown formatting ──────────────────────────────────────────
172
+
173
+ def _format_markdown(query: str, processor: str, run_id: str, output: dict | None,
174
+ created_at: str, include_basis: bool = True) -> str:
175
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
176
+ lines = [
177
+ "---", f'query: "{query}"', f"processor: {processor}",
178
+ f"run_id: {run_id}", f"created_at: {created_at}", f"retrieved_at: {now}",
179
+ "---", "", f"# Research: {query}", "",
180
+ ]
181
+
182
+ if output is None:
183
+ lines.append("*No output returned.*")
184
+ return "\n".join(lines)
185
+
186
+ content = output.get("content")
187
+ basis = output.get("basis", [])
188
+
189
+ if isinstance(content, str):
190
+ # Could be JSON string or plain text
191
+ try:
192
+ parsed = json.loads(content)
193
+ if isinstance(parsed, dict):
194
+ _render_dict(lines, parsed)
195
+ else:
196
+ lines.append(content)
197
+ except (json.JSONDecodeError, TypeError):
198
+ lines.append(content)
199
+ elif isinstance(content, dict):
200
+ _render_dict(lines, content)
201
+ elif content is not None:
202
+ lines.append(str(content))
203
+
204
+ if include_basis and basis:
205
+ lines.extend(["", "## Research Basis", ""])
206
+ for entry in basis:
207
+ field = entry.get("field", "unknown")
208
+ reasoning = entry.get("reasoning", "")
209
+ confidence = entry.get("confidence", "")
210
+ citations = entry.get("citations", [])
211
+ lines.append(f"### {field}")
212
+ if confidence:
213
+ lines.append(f"**Confidence:** {confidence}")
214
+ if reasoning:
215
+ lines.append(f"\n{reasoning}")
216
+ if citations:
217
+ lines.append("")
218
+ for cite in citations:
219
+ url = cite.get("url", "")
220
+ title = cite.get("title", url)
221
+ lines.append(f"- [{title}]({url})")
222
+ for exc in cite.get("excerpts", []):
223
+ lines.append(f" > {exc}")
224
+ lines.append("")
225
+
226
+ return "\n".join(lines)
227
+
228
+
229
+ def _render_dict(lines: list[str], d: dict):
230
+ for key, value in d.items():
231
+ lines.append(f"## {key.replace('_', ' ').title()}")
232
+ lines.append("")
233
+ if isinstance(value, str):
234
+ lines.append(value)
235
+ elif isinstance(value, list):
236
+ if value and all(isinstance(i, dict) for i in value):
237
+ _render_table(lines, value)
238
+ else:
239
+ for item in value:
240
+ if isinstance(item, dict):
241
+ for k, v in item.items():
242
+ lines.append(f"**{k.replace('_', ' ').title()}:** {v}")
243
+ lines.append("")
244
+ else:
245
+ lines.append(f"- {item}")
246
+ elif isinstance(value, dict):
247
+ for k, v in value.items():
248
+ lines.append(f"**{k.replace('_', ' ').title()}:** {v}")
249
+ else:
250
+ lines.append(str(value))
251
+ lines.append("")
252
+
253
+
254
+ def _render_table(lines: list[str], items: list[dict]):
255
+ keys = []
256
+ for item in items:
257
+ for k in item:
258
+ if k not in keys:
259
+ keys.append(k)
260
+ headers = [k.replace("_", " ").title() for k in keys]
261
+ lines.append("| " + " | ".join(headers) + " |")
262
+ lines.append("| " + " | ".join("---" for _ in keys) + " |")
263
+ for item in items:
264
+ row = [str(item.get(k, "")).replace("\n", " ").replace("|", "\\|") for k in keys]
265
+ lines.append("| " + " | ".join(row) + " |")
266
+
267
+
268
+ # ── Commands ─────────────────────────────────────────────────────
269
+
270
+ def cmd_run(args):
271
+ """Create and execute a research task."""
272
+ api_key = _get_api_key()
273
+ query = " ".join(args.query)
274
+ processor = args.processor
275
+
276
+ if not args.quiet:
277
+ print(f" ▸ submitting task (processor={processor})...", file=sys.stderr)
278
+
279
+ # Build request body
280
+ body: dict = {
281
+ "processor": processor,
282
+ "input": query,
283
+ "enable_events": True,
284
+ }
285
+
286
+ # Input: JSON object from file or --input-json
287
+ if getattr(args, "input_json", None):
288
+ try:
289
+ body["input"] = json.loads(args.input_json)
290
+ except json.JSONDecodeError:
291
+ print(" ✗ --input-json must be valid JSON", file=sys.stderr); return 1
292
+
293
+ if getattr(args, "input_file", None):
294
+ path = pathlib.Path(args.input_file)
295
+ if not path.exists():
296
+ print(f" ✗ input file not found: {path}", file=sys.stderr); return 1
297
+ body["input"] = json.loads(path.read_text())
298
+
299
+ # Output schema
300
+ if getattr(args, "schema", None):
301
+ schema_path = pathlib.Path(args.schema)
302
+ if not schema_path.exists():
303
+ print(f" ✗ schema file not found: {schema_path}", file=sys.stderr); return 1
304
+ schema_data = json.loads(schema_path.read_text())
305
+ body["task_spec"] = {"output_schema": {"type": "json", "json_schema": schema_data}}
306
+ elif getattr(args, "output_schema", None):
307
+ body["task_spec"] = {"output_schema": args.output_schema}
308
+ elif getattr(args, "text", False):
309
+ body["task_spec"] = {"output_schema": {"type": "text"}}
310
+ # else: auto (default for pro+ processors)
311
+
312
+ # Source policy
313
+ source_policy = {}
314
+ if getattr(args, "include_domains", None):
315
+ source_policy["include_domains"] = [d.strip() for d in args.include_domains.split(",")]
316
+ if getattr(args, "exclude_domains", None):
317
+ source_policy["exclude_domains"] = [d.strip() for d in args.exclude_domains.split(",")]
318
+ if getattr(args, "after_date", None):
319
+ source_policy["after_date"] = args.after_date
320
+ if source_policy:
321
+ body["source_policy"] = source_policy
322
+
323
+ # Advanced settings
324
+ if getattr(args, "location", None):
325
+ body["advanced_settings"] = {"location": args.location}
326
+
327
+ # Metadata
328
+ if getattr(args, "metadata", None):
329
+ try:
330
+ body["metadata"] = json.loads(args.metadata)
331
+ except json.JSONDecodeError:
332
+ # Parse key=value pairs
333
+ meta = {}
334
+ for pair in args.metadata.split(","):
335
+ if "=" in pair:
336
+ k, v = pair.split("=", 1)
337
+ meta[k.strip()] = v.strip()
338
+ body["metadata"] = meta
339
+
340
+ # Follow-up on previous interaction
341
+ if getattr(args, "follow_up", None):
342
+ body["previous_interaction_id"] = args.follow_up
343
+
344
+ # Submit
345
+ headers = {"parallel-beta": SSE_BETA}
346
+ task = _api_request("POST", "/v1/tasks/runs", api_key, body=body, extra_headers=headers)
347
+ run_id = task["run_id"]
348
+ created_at = task.get("created_at", "")
349
+
350
+ if not args.quiet:
351
+ print(f" · run_id: {run_id}", file=sys.stderr)
352
+
353
+ # Stream progress + wait for result
354
+ if not args.quiet:
355
+ print(" ░░░░░░░░░░░░░░░░░░░░ researching...", file=sys.stderr)
356
+
357
+ output = _stream_sse(api_key, run_id, args.quiet)
358
+ if output is None:
359
+ if not args.quiet:
360
+ print(" [fallback] polling...", file=sys.stderr)
361
+ output = _poll_until_complete(api_key, run_id, args.quiet)
362
+
363
+ # Output
364
+ out_path = pathlib.Path(args.output) if args.output else None
365
+
366
+ if args.json:
367
+ result = {
368
+ "success": True, "run_id": run_id, "processor": processor,
369
+ "output": output,
370
+ }
371
+ if out_path:
372
+ result["path"] = str(out_path.resolve())
373
+ text = json.dumps(result, indent=2)
374
+ if out_path:
375
+ out_path.parent.mkdir(parents=True, exist_ok=True)
376
+ out_path.write_text(text, encoding="utf-8")
377
+ print(text)
378
+ else:
379
+ md = _format_markdown(query, processor, run_id, output, created_at,
380
+ include_basis=not getattr(args, "no_basis", False))
381
+ if out_path:
382
+ out_path.parent.mkdir(parents=True, exist_ok=True)
383
+ out_path.write_text(md, encoding="utf-8")
384
+ if not args.quiet:
385
+ print(f"\n ✓ {out_path}", file=sys.stderr)
386
+ if args.quiet:
387
+ print(str(out_path.resolve()))
388
+ else:
389
+ print(md)
390
+
391
+ return 0
392
+
393
+
394
+ def cmd_status(args):
395
+ """Check task run status."""
396
+ api_key = _get_api_key()
397
+ result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
398
+
399
+ if args.json:
400
+ print(json.dumps({"success": True, **result}))
401
+ else:
402
+ status = result.get("status", "unknown")
403
+ print(f" {args.run_id}: {status}")
404
+ if result.get("error"):
405
+ print(f" error: {result['error']}")
406
+ if result.get("warnings"):
407
+ for w in result["warnings"]:
408
+ print(f" warning: {w.get('message', '')}")
409
+ return 0
410
+
411
+
412
+ def cmd_processors(args):
413
+ """List available processor tiers."""
414
+ STANDARD = [
415
+ ("lite", "10s – 60s", "Basic metadata, low latency", "~2"),
416
+ ("base", "15s – 100s", "Reliable standard enrichments", "~5"),
417
+ ("core", "60s – 5min", "Cross-referenced, moderate complexity", "~10"),
418
+ ("core2x", "60s – 10min", "High complexity cross-referenced", "~10"),
419
+ ("pro", "2min – 10min", "Exploratory web research", "~20"),
420
+ ("ultra", "5min – 25min", "Advanced multi-source deep research", "~20"),
421
+ ("ultra2x", "5min – 50min", "Difficult deep research", "~25"),
422
+ ("ultra4x", "5min – 90min", "Very difficult deep research", "~25"),
423
+ ("ultra8x", "5min – 2hr", "Most difficult deep research", "~25"),
424
+ ]
425
+ FAST = [
426
+ ("lite-fast", "10s – 20s", "Lowest latency", "~2"),
427
+ ("base-fast", "15s – 50s", "Fast standard enrichments", "~5"),
428
+ ("core-fast", "15s – 100s", "Fast cross-referenced", "~10"),
429
+ ("core2x-fast", "15s – 3min", "Fast high complexity", "~10"),
430
+ ("pro-fast", "30s – 5min", "Fast exploratory research", "~20"),
431
+ ("ultra-fast", "1min – 10min", "Fast deep research", "~20"),
432
+ ("ultra2x-fast", "1min – 20min", "Fast difficult research", "~25"),
433
+ ("ultra4x-fast", "1min – 40min", "Fast very difficult research", "~25"),
434
+ ("ultra8x-fast", "1min – 1hr", "Fast most difficult research", "~25"),
435
+ ]
436
+
437
+ if args.json:
438
+ all_procs = []
439
+ for name, latency, desc, fields in STANDARD:
440
+ all_procs.append({"name": name, "variant": "standard", "latency": latency, "description": desc, "max_fields": fields})
441
+ for name, latency, desc, fields in FAST:
442
+ all_procs.append({"name": name, "variant": "fast", "latency": latency, "description": desc, "max_fields": fields})
443
+ print(json.dumps({"success": True, "processors": all_procs}))
444
+ return 0
445
+
446
+ print("\n Standard processors (prioritize data freshness):\n")
447
+ print(f" {'PROCESSOR':<12} {'LATENCY':<16} {'MAX FIELDS':<12} {'STRENGTHS'}")
448
+ print(f" {'─' * 75}")
449
+ for name, latency, desc, fields in STANDARD:
450
+ print(f" {name:<12} {latency:<16} {fields:<12} {desc}")
451
+
452
+ print("\n Fast processors (prioritize speed, 2-5x faster):\n")
453
+ print(f" {'PROCESSOR':<16} {'LATENCY':<16} {'MAX FIELDS':<12} {'STRENGTHS'}")
454
+ print(f" {'─' * 75}")
455
+ for name, latency, desc, fields in FAST:
456
+ print(f" {name:<16} {latency:<16} {fields:<12} {desc}")
457
+
458
+ print(f"""
459
+ Notes:
460
+ · Standard processors prioritize freshness — best for accuracy-critical tasks
461
+ · Fast processors are 2-5x faster — best for interactive/agent workflows
462
+ · 'auto' output schema enables Deep Research for pro and above
463
+ · Max fields are approximate — complex fields use more capacity
464
+ · Pricing: docs.parallel.ai/getting-started/pricing
465
+ """)
466
+ return 0
467
+
468
+
469
+ def cmd_result(args):
470
+ """Fetch completed task result."""
471
+ api_key = _get_api_key()
472
+
473
+ # Check status first
474
+ run = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
475
+ if run.get("status") != "completed":
476
+ if args.json:
477
+ print(json.dumps({"success": False, "status": run.get("status"), "error": run.get("error")}))
478
+ else:
479
+ print(f" ✗ task not complete: {run.get('status')}", file=sys.stderr)
480
+ return 1
481
+
482
+ result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}/result", api_key)
483
+ output = result.get("output")
484
+ out_path = pathlib.Path(args.output) if args.output else None
485
+
486
+ if args.json:
487
+ text = json.dumps({"success": True, "run_id": args.run_id, "output": output}, indent=2)
488
+ if out_path:
489
+ out_path.parent.mkdir(parents=True, exist_ok=True)
490
+ out_path.write_text(text, encoding="utf-8")
491
+ print(text)
492
+ else:
493
+ md = _format_markdown("(retrieved)", run.get("processor", ""), args.run_id,
494
+ output, run.get("created_at", ""),
495
+ include_basis=not getattr(args, "no_basis", False))
496
+ if out_path:
497
+ out_path.parent.mkdir(parents=True, exist_ok=True)
498
+ out_path.write_text(md, encoding="utf-8")
499
+ if not args.quiet:
500
+ print(f" ✓ {out_path}", file=sys.stderr)
501
+ else:
502
+ print(md)
503
+
504
+ return 0
505
+
506
+
507
+ # ── Parser ───────────────────────────────────────────────────────
508
+
509
+ def register(subparsers):
510
+ """Register the research subcommand."""
511
+ F = argparse.RawDescriptionHelpFormatter
512
+
513
+ p = subparsers.add_parser(
514
+ "research", aliases=["r"], help="Web research tasks (Parallel Task API)",
515
+ formatter_class=F,
516
+ epilog="""modes:
517
+ escli research "query" -o report.md Run a research task (default)
518
+ escli research --processors List available processor tiers
519
+ escli research --status <run-id> Check task status
520
+ escli research --result <run-id> -o out.md Fetch completed result
521
+
522
+ examples:
523
+ escli research "HVAC industry market report" -o hvac.md
524
+ escli research "Stripe" --output-schema "founding year and total funding" -p base
525
+ escli research "AI startups 2026" -o ai.md -p ultra --after-date 2026-01-01
526
+ escli research "competitive analysis of CRM" -o crm.md --text --include-domains g2.com,gartner.com
527
+ escli research "Stripe" --schema enrichment.json -o stripe.json -p core
528
+ escli research "follow-up on API?" -o followup.md --follow-up trun_xxx
529
+
530
+ processors (ascending quality):
531
+ lite → base → core → core2x → pro → ultra → ultra2x → ultra4x → ultra8x
532
+ Append -fast for speed (e.g. pro-fast, ultra-fast)
533
+
534
+ output modes:
535
+ (default) Auto — processor determines structure (deep research for pro+)
536
+ --text Markdown report with inline citations
537
+ --schema FILE Structured JSON output per your JSON Schema file
538
+ --output-schema S Inline schema description string
539
+ """)
540
+
541
+ # Query (positional, optional — not needed for --status/--result/--processors)
542
+ p.add_argument("query", nargs="*", help="Research question or topic")
543
+
544
+ # Mode flags (mutually exclusive with running a query)
545
+ mode_g = p.add_argument_group("modes")
546
+ mode_g.add_argument("--processors", action="store_true", help="List available processor tiers")
547
+ mode_g.add_argument("--status", default=None, metavar="RUN_ID", help="Check task run status")
548
+ mode_g.add_argument("--result", default=None, metavar="RUN_ID", help="Fetch completed task result")
549
+
550
+ # Run options
551
+ p.add_argument("-o", "--output", default=None, help="Output file path (markdown or JSON)")
552
+ p.add_argument("-p", "--processor", default="pro", choices=PROCESSORS, help="Processor tier (default: pro)")
553
+
554
+ # Output schema
555
+ schema_g = p.add_argument_group("output schema")
556
+ schema_g.add_argument("--text", action="store_true", help="Markdown report format")
557
+ schema_g.add_argument("--schema", default=None, metavar="FILE", help="JSON Schema file for structured output")
558
+ schema_g.add_argument("--output-schema", default=None, metavar="STR", help="Inline output schema description")
559
+
560
+ # Input
561
+ input_g = p.add_argument_group("input")
562
+ input_g.add_argument("--input-json", default=None, metavar="JSON", help="JSON object as input (instead of text)")
563
+ input_g.add_argument("--input-file", default=None, metavar="FILE", help="JSON file as input")
564
+
565
+ # Source policy
566
+ source_g = p.add_argument_group("source policy")
567
+ source_g.add_argument("--include-domains", default=None, metavar="D1,D2", help="Only use these domains")
568
+ source_g.add_argument("--exclude-domains", default=None, metavar="D1,D2", help="Exclude these domains")
569
+ source_g.add_argument("--after-date", default=None, metavar="YYYY-MM-DD", help="Only content after this date")
570
+
571
+ # Advanced
572
+ adv_g = p.add_argument_group("advanced")
573
+ adv_g.add_argument("--location", default=None, metavar="CC", help="ISO country code for geo-targeted results")
574
+ adv_g.add_argument("--metadata", default=None, help="Metadata as JSON or key=val,key=val")
575
+ adv_g.add_argument("--follow-up", default=None, metavar="RUN_ID", help="Follow-up on a previous task run")
576
+ adv_g.add_argument("--no-basis", action="store_true", help="Exclude citations and reasoning")
577
+
578
+ p.set_defaults(func=_dispatch)
579
+
580
+ return p
581
+
582
+
583
+ def _dispatch(args):
584
+ """Route to the right handler based on flags."""
585
+ if getattr(args, "processors", False):
586
+ return cmd_processors(args)
587
+ if getattr(args, "status", None):
588
+ args.run_id = args.status
589
+ return cmd_status(args)
590
+ if getattr(args, "result", None):
591
+ args.run_id = args.result
592
+ return cmd_result(args)
593
+ if not args.query:
594
+ print(" usage: escli research \"query\" -o output.md", file=sys.stderr)
595
+ print(" run 'escli research --processors' to see available tiers", file=sys.stderr)
596
+ return 2
597
+ return cmd_run(args)