ktr-cli 0.1.0__py3-none-any.whl
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.
- kantree_cli/__init__.py +3 -0
- kantree_cli/cli.py +7922 -0
- kantree_cli/core/__init__.py +1 -0
- kantree_cli/core/client.py +230 -0
- kantree_cli/core/config.py +226 -0
- kantree_cli/core/context.py +141 -0
- kantree_cli/core/errors.py +32 -0
- kantree_cli/core/output.py +64 -0
- kantree_cli/core/response.py +86 -0
- kantree_cli/services/__init__.py +1 -0
- kantree_cli/services/cards.py +726 -0
- kantree_cli/services/importers.py +304 -0
- kantree_cli/services/kql.py +43 -0
- kantree_cli/services/resolver.py +236 -0
- kantree_cli/services/search.py +67 -0
- kantree_cli/services/views.py +124 -0
- kantree_cli/services/webhooks.py +120 -0
- kantree_cli/services/workspaces.py +1300 -0
- ktr_cli-0.1.0.dist-info/METADATA +173 -0
- ktr_cli-0.1.0.dist-info/RECORD +22 -0
- ktr_cli-0.1.0.dist-info/WHEEL +4 -0
- ktr_cli-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core runtime helpers for ktr-cli."""
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import time as _time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from kantree_cli.core.errors import (
|
|
9
|
+
ApiError,
|
|
10
|
+
AuthError,
|
|
11
|
+
NetworkError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PreconditionError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
18
|
+
DEFAULT_MAX_RETRIES = 3
|
|
19
|
+
_RETRYABLE_STATUSES = frozenset({502, 503, 504})
|
|
20
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class KantreeResponse:
|
|
25
|
+
status_code: int
|
|
26
|
+
data: Any
|
|
27
|
+
headers: dict[str, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class KantreeRawResponse:
|
|
32
|
+
status_code: int
|
|
33
|
+
content: bytes
|
|
34
|
+
headers: dict[str, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KantreeClient:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
base_url: str,
|
|
42
|
+
api_key: str | None,
|
|
43
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
44
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
45
|
+
session: requests.Session | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._base_url = base_url.rstrip("/")
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._timeout_seconds = timeout_seconds
|
|
50
|
+
self._max_retries = max_retries
|
|
51
|
+
self._session = session or requests.Session()
|
|
52
|
+
|
|
53
|
+
def request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
params: dict[str, str] | None = None,
|
|
59
|
+
json_body: Any = None,
|
|
60
|
+
form_data: dict[str, str] | None = None,
|
|
61
|
+
files: dict[str, Any] | None = None,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
) -> KantreeResponse:
|
|
64
|
+
if json_body is not None and (form_data is not None or files is not None):
|
|
65
|
+
raise ValidationError("Use either a JSON payload or form-data/files payload, not both.")
|
|
66
|
+
|
|
67
|
+
target_url = self._build_url(path)
|
|
68
|
+
request_headers = {"Accept": "application/json"}
|
|
69
|
+
if self._api_key:
|
|
70
|
+
request_headers["X-Api-Key"] = self._api_key
|
|
71
|
+
if headers:
|
|
72
|
+
request_headers.update(headers)
|
|
73
|
+
|
|
74
|
+
upper_method = method.upper()
|
|
75
|
+
retryable = upper_method in _IDEMPOTENT_METHODS and self._max_retries > 0
|
|
76
|
+
|
|
77
|
+
for attempt in range(self._max_retries + 1):
|
|
78
|
+
if attempt > 0:
|
|
79
|
+
backoff = 2 ** (attempt - 1)
|
|
80
|
+
_time.sleep(backoff)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
response = self._session.request(
|
|
84
|
+
method=upper_method,
|
|
85
|
+
url=target_url,
|
|
86
|
+
params=params,
|
|
87
|
+
json=json_body,
|
|
88
|
+
data=form_data,
|
|
89
|
+
files=files,
|
|
90
|
+
headers=request_headers,
|
|
91
|
+
timeout=self._timeout_seconds,
|
|
92
|
+
)
|
|
93
|
+
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
94
|
+
if retryable and attempt < self._max_retries:
|
|
95
|
+
continue
|
|
96
|
+
raise NetworkError(
|
|
97
|
+
f"Network request failed: {exc}"
|
|
98
|
+
if not isinstance(exc, requests.Timeout)
|
|
99
|
+
else f"Request timed out after {self._timeout_seconds:.0f}s"
|
|
100
|
+
) from exc
|
|
101
|
+
except requests.RequestException as exc:
|
|
102
|
+
raise NetworkError(f"Network request failed: {exc}") from exc
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
retryable
|
|
106
|
+
and response.status_code in _RETRYABLE_STATUSES
|
|
107
|
+
and attempt < self._max_retries
|
|
108
|
+
):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if response.status_code in (401, 403):
|
|
114
|
+
raise AuthError(f"Authentication failed ({response.status_code})")
|
|
115
|
+
if response.status_code == 404:
|
|
116
|
+
details = _extract_api_error_details(response)
|
|
117
|
+
raise NotFoundError(f"Resource not found for `{path}`: {details}")
|
|
118
|
+
if response.status_code == 412:
|
|
119
|
+
details = _extract_api_error_details(response)
|
|
120
|
+
raise PreconditionError(f"Precondition failed for `{path}`: {details}")
|
|
121
|
+
if response.status_code >= 400:
|
|
122
|
+
details = _extract_api_error_details(response)
|
|
123
|
+
raise ApiError(f"API request failed ({response.status_code}): {details}")
|
|
124
|
+
|
|
125
|
+
return KantreeResponse(
|
|
126
|
+
status_code=response.status_code,
|
|
127
|
+
data=_parse_response_data(response),
|
|
128
|
+
headers=dict(response.headers),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def get_json(self, path: str) -> Any:
|
|
132
|
+
response = self.request("GET", path)
|
|
133
|
+
return response.data
|
|
134
|
+
|
|
135
|
+
def download_same_origin_url(self, url: str) -> KantreeRawResponse:
|
|
136
|
+
target = urlparse(url)
|
|
137
|
+
base = urlparse(self._base_url)
|
|
138
|
+
if target.scheme not in {"http", "https"} or not target.netloc:
|
|
139
|
+
raise ValidationError("Artifact URL must be absolute.")
|
|
140
|
+
if (target.scheme, target.netloc) != (base.scheme, base.netloc):
|
|
141
|
+
raise ValidationError("Artifact URL must use the configured Kantree origin.")
|
|
142
|
+
|
|
143
|
+
request_headers = {"Accept": "*/*"}
|
|
144
|
+
if self._api_key:
|
|
145
|
+
request_headers["X-Api-Key"] = self._api_key
|
|
146
|
+
|
|
147
|
+
retryable = self._max_retries > 0
|
|
148
|
+
for attempt in range(self._max_retries + 1):
|
|
149
|
+
if attempt > 0:
|
|
150
|
+
backoff = 2 ** (attempt - 1)
|
|
151
|
+
_time.sleep(backoff)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
response = self._session.request(
|
|
155
|
+
method="GET",
|
|
156
|
+
url=url,
|
|
157
|
+
headers=request_headers,
|
|
158
|
+
timeout=self._timeout_seconds,
|
|
159
|
+
)
|
|
160
|
+
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
161
|
+
if retryable and attempt < self._max_retries:
|
|
162
|
+
continue
|
|
163
|
+
raise NetworkError(
|
|
164
|
+
f"Network request failed: {exc}"
|
|
165
|
+
if not isinstance(exc, requests.Timeout)
|
|
166
|
+
else f"Request timed out after {self._timeout_seconds:.0f}s"
|
|
167
|
+
) from exc
|
|
168
|
+
except requests.RequestException as exc:
|
|
169
|
+
raise NetworkError(f"Network request failed: {exc}") from exc
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
retryable
|
|
173
|
+
and response.status_code in _RETRYABLE_STATUSES
|
|
174
|
+
and attempt < self._max_retries
|
|
175
|
+
):
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
if response.status_code in (401, 403):
|
|
181
|
+
raise AuthError(f"Authentication failed ({response.status_code})")
|
|
182
|
+
if response.status_code == 404:
|
|
183
|
+
details = _extract_api_error_details(response)
|
|
184
|
+
raise NotFoundError(f"Resource not found for artifact URL: {details}")
|
|
185
|
+
if response.status_code == 412:
|
|
186
|
+
details = _extract_api_error_details(response)
|
|
187
|
+
raise PreconditionError(f"Precondition failed for artifact URL: {details}")
|
|
188
|
+
if response.status_code >= 400:
|
|
189
|
+
details = _extract_api_error_details(response)
|
|
190
|
+
raise ApiError(f"API request failed ({response.status_code}): {details}")
|
|
191
|
+
|
|
192
|
+
return KantreeRawResponse(
|
|
193
|
+
status_code=response.status_code,
|
|
194
|
+
content=response.content,
|
|
195
|
+
headers=dict(response.headers),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _build_url(self, path: str) -> str:
|
|
199
|
+
if path.lower().startswith(("http://", "https://")):
|
|
200
|
+
raise ValidationError(
|
|
201
|
+
"Path must be relative to the configured base URL; absolute URLs are not allowed."
|
|
202
|
+
)
|
|
203
|
+
if not path.startswith("/"):
|
|
204
|
+
path = f"/{path}"
|
|
205
|
+
return f"{self._base_url}{path}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract_api_error_details(response: requests.Response) -> str:
|
|
209
|
+
payload = _parse_response_data(response)
|
|
210
|
+
if isinstance(payload, dict):
|
|
211
|
+
for key in ("error", "message", "detail", "description"):
|
|
212
|
+
value = payload.get(key)
|
|
213
|
+
if isinstance(value, str) and value.strip():
|
|
214
|
+
return value
|
|
215
|
+
errors_value = payload.get("errors")
|
|
216
|
+
if isinstance(errors_value, list):
|
|
217
|
+
parts = [str(item) for item in errors_value if item]
|
|
218
|
+
if parts:
|
|
219
|
+
return "; ".join(parts)
|
|
220
|
+
if isinstance(errors_value, str) and errors_value.strip():
|
|
221
|
+
return errors_value
|
|
222
|
+
text = response.text.strip()
|
|
223
|
+
return text if text else "unknown API error"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_response_data(response: requests.Response) -> Any:
|
|
227
|
+
try:
|
|
228
|
+
return response.json()
|
|
229
|
+
except ValueError:
|
|
230
|
+
return response.text
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kantree_cli.core.errors import ValidationError
|
|
8
|
+
|
|
9
|
+
DEFAULT_PROFILE = "default"
|
|
10
|
+
DEFAULT_BASE_URL = "https://kantree.io/api/1.0"
|
|
11
|
+
DEFAULT_API_KEY_ENV = "KANTREE_API_KEY"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ProfileConfig:
|
|
16
|
+
base_url: str | None = None
|
|
17
|
+
api_key_env: str | None = None
|
|
18
|
+
default_workspace: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class SearchPresetConfig:
|
|
23
|
+
kql: str
|
|
24
|
+
scope: str | None = None
|
|
25
|
+
default_format: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class AppConfig:
|
|
30
|
+
active_profile: str | None = None
|
|
31
|
+
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
|
32
|
+
search_presets: dict[str, SearchPresetConfig] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
return config_dir() / "config.toml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_dir() -> Path:
|
|
40
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
41
|
+
if xdg_config_home:
|
|
42
|
+
return Path(xdg_config_home).expanduser() / "kantree"
|
|
43
|
+
return Path.home() / ".config" / "kantree"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_profile_config() -> ProfileConfig:
|
|
47
|
+
return ProfileConfig(base_url=DEFAULT_BASE_URL, api_key_env=DEFAULT_API_KEY_ENV)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_app_config() -> AppConfig:
|
|
51
|
+
return AppConfig(
|
|
52
|
+
active_profile=DEFAULT_PROFILE,
|
|
53
|
+
profiles={DEFAULT_PROFILE: default_profile_config()},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
58
|
+
path = path or config_path()
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return AppConfig()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
import tomllib
|
|
64
|
+
|
|
65
|
+
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
raise ValidationError(f"Failed to read config file: {path}") from exc
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise ValidationError(f"Invalid TOML in config file: {path}") from exc
|
|
70
|
+
|
|
71
|
+
return _parse_app_config(parsed)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_config(config: AppConfig, path: Path | None = None) -> Path:
|
|
75
|
+
path = path or config_path()
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
data = _dump_app_config(config)
|
|
78
|
+
|
|
79
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
82
|
+
stream.write(data)
|
|
83
|
+
finally:
|
|
84
|
+
os.chmod(path, 0o600)
|
|
85
|
+
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ensure_default_config(path: Path | None = None) -> tuple[AppConfig, Path, bool]:
|
|
90
|
+
path = path or config_path()
|
|
91
|
+
if path.exists():
|
|
92
|
+
return load_config(path), path, False
|
|
93
|
+
|
|
94
|
+
config = default_app_config()
|
|
95
|
+
save_config(config, path)
|
|
96
|
+
return config, path, True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_app_config(raw: dict[str, Any]) -> AppConfig:
|
|
100
|
+
active_profile = raw.get("active_profile")
|
|
101
|
+
if active_profile is not None and not isinstance(active_profile, str):
|
|
102
|
+
raise ValidationError("`active_profile` must be a string in config.toml")
|
|
103
|
+
|
|
104
|
+
raw_profiles = raw.get("profiles", {})
|
|
105
|
+
if raw_profiles is None:
|
|
106
|
+
raw_profiles = {}
|
|
107
|
+
if not isinstance(raw_profiles, dict):
|
|
108
|
+
raise ValidationError("`profiles` must be a table in config.toml")
|
|
109
|
+
|
|
110
|
+
profiles: dict[str, ProfileConfig] = {}
|
|
111
|
+
for profile_name, profile_data in raw_profiles.items():
|
|
112
|
+
if not isinstance(profile_name, str):
|
|
113
|
+
raise ValidationError("Profile names must be strings in config.toml")
|
|
114
|
+
if not isinstance(profile_data, dict):
|
|
115
|
+
raise ValidationError(f"Profile `{profile_name}` must be a table in config.toml")
|
|
116
|
+
profiles[profile_name] = ProfileConfig(
|
|
117
|
+
base_url=_as_optional_string(profile_data.get("base_url"), "base_url", profile_name),
|
|
118
|
+
api_key_env=_as_optional_string(
|
|
119
|
+
profile_data.get("api_key_env"),
|
|
120
|
+
"api_key_env",
|
|
121
|
+
profile_name,
|
|
122
|
+
),
|
|
123
|
+
default_workspace=_as_optional_string(
|
|
124
|
+
profile_data.get("default_workspace"),
|
|
125
|
+
"default_workspace",
|
|
126
|
+
profile_name,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
raw_search_presets = raw.get("search_presets", {})
|
|
131
|
+
if raw_search_presets is None:
|
|
132
|
+
raw_search_presets = {}
|
|
133
|
+
if not isinstance(raw_search_presets, dict):
|
|
134
|
+
raise ValidationError("`search_presets` must be a table in config.toml")
|
|
135
|
+
|
|
136
|
+
search_presets: dict[str, SearchPresetConfig] = {}
|
|
137
|
+
for preset_name, preset_data in raw_search_presets.items():
|
|
138
|
+
if not isinstance(preset_name, str):
|
|
139
|
+
raise ValidationError("Search preset names must be strings in config.toml")
|
|
140
|
+
if not isinstance(preset_data, dict):
|
|
141
|
+
raise ValidationError(f"Search preset `{preset_name}` must be a table in config.toml")
|
|
142
|
+
kql = _as_required_string(preset_data.get("kql"), "kql", preset_name)
|
|
143
|
+
search_presets[preset_name] = SearchPresetConfig(
|
|
144
|
+
kql=kql,
|
|
145
|
+
scope=_as_optional_string(
|
|
146
|
+
preset_data.get("scope"),
|
|
147
|
+
"scope",
|
|
148
|
+
preset_name,
|
|
149
|
+
key_prefix="search_presets",
|
|
150
|
+
),
|
|
151
|
+
default_format=_as_optional_string(
|
|
152
|
+
preset_data.get("default_format"),
|
|
153
|
+
"default_format",
|
|
154
|
+
preset_name,
|
|
155
|
+
key_prefix="search_presets",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return AppConfig(
|
|
160
|
+
active_profile=active_profile,
|
|
161
|
+
profiles=profiles,
|
|
162
|
+
search_presets=search_presets,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _dump_app_config(config: AppConfig) -> str:
|
|
167
|
+
lines: list[str] = []
|
|
168
|
+
|
|
169
|
+
if config.active_profile is not None:
|
|
170
|
+
lines.append(f"active_profile = {_toml_quote(config.active_profile)}")
|
|
171
|
+
|
|
172
|
+
for profile_name in sorted(config.profiles):
|
|
173
|
+
profile = config.profiles[profile_name]
|
|
174
|
+
lines.append("")
|
|
175
|
+
lines.append(f"[profiles.{_toml_quote(profile_name)}]")
|
|
176
|
+
if profile.base_url is not None:
|
|
177
|
+
lines.append(f"base_url = {_toml_quote(profile.base_url)}")
|
|
178
|
+
if profile.api_key_env is not None:
|
|
179
|
+
lines.append(f"api_key_env = {_toml_quote(profile.api_key_env)}")
|
|
180
|
+
if profile.default_workspace is not None:
|
|
181
|
+
lines.append(f"default_workspace = {_toml_quote(profile.default_workspace)}")
|
|
182
|
+
|
|
183
|
+
for preset_name in sorted(config.search_presets):
|
|
184
|
+
preset = config.search_presets[preset_name]
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append(f"[search_presets.{_toml_quote(preset_name)}]")
|
|
187
|
+
lines.append(f"kql = {_toml_quote(preset.kql)}")
|
|
188
|
+
if preset.scope is not None:
|
|
189
|
+
lines.append(f"scope = {_toml_quote(preset.scope)}")
|
|
190
|
+
if preset.default_format is not None:
|
|
191
|
+
lines.append(f"default_format = {_toml_quote(preset.default_format)}")
|
|
192
|
+
|
|
193
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _as_optional_string(
|
|
197
|
+
value: Any,
|
|
198
|
+
key: str,
|
|
199
|
+
entry_name: str,
|
|
200
|
+
*,
|
|
201
|
+
key_prefix: str = "profiles",
|
|
202
|
+
) -> str | None:
|
|
203
|
+
if value is None:
|
|
204
|
+
return None
|
|
205
|
+
if not isinstance(value, str):
|
|
206
|
+
raise ValidationError(f"`{key_prefix}.{entry_name}.{key}` must be a string in config.toml")
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_required_string(value: Any, key: str, entry_name: str) -> str:
|
|
211
|
+
if value is None:
|
|
212
|
+
raise ValidationError(f"`search_presets.{entry_name}.{key}` is required in config.toml")
|
|
213
|
+
if not isinstance(value, str):
|
|
214
|
+
raise ValidationError(
|
|
215
|
+
f"`search_presets.{entry_name}.{key}` must be a string in config.toml"
|
|
216
|
+
)
|
|
217
|
+
normalized = value.strip()
|
|
218
|
+
if not normalized:
|
|
219
|
+
raise ValidationError(
|
|
220
|
+
f"`search_presets.{entry_name}.{key}` must be a non-empty string in config.toml"
|
|
221
|
+
)
|
|
222
|
+
return normalized
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _toml_quote(value: str) -> str:
|
|
226
|
+
return json.dumps(value)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from kantree_cli.core.config import (
|
|
6
|
+
DEFAULT_API_KEY_ENV,
|
|
7
|
+
DEFAULT_BASE_URL,
|
|
8
|
+
DEFAULT_PROFILE,
|
|
9
|
+
AppConfig,
|
|
10
|
+
config_path,
|
|
11
|
+
load_config,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ResolvedSettings:
|
|
17
|
+
profile_name: str
|
|
18
|
+
base_url: str
|
|
19
|
+
api_key_env: str
|
|
20
|
+
api_key_value: str | None
|
|
21
|
+
api_key_source: str
|
|
22
|
+
default_organization: str | None
|
|
23
|
+
default_organization_source: str | None
|
|
24
|
+
default_workspace: str | None
|
|
25
|
+
default_workspace_source: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class AppRuntime:
|
|
30
|
+
config_path: Path
|
|
31
|
+
config: AppConfig
|
|
32
|
+
profile_override: str | None
|
|
33
|
+
base_url_override: str | None
|
|
34
|
+
organization_override: str | None
|
|
35
|
+
workspace_id_override: int | None
|
|
36
|
+
workspace_name_override: str | None
|
|
37
|
+
output_format: str
|
|
38
|
+
output_fields: list[str] | None
|
|
39
|
+
verbose: bool
|
|
40
|
+
assume_yes: bool
|
|
41
|
+
debug: bool
|
|
42
|
+
|
|
43
|
+
def resolve(self) -> ResolvedSettings:
|
|
44
|
+
profile_name = (
|
|
45
|
+
self.profile_override
|
|
46
|
+
or os.getenv("KANTREE_PROFILE")
|
|
47
|
+
or self.config.active_profile
|
|
48
|
+
or DEFAULT_PROFILE
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
profile_config = self.config.profiles.get(profile_name)
|
|
52
|
+
|
|
53
|
+
base_url = (
|
|
54
|
+
self.base_url_override
|
|
55
|
+
or os.getenv("KANTREE_BASE_URL")
|
|
56
|
+
or (profile_config.base_url if profile_config else None)
|
|
57
|
+
or DEFAULT_BASE_URL
|
|
58
|
+
)
|
|
59
|
+
base_url = base_url.rstrip("/")
|
|
60
|
+
|
|
61
|
+
api_key_env = (
|
|
62
|
+
os.getenv("KANTREE_API_KEY_ENV")
|
|
63
|
+
or (profile_config.api_key_env if profile_config else None)
|
|
64
|
+
or DEFAULT_API_KEY_ENV
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
direct_api_key = os.getenv("KANTREE_API_KEY")
|
|
68
|
+
if direct_api_key:
|
|
69
|
+
api_key_value = direct_api_key
|
|
70
|
+
api_key_source = "env:KANTREE_API_KEY"
|
|
71
|
+
else:
|
|
72
|
+
api_key_value = os.getenv(api_key_env)
|
|
73
|
+
api_key_source = f"env:{api_key_env}"
|
|
74
|
+
|
|
75
|
+
default_organization = self.organization_override
|
|
76
|
+
default_organization_source = (
|
|
77
|
+
"flag:--org" if self.organization_override is not None else None
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
default_workspace: str | None = None
|
|
81
|
+
default_workspace_source: str | None = None
|
|
82
|
+
env_workspace_id = (os.getenv("KANTREE_WORKSPACE_ID") or "").strip()
|
|
83
|
+
env_workspace_name = (os.getenv("KANTREE_WORKSPACE") or "").strip()
|
|
84
|
+
if env_workspace_id:
|
|
85
|
+
default_workspace = env_workspace_id
|
|
86
|
+
default_workspace_source = "env:KANTREE_WORKSPACE_ID"
|
|
87
|
+
elif env_workspace_name:
|
|
88
|
+
default_workspace = env_workspace_name
|
|
89
|
+
default_workspace_source = "env:KANTREE_WORKSPACE"
|
|
90
|
+
elif profile_config and profile_config.default_workspace:
|
|
91
|
+
default_workspace = profile_config.default_workspace.strip() or None
|
|
92
|
+
if default_workspace:
|
|
93
|
+
default_workspace_source = f"profile:{profile_name}.default_workspace"
|
|
94
|
+
|
|
95
|
+
if self.workspace_id_override is not None:
|
|
96
|
+
default_workspace = str(self.workspace_id_override)
|
|
97
|
+
default_workspace_source = "flag:--workspace-id"
|
|
98
|
+
elif self.workspace_name_override is not None:
|
|
99
|
+
default_workspace = self.workspace_name_override
|
|
100
|
+
default_workspace_source = "flag:--workspace"
|
|
101
|
+
|
|
102
|
+
return ResolvedSettings(
|
|
103
|
+
profile_name=profile_name,
|
|
104
|
+
base_url=base_url,
|
|
105
|
+
api_key_env=api_key_env,
|
|
106
|
+
api_key_value=api_key_value,
|
|
107
|
+
api_key_source=api_key_source,
|
|
108
|
+
default_organization=default_organization,
|
|
109
|
+
default_organization_source=default_organization_source,
|
|
110
|
+
default_workspace=default_workspace,
|
|
111
|
+
default_workspace_source=default_workspace_source,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_runtime(
|
|
116
|
+
profile_override: str | None,
|
|
117
|
+
base_url_override: str | None,
|
|
118
|
+
organization_override: str | None,
|
|
119
|
+
workspace_id_override: int | None,
|
|
120
|
+
workspace_name_override: str | None,
|
|
121
|
+
output_format: str,
|
|
122
|
+
output_fields: list[str] | None,
|
|
123
|
+
verbose: bool,
|
|
124
|
+
assume_yes: bool,
|
|
125
|
+
debug: bool,
|
|
126
|
+
) -> AppRuntime:
|
|
127
|
+
path = config_path()
|
|
128
|
+
return AppRuntime(
|
|
129
|
+
config_path=path,
|
|
130
|
+
config=load_config(path),
|
|
131
|
+
profile_override=profile_override,
|
|
132
|
+
base_url_override=base_url_override,
|
|
133
|
+
organization_override=organization_override,
|
|
134
|
+
workspace_id_override=workspace_id_override,
|
|
135
|
+
workspace_name_override=workspace_name_override,
|
|
136
|
+
output_format=output_format.lower(),
|
|
137
|
+
output_fields=output_fields,
|
|
138
|
+
verbose=verbose,
|
|
139
|
+
assume_yes=assume_yes,
|
|
140
|
+
debug=debug,
|
|
141
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class KantreeError(Exception):
|
|
2
|
+
"""Base class for normalized CLI errors with explicit exit codes."""
|
|
3
|
+
|
|
4
|
+
exit_code = 1
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthError(KantreeError):
|
|
8
|
+
exit_code = 3
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotFoundError(KantreeError):
|
|
12
|
+
exit_code = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AmbiguityError(KantreeError):
|
|
16
|
+
exit_code = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationError(KantreeError):
|
|
20
|
+
exit_code = 6
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(KantreeError):
|
|
24
|
+
exit_code = 7
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NetworkError(KantreeError):
|
|
28
|
+
exit_code = 8
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PreconditionError(KantreeError):
|
|
32
|
+
exit_code = 12
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Iterable
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def emit_json(payload: Any) -> None:
|
|
8
|
+
click.echo(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit_ids(values: Iterable[object]) -> None:
|
|
12
|
+
for value in values:
|
|
13
|
+
click.echo(str(value))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def emit_ndjson(values: Iterable[Any]) -> None:
|
|
17
|
+
for value in values:
|
|
18
|
+
click.echo(json.dumps(value, ensure_ascii=False, sort_keys=True))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def value_for_field(item: dict[str, Any], field: str) -> Any:
|
|
22
|
+
current: Any = item
|
|
23
|
+
for token in field.split("."):
|
|
24
|
+
if not isinstance(current, dict):
|
|
25
|
+
return None
|
|
26
|
+
current = current.get(token)
|
|
27
|
+
return current
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit_tsv(items: list[dict[str, Any]], fields: list[str]) -> None:
|
|
31
|
+
click.echo("\t".join(fields))
|
|
32
|
+
for item in items:
|
|
33
|
+
row = [_stringify(value_for_field(item, field)) for field in fields]
|
|
34
|
+
click.echo("\t".join(row))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def emit_table(items: list[dict[str, Any]], fields: list[str]) -> None:
|
|
38
|
+
rows = [[_stringify(value_for_field(item, field)) for field in fields] for item in items]
|
|
39
|
+
widths = [len(field) for field in fields]
|
|
40
|
+
for row in rows:
|
|
41
|
+
for index, cell in enumerate(row):
|
|
42
|
+
widths[index] = max(widths[index], len(cell))
|
|
43
|
+
|
|
44
|
+
header = " | ".join(field.ljust(widths[index]) for index, field in enumerate(fields))
|
|
45
|
+
separator = "-+-".join("-" * width for width in widths)
|
|
46
|
+
click.echo(header)
|
|
47
|
+
click.echo(separator)
|
|
48
|
+
for row in rows:
|
|
49
|
+
click.echo(" | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stringify(value: Any) -> str:
|
|
53
|
+
if value is None:
|
|
54
|
+
return ""
|
|
55
|
+
if isinstance(value, bool):
|
|
56
|
+
return "true" if value else "false"
|
|
57
|
+
if isinstance(value, (int, float, str)):
|
|
58
|
+
return str(value)
|
|
59
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def emit_error(message: str, *, exit_code: int) -> None:
|
|
63
|
+
click.echo(f"Error: {message}", err=True)
|
|
64
|
+
raise click.exceptions.Exit(exit_code)
|