quaestor-cli 0.3.0__tar.gz → 0.4.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.
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/PKG-INFO +26 -8
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/README.md +25 -7
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/pyproject.toml +1 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/doctor.py +34 -9
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/log.py +4 -9
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quest.py +79 -2
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/config.py +19 -12
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/main.py +2 -1
- quaestor_cli-0.4.0/src/quaestor_cli/quest_links.py +20 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/quest_models.py +30 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/runtime.py +4 -7
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_auth.py +72 -2
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_log.py +88 -13
- quaestor_cli-0.4.0/tests/test_cli_quest_links.py +119 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_timer_review_doctor.py +68 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/uv.lock +1 -1
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/.gitignore +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/__init__.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/api.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/client.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/__init__.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/auth.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/events.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quest_format.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quests.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/review.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/show.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/streaks.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/timer.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/errors.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/models.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/output.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/parse.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/quest_api.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/resolve.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/window.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/conftest.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/factories.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_errors.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_events.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_quest_emoji.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_quests.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_review_fixes.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_show.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_parse.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_quest_parse_and_doctor.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_resolve.py +0 -0
- {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_window.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quaestor-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Command-line client for the Quaestor API — built for humans and agents.
|
|
5
5
|
Project-URL: Homepage, https://quaestor.app
|
|
6
6
|
Project-URL: Repository, https://github.com/neuromaxer/quaestor-lite
|
|
@@ -39,10 +39,9 @@ uv tool install quaestor-cli
|
|
|
39
39
|
## Setup
|
|
40
40
|
|
|
41
41
|
Create an API key in the Quaestor web app under **Profile → Developer Access**.
|
|
42
|
-
|
|
42
|
+
The CLI uses the hosted production API automatically. On macOS, copy the key and run:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
|
|
46
45
|
pbpaste | qst auth login
|
|
47
46
|
qst doctor
|
|
48
47
|
```
|
|
@@ -54,9 +53,9 @@ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
|
|
|
54
53
|
CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
|
|
55
54
|
precedence over the file, but it is not needed for this setup.
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
56
|
+
Logging in again replaces the key while preserving an existing server override.
|
|
57
|
+
`qst doctor` reports authentication as unknown when the server cannot verify it;
|
|
58
|
+
only an explicit authentication refusal means the key was rejected.
|
|
60
59
|
|
|
61
60
|
## The evening diary workflow
|
|
62
61
|
|
|
@@ -84,7 +83,7 @@ qst doctor Check URL, reachability, credential and granted
|
|
|
84
83
|
qst agenda [--week|--days N] Overdue, past deadline, ready to plan, and each day's quests
|
|
85
84
|
qst quests [words] [filters] Find quests: --status, --overdue, --on, --from/--to,
|
|
86
85
|
--deadline-by, --under <quest>, --archived, --sort, --limit
|
|
87
|
-
qst quest show <quest> Status, dates, sub-quest progress, description, latest Quest Log
|
|
86
|
+
qst quest show <quest> Status, dates, sub-quest progress, linked quests, its link, description, latest Quest Log
|
|
88
87
|
qst quest log <quest> Quest Log entries, newest first (--date for one day)
|
|
89
88
|
qst quest add <title> --under <quest> --on fri --due <date> --priority high --emoji 🚀 ...
|
|
90
89
|
qst quest edit <quest> --title --emoji --priority --start/--due (or 'none') --under/--top-level
|
|
@@ -96,10 +95,14 @@ qst quest reschedule <q> <from> [to] Move one planned day (to today by default
|
|
|
96
95
|
qst quest check <quest> Check off a day (--date, --undo); starts Backlog/Todo quests
|
|
97
96
|
qst quest note <quest> <text> Append to that day's Quest Log entry (--date, --replace)
|
|
98
97
|
qst quest link|unlink <quest> <streak> Link a streak (context only, nothing syncs)
|
|
98
|
+
qst quest link-quest|unlink-quest <quest> <other> Link two related quests (both list each other)
|
|
99
|
+
qst quest mention <quest> Markdown link for a note or description; the app shows it as a chip
|
|
99
100
|
```
|
|
100
101
|
|
|
101
102
|
Every command takes `--json` for machine-readable output on stdout. Every write takes
|
|
102
|
-
`--dry-run`, which prints the request and
|
|
103
|
+
`--dry-run`, which prints the request and writes nothing. `log` uses a read-only
|
|
104
|
+
streak lookup to validate the name, mode and `--unit` before printing the resolved
|
|
105
|
+
payload. A COUNT/TIME mismatch or wrong unit exits `1` without posting an event.
|
|
103
106
|
|
|
104
107
|
### Windows
|
|
105
108
|
|
|
@@ -219,3 +222,18 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
|
|
|
219
222
|
|
|
220
223
|
Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
|
|
221
224
|
Quaestor backend, so it installs in seconds and tolerates version skew against the server.
|
|
225
|
+
|
|
226
|
+
## Developer configuration
|
|
227
|
+
|
|
228
|
+
Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
|
|
229
|
+
The development flag is hidden from normal help and never persists a local URL.
|
|
230
|
+
|
|
231
|
+
Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
|
|
232
|
+
`base_url` in the credential store. Resolution order is `--dev`, environment, stored
|
|
233
|
+
URL, then the hosted production default. Ordinary hosted setup needs no override.
|
|
234
|
+
|
|
235
|
+
Quest links (`qst quest mention`, `show`) point at the web app: `https://quaestor.app` by
|
|
236
|
+
default, `http://localhost:3000` with `--dev`, or `QUAESTOR_APP_URL` for other deployments.
|
|
237
|
+
|
|
238
|
+
`qst` refuses to send a key over plain HTTP except to loopback. Set
|
|
239
|
+
`QUAESTOR_ALLOW_INSECURE=1` only for deliberate plaintext development deployments.
|
|
@@ -17,10 +17,9 @@ uv tool install quaestor-cli
|
|
|
17
17
|
## Setup
|
|
18
18
|
|
|
19
19
|
Create an API key in the Quaestor web app under **Profile → Developer Access**.
|
|
20
|
-
|
|
20
|
+
The CLI uses the hosted production API automatically. On macOS, copy the key and run:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
|
|
24
23
|
pbpaste | qst auth login
|
|
25
24
|
qst doctor
|
|
26
25
|
```
|
|
@@ -32,9 +31,9 @@ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
|
|
|
32
31
|
CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
|
|
33
32
|
precedence over the file, but it is not needed for this setup.
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
34
|
+
Logging in again replaces the key while preserving an existing server override.
|
|
35
|
+
`qst doctor` reports authentication as unknown when the server cannot verify it;
|
|
36
|
+
only an explicit authentication refusal means the key was rejected.
|
|
38
37
|
|
|
39
38
|
## The evening diary workflow
|
|
40
39
|
|
|
@@ -62,7 +61,7 @@ qst doctor Check URL, reachability, credential and granted
|
|
|
62
61
|
qst agenda [--week|--days N] Overdue, past deadline, ready to plan, and each day's quests
|
|
63
62
|
qst quests [words] [filters] Find quests: --status, --overdue, --on, --from/--to,
|
|
64
63
|
--deadline-by, --under <quest>, --archived, --sort, --limit
|
|
65
|
-
qst quest show <quest> Status, dates, sub-quest progress, description, latest Quest Log
|
|
64
|
+
qst quest show <quest> Status, dates, sub-quest progress, linked quests, its link, description, latest Quest Log
|
|
66
65
|
qst quest log <quest> Quest Log entries, newest first (--date for one day)
|
|
67
66
|
qst quest add <title> --under <quest> --on fri --due <date> --priority high --emoji 🚀 ...
|
|
68
67
|
qst quest edit <quest> --title --emoji --priority --start/--due (or 'none') --under/--top-level
|
|
@@ -74,10 +73,14 @@ qst quest reschedule <q> <from> [to] Move one planned day (to today by default
|
|
|
74
73
|
qst quest check <quest> Check off a day (--date, --undo); starts Backlog/Todo quests
|
|
75
74
|
qst quest note <quest> <text> Append to that day's Quest Log entry (--date, --replace)
|
|
76
75
|
qst quest link|unlink <quest> <streak> Link a streak (context only, nothing syncs)
|
|
76
|
+
qst quest link-quest|unlink-quest <quest> <other> Link two related quests (both list each other)
|
|
77
|
+
qst quest mention <quest> Markdown link for a note or description; the app shows it as a chip
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
Every command takes `--json` for machine-readable output on stdout. Every write takes
|
|
80
|
-
`--dry-run`, which prints the request and
|
|
81
|
+
`--dry-run`, which prints the request and writes nothing. `log` uses a read-only
|
|
82
|
+
streak lookup to validate the name, mode and `--unit` before printing the resolved
|
|
83
|
+
payload. A COUNT/TIME mismatch or wrong unit exits `1` without posting an event.
|
|
81
84
|
|
|
82
85
|
### Windows
|
|
83
86
|
|
|
@@ -197,3 +200,18 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
|
|
|
197
200
|
|
|
198
201
|
Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
|
|
199
202
|
Quaestor backend, so it installs in seconds and tolerates version skew against the server.
|
|
203
|
+
|
|
204
|
+
## Developer configuration
|
|
205
|
+
|
|
206
|
+
Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
|
|
207
|
+
The development flag is hidden from normal help and never persists a local URL.
|
|
208
|
+
|
|
209
|
+
Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
|
|
210
|
+
`base_url` in the credential store. Resolution order is `--dev`, environment, stored
|
|
211
|
+
URL, then the hosted production default. Ordinary hosted setup needs no override.
|
|
212
|
+
|
|
213
|
+
Quest links (`qst quest mention`, `show`) point at the web app: `https://quaestor.app` by
|
|
214
|
+
default, `http://localhost:3000` with `--dev`, or `QUAESTOR_APP_URL` for other deployments.
|
|
215
|
+
|
|
216
|
+
`qst` refuses to send a key over plain HTTP except to loopback. Set
|
|
217
|
+
`QUAESTOR_ALLOW_INSECURE=1` only for deliberate plaintext development deployments.
|
|
@@ -10,7 +10,7 @@ from quaestor_cli import __version__, api
|
|
|
10
10
|
from quaestor_cli.client import QuaestorClient
|
|
11
11
|
from quaestor_cli.config import ENV_API_KEY, credentials_path
|
|
12
12
|
from quaestor_cli.errors import CliError, ExitCode
|
|
13
|
-
from quaestor_cli.output import emit, emit_json, format_scopes
|
|
13
|
+
from quaestor_cli.output import emit, emit_json, format_scopes, warn
|
|
14
14
|
from quaestor_cli.runtime import CliState, JsonFlag, cli_state, execute
|
|
15
15
|
|
|
16
16
|
UNKNOWN = "unknown"
|
|
@@ -45,7 +45,7 @@ class Diagnosis:
|
|
|
45
45
|
credential_source: str
|
|
46
46
|
key_label: str
|
|
47
47
|
reachable: bool
|
|
48
|
-
authenticated: bool
|
|
48
|
+
authenticated: bool | None
|
|
49
49
|
scopes: list[str] | None
|
|
50
50
|
"""None when the server has no /auth/whoami, i.e. scopes are unknowable."""
|
|
51
51
|
user: str | None = None
|
|
@@ -58,6 +58,8 @@ class Diagnosis:
|
|
|
58
58
|
"""Worst problem found decides the exit code."""
|
|
59
59
|
if not self.reachable:
|
|
60
60
|
return ExitCode.SERVER
|
|
61
|
+
if self.authenticated is None:
|
|
62
|
+
return ExitCode.SERVER
|
|
61
63
|
if not self.authenticated:
|
|
62
64
|
return ExitCode.AUTH
|
|
63
65
|
return ExitCode.OK
|
|
@@ -77,7 +79,12 @@ def _run(state: CliState) -> None:
|
|
|
77
79
|
else:
|
|
78
80
|
emit(*_lines(diagnosis))
|
|
79
81
|
if diagnosis.exit_code is not ExitCode.OK:
|
|
80
|
-
|
|
82
|
+
detail = diagnosis.detail or "Health check failed."
|
|
83
|
+
if state.json_output:
|
|
84
|
+
# The diagnosis already includes the failure; keep stdout one JSON document.
|
|
85
|
+
warn(detail)
|
|
86
|
+
raise typer.Exit(code=int(diagnosis.exit_code))
|
|
87
|
+
raise CliError(detail, diagnosis.exit_code)
|
|
81
88
|
|
|
82
89
|
|
|
83
90
|
def diagnose(state: CliState) -> Diagnosis:
|
|
@@ -86,13 +93,14 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
86
93
|
anonymous = QuaestorClient(base_url=state.config.base_url)
|
|
87
94
|
health = anonymous.probe(api.HEALTHZ_PATH)
|
|
88
95
|
|
|
89
|
-
authenticated =
|
|
96
|
+
authenticated: bool | None = None
|
|
90
97
|
scopes: list[str] | None = None
|
|
91
98
|
user: str | None = None
|
|
92
99
|
detail: str | None = None
|
|
93
100
|
if not health.is_success:
|
|
94
101
|
detail = health.detail
|
|
95
102
|
elif credential is None:
|
|
103
|
+
authenticated = False
|
|
96
104
|
detail = f"No API key: set {ENV_API_KEY} or run 'qst auth login'."
|
|
97
105
|
else:
|
|
98
106
|
auth = _check_credential(state)
|
|
@@ -106,7 +114,7 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
106
114
|
base_url=state.config.base_url,
|
|
107
115
|
credential_source=credential.source if credential else "none",
|
|
108
116
|
key_label=credential.label if credential else "-",
|
|
109
|
-
reachable=health.
|
|
117
|
+
reachable=health.reachable,
|
|
110
118
|
authenticated=authenticated,
|
|
111
119
|
scopes=scopes,
|
|
112
120
|
user=user,
|
|
@@ -119,7 +127,7 @@ def diagnose(state: CliState) -> Diagnosis:
|
|
|
119
127
|
class _CredentialCheck:
|
|
120
128
|
"""What the identity lookup could establish about the credential."""
|
|
121
129
|
|
|
122
|
-
authenticated: bool
|
|
130
|
+
authenticated: bool | None
|
|
123
131
|
scopes: list[str] | None
|
|
124
132
|
user: str | None
|
|
125
133
|
detail: str | None
|
|
@@ -138,12 +146,17 @@ def _check_credential(state: CliState) -> _CredentialCheck:
|
|
|
138
146
|
detail=None,
|
|
139
147
|
)
|
|
140
148
|
if not lookup.is_unsupported:
|
|
141
|
-
return _CredentialCheck(
|
|
149
|
+
return _CredentialCheck(
|
|
150
|
+
authenticated=False if lookup.probe.rejected_credential else None,
|
|
151
|
+
scopes=None,
|
|
152
|
+
user=None,
|
|
153
|
+
detail=lookup.probe.detail,
|
|
154
|
+
)
|
|
142
155
|
|
|
143
156
|
# Server predates /auth/whoami: doctor must still say whether the key works.
|
|
144
157
|
fallback = client.probe(api.STREAKS_PATH, {"limit": 1})
|
|
145
158
|
return _CredentialCheck(
|
|
146
|
-
authenticated=fallback.is_success,
|
|
159
|
+
authenticated=True if fallback.is_success else False if fallback.rejected_credential else None,
|
|
147
160
|
scopes=None,
|
|
148
161
|
user=None,
|
|
149
162
|
detail=None if fallback.is_success else fallback.detail,
|
|
@@ -154,19 +167,31 @@ def _lines(diagnosis: Diagnosis) -> tuple[str, ...]:
|
|
|
154
167
|
"""Render the diagnosis as aligned human-readable lines."""
|
|
155
168
|
if diagnosis.scopes is not None:
|
|
156
169
|
scopes = format_scopes(diagnosis.scopes)
|
|
170
|
+
elif not diagnosis.reachable:
|
|
171
|
+
scopes = f"{UNKNOWN} (host unreachable)"
|
|
172
|
+
elif diagnosis.authenticated is None:
|
|
173
|
+
scopes = f"{UNKNOWN} (authentication check failed)"
|
|
174
|
+
elif diagnosis.credential_source == "none":
|
|
175
|
+
scopes = f"{UNKNOWN} (no API key)"
|
|
157
176
|
elif not diagnosis.authenticated:
|
|
158
177
|
# The credential was rejected, so scopes were never knowable. Blaming the
|
|
159
178
|
# server's age here points the reader at the wrong problem.
|
|
160
179
|
scopes = f"{UNKNOWN} (credential was rejected)"
|
|
161
180
|
else:
|
|
162
181
|
scopes = f"{UNKNOWN} (this server has no /auth/whoami)"
|
|
182
|
+
if not diagnosis.reachable:
|
|
183
|
+
authenticated = f"{UNKNOWN} (host unreachable)"
|
|
184
|
+
elif diagnosis.authenticated is None:
|
|
185
|
+
authenticated = f"{UNKNOWN} (authentication check failed)"
|
|
186
|
+
else:
|
|
187
|
+
authenticated = "yes" if diagnosis.authenticated else "no"
|
|
163
188
|
fields = (
|
|
164
189
|
("cli version", diagnosis.cli_version),
|
|
165
190
|
("base url", diagnosis.base_url),
|
|
166
191
|
("credential", f"{diagnosis.credential_source} ({diagnosis.key_label})"),
|
|
167
192
|
("store", str(credentials_path())),
|
|
168
193
|
("reachable", "yes" if diagnosis.reachable else "no"),
|
|
169
|
-
("authenticated",
|
|
194
|
+
("authenticated", authenticated),
|
|
170
195
|
("user", diagnosis.user or "-"),
|
|
171
196
|
("scopes", scopes),
|
|
172
197
|
)
|
|
@@ -186,21 +186,16 @@ def _window_from_duration(day: date, duration: timedelta, now: datetime) -> Time
|
|
|
186
186
|
|
|
187
187
|
def _run(state: CliState, *, streak_query: str, intent: LogIntent, unit: str | None) -> None:
|
|
188
188
|
"""Resolve the streak, validate it against the intent, then POST the event."""
|
|
189
|
-
if state.dry_run:
|
|
190
|
-
report_dry_run(
|
|
191
|
-
state,
|
|
192
|
-
path=api.EVENTS_PATH,
|
|
193
|
-
body=intent.to_body(streak_id=None, idempotency_key=state.idempotency_key),
|
|
194
|
-
unresolved_streak=streak_query,
|
|
195
|
-
)
|
|
196
|
-
return
|
|
197
|
-
|
|
198
189
|
client = state.client()
|
|
199
190
|
match = resolve_streak(api.list_streaks(client), streak_query)
|
|
200
191
|
_check_mode(match.streak, intent)
|
|
201
192
|
_check_unit(match.streak, unit)
|
|
202
193
|
|
|
203
194
|
body = intent.to_body(streak_id=match.streak.id, idempotency_key=state.idempotency_key)
|
|
195
|
+
if state.dry_run:
|
|
196
|
+
report_dry_run(state, path=api.EVENTS_PATH, body=body)
|
|
197
|
+
return
|
|
198
|
+
|
|
204
199
|
created = api.create_event(client, body)
|
|
205
200
|
|
|
206
201
|
if state.json_output:
|
|
@@ -22,6 +22,7 @@ from quaestor_cli.client import QuaestorClient
|
|
|
22
22
|
from quaestor_cli.commands.quest_format import fmt_day, named, priority, progress, timeline
|
|
23
23
|
from quaestor_cli.errors import CliError, ExitCode, usage_error
|
|
24
24
|
from quaestor_cli.output import emit, emit_json
|
|
25
|
+
from quaestor_cli.quest_links import mention_markdown, quest_url
|
|
25
26
|
from quaestor_cli.quest_models import PRIORITY_LABEL, STATUS_LABEL, Quest
|
|
26
27
|
from quaestor_cli.resolve import resolve_streak
|
|
27
28
|
from quaestor_cli.runtime import (
|
|
@@ -163,8 +164,9 @@ def show_command(ctx: typer.Context, quest: QuestArg, json_output: JsonFlag = Fa
|
|
|
163
164
|
client = state.client()
|
|
164
165
|
today = date.today()
|
|
165
166
|
detail, raw = quest_api.get_detail(client, _resolve(client, quest).id, today=today)
|
|
167
|
+
url = quest_url(state.config.app_url, detail.quest.id)
|
|
166
168
|
if state.json_output:
|
|
167
|
-
emit_json(raw)
|
|
169
|
+
emit_json({**raw, "url": url} if isinstance(raw, dict) else raw)
|
|
168
170
|
return
|
|
169
171
|
q = detail.quest
|
|
170
172
|
dates = ", ".join(f"{fmt_day(t.day, today)} {_state_label(t, today)}" for t in q.todo_dates)
|
|
@@ -177,6 +179,8 @@ def show_command(ctx: typer.Context, quest: QuestArg, json_output: JsonFlag = Fa
|
|
|
177
179
|
f" todo dates: {dates or '-'}",
|
|
178
180
|
f" sub-quests: {f'{progress(q)} done' if q.progress_total else 'none'}",
|
|
179
181
|
f" streaks: {', '.join(streak_names) if streak_names else 'none linked'}",
|
|
182
|
+
f" linked: {'; '.join(lq.full_path for lq in detail.linked) if detail.linked else 'none'}",
|
|
183
|
+
f" link: {url}",
|
|
180
184
|
*[f" {line}" for line in _hidden_note(q)],
|
|
181
185
|
]
|
|
182
186
|
if detail.description.strip():
|
|
@@ -681,7 +685,7 @@ def link_command(
|
|
|
681
685
|
json_output: JsonFlag = False,
|
|
682
686
|
dry_run: DryRunFlag = False,
|
|
683
687
|
) -> None:
|
|
684
|
-
"""Link a streak to a quest (context only: nothing is logged or synced)."""
|
|
688
|
+
"""Link a streak to a quest (context only: nothing is logged or synced). For quests, see link-quest."""
|
|
685
689
|
state = cli_state(ctx, json_output, dry_run)
|
|
686
690
|
execute(lambda: _link(state, quest, streak, unlink=False))
|
|
687
691
|
|
|
@@ -697,3 +701,76 @@ def unlink_command(
|
|
|
697
701
|
"""Remove a streak link from a quest (a no-op if it isn't linked)."""
|
|
698
702
|
state = cli_state(ctx, json_output, dry_run)
|
|
699
703
|
execute(lambda: _link(state, quest, streak, unlink=True))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
# Quest links (symmetric: each quest lists the other) and mentions
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _link_quest(state: CliState, quest: str, other: str, *, unlink: bool) -> None:
|
|
712
|
+
client = state.client()
|
|
713
|
+
target = _resolve(client, quest)
|
|
714
|
+
match = _resolve(client, other)
|
|
715
|
+
if match.id == target.id:
|
|
716
|
+
raise usage_error(f"{target.full_path} can't be linked to itself.")
|
|
717
|
+
linked = match.id in target.linked_quest_ids
|
|
718
|
+
if linked != unlink:
|
|
719
|
+
# Already in the requested state: nothing to send, and not an error for an agent.
|
|
720
|
+
state_word = "is already linked to" if linked else "wasn't linked to"
|
|
721
|
+
if state.json_output:
|
|
722
|
+
emit_json({"changed": False, "quest": target.full_path, "linked_quest": match.full_path})
|
|
723
|
+
else:
|
|
724
|
+
emit(f"{target.full_path} {state_word} {match.full_path}; nothing changed.")
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
def describe(result: quest_api.WriteResult) -> list[str]:
|
|
728
|
+
verb, joiner = ("Unlinked", "from") if unlink else ("Linked", "to")
|
|
729
|
+
return [f"{verb} {target.full_path} {joiner} {match.full_path}."]
|
|
730
|
+
|
|
731
|
+
step = Step("DELETE" if unlink else "PUT", quest_api.quest_path(target.id, "links", match.id))
|
|
732
|
+
resolved = {"quest": target.full_path, "linked_quest": match.full_path}
|
|
733
|
+
_run(state, Plan([step], describe, target=target, resolved=resolved))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@app.command("link-quest")
|
|
737
|
+
def link_quest_command(
|
|
738
|
+
ctx: typer.Context,
|
|
739
|
+
quest: QuestArg,
|
|
740
|
+
other: Annotated[str, typer.Argument(help="The quest to link (name or path).")],
|
|
741
|
+
json_output: JsonFlag = False,
|
|
742
|
+
dry_run: DryRunFlag = False,
|
|
743
|
+
) -> None:
|
|
744
|
+
"""Link two quests as related; both then list each other (navigation only, nothing syncs)."""
|
|
745
|
+
state = cli_state(ctx, json_output, dry_run)
|
|
746
|
+
execute(lambda: _link_quest(state, quest, other, unlink=False))
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@app.command("unlink-quest")
|
|
750
|
+
def unlink_quest_command(
|
|
751
|
+
ctx: typer.Context,
|
|
752
|
+
quest: QuestArg,
|
|
753
|
+
other: Annotated[str, typer.Argument(help="The linked quest (name or path).")],
|
|
754
|
+
json_output: JsonFlag = False,
|
|
755
|
+
dry_run: DryRunFlag = False,
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Remove the link between two quests, from both sides (a no-op if they aren't linked)."""
|
|
758
|
+
state = cli_state(ctx, json_output, dry_run)
|
|
759
|
+
execute(lambda: _link_quest(state, quest, other, unlink=True))
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@app.command("mention")
|
|
763
|
+
def mention_command(ctx: typer.Context, quest: QuestArg, json_output: JsonFlag = False) -> None:
|
|
764
|
+
"""Print a Markdown link to a quest. Put it in a note or description: the app shows it as a quest chip."""
|
|
765
|
+
state = cli_state(ctx, json_output)
|
|
766
|
+
|
|
767
|
+
def run() -> None:
|
|
768
|
+
target = _resolve(state.client(), quest)
|
|
769
|
+
url = quest_url(state.config.app_url, target.id)
|
|
770
|
+
markdown = mention_markdown(state.config.app_url, target.id, target.label)
|
|
771
|
+
if state.json_output:
|
|
772
|
+
emit_json({"quest": target.full_path, "id": target.id, "url": url, "markdown": markdown})
|
|
773
|
+
else:
|
|
774
|
+
emit(markdown)
|
|
775
|
+
|
|
776
|
+
execute(run)
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
The environment is the primary mechanism (`QUAESTOR_API_KEY`, `QUAESTOR_URL`);
|
|
4
|
-
`~/.config/quaestor/credentials.json` is the fallback store written by
|
|
5
|
-
`qst auth login`. The environment always wins over the file.
|
|
6
|
-
"""
|
|
1
|
+
"""Resolve credentials and developer URL overrides, defaulting to hosted Quaestor."""
|
|
7
2
|
|
|
8
3
|
from __future__ import annotations
|
|
9
4
|
|
|
@@ -16,9 +11,14 @@ from urllib.parse import urlparse
|
|
|
16
11
|
|
|
17
12
|
from quaestor_cli.errors import auth_error, server_error, usage_error
|
|
18
13
|
|
|
19
|
-
DEFAULT_BASE_URL = "
|
|
14
|
+
DEFAULT_BASE_URL = "https://ql-backend-fniqyq6lfa-ey.a.run.app"
|
|
15
|
+
LOCAL_BASE_URL = "http://localhost:8000"
|
|
16
|
+
# The web app, for quest links (`<app>/home?tab=quests&quest=<id>`) that it shows as chips.
|
|
17
|
+
DEFAULT_APP_URL = "https://quaestor.app"
|
|
18
|
+
LOCAL_APP_URL = "http://localhost:3000"
|
|
20
19
|
ENV_API_KEY = "QUAESTOR_API_KEY"
|
|
21
20
|
ENV_BASE_URL = "QUAESTOR_URL"
|
|
21
|
+
ENV_APP_URL = "QUAESTOR_APP_URL"
|
|
22
22
|
|
|
23
23
|
_API_KEY_PREFIX = "qst_live_"
|
|
24
24
|
_CREDENTIALS_FILE_MODE = 0o600
|
|
@@ -48,6 +48,7 @@ class Config:
|
|
|
48
48
|
|
|
49
49
|
base_url: str
|
|
50
50
|
credential: Credential | None
|
|
51
|
+
app_url: str = DEFAULT_APP_URL
|
|
51
52
|
|
|
52
53
|
def require_credential(self) -> Credential:
|
|
53
54
|
"""Return the credential or fail with the auth exit code."""
|
|
@@ -79,8 +80,8 @@ def credentials_path() -> Path:
|
|
|
79
80
|
return config_dir() / "credentials.json"
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
def load_config() -> Config:
|
|
83
|
-
"""Resolve
|
|
83
|
+
def load_config(*, dev: bool = False) -> Config:
|
|
84
|
+
"""Resolve an explicit dev target, then environment, file, and hosted defaults."""
|
|
84
85
|
stored = _read_credentials_file()
|
|
85
86
|
env_key = os.environ.get(ENV_API_KEY, "").strip()
|
|
86
87
|
|
|
@@ -91,8 +92,10 @@ def load_config() -> Config:
|
|
|
91
92
|
credential = Credential(api_key=stored.api_key, source="file")
|
|
92
93
|
|
|
93
94
|
env_url = os.environ.get(ENV_BASE_URL, "").strip()
|
|
94
|
-
base_url = (env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
95
|
-
|
|
95
|
+
base_url = (LOCAL_BASE_URL if dev else env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
96
|
+
env_app_url = os.environ.get(ENV_APP_URL, "").strip()
|
|
97
|
+
app_url = (LOCAL_APP_URL if dev else env_app_url or DEFAULT_APP_URL).rstrip("/")
|
|
98
|
+
return Config(base_url=base_url, credential=credential, app_url=app_url)
|
|
96
99
|
|
|
97
100
|
|
|
98
101
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
@@ -157,7 +160,11 @@ def save_credential(api_key: str) -> Path:
|
|
|
157
160
|
os.chmod(directory, _CREDENTIALS_DIR_MODE)
|
|
158
161
|
|
|
159
162
|
path = credentials_path()
|
|
160
|
-
|
|
163
|
+
stored = _read_credentials_file()
|
|
164
|
+
contents = {"api_key": key}
|
|
165
|
+
if stored.base_url:
|
|
166
|
+
contents["base_url"] = stored.base_url
|
|
167
|
+
payload = json.dumps(contents, indent=2) + "\n"
|
|
161
168
|
# Open with the mode up front so the secret is never briefly world-readable,
|
|
162
169
|
# then chmod as well for the case where the file already existed.
|
|
163
170
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _CREDENTIALS_FILE_MODE)
|
|
@@ -47,12 +47,13 @@ def main(
|
|
|
47
47
|
help="Reuse this key when retrying a write (add, note, log) so it is applied once.",
|
|
48
48
|
),
|
|
49
49
|
version: bool = typer.Option(False, "--version", help="Print the CLI version and exit."),
|
|
50
|
+
dev: bool = typer.Option(False, "--dev", hidden=True, help="Use the local development API."),
|
|
50
51
|
) -> None:
|
|
51
52
|
"""Accept global flags before the subcommand, e.g. `qst --json streaks`."""
|
|
52
53
|
if version:
|
|
53
54
|
typer.echo(__version__)
|
|
54
55
|
raise typer.Exit()
|
|
55
|
-
ctx.obj = RootFlags(json_output=json_output, dry_run=dry_run, idempotency_key=idempotency_key)
|
|
56
|
+
ctx.obj = RootFlags(json_output=json_output, dry_run=dry_run, idempotency_key=idempotency_key, dev=dev)
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
def _with_window_rules(summary: str) -> str:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Links to quests in the web app, which shows them as quest chips in Markdown.
|
|
2
|
+
|
|
3
|
+
Same format as the app's "Copy link" (`<app>/home?tab=quests&quest=<id>`), so a
|
|
4
|
+
mention an agent writes into a note or description renders exactly like one pasted there.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_LABEL_SPECIAL = re.compile(r"([\\\[\]])")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def quest_url(app_url: str, quest_id: str) -> str:
|
|
15
|
+
return f"{app_url.rstrip('/')}/home?tab=quests&quest={quest_id}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def mention_markdown(app_url: str, quest_id: str, label: str) -> str:
|
|
19
|
+
"""`[label](url)` with Markdown brackets in the label escaped, so a title can't break the link."""
|
|
20
|
+
return f"[{_LABEL_SPECIAL.sub(r'\\\1', label)}]({quest_url(app_url, quest_id)})"
|
|
@@ -59,6 +59,8 @@ class Quest:
|
|
|
59
59
|
progress_done: int
|
|
60
60
|
progress_total: int
|
|
61
61
|
emoji: str | None = None
|
|
62
|
+
# Related quests (symmetric): each lists the other.
|
|
63
|
+
linked_quest_ids: tuple[str, ...] = ()
|
|
62
64
|
# Archived, or inside an archived quest: still addressable, but hidden in the app.
|
|
63
65
|
hidden: bool = False
|
|
64
66
|
# The actionable missed day (spec §6.2), when listed with a local today.
|
|
@@ -99,6 +101,7 @@ class Quest:
|
|
|
99
101
|
raw_path = payload.get("path")
|
|
100
102
|
path = tuple(str(p) for p in raw_path) if isinstance(raw_path, list) else ()
|
|
101
103
|
raw_streaks = payload.get("streak_ids")
|
|
104
|
+
raw_linked = payload.get("linked_quest_ids")
|
|
102
105
|
return cls(
|
|
103
106
|
id=str(payload.get("id", "")),
|
|
104
107
|
title=str(payload.get("title", "")),
|
|
@@ -113,6 +116,7 @@ class Quest:
|
|
|
113
116
|
end_date=_optional_date(payload.get("end_date")),
|
|
114
117
|
todo_dates=dates,
|
|
115
118
|
streak_ids=tuple(str(s) for s in raw_streaks) if isinstance(raw_streaks, list) else (),
|
|
119
|
+
linked_quest_ids=tuple(str(q) for q in raw_linked) if isinstance(raw_linked, list) else (),
|
|
116
120
|
diary_count=_optional_int(payload.get("diary_count")) or 0,
|
|
117
121
|
progress_done=_optional_int(progress.get("done")) or 0,
|
|
118
122
|
progress_total=_optional_int(progress.get("total")) or 0,
|
|
@@ -130,13 +134,28 @@ class DiaryEntry:
|
|
|
130
134
|
source: str | None
|
|
131
135
|
|
|
132
136
|
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class LinkedQuest:
|
|
139
|
+
"""A linked quest as the detail names it."""
|
|
140
|
+
|
|
141
|
+
id: str
|
|
142
|
+
title: str
|
|
143
|
+
status: str
|
|
144
|
+
path: tuple[str, ...]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def full_path(self) -> str:
|
|
148
|
+
return PATH_SEPARATOR.join([*self.path, self.title or "Untitled"])
|
|
149
|
+
|
|
150
|
+
|
|
133
151
|
@dataclass(frozen=True)
|
|
134
152
|
class QuestDetail:
|
|
135
|
-
"""A quest plus its Markdown description
|
|
153
|
+
"""A quest plus its Markdown description, diary (newest first) and linked quests."""
|
|
136
154
|
|
|
137
155
|
quest: Quest
|
|
138
156
|
description: str
|
|
139
157
|
diary: tuple[DiaryEntry, ...]
|
|
158
|
+
linked: tuple[LinkedQuest, ...] = ()
|
|
140
159
|
|
|
141
160
|
@classmethod
|
|
142
161
|
def from_payload(cls, payload: Mapping[str, Any]) -> QuestDetail:
|
|
@@ -145,10 +164,20 @@ class QuestDetail:
|
|
|
145
164
|
for e in _mappings(payload.get("diary"))
|
|
146
165
|
if (d := _optional_date(e.get("date"))) is not None
|
|
147
166
|
)
|
|
167
|
+
linked = tuple(
|
|
168
|
+
LinkedQuest(
|
|
169
|
+
id=str(q.get("id", "")),
|
|
170
|
+
title=str(q.get("title", "")),
|
|
171
|
+
status=str(q.get("status", "")),
|
|
172
|
+
path=tuple(str(p) for p in raw) if isinstance(raw := q.get("path"), list) else (),
|
|
173
|
+
)
|
|
174
|
+
for q in _mappings(payload.get("linked_quests"))
|
|
175
|
+
)
|
|
148
176
|
return cls(
|
|
149
177
|
quest=Quest.from_payload(payload),
|
|
150
178
|
description=str(payload.get("description") or ""),
|
|
151
179
|
diary=diary,
|
|
180
|
+
linked=linked,
|
|
152
181
|
)
|
|
153
182
|
|
|
154
183
|
|
|
@@ -18,7 +18,7 @@ from quaestor_cli.output import emit, emit_json, warn
|
|
|
18
18
|
|
|
19
19
|
JsonFlag = Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON on stdout.")]
|
|
20
20
|
DryRunFlag = Annotated[
|
|
21
|
-
bool, typer.Option("--dry-run", help="Print the request that would be sent;
|
|
21
|
+
bool, typer.Option("--dry-run", help="Print the request that would be sent; write nothing.")
|
|
22
22
|
]
|
|
23
23
|
|
|
24
24
|
|
|
@@ -36,6 +36,7 @@ class RootFlags:
|
|
|
36
36
|
json_output: bool = False
|
|
37
37
|
dry_run: bool = False
|
|
38
38
|
idempotency_key: str | None = None
|
|
39
|
+
dev: bool = False
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def _default_idempotency_key() -> str:
|
|
@@ -74,7 +75,7 @@ def cli_state(ctx: typer.Context, json_output: bool = False, dry_run: bool = Fal
|
|
|
74
75
|
return CliState(
|
|
75
76
|
json_output=json_output or root.json_output,
|
|
76
77
|
dry_run=dry_run or root.dry_run,
|
|
77
|
-
config=load_config(),
|
|
78
|
+
config=load_config(dev=root.dev),
|
|
78
79
|
**extra,
|
|
79
80
|
)
|
|
80
81
|
|
|
@@ -86,11 +87,7 @@ def report_dry_run(
|
|
|
86
87
|
body: Mapping[str, Any],
|
|
87
88
|
unresolved_streak: str | None = None,
|
|
88
89
|
) -> None:
|
|
89
|
-
"""Print
|
|
90
|
-
|
|
91
|
-
Names stay unresolved because resolving one needs a request, and --dry-run
|
|
92
|
-
promises none.
|
|
93
|
-
"""
|
|
90
|
+
"""Print a write preview without sending it; callers may resolve names via GET."""
|
|
94
91
|
if state.json_output:
|
|
95
92
|
request = {"method": "POST", "url": state.url_for(path), "body": dict(body)}
|
|
96
93
|
emit_json({"dry_run": True, "request": request, "unresolved_streak": unresolved_streak})
|
|
@@ -10,7 +10,7 @@ import pytest
|
|
|
10
10
|
import respx
|
|
11
11
|
from typer.testing import CliRunner
|
|
12
12
|
|
|
13
|
-
from conftest import FAKE_KEY
|
|
13
|
+
from conftest import BASE_URL, FAKE_KEY
|
|
14
14
|
from factories import (
|
|
15
15
|
API_KEY_ID,
|
|
16
16
|
AUTH_USER_ID,
|
|
@@ -18,7 +18,7 @@ from factories import (
|
|
|
18
18
|
streak_payload,
|
|
19
19
|
whoami_payload,
|
|
20
20
|
)
|
|
21
|
-
from quaestor_cli.config import credentials_path
|
|
21
|
+
from quaestor_cli.config import DEFAULT_BASE_URL, LOCAL_BASE_URL, credentials_path, load_config
|
|
22
22
|
from quaestor_cli.main import app
|
|
23
23
|
|
|
24
24
|
STORED_KEY = "qst_live_fedcba9876543210_stored-secret"
|
|
@@ -44,6 +44,76 @@ def test_login_without_input_exits_one(runner: CliRunner) -> None:
|
|
|
44
44
|
assert "No API key on stdin" in result.stderr
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_login_preserves_stored_server_when_replacing_key(
|
|
48
|
+
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Logging in again keeps the server that subsequent commands use."""
|
|
51
|
+
path = credentials_path()
|
|
52
|
+
path.parent.mkdir(parents=True)
|
|
53
|
+
path.write_text(json.dumps({"api_key": FAKE_KEY, "base_url": BASE_URL}))
|
|
54
|
+
monkeypatch.delenv("QUAESTOR_URL")
|
|
55
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
56
|
+
api_mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
57
|
+
|
|
58
|
+
login = runner.invoke(app, ["auth", "login"], input=f"{STORED_KEY}\n")
|
|
59
|
+
|
|
60
|
+
assert login.exit_code == 0, login.output
|
|
61
|
+
assert json.loads(path.read_text()) == {"api_key": STORED_KEY, "base_url": BASE_URL}
|
|
62
|
+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
63
|
+
whoami = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
64
|
+
assert whoami.exit_code == 0, whoami.output
|
|
65
|
+
assert json.loads(whoami.stdout)["base_url"] == BASE_URL
|
|
66
|
+
assert api_mock.calls.last.request.headers["authorization"] == f"Bearer {STORED_KEY}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_login_uses_hosted_api_without_url_configuration(
|
|
70
|
+
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Fresh setup reaches production after storing only the API key."""
|
|
73
|
+
monkeypatch.delenv("QUAESTOR_URL")
|
|
74
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
75
|
+
assert DEFAULT_BASE_URL.startswith("https://")
|
|
76
|
+
assert DEFAULT_BASE_URL != LOCAL_BASE_URL
|
|
77
|
+
|
|
78
|
+
login = runner.invoke(app, ["auth", "login"], input=f"{STORED_KEY}\n")
|
|
79
|
+
|
|
80
|
+
assert login.exit_code == 0, login.output
|
|
81
|
+
with respx.mock(base_url=f"{DEFAULT_BASE_URL}/api/v1") as mock:
|
|
82
|
+
whoami = mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
83
|
+
result = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
84
|
+
|
|
85
|
+
assert result.exit_code == 0, result.output
|
|
86
|
+
assert json.loads(result.stdout)["base_url"] == DEFAULT_BASE_URL
|
|
87
|
+
assert whoami.calls.last.request.headers["authorization"] == f"Bearer {STORED_KEY}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_dev_flag_overrides_other_servers_without_changing_the_store(
|
|
91
|
+
runner: CliRunner,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Local testing is explicit and does not change the server for later commands."""
|
|
94
|
+
path = credentials_path()
|
|
95
|
+
path.parent.mkdir(parents=True)
|
|
96
|
+
contents = {"api_key": STORED_KEY, "base_url": "https://staging.example"}
|
|
97
|
+
path.write_text(json.dumps(contents))
|
|
98
|
+
|
|
99
|
+
with respx.mock(base_url=f"{LOCAL_BASE_URL}/api/v1") as mock:
|
|
100
|
+
mock.get("/auth/whoami").mock(return_value=httpx.Response(200, json=whoami_payload()))
|
|
101
|
+
result = runner.invoke(app, ["--dev", "auth", "whoami", "--json"])
|
|
102
|
+
|
|
103
|
+
assert result.exit_code == 0, result.output
|
|
104
|
+
assert json.loads(result.stdout)["base_url"] == LOCAL_BASE_URL
|
|
105
|
+
assert json.loads(path.read_text()) == contents
|
|
106
|
+
assert load_config().base_url == BASE_URL
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_dev_flag_is_not_shown_in_normal_help(runner: CliRunner) -> None:
|
|
110
|
+
"""Development configuration stays out of the normal setup path."""
|
|
111
|
+
result = runner.invoke(app, ["--help"], color=False)
|
|
112
|
+
|
|
113
|
+
assert result.exit_code == 0, result.output
|
|
114
|
+
assert "--dev" not in result.stdout
|
|
115
|
+
|
|
116
|
+
|
|
47
117
|
def test_stored_key_is_used_when_environment_is_empty(
|
|
48
118
|
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
49
119
|
) -> None:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
|
+
import pytest
|
|
8
9
|
import respx
|
|
9
10
|
from typer.testing import CliRunner
|
|
10
11
|
|
|
@@ -102,14 +103,15 @@ def test_log_resolves_a_prefix_to_one_streak(runner: CliRunner, api_mock: respx.
|
|
|
102
103
|
assert json.loads(events.calls.last.request.content)["streak_id"] == COUNT_STREAK_ID
|
|
103
104
|
|
|
104
105
|
|
|
106
|
+
@pytest.mark.parametrize("dry_run_args", [[], ["--dry-run"]], ids=["write", "dry-run"])
|
|
105
107
|
def test_log_unknown_streak_exits_three_and_lists_names(
|
|
106
|
-
runner: CliRunner, api_mock: respx.MockRouter
|
|
108
|
+
runner: CliRunner, api_mock: respx.MockRouter, dry_run_args: list[str]
|
|
107
109
|
) -> None:
|
|
108
110
|
"""User names a streak that does not exist and gets the real names back."""
|
|
109
111
|
_mock_streaks(api_mock, streak_payload("Reading"))
|
|
110
112
|
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
|
|
111
113
|
|
|
112
|
-
result = runner.invoke(app, ["log", "meditate", "5"])
|
|
114
|
+
result = runner.invoke(app, ["log", "meditate", "5", *dry_run_args])
|
|
113
115
|
|
|
114
116
|
assert result.exit_code == 3
|
|
115
117
|
assert "No streak matches 'meditate'" in result.stderr
|
|
@@ -117,14 +119,17 @@ def test_log_unknown_streak_exits_three_and_lists_names(
|
|
|
117
119
|
assert not events.called
|
|
118
120
|
|
|
119
121
|
|
|
120
|
-
|
|
122
|
+
@pytest.mark.parametrize("dry_run_args", [[], ["--dry-run"]], ids=["write", "dry-run"])
|
|
123
|
+
def test_log_ambiguous_streak_writes_nothing(
|
|
124
|
+
runner: CliRunner, api_mock: respx.MockRouter, dry_run_args: list[str]
|
|
125
|
+
) -> None:
|
|
121
126
|
"""User types an ambiguous name; the CLI lists candidates and posts nothing."""
|
|
122
127
|
_mock_streaks(
|
|
123
128
|
api_mock, streak_payload("Reading", streak_id="a"), streak_payload("Running", streak_id="b")
|
|
124
129
|
)
|
|
125
130
|
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
|
|
126
131
|
|
|
127
|
-
result = runner.invoke(app, ["log", "r", "5"])
|
|
132
|
+
result = runner.invoke(app, ["log", "r", "5", *dry_run_args])
|
|
128
133
|
|
|
129
134
|
assert result.exit_code == 3
|
|
130
135
|
assert "ambiguous" in result.stderr
|
|
@@ -142,16 +147,56 @@ def test_log_duplicate_request_reports_already_logged(runner: CliRunner, api_moc
|
|
|
142
147
|
assert "Already logged" in result.stdout
|
|
143
148
|
|
|
144
149
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
def test_log_dry_run_resolves_streak_but_writes_nothing(
|
|
151
|
+
runner: CliRunner, api_mock: respx.MockRouter
|
|
152
|
+
) -> None:
|
|
153
|
+
"""User previews a resolved log entry; only the streak lookup is sent."""
|
|
154
|
+
streaks = _mock_streaks(api_mock, streak_payload("Reading"))
|
|
155
|
+
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
|
|
149
156
|
|
|
150
157
|
result = runner.invoke(app, ["log", "Reading", "5", "--dry-run"])
|
|
151
158
|
|
|
152
159
|
assert result.exit_code == 0, result.output
|
|
153
|
-
assert
|
|
160
|
+
assert streaks.called
|
|
161
|
+
assert all(call.request.method == "GET" for call in api_mock.calls)
|
|
162
|
+
assert not events.called
|
|
154
163
|
assert "DRY RUN" in result.stdout
|
|
164
|
+
assert COUNT_STREAK_ID in result.stdout
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_log_time_dry_run_json_has_resolved_body(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
168
|
+
"""A valid TIME preview includes the resolved ID and exact window without writing."""
|
|
169
|
+
_mock_streaks(api_mock, time_streak_payload("Leetcode 🧑💻"))
|
|
170
|
+
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=time_event_payload()))
|
|
171
|
+
|
|
172
|
+
result = runner.invoke(
|
|
173
|
+
app,
|
|
174
|
+
[
|
|
175
|
+
"--json",
|
|
176
|
+
"--dry-run",
|
|
177
|
+
"log",
|
|
178
|
+
"Leetcode",
|
|
179
|
+
"--from",
|
|
180
|
+
"09:00",
|
|
181
|
+
"--to",
|
|
182
|
+
"09:45",
|
|
183
|
+
"--date",
|
|
184
|
+
"2026-09-20",
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert result.exit_code == 0, result.output
|
|
189
|
+
payload = json.loads(result.stdout)
|
|
190
|
+
assert payload["dry_run"] is True
|
|
191
|
+
assert payload["unresolved_streak"] is None
|
|
192
|
+
request = payload["request"]
|
|
193
|
+
assert request["method"] == "POST"
|
|
194
|
+
assert request["body"]["streak_id"] == TIME_STREAK_ID
|
|
195
|
+
assert request["body"]["type"] == "time"
|
|
196
|
+
assert request["body"]["started_at_local"] == "2026-09-20T09:00:00"
|
|
197
|
+
assert request["body"]["ended_at_local"] == "2026-09-20T09:45:00"
|
|
198
|
+
assert all(call.request.method == "GET" for call in api_mock.calls)
|
|
199
|
+
assert not events.called
|
|
155
200
|
|
|
156
201
|
|
|
157
202
|
def test_log_json_output_is_parseable(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
@@ -199,24 +244,54 @@ def test_log_count_value_on_time_streak_is_refused(runner: CliRunner, api_mock:
|
|
|
199
244
|
assert not events.called
|
|
200
245
|
|
|
201
246
|
|
|
202
|
-
|
|
247
|
+
@pytest.mark.parametrize("global_flag", [True, False], ids=["global", "local"])
|
|
248
|
+
@pytest.mark.parametrize("json_output", [False, True], ids=["text", "json"])
|
|
249
|
+
def test_log_dry_run_count_value_on_time_streak_is_refused(
|
|
250
|
+
runner: CliRunner, api_mock: respx.MockRouter, global_flag: bool, json_output: bool
|
|
251
|
+
) -> None:
|
|
252
|
+
"""A dry run rejects a count on a resolved TIME streak without posting an event."""
|
|
253
|
+
_mock_streaks(api_mock, time_streak_payload("Leetcode 🧑💻"))
|
|
254
|
+
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=time_event_payload()))
|
|
255
|
+
|
|
256
|
+
args = ["--dry-run", "log", "Leetcode", "1"] if global_flag else ["log", "Leetcode", "1", "--dry-run"]
|
|
257
|
+
if json_output:
|
|
258
|
+
args.insert(0, "--json")
|
|
259
|
+
result = runner.invoke(app, args)
|
|
260
|
+
|
|
261
|
+
assert result.exit_code == 1, result.output
|
|
262
|
+
assert "TIME streak" in result.stderr
|
|
263
|
+
assert not events.called
|
|
264
|
+
assert all(call.request.method == "GET" for call in api_mock.calls)
|
|
265
|
+
if json_output:
|
|
266
|
+
assert json.loads(result.stdout)["error"]["exit_code"] == 1
|
|
267
|
+
else:
|
|
268
|
+
assert result.stdout == ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.parametrize("dry_run_args", [[], ["--dry-run"]], ids=["write", "dry-run"])
|
|
272
|
+
def test_log_minutes_on_count_streak_is_refused(
|
|
273
|
+
runner: CliRunner, api_mock: respx.MockRouter, dry_run_args: list[str]
|
|
274
|
+
) -> None:
|
|
203
275
|
"""User passes --minutes to a COUNT streak and is redirected to a plain value."""
|
|
204
276
|
_mock_streaks(api_mock, streak_payload("Reading"))
|
|
205
277
|
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
|
|
206
278
|
|
|
207
|
-
result = runner.invoke(app, ["log", "Reading", "--minutes", "45"])
|
|
279
|
+
result = runner.invoke(app, ["log", "Reading", "--minutes", "45", *dry_run_args])
|
|
208
280
|
|
|
209
281
|
assert result.exit_code == 1
|
|
210
282
|
assert "COUNT streak" in result.stderr
|
|
211
283
|
assert not events.called
|
|
212
284
|
|
|
213
285
|
|
|
214
|
-
|
|
286
|
+
@pytest.mark.parametrize("dry_run_args", [[], ["--dry-run"]], ids=["write", "dry-run"])
|
|
287
|
+
def test_log_wrong_unit_is_refused(
|
|
288
|
+
runner: CliRunner, api_mock: respx.MockRouter, dry_run_args: list[str]
|
|
289
|
+
) -> None:
|
|
215
290
|
"""User asserts the wrong unit; the CLI refuses rather than logging the number blind."""
|
|
216
291
|
_mock_streaks(api_mock, streak_payload("Reading"))
|
|
217
292
|
events = api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
|
|
218
293
|
|
|
219
|
-
result = runner.invoke(app, ["log", "Reading", "5", "--unit", "minutes"])
|
|
294
|
+
result = runner.invoke(app, ["log", "Reading", "5", "--unit", "minutes", *dry_run_args])
|
|
220
295
|
|
|
221
296
|
assert result.exit_code == 1
|
|
222
297
|
assert "measured in pages" in result.stderr
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Quest-to-quest links and mentions: `qst quest link-quest / unlink-quest / mention / show`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import respx
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
from factories import PARENT_QUEST_ID, QUEST_ID, quest_payload
|
|
14
|
+
from quaestor_cli.main import app
|
|
15
|
+
|
|
16
|
+
APP = "https://quaestor.app"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _quest(title: str, quest_id: str, linked: list[str] | None = None, **kw: Any) -> dict[str, Any]:
|
|
20
|
+
return {**quest_payload(title, quest_id=quest_id, **kw), "linked_quest_ids": linked or []}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolves(api_mock: respx.MockRouter, *quests: dict[str, Any]) -> None:
|
|
24
|
+
for q in quests:
|
|
25
|
+
route = api_mock.get("/quests/resolve", params={"ref": q["title"]})
|
|
26
|
+
route.mock(return_value=httpx.Response(200, json=q))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_link_quest_links_both_by_name(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
30
|
+
"""Agent links Website to Launch by name; one PUT to the links endpoint, confirmed with both paths."""
|
|
31
|
+
_resolves(api_mock, _quest("Website", QUEST_ID), _quest("Launch", PARENT_QUEST_ID))
|
|
32
|
+
route = api_mock.put(f"/quests/{QUEST_ID}/links/{PARENT_QUEST_ID}").mock(
|
|
33
|
+
return_value=httpx.Response(200, json=_quest("Website", QUEST_ID, [PARENT_QUEST_ID]))
|
|
34
|
+
)
|
|
35
|
+
result = runner.invoke(app, ["quest", "link-quest", "Website", "Launch"])
|
|
36
|
+
assert result.exit_code == 0, result.output
|
|
37
|
+
assert route.called and "Linked Website to Launch." in result.stdout
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_link_quest_already_linked_sends_nothing(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
41
|
+
"""Agent repeats a link that exists; nothing is sent and the JSON says nothing changed."""
|
|
42
|
+
_resolves(api_mock, _quest("Website", QUEST_ID, [PARENT_QUEST_ID]), _quest("Launch", PARENT_QUEST_ID))
|
|
43
|
+
route = api_mock.put(f"/quests/{QUEST_ID}/links/{PARENT_QUEST_ID}")
|
|
44
|
+
result = runner.invoke(app, ["quest", "link-quest", "Website", "Launch", "--json"])
|
|
45
|
+
assert result.exit_code == 0, result.output
|
|
46
|
+
assert not route.called
|
|
47
|
+
assert json.loads(result.stdout) == {"changed": False, "quest": "Website", "linked_quest": "Launch"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_link_quest_to_itself_is_a_usage_error(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
51
|
+
"""Agent tries to link a quest to itself; refused before anything is sent."""
|
|
52
|
+
_resolves(api_mock, _quest("Website", QUEST_ID))
|
|
53
|
+
result = runner.invoke(app, ["quest", "link-quest", "Website", "Website"])
|
|
54
|
+
assert result.exit_code == 1 and "can't be linked to itself" in result.output
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unlink_quest_deletes_the_link(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
58
|
+
"""Agent unlinks two linked quests; one DELETE, and --dry-run would have sent nothing."""
|
|
59
|
+
_resolves(api_mock, _quest("Website", QUEST_ID, [PARENT_QUEST_ID]), _quest("Launch", PARENT_QUEST_ID))
|
|
60
|
+
route = api_mock.delete(f"/quests/{QUEST_ID}/links/{PARENT_QUEST_ID}").mock(
|
|
61
|
+
return_value=httpx.Response(200, json=_quest("Website", QUEST_ID))
|
|
62
|
+
)
|
|
63
|
+
dry = runner.invoke(app, ["quest", "unlink-quest", "Website", "Launch", "--dry-run"])
|
|
64
|
+
assert dry.exit_code == 0, dry.output
|
|
65
|
+
assert route.call_count == 0
|
|
66
|
+
result = runner.invoke(app, ["quest", "unlink-quest", "Website", "Launch"])
|
|
67
|
+
assert result.exit_code == 0, result.output
|
|
68
|
+
assert route.called and "Unlinked Website from Launch." in result.stdout
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_mention_prints_markdown_link_for_a_chip(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
72
|
+
"""Agent asks for a mention of a quest with brackets in its title; it gets an escaped Markdown link."""
|
|
73
|
+
_resolves(api_mock, _quest("Fix [urgent] bug", QUEST_ID, path=["Launch"]))
|
|
74
|
+
result = runner.invoke(app, ["quest", "mention", "Fix [urgent] bug"])
|
|
75
|
+
assert result.exit_code == 0, result.output
|
|
76
|
+
assert result.stdout.strip() == f"[Fix \\[urgent\\] bug]({APP}/home?tab=quests&quest={QUEST_ID})"
|
|
77
|
+
|
|
78
|
+
as_json = json.loads(runner.invoke(app, ["quest", "mention", "Fix [urgent] bug", "--json"]).stdout)
|
|
79
|
+
assert as_json["url"] == f"{APP}/home?tab=quests&quest={QUEST_ID}"
|
|
80
|
+
assert as_json["quest"] == "Launch › Fix [urgent] bug"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_mention_uses_the_configured_app_url(
|
|
84
|
+
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
85
|
+
) -> None:
|
|
86
|
+
"""A self-hosted app sets QUAESTOR_APP_URL; mentions point there."""
|
|
87
|
+
monkeypatch.setenv("QUAESTOR_APP_URL", "https://quests.example.org/")
|
|
88
|
+
_resolves(api_mock, _quest("Website", QUEST_ID))
|
|
89
|
+
result = runner.invoke(app, ["quest", "mention", "Website"])
|
|
90
|
+
assert f"(https://quests.example.org/home?tab=quests&quest={QUEST_ID})" in result.stdout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_show_lists_linked_quests_and_the_quest_link(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
94
|
+
"""User shows a quest; linked quests appear by full path, plus the quest's own link (also in --json)."""
|
|
95
|
+
_resolves(api_mock, _quest("Website", QUEST_ID, [PARENT_QUEST_ID]))
|
|
96
|
+
detail = {
|
|
97
|
+
**_quest("Website", QUEST_ID, [PARENT_QUEST_ID]),
|
|
98
|
+
"description": "",
|
|
99
|
+
"diary": [],
|
|
100
|
+
"linked_quests": [
|
|
101
|
+
{
|
|
102
|
+
"id": PARENT_QUEST_ID,
|
|
103
|
+
"title": "Schema",
|
|
104
|
+
"emoji": None,
|
|
105
|
+
"status": "todo",
|
|
106
|
+
"path": ["Launch", "Backend"],
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
api_mock.get(f"/quests/{QUEST_ID}").mock(return_value=httpx.Response(200, json=detail))
|
|
111
|
+
|
|
112
|
+
result = runner.invoke(app, ["quest", "show", "Website"])
|
|
113
|
+
assert result.exit_code == 0, result.output
|
|
114
|
+
assert "linked: Launch › Backend › Schema" in result.stdout
|
|
115
|
+
assert f"link: {APP}/home?tab=quests&quest={QUEST_ID}" in result.stdout
|
|
116
|
+
|
|
117
|
+
as_json = json.loads(runner.invoke(app, ["quest", "show", "Website", "--json"]).stdout)
|
|
118
|
+
assert as_json["url"] == f"{APP}/home?tab=quests&quest={QUEST_ID}"
|
|
119
|
+
assert as_json["linked_quests"][0]["path"] == ["Launch", "Backend"]
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
|
+
import pytest
|
|
8
9
|
import respx
|
|
9
10
|
from typer.testing import CliRunner
|
|
10
11
|
|
|
@@ -277,9 +278,76 @@ def test_doctor_reports_unreachable_backend(runner: CliRunner, api_mock: respx.M
|
|
|
277
278
|
|
|
278
279
|
assert result.exit_code == 4
|
|
279
280
|
assert "reachable: no" in result.stdout
|
|
281
|
+
assert "authenticated: unknown (host unreachable)" in result.stdout
|
|
282
|
+
assert "scopes: unknown (host unreachable)" in result.stdout
|
|
283
|
+
assert "credential was rejected" not in result.stdout
|
|
280
284
|
assert "Cannot reach" in result.stderr
|
|
281
285
|
|
|
282
286
|
|
|
287
|
+
def test_doctor_reports_unknown_authentication_in_json_when_unreachable(
|
|
288
|
+
runner: CliRunner, api_mock: respx.MockRouter
|
|
289
|
+
) -> None:
|
|
290
|
+
"""An unreachable host cannot establish whether the credential works."""
|
|
291
|
+
api_mock.get("/healthz").mock(side_effect=httpx.ConnectError("connection refused"))
|
|
292
|
+
|
|
293
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
294
|
+
|
|
295
|
+
assert result.exit_code == 4, result.output
|
|
296
|
+
diagnosis = json.loads(result.stdout)
|
|
297
|
+
assert diagnosis["reachable"] is False
|
|
298
|
+
assert diagnosis["authenticated"] is None
|
|
299
|
+
assert diagnosis["scopes"] is None
|
|
300
|
+
assert len(api_mock.calls) == 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.parametrize("response", ["connection", "server"])
|
|
304
|
+
def test_doctor_reports_unknown_when_authentication_check_fails(
|
|
305
|
+
runner: CliRunner, api_mock: respx.MockRouter, response: str
|
|
306
|
+
) -> None:
|
|
307
|
+
"""A working health check does not turn a failed identity lookup into rejection."""
|
|
308
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
309
|
+
identity = api_mock.get("/auth/whoami")
|
|
310
|
+
if response == "connection":
|
|
311
|
+
identity.mock(side_effect=httpx.ConnectError("connection refused"))
|
|
312
|
+
else:
|
|
313
|
+
identity.mock(return_value=httpx.Response(503, json={"detail": "Unavailable"}))
|
|
314
|
+
|
|
315
|
+
result = runner.invoke(app, ["doctor"])
|
|
316
|
+
|
|
317
|
+
assert result.exit_code == 4, result.output
|
|
318
|
+
assert "reachable: yes" in result.stdout
|
|
319
|
+
assert "authenticated: unknown (authentication check failed)" in result.stdout
|
|
320
|
+
assert "credential was rejected" not in result.stdout
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_doctor_reports_reachable_but_unhealthy_server(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
324
|
+
"""A server that answers with 503 is reachable, but cannot verify the key."""
|
|
325
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(503, json={"detail": "Unavailable"}))
|
|
326
|
+
|
|
327
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
328
|
+
|
|
329
|
+
assert result.exit_code == 4, result.output
|
|
330
|
+
diagnosis = json.loads(result.stdout)
|
|
331
|
+
assert diagnosis["reachable"] is True
|
|
332
|
+
assert diagnosis["authenticated"] is None
|
|
333
|
+
assert diagnosis["detail"] == "Unavailable"
|
|
334
|
+
assert len(api_mock.calls) == 1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_doctor_does_not_claim_rejection_when_no_key_is_configured(
|
|
338
|
+
runner: CliRunner, api_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
339
|
+
) -> None:
|
|
340
|
+
"""A missing credential has never been presented for authentication."""
|
|
341
|
+
monkeypatch.delenv("QUAESTOR_API_KEY")
|
|
342
|
+
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
343
|
+
|
|
344
|
+
result = runner.invoke(app, ["doctor"])
|
|
345
|
+
|
|
346
|
+
assert result.exit_code == 2, result.output
|
|
347
|
+
assert "scopes: unknown (no API key)" in result.stdout
|
|
348
|
+
assert "credential was rejected" not in result.stdout
|
|
349
|
+
|
|
350
|
+
|
|
283
351
|
def test_doctor_reports_bad_credential(runner: CliRunner, api_mock: respx.MockRouter) -> None:
|
|
284
352
|
"""Backend is up but the key is rejected; doctor exits with the auth code."""
|
|
285
353
|
api_mock.get("/healthz").mock(return_value=httpx.Response(200, json={"ok": True}))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|