crowdtime-cli 0.2.1__tar.gz → 0.3.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.2.1 → crowdtime_cli-0.3.0}/PKG-INFO +17 -3
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/README.md +16 -2
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/__init__.py +1 -1
- crowdtime_cli-0.3.0/src/crowdtime_cli/commands/clients_cmd.py +321 -0
- crowdtime_cli-0.3.0/src/crowdtime_cli/commands/expense_cmd.py +383 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/invoice_cmd.py +312 -17
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/projects_cmd.py +90 -1
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/main.py +22 -2
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +50 -6
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +246 -8
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +249 -19
- crowdtime_cli-0.2.1/src/crowdtime_cli/commands/clients_cmd.py +0 -150
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/.gitignore +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/LICENSE +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.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.3.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
|
|
@@ -36,14 +36,15 @@ Description-Content-Type: text/markdown
|
|
|
36
36
|
|
|
37
37
|
**AI-powered time tracking from your terminal.** A modern, developer-friendly alternative to Harvest.
|
|
38
38
|
|
|
39
|
-
Track time, manage projects, generate reports, submit timesheets, create invoices, and get AI-powered insights — all without leaving your terminal.
|
|
39
|
+
Track time, manage projects, log expenses, generate reports, submit timesheets, create invoices, and get AI-powered insights — all without leaving your terminal.
|
|
40
40
|
|
|
41
41
|
## Features
|
|
42
42
|
|
|
43
43
|
- **Fast time tracking** — Start/stop timers or log entries directly with natural duration formats (`2h30m`, `1:45`, `0.5d`)
|
|
44
44
|
- **AI-powered** — Natural language time entry, smart suggestions based on your patterns, and automatic standup/slack summaries
|
|
45
45
|
- **Timesheets** — Submit, approve, and reject timesheets with team overview for managers
|
|
46
|
-
- **
|
|
46
|
+
- **Expense tracking** — Log expenses with categories, vendors, receipts, and billable flags; include on invoices
|
|
47
|
+
- **Invoicing** — Create invoices from tracked time and expenses, send PDFs, record payments, manage recurring templates and retainers
|
|
47
48
|
- **Rich terminal UI** — Beautiful tables, dashboards, and reports powered by Rich
|
|
48
49
|
- **Multi-org support** — Switch between organizations seamlessly
|
|
49
50
|
- **Secure by default** — API tokens stored in your OS keychain, never in plain text
|
|
@@ -108,6 +109,8 @@ ct report --week
|
|
|
108
109
|
| `ct projects switch` | — | Set a default project for new entries |
|
|
109
110
|
| `ct clients list` | — | List clients |
|
|
110
111
|
| `ct clients create` | — | Create a new client |
|
|
112
|
+
| `ct clients contacts <id>` | — | List contacts for a client |
|
|
113
|
+
| `ct clients add-contact <id>` | — | Add a contact to a client |
|
|
111
114
|
| `ct tasks list` | — | List tasks (optionally filtered by project) |
|
|
112
115
|
| `ct tasks create` | — | Create a task and assign to a project |
|
|
113
116
|
| `ct favorites list` | — | List saved entry templates |
|
|
@@ -138,6 +141,17 @@ ct report --week
|
|
|
138
141
|
| `ct timesheet team` | Team overview: hours, entries, status per member (manager+) |
|
|
139
142
|
| `ct timesheet viewable-users` | List members you can view timesheets for |
|
|
140
143
|
|
|
144
|
+
### Expenses
|
|
145
|
+
|
|
146
|
+
| Command | Description |
|
|
147
|
+
|---------|-------------|
|
|
148
|
+
| `ct expense list` | List expenses (filter by --project, --category, --from/--to) |
|
|
149
|
+
| `ct expense create` | Create an expense (amount, vendor, date, category, project) |
|
|
150
|
+
| `ct expense edit <id>` | Edit an existing expense |
|
|
151
|
+
| `ct expense delete <id>` | Delete an expense |
|
|
152
|
+
| `ct expense categories` | List expense categories |
|
|
153
|
+
| `ct expense add-category` | Create a new expense category |
|
|
154
|
+
|
|
141
155
|
### Invoicing
|
|
142
156
|
|
|
143
157
|
| Command | Description |
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
**AI-powered time tracking from your terminal.** A modern, developer-friendly alternative to Harvest.
|
|
4
4
|
|
|
5
|
-
Track time, manage projects, generate reports, submit timesheets, create invoices, and get AI-powered insights — all without leaving your terminal.
|
|
5
|
+
Track time, manage projects, log expenses, generate reports, submit timesheets, create invoices, and get AI-powered insights — all without leaving your terminal.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Fast time tracking** — Start/stop timers or log entries directly with natural duration formats (`2h30m`, `1:45`, `0.5d`)
|
|
10
10
|
- **AI-powered** — Natural language time entry, smart suggestions based on your patterns, and automatic standup/slack summaries
|
|
11
11
|
- **Timesheets** — Submit, approve, and reject timesheets with team overview for managers
|
|
12
|
-
- **
|
|
12
|
+
- **Expense tracking** — Log expenses with categories, vendors, receipts, and billable flags; include on invoices
|
|
13
|
+
- **Invoicing** — Create invoices from tracked time and expenses, send PDFs, record payments, manage recurring templates and retainers
|
|
13
14
|
- **Rich terminal UI** — Beautiful tables, dashboards, and reports powered by Rich
|
|
14
15
|
- **Multi-org support** — Switch between organizations seamlessly
|
|
15
16
|
- **Secure by default** — API tokens stored in your OS keychain, never in plain text
|
|
@@ -74,6 +75,8 @@ ct report --week
|
|
|
74
75
|
| `ct projects switch` | — | Set a default project for new entries |
|
|
75
76
|
| `ct clients list` | — | List clients |
|
|
76
77
|
| `ct clients create` | — | Create a new client |
|
|
78
|
+
| `ct clients contacts <id>` | — | List contacts for a client |
|
|
79
|
+
| `ct clients add-contact <id>` | — | Add a contact to a client |
|
|
77
80
|
| `ct tasks list` | — | List tasks (optionally filtered by project) |
|
|
78
81
|
| `ct tasks create` | — | Create a task and assign to a project |
|
|
79
82
|
| `ct favorites list` | — | List saved entry templates |
|
|
@@ -104,6 +107,17 @@ ct report --week
|
|
|
104
107
|
| `ct timesheet team` | Team overview: hours, entries, status per member (manager+) |
|
|
105
108
|
| `ct timesheet viewable-users` | List members you can view timesheets for |
|
|
106
109
|
|
|
110
|
+
### Expenses
|
|
111
|
+
|
|
112
|
+
| Command | Description |
|
|
113
|
+
|---------|-------------|
|
|
114
|
+
| `ct expense list` | List expenses (filter by --project, --category, --from/--to) |
|
|
115
|
+
| `ct expense create` | Create an expense (amount, vendor, date, category, project) |
|
|
116
|
+
| `ct expense edit <id>` | Edit an existing expense |
|
|
117
|
+
| `ct expense delete <id>` | Delete an expense |
|
|
118
|
+
| `ct expense categories` | List expense categories |
|
|
119
|
+
| `ct expense add-category` | Create a new expense category |
|
|
120
|
+
|
|
107
121
|
### Invoicing
|
|
108
122
|
|
|
109
123
|
| Command | Description |
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Client commands: list, show, create, archive, contacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ..client import APIError, CrowdTimeClient
|
|
12
|
+
from ..formatters import format_error, format_success, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="clients", help="Manage clients.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_client(api_client: CrowdTimeClient, client_ref: str) -> str:
|
|
19
|
+
"""Resolve a client name or ID to a client UUID.
|
|
20
|
+
|
|
21
|
+
If client_ref looks like a UUID, return it directly.
|
|
22
|
+
Otherwise search by name and return the first exact match.
|
|
23
|
+
"""
|
|
24
|
+
# If it looks like a UUID, use it directly
|
|
25
|
+
if len(client_ref) == 36 and client_ref.count("-") == 4:
|
|
26
|
+
return client_ref
|
|
27
|
+
|
|
28
|
+
# Search by name
|
|
29
|
+
data = api_client.get("/clients/", params={"search": client_ref})
|
|
30
|
+
clients_list = data if isinstance(data, list) else data.get("results", [])
|
|
31
|
+
|
|
32
|
+
# Exact match (case-insensitive)
|
|
33
|
+
for c in clients_list:
|
|
34
|
+
if c.get("name", "").lower() == client_ref.lower():
|
|
35
|
+
return c["id"]
|
|
36
|
+
|
|
37
|
+
# Partial match — take first result if only one
|
|
38
|
+
if len(clients_list) == 1:
|
|
39
|
+
return clients_list[0]["id"]
|
|
40
|
+
|
|
41
|
+
if not clients_list:
|
|
42
|
+
raise APIError(f"Client '{client_ref}' not found.", status_code=404)
|
|
43
|
+
|
|
44
|
+
# Multiple matches — ambiguous
|
|
45
|
+
names = ", ".join(c.get("name", "") for c in clients_list[:5])
|
|
46
|
+
raise APIError(
|
|
47
|
+
f"Multiple clients match '{client_ref}': {names}. Please use the client ID instead.",
|
|
48
|
+
status_code=400,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("list")
|
|
53
|
+
def list_clients(
|
|
54
|
+
archived: bool = typer.Option(False, "--archived", "-a", help="Include archived clients."),
|
|
55
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""List all clients in the current organization."""
|
|
58
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
59
|
+
|
|
60
|
+
params: dict = {}
|
|
61
|
+
if archived:
|
|
62
|
+
params["is_archived"] = "true"
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
data = client.get("/clients/", params=params)
|
|
66
|
+
clients_list = data if isinstance(data, list) else data.get("results", [])
|
|
67
|
+
|
|
68
|
+
if output_json:
|
|
69
|
+
print_json(clients_list)
|
|
70
|
+
else:
|
|
71
|
+
if not clients_list:
|
|
72
|
+
console.print("[dim]No clients found.[/dim]")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
table = Table(show_header=True, header_style="bold")
|
|
76
|
+
table.add_column("ID", style="dim")
|
|
77
|
+
table.add_column("Name")
|
|
78
|
+
table.add_column("Contact")
|
|
79
|
+
table.add_column("Projects", justify="right")
|
|
80
|
+
table.add_column("Status")
|
|
81
|
+
|
|
82
|
+
for c in clients_list:
|
|
83
|
+
client_id = str(c.get("id", ""))
|
|
84
|
+
name = c.get("name", "")
|
|
85
|
+
contact = c.get("contact_name", "") or "-"
|
|
86
|
+
project_count = str(c.get("project_count", 0))
|
|
87
|
+
is_archived = c.get("is_archived", False)
|
|
88
|
+
status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
|
|
89
|
+
table.add_row(client_id, name, contact, project_count, status)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
except APIError as e:
|
|
93
|
+
format_error(e.message)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def show(
|
|
99
|
+
client_id: str = typer.Argument(..., help="Client ID."),
|
|
100
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Show details for a specific client."""
|
|
103
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
data = api_client.get(f"/clients/{client_id}/")
|
|
107
|
+
|
|
108
|
+
if output_json:
|
|
109
|
+
print_json(data)
|
|
110
|
+
else:
|
|
111
|
+
console.print(f"\n[bold]{data.get('name')}[/bold]")
|
|
112
|
+
console.print(f" ID: {data.get('id')}")
|
|
113
|
+
console.print(f" Currency: {data.get('currency', '-')}")
|
|
114
|
+
console.print(f" Contact: {data.get('contact_name', '-')}")
|
|
115
|
+
if data.get("contact_email"):
|
|
116
|
+
console.print(f" Email: {data['contact_email']}")
|
|
117
|
+
if data.get("contact_phone"):
|
|
118
|
+
console.print(f" Phone: {data['contact_phone']}")
|
|
119
|
+
if data.get("address"):
|
|
120
|
+
console.print(f" Address: {data['address']}")
|
|
121
|
+
status = "Archived" if data.get("is_archived") else "Active"
|
|
122
|
+
console.print(f" Status: {status}")
|
|
123
|
+
console.print()
|
|
124
|
+
except APIError as e:
|
|
125
|
+
if e.status_code == 404:
|
|
126
|
+
format_error(f"Client '{client_id}' not found.")
|
|
127
|
+
else:
|
|
128
|
+
format_error(e.message)
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def create(
|
|
134
|
+
name: str = typer.Argument(..., help="Client name."),
|
|
135
|
+
currency: Optional[str] = typer.Option(None, "--currency", help="Currency code (e.g. USD)."),
|
|
136
|
+
contact_name: Optional[str] = typer.Option(None, "--contact", help="Contact person name."),
|
|
137
|
+
contact_email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
|
|
138
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Create a new client."""
|
|
141
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
142
|
+
|
|
143
|
+
payload: dict = {"name": name}
|
|
144
|
+
if currency:
|
|
145
|
+
payload["currency"] = currency
|
|
146
|
+
if contact_name:
|
|
147
|
+
payload["contact_name"] = contact_name
|
|
148
|
+
if contact_email:
|
|
149
|
+
payload["contact_email"] = contact_email
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
data = api_client.post("/clients/", data=payload)
|
|
153
|
+
|
|
154
|
+
if output_json:
|
|
155
|
+
print_json(data)
|
|
156
|
+
else:
|
|
157
|
+
format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
|
|
158
|
+
except APIError as e:
|
|
159
|
+
format_error(e.message)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def archive(
|
|
165
|
+
client_id: str = typer.Argument(..., help="Client ID to archive."),
|
|
166
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Archive a client."""
|
|
169
|
+
if not force:
|
|
170
|
+
if not typer.confirm(f"Archive client '{client_id}'?"):
|
|
171
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
api_client.delete(f"/clients/{client_id}/")
|
|
178
|
+
format_success(f"Client '{client_id}' archived.")
|
|
179
|
+
except APIError as e:
|
|
180
|
+
if e.status_code == 404:
|
|
181
|
+
format_error(f"Client '{client_id}' not found.")
|
|
182
|
+
else:
|
|
183
|
+
format_error(e.message)
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ─── Contact subcommands ─────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command("contacts")
|
|
191
|
+
def list_contacts(
|
|
192
|
+
client_ref: str = typer.Argument(..., help="Client name or ID."),
|
|
193
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""List all contacts for a client.
|
|
196
|
+
|
|
197
|
+
Examples:
|
|
198
|
+
ct clients contacts "Acme Corp"
|
|
199
|
+
ct clients contacts <client-id>
|
|
200
|
+
"""
|
|
201
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
client_id = _resolve_client(api_client, client_ref)
|
|
205
|
+
data = api_client.get(f"/clients/{client_id}/contacts/")
|
|
206
|
+
contacts = data if isinstance(data, list) else data.get("results", [])
|
|
207
|
+
|
|
208
|
+
if output_json:
|
|
209
|
+
print_json(contacts)
|
|
210
|
+
else:
|
|
211
|
+
if not contacts:
|
|
212
|
+
console.print("[dim]No contacts found for this client.[/dim]")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
table = Table(show_header=True, header_style="bold")
|
|
216
|
+
table.add_column("ID", style="dim")
|
|
217
|
+
table.add_column("Name", width=20)
|
|
218
|
+
table.add_column("Email", width=25)
|
|
219
|
+
table.add_column("Phone", width=15)
|
|
220
|
+
table.add_column("Role", width=15)
|
|
221
|
+
table.add_column("Primary", justify="center", width=8)
|
|
222
|
+
|
|
223
|
+
for contact in contacts:
|
|
224
|
+
is_primary = contact.get("is_primary", False)
|
|
225
|
+
primary_label = "[green]Yes[/green]" if is_primary else "[dim]No[/dim]"
|
|
226
|
+
|
|
227
|
+
table.add_row(
|
|
228
|
+
str(contact.get("id", "")),
|
|
229
|
+
contact.get("name", ""),
|
|
230
|
+
contact.get("email", "") or "-",
|
|
231
|
+
contact.get("phone", "") or "-",
|
|
232
|
+
contact.get("role", "") or "-",
|
|
233
|
+
primary_label,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
console.print(table)
|
|
237
|
+
except APIError as e:
|
|
238
|
+
if e.status_code == 404:
|
|
239
|
+
format_error(f"Client '{client_ref}' not found.")
|
|
240
|
+
else:
|
|
241
|
+
format_error(e.message)
|
|
242
|
+
raise typer.Exit(1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("add-contact")
|
|
246
|
+
def add_contact(
|
|
247
|
+
client_ref: str = typer.Argument(..., help="Client name or ID."),
|
|
248
|
+
name: str = typer.Option(..., "--name", help="Contact person name."),
|
|
249
|
+
email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
|
|
250
|
+
phone: Optional[str] = typer.Option(None, "--phone", help="Contact phone."),
|
|
251
|
+
role: Optional[str] = typer.Option(None, "--role", help="Contact role/title."),
|
|
252
|
+
primary: bool = typer.Option(False, "--primary", help="Set as primary contact."),
|
|
253
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Add a contact to a client.
|
|
256
|
+
|
|
257
|
+
Examples:
|
|
258
|
+
ct clients add-contact "Acme Corp" --name "Jane Doe" --email jane@acme.com
|
|
259
|
+
ct clients add-contact <client-id> --name "John Smith" --role "CTO" --primary
|
|
260
|
+
"""
|
|
261
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
client_id = _resolve_client(api_client, client_ref)
|
|
265
|
+
|
|
266
|
+
payload: dict = {"name": name}
|
|
267
|
+
if email:
|
|
268
|
+
payload["email"] = email
|
|
269
|
+
if phone:
|
|
270
|
+
payload["phone"] = phone
|
|
271
|
+
if role:
|
|
272
|
+
payload["role"] = role
|
|
273
|
+
if primary:
|
|
274
|
+
payload["is_primary"] = True
|
|
275
|
+
|
|
276
|
+
data = api_client.post(f"/clients/{client_id}/contacts/", data=payload)
|
|
277
|
+
|
|
278
|
+
if output_json:
|
|
279
|
+
print_json(data)
|
|
280
|
+
else:
|
|
281
|
+
format_success(
|
|
282
|
+
f"Contact '{data.get('name', name)}' added to client "
|
|
283
|
+
f"(ID: {data.get('id', '')})"
|
|
284
|
+
)
|
|
285
|
+
except APIError as e:
|
|
286
|
+
if e.status_code == 404:
|
|
287
|
+
format_error(f"Client '{client_ref}' not found.")
|
|
288
|
+
else:
|
|
289
|
+
format_error(e.message)
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.command("remove-contact")
|
|
294
|
+
def remove_contact(
|
|
295
|
+
client_ref: str = typer.Argument(..., help="Client name or ID."),
|
|
296
|
+
contact_id: str = typer.Argument(..., help="Contact ID to remove."),
|
|
297
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Remove a contact from a client.
|
|
300
|
+
|
|
301
|
+
Examples:
|
|
302
|
+
ct clients remove-contact "Acme Corp" <contact-id>
|
|
303
|
+
ct clients remove-contact <client-id> <contact-id> --force
|
|
304
|
+
"""
|
|
305
|
+
if not force:
|
|
306
|
+
if not typer.confirm(f"Remove contact '{contact_id}'?"):
|
|
307
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
client_id = _resolve_client(api_client, client_ref)
|
|
314
|
+
api_client.delete(f"/clients/{client_id}/contacts/{contact_id}/")
|
|
315
|
+
format_success(f"Contact '{contact_id}' removed.")
|
|
316
|
+
except APIError as e:
|
|
317
|
+
if e.status_code == 404:
|
|
318
|
+
format_error(f"Client or contact not found.")
|
|
319
|
+
else:
|
|
320
|
+
format_error(e.message)
|
|
321
|
+
raise typer.Exit(1)
|