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,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
@@ -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}")