asp-cli 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.
- asp_cli-0.1.0/.gitignore +40 -0
- asp_cli-0.1.0/LICENSE +21 -0
- asp_cli-0.1.0/PKG-INFO +54 -0
- asp_cli-0.1.0/README.md +25 -0
- asp_cli-0.1.0/pyproject.toml +48 -0
- asp_cli-0.1.0/src/asp_cli/__init__.py +1 -0
- asp_cli-0.1.0/src/asp_cli/api_client.py +118 -0
- asp_cli-0.1.0/src/asp_cli/config.py +176 -0
- asp_cli-0.1.0/src/asp_cli/errors.py +26 -0
- asp_cli-0.1.0/src/asp_cli/main.py +1461 -0
- asp_cli-0.1.0/src/asp_cli/output.py +66 -0
- asp_cli-0.1.0/src/asp_cli/py.typed +0 -0
- asp_cli-0.1.0/src/asp_cli/spec/__init__.py +0 -0
- asp_cli-0.1.0/src/asp_cli/spec/operations.json +404 -0
- asp_cli-0.1.0/uv.lock +333 -0
asp_cli-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg
|
|
8
|
+
|
|
9
|
+
# Virtual environment
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Django
|
|
13
|
+
*.sqlite3
|
|
14
|
+
media/
|
|
15
|
+
staticfiles/
|
|
16
|
+
|
|
17
|
+
# Environment
|
|
18
|
+
.env
|
|
19
|
+
.env.local
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# Node
|
|
28
|
+
node_modules/
|
|
29
|
+
|
|
30
|
+
# Local git worktrees
|
|
31
|
+
.worktrees/
|
|
32
|
+
/asf-doc/
|
|
33
|
+
/asp-marketplace/
|
|
34
|
+
|
|
35
|
+
# OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
Thumbs.db
|
|
38
|
+
|
|
39
|
+
.claude/*
|
|
40
|
+
.asp/
|
asp_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agentic SOC Platform contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
asp_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: asp-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command line client for Agentic SOC Platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/FunnyWolf/agentic-soc-platform
|
|
6
|
+
Project-URL: Repository, https://github.com/FunnyWolf/agentic-soc-platform
|
|
7
|
+
Project-URL: Issues, https://github.com/FunnyWolf/agentic-soc-platform/issues
|
|
8
|
+
Author: Agentic SOC Platform contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentic-soc,cli,security,soc
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: httpx>=0.28.1
|
|
24
|
+
Requires-Dist: jmespath>=1.0.1
|
|
25
|
+
Requires-Dist: pydantic>=2.13.4
|
|
26
|
+
Requires-Dist: rich>=14.2.0
|
|
27
|
+
Requires-Dist: typer>=0.20.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ASP CLI
|
|
31
|
+
|
|
32
|
+
Command line client for Agentic SOC Platform.
|
|
33
|
+
|
|
34
|
+
`asp-cli` provides the `asp` command for SOC analysts and automation agents to authenticate with an ASP server, inspect cases and alerts, add comments, upload files, run playbooks, and query investigation integrations.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
pipx install asp-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
asp auth login --api-url https://asp.example.com --api-key asp_xxx
|
|
46
|
+
asp doctor
|
|
47
|
+
asp case list
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For automation and agent skills, prefer stable JSON output:
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
asp case list --output json
|
|
54
|
+
```
|
asp_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ASP CLI
|
|
2
|
+
|
|
3
|
+
Command line client for Agentic SOC Platform.
|
|
4
|
+
|
|
5
|
+
`asp-cli` provides the `asp` command for SOC analysts and automation agents to authenticate with an ASP server, inspect cases and alerts, add comments, upload files, run playbooks, and query investigation integrations.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
pipx install asp-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
asp auth login --api-url https://asp.example.com --api-key asp_xxx
|
|
17
|
+
asp doctor
|
|
18
|
+
asp case list
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For automation and agent skills, prefer stable JSON output:
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
asp case list --output json
|
|
25
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "asp-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command line client for Agentic SOC Platform"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Agentic SOC Platform contributors" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["agentic-soc", "soc", "security", "cli"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Information Technology",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.28.1",
|
|
26
|
+
"jmespath>=1.0.1",
|
|
27
|
+
"pydantic>=2.13.4",
|
|
28
|
+
"rich>=14.2.0",
|
|
29
|
+
"typer>=0.20.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/FunnyWolf/agentic-soc-platform"
|
|
34
|
+
Repository = "https://github.com/FunnyWolf/agentic-soc-platform"
|
|
35
|
+
Issues = "https://github.com/FunnyWolf/agentic-soc-platform/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
asp = "asp_cli.main:run"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling>=1.28"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/asp_cli"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
48
|
+
"src/asp_cli/spec/operations.json" = "asp_cli/spec/operations.json"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import redact_secret
|
|
11
|
+
from .errors import (
|
|
12
|
+
CliError,
|
|
13
|
+
EXIT_AUTH,
|
|
14
|
+
EXIT_NETWORK,
|
|
15
|
+
EXIT_NOT_FOUND,
|
|
16
|
+
EXIT_PERMISSION,
|
|
17
|
+
EXIT_SERVER,
|
|
18
|
+
EXIT_USAGE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AspClient:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
api_url: str,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
verbose: bool = False,
|
|
29
|
+
console: Console | None = None,
|
|
30
|
+
timeout: float = 20.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.base_url = _normalize_base_url(api_url)
|
|
33
|
+
self.api_key = api_key
|
|
34
|
+
self.verbose = verbose
|
|
35
|
+
self.console = console or Console(stderr=True)
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def health(self) -> dict[str, Any]:
|
|
39
|
+
return self.request("GET", "/api/health/", authenticated=False)
|
|
40
|
+
|
|
41
|
+
def version(self) -> dict[str, Any]:
|
|
42
|
+
return self.request("GET", "/api/agent/v1/version/")
|
|
43
|
+
|
|
44
|
+
def request(self, method: str, path: str, *, authenticated: bool = True, json: Any = None, files: Any = None) -> dict[str, Any]:
|
|
45
|
+
if authenticated and not self.api_key:
|
|
46
|
+
raise CliError("missing_api_key", "API key is required", {}, EXIT_AUTH)
|
|
47
|
+
|
|
48
|
+
headers = {
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"User-Agent": f"asp-cli/{__version__}",
|
|
51
|
+
}
|
|
52
|
+
if authenticated and self.api_key:
|
|
53
|
+
headers["Authorization"] = f"Api-Key {self.api_key}"
|
|
54
|
+
|
|
55
|
+
url = f"{self.base_url}{path}"
|
|
56
|
+
started = time.perf_counter()
|
|
57
|
+
try:
|
|
58
|
+
response = httpx.request(method, url, headers=headers, json=json, files=files, timeout=self.timeout)
|
|
59
|
+
except httpx.HTTPError as exc:
|
|
60
|
+
raise CliError("network_error", f"Unable to reach ASP server: {exc}", {"url": _redact_url(url)}, EXIT_NETWORK) from exc
|
|
61
|
+
|
|
62
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
63
|
+
if self.verbose:
|
|
64
|
+
self.console.print(f"{method} {path} -> {response.status_code} ({elapsed_ms}ms)", style="dim")
|
|
65
|
+
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
self._raise_http_error(response, path)
|
|
68
|
+
|
|
69
|
+
if not response.content:
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
payload = response.json()
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise CliError("invalid_response", "Server returned non-JSON response", {"status_code": response.status_code}, EXIT_SERVER) from exc
|
|
75
|
+
if not isinstance(payload, dict):
|
|
76
|
+
raise CliError("invalid_response", "Server response must be a JSON object", {"status_code": response.status_code}, EXIT_SERVER)
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
def _raise_http_error(self, response: httpx.Response, path: str) -> None:
|
|
80
|
+
message = _response_message(response)
|
|
81
|
+
details = {"status_code": response.status_code, "path": path}
|
|
82
|
+
if response.status_code == 400:
|
|
83
|
+
raise CliError("bad_request", message, details, EXIT_USAGE)
|
|
84
|
+
if response.status_code == 401:
|
|
85
|
+
raise CliError("authentication_failed", message, details, EXIT_AUTH)
|
|
86
|
+
if response.status_code == 403:
|
|
87
|
+
raise CliError("permission_denied", message, details, EXIT_PERMISSION)
|
|
88
|
+
if response.status_code == 404:
|
|
89
|
+
raise CliError("not_found", message, details, EXIT_NOT_FOUND)
|
|
90
|
+
raise CliError("server_error", message, details, EXIT_SERVER)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_base_url(api_url: str) -> str:
|
|
94
|
+
base = api_url.strip().rstrip("/")
|
|
95
|
+
if base.endswith("/api"):
|
|
96
|
+
base = base[:-4]
|
|
97
|
+
if not base:
|
|
98
|
+
raise CliError("missing_api_url", "ASP API URL is required", {}, EXIT_USAGE)
|
|
99
|
+
return base
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _response_message(response: httpx.Response) -> str:
|
|
103
|
+
try:
|
|
104
|
+
payload = response.json()
|
|
105
|
+
except ValueError:
|
|
106
|
+
return response.text.strip() or f"HTTP {response.status_code}"
|
|
107
|
+
if isinstance(payload, dict):
|
|
108
|
+
detail = payload.get("detail")
|
|
109
|
+
if isinstance(detail, str):
|
|
110
|
+
return detail
|
|
111
|
+
error = payload.get("error")
|
|
112
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
113
|
+
return error["message"]
|
|
114
|
+
return f"HTTP {response.status_code}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redact_url(url: str) -> str:
|
|
118
|
+
return url.replace(redact_secret(url), "****") if "asp_" in url else url
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import CliError, EXIT_CONFIG
|
|
10
|
+
|
|
11
|
+
GLOBAL_SETTINGS_PATH = Path.home() / ".asp" / "settings.json"
|
|
12
|
+
LOCAL_SETTINGS_DIR = ".asp"
|
|
13
|
+
SETTINGS_FILENAME = "settings.json"
|
|
14
|
+
SUPPORTED_KEYS = {"api_url", "api_key"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ResolvedConfig:
|
|
19
|
+
api_url: str | None
|
|
20
|
+
api_key: str | None
|
|
21
|
+
sources: dict[str, str]
|
|
22
|
+
global_path: Path
|
|
23
|
+
local_path: Path | None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def has_auth(self) -> bool:
|
|
27
|
+
return bool(self.api_url and self.api_key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_config(*, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> ResolvedConfig:
|
|
31
|
+
cwd = (cwd or Path.cwd()).resolve()
|
|
32
|
+
global_settings = read_settings(GLOBAL_SETTINGS_PATH)
|
|
33
|
+
local_path = find_local_settings(cwd)
|
|
34
|
+
local_settings = read_settings(local_path) if local_path else {}
|
|
35
|
+
values: dict[str, Any] = {}
|
|
36
|
+
sources: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
_merge(values, sources, global_settings, "global")
|
|
39
|
+
if local_path:
|
|
40
|
+
_merge(values, sources, local_settings, "local")
|
|
41
|
+
_merge(
|
|
42
|
+
values,
|
|
43
|
+
sources,
|
|
44
|
+
{
|
|
45
|
+
"api_url": os.environ.get("ASP_API_URL"),
|
|
46
|
+
"api_key": os.environ.get("ASP_API_KEY"),
|
|
47
|
+
},
|
|
48
|
+
"env",
|
|
49
|
+
)
|
|
50
|
+
_merge(values, sources, {"api_url": api_url, "api_key": api_key}, "flags")
|
|
51
|
+
|
|
52
|
+
return ResolvedConfig(
|
|
53
|
+
api_url=_clean(values.get("api_url")),
|
|
54
|
+
api_key=_clean(values.get("api_key")),
|
|
55
|
+
sources=sources,
|
|
56
|
+
global_path=GLOBAL_SETTINGS_PATH,
|
|
57
|
+
local_path=local_path,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def auth_settings_path(*, local: bool, cwd: Path | None = None) -> Path:
|
|
62
|
+
if local:
|
|
63
|
+
return (cwd or Path.cwd()).resolve() / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
64
|
+
return GLOBAL_SETTINGS_PATH
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_auth(*, api_url: str, api_key: str, local: bool = False, cwd: Path | None = None) -> Path:
|
|
68
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
69
|
+
settings = read_settings(path)
|
|
70
|
+
settings["api_url"] = api_url.rstrip("/")
|
|
71
|
+
settings["api_key"] = api_key
|
|
72
|
+
write_settings(path, settings)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def clear_auth(*, local: bool = False, cwd: Path | None = None) -> Path:
|
|
77
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
78
|
+
settings = read_settings(path)
|
|
79
|
+
settings.pop("api_url", None)
|
|
80
|
+
settings.pop("api_key", None)
|
|
81
|
+
write_settings(path, settings)
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_config_value(key: str, value: str, *, local: bool = False, cwd: Path | None = None) -> Path:
|
|
86
|
+
if key not in SUPPORTED_KEYS:
|
|
87
|
+
raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG)
|
|
88
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
89
|
+
settings = read_settings(path)
|
|
90
|
+
settings[key] = value.rstrip("/") if key == "api_url" else value
|
|
91
|
+
write_settings(path, settings)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_config_value(key: str, *, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> tuple[str | None, str | None]:
|
|
96
|
+
if key not in SUPPORTED_KEYS:
|
|
97
|
+
raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG)
|
|
98
|
+
config = resolve_config(cwd=cwd, api_url=api_url, api_key=api_key)
|
|
99
|
+
return getattr(config, key), config.sources.get(key)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_settings(path: Path | None) -> dict[str, Any]:
|
|
103
|
+
if path is None or not path.exists():
|
|
104
|
+
return {}
|
|
105
|
+
try:
|
|
106
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
107
|
+
payload = json.load(handle)
|
|
108
|
+
except json.JSONDecodeError as exc:
|
|
109
|
+
raise CliError("invalid_config", f"Invalid JSON in settings file: {path}", {"path": str(path)}, EXIT_CONFIG) from exc
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
raise CliError("invalid_config", f"Settings file must contain a JSON object: {path}", {"path": str(path)}, EXIT_CONFIG)
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_settings(path: Path, settings: dict[str, Any]) -> None:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
118
|
+
json.dump(settings, handle, indent=2, sort_keys=True)
|
|
119
|
+
handle.write("\n")
|
|
120
|
+
_restrict_permissions(path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_local_settings(cwd: Path) -> Path | None:
|
|
124
|
+
git_root = find_git_root(cwd)
|
|
125
|
+
if git_root is None:
|
|
126
|
+
candidate = cwd / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
127
|
+
return candidate if candidate.exists() else None
|
|
128
|
+
|
|
129
|
+
current = cwd
|
|
130
|
+
while True:
|
|
131
|
+
candidate = current / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
132
|
+
if candidate.exists():
|
|
133
|
+
return candidate
|
|
134
|
+
if current == git_root:
|
|
135
|
+
return None
|
|
136
|
+
current = current.parent
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def find_git_root(cwd: Path) -> Path | None:
|
|
140
|
+
current = cwd
|
|
141
|
+
while True:
|
|
142
|
+
if (current / ".git").exists():
|
|
143
|
+
return current
|
|
144
|
+
if current == current.parent:
|
|
145
|
+
return None
|
|
146
|
+
current = current.parent
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def redact_secret(value: str | None) -> str:
|
|
150
|
+
if not value:
|
|
151
|
+
return ""
|
|
152
|
+
if len(value) <= 8:
|
|
153
|
+
return "****"
|
|
154
|
+
return f"{value[:4]}...{value[-4:]}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _merge(values: dict[str, Any], sources: dict[str, str], incoming: dict[str, Any], source: str) -> None:
|
|
158
|
+
for key in SUPPORTED_KEYS:
|
|
159
|
+
value = _clean(incoming.get(key))
|
|
160
|
+
if value:
|
|
161
|
+
values[key] = value
|
|
162
|
+
sources[key] = source
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _clean(value: Any) -> str | None:
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
text = str(value).strip()
|
|
169
|
+
return text or None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _restrict_permissions(path: Path) -> None:
|
|
173
|
+
try:
|
|
174
|
+
os.chmod(path, 0o600)
|
|
175
|
+
except OSError:
|
|
176
|
+
return
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EXIT_USAGE = 2
|
|
8
|
+
EXIT_CONFIG = 3
|
|
9
|
+
EXIT_AUTH = 4
|
|
10
|
+
EXIT_PERMISSION = 5
|
|
11
|
+
EXIT_NOT_FOUND = 6
|
|
12
|
+
EXIT_CONFLICT = 7
|
|
13
|
+
EXIT_VERSION = 8
|
|
14
|
+
EXIT_NETWORK = 70
|
|
15
|
+
EXIT_SERVER = 75
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CliError(Exception):
|
|
20
|
+
code: str
|
|
21
|
+
message: str
|
|
22
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
exit_code: int = EXIT_USAGE
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return self.message
|