quaestor-cli 0.3.0__tar.gz → 0.3.1__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.
Files changed (46) hide show
  1. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/PKG-INFO +17 -6
  2. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/README.md +16 -5
  3. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/pyproject.toml +1 -1
  4. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/doctor.py +34 -9
  5. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/config.py +11 -11
  6. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/main.py +2 -1
  7. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/runtime.py +2 -1
  8. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_auth.py +72 -2
  9. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_timer_review_doctor.py +68 -0
  10. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/uv.lock +1 -1
  11. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/.gitignore +0 -0
  12. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/__init__.py +0 -0
  13. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/api.py +0 -0
  14. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/client.py +0 -0
  15. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/__init__.py +0 -0
  16. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/auth.py +0 -0
  17. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/events.py +0 -0
  18. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/log.py +0 -0
  19. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quest.py +0 -0
  20. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quest_format.py +0 -0
  21. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/quests.py +0 -0
  22. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/review.py +0 -0
  23. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/show.py +0 -0
  24. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/streaks.py +0 -0
  25. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/commands/timer.py +0 -0
  26. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/errors.py +0 -0
  27. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/models.py +0 -0
  28. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/output.py +0 -0
  29. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/parse.py +0 -0
  30. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/quest_api.py +0 -0
  31. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/quest_models.py +0 -0
  32. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/resolve.py +0 -0
  33. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/src/quaestor_cli/window.py +0 -0
  34. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/conftest.py +0 -0
  35. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/factories.py +0 -0
  36. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_errors.py +0 -0
  37. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_events.py +0 -0
  38. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_log.py +0 -0
  39. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_quest_emoji.py +0 -0
  40. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_quests.py +0 -0
  41. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_review_fixes.py +0 -0
  42. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_cli_show.py +0 -0
  43. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_parse.py +0 -0
  44. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_quest_parse_and_doctor.py +0 -0
  45. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/tests/test_resolve.py +0 -0
  46. {quaestor_cli-0.3.0 → quaestor_cli-0.3.1}/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.0
3
+ Version: 0.3.1
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
- Set the backend URL, then store the key from stdin. On macOS, copy the key and run:
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
- `qst` refuses to send a key over plain HTTP to anything but a loopback host. Set
58
- `QUAESTOR_ALLOW_INSECURE=1` only if you are knowingly running a plaintext self-hosted
59
- backend.
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
 
@@ -219,3 +218,15 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
219
218
 
220
219
  Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
221
220
  Quaestor backend, so it installs in seconds and tolerates version skew against the server.
221
+
222
+ ## Developer configuration
223
+
224
+ Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
225
+ The development flag is hidden from normal help and never persists a local URL.
226
+
227
+ Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
228
+ `base_url` in the credential store. Resolution order is `--dev`, environment, stored
229
+ URL, then the hosted production default. Ordinary hosted setup needs no override.
230
+
231
+ `qst` refuses to send a key over plain HTTP except to loopback. Set
232
+ `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
- Set the backend URL, then store the key from stdin. On macOS, copy the key and run:
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
- `qst` refuses to send a key over plain HTTP to anything but a loopback host. Set
36
- `QUAESTOR_ALLOW_INSECURE=1` only if you are knowingly running a plaintext self-hosted
37
- backend.
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
 
@@ -197,3 +196,15 @@ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/featur
197
196
 
198
197
  Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
199
198
  Quaestor backend, so it installs in seconds and tolerates version skew against the server.
199
+
200
+ ## Developer configuration
201
+
202
+ Use `qst --dev doctor` to target `http://localhost:8000` for that invocation.
203
+ The development flag is hidden from normal help and never persists a local URL.
204
+
205
+ Staging and self-hosted deployments can still use `QUAESTOR_URL` or an existing
206
+ `base_url` in the credential store. Resolution order is `--dev`, environment, stored
207
+ URL, then the hosted production default. Ordinary hosted setup needs no override.
208
+
209
+ `qst` refuses to send a key over plain HTTP except to loopback. Set
210
+ `QUAESTOR_ALLOW_INSECURE=1` only for deliberate plaintext development deployments.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quaestor-cli"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Command-line client for the Quaestor API — built for humans and agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- raise CliError(diagnosis.detail or "Health check failed.", diagnosis.exit_code)
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 = False
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.is_success,
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(authenticated=False, scopes=None, user=None, detail=lookup.probe.detail)
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", "yes" if diagnosis.authenticated else "no"),
194
+ ("authenticated", authenticated),
170
195
  ("user", diagnosis.user or "-"),
171
196
  ("scopes", scopes),
172
197
  )
@@ -1,9 +1,4 @@
1
- """Credential and base-URL resolution.
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,7 +11,8 @@ 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 = "http://localhost:8000"
14
+ DEFAULT_BASE_URL = "https://ql-backend-fniqyq6lfa-ey.a.run.app"
15
+ LOCAL_BASE_URL = "http://localhost:8000"
20
16
  ENV_API_KEY = "QUAESTOR_API_KEY"
21
17
  ENV_BASE_URL = "QUAESTOR_URL"
22
18
 
@@ -79,8 +75,8 @@ def credentials_path() -> Path:
79
75
  return config_dir() / "credentials.json"
80
76
 
81
77
 
82
- def load_config() -> Config:
83
- """Resolve base URL and credential from the environment, then the file."""
78
+ def load_config(*, dev: bool = False) -> Config:
79
+ """Resolve an explicit dev target, then environment, file, and hosted defaults."""
84
80
  stored = _read_credentials_file()
85
81
  env_key = os.environ.get(ENV_API_KEY, "").strip()
86
82
 
@@ -91,7 +87,7 @@ def load_config() -> Config:
91
87
  credential = Credential(api_key=stored.api_key, source="file")
92
88
 
93
89
  env_url = os.environ.get(ENV_BASE_URL, "").strip()
94
- base_url = (env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
90
+ base_url = (LOCAL_BASE_URL if dev else env_url or stored.base_url or DEFAULT_BASE_URL).rstrip("/")
95
91
  return Config(base_url=base_url, credential=credential)
96
92
 
97
93
 
@@ -157,7 +153,11 @@ def save_credential(api_key: str) -> Path:
157
153
  os.chmod(directory, _CREDENTIALS_DIR_MODE)
158
154
 
159
155
  path = credentials_path()
160
- payload = json.dumps({"api_key": key}, indent=2) + "\n"
156
+ stored = _read_credentials_file()
157
+ contents = {"api_key": key}
158
+ if stored.base_url:
159
+ contents["base_url"] = stored.base_url
160
+ payload = json.dumps(contents, indent=2) + "\n"
161
161
  # Open with the mode up front so the secret is never briefly world-readable,
162
162
  # then chmod as well for the case where the file already existed.
163
163
  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:
@@ -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
 
@@ -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
 
@@ -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}))
@@ -416,7 +416,7 @@ wheels = [
416
416
 
417
417
  [[package]]
418
418
  name = "quaestor-cli"
419
- version = "0.3.0"
419
+ version = "0.3.1"
420
420
  source = { editable = "." }
421
421
  dependencies = [
422
422
  { name = "httpx" },
File without changes