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.
Files changed (35) hide show
  1. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/PKG-INFO +17 -3
  2. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/README.md +16 -2
  3. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/__init__.py +1 -1
  5. crowdtime_cli-0.3.0/src/crowdtime_cli/commands/clients_cmd.py +321 -0
  6. crowdtime_cli-0.3.0/src/crowdtime_cli/commands/expense_cmd.py +383 -0
  7. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/invoice_cmd.py +312 -17
  8. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/projects_cmd.py +90 -1
  9. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/main.py +22 -2
  10. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +50 -6
  11. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +246 -8
  12. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +249 -19
  13. crowdtime_cli-0.2.1/src/crowdtime_cli/commands/clients_cmd.py +0 -150
  14. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/.gitignore +0 -0
  15. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/LICENSE +0 -0
  16. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/auth.py +0 -0
  17. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/client.py +0 -0
  18. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  19. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  20. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  21. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  22. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  23. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  24. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  25. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  26. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  27. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  28. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  29. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  30. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/config.py +0 -0
  31. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/formatters.py +0 -0
  32. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/models.py +0 -0
  33. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/oauth.py +0 -0
  34. {crowdtime_cli-0.2.1 → crowdtime_cli-0.3.0}/src/crowdtime_cli/resolvers.py +0 -0
  35. {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.2.1
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
- - **Invoicing** — Create invoices from tracked time, send PDFs, record payments, manage recurring templates and retainers
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
- - **Invoicing** — Create invoices from tracked time, send PDFs, record payments, manage recurring templates and retainers
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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.2.1"
3
+ version = "0.3.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.2.1"
3
+ __version__ = "0.3.0"
@@ -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)