tetra-cli 0.2.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/PKG-INFO +1 -1
  2. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/pyproject.toml +1 -1
  3. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tests/test_smoke.py +1 -1
  4. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/__init__.py +1 -1
  5. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/capacity.py +17 -10
  6. tetra_cli-0.5.0/tetra_cli/api_client/operations/event_sessions.py +228 -0
  7. tetra_cli-0.5.0/tetra_cli/api_client/operations/foods.py +31 -0
  8. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/goals.py +176 -8
  9. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/groups.py +24 -5
  10. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/peer_connections.py +31 -0
  11. tetra_cli-0.5.0/tetra_cli/api_client/operations/skill_tree_rankings.py +152 -0
  12. tetra_cli-0.5.0/tetra_cli/api_client/operations/skill_tree_requests.py +161 -0
  13. tetra_cli-0.5.0/tetra_cli/api_client/operations/skill_trees.py +401 -0
  14. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/social_2.py +10 -10
  15. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/social_3.py +6 -6
  16. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/values.py +1 -1
  17. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/values_2.py +23 -6
  18. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/PKG-INFO +1 -1
  19. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/SOURCES.txt +4 -0
  20. tetra_cli-0.2.0/tetra_cli/api_client/operations/skill_trees.py +0 -160
  21. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/README.md +0 -0
  22. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/setup.cfg +0 -0
  23. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tests/test_no_backend_leak.py +0 -0
  24. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/__init__.py +0 -0
  25. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/client.py +0 -0
  26. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/config.py +0 -0
  27. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/__init__.py +0 -0
  28. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/accounts.py +0 -0
  29. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/ai.py +0 -0
  30. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/analysis.py +0 -0
  31. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/api_keys.py +0 -0
  32. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/archive.py +0 -0
  33. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/awards.py +0 -0
  34. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/conversations.py +0 -0
  35. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/conversations_2.py +0 -0
  36. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/cosmetics.py +0 -0
  37. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/dashboard.py +0 -0
  38. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/data.py +0 -0
  39. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/events.py +0 -0
  40. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/gamification.py +0 -0
  41. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/issues.py +0 -0
  42. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/offset.py +0 -0
  43. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/onboarding.py +0 -0
  44. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/outcome_schemas.py +0 -0
  45. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/plaid.py +0 -0
  46. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/reminders.py +0 -0
  47. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/scratches.py +0 -0
  48. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/social_4.py +0 -0
  49. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/strava.py +0 -0
  50. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/stripe.py +0 -0
  51. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/tags.py +0 -0
  52. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/watch.py +0 -0
  53. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/webhooks.py +0 -0
  54. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/api_client/operations/xp.py +0 -0
  55. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/__init__.py +0 -0
  56. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/__main__.py +0 -0
  57. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/app.py +0 -0
  58. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/commands/__init__.py +0 -0
  59. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/commands/auth.py +0 -0
  60. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/commands/guide.py +0 -0
  61. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/commands/messages.py +0 -0
  62. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/commands/skill.py +0 -0
  63. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/context.py +0 -0
  64. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/generate.py +0 -0
  65. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/cli/output.py +0 -0
  66. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/mcp_gen.py +0 -0
  67. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/ontology.py +0 -0
  68. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/registry.py +0 -0
  69. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/skill/SKILL.md +0 -0
  70. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli/skill/__init__.py +0 -0
  71. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/dependency_links.txt +0 -0
  72. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/entry_points.txt +0 -0
  73. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/requires.txt +0 -0
  74. {tetra_cli-0.2.0 → tetra_cli-0.5.0}/tetra_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tetra-cli
3
- Version: 0.2.0
3
+ Version: 0.5.0
4
4
  Summary: Standalone CLI/agent client for the Tetra API (no backend).
5
5
  Project-URL: Homepage, https://tetraoptum.com
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tetra-cli"
7
- version = "0.2.0"
7
+ version = "0.5.0"
8
8
  description = "Standalone CLI/agent client for the Tetra API (no backend)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,4 +3,4 @@ import tetra_cli
3
3
 
