crowdtime-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- crowdtime_cli/auth.py +69 -0
- crowdtime_cli/client.py +177 -0
- crowdtime_cli/commands/__init__.py +1 -0
- crowdtime_cli/commands/ai_cmd.py +211 -0
- crowdtime_cli/commands/auth_cmd.py +160 -0
- crowdtime_cli/commands/clients_cmd.py +150 -0
- crowdtime_cli/commands/config_cmd.py +91 -0
- crowdtime_cli/commands/favorites_cmd.py +128 -0
- crowdtime_cli/commands/log_cmd.py +298 -0
- crowdtime_cli/commands/org_cmd.py +134 -0
- crowdtime_cli/commands/projects_cmd.py +175 -0
- crowdtime_cli/commands/report_cmd.py +242 -0
- crowdtime_cli/commands/skill_cmd.py +266 -0
- crowdtime_cli/commands/tasks_cmd.py +101 -0
- crowdtime_cli/commands/timer_cmd.py +207 -0
- crowdtime_cli/config.py +125 -0
- crowdtime_cli/formatters.py +395 -0
- crowdtime_cli/main.py +334 -0
- crowdtime_cli/models.py +146 -0
- crowdtime_cli/oauth.py +107 -0
- crowdtime_cli/resolvers.py +80 -0
- crowdtime_cli/skills/crowdtime/SKILL.md +193 -0
- crowdtime_cli/skills/crowdtime/references/commands.md +659 -0
- crowdtime_cli/skills/crowdtime/references/workflows.md +286 -0
- crowdtime_cli/utils.py +166 -0
- crowdtime_cli-0.1.0.dist-info/METADATA +140 -0
- crowdtime_cli-0.1.0.dist-info/RECORD +31 -0
- crowdtime_cli-0.1.0.dist-info/WHEEL +4 -0
- crowdtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- crowdtime_cli-0.1.0.dist-info/licenses/LICENSE +77 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Client commands: list, show, create, archive."""
|
|
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
|
+
@app.command("list")
|
|
19
|
+
def list_clients(
|
|
20
|
+
archived: bool = typer.Option(False, "--archived", "-a", help="Include archived clients."),
|
|
21
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""List all clients in the current organization."""
|
|
24
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
25
|
+
|
|
26
|
+
params: dict = {}
|
|
27
|
+
if archived:
|
|
28
|
+
params["is_archived"] = "true"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
data = client.get("/clients/", params=params)
|
|
32
|
+
clients_list = data if isinstance(data, list) else data.get("results", [])
|
|
33
|
+
|
|
34
|
+
if output_json:
|
|
35
|
+
print_json(clients_list)
|
|
36
|
+
else:
|
|
37
|
+
if not clients_list:
|
|
38
|
+
console.print("[dim]No clients found.[/dim]")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
table = Table(show_header=True, header_style="bold")
|
|
42
|
+
table.add_column("ID", style="dim")
|
|
43
|
+
table.add_column("Name")
|
|
44
|
+
table.add_column("Contact")
|
|
45
|
+
table.add_column("Projects", justify="right")
|
|
46
|
+
table.add_column("Status")
|
|
47
|
+
|
|
48
|
+
for c in clients_list:
|
|
49
|
+
client_id = str(c.get("id", ""))
|
|
50
|
+
name = c.get("name", "")
|
|
51
|
+
contact = c.get("contact_name", "") or "-"
|
|
52
|
+
project_count = str(c.get("project_count", 0))
|
|
53
|
+
is_archived = c.get("is_archived", False)
|
|
54
|
+
status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
|
|
55
|
+
table.add_row(client_id, name, contact, project_count, status)
|
|
56
|
+
|
|
57
|
+
console.print(table)
|
|
58
|
+
except APIError as e:
|
|
59
|
+
format_error(e.message)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def show(
|
|
65
|
+
client_id: str = typer.Argument(..., help="Client ID."),
|
|
66
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Show details for a specific client."""
|
|
69
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
data = api_client.get(f"/clients/{client_id}/")
|
|
73
|
+
|
|
74
|
+
if output_json:
|
|
75
|
+
print_json(data)
|
|
76
|
+
else:
|
|
77
|
+
console.print(f"\n[bold]{data.get('name')}[/bold]")
|
|
78
|
+
console.print(f" ID: {data.get('id')}")
|
|
79
|
+
console.print(f" Currency: {data.get('currency', '-')}")
|
|
80
|
+
console.print(f" Contact: {data.get('contact_name', '-')}")
|
|
81
|
+
if data.get("contact_email"):
|
|
82
|
+
console.print(f" Email: {data['contact_email']}")
|
|
83
|
+
if data.get("contact_phone"):
|
|
84
|
+
console.print(f" Phone: {data['contact_phone']}")
|
|
85
|
+
if data.get("address"):
|
|
86
|
+
console.print(f" Address: {data['address']}")
|
|
87
|
+
status = "Archived" if data.get("is_archived") else "Active"
|
|
88
|
+
console.print(f" Status: {status}")
|
|
89
|
+
console.print()
|
|
90
|
+
except APIError as e:
|
|
91
|
+
if e.status_code == 404:
|
|
92
|
+
format_error(f"Client '{client_id}' not found.")
|
|
93
|
+
else:
|
|
94
|
+
format_error(e.message)
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def create(
|
|
100
|
+
name: str = typer.Argument(..., help="Client name."),
|
|
101
|
+
currency: Optional[str] = typer.Option(None, "--currency", help="Currency code (e.g. USD)."),
|
|
102
|
+
contact_name: Optional[str] = typer.Option(None, "--contact", help="Contact person name."),
|
|
103
|
+
contact_email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
|
|
104
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Create a new client."""
|
|
107
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
108
|
+
|
|
109
|
+
payload: dict = {"name": name}
|
|
110
|
+
if currency:
|
|
111
|
+
payload["currency"] = currency
|
|
112
|
+
if contact_name:
|
|
113
|
+
payload["contact_name"] = contact_name
|
|
114
|
+
if contact_email:
|
|
115
|
+
payload["contact_email"] = contact_email
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
data = api_client.post("/clients/", data=payload)
|
|
119
|
+
|
|
120
|
+
if output_json:
|
|
121
|
+
print_json(data)
|
|
122
|
+
else:
|
|
123
|
+
format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
|
|
124
|
+
except APIError as e:
|
|
125
|
+
format_error(e.message)
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def archive(
|
|
131
|
+
client_id: str = typer.Argument(..., help="Client ID to archive."),
|
|
132
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Archive a client."""
|
|
135
|
+
if not force:
|
|
136
|
+
if not typer.confirm(f"Archive client '{client_id}'?"):
|
|
137
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
api_client.delete(f"/clients/{client_id}/")
|
|
144
|
+
format_success(f"Client '{client_id}' archived.")
|
|
145
|
+
except APIError as e:
|
|
146
|
+
if e.status_code == 404:
|
|
147
|
+
format_error(f"Client '{client_id}' not found.")
|
|
148
|
+
else:
|
|
149
|
+
format_error(e.message)
|
|
150
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Config commands: set, get, list, edit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from ..config import get_config
|
|
12
|
+
from ..formatters import format_error, format_success, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="config", help="Manage CLI configuration.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("set")
|
|
19
|
+
def config_set(
|
|
20
|
+
key: str = typer.Argument(..., help="Config key (e.g. server.url, defaults.project)."),
|
|
21
|
+
value: str = typer.Argument(..., help="Value to set."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Set a configuration value.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
ct config set server.url https://api.crowdtime.io
|
|
27
|
+
ct config set defaults.project myproject
|
|
28
|
+
ct config set defaults.daily_target 7h
|
|
29
|
+
"""
|
|
30
|
+
config = get_config()
|
|
31
|
+
# Convert boolean strings
|
|
32
|
+
if value.lower() in ("true", "yes", "1"):
|
|
33
|
+
config.set(key, True)
|
|
34
|
+
elif value.lower() in ("false", "no", "0"):
|
|
35
|
+
config.set(key, False)
|
|
36
|
+
else:
|
|
37
|
+
config.set(key, value)
|
|
38
|
+
format_success(f"{key} = {value}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("get")
|
|
42
|
+
def config_get(
|
|
43
|
+
key: str = typer.Argument(..., help="Config key to read."),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Get a configuration value."""
|
|
46
|
+
config = get_config()
|
|
47
|
+
value = config.get(key)
|
|
48
|
+
if value is None:
|
|
49
|
+
format_error(f"Key '{key}' not found.")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
console.print(f"{key} = {value}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("list")
|
|
55
|
+
def config_list(
|
|
56
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Show all configuration values."""
|
|
59
|
+
config = get_config()
|
|
60
|
+
all_config = config.all()
|
|
61
|
+
|
|
62
|
+
if output_json:
|
|
63
|
+
print_json(dict(all_config))
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
console.print(f"\n[dim]Config file: {config.config_path}[/dim]\n")
|
|
67
|
+
for section, values in all_config.items():
|
|
68
|
+
console.print(f"[bold][{section}][/bold]")
|
|
69
|
+
if isinstance(values, dict):
|
|
70
|
+
for key, value in values.items():
|
|
71
|
+
console.print(f" {key} = {value}")
|
|
72
|
+
else:
|
|
73
|
+
console.print(f" {values}")
|
|
74
|
+
console.print()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("edit")
|
|
78
|
+
def config_edit() -> None:
|
|
79
|
+
"""Open the config file in your default editor."""
|
|
80
|
+
config = get_config()
|
|
81
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "nano"))
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
subprocess.run([editor, str(config.config_path)], check=True)
|
|
85
|
+
format_success(f"Config saved at {config.config_path}")
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
format_error(f"Editor '{editor}' not found. Set $EDITOR to your preferred editor.")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
except subprocess.CalledProcessError:
|
|
90
|
+
format_error("Editor exited with an error.")
|
|
91
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Favorites commands: list, create, delete, start."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ..client import APIError, CrowdTimeClient
|
|
11
|
+
from ..formatters import (
|
|
12
|
+
format_error,
|
|
13
|
+
format_favorites_table,
|
|
14
|
+
format_success,
|
|
15
|
+
format_timer,
|
|
16
|
+
print_json,
|
|
17
|
+
)
|
|
18
|
+
from ..models import FavoriteEntry, TimeEntry
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="favorites", help="Manage favorite time entry templates.")
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("list")
|
|
25
|
+
def list_favorites(
|
|
26
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List all saved favorites."""
|
|
29
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
data = client.get("/favorites/")
|
|
33
|
+
favs_list = data if isinstance(data, list) else data.get("results", [])
|
|
34
|
+
favorites = [FavoriteEntry(**item) for item in favs_list]
|
|
35
|
+
|
|
36
|
+
if output_json:
|
|
37
|
+
print_json(favorites)
|
|
38
|
+
else:
|
|
39
|
+
format_favorites_table(favorites)
|
|
40
|
+
except APIError as e:
|
|
41
|
+
format_error(e.message)
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def create(
|
|
47
|
+
description: str = typer.Argument("", help="Description for the favorite."),
|
|
48
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or ID."),
|
|
49
|
+
task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
|
|
50
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes."),
|
|
51
|
+
billable: bool = typer.Option(True, "--billable/--no-billable", "-b/-B",
|
|
52
|
+
help="Mark as billable."),
|
|
53
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create a new favorite entry template."""
|
|
56
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
57
|
+
|
|
58
|
+
payload: dict = {
|
|
59
|
+
"description": description,
|
|
60
|
+
"is_billable": billable,
|
|
61
|
+
}
|
|
62
|
+
if project:
|
|
63
|
+
payload["project"] = project
|
|
64
|
+
if task:
|
|
65
|
+
payload["task"] = task
|
|
66
|
+
if notes:
|
|
67
|
+
payload["notes"] = notes
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
data = client.post("/favorites/", data=payload)
|
|
71
|
+
fav = FavoriteEntry(**data)
|
|
72
|
+
|
|
73
|
+
if output_json:
|
|
74
|
+
print_json(fav)
|
|
75
|
+
else:
|
|
76
|
+
format_success(f"Favorite created (ID: {fav.id})")
|
|
77
|
+
except APIError as e:
|
|
78
|
+
format_error(e.message)
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def delete(
|
|
84
|
+
favorite_id: str = typer.Argument(..., help="Favorite ID to delete."),
|
|
85
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Delete a favorite."""
|
|
88
|
+
if not force:
|
|
89
|
+
if not typer.confirm(f"Delete favorite {favorite_id}?"):
|
|
90
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
client.delete(f"/favorites/{favorite_id}/")
|
|
97
|
+
format_success(f"Favorite {favorite_id} deleted.")
|
|
98
|
+
except APIError as e:
|
|
99
|
+
if e.status_code == 404:
|
|
100
|
+
format_error(f"Favorite {favorite_id} not found.")
|
|
101
|
+
else:
|
|
102
|
+
format_error(e.message)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def start(
|
|
108
|
+
favorite_id: str = typer.Argument(..., help="Favorite ID to start timer from."),
|
|
109
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Start a timer from a favorite template."""
|
|
112
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
data = client.post(f"/favorites/{favorite_id}/start/")
|
|
116
|
+
entry = TimeEntry(**data)
|
|
117
|
+
|
|
118
|
+
if output_json:
|
|
119
|
+
print_json(entry)
|
|
120
|
+
else:
|
|
121
|
+
format_success("Timer started from favorite")
|
|
122
|
+
format_timer(entry)
|
|
123
|
+
except APIError as e:
|
|
124
|
+
if e.status_code == 404:
|
|
125
|
+
format_error(f"Favorite {favorite_id} not found.")
|
|
126
|
+
else:
|
|
127
|
+
format_error(e.message)
|
|
128
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Log commands: create, edit, delete, list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ..client import APIError, CrowdTimeClient
|
|
11
|
+
from ..formatters import (
|
|
12
|
+
format_entries_table,
|
|
13
|
+
format_entry_summary,
|
|
14
|
+
format_error,
|
|
15
|
+
format_success,
|
|
16
|
+
print_json,
|
|
17
|
+
)
|
|
18
|
+
from ..models import TimeEntry
|
|
19
|
+
from ..resolvers import resolve_task
|
|
20
|
+
from ..utils import format_date, parse_date, parse_duration
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(name="log", help="Log and manage time entries.")
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback(invoke_without_command=True)
|
|
27
|
+
def log_default(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Log and manage time entries. Run without arguments to list today's entries."""
|
|
32
|
+
if ctx.invoked_subcommand is None:
|
|
33
|
+
_list_entries(date_str="today", output_json=output_json)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def create(
|
|
38
|
+
duration: str = typer.Argument(..., help="Duration (e.g. 2h, 2h30m, 2:30, 150m)."),
|
|
39
|
+
description: Optional[str] = typer.Argument(None, help="What did you work on?"),
|
|
40
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project slug or ID."),
|
|
41
|
+
task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
|
|
42
|
+
date: Optional[str] = typer.Option(None, "--date", "-d", help="Date (default: today)."),
|
|
43
|
+
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
|
|
44
|
+
help="Mark as billable/non-billable."),
|
|
45
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Log a completed time entry.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
ct log create 2h "Code review"
|
|
51
|
+
ct log create -p myproject 2h30m "Feature work"
|
|
52
|
+
ct l -p myproject -d yesterday 1:30 "Standup"
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
hours = parse_duration(duration)
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
format_error(str(e))
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
61
|
+
|
|
62
|
+
payload: dict = {
|
|
63
|
+
"hours": str(hours),
|
|
64
|
+
}
|
|
65
|
+
if description:
|
|
66
|
+
payload["notes"] = description
|
|
67
|
+
|
|
68
|
+
project_id = project or client.config.default_project
|
|
69
|
+
if project_id:
|
|
70
|
+
payload["project"] = project_id
|
|
71
|
+
if task:
|
|
72
|
+
try:
|
|
73
|
+
payload["task"] = resolve_task(client, task, project_id=project_id)
|
|
74
|
+
except APIError as e:
|
|
75
|
+
format_error(f"Failed to resolve task '{task}': {e.message}")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
if date:
|
|
78
|
+
try:
|
|
79
|
+
parsed = parse_date(date)
|
|
80
|
+
payload["date"] = format_date(parsed)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
format_error(str(e))
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
if billable is not None:
|
|
85
|
+
payload["is_billable"] = billable
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
data = client.post("/time/", data=payload)
|
|
89
|
+
entry = TimeEntry(**data)
|
|
90
|
+
|
|
91
|
+
if output_json:
|
|
92
|
+
print_json(entry)
|
|
93
|
+
else:
|
|
94
|
+
format_success("Time entry logged")
|
|
95
|
+
format_entry_summary(entry)
|
|
96
|
+
except APIError as e:
|
|
97
|
+
format_error(e.message)
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def edit(
|
|
103
|
+
entry_id: str = typer.Argument(..., help="Time entry ID to edit."),
|
|
104
|
+
duration: Optional[str] = typer.Option(None, "--duration", help="New duration."),
|
|
105
|
+
description: Optional[str] = typer.Option(None, "--description", help="New description."),
|
|
106
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="New project."),
|
|
107
|
+
task: Optional[str] = typer.Option(None, "--task", "-t", help="New task."),
|
|
108
|
+
date: Optional[str] = typer.Option(None, "--date", "-d", help="New date."),
|
|
109
|
+
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
|
|
110
|
+
help="Mark as billable/non-billable."),
|
|
111
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Edit an existing time entry."""
|
|
114
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
115
|
+
|
|
116
|
+
payload: dict = {}
|
|
117
|
+
if duration:
|
|
118
|
+
try:
|
|
119
|
+
payload["hours"] = str(parse_duration(duration))
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
format_error(str(e))
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
if description is not None:
|
|
124
|
+
payload["notes"] = description
|
|
125
|
+
if project:
|
|
126
|
+
payload["project"] = project
|
|
127
|
+
if task:
|
|
128
|
+
try:
|
|
129
|
+
payload["task"] = resolve_task(client, task, project_id=project)
|
|
130
|
+
except APIError as e:
|
|
131
|
+
format_error(f"Failed to resolve task '{task}': {e.message}")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
if date:
|
|
134
|
+
try:
|
|
135
|
+
payload["date"] = format_date(parse_date(date))
|
|
136
|
+
except ValueError as e:
|
|
137
|
+
format_error(str(e))
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
if billable is not None:
|
|
140
|
+
payload["is_billable"] = billable
|
|
141
|
+
|
|
142
|
+
if not payload:
|
|
143
|
+
format_error("No changes specified. Use --duration, --description, --project, etc.")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
data = client.patch(f"/time/{entry_id}/", data=payload)
|
|
148
|
+
entry = TimeEntry(**data)
|
|
149
|
+
|
|
150
|
+
if output_json:
|
|
151
|
+
print_json(entry)
|
|
152
|
+
else:
|
|
153
|
+
format_success(f"Entry {entry_id} updated")
|
|
154
|
+
format_entry_summary(entry)
|
|
155
|
+
except APIError as e:
|
|
156
|
+
if e.status_code == 404:
|
|
157
|
+
format_error(f"Entry {entry_id} not found.")
|
|
158
|
+
else:
|
|
159
|
+
format_error(e.message)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def delete(
|
|
165
|
+
entry_id: str = typer.Argument(..., help="Time entry ID to delete."),
|
|
166
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Delete a time entry."""
|
|
169
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
170
|
+
|
|
171
|
+
if not force:
|
|
172
|
+
# Fetch entry details for confirmation
|
|
173
|
+
try:
|
|
174
|
+
data = client.get(f"/time/{entry_id}/")
|
|
175
|
+
entry = TimeEntry(**data)
|
|
176
|
+
desc = entry.description or "(no description)"
|
|
177
|
+
if not typer.confirm(f"Delete entry '{desc}'?"):
|
|
178
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
179
|
+
return
|
|
180
|
+
except APIError as e:
|
|
181
|
+
if e.status_code == 404:
|
|
182
|
+
format_error(f"Entry {entry_id} not found.")
|
|
183
|
+
else:
|
|
184
|
+
format_error(e.message)
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
client.delete(f"/time/{entry_id}/")
|
|
189
|
+
format_success(f"Entry {entry_id} deleted.")
|
|
190
|
+
except APIError as e:
|
|
191
|
+
if e.status_code == 404:
|
|
192
|
+
format_error(f"Entry {entry_id} not found.")
|
|
193
|
+
else:
|
|
194
|
+
format_error(e.message)
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("list")
|
|
199
|
+
def list_entries(
|
|
200
|
+
date: Optional[str] = typer.Option(None, "--date", "-d", help="Filter by date."),
|
|
201
|
+
week: bool = typer.Option(False, "--week", "-w", help="Show this week's entries."),
|
|
202
|
+
month: bool = typer.Option(False, "--month", "-m", help="Show this month's entries."),
|
|
203
|
+
from_date: Optional[str] = typer.Option(None, "--from", help="Start date for range filter."),
|
|
204
|
+
to_date: Optional[str] = typer.Option(None, "--to", help="End date for range filter."),
|
|
205
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project."),
|
|
206
|
+
format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
|
|
207
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
208
|
+
) -> None:
|
|
209
|
+
"""List time entries with optional filters."""
|
|
210
|
+
_list_entries(
|
|
211
|
+
date_str=date,
|
|
212
|
+
week=week,
|
|
213
|
+
month=month,
|
|
214
|
+
from_date=from_date,
|
|
215
|
+
to_date=to_date,
|
|
216
|
+
project=project,
|
|
217
|
+
format_output=format_output,
|
|
218
|
+
output_json=output_json,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _list_entries(
|
|
223
|
+
date_str: str | None = None,
|
|
224
|
+
week: bool = False,
|
|
225
|
+
month: bool = False,
|
|
226
|
+
from_date: str | None = None,
|
|
227
|
+
to_date: str | None = None,
|
|
228
|
+
project: str | None = None,
|
|
229
|
+
format_output: str = "table",
|
|
230
|
+
output_json: bool = False,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Internal function to list entries."""
|
|
233
|
+
from datetime import date as date_type, timedelta
|
|
234
|
+
|
|
235
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
236
|
+
|
|
237
|
+
params: dict = {}
|
|
238
|
+
|
|
239
|
+
if date_str:
|
|
240
|
+
try:
|
|
241
|
+
parsed = parse_date(date_str)
|
|
242
|
+
params["date"] = format_date(parsed)
|
|
243
|
+
except ValueError as e:
|
|
244
|
+
format_error(str(e))
|
|
245
|
+
raise typer.Exit(1)
|
|
246
|
+
elif week:
|
|
247
|
+
today = date_type.today()
|
|
248
|
+
start = today - timedelta(days=today.weekday())
|
|
249
|
+
params["from"] = format_date(start)
|
|
250
|
+
params["to"] = format_date(today)
|
|
251
|
+
elif month:
|
|
252
|
+
today = date_type.today()
|
|
253
|
+
start = today.replace(day=1)
|
|
254
|
+
params["from"] = format_date(start)
|
|
255
|
+
params["to"] = format_date(today)
|
|
256
|
+
|
|
257
|
+
if from_date:
|
|
258
|
+
try:
|
|
259
|
+
params["from"] = format_date(parse_date(from_date))
|
|
260
|
+
except ValueError as e:
|
|
261
|
+
format_error(str(e))
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
if to_date:
|
|
264
|
+
try:
|
|
265
|
+
params["to"] = format_date(parse_date(to_date))
|
|
266
|
+
except ValueError as e:
|
|
267
|
+
format_error(str(e))
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
|
|
270
|
+
if project:
|
|
271
|
+
params["project"] = project
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
data = client.get("/time/", params=params)
|
|
275
|
+
entries = [TimeEntry(**item) for item in (data if isinstance(data, list) else data.get("results", []))]
|
|
276
|
+
|
|
277
|
+
if format_output == "json" or output_json:
|
|
278
|
+
print_json(entries)
|
|
279
|
+
elif format_output == "csv":
|
|
280
|
+
_print_csv(entries)
|
|
281
|
+
else:
|
|
282
|
+
format_entries_table(entries, show_date=True)
|
|
283
|
+
except APIError as e:
|
|
284
|
+
format_error(e.message)
|
|
285
|
+
raise typer.Exit(1)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_csv(entries: list[TimeEntry]) -> None:
|
|
289
|
+
"""Print entries as CSV."""
|
|
290
|
+
console.print("id,date,project,task,description,duration,billable")
|
|
291
|
+
for e in entries:
|
|
292
|
+
desc = (e.description or "").replace('"', '""')
|
|
293
|
+
console.print(
|
|
294
|
+
f'{e.id},{e.date or ""},'
|
|
295
|
+
f'{e.project_name or ""},{e.task_name or ""},'
|
|
296
|
+
f'"{desc}",{e.duration or 0},'
|
|
297
|
+
f'{"yes" if e.is_billable else "no"}'
|
|
298
|
+
)
|