quaestor-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.
- quaestor_cli/__init__.py +7 -0
- quaestor_cli/api.py +224 -0
- quaestor_cli/client.py +187 -0
- quaestor_cli/commands/__init__.py +1 -0
- quaestor_cli/commands/auth.py +152 -0
- quaestor_cli/commands/doctor.py +147 -0
- quaestor_cli/commands/events.py +85 -0
- quaestor_cli/commands/log.py +245 -0
- quaestor_cli/commands/review.py +64 -0
- quaestor_cli/commands/show.py +121 -0
- quaestor_cli/commands/streaks.py +37 -0
- quaestor_cli/commands/timer.py +104 -0
- quaestor_cli/config.py +182 -0
- quaestor_cli/errors.py +52 -0
- quaestor_cli/main.py +57 -0
- quaestor_cli/models.py +318 -0
- quaestor_cli/output.py +92 -0
- quaestor_cli/parse.py +116 -0
- quaestor_cli/resolve.py +65 -0
- quaestor_cli/runtime.py +88 -0
- quaestor_cli/window.py +93 -0
- quaestor_cli-0.1.0.dist-info/METADATA +7 -0
- quaestor_cli-0.1.0.dist-info/RECORD +25 -0
- quaestor_cli-0.1.0.dist-info/WHEEL +4 -0
- quaestor_cli-0.1.0.dist-info/entry_points.txt +2 -0
quaestor_cli/__init__.py
ADDED
quaestor_cli/api.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Endpoint-level helpers shared by commands.
|
|
2
|
+
|
|
3
|
+
Keeping the URL shapes in one module means a contract change is a one-file edit,
|
|
4
|
+
and commands stay about presentation and validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from quaestor_cli.client import ApiResponse, Probe, QuaestorClient
|
|
15
|
+
from quaestor_cli.models import DailyStat, Event, Identity, Streak, StreakReview, TimerSnapshot
|
|
16
|
+
from quaestor_cli.window import DateWindow
|
|
17
|
+
|
|
18
|
+
WHOAMI_PATH = "/auth/whoami"
|
|
19
|
+
STREAKS_PATH = "/streaks"
|
|
20
|
+
STREAKS_WITH_STATS_PATH = "/streaks/with-stats"
|
|
21
|
+
EVENTS_PATH = "/events"
|
|
22
|
+
STATS_DAILY_PATH = "/stats/daily"
|
|
23
|
+
TIMERS_PATH = "/timers"
|
|
24
|
+
TIMERS_STOP_PATH = "/timers/stop"
|
|
25
|
+
TIMERS_ACTIVE_PATH = "/timers/active"
|
|
26
|
+
HEALTHZ_PATH = "/healthz"
|
|
27
|
+
|
|
28
|
+
# The server caps this at 200; the CLI always wants the full set for name matching.
|
|
29
|
+
MAX_STREAKS = 200
|
|
30
|
+
# The server's own maximum, passed explicitly rather than relying on its default of 20.
|
|
31
|
+
EVENTS_FETCH_LIMIT = 1000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class IdentityLookup:
|
|
36
|
+
"""Outcome of `GET /auth/whoami`, including the "server is too old" case."""
|
|
37
|
+
|
|
38
|
+
probe: Probe
|
|
39
|
+
identity: Identity | None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_success(self) -> bool:
|
|
43
|
+
"""True when the identity and its scopes were read."""
|
|
44
|
+
return self.identity is not None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_unsupported(self) -> bool:
|
|
48
|
+
"""True when the endpoint is absent, so scopes are simply unknowable."""
|
|
49
|
+
return self.probe.is_unsupported
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_identity(client: QuaestorClient) -> IdentityLookup:
|
|
53
|
+
"""Read the caller's identity and scopes, returning failures as data.
|
|
54
|
+
|
|
55
|
+
Unscoped on the server, so it doubles as the cheapest "is my credential
|
|
56
|
+
accepted?" check.
|
|
57
|
+
"""
|
|
58
|
+
probe = client.probe(WHOAMI_PATH)
|
|
59
|
+
payload = probe.payload if isinstance(probe.payload, Mapping) else None
|
|
60
|
+
identity = Identity.from_payload(payload) if probe.is_success and payload else None
|
|
61
|
+
return IdentityLookup(probe=probe, identity=identity)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_streaks(client: QuaestorClient, *, only_active: bool = True) -> list[Streak]:
|
|
65
|
+
"""Fetch the streaks used for name resolution and the `streaks` listing."""
|
|
66
|
+
response = client.get(STREAKS_PATH, {"limit": MAX_STREAKS, "only_active": only_active})
|
|
67
|
+
return Streak.from_list(response.payload)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class StreakDetail:
|
|
72
|
+
"""One streak's full metadata plus the raw payload for --json."""
|
|
73
|
+
|
|
74
|
+
streak: Streak
|
|
75
|
+
raw: Any
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_streak(client: QuaestorClient, streak_id: str) -> StreakDetail:
|
|
79
|
+
"""Fetch a single streak's full record."""
|
|
80
|
+
response = client.get(f"{STREAKS_PATH}/{streak_id}")
|
|
81
|
+
payload = response.payload if isinstance(response.payload, Mapping) else {}
|
|
82
|
+
return StreakDetail(streak=Streak.from_payload(payload), raw=response.payload)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class CreatedEvent:
|
|
87
|
+
"""A created (or deduplicated) event plus the raw payload for --json."""
|
|
88
|
+
|
|
89
|
+
event: Event
|
|
90
|
+
raw: Any
|
|
91
|
+
already_logged: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_event(client: QuaestorClient, body: Mapping[str, Any]) -> CreatedEvent:
|
|
95
|
+
"""POST an event; a 200 instead of 201 means the idempotency key already existed."""
|
|
96
|
+
response = client.post(EVENTS_PATH, body)
|
|
97
|
+
payload = response.payload if isinstance(response.payload, Mapping) else {}
|
|
98
|
+
return CreatedEvent(
|
|
99
|
+
event=Event.from_payload(payload),
|
|
100
|
+
raw=response.payload,
|
|
101
|
+
already_logged=response.status_code == 200,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class EventPage:
|
|
107
|
+
"""Events for a window, plus the raw payload and whether the fetch capped out."""
|
|
108
|
+
|
|
109
|
+
events: list[Event]
|
|
110
|
+
raw: Any
|
|
111
|
+
limit_reached: bool
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def count(self) -> int:
|
|
115
|
+
"""How many events came back."""
|
|
116
|
+
return len(self.events)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_events(client: QuaestorClient, *, streak_id: str, window: DateWindow) -> EventPage:
|
|
120
|
+
"""Fetch a streak's events in a window, newest event date first."""
|
|
121
|
+
response = client.get(
|
|
122
|
+
EVENTS_PATH,
|
|
123
|
+
{
|
|
124
|
+
"streak_id": streak_id,
|
|
125
|
+
"date_from": window.date_from.isoformat(),
|
|
126
|
+
"date_to": window.date_to.isoformat(),
|
|
127
|
+
# The server sorts this descending, so the newest events survive truncation.
|
|
128
|
+
"sort_by": "event_date",
|
|
129
|
+
"limit": EVENTS_FETCH_LIMIT,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
events = Event.from_list(response.payload)
|
|
133
|
+
return EventPage(events=events, raw=response.payload, limit_reached=len(events) >= EVENTS_FETCH_LIMIT)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class DailySeries:
|
|
138
|
+
"""Per-day rows for a window, plus the raw payload for --json."""
|
|
139
|
+
|
|
140
|
+
rows: list[DailyStat]
|
|
141
|
+
raw: Any
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def active_days(self) -> int:
|
|
145
|
+
"""Days with at least one event."""
|
|
146
|
+
return sum(1 for row in self.rows if row.is_active)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def total(self) -> float:
|
|
150
|
+
"""Window total in the streak's own unit (seconds for TIME)."""
|
|
151
|
+
return sum(row.total for row in self.rows)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def daily_stats(client: QuaestorClient, *, streak_id: str, window: DateWindow) -> DailySeries:
|
|
155
|
+
"""Fetch zero-filled per-day totals, so missed days are visible."""
|
|
156
|
+
response = client.get(
|
|
157
|
+
STATS_DAILY_PATH,
|
|
158
|
+
{
|
|
159
|
+
"streak_id": streak_id,
|
|
160
|
+
"date_from": window.date_from.isoformat(),
|
|
161
|
+
"date_to": window.date_to.isoformat(),
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
return DailySeries(rows=DailyStat.from_list(response.payload), raw=response.payload)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class TimerResult:
|
|
169
|
+
"""A timer operation's snapshot plus the raw payload for --json."""
|
|
170
|
+
|
|
171
|
+
snapshot: TimerSnapshot
|
|
172
|
+
raw: Any
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def start_timer(client: QuaestorClient, *, streak_id: str, now_local: datetime) -> TimerResult:
|
|
176
|
+
"""Start a timer that begins recording at `now_local`."""
|
|
177
|
+
body = {
|
|
178
|
+
"streak_id": streak_id,
|
|
179
|
+
"started_at_local": now_local.isoformat(),
|
|
180
|
+
"now_local": now_local.isoformat(),
|
|
181
|
+
}
|
|
182
|
+
response = client.post(TIMERS_PATH, body)
|
|
183
|
+
return TimerResult(snapshot=_snapshot(response), raw=response.payload)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stop_timer(client: QuaestorClient, *, now_local: datetime) -> TimerResult:
|
|
187
|
+
"""Finalise the running block at `now_local`."""
|
|
188
|
+
response = client.post(TIMERS_STOP_PATH, {"now_local": now_local.isoformat()})
|
|
189
|
+
payload = response.payload if isinstance(response.payload, Mapping) else {}
|
|
190
|
+
return TimerResult(snapshot=TimerSnapshot.from_stop_payload(payload), raw=response.payload)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def active_timer(client: QuaestorClient) -> TimerResult:
|
|
194
|
+
"""Read the currently running timer, if any."""
|
|
195
|
+
response = client.get(TIMERS_ACTIVE_PATH)
|
|
196
|
+
return TimerResult(snapshot=_snapshot(response), raw=response.payload)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class ReviewResult:
|
|
201
|
+
"""Review rows plus the raw payload for --json."""
|
|
202
|
+
|
|
203
|
+
rows: list[StreakReview]
|
|
204
|
+
raw: Any
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def review(client: QuaestorClient, window: DateWindow, *, today: date) -> ReviewResult:
|
|
208
|
+
"""Fetch streaks with stats for the window, using the caller's local today."""
|
|
209
|
+
response = client.get(
|
|
210
|
+
STREAKS_WITH_STATS_PATH,
|
|
211
|
+
{
|
|
212
|
+
"date_from": window.date_from.isoformat(),
|
|
213
|
+
"date_to": window.date_to.isoformat(),
|
|
214
|
+
"today": today.isoformat(),
|
|
215
|
+
"only_active": True,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
return ReviewResult(rows=StreakReview.from_list(response.payload), raw=response.payload)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _snapshot(response: ApiResponse) -> TimerSnapshot:
|
|
222
|
+
"""Read a TimerStateOut body into a snapshot, tolerating an empty body."""
|
|
223
|
+
payload = response.payload if isinstance(response.payload, Mapping) else {}
|
|
224
|
+
return TimerSnapshot.from_payload(payload)
|
quaestor_cli/client.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""HTTP access to the Quaestor API.
|
|
2
|
+
|
|
3
|
+
`httpx` is imported inside the request method on purpose: importing it at module
|
|
4
|
+
level adds ~18ms to every invocation, including `qst --help`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from quaestor_cli.config import assert_safe_for_credential
|
|
14
|
+
from quaestor_cli.errors import CliError, ExitCode
|
|
15
|
+
|
|
16
|
+
API_PREFIX = "/api/v1"
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
|
18
|
+
|
|
19
|
+
# Maps an HTTP failure onto the exit code an agent should act on.
|
|
20
|
+
_STATUS_EXIT_CODES: dict[int, ExitCode] = {
|
|
21
|
+
400: ExitCode.USAGE,
|
|
22
|
+
401: ExitCode.AUTH,
|
|
23
|
+
403: ExitCode.AUTH,
|
|
24
|
+
404: ExitCode.NOT_FOUND,
|
|
25
|
+
422: ExitCode.USAGE,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ApiResponse:
|
|
31
|
+
"""A decoded API response. `payload` is None for empty bodies."""
|
|
32
|
+
|
|
33
|
+
status_code: int
|
|
34
|
+
payload: Any
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_success(self) -> bool:
|
|
38
|
+
"""True for any 2xx status."""
|
|
39
|
+
return 200 <= self.status_code < 300
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def detail(self) -> str:
|
|
43
|
+
"""The server's `detail` string, falling back to a generic message."""
|
|
44
|
+
if isinstance(self.payload, Mapping):
|
|
45
|
+
detail = self.payload.get("detail")
|
|
46
|
+
if isinstance(detail, str) and detail:
|
|
47
|
+
return detail
|
|
48
|
+
if isinstance(self.payload, str) and self.payload:
|
|
49
|
+
return self.payload
|
|
50
|
+
return f"HTTP {self.status_code}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Probe:
|
|
55
|
+
"""A health check that never raises: transport failures are returned as data."""
|
|
56
|
+
|
|
57
|
+
reachable: bool
|
|
58
|
+
status_code: int | None
|
|
59
|
+
detail: str
|
|
60
|
+
payload: Any = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_success(self) -> bool:
|
|
64
|
+
"""True when the server answered with a 2xx."""
|
|
65
|
+
return self.reachable and self.status_code is not None and 200 <= self.status_code < 300
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_unsupported(self) -> bool:
|
|
69
|
+
"""True when the endpoint does not exist, i.e. the server predates it."""
|
|
70
|
+
return self.status_code == 404
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def rejected_credential(self) -> bool:
|
|
74
|
+
"""True when the server refused the credential or its scopes."""
|
|
75
|
+
return self.status_code in (401, 403)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class QuaestorClient:
|
|
79
|
+
"""Thin authenticated wrapper over the Quaestor REST API."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
base_url: str,
|
|
84
|
+
api_key: str | None = None,
|
|
85
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.base_url = base_url.rstrip("/")
|
|
88
|
+
if api_key is not None:
|
|
89
|
+
assert_safe_for_credential(self.base_url)
|
|
90
|
+
self._api_key = api_key
|
|
91
|
+
self._timeout = timeout
|
|
92
|
+
|
|
93
|
+
def url_for(self, path: str) -> str:
|
|
94
|
+
"""Absolute URL for an API path such as `/streaks`."""
|
|
95
|
+
return f"{self.base_url}{API_PREFIX}{path}"
|
|
96
|
+
|
|
97
|
+
def get(
|
|
98
|
+
self,
|
|
99
|
+
path: str,
|
|
100
|
+
params: Mapping[str, Any] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
check: bool = True,
|
|
103
|
+
) -> ApiResponse:
|
|
104
|
+
"""GET an API path; `check=False` returns failures instead of raising."""
|
|
105
|
+
return self.request("GET", path, params=params, check=check)
|
|
106
|
+
|
|
107
|
+
def post(
|
|
108
|
+
self,
|
|
109
|
+
path: str,
|
|
110
|
+
body: Mapping[str, Any] | None = None,
|
|
111
|
+
*,
|
|
112
|
+
check: bool = True,
|
|
113
|
+
) -> ApiResponse:
|
|
114
|
+
"""POST a JSON body to an API path."""
|
|
115
|
+
return self.request("POST", path, body=body, check=check)
|
|
116
|
+
|
|
117
|
+
def probe(self, path: str, params: Mapping[str, Any] | None = None) -> Probe:
|
|
118
|
+
"""GET a path for diagnostics, reporting any failure instead of raising."""
|
|
119
|
+
try:
|
|
120
|
+
response = self.request("GET", path, params=params, check=False)
|
|
121
|
+
except CliError as exc:
|
|
122
|
+
return Probe(reachable=False, status_code=None, detail=exc.message)
|
|
123
|
+
return Probe(
|
|
124
|
+
reachable=True,
|
|
125
|
+
status_code=response.status_code,
|
|
126
|
+
detail="ok" if response.is_success else response.detail,
|
|
127
|
+
payload=response.payload,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def request(
|
|
131
|
+
self,
|
|
132
|
+
method: str,
|
|
133
|
+
path: str,
|
|
134
|
+
*,
|
|
135
|
+
params: Mapping[str, Any] | None = None,
|
|
136
|
+
body: Mapping[str, Any] | None = None,
|
|
137
|
+
check: bool = True,
|
|
138
|
+
) -> ApiResponse:
|
|
139
|
+
"""Send one request, mapping transport and HTTP failures to CliError."""
|
|
140
|
+
import httpx # Lazy: keeps `qst --help` fast.
|
|
141
|
+
|
|
142
|
+
url = self.url_for(path)
|
|
143
|
+
try:
|
|
144
|
+
raw = httpx.request(
|
|
145
|
+
method,
|
|
146
|
+
url,
|
|
147
|
+
params=_clean_params(params),
|
|
148
|
+
json=dict(body) if body is not None else None,
|
|
149
|
+
headers=self._headers(),
|
|
150
|
+
timeout=self._timeout,
|
|
151
|
+
)
|
|
152
|
+
except httpx.HTTPError as exc:
|
|
153
|
+
raise CliError(f"Cannot reach {url}: {exc}", ExitCode.SERVER) from exc
|
|
154
|
+
|
|
155
|
+
response = ApiResponse(status_code=raw.status_code, payload=_decode(raw))
|
|
156
|
+
if check and not response.is_success:
|
|
157
|
+
raise CliError(response.detail, _exit_code_for(response.status_code))
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
def _headers(self) -> dict[str, str]:
|
|
161
|
+
"""Build request headers, including bearer auth when a key is present."""
|
|
162
|
+
headers = {"Accept": "application/json"}
|
|
163
|
+
if self._api_key:
|
|
164
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
165
|
+
return headers
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _exit_code_for(status_code: int) -> ExitCode:
|
|
169
|
+
"""Translate an HTTP status into the CLI's exit-code contract."""
|
|
170
|
+
return _STATUS_EXIT_CODES.get(status_code, ExitCode.SERVER)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _decode(raw: Any) -> Any:
|
|
174
|
+
"""Decode a JSON body, falling back to text for non-JSON responses."""
|
|
175
|
+
if not raw.content:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return raw.json()
|
|
179
|
+
except ValueError:
|
|
180
|
+
return raw.text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _clean_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
184
|
+
"""Drop None-valued query params so the server applies its own defaults."""
|
|
185
|
+
if not params:
|
|
186
|
+
return None
|
|
187
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command implementations, one module per command group."""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`qst auth` — store, inspect and remove the API credential."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from quaestor_cli import api
|
|
10
|
+
from quaestor_cli.config import (
|
|
11
|
+
ENV_API_KEY,
|
|
12
|
+
Credential,
|
|
13
|
+
credentials_path,
|
|
14
|
+
delete_credential,
|
|
15
|
+
key_label,
|
|
16
|
+
save_credential,
|
|
17
|
+
)
|
|
18
|
+
from quaestor_cli.errors import CliError, ExitCode, usage_error
|
|
19
|
+
from quaestor_cli.output import emit, emit_json, format_scopes, warn
|
|
20
|
+
from quaestor_cli.runtime import CliState, JsonFlag, cli_state, execute
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Manage the stored API credential.", no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
_LEGACY_SERVER_NOTE = "unknown (this server has no /auth/whoami)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("login")
|
|
28
|
+
def login(ctx: typer.Context, json_output: JsonFlag = False) -> None:
|
|
29
|
+
"""Read an API key from stdin and store it, so it never lands in shell history."""
|
|
30
|
+
state = cli_state(ctx, json_output)
|
|
31
|
+
execute(lambda: _login(state))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("whoami")
|
|
35
|
+
def whoami(ctx: typer.Context, json_output: JsonFlag = False) -> None:
|
|
36
|
+
"""Show which credential is in use and whether the API accepts it."""
|
|
37
|
+
state = cli_state(ctx, json_output)
|
|
38
|
+
execute(lambda: _whoami(state))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("logout")
|
|
42
|
+
def logout(ctx: typer.Context, json_output: JsonFlag = False) -> None:
|
|
43
|
+
"""Delete the stored credential file."""
|
|
44
|
+
state = cli_state(ctx, json_output)
|
|
45
|
+
execute(lambda: _logout(state))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _login(state: CliState) -> None:
|
|
49
|
+
"""Persist the key piped on stdin with 0600 permissions."""
|
|
50
|
+
if sys.stdin.isatty():
|
|
51
|
+
warn("Paste the API key and press Enter:")
|
|
52
|
+
key = _read_key_from_stdin()
|
|
53
|
+
path = save_credential(key)
|
|
54
|
+
|
|
55
|
+
label = key_label(key)
|
|
56
|
+
if state.json_output:
|
|
57
|
+
emit_json({"stored": str(path), "key_label": label, "mode": "0600"})
|
|
58
|
+
return
|
|
59
|
+
emit(f"Stored credential {label} in {path} (mode 0600).")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _whoami(state: CliState) -> None:
|
|
63
|
+
"""Report the credential in use plus the identity and scopes the server grants."""
|
|
64
|
+
credential = state.config.require_credential()
|
|
65
|
+
lookup = api.fetch_identity(state.client())
|
|
66
|
+
if lookup.is_unsupported:
|
|
67
|
+
_whoami_without_scopes(state, credential)
|
|
68
|
+
return
|
|
69
|
+
identity = lookup.identity
|
|
70
|
+
if identity is None:
|
|
71
|
+
code = ExitCode.AUTH if lookup.probe.rejected_credential else ExitCode.SERVER
|
|
72
|
+
raise CliError(lookup.probe.detail, code)
|
|
73
|
+
|
|
74
|
+
if state.json_output:
|
|
75
|
+
emit_json(
|
|
76
|
+
{
|
|
77
|
+
"base_url": state.config.base_url,
|
|
78
|
+
"credential_source": credential.source,
|
|
79
|
+
"key_label": credential.label,
|
|
80
|
+
"authenticated": True,
|
|
81
|
+
"user": identity.auth_user_id,
|
|
82
|
+
"auth_method": identity.auth_method,
|
|
83
|
+
"api_key_id": identity.api_key_id,
|
|
84
|
+
"scopes": list(identity.scopes),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
emit(
|
|
89
|
+
f"base url: {state.config.base_url}",
|
|
90
|
+
f"credential: {credential.source} ({credential.label})",
|
|
91
|
+
"status: authenticated",
|
|
92
|
+
f"user: {identity.auth_user_id}",
|
|
93
|
+
f"method: {identity.auth_method}",
|
|
94
|
+
f"key id: {identity.api_key_id or '-'}",
|
|
95
|
+
f"scopes: {format_scopes(identity.scopes)}",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _whoami_without_scopes(state: CliState, credential: Credential) -> None:
|
|
100
|
+
"""Fall back to a plain authenticated read when the server has no whoami route."""
|
|
101
|
+
probe = state.client().probe(api.STREAKS_PATH, {"limit": 1})
|
|
102
|
+
if not probe.is_success:
|
|
103
|
+
code = ExitCode.AUTH if probe.rejected_credential else ExitCode.SERVER
|
|
104
|
+
raise CliError(probe.detail, code)
|
|
105
|
+
|
|
106
|
+
if state.json_output:
|
|
107
|
+
emit_json(
|
|
108
|
+
{
|
|
109
|
+
"base_url": state.config.base_url,
|
|
110
|
+
"credential_source": credential.source,
|
|
111
|
+
"key_label": credential.label,
|
|
112
|
+
"authenticated": True,
|
|
113
|
+
"user": None,
|
|
114
|
+
"auth_method": None,
|
|
115
|
+
"api_key_id": None,
|
|
116
|
+
"scopes": None,
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
emit(
|
|
121
|
+
f"base url: {state.config.base_url}",
|
|
122
|
+
f"credential: {credential.source} ({credential.label})",
|
|
123
|
+
"status: authenticated",
|
|
124
|
+
f"user: {_LEGACY_SERVER_NOTE}",
|
|
125
|
+
f"scopes: {_LEGACY_SERVER_NOTE}",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _logout(state: CliState) -> None:
|
|
130
|
+
"""Remove the credential file and warn if the environment still holds a key."""
|
|
131
|
+
path = credentials_path()
|
|
132
|
+
removed = delete_credential()
|
|
133
|
+
env_key_present = state.config.credential is not None and state.config.credential.source == "env"
|
|
134
|
+
|
|
135
|
+
if state.json_output:
|
|
136
|
+
emit_json({"removed": removed, "path": str(path), "env_key_still_set": env_key_present})
|
|
137
|
+
elif removed:
|
|
138
|
+
emit(f"Removed {path}.")
|
|
139
|
+
else:
|
|
140
|
+
emit(f"No stored credential at {path}.")
|
|
141
|
+
|
|
142
|
+
if env_key_present:
|
|
143
|
+
warn(f"{ENV_API_KEY} is still set in the environment and takes precedence.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_key_from_stdin() -> str:
|
|
147
|
+
"""Take the first non-empty line of stdin as the key."""
|
|
148
|
+
for line in sys.stdin.read().splitlines():
|
|
149
|
+
candidate = line.strip()
|
|
150
|
+
if candidate:
|
|
151
|
+
return candidate
|
|
152
|
+
raise usage_error("No API key on stdin. Pipe it in, e.g. 'pbpaste | qst auth login'.")
|