cdx-manager 0.6.0 → 0.6.2
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 +26 -10
- package/changelogs/CHANGELOGS_0_6_1.md +30 -0
- package/changelogs/CHANGELOGS_0_6_2.md +42 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +42 -2
- package/src/cli.py +10 -5
- package/src/cli_commands.py +335 -37
- package/src/cli_render.py +1 -1
- package/src/config.py +3 -1
- package/src/provider_runtime.py +81 -2
- package/src/session_service.py +37 -2
- package/src/update_check.py +47 -8
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
|
-
**Run multiple Codex and
|
|
5
|
+
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
7
7
|
If you use AI coding tools at scale ; multiple accounts, multiple providers : you know the friction: re-authenticating, losing context, juggling environment variables. `cdx` removes all of that.
|
|
8
8
|
|
|
@@ -32,7 +32,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
32
32
|
|
|
33
33
|
## What it does
|
|
34
34
|
|
|
35
|
-
- **Multiple
|
|
35
|
+
- **Multiple providers, one tool.** Register as many Codex, Claude, Antigravity, or Ollama sessions as you need. Codex and Claude get isolated auth environments; Antigravity is launchable through `agy` with OS-keyring auth; Ollama runs local models through `ollama run`.
|
|
36
36
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
37
37
|
- **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
|
|
38
38
|
- **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
|
|
@@ -54,7 +54,9 @@ One command to launch any session. Zero auth juggling.
|
|
|
54
54
|
- Python 3.9+, zero runtime dependencies.
|
|
55
55
|
- Environment isolation per session:
|
|
56
56
|
- Codex sessions override `CODEX_HOME` to a dedicated profile directory.
|
|
57
|
-
- Claude sessions override `HOME` to a dedicated profile directory.
|
|
57
|
+
- Claude sessions override `HOME` to a dedicated profile directory and disable Claude Code commit co-author attribution by default.
|
|
58
|
+
- Antigravity sessions launch `agy` with a dedicated `HOME` for file-based settings; Google account credentials may still be stored in the OS keyring by Antigravity itself.
|
|
59
|
+
- Ollama sessions use the local Ollama server and launch `ollama run <model>`; set a model with `cdx set <name> --model <model>`.
|
|
58
60
|
- New Codex sessions seed their auth home from your existing global `~/.codex/auth.json` when available, so an already logged-in Codex CLI can be reused without giving up per-session isolation afterward.
|
|
59
61
|
- Persistence:
|
|
60
62
|
- Session registry at `~/.cdx/sessions.json` (versioned JSON store).
|
|
@@ -67,6 +69,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
67
69
|
- Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
|
|
68
70
|
- 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
71
|
- Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
|
|
72
|
+
- On Linux, transcript capture uses the `util-linux` `script -c` command form.
|
|
70
73
|
- If `script` is unavailable, Codex launch falls back to running without transcript capture.
|
|
71
74
|
- On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
|
|
72
75
|
- Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
|
|
@@ -81,7 +84,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
81
84
|
|
|
82
85
|
- Node.js 18+ with npm
|
|
83
86
|
- Python 3.9+
|
|
84
|
-
- `codex` and/or `
|
|
87
|
+
- `codex`, `claude`, `agy`, and/or `ollama` CLI installed and available in your PATH
|
|
85
88
|
|
|
86
89
|
On Windows, the npm launcher looks for Python in this order: `py -3`, `python`, then `python3`. Make sure at least one of those commands resolves to Python 3.
|
|
87
90
|
|
|
@@ -131,7 +134,7 @@ For a specific version:
|
|
|
131
134
|
|
|
132
135
|
```bash
|
|
133
136
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
134
|
-
CDX_VERSION=v0.6.
|
|
137
|
+
CDX_VERSION=v0.6.2 sh install.sh
|
|
135
138
|
```
|
|
136
139
|
|
|
137
140
|
From source:
|
|
@@ -226,6 +229,9 @@ cdx add work
|
|
|
226
229
|
# Register a Claude session
|
|
227
230
|
cdx add claude personal
|
|
228
231
|
|
|
232
|
+
# Register an Ollama session
|
|
233
|
+
cdx add ollama local --model llama3.2
|
|
234
|
+
|
|
229
235
|
# List all sessions
|
|
230
236
|
cdx
|
|
231
237
|
|
|
@@ -255,6 +261,11 @@ By default, `cdx` launches provider CLIs without forcing model effort, permissio
|
|
|
255
261
|
```bash
|
|
256
262
|
cdx set work --power medium --permission full --fast off
|
|
257
263
|
cdx set personal --power low --permission review
|
|
264
|
+
cdx set --sessions all --permission auto
|
|
265
|
+
cdx set --provider ollama --model llama3.2
|
|
266
|
+
cdx power all low
|
|
267
|
+
cdx perm provider:claude review
|
|
268
|
+
cdx model provider:ollama llama3.2
|
|
258
269
|
cdx config work
|
|
259
270
|
```
|
|
260
271
|
|
|
@@ -262,7 +273,11 @@ Those values are stored on the session and reapplied every time you run `cdx wor
|
|
|
262
273
|
|
|
263
274
|
```bash
|
|
264
275
|
cdx unset work --power
|
|
276
|
+
cdx unset --sessions work,personal --fast
|
|
277
|
+
cdx unset --provider claude --permission
|
|
265
278
|
cdx unset work --all
|
|
279
|
+
cdx power all default
|
|
280
|
+
cdx model provider:ollama default
|
|
266
281
|
```
|
|
267
282
|
|
|
268
283
|
`--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set.
|
|
@@ -293,7 +308,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
293
308
|
| `cdx --json` | List all sessions as a machine-readable JSON payload |
|
|
294
309
|
| `cdx <name>` | Launch a session (checks auth first) |
|
|
295
310
|
| `cdx <name> [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
|
|
296
|
-
| `cdx add [provider] <name> [--json]` | Register a new session (`provider`: `codex` or `
|
|
311
|
+
| `cdx add [provider] <name> [--model MODEL] [--json]` | Register a new session (`provider`: `codex`, `claude`, `antigravity`, or `ollama`; Ollama requires `--model`) |
|
|
297
312
|
| `cdx cp <source> <dest> [--json]` | Copy a session into another session name, overwriting the destination if it exists |
|
|
298
313
|
| `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
|
|
299
314
|
| `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
|
|
@@ -301,13 +316,14 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
301
316
|
| `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
|
|
302
317
|
| `cdx enable <name> [--json]` | Re-enable a disabled session |
|
|
303
318
|
| `cdx config <name> [--json]` | Show persistent launch settings for a session |
|
|
304
|
-
| `cdx
|
|
305
|
-
| `cdx
|
|
319
|
+
| `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
|
|
320
|
+
| `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--model MODEL] [--json]` | Persist launch settings for one or more sessions |
|
|
321
|
+
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--model\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
306
322
|
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
307
323
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
308
324
|
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
309
325
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
310
|
-
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports
|
|
326
|
+
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports cross-provider handoff |
|
|
311
327
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
312
328
|
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
313
329
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
@@ -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`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog (`0.6.1 -> 0.6.2`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-29
|
|
4
|
+
|
|
5
|
+
## Provider Support
|
|
6
|
+
|
|
7
|
+
- Added Antigravity provider support through the `agy` CLI with isolated session homes, launch settings, and transcript capture.
|
|
8
|
+
- Added Ollama provider support through `ollama run`, including model-backed session configuration and transcript capture.
|
|
9
|
+
- Added explicit `cdx add ollama <name> --model MODEL` registration so Ollama sessions do not depend on ambiguous session-name model defaults.
|
|
10
|
+
|
|
11
|
+
## Launch Settings
|
|
12
|
+
|
|
13
|
+
- Added bulk launch setting updates for all sessions, provider-filtered sessions, and named session subsets.
|
|
14
|
+
- Added shortcut launch setting commands for power, permission, fast mode, and model changes.
|
|
15
|
+
- Updated help and README command documentation for provider launch settings.
|
|
16
|
+
|
|
17
|
+
## Claude Behavior
|
|
18
|
+
|
|
19
|
+
- Fixed Claude auth reuse and status refresh behavior.
|
|
20
|
+
- Disabled Claude commit attribution by default for managed Claude sessions.
|
|
21
|
+
|
|
22
|
+
## Runtime Reliability
|
|
23
|
+
|
|
24
|
+
- Set `OLLAMA_NOHISTORY=1` for Ollama launch, status, and auth-check operations to avoid local history-file permission failures.
|
|
25
|
+
- Preserved provider-specific launch arguments across the transcript wrapper and fallback paths.
|
|
26
|
+
|
|
27
|
+
## Release Metadata and Documentation
|
|
28
|
+
|
|
29
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.2`.
|
|
30
|
+
|
|
31
|
+
## Validation and Regression Coverage
|
|
32
|
+
|
|
33
|
+
- Added coverage for Antigravity launch specs and auth checks.
|
|
34
|
+
- Added coverage for Ollama session creation, model persistence, launch specs, and no-history environment handling.
|
|
35
|
+
- Added coverage for bulk and shortcut launch setting commands.
|
|
36
|
+
- Added coverage for Claude auth/status behavior and commit-attribution defaults.
|
|
37
|
+
|
|
38
|
+
## Validation and Regression Evidence
|
|
39
|
+
|
|
40
|
+
- `npm run lint`
|
|
41
|
+
- `npm test`
|
|
42
|
+
- `npm pack --dry-run`
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"v0.4.0": {
|
|
9
9
|
"github_tarball_sha256": "47a99ff2663f4fee33339098d2cfe7c27fbd0f74a16a1f6711f3003287409ae8",
|
|
10
10
|
"github_zip_sha256": "ae7eff748e569c621ef203abb6b395fbf893370471e962252e8c781e7751e5c8"
|
|
11
|
+
},
|
|
12
|
+
"v0.6.1": {
|
|
13
|
+
"github_tarball_sha256": "5ae4032894e1806ffb3a713e555c12b2faa3bfa6fd7c9fa468664a8577abe827",
|
|
14
|
+
"github_zip_sha256": "7949e99dcfe21dc4e2b82f720b515aea8f0ac9f2059142032d488bcfbc4dfaf1"
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/claude_usage.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
3
5
|
import urllib.request
|
|
4
6
|
import urllib.error
|
|
5
7
|
from datetime import datetime, timezone
|
|
@@ -9,6 +11,7 @@ from .errors import CdxError
|
|
|
9
11
|
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
10
12
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
11
13
|
CLAUDE_STATUS_PROBE_MODEL = os.environ.get("CDX_CLAUDE_STATUS_MODEL", "claude-haiku-4-5-20251001")
|
|
14
|
+
CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS = 15
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def _read_claude_credentials(auth_home):
|
|
@@ -21,6 +24,40 @@ def _read_claude_credentials(auth_home):
|
|
|
21
24
|
return None
|
|
22
25
|
|
|
23
26
|
|
|
27
|
+
def _home_env_overrides(auth_home):
|
|
28
|
+
overrides = {
|
|
29
|
+
"HOME": auth_home,
|
|
30
|
+
"CLAUDE_CONFIG_DIR": auth_home,
|
|
31
|
+
"CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
|
|
32
|
+
}
|
|
33
|
+
if sys.platform == "win32":
|
|
34
|
+
overrides["USERPROFILE"] = auth_home
|
|
35
|
+
overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
|
|
36
|
+
overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
|
|
37
|
+
return overrides
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
|
|
41
|
+
if not auth_home:
|
|
42
|
+
return
|
|
43
|
+
if os.environ.get("CDX_CLAUDE_SKIP_AUTH_STATUS_REFRESH") == "1":
|
|
44
|
+
return
|
|
45
|
+
runner = runner or subprocess.run
|
|
46
|
+
command = ["claude", "auth", "status"]
|
|
47
|
+
run_env = {**(env or os.environ), **_home_env_overrides(auth_home)}
|
|
48
|
+
try:
|
|
49
|
+
runner(
|
|
50
|
+
command,
|
|
51
|
+
env=run_env,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS,
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
|
|
24
61
|
def _format_reset_date(unix_seconds):
|
|
25
62
|
dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
|
|
26
63
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
@@ -109,8 +146,11 @@ def fetch_claude_rate_limit_headers(access_token):
|
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
|
|
112
|
-
def refresh_claude_session_status(session):
|
|
113
|
-
|
|
149
|
+
def refresh_claude_session_status(session, auth_refresher=None):
|
|
150
|
+
auth_home = session.get("authHome", "")
|
|
151
|
+
refresher = auth_refresher or _refresh_claude_cli_credentials
|
|
152
|
+
refresher(auth_home)
|
|
153
|
+
creds = _read_claude_credentials(auth_home)
|
|
114
154
|
if not creds or not creds.get("accessToken"):
|
|
115
155
|
return None
|
|
116
156
|
return fetch_claude_rate_limit_headers(creds["accessToken"])
|
package/src/cli.py
CHANGED
|
@@ -23,6 +23,7 @@ from .cli_commands import (
|
|
|
23
23
|
handle_launch,
|
|
24
24
|
handle_login,
|
|
25
25
|
handle_logout,
|
|
26
|
+
handle_launch_setting_alias,
|
|
26
27
|
handle_notify,
|
|
27
28
|
handle_remove,
|
|
28
29
|
handle_repair,
|
|
@@ -54,7 +55,7 @@ from .status_view import (
|
|
|
54
55
|
)
|
|
55
56
|
from .update_check import check_for_update
|
|
56
57
|
|
|
57
|
-
VERSION = "0.6.
|
|
58
|
+
VERSION = "0.6.2"
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
# ---------------------------------------------------------------------------
|
|
@@ -73,13 +74,14 @@ def _print_help(use_color=False):
|
|
|
73
74
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
74
75
|
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
75
76
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
76
|
-
f" {_style('cdx
|
|
77
|
-
f" {_style('cdx
|
|
77
|
+
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
78
|
+
f" {_style('cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--json]', '36', use_color)}",
|
|
79
|
+
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--all) [--json]', '36', use_color)}",
|
|
78
80
|
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
79
81
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
80
82
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
81
83
|
f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
|
|
82
|
-
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
84
|
+
f" {_style('cdx add [provider] <name> [--model MODEL] [--json]', '36', use_color)}",
|
|
83
85
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
84
86
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
|
85
87
|
f" {_style('cdx login <name> [--json]', '36', use_color)}",
|
|
@@ -231,7 +233,7 @@ def main(argv, options=None):
|
|
|
231
233
|
"version": VERSION,
|
|
232
234
|
"cwd": options.get("cwd") or os.getcwd(),
|
|
233
235
|
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
234
|
-
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
|
|
236
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "power", "perm", "fast", "model", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
|
|
235
237
|
) else None,
|
|
236
238
|
"use_color": use_color,
|
|
237
239
|
}
|
|
@@ -292,6 +294,9 @@ def main(argv, options=None):
|
|
|
292
294
|
if command == "unset":
|
|
293
295
|
return handle_unset(rest, ctx)
|
|
294
296
|
|
|
297
|
+
if command in ("power", "perm", "fast", "model"):
|
|
298
|
+
return handle_launch_setting_alias(command, rest, ctx)
|
|
299
|
+
|
|
295
300
|
if command == "history":
|
|
296
301
|
return handle_history(rest, ctx)
|
|
297
302
|
|
package/src/cli_commands.py
CHANGED
|
@@ -2,13 +2,14 @@ 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
|
|
8
9
|
|
|
9
10
|
from .claude_refresh import _refresh_claude_sessions
|
|
10
11
|
from .cli_render import _dim, _info, _success, _warn
|
|
11
|
-
from .config import PROVIDER_CODEX
|
|
12
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CODEX, PROVIDER_OLLAMA, PROVIDERS
|
|
12
13
|
from .context_store import (
|
|
13
14
|
clear_context,
|
|
14
15
|
edit_context,
|
|
@@ -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
|
|
|
@@ -49,13 +50,15 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
|
|
|
49
50
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
50
51
|
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
51
52
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
52
|
-
SET_USAGE = "Usage: cdx set <name
|
|
53
|
-
UNSET_USAGE = "Usage: cdx unset <name
|
|
53
|
+
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--model MODEL] [--json]"
|
|
54
|
+
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--model|--all) [--json]"
|
|
55
|
+
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
54
56
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
55
57
|
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
56
58
|
LAST_USAGE = "Usage: cdx last [--json]"
|
|
57
59
|
API_SCHEMA_VERSION = 1
|
|
58
60
|
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
61
|
+
HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def _local_now_iso():
|
|
@@ -194,9 +197,132 @@ def _latest_launch_transcript_path(session):
|
|
|
194
197
|
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
195
198
|
|
|
196
199
|
|
|
197
|
-
def
|
|
200
|
+
def _get_session_home(session):
|
|
201
|
+
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _safe_stat(path):
|
|
205
|
+
try:
|
|
206
|
+
return os.stat(path)
|
|
207
|
+
except OSError:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _sort_recent_paths(paths):
|
|
212
|
+
stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
|
|
213
|
+
return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collect_native_handoff_transcript_paths(session):
|
|
217
|
+
root = _get_session_home(session)
|
|
218
|
+
if not root:
|
|
219
|
+
return []
|
|
220
|
+
candidates = []
|
|
221
|
+
direct = [
|
|
222
|
+
os.path.join(root, "history.jsonl"),
|
|
223
|
+
os.path.join(root, "session_index.jsonl"),
|
|
224
|
+
]
|
|
225
|
+
candidates.extend(path for path in direct if _safe_stat(path))
|
|
226
|
+
|
|
227
|
+
scan_roots = [
|
|
228
|
+
os.path.join(root, "sessions"),
|
|
229
|
+
os.path.join(root, ".claude", "projects"),
|
|
230
|
+
os.path.join(root, "projects"),
|
|
231
|
+
]
|
|
232
|
+
skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
|
|
233
|
+
for scan_root in scan_roots:
|
|
234
|
+
if not os.path.isdir(scan_root):
|
|
235
|
+
continue
|
|
236
|
+
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
237
|
+
dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
|
|
238
|
+
for filename in filenames:
|
|
239
|
+
if filename.endswith(".jsonl") or filename.endswith(".log"):
|
|
240
|
+
candidates.append(os.path.join(dirpath, filename))
|
|
241
|
+
return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _latest_handoff_transcript_path(session):
|
|
245
|
+
launch_path = _latest_launch_transcript_path(session)
|
|
246
|
+
if launch_path:
|
|
247
|
+
return launch_path
|
|
248
|
+
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
249
|
+
return native_paths[0] if native_paths else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _collect_handoff_text_fragments(value):
|
|
253
|
+
fragments = []
|
|
254
|
+
if isinstance(value, str):
|
|
255
|
+
text = value.strip()
|
|
256
|
+
if text:
|
|
257
|
+
fragments.append(text)
|
|
258
|
+
elif isinstance(value, list):
|
|
259
|
+
for item in value:
|
|
260
|
+
fragments.extend(_collect_handoff_text_fragments(item))
|
|
261
|
+
elif isinstance(value, dict):
|
|
262
|
+
if isinstance(value.get("text"), str):
|
|
263
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("text")))
|
|
264
|
+
if isinstance(value.get("content"), (str, list, dict)):
|
|
265
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("content")))
|
|
266
|
+
if isinstance(value.get("message"), dict):
|
|
267
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("message")))
|
|
268
|
+
if isinstance(value.get("payload"), dict):
|
|
269
|
+
fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
|
|
270
|
+
return fragments
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _jsonl_record_to_handoff_text(record):
|
|
274
|
+
if not isinstance(record, dict):
|
|
275
|
+
return None
|
|
276
|
+
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
277
|
+
role = (
|
|
278
|
+
message.get("role")
|
|
279
|
+
or record.get("role")
|
|
280
|
+
or record.get("type")
|
|
281
|
+
or record.get("sender")
|
|
282
|
+
or "entry"
|
|
283
|
+
)
|
|
284
|
+
text_sources = []
|
|
285
|
+
for key in ("content", "text", "payload"):
|
|
286
|
+
if key in record:
|
|
287
|
+
text_sources.append(record.get(key))
|
|
288
|
+
for key in ("content", "text"):
|
|
289
|
+
if key in message:
|
|
290
|
+
text_sources.append(message.get(key))
|
|
291
|
+
fragments = []
|
|
292
|
+
for value in text_sources:
|
|
293
|
+
fragments.extend(_collect_handoff_text_fragments(value))
|
|
294
|
+
text = "\n".join(fragment for fragment in fragments if fragment).strip()
|
|
295
|
+
if not text:
|
|
296
|
+
return None
|
|
297
|
+
role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
|
|
298
|
+
return f"[{role}]\n{text}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _read_jsonl_handoff_transcript(path):
|
|
302
|
+
entries = []
|
|
198
303
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
199
|
-
|
|
304
|
+
for line in handle:
|
|
305
|
+
line = line.strip()
|
|
306
|
+
if not line:
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
text = _jsonl_record_to_handoff_text(json.loads(line))
|
|
310
|
+
except json.JSONDecodeError:
|
|
311
|
+
text = None
|
|
312
|
+
if text:
|
|
313
|
+
entries.append(text)
|
|
314
|
+
if entries:
|
|
315
|
+
return "\n\n".join(entries)
|
|
316
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
317
|
+
return handle.read()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _read_handoff_transcript(path):
|
|
321
|
+
if path.endswith(".jsonl"):
|
|
322
|
+
content = _read_jsonl_handoff_transcript(path)
|
|
323
|
+
else:
|
|
324
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
325
|
+
content = handle.read()
|
|
200
326
|
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
201
327
|
return content, False
|
|
202
328
|
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
@@ -288,17 +414,17 @@ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=
|
|
|
288
414
|
def _parse_add_args(args):
|
|
289
415
|
parsed = _parse_flag_args(
|
|
290
416
|
args,
|
|
291
|
-
{},
|
|
292
|
-
"Usage: cdx add [provider] <name> [--json]",
|
|
417
|
+
{"--model": {"key": "model", "type": "str", "default": None}},
|
|
418
|
+
"Usage: cdx add [provider] <name> [--model MODEL] [--json]",
|
|
293
419
|
positionals_key="values",
|
|
294
420
|
max_positionals=2,
|
|
295
421
|
)
|
|
296
422
|
values = parsed["values"]
|
|
297
423
|
if len(values) == 1:
|
|
298
|
-
return {"provider": PROVIDER_CODEX, "name": values[0]}
|
|
424
|
+
return {"provider": PROVIDER_CODEX, "name": values[0], "model": parsed["model"]}
|
|
299
425
|
if len(values) == 2:
|
|
300
|
-
return {"provider": values[0], "name": values[1]}
|
|
301
|
-
raise CdxError("Usage: cdx add [provider] <name> [--json]")
|
|
426
|
+
return {"provider": values[0], "name": values[1], "model": parsed["model"]}
|
|
427
|
+
raise CdxError("Usage: cdx add [provider] <name> [--model MODEL] [--json]")
|
|
302
428
|
|
|
303
429
|
|
|
304
430
|
def _parse_copy_args(args):
|
|
@@ -345,18 +471,33 @@ def _parse_set_args(args):
|
|
|
345
471
|
"--power": {"key": "power", "type": "str", "default": None},
|
|
346
472
|
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
347
473
|
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
474
|
+
"--model": {"key": "model", "type": "str", "default": None},
|
|
475
|
+
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
476
|
+
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
|
|
348
477
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
349
478
|
}, SET_USAGE, positionals_key="names", max_positionals=1)
|
|
350
|
-
if len(parsed["names"])
|
|
479
|
+
if len(parsed["names"]) > 1:
|
|
480
|
+
raise CdxError(SET_USAGE)
|
|
481
|
+
if parsed["names"] and parsed["sessions"]:
|
|
482
|
+
raise CdxError(SET_USAGE)
|
|
483
|
+
if parsed["names"] and parsed["provider"]:
|
|
484
|
+
raise CdxError(SET_USAGE)
|
|
485
|
+
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
351
486
|
raise CdxError(SET_USAGE)
|
|
352
487
|
settings = {
|
|
353
488
|
key: parsed[key]
|
|
354
|
-
for key in ("power", "permission", "fast")
|
|
489
|
+
for key in ("power", "permission", "fast", "model")
|
|
355
490
|
if parsed[key] is not None
|
|
356
491
|
}
|
|
357
492
|
if not settings:
|
|
358
493
|
raise CdxError(SET_USAGE)
|
|
359
|
-
return {
|
|
494
|
+
return {
|
|
495
|
+
"name": parsed["names"][0] if parsed["names"] else None,
|
|
496
|
+
"sessions": parsed["sessions"],
|
|
497
|
+
"provider": parsed["provider"],
|
|
498
|
+
"settings": settings,
|
|
499
|
+
"json": parsed["json"],
|
|
500
|
+
}
|
|
360
501
|
|
|
361
502
|
|
|
362
503
|
def _parse_unset_args(args):
|
|
@@ -364,17 +505,32 @@ def _parse_unset_args(args):
|
|
|
364
505
|
"--power": {"key": "power", "type": "bool", "default": False},
|
|
365
506
|
"--permission": {"key": "permission", "type": "bool", "default": False},
|
|
366
507
|
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
508
|
+
"--model": {"key": "model", "type": "bool", "default": False},
|
|
367
509
|
"--all": {"key": "all", "type": "bool", "default": False},
|
|
510
|
+
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
511
|
+
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, UNSET_USAGE)},
|
|
368
512
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
369
513
|
}, UNSET_USAGE, positionals_key="names", max_positionals=1)
|
|
370
|
-
if len(parsed["names"])
|
|
514
|
+
if len(parsed["names"]) > 1:
|
|
371
515
|
raise CdxError(UNSET_USAGE)
|
|
372
|
-
|
|
373
|
-
|
|
516
|
+
if parsed["names"] and parsed["sessions"]:
|
|
517
|
+
raise CdxError(UNSET_USAGE)
|
|
518
|
+
if parsed["names"] and parsed["provider"]:
|
|
519
|
+
raise CdxError(UNSET_USAGE)
|
|
520
|
+
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
521
|
+
raise CdxError(UNSET_USAGE)
|
|
522
|
+
keys = ["power", "permission", "fast", "model"] if parsed["all"] else [
|
|
523
|
+
key for key in ("power", "permission", "fast", "model") if parsed[key]
|
|
374
524
|
]
|
|
375
525
|
if not keys:
|
|
376
526
|
raise CdxError(UNSET_USAGE)
|
|
377
|
-
return {
|
|
527
|
+
return {
|
|
528
|
+
"name": parsed["names"][0] if parsed["names"] else None,
|
|
529
|
+
"sessions": parsed["sessions"],
|
|
530
|
+
"provider": parsed["provider"],
|
|
531
|
+
"keys": keys,
|
|
532
|
+
"json": parsed["json"],
|
|
533
|
+
}
|
|
378
534
|
|
|
379
535
|
|
|
380
536
|
def _parse_config_args(args):
|
|
@@ -549,6 +705,49 @@ def _parse_session_names(value):
|
|
|
549
705
|
return names
|
|
550
706
|
|
|
551
707
|
|
|
708
|
+
def _parse_set_unset_sessions(value):
|
|
709
|
+
text = str(value or "").strip()
|
|
710
|
+
if not text:
|
|
711
|
+
raise CdxError("At least one session name is required in --sessions.")
|
|
712
|
+
if text.lower() == "all":
|
|
713
|
+
return "all"
|
|
714
|
+
return _parse_session_names(text)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _parse_provider_filter(value, usage):
|
|
718
|
+
provider = str(value or "").strip()
|
|
719
|
+
if provider not in PROVIDERS:
|
|
720
|
+
raise CdxError(usage)
|
|
721
|
+
return provider
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _parse_launch_setting_target(value, usage):
|
|
725
|
+
text = str(value or "").strip()
|
|
726
|
+
if not text:
|
|
727
|
+
raise CdxError(usage)
|
|
728
|
+
if text == "all":
|
|
729
|
+
return {"name": None, "sessions": "all", "provider": None}
|
|
730
|
+
if text.startswith("provider:"):
|
|
731
|
+
return {"name": None, "sessions": None, "provider": _parse_provider_filter(text.split(":", 1)[1], usage)}
|
|
732
|
+
if "," in text:
|
|
733
|
+
return {"name": None, "sessions": _parse_session_names(text), "provider": None}
|
|
734
|
+
return {"name": text, "sessions": None, "provider": None}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _parse_launch_setting_alias_args(kind, args):
|
|
738
|
+
json_flag, cleaned = _parse_json_flag(args)
|
|
739
|
+
if len(cleaned) != 2:
|
|
740
|
+
raise CdxError(SETTING_ALIAS_USAGE)
|
|
741
|
+
target = _parse_launch_setting_target(cleaned[0], SETTING_ALIAS_USAGE)
|
|
742
|
+
value = cleaned[1]
|
|
743
|
+
field = "permission" if kind == "perm" else kind
|
|
744
|
+
if value == "default":
|
|
745
|
+
return {**target, "keys": [field], "json": json_flag, "alias": kind}
|
|
746
|
+
if field == "fast":
|
|
747
|
+
value = _parse_fast_value(value)
|
|
748
|
+
return {**target, "settings": {field: value}, "json": json_flag, "alias": kind}
|
|
749
|
+
|
|
750
|
+
|
|
552
751
|
def _parse_update_args(args):
|
|
553
752
|
parsed = _parse_flag_args(args, {
|
|
554
753
|
"--check": {"key": "check", "type": "bool", "default": False},
|
|
@@ -641,7 +840,13 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
641
840
|
def handle_add(rest, ctx):
|
|
642
841
|
json_flag, args = _parse_json_flag(rest)
|
|
643
842
|
parsed = _parse_add_args(args)
|
|
843
|
+
if parsed["provider"] == PROVIDER_OLLAMA and not parsed.get("model"):
|
|
844
|
+
raise CdxError("Usage: cdx add ollama <name> --model MODEL [--json]")
|
|
845
|
+
if parsed.get("model") and parsed["provider"] != PROVIDER_OLLAMA:
|
|
846
|
+
raise CdxError("--model is only supported when adding an ollama session.")
|
|
644
847
|
session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
|
|
848
|
+
if parsed.get("model"):
|
|
849
|
+
session = ctx["service"]["set_launch_settings"](parsed["name"], {"model": parsed["model"]})
|
|
645
850
|
message = f"Created session {parsed['name']} ({parsed['provider']})"
|
|
646
851
|
_ensure_session_authentication(
|
|
647
852
|
session,
|
|
@@ -757,9 +962,43 @@ def _format_launch_config(session):
|
|
|
757
962
|
f"power: {launch.get('power') or 'default'}",
|
|
758
963
|
f"permission: {launch.get('permission') or 'default'}",
|
|
759
964
|
f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
|
|
965
|
+
f"model: {launch.get('model') or 'default'}",
|
|
760
966
|
])
|
|
761
967
|
|
|
762
968
|
|
|
969
|
+
def _resolve_bulk_launch_targets(parsed, service):
|
|
970
|
+
sessions = service["list_sessions"]()
|
|
971
|
+
by_name = {session["name"]: session for session in sessions}
|
|
972
|
+
if parsed.get("name"):
|
|
973
|
+
session = by_name.get(parsed["name"])
|
|
974
|
+
if not session:
|
|
975
|
+
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
976
|
+
targets = [session]
|
|
977
|
+
elif parsed.get("sessions") == "all":
|
|
978
|
+
targets = sessions
|
|
979
|
+
elif parsed.get("sessions"):
|
|
980
|
+
targets = []
|
|
981
|
+
for name in parsed["sessions"]:
|
|
982
|
+
session = by_name.get(name)
|
|
983
|
+
if not session:
|
|
984
|
+
raise CdxError(f"Unknown session: {name}")
|
|
985
|
+
targets.append(session)
|
|
986
|
+
else:
|
|
987
|
+
targets = sessions
|
|
988
|
+
if parsed.get("provider"):
|
|
989
|
+
targets = [session for session in targets if session["provider"] == parsed["provider"]]
|
|
990
|
+
if not targets:
|
|
991
|
+
raise CdxError("No sessions matched.")
|
|
992
|
+
return targets
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _format_bulk_launch_summary(sessions):
|
|
996
|
+
names = [session["name"] for session in sessions]
|
|
997
|
+
if len(names) <= 8:
|
|
998
|
+
return ", ".join(names)
|
|
999
|
+
return ", ".join(names[:8]) + f", +{len(names) - 8} more"
|
|
1000
|
+
|
|
1001
|
+
|
|
763
1002
|
def _format_duration_ms(value):
|
|
764
1003
|
if value is None:
|
|
765
1004
|
return "-"
|
|
@@ -877,30 +1116,77 @@ def _format_history(entries, use_color=False):
|
|
|
877
1116
|
])
|
|
878
1117
|
|
|
879
1118
|
|
|
880
|
-
def
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1119
|
+
def _apply_launch_settings(parsed, ctx, action="set"):
|
|
1120
|
+
targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
|
|
1121
|
+
sessions = [
|
|
1122
|
+
ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
|
|
1123
|
+
for session in targets
|
|
1124
|
+
]
|
|
1125
|
+
if len(sessions) == 1:
|
|
1126
|
+
session = sessions[0]
|
|
1127
|
+
message = f"Updated launch settings for {session['name']}"
|
|
1128
|
+
else:
|
|
1129
|
+
message = f"Updated launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
|
|
884
1130
|
if parsed["json"]:
|
|
885
|
-
|
|
1131
|
+
payload = _json_success(
|
|
1132
|
+
action,
|
|
1133
|
+
message,
|
|
1134
|
+
updated_count=len(sessions),
|
|
1135
|
+
session=sessions[0] if len(sessions) == 1 else None,
|
|
1136
|
+
sessions=sessions,
|
|
1137
|
+
launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
|
|
1138
|
+
)
|
|
1139
|
+
_write_json(ctx, payload)
|
|
886
1140
|
return 0
|
|
887
1141
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
888
|
-
|
|
1142
|
+
if len(sessions) == 1:
|
|
1143
|
+
ctx["out"](f"{_format_launch_config(sessions[0])}\n")
|
|
889
1144
|
return 0
|
|
890
1145
|
|
|
891
1146
|
|
|
892
|
-
def
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1147
|
+
def _clear_launch_settings(parsed, ctx, action="unset"):
|
|
1148
|
+
targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
|
|
1149
|
+
sessions = [
|
|
1150
|
+
ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
|
|
1151
|
+
for session in targets
|
|
1152
|
+
]
|
|
1153
|
+
if len(sessions) == 1:
|
|
1154
|
+
session = sessions[0]
|
|
1155
|
+
message = f"Cleared launch settings for {session['name']}"
|
|
1156
|
+
else:
|
|
1157
|
+
message = f"Cleared launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
|
|
896
1158
|
if parsed["json"]:
|
|
897
|
-
|
|
1159
|
+
payload = _json_success(
|
|
1160
|
+
action,
|
|
1161
|
+
message,
|
|
1162
|
+
updated_count=len(sessions),
|
|
1163
|
+
session=sessions[0] if len(sessions) == 1 else None,
|
|
1164
|
+
sessions=sessions,
|
|
1165
|
+
launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
|
|
1166
|
+
)
|
|
1167
|
+
_write_json(ctx, payload)
|
|
898
1168
|
return 0
|
|
899
1169
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
900
|
-
|
|
1170
|
+
if len(sessions) == 1:
|
|
1171
|
+
ctx["out"](f"{_format_launch_config(sessions[0])}\n")
|
|
901
1172
|
return 0
|
|
902
1173
|
|
|
903
1174
|
|
|
1175
|
+
def handle_set(rest, ctx):
|
|
1176
|
+
return _apply_launch_settings(_parse_set_args(rest), ctx)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def handle_unset(rest, ctx):
|
|
1180
|
+
return _clear_launch_settings(_parse_unset_args(rest), ctx)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def handle_launch_setting_alias(kind, rest, ctx):
|
|
1184
|
+
parsed = _parse_launch_setting_alias_args(kind, rest)
|
|
1185
|
+
if parsed.get("settings"):
|
|
1186
|
+
return _apply_launch_settings(parsed, ctx, action=kind)
|
|
1187
|
+
return _clear_launch_settings(parsed, ctx, action=kind)
|
|
1188
|
+
|
|
1189
|
+
|
|
904
1190
|
def handle_config(rest, ctx):
|
|
905
1191
|
parsed = _parse_config_args(rest)
|
|
906
1192
|
session = ctx["service"]["get_session"](parsed["name"])
|
|
@@ -1380,10 +1666,11 @@ def handle_login(rest, ctx):
|
|
|
1380
1666
|
session = ctx["service"]["get_session"](args[0])
|
|
1381
1667
|
if not session:
|
|
1382
1668
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1669
|
+
if session["provider"] not in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
|
|
1670
|
+
_run_interactive_provider_command(
|
|
1671
|
+
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
1672
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
1673
|
+
)
|
|
1387
1674
|
_run_interactive_provider_command(
|
|
1388
1675
|
session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
1389
1676
|
signal_emitter=ctx.get("signal_emitter")
|
|
@@ -1408,6 +1695,10 @@ def handle_logout(rest, ctx):
|
|
|
1408
1695
|
session = ctx["service"]["get_session"](args[0])
|
|
1409
1696
|
if not session:
|
|
1410
1697
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
1698
|
+
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
1699
|
+
raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
|
|
1700
|
+
if session["provider"] == PROVIDER_OLLAMA:
|
|
1701
|
+
raise CdxError("Ollama sessions do not use cdx-managed authentication.")
|
|
1411
1702
|
_run_interactive_provider_command(
|
|
1412
1703
|
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
1413
1704
|
signal_emitter=ctx.get("signal_emitter")
|
|
@@ -1437,7 +1728,14 @@ def handle_update(rest, ctx):
|
|
|
1437
1728
|
if parsed["version"] is not None:
|
|
1438
1729
|
target_version = str(parsed["version"]).strip().lstrip("v")
|
|
1439
1730
|
else:
|
|
1440
|
-
|
|
1731
|
+
try:
|
|
1732
|
+
latest = (
|
|
1733
|
+
release_fetcher()
|
|
1734
|
+
if ctx["options"].get("fetchLatestRelease")
|
|
1735
|
+
else fetch_latest_release_or_raise(env=ctx.get("env"))
|
|
1736
|
+
)
|
|
1737
|
+
except LatestReleaseCheckError as error:
|
|
1738
|
+
raise CdxError(str(error)) from error
|
|
1441
1739
|
if not latest:
|
|
1442
1740
|
raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
|
|
1443
1741
|
target_version = str(latest.get("latest_version") or "").strip()
|
|
@@ -1613,9 +1911,9 @@ def handle_handoff(rest, ctx):
|
|
|
1613
1911
|
target = ctx["service"]["get_session"](target_name)
|
|
1614
1912
|
if not target:
|
|
1615
1913
|
raise CdxError(f"Unknown session: {target_name}")
|
|
1616
|
-
transcript_path =
|
|
1914
|
+
transcript_path = _latest_handoff_transcript_path(source)
|
|
1617
1915
|
if not transcript_path:
|
|
1618
|
-
raise CdxError(f"No
|
|
1916
|
+
raise CdxError(f"No transcript found for session: {source_name}")
|
|
1619
1917
|
transcript, truncated = _read_handoff_transcript(transcript_path)
|
|
1620
1918
|
context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
|
|
1621
1919
|
write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
|
package/src/cli_render.py
CHANGED
|
@@ -145,7 +145,7 @@ def _format_sessions(service, use_color=False):
|
|
|
145
145
|
_style("Next actions:", "1", use_color),
|
|
146
146
|
f" {_style('cdx status', '36', use_color)}",
|
|
147
147
|
f" {_style('cdx ready', '36', use_color)}",
|
|
148
|
-
f" {_style('cdx
|
|
148
|
+
f" {_style('cdx perm all default', '36', use_color)}",
|
|
149
149
|
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
150
150
|
f" {_style('cdx history', '36', use_color)}",
|
|
151
151
|
f" {_style('cdx help', '36', use_color)}",
|
package/src/config.py
CHANGED
|
@@ -3,7 +3,9 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
PROVIDER_CODEX = "codex"
|
|
5
5
|
PROVIDER_CLAUDE = "claude"
|
|
6
|
-
|
|
6
|
+
PROVIDER_ANTIGRAVITY = "antigravity"
|
|
7
|
+
PROVIDER_OLLAMA = "ollama"
|
|
8
|
+
PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE, PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA)
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def get_cdx_home(env=None):
|
package/src/provider_runtime.py
CHANGED
|
@@ -7,7 +7,7 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
|
|
10
|
-
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
10
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
11
11
|
from .errors import CdxError
|
|
12
12
|
|
|
13
13
|
|
|
@@ -35,6 +35,19 @@ def _home_env_overrides(auth_home):
|
|
|
35
35
|
directory via USERPROFILE (and falls back to HOMEDRIVE+HOMEPATH), so we
|
|
36
36
|
set all three to ensure profile isolation works regardless of the platform.
|
|
37
37
|
"""
|
|
38
|
+
overrides = {
|
|
39
|
+
"HOME": auth_home,
|
|
40
|
+
"CLAUDE_CONFIG_DIR": auth_home,
|
|
41
|
+
"CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
|
|
42
|
+
}
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
overrides["USERPROFILE"] = auth_home
|
|
45
|
+
overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
|
|
46
|
+
overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
|
|
47
|
+
return overrides
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _antigravity_env_overrides(auth_home):
|
|
38
51
|
overrides = {"HOME": auth_home}
|
|
39
52
|
if sys.platform == "win32":
|
|
40
53
|
overrides["USERPROFILE"] = auth_home
|
|
@@ -85,6 +98,18 @@ def _launch_config_args(session):
|
|
|
85
98
|
if permission:
|
|
86
99
|
args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
|
|
87
100
|
return args
|
|
101
|
+
if provider == PROVIDER_ANTIGRAVITY:
|
|
102
|
+
if permission == "review":
|
|
103
|
+
args += ["--sandbox"]
|
|
104
|
+
if permission == "full":
|
|
105
|
+
args += ["--dangerously-skip-permissions"]
|
|
106
|
+
return args
|
|
107
|
+
if provider == PROVIDER_OLLAMA:
|
|
108
|
+
if power:
|
|
109
|
+
args += ["--think", power if power in ("low", "medium", "high") else "high"]
|
|
110
|
+
if permission == "full":
|
|
111
|
+
args += ["--experimental-yolo"]
|
|
112
|
+
return args
|
|
88
113
|
if power:
|
|
89
114
|
args += ["-c", f'model_reasoning_effort="{power}"']
|
|
90
115
|
if permission:
|
|
@@ -131,7 +156,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
131
156
|
args = args + [transcript_path]
|
|
132
157
|
args = args + [spec["command"]] + spec["args"]
|
|
133
158
|
else:
|
|
134
|
-
args =
|
|
159
|
+
args = _default_script_args(transcript_path, spec)
|
|
135
160
|
return {
|
|
136
161
|
"command": script_bin,
|
|
137
162
|
"args": args,
|
|
@@ -142,6 +167,13 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
|
|
170
|
+
def _default_script_args(transcript_path, spec):
|
|
171
|
+
if sys.platform.startswith("linux"):
|
|
172
|
+
command = shlex.join([spec["command"]] + spec["args"])
|
|
173
|
+
return ["-q", "-F", "-c", command, transcript_path]
|
|
174
|
+
return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
|
|
175
|
+
|
|
176
|
+
|
|
145
177
|
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
146
178
|
if initial_prompt is not None:
|
|
147
179
|
if not isinstance(initial_prompt, str):
|
|
@@ -164,6 +196,35 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
164
196
|
},
|
|
165
197
|
"label": "claude",
|
|
166
198
|
}, env=env)
|
|
199
|
+
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
200
|
+
args = _launch_config_args(session)
|
|
201
|
+
if initial_prompt:
|
|
202
|
+
args += ["--prompt-interactive", initial_prompt]
|
|
203
|
+
return _wrap_launch_with_transcript(session, {
|
|
204
|
+
"command": "agy",
|
|
205
|
+
"args": args,
|
|
206
|
+
"options": {
|
|
207
|
+
"cwd": cwd,
|
|
208
|
+
"env": {**env, **_antigravity_env_overrides(_get_auth_home(session))},
|
|
209
|
+
},
|
|
210
|
+
"label": "antigravity",
|
|
211
|
+
}, env=env)
|
|
212
|
+
if session["provider"] == PROVIDER_OLLAMA:
|
|
213
|
+
launch = session.get("launch") or {}
|
|
214
|
+
model = launch.get("model") or session["name"]
|
|
215
|
+
ollama_env = {**env, "OLLAMA_NOHISTORY": "1"}
|
|
216
|
+
args = ["run", model] + _launch_config_args(session)
|
|
217
|
+
if initial_prompt:
|
|
218
|
+
args.append(initial_prompt)
|
|
219
|
+
return _wrap_launch_with_transcript(session, {
|
|
220
|
+
"command": "ollama",
|
|
221
|
+
"args": args,
|
|
222
|
+
"options": {
|
|
223
|
+
"cwd": cwd,
|
|
224
|
+
"env": ollama_env,
|
|
225
|
+
},
|
|
226
|
+
"label": "ollama",
|
|
227
|
+
}, env=env)
|
|
167
228
|
args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
|
|
168
229
|
if initial_prompt:
|
|
169
230
|
args.append(initial_prompt)
|
|
@@ -190,6 +251,13 @@ def _build_login_status_spec(session, env_override=None):
|
|
|
190
251
|
|
|
191
252
|
return {"command": "claude", "args": ["auth", "status"], "env": env,
|
|
192
253
|
"parser": parser, "label": "claude auth status"}
|
|
254
|
+
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
255
|
+
env.update(_antigravity_env_overrides(_get_auth_home(session)))
|
|
256
|
+
return {"command": "agy", "args": ["--version"], "env": env,
|
|
257
|
+
"parser": lambda _output: True, "label": "antigravity cli"}
|
|
258
|
+
if session["provider"] == PROVIDER_OLLAMA:
|
|
259
|
+
return {"command": "ollama", "args": ["--version"], "env": {**env, "OLLAMA_NOHISTORY": "1"},
|
|
260
|
+
"parser": lambda _output: True, "label": "ollama cli"}
|
|
193
261
|
env["CODEX_HOME"] = _get_auth_home(session)
|
|
194
262
|
|
|
195
263
|
def parser(output):
|
|
@@ -208,6 +276,17 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
|
|
|
208
276
|
env.update(_home_env_overrides(_get_auth_home(session)))
|
|
209
277
|
return {"command": "claude", "args": ["auth", action],
|
|
210
278
|
"options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
|
|
279
|
+
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
280
|
+
if action == "logout":
|
|
281
|
+
raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
|
|
282
|
+
env.update(_antigravity_env_overrides(_get_auth_home(session)))
|
|
283
|
+
return {"command": "agy", "args": [],
|
|
284
|
+
"options": {"cwd": cwd, "env": env}, "label": "antigravity"}
|
|
285
|
+
if session["provider"] == PROVIDER_OLLAMA:
|
|
286
|
+
if action == "logout":
|
|
287
|
+
raise CdxError("Ollama sessions do not use cdx-managed authentication.")
|
|
288
|
+
return {"command": "ollama", "args": ["list"],
|
|
289
|
+
"options": {"cwd": cwd, "env": {**env, "OLLAMA_NOHISTORY": "1"}}, "label": "ollama"}
|
|
211
290
|
env["CODEX_HOME"] = _get_auth_home(session)
|
|
212
291
|
return {"command": "codex", "args": [action],
|
|
213
292
|
"options": {"cwd": cwd, "env": env}, "label": f"codex {action}"}
|
package/src/session_service.py
CHANGED
|
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|
|
9
9
|
from urllib.parse import quote
|
|
10
10
|
|
|
11
11
|
from .backup_bundle import decode_bundle, encode_bundle
|
|
12
|
-
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
|
|
12
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
|
|
13
13
|
from .codex_usage import fetch_codex_rate_limits
|
|
14
14
|
from .errors import CdxError
|
|
15
15
|
from .session_store import create_session_store
|
|
@@ -55,6 +55,7 @@ STATUS_CACHE_TTL_SECONDS = 60
|
|
|
55
55
|
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
56
56
|
LAUNCH_POWER_VALUES = {"low", "medium", "high", "xhigh", "max"}
|
|
57
57
|
LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
|
|
58
|
+
MAX_LAUNCH_MODEL_LENGTH = 128
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
def _encode(name):
|
|
@@ -112,6 +113,13 @@ def _normalize_launch_settings(settings):
|
|
|
112
113
|
normalized["fast"] = False
|
|
113
114
|
else:
|
|
114
115
|
raise CdxError(f"Unsupported fast value: {settings['fast']}")
|
|
116
|
+
if "model" in settings and settings["model"] is not None:
|
|
117
|
+
model = str(settings["model"]).strip()
|
|
118
|
+
if not model:
|
|
119
|
+
raise CdxError("Model cannot be empty.")
|
|
120
|
+
if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
|
|
121
|
+
raise CdxError("Model contains unsupported characters.")
|
|
122
|
+
normalized["model"] = model
|
|
115
123
|
return normalized
|
|
116
124
|
|
|
117
125
|
|
|
@@ -322,6 +330,29 @@ def _read_expected_account_email(auth_home):
|
|
|
322
330
|
return None
|
|
323
331
|
|
|
324
332
|
|
|
333
|
+
def _ensure_claude_attribution_disabled(auth_home):
|
|
334
|
+
settings_dir = os.path.join(auth_home, ".claude")
|
|
335
|
+
settings_path = os.path.join(settings_dir, "settings.json")
|
|
336
|
+
try:
|
|
337
|
+
os.makedirs(settings_dir, exist_ok=True)
|
|
338
|
+
with open(settings_path, "r", encoding="utf-8") as handle:
|
|
339
|
+
settings = json.load(handle)
|
|
340
|
+
except FileNotFoundError:
|
|
341
|
+
settings = {}
|
|
342
|
+
except (OSError, json.JSONDecodeError):
|
|
343
|
+
settings = {}
|
|
344
|
+
if not isinstance(settings, dict):
|
|
345
|
+
settings = {}
|
|
346
|
+
settings["includeCoAuthoredBy"] = False
|
|
347
|
+
try:
|
|
348
|
+
with open(settings_path, "w", encoding="utf-8") as handle:
|
|
349
|
+
json.dump(settings, handle, indent=2, sort_keys=True)
|
|
350
|
+
handle.write("\n")
|
|
351
|
+
except OSError:
|
|
352
|
+
return False
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
325
356
|
def create_session_service(options=None):
|
|
326
357
|
if options is None:
|
|
327
358
|
options = {}
|
|
@@ -337,6 +368,8 @@ def create_session_service(options=None):
|
|
|
337
368
|
root = _get_session_root(name)
|
|
338
369
|
if provider == PROVIDER_CLAUDE:
|
|
339
370
|
return os.path.join(root, "claude-home")
|
|
371
|
+
if provider == PROVIDER_ANTIGRAVITY:
|
|
372
|
+
return os.path.join(root, "antigravity-home")
|
|
340
373
|
return root
|
|
341
374
|
|
|
342
375
|
def _normalize_provider(provider):
|
|
@@ -454,6 +487,8 @@ def create_session_service(options=None):
|
|
|
454
487
|
_ensure_private_dir(auth_home)
|
|
455
488
|
if normalized_provider == PROVIDER_CODEX:
|
|
456
489
|
_seed_codex_auth_from_global(auth_home, env=env)
|
|
490
|
+
if normalized_provider == PROVIDER_CLAUDE:
|
|
491
|
+
_ensure_claude_attribution_disabled(auth_home)
|
|
457
492
|
now = _local_now_iso()
|
|
458
493
|
session = {
|
|
459
494
|
"name": name,
|
|
@@ -736,7 +771,7 @@ def create_session_service(options=None):
|
|
|
736
771
|
raise CdxError(f"Unknown session: {name}")
|
|
737
772
|
if not keys:
|
|
738
773
|
raise CdxError("At least one launch setting is required.")
|
|
739
|
-
allowed = {"power", "permission", "fast"}
|
|
774
|
+
allowed = {"power", "permission", "fast", "model"}
|
|
740
775
|
unknown = [key for key in keys if key not in allowed]
|
|
741
776
|
if unknown:
|
|
742
777
|
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
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
|
|