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,325 @@
|
|
|
1
|
+
"""Project commands: pm project list/create/current/link/unlink, pm web."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from projectmemory_cli import config, git, output
|
|
11
|
+
from projectmemory_cli.api.client import APIClient
|
|
12
|
+
from projectmemory_cli.api.errors import APIError
|
|
13
|
+
from projectmemory_cli.models.api import ProjectSummary
|
|
14
|
+
from projectmemory_cli.project_context import (
|
|
15
|
+
fetch_projects,
|
|
16
|
+
get_mapped_project_id_for_cwd,
|
|
17
|
+
remove_repo_mapping,
|
|
18
|
+
resolve_project,
|
|
19
|
+
save_repo_mapping,
|
|
20
|
+
select_project_interactive,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="Project management commands.", no_args_is_help=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_client() -> APIClient:
|
|
27
|
+
from projectmemory_cli import credentials
|
|
28
|
+
token = credentials.get_token()
|
|
29
|
+
if not token:
|
|
30
|
+
output.error("Not logged in. Run: pm login")
|
|
31
|
+
raise SystemExit(2)
|
|
32
|
+
return APIClient.from_env()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# pm project list
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@app.command("list")
|
|
40
|
+
def project_list(
|
|
41
|
+
json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
42
|
+
plain: bool = typer.Option(False, "--plain", help="Plain text output."),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""List all accessible projects."""
|
|
45
|
+
output.configure(json_mode=json, plain_mode=plain)
|
|
46
|
+
client = _require_client()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
projects = fetch_projects(client)
|
|
50
|
+
except APIError as exc:
|
|
51
|
+
output.error(str(exc))
|
|
52
|
+
raise SystemExit(exc.exit_code)
|
|
53
|
+
|
|
54
|
+
if json:
|
|
55
|
+
import json as _json
|
|
56
|
+
output.print_json([p.model_dump() for p in projects])
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
current_id = config.get_current_project_id()
|
|
60
|
+
mapped = get_mapped_project_id_for_cwd()
|
|
61
|
+
mapped_id = str(mapped[0]) if mapped else ""
|
|
62
|
+
|
|
63
|
+
if not projects:
|
|
64
|
+
output.info("No projects found. Run: pm project create")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
headers = ["#", "Name", "Status", "Session", "Updated"]
|
|
68
|
+
rows = []
|
|
69
|
+
for p in projects:
|
|
70
|
+
marker = ""
|
|
71
|
+
if str(p.id) == current_id:
|
|
72
|
+
marker = "● "
|
|
73
|
+
if mapped_id and str(p.id) == mapped_id:
|
|
74
|
+
marker += "⚑ "
|
|
75
|
+
session_text = "active" if p.has_active_session else "—"
|
|
76
|
+
updated = output.format_ago(p.last_updated_at or p.updated_at)
|
|
77
|
+
rows.append([marker + str(p.id), p.name, p.status, session_text, updated])
|
|
78
|
+
|
|
79
|
+
output.table(headers, rows, title="Projects")
|
|
80
|
+
output.blank()
|
|
81
|
+
output.hint("● = current project ⚑ = mapped to this directory")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# pm project create
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
@app.command("create")
|
|
89
|
+
def project_create(
|
|
90
|
+
name: Optional[str] = typer.Option(None, "--name", help="Project name."),
|
|
91
|
+
description: Optional[str] = typer.Option(None, "--description", help="Project description."),
|
|
92
|
+
next_action: Optional[str] = typer.Option(None, "--next", help="Initial Next Action."),
|
|
93
|
+
status_text: Optional[str] = typer.Option(None, "--status-text", help="Current Status."),
|
|
94
|
+
context: Optional[str] = typer.Option(None, "--context", help="Important Context."),
|
|
95
|
+
link_repo: bool = typer.Option(False, "--link-repo", help="Link current git repo."),
|
|
96
|
+
no_interactive: bool = typer.Option(False, "--no-interactive", help="Skip all prompts."),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Create a new project and start its first session."""
|
|
99
|
+
client = _require_client()
|
|
100
|
+
|
|
101
|
+
if not no_interactive:
|
|
102
|
+
if not name:
|
|
103
|
+
name = questionary.text("Project name:").ask()
|
|
104
|
+
if not name:
|
|
105
|
+
output.info("Cancelled.")
|
|
106
|
+
raise SystemExit(7)
|
|
107
|
+
if description is None:
|
|
108
|
+
description = questionary.text("Description (optional):").ask() or ""
|
|
109
|
+
|
|
110
|
+
# Optional memory init
|
|
111
|
+
init = questionary.confirm("Initialize Project Memory now?", default=True).ask()
|
|
112
|
+
if init:
|
|
113
|
+
if next_action is None:
|
|
114
|
+
next_action = questionary.text("Next Action (optional):").ask() or ""
|
|
115
|
+
if status_text is None:
|
|
116
|
+
status_text = questionary.text("Current Status (optional):").ask() or ""
|
|
117
|
+
if context is None:
|
|
118
|
+
context = questionary.text("Important Context (optional):").ask() or ""
|
|
119
|
+
|
|
120
|
+
if not name:
|
|
121
|
+
output.error("Project name is required.")
|
|
122
|
+
raise SystemExit(4)
|
|
123
|
+
|
|
124
|
+
payload: dict = {"name": name, "description": description or ""}
|
|
125
|
+
if next_action:
|
|
126
|
+
payload["next_action"] = next_action
|
|
127
|
+
if status_text:
|
|
128
|
+
payload["status_text"] = status_text
|
|
129
|
+
if context:
|
|
130
|
+
payload["context"] = context
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
data = client.post("/api/cli/projects/", json=payload)
|
|
134
|
+
except APIError as exc:
|
|
135
|
+
output.error(str(exc))
|
|
136
|
+
raise SystemExit(exc.exit_code)
|
|
137
|
+
|
|
138
|
+
project_data = data.get("project", {})
|
|
139
|
+
project = ProjectSummary.model_validate(project_data)
|
|
140
|
+
active_session = project.active_session
|
|
141
|
+
|
|
142
|
+
output.blank()
|
|
143
|
+
output.success(f"Project created.")
|
|
144
|
+
output.success(f"Current project: [bold]{project.name}[/bold]")
|
|
145
|
+
if active_session:
|
|
146
|
+
output.success("Session started.")
|
|
147
|
+
|
|
148
|
+
# Set as current
|
|
149
|
+
config.set_current_project(project.id)
|
|
150
|
+
|
|
151
|
+
# Offer repo link
|
|
152
|
+
in_git = git.is_git_repo()
|
|
153
|
+
linked = False
|
|
154
|
+
if in_git and not no_interactive:
|
|
155
|
+
do_link = questionary.confirm("Link this repository to the project?", default=True).ask()
|
|
156
|
+
if do_link:
|
|
157
|
+
_do_link_repo(project.id, project.name)
|
|
158
|
+
linked = True
|
|
159
|
+
elif link_repo and in_git:
|
|
160
|
+
_do_link_repo(project.id, project.name)
|
|
161
|
+
linked = True
|
|
162
|
+
|
|
163
|
+
if linked:
|
|
164
|
+
output.success("Repository linked.")
|
|
165
|
+
|
|
166
|
+
output.blank()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# pm project current
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
@app.command("current")
|
|
174
|
+
def project_current() -> None:
|
|
175
|
+
"""Show the current CLI project and session status."""
|
|
176
|
+
client = _require_client()
|
|
177
|
+
current_id = config.get_current_project_id()
|
|
178
|
+
|
|
179
|
+
resolution = "stored"
|
|
180
|
+
mapped = get_mapped_project_id_for_cwd()
|
|
181
|
+
if mapped:
|
|
182
|
+
current_id = str(mapped[0])
|
|
183
|
+
resolution = "directory mapping"
|
|
184
|
+
|
|
185
|
+
if not current_id:
|
|
186
|
+
output.warn("No current project set.")
|
|
187
|
+
output.hint("Run: pm resume")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
projects = fetch_projects(client)
|
|
192
|
+
except APIError as exc:
|
|
193
|
+
output.error(str(exc))
|
|
194
|
+
raise SystemExit(exc.exit_code)
|
|
195
|
+
|
|
196
|
+
from projectmemory_cli.project_context import find_project_by_name_or_id
|
|
197
|
+
project = find_project_by_name_or_id(projects, current_id)
|
|
198
|
+
if not project:
|
|
199
|
+
output.warn(f"Stored project ID {current_id!r} not found.")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
output.blank()
|
|
203
|
+
output.kv("Project", f"[bold]{project.name}[/bold]")
|
|
204
|
+
output.kv("ID", str(project.id))
|
|
205
|
+
output.kv("Resolved via", resolution)
|
|
206
|
+
output.kv("Status", project.status)
|
|
207
|
+
|
|
208
|
+
if project.has_active_session and project.active_session:
|
|
209
|
+
s = project.active_session
|
|
210
|
+
output.kv("Session", f"active (started {output.format_ago(s.started_at)})")
|
|
211
|
+
else:
|
|
212
|
+
output.kv("Session", "[dim]none[/dim]")
|
|
213
|
+
output.hint("Run: pm resume")
|
|
214
|
+
|
|
215
|
+
output.blank()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# pm project link
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
@app.command("link")
|
|
223
|
+
def project_link(
|
|
224
|
+
project_arg: Optional[str] = typer.Argument(None, help="Project name or ID."),
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Link the current directory/git repo to a Project Memory project."""
|
|
227
|
+
client = _require_client()
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
project = resolve_project(client, arg=project_arg)
|
|
231
|
+
except APIError as exc:
|
|
232
|
+
output.error(str(exc))
|
|
233
|
+
raise SystemExit(exc.exit_code)
|
|
234
|
+
|
|
235
|
+
if not project:
|
|
236
|
+
output.error("No project selected.")
|
|
237
|
+
raise SystemExit(1)
|
|
238
|
+
|
|
239
|
+
_do_link_repo(project.id, project.name, check_mismatch=True, project=project)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# pm project unlink
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
@app.command("unlink")
|
|
247
|
+
def project_unlink() -> None:
|
|
248
|
+
"""Remove the current directory/git repo mapping."""
|
|
249
|
+
cwd = str(Path.cwd())
|
|
250
|
+
if remove_repo_mapping(cwd):
|
|
251
|
+
output.success("Directory mapping removed.")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
root = git.get_repo_root()
|
|
255
|
+
if root and remove_repo_mapping(str(root)):
|
|
256
|
+
output.success("Repository mapping removed.")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
output.warn("No mapping found for the current directory.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# pm web
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def web(
|
|
267
|
+
project_arg: Optional[str] = typer.Argument(None, help="Project name or ID."),
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Open the current project workspace in the browser."""
|
|
270
|
+
import webbrowser
|
|
271
|
+
client = _require_client()
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
project = resolve_project(client, arg=project_arg)
|
|
275
|
+
except APIError as exc:
|
|
276
|
+
output.error(str(exc))
|
|
277
|
+
raise SystemExit(exc.exit_code)
|
|
278
|
+
|
|
279
|
+
if not project:
|
|
280
|
+
output.error("No current project. Run: pm resume")
|
|
281
|
+
raise SystemExit(1)
|
|
282
|
+
|
|
283
|
+
url = project.web_url or f"https://projectmemory.app/app/projects/{project.id}/"
|
|
284
|
+
output.info(f"Opening [link]{url}[/link]")
|
|
285
|
+
webbrowser.open(url)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Internal helper
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _do_link_repo(
|
|
293
|
+
project_id: int,
|
|
294
|
+
project_name: str,
|
|
295
|
+
*,
|
|
296
|
+
check_mismatch: bool = False,
|
|
297
|
+
project: Optional[ProjectSummary] = None,
|
|
298
|
+
) -> None:
|
|
299
|
+
root = git.get_repo_root()
|
|
300
|
+
cwd = str(Path.cwd())
|
|
301
|
+
path_to_save = str(root) if root else cwd
|
|
302
|
+
|
|
303
|
+
if check_mismatch and project and root:
|
|
304
|
+
local_remote = git.normalize_github_repo(git.get_remote_url(root))
|
|
305
|
+
if local_remote and project.github.connected and project.github.repository:
|
|
306
|
+
expected = project.github.repository.full_name.lower()
|
|
307
|
+
if local_remote != expected:
|
|
308
|
+
output.warn(
|
|
309
|
+
f"Remote mismatch: local repo is '{local_remote}' "
|
|
310
|
+
f"but project is linked to '{expected}'."
|
|
311
|
+
)
|
|
312
|
+
confirmed = questionary.confirm("Link anyway?", default=False).ask()
|
|
313
|
+
if not confirmed:
|
|
314
|
+
output.info("Link cancelled.")
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
save_repo_mapping(path_to_save, project_id, project_name)
|
|
318
|
+
|
|
319
|
+
# Also save git remote key for multi-directory use
|
|
320
|
+
if root:
|
|
321
|
+
remote_name = git.normalize_github_repo(git.get_remote_url(root))
|
|
322
|
+
if remote_name:
|
|
323
|
+
save_repo_mapping(f"git_remote:{remote_name}", project_id, project_name)
|
|
324
|
+
|
|
325
|
+
output.success(f"Linked [bold]{path_to_save}[/bold] → {project_name}")
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""pm resume — begin or return to work on a project."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from projectmemory_cli import config, output
|
|
11
|
+
from projectmemory_cli.api.client import APIClient
|
|
12
|
+
from projectmemory_cli.api.errors import APIError
|
|
13
|
+
from projectmemory_cli.models.api import ActiveSession, ProjectContext, ProjectSummary
|
|
14
|
+
from projectmemory_cli.project_context import (
|
|
15
|
+
fetch_projects,
|
|
16
|
+
get_active_session_for_project,
|
|
17
|
+
resolve_project,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_OLD_SESSION_HOURS = 24
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _session_age_hours(session: ActiveSession) -> float:
|
|
24
|
+
try:
|
|
25
|
+
started = datetime.fromisoformat(session.started_at.replace("Z", "+00:00"))
|
|
26
|
+
return (datetime.now(timezone.utc) - started).total_seconds() / 3600
|
|
27
|
+
except Exception:
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _display_resume(context: ProjectContext, session_state: str, *, show_tasks: bool = True) -> None:
|
|
32
|
+
p = context.project
|
|
33
|
+
session = context.active_session
|
|
34
|
+
|
|
35
|
+
output.blank()
|
|
36
|
+
output.project_banner(p.name)
|
|
37
|
+
output.blank()
|
|
38
|
+
|
|
39
|
+
if session_state == "resumed":
|
|
40
|
+
output.info("[bold]Resuming active session[/bold]")
|
|
41
|
+
else:
|
|
42
|
+
output.info("[bold]Starting a new session[/bold]")
|
|
43
|
+
|
|
44
|
+
if session:
|
|
45
|
+
output.info(f"Started {output.format_ago(session.started_at)}")
|
|
46
|
+
|
|
47
|
+
if p.last_updated_at:
|
|
48
|
+
output.kv("Last updated", output.format_ago(p.last_updated_at))
|
|
49
|
+
if p.last_memory_updated_at:
|
|
50
|
+
output.kv("Last memory updated", output.format_ago(p.last_memory_updated_at))
|
|
51
|
+
|
|
52
|
+
mem = context.memory
|
|
53
|
+
if mem.next_action.title:
|
|
54
|
+
output.blank()
|
|
55
|
+
output.header("Next Action")
|
|
56
|
+
output.info(f" {mem.next_action.title}")
|
|
57
|
+
if mem.next_action.linked_source:
|
|
58
|
+
src = mem.next_action.linked_source
|
|
59
|
+
output.hint(f" Linked {src.type} #{src.id}")
|
|
60
|
+
|
|
61
|
+
if mem.current_status.title:
|
|
62
|
+
output.blank()
|
|
63
|
+
output.header("Current Status")
|
|
64
|
+
output.info(f" {mem.current_status.title}")
|
|
65
|
+
|
|
66
|
+
if mem.important_context.title:
|
|
67
|
+
output.blank()
|
|
68
|
+
output.header("Important Context")
|
|
69
|
+
output.info(f" {mem.important_context.title}")
|
|
70
|
+
|
|
71
|
+
if context.open_issues:
|
|
72
|
+
output.blank()
|
|
73
|
+
output.header("Open Issues")
|
|
74
|
+
for issue in context.open_issues[:5]:
|
|
75
|
+
output.info(f" [dim]#{issue['id']}[/dim] {issue['description']}")
|
|
76
|
+
if len(context.open_issues) > 5:
|
|
77
|
+
output.hint(f" +{len(context.open_issues) - 5} more")
|
|
78
|
+
|
|
79
|
+
if show_tasks and context.open_tasks:
|
|
80
|
+
output.blank()
|
|
81
|
+
output.header("Open Tasks")
|
|
82
|
+
for task in context.open_tasks[:5]:
|
|
83
|
+
na = " [dim]→ next action[/dim]" if task.get("is_next_action") else ""
|
|
84
|
+
output.info(f" [dim]#{task['id']}[/dim] {task['title']}{na}")
|
|
85
|
+
if len(context.open_tasks) > 5:
|
|
86
|
+
output.hint(f" +{len(context.open_tasks) - 5} more")
|
|
87
|
+
|
|
88
|
+
output.blank()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle_old_session(client: APIClient, project: ProjectSummary, session: ActiveSession) -> Optional[ActiveSession]:
|
|
92
|
+
"""Prompt user when session is unusually old. Return final session or None on cancel."""
|
|
93
|
+
age_h = _session_age_hours(session)
|
|
94
|
+
age_str = output.format_ago(session.started_at)
|
|
95
|
+
output.warn(f"This session started {age_str}.")
|
|
96
|
+
output.blank()
|
|
97
|
+
|
|
98
|
+
choice = questionary.select(
|
|
99
|
+
"What would you like to do?",
|
|
100
|
+
choices=[
|
|
101
|
+
questionary.Choice("Continue this session", value="continue"),
|
|
102
|
+
questionary.Choice("End it and start a new session", value="end_new"),
|
|
103
|
+
questionary.Choice("Cancel", value="cancel"),
|
|
104
|
+
],
|
|
105
|
+
).ask()
|
|
106
|
+
|
|
107
|
+
if choice == "cancel" or choice is None:
|
|
108
|
+
output.info("Cancelled.")
|
|
109
|
+
raise SystemExit(7)
|
|
110
|
+
|
|
111
|
+
if choice == "continue":
|
|
112
|
+
return session
|
|
113
|
+
|
|
114
|
+
if choice == "end_new":
|
|
115
|
+
from projectmemory_cli.commands.sessions import _run_end_session
|
|
116
|
+
_run_end_session(client, project.id, session, interactive=True)
|
|
117
|
+
return None # caller will create new session
|
|
118
|
+
|
|
119
|
+
return session
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _switch_prompt(current_project_name: str) -> str:
|
|
123
|
+
"""Prompt when switching from a project with an active session."""
|
|
124
|
+
output.warn(f"[bold]{current_project_name}[/bold] still has an active session.")
|
|
125
|
+
output.blank()
|
|
126
|
+
choice = questionary.select(
|
|
127
|
+
"What would you like to do?",
|
|
128
|
+
choices=[
|
|
129
|
+
questionary.Choice("End Session and switch", value="end"),
|
|
130
|
+
questionary.Choice("Keep Session Active and switch", value="keep"),
|
|
131
|
+
questionary.Choice("Cancel", value="cancel"),
|
|
132
|
+
],
|
|
133
|
+
).ask()
|
|
134
|
+
return choice or "cancel"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resume(
|
|
138
|
+
project_arg: Optional[str] = typer.Argument(None, help="Project name or ID."),
|
|
139
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or ID."),
|
|
140
|
+
keep_session: bool = typer.Option(False, "--keep-session", help="Keep current project session active when switching."),
|
|
141
|
+
compact: bool = typer.Option(False, "--compact", help="Compact output."),
|
|
142
|
+
json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
143
|
+
no_tasks: bool = typer.Option(False, "--no-tasks", help="Hide tasks in output."),
|
|
144
|
+
open_browser: bool = typer.Option(False, "--open", help="Open project in browser after resuming."),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Begin or resume work on a project."""
|
|
147
|
+
from projectmemory_cli import credentials
|
|
148
|
+
token = credentials.get_token()
|
|
149
|
+
if not token:
|
|
150
|
+
output.error("Not logged in. Run: pm login")
|
|
151
|
+
raise SystemExit(2)
|
|
152
|
+
|
|
153
|
+
output.configure(json_mode=json)
|
|
154
|
+
client = APIClient.from_env()
|
|
155
|
+
|
|
156
|
+
target_arg = project_arg or project
|
|
157
|
+
|
|
158
|
+
# Resolve target project
|
|
159
|
+
try:
|
|
160
|
+
target = resolve_project(client, arg=target_arg)
|
|
161
|
+
except APIError as exc:
|
|
162
|
+
output.error(str(exc))
|
|
163
|
+
raise SystemExit(exc.exit_code)
|
|
164
|
+
|
|
165
|
+
if not target:
|
|
166
|
+
output.error("No project selected. Run: pm resume <project-name>")
|
|
167
|
+
raise SystemExit(1)
|
|
168
|
+
|
|
169
|
+
# Check if we need to handle switching
|
|
170
|
+
current_id = config.get_current_project_id()
|
|
171
|
+
if current_id and str(current_id) != str(target.id) and not keep_session:
|
|
172
|
+
# Check if current project has an active session
|
|
173
|
+
current_session = get_active_session_for_project(client, int(current_id))
|
|
174
|
+
if current_session:
|
|
175
|
+
# Find current project name
|
|
176
|
+
try:
|
|
177
|
+
all_projects = fetch_projects(client)
|
|
178
|
+
from projectmemory_cli.project_context import find_project_by_name_or_id
|
|
179
|
+
current_proj = find_project_by_name_or_id(all_projects, current_id)
|
|
180
|
+
current_name = current_proj.name if current_proj else f"Project #{current_id}"
|
|
181
|
+
except APIError:
|
|
182
|
+
current_name = f"Project #{current_id}"
|
|
183
|
+
|
|
184
|
+
switch_choice = _switch_prompt(current_name)
|
|
185
|
+
|
|
186
|
+
if switch_choice == "cancel":
|
|
187
|
+
output.info("Cancelled.")
|
|
188
|
+
raise SystemExit(7)
|
|
189
|
+
if switch_choice == "end":
|
|
190
|
+
from projectmemory_cli.commands.sessions import _run_end_session
|
|
191
|
+
_run_end_session(client, int(current_id), current_session, interactive=True)
|
|
192
|
+
|
|
193
|
+
# Resume / start session for target project
|
|
194
|
+
try:
|
|
195
|
+
data = client.post(f"/api/cli/projects/{target.id}/resume/")
|
|
196
|
+
except APIError as exc:
|
|
197
|
+
output.error(str(exc))
|
|
198
|
+
raise SystemExit(exc.exit_code)
|
|
199
|
+
|
|
200
|
+
session_state = data.get("session_state", "started")
|
|
201
|
+
context_data = data.get("context", {})
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
ctx = ProjectContext.model_validate(context_data)
|
|
205
|
+
except Exception:
|
|
206
|
+
ctx = None
|
|
207
|
+
|
|
208
|
+
active_session = ctx.active_session if ctx else None
|
|
209
|
+
|
|
210
|
+
# Check for old session
|
|
211
|
+
if active_session and _session_age_hours(active_session) > _OLD_SESSION_HOURS:
|
|
212
|
+
result = _handle_old_session(client, target, active_session)
|
|
213
|
+
if result is None:
|
|
214
|
+
# Start fresh
|
|
215
|
+
data = client.post(f"/api/cli/projects/{target.id}/resume/")
|
|
216
|
+
session_state = data.get("session_state", "started")
|
|
217
|
+
context_data = data.get("context", {})
|
|
218
|
+
ctx = ProjectContext.model_validate(context_data)
|
|
219
|
+
active_session = ctx.active_session if ctx else None
|
|
220
|
+
|
|
221
|
+
# Set as current project
|
|
222
|
+
config.set_current_project(target.id)
|
|
223
|
+
|
|
224
|
+
if json:
|
|
225
|
+
import json as _json
|
|
226
|
+
output.print_json(data)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
if ctx:
|
|
230
|
+
if not compact:
|
|
231
|
+
_display_resume(ctx, session_state, show_tasks=not no_tasks)
|
|
232
|
+
else:
|
|
233
|
+
output.success(f"[bold]{target.name}[/bold] — {session_state}")
|
|
234
|
+
else:
|
|
235
|
+
output.success(f"[bold]{target.name}[/bold] — session {session_state}")
|
|
236
|
+
|
|
237
|
+
if open_browser:
|
|
238
|
+
import webbrowser
|
|
239
|
+
url = target.web_url or f"https://projectmemory.app/app/projects/{target.id}/"
|
|
240
|
+
webbrowser.open(url)
|