skybrush-log-analyzer 0.1.0__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,88 @@
1
+ Metadata-Version: 2.3
2
+ Name: skybrush-log-analyzer
3
+ Version: 0.1.0
4
+ Summary: Command line application for ArduPilot log analysis using https://logs.skybrush.io.
5
+ Author: Peter Volf, Tamas Nepusz, Gabor Vasarhelyi
6
+ Author-email: Peter Volf <do.volfp@gmail.com>, Tamas Nepusz <tamas@collmot.com>, Gabor Vasarhelyi <vasarhelyi@collmot.com>
7
+ Requires-Dist: httpx2>=2.9.1
8
+ Requires-Dist: platformdirs>=4.11.0
9
+ Requires-Dist: typer>=0.27.1
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Skybrush Log Analyzer
14
+
15
+ Command line application for analyzing ArduPilot logs using [Skybrush Log Analyzer](https://logs.skybrush.io).
16
+
17
+ Agent skill included for AI assistance.
18
+
19
+ ## Install
20
+
21
+ **Option A (recommended): install as a tool**
22
+
23
+ ```bash
24
+ uv tool install skybrush-log-analyzer
25
+ ```
26
+
27
+ If `uv` warns that its tool binary directory (e.g. `~/.local/bin`) is not on your `PATH`, follow its suggestion so `skybrush-log-analyzer` can be invoked directly.
28
+
29
+ **Option B: run without installing**
30
+
31
+ Skip installation and prefix every CLI command with `uvx skybrush-log-analyzer`.
32
+
33
+ ## How to use
34
+
35
+ The commands below assume Option A.
36
+
37
+ 1. Create a Skybrush API key at [https://account.skybrush.io/developer](https://account.skybrush.io/developer)
38
+ 2. Log in with your API key: `skybrush-log-analyzer login`
39
+ 3. Navigate to the directory containing your log files and analyze a log file: `skybrush-log-analyzer analyze <path-to-log-file>`
40
+
41
+ Run `skybrush-log-analyzer --help` to see all commands.
42
+
43
+ ## Agent skill
44
+
45
+ The project comes with an agent skill that helps agents use the included CLI to help you with log analysis.
46
+
47
+ You can install the skill with the following command:
48
+
49
+ ```bash
50
+ skybrush-log-analyzer add-skill
51
+ ```
52
+
53
+ Or, without installing the CLI:
54
+
55
+ ```bash
56
+ uvx skybrush-log-analyzer add-skill
57
+ ```
58
+
59
+ Good to know:
60
+
61
+ - By default, the skill will be installed globally to `.agents/skills` in your home directory.
62
+ - If you use Claude Code, add the `--claude` option to the command, which changes the install path to `.claude/skills`.
63
+ - To install the skill in the current working directory, use the `--local` option.
64
+
65
+ Depending on your agent harness, you may need to restart or reload the harness so it recognizes the newly installed skill.
66
+
67
+ Assuming you logged in and navigated to your log files as described above, all you need to do is start a new agent session, check whether the `skybrush-log-analysis` skill appears in the list of loaded skills, and prompt the agent to analyze a log file or list available log analysis tests.
68
+
69
+ ## Upgrade
70
+
71
+ When using `uv tool`, upgrade the CLI and re-install the bundled agent skill so that it matches the new version:
72
+
73
+ ```bash
74
+ uv tool upgrade skybrush-log-analyzer
75
+ skybrush-log-analyzer add-skill --force
76
+ ```
77
+
78
+ When using `uvx`, the CLI always runs the latest revision, but the installed skill does not follow automatically. Refresh it periodically to avoid inconsistency between the skill and the CLI:
79
+
80
+ ```bash
81
+ uvx skybrush-log-analyzer add-skill --force
82
+ ```
83
+
84
+ ## Troubleshooting
85
+
86
+ ### [Windows] Character encoding issues
87
+
88
+ Set `PYTHONIOENCODING=utf-8` before commands to avoid Windows codepage errors (e.g. `UnicodeEncodeError` on `µ`), or set the `PYTHONUTF8=1` environment variable permanently.
@@ -0,0 +1,76 @@
1
+ # Skybrush Log Analyzer
2
+
3
+ Command line application for analyzing ArduPilot logs using [Skybrush Log Analyzer](https://logs.skybrush.io).
4
+
5
+ Agent skill included for AI assistance.
6
+
7
+ ## Install
8
+
9
+ **Option A (recommended): install as a tool**
10
+
11
+ ```bash
12
+ uv tool install skybrush-log-analyzer
13
+ ```
14
+
15
+ If `uv` warns that its tool binary directory (e.g. `~/.local/bin`) is not on your `PATH`, follow its suggestion so `skybrush-log-analyzer` can be invoked directly.
16
+
17
+ **Option B: run without installing**
18
+
19
+ Skip installation and prefix every CLI command with `uvx skybrush-log-analyzer`.
20
+
21
+ ## How to use
22
+
23
+ The commands below assume Option A.
24
+
25
+ 1. Create a Skybrush API key at [https://account.skybrush.io/developer](https://account.skybrush.io/developer)
26
+ 2. Log in with your API key: `skybrush-log-analyzer login`
27
+ 3. Navigate to the directory containing your log files and analyze a log file: `skybrush-log-analyzer analyze <path-to-log-file>`
28
+
29
+ Run `skybrush-log-analyzer --help` to see all commands.
30
+
31
+ ## Agent skill
32
+
33
+ The project comes with an agent skill that helps agents use the included CLI to help you with log analysis.
34
+
35
+ You can install the skill with the following command:
36
+
37
+ ```bash
38
+ skybrush-log-analyzer add-skill
39
+ ```
40
+
41
+ Or, without installing the CLI:
42
+
43
+ ```bash
44
+ uvx skybrush-log-analyzer add-skill
45
+ ```
46
+
47
+ Good to know:
48
+
49
+ - By default, the skill will be installed globally to `.agents/skills` in your home directory.
50
+ - If you use Claude Code, add the `--claude` option to the command, which changes the install path to `.claude/skills`.
51
+ - To install the skill in the current working directory, use the `--local` option.
52
+
53
+ Depending on your agent harness, you may need to restart or reload the harness so it recognizes the newly installed skill.
54
+
55
+ Assuming you logged in and navigated to your log files as described above, all you need to do is start a new agent session, check whether the `skybrush-log-analysis` skill appears in the list of loaded skills, and prompt the agent to analyze a log file or list available log analysis tests.
56
+
57
+ ## Upgrade
58
+
59
+ When using `uv tool`, upgrade the CLI and re-install the bundled agent skill so that it matches the new version:
60
+
61
+ ```bash
62
+ uv tool upgrade skybrush-log-analyzer
63
+ skybrush-log-analyzer add-skill --force
64
+ ```
65
+
66
+ When using `uvx`, the CLI always runs the latest revision, but the installed skill does not follow automatically. Refresh it periodically to avoid inconsistency between the skill and the CLI:
67
+
68
+ ```bash
69
+ uvx skybrush-log-analyzer add-skill --force
70
+ ```
71
+
72
+ ## Troubleshooting
73
+
74
+ ### [Windows] Character encoding issues
75
+
76
+ Set `PYTHONIOENCODING=utf-8` before commands to avoid Windows codepage errors (e.g. `UnicodeEncodeError` on `µ`), or set the `PYTHONUTF8=1` environment variable permanently.
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "skybrush-log-analyzer"
3
+ version = "0.1.0"
4
+ description = "Command line application for ArduPilot log analysis using https://logs.skybrush.io."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "httpx2>=2.9.1",
9
+ "platformdirs>=4.11.0",
10
+ "typer>=0.27.1",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "Peter Volf"
15
+ email = "do.volfp@gmail.com"
16
+
17
+ [[project.authors]]
18
+ name = "Tamas Nepusz"
19
+ email = "tamas@collmot.com"
20
+
21
+ [[project.authors]]
22
+ name = "Gabor Vasarhelyi"
23
+ email = "vasarhelyi@collmot.com"
24
+
25
+ [project.scripts]
26
+ skybrush-log-analyzer = "skybrush_log_analyzer.main:run"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "mypy>=2.3.1",
31
+ "poethepoet>=0.48.0",
32
+ "ruff>=0.16.9",
33
+ "zensical>=0.0.65",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.1,<0.13.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.build-backend]
41
+ module-root = ""
42
+
43
+ [tool.mypy]
44
+ strict = true
45
+ show_error_codes = true
46
+
47
+ [tool.ruff]
48
+ line-length = 108
49
+ exclude = [
50
+ ".git",
51
+ ".mypy_cache",
52
+ ".pytest_cache",
53
+ ".ruff_cache",
54
+ ".venv",
55
+ "dist",
56
+ "docs",
57
+ ]
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "B",
62
+ "C",
63
+ "E",
64
+ "F",
65
+ "I",
66
+ "S",
67
+ "W",
68
+ ]
69
+
70
+ [tool.poe.tasks]
71
+ serve-docs = "zensical serve"
72
+ build-docs = "zensical build --clean"
73
+ format = "ruff format . --check"
74
+ format-fix = "ruff format ."
75
+ lint = "ruff check ."
76
+ lint-fix = "ruff check . --fix"
77
+ type = "mypy ."
78
+
79
+ [tool.poe.tasks.check]
80
+ sequence = [
81
+ "format",
82
+ "lint",
83
+ "type",
84
+ ]
85
+ ignore_fail = "return_non_zero"
@@ -0,0 +1,74 @@
1
+ [project]
2
+ name = "skybrush-log-analyzer"
3
+ version = "0.1.0"
4
+ description = "Command line application for ArduPilot log analysis using https://logs.skybrush.io."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Peter Volf", email = "do.volfp@gmail.com" },
8
+ { name = "Tamas Nepusz", email = "tamas@collmot.com" },
9
+ { name = "Gabor Vasarhelyi", email = "vasarhelyi@collmot.com" },
10
+ ]
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "httpx2>=2.9.1",
14
+ "platformdirs>=4.11.0",
15
+ "typer>=0.27.1",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "mypy>=2.3.1",
21
+ "poethepoet>=0.48.0",
22
+ "ruff>=0.16.9",
23
+ "zensical>=0.0.65",
24
+ ]
25
+
26
+ [project.scripts]
27
+ skybrush-log-analyzer = "skybrush_log_analyzer.main:run"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.12.1,<0.13.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.uv.build-backend]
34
+ module-root=""
35
+
36
+ [tool.mypy]
37
+ strict = true
38
+ show_error_codes = true
39
+
40
+ [tool.ruff]
41
+ line-length = 108
42
+ exclude = [
43
+ ".git",
44
+ ".mypy_cache",
45
+ ".pytest_cache",
46
+ ".ruff_cache",
47
+ ".venv",
48
+ "dist",
49
+ "docs",
50
+ ]
51
+ lint.select = [
52
+ "B", # flake8-bugbear
53
+ "C", # flake8-comprehensions
54
+ "E", # pycodestyle errors
55
+ "F", # pyflakes
56
+ "I", # isort
57
+ "S", # flake8-bandit - we must ignore these rules in tests
58
+ "W", # pycodestyle warnings
59
+ ]
60
+
61
+ [tool.poe.tasks]
62
+ serve-docs = "zensical serve"
63
+ build-docs = "zensical build --clean"
64
+
65
+ format = "ruff format . --check"
66
+ format-fix = "ruff format ."
67
+
68
+ lint = "ruff check ."
69
+ lint-fix = "ruff check . --fix"
70
+
71
+ type = "mypy ."
72
+
73
+ check.sequence = ["format", "lint", "type"]
74
+ check.ignore_fail = "return_non_zero"
@@ -0,0 +1,70 @@
1
+ from typing import Any
2
+
3
+
4
+ def to_agent_view(analysis: dict[str, Any]) -> dict[str, Any]:
5
+ """
6
+ Returns the compact, agent-facing view of an analysis API response.
7
+
8
+ Drops internal IDs and null fields, orders test results by severity,
9
+ counts the statuses, omits the messages of passing tests, and adds a
10
+ note when there are no test results.
11
+ """
12
+ tests = [
13
+ _test_view(result)
14
+ for result in sorted(
15
+ analysis.get("test_results") or [],
16
+ key=lambda result: _STATUS_RANK.get(result.get("status"), len(_STATUS_RANK)),
17
+ )
18
+ ]
19
+ view = {
20
+ "note_title": analysis.get("note_title"),
21
+ "note_description": analysis.get("note_description"),
22
+ "status_counts": _status_counts(tests),
23
+ "test_results": tests,
24
+ }
25
+ if not tests:
26
+ view["note"] = "No test results in this group; run the analyze command to produce them."
27
+ return _without_none_fields(view)
28
+
29
+
30
+ _STATUS_RANK = {
31
+ status: rank
32
+ for rank, status in enumerate(
33
+ (
34
+ "FAIL",
35
+ "WARN",
36
+ "INFO",
37
+ "PASS",
38
+ "SKIP",
39
+ "NOT_APPLICABLE",
40
+ )
41
+ )
42
+ }
43
+
44
+
45
+ def _test_view(result: dict[str, Any]) -> dict[str, Any]:
46
+ messages = [] if result.get("status") == "PASS" else result.get("messages") or []
47
+ view: dict[str, Any] = {
48
+ "test_name": result.get("test_name"),
49
+ "status": result.get("status"),
50
+ "title": result.get("title"),
51
+ }
52
+ if messages:
53
+ view["messages"] = [_message_view(message) for message in messages]
54
+ return _without_none_fields(view)
55
+
56
+
57
+ def _message_view(message: dict[str, Any]) -> dict[str, Any]:
58
+ return {key: value for key, value in message.items() if key != "id" and value is not None}
59
+
60
+
61
+ def _status_counts(tests: list[dict[str, Any]]) -> dict[str, int]:
62
+ counts: dict[str, int] = {}
63
+ for test in tests:
64
+ status = test["status"]
65
+ counts[status] = counts.get(status, 0) + 1
66
+ return counts
67
+
68
+
69
+ def _without_none_fields(view: dict[str, Any]) -> dict[str, Any]:
70
+ return {key: value for key, value in view.items() if value is not None}
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import IO, TYPE_CHECKING, Any
6
+
7
+ import httpx2
8
+
9
+ from .errors import BadRequest, ResponseParsingFailed, ServerError, Unauthenticated
10
+ from .typing import SuggestionCategory
11
+ from .utils import calculate_file_hash
12
+
13
+ if TYPE_CHECKING:
14
+ from .auth_client import AuthClient
15
+ from .config import Config
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class APIClient:
20
+ config: Config
21
+ auth: AuthClient
22
+
23
+ def version(self) -> Any:
24
+ return self.request("/version")
25
+
26
+ def tests(self, group: str = "default") -> Any:
27
+ if group == "all":
28
+ # No "all" listing route on the backend; merge the two listings.
29
+ merged: list[dict[str, Any]] = []
30
+ seen: set[str] = set()
31
+ for listing in (self.request("/tests"), self.request("/tests/extras")):
32
+ for test in listing:
33
+ name = test.get("name")
34
+ if name not in seen:
35
+ seen.add(name)
36
+ merged.append(test)
37
+ return merged
38
+ return self.request("/tests/extras" if group == "extra" else "/tests")
39
+
40
+ def analyze(self, path: Path, *, test_names: list[str] | None = None) -> Any:
41
+ # POST returns the new run only; test results live on the merged by-hash view.
42
+ with path.open("rb") as f:
43
+ self.request(
44
+ "/analysis/",
45
+ method="POST",
46
+ files={"file": (path.name, f, "application/octet-stream")},
47
+ data={"test_names": test_names} if test_names is not None else None,
48
+ timeout=30,
49
+ )
50
+
51
+ return self.get_analysis(path)
52
+
53
+ def get_web_login_url(self) -> str:
54
+ """Acquires a web login URL for the user."""
55
+ link = self.request("/login-ticket/", method="POST")
56
+ login_url = link.get("login_url") if isinstance(link, dict) else None
57
+ if not isinstance(login_url, str) or not login_url:
58
+ raise ResponseParsingFailed()
59
+ return login_url
60
+
61
+ def suggest(self, *, category: SuggestionCategory, title: str, content: str) -> Any:
62
+ return self.request(
63
+ "/suggestion/",
64
+ method="POST",
65
+ json={
66
+ "category": category,
67
+ "title": title,
68
+ "content": content,
69
+ },
70
+ )
71
+
72
+ def get_analysis(self, path: Path, *, extras: bool = False) -> Any:
73
+ suffix = "/extras" if extras else ""
74
+ return self.request(
75
+ f"/analysis/by-hash/{calculate_file_hash(path.read_bytes())}{suffix}",
76
+ params={"filename": path.name},
77
+ )
78
+
79
+ def request(
80
+ self,
81
+ path: str,
82
+ *,
83
+ method: str = "GET",
84
+ data: dict[str, Any] | None = None,
85
+ files: dict[str, tuple[str, IO[bytes], str]] | None = None,
86
+ params: dict[str, Any] | None = None,
87
+ json: dict[str, Any] | None = None,
88
+ timeout: int | float = 5,
89
+ ) -> Any:
90
+ response = self._send_request(
91
+ path,
92
+ method=method,
93
+ data=data,
94
+ files=files,
95
+ params=params,
96
+ json=json,
97
+ timeout=timeout,
98
+ )
99
+
100
+ if response.status_code == 401:
101
+ self.auth.refresh_token()
102
+ # File bodies may have been consumed; rewind when possible.
103
+ if files is not None:
104
+ for _, file_obj, _ in files.values():
105
+ seek = getattr(file_obj, "seek", None)
106
+ if callable(seek):
107
+ seek(0)
108
+ response = self._send_request(
109
+ path,
110
+ method=method,
111
+ data=data,
112
+ files=files,
113
+ params=params,
114
+ json=json,
115
+ timeout=timeout,
116
+ )
117
+ if response.status_code == 401:
118
+ raise Unauthenticated()
119
+
120
+ status_code = response.status_code
121
+ if 400 <= status_code < 500:
122
+ print(f"Request failed ({status_code}): {response.text}")
123
+ raise BadRequest(response.text)
124
+
125
+ if status_code >= 500:
126
+ raise ServerError()
127
+
128
+ try:
129
+ return response.json()
130
+ except Exception as e:
131
+ raise ResponseParsingFailed() from e
132
+
133
+ def _send_request(
134
+ self,
135
+ path: str,
136
+ *,
137
+ method: str,
138
+ data: dict[str, Any] | None,
139
+ files: dict[str, tuple[str, IO[bytes], str]] | None,
140
+ params: dict[str, Any] | None,
141
+ json: dict[str, Any] | None,
142
+ timeout: int | float,
143
+ ) -> httpx2.Response:
144
+ with httpx2.Client(
145
+ base_url=self.config.api_url,
146
+ headers=self.auth.make_auth_headers(),
147
+ timeout=timeout,
148
+ ) as client:
149
+ return client.request(method, path, files=files, data=data, params=params, json=json)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import httpx2
6
+
7
+ from .errors import AccountUnreachable, InvalidAPIKey, Unauthenticated
8
+
9
+ if TYPE_CHECKING:
10
+ from .config import Config
11
+
12
+
13
+ class AuthClient:
14
+ """Acquires and stores API tokens from the account."""
15
+
16
+ __slots__ = ("_config", "_token")
17
+
18
+ def __init__(self, config: Config) -> None:
19
+ self._config = config
20
+ self._token: str | None = None
21
+
22
+ def make_auth_headers(self) -> dict[str, str]:
23
+ """
24
+ Returns authorization headers for API requests.
25
+
26
+ Raises:
27
+ Unauthenticated: No API key is configured.
28
+ InvalidAPIKey: The account rejected the API key.
29
+ AccountUnreachable: The account could not be reached or returned an unexpected response.
30
+ """
31
+ return {"Authorization": f"Bearer {self._get_token()}"}
32
+
33
+ def refresh_token(self) -> None:
34
+ """
35
+ Discards any stored token and acquires a new one.
36
+
37
+ Raises:
38
+ Unauthenticated: No API key is configured.
39
+ InvalidAPIKey: The account rejected the API key.
40
+ AccountUnreachable: The account could not be reached or returned an unexpected response.
41
+ """
42
+ # Invalidate everything
43
+ self._token = None
44
+ self._config.clear_token()
45
+ # Request fresh token
46
+ self._request_new_token()
47
+
48
+ def _get_token(self) -> str:
49
+ """
50
+ Returns token from memory, storage, or a fresh token request.
51
+
52
+ Raises:
53
+ Unauthenticated: No API key is configured.
54
+ InvalidAPIKey: The account rejected the API key.
55
+ AccountUnreachable: The account could not be reached or returned an unexpected response.
56
+ """
57
+ if self._token is not None:
58
+ return self._token
59
+
60
+ token = self._config.load_token()
61
+ if token is not None:
62
+ self._token = token
63
+ return token
64
+
65
+ return self._request_new_token()
66
+
67
+ def _request_new_token(self) -> str:
68
+ """
69
+ Acquires a new token and stores it.
70
+
71
+ Raises:
72
+ Unauthenticated: No API key is configured.
73
+ InvalidAPIKey: The account rejected the API key.
74
+ AccountUnreachable: The account could not be reached or returned an unexpected response.
75
+ """
76
+ if not self._config.api_key:
77
+ raise Unauthenticated()
78
+
79
+ try:
80
+ with httpx2.Client(base_url=self._config.account_url, timeout=5) as client:
81
+ response = client.post(
82
+ "/api/get-api-token",
83
+ json={"apiKey": self._config.api_key},
84
+ )
85
+ except httpx2.HTTPError as e:
86
+ raise AccountUnreachable(str(e)) from e
87
+
88
+ if response.status_code in (401, 403):
89
+ raise InvalidAPIKey("API key rejected by account app")
90
+
91
+ if response.status_code != 200:
92
+ raise AccountUnreachable(f"Unexpected status from account app: {response.status_code}")
93
+
94
+ try:
95
+ body = response.json()
96
+ except Exception as e:
97
+ raise AccountUnreachable("Account app returned non-JSON response") from e
98
+
99
+ token = body.get("result") if isinstance(body, dict) else None
100
+ if not isinstance(token, str) or not token:
101
+ raise AccountUnreachable("Account app response missing token result")
102
+
103
+ self._config.save_token(token)
104
+ self._token = token
105
+ return token
@@ -0,0 +1,276 @@
1
+ import shutil
2
+ import webbrowser
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from .analysis import to_agent_view
9
+ from .api_client import APIClient
10
+ from .auth_client import AuthClient
11
+ from .config import Config, describe_config_location, load_config, login, logout
12
+ from .errors import (
13
+ AccountUnreachable,
14
+ ApplicationError,
15
+ BadRequest,
16
+ ConfigError,
17
+ InvalidAPIKey,
18
+ ResponseParsingFailed,
19
+ ServerError,
20
+ Unauthenticated,
21
+ typer_error_handler,
22
+ )
23
+ from .paths import ConfigPath, DestinationPath, ResourcePath
24
+ from .typing import SuggestionCategory, TestGroup
25
+ from .utils import print_json
26
+
27
+ app = typer.Typer(
28
+ name="ArduPilot log analysis CLI",
29
+ help="CLI application for ArduPilot log analysis services provided by the Skybrush.io team.",
30
+ no_args_is_help=True,
31
+ context_settings={"help_option_names": ["-h", "--help"]},
32
+ )
33
+
34
+ config = load_config()
35
+ auth = AuthClient(config)
36
+ client = APIClient(config, auth)
37
+
38
+
39
+ @app.command()
40
+ def web() -> None:
41
+ """Open the log analysis web interface in the default browser."""
42
+ with typer_error_handler(config):
43
+ url = client.get_web_login_url()
44
+ print(f"Opening {url} ...")
45
+ webbrowser.open(url)
46
+
47
+
48
+ @app.command()
49
+ def account() -> None:
50
+ """Open the Skybrush account page in the default browser."""
51
+ print(f"Opening {config.account_url} ...")
52
+ webbrowser.open(config.account_url)
53
+
54
+
55
+ @app.command()
56
+ def version() -> None:
57
+ """Show analysis backend version info."""
58
+ with typer_error_handler(config):
59
+ print_json(client.version())
60
+
61
+
62
+ @app.command()
63
+ def tests(
64
+ group: Annotated[
65
+ TestGroup,
66
+ typer.Argument(
67
+ help="Test group to list: the default tests, the extra (verbose) ones, or all of them."
68
+ ),
69
+ ] = "default",
70
+ ) -> None:
71
+ """List available ArduPilot log tests."""
72
+ with typer_error_handler(config):
73
+ print_json(client.tests(group))
74
+
75
+
76
+ @app.command()
77
+ def analyze(
78
+ file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
79
+ test: Annotated[
80
+ list[str] | None,
81
+ typer.Option(
82
+ "--test",
83
+ "-t",
84
+ help="Test name to run (repeatable). Omit to run all tests.",
85
+ ),
86
+ ] = None,
87
+ ) -> None:
88
+ """Upload the given log file, run the analysis on it, and print the results."""
89
+ if not file.is_file():
90
+ print(f"Log file not found: {file}")
91
+ raise typer.Exit(code=1)
92
+
93
+ with typer_error_handler(config):
94
+ # Refresh the API token before file upload to try to avoid hitting
95
+ # token expiration during the operation.
96
+ auth.refresh_token()
97
+ result = client.analyze(file, test_names=test)
98
+ print_json(to_agent_view(result))
99
+
100
+
101
+ @app.command(name="get-analysis")
102
+ def get_analysis(
103
+ file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
104
+ raw: Annotated[
105
+ bool,
106
+ typer.Option("--raw", help="Print the raw API response without trimming."),
107
+ ] = False,
108
+ ) -> None:
109
+ """Fetch existing analysis for the given log file."""
110
+ if not file.is_file():
111
+ print(f"Log file not found: {file}")
112
+ raise typer.Exit(code=1)
113
+
114
+ with typer_error_handler(config):
115
+ result = client.get_analysis(file)
116
+ print_json(result if raw else to_agent_view(result))
117
+
118
+
119
+ @app.command(name="get-analysis-extras")
120
+ def get_analysis_extras(
121
+ file: Annotated[Path, typer.Argument(help="Path to the ArduPilot log file.")],
122
+ raw: Annotated[
123
+ bool,
124
+ typer.Option("--raw", help="Print the raw API response without trimming."),
125
+ ] = False,
126
+ ) -> None:
127
+ """Fetch the extra, verbose test results for the given log file."""
128
+ if not file.is_file():
129
+ print(f"Log file not found: {file}")
130
+ raise typer.Exit(code=1)
131
+
132
+ with typer_error_handler(config):
133
+ result = client.get_analysis(file, extras=True)
134
+ print_json(result if raw else to_agent_view(result))
135
+
136
+
137
+ @app.command(name="suggest")
138
+ def suggest(
139
+ content: Annotated[
140
+ str,
141
+ typer.Argument(
142
+ help="Feedback content (10-2000 characters). "
143
+ "Describe the error noticed, the missing log test, or the user feedback."
144
+ ),
145
+ ],
146
+ title: Annotated[
147
+ str,
148
+ typer.Option(
149
+ "--title", "-t", help="Short summary (1-100 characters, excluding surrounding whitespace)."
150
+ ),
151
+ ],
152
+ category: Annotated[
153
+ SuggestionCategory,
154
+ typer.Option(
155
+ "--category", "-c", help="Feedback category. Use 'test_case' for missing or improved log tests."
156
+ ),
157
+ ],
158
+ ) -> None:
159
+ """Submit feedback about the analysis service to the Skybrush team."""
160
+ content = content.strip()
161
+ if not (10 <= len(content) <= 2000):
162
+ raise typer.BadParameter(
163
+ "Feedback content must be 10-2000 characters long (excluding surrounding whitespace)."
164
+ )
165
+
166
+ title = title.strip()
167
+ if not (1 <= len(title) <= 100):
168
+ raise typer.BadParameter(
169
+ "Feedback title must be 1-100 characters long (excluding surrounding whitespace)."
170
+ )
171
+
172
+ with typer_error_handler(config):
173
+ result = client.suggest(category=category, title=title, content=content)
174
+ print_json(result)
175
+
176
+
177
+ @app.command(name="login")
178
+ def login_cmd(
179
+ api_key: Annotated[str, typer.Option(prompt="API key")],
180
+ local: Annotated[
181
+ bool,
182
+ typer.Option("--local", help="Whether to update the local config instead of the global one."),
183
+ ] = False,
184
+ ) -> None:
185
+ """Log in with a Skybrush API key."""
186
+ candidate = Config(
187
+ api_key=api_key,
188
+ api_url=config.api_url,
189
+ account_url=config.account_url,
190
+ config_dir=ConfigPath.dir(local=local),
191
+ )
192
+ candidate_auth = AuthClient(candidate)
193
+ candidate_client = APIClient(candidate, candidate_auth)
194
+
195
+ with typer_error_handler(candidate):
196
+ # Mint a fresh token with the candidate key and check backend acceptance.
197
+ candidate_auth.refresh_token()
198
+ candidate_client.version()
199
+ try:
200
+ login(api_key, local=local)
201
+ except OSError as e:
202
+ raise ConfigError(str(e)) from e
203
+ print("Logged in.")
204
+
205
+
206
+ @app.command(name="logout")
207
+ def logout_cmd() -> None:
208
+ """Log out."""
209
+ if config.api_key is None:
210
+ print("Not logged in.")
211
+ raise typer.Exit(code=1)
212
+
213
+ with typer_error_handler(config):
214
+ try:
215
+ logout(config)
216
+ except OSError as e:
217
+ raise ConfigError(str(e)) from e
218
+ print(f"Logged out ({describe_config_location(config)}).")
219
+
220
+
221
+ @app.command(name="add-skill")
222
+ def add_skill(
223
+ force: Annotated[
224
+ bool,
225
+ typer.Option("--force", help="Overwrite existing skill installation."),
226
+ ] = False,
227
+ local: Annotated[
228
+ bool,
229
+ typer.Option(
230
+ "--local", help="Whether to install into the project-local or the global skills directory."
231
+ ),
232
+ ] = False,
233
+ claude: Annotated[
234
+ bool,
235
+ typer.Option("--claude", help="Whether to install for Claude Code."),
236
+ ] = False,
237
+ ) -> None:
238
+ """Install the bundled agent skill for the current user."""
239
+ source = ResourcePath.skill()
240
+ dest = DestinationPath.skill(local=local, claude=claude)
241
+ if dest.exists():
242
+ if not force:
243
+ print(f"{dest} already exists, use --force to overwrite.")
244
+ raise typer.Exit(code=1)
245
+ shutil.rmtree(dest)
246
+ shutil.copytree(source, dest)
247
+ print(f"Skill installed in {dest}.")
248
+
249
+
250
+ @app.command()
251
+ def auth_status() -> None:
252
+ """Show current Skybrush account authentication status."""
253
+ if config.api_key is None:
254
+ print("No API key configured. Use the login command to log in.")
255
+ raise typer.Exit(code=1)
256
+
257
+ print(f"Using {describe_config_location(config)}.")
258
+ try:
259
+ auth.refresh_token()
260
+ client.version()
261
+ print(f"Authenticated at {config.account_url}.")
262
+ except InvalidAPIKey:
263
+ print(f"API key is invalid or has been revoked. Create a new one at {config.account_url}.")
264
+ raise typer.Exit(code=1) from None
265
+ except AccountUnreachable:
266
+ print(f"Could not reach {config.account_url} to refresh the API token.")
267
+ raise typer.Exit(code=1) from None
268
+ except Unauthenticated:
269
+ print("Backend rejected the API token.")
270
+ raise typer.Exit(code=1) from None
271
+ except (BadRequest, ResponseParsingFailed, ServerError):
272
+ print("Backend could not validate the API token.")
273
+ raise typer.Exit(code=1) from None
274
+ except ApplicationError:
275
+ print("Authentication status check failed.")
276
+ raise typer.Exit(code=1) from None
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .paths import ConfigPath
8
+
9
+ _DEFAULT_API_URL = "https://logs.skybrush.io/api/v1"
10
+ _DEFAULT_ACCOUNT_URL = "https://account.skybrush.io"
11
+ _CONFIG_FILENAME = "config.toml"
12
+ _TOKEN_FILENAME = "token.txt" # noqa: S105
13
+
14
+
15
+ @dataclass(kw_only=True, frozen=True, slots=True)
16
+ class Config:
17
+ """Application config."""
18
+
19
+ account_url: str
20
+ api_key: str | None
21
+ api_url: str
22
+ config_dir: Path
23
+
24
+ @property
25
+ def token_path(self) -> Path:
26
+ return self.config_dir / _TOKEN_FILENAME
27
+
28
+ def load_token(self) -> str | None:
29
+ """Loads the saved token if it exists."""
30
+ try:
31
+ token = self.token_path.read_text().strip()
32
+ except OSError:
33
+ return None
34
+ return token or None
35
+
36
+ def save_token(self, token: str) -> None:
37
+ """Saves the given token, creating parent directories as needed."""
38
+ self.token_path.parent.mkdir(parents=True, exist_ok=True)
39
+ self.token_path.write_text(token)
40
+
41
+ def clear_token(self) -> None:
42
+ """Deletes the saved token if it exists."""
43
+ try:
44
+ self.token_path.unlink()
45
+ except FileNotFoundError:
46
+ pass
47
+
48
+
49
+ def load_config() -> Config:
50
+ """
51
+ Loads the application config.
52
+
53
+ Config directory search order:
54
+
55
+ - Project-local: `<current-working-directory>/.skybrush-log-analyzer/`
56
+ - Global: `<user-config-dir>/skybrush-log-analyzer/`
57
+ """
58
+ for directory in (ConfigPath.dir(local=True), ConfigPath.dir(local=False)):
59
+ path = directory / _CONFIG_FILENAME
60
+ if path.is_file():
61
+ data = _read_toml(path)
62
+ return Config(
63
+ api_key=data.get("api_key"),
64
+ api_url=data.get("api_url", _DEFAULT_API_URL),
65
+ account_url=data.get("account_url", _DEFAULT_ACCOUNT_URL),
66
+ config_dir=directory,
67
+ )
68
+
69
+ return Config(
70
+ api_key=None,
71
+ api_url=_DEFAULT_API_URL,
72
+ account_url=_DEFAULT_ACCOUNT_URL,
73
+ config_dir=ConfigPath.dir(local=False),
74
+ )
75
+
76
+
77
+ def login(api_key: str, *, local: bool = False) -> None:
78
+ """
79
+ Stores the given API key in the application's config file.
80
+
81
+ Arguments:
82
+ api_key: The API key to store.
83
+ local: Whether the project-local or the global config file
84
+ should be updated.
85
+ """
86
+ path = ConfigPath.dir(local=local) / _CONFIG_FILENAME
87
+ data = _read_toml(path)
88
+ data["api_key"] = api_key
89
+ _write_toml(path, data)
90
+
91
+
92
+ def logout(config: Config) -> None:
93
+ """
94
+ Removes the config file for the given `Config` and
95
+ clears the corresponding token if it exists.
96
+
97
+ """
98
+ try:
99
+ (config.config_dir / _CONFIG_FILENAME).unlink()
100
+ except FileNotFoundError:
101
+ pass
102
+
103
+ config.clear_token()
104
+
105
+
106
+ def describe_config_location(config: Config) -> str:
107
+ """Human-readable label for the config directory corresponding to the given config."""
108
+ if config.config_dir == ConfigPath.dir(local=True):
109
+ return f"local config at {config.config_dir}"
110
+ if config.config_dir == ConfigPath.dir(local=False):
111
+ return f"global config at {config.config_dir}"
112
+ return f"custom config at {config.config_dir}"
113
+
114
+
115
+ def _read_toml(path: Path) -> dict[str, str]:
116
+ """
117
+ Reads the given toml file, returning only flat string values.
118
+
119
+ Returns an empty dict for a missing or unparseable file.
120
+ """
121
+ if not path.is_file():
122
+ return {}
123
+ try:
124
+ with path.open("rb") as fh:
125
+ data = tomllib.load(fh)
126
+ except tomllib.TOMLDecodeError as e:
127
+ import warnings
128
+
129
+ warnings.warn(f"Ignoring unparseable config file {path}: {e}", stacklevel=2)
130
+ return {}
131
+
132
+ # Keep flat string values only; ignore anything else.
133
+ return {k: v for k, v in data.items() if isinstance(v, str)}
134
+
135
+
136
+ def _write_toml(path: Path, data: dict[str, str]) -> None:
137
+ path.parent.mkdir(parents=True, exist_ok=True)
138
+ lines = [f'{k} = "{_escape(v)}"' for k, v in data.items()]
139
+ path.write_text("\n".join(lines) + ("\n" if lines else ""))
140
+
141
+
142
+ def _escape(value: str) -> str:
143
+ # Minimal TOML basic-string escaping.
144
+ return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+
9
+ if TYPE_CHECKING:
10
+ from .config import Config
11
+
12
+
13
+ class ApplicationError(Exception):
14
+ """Base class for errors raised by the CLI application."""
15
+
16
+
17
+ class ApplicationErrorWithMessage(ApplicationError):
18
+ def __init__(self, message: str) -> None:
19
+ super().__init__(message)
20
+
21
+ @property
22
+ def message(self) -> str:
23
+ return str(self.args[0])
24
+
25
+
26
+ class ConfigError(ApplicationErrorWithMessage): ...
27
+
28
+
29
+ class Unauthenticated(ApplicationError): ...
30
+
31
+
32
+ class InvalidAPIKey(ApplicationErrorWithMessage): ...
33
+
34
+
35
+ class AccountUnreachable(ApplicationErrorWithMessage): ...
36
+
37
+
38
+ class BadRequest(ApplicationErrorWithMessage): ...
39
+
40
+
41
+ class ServerError(ApplicationError): ...
42
+
43
+
44
+ class ResponseParsingFailed(ApplicationError): ...
45
+
46
+
47
+ @contextmanager
48
+ def typer_error_handler(config: Config) -> Iterator[None]: # noqa: C901
49
+ try:
50
+ yield
51
+ except Unauthenticated as e:
52
+ print("Authentication error. Check your API key.")
53
+ raise typer.Exit(1) from e
54
+ except InvalidAPIKey as e:
55
+ print(f"Your API key is invalid or has been revoked. Create a new one at {config.account_url}.")
56
+ raise typer.Exit(1) from e
57
+ except AccountUnreachable as e:
58
+ print(f"Could not reach {config.account_url} to acquire an API token.")
59
+ raise typer.Exit(1) from e
60
+ except BadRequest as e:
61
+ print(f"Bad request: {e.message}")
62
+ raise typer.Exit(1) from e
63
+ except ConfigError as e:
64
+ print(f"Config error: {e.message}")
65
+ raise typer.Exit(1) from e
66
+ except ServerError as e:
67
+ print("An error occurred on the server.")
68
+ raise typer.Exit(1) from e
69
+ except ResponseParsingFailed as e:
70
+ print("Server returned non-JSON response.")
71
+ raise typer.Exit(1) from e
72
+ except ApplicationErrorWithMessage as e:
73
+ print(f"An unexpected error occurred: {e.message}")
74
+ raise typer.Exit(1) from e
75
+ except ApplicationError as e:
76
+ print("An unexpected error occurred.")
77
+ raise typer.Exit(1) from e
78
+ except Exception as e:
79
+ print("An unexpected error occurred.")
80
+ raise typer.Exit(1) from e
@@ -0,0 +1,8 @@
1
+ def run() -> None:
2
+ from .cli import app
3
+
4
+ app()
5
+
6
+
7
+ if __name__ == "__main__":
8
+ run()
@@ -0,0 +1,36 @@
1
+ import importlib.resources
2
+ from pathlib import Path
3
+ from typing import cast
4
+
5
+ import platformdirs
6
+
7
+
8
+ class ResourcePath:
9
+ @classmethod
10
+ def skill(cls) -> Path:
11
+ """Path to the bundled agent skill."""
12
+ return cls._resources_root() / "skills" / "skybrush-log-analysis"
13
+
14
+ @classmethod
15
+ def _resources_root(cls) -> Path:
16
+ """Path to the root of the bundled resources."""
17
+ return cast(Path, importlib.resources.files("skybrush_log_analyzer") / "resources")
18
+
19
+
20
+ class DestinationPath:
21
+ @classmethod
22
+ def skill(cls, *, local: bool, claude: bool = False) -> Path:
23
+ """Installation path of the agent skill in the given scope."""
24
+ base = Path.cwd() if local else Path.home()
25
+ harness_dir = ".claude" if claude else ".agents"
26
+ return base / harness_dir / "skills" / "skybrush-log-analysis"
27
+
28
+
29
+ class ConfigPath:
30
+ @classmethod
31
+ def dir(cls, *, local: bool) -> Path:
32
+ """Config directory in the given scope."""
33
+ if local:
34
+ return Path.cwd() / ".skybrush-log-analyzer"
35
+
36
+ return platformdirs.user_config_path("skybrush-log-analyzer")
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: skybrush-log-analysis
3
+ description: Uses the Skybrush Log Analyzer to analyze ArduPilot logs. Use it to run deterministic log analysis, or check available log analysis tools and tests.
4
+ ---
5
+
6
+ # Overview
7
+
8
+ Skybrush Log Analyzer is a Q&A style ArduPilot log analyzer with test algorithms covering many categories (motor, gps, battery etc.), answering questions about a single log file with human-readable output.
9
+
10
+ Invoke the CLI as `skybrush-log-analyzer <COMMAND>`. If `skybrush-log-analyzer` is not available, fall back to: `uvx skybrush-log-analyzer <COMMAND>`.
11
+
12
+ On Windows set `PYTHONIOENCODING=utf-8` (or `PYTHONUTF8=1`) before invoking the CLI.
13
+
14
+ If authentication fails, stop and tell the user! Do not try to fix it by reading env files or environment variables!
15
+
16
+ If you encounter an issue during the analysis process, stop and notify the user!
17
+
18
+ Do not attempt any extra, manual analysis unless directly requested by the user.
19
+
20
+ Avoid rabbit-holes!
21
+
22
+ ## Commands
23
+
24
+ - `--help`: Show help
25
+ - `version`: Get version info
26
+ - `tests`: List available log tests (`tests` lists the default tests; `tests extra` the extra, verbose ones; `tests all` both groups)
27
+ - `analyze PATH/TO/LOG.bin`: Analyze a log file (runs all tests by default) - uploads the log, runs the analysis, and prints a compact summary of the default test results (omitting passing tests). Extra, verbose tests are run and results are stored too, but only shown via `get-analysis-extras`.
28
+ - `analyze PATH/TO/LOG.bin -t TEST_NAME`: Run only the given tests, `-t` / `--test` can be repeated:`-t TEST_A -t TEST_B`
29
+ - `get-analysis PATH/TO/LOG.bin`: Fetch previously stored analysis results for the given log file (same compact summary as `analyze`, default test results only); add `--raw` for the untrimmed API response
30
+ - `get-analysis-extras PATH/TO/LOG.bin`: Fetch the extra, verbose test results for the given log file (e.g. full parameter value listing) that are excluded from the default output; add `--raw` for the untrimmed API response
31
+ - `suggest -t "TITLE" -c CATEGORY "FEEDBACK TEXT"`: Submit valuable feedback about the analysis service to the Skybrush team; `-t` / `--title` is a short summary (1-100 characters) and `-c` / `--category` is one of `test_case`, `feature`, `improvement`, `bug`, `other`
32
+ - `auth-status`: Show the current authentication status
33
+
34
+ ## Standard results vs. extras
35
+
36
+ - `get-analysis` and `get-analysis-extras` are complementary: the former returns the default test results, the latter the extra, verbose ones.
37
+ - Empty `test_results` in either output means no tests from that group have been run for the given log file yet - run `analyze` to produce them.
38
+ - If a test the user asked about is missing from the standard output, check the extras.
39
+
40
+ ## Analysis process
41
+
42
+ STEP 1: Look for existing analysis results using the `get-analysis` command
43
+
44
+ - Yes: Notify the user about existing results, ask if fresh log analysis should be triggered, and proceed accordingly.
45
+ - No: Move on to next step.
46
+
47
+ STEP 2: Did the user ask to test or analyze a specific component or topic?
48
+
49
+ - Yes: Query available tests using the `tests` command. Propose test list to user. The most cost-effective solution is often to run all tests once, and later query and filter existing results.
50
+ - No: Move on to next step with no test name filtering.
51
+
52
+ STEP 3: Analyze the log file using the `analyze` command
53
+
54
+ STEP 4: Interpret and summarize the relevant results to the user
55
+
56
+ ## Submitting feedback
57
+
58
+ Submit feedback with the `suggest` command when you notice any of the following:
59
+
60
+ - A test result contradicts what the log actually shows (suspected bug) — category `bug`.
61
+ - No available test covers something you or the user needed, or an existing test would need a change to cover it — category `test_case`.
62
+ - The results are correct but confusing, incomplete, or hard to work with — category `improvement`, or `feature` if it requires a new capability.
63
+ - The user's reaction suggests that feedback or test suite changes would be useful (e.g. they question a result, seem dissatisfied with it, or pinpoint a key detail that would have helped the analysis) — category `other` unless it clearly matches one of the above.
64
+
65
+ Regarding the feedback message:
66
+
67
+ - Make it technical and self-contained
68
+ - Make it concise, but detailed enough for the reader to fully understand the details and the context
69
+ - Reference affected or related test names and log messages where relevant
70
+ - End the message with a line identifying yourself, in this format: `By: <model> (<harness>)`
71
+
72
+ Never include secrets in the feedback!
73
+
74
+ Submit as soon as the issue is understood well enough for meaningful feedback; do not let it interrupt or delay the analysis for the user.
75
+
76
+ Submit at most one suggestion per distinct issue.
77
+
78
+ You may submit feedback autonomously. If you are unsure whether something is worth submitting, ask the user first. The feedback API is rate limited.
@@ -0,0 +1,5 @@
1
+ from typing import Literal
2
+
3
+ TestGroup = Literal["default", "extra", "all"]
4
+
5
+ SuggestionCategory = Literal["test_case", "feature", "improvement", "bug", "other"]
@@ -0,0 +1,13 @@
1
+ import hashlib
2
+ import json
3
+ from typing import Any
4
+
5
+
6
+ def calculate_file_hash(content: bytes) -> str:
7
+ """Calculate SHA256 hash of file content."""
8
+ return hashlib.sha256(content).hexdigest()
9
+
10
+
11
+ def print_json(data: Any) -> None:
12
+ """Dumps the given data to JSON and prints the result."""
13
+ print(json.dumps(data, ensure_ascii=False, allow_nan=False))