cc-codex
Delegates coding tasks to Codex CLI for prototyping, debugging, and code review. Use when: (1) Backend/logic implementation, (2) Algorithm design and optimization, (3) Bug analysis and debugging, (4) API/database code generation, (5) Code quality review and refactoring. Triggers:
Install
npx skills add https://github.com/Dianel555/DSkills/tree/main/skills/cc-codex
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dianel555-dskills@llmmart
git clone https://github.com/Dianel555/DSkills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole dianel555/dskills collection as a plugin from our marketplace. Git is the plain clone.
README
cc-codex
A Claude Code Agent Skill that bridges Claude with OpenAI Codex CLI for multi-model collaboration on coding tasks.
Overview
This Skill enables Claude to delegate coding tasks to Codex CLI, combining the strengths of multiple AI models. Codex handles algorithm implementation, debugging, and code analysis while Claude orchestrates the workflow and refines the output.
Codex config (~/.codex/config.toml), hooks, MCP servers, plugins, skills, and AGENTS.md are loaded by the Codex process itself. This bridge does not sync Claude Code MCP/hooks/plugins into Codex — the two runtimes stay separate.
Features
- Multi-turn sessions: Maintain conversation context across multiple interactions via
SESSION_ID - Sandboxed execution: Three security levels (
read-only,workspace-write,danger-full-access) - JSON output: Structured responses for easy parsing and integration
- Image support: Attach images to prompts for visual context
- Cross-platform: Windows path escaping handled automatically
- Config isolation: Optional
--ignore-user-config - Hook trust bypass: Optional
--dangerously-bypass-hook-trustfor vetted automation - Management passthrough:
mcp/pluginsubcommands forward tocodex mcp|plugin
Installation
- Ensure Codex CLI is installed and available in your PATH
- Copy this Skill to your Claude Code skills directory:
- User-level:
~/.claude/skills/cc-codex/ - Project-level:
.claude/skills/cc-codex/
- User-level:
Or install via the DSkills marketplace entry cc-codex.
Usage
Basic
python scripts/codex_bridge.py --cd "/path/to/project" --PROMPT "Analyze the authentication flow"
Multi-turn Session
# Start a session
python scripts/codex_bridge.py --cd "/project" --PROMPT "Review login.py for security issues"
# Response includes SESSION_ID
# Continue the session
python scripts/codex_bridge.py --cd "/project" --SESSION_ID "uuid-from-response" --PROMPT "Suggest fixes for the issues found"
Manage Codex MCP / plugins
python scripts/codex_bridge.py mcp list
python scripts/codex_bridge.py plugin list
python scripts/codex_bridge.py plugin marketplace list
Parameters
| Parameter | Required | Description |
|---|---|---|
--PROMPT |
Yes* | Task instruction (*not required for mcp/plugin subcommands) |
--cd |
Yes* | Workspace root directory |
--sandbox |
No | Security level: read-only (default), workspace-write, danger-full-access |
--SESSION_ID |
No | Resume a previous session |
--return-all-messages |
No | Include full reasoning trace in output |
--image |
No | Attach image files (comma-separated or repeated) |
--model |
No | Specify model (use only when explicitly requested) |
--profile |
No | Codex profile name (use only when explicitly requested) |
--yolo |
No | Bypass all approvals (use with caution) |
--ignore-user-config |
No | Skip loading $CODEX_HOME/config.toml |
--dangerously-bypass-hook-trust |
No | Run hooks without persisted trust (dangerous) |
Output Format
{
"success": true,
"SESSION_ID": "uuid",
"agent_messages": "Codex response text",
"stream_file": "/tmp/codex_stream_....jsonl",
"all_messages": []
}
Passthrough (mcp / plugin) output:
{
"success": true,
"output": "...",
"error": "",
"returncode": 0
}
License
MIT License. See LICENSE for details.
Skill manifest
Quick Start
python scripts/codex_bridge.py --cd "/path/to/project" --PROMPT "Your task"
Output: JSON with success, SESSION_ID, agent_messages, stream_file (path to the raw JSONL stream persisted line-by-line), optional stderr (codex diagnostics; lines repeated verbatim except for their timestamp are collapsed to one line with an [xN] count), and optional error.
Headless exec runs with approvals disabled by Codex itself; bridge --yolo maps to Codex's --dangerously-bypass-approvals-and-sandbox. Stdin is detached, so an approval prompt could never be answered. On Windows the PROMPT is delivered via stdin (- positional) to bypass cmd.exe quoting/length limits.
Parameters
usage: codex_bridge.py [-h] [--PROMPT PROMPT] [--cd CD]
[--sandbox {read-only,workspace-write,danger-full-access}]
[--SESSION_ID SESSION_ID] [--skip-git-repo-check]
[--return-all-messages] [--image IMAGE] [--model MODEL]
[--yolo] [--profile PROFILE] [--stream-file STREAM_FILE]
[--idle-timeout IDLE_TIMEOUT] [--ignore-user-config]
[--dangerously-bypass-hook-trust]
{mcp,plugin} ...
Codex Bridge
options:
--PROMPT PROMPT Instruction for the task to send to codex.
--cd CD Set the workspace root for codex before executing the task.
--sandbox {read-only,workspace-write,danger-full-access}
Sandbox policy for model-generated commands. Defaults to `read-only`.
--SESSION_ID SESSION_ID
Resume the specified session of the codex.
--skip-git-repo-check
Allow codex running outside a Git repository.
--return-all-messages
Return all messages (reasoning, tool calls, etc.).
--image IMAGE Attach image files to the initial prompt.
--model MODEL Model for the session (only when user explicitly requests).
--yolo Bypass approvals/sandboxing (last resort).
--profile PROFILE Load `~/.codex/<name>.config.toml` profile (only when user requests).
--stream-file STREAM_FILE
Persist raw JSONL stream path.
--idle-timeout IDLE_TIMEOUT
Kill codex if no output for N seconds (default 600; 0 disables).
--ignore-user-config Do not load `$CODEX_HOME/config.toml` (auth still uses CODEX_HOME).
--dangerously-bypass-hook-trust
Run Codex hooks without persisted hook trust. DANGEROUS.
subcommands:
mcp Thin passthrough to `codex mcp` (list/get/add/remove/login/logout).
plugin Thin passthrough to `codex plugin` (add/list/remove/marketplace).
Multi-turn Sessions
Always capture SESSION_ID from the first response for follow-up:
# Initial task
python scripts/codex_bridge.py --cd "/project" --PROMPT "Analyze auth in login.py"
# Continue with SESSION_ID
python scripts/codex_bridge.py --cd "/project" --SESSION_ID "uuid-from-response" --PROMPT "Write unit tests for that"
Config Inheritance
| Source | Inherited by default? |
|---|---|
~/.codex/config.toml |
Yes (unless --ignore-user-config) |
Profile (--profile) |
Only when explicitly passed |
Project .codex/ (trusted) |
Yes, when Codex trusts the project |
Hooks / MCP / plugins / skills / AGENTS.md |
Yes, via Codex runtime |
Management Passthrough
python scripts/codex_bridge.py mcp list
python scripts/codex_bridge.py plugin list
Common Patterns
Prototyping (read-only, request diffs):
python scripts/codex_bridge.py --cd "/project" --PROMPT "Generate unified diff to add logging"
Verification (codex must run tests/builds — read-only blocks them):
python scripts/codex_bridge.py --cd "/project" --sandbox workspace-write --PROMPT "Run pytest, fix the failure, output a unified diff patch"
Keep requesting patch text; audit afterwards with git status && git diff.
Debug with full trace:
python scripts/codex_bridge.py --cd "/project" --PROMPT "Debug this error" --return-all-messages
Headless hooks (vetted automation only):
python scripts/codex_bridge.py --cd "/project" --PROMPT "..." --dangerously-bypass-hook-trust
Files (dskills)
-
scripts
-
codex_bridge.py 22.2 KB
""" Codex Bridge Script for Claude Agent Skills. Wraps the Codex CLI to provide a JSON-based interface for Claude. """ from __future__ import annotations import argparse import contextlib import json import os import queue import re import shutil import subprocess import sys import tempfile import threading import time from collections import Counter from collections.abc import Generator from pathlib import Path def _is_windows() -> bool: """Platform seam: patched in tests so no test mutates the shared os.name.""" return os.name == "nt" def _get_windows_npm_paths() -> list[Path]: """Return candidate directories for npm global installs on Windows.""" if not _is_windows(): return [] paths: list[Path] = [] env = os.environ if prefix := env.get("NPM_CONFIG_PREFIX") or env.get("npm_config_prefix"): paths.append(Path(prefix)) if appdata := env.get("APPDATA"): paths.append(Path(appdata) / "npm") if localappdata := env.get("LOCALAPPDATA"): paths.append(Path(localappdata) / "npm") if programfiles := env.get("ProgramFiles"): paths.append(Path(programfiles) / "nodejs") return paths def _augment_path_env(env: dict) -> None: """Prepend npm global directories to PATH if missing.""" if not _is_windows(): return path_key = next((k for k in env if k.upper() == "PATH"), "PATH") path_entries = [p for p in env.get(path_key, "").split(os.pathsep) if p] lower_set = {p.lower() for p in path_entries} for candidate in _get_windows_npm_paths(): if candidate.is_dir() and str(candidate).lower() not in lower_set: path_entries.insert(0, str(candidate)) lower_set.add(str(candidate).lower()) env[path_key] = os.pathsep.join(path_entries) def _resolve_executable(name: str, env: dict) -> str: """Resolve executable path, checking npm dirs for .cmd/.bat on Windows.""" if os.path.isabs(name) or os.sep in name or (os.altsep and os.altsep in name): return name path_key = next((k for k in env if k.upper() == "PATH"), "PATH") path_val = env.get(path_key) win_exts = {".exe", ".cmd", ".bat", ".com"} if resolved := shutil.which(name, path=path_val): if _is_windows(): suffix = Path(resolved).suffix.lower() if not suffix: resolved_dir = str(Path(resolved).parent) for ext in (".cmd", ".bat", ".exe", ".com"): candidate = Path(resolved_dir) / f"{name}{ext}" if candidate.is_file(): return str(candidate) elif suffix not in win_exts: return resolved return resolved if _is_windows(): for base in _get_windows_npm_paths(): for ext in (".cmd", ".bat", ".exe", ".com"): candidate = base / f"{name}{ext}" if candidate.is_file(): return str(candidate) return name def _prepare_popen_cmd(cmd: list[str], env: dict): """Resolve executable and wrap Windows .cmd/.bat via cmd.exe.""" popen_cmd = cmd.copy() exe_path = _resolve_executable(cmd[0], env) popen_cmd[0] = exe_path if _is_windows() and Path(exe_path).suffix.lower() in {".cmd", ".bat"}: # cmd.exe truncates argv at embedded \n/\r/\t; escape as literals here only. popen_cmd = [windows_escape(a) for a in popen_cmd] def _cmd_quote(arg: str) -> str: # Port of Rust std append_bat_arg (CVE-2024-24576 fix), adapted to the # npm .cmd shim re-parsing (%* then node/MSVC CRT rules). Always quote; # "" for an embedded quote, double backslash runs around quotes, and # %%cd:~,% for % so no %VAR% can form. ^ needs no escaping inside quotes. if not arg: return '""' out = ['"'] backslashes = 0 for ch in arg: if ch == "\\": backslashes += 1 continue if ch == '"': out.append("\\" * (backslashes * 2)) out.append('""') elif ch == "%": out.append("\\" * backslashes) out.append("%%cd:~,%") else: out.append("\\" * backslashes) out.append(ch) backslashes = 0 out.append("\\" * (backslashes * 2)) out.append('"') return "".join(out) cmdline = " ".join(_cmd_quote(a) for a in popen_cmd) comspec = env.get("COMSPEC", "cmd.exe") popen_cmd = f'"{comspec}" /d /s /c "{cmdline}"' return popen_cmd def run_shell_command( cmd: list[str], idle_timeout: float = 600.0, stderr_sink: list[str] | None = None ) -> Generator[str, None, None]: """Execute a command and stream its output line-by-line. idle_timeout: terminate the process if no line is produced for this many seconds (0 disables). On timeout a synthetic `_bridge_fatal` error line is yielded so callers can report a hard failure instead of hanging forever. stderr_sink: if provided, the child's stderr is drained into it on a separate thread (kept off stdout so it cannot corrupt the JSON stream). """ env = os.environ.copy() _augment_path_env(env) # On the Windows .cmd/.bat path the whole prompt travels inside the cmd.exe # command line, which breaks at ~8k chars ("The command line is too long") # and re-parses quoting. codex exec accepts `-` as the PROMPT positional to # read the prompt from stdin, sidestepping both. Only the exec form ends # with `-- PROMPT`; passthrough (mcp/plugin) must keep stdin detached. stdin_prompt = None if _is_windows() and len(cmd) > 2 and cmd[0] == "codex" and cmd[1] == "exec" and cmd[-2] == "--": resolved = _resolve_executable(cmd[0], env) if Path(resolved).suffix.lower() in {".cmd", ".bat"}: stdin_prompt = cmd[-1] cmd = cmd[:-1] + ["-"] popen_cmd = _prepare_popen_cmd(cmd, env) process = subprocess.Popen( popen_cmd, shell=False, stdin=subprocess.PIPE if stdin_prompt is not None else subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, encoding="utf-8", errors="replace", env=env, ) if stdin_prompt is not None and process.stdin is not None: def write_prompt() -> None: with contextlib.suppress(BrokenPipeError, OSError, ValueError): process.stdin.write(stdin_prompt) with contextlib.suppress(BrokenPipeError, OSError, ValueError): process.stdin.close() threading.Thread(target=write_prompt, daemon=True).start() def terminate_process_tree() -> None: # On the cmd.exe wrapper path, terminate() only kills the shell; the node # launcher and codex.exe survive as orphans and keep stdout open (the # reader thread then hangs). Kill the whole tree. if process.poll() is not None: return if _is_windows(): subprocess.run( ["taskkill", "/T", "/F", "/PID", str(process.pid)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) else: process.terminate() output_queue: queue.Queue[str | None] = queue.Queue() graceful_shutdown_delay = 0.3 def is_turn_completed(line: str) -> bool: try: data = json.loads(line) return data.get("type") == "turn.completed" except (json.JSONDecodeError, AttributeError, TypeError): return False def read_output() -> None: if process.stdout: for line in iter(process.stdout.readline, ""): stripped = line.strip() output_queue.put(stripped) if is_turn_completed(stripped): time.sleep(graceful_shutdown_delay) terminate_process_tree() break process.stdout.close() output_queue.put(None) def read_stderr() -> None: if process.stderr: for line in iter(process.stderr.readline, ""): if stderr_sink is not None: stderr_sink.append(line.rstrip("\n")) process.stderr.close() # daemon: after a tree-kill that misses a grandchild, a blocked readline must # not keep the interpreter alive once the caller stops consuming the queue. thread = threading.Thread(target=read_output, daemon=True) thread.start() err_thread = threading.Thread(target=read_stderr, daemon=True) err_thread.start() last_activity = time.monotonic() while True: try: line = output_queue.get(timeout=0.5) last_activity = time.monotonic() if line is None: break yield line except queue.Empty: if process.poll() is not None and not thread.is_alive(): break if idle_timeout and (time.monotonic() - last_activity) > idle_timeout: terminate_process_tree() yield json.dumps( { "type": "error", "message": f"[bridge] idle timeout after {idle_timeout:.0f}s with no output", "_bridge_fatal": True, } ) break try: process.wait(timeout=5) except subprocess.TimeoutExpired: terminate_process_tree() process.wait() thread.join(timeout=5) err_thread.join(timeout=5) while not output_queue.empty(): try: line = output_queue.get_nowait() if line is not None: yield line except queue.Empty: break def windows_escape(prompt): """Windows style string escaping for newlines and special chars in prompt text.""" result = prompt.replace("\n", "\\n") result = result.replace("\r", "\\r") result = result.replace("\t", "\\t") return result def configure_windows_stdio() -> None: """Configure stdout/stderr to use UTF-8 encoding on Windows.""" if not _is_windows(): return for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if callable(reconfigure): with contextlib.suppress(ValueError, OSError): reconfigure(encoding="utf-8") def emit(result: dict) -> None: print(json.dumps(result, indent=2, ensure_ascii=False)) _LOG_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\s+") def collapse_stderr(lines: list[str]) -> str: """Each distinct stderr line once, first-seen order, with a repeat count. codex logs one `... without active item` ERROR per streamed delta (openai/codex#16801); the lines differ only in their tracing timestamp. """ counts = Counter(_LOG_TIMESTAMP.sub("", line) for line in lines if line.strip()) return "\n".join(f"{line} [x{n}]" if n > 1 else line for line, n in counts.items()) def run_passthrough(subcommand: str, extra: list[str], timeout: float = 120.0) -> None: """Thin passthrough to `codex <subcommand> ...` (mcp / plugin management).""" env = os.environ.copy() _augment_path_env(env) cmd = ["codex", subcommand] + extra popen_cmd = _prepare_popen_cmd(cmd, env) try: cp = subprocess.run( popen_cmd, shell=False, check=False, stdin=subprocess.DEVNULL, capture_output=True, timeout=timeout, text=True, encoding="utf-8", errors="replace", env=env, ) emit( { "success": cp.returncode == 0, "output": cp.stdout or "", "error": cp.stderr or "", "returncode": cp.returncode, } ) except subprocess.TimeoutExpired: emit({"success": False, "output": "", "error": f"codex {subcommand} timed out", "returncode": -1}) except FileNotFoundError: emit({"success": False, "output": "", "error": "codex binary not found in PATH", "returncode": 127}) def build_exec_cmd(args) -> list[str]: """Build `codex exec ...` argv from parsed run args.""" last_fd, last_msg_path = tempfile.mkstemp(prefix="codex_last_", suffix=".txt") os.close(last_fd) cmd = [ "codex", "exec", "--sandbox", args.sandbox, "--cd", args.cd, "--json", "--output-last-message", last_msg_path, ] if args.image: cmd.extend(["--image", ",".join(args.image)]) if args.model: cmd.extend(["--model", args.model]) if args.profile: cmd.extend(["--profile", args.profile]) if args.yolo: cmd.append("--dangerously-bypass-approvals-and-sandbox") if args.skip_git_repo_check: cmd.append("--skip-git-repo-check") if args.ignore_user_config: cmd.append("--ignore-user-config") if args.dangerously_bypass_hook_trust: cmd.append("--dangerously-bypass-hook-trust") if args.SESSION_ID: cmd.extend(["resume", args.SESSION_ID]) cmd += ["--", args.PROMPT] return cmd, last_msg_path def cmd_run(args) -> None: cmd, last_msg_path = build_exec_cmd(args) all_messages = [] agent_messages = "" success = True err_message = "" thread_id = None turn_completed = False timed_out = False stderr_sink: list[str] = [] # Persist the raw JSONL stream incrementally so partial output survives a # crash/kill/timeout (the in-memory result is only printed once at the end). if args.stream_file: stream_path = args.stream_file stream_fp = open(stream_path, "w", encoding="utf-8") # noqa: SIM115 (closed below) else: sfd, stream_path = tempfile.mkstemp(prefix="codex_stream_", suffix=".jsonl") stream_fp = os.fdopen(sfd, "w", encoding="utf-8") for line in run_shell_command(cmd, idle_timeout=args.idle_timeout, stderr_sink=stderr_sink): stream_fp.write(line + "\n") stream_fp.flush() try: line_dict = json.loads(line.strip()) all_messages.append(line_dict) item = line_dict.get("item", {}) item_type = item.get("type", "") if item_type == "agent_message": # codex emits one agent_message per preamble between tool calls # plus a final one; only the last non-empty text is the answer # (same semantics as --output-last-message). Concatenating them # pollutes the result with intermediate narration. agent_messages = item.get("text", "") or agent_messages if line_dict.get("thread_id") is not None: thread_id = line_dict.get("thread_id") if line_dict.get("_bridge_fatal"): timed_out = True success = False # Error/reconnect events appear either top-level (codex <= 0.130) or # item-level (codex 0.136: item.completed + item.type == "error"). top_type = line_dict.get("type", "") if top_type == "turn.completed": turn_completed = True err_text = "" if "fail" in top_type: err_text = (line_dict.get("error") or {}).get("message", "") or line_dict.get("message", "") elif "error" in top_type: err_text = line_dict.get("message", "") elif item_type == "error": err_text = item.get("message", "") if err_text: is_reconnecting = bool(re.match(r"^Reconnecting\.\.\.\s+\d+/\d+(\s|$)", err_text)) if not is_reconnecting: # Unconditional: the final reconciliation restores success # only for turns that actually completed. success = False err_message += "\n\n[codex error] " + err_text except json.JSONDecodeError: err_message += "\n\n[json decode error] " + line continue except Exception as error: err_message += "\n\n[unexpected error] " + f"Unexpected error: {error}. Line: {line!r}" success = False continue stream_fp.close() # Parse-independent fallback: codex wrote the final answer to a file via # --output-last-message, so a corrupted/dropped agent_message line is recoverable. if len(agent_messages) == 0: try: file_msg = Path(last_msg_path).read_text(encoding="utf-8", errors="replace").strip() except OSError: file_msg = "" if file_msg: agent_messages = file_msg if thread_id is None: success = False err_message = "Failed to get `SESSION_ID` from the codex session. \n\n" + err_message if len(agent_messages) == 0: success = False err_message = ( "Failed to get `agent_messages` from the codex session. \n\n You can try to set `return_all_messages` to `True` to get the full reasoning information. " + err_message ) if not turn_completed: # turn.failed / stream cut short: codex only emits the final answer # right before `turn.completed`, so every agent_message captured here # is an intermediate preamble. Hard failures (e.g. 429 retry # exhaustion) must surface instead of being masked by a preamble. success = False err_message = ( "Codex turn did not complete: no `turn.completed` event was observed; " "any captured agent_message is intermediate narration, not the final answer. " "Inspect `stream_file` for details." + ("\n\n" + err_message.lstrip() if err_message else "") ) elif len(agent_messages) > 0 and thread_id is not None and not timed_out: # Turn completed with both thread_id and agent_messages → transient errors # mid-stream (reconnects, decode noise) must not bury the final answer. # A hard idle timeout (timed_out) is exempt: never report it as success. success = True err_message = "" if success: result = { "success": True, "SESSION_ID": thread_id, "agent_messages": agent_messages, } else: result = {"success": False, "error": err_message} result["stream_file"] = stream_path if stderr_sink: result["stderr"] = collapse_stderr(stderr_sink) if args.return_all_messages: result["all_messages"] = all_messages with contextlib.suppress(OSError): os.unlink(last_msg_path) emit(result) def main(): configure_windows_stdio() # Subcommand passthrough must bypass argparse so trailing args stay intact # (e.g. `mcp list`, `plugin marketplace list`). if len(sys.argv) > 1 and sys.argv[1] in ("mcp", "plugin"): run_passthrough(sys.argv[1], sys.argv[2:]) return parser = argparse.ArgumentParser(description="Codex Bridge") parser.add_argument("--PROMPT", required=True, help="Instruction for the task to send to codex.") parser.add_argument("--cd", required=True, help="Set the workspace root for codex before executing the task.") parser.add_argument( "--sandbox", default="read-only", choices=["read-only", "workspace-write", "danger-full-access"], help="Sandbox policy for model-generated commands. Defaults to `read-only`.", ) parser.add_argument( "--SESSION_ID", default="", help="Resume the specified session of the codex. Defaults to `None`, start a new session.", ) parser.add_argument( "--skip-git-repo-check", action="store_true", default=True, help="Allow codex running outside a Git repository (useful for one-off directories).", ) parser.add_argument( "--return-all-messages", action="store_true", help="Return all messages (e.g. reasoning, tool calls, etc.) from the codex session. Set to `False` by default, only the agent's final reply message is returned.", ) parser.add_argument( "--image", action="append", default=[], help="Attach one or more image files to the initial prompt. Separate multiple paths with commas or repeat the flag.", ) parser.add_argument( "--model", default="", help="The model to use for the codex session. This parameter is strictly prohibited unless explicitly specified by the user.", ) parser.add_argument( "--yolo", action="store_true", help="Run every command without approvals or sandboxing. Only use when `sandbox` couldn't be applied.", ) parser.add_argument( "--profile", default="", help="Configuration profile name to load from `~/.codex/config.toml`. This parameter is strictly prohibited unless explicitly specified by the user.", ) parser.add_argument( "--stream-file", default="", help="Append each raw JSONL line here as it arrives so partial output survives a crash/kill/timeout. Empty -> auto temp file; the path is reported in the result.", ) parser.add_argument( "--idle-timeout", type=float, default=600.0, help="Terminate codex if no output is received for this many seconds (0 disables).", ) parser.add_argument( "--ignore-user-config", action="store_true", help="Do not load `$CODEX_HOME/config.toml` (auth still uses CODEX_HOME). Isolates the session from user global config.", ) parser.add_argument( "--dangerously-bypass-hook-trust", action="store_true", help="Run enabled Codex hooks without requiring persisted hook trust. DANGEROUS; only for vetted automation.", ) # Document subcommands in --help without going through argparse positional parse. sub = parser.add_subparsers(dest="subcommand") sub.add_parser("mcp", help="Thin passthrough to `codex mcp` (list/get/add/remove/login/logout).") sub.add_parser("plugin", help="Thin passthrough to `codex plugin` (add/list/remove/marketplace).") args = parser.parse_args() cmd_run(args) if __name__ == "__main__": main()
-
-
tests
-
test_codex_bridge.py 21.4 KB
"""Regression tests for codex_bridge.py. Run: python -m pytest skills/cc-codex/tests/test_codex_bridge.py These mock run_shell_command / subprocess; no real codex process is launched. """ import importlib.util import json import subprocess import sys import threading import time from pathlib import Path from types import SimpleNamespace _SRC = Path(__file__).resolve().parents[1] / "scripts" / "codex_bridge.py" _spec = importlib.util.spec_from_file_location("codex_bridge", _SRC) cb = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(cb) def test_no_test_mutates_the_shared_os_name(): """Tests must patch codex_bridge._is_windows, never the shared os.<name>. On Linux + py3.11 pathlib.Path.__new__ picks its flavour from that value and pytest builds Paths mid-run, so leaking it surfaces as INTERNALERROR: cannot instantiate 'WindowsPath' on your system. It cannot reproduce on Windows, where the value is already "nt" -- hence a source check rather than a value comparison, which cannot see a no-op patch. """ # split so this file does not match its own scan: the attribute spelling, # and the setattr form, which names the module and attribute as arguments needles = ("os." + "name", "os, " + '"name"') for path in sorted(Path(__file__).parent.rglob("*.py")): text = path.read_text(encoding="utf-8") for needle in needles: assert needle not in text, f"{path.name}: patch codex_bridge._is_windows, not the shared {needle}" def _run_main(monkeypatch, capsys, fake_run, argv_extra): monkeypatch.setattr(cb, "run_shell_command", fake_run) monkeypatch.setattr(sys, "argv", ["codex_bridge.py", "--PROMPT", "x", "--cd", "."] + argv_extra) cb.main() return json.loads(capsys.readouterr().out) def test_last_message_fallback_recovers_corrupted_answer(monkeypatch, capsys): """A+E: agent_message line is corrupted JSON, but --output-last-message holds the answer.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): path = cmd[cmd.index("--output-last-message") + 1] Path(path).write_text("hello world", encoding="utf-8") yield json.dumps({"type": "thread.started", "thread_id": "sess-123"}) yield '{"item": {"type": "agent_message", "text": "hel' # truncated/corrupt yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["agent_messages"] == "hello world" assert out["SESSION_ID"] == "sess-123" def test_stream_file_persists_every_line(monkeypatch, capsys, tmp_path): """B: every raw line is written to disk so a mid-run kill keeps partial output.""" sf = tmp_path / "stream.jsonl" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "s"}) yield json.dumps({"item": {"type": "agent_message", "text": "hi"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, ["--stream-file", str(sf)]) lines = sf.read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 3 assert json.loads(lines[1])["item"]["text"] == "hi" assert out["stream_file"] == str(sf) def test_idle_timeout_not_swallowed_by_reconciliation(monkeypatch, capsys): """C + cross-check issue #1: a hard idle timeout AFTER a partial answer must still report success=False, not be cleared by the success reconciliation.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-9"}) yield json.dumps({"item": {"type": "agent_message", "text": "partial..."}}) yield json.dumps( {"type": "error", "message": "[bridge] idle timeout after 600s with no output", "_bridge_fatal": True} ) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is False assert "idle timeout" in out["error"] def test_item_level_error_is_detected(monkeypatch, capsys): """E: codex 0.136 reports errors as item.completed/item.type==error (not top-level).""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-1"}) yield json.dumps({"type": "item.completed", "item": {"type": "error", "message": "boom at item level"}}) # no agent_message and codex wrote nothing to the last-message file out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is False assert "boom at item level" in out["error"] def test_item_level_reconnect_is_tolerated(monkeypatch, capsys): """E: an item-level transient reconnect must NOT bury a real final answer.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-2"}) yield json.dumps( { "type": "item.completed", "item": {"type": "error", "message": "Reconnecting... 1/5 (stream disconnected before completion: x)"}, } ) yield json.dumps({"item": {"type": "agent_message", "text": "final answer"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["agent_messages"] == "final answer" def test_happy_path_reports_stream_and_session(monkeypatch, capsys): """Baseline: normal turn still works after the changes.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-ok"}) yield json.dumps({"item": {"type": "agent_message", "text": "hello"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["agent_messages"] == "hello" assert "stream_file" in out def test_multiple_agent_messages_returns_only_last(monkeypatch, capsys): """Regression: codex emits one agent_message per preamble between tool calls plus a final one. Only the last is the answer; concatenating them pollutes the output (2MB file bug). Match --output-last-message semantics.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-multi"}) yield json.dumps( { "type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "First I'll read the file."}, } ) yield json.dumps( {"type": "item.started", "item": {"id": "item_1", "type": "command_execution", "command": "ls"}} ) yield json.dumps( { "type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "exit_code": 0}, } ) yield json.dumps( { "type": "item.completed", "item": {"id": "item_2", "type": "agent_message", "text": "Now checking the config."}, } ) yield json.dumps( {"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "FINAL ANSWER: done."}} ) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["agent_messages"] == "FINAL ANSWER: done." assert "First I'll read" not in out["agent_messages"] assert "Now checking" not in out["agent_messages"] def test_empty_final_agent_message_keeps_prior_nonempty(monkeypatch, capsys): """A trailing empty agent_message text must not wipe the real answer (codex never writes an empty final message to --output-last-message).""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-empty-final"}) yield json.dumps( {"type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "real answer"}} ) yield json.dumps({"type": "item.completed", "item": {"id": "item_1", "type": "agent_message", "text": ""}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["agent_messages"] == "real answer" def test_turn_failed_after_preambles_reports_failure(monkeypatch, capsys): """Regression: a hard 429 failure (turn.failed, no turn.completed) must not report the last preamble narration as the final answer with success=true. Event sequence replayed from a captured real stream.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-429"}) yield json.dumps( { "type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "I will locate the adapter first."}, } ) yield json.dumps( { "type": "item.completed", "item": {"id": "item_1", "type": "agent_message", "text": "Now converging the fix into two edits."}, } ) yield json.dumps({"type": "error", "message": "Reconnecting... 1/5 (stream disconnected before completion: x)"}) yield json.dumps({"type": "error", "message": "exceeded retry limit, last status: 429 Too Many Requests"}) yield json.dumps( {"type": "turn.failed", "error": {"message": "exceeded retry limit, last status: 429 Too Many Requests"}} ) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is False assert "429" in out["error"] assert "did not complete" in out["error"] assert "agent_messages" not in out def test_truncated_stream_without_turn_event_reports_failure(monkeypatch, capsys): """A stream that dies after preambles (no turn.completed/turn.failed, no error event) must not be reported as success.""" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-cut"}) yield json.dumps({"item": {"type": "agent_message", "text": "Working on it..."}}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is False assert "did not complete" in out["error"] assert "agent_messages" not in out def test_stderr_repeated_delta_noise_is_collapsed(monkeypatch, capsys): """Regression: codex logs one `... without active item` ERROR per streamed delta (openai/codex#16801); a 40-minute run left 2,822 stderr lines / 261 KB holding 7 distinct messages, burying the real errors. Repeats collapse to one line with a count in first-seen order; a unique line survives.""" delta = "ERROR codex_core::util: OutputTextDelta without active item" summary = "ERROR codex_core::util: ReasoningSummaryDelta without active item" unique = "ERROR codex_core::tools::router: error=failed to parse function arguments: missing field `target`" def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): stderr_sink.append(f"2026-09-20T09:13:27.998619Z {delta}") stderr_sink.append(f"2026-09-20T09:13:28.000001Z {summary}") stderr_sink.append(f"2026-09-20T09:20:00.000001Z {unique}") stderr_sink.extend(f"2026-09-20T09:30:00.{i:06d}Z {delta}" for i in range(1225)) stderr_sink.append(f"2026-09-20T09:53:10.103397Z {summary}") yield json.dumps({"type": "thread.started", "thread_id": "sess-noise"}) yield json.dumps({"item": {"type": "agent_message", "text": "answer"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert out["success"] is True assert out["stderr"].splitlines() == [f"{delta} [x1226]", f"{summary} [x2]", unique] def test_stderr_omitted_when_child_wrote_nothing(monkeypatch, capsys): def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): yield json.dumps({"type": "thread.started", "thread_id": "sess-quiet"}) yield json.dumps({"item": {"type": "agent_message", "text": "answer"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main(monkeypatch, capsys, fake_run, []) assert "stderr" not in out def test_ignore_user_config_and_hook_trust_flags_in_cmd(monkeypatch, capsys): captured = {} def fake_run(cmd, idle_timeout=300.0, stderr_sink=None): captured["cmd"] = cmd yield json.dumps({"type": "thread.started", "thread_id": "sess-flags"}) yield json.dumps({"item": {"type": "agent_message", "text": "ok"}}) yield json.dumps({"type": "turn.completed"}) out = _run_main( monkeypatch, capsys, fake_run, ["--ignore-user-config", "--dangerously-bypass-hook-trust"], ) assert out["success"] is True assert "--ignore-user-config" in captured["cmd"] assert "--dangerously-bypass-hook-trust" in captured["cmd"] assert captured["cmd"][:2] == ["codex", "exec"] def test_build_exec_cmd_default_omits_isolation_flags(): args = SimpleNamespace( PROMPT="p", cd=".", sandbox="read-only", image=[], model="", profile="", yolo=False, skip_git_repo_check=True, ignore_user_config=False, dangerously_bypass_hook_trust=False, SESSION_ID="", ) cmd, last = cb.build_exec_cmd(args) assert "--ignore-user-config" not in cmd assert "--dangerously-bypass-hook-trust" not in cmd assert "--skip-git-repo-check" in cmd assert cmd[:2] == ["codex", "exec"] assert "--ask-for-approval" not in cmd assert "--approval-policy" not in cmd Path(last).unlink(missing_ok=True) def test_build_exec_cmd_yolo_uses_canonical_bypass_flag(): """The bridge's --yolo maps to Codex's canonical bypass option.""" args = SimpleNamespace( PROMPT="p", cd=".", sandbox="read-only", image=[], model="", profile="", yolo=True, skip_git_repo_check=True, ignore_user_config=False, dangerously_bypass_hook_trust=False, SESSION_ID="", ) cmd, last = cb.build_exec_cmd(args) assert "--dangerously-bypass-approvals-and-sandbox" in cmd assert "--yolo" not in cmd assert "--ask-for-approval" not in cmd Path(last).unlink(missing_ok=True) def test_mcp_passthrough(monkeypatch, capsys): def fake_run(popen_cmd, **kwargs): return SimpleNamespace(returncode=0, stdout="mcp-list-ok\n", stderr="") monkeypatch.setattr(cb.subprocess, "run", fake_run) monkeypatch.setattr(sys, "argv", ["codex_bridge.py", "mcp", "list"]) cb.main() out = json.loads(capsys.readouterr().out) assert out["success"] is True assert "mcp-list-ok" in out["output"] def test_plugin_passthrough_timeout(monkeypatch, capsys): def fake_run(popen_cmd, **kwargs): raise subprocess.TimeoutExpired(cmd=popen_cmd, timeout=1) monkeypatch.setattr(cb.subprocess, "run", fake_run) monkeypatch.setattr(sys, "argv", ["codex_bridge.py", "plugin", "list"]) cb.main() out = json.loads(capsys.readouterr().out) assert out["success"] is False assert "timed out" in out["error"] class _FakeStdin: def __init__(self): self.chunks = [] self.closed = False def write(self, value): self.chunks.append(value) def close(self): self.closed = True class _FakeProcess: def __init__(self, pid=4242): self.pid = pid self.stdin = _FakeStdin() self.stdout = None self.stderr = None def poll(self): return 0 def wait(self, timeout=None): return 0 def terminate(self): pass def kill(self): pass def test_cmd_quote_rust_bat_encoding(monkeypatch): """Embedded quotes must survive the npm .cmd shim's double re-parse: "A \"Out of scope\" B" arrives as ONE argv entry, not three.""" monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") command = cb._prepare_popen_cmd( ["codex.cmd", "exec", "--", 'A "Out of scope" B 100% done'], {"COMSPEC": "cmd.exe"}, ) assert command.startswith('"cmd.exe" /d /s /c "') # quotes: doubled, never the broken quote-caret-quote-quote escape assert '"A ""Out of scope"" B 100' in command assert '"^""' not in command # percent: yt-dlp form, never a bare doubled % that survives to the child assert "100%%cd:~,% done" in command assert "100%% done" not in command def test_cmd_quote_empty_and_trailing_backslashes(monkeypatch): monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") command = cb._prepare_popen_cmd(["codex.cmd", "exec", "--", "\\"], {}) # trailing backslash run is doubled so the closing quote is not escaped assert command.endswith('"exec" "--" "\\\\""') def test_passthrough_keeps_new_quoting_and_devnull_stdin(monkeypatch): """mcp/plugin passthrough must use the new quoting but never take the stdin-prompt path (its last arg is not a PROMPT).""" monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") captured = {} def fake_popen(command, **kwargs): captured["command"] = command captured["kwargs"] = kwargs return _FakeProcess() monkeypatch.setattr(cb.subprocess, "Popen", fake_popen) list(cb.run_shell_command(["codex.cmd", "mcp", "list"], idle_timeout=0)) assert captured["kwargs"]["stdin"] is subprocess.DEVNULL assert '/d /s /c ""C:\\npm\\codex.cmd" "mcp" "list""' in captured["command"] def test_shim_exec_prompt_goes_through_stdin(monkeypatch): """On the Windows shim path the PROMPT positional is rewritten to `-` and delivered via stdin, so an 8k+ prompt can't hit "command line too long".""" monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") captured = {} def fake_popen(command, **kwargs): captured["command"] = command captured["kwargs"] = kwargs return _FakeProcess() monkeypatch.setattr(cb.subprocess, "Popen", fake_popen) list(cb.run_shell_command(["codex", "exec", "--", "x" * 9000], idle_timeout=0)) assert captured["kwargs"]["stdin"] is subprocess.PIPE assert captured["command"].endswith('"exec" "--" "-""') def test_shim_exec_prompt_stdin_delivered(monkeypatch): """Same as above but asserts the prompt bytes actually reach stdin.""" monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") proc = _FakeProcess() monkeypatch.setattr(cb.subprocess, "Popen", lambda command, **kwargs: proc) list(cb.run_shell_command(["codex", "exec", "--", "x" * 9000], idle_timeout=0)) for _ in range(100): if proc.stdin.closed: break time.sleep(0.01) assert "".join(proc.stdin.chunks) == "x" * 9000 assert proc.stdin.closed def test_posix_exec_keeps_devnull_stdin(monkeypatch): monkeypatch.setattr(cb, "_is_windows", lambda: False) captured = {} def fake_popen(command, **kwargs): captured["kwargs"] = kwargs return _FakeProcess() monkeypatch.setattr(cb.subprocess, "Popen", fake_popen) list(cb.run_shell_command(["codex", "exec", "--", "prompt"], idle_timeout=0)) assert captured["kwargs"]["stdin"] is subprocess.DEVNULL def test_windows_termination_kills_process_tree(monkeypatch): """terminate() on the cmd.exe wrapper orphans node; taskkill /T /F is required.""" monkeypatch.setattr(cb, "_is_windows", lambda: True) monkeypatch.setattr(cb, "_resolve_executable", lambda name, env: r"C:\npm\codex.cmd") calls = [] class _Proc(_FakeProcess): def __init__(self): super().__init__(pid=4321) self.exited = False def poll(self): return 0 if self.exited else None def wait(self, timeout=None): if timeout is not None and not self.exited: raise subprocess.TimeoutExpired("cmd", timeout) self.exited = True return 0 proc = _Proc() def fake_run(command, **kwargs): calls.append((command, kwargs)) proc.exited = True monkeypatch.setattr(cb.subprocess, "run", fake_run) monkeypatch.setattr(cb.subprocess, "Popen", lambda command, **kwargs: proc) list(cb.run_shell_command(["codex.cmd", "mcp", "list"], idle_timeout=0)) assert ["taskkill", "/T", "/F", "/PID", "4321"] in [c[0] for c in calls] def test_output_reader_is_daemon_thread(monkeypatch): """A blocked readline after a tree-kill that misses a grandchild must not keep the interpreter alive once the caller stops consuming.""" monkeypatch.setattr(cb, "_is_windows", lambda: False) created = [] real_thread = threading.Thread def factory(*args, **kwargs): t = real_thread(*args, **kwargs) created.append(t) return t monkeypatch.setattr(cb.threading, "Thread", factory) monkeypatch.setattr(cb.subprocess, "Popen", lambda command, **kwargs: _FakeProcess()) list(cb.run_shell_command(["codex", "exec", "--", "x"], idle_timeout=0)) assert created and all(t.daemon for t in created)
-
-
README.md 3.5 KB
# cc-codex A Claude Code **Agent Skill** that bridges Claude with OpenAI Codex CLI for multi-model collaboration on coding tasks. ## Overview This Skill enables Claude to delegate coding tasks to Codex CLI, combining the strengths of multiple AI models. Codex handles algorithm implementation, debugging, and code analysis while Claude orchestrates the workflow and refines the output. Codex config (`~/.codex/config.toml`), hooks, MCP servers, plugins, skills, and `AGENTS.md` are loaded by the **Codex process itself**. This bridge does not sync Claude Code MCP/hooks/plugins into Codex — the two runtimes stay separate. ## Features - **Multi-turn sessions**: Maintain conversation context across multiple interactions via `SESSION_ID` - **Sandboxed execution**: Three security levels (`read-only`, `workspace-write`, `danger-full-access`) - **JSON output**: Structured responses for easy parsing and integration - **Image support**: Attach images to prompts for visual context - **Cross-platform**: Windows path escaping handled automatically - **Config isolation**: Optional `--ignore-user-config` - **Hook trust bypass**: Optional `--dangerously-bypass-hook-trust` for vetted automation - **Management passthrough**: `mcp` / `plugin` subcommands forward to `codex mcp|plugin` ## Installation 1. Ensure [Codex CLI](https://github.com/openai/codex) is installed and available in your PATH 2. Copy this Skill to your Claude Code skills directory: - User-level: `~/.claude/skills/cc-codex/` - Project-level: `.claude/skills/cc-codex/` Or install via the DSkills marketplace entry `cc-codex`. ## Usage ### Basic ```bash python scripts/codex_bridge.py --cd "/path/to/project" --PROMPT "Analyze the authentication flow" ``` ### Multi-turn Session ```bash # Start a session python scripts/codex_bridge.py --cd "/project" --PROMPT "Review login.py for security issues" # Response includes SESSION_ID # Continue the session python scripts/codex_bridge.py --cd "/project" --SESSION_ID "uuid-from-response" --PROMPT "Suggest fixes for the issues found" ``` ### Manage Codex MCP / plugins ```bash python scripts/codex_bridge.py mcp list python scripts/codex_bridge.py plugin list python scripts/codex_bridge.py plugin marketplace list ``` ### Parameters | Parameter | Required | Description | |-----------|----------|-------------| | `--PROMPT` | Yes* | Task instruction (*not required for `mcp`/`plugin` subcommands) | | `--cd` | Yes* | Workspace root directory | | `--sandbox` | No | Security level: `read-only` (default), `workspace-write`, `danger-full-access` | | `--SESSION_ID` | No | Resume a previous session | | `--return-all-messages` | No | Include full reasoning trace in output | | `--image` | No | Attach image files (comma-separated or repeated) | | `--model` | No | Specify model (use only when explicitly requested) | | `--profile` | No | Codex profile name (use only when explicitly requested) | | `--yolo` | No | Bypass all approvals (use with caution) | | `--ignore-user-config` | No | Skip loading `$CODEX_HOME/config.toml` | | `--dangerously-bypass-hook-trust` | No | Run hooks without persisted trust (dangerous) | ### Output Format ```json { "success": true, "SESSION_ID": "uuid", "agent_messages": "Codex response text", "stream_file": "/tmp/codex_stream_....jsonl", "all_messages": [] } ``` Passthrough (`mcp` / `plugin`) output: ```json { "success": true, "output": "...", "error": "", "returncode": 0 } ``` ## License MIT License. See [LICENSE](LICENSE) for details. -
ruff.toml 656 B
# Lint and format baseline for this skill. # # Kept explicit rather than relying on ruff's default rule set: CI installs ruff # unpinned, so a future release that widens its defaults would otherwise turn # this job red without any change to the code. line-length = 120 target-version = "py311" [lint] select = [ "E", # pycodestyle errors "F", # pyflakes "W", # pycodestyle warnings "I", # isort "N", # pep8-naming "UP", # pyupgrade "B", # flake8-bugbear "A", # flake8-builtins "C4", # flake8-comprehensions "SIM", # flake8-simplify ] ignore = ["E501"] # line too long (handled by line-length above) -
SKILL.md 5 KB
--- name: cc-codex description: | Delegates coding tasks to Codex CLI for prototyping, debugging, and code review. Use when: (1) Backend/logic implementation, (2) Algorithm design and optimization, (3) Bug analysis and debugging, (4) API/database code generation, (5) Code quality review and refactoring. Triggers: "implement algorithm", "debug", "analyze code", "backend task", "API implementation", "optimize performance", "refactor", "generate prototype", "code review". IMPORTANT: Default sandbox="read-only" (analysis/review/patch generation); use sandbox="workspace-write" only when codex must run tests/builds to verify. Always request unified diff patches only. Supports multi-turn sessions via SESSION_ID. --- ## Quick Start ```bash python scripts/codex_bridge.py --cd "/path/to/project" --PROMPT "Your task" ``` **Output:** JSON with `success`, `SESSION_ID`, `agent_messages`, `stream_file` (path to the raw JSONL stream persisted line-by-line), optional `stderr` (codex diagnostics; lines repeated verbatim except for their timestamp are collapsed to one line with an `[xN]` count), and optional `error`. Headless `exec` runs with approvals disabled by Codex itself; bridge `--yolo` maps to Codex's `--dangerously-bypass-approvals-and-sandbox`. Stdin is detached, so an approval prompt could never be answered. On Windows the PROMPT is delivered via stdin (`-` positional) to bypass cmd.exe quoting/length limits. ## Parameters ``` usage: codex_bridge.py [-h] [--PROMPT PROMPT] [--cd CD] [--sandbox {read-only,workspace-write,danger-full-access}] [--SESSION_ID SESSION_ID] [--skip-git-repo-check] [--return-all-messages] [--image IMAGE] [--model MODEL] [--yolo] [--profile PROFILE] [--stream-file STREAM_FILE] [--idle-timeout IDLE_TIMEOUT] [--ignore-user-config] [--dangerously-bypass-hook-trust] {mcp,plugin} ... Codex Bridge options: --PROMPT PROMPT Instruction for the task to send to codex. --cd CD Set the workspace root for codex before executing the task. --sandbox {read-only,workspace-write,danger-full-access} Sandbox policy for model-generated commands. Defaults to `read-only`. --SESSION_ID SESSION_ID Resume the specified session of the codex. --skip-git-repo-check Allow codex running outside a Git repository. --return-all-messages Return all messages (reasoning, tool calls, etc.). --image IMAGE Attach image files to the initial prompt. --model MODEL Model for the session (only when user explicitly requests). --yolo Bypass approvals/sandboxing (last resort). --profile PROFILE Load `~/.codex/<name>.config.toml` profile (only when user requests). --stream-file STREAM_FILE Persist raw JSONL stream path. --idle-timeout IDLE_TIMEOUT Kill codex if no output for N seconds (default 600; 0 disables). --ignore-user-config Do not load `$CODEX_HOME/config.toml` (auth still uses CODEX_HOME). --dangerously-bypass-hook-trust Run Codex hooks without persisted hook trust. DANGEROUS. subcommands: mcp Thin passthrough to `codex mcp` (list/get/add/remove/login/logout). plugin Thin passthrough to `codex plugin` (add/list/remove/marketplace). ``` ## Multi-turn Sessions **Always capture `SESSION_ID`** from the first response for follow-up: ```bash # Initial task python scripts/codex_bridge.py --cd "/project" --PROMPT "Analyze auth in login.py" # Continue with SESSION_ID python scripts/codex_bridge.py --cd "/project" --SESSION_ID "uuid-from-response" --PROMPT "Write unit tests for that" ``` ## Config Inheritance | Source | Inherited by default? | |--------|------------------------| | `~/.codex/config.toml` | Yes (unless `--ignore-user-config`) | | Profile (`--profile`) | Only when explicitly passed | | Project `.codex/` (trusted) | Yes, when Codex trusts the project | | Hooks / MCP / plugins / skills / `AGENTS.md` | Yes, via Codex runtime | ## Management Passthrough ```bash python scripts/codex_bridge.py mcp list python scripts/codex_bridge.py plugin list ``` ## Common Patterns **Prototyping (read-only, request diffs):** ```bash python scripts/codex_bridge.py --cd "/project" --PROMPT "Generate unified diff to add logging" ``` **Verification (codex must run tests/builds — read-only blocks them):** ```bash python scripts/codex_bridge.py --cd "/project" --sandbox workspace-write --PROMPT "Run pytest, fix the failure, output a unified diff patch" ``` Keep requesting patch text; audit afterwards with `git status && git diff`. **Debug with full trace:** ```bash python scripts/codex_bridge.py --cd "/project" --PROMPT "Debug this error" --return-all-messages ``` **Headless hooks (vetted automation only):** ```bash python scripts/codex_bridge.py --cd "/project" --PROMPT "..." --dangerously-bypass-hook-trust ```
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.