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
projectmemory_cli/git.py
ADDED
|
@@ -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 @@
|
|
|
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"
|