tetra-cli 0.2.0__tar.gz → 0.4.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 (72) hide show
  1. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/PKG-INFO +1 -1
  2. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/pyproject.toml +1 -1
  3. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/__init__.py +1 -1
  4. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/capacity.py +17 -10
  5. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/goals.py +176 -8
  6. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/groups.py +19 -0
  7. tetra_cli-0.4.0/tetra_cli/api_client/operations/skill_tree_rankings.py +152 -0
  8. tetra_cli-0.4.0/tetra_cli/api_client/operations/skill_tree_requests.py +161 -0
  9. tetra_cli-0.4.0/tetra_cli/api_client/operations/skill_trees.py +401 -0
  10. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/values.py +1 -1
  11. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/values_2.py +23 -6
  12. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli.egg-info/PKG-INFO +1 -1
  13. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli.egg-info/SOURCES.txt +2 -0
  14. tetra_cli-0.2.0/tetra_cli/api_client/operations/skill_trees.py +0 -160
  15. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/README.md +0 -0
  16. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/setup.cfg +0 -0
  17. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tests/test_no_backend_leak.py +0 -0
  18. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tests/test_smoke.py +0 -0
  19. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/__init__.py +0 -0
  20. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/client.py +0 -0
  21. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/config.py +0 -0
  22. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/__init__.py +0 -0
  23. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/accounts.py +0 -0
  24. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/ai.py +0 -0
  25. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/analysis.py +0 -0
  26. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/api_keys.py +0 -0
  27. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/archive.py +0 -0
  28. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/awards.py +0 -0
  29. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/conversations.py +0 -0
  30. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/conversations_2.py +0 -0
  31. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/cosmetics.py +0 -0
  32. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/dashboard.py +0 -0
  33. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/data.py +0 -0
  34. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/events.py +0 -0
  35. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/gamification.py +0 -0
  36. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/issues.py +0 -0
  37. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/offset.py +0 -0
  38. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/onboarding.py +0 -0
  39. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/outcome_schemas.py +0 -0
  40. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/peer_connections.py +0 -0
  41. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/plaid.py +0 -0
  42. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/reminders.py +0 -0
  43. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/scratches.py +0 -0
  44. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/social_2.py +0 -0
  45. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/social_3.py +0 -0
  46. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/social_4.py +0 -0
  47. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/strava.py +0 -0
  48. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/stripe.py +0 -0
  49. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/tags.py +0 -0
  50. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/watch.py +0 -0
  51. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/webhooks.py +0 -0
  52. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/api_client/operations/xp.py +0 -0
  53. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/__init__.py +0 -0
  54. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/__main__.py +0 -0
  55. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/app.py +0 -0
  56. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/commands/__init__.py +0 -0
  57. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/commands/auth.py +0 -0
  58. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/commands/guide.py +0 -0
  59. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/commands/messages.py +0 -0
  60. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/commands/skill.py +0 -0
  61. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/context.py +0 -0
  62. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/generate.py +0 -0
  63. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/cli/output.py +0 -0
  64. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/mcp_gen.py +0 -0
  65. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/ontology.py +0 -0
  66. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/registry.py +0 -0
  67. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/skill/SKILL.md +0 -0
  68. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli/skill/__init__.py +0 -0
  69. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli.egg-info/dependency_links.txt +0 -0
  70. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli.egg-info/entry_points.txt +0 -0
  71. {tetra_cli-0.2.0 → tetra_cli-0.4.0}/tetra_cli.egg-info/requires.txt +0 -0
  72. {tetra_cli-0.2.0 → tetra_cli-0.4.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.4.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.4.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 @@
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.4.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
+ )
@@ -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)
@@ -484,6 +484,25 @@ async def mark_viewed(client: TetraClient) -> dict[str, Any]:
484
484
  return await client.post("/api/v1/social/mark-viewed")
485
485
 
486
486
 
487
+ @operation(
488
+ cli="social notifications-mark-viewed",
489
+ summary="Mark all social notifications viewed (clears the nav badge).",
490
+ covers=[("POST", "/api/v1/social/notifications/mark-viewed")],
491
+ )
492
+ async def mark_notifications_viewed(client: TetraClient) -> dict[str, Any]:
493
+ """Mark every unviewed social notification as viewed.
494
+
495
+ Takes no body; keys off the authenticated caller.
496
+
497
+ Args:
498
+ client: Authenticated TetraClient
499
+
500
+ Returns:
501
+ Dict with the count of notifications marked viewed.
502
+ """
503
+ return await client.post("/api/v1/social/notifications/mark-viewed")
504
+
505
+
487
506
  # =============================================================================
488
507
  # SEARCH / INVITE LINKS / SHARING
489
508
  # =============================================================================
@@ -0,0 +1,152 @@
1
+ """Pure async operations for skill-tree rank measures.
2
+
3
+ A rank measure controls how a published skill tree's cohort standings are
4
+ scored, per goal type. Every MeasureSpec (the shape below) is a JSON object:
5
+
6
+ {
7
+ "form": "scalar" | "target_shaped",
8
+ "agg": "sum" | "max" | "count" | "total_duration" | null,
9
+ "column": str | null,
10
+ "equation": str | null,
11
+ "dimensions": [{"column": str, "direction": "asc" | "desc",
12
+ "agg": "max" | "sum"}, ...],
13
+ "label": str | null
14
+ }
15
+
16
+ ``form="scalar"`` needs ``agg`` (plus ``column`` or ``equation`` for outcome
17
+ types; ``total_duration``/``count`` need neither). ``form="target_shaped"``
18
+ needs a non-empty ordered ``dimensions`` list — dimension 0 is the primary
19
+ sort key, later entries break ties, each compared per its own ``direction``.
20
+
21
+ There are two config layers, both keyed by ``type_key`` (a goal's outcome
22
+ schema key):
23
+ - The tree's canonical ``measures`` — set only by the tree's author via
24
+ :func:`set_tree_rank_measures`; drives the persisted standings
25
+ (``SharedGoalRanking``) everyone sees.
26
+ - Each caller's own personal ``prefs`` — set via
27
+ :func:`set_tree_rank_measure_pref`; a client-side-only re-rank
28
+ preference that never touches the canonical measures or persisted
29
+ standings.
30
+
31
+ :func:`get_tree_rank_measures` returns the canonical measures plus, for each
32
+ goal type present in the tree, its ``outcome_columns`` — the valid values for
33
+ a scalar spec's ``column`` or a dimension's ``column``.
34
+ """
35
+ from typing import Any
36
+
37
+ from tetra_cli.api_client import TetraClient
38
+ from tetra_cli.registry import arg, operation, opt
39
+
40
+ _MEASURE_SPEC_SHAPE = (
41
+ "A MeasureSpec is a JSON object: {form: 'scalar'|'target_shaped', "
42
+ "agg: 'sum'|'max'|'count'|'total_duration'|null, column: str|null, "
43
+ "equation: str|null, dimensions: [{column, direction:'asc'|'desc', "
44
+ "agg:'max'|'sum'}, ...], label: str|null}. form='scalar' needs agg "
45
+ "(plus column or equation for outcome types; total_duration/count need "
46
+ "neither); form='target_shaped' needs a non-empty ordered dimensions "
47
+ "list (dimension 0 is primary, later entries break ties). column/"
48
+ "dimension columns must be one of the goal type's outcome_columns from "
49
+ "get_tree_rank_measures."
50
+ )
51
+
52
+
53
+ @operation(
54
+ cli="values tree rank-measures",
55
+ summary=(
56
+ "Get a published skill tree's configured rank measures plus its "
57
+ "available goal types. Returns {measures: {type_key: MeasureSpec}, "
58
+ "types: [{type_key, label, outcome_columns}]}. " + _MEASURE_SPEC_SHAPE
59
+ ),
60
+ covers=[("GET", "/api/v1/skill-trees/{tree_uid}/rank-measures")],
61
+ params={"tree_uid": arg(help="Root value UID of the published skill tree.")},
62
+ )
63
+ async def get_tree_rank_measures(client: TetraClient, tree_uid: str) -> dict[str, Any]:
64
+ """Get a skill tree's configured rank measures and available goal types.
65
+
66
+ Any caller who can view at least one of the tree's publish scopes may
67
+ call this — reuses the change-request view check, since rank-measure
68
+ config is standings metadata, not sensitive.
69
+
70
+ Args:
71
+ client: Authenticated TetraClient.
72
+ tree_uid: Root value UID of the published skill tree.
73
+
74
+ Returns:
75
+ Dict with ``measures`` (``type_key`` -> MeasureSpec dict) and
76
+ ``types`` (list of ``{type_key, label, outcome_columns}``).
77
+ """
78
+ return await client.get(f"/api/v1/skill-trees/{tree_uid}/rank-measures")
79
+
80
+
81
+ @operation(
82
+ cli="values tree set-rank-measures",
83
+ summary=(
84
+ "Author-only: set a published skill tree's canonical rank measure "
85
+ "for one or more goal types, synchronously recomputing every "
86
+ "existing standings row for the tree's cohort goals. measures is a "
87
+ "JSON object mapping type_key -> MeasureSpec: {full replacement per "
88
+ "type_key given; types omitted from measures are left untouched}. "
89
+ + _MEASURE_SPEC_SHAPE + " Call get_tree_rank_measures first to see "
90
+ "each type's outcome_columns."
91
+ ),
92
+ covers=[("PUT", "/api/v1/skill-trees/{tree_uid}/rank-measures")],
93
+ params={
94
+ "tree_uid": arg(help="Root value UID of the published skill tree (author only)."),
95
+ "measures": opt(
96
+ "--measures", json=True,
97
+ help="JSON object: {type_key: MeasureSpec, ...}.",
98
+ ),
99
+ },
100
+ )
101
+ async def set_tree_rank_measures(
102
+ client: TetraClient, tree_uid: str, measures: dict[str, Any]
103
+ ) -> dict[str, Any]:
104
+ """Author-only: set a tree's canonical rank measure per goal type.
105
+
106
+ Args:
107
+ client: Authenticated TetraClient.
108
+ tree_uid: Root value UID of the published skill tree.
109
+ measures: Dict mapping ``type_key`` -> MeasureSpec dict.
110
+
111
+ Returns:
112
+ Dict with the saved ``measures`` map.
113
+ """
114
+ return await client.put(f"/api/v1/skill-trees/{tree_uid}/rank-measures", json=measures)
115
+
116
+
117
+ @operation(
118
+ cli="values tree set-rank-measure-pref",
119
+ summary=(
120
+ "Set the caller's own personal rank-measure override for one goal "
121
+ "type on a published skill tree — a client-side re-rank preference "
122
+ "that never touches the tree's canonical measures or persisted "
123
+ "standings. spec is a single MeasureSpec JSON object (see below) "
124
+ "applied to type_key. " + _MEASURE_SPEC_SHAPE + " Returns the "
125
+ "caller's full updated prefs map {type_key: MeasureSpec}."
126
+ ),
127
+ covers=[("PUT", "/api/v1/skill-trees/{tree_uid}/rank-measures/prefs")],
128
+ params={
129
+ "tree_uid": arg(help="Root value UID of the published skill tree."),
130
+ "type_key": arg(help="Goal type key this preference applies to."),
131
+ "spec": opt("--spec", json=True, help="MeasureSpec as a JSON object."),
132
+ },
133
+ )
134
+ async def set_tree_rank_measure_pref(
135
+ client: TetraClient, tree_uid: str, type_key: str, spec: dict[str, Any]
136
+ ) -> dict[str, Any]:
137
+ """Upsert the caller's own rank-measure preference for one goal type.
138
+
139
+ Args:
140
+ client: Authenticated TetraClient.
141
+ tree_uid: Root value UID of the published skill tree.
142
+ type_key: Goal type key this preference applies to.
143
+ spec: MeasureSpec dict for this type.
144
+
145
+ Returns:
146
+ Dict with the caller's full updated prefs map (``type_key`` ->
147
+ MeasureSpec dict).
148
+ """
149
+ return await client.put(
150
+ f"/api/v1/skill-trees/{tree_uid}/rank-measures/prefs",
151
+ json={type_key: spec},
152
+ )