cdx-manager 0.3.3 → 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 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](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.3.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
9
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.3.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
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.3 sh
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 add [provider] <name>` | Register a new session (`provider`: `codex` or `claude`, default: `codex`) |
168
- | `cdx cp <source> <dest>` | Copy a session into another session name, overwriting the destination if it exists |
169
- | `cdx ren <source> <dest>` | Rename a session and move its auth data |
170
- | `cdx login <name>` | Re-authenticate a session (logout + login) |
171
- | `cdx logout <name>` | Log out of a session |
172
- | `cdx rmv <name> [--force]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
173
- | `cdx clean [name]` | Clear launch transcript logs for one session or all sessions |
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.stderr.write(f"{format_error(error)}\n")
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,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",
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
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.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,12 +209,22 @@ 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
+
183
228
  def _enable_windows_ansi():
184
229
  if sys.platform != "win32":
185
230
  return
@@ -212,7 +257,10 @@ def cli_entry():
212
257
  try:
213
258
  raise SystemExit(main(sys.argv[1:]))
214
259
  except CdxError as error:
215
- sys.stderr.write(f"{format_error(error)}\n")
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")
216
264
  raise SystemExit(error.exit_code)
217
265
 
218
266
 
@@ -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
- parsed = _parse_add_args(rest)
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
- parsed = _parse_copy_args(rest)
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
- parsed = _parse_rename_args(rest)
117
- ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
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
- parsed = _parse_remove_args(rest)
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(rest) == 0:
194
+ if len(args) == 0:
145
195
  targets = service["list_sessions"]()
146
- elif len(rest) == 1:
147
- session = service["get_session"](rest[0])
196
+ elif len(args) == 1:
197
+ session = service["get_session"](args[0])
148
198
  if not session:
149
- raise CdxError(f"Unknown session: {rest[0]}")
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
- if len(rest) != 1:
305
- raise CdxError("Usage: cdx login <name>")
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"](rest[0])
389
+ session = ctx["service"]["get_session"](args[0])
309
390
  if not session:
310
- raise CdxError(f"Unknown session: {rest[0]}")
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"](rest[0], lambda auth: {
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
- if len(rest) != 1:
331
- raise CdxError("Usage: cdx logout <name>")
332
- session = ctx["service"]["get_session"](rest[0])
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: {rest[0]}")
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"](rest[0], lambda auth: {
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
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
451
+ if not json_flag:
452
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
363
453
  if session["provider"] == "codex":
364
- ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
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
- f"{script_bin} CLI {'found' if script_path else 'not found; Codex will launch without transcript fallback'}",
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):
@@ -187,6 +187,15 @@ def _signal_exit_code(sig):
187
187
  return mapping.get(sig, 1)
188
188
 
189
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)
197
+
198
+
190
199
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
191
200
  env_override=None, signal_emitter=None):
192
201
  spawn = spawn or subprocess.Popen
@@ -260,7 +269,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
260
269
 
261
270
  if forwarded_signal[0] is not None:
262
271
  raise CdxError(
263
- f"{spec['label']} interrupted by {forwarded_signal[0].name} for session {session['name']}",
272
+ f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
264
273
  _signal_exit_code(forwarded_signal[0]),
265
274
  )
266
275
  if child.returncode != 0: