cdx-manager 0.21.2 → 0.21.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.21.2
1
+ 0.21.4
@@ -0,0 +1,5 @@
1
+ # CDX Manager 0.21.3
2
+
3
+ ## Fixes
4
+
5
+ - Claude in Chrome now works from CDX Claude profiles (`claude --chrome`, `/chrome`, or `cdx set <name> --extra-args '--chrome'`). Profile isolation redirects HOME, so Claude Code looked for Chrome and its Claude extension inside the profile home, found neither, and never installed its native-messaging host where Chrome reads it. CDX now links the real Chrome directory into each Claude profile home, as it already did for the macOS keychain: `~/Library/Application Support/Google/Chrome` on macOS, `~/.config/google-chrome` on Linux. Windows needed no change: Chrome lives under `%LOCALAPPDATA%` and the host is registered in the registry, neither of which CDX redirects. Profile copies keep the link as a link and never copy the Chrome profile itself.
@@ -0,0 +1,10 @@
1
+ # CDX Manager 0.21.4
2
+
3
+ ## Start a session on a first message
4
+
5
+ - `cdx <name> --prompt TEXT` and `cdx <name> --prompt-file PATH` launch an interactive session that starts on that message, for launcher scripts that open a session already pointed at a task. Both spellings `--flag value` and `--flag=value` work. They are refused before the provider starts when combined with each other, with `--resume` or `cdx resume`, with `--prompt-file -` (the session needs standard input), and for Ollama sessions (`ollama run` answers once and exits). A relative `--prompt-file` path is read from the current directory, not `--dir`. Contributed by Nicolas Rigaudy (#13).
6
+
7
+ ## Fixes
8
+
9
+ - Claude sessions started from inside a running Claude Code session (a hook, or a terminal or script it spawned) saved no transcript: they inherited the parent's session markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, ...) and ran as a child session, which broke resume and interactive usage backfill. CDX now drops those markers for every Claude spawn; user settings such as `CLAUDE_CODE_DISABLE_*` still pass through. Contributed by Nicolas Rigaudy (#14).
10
+ - A prompt starting with `-` (from `--prompt`, `cdx handoff` or `cdx run`) is now passed after `--`, so Claude, Codex and Ollama no longer read it as an option (for example `-h` printed the provider's help instead of starting).
@@ -496,6 +496,28 @@
496
496
  "x86_64-pc-windows-msvc": "c316d753c055be48d74e23537ca66093a1a7c36df8fb8518d6b909484ccb9415",
497
497
  "x86_64-unknown-linux-musl": "f5a034ba301ecf10de33498cd99f00fa3aa6afda94d7c743390af05f4f718790"
498
498
  }
499
+ },
500
+ "v0.21.2": {
501
+ "github_tarball_sha256": "b6225a95b590cc931988260597b870edbff00b3175f5c8091a0e23503d408e5e",
502
+ "github_zip_sha256": "b0714ed222e17ae9bdd658c37b6ba32b9026ddddfb4d20794783b2203f4ccc88",
503
+ "tray_assets": {
504
+ "aarch64-apple-darwin": "c4b8938b62fad445351938f79e0cd42d5342a530e88eeb0f52c51e2fe77e3391",
505
+ "aarch64-unknown-linux-musl": "6f9c959e4a17a39c3d548b48afe326ca2d68bee0e298f532c4880d080d41eb20",
506
+ "x86_64-apple-darwin": "f41652aa24d27e2e4deef566cd66c11fc7cb3b5155dc8d5a917433a3e172d33b",
507
+ "x86_64-pc-windows-msvc": "856f9dc459182adb10f66f2836abc862be82feff1cc3f1cf859764336487d781",
508
+ "x86_64-unknown-linux-musl": "f1832fdbe7114b4625da21d6a3a0f0c100f18d3a441796e809e568620e4cd345"
509
+ }
510
+ },
511
+ "v0.21.3": {
512
+ "github_tarball_sha256": "4f38562539953131cd2d8aedffea71ba1e7b2d8ccbc899b6521a1ed8c7507cf2",
513
+ "github_zip_sha256": "06e95cf08fcdec0e978c16980f58f282b01daee0513ab8db1ea659b423e58dc6",
514
+ "tray_assets": {
515
+ "aarch64-apple-darwin": "086ec24d2d1ad2820394e74a5f100cec62da1ffe2df91ee470c247f5b1cb9bb0",
516
+ "aarch64-unknown-linux-musl": "0f32a168e097a4d18da86fe0ae028465d5fb43afae6344da39a76463ba99c0ea",
517
+ "x86_64-apple-darwin": "348ec8e5ed89c1181a800ab65b5d736a46b8118d7b83445847049cae95735af8",
518
+ "x86_64-pc-windows-msvc": "9729f245b46420ae4b11c1a50d3ba9cba682854866ef37a3f334dccbdd70ac89",
519
+ "x86_64-unknown-linux-musl": "182fb651dab29a6a21b3b8ef56c196e954fd75230891f8532c21b09ff5fbacee"
520
+ }
499
521
  }