4
4
 
5
5
  def test_version():
6
- assert tetra_cli.__version__ == "0.1.0"
6
+ assert tetra_cli.__version__ == "0.5.0"
@@ -3,4 +3,4 @@
3
3
  Speaks only HTTP to a remote Tetra API with a bearer token. Contains NO
4
4
  backend server code (no models, routes, services, ORM). Safe to distribute.
5
5
  """
6
- __version__ = "0.1.0"
6
+ __version__ = "0.5.0"
@@ -61,24 +61,31 @@ async def get_group_capacity(
61
61
 
62
62
  @operation(
63
63
  cli="capacity purchase-slot",
64
- summary="Buy the next credit-purchased active capacity slot.",
64
+ summary="Buy the next credit-purchased active capacity slots.",
65
65
  covers=[("POST", "/api/v1/capacity/purchase-slot")],
66
+ params={"quantity": arg(help="How many slots to buy (default 1)")},
66
67
  )
67
- async def purchase_capacity_slot(client: TetraClient) -> dict[str, Any]:
68
- """Buy the next credit-purchased active slot (escalating credit cost).
68
+ async def purchase_capacity_slot(
69
+ client: TetraClient, quantity: int = 1,
70
+ ) -> dict[str, Any]:
71
+ """Buy the next ``quantity`` credit-purchased active slots.
69
72
 
70
- The endpoint takes no body — the next slot's cost and the caller's
71
- eligibility are derived server-side from the account state.
73
+ Per-slot cost escalates and the purchase is all-or-nothing: the summed
74
+ cost is debited in one transaction or the call fails without buying any.
72
75
 
73
76
  Args:
74
77
  client: Authenticated TetraClient
78
+ quantity: How many slots to buy (default 1)
75
79
 
76
80
  Returns:
77
- Updated capacity dict reflecting the new ``purchased_slots`` and
78
- ``next_slot_cost``.
81
+ Updated capacity dict reflecting the new ``purchased_slots``,
82
+ ``next_slot_cost``, and ``slot_cost_paid`` (total credits debited).
79
83
 
80
84
  Raises:
81
- TetraClientError: 400 ``insufficient_credits`` if the balance is too
82
- low; 400 ``unlimited_capacity`` for Pro/comped accounts.
85
+ TetraClientError: 400 ``insufficient_credits`` if the balance can't
86
+ cover the summed cost; 400 ``free_cap_reached`` when a free
87
+ account would end past the slot hard cap.
83
88
  """
