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.
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/.gitignore +5 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/__init__.py +1 -1
- crowdtime_cli-0.12.0/src/crowdtime_cli/commands/team_cmd.py +415 -0
- crowdtime_cli-0.12.0/src/crowdtime_cli/commands/version_cmd.py +57 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/main.py +67 -5
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/models.py +2 -2
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +36 -1
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +116 -1
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +49 -5
- crowdtime_cli-0.12.0/src/crowdtime_cli/version_check.py +184 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/LICENSE +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/README.md +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.10.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/resolvers.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
444
|
-
except SystemExit:
|
|
445
|
-
|
|
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
|
-
|
|
504
|
+
exit_code = 0
|
|
448
505
|
except Exception as e:
|
|
449
506
|
format_error(f"Unexpected error: {e}")
|
|
450
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
660
|
+
# 3a. Submit full week
|
|
622
661
|
ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
|
|
623
662
|
|
|
624
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|