crowdtime-cli 0.10.0__tar.gz → 0.11.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.11.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/__init__.py +1 -1
  4. crowdtime_cli-0.11.0/src/crowdtime_cli/commands/team_cmd.py +218 -0
  5. crowdtime_cli-0.11.0/src/crowdtime_cli/commands/version_cmd.py +57 -0
  6. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/main.py +67 -5
  7. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/models.py +2 -2
  8. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +28 -1
  9. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +96 -1
  10. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +49 -5
  11. crowdtime_cli-0.11.0/src/crowdtime_cli/version_check.py +184 -0
  12. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/.gitignore +0 -0
  13. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/LICENSE +0 -0
  14. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/README.md +0 -0
  15. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/auth.py +0 -0
  16. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/client.py +0 -0
  17. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  18. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  19. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  20. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  21. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  22. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  23. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  24. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  25. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  26. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  27. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  28. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  29. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  30. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  31. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  32. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  33. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  34. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  35. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  36. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/config.py +0 -0
  37. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/formatters.py +0 -0
  38. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/oauth.py +0 -0
  39. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/resolvers.py +0 -0
  40. {crowdtime_cli-0.10.0 → crowdtime_cli-0.11.0}/src/crowdtime_cli/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.10.0
3
+ Version: 0.11.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.11.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.11.0"
@@ -0,0 +1,218 @@
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)
@@ -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,22 @@ 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 (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+)
375
+ ```
376
+
377
+ **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
+ 1. **Direct reports** — members assigned via `ct team assign`.
379
+ 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager`.
380
+
381
+ 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.
382
+
366
383
  ### Organizations
367
384
  ```bash
368
385
  ct org list # List your organizations
@@ -399,8 +416,18 @@ ct config set server.url https://... # Set server URL
399
416
  ct config set defaults.project myproj # Set default project
400
417
  ct config set defaults.daily_target 7h # Set daily target
401
418
  ct config edit # Open in editor
419
+ ct config set update_check.enabled false # Disable the daily update-check warning
402
420
  ```
403
421
 
422
+ ### Version
423
+ ```bash
424
+ ct version # Show installed + latest CLI version (hits PyPI)
425
+ ct version --json # Machine-readable output
426
+ ct --version # Print just the installed version (no network)
427
+ ```
428
+
429
+ 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`.
430
+
404
431
  ## Common Workflows
405
432
 
406
433
  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,99 @@ 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 [--json]
2477
+ ```
2478
+
2479
+ Assigns `MEMBER` as a direct report to `MANAGER`. Admin or owner only.
2480
+
2481
+ Each argument accepts an email, user ID, or membership ID. The CLI resolves each to a membership in the current org.
2482
+
2483
+ - Manager must have role `project_manager`, `manager`, `admin`, or `owner`.
2484
+ - Both must be in the same organization.
2485
+ - A member cannot manage themselves.
2486
+
2487
+ Endpoint: `POST /manager-assignments/`
2488
+
2489
+ ### ct team unassign
2490
+
2491
+ ```
2492
+ ct team unassign ASSIGNMENT_ID
2493
+ ```
2494
+
2495
+ Removes a direct-report assignment by its ID (from `ct team list`). Admin or owner only.
2496
+
2497
+ Endpoint: `DELETE /manager-assignments/<id>/`
2498
+
2499
+ ### Visibility semantics
2500
+
2501
+ A `manager` or `project_manager` sees time entries, timesheets, and team reports only for users in their **visible set**, which is the union of:
2502
+
2503
+ 1. **Direct reports** — members in `ct team list --manager me`.
2504
+ 2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`).
2505
+
2506
+ A `manager` with **zero** assignments and **zero** PM projects sees only their own entries. `admin` and `owner` always see everything.
2507
+
2508
+ 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.
2509
+
2510
+ ---
2511
+
2512
+ ## ct version
2513
+
2514
+ ```
2515
+ ct version [--json]
2516
+ ```
2517
+
2518
+ 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).
2519
+
2520
+ **Output:**
2521
+ - Plain: three lines — installed, latest, and a summary (`✓ You are up to date` or `⚠ A newer version is available`).
2522
+ - `--json`: `{ "installed": "0.10.0", "latest": "0.11.0", "up_to_date": false, "outdated": true }`.
2523
+
2524
+ **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:
2525
+
2526
+ - `CROWDTIME_NO_VERSION_CHECK=1` environment variable
2527
+ - `ct config set update_check.enabled false`
2528
+
2529
+ 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.
2530
+
2531
+ **Note:** `ct --version` (top-level flag) prints only the installed version without hitting the network, while `ct version` (subcommand) also fetches and compares.
2532
+
2533
+ ---
2534
+
2440
2535
  ## ct aliases
2441
2536
 
2442
2537
  ```
@@ -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