agent-manager-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.
- agent_manager_cli-0.1.0.dist-info/METADATA +183 -0
- agent_manager_cli-0.1.0.dist-info/RECORD +14 -0
- agent_manager_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_manager_cli-0.1.0.dist-info/entry_points.txt +2 -0
- am/__init__.py +3 -0
- am/__main__.py +5 -0
- am/cli.py +817 -0
- am/scanners/__init__.py +21 -0
- am/scanners/_util.py +17 -0
- am/scanners/claude.py +105 -0
- am/scanners/codex.py +88 -0
- am/scanners/gemini.py +99 -0
- am/schemas/__init__.py +0 -0
- am/schemas/session.py +12 -0
am/scanners/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from am.scanners.claude import scan_claude_sessions
|
|
6
|
+
from am.scanners.codex import scan_codex_sessions
|
|
7
|
+
from am.scanners.gemini import scan_gemini_sessions
|
|
8
|
+
from am.schemas.session import LocalSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def scan_all_sessions() -> list[LocalSession]:
|
|
12
|
+
"""Scan all supported CLI tools and return merged session list."""
|
|
13
|
+
sessions: list[LocalSession] = []
|
|
14
|
+
sessions.extend(scan_claude_sessions())
|
|
15
|
+
sessions.extend(scan_codex_sessions())
|
|
16
|
+
sessions.extend(scan_gemini_sessions())
|
|
17
|
+
sessions.sort(
|
|
18
|
+
key=lambda s: s.updated_at or datetime.min.replace(tzinfo=UTC),
|
|
19
|
+
reverse=True,
|
|
20
|
+
)
|
|
21
|
+
return sessions
|
am/scanners/_util.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Max bytes to read from end of a file to avoid OOM
|
|
4
|
+
TAIL_BYTES = 64 * 1024 # 64 KB
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read_tail(path, max_bytes: int = TAIL_BYTES) -> str:
|
|
8
|
+
"""Read the tail of a file, skipping partial first line."""
|
|
9
|
+
try:
|
|
10
|
+
file_size = path.stat().st_size
|
|
11
|
+
with open(path, "rb") as f:
|
|
12
|
+
if file_size > max_bytes:
|
|
13
|
+
f.seek(file_size - max_bytes)
|
|
14
|
+
f.readline() # skip partial line
|
|
15
|
+
return f.read().decode("utf-8", errors="replace")
|
|
16
|
+
except Exception:
|
|
17
|
+
return ""
|
am/scanners/claude.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Scanner for Claude Code CLI sessions (~/.claude/projects/)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from am.scanners._util import read_tail
|
|
11
|
+
from am.schemas.session import LocalSession
|
|
12
|
+
|
|
13
|
+
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
14
|
+
|
|
15
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _project_name(dirname: str) -> str:
|
|
19
|
+
parts = dirname.strip("-").split("-")
|
|
20
|
+
return parts[-1] if parts else dirname
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_cwd(project_dir: Path) -> str | None:
|
|
24
|
+
"""Extract real project path from `cwd` field in JSONL metadata."""
|
|
25
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
26
|
+
try:
|
|
27
|
+
with open(jsonl_file, encoding="utf-8") as f:
|
|
28
|
+
for line in f:
|
|
29
|
+
line = line.strip()
|
|
30
|
+
if not line:
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
entry = json.loads(line)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
continue
|
|
36
|
+
cwd = entry.get("cwd")
|
|
37
|
+
if cwd and isinstance(cwd, str):
|
|
38
|
+
return cwd
|
|
39
|
+
except Exception:
|
|
40
|
+
continue
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _last_user_message(jsonl_path: Path) -> str | None:
|
|
45
|
+
tail = read_tail(jsonl_path)
|
|
46
|
+
if not tail:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
for line in reversed(tail.strip().splitlines()):
|
|
50
|
+
try:
|
|
51
|
+
entry = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
if entry.get("type") != "user":
|
|
55
|
+
continue
|
|
56
|
+
msg = entry.get("message", {})
|
|
57
|
+
if msg.get("role") != "user":
|
|
58
|
+
continue
|
|
59
|
+
content = msg.get("content")
|
|
60
|
+
if isinstance(content, str):
|
|
61
|
+
text = content
|
|
62
|
+
elif isinstance(content, list):
|
|
63
|
+
text = " ".join(
|
|
64
|
+
b.get("text", "")
|
|
65
|
+
for b in content
|
|
66
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
continue
|
|
70
|
+
text = text.strip()
|
|
71
|
+
if text and not text.startswith("[Request interrupted"):
|
|
72
|
+
return text[:200]
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scan_claude_sessions() -> list[LocalSession]:
|
|
77
|
+
if not CLAUDE_DIR.is_dir():
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
sessions: list[LocalSession] = []
|
|
81
|
+
|
|
82
|
+
for project_dir in CLAUDE_DIR.iterdir():
|
|
83
|
+
if not project_dir.is_dir():
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
name = _project_name(project_dir.name)
|
|
87
|
+
cwd = _resolve_cwd(project_dir)
|
|
88
|
+
|
|
89
|
+
for f in project_dir.glob("*.jsonl"):
|
|
90
|
+
sid = f.stem
|
|
91
|
+
if not _UUID_RE.match(sid):
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
sessions.append(
|
|
95
|
+
LocalSession(
|
|
96
|
+
session_id=sid,
|
|
97
|
+
project=name,
|
|
98
|
+
project_path=cwd or "",
|
|
99
|
+
cli="claude",
|
|
100
|
+
last_message=_last_user_message(f),
|
|
101
|
+
updated_at=datetime.fromtimestamp(f.stat().st_mtime, tz=UTC),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return sessions
|
am/scanners/codex.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Scanner for OpenAI Codex CLI sessions (~/.codex/sessions/)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from am.scanners._util import read_tail
|
|
11
|
+
from am.schemas.session import LocalSession
|
|
12
|
+
|
|
13
|
+
CODEX_DIR = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
14
|
+
SESSIONS_DIR = CODEX_DIR / "sessions"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_codex_session(jsonl_path: Path) -> LocalSession | None:
|
|
18
|
+
"""Parse a Codex rollout-*.jsonl file."""
|
|
19
|
+
tail = read_tail(jsonl_path)
|
|
20
|
+
if not tail:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Extract session_id from filename: rollout-<timestamp>-<id>.jsonl
|
|
24
|
+
sid = jsonl_path.stem # e.g. "rollout-1234567890-abcdef"
|
|
25
|
+
|
|
26
|
+
project_path = ""
|
|
27
|
+
last_message = None
|
|
28
|
+
|
|
29
|
+
for line in reversed(tail.strip().splitlines()):
|
|
30
|
+
try:
|
|
31
|
+
entry = json.loads(line)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Try to find project path from turn_context or session_meta
|
|
36
|
+
if not project_path:
|
|
37
|
+
if entry.get("type") == "session_meta":
|
|
38
|
+
project_path = entry.get("cwd", "")
|
|
39
|
+
elif entry.get("cwd"):
|
|
40
|
+
project_path = entry["cwd"]
|
|
41
|
+
|
|
42
|
+
# Find last user message
|
|
43
|
+
if not last_message:
|
|
44
|
+
payload = entry.get("payload", {})
|
|
45
|
+
if payload.get("type") == "user_message":
|
|
46
|
+
text = payload.get("message", "").strip()
|
|
47
|
+
if text:
|
|
48
|
+
last_message = text[:200]
|
|
49
|
+
|
|
50
|
+
# Also check event_msg wrapper
|
|
51
|
+
if entry.get("type") == "event_msg":
|
|
52
|
+
inner = entry.get("payload", {})
|
|
53
|
+
if inner.get("type") == "user_message":
|
|
54
|
+
text = inner.get("message", "").strip()
|
|
55
|
+
if text:
|
|
56
|
+
last_message = text[:200]
|
|
57
|
+
|
|
58
|
+
if project_path and last_message:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
project_name = Path(project_path).name if project_path else sid
|
|
62
|
+
|
|
63
|
+
return LocalSession(
|
|
64
|
+
session_id=sid,
|
|
65
|
+
project=project_name,
|
|
66
|
+
project_path=project_path,
|
|
67
|
+
cli="codex",
|
|
68
|
+
last_message=last_message,
|
|
69
|
+
updated_at=datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=UTC),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scan_codex_sessions() -> list[LocalSession]:
|
|
74
|
+
if not SESSIONS_DIR.is_dir():
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
sessions: list[LocalSession] = []
|
|
78
|
+
|
|
79
|
+
# Sessions are in YYYY/MM/DD/ subdirectories
|
|
80
|
+
for jsonl_file in SESSIONS_DIR.rglob("rollout-*.jsonl"):
|
|
81
|
+
try:
|
|
82
|
+
session = _parse_codex_session(jsonl_file)
|
|
83
|
+
if session:
|
|
84
|
+
sessions.append(session)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
return sessions
|
am/scanners/gemini.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Scanner for Google Gemini CLI sessions (~/.gemini/tmp/)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from am.schemas.session import LocalSession
|
|
10
|
+
|
|
11
|
+
GEMINI_DIR = Path.home() / ".gemini" / "tmp"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_gemini_project(project_dir: Path) -> list[LocalSession]:
|
|
15
|
+
"""Parse sessions from a Gemini project hash directory."""
|
|
16
|
+
chats_dir = project_dir / "chats"
|
|
17
|
+
if not chats_dir.is_dir():
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
sessions: list[LocalSession] = []
|
|
21
|
+
|
|
22
|
+
# Try to find project path from logs.json
|
|
23
|
+
project_path = ""
|
|
24
|
+
logs_file = chats_dir / "logs.json"
|
|
25
|
+
if logs_file.is_file():
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(logs_file.read_text(encoding="utf-8"))
|
|
28
|
+
# logs.json may contain cwd or project root info
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
project_path = data.get("cwd", "")
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
project_name = Path(project_path).name if project_path else project_dir.name[:12]
|
|
35
|
+
|
|
36
|
+
# Scan JSON files in chats/ directory
|
|
37
|
+
for chat_file in chats_dir.glob("*.json"):
|
|
38
|
+
if chat_file.name == "logs.json":
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
stat = chat_file.stat()
|
|
42
|
+
last_message = None
|
|
43
|
+
|
|
44
|
+
# Try to extract last user message from chat JSON
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(chat_file.read_text(encoding="utf-8"))
|
|
47
|
+
messages = []
|
|
48
|
+
if isinstance(data, list):
|
|
49
|
+
messages = data
|
|
50
|
+
elif isinstance(data, dict):
|
|
51
|
+
messages = data.get("messages", [])
|
|
52
|
+
if not project_path:
|
|
53
|
+
project_path = data.get("cwd", "")
|
|
54
|
+
|
|
55
|
+
for msg in reversed(messages):
|
|
56
|
+
if msg.get("role") == "user":
|
|
57
|
+
content = msg.get("content", "")
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
content = " ".join(
|
|
60
|
+
p.get("text", "") for p in content if isinstance(p, dict)
|
|
61
|
+
)
|
|
62
|
+
text = str(content).strip()
|
|
63
|
+
if text:
|
|
64
|
+
last_message = text[:200]
|
|
65
|
+
break
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
sessions.append(
|
|
70
|
+
LocalSession(
|
|
71
|
+
session_id=chat_file.stem,
|
|
72
|
+
project=project_name,
|
|
73
|
+
project_path=project_path,
|
|
74
|
+
cli="gemini",
|
|
75
|
+
last_message=last_message,
|
|
76
|
+
updated_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
except Exception:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
return sessions
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scan_gemini_sessions() -> list[LocalSession]:
|
|
86
|
+
if not GEMINI_DIR.is_dir():
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
sessions: list[LocalSession] = []
|
|
90
|
+
|
|
91
|
+
for project_dir in GEMINI_DIR.iterdir():
|
|
92
|
+
if not project_dir.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
sessions.extend(_parse_gemini_project(project_dir))
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
return sessions
|
am/schemas/__init__.py
ADDED
|
File without changes
|
am/schemas/session.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocalSession(BaseModel):
|
|
7
|
+
session_id: str
|
|
8
|
+
project: str
|
|
9
|
+
project_path: str
|
|
10
|
+
cli: str # "claude", "codex", "gemini"
|
|
11
|
+
last_message: str | None = None
|
|
12
|
+
updated_at: datetime | None = None
|