codex-safe-switch 1.0.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.
- codex_safe_switch/__init__.py +1 -0
- codex_safe_switch/_swap.py +69 -0
- codex_safe_switch/cli.py +1770 -0
- codex_safe_switch/picker.py +145 -0
- codex_safe_switch-1.0.0.dist-info/METADATA +201 -0
- codex_safe_switch-1.0.0.dist-info/RECORD +9 -0
- codex_safe_switch-1.0.0.dist-info/WHEEL +4 -0
- codex_safe_switch-1.0.0.dist-info/entry_points.txt +2 -0
- codex_safe_switch-1.0.0.dist-info/licenses/LICENSE +21 -0
codex_safe_switch/cli.py
ADDED
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
"""codex-safe-switch — manage Codex provider profiles.
|
|
2
|
+
|
|
3
|
+
Storage layout:
|
|
4
|
+
|
|
5
|
+
~/.codex/profiles/
|
|
6
|
+
├── .active # name of the currently-loaded profile
|
|
7
|
+
├── .official/ # reserved snapshot for official OpenAI login
|
|
8
|
+
└── <name>/
|
|
9
|
+
├── auth.json # optional; copied only when the profile owns auth
|
|
10
|
+
├── provider.toml # merged into ~/.codex/config.toml
|
|
11
|
+
└── session.toml # optional session-state scope metadata
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import shutil
|
|
23
|
+
import stat
|
|
24
|
+
import sqlite3
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
import tomlkit
|
|
33
|
+
|
|
34
|
+
from . import _swap
|
|
35
|
+
from .picker import pick
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SESSION_STATE_FILES = (
|
|
39
|
+
"history.jsonl",
|
|
40
|
+
"session_index.jsonl",
|
|
41
|
+
"state_5.sqlite",
|
|
42
|
+
"state_5.sqlite-shm",
|
|
43
|
+
"state_5.sqlite-wal",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
OFFICIAL_PROFILE_NAME = "official"
|
|
47
|
+
OFFICIAL_PROFILE_DIRNAME = ".official"
|
|
48
|
+
OFFICIAL_ALIASES = {OFFICIAL_PROFILE_NAME, "openai"}
|
|
49
|
+
ALFRED_INIT_ARG = "__init__"
|
|
50
|
+
REMOTE_CONTROL_INSTALL_COMMAND = "curl -fsSL https://chatgpt.com/codex/install.sh | sh"
|
|
51
|
+
REMOTE_CONTROL_REPAIR_ENV = "CODEX_SWITCH_REMOTE_REPAIR"
|
|
52
|
+
CLI_SURFACE_CHECK_ENV = "CODEX_SWITCH_CLI_SURFACE_CHECK"
|
|
53
|
+
DESKTOP_CODEX_PATH_ENV = "CODEX_DESKTOP_CODEX_PATH"
|
|
54
|
+
REMOTE_PROXY_RESPAWN_GRACE_SECONDS = 1.5
|
|
55
|
+
GLOBAL_STATE_FILES = (".codex-global-state.json", ".codex-global-state.json.bak")
|
|
56
|
+
REMOTE_HOST_PREFIX = "remote-ssh-"
|
|
57
|
+
ELECTRON_REMOTE_STATE_KEYS = (
|
|
58
|
+
"electron-local-remote-control-environment-id",
|
|
59
|
+
"electron-local-remote-control-installation-id",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SessionConfig:
|
|
65
|
+
mode: str = "shared"
|
|
66
|
+
scope: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
def describe(self) -> str:
|
|
69
|
+
if self.mode == "scoped":
|
|
70
|
+
return f"scoped ({self.scope})"
|
|
71
|
+
return "shared"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class IdentityConfig:
|
|
76
|
+
provider: Optional[str]
|
|
77
|
+
model: Optional[str]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class MergeHistoryResult:
|
|
82
|
+
changed_files: int
|
|
83
|
+
changed_lines: int
|
|
84
|
+
state_rows: int
|
|
85
|
+
backup_dir: Optional[Path]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class CodexCliSurface:
|
|
90
|
+
label: str
|
|
91
|
+
version: str
|
|
92
|
+
path: Optional[Path]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def profile_root() -> Path:
|
|
96
|
+
return Path(os.environ.get("CODEX_PROFILE_ROOT") or Path.home() / ".codex" / "profiles")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def codex_dir() -> Path:
|
|
100
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def active_file() -> Path:
|
|
104
|
+
return profile_root() / ".active"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def official_profile_dir() -> Path:
|
|
108
|
+
return profile_root() / OFFICIAL_PROFILE_DIRNAME
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def session_state_root() -> Path:
|
|
112
|
+
return profile_root() / ".session-state"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def active_name() -> Optional[str]:
|
|
116
|
+
p = active_file()
|
|
117
|
+
if not p.exists():
|
|
118
|
+
return None
|
|
119
|
+
name = p.read_text().strip()
|
|
120
|
+
return name or None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_profile_name(name: Optional[str]) -> Optional[str]:
|
|
124
|
+
if name is None:
|
|
125
|
+
return None
|
|
126
|
+
stripped = name.strip()
|
|
127
|
+
if stripped in OFFICIAL_ALIASES or stripped == OFFICIAL_PROFILE_DIRNAME:
|
|
128
|
+
return OFFICIAL_PROFILE_NAME
|
|
129
|
+
return stripped or None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def profile_dir_for_name(name: str) -> Path:
|
|
133
|
+
normalized = normalize_profile_name(name)
|
|
134
|
+
if normalized == OFFICIAL_PROFILE_NAME:
|
|
135
|
+
return official_profile_dir()
|
|
136
|
+
return profile_root() / normalized
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def current_identity() -> IdentityConfig:
|
|
140
|
+
cfg = _swap.load(codex_dir() / "config.toml")
|
|
141
|
+
provider = cfg.get("model_provider")
|
|
142
|
+
model = cfg.get("model")
|
|
143
|
+
provider = str(provider).strip() if provider is not None else None
|
|
144
|
+
model = str(model).strip() if model is not None else None
|
|
145
|
+
return IdentityConfig(provider=provider or None, model=model or None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def profile_requires_auth_file(profile_dir: Path, provider_doc=None) -> bool:
|
|
149
|
+
"""Return true when switching this profile should replace ~/.codex/auth.json."""
|
|
150
|
+
if normalize_profile_name(profile_dir.name) == OFFICIAL_PROFILE_NAME:
|
|
151
|
+
return True
|
|
152
|
+
doc = provider_doc if provider_doc is not None else _swap.load(profile_dir / "provider.toml")
|
|
153
|
+
preferred_auth_method = str(doc.get("preferred_auth_method") or "").strip()
|
|
154
|
+
if preferred_auth_method == "chatgpt":
|
|
155
|
+
return True
|
|
156
|
+
provider = str(doc.get("model_provider") or "").strip()
|
|
157
|
+
providers = doc.get("model_providers")
|
|
158
|
+
if provider and providers is not None and provider in providers:
|
|
159
|
+
provider_cfg = providers[provider]
|
|
160
|
+
requires = provider_cfg.get("requires_openai_auth")
|
|
161
|
+
if requires is not None:
|
|
162
|
+
return bool(requires)
|
|
163
|
+
if preferred_auth_method == "apikey":
|
|
164
|
+
return True
|
|
165
|
+
return not provider
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_profiles() -> list[str]:
|
|
169
|
+
root = profile_root()
|
|
170
|
+
if not root.exists():
|
|
171
|
+
return [OFFICIAL_PROFILE_NAME] if official_profile_dir().is_dir() else []
|
|
172
|
+
names = sorted(
|
|
173
|
+
d.name
|
|
174
|
+
for d in root.iterdir()
|
|
175
|
+
if d.is_dir() and not d.name.startswith(".") and d.name != "bin"
|
|
176
|
+
)
|
|
177
|
+
if official_profile_dir().is_dir():
|
|
178
|
+
return [OFFICIAL_PROFILE_NAME, *names]
|
|
179
|
+
return names
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _safe_profile_name(raw: Optional[str]) -> str:
|
|
183
|
+
name = (raw or "current").strip().lower()
|
|
184
|
+
chars = [ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in name]
|
|
185
|
+
safe = "".join(chars).strip(".-_") or "current"
|
|
186
|
+
if normalize_profile_name(safe) == OFFICIAL_PROFILE_NAME:
|
|
187
|
+
return "openai-api"
|
|
188
|
+
return safe
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def bootstrap_current_profile(*, verbose: bool = True) -> Optional[str]:
|
|
192
|
+
"""Import the current Codex config on first run, if no profiles exist yet."""
|
|
193
|
+
if list_profiles():
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
codex = codex_dir()
|
|
197
|
+
auth = codex / "auth.json"
|
|
198
|
+
cfg = codex / "config.toml"
|
|
199
|
+
if not auth.is_file():
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
if current_looks_official(codex):
|
|
203
|
+
snapshot_official_state(codex)
|
|
204
|
+
name = OFFICIAL_PROFILE_NAME
|
|
205
|
+
else:
|
|
206
|
+
identity = current_identity()
|
|
207
|
+
name = _safe_profile_name(identity.provider)
|
|
208
|
+
dir_ = profile_root() / name
|
|
209
|
+
dir_.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
if cfg.is_file():
|
|
211
|
+
_swap.extract(cfg, dir_ / "provider.toml")
|
|
212
|
+
else:
|
|
213
|
+
(dir_ / "provider.toml").write_text("")
|
|
214
|
+
if profile_requires_auth_file(dir_):
|
|
215
|
+
shutil.copy2(auth, dir_ / "auth.json")
|
|
216
|
+
write_session_config(dir_, SessionConfig())
|
|
217
|
+
|
|
218
|
+
active_file().parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
active_file().write_text(name + "\n")
|
|
220
|
+
if verbose:
|
|
221
|
+
print(f"initialized profile from existing Codex config → {name}")
|
|
222
|
+
return name
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def no_profiles_message() -> str:
|
|
226
|
+
return (
|
|
227
|
+
"no profiles yet — configure Codex once, then run `codex-safe-switch` to import it, "
|
|
228
|
+
"or create one with `codex-safe-switch save <name>`"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _die(msg: str) -> "NoReturn": # type: ignore[name-defined]
|
|
233
|
+
print(f"codex-safe-switch: {msg}", file=sys.stderr)
|
|
234
|
+
raise SystemExit(1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _ps_output() -> str:
|
|
238
|
+
result = subprocess.run(
|
|
239
|
+
["ps", "-axo", "pid=,args="],
|
|
240
|
+
check=False,
|
|
241
|
+
stdout=subprocess.PIPE,
|
|
242
|
+
stderr=subprocess.DEVNULL,
|
|
243
|
+
text=True,
|
|
244
|
+
)
|
|
245
|
+
return result.stdout
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _pid_exists(pid: int) -> bool:
|
|
249
|
+
try:
|
|
250
|
+
os.kill(pid, 0)
|
|
251
|
+
except ProcessLookupError:
|
|
252
|
+
return False
|
|
253
|
+
except PermissionError:
|
|
254
|
+
return True
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _looks_like_codex_process(pid: int, args: str) -> bool:
|
|
259
|
+
if pid == os.getpid():
|
|
260
|
+
return False
|
|
261
|
+
lower = args.lower()
|
|
262
|
+
if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
|
|
263
|
+
return False
|
|
264
|
+
if _looks_like_remote_proxy_process(pid, args):
|
|
265
|
+
return True
|
|
266
|
+
executable = Path(args.split(maxsplit=1)[0]).name.lower() if args.strip() else ""
|
|
267
|
+
if executable in {"codex", "codex-app-server", "codex-server"}:
|
|
268
|
+
return True
|
|
269
|
+
return (
|
|
270
|
+
"/codex.app/contents/" in lower
|
|
271
|
+
or " codex app-server" in lower
|
|
272
|
+
or " codex-app-server" in lower
|
|
273
|
+
or " codex server" in lower
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def find_codex_processes() -> list[int]:
|
|
278
|
+
pids: list[int] = []
|
|
279
|
+
for line in _ps_output().splitlines():
|
|
280
|
+
parts = line.strip().split(maxsplit=1)
|
|
281
|
+
if len(parts) != 2:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
pid = int(parts[0])
|
|
285
|
+
except ValueError:
|
|
286
|
+
continue
|
|
287
|
+
if _looks_like_codex_process(pid, parts[1]):
|
|
288
|
+
pids.append(pid)
|
|
289
|
+
return pids
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def restart_codex_processes() -> int:
|
|
293
|
+
pids = find_codex_processes()
|
|
294
|
+
for pid in pids:
|
|
295
|
+
try:
|
|
296
|
+
os.kill(pid, signal.SIGTERM)
|
|
297
|
+
except ProcessLookupError:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
deadline = time.monotonic() + 1.5
|
|
301
|
+
while time.monotonic() < deadline and any(_pid_exists(pid) for pid in pids):
|
|
302
|
+
time.sleep(0.05)
|
|
303
|
+
|
|
304
|
+
for pid in pids:
|
|
305
|
+
if not _pid_exists(pid):
|
|
306
|
+
continue
|
|
307
|
+
try:
|
|
308
|
+
os.kill(pid, signal.SIGKILL)
|
|
309
|
+
except ProcessLookupError:
|
|
310
|
+
pass
|
|
311
|
+
return len(pids)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _looks_like_unmanaged_app_server(pid: int, args: str) -> bool:
|
|
315
|
+
if pid == os.getpid():
|
|
316
|
+
return False
|
|
317
|
+
lower = args.lower()
|
|
318
|
+
if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
|
|
319
|
+
return False
|
|
320
|
+
if "--listen unix://" not in lower:
|
|
321
|
+
return False
|
|
322
|
+
parts = args.split(maxsplit=1)
|
|
323
|
+
executable = Path(parts[0]).name.lower() if parts else ""
|
|
324
|
+
rest = parts[1].lower() if len(parts) == 2 else ""
|
|
325
|
+
return executable == "codex" and rest.startswith("app-server")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def find_unmanaged_app_server_processes() -> list[int]:
|
|
329
|
+
pids: list[int] = []
|
|
330
|
+
for line in _ps_output().splitlines():
|
|
331
|
+
parts = line.strip().split(maxsplit=1)
|
|
332
|
+
if len(parts) != 2:
|
|
333
|
+
continue
|
|
334
|
+
try:
|
|
335
|
+
pid = int(parts[0])
|
|
336
|
+
except ValueError:
|
|
337
|
+
continue
|
|
338
|
+
if _looks_like_unmanaged_app_server(pid, parts[1]):
|
|
339
|
+
pids.append(pid)
|
|
340
|
+
return pids
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _terminate_processes(pids: list[int]) -> set[int]:
|
|
344
|
+
for pid in pids:
|
|
345
|
+
try:
|
|
346
|
+
os.kill(pid, signal.SIGTERM)
|
|
347
|
+
except ProcessLookupError:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
deadline = time.monotonic() + 1.5
|
|
351
|
+
while time.monotonic() < deadline and any(_pid_exists(pid) for pid in pids):
|
|
352
|
+
time.sleep(0.05)
|
|
353
|
+
|
|
354
|
+
for pid in pids:
|
|
355
|
+
if not _pid_exists(pid):
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
os.kill(pid, signal.SIGKILL)
|
|
359
|
+
except ProcessLookupError:
|
|
360
|
+
pass
|
|
361
|
+
return set(pids)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def stop_unmanaged_app_server_processes() -> set[int]:
|
|
365
|
+
return _terminate_processes(find_unmanaged_app_server_processes())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _looks_like_remote_proxy_process(pid: int, args: str) -> bool:
|
|
369
|
+
if pid == os.getpid():
|
|
370
|
+
return False
|
|
371
|
+
lower = args.lower()
|
|
372
|
+
if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
|
|
373
|
+
return False
|
|
374
|
+
if "codex app-server proxy" in lower:
|
|
375
|
+
return True
|
|
376
|
+
parts = args.split(maxsplit=1)
|
|
377
|
+
executable = Path(parts[0]).name.lower() if parts else ""
|
|
378
|
+
rest = parts[1].lower() if len(parts) == 2 else ""
|
|
379
|
+
return executable == "codex" and rest.startswith("app-server proxy")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def find_remote_proxy_processes() -> list[int]:
|
|
383
|
+
pids: list[int] = []
|
|
384
|
+
for line in _ps_output().splitlines():
|
|
385
|
+
parts = line.strip().split(maxsplit=1)
|
|
386
|
+
if len(parts) != 2:
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
pid = int(parts[0])
|
|
390
|
+
except ValueError:
|
|
391
|
+
continue
|
|
392
|
+
if _looks_like_remote_proxy_process(pid, parts[1]):
|
|
393
|
+
pids.append(pid)
|
|
394
|
+
return pids
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def stop_remote_proxy_processes(*, exclude: Optional[set[int]] = None) -> set[int]:
|
|
398
|
+
ignored = exclude or set()
|
|
399
|
+
pids = [pid for pid in find_remote_proxy_processes() if pid not in ignored]
|
|
400
|
+
return _terminate_processes(pids)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _custom_codex_home_is_active(codex: Path) -> bool:
|
|
404
|
+
configured = os.environ.get("CODEX_HOME")
|
|
405
|
+
if not configured:
|
|
406
|
+
return False
|
|
407
|
+
try:
|
|
408
|
+
return Path(configured).expanduser().resolve() != (Path.home() / ".codex").resolve()
|
|
409
|
+
except OSError:
|
|
410
|
+
return Path(configured).expanduser() != Path.home() / ".codex"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _env_bool(name: str) -> Optional[bool]:
|
|
414
|
+
value = os.environ.get(name)
|
|
415
|
+
if value is None:
|
|
416
|
+
return None
|
|
417
|
+
return value.strip().lower() not in {"0", "false", "no", "off"}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def remote_control_repair_allowed(codex: Path) -> bool:
|
|
421
|
+
override = _env_bool(REMOTE_CONTROL_REPAIR_ENV)
|
|
422
|
+
if override is not None:
|
|
423
|
+
return override
|
|
424
|
+
return not _custom_codex_home_is_active(codex) and has_remote_control_enrollment(codex)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def cli_surface_check_allowed(codex: Path) -> bool:
|
|
428
|
+
override = _env_bool(CLI_SURFACE_CHECK_ENV)
|
|
429
|
+
if override is not None:
|
|
430
|
+
return override
|
|
431
|
+
return not _custom_codex_home_is_active(codex) and remote_control_repair_allowed(codex)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def has_remote_control_enrollment(codex: Path) -> bool:
|
|
435
|
+
state_db = codex / "state_5.sqlite"
|
|
436
|
+
if not state_db.exists():
|
|
437
|
+
return False
|
|
438
|
+
try:
|
|
439
|
+
conn = connect_sqlite_readonly(state_db)
|
|
440
|
+
except sqlite3.Error:
|
|
441
|
+
return False
|
|
442
|
+
try:
|
|
443
|
+
exists = conn.execute(
|
|
444
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'remote_control_enrollments'"
|
|
445
|
+
).fetchone()
|
|
446
|
+
if not exists:
|
|
447
|
+
return False
|
|
448
|
+
row = conn.execute("SELECT COUNT(*) FROM remote_control_enrollments").fetchone()
|
|
449
|
+
return bool(row and int(row[0]) > 0)
|
|
450
|
+
except sqlite3.Error:
|
|
451
|
+
return False
|
|
452
|
+
finally:
|
|
453
|
+
conn.close()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _run_codex_command(args: list[str]) -> Optional[subprocess.CompletedProcess[str]]:
|
|
457
|
+
if shutil.which("codex") is None:
|
|
458
|
+
return None
|
|
459
|
+
try:
|
|
460
|
+
return subprocess.run(
|
|
461
|
+
["codex", *args],
|
|
462
|
+
check=False,
|
|
463
|
+
stdout=subprocess.PIPE,
|
|
464
|
+
stderr=subprocess.PIPE,
|
|
465
|
+
text=True,
|
|
466
|
+
)
|
|
467
|
+
except OSError:
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
|
|
472
|
+
return "\n".join(part for part in (result.stdout, result.stderr) if part)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _load_daemon_version(codex: Path) -> dict:
|
|
476
|
+
result = _run_codex_command(["app-server", "daemon", "version"])
|
|
477
|
+
if result is None:
|
|
478
|
+
return {}
|
|
479
|
+
if not result.stdout.strip():
|
|
480
|
+
return {}
|
|
481
|
+
try:
|
|
482
|
+
data = json.loads(result.stdout)
|
|
483
|
+
except json.JSONDecodeError:
|
|
484
|
+
return {}
|
|
485
|
+
return data if isinstance(data, dict) else {}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _managed_standalone_path(codex: Path, daemon_version: dict) -> Path:
|
|
489
|
+
raw = daemon_version.get("managedCodexPath")
|
|
490
|
+
if raw:
|
|
491
|
+
return Path(str(raw)).expanduser()
|
|
492
|
+
return codex / "packages" / "standalone" / "current" / "codex"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _desktop_bundled_codex_path() -> Path:
|
|
496
|
+
raw = os.environ.get(DESKTOP_CODEX_PATH_ENV)
|
|
497
|
+
if raw:
|
|
498
|
+
return Path(raw).expanduser()
|
|
499
|
+
return Path("/Applications/Codex.app/Contents/Resources/codex")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _normalize_codex_version(value: object) -> Optional[str]:
|
|
503
|
+
text = str(value or "").strip()
|
|
504
|
+
if not text:
|
|
505
|
+
return None
|
|
506
|
+
for token in text.replace("\n", " ").split():
|
|
507
|
+
cleaned = token.strip(" \t,;()[]")
|
|
508
|
+
if cleaned and cleaned[0].isdigit():
|
|
509
|
+
return cleaned
|
|
510
|
+
return text
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _run_codex_version(executable: Path) -> Optional[str]:
|
|
514
|
+
try:
|
|
515
|
+
result = subprocess.run(
|
|
516
|
+
[str(executable), "--version"],
|
|
517
|
+
check=False,
|
|
518
|
+
stdout=subprocess.PIPE,
|
|
519
|
+
stderr=subprocess.PIPE,
|
|
520
|
+
text=True,
|
|
521
|
+
)
|
|
522
|
+
except (OSError, subprocess.SubprocessError):
|
|
523
|
+
return None
|
|
524
|
+
output = result.stdout.strip() or result.stderr.strip()
|
|
525
|
+
if result.returncode != 0 and not output:
|
|
526
|
+
return None
|
|
527
|
+
return _normalize_codex_version(output)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def maybe_warn_cli_surface_mismatch(codex: Path, daemon_version: dict) -> None:
|
|
531
|
+
if not cli_surface_check_allowed(codex):
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
surfaces: list[CodexCliSurface] = []
|
|
535
|
+
shell_path = shutil.which("codex")
|
|
536
|
+
shell_version = _normalize_codex_version(daemon_version.get("cliVersion"))
|
|
537
|
+
if shell_path and shell_version:
|
|
538
|
+
surfaces.append(CodexCliSurface("shell codex", shell_version, Path(shell_path)))
|
|
539
|
+
|
|
540
|
+
managed_path = _managed_standalone_path(codex, daemon_version)
|
|
541
|
+
managed_version = _normalize_codex_version(daemon_version.get("managedCodexVersion"))
|
|
542
|
+
if managed_path.is_file() and managed_version:
|
|
543
|
+
surfaces.append(CodexCliSurface("managed standalone", managed_version, managed_path))
|
|
544
|
+
|
|
545
|
+
desktop_path = _desktop_bundled_codex_path()
|
|
546
|
+
if desktop_path.is_file():
|
|
547
|
+
desktop_version = _run_codex_version(desktop_path)
|
|
548
|
+
if desktop_version:
|
|
549
|
+
surfaces.append(CodexCliSurface("Desktop bundled", desktop_version, desktop_path))
|
|
550
|
+
|
|
551
|
+
versions = {surface.version for surface in surfaces}
|
|
552
|
+
if len(versions) <= 1:
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
print("codex cli warning → multiple Codex CLI versions are active")
|
|
556
|
+
for surface in surfaces:
|
|
557
|
+
path = f" ({surface.path})" if surface.path is not None else ""
|
|
558
|
+
print(f" {surface.label}: {surface.version}{path}")
|
|
559
|
+
print("codex cli note → Desktop app owns its bundled CLI; update/restart Codex.app instead of overwriting the app bundle")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _remote_control_needs_standalone_install(text: str) -> bool:
|
|
563
|
+
lower = text.lower()
|
|
564
|
+
return "managed standalone codex install not found" in lower
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _remote_control_is_unmanaged(text: str) -> bool:
|
|
568
|
+
lower = text.lower()
|
|
569
|
+
return (
|
|
570
|
+
"not managed by codex app-server daemon" in lower
|
|
571
|
+
or "unmanaged" in lower and "app-server" in lower
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _remote_control_pending_description(result: subprocess.CompletedProcess[str]) -> Optional[str]:
|
|
576
|
+
if result.returncode != 0:
|
|
577
|
+
return None
|
|
578
|
+
try:
|
|
579
|
+
data = json.loads(result.stdout)
|
|
580
|
+
except json.JSONDecodeError:
|
|
581
|
+
return None
|
|
582
|
+
if not isinstance(data, dict):
|
|
583
|
+
return None
|
|
584
|
+
status = str(data.get("status") or "").strip()
|
|
585
|
+
timed_out = data.get("timedOut") is True
|
|
586
|
+
if status != "connecting" and not timed_out:
|
|
587
|
+
return None
|
|
588
|
+
bits = []
|
|
589
|
+
if status:
|
|
590
|
+
bits.append(f"status={status}")
|
|
591
|
+
if timed_out:
|
|
592
|
+
bits.append("timedOut=true")
|
|
593
|
+
environment = data.get("environmentId")
|
|
594
|
+
if environment:
|
|
595
|
+
bits.append(f"environment={environment}")
|
|
596
|
+
return " ".join(bits) if bits else "status=connecting"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _print_missing_standalone_warning(path: Path) -> None:
|
|
600
|
+
print(f"remote-control warning → managed standalone Codex missing at {path}")
|
|
601
|
+
print(f"remote-control install → {REMOTE_CONTROL_INSTALL_COMMAND}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
605
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
606
|
+
fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
607
|
+
os.close(fd)
|
|
608
|
+
tmp = Path(tmp_str)
|
|
609
|
+
try:
|
|
610
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
|
611
|
+
_copy_mode(path if path.exists() else None, tmp)
|
|
612
|
+
os.replace(tmp, path)
|
|
613
|
+
finally:
|
|
614
|
+
if tmp.exists():
|
|
615
|
+
tmp.unlink(missing_ok=True)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _repair_remote_selection_payload(data: dict) -> bool:
|
|
619
|
+
changed = False
|
|
620
|
+
selected = data.get("selected-remote-host-id")
|
|
621
|
+
if isinstance(selected, str) and selected.startswith(REMOTE_HOST_PREFIX):
|
|
622
|
+
data.pop("selected-remote-host-id", None)
|
|
623
|
+
changed = True
|
|
624
|
+
|
|
625
|
+
for key in ELECTRON_REMOTE_STATE_KEYS:
|
|
626
|
+
if key in data:
|
|
627
|
+
data.pop(key, None)
|
|
628
|
+
changed = True
|
|
629
|
+
|
|
630
|
+
auto_connect = data.get("remote-connection-auto-connect-by-host-id")
|
|
631
|
+
if isinstance(auto_connect, dict):
|
|
632
|
+
for host_id in list(auto_connect):
|
|
633
|
+
if isinstance(host_id, str) and host_id.startswith(REMOTE_HOST_PREFIX):
|
|
634
|
+
auto_connect.pop(host_id, None)
|
|
635
|
+
changed = True
|
|
636
|
+
|
|
637
|
+
analytics = data.get("remote-connection-analytics-id-by-host-id")
|
|
638
|
+
if isinstance(analytics, dict):
|
|
639
|
+
for host_id in list(analytics):
|
|
640
|
+
if isinstance(host_id, str) and host_id.startswith(REMOTE_HOST_PREFIX):
|
|
641
|
+
analytics.pop(host_id, None)
|
|
642
|
+
changed = True
|
|
643
|
+
|
|
644
|
+
for key in ("codex-managed-remote-connections", "remote-projects"):
|
|
645
|
+
value = data.get(key)
|
|
646
|
+
if not isinstance(value, list):
|
|
647
|
+
continue
|
|
648
|
+
filtered = [
|
|
649
|
+
item
|
|
650
|
+
for item in value
|
|
651
|
+
if not (
|
|
652
|
+
isinstance(item, dict)
|
|
653
|
+
and isinstance(item.get("hostId"), str)
|
|
654
|
+
and item["hostId"].startswith(REMOTE_HOST_PREFIX)
|
|
655
|
+
)
|
|
656
|
+
]
|
|
657
|
+
if len(filtered) != len(value):
|
|
658
|
+
data[key] = filtered
|
|
659
|
+
changed = True
|
|
660
|
+
return changed
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def maybe_repair_remote_selection_state(codex: Path) -> None:
|
|
664
|
+
if not remote_control_repair_allowed(codex):
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
changed_files = 0
|
|
668
|
+
for rel in GLOBAL_STATE_FILES:
|
|
669
|
+
path = codex / rel
|
|
670
|
+
if not path.exists():
|
|
671
|
+
continue
|
|
672
|
+
try:
|
|
673
|
+
data = json.loads(path.read_text())
|
|
674
|
+
except json.JSONDecodeError:
|
|
675
|
+
continue
|
|
676
|
+
if not isinstance(data, dict):
|
|
677
|
+
continue
|
|
678
|
+
if _repair_remote_selection_payload(data):
|
|
679
|
+
_atomic_write_json(path, data)
|
|
680
|
+
changed_files += 1
|
|
681
|
+
|
|
682
|
+
if changed_files:
|
|
683
|
+
print(f"remote selection repaired → {changed_files} files")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def maybe_repair_remote_control(codex: Path) -> set[int]:
|
|
687
|
+
if not remote_control_repair_allowed(codex):
|
|
688
|
+
return set()
|
|
689
|
+
|
|
690
|
+
daemon_version = _load_daemon_version(codex)
|
|
691
|
+
maybe_warn_cli_surface_mismatch(codex, daemon_version)
|
|
692
|
+
managed_path = _managed_standalone_path(codex, daemon_version)
|
|
693
|
+
if not managed_path.is_file():
|
|
694
|
+
_print_missing_standalone_warning(managed_path)
|
|
695
|
+
return set()
|
|
696
|
+
|
|
697
|
+
first = _run_codex_command(["remote-control", "start", "--json"])
|
|
698
|
+
if first is None:
|
|
699
|
+
return set()
|
|
700
|
+
pending = _remote_control_pending_description(first)
|
|
701
|
+
if pending:
|
|
702
|
+
print(f"remote-control warning → daemon still connecting ({pending})")
|
|
703
|
+
return set()
|
|
704
|
+
if first.returncode == 0:
|
|
705
|
+
return set()
|
|
706
|
+
|
|
707
|
+
first_output = _combined_output(first)
|
|
708
|
+
if _remote_control_needs_standalone_install(first_output):
|
|
709
|
+
_print_missing_standalone_warning(managed_path)
|
|
710
|
+
return set()
|
|
711
|
+
if not _remote_control_is_unmanaged(first_output):
|
|
712
|
+
return set()
|
|
713
|
+
|
|
714
|
+
stopped = stop_unmanaged_app_server_processes()
|
|
715
|
+
retry = _run_codex_command(["remote-control", "start", "--json"])
|
|
716
|
+
if retry is not None and retry.returncode == 0:
|
|
717
|
+
print(f"remote-control repaired → restarted managed app-server (stopped {len(stopped)})")
|
|
718
|
+
return stopped
|
|
719
|
+
|
|
720
|
+
retry_output = _combined_output(retry) if retry is not None else first_output
|
|
721
|
+
if _remote_control_needs_standalone_install(retry_output):
|
|
722
|
+
_print_missing_standalone_warning(managed_path)
|
|
723
|
+
else:
|
|
724
|
+
print("remote-control warning → could not repair managed app-server automatically")
|
|
725
|
+
return stopped
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def maybe_stop_remote_proxy_processes(codex: Path, *, exclude: Optional[set[int]] = None) -> None:
|
|
729
|
+
if not remote_control_repair_allowed(codex):
|
|
730
|
+
return
|
|
731
|
+
stopped = stop_remote_proxy_processes(exclude=exclude)
|
|
732
|
+
if not stopped:
|
|
733
|
+
return
|
|
734
|
+
print(f"remote-control repaired → stopped stale remote proxy processes (stopped {len(stopped)})")
|
|
735
|
+
print("remote-control hint → restart Codex Desktop if old remote proxy processes reappear")
|
|
736
|
+
time.sleep(REMOTE_PROXY_RESPAWN_GRACE_SECONDS)
|
|
737
|
+
remaining = [pid for pid in find_remote_proxy_processes() if pid not in stopped]
|
|
738
|
+
if remaining:
|
|
739
|
+
joined = ", ".join(str(pid) for pid in remaining)
|
|
740
|
+
print(f"remote-control warning → Desktop respawned remote proxy processes (pids: {joined})")
|
|
741
|
+
print("remote-control hint → restart Codex Desktop to drop stale in-memory remote state")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _atomic_write_copy(src: Path, dst: Path) -> None:
|
|
745
|
+
"""Replace dst with src's contents, preserving dst's mode when present."""
|
|
746
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
fd, tmp_str = tempfile.mkstemp(prefix=dst.name + ".", dir=dst.parent)
|
|
748
|
+
os.close(fd)
|
|
749
|
+
tmp = Path(tmp_str)
|
|
750
|
+
try:
|
|
751
|
+
shutil.copyfile(src, tmp)
|
|
752
|
+
_copy_mode(dst if dst.exists() else None, tmp)
|
|
753
|
+
os.replace(tmp, dst)
|
|
754
|
+
finally:
|
|
755
|
+
if tmp.exists():
|
|
756
|
+
tmp.unlink(missing_ok=True)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _copy_mode(ref: Optional[Path], target: Path) -> None:
|
|
760
|
+
"""Match target's mode to ref; fall back to 0600 for first-run."""
|
|
761
|
+
if ref is not None and ref.exists():
|
|
762
|
+
os.chmod(target, stat.S_IMODE(ref.stat().st_mode))
|
|
763
|
+
else:
|
|
764
|
+
os.chmod(target, 0o600)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def session_config_path(profile_dir: Path) -> Path:
|
|
768
|
+
return profile_dir / "session.toml"
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def load_session_config(profile_dir: Path) -> SessionConfig:
|
|
772
|
+
path = session_config_path(profile_dir)
|
|
773
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
774
|
+
return SessionConfig()
|
|
775
|
+
try:
|
|
776
|
+
doc = tomlkit.parse(path.read_text())
|
|
777
|
+
except Exception as exc: # pragma: no cover - defensive config parsing
|
|
778
|
+
_die(f"invalid {path}: {exc}")
|
|
779
|
+
mode = str(doc.get("mode", "shared")).strip() or "shared"
|
|
780
|
+
if mode not in {"shared", "scoped"}:
|
|
781
|
+
_die(f"invalid {path}: mode must be 'shared' or 'scoped'")
|
|
782
|
+
scope = doc.get("scope")
|
|
783
|
+
scope = str(scope).strip() if scope is not None else None
|
|
784
|
+
if mode == "scoped" and not scope:
|
|
785
|
+
_die(f"invalid {path}: scoped mode requires a non-empty scope")
|
|
786
|
+
return SessionConfig(mode=mode, scope=scope)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def write_session_config(profile_dir: Path, config: SessionConfig) -> None:
|
|
790
|
+
path = session_config_path(profile_dir)
|
|
791
|
+
if config.mode == "shared":
|
|
792
|
+
path.unlink(missing_ok=True)
|
|
793
|
+
return
|
|
794
|
+
doc = tomlkit.document()
|
|
795
|
+
doc["mode"] = config.mode
|
|
796
|
+
doc["scope"] = config.scope
|
|
797
|
+
path.write_text(tomlkit.dumps(doc))
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def session_scope_dir(scope: str) -> Path:
|
|
801
|
+
return session_state_root() / scope
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _copy_optional(src: Path, dst: Path) -> None:
|
|
805
|
+
if src.exists():
|
|
806
|
+
_atomic_write_copy(src, dst)
|
|
807
|
+
else:
|
|
808
|
+
dst.unlink(missing_ok=True)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def save_session_state(config: SessionConfig, codex: Path) -> None:
|
|
812
|
+
if config.mode != "scoped" or not config.scope:
|
|
813
|
+
return
|
|
814
|
+
scope_dir = session_scope_dir(config.scope)
|
|
815
|
+
scope_dir.mkdir(parents=True, exist_ok=True)
|
|
816
|
+
for rel in SESSION_STATE_FILES:
|
|
817
|
+
src = codex / rel
|
|
818
|
+
dst = scope_dir / rel
|
|
819
|
+
_copy_optional(src, dst)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def clear_session_state(codex: Path) -> None:
|
|
823
|
+
for rel in SESSION_STATE_FILES:
|
|
824
|
+
(codex / rel).unlink(missing_ok=True)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def restore_session_state(config: SessionConfig, codex: Path) -> None:
|
|
828
|
+
if config.mode != "scoped" or not config.scope:
|
|
829
|
+
return
|
|
830
|
+
scope_dir = session_scope_dir(config.scope)
|
|
831
|
+
if not scope_dir.exists():
|
|
832
|
+
clear_session_state(codex)
|
|
833
|
+
return
|
|
834
|
+
for rel in SESSION_STATE_FILES:
|
|
835
|
+
src = scope_dir / rel
|
|
836
|
+
dst = codex / rel
|
|
837
|
+
_copy_optional(src, dst)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def maybe_switch_session_state(current_name: Optional[str], next_name: str, codex: Path) -> None:
|
|
841
|
+
current_cfg = SessionConfig()
|
|
842
|
+
next_cfg = load_session_config(profile_dir_for_name(next_name))
|
|
843
|
+
current_name = normalize_profile_name(current_name)
|
|
844
|
+
if current_name:
|
|
845
|
+
current_dir = profile_dir_for_name(current_name)
|
|
846
|
+
if current_dir.is_dir():
|
|
847
|
+
current_cfg = load_session_config(current_dir)
|
|
848
|
+
if current_cfg == next_cfg:
|
|
849
|
+
return
|
|
850
|
+
save_session_state(current_cfg, codex)
|
|
851
|
+
restore_session_state(next_cfg, codex)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def iter_rollout_files(codex: Path) -> list[Path]:
|
|
855
|
+
files: list[Path] = []
|
|
856
|
+
sessions = codex / "sessions"
|
|
857
|
+
archived = codex / "archived_sessions"
|
|
858
|
+
if sessions.exists():
|
|
859
|
+
files.extend(sorted(p for p in sessions.rglob("*.jsonl") if p.is_file()))
|
|
860
|
+
if archived.exists():
|
|
861
|
+
files.extend(sorted(p for p in archived.glob("*.jsonl") if p.is_file()))
|
|
862
|
+
return files
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def backup_path(codex: Path, label: str) -> Path:
|
|
866
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
867
|
+
return codex / f"{label}-{stamp}"
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def backup_root(codex: Path, label: str) -> Path:
|
|
871
|
+
root = backup_path(codex, label)
|
|
872
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
873
|
+
return root
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def backup_copy(src: Path, backup_dir: Path, codex: Path) -> None:
|
|
877
|
+
if not src.exists():
|
|
878
|
+
return
|
|
879
|
+
rel = src.relative_to(codex)
|
|
880
|
+
dst = backup_dir / rel
|
|
881
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
882
|
+
shutil.copy2(src, dst)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def normalize_rollout_file(
|
|
886
|
+
path: Path,
|
|
887
|
+
target: IdentityConfig,
|
|
888
|
+
keep_models: bool,
|
|
889
|
+
*,
|
|
890
|
+
apply: bool = True,
|
|
891
|
+
) -> tuple[int, bool]:
|
|
892
|
+
lines_out: list[str] = []
|
|
893
|
+
changed_lines = 0
|
|
894
|
+
changed = False
|
|
895
|
+
original_stat = path.stat()
|
|
896
|
+
with path.open() as fh:
|
|
897
|
+
for raw in fh:
|
|
898
|
+
try:
|
|
899
|
+
item = json.loads(raw)
|
|
900
|
+
except json.JSONDecodeError:
|
|
901
|
+
lines_out.append(raw)
|
|
902
|
+
continue
|
|
903
|
+
|
|
904
|
+
item_changed = False
|
|
905
|
+
payload = item.get("payload")
|
|
906
|
+
if isinstance(payload, dict):
|
|
907
|
+
if item.get("type") == "session_meta" and target.provider:
|
|
908
|
+
if payload.get("model_provider") != target.provider:
|
|
909
|
+
payload["model_provider"] = target.provider
|
|
910
|
+
item_changed = True
|
|
911
|
+
elif item.get("type") == "turn_context":
|
|
912
|
+
if target.provider and payload.get("model_provider") != target.provider:
|
|
913
|
+
payload["model_provider"] = target.provider
|
|
914
|
+
item_changed = True
|
|
915
|
+
if not keep_models and target.model and payload.get("model") != target.model:
|
|
916
|
+
payload["model"] = target.model
|
|
917
|
+
item_changed = True
|
|
918
|
+
|
|
919
|
+
if item_changed:
|
|
920
|
+
changed = True
|
|
921
|
+
changed_lines += 1
|
|
922
|
+
lines_out.append(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
923
|
+
else:
|
|
924
|
+
lines_out.append(raw)
|
|
925
|
+
|
|
926
|
+
if changed and apply:
|
|
927
|
+
fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
928
|
+
os.close(fd)
|
|
929
|
+
tmp = Path(tmp_str)
|
|
930
|
+
try:
|
|
931
|
+
tmp.write_text("".join(lines_out))
|
|
932
|
+
_copy_mode(path, tmp)
|
|
933
|
+
os.replace(tmp, path)
|
|
934
|
+
finally:
|
|
935
|
+
if tmp.exists():
|
|
936
|
+
tmp.unlink(missing_ok=True)
|
|
937
|
+
os.utime(path, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
|
|
938
|
+
return changed_lines, changed
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def normalize_state_db(path: Path, target: IdentityConfig, keep_models: bool) -> int:
|
|
942
|
+
if not path.exists():
|
|
943
|
+
return 0
|
|
944
|
+
conn = sqlite3.connect(path)
|
|
945
|
+
try:
|
|
946
|
+
cur = conn.cursor()
|
|
947
|
+
if keep_models:
|
|
948
|
+
if not target.provider:
|
|
949
|
+
return 0
|
|
950
|
+
count = cur.execute(
|
|
951
|
+
"SELECT COUNT(*) FROM threads WHERE model_provider != ?",
|
|
952
|
+
(target.provider,),
|
|
953
|
+
).fetchone()[0]
|
|
954
|
+
cur.execute("UPDATE threads SET model_provider = ? WHERE model_provider != ?", (target.provider, target.provider))
|
|
955
|
+
else:
|
|
956
|
+
if not target.provider or not target.model:
|
|
957
|
+
return 0
|
|
958
|
+
count = cur.execute(
|
|
959
|
+
"SELECT COUNT(*) FROM threads WHERE model_provider != ? OR ifnull(model, '') != ?",
|
|
960
|
+
(target.provider, target.model),
|
|
961
|
+
).fetchone()[0]
|
|
962
|
+
cur.execute(
|
|
963
|
+
"UPDATE threads SET model_provider = ?, model = ? WHERE model_provider != ? OR ifnull(model, '') != ?",
|
|
964
|
+
(target.provider, target.model, target.provider, target.model),
|
|
965
|
+
)
|
|
966
|
+
conn.commit()
|
|
967
|
+
return int(count)
|
|
968
|
+
finally:
|
|
969
|
+
conn.close()
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def connect_sqlite_readonly(path: Path) -> sqlite3.Connection:
|
|
973
|
+
return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def count_state_rows_to_normalize(path: Path, target: IdentityConfig, keep_models: bool) -> int:
|
|
977
|
+
if not path.exists():
|
|
978
|
+
return 0
|
|
979
|
+
conn = connect_sqlite_readonly(path)
|
|
980
|
+
try:
|
|
981
|
+
cur = conn.cursor()
|
|
982
|
+
if keep_models:
|
|
983
|
+
if not target.provider:
|
|
984
|
+
return 0
|
|
985
|
+
return int(
|
|
986
|
+
cur.execute(
|
|
987
|
+
"SELECT COUNT(*) FROM threads WHERE model_provider != ?",
|
|
988
|
+
(target.provider,),
|
|
989
|
+
).fetchone()[0]
|
|
990
|
+
)
|
|
991
|
+
if not target.provider or not target.model:
|
|
992
|
+
return 0
|
|
993
|
+
return int(
|
|
994
|
+
cur.execute(
|
|
995
|
+
"SELECT COUNT(*) FROM threads WHERE model_provider != ? OR ifnull(model, '') != ?",
|
|
996
|
+
(target.provider, target.model),
|
|
997
|
+
).fetchone()[0]
|
|
998
|
+
)
|
|
999
|
+
finally:
|
|
1000
|
+
conn.close()
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def sqlite_threads_columns(conn: sqlite3.Connection) -> list[str]:
|
|
1004
|
+
found = conn.execute(
|
|
1005
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads'"
|
|
1006
|
+
).fetchone()
|
|
1007
|
+
if not found:
|
|
1008
|
+
return []
|
|
1009
|
+
return [str(row[1]) for row in conn.execute("PRAGMA table_info(threads)").fetchall()]
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _fmt_value(value) -> str:
|
|
1013
|
+
if value is None or value == "":
|
|
1014
|
+
return "(empty)"
|
|
1015
|
+
return str(value)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def read_thread_distribution(path: Path) -> tuple[list[tuple[str, str, int]], Optional[str]]:
|
|
1019
|
+
if not path.exists():
|
|
1020
|
+
return [], "missing state_5.sqlite"
|
|
1021
|
+
conn = connect_sqlite_readonly(path)
|
|
1022
|
+
try:
|
|
1023
|
+
columns = sqlite_threads_columns(conn)
|
|
1024
|
+
if not columns:
|
|
1025
|
+
return [], "missing threads table"
|
|
1026
|
+
if "model_provider" not in columns or "model" not in columns:
|
|
1027
|
+
return [], "threads table missing model_provider/model columns"
|
|
1028
|
+
rows = conn.execute(
|
|
1029
|
+
"""
|
|
1030
|
+
SELECT ifnull(model_provider, ''), ifnull(model, ''), COUNT(*)
|
|
1031
|
+
FROM threads
|
|
1032
|
+
GROUP BY ifnull(model_provider, ''), ifnull(model, '')
|
|
1033
|
+
ORDER BY COUNT(*) DESC, ifnull(model_provider, ''), ifnull(model, '')
|
|
1034
|
+
"""
|
|
1035
|
+
).fetchall()
|
|
1036
|
+
return [(str(provider), str(model), int(count)) for provider, model, count in rows], None
|
|
1037
|
+
except sqlite3.Error as exc:
|
|
1038
|
+
return [], str(exc)
|
|
1039
|
+
finally:
|
|
1040
|
+
conn.close()
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def read_recent_threads(path: Path, limit: int = 5) -> tuple[list[dict[str, object]], Optional[str]]:
|
|
1044
|
+
if not path.exists():
|
|
1045
|
+
return [], "missing state_5.sqlite"
|
|
1046
|
+
conn = connect_sqlite_readonly(path)
|
|
1047
|
+
try:
|
|
1048
|
+
columns = sqlite_threads_columns(conn)
|
|
1049
|
+
if not columns:
|
|
1050
|
+
return [], "missing threads table"
|
|
1051
|
+
selected = [c for c in ("id", "thread_id", "title", "model_provider", "model", "updated_at", "created_at") if c in columns]
|
|
1052
|
+
if not selected:
|
|
1053
|
+
return [], "threads table has no displayable columns"
|
|
1054
|
+
order_col = next((c for c in ("updated_at", "created_at") if c in columns), None)
|
|
1055
|
+
select_sql = ", ".join(f'"{c}"' for c in selected)
|
|
1056
|
+
order_sql = f' ORDER BY "{order_col}" DESC' if order_col else " ORDER BY rowid DESC"
|
|
1057
|
+
rows = conn.execute(f"SELECT rowid, {select_sql} FROM threads{order_sql} LIMIT ?", (limit,)).fetchall()
|
|
1058
|
+
recent = []
|
|
1059
|
+
for row in rows:
|
|
1060
|
+
item = {"rowid": row[0]}
|
|
1061
|
+
item.update({name: value for name, value in zip(selected, row[1:])})
|
|
1062
|
+
recent.append(item)
|
|
1063
|
+
return recent, None
|
|
1064
|
+
except sqlite3.Error as exc:
|
|
1065
|
+
return [], str(exc)
|
|
1066
|
+
finally:
|
|
1067
|
+
conn.close()
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def has_provider_model_drift(
|
|
1071
|
+
distribution: list[tuple[str, str, int]],
|
|
1072
|
+
identity: IdentityConfig,
|
|
1073
|
+
) -> Optional[bool]:
|
|
1074
|
+
if not distribution:
|
|
1075
|
+
return None
|
|
1076
|
+
if not identity.provider:
|
|
1077
|
+
return None
|
|
1078
|
+
for provider, model, _count in distribution:
|
|
1079
|
+
if provider != identity.provider:
|
|
1080
|
+
return True
|
|
1081
|
+
if identity.model and model != identity.model:
|
|
1082
|
+
return True
|
|
1083
|
+
return False
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def plan_merge_history_to_target(codex: Path, target: IdentityConfig, keep_models: bool) -> MergeHistoryResult:
|
|
1087
|
+
rollout_files = iter_rollout_files(codex)
|
|
1088
|
+
state_db = codex / "state_5.sqlite"
|
|
1089
|
+
changed_files = 0
|
|
1090
|
+
changed_lines = 0
|
|
1091
|
+
|
|
1092
|
+
for path in rollout_files:
|
|
1093
|
+
line_count, changed = normalize_rollout_file(path, target, keep_models, apply=False)
|
|
1094
|
+
if changed:
|
|
1095
|
+
changed_files += 1
|
|
1096
|
+
changed_lines += line_count
|
|
1097
|
+
|
|
1098
|
+
state_rows = 0
|
|
1099
|
+
if state_db.exists():
|
|
1100
|
+
state_rows = count_state_rows_to_normalize(state_db, target, keep_models)
|
|
1101
|
+
|
|
1102
|
+
backup_dir = backup_path(codex, "history-merge-backup") if changed_files or state_rows else None
|
|
1103
|
+
return MergeHistoryResult(changed_files, changed_lines, state_rows, backup_dir)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def merge_history_to_target(codex: Path, target: IdentityConfig, keep_models: bool) -> MergeHistoryResult:
|
|
1107
|
+
rollout_files = iter_rollout_files(codex)
|
|
1108
|
+
state_db = codex / "state_5.sqlite"
|
|
1109
|
+
backup_dir: Optional[Path] = None
|
|
1110
|
+
changed_files = 0
|
|
1111
|
+
changed_lines = 0
|
|
1112
|
+
|
|
1113
|
+
for path in rollout_files:
|
|
1114
|
+
line_count, changed = normalize_rollout_file(path, target, keep_models, apply=False)
|
|
1115
|
+
if changed:
|
|
1116
|
+
if backup_dir is None:
|
|
1117
|
+
backup_dir = backup_root(codex, "history-merge-backup")
|
|
1118
|
+
backup_copy(path, backup_dir, codex)
|
|
1119
|
+
line_count, _ = normalize_rollout_file(path, target, keep_models)
|
|
1120
|
+
changed_files += 1
|
|
1121
|
+
changed_lines += line_count
|
|
1122
|
+
|
|
1123
|
+
state_rows = 0
|
|
1124
|
+
if state_db.exists():
|
|
1125
|
+
preview_state_rows = count_state_rows_to_normalize(state_db, target, keep_models)
|
|
1126
|
+
if preview_state_rows:
|
|
1127
|
+
if backup_dir is None:
|
|
1128
|
+
backup_dir = backup_root(codex, "history-merge-backup")
|
|
1129
|
+
backup_copy(state_db, backup_dir, codex)
|
|
1130
|
+
for suffix in ("-shm", "-wal"):
|
|
1131
|
+
sidecar = codex / f"state_5.sqlite{suffix}"
|
|
1132
|
+
backup_copy(sidecar, backup_dir, codex)
|
|
1133
|
+
state_rows = normalize_state_db(state_db, target, keep_models)
|
|
1134
|
+
|
|
1135
|
+
return MergeHistoryResult(changed_files, changed_lines, state_rows, backup_dir)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def maybe_auto_merge_history(codex: Path, *, keep_models: bool = False) -> None:
|
|
1139
|
+
target = current_identity()
|
|
1140
|
+
if not target.provider:
|
|
1141
|
+
return
|
|
1142
|
+
effective_keep_models = keep_models or not target.model
|
|
1143
|
+
result = merge_history_to_target(
|
|
1144
|
+
codex,
|
|
1145
|
+
target,
|
|
1146
|
+
effective_keep_models,
|
|
1147
|
+
)
|
|
1148
|
+
if result.changed_files or result.state_rows:
|
|
1149
|
+
mode = "provider-only" if effective_keep_models else "provider+model"
|
|
1150
|
+
model_desc = "(preserved)" if effective_keep_models else target.model
|
|
1151
|
+
print(
|
|
1152
|
+
f"history aligned → provider={target.provider} "
|
|
1153
|
+
f"model={model_desc} mode={mode} "
|
|
1154
|
+
f"files={result.changed_files} lines={result.changed_lines} state_rows={result.state_rows}"
|
|
1155
|
+
)
|
|
1156
|
+
if result.backup_dir is not None:
|
|
1157
|
+
print(f"history backup → {result.backup_dir}")
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _parse_index_timestamp_ms(raw: object) -> int:
|
|
1161
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
1162
|
+
return 0
|
|
1163
|
+
text = raw.strip()
|
|
1164
|
+
if text.endswith("Z"):
|
|
1165
|
+
text = text[:-1] + "+00:00"
|
|
1166
|
+
try:
|
|
1167
|
+
parsed = datetime.fromisoformat(text)
|
|
1168
|
+
except ValueError:
|
|
1169
|
+
return 0
|
|
1170
|
+
if parsed.tzinfo is None:
|
|
1171
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1172
|
+
return int(parsed.timestamp() * 1000)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _format_index_timestamp(updated_at: object, updated_at_ms: object) -> str:
|
|
1176
|
+
millis: Optional[int] = None
|
|
1177
|
+
if isinstance(updated_at_ms, int):
|
|
1178
|
+
millis = updated_at_ms
|
|
1179
|
+
elif isinstance(updated_at_ms, float):
|
|
1180
|
+
millis = int(updated_at_ms)
|
|
1181
|
+
elif isinstance(updated_at, int):
|
|
1182
|
+
millis = updated_at * 1000
|
|
1183
|
+
elif isinstance(updated_at, float):
|
|
1184
|
+
millis = int(updated_at * 1000)
|
|
1185
|
+
if millis is None:
|
|
1186
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
1187
|
+
return datetime.fromtimestamp(millis / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _read_session_index_latest(path: Path) -> dict[str, int]:
|
|
1191
|
+
latest: dict[str, int] = {}
|
|
1192
|
+
if not path.exists():
|
|
1193
|
+
return latest
|
|
1194
|
+
with path.open() as fh:
|
|
1195
|
+
for raw in fh:
|
|
1196
|
+
try:
|
|
1197
|
+
item = json.loads(raw)
|
|
1198
|
+
except json.JSONDecodeError:
|
|
1199
|
+
continue
|
|
1200
|
+
if not isinstance(item, dict):
|
|
1201
|
+
continue
|
|
1202
|
+
thread_id = item.get("id")
|
|
1203
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
1204
|
+
continue
|
|
1205
|
+
latest[thread_id] = max(latest.get(thread_id, 0), _parse_index_timestamp_ms(item.get("updated_at")))
|
|
1206
|
+
return latest
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _session_index_thread_columns(conn: sqlite3.Connection) -> list[str]:
|
|
1210
|
+
columns = sqlite_threads_columns(conn)
|
|
1211
|
+
needed = {"id", "updated_at"}
|
|
1212
|
+
if not needed.issubset(set(columns)):
|
|
1213
|
+
return []
|
|
1214
|
+
return columns
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def maybe_repair_session_index(codex: Path) -> None:
|
|
1218
|
+
index = codex / "session_index.jsonl"
|
|
1219
|
+
state_db = codex / "state_5.sqlite"
|
|
1220
|
+
if not state_db.exists():
|
|
1221
|
+
return
|
|
1222
|
+
try:
|
|
1223
|
+
conn = connect_sqlite_readonly(state_db)
|
|
1224
|
+
except sqlite3.Error:
|
|
1225
|
+
return
|
|
1226
|
+
try:
|
|
1227
|
+
columns = _session_index_thread_columns(conn)
|
|
1228
|
+
if not columns:
|
|
1229
|
+
return
|
|
1230
|
+
selected = [c for c in ("id", "title", "preview", "updated_at", "updated_at_ms", "archived") if c in columns]
|
|
1231
|
+
select_sql = ", ".join(f'"{c}"' for c in selected)
|
|
1232
|
+
rows = conn.execute(
|
|
1233
|
+
f"""
|
|
1234
|
+
SELECT {select_sql}
|
|
1235
|
+
FROM threads
|
|
1236
|
+
WHERE ifnull(archived, 0) = 0
|
|
1237
|
+
ORDER BY ifnull(updated_at_ms, updated_at * 1000) ASC
|
|
1238
|
+
"""
|
|
1239
|
+
).fetchall()
|
|
1240
|
+
except sqlite3.Error:
|
|
1241
|
+
return
|
|
1242
|
+
finally:
|
|
1243
|
+
conn.close()
|
|
1244
|
+
|
|
1245
|
+
latest = _read_session_index_latest(index)
|
|
1246
|
+
additions: list[dict[str, str]] = []
|
|
1247
|
+
for row in rows:
|
|
1248
|
+
item = {name: value for name, value in zip(selected, row)}
|
|
1249
|
+
thread_id = item.get("id")
|
|
1250
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
1251
|
+
continue
|
|
1252
|
+
updated_at_ms = item.get("updated_at_ms")
|
|
1253
|
+
updated_at = item.get("updated_at")
|
|
1254
|
+
if isinstance(updated_at_ms, (int, float)):
|
|
1255
|
+
current_ms = int(updated_at_ms)
|
|
1256
|
+
elif isinstance(updated_at, (int, float)):
|
|
1257
|
+
current_ms = int(updated_at * 1000)
|
|
1258
|
+
else:
|
|
1259
|
+
continue
|
|
1260
|
+
if current_ms <= latest.get(thread_id, 0):
|
|
1261
|
+
continue
|
|
1262
|
+
title = item.get("title") or item.get("preview") or "Untitled session"
|
|
1263
|
+
additions.append(
|
|
1264
|
+
{
|
|
1265
|
+
"id": thread_id,
|
|
1266
|
+
"thread_name": str(title),
|
|
1267
|
+
"updated_at": _format_index_timestamp(updated_at, updated_at_ms),
|
|
1268
|
+
}
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
if not additions:
|
|
1272
|
+
return
|
|
1273
|
+
|
|
1274
|
+
index.parent.mkdir(parents=True, exist_ok=True)
|
|
1275
|
+
with index.open("a") as fh:
|
|
1276
|
+
for item in additions:
|
|
1277
|
+
fh.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
1278
|
+
print(f"session index repaired → {len(additions)} entries")
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def current_looks_official(codex: Path) -> bool:
|
|
1282
|
+
cfg = _swap.load(codex / "config.toml")
|
|
1283
|
+
auth_path = codex / "auth.json"
|
|
1284
|
+
if not auth_path.exists():
|
|
1285
|
+
return False
|
|
1286
|
+
try:
|
|
1287
|
+
auth = json.loads(auth_path.read_text())
|
|
1288
|
+
except json.JSONDecodeError:
|
|
1289
|
+
return False
|
|
1290
|
+
provider = str(cfg.get("model_provider") or "").strip()
|
|
1291
|
+
auth_method = str(cfg.get("preferred_auth_method") or "").strip()
|
|
1292
|
+
auth_mode = str(auth.get("auth_mode") or "").strip()
|
|
1293
|
+
return provider == "openai" and auth_mode == "chatgpt" and auth_method in {"", "chatgpt"}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def snapshot_official_state(codex: Path) -> None:
|
|
1297
|
+
auth = codex / "auth.json"
|
|
1298
|
+
cfg = codex / "config.toml"
|
|
1299
|
+
if not auth.is_file():
|
|
1300
|
+
_die(f"no {auth} to snapshot")
|
|
1301
|
+
dir_ = official_profile_dir()
|
|
1302
|
+
dir_.mkdir(parents=True, exist_ok=True)
|
|
1303
|
+
shutil.copy2(auth, dir_ / "auth.json")
|
|
1304
|
+
if cfg.is_file():
|
|
1305
|
+
_swap.extract(cfg, dir_ / "provider.toml")
|
|
1306
|
+
else:
|
|
1307
|
+
(dir_ / "provider.toml").write_text("")
|
|
1308
|
+
write_session_config(dir_, SessionConfig())
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def ensure_official_snapshot_available(codex: Path) -> None:
|
|
1312
|
+
if official_profile_dir().is_dir():
|
|
1313
|
+
auth = official_profile_dir() / "auth.json"
|
|
1314
|
+
prov = official_profile_dir() / "provider.toml"
|
|
1315
|
+
if auth.is_file() and prov.is_file():
|
|
1316
|
+
return
|
|
1317
|
+
if current_looks_official(codex):
|
|
1318
|
+
snapshot_official_state(codex)
|
|
1319
|
+
return
|
|
1320
|
+
_die("official OpenAI snapshot not found; switch to official once, then run `codex-safe-switch official`")
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def switch_to_profile(name: str, *, restart_codex: bool = False) -> int:
|
|
1324
|
+
normalized_name = normalize_profile_name(name)
|
|
1325
|
+
dir_ = profile_dir_for_name(normalized_name)
|
|
1326
|
+
if not dir_.is_dir():
|
|
1327
|
+
bootstrap_current_profile()
|
|
1328
|
+
dir_ = profile_dir_for_name(normalized_name)
|
|
1329
|
+
if not dir_.is_dir():
|
|
1330
|
+
_die(f"profile not found: {name}")
|
|
1331
|
+
auth_src = dir_ / "auth.json"
|
|
1332
|
+
prov_src = dir_ / "provider.toml"
|
|
1333
|
+
if not prov_src.is_file():
|
|
1334
|
+
_die(f"missing {prov_src}")
|
|
1335
|
+
provider_doc = _swap.load(prov_src)
|
|
1336
|
+
needs_auth_file = profile_requires_auth_file(dir_, provider_doc)
|
|
1337
|
+
if needs_auth_file and not auth_src.is_file():
|
|
1338
|
+
_die(f"missing {auth_src}")
|
|
1339
|
+
|
|
1340
|
+
codex = codex_dir()
|
|
1341
|
+
codex.mkdir(parents=True, exist_ok=True)
|
|
1342
|
+
cfg = codex / "config.toml"
|
|
1343
|
+
auth = codex / "auth.json"
|
|
1344
|
+
current = normalize_profile_name(active_name())
|
|
1345
|
+
|
|
1346
|
+
if current_looks_official(codex) and normalized_name != OFFICIAL_PROFILE_NAME:
|
|
1347
|
+
snapshot_official_state(codex)
|
|
1348
|
+
|
|
1349
|
+
if not cfg.exists():
|
|
1350
|
+
cfg.touch()
|
|
1351
|
+
|
|
1352
|
+
fd, tmp_cfg_str = tempfile.mkstemp(prefix="config.toml.", dir=codex)
|
|
1353
|
+
os.close(fd)
|
|
1354
|
+
tmp_cfg = Path(tmp_cfg_str)
|
|
1355
|
+
try:
|
|
1356
|
+
_swap.merge(cfg, prov_src, tmp_cfg)
|
|
1357
|
+
_copy_mode(cfg, tmp_cfg)
|
|
1358
|
+
os.replace(tmp_cfg, cfg)
|
|
1359
|
+
finally:
|
|
1360
|
+
if tmp_cfg.exists():
|
|
1361
|
+
tmp_cfg.unlink(missing_ok=True)
|
|
1362
|
+
|
|
1363
|
+
if needs_auth_file:
|
|
1364
|
+
_atomic_write_copy(auth_src, auth)
|
|
1365
|
+
maybe_switch_session_state(current, normalized_name, codex)
|
|
1366
|
+
|
|
1367
|
+
active_file().write_text(normalized_name + "\n")
|
|
1368
|
+
print(f"switched → {normalized_name}")
|
|
1369
|
+
maybe_auto_merge_history(codex)
|
|
1370
|
+
maybe_repair_session_index(codex)
|
|
1371
|
+
maybe_repair_remote_selection_state(codex)
|
|
1372
|
+
stopped_runtime = maybe_repair_remote_control(codex)
|
|
1373
|
+
if restart_codex:
|
|
1374
|
+
count = restart_codex_processes()
|
|
1375
|
+
print(f"restarted Codex processes → {count}")
|
|
1376
|
+
else:
|
|
1377
|
+
maybe_stop_remote_proxy_processes(codex, exclude=stopped_runtime)
|
|
1378
|
+
return 0
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def cmd_merge_history(args) -> int:
|
|
1382
|
+
codex = codex_dir()
|
|
1383
|
+
identity = current_identity()
|
|
1384
|
+
provider = args.provider or identity.provider
|
|
1385
|
+
model = identity.model if args.model is None else args.model
|
|
1386
|
+
|
|
1387
|
+
if not provider:
|
|
1388
|
+
_die("target provider is empty; pass --provider or set model_provider in ~/.codex/config.toml")
|
|
1389
|
+
if not args.keep_models and not model:
|
|
1390
|
+
_die("target model is empty; pass --model or use --keep-models")
|
|
1391
|
+
|
|
1392
|
+
target = IdentityConfig(provider=provider, model=model)
|
|
1393
|
+
if args.dry_run:
|
|
1394
|
+
result = plan_merge_history_to_target(codex, target, args.keep_models)
|
|
1395
|
+
else:
|
|
1396
|
+
result = merge_history_to_target(codex, target, args.keep_models)
|
|
1397
|
+
|
|
1398
|
+
mode = "provider-only" if args.keep_models else "provider+model"
|
|
1399
|
+
model_desc = "(preserved)" if args.keep_models else target.model
|
|
1400
|
+
verb = "would merge history" if args.dry_run else "merged history"
|
|
1401
|
+
action = "would update" if args.dry_run else "updated"
|
|
1402
|
+
print(f"{verb} → provider={target.provider} model={model_desc} mode={mode}")
|
|
1403
|
+
if result.backup_dir is not None:
|
|
1404
|
+
suffix = " (would create)" if args.dry_run else ""
|
|
1405
|
+
print(f"backup → {result.backup_dir}{suffix}")
|
|
1406
|
+
else:
|
|
1407
|
+
print("backup → (not needed)")
|
|
1408
|
+
print(f"rollout files {action} → {result.changed_files}")
|
|
1409
|
+
print(f"rollout lines {action} → {result.changed_lines}")
|
|
1410
|
+
print(f"state rows {action} → {result.state_rows}")
|
|
1411
|
+
return 0
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def cmd_doctor_history(_args) -> int:
|
|
1415
|
+
codex = codex_dir()
|
|
1416
|
+
identity = current_identity()
|
|
1417
|
+
profile = normalize_profile_name(active_name())
|
|
1418
|
+
if profile:
|
|
1419
|
+
profile_dir = profile_dir_for_name(profile)
|
|
1420
|
+
session_desc = load_session_config(profile_dir).describe() if profile_dir.is_dir() else "(profile missing)"
|
|
1421
|
+
else:
|
|
1422
|
+
session_desc = "(none)"
|
|
1423
|
+
|
|
1424
|
+
print("history doctor")
|
|
1425
|
+
print(f"current profile → {profile or '(none)'}")
|
|
1426
|
+
print(f"current config → provider={identity.provider or '(empty)'} model={identity.model or '(empty)'}")
|
|
1427
|
+
print(f"session state → {session_desc}")
|
|
1428
|
+
|
|
1429
|
+
state_db = codex / "state_5.sqlite"
|
|
1430
|
+
distribution, dist_error = read_thread_distribution(state_db)
|
|
1431
|
+
print("threads provider/model distribution:")
|
|
1432
|
+
if dist_error:
|
|
1433
|
+
print(f" ({dist_error})")
|
|
1434
|
+
elif not distribution:
|
|
1435
|
+
print(" (empty)")
|
|
1436
|
+
else:
|
|
1437
|
+
for provider, model, count in distribution:
|
|
1438
|
+
print(f" {count} provider={_fmt_value(provider)} model={_fmt_value(model)}")
|
|
1439
|
+
|
|
1440
|
+
recent, recent_error = read_recent_threads(state_db)
|
|
1441
|
+
print("recent threads:")
|
|
1442
|
+
if recent_error:
|
|
1443
|
+
print(f" ({recent_error})")
|
|
1444
|
+
elif not recent:
|
|
1445
|
+
print(" (empty)")
|
|
1446
|
+
else:
|
|
1447
|
+
for item in recent:
|
|
1448
|
+
bits = [f"rowid={item.pop('rowid')}"]
|
|
1449
|
+
for key, value in item.items():
|
|
1450
|
+
bits.append(f"{key}={_fmt_value(value)}")
|
|
1451
|
+
print(" " + " ".join(bits))
|
|
1452
|
+
|
|
1453
|
+
sqlite_drift = has_provider_model_drift(distribution, identity)
|
|
1454
|
+
if sqlite_drift is None:
|
|
1455
|
+
print("sqlite provider/model drift → unknown")
|
|
1456
|
+
else:
|
|
1457
|
+
print(f"sqlite provider/model drift → {'yes' if sqlite_drift else 'no'}")
|
|
1458
|
+
|
|
1459
|
+
if identity.provider:
|
|
1460
|
+
effective_keep_models = not identity.model
|
|
1461
|
+
plan = plan_merge_history_to_target(codex, identity, effective_keep_models)
|
|
1462
|
+
rollout_drift = plan.changed_files > 0
|
|
1463
|
+
overall_drift = bool(sqlite_drift) or rollout_drift or plan.state_rows > 0
|
|
1464
|
+
print(
|
|
1465
|
+
"planned history alignment → "
|
|
1466
|
+
f"files={plan.changed_files} lines={plan.changed_lines} state_rows={plan.state_rows}"
|
|
1467
|
+
)
|
|
1468
|
+
print(f"rollout metadata drift → {'yes' if rollout_drift else 'no'}")
|
|
1469
|
+
print(f"provider/model drift → {'yes' if overall_drift else 'no'}")
|
|
1470
|
+
else:
|
|
1471
|
+
print("planned history alignment → unknown (empty provider)")
|
|
1472
|
+
print("rollout metadata drift → unknown")
|
|
1473
|
+
print("provider/model drift → unknown")
|
|
1474
|
+
return 0
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def cmd_ls(_args) -> int:
|
|
1478
|
+
bootstrap_current_profile()
|
|
1479
|
+
active = normalize_profile_name(active_name())
|
|
1480
|
+
profiles = list_profiles()
|
|
1481
|
+
if not profiles:
|
|
1482
|
+
_die(no_profiles_message())
|
|
1483
|
+
for name in profiles:
|
|
1484
|
+
prefix = "★" if name == active else " "
|
|
1485
|
+
print(f"{prefix} {name}")
|
|
1486
|
+
return 0
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def cmd_current(_args) -> int:
|
|
1490
|
+
bootstrap_current_profile()
|
|
1491
|
+
name = normalize_profile_name(active_name())
|
|
1492
|
+
if name:
|
|
1493
|
+
print(name)
|
|
1494
|
+
return 0
|
|
1495
|
+
print("(none)")
|
|
1496
|
+
return 1
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def cmd_use(args) -> int:
|
|
1500
|
+
name = normalize_profile_name(args.name)
|
|
1501
|
+
if args.name == ALFRED_INIT_ARG:
|
|
1502
|
+
initialized = bootstrap_current_profile()
|
|
1503
|
+
if initialized is None:
|
|
1504
|
+
_die(no_profiles_message())
|
|
1505
|
+
return 0
|
|
1506
|
+
if not name:
|
|
1507
|
+
bootstrap_current_profile()
|
|
1508
|
+
profiles = list_profiles()
|
|
1509
|
+
if not profiles:
|
|
1510
|
+
_die(no_profiles_message())
|
|
1511
|
+
chosen = pick(profiles, active=normalize_profile_name(active_name()), prompt="Switch to which profile?")
|
|
1512
|
+
if chosen is None:
|
|
1513
|
+
print("cancelled")
|
|
1514
|
+
return 1
|
|
1515
|
+
name = normalize_profile_name(chosen)
|
|
1516
|
+
if name == OFFICIAL_PROFILE_NAME:
|
|
1517
|
+
return cmd_official(args)
|
|
1518
|
+
return switch_to_profile(name, restart_codex=getattr(args, "restart_codex", False))
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def cmd_official(args) -> int:
|
|
1522
|
+
codex = codex_dir()
|
|
1523
|
+
ensure_official_snapshot_available(codex)
|
|
1524
|
+
return switch_to_profile(OFFICIAL_PROFILE_NAME, restart_codex=getattr(args, "restart_codex", False))
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def cmd_save(args) -> int:
|
|
1528
|
+
name = normalize_profile_name(args.name)
|
|
1529
|
+
if name in {OFFICIAL_PROFILE_NAME, "bin", ".active"} or args.name.startswith("."):
|
|
1530
|
+
_die(f"reserved name: {args.name}")
|
|
1531
|
+
if args.shared and args.scope:
|
|
1532
|
+
_die("choose either --shared or --scope, not both")
|
|
1533
|
+
dir_ = profile_root() / name
|
|
1534
|
+
dir_.mkdir(parents=True, exist_ok=True)
|
|
1535
|
+
|
|
1536
|
+
codex = codex_dir()
|
|
1537
|
+
auth = codex / "auth.json"
|
|
1538
|
+
cfg = codex / "config.toml"
|
|
1539
|
+
if cfg.is_file():
|
|
1540
|
+
_swap.extract(cfg, dir_ / "provider.toml")
|
|
1541
|
+
else:
|
|
1542
|
+
(dir_ / "provider.toml").write_text("")
|
|
1543
|
+
if profile_requires_auth_file(dir_):
|
|
1544
|
+
if not auth.is_file():
|
|
1545
|
+
_die(f"no {auth} to snapshot")
|
|
1546
|
+
shutil.copy2(auth, dir_ / "auth.json")
|
|
1547
|
+
else:
|
|
1548
|
+
(dir_ / "auth.json").unlink(missing_ok=True)
|
|
1549
|
+
|
|
1550
|
+
if args.shared:
|
|
1551
|
+
session_cfg = SessionConfig()
|
|
1552
|
+
write_session_config(dir_, session_cfg)
|
|
1553
|
+
elif args.scope:
|
|
1554
|
+
scope = args.scope.strip()
|
|
1555
|
+
if not scope:
|
|
1556
|
+
_die("scope must not be empty")
|
|
1557
|
+
session_cfg = SessionConfig(mode="scoped", scope=scope)
|
|
1558
|
+
write_session_config(dir_, session_cfg)
|
|
1559
|
+
save_session_state(session_cfg, codex)
|
|
1560
|
+
|
|
1561
|
+
print(f"saved → {name}")
|
|
1562
|
+
return 0
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def cmd_show(args) -> int:
|
|
1566
|
+
name = normalize_profile_name(args.name)
|
|
1567
|
+
dir_ = profile_dir_for_name(name)
|
|
1568
|
+
if not dir_.is_dir():
|
|
1569
|
+
_die(f"profile not found: {args.name}")
|
|
1570
|
+
prov = dir_ / "provider.toml"
|
|
1571
|
+
print(f"# {prov}")
|
|
1572
|
+
if prov.exists():
|
|
1573
|
+
text = prov.read_text()
|
|
1574
|
+
print(text if text else "(empty)")
|
|
1575
|
+
else:
|
|
1576
|
+
print("(missing)")
|
|
1577
|
+
print()
|
|
1578
|
+
auth = dir_ / "auth.json"
|
|
1579
|
+
print(f"# {auth} (keys only)")
|
|
1580
|
+
if auth.exists():
|
|
1581
|
+
try:
|
|
1582
|
+
data = json.loads(auth.read_text())
|
|
1583
|
+
for k in sorted(data.keys()):
|
|
1584
|
+
print(k)
|
|
1585
|
+
except json.JSONDecodeError as e:
|
|
1586
|
+
print(f"(invalid json: {e})")
|
|
1587
|
+
else:
|
|
1588
|
+
print("(missing)")
|
|
1589
|
+
print()
|
|
1590
|
+
print(f"# {session_config_path(dir_)}")
|
|
1591
|
+
session_cfg = load_session_config(dir_)
|
|
1592
|
+
print(session_cfg.describe())
|
|
1593
|
+
return 0
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def cmd_rm(args) -> int:
|
|
1597
|
+
name = normalize_profile_name(args.name)
|
|
1598
|
+
dir_ = profile_dir_for_name(name)
|
|
1599
|
+
if not dir_.is_dir():
|
|
1600
|
+
_die(f"profile not found: {args.name}")
|
|
1601
|
+
if name == normalize_profile_name(active_name()):
|
|
1602
|
+
_die(f"cannot remove the active profile: {args.name}")
|
|
1603
|
+
shutil.rmtree(dir_)
|
|
1604
|
+
print(f"removed → {name}")
|
|
1605
|
+
return 0
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def cmd_state(args) -> int:
|
|
1609
|
+
name = normalize_profile_name(args.name)
|
|
1610
|
+
dir_ = profile_dir_for_name(name)
|
|
1611
|
+
if not dir_.is_dir():
|
|
1612
|
+
_die(f"profile not found: {args.name}")
|
|
1613
|
+
|
|
1614
|
+
if args.shared and args.scope:
|
|
1615
|
+
_die("choose either --shared or --scope, not both")
|
|
1616
|
+
|
|
1617
|
+
if not args.shared and not args.scope:
|
|
1618
|
+
print(load_session_config(dir_).describe())
|
|
1619
|
+
return 0
|
|
1620
|
+
|
|
1621
|
+
if args.shared:
|
|
1622
|
+
cfg = SessionConfig()
|
|
1623
|
+
else:
|
|
1624
|
+
scope = args.scope.strip()
|
|
1625
|
+
if not scope:
|
|
1626
|
+
_die("scope must not be empty")
|
|
1627
|
+
cfg = SessionConfig(mode="scoped", scope=scope)
|
|
1628
|
+
|
|
1629
|
+
write_session_config(dir_, cfg)
|
|
1630
|
+
|
|
1631
|
+
current = normalize_profile_name(active_name())
|
|
1632
|
+
if name == current or current is None:
|
|
1633
|
+
save_session_state(cfg, codex_dir())
|
|
1634
|
+
|
|
1635
|
+
print(f"{name}: session state → {cfg.describe()}")
|
|
1636
|
+
return 0
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def cmd_alfred_list(_args) -> int:
|
|
1640
|
+
"""Emit Alfred Script Filter JSON."""
|
|
1641
|
+
bootstrap_current_profile(verbose=False)
|
|
1642
|
+
active = normalize_profile_name(active_name())
|
|
1643
|
+
items = []
|
|
1644
|
+
for name in list_profiles():
|
|
1645
|
+
if name == active:
|
|
1646
|
+
items.append({
|
|
1647
|
+
"uid": name,
|
|
1648
|
+
"title": f"★ {name}",
|
|
1649
|
+
"arg": name,
|
|
1650
|
+
"subtitle": "current profile — pressing return reloads it",
|
|
1651
|
+
"autocomplete": name,
|
|
1652
|
+
})
|
|
1653
|
+
else:
|
|
1654
|
+
items.append({
|
|
1655
|
+
"uid": name,
|
|
1656
|
+
"title": name,
|
|
1657
|
+
"arg": name,
|
|
1658
|
+
"subtitle": f"switch to {name}",
|
|
1659
|
+
"autocomplete": name,
|
|
1660
|
+
})
|
|
1661
|
+
if not items:
|
|
1662
|
+
items.append({
|
|
1663
|
+
"uid": "initialize",
|
|
1664
|
+
"title": "Initialize Codex profiles",
|
|
1665
|
+
"arg": ALFRED_INIT_ARG,
|
|
1666
|
+
"subtitle": "Run codex-safe-switch save <name> after configuring Codex once",
|
|
1667
|
+
"autocomplete": "initialize",
|
|
1668
|
+
})
|
|
1669
|
+
print(json.dumps({"items": items}, ensure_ascii=False))
|
|
1670
|
+
return 0
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def cmd_pick(_args) -> int:
|
|
1674
|
+
"""Default action when no subcommand is given: interactive switch."""
|
|
1675
|
+
bootstrap_current_profile()
|
|
1676
|
+
profiles = list_profiles()
|
|
1677
|
+
if not profiles:
|
|
1678
|
+
_die(no_profiles_message())
|
|
1679
|
+
chosen = pick(profiles, active=normalize_profile_name(active_name()), prompt="Switch to which profile?")
|
|
1680
|
+
if chosen is None:
|
|
1681
|
+
print("cancelled")
|
|
1682
|
+
return 1
|
|
1683
|
+
args_ns = argparse.Namespace(name=chosen, restart_codex=False)
|
|
1684
|
+
return cmd_use(args_ns)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def cmd_restart_codex(_args) -> int:
|
|
1688
|
+
count = restart_codex_processes()
|
|
1689
|
+
print(f"restarted Codex processes → {count}")
|
|
1690
|
+
return 0
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1694
|
+
p = argparse.ArgumentParser(
|
|
1695
|
+
prog="codex-safe-switch",
|
|
1696
|
+
description="Switch between Codex provider profiles. Run with no subcommand for an interactive picker.",
|
|
1697
|
+
)
|
|
1698
|
+
subs = p.add_subparsers(dest="cmd", metavar="<command>")
|
|
1699
|
+
|
|
1700
|
+
s = subs.add_parser("ls", aliases=["list"], help="list profiles (★ = active)")
|
|
1701
|
+
s.set_defaults(func=cmd_ls)
|
|
1702
|
+
|
|
1703
|
+
s = subs.add_parser("current", help="print the active profile name")
|
|
1704
|
+
s.set_defaults(func=cmd_current)
|
|
1705
|
+
|
|
1706
|
+
s = subs.add_parser("official", aliases=["openai"], help="switch back to official OpenAI ChatGPT login")
|
|
1707
|
+
s.add_argument("--restart-codex", action="store_true", help="terminate Codex app/server processes after switching")
|
|
1708
|
+
s.set_defaults(func=cmd_official)
|
|
1709
|
+
|
|
1710
|
+
s = subs.add_parser("use", aliases=["switch"], help="load <name> (interactive if omitted)")
|
|
1711
|
+
s.add_argument("name", nargs="?")
|
|
1712
|
+
s.add_argument("--restart-codex", action="store_true", help="terminate Codex app/server processes after switching")
|
|
1713
|
+
s.set_defaults(func=cmd_use)
|
|
1714
|
+
|
|
1715
|
+
s = subs.add_parser("save", help="snapshot the current ~/.codex state as <name>")
|
|
1716
|
+
s.add_argument("name")
|
|
1717
|
+
s.add_argument("--scope", help="also bind this profile to a session-state scope and seed it now")
|
|
1718
|
+
s.add_argument("--shared", action="store_true", help="clear any session-state scope while saving")
|
|
1719
|
+
s.set_defaults(func=cmd_save)
|
|
1720
|
+
|
|
1721
|
+
s = subs.add_parser("show", help="print <name>'s provider.toml + auth.json keys")
|
|
1722
|
+
s.add_argument("name")
|
|
1723
|
+
s.set_defaults(func=cmd_show)
|
|
1724
|
+
|
|
1725
|
+
s = subs.add_parser("state", help="show or set <name>'s session-state scope")
|
|
1726
|
+
s.add_argument("name")
|
|
1727
|
+
s.add_argument("--scope", help="store/restore Codex history state under this shared scope")
|
|
1728
|
+
s.add_argument("--shared", action="store_true", help="disable session-state swapping for this profile")
|
|
1729
|
+
s.set_defaults(func=cmd_state)
|
|
1730
|
+
|
|
1731
|
+
s = subs.add_parser("merge-history", help="rewrite local Codex history metadata into one provider/model identity")
|
|
1732
|
+
s.add_argument("--provider", help="target provider; defaults to current ~/.codex/config.toml model_provider")
|
|
1733
|
+
s.add_argument("--model", help="target model; defaults to current ~/.codex/config.toml model")
|
|
1734
|
+
s.add_argument(
|
|
1735
|
+
"--keep-models",
|
|
1736
|
+
action="store_true",
|
|
1737
|
+
help="only normalize provider identity and keep existing per-thread model values",
|
|
1738
|
+
)
|
|
1739
|
+
s.add_argument("--dry-run", action="store_true", help="report planned history changes without writing files")
|
|
1740
|
+
s.set_defaults(func=cmd_merge_history)
|
|
1741
|
+
|
|
1742
|
+
s = subs.add_parser("doctor-history", help="read-only diagnostics for Codex history provider/model state")
|
|
1743
|
+
s.set_defaults(func=cmd_doctor_history)
|
|
1744
|
+
|
|
1745
|
+
s = subs.add_parser("restart-codex", help="terminate Codex app/server processes so config changes take effect")
|
|
1746
|
+
s.set_defaults(func=cmd_restart_codex)
|
|
1747
|
+
|
|
1748
|
+
s = subs.add_parser("rm", aliases=["remove"], help="delete profile (active is protected)")
|
|
1749
|
+
s.add_argument("name")
|
|
1750
|
+
s.set_defaults(func=cmd_rm)
|
|
1751
|
+
|
|
1752
|
+
s = subs.add_parser("alfred-list", help="JSON for Alfred Script Filter")
|
|
1753
|
+
s.set_defaults(func=cmd_alfred_list)
|
|
1754
|
+
|
|
1755
|
+
return p
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
1759
|
+
try:
|
|
1760
|
+
args = build_parser().parse_args(argv)
|
|
1761
|
+
if args.cmd is None:
|
|
1762
|
+
return cmd_pick(args)
|
|
1763
|
+
return args.func(args)
|
|
1764
|
+
except KeyboardInterrupt:
|
|
1765
|
+
print("codex-safe-switch: interrupted", file=sys.stderr)
|
|
1766
|
+
return 130
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
if __name__ == "__main__":
|
|
1770
|
+
raise SystemExit(main())
|