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.
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/PKG-INFO +12 -3
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/README.md +11 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/auth.py +19 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/client.py +66 -4
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/auth_cmd.py +11 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/config_cmd.py +4 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/insights_cmd.py +4 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/log_cmd.py +38 -5
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/org_cmd.py +5 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timesheet_cmd.py +67 -17
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/version_cmd.py +2 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/config.py +16 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/formatters.py +25 -4
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/oauth.py +25 -3
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +52 -30
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +70 -49
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +8 -6
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +45 -23
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/version_check.py +17 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/.gitignore +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/LICENSE +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.19.0}/src/crowdtime_cli/resolvers.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|