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