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
|
@@ -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")
|
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())
|
tetra_cli/cli/context.py
ADDED
|
@@ -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())
|