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.
- tetra_cli/__init__.py +6 -0
- tetra_cli/api_client/__init__.py +10 -0
- tetra_cli/api_client/client.py +173 -0
- tetra_cli/api_client/config.py +125 -0
- tetra_cli/api_client/operations/__init__.py +9 -0
- tetra_cli/api_client/operations/accounts.py +303 -0
- tetra_cli/api_client/operations/ai.py +278 -0
- tetra_cli/api_client/operations/analysis.py +190 -0
- tetra_cli/api_client/operations/api_keys.py +145 -0
- tetra_cli/api_client/operations/archive.py +114 -0
- tetra_cli/api_client/operations/awards.py +123 -0
- tetra_cli/api_client/operations/capacity.py +84 -0
- tetra_cli/api_client/operations/conversations.py +447 -0
- tetra_cli/api_client/operations/conversations_2.py +262 -0
- tetra_cli/api_client/operations/cosmetics.py +148 -0
- tetra_cli/api_client/operations/dashboard.py +282 -0
- tetra_cli/api_client/operations/data.py +250 -0
- tetra_cli/api_client/operations/events.py +734 -0
- tetra_cli/api_client/operations/gamification.py +470 -0
- tetra_cli/api_client/operations/goals.py +1144 -0
- tetra_cli/api_client/operations/groups.py +647 -0
- tetra_cli/api_client/operations/issues.py +198 -0
- tetra_cli/api_client/operations/offset.py +61 -0
- tetra_cli/api_client/operations/onboarding.py +284 -0
- tetra_cli/api_client/operations/outcome_schemas.py +292 -0
- tetra_cli/api_client/operations/peer_connections.py +243 -0
- tetra_cli/api_client/operations/plaid.py +329 -0
- tetra_cli/api_client/operations/reminders.py +273 -0
- tetra_cli/api_client/operations/scratches.py +280 -0
- tetra_cli/api_client/operations/skill_trees.py +160 -0
- tetra_cli/api_client/operations/social_2.py +560 -0
- tetra_cli/api_client/operations/social_3.py +618 -0
- tetra_cli/api_client/operations/social_4.py +527 -0
- tetra_cli/api_client/operations/strava.py +215 -0
- tetra_cli/api_client/operations/stripe.py +113 -0
- tetra_cli/api_client/operations/tags.py +488 -0
- tetra_cli/api_client/operations/values.py +867 -0
- tetra_cli/api_client/operations/values_2.py +584 -0
- tetra_cli/api_client/operations/watch.py +105 -0
- tetra_cli/api_client/operations/webhooks.py +50 -0
- tetra_cli/api_client/operations/xp.py +27 -0
- tetra_cli/cli/__init__.py +5 -0
- tetra_cli/cli/__main__.py +5 -0
- tetra_cli/cli/app.py +86 -0
- tetra_cli/cli/commands/__init__.py +1 -0
- tetra_cli/cli/commands/auth.py +201 -0
- tetra_cli/cli/commands/guide.py +8 -0
- tetra_cli/cli/commands/messages.py +161 -0
- tetra_cli/cli/commands/skill.py +71 -0
- tetra_cli/cli/context.py +13 -0
- tetra_cli/cli/generate.py +282 -0
- tetra_cli/cli/output.py +58 -0
- tetra_cli/mcp_gen.py +137 -0
- tetra_cli/ontology.py +70 -0
- tetra_cli/registry.py +118 -0
- tetra_cli/skill/SKILL.md +69 -0
- tetra_cli/skill/__init__.py +1 -0
- tetra_cli-0.2.0.dist-info/METADATA +140 -0
- tetra_cli-0.2.0.dist-info/RECORD +62 -0
- tetra_cli-0.2.0.dist-info/WHEEL +5 -0
- tetra_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tetra_cli-0.2.0.dist-info/top_level.txt +1 -0
tetra_cli/__init__.py
ADDED
|
@@ -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}")
|