crowdtime-cli 0.17.1__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/PKG-INFO +12 -3
  2. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/README.md +11 -2
  3. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/__init__.py +1 -1
  5. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/auth.py +19 -2
  6. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/client.py +66 -4
  7. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/auth_cmd.py +11 -1
  8. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
  9. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/config_cmd.py +4 -2
  10. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/insights_cmd.py +4 -2
  11. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/log_cmd.py +38 -5
  12. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/org_cmd.py +5 -2
  13. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
  14. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timesheet_cmd.py +67 -17
  15. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/version_cmd.py +2 -2
  16. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/config.py +16 -0
  17. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/formatters.py +25 -4
  18. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/oauth.py +25 -3
  19. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +52 -30
  20. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +70 -49
  21. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +8 -6
  22. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +45 -23
  23. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/version_check.py +17 -1
  24. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/.gitignore +0 -0
  25. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/LICENSE +0 -0
  26. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  27. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  28. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  29. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  30. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  31. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  32. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  33. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  34. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  35. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
  36. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  37. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  38. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  39. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  40. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/main.py +0 -0
  41. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/models.py +0 -0
  42. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/resolvers.py +0 -0
  43. {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.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.17.1
3
+ Version: 0.19.0
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -56,10 +56,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
56
56
  ## Installation
57
57
 
58
58
  ```bash
59
- pip install crowdtime-cli
59
+ pipx install crowdtime-cli
60
60
  ```
61
61
 
62
- Requires Python 3.11+
62
+ Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
63
+
64
+ Prefer pip? Install into a virtualenv:
65
+
66
+ ```bash
67
+ python3 -m venv ~/.venvs/crowdtime
68
+ ~/.venvs/crowdtime/bin/pip install crowdtime-cli
69
+ ```
70
+
71
+ To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
63
72
 
64
73
  ## Quick Start
65
74
 
@@ -21,10 +21,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
21
21
  ## Installation
22
22
 
23
23
  ```bash
24
- pip install crowdtime-cli
24
+ pipx install crowdtime-cli
25
25
  ```
26
26
 
27
- Requires Python 3.11+
27
+ Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
28
+
29
+ Prefer pip? Install into a virtualenv:
30
+
31
+ ```bash
32
+ python3 -m venv ~/.venvs/crowdtime
33
+ ~/.venvs/crowdtime/bin/pip install crowdtime-cli
34
+ ```
35
+
36
+ To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
28
37
 
29
38
  ## Quick Start
30
39
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.17.1"
3
+ version = "0.19.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.17.1"
3
+ __version__ = "0.19.0"
@@ -5,6 +5,8 @@ Stores API tokens in the system keyring, with file-based fallback.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import os
9
+ import sys
8
10
  from pathlib import Path
9
11
 
10
12
  from .config import CONFIG_DIR
@@ -51,8 +53,23 @@ def clear_token() -> None:
51
53
  def _save_token_file(token: str) -> None:
52
54
  """File-based fallback for token storage."""
53
55
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
54
- CREDENTIALS_FILE.write_text(token)
55
- CREDENTIALS_FILE.chmod(0o600)
56
+ # Created owner-only from the start — writing first and tightening the
57
+ # mode afterwards leaves the token world-readable in between.
58
+ fd = os.open(CREDENTIALS_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ try:
60
+ # The mode above only applies to a new file; an existing one keeps
61
+ # whatever it had, so tighten it before the token goes in.
62
+ if hasattr(os, "fchmod"):
63
+ os.fchmod(fd, 0o600)
64
+ except OSError:
65
+ pass
66
+ with os.fdopen(fd, "w") as f:
67
+ f.write(token)
68
+ print(
69
+ "Warning: no system keyring available — the API token was stored in "
70
+ f"plain text at {CREDENTIALS_FILE} (readable only by you).",
71
+ file=sys.stderr,
72
+ )
56
73
 
57
74
 
58
75
  def _get_token_file() -> str | None:
@@ -7,16 +7,39 @@ and friendly error handling.
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import sys
10
11
  from typing import Any
11
12
 
12
13
  import httpx
13
14
  from rich.console import Console
15
+ from rich.markup import escape
14
16
 
15
17
  from .auth import get_token
16
- from .config import get_config
18
+ from .config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
17
19
 
18
20
  console = Console(stderr=True)
19
21
  _DEBUG = os.environ.get("CROWDTIME_DEBUG", "").lower() in ("1", "true", "yes")
22
+ # One warning per invocation, however many clients a command creates.
23
+ _warned_insecure_url = False
24
+
25
+
26
+ def _error_message(detail: Any) -> str:
27
+ """Turn an API error body into one readable line.
28
+
29
+ Validation errors arrive as ``{"field": ["message", ...]}``; printing the
30
+ dict verbatim buries the reason the server gave.
31
+ """
32
+ if isinstance(detail, dict):
33
+ if isinstance(detail.get("detail"), str):
34
+ return detail["detail"]
35
+ parts = []
36
+ for field, value in detail.items():
37
+ text = "; ".join(str(v) for v in value) if isinstance(value, (list, tuple)) else str(value)
38
+ parts.append(text if field in ("non_field_errors", "detail") else f"{field}: {text}")
39
+ return " | ".join(parts) if parts else str(detail)
40
+ if isinstance(detail, list):
41
+ return "; ".join(str(item) for item in detail)
42
+ return str(detail)
20
43
 
21
44
 
22
45
  class APIError(Exception):
@@ -37,6 +60,14 @@ class CrowdTimeClient:
37
60
  self.base_url = self.config.server_url
38
61
  self.token = get_token()
39
62
  self.org_slug = self.config.organization
63
+ # Extra line a command can set before a call, shown if the server
64
+ # answers 403 — for pointing at the command that would work instead.
65
+ self.forbidden_hint: str | None = None
66
+
67
+ global _warned_insecure_url
68
+ if not _warned_insecure_url and is_insecure_server_url(self.base_url):
69
+ _warned_insecure_url = True
70
+ print(INSECURE_SERVER_URL_WARNING.format(url=self.base_url), file=sys.stderr)
40
71
 
41
72
  if require_auth and not self.token:
42
73
  console.print(
@@ -102,6 +133,25 @@ class CrowdTimeClient:
102
133
  response = self._request("DELETE", url)
103
134
  return response
104
135
 
136
+ def current_user_id(self) -> str | None:
137
+ """The authenticated user's id, cached in the config after the first lookup.
138
+
139
+ ``ct auth login`` refreshes the cache and ``ct auth logout`` clears it,
140
+ so the cached id always belongs to the stored token.
141
+ """
142
+ cached = self.config.get("user.id")
143
+ if cached:
144
+ return str(cached)
145
+ try:
146
+ me = self.get("/auth/me/", org_scoped=False)
147
+ except APIError:
148
+ return None
149
+ user_id = me.get("id") if isinstance(me, dict) else None
150
+ if not user_id:
151
+ return None
152
+ self.config.set("user.id", str(user_id))
153
+ return str(user_id)
154
+
105
155
  def _request(self, method: str, url: str, **kwargs: Any) -> Any:
106
156
  """Execute an HTTP request with error handling."""
107
157
  if _DEBUG:
@@ -138,7 +188,20 @@ class CrowdTimeClient:
138
188
  raise SystemExit(1)
139
189
 
140
190
  if response.status_code == 403:
141
- console.print("[red]Permission denied.[/red] You don't have access to this resource.")
191
+ # The server says why (not your entry, already invoiced, no
192
+ # approval authority, read-only token…) — pass that on instead of
193
+ # a generic refusal.
194
+ reason = "You don't have access to this resource."
195
+ try:
196
+ body = response.json()
197
+ if isinstance(body, dict) and isinstance(body.get("detail"), str) and body["detail"]:
198
+ reason = body["detail"]
199
+ except Exception:
200
+ pass
201
+ console.print(f"[red]Permission denied.[/red] {escape(reason)}")
202
+ hint = getattr(self, "forbidden_hint", None)
203
+ if hint:
204
+ console.print(f"[dim]{escape(hint)}[/dim]")
142
205
  raise SystemExit(1)
143
206
 
144
207
  if response.status_code == 402:
@@ -179,8 +242,7 @@ class CrowdTimeClient:
179
242
  detail = response.json()
180
243
  except Exception:
181
244
  detail = response.text
182
- msg = detail.get("detail", str(detail)) if isinstance(detail, dict) else str(detail)
183
- raise APIError(msg, status_code=response.status_code, detail=detail)
245
+ raise APIError(_error_message(detail), status_code=response.status_code, detail=detail)
184
246
 
185
247
  if not response.content:
186
248
  return None
@@ -59,6 +59,7 @@ def _login_with_token(token: str, output_json: bool) -> None:
59
59
  client = CrowdTimeClient(require_auth=True, require_org=False)
60
60
  data = client.get("/api/v1/auth/me/", org_scoped=False)
61
61
  user = User(**data)
62
+ get_config().set("user.id", user.id) # read back by current_user_id()
62
63
 
63
64
  if output_json:
64
65
  print_json(user)
@@ -81,7 +82,14 @@ def _login_with_browser(output_json: bool) -> None:
81
82
  console.print(f"[dim]Server: {server_url}[/dim]")
82
83
  console.print(f"[dim]Waiting for authentication (up to 2 minutes)...[/dim]\n")
83
84
 
84
- result = run_oauth_flow(server_url)
85
+ def _show_code(code: str) -> None:
86
+ console.print(f"Verification code: [bold]{code}[/bold]")
87
+ console.print(
88
+ "[dim]The browser will ask you to authorize this login. "
89
+ "Only approve it if it shows the same code.[/dim]\n"
90
+ )
91
+
92
+ result = run_oauth_flow(server_url, on_verification_code=_show_code)
85
93
 
86
94
  if result is None:
87
95
  console.print()
@@ -111,6 +119,7 @@ def _login_with_browser(output_json: bool) -> None:
111
119
  client = CrowdTimeClient(require_auth=True, require_org=False)
112
120
  data = client.get("/api/v1/auth/me/", org_scoped=False)
113
121
  user = User(**data)
122
+ config.set("user.id", user.id) # read back by current_user_id()
114
123
 
115
124
  if output_json:
116
125
  print_json(user)
@@ -133,6 +142,7 @@ def logout() -> None:
133
142
  return
134
143
 
135
144
  clear_token()
145
+ get_config().set("user.id", "")
136
146
  format_success("Logged out. Token removed.")
137
147
 
138
148
 
@@ -84,7 +84,9 @@ def list_clients(
84
84
  for c in clients_list:
85
85
  client_id = str(c.get("id", ""))
86
86
  name = c.get("name", "")
87
- contact = c.get("contact_name", "") or "-"
87
+ # The primary contact record; contact_name is a legacy field (CRO-83).
88
+ primary = c.get("primary_contact") or {}
89
+ contact = primary.get("name") or c.get("contact_name", "") or "-"
88
90
  project_count = str(c.get("project_count", 0))
89
91
  is_archived = c.get("is_archived", False)
90
92
  status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
@@ -117,11 +119,30 @@ def show(
117
119
  console.print(f"\n[bold]{data.get('name')}[/bold]")
118
120
  console.print(f" ID: {data.get('id')}")
119
121
  console.print(f" Currency: {data.get('currency', '-')}")
120
- console.print(f" Contact: {data.get('contact_name', '-')}")
121
- if data.get("contact_email"):
122
- console.print(f" Email: {data['contact_email']}")
123
- if data.get("contact_phone"):
124
- console.print(f" Phone: {data['contact_phone']}")
122
+ contacts = data.get("contacts") or []
123
+ if contacts:
124
+ for contact in contacts:
125
+ line = f" Contact: {contact.get('name', '')}"
126
+ if contact.get("email"):
127
+ line += f" <{contact['email']}>"
128
+ if contact.get("phone"):
129
+ line += f" {contact['phone']}"
130
+ if contact.get("role"):
131
+ line += f" — {contact['role']}"
132
+ flags = [
133
+ label for key, label in (("is_primary", "primary"), ("receives_invoices", "invoices"))
134
+ if contact.get(key)
135
+ ]
136
+ if flags:
137
+ line += f" [dim]({', '.join(flags)})[/dim]"
138
+ console.print(line)
139
+ else:
140
+ # Legacy contact fields, only on clients that never got a contact record.
141
+ console.print(f" Contact: {data.get('contact_name') or '-'}")
142
+ if data.get("contact_email"):
143
+ console.print(f" Email: {data['contact_email']}")
144
+ if data.get("contact_phone"):
145
+ console.print(f" Phone: {data['contact_phone']}")
125
146
  if data.get("address"):
126
147
  console.print(f" Address: {data['address']}")
127
148
  status = "Archived" if data.get("is_archived") else "Active"
@@ -149,7 +170,7 @@ def create(
149
170
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
150
171
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
151
172
  ) -> None:
152
- """Create a new client.
173
+ """Create a new client. --contact/--email add its primary contact.
153
174
 
154
175
  Examples:
155
176
  ct clients create "Acme Corp"
@@ -157,13 +178,13 @@ def create(
157
178
  """
158
179
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
159
180
 
181
+ if contact_email and not contact_name:
182
+ format_error("--email needs --contact (the contact's name).")
183
+ raise typer.Exit(1)
184
+
160
185
  payload: dict = {"name": name}
161
186
  if currency:
162
187
  payload["currency"] = currency
163
- if contact_name:
164
- payload["contact_name"] = contact_name
165
- if contact_email:
166
- payload["contact_email"] = contact_email
167
188
  if address_line1:
168
189
  payload["address_line1"] = address_line1
169
190
  if address_line2:
@@ -180,10 +201,22 @@ def create(
180
201
  try:
181
202
  data = api_client.post("/clients/", data=payload)
182
203
 
204
+ # Contacts are records of their own, shown on the client page and used
205
+ # for invoicing — not the legacy contact_* fields on the client (CRO-83).
206
+ if contact_name:
207
+ contact = api_client.post(f"/clients/{data.get('id')}/contacts/", data={
208
+ "name": contact_name,
209
+ "email": contact_email or "",
210
+ "is_primary": True,
211
+ })
212
+ data["contacts"] = [contact]
213
+
183
214
  if output_json:
184
215
  print_json(data)
185
216
  else:
186
217
  format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
218
+ if contact_name:
219
+ console.print(f" Primary contact: {contact_name}" + (f" <{contact_email}>" if contact_email else ""))
187
220
  except APIError as e:
188
221
  format_error(e.message)
189
222
  raise typer.Exit(1)
@@ -203,7 +236,7 @@ def edit(
203
236
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
204
237
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
205
238
  ) -> None:
206
- """Edit an existing client.
239
+ """Edit an existing client. --contact/--email update its primary contact.
207
240
 
208
241
  Examples:
209
242
  ct clients edit <client-id> --name "New Name"
@@ -216,10 +249,6 @@ def edit(
216
249
  payload["name"] = name
217
250
  if currency is not None:
218
251
  payload["currency"] = currency
219
- if contact_name is not None:
220
- payload["contact_name"] = contact_name
221
- if contact_email is not None:
222
- payload["contact_email"] = contact_email
223
252
  if address_line1 is not None:
224
253
  payload["address_line1"] = address_line1
225
254
  if city is not None:
@@ -231,12 +260,35 @@ def edit(
231
260
  if country is not None:
232
261
  payload["country"] = country
233
262
 
234
- if not payload:
263
+ contact_payload: dict = {}
264
+ if contact_name is not None:
265
+ contact_payload["name"] = contact_name
266
+ if contact_email is not None:
267
+ contact_payload["email"] = contact_email
268
+
269
+ if not payload and not contact_payload:
235
270
  format_error("No changes specified. Use --name, --currency, --contact, etc.")
236
271
  raise typer.Exit(1)
237
272
 
238
273
  try:
239
- data = api_client.patch(f"/clients/{client_id}/", data=payload)
274
+ if payload:
275
+ data = api_client.patch(f"/clients/{client_id}/", data=payload)
276
+ else:
277
+ data = api_client.get(f"/clients/{client_id}/")
278
+
279
+ # Contacts are records of their own, shown on the client page and used
280
+ # for invoicing (CRO-83): update the primary one, or create it.
281
+ if contact_payload:
282
+ contacts = extract_results(api_client.get(f"/clients/{client_id}/contacts/"))
283
+ primary = next((c for c in contacts if c.get("is_primary")), contacts[0] if contacts else None)
284
+ if primary:
285
+ api_client.patch(f"/clients/{client_id}/contacts/{primary['id']}/", data=contact_payload)
286
+ elif contact_payload.get("name"):
287
+ api_client.post(f"/clients/{client_id}/contacts/", data={**contact_payload, "is_primary": True})
288
+ else:
289
+ format_error("This client has no contact yet — pass --contact (the contact's name) with --email.")
290
+ raise typer.Exit(1)
291
+ data = api_client.get(f"/clients/{client_id}/")
240
292
 
241
293
  if output_json:
242
294
  print_json(data)
@@ -8,7 +8,7 @@ import subprocess
8
8
  import typer
9
9
  from rich.console import Console
10
10
 
11
- from ..config import get_config
11
+ from ..config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
12
12
  from ..formatters import format_error, format_success, print_json
13
13
 
14
14
  app = typer.Typer(name="config", help="Manage CLI configuration.")
@@ -23,11 +23,13 @@ def config_set(
23
23
  """Set a configuration value.
24
24
 
25
25
  Examples:
26
- ct config set server.url https://api.crowdtime.io
26
+ ct config set server.url https://api.crowdtime.lat
27
27
  ct config set defaults.project myproject
28
28
  ct config set defaults.daily_target 7h
29
29
  """
30
30
  config = get_config()
31
+ if key == "server.url" and is_insecure_server_url(value):
32
+ typer.echo(INSECURE_SERVER_URL_WARNING.format(url=value), err=True)
31
33
  # Convert boolean strings
32
34
  if value.lower() in ("true", "yes", "1"):
33
35
  config.set(key, True)
@@ -571,7 +571,8 @@ def profitability_insights(
571
571
 
572
572
  Revenue is computed from billable time entries. Cost is derived from team
573
573
  compensation configs (hourly rate or prorated salary). Margin = Revenue - Cost.
574
- Requires manager+ role. Cost data requires payroll compensation configs.
574
+ Requires manager+ role. Cost and margin are shown to admins and owners only
575
+ (they derive from payroll); account managers get revenue and hours.
575
576
 
576
577
  Examples:
577
578
  ct insights profitability
@@ -671,7 +672,8 @@ def profitability_insights(
671
672
 
672
673
  if not has_cost:
673
674
  console.print(
674
- "[dim]ℹ Cost data unavailable — configure compensation in Payroll to see margins.[/dim]"
675
+ "[dim]ℹ Cost and margin are not shown. They derive from payroll, so they are "
676
+ "visible to admins and owners only, and need compensation configured in Payroll.[/dim]"
675
677
  )
676
678
 
677
679
  console.print()
@@ -277,8 +277,15 @@ def bulk_delete(
277
277
  if skipped:
278
278
  for s in skipped:
279
279
  console.print(f" [yellow]Skipped[/yellow] {s['id']}: {s['reason']}")
280
+ # Entries that aren't yours are not reported at all — the server
281
+ # treats them as not found.
282
+ unmatched = len(set(ids)) - deleted_count - len(skipped)
280
283
  if not deleted_count and not skipped:
281
284
  console.print("[dim]No matching entries found.[/dim]")
285
+ elif unmatched > 0:
286
+ console.print(
287
+ f" [dim]{unmatched} ID(s) not found (wrong ID, or not your entry).[/dim]"
288
+ )
282
289
  except APIError as e:
283
290
  format_error(e.message)
284
291
  raise typer.Exit(1)
@@ -374,11 +381,23 @@ def list_entries(
374
381
  task: Optional[str] = typer.Option(None, "--task", "-t", help="Filter by task name or ID."),
375
382
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
376
383
  help="Filter by billable status."),
377
- user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter by user ID (manager+ only)."),
384
+ user: Optional[str] = typer.Option(
385
+ None, "--user", "-u",
386
+ help="Filter by user ID. Only people whose time you can see: yourself, your direct "
387
+ "reports (manager), and anyone on projects you manage (project manager).",
388
+ ),
389
+ mine: bool = typer.Option(
390
+ False, "--mine",
391
+ help="Only your own entries. Managers and project managers otherwise also see "
392
+ "the time of the people and projects they manage.",
393
+ ),
378
394
  format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
379
395
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
380
396
  ) -> None:
381
- """List time entries with optional filters."""
397
+ """List time entries with optional filters.
398
+
399
+ The table and CSV show a User column whenever an entry isn't yours.
400
+ """
382
401
  effective_date = date
383
402
  if today_flag:
384
403
  effective_date = "today"
@@ -395,6 +414,7 @@ def list_entries(
395
414
  task=task,
396
415
  billable=billable,
397
416
  user=user,
417
+ mine=mine,
398
418
  format_output=format_output,
399
419
  output_json=output_json,
400
420
  )
@@ -410,6 +430,7 @@ def _list_entries(
410
430
  task: str | None = None,
411
431
  billable: bool | None = None,
412
432
  user: str | None = None,
433
+ mine: bool = False,
413
434
  format_output: str = "table",
414
435
  output_json: bool = False,
415
436
  ) -> None:
@@ -418,6 +439,14 @@ def _list_entries(
418
439
 
419
440
  client = CrowdTimeClient(require_auth=True, require_org=True)
420
441
 
442
+ # Known up front so other people's entries can be told apart (CRO-85).
443
+ me = client.current_user_id()
444
+ if mine:
445
+ if not me:
446
+ format_error("Could not resolve your user id. Run ct auth login again.")
447
+ raise typer.Exit(1)
448
+ user = me
449
+
421
450
  params: dict = {}
422
451
 
423
452
  if date_str:
@@ -477,7 +506,10 @@ def _list_entries(
477
506
  elif format_output == "csv":
478
507
  _print_csv(entries)
479
508
  else:
480
- format_entries_table(entries, show_date=True)
509
+ # Someone else's entry with no owner column looks like your own;
510
+ # the column appears as soon as the list holds one (CRO-85).
511
+ foreign = any(e.user and e.user != me for e in entries)
512
+ format_entries_table(entries, show_date=True, show_user=bool(user) or foreign)
481
513
  except APIError as e:
482
514
  format_error(e.message)
483
515
  raise typer.Exit(1)
@@ -485,13 +517,14 @@ def _list_entries(
485
517
 
486
518
  def _print_csv(entries: list[TimeEntry]) -> None:
487
519
  """Print entries as CSV (plain output, safe for piping)."""
488
- print("id,date,project,task,description,duration,billable,hourly_rate,billable_amount")
520
+ print("id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount")
489
521
  for e in entries:
490
522
  desc = (e.description or "").replace('"', '""')
523
+ user_name = (e.user_name or "").replace('"', '""')
491
524
  rate = getattr(e, "hourly_rate", None) or ""
492
525
  amount = getattr(e, "billable_amount", None) or ""
493
526
  print(
494
- f'{e.id},{e.date or ""},'
527
+ f'{e.id},{e.date or ""},"{user_name}",'
495
528
  f'{e.project_name or ""},{e.task_name or ""},'
496
529
  f'"{desc}",{e.duration or 0},'
497
530
  f'{"yes" if e.is_billable else "no"},'
@@ -231,7 +231,10 @@ def update_member(
231
231
  capacity: Optional[float] = typer.Option(None, "--capacity", help="Weekly capacity hours."),
232
232
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
233
233
  ) -> None:
234
- """Update an organization member's role, rate, or capacity.
234
+ """Update an organization member's role, rate, or capacity (admin+).
235
+
236
+ You cannot grant a role above your own, and only the owner can change
237
+ the owner's membership. Ownership is not assignable here.
235
238
 
236
239
  Examples:
237
240
  ct org update-member <user-id> --role project_manager
@@ -363,7 +366,7 @@ def revoke_invite(
363
366
  def invitations(
364
367
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
365
368
  ) -> None:
366
- """List pending invitations for the current organization."""
369
+ """List pending invitations for the current organization (admin+)."""
367
370
  client = CrowdTimeClient(require_auth=True, require_org=True)
368
371
 
369
372
  try:
@@ -649,7 +649,7 @@ def saved_delete(
649
649
  client = CrowdTimeClient(require_auth=True, require_org=True)
650
650
 
651
651
  if not force:
652
- if not typer.confirm(f"Delete saved report {report_id[:8]}…?"):
652
+ if not typer.confirm(f"Delete saved report {report_id}?"):
653
653
  console.print("[dim]Cancelled.[/dim]")
654
654
  raise typer.Exit(0)
655
655