crowdtime-cli 0.18.0__tar.gz → 0.19.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 (43) hide show
  1. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/client.py +19 -0
  5. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/auth_cmd.py +3 -0
  6. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
  7. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/log_cmd.py +31 -5
  8. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timesheet_cmd.py +25 -4
  9. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/formatters.py +25 -4
  10. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -4
  11. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +21 -15
  12. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/.gitignore +0 -0
  13. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/LICENSE +0 -0
  14. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/README.md +0 -0
  15. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/auth.py +0 -0
  16. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  17. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  18. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  19. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  20. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  21. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  22. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  23. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  24. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  25. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  26. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  27. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  28. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
  29. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  30. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  31. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  32. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  33. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  34. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  35. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/config.py +0 -0
  36. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/main.py +0 -0
  37. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/models.py +0 -0
  38. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/oauth.py +0 -0
  39. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/resolvers.py +0 -0
  40. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
  41. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +0 -0
  42. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.18.0
3
+ Version: 0.19.0
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.18.0"
3
+ version = "0.19.0"
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.18.0"
3
+ __version__ = "0.19.0"
@@ -133,6 +133,25 @@ class CrowdTimeClient:
133
133
  response = self._request("DELETE", url)
134
134
  return response
135
135
 
136
+ def current_user_id(self) -> str | None:
137
+ """The authenticated user's id, cached in the config after the first lookup.
138
+
139
+ ``ct auth login`` refreshes the cache and ``ct auth logout`` clears it,
140
+ so the cached id always belongs to the stored token.
141
+ """
142
+ cached = self.config.get("user.id")
143
+ if cached:
144
+ return str(cached)
145
+ try:
146
+ me = self.get("/auth/me/", org_scoped=False)
147
+ except APIError:
148
+ return None
149
+ user_id = me.get("id") if isinstance(me, dict) else None
150
+ if not user_id:
151
+ return None
152
+ self.config.set("user.id", str(user_id))
153
+ return str(user_id)
154
+
136
155
  def _request(self, method: str, url: str, **kwargs: Any) -> Any:
137
156
  """Execute an HTTP request with error handling."""
138
157
  if _DEBUG:
@@ -59,6 +59,7 @@ def _login_with_token(token: str, output_json: bool) -> None:
59
59
  client = CrowdTimeClient(require_auth=True, require_org=False)
60
60
  data = client.get("/api/v1/auth/me/", org_scoped=False)
61
61
  user = User(**data)
62
+ get_config().set("user.id", user.id) # read back by current_user_id()
62
63
 
63
64
  if output_json:
64
65
  print_json(user)
@@ -118,6 +119,7 @@ def _login_with_browser(output_json: bool) -> None:
118
119
  client = CrowdTimeClient(require_auth=True, require_org=False)
119
120
  data = client.get("/api/v1/auth/me/", org_scoped=False)
120
121
  user = User(**data)
122
+ config.set("user.id", user.id) # read back by current_user_id()
121
123
 
122
124
  if output_json:
123
125
  print_json(user)
@@ -140,6 +142,7 @@ def logout() -> None:
140
142
  return
141
143
 
142
144
  clear_token()
145
+ get_config().set("user.id", "")
143
146
  format_success("Logged out. Token removed.")
144
147
 
145
148
 
