quaestor-cli 0.2.0__tar.gz → 0.3.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 (50) hide show
  1. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/.gitignore +6 -0
  2. quaestor_cli-0.3.0/PKG-INFO +221 -0
  3. quaestor_cli-0.3.0/README.md +199 -0
  4. quaestor_cli-0.3.0/pyproject.toml +69 -0
  5. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/client.py +30 -14
  6. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/doctor.py +34 -6
  7. quaestor_cli-0.3.0/src/quaestor_cli/commands/quest.py +699 -0
  8. quaestor_cli-0.3.0/src/quaestor_cli/commands/quest_format.py +49 -0
  9. quaestor_cli-0.3.0/src/quaestor_cli/commands/quests.py +223 -0
  10. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/errors.py +5 -1
  11. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/main.py +24 -3
  12. quaestor_cli-0.3.0/src/quaestor_cli/parse.py +253 -0
  13. quaestor_cli-0.3.0/src/quaestor_cli/quest_api.py +129 -0
  14. quaestor_cli-0.3.0/src/quaestor_cli/quest_models.py +231 -0
  15. quaestor_cli-0.3.0/src/quaestor_cli/runtime.py +171 -0
  16. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/factories.py +36 -0
  17. quaestor_cli-0.3.0/tests/test_cli_quest_emoji.py +57 -0
  18. quaestor_cli-0.3.0/tests/test_cli_quests.py +390 -0
  19. quaestor_cli-0.3.0/tests/test_cli_review_fixes.py +373 -0
  20. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_timer_review_doctor.py +17 -0
  21. quaestor_cli-0.3.0/tests/test_quest_parse_and_doctor.py +99 -0
  22. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/uv.lock +2 -2
  23. quaestor_cli-0.2.0/PKG-INFO +0 -7
  24. quaestor_cli-0.2.0/pyproject.toml +0 -45
  25. quaestor_cli-0.2.0/src/quaestor_cli/parse.py +0 -116
  26. quaestor_cli-0.2.0/src/quaestor_cli/runtime.py +0 -88
  27. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/__init__.py +0 -0
  28. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/api.py +0 -0
  29. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/__init__.py +0 -0
  30. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/auth.py +0 -0
  31. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/events.py +0 -0
  32. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/log.py +0 -0
  33. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/review.py +0 -0
  34. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/show.py +0 -0
  35. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/streaks.py +0 -0
  36. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/commands/timer.py +0 -0
  37. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/config.py +0 -0
  38. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/models.py +0 -0
  39. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/output.py +0 -0
  40. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/resolve.py +0 -0
  41. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/src/quaestor_cli/window.py +0 -0
  42. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/conftest.py +0 -0
  43. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_auth.py +0 -0
  44. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_errors.py +0 -0
  45. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_events.py +0 -0
  46. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_log.py +0 -0
  47. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_cli_show.py +0 -0
  48. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_parse.py +0 -0
  49. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_resolve.py +0 -0
  50. {quaestor_cli-0.2.0 → quaestor_cli-0.3.0}/tests/test_window.py +0 -0
@@ -28,4 +28,10 @@ coverage/
28
28
  terraform.tfvars
29
29
  terraform.tfstate
30
30
  .terraform/
