crowdtime-cli 0.9.0__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.
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/.gitignore +6 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/client.py +6 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/ai_cmd.py +42 -22
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/auth_cmd.py +97 -1
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/billing_cmd.py +19 -24
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/clients_cmd.py +101 -7
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/expense_cmd.py +74 -32
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/favorites_cmd.py +257 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/insights_cmd.py +683 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/invoice_cmd.py +944 -76
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/log_cmd.py +550 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/org_cmd.py +410 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/payroll_cmd.py +68 -84
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/projects_cmd.py +673 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/report_cmd.py +705 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/tasks_cmd.py +254 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/timer_cmd.py +362 -0
- crowdtime_cli-0.10.0/src/crowdtime_cli/commands/timesheet_cmd.py +986 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/config.py +2 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/formatters.py +198 -28
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/main.py +86 -3
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/models.py +60 -2
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/resolvers.py +50 -11
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +130 -12
- crowdtime_cli-0.10.0/src/crowdtime_cli/skills/crowdtime/references/commands.md +2469 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +497 -20
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/utils.py +106 -0
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/favorites_cmd.py +0 -128
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/log_cmd.py +0 -298
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/org_cmd.py +0 -235
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/projects_cmd.py +0 -264
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/report_cmd.py +0 -242
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/tasks_cmd.py +0 -101
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/timer_cmd.py +0 -207
- crowdtime_cli-0.9.0/src/crowdtime_cli/commands/timesheet_cmd.py +0 -384
- crowdtime_cli-0.9.0/src/crowdtime_cli/skills/crowdtime/references/commands.md +0 -1391
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/LICENSE +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/README.md +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.9.0 → crowdtime_cli-0.10.0}/src/crowdtime_cli/oauth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -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
|
-
|
|
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" --
|
|
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
|
|
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
|
-
|
|
65
|
+
**result.parsed_fields,
|
|
64
66
|
})
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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()
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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"{
|
|
158
|
-
table.add_row("Per seat", f"{
|
|
159
|
-
table.add_row("Total", f"{
|
|
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
|
-
|
|
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",
|
|
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 {
|
|
336
|
+
console.print(f" [dim]Billed annually at {format_currency(total * 12)}/yr[/dim]")
|
|
342
337
|
else:
|
|
343
|
-
console.print(f" [dim]Billed at {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|