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