crowdtime-cli 0.10.0__tar.gz → 0.12.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 (40) hide show
  1. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/.gitignore +5 -0
  2. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/PKG-INFO +1 -1
  3. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/__init__.py +1 -1
  5. crowdtime_cli-0.12.0/src/crowdtime_cli/commands/team_cmd.py +415 -0
  6. crowdtime_cli-0.12.0/src/crowdtime_cli/commands/version_cmd.py +57 -0
  7. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/main.py +67 -5
  8. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/models.py +2 -2
  9. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +36 -1
  10. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +116 -1
  11. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +49 -5
  12. crowdtime_cli-0.12.0/src/crowdtime_cli/version_check.py +184 -0
  13. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/LICENSE +0 -0
  14. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/README.md +0 -0
  15. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/auth.py +0 -0
  16. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/client.py +0 -0
  17. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  18. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  19. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  20. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  21. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  22. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  23. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  24. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  25. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  26. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  27. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  28. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  29. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  30. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  31. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  32. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  33. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  34. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  35. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  36. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/config.py +0 -0
  37. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/formatters.py +0 -0
  38. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/oauth.py +0 -0
  39. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/resolvers.py +0 -0
  40. {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/utils.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.10.0
3
+ Version: 0.12.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.10.0"
3
+ version = "0.12.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.10.0"
3
+ __version__ = "0.12.0"
@@ -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)
@@ -0,0 +1,57 @@
1
+ """Version command: show installed and latest CrowdTime CLI versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from .. import __version__
9
+ from ..formatters import print_json
10
+ from ..version_check import _parse_simple, get_latest, is_outdated
11
+
12
+ app = typer.Typer(name="version", help="Show installed and latest CLI versions.")
13
+ console = Console()
14
+
15
+
16
+ @app.callback(invoke_without_command=True)
17
+ def version(
18
+ ctx: typer.Context,
19
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
20
+ ) -> None:
21
+ """Show the installed CLI version and the latest published on PyPI.
22
+
23
+ Bypasses the 12-hour cache used by the background update check —
24
+ always fetches the latest version fresh.
25
+ """
26
+ if ctx.invoked_subcommand is not None:
27
+ return
28
+
29
+ latest = get_latest(force_refresh=True)
30
+ outdated = is_outdated(__version__, latest)
31
+ parseable = _parse_simple(__version__) is not None and _parse_simple(latest or "") is not None
32
+ up_to_date = parseable and not outdated
33
+
34
+ if output_json:
35
+ print_json({
36
+ "installed": __version__,
37
+ "latest": latest,
38
+ "up_to_date": up_to_date if parseable else None,
39
+ "outdated": outdated,
40
+ })
41
+ return
42
+
43
+ console.print(f"Installed: [bold]{__version__}[/bold]")
44
+ if latest is None:
45
+ console.print("Latest: [dim](could not reach PyPI)[/dim]")
46
+ else:
47
+ console.print(f"Latest: [bold]{latest}[/bold]")
48
+
49
+ if not parseable:
50
+ console.print("[dim]Unable to compare versions (dev or pre-release build).[/dim]")
51
+ elif outdated:
52
+ console.print(
53
+ f"[yellow]\u26a0 A newer version is available. "
54
+ f"Run [bold]pip install -U crowdtime-cli[/bold] to upgrade.[/yellow]"
55
+ )
56
+ else:
57
+ console.print("[green]\u2713 You are up to date.[/green]")
@@ -36,8 +36,10 @@ from .commands import (
36
36
  report_cmd,
37
37
  skill_cmd,
38
38
  tasks_cmd,
39
+ team_cmd,
39
40
  timesheet_cmd,
40
41
  timer_cmd,
42
+ version_cmd,
41
43
  )
42
44
 
43
45
  console = Console()
@@ -69,6 +71,8 @@ app.add_typer(timesheet_cmd.app, name="timesheet")
69
71
  app.add_typer(invoice_cmd.app, name="invoice")
70
72
  app.add_typer(expense_cmd.app, name="expense")
71
73
  app.add_typer(insights_cmd.app, name="insights")
74
+ app.add_typer(team_cmd.app, name="team")
75
+ app.add_typer(version_cmd.app, name="version")
72
76
 
73
77
 
74
78
  # ─── Status command (default when no subcommand given) ──────────────────────
@@ -398,6 +402,7 @@ def _is_known_command(arg: str) -> bool:
398
402
  known = {
399
403
  "auth", "billing", "timer", "log", "projects", "clients", "tasks", "report",
400
404
  "ai", "org", "config", "favorites", "skill", "timesheet", "invoice", "expense", "payroll",
405
+ "insights", "team", "version",
401
406
  "status", "s", "t", "ts", "tx", "sw", "c", "d", "f", "aliases",
402
407
  "l", "ll", "r", "p", "b", "ex", "--help", "-h", "--version", "-v", "--json",
403
408
  }
@@ -421,12 +426,56 @@ def _fix_log_routing() -> None:
421
426
  sys.argv = [sys.argv[0], "log", "create"] + sys.argv[2:]
422
427
 
423
428
 
429
+ _SUPPRESS_CHECK_FLAGS = frozenset({"--json", "--version", "-v", "--help", "-h"})
430
+ # Subcommands that either render their own version info or shouldn't be
431
+ # followed by extra stderr chatter.
432
+ _SUPPRESS_CHECK_COMMANDS = frozenset({"version", "aliases"})
433
+
434
+
435
+ def _should_run_version_check(original_argv: list[str]) -> bool:
436
+ """Return True when the post-command update-check hint should run.
437
+
438
+ Only real CLI flags are inspected — a quoted positional like
439
+ ``ct "refactor the --json output"`` must not suppress the check.
440
+ """
441
+ argv = original_argv[1:]
442
+ if not argv:
443
+ return True
444
+ for token in argv:
445
+ # Only treat tokens that look like flags (start with '-') as flags.
446
+ if token.startswith("-") and token in _SUPPRESS_CHECK_FLAGS:
447
+ return False
448
+ if argv[0] in _SUPPRESS_CHECK_COMMANDS:
449
+ return False
450
+ # LLM-facing paths: warn text on stderr can confuse agents/tools that
451
+ # parse `ct` output. `ct ai parse ...` and the magic-routing fallthrough
452
+ # (unknown first arg → rewritten to `ai parse`) both qualify.
453
+ if argv[0] == "ai" and len(argv) > 1 and argv[1] == "parse":
454
+ return False
455
+ if not argv[0].startswith("-") and not _is_known_command(argv[0]):
456
+ return False
457
+ return True
458
+
459
+
460
+ def _run_version_check() -> None:
461
+ """Best-effort update-check. Never raises, never writes to stdout."""
462
+ try:
463
+ from .version_check import check_and_warn
464
+ check_and_warn(Console(stderr=True))
465
+ except Exception:
466
+ pass
467
+
468
+
424
469
  def _original_main() -> None:
425
470
  """CLI entry point with magic routing and error handling.
