kctl-plausible 0.6.2__tar.gz

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,33 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kctl-plausible
3
+ Version: 0.6.2
4
+ Summary: Kodemeio Plausible Analytics CLI — manage Plausible CE via Stats API v1
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.28.0
7
+ Requires-Dist: kctl-lib>=0.7.0
8
+ Requires-Dist: pydantic>=2.10.0
9
+ Requires-Dist: pyyaml>=6.0.2
10
+ Requires-Dist: rich>=13.9.0
11
+ Requires-Dist: typer>=0.15.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
14
+ Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
17
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
@@ -0,0 +1,120 @@
1
+ # kctl-plausible
2
+
3
+ Kodemeio Plausible CLI — manage analytics via Stats API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # From workspace (development)
9
+ uv tool install --editable packages/kctl-plausible
10
+
11
+ # From PyPI
12
+ uv tool install kctl-plausible
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ # Initialize configuration
19
+ kctl-plausible config init
20
+
21
+ # Test API connectivity
22
+ kctl-plausible config test
23
+
24
+ # View realtime visitors
25
+ kctl-plausible stats realtime terakidz.com
26
+
27
+ # List all tracked sites
28
+ kctl-plausible sites list
29
+
30
+ # View top pages for a site
31
+ kctl-plausible stats pages terakidz.com --period 7d
32
+ ```
33
+
34
+ ## Command Groups
35
+
36
+ | Group | Commands | Description |
37
+ |-------|----------|-------------|
38
+ | `config` | init, add, use, show, set, remove, profiles, current, test, migrate | Manage profiles and credentials |
39
+ | `stats` | realtime, aggregate, timeseries, pages, sources, countries | Analytics data queries |
40
+ | `sites` | list, show, create, delete | Site (domain) management |
41
+ | `goals` | list, create, delete | Conversion goal management |
42
+ | `export` | csv, json | Bulk data export |
43
+ | `doctor` | check | Diagnostic checks (API connectivity, auth, config) |
44
+
45
+ ## Global Options
46
+
47
+ All commands accept these options:
48
+
49
+ | Option | Short | Description |
50
+ |--------|-------|-------------|
51
+ | `--json` | | Output as JSON |
52
+ | `--format` | `-f` | Output format: `pretty`, `json`, `csv`, `yaml` |
53
+ | `--quiet` | `-q` | Suppress info messages |
54
+ | `--no-header` | | Omit header row in table/CSV output |
55
+ | `--profile` | `-p` | Config profile name |
56
+ | `--version` | `-V` | Show version and exit |
57
+
58
+ ## Configuration
59
+
60
+ Config is stored in `~/.config/kodemeio/config.yaml` under the `plausible` key.
61
+
62
+ ```bash
63
+ # Create a new profile
64
+ kctl-plausible config init
65
+
66
+ # Add a named profile
67
+ kctl-plausible config add --profile prod
68
+
69
+ # Switch active profile
70
+ kctl-plausible config use prod
71
+
72
+ # Show current config (secrets masked)
73
+ kctl-plausible config show
74
+
75
+ # Test connectivity
76
+ kctl-plausible config test
77
+ ```
78
+
79
+ ### Config Keys
80
+
81
+ | Key | Description |
82
+ |-----|-------------|
83
+ | `url` | Plausible instance URL (e.g. `https://plausible.io` or self-hosted) |
84
+ | `api_key` | Plausible API key with Stats API access |
85
+
86
+ ### Profile Example
87
+
88
+ ```yaml
89
+ profiles:
90
+ kodemeio:
91
+ plausible:
92
+ url: https://analytics.kodeme.io
93
+ api_key: ${PLAUSIBLE_API_KEY}
94
+ ```
95
+
96
+ ## Common Workflows
97
+
98
+ ```bash
99
+ # View aggregate stats for a period
100
+ kctl-plausible stats aggregate terakidz.com --period 30d --metrics visitors,pageviews
101
+
102
+ # Check top traffic sources
103
+ kctl-plausible stats sources terakidz.com --period 7d
104
+
105
+ # Create a new conversion goal
106
+ kctl-plausible goals create terakidz.com --event "Signup"
107
+
108
+ # Export monthly data as CSV
109
+ kctl-plausible export csv terakidz.com --period month > analytics.csv
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ cd packages/kctl-plausible
116
+ uv sync --all-extras
117
+ uv run pytest tests/ -v
118
+ uv run mypy src/
119
+ uv run ruff check src/
120
+ ```
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-plausible"
7
+ version = "0.6.2"
8
+ description = "Kodemeio Plausible Analytics CLI — manage Plausible CE via Stats API v1"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "kctl-lib>=0.7.0",
12
+ "typer>=0.15.0",
13
+ "rich>=13.9.0",
14
+ "pydantic>=2.10.0",
15
+ "pyyaml>=6.0.2",
16
+ "httpx>=0.28.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.3.0",
22
+ "pytest-httpx>=0.35.0",
23
+ "ruff>=0.9.0",
24
+ "mypy>=1.14.0",
25
+ "types-PyYAML>=6.0.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ kctl-plausible = "kctl_plausible.cli:_run"
30
+
31
+ [tool.uv.sources]
32
+ kctl-lib = { workspace = true }
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/kctl_plausible"]
36
+
37
+ [tool.ruff]
38
+ target-version = "py312"
39
+ line-length = 120
40
+
41
+ [tool.mypy]
42
+ python_version = "3.12"
43
+ strict = true
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: plausible-admin
3
+ description: >
4
+ Plausible Analytics management via kctl-plausible CLI.
5
+ MUST use for ANY kctl-plausible operation.
6
+ Triggers on: "plausible, analytics, stats, pageviews, visitors, traffic, goals".
7
+ ---
8
+
9
+ # kctl-plausible
10
+
11
+ Kodemeio Plausible Analytics CLI - query and manage your Plausible CE instance (stats, sites, goals, exports).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ uv tool install kctl-plausible
17
+ ```
18
+
19
+ ## Global Options
20
+
21
+ | Option | Short | Description |
22
+ |--------|-------|-------------|
23
+ | `--json` | | JSON output (shortcut for --format json) |
24
+ | `--quiet` | `-q` | Suppress info messages |
25
+ | `--format` | `-f` | Output format: pretty/json/csv/yaml |
26
+ | `--no-header` | | Omit headers in CSV output |
27
+ | `--debug` | | Enable debug logging |
28
+ | `--profile` | `-p` | Config profile name |
29
+ | `--url` | | API URL override |
30
+ | `--api-key` | | API key override |
31
+ | `--version` | `-V` | Show version |
32
+
33
+ ## Commands
34
+
35
+ ### config
36
+ - `kctl-plausible config init` — Initialize CLI configuration
37
+ - `kctl-plausible config show` — Show configuration (keys masked)
38
+ - `kctl-plausible config test` — Test API connection
39
+
40
+ ### stats
41
+ - `kctl-plausible stats realtime` — Get current realtime visitors count
42
+ - `kctl-plausible stats aggregate` — Get aggregate stats for a site
43
+ - `kctl-plausible stats breakdown` — Get breakdown stats by a property
44
+ - `kctl-plausible stats timeseries` — Get timeseries stats for a site
45
+
46
+ ### sites
47
+ - `kctl-plausible sites list` — List all sites
48
+ - `kctl-plausible sites create` — Create (provision) a new site
49
+ - `kctl-plausible sites delete` — Delete a site
50
+ - `kctl-plausible sites info` — Get site details
51
+
52
+ ### goals
53
+ - `kctl-plausible goals list` — List all goals for a site
54
+ - `kctl-plausible goals create` — Create a new goal for a site
55
+ - `kctl-plausible goals delete` — Delete a goal from a site
56
+
57
+ ### export
58
+ - `kctl-plausible export csv` — Export breakdown stats as CSV
59
+ - `kctl-plausible export json` — Export breakdown stats as JSON
60
+
61
+ ### doctor
62
+ - `kctl-plausible doctor` — Run diagnostic checks
63
+
64
+ ## Configuration
65
+
66
+ Service key: `plausible` in `~/.config/kodemeio/config.yaml`
67
+
68
+ ```yaml
69
+ profiles:
70
+ terakidz:
71
+ plausible:
72
+ url: https://analytics.kodeme.io
73
+ api_key: <key>
74
+ ```
@@ -0,0 +1,3 @@
1
+ """kctl-plausible: Kodemeio Plausible Analytics CLI."""
2
+
3
+ __version__ = "0.6.2"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_plausible."""
2
+
3
+ from kctl_plausible.cli import _run
4
+
5
+ _run()
@@ -0,0 +1,97 @@
1
+ """Main CLI entry point for kctl-plausible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import KctlError, handle_cli_error
9
+
10
+ from kctl_plausible import __version__
11
+ from kctl_plausible.commands.config_cmd import app as config_app
12
+ from kctl_plausible.commands.doctor import app as doctor_app
13
+ from kctl_plausible.commands.export import app as export_app
14
+ from kctl_plausible.commands.goals import app as goals_app
15
+ from kctl_plausible.commands.sites import app as sites_app
16
+ from kctl_plausible.commands.stats import app as stats_app
17
+ from kctl_plausible.core.callbacks import AppContext
18
+ from kctl_lib.self_update import notify_if_outdated
19
+ from kctl_lib.tui import add_tui_command
20
+
21
+
22
+ def version_callback(value: bool) -> None:
23
+ if value:
24
+ typer.echo(f"kctl-plausible {__version__}")
25
+ raise typer.Exit()
26
+
27
+
28
+ app = typer.Typer(
29
+ name="kctl-plausible",
30
+ help="Kodemeio Plausible Analytics CLI - query and manage your Plausible CE instance.",
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ pretty_exceptions_enable=False,
34
+ )
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ ctx: typer.Context,
40
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON (shortcut for --format json)")] = False,
41
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
42
+ output_format: Annotated[
43
+ str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")
44
+ ] = "pretty",
45
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit headers in CSV output")] = False,
46
+ debug: Annotated[bool, typer.Option("--debug", help="Enable debug logging")] = False,
47
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
48
+ url: Annotated[str | None, typer.Option("--url", help="API URL override")] = None,
49
+ api_key: Annotated[str | None, typer.Option("--api-key", help="API key override")] = None,
50
+ version: Annotated[
51
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
52
+ ] = False,
53
+ ) -> None:
54
+ """Kodemeio Plausible Analytics CLI."""
55
+ import os
56
+
57
+ if debug:
58
+ os.environ["KCTL_DEBUG"] = "1"
59
+
60
+ effective_format = "json" if json_output else output_format
61
+
62
+ ctx.ensure_object(dict)
63
+ ctx.obj = AppContext(
64
+ json_mode=json_output or effective_format == "json",
65
+ quiet=quiet,
66
+ format=effective_format,
67
+ no_header=no_header,
68
+ debug=debug,
69
+ profile=profile,
70
+ url_override=url,
71
+ api_key_override=api_key,
72
+ )
73
+ notify_if_outdated(ctx.obj.output, "kctl-plausible", __version__)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Command groups
78
+ # ---------------------------------------------------------------------------
79
+ app.add_typer(config_app, name="config")
80
+ app.add_typer(stats_app, name="stats")
81
+ app.add_typer(sites_app, name="sites")
82
+ app.add_typer(goals_app, name="goals")
83
+ app.add_typer(export_app, name="export")
84
+ app.add_typer(doctor_app, name="doctor")
85
+ add_tui_command(app, service_key="plausible", version=__version__)
86
+
87
+
88
+ def _run() -> None:
89
+ """Entry point with error handling."""
90
+ try:
91
+ app()
92
+ except KctlError as e:
93
+ handle_cli_error(e)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ _run()
@@ -0,0 +1,105 @@
1
+ """Configuration management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_plausible.core.callbacks import AppContext
10
+ from kctl_plausible.core.config import (
11
+ CONFIG_FILE,
12
+ SERVICE_KEY,
13
+ ServiceConfig,
14
+ get_all_services_in_profile,
15
+ get_default_profile,
16
+ get_profile_names,
17
+ resolve_active_profile_name,
18
+ set_default_profile,
19
+ set_service_config,
20
+ )
21
+
22
+ app = typer.Typer(help="Manage CLI configuration and profiles.")
23
+
24
+
25
+ def _mask(val: str) -> str:
26
+ if not val:
27
+ return "[dim]not set[/dim]"
28
+ return f"{val[:4]}{'*' * max(0, len(val) - 8)}{val[-4:]}" if len(val) > 10 else "****"
29
+
30
+
31
+ @app.command()
32
+ def init(
33
+ ctx: typer.Context,
34
+ url: Annotated[str | None, typer.Option("--url")] = None,
35
+ api_key: Annotated[str | None, typer.Option("--api-key")] = None,
36
+ name: Annotated[str | None, typer.Option("--name", "-n")] = None,
37
+ ) -> None:
38
+ """Initialize CLI configuration."""
39
+ c: AppContext = ctx.obj
40
+ out = c.output
41
+ profile_name = name or typer.prompt("Profile name", default="kodemeio")
42
+ api_url = url or typer.prompt("Plausible URL (e.g. https://plausible.example.com)")
43
+ key = api_key or typer.prompt("API key", hide_input=True)
44
+
45
+ svc = ServiceConfig(url=api_url, api_key=key)
46
+ set_service_config(profile_name, svc)
47
+ if len(get_profile_names()) <= 1:
48
+ set_default_profile(profile_name)
49
+ out.success(f"Configuration saved to {CONFIG_FILE}")
50
+ out.kv("Profile", profile_name)
51
+ out.kv("URL", api_url)
52
+ out.kv("API Key", _mask(key))
53
+
54
+
55
+ @app.command()
56
+ def show(ctx: typer.Context) -> None:
57
+ """Show configuration (keys masked)."""
58
+ c: AppContext = ctx.obj
59
+ out = c.output
60
+ default = get_default_profile()
61
+ sections = [
62
+ (
63
+ "General",
64
+ [
65
+ ("Config file", str(CONFIG_FILE)),
66
+ ("Default profile", default),
67
+ ("Service key", SERVICE_KEY),
68
+ ],
69
+ )
70
+ ]
71
+ for pname in get_profile_names():
72
+ marker = " [green](default)[/green]" if pname == default else ""
73
+ services = get_all_services_in_profile(pname)
74
+ kvs = []
75
+ for svc_name, svc_data in services.items():
76
+ if not isinstance(svc_data, dict):
77
+ continue
78
+ indicator = "[green]●[/green]" if svc_name == SERVICE_KEY else "[dim]○[/dim]"
79
+ kvs.append(
80
+ (
81
+ f"{indicator} {svc_name}",
82
+ f"{svc_data.get('url', '')} key: {_mask(svc_data.get('api_key', ''))}",
83
+ )
84
+ )
85
+ sections.append((f"Profile: {pname}{marker}", kvs or [("(empty)", "")]))
86
+ out.detail("Configuration", sections)
87
+
88
+
89
+ @app.command()
90
+ def test(ctx: typer.Context) -> None:
91
+ """Test API connection."""
92
+ c: AppContext = ctx.obj
93
+ out = c.output
94
+ active = resolve_active_profile_name(c.profile)
95
+ out.info(f"Testing profile '{active}' → {SERVICE_KEY}")
96
+ try:
97
+ status = c.client.check_health()
98
+ if status == 200:
99
+ out.success("Connected to Plausible Analytics API")
100
+ else:
101
+ out.error(f"Connection failed: HTTP {status}")
102
+ raise typer.Exit(1)
103
+ except Exception as e:
104
+ out.error(f"Connection failed: {e}")
105
+ raise typer.Exit(1) from e
@@ -0,0 +1,82 @@
1
+ """Doctor diagnostic checks for kctl-plausible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import typer
8
+ from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
9
+
10
+ from kctl_plausible.core.callbacks import AppContext
11
+
12
+
13
+ @dataclass
14
+ class APIConnectivityCheck:
15
+ """Check that the configured API endpoint is reachable."""
16
+
17
+ name: str = "API Connectivity"
18
+
19
+ def run(self) -> CheckResult:
20
+ try:
21
+ from kctl_plausible.core.config import get_service_config, resolve_active_profile_name
22
+
23
+ profile = resolve_active_profile_name()
24
+ cfg = get_service_config(profile)
25
+ url = cfg.url or ""
26
+ if not url:
27
+ return CheckResult(
28
+ name=self.name,
29
+ status="fail",
30
+ message="No URL configured",
31
+ fix_command="kctl-plausible config init",
32
+ )
33
+ return CheckResult(name=self.name, status="ok", message=f"URL: {url}")
34
+ except Exception as e:
35
+ return CheckResult(name=self.name, status="warn", message=str(e))
36
+
37
+
38
+ @dataclass
39
+ class AuthCheck:
40
+ """Check that authentication credentials are configured."""
41
+
42
+ name: str = "Authentication"
43
+
44
+ def run(self) -> CheckResult:
45
+ try:
46
+ from kctl_plausible.core.config import get_service_config, resolve_active_profile_name
47
+
48
+ profile = resolve_active_profile_name()
49
+ cfg = get_service_config(profile)
50
+ token = cfg.api_key or ""
51
+ if not token:
52
+ return CheckResult(
53
+ name=self.name,
54
+ status="fail",
55
+ message="No API key configured",
56
+ fix_command="kctl-plausible config init",
57
+ )
58
+ masked = token[:4] + "****" + token[-4:] if len(token) > 8 else "****"
59
+ return CheckResult(name=self.name, status="ok", message=f"API key configured ({masked})")
60
+ except Exception as e:
61
+ return CheckResult(name=self.name, status="warn", message=str(e))
62
+
63
+
64
+ app = typer.Typer(help="Run diagnostic checks.", no_args_is_help=False, invoke_without_command=True)
65
+
66
+
67
+ @app.callback(invoke_without_command=True)
68
+ def doctor(ctx: typer.Context) -> None:
69
+ """Run all diagnostic checks."""
70
+ if ctx.invoked_subcommand is not None:
71
+ return
72
+ actx: AppContext = ctx.obj
73
+ out = actx.output
74
+
75
+ checks: list[DoctorCheck] = [
76
+ APIConnectivityCheck(),
77
+ AuthCheck(),
78
+ ]
79
+
80
+ all_passed = run_doctor(checks, out) # type: ignore[arg-type]
81
+ if not all_passed:
82
+ raise typer.Exit(code=1)
@@ -0,0 +1,99 @@
1
+ """Export commands for Plausible Analytics.
2
+
3
+ Export breakdown data as CSV or JSON to stdout or file.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import json
10
+ import sys
11
+ from io import StringIO
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from kctl_plausible.core.callbacks import AppContext
17
+
18
+ app = typer.Typer(help="Export Plausible stats data.")
19
+
20
+
21
+ def _fetch_breakdown(c: AppContext, site_id: str, period: str, prop: str, metrics: str, limit: int) -> list[dict]:
22
+ """Fetch breakdown data from the API."""
23
+ params: dict[str, str | int] = {
24
+ "site_id": site_id,
25
+ "period": period,
26
+ "property": prop,
27
+ "metrics": metrics,
28
+ "limit": limit,
29
+ }
30
+ result = c.client.get("/stats/breakdown", params=params)
31
+ if isinstance(result, dict) and "results" in result:
32
+ return result["results"]
33
+ if isinstance(result, list):
34
+ return result
35
+ return []
36
+
37
+
38
+ @app.command("csv")
39
+ def csv_cmd(
40
+ ctx: typer.Context,
41
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
42
+ prop: Annotated[
43
+ str, typer.Option("--property", help="Breakdown property: visit:source, visit:page, etc.")
44
+ ] = "visit:source",
45
+ period: Annotated[str, typer.Option("--period", "-p", help="Time period")] = "30d",
46
+ metrics: Annotated[str, typer.Option("--metrics", "-m", help="Comma-separated metrics")] = "visitors,pageviews",
47
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Max results")] = 100,
48
+ output: Annotated[str | None, typer.Option("--output", "-o", help="Output file path (default: stdout)")] = None,
49
+ ) -> None:
50
+ """Export breakdown stats as CSV."""
51
+ c: AppContext = ctx.obj
52
+ rows = _fetch_breakdown(c, site_id, period, prop, metrics, limit)
53
+
54
+ if not rows:
55
+ c.output.info("No data to export.")
56
+ return
57
+
58
+ columns = list(rows[0].keys())
59
+ buf = StringIO()
60
+ writer = csv.DictWriter(buf, fieldnames=columns)
61
+ writer.writeheader()
62
+ writer.writerows(rows)
63
+
64
+ csv_content = buf.getvalue()
65
+ if output:
66
+ with open(output, "w", encoding="utf-8") as f:
67
+ f.write(csv_content)
68
+ c.output.success(f"Exported {len(rows)} rows to {output}")
69
+ else:
70
+ sys.stdout.write(csv_content)
71
+
72
+
73
+ @app.command("json")
74
+ def json_cmd(
75
+ ctx: typer.Context,
76
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
77
+ prop: Annotated[
78
+ str, typer.Option("--property", help="Breakdown property: visit:source, visit:page, etc.")
79
+ ] = "visit:source",
80
+ period: Annotated[str, typer.Option("--period", "-p", help="Time period")] = "30d",
81
+ metrics: Annotated[str, typer.Option("--metrics", "-m", help="Comma-separated metrics")] = "visitors,pageviews",
82
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Max results")] = 100,
83
+ output: Annotated[str | None, typer.Option("--output", "-o", help="Output file path (default: stdout)")] = None,
84
+ ) -> None:
85
+ """Export breakdown stats as JSON."""
86
+ c: AppContext = ctx.obj
87
+ rows = _fetch_breakdown(c, site_id, period, prop, metrics, limit)
88
+
89
+ if not rows:
90
+ c.output.info("No data to export.")
91
+ return
92
+
93
+ json_content = json.dumps(rows, indent=2)
94
+ if output:
95
+ with open(output, "w", encoding="utf-8") as f:
96
+ f.write(json_content)
97
+ c.output.success(f"Exported {len(rows)} rows to {output}")
98
+ else:
99
+ sys.stdout.write(json_content + "\n")
@@ -0,0 +1,82 @@
1
+ """Goal management commands for Plausible Analytics.
2
+
3
+ Supports listing, creating, and deleting goals for a site.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_plausible.core.callbacks import AppContext
13
+
14
+ app = typer.Typer(help="Manage Plausible goals.")
15
+
16
+
17
+ @app.command("list")
18
+ def list_cmd(
19
+ ctx: typer.Context,
20
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
21
+ ) -> None:
22
+ """List all goals for a site."""
23
+ c: AppContext = ctx.obj
24
+ out = c.output
25
+ result = c.client.get("/sites/goals", params={"site_id": site_id})
26
+ if isinstance(result, dict) and "goals" in result:
27
+ goals = result["goals"]
28
+ elif isinstance(result, list):
29
+ goals = result
30
+ else:
31
+ goals = [result] if result else []
32
+
33
+ out.table(goals, columns=["id", "goal_type", "event_name", "page_path"], title=f"Goals — {site_id}")
34
+
35
+
36
+ @app.command()
37
+ def create(
38
+ ctx: typer.Context,
39
+ site_id: Annotated[str, typer.Argument(help="Site domain")],
40
+ goal_type: Annotated[str, typer.Option("--type", "-t", help="Goal type: event or page_path")] = "event",
41
+ event_name: Annotated[str | None, typer.Option("--event-name", "-e", help="Event name (for event goals)")] = None,
42
+ page_path: Annotated[str | None, typer.Option("--page-path", help="Page path (for page_path goals)")] = None,
43
+ ) -> None:
44
+ """Create a new goal for a site."""
45
+ c: AppContext = ctx.obj
46
+ out = c.output
47
+
48
+ payload: dict[str, str] = {"site_id": site_id, "goal_type": goal_type}
49
+ if goal_type == "event":
50
+ if not event_name:
51
+ out.error("--event-name is required for event goals")
52
+ raise typer.Exit(1)
53
+ payload["event_name"] = event_name
54
+ elif goal_type == "page_path":
55
+ if not page_path:
56
+ out.error("--page-path is required for page_path goals")
57
+ raise typer.Exit(1)
58
+ payload["page_path"] = page_path
59
+ else:
60
+ out.error(f"Unknown goal type: {goal_type}. Use 'event' or 'page_path'.")
61
+ raise typer.Exit(1)
62
+
63
+ result = c.client.post("/sites/goals", json=payload)
64
+ out.success(f"Goal created for {site_id}")
65
+ out.raw_json(result)
66
+
67
+
68
+ @app.command()
69
+ def delete(
70
+ ctx: typer.Context,
71
+ site_id: Annotated[str, typer.Argument(help="Site domain")],
72
+ goal_id: Annotated[int, typer.Argument(help="Goal ID to delete")],
73
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
74
+ ) -> None:
75
+ """Delete a goal from a site."""
76
+ c: AppContext = ctx.obj
77
+ out = c.output
78
+ if not force:
79
+ typer.confirm(f"Delete goal {goal_id} from site '{site_id}'?", abort=True)
80
+
81
+ c.client.delete(f"/sites/goals/{goal_id}", params={"site_id": site_id})
82
+ out.success(f"Goal {goal_id} deleted from {site_id}")
@@ -0,0 +1,72 @@
1
+ """Site management commands for Plausible Analytics.
2
+
3
+ Supports listing, creating, deleting, and getting info on sites.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_plausible.core.callbacks import AppContext
13
+
14
+ app = typer.Typer(help="Manage Plausible sites.")
15
+
16
+
17
+ @app.command("list")
18
+ def list_cmd(ctx: typer.Context) -> None:
19
+ """List all sites."""
20
+ c: AppContext = ctx.obj
21
+ out = c.output
22
+ result = c.client.get("/sites")
23
+ if isinstance(result, dict) and "sites" in result:
24
+ sites = result["sites"]
25
+ elif isinstance(result, list):
26
+ sites = result
27
+ else:
28
+ sites = [result] if result else []
29
+
30
+ out.table(sites, columns=["domain", "timezone"], title="Plausible Sites")
31
+
32
+
33
+ @app.command()
34
+ def create(
35
+ ctx: typer.Context,
36
+ domain: Annotated[str, typer.Argument(help="Domain to add (e.g. example.com)")],
37
+ timezone: Annotated[str, typer.Option("--timezone", "-tz", help="Timezone (e.g. Europe/London)")] = "Etc/UTC",
38
+ ) -> None:
39
+ """Create (provision) a new site."""
40
+ c: AppContext = ctx.obj
41
+ out = c.output
42
+ result = c.client.post("/sites", json={"domain": domain, "timezone": timezone})
43
+ out.success(f"Site created: {domain}")
44
+ out.raw_json(result)
45
+
46
+
47
+ @app.command()
48
+ def delete(
49
+ ctx: typer.Context,
50
+ site_id: Annotated[str, typer.Argument(help="Site domain to delete")],
51
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
52
+ ) -> None:
53
+ """Delete a site."""
54
+ c: AppContext = ctx.obj
55
+ out = c.output
56
+ if not force:
57
+ typer.confirm(f"Delete site '{site_id}'? This cannot be undone.", abort=True)
58
+
59
+ c.client.delete(f"/sites/{site_id}")
60
+ out.success(f"Site deleted: {site_id}")
61
+
62
+
63
+ @app.command()
64
+ def info(
65
+ ctx: typer.Context,
66
+ site_id: Annotated[str, typer.Argument(help="Site domain to get info for")],
67
+ ) -> None:
68
+ """Get site details."""
69
+ c: AppContext = ctx.obj
70
+ out = c.output
71
+ result = c.client.get(f"/sites/{site_id}")
72
+ out.raw_json(result)
@@ -0,0 +1,128 @@
1
+ """Stats commands for Plausible Analytics.
2
+
3
+ Covers realtime visitors, aggregate stats, breakdowns, and timeseries.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_plausible.core.callbacks import AppContext
13
+
14
+ app = typer.Typer(help="Query Plausible Analytics stats.")
15
+
16
+
17
+ @app.command()
18
+ def realtime(
19
+ ctx: typer.Context,
20
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
21
+ ) -> None:
22
+ """Get current realtime visitors count."""
23
+ c: AppContext = ctx.obj
24
+ out = c.output
25
+ result = c.client.get("/stats/realtime/visitors", params={"site_id": site_id})
26
+ out.kv("Site", site_id)
27
+ out.kv("Realtime Visitors", str(result))
28
+
29
+
30
+ @app.command()
31
+ def aggregate(
32
+ ctx: typer.Context,
33
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
34
+ period: Annotated[str, typer.Option("--period", "-p", help="Time period: day, 7d, 30d, month, 6mo, 12mo")] = "30d",
35
+ metrics: Annotated[
36
+ str,
37
+ typer.Option("--metrics", "-m", help="Comma-separated metrics: visitors,pageviews,bounce_rate,visit_duration"),
38
+ ] = "visitors,pageviews,bounce_rate,visit_duration",
39
+ date: Annotated[str | None, typer.Option("--date", help="Date (YYYY-MM-DD) for the period")] = None,
40
+ filters: Annotated[str | None, typer.Option("--filters", help="Plausible filter expression")] = None,
41
+ ) -> None:
42
+ """Get aggregate stats for a site."""
43
+ c: AppContext = ctx.obj
44
+ out = c.output
45
+ params: dict[str, str] = {"site_id": site_id, "period": period, "metrics": metrics}
46
+ if date:
47
+ params["date"] = date
48
+ if filters:
49
+ params["filters"] = filters
50
+
51
+ result = c.client.get("/stats/aggregate", params=params)
52
+ if isinstance(result, dict) and "results" in result:
53
+ results = result["results"]
54
+ rows = [{"metric": k, "value": v.get("value", v) if isinstance(v, dict) else v} for k, v in results.items()]
55
+ out.table(rows, columns=["metric", "value"], title=f"Aggregate Stats — {site_id} ({period})")
56
+ else:
57
+ out.raw_json(result)
58
+
59
+
60
+ @app.command()
61
+ def breakdown(
62
+ ctx: typer.Context,
63
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
64
+ prop: Annotated[
65
+ str, typer.Option("--property", help="Breakdown property: visit:source, visit:page, etc.")
66
+ ] = "visit:source",
67
+ period: Annotated[str, typer.Option("--period", "-p", help="Time period: day, 7d, 30d, month, 6mo, 12mo")] = "30d",
68
+ metrics: Annotated[str, typer.Option("--metrics", "-m", help="Comma-separated metrics")] = "visitors,pageviews",
69
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Max results to return")] = 10,
70
+ date: Annotated[str | None, typer.Option("--date", help="Date (YYYY-MM-DD) for the period")] = None,
71
+ filters: Annotated[str | None, typer.Option("--filters", help="Plausible filter expression")] = None,
72
+ ) -> None:
73
+ """Get breakdown stats by a property."""
74
+ c: AppContext = ctx.obj
75
+ out = c.output
76
+ params: dict[str, str | int] = {
77
+ "site_id": site_id,
78
+ "period": period,
79
+ "property": prop,
80
+ "metrics": metrics,
81
+ "limit": limit,
82
+ }
83
+ if date:
84
+ params["date"] = date
85
+ if filters:
86
+ params["filters"] = filters
87
+
88
+ result = c.client.get("/stats/breakdown", params=params)
89
+ if isinstance(result, dict) and "results" in result:
90
+ rows = result["results"]
91
+ # Determine columns from first row
92
+ if rows:
93
+ columns = list(rows[0].keys())
94
+ else:
95
+ columns = [prop.split(":")[-1], "visitors"]
96
+ out.table(rows, columns=columns, title=f"Breakdown by {prop} — {site_id} ({period})")
97
+ else:
98
+ out.raw_json(result)
99
+
100
+
101
+ @app.command()
102
+ def timeseries(
103
+ ctx: typer.Context,
104
+ site_id: Annotated[str, typer.Argument(help="Site domain (e.g. example.com)")],
105
+ period: Annotated[str, typer.Option("--period", "-p", help="Time period: day, 7d, 30d, month, 6mo, 12mo")] = "30d",
106
+ metrics: Annotated[str, typer.Option("--metrics", "-m", help="Comma-separated metrics")] = "visitors,pageviews",
107
+ date: Annotated[str | None, typer.Option("--date", help="Date (YYYY-MM-DD) for the period")] = None,
108
+ filters: Annotated[str | None, typer.Option("--filters", help="Plausible filter expression")] = None,
109
+ ) -> None:
110
+ """Get timeseries stats for a site."""
111
+ c: AppContext = ctx.obj
112
+ out = c.output
113
+ params: dict[str, str] = {"site_id": site_id, "period": period, "metrics": metrics}
114
+ if date:
115
+ params["date"] = date
116
+ if filters:
117
+ params["filters"] = filters
118
+
119
+ result = c.client.get("/stats/timeseries", params=params)
120
+ if isinstance(result, dict) and "results" in result:
121
+ rows = result["results"]
122
+ if rows:
123
+ columns = list(rows[0].keys())
124
+ else:
125
+ columns = ["date", "visitors"]
126
+ out.table(rows, columns=columns, title=f"Timeseries — {site_id} ({period})")
127
+ else:
128
+ out.raw_json(result)
@@ -0,0 +1,39 @@
1
+ """Typer global callback and shared context for kctl-plausible.
2
+
3
+ Subclasses AppContextBase from kctl-lib with Plausible Analytics-specific fields.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ from kctl_lib.callbacks import AppContextBase
11
+
12
+ from kctl_plausible.core.client import PlausibleClient
13
+ from kctl_plausible.core.config import resolve_connection
14
+
15
+
16
+ @dataclass
17
+ class AppContext(AppContextBase):
18
+ """Plausible Analytics-specific application context."""
19
+
20
+ debug: bool = False
21
+ url_override: str | None = None
22
+ api_key_override: str | None = None
23
+ _client: PlausibleClient | None = field(default=None, repr=False, init=False)
24
+
25
+ @property
26
+ def client(self) -> PlausibleClient:
27
+ if self._client is None:
28
+ url, api_key = resolve_connection(
29
+ profile_name=self.profile,
30
+ url_override=self.url_override,
31
+ api_key_override=self.api_key_override,
32
+ )
33
+ self._client = PlausibleClient(base_url=url, api_key=api_key)
34
+ return self._client
35
+
36
+ def close(self) -> None:
37
+ """Close underlying HTTP client."""
38
+ if self._client is not None:
39
+ self._client.close()
@@ -0,0 +1,61 @@
1
+ """Plausible Analytics API client, subclassing kctl-lib's APIClient.
2
+
3
+ Provides Plausible-specific auth (Authorization: Bearer <key>),
4
+ retry support, and health check functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from kctl_lib.api_client import APIClient
13
+ from kctl_lib.exceptions import ConfigError
14
+
15
+
16
+ class PlausibleClient(APIClient):
17
+ """Synchronous httpx client for Plausible Analytics API with retry support."""
18
+
19
+ AUTH_HEADER = "Authorization"
20
+ AUTH_PREFIX = "Bearer "
21
+ API_PREFIX = "/api/v1"
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = "",
26
+ api_key: str = "",
27
+ timeout: float = 30.0,
28
+ max_retries: int = 3,
29
+ retry_base_delay: float = 2.0,
30
+ retry_max_delay: float = 60.0,
31
+ **kwargs: Any,
32
+ ):
33
+ if not base_url:
34
+ raise ConfigError("No URL configured. Run: kctl-plausible config init")
35
+
36
+ super().__init__(
37
+ base_url=base_url,
38
+ credential=api_key or "unset",
39
+ timeout=timeout,
40
+ retry_enabled=True,
41
+ max_retries=max_retries,
42
+ retry_base_delay=retry_base_delay,
43
+ retry_max_delay=retry_max_delay,
44
+ **kwargs,
45
+ )
46
+
47
+ @property
48
+ def root_url(self) -> str:
49
+ """Public accessor for the root URL (without /api/v1)."""
50
+ return self._base_url.rsplit("/api/v1", 1)[0]
51
+
52
+ def check_health(self) -> int:
53
+ """Check health by hitting /api/health (not under /v1), returns HTTP status code."""
54
+ try:
55
+ r = httpx.get(
56
+ f"{self.root_url}/api/health",
57
+ timeout=5,
58
+ )
59
+ return r.status_code
60
+ except httpx.HTTPError:
61
+ return 0
@@ -0,0 +1,134 @@
1
+ """Profile management and configuration resolution for kctl-plausible.
2
+
3
+ Delegates to kctl-lib's config framework with Plausible Analytics-specific settings.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ from kctl_lib.config import (
11
+ CONFIG_DIR,
12
+ CONFIG_FILE,
13
+ ConfigFile,
14
+ expand_env,
15
+ get_all_services_in_profile,
16
+ get_default_profile,
17
+ get_profile_names,
18
+ is_service_scoped,
19
+ load_config,
20
+ load_raw_config,
21
+ remove_profile,
22
+ save_raw_config,
23
+ set_default_profile,
24
+ )
25
+ from kctl_lib.config import get_service_config as _get_service_config
26
+ from kctl_lib.config import (
27
+ resolve_active_profile_name as _resolve_active_profile_name,
28
+ )
29
+ from kctl_lib.config import set_service_config as _set_service_config
30
+ from pydantic import BaseModel
31
+
32
+ # This CLI's service key within a profile
33
+ SERVICE_KEY = "plausible"
34
+
35
+ # Environment variable prefix for this CLI
36
+ ENV_PREFIX = "KCTL_PLAUSIBLE"
37
+
38
+ __all__ = [
39
+ "CONFIG_DIR",
40
+ "CONFIG_FILE",
41
+ "ConfigFile",
42
+ "SERVICE_KEY",
43
+ "ServiceConfig",
44
+ "get_all_services_in_profile",
45
+ "get_default_profile",
46
+ "get_profile_names",
47
+ "get_service_config",
48
+ "is_service_scoped",
49
+ "load_config",
50
+ "load_raw_config",
51
+ "remove_profile",
52
+ "resolve_active_profile_name",
53
+ "resolve_connection",
54
+ "save_raw_config",
55
+ "set_default_profile",
56
+ "set_service_config",
57
+ ]
58
+
59
+
60
+ class ServiceConfig(BaseModel):
61
+ """Plausible Analytics service-specific config within a profile."""
62
+
63
+ url: str = ""
64
+ api_key: str = ""
65
+
66
+
67
+ def get_service_config(profile_name: str) -> ServiceConfig:
68
+ """Get Plausible service config from a profile."""
69
+ raw = _get_service_config(
70
+ profile_name,
71
+ SERVICE_KEY,
72
+ valid_fields=list(ServiceConfig.model_fields.keys()),
73
+ )
74
+ if not raw:
75
+ return ServiceConfig()
76
+ return ServiceConfig(**raw)
77
+
78
+
79
+ def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
80
+ """Write Plausible service config into a profile."""
81
+ svc_data = svc_config.model_dump(exclude_defaults=False)
82
+ # Remove empty values
83
+ cleaned = {k: v for k, v in svc_data.items() if v}
84
+ _set_service_config(profile_name, SERVICE_KEY, cleaned)
85
+
86
+
87
+ def _expand_key(api_key: str) -> str:
88
+ """Expand ${ENV_VAR} references in API key values."""
89
+ return expand_env(api_key)
90
+
91
+
92
+ def resolve_active_profile_name(
93
+ profile_name: str | None = None,
94
+ ) -> str:
95
+ """Resolve the active profile name from all sources."""
96
+ return _resolve_active_profile_name(profile_name, ENV_PREFIX)
97
+
98
+
99
+ def resolve_connection(
100
+ profile_name: str | None = None,
101
+ url_override: str | None = None,
102
+ api_key_override: str | None = None,
103
+ ) -> tuple[str, str]:
104
+ """Resolve API URL and API key from all sources.
105
+
106
+ Priority:
107
+ 1. CLI flags (url_override, api_key_override)
108
+ 2. KCTL_PLAUSIBLE_URL / KCTL_PLAUSIBLE_API_KEY env vars
109
+ 3. Profile's plausible service config
110
+ """
111
+ url = ""
112
+ api_key = ""
113
+
114
+ # 3. Config file profile (service-scoped)
115
+ pname = resolve_active_profile_name(profile_name)
116
+ svc = get_service_config(pname)
117
+ if svc.url:
118
+ url = svc.url
119
+ if svc.api_key:
120
+ api_key = svc.api_key
121
+
122
+ # 2. KCTL env vars
123
+ if env_url := os.environ.get("KCTL_PLAUSIBLE_URL"):
124
+ url = env_url
125
+ if env_key := os.environ.get("KCTL_PLAUSIBLE_API_KEY"):
126
+ api_key = env_key
127
+
128
+ # 1. CLI flags
129
+ if url_override:
130
+ url = url_override
131
+ if api_key_override:
132
+ api_key = api_key_override
133
+
134
+ return url, api_key
File without changes
@@ -0,0 +1,83 @@
1
+ """Shared test fixtures for kctl-plausible tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ from kctl_lib.output import Output
12
+
13
+ from kctl_plausible.core.callbacks import AppContext
14
+ from kctl_plausible.core.client import PlausibleClient
15
+
16
+
17
+ @pytest.fixture
18
+ def runner() -> CliRunner:
19
+ """Typer CLI test runner."""
20
+ return CliRunner()
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_client() -> MagicMock:
25
+ """Mocked PlausibleClient with predictable responses."""
26
+ client = MagicMock(spec=PlausibleClient)
27
+ client._root_url = "https://plausible.test.io"
28
+ client._base_url = "https://plausible.test.io/api/v1"
29
+ client._credential = "test-api-key-12345"
30
+ client.AUTH_HEADER = "Authorization"
31
+ client.AUTH_PREFIX = "Bearer "
32
+ client.check_health.return_value = 200
33
+ client.get.return_value = {"results": {}}
34
+ client.post.return_value = {}
35
+ client.delete.return_value = {}
36
+ return client
37
+
38
+
39
+ @pytest.fixture
40
+ def app_context(mock_client: MagicMock) -> AppContext:
41
+ """AppContext with mock client injected."""
42
+ ctx = AppContext(json_mode=True)
43
+ ctx._client = mock_client
44
+ return ctx
45
+
46
+
47
+ @pytest.fixture
48
+ def mock_config(tmp_path: Path):
49
+ """Redirect kctl-lib config to a temp directory."""
50
+ config_dir = tmp_path / "kodemeio"
51
+ config_dir.mkdir(parents=True)
52
+ config_file = config_dir / "config.yaml"
53
+ config_file.write_text("default_profile: default\nprofiles: {}\n")
54
+ with patch("kctl_lib.config.CONFIG_FILE", config_file):
55
+ yield config_file
56
+
57
+
58
+ @pytest.fixture
59
+ def mock_output() -> Output:
60
+ """Output instance for testing."""
61
+ return Output(json_mode=False, quiet=True, format="pretty")
62
+
63
+
64
+ @pytest.fixture
65
+ def mock_context(mock_client: MagicMock, mock_output: Output) -> AppContext:
66
+ """AppContext with mocked client."""
67
+ ctx = AppContext(quiet=True)
68
+ ctx._client = mock_client
69
+ ctx._output = mock_output
70
+ return ctx
71
+
72
+
73
+ @pytest.fixture
74
+ def sample_stats() -> dict:
75
+ """Sample Plausible aggregate stats response."""
76
+ return {
77
+ "results": {
78
+ "visitors": {"value": 1234},
79
+ "pageviews": {"value": 5678},
80
+ "bounce_rate": {"value": 42.5},
81
+ "visit_duration": {"value": 185},
82
+ }
83
+ }
@@ -0,0 +1,30 @@
1
+ """Basic CLI tests for kctl-plausible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from kctl_plausible.cli import app
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ def test_app_exists():
13
+ assert app is not None
14
+
15
+
16
+ def test_help():
17
+ result = runner.invoke(app, ["--help"])
18
+ assert result.exit_code == 0
19
+ assert "Usage" in result.output
20
+
21
+
22
+ def test_version():
23
+ result = runner.invoke(app, ["--version"])
24
+ assert result.exit_code == 0
25
+
26
+
27
+ def test_config_help():
28
+ result = runner.invoke(app, ["config", "--help"])
29
+ assert result.exit_code == 0
30
+ assert "Usage" in result.output