diff --git a/src/opencode_usage/cli.py b/src/opencode_usage/cli.py index d0192a7..b97d4c4 100644 --- a/src/opencode_usage/cli.py +++ b/src/opencode_usage/cli.py @@ -70,6 +70,12 @@ def _build_parser() -> argparse.ArgumentParser: # ── run ────────────────────────────────────────────────── run_p = sub.add_parser("run", help="Token usage statistics (default)") _add_time_args(run_p) + run_p.add_argument( + "--db", + default=None, + metavar="PATH", + help="Path to opencode.db (default: OPENCODE_DB env or 'opencode db path')", + ) run_p.add_argument( "--by", choices=["model", "agent", "provider", "session", "day"], @@ -98,11 +104,23 @@ def _build_parser() -> argparse.ArgumentParser: # ── insights ──────────────────────────────────────────── ins_p = sub.add_parser("insights", help="LLM-powered usage analysis report") _add_time_args(ins_p) + ins_p.add_argument( + "--db", + default=None, + metavar="PATH", + help="Path to opencode.db (default: OPENCODE_DB env or 'opencode db path')", + ) ins_p.add_argument( "--model", default=None, help="Model ID for insights analysis (interactive picker if omitted)", ) + ins_p.add_argument( + "--no-llm", + action="store_true", + dest="no_llm", + help="Skip LLM analysis; generate a data-only report", + ) ins_p.add_argument( "--force", action="store_true", @@ -187,7 +205,7 @@ def _compute_deltas( def _cmd_run(args: argparse.Namespace) -> None: """Execute the ``run`` subcommand.""" try: - db = OpenCodeDB() + db = OpenCodeDB(args.db) except FileNotFoundError as e: render.console.print(f"[red]Error:[/red] {e}") sys.exit(1) diff --git a/src/opencode_usage/insights/orchestrator.py b/src/opencode_usage/insights/orchestrator.py index 3314e1a..9c9a3b1 100644 --- a/src/opencode_usage/insights/orchestrator.py +++ b/src/opencode_usage/insights/orchestrator.py @@ -73,42 +73,46 @@ def run_insights(args: argparse.Namespace) -> None: at_a_glance: dict[str, object] = {} llm_available = True - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - task = progress.add_task( - f"Analyzing sessions (0/{len(session_ids)})...", total=len(session_ids) - ) - - def on_progress(current: int, total: int) -> None: - progress.update( - task, - completed=current, - description=f"Analyzing sessions ({current}/{total})...", + if getattr(args, "no_llm", False): + llm_available = False + console.print("[yellow]Data-only mode (--no-llm): skipping LLM analysis[/yellow]") + else: + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task( + f"Analyzing sessions (0/{len(session_ids)})...", total=len(session_ids) ) - facets = extract_facets( - db_path, session_ids, config, cache=cache, on_progress=on_progress - ) - progress.update(task, description="Running aggregate analysis...") - aggregate_results = run_aggregate_analysis(facets, stats, config) - progress.update(task, description="Generating summary...") - at_a_glance = generate_at_a_glance(aggregate_results, stats, config) + def on_progress(current: int, total: int) -> None: + progress.update( + task, + completed=current, + description=f"Analyzing sessions ({current}/{total})...", + ) - except FileNotFoundError: - llm_available = False - console.print( - "[yellow]Warning:[/yellow] opencode not found — generating data-only report " - "(install opencode for full analysis)" - ) - except Exception as e: - llm_available = False - warnings.warn(f"LLM analysis failed: {e}", stacklevel=2) - console.print(f"[yellow]Warning:[/yellow] LLM analysis failed: {e}") - console.print("Generating data-only report.") + facets = extract_facets( + db_path, session_ids, config, cache=cache, on_progress=on_progress + ) + progress.update(task, description="Running aggregate analysis...") + aggregate_results = run_aggregate_analysis(facets, stats, config) + progress.update(task, description="Generating summary...") + at_a_glance = generate_at_a_glance(aggregate_results, stats, config) + + except FileNotFoundError: + llm_available = False + console.print( + "[yellow]Warning:[/yellow] opencode not found — generating data-only report " + "(install opencode for full analysis)" + ) + except Exception as e: + llm_available = False + warnings.warn(f"LLM analysis failed: {e}", stacklevel=2) + console.print(f"[yellow]Warning:[/yellow] LLM analysis failed: {e}") + console.print("Generating data-only report.") # Phase 3: Generate report insights_data = { diff --git a/tests/test_cli.py b/tests/test_cli.py index 6715502..b5c9974 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -326,3 +326,26 @@ def test_unknown_group_returns_empty(self, tmp_path): db = OpenCodeDB(db_path=_make_cli_db(tmp_path)) rows = _fetch_rows(db, "unknown") assert rows == [] + + +# ── --db and --no-llm flags ────────────────────────────────── + + +class TestDbFlag: + def test_run_db_flag(self): + args = _build_parser().parse_args(["run", "--db", "/tmp/x.db"]) + assert args.db == "/tmp/x.db" + + def test_insights_db_flag(self): + args = _build_parser().parse_args(["insights", "--db", "/tmp/x.db"]) + assert args.db == "/tmp/x.db" + + +class TestNoLlmFlag: + def test_no_llm_default_false(self): + args = _build_parser().parse_args(["insights"]) + assert args.no_llm is False + + def test_no_llm_set(self): + args = _build_parser().parse_args(["insights", "--no-llm"]) + assert args.no_llm is True diff --git a/tests/test_insights_orchestrator.py b/tests/test_insights_orchestrator.py index 441ede1..9ec4d3a 100644 --- a/tests/test_insights_orchestrator.py +++ b/tests/test_insights_orchestrator.py @@ -413,3 +413,27 @@ def test_output_path_from_config( assert output_path.exists() assert output_path.read_text() == "custom" + + @patch("opencode_usage.insights.orchestrator.generate_report") + @patch("opencode_usage.insights.orchestrator.aggregate_all") + @patch("opencode_usage.insights.orchestrator.filter_sessions") + def test_no_llm_skips_llm_analysis( + self, + mock_filter, + mock_aggregate, + mock_generate_report, + tmp_path, + ): + """--no-llm skips the LLM phases and still generates a data-only report.""" + output_path = tmp_path / "test.html" + mock_filter.return_value = ["s1"] + mock_aggregate.return_value = _mock_stats() + mock_generate_report.return_value = "data-only" + + with patch("opencode_usage.insights.orchestrator.extract_facets") as mock_ef: + args = _make_args(output=str(output_path), no_llm=True) + run_insights(args) + + mock_ef.assert_not_called() + assert output_path.exists() + assert "data-only" in output_path.read_text()