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,435 @@
|
|
|
1
|
+
"""Task commands: pm task list/show/add/edit/done/snooze/reopen/delete, pm done."""
|
|
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.models.api import Task, TaskCounts
|
|
13
|
+
from projectmemory_cli.project_context import guard_project_session
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Task management 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 _session_client(client: APIClient, session_id: int) -> APIClient:
|
|
28
|
+
return APIClient(base_url=client.base_url, token=client.token, session_id=session_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _display_task(task: Task) -> None:
|
|
32
|
+
status = "✓" if task.is_completed else ("💤" if task.is_snoozed else "○")
|
|
33
|
+
na = " [dim]→ next action[/dim]" if task.is_next_action else ""
|
|
34
|
+
output.info(f" {status} [bold]#{task.id}[/bold] {task.title}{na}")
|
|
35
|
+
if task.description:
|
|
36
|
+
output.hint(f" {task.description[:100]}")
|
|
37
|
+
if task.priority != "medium":
|
|
38
|
+
output.hint(f" Priority: {task.priority}")
|
|
39
|
+
if task.due_date:
|
|
40
|
+
output.hint(f" Due: {task.due_date}")
|
|
41
|
+
if task.has_linked_commits:
|
|
42
|
+
output.hint(f" Commits: {task.linked_commit_count}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _display_tasks_table(tasks: list[Task], title: str = "Tasks") -> None:
|
|
46
|
+
if not tasks:
|
|
47
|
+
output.hint("(none)")
|
|
48
|
+
return
|
|
49
|
+
headers = ["#", "Status", "Title", "Priority", "Due"]
|
|
50
|
+
rows = []
|
|
51
|
+
for t in tasks:
|
|
52
|
+
status = "done" if t.is_completed else ("snoozed" if t.is_snoozed else "open")
|
|
53
|
+
na = " →" if t.is_next_action else ""
|
|
54
|
+
rows.append([str(t.id), status, t.title[:50] + na, t.priority, t.due_date or "—"])
|
|
55
|
+
output.table(headers, rows, title=title)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _offer_commit_link(client: APIClient, session_id: int, task: Task, project_id: int) -> None:
|
|
59
|
+
"""After completing a task, optionally link a recent commit."""
|
|
60
|
+
root = git.get_repo_root()
|
|
61
|
+
if not root:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Check if project has GitHub connection
|
|
65
|
+
try:
|
|
66
|
+
ctx_data = client.get(f"/api/cli/projects/{project_id}/context/")
|
|
67
|
+
github = ctx_data.get("github", {})
|
|
68
|
+
if not github.get("connected"):
|
|
69
|
+
return
|
|
70
|
+
repo_full_name = github.get("repository", {}).get("full_name", "")
|
|
71
|
+
except APIError:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
commits = git.get_recent_commits(root, limit=10)
|
|
75
|
+
if not commits:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
output.blank()
|
|
79
|
+
output.info("[bold]Recent commits:[/bold]")
|
|
80
|
+
choices = [questionary.Choice("Skip", value=None)]
|
|
81
|
+
choices.extend(questionary.Choice(f"{c.short_sha} {c.message[:60]}", value=c) for c in commits)
|
|
82
|
+
|
|
83
|
+
selected = questionary.select("Select a commit, or press Enter to skip:", choices=choices).ask()
|
|
84
|
+
if not selected:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
sc = _session_client(client, session_id)
|
|
88
|
+
try:
|
|
89
|
+
sc.post(
|
|
90
|
+
f"/api/cli/tasks/{task.id}/commits/link/",
|
|
91
|
+
json={
|
|
92
|
+
"sha": selected.sha,
|
|
93
|
+
"message": selected.message,
|
|
94
|
+
"author_name": selected.author,
|
|
95
|
+
"repository_full_name": repo_full_name,
|
|
96
|
+
},
|
|
97
|
+
project_name=task.project_name,
|
|
98
|
+
)
|
|
99
|
+
output.success(f"Commit [dim]{selected.short_sha}[/dim] linked.")
|
|
100
|
+
except APIError as exc:
|
|
101
|
+
output.warn(f"Could not link commit: {exc}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Commands
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@app.command("list")
|
|
109
|
+
def task_list(
|
|
110
|
+
status: str = typer.Option("open", "--status", "-s", help="open|completed|snoozed|all"),
|
|
111
|
+
open_only: bool = typer.Option(False, "--open", help="Show open tasks."),
|
|
112
|
+
completed: bool = typer.Option(False, "--completed", help="Show completed tasks."),
|
|
113
|
+
snoozed: bool = typer.Option(False, "--snoozed", help="Show snoozed tasks."),
|
|
114
|
+
all_tasks: bool = typer.Option(False, "--all", help="Show all tasks."),
|
|
115
|
+
search: Optional[str] = typer.Option(None, "--search", help="Search query."),
|
|
116
|
+
json: bool = typer.Option(False, "--json"),
|
|
117
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""List tasks for the current project."""
|
|
120
|
+
output.configure(json_mode=json)
|
|
121
|
+
client = _require_client()
|
|
122
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
123
|
+
|
|
124
|
+
if all_tasks:
|
|
125
|
+
status = "all"
|
|
126
|
+
elif completed:
|
|
127
|
+
status = "completed"
|
|
128
|
+
elif snoozed:
|
|
129
|
+
status = "snoozed"
|
|
130
|
+
elif open_only:
|
|
131
|
+
status = "open"
|
|
132
|
+
|
|
133
|
+
params: dict = {"status": status}
|
|
134
|
+
if search:
|
|
135
|
+
params["search"] = search
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
data = client.get(f"/api/cli/projects/{project.id}/tasks/", params=params)
|
|
139
|
+
except APIError as exc:
|
|
140
|
+
output.error(str(exc))
|
|
141
|
+
raise SystemExit(exc.exit_code)
|
|
142
|
+
|
|
143
|
+
if json:
|
|
144
|
+
output.print_json(data)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
tasks = [Task.model_validate(t) for t in data.get("tasks", [])]
|
|
148
|
+
counts = data.get("counts", {})
|
|
149
|
+
_display_tasks_table(tasks, title=f"{project.name} — {status} tasks")
|
|
150
|
+
output.hint(f"open: {counts.get('open', 0)} snoozed: {counts.get('snoozed', 0)} done: {counts.get('completed', 0)}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command("show")
|
|
154
|
+
def task_show(
|
|
155
|
+
task_id: int = typer.Argument(...),
|
|
156
|
+
json: bool = typer.Option(False, "--json"),
|
|
157
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Show details of a task."""
|
|
160
|
+
output.configure(json_mode=json)
|
|
161
|
+
client = _require_client()
|
|
162
|
+
guard_project_session(client, project_arg=project_arg)
|
|
163
|
+
try:
|
|
164
|
+
data = client.get(f"/api/cli/tasks/{task_id}/")
|
|
165
|
+
except APIError as exc:
|
|
166
|
+
output.error(str(exc))
|
|
167
|
+
raise SystemExit(exc.exit_code)
|
|
168
|
+
if json:
|
|
169
|
+
output.print_json(data)
|
|
170
|
+
return
|
|
171
|
+
task = Task.model_validate(data.get("task", {}))
|
|
172
|
+
output.blank()
|
|
173
|
+
_display_task(task)
|
|
174
|
+
if task.github_commits:
|
|
175
|
+
output.blank()
|
|
176
|
+
output.info("[bold]Linked commits:[/bold]")
|
|
177
|
+
for c in task.github_commits:
|
|
178
|
+
output.hint(f" {c.get('short_sha','?')} {c.get('message','')[:60]}")
|
|
179
|
+
output.blank()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("add")
|
|
183
|
+
def task_add(
|
|
184
|
+
title: Optional[str] = typer.Argument(None, help="Task title."),
|
|
185
|
+
description: Optional[str] = typer.Option(None, "--description", "-d"),
|
|
186
|
+
priority: Optional[str] = typer.Option(None, "--priority", help="low|medium|high"),
|
|
187
|
+
due: Optional[str] = typer.Option(None, "--due", help="Due date (YYYY-MM-DD)."),
|
|
188
|
+
next_action: bool = typer.Option(False, "--next", help="Set as Next Action."),
|
|
189
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Add a new task."""
|
|
192
|
+
client = _require_client()
|
|
193
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
194
|
+
sc = _session_client(client, session.id)
|
|
195
|
+
|
|
196
|
+
if not title:
|
|
197
|
+
title = questionary.text("Task title:").ask()
|
|
198
|
+
if not title:
|
|
199
|
+
raise SystemExit(7)
|
|
200
|
+
|
|
201
|
+
payload: dict = {"title": title}
|
|
202
|
+
if description:
|
|
203
|
+
payload["description"] = description
|
|
204
|
+
if priority:
|
|
205
|
+
payload["priority"] = priority
|
|
206
|
+
if due:
|
|
207
|
+
payload["due_date"] = due
|
|
208
|
+
if next_action:
|
|
209
|
+
payload["set_as_next_action"] = True
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
data = sc.post(f"/api/cli/projects/{project.id}/tasks/", json=payload, project_name=project.name)
|
|
213
|
+
except APIError as exc:
|
|
214
|
+
output.error(str(exc))
|
|
215
|
+
raise SystemExit(exc.exit_code)
|
|
216
|
+
|
|
217
|
+
task = Task.model_validate(data.get("task", {}))
|
|
218
|
+
output.success(f"Task #{task.id}: [bold]{task.title}[/bold]")
|
|
219
|
+
if next_action:
|
|
220
|
+
output.success("Set as Next Action.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("edit")
|
|
224
|
+
def task_edit(
|
|
225
|
+
task_id: int = typer.Argument(...),
|
|
226
|
+
title: Optional[str] = typer.Option(None, "--title"),
|
|
227
|
+
description: Optional[str] = typer.Option(None, "--description", "-d"),
|
|
228
|
+
priority: Optional[str] = typer.Option(None, "--priority"),
|
|
229
|
+
due: Optional[str] = typer.Option(None, "--due"),
|
|
230
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Edit a task."""
|
|
233
|
+
client = _require_client()
|
|
234
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
235
|
+
sc = _session_client(client, session.id)
|
|
236
|
+
|
|
237
|
+
payload: dict = {}
|
|
238
|
+
if title:
|
|
239
|
+
payload["title"] = title
|
|
240
|
+
if description is not None:
|
|
241
|
+
payload["description"] = description
|
|
242
|
+
if priority:
|
|
243
|
+
payload["priority"] = priority
|
|
244
|
+
if due is not None:
|
|
245
|
+
payload["due_date"] = due
|
|
246
|
+
|
|
247
|
+
if not payload:
|
|
248
|
+
output.warn("No changes specified.")
|
|
249
|
+
raise SystemExit(0)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
data = sc.patch(f"/api/cli/tasks/{task_id}/", json=payload, project_name=project.name)
|
|
253
|
+
except APIError as exc:
|
|
254
|
+
output.error(str(exc))
|
|
255
|
+
raise SystemExit(exc.exit_code)
|
|
256
|
+
task = Task.model_validate(data.get("task", {}))
|
|
257
|
+
output.success(f"Task #{task.id} updated.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command("done")
|
|
261
|
+
def task_done(
|
|
262
|
+
task_id: int = typer.Argument(...),
|
|
263
|
+
commit: Optional[str] = typer.Option(None, "--commit", help="Commit ref to link (e.g. HEAD)."),
|
|
264
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Complete a task (and optionally link a commit)."""
|
|
267
|
+
client = _require_client()
|
|
268
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
269
|
+
sc = _session_client(client, session.id)
|
|
270
|
+
|
|
271
|
+
# 1. Complete task immediately
|
|
272
|
+
try:
|
|
273
|
+
data = sc.post(f"/api/cli/tasks/{task_id}/complete/", project_name=project.name)
|
|
274
|
+
except APIError as exc:
|
|
275
|
+
output.error(str(exc))
|
|
276
|
+
raise SystemExit(exc.exit_code)
|
|
277
|
+
|
|
278
|
+
task = Task.model_validate(data.get("task", {}))
|
|
279
|
+
output.success(f"Task #{task.id} completed.")
|
|
280
|
+
|
|
281
|
+
# 2. Commit linking
|
|
282
|
+
if commit:
|
|
283
|
+
# Direct commit ref
|
|
284
|
+
root = git.get_repo_root()
|
|
285
|
+
sha = git.resolve_sha(commit, root) if root else ""
|
|
286
|
+
if not sha:
|
|
287
|
+
output.warn(f"Could not resolve commit ref: {commit!r}")
|
|
288
|
+
else:
|
|
289
|
+
info = git.get_commit_info(sha, root)
|
|
290
|
+
try:
|
|
291
|
+
ctx_data = client.get(f"/api/cli/projects/{project.id}/context/")
|
|
292
|
+
repo_full = ctx_data.get("github", {}).get("repository", {}).get("full_name", "")
|
|
293
|
+
sc.post(
|
|
294
|
+
f"/api/cli/tasks/{task_id}/commits/link/",
|
|
295
|
+
json={
|
|
296
|
+
"sha": sha,
|
|
297
|
+
"message": info.message if info else "",
|
|
298
|
+
"author_name": info.author if info else "",
|
|
299
|
+
"repository_full_name": repo_full,
|
|
300
|
+
},
|
|
301
|
+
project_name=project.name,
|
|
302
|
+
)
|
|
303
|
+
output.success(f"Commit {sha[:8]} linked.")
|
|
304
|
+
except APIError as exc:
|
|
305
|
+
output.warn(f"Could not link commit: {exc}")
|
|
306
|
+
else:
|
|
307
|
+
# Offer interactive selection
|
|
308
|
+
_offer_commit_link(client, session.id, task, project.id)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command("snooze")
|
|
312
|
+
def task_snooze(
|
|
313
|
+
task_id: int = typer.Argument(...),
|
|
314
|
+
choice: Optional[str] = typer.Option(None, "--choice", help="tomorrow|next_week|next_month|someday"),
|
|
315
|
+
until: Optional[str] = typer.Option(None, "--until", help="Date YYYY-MM-DD (for pick_date)."),
|
|
316
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Snooze a task."""
|
|
319
|
+
client = _require_client()
|
|
320
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
321
|
+
sc = _session_client(client, session.id)
|
|
322
|
+
|
|
323
|
+
if not choice:
|
|
324
|
+
choice = questionary.select(
|
|
325
|
+
"Snooze until:",
|
|
326
|
+
choices=[
|
|
327
|
+
questionary.Choice("Tomorrow", value="tomorrow"),
|
|
328
|
+
questionary.Choice("Next week", value="next_week"),
|
|
329
|
+
questionary.Choice("Next month", value="next_month"),
|
|
330
|
+
questionary.Choice("Someday", value="someday"),
|
|
331
|
+
questionary.Choice("Pick a date", value="pick_date"),
|
|
332
|
+
],
|
|
333
|
+
).ask()
|
|
334
|
+
|
|
335
|
+
if not choice:
|
|
336
|
+
raise SystemExit(7)
|
|
337
|
+
|
|
338
|
+
payload: dict = {"choice": choice}
|
|
339
|
+
if choice == "pick_date":
|
|
340
|
+
if not until:
|
|
341
|
+
until = questionary.text("Date (YYYY-MM-DD):").ask()
|
|
342
|
+
payload["snoozed_until"] = until
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
data = sc.post(f"/api/cli/tasks/{task_id}/snooze/", json=payload, project_name=project.name)
|
|
346
|
+
except APIError as exc:
|
|
347
|
+
output.error(str(exc))
|
|
348
|
+
raise SystemExit(exc.exit_code)
|
|
349
|
+
task = Task.model_validate(data.get("task", {}))
|
|
350
|
+
snooze_str = task.snoozed_until or "someday"
|
|
351
|
+
output.success(f"Task #{task_id} snoozed until {snooze_str}.")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command("reopen")
|
|
355
|
+
def task_reopen(
|
|
356
|
+
task_id: int = typer.Argument(...),
|
|
357
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Reopen a completed task."""
|
|
360
|
+
client = _require_client()
|
|
361
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
362
|
+
sc = _session_client(client, session.id)
|
|
363
|
+
try:
|
|
364
|
+
sc.post(f"/api/cli/tasks/{task_id}/reopen/", project_name=project.name)
|
|
365
|
+
except APIError as exc:
|
|
366
|
+
output.error(str(exc))
|
|
367
|
+
raise SystemExit(exc.exit_code)
|
|
368
|
+
output.success(f"Task #{task_id} reopened.")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.command("delete")
|
|
372
|
+
def task_delete(
|
|
373
|
+
task_id: int = typer.Argument(...),
|
|
374
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
375
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Delete a task."""
|
|
378
|
+
client = _require_client()
|
|
379
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
380
|
+
sc = _session_client(client, session.id)
|
|
381
|
+
|
|
382
|
+
if not yes:
|
|
383
|
+
confirmed = typer.confirm(f"Delete task #{task_id}?", default=False)
|
|
384
|
+
if not confirmed:
|
|
385
|
+
output.info("Cancelled.")
|
|
386
|
+
raise SystemExit(7)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
sc.delete(f"/api/cli/tasks/{task_id}/", project_name=project.name)
|
|
390
|
+
except APIError as exc:
|
|
391
|
+
output.error(str(exc))
|
|
392
|
+
raise SystemExit(exc.exit_code)
|
|
393
|
+
output.success(f"Task #{task_id} deleted.")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
# pm done — interactive shortcut
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def done_interactive(
|
|
401
|
+
project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Interactive task completion shortcut."""
|
|
404
|
+
client = _require_client()
|
|
405
|
+
project, session = guard_project_session(client, project_arg=project_arg)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
data = client.get(f"/api/cli/projects/{project.id}/tasks/", params={"status": "open"})
|
|
409
|
+
except APIError as exc:
|
|
410
|
+
output.error(str(exc))
|
|
411
|
+
raise SystemExit(exc.exit_code)
|
|
412
|
+
|
|
413
|
+
tasks = [Task.model_validate(t) for t in data.get("tasks", [])]
|
|
414
|
+
if not tasks:
|
|
415
|
+
output.info("No open tasks.")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
task = questionary.select(
|
|
419
|
+
"Which task did you complete?",
|
|
420
|
+
choices=[questionary.Choice(f"#{t.id} {t.title}", value=t) for t in tasks],
|
|
421
|
+
).ask()
|
|
422
|
+
|
|
423
|
+
if not task:
|
|
424
|
+
raise SystemExit(7)
|
|
425
|
+
|
|
426
|
+
sc = _session_client(client, session.id)
|
|
427
|
+
try:
|
|
428
|
+
result = sc.post(f"/api/cli/tasks/{task.id}/complete/", project_name=project.name)
|
|
429
|
+
except APIError as exc:
|
|
430
|
+
output.error(str(exc))
|
|
431
|
+
raise SystemExit(exc.exit_code)
|
|
432
|
+
|
|
433
|
+
completed_task = Task.model_validate(result.get("task", {}))
|
|
434
|
+
output.success(f"Task #{completed_task.id} completed.")
|
|
435
|
+
_offer_commit_link(client, session.id, completed_task, project.id)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration management.
|
|
2
|
+
|
|
3
|
+
Storage: ~/.config/projectmemory/config.toml (via platformdirs)
|
|
4
|
+
|
|
5
|
+
Environment overrides (highest priority):
|
|
6
|
+
PROJECTMEMORY_API_URL → api_url
|
|
7
|
+
PROJECTMEMORY_TOKEN → token (not written, read-only override)
|
|
8
|
+
PROJECTMEMORY_PROJECT → current_project_id
|
|
9
|
+
PROJECTMEMORY_NO_COLOR → color = false
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import tomli_w
|
|
19
|
+
from platformdirs import user_config_dir
|
|
20
|
+
|
|
21
|
+
APP_NAME = "projectmemory"
|
|
22
|
+
CONFIG_DIR = Path(user_config_dir(APP_NAME))
|
|
23
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
24
|
+
|
|
25
|
+
DEFAULTS: dict[str, Any] = {
|
|
26
|
+
"api_url": "https://projectmemory.app",
|
|
27
|
+
"current_project_id": "",
|
|
28
|
+
"output": "table",
|
|
29
|
+
"color": True,
|
|
30
|
+
"editor": os.environ.get("EDITOR", "nano"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_file() -> dict[str, Any]:
|
|
35
|
+
if not CONFIG_FILE.exists():
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
return tomllib.loads(CONFIG_FILE.read_text())
|
|
39
|
+
except Exception:
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_file(data: dict[str, Any]) -> None:
|
|
44
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
CONFIG_FILE.write_text(tomli_w.dumps(data))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load() -> dict[str, Any]:
|
|
49
|
+
"""Return the merged config: defaults → file → environment."""
|
|
50
|
+
cfg = dict(DEFAULTS)
|
|
51
|
+
cfg.update(_load_file())
|
|
52
|
+
|
|
53
|
+
# Environment overrides
|
|
54
|
+
if val := os.environ.get("PROJECTMEMORY_API_URL"):
|
|
55
|
+
cfg["api_url"] = val.rstrip("/")
|
|
56
|
+
if os.environ.get("PROJECTMEMORY_NO_COLOR"):
|
|
57
|
+
cfg["color"] = False
|
|
58
|
+
if val := os.environ.get("PROJECTMEMORY_PROJECT"):
|
|
59
|
+
cfg["current_project_id"] = val
|
|
60
|
+
|
|
61
|
+
cfg["api_url"] = (cfg.get("api_url") or DEFAULTS["api_url"]).rstrip("/")
|
|
62
|
+
return cfg
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get(key: str, default: Any = None) -> Any:
|
|
66
|
+
return load().get(key, default)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_key(key: str, value: Any) -> None:
|
|
70
|
+
data = _load_file()
|
|
71
|
+
data[key] = value
|
|
72
|
+
_save_file(data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reset() -> None:
|
|
76
|
+
_save_file({})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_current_project(project_id: int | str) -> None:
|
|
80
|
+
set_key("current_project_id", str(project_id))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_current_project_id() -> str:
|
|
84
|
+
# env takes precedence
|
|
85
|
+
if val := os.environ.get("PROJECTMEMORY_PROJECT"):
|
|
86
|
+
return val
|
|
87
|
+
return str(get("current_project_id", "") or "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_api_url() -> str:
|
|
91
|
+
return str(get("api_url", DEFAULTS["api_url"])).rstrip("/")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_editor() -> str:
|
|
95
|
+
return str(get("editor", DEFAULTS["editor"]))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def all_settings() -> dict[str, Any]:
|
|
99
|
+
"""Return all settings suitable for display (masks token)."""
|
|
100
|
+
cfg = load()
|
|
101
|
+
return {k: v for k, v in cfg.items()}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Secure token storage.
|
|
2
|
+
|
|
3
|
+
Priority order:
|
|
4
|
+
1. PROJECTMEMORY_TOKEN environment variable (read-only)
|
|
5
|
+
2. OS keyring (keyring library)
|
|
6
|
+
3. Fallback: ~/.config/projectmemory/credentials.json (mode 600)
|
|
7
|
+
|
|
8
|
+
Passwords are never stored.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from platformdirs import user_config_dir
|
|
18
|
+
|
|
19
|
+
APP_NAME = "projectmemory"
|
|
20
|
+
KEYRING_SERVICE = "projectmemory-cli"
|
|
21
|
+
KEYRING_USERNAME = "token"
|
|
22
|
+
CRED_FILE = Path(user_config_dir(APP_NAME)) / "credentials.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _keyring_available() -> bool:
|
|
26
|
+
try:
|
|
27
|
+
import keyring # noqa: F401
|
|
28
|
+
return True
|
|
29
|
+
except ImportError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_token() -> str | None:
|
|
34
|
+
# 1. Environment variable
|
|
35
|
+
if val := os.environ.get("PROJECTMEMORY_TOKEN"):
|
|
36
|
+
return val.strip()
|
|
37
|
+
|
|
38
|
+
# 2. OS keyring
|
|
39
|
+
if _keyring_available():
|
|
40
|
+
try:
|
|
41
|
+
import keyring
|
|
42
|
+
token = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
43
|
+
if token:
|
|
44
|
+
return token.strip()
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# 3. Fallback file
|
|
49
|
+
if CRED_FILE.exists():
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(CRED_FILE.read_text())
|
|
52
|
+
token = data.get("token", "")
|
|
53
|
+
if token:
|
|
54
|
+
return token.strip()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_token(token: str) -> bool:
|
|
62
|
+
"""Save token. Returns True if saved to keyring, False if fallback file."""
|
|
63
|
+
if _keyring_available():
|
|
64
|
+
try:
|
|
65
|
+
import keyring
|
|
66
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, token)
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# Fallback file
|
|
72
|
+
_write_cred_file({"token": token})
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def delete_token() -> None:
|
|
77
|
+
"""Remove token from all storage locations."""
|
|
78
|
+
if _keyring_available():
|
|
79
|
+
try:
|
|
80
|
+
import keyring
|
|
81
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
if CRED_FILE.exists():
|
|
86
|
+
try:
|
|
87
|
+
CRED_FILE.unlink()
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_cred_file(data: dict) -> None:
|
|
93
|
+
CRED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
CRED_FILE.write_text(json.dumps(data))
|
|
95
|
+
try:
|
|
96
|
+
CRED_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|