Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/opencode_usage/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 37 additions & 33 deletions src/opencode_usage/insights/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
23 changes: 23 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions tests/test_insights_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,27 @@ def test_output_path_from_config(

assert output_path.exists()
assert output_path.read_text() == "<html>custom</html>"

@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 = "<html>data-only</html>"

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()