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,282 @@
|
|
|
1
|
+
"""Generate Typer commands from the operation registry.
|
|
2
|
+
|
|
3
|
+
Typer reads a command function's parameters (name, annotation, default) to
|
|
4
|
+
build its CLI. We synthesise a function with a real ``__signature__`` whose
|
|
5
|
+
parameter defaults are ``typer.Option``/``typer.Argument`` objects, so Typer
|
|
6
|
+
introspects it like a hand-written command. The body collects the parsed
|
|
7
|
+
values, parses any JSON-blob options, and runs the op in-process via the
|
|
8
|
+
existing emit/build_client glue.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import json as _json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from tetra_cli import registry
|
|
21
|
+
from tetra_cli.cli.output import emit, emit_error, run_async
|
|
22
|
+
from tetra_cli.registry import OperationSpec, ParamSpec
|
|
23
|
+
|
|
24
|
+
# `output="file"` ops (binary/CSV/zip/iCal downloads) ARE honored: the op
|
|
25
|
+
# returns a ``(content_bytes, filename)`` tuple via ``TetraClient.download``
|
|
26
|
+
# and the generated command writes the bytes to disk (or stdout) instead of
|
|
27
|
+
# JSON-encoding them. See the file-output branch in ``build_typer_command``.
|
|
28
|
+
#
|
|
29
|
+
# superuser ops are skipped entirely (admin-only; not part of the shareable
|
|
30
|
+
# user-scoped surface). TODO: the text:<formatter> / stream `output` formatters
|
|
31
|
+
# are not yet honored — those ops are still emitted as JSON. Deferred.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clear_flag(name: str) -> str:
|
|
35
|
+
"""Companion boolean flag that clears a repeatable list param to []."""
|
|
36
|
+
return f"--clear-{name.replace('_', '-')}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write_file_result(result: Any, output: str | None) -> None:
|
|
40
|
+
"""Write an ``output="file"`` op's ``(bytes, filename)`` result to disk.
|
|
41
|
+
|
|
42
|
+
Destination resolution:
|
|
43
|
+
* ``output == "-"`` -> raw bytes to ``sys.stdout.buffer`` (no JSON).
|
|
44
|
+
* ``output`` set -> that path.
|
|
45
|
+
* otherwise -> the server-supplied ``filename`` in the cwd.
|
|
46
|
+
|
|
47
|
+
On success (non-stdout) emits ``{"saved": <path>, "bytes": <n>}`` as JSON
|
|
48
|
+
so the CLI's single-line machine-readable contract still holds. The raw
|
|
49
|
+
bytes are never JSON-serialized.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
result: The ``(content_bytes, filename)`` tuple the op returned.
|
|
53
|
+
output: The ``--output`` value (path, ``"-"``, or None).
|
|
54
|
+
"""
|
|
55
|
+
content, filename = result
|
|
56
|
+
if output == "-":
|
|
57
|
+
sys.stdout.buffer.write(content)
|
|
58
|
+
sys.stdout.buffer.flush()
|
|
59
|
+
return
|
|
60
|
+
path = output or filename
|
|
61
|
+
with open(path, "wb") as fh:
|
|
62
|
+
fh.write(content)
|
|
63
|
+
emit({"saved": path, "bytes": len(content)})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _typer_default(name: str, ps: ParamSpec, sig: inspect.Signature) -> Any:
|
|
67
|
+
"""Build the typer.Argument/Option default that drives Typer's parser.
|
|
68
|
+
|
|
69
|
+
For an OPTION, the Typer default mirrors the op signature's own default so
|
|
70
|
+
that omitting the flag yields the op's intended value (e.g. ``limit=50``)
|
|
71
|
+
rather than a spurious ``None`` that would override it. Repeatable/list and
|
|
72
|
+
boolean options keep ``None``/``False`` as before (the impl normalizes an
|
|
73
|
+
empty list back to None when appropriate). Required params (no signature
|
|
74
|
+
default) stay positional arguments.
|
|
75
|
+
"""
|
|
76
|
+
if ps.kind == "argument":
|
|
77
|
+
return typer.Argument(..., help=ps.help)
|
|
78
|
+
flags = ps.flags or (f"--{name.replace('_', '-')}",)
|
|
79
|
+
kwargs: dict[str, Any] = {"help": ps.help}
|
|
80
|
+
if ps.min is not None:
|
|
81
|
+
kwargs["min"] = ps.min
|
|
82
|
+
if ps.max is not None:
|
|
83
|
+
kwargs["max"] = ps.max
|
|
84
|
+
if ps.choices:
|
|
85
|
+
# Constrain the option to a fixed set; Click validates and lists the
|
|
86
|
+
# choices in --help. The op still receives the raw string value.
|
|
87
|
+
kwargs["click_type"] = click.Choice(ps.choices)
|
|
88
|
+
# Repeatable options collect N values; Typer/Click needs None (not the op's
|
|
89
|
+
# default) so the impl can tell "flag omitted" from "flag given empty".
|
|
90
|
+
if ps.repeatable:
|
|
91
|
+
return typer.Option(None, *flags, **kwargs)
|
|
92
|
+
sig_default = sig.parameters[name].default
|
|
93
|
+
cli_default = None if sig_default is inspect.Parameter.empty else sig_default
|
|
94
|
+
return typer.Option(cli_default, *flags, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _annotation(name: str, ps: ParamSpec, sig: inspect.Signature) -> Any:
|
|
98
|
+
"""Pick the CLI-facing annotation Typer should parse the value as.
|
|
99
|
+
|
|
100
|
+
Repeatable -> Optional[list[str]] (collects N values), JSON-blob -> str
|
|
101
|
+
(we parse it ourselves), otherwise infer int/float/bool/str from the op's
|
|
102
|
+
own annotation when present.
|
|
103
|
+
"""
|
|
104
|
+
if ps.repeatable:
|
|
105
|
+
return Optional[list[str]]
|
|
106
|
+
if ps.json:
|
|
107
|
+
return Optional[str]
|
|
108
|
+
# min/max bounds only make sense for numbers -> treat as int.
|
|
109
|
+
if ps.min is not None or ps.max is not None:
|
|
110
|
+
return Optional[int]
|
|
111
|
+
param = sig.parameters[name]
|
|
112
|
+
ann = param.annotation
|
|
113
|
+
raw = repr(ann)
|
|
114
|
+
if "int" in raw:
|
|
115
|
+
return Optional[int]
|
|
116
|
+
if "float" in raw:
|
|
117
|
+
return Optional[float]
|
|
118
|
+
if "bool" in raw:
|
|
119
|
+
return Optional[bool]
|
|
120
|
+
# No usable annotation: infer the CLI type from a non-None signature
|
|
121
|
+
# default so an int default (e.g. limit=50) is parsed as an int, not a
|
|
122
|
+
# string. (bool checked before int: bool is a subclass of int.)
|
|
123
|
+
if ann is inspect.Parameter.empty and param.default not in (
|
|
124
|
+
inspect.Parameter.empty, None,
|
|
125
|
+
):
|
|
126
|
+
dft = param.default
|
|
127
|
+
if isinstance(dft, bool):
|
|
128
|
+
return Optional[bool]
|
|
129
|
+
if isinstance(dft, int):
|
|
130
|
+
return Optional[int]
|
|
131
|
+
if isinstance(dft, float):
|
|
132
|
+
return Optional[float]
|
|
133
|
+
if ps.kind == "argument":
|
|
134
|
+
return str
|
|
135
|
+
return Optional[str]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_typer_command(spec: OperationSpec):
|
|
139
|
+
"""Return (command_name, callable) for ``group.command(name)(callable)``."""
|
|
140
|
+
sig = spec.signature()
|
|
141
|
+
op_params = spec.op_param_names()
|
|
142
|
+
is_file = spec.output == "file"
|
|
143
|
+
# For file ops, synthesize an `--output` (or `--out` if the op already owns
|
|
144
|
+
# a param literally named `output`) flag that picks the write destination.
|
|
145
|
+
output_kwarg = "out" if "output" in op_params else "output"
|
|
146
|
+
output_flags = (
|
|
147
|
+
("--out",) if output_kwarg == "out" else ("--output", "-o")
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
parameters: list[inspect.Parameter] = []
|
|
151
|
+
# Track which op params get a `--clear-<name>` companion and the synthetic
|
|
152
|
+
# kwarg name that carries that boolean into impl.
|
|
153
|
+
clear_kwargs: dict[str, str] = {}
|
|
154
|
+
for name in op_params:
|
|
155
|
+
ps = spec.params.get(name, ParamSpec())
|
|
156
|
+
default = _typer_default(name, ps, sig)
|
|
157
|
+
ann = _annotation(name, ps, sig)
|
|
158
|
+
parameters.append(inspect.Parameter(
|
|
159
|
+
name,
|
|
160
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
161
|
+
default=default,
|
|
162
|
+
annotation=ann,
|
|
163
|
+
))
|
|
164
|
+
if ps.kind == "option" and ps.repeatable and ps.clears:
|
|
165
|
+
clear_name = f"clear_{name}"
|
|
166
|
+
clear_kwargs[name] = clear_name
|
|
167
|
+
parameters.append(inspect.Parameter(
|
|
168
|
+
clear_name,
|
|
169
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
170
|
+
default=typer.Option(
|
|
171
|
+
False, _clear_flag(name),
|
|
172
|
+
help=f"Clear {name} (send an empty list).",
|
|
173
|
+
),
|
|
174
|
+
annotation=bool,
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
if is_file:
|
|
178
|
+
parameters.append(inspect.Parameter(
|
|
179
|
+
output_kwarg,
|
|
180
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
181
|
+
default=typer.Option(
|
|
182
|
+
None, *output_flags,
|
|
183
|
+
help="Write to this path (default: server filename in cwd; "
|
|
184
|
+
"'-' for stdout).",
|
|
185
|
+
),
|
|
186
|
+
annotation=Optional[str],
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
def impl(**kwargs: Any) -> None:
|
|
190
|
+
from tetra_cli.cli import context as _ctx
|
|
191
|
+
call_kwargs: dict[str, Any] = {}
|
|
192
|
+
for name in op_params:
|
|
193
|
+
ps = spec.params.get(name, ParamSpec())
|
|
194
|
+
val = kwargs.get(name)
|
|
195
|
+
if ps.json and val is not None:
|
|
196
|
+
try:
|
|
197
|
+
val = _json.loads(val)
|
|
198
|
+
except _json.JSONDecodeError as exc:
|
|
199
|
+
emit_error("invalid_json", f"--{name} is not valid JSON: {exc}")
|
|
200
|
+
return
|
|
201
|
+
if ps.repeatable and ps.clears and kwargs.get(clear_kwargs[name]):
|
|
202
|
+
# Explicit --clear-<name> wins: send [] to clear the list.
|
|
203
|
+
val = []
|
|
204
|
+
elif ps.repeatable and val == [] and not ps.clears:
|
|
205
|
+
val = None
|
|
206
|
+
call_kwargs[name] = val
|
|
207
|
+
|
|
208
|
+
async def _call() -> Any:
|
|
209
|
+
client = _ctx.build_client()
|
|
210
|
+
try:
|
|
211
|
+
return await spec.func(client, **call_kwargs)
|
|
212
|
+
finally:
|
|
213
|
+
await client.close()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
result = run_async(_call())
|
|
217
|
+
if is_file:
|
|
218
|
+
_write_file_result(result, kwargs.get(output_kwarg))
|
|
219
|
+
else:
|
|
220
|
+
emit(result)
|
|
221
|
+
except (typer.Exit, SystemExit):
|
|
222
|
+
# Control-flow signals (e.g. emit_error's SystemExit, an op's
|
|
223
|
+
# typer.Exit) must propagate with their own exit code, not be
|
|
224
|
+
# rewrapped as an api_error.
|
|
225
|
+
raise
|
|
226
|
+
except Exception as exc: # noqa: BLE001
|
|
227
|
+
emit_error("api_error", str(exc))
|
|
228
|
+
|
|
229
|
+
impl.__signature__ = inspect.Signature(parameters) # type: ignore[attr-defined]
|
|
230
|
+
impl.__name__ = spec.command.replace("-", "_").replace(" ", "_")
|
|
231
|
+
impl.__doc__ = spec.summary
|
|
232
|
+
return spec.command, impl
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def register_generated(app: typer.Typer) -> set[str]:
|
|
236
|
+
"""Build a Typer command for every non-manual registered op and mount it.
|
|
237
|
+
|
|
238
|
+
A spec's ``cli`` string may have any number of segments: the last segment
|
|
239
|
+
is the command name, every earlier segment is a (possibly nested) Typer
|
|
240
|
+
group. Intermediate groups are created on demand and cached by their full
|
|
241
|
+
path prefix so sibling commands share the same parent group
|
|
242
|
+
(``goals metrics record`` and ``goals metrics edit`` reuse one ``metrics``).
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The set of top-level group names produced. The app registers generated
|
|
246
|
+
groups first and skips any manual command module whose group the
|
|
247
|
+
registry now owns, so annotating an op never collides with a leftover
|
|
248
|
+
hand-written command for the same group.
|
|
249
|
+
"""
|
|
250
|
+
registry.load_operations()
|
|
251
|
+
# Cache nested Typer groups by their full path prefix, e.g. "goals" and
|
|
252
|
+
# "goals metrics". The empty prefix maps to the root app.
|
|
253
|
+
groups: dict[str, typer.Typer] = {"": app}
|
|
254
|
+
top_groups: set[str] = set()
|
|
255
|
+
for spec in registry.all():
|
|
256
|
+
# superuser ops are admin-only (server 403s for normal keys) — keep them
|
|
257
|
+
# off the shareable agent surface entirely.
|
|
258
|
+
if spec.manual or spec.superuser:
|
|
259
|
+
continue
|
|
260
|
+
segments = spec.cli.split()
|
|
261
|
+
if len(segments) > 1:
|
|
262
|
+
top_groups.add(segments[0])
|
|
263
|
+
*group_path, command_name = segments
|
|
264
|
+
# Walk/create the chain of intermediate groups.
|
|
265
|
+
prefix = ""
|
|
266
|
+
for seg in group_path:
|
|
267
|
+
child_prefix = f"{prefix} {seg}".strip()
|
|
268
|
+
child = groups.get(child_prefix)
|
|
269
|
+
if child is None:
|
|
270
|
+
child = typer.Typer(no_args_is_help=True)
|
|
271
|
+
groups[child_prefix] = child
|
|
272
|
+
# Give each generated group a non-blank description so the
|
|
273
|
+
# parent's --help isn't a wall of empty rows. Derived from the
|
|
274
|
+
# segment name (e.g. "goals" -> "Goals commands").
|
|
275
|
+
groups[prefix].add_typer(
|
|
276
|
+
child, name=seg, help=f"{seg.replace('-', ' ').capitalize()} commands",
|
|
277
|
+
)
|
|
278
|
+
prefix = child_prefix
|
|
279
|
+
parent = groups[prefix]
|
|
280
|
+
_, fn = build_typer_command(spec)
|
|
281
|
+
parent.command(command_name, help=spec.summary)(fn)
|
|
282
|
+
return top_groups
|
tetra_cli/cli/output.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""JSON output, error reporting, and async glue for the CLI."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
# Module-level pretty flag. The root Typer callback toggles this.
|
|
12
|
+
_pretty = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_pretty(value: bool) -> None:
|
|
16
|
+
"""Toggle indented JSON output. Called by the root --pretty option."""
|
|
17
|
+
global _pretty
|
|
18
|
+
_pretty = value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _serialize(obj: Any) -> Any:
|
|
22
|
+
if isinstance(obj, (datetime, date)):
|
|
23
|
+
return obj.isoformat()
|
|
24
|
+
if hasattr(obj, "__dict__"):
|
|
25
|
+
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
|
26
|
+
return str(obj)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def emit(data: Any) -> None:
|
|
30
|
+
"""Print JSON to stdout. Single exit point for all CLI output."""
|
|
31
|
+
if _pretty:
|
|
32
|
+
print(json.dumps(data, default=_serialize, indent=2))
|
|
33
|
+
else:
|
|
34
|
+
print(json.dumps(data, default=_serialize))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def emit_error(error: str, message: str, exit_code: int = 1) -> None:
|
|
38
|
+
"""Print JSON error to stderr and exit."""
|
|
39
|
+
print(json.dumps({"error": error, "message": message}), file=sys.stderr)
|
|
40
|
+
sys.exit(exit_code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_async(coro: Coroutine[Any, Any, T]) -> T:
|
|
44
|
+
"""Run an async coroutine from a sync Typer command body."""
|
|
45
|
+
return asyncio.run(coro)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def configure_cli_logging() -> None:
|
|
49
|
+
"""Route all logging to stderr so stdout stays clean for JSON output."""
|
|
50
|
+
import logging
|
|
51
|
+
root = logging.getLogger()
|
|
52
|
+
for handler in root.handlers[:]:
|
|
53
|
+
root.removeHandler(handler)
|
|
54
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
55
|
+
stderr_handler.setFormatter(
|
|
56
|
+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
57
|
+
)
|
|
58
|
+
root.addHandler(stderr_handler)
|
tetra_cli/mcp_gen.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Generate MCP tools from the operation registry.
|
|
2
|
+
|
|
3
|
+
FastMCP introspects a tool function's typed parameters to build its input
|
|
4
|
+
schema. We synthesise an async function with an injected ``__signature__``
|
|
5
|
+
(json params -> dict, repeatable -> list[str], else str/int/float/bool, with
|
|
6
|
+
op defaults preserved) and register it on the FastMCP instance passed in by the
|
|
7
|
+
caller. This module must NOT import ``mcp`` — the FastMCP object is injected, so
|
|
8
|
+
the slim package's deps stay httpx+typer and the no-backend-leak invariant holds.
|
|
9
|
+
|
|
10
|
+
Optionality is signalled purely by the synthesised parameter's DEFAULT value:
|
|
11
|
+
a parameter with ``default=None`` is optional to FastMCP regardless of its
|
|
12
|
+
annotation, so we keep annotations bare (``dict``, ``list[str]``, ``str`` …)
|
|
13
|
+
rather than wrapping them in ``Optional[...]``. Required op params (no signature
|
|
14
|
+
default) get no default in the synthesised signature.
|
|
15
|
+
|
|
16
|
+
TODO: ``output="text:<formatter>"`` formatting (tools currently return the raw
|
|
17
|
+
JSON-able op result) and ``choices`` -> ``typing.Literal`` constraints are
|
|
18
|
+
deferred to a later phase; don't mistake these for working.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import inspect
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from tetra_cli import registry
|
|
28
|
+
from tetra_cli.registry import OperationSpec, ParamSpec
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _file_result_to_tool_payload(result: Any) -> Any:
|
|
32
|
+
"""Turn a file op's ``(bytes, filename)`` return into a tool-friendly dict.
|
|
33
|
+
|
|
34
|
+
``output="file"`` ops (data export, iCal, projection CSV/zip) download raw
|
|
35
|
+
bytes for the CLI to write to disk. An MCP tool has no disk sink, so return
|
|
36
|
+
the content inline instead: UTF-8 text when the body decodes (JSON exports,
|
|
37
|
+
iCal, CSV), base64 otherwise (zip archives). This lets an agent reach the
|
|
38
|
+
same export capabilities a human has, without leaving the conversation.
|
|
39
|
+
"""
|
|
40
|
+
if not (isinstance(result, tuple) and len(result) == 2):
|
|
41
|
+
return result # Not a (bytes, filename) tuple — pass through unchanged.
|
|
42
|
+
content, filename = result
|
|
43
|
+
if isinstance(content, (bytes, bytearray)):
|
|
44
|
+
try:
|
|
45
|
+
return {"filename": filename, "content": bytes(content).decode("utf-8")}
|
|
46
|
+
except UnicodeDecodeError:
|
|
47
|
+
return {
|
|
48
|
+
"filename": filename,
|
|
49
|
+
"encoding": "base64",
|
|
50
|
+
"content_base64": base64.b64encode(bytes(content)).decode("ascii"),
|
|
51
|
+
}
|
|
52
|
+
return {"filename": filename, "content": content}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _annotation(name: str, ps: ParamSpec, sig: inspect.Signature) -> Any:
|
|
56
|
+
"""Pick the MCP-facing annotation FastMCP should expose for a param.
|
|
57
|
+
|
|
58
|
+
JSON-blob params take a ``dict`` (MCP passes structured objects directly),
|
|
59
|
+
repeatable params take ``list[str]``, otherwise infer bool/int/float/str
|
|
60
|
+
from the op's own annotation. Optionality is carried by the default value,
|
|
61
|
+
not by wrapping in ``Optional`` — see the module docstring.
|
|
62
|
+
"""
|
|
63
|
+
if ps.json:
|
|
64
|
+
return dict
|
|
65
|
+
if ps.repeatable:
|
|
66
|
+
return list[str]
|
|
67
|
+
ann = sig.parameters[name].annotation
|
|
68
|
+
raw = repr(ann)
|
|
69
|
+
# bool checked before int: bool is a subclass of int.
|
|
70
|
+
if "bool" in raw:
|
|
71
|
+
return bool
|
|
72
|
+
if "int" in raw:
|
|
73
|
+
return int
|
|
74
|
+
if "float" in raw:
|
|
75
|
+
return float
|
|
76
|
+
return str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_tool(spec: OperationSpec, client_factory: Callable[[], Any]):
|
|
80
|
+
"""Synthesise the async tool function FastMCP will introspect and call."""
|
|
81
|
+
sig = spec.signature()
|
|
82
|
+
op_params = spec.op_param_names()
|
|
83
|
+
required = set(spec.required_param_names())
|
|
84
|
+
|
|
85
|
+
parameters: list[inspect.Parameter] = []
|
|
86
|
+
for name in op_params:
|
|
87
|
+
ps = spec.params.get(name, ParamSpec())
|
|
88
|
+
ann = _annotation(name, ps, sig)
|
|
89
|
+
if name in required:
|
|
90
|
+
# No default -> FastMCP treats it as required in the input schema.
|
|
91
|
+
parameters.append(inspect.Parameter(
|
|
92
|
+
name, inspect.Parameter.KEYWORD_ONLY, annotation=ann))
|
|
93
|
+
else:
|
|
94
|
+
sig_default = sig.parameters[name].default
|
|
95
|
+
default = None if sig_default is inspect.Parameter.empty else sig_default
|
|
96
|
+
# default present -> optional, even with a bare (non-Optional) annotation.
|
|
97
|
+
parameters.append(inspect.Parameter(
|
|
98
|
+
name, inspect.Parameter.KEYWORD_ONLY, default=default,
|
|
99
|
+
annotation=ann))
|
|
100
|
+
|
|
101
|
+
is_file = spec.output == "file"
|
|
102
|
+
|
|
103
|
+
async def impl(**kwargs: Any) -> Any:
|
|
104
|
+
client = client_factory()
|
|
105
|
+
result = await spec.func(client, **kwargs)
|
|
106
|
+
return _file_result_to_tool_payload(result) if is_file else result
|
|
107
|
+
|
|
108
|
+
impl.__signature__ = inspect.Signature(parameters) # type: ignore[attr-defined]
|
|
109
|
+
impl.__name__ = spec.mcp_tool or spec.func.__name__
|
|
110
|
+
impl.__doc__ = spec.summary
|
|
111
|
+
return impl
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def register_mcp_tools(mcp: Any, client_factory: Callable[[], Any]) -> int:
|
|
115
|
+
"""Register an MCP tool for every non-manual registered op.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
mcp: a FastMCP-like object exposing ``.tool(name=, description=)`` -> decorator.
|
|
119
|
+
client_factory: returns a shared TetraClient for tool calls (not closed per call).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The number of tools registered.
|
|
123
|
+
"""
|
|
124
|
+
registry.load_operations()
|
|
125
|
+
count = 0
|
|
126
|
+
for spec in registry.all():
|
|
127
|
+
# manual = hand-written elsewhere; superuser = admin-only (server 403s
|
|
128
|
+
# normal keys); stream = long-lived SSE that can't be a single tool
|
|
129
|
+
# return. File ops ARE exposed — their content is returned inline (see
|
|
130
|
+
# _file_result_to_tool_payload) so an agent has the same exports a human
|
|
131
|
+
# does.
|
|
132
|
+
if spec.manual or spec.superuser or spec.output == "stream":
|
|
133
|
+
continue
|
|
134
|
+
fn = _build_tool(spec, client_factory)
|
|
135
|
+
mcp.tool(name=spec.mcp_tool, description=spec.summary)(fn)
|
|
136
|
+
count += 1
|
|
137
|
+
return count
|
tetra_cli/ontology.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Canonical agent-facing ontology for Tetra: values, goals, events.
|
|
2
|
+
|
|
3
|
+
This is the slim-package copy of :data:`ONTOLOGY_GUIDE`. The CLI (``tetra-cli
|
|
4
|
+
guide``) returns it verbatim. It carries no backend dependencies so the CLI
|
|
5
|
+
package stays free of any ``tetra.*`` imports. The backend keeps its own copy
|
|
6
|
+
at ``tetra.core.ontology`` (which also owns the goal-linkage nudge logic).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
ONTOLOGY_GUIDE = """\
|
|
11
|
+
# Tetra: Values, Goals, Events
|
|
12
|
+
|
|
13
|
+
Tetra aligns your **time and money** with what matters. Three layers:
|
|
14
|
+
|
|
15
|
+
**Values — how you *plan* to spend time or money.** General, durable life areas,
|
|
16
|
+
arranged hierarchically. A value can carry a *schedule*: temporal
|
|
17
|
+
(`see friends Thu 6-9pm`) or financial (`$215/mo bill`). Every value descends
|
|
18
|
+
from one of the six **core values** — the top-level life domains: Intellect,
|
|
19
|
+
Willpower, Vitality, Spirit, Charisma, Creativity. (This is enforced: a value's
|
|
20
|
+
root must be a core value, so always create under one.) Values answer *"where
|
|
21
|
+
should my time and money go?"*
|
|
22
|
+
|
|
23
|
+
**Goals — concrete things you *achieve*, with metrics.** Goals hang off values
|
|
24
|
+
as **skill trees**: the specific, trackable things you'll do to get excellent at
|
|
25
|
+
a value. A goal has expected time/cost, dependencies, and a metric (reps, pages,
|
|
26
|
+
$, complete). Goals answer *"what concretely will I do for this value?"*
|
|
27
|
+
|
|
28
|
+
**Events — what *actually* happened.** Every event records real time/money and
|
|
29
|
+
links to a goal and/or value. Events answer *"what did I really do?"* and are
|
|
30
|
+
how progress and planned-vs-actual comparison work.
|
|
31
|
+
|
|
32
|
+
## Granularity — be as granular as the data you'll match against.
|
|
33
|
+
|
|
34
|
+
- Granular is *good* when it maps to real actuals or feeds a projection. Per-bill
|
|
35
|
+
values (`Financial > Bills > Internet`) let imported bank/credit-card data
|
|
36
|
+
reconcile planned vs. actual spend. Granular time-off
|
|
37
|
+
(`Sandia > Time Off > Vacation/Flex/...`) feeds projections like *"when will I
|
|
38
|
+
have enough PTO + savings for a vacation?"*
|
|
39
|
+
- The real anti-patterns are **unattributed events** (time/money logged against
|
|
40
|
+
no value) and **goals not linked to any value** (orphaned skill-tree
|
|
41
|
+
branches) — not depth itself.
|
|
42
|
+
|
|
43
|
+
## When acting for a user
|
|
44
|
+
|
|
45
|
+
Put plans in values, achievements in goals, actuals in events; attach every
|
|
46
|
+
event to a value/goal; keep new values under an existing core value; prefer
|
|
47
|
+
reusing an existing value/goal over creating a near-duplicate.
|
|
48
|
+
|
|
49
|
+
## Report what you do (required)
|
|
50
|
+
|
|
51
|
+
After any write — creating, editing, moving, or deleting a value, goal, or
|
|
52
|
+
event — post a short **action report** to the user's Tetra AI thread so the
|
|
53
|
+
user and every other AI share the same history. Use the messages API
|
|
54
|
+
(``POST /conversations/{ai_uid}/messages`` with ``interaction_type='action'``)
|
|
55
|
+
or the ``log_action`` tool. Each action report must carry: ``action_type``
|
|
56
|
+
(e.g. value_moved, goal_created, event_logged), ``subject_type``
|
|
57
|
+
(value | goal | event), ``subject_uid``, and ``subject_name`` set to the
|
|
58
|
+
entity's FULL canonical name (not a leaf) — the app uses ``subject_name`` to
|
|
59
|
+
render the card and deep-link it to the entity's detail view — plus a one-line
|
|
60
|
+
summary, the before/after, and a **reason** for the change. When you act because the user
|
|
61
|
+
replied to an earlier action, set ``reply_to_uid`` to that action so the
|
|
62
|
+
thread stays threaded. Before acting, poll ``GET .../agent/unseen`` (or the
|
|
63
|
+
``get_unseen_messages`` tool) to pick up the user's replies and honor them.
|
|
64
|
+
Instead of polling, you may register a push webhook (``register_webhook`` tool
|
|
65
|
+
or ``tetra messages webhook set``) and Tetra will POST new user messages to
|
|
66
|
+
your endpoint as they arrive. To react in real time without a webhook, run
|
|
67
|
+
``tetra messages subscribe`` — an SSE stream that pushes each new AI-thread
|
|
68
|
+
message as a JSON line (replays anything missed, then live); it's a firehose,
|
|
69
|
+
so ignore your own ``api_key_uid`` echoes.
|
|
70
|
+
"""
|
tetra_cli/registry.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Single source of truth for the agent surface.
|
|
2
|
+
|
|
3
|
+
`@operation` annotates an async op function with the metadata both the CLI
|
|
4
|
+
generator and the MCP generator need: command path, params, output kind, and
|
|
5
|
+
the API route(s) it covers (for the parity guard). The op function body stays
|
|
6
|
+
the executor — this only records surface metadata.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ParamSpec:
|
|
18
|
+
"""How one op parameter is exposed on the CLI/MCP surface."""
|
|
19
|
+
kind: str = "option" # "argument" | "option"
|
|
20
|
+
flags: tuple[str, ...] = () # e.g. ("--tag",); empty -> derived
|
|
21
|
+
help: str | None = None
|
|
22
|
+
repeatable: bool = False # list-valued (repeatable CLI option / MCP array)
|
|
23
|
+
json: bool = False # CLI parses a JSON string; MCP passes a dict
|
|
24
|
+
clears: bool = False # allow empty -> send [] (clear a list)
|
|
25
|
+
choices: list[str] | None = None
|
|
26
|
+
min: int | None = None
|
|
27
|
+
max: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def arg(*, help: str | None = None) -> ParamSpec:
|
|
31
|
+
"""A positional CLI argument (required)."""
|
|
32
|
+
return ParamSpec(kind="argument", help=help)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def opt(*flags: str, help: str | None = None, repeatable: bool = False,
|
|
36
|
+
json: bool = False, clears: bool = False,
|
|
37
|
+
choices: list[str] | None = None,
|
|
38
|
+
min: int | None = None, max: int | None = None) -> ParamSpec:
|
|
39
|
+
"""A CLI option (--flag)."""
|
|
40
|
+
return ParamSpec(kind="option", flags=tuple(flags), help=help,
|
|
41
|
+
repeatable=repeatable, json=json, clears=clears,
|
|
42
|
+
choices=choices, min=min, max=max)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class OperationSpec:
|
|
47
|
+
func: Callable[..., Any]
|
|
48
|
+
cli: str
|
|
49
|
+
summary: str
|
|
50
|
+
covers: list[tuple[str, str]]
|
|
51
|
+
params: dict[str, ParamSpec] = field(default_factory=dict)
|
|
52
|
+
mcp_tool: str | None = None
|
|
53
|
+
superuser: bool = False
|
|
54
|
+
output: str = "json" # "json" | "text:<formatter>" | "stream" | "file"
|
|
55
|
+
manual: bool = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def group(self) -> str:
|
|
59
|
+
return self.cli.split(" ", 1)[0]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def command(self) -> str:
|
|
63
|
+
parts = self.cli.split(" ", 1)
|
|
64
|
+
return parts[1] if len(parts) > 1 else parts[0]
|
|
65
|
+
|
|
66
|
+
def signature(self) -> inspect.Signature:
|
|
67
|
+
return inspect.signature(self.func)
|
|
68
|
+
|
|
69
|
+
def op_param_names(self) -> list[str]:
|
|
70
|
+
"""Op params excluding the leading `client`."""
|
|
71
|
+
return [p for p in self.signature().parameters if p != "client"]
|
|
72
|
+
|
|
73
|
+
def required_param_names(self) -> list[str]:
|
|
74
|
+
"""Op params (excluding client) with no default = required."""
|
|
75
|
+
out = []
|
|
76
|
+
for name, p in self.signature().parameters.items():
|
|
77
|
+
if name == "client":
|
|
78
|
+
continue
|
|
79
|
+
if p.default is inspect.Parameter.empty:
|
|
80
|
+
out.append(name)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
REGISTRY: list[OperationSpec] = []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def operation(*, cli: str, summary: str, covers: list[tuple[str, str]],
|
|
88
|
+
params: dict[str, ParamSpec] | None = None,
|
|
89
|
+
mcp_tool: str | None = None, superuser: bool = False,
|
|
90
|
+
output: str = "json", manual: bool = False):
|
|
91
|
+
"""Register `func` in REGISTRY with its surface metadata. Returns func unchanged."""
|
|
92
|
+
def deco(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
93
|
+
spec = OperationSpec(
|
|
94
|
+
func=func, cli=cli, summary=summary, covers=list(covers),
|
|
95
|
+
params=dict(params or {}),
|
|
96
|
+
mcp_tool=mcp_tool or func.__name__,
|
|
97
|
+
superuser=superuser, output=output, manual=manual,
|
|
98
|
+
)
|
|
99
|
+
func.__operation__ = spec # type: ignore[attr-defined]
|
|
100
|
+
REGISTRY.append(spec)
|
|
101
|
+
return func
|
|
102
|
+
return deco
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def all() -> list[OperationSpec]:
|
|
106
|
+
"""All registered operation specs (call load_operations() first to populate)."""
|
|
107
|
+
return list(REGISTRY)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_operations() -> None:
|
|
111
|
+
"""Import every operations submodule so their @operation decorators run."""
|
|
112
|
+
import importlib
|
|
113
|
+
import pkgutil
|
|
114
|
+
|
|
115
|
+
from tetra_cli.api_client import operations as ops_pkg
|
|
116
|
+
|
|
117
|
+
for info in pkgutil.iter_modules(ops_pkg.__path__):
|
|
118
|
+
importlib.import_module(f"tetra_cli.api_client.operations.{info.name}")
|