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/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()
|
crowdtime_cli/client.py
ADDED
|
@@ -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()
|