@@ -84,7 +84,9 @@ def list_clients(
84
84
  for c in clients_list:
85
85
  client_id = str(c.get("id", ""))
86
86
  name = c.get("name", "")
87
- contact = c.get("contact_name", "") or "-"
87
+ # The primary contact record; contact_name is a legacy field (CRO-83).
88
+ primary = c.get("primary_contact") or {}
89
+ contact = primary.get("name") or c.get("contact_name", "") or "-"
88
90
  project_count = str(c.get("project_count", 0))
89
91
  is_archived = c.get("is_archived", False)
90
92
  status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
@@ -117,11 +119,30 @@ def show(
117
119
  console.print(f"\n[bold]{data.get('name')}[/bold]")
118
120
  console.print(f" ID: {data.get('id')}")
119
121
  console.print(f" Currency: {data.get('currency', '-')}")
120
- console.print(f" Contact: {data.get('contact_name', '-')}")
121
- if data.get("contact_email"):
122
- console.print(f" Email: {data['contact_email']}")
123
- if data.get("contact_phone"):
124
- console.print(f" Phone: {data['contact_phone']}")
122
+ contacts = data.get("contacts") or []
123
+ if contacts:
124
+ for contact in contacts:
125
+ line = f" Contact: {contact.get('name', '')}"
126
+ if contact.get("email"):
127
+ line += f" <{contact['email']}>"
128
+ if contact.get("phone"):
129
+ line += f" {contact['phone']}"
130
+ if contact.get("role"):
131
+ line += f" — {contact['role']}"
132
+ flags = [
133
+ label for key, label in (("is_primary", "primary"), ("receives_invoices", "invoices"))
134
+ if contact.get(key)
135
+ ]
136
+ if flags:
137
+ line += f" [dim]({', '.join(flags)})[/dim]"
138
+ console.print(line)
139
+ else:
140
+ # Legacy contact fields, only on clients that never got a contact record.
141
+ console.print(f" Contact: {data.get('contact_name') or '-'}")
142
+ if data.get("contact_email"):
143
+ console.print(f" Email: {data['contact_email']}")
144
+ if data.get("contact_phone"):
145
+ console.print(f" Phone: {data['contact_phone']}")
125
146
  if data.get("address"):
126
147
  console.print(f" Address: {data['address']}")
127
148
  status = "Archived" if data.get("is_archived") else "Active"
@@ -149,7 +170,7 @@ def create(
149
170
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
150
171
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
151
172
  ) -> None:
152
- """Create a new client.
173
+ """Create a new client. --contact/--email add its primary contact.
153
174
 
154
175
  Examples:
155
176
  ct clients create "Acme Corp"
@@ -157,13 +178,13 @@ def create(
157
178
  """
158
179
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
159
180
 
181
+ if contact_email and not contact_name:
182
+ format_error("--email needs --contact (the contact's name).")
183
+ raise typer.Exit(1)
184
+
160
185
  payload: dict = {"name": name}
161
186
  if currency:
162
187
  payload["currency"] = currency
163
- if contact_name:
164
- payload["contact_name"] = contact_name
165
- if contact_email:
166
- payload["contact_email"] = contact_email
167
188
  if address_line1:
168
189
  payload["address_line1"] = address_line1
169
190
  if address_line2:
@@ -180,10 +201,22 @@ def create(
180
201
  try:
181
202
  data = api_client.post("/clients/", data=payload)
182
203
 
204
+ # Contacts are records of their own, shown on the client page and used
205
+ # for invoicing — not the legacy contact_* fields on the client (CRO-83).
206
+ if contact_name:
207
+ contact = api_client.post(f"/clients/{data.get('id')}/contacts/", data={
208
+ "name": contact_name,
209
+ "email": contact_email or "",
210
+ "is_primary": True,
211
+ })
212
+ data["contacts"] = [contact]
213
+
183
214
  if output_json:
184
215
  print_json(data)
185
216
  else:
186
217
  format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
218
+ if contact_name:
219
+ console.print(f" Primary contact: {contact_name}" + (f" <{contact_email}>" if contact_email else ""))
187
220
  except APIError as e:
188
221
  format_error(e.message)
189
222
  raise typer.Exit(1)
@@ -203,7 +236,7 @@ def edit(
203
236
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
204
237
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
205
238
  ) -> None:
206
- """Edit an existing client.
239
+ """Edit an existing client. --contact/--email update its primary contact.
207
240
 
208
241
  Examples:
209
242
  ct clients edit <client-id> --name "New Name"
@@ -216,10 +249,6 @@ def edit(
216
249
  payload["name"] = name
217
250
  if currency is not None:
218
251
  payload["currency"] = currency
219
- if contact_name is not None:
220
- payload["contact_name"] = contact_name
221
- if contact_email is not None:
222
- payload["contact_email"] = contact_email
223
252
  if address_line1 is not None:
224
253
  payload["address_line1"] = address_line1
225
254
  if city is not None:
@@ -231,12 +260,35 @@ def edit(
231
260
  if country is not None:
232
261
  payload["country"] = country
233
262
 
234
- if not payload:
263
+ contact_payload: dict = {}
264
+ if contact_name is not None:
265
+ contact_payload["name"] = contact_name
266
+ if contact_email is not None:
267
+ contact_payload["email"] = contact_email
268
+
269
+ if not payload and not contact_payload:
235
270
  format_error("No changes specified. Use --name, --currency, --contact, etc.")
236
271
  raise typer.Exit(1)
237
272
 
238
273
  try:
239
- data = api_client.patch(f"/clients/{client_id}/", data=payload)
274
+ if payload:
275
+ data = api_client.patch(f"/clients/{client_id}/", data=payload)
276
+ else:
277
+ data = api_client.get(f"/clients/{client_id}/")
278
+
279
+ # Contacts are records of their own, shown on the client page and used
280
+ # for invoicing (CRO-83): update the primary one, or create it.
281
+ if contact_payload:
282
+ contacts = extract_results(api_client.get(f"/clients/{client_id}/contacts/"))
283
+ primary = next((c for c in contacts if c.get("is_primary")), contacts[0] if contacts else None)
284
+ if primary:
285
+ api_client.patch(f"/clients/{client_id}/contacts/{primary['id']}/", data=contact_payload)
286
+ elif contact_payload.get("name"):
287
+ api_client.post(f"/clients/{client_id}/contacts/", data={**contact_payload, "is_primary": True})
288
+ else:
289
+ format_error("This client has no contact yet — pass --contact (the contact's name) with --email.")
290
+ raise typer.Exit(1)
291
+ data = api_client.get(f"/clients/{client_id}/")
240
292
 
241
293
  if output_json:
242
294
  print_json(data)
@@ -381,11 +381,23 @@ def list_entries(
381
381
  task: Optional[str] = typer.Option(None, "--task", "-t", help="Filter by task name or ID."),
382
382
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
383
383
  help="Filter by billable status."),
384
- user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter by user ID (manager+ only)."),
384
+ user: Optional[str] = typer.Option(
385
+ None, "--user", "-u",
386
+ help="Filter by user ID. Only people whose time you can see: yourself, your direct "
387
+ "reports (manager), and anyone on projects you manage (project manager).",
388
+ ),
389
+ mine: bool = typer.Option(
390
+ False, "--mine",
391
+ help="Only your own entries. Managers and project managers otherwise also see "
392
+ "the time of the people and projects they manage.",
393
+ ),
385
394
  format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
386
395
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
387
396
  ) -> None:
388
- """List time entries with optional filters."""
397
+ """List time entries with optional filters.
398
+
399
+ The table and CSV show a User column whenever an entry isn't yours.
400
+ """
389
401
  effective_date = date
390
402
  if today_flag:
391
403
  effective_date = "today"
@@ -402,6 +414,7 @@ def list_entries(
402
414
  task=task,
403
415
  billable=billable,
404
416
  user=user,
417
+ mine=mine,
405
418
  format_output=format_output,
406
419
  output_json=output_json,
407
420
  )
@@ -417,6 +430,7 @@ def _list_entries(
417
430
  task: str | None = None,
418
431
  billable: bool | None = None,
419
432
  user: str | None = None,
433
+ mine: bool = False,
420
434
  format_output: str = "table",
421
435
  output_json: bool = False,
422
436
  ) -> None:
@@ -425,6 +439,14 @@ def _list_entries(
425
439
 
426
440
  client = CrowdTimeClient(require_auth=True, require_org=True)
427
441
 
442
+ # Known up front so other people's entries can be told apart (CRO-85).
443
+ me = client.current_user_id()
444
+ if mine:
445
+ if not me:
446
+ format_error("Could not resolve your user id. Run ct auth login again.")
447
+ raise typer.Exit(1)
448
+ user = me
449
+
428
450
  params: dict = {}
429
451
 
430
452
  if date_str:
@@ -484,7 +506,10 @@ def _list_entries(
484
506
  elif format_output == "csv":
485
507
  _print_csv(entries)
486
508
  else:
487
- format_entries_table(entries, show_date=True)
509
+ # Someone else's entry with no owner column looks like your own;
510
+ # the column appears as soon as the list holds one (CRO-85).
511
+ foreign = any(e.user and e.user != me for e in entries)
512
+ format_entries_table(entries, show_date=True, show_user=bool(user) or foreign)
488
513
  except APIError as e:
489
514
  format_error(e.message)
490
515
  raise typer.Exit(1)
@@ -492,13 +517,14 @@ def _list_entries(
492
517
 
493
518
  def _print_csv(entries: list[TimeEntry]) -> None:
494
519
  """Print entries as CSV (plain output, safe for piping)."""
495
- print("id,date,project,task,description,duration,billable,hourly_rate,billable_amount")
520
+ print("id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount")
496
521
  for e in entries:
497
522
  desc = (e.description or "").replace('"', '""')
523
+ user_name = (e.user_name or "").replace('"', '""')
498
524
  rate = getattr(e, "hourly_rate", None) or ""
499
525
  amount = getattr(e, "billable_amount", None) or ""
500
526
  print(
501
- f'{e.id},{e.date or ""},'
527
+ f'{e.id},{e.date or ""},"{user_name}",'
502
528
  f'{e.project_name or ""},{e.task_name or ""},'
503
529
  f'"{desc}",{e.duration or 0},'
504
530
  f'{"yes" if e.is_billable else "no"},'
@@ -181,22 +181,42 @@ def timesheet_default(ctx: typer.Context) -> None:
181
181
 
182
182
  @app.command("list")
183
183
  def list_timesheets(
184
- user: Optional[str] = typer.Option(None, "--user", "-u", help="User ID to view (manager+)."),
184
+ user: Optional[str] = typer.Option(
185
+ None, "--user", "-u",
186
+ help="User ID to view. Only people whose timesheets you can see: yourself, your "
187
+ "direct reports (manager), and people on projects you manage (project manager).",
188
+ ),
189
+ mine: bool = typer.Option(
190
+ False, "--mine",
191
+ help="Only your own timesheets. Managers and project managers otherwise also see "
192
+ "the timesheets of the people and projects they manage.",
193
+ ),
185
194
  status_filter: Optional[str] = typer.Option(
186
195
  None, "--status", "-s",
187
196
  help="Filter by status: draft, submitted, approved, rejected.",
188
197
  ),
189
198
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
190
199
  ) -> None:
191
- """List timesheets. Managers can view other users' timesheets.
200
+ """List timesheets. The table shows a User column whenever one isn't yours.
201
+
202
+ A project manager sees a teammate's timesheet trimmed to the projects they
203
+ manage; a manager sees their direct reports' whole timesheets.
192
204
 
193
205
  Examples:
194
206
  ct timesheet list
207
+ ct timesheet list --mine
195
208
  ct timesheet list --user <user-id>
196
209
  ct timesheet list --status submitted
197
210
  """
198
211
  client = CrowdTimeClient(require_auth=True, require_org=True)
199
212
 
213
+ me = client.current_user_id()
214
+ if mine:
215
+ if not me:
216
+ format_error("Could not resolve your user id. Run ct auth login again.")
217
+ raise typer.Exit(1)
218
+ user = me
219
+
200
220
  params: dict = {}
201
221
  if user:
202
222
  params["user_id"] = user
@@ -210,8 +230,9 @@ def list_timesheets(
210
230
  if output_json:
211
231
  print_json(results)
212
232
  else:
213
- # Show user column when filtering by status (manager view) or by user
214
- _print_timesheets_table(results, show_user=bool(user or status_filter))
233
+ # Someone else's timesheet with no owner column looks like yours (CRO-85).
234
+ foreign = any(ts.get("user") and ts.get("user") != me for ts in results)
235
+ _print_timesheets_table(results, show_user=bool(user or status_filter) or foreign)
215
236
  except APIError as e:
216
237
  format_error(e.message)
217
238
  raise typer.Exit(1)
@@ -195,8 +195,14 @@ def format_favorite_detail(fav: FavoriteEntry) -> None:
195
195
  console.print(table)
196
196
 
197
197
 
198
- def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> None:
199
- """Display time entries in a Rich table."""
198
+ def format_entries_table(
199
+ entries: list[TimeEntry], show_date: bool = True, show_user: bool = False,
200
+ ) -> None:
201
+ """Display time entries in a Rich table.
202
+
203
+ ``show_user`` adds the owner column — needed whenever the list can hold
204
+ someone else's entries, which otherwise look like your own (CRO-85).
205
+ """
200
206
  if not entries:
201
207
  console.print("[dim]No entries found.[/dim]")
202
208
  return
@@ -205,6 +211,8 @@ def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> No
205
211
  table.add_column("ID", style="dim")
206
212
  if show_date:
207
213
  table.add_column("Date", width=12)
214
+ if show_user:
215
+ table.add_column("User", width=18)
208
216
  table.add_column("Project", style="cyan", width=15)
209
217
  table.add_column("Task", style="dim", width=15)
210
218
  table.add_column("Description", width=35)
@@ -215,6 +223,8 @@ def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> No
215
223
  row: list[str] = [str(entry.id)]
216
224
  if show_date:
217
225
  row.append(str(entry.date or ""))
226
+ if show_user:
227
+ row.append(entry.user_name or "")
218
228
  row.extend([
219
229
  entry.project_name or "",
220
230
  entry.task_name or "",
@@ -540,16 +550,27 @@ def format_members_table(members: list[dict[str, Any]]) -> None:
540
550
  table.add_column("Name", width=20)
541
551
  table.add_column("Email", width=25)
542
552
  table.add_column("Role", width=18)
553
+ table.add_column("Projects", width=30)
543
554
  table.add_column("Status", width=10)
544
555
 
545
556
  for m in members:
557
+ # The API nests the user; older callers passed flattened user_* keys.
558
+ user = m.get("user") if isinstance(m.get("user"), dict) else {}
559
+ name = m.get("user_name") or user.get("full_name") or user.get("email", "")
560
+ email = m.get("user_email") or user.get("email", "")
546
561
  status = "[green]Active[/green]" if m.get("is_active", True) else "[dim]Inactive[/dim]"
547
562
  role = m.get("role", "member")
548
563
  role_label = ROLE_LABELS.get(role, role)
564
+ # Project assignments, the ones they run marked (CRO-84).
565
+ projects = ", ".join(
566
+ p.get("name", "") + (" (PM)" if p.get("project_role") == "project_manager" else "")
567
+ for p in (m.get("projects") or [])
568
+ )
549
569
  table.add_row(
550
- m.get("user_name", ""),
551
- m.get("user_email", ""),
570
+ name,
571
+ email,
552
572
  role_label,
573
+ truncate(projects, 30) if projects else "[dim]-[/dim]",
553
574
  status,
554
575
  )
555
576
 
@@ -183,9 +183,13 @@ IDs — it lists every active org member not already on the project. Do not use
183
183
  a different and narrower set.
184
184
 
185
185
  **Per-project roles:** setting `--role project_manager` on a project member
186
- grants them management of *that project only* (adding members, editing it,
187
- managing its tasks), whatever their organization role is. That is the way to
188
- let a member run one project without making them an org-wide project manager.
186
+ makes them run *that project only* — editing it, adding members, managing its
187
+ tasks, seeing everyone's time on it and approving its timesheet portions —
188
+ whatever their organization role is (an org `member` included). It is also
189
+ the **only** way an org-level `project_manager` gets any of that on a project:
190
+ being added as a plain member grants nothing beyond logging time there
191
+ (CRO-85). Whole-timesheet review, team overviews and leave approval stay with
192
+ org-level managers and project managers.
189
193
 
190
194
  #### Bulk Task Assignment
191
195
  ```bash
@@ -488,7 +492,7 @@ All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` en
488
492
 
489
493
  **Visibility rule** — what a `manager` or `project_manager` sees of other people:
490
494
  1. **Direct reports** (assigned via `ct team assign`) — everything: all their time entries on any project, their whole timesheets, utilization, compliance and anomalies.
491
- 2. **Projects they manage** (`project_role = project_manager`, or an org-level manager/PM who is a member of the project) — every entry logged on those projects, by anyone, and that project's portion of each person's timesheet. **Not** the rest of those people's work: time a teammate logged on other projects or clients is hidden, and people-level reports (`ct report team`, `ct insights team|allocations`, `ct timesheet compliance`) leave them out.
495
+ 2. **Projects they run** (set as `project_role = project_manager` on the project explicitly — whatever their org role, an org `member` included — or an org-level manager/admin who is a member of it; an org-level `project_manager` added as a plain member does **not** count) — every entry logged on those projects, by anyone, and that project's portion of each person's timesheet. **Not** the rest of those people's work: time a teammate logged on other projects or clients is hidden, and people-level reports (`ct report team`, `ct insights team|allocations`, `ct timesheet compliance`) leave them out.
492
496
  3. **Time off** is the exception: a project manager still sees, creates and approves PTO for the people on their projects (see PTO).
493
497
 
494
498
  A `manager` with **zero** direct reports and **zero** managed projects sees only their own entries. `admin` and `owner` always see everything. `member` sees only their own data. `viewer` sees entries on their assigned projects, but nobody else's timesheets or expenses. To give someone cross-project visibility of a person, make that person their direct report; for project-scoped visibility, make them PM of the project.
@@ -300,6 +300,8 @@ ct l -p crowdtime-dev -t "Design" 1:30 "wireframes"
300
300
  | Days | `0.25d` | 2.0 hours (1d = 8h) |
301
301
  | Decimal | `2.5` | 2.5 hours |
302
302
 
303
+ Any precision is accepted and rounded half-up to 2 decimals (`1:20` → 1.33h). `0` is valid and logs a zero-hour entry; negative durations are refused.
304
+
303
305
  **Date formats:**
304
306
  | Format | Example |
305
307
  |--------|---------|
@@ -382,10 +384,13 @@ ct log list [options]
382
384
  | `--project`, `-p` | string | Filter by project |
383
385
  | `--task`, `-t` | string | Filter by task name or ID |
384
386
  | `--billable/-B` | flag | Filter billable / non-billable entries |
385
- | `--user`, `-u` | string | Filter by user ID (manager+ only) |
387
+ | `--user`, `-u` | string | Filter by user ID. Only people whose time you can see: yourself, your direct reports (manager), anyone on projects you manage (explicit project manager) |
388
+ | `--mine` | flag | Only your own entries |
386
389
  | `--format` | string | Output: `table`, `json`, `csv` |
387
390
  | `--json` | flag | JSON output |
388
391
 
392
+ **Whose entries come back.** `member` and `viewer`-free lists are your own. A `manager` also gets their direct reports' entries; a project manager also gets everything logged on the projects where they are set as `project_manager` explicitly; `admin`/`owner` get everyone's. Every row carries `user` / `user_name` in JSON, the table adds a **User** column as soon as one row isn't yours, and CSV always has a `user` column (`id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount`). Never assume a listed entry is the caller's — check `user`, or pass `--mine`.
393
+
389
394
  CSV output includes financial columns: `hourly_rate`, `billable_amount`. They are empty for viewers, members and project managers (rates and amounts are manager+ only).
390
395
 
391
396
  Endpoint: `GET /time/`
@@ -435,7 +440,7 @@ ct clients list [--archived/-a] [--json]
435
440
  | `--archived`, `-a` | flag | Include archived clients |
436
441
  | `--json` | flag | JSON output |
437
442
 
438
- Shows: ID, Name, Contact, Projects count, Status.
443
+ Shows: ID, Name, Contact, Projects count, Status. The Contact column is the client's primary contact record (`primary_contact` in JSON: `{id, name, email}` or `null`); the `contact_name` field is legacy and no longer edited anywhere.
439
444
  Endpoint: `GET /clients/`
440
445
 
441
446
  ### ct clients show
@@ -444,7 +449,7 @@ Endpoint: `GET /clients/`
444
449
  ct clients show CLIENT_ID [--json]
445
450
  ```
446
451
 
447
- Shows: ID, name, currency, contact info, status.
452
+ Shows: ID, name, currency, every contact (`contacts[]` in JSON, with `is_primary` / `receives_invoices`), address, status. Contacts are managed with `ct clients contacts ...`.
448
453
  Endpoint: `GET /clients/{id}/`
449
454
 
450
455
  ### ct clients create
@@ -456,8 +461,8 @@ ct clients create NAME [options]
456
461
  | Option | Type | Description |
457
462
  |--------|------|-------------|
458
463
  | `--currency` | string | Currency code (e.g. USD) |
459
- | `--contact` | string | Contact person name |
460
- | `--email` | string | Contact email |
464
+ | `--contact` | string | Name of the primary contact — created as a contact record (`POST /clients/{id}/contacts/`) |
465
+ | `--email` | string | Email of that contact (needs `--contact`) |
461
466
  | `--address-line1` | string | Street address |
462
467
  | `--address-line2` | string | Suite, unit, etc. |
463
468
  | `--city` | string | City |
@@ -478,8 +483,8 @@ ct clients edit CLIENT_ID [options]
478
483
  |--------|------|-------------|
479
484
  | `--name` | string | New client name |
480
485
  | `--currency` | string | Currency code (e.g. EUR) |
481
- | `--contact` | string | Contact person name |
482
- | `--email` | string | Contact email |
486
+ | `--contact` | string | Primary contact's name — updates the primary contact record, or creates it |
487
+ | `--email` | string | Primary contact's email (with `--contact` when the client has no contact yet) |
483
488
  | `--address-line1` | string | Street address |
484
489
  | `--city` | string | City |
485
490
  | `--state` | string | State or province |
@@ -1115,7 +1120,7 @@ ct projects update-member PROJECT_ID USER_ID [options]
1115
1120
  | `--rate` | float | Hourly rate override for this project member (manager+; ignored for other roles) |
1116
1121
  | `--json` | flag | JSON output |
1117
1122
 
1118
- Updates a member's project-specific role and/or rate. The `--role` sets a `project_role` that overrides the member's org-level role for this project only. Response includes `effective_role`, `org_role`, and `org_hourly_rate` for context.
1123
+ Updates a member's project-specific role and/or rate. The `--role` sets a `project_role` that overrides the member's org-level role for this project only. An org-level `project_manager` manages (and sees other people's time on) only the projects where this is set to `project_manager` explicitly — without it they are a plain member of the project. Response includes `effective_role`, `org_role`, and `org_hourly_rate` for context.
1119
1124
 
1120
1125
  **Usage examples:**
1121
1126
  ```bash
@@ -1137,7 +1142,7 @@ ct projects assignable-users PROJECT_ID [--json]
1137
1142
 
1138
1143
  Lists every **active organization member not already on the project**, with their user IDs and org roles. This is the correct source for the user IDs that `ct projects add-members` needs.
1139
1144
 
1140
- It is deliberately not filtered by review visibility — `ct team list` and the timesheet endpoints answer "whose time can I review", which is a narrower set and the wrong question when staffing a project. Requires permission to manage the project (org project_manager+ assigned to it, an account manager or above, or a per-project `project_role` of `project_manager`). Endpoint: `GET /projects/<id>/assignable-users/`
1145
+ It is deliberately not filtered by review visibility — `ct team list` and the timesheet endpoints answer "whose time can I review", which is a narrower set and the wrong question when staffing a project. Requires permission to manage the project (an account manager or above, or a per-project `project_role` of `project_manager` — an org-level project manager needs that explicit role too). Endpoint: `GET /projects/<id>/assignable-users/`
1141
1146
 
1142
1147
  ### ct projects add-members
1143
1148
 
@@ -1557,7 +1562,7 @@ Switches active organization. Saves to config `defaults.organization`.
1557
1562
  ct org members [--json]
1558
1563
  ```
1559
1564
 
1560
- Shows: Name, Email, Role, Status. Requires manager+ role. Endpoint: `GET /members/`
1565
+ Shows: Name, Email, Role, Projects, Status. Requires manager+ role. Each member carries `projects[]` in JSON — `{id, name, code, status, project_role}` for every project they are on; `project_role` is `"project_manager"` when they run it (shown as "(PM)" in the table). Endpoint: `GET /members/`
1561
1566
 
1562
1567
  ### ct org invite
1563
1568
 
@@ -1589,7 +1594,7 @@ ct org update-member USER_ID [options]
1589
1594
  | `USER_ID` | string (required) | User UUID |
1590
1595
  | `--role`, `-r` | string | New org role: admin, manager, project_manager, member, viewer |
1591
1596
  | `--rate` | string | Hourly rate |
1592
- | `--capacity` | float | Weekly capacity hours |
1597
+ | `--capacity` | float | Weekly capacity hours (0–168, rounded to 1 decimal; 0 is allowed) |
1593
1598
  | `--json` | flag | Output as JSON |
1594
1599
 
1595
1600
  Updates an organization member's role, hourly rate, or weekly capacity (admin+). Nobody can grant a role above their own or edit someone who outranks them (`403`); the `owner` role cannot be assigned and the owner's role cannot be changed (`400`); only the owner can change the owner's membership at all. Deactivating a member this way also revokes their API tokens and pending invitations.
@@ -1715,16 +1720,17 @@ compact = false
1715
1720
  ### ct timesheet list
1716
1721
 
1717
1722
  ```
1718
- ct timesheet list [--user USER_ID] [--status STATUS] [--json]
1723
+ ct timesheet list [--user USER_ID] [--mine] [--status STATUS] [--json]
1719
1724
  ```
1720
1725
 
1721
1726
  | Option | Type | Description |
1722
1727
  |--------|------|-------------|
1723
- | `--user`, `-u` | string | User ID to view (manager+) |
1728
+ | `--user`, `-u` | string | User ID to view. Only people whose timesheets you can see: yourself, your direct reports (manager), people on projects you manage (explicit project manager) |
1729
+ | `--mine` | flag | Only your own timesheets |
1724
1730
  | `--status`, `-s` | string | Filter: `draft`, `submitted`, `approved`, `rejected` |
1725
1731
  | `--json` | flag | JSON output |
1726
1732
 
1727
- Lists your timesheets (or all viewable timesheets for managers). Endpoint: `GET /timesheets/`
1733
+ Lists your timesheets, plus — for managers and project managers — the timesheets of the people they manage. A project manager sees a teammate's timesheet trimmed to the projects they manage (`project_approvals`, `total_hours`, `entry_count` cover only those). Rows carry `user` / `user_name`; the table adds a **User** column as soon as one row isn't yours. Never assume a listed timesheet is the caller's — check `user`, or pass `--mine`. Endpoint: `GET /timesheets/`
1728
1734
 
1729
1735
  ### ct timesheet submit
1730
1736
 
@@ -2658,7 +2664,7 @@ Endpoint: `POST /manager-assignments/bulk-set/` with both `add` and `remove` com
2658
2664
  A `manager` or `project_manager` sees time entries, timesheets, and team reports only for users in their **visible set**, which is the union of:
2659
2665
 
2660
2666
  1. **Direct reports** — members in `ct team list --manager me`.
2661
- 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`).
2667
+ 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`). This works for any org role — an org `member` promoted on one project sees its time and approves its portions (`ct timesheet approve --project`) — and being on a project without that explicit role grants nothing, whatever the org role (CRO-85).
2662
2668
 
2663
2669
  A `manager` with **zero** assignments and **zero** PM projects sees only their own entries. `admin` and `owner` always see everything.
2664
2670
 
File without changes
File without changes