tetra-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. tetra_cli/__init__.py +6 -0
  2. tetra_cli/api_client/__init__.py +10 -0
  3. tetra_cli/api_client/client.py +173 -0
  4. tetra_cli/api_client/config.py +125 -0
  5. tetra_cli/api_client/operations/__init__.py +9 -0
  6. tetra_cli/api_client/operations/accounts.py +303 -0
  7. tetra_cli/api_client/operations/ai.py +278 -0
  8. tetra_cli/api_client/operations/analysis.py +190 -0
  9. tetra_cli/api_client/operations/api_keys.py +145 -0
  10. tetra_cli/api_client/operations/archive.py +114 -0
  11. tetra_cli/api_client/operations/awards.py +123 -0
  12. tetra_cli/api_client/operations/capacity.py +84 -0
  13. tetra_cli/api_client/operations/conversations.py +447 -0
  14. tetra_cli/api_client/operations/conversations_2.py +262 -0
  15. tetra_cli/api_client/operations/cosmetics.py +148 -0
  16. tetra_cli/api_client/operations/dashboard.py +282 -0
  17. tetra_cli/api_client/operations/data.py +250 -0
  18. tetra_cli/api_client/operations/events.py +734 -0
  19. tetra_cli/api_client/operations/gamification.py +470 -0
  20. tetra_cli/api_client/operations/goals.py +1144 -0
  21. tetra_cli/api_client/operations/groups.py +647 -0
  22. tetra_cli/api_client/operations/issues.py +198 -0
  23. tetra_cli/api_client/operations/offset.py +61 -0
  24. tetra_cli/api_client/operations/onboarding.py +284 -0
  25. tetra_cli/api_client/operations/outcome_schemas.py +292 -0
  26. tetra_cli/api_client/operations/peer_connections.py +243 -0
  27. tetra_cli/api_client/operations/plaid.py +329 -0
  28. tetra_cli/api_client/operations/reminders.py +273 -0
  29. tetra_cli/api_client/operations/scratches.py +280 -0
  30. tetra_cli/api_client/operations/skill_trees.py +160 -0
  31. tetra_cli/api_client/operations/social_2.py +560 -0
  32. tetra_cli/api_client/operations/social_3.py +618 -0
  33. tetra_cli/api_client/operations/social_4.py +527 -0
  34. tetra_cli/api_client/operations/strava.py +215 -0
  35. tetra_cli/api_client/operations/stripe.py +113 -0
  36. tetra_cli/api_client/operations/tags.py +488 -0
  37. tetra_cli/api_client/operations/values.py +867 -0
  38. tetra_cli/api_client/operations/values_2.py +584 -0
  39. tetra_cli/api_client/operations/watch.py +105 -0
  40. tetra_cli/api_client/operations/webhooks.py +50 -0
  41. tetra_cli/api_client/operations/xp.py +27 -0
  42. tetra_cli/cli/__init__.py +5 -0
  43. tetra_cli/cli/__main__.py +5 -0
  44. tetra_cli/cli/app.py +86 -0
  45. tetra_cli/cli/commands/__init__.py +1 -0
  46. tetra_cli/cli/commands/auth.py +201 -0
  47. tetra_cli/cli/commands/guide.py +8 -0
  48. tetra_cli/cli/commands/messages.py +161 -0
  49. tetra_cli/cli/commands/skill.py +71 -0
  50. tetra_cli/cli/context.py +13 -0
  51. tetra_cli/cli/generate.py +282 -0
  52. tetra_cli/cli/output.py +58 -0
  53. tetra_cli/mcp_gen.py +137 -0
  54. tetra_cli/ontology.py +70 -0
  55. tetra_cli/registry.py +118 -0
  56. tetra_cli/skill/SKILL.md +69 -0
  57. tetra_cli/skill/__init__.py +1 -0
  58. tetra_cli-0.2.0.dist-info/METADATA +140 -0
  59. tetra_cli-0.2.0.dist-info/RECORD +62 -0
  60. tetra_cli-0.2.0.dist-info/WHEEL +5 -0
  61. tetra_cli-0.2.0.dist-info/entry_points.txt +2 -0
  62. tetra_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """Pure async operations for the AI chat API.
2
+
3
+ Covers natural-language querying (RAG + analytics tool calling), embedding
4
+ synchronization, chat history, model discovery, and per-account AI config.
5
+ Each op mirrors the FastAPI contract in ``tetra/api/routes/ai.py`` exactly;
6
+ optional body/query fields that are ``None`` are omitted so server defaults
7
+ apply.
8
+ """
9
+ from typing import Any
10
+
11
+ from tetra_cli.api_client import TetraClient
12
+ from tetra_cli.registry import arg, operation, opt
13
+
14
+
15
+ @operation(
16
+ cli="ai status",
17
+ summary="Check AI feature availability and configured LLM provider.",
18
+ covers=[("GET", "/api/v1/ai/status")],
19
+ )
20
+ async def ai_status(client: TetraClient) -> dict[str, Any]:
21
+ """Check AI feature availability.
22
+
23
+ Returns whether AI is enabled, which LLM provider/embedding model are
24
+ configured, and (when enabled) whether the LLM is reachable.
25
+
26
+ Args:
27
+ client: Authenticated TetraClient
28
+
29
+ Returns:
30
+ Dict with ai_enabled, llm_provider, embedding_model, and (if enabled)
31
+ llm_available.
32
+ """
33
+ return await client.get("/api/v1/ai/status")
34
+
35
+
36
+ @operation(
37
+ cli="ai models",
38
+ summary="List available LLM models for the configured provider.",
39
+ covers=[("GET", "/api/v1/ai/models")],
40
+ )
41
+ async def list_models(client: TetraClient) -> dict[str, Any]:
42
+ """List available LLM models.
43
+
44
+ For Ollama, this queries the local Ollama API for installed models.
45
+
46
+ Args:
47
+ client: Authenticated TetraClient
48
+
49
+ Returns:
50
+ Dict with 'models' list and the 'provider' name.
51
+ """
52
+ return await client.get("/api/v1/ai/models")
53
+
54
+
55
+ @operation(
56
+ cli="ai config",
57
+ summary="Get the current account's AI configuration.",
58
+ covers=[("GET", "/api/v1/ai/config")],
59
+ )
60
+ async def get_ai_config(client: TetraClient) -> dict[str, Any]:
61
+ """Get the current account's AI configuration.
62
+
63
+ Args:
64
+ client: Authenticated TetraClient
65
+
66
+ Returns:
67
+ Dict with 'llm_model' (null when using the server default).
68
+ """
69
+ return await client.get("/api/v1/ai/config")
70
+
71
+
72
+ @operation(
73
+ cli="ai set-config",
74
+ summary="Set or reset the current account's preferred LLM model.",
75
+ covers=[("PUT", "/api/v1/ai/config")],
76
+ params={
77
+ "llm_model": opt(
78
+ "--llm-model",
79
+ help="Preferred model name. Omit/clear to reset to server default.",
80
+ clears=True,
81
+ ),
82
+ },
83
+ )
84
+ async def update_ai_config(
85
+ client: TetraClient,
86
+ *,
87
+ llm_model: str | None = None,
88
+ ) -> dict[str, Any]:
89
+ """Update the current account's AI configuration.
90
+
91
+ The PUT route always reads the ``llm_model`` field: a non-null value sets
92
+ the preferred model, ``null`` resets to the server default. The body is
93
+ therefore always sent with an explicit ``llm_model`` key (defaulting to
94
+ ``None`` = reset) rather than being omitted.
95
+
96
+ Args:
97
+ client: Authenticated TetraClient
98
+ llm_model: Preferred model name, or None to reset to the server
99
+ default.
100
+
101
+ Returns:
102
+ Updated config dict with the effective 'llm_model'.
103
+ """
104
+ body: dict[str, Any] = {"llm_model": llm_model}
105
+ return await client.put("/api/v1/ai/config", json=body)
106
+
107
+
108
+ @operation(
109
+ cli="ai query",
110
+ summary="Ask a natural-language question about your data (RAG + tools).",
111
+ covers=[("POST", "/api/v1/ai/query")],
112
+ params={
113
+ "query": arg(help="Natural-language question (1-2000 chars)."),
114
+ "entity_types": opt(
115
+ "--entity-type", repeatable=True,
116
+ help="Restrict search to entity types (value | goal | event).",
117
+ ),
118
+ "date_start": opt("--date-start", help="Filter start date (YYYY-MM-DD)."),
119
+ "date_end": opt("--date-end", help="Filter end date (YYYY-MM-DD)."),
120
+ },
121
+ )
122
+ async def ai_query(
123
+ client: TetraClient,
124
+ *,
125
+ query: str,
126
+ entity_types: list[str] | None = None,
127
+ date_start: str | None = None,
128
+ date_end: str | None = None,
129
+ ) -> dict[str, Any]:
130
+ """Execute a natural-language query against the account's data.
131
+
132
+ Mirrors ``AIQueryRequest``: ``query`` is required, the rest are optional
133
+ filters omitted when not provided. The exchange is persisted to chat
134
+ history server-side.
135
+
136
+ Args:
137
+ client: Authenticated TetraClient
138
+ query: Natural-language question (1-2000 characters).
139
+ entity_types: Optional entity-type filter ('value', 'goal', 'event').
140
+ date_start: Optional ISO start date (YYYY-MM-DD) for filtering.
141
+ date_end: Optional ISO end date (YYYY-MM-DD) for filtering.
142
+
143
+ Returns:
144
+ Dict with 'answer', 'sources', 'tools_used', and 'query_id'.
145
+ """
146
+ body: dict[str, Any] = {"query": query}
147
+ if entity_types:
148
+ body["entity_types"] = entity_types
149
+ if date_start is not None:
150
+ body["date_start"] = date_start
151
+ if date_end is not None:
152
+ body["date_end"] = date_end
153
+ return await client.post("/api/v1/ai/query", json=body)
154
+
155
+
156
+ @operation(
157
+ cli="ai query-stream",
158
+ summary="Ask a question and stream progress via Server-Sent Events.",
159
+ covers=[("POST", "/api/v1/ai/query/stream")],
160
+ params={
161
+ "query": arg(help="Natural-language question (1-2000 chars)."),
162
+ "entity_types": opt(
163
+ "--entity-type", repeatable=True,
164
+ help="Restrict search to entity types (value | goal | event).",
165
+ ),
166
+ "date_start": opt("--date-start", help="Filter start date (YYYY-MM-DD)."),
167
+ "date_end": opt("--date-end", help="Filter end date (YYYY-MM-DD)."),
168
+ },
169
+ output="stream",
170
+ )
171
+ async def ai_query_stream(
172
+ client: TetraClient,
173
+ *,
174
+ query: str,
175
+ entity_types: list[str] | None = None,
176
+ date_start: str | None = None,
177
+ date_end: str | None = None,
178
+ ) -> dict[str, Any]:
179
+ """Stream AI query progress via Server-Sent Events.
180
+
181
+ Same request shape as :func:`ai_query` (the ``AIQueryRequest`` body); the
182
+ route returns an SSE stream (status events, then a done/error event)
183
+ instead of a single JSON response.
184
+
185
+ Args:
186
+ client: Authenticated TetraClient
187
+ query: Natural-language question (1-2000 characters).
188
+ entity_types: Optional entity-type filter ('value', 'goal', 'event').
189
+ date_start: Optional ISO start date (YYYY-MM-DD) for filtering.
190
+ date_end: Optional ISO end date (YYYY-MM-DD) for filtering.
191
+
192
+ Returns:
193
+ The raw stream payload as returned by the client transport.
194
+ """
195
+ body: dict[str, Any] = {"query": query}
196
+ if entity_types:
197
+ body["entity_types"] = entity_types
198
+ if date_start is not None:
199
+ body["date_start"] = date_start
200
+ if date_end is not None:
201
+ body["date_end"] = date_end
202
+ return await client.post("/api/v1/ai/query/stream", json=body)
203
+
204
+
205
+ @operation(
206
+ cli="ai sync",
207
+ summary="Re-index values, goals, and events for semantic search.",
208
+ covers=[("POST", "/api/v1/ai/embeddings/sync")],
209
+ )
210
+ async def sync_embeddings(client: TetraClient) -> dict[str, Any]:
211
+ """Trigger embedding generation/refresh for the current account.
212
+
213
+ Re-indexes all values, goals, and events for semantic search. This is an
214
+ expensive operation and should be called sparingly. Takes no body.
215
+
216
+ Args:
217
+ client: Authenticated TetraClient
218
+
219
+ Returns:
220
+ Dict with 'entities_indexed' and 'status'.
221
+ """
222
+ return await client.post("/api/v1/ai/embeddings/sync")
223
+
224
+
225
+ @operation(
226
+ cli="ai history",
227
+ summary="Get the current account's AI chat history (chronological).",
228
+ covers=[("GET", "/api/v1/ai/chat/history")],
229
+ params={
230
+ "limit": opt("--limit", min=1, max=200,
231
+ help="Max messages to return (1-200, default 50)."),
232
+ "offset": opt("--offset", min=0,
233
+ help="Number of messages to skip (default 0)."),
234
+ },
235
+ )
236
+ async def get_chat_history(
237
+ client: TetraClient,
238
+ *,
239
+ limit: int | None = None,
240
+ offset: int | None = None,
241
+ ) -> dict[str, Any]:
242
+ """Get chat history for the current account.
243
+
244
+ Returns the most recent messages ordered chronologically (ascending).
245
+ Query params are omitted when not provided so the server defaults
246
+ (limit=50, offset=0) apply.
247
+
248
+ Args:
249
+ client: Authenticated TetraClient
250
+ limit: Max number of messages to return (1-200).
251
+ offset: Number of messages to skip for pagination.
252
+
253
+ Returns:
254
+ Dict with 'messages' list and 'total' count.
255
+ """
256
+ params: dict[str, Any] = {}
257
+ if limit is not None:
258
+ params["limit"] = limit
259
+ if offset is not None:
260
+ params["offset"] = offset
261
+ return await client.get("/api/v1/ai/chat/history", params=params or None)
262
+
263
+
264
+ @operation(
265
+ cli="ai clear-history",
266
+ summary="Delete all AI chat history for the current account.",
267
+ covers=[("DELETE", "/api/v1/ai/chat/history")],
268
+ )
269
+ async def clear_chat_history(client: TetraClient) -> dict[str, Any]:
270
+ """Clear all chat history for the current account.
271
+
272
+ Args:
273
+ client: Authenticated TetraClient
274
+
275
+ Returns:
276
+ Dict with 'deleted' count and 'status'.
277
+ """
278
+ return await client.delete("/api/v1/ai/chat/history")
@@ -0,0 +1,190 @@
1
+ """Pure async operations for the analysis/export APIs."""
2
+ from typing import Any
3
+
4
+ from tetra_cli.api_client import TetraClient
5
+ from tetra_cli.registry import operation, opt
6
+
7
+
8
+ @operation(
9
+ cli="analysis time-allocation",
10
+ summary="Show how time is allocated across values for a date range.",
11
+ covers=[("GET", "/api/v1/analysis/time-allocation")],
12
+ params={
13
+ "start_date": opt("--start-date", help="Start date filter (YYYY-MM-DD)"),
14
+ "end_date": opt("--end-date", help="End date filter (YYYY-MM-DD)"),
15
+ },
16
+ )
17
+ async def get_time_allocation(
18
+ client: TetraClient,
19
+ *,
20
+ start_date: str | None = None,
21
+ end_date: str | None = None,
22
+ ) -> dict[str, Any]:
23
+ """Show how time is allocated across values for a date range.
24
+
25
+ Args:
26
+ client: Authenticated TetraClient
27
+ start_date: Optional start-date filter (YYYY-MM-DD)
28
+ end_date: Optional end-date filter (YYYY-MM-DD)
29
+
30
+ Returns:
31
+ Dict with total_hours and a per-value allocation list.
32
+ """
33
+ params: dict[str, Any] = {}
34
+ if start_date:
35
+ params["start_date"] = start_date
36
+ if end_date:
37
+ params["end_date"] = end_date
38
+ return await client.get("/api/v1/analysis/time-allocation", params=params)
39
+
40
+
41
+ @operation(
42
+ cli="analysis spending-by-goal",
43
+ summary="Show spending (outcomes) distributed across goals for a date range.",
44
+ covers=[("GET", "/api/v1/analysis/spending-by-goal")],
45
+ params={
46
+ "start_date": opt("--start-date", help="Start date filter (YYYY-MM-DD)"),
47
+ "end_date": opt("--end-date", help="End date filter (YYYY-MM-DD)"),
48
+ },
49
+ )
50
+ async def get_spending_by_goal(
51
+ client: TetraClient,
52
+ *,
53
+ start_date: str | None = None,
54
+ end_date: str | None = None,
55
+ ) -> dict[str, Any]:
56
+ """Show spending (outcomes) distributed across goals for a date range.
57
+
58
+ Args:
59
+ client: Authenticated TetraClient
60
+ start_date: Optional start-date filter (YYYY-MM-DD)
61
+ end_date: Optional end-date filter (YYYY-MM-DD)
62
+
63
+ Returns:
64
+ Dict with total_spending and a per-goal spending list.
65
+ """
66
+ params: dict[str, Any] = {}
67
+ if start_date:
68
+ params["start_date"] = start_date
69
+ if end_date:
70
+ params["end_date"] = end_date
71
+ return await client.get("/api/v1/analysis/spending-by-goal", params=params)
72
+
73
+
74
+ @operation(
75
+ cli="analysis effective-hourly",
76
+ summary="Calculate effective hourly rate from an income goal and a work value.",
77
+ covers=[("GET", "/api/v1/analysis/effective-hourly")],
78
+ params={
79
+ "income_goal": opt("--income-goal", help="Goal name that tracks income"),
80
+ "work_value": opt("--work-value", help="Value name that tracks work time"),
81
+ "start": opt("--start", help="Start date filter (YYYY-MM-DD)"),
82
+ "end": opt("--end", help="End date filter (YYYY-MM-DD)"),
83
+ },
84
+ )
85
+ async def get_effective_hourly(
86
+ client: TetraClient,
87
+ *,
88
+ income_goal: str,
89
+ work_value: str,
90
+ start: str | None = None,
91
+ end: str | None = None,
92
+ ) -> dict[str, Any]:
93
+ """Calculate effective hourly rate ($/hour).
94
+
95
+ Compares total income (events on ``income_goal``) to total hours
96
+ (events on ``work_value``) to derive an effective rate. The backend
97
+ requires both ``income_goal`` and ``work_value`` query params; ``start``
98
+ and ``end`` are optional date filters.
99
+
100
+ Args:
101
+ client: Authenticated TetraClient
102
+ income_goal: Goal name that tracks income (required)
103
+ work_value: Value name that tracks work time (required)
104
+ start: Optional start-date filter (YYYY-MM-DD)
105
+ end: Optional end-date filter (YYYY-MM-DD)
106
+
107
+ Returns:
108
+ Dict with total_income, total_hours, and effective_rate.
109
+ """
110
+ params: dict[str, Any] = {
111
+ "income_goal": income_goal,
112
+ "work_value": work_value,
113
+ }
114
+ if start:
115
+ params["start"] = start
116
+ if end:
117
+ params["end"] = end
118
+ return await client.get("/api/v1/analysis/effective-hourly", params=params)
119
+
120
+
121
+ @operation(
122
+ cli="analysis double-draw-detection",
123
+ summary="Detect likely double-counted financial draws within a value hierarchy.",
124
+ covers=[("GET", "/api/v1/analysis/double-draw-detection")],
125
+ params={
126
+ "value_uid": opt("--value-uid", help="Parent value UID to scan"),
127
+ "start": opt("--start", help="Start date (default: 90 days ago)"),
128
+ "end": opt("--end", help="End date (default: today)"),
129
+ "min_confidence": opt("--min-confidence", min=0, max=1,
130
+ help="Minimum confidence threshold (0-1)"),
131
+ },
132
+ )
133
+ async def get_double_draw_detection(
134
+ client: TetraClient,
135
+ *,
136
+ value_uid: str,
137
+ start: str | None = None,
138
+ end: str | None = None,
139
+ min_confidence: float | None = None,
140
+ ) -> dict[str, Any]:
141
+ """Detect likely double-counted financial draws within a value hierarchy.
142
+
143
+ Scans the parent value (``value_uid``) and its children for lump events
144
+ on one child that match the sum of itemized events on a sibling child.
145
+ The backend requires ``value_uid``; ``start``/``end`` default to a
146
+ 90-day window and ``min_confidence`` defaults to 0.5 server-side.
147
+
148
+ Args:
149
+ client: Authenticated TetraClient
150
+ value_uid: Parent value UID to scan (required)
151
+ start: Optional start date (YYYY-MM-DD; default 90 days ago)
152
+ end: Optional end date (YYYY-MM-DD; default today)
153
+ min_confidence: Optional minimum confidence threshold (0-1)
154
+
155
+ Returns:
156
+ Dict with a ``matches`` list of detected double-draw matches.
157
+ """
158
+ params: dict[str, Any] = {"value_uid": value_uid}
159
+ if start:
160
+ params["start"] = start
161
+ if end:
162
+ params["end"] = end
163
+ if min_confidence is not None:
164
+ params["min_confidence"] = min_confidence
165
+ return await client.get(
166
+ "/api/v1/analysis/double-draw-detection", params=params,
167
+ )
168
+
169
+
170
+ @operation(
171
+ cli="data export",
172
+ summary="Export all account data as a .zip of CSVs (values, goals, events).",
173
+ covers=[("GET", "/api/v1/data/export")],
174
+ output="file",
175
+ )
176
+ async def export_data(client: TetraClient) -> tuple[bytes, str]:
177
+ """Export all account data as a downloadable zip archive.
178
+
179
+ The export endpoint returns a ``.zip`` containing ``values.csv``,
180
+ ``goals.csv``, and ``events.csv`` (the same archive ``data import``
181
+ accepts), so the op downloads the raw bytes rather than JSON-decoding
182
+ them. Over MCP the bytes are returned base64-encoded.
183
+
184
+ Args:
185
+ client: Authenticated TetraClient.
186
+
187
+ Returns:
188
+ A ``(content_bytes, filename)`` tuple for the file generator to write.
189
+ """
190
+ return await client.download("/api/v1/data/export")
@@ -0,0 +1,145 @@
1
+ """Pure async operations for the API-key management API.
2
+
3
+ These ops mirror ``backend/tetra/api/routes/api_keys.py``. API keys are the
4
+ agent's own credentials: an account creates a key (the raw token is shown
5
+ ONCE), lists its keys, updates scopes/avatar, and revokes keys. Webhook
6
+ registration on a key lives in ``webhooks.py`` (it shares the PATCH route but
7
+ is exposed as its own dedicated ops); the ``update`` op here handles the
8
+ scopes/avatar path of that same PATCH.
9
+ """
10
+ from typing import Any
11
+
12
+ from tetra_cli.api_client import TetraClient
13
+ from tetra_cli.registry import arg, operation, opt
14
+
15
+
16
+ @operation(
17
+ cli="api-keys list",
18
+ summary="List all API keys for the current account.",
19
+ covers=[("GET", "/api/v1/api-keys/")],
20
+ )
21
+ async def list_api_keys(client: TetraClient) -> list[dict[str, Any]]:
22
+ """List all API keys for the current account.
23
+
24
+ The raw token is never returned here — it is only shown once at
25
+ creation time. Each entry exposes the prefix, scopes, avatar, activity
26
+ timestamps, and whether a webhook is configured.
27
+
28
+ Args:
29
+ client: Authenticated TetraClient
30
+
31
+ Returns:
32
+ List of API-key dicts (without raw tokens).
33
+ """
34
+ return await client.get("/api/v1/api-keys/")
35
+
36
+
37
+ @operation(
38
+ cli="api-keys create",
39
+ summary="Create a new API key (raw token is shown only once).",
40
+ covers=[("POST", "/api/v1/api-keys/")],
41
+ params={
42
+ "name": arg(help="Human-readable key name (max 100 chars)."),
43
+ "scopes": opt("--scope", repeatable=True,
44
+ help="Permission scope (repeatable; at least one required)."),
45
+ "expires_at": opt("--expires-at",
46
+ help="ISO-8601 expiry timestamp (UTC). Omit for no expiry."),
47
+ "avatar": opt("--avatar", json=True,
48
+ help="Avatar configuration as a JSON dict."),
49
+ },
50
+ )
51
+ async def create_api_key(
52
+ client: TetraClient,
53
+ *,
54
+ name: str,
55
+ scopes: list[str],
56
+ expires_at: str | None = None,
57
+ avatar: dict[str, Any] | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Create a new API key.
60
+
61
+ The response includes ``raw_token`` exactly once — it cannot be
62
+ retrieved again, so the caller must capture it immediately. Only fields
63
+ that are provided are sent; ``expires_at`` and ``avatar`` are omitted
64
+ when None so the API applies its defaults (no expiry / no avatar).
65
+
66
+ Args:
67
+ client: Authenticated TetraClient
68
+ name: Human-readable key name (required, max 100 chars)
69
+ scopes: Permission scopes (at least one required)
70
+ expires_at: Optional ISO-8601 UTC expiry timestamp
71
+ avatar: Optional avatar configuration dict
72
+
73
+ Returns:
74
+ Dict with ``key`` (the API-key metadata) and ``raw_token``.
75
+ """
76
+ body: dict[str, Any] = {"name": name, "scopes": scopes}
77
+ if expires_at is not None:
78
+ body["expires_at"] = expires_at
79
+ if avatar is not None:
80
+ body["avatar"] = avatar
81
+ return await client.post("/api/v1/api-keys/", json=body)
82
+
83
+
84
+ @operation(
85
+ cli="api-keys update",
86
+ summary="Update an API key's scopes and/or avatar.",
87
+ covers=[("PATCH", "/api/v1/api-keys/{uid}")],
88
+ params={
89
+ "uid": arg(help="API key UID."),
90
+ "scopes": opt("--scope", repeatable=True,
91
+ help="Permission scope (repeatable; replaces all scopes)."),
92
+ "avatar": opt("--avatar", json=True,
93
+ help="Avatar configuration as a JSON dict (pass to clear handled separately)."),
94
+ },
95
+ )
96
+ async def update_api_key(
97
+ client: TetraClient,
98
+ *,
99
+ uid: str,
100
+ scopes: list[str] | None = None,
101
+ avatar: dict[str, Any] | None = None,
102
+ ) -> dict[str, Any]:
103
+ """Update an API key's scopes and/or avatar.
104
+
105
+ The backend keys off ``model_fields_set``: only fields present in the
106
+ request body are applied, and a missing field is left untouched. This op
107
+ therefore only includes ``scopes`` / ``avatar`` when the caller actually
108
+ passed them — a scopes-only update never disturbs the avatar (or a
109
+ configured webhook), and vice-versa. Webhook registration is handled by
110
+ the dedicated ops in ``webhooks.py``.
111
+
112
+ Args:
113
+ client: Authenticated TetraClient
114
+ uid: API key UID
115
+ scopes: New scope list (replaces all scopes; cannot be null)
116
+ avatar: New avatar configuration dict
117
+
118
+ Returns:
119
+ Dict ``{"updated": True}`` on success.
120
+ """
121
+ body: dict[str, Any] = {}
122
+ if scopes is not None:
123
+ body["scopes"] = scopes
124
+ if avatar is not None:
125
+ body["avatar"] = avatar
126
+ return await client.patch(f"/api/v1/api-keys/{uid}", json=body)
127
+
128
+
129
+ @operation(
130
+ cli="api-keys revoke",
131
+ summary="Revoke (deactivate) an API key.",
132
+ covers=[("DELETE", "/api/v1/api-keys/{uid}")],
133
+ params={"uid": arg(help="API key UID to revoke.")},
134
+ )
135
+ async def revoke_api_key(client: TetraClient, uid: str) -> dict[str, Any]:
136
+ """Revoke an API key, immediately deactivating it.
137
+
138
+ Args:
139
+ client: Authenticated TetraClient
140
+ uid: API key UID to revoke
141
+
142
+ Returns:
143
+ Dict ``{"revoked": True}`` on success.
144
+ """
145
+ return await client.delete(f"/api/v1/api-keys/{uid}")