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,124 @@
1
+ """Local Git repository helpers.
2
+
3
+ Reads local git state using subprocess — no GitHub API calls.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import NamedTuple
10
+
11
+
12
+ class GitCommit(NamedTuple):
13
+ sha: str
14
+ short_sha: str
15
+ message: str
16
+ author: str
17
+ date: str
18
+
19
+
20
+ def _run(*args: str, cwd: Path | None = None) -> str:
21
+ """Run a git command and return stdout, or '' on error."""
22
+ try:
23
+ result = subprocess.run(
24
+ ["git", *args],
25
+ capture_output=True,
26
+ text=True,
27
+ cwd=cwd,
28
+ timeout=5,
29
+ )
30
+ return result.stdout.strip() if result.returncode == 0 else ""
31
+ except Exception:
32
+ return ""
33
+
34
+
35
+ def is_git_repo(path: Path | None = None) -> bool:
36
+ return bool(_run("rev-parse", "--git-dir", cwd=path))
37
+
38
+
39
+ def get_repo_root(path: Path | None = None) -> Path | None:
40
+ root = _run("rev-parse", "--show-toplevel", cwd=path)
41
+ return Path(root) if root else None
42
+
43
+
44
+ def get_remote_url(path: Path | None = None, remote: str = "origin") -> str:
45
+ """Return the remote URL for the given remote name."""
46
+ return _run("remote", "get-url", remote, cwd=path)
47
+
48
+
49
+ def normalize_github_repo(url: str) -> str:
50
+ """Extract 'owner/repo' from a GitHub remote URL."""
51
+ url = url.strip()
52
+ if not url:
53
+ return ""
54
+ # Strip .git suffix
55
+ if url.endswith(".git"):
56
+ url = url[:-4]
57
+ # SSH: git@github.com:owner/repo
58
+ if "github.com:" in url:
59
+ return url.split("github.com:", 1)[1].lower().strip("/")
60
+ # HTTPS: https://github.com/owner/repo
61
+ if "github.com" in url:
62
+ parts = url.split("github.com/", 1)
63
+ if len(parts) > 1:
64
+ return "/".join(parts[1].strip("/").split("/")[:2]).lower()
65
+ return ""
66
+
67
+
68
+ def get_github_repo_name(path: Path | None = None) -> str:
69
+ """Return 'owner/repo' if the local remote is a GitHub repo, else ''."""
70
+ url = get_remote_url(path)
71
+ return normalize_github_repo(url)
72
+
73
+
74
+ def get_recent_commits(path: Path | None = None, limit: int = 10) -> list[GitCommit]:
75
+ """Return the most recent local commits."""
76
+ fmt = "%H|||%h|||%s|||%an|||%ci"
77
+ output = _run("log", f"--pretty=format:{fmt}", f"-{limit}", cwd=path)
78
+ commits = []
79
+ for line in output.splitlines():
80
+ parts = line.split("|||", 4)
81
+ if len(parts) == 5:
82
+ commits.append(GitCommit(
83
+ sha=parts[0],
84
+ short_sha=parts[1],
85
+ message=parts[2],
86
+ author=parts[3],
87
+ date=parts[4],
88
+ ))
89
+ return commits
90
+
91
+
92
+ def get_head_sha(path: Path | None = None) -> str:
93
+ return _run("rev-parse", "HEAD", cwd=path)
94
+
95
+
96
+ def resolve_sha(ref: str, path: Path | None = None) -> str:
97
+ """Resolve a ref (HEAD, sha, branch) to a full SHA."""
98
+ return _run("rev-parse", ref, cwd=path)
99
+
100
+
101
+ def get_commit_info(sha: str, path: Path | None = None) -> GitCommit | None:
102
+ """Get info for a specific commit SHA."""
103
+ fmt = "%H|||%h|||%s|||%an|||%ci"
104
+ output = _run("show", "-s", f"--pretty=format:{fmt}", sha, cwd=path)
105
+ if not output:
106
+ return None
107
+ parts = output.splitlines()[0].split("|||", 4)
108
+ if len(parts) == 5:
109
+ return GitCommit(
110
+ sha=parts[0],
111
+ short_sha=parts[1],
112
+ message=parts[2],
113
+ author=parts[3],
114
+ date=parts[4],
115
+ )
116
+ return None
117
+
118
+
119
+ def get_current_branch(path: Path | None = None) -> str:
120
+ return _run("branch", "--show-current", cwd=path) or _run("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
121
+
122
+
123
+ def get_short_head_sha(path: Path | None = None) -> str:
124
+ return _run("rev-parse", "--short", "HEAD", cwd=path)
@@ -0,0 +1,4 @@
1
+ """CLI entry point — registers all command groups on the Typer app."""
2
+ from projectmemory_cli.app import app # noqa: F401 — re-export for entry point
3
+
4
+ __all__ = ["app"]
@@ -0,0 +1 @@
1
+ """Pydantic models for API responses."""
@@ -0,0 +1,226 @@
1
+ """Pydantic models for all API response shapes."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Any, Optional
6
+
7
+ from pydantic import BaseModel, field_validator
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Auth
12
+ # ---------------------------------------------------------------------------
13
+
14
+ class UserInfo(BaseModel):
15
+ id: int
16
+ email: str
17
+ name: str = ""
18
+ plan: str = "free"
19
+ email_verified: bool = True
20
+ email_verification_required: bool = False
21
+ limits: dict[str, Any] = {}
22
+ usage: dict[str, Any] = {}
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Sessions
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class ActiveSession(BaseModel):
30
+ id: int
31
+ project: int
32
+ project_name: str = ""
33
+ started_at: str
34
+ updated_at: str
35
+ duration_seconds: int = 0
36
+ source: str = "cli"
37
+
38
+
39
+ class SessionInfo(BaseModel):
40
+ id: int
41
+ project: int
42
+ project_name: str = ""
43
+ started_at: str
44
+ ended_at: Optional[str] = None
45
+ finalized_at: Optional[str] = None
46
+ is_active: bool
47
+ session_date: str
48
+ progress: str = ""
49
+ note: str = ""
50
+ duration_minutes: Optional[int] = None
51
+ mood: str = ""
52
+ source: str = "web"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Memory
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class NextActionLinkedSource(BaseModel):
60
+ type: str # "task" | "issue"
61
+ id: int
62
+ title: str
63
+ status: str
64
+
65
+
66
+ class NextAction(BaseModel):
67
+ title: str = ""
68
+ description: str = ""
69
+ source_type: str = "manual"
70
+ linked_source: Optional[NextActionLinkedSource] = None
71
+
72
+
73
+ class MemoryField(BaseModel):
74
+ title: str = ""
75
+ description: str = ""
76
+
77
+
78
+ class Memory(BaseModel):
79
+ next_action: NextAction = NextAction()
80
+ current_status: MemoryField = MemoryField()
81
+ important_context: MemoryField = MemoryField()
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # GitHub
86
+ # ---------------------------------------------------------------------------
87
+
88
+ class GitHubRepo(BaseModel):
89
+ id: int
90
+ owner: str
91
+ name: str
92
+ full_name: str
93
+ private: bool = False
94
+ default_branch: str = "main"
95
+
96
+
97
+ class GitHubInfo(BaseModel):
98
+ connected: bool = False
99
+ repository: Optional[GitHubRepo] = None
100
+ last_synced_at: Optional[str] = None
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Projects
105
+ # ---------------------------------------------------------------------------
106
+
107
+ class ProjectCounts(BaseModel):
108
+ open_tasks: int = 0
109
+ completed_tasks: int = 0
110
+ snoozed_tasks: int = 0
111
+ open_issues: int = 0
112
+ resolved_issues: int = 0
113
+ notes: int = 0
114
+ sessions: int = 0
115
+
116
+
117
+ class ProjectSummary(BaseModel):
118
+ id: int
119
+ name: str
120
+ description: str = ""
121
+ status: str = "active"
122
+ is_starred: bool = False
123
+ created_at: str
124
+ updated_at: str
125
+ last_updated_at: Optional[str] = None
126
+ last_memory_updated_at: Optional[str] = None
127
+ web_url: str = ""
128
+ active_session: Optional[ActiveSession] = None
129
+ has_active_session: bool = False
130
+ next_action: NextAction = NextAction()
131
+ github: GitHubInfo = GitHubInfo()
132
+ counts: ProjectCounts = ProjectCounts()
133
+
134
+ @field_validator("counts", mode="before")
135
+ @classmethod
136
+ def coerce_counts(cls, v: Any) -> Any:
137
+ if isinstance(v, dict):
138
+ return v
139
+ return {}
140
+
141
+
142
+ class ProjectContext(BaseModel):
143
+ project: ProjectSummary
144
+ memory: Memory
145
+ open_tasks: list[dict[str, Any]] = []
146
+ open_issues: list[dict[str, Any]] = []
147
+ latest_session: Optional[dict[str, Any]] = None
148
+ active_session: Optional[ActiveSession] = None
149
+ github: GitHubInfo = GitHubInfo()
150
+ counts: ProjectCounts = ProjectCounts()
151
+
152
+ @field_validator("counts", mode="before")
153
+ @classmethod
154
+ def coerce_counts(cls, v: Any) -> Any:
155
+ if isinstance(v, dict):
156
+ return v
157
+ return {}
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Tasks
162
+ # ---------------------------------------------------------------------------
163
+
164
+ class Task(BaseModel):
165
+ id: int
166
+ project: int
167
+ project_name: str = ""
168
+ title: str
169
+ description: str = ""
170
+ priority: str = "medium"
171
+ due_date: Optional[str] = None
172
+ is_completed: bool = False
173
+ is_snoozed: bool = False
174
+ snoozed_until: Optional[str] = None
175
+ snoozed_is_someday: bool = False
176
+ is_next_action: bool = False
177
+ created_at: str
178
+ updated_at: str
179
+ completed_at: Optional[str] = None
180
+ has_linked_commits: bool = False
181
+ linked_commit_count: int = 0
182
+ github_issue: Optional[dict[str, Any]] = None
183
+ github_commits: list[dict[str, Any]] = []
184
+
185
+
186
+ class TaskCounts(BaseModel):
187
+ open: int = 0
188
+ completed: int = 0
189
+ snoozed: int = 0
190
+ total: int = 0
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Notes
195
+ # ---------------------------------------------------------------------------
196
+
197
+ class Note(BaseModel):
198
+ id: int
199
+ project: int
200
+ project_name: str = ""
201
+ title: str = ""
202
+ content: str
203
+ created_at: str
204
+ updated_at: str
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Issues
209
+ # ---------------------------------------------------------------------------
210
+
211
+ class Issue(BaseModel):
212
+ id: int
213
+ description: str
214
+ is_resolved: bool = False
215
+ resolution_notes: str = ""
216
+ is_next_action: bool = False
217
+ created_at: str
218
+ updated_at: str
219
+ resolved_at: Optional[str] = None
220
+ github_issue: Optional[dict[str, Any]] = None
221
+
222
+
223
+ class IssueCounts(BaseModel):
224
+ open: int = 0
225
+ resolved: int = 0
226
+ total: int = 0
@@ -0,0 +1,227 @@
1
+ """Rich-based terminal output helpers.
2
+
3
+ Supports --json, --plain, --quiet, --no-color, --debug modes.
4
+ Automatically disables decorative formatting when output is piped.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+ from rich import box
17
+
18
+ # Module-level console — colour disabled if not a tty or NO_COLOR is set
19
+ _is_tty = sys.stdout.isatty()
20
+
21
+ console = Console(
22
+ highlight=False,
23
+ markup=True,
24
+ emoji=True,
25
+ )
26
+ err_console = Console(stderr=True, highlight=False)
27
+
28
+ # Runtime flags (set by command callbacks)
29
+ _json_mode: bool = False
30
+ _plain_mode: bool = False
31
+ _quiet_mode: bool = False
32
+ _debug_mode: bool = False
33
+
34
+
35
+ def configure(*, json_mode: bool | None = None, plain_mode: bool | None = None,
36
+ quiet_mode: bool | None = None, debug_mode: bool | None = None,
37
+ no_color: bool | None = None) -> None:
38
+ global _json_mode, _plain_mode, _quiet_mode, _debug_mode
39
+ if json_mode is not None:
40
+ _json_mode = json_mode
41
+ if plain_mode is not None:
42
+ _plain_mode = plain_mode
43
+ if quiet_mode is not None:
44
+ _quiet_mode = quiet_mode
45
+ if debug_mode is not None:
46
+ _debug_mode = debug_mode
47
+ if no_color is True or not _is_tty:
48
+ console._markup = False # type: ignore[attr-defined]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Print helpers
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def print_json(data: Any) -> None:
56
+ console.print_json(json.dumps(data, default=str))
57
+
58
+
59
+ def info(msg: str) -> None:
60
+ if not _quiet_mode:
61
+ if _plain_mode:
62
+ print(msg)
63
+ else:
64
+ console.print(msg)
65
+
66
+
67
+ def success(msg: str) -> None:
68
+ if _plain_mode:
69
+ print(msg)
70
+ else:
71
+ console.print(f"[bold green]✓[/bold green] {msg}")
72
+
73
+
74
+ def warn(msg: str) -> None:
75
+ if _plain_mode:
76
+ print(f"WARNING: {msg}", file=sys.stderr)
77
+ else:
78
+ err_console.print(f"[bold yellow]⚠[/bold yellow] {msg}")
79
+
80
+
81
+ def error(msg: str) -> None:
82
+ if _plain_mode:
83
+ print(f"Error: {msg}", file=sys.stderr)
84
+ else:
85
+ err_console.print(f"[bold red]✗[/bold red] {msg}")
86
+
87
+
88
+ def hint(msg: str) -> None:
89
+ if not _quiet_mode:
90
+ if _plain_mode:
91
+ print(f" {msg}")
92
+ else:
93
+ console.print(f"[dim] {msg}[/dim]")
94
+
95
+
96
+ def blank() -> None:
97
+ if not _quiet_mode:
98
+ console.print()
99
+
100
+
101
+ def rule(title: str = "") -> None:
102
+ if not _quiet_mode and not _plain_mode:
103
+ console.rule(title, style="dim")
104
+
105
+
106
+ def header(title: str) -> None:
107
+ if _quiet_mode:
108
+ return
109
+ if _plain_mode:
110
+ print(f"\n{title.upper()}")
111
+ print("-" * len(title))
112
+ else:
113
+ console.print(f"\n[bold cyan]{title.upper()}[/bold cyan]")
114
+
115
+
116
+ def panel(title: str, content: str, style: str = "cyan") -> None:
117
+ if _plain_mode:
118
+ print(f"\n{title.upper()}\n{content}")
119
+ else:
120
+ console.print(Panel(content, title=f"[bold]{title}[/bold]", border_style=style, expand=False))
121
+
122
+
123
+ def project_banner(name: str) -> None:
124
+ if _quiet_mode:
125
+ return
126
+ if _plain_mode:
127
+ print(f"\nPROJECT: {name.upper()}\n")
128
+ else:
129
+ console.print(
130
+ Panel(
131
+ f"[bold white]{name.upper()}[/bold white]",
132
+ border_style="bright_cyan",
133
+ expand=False,
134
+ padding=(0, 2),
135
+ )
136
+ )
137
+
138
+
139
+ def kv(label: str, value: str, label_style: str = "dim") -> None:
140
+ if not _quiet_mode:
141
+ if _plain_mode:
142
+ print(f"{label}: {value}")
143
+ else:
144
+ console.print(f"[{label_style}]{label}:[/{label_style}] {value}")
145
+
146
+
147
+ def section(title: str, items: list[str], empty_msg: str = "(none)") -> None:
148
+ if _quiet_mode:
149
+ return
150
+ header(title)
151
+ if not items:
152
+ hint(empty_msg)
153
+ else:
154
+ for item in items:
155
+ if _plain_mode:
156
+ print(f" {item}")
157
+ else:
158
+ console.print(f" {item}")
159
+
160
+
161
+ def table(headers: list[str], rows: list[list[str]], title: str = "") -> None:
162
+ if _plain_mode:
163
+ if title:
164
+ print(f"\n{title}")
165
+ print(" ".join(headers))
166
+ for row in rows:
167
+ print(" ".join(str(c) for c in row))
168
+ return
169
+ t = Table(
170
+ title=title or None,
171
+ box=box.SIMPLE_HEAD,
172
+ show_header=True,
173
+ header_style="bold cyan",
174
+ border_style="dim",
175
+ )
176
+ for h in headers:
177
+ t.add_column(h)
178
+ for row in rows:
179
+ t.add_row(*[str(c) for c in row])
180
+ console.print(t)
181
+
182
+
183
+ def debug(msg: str) -> None:
184
+ if _debug_mode:
185
+ err_console.print(f"[dim magenta][DEBUG][/dim magenta] {msg}")
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Duration formatting
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def format_duration(seconds: int) -> str:
193
+ if seconds < 60:
194
+ return f"{seconds}s"
195
+ minutes = seconds // 60
196
+ if minutes < 60:
197
+ return f"{minutes}m"
198
+ hours = minutes // 60
199
+ mins = minutes % 60
200
+ if mins:
201
+ return f"{hours}h {mins}m"
202
+ return f"{hours}h"
203
+
204
+
205
+ def format_ago(dt_str: str | None) -> str:
206
+ """Convert ISO datetime string to 'Xh Ym ago' text."""
207
+ if not dt_str:
208
+ return "unknown"
209
+ try:
210
+ from datetime import datetime, timezone
211
+ dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
212
+ diff = datetime.now(timezone.utc) - dt
213
+ secs = int(diff.total_seconds())
214
+ if secs < 60:
215
+ return "just now"
216
+ if secs < 3600:
217
+ return f"{secs // 60}m ago"
218
+ if secs < 86400:
219
+ h = secs // 3600
220
+ m = (secs % 3600) // 60
221
+ return f"{h}h {m}m ago" if m else f"{h}h ago"
222
+ days = secs // 86400
223
+ if days == 1:
224
+ return "yesterday"
225
+ return f"{days} days ago"
226
+ except Exception:
227
+ return dt_str or "unknown"