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.
@@ -0,0 +1,3 @@
1
+ """Project Memory CLI package."""
2
+
3
+ __version__ = "0.1.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/")
@@ -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)