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
crowdtime_cli/main.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""CrowdTime CLI - Main entry point.
|
|
2
|
+
|
|
3
|
+
Registers all command groups, handles magic default routing,
|
|
4
|
+
and provides short aliases for common commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import date, timedelta
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .client import APIError, CrowdTimeClient
|
|
19
|
+
from .formatters import format_error, format_status, format_timer, print_json
|
|
20
|
+
from .models import TimeEntry, User
|
|
21
|
+
|
|
22
|
+
from .commands import (
|
|
23
|
+
ai_cmd,
|
|
24
|
+
auth_cmd,
|
|
25
|
+
clients_cmd,
|
|
26
|
+
config_cmd,
|
|
27
|
+
favorites_cmd,
|
|
28
|
+
log_cmd,
|
|
29
|
+
org_cmd,
|
|
30
|
+
projects_cmd,
|
|
31
|
+
report_cmd,
|
|
32
|
+
skill_cmd,
|
|
33
|
+
tasks_cmd,
|
|
34
|
+
timer_cmd,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
name="crowdtime",
|
|
41
|
+
help="CrowdTime - AI-powered time tracking from the command line.",
|
|
42
|
+
rich_markup_mode="rich",
|
|
43
|
+
no_args_is_help=False,
|
|
44
|
+
invoke_without_command=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Register command groups
|
|
48
|
+
app.add_typer(auth_cmd.app, name="auth")
|
|
49
|
+
app.add_typer(timer_cmd.app, name="timer")
|
|
50
|
+
app.add_typer(log_cmd.app, name="log")
|
|
51
|
+
app.add_typer(projects_cmd.app, name="projects")
|
|
52
|
+
app.add_typer(clients_cmd.app, name="clients")
|
|
53
|
+
app.add_typer(tasks_cmd.app, name="tasks")
|
|
54
|
+
app.add_typer(report_cmd.app, name="report")
|
|
55
|
+
app.add_typer(ai_cmd.app, name="ai")
|
|
56
|
+
app.add_typer(org_cmd.app, name="org")
|
|
57
|
+
app.add_typer(config_cmd.app, name="config")
|
|
58
|
+
app.add_typer(favorites_cmd.app, name="favorites")
|
|
59
|
+
app.add_typer(skill_cmd.app, name="skill")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─── Status command (default when no subcommand given) ──────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _show_status(output_json: bool = False) -> None:
|
|
66
|
+
"""Show the full status dashboard."""
|
|
67
|
+
try:
|
|
68
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
69
|
+
except SystemExit:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Fetch user info
|
|
73
|
+
try:
|
|
74
|
+
user_data = client.get("/api/v1/auth/me/", org_scoped=False)
|
|
75
|
+
user = User(**user_data)
|
|
76
|
+
user_name = user.display_name
|
|
77
|
+
except (APIError, SystemExit):
|
|
78
|
+
user_name = "Unknown"
|
|
79
|
+
|
|
80
|
+
org_name = client.org_slug or "No org"
|
|
81
|
+
|
|
82
|
+
# Fetch running timer
|
|
83
|
+
running_entry: TimeEntry | None = None
|
|
84
|
+
try:
|
|
85
|
+
running_data = client.get("/time/running/")
|
|
86
|
+
running_entry = TimeEntry(**running_data)
|
|
87
|
+
except APIError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Fetch today's entries
|
|
91
|
+
today = date.today()
|
|
92
|
+
today_entries: list[TimeEntry] = []
|
|
93
|
+
try:
|
|
94
|
+
today_data = client.get("/time/daily/{}/".format(today.isoformat()))
|
|
95
|
+
if isinstance(today_data, list):
|
|
96
|
+
today_list = today_data
|
|
97
|
+
elif isinstance(today_data, dict):
|
|
98
|
+
today_list = today_data.get("entries", today_data.get("results", []))
|
|
99
|
+
else:
|
|
100
|
+
today_list = []
|
|
101
|
+
today_entries = [TimeEntry(**item) for item in today_list]
|
|
102
|
+
except APIError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Fetch this week's entries
|
|
106
|
+
week_entries: list[TimeEntry] | None = None
|
|
107
|
+
try:
|
|
108
|
+
monday = today - timedelta(days=today.weekday())
|
|
109
|
+
week_data = client.get("/time/weekly/{}/".format(monday.isoformat()))
|
|
110
|
+
if isinstance(week_data, list):
|
|
111
|
+
week_list = week_data
|
|
112
|
+
elif isinstance(week_data, dict):
|
|
113
|
+
week_list = week_data.get("entries", week_data.get("results", []))
|
|
114
|
+
else:
|
|
115
|
+
week_list = []
|
|
116
|
+
week_entries = [TimeEntry(**item) for item in week_list]
|
|
117
|
+
except APIError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
if output_json:
|
|
121
|
+
print_json({
|
|
122
|
+
"user": user_name,
|
|
123
|
+
"organization": org_name,
|
|
124
|
+
"running_timer": running_entry.model_dump(mode="json") if running_entry else None,
|
|
125
|
+
"today_entries": [e.model_dump(mode="json") for e in today_entries],
|
|
126
|
+
"today_total": str(sum((e.duration or Decimal("0")) for e in today_entries if not e.is_running)),
|
|
127
|
+
})
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
format_status(
|
|
131
|
+
user_name=user_name,
|
|
132
|
+
org_name=org_name,
|
|
133
|
+
running_entry=running_entry,
|
|
134
|
+
today_entries=today_entries,
|
|
135
|
+
daily_target=client.config.daily_target,
|
|
136
|
+
week_entries=week_entries,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.callback(invoke_without_command=True)
|
|
141
|
+
def main(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version."),
|
|
144
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""CrowdTime - AI-powered time tracking from the command line."""
|
|
147
|
+
if version:
|
|
148
|
+
console.print(f"crowdtime-cli {__version__}")
|
|
149
|
+
raise typer.Exit()
|
|
150
|
+
|
|
151
|
+
if ctx.invoked_subcommand is None:
|
|
152
|
+
_show_status(output_json=output_json)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ─── Short alias commands ───────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command("status", hidden=False)
|
|
159
|
+
def status_cmd(
|
|
160
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Show current status dashboard."""
|
|
163
|
+
_show_status(output_json=output_json)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("s", hidden=True)
|
|
167
|
+
def status_alias(
|
|
168
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Alias for 'status'."""
|
|
171
|
+
_show_status(output_json=output_json)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("t", hidden=True)
|
|
175
|
+
def timer_status_alias(
|
|
176
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Alias for 'timer status'."""
|
|
179
|
+
timer_cmd.status(output_json=output_json)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("ts", hidden=True)
|
|
183
|
+
def timer_start_alias(
|
|
184
|
+
description: str = typer.Argument("", help="What are you working on?"),
|
|
185
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
186
|
+
task: Optional[str] = typer.Option(None, "--task", "-t"),
|
|
187
|
+
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B"),
|
|
188
|
+
output_json: bool = typer.Option(False, "--json"),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Alias for 'timer start'."""
|
|
191
|
+
timer_cmd.start(
|
|
192
|
+
description=description, project=project, task=task,
|
|
193
|
+
billable=billable, output_json=output_json,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command("tx", hidden=True)
|
|
198
|
+
def timer_stop_alias(
|
|
199
|
+
note: Optional[str] = typer.Option(None, "--note", "-n"),
|
|
200
|
+
output_json: bool = typer.Option(False, "--json"),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Alias for 'timer stop'."""
|
|
203
|
+
timer_cmd.stop(note=note, output_json=output_json)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command("l", hidden=True)
|
|
207
|
+
def log_alias(
|
|
208
|
+
duration: str = typer.Argument(..., help="Duration (e.g. 2h, 2h30m, 2:30)."),
|
|
209
|
+
description: Optional[str] = typer.Argument(None),
|
|
210
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
211
|
+
task: Optional[str] = typer.Option(None, "--task", "-t"),
|
|
212
|
+
date: Optional[str] = typer.Option(None, "--date", "-d"),
|
|
213
|
+
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B"),
|
|
214
|
+
output_json: bool = typer.Option(False, "--json"),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Alias for 'log create'."""
|
|
217
|
+
log_cmd.create(
|
|
218
|
+
duration=duration, description=description, project=project,
|
|
219
|
+
task=task, date=date, billable=billable, output_json=output_json,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("ll", hidden=True)
|
|
224
|
+
def log_list_alias(
|
|
225
|
+
date: Optional[str] = typer.Option(None, "--date", "-d"),
|
|
226
|
+
week: bool = typer.Option(False, "--week", "-w"),
|
|
227
|
+
month: bool = typer.Option(False, "--month", "-m"),
|
|
228
|
+
from_date: Optional[str] = typer.Option(None, "--from"),
|
|
229
|
+
to_date: Optional[str] = typer.Option(None, "--to"),
|
|
230
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
231
|
+
format_output: str = typer.Option("table", "--format"),
|
|
232
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Alias for 'log list'."""
|
|
235
|
+
log_cmd.list_entries(
|
|
236
|
+
date=date, week=week, month=month,
|
|
237
|
+
from_date=from_date, to_date=to_date, project=project,
|
|
238
|
+
format_output=format_output, output_json=output_json,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command("r", hidden=True)
|
|
243
|
+
def report_alias(
|
|
244
|
+
ctx: typer.Context,
|
|
245
|
+
today_flag: bool = typer.Option(False, "--today"),
|
|
246
|
+
yesterday_flag: bool = typer.Option(False, "--yesterday"),
|
|
247
|
+
week: bool = typer.Option(False, "--week", "-w"),
|
|
248
|
+
last_week: bool = typer.Option(False, "--last-week"),
|
|
249
|
+
month: bool = typer.Option(False, "--month", "-m"),
|
|
250
|
+
from_date: Optional[str] = typer.Option(None, "--from"),
|
|
251
|
+
to_date: Optional[str] = typer.Option(None, "--to"),
|
|
252
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
253
|
+
group_by: str = typer.Option("project", "--group-by", "-g"),
|
|
254
|
+
format_output: str = typer.Option("table", "--format", "-f"),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Alias for 'report'."""
|
|
257
|
+
report_cmd.report(
|
|
258
|
+
ctx=ctx,
|
|
259
|
+
today_flag=today_flag, yesterday_flag=yesterday_flag, week=week,
|
|
260
|
+
last_week=last_week, month=month, from_date=from_date, to_date=to_date,
|
|
261
|
+
project=project, group_by=group_by, format_output=format_output,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("p", hidden=True)
|
|
266
|
+
def projects_list_alias(
|
|
267
|
+
archived: bool = typer.Option(False, "--archived", "-a"),
|
|
268
|
+
output_json: bool = typer.Option(False, "--json"),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Alias for 'projects list'."""
|
|
271
|
+
projects_cmd.list_projects(archived=archived, output_json=output_json)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ─── Magic routing: unrecognized text -> ai parse ────────────────────────────
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _is_known_command(arg: str) -> bool:
|
|
278
|
+
"""Check if an argument is a known command or subcommand."""
|
|
279
|
+
known = {
|
|
280
|
+
"auth", "timer", "log", "projects", "clients", "tasks", "report", "ai",
|
|
281
|
+
"org", "config", "favorites", "skill", "status", "s", "t", "ts", "tx",
|
|
282
|
+
"l", "ll", "r", "p", "--help", "-h", "--version", "-v", "--json",
|
|
283
|
+
}
|
|
284
|
+
return arg in known
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_LOG_SUBCOMMANDS = {"list", "edit", "delete", "create", "--help", "-h", "--json"}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _fix_log_routing() -> None:
|
|
291
|
+
"""Auto-insert 'create' for `ct log -p ... 2h "work"` style invocations.
|
|
292
|
+
|
|
293
|
+
When `ct log` is followed by args that aren't a known log subcommand,
|
|
294
|
+
the user intends `ct log create ...`. Insert 'create' so Typer routes
|
|
295
|
+
correctly without the callback needing positional args (which would
|
|
296
|
+
break subcommand routing).
|
|
297
|
+
"""
|
|
298
|
+
if len(sys.argv) > 2 and sys.argv[1] == "log":
|
|
299
|
+
second = sys.argv[2]
|
|
300
|
+
if second not in _LOG_SUBCOMMANDS:
|
|
301
|
+
sys.argv = [sys.argv[0], "log", "create"] + sys.argv[2:]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _original_main() -> None:
|
|
305
|
+
"""CLI entry point with magic routing and error handling.
|
|
306
|
+
|
|
307
|
+
If the first argument is not a known command, treat the entire
|
|
308
|
+
argument string as natural language and route to 'ai parse'.
|
|
309
|
+
"""
|
|
310
|
+
if len(sys.argv) > 1:
|
|
311
|
+
first_arg = sys.argv[1]
|
|
312
|
+
# If it's not a known command and doesn't start with --, route to ai parse
|
|
313
|
+
if not first_arg.startswith("-") and not _is_known_command(first_arg):
|
|
314
|
+
text = " ".join(sys.argv[1:])
|
|
315
|
+
sys.argv = [sys.argv[0], "ai", "parse", text]
|
|
316
|
+
|
|
317
|
+
_fix_log_routing()
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
app()
|
|
321
|
+
except APIError as e:
|
|
322
|
+
format_error(e.message)
|
|
323
|
+
raise SystemExit(1)
|
|
324
|
+
except SystemExit:
|
|
325
|
+
raise
|
|
326
|
+
except KeyboardInterrupt:
|
|
327
|
+
raise SystemExit(0)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
format_error(f"Unexpected error: {e}")
|
|
330
|
+
raise SystemExit(1)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
_original_main()
|
crowdtime_cli/models.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Pydantic models for CrowdTime API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(BaseModel):
|
|
12
|
+
id: str
|
|
13
|
+
email: str
|
|
14
|
+
first_name: str = ""
|
|
15
|
+
last_name: str = ""
|
|
16
|
+
timezone: str = "UTC"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def display_name(self) -> str:
|
|
20
|
+
if self.first_name or self.last_name:
|
|
21
|
+
return f"{self.first_name} {self.last_name}".strip()
|
|
22
|
+
return self.email
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Organization(BaseModel):
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
slug: str
|
|
29
|
+
role: str | None = None
|
|
30
|
+
member_count: int | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Project(BaseModel):
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
code: str = ""
|
|
37
|
+
client: str | None = None
|
|
38
|
+
color: str = "#3B82F6"
|
|
39
|
+
status: str = "active"
|
|
40
|
+
is_billable: bool = True
|
|
41
|
+
budget_hours: Decimal | None = None
|
|
42
|
+
description: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Task(BaseModel):
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
project: str | None = None
|
|
49
|
+
project_name: str | None = None
|
|
50
|
+
is_billable: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TimeEntry(BaseModel):
|
|
54
|
+
model_config = {"extra": "ignore"}
|
|
55
|
+
|
|
56
|
+
id: str | None = None
|
|
57
|
+
description: str = ""
|
|
58
|
+
notes: str = ""
|
|
59
|
+
project: str | None = None
|
|
60
|
+
project_name: str | None = None
|
|
61
|
+
project_code: str | None = None
|
|
62
|
+
project_color: str | None = None
|
|
63
|
+
task: str | None = None
|
|
64
|
+
task_name: str | None = None
|
|
65
|
+
date: Optional[dt.date] = None
|
|
66
|
+
start_time: Optional[dt.datetime] = None
|
|
67
|
+
end_time: Optional[dt.datetime] = None
|
|
68
|
+
duration: Decimal | None = None
|
|
69
|
+
hours: Decimal | None = None
|
|
70
|
+
is_running: bool = False
|
|
71
|
+
is_billable: bool = True
|
|
72
|
+
user: str | None = None
|
|
73
|
+
user_name: str | None = None
|
|
74
|
+
source: str = ""
|
|
75
|
+
created_at: Optional[dt.datetime] = None
|
|
76
|
+
updated_at: Optional[dt.datetime] = None
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="before")
|
|
79
|
+
@classmethod
|
|
80
|
+
def _normalize_fields(cls, data: Any) -> Any:
|
|
81
|
+
if isinstance(data, dict):
|
|
82
|
+
# Map API field names to CLI field names
|
|
83
|
+
if "notes" in data and not data.get("description"):
|
|
84
|
+
data["description"] = data["notes"]
|
|
85
|
+
if "hours" in data and not data.get("duration"):
|
|
86
|
+
data["duration"] = data["hours"]
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FavoriteEntry(BaseModel):
|
|
91
|
+
id: str
|
|
92
|
+
project: str | None = None
|
|
93
|
+
project_name: str | None = None
|
|
94
|
+
task: str | None = None
|
|
95
|
+
task_name: str | None = None
|
|
96
|
+
description: str = ""
|
|
97
|
+
is_billable: bool = True
|
|
98
|
+
use_count: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ParseResult(BaseModel):
|
|
102
|
+
parsed_result: dict[str, Any] = Field(default_factory=dict)
|
|
103
|
+
confidence: float = 0.0
|
|
104
|
+
ambiguities: list[str] = Field(default_factory=list)
|
|
105
|
+
parse_log_id: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Suggestion(BaseModel):
|
|
109
|
+
model_config = {"extra": "ignore"}
|
|
110
|
+
|
|
111
|
+
project_name: str = ""
|
|
112
|
+
task_name: str | None = ""
|
|
113
|
+
description: str = ""
|
|
114
|
+
estimated_hours: Decimal | None = None
|
|
115
|
+
reason: str = ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class WeeklySummary(BaseModel):
|
|
119
|
+
headline: str = ""
|
|
120
|
+
total_hours: Decimal = Decimal("0")
|
|
121
|
+
project_breakdown: list[dict[str, Any]] = Field(default_factory=list)
|
|
122
|
+
insights: list[str] = Field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ReportRow(BaseModel):
|
|
126
|
+
project: str = ""
|
|
127
|
+
task: str = ""
|
|
128
|
+
hours: Decimal = Decimal("0")
|
|
129
|
+
billable_hours: Decimal = Decimal("0")
|
|
130
|
+
entries_count: int = 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Member(BaseModel):
|
|
134
|
+
id: str
|
|
135
|
+
user_email: str = ""
|
|
136
|
+
user_name: str = ""
|
|
137
|
+
role: str = "member"
|
|
138
|
+
is_active: bool = True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TokenResponse(BaseModel):
|
|
142
|
+
id: str | None = None
|
|
143
|
+
name: str = ""
|
|
144
|
+
key: str = ""
|
|
145
|
+
key_prefix: str = ""
|
|
146
|
+
scopes: list[str] = Field(default_factory=list)
|
crowdtime_cli/oauth.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Browser-based OAuth login flow for CrowdTime CLI.
|
|
2
|
+
|
|
3
|
+
Opens the user's browser for Google OAuth authentication. The CLI polls
|
|
4
|
+
the server for the auth code, then exchanges it for an API token (PKCE-protected).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_oauth_flow(server_url: str, timeout: int = 120) -> tuple[str, str] | None:
|
|
18
|
+
"""Run the browser-based OAuth login flow with PKCE protection.
|
|
19
|
+
|
|
20
|
+
1. Opens browser for Google OAuth on the server
|
|
21
|
+
2. Polls the server until the auth code is available
|
|
22
|
+
3. Exchanges the code + PKCE verifier for an API token
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
server_url: The CrowdTime server URL (e.g. https://api.crowdtime.lat).
|
|
26
|
+
timeout: Max seconds to wait for the user to complete OAuth.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Tuple of (api_token, org_slug) on success, or None on failure/timeout.
|
|
30
|
+
"""
|
|
31
|
+
# Generate PKCE pair
|
|
32
|
+
code_verifier = secrets.token_hex(32)
|
|
33
|
+
code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest()
|
|
34
|
+
|
|
35
|
+
# Generate state for CSRF protection
|
|
36
|
+
state = secrets.token_hex(32)
|
|
37
|
+
|
|
38
|
+
login_url = (
|
|
39
|
+
f'{server_url}/api/v1/auth/cli-login/'
|
|
40
|
+
f'?state={state}&code_challenge={code_challenge}'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not webbrowser.open(login_url):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Poll the server for the auth code
|
|
47
|
+
poll_url = f'{server_url}/api/v1/auth/cli-poll/?state={state}'
|
|
48
|
+
deadline = time.monotonic() + timeout
|
|
49
|
+
poll_interval = 2 # seconds
|
|
50
|
+
|
|
51
|
+
while time.monotonic() < deadline:
|
|
52
|
+
time.sleep(poll_interval)
|
|
53
|
+
try:
|
|
54
|
+
response = httpx.get(poll_url, timeout=10.0)
|
|
55
|
+
if response.status_code == 200:
|
|
56
|
+
data = response.json()
|
|
57
|
+
if data.get('status') == 'complete' and data.get('code'):
|
|
58
|
+
return _exchange_code(
|
|
59
|
+
server_url=server_url,
|
|
60
|
+
code=data['code'],
|
|
61
|
+
code_verifier=code_verifier,
|
|
62
|
+
)
|
|
63
|
+
except (httpx.HTTPError, ValueError, KeyError):
|
|
64
|
+
pass # Retry on transient errors
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _exchange_code(
|
|
70
|
+
server_url: str,
|
|
71
|
+
code: str,
|
|
72
|
+
code_verifier: str,
|
|
73
|
+
) -> tuple[str, str] | None:
|
|
74
|
+
"""Exchange a one-time auth code for an API token via server-to-server POST.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
server_url: The CrowdTime server URL.
|
|
78
|
+
code: The one-time auth code from polling.
|
|
79
|
+
code_verifier: The PKCE verifier to prove we initiated the flow.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Tuple of (api_token, org_slug) on success, or None on failure.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
response = httpx.post(
|
|
86
|
+
f'{server_url}/api/v1/auth/cli-exchange/',
|
|
87
|
+
json={
|
|
88
|
+
'code': code,
|
|
89
|
+
'code_verifier': code_verifier,
|
|
90
|
+
},
|
|
91
|
+
timeout=10.0,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if response.status_code != 200:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
data = response.json()
|
|
98
|
+
token = data.get('token')
|
|
99
|
+
org_slug = data.get('org_slug', '')
|
|
100
|
+
|
|
101
|
+
if not token:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return token, org_slug
|
|
105
|
+
|
|
106
|
+
except (httpx.HTTPError, ValueError, KeyError):
|
|
107
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared name-to-UUID resolution helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from .client import APIError, CrowdTimeClient
|
|
10
|
+
|
|
11
|
+
console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_task(client: CrowdTimeClient, task_ref: str, project_id: str | None = None) -> str:
|
|
15
|
+
"""Resolve a task name or UUID to a UUID. Auto-assigns to project if needed.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
client: Authenticated API client.
|
|
19
|
+
task_ref: Task name or UUID.
|
|
20
|
+
project_id: If provided, ensures the task is assigned to this project.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The task UUID string.
|
|
24
|
+
"""
|
|
25
|
+
# If it looks like a UUID already, use it directly
|
|
26
|
+
if _looks_like_uuid(task_ref):
|
|
27
|
+
task_id = task_ref
|
|
28
|
+
else:
|
|
29
|
+
# Search org tasks by name
|
|
30
|
+
data = client.get("/tasks/", params={"search": task_ref})
|
|
31
|
+
tasks_list = data if isinstance(data, list) else data.get("results", [])
|
|
32
|
+
|
|
33
|
+
# Exact match (case-insensitive)
|
|
34
|
+
task_id = None
|
|
35
|
+
for t in tasks_list:
|
|
36
|
+
if t.get("name", "").lower() == task_ref.lower():
|
|
37
|
+
task_id = t["id"]
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
if task_id is None:
|
|
41
|
+
# No match — create the task
|
|
42
|
+
console.print(f"[dim]Task '{task_ref}' not found — creating it...[/dim]")
|
|
43
|
+
new_task = client.post("/tasks/", data={"name": task_ref})
|
|
44
|
+
task_id = new_task["id"]
|
|
45
|
+
|
|
46
|
+
# If a project is specified, ensure the task is assigned to it
|
|
47
|
+
if project_id:
|
|
48
|
+
_ensure_task_assigned_to_project(client, task_id, project_id)
|
|
49
|
+
|
|
50
|
+
return task_id
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ensure_task_assigned_to_project(
|
|
54
|
+
client: CrowdTimeClient, task_id: str, project_id: str
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Check if a task is assigned to a project; assign it if not."""
|
|
57
|
+
# List tasks currently assigned to the project
|
|
58
|
+
try:
|
|
59
|
+
data = client.get(f"/projects/{project_id}/tasks/")
|
|
60
|
+
assigned = data if isinstance(data, list) else data.get("results", [])
|
|
61
|
+
|
|
62
|
+
for pt in assigned:
|
|
63
|
+
# The response has 'task' (UUID) field
|
|
64
|
+
if pt.get("task") == task_id:
|
|
65
|
+
return # Already assigned
|
|
66
|
+
|
|
67
|
+
# Not assigned — assign it
|
|
68
|
+
console.print(f"[dim]Assigning task to project...[/dim]")
|
|
69
|
+
client.post(f"/projects/{project_id}/tasks/", data={"task": task_id})
|
|
70
|
+
except APIError:
|
|
71
|
+
# If we can't check/assign, let the API validate later
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _looks_like_uuid(value: str) -> bool:
|
|
76
|
+
"""Quick check if a string looks like a UUID."""
|
|
77
|
+
return bool(re.match(
|
|
78
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
79
|
+
value.lower(),
|
|
80
|
+
))
|