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
tetra_cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """tetra-cli: standalone, backend-free client for the Tetra API.
2
+
3
+ Speaks only HTTP to a remote Tetra API with a bearer token. Contains NO
4
+ backend server code (no models, routes, services, ORM). Safe to distribute.
5
+ """
6
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ """Shared HTTP client and operations for the Tetra API.
2
+
3
+ TetraClient/TetraConfig live in .client/.config (this package — no backend).
4
+ The operations package is the single source of truth for HTTP path, body
5
+ shape, and parameter conversions (e.g. duration_minutes -> seconds).
6
+ """
7
+ from tetra_cli.api_client.client import TetraClient, TetraClientError
8
+ from tetra_cli.api_client.config import TetraConfig
9
+
10
+ __all__ = ["TetraClient", "TetraClientError", "TetraConfig"]
@@ -0,0 +1,173 @@
1
+ """HTTP client wrapper for the Tetra API.
2
+
3
+ Handles authentication headers, error normalization, and response parsing.
4
+ All MCP tools use this client instead of making raw httpx calls.
5
+ """
6
+ import logging
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from tetra_cli.api_client.config import TetraConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class TetraClientError(Exception):
17
+ """Error from the Tetra API, formatted for MCP tool output."""
18
+
19
+ def __init__(self, message: str, status_code: int = 0):
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+
23
+
24
+ class TetraClient:
25
+ """Async HTTP client for the Tetra REST API."""
26
+
27
+ def __init__(self, config: TetraConfig):
28
+ self._config = config
29
+ self._client = httpx.AsyncClient(
30
+ base_url=config.api_url,
31
+ headers={
32
+ "Authorization": f"Bearer {config.token}",
33
+ # The CLI/MCP surface is agent-driven by definition. The backend
34
+ # honors this header in ``is_agent_request`` so created/updated
35
+ # entities get ``agent_touched=True`` regardless of whether the
36
+ # token is a real API key or a JWT minted via ``dev_token.py``.
37
+ "X-Tetra-Agent": "1",
38
+ },
39
+ timeout=30.0,
40
+ )
41
+
42
+ async def close(self) -> None:
43
+ """Close the underlying HTTP client."""
44
+ await self._client.aclose()
45
+
46
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
47
+ """GET request with error handling."""
48
+ response = await self._client.get(path, params=_clean_params(params))
49
+ return _handle_response(response)
50
+
51
+ async def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
52
+ """POST request with error handling."""
53
+ response = await self._client.post(path, json=json)
54
+ return _handle_response(response)
55
+
56
+ async def put(self, path: str, json: dict[str, Any] | None = None) -> Any:
57
+ """PUT request with error handling."""
58
+ response = await self._client.put(path, json=json)
59
+ return _handle_response(response)
60
+
61
+ async def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
62
+ """PATCH request with error handling."""
63
+ response = await self._client.patch(path, json=json)
64
+ return _handle_response(response)
65
+
66
+ async def delete(self, path: str) -> Any:
67
+ """DELETE request with error handling."""
68
+ response = await self._client.delete(path)
69
+ return _handle_response(response)
70
+
71
+ async def download(
72
+ self, path: str, params: dict[str, Any] | None = None
73
+ ) -> tuple[bytes, str]:
74
+ """GET a binary/file response. Returns ``(content_bytes, filename)``.
75
+
76
+ Used by ``output="file"`` ops whose endpoints return binary or
77
+ non-JSON bodies (zip, CSV, iCal). Unlike :meth:`get`, this never
78
+ JSON-decodes the response. On a non-2xx status it delegates to
79
+ :func:`_handle_response`, which raises :class:`TetraClientError`
80
+ (and is guarded against binary error bodies).
81
+
82
+ Args:
83
+ path: API path to GET.
84
+ params: Optional query params (None values are dropped).
85
+
86
+ Returns:
87
+ A tuple of the raw response bytes and a best-effort filename
88
+ derived from the ``Content-Disposition`` header, the last path
89
+ segment, or ``"download"``.
90
+ """
91
+ response = await self._client.get(path, params=_clean_params(params))
92
+ if not (200 <= response.status_code < 300):
93
+ # Reuse the non-2xx error path; it always raises TetraClientError.
94
+ _handle_response(response)
95
+ content = response.content
96
+ filename = (
97
+ _filename_from_disposition(response.headers.get("content-disposition"))
98
+ or path.rstrip("/").split("/")[-1]
99
+ or "download"
100
+ )
101
+ return content, filename
102
+
103
+
104
+ def _filename_from_disposition(value: str | None) -> str | None:
105
+ """Extract the filename from a ``Content-Disposition`` header value.
106
+
107
+ Parses ``attachment; filename=foo.zip`` (and the quoted
108
+ ``filename="foo.zip"`` variant). Returns None when the header is absent
109
+ or carries no ``filename`` directive.
110
+
111
+ Args:
112
+ value: The raw ``Content-Disposition`` header value, or None.
113
+
114
+ Returns:
115
+ The unquoted filename, or None.
116
+ """
117
+ if not value:
118
+ return None
119
+ for part in value.split(";"):
120
+ part = part.strip()
121
+ if part.lower().startswith("filename="):
122
+ name = part[len("filename="):].strip().strip('"')
123
+ return name or None
124
+ return None
125
+
126
+
127
+ def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
128
+ """Remove None values from query parameters."""
129
+ if params is None:
130
+ return None
131
+ return {k: v for k, v in params.items() if v is not None}
132
+
133
+
134
+ def _handle_response(response: httpx.Response) -> Any:
135
+ """Parse response and raise clear errors for non-2xx status codes."""
136
+ if response.status_code == 204:
137
+ return {"deleted": True}
138
+
139
+ if 200 <= response.status_code < 300:
140
+ return response.json()
141
+
142
+ # Extract error detail from response body. Guard every access: a binary
143
+ # error body (e.g. an upstream returning a zip on failure) must not crash
144
+ # the error path itself, so fall back to text, then to the status code.
145
+ try:
146
+ body = response.json()
147
+ detail = body.get("detail", str(body))
148
+ except Exception:
149
+ try:
150
+ detail = response.text or f"HTTP {response.status_code}"
151
+ except Exception:
152
+ detail = f"HTTP {response.status_code}"
153
+
154
+ status = response.status_code
155
+
156
+ if status == 401:
157
+ raise TetraClientError(
158
+ "Authentication failed. Check TETRA_API_KEY or TETRA_USERNAME.", status
159
+ )
160
+ if status == 403:
161
+ raise TetraClientError(f"Permission denied: {detail}", status)
162
+ if status == 404:
163
+ raise TetraClientError(f"Not found: {detail}", status)
164
+ if status == 400:
165
+ raise TetraClientError(f"Invalid request: {detail}", status)
166
+ if status == 422:
167
+ raise TetraClientError(f"Validation error: {detail}", status)
168
+ if status >= 500:
169
+ raise TetraClientError(
170
+ f"Server error (HTTP {status}). Is the Tetra backend running?", status
171
+ )
172
+
173
+ raise TetraClientError(f"Unexpected HTTP {status}: {detail}", status)
@@ -0,0 +1,125 @@
1
+ """MCP server configuration and authentication resolution.
2
+
3
+ Resolves authentication token from environment variables using a three-tier
4
+ fallback: API key > credential file > login.
5
+ """
6
+ import json
7
+ import logging
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Default backend for a published install: the CLI exists to act on prod on the
17
+ # user's behalf, so an out-of-the-box `pip install` must point at prod. Local
18
+ # dev/tests override this with `TETRA_API_URL=http://localhost:8000`.
19
+ DEFAULT_API_URL = "https://api.tetraoptum.com"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class TetraConfig:
24
+ """Immutable configuration for the Tetra MCP server."""
25
+
26
+ api_url: str
27
+ token: str
28
+
29
+ @classmethod
30
+ def from_env(cls) -> "TetraConfig":
31
+ """Resolve configuration from environment variables and saved config.
32
+
33
+ Priority:
34
+ 1. TETRA_API_KEY env var - direct API key (`tetra_...`)
35
+ 2. ~/.tetra/cli_config.json - saved via `tetra-cli auth login`
36
+ 3. TETRA_USERNAME env - reads ~/.tetra/{username}_credentials.json
37
+ 4. TETRA_USERNAME + TETRA_PASSWORD - POSTs to /api/v1/auth/login
38
+
39
+ The API key (1 & 2) is the only public/documented path — it's what we
40
+ mint for users. The username/credential-file/password paths (3 & 4)
41
+ exist solely for driving local CLI automation in tests; they're kept
42
+ out of the published skill so users don't reach for them.
43
+
44
+ `TETRA_API_URL` env always overrides whatever URL the saved
45
+ config/credentials file specifies, so users can flip backends
46
+ (staging vs prod) without re-running `auth login`.
47
+
48
+ Raises:
49
+ ValueError: If no authentication method can be resolved.
50
+ """
51
+ env_api_url = os.environ.get("TETRA_API_URL")
52
+
53
+ # Priority 1: TETRA_API_KEY env (highest — explicit per-call override).
54
+ api_key = os.environ.get("TETRA_API_KEY")
55
+ if api_key:
56
+ logger.info("Using TETRA_API_KEY env var")
57
+ return cls(api_url=env_api_url or DEFAULT_API_URL, token=api_key)
58
+
59
+ # Priority 2: Saved CLI config (~/.tetra/cli_config.json).
60
+ # Created by `tetra-cli auth login`. Lets an AI agent persist
61
+ # a key once and have every subsequent CLI/MCP call pick it up.
62
+ saved_cfg_path = Path.home() / ".tetra" / "cli_config.json"
63
+ if saved_cfg_path.exists():
64
+ try:
65
+ saved = json.loads(saved_cfg_path.read_text())
66
+ saved_token = saved.get("api_key")
67
+ if saved_token:
68
+ api_url = env_api_url or saved.get("api_url") or DEFAULT_API_URL
69
+ logger.info("Using saved CLI config: %s", saved_cfg_path)
70
+ return cls(api_url=api_url, token=saved_token)
71
+ except (json.JSONDecodeError, OSError) as exc:
72
+ logger.warning("Ignoring malformed %s: %s", saved_cfg_path, exc)
73
+
74
+ username = os.environ.get("TETRA_USERNAME")
75
+
76
+ # Priority 3: Per-username credential file (JWT, may be expired).
77
+ if username:
78
+ cred_path = Path.home() / ".tetra" / f"{username}_credentials.json"
79
+ if cred_path.exists():
80
+ cred_data = json.loads(cred_path.read_text())
81
+ token = cred_data.get("token") or cred_data.get("access_token", "")
82
+ logger.info("Using credential file: %s", cred_path)
83
+ return cls(
84
+ api_url=env_api_url or DEFAULT_API_URL,
85
+ token=token,
86
+ )
87
+
88
+ # Priority 4: Login with password.
89
+ password = os.environ.get("TETRA_PASSWORD")
90
+ if username and password:
91
+ api_url = env_api_url or DEFAULT_API_URL
92
+ logger.info("Logging in as %s", username)
93
+ token = _login(api_url, username, password)
94
+ return cls(api_url=api_url, token=token)
95
+
96
+ raise ValueError(
97
+ "No API key configured. Mint one in Settings → API Keys, then either:\n"
98
+ " TETRA_API_KEY=tetra_... - set it in the environment, or\n"
99
+ " tetra-cli auth login <key> - save it to ~/.tetra/cli_config.json once.\n"
100
+ f"Point at another backend with TETRA_API_URL (defaults to {DEFAULT_API_URL})."
101
+ )
102
+
103
+
104
+ def _login(api_url: str, username: str, password: str) -> str:
105
+ """Authenticate via the login endpoint and return the access token.
106
+
107
+ Args:
108
+ api_url: Base URL of the Tetra API.
109
+ username: Account username or email.
110
+ password: Account password.
111
+
112
+ Returns:
113
+ JWT access token string.
114
+
115
+ Raises:
116
+ ValueError: If login fails.
117
+ """
118
+ response = httpx.post(
119
+ f"{api_url}/api/v1/auth/login",
120
+ json={"identifier": username, "password": password},
121
+ timeout=10.0,
122
+ )
123
+ if response.status_code != 200:
124
+ raise ValueError(f"Login failed (HTTP {response.status_code}): {response.text}")
125
+ return response.json()["access_token"]
@@ -0,0 +1,9 @@
1
+ """Pure async operation functions wrapping the Tetra REST API.
2
+
3
+ Each module exports functions of the shape:
4
+ async def op_name(client: TetraClient, *, ...kwargs) -> dict[str, Any]
5
+
6
+ These are the single source of truth for HTTP path, body shape, and
7
+ parameter conversions (e.g. duration_minutes -> seconds). Both the MCP
8
+ tools and the CLI commands call these operations.
9
+ """
@@ -0,0 +1,303 @@
1
+ """Pure async operations for the accounts API.
2
+
3
+ Covers the account-profile endpoints (own account, lookup by UID, profile
4
+ update, username availability) and the active-recording session endpoints
5
+ (get / create / update / clear) that the frontend uses to sync an in-progress
6
+ recording across devices.
7
+
8
+ Recording state is stored under ``account.details['active_recording']``. The
9
+ backend accepts ``start_time`` / ``last_modified`` as ISO-8601 datetime strings
10
+ and ``pending_events`` as a list of objects, so those are exposed as JSON-shaped
11
+ options rather than scalars.
12
+ """
13
+ from typing import Any
14
+
15
+ from tetra_cli.api_client import TetraClient
16
+ from tetra_cli.registry import arg, operation, opt
17
+
18
+
19
+ @operation(
20
+ cli="accounts me",
21
+ summary="Get the current authenticated account's profile.",
22
+ covers=[("GET", "/api/v1/accounts/me")],
23
+ )
24
+ async def get_my_account(client: TetraClient) -> dict[str, Any]:
25
+ """Get the current authenticated account's profile.
26
+
27
+ Args:
28
+ client: Authenticated TetraClient
29
+
30
+ Returns:
31
+ Account dict including derived fields (has_passkey, is_pro,
32
+ subscription_status).
33
+ """
34
+ return await client.get("/api/v1/accounts/me")
35
+
36
+
37
+ @operation(
38
+ cli="accounts get",
39
+ summary="Get an account by UID (own account, or any account for superusers).",
40
+ covers=[("GET", "/api/v1/accounts/{uid}")],
41
+ params={"uid": arg(help="Account UID to fetch")},
42
+ )
43
+ async def get_account(client: TetraClient, uid: str) -> dict[str, Any]:
44
+ """Get an account by UID.
45
+
46
+ Returns your own account freely; fetching another account's profile
47
+ requires a superuser token (the backend 403s otherwise).
48
+
49
+ Args:
50
+ client: Authenticated TetraClient
51
+ uid: Account UID to fetch
52
+
53
+ Returns:
54
+ Account dict
55
+ """
56
+ return await client.get(f"/api/v1/accounts/{uid}")
57
+
58
+
59
+ @operation(
60
+ cli="accounts update",
61
+ summary="Update the current account's profile. Only provided fields change.",
62
+ covers=[("PUT", "/api/v1/accounts/me")],
63
+ params={
64
+ "username": opt("--username", help="New username (3+ chars, alnum/_/-)."),
65
+ "full_name": opt("--full-name", help="Display name."),
66
+ "description": opt("--description", help="Profile description (<=1000)."),
67
+ "phone": opt("--phone", help="Phone number (<=30 chars)."),
68
+ "address": opt("--address", help="Address (<=500 chars)."),
69
+ "timezone": opt("--timezone",
70
+ help="IANA timezone, e.g. 'America/Denver'."),
71
+ "details": opt("--details", json=True,
72
+ help="Free-form details object as a JSON dict."),
73
+ "unit_system": opt("--unit-system", choices=["imperial", "metric"],
74
+ help="Distance/elevation render preference."),
75
+ },
76
+ )
77
+ async def update_my_account(
78
+ client: TetraClient,
79
+ *,
80
+ username: str | None = None,
81
+ full_name: str | None = None,
82
+ description: str | None = None,
83
+ phone: str | None = None,
84
+ address: str | None = None,
85
+ timezone: str | None = None,
86
+ details: dict[str, Any] | None = None,
87
+ geotag_events: bool | None = None,
88
+ unit_system: str | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Update the current account's profile. Only provided fields are changed.
91
+
92
+ Each non-None field is forwarded to the API; omitted fields keep their
93
+ stored value (the backend update is a partial merge).
94
+
95
+ Args:
96
+ client: Authenticated TetraClient
97
+ username: New username (3+ chars, letters/numbers/underscores/hyphens)
98
+ full_name: Display name
99
+ description: Profile description (max 1000 chars)
100
+ phone: Phone number (max 30 chars)
101
+ address: Address (max 500 chars)
102
+ timezone: IANA timezone string (e.g. 'America/Denver')
103
+ details: Free-form details object merged onto the account
104
+ geotag_events: Whether to opt into passive geotagging of events
105
+ unit_system: 'imperial' or 'metric' render preference
106
+
107
+ Returns:
108
+ Updated account dict
109
+ """
110
+ body: dict[str, Any] = {}
111
+ if username is not None:
112
+ body["username"] = username
113
+ if full_name is not None:
114
+ body["full_name"] = full_name
115
+ if description is not None:
116
+ body["description"] = description
117
+ if phone is not None:
118
+ body["phone"] = phone
119
+ if address is not None:
120
+ body["address"] = address
121
+ if timezone is not None:
122
+ body["timezone"] = timezone
123
+ if details is not None:
124
+ body["details"] = details
125
+ if geotag_events is not None:
126
+ body["geotag_events"] = geotag_events
127
+ if unit_system is not None:
128
+ body["unit_system"] = unit_system
129
+ return await client.put("/api/v1/accounts/me", json=body)
130
+
131
+
132
+ @operation(
133
+ cli="accounts check-username",
134
+ summary="Check whether a username is available and valid.",
135
+ covers=[("GET", "/api/v1/accounts/check-username/{username}")],
136
+ params={"username": arg(help="Username to check")},
137
+ )
138
+ async def check_username(client: TetraClient, username: str) -> dict[str, Any]:
139
+ """Check whether a username is available and valid.
140
+
141
+ Validation mirrors the signup rules (3+ chars, alphanumeric plus
142
+ underscore/hyphen) and the availability check excludes your own account,
143
+ so checking your current username reports it as available.
144
+
145
+ Args:
146
+ client: Authenticated TetraClient
147
+ username: Username to check
148
+
149
+ Returns:
150
+ Dict with ``available``, ``valid``, and a human-readable ``message``.
151
+ """
152
+ return await client.get(f"/api/v1/accounts/check-username/{username}")
153
+
154
+
155
+ @operation(
156
+ cli="accounts recording-get",
157
+ summary="Get the current active recording session.",
158
+ covers=[("GET", "/api/v1/accounts/me/recording")],
159
+ )
160
+ async def get_active_recording(client: TetraClient) -> dict[str, Any]:
161
+ """Get the current active recording session.
162
+
163
+ Returns the in-progress recording stored on the account. The backend
164
+ 404s (surfaced as a "Not found" error) when there is no active session.
165
+
166
+ Args:
167
+ client: Authenticated TetraClient
168
+
169
+ Returns:
170
+ Active recording dict with ``start_time``, ``pending_events``, and
171
+ ``last_modified``.
172
+ """
173
+ return await client.get("/api/v1/accounts/me/recording")
174
+
175
+
176
+ @operation(
177
+ cli="accounts recording-start",
178
+ summary="Start a new active recording session.",
179
+ covers=[("POST", "/api/v1/accounts/me/recording")],
180
+ params={
181
+ "start_time": arg(help="Session start time as an ISO-8601 datetime."),
182
+ "pending_events": opt("--pending-events", json=True,
183
+ help="List of pending-event objects (JSON array)."),
184
+ "last_modified": opt("--last-modified",
185
+ help="Conflict-resolution timestamp (ISO-8601)."),
186
+ },
187
+ )
188
+ async def create_active_recording(
189
+ client: TetraClient,
190
+ *,
191
+ start_time: str,
192
+ pending_events: list[dict[str, Any]] | None = None,
193
+ last_modified: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ """Start a new active recording session.
196
+
197
+ Overwrites any existing active recording. ``start_time`` is required;
198
+ ``pending_events`` defaults to an empty list when omitted.
199
+
200
+ Args:
201
+ client: Authenticated TetraClient
202
+ start_time: Session start time as an ISO-8601 datetime string
203
+ pending_events: Pending-event objects captured so far (defaults to [])
204
+ last_modified: Conflict-resolution timestamp as ISO-8601
205
+
206
+ Returns:
207
+ The created active recording dict
208
+ """
209
+ body: dict[str, Any] = {
210
+ "start_time": start_time,
211
+ "pending_events": pending_events if pending_events is not None else [],
212
+ }
213
+ if last_modified is not None:
214
+ body["last_modified"] = last_modified
215
+ return await client.post("/api/v1/accounts/me/recording", json=body)
216
+
217
+
218
+ @operation(
219
+ cli="accounts recording-update",
220
+ summary="Update or create the active recording session. Only provided fields change.",
221
+ covers=[("PUT", "/api/v1/accounts/me/recording")],
222
+ params={
223
+ "start_time": opt("--start-time",
224
+ help="Session start time as an ISO-8601 datetime."),
225
+ "pending_events": opt("--pending-events", json=True,
226
+ help="List of pending-event objects (JSON array)."),
227
+ "last_modified": opt("--last-modified",
228
+ help="Conflict-resolution timestamp (ISO-8601)."),
229
+ },
230
+ )
231
+ async def update_active_recording(
232
+ client: TetraClient,
233
+ *,
234
+ start_time: str | None = None,
235
+ pending_events: list[dict[str, Any]] | None = None,
236
+ last_modified: str | None = None,
237
+ ) -> dict[str, Any]:
238
+ """Update (or create) the active recording session.
239
+
240
+ Only non-None fields are sent; the backend merges them onto the existing
241
+ recording state, so this is a partial update.
242
+
243
+ Args:
244
+ client: Authenticated TetraClient
245
+ start_time: New session start time as ISO-8601
246
+ pending_events: Replacement pending-event objects
247
+ last_modified: Conflict-resolution timestamp as ISO-8601
248
+
249
+ Returns:
250
+ The updated active recording dict
251
+ """
252
+ body: dict[str, Any] = {}
253
+ if start_time is not None:
254
+ body["start_time"] = start_time
255
+ if pending_events is not None:
256
+ body["pending_events"] = pending_events
257
+ if last_modified is not None:
258
+ body["last_modified"] = last_modified
259
+ return await client.put("/api/v1/accounts/me/recording", json=body)
260
+
261
+
262
+ @operation(
263
+ cli="accounts recording-clear",
264
+ summary="Clear the active recording session.",
265
+ covers=[("DELETE", "/api/v1/accounts/me/recording")],
266
+ )
267
+ async def delete_active_recording(client: TetraClient) -> dict[str, Any]:
268
+ """Clear the active recording session.
269
+
270
+ Idempotent: succeeds whether or not a recording is currently active.
271
+
272
+ Args:
273
+ client: Authenticated TetraClient
274
+
275
+ Returns:
276
+ Status dict (``{"status": "ok"}``)
277
+ """
278
+ return await client.delete("/api/v1/accounts/me/recording")
279
+
280
+
281
+ @operation(
282
+ cli="accounts app-request",
283
+ summary="Record a request for native-app (TestFlight) access for a platform.",
284
+ covers=[("POST", "/api/v1/accounts/me/app-request/{platform}")],
285
+ params={"platform": arg(help="Target platform (e.g. ios). ios is tracked; others no-op.")},
286
+ )
287
+ async def request_mobile_app(
288
+ client: TetraClient, platform: str,
289
+ ) -> dict[str, Any]:
290
+ """Record a request for native-app access on a platform.
291
+
292
+ Only iOS is tracked (sets ``ios_requested_at``; idempotent — first request
293
+ wins) so an admin can add the account to TestFlight. Any non-iOS platform
294
+ is accepted and returns the account unchanged.
295
+
296
+ Args:
297
+ client: Authenticated TetraClient
298
+ platform: Target platform (e.g. ``ios``).
299
+
300
+ Returns:
301
+ The (possibly unchanged) account dict.
302
+ """
303
+ return await client.post(f"/api/v1/accounts/me/app-request/{platform}")