cdx-manager 0.6.0 → 0.6.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/README.md +3 -2
- package/changelogs/CHANGELOGS_0_6_1.md +30 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_commands.py +138 -6
- package/src/provider_runtime.py +8 -1
- package/src/update_check.py +47 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -67,6 +67,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
67
67
|
- Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
|
|
68
68
|
- Fallback: `status-source` scans provider JSONL history files and terminal log transcripts, strips ANSI/OSC sequences, and extracts `usage%`, `5h remaining%`, and `week remaining%` via pattern matching.
|
|
69
69
|
- Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
|
|
70
|
+
- On Linux, transcript capture uses the `util-linux` `script -c` command form.
|
|
70
71
|
- If `script` is unavailable, Codex launch falls back to running without transcript capture.
|
|
71
72
|
- On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
|
|
72
73
|
- Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
|
|
@@ -131,7 +132,7 @@ For a specific version:
|
|
|
131
132
|
|
|
132
133
|
```bash
|
|
133
134
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
134
|
-
CDX_VERSION=v0.6.
|
|
135
|
+
CDX_VERSION=v0.6.1 sh install.sh
|
|
135
136
|
```
|
|
136
137
|
|
|
137
138
|
From source:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog (`0.6.0 -> 0.6.1`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-28
|
|
4
|
+
|
|
5
|
+
## Handoff Reliability
|
|
6
|
+
|
|
7
|
+
- Fixed `cdx handoff <source> <target>` when a source session has no `cdx-session*.log` launch transcript.
|
|
8
|
+
- Added fallback discovery for native provider histories, including Claude project JSONL files under the isolated session home.
|
|
9
|
+
- Converted JSONL message records into readable role-prefixed handoff context instead of requiring raw terminal logs.
|
|
10
|
+
|
|
11
|
+
## Linux Transcript Capture
|
|
12
|
+
|
|
13
|
+
- Fixed default transcript capture on Linux and Arch Linux by using the `util-linux` `script -q -F -c "<command>" <transcript>` form.
|
|
14
|
+
- Kept the existing BSD/macOS `script` invocation unchanged.
|
|
15
|
+
- Preserved custom `CDX_SCRIPT_ARGS` behavior for users who explicitly configure their wrapper.
|
|
16
|
+
|
|
17
|
+
## Release Metadata and Documentation
|
|
18
|
+
|
|
19
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.1`.
|
|
20
|
+
- Documented the Linux-specific transcript capture behavior in the README.
|
|
21
|
+
|
|
22
|
+
## Validation and Regression Coverage
|
|
23
|
+
|
|
24
|
+
- Added regression coverage for Claude-to-Claude handoff from native `.claude/projects/*.jsonl` history when no launch log exists.
|
|
25
|
+
- Added runtime coverage for the Linux `script` invocation shape.
|
|
26
|
+
|
|
27
|
+
## Validation and Regression Evidence
|
|
28
|
+
|
|
29
|
+
- `npm run lint`
|
|
30
|
+
- `npm test`
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
package/src/cli_commands.py
CHANGED
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import getpass
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import sys
|
|
6
7
|
import time
|
|
7
8
|
from datetime import datetime, timedelta
|
|
@@ -37,7 +38,7 @@ from .provider_runtime import (
|
|
|
37
38
|
from .repair import format_repair_report, repair_health
|
|
38
39
|
from .backup_bundle import read_bundle_meta
|
|
39
40
|
from .status_view import _format_status_detail, _format_status_rows
|
|
40
|
-
from .update_check import fetch_latest_release, is_newer_version
|
|
41
|
+
from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
|
|
41
42
|
from .update_manager import build_update_plan, format_update_failure, run_update_plan
|
|
42
43
|
|
|
43
44
|
|
|
@@ -56,6 +57,7 @@ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|t
|
|
|
56
57
|
LAST_USAGE = "Usage: cdx last [--json]"
|
|
57
58
|
API_SCHEMA_VERSION = 1
|
|
58
59
|
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
60
|
+
HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def _local_now_iso():
|
|
@@ -194,9 +196,132 @@ def _latest_launch_transcript_path(session):
|
|
|
194
196
|
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
195
197
|
|
|
196
198
|
|
|
197
|
-
def
|
|
199
|
+
def _get_session_home(session):
|
|
200
|
+
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _safe_stat(path):
|
|
204
|
+
try:
|
|
205
|
+
return os.stat(path)
|
|
206
|
+
except OSError:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _sort_recent_paths(paths):
|
|
211
|
+
stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
|
|
212
|
+
return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _collect_native_handoff_transcript_paths(session):
|
|
216
|
+
root = _get_session_home(session)
|
|
217
|
+
if not root:
|
|
218
|
+
return []
|
|
219
|
+
candidates = []
|
|
220
|
+
direct = [
|
|
221
|
+
os.path.join(root, "history.jsonl"),
|
|
222
|
+
os.path.join(root, "session_index.jsonl"),
|
|
223
|
+
]
|
|
224
|
+
candidates.extend(path for path in direct if _safe_stat(path))
|
|
225
|
+
|
|
226
|
+
scan_roots = [
|
|
227
|
+
os.path.join(root, "sessions"),
|
|
228
|
+
os.path.join(root, ".claude", "projects"),
|
|
229
|
+
os.path.join(root, "projects"),
|
|
230
|
+
]
|
|
231
|
+
skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
|
|
232
|
+
for scan_root in scan_roots:
|
|
233
|
+
if not os.path.isdir(scan_root):
|
|
234
|
+
continue
|
|
235
|
+
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
236
|
+
dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
|
|
237
|
+
for filename in filenames:
|
|
238
|
+
if filename.endswith(".jsonl") or filename.endswith(".log"):
|
|
239
|
+
candidates.append(os.path.join(dirpath, filename))
|
|
240
|
+
return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _latest_handoff_transcript_path(session):
|
|
244
|
+
launch_path = _latest_launch_transcript_path(session)
|
|
245
|
+
if launch_path:
|
|
246
|
+
return launch_path
|
|
247
|
+
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
248
|
+
return native_paths[0] if native_paths else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _collect_handoff_text_fragments(value):
|
|
252
|
+
fragments = []
|
|
253
|
+
if isinstance(value, str):
|
|
254
|
+
text = value.strip()
|
|
255
|
+
if text:
|
|
256
|
+
fragments.append(text)
|
|
257
|
+
elif isinstance(value, list):
|
|
258
|
+
for item in value:
|
|
259
|
+
fragments.extend(_collect_handoff_text_fragments(item))
|
|
260
|
+
elif isinstance(value, dict):
|
|
261
|
+
if isinstance(value.get("text"), str):
|
|
262
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("text")))
|
|
263
|
+
if isinstance(value.get("content"), (str, list, dict)):
|
|
264
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("content")))
|
|
265
|
+
if isinstance(value.get("message"), dict):
|
|
266
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("message")))
|
|
267
|
+
if isinstance(value.get("payload"), dict):
|
|
268
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
|
|
269
|
+
return fragments
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _jsonl_record_to_handoff_text(record):
|
|
273
|
+
if not isinstance(record, dict):
|
|
274
|
+
return None
|
|
275
|
+
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
276
|
+
role = (
|
|
277
|
+
message.get("role")
|
|
278
|
+
or record.get("role")
|
|
279
|
+
or record.get("type")
|
|
280
|
+
or record.get("sender")
|
|
281
|
+
or "entry"
|
|
282
|
+
)
|
|
283
|
+
text_sources = []
|
|
284
|
+
for key in ("content", "text", "payload"):
|
|
285
|
+
if key in record:
|
|
286
|
+
text_sources.append(record.get(key))
|
|
287
|
+
for key in ("content", "text"):
|
|
288
|
+
if key in message:
|
|
289
|
+
text_sources.append(message.get(key))
|
|
290
|
+
fragments = []
|
|
291
|
+
for value in text_sources:
|
|
292
|
+
fragments.extend(_collect_handoff_text_fragments(value))
|
|
293
|
+
text = "\n".join(fragment for fragment in fragments if fragment).strip()
|
|
294
|
+
if not text:
|
|
295
|
+
return None
|
|
296
|
+
role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
|
|
297
|
+
return f"[{role}]\n{text}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _read_jsonl_handoff_transcript(path):
|
|
301
|
+
entries = []
|
|
302
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
303
|
+
for line in handle:
|
|
304
|
+
line = line.strip()
|
|
305
|
+
if not line:
|
|
306
|
+
continue
|
|
307
|
+
try:
|
|
308
|
+
text = _jsonl_record_to_handoff_text(json.loads(line))
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
text = None
|
|
311
|
+
if text:
|
|
312
|
+
entries.append(text)
|
|
313
|
+
if entries:
|
|
314
|
+
return "\n\n".join(entries)
|
|
198
315
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
199
|
-
|
|
316
|
+
return handle.read()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _read_handoff_transcript(path):
|
|
320
|
+
if path.endswith(".jsonl"):
|
|
321
|
+
content = _read_jsonl_handoff_transcript(path)
|
|
322
|
+
else:
|
|
323
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
324
|
+
content = handle.read()
|
|
200
325
|
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
201
326
|
return content, False
|
|
202
327
|
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
@@ -1437,7 +1562,14 @@ def handle_update(rest, ctx):
|
|
|
1437
1562
|
if parsed["version"] is not None:
|
|
1438
1563
|
target_version = str(parsed["version"]).strip().lstrip("v")
|
|
1439
1564
|
else:
|
|
1440
|
-
|
|
1565
|
+
try:
|
|
1566
|
+
latest = (
|
|
1567
|
+
release_fetcher()
|
|
1568
|
+
if ctx["options"].get("fetchLatestRelease")
|
|
1569
|
+
else fetch_latest_release_or_raise(env=ctx.get("env"))
|
|
1570
|
+
)
|
|
1571
|
+
except LatestReleaseCheckError as error:
|
|
1572
|
+
raise CdxError(str(error)) from error
|
|
1441
1573
|
if not latest:
|
|
1442
1574
|
raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
|
|
1443
1575
|
target_version = str(latest.get("latest_version") or "").strip()
|
|
@@ -1613,9 +1745,9 @@ def handle_handoff(rest, ctx):
|
|
|
1613
1745
|
target = ctx["service"]["get_session"](target_name)
|
|
1614
1746
|
if not target:
|
|
1615
1747
|
raise CdxError(f"Unknown session: {target_name}")
|
|
1616
|
-
transcript_path =
|
|
1748
|
+
transcript_path = _latest_handoff_transcript_path(source)
|
|
1617
1749
|
if not transcript_path:
|
|
1618
|
-
raise CdxError(f"No
|
|
1750
|
+
raise CdxError(f"No transcript found for session: {source_name}")
|
|
1619
1751
|
transcript, truncated = _read_handoff_transcript(transcript_path)
|
|
1620
1752
|
context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
|
|
1621
1753
|
write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
|
package/src/provider_runtime.py
CHANGED
|
@@ -131,7 +131,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
131
131
|
args = args + [transcript_path]
|
|
132
132
|
args = args + [spec["command"]] + spec["args"]
|
|
133
133
|
else:
|
|
134
|
-
args =
|
|
134
|
+
args = _default_script_args(transcript_path, spec)
|
|
135
135
|
return {
|
|
136
136
|
"command": script_bin,
|
|
137
137
|
"args": args,
|
|
@@ -142,6 +142,13 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _default_script_args(transcript_path, spec):
|
|
146
|
+
if sys.platform.startswith("linux"):
|
|
147
|
+
command = shlex.join([spec["command"]] + spec["args"])
|
|
148
|
+
return ["-q", "-F", "-c", command, transcript_path]
|
|
149
|
+
return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
|
|
150
|
+
|
|
151
|
+
|
|
145
152
|
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
146
153
|
if initial_prompt is not None:
|
|
147
154
|
if not isinstance(initial_prompt, str):
|
package/src/update_check.py
CHANGED
|
@@ -9,6 +9,10 @@ UPDATE_CHECK_TTL_SECONDS = 12 * 60 * 60
|
|
|
9
9
|
LATEST_RELEASE_URL = "https://api.github.com/repos/AlexAgo83/cdx-manager/releases/latest"
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class LatestReleaseCheckError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
12
16
|
def _parse_version(value):
|
|
13
17
|
raw = str(value or "").strip().lstrip("v")
|
|
14
18
|
parts = raw.split(".")
|
|
@@ -51,13 +55,22 @@ def _write_cache(path, payload):
|
|
|
51
55
|
handle.write("\n")
|
|
52
56
|
|
|
53
57
|
|
|
54
|
-
def
|
|
58
|
+
def _github_token(env=None):
|
|
59
|
+
env = env or os.environ
|
|
60
|
+
return env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fetch_latest_release(env=None):
|
|
64
|
+
headers = {
|
|
65
|
+
"Accept": "application/vnd.github+json",
|
|
66
|
+
"User-Agent": "cdx-manager-update-check",
|
|
67
|
+
}
|
|
68
|
+
token = _github_token(env)
|
|
69
|
+
if token:
|
|
70
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
55
71
|
request = urllib.request.Request(
|
|
56
72
|
LATEST_RELEASE_URL,
|
|
57
|
-
headers=
|
|
58
|
-
"Accept": "application/vnd.github+json",
|
|
59
|
-
"User-Agent": "cdx-manager-update-check",
|
|
60
|
-
},
|
|
73
|
+
headers=headers,
|
|
61
74
|
)
|
|
62
75
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
63
76
|
payload = json.loads(response.read().decode("utf-8"))
|
|
@@ -67,13 +80,39 @@ def _fetch_latest_release():
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
|
|
70
|
-
def
|
|
83
|
+
def _format_fetch_error(error):
|
|
84
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
85
|
+
if error.code == 403:
|
|
86
|
+
return (
|
|
87
|
+
"GitHub API rate limit reached while checking the latest cdx-manager release. "
|
|
88
|
+
"Try again later, set GH_TOKEN or GITHUB_TOKEN, or run cdx update --version <version>."
|
|
89
|
+
)
|
|
90
|
+
if error.code == 404:
|
|
91
|
+
return "Unable to find the latest cdx-manager release on GitHub."
|
|
92
|
+
return f"GitHub returned HTTP {error.code} while checking the latest cdx-manager release."
|
|
93
|
+
if isinstance(error, urllib.error.URLError):
|
|
94
|
+
reason = getattr(error, "reason", None)
|
|
95
|
+
suffix = f" ({reason})" if reason else ""
|
|
96
|
+
return f"Unable to reach GitHub while checking the latest cdx-manager release{suffix}."
|
|
97
|
+
if isinstance(error, TimeoutError):
|
|
98
|
+
return "Timed out while checking the latest cdx-manager release."
|
|
99
|
+
return "Unable to check for the latest cdx-manager release."
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_latest_release(env=None):
|
|
71
103
|
try:
|
|
72
|
-
return _fetch_latest_release()
|
|
104
|
+
return _fetch_latest_release(env=env)
|
|
73
105
|
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
74
106
|
return None
|
|
75
107
|
|
|
76
108
|
|
|
109
|
+
def fetch_latest_release_or_raise(env=None):
|
|
110
|
+
try:
|
|
111
|
+
return _fetch_latest_release(env=env)
|
|
112
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error:
|
|
113
|
+
raise LatestReleaseCheckError(_format_fetch_error(error)) from error
|
|
114
|
+
|
|
115
|
+
|
|
77
116
|
def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
78
117
|
env = env or os.environ
|
|
79
118
|
now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
|
|
@@ -94,7 +133,7 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
|
94
133
|
}
|
|
95
134
|
return None
|
|
96
135
|
|
|
97
|
-
latest = fetch_latest_release()
|
|
136
|
+
latest = fetch_latest_release(env=env)
|
|
98
137
|
if not latest:
|
|
99
138
|
return None
|
|
100
139
|
|