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,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))
|