84
- return await client.post("/api/v1/capacity/purchase-slot")
89
+ return await client.post(
90
+ "/api/v1/capacity/purchase-slot", json={"quantity": quantity}
91
+ )
@@ -0,0 +1,228 @@
1
+ """Pure async operations for the event-sessions API.
2
+
3
+ Event Sessions group two or more existing events on the same subject timeline
4
+ into a named "occurrence" (e.g. three lifts logged in one gym visit). Grouping
5
+ is a first-class capability, so it must be drivable from the CLI and the
6
+ generated MCP tools — not just the web app. Each op here is a thin wrapper over
7
+ one ``/api/v1/event-sessions`` endpoint; the backend owns quorum, shared-scope,
8
+ and authorization rules.
9
+ """
10
+ from typing import Any
11
+
12
+ from tetra_cli.api_client import TetraClient
13
+ from tetra_cli.registry import arg, operation, opt
14
+
15
+
16
+ @operation(
17
+ cli="events session group",
18
+ summary="Group >= 2 existing events into a new session.",
19
+ covers=[("POST", "/api/v1/event-sessions")],
20
+ params={
21
+ "event_uids": opt("--uid", repeatable=True,
22
+ help="Event UID to group (repeatable, >= 2 required)"),
23
+ "name": opt("--name", help="Optional session label"),
24
+ },
25
+ )
26
+ async def group_events(
27
+ client: TetraClient,
28
+ *,
29
+ event_uids: list[str],
30
+ name: str | None = None,
31
+ ) -> dict[str, Any]:
32
+ """Group existing events into a new session.
33
+
34
+ All events must resolve and share one ``group_uid`` (the same subject
35
+ timeline); the backend rejects cross-group grouping with HTTP 400.
36
+
37
+ Args:
38
+ client: Authenticated TetraClient
39
+ event_uids: Event UIDs to group (at least two, same subject timeline)
40
+ name: Optional display label for the session
41
+
42
+ Returns:
43
+ The created session detail (uid, members, summary fields).
44
+ """
45
+ body: dict[str, Any] = {"event_uids": event_uids}
46
+ if name is not None:
47
+ body["name"] = name
48
+ return await client.post("/api/v1/event-sessions", json=body)
49
+
50
+
51
+ @operation(
52
+ cli="events session show",
53
+ summary="Get a session with its member events.",
54
+ covers=[("GET", "/api/v1/event-sessions/{uid}")],
55
+ params={
56
+ "uid": arg(help="Session UID"),
57
+ },
58
+ )
59
+ async def show_event_session(client: TetraClient, uid: str) -> dict[str, Any]:
60
+ """Get one session and its member events.
61
+
62
+ Args:
63
+ client: Authenticated TetraClient
64
+ uid: Session UID
65
+
66
+ Returns:
67
+ The session detail dict (summary fields plus member events).
68
+ """
69
+ return await client.get(f"/api/v1/event-sessions/{uid}")
70
+
71
+
72
+ @operation(
73
+ cli="events session list",
74
+ summary="List sessions on the caller's own timeline.",
75
+ covers=[("GET", "/api/v1/event-sessions")],
76
+ )
77
+ async def list_event_sessions(client: TetraClient) -> list[dict[str, Any]]:
78
+ """List the caller's own personal-timeline sessions.
79
+
80
+ Intentionally self-scoped to the caller's ``personal_group_uid`` — group
81
+ sessions are reached via their events, not this listing.
82
+
83
+ Args:
84
+ client: Authenticated TetraClient
85
+
86
+ Returns:
87
+ A list of session summary dicts.
88
+ """
89
+ return await client.get("/api/v1/event-sessions")
90
+
91
+
92
+ @operation(
93
+ cli="events session rename",
94
+ summary="Set a session's display label.",
95
+ covers=[("PATCH", "/api/v1/event-sessions/{uid}")],
96
+ params={
97
+ "uid": arg(help="Session UID"),
98
+ "name": arg(help="New display label"),
99
+ },
100
+ )
101
+ async def rename_event_session(
102
+ client: TetraClient, uid: str, name: str,
103
+ ) -> dict[str, Any]:
104
+ """Relabel a session.
105
+
106
+ Args:
107
+ client: Authenticated TetraClient
108
+ uid: Session UID
109
+ name: New display label
110
+
111
+ Returns:
112
+ The updated session summary dict.
113
+ """
114
+ return await client.patch(
115
+ f"/api/v1/event-sessions/{uid}", json={"name": name}
116
+ )
117
+
118
+
119
+ @operation(
120
+ cli="events session add-member",
121
+ summary="Attach one more event to an existing session.",
122
+ covers=[("POST", "/api/v1/event-sessions/{uid}/members")],
123
+ params={
124
+ "uid": arg(help="Session UID"),
125
+ "event_uid": arg(help="Event UID to attach"),
126
+ },
127
+ )
128
+ async def add_event_session_member(
129
+ client: TetraClient, uid: str, event_uid: str,
130
+ ) -> dict[str, Any]:
131
+ """Attach one more event to a session.
132
+
133
+ The event must share the session's ``group_uid``; if it already belongs to
134
+ another session it is re-homed here.
135
+
136
+ Args:
137
+ client: Authenticated TetraClient
138
+ uid: Session UID to grow
139
+ event_uid: Event UID to attach
140
+
141
+ Returns:
142
+ The updated session summary dict.
143
+ """
144
+ return await client.post(
145
+ f"/api/v1/event-sessions/{uid}/members", json={"event_uid": event_uid}
146
+ )
147
+
148
+
149
+ @operation(
150
+ cli="events session remove-member",
151
+ summary="Detach one event; the session dissolves below quorum.",
152
+ covers=[("DELETE", "/api/v1/event-sessions/{uid}/members/{event_uid}")],
153
+ params={
154
+ "uid": arg(help="Session UID"),
155
+ "event_uid": arg(help="Event UID to detach"),
156
+ },
157
+ )
158
+ async def remove_event_session_member(
159
+ client: TetraClient, uid: str, event_uid: str,
160
+ ) -> dict[str, Any] | None:
161
+ """Detach one event from a session.
162
+
163
+ If removing the event drops the session below quorum, the session is
164
+ dissolved entirely and the response is ``null``.
165
+
166
+ Args:
167
+ client: Authenticated TetraClient
168
+ uid: Session UID
169
+ event_uid: Event UID to detach
170
+
171
+ Returns:
172
+ The resulting session summary, or ``None`` if the session dissolved.
173
+ """
174
+ return await client.delete(
175
+ f"/api/v1/event-sessions/{uid}/members/{event_uid}"
176
+ )
177
+
178
+
179
+ @operation(
180
+ cli="events session ungroup",
181
+ summary="Dissolve a session entirely, unlinking all its members.",
182
+ covers=[("DELETE", "/api/v1/event-sessions/{uid}")],
183
+ params={
184
+ "uid": arg(help="Session UID"),
185
+ },
186
+ )
187
+ async def ungroup_event_session(
188
+ client: TetraClient, uid: str,
189
+ ) -> dict[str, Any] | None:
190
+ """Dissolve a session, unlinking every member event.
191
+
192
+ Args:
193
+ client: Authenticated TetraClient
194
+ uid: Session UID
195
+
196
+ Returns:
197
+ Empty/``None`` — the backend responds 204 No Content.
198
+ """
199
+ return await client.delete(f"/api/v1/event-sessions/{uid}")
200
+
201
+
202
+ @operation(
203
+ cli="events session suggest",
204
+ summary="Advisory: which session/event should an event join? (never stamps)",
205
+ covers=[("POST", "/api/v1/event-sessions/suggest")],
206
+ params={
207
+ "event_uid": arg(help="Candidate event UID"),
208
+ },
209
+ )
210
+ async def suggest_event_session(
211
+ client: TetraClient, event_uid: str,
212
+ ) -> dict[str, Any] | None:
213
+ """Ask which session/event the given event should join.
214
+
215
+ Purely advisory — it never mutates. Returns ``null`` when nothing on the
216
+ same subject timeline matches within the session window.
217
+
218
+ Args:
219
+ client: Authenticated TetraClient
220
+ event_uid: Candidate event UID
221
+
222
+ Returns:
223
+ A suggestion dict (join_session_uid / join_event_uid / derived_label),
224
+ or ``None`` when nothing matches.
225
+ """
226
+ return await client.post(
227
+ "/api/v1/event-sessions/suggest", json={"event_uid": event_uid}
228
+ )
@@ -0,0 +1,31 @@
1
+ """Pure async operations for the foods API."""
2
+ from typing import Any
3
+
4
+ from tetra_cli.api_client import TetraClient
5
+ from tetra_cli.registry import arg, operation
6
+
7
+
8
+ @operation(
9
+ cli="foods search",
10
+ summary="Search foods by free-text description (USDA-backed, cached).",
11
+ covers=[("GET", "/api/v1/foods/search")],
12
+ params={
13
+ "q": arg(help="Free-text food search term (e.g. \"egg\")"),
14
+ },
15
+ )
16
+ async def food_search(client: TetraClient, q: str) -> Any:
17
+ """Search cached/USDA foods by free-text description.
18
+
19
+ Delegates to the server's ``FoodLookupService``: a local-cache hit
20
+ answers immediately, a miss fills in from USDA FoodData Central. Returns
21
+ whatever shape the endpoint returns, unmodified.
22
+
23
+ Args:
24
+ client: Authenticated TetraClient
25
+ q: Non-empty free-text search term (e.g. "egg")
26
+
27
+ Returns:
28
+ List of food dicts (fdc_food_id, description, brand, kcal_per_100g,
29
+ protein_per_100g, carb_per_100g, fat_per_100g, serving_size_g)
30
+ """
31
+ return await client.get("/api/v1/foods/search", params={"q": q})
@@ -188,6 +188,10 @@ async def create_goal(
188
188
  help=("Goal status as a label (created | in_progress | archived | "
189
189
  "abandoned | completed) or its numeric form (1-5)."),
190
190
  ),