31
+
32
+ # Local agent tooling
33
+ .playwright-mcp/
34
+ .claude/
35
+
36
+ # Playwright MCP screenshots and snapshots
31
37
  .playwright-mcp/
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.5
2
+ Name: quaestor-cli
3
+ Version: 0.3.0
4
+ Summary: Command-line client for the Quaestor API — built for humans and agents.
5
+ Project-URL: Homepage, https://quaestor.app
6
+ Project-URL: Repository, https://github.com/neuromaxer/quaestor-lite
7
+ Project-URL: Documentation, https://github.com/neuromaxer/quaestor-lite/blob/main/apps/cli/README.md
8
+ Project-URL: Issues, https://github.com/neuromaxer/quaestor-lite/issues
9
+ Keywords: agent,ai,cli,habit-tracker,quaestor
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Utilities
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: typer>=0.16
21
+ Description-Content-Type: text/markdown
22
+
23
+ # quaestor-cli
24
+
25
+ `qst` — a command-line client for [Quaestor](https://quaestor.app), an agent-native
26
+ platform for diary-based life management. An evening diary becomes tracked habits,
27
+ quest progress, and a morning plan.
28
+
29
+ Built for two users at once: a person in a terminal, and an AI assistant acting on their
30
+ behalf. That second audience shapes most of the design — commands are task-shaped rather
31
+ than endpoint-shaped, streaks and quests are addressed by name, failures are
32
+ distinguishable by exit code, and the server's own error message is printed verbatim so
33
+ an agent can relay something true.
34
+
35
+ ```bash
36
+ uv tool install quaestor-cli
37
+ ```
38
+
39
+ ## Setup
40
+
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:
43
+
44
+ ```bash
45
+ export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
46
+ pbpaste | qst auth login
47
+ qst doctor
48
+ ```
49
+
50
+ On other systems, run `qst auth login`, paste the key, press Enter, then signal
51
+ end-of-input (Ctrl-D on Unix).
52
+
53
+ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
54
+ CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
55
+ precedence over the file, but it is not needed for this setup.
56
+
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.
60
+
61
+ ## The evening diary workflow
62
+
63
+ Spend 10–15 minutes writing or recording your day. Your agent uses `qst` to log
64
+ measured habits, record quest progress, and plan the work you want to do tomorrow.
65
+ It reports what changed and prepares a short morning plan with Main and Side Quests.
66
+
67
+ Keep the original diary with your agent or in a place you choose. The CLI stores
68
+ habit records and quest-specific notes in Quaestor, not a general diary entry.
69
+ Those original entries become a record of your progress and a story worth keeping.
70
+
71
+ ## Commands
72
+
73
+ ```
74
+ qst streaks List streaks with mode, unit and target
75
+ qst show <streak> Full configuration plus per-day totals, including missed days
76
+ qst log <streak> <value> Log a COUNT amount
77
+ qst log <streak> --minutes 45 Log a TIME block (45, 45m, 1h30m, 1.5h)
78
+ qst events <streak> Individual entries, newest first, with the source of each
79
+ qst review Totals, active days and current streak per streak
80
+ qst timer start|stop|status Control the single running timer
81
+ qst auth login|whoami|logout Manage the stored credential
82
+ qst doctor Check URL, reachability, credential and granted scopes
83
+
84
+ qst agenda [--week|--days N] Overdue, past deadline, ready to plan, and each day's quests
85
+ qst quests [words] [filters] Find quests: --status, --overdue, --on, --from/--to,
86
+ --deadline-by, --under <quest>, --archived, --sort, --limit
87
+ qst quest show <quest> Status, dates, sub-quest progress, description, latest Quest Log
88
+ qst quest log <quest> Quest Log entries, newest first (--date for one day)
89
+ qst quest add <title> --under <quest> --on fri --due <date> --priority high --emoji 🚀 ...
90
+ qst quest edit <quest> --title --emoji --priority --start/--due (or 'none') --under/--top-level
91
+ qst quest status <quest> <s> backlog | todo | in-progress | done | archived
92
+ qst quest archive <quest> Hide it and its sub-quests (there is no delete)
93
+ qst quest plan <quest> <day>... Plan days; a Backlog quest becomes Todo
94
+ qst quest unplan <quest> <day> Remove a planned day (never demotes)
95
+ qst quest reschedule <q> <from> [to] Move one planned day (to today by default)
96
+ qst quest check <quest> Check off a day (--date, --undo); starts Backlog/Todo quests
97
+ qst quest note <quest> <text> Append to that day's Quest Log entry (--date, --replace)
98
+ qst quest link|unlink <quest> <streak> Link a streak (context only, nothing syncs)
99
+ ```
100
+
101
+ Every command takes `--json` for machine-readable output on stdout. Every write takes
102
+ `--dry-run`, which prints the request and sends nothing.
103
+
104
+ ### Windows
105
+
106
+ `events`, `show` and `review` share one set of window flags: `--today`, `--week`
107
+ (default), `--days N`, or an explicit `--from` / `--to`. The explicit and relative forms
108
+ cannot be combined. Dates accept `today`, `yesterday`, a weekday (`mon`..`sun`, meaning
109
+ the most recent past one), `YYYY-MM-DD`, or an offset like `-3d`.
110
+
111
+ ### Streaks are named, never UUIDs
112
+
113
+ `<streak>` is a name. Resolution tries exact match, then unique prefix, then unique
114
+ substring. On zero or multiple matches it exits `3`, lists the candidates, and writes
115
+ nothing — it never guesses.
116
+
117
+ ```
118
+ $ qst log r 1
119
+ Streak 'r' is ambiguous — candidates: Reading, Running. Nothing was written.
120
+ ```
121
+
122
+ ### Quests are named too — by title or path
123
+
124
+ `<quest>` is a title, or a path of titles that names the parents as well:
125
+ `"Launch > Backend > Schema"` (`›` works too). The server resolves it — exact, then unique
126
+ prefix, then unique substring; archived quests only when nothing live matches — and on
127
+ zero or several matches `qst` exits `3`, prints the candidates' full paths, and writes
128
+ nothing.
129
+
130
+ ```
131
+ $ qst quest check "write migration"
132
+ Quest 'write migration' is ambiguous — 2 quests match: Launch › Backend › Write migration;
133
+ Blog › Write migration. Use a path such as 'Launch > Backend > Write migration' to pick one.
134
+ Nothing was written.
135
+ ```
136
+
137
+ With `--json`, the failure is one JSON document on stdout: `{"error": {"exit_code": 3,
138
+ "message": ..., "candidates": [...]}}`.
139
+
140
+ ### Which way date words point
141
+
142
+ Where you plan (`add --on/--due/--start`, `plan`, a reschedule target, `agenda`/`quests
143
+ --from/--to`), a weekday means the **coming** one: `fri` on a Wednesday is this Friday,
144
+ and today if it is Friday; `next fri` is a week later; `+1w` is a week from today.
145
+ Where you record (`check --date`, `note --date`, streak commands), a weekday is the most
146
+ recent one and `last fri` is strictly before today; `check` refuses future days.
147
+ Where you name a day the quest already has (`reschedule`'s first day, `unplan`), a
148
+ weekday picks the quest's own date on that weekday within a week of today, in either
149
+ direction — so `qst quest reschedule report tue thu` works whether Tuesday was yesterday
150
+ or is next week.
151
+
152
+ ### Retrying writes safely
153
+
154
+ Dropped connections are retried automatically with the same idempotency key. If a write
155
+ still fails with exit 4, the message includes the key; re-run with
156
+ `qst --idempotency-key <key> ...` (or set `QUAESTOR_IDEMPOTENCY_KEY`) and the server
157
+ replays the original write instead of creating a duplicate.
158
+
159
+ Quest reads send your local date (`today=`), so overdue and "today" match the web app.
160
+ Writes that change a status automatically say so (`Status: Todo → In progress.`).
161
+
162
+ ### `--unit` is a safety check, not data
163
+
164
+ Units belong to the streak, not the event, so `--unit` is never sent to the API. It
165
+ asserts what you believe the streak measures and fails before writing if you are wrong —
166
+ which is what stops an assistant logging "5 miles" as 5 km.
167
+
168
+ ```
169
+ $ qst log running 5 --unit miles
170
+ 'Running' is measured in km, not miles.
171
+ ```
172
+
173
+ ## Exit codes
174
+
175
+ An agent's only reliable error channel, so they are stable:
176
+
177
+ | Code | Meaning |
178
+ | --- | --- |
179
+ | 0 | Success |
180
+ | 1 | Bad arguments or unparseable input |
181
+ | 2 | Authentication failed, or the key lacks the required scope |
182
+ | 3 | Unknown or ambiguous streak or quest |
183
+ | 4 | Server or network error |
184
+
185
+ On failure the server's `detail` message is printed verbatim to stderr.
186
+
187
+ ## Scopes
188
+
189
+ A key only carries the permissions granted when it was created, and the server enforces
190
+ them. Insufficient scope returns a message naming what is missing, so it is clear whether
191
+ something is a bug or a deliberate restriction:
192
+
193
+ ```
194
+ $ qst log reading 5
195
+ API key is missing required scope(s): events:write
196
+ ```
197
+
198
+ Deleting a streak has no scope at all — it is web-app only, because it also destroys every
199
+ event, total and milestone beneath it. The same goes for deleting a quest or a Quest Log
200
+ entry: `qst` has no delete command, and a key can archive a quest instead.
201
+
202
+ Quest commands need `quests:read` (agenda, lists, show, log) and `quests:write`
203
+ (everything else, archive included). Both are pre-checked when a key is created; older
204
+ keys lack them, and `qst doctor` lists the scopes a key actually holds plus a hint naming
205
+ the quest commands that will fail.
206
+
207
+ ## Using this with an AI assistant
208
+
209
+ The repository ships an [Agent
210
+ Skill](https://github.com/neuromaxer/quaestor-lite/blob/main/skills/quaestor/SKILL.md)
211
+ that teaches an assistant when and how to use these commands, following the
212
+ [agentskills.io](https://agentskills.io) format. Copy it to `~/.agents/skills/` (OpenClaw)
213
+ or `~/.hermes/skills/` (Hermes Agent).
214
+
215
+ Design notes and the security model are in the [feature
216
+ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/features/agent-connect/agent-connect.md).
217
+
218
+ ## Requirements
219
+
220
+ Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
221
+ Quaestor backend, so it installs in seconds and tolerates version skew against the server.
@@ -0,0 +1,199 @@
1
+ # quaestor-cli
2
+
3
+ `qst` — a command-line client for [Quaestor](https://quaestor.app), an agent-native
4
+ platform for diary-based life management. An evening diary becomes tracked habits,
5
+ quest progress, and a morning plan.
6
+
7
+ Built for two users at once: a person in a terminal, and an AI assistant acting on their
8
+ behalf. That second audience shapes most of the design — commands are task-shaped rather
9
+ than endpoint-shaped, streaks and quests are addressed by name, failures are
10
+ distinguishable by exit code, and the server's own error message is printed verbatim so
11
+ an agent can relay something true.
12
+
13
+ ```bash
14
+ uv tool install quaestor-cli
15
+ ```
16
+
17
+ ## Setup
18
+
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:
21
+
22
+ ```bash
23
+ export QUAESTOR_URL="https://your-quaestor-backend" # defaults to http://localhost:8000
24
+ pbpaste | qst auth login
25
+ qst doctor
26
+ ```
27
+
28
+ On other systems, run `qst auth login`, paste the key, press Enter, then signal
29
+ end-of-input (Ctrl-D on Unix).
30
+
31
+ The key is stored at `~/.config/quaestor/credentials.json` with mode `0600`. The
32
+ CLI also supports `QUAESTOR_API_KEY`; an existing environment variable takes
33
+ precedence over the file, but it is not needed for this setup.
34
+
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.
38
+
39
+ ## The evening diary workflow
40
+
41
+ Spend 10–15 minutes writing or recording your day. Your agent uses `qst` to log
42
+ measured habits, record quest progress, and plan the work you want to do tomorrow.
43
+ It reports what changed and prepares a short morning plan with Main and Side Quests.
44
+
45
+ Keep the original diary with your agent or in a place you choose. The CLI stores
46
+ habit records and quest-specific notes in Quaestor, not a general diary entry.
47
+ Those original entries become a record of your progress and a story worth keeping.
48
+
49
+ ## Commands
50
+
51
+ ```
52
+ qst streaks List streaks with mode, unit and target
53
+ qst show <streak> Full configuration plus per-day totals, including missed days
54
+ qst log <streak> <value> Log a COUNT amount
55
+ qst log <streak> --minutes 45 Log a TIME block (45, 45m, 1h30m, 1.5h)
56
+ qst events <streak> Individual entries, newest first, with the source of each
57
+ qst review Totals, active days and current streak per streak
58
+ qst timer start|stop|status Control the single running timer
59
+ qst auth login|whoami|logout Manage the stored credential
60
+ qst doctor Check URL, reachability, credential and granted scopes
61
+
62
+ qst agenda [--week|--days N] Overdue, past deadline, ready to plan, and each day's quests
63
+ qst quests [words] [filters] Find quests: --status, --overdue, --on, --from/--to,
64
+ --deadline-by, --under <quest>, --archived, --sort, --limit
65
+ qst quest show <quest> Status, dates, sub-quest progress, description, latest Quest Log
66
+ qst quest log <quest> Quest Log entries, newest first (--date for one day)
67
+ qst quest add <title> --under <quest> --on fri --due <date> --priority high --emoji 🚀 ...
68
+ qst quest edit <quest> --title --emoji --priority --start/--due (or 'none') --under/--top-level
69
+ qst quest status <quest> <s> backlog | todo | in-progress | done | archived
70
+ qst quest archive <quest> Hide it and its sub-quests (there is no delete)
71
+ qst quest plan <quest> <day>... Plan days; a Backlog quest becomes Todo
72
+ qst quest unplan <quest> <day> Remove a planned day (never demotes)
73
+ qst quest reschedule <q> <from> [to] Move one planned day (to today by default)
74
+ qst quest check <quest> Check off a day (--date, --undo); starts Backlog/Todo quests
75
+ qst quest note <quest> <text> Append to that day's Quest Log entry (--date, --replace)
76
+ qst quest link|unlink <quest> <streak> Link a streak (context only, nothing syncs)
77
+ ```
78
+
79
+ Every command takes `--json` for machine-readable output on stdout. Every write takes
80
+ `--dry-run`, which prints the request and sends nothing.
81
+
82
+ ### Windows
83
+
84
+ `events`, `show` and `review` share one set of window flags: `--today`, `--week`
85
+ (default), `--days N`, or an explicit `--from` / `--to`. The explicit and relative forms
86
+ cannot be combined. Dates accept `today`, `yesterday`, a weekday (`mon`..`sun`, meaning
87
+ the most recent past one), `YYYY-MM-DD`, or an offset like `-3d`.
88
+
89
+ ### Streaks are named, never UUIDs
90
+
91
+ `<streak>` is a name. Resolution tries exact match, then unique prefix, then unique
92
+ substring. On zero or multiple matches it exits `3`, lists the candidates, and writes
93
+ nothing — it never guesses.
94
+
95
+ ```
96
+ $ qst log r 1
97
+ Streak 'r' is ambiguous — candidates: Reading, Running. Nothing was written.
98
+ ```
99
+
100
+ ### Quests are named too — by title or path
101
+
102
+ `<quest>` is a title, or a path of titles that names the parents as well:
103
+ `"Launch > Backend > Schema"` (`›` works too). The server resolves it — exact, then unique
104
+ prefix, then unique substring; archived quests only when nothing live matches — and on
105
+ zero or several matches `qst` exits `3`, prints the candidates' full paths, and writes
106
+ nothing.
107
+
108
+ ```
109
+ $ qst quest check "write migration"
110
+ Quest 'write migration' is ambiguous — 2 quests match: Launch › Backend › Write migration;
111
+ Blog › Write migration. Use a path such as 'Launch > Backend > Write migration' to pick one.
112
+ Nothing was written.
113
+ ```
114
+
115
+ With `--json`, the failure is one JSON document on stdout: `{"error": {"exit_code": 3,
116
+ "message": ..., "candidates": [...]}}`.
117
+
118
+ ### Which way date words point
119
+
120
+ Where you plan (`add --on/--due/--start`, `plan`, a reschedule target, `agenda`/`quests
121
+ --from/--to`), a weekday means the **coming** one: `fri` on a Wednesday is this Friday,
122
+ and today if it is Friday; `next fri` is a week later; `+1w` is a week from today.
123
+ Where you record (`check --date`, `note --date`, streak commands), a weekday is the most
124
+ recent one and `last fri` is strictly before today; `check` refuses future days.
125
+ Where you name a day the quest already has (`reschedule`'s first day, `unplan`), a
126
+ weekday picks the quest's own date on that weekday within a week of today, in either
127
+ direction — so `qst quest reschedule report tue thu` works whether Tuesday was yesterday
128
+ or is next week.
129
+
130
+ ### Retrying writes safely
131
+
132
+ Dropped connections are retried automatically with the same idempotency key. If a write
133
+ still fails with exit 4, the message includes the key; re-run with
134
+ `qst --idempotency-key <key> ...` (or set `QUAESTOR_IDEMPOTENCY_KEY`) and the server
135
+ replays the original write instead of creating a duplicate.
136
+
137
+ Quest reads send your local date (`today=`), so overdue and "today" match the web app.
138
+ Writes that change a status automatically say so (`Status: Todo → In progress.`).
139
+
140
+ ### `--unit` is a safety check, not data
141
+
142
+ Units belong to the streak, not the event, so `--unit` is never sent to the API. It
143
+ asserts what you believe the streak measures and fails before writing if you are wrong —
144
+ which is what stops an assistant logging "5 miles" as 5 km.
145
+
146
+ ```
147
+ $ qst log running 5 --unit miles
148
+ 'Running' is measured in km, not miles.
149
+ ```
150
+
151
+ ## Exit codes
152
+
153
+ An agent's only reliable error channel, so they are stable:
154
+
155
+ | Code | Meaning |
156
+ | --- | --- |
157
+ | 0 | Success |
158
+ | 1 | Bad arguments or unparseable input |
159
+ | 2 | Authentication failed, or the key lacks the required scope |
160
+ | 3 | Unknown or ambiguous streak or quest |
161
+ | 4 | Server or network error |
162
+
163
+ On failure the server's `detail` message is printed verbatim to stderr.
164
+
165
+ ## Scopes
166
+
167
+ A key only carries the permissions granted when it was created, and the server enforces
168
+ them. Insufficient scope returns a message naming what is missing, so it is clear whether
169
+ something is a bug or a deliberate restriction:
170
+
171
+ ```
172
+ $ qst log reading 5
173
+ API key is missing required scope(s): events:write
174
+ ```
175
+
176
+ Deleting a streak has no scope at all — it is web-app only, because it also destroys every
177
+ event, total and milestone beneath it. The same goes for deleting a quest or a Quest Log
178
+ entry: `qst` has no delete command, and a key can archive a quest instead.
179
+
180
+ Quest commands need `quests:read` (agenda, lists, show, log) and `quests:write`
181
+ (everything else, archive included). Both are pre-checked when a key is created; older
182
+ keys lack them, and `qst doctor` lists the scopes a key actually holds plus a hint naming
183
+ the quest commands that will fail.
184
+
185
+ ## Using this with an AI assistant
186
+
187
+ The repository ships an [Agent
188
+ Skill](https://github.com/neuromaxer/quaestor-lite/blob/main/skills/quaestor/SKILL.md)
189
+ that teaches an assistant when and how to use these commands, following the
190
+ [agentskills.io](https://agentskills.io) format. Copy it to `~/.agents/skills/` (OpenClaw)
191
+ or `~/.hermes/skills/` (Hermes Agent).
192
+
193
+ Design notes and the security model are in the [feature
194
+ documentation](https://github.com/neuromaxer/quaestor-lite/blob/main/docs/features/agent-connect/agent-connect.md).
195
+
196
+ ## Requirements
197
+
198
+ Python 3.12+. Depends only on `httpx` and `typer` — it speaks HTTP and never imports the
199
+ Quaestor backend, so it installs in seconds and tolerates version skew against the server.
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "quaestor-cli"
3
+ version = "0.3.0"
4
+ description = "Command-line client for the Quaestor API — built for humans and agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ # NOTE: no `license` yet — the repository has no LICENSE file, so publishing one here
8
+ # would be inventing a licensing decision. Until one is chosen, PyPI shows no license,
9
+ # which legally means all rights reserved.
10
+ keywords = ["quaestor", "habit-tracker", "cli", "agent", "ai"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Utilities",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27",
23
+ # 0.16 is the first release that defaults pretty_exceptions_show_locals to False.
24
+ # Older versions render local variables — including the API key — into a crash
25
+ # traceback. main.py also sets the flag explicitly; this floor is belt and braces.
26
+ "typer>=0.16",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://quaestor.app"
31
+ Repository = "https://github.com/neuromaxer/quaestor-lite"
32
+ Documentation = "https://github.com/neuromaxer/quaestor-lite/blob/main/apps/cli/README.md"
33
+ Issues = "https://github.com/neuromaxer/quaestor-lite/issues"
34
+
35
+ [project.scripts]
36
+ qst = "quaestor_cli.main:app"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=8.0",
41
+ "pytest-asyncio>=0.23",
42
+ "respx>=0.21",
43
+ "ruff>=0.6",
44
+ "mypy>=1.11",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["hatchling"]
49
+ build-backend = "hatchling.build"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/quaestor_cli"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
57
+
58
+ [tool.ruff]
59
+ line-length = 110
60
+ target-version = "py312"
61
+ src = ["src", "tests"]
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "UP", "B", "SIM"]
65
+
66
+ [tool.mypy]
67
+ python_version = "3.12"
68
+ strict = true
69
+ warn_unreachable = true
@@ -6,6 +6,7 @@ level adds ~18ms to every invocation, including `qst --help`.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import time
9
10
  from collections.abc import Mapping
10
11
  from dataclasses import dataclass
11
12
  from typing import Any
@@ -15,6 +16,7 @@ from quaestor_cli.errors import CliError, ExitCode
15
16
 
16
17
  API_PREFIX = "/api/v1"
17
18
  DEFAULT_TIMEOUT_SECONDS = 15.0
19
+ RETRY_BACKOFF_SECONDS = 0.5 # 0.5s, then 1s: enough to ride out a blip, short for an agent turn
18
20
 
19
21
  # Maps an HTTP failure onto the exit code an agent should act on.
20
22
  _STATUS_EXIT_CODES: dict[int, ExitCode] = {
@@ -135,26 +137,40 @@ class QuaestorClient:
135
137
  params: Mapping[str, Any] | None = None,
136
138
  body: Mapping[str, Any] | None = None,
137
139
  check: bool = True,
140
+ retries: int = 0,
138
141
  ) -> ApiResponse:
139
- """Send one request, mapping transport and HTTP failures to CliError."""
142
+ """Send one request, mapping transport and HTTP failures to CliError.
143
+
144
+ `retries` re-sends the *same* request after a transport failure (timeout, dropped
145
+ connection), never after an HTTP error. Only pass it for requests that are safe to
146
+ repeat: idempotent ones, or writes carrying an idempotency key.
147
+ """
140
148
  import httpx # Lazy: keeps `qst --help` fast.
141
149
 
142
150
  url = self.url_for(path)
143
- try:
144
- raw = httpx.request(
145
- method,
146
- url,
147
- params=_clean_params(params),
148
- json=dict(body) if body is not None else None,
149
- headers=self._headers(),
150
- timeout=self._timeout,
151
- )
152
- except httpx.HTTPError as exc:
153
- raise CliError(f"Cannot reach {url}: {exc}", ExitCode.SERVER) from exc
151
+ attempt = 0
152
+ while True:
153
+ try:
154
+ raw = httpx.request(
155
+ method,
156
+ url,
157
+ params=_clean_params(params),
158
+ json=dict(body) if body is not None else None,
159
+ headers=self._headers(),
160
+ timeout=self._timeout,
161
+ )
162
+ break
163
+ except httpx.TransportError as exc:
164
+ if attempt >= retries:
165
+ raise CliError(f"Cannot reach {url}: {exc}", ExitCode.SERVER) from exc
166
+ attempt += 1
167
+ time.sleep(RETRY_BACKOFF_SECONDS * attempt)
168
+ except httpx.HTTPError as exc:
169
+ raise CliError(f"Cannot reach {url}: {exc}", ExitCode.SERVER) from exc
154
170
 
155
171
  response = ApiResponse(status_code=raw.status_code, payload=_decode(raw))
156
172
  if check and not response.is_success:
157
- raise CliError(response.detail, _exit_code_for(response.status_code))
173
+ raise CliError(response.detail, exit_code_for(response.status_code))
158
174
  return response
159
175
 
160
176
  def _headers(self) -> dict[str, str]:
@@ -165,7 +181,7 @@ class QuaestorClient:
165
181
  return headers
166
182
 
167
183
 
168
- def _exit_code_for(status_code: int) -> ExitCode:
184
+ def exit_code_for(status_code: int) -> ExitCode:
169
185
  """Translate an HTTP status into the CLI's exit-code contract."""
170
186
  return _STATUS_EXIT_CODES.get(status_code, ExitCode.SERVER)
171
187
 
@@ -15,6 +15,26 @@ from quaestor_cli.runtime import CliState, JsonFlag, cli_state, execute
15
15
 
16
16
  UNKNOWN = "unknown"
17
17
 
18
+ # Scopes a key needs for each command family. Keys made before Quests existed lack
19
+ # the quest scopes; new keys get them by default (both pre-checked in the dashboard).
20
+ FEATURE_SCOPES: dict[str, str] = {
21
+ "quests:read": "qst agenda, qst quests, qst quest show/log",
22
+ "quests:write": "qst quest add/plan/check/note/link/archive",
23
+ }
24
+ NEW_KEY_HINT = (
25
+ "Create a new key in the web app (Profile → Developer Access): "
26
+ "new keys include quests:read and quests:write by default."
27
+ )
28
+
29
+
30
+ def scope_hints(scopes: list[str] | None) -> list[str]:
31
+ """Explain which commands a key's missing scopes switch off, and how to fix it."""
32
+ if scopes is None:
33
+ return []
34
+ missing = [scope for scope in FEATURE_SCOPES if scope not in scopes]
35
+ hints = [f"{scope} not granted — {FEATURE_SCOPES[scope]} will fail (403)." for scope in missing]
36
+ return [*hints, NEW_KEY_HINT] if hints else []
37
+
18
38
 
19
39
  @dataclass(frozen=True)
20
40
  class Diagnosis:
@@ -30,6 +50,8 @@ class Diagnosis:
30
50
  """None when the server has no /auth/whoami, i.e. scopes are unknowable."""
31
51
  user: str | None = None
32
52
  detail: str | None = None
53
+ hints: tuple[str, ...] = ()
54
+ """Advice that isn't a failure, e.g. scopes a key was created without."""
33
55
 
34
56
  @property
35
57
  def exit_code(self) -> ExitCode:
@@ -89,6 +111,7 @@ def diagnose(state: CliState) -> Diagnosis:
89
111
  scopes=scopes,
90
112
  user=user,
91
113
  detail=detail,
114
+ hints=tuple(scope_hints(scopes)),
92
115
  )
93
116
 
94
117
 
@@ -129,11 +152,14 @@ def _check_credential(state: CliState) -> _CredentialCheck:
129
152
 
130
153
  def _lines(diagnosis: Diagnosis) -> tuple[str, ...]:
131
154
  """Render the diagnosis as aligned human-readable lines."""
132
- scopes = (
133
- format_scopes(diagnosis.scopes)
134
- if diagnosis.scopes is not None
135
- else f"{UNKNOWN} (this server has no /auth/whoami)"
136
- )
155
+ if diagnosis.scopes is not None:
156
+ scopes = format_scopes(diagnosis.scopes)
157
+ elif not diagnosis.authenticated:
158
+ # The credential was rejected, so scopes were never knowable. Blaming the
159
+ # server's age here points the reader at the wrong problem.
160
+ scopes = f"{UNKNOWN} (credential was rejected)"
161
+ else:
162
+ scopes = f"{UNKNOWN} (this server has no /auth/whoami)"
137
163
  fields = (
138
164
  ("cli version", diagnosis.cli_version),
139
165
  ("base url", diagnosis.base_url),
@@ -144,4 +170,6 @@ def _lines(diagnosis: Diagnosis) -> tuple[str, ...]:
144
170
  ("user", diagnosis.user or "-"),
145
171
  ("scopes", scopes),
146
172
  )
147
- return tuple(f"{label + ':':<15}{value}" for label, value in fields)
173
+ lines = [f"{label + ':':<15}{value}" for label, value in fields]
174
+ lines += [f"{'hint:':<15}{hint}" for hint in diagnosis.hints]
175
+ return tuple(lines)