426
471
 
427
472
  If the first argument is not a known command, treat the entire
428
473
  argument string as natural language and route to 'ai parse'.
429
474
  """
475
+ # Snapshot argv *before* magic routing rewrites it, so the version-check
476
+ # suppression rules see what the user actually typed.
477
+ original_argv = list(sys.argv)
478
+
430
479
  if len(sys.argv) > 1:
431
480
  first_arg = sys.argv[1]
432
481
  # If it's not a known command and doesn't start with --, route to ai parse
@@ -436,18 +485,31 @@ def _original_main() -> None:
436
485
 
437
486
  _fix_log_routing()
438
487
 
488
+ exit_code = 0
439
489
  try:
440
490
  app()
441
491
  except APIError as e:
442
492
  format_error(e.message)
443
- raise SystemExit(1)
444
- except SystemExit:
445
- raise
493
+ exit_code = 1
494
+ except SystemExit as e:
495
+ # Python convention: None → 0, int → pass through, anything else
496
+ # (including str — used by Click/Typer usage errors) → 1.
497
+ if e.code is None:
498
+ exit_code = 0
499
+ elif isinstance(e.code, int):
500
+ exit_code = e.code
501
+ else:
502
+ exit_code = 1
446
503
  except KeyboardInterrupt:
447
- raise SystemExit(0)
504
+ exit_code = 0
448
505
  except Exception as e:
449
506
  format_error(f"Unexpected error: {e}")
450
- raise SystemExit(1)
507
+ exit_code = 1
508
+
509
+ if _should_run_version_check(original_argv):
510
+ _run_version_check()
511
+
512
+ raise SystemExit(exit_code)
451
513
 
452
514
 
453
515
  if __name__ == "__main__":
@@ -40,9 +40,9 @@ class Project(BaseModel):
40
40
  billing_mode: str = "custom"
41
41
  status: str = "active"
42
42
  is_billable: bool = True
43
- budget_type: str = "none"
43
+ budget_type: str | None = "none"
44
44
  budget_amount: Decimal | None = None
45
- budget_alert_percent: int = 80
45
+ budget_alert_percent: int | None = 80
46
46
  description: str = ""
47
47
  notes: str = ""
48
48
 
@@ -237,7 +237,8 @@ ct favorites delete <id>
237
237
  ```bash
238
238
  ct timesheet list # List your timesheets
239
239
  ct timesheet list --status submitted # Filter by status
240
- ct timesheet submit --from 2026-03-10 --to 2026-03-16 # Submit for a period
240
+ ct timesheet submit --from 2026-03-10 --to 2026-03-16 # Submit full week
241
+ ct timesheet submit --from 2026-03-30 --to 2026-03-31 # Partial week (end-of-month split)
241
242
  ct timesheet recall <id> # Recall submitted timesheet back to draft (--force to skip confirm)
242
243
  ct timesheet approve <id> # Approve all project portions at once (manager+)
243
244
  ct timesheet approve <id> --project <project-id> # Approve one project's portion (PM for that project)
@@ -363,6 +364,30 @@ ct payroll payments --period 2026-03 --status paid
363
364
 
364
365
  **Workflow**: Configure compensation → Run liquidation → Approve → Mark paid.
365
366
 
367
+ ### Team (Direct Reports)
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
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
381
+ ```
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
+
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:
386
+ 1. **Direct reports** — members assigned via `ct team assign`.
387
+ 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager`.
388
+
389
+ A `manager` with **zero** assignments and **zero** PM projects sees only their own entries. `admin` and `owner` always see everything. This is the Harvest-style assignment model: assign *people* to a manager for cross-project visibility, or assign them as a PM on specific projects for project-scoped visibility.
390
+
366
391
  ### Organizations
367
392
  ```bash
368
393
  ct org list # List your organizations
@@ -399,8 +424,18 @@ ct config set server.url https://... # Set server URL
399
424
  ct config set defaults.project myproj # Set default project
400
425
  ct config set defaults.daily_target 7h # Set daily target
401
426
  ct config edit # Open in editor
427
+ ct config set update_check.enabled false # Disable the daily update-check warning
402
428
  ```
403
429
 
430
+ ### Version
431
+ ```bash
432
+ ct version # Show installed + latest CLI version (hits PyPI)
433
+ ct version --json # Machine-readable output
434
+ ct --version # Print just the installed version (no network)
435
+ ```
436
+
437
+ The CLI also runs a once-per-12-hours background update check after every command and prints a one-line hint on stderr when a newer version exists. Opt out with `CROWDTIME_NO_VERSION_CHECK=1` or `ct config set update_check.enabled false`.
438
+
404
439
  ## Common Workflows
405
440
 
406
441
  Read `references/workflows.md` for detailed multi-step workflow patterns including:
@@ -23,6 +23,8 @@ Every command, subcommand, argument, flag, and option in the CrowdTime CLI.
23
23
  - [ct timesheet](#ct-timesheet)
24
24
  - [ct invoice](#ct-invoice)
25
25
  - [ct insights](#ct-insights)
26
+ - [ct team](#ct-team)
27
+ - [ct version](#ct-version)
26
28
  - [ct aliases](#ct-aliases)
27
29
  - [Short Aliases](#short-aliases)
28
30
 
@@ -1674,7 +1676,7 @@ ct timesheet submit --from DATE --to DATE [--force/-f] [--json]
1674
1676
  | `--force`, `-f` | flag | Skip confirmation prompt |
1675
1677
  | `--json` | flag | JSON output |
1676
1678
 
1677
- Submits a timesheet for the given period. Shows a preview of entries and hours before confirming (use `--force` to skip). Rejects zero-hour submissions and overlapping periods. Endpoint: `POST /timesheets/submit/`
1679
+ Submits a timesheet for the given period (any date range — not limited to full weeks). Supports partial-week submissions for end-of-month splits. Shows a preview of entries and hours before confirming (use `--force` to skip). Rejects zero-hour submissions and periods that overlap with already submitted/approved timesheets. Endpoint: `POST /timesheets/submit/`
1678
1680
 
1679
1681
  ### ct timesheet approve
1680
1682
 
@@ -2437,6 +2439,119 @@ Endpoint: `GET /insights/profitability/`
2437
2439
 
2438
2440
  ---
2439
2441
 
2442
+ ## ct team
2443
+
2444
+ Manage manager → direct-report assignments. Mirrors Harvest's manager model: assign people to a manager so the manager can see their hours across any project without being a PM on each project individually. Orthogonal to per-project PM assignments; visibility is the union of both.
2445
+
2446
+ ### ct team list
2447
+
2448
+ ```
2449
+ ct team list [options]
2450
+ ```
2451
+
2452
+ Lists manager → direct-report assignments in the current organization. Admins and owners see everything; managers and project managers see only rows where they are the manager.
2453
+
2454
+ **Options:**
2455
+ - `--manager/-m TEXT` — filter to a specific manager (membership ID, user ID, or email)
2456
+ - `--member TEXT` — filter to a specific member
2457
+ - `--json` — JSON output
2458
+
2459
+ Endpoint: `GET /manager-assignments/`
2460
+
2461
+ ### ct team reports
2462
+
2463
+ ```
2464
+ ct team reports [options]
2465
+ ```
2466
+
2467
+ Shows the direct reports of a manager. Defaults to the current user. Same shape as `ct team list` but focused on the "who reports to this person" view.
2468
+
2469
+ **Options:**
2470
+ - `--manager/-m TEXT` — target manager (defaults to the caller). Admins can query any manager; non-admin callers can only see their own.
2471
+ - `--json` — JSON output
2472
+
2473
+ ### ct team assign
2474
+
2475
+ ```
2476
+ ct team assign MANAGER MEMBER [MEMBER...] [--json]
2477
+ ct team assign MANAGER --all [--json]
2478
+ ```
2479
+
2480
+ Assigns one or more members as direct reports to `MANAGER`. Admin or owner only.
2481
+
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.
2483
+
2484
+ - Manager must have role `project_manager`, `manager`, `admin`, or `owner`.
2485
+ - Members must be in the same organization.
2486
+ - Already-assigned members are silently skipped (reported in `skipped.already_assigned`).
2487
+
2488
+ Endpoint: `POST /manager-assignments/bulk-set/` with `{manager, add: [ids]}`.
2489
+
2490
+ ### ct team unassign
2491
+
2492
+ ```
2493
+ ct team unassign MANAGER MEMBER [MEMBER...] [--json]
2494
+ ct team unassign MANAGER --all [--json]
2495
+ ```
2496
+
2497
+ Removes one or more direct-report assignments from `MANAGER`. Admin or owner only.
2498
+
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.
2518
+
2519
+ ### Visibility semantics
2520
+
2521
+ A `manager` or `project_manager` sees time entries, timesheets, and team reports only for users in their **visible set**, which is the union of:
2522
+
2523
+ 1. **Direct reports** — members in `ct team list --manager me`.
2524
+ 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`).
2525
+
2526
+ A `manager` with **zero** assignments and **zero** PM projects sees only their own entries. `admin` and `owner` always see everything.
2527
+
2528
+ Timesheet approval follows the same rule: a manager can approve/reject only timesheets of users in their visible set (or projects where they are the explicit PM). Admins/owners can approve anything.
2529
+
2530
+ ---
2531
+
2532
+ ## ct version
2533
+
2534
+ ```
2535
+ ct version [--json]
2536
+ ```
2537
+
2538
+ Shows the installed CrowdTime CLI version and the latest version published to PyPI. Fetches fresh from PyPI every call (bypasses the 12-hour background-check cache).
2539
+
2540
+ **Output:**
2541
+ - Plain: three lines — installed, latest, and a summary (`✓ You are up to date` or `⚠ A newer version is available`).
2542
+ - `--json`: `{ "installed": "0.10.0", "latest": "0.11.0", "up_to_date": false, "outdated": true }`.
2543
+
2544
+ **Related:** after every other `ct` command, the CLI also performs a once-per-12-hours background check against PyPI and prints a one-line upgrade hint to stderr when out of date. Opt out with either:
2545
+
2546
+ - `CROWDTIME_NO_VERSION_CHECK=1` environment variable
2547
+ - `ct config set update_check.enabled false`
2548
+
2549
+ Dev builds, pre-release tags (`rc`, `a`, `dev`, etc.), and local version suffixes are skipped — no warning, no network call is wasted on comparing uncomparable versions.
2550
+
2551
+ **Note:** `ct --version` (top-level flag) prints only the installed version without hitting the network, while `ct version` (subcommand) also fetches and compares.
2552
+
2553
+ ---
2554
+
2440
2555
  ## ct aliases
2441
2556
 
2442
2557
  ```
@@ -443,6 +443,38 @@ ct report team --month --member john@company.com
443
443
  ct org members
444
444
  ```
445
445
 
446
+ ### Assign Direct Reports to a Manager
447
+
448
+ A `manager` or `project_manager` sees time entries, timesheets, and reports only for users they are responsible for. Two independent axes determine that visibility:
449
+
450
+ 1. **Direct-report assignments** (`ct team assign`) — person-based, visible across every project the member works on.
451
+ 2. **Per-project PM assignment** (`ct projects update-member ... --role project_manager`) — project-based, visible to the PM for anyone logging time on that project.
452
+
453
+ A `manager` with zero assignments and zero PM projects sees only their own entries. `admin` and `owner` always see everything.
454
+
455
+ ```bash
456
+ # Admin assigns three members as direct reports of a manager
457
+ ct team assign alice@company.com bob@company.com
458
+ ct team assign alice@company.com carol@company.com
459
+ ct team assign alice@company.com dave@company.com
460
+
461
+ # Alice — now a manager for those three — checks who reports to her
462
+ ct team reports
463
+
464
+ # Alice reviews her team's hours for the week (scoped automatically)
465
+ ct log list --week
466
+
467
+ # Alice can approve their timesheets without being a PM on any of their projects
468
+ ct timesheet list --status submitted
469
+ ct timesheet approve <timesheet-id>
470
+
471
+ # Remove an assignment later
472
+ ct team list --manager alice@company.com # find the assignment ID
473
+ ct team unassign <assignment-id>
474
+ ```
475
+
476
+ **Tip**: combine with per-project PM assignment when a manager should also see *all* contributors on a specific project — even those who aren't direct reports. For example, use `ct projects update-member <project-id> <user-id> --role project_manager` for a project lead, and `ct team assign` for HR-style oversight.
477
+
446
478
  ---
447
479
 
448
480
  ## Reporting and Export
@@ -518,7 +550,10 @@ ct report saved delete <report-id>
518
550
 
519
551
  ## Timesheet Submission and Approval
520
552
 
521
- ### Submit a Weekly Timesheet
553
+ ### Submit a Timesheet
554
+
555
+ Timesheets can cover any date range — a full week, a partial week, or a custom period.
556
+ This is useful when the month ends mid-week and you need to split submissions.
522
557
 
523
558
  ```bash
524
559
  # Review your week first
@@ -527,9 +562,13 @@ ct report --week -g day
527
562
  # Fill any gaps
528
563
  ct l -p project-alpha -d monday 6h "feature development"
529
564
 
530
- # Submit the timesheet
565
+ # Submit a full week
531
566
  ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
532
567
 
568
+ # Submit a partial week (e.g. end-of-month split)
569
+ ct timesheet submit --from monday --to wednesday --force # Close out March
570
+ ct timesheet submit --from thursday --to friday --force # Start April
571
+
533
572
  # Check submission status
534
573
  ct timesheet list
535
574
 
@@ -609,7 +648,7 @@ ct timesheet compliance --from 2026-03-09 --to 2026-03-15
609
648
  # pending approval (with days waiting), and rejected timesheets needing resubmission
610
649
  ```
611
650
 
612
- ### End-of-Week Timesheet Routine
651
+ ### End-of-Week / End-of-Month Timesheet Routine
613
652
 
614
653
  ```bash
615
654
  # 1. Check your hours
@@ -618,10 +657,15 @@ ct report --week
618
657
  # 2. Fill gaps
619
658
  ct report --week -g day # spot light days
620
659
 
621
- # 3. Submit
660
+ # 3a. Submit full week
622
661
  ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
623
662
 
624
- # 4. (Manager) Review team shows capacity % and status
663
+ # 3b. OR submit partial week (month ends mid-week)
664
+ ct timesheet submit --from 2026-03-30 --to 2026-03-31 --force # Close March
665
+ # ...later, after logging remaining days...
666
+ ct timesheet submit --from 2026-04-01 --to 2026-04-05 --force # Submit April portion
667
+
668
+ # 4. (Manager) Review team — shows capacity %, status, and partial submissions
625
669
  ct timesheet team
626
670
 
627
671
  # 5. (Manager) Bulk approve all submitted
@@ -0,0 +1,184 @@
1
+ """PyPI version check: warn when the installed CLI is out of date.
2
+
3
+ A single outbound call to PyPI, cached locally for 12 hours. The check is
4
+ fire-and-forget — network errors, PyPI outages, and parse failures are all
5
+ silenced so they never affect command output or exit status.
6
+
7
+ Opt-out:
8
+ - Environment variable: ``CROWDTIME_NO_VERSION_CHECK=1``
9
+ - Config flag: ``[update_check] enabled = false`` in ``config.toml``
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import tempfile
18
+ from datetime import datetime, timedelta, timezone
19
+ from pathlib import Path
20
+ from typing import Optional, Tuple
21
+
22
+ import httpx
23
+
24
+ from . import __version__
25
+
26
+ PYPI_URL = "https://pypi.org/pypi/crowdtime-cli/json"
27
+ CHECK_INTERVAL = timedelta(hours=12)
28
+ FETCH_TIMEOUT_SEC = 2.0
29
+ OPT_OUT_ENV = "CROWDTIME_NO_VERSION_CHECK"
30
+
31
+ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
32
+
33
+
34
+ def _cache_path() -> Path:
35
+ """Return the cache file path. Deferred so tests can monkeypatch CONFIG_DIR."""
36
+ from .config import CONFIG_DIR
37
+ return CONFIG_DIR / "version_check.json"
38
+
39
+
40
+ def _parse_simple(v: Optional[str]) -> Optional[Tuple[int, int, int]]:
41
+ """Parse a strict ``MAJOR.MINOR.PATCH`` version string.
42
+
43
+ Returns None for dev builds, pre-releases, and local versions —
44
+ we deliberately skip the check in those cases rather than risk a
45
+ false positive.
46
+ """
47
+ if not v:
48
+ return None
49
+ m = _VERSION_RE.match(v)
50
+ if not m:
51
+ return None
52
+ return int(m.group(1)), int(m.group(2)), int(m.group(3))
53
+
54
+
55
+ def _read_cache() -> dict:
56
+ try:
57
+ return json.loads(_cache_path().read_text())
58
+ except (FileNotFoundError, ValueError, OSError):
59
+ return {}
60
+
61
+
62
+ def _write_cache(latest: Optional[str]) -> None:
63
+ """Write the cache atomically via tmp-file + os.replace.
64
+
65
+ Guards against two concurrent ``ct`` processes interleaving writes and
66
+ leaving a truncated/partial JSON behind (which would silently disable
67
+ the cache on the next read).
68
+ """
69
+ try:
70
+ path = _cache_path()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ payload = json.dumps({
73
+ "last_checked": datetime.now(timezone.utc).isoformat(),
74
+ "latest_version": latest,
75
+ })
76
+ fd, tmp = tempfile.mkstemp(
77
+ prefix="version_check-", suffix=".tmp", dir=str(path.parent),
78
+ )
79
+ try:
80
+ with os.fdopen(fd, "w") as f:
81
+ f.write(payload)
82
+ os.replace(tmp, path)
83
+ except Exception:
84
+ # Ensure tmp doesn't linger if replace fails.
85
+ try:
86
+ os.unlink(tmp)
87
+ except OSError:
88
+ pass
89
+ raise
90
+ except OSError:
91
+ pass # Cache is a performance nicety — never fatal.
92
+
93
+
94
+ def _is_fresh(cache: dict) -> bool:
95
+ last = cache.get("last_checked")
96
+ if not isinstance(last, str):
97
+ return False
98
+ try:
99
+ last_dt = datetime.fromisoformat(last)
100
+ except ValueError:
101
+ return False
102
+ if last_dt.tzinfo is None:
103
+ last_dt = last_dt.replace(tzinfo=timezone.utc)
104
+ delta = datetime.now(timezone.utc) - last_dt
105
+ # Guard against a system clock set to the past: a negative delta would
106
+ # otherwise keep stale caches "fresh" indefinitely. Treat any non-positive
107
+ # delta as stale so we re-fetch on the next invocation.
108
+ return timedelta(0) <= delta < CHECK_INTERVAL
109
+
110
+
111
+ def _fetch_latest() -> Optional[str]:
112
+ """Fetch the latest published version from PyPI. Returns None on any failure."""
113
+ try:
114
+ resp = httpx.get(PYPI_URL, timeout=FETCH_TIMEOUT_SEC)
115
+ resp.raise_for_status()
116
+ version = resp.json().get("info", {}).get("version")
117
+ return version if isinstance(version, str) else None
118
+ except Exception:
119
+ return None
120
+
121
+
122
+ def get_latest(force_refresh: bool = False) -> Optional[str]:
123
+ """Return the latest known version string, from cache or a fresh fetch.
124
+
125
+ When ``force_refresh=True``, always hit PyPI and update the cache.
126
+ Otherwise, a cache entry younger than ``CHECK_INTERVAL`` is reused.
127
+ A failed fetch falls back to the previous cached value (if any) so
128
+ transient PyPI outages don't wipe out the warning signal.
129
+ """
130
+ cache = _read_cache()
131
+ cached_latest = cache.get("latest_version")
132
+ if not force_refresh and _is_fresh(cache):
133
+ return cached_latest if isinstance(cached_latest, str) else None
134
+
135
+ fetched = _fetch_latest()
136
+ if fetched is not None:
137
+ _write_cache(fetched)
138
+ return fetched
139
+
140
+ # Fetch failed. Refresh the timestamp anyway so we don't retry on every
141
+ # single command, but preserve any prior cached version for warning text.
142
+ _write_cache(cached_latest if isinstance(cached_latest, str) else None)
143
+ return cached_latest if isinstance(cached_latest, str) else None
144
+
145
+
146
+ def _opt_out() -> bool:
147
+ """Return True when the user has disabled the version check."""
148
+ if os.environ.get(OPT_OUT_ENV) == "1":
149
+ return True
150
+ try:
151
+ from .config import get_config
152
+ return not get_config().get("update_check.enabled", True)
153
+ except Exception:
154
+ return False
155
+
156
+
157
+ def is_outdated(current: str, latest: Optional[str]) -> bool:
158
+ """Return True iff ``current`` and ``latest`` are both parseable and current < latest."""
159
+ current_t = _parse_simple(current)
160
+ latest_t = _parse_simple(latest)
161
+ if current_t is None or latest_t is None:
162
+ return False
163
+ return current_t < latest_t
164
+
165
+
166
+ def check_and_warn(console) -> None:
167
+ """Daily-ish check: print an upgrade hint on stderr if out of date.
168
+
169
+ Silent on any error. Honors the opt-out env var and config flag. Dev /
170
+ pre-release / local versions are skipped (no comparison possible).
171
+ """
172
+ if _opt_out():
173
+ return
174
+ if _parse_simple(__version__) is None:
175
+ return # dev build — not something we can sensibly compare
176
+ latest = get_latest()
177
+ if not is_outdated(__version__, latest):
178
+ return
179
+ console.print(
180
+ f"[yellow]\u26a0 crowdtime-cli {latest} is available "
181
+ f"(you have {__version__}). "
182
+ f"Run [bold]pip install -U crowdtime-cli[/bold] to upgrade.[/yellow]",
183
+ highlight=False,
184
+ )
File without changes
File without changes