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,277 @@
|
|
|
1
|
+
"""Session commands: pm session status, pm session end."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import questionary
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from projectmemory_cli import config, output
|
|
10
|
+
from projectmemory_cli.api.client import APIClient
|
|
11
|
+
from projectmemory_cli.api.errors import APIError, SessionAlreadyFinalizedError
|
|
12
|
+
from projectmemory_cli.models.api import ActiveSession
|
|
13
|
+
from projectmemory_cli.project_context import guard_project_session
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Session commands.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_client() -> APIClient:
|
|
19
|
+
from projectmemory_cli import credentials
|
|
20
|
+
token = credentials.get_token()
|
|
21
|
+
if not token:
|
|
22
|
+
output.error("Not logged in. Run: pm login")
|
|
23
|
+
raise SystemExit(2)
|
|
24
|
+
return APIClient.from_env()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _review_data(response: dict) -> dict:
|
|
28
|
+
return response.get("review", response)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _short_text(value: str, limit: int = 72) -> str:
|
|
32
|
+
value = (value or "").replace("\n", " ").strip()
|
|
33
|
+
return value if len(value) <= limit else value[: limit - 1].rstrip() + "…"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _item_title(item: dict) -> str:
|
|
37
|
+
return item.get("title") or item.get("description") or item.get("message") or item.get("path") or f"#{item.get('id', '?')}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _display_compact_section(title: str, items: list[dict], *, icon: str = "•", limit: int = 5) -> None:
|
|
41
|
+
output.header(f"{title} ({len(items)})")
|
|
42
|
+
if not items:
|
|
43
|
+
output.hint("(none)")
|
|
44
|
+
return
|
|
45
|
+
for item in items[:limit]:
|
|
46
|
+
prefix = ""
|
|
47
|
+
if item.get("number"):
|
|
48
|
+
prefix = f"#{item['number']} "
|
|
49
|
+
elif item.get("sha"):
|
|
50
|
+
prefix = f"{str(item['sha'])[:8]} "
|
|
51
|
+
output.info(f" {icon} {prefix}{_short_text(_item_title(item))}")
|
|
52
|
+
if len(items) > limit:
|
|
53
|
+
output.hint(f"+{len(items) - limit} more")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("status")
|
|
57
|
+
def session_status(json: bool = typer.Option(False, "--json")) -> None:
|
|
58
|
+
"""Show active sessions."""
|
|
59
|
+
output.configure(json_mode=json)
|
|
60
|
+
client = _require_client()
|
|
61
|
+
try:
|
|
62
|
+
data = client.get("/api/cli/session/status/")
|
|
63
|
+
except APIError as exc:
|
|
64
|
+
output.error(str(exc))
|
|
65
|
+
raise SystemExit(exc.exit_code)
|
|
66
|
+
|
|
67
|
+
if json:
|
|
68
|
+
output.print_json(data)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
sessions = data.get("active_sessions", [])
|
|
72
|
+
current_id = config.get_current_project_id()
|
|
73
|
+
if not sessions:
|
|
74
|
+
output.info("No active sessions.")
|
|
75
|
+
output.hint("Run: pm resume")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
output.blank()
|
|
79
|
+
output.header("Active Sessions")
|
|
80
|
+
current = [s for s in sessions if str(s.get("project")) == str(current_id)]
|
|
81
|
+
other = [s for s in sessions if str(s.get("project")) != str(current_id)]
|
|
82
|
+
|
|
83
|
+
def line(s: dict) -> str:
|
|
84
|
+
name = s.get("project_name", f"Project #{s.get('project', '?')}")
|
|
85
|
+
started = output.format_ago(s.get("started_at", ""))
|
|
86
|
+
duration = output.format_duration(int(s.get("duration_seconds") or 0))
|
|
87
|
+
return f"[bold]{name}[/bold] [dim]started {started} ({duration})[/dim]"
|
|
88
|
+
|
|
89
|
+
if current:
|
|
90
|
+
output.info("Current:")
|
|
91
|
+
for s in current:
|
|
92
|
+
output.info(f" {line(s)}")
|
|
93
|
+
if other:
|
|
94
|
+
if current:
|
|
95
|
+
output.blank()
|
|
96
|
+
output.info("Other:")
|
|
97
|
+
for s in other:
|
|
98
|
+
output.info(f" {line(s)}")
|
|
99
|
+
output.blank()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command("end")
|
|
103
|
+
def session_end(
|
|
104
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
105
|
+
note: Optional[str] = typer.Option(None, "--note", "-n", help="Additional session note."),
|
|
106
|
+
progress: Optional[str] = typer.Option(None, "--progress", help="Override generated session summary."),
|
|
107
|
+
no_review: bool = typer.Option(False, "--no-review", help="Skip session review display."),
|
|
108
|
+
no_memory: bool = typer.Option(False, "--no-memory", help="Skip memory update prompts."),
|
|
109
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip final confirmation."),
|
|
110
|
+
json: bool = typer.Option(False, "--json"),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""End the current work session."""
|
|
113
|
+
output.configure(json_mode=json)
|
|
114
|
+
client = _require_client()
|
|
115
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
116
|
+
_run_end_session(
|
|
117
|
+
client,
|
|
118
|
+
project.id,
|
|
119
|
+
session,
|
|
120
|
+
interactive=not json,
|
|
121
|
+
note=note,
|
|
122
|
+
progress=progress,
|
|
123
|
+
no_review=no_review,
|
|
124
|
+
no_memory=no_memory,
|
|
125
|
+
assume_yes=yes,
|
|
126
|
+
json_mode=json,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_end_session(
|
|
131
|
+
client: APIClient,
|
|
132
|
+
project_id: int,
|
|
133
|
+
session: ActiveSession,
|
|
134
|
+
*,
|
|
135
|
+
interactive: bool = True,
|
|
136
|
+
note: Optional[str] = None,
|
|
137
|
+
progress: Optional[str] = None,
|
|
138
|
+
no_review: bool = False,
|
|
139
|
+
no_memory: bool = False,
|
|
140
|
+
assume_yes: bool = False,
|
|
141
|
+
json_mode: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Core session end flow. Called from session end command and resume switching."""
|
|
144
|
+
session_client = APIClient(base_url=client.base_url, token=client.token, session_id=session.id)
|
|
145
|
+
review: dict = {}
|
|
146
|
+
if not no_review:
|
|
147
|
+
try:
|
|
148
|
+
review_response = client.get("/api/cli/session/review/", params={"session_id": str(session.id)})
|
|
149
|
+
review = _review_data(review_response)
|
|
150
|
+
if interactive:
|
|
151
|
+
_display_review(review)
|
|
152
|
+
except APIError as exc:
|
|
153
|
+
if interactive:
|
|
154
|
+
output.warn(f"Could not load session review: {exc}")
|
|
155
|
+
|
|
156
|
+
payload: dict = {"session_id": session.id}
|
|
157
|
+
changed_memory: list[str] = []
|
|
158
|
+
|
|
159
|
+
if interactive:
|
|
160
|
+
if note is None:
|
|
161
|
+
note = questionary.text("Anything else worth remembering? (optional):").ask() or ""
|
|
162
|
+
if not no_memory:
|
|
163
|
+
update_memory = questionary.confirm(
|
|
164
|
+
"Review Project Memory before ending?", default=False
|
|
165
|
+
).ask()
|
|
166
|
+
if update_memory:
|
|
167
|
+
changed_memory = _gather_memory_update(client, session, review=review)
|
|
168
|
+
if not assume_yes:
|
|
169
|
+
confirmed = questionary.confirm("End session?", default=True).ask()
|
|
170
|
+
if not confirmed:
|
|
171
|
+
output.info("Cancelled.")
|
|
172
|
+
raise SystemExit(7)
|
|
173
|
+
|
|
174
|
+
if note:
|
|
175
|
+
payload["session_note"] = note
|
|
176
|
+
if progress:
|
|
177
|
+
payload["session_progress"] = progress
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
data = session_client.post("/api/cli/session/end/", json=payload)
|
|
181
|
+
except SessionAlreadyFinalizedError:
|
|
182
|
+
output.warn("This session was already ended from another client.")
|
|
183
|
+
output.hint("Run: pm resume")
|
|
184
|
+
raise SystemExit(8)
|
|
185
|
+
except APIError as exc:
|
|
186
|
+
output.error(str(exc))
|
|
187
|
+
raise SystemExit(exc.exit_code)
|
|
188
|
+
|
|
189
|
+
if json_mode:
|
|
190
|
+
output.print_json(data)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
output.blank()
|
|
194
|
+
output.success("Session ended.")
|
|
195
|
+
output.success("Session summary saved.")
|
|
196
|
+
if changed_memory:
|
|
197
|
+
output.hint("Updated Project Memory: " + ", ".join(changed_memory))
|
|
198
|
+
output.blank()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _display_review(review: dict) -> None:
|
|
202
|
+
project = review.get("project", {})
|
|
203
|
+
project_name = project.get("name", "Project")
|
|
204
|
+
duration = output.format_duration(int(review.get("duration_seconds") or 0))
|
|
205
|
+
counts = review.get("counts", {})
|
|
206
|
+
|
|
207
|
+
output.blank()
|
|
208
|
+
output.rule("Session Review")
|
|
209
|
+
output.kv("Project", f"[bold]{project_name}[/bold]")
|
|
210
|
+
output.kv("Duration", duration)
|
|
211
|
+
|
|
212
|
+
summary_parts = []
|
|
213
|
+
labels = [
|
|
214
|
+
("completed_tasks", "task completed", "tasks completed"),
|
|
215
|
+
("resolved_issues", "issue resolved", "issues resolved"),
|
|
216
|
+
("github_commits", "commit", "commits"),
|
|
217
|
+
("github_issues_closed", "GitHub issue closed", "GitHub issues closed"),
|
|
218
|
+
("changed_files", "file changed", "files changed"),
|
|
219
|
+
]
|
|
220
|
+
for key, singular, plural in labels:
|
|
221
|
+
count = int(counts.get(key) or len(review.get(key, [])) or 0)
|
|
222
|
+
if count:
|
|
223
|
+
summary_parts.append(f"{count} {singular if count == 1 else plural}")
|
|
224
|
+
if summary_parts:
|
|
225
|
+
output.kv("Activity", " • ".join(summary_parts))
|
|
226
|
+
|
|
227
|
+
_display_compact_section("Completed Tasks", review.get("completed_tasks", []), icon="✓")
|
|
228
|
+
_display_compact_section("Resolved Project Memory Issues", review.get("resolved_issues", []), icon="✓")
|
|
229
|
+
_display_compact_section("GitHub Commits", review.get("github_commits", []), icon="•")
|
|
230
|
+
_display_compact_section("GitHub Issues Closed", review.get("github_issues_closed", []), icon="✓")
|
|
231
|
+
_display_compact_section("Files Changed", review.get("changed_files", []), icon="•", limit=5)
|
|
232
|
+
|
|
233
|
+
memory = review.get("memory", {})
|
|
234
|
+
if memory:
|
|
235
|
+
output.header("Current Project Memory")
|
|
236
|
+
for label, key in [("Next Action", "next_action"), ("Current Status", "current_status"), ("Important Context", "important_context")]:
|
|
237
|
+
field = memory.get(key, {}) or {}
|
|
238
|
+
title = field.get("title") or field.get("text") or "(none)"
|
|
239
|
+
output.kv(label, _short_text(title, 100))
|
|
240
|
+
output.blank()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _gather_memory_update(client: APIClient, session: ActiveSession, *, review: dict | None = None) -> list[str]:
|
|
244
|
+
"""Optionally update memory fields using normal backend operations."""
|
|
245
|
+
from projectmemory_cli.commands.memory import memory_context_set, memory_status_set
|
|
246
|
+
from projectmemory_cli.commands.next_action import run_next_action_menu
|
|
247
|
+
from projectmemory_cli.project_context import fetch_projects, find_project_by_name_or_id
|
|
248
|
+
|
|
249
|
+
projects = fetch_projects(client)
|
|
250
|
+
project = find_project_by_name_or_id(projects, str(session.project))
|
|
251
|
+
if not project:
|
|
252
|
+
output.warn("Could not resolve project for memory update.")
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
changed: list[str] = []
|
|
256
|
+
while True:
|
|
257
|
+
choice = questionary.select(
|
|
258
|
+
"Update Project Memory:",
|
|
259
|
+
choices=[
|
|
260
|
+
questionary.Choice("Next Action", value="next"),
|
|
261
|
+
questionary.Choice("Current Status", value="status"),
|
|
262
|
+
questionary.Choice("Important Context", value="context"),
|
|
263
|
+
questionary.Choice("Done", value="done"),
|
|
264
|
+
],
|
|
265
|
+
).ask()
|
|
266
|
+
if not choice or choice == "done":
|
|
267
|
+
break
|
|
268
|
+
if choice == "next":
|
|
269
|
+
if run_next_action_menu(client, project, session):
|
|
270
|
+
changed.append("Next Action")
|
|
271
|
+
elif choice == "status":
|
|
272
|
+
memory_status_set(text=None, editor=False, stdin=False, project_arg=str(project.id))
|
|
273
|
+
changed.append("Current Status")
|
|
274
|
+
elif choice == "context":
|
|
275
|
+
memory_context_set(text=None, editor=False, stdin=False, project_arg=str(project.id))
|
|
276
|
+
changed.append("Important Context")
|
|
277
|
+
return changed
|