crowdtime-cli 0.8.2__tar.gz → 0.10.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 (46) hide show
  1. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/.gitignore +6 -0
  2. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/PKG-INFO +1 -1
  3. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/pyproject.toml +1 -1
  4. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/__init__.py +1 -1
  5. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/client.py +6 -0
  6. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/ai_cmd.py +42 -22
  7. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/auth_cmd.py +97 -1
  8. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/billing_cmd.py +19 -24
  9. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/clients_cmd.py +101 -7
  10. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/expense_cmd.py +74 -32
  11. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/favorites_cmd.py +257 -0
  12. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/insights_cmd.py +683 -0
  13. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/invoice_cmd.py +998 -81
  14. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/log_cmd.py +550 -0
  15. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/org_cmd.py +410 -0
  16. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/payroll_cmd.py +68 -84
  17. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/projects_cmd.py +673 -0
  18. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/report_cmd.py +705 -0
  19. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/tasks_cmd.py +254 -0
  20. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/timer_cmd.py +362 -0
  21. crowdtime_cli-0.10.0/src/crowdtime_cli/commands/timesheet_cmd.py +986 -0
  22. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/config.py +2 -0
  23. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/formatters.py +198 -28
  24. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/main.py +86 -3
  25. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/models.py +60 -2
  26. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/resolvers.py +50 -11
  27. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +130 -12
  28. crowdtime_cli-0.10.0/src/crowdtime_cli/skills/crowdtime/references/commands.md +2469 -0
  29. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +501 -21
  30. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/utils.py +106 -0
  31. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/favorites_cmd.py +0 -128
  32. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/log_cmd.py +0 -298
  33. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/org_cmd.py +0 -235
  34. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/projects_cmd.py +0 -264
  35. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/report_cmd.py +0 -242
  36. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/tasks_cmd.py +0 -101
  37. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/timer_cmd.py +0 -207
  38. crowdtime_cli-0.8.2/src/crowdtime_cli/commands/timesheet_cmd.py +0 -384
  39. crowdtime_cli-0.8.2/src/crowdtime_cli/skills/crowdtime/references/commands.md +0 -1370
  40. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/LICENSE +0 -0
  41. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/README.md +0 -0
  42. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/auth.py +0 -0
  43. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  44. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  45. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  46. {crowdtime_cli-0.8.2 → crowdtime_cli-0.10.0}/src/crowdtime_cli/oauth.py +0 -0
@@ -49,6 +49,12 @@ docker-compose.override.yml
49
49
  # Claude Code
50
50
  .claude/
51
51
 
52
+ # Ralph Loop (runtime state)
53
+ .ralph-done
54
+ .ralph-session
55
+ ralph.log
56
+ QUESTIONS.md
57
+
52
58
  # Frontend
53
59
  node_modules/
54
60
  .next/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.8.2
3
+ Version: 0.10.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.8.2"
3
+ version = "0.10.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.8.2"
3
+ __version__ = "0.10.0"
@@ -6,6 +6,7 @@ and friendly error handling.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  from typing import Any
10
11
 
11
12
  import httpx
@@ -15,6 +16,7 @@ from .auth import get_token
15
16
  from .config import get_config
16
17
 
17
18
  console = Console(stderr=True)
19
+ _DEBUG = os.environ.get("CROWDTIME_DEBUG", "").lower() in ("1", "true", "yes")
18
20
 
19
21
 
20
22
  class APIError(Exception):
@@ -102,8 +104,12 @@ class CrowdTimeClient:
102
104
 
103
105
  def _request(self, method: str, url: str, **kwargs: Any) -> Any:
104
106
  """Execute an HTTP request with error handling."""
107
+ if _DEBUG:
108
+ console.print(f"[dim]→ {method} {url}[/dim]", highlight=False)
105
109
  try:
106
110
  response = self._client.request(method, url, **kwargs)
111
+ if _DEBUG:
112
+ console.print(f"[dim]← {response.status_code}[/dim]", highlight=False)
107
113
  return self._handle_response(response)
108
114
  except httpx.ConnectError:
