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,282 @@
1
+ """Next Action commands: pm next, pm next set, pm next task, pm next issue, pm next clear."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import questionary
7
+ import typer
8
+
9
+ from projectmemory_cli import output
10
+ from projectmemory_cli.api.client import APIClient
11
+ from projectmemory_cli.api.errors import APIError
12
+ from projectmemory_cli.project_context import guard_project_session
13
+
14
+ app = typer.Typer(help="Next Action commands.", no_args_is_help=False)
15
+
16
+
17
+ def _require_client() -> APIClient:
18
+ from projectmemory_cli import credentials
19
+ token = credentials.get_token()
20
+ if not token:
21
+ output.error("Not logged in. Run: pm login")
22
+ raise SystemExit(2)
23
+ return APIClient.from_env()
24
+
25
+
26
+ def _session_client(client: APIClient, session_id: int) -> APIClient:
27
+ return APIClient(base_url=client.base_url, token=client.token, session_id=session_id, debug=client.debug)
28
+
29
+
30
+ def _display_next_action(na: dict) -> None:
31
+ title = na.get("title", "")
32
+ source_type = na.get("source_type", "manual")
33
+ linked = na.get("linked_source")
34
+
35
+ if not title:
36
+ output.info("[dim](no next action set)[/dim]")
37
+ return
38
+
39
+ output.blank()
40
+ output.header("Next Action")
41
+ output.info(f" {title}")
42
+ if source_type == "manual":
43
+ output.hint(" Manual")
44
+ elif linked:
45
+ t = linked.get("type", source_type)
46
+ lid = linked.get("id", "")
47
+ ltitle = linked.get("title", "")
48
+ output.hint(f" Linked {t} #{lid}: {ltitle}")
49
+ else:
50
+ output.hint(f" Source: {source_type}")
51
+ output.blank()
52
+
53
+
54
+ def run_next_action_menu(client: APIClient, project, session, *, project_arg: str | None = None) -> bool:
55
+ """Run the same unified Next Action choices used by web, via CLI orchestration.
56
+
57
+ Returns True when a backend change was attempted successfully.
58
+ """
59
+ session_client = _session_client(client, session.id)
60
+ choice = questionary.select(
61
+ "Next Action:",
62
+ choices=[
63
+ questionary.Choice("Write Next Action", value="write"),
64
+ questionary.Choice("Create and link a task", value="create_task"),
65
+ questionary.Choice("Link an existing task", value="link_task"),
66
+ questionary.Choice("Link an open issue", value="link_issue"),
67
+ questionary.Choice("Clear Next Action", value="clear"),
68
+ questionary.Choice("Cancel", value="cancel"),
69
+ ],
70
+ ).ask()
71
+
72
+ if not choice or choice == "cancel":
73
+ return False
74
+
75
+ try:
76
+ if choice == "write":
77
+ text = questionary.text("Next Action:").ask()
78
+ if not text:
79
+ return False
80
+ data = session_client.patch(
81
+ f"/api/cli/projects/{project.id}/next-action/",
82
+ json={"title": text},
83
+ project_name=project.name,
84
+ )
85
+ output.success(f"Next Action set: [bold]{text}[/bold]")
86
+ _display_next_action(data.get("next_action", {}))
87
+ return True
88
+
89
+ if choice == "create_task":
90
+ title = questionary.text("Task title:").ask()
91
+ if not title:
92
+ return False
93
+ task_data = session_client.post(
94
+ f"/api/cli/projects/{project.id}/tasks/",
95
+ json={"title": title},
96
+ project_name=project.name,
97
+ )
98
+ task_id = task_data["task"]["id"]
99
+ data = session_client.post(
100
+ f"/api/cli/projects/{project.id}/next-action/task/",
101
+ json={"task_id": task_id},
102
+ project_name=project.name,
103
+ )
104
+ output.success(f"Task #{task_id} created and set as Next Action.")
105
+ _display_next_action(data.get("next_action", {}))
106
+ return True
107
+
108
+ if choice == "link_task":
109
+ tasks_data = client.get(f"/api/cli/projects/{project.id}/tasks/", params={"status": "open"})
110
+ tasks = tasks_data.get("tasks", [])
111
+ if not tasks:
112
+ output.warn("No open tasks.")
113
+ return False
114
+ task = questionary.select(
115
+ "Select task:",
116
+ choices=[questionary.Choice(f"#{t['id']} {t['title']}", value=t) for t in tasks],
117
+ ).ask()
118
+ if not task:
119
+ return False
120
+ data = session_client.post(
121
+ f"/api/cli/projects/{project.id}/next-action/task/",
122
+ json={"task_id": task["id"]},
123
+ project_name=project.name,
124
+ )
125
+ output.success(f"Task #{task['id']} set as Next Action.")
126
+ _display_next_action(data.get("next_action", {}))
127
+ return True
128
+
129
+ if choice == "link_issue":
130
+ issues_data = client.get(f"/api/cli/projects/{project.id}/issues/", params={"status": "open"})
131
+ issues = issues_data.get("issues", [])
132
+ if not issues:
133
+ output.warn("No open issues.")
134
+ return False
135
+ issue = questionary.select(
136
+ "Select issue:",
137
+ choices=[questionary.Choice(f"#{i['id']} {i['description']}", value=i) for i in issues],
138
+ ).ask()
139
+ if not issue:
140
+ return False
141
+ data = session_client.post(
142
+ f"/api/cli/projects/{project.id}/next-action/issue/",
143
+ json={"issue_id": issue["id"]},
144
+ project_name=project.name,
145
+ )
146
+ output.success(f"Issue #{issue['id']} set as Next Action.")
147
+ _display_next_action(data.get("next_action", {}))
148
+ return True
149
+
150
+ if choice == "clear":
151
+ data = session_client.post(
152
+ f"/api/cli/projects/{project.id}/next-action/clear/",
153
+ project_name=project.name,
154
+ )
155
+ output.success("Next Action cleared.")
156
+ _display_next_action(data.get("next_action", {}))
157
+ return True
158
+
159
+ except APIError as exc:
160
+ output.error(str(exc))
161
+ raise SystemExit(exc.exit_code)
162
+
163
+ return False
164
+
165
+
166
+ @app.callback(invoke_without_command=True)
167
+ def next_default(
168
+ ctx: typer.Context,
169
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
170
+ ) -> None:
171
+ """Show the current Next Action when no subcommand is supplied."""
172
+ if ctx.invoked_subcommand is not None:
173
+ return
174
+ next_show(project_arg=project_arg)
175
+
176
+
177
+ @app.command("show")
178
+ def next_show(
179
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
180
+ ) -> None:
181
+ """Show the current Next Action."""
182
+ client = _require_client()
183
+ project, _session = guard_project_session(client, project_arg=project_arg)
184
+ try:
185
+ data = client.get(f"/api/cli/projects/{project.id}/next-action/")
186
+ except APIError as exc:
187
+ output.error(str(exc))
188
+ raise SystemExit(exc.exit_code)
189
+ _display_next_action(data.get("next_action", {}))
190
+
191
+
192
+ @app.command("set")
193
+ def next_set(
194
+ text: Optional[str] = typer.Argument(None, help="Next Action text. Omit for interactive menu."),
195
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
196
+ ) -> None:
197
+ """Set the Next Action manually or use the unified interactive menu."""
198
+ client = _require_client()
199
+ project, session = guard_project_session(client, project_arg=project_arg)
200
+ session_client = _session_client(client, session.id)
201
+
202
+ if text:
203
+ try:
204
+ data = session_client.patch(
205
+ f"/api/cli/projects/{project.id}/next-action/",
206
+ json={"title": text},
207
+ project_name=project.name,
208
+ )
209
+ except APIError as exc:
210
+ output.error(str(exc))
211
+ raise SystemExit(exc.exit_code)
212
+ output.success(f"Next Action set: [bold]{text}[/bold]")
213
+ _display_next_action(data.get("next_action", {}))
214
+ return
215
+
216
+ changed = run_next_action_menu(client, project, session, project_arg=project_arg)
217
+ if not changed:
218
+ output.info("No changes made.")
219
+
220
+
221
+ @app.command("task")
222
+ def next_task(
223
+ task_id: int = typer.Argument(..., help="Task ID to set as Next Action."),
224
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
225
+ ) -> None:
226
+ """Link a task as the Next Action."""
227
+ client = _require_client()
228
+ project, session = guard_project_session(client, project_arg=project_arg)
229
+ session_client = _session_client(client, session.id)
230
+ try:
231
+ data = session_client.post(
232
+ f"/api/cli/projects/{project.id}/next-action/task/",
233
+ json={"task_id": task_id},
234
+ project_name=project.name,
235
+ )
236
+ except APIError as exc:
237
+ output.error(str(exc))
238
+ raise SystemExit(exc.exit_code)
239
+ output.success(f"Task #{task_id} set as Next Action.")
240
+ _display_next_action(data.get("next_action", {}))
241
+
242
+
243
+ @app.command("issue")
244
+ def next_issue(
245
+ issue_id: int = typer.Argument(..., help="Issue ID to set as Next Action."),
246
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
247
+ ) -> None:
248
+ """Link an issue as the Next Action."""
249
+ client = _require_client()
250
+ project, session = guard_project_session(client, project_arg=project_arg)
251
+ session_client = _session_client(client, session.id)
252
+ try:
253
+ data = session_client.post(
254
+ f"/api/cli/projects/{project.id}/next-action/issue/",
255
+ json={"issue_id": issue_id},
256
+ project_name=project.name,
257
+ )
258
+ except APIError as exc:
259
+ output.error(str(exc))
260
+ raise SystemExit(exc.exit_code)
261
+ output.success(f"Issue #{issue_id} set as Next Action.")
262
+ _display_next_action(data.get("next_action", {}))
263
+
264
+
265
+ @app.command("clear")
266
+ def next_clear(
267
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
268
+ ) -> None:
269
+ """Clear the Next Action."""
270
+ client = _require_client()
271
+ project, session = guard_project_session(client, project_arg=project_arg)
272
+ session_client = _session_client(client, session.id)
273
+ try:
274
+ data = session_client.post(
275
+ f"/api/cli/projects/{project.id}/next-action/clear/",
276
+ project_name=project.name,
277
+ )
278
+ except APIError as exc:
279
+ output.error(str(exc))
280
+ raise SystemExit(exc.exit_code)
281
+ output.success("Next Action cleared.")
282
+ _display_next_action(data.get("next_action", {}))
@@ -0,0 +1,231 @@
1
+ """Note commands: pm note list/show/add/edit/delete, pm capture."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import questionary
8
+ import typer
9
+
10
+ from projectmemory_cli import output
11
+ from projectmemory_cli.api.client import APIClient
12
+ from projectmemory_cli.api.errors import APIError
13
+ from projectmemory_cli.models.api import Note
14
+ from projectmemory_cli.project_context import guard_project_session
15
+
16
+ app = typer.Typer(help="Note commands.", no_args_is_help=True)
17
+
18
+
19
+ def _require_client() -> APIClient:
20
+ from projectmemory_cli import credentials
21
+ token = credentials.get_token()
22
+ if not token:
23
+ output.error("Not logged in. Run: pm login")
24
+ raise SystemExit(2)
25
+ return APIClient.from_env()
26
+
27
+
28
+ def _session_client(client: APIClient, session_id: int) -> APIClient:
29
+ return APIClient(base_url=client.base_url, token=client.token, session_id=session_id)
30
+
31
+
32
+ def _display_note(note: Note) -> None:
33
+ output.blank()
34
+ if note.title:
35
+ output.info(f"[bold]#{note.id} {note.title}[/bold]")
36
+ else:
37
+ output.info(f"[bold]#{note.id}[/bold]")
38
+ output.info(note.content)
39
+ output.hint(f"Updated {output.format_ago(note.updated_at)}")
40
+ output.blank()
41
+
42
+
43
+ @app.command("list")
44
+ def note_list(
45
+ search: Optional[str] = typer.Option(None, "--search", help="Search query."),
46
+ json: bool = typer.Option(False, "--json"),
47
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
48
+ ) -> None:
49
+ """List notes for the current project."""
50
+ output.configure(json_mode=json)
51
+ client = _require_client()
52
+ project, session = guard_project_session(client, project_arg=project_arg)
53
+
54
+ params: dict = {}
55
+ if search:
56
+ params["search"] = search
57
+
58
+ try:
59
+ data = client.get(f"/api/cli/projects/{project.id}/notes/", params=params)
60
+ except APIError as exc:
61
+ output.error(str(exc))
62
+ raise SystemExit(exc.exit_code)
63
+
64
+ if json:
65
+ output.print_json(data)
66
+ return
67
+
68
+ notes = [Note.model_validate(n) for n in data.get("notes", [])]
69
+ if not notes:
70
+ output.hint("No notes.")
71
+ return
72
+ headers = ["#", "Title", "Updated"]
73
+ rows = [[str(n.id), n.title or n.content[:50], output.format_ago(n.updated_at)] for n in notes]
74
+ output.table(headers, rows, title=f"{project.name} — Notes ({data.get('count', len(notes))})")
75
+
76
+
77
+ @app.command("show")
78
+ def note_show(
79
+ note_id: int = typer.Argument(...),
80
+ json: bool = typer.Option(False, "--json"),
81
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
82
+ ) -> None:
83
+ """Show a note's full content."""
84
+ output.configure(json_mode=json)
85
+ client = _require_client()
86
+ guard_project_session(client, project_arg=project_arg)
87
+ try:
88
+ data = client.get(f"/api/cli/notes/{note_id}/")
89
+ except APIError as exc:
90
+ output.error(str(exc))
91
+ raise SystemExit(exc.exit_code)
92
+ if json:
93
+ output.print_json(data)
94
+ return
95
+ note = Note.model_validate(data.get("note", {}))
96
+ _display_note(note)
97
+
98
+
99
+ @app.command("add")
100
+ def note_add(
101
+ content: Optional[str] = typer.Argument(None, help="Note content."),
102
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Note title."),
103
+ editor: bool = typer.Option(False, "--editor", "-e", help="Open editor for note content."),
104
+ stdin: bool = typer.Option(False, "--stdin", help="Read content from stdin."),
105
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
106
+ ) -> None:
107
+ """Add a note to the current project."""
108
+ client = _require_client()
109
+ project, session = guard_project_session(client, project_arg=project_arg)
110
+ sc = _session_client(client, session.id)
111
+
112
+ if stdin or (not content and not sys.stdin.isatty()):
113
+ content = sys.stdin.read().strip()
114
+ elif editor:
115
+ from projectmemory_cli.commands.memory import _open_in_editor
116
+ content = _open_in_editor(content or "")
117
+ elif not content:
118
+ content = questionary.text("Note content:").ask() or ""
119
+
120
+ if not content:
121
+ output.info("No content provided.")
122
+ raise SystemExit(0)
123
+
124
+ if not title:
125
+ title = ""
126
+
127
+ try:
128
+ data = sc.post(
129
+ f"/api/cli/projects/{project.id}/notes/",
130
+ json={"content": content, "title": title},
131
+ project_name=project.name,
132
+ )
133
+ except APIError as exc:
134
+ output.error(str(exc))
135
+ raise SystemExit(exc.exit_code)
136
+
137
+ note = Note.model_validate(data.get("note", {}))
138
+ output.success(f"Note #{note.id} created.")
139
+
140
+
141
+ @app.command("edit")
142
+ def note_edit(
143
+ note_id: int = typer.Argument(...),
144
+ title: Optional[str] = typer.Option(None, "--title", "-t"),
145
+ content: Optional[str] = typer.Option(None, "--content"),
146
+ editor: bool = typer.Option(False, "--editor", "-e"),
147
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
148
+ ) -> None:
149
+ """Edit a note."""
150
+ client = _require_client()
151
+ project, session = guard_project_session(client, project_arg=project_arg)
152
+ sc = _session_client(client, session.id)
153
+
154
+ payload: dict = {}
155
+ if title is not None:
156
+ payload["title"] = title
157
+
158
+ if editor:
159
+ # Fetch current content first
160
+ try:
161
+ current_data = client.get(f"/api/cli/notes/{note_id}/")
162
+ current_note = current_data.get("note", {})
163
+ current_content = current_note.get("content", "")
164
+ except APIError:
165
+ current_content = ""
166
+
167
+ from projectmemory_cli.commands.memory import _open_in_editor
168
+ content = _open_in_editor(current_content)
169
+
170
+ if content is not None:
171
+ payload["content"] = content
172
+
173
+ if not payload:
174
+ output.warn("No changes specified.")
175
+ raise SystemExit(0)
176
+
177
+ try:
178
+ sc.patch(f"/api/cli/notes/{note_id}/", json=payload, project_name=project.name)
179
+ except APIError as exc:
180
+ output.error(str(exc))
181
+ raise SystemExit(exc.exit_code)
182
+ output.success(f"Note #{note_id} updated.")
183
+
184
+
185
+ @app.command("delete")
186
+ def note_delete(
187
+ note_id: int = typer.Argument(...),
188
+ yes: bool = typer.Option(False, "--yes", "-y"),
189
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
190
+ ) -> None:
191
+ """Delete a note."""
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 yes:
197
+ confirmed = typer.confirm(f"Delete note #{note_id}?", default=False)
198
+ if not confirmed:
199
+ output.info("Cancelled.")
200
+ raise SystemExit(7)
201
+
202
+ try:
203
+ sc.delete(f"/api/cli/notes/{note_id}/", project_name=project.name)
204
+ except APIError as exc:
205
+ output.error(str(exc))
206
+ raise SystemExit(exc.exit_code)
207
+ output.success(f"Note #{note_id} deleted.")
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # pm capture — quick note shortcut
212
+ # ---------------------------------------------------------------------------
213
+
214
+ @app.command("search")
215
+ def note_search(
216
+ query: str = typer.Argument(..., help="Search query."),
217
+ json: bool = typer.Option(False, "--json"),
218
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
219
+ ) -> None:
220
+ """Search notes in the current project."""
221
+ note_list(search=query, json=json, project_arg=project_arg)
222
+
223
+
224
+ def capture(
225
+ text: Optional[str] = typer.Argument(None, help="Note content."),
226
+ title: Optional[str] = typer.Option(None, "--title", "-t"),
227
+ stdin: bool = typer.Option(False, "--stdin"),
228
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
229
+ ) -> None:
230
+ """Quick note capture (shortcut for pm note add)."""
231
+ note_add(content=text, title=title, editor=False, stdin=stdin, project_arg=project_arg)