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.
- kctl_plausible-0.6.2/.gitignore +33 -0
- kctl_plausible-0.6.2/PKG-INFO +17 -0
- kctl_plausible-0.6.2/README.md +120 -0
- kctl_plausible-0.6.2/pyproject.toml +43 -0
- kctl_plausible-0.6.2/skills/plausible-admin/SKILL.md +74 -0
- kctl_plausible-0.6.2/src/kctl_plausible/__init__.py +3 -0
- kctl_plausible-0.6.2/src/kctl_plausible/__main__.py +5 -0
- kctl_plausible-0.6.2/src/kctl_plausible/cli.py +97 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/__init__.py +0 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/config_cmd.py +105 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/doctor.py +82 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/export.py +99 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/goals.py +82 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/sites.py +72 -0
- kctl_plausible-0.6.2/src/kctl_plausible/commands/stats.py +128 -0
- kctl_plausible-0.6.2/src/kctl_plausible/core/__init__.py +0 -0
- kctl_plausible-0.6.2/src/kctl_plausible/core/callbacks.py +39 -0
- kctl_plausible-0.6.2/src/kctl_plausible/core/client.py +61 -0
- kctl_plausible-0.6.2/src/kctl_plausible/core/config.py +134 -0
- kctl_plausible-0.6.2/tests/__init__.py +0 -0
- kctl_plausible-0.6.2/tests/conftest.py +83 -0
- kctl_plausible-0.6.2/tests/test_cli.py +30 -0
|
@@ -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,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()
|
|
File without changes
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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
|