diff --git a/README.md b/README.md index d313e27..a3a7d95 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ After installation, `opencode-usage` is available globally. ## Usage -The CLI has two subcommands: `run` (default) and `insights`. +The CLI has these subcommands: `run` (default), `insights`, `providers`, `models`, and `help`. ### `run` — Token usage statistics @@ -63,6 +63,27 @@ opencode-usage run --by model --json | jq '.rows[].label' opencode-usage run --since 7d --compare ``` +### `providers` / `models` — list available IDs + +Both list what opencode itself exposes (`opencode models`), so they stay in +sync with your setup. Providers marked with `*` have credentials in +opencode's `auth.json`. + +```bash +opencode-usage providers +opencode-usage models +opencode-usage models --provider opencode-go --id +opencode-usage providers --json +``` + +### `help` — command help + +```bash +opencode-usage help # general help, config docs, examples +opencode-usage help run +opencode-usage help insights +``` + ### `insights` — LLM-powered analysis Analyze your OpenCode sessions and generate an HTML report with workflow insights, friction patterns, agent performance, and actionable suggestions. @@ -108,6 +129,23 @@ Requires an API key for the LLM provider — set via environment variable (e.g. ## Configuration +### Config file + +Optional `~/.config/opencode-usage/config.toml` sets defaults. CLI flags +always win over config values, which win over built-in defaults. + +```toml +[defaults] +days = 7 # or weeks / months +by = "day" # minute, hour, day, week, month, model, agent, provider, session +sort = "tokens" # tokens, cost, calls + +[insights] +preferred_models = ["opencode-go/kimi-k3"] # shown first in the model picker +``` + +### Environment variables + | Environment Variable | Description | |---|---| | `OPENCODE_DB` | Override database path (default: auto-detected per platform) | diff --git a/src/opencode_usage/cli.py b/src/opencode_usage/cli.py index d0192a7..1d8170b 100644 --- a/src/opencode_usage/cli.py +++ b/src/opencode_usage/cli.py @@ -10,9 +10,87 @@ from typing import Any from . import __version__, render +from .config import Config +from .config import load as load_config from .db import OpenCodeDB, UsageRow from .render import render_daily, render_grouped, render_summary +_MAIN_EPILOG = """ +Configuration: + Values are read from ~/.config/opencode-usage/config.toml + (or $XDG_CONFIG_HOME/opencode-usage/config.toml). CLI flags always win + over config values, which win over built-in defaults. + + [defaults] + days = 7 # or weeks / months + by = "day" # minute, hour, day, week, month, model, agent, + # provider, session + sort = "tokens" # tokens, cost, calls + + [insights] + preferred_models = ["opencode-go/kimi-k3"] + # ordered list shown first in the interactive picker + +Environment: + OPENCODE_DB override the database path (same as --db) + +Commands: + run token usage statistics (default) + insights LLM-powered usage analysis report + providers list provider IDs (from 'opencode models') + models list model IDs (from 'opencode models') + help show help for a command + +Examples: + opencode-usage last 7 days, daily breakdown + opencode-usage --days 30 --by model + opencode-usage --weeks 2 --by month + opencode-usage --months 3 --by week + opencode-usage --since 3h --by hour + opencode-usage --provider kimi-for-coding --by model --sort cost + opencode-usage --model k3 --agent build --csv + opencode-usage --compare --by model + opencode-usage insights --no-llm + opencode-usage help run +""" + +_RUN_EPILOG = """ +Time filtering: + --since, --days, --weeks and --months are alternatives; the first one + given wins in that order. --since accepts durations (7d, 2w, 30d, 3h, + 12m) or an ISO date (2026-08-01). + +Grouping (--by): + minute per-minute buckets hour per-hour buckets + day per-day buckets (default) week per-week buckets (2026-W31) + month per-month buckets model per model + agent per agent, one row per agent x model + provider per provider session per session (by title) + +Filters (repeatable): + --provider / --model / --agent keep only matching rows; --exclude-provider + removes them. Matching is exact on the providerID / modelID / agent fields. + +Output: + --sort orders grouped rows by tokens (default), cost, or calls. + --json emits machine-readable JSON, including run_rate_monthly. + --csv emits rows as CSV; mutually exclusive with --json. + The summary panel includes an "approx $X/mo" run-rate projection. + --compare adds deltas against the previous period of the same length. +""" + +_INSIGHTS_EPILOG = """ + Generates a self-contained HTML report. Sessions are analyzed with an + LLM through the opencode CLI unless --no-llm is given. + + --model selects the analysis model (provider/model). When omitted, an + interactive picker shows the models from [insights] preferred_models in + the config file first, then the recommended list. + --force clears the per-facet cache and re-analyzes everything. + --concurrency caps parallel LLM workers (default: min(cpu_count, 8)). + --no-llm skips all LLM calls and produces a data-only report. +""" + def _parse_since(value: str) -> datetime: """Parse a relative duration like '7d', '2w', '30d', '3h' or an ISO date.""" @@ -61,14 +139,24 @@ def _add_time_args(parser: argparse.ArgumentParser) -> None: def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="opencode-usage", - description="Track and display OpenCode token usage statistics.", + description=( + "Track and display OpenCode token usage statistics, read directly " + "from OpenCode's local SQLite database." + ), + epilog=_MAIN_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") - sub = p.add_subparsers(dest="subcommand") + sub = p.add_subparsers(dest="subcommand", metavar="COMMAND") # ── run ────────────────────────────────────────────────── - run_p = sub.add_parser("run", help="Token usage statistics (default)") + run_p = sub.add_parser( + "run", + help="Token usage statistics (default)", + epilog=_RUN_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) _add_time_args(run_p) run_p.add_argument( "--by", @@ -96,7 +184,12 @@ def _build_parser() -> argparse.ArgumentParser: ) # ── insights ──────────────────────────────────────────── - ins_p = sub.add_parser("insights", help="LLM-powered usage analysis report") + ins_p = sub.add_parser( + "insights", + help="LLM-powered usage analysis report", + epilog=_INSIGHTS_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) _add_time_args(ins_p) ins_p.add_argument( "--model", @@ -123,6 +216,59 @@ def _build_parser() -> argparse.ArgumentParser: help="Output path for HTML report", ) + # ── help ──────────────────────────────────────────────── + help_p = sub.add_parser("help", help="Show help for a command") + help_p.add_argument( + "command", + nargs="?", + choices=("run", "insights", "providers", "models"), + help="Command to show help for (omit for general help)", + ) + + # ── providers ─────────────────────────────────────────── + prov_p = sub.add_parser( + "providers", + help="List provider IDs (from 'opencode models')", + description=( + "List provider IDs usable with --provider / --exclude-provider. " + "Providers marked with '*' have credentials in opencode's auth.json." + ), + ) + prov_p.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Output as JSON", + ) + + # ── models ────────────────────────────────────────────── + mod_p = sub.add_parser( + "models", + help="List model IDs (from 'opencode models')", + description=( + "List provider/model IDs usable with --model / --provider. " + "Use --id to show the bare model ID as stored in the database." + ), + ) + mod_p.add_argument( + "--provider", + action="append", + default=None, + metavar="ID", + help="Only list models of this provider (repeatable)", + ) + mod_p.add_argument( + "--id", + action="store_true", + help="Show only the model ID, without the provider prefix", + ) + mod_p.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Output as JSON", + ) + return p @@ -184,7 +330,19 @@ def _compute_deltas( return deltas -def _cmd_run(args: argparse.Namespace) -> None: +def _apply_config_defaults(args: argparse.Namespace, cfg: Config) -> None: + """Fill config defaults into *args* where no CLI flag was given.""" + if args.since is None and all( + getattr(args, attr, None) is None for attr in ("days", "weeks", "months") + ): + for attr in ("days", "weeks", "months"): + value = getattr(cfg, attr, None) + if value is not None: + setattr(args, attr, value) + break + + +def _cmd_run(args: argparse.Namespace, cfg: Config) -> None: """Execute the ``run`` subcommand.""" try: db = OpenCodeDB() @@ -192,8 +350,9 @@ def _cmd_run(args: argparse.Namespace) -> None: render.console.print(f"[red]Error:[/red] {e}") sys.exit(1) + _apply_config_defaults(args, cfg) since, period = _resolve_since(args) - group_by = args.by or "day" + group_by = args.by or cfg.by or "day" now = datetime.now().astimezone() prev_since = None @@ -235,28 +394,113 @@ def _cmd_run(args: argparse.Namespace) -> None: render_grouped(rows, group_by, period, deltas=deltas) -def _cmd_insights(args: argparse.Namespace) -> None: +def _cmd_insights(args: argparse.Namespace, cfg: Config) -> None: """Execute the ``insights`` subcommand.""" + _apply_config_defaults(args, cfg) if args.model is None: from .models import select_model_interactive - args.model = select_model_interactive(render.console) + args.model = select_model_interactive(render.console, cfg) from .insights.orchestrator import run_insights run_insights(args) +def _cmd_help(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + """Show help for *args.command*, or the general help when omitted.""" + if args.command: + _build_parser().parse_args([args.command, "--help"]) + parser.print_help() + + +def _list_opencode_models() -> list[str]: + """Return ``opencode models`` output, exiting with an error if unavailable.""" + from ._opencode_cli import run_models + + models = run_models() + if not models: + render.console.print( + "[red]Error:[/red] Could not list models — is [bold]opencode[/bold] installed?" + ) + sys.exit(1) + return models + + +def _cmd_providers(args: argparse.Namespace) -> None: + """Execute the ``providers`` subcommand.""" + import json as _json + + from ._opencode_cli import get_auth_path + + models = _list_opencode_models() + providers = sorted({m.split("/", 1)[0] for m in models if "/" in m}) + + connected: set[str] = set() + try: + with open(get_auth_path()) as f: + connected = set(_json.load(f).keys()) + except (OSError, ValueError): + pass + + if args.json_output: + print(_json.dumps(providers, indent=2)) + return + + for provider in providers: + mark = "*" if provider in connected else " " + print(f"{mark} {provider}") + if connected: + render.console.print() + render.console.print("[dim]* = connected (credentials present)[/dim]") + + +def _cmd_models(args: argparse.Namespace) -> None: + """Execute the ``models`` subcommand.""" + import json as _json + + models = _list_opencode_models() + + if args.provider: + wanted = set(args.provider) + models = [m for m in models if m.split("/", 1)[0] in wanted] + models = sorted(models) + + if args.json_output: + print(_json.dumps([m.split("/", 1)[1] if args.id else m for m in models], indent=2)) + return + + for m in models: + print(m.split("/", 1)[1] if args.id else m) + + def main(argv: list[str] | None = None) -> None: parser = _build_parser() raw = argv if argv is not None else sys.argv[1:] - if not raw or raw[0] not in ("run", "insights", "-h", "--help", "-V", "--version"): + if not raw or raw[0] not in ( + "run", + "insights", + "help", + "providers", + "models", + "-h", + "--help", + "-V", + "--version", + ): raw = ["run", *raw] args = parser.parse_args(raw) - - if args.subcommand == "insights": - _cmd_insights(args) + cfg = load_config() + + if args.subcommand == "help": + _cmd_help(parser, args) + elif args.subcommand == "insights": + _cmd_insights(args, cfg) + elif args.subcommand == "providers": + _cmd_providers(args) + elif args.subcommand == "models": + _cmd_models(args) else: - _cmd_run(args) + _cmd_run(args, cfg) diff --git a/src/opencode_usage/config.py b/src/opencode_usage/config.py new file mode 100644 index 0000000..da94cba --- /dev/null +++ b/src/opencode_usage/config.py @@ -0,0 +1,79 @@ +"""Persistent configuration for opencode-usage. + +Values are loaded from ``~/.config/opencode-usage/config.toml`` +(or ``$XDG_CONFIG_HOME/opencode-usage/config.toml``). CLI flags always win +over config values, which in turn win over built-in defaults. + +Example config file:: + + [defaults] + days = 7 + by = "day" + sort = "tokens" + + [insights] + preferred_models = ["opencode-go/kimi-k3"] +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +import tomllib + + +@dataclass +class Config: + """Effective configuration with built-in defaults.""" + + days: int | None = None + weeks: int | None = None + months: int | None = None + by: str | None = None + sort: str = "tokens" + preferred_models: list[str] = field(default_factory=list) + + +def config_dir() -> Path: + """Return the config directory (``~/.config/opencode-usage``).""" + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + return Path(xdg) / "opencode-usage" + return Path.home() / ".config" / "opencode-usage" + + +def config_path() -> Path: + """Return the path to the config file.""" + return config_dir() / "config.toml" + + +def load() -> Config: + """Load the config file; missing or malformed files yield defaults.""" + cfg = Config() + path = config_path() + if not path.exists(): + return cfg + try: + with open(path, "rb") as f: + data = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return cfg + + defaults = data.get("defaults") or {} + insights = data.get("insights") or {} + + if isinstance(defaults.get("days"), int) and defaults["days"] > 0: + cfg.days = defaults["days"] + if isinstance(defaults.get("weeks"), int) and defaults["weeks"] > 0: + cfg.weeks = defaults["weeks"] + if isinstance(defaults.get("months"), int) and defaults["months"] > 0: + cfg.months = defaults["months"] + if isinstance(defaults.get("by"), str) and defaults["by"]: + cfg.by = defaults["by"] + if isinstance(defaults.get("sort"), str) and defaults["sort"] in ("tokens", "cost", "calls"): + cfg.sort = defaults["sort"] + if isinstance(insights.get("preferred_models"), list): + cfg.preferred_models = [str(m) for m in insights["preferred_models"]] + return cfg diff --git a/src/opencode_usage/models.py b/src/opencode_usage/models.py index 5306a0a..37c819f 100644 --- a/src/opencode_usage/models.py +++ b/src/opencode_usage/models.py @@ -56,8 +56,12 @@ def rank_models(models: list[str]) -> list[str]: return sorted(models, key=lambda m: (*_tier(m), m.lower())) -def select_model_interactive(console: Console) -> str: - """Interactive model picker — exits with code 1 if opencode is unavailable.""" +def select_model_interactive(console: Console, cfg: object | None = None) -> str: + """Interactive model picker — exits with code 1 if opencode is unavailable. + + When *cfg* carries a ``preferred_models`` list (config file), those + models are shown first, before the built-in recommended list. + """ all_models = list_models() if not all_models: console.print( @@ -65,8 +69,18 @@ def select_model_interactive(console: Console) -> str: ) sys.exit(1) + preferred: list[str] = list(_PREFERRED) + if cfg is not None: + configured = getattr(cfg, "preferred_models", None) + if configured: + valid = {m.lower() for m in all_models} + preferred = [m for m in configured if m.lower() in valid] + if not preferred: + preferred = list(_PREFERRED) + ranked = rank_models(all_models) - top = ranked[:_TOP_N] + top = [m for m in ranked if m in preferred] + [m for m in ranked if m not in preferred] + top = top[:_TOP_N] console.print() console.print("[cyan]Select a model for insights analysis:[/cyan]") diff --git a/tests/test_cli.py b/tests/test_cli.py index 6715502..7e15618 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -326,3 +326,36 @@ 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 == [] + + +# ── help, providers and models subcommands ─────────────────── + + +class TestHelpSubcommand: + def test_help_subcommand_parses(self): + args = _build_parser().parse_args(["help"]) + assert args.subcommand == "help" + assert args.command is None + + def test_help_with_command(self): + args = _build_parser().parse_args(["help", "run"]) + assert args.subcommand == "help" + assert args.command == "run" + + def test_help_invalid_command(self): + with pytest.raises(SystemExit): + _build_parser().parse_args(["help", "bogus"]) + + +class TestProvidersModelsSubcommands: + def test_providers_json(self): + args = _build_parser().parse_args(["providers", "--json"]) + assert args.subcommand == "providers" + assert args.json_output is True + + def test_models_flags(self): + args = _build_parser().parse_args(["models", "--provider", "opencode-go", "--id", "--json"]) + assert args.subcommand == "models" + assert args.provider == ["opencode-go"] + assert args.id is True + assert args.json_output is True diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..73e9e2a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,60 @@ +"""Tests for opencode_usage.config persistent configuration.""" + +from __future__ import annotations + +from opencode_usage.config import config_dir, config_path, load + + +class TestConfigLoad: + def test_missing_file_yields_defaults(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "nonexistent")) + cfg = load() + assert cfg.days is None + assert cfg.by is None + assert cfg.sort == "tokens" + assert cfg.preferred_models == [] + + def test_valid_file(self, tmp_path, monkeypatch): + conf_dir = tmp_path / "config" + (conf_dir / "opencode-usage").mkdir(parents=True) + (conf_dir / "opencode-usage" / "config.toml").write_text( + "[defaults]\n" + "days = 14\n" + 'by = "model"\n' + 'sort = "cost"\n' + "\n" + "[insights]\n" + 'preferred_models = ["opencode-go/kimi-k3"]\n' + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(conf_dir)) + cfg = load() + assert cfg.days == 14 + assert cfg.by == "model" + assert cfg.sort == "cost" + assert cfg.preferred_models == ["opencode-go/kimi-k3"] + + def test_malformed_file_yields_defaults(self, tmp_path, monkeypatch): + conf_dir = tmp_path / "config" + (conf_dir / "opencode-usage").mkdir(parents=True) + (conf_dir / "opencode-usage" / "config.toml").write_text("not [valid toml") + monkeypatch.setenv("XDG_CONFIG_HOME", str(conf_dir)) + cfg = load() + assert cfg.sort == "tokens" + + def test_invalid_sort_ignored(self, tmp_path, monkeypatch): + conf_dir = tmp_path / "config" + (conf_dir / "opencode-usage").mkdir(parents=True) + (conf_dir / "opencode-usage" / "config.toml").write_text('[defaults]\nsort = "bogus"\n') + monkeypatch.setenv("XDG_CONFIG_HOME", str(conf_dir)) + cfg = load() + assert cfg.sort == "tokens" + + +class TestConfigPaths: + def test_default_dir_is_dot_config(self, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + assert str(config_dir()).endswith(".config/opencode-usage") + + def test_path_under_dir(self): + assert config_path().parent == config_dir() + assert config_path().name == "config.toml" diff --git a/tests/test_models.py b/tests/test_models.py index d4b791f..6aa9c22 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -290,3 +290,35 @@ def test_search_empty_query_returns_to_menu(self): ): result = select_model_interactive(console) assert result == top[1] + + def test_preferred_models_from_config_first(self): + from io import StringIO + + from rich.console import Console + + console = Console(file=StringIO()) + models = _SAMPLE_MODELS.copy() + cfg = type("Cfg", (), {"preferred_models": ["opencode/kimi-k2.5"]})() + + with ( + patch("opencode_usage.models.list_models", return_value=models), + patch("opencode_usage.models.Prompt.ask", return_value="1"), + ): + result = select_model_interactive(console, cfg) + assert result == "opencode/kimi-k2.5" + + def test_invalid_preferred_falls_back_to_defaults(self): + from io import StringIO + + from rich.console import Console + + console = Console(file=StringIO()) + models = _SAMPLE_MODELS.copy() + cfg = type("Cfg", (), {"preferred_models": ["bogus/model"]})() + + with ( + patch("opencode_usage.models.list_models", return_value=models), + patch("opencode_usage.models.Prompt.ask", return_value="1"), + ): + result = select_model_interactive(console, cfg) + assert result == rank_models(models)[0]