cdx-manager 0.2.1
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.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/bin/cdx +20 -0
- package/changelogs/CHANGELOGS_0_1_1.md +98 -0
- package/changelogs/CHANGELOGS_0_2_0.md +68 -0
- package/changelogs/CHANGELOGS_0_2_1.md +29 -0
- package/package.json +44 -0
- package/src/__init__.py +17 -0
- package/src/claude_refresh.py +72 -0
- package/src/claude_usage.py +84 -0
- package/src/cli.py +188 -0
- package/src/cli_commands.py +369 -0
- package/src/cli_render.py +131 -0
- package/src/config.py +8 -0
- package/src/errors.py +4 -0
- package/src/health.py +125 -0
- package/src/notify.py +138 -0
- package/src/provider_runtime.py +290 -0
- package/src/repair.py +121 -0
- package/src/session_service.py +563 -0
- package/src/session_store.py +244 -0
- package/src/status_source.py +572 -0
- package/src/status_view.py +270 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import CdxError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_dir(path):
|
|
11
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_json(file_path, fallback):
|
|
15
|
+
try:
|
|
16
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
17
|
+
return json.load(f)
|
|
18
|
+
except FileNotFoundError:
|
|
19
|
+
return fallback
|
|
20
|
+
except json.JSONDecodeError as error:
|
|
21
|
+
raise CdxError(f"Corrupt JSON file: {file_path}") from error
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_json(file_path, value):
|
|
25
|
+
directory = os.path.dirname(file_path)
|
|
26
|
+
_ensure_dir(directory)
|
|
27
|
+
fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(file_path)}.", suffix=".tmp", dir=directory)
|
|
28
|
+
try:
|
|
29
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
30
|
+
json.dump(value, f, indent=2)
|
|
31
|
+
f.write("\n")
|
|
32
|
+
f.flush()
|
|
33
|
+
os.fsync(f.fileno())
|
|
34
|
+
os.replace(temp_path, file_path)
|
|
35
|
+
_fsync_directory(directory)
|
|
36
|
+
except Exception:
|
|
37
|
+
try:
|
|
38
|
+
os.unlink(temp_path)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
pass
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fsync_directory(directory):
|
|
45
|
+
try:
|
|
46
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
47
|
+
except OSError:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
os.fsync(fd)
|
|
51
|
+
finally:
|
|
52
|
+
os.close(fd)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def _file_lock(lock_path):
|
|
57
|
+
_ensure_dir(os.path.dirname(lock_path))
|
|
58
|
+
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
59
|
+
try:
|
|
60
|
+
import fcntl
|
|
61
|
+
except ImportError as error:
|
|
62
|
+
raise CdxError("Session store locking requires fcntl on this platform") from error
|
|
63
|
+
try:
|
|
64
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
65
|
+
except OSError as error:
|
|
66
|
+
raise CdxError(f"Failed to lock session store: {error}") from error
|
|
67
|
+
try:
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
try:
|
|
71
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
72
|
+
except OSError as error:
|
|
73
|
+
raise CdxError(f"Failed to unlock session store: {error}") from error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_session_store(base_dir):
|
|
77
|
+
store_file = os.path.join(base_dir, "sessions.json")
|
|
78
|
+
lock_file = os.path.join(base_dir, ".sessions.lock")
|
|
79
|
+
state_dir = os.path.join(base_dir, "state")
|
|
80
|
+
|
|
81
|
+
def _state_file_path(name):
|
|
82
|
+
return os.path.join(state_dir, f"{_encode(name)}.json")
|
|
83
|
+
|
|
84
|
+
def _encode(name):
|
|
85
|
+
from urllib.parse import quote
|
|
86
|
+
return quote(name, safe="")
|
|
87
|
+
|
|
88
|
+
def _load():
|
|
89
|
+
data = _read_json(store_file, {"version": 1, "sessions": []})
|
|
90
|
+
return data.get("sessions", [])
|
|
91
|
+
|
|
92
|
+
def _save(sessions):
|
|
93
|
+
_write_json(store_file, {"version": 1, "sessions": sessions})
|
|
94
|
+
|
|
95
|
+
def _read_session_state_unlocked(name):
|
|
96
|
+
return _read_json(_state_file_path(name), None)
|
|
97
|
+
|
|
98
|
+
def _write_session_state_unlocked(name, state):
|
|
99
|
+
_write_json(_state_file_path(name), state)
|
|
100
|
+
|
|
101
|
+
def _remove_session_state_unlocked(name):
|
|
102
|
+
try:
|
|
103
|
+
os.remove(_state_file_path(name))
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def _default_state(session):
|
|
108
|
+
return {
|
|
109
|
+
"provider": session["provider"],
|
|
110
|
+
"status": "ready",
|
|
111
|
+
"rehydratedAt": None,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def list_sessions():
|
|
115
|
+
with _file_lock(lock_file):
|
|
116
|
+
return sorted(_load(), key=lambda session: session.get("name", ""))
|
|
117
|
+
|
|
118
|
+
def get_session(name):
|
|
119
|
+
with _file_lock(lock_file):
|
|
120
|
+
for s in _load():
|
|
121
|
+
if s.get("name") == name:
|
|
122
|
+
return s
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def add_session(session):
|
|
126
|
+
with _file_lock(lock_file):
|
|
127
|
+
sessions = _load()
|
|
128
|
+
if any(s.get("name") == session["name"] for s in sessions):
|
|
129
|
+
return {"ok": False, "session": None}
|
|
130
|
+
_write_session_state_unlocked(session["name"], _default_state(session))
|
|
131
|
+
sessions.append(session)
|
|
132
|
+
try:
|
|
133
|
+
_save(sessions)
|
|
134
|
+
except Exception:
|
|
135
|
+
_remove_session_state_unlocked(session["name"])
|
|
136
|
+
raise
|
|
137
|
+
return {"ok": True, "session": session}
|
|
138
|
+
|
|
139
|
+
def update_session(name, updater):
|
|
140
|
+
with _file_lock(lock_file):
|
|
141
|
+
sessions = _load()
|
|
142
|
+
for i, s in enumerate(sessions):
|
|
143
|
+
if s.get("name") == name:
|
|
144
|
+
sessions[i] = updater(s)
|
|
145
|
+
_save(sessions)
|
|
146
|
+
return sessions[i]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def remove_session(name):
|
|
150
|
+
with _file_lock(lock_file):
|
|
151
|
+
sessions = _load()
|
|
152
|
+
for i, s in enumerate(sessions):
|
|
153
|
+
if s.get("name") == name:
|
|
154
|
+
removed = sessions.pop(i)
|
|
155
|
+
old_state = _read_session_state_unlocked(name)
|
|
156
|
+
_remove_session_state_unlocked(name)
|
|
157
|
+
try:
|
|
158
|
+
_save(sessions)
|
|
159
|
+
except Exception:
|
|
160
|
+
if old_state is not None:
|
|
161
|
+
_write_session_state_unlocked(name, old_state)
|
|
162
|
+
raise
|
|
163
|
+
return removed
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def rename_session(source_name, dest_name, updater):
|
|
167
|
+
with _file_lock(lock_file):
|
|
168
|
+
sessions = _load()
|
|
169
|
+
source_index = None
|
|
170
|
+
for i, s in enumerate(sessions):
|
|
171
|
+
if s.get("name") == source_name:
|
|
172
|
+
source_index = i
|
|
173
|
+
elif s.get("name") == dest_name:
|
|
174
|
+
return {"ok": False, "session": None, "reason": "exists"}
|
|
175
|
+
if source_index is None:
|
|
176
|
+
return {"ok": False, "session": None, "reason": "missing"}
|
|
177
|
+
|
|
178
|
+
updated = updater(sessions[source_index])
|
|
179
|
+
source_state_path = _state_file_path(source_name)
|
|
180
|
+
dest_state_path = _state_file_path(dest_name)
|
|
181
|
+
moved_state = False
|
|
182
|
+
try:
|
|
183
|
+
os.replace(source_state_path, dest_state_path)
|
|
184
|
+
moved_state = True
|
|
185
|
+
except FileNotFoundError:
|
|
186
|
+
pass
|
|
187
|
+
sessions[source_index] = updated
|
|
188
|
+
try:
|
|
189
|
+
_save(sessions)
|
|
190
|
+
except Exception:
|
|
191
|
+
if moved_state:
|
|
192
|
+
os.replace(dest_state_path, source_state_path)
|
|
193
|
+
raise
|
|
194
|
+
return {"ok": True, "session": updated, "reason": None}
|
|
195
|
+
|
|
196
|
+
def replace_session(name, session):
|
|
197
|
+
with _file_lock(lock_file):
|
|
198
|
+
sessions = _load()
|
|
199
|
+
old_state = _read_session_state_unlocked(name)
|
|
200
|
+
old_session = None
|
|
201
|
+
replaced = False
|
|
202
|
+
for i, existing in enumerate(sessions):
|
|
203
|
+
if existing.get("name") == name:
|
|
204
|
+
old_session = existing
|
|
205
|
+
sessions[i] = session
|
|
206
|
+
replaced = True
|
|
207
|
+
break
|
|
208
|
+
if not replaced:
|
|
209
|
+
sessions.append(session)
|
|
210
|
+
_write_session_state_unlocked(session["name"], _default_state(session))
|
|
211
|
+
try:
|
|
212
|
+
_save(sessions)
|
|
213
|
+
except Exception:
|
|
214
|
+
if old_state is None:
|
|
215
|
+
_remove_session_state_unlocked(session["name"])
|
|
216
|
+
else:
|
|
217
|
+
_write_session_state_unlocked(session["name"], old_state)
|
|
218
|
+
if old_session is not None:
|
|
219
|
+
for i, existing in enumerate(sessions):
|
|
220
|
+
if existing.get("name") == name:
|
|
221
|
+
sessions[i] = old_session
|
|
222
|
+
break
|
|
223
|
+
raise
|
|
224
|
+
return {"ok": True, "session": session, "replaced": replaced}
|
|
225
|
+
|
|
226
|
+
def read_session_state(name):
|
|
227
|
+
with _file_lock(lock_file):
|
|
228
|
+
return _read_session_state_unlocked(name)
|
|
229
|
+
|
|
230
|
+
def write_session_state(name, state):
|
|
231
|
+
with _file_lock(lock_file):
|
|
232
|
+
_write_session_state_unlocked(name, state)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"list_sessions": list_sessions,
|
|
236
|
+
"get_session": get_session,
|
|
237
|
+
"add_session": add_session,
|
|
238
|
+
"update_session": update_session,
|
|
239
|
+
"remove_session": remove_session,
|
|
240
|
+
"rename_session": rename_session,
|
|
241
|
+
"replace_session": replace_session,
|
|
242
|
+
"read_session_state": read_session_state,
|
|
243
|
+
"write_session_state": write_session_state,
|
|
244
|
+
}
|