diff --git a/skills/codex/SKILL.md b/skills/codex/SKILL.md index 1a12151..5f2b12a 100644 --- a/skills/codex/SKILL.md +++ b/skills/codex/SKILL.md @@ -18,9 +18,21 @@ Execute Codex CLI commands and parse structured JSON responses. Supports file re ## Usage -通过 Bash tool 调用: +**推荐方式**(使用 uv run,自动管理 Python 环境): ```bash -node ~/.claude/skills/codex/scripts/codex.js "" [model] [working_dir] +uv run ~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] +``` + +**备选方式**(直接执行或使用 Python): +```bash +~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] +# 或 +python3 ~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] +``` + +恢复会话: +```bash +uv run ~/.claude/skills/codex/scripts/codex.py resume "" [model] [working_dir] ``` ## Timeout Control @@ -37,19 +49,19 @@ node ~/.claude/skills/codex/scripts/codex.js "" [model] [working_dir] - `model` (optional): Model to use (default: gpt-5-codex) - `gpt-5-codex`: Default, optimized for code - `gpt-5`: Fast general purpose - - `o3`: Deep reasoning - - `o4-mini`: Quick tasks - - `codex-1`: Software engineering - `working_dir` (optional): Working directory (default: current) ### Return Format -Extracts `agent_message` from Codex JSON stream: +Extracts `agent_message` from Codex JSON stream and appends session ID: ``` Agent response text here... + +--- +SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14 ``` -Error format: +Error format (stderr): ``` ERROR: Error message ``` @@ -59,41 +71,69 @@ ERROR: Error message When calling via Bash tool, always include the timeout parameter: ``` Bash tool parameters: -- command: node ~/.claude/skills/codex/scripts/codex.js "" [model] [working_dir] +- command: uv run ~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] - timeout: 7200000 - description: ``` +Alternatives: +``` +# Direct execution (simplest) +- command: ~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] + +# Using python3 +- command: python3 ~/.claude/skills/codex/scripts/codex.py "" [model] [working_dir] +``` + ### Examples **Basic code analysis:** ```bash -# Via Bash tool with timeout parameter -node ~/.claude/skills/codex/scripts/codex.js "explain @src/main.ts" +# Recommended: via uv run (auto-manages Python environment) +uv run ~/.claude/skills/codex/scripts/codex.py "explain @src/main.ts" # timeout: 7200000 + +# Alternative: direct execution +~/.claude/skills/codex/scripts/codex.py "explain @src/main.ts" ``` **Refactoring with specific model:** ```bash -node ~/.claude/skills/codex/scripts/codex.js "refactor @src/utils for performance" "gpt-5" +uv run ~/.claude/skills/codex/scripts/codex.py "refactor @src/utils for performance" "gpt-5" # timeout: 7200000 ``` **Multi-file analysis:** ```bash -node ~/.claude/skills/codex/scripts/codex.js "analyze @. and find security issues" "gpt-5-codex" "/path/to/project" +uv run ~/.claude/skills/codex/scripts/codex.py "analyze @. and find security issues" "gpt-5-codex" "/path/to/project" # timeout: 7200000 ``` -**Quick task:** +**Resume previous session:** ```bash -node ~/.claude/skills/codex/scripts/codex.js "add comments to @utils.js" "gpt-5-codex" +# First session +uv run ~/.claude/skills/codex/scripts/codex.py "add comments to @utils.js" "gpt-5-codex" +# Output includes: SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14 + +# Continue the conversation +uv run ~/.claude/skills/codex/scripts/codex.py resume 019a7247-ac9d-71f3-89e2-a823dbd8fd14 "now add type hints" # timeout: 7200000 ``` +**Using python3 directly (alternative):** +```bash +python3 ~/.claude/skills/codex/scripts/codex.py "your task here" +``` + ## Notes -- Runs with `--dangerously-bypass-approvals-and-sandbox` for automation +- **Recommended**: Use `uv run` for automatic Python environment management (requires uv installed) +- **Alternative**: Direct execution `./codex.py` (uses system Python via shebang) +- Python implementation using standard library (zero dependencies) +- Cross-platform compatible (Windows/macOS/Linux) +- PEP 723 compliant (inline script metadata) +- Runs with `--dangerously-bypass-approvals-and-sandbox` for automation (new sessions only) - Uses `--skip-git-repo-check` to work in any directory - Streams progress, returns only final agent message +- Every execution returns a session ID for resuming conversations - Requires Codex CLI installed and authenticated diff --git a/skills/codex/scripts/codex.js b/skills/codex/scripts/codex.js deleted file mode 100755 index fb0f23d..0000000 --- a/skills/codex/scripts/codex.js +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node -import { spawn } from 'node:child_process'; - -const DEFAULT_MODEL = 'gpt-5-codex'; -const DEFAULT_WORKDIR = '.'; -const DEFAULT_TIMEOUT_MS = 7_200_000; // 2 hours -const FORCE_KILL_DELAY_MS = 5_000; - -const args = process.argv.slice(2); -const [task, modelArg, workdirArg] = args; - -const logError = (message) => { - process.stderr.write(`ERROR: ${message}\n`); -}; - -const logWarn = (message) => { - process.stderr.write(`WARN: ${message}\n`); -}; - -if (!task) { - logError('Task required'); - process.exit(1); -} - -const model = modelArg || DEFAULT_MODEL; -const workdir = workdirArg || DEFAULT_WORKDIR; - -const resolveTimeout = () => { - const raw = process.env.CODEX_TIMEOUT; - if (raw == null || raw === '') { - return DEFAULT_TIMEOUT_MS; - } - - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - logWarn(`Invalid CODEX_TIMEOUT "${raw}", falling back to ${DEFAULT_TIMEOUT_MS}ms`); - return DEFAULT_TIMEOUT_MS; - } - - return parsed; -}; - -const codexArgs = [ - 'e', - '-m', - model, - '--dangerously-bypass-approvals-and-sandbox', - '--skip-git-repo-check', - '-C', - workdir, - '--json', - task, -]; - -const child = spawn('codex', codexArgs, { - stdio: ['ignore', 'pipe', 'inherit'], -}); - -let timedOut = false; -let lastAgentMessage = null; -let stdoutBuffer = ''; -let forceKillTimer = null; - -const timeoutMs = resolveTimeout(); - -const forceTerminate = () => { - if (!child.killed) { - child.kill('SIGTERM'); - forceKillTimer = setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL'); - } - }, FORCE_KILL_DELAY_MS); - } -}; - -const timeoutHandle = setTimeout(() => { - timedOut = true; - logError('Codex execution timeout'); - forceTerminate(); -}, timeoutMs); - -const normalizeText = (text) => { - if (typeof text === 'string') { - return text; - } - if (Array.isArray(text)) { - return text.join(''); - } - return null; -}; - -const handleJsonLine = (line) => { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - - let event; - try { - event = JSON.parse(trimmed); - } catch (err) { - logWarn(`Failed to parse Codex output line: ${trimmed}`); - return; - } - - if ( - event && - event.type === 'item.completed' && - event.item && - event.item.type === 'agent_message' - ) { - const text = normalizeText(event.item.text); - if (text != null) { - lastAgentMessage = text; - } - } -}; - -child.stdout.on('data', (chunk) => { - stdoutBuffer += chunk.toString('utf8'); - let newlineIndex = stdoutBuffer.indexOf('\n'); - - while (newlineIndex !== -1) { - const line = stdoutBuffer.slice(0, newlineIndex); - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); - handleJsonLine(line); - newlineIndex = stdoutBuffer.indexOf('\n'); - } -}); - -child.stdout.on('end', () => { - if (stdoutBuffer) { - handleJsonLine(stdoutBuffer); - stdoutBuffer = ''; - } -}); - -child.on('error', (err) => { - clearTimeout(timeoutHandle); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - logError(`Failed to start Codex CLI: ${err.message}`); - process.exit(1); -}); - -child.on('close', (code, signal) => { - clearTimeout(timeoutHandle); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - - if (timedOut) { - process.exit(124); - return; - } - - if (code === 0) { - if (lastAgentMessage != null) { - process.stdout.write(`${lastAgentMessage}\n`); - process.exit(0); - } else { - logError('Codex completed without an agent_message output'); - process.exit(1); - } - return; - } - - if (signal) { - logError(`Codex terminated with signal ${signal}`); - process.exit(code ?? 1); - return; - } - - logError(`Codex exited with status ${code}`); - process.exit(code ?? 1); -}); diff --git a/skills/codex/scripts/codex.py b/skills/codex/scripts/codex.py new file mode 100755 index 0000000..f09b59e --- /dev/null +++ b/skills/codex/scripts/codex.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.8" +# dependencies = [] +# /// +""" +Codex CLI wrapper with cross-platform support and session management. + +Usage: + New session: uv run codex.py "task" [model] [workdir] + Resume: uv run codex.py resume "task" [model] [workdir] + Alternative: python3 codex.py "task" + Direct exec: ./codex.py "task" +""" +import subprocess +import json +import sys +import os +from typing import Optional + +DEFAULT_MODEL = 'gpt-5-codex' +DEFAULT_WORKDIR = '.' +DEFAULT_TIMEOUT = 7200 # 2 hours in seconds +FORCE_KILL_DELAY = 5 + + +def log_error(message: str): + """输出错误信息到 stderr""" + sys.stderr.write(f"ERROR: {message}\n") + + +def log_warn(message: str): + """输出警告信息到 stderr""" + sys.stderr.write(f"WARN: {message}\n") + + +def resolve_timeout() -> int: + """解析超时配置(秒)""" + raw = os.environ.get('CODEX_TIMEOUT', '') + if not raw: + return DEFAULT_TIMEOUT + + try: + parsed = int(raw) + if parsed <= 0: + log_warn(f"Invalid CODEX_TIMEOUT '{raw}', falling back to {DEFAULT_TIMEOUT}s") + return DEFAULT_TIMEOUT + # 环境变量是毫秒,转换为秒 + return parsed // 1000 if parsed > 10000 else parsed + except ValueError: + log_warn(f"Invalid CODEX_TIMEOUT '{raw}', falling back to {DEFAULT_TIMEOUT}s") + return DEFAULT_TIMEOUT + + +def normalize_text(text) -> Optional[str]: + """规范化文本:字符串或字符串数组""" + if isinstance(text, str): + return text + if isinstance(text, list): + return ''.join(text) + return None + + +def parse_args(): + """解析命令行参数""" + if len(sys.argv) < 2: + log_error('Task required') + sys.exit(1) + + # 检测是否为 resume 模式 + if sys.argv[1] == 'resume': + if len(sys.argv) < 4: + log_error('Resume mode requires: resume ') + sys.exit(1) + return { + 'mode': 'resume', + 'session_id': sys.argv[2], + 'task': sys.argv[3], + 'model': sys.argv[4] if len(sys.argv) > 4 else DEFAULT_MODEL, + 'workdir': sys.argv[5] if len(sys.argv) > 5 else DEFAULT_WORKDIR + } + else: + return { + 'mode': 'new', + 'task': sys.argv[1], + 'model': sys.argv[2] if len(sys.argv) > 2 else DEFAULT_MODEL, + 'workdir': sys.argv[3] if len(sys.argv) > 3 else DEFAULT_WORKDIR + } + + +def build_codex_args(params: dict) -> list: + """构建 codex CLI 参数""" + if params['mode'] == 'resume': + return [ + 'codex', 'e', + '--skip-git-repo-check', + '--json', + 'resume', + params['session_id'], + params['task'] + ] + else: + return [ + 'codex', 'e', + '-m', params['model'], + '--dangerously-bypass-approvals-and-sandbox', + '--skip-git-repo-check', + '-C', params['workdir'], + '--json', + params['task'] + ] + + +def main(): + params = parse_args() + codex_args = build_codex_args(params) + timeout_sec = resolve_timeout() + + thread_id: Optional[str] = None + last_agent_message: Optional[str] = None + + try: + # 启动 codex 子进程 + process = subprocess.Popen( + codex_args, + stdout=subprocess.PIPE, + stderr=sys.stderr, # 错误直接透传到 stderr + text=True, + bufsize=1 # 行缓冲 + ) + + # 逐行解析 JSON 输出 + for line in process.stdout: + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + + # 捕获 thread_id + if event.get('type') == 'thread.started': + thread_id = event.get('thread_id') + + # 捕获 agent_message + if (event.get('type') == 'item.completed' and + event.get('item', {}).get('type') == 'agent_message'): + text = normalize_text(event['item'].get('text')) + if text: + last_agent_message = text + + except json.JSONDecodeError: + log_warn(f"Failed to parse line: {line}") + + # 等待进程结束 + returncode = process.wait(timeout=timeout_sec) + + if returncode == 0: + if last_agent_message: + # 输出 agent_message + sys.stdout.write(f"{last_agent_message}\n") + + # 输出 session_id(如果存在) + if thread_id: + sys.stdout.write(f"\n---\nSESSION_ID: {thread_id}\n") + + sys.exit(0) + else: + log_error('Codex completed without agent_message output') + sys.exit(1) + else: + log_error(f'Codex exited with status {returncode}') + sys.exit(returncode) + + except subprocess.TimeoutExpired: + log_error('Codex execution timeout') + process.kill() + try: + process.wait(timeout=FORCE_KILL_DELAY) + except subprocess.TimeoutExpired: + pass + sys.exit(124) + + except FileNotFoundError: + log_error("codex command not found in PATH") + sys.exit(127) + + except KeyboardInterrupt: + process.terminate() + try: + process.wait(timeout=FORCE_KILL_DELAY) + except subprocess.TimeoutExpired: + process.kill() + sys.exit(130) + + +if __name__ == '__main__': + main()