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.
Files changed (48) hide show
  1. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/PKG-INFO +26 -8
  2. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/README.md +25 -7
  3. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/pyproject.toml +1 -1
  4. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/doctor.py +34 -9
  5. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/log.py +4 -9
  6. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quest.py +79 -2
  7. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/config.py +19 -12
  8. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/main.py +2 -1
  9. quaestor_cli-0.4.0/src/quaestor_cli/quest_links.py +20 -0
  10. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/quest_models.py +30 -1
  11. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/runtime.py +4 -7
  12. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_auth.py +72 -2
  13. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_log.py +88 -13
  14. quaestor_cli-0.4.0/tests/test_cli_quest_links.py +119 -0
  15. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_timer_review_doctor.py +68 -0
  16. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/uv.lock +1 -1
  17. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/.gitignore +0 -0
  18. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/__init__.py +0 -0
  19. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/api.py +0 -0
  20. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/client.py +0 -0
  21. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/__init__.py +0 -0
  22. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/auth.py +0 -0
  23. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/events.py +0 -0
  24. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quest_format.py +0 -0
  25. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/quests.py +0 -0
  26. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/review.py +0 -0
  27. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/show.py +0 -0
  28. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/streaks.py +0 -0
  29. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/commands/timer.py +0 -0
  30. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/errors.py +0 -0
  31. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/models.py +0 -0
  32. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/output.py +0 -0
  33. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/parse.py +0 -0
  34. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/quest_api.py +0 -0
  35. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/resolve.py +0 -0
  36. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/src/quaestor_cli/window.py +0 -0
  37. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/conftest.py +0 -0
  38. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/factories.py +0 -0
  39. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_errors.py +0 -0
  40. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_events.py +0 -0
  41. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_quest_emoji.py +0 -0
  42. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_quests.py +0 -0
  43. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_review_fixes.py +0 -0
  44. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_cli_show.py +0 -0
  45. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_parse.py +0 -0
  46. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_quest_parse_and_doctor.py +0 -0
  47. {quaestor_cli-0.3.0 → quaestor_cli-0.4.0}/tests/test_resolve.py +0 -0
  48. {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.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
- 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
 
@@ -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 sends nothing.
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
- 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
 
@@ -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 sends nothing.
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quaestor-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
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
  )
@@ -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
- """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,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 = "http://localhost:8000"
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 base URL and credential from the environment, then the file."""
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
- return Config(base_url=base_url, credential=credential)
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
- payload = json.dumps({"api_key": key}, indent=2) + "\n"
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 and diary (newest first)."""
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; send nothing.")
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 the request a write command would send, without contacting the API.
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
- def test_log_ambiguous_streak_writes_nothing(runner: CliRunner, api_mock: respx.MockRouter) -> None:
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 test_log_dry_run_sends_no_request(runner: CliRunner, api_mock: respx.MockRouter) -> None:
146
- """User previews a log entry; nothing is sent and nothing is written."""
147
- _mock_streaks(api_mock, streak_payload("Reading"))
148
- api_mock.post("/events").mock(return_value=httpx.Response(201, json=count_event_payload()))
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 not api_mock.calls
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
- def test_log_minutes_on_count_streak_is_refused(runner: CliRunner, api_mock: respx.MockRouter) -> None:
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
- def test_log_wrong_unit_is_refused(runner: CliRunner, api_mock: respx.MockRouter) -> None:
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}))
@@ -416,7 +416,7 @@ wheels = [
416
416
 
417
417
  [[package]]
418
418
  name = "quaestor-cli"
419
- version = "0.3.0"
419
+ version = "0.4.0"
420
420
  source = { editable = "." }
421
421
  dependencies = [
422
422
  { name = "httpx" },
File without changes