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,50 @@
1
+ """Pure async operations for registering an API key's webhook."""
2
+ import re
3
+ from typing import Any
4
+
5
+ from tetra_cli.api_client import TetraClient
6
+ from tetra_cli.registry import arg, operation, opt
7
+
8
+ # API-key UIDs are opaque tokens (UUID-style). Validate before interpolating
9
+ # into the request path so a malformed/crafted value can't redirect the call
10
+ # to a different endpoint (defense-in-depth path-injection guard).
11
+ _UID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
12
+
13
+
14
+ def _require_uid(api_key_uid: str) -> str:
15
+ if not _UID_RE.fullmatch(api_key_uid or ""):
16
+ raise ValueError(f"Invalid api_key_uid: {api_key_uid!r}")
17
+ return api_key_uid
18
+
19
+
20
+ @operation(
21
+ cli="api-keys set-webhook",
22
+ summary="Register the agent push webhook on an API key.",
23
+ covers=[("PATCH", "/api/v1/api-keys/{uid}")],
24
+ params={
25
+ "api_key_uid": arg(help="API key UID to attach the webhook to."),
26
+ "url": arg(help="HTTPS endpoint to POST new user messages to."),
27
+ "secret": opt("--secret", help="HMAC signing secret."),
28
+ },
29
+ )
30
+ async def set_webhook(
31
+ client: TetraClient, api_key_uid: str, *, url: str, secret: str | None = None
32
+ ) -> dict[str, Any]:
33
+ """Register (or update) the webhook URL+secret on an API key."""
34
+ _require_uid(api_key_uid)
35
+ body: dict[str, Any] = {"webhook_url": url}
36
+ if secret is not None:
37
+ body["webhook_secret"] = secret
38
+ return await client.patch(f"/api/v1/api-keys/{api_key_uid}", json=body)
39
+
40
+
41
+ @operation(
42
+ cli="api-keys clear-webhook",
43
+ summary="Remove the agent push webhook from an API key.",
44
+ covers=[("PATCH", "/api/v1/api-keys/{uid}")],
45
+ params={"api_key_uid": arg(help="API key UID to clear the webhook from.")},
46
+ )
47
+ async def clear_webhook(client: TetraClient, api_key_uid: str) -> dict[str, Any]:
48
+ """Remove the webhook from an API key."""
49
+ _require_uid(api_key_uid)
50
+ return await client.patch(f"/api/v1/api-keys/{api_key_uid}", json={"webhook_url": None})
@@ -0,0 +1,27 @@
1
+ """Pure async operations for the XP page API."""
2
+ from typing import Any
3
+
4
+ from tetra_cli.api_client import TetraClient
5
+ from tetra_cli.registry import operation
6
+
7
+
8
+ @operation(
9
+ cli="xp stats",
10
+ summary="Get XP page statistics: level, streak, multiplier, achievements, and rankings.",
11
+ covers=[("GET", "/api/v1/xp/stats")],
12
+ )
13
+ async def get_xp_stats(client: TetraClient) -> dict[str, Any]:
14
+ """Get XP page statistics for the current account.
15
+
16
+ Returns comprehensive XP data: current level and XP progress, streak and
17
+ multiplier, next reward preview, activity stats, recent achievements, and
18
+ value performance rankings. Takes no parameters — the backend scopes the
19
+ response to the authenticated account.
20
+
21
+ Args:
22
+ client: Authenticated TetraClient
23
+
24
+ Returns:
25
+ Dict with the full XP stats payload (XpStatsResponse).
26
+ """
27
+ return await client.get("/api/v1/xp/stats")
@@ -0,0 +1,5 @@
1
+ """Tetra command-line interface.
2
+
3
+ Mirrors the MCP server's tool surface, emitting JSON to stdout. Auth via
4
+ TETRA_API_KEY / TETRA_USERNAME env vars, identical to the MCP server.
5
+ """
@@ -0,0 +1,5 @@
1
+ """Entrypoint: python -m tetra_cli.cli"""
2
+ from tetra_cli.cli.app import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
tetra_cli/cli/app.py ADDED
@@ -0,0 +1,86 @@
1
+ """Root Typer app with command group registration and the entrypoint."""
2
+ import typer
3
+
4
+ from tetra_cli.cli.output import set_pretty
5
+
6
+ app = typer.Typer(
7
+ help=(
8
+ "Tetra CLI — JSON-emitting command line for the Tetra API. "
9
+ "After making changes, log them with 'tetra-cli conversations ai-action' so any AI shares the history."
10
+ ),
11
+ no_args_is_help=True,
12
+ )
13
+
14
+
15
+ def _pretty_callback(value: bool) -> bool:
16
+ set_pretty(value)
17
+ return value
18
+
19
+
20
+ @app.callback()
21
+ def root(
22
+ pretty: bool = typer.Option(
23
+ False, "--pretty", help="Indent JSON output for human reading.",
24
+ callback=_pretty_callback, is_eager=True,
25
+ ),
26
+ ) -> None:
27
+ """Tetra CLI — emits JSON to stdout, errors to stderr."""
28
+
29
+
30
+ _commands_registered = False
31
+
32
+
33
+ def register_commands() -> None:
34
+ """Mount per-noun subapps. Deferred to avoid circular imports at module load.
35
+
36
+ Idempotent: several test modules import the app and call this at module
37
+ load, so guard against re-mounting (which would raise Typer duplicate
38
+ command/group errors).
39
+
40
+ The ``goals`` and ``events`` groups are NOT registered manually — they are
41
+ produced by ``register_generated`` from the @operation registry, which is
42
+ now the single source of truth for those two groups.
43
+ """
44
+ global _commands_registered
45
+ if _commands_registered:
46
+ return
47
+ _commands_registered = True
48
+
49
+ from tetra_cli.cli.commands import auth, guide, messages, skill
50
+ from tetra_cli.cli.generate import register_generated
51
+
52
+ # Generated commands from the @operation registry are the SINGLE source of
53
+ # truth for every API-backed capability. Register them first; the returned
54
+ # set is the top-level groups they own (used only as a safety check below).
55
+ generated = register_generated(app)
56
+
57
+ # The only surviving hand-written modules are those with no single-return
58
+ # API mapping a generator can express:
59
+ # * auth — local API-key config (no API route at all)
60
+ # * guide — static ontology text (no API route)
61
+ # * messages — the SSE stream (`subscribe`) + poll loop (`watch`) only;
62
+ # its read/post/reply/action commands are now generated `conversations`
63
+ # ops. `messages` is NOT a generated group, so mounting it is safe.
64
+ assert "messages" not in generated, "messages must not be a generated group"
65
+ app.add_typer(
66
+ messages.app, name="messages",
67
+ help="Stream (subscribe) or poll (watch) the AI thread in real time.",
68
+ )
69
+
70
+ # auth and guide are local/static (no API op); always manual.
71
+ app.add_typer(auth.app, name="auth", help="Save / inspect / clear the API key.")
72
+ app.command(name="guide", help="Print the Tetra ontology guide (values/goals/events).")(guide.guide)
73
+
74
+ # skill — installs the bundled agent skill locally (no API op).
75
+ app.add_typer(
76
+ skill.app, name="skill",
77
+ help="Install the Tetra agent skill (teaches an AI to drive this CLI).",
78
+ )
79
+
80
+
81
+ def main() -> None:
82
+ """Console-script entrypoint: configure logging, register commands, run."""
83
+ from tetra_cli.cli.output import configure_cli_logging
84
+ configure_cli_logging()
85
+ register_commands()
86
+ app()
@@ -0,0 +1 @@
1
+ """Per-noun Typer subapps. Each module exposes `app: typer.Typer`."""
@@ -0,0 +1,201 @@
1
+ """tetra-cli auth {login,status,logout}
2
+
3
+ Persists a `tetra_…` API key to `~/.tetra/cli_config.json` so subsequent
4
+ CLI/MCP invocations resolve credentials automatically. The CLI and MCP
5
+ server share `TetraConfig.from_env`, so saving once here makes any AI
6
+ agent that runs `tetra-cli` or speaks the MCP protocol authenticated
7
+ without per-call env-var juggling.
8
+
9
+ Resolution priority (set by `TetraConfig.from_env`):
10
+ 1. TETRA_API_KEY env var
11
+ 2. ~/.tetra/cli_config.json (this file)
12
+ 3. TETRA_USERNAME + credentials.json
13
+ 4. TETRA_USERNAME + TETRA_PASSWORD (login)
14
+
15
+ Env vars still override the saved config so users can flip backends
16
+ (staging vs prod) without re-running `auth login`.
17
+ """
18
+ import json
19
+ import os
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import httpx
25
+ import typer
26
+
27
+ from tetra_cli.cli.output import emit, emit_error
28
+
29
+ DEFAULT_API_URL = "https://api.tetraoptum.com"
30
+
31
+
32
+ app = typer.Typer(
33
+ no_args_is_help=True,
34
+ help="Save / inspect / clear the API key the CLI + MCP use to talk to Tetra.",
35
+ )
36
+
37
+
38
+ def _config_dir() -> Path:
39
+ return Path.home() / ".tetra"
40
+
41
+
42
+ def _config_path() -> Path:
43
+ """Single source of truth for the saved-config location.
44
+
45
+ Reading via a function (not a module-level constant) lets tests
46
+ redirect `$HOME` to a tmp dir without monkeypatching this module.
47
+ """
48
+ return _config_dir() / "cli_config.json"
49
+
50
+
51
+ def _verify_key(api_url: str, api_key: str) -> dict[str, Any]:
52
+ """Confirm the key works against `api_url` by hitting `/accounts/me`.
53
+
54
+ Returns the account payload on success; raises `ValueError` with a
55
+ short reason on failure. We never save an unverified key — that
56
+ would leave the user with a broken config that fails on every
57
+ subsequent invocation instead of upfront.
58
+ """
59
+ try:
60
+ resp = httpx.get(
61
+ f"{api_url}/api/v1/accounts/me",
62
+ headers={"Authorization": f"Bearer {api_key}"},
63
+ timeout=10.0,
64
+ )
65
+ except httpx.RequestError as exc:
66
+ raise ValueError(f"Could not reach {api_url}: {exc}") from exc
67
+ if resp.status_code == 401:
68
+ raise ValueError(
69
+ f"Backend rejected the key (HTTP 401). Either it's wrong, expired, "
70
+ f"or it was minted on a different backend. Mint a new one in Settings → "
71
+ f"API Keys on {api_url} and try again."
72
+ )
73
+ if resp.status_code != 200:
74
+ raise ValueError(f"HTTP {resp.status_code}: {resp.text[:200]}")
75
+ return resp.json()
76
+
77
+
78
+ def _mask(token: str) -> str:
79
+ """Truncate a token for display. Keeps the prefix users see in
80
+ the API Keys list, drops the rest, e.g. `tetra_abcdef…`.
81
+ """
82
+ if not token:
83
+ return ""
84
+ if len(token) <= 12:
85
+ return token[:6] + "…"
86
+ return token[:12] + "…"
87
+
88
+
89
+ @app.command("login")
90
+ def login(
91
+ token: str | None = typer.Argument(
92
+ None,
93
+ metavar="API_KEY",
94
+ help="The `tetra_…` API key (alternative to --key).",
95
+ ),
96
+ key: str | None = typer.Option(
97
+ None, "--key", help="API key (`tetra_…`). Positional argument also accepted."
98
+ ),
99
+ url: str = typer.Option(
100
+ DEFAULT_API_URL,
101
+ "--url",
102
+ help=f"Backend base URL. Defaults to {DEFAULT_API_URL}.",
103
+ ),
104
+ ) -> None:
105
+ """Verify a `tetra_…` API key and save it to ~/.tetra/cli_config.json.
106
+
107
+ After this, every `tetra-cli` and `tetra-mcp` invocation
108
+ on this machine reads the saved key automatically — no env vars
109
+ needed. `TETRA_API_KEY` / `TETRA_API_URL` env vars still override
110
+ the saved values per-invocation if set.
111
+ """
112
+ raw = key or token
113
+ if not raw:
114
+ emit_error("missing_key", "Pass the API key as a positional arg or --key.")
115
+ return
116
+ raw = raw.strip()
117
+ if not raw.startswith("tetra_"):
118
+ emit_error(
119
+ "bad_key_format",
120
+ "API keys start with `tetra_`. Got: " + raw[:8] + "…",
121
+ )
122
+ return
123
+
124
+ try:
125
+ account = _verify_key(url, raw)
126
+ except ValueError as exc:
127
+ emit_error("verify_failed", str(exc))
128
+ return
129
+
130
+ cfg = {
131
+ "api_key": raw,
132
+ "api_url": url,
133
+ "saved_at": datetime.now(UTC).isoformat(),
134
+ "verified_username": account.get("username"),
135
+ "verified_uid": account.get("uid"),
136
+ }
137
+ _config_dir().mkdir(parents=True, exist_ok=True)
138
+ path = _config_path()
139
+ path.write_text(json.dumps(cfg, indent=2))
140
+ # API key on disk is sensitive — lock the file down to owner-only.
141
+ os.chmod(path, 0o600)
142
+
143
+ emit({
144
+ "saved": str(path),
145
+ "verified_username": account.get("username"),
146
+ "api_url": url,
147
+ "api_key": _mask(raw),
148
+ })
149
+
150
+
151
+ @app.command("status")
152
+ def status() -> None:
153
+ """Report which auth source the CLI will use on the next invocation.
154
+
155
+ Lets an AI/agent confirm "yes, I'm authenticated" before retrying a
156
+ failed call. Tokens are masked — full value never appears in output.
157
+ """
158
+ # Env vars win — report them first.
159
+ env_key = os.environ.get("TETRA_API_KEY")
160
+ if env_key:
161
+ emit({
162
+ "source": "env_api_key",
163
+ "api_key": _mask(env_key),
164
+ "api_url": os.environ.get("TETRA_API_URL", DEFAULT_API_URL),
165
+ })
166
+ return
167
+
168
+ env_username = os.environ.get("TETRA_USERNAME")
169
+ if env_username:
170
+ emit({
171
+ "source": "env_username",
172
+ "username": env_username,
173
+ "api_url": os.environ.get("TETRA_API_URL", DEFAULT_API_URL),
174
+ })
175
+ return
176
+
177
+ path = _config_path()
178
+ if path.exists():
179
+ data = json.loads(path.read_text())
180
+ emit({
181
+ "source": "saved_config",
182
+ "path": str(path),
183
+ "api_key": _mask(data.get("api_key", "")),
184
+ "api_url": data.get("api_url"),
185
+ "verified_username": data.get("verified_username"),
186
+ "saved_at": data.get("saved_at"),
187
+ })
188
+ return
189
+
190
+ emit({"source": "none", "hint": "Run `tetra-cli auth login <key>` to save."})
191
+
192
+
193
+ @app.command("logout")
194
+ def logout() -> None:
195
+ """Remove the saved config. Idempotent — does nothing if no file exists."""
196
+ path = _config_path()
197
+ if path.exists():
198
+ path.unlink()
199
+ emit({"removed": str(path)})
200
+ else:
201
+ emit({"removed": None, "hint": "Nothing to remove."})
@@ -0,0 +1,8 @@
1
+ """tetra-cli guide — print the canonical Tetra ontology guide for agents."""
2
+ from tetra_cli.cli.output import emit
3
+ from tetra_cli.ontology import ONTOLOGY_GUIDE
4
+
5
+
6
+ def guide() -> None:
7
+ """Explain how to structure data: values (plan) vs goals (achieve) vs events (actuals)."""
8
+ emit({"guide": ONTOLOGY_GUIDE})
@@ -0,0 +1,161 @@
1
+ """tetra messages — real-time AI-thread stream (subscribe) and poll loop (watch).
2
+
3
+ Only the streaming/polling commands live here: they have no single-return API
4
+ mapping, so the @operation generator cannot express them. Every other
5
+ AI-thread capability (read, post, reply, action) is a generated ``conversations``
6
+ command:
7
+
8
+ tetra-cli conversations ai-read [--all] # was: messages read
9
+ tetra-cli conversations ai-post "<text>" # was: messages post
10
+ tetra-cli conversations ai-reply <uid> "<text>" # was: messages reply
11
+ tetra-cli conversations ai-action --content ... --action '{...}' # was: messages action
12
+
13
+ Examples::
14
+
15
+ tetra messages subscribe # SSE firehose, one JSON object per line
16
+ tetra messages watch --interval 5 # poll loop for external drivers
17
+ """
18
+ import asyncio
19
+ import json
20
+
21
+ import typer
22
+
23
+ import tetra_cli.cli.context as _ctx
24
+ from tetra_cli.api_client import TetraClient
25
+ from tetra_cli.api_client.operations import conversations as ops
26
+ from tetra_cli.cli.output import emit_error, run_async
27
+
28
+ app = typer.Typer(
29
+ no_args_is_help=True,
30
+ help="Stream (subscribe) or poll (watch) the AI thread in real time.",
31
+ )
32
+
33
+
34
+ def parse_sse_event(block: str) -> dict | None:
35
+ """Parse one SSE event block into {'id': str|None, 'data': dict}, or None
36
+ for comment/heartbeat/blank blocks. Field order within the block is
37
+ insignificant (the backend emits data then id)."""
38
+ event_id = None
39
+ data_lines = []
40
+ for line in block.splitlines():
41
+ if line.startswith(":"): # comment / heartbeat
42
+ continue
43
+ if line.startswith("id:"):
44
+ event_id = line[3:].strip()
45
+ elif line.startswith("data:"):
46
+ data_lines.append(line[5:].strip())
47
+ if not data_lines:
48
+ return None
49
+ try:
50
+ return {"id": event_id, "data": json.loads("".join(data_lines))}
51
+ except json.JSONDecodeError:
52
+ return None
53
+
54
+
55
+ def _subscribe_endpoint() -> tuple[str, str]:
56
+ """Return ``(origin, bearer_token)`` for the SSE stream.
57
+
58
+ Reuses the exact config the rest of the CLI uses (``TetraConfig.from_env()``
59
+ via :mod:`tetra_cli.api_client`). ``api_url`` is the bare origin (no
60
+ ``/api/v1``), so the caller appends the route path directly.
61
+ """
62
+ from tetra_cli.api_client import TetraConfig
63
+
64
+ cfg = TetraConfig.from_env()
65
+ return cfg.api_url.rstrip("/"), cfg.token
66
+
67
+
68
+ @app.command("watch")
69
+ def watch(
70
+ interval: float = typer.Option(5.0, "--interval", help="Seconds between polls."),
71
+ mark_read: bool = typer.Option(
72
+ True, "--mark-read/--no-mark-read",
73
+ help="Advance the cursor after printing.",
74
+ ),
75
+ ) -> None:
76
+ """Poll for unseen messages forever, printing each new batch as JSON lines."""
77
+
78
+ async def _poll_once(client: TetraClient, convo_uid: str) -> list:
79
+ result = await ops.get_unseen(client, convo_uid)
80
+ msgs = result.get("messages", [])
81
+ if msgs and mark_read:
82
+ await ops.mark_agent_read(client, convo_uid)
83
+ return msgs
84
+
85
+ async def _loop() -> None:
86
+ client = _ctx.build_client()
87
+ try:
88
+ convo = await ops.get_ai_conversation(client)
89
+ while True:
90
+ for m in await _poll_once(client, convo["uid"]):
91
+ typer.echo(json.dumps(m))
92
+ await asyncio.sleep(interval)
93
+ finally:
94
+ await client.close()
95
+
96
+ try:
97
+ run_async(_loop())
98
+ except KeyboardInterrupt: # pragma: no cover - interactive
99
+ raise typer.Exit(0)
100
+
101
+
102
+ @app.command("subscribe")
103
+ def subscribe(
104
+ reconnect: bool = typer.Option(
105
+ True, "--reconnect/--no-reconnect",
106
+ help="Auto-reconnect on disconnect.",
107
+ ),
108
+ reconnect_max_seconds: float = typer.Option(
109
+ 30.0, "--reconnect-max-seconds",
110
+ help="Max backoff between reconnects.",
111
+ ),
112
+ ) -> None:
113
+ """Stream new AI-thread messages as JSON lines (real-time). Runs until killed.
114
+
115
+ Emits one JSON object per line per interaction; the firehose includes your
116
+ own messages — filter on api_key_uid downstream if needed.
117
+ """
118
+ import time
119
+
120
+ import httpx
121
+
122
+ base_url, token = _subscribe_endpoint()
123
+ url = f"{base_url}/api/v1/conversations/ai/stream"
124
+ last_id: str | None = None
125
+ backoff = 1.0
126
+
127
+ while True:
128
+ try:
129
+ req_headers = {
130
+ "Authorization": f"Bearer {token}",
131
+ "Accept": "text/event-stream",
132
+ }
133
+ if last_id:
134
+ req_headers["Last-Event-ID"] = last_id
135
+ with httpx.stream("GET", url, headers=req_headers, timeout=None) as resp:
136
+ if resp.status_code != 200:
137
+ emit_error("stream_error", f"HTTP {resp.status_code}", exit_code=1)
138
+ return
139
+ backoff = 1.0
140
+ block_lines: list[str] = []
141
+ for line in resp.iter_lines():
142
+ if line == "":
143
+ ev = parse_sse_event("\n".join(block_lines))
144
+ block_lines = []
145
+ if ev is None:
146
+ continue
147
+ if ev["id"]:
148
+ last_id = ev["id"]
149
+ typer.echo(json.dumps(ev["data"]))
150
+ else:
151
+ block_lines.append(line)
152
+ except KeyboardInterrupt: # pragma: no cover - interactive
153
+ raise typer.Exit(0)
154
+ except (httpx.HTTPError, OSError) as exc: # network error
155
+ if not reconnect:
156
+ emit_error("stream_error", str(exc), exit_code=1)
157
+ return
158
+ if not reconnect:
159
+ return
160
+ time.sleep(min(backoff, reconnect_max_seconds))
161
+ backoff = min(backoff * 2, reconnect_max_seconds)
@@ -0,0 +1,71 @@
1
+ """tetra-cli skill {install,show}
2
+
3
+ Ships the agent-facing Tetra skill bundled inside this package out to where an
4
+ AI coding agent discovers it (Claude Code: ``~/.claude/skills/``). Keeping the
5
+ skill in the wheel means `pip install tetra-cli` delivers the client AND the
6
+ instructions that teach an agent to drive it — one artifact, one version.
7
+ """
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from tetra_cli.cli.output import emit, emit_error
14
+
15
+ app = typer.Typer(
16
+ no_args_is_help=True,
17
+ help="Install the Tetra agent skill (teaches an AI to drive this CLI).",
18
+ )
19
+
20
+ # Default skills root for Claude Code. Other agents (Codex, etc.) keep skills
21
+ # elsewhere — point `--dir` at their skills directory for those.
22
+ _DEFAULT_SKILLS_DIR = Path.home() / ".claude" / "skills"
23
+ _SKILL_NAME = "tetra-cli"
24
+
25
+
26
+ def _bundled_skill() -> str:
27
+ """Return the bundled SKILL.md text shipped inside the package."""
28
+ return resources.files("tetra_cli.skill").joinpath("SKILL.md").read_text(encoding="utf-8")
29
+
30
+
31
+ @app.command("install")
32
+ def install(
33
+ dir: Path = typer.Option(
34
+ _DEFAULT_SKILLS_DIR,
35
+ "--dir",
36
+ help="Skills directory to install into. Defaults to Claude Code's "
37
+ "~/.claude/skills; pass another agent's skills dir to target it.",
38
+ ),
39
+ force: bool = typer.Option(
40
+ False, "--force", help="Overwrite an existing tetra-cli skill."
41
+ ),
42
+ ) -> None:
43
+ """Copy the bundled Tetra skill into an agent's skills directory.
44
+
45
+ Writes ``<dir>/tetra-cli/SKILL.md``. Idempotent unless the file already
46
+ exists with different content — then it refuses without ``--force`` so a
47
+ user's local edits are never silently clobbered.
48
+ """
49
+ dest_dir = dir / _SKILL_NAME
50
+ dest = dest_dir / "SKILL.md"
51
+ content = _bundled_skill()
52
+
53
+ if dest.exists() and not force:
54
+ if dest.read_text(encoding="utf-8") == content:
55
+ emit({"installed": str(dest), "status": "already_current"})
56
+ return
57
+ emit_error(
58
+ "skill_exists",
59
+ f"{dest} already exists and differs. Re-run with --force to overwrite.",
60
+ )
61
+ return
62
+
63
+ dest_dir.mkdir(parents=True, exist_ok=True)
64
+ dest.write_text(content, encoding="utf-8")
65
+ emit({"installed": str(dest), "status": "ok"})
66
+
67
+
68
+ @app.command("show")
69
+ def show() -> None:
70
+ """Print the bundled skill to stdout (to inspect or pipe it somewhere)."""
71
+ typer.echo(_bundled_skill())
@@ -0,0 +1,13 @@
1
+ """Per-invocation context: builds an authenticated TetraClient from env."""
2
+ from tetra_cli.api_client import TetraClient, TetraConfig
3
+
4
+
5
+ def build_client() -> TetraClient:
6
+ """Build a TetraClient using TetraConfig.from_env().
7
+
8
+ Auth resolution priority (set one):
9
+ TETRA_API_KEY - direct API key (tetra_...)
10
+ TETRA_USERNAME - reads ~/.tetra/{username}_credentials.json
11
+ TETRA_USERNAME + TETRA_PASSWORD - logs in via API
12
+ """
13
+ return TetraClient(TetraConfig.from_env())