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.
@@ -0,0 +1,3 @@
1
+ """CrowdTime CLI - AI-powered time tracking from the command line."""
2
+
3
+ __version__ = "0.1.0"
crowdtime_cli/auth.py ADDED
@@ -0,0 +1,69 @@
1
+ """Authentication and token management for CrowdTime CLI.
2
+
3
+ Stores API tokens in the system keyring, with file-based fallback.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from .config import CONFIG_DIR
11
+
12
+ SERVICE_NAME = "crowdtime"
13
+ USERNAME = "api_token"
14
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials"
15
+
16
+
17
+ def save_token(token: str) -> None:
18
+ """Store the API token, preferring keyring with file fallback."""
19
+ try:
20
+ import keyring
21
+
22
+ keyring.set_password(SERVICE_NAME, USERNAME, token)
23
+ except Exception:
24
+ _save_token_file(token)
25
+
26
+
27
+ def get_token() -> str | None:
28
+ """Retrieve the stored API token."""
29
+ try:
30
+ import keyring
31
+
32
+ token = keyring.get_password(SERVICE_NAME, USERNAME)
33
+ if token:
34
+ return token
35
+ except Exception:
36
+ pass
37
+ return _get_token_file()
38
+
39
+
40
+ def clear_token() -> None:
41
+ """Remove the stored API token."""
42
+ try:
43
+ import keyring
44
+
45
+ keyring.delete_password(SERVICE_NAME, USERNAME)
46
+ except Exception:
47
+ pass
48
+ _clear_token_file()
49
+
50
+
51
+ def _save_token_file(token: str) -> None:
52
+ """File-based fallback for token storage."""
53
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
54
+ CREDENTIALS_FILE.write_text(token)
55
+ CREDENTIALS_FILE.chmod(0o600)
56
+
57
+
58
+ def _get_token_file() -> str | None:
59
+ """File-based fallback for token retrieval."""
60
+ if CREDENTIALS_FILE.exists():
61
+ content = CREDENTIALS_FILE.read_text().strip()
62
+ return content if content else None
63
+ return None
64
+
65
+
66
+ def _clear_token_file() -> None:
67
+ """Remove file-based token."""
68
+ if CREDENTIALS_FILE.exists():
69
+ CREDENTIALS_FILE.unlink()
@@ -0,0 +1,177 @@
1
+ """HTTP client for the CrowdTime API.
2
+
3
+ Wraps httpx with auth header injection, org-scoped URL prefixing,
4
+ and friendly error handling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ from .auth import get_token
15
+ from .config import get_config
16
+
17
+ console = Console(stderr=True)
18
+
19
+
20
+ class APIError(Exception):
21
+ """Raised when an API call fails."""
22
+
23
+ def __init__(self, message: str, status_code: int | None = None, detail: Any = None) -> None:
24
+ self.message = message
25
+ self.status_code = status_code
26
+ self.detail = detail
27
+ super().__init__(message)
28
+
29
+
30
+ class CrowdTimeClient:
31
+ """Synchronous HTTP client for the CrowdTime API."""
32
+
33
+ def __init__(self, require_auth: bool = True, require_org: bool = False) -> None:
34
+ self.config = get_config()
35
+ self.base_url = self.config.server_url
36
+ self.token = get_token()
37
+ self.org_slug = self.config.organization
38
+
39
+ if require_auth and not self.token:
40
+ console.print(
41
+ "[red]Not authenticated.[/red] Please run [bold]ct login[/bold] first."
42
+ )
43
+ raise SystemExit(1)
44
+
45
+ if require_org and not self.org_slug:
46
+ console.print(
47
+ "[red]No organization set.[/red] Please run [bold]ct org switch <slug>[/bold] first."
48
+ )
49
+ raise SystemExit(1)
50
+
51
+ headers: dict[str, str] = {
52
+ "Content-Type": "application/json",
53
+ "Accept": "application/json",
54
+ }
55
+ if self.token:
56
+ headers["Authorization"] = f"Bearer {self.token}"
57
+
58
+ self._client = httpx.Client(
59
+ base_url=self.base_url,
60
+ headers=headers,
61
+ timeout=30.0,
62
+ )
63
+
64
+ def _org_prefix(self) -> str:
65
+ """Return the org-scoped URL prefix."""
66
+ return f"/api/v1/organizations/{self.org_slug}"
67
+
68
+ def _url(self, path: str, org_scoped: bool = True) -> str:
69
+ """Build the full URL path."""
70
+ if path.startswith("/api/"):
71
+ return path
72
+ if org_scoped and self.org_slug:
73
+ return f"{self._org_prefix()}/{path.lstrip('/')}"
74
+ return f"/api/v1/{path.lstrip('/')}"
75
+
76
+ def get(self, path: str, params: dict[str, Any] | None = None,
77
+ org_scoped: bool = True) -> Any:
78
+ """Make a GET request."""
79
+ url = self._url(path, org_scoped=org_scoped)
80
+ response = self._request("GET", url, params=params)
81
+ return response
82
+
83
+ def post(self, path: str, data: dict[str, Any] | None = None,
84
+ org_scoped: bool = True) -> Any:
85
+ """Make a POST request."""
86
+ url = self._url(path, org_scoped=org_scoped)
87
+ response = self._request("POST", url, json=data)
88
+ return response
89
+
90
+ def patch(self, path: str, data: dict[str, Any] | None = None,
91
+ org_scoped: bool = True) -> Any:
92
+ """Make a PATCH request."""
93
+ url = self._url(path, org_scoped=org_scoped)
94
+ response = self._request("PATCH", url, json=data)
95
+ return response
96
+
97
+ def delete(self, path: str, org_scoped: bool = True) -> Any:
98
+ """Make a DELETE request."""
99
+ url = self._url(path, org_scoped=org_scoped)
100
+ response = self._request("DELETE", url)
101
+ return response
102
+
103
+ def _request(self, method: str, url: str, **kwargs: Any) -> Any:
104
+ """Execute an HTTP request with error handling."""
105
+ try:
106
+ response = self._client.request(method, url, **kwargs)
107
+ return self._handle_response(response)
108
+ except httpx.ConnectError:
109
+ console.print(
110
+ f"[red]Cannot connect to server at {self.base_url}[/red]\n"
111
+ "Is the CrowdTime server running?"
112
+ )
113
+ raise SystemExit(1)
114
+ except httpx.TimeoutException:
115
+ console.print("[red]Request timed out.[/red] Please try again.")
116
+ raise SystemExit(1)
117
+ except APIError:
118
+ raise
119
+ except httpx.HTTPError as e:
120
+ console.print(f"[red]HTTP error:[/red] {e}")
121
+ raise SystemExit(1)
122
+
123
+ def _handle_response(self, response: httpx.Response) -> Any:
124
+ """Check response status and parse JSON."""
125
+ if response.status_code == 204:
126
+ return None
127
+
128
+ if response.status_code == 401:
129
+ console.print(
130
+ "[red]Authentication failed.[/red] Please run [bold]ct login[/bold] to re-authenticate."
131
+ )
132
+ raise SystemExit(1)
133
+
134
+ if response.status_code == 403:
135
+ console.print("[red]Permission denied.[/red] You don't have access to this resource.")
136
+ raise SystemExit(1)
137
+
138
+ if response.status_code == 404:
139
+ raise APIError("Not found", status_code=404)
140
+
141
+ if response.status_code >= 500:
142
+ # Server errors — show a friendly message, not raw HTML
143
+ try:
144
+ detail = response.json()
145
+ msg = detail.get("detail", "Internal server error")
146
+ except Exception:
147
+ msg = "Internal server error"
148
+ raise APIError(
149
+ f"Server error ({response.status_code}): {msg}",
150
+ status_code=response.status_code,
151
+ )
152
+
153
+ if response.status_code >= 400:
154
+ try:
155
+ detail = response.json()
156
+ except Exception:
157
+ detail = response.text
158
+ msg = detail.get("detail", str(detail)) if isinstance(detail, dict) else str(detail)
159
+ raise APIError(msg, status_code=response.status_code, detail=detail)
160
+
161
+ if not response.content:
162
+ return None
163
+
164
+ try:
165
+ return response.json()
166
+ except Exception:
167
+ return response.text
168
+
169
+ def close(self) -> None:
170
+ """Close the underlying HTTP client."""
171
+ self._client.close()
172
+
173
+ def __enter__(self) -> CrowdTimeClient:
174
+ return self
175
+
176
+ def __exit__(self, *args: Any) -> None:
177
+ self.close()
@@ -0,0 +1 @@
1
+ """CrowdTime CLI commands."""
@@ -0,0 +1,211 @@
1
+ """AI commands: parse, suggest, summarize."""
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_parse_result,
14
+ format_success,
15
+ format_suggestions,
16
+ print_json,
17
+ )
18
+ from ..models import ParseResult, Suggestion, TimeEntry, WeeklySummary
19
+ from ..utils import format_date, parse_date
20
+
21
+ app = typer.Typer(name="ai", help="AI-powered time tracking features.")
22
+ console = Console()
23
+
24
+
25
+ @app.command()
26
+ def parse(
27
+ text: str = typer.Argument(..., help="Natural language time entry description."),
28
+ 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."),
30
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
31
+ ) -> None:
32
+ """Parse natural language into a time entry.
33
+
34
+ Examples:
35
+ ct ai parse "2 hours on project alpha doing code review"
36
+ ct ai parse "spent yesterday afternoon on bug fixes for client X"
37
+ ct ai parse "30min standup" --yes
38
+ """
39
+ client = CrowdTimeClient(require_auth=True, require_org=True)
40
+
41
+ try:
42
+ data = client.post("/ai/parse/", data={"text": text})
43
+ result = ParseResult(**data)
44
+
45
+ if output_json:
46
+ print_json(result)
47
+ return
48
+
49
+ format_parse_result(result)
50
+
51
+ if dry_run:
52
+ console.print("[dim]Dry run - not saving.[/dim]")
53
+ return
54
+
55
+ if not yes:
56
+ if not typer.confirm("Create this entry?"):
57
+ console.print("[dim]Cancelled.[/dim]")
58
+ return
59
+
60
+ # Confirm and create the entry
61
+ confirm_data = client.post("/ai/parse/confirm/", data={
62
+ "parse_log_id": result.parse_log_id,
63
+ "parsed_result": result.parsed_result,
64
+ })
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)
69
+
70
+ except APIError as e:
71
+ format_error(e.message)
72
+ raise typer.Exit(1)
73
+
74
+
75
+ @app.command()
76
+ def suggest(
77
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Date for suggestions."),
78
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
79
+ ) -> None:
80
+ """Get AI-powered entry suggestions based on your patterns.
81
+
82
+ Suggests what you might be working on based on your history.
83
+ """
84
+ client = CrowdTimeClient(require_auth=True, require_org=True)
85
+
86
+ params: dict = {}
87
+ if date:
88
+ try:
89
+ params["date"] = format_date(parse_date(date))
90
+ except ValueError as e:
91
+ format_error(str(e))
92
+ raise typer.Exit(1)
93
+
94
+ try:
95
+ data = client.get("/ai/suggestions/", params=params)
96
+ suggestions_list = data if isinstance(data, list) else data.get("results", [])
97
+ suggestions = [Suggestion(**item) for item in suggestions_list]
98
+
99
+ if output_json:
100
+ print_json(suggestions)
101
+ return
102
+
103
+ format_suggestions(suggestions)
104
+
105
+ if suggestions:
106
+ choice = typer.prompt(
107
+ "Start a timer with suggestion # (or Enter to skip)",
108
+ default="",
109
+ show_default=False,
110
+ )
111
+ if choice.strip().isdigit():
112
+ idx = int(choice.strip()) - 1
113
+ if 0 <= idx < len(suggestions):
114
+ 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
120
+
121
+ start_data = client.post("/time/start/", data=payload)
122
+ entry = TimeEntry(**start_data)
123
+ format_success("Timer started from suggestion")
124
+ from ..formatters import format_timer
125
+ format_timer(entry)
126
+ else:
127
+ format_error("Invalid selection.")
128
+
129
+ except APIError as e:
130
+ format_error(e.message)
131
+ raise typer.Exit(1)
132
+
133
+
134
+ @app.command()
135
+ def summarize(
136
+ today_flag: bool = typer.Option(False, "--today", help="Summarize today."),
137
+ week: bool = typer.Option(True, "--week", "-w", help="Summarize this week (default)."),
138
+ month: bool = typer.Option(False, "--month", "-m", help="Summarize this month."),
139
+ for_format: str = typer.Option("report", "--for", help="Format for: standup, report, slack."),
140
+ copy: bool = typer.Option(False, "--copy", "-c", help="Copy to clipboard."),
141
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
142
+ ) -> None:
143
+ """Generate an AI summary of your tracked time.
144
+
145
+ Examples:
146
+ ct ai summarize --week --for standup
147
+ ct ai summarize --today --for slack --copy
148
+ """
149
+ client = CrowdTimeClient(require_auth=True, require_org=True)
150
+
151
+ params: dict = {"format": for_format}
152
+ if today_flag:
153
+ params["period"] = "today"
154
+ elif month:
155
+ params["period"] = "month"
156
+ else:
157
+ params["period"] = "week"
158
+
159
+ try:
160
+ data = client.get("/ai/summary/weekly/", params=params)
161
+ summary = WeeklySummary(**data)
162
+
163
+ if output_json:
164
+ print_json(summary)
165
+ return
166
+
167
+ from ..utils import format_duration
168
+
169
+ console.print(f"\n[bold]{summary.headline}[/bold]")
170
+ console.print(f"Total: [yellow]{format_duration(summary.total_hours)}[/yellow]\n")
171
+
172
+ if summary.project_breakdown:
173
+ console.print("[bold]By Project:[/bold]")
174
+ for pb in summary.project_breakdown:
175
+ hours = format_duration(pb.get("hours", 0))
176
+ console.print(f" [cyan]{pb.get('project', 'Unknown')}[/cyan]: {hours}")
177
+
178
+ if summary.insights:
179
+ console.print("\n[bold]Insights:[/bold]")
180
+ for insight in summary.insights:
181
+ console.print(f" - {insight}")
182
+
183
+ console.print()
184
+
185
+ if copy:
186
+ try:
187
+ import subprocess
188
+ text_to_copy = f"{summary.headline}\n"
189
+ text_to_copy += f"Total: {format_duration(summary.total_hours)}\n"
190
+ for pb in summary.project_breakdown:
191
+ text_to_copy += f" {pb.get('project', '')}: {format_duration(pb.get('hours', 0))}\n"
192
+
193
+ process = subprocess.Popen(
194
+ ["pbcopy"], stdin=subprocess.PIPE, text=True
195
+ )
196
+ process.communicate(text_to_copy)
197
+ format_success("Copied to clipboard")
198
+ except Exception:
199
+ try:
200
+ process = subprocess.Popen(
201
+ ["xclip", "-selection", "clipboard"],
202
+ stdin=subprocess.PIPE, text=True
203
+ )
204
+ process.communicate(text_to_copy)
205
+ format_success("Copied to clipboard")
206
+ except Exception:
207
+ console.print("[dim]Could not copy to clipboard.[/dim]")
208
+
209
+ except APIError as e:
210
+ format_error(e.message)
211
+ raise typer.Exit(1)
@@ -0,0 +1,160 @@
1
+ """Authentication commands: login, logout, whoami."""
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 ..auth import clear_token, get_token, save_token
11
+ from ..client import CrowdTimeClient
12
+ from ..config import get_config
13
+ from ..formatters import format_error, format_success, print_json
14
+ from ..models import User
15
+
16
+ app = typer.Typer(name="auth", help="Authentication commands.")
17
+ console = Console()
18
+
19
+
20
+ @app.command()
21
+ def login(
22
+ token: Optional[str] = typer.Option(
23
+ None, "--token", "-t",
24
+ help="API token for manual authentication (skips browser login).",
25
+ ),
26
+ no_browser: bool = typer.Option(
27
+ False, "--no-browser",
28
+ help="Don't open a browser. Prompt for token instead.",
29
+ ),
30
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
31
+ ) -> None:
32
+ """Authenticate with the CrowdTime server.
33
+
34
+ By default, opens your browser for Google OAuth login.
35
+ Use --token to provide an API token directly, or --no-browser to be prompted.
36
+ """
37
+ if token:
38
+ _login_with_token(token, output_json)
39
+ elif no_browser:
40
+ manual_token = typer.prompt("API Token", hide_input=True)
41
+ if not manual_token or not manual_token.strip():
42
+ format_error("Token cannot be empty.")
43
+ raise typer.Exit(1)
44
+ _login_with_token(manual_token.strip(), output_json)
45
+ else:
46
+ _login_with_browser(output_json)
47
+
48
+
49
+ def _login_with_token(token: str, output_json: bool) -> None:
50
+ """Authenticate using a provided API token."""
51
+ token = token.strip()
52
+ if not token:
53
+ format_error("Token cannot be empty.")
54
+ raise typer.Exit(1)
55
+
56
+ save_token(token)
57
+ try:
58
+ client = CrowdTimeClient(require_auth=True, require_org=False)
59
+ data = client.get("/api/v1/auth/me/", org_scoped=False)
60
+ user = User(**data)
61
+
62
+ if output_json:
63
+ print_json(user)
64
+ else:
65
+ format_success(f"Logged in as {user.display_name} ({user.email})")
66
+ except SystemExit:
67
+ clear_token()
68
+ format_error("Invalid token. Could not authenticate.")
69
+ raise typer.Exit(1)
70
+
71
+
72
+ def _login_with_browser(output_json: bool) -> None:
73
+ """Authenticate via browser-based Google OAuth."""
74
+ from ..oauth import run_oauth_flow
75
+
76
+ config = get_config()
77
+ server_url = config.server_url
78
+
79
+ console.print(f"\n[bold]Opening browser for Google login...[/bold]")
80
+ console.print(f"[dim]Server: {server_url}[/dim]")
81
+ console.print(f"[dim]Waiting for authentication (up to 2 minutes)...[/dim]\n")
82
+
83
+ result = run_oauth_flow(server_url)
84
+
85
+ if result is None:
86
+ console.print()
87
+ format_error(
88
+ "Browser login failed or timed out.\n"
89
+ " Possible causes:\n"
90
+ " - Browser didn't open (try copying the URL manually)\n"
91
+ " - Google OAuth is not configured on the server\n"
92
+ " - Login was cancelled or took too long\n\n"
93
+ " You can also login with a token:\n"
94
+ " ct auth login --token <your-api-token>\n"
95
+ " ct auth login --no-browser"
96
+ )
97
+ raise typer.Exit(1)
98
+
99
+ api_token, org_slug = result
100
+
101
+ # Store the token
102
+ save_token(api_token)
103
+
104
+ # Auto-set the organization if returned
105
+ if org_slug:
106
+ config.set("defaults.organization", org_slug)
107
+
108
+ # Verify the token works
109
+ try:
110
+ client = CrowdTimeClient(require_auth=True, require_org=False)
111
+ data = client.get("/api/v1/auth/me/", org_scoped=False)
112
+ user = User(**data)
113
+
114
+ if output_json:
115
+ print_json(user)
116
+ else:
117
+ format_success(f"Logged in as {user.display_name} ({user.email})")
118
+ if org_slug:
119
+ console.print(f" Organization set to [cyan]{org_slug}[/cyan]")
120
+ console.print()
121
+ except SystemExit:
122
+ clear_token()
123
+ format_error("Token verification failed after OAuth login.")
124
+ raise typer.Exit(1)
125
+
126
+
127
+ @app.command()
128
+ def logout() -> None:
129
+ """Log out and remove stored credentials."""
130
+ if not get_token():
131
+ console.print("[dim]Already logged out.[/dim]")
132
+ return
133
+
134
+ clear_token()
135
+ format_success("Logged out. Token removed.")
136
+
137
+
138
+ @app.command()
139
+ def whoami(
140
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
141
+ ) -> None:
142
+ """Show the currently authenticated user and organization."""
143
+ client = CrowdTimeClient(require_auth=True, require_org=False)
144
+ data = client.get("/api/v1/auth/me/", org_scoped=False)
145
+ user = User(**data)
146
+
147
+ if output_json:
148
+ print_json(user)
149
+ return
150
+
151
+ console.print(f"\n[bold]{user.display_name}[/bold]")
152
+ console.print(f" Email: {user.email}")
153
+ console.print(f" Timezone: {user.timezone}")
154
+
155
+ org_slug = client.config.organization
156
+ if org_slug:
157
+ console.print(f" Org: [cyan]{org_slug}[/cyan]")
158
+ else:
159
+ console.print(" Org: [dim]Not set (run ct org switch <slug>)[/dim]")
160
+ console.print()