109
115
  console.print(
@@ -9,10 +9,12 @@ from rich.console import Console
9
9
 
10
10
  from ..client import APIError, CrowdTimeClient
11
11
  from ..formatters import (
12
+ extract_results,
12
13
  format_error,
13
14
  format_parse_result,
14
15
  format_success,
15
16
  format_suggestions,
17
+ format_warning,
16
18
  print_json,
17
19
  )
18
20
  from ..models import ParseResult, Suggestion, TimeEntry, WeeklySummary
@@ -26,7 +28,7 @@ console = Console()
26
28
  def parse(
27
29
  text: str = typer.Argument(..., help="Natural language time entry description."),
28
30
  dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be created without saving."),
29
- yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
31
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
30
32
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
31
33
  ) -> None:
32
34
  """Parse natural language into a time entry.
@@ -34,7 +36,7 @@ def parse(
34
36
  Examples:
35
37
  ct ai parse "2 hours on project alpha doing code review"
36
38
  ct ai parse "spent yesterday afternoon on bug fixes for client X"
37
- ct ai parse "30min standup" --yes
39
+ ct ai parse "30min standup" --force
38
40
  """
39
41
  client = CrowdTimeClient(require_auth=True, require_org=True)
40
42
 
@@ -52,7 +54,7 @@ def parse(
52
54
  console.print("[dim]Dry run - not saving.[/dim]")
53
55
  return
54
56
 
55
- if not yes:
57
+ if not force:
56
58
  if not typer.confirm("Create this entry?"):
57
59
  console.print("[dim]Cancelled.[/dim]")
58
60
  return
@@ -60,12 +62,18 @@ def parse(
60
62
  # Confirm and create the entry
61
63
  confirm_data = client.post("/ai/parse/confirm/", data={
62
64
  "parse_log_id": result.parse_log_id,
63
- "parsed_result": result.parsed_result,
65
+ **result.parsed_fields,
64
66
  })
65
- entry = TimeEntry(**confirm_data)
66
- format_success("Entry created from AI parse")
67
- from ..formatters import format_entry_summary
68
- format_entry_summary(entry)
67
+
68
+ # Response may be a full TimeEntry or a minimal {detail, time_entry_id}
69
+ if "id" in confirm_data and "project_name" in confirm_data:
70
+ entry = TimeEntry(**confirm_data)
71
+ format_success("Entry created from AI parse")
72
+ from ..formatters import format_entry_summary
73
+ format_entry_summary(entry)
74
+ else:
75
+ entry_id = confirm_data.get("time_entry_id", "")
76
+ format_success(f"Entry created from AI parse (ID: {entry_id})")
69
77
 
70
78
  except APIError as e:
71
79
  format_error(e.message)
@@ -74,7 +82,7 @@ def parse(
74
82
 
75
83
  @app.command()
76
84
  def suggest(
77
- date: Optional[str] = typer.Option(None, "--date", "-d", help="Date for suggestions."),
85
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Date for suggestions (YYYY-MM-DD)."),
78
86
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
79
87
  ) -> None:
80
88
  """Get AI-powered entry suggestions based on your patterns.
@@ -93,7 +101,7 @@ def suggest(
93
101
 
94
102
  try:
95
103
  data = client.get("/ai/suggestions/", params=params)
96
- suggestions_list = data if isinstance(data, list) else data.get("results", [])
104
+ suggestions_list = extract_results(data)
97
105
  suggestions = [Suggestion(**item) for item in suggestions_list]
98
106
 
99
107
  if output_json:
@@ -108,15 +116,19 @@ def suggest(
108
116
  default="",
109
117
  show_default=False,
110
118
  )
111
- if choice.strip().isdigit():
119
+ if not choice.strip():
120
+ console.print("[dim]Skipped.[/dim]")
121
+ elif choice.strip().isdigit():
112
122
  idx = int(choice.strip()) - 1
113
123
  if 0 <= idx < len(suggestions):
114
124
  s = suggestions[idx]
115
- payload: dict = {"description": s.description}
116
- if s.project_name:
117
- payload["project"] = s.project_name
118
- if s.task_name:
119
- payload["task"] = s.task_name
125
+ if not s.project_id:
126
+ format_error("Suggestion has no project ID. Use 'ct timer start -p <project>' manually.")
127
+ raise typer.Exit(1)
128
+ payload: dict = {"notes": s.description}
129
+ payload["project_id"] = s.project_id
130
+ if s.task_id:
131
+ payload["task_id"] = s.task_id
120
132
 
121
133
  start_data = client.post("/time/start/", data=payload)
122
134
  entry = TimeEntry(**start_data)
@@ -125,6 +137,8 @@ def suggest(
125
137
  format_timer(entry)
126
138
  else:
127
139
  format_error("Invalid selection.")
140
+ else:
141
+ console.print("[dim]Skipped.[/dim]")
128
142
 
129
143
  except APIError as e:
130
144
  format_error(e.message)
@@ -134,7 +148,7 @@ def suggest(
134
148
  @app.command()
135
149
  def summarize(
136
150
  today_flag: bool = typer.Option(False, "--today", help="Summarize today."),
137
- week: bool = typer.Option(True, "--week", "-w", help="Summarize this week (default)."),
151
+ week: bool = typer.Option(False, "--week", "-w", help="Summarize this week."),
138
152
  month: bool = typer.Option(False, "--month", "-m", help="Summarize this month."),
139
153
  for_format: str = typer.Option("report", "--for", help="Format for: standup, report, slack."),
140
154
  copy: bool = typer.Option(False, "--copy", "-c", help="Copy to clipboard."),
@@ -194,17 +208,23 @@ def summarize(
194
208
  ["pbcopy"], stdin=subprocess.PIPE, text=True
195
209
  )
196
210
  process.communicate(text_to_copy)
197
- format_success("Copied to clipboard")
198
- except Exception:
211
+ if process.returncode == 0:
212
+ format_success("Copied to clipboard")
213
+ else:
214
+ format_warning("Clipboard copy may have failed.")
215
+ except OSError:
199
216
  try:
200
217
  process = subprocess.Popen(
201
218
  ["xclip", "-selection", "clipboard"],
202
219
  stdin=subprocess.PIPE, text=True
203
220
  )
204
221
  process.communicate(text_to_copy)
205
- format_success("Copied to clipboard")
206
- except Exception:
207
- console.print("[dim]Could not copy to clipboard.[/dim]")
222
+ if process.returncode == 0:
223
+ format_success("Copied to clipboard")
224
+ else:
225
+ format_warning("Clipboard copy may have failed.")
226
+ except OSError:
227
+ format_warning("Could not copy to clipboard (pbcopy/xclip not found).")
208
228
 
209
229
  except APIError as e:
210
230
  format_error(e.message)
@@ -1,7 +1,8 @@
1
- """Authentication commands: login, logout, whoami."""
1
+ """Authentication commands: login, logout, whoami, data-export, delete-account."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from typing import Optional
6
7
 
7
8
  import typer
@@ -158,3 +159,98 @@ def whoami(
158
159
  else:
159
160
  console.print(" Org: [dim]Not set (run ct org switch <slug>)[/dim]")
160
161
  console.print()
162
+
163
+
164
+ @app.command("data-export")
165
+ def data_export(
166
+ output_file: Optional[str] = typer.Option(
167
+ None, "--output", "-o",
168
+ help="Write export to file instead of stdout.",
169
+ ),
170
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON (default for this command)."),
171
+ ) -> None:
172
+ """Export all your personal data (GDPR data portability).
173
+
174
+ Downloads a JSON dump of all your data across all organizations:
175
+ time entries, timesheets, favorites, expenses, API tokens, etc.
176
+ """
177
+ client = CrowdTimeClient(require_auth=True, require_org=False)
178
+ data = client.get("/api/v1/auth/data-export/", org_scoped=False)
179
+
180
+ formatted = json.dumps(data, indent=2, default=str)
181
+
182
+ if output_file:
183
+ with open(output_file, "w") as f:
184
+ f.write(formatted)
185
+ format_success(f"Data exported to {output_file}")
186
+ console.print(f" [dim]{len(data.get('time_entries', []))} time entries, "
187
+ f"{len(data.get('timesheets', []))} timesheets, "
188
+ f"{len(data.get('favorites', []))} favorites[/dim]")
189
+ else:
190
+ console.print(formatted)
191
+
192
+
193
+ @app.command("delete-account")
194
+ def delete_account(
195
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
196
+ cancel: bool = typer.Option(False, "--cancel", help="Cancel a pending account deletion."),
197
+ status_check: bool = typer.Option(False, "--status", help="Check deletion status."),
198
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
199
+ ) -> None:
200
+ """Request or cancel account deletion (GDPR right to erasure).
201
+
202
+ Schedules your account for permanent deletion after a 30-day grace period.
203
+ During the grace period, you can cancel with --cancel.
204
+ """
205
+ client = CrowdTimeClient(require_auth=True, require_org=False)
206
+
207
+ if status_check:
208
+ data = client.get("/api/v1/auth/delete-account/", org_scoped=False)
209
+ if output_json:
210
+ console.print(json.dumps(data, indent=2))
211
+ elif data.get("scheduled"):
212
+ console.print(f"[yellow]Account deletion scheduled[/yellow]")
213
+ console.print(f" Scheduled at: {data['scheduled_at']}")
214
+ console.print(f" Deletes after: {data['delete_after']}")
215
+ else:
216
+ console.print("[dim]No pending account deletion.[/dim]")
217
+ return
218
+
219
+ if cancel:
220
+ client.delete("/api/v1/auth/delete-account/", org_scoped=False)
221
+ format_success("Account deletion cancelled.")
222
+ return
223
+
224
+ # Request deletion
225
+ if not force and not output_json:
226
+ console.print("\n[bold red]⚠ Account Deletion[/bold red]")
227
+ console.print("This will permanently delete your account and all associated data after 30 days.")
228
+ console.print("During the grace period, you can cancel with: ct auth delete-account --cancel\n")
229
+ if not typer.confirm("Are you sure you want to delete your account?"):
230
+ console.print("[dim]Cancelled.[/dim]")
231
+ raise typer.Exit(0)
232
+
233
+ # Build request body
234
+ from ..auth import get_token as _get_token
235
+ body = {}
236
+
237
+ # Check if user has a password (try password-based confirmation)
238
+ me_data = client.get("/api/v1/auth/change-password/", org_scoped=False)
239
+ if me_data.get("has_password"):
240
+ if output_json or force:
241
+ password = typer.prompt("Password", hide_input=True)
242
+ else:
243
+ password = typer.prompt("Enter your password to confirm", hide_input=True)
244
+ body["password"] = password
245
+ else:
246
+ body["confirm"] = True
247
+
248
+ data = client.post("/api/v1/auth/delete-account/", data=body, org_scoped=False)
249
+
250
+ if output_json:
251
+ console.print(json.dumps(data, indent=2))
252
+ else:
253
+ console.print(f"\n[yellow]{data.get('detail', 'Account deletion scheduled.')}[/yellow]")
254
+ if data.get("delete_after"):
255
+ console.print(f" Deletes after: {data['delete_after']}")
256
+ console.print(f"\n To cancel: [bold]ct auth delete-account --cancel[/bold]\n")
@@ -12,7 +12,8 @@ from rich.panel import Panel
12
12
  from rich.table import Table
13
13
 
14
14
  from ..client import APIError, CrowdTimeClient
15
- from ..formatters import format_error, format_success, print_json
15
+ from ..formatters import extract_results, format_currency, format_error, format_success, print_json
16
+ from ..utils import format_iso_date
16
17
 
17
18
  app = typer.Typer(name="billing", help="Manage subscription and billing.")
18
19
  console = Console()
@@ -55,22 +56,12 @@ def _cents_to_dollars(cents: int | str | None) -> Decimal:
55
56
  return Decimal("0")
56
57
  try:
57
58
  return Decimal(str(cents)) / Decimal("100")
58
- except Exception:
59
+ except (ValueError, ArithmeticError):
59
60
  return Decimal("0")
60
61
 
61
62
 
62
- def _format_currency(amount: Decimal) -> str:
63
- """Format a Decimal dollar amount for display."""
64
- return f"${amount:,.2f}"
65
63
 
66
64
 
67
- def _format_date(date_str: str | None) -> str:
68
- """Format an ISO date string for display (YYYY-MM-DD)."""
69
- if not date_str:
70
- return ""
71
- # Handle datetime strings by taking just the date part
72
- return date_str[:10]
73
-
74
65
 
75
66
  # ─── Commands ─────────────────────────────────────────────────────
76
67
 
@@ -131,9 +122,9 @@ def status(
131
122
  per_seat_dollars = _cents_to_dollars(data.get("per_seat_amount"))
132
123
  total_dollars = _cents_to_dollars(data.get("total_amount"))
133
124
 
134
- period_end = _format_date(data.get("current_period_end"))
125
+ period_end = format_iso_date(data.get("current_period_end"), placeholder="")
135
126
  cancel_at_end = data.get("cancel_at_period_end", False)
136
- trial_ends = _format_date(data.get("trial_ends_at"))
127
+ trial_ends = format_iso_date(data.get("trial_ends_at"), placeholder="")
137
128
 
138
129
  # Calculate next billing amount (total * months in period)
139
130
  # For monthly it's the same; for annual it's total * 12
@@ -154,15 +145,19 @@ def status(
154
145
  table.add_row("Status", f"[{color}]{label}[/{color}]")
155
146
  table.add_row("Plan", PLAN_LABELS.get(plan, plan.capitalize()))
156
147
  table.add_row("Seats", str(seat_count))
157
- table.add_row("Base", f"{_format_currency(base_dollars)}/mo")
158
- table.add_row("Per seat", f"{_format_currency(per_seat_dollars)}/mo")
159
- table.add_row("Total", f"{_format_currency(total_dollars)}/mo")
148
+ table.add_row("Base", f"{format_currency(base_dollars)}/mo")
149
+ table.add_row("Per seat", f"{format_currency(per_seat_dollars)}/mo")
150
+ table.add_row("Total", f"{format_currency(total_dollars)}/mo")
160
151
 
161
152
  if sub_status == "trialing" and trial_ends:
162
- table.add_row("Trial ends", trial_ends)
153
+ is_usable = data.get("is_usable", False)
154
+ if not is_usable:
155
+ table.add_row("Trial ended", f"[red]{trial_ends}[/red]")
156
+ else:
157
+ table.add_row("Trial ends", trial_ends)
163
158
  elif period_end:
164
159
  table.add_row("Next bill", period_end)
165
- table.add_row("Next amount", _format_currency(next_amount))
160
+ table.add_row("Next amount", format_currency(next_amount))
166
161
 
167
162
  if cancel_at_end:
168
163
  table.add_row("", "[yellow]Cancels at period end[/yellow]")
@@ -338,9 +333,9 @@ def plan(
338
333
 
339
334
  total = _cents_to_dollars(data.get("total_amount"))
340
335
  if new_plan == "annual":
341
- console.print(f" [dim]Billed annually at {_format_currency(total * 12)}/yr[/dim]")
336
+ console.print(f" [dim]Billed annually at {format_currency(total * 12)}/yr[/dim]")
342
337
  else:
343
- console.print(f" [dim]Billed at {_format_currency(total)}/mo[/dim]")
338
+ console.print(f" [dim]Billed at {format_currency(total)}/mo[/dim]")
344
339
 
345
340
 
346
341
  @app.command("reactivate")
@@ -377,7 +372,7 @@ def reactivate(
377
372
  return
378
373
 
379
374
  format_success("Subscription reactivated.")
380
- period_end = _format_date(data.get("current_period_end"))
375
+ period_end = format_iso_date(data.get("current_period_end"), placeholder="")
381
376
  if period_end:
382
377
  console.print(f" [dim]Next renewal: {period_end}[/dim]")
383
378
 
@@ -412,7 +407,7 @@ def events(
412
407
  raise typer.Exit(1)
413
408
 
414
409
  # Handle both paginated and flat list responses
415
- event_list = data if isinstance(data, list) else data.get("results", data.get("data", []))
410
+ event_list = extract_results(data) or (data.get("data", []) if isinstance(data, dict) else [])
416
411
  event_list = event_list[:limit]
417
412
 
418
413
  if output_json:
@@ -429,7 +424,7 @@ def events(
429
424
  table.add_column("Status", width=10)
430
425
 
431
426
  for event in event_list:
432
- date = _format_date(event.get("created_at"))
427
+ date = format_iso_date(event.get("created_at"))
433
428
  event_type = event.get("event_type", "unknown")
434
429
  processed = event.get("processed", False)
435
430
  error = event.get("error", "")
@@ -9,7 +9,8 @@ from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
11
  from ..client import APIError, CrowdTimeClient
12
- from ..formatters import format_error, format_success, print_json
12
+ from ..formatters import extract_results, format_error, format_success, print_json
13
+ from ..utils import is_uuid
13
14
 
14
15
  app = typer.Typer(name="clients", help="Manage clients.")
15
16
  console = Console()
@@ -22,12 +23,12 @@ def _resolve_client(api_client: CrowdTimeClient, client_ref: str) -> str:
22
23
  Otherwise search by name and return the first exact match.
23
24
  """
24
25
  # If it looks like a UUID, use it directly
25
- if len(client_ref) == 36 and client_ref.count("-") == 4:
26
+ if is_uuid(client_ref):
26
27
  return client_ref
27
28
 
28
29
  # Search by name
29
30
  data = api_client.get("/clients/", params={"search": client_ref})
30
- clients_list = data if isinstance(data, list) else data.get("results", [])
31
+ clients_list = extract_results(data)
31
32
 
32
33
  # Exact match (case-insensitive)
33
34
  for c in clients_list:
@@ -63,13 +64,14 @@ def list_clients(
63
64
 
64
65
  try:
65
66
  data = client.get("/clients/", params=params)
66
- clients_list = data if isinstance(data, list) else data.get("results", [])
67
+ clients_list = extract_results(data)
67
68
 
68
69
  if output_json:
69
70
  print_json(clients_list)
70
71
  else:
71
72
  if not clients_list:
72
73
  console.print("[dim]No clients found.[/dim]")
74
+ console.print("Run [bold]ct clients create <name>[/bold] to add a client.")
73
75
  return
74
76
 
75
77
  table = Table(show_header=True, header_style="bold")
@@ -99,7 +101,11 @@ def show(
99
101
  client_id: str = typer.Argument(..., help="Client ID."),
100
102
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
101
103
  ) -> None:
102
- """Show details for a specific client."""
104
+ """Show details for a specific client.
105
+
106
+ Examples:
107
+ ct clients show <client-id>
108
+ """
103
109
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
104
110
 
105
111
  try:
@@ -135,9 +141,20 @@ def create(
135
141
  currency: Optional[str] = typer.Option(None, "--currency", help="Currency code (e.g. USD)."),
136
142
  contact_name: Optional[str] = typer.Option(None, "--contact", help="Contact person name."),
137
143
  contact_email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
144
+ address_line1: Optional[str] = typer.Option(None, "--address-line1", help="Street address."),
145
+ address_line2: Optional[str] = typer.Option(None, "--address-line2", help="Suite, unit, etc."),
146
+ city: Optional[str] = typer.Option(None, "--city", help="City."),
147
+ state: Optional[str] = typer.Option(None, "--state", help="State/province."),
148
+ postal_code: Optional[str] = typer.Option(None, "--postal-code", help="Postal/ZIP code."),
149
+ country: Optional[str] = typer.Option(None, "--country", help="Country."),
138
150
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
139
151
  ) -> None:
140
- """Create a new client."""
152
+ """Create a new client.
153
+
154
+ Examples:
155
+ ct clients create "Acme Corp"
156
+ ct clients create "Acme Corp" --currency EUR --contact "Jane Doe" --email jane@acme.com
157
+ """
141
158
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
142
159
 
143
160
  payload: dict = {"name": name}
@@ -147,6 +164,18 @@ def create(
147
164
  payload["contact_name"] = contact_name
148
165
  if contact_email:
149
166
  payload["contact_email"] = contact_email
167
+ if address_line1:
168
+ payload["address_line1"] = address_line1
169
+ if address_line2:
170
+ payload["address_line2"] = address_line2
171
+ if city:
172
+ payload["city"] = city
173
+ if state:
174
+ payload["state"] = state
175
+ if postal_code:
176
+ payload["postal_code"] = postal_code
177
+ if country:
178
+ payload["country"] = country
150
179
 
151
180
  try:
152
181
  data = api_client.post("/clients/", data=payload)
@@ -160,6 +189,67 @@ def create(
160
189
  raise typer.Exit(1)
161
190
 
162
191
 
192
+ @app.command()
193
+ def edit(
194
+ client_id: str = typer.Argument(..., help="Client ID to edit."),
195
+ name: Optional[str] = typer.Option(None, "--name", help="New client name."),
196
+ currency: Optional[str] = typer.Option(None, "--currency", help="Currency code (e.g. USD, EUR)."),
197
+ contact_name: Optional[str] = typer.Option(None, "--contact", help="Contact person name."),
198
+ contact_email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
199
+ address_line1: Optional[str] = typer.Option(None, "--address-line1", help="Street address."),
200
+ city: Optional[str] = typer.Option(None, "--city", help="City."),
201
+ state: Optional[str] = typer.Option(None, "--state", help="State/province."),
202
+ postal_code: Optional[str] = typer.Option(None, "--postal-code", help="Postal/ZIP code."),
203
+ country: Optional[str] = typer.Option(None, "--country", help="Country."),
204
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
205
+ ) -> None:
206
+ """Edit an existing client.
207
+
208
+ Examples:
209
+ ct clients edit <client-id> --name "New Name"
210
+ ct clients edit <client-id> --currency EUR --contact "Jane Doe"
211
+ """
212
+ api_client = CrowdTimeClient(require_auth=True, require_org=True)
213
+
214
+ payload: dict = {}
215
+ if name is not None:
216
+ payload["name"] = name
217
+ if currency is not None:
218
+ 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
+ if address_line1 is not None:
224
+ payload["address_line1"] = address_line1
225
+ if city is not None:
226
+ payload["city"] = city
227
+ if state is not None:
228
+ payload["state"] = state
229
+ if postal_code is not None:
230
+ payload["postal_code"] = postal_code
231
+ if country is not None:
232
+ payload["country"] = country
233
+
234
+ if not payload:
235
+ format_error("No changes specified. Use --name, --currency, --contact, etc.")
236
+ raise typer.Exit(1)
237
+
238
+ try:
239
+ data = api_client.patch(f"/clients/{client_id}/", data=payload)
240
+
241
+ if output_json:
242
+ print_json(data)
243
+ else:
244
+ format_success(f"Client '{data.get('name')}' updated (ID: {data.get('id')})")
245
+ except APIError as e:
246
+ if e.status_code == 404:
247
+ format_error(f"Client '{client_id}' not found.")
248
+ else:
249
+ format_error(e.message)
250
+ raise typer.Exit(1)
251
+
252
+
163
253
  @app.command()
164
254
  def archive(
165
255
  client_id: str = typer.Argument(..., help="Client ID to archive."),
@@ -203,7 +293,7 @@ def list_contacts(
203
293
  try:
204
294
  client_id = _resolve_client(api_client, client_ref)
205
295
  data = api_client.get(f"/clients/{client_id}/contacts/")
206
- contacts = data if isinstance(data, list) else data.get("results", [])
296
+ contacts = extract_results(data)
207
297
 
208
298
  if output_json:
209
299
  print_json(contacts)
@@ -250,6 +340,7 @@ def add_contact(
250
340
  phone: Optional[str] = typer.Option(None, "--phone", help="Contact phone."),
251
341
  role: Optional[str] = typer.Option(None, "--role", help="Contact role/title."),
252
342
  primary: bool = typer.Option(False, "--primary", help="Set as primary contact."),
343
+ receives_invoices: bool = typer.Option(False, "--receives-invoices", help="Receives invoices and payment reminders."),
253
344
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
254
345
  ) -> None:
255
346
  """Add a contact to a client.
@@ -257,6 +348,7 @@ def add_contact(
257
348
  Examples:
258
349
  ct clients add-contact "Acme Corp" --name "Jane Doe" --email jane@acme.com
259
350
  ct clients add-contact <client-id> --name "John Smith" --role "CTO" --primary
351
+ ct clients add-contact "Acme Corp" --name "Billing Dept" --email billing@acme.com --receives-invoices
260
352
  """
261
353
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
262
354
 
@@ -272,6 +364,8 @@ def add_contact(
272
364
  payload["role"] = role
273
365
  if primary:
274
366
  payload["is_primary"] = True
367
+ if receives_invoices:
368
+ payload["receives_invoices"] = True
275
369
 
276
370
  data = api_client.post(f"/clients/{client_id}/contacts/", data=payload)
277
371