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,147 @@
1
+ """Commit commands: pm commit link."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import questionary
7
+ import typer
8
+
9
+ from projectmemory_cli import git, output
10
+ from projectmemory_cli.api.client import APIClient
11
+ from projectmemory_cli.api.errors import APIError
12
+ from projectmemory_cli.project_context import guard_project_session
13
+
14
+ app = typer.Typer(help="Commit linking commands.", no_args_is_help=True)
15
+
16
+
17
+ def _require_client() -> APIClient:
18
+ from projectmemory_cli import credentials
19
+ token = credentials.get_token()
20
+ if not token:
21
+ output.error("Not logged in. Run: pm login")
22
+ raise SystemExit(2)
23
+ return APIClient.from_env()
24
+
25
+
26
+ def _session_client(client: APIClient, session_id: int) -> APIClient:
27
+ return APIClient(base_url=client.base_url, token=client.token, session_id=session_id, debug=client.debug)
28
+
29
+
30
+ def _project_repo(client: APIClient, project_id: int) -> str:
31
+ ctx_data = client.get(f"/api/cli/projects/{project_id}/context/")
32
+ github = ctx_data.get("github", {})
33
+ if not github.get("connected"):
34
+ output.error("This project has no connected GitHub repository.")
35
+ output.hint("Connect a repository in the web app first.")
36
+ raise SystemExit(3)
37
+ repo_full = github.get("repository", {}).get("full_name", "")
38
+ if not repo_full:
39
+ output.error("Could not determine the connected GitHub repository.")
40
+ raise SystemExit(3)
41
+ return repo_full
42
+
43
+
44
+ def _verify_local_repo_matches(repo_full: str, root) -> None:
45
+ local_remote = git.normalize_github_repo(git.get_remote_url(root)) if root else ""
46
+ if local_remote and local_remote.lower() != repo_full.lower():
47
+ output.error(
48
+ f"Repository mismatch: local repo is '{local_remote}', "
49
+ f"but this project is connected to '{repo_full.lower()}'."
50
+ )
51
+ raise SystemExit(3)
52
+
53
+
54
+ def _resolve_commit(ref: str, root) -> tuple[str, object | None]:
55
+ sha = git.resolve_sha(ref, root) if root else ""
56
+ if not sha and ref:
57
+ sha = ref
58
+ if not sha:
59
+ output.error("Could not resolve commit. Ensure you are in a git repository.")
60
+ raise SystemExit(1)
61
+ return sha, git.get_commit_info(sha, root)
62
+
63
+
64
+ def _link_commit(client: APIClient, session_id: int, project_name: str, task_id: int, sha: str, info, repo_full: str) -> None:
65
+ sc = _session_client(client, session_id)
66
+ payload: dict = {"sha": sha, "repository_full_name": repo_full}
67
+ if info:
68
+ payload["message"] = info.message
69
+ payload["author_name"] = info.author
70
+ try:
71
+ data = sc.post(f"/api/cli/tasks/{task_id}/commits/link/", json=payload, project_name=project_name)
72
+ except APIError as exc:
73
+ output.error(str(exc))
74
+ raise SystemExit(exc.exit_code)
75
+ if data.get("created", False):
76
+ output.success(f"Commit {sha[:8]} linked to task #{task_id}.")
77
+ else:
78
+ output.info(f"Commit {sha[:8]} was already linked to task #{task_id}.")
79
+
80
+
81
+ @app.command("link")
82
+ def commit_link(
83
+ sha: Optional[str] = typer.Argument(None, help="Commit SHA or ref, e.g. HEAD."),
84
+ task_id: Optional[int] = typer.Option(None, "--task", "-t", help="Completed task ID."),
85
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
86
+ ) -> None:
87
+ """Link a local git commit to a completed task."""
88
+ client = _require_client()
89
+ project, session = guard_project_session(client, project_arg=project_arg)
90
+ root = git.get_repo_root()
91
+ if not root:
92
+ output.error("Run this command inside a Git repository.")
93
+ raise SystemExit(1)
94
+
95
+ try:
96
+ repo_full = _project_repo(client, project.id)
97
+ _verify_local_repo_matches(repo_full, root)
98
+ except APIError as exc:
99
+ output.error(str(exc))
100
+ raise SystemExit(exc.exit_code)
101
+
102
+ if sha and task_id:
103
+ resolved_sha, info = _resolve_commit(sha, root)
104
+ _link_commit(client, session.id, project.name, task_id, resolved_sha, info, repo_full)
105
+ return
106
+
107
+ commits = git.get_recent_commits(root, limit=10)
108
+ if not commits:
109
+ output.info("No recent local commits found.")
110
+ raise SystemExit(0)
111
+
112
+ try:
113
+ tasks_data = client.get(f"/api/cli/projects/{project.id}/tasks/", params={"status": "completed"})
114
+ except APIError as exc:
115
+ output.error(str(exc))
116
+ raise SystemExit(exc.exit_code)
117
+ completed_tasks = [t for t in tasks_data.get("tasks", []) if not t.get("has_linked_commits")]
118
+ if not completed_tasks:
119
+ output.info("No completed tasks without linked commits.")
120
+ raise SystemExit(0)
121
+
122
+ selected_commit = None
123
+ if sha:
124
+ resolved_sha, info = _resolve_commit(sha, root)
125
+ else:
126
+ selected_commit = questionary.select(
127
+ "Select commit:",
128
+ choices=[questionary.Choice(f"{c.short_sha} {c.message[:70]}", value=c) for c in commits]
129
+ + [questionary.Choice("Cancel", value=None)],
130
+ ).ask()
131
+ if not selected_commit:
132
+ output.info("Cancelled.")
133
+ raise SystemExit(7)
134
+ resolved_sha, info = selected_commit.sha, selected_commit
135
+
136
+ if not task_id:
137
+ selected_task = questionary.select(
138
+ "Select completed task:",
139
+ choices=[questionary.Choice(f"#{t['id']} {t['title']}", value=t) for t in completed_tasks]
140
+ + [questionary.Choice("Cancel", value=None)],
141
+ ).ask()
142
+ if not selected_task:
143
+ output.info("Cancelled.")
144
+ raise SystemExit(7)
145
+ task_id = selected_task["id"]
146
+
147
+ _link_commit(client, session.id, project.name, task_id, resolved_sha, info, repo_full)
@@ -0,0 +1,56 @@
1
+ """Configuration commands: pm config show, pm config set, pm config reset."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from projectmemory_cli import config, output
7
+
8
+
9
+ def show() -> None:
10
+ """Display current configuration."""
11
+ cfg = config.all_settings()
12
+ output.blank()
13
+ output.info("[bold]Configuration[/bold]")
14
+ output.blank()
15
+ for key, value in cfg.items():
16
+ output.kv(key, str(value))
17
+ output.blank()
18
+ output.hint(f"Config file: {config.CONFIG_FILE}")
19
+
20
+
21
+ def set_value(
22
+ key: str = typer.Argument(..., help="Config key to set."),
23
+ value: str = typer.Argument(..., help="Value to assign."),
24
+ ) -> None:
25
+ """Set a configuration value."""
26
+ valid_keys = set(config.DEFAULTS.keys())
27
+ if key not in valid_keys:
28
+ output.error(f"Unknown config key: {key!r}")
29
+ output.hint(f"Valid keys: {', '.join(sorted(valid_keys))}")
30
+ raise SystemExit(4)
31
+
32
+ # Type coercion
33
+ current = config.DEFAULTS.get(key)
34
+ if isinstance(current, bool):
35
+ coerced: object = value.lower() in ("true", "1", "yes", "on")
36
+ elif isinstance(current, int):
37
+ try:
38
+ coerced = int(value)
39
+ except ValueError:
40
+ output.error(f"{key} must be an integer.")
41
+ raise SystemExit(4)
42
+ else:
43
+ coerced = value
44
+
45
+ config.set_key(key, coerced)
46
+ output.success(f"{key} = {coerced!r}")
47
+
48
+
49
+ def reset() -> None:
50
+ """Reset all configuration to defaults."""
51
+ confirmed = typer.confirm("Reset all configuration to defaults?", default=False)
52
+ if not confirmed:
53
+ output.info("Cancelled.")
54
+ raise SystemExit(7)
55
+ config.reset()
56
+ output.success("Configuration reset to defaults.")
@@ -0,0 +1,375 @@
1
+ """Diagnostic command: pm doctor."""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import sys
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ import typer
11
+ from rich.table import Table
12
+
13
+ from projectmemory_cli import __version__, config, credentials, git, output
14
+ from projectmemory_cli.api.client import APIClient
15
+ from projectmemory_cli.api.errors import APIError
16
+ from projectmemory_cli.project_context import get_mapped_project_id_for_cwd
17
+
18
+
19
+ Status = str
20
+
21
+
22
+ def _status(ok: bool | None, label: str) -> dict[str, Any]:
23
+ if ok is True:
24
+ return {"status": "ok", "label": label, "icon": "✓"}
25
+ if ok is False:
26
+ return {"status": "error", "label": label, "icon": "✗"}
27
+ return {"status": "warning", "label": label, "icon": "⚠"}
28
+
29
+
30
+ def _mask_url(url: str) -> str:
31
+ # Avoid leaking basic-auth style URLs if a user configured one accidentally.
32
+ if "@" in url and "://" in url:
33
+ scheme, rest = url.split("://", 1)
34
+ return f"{scheme}://***@{rest.split('@', 1)[1]}"
35
+ return url
36
+
37
+
38
+ def _safe_get(url: str, token: str | None = None) -> tuple[bool, int | None, float | None, str]:
39
+ headers = {"Accept": "application/json", "User-Agent": f"projectmemory-cli/{__version__}"}
40
+ if token:
41
+ headers["Authorization"] = f"Bearer {token}"
42
+ start = time.perf_counter()
43
+ try:
44
+ resp = httpx.get(url, headers=headers, timeout=8.0)
45
+ latency = (time.perf_counter() - start) * 1000
46
+ return True, resp.status_code, latency, ""
47
+ except Exception as exc:
48
+ latency = (time.perf_counter() - start) * 1000
49
+ return False, None, latency, str(exc)
50
+
51
+
52
+ def _add_table(title: str, rows: list[tuple[str, str]]) -> None:
53
+ table = Table(title=title, show_header=False, box=None, padding=(0, 2))
54
+ table.add_column("Field", style="dim")
55
+ table.add_column("Value")
56
+ for key, value in rows:
57
+ table.add_row(key, value)
58
+ output.console.print(table)
59
+
60
+
61
+ def _health_line(state: dict[str, Any]) -> str:
62
+ icon = state.get("icon", "•")
63
+ label = state.get("label", "")
64
+ status = state.get("status", "")
65
+ if status == "ok":
66
+ return f"[green]{icon}[/green] {label}"
67
+ if status == "error":
68
+ return f"[red]{icon}[/red] {label}"
69
+ return f"[yellow]{icon}[/yellow] {label}"
70
+
71
+
72
+ def _print_checks(checks: list[dict[str, Any]]) -> None:
73
+ output.header("Checks")
74
+ for check in checks:
75
+ output.info(_health_line(check))
76
+
77
+
78
+ def _exit_code(checks: list[dict[str, Any]]) -> int:
79
+ if any(c.get("status") == "error" for c in checks):
80
+ return 2
81
+ if any(c.get("status") == "warning" for c in checks):
82
+ return 1
83
+ return 0
84
+
85
+
86
+ def _user_from_auth_payload(payload: dict[str, Any]) -> dict[str, Any]:
87
+ return payload.get("user") or payload.get("account") or {}
88
+
89
+
90
+ def doctor(
91
+ json: bool = typer.Option(False, "--json", help="Output diagnostic report as JSON."),
92
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Include extra endpoint/status details."),
93
+ no_network: bool = typer.Option(False, "--no-network", help="Skip API/network checks."),
94
+ ) -> None:
95
+ """Diagnose CLI configuration, authentication, backend, project, session, and Git state."""
96
+ output.configure(json_mode=json)
97
+
98
+ cfg = config.load()
99
+ api_url = config.get_api_url()
100
+ token = credentials.get_token()
101
+ token_source = "none"
102
+ if token:
103
+ import os
104
+ if os.environ.get("PROJECTMEMORY_TOKEN"):
105
+ token_source = "environment"
106
+ elif credentials.CRED_FILE.exists():
107
+ token_source = "keyring or fallback file"
108
+ else:
109
+ token_source = "keyring"
110
+
111
+ checks: list[dict[str, Any]] = []
112
+ report: dict[str, Any] = {
113
+ "cli": {
114
+ "version": __version__,
115
+ "python": platform.python_version(),
116
+ "python_executable": sys.executable,
117
+ "os": f"{platform.system()} {platform.release()}",
118
+ "config_file": str(config.CONFIG_FILE),
119
+ "api_url": _mask_url(api_url),
120
+ "output_mode": cfg.get("output", "table"),
121
+ "color": bool(cfg.get("color", True)),
122
+ },
123
+ "authentication": {
124
+ "logged_in": bool(token),
125
+ "token_present": bool(token),
126
+ "token_source": token_source,
127
+ "token_valid": None,
128
+ "token_expiration": None,
129
+ "email": None,
130
+ },
131
+ "backend": {
132
+ "reachable": None,
133
+ "latency_ms": None,
134
+ "version": None,
135
+ "environment": None,
136
+ },
137
+ "current_project": {
138
+ "name": None,
139
+ "id": config.get_current_project_id() or None,
140
+ "mapping_status": "not mapped",
141
+ "active_session": None,
142
+ "session_id": None,
143
+ "session_started_at": None,
144
+ "session_duration": None,
145
+ },
146
+ "git": {},
147
+ "connectivity": {},
148
+ "subscription": {
149
+ "plan": None,
150
+ "read_only": None,
151
+ "email_verified": None,
152
+ "email_verification_required": None,
153
+ },
154
+ "checks": checks,
155
+ }
156
+
157
+ if token:
158
+ checks.append(_status(True, "Token found"))
159
+ else:
160
+ checks.append(_status(False, "Not logged in"))
161
+
162
+ root = git.get_repo_root()
163
+ mapped = get_mapped_project_id_for_cwd()
164
+ if mapped:
165
+ report["current_project"]["mapping_status"] = f"mapped to project #{mapped[0]} ({mapped[1]})"
166
+ if root:
167
+ remote_url = git.get_remote_url(root)
168
+ report["git"] = {
169
+ "repository_detected": True,
170
+ "root": str(root),
171
+ "branch": git.get_current_branch(root),
172
+ "commit": git.get_short_head_sha(root),
173
+ "remote_origin_url": remote_url,
174
+ "remote_github_repository": git.normalize_github_repo(remote_url),
175
+ "linked_to_current_project": None,
176
+ "repository_match": None,
177
+ }
178
+ checks.append(_status(True, "Git repository detected"))
179
+ else:
180
+ report["git"] = {"repository_detected": False, "message": "No Git repository detected."}
181
+ checks.append(_status(None, "No Git repository detected"))
182
+
183
+ auth_payload: dict[str, Any] = {}
184
+ projects_payload: dict[str, Any] = {}
185
+ sessions_payload: dict[str, Any] = {}
186
+ current_project: dict[str, Any] | None = None
187
+
188
+ if no_network:
189
+ report["backend"]["reachable"] = None
190
+ report["connectivity"]["network_checks"] = "skipped"
191
+ checks.append(_status(None, "Network checks skipped"))
192
+ else:
193
+ reachable, status_code, latency, error = _safe_get(f"{api_url}/api/cli/auth/status/", token=token)
194
+ report["backend"].update({"reachable": reachable, "latency_ms": round(latency or 0, 1), "auth_status_code": status_code})
195
+ report["connectivity"]["backend_api"] = {"reachable": reachable, "status_code": status_code, "error": error}
196
+ if reachable:
197
+ checks.append(_status(True, f"Backend reachable ({round(latency or 0)} ms)"))
198
+ else:
199
+ checks.append(_status(False, "Backend unreachable"))
200
+
201
+ if token:
202
+ client = APIClient.from_env()
203
+ try:
204
+ auth_payload = client.get("/api/cli/auth/status/")
205
+ user = _user_from_auth_payload(auth_payload)
206
+ report["authentication"].update({
207
+ "token_valid": bool(auth_payload.get("authenticated", True)),
208
+ "token_expiration": auth_payload.get("expires_at") or auth_payload.get("token_expires_at"),
209
+ "email": user.get("email"),
210
+ })
211
+ report["subscription"].update({
212
+ "plan": user.get("plan"),
213
+ "read_only": user.get("read_only") or user.get("is_read_only"),
214
+ "email_verified": user.get("email_verified"),
215
+ "email_verification_required": user.get("email_verification_required"),
216
+ })
217
+ checks.append(_status(True, "Logged in"))
218
+ if user.get("email_verified") is False:
219
+ checks.append(_status(False, "Email not verified"))
220
+ except APIError as exc:
221
+ report["authentication"].update({"token_valid": False, "error": str(exc)})
222
+ checks.append(_status(False, "Token invalid or expired"))
223
+
224
+ try:
225
+ sessions_payload = client.get("/api/cli/session/status/")
226
+ report["connectivity"]["session_endpoint"] = {"reachable": True}
227
+ checks.append(_status(True, "Session endpoint reachable"))
228
+ except APIError as exc:
229
+ report["connectivity"]["session_endpoint"] = {"reachable": False, "error": str(exc)}
230
+ checks.append(_status(False, "Session endpoint unavailable"))
231
+
232
+ try:
233
+ projects_payload = client.get("/api/cli/projects/")
234
+ report["connectivity"]["projects_endpoint"] = {"reachable": True}
235
+ except APIError as exc:
236
+ report["connectivity"]["projects_endpoint"] = {"reachable": False, "error": str(exc)}
237
+
238
+ current_id = str(config.get_current_project_id() or "")
239
+ projects = projects_payload.get("projects", []) if projects_payload else []
240
+ if current_id and projects:
241
+ for project in projects:
242
+ if str(project.get("id")) == current_id:
243
+ current_project = project
244
+ break
245
+ elif mapped and projects:
246
+ for project in projects:
247
+ if str(project.get("id")) == str(mapped[0]):
248
+ current_project = project
249
+ break
250
+
251
+ if current_project:
252
+ report["current_project"].update({
253
+ "name": current_project.get("name"),
254
+ "id": current_project.get("id"),
255
+ })
256
+ active = current_project.get("active_session") or None
257
+ if not active:
258
+ for session in sessions_payload.get("active_sessions", []) if sessions_payload else []:
259
+ if str(session.get("project")) == str(current_project.get("id")):
260
+ active = session
261
+ break
262
+ if active:
263
+ report["current_project"].update({
264
+ "active_session": True,
265
+ "session_id": active.get("id"),
266
+ "session_started_at": active.get("started_at"),
267
+ "session_duration": output.format_duration(int(active.get("duration_seconds") or 0)),
268
+ })
269
+ checks.append(_status(True, "Active session"))
270
+ else:
271
+ report["current_project"]["active_session"] = False
272
+ checks.append(_status(None, "No active session for current project"))
273
+
274
+ github = current_project.get("github", {}) or {}
275
+ repo = github.get("repository") or {}
276
+ report["connectivity"]["github_integration"] = {
277
+ "connected": bool(github.get("connected")),
278
+ "repository": repo.get("full_name"),
279
+ "last_synced_at": github.get("last_synced_at"),
280
+ }
281
+ if github.get("connected"):
282
+ checks.append(_status(True, "GitHub integration connected"))
283
+ else:
284
+ checks.append(_status(None, "No connected GitHub repository"))
285
+
286
+ if root:
287
+ local_repo = report["git"].get("remote_github_repository")
288
+ connected_repo = (repo.get("full_name") or "").lower()
289
+ linked = mapped and str(mapped[0]) == str(current_project.get("id"))
290
+ match = bool(local_repo and connected_repo and local_repo == connected_repo)
291
+ report["git"].update({
292
+ "linked_to_current_project": bool(linked),
293
+ "connected_project_repository": connected_repo or None,
294
+ "repository_match": match if connected_repo else None,
295
+ })
296
+ if connected_repo and local_repo and not match:
297
+ checks.append(_status(None, "Repository mismatch"))
298
+ elif current_id:
299
+ checks.append(_status(None, "Current project not found or unavailable offline"))
300
+ else:
301
+ checks.append(_status(None, "No current CLI project"))
302
+
303
+ if json:
304
+ output.print_json(report)
305
+ raise typer.Exit(_exit_code(checks))
306
+
307
+ output.blank()
308
+ output.project_banner("Project Memory Doctor")
309
+ _print_checks(checks)
310
+
311
+ output.blank()
312
+ _add_table("CLI", [
313
+ ("Version", report["cli"]["version"]),
314
+ ("Python", report["cli"]["python"]),
315
+ ("OS", report["cli"]["os"]),
316
+ ("Config", report["cli"]["config_file"]),
317
+ ("API URL", report["cli"]["api_url"]),
318
+ ("Output", str(report["cli"]["output_mode"])),
319
+ ("Color", "enabled" if report["cli"]["color"] else "disabled"),
320
+ ])
321
+
322
+ _add_table("Authentication", [
323
+ ("Logged in", "yes" if report["authentication"]["logged_in"] else "no"),
324
+ ("Email", str(report["authentication"].get("email") or "unknown")),
325
+ ("Token", "present, hidden" if token else "not found"),
326
+ ("Token valid", str(report["authentication"].get("token_valid"))),
327
+ ("Token expiration", str(report["authentication"].get("token_expiration") or "unknown")),
328
+ ])
329
+
330
+ _add_table("Backend", [
331
+ ("Reachable", str(report["backend"].get("reachable"))),
332
+ ("Latency", f"{report['backend'].get('latency_ms')} ms" if report["backend"].get("latency_ms") is not None else "unknown"),
333
+ ("Version", str(report["backend"].get("version") or "unknown")),
334
+ ("Environment", str(report["backend"].get("environment") or "unknown")),
335
+ ])
336
+
337
+ _add_table("Current Project", [
338
+ ("Project", str(report["current_project"].get("name") or "unknown")),
339
+ ("Project ID", str(report["current_project"].get("id") or "unknown")),
340
+ ("Mapping", str(report["current_project"].get("mapping_status") or "not mapped")),
341
+ ("Active session", str(report["current_project"].get("active_session"))),
342
+ ("Session ID", str(report["current_project"].get("session_id") or "unknown")),
343
+ ("Started", str(report["current_project"].get("session_started_at") or "unknown")),
344
+ ("Duration", str(report["current_project"].get("session_duration") or "unknown")),
345
+ ])
346
+
347
+ git_rows = []
348
+ if report["git"].get("repository_detected"):
349
+ git_rows = [
350
+ ("Repository", "detected"),
351
+ ("Root", str(report["git"].get("root") or "unknown")),
352
+ ("Branch", str(report["git"].get("branch") or "unknown")),
353
+ ("Commit", str(report["git"].get("commit") or "unknown")),
354
+ ("Remote", str(report["git"].get("remote_origin_url") or "unknown")),
355
+ ("Linked to current project", str(report["git"].get("linked_to_current_project"))),
356
+ ("Repository match", str(report["git"].get("repository_match"))),
357
+ ]
358
+ else:
359
+ git_rows = [("Repository", "No Git repository detected.")]
360
+ _add_table("Git", git_rows)
361
+
362
+ _add_table("Subscription", [
363
+ ("Plan", str(report["subscription"].get("plan") or "unknown")),
364
+ ("Read-only", str(report["subscription"].get("read_only"))),
365
+ ("Email verified", str(report["subscription"].get("email_verified"))),
366
+ ("Verification required", str(report["subscription"].get("email_verification_required"))),
367
+ ])
368
+
369
+ if verbose:
370
+ output.blank()
371
+ output.header("Connectivity Details")
372
+ for key, value in report["connectivity"].items():
373
+ output.kv(key, str(value))
374
+
375
+ raise typer.Exit(_exit_code(checks))