191
+ "group_uid": opt(
192
+ "--group-uid",
193
+ help="Group UID for a group-scoped update (e.g. editing a goal you "
194
+ "created on a mentee's behalf). Omit for your personal scope."),
191
195
  },
192
196
  )
193
197
  async def update_goal(
@@ -206,6 +210,7 @@ async def update_goal(
206
210
  outcome_shape: str | None = None,
207
211
  outcome_names: list[str] | None = None,
208
212
  status: str | None = None,
213
+ group_uid: str | None = None,
209
214
  ) -> dict[str, Any]:
210
215
  """Update an existing goal. Only provided (non-None) fields are changed.
211
216
 
@@ -227,6 +232,9 @@ async def update_goal(
227
232
  2=IN_PROGRESS, 3=ARCHIVED, 4=ABANDONED, 5=COMPLETED) or a label
228
233
  string ("created" … "completed", case-insensitive). The op
229
234
  normalizes labels→int so the CLI/MCP surfaces share the behavior.
235
+ group_uid: Group UID for a group-scoped update. Required to edit a goal
236
+ that lives in a group (e.g. one created on a mentee's behalf);
237
+ omit for personal-scope goals.
230
238
 
231
239
  Returns:
232
240
  Updated goal dict
@@ -260,26 +268,43 @@ async def update_goal(
260
268
  body["outcome_names"] = outcome_names
261
269
  if status is not None:
262
270
  body["status"] = _normalize_status(status)
263
- return await client.put(f"/api/v1/goals/{uid}", json=body)
271
+ path = f"/api/v1/goals/{uid}"
272
+ if group_uid is not None:
273
+ path += f"?group_uid={group_uid}"
274
+ return await client.put(path, json=body)
264
275
 
265
276
 
266
277
  @operation(
267
278
  cli="goals delete",
268
279
  summary="Delete a goal permanently.",
269
280
  covers=[("DELETE", "/api/v1/goals/{uid}")],
270
- params={"uid": arg(help="Goal UID to delete")},
281
+ params={
282
+ "uid": arg(help="Goal UID to delete"),
283
+ "group_uid": opt(
284
+ "--group-uid",
285
+ help="Group UID for a group-scoped delete (e.g. a goal you created "
286
+ "on a mentee's behalf). Omit for your personal scope."),
287
+ },
271
288
  )
272
- async def delete_goal(client: TetraClient, uid: str) -> dict[str, Any]:
289
+ async def delete_goal(
290
+ client: TetraClient, uid: str, *, group_uid: str | None = None,
291
+ ) -> dict[str, Any]:
273
292
  """Delete a goal permanently.
274
293
 
275
294
  Args:
276
295
  client: Authenticated TetraClient
277
296
  uid: Goal UID to delete
297
+ group_uid: Group UID for a group-scoped delete. Required to delete a
298
+ goal that lives in a group (e.g. one created on a mentee's behalf);
299
+ omit for personal-scope goals.
278
300
 
279
301
  Returns:
280
302
  Deletion confirmation dict
281
303
  """
282
- return await client.delete(f"/api/v1/goals/{uid}")
304
+ path = f"/api/v1/goals/{uid}"
305
+ if group_uid is not None:
306
+ path += f"?group_uid={group_uid}"
307
+ return await client.delete(path)
283
308
 
284
309
 
285
310
  @operation(
@@ -476,6 +501,10 @@ async def delete_goal_metric(
476
501
  help="New parent goal name (omit to keep current)."),
477
502
  "new_priority": opt("--priority", min=1,
478
503
  help="New priority among siblings (1 = top)."),
504
+ "group_uid": opt(
505
+ "--group-uid",
506
+ help="Group UID for a group-scoped move (e.g. a mentee's goal). "
507
+ "Omit for your personal scope."),
479
508
  },
480
509
  )
481
510
  async def move_goal(
@@ -484,6 +513,7 @@ async def move_goal(
484
513
  uid: str,
485
514
  new_parent: str | None = None,
486
515
  new_priority: int | None = None,
516
+ group_uid: str | None = None,
487
517
  ) -> dict[str, Any]:
488
518
  """Move a goal to a new parent and/or position in the sibling order.
489
519
 
@@ -508,8 +538,12 @@ async def move_goal(
508
538
  body: dict[str, Any] = {"uid": uid}
509
539
  if new_parent is None:
510
540
  # Caller didn't specify — pre-fetch and use current parent so the
511
- # API doesn't interpret missing as "move to root."
512
- current = await client.get(f"/api/v1/goals/{uid}")
541
+ # API doesn't interpret missing as "move to root." Scope the read to
542
+ # the same group so a mentee's goal resolves.
543
+ read_path = f"/api/v1/goals/{uid}"
544
+ if group_uid is not None:
545
+ read_path += f"?group_uid={group_uid}"
546
+ current = await client.get(read_path)
513
547
  if current.get("parent"):
514
548
  body["new_parent"] = current["parent"]
515
549
  # else: goal is already a root, omit new_parent (API treats omitted
@@ -521,6 +555,8 @@ async def move_goal(
521
555
  body["new_parent"] = new_parent
522
556
  if new_priority is not None:
523
557
  body["new_priority"] = new_priority
558
+ if group_uid is not None:
559
+ body["group_uid"] = group_uid
524
560
  return await client.post("/api/v1/goals/move", json=body)
525
561
 
526
562
 
@@ -574,6 +610,10 @@ async def batch_delete_goals(
574
610
  "updates": opt("--updates", json=True,
575
611
  help="JSON map of {goal_uid: new_priority} (1-based, sequential)."),
576
612
  "parent": opt("--parent", help="Parent goal name (omit for root siblings)."),
613
+ "group_uid": opt(
614
+ "--group-uid",
615
+ help="Group UID for a group-scoped reorder (e.g. a mentee's goals). "
616
+ "Omit for your personal scope."),
577
617
  },
578
618
  )
579
619
  async def batch_update_goal_priorities(
@@ -581,6 +621,7 @@ async def batch_update_goal_priorities(
581
621
  *,
582
622
  updates: dict[str, int],
583
623
  parent: str | None = None,
624
+ group_uid: str | None = None,
584
625
  ) -> dict[str, Any]:
585
626
  """Batch-update priorities for goals within one sibling group.
586
627
 
@@ -591,6 +632,8 @@ async def batch_update_goal_priorities(
591
632
  client: Authenticated TetraClient
592
633
  updates: Map of ``{goal_uid: new_priority}`` (1 = top).
593
634
  parent: Parent goal name (null/omit for root-level siblings).
635
+ group_uid: Group UID for a group-scoped reorder (e.g. a mentee's
636
+ goals); omit for personal scope.
594
637
 
595
638
  Returns:
596
639
  Dict with ``updated`` count and the affected ``uids``.
@@ -598,7 +641,10 @@ async def batch_update_goal_priorities(
598
641
  body: dict[str, Any] = {"updates": updates}
599
642
  if parent is not None:
600
643
  body["parent"] = parent
601
- return await client.post("/api/v1/goals/batch-update-priorities", json=body)
644
+ path = "/api/v1/goals/batch-update-priorities"
645
+ if group_uid is not None:
646
+ path += f"?group_uid={group_uid}"
647
+ return await client.post(path, json=body)
602
648
 
603
649
 
604
650
  @operation(
@@ -610,6 +656,14 @@ async def batch_update_goal_priorities(
610
656
  help="Source goal UID to merge (repeatable; 2+ required)."),
611
657
  "new_goal": opt("--new-goal", json=True,
612
658
  help="New goal data as a JSON dict (at least {\"name\": ...})."),
659
+ "group_uid": opt(
660
+ "--group-uid",
661
+ help="Group UID for a group-scoped merge (e.g. a mentee's goals). "
662
+ "Omit for your personal scope."),
663
+ "for_account_uid": opt(
664
+ "--for-account-uid",
665
+ help="Merge on this member's behalf — the merged goal belongs to "
666
+ "them (mentee account UID); requires --group-uid."),
613
667
  },
614
668
  )
615
669
  async def merge_goals(
@@ -617,6 +671,8 @@ async def merge_goals(
617
671
  *,
618
672
  source_uids: list[str],
619
673
  new_goal: dict[str, Any],
674
+ group_uid: str | None = None,
675
+ for_account_uid: str | None = None,
620
676
  ) -> dict[str, Any]:
621
677
  """Merge two or more goals into a new goal.
622
678
 
@@ -628,11 +684,19 @@ async def merge_goals(
628
684
  source_uids: UIDs of the goals to merge (at least 2).
629
685
  new_goal: New goal payload (must include ``name``; may include
630
686
  ``parent``, ``values``, etc.).
687
+ group_uid: Group UID for a group-scoped merge (e.g. a mentee's
688
+ goals); omit for personal scope.
689
+ for_account_uid: Merge on this member's behalf so the merged goal
690
+ belongs to them (mentee account UID); requires ``group_uid``.
631
691
 
632
692
  Returns:
633
693
  The created goal dict.
634
694
  """
635
695
  body: dict[str, Any] = {"source_uids": source_uids, "new_goal": new_goal}
696
+ if group_uid is not None:
697
+ body["group_uid"] = group_uid
698
+ if for_account_uid is not None:
699
+ body["for_account_uid"] = for_account_uid
636
700
  return await client.post("/api/v1/goals/merge", json=body)
637
701
 
638
702
 
@@ -1085,6 +1149,10 @@ async def get_goal_status_stages(
1085
1149
  "uid": arg(help="Goal UID"),
1086
1150
  "old_name": opt("--old-name", help="Current field name to rename."),
1087
1151
  "new_name": opt("--new-name", help="New field name."),
1152
+ "group_uid": opt(
1153
+ "--group-uid",
1154
+ help="Group UID for a group-scoped rename (e.g. a mentee's goal). "
1155
+ "Omit for your personal scope."),
1088
1156
  },
1089
1157
  )
1090
1158
  async def rename_goal_field(
@@ -1093,6 +1161,7 @@ async def rename_goal_field(
1093
1161
  *,
1094
1162
  old_name: str,
1095
1163
  new_name: str,
1164
+ group_uid: str | None = None,
1096
1165
  ) -> dict[str, Any]:
1097
1166
  """Rename a tuple/record field on a goal and across all its events.
1098
1167
 
@@ -1104,13 +1173,18 @@ async def rename_goal_field(
1104
1173
  uid: Goal UID.
1105
1174
  old_name: Existing field name.
1106
1175
  new_name: Replacement field name.
1176
+ group_uid: Group UID for a group-scoped rename (e.g. a mentee's
1177
+ goal); omit for personal scope.
1107
1178
 
1108
1179
  Returns:
1109
1180
  Dict with ``success``, ``events_updated``, and the new
1110
1181
  ``outcome_names``.
1111
1182
  """
1112
1183
  body: dict[str, Any] = {"old_name": old_name, "new_name": new_name}
1113
- return await client.post(f"/api/v1/goals/{uid}/rename-field", json=body)
1184
+ path = f"/api/v1/goals/{uid}/rename-field"
1185
+ if group_uid is not None:
1186
+ path += f"?group_uid={group_uid}"
1187
+ return await client.post(path, json=body)
1114
1188
 
1115
1189
 
1116
1190
  @operation(
@@ -1142,3 +1216,97 @@ async def reorder_all_goals(
1142
1216
  return await client.post(
1143
1217
  "/api/v1/goals/reorder-all", json={"order": order}
1144
1218
  )
1219
+
1220
+
1221
+ @operation(
1222
+ cli="goals link-value",
1223
+ summary="Mirror-link a goal to a value: renaming one renames the other.",
1224
+ covers=[("POST", "/api/v1/goals/{uid}/link-value")],
1225
+ params={
1226
+ "uid": arg(help="Goal UID to link"),
1227
+ "value_uid": arg(help="Value UID to link it to (same scope)"),
1228
+ "name_source": opt(
1229
+ "--name-source",
1230
+ choices=["goal", "value"],
1231
+ help="When the leaf names differ, which name wins — the loser "
1232
+ "is renamed to match (with descendant cascade). "
1233
+ "Default: goal.",
1234
+ ),
1235
+ "group_uid": opt(
1236
+ "--group-uid",
1237
+ help="Group UID for a group-scoped link (goal + value both in "
1238
+ "that group, e.g. a mentee's). Omit for your personal scope."),
1239
+ },
1240
+ )
1241
+ async def link_goal_value(
1242
+ client: TetraClient,
1243
+ *,
1244
+ uid: str,
1245
+ value_uid: str,
1246
+ name_source: str | None = None,
1247
+ group_uid: str | None = None,
1248
+ ) -> dict[str, Any]:
1249
+ """Create the explicit 1:1 mirror link between a goal and a value.
1250
+
1251
+ Same scope required; a value can be the mirror of at most one goal
1252
+ (409 when it is already linked elsewhere). While linked, leaf renames
1253
+ propagate across the link in both directions. Independent of the
1254
+ draws-from/adds-to assignment in the goal's ``values``.
1255
+
1256
+ Args:
1257
+ client: Authenticated TetraClient
1258
+ uid: Goal UID.
1259
+ value_uid: Value UID in the same scope.
1260
+ name_source: 'goal' or 'value' — which name wins when the leaves
1261
+ differ (the loser is renamed with cascade). Defaults to 'goal'.
1262
+ group_uid: Group UID for a group-scoped link (both goal and value
1263
+ live in that group, e.g. a mentee's); omit for personal scope.
1264
+
1265
+ Returns:
1266
+ The updated goal dict, including ``linked_value`` {uid, name}.
1267
+ """
1268
+ body: dict[str, Any] = {"value_uid": value_uid}
1269
+ if name_source is not None:
1270
+ body["name_source"] = name_source
1271
+ path = f"/api/v1/goals/{uid}/link-value"
1272
+ if group_uid is not None:
1273
+ path += f"?group_uid={group_uid}"
1274
+ return await client.post(path, json=body)
1275
+
1276
+
1277
+ @operation(
1278
+ cli="goals unlink-value",
1279
+ summary="Remove a goal's mirror link; both sides rename independently.",
1280
+ covers=[("DELETE", "/api/v1/goals/{uid}/link-value")],
1281
+ params={
1282
+ "uid": arg(help="Goal UID to unlink"),
1283
+ "group_uid": opt(
1284
+ "--group-uid",
1285
+ help="Group UID for a group-scoped unlink (e.g. a mentee's goal). "
1286
+ "Omit for your personal scope."),
1287
+ },
1288
+ )
1289
+ async def unlink_goal_value(
1290
+ client: TetraClient,
1291
+ *,
1292
+ uid: str,
1293
+ group_uid: str | None = None,
1294
+ ) -> dict[str, Any]:
1295
+ """Remove the goal's explicit value link.
1296
+
1297
+ The draws-from/adds-to assignment in ``values`` is kept — unlinking
1298
+ only stops rename mirroring.
1299
+
1300
+ Args:
1301
+ client: Authenticated TetraClient
1302
+ uid: Goal UID.
1303
+ group_uid: Group UID for a group-scoped unlink (e.g. a mentee's
1304
+ goal); omit for personal scope.
1305
+
1306
+ Returns:
1307
+ The updated goal dict with ``linked_value: null``.
1308
+ """
1309
+ path = f"/api/v1/goals/{uid}/link-value"
1310
+ if group_uid is not None:
1311
+ path += f"?group_uid={group_uid}"
1312
+ return await client.delete(path)