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,244 @@
1
+ """Project context resolution and session guard.
2
+
3
+ Resolution order for current project:
4
+ 1. Explicit project argument (--project flag or positional name/id)
5
+ 2. Current directory / git remote mapping
6
+ 3. Stored current_project_id in config
7
+ 4. Interactive selection
8
+
9
+ The session guard always fetches fresh state from the backend —
10
+ local cache is never treated as authoritative.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from platformdirs import user_config_dir
20
+
21
+ from projectmemory_cli import config, output
22
+ from projectmemory_cli.api.client import APIClient
23
+ from projectmemory_cli.api.errors import (
24
+ APIError,
25
+ AuthenticationError,
26
+ NoActiveSessionError,
27
+ )
28
+ from projectmemory_cli.models.api import ActiveSession, ProjectSummary
29
+
30
+ APP_NAME = "projectmemory"
31
+ REPO_MAP_FILE = Path(user_config_dir(APP_NAME)) / "repo_map.json"
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Repository mapping
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _load_repo_map() -> dict:
39
+ if not REPO_MAP_FILE.exists():
40
+ return {}
41
+ try:
42
+ return json.loads(REPO_MAP_FILE.read_text())
43
+ except Exception:
44
+ return {}
45
+
46
+
47
+ def _save_repo_map(data: dict) -> None:
48
+ REPO_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
49
+ REPO_MAP_FILE.write_text(json.dumps(data, indent=2))
50
+
51
+
52
+ def save_repo_mapping(path: str, project_id: int, project_name: str) -> None:
53
+ data = _load_repo_map()
54
+ data[path] = {"project_id": project_id, "project_name": project_name}
55
+ _save_repo_map(data)
56
+
57
+
58
+ def remove_repo_mapping(path: str) -> bool:
59
+ data = _load_repo_map()
60
+ if path in data:
61
+ del data[path]
62
+ _save_repo_map(data)
63
+ return True
64
+ return False
65
+
66
+
67
+ def get_mapped_project_id_for_cwd() -> Optional[tuple[int, str]]:
68
+ """Return (project_id, project_name) if CWD or a git root is mapped."""
69
+ from projectmemory_cli import git
70
+ data = _load_repo_map()
71
+ if not data:
72
+ return None
73
+
74
+ cwd = str(Path.cwd())
75
+ if cwd in data:
76
+ entry = data[cwd]
77
+ return entry["project_id"], entry["project_name"]
78
+
79
+ # Check git repo root
80
+ root = git.get_repo_root()
81
+ if root:
82
+ root_str = str(root)
83
+ if root_str in data:
84
+ entry = data[root_str]
85
+ return entry["project_id"], entry["project_name"]
86
+
87
+ # Check by git remote
88
+ remote_name = git.normalize_github_repo(git.get_remote_url(root))
89
+ if remote_name:
90
+ key = f"git_remote:{remote_name}"
91
+ if key in data:
92
+ entry = data[key]
93
+ return entry["project_id"], entry["project_name"]
94
+
95
+ return None
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Project resolution
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def fetch_projects(client: APIClient) -> list[ProjectSummary]:
103
+ data = client.get("/api/cli/projects/")
104
+ return [ProjectSummary.model_validate(p) for p in data.get("projects", [])]
105
+
106
+
107
+ def find_project_by_name_or_id(
108
+ projects: list[ProjectSummary], query: str
109
+ ) -> Optional[ProjectSummary]:
110
+ """Find by exact ID match first, then case-insensitive name."""
111
+ query = query.strip()
112
+ # Try numeric ID
113
+ try:
114
+ pid = int(query)
115
+ for p in projects:
116
+ if p.id == pid:
117
+ return p
118
+ except ValueError:
119
+ pass
120
+ # Name match (case-insensitive, partial)
121
+ ql = query.lower()
122
+ for p in projects:
123
+ if p.name.lower() == ql:
124
+ return p
125
+ # Partial
126
+ matches = [p for p in projects if ql in p.name.lower()]
127
+ if len(matches) == 1:
128
+ return matches[0]
129
+ return None
130
+
131
+
132
+ def select_project_interactive(projects: list[ProjectSummary]) -> Optional[ProjectSummary]:
133
+ """Present a questionary menu to pick a project."""
134
+ import questionary
135
+ if not projects:
136
+ return None
137
+ choices = [
138
+ questionary.Choice(
139
+ title=f"{p.name}{' [active session]' if p.has_active_session else ''}",
140
+ value=p,
141
+ )
142
+ for p in projects
143
+ ]
144
+ try:
145
+ result = questionary.select(
146
+ "Select project:",
147
+ choices=choices,
148
+ ).ask()
149
+ return result
150
+ except (KeyboardInterrupt, EOFError):
151
+ return None
152
+
153
+
154
+ def resolve_project(
155
+ client: APIClient,
156
+ *,
157
+ arg: Optional[str] = None,
158
+ interactive: bool = True,
159
+ ) -> Optional[ProjectSummary]:
160
+ """Full project resolution pipeline."""
161
+
162
+ projects: list[ProjectSummary] | None = None
163
+
164
+ def _projects() -> list[ProjectSummary]:
165
+ nonlocal projects
166
+ if projects is None:
167
+ projects = fetch_projects(client)
168
+ return projects
169
+
170
+ # 1. Explicit argument
171
+ if arg:
172
+ ps = _projects()
173
+ found = find_project_by_name_or_id(ps, arg)
174
+ if found:
175
+ return found
176
+ output.error(f"Project not found: {arg!r}")
177
+ raise SystemExit(5)
178
+
179
+ # 2. Current directory / git mapping
180
+ mapped = get_mapped_project_id_for_cwd()
181
+ if mapped:
182
+ project_id, _ = mapped
183
+ ps = _projects()
184
+ found = find_project_by_name_or_id(ps, str(project_id))
185
+ if found:
186
+ return found
187
+
188
+ # 3. Stored current project
189
+ stored_id = config.get_current_project_id()
190
+ if stored_id:
191
+ ps = _projects()
192
+ found = find_project_by_name_or_id(ps, stored_id)
193
+ if found:
194
+ return found
195
+
196
+ # 4. Interactive selection
197
+ if interactive:
198
+ ps = _projects()
199
+ if not ps:
200
+ output.error("You have no projects. Run: pm project create")
201
+ raise SystemExit(5)
202
+ return select_project_interactive(ps)
203
+
204
+ return None
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Session guard
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def get_active_session_for_project(client: APIClient, project_id: int) -> Optional[ActiveSession]:
212
+ """Fetch the active session for a project from the backend (never cached)."""
213
+ try:
214
+ data = client.get("/api/cli/session/status/")
215
+ except AuthenticationError:
216
+ raise
217
+ except APIError:
218
+ return None
219
+ sessions = data.get("active_sessions", [])
220
+ for s in sessions:
221
+ if s.get("project") == project_id:
222
+ return ActiveSession.model_validate(s)
223
+ return None
224
+
225
+
226
+ def guard_project_session(
227
+ client: APIClient,
228
+ *,
229
+ project_arg: Optional[str] = None,
230
+ ) -> tuple[ProjectSummary, ActiveSession]:
231
+ """Resolve project and verify active session. Exit with clear message on failure."""
232
+ project = resolve_project(client, arg=project_arg)
233
+ if not project:
234
+ output.error("No current project.")
235
+ output.hint("Run:\n pm resume")
236
+ raise SystemExit(1)
237
+
238
+ session = get_active_session_for_project(client, project.id)
239
+ if not session:
240
+ output.error(f"No active session for {project.name}.")
241
+ output.hint("Run:\n pm resume")
242
+ raise SystemExit(8)
243
+
244
+ return project, session
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: projectmemory-cli
3
+ Version: 0.1.0
4
+ Summary: Project Memory CLI — developer context in your terminal
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: keyring>=25
9
+ Requires-Dist: platformdirs>=4
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Dist: questionary>=2
12
+ Requires-Dist: rich>=13
13
+ Requires-Dist: tomli-w>=1
14
+ Requires-Dist: typer[all]>=0.12
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.10; extra == 'dev'
17
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
18
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
19
+ Requires-Dist: pytest-mock>=3; extra == 'dev'
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Project Memory CLI
25
+
26
+ Developer context in your terminal — a Python CLI client for [projectmemory.app](https://projectmemory.app).
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install projectmemory-cli
32
+ ```
33
+
34
+ Or from source:
35
+
36
+ ```bash
37
+ git clone https://github.com/your-org/projectmemory-cli
38
+ cd projectmemory-cli
39
+ pip install -e ".[dev]"
40
+ ```
41
+
42
+ Both `pm` and `projectmemory` are registered as executable names.
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # Authenticate (opens browser)
48
+ pm login
49
+
50
+ # Create a project and start its first session
51
+ pm project create
52
+
53
+ # Or resume an existing project/session
54
+ pm resume "My App"
55
+
56
+ # See your current context
57
+ pm memory show
58
+
59
+ # Work with tasks
60
+ pm task list
61
+ pm task add "Implement login"
62
+ pm task done 42 # completes task, then optionally links a commit
63
+ pm done # interactive task completion shortcut
64
+
65
+ # Capture notes
66
+ pm capture "Don't forget to update the Stripe webhook URL"
67
+
68
+ # Report an issue
69
+ pm issue add "Login fails on mobile Safari"
70
+
71
+ # End your session with the shared backend review
72
+ pm session end
73
+ ```
74
+
75
+ ## Commands
76
+
77
+ ### Authentication
78
+ | Command | Description |
79
+ |---|---|
80
+ | `pm login` | Authenticate via browser |
81
+ | `pm logout` | Revoke token |
82
+ | `pm whoami` | Show current user |
83
+ | `pm auth status` | Check auth status |
84
+ | `pm doctor` | Diagnose CLI config, auth, backend, project, session, and Git state |
85
+
86
+ ### Projects
87
+ | Command | Description |
88
+ |---|---|
89
+ | `pm project list` | List all projects |
90
+ | `pm project create` | Create a project (interactive) |
91
+ | `pm project current` | Show current project + session |
92
+ | `pm project link` | Link this directory to a project |
93
+ | `pm project unlink` | Remove directory mapping |
94
+ | `pm web` | Open project in browser |
95
+
96
+ ### Work Sessions
97
+ | Command | Description |
98
+ |---|---|
99
+ | `pm resume [project]` | Start or resume a session |
100
+ | `pm session status` | Show all active sessions |
101
+ | `pm session end` | End the current session |
102
+
103
+ ### Project Memory
104
+ | Command | Description |
105
+ |---|---|
106
+ | `pm memory show` | Display all memory fields |
107
+ | `pm memory status set [text]` | Update Current Status |
108
+ | `pm memory context set [text]` | Update Important Context |
109
+ | `pm memory update` | Interactive memory update, including open issues |
110
+
111
+ ### Next Action
112
+ | Command | Description |
113
+ |---|---|
114
+ | `pm next` | Show current Next Action |
115
+ | `pm next set [text]` | Set Next Action (interactive menu) |
116
+ | `pm next task <id>` | Link a task as Next Action |
117
+ | `pm next issue <id>` | Link an issue as Next Action |
118
+ | `pm next clear` | Clear Next Action |
119
+
120
+ ### Tasks
121
+ | Command | Description |
122
+ |---|---|
123
+ | `pm task list` | List tasks; supports `--open`, `--completed`, `--snoozed`, `--all` |
124
+ | `pm task add [title]` | Create a task |
125
+ | `pm task done <id>` | Complete task + optional commit link |
126
+ | `pm done` | Interactive task completion shortcut |
127
+ | `pm task snooze <id>` | Snooze task |
128
+ | `pm task reopen <id>` | Reopen task |
129
+ | `pm task edit <id>` | Edit task |
130
+ | `pm task delete <id>` | Delete task |
131
+
132
+ ### Notes
133
+ | Command | Description |
134
+ |---|---|
135
+ | `pm note list` | List notes |
136
+ | `pm note add [content]` | Create a note; supports `--title`, `--editor`, `--stdin` |
137
+ | `pm note search <query>` | Search notes |
138
+ | `pm capture [text]` | Quick note shortcut |
139
+ | `pm note edit <id>` | Edit a note |
140
+ | `pm note delete <id>` | Delete a note |
141
+
142
+ ### Issues
143
+ | Command | Description |
144
+ |---|---|
145
+ | `pm issue list` | List issues; supports `--open`, `--resolved`, `--all` |
146
+ | `pm issue add [description]` | Report an issue |
147
+ | `pm issue resolve <id>` | Mark issue as resolved |
148
+ | `pm issue reopen <id>` | Reopen issue |
149
+ | `pm issue edit <id>` | Edit issue |
150
+ | `pm issue delete <id>` | Delete issue |
151
+
152
+ ### Commits
153
+ | Command | Description |
154
+ |---|---|
155
+ | `pm commit link` | Interactive recovery flow for linking a commit to a completed task |
156
+ | `pm commit link HEAD --task 24` | Directly link a commit/ref to a completed task |
157
+
158
+ ## Configuration
159
+
160
+ Configuration is stored at `~/.config/projectmemory/config.toml`.
161
+
162
+ The default `api_url` is the Project Memory site root, for example `https://projectmemory.app`. The CLI adds API paths such as `/api/cli/...` internally.
163
+
164
+ ```bash
165
+ pm config show # show all settings
166
+ pm config set api_url https://your-instance.example.com
167
+ pm config reset # restore defaults
168
+ ```
169
+
170
+ ### Environment variables
171
+
172
+ | Variable | Description |
173
+ |---|---|
174
+ | `PROJECTMEMORY_API_URL` | API base URL |
175
+ | `PROJECTMEMORY_TOKEN` | Token override (skip keyring) |
176
+ | `PROJECTMEMORY_PROJECT` | Current project ID override |
177
+ | `PROJECTMEMORY_NO_COLOR` | Disable color output |
178
+
179
+
180
+ ## Diagnostics
181
+
182
+ Run a read-only diagnostic report when troubleshooting local configuration or support issues:
183
+
184
+ ```bash
185
+ pm doctor
186
+ pm doctor --json
187
+ pm doctor --verbose
188
+ pm doctor --no-network
189
+ ```
190
+
191
+ `pm doctor` never prints authentication tokens, refresh tokens, passwords, private keys, or OAuth secrets. It exits with `0` when healthy, `1` when warnings are present, and `2` when critical issues prevent normal CLI use.
192
+
193
+ ## Directory Linking
194
+
195
+ Link the current git repository to a project so `pm` knows which project to use automatically:
196
+
197
+ ```bash
198
+ cd ~/code/my-app
199
+ pm project link "My App"
200
+
201
+ # Now pm commands in this directory use "My App" automatically
202
+ pm task list # no --project needed
203
+ ```
204
+
205
+ ## How Sessions Work
206
+
207
+ The CLI shares the session model with the web app. Sessions must be active for write operations (creating tasks, notes, issues, updating memory).
208
+
209
+ - `pm resume` starts or resumes the backend session for the selected project
210
+ - `pm session end` shows the shared backend Session Review and finalizes that exact session
211
+ - If the web app ends your session, the CLI will show a clear error and ask you to `pm resume` again
212
+ - A user can have active sessions in multiple projects, but the CLI has one current project
213
+ - Switching projects can end the current project session, keep it active, or cancel
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ # Install with dev dependencies
219
+ pip install -e ".[dev]"
220
+
221
+ # Run tests
222
+ pytest
223
+
224
+ # Run with coverage
225
+ pytest --cov=projectmemory_cli --cov-report=term-missing
226
+
227
+ # Lint
228
+ ruff check projectmemory_cli/
229
+
230
+ # Type check
231
+ mypy projectmemory_cli/
232
+ ```
233
+
234
+ ## Security
235
+
236
+ - Tokens are stored in the OS keyring (or `~/.config/projectmemory/credentials.json` with `chmod 600` as fallback)
237
+ - Passwords are never stored
238
+ - The `PROJECTMEMORY_TOKEN` environment variable overrides stored credentials (read-only)
239
+ - Tokens can be revoked remotely via `pm logout` or from the web app at projectmemory.app/settings
@@ -0,0 +1,32 @@
1
+ projectmemory_cli/__init__.py,sha256=_mSPeKukKlAdhOhlyV7KDX3nbP8dTqDsNKZyypYB17o,57
2
+ projectmemory_cli/app.py,sha256=SJEYEj0lh6RozWj80C2AnjsyceQojEDelIKEl5gpKag,2742
3
+ projectmemory_cli/config.py,sha256=G764kH7NtINJsGwivL6qsGO6h98N53XJeIqlSMf5bVw,2591
4
+ projectmemory_cli/credentials.py,sha256=tZwBgJRa8IkN9oYs8hWwyMfVs9qpeX4XTA-VLtRY6as,2400
5
+ projectmemory_cli/git.py,sha256=eu3i6K1T_n1PCrVUnsr2ZLUVFy_fjQXPfgKhUxDvoD4,3693
6
+ projectmemory_cli/main.py,sha256=gQZnlXah8alZblfSkMmd-ZmeTG-EGQJ2zO1vWJi0ZsU,174
7
+ projectmemory_cli/output.py,sha256=5U5QKQgObXt7tkFJR9CZsnbETw8eiWiq8J3H2Lcm-ho,6083
8
+ projectmemory_cli/project_context.py,sha256=i5sv3E-5EyN3Ig-xR3k0aM8L6vFRt0FACxZNLoqrzvE,7151
9
+ projectmemory_cli/api/__init__.py,sha256=d7XMEse6wwtLORqrQmnhaKMI8KM3lJSzRVE7HVskq1w,19
10
+ projectmemory_cli/api/auth.py,sha256=VAkHwcBezEL7Q8ftw_M3XhtKLM67e2forJK4s_24PgM,2996
11
+ projectmemory_cli/api/client.py,sha256=ZXnYj1nBNt_6vQIKr5ewUoBOyHNBTAHgEQ0ZAn1O-KU,4328
12
+ projectmemory_cli/api/errors.py,sha256=38bgoIFaJGjg5AoJpBim-lKUUGI1EHHEePIfhWf9HMQ,4249
13
+ projectmemory_cli/api/sessions.py,sha256=_61u2Ln3m74ZMjEowZ14ChV1jb3H5PV5QAVcBL_4mJ4,894
14
+ projectmemory_cli/commands/__init__.py,sha256=zltFPaCZgkeTdOH1YWrUEqqBF9Dg6tokgAFcmqP4_n4,24
15
+ projectmemory_cli/commands/auth.py,sha256=xhKPerPKqpagFGV0ohCYxa-J-_WsDnD_6rvWTaJdXSA,4949
16
+ projectmemory_cli/commands/commits.py,sha256=t-MdXCdz3D4ppB8bg7HRzDziVYGyIvquqS3nEgh72kg,5596
17
+ projectmemory_cli/commands/config.py,sha256=7qybOO4Gu-8V6z3beX4WgT_QMdvxVZnmnU7DyDECTSk,1693
18
+ projectmemory_cli/commands/doctor.py,sha256=dcBWnpiA0-F-LW_sVNL5_WLnhAoTGzuD8imH_lpoeO8,15454
19
+ projectmemory_cli/commands/issues.py,sha256=nTZqFGtXnSO6RkBGdB0YqrSj80KIcpmKT0C1KrcVIkw,8143
20
+ projectmemory_cli/commands/memory.py,sha256=oR_IGCsbT0S-Jmd4cOeIulGDmNBG2FQ-HZppEr7R5r8,8089
21
+ projectmemory_cli/commands/next_action.py,sha256=DpoIg74NCCq009I6o6NaE3WZ7PVEKsNe2cHR5kpzIdk,10286
22
+ projectmemory_cli/commands/notes.py,sha256=RfNSiqX-Qqej6wGz22Pm50r8E9rkOxK231Tj-qtyGq0,7769
23
+ projectmemory_cli/commands/projects.py,sha256=X1-DHFB34IR8kz-oK8EonFXj-gatgEtkQH6tl9EaIqo,11073
24
+ projectmemory_cli/commands/resume.py,sha256=623w3_dphwBPxl9Fp2tXCIYFCJ4v5M2tGxBjqs4_g18,8819
25
+ projectmemory_cli/commands/sessions.py,sha256=_RmAKhiTX1PQlVrBi651gjjcvA5aYYrVeq63So29hEM,10446
26
+ projectmemory_cli/commands/tasks.py,sha256=MfN9Vqij7myr8Hd9I-32eV9oslg8qItLfT1McOB1qwg,15732
27
+ projectmemory_cli/models/__init__.py,sha256=Olg44Lfn1NJZ590xdEv7rZrS47PxSUnCXo9pOnSJm1s,41
28
+ projectmemory_cli/models/api.py,sha256=q8-lAaH7mimedjYRAPgvWMwQ6EaOVwt1U_K9TImtuM4,5800
29
+ projectmemory_cli-0.1.0.dist-info/METADATA,sha256=wSoHLLMEtzrWEs28KTQaN4jb3is43ScHDhuQWsofnJU,7156
30
+ projectmemory_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
31
+ projectmemory_cli-0.1.0.dist-info/entry_points.txt,sha256=PFSO5C1l54v5RTc5LnHZUB3JKLhsCSXIMQwEPGW70Hg,93
32
+ projectmemory_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pm = projectmemory_cli.main:app
3
+ projectmemory = projectmemory_cli.main:app