tetra-cli 0.2.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 (69) hide show
  1. tetra_cli-0.2.0/PKG-INFO +140 -0
  2. tetra_cli-0.2.0/README.md +125 -0
  3. tetra_cli-0.2.0/pyproject.toml +44 -0
  4. tetra_cli-0.2.0/setup.cfg +4 -0
  5. tetra_cli-0.2.0/tests/test_no_backend_leak.py +52 -0
  6. tetra_cli-0.2.0/tests/test_smoke.py +6 -0
  7. tetra_cli-0.2.0/tetra_cli/__init__.py +6 -0
  8. tetra_cli-0.2.0/tetra_cli/api_client/__init__.py +10 -0
  9. tetra_cli-0.2.0/tetra_cli/api_client/client.py +173 -0
  10. tetra_cli-0.2.0/tetra_cli/api_client/config.py +125 -0
  11. tetra_cli-0.2.0/tetra_cli/api_client/operations/__init__.py +9 -0
  12. tetra_cli-0.2.0/tetra_cli/api_client/operations/accounts.py +303 -0
  13. tetra_cli-0.2.0/tetra_cli/api_client/operations/ai.py +278 -0
  14. tetra_cli-0.2.0/tetra_cli/api_client/operations/analysis.py +190 -0
  15. tetra_cli-0.2.0/tetra_cli/api_client/operations/api_keys.py +145 -0
  16. tetra_cli-0.2.0/tetra_cli/api_client/operations/archive.py +114 -0
  17. tetra_cli-0.2.0/tetra_cli/api_client/operations/awards.py +123 -0
  18. tetra_cli-0.2.0/tetra_cli/api_client/operations/capacity.py +84 -0
  19. tetra_cli-0.2.0/tetra_cli/api_client/operations/conversations.py +447 -0
  20. tetra_cli-0.2.0/tetra_cli/api_client/operations/conversations_2.py +262 -0
  21. tetra_cli-0.2.0/tetra_cli/api_client/operations/cosmetics.py +148 -0
  22. tetra_cli-0.2.0/tetra_cli/api_client/operations/dashboard.py +282 -0
  23. tetra_cli-0.2.0/tetra_cli/api_client/operations/data.py +250 -0
  24. tetra_cli-0.2.0/tetra_cli/api_client/operations/events.py +734 -0
  25. tetra_cli-0.2.0/tetra_cli/api_client/operations/gamification.py +470 -0
  26. tetra_cli-0.2.0/tetra_cli/api_client/operations/goals.py +1144 -0
  27. tetra_cli-0.2.0/tetra_cli/api_client/operations/groups.py +647 -0
  28. tetra_cli-0.2.0/tetra_cli/api_client/operations/issues.py +198 -0
  29. tetra_cli-0.2.0/tetra_cli/api_client/operations/offset.py +61 -0
  30. tetra_cli-0.2.0/tetra_cli/api_client/operations/onboarding.py +284 -0
  31. tetra_cli-0.2.0/tetra_cli/api_client/operations/outcome_schemas.py +292 -0
  32. tetra_cli-0.2.0/tetra_cli/api_client/operations/peer_connections.py +243 -0
  33. tetra_cli-0.2.0/tetra_cli/api_client/operations/plaid.py +329 -0
  34. tetra_cli-0.2.0/tetra_cli/api_client/operations/reminders.py +273 -0
  35. tetra_cli-0.2.0/tetra_cli/api_client/operations/scratches.py +280 -0
  36. tetra_cli-0.2.0/tetra_cli/api_client/operations/skill_trees.py +160 -0
  37. tetra_cli-0.2.0/tetra_cli/api_client/operations/social_2.py +560 -0
  38. tetra_cli-0.2.0/tetra_cli/api_client/operations/social_3.py +618 -0
  39. tetra_cli-0.2.0/tetra_cli/api_client/operations/social_4.py +527 -0
  40. tetra_cli-0.2.0/tetra_cli/api_client/operations/strava.py +215 -0
  41. tetra_cli-0.2.0/tetra_cli/api_client/operations/stripe.py +113 -0
  42. tetra_cli-0.2.0/tetra_cli/api_client/operations/tags.py +488 -0
  43. tetra_cli-0.2.0/tetra_cli/api_client/operations/values.py +867 -0
  44. tetra_cli-0.2.0/tetra_cli/api_client/operations/values_2.py +584 -0
  45. tetra_cli-0.2.0/tetra_cli/api_client/operations/watch.py +105 -0
  46. tetra_cli-0.2.0/tetra_cli/api_client/operations/webhooks.py +50 -0
  47. tetra_cli-0.2.0/tetra_cli/api_client/operations/xp.py +27 -0
  48. tetra_cli-0.2.0/tetra_cli/cli/__init__.py +5 -0
  49. tetra_cli-0.2.0/tetra_cli/cli/__main__.py +5 -0
  50. tetra_cli-0.2.0/tetra_cli/cli/app.py +86 -0
  51. tetra_cli-0.2.0/tetra_cli/cli/commands/__init__.py +1 -0
  52. tetra_cli-0.2.0/tetra_cli/cli/commands/auth.py +201 -0
  53. tetra_cli-0.2.0/tetra_cli/cli/commands/guide.py +8 -0
  54. tetra_cli-0.2.0/tetra_cli/cli/commands/messages.py +161 -0
  55. tetra_cli-0.2.0/tetra_cli/cli/commands/skill.py +71 -0
  56. tetra_cli-0.2.0/tetra_cli/cli/context.py +13 -0
  57. tetra_cli-0.2.0/tetra_cli/cli/generate.py +282 -0
  58. tetra_cli-0.2.0/tetra_cli/cli/output.py +58 -0
  59. tetra_cli-0.2.0/tetra_cli/mcp_gen.py +137 -0
  60. tetra_cli-0.2.0/tetra_cli/ontology.py +70 -0
  61. tetra_cli-0.2.0/tetra_cli/registry.py +118 -0
  62. tetra_cli-0.2.0/tetra_cli/skill/SKILL.md +69 -0
  63. tetra_cli-0.2.0/tetra_cli/skill/__init__.py +1 -0
  64. tetra_cli-0.2.0/tetra_cli.egg-info/PKG-INFO +140 -0
  65. tetra_cli-0.2.0/tetra_cli.egg-info/SOURCES.txt +67 -0
  66. tetra_cli-0.2.0/tetra_cli.egg-info/dependency_links.txt +1 -0
  67. tetra_cli-0.2.0/tetra_cli.egg-info/entry_points.txt +2 -0
  68. tetra_cli-0.2.0/tetra_cli.egg-info/requires.txt +8 -0
  69. tetra_cli-0.2.0/tetra_cli.egg-info/top_level.txt +1 -0
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: tetra-cli
3
+ Version: 0.2.0
4
+ Summary: Standalone CLI/agent client for the Tetra API (no backend).
5
+ Project-URL: Homepage, https://tetraoptum.com
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: typer>=0.12.0
10
+ Requires-Dist: click>=8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
14
+ Requires-Dist: respx>=0.21; extra == "dev"
15
+
16
+ # Tetra CLI
17
+
18
+ [**Tetra**](https://tetraoptum.com) is an intelligent life organizer — "Tetris
19
+ for time." It helps you align your time and money with what you actually value,
20
+ through hierarchical **values**, **goals**, and **events**, plus the analytics
21
+ to see where it all goes. Learn more or create a free account at
22
+ **[tetraoptum.com](https://tetraoptum.com)**.
23
+
24
+ This package is the standalone command-line client for Tetra — manage your
25
+ values, goals, events, and time from your terminal or from any LLM agent
26
+ (Claude Code, OpenClaw, and other tool-using assistants).
27
+
28
+ It talks to the Tetra API over HTTPS using **your** API key. It has no access
29
+ to the Tetra backend internals — it only does what your account can do.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install tetra-cli
35
+ ```
36
+
37
+ This installs the `tetra-cli` command. It depends only on public packages
38
+ (`httpx`, `typer`, `click`).
39
+
40
+ ## 1. Get an API key
41
+
42
+ In the Tetra web app, go to **Settings → API Keys → Create key**. Choose the
43
+ scopes your agent needs (you can create a read-only key, or grant specific
44
+ writes):
45
+
46
+ | Scope | Lets the agent… |
47
+ |-------|-----------------|
48
+ | `read` | read everything in your account |
49
+ | `events:write` | record / edit / delete events |
50
+ | `goals:write` | create / edit / delete goals |
51
+ | `values:write` | create / edit / delete values |
52
+ | `tags:write` | create / edit / merge tags |
53
+
54
+ The key (`tetra_…`) is shown **once**. Copy it then.
55
+
56
+ > **Keep your API key secret.** It grants the same access as logging in —
57
+ > anyone who has it can act as you, just like your password. Store it in your
58
+ > agent's secrets/credential store, never paste it into shared chats or commit
59
+ > it to git, and revoke it from **Settings → API Keys** if it may have leaked.
60
+
61
+ ## 2. Configure the CLI
62
+
63
+ Save the key once (it's stored in `~/.tetra/cli_config.json`):
64
+
65
+ ```bash
66
+ tetra-cli auth login tetra_your_key_here
67
+ # defaults to https://api.tetraoptum.com; pass --url to target another server
68
+ tetra-cli auth status # confirm who you're authenticated as
69
+ ```
70
+
71
+ Or skip the saved config and use environment variables (handy for agents /
72
+ CI), which always take precedence:
73
+
74
+ ```bash
75
+ export TETRA_API_KEY=tetra_your_key_here
76
+ export TETRA_API_URL=http://localhost:8000 # optional; only to target a local backend (defaults to prod)
77
+ ```
78
+
79
+ ## 3. Install the agent skill (optional)
80
+
81
+ To let an AI coding agent drive Tetra for you, install the bundled skill into
82
+ its skills directory (defaults to Claude Code's `~/.claude/skills`):
83
+
84
+ ```bash
85
+ tetra-cli skill install # -> ~/.claude/skills/tetra-cli/SKILL.md
86
+ tetra-cli skill install --dir /path/to/other/agent/skills # another agent
87
+ tetra-cli skill show # print the skill without installing
88
+ ```
89
+
90
+ ## 4. Use it
91
+
92
+ Goals and values are addressed by their hierarchical **name** — no UID hunting:
93
+
94
+ ```bash
95
+ tetra-cli values list
96
+ tetra-cli goals list
97
+ tetra-cli goals get "Exercise > Weight Training"
98
+ tetra-cli events create 2026-06-10 --goal "Exercise > Weight Training" \
99
+ --duration-minutes 45 --description "Bench + accessories"
100
+ tetra-cli events list --on-date 2026-06-10
101
+ tetra-cli dashboard week-snapshot
102
+ ```
103
+
104
+ Every command supports `--help`. Output is JSON by default (ideal for agents);
105
+ add `--pretty` for human-readable tables.
106
+
107
+ ## Full command surface
108
+
109
+ The CLI mirrors **everything you can do in the Tetra web app** — your agent can
110
+ act on your behalf anywhere you can act yourself. Commands are generated from
111
+ the same operation registry the app uses, so the surface never drifts behind
112
+ the product (a parity check in CI fails if a user-facing endpoint has no
113
+ command). Run `tetra-cli --help` to browse the groups:
114
+
115
+ | Group | What it covers |
116
+ |-------|----------------|
117
+ | `values`, `goals`, `events`, `tags` | the core model: create/read/update/delete, hierarchy moves, metrics, bulk edits |
118
+ | `dashboard`, `analysis`, `data` | week/day analytics, time & spending breakdowns, full export & CSV import |
119
+ | `scratches`, `notifications` | quick capture, one-shot reminders |
120
+ | `gamification`, `awards`, `xp`, `capacity` | XP/level/streak, credits, gems, item shop, slot capacity |
121
+ | `social`, `peers`, `conversations` | groups, sharing, Arena cohorts, connections, DMs & the AI thread |
122
+ | `outcome-schemas`, `skill-trees` | reusable metric & tree templates |
123
+ | `accounts`, `api-keys`, `onboarding` | your profile, key management, onboarding |
124
+ | `archive`, `stripe` | restore soft-deleted items, manage your subscription |
125
+ | `strava`, `plaid`, `issues` | activity & bank integrations, bug reports |
126
+
127
+ Admin/superuser endpoints are intentionally excluded — a key only ever reaches
128
+ your own account's data.
129
+
130
+ ## Using it from an LLM agent
131
+
132
+ Point your agent's shell at the `tetra-cli` binary with `TETRA_API_KEY` set in
133
+ its environment. The agent can then drive data entry, edits, and analysis on
134
+ your account. For a Model-Context-Protocol integration (Claude Desktop, etc.),
135
+ see the companion **tetra-mcp** package, which reads the same
136
+ `~/.tetra/cli_config.json`.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,125 @@
1
+ # Tetra CLI
2
+
3
+ [**Tetra**](https://tetraoptum.com) is an intelligent life organizer — "Tetris
4
+ for time." It helps you align your time and money with what you actually value,
5
+ through hierarchical **values**, **goals**, and **events**, plus the analytics
6
+ to see where it all goes. Learn more or create a free account at
7
+ **[tetraoptum.com](https://tetraoptum.com)**.
8
+
9
+ This package is the standalone command-line client for Tetra — manage your
10
+ values, goals, events, and time from your terminal or from any LLM agent
11
+ (Claude Code, OpenClaw, and other tool-using assistants).
12
+
13
+ It talks to the Tetra API over HTTPS using **your** API key. It has no access
14
+ to the Tetra backend internals — it only does what your account can do.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install tetra-cli
20
+ ```
21
+
22
+ This installs the `tetra-cli` command. It depends only on public packages
23
+ (`httpx`, `typer`, `click`).
24
+
25
+ ## 1. Get an API key
26
+
27
+ In the Tetra web app, go to **Settings → API Keys → Create key**. Choose the
28
+ scopes your agent needs (you can create a read-only key, or grant specific
29
+ writes):
30
+
31
+ | Scope | Lets the agent… |
32
+ |-------|-----------------|
33
+ | `read` | read everything in your account |
34
+ | `events:write` | record / edit / delete events |
35
+ | `goals:write` | create / edit / delete goals |
36
+ | `values:write` | create / edit / delete values |
37
+ | `tags:write` | create / edit / merge tags |
38
+
39
+ The key (`tetra_…`) is shown **once**. Copy it then.
40
+
41
+ > **Keep your API key secret.** It grants the same access as logging in —
42
+ > anyone who has it can act as you, just like your password. Store it in your
43
+ > agent's secrets/credential store, never paste it into shared chats or commit
44
+ > it to git, and revoke it from **Settings → API Keys** if it may have leaked.
45
+
46
+ ## 2. Configure the CLI
47
+
48
+ Save the key once (it's stored in `~/.tetra/cli_config.json`):
49
+
50
+ ```bash
51
+ tetra-cli auth login tetra_your_key_here
52
+ # defaults to https://api.tetraoptum.com; pass --url to target another server
53
+ tetra-cli auth status # confirm who you're authenticated as
54
+ ```
55
+
56
+ Or skip the saved config and use environment variables (handy for agents /
57
+ CI), which always take precedence:
58
+
59
+ ```bash
60
+ export TETRA_API_KEY=tetra_your_key_here
61
+ export TETRA_API_URL=http://localhost:8000 # optional; only to target a local backend (defaults to prod)
62
+ ```
63
+
64
+ ## 3. Install the agent skill (optional)
65
+
66
+ To let an AI coding agent drive Tetra for you, install the bundled skill into
67
+ its skills directory (defaults to Claude Code's `~/.claude/skills`):
68
+
69
+ ```bash
70
+ tetra-cli skill install # -> ~/.claude/skills/tetra-cli/SKILL.md
71
+ tetra-cli skill install --dir /path/to/other/agent/skills # another agent
72
+ tetra-cli skill show # print the skill without installing
73
+ ```
74
+
75
+ ## 4. Use it
76
+
77
+ Goals and values are addressed by their hierarchical **name** — no UID hunting:
78
+
79
+ ```bash
80
+ tetra-cli values list
81
+ tetra-cli goals list
82
+ tetra-cli goals get "Exercise > Weight Training"
83
+ tetra-cli events create 2026-06-10 --goal "Exercise > Weight Training" \
84
+ --duration-minutes 45 --description "Bench + accessories"
85
+ tetra-cli events list --on-date 2026-06-10
86
+ tetra-cli dashboard week-snapshot
87
+ ```
88
+
89
+ Every command supports `--help`. Output is JSON by default (ideal for agents);
90
+ add `--pretty` for human-readable tables.
91
+
92
+ ## Full command surface
93
+
94
+ The CLI mirrors **everything you can do in the Tetra web app** — your agent can
95
+ act on your behalf anywhere you can act yourself. Commands are generated from
96
+ the same operation registry the app uses, so the surface never drifts behind
97
+ the product (a parity check in CI fails if a user-facing endpoint has no
98
+ command). Run `tetra-cli --help` to browse the groups:
99
+
100
+ | Group | What it covers |
101
+ |-------|----------------|
102
+ | `values`, `goals`, `events`, `tags` | the core model: create/read/update/delete, hierarchy moves, metrics, bulk edits |
103
+ | `dashboard`, `analysis`, `data` | week/day analytics, time & spending breakdowns, full export & CSV import |
104
+ | `scratches`, `notifications` | quick capture, one-shot reminders |
105
+ | `gamification`, `awards`, `xp`, `capacity` | XP/level/streak, credits, gems, item shop, slot capacity |
106
+ | `social`, `peers`, `conversations` | groups, sharing, Arena cohorts, connections, DMs & the AI thread |
107
+ | `outcome-schemas`, `skill-trees` | reusable metric & tree templates |
108
+ | `accounts`, `api-keys`, `onboarding` | your profile, key management, onboarding |
109
+ | `archive`, `stripe` | restore soft-deleted items, manage your subscription |
110
+ | `strava`, `plaid`, `issues` | activity & bank integrations, bug reports |
111
+
112
+ Admin/superuser endpoints are intentionally excluded — a key only ever reaches
113
+ your own account's data.
114
+
115
+ ## Using it from an LLM agent
116
+
117
+ Point your agent's shell at the `tetra-cli` binary with `TETRA_API_KEY` set in
118
+ its environment. The agent can then drive data entry, edits, and analysis on
119
+ your account. For a Model-Context-Protocol integration (Claude Desktop, etc.),
120
+ see the companion **tetra-mcp** package, which reads the same
121
+ `~/.tetra/cli_config.json`.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tetra-cli"
7
+ version = "0.2.0"
8
+ description = "Standalone CLI/agent client for the Tetra API (no backend)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "httpx>=0.27",
13
+ "typer>=0.12.0",
14
+ # Imported directly (click.Choice in cli/generate.py). typer is built on
15
+ # click but newer typer releases no longer re-export it as a hard dep, so
16
+ # declare it explicitly — otherwise a clean install fails at import time.
17
+ "click>=8.0",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://tetraoptum.com"
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8.0.0",
26
+ "pytest-asyncio>=0.23.0",
27
+ "respx>=0.21",
28
+ ]
29
+
30
+ [project.scripts]
31
+ tetra-cli = "tetra_cli.cli.app:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["tetra_cli*"]
36
+
37
+ # Ship the bundled agent skill (SKILL.md) in the wheel so `pip install tetra-cli`
38
+ # delivers the client AND the instructions that teach an AI to drive it.
39
+ [tool.setuptools.package-data]
40
+ "tetra_cli.skill" = ["*.md"]
41
+
42
+ [tool.pytest.ini_options]
43
+ pythonpath = ["."]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ """tetra_cli must never import the backend or heavy server deps.
2
+
3
+ This is the distribution invariant: the slim package is shipped to agents
4
+ and must contain/pull in NO backend (models, routes, services, ORM). The
5
+ guard imports every tetra_cli submodule, then asserts no forbidden module
6
+ got loaded into sys.modules.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import pkgutil
12
+ import sys
13
+
14
+ import tetra_cli
15
+
16
+ FORBIDDEN_PREFIXES = (
17
+ "tetra.api",
18
+ "tetra.core",
19
+ "tetra.services",
20
+ "tetra.db",
21
+ "sqlalchemy",
22
+ "fastapi",
23
+ "psycopg2",
24
+ "alembic",
25
+ "stripe",
26
+ "redis",
27
+ "llama_index",
28
+ )
29
+
30
+
31
+ def _all_tetra_cli_modules() -> list[str]:
32
+ mods = ["tetra_cli"]
33
+ for info in pkgutil.walk_packages(tetra_cli.__path__, prefix="tetra_cli."):
34
+ mods.append(info.name)
35
+ return mods
36
+
37
+
38
+ def test_importing_tetra_cli_pulls_in_no_backend():
39
+ # Drop anything forbidden already loaded by the test session, so we measure
40
+ # only what tetra_cli imports trigger.
41
+ for name in list(sys.modules):
42
+ if name.startswith(FORBIDDEN_PREFIXES):
43
+ del sys.modules[name]
44
+
45
+ for mod in _all_tetra_cli_modules():
46
+ importlib.import_module(mod)
47
+
48
+ leaked = sorted(
49
+ name for name in sys.modules
50
+ if name.startswith(FORBIDDEN_PREFIXES)
51
+ )
52
+ assert leaked == [], f"tetra_cli leaked backend/heavy imports: {leaked}"
@@ -0,0 +1,6 @@
1
+ """Package imports and exposes its version."""
2
+ import tetra_cli
3
+
4
+
5
+ def test_version():
6
+ assert tetra_cli.__version__ == "0.1.0"
@@ -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)