cdx-manager 0.3.2 → 0.3.4
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 +128 -9
- package/bin/cdx +5 -2
- package/changelogs/CHANGELOGS_0_3_3.md +27 -0
- package/changelogs/CHANGELOGS_0_3_4.md +31 -0
- package/install.ps1 +82 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +89 -13
- package/src/cli_commands.py +121 -28
- package/src/health.py +13 -1
- package/src/notify.py +26 -1
- package/src/provider_runtime.py +37 -6
- package/src/session_store.py +30 -13
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ If you use AI coding tools at scale ; multiple accounts, multiple providers : yo
|
|
|
6
6
|
|
|
7
7
|
One command to launch any session. Zero auth juggling.
|
|
8
8
|
|
|
9
|
-
[](LICENSE) ](LICENSE)  
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -16,7 +16,9 @@ One command to launch any session. Zero auth juggling.
|
|
|
16
16
|
- [Technical Overview](#technical-overview)
|
|
17
17
|
- [Getting Started](#getting-started)
|
|
18
18
|
- [All Commands](#all-commands)
|
|
19
|
+
- [JSON Output](#json-output)
|
|
19
20
|
- [Available Scripts](#available-scripts)
|
|
21
|
+
- [Windows Support](#windows-support)
|
|
20
22
|
- [Project Structure](#project-structure)
|
|
21
23
|
- [Data Layout](#data-layout)
|
|
22
24
|
- [Troubleshooting](#troubleshooting)
|
|
@@ -53,6 +55,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
53
55
|
- 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.
|
|
54
56
|
- Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
|
|
55
57
|
- If `script` is unavailable, Codex launch falls back to running without transcript capture.
|
|
58
|
+
- On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
|
|
56
59
|
- Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
|
|
57
60
|
- Signal forwarding: `SIGINT`, `SIGTERM`, and `SIGHUP` are forwarded to the child process and produce clean exit codes.
|
|
58
61
|
- Test stack: Python built-in `unittest` runner with no test framework dependency.
|
|
@@ -87,6 +90,18 @@ With uv:
|
|
|
87
90
|
uv tool install cdx-manager
|
|
88
91
|
```
|
|
89
92
|
|
|
93
|
+
On Windows with PowerShell:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
npm install -g cdx-manager
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
With the standalone PowerShell installer:
|
|
100
|
+
|
|
101
|
+
```powershell
|
|
102
|
+
irm https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.ps1 | iex
|
|
103
|
+
```
|
|
104
|
+
|
|
90
105
|
With the standalone GitHub installer:
|
|
91
106
|
|
|
92
107
|
```bash
|
|
@@ -96,7 +111,7 @@ curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.
|
|
|
96
111
|
For a specific version:
|
|
97
112
|
|
|
98
113
|
```bash
|
|
99
|
-
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | CDX_VERSION=v0.3.
|
|
114
|
+
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | CDX_VERSION=v0.3.4 sh
|
|
100
115
|
```
|
|
101
116
|
|
|
102
117
|
From source:
|
|
@@ -107,6 +122,14 @@ cd cdx-manager
|
|
|
107
122
|
make install
|
|
108
123
|
```
|
|
109
124
|
|
|
125
|
+
From source on Windows:
|
|
126
|
+
|
|
127
|
+
```powershell
|
|
128
|
+
git clone <repo>
|
|
129
|
+
cd cdx-manager
|
|
130
|
+
npm install -g .
|
|
131
|
+
```
|
|
132
|
+
|
|
110
133
|
`cdx` is now available globally. Changes to the source take effect immediately — no reinstall needed.
|
|
111
134
|
|
|
112
135
|
To uninstall:
|
|
@@ -115,6 +138,12 @@ To uninstall:
|
|
|
115
138
|
make uninstall
|
|
116
139
|
```
|
|
117
140
|
|
|
141
|
+
To uninstall on Windows after `npm install -g`:
|
|
142
|
+
|
|
143
|
+
```powershell
|
|
144
|
+
npm uninstall -g cdx-manager
|
|
145
|
+
```
|
|
146
|
+
|
|
118
147
|
Alternatively, for a non-symlinked global source install:
|
|
119
148
|
|
|
120
149
|
```bash
|
|
@@ -137,6 +166,24 @@ export CDX_SCRIPT_BIN=script
|
|
|
137
166
|
export CDX_SCRIPT_ARGS='-q -F {transcript}'
|
|
138
167
|
```
|
|
139
168
|
|
|
169
|
+
PowerShell equivalents:
|
|
170
|
+
|
|
171
|
+
```powershell
|
|
172
|
+
$env:CDX_HOME = "C:\cdx-data"
|
|
173
|
+
$env:CDX_CLAUDE_STATUS_MODEL = "claude-haiku-4-5-20251001"
|
|
174
|
+
$env:CDX_SCRIPT_BIN = "script"
|
|
175
|
+
$env:CDX_SCRIPT_ARGS = "-q -F {transcript}"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Command Prompt equivalents:
|
|
179
|
+
|
|
180
|
+
```cmd
|
|
181
|
+
set CDX_HOME=C:\cdx-data
|
|
182
|
+
set CDX_CLAUDE_STATUS_MODEL=claude-haiku-4-5-20251001
|
|
183
|
+
set CDX_SCRIPT_BIN=script
|
|
184
|
+
set CDX_SCRIPT_ARGS=-q -F {transcript}
|
|
185
|
+
```
|
|
186
|
+
|
|
140
187
|
### Quick Start
|
|
141
188
|
|
|
142
189
|
```bash
|
|
@@ -163,14 +210,16 @@ cdx status
|
|
|
163
210
|
| Command | Description |
|
|
164
211
|
|---|---|
|
|
165
212
|
| `cdx` | List all sessions with last-updated timestamps |
|
|
213
|
+
| `cdx --json` | List all sessions as a machine-readable JSON payload |
|
|
166
214
|
| `cdx <name>` | Launch a session (checks auth first) |
|
|
167
|
-
| `cdx
|
|
168
|
-
| `cdx
|
|
169
|
-
| `cdx
|
|
170
|
-
| `cdx
|
|
171
|
-
| `cdx
|
|
172
|
-
| `cdx
|
|
173
|
-
| `cdx
|
|
215
|
+
| `cdx <name> [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
|
|
216
|
+
| `cdx add [provider] <name> [--json]` | Register a new session (`provider`: `codex` or `claude`, default: `codex`) |
|
|
217
|
+
| `cdx cp <source> <dest> [--json]` | Copy a session into another session name, overwriting the destination if it exists |
|
|
218
|
+
| `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
|
|
219
|
+
| `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
|
|
220
|
+
| `cdx logout <name> [--json]` | Log out of a session |
|
|
221
|
+
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
222
|
+
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
174
223
|
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
175
224
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
176
225
|
| `cdx notify <name> --at-reset [--poll seconds] [--once]` | Wait for a session reset time and send a desktop notification when due |
|
|
@@ -183,6 +232,57 @@ cdx status
|
|
|
183
232
|
|
|
184
233
|
---
|
|
185
234
|
|
|
235
|
+
## JSON Output
|
|
236
|
+
|
|
237
|
+
`cdx-manager` can be consumed by other apps through its CLI JSON contract.
|
|
238
|
+
|
|
239
|
+
Commands with machine-readable output:
|
|
240
|
+
|
|
241
|
+
- `cdx --json`
|
|
242
|
+
- `cdx status --json`
|
|
243
|
+
- `cdx status <name> --json`
|
|
244
|
+
- `cdx add ... --json`
|
|
245
|
+
- `cdx cp ... --json`
|
|
246
|
+
- `cdx ren ... --json`
|
|
247
|
+
- `cdx rmv ... --json`
|
|
248
|
+
- `cdx clean ... --json`
|
|
249
|
+
- `cdx login ... --json`
|
|
250
|
+
- `cdx logout ... --json`
|
|
251
|
+
- `cdx doctor --json`
|
|
252
|
+
- `cdx repair --json`
|
|
253
|
+
- `cdx notify ... --json`
|
|
254
|
+
|
|
255
|
+
Success payloads follow a shared envelope:
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"ok": true,
|
|
260
|
+
"action": "add",
|
|
261
|
+
"message": "Created session work (codex)",
|
|
262
|
+
"warnings": [],
|
|
263
|
+
"session": {
|
|
264
|
+
"name": "work"
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Errors use a shared stderr JSON envelope whenever `--json` is present:
|
|
270
|
+
|
|
271
|
+
```json
|
|
272
|
+
{
|
|
273
|
+
"ok": false,
|
|
274
|
+
"error": {
|
|
275
|
+
"code": "invalid_usage",
|
|
276
|
+
"message": "Usage: cdx status [--json] [--refresh] | ...",
|
|
277
|
+
"exit_code": 1
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
This makes `cdx-manager` usable from editor plugins, scripts, and desktop apps without scraping human-readable terminal output.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
186
286
|
## Available Scripts
|
|
187
287
|
|
|
188
288
|
- `npm test`: run the Python test suite
|
|
@@ -193,6 +293,24 @@ cdx status
|
|
|
193
293
|
|
|
194
294
|
---
|
|
195
295
|
|
|
296
|
+
## Windows Support
|
|
297
|
+
|
|
298
|
+
- Supported install paths on Windows:
|
|
299
|
+
- `npm install -g cdx-manager`
|
|
300
|
+
- `pipx install cdx-manager`
|
|
301
|
+
- `uv tool install cdx-manager`
|
|
302
|
+
- `install.ps1`
|
|
303
|
+
- `install.sh` is Unix-only.
|
|
304
|
+
- `make install` and `make uninstall` are Unix-oriented convenience commands, not the default Windows path.
|
|
305
|
+
- `cdx` isolates Claude sessions on Windows by setting `HOME`, `USERPROFILE`, `HOMEDRIVE`, and `HOMEPATH`.
|
|
306
|
+
- Desktop notifications use PowerShell on Windows.
|
|
307
|
+
- Codex transcript capture is optional on Windows:
|
|
308
|
+
- if a compatible `script` command is available and exposed via `CDX_SCRIPT_BIN`, `cdx` uses it
|
|
309
|
+
- otherwise Codex launches without transcript capture and the session still works normally
|
|
310
|
+
- `cdx doctor` reports the transcript-capture fallback explicitly so missing `script` on Windows is visible without being treated as a hard failure.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
196
314
|
## Project Structure
|
|
197
315
|
|
|
198
316
|
```text
|
|
@@ -255,6 +373,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
|
|
|
255
373
|
- **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
|
|
256
374
|
- **`cdx login` hangs** — the provider's login flow requires a browser or device code. Follow the on-screen instructions in the terminal that opened.
|
|
257
375
|
- **`make install` says `npm link` is not found** — ensure Node.js and npm are installed and in your PATH.
|
|
376
|
+
- **On Windows, `doctor` warns that `script` is missing** — this is expected on many setups. Codex still launches, but transcript capture stays disabled unless you point `CDX_SCRIPT_BIN` to a compatible wrapper.
|
|
258
377
|
|
|
259
378
|
---
|
|
260
379
|
|
package/bin/cdx
CHANGED
|
@@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
8
8
|
if str(ROOT) not in sys.path:
|
|
9
9
|
sys.path.insert(0, str(ROOT))
|
|
10
10
|
|
|
11
|
-
from src.cli import format_error, main # noqa: E402
|
|
11
|
+
from src.cli import format_error, format_json_error, main, wants_json # noqa: E402
|
|
12
12
|
from src.errors import CdxError # noqa: E402
|
|
13
13
|
|
|
14
14
|
|
|
@@ -16,5 +16,8 @@ if __name__ == "__main__":
|
|
|
16
16
|
try:
|
|
17
17
|
raise SystemExit(main(sys.argv[1:]))
|
|
18
18
|
except CdxError as error:
|
|
19
|
-
sys.
|
|
19
|
+
if wants_json(sys.argv[1:]):
|
|
20
|
+
sys.stderr.write(f"{format_json_error(error)}\n")
|
|
21
|
+
else:
|
|
22
|
+
sys.stderr.write(f"{format_error(error)}\n")
|
|
20
23
|
raise SystemExit(error.exit_code)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CHANGELOGS_0_3_3
|
|
2
|
+
|
|
3
|
+
Release date: 2026-04-16
|
|
4
|
+
|
|
5
|
+
## CDX Manager 0.3.3
|
|
6
|
+
|
|
7
|
+
CDX Manager 0.3.3 adds Windows compatibility across the full codebase.
|
|
8
|
+
|
|
9
|
+
### Windows support
|
|
10
|
+
|
|
11
|
+
- **Session store locking**: replaced `fcntl.flock` (Unix-only) with `msvcrt.locking` on Windows, with `seek(0)` to ensure consistent byte-range locking.
|
|
12
|
+
- **Signal handling**: guarded `signal.SIGHUP` references behind `hasattr` checks — `SIGHUP` does not exist on Windows.
|
|
13
|
+
- **Profile isolation**: added `_home_env_overrides()` helper that sets `USERPROFILE`, `HOMEDRIVE`, and `HOMEPATH` in addition to `HOME` when launching the `claude` CLI on Windows, so Node.js `os.homedir()` resolves to the correct session profile.
|
|
14
|
+
- **Desktop notifications**: `cdx notify` now sends a notification via PowerShell `System.Windows.Forms.MessageBox` on Windows (falls back silently if PowerShell is unavailable).
|
|
15
|
+
- **ANSI colors**: `cli_entry` enables VT processing via `ctypes.windll.kernel32.SetConsoleMode` on Windows so color output works in terminals that support it.
|
|
16
|
+
- **Console encoding**: `cli_entry` reconfigures `stdout`/`stderr` to UTF-8 on Windows to prevent `UnicodeEncodeError` on non-ASCII session names.
|
|
17
|
+
|
|
18
|
+
### Maintenance
|
|
19
|
+
|
|
20
|
+
- Expanded `.gitignore` with standard Python build artifacts (`__pycache__/`, `*.egg-info/`, `dist/`, `build/`), virtual environments, coverage output, and OS-specific files (`.DS_Store`, `Thumbs.db`, `desktop.ini`).
|
|
21
|
+
|
|
22
|
+
### Validation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm run lint
|
|
26
|
+
npm test
|
|
27
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# CHANGELOGS_0_3_4
|
|
2
|
+
|
|
3
|
+
Release date: 2026-04-16
|
|
4
|
+
|
|
5
|
+
## CDX Manager 0.3.4
|
|
6
|
+
|
|
7
|
+
CDX Manager 0.3.4 makes the CLI consumable by other applications through a structured JSON contract and rounds out the Windows release surface.
|
|
8
|
+
|
|
9
|
+
### JSON CLI API
|
|
10
|
+
|
|
11
|
+
- Added `cdx --json` to list known sessions as a machine-readable payload.
|
|
12
|
+
- Added `--json` support for session-management commands: `add`, `cp`, `ren`, `rmv`, `clean`, `login`, and `logout`.
|
|
13
|
+
- Added a shared success envelope for JSON responses with `ok`, `action`, `message`, and `warnings`.
|
|
14
|
+
- Added a shared stderr error envelope for JSON mode with machine-readable `code`, `message`, and `exit_code`.
|
|
15
|
+
- Documented the JSON contract in the README so editor plugins and desktop apps can integrate without scraping human-readable terminal output.
|
|
16
|
+
|
|
17
|
+
### Windows release hardening
|
|
18
|
+
|
|
19
|
+
- Added a native `install.ps1` installer for Windows.
|
|
20
|
+
- Documented supported Windows install paths and the optional transcript-capture fallback.
|
|
21
|
+
- Added targeted `win32` unit coverage for CLI startup, provider environment isolation, notifications, and session-store locking.
|
|
22
|
+
- Added a Windows CI smoke flow that installs the package and exercises core CLI commands with shimmed providers.
|
|
23
|
+
|
|
24
|
+
### Validation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm run lint
|
|
28
|
+
npm test
|
|
29
|
+
npm_config_cache=/tmp/cdx-npm-cache npm pack --dry-run
|
|
30
|
+
python3 logics/skills/logics.py lint --require-status
|
|
31
|
+
```
|
package/install.ps1
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Version = $env:CDX_VERSION,
|
|
3
|
+
[string]$Prefix = $env:CDX_PREFIX
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
|
|
8
|
+
$repo = "AlexAgo83/cdx-manager"
|
|
9
|
+
|
|
10
|
+
function Require-Command {
|
|
11
|
+
param([string]$Name)
|
|
12
|
+
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
|
13
|
+
throw "cdx install: missing required command: $Name"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Require-Command python
|
|
18
|
+
|
|
19
|
+
if (-not $Prefix) {
|
|
20
|
+
$Prefix = Join-Path $env:LOCALAPPDATA "cdx-manager"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
$binDir = Join-Path $Prefix "bin"
|
|
24
|
+
$installRoot = Join-Path $Prefix "versions"
|
|
25
|
+
|
|
26
|
+
if (-not $Version) {
|
|
27
|
+
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest"
|
|
28
|
+
$Version = $release.tag_name
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if ($Version.StartsWith("v")) {
|
|
32
|
+
$tag = $Version
|
|
33
|
+
} else {
|
|
34
|
+
$tag = "v$Version"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
$tmpRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("cdx-install-" + [guid]::NewGuid().ToString("N"))
|
|
38
|
+
$archivePath = Join-Path $tmpRoot "cdx-manager.zip"
|
|
39
|
+
$extractRoot = Join-Path $tmpRoot "extract"
|
|
40
|
+
$targetDir = Join-Path $installRoot $tag.TrimStart("v")
|
|
41
|
+
$archiveUrl = "https://github.com/$repo/archive/refs/tags/$tag.zip"
|
|
42
|
+
|
|
43
|
+
New-Item -ItemType Directory -Force -Path $tmpRoot, $extractRoot, $binDir, $installRoot | Out-Null
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath
|
|
47
|
+
Expand-Archive -Path $archivePath -DestinationPath $extractRoot -Force
|
|
48
|
+
|
|
49
|
+
$sourceDir = Get-ChildItem -Path $extractRoot -Directory | Select-Object -First 1
|
|
50
|
+
if (-not $sourceDir) {
|
|
51
|
+
throw "cdx install: failed to extract release archive"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Test-Path $targetDir) {
|
|
55
|
+
Remove-Item -Recurse -Force $targetDir
|
|
56
|
+
}
|
|
57
|
+
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
|
|
58
|
+
Copy-Item -Path (Join-Path $sourceDir.FullName "*") -Destination $targetDir -Recurse -Force
|
|
59
|
+
|
|
60
|
+
$launcherPath = Join-Path $binDir "cdx.cmd"
|
|
61
|
+
$launcher = @"
|
|
62
|
+
@echo off
|
|
63
|
+
set SCRIPT=%~dp0..\versions\${($tag.TrimStart("v"))}\bin\cdx
|
|
64
|
+
where py >nul 2>nul
|
|
65
|
+
if %ERRORLEVEL%==0 (
|
|
66
|
+
py -3 "%SCRIPT%" %*
|
|
67
|
+
exit /b %ERRORLEVEL%
|
|
68
|
+
)
|
|
69
|
+
python "%SCRIPT%" %*
|
|
70
|
+
"@
|
|
71
|
+
Set-Content -Path $launcherPath -Value $launcher -Encoding ascii
|
|
72
|
+
|
|
73
|
+
Write-Host "Installed cdx $tag to $targetDir"
|
|
74
|
+
Write-Host "Created launcher $launcherPath"
|
|
75
|
+
if (-not (($env:PATH -split ";") -contains $binDir)) {
|
|
76
|
+
Write-Warning "Add $binDir to PATH to run cdx from anywhere."
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
if (Test-Path $tmpRoot) {
|
|
80
|
+
Remove-Item -Recurse -Force $tmpRoot
|
|
81
|
+
}
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Terminal session manager for Codex and Claude accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexandre Agostini",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"changelogs",
|
|
29
29
|
"src",
|
|
30
30
|
"install.sh",
|
|
31
|
+
"install.ps1",
|
|
31
32
|
"pyproject.toml",
|
|
32
33
|
"README.md",
|
|
33
34
|
"LICENSE"
|
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
5
6
|
|
|
@@ -39,7 +40,7 @@ from .status_view import (
|
|
|
39
40
|
_format_status_rows,
|
|
40
41
|
)
|
|
41
42
|
|
|
42
|
-
VERSION = "0.3.
|
|
43
|
+
VERSION = "0.3.4"
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
# ---------------------------------------------------------------------------
|
|
@@ -52,21 +53,22 @@ def _print_help(use_color=False):
|
|
|
52
53
|
"",
|
|
53
54
|
_style("Usage:", "1", use_color),
|
|
54
55
|
f" {_style('cdx', '36', use_color)}",
|
|
56
|
+
f" {_style('cdx --json', '36', use_color)}",
|
|
55
57
|
f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
|
|
56
58
|
f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
|
|
57
59
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
58
|
-
f" {_style('cdx add [provider] <name>', '36', use_color)}",
|
|
59
|
-
f" {_style('cdx cp <source> <dest>', '36', use_color)}",
|
|
60
|
-
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
61
|
-
f" {_style('cdx login <name>', '36', use_color)}",
|
|
62
|
-
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
63
|
-
f" {_style('cdx rmv <name> [--force]', '36', use_color)}",
|
|
64
|
-
f" {_style('cdx clean [name]', '36', use_color)}",
|
|
60
|
+
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
61
|
+
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
62
|
+
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
|
63
|
+
f" {_style('cdx login <name> [--json]', '36', use_color)}",
|
|
64
|
+
f" {_style('cdx logout <name> [--json]', '36', use_color)}",
|
|
65
|
+
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
66
|
+
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
65
67
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
66
68
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
67
|
-
f" {_style('cdx notify <name> --at-reset', '36', use_color)}",
|
|
68
|
-
f" {_style('cdx notify --next-ready', '36', use_color)}",
|
|
69
|
-
f" {_style('cdx <name>', '36', use_color)}",
|
|
69
|
+
f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
|
|
70
|
+
f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
|
|
71
|
+
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
70
72
|
f" {_style('cdx --help', '36', use_color)}",
|
|
71
73
|
f" {_style('cdx --version', '36', use_color)}",
|
|
72
74
|
])
|
|
@@ -76,6 +78,33 @@ def _print_version():
|
|
|
76
78
|
return VERSION
|
|
77
79
|
|
|
78
80
|
|
|
81
|
+
def wants_json(argv):
|
|
82
|
+
return "--json" in argv
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_json_error(error):
|
|
86
|
+
message = str(error)
|
|
87
|
+
code = "cdx_error"
|
|
88
|
+
if message.startswith("Usage:"):
|
|
89
|
+
code = "invalid_usage"
|
|
90
|
+
elif message.startswith("Unknown session:"):
|
|
91
|
+
code = "unknown_session"
|
|
92
|
+
elif message.startswith("Unknown command:"):
|
|
93
|
+
code = "unknown_command"
|
|
94
|
+
elif message.startswith("Session already exists:"):
|
|
95
|
+
code = "session_exists"
|
|
96
|
+
elif "requires an interactive terminal" in message or "requires confirmation" in message:
|
|
97
|
+
code = "interactive_terminal_required"
|
|
98
|
+
return json.dumps({
|
|
99
|
+
"ok": False,
|
|
100
|
+
"error": {
|
|
101
|
+
"code": code,
|
|
102
|
+
"message": message,
|
|
103
|
+
"exit_code": error.exit_code,
|
|
104
|
+
},
|
|
105
|
+
}, indent=2)
|
|
106
|
+
|
|
107
|
+
|
|
79
108
|
# ---------------------------------------------------------------------------
|
|
80
109
|
# main()
|
|
81
110
|
# ---------------------------------------------------------------------------
|
|
@@ -114,6 +143,11 @@ def main(argv, options=None):
|
|
|
114
143
|
out(f"{_print_version()}\n")
|
|
115
144
|
return 0
|
|
116
145
|
|
|
146
|
+
if argv == ["--json"]:
|
|
147
|
+
rows = service["format_list_rows"]()
|
|
148
|
+
out(f"{json.dumps(_list_json_payload(rows), indent=2)}\n")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
117
151
|
if not argv:
|
|
118
152
|
out(f"{_format_sessions(service, use_color=use_color)}\n")
|
|
119
153
|
return 0
|
|
@@ -122,6 +156,7 @@ def main(argv, options=None):
|
|
|
122
156
|
ctx = {
|
|
123
157
|
"env": env,
|
|
124
158
|
"options": options,
|
|
159
|
+
"raw_args": argv,
|
|
125
160
|
"err": err,
|
|
126
161
|
"out": out,
|
|
127
162
|
"refresh_fn": refresh_fn,
|
|
@@ -174,17 +209,58 @@ def main(argv, options=None):
|
|
|
174
209
|
out(f"{_print_version()}\n")
|
|
175
210
|
return 0
|
|
176
211
|
|
|
177
|
-
if not rest:
|
|
212
|
+
if not rest or rest == ["--json"]:
|
|
178
213
|
return handle_launch(command, ctx)
|
|
179
214
|
|
|
180
215
|
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
181
216
|
|
|
182
217
|
|
|
218
|
+
def _list_json_payload(rows):
|
|
219
|
+
return {
|
|
220
|
+
"ok": True,
|
|
221
|
+
"action": "list",
|
|
222
|
+
"message": "Listed known sessions",
|
|
223
|
+
"warnings": [],
|
|
224
|
+
"sessions": rows,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _enable_windows_ansi():
|
|
229
|
+
if sys.platform != "win32":
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
import ctypes
|
|
233
|
+
kernel32 = ctypes.windll.kernel32
|
|
234
|
+
for handle_id in (-10, -11, -12): # stdin, stdout, stderr
|
|
235
|
+
handle = kernel32.GetStdHandle(handle_id)
|
|
236
|
+
mode = ctypes.c_ulong()
|
|
237
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
238
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _configure_windows_encoding():
|
|
244
|
+
if sys.platform != "win32":
|
|
245
|
+
return
|
|
246
|
+
for stream in (sys.stdout, sys.stderr):
|
|
247
|
+
try:
|
|
248
|
+
if hasattr(stream, "reconfigure"):
|
|
249
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
183
254
|
def cli_entry():
|
|
255
|
+
_enable_windows_ansi()
|
|
256
|
+
_configure_windows_encoding()
|
|
184
257
|
try:
|
|
185
258
|
raise SystemExit(main(sys.argv[1:]))
|
|
186
259
|
except CdxError as error:
|
|
187
|
-
sys.
|
|
260
|
+
if wants_json(sys.argv[1:]):
|
|
261
|
+
sys.stderr.write(f"{format_json_error(error)}\n")
|
|
262
|
+
else:
|
|
263
|
+
sys.stderr.write(f"{format_error(error)}\n")
|
|
188
264
|
raise SystemExit(error.exit_code)
|
|
189
265
|
|
|
190
266
|
|
package/src/cli_commands.py
CHANGED
|
@@ -32,23 +32,44 @@ def _local_now_iso():
|
|
|
32
32
|
return datetime.now().astimezone().isoformat()
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _json_success(action, message, **extra):
|
|
36
|
+
payload = {
|
|
37
|
+
"ok": True,
|
|
38
|
+
"action": action,
|
|
39
|
+
"message": message,
|
|
40
|
+
"warnings": [],
|
|
41
|
+
}
|
|
42
|
+
payload.update(extra)
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_json(ctx, payload):
|
|
47
|
+
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_json_flag(args):
|
|
51
|
+
json_flag = "--json" in args
|
|
52
|
+
cleaned = [arg for arg in args if arg != "--json"]
|
|
53
|
+
return json_flag, cleaned
|
|
54
|
+
|
|
55
|
+
|
|
35
56
|
def _parse_add_args(args):
|
|
36
57
|
if len(args) == 1:
|
|
37
58
|
return {"provider": "codex", "name": args[0]}
|
|
38
59
|
if len(args) == 2:
|
|
39
60
|
return {"provider": args[0], "name": args[1]}
|
|
40
|
-
raise CdxError("Usage: cdx add [provider] <name>")
|
|
61
|
+
raise CdxError("Usage: cdx add [provider] <name> [--json]")
|
|
41
62
|
|
|
42
63
|
|
|
43
64
|
def _parse_copy_args(args):
|
|
44
65
|
if len(args) != 2:
|
|
45
|
-
raise CdxError("Usage: cdx cp <source> <dest>")
|
|
66
|
+
raise CdxError("Usage: cdx cp <source> <dest> [--json]")
|
|
46
67
|
return {"source": args[0], "dest": args[1]}
|
|
47
68
|
|
|
48
69
|
|
|
49
70
|
def _parse_rename_args(args):
|
|
50
71
|
if len(args) != 2:
|
|
51
|
-
raise CdxError("Usage: cdx ren <source> <dest>")
|
|
72
|
+
raise CdxError("Usage: cdx ren <source> <dest> [--json]")
|
|
52
73
|
return {"source": args[0], "dest": args[1]}
|
|
53
74
|
|
|
54
75
|
|
|
@@ -57,7 +78,7 @@ def _parse_remove_args(args):
|
|
|
57
78
|
names = [a for a in args if a != "--force"]
|
|
58
79
|
unknown = [a for a in args if a.startswith("-") and a != "--force"]
|
|
59
80
|
if unknown or len(names) != 1 or len(args) > 2:
|
|
60
|
-
raise CdxError("Usage: cdx rmv <name> [--force]")
|
|
81
|
+
raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
|
|
61
82
|
return {"name": names[0], "force": force}
|
|
62
83
|
|
|
63
84
|
|
|
@@ -78,10 +99,10 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
78
99
|
|
|
79
100
|
|
|
80
101
|
def handle_add(rest, ctx):
|
|
81
|
-
|
|
102
|
+
json_flag, args = _parse_json_flag(rest)
|
|
103
|
+
parsed = _parse_add_args(args)
|
|
82
104
|
session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
|
|
83
105
|
message = f"Created session {parsed['name']} ({parsed['provider']})"
|
|
84
|
-
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
85
106
|
_ensure_session_authentication(
|
|
86
107
|
session,
|
|
87
108
|
ctx["service"],
|
|
@@ -100,28 +121,50 @@ def handle_add(rest, ctx):
|
|
|
100
121
|
"lastAuthenticatedAt": now,
|
|
101
122
|
"lastLoggedOutAt": auth.get("lastLoggedOutAt"),
|
|
102
123
|
})
|
|
124
|
+
if json_flag:
|
|
125
|
+
_write_json(ctx, _json_success(
|
|
126
|
+
"add",
|
|
127
|
+
message,
|
|
128
|
+
session=ctx["service"]["get_session"](parsed["name"]),
|
|
129
|
+
))
|
|
130
|
+
return 0
|
|
131
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
103
132
|
return 0
|
|
104
133
|
|
|
105
134
|
|
|
106
135
|
def handle_copy(rest, ctx):
|
|
107
|
-
|
|
136
|
+
json_flag, args = _parse_json_flag(rest)
|
|
137
|
+
parsed = _parse_copy_args(args)
|
|
108
138
|
result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
|
|
109
139
|
overwritten = " (overwritten)" if result["overwritten"] else ""
|
|
110
140
|
message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
|
|
141
|
+
if json_flag:
|
|
142
|
+
_write_json(ctx, _json_success(
|
|
143
|
+
"copy",
|
|
144
|
+
message,
|
|
145
|
+
session=result["session"],
|
|
146
|
+
overwritten=result["overwritten"],
|
|
147
|
+
))
|
|
148
|
+
return 0
|
|
111
149
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
112
150
|
return 0
|
|
113
151
|
|
|
114
152
|
|
|
115
153
|
def handle_rename(rest, ctx):
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
json_flag, args = _parse_json_flag(rest)
|
|
155
|
+
parsed = _parse_rename_args(args)
|
|
156
|
+
session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
|
|
118
157
|
message = f"Renamed session {parsed['source']} to {parsed['dest']}"
|
|
158
|
+
if json_flag:
|
|
159
|
+
_write_json(ctx, _json_success("rename", message, session=session))
|
|
160
|
+
return 0
|
|
119
161
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
120
162
|
return 0
|
|
121
163
|
|
|
122
164
|
|
|
123
165
|
def handle_remove(rest, ctx):
|
|
124
|
-
|
|
166
|
+
json_flag, args = _parse_json_flag(rest)
|
|
167
|
+
parsed = _parse_remove_args(args)
|
|
125
168
|
if not parsed["force"]:
|
|
126
169
|
confirm_fn = ctx["options"].get("confirmRemove")
|
|
127
170
|
if confirm_fn:
|
|
@@ -131,30 +174,47 @@ def handle_remove(rest, ctx):
|
|
|
131
174
|
else:
|
|
132
175
|
confirmed = _confirm_removal(parsed["name"])
|
|
133
176
|
if not confirmed:
|
|
177
|
+
if json_flag:
|
|
178
|
+
_write_json(ctx, _json_success("remove", "Cancelled.", cancelled=True, session=None))
|
|
179
|
+
return 0
|
|
134
180
|
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
135
181
|
return 0
|
|
136
|
-
ctx["service"]["remove_session"](parsed["name"])
|
|
182
|
+
removed = ctx["service"]["remove_session"](parsed["name"])
|
|
137
183
|
message = f"Removed session {parsed['name']}"
|
|
184
|
+
if json_flag:
|
|
185
|
+
_write_json(ctx, _json_success("remove", message, session=removed, cancelled=False))
|
|
186
|
+
return 0
|
|
138
187
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
139
188
|
return 0
|
|
140
189
|
|
|
141
190
|
|
|
142
191
|
def handle_clean(rest, ctx):
|
|
192
|
+
json_flag, args = _parse_json_flag(rest)
|
|
143
193
|
service = ctx["service"]
|
|
144
|
-
if len(
|
|
194
|
+
if len(args) == 0:
|
|
145
195
|
targets = service["list_sessions"]()
|
|
146
|
-
elif len(
|
|
147
|
-
session = service["get_session"](
|
|
196
|
+
elif len(args) == 1:
|
|
197
|
+
session = service["get_session"](args[0])
|
|
148
198
|
if not session:
|
|
149
|
-
raise CdxError(f"Unknown session: {
|
|
199
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
150
200
|
targets = [session]
|
|
151
201
|
else:
|
|
152
|
-
raise CdxError("Usage: cdx clean [name]")
|
|
202
|
+
raise CdxError("Usage: cdx clean [name] [--json]")
|
|
153
203
|
|
|
204
|
+
cleaned_sessions = []
|
|
154
205
|
for session in targets:
|
|
155
206
|
log_paths = _list_launch_transcript_paths(session)
|
|
156
207
|
if not log_paths:
|
|
157
208
|
message = f"{session['name']}: no log found"
|
|
209
|
+
cleaned_sessions.append({
|
|
210
|
+
"session_name": session["name"],
|
|
211
|
+
"cleared": False,
|
|
212
|
+
"files_cleared": 0,
|
|
213
|
+
"freed_kb": 0,
|
|
214
|
+
"message": message,
|
|
215
|
+
})
|
|
216
|
+
if json_flag:
|
|
217
|
+
continue
|
|
158
218
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
159
219
|
continue
|
|
160
220
|
total_size = 0
|
|
@@ -171,10 +231,30 @@ def handle_clean(rest, ctx):
|
|
|
171
231
|
f"Cleared {session['name']} logs ({cleared} file"
|
|
172
232
|
f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
|
|
173
233
|
)
|
|
234
|
+
cleaned_sessions.append({
|
|
235
|
+
"session_name": session["name"],
|
|
236
|
+
"cleared": True,
|
|
237
|
+
"files_cleared": cleared,
|
|
238
|
+
"freed_kb": round(total_size / 1024),
|
|
239
|
+
"message": message,
|
|
240
|
+
})
|
|
241
|
+
if json_flag:
|
|
242
|
+
continue
|
|
174
243
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
175
244
|
else:
|
|
176
245
|
message = f"{session['name']}: no log found"
|
|
246
|
+
cleaned_sessions.append({
|
|
247
|
+
"session_name": session["name"],
|
|
248
|
+
"cleared": False,
|
|
249
|
+
"files_cleared": 0,
|
|
250
|
+
"freed_kb": 0,
|
|
251
|
+
"message": message,
|
|
252
|
+
})
|
|
253
|
+
if json_flag:
|
|
254
|
+
continue
|
|
177
255
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
256
|
+
if json_flag:
|
|
257
|
+
_write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
|
|
178
258
|
return 0
|
|
179
259
|
|
|
180
260
|
|
|
@@ -301,13 +381,14 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
|
|
|
301
381
|
|
|
302
382
|
|
|
303
383
|
def handle_login(rest, ctx):
|
|
304
|
-
|
|
305
|
-
|
|
384
|
+
json_flag, args = _parse_json_flag(rest)
|
|
385
|
+
if len(args) != 1:
|
|
386
|
+
raise CdxError("Usage: cdx login <name> [--json]")
|
|
306
387
|
if not ctx["stdin_is_tty"]:
|
|
307
388
|
raise CdxError("Login requires an interactive terminal.")
|
|
308
|
-
session = ctx["service"]["get_session"](
|
|
389
|
+
session = ctx["service"]["get_session"](args[0])
|
|
309
390
|
if not session:
|
|
310
|
-
raise CdxError(f"Unknown session: {
|
|
391
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
311
392
|
_run_interactive_provider_command(
|
|
312
393
|
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
313
394
|
signal_emitter=ctx.get("signal_emitter")
|
|
@@ -317,36 +398,44 @@ def handle_login(rest, ctx):
|
|
|
317
398
|
signal_emitter=ctx.get("signal_emitter")
|
|
318
399
|
)
|
|
319
400
|
now = _local_now_iso()
|
|
320
|
-
ctx["service"]["update_auth_state"](
|
|
401
|
+
ctx["service"]["update_auth_state"](args[0], lambda auth: {
|
|
321
402
|
**auth, "status": "authenticated",
|
|
322
403
|
"lastCheckedAt": now, "lastAuthenticatedAt": now,
|
|
323
404
|
})
|
|
324
405
|
message = f"Reauthenticated session {session['name']} ({session['provider']})"
|
|
406
|
+
if json_flag:
|
|
407
|
+
_write_json(ctx, _json_success("login", message, session=ctx["service"]["get_session"](session["name"])))
|
|
408
|
+
return 0
|
|
325
409
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
326
410
|
return 0
|
|
327
411
|
|
|
328
412
|
|
|
329
413
|
def handle_logout(rest, ctx):
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
414
|
+
json_flag, args = _parse_json_flag(rest)
|
|
415
|
+
if len(args) != 1:
|
|
416
|
+
raise CdxError("Usage: cdx logout <name> [--json]")
|
|
417
|
+
session = ctx["service"]["get_session"](args[0])
|
|
333
418
|
if not session:
|
|
334
|
-
raise CdxError(f"Unknown session: {
|
|
419
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
335
420
|
_run_interactive_provider_command(
|
|
336
421
|
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
337
422
|
signal_emitter=ctx.get("signal_emitter")
|
|
338
423
|
)
|
|
339
424
|
now = _local_now_iso()
|
|
340
|
-
ctx["service"]["update_auth_state"](
|
|
425
|
+
ctx["service"]["update_auth_state"](args[0], lambda auth: {
|
|
341
426
|
**auth, "status": "logged_out",
|
|
342
427
|
"lastCheckedAt": now, "lastLoggedOutAt": now,
|
|
343
428
|
})
|
|
344
429
|
message = f"Logged out session {session['name']} ({session['provider']})"
|
|
430
|
+
if json_flag:
|
|
431
|
+
_write_json(ctx, _json_success("logout", message, session=ctx["service"]["get_session"](session["name"])))
|
|
432
|
+
return 0
|
|
345
433
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
346
434
|
return 0
|
|
347
435
|
|
|
348
436
|
|
|
349
437
|
def handle_launch(command, ctx):
|
|
438
|
+
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
350
439
|
session = ctx["service"]["launch_session"](command)
|
|
351
440
|
_ensure_session_authentication(
|
|
352
441
|
session,
|
|
@@ -359,11 +448,15 @@ def handle_launch(command, ctx):
|
|
|
359
448
|
signal_emitter=ctx.get("signal_emitter"),
|
|
360
449
|
)
|
|
361
450
|
message = f"Launching {session['provider']} session {session['name']}"
|
|
362
|
-
|
|
451
|
+
if not json_flag:
|
|
452
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
363
453
|
if session["provider"] == "codex":
|
|
364
|
-
|
|
454
|
+
if not json_flag:
|
|
455
|
+
ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
|
|
365
456
|
_run_interactive_provider_command(
|
|
366
457
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
367
458
|
signal_emitter=ctx.get("signal_emitter")
|
|
368
459
|
)
|
|
460
|
+
if json_flag:
|
|
461
|
+
_write_json(ctx, _json_success("launch", message, session=ctx["service"]["get_session"](session["name"])))
|
|
369
462
|
return 0
|
package/src/health.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
|
+
import sys
|
|
4
5
|
import tempfile
|
|
5
6
|
from urllib.parse import quote, unquote
|
|
6
7
|
|
|
@@ -43,7 +44,7 @@ def collect_health_report(service, base_dir, env=None):
|
|
|
43
44
|
issues.append(_issue(
|
|
44
45
|
"OK" if script_path else "WARN",
|
|
45
46
|
"script_cli",
|
|
46
|
-
|
|
47
|
+
_script_cli_message(script_bin, bool(script_path)),
|
|
47
48
|
script_path,
|
|
48
49
|
))
|
|
49
50
|
|
|
@@ -74,6 +75,17 @@ def _check_cdx_home(base_dir):
|
|
|
74
75
|
return _issue("FAIL", "cdx_home_writable", "CDX_HOME is not writable", f"{base_dir}: {error}")
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
def _script_cli_message(script_bin, is_available):
|
|
79
|
+
if is_available:
|
|
80
|
+
return f"{script_bin} CLI found"
|
|
81
|
+
if sys.platform == "win32":
|
|
82
|
+
return (
|
|
83
|
+
f"{script_bin} CLI not found; Codex will launch without transcript capture "
|
|
84
|
+
f"(expected on many Windows setups)"
|
|
85
|
+
)
|
|
86
|
+
return f"{script_bin} CLI not found; Codex will launch without transcript fallback"
|
|
87
|
+
|
|
88
|
+
|
|
77
89
|
def _collect_profile_issues(base_dir, session_names):
|
|
78
90
|
profile_dir = _profiles_dir(base_dir)
|
|
79
91
|
if not os.path.isdir(profile_dir):
|
package/src/notify.py
CHANGED
|
@@ -114,13 +114,38 @@ def _event(ready, title, message, session_name, target_timestamp=None):
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
117
|
+
import sys
|
|
117
118
|
spawn_sync = spawn_sync or subprocess.run
|
|
118
119
|
env = env or os.environ
|
|
119
|
-
if
|
|
120
|
+
if sys.platform == "win32":
|
|
121
|
+
_send_windows_notification(title, message, spawn_sync, env)
|
|
122
|
+
elif shutil_which("osascript", env):
|
|
120
123
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
121
124
|
spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def _send_windows_notification(title, message, spawn_sync, env):
|
|
128
|
+
title_escaped = _escape_powershell(title)
|
|
129
|
+
message_escaped = _escape_powershell(message)
|
|
130
|
+
script = (
|
|
131
|
+
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
132
|
+
f"[System.Windows.Forms.MessageBox]::Show('{message_escaped}', '{title_escaped}')"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
spawn_sync(
|
|
136
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
137
|
+
env=env,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
)
|
|
141
|
+
except (FileNotFoundError, OSError):
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _escape_powershell(value):
|
|
146
|
+
return str(value).replace("'", "''")
|
|
147
|
+
|
|
148
|
+
|
|
124
149
|
def shutil_which(command, env):
|
|
125
150
|
import shutil
|
|
126
151
|
return shutil.which(command, path=env.get("PATH"))
|
package/src/provider_runtime.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import signal
|
|
4
4
|
import shlex
|
|
5
5
|
import subprocess
|
|
6
|
+
import sys
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
|
|
8
9
|
from .errors import CdxError
|
|
@@ -11,6 +12,21 @@ from .errors import CdxError
|
|
|
11
12
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def _home_env_overrides(auth_home):
|
|
16
|
+
"""Return env vars that point the claude CLI to the given home directory.
|
|
17
|
+
|
|
18
|
+
On Unix, only HOME is needed. On Windows, Node.js resolves the home
|
|
19
|
+
directory via USERPROFILE (and falls back to HOMEDRIVE+HOMEPATH), so we
|
|
20
|
+
set all three to ensure profile isolation works regardless of the platform.
|
|
21
|
+
"""
|
|
22
|
+
overrides = {"HOME": auth_home}
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
overrides["USERPROFILE"] = auth_home
|
|
25
|
+
overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
|
|
26
|
+
overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
|
|
27
|
+
return overrides
|
|
28
|
+
|
|
29
|
+
|
|
14
30
|
def _get_auth_home(session):
|
|
15
31
|
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
16
32
|
|
|
@@ -91,7 +107,7 @@ def _build_launch_spec(session, cwd=None, env_override=None):
|
|
|
91
107
|
"args": ["--name", session["name"]],
|
|
92
108
|
"options": {
|
|
93
109
|
"cwd": cwd,
|
|
94
|
-
"env": {**env,
|
|
110
|
+
"env": {**env, **_home_env_overrides(_get_auth_home(session))},
|
|
95
111
|
},
|
|
96
112
|
"label": "claude",
|
|
97
113
|
}
|
|
@@ -108,7 +124,7 @@ def _build_launch_spec(session, cwd=None, env_override=None):
|
|
|
108
124
|
def _build_login_status_spec(session, env_override=None):
|
|
109
125
|
env = {**os.environ, **(env_override or {})}
|
|
110
126
|
if session["provider"] == "claude":
|
|
111
|
-
env
|
|
127
|
+
env.update(_home_env_overrides(_get_auth_home(session)))
|
|
112
128
|
|
|
113
129
|
def parser(output):
|
|
114
130
|
try:
|
|
@@ -133,7 +149,7 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
|
|
|
133
149
|
cwd = cwd or os.getcwd()
|
|
134
150
|
env = {**os.environ, **(env_override or {})}
|
|
135
151
|
if session["provider"] == "claude":
|
|
136
|
-
env
|
|
152
|
+
env.update(_home_env_overrides(_get_auth_home(session)))
|
|
137
153
|
return {"command": "claude", "args": ["auth", action],
|
|
138
154
|
"options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
|
|
139
155
|
env["CODEX_HOME"] = _get_auth_home(session)
|
|
@@ -165,7 +181,19 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None):
|
|
|
165
181
|
|
|
166
182
|
|
|
167
183
|
def _signal_exit_code(sig):
|
|
168
|
-
|
|
184
|
+
mapping = {signal.SIGINT: 130, signal.SIGTERM: 143}
|
|
185
|
+
if hasattr(signal, "SIGHUP"):
|
|
186
|
+
mapping[signal.SIGHUP] = 129
|
|
187
|
+
return mapping.get(sig, 1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _signal_name(sig):
|
|
191
|
+
if hasattr(sig, "name"):
|
|
192
|
+
return sig.name
|
|
193
|
+
try:
|
|
194
|
+
return signal.Signals(sig).name
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return str(sig)
|
|
169
197
|
|
|
170
198
|
|
|
171
199
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
@@ -210,7 +238,10 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
210
238
|
handlers.append((sig, handler))
|
|
211
239
|
signal_emitter.on(sig, handler)
|
|
212
240
|
else:
|
|
213
|
-
|
|
241
|
+
_forward_sigs = [signal.SIGINT, signal.SIGTERM]
|
|
242
|
+
if hasattr(signal, "SIGHUP"):
|
|
243
|
+
_forward_sigs.append(signal.SIGHUP)
|
|
244
|
+
for sig in _forward_sigs:
|
|
214
245
|
try:
|
|
215
246
|
original_handlers[sig] = signal.signal(sig, forward)
|
|
216
247
|
except (OSError, ValueError):
|
|
@@ -238,7 +269,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
238
269
|
|
|
239
270
|
if forwarded_signal[0] is not None:
|
|
240
271
|
raise CdxError(
|
|
241
|
-
f"{spec['label']} interrupted by {forwarded_signal[0]
|
|
272
|
+
f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
|
|
242
273
|
_signal_exit_code(forwarded_signal[0]),
|
|
243
274
|
)
|
|
244
275
|
if child.returncode != 0:
|
package/src/session_store.py
CHANGED
|
@@ -54,23 +54,40 @@ def _fsync_directory(directory):
|
|
|
54
54
|
|
|
55
55
|
@contextmanager
|
|
56
56
|
def _file_lock(lock_path):
|
|
57
|
+
import sys
|
|
57
58
|
_ensure_dir(os.path.dirname(lock_path))
|
|
58
59
|
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
59
|
-
|
|
60
|
-
import
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
import msvcrt
|
|
62
|
+
try:
|
|
63
|
+
lock.seek(0)
|
|
64
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
|
|
65
|
+
except OSError as error:
|
|
66
|
+
raise CdxError(f"Failed to lock session store: {error}") from error
|
|
67
|
+
try:
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
try:
|
|
71
|
+
lock.seek(0)
|
|
72
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
|
|
73
|
+
except OSError as error:
|
|
74
|
+
raise CdxError(f"Failed to unlock session store: {error}") from error
|
|
75
|
+
else:
|
|
70
76
|
try:
|
|
71
|
-
|
|
77
|
+
import fcntl
|
|
78
|
+
except ImportError as error:
|
|
79
|
+
raise CdxError("Session store locking requires fcntl on this platform") from error
|
|
80
|
+
try:
|
|
81
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
72
82
|
except OSError as error:
|
|
73
|
-
raise CdxError(f"Failed to
|
|
83
|
+
raise CdxError(f"Failed to lock session store: {error}") from error
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
try:
|
|
88
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
89
|
+
except OSError as error:
|
|
90
|
+
raise CdxError(f"Failed to unlock session store: {error}") from error
|
|
74
91
|
|
|
75
92
|
|
|
76
93
|
def create_session_store(base_dir):
|