500
522
  }
501
523
  }
package/docs/cli.md CHANGED
@@ -8,6 +8,7 @@
8
8
  | `cdx --json` | List all sessions as a machine-readable JSON payload |
9
9
  | `cdx <name>` | Launch a session (checks auth first) |
10
10
  | `cdx <name> [--json]` | Launch a session; `--json` returns a structured success payload after the interactive run ends |
11
+ | `cdx <name> --prompt TEXT` / `cdx <name> --prompt-file PATH` | Launch a session that starts on this first message (not with `--resume` or Ollama sessions; `--prompt-file -` is refused because the session needs standard input; a relative `--prompt-file` path is read from the current directory, not `--dir`) |
11
12
  | `cdx <name> -r` / `cdx <name> --resume` | Resume the provider-native conversation for a session when supported |
12
13
  | `cdx add [provider] <name> [--model MODEL] [--json]` | Register a new session (`provider`: `codex`, `claude`, `antigravity`, or `ollama`; Ollama requires `--model`) |
13
14
  | `cdx cp <source> <dest> [--json]` | Copy a session into another session name, overwriting the destination if it exists |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
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.21.2"
7
+ version = "0.21.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -117,36 +117,52 @@ def claude_profile_store_dir(auth_home):
117
117
  return os.path.join(auth_home, "credentials")
118
118
 
119
119
 
120
- def _link_macos_keychain(auth_home):
121
- """Make the operator's login keychain reachable from a redirected home.
122
-
123
- macOS resolves the login keychain through `$HOME/Library/Keychains`.
124
- Profile isolation redirects HOME (see `_home_env_overrides`), so that path
125
- does not exist: every Claude Code launch then raises a "Keychain Not Found"
126
- dialog before falling back to a plaintext credentials file. Linking the
127
- real keychain directory into the profile home restores the lookup. Failure
128
- is non-fatal, and deliberately quiet: the profile simply keeps the
129
- file-based credentials it already used.
120
+ # Real-home directories a redirected profile home must still reach on macOS.
121
+ # Keychains: the login keychain (else a "Keychain Not Found" dialog on every launch).
122
+ # Chrome: Claude in Chrome looks for its extension and installs its native-messaging
123
+ # host under $HOME/Library/Application Support/Google/Chrome; without the link,
124
+ # `claude --chrome` sees no extension and Chrome never finds the host.
125
+ # Linux keeps Chrome under ~/.config (XDG), also resolved from HOME. Windows needs
126
+ # nothing: the host is registered in the registry and Chrome lives in
127
+ # %LOCALAPPDATA%, which profile isolation does not redirect.
128
+ _MACOS_SHARED_DIRS = (
129
+ ("Library", "Keychains"),
130
+ ("Library", "Application Support", "Google", "Chrome"),
131
+ )
132
+ _LINUX_SHARED_DIRS = ((".config", "google-chrome"),)
133
+
134
+
135
+ def _link_real_home_dirs(auth_home):
136
+ """Link the real-home directories in `_MACOS_SHARED_DIRS` (or `_LINUX_SHARED_DIRS`) into a redirected home.
137
+
138
+ Profile isolation redirects HOME (see `_home_env_overrides`), so these
139
+ paths do not exist in the profile home. A real directory already there is
140
+ left alone. Failure is non-fatal, and deliberately quiet: the profile
141
+ simply keeps working without that integration.
130
142
  """
131
- if sys.platform != "darwin" or pwd is None:
143
+ shared = _MACOS_SHARED_DIRS if sys.platform == "darwin" else _LINUX_SHARED_DIRS if sys.platform.startswith("linux") else ()
144
+ if not shared or pwd is None:
132
145
  return
133
146
  try:
134
147
  real_home = pwd.getpwuid(os.getuid()).pw_dir
135
148
  except (KeyError, OSError):
136
149
  return
137
- source = os.path.join(real_home, "Library", "Keychains")
138
- if not os.path.isdir(source):
139
- return
140
- link = os.path.join(auth_home, "Library", "Keychains")
141
- if os.path.realpath(link) == os.path.realpath(source):
142
- return
143
- try:
144
- os.makedirs(os.path.dirname(link), exist_ok=True)
145
- if os.path.islink(link):
146
- os.unlink(link)
147
- os.symlink(source, link)
148
- except OSError:
149
- return
150
+ for parts in shared:
151
+ source = os.path.join(real_home, *parts)
152
+ if not os.path.isdir(source):
153
+ continue
154
+ link = os.path.join(auth_home, *parts)
155
+ if os.path.realpath(link) == os.path.realpath(source):
156
+ continue
157
+ if os.path.exists(link) and not os.path.islink(link):
158
+ continue
159
+ try:
160
+ os.makedirs(os.path.dirname(link), exist_ok=True)
161
+ if os.path.islink(link):
162
+ os.unlink(link)
163
+ os.symlink(source, link)
164
+ except OSError:
165
+ continue
150
166
 
151
167
 
152
168
  def _secure_storage_overrides(auth_home):
@@ -167,7 +183,7 @@ def _secure_storage_overrides(auth_home):
167
183
  the variable overrides that directory as well as naming the entry. Any
168
184
  other value would also move where credentials are read from.
169
185
  """
170
- _link_macos_keychain(auth_home)
186
+ _link_real_home_dirs(auth_home)
171
187
  return {"CLAUDE_SECURESTORAGE_CONFIG_DIR": os.path.join(auth_home, ".claude")}
172
188
 
173
189
 
package/src/cli.py CHANGED
@@ -70,6 +70,7 @@ from .cli_render import (
70
70
  _visible_len,
71
71
  format_error,
72
72
  )
73
+ from .config import PROVIDER_OLLAMA
73
74
  from .errors import CdxError
74
75
  from .provider_runtime import (
75
76
  LOG_ROTATE_BYTES,
@@ -288,6 +289,7 @@ def _print_help(use_color=False):
288
289
  f" {_style('cdx set <name> --notify on', '36', use_color)} # agent finishes or needs attention",
289
290
  f" {_style('cdx ready', '36', use_color)} # next session to become ready",
290
291
  f" {_style('cdx <name> [--json]', '36', use_color)}",
292
+ f" {_style('cdx <name> --prompt TEXT|--prompt-file PATH', '36', use_color)} # start with a first message",
291
293
  f" {_style('cdx --help', '36', use_color)}",
292
294
  f" {_style('cdx --version', '36', use_color)}",
293
295
  ])
@@ -623,10 +625,18 @@ def main(argv, options=None):
623
625
  if handler:
624
626
  return handler(rest, ctx)
625
627
 
626
- from .commands.launch import _parse_launch_args
627
- directory, launch_args = _parse_launch_args(rest)
628
+ from .commands.launch import _parse_launch_args, _read_launch_prompt
629
+ directory, launch_args, prompt = _parse_launch_args(rest)
628
630
  if all(arg in ("--json", "-r", "--resume") for arg in launch_args):
629
- return handle_launch(command, ctx, resume=("-r" in launch_args or "--resume" in launch_args), directory=directory)
631
+ resume = "-r" in launch_args or "--resume" in launch_args
632
+ has_prompt = prompt["prompt"] is not None or prompt["prompt_file"] is not None
633
+ if resume and has_prompt:
634
+ raise CdxError("cdx <name>: --prompt and --prompt-file are not supported with --resume.")
635
+ # `ollama run MODEL PROMPT` answers once and exits instead of opening a session.
636
+ if has_prompt and (ctx["service"]["get_session"](command) or {}).get("provider") == PROVIDER_OLLAMA:
637
+ raise CdxError("cdx <name>: --prompt and --prompt-file are not supported for Ollama sessions.")
638
+ return handle_launch(command, ctx, initial_prompt=_read_launch_prompt(prompt), resume=resume,
639
+ directory=directory)
630
640
 
631
641
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
632
642
 
@@ -30,31 +30,45 @@ from ..provider_runtime import (
30
30
  _get_auth_home,
31
31
  _run_interactive_provider_command,
32
32
  )
33
+ from ..run_command import PROMPT_STDIN, read_run_prompt
33
34
  from ..run_usage import estimate_cost
34
35
 
36
+ LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH] [--resume] [--json]"
37
+
35
38
 
36
39
  def _parse_launch_args(args):
37
- """Return the per-launch directory and ordinary launch flags."""
38
- directory = None
40
+ """Return the per-launch directory, initial prompt source and ordinary launch flags."""
41
+ values = {"--dir": None, "--prompt": None, "--prompt-file": None}
39
42
  cleaned = []
40
43
  index = 0
41
44
  while index < len(args):
42
45
  arg = args[index]
43
- if arg == "--dir":
44
- if directory is not None or index + 1 >= len(args):
45
- raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
46
- directory = args[index + 1]
47
- index += 2
48
- continue
49
- if arg.startswith("--dir="):
50
- if directory is not None or not arg.split("=", 1)[1]:
51
- raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
52
- directory = arg.split("=", 1)[1]
46
+ flag, sep, inline = arg.partition("=")
47
+ if flag not in values:
48
+ cleaned.append(arg)
53
49
  index += 1
54
50
  continue
55
- cleaned.append(arg)
56
- index += 1
57
- return directory, cleaned
51
+ if sep:
52
+ value, index = inline, index + 1
53
+ elif index + 1 < len(args):
54
+ value, index = args[index + 1], index + 2
55
+ else:
56
+ raise CdxError(LAUNCH_USAGE)
57
+ if values[flag] is not None or not value:
58
+ raise CdxError(LAUNCH_USAGE)
59
+ values[flag] = value
60
+ if values["--prompt"] is not None and values["--prompt-file"] is not None:
61
+ raise CdxError("cdx <name>: --prompt and --prompt-file are mutually exclusive.")
62
+ if values["--prompt-file"] == PROMPT_STDIN:
63
+ raise CdxError("cdx <name>: --prompt-file - is not supported; the interactive session needs standard input.")
64
+ prompt = {"prompt": values["--prompt"], "prompt_file": values["--prompt-file"]}
65
+ return values["--dir"], cleaned, prompt
66
+
67
+
68
+ def _read_launch_prompt(prompt):
69
+ if prompt["prompt"] is None and prompt["prompt_file"] is None:
70
+ return None
71
+ return read_run_prompt(prompt)
58
72
 
59
73
 
60
74
  def _launch_directory(session, ctx, explicit, json_flag):
@@ -154,7 +168,9 @@ def handle_can_resume(rest, ctx):
154
168
  return 0
155
169
 
156
170
  def handle_resume(rest, ctx):
157
- directory, rest = _parse_launch_args(rest)
171
+ directory, rest, prompt = _parse_launch_args(rest)
172
+ if prompt["prompt"] is not None or prompt["prompt_file"] is not None:
173
+ raise CdxError("cdx resume: --prompt and --prompt-file are not supported when resuming.")
158
174
  json_flag, args = _parse_json_flag(rest)
159
175
  if len(args) != 1:
160
176
  raise CdxError(RESUME_USAGE)
@@ -137,6 +137,21 @@ REDACTED_PROMPT_ARG = "[prompt redacted]"
137
137
  # Documented Claude Code switch that stops it from setting and clearing the
138
138
  # terminal title, leaving cdx's shared title keeper as the only writer.
139
139
  CLAUDE_DISABLE_TERMINAL_TITLE_ENV = "CLAUDE_CODE_DISABLE_TERMINAL_TITLE"
140
+ # Set by a running Claude Code session for its own child processes. When cdx is
141
+ # started from inside one (a hook, or a terminal or script it spawned), the new
142
+ # claude inherits them, treats itself as a child session and turns transcript
143
+ # saving off, which breaks resume and usage backfill.
144
+ CLAUDE_PARENT_SESSION_ENV = (
145
+ "CLAUDECODE",
146
+ "CLAUDE_CODE_CHILD_SESSION",
147
+ "CLAUDE_CODE_SESSION_ID",
148
+ "CLAUDE_CODE_SESSION_ATTENDED",
149
+ "CLAUDE_CODE_ENTRYPOINT",
150
+ "CLAUDE_CODE_EXECPATH",
151
+ "CLAUDE_CODE_MESSAGING_SOCKET",
152
+ "CLAUDE_CODE_MESSAGING_TOKEN",
153
+ "CLAUDE_PID",
154
+ )
140
155
  CLAUDE_CLI_MODEL_ALIASES = {
141
156
  "claude-sonnet": "sonnet",
142
157
  "claude-opus": "opus",
@@ -216,6 +231,8 @@ def _claude_env(base_env, auth_home, own_terminal_title=False):
216
231
  env = {**base_env, **_home_env_overrides(auth_home)}
217
232
  env.pop("CLAUDE_CONFIG_DIR", None)
218
233
  env.pop("CODEX_HOME", None)
234
+ for key in CLAUDE_PARENT_SESSION_ENV:
235
+ env.pop(key, None)
219
236
  env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
220
237
  if own_terminal_title:
221
238
  env[CLAUDE_DISABLE_TERMINAL_TITLE_ENV] = "1"
@@ -672,7 +689,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
672
689
  args += preference_args
673
690
  args += _extra_args(session)
674
691
  if initial_prompt:
675
- args.append(initial_prompt)
692
+ # `--` keeps a prompt that starts with `-` from being read as an option.
693
+ args += ["--", initial_prompt]
676
694
  auth_home = _get_auth_home(session)
677
695
  claude_env = _claude_env(env, auth_home, own_terminal_title=True)
678
696
  oauth_token = _read_claude_launch_oauth_token(auth_home)
@@ -711,7 +729,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
711
729
  # failed only on the model pull.
712
730
  args = ["run", model] + _launch_config_args(session)
713
731
  if initial_prompt:
714
- args.append(initial_prompt)
732
+ # `--` keeps a prompt that starts with `-` from being read as an option.
733
+ args += ["--", initial_prompt]
715
734
  return _wrap_launch_with_transcript(session, {
716
735
  "command": "ollama",
717
736
  "args": args,
@@ -728,7 +747,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
728
747
  args += _launch_config_args(session)
729
748
  args += preference_args
730
749
  if initial_prompt:
731
- args.append(initial_prompt)
750
+ # `--` keeps a prompt that starts with `-` from being read as an option.
751
+ args += ["--", initial_prompt]
732
752
  return _wrap_launch_with_transcript(session, {
733
753
  "command": "codex",
734
754
  "args": args,
@@ -933,7 +953,8 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
933
953
  args += preference_args
934
954
  args += _extra_args(session)
935
955
  if initial_prompt:
936
- args.append(initial_prompt)
956
+ # `--` keeps a prompt that starts with `-` from being read as an option.
957
+ args += ["--", initial_prompt]
937
958
  auth_home = _get_auth_home(session)
938
959
  claude_env = _claude_env(env, auth_home)
939
960
  oauth_token = _read_claude_launch_oauth_token(auth_home)
@@ -958,7 +979,8 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
958
979
  args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
959
980
  args += preference_args
960
981
  if initial_prompt:
961
- args.append(initial_prompt)
982
+ # `--` keeps a prompt that starts with `-` from being read as an option.
983
+ args += ["--", initial_prompt]
962
984
  return {
963
985
  "command": "codex",
964
986
  "args": args,
@@ -35,7 +35,7 @@ from contextlib import nullcontext
35
35
  from functools import partial
36
36
 
37
37
  from .claude_credentials import copy_keychain_credentials, delete_keychain_credentials
38
- from .claude_usage import _link_macos_keychain
38
+ from .claude_usage import _link_real_home_dirs
39
39
  from .codex_usage import fetch_codex_rate_limits
40
40
  from .config import (
41
41
  MAX_LAUNCH_BUDGET_USD,
@@ -573,8 +573,9 @@ def copy_session(base_dir, store, source_name, dest_name):
573
573
  with credential_copy:
574
574
  if source["provider"] == PROVIDER_CLAUDE:
575
575
  shutil.copytree(source_root, temp_root, symlinks=True,
576
- ignore=lambda _directory, names: [n for n in names if n == "Keychains"])
577
- _link_macos_keychain(os.path.join(temp_root, "claude-home"))
576
+ ignore=lambda directory, names: [n for n in names if n == "Keychains" or (
577
+ n == "Chrome" and os.path.islink(os.path.join(directory, n)))])
578
+ _link_real_home_dirs(os.path.join(temp_root, "claude-home"))
578
579
  else:
579
580
  shutil.copytree(source_root, temp_root)
580
581
  if existing: