crowdtime-cli 0.11.0__tar.gz → 0.12.1__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 (41) hide show
  1. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/.gitignore +5 -0
  2. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/PKG-INFO +1 -1
  3. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/__init__.py +1 -1
  5. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/projects_cmd.py +19 -9
  6. crowdtime_cli-0.12.1/src/crowdtime_cli/commands/team_cmd.py +415 -0
  7. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/skills/crowdtime/SKILL.md +14 -6
  8. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/skills/crowdtime/references/commands.md +29 -9
  9. crowdtime_cli-0.11.0/src/crowdtime_cli/commands/team_cmd.py +0 -218
  10. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/LICENSE +0 -0
  11. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/README.md +0 -0
  12. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/auth.py +0 -0
  13. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/client.py +0 -0
  14. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/__init__.py +0 -0
  15. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  16. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  17. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  18. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  19. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  20. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  21. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  22. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  23. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  24. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  25. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  26. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  27. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  28. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  29. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  30. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  31. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  32. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  33. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/config.py +0 -0
  34. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/formatters.py +0 -0
  35. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/main.py +0 -0
  36. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/models.py +0 -0
  37. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/oauth.py +0 -0
  38. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/resolvers.py +0 -0
  39. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +0 -0
  40. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/utils.py +0 -0
  41. {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.1}/src/crowdtime_cli/version_check.py +0 -0
@@ -13,6 +13,10 @@ build/
13
13
  venv/
14
14
  ENV/
15
15
 
16
+ # Accidentally-created user/pip caches when pip runs with HOME=repo
17
+ .cache/
18
+ .local/
19
+
16
20
  # Environment
17
21
  .env
18
22
 
@@ -65,3 +69,4 @@ coolify_deployment.md
65
69
  pypi_deployment.md
66
70
  sentry-workflow.md
67
71
  linear-workflow.md
72
+ seeds-mentores.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.11.0
3
+ Version: 0.12.1
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.11.0"
3
+ version = "0.12.1"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.12.1"
@@ -13,6 +13,7 @@ from ..client import APIError, CrowdTimeClient
13
13
  from ..config import get_config
14
14
  from ..formatters import extract_results, format_currency, format_error, format_projects_table, format_success, print_json
15
15
  from ..models import Project
16
+ from ..resolvers import resolve_project
16
17
  from ..utils import format_duration
17
18
 
18
19
  app = typer.Typer(name="projects", help="Manage projects.")
@@ -82,7 +83,8 @@ def show(
82
83
  client = CrowdTimeClient(require_auth=True, require_org=True)
83
84
 
84
85
  try:
85
- data = client.get(f"/projects/{project_id}/")
86
+ project_uuid = resolve_project(client, project_id)
87
+ data = client.get(f"/projects/{project_uuid}/")
86
88
  project = Project(**data)
87
89
 
88
90
  if output_json:
@@ -207,7 +209,8 @@ def archive(
207
209
  client = CrowdTimeClient(require_auth=True, require_org=True)
208
210
 
209
211
  try:
210
- data = client.patch(f"/projects/{project_id}/", data={"status": "archived"})
212
+ project_uuid = resolve_project(client, project_id)
213
+ data = client.patch(f"/projects/{project_uuid}/", data={"status": "archived"})
211
214
  format_success(f"Project '{project_id}' archived.")
212
215
  except APIError as e:
213
216
  if e.status_code == 404:
@@ -304,7 +307,8 @@ def update(
304
307
  raise typer.Exit(1)
305
308
 
306
309
  try:
307
- data = api_client.patch(f"/projects/{project_id}/", data=payload)
310
+ project_uuid = resolve_project(api_client, project_id)
311
+ data = api_client.patch(f"/projects/{project_uuid}/", data=payload)
308
312
  project = Project(**data)
309
313
 
310
314
  if output_json:
@@ -348,7 +352,8 @@ def budget(
348
352
 
349
353
  try:
350
354
  # Fetch project details for budget type and amount
351
- project_data = client.get(f"/projects/{project_id}/")
355
+ project_uuid = resolve_project(client, project_id)
356
+ project_data = client.get(f"/projects/{project_uuid}/")
352
357
  project = Project(**project_data)
353
358
 
354
359
  # Trigger refresh if requested
@@ -455,7 +460,8 @@ def list_members(
455
460
  client = CrowdTimeClient(require_auth=True, require_org=True)
456
461
 
457
462
  try:
458
- data = client.get(f"/projects/{project_id}/members/")
463
+ project_uuid = resolve_project(client, project_id)
464
+ data = client.get(f"/projects/{project_uuid}/members/")
459
465
  members_list = data if isinstance(data, list) else extract_results(data)
460
466
 
461
467
  if output_json:
@@ -513,7 +519,8 @@ def list_tasks(
513
519
  client = CrowdTimeClient(require_auth=True, require_org=True)
514
520
 
515
521
  try:
516
- data = client.get(f"/projects/{project_id}/tasks/")
522
+ project_uuid = resolve_project(client, project_id)
523
+ data = client.get(f"/projects/{project_uuid}/tasks/")
517
524
  tasks_list = data if isinstance(data, list) else extract_results(data)
518
525
 
519
526
  if output_json:
@@ -584,7 +591,8 @@ def update_member(
584
591
  raise typer.Exit(1)
585
592
 
586
593
  try:
587
- data = client.patch(f"/projects/{project_id}/members/{user_id}/", data=payload)
594
+ project_uuid = resolve_project(client, project_id)
595
+ data = client.patch(f"/projects/{project_uuid}/members/{user_id}/", data=payload)
588
596
  if output_json:
589
597
  print_json(data)
590
598
  else:
@@ -633,7 +641,8 @@ def add_members_bulk(
633
641
  members.append(entry)
634
642
 
635
643
  try:
636
- data = client.post(f"/projects/{project_id}/members/bulk/", data={"members": members})
644
+ project_uuid = resolve_project(client, project_id)
645
+ data = client.post(f"/projects/{project_uuid}/members/bulk/", data={"members": members})
637
646
  result = extract_results(data) or data
638
647
 
639
648
  if output_json:
@@ -660,7 +669,8 @@ def add_tasks_bulk(
660
669
  client = CrowdTimeClient(require_auth=True, require_org=True)
661
670
 
662
671
  try:
663
- data = client.post(f"/projects/{project_id}/tasks/bulk/", data={"task_ids": tasks})
672
+ project_uuid = resolve_project(client, project_id)
673
+ data = client.post(f"/projects/{project_uuid}/tasks/bulk/", data={"task_ids": tasks})
664
674
  result = extract_results(data) or data
665
675
 
666
676
  if output_json:
@@ -0,0 +1,415 @@
1
+ """Team commands: manage manager → direct-report assignments.
2
+
3
+ Mirrors Harvest's manager model. Admins assign members as direct reports
4
+ to a manager; the manager can see those members' hours across any project
5
+ without being added as a PM to each project individually.
6
+
7
+ Bulk operations use the delta-based ``/manager-assignments/bulk-set/``
8
+ endpoint — the client computes ``add`` and ``remove`` sets, keeping
9
+ concurrent edits from clobbering each other.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import List, Optional
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from ..client import APIError, CrowdTimeClient
21
+ from ..formatters import extract_results, format_error, format_success, print_json
22
+
23
+ app = typer.Typer(name="team", help="Manage direct-report assignments.")
24
+ console = Console()
25
+
26
+
27
+ def _members_list(client: CrowdTimeClient) -> list[dict]:
28
+ data = client.get("/members/")
29
+ return extract_results(data)
30
+
31
+
32
+ def _resolve_membership(client: CrowdTimeClient, ref: str, *, members: Optional[list[dict]] = None) -> dict:
33
+ """Resolve a membership by email, user UUID, or membership UUID.
34
+
35
+ Accepts an optional pre-fetched ``members`` list to avoid repeat HTTP
36
+ calls when resolving many refs at once.
37
+ """
38
+ members = members or _members_list(client)
39
+ ref_lower = ref.lower()
40
+ for m in members:
41
+ if (m.get("user", {}).get("email") or "").lower() == ref_lower:
42
+ return m
43
+ for m in members:
44
+ if m.get("id") == ref:
45
+ return m
46
+ for m in members:
47
+ if m.get("user", {}).get("id") == ref:
48
+ return m
49
+ raise APIError(
50
+ f"No member found matching '{ref}'. Use email, user ID, or membership ID.",
51
+ status_code=404,
52
+ )
53
+
54
+
55
+ def _current_direct_report_ids(client: CrowdTimeClient, manager_id: str) -> set[str]:
56
+ """Return the set of membership IDs currently assigned to ``manager_id``."""
57
+ data = client.get("/manager-assignments/", params={"manager": manager_id})
58
+ rows = extract_results(data)
59
+ return {row["member"] for row in rows}
60
+
61
+
62
+ def _render_bulk_result(result: dict, *, members_by_id: dict[str, dict]) -> None:
63
+ """Pretty-print the bulk-set response."""
64
+ added = result.get("added") or []
65
+ removed = result.get("removed") or []
66
+ skipped = result.get("skipped") or {}
67
+
68
+ def _label(mid: str) -> str:
69
+ m = members_by_id.get(mid)
70
+ if not m:
71
+ return mid
72
+ u = m.get("user") or {}
73
+ return u.get("full_name") or u.get("email") or mid
74
+
75
+ if added:
76
+ console.print(f"[green]+ added[/green] {', '.join(_label(x) for x in added)}")
77
+ if removed:
78
+ console.print(f"[red]- removed[/red] {', '.join(_label(x) for x in removed)}")
79
+ for bucket_key, color, noun in (
80
+ ("already_assigned", "dim", "already assigned"),
81
+ ("not_assigned", "dim", "not assigned"),
82
+ ("not_in_org", "yellow", "not in org"),
83
+ ("invalid_role", "yellow", "invalid"),
84
+ ):
85
+ items = skipped.get(bucket_key) or []
86
+ if items:
87
+ console.print(f"[{color}]· {noun}: {', '.join(_label(x) for x in items)}[/{color}]")
88
+
89
+
90
+ def _bulk_set(
91
+ client: CrowdTimeClient,
92
+ manager_id: str,
93
+ *,
94
+ add: List[str],
95
+ remove: List[str],
96
+ members: list[dict],
97
+ output_json: bool,
98
+ ) -> None:
99
+ """Call the bulk-set endpoint and render the result."""
100
+ if not add and not remove:
101
+ console.print("[dim]Nothing to change.[/dim]")
102
+ return
103
+ members_by_id = {m["id"]: m for m in members}
104
+ try:
105
+ result = client.post("/manager-assignments/bulk-set/", data={
106
+ "manager": manager_id,
107
+ "add": add,
108
+ "remove": remove,
109
+ })
110
+ except APIError as e:
111
+ format_error(e.message)
112
+ raise typer.Exit(1)
113
+ if output_json:
114
+ print_json(result)
115
+ return
116
+ added_n = len(result.get("added") or [])
117
+ removed_n = len(result.get("removed") or [])
118
+ if added_n or removed_n:
119
+ parts = []
120
+ if added_n:
121
+ parts.append(f"{added_n} added")
122
+ if removed_n:
123
+ parts.append(f"{removed_n} removed")
124
+ format_success("; ".join(parts))
125
+ _render_bulk_result(result, members_by_id=members_by_id)
126
+
127
+
128
+ @app.command("assign")
129
+ def assign(
130
+ manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
131
+ members: Optional[List[str]] = typer.Argument(None, help="One or more members to assign (email, user ID, or membership ID)."),
132
+ assign_all: bool = typer.Option(False, "--all", help="Assign every active member of the organization."),
133
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
134
+ ) -> None:
135
+ """Assign one or more members as direct reports to a manager.
136
+
137
+ Admin+ only. The member's hours become visible to the manager across
138
+ all projects — no per-project PM assignment needed.
139
+
140
+ Examples:
141
+ ct team assign alice@acme.com bob@acme.com
142
+ ct team assign alice@acme.com bob@acme.com carol@acme.com dave@acme.com
143
+ ct team assign alice@acme.com --all
144
+ """
145
+ if bool(members) == assign_all:
146
+ format_error(
147
+ "Pass one or more members, OR use --all. Not both, not neither."
148
+ if members and assign_all
149
+ else "Specify members to assign, or pass --all."
150
+ )
151
+ raise typer.Exit(2)
152
+
153
+ client = CrowdTimeClient(require_auth=True, require_org=True)
154
+ try:
155
+ all_members = _members_list(client)
156
+ manager_m = _resolve_membership(client, manager, members=all_members)
157
+ except APIError as e:
158
+ format_error(e.message)
159
+ raise typer.Exit(1)
160
+
161
+ if assign_all:
162
+ add_ids = [
163
+ m["id"] for m in all_members
164
+ if m.get("is_active", True) and m["id"] != manager_m["id"]
165
+ ]
166
+ else:
167
+ try:
168
+ resolved = [
169
+ _resolve_membership(client, ref, members=all_members)
170
+ for ref in members
171
+ ]
172
+ except APIError as e:
173
+ format_error(e.message)
174
+ raise typer.Exit(1)
175
+ add_ids = [m["id"] for m in resolved]
176
+
177
+ _bulk_set(
178
+ client, manager_m["id"],
179
+ add=add_ids, remove=[],
180
+ members=all_members, output_json=output_json,
181
+ )
182
+
183
+
184
+ @app.command("unassign")
185
+ def unassign(
186
+ manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
187
+ members: Optional[List[str]] = typer.Argument(None, help="One or more direct reports to remove. Mutually exclusive with --all."),
188
+ unassign_all: bool = typer.Option(False, "--all", help="Remove every current direct report of this manager."),
189
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
190
+ ) -> None:
191
+ """Remove one or more direct-report assignments from a manager.
192
+
193
+ Admin+ only.
194
+
195
+ Examples:
196
+ ct team unassign alice@acme.com bob@acme.com
197
+ ct team unassign alice@acme.com --all
198
+ """
199
+ if bool(members) == unassign_all:
200
+ format_error(
201
+ "Pass one or more members, OR use --all. Not both, not neither."
202
+ if members and unassign_all
203
+ else "Specify members to unassign, or pass --all."
204
+ )
205
+ raise typer.Exit(2)
206
+
207
+ client = CrowdTimeClient(require_auth=True, require_org=True)
208
+ try:
209
+ all_members = _members_list(client)
210
+ manager_m = _resolve_membership(client, manager, members=all_members)
211
+ except APIError as e:
212
+ format_error(e.message)
213
+ raise typer.Exit(1)
214
+
215
+ if unassign_all:
216
+ try:
217
+ remove_ids = list(_current_direct_report_ids(client, manager_m["id"]))
218
+ except APIError as e:
219
+ format_error(e.message)
220
+ raise typer.Exit(1)
221
+ else:
222
+ try:
223
+ resolved = [
224
+ _resolve_membership(client, ref, members=all_members)
225
+ for ref in members
226
+ ]
227
+ except APIError as e:
228
+ format_error(e.message)
229
+ raise typer.Exit(1)
230
+ remove_ids = [m["id"] for m in resolved]
231
+
232
+ _bulk_set(
233
+ client, manager_m["id"],
234
+ add=[], remove=remove_ids,
235
+ members=all_members, output_json=output_json,
236
+ )
237
+
238
+
239
+ @app.command("set")
240
+ def set_reports(
241
+ manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
242
+ members: List[str] = typer.Option(
243
+ [], "--member", "-m",
244
+ help="Member to include as a direct report. Repeat for each. Pass zero to clear all.",
245
+ ),
246
+ all_members: bool = typer.Option(False, "--all", help="Set the direct reports to every active org member."),
247
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
248
+ ) -> None:
249
+ """Declaratively set the full list of direct reports for a manager.
250
+
251
+ Computes the diff against the current assignments and applies it in one
252
+ atomic call. Members not in the list are unassigned; new members are
253
+ added. Admin+ only.
254
+
255
+ Examples:
256
+ ct team set alice@acme.com --member bob@acme.com --member carol@acme.com
257
+ ct team set alice@acme.com --all # replace with every org member
258
+ ct team set alice@acme.com # clear all reports (no --member flags)
259
+ """
260
+ client = CrowdTimeClient(require_auth=True, require_org=True)
261
+
262
+ try:
263
+ all_active = _members_list(client)
264
+ manager_m = _resolve_membership(client, manager, members=all_active)
265
+ except APIError as e:
266
+ format_error(e.message)
267
+ raise typer.Exit(1)
268
+
269
+ if all_members:
270
+ if members:
271
+ format_error("Pass --member or --all, not both.")
272
+ raise typer.Exit(2)
273
+ desired = {
274
+ m["id"] for m in all_active
275
+ if m.get("is_active", True) and m["id"] != manager_m["id"]
276
+ }
277
+ else:
278
+ try:
279
+ desired = {
280
+ _resolve_membership(client, ref, members=all_active)["id"]
281
+ for ref in members
282
+ }
283
+ except APIError as e:
284
+ format_error(e.message)
285
+ raise typer.Exit(1)
286
+
287
+ try:
288
+ current = _current_direct_report_ids(client, manager_m["id"])
289
+ except APIError as e:
290
+ format_error(e.message)
291
+ raise typer.Exit(1)
292
+
293
+ add_ids = sorted(desired - current)
294
+ remove_ids = sorted(current - desired)
295
+
296
+ _bulk_set(
297
+ client, manager_m["id"],
298
+ add=add_ids, remove=remove_ids,
299
+ members=all_active, output_json=output_json,
300
+ )
301
+
302
+
303
+ @app.command("list")
304
+ def list_assignments(
305
+ manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Filter to a manager (ID, user ID, or email)."),
306
+ member: Optional[str] = typer.Option(None, "--member", help="Filter to a member (ID, user ID, or email)."),
307
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
308
+ ) -> None:
309
+ """List manager→direct-report assignments.
310
+
311
+ Admin+ sees all assignments in the org. Other roles see only rows where
312
+ they are the manager.
313
+ """
314
+ client = CrowdTimeClient(require_auth=True, require_org=True)
315
+
316
+ params: dict = {}
317
+ try:
318
+ if manager:
319
+ m = _resolve_membership(client, manager)
320
+ params["manager"] = m["id"]
321
+ if member:
322
+ m = _resolve_membership(client, member)
323
+ params["member"] = m["id"]
324
+ except APIError as e:
325
+ format_error(e.message)
326
+ raise typer.Exit(1)
327
+
328
+ try:
329
+ data = client.get("/manager-assignments/", params=params)
330
+ except APIError as e:
331
+ format_error(e.message)
332
+ raise typer.Exit(1)
333
+
334
+ rows = extract_results(data)
335
+
336
+ if output_json:
337
+ print_json(rows)
338
+ return
339
+
340
+ if not rows:
341
+ console.print("[dim]No direct-report assignments.[/dim]")
342
+ return
343
+
344
+ table = Table(show_header=True, header_style="bold")
345
+ table.add_column("Assignment ID", style="dim")
346
+ table.add_column("Manager")
347
+ table.add_column("Manager Email", style="dim")
348
+ table.add_column("Direct Report")
349
+ table.add_column("Report Email", style="dim")
350
+
351
+ for row in rows:
352
+ table.add_row(
353
+ row.get("id", ""),
354
+ row.get("manager_name") or "-",
355
+ row.get("manager_email") or "-",
356
+ row.get("member_name") or "-",
357
+ row.get("member_email") or "-",
358
+ )
359
+
360
+ console.print(table)
361
+
362
+
363
+ @app.command("reports")
364
+ def reports(
365
+ manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Manager to list reports for. Defaults to you."),
366
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
367
+ ) -> None:
368
+ """Show direct reports of a manager (defaults to the current user).
369
+
370
+ Examples:
371
+ ct team reports
372
+ ct team reports --manager alice@acme.com
373
+ """
374
+ client = CrowdTimeClient(require_auth=True, require_org=True)
375
+
376
+ params: dict = {}
377
+ if manager:
378
+ try:
379
+ m = _resolve_membership(client, manager)
380
+ params["manager"] = m["id"]
381
+ except APIError as e:
382
+ format_error(e.message)
383
+ raise typer.Exit(1)
384
+
385
+ try:
386
+ data = client.get("/manager-assignments/", params=params)
387
+ except APIError as e:
388
+ format_error(e.message)
389
+ raise typer.Exit(1)
390
+
391
+ rows = extract_results(data)
392
+
393
+ if output_json:
394
+ print_json(rows)
395
+ return
396
+
397
+ if not rows:
398
+ console.print("[dim]No direct reports.[/dim]")
399
+ return
400
+
401
+ table = Table(show_header=True, header_style="bold")
402
+ table.add_column("Direct Report")
403
+ table.add_column("Email", style="dim")
404
+ table.add_column("User ID", style="dim")
405
+ table.add_column("Assignment ID", style="dim")
406
+
407
+ for row in rows:
408
+ table.add_row(
409
+ row.get("member_name") or "-",
410
+ row.get("member_email") or "-",
411
+ row.get("member_user_id") or "-",
412
+ row.get("id", ""),
413
+ )
414
+
415
+ console.print(table)
@@ -366,14 +366,22 @@ ct payroll payments --period 2026-03 --status paid
366
366
 
367
367
  ### Team (Direct Reports)
368
368
  ```bash
369
- ct team list # All direct-report assignments (admin+) or your own
370
- ct team list --manager alice@company.com # Filter to a specific manager
371
- ct team reports # Show YOUR direct reports (manager only)
372
- ct team reports --manager alice@company.com # Someone else's reports (admin+)
373
- ct team assign alice@co.com bob@co.com # Assign bob as a direct report of alice (admin+)
374
- ct team unassign <assignment-id> # Remove an assignment (admin+)
369
+ ct team list # All direct-report assignments (admin+) or your own
370
+ ct team list --manager alice@company.com # Filter to a specific manager
371
+ ct team reports # Show YOUR direct reports
372
+ ct team reports --manager alice@company.com # Someone else's reports (admin+)
373
+ ct team assign alice@co.com bob@co.com # Assign bob as a direct report of alice (admin+)
374
+ ct team assign alice@co.com bob@co.com carol@co.com # Assign multiple in one call
375
+ ct team assign alice@co.com --all # Assign every active org member as her reports
376
+ ct team unassign alice@co.com bob@co.com # Remove one or more reports (by member, not assignment id)
377
+ ct team unassign alice@co.com --all # Clear all her direct reports
378
+ ct team set alice@co.com --member bob@co.com --member carol@co.com # Declarative: replace the full set
379
+ ct team set alice@co.com # Clear all (zero --member flags)
380
+ ct team set alice@co.com --all # Set her reports to the full org
375
381
  ```
376
382
 
383
+ All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` endpoint — changes apply in a single transaction and the response reports `added` / `removed` / `skipped` buckets.
384
+
377
385
  **Visibility rule**: a `manager` or `project_manager` sees time entries / timesheets / team reports only for users in their visible set, which is the union of:
378
386
  1. **Direct reports** — members assigned via `ct team assign`.
379
387
  2. **PM-project members** — users of projects where the caller holds `project_role = project_manager`.
@@ -2473,28 +2473,48 @@ Shows the direct reports of a manager. Defaults to the current user. Same shape
2473
2473
  ### ct team assign
2474
2474
 
2475
2475
  ```
2476
- ct team assign MANAGER MEMBER [--json]
2476
+ ct team assign MANAGER MEMBER [MEMBER...] [--json]
2477
+ ct team assign MANAGER --all [--json]
2477
2478
  ```
2478
2479
 
2479
- Assigns `MEMBER` as a direct report to `MANAGER`. Admin or owner only.
2480
+ Assigns one or more members as direct reports to `MANAGER`. Admin or owner only.
2480
2481
 
2481
- Each argument accepts an email, user ID, or membership ID. The CLI resolves each to a membership in the current org.
2482
+ Each positional accepts an email, user ID, or membership ID. `--all` assigns every active member of the organization (excluding the manager themselves). Mutually exclusive with positional members.
2482
2483
 
2483
2484
  - Manager must have role `project_manager`, `manager`, `admin`, or `owner`.
2484
- - Both must be in the same organization.
2485
- - A member cannot manage themselves.
2485
+ - Members must be in the same organization.
2486
+ - Already-assigned members are silently skipped (reported in `skipped.already_assigned`).
2486
2487
 
2487
- Endpoint: `POST /manager-assignments/`
2488
+ Endpoint: `POST /manager-assignments/bulk-set/` with `{manager, add: [ids]}`.
2488
2489
 
2489
2490
  ### ct team unassign
2490
2491
 
2491
2492
  ```
2492
- ct team unassign ASSIGNMENT_ID
2493
+ ct team unassign MANAGER MEMBER [MEMBER...] [--json]
2494
+ ct team unassign MANAGER --all [--json]
2493
2495
  ```
2494
2496
 
2495
- Removes a direct-report assignment by its ID (from `ct team list`). Admin or owner only.
2497
+ Removes one or more direct-report assignments from `MANAGER`. Admin or owner only.
2496
2498
 
2497
- Endpoint: `DELETE /manager-assignments/<id>/`
2499
+ Each positional is a member (email, user ID, or membership ID) — **not** an assignment ID. `--all` clears every current direct report for this manager.
2500
+
2501
+ Endpoint: `POST /manager-assignments/bulk-set/` with `{manager, remove: [ids]}`.
2502
+
2503
+ ### ct team set
2504
+
2505
+ ```
2506
+ ct team set MANAGER [--member EMAIL]... [--all] [--json]
2507
+ ```
2508
+
2509
+ Declaratively sets the full list of direct reports for `MANAGER`. The CLI fetches the current assignments, computes the diff against the desired set, and applies both adds and removes in a single atomic call. Admin or owner only.
2510
+
2511
+ Examples:
2512
+
2513
+ - `ct team set alice@co.com --member bob --member carol` — reports are exactly {bob, carol}; anyone else currently assigned is unassigned.
2514
+ - `ct team set alice@co.com` — clear all (zero `--member` flags).
2515
+ - `ct team set alice@co.com --all` — reports are every active org member.
2516
+
2517
+ Endpoint: `POST /manager-assignments/bulk-set/` with both `add` and `remove` computed client-side.
2498
2518
 
2499
2519
  ### Visibility semantics
2500
2520
 
@@ -1,218 +0,0 @@
1
- """Team commands: manage manager → direct-report assignments.
2
-
3
- Mirrors Harvest's manager model. Admins assign members as direct reports
4
- to a manager; the manager can see those members' hours across any project
5
- without being added as a PM to each project individually.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from typing import Optional
11
-
12
- import typer
13
- from rich.console import Console
14
- from rich.table import Table
15
-
16
- from ..client import APIError, CrowdTimeClient
17
- from ..formatters import extract_results, format_error, format_success, print_json
18
-
19
- app = typer.Typer(name="team", help="Manage direct-report assignments.")
20
- console = Console()
21
-
22
-
23
- def _resolve_membership(client: CrowdTimeClient, ref: str) -> dict:
24
- """Resolve a membership by UUID, user UUID, or email. Returns the membership dict."""
25
- data = client.get("/members/")
26
- members_list = extract_results(data)
27
-
28
- ref_lower = ref.lower()
29
- # Exact email match
30
- for m in members_list:
31
- if (m.get("user", {}).get("email") or "").lower() == ref_lower:
32
- return m
33
- # Membership ID match
34
- for m in members_list:
35
- if m.get("id") == ref:
36
- return m
37
- # User ID match
38
- for m in members_list:
39
- if m.get("user", {}).get("id") == ref:
40
- return m
41
-
42
- raise APIError(
43
- f"No member found matching '{ref}'. Use email, user ID, or membership ID.",
44
- status_code=404,
45
- )
46
-
47
-
48
- @app.command("assign")
49
- def assign(
50
- manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
51
- member: str = typer.Argument(..., help="Member to assign (email, user ID, or membership ID)."),
52
- output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
53
- ) -> None:
54
- """Assign a member as a direct report to a manager.
55
-
56
- Admin+ only. The member's hours become visible to the manager across
57
- all projects — no per-project PM assignment needed.
58
-
59
- Examples:
60
- ct team assign alice@acme.com bob@acme.com
61
- ct team assign <manager-membership-id> <member-membership-id>
62
- """
63
- client = CrowdTimeClient(require_auth=True, require_org=True)
64
-
65
- try:
66
- manager_m = _resolve_membership(client, manager)
67
- member_m = _resolve_membership(client, member)
68
- except APIError as e:
69
- format_error(e.message)
70
- raise typer.Exit(1)
71
-
72
- try:
73
- data = client.post("/manager-assignments/", data={
74
- "manager": manager_m["id"],
75
- "member": member_m["id"],
76
- })
77
- except APIError as e:
78
- format_error(e.message)
79
- raise typer.Exit(1)
80
-
81
- if output_json:
82
- print_json(data)
83
- return
84
- manager_name = manager_m.get("user", {}).get("full_name") or manager_m.get("user", {}).get("email")
85
- member_name = member_m.get("user", {}).get("full_name") or member_m.get("user", {}).get("email")
86
- format_success(f"{member_name} now reports to {manager_name}.")
87
-
88
-
89
- @app.command("unassign")
90
- def unassign(
91
- assignment_id: str = typer.Argument(..., help="Manager assignment ID (from `ct team list`)."),
92
- ) -> None:
93
- """Remove a direct-report assignment.
94
-
95
- Admin+ only.
96
- """
97
- client = CrowdTimeClient(require_auth=True, require_org=True)
98
- try:
99
- client.delete(f"/manager-assignments/{assignment_id}/")
100
- except APIError as e:
101
- format_error(e.message)
102
- raise typer.Exit(1)
103
- format_success(f"Assignment {assignment_id} removed.")
104
-
105
-
106
- @app.command("list")
107
- def list_assignments(
108
- manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Filter to a manager (ID, user ID, or email)."),
109
- member: Optional[str] = typer.Option(None, "--member", help="Filter to a member (ID, user ID, or email)."),
110
- output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
111
- ) -> None:
112
- """List manager→direct-report assignments.
113
-
114
- Admin+ sees all assignments in the org. Other roles see only rows where
115
- they are the manager.
116
- """
117
- client = CrowdTimeClient(require_auth=True, require_org=True)
118
-
119
- params: dict = {}
120
- try:
121
- if manager:
122
- m = _resolve_membership(client, manager)
123
- params["manager"] = m["id"]
124
- if member:
125
- m = _resolve_membership(client, member)
126
- params["member"] = m["id"]
127
- except APIError as e:
128
- format_error(e.message)
129
- raise typer.Exit(1)
130
-
131
- try:
132
- data = client.get("/manager-assignments/", params=params)
133
- except APIError as e:
134
- format_error(e.message)
135
- raise typer.Exit(1)
136
-
137
- rows = extract_results(data)
138
-
139
- if output_json:
140
- print_json(rows)
141
- return
142
-
143
- if not rows:
144
- console.print("[dim]No direct-report assignments.[/dim]")
145
- return
146
-
147
- table = Table(show_header=True, header_style="bold")
148
- table.add_column("Assignment ID", style="dim")
149
- table.add_column("Manager")
150
- table.add_column("Manager Email", style="dim")
151
- table.add_column("Direct Report")
152
- table.add_column("Report Email", style="dim")
153
-
154
- for row in rows:
155
- table.add_row(
156
- row.get("id", ""),
157
- row.get("manager_name") or "-",
158
- row.get("manager_email") or "-",
159
- row.get("member_name") or "-",
160
- row.get("member_email") or "-",
161
- )
162
-
163
- console.print(table)
164
-
165
-
166
- @app.command("reports")
167
- def reports(
168
- manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Manager to list reports for. Defaults to you."),
169
- output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
170
- ) -> None:
171
- """Show direct reports of a manager (defaults to the current user).
172
-
173
- Examples:
174
- ct team reports
175
- ct team reports --manager alice@acme.com
176
- """
177
- client = CrowdTimeClient(require_auth=True, require_org=True)
178
-
179
- params: dict = {}
180
- if manager:
181
- try:
182
- m = _resolve_membership(client, manager)
183
- params["manager"] = m["id"]
184
- except APIError as e:
185
- format_error(e.message)
186
- raise typer.Exit(1)
187
-
188
- try:
189
- data = client.get("/manager-assignments/", params=params)
190
- except APIError as e:
191
- format_error(e.message)
192
- raise typer.Exit(1)
193
-
194
- rows = extract_results(data)
195
-
196
- if output_json:
197
- print_json(rows)
198
- return
199
-
200
- if not rows:
201
- console.print("[dim]No direct reports.[/dim]")
202
- return
203
-
204
- table = Table(show_header=True, header_style="bold")
205
- table.add_column("Direct Report")
206
- table.add_column("Email", style="dim")
207
- table.add_column("User ID", style="dim")
208
- table.add_column("Assignment ID", style="dim")
209
-
210
- for row in rows:
211
- table.add_row(
212
- row.get("member_name") or "-",
213
- row.get("member_email") or "-",
214
- row.get("member_user_id") or "-",
215
- row.get("id", ""),
216
- )
217
-
218
- console.print(table)
File without changes
File without changes