cu-cli 0.1.0b1__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.
Files changed (56) hide show
  1. cu_cli/__init__.py +17 -0
  2. cu_cli/__main__.py +11 -0
  3. cu_cli/apiversion.py +124 -0
  4. cu_cli/cli.py +138 -0
  5. cu_cli/client.py +138 -0
  6. cu_cli/commands/__init__.py +4 -0
  7. cu_cli/commands/_command_spec.py +94 -0
  8. cu_cli/commands/_help.py +33 -0
  9. cu_cli/commands/_infra_models.py +184 -0
  10. cu_cli/commands/_infra_wizard.py +630 -0
  11. cu_cli/commands/_model_setup.py +46 -0
  12. cu_cli/commands/_options.py +112 -0
  13. cu_cli/commands/analyze.py +631 -0
  14. cu_cli/commands/analyzer.py +1462 -0
  15. cu_cli/commands/defaults.py +172 -0
  16. cu_cli/commands/doctor.py +166 -0
  17. cu_cli/commands/env_var.py +67 -0
  18. cu_cli/commands/infra.py +302 -0
  19. cu_cli/commands/profile_cmd.py +525 -0
  20. cu_cli/commands/upgrade.py +120 -0
  21. cu_cli/core/__init__.py +17 -0
  22. cu_cli/core/analyze.py +44 -0
  23. cu_cli/core/analyzers.py +30 -0
  24. cu_cli/core/azure_resources.py +486 -0
  25. cu_cli/core/defaults.py +18 -0
  26. cu_cli/core/doctor.py +42 -0
  27. cu_cli/core/foundry.py +68 -0
  28. cu_cli/core/infra_models.py +367 -0
  29. cu_cli/core/inputs.py +209 -0
  30. cu_cli/core/schema.py +24 -0
  31. cu_cli/errors.py +174 -0
  32. cu_cli/exit_codes.py +20 -0
  33. cu_cli/modality.py +24 -0
  34. cu_cli/output.py +179 -0
  35. cu_cli/profile.py +30 -0
  36. cu_cli/py.typed +0 -0
  37. cu_cli/resources/__init__.py +4 -0
  38. cu_cli/resources/azd_template/README.md +187 -0
  39. cu_cli/resources/azd_template/azure.yaml +27 -0
  40. cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
  41. cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
  42. cu_cli/resources/azd_template/infra/main.bicep +115 -0
  43. cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
  44. cu_cli/resources/azd_template/infra/models.json +1 -0
  45. cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
  46. cu_cli/schema_validate.py +28 -0
  47. cu_cli/spec_validate.py +18 -0
  48. cu_cli/telemetry.py +42 -0
  49. cu_cli/update_check.py +154 -0
  50. cu_cli/update_provider.py +92 -0
  51. cu_cli/windows_self_upgrade.py +245 -0
  52. cu_cli-0.1.0b1.dist-info/METADATA +345 -0
  53. cu_cli-0.1.0b1.dist-info/RECORD +56 -0
  54. cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
  55. cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
  56. cu_cli-0.1.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,631 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """``cu analyze`` standalone adapter."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ import tempfile
12
+ from typing import TYPE_CHECKING
13
+
14
+ from azure.core.exceptions import HttpResponseError
15
+ from cu_cli_core.command_spec import (
16
+ ANALYZE,
17
+ CommandBindingError,
18
+ build_request,
19
+ resolve_identifier,
20
+ )
21
+ import rich_click as click
22
+ from rich.markup import escape as _esc
23
+
24
+ from ..apiversion import API_VERSION_HELP, ensure_supported, supports_api_feature
25
+ from ..client import build_client
26
+ from ..profile import Profile
27
+ from cu_cli_core.analysis import (
28
+ AnalyzeJob,
29
+ AnalyzeOutcome,
30
+ AnalyzeResponse,
31
+ analyze_one,
32
+ analyze_one_inline,
33
+ analyze_one_inline_with_usage,
34
+ analyze_one_with_usage,
35
+ )
36
+ from ..errors import CuCliError, _format_service_error, friendly_errors
37
+ from ..exit_codes import GENERIC_ERROR, VALIDATION_FAILURE
38
+ from ..output import (
39
+ EmptyMarkdownOutputError,
40
+ console,
41
+ dump_json,
42
+ dump_markdown,
43
+ dumps_json,
44
+ render_markdown,
45
+ to_jsonable,
46
+ )
47
+ from ._options import CALLING_TIME_OPTION, calling_time
48
+ from ._command_spec import with_command_arguments
49
+
50
+ if TYPE_CHECKING:
51
+ from cu_cli_core.contracts import ExecutionPlan, ExistingResultPolicy, InputPlan
52
+
53
+
54
+ def _run_one(client, job: AnalyzeJob):
55
+ """Thin, patchable seam around :func:`cu_cli.core.analyze.analyze_one`.
56
+
57
+ Kept as a module-level indirection so tests can inject a fake analyzer and
58
+ so callers that want the originating job alongside the result get the
59
+ familiar ``(job, result)`` tuple. All real work lives in ``core``.
60
+ """
61
+ return job, analyze_one(client, job)
62
+
63
+
64
+ def _run_one_inline(client, job: AnalyzeJob):
65
+ """Run one job through the synchronous inline analyze API."""
66
+ return job, analyze_one_inline(client, job)
67
+
68
+
69
+ def _run_one_with_usage(client, job: AnalyzeJob):
70
+ """Run one long-running analysis while retaining usage metadata."""
71
+ return job, analyze_one_with_usage(client, job)
72
+
73
+
74
+ def _run_one_inline_with_usage(client, job: AnalyzeJob):
75
+ """Run one inline analysis while retaining usage metadata."""
76
+ return job, analyze_one_inline_with_usage(client, job)
77
+
78
+
79
+ def _print_usage(usage, *, input_ref: str) -> None:
80
+ """Render request usage to stderr without changing data written to stdout."""
81
+ console.print("\n")
82
+ console.print(f"[bold cyan]Usage:[/bold cyan] {_esc(input_ref)}")
83
+ if usage is None:
84
+ console.print("[dim]usage details were not returned by the service.[/dim]")
85
+ return
86
+ console.print_json(data=to_jsonable(usage))
87
+
88
+
89
+ _ON_EXISTS_ENV = "CU_ON_EXISTS"
90
+ _ON_EXISTS_CHOICES = ("error", "skip", "reanalyze")
91
+
92
+
93
+ def _friendly_analyze_error(exc: BaseException) -> str:
94
+ """Return a user-facing error string for per-input analyze failures."""
95
+ msg = str(exc)
96
+ if isinstance(exc, EmptyMarkdownOutputError) or (
97
+ "to_llm_input() returned empty markdown output" in msg
98
+ ):
99
+ return (
100
+ "Analysis succeeded, but the Markdown view was empty. "
101
+ "Retry with --json to inspect the complete result."
102
+ )
103
+ if isinstance(exc, HttpResponseError):
104
+ return _format_service_error(exc)
105
+ return msg
106
+
107
+
108
+ def _resolve_on_exists(explicit: str | None) -> ExistingResultPolicy:
109
+ """Resolve the explicit or environment-selected existing-result policy."""
110
+ from cu_cli_core.contracts import ExistingResultPolicy
111
+
112
+ raw = explicit or os.environ.get(_ON_EXISTS_ENV, "").strip().lower() or "error"
113
+ if not raw:
114
+ raw = "error"
115
+ if raw not in _ON_EXISTS_CHOICES:
116
+ raise CuCliError(
117
+ f"{_ON_EXISTS_ENV} must be one of {'|'.join(_ON_EXISTS_CHOICES)} "
118
+ f"(got {os.environ.get(_ON_EXISTS_ENV)!r}).",
119
+ exit_code=VALIDATION_FAILURE,
120
+ )
121
+ return ExistingResultPolicy(raw)
122
+
123
+
124
+ def _validate_report_path(report_path: Path | None, jobs: list[AnalyzeJob]) -> None:
125
+ """Reject a report path that would overwrite a finalized analysis result."""
126
+ if report_path is None:
127
+ return
128
+ resolved_report_path = report_path.resolve(strict=False)
129
+ for job in jobs:
130
+ if (
131
+ job.out_path is not None
132
+ and job.out_path.resolve(strict=False) == resolved_report_path
133
+ ):
134
+ raise CuCliError(
135
+ f"--report-file conflicts with an analysis result file: {report_path}",
136
+ hint=(
137
+ "choose a different --report-file path; planned result path: "
138
+ f"{job.out_path}."
139
+ ),
140
+ exit_code=VALIDATION_FAILURE,
141
+ )
142
+
143
+
144
+ def _preflight_output_writes(
145
+ jobs: list[AnalyzeJob],
146
+ *,
147
+ report_path: Path | None,
148
+ ) -> None:
149
+ output_paths = [job.out_path for job in jobs if job.out_path is not None]
150
+ if report_path is not None:
151
+ output_paths.append(report_path)
152
+
153
+ directories = {path.parent.resolve(strict=False) for path in output_paths}
154
+ for directory in sorted(directories, key=str):
155
+ directory.mkdir(parents=True, exist_ok=True)
156
+ with tempfile.NamedTemporaryFile(
157
+ dir=directory,
158
+ prefix=".cu-write-check-",
159
+ ) as handle:
160
+ handle.write(b"\0")
161
+ handle.flush()
162
+
163
+
164
+ def _write_analyze_report(path: Path, *, analyzer_id, fmt: str, results: list[dict]) -> None:
165
+ """Write a machine-readable per-input status report (regression).
166
+
167
+ ``results`` is a flat list of ``{"input", "status", ...}`` records where
168
+ ``status`` is ``succeeded`` / ``failed`` / ``skipped``. The stable ``schema`` key
169
+ lets agents parse the summary without scraping human-formatted stderr.
170
+ """
171
+ counts = {"succeeded": 0, "failed": 0, "skipped": 0}
172
+ for r in results:
173
+ status = r.get("status")
174
+ if status in counts:
175
+ counts[status] += 1
176
+ counts["total"] = len(results)
177
+ payload = dumps_json(
178
+ {
179
+ "schema": "cu-cli/analyze-report/v1",
180
+ "analyzer": analyzer_id,
181
+ "result_view": "full" if fmt == "json" else "llm-input",
182
+ "counts": counts,
183
+ "results": results,
184
+ }
185
+ )
186
+ path.parent.mkdir(parents=True, exist_ok=True)
187
+ temporary: Path | None = None
188
+ try:
189
+ with tempfile.NamedTemporaryFile(
190
+ mode="w",
191
+ encoding="utf-8",
192
+ dir=path.parent,
193
+ prefix=f".{path.name}.",
194
+ suffix=".tmp",
195
+ delete=False,
196
+ ) as handle:
197
+ handle.write(payload)
198
+ temporary = Path(handle.name)
199
+ os.replace(temporary, path)
200
+ except OSError:
201
+ if temporary is not None:
202
+ temporary.unlink(missing_ok=True)
203
+ raise
204
+
205
+
206
+ def _print_extension_counts(input_plan: InputPlan) -> None:
207
+ for extension, count in input_plan.extension_counts.items():
208
+ console.print(f" {extension:<10} {count}")
209
+
210
+
211
+ def _print_discovery_skips(input_plan: InputPlan) -> None:
212
+ if not input_plan.skipped:
213
+ return
214
+ console.print(f"Skipped during discovery: {len(input_plan.skipped)}")
215
+ for item in input_plan.skipped:
216
+ console.print(f" [dim]- {_esc(str(item.path))}: {_esc(item.reason)}[/dim]")
217
+
218
+
219
+ def _print_discovery(input_plan: InputPlan, *, analyzer_id: str) -> None:
220
+ console.print(f"[yellow]Found {len(input_plan.inputs)} files:[/yellow]")
221
+ _print_extension_counts(input_plan)
222
+ _print_discovery_skips(input_plan)
223
+ console.print(f"\nAnalyzer: {analyzer_id}")
224
+ console.print(f"Recursive: {'yes' if input_plan.recursive else 'no'}")
225
+
226
+
227
+ def _print_dry_run(plan: ExecutionPlan, *, analyzer_id: str) -> None:
228
+ input_plan = plan.input_plan
229
+ console.print("[bold cyan]Dry run[/bold cyan]")
230
+ console.print(
231
+ f"Selected: {len(input_plan.inputs)} file(s), {input_plan.total_bytes} byte(s)"
232
+ )
233
+ _print_extension_counts(input_plan)
234
+ _print_discovery_skips(input_plan)
235
+ console.print(f"Analyzer: {analyzer_id}")
236
+ console.print(f"Recursive: {'yes' if input_plan.recursive else 'no'}")
237
+ console.print(f"On existing: {plan.on_existing.value}")
238
+ for output in plan.outputs:
239
+ if output.path is None:
240
+ destination = "stdout"
241
+ else:
242
+ destination = str(output.path)
243
+ if output.exists:
244
+ action = (
245
+ "skip"
246
+ if plan.on_existing.value == "skip"
247
+ else plan.on_existing.value
248
+ )
249
+ console.print(
250
+ f" {_esc(str(output.source.path))} -> {_esc(destination)} "
251
+ f"[dim](exists: {action})[/dim]"
252
+ )
253
+ else:
254
+ console.print(f" {_esc(str(output.source.path))} -> {_esc(destination)}")
255
+ console.print(
256
+ "[dim]No service calls or files were written. Analyzer existence, "
257
+ "service-side format acceptance, usage, and cost were not validated.[/dim]"
258
+ )
259
+
260
+
261
+ @click.command("analyze",
262
+ help=ANALYZE.help,
263
+ epilog="When a result file already exists, analyze stops unless "
264
+ "--on-existing skip or --on-existing reanalyze is selected. "
265
+ "Set CU_ON_EXISTS=error|skip|reanalyze to change the default.\n\n"
266
+ "[white] [/white]\n\n"
267
+ "[bold cyan]Common commands:[/bold cyan]\n\n"
268
+ "[bold green]cu analyze[/bold green] [bold yellow]FILE[/bold yellow]\n\n"
269
+ "[white]\u00a0\u00a0Analyze one file with the configured default "
270
+ "analyzer.[/white]\n\n"
271
+ "[bold green]cu analyze[/bold green] [bold yellow]FILE[/bold yellow] "
272
+ "[bold cyan]-a[/bold cyan] [bold magenta]prebuilt-invoice[/bold magenta] "
273
+ "[bold cyan]--json[/bold cyan]\n\n"
274
+ "[white]\u00a0\u00a0Extract invoice fields as JSON.[/white]\n\n"
275
+ "[bold green]cu analyze[/bold green] "
276
+ "[bold cyan]--source[/bold cyan] [bold yellow]DIRECTORY[/bold yellow] "
277
+ "[bold cyan]--output-dir[/bold cyan] [bold yellow]TARGET_DIR[/bold yellow]\n\n"
278
+ "[white]\u00a0\u00a0Analyze immediate files in DIRECTORY and write all "
279
+ "result files to TARGET_DIR instead of beside each input.[/white]")
280
+ @with_command_arguments(ANALYZE)
281
+ @CALLING_TIME_OPTION
282
+ @click.option("-p", "--profile", "profile_name", default=None,
283
+ help="Named CU CLI profile to use (from cu profile).")
284
+ @click.option("--endpoint", default=None, help="Override configured endpoint.")
285
+ @click.option("--auth-mode", type=click.Choice(["login", "key"]), default=None,
286
+ help="Authentication mode; defaults to the selected CU CLI profile.")
287
+ @click.option("--api-key", default=None, help="Override configured API key.")
288
+ @click.option("--api-version", "api_version", default=None,
289
+ help=API_VERSION_HELP)
290
+ @friendly_errors
291
+ def cmd_analyze(
292
+ inputs,
293
+ files,
294
+ sources,
295
+ pattern,
296
+ recursive,
297
+ analyzer_id,
298
+ out_dir,
299
+ output_file,
300
+ llm_input,
301
+ json_output,
302
+ report_path,
303
+ concurrency,
304
+ on_existing,
305
+ dry_run,
306
+ assume_yes,
307
+ endpoint,
308
+ api_key,
309
+ api_version,
310
+ auth_mode,
311
+ profile_name,
312
+ inline,
313
+ show_usage,
314
+ show_calling_time,
315
+ ) -> None:
316
+ from cu_cli_core.contracts import ExistingResultPolicy, InputOrigin, ResultView
317
+ from cu_cli_core.input_planning import plan_inputs, plan_outputs
318
+
319
+ try:
320
+ request = build_request(
321
+ ANALYZE,
322
+ {
323
+ "inputs": inputs,
324
+ "files": files,
325
+ "sources": sources,
326
+ "pattern": pattern,
327
+ "recursive": recursive,
328
+ "analyzer_id": analyzer_id,
329
+ "inline": inline,
330
+ "show_usage": show_usage,
331
+ "llm_input": llm_input,
332
+ "json_output": json_output,
333
+ "output_file": output_file,
334
+ "out_dir": out_dir,
335
+ "on_existing": on_existing,
336
+ "dry_run": dry_run,
337
+ "assume_yes": assume_yes,
338
+ "report_path": report_path,
339
+ "concurrency": concurrency,
340
+ },
341
+ )
342
+ except CommandBindingError as exc:
343
+ raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
344
+
345
+ # Fail fast, before any input discovery, CU service call, or result-file
346
+ # write: an existing report is never overwritten.
347
+ if dry_run and assume_yes:
348
+ raise CuCliError(
349
+ "--dry-run and --yes cannot be combined.",
350
+ exit_code=VALIDATION_FAILURE,
351
+ )
352
+ if llm_input and json_output:
353
+ raise CuCliError(
354
+ "--llm-input and --json cannot be combined.",
355
+ exit_code=VALIDATION_FAILURE,
356
+ )
357
+ if not dry_run and report_path is not None and report_path.exists():
358
+ raise CuCliError(
359
+ f"--report-file already exists: {report_path}",
360
+ hint="choose a new --report-file path; existing reports are never overwritten.",
361
+ exit_code=VALIDATION_FAILURE,
362
+ )
363
+ input_plan = plan_inputs(
364
+ positional=request.positional_inputs,
365
+ files=request.files,
366
+ sources=request.sources,
367
+ pattern=request.pattern,
368
+ recursive=request.recursive,
369
+ )
370
+ policy = _resolve_on_exists(request.on_existing)
371
+ view = ResultView.FULL if json_output else ResultView.LLM_INPUT
372
+ fmt = "json" if view is ResultView.FULL else "markdown"
373
+ execution_plan = plan_outputs(
374
+ input_plan,
375
+ view=view,
376
+ output_file=request.output_file,
377
+ output_dir=request.output_dir,
378
+ on_existing=policy,
379
+ dry_run=dry_run,
380
+ )
381
+ profile = Profile.load(profile_name=profile_name)
382
+ effective_analyzer = request.analyzer or profile.default_analyzer
383
+ if not effective_analyzer:
384
+ raise CuCliError(
385
+ "no analyzer was specified and no default_analyzer is configured.",
386
+ hint="pass --analyzer ANALYZER_ID or run "
387
+ "`cu profile set default_analyzer ANALYZER_ID`.",
388
+ exit_code=VALIDATION_FAILURE,
389
+ )
390
+ skipped_report = [
391
+ {
392
+ "input": str(item.path),
393
+ "status": "skipped",
394
+ "reason": item.reason,
395
+ "output": None,
396
+ }
397
+ for item in input_plan.skipped
398
+ ]
399
+
400
+ jobs = [
401
+ AnalyzeJob(
402
+ input_ref=str(output.source.path),
403
+ analyzer_id=effective_analyzer,
404
+ out_path=output.path,
405
+ output_format=fmt,
406
+ )
407
+ for output in execution_plan.outputs
408
+ ]
409
+ to_stdout = len(jobs) == 1 and jobs[0].out_path is None
410
+
411
+ effective_api_version = ensure_supported(api_version or profile.api_version)
412
+ if inline:
413
+ if not supports_api_feature(effective_api_version, "inline-analysis"):
414
+ raise CuCliError(
415
+ "--inline requires API version 2026-06-01-preview.",
416
+ hint="pass `--api-version 2026-06-01-preview` or save it with "
417
+ "`cu profile set api_version 2026-06-01-preview`.",
418
+ )
419
+
420
+ skipped: list[Path] = []
421
+ if not to_stdout:
422
+ if not dry_run:
423
+ _validate_report_path(report_path, jobs)
424
+ existing = [j for j in jobs if j.out_path is not None and j.out_path.exists()]
425
+ if dry_run:
426
+ _print_dry_run(execution_plan, analyzer_id=effective_analyzer)
427
+ return
428
+ if existing:
429
+ where = f"under {out_dir}" if out_dir is not None else "next to the inputs"
430
+ if policy is ExistingResultPolicy.ERROR:
431
+ raise CuCliError(
432
+ f"{len(existing)} of {len(jobs)} result file(s) already exist {where}.",
433
+ hint="choose --on-existing skip to keep them or "
434
+ "--on-existing reanalyze to replace them (re-bills).",
435
+ exit_code=VALIDATION_FAILURE,
436
+ )
437
+ if policy is ExistingResultPolicy.SKIP:
438
+ skipped = [j.out_path for j in existing if j.out_path is not None]
439
+ for j in existing:
440
+ skipped_report.append({
441
+ "input": j.input_ref,
442
+ "status": "skipped",
443
+ "reason": "result file already exists",
444
+ "output": str(j.out_path) if j.out_path is not None else None,
445
+ })
446
+ existing_ids = {id(j) for j in existing}
447
+ jobs = [j for j in jobs if id(j) not in existing_ids]
448
+ elif policy is ExistingResultPolicy.REANALYZE:
449
+ console.print(
450
+ "[yellow]Warning:[/yellow] Reanalysis sends the input to "
451
+ "Content Understanding again and may incur additional charges. "
452
+ "Existing result files will be replaced."
453
+ )
454
+ if not jobs:
455
+ message = (
456
+ f"[green]nothing to do:[/green] {len(skipped)} result file(s) already exist "
457
+ "(skipped)."
458
+ )
459
+ if input_plan.skipped:
460
+ message += f" {len(input_plan.skipped)} source entry(s) skipped during discovery."
461
+ console.print(message)
462
+ if report_path is not None:
463
+ _write_analyze_report(report_path, analyzer_id=effective_analyzer, fmt=fmt,
464
+ results=skipped_report)
465
+ console.print(f"[dim]report:[/dim] wrote {report_path}")
466
+ return
467
+ discovered = any(
468
+ item.origin in {InputOrigin.POSITIONAL_SOURCE, InputOrigin.NAMED_SOURCE}
469
+ for item in input_plan.inputs
470
+ )
471
+ if not assume_yes and discovered and len(jobs) > 1 and sys.stdin.isatty():
472
+ _print_discovery(input_plan, analyzer_id=effective_analyzer)
473
+ if not click.confirm("proceed?", default=False):
474
+ raise CuCliError("aborted by user.", hint="narrow the inputs or pass --yes.")
475
+ elif dry_run:
476
+ _print_dry_run(execution_plan, analyzer_id=effective_analyzer)
477
+ return
478
+
479
+ _preflight_output_writes(jobs, report_path=report_path)
480
+ client = build_client(profile, endpoint_override=endpoint, api_key_override=api_key,
481
+ api_version_override=api_version,
482
+ auth_mode_override=auth_mode)
483
+ if show_usage:
484
+ run_one = _run_one_inline_with_usage if inline else _run_one_with_usage
485
+ else:
486
+ run_one = _run_one_inline if inline else _run_one
487
+
488
+ if to_stdout:
489
+ job = jobs[0]
490
+ with calling_time(show_calling_time) as calling_timer:
491
+ batch_result = resolve_identifier(ANALYZE.operation)(
492
+ client,
493
+ request,
494
+ input_plan=input_plan,
495
+ jobs=[job],
496
+ run=lambda c, j: run_one(c, j)[1],
497
+ )
498
+ if batch_result.failures:
499
+ failure = batch_result.failures[0].error
500
+ assert failure is not None
501
+ # Match the batch path: a failed single-input run still emits the
502
+ # --report file (one "failed" entry) before the friendly error exits
503
+ # 1, so a scripted single-file caller never gets a missing report
504
+ # (regression). Re-raise the captured core outcome so @friendly_errors
505
+ # renders the same message and exit code as before.
506
+ if report_path is not None:
507
+ _write_analyze_report(
508
+ report_path, analyzer_id=effective_analyzer, fmt=fmt,
509
+ results=[{"input": job.input_ref, "status": "failed",
510
+ "analyzer": job.analyzer_id,
511
+ "error": _friendly_analyze_error(failure)}] + skipped_report,
512
+ )
513
+ console.print(f"[dim]report:[/dim] wrote {report_path}")
514
+ raise failure
515
+ response = batch_result.successes[0].result
516
+ if show_usage:
517
+ assert isinstance(response, AnalyzeResponse)
518
+ result = response.result
519
+ else:
520
+ result = response
521
+ if fmt == "json":
522
+ dump_json(result)
523
+ else:
524
+ try:
525
+ dump_markdown(result)
526
+ except EmptyMarkdownOutputError as exc:
527
+ error = _friendly_analyze_error(exc)
528
+ if report_path is not None:
529
+ _write_analyze_report(
530
+ report_path,
531
+ analyzer_id=effective_analyzer,
532
+ fmt=fmt,
533
+ results=[
534
+ {
535
+ "input": job.input_ref,
536
+ "status": "failed",
537
+ "analyzer": job.analyzer_id,
538
+ "error": error,
539
+ }
540
+ ] + skipped_report,
541
+ )
542
+ console.print(f"[dim]report:[/dim] wrote {report_path}")
543
+ raise CuCliError(error) from exc
544
+ if report_path is not None:
545
+ _write_analyze_report(
546
+ report_path, analyzer_id=effective_analyzer, fmt=fmt,
547
+ results=[{"input": job.input_ref, "status": "succeeded",
548
+ "analyzer": job.analyzer_id, "output": None}] + skipped_report,
549
+ )
550
+ console.print(f"[dim]report:[/dim] wrote {report_path}")
551
+ if show_usage:
552
+ assert isinstance(response, AnalyzeResponse)
553
+ _print_usage(response.usage, input_ref=job.input_ref)
554
+ calling_timer.print()
555
+ return
556
+
557
+ failures: list[tuple[str, str]] = []
558
+ written: list[Path] = []
559
+ results_report: list[dict] = []
560
+ usage_results: list[tuple[str, object]] = []
561
+ console.print(f"[bold]analyze[/bold] {len(jobs)} file(s) -> "
562
+ f"{out_dir or 'alongside inputs'}")
563
+
564
+ def _persist(outcome: AnalyzeOutcome) -> None:
565
+ job = outcome.job
566
+ if not outcome.ok:
567
+ assert outcome.error is not None
568
+ err = _friendly_analyze_error(outcome.error)
569
+ failures.append((job.input_ref, err))
570
+ results_report.append({"input": job.input_ref, "status": "failed",
571
+ "analyzer": job.analyzer_id, "error": err})
572
+ return
573
+ try:
574
+ assert job.out_path is not None
575
+ if show_usage:
576
+ assert isinstance(outcome.result, AnalyzeResponse)
577
+ result = outcome.result.result
578
+ usage_results.append((job.input_ref, outcome.result.usage))
579
+ else:
580
+ result = outcome.result
581
+ if fmt == "json":
582
+ dump_json(result, out=job.out_path)
583
+ else:
584
+ job.out_path.parent.mkdir(parents=True, exist_ok=True)
585
+ job.out_path.write_text(render_markdown(result), encoding="utf-8")
586
+ written.append(job.out_path)
587
+ results_report.append({"input": job.input_ref, "status": "succeeded",
588
+ "analyzer": job.analyzer_id, "output": str(job.out_path)})
589
+ except Exception as exc: # noqa: BLE001 — per-file isolation on write
590
+ err = _friendly_analyze_error(exc)
591
+ failures.append((job.input_ref, err))
592
+ results_report.append({"input": job.input_ref, "status": "failed",
593
+ "analyzer": job.analyzer_id, "error": err})
594
+
595
+ with calling_time(show_calling_time) as calling_timer:
596
+ resolve_identifier(ANALYZE.operation)(
597
+ client,
598
+ request,
599
+ input_plan=input_plan,
600
+ jobs=jobs,
601
+ on_result=_persist,
602
+ run=lambda c, j: run_one(c, j)[1],
603
+ )
604
+
605
+ # Write the report before the failure exit so agents always get it (regression).
606
+ if report_path is not None:
607
+ _write_analyze_report(report_path, analyzer_id=effective_analyzer, fmt=fmt,
608
+ results=results_report + skipped_report)
609
+ console.print(f"[dim]report:[/dim] wrote {report_path}")
610
+ summary = [f"[green]{len(written)} ok[/green]", f"[red]{len(failures)} failed[/red]"]
611
+ if skipped:
612
+ summary.append(f"[dim]{len(skipped)} skipped (existing)[/dim]")
613
+ if input_plan.skipped:
614
+ summary.append(f"[dim]{len(input_plan.skipped)} skipped (discovery)[/dim]")
615
+ console.print(", ".join(summary))
616
+ for path in written:
617
+ console.print(f" [green]->[/green] {path}")
618
+ if failures:
619
+ # List every failed input and its full reason — never truncate the file
620
+ # list or the per-file service message, so an agent can act on each one
621
+ # (regression).
622
+ console.print(f"[red]{len(failures)} failed input(s):[/red]")
623
+ for ref, err in failures:
624
+ console.print(f" [red]x[/red] {_esc(ref)}")
625
+ for line in (err.splitlines() or [""]):
626
+ console.print(f" [dim]{_esc(line)}[/dim]")
627
+ for input_ref, usage in usage_results:
628
+ _print_usage(usage, input_ref=input_ref)
629
+ calling_timer.print()
630
+ if failures:
631
+ sys.exit(GENERIC_ERROR)