projectmemory-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.
- projectmemory_cli/__init__.py +3 -0
- projectmemory_cli/api/__init__.py +1 -0
- projectmemory_cli/api/auth.py +94 -0
- projectmemory_cli/api/client.py +135 -0
- projectmemory_cli/api/errors.py +124 -0
- projectmemory_cli/api/sessions.py +25 -0
- projectmemory_cli/app.py +80 -0
- projectmemory_cli/commands/__init__.py +1 -0
- projectmemory_cli/commands/auth.py +163 -0
- projectmemory_cli/commands/commits.py +147 -0
- projectmemory_cli/commands/config.py +56 -0
- projectmemory_cli/commands/doctor.py +375 -0
- projectmemory_cli/commands/issues.py +234 -0
- projectmemory_cli/commands/memory.py +235 -0
- projectmemory_cli/commands/next_action.py +282 -0
- projectmemory_cli/commands/notes.py +231 -0
- projectmemory_cli/commands/projects.py +325 -0
- projectmemory_cli/commands/resume.py +240 -0
- projectmemory_cli/commands/sessions.py +277 -0
- projectmemory_cli/commands/tasks.py +435 -0
- projectmemory_cli/config.py +101 -0
- projectmemory_cli/credentials.py +98 -0
- projectmemory_cli/git.py +124 -0
- projectmemory_cli/main.py +4 -0
- projectmemory_cli/models/__init__.py +1 -0
- projectmemory_cli/models/api.py +226 -0
- projectmemory_cli/output.py +227 -0
- projectmemory_cli/project_context.py +244 -0
- projectmemory_cli-0.1.0.dist-info/METADATA +239 -0
- projectmemory_cli-0.1.0.dist-info/RECORD +32 -0
- projectmemory_cli-0.1.0.dist-info/WHEEL +4 -0
- projectmemory_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API package."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Device authorization flow API calls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from projectmemory_cli.api.client import APIClient
|
|
8
|
+
from projectmemory_cli.api.errors import APIError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DeviceAuthStart:
|
|
13
|
+
device_code: str
|
|
14
|
+
user_code: str
|
|
15
|
+
verification_uri: str
|
|
16
|
+
verification_uri_complete: str
|
|
17
|
+
expires_in: int
|
|
18
|
+
interval: int
|
|
19
|
+
authorization_id: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DeviceAuthResult:
|
|
24
|
+
access_token: str
|
|
25
|
+
token_type: str
|
|
26
|
+
token_id: int
|
|
27
|
+
user: dict
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start_device_auth(client: APIClient, device_name: str = "Project Memory CLI") -> DeviceAuthStart:
|
|
31
|
+
data = client.post("/api/cli/auth/device/", json={"device_name": device_name})
|
|
32
|
+
return DeviceAuthStart(
|
|
33
|
+
device_code=data["device_code"],
|
|
34
|
+
user_code=data["user_code"],
|
|
35
|
+
verification_uri=data["verification_uri"],
|
|
36
|
+
verification_uri_complete=data["verification_uri_complete"],
|
|
37
|
+
expires_in=data["expires_in"],
|
|
38
|
+
interval=data["interval"],
|
|
39
|
+
authorization_id=data["authorization_id"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def poll_device_auth(
|
|
44
|
+
client: APIClient,
|
|
45
|
+
device_code: str,
|
|
46
|
+
*,
|
|
47
|
+
interval: int = 5,
|
|
48
|
+
timeout: int = 900,
|
|
49
|
+
on_pending: object = None,
|
|
50
|
+
) -> DeviceAuthResult:
|
|
51
|
+
"""Poll until the authorization is approved, denied, or expired.
|
|
52
|
+
|
|
53
|
+
Calls `on_pending()` (if callable) on each pending response.
|
|
54
|
+
Raises APIError on denial, expiry, or timeout.
|
|
55
|
+
"""
|
|
56
|
+
deadline = time.time() + timeout
|
|
57
|
+
while time.time() < deadline:
|
|
58
|
+
try:
|
|
59
|
+
data = client.post("/api/cli/auth/device/poll/", json={"device_code": device_code})
|
|
60
|
+
return DeviceAuthResult(
|
|
61
|
+
access_token=data["access_token"],
|
|
62
|
+
token_type=data["token_type"],
|
|
63
|
+
token_id=data["token_id"],
|
|
64
|
+
user=data["user"],
|
|
65
|
+
)
|
|
66
|
+
except APIError as exc:
|
|
67
|
+
err_data = getattr(exc, "data", {}) or {}
|
|
68
|
+
err_code = err_data.get("error", "")
|
|
69
|
+
if err_code == "authorization_pending":
|
|
70
|
+
if callable(on_pending):
|
|
71
|
+
on_pending() # type: ignore[call-arg]
|
|
72
|
+
time.sleep(interval)
|
|
73
|
+
continue
|
|
74
|
+
if err_code == "expired_token":
|
|
75
|
+
raise APIError("Authorization code expired. Run `pm login` to try again.")
|
|
76
|
+
if err_code == "access_denied":
|
|
77
|
+
raise APIError("Authorization was denied in the browser.")
|
|
78
|
+
if err_code == "authorization_consumed":
|
|
79
|
+
raise APIError("This authorization code was already used.")
|
|
80
|
+
raise
|
|
81
|
+
raise APIError("Authorization timed out. Run `pm login` to try again.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def revoke_token(client: APIClient) -> bool:
|
|
85
|
+
data = client.post("/api/cli/auth/logout/")
|
|
86
|
+
return bool(data.get("revoked"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def whoami(client: APIClient) -> dict:
|
|
90
|
+
return client.get("/api/cli/whoami/")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def auth_status(client: APIClient) -> dict:
|
|
94
|
+
return client.get("/api/cli/auth/status/")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""HTTPX-based API client.
|
|
2
|
+
|
|
3
|
+
Injects:
|
|
4
|
+
- Authorization: Bearer <token>
|
|
5
|
+
- X-Project-Memory-Session: <session_id> (when provided)
|
|
6
|
+
- User-Agent: projectmemory-cli/<version>
|
|
7
|
+
- Content-Type: application/json
|
|
8
|
+
|
|
9
|
+
All requests go through _request() which translates HTTP errors into
|
|
10
|
+
structured APIError subclasses via api.errors.raise_for_response().
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from projectmemory_cli import output
|
|
19
|
+
from projectmemory_cli.api.errors import NetworkError, raise_for_response
|
|
20
|
+
|
|
21
|
+
_VERSION = "0.1.0"
|
|
22
|
+
_TIMEOUT = 30.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str,
|
|
29
|
+
token: str | None = None,
|
|
30
|
+
session_id: int | str | None = None,
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
):
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
self.token = token
|
|
35
|
+
self.session_id = session_id
|
|
36
|
+
self.debug = debug
|
|
37
|
+
|
|
38
|
+
def _headers(self, extra: dict | None = None) -> dict[str, str]:
|
|
39
|
+
headers: dict[str, str] = {
|
|
40
|
+
"User-Agent": f"projectmemory-cli/{_VERSION}",
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
}
|
|
43
|
+
if self.token:
|
|
44
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
45
|
+
if self.session_id is not None:
|
|
46
|
+
headers["X-Project-Memory-Session"] = str(self.session_id)
|
|
47
|
+
if extra:
|
|
48
|
+
headers.update(extra)
|
|
49
|
+
return headers
|
|
50
|
+
|
|
51
|
+
def _log_request(self, method: str, url: str, body: Any = None) -> None:
|
|
52
|
+
if not self.debug:
|
|
53
|
+
return
|
|
54
|
+
token_hint = " [token=pmcli_***]" if self.token else ""
|
|
55
|
+
output.debug(f"{method} {url}{token_hint}")
|
|
56
|
+
if body:
|
|
57
|
+
import json
|
|
58
|
+
output.debug(f" body={json.dumps(body, default=str)[:300]}")
|
|
59
|
+
|
|
60
|
+
def _log_response(self, resp: httpx.Response) -> None:
|
|
61
|
+
if self.debug:
|
|
62
|
+
output.debug(f" -> {resp.status_code}")
|
|
63
|
+
|
|
64
|
+
def _request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
json: Any = None,
|
|
70
|
+
params: dict | None = None,
|
|
71
|
+
project_name: str = "",
|
|
72
|
+
) -> Any:
|
|
73
|
+
url = f"{self.base_url}{path}"
|
|
74
|
+
self._log_request(method, url, json)
|
|
75
|
+
try:
|
|
76
|
+
with httpx.Client(timeout=_TIMEOUT) as client:
|
|
77
|
+
resp = client.request(
|
|
78
|
+
method,
|
|
79
|
+
url,
|
|
80
|
+
headers=self._headers(),
|
|
81
|
+
json=json,
|
|
82
|
+
params=params,
|
|
83
|
+
)
|
|
84
|
+
except httpx.TimeoutException:
|
|
85
|
+
raise NetworkError("Request timed out. Check your connection.")
|
|
86
|
+
except httpx.ConnectError:
|
|
87
|
+
raise NetworkError(
|
|
88
|
+
"Cannot reach the Project Memory API.\n"
|
|
89
|
+
"Check your connection or try again later."
|
|
90
|
+
)
|
|
91
|
+
except httpx.RequestError as exc:
|
|
92
|
+
raise NetworkError(f"Network error: {exc}")
|
|
93
|
+
|
|
94
|
+
self._log_response(resp)
|
|
95
|
+
|
|
96
|
+
if resp.status_code == 204:
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
data = resp.json()
|
|
101
|
+
except Exception:
|
|
102
|
+
data = {"detail": resp.text[:500]}
|
|
103
|
+
|
|
104
|
+
if not resp.is_success:
|
|
105
|
+
raise_for_response(resp.status_code, data, project_name=project_name)
|
|
106
|
+
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
def get(self, path: str, *, params: dict | None = None, project_name: str = "") -> Any:
|
|
110
|
+
return self._request("GET", path, params=params, project_name=project_name)
|
|
111
|
+
|
|
112
|
+
def post(self, path: str, *, json: Any = None, project_name: str = "") -> Any:
|
|
113
|
+
return self._request("POST", path, json=json or {}, project_name=project_name)
|
|
114
|
+
|
|
115
|
+
def patch(self, path: str, *, json: Any = None, project_name: str = "") -> Any:
|
|
116
|
+
return self._request("PATCH", path, json=json or {}, project_name=project_name)
|
|
117
|
+
|
|
118
|
+
def delete(self, path: str, *, project_name: str = "") -> Any:
|
|
119
|
+
return self._request("DELETE", path, project_name=project_name)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_env(
|
|
123
|
+
cls,
|
|
124
|
+
*,
|
|
125
|
+
session_id: int | str | None = None,
|
|
126
|
+
debug: bool = False,
|
|
127
|
+
) -> "APIClient":
|
|
128
|
+
"""Build a client using current config + stored credentials."""
|
|
129
|
+
from projectmemory_cli import config, credentials
|
|
130
|
+
return cls(
|
|
131
|
+
base_url=config.get_api_url(),
|
|
132
|
+
token=credentials.get_token(),
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
debug=debug,
|
|
135
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Structured API error types and HTTP→CLI exit code mapping."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class APIError(Exception):
|
|
8
|
+
"""Base API error."""
|
|
9
|
+
exit_code: int = 1
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status_code: int = 0, data: Any = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.data = data or {}
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return self.args[0]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(APIError):
|
|
21
|
+
"""401 — token missing, expired, or revoked."""
|
|
22
|
+
exit_code = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionError(APIError):
|
|
26
|
+
"""403 — permission denied, read-only, email verification required."""
|
|
27
|
+
exit_code = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationError(APIError):
|
|
31
|
+
"""400/422 — validation failure."""
|
|
32
|
+
exit_code = 4
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(APIError):
|
|
36
|
+
"""404 — resource not found."""
|
|
37
|
+
exit_code = 5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NetworkError(APIError):
|
|
41
|
+
"""Connection / timeout error."""
|
|
42
|
+
exit_code = 6
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CancelledError(APIError):
|
|
46
|
+
"""User cancelled the operation."""
|
|
47
|
+
exit_code = 7
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionConflictError(APIError):
|
|
51
|
+
"""409 — session conflict (no active session or already finalized)."""
|
|
52
|
+
exit_code = 8
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NoActiveSessionError(SessionConflictError):
|
|
56
|
+
"""No active session for a project."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionAlreadyFinalizedError(SessionConflictError):
|
|
60
|
+
"""Session was already ended by another client."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def raise_for_response(status_code: int, data: dict, project_name: str = "") -> None:
|
|
64
|
+
"""Convert an API error response into a structured exception."""
|
|
65
|
+
code = data.get("code", "")
|
|
66
|
+
detail = data.get("detail", "") or data.get("error", "") or str(data)
|
|
67
|
+
|
|
68
|
+
if status_code == 401:
|
|
69
|
+
raise AuthenticationError(
|
|
70
|
+
"Your session has expired or is invalid.\n\nRun:\n pm login",
|
|
71
|
+
status_code=status_code,
|
|
72
|
+
data=data,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if status_code == 403:
|
|
76
|
+
ev = data.get("email_verification_required")
|
|
77
|
+
if ev:
|
|
78
|
+
raise PermissionError(
|
|
79
|
+
"Email verification is required before using this action.\n\n"
|
|
80
|
+
"Open:\n https://projectmemory.app/settings/security",
|
|
81
|
+
status_code=status_code,
|
|
82
|
+
data=data,
|
|
83
|
+
)
|
|
84
|
+
if "read-only" in detail.lower() or "read_only" in str(data).lower():
|
|
85
|
+
raise PermissionError(
|
|
86
|
+
"This account is currently read-only because it exceeds the Free plan limit.\n\n"
|
|
87
|
+
"Upgrade or reduce the number of projects in the web app.",
|
|
88
|
+
status_code=status_code,
|
|
89
|
+
data=data,
|
|
90
|
+
)
|
|
91
|
+
raise PermissionError(detail or "Permission denied.", status_code=status_code, data=data)
|
|
92
|
+
|
|
93
|
+
if status_code == 404:
|
|
94
|
+
raise NotFoundError(detail or "Resource not found.", status_code=status_code, data=data)
|
|
95
|
+
|
|
96
|
+
if status_code == 409:
|
|
97
|
+
if code == "no_active_session":
|
|
98
|
+
name = project_name or "this project"
|
|
99
|
+
raise NoActiveSessionError(
|
|
100
|
+
f"No active session for {name}.\n\nRun:\n pm resume",
|
|
101
|
+
status_code=status_code,
|
|
102
|
+
data=data,
|
|
103
|
+
)
|
|
104
|
+
if "already been finalized" in detail or "already finalized" in detail:
|
|
105
|
+
raise SessionAlreadyFinalizedError(
|
|
106
|
+
"This session was already ended from another client.\n\nRun:\n pm resume\n\nto begin a new session.",
|
|
107
|
+
status_code=status_code,
|
|
108
|
+
data=data,
|
|
109
|
+
)
|
|
110
|
+
raise SessionConflictError(detail, status_code=status_code, data=data)
|
|
111
|
+
|
|
112
|
+
if status_code in (400, 422):
|
|
113
|
+
# Format field errors nicely
|
|
114
|
+
if isinstance(data, dict) and any(isinstance(v, list) for v in data.values()):
|
|
115
|
+
lines = []
|
|
116
|
+
for field, msgs in data.items():
|
|
117
|
+
if isinstance(msgs, list):
|
|
118
|
+
lines.append(f" {field}: {', '.join(str(m) for m in msgs)}")
|
|
119
|
+
else:
|
|
120
|
+
lines.append(f" {field}: {msgs}")
|
|
121
|
+
raise ValidationError("\n".join(lines), status_code=status_code, data=data)
|
|
122
|
+
raise ValidationError(detail or str(data), status_code=status_code, data=data)
|
|
123
|
+
|
|
124
|
+
raise APIError(detail or f"Unexpected error ({status_code})", status_code=status_code, data=data)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Session-related API calls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from projectmemory_cli.api.client import APIClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_session_status(client: APIClient) -> dict:
|
|
8
|
+
return client.get("/api/cli/session/status/")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_session_review(client: APIClient, session_id: int | str) -> dict:
|
|
12
|
+
return client.get("/api/cli/session/review/", params={"session_id": str(session_id)})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def end_session(client: APIClient, session_id: int | str, *, payload: dict | None = None) -> dict:
|
|
16
|
+
body = {"session_id": int(session_id), **(payload or {})}
|
|
17
|
+
return client.post("/api/cli/session/end/", json=body)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resume_project(client: APIClient, project_id: int | str) -> dict:
|
|
21
|
+
return client.post(f"/api/cli/projects/{project_id}/resume/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_project_sessions(client: APIClient, project_id: int | str) -> dict:
|
|
25
|
+
return client.get(f"/api/cli/projects/{project_id}/sessions/")
|
projectmemory_cli/app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Typer application and global options."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from projectmemory_cli import config as pm_config
|
|
7
|
+
from projectmemory_cli import output
|
|
8
|
+
from projectmemory_cli.commands import (
|
|
9
|
+
auth as auth_cmd,
|
|
10
|
+
commits as commits_cmd,
|
|
11
|
+
config as config_cmd,
|
|
12
|
+
doctor as doctor_cmd,
|
|
13
|
+
issues as issues_cmd,
|
|
14
|
+
memory as memory_cmd,
|
|
15
|
+
next_action as next_action_cmd,
|
|
16
|
+
notes as notes_cmd,
|
|
17
|
+
projects as projects_cmd,
|
|
18
|
+
resume as resume_cmd,
|
|
19
|
+
sessions as sessions_cmd,
|
|
20
|
+
tasks as tasks_cmd,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="pm",
|
|
25
|
+
help="Project Memory — developer context in your terminal.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
pretty_exceptions_enable=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def main(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
json: bool = typer.Option(False, "--json", help="Output JSON where supported."),
|
|
36
|
+
plain: bool = typer.Option(False, "--plain", help="Use plain text output."),
|
|
37
|
+
quiet: bool = typer.Option(False, "--quiet", help="Only print essential output."),
|
|
38
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable color output."),
|
|
39
|
+
debug: bool = typer.Option(False, "--debug", help="Show redacted debug information."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Configure process-wide output options before command execution."""
|
|
42
|
+
no_color = no_color or not bool(pm_config.get("color", True))
|
|
43
|
+
output.configure(
|
|
44
|
+
json_mode=json,
|
|
45
|
+
plain_mode=plain,
|
|
46
|
+
quiet_mode=quiet,
|
|
47
|
+
debug_mode=debug,
|
|
48
|
+
no_color=no_color,
|
|
49
|
+
)
|
|
50
|
+
ctx.obj = {"json": json, "plain": plain, "quiet": quiet, "no_color": no_color, "debug": debug}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app.add_typer(projects_cmd.app, name="project")
|
|
54
|
+
app.add_typer(sessions_cmd.app, name="session")
|
|
55
|
+
app.add_typer(memory_cmd.app, name="memory")
|
|
56
|
+
app.add_typer(next_action_cmd.app, name="next")
|
|
57
|
+
app.add_typer(tasks_cmd.app, name="task")
|
|
58
|
+
app.add_typer(notes_cmd.app, name="note")
|
|
59
|
+
app.add_typer(issues_cmd.app, name="issue")
|
|
60
|
+
app.add_typer(commits_cmd.app, name="commit")
|
|
61
|
+
|
|
62
|
+
app.command("login")(auth_cmd.login)
|
|
63
|
+
app.command("logout")(auth_cmd.logout)
|
|
64
|
+
app.command("whoami")(auth_cmd.whoami)
|
|
65
|
+
app.command("doctor")(doctor_cmd.doctor)
|
|
66
|
+
|
|
67
|
+
auth_app = typer.Typer(help="Authentication commands.", no_args_is_help=True)
|
|
68
|
+
auth_app.command("status")(auth_cmd.status)
|
|
69
|
+
app.add_typer(auth_app, name="auth")
|
|
70
|
+
|
|
71
|
+
config_app = typer.Typer(help="Configuration commands.", no_args_is_help=True)
|
|
72
|
+
config_app.command("show")(config_cmd.show)
|
|
73
|
+
config_app.command("set")(config_cmd.set_value)
|
|
74
|
+
config_app.command("reset")(config_cmd.reset)
|
|
75
|
+
app.add_typer(config_app, name="config")
|
|
76
|
+
|
|
77
|
+
app.command("resume")(resume_cmd.resume)
|
|
78
|
+
app.command("web")(projects_cmd.web)
|
|
79
|
+
app.command("done")(tasks_cmd.done_interactive)
|
|
80
|
+
app.command("capture")(notes_cmd.capture)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package."""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Authentication commands: pm login, pm logout, pm whoami, pm auth status."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from projectmemory_cli import credentials, output
|
|
10
|
+
from projectmemory_cli.api import auth as auth_api
|
|
11
|
+
from projectmemory_cli.api.client import APIClient
|
|
12
|
+
from projectmemory_cli.api.errors import APIError, AuthenticationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_client(require_auth: bool = False) -> APIClient:
|
|
16
|
+
client = APIClient.from_env()
|
|
17
|
+
if require_auth and not client.token:
|
|
18
|
+
output.error("Not logged in. Run: pm login")
|
|
19
|
+
raise SystemExit(2)
|
|
20
|
+
return client
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def login(
|
|
24
|
+
device_name: str = typer.Option(
|
|
25
|
+
"Project Memory CLI",
|
|
26
|
+
"--device-name",
|
|
27
|
+
help="Name for this CLI session token.",
|
|
28
|
+
),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Authenticate via browser-based device authorization."""
|
|
31
|
+
client = APIClient.from_env()
|
|
32
|
+
|
|
33
|
+
output.info("Requesting authorization from Project Memory...")
|
|
34
|
+
try:
|
|
35
|
+
auth = auth_api.start_device_auth(client, device_name=device_name)
|
|
36
|
+
except APIError as exc:
|
|
37
|
+
output.error(str(exc))
|
|
38
|
+
raise SystemExit(exc.exit_code)
|
|
39
|
+
|
|
40
|
+
output.blank()
|
|
41
|
+
output.info(f"[bold]Code:[/bold] [bright_cyan]{auth.user_code}[/bright_cyan]")
|
|
42
|
+
output.blank()
|
|
43
|
+
|
|
44
|
+
opened = webbrowser.open(auth.verification_uri_complete)
|
|
45
|
+
if opened:
|
|
46
|
+
output.info("Opening Project Memory in your browser...")
|
|
47
|
+
else:
|
|
48
|
+
output.warn("Could not open browser automatically.")
|
|
49
|
+
output.info("Visit:")
|
|
50
|
+
output.info(f" [link]{auth.verification_uri_complete}[/link]")
|
|
51
|
+
output.blank()
|
|
52
|
+
output.info("[dim]Waiting for authorization...[/dim]")
|
|
53
|
+
|
|
54
|
+
dots = [0]
|
|
55
|
+
|
|
56
|
+
def on_pending() -> None:
|
|
57
|
+
dots[0] += 1
|
|
58
|
+
if dots[0] % 6 == 0: # every ~30s
|
|
59
|
+
output.info("[dim]Still waiting...[/dim]")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = auth_api.poll_device_auth(
|
|
63
|
+
client,
|
|
64
|
+
auth.device_code,
|
|
65
|
+
interval=auth.interval,
|
|
66
|
+
timeout=auth.expires_in,
|
|
67
|
+
on_pending=on_pending,
|
|
68
|
+
)
|
|
69
|
+
except APIError as exc:
|
|
70
|
+
output.blank()
|
|
71
|
+
output.error(str(exc))
|
|
72
|
+
raise SystemExit(exc.exit_code)
|
|
73
|
+
|
|
74
|
+
# Store token
|
|
75
|
+
used_keyring = credentials.save_token(result.access_token)
|
|
76
|
+
|
|
77
|
+
output.blank()
|
|
78
|
+
user = result.user
|
|
79
|
+
email = user.get("email", "")
|
|
80
|
+
plan = user.get("plan", "free")
|
|
81
|
+
output.success(f"Logged in as [bold]{email}[/bold] [dim]({plan})[/dim]")
|
|
82
|
+
if not used_keyring:
|
|
83
|
+
output.hint("Token saved to fallback credentials file (keyring not available).")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def logout() -> None:
|
|
87
|
+
"""Revoke CLI token and remove it locally."""
|
|
88
|
+
token = credentials.get_token()
|
|
89
|
+
if not token:
|
|
90
|
+
output.warn("Not currently logged in.")
|
|
91
|
+
raise SystemExit(0)
|
|
92
|
+
|
|
93
|
+
client = APIClient.from_env()
|
|
94
|
+
try:
|
|
95
|
+
auth_api.revoke_token(client)
|
|
96
|
+
output.success("Token revoked on server.")
|
|
97
|
+
except AuthenticationError:
|
|
98
|
+
output.warn("Token was already invalid on the server.")
|
|
99
|
+
except APIError as exc:
|
|
100
|
+
output.warn(f"Could not revoke token remotely: {exc}")
|
|
101
|
+
|
|
102
|
+
credentials.delete_token()
|
|
103
|
+
output.success("Logged out.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def whoami() -> None:
|
|
107
|
+
"""Show the currently authenticated user."""
|
|
108
|
+
client = _make_client(require_auth=True)
|
|
109
|
+
try:
|
|
110
|
+
data = auth_api.whoami(client)
|
|
111
|
+
except APIError as exc:
|
|
112
|
+
output.error(str(exc))
|
|
113
|
+
raise SystemExit(exc.exit_code)
|
|
114
|
+
|
|
115
|
+
user = data.get("user", {})
|
|
116
|
+
email = user.get("email", "—")
|
|
117
|
+
name = user.get("name", "")
|
|
118
|
+
plan = user.get("plan", "free")
|
|
119
|
+
verified = user.get("email_verified", True)
|
|
120
|
+
|
|
121
|
+
output.blank()
|
|
122
|
+
output.kv("Email", email)
|
|
123
|
+
if name:
|
|
124
|
+
output.kv("Name", name)
|
|
125
|
+
output.kv("Plan", plan.capitalize())
|
|
126
|
+
output.kv("Email verified", "Yes" if verified else "[yellow]No[/yellow]")
|
|
127
|
+
|
|
128
|
+
limits = user.get("limits", {})
|
|
129
|
+
usage = user.get("usage", {})
|
|
130
|
+
if usage:
|
|
131
|
+
projects_used = usage.get("projects", "—")
|
|
132
|
+
projects_max = limits.get("max_projects") or "unlimited"
|
|
133
|
+
output.kv("Projects", f"{projects_used} / {projects_max}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def status() -> None:
|
|
137
|
+
"""Show authentication status (alias for pm auth status)."""
|
|
138
|
+
token = credentials.get_token()
|
|
139
|
+
if not token:
|
|
140
|
+
output.warn("Not logged in.")
|
|
141
|
+
output.hint("Run: pm login")
|
|
142
|
+
raise SystemExit(2)
|
|
143
|
+
|
|
144
|
+
client = APIClient.from_env()
|
|
145
|
+
try:
|
|
146
|
+
data = auth_api.auth_status(client)
|
|
147
|
+
except AuthenticationError:
|
|
148
|
+
output.error("Token is expired or revoked.")
|
|
149
|
+
output.hint("Run: pm login")
|
|
150
|
+
raise SystemExit(2)
|
|
151
|
+
except APIError as exc:
|
|
152
|
+
output.error(str(exc))
|
|
153
|
+
raise SystemExit(exc.exit_code)
|
|
154
|
+
|
|
155
|
+
authenticated = data.get("authenticated", False)
|
|
156
|
+
user = data.get("user", {})
|
|
157
|
+
email = user.get("email", "—")
|
|
158
|
+
|
|
159
|
+
if authenticated:
|
|
160
|
+
output.success(f"Authenticated as [bold]{email}[/bold]")
|
|
161
|
+
else:
|
|
162
|
+
output.warn("Not authenticated.")
|
|
163
|
+
raise SystemExit(2)
|