diff --git a/.gitignore b/.gitignore index 0fc67c8..1bc656e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .claude-session .claude/ *.mp4 +tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 6f8b745..71c1623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -- 该文件夹是 claude_code_bridge (ccb) 开发文件夹,要注意兼容性,同时修改代码注意同时修改install,安装使用install安装,完成后要git增加版本并推送 \ No newline at end of file +- This is the claude_code_bridge (ccb) development folder. Pay attention to compatibility. When modifying code, also update install scripts. Use install.sh/install.ps1 to install. After completion, git commit and push. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f09f9ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,48 @@ +# AGPL-3.0 - GNU Affero General Public License v3.0 + +## English + +This software is licensed under the AGPL-3.0 license, which means: + +- **Attribution Required**: You must give appropriate credit, provide a link to the license, and indicate if changes were made. When using Claude Code Bridge (CCB), please credit the original project. + +- **Open Source Required**: If you modify this software and distribute it or run it as a service, you must release your source code under AGPL-3.0. + +- **Network Use (Copyleft)**: If you run this software as a network service (e.g., SaaS), users interacting with it over the network must be able to receive the source code. + +- **No Closed-Source Use**: You cannot use this software in proprietary/closed-source projects unless you open-source the entire project under AGPL-3.0. + +**In short**: You can use Claude Code Bridge for free, but if you build upon it, your code must also be open-sourced under AGPL-3.0 with attribution to this project. Closed-source commercial use requires a separate license. + +For commercial licensing inquiries (closed-source use), please contact the maintainer. + +--- + +## 中文 + +本软件采用 AGPL-3.0 许可协议,这意味着: + +- **署名要求**:您必须注明出处,提供许可协议链接,并说明是否进行了修改。使用 Claude Code Bridge (CCB) 时,请注明项目来源。 + +- **开源要求**:如果您修改此软件并将其分发或作为服务运行,则必须根据 AGPL-3.0 发布您的源代码。 + +- **网络使用(Copyleft)**:如果您将此软件作为网络服务(例如 SaaS)运行,则通过网络与其交互的用户必须能够接收源代码。 + +- **禁止闭源使用**:您不能在专有/闭源项目中使用此软件,除非您将整个项目根据 AGPL-3.0 开源。 + +**简单来说**:您可以免费使用 Claude Code Bridge,但如果您基于它进行开发,您的代码也必须根据 AGPL-3.0 开源,并注明本项目。闭源商业用途需要单独的许可证。 + +对于商业许可咨询(闭源使用),请联系维护者。 + +--- + +## Full License Text + +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +For the complete license text, see: https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/README.md b/README.md index 784b4ec..139ba4d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Windows | macOS | Linux — One Tool, All Platforms** -[![Version](https://img.shields.io/badge/version-2.1-orange.svg)]() +[![Version](https://img.shields.io/badge/version-2.2-orange.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)]() @@ -184,6 +184,8 @@ ccb update # Update to latest version - Python 3.10+ - tmux or WezTerm (at least one; WezTerm recommended) +> **⚠️ Windows Users:** Always install WezTerm using the **native Windows .exe installer** from [wezfurlong.org/wezterm](https://wezfurlong.org/wezterm/), even if you use WSL. Do NOT install WezTerm inside WSL. After installation, configure WezTerm to connect to WSL via `wsl.exe` as the default shell. This ensures proper split-pane functionality. + ## Uninstall ```bash @@ -370,8 +372,9 @@ ccb update # 更新到最新版本 ## 依赖 - Python 3.10+ -- tmux 或 WezTerm(至少安装一个),强烈推荐wezterm +- tmux 或 WezTerm(至少安装一个),强烈推荐 WezTerm +> **⚠️ Windows 用户注意:** 必须使用 **Windows 原生 .exe 安装包** 安装 WezTerm([下载地址](https://wezfurlong.org/wezterm/)),即使你使用 WSL 也是如此。**不要在 WSL 内部安装 WezTerm**。安装完成后,可在 WezTerm 设置中将默认 shell 配置为 `wsl.exe`,即可无缝接入 WSL 环境,同时保证分屏功能正常工作。 ## 卸载 diff --git a/bin/cask b/bin/cask index 27a053c..11c2da2 100755 --- a/bin/cask +++ b/bin/cask @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -cask - 将消息转发到 Codex 会话 +cask - Forward message to Codex session """ from __future__ import annotations import json @@ -12,12 +12,14 @@ from typing import Optional, Tuple script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() from terminal import get_backend_for_session, get_pane_id_from_session def _usage() -> None: - print("用法: cask <消息>", file=sys.stderr) + print("Usage: cask ", file=sys.stderr) def _load_session() -> Optional[dict]: @@ -46,10 +48,10 @@ def _load_session() -> Optional[dict]: def _resolve_session() -> Tuple[dict, str]: data = _load_session() if not data: - raise RuntimeError("❌ 未找到 Codex 会话,请先运行 ccb up codex") + raise RuntimeError("❌ Codex session not found, please run ccb up codex first") pane_id = get_pane_id_from_session(data) if not pane_id: - raise RuntimeError("❌ 会话配置无效") + raise RuntimeError("❌ Session config invalid") return data, pane_id @@ -67,12 +69,12 @@ def main(argv: list[str]) -> int: data, pane_id = _resolve_session() backend = get_backend_for_session(data) if not backend: - raise RuntimeError("❌ 无法初始化终端后端") + raise RuntimeError("❌ Cannot initialize terminal backend") if not backend.is_alive(pane_id): terminal = data.get("terminal", "tmux") - raise RuntimeError(f"❌ {terminal} 会话不存在: {pane_id}\n提示: 请确认 ccb 正在运行") + raise RuntimeError(f"❌ {terminal} session not found: {pane_id}\nHint: Please confirm ccb is running") backend.send_text(pane_id, raw_command) - print(f"✅ 已发送到 Codex ({pane_id})") + print(f"✅ Sent to Codex ({pane_id})") return 0 except Exception as exc: print(exc, file=sys.stderr) diff --git a/bin/cask-w b/bin/cask-w index 822cc7b..91de217 100755 --- a/bin/cask-w +++ b/bin/cask-w @@ -1,35 +1,94 @@ #!/usr/bin/env python3 """ -cask-w - 同步发送消息到 Codex 并等待回复 +cask-w - Send message to Codex and wait for reply (pure sync mode) +Designed to be run with Claude Code's run_in_background=true """ from __future__ import annotations +import os import sys from pathlib import Path +import json script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) - -from codex_comm import CodexCommunicator +from compat import setup_windows_encoding +setup_windows_encoding() def main(argv: list[str]) -> int: if len(argv) <= 1: - print("用法: cask-w <消息>", file=sys.stderr) + print("Usage: cask-w ", file=sys.stderr) return 1 message = " ".join(argv[1:]).strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) + print("❌ Message cannot be empty", file=sys.stderr) return 1 + from codex_comm import CodexCommunicator + from i18n import t + + def save_pending_state(state: dict) -> None: + session_file = Path.cwd() / ".codex-session" + if not session_file.exists(): + return + try: + with session_file.open("r", encoding="utf-8-sig") as handle: + data = json.load(handle) + data["pending_state"] = { + "log_path": str(state.get("log_path")) if state.get("log_path") else None, + "offset": int(state.get("offset", 0) or 0), + } + tmp_file = session_file.with_suffix(".tmp") + with tmp_file.open("w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + os.replace(tmp_file, session_file) + except Exception: + return + try: - comm = CodexCommunicator() - reply = comm.ask_sync(message, timeout=0) - return 0 if reply else 1 + comm = CodexCommunicator(lazy_init=True) + + # Check session health + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + print(f"❌ Session error: {status}", file=sys.stderr) + return 1 + + # Send message + print(f"🔔 {t('sending_to', provider='Codex')}", flush=True) + marker, state = comm._send_message(message) + comm._remember_codex_session(state.get("log_path") or comm.log_reader.current_log_path()) + + # Pure sync wait (default 1 hour, configurable via CCB_SYNC_TIMEOUT) + sync_timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + message_reply, _ = comm.log_reader.wait_for_message(state, sync_timeout) + + # Save to cache + cache_dir = Path.home() / ".cache" / "ccb" + cache_dir.mkdir(parents=True, exist_ok=True) + reply_file = cache_dir / "codex_last_reply.txt" + + if message_reply: + print(f"🤖 {t('reply_from', provider='Codex')}") + print(message_reply) + reply_file.write_text(message_reply, encoding="utf-8") + else: + print(f"⏰ Timeout after {int(sync_timeout)}s") + save_pending_state(state) + return 0 + except KeyboardInterrupt: + # Best-effort: preserve pending state so /cpend can fetch later. + try: + save_pending_state(locals().get("state", {}) if isinstance(locals().get("state"), dict) else {}) + except Exception: + pass + print("❌ Interrupted", file=sys.stderr) + return 130 except Exception as exc: - print(exc, file=sys.stderr) + print(f"❌ {exc}", file=sys.stderr) return 1 diff --git a/bin/cpend b/bin/cpend index b971983..8747d57 100755 --- a/bin/cpend +++ b/bin/cpend @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -cpend - 查看 Codex 最新回复 +cpend - View latest Codex reply """ import json @@ -10,67 +10,103 @@ from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() + +from i18n import t +from session_utils import safe_write_session try: from codex_comm import CodexCommunicator except ImportError as exc: - print(f"导入失败: {exc}") + print(f"Import failed: {exc}") sys.exit(1) +def _load_cached_reply() -> str | None: + """Load cached reply from background cask-w process""" + cache_file = Path.home() / ".cache" / "ccb" / "codex_last_reply.txt" + if not cache_file.exists(): + return None + try: + content = cache_file.read_text(encoding="utf-8").strip() + if content: + cache_file.unlink() # Clear after reading + return content + except Exception: + pass + return None + + def _load_pending_state() -> dict: - """从 .codex-session 加载 cask-w 超时时保存的状态""" + """Load pending state saved by cask-w timeout from .codex-session""" session_file = Path.cwd() / ".codex-session" if not session_file.exists(): return {} try: - with session_file.open("r", encoding="utf-8") as f: + with session_file.open("r", encoding="utf-8-sig") as f: data = json.load(f) - return data.get("pending_state", {}) + pending = data.get("pending_state", {}) + return pending if isinstance(pending, dict) else {} except Exception: return {} def _clear_pending_state() -> None: - """清除 pending_state""" + """Clear pending_state""" session_file = Path.cwd() / ".codex-session" if not session_file.exists(): return try: - with session_file.open("r", encoding="utf-8") as f: + with session_file.open("r", encoding="utf-8-sig") as f: data = json.load(f) if "pending_state" in data: del data["pending_state"] - with session_file.open("w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) except Exception: pass def main() -> int: try: + # First check cached reply from background cask-w + cached = _load_cached_reply() + if cached: + print(f"🤖 Codex reply:") + print(cached) + return 0 + comm = CodexCommunicator() pending = _load_pending_state() if pending and pending.get("log_path"): - state = { - "log_path": Path(pending["log_path"]), - "offset": pending.get("offset", 0), - } - message, _ = comm.log_reader.try_get_message(state) - if message: - _clear_pending_state() - print(message) - return 0 + try: + log_path = Path(str(pending.get("log_path"))).expanduser() + except Exception: + log_path = None + + # Avoid falling back to "global latest" if the pending log path is missing, + # otherwise cpend may display an unrelated reply from another project. + if log_path and log_path.exists(): + try: + offset = int(pending.get("offset", 0) or 0) + except Exception: + offset = 0 + state = {"log_path": str(log_path), "offset": offset} + message, _ = comm.log_reader.try_get_message(state) + if message: + _clear_pending_state() + print(message) + return 0 output = comm.consume_pending(display=False) if output: print(output) else: - print('暂无 Codex 回复') + print(t('no_reply_available', provider='Codex')) return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ {t('execution_failed', error=exc)}") return 1 diff --git a/bin/cping b/bin/cping index 730ca0e..79bad6d 100755 --- a/bin/cping +++ b/bin/cping @@ -1,17 +1,18 @@ #!/usr/bin/env python3 """ -cping 命令入口点 -测试与 Codex 的连通性 +cping command entry point +Test connectivity with Codex """ import sys import os from pathlib import Path -# 添加lib目录到Python路径 script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() try: from codex_comm import CodexCommunicator @@ -24,13 +25,13 @@ try: return 0 if healthy else 1 except Exception as e: - print(f"❌ 连通性测试失败: {e}") + print(f"❌ Connectivity test failed: {e}") return 1 if __name__ == "__main__": sys.exit(main()) except ImportError as e: - print(f"❌ 导入模块失败: {e}") - print("请确保 codex_comm.py 在同一目录下") + print(f"❌ Module import failed: {e}") + print("Please ensure codex_comm.py is in the same directory") sys.exit(1) diff --git a/bin/gask b/bin/gask index c194292..dba7dc8 100755 --- a/bin/gask +++ b/bin/gask @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -gask - 异步发送消息到 Gemini +gask - Async send message to Gemini """ from __future__ import annotations @@ -11,18 +11,20 @@ from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() from gemini_comm import GeminiCommunicator def main(argv: list[str]) -> int: if len(argv) <= 1: - print("用法: gask <消息>", file=sys.stderr) + print("Usage: gask ", file=sys.stderr) return 1 message = " ".join(argv[1:]).strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) + print("❌ Message cannot be empty", file=sys.stderr) return 1 try: diff --git a/bin/gask-w b/bin/gask-w index 518f1be..96267eb 100755 --- a/bin/gask-w +++ b/bin/gask-w @@ -1,39 +1,113 @@ #!/usr/bin/env python3 """ -gask-w - 同步发送消息到 Gemini 并等待回复 +gask-w - Send message to Gemini and wait for reply (pure sync mode) +Designed to be run with Claude Code's run_in_background=true """ - from __future__ import annotations - +import os import sys from pathlib import Path +import json script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) - -from gemini_comm import GeminiCommunicator +from compat import setup_windows_encoding +setup_windows_encoding() def main(argv: list[str]) -> int: if len(argv) <= 1: - print("用法: gask-w <消息>", file=sys.stderr) + print("Usage: gask-w ", file=sys.stderr) return 1 message = " ".join(argv[1:]).strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) + print("❌ Message cannot be empty", file=sys.stderr) return 1 + from gemini_comm import GeminiCommunicator + from i18n import t + + def save_pending_state(state: dict) -> None: + session_file = Path.cwd() / ".gemini-session" + if not session_file.exists(): + return + try: + with session_file.open("r", encoding="utf-8-sig") as handle: + data = json.load(handle) + data["pending_state"] = { + "session_path": str(state.get("session_path")) if state.get("session_path") else None, + "msg_count": int(state.get("msg_count", 0) or 0), + "mtime": float(state.get("mtime", 0.0) or 0.0), + "mtime_ns": int(state.get("mtime_ns", 0) or 0), + "size": int(state.get("size", 0) or 0), + "last_gemini_id": state.get("last_gemini_id"), + "last_gemini_hash": state.get("last_gemini_hash"), + } + tmp_file = session_file.with_suffix(".tmp") + with tmp_file.open("w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + os.replace(tmp_file, session_file) + except Exception: + return + + # Save to cache + cache_dir = Path.home() / ".cache" / "ccb" + cache_dir.mkdir(parents=True, exist_ok=True) + reply_file = cache_dir / "gemini_last_reply.txt" + # Clear stale cache + if reply_file.exists(): + try: + reply_file.unlink() + except Exception: + pass + try: - comm = GeminiCommunicator() - reply = comm.ask_sync(message, timeout=0) - return 0 if reply else 1 + comm = GeminiCommunicator(lazy_init=True) + + # Check session health + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + print(f"❌ Session error: {status}", file=sys.stderr) + return 1 + + # Send message + print(f"🔔 {t('sending_to', provider='Gemini')}", flush=True) + marker, state = comm._send_message(message) + comm._remember_gemini_session(state.get("session_path") or comm.log_reader.current_session_path()) + + # Pure sync wait (default 1 hour, configurable via CCB_SYNC_TIMEOUT) + sync_timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + message_reply, new_state = comm.log_reader.wait_for_message(state, sync_timeout) + state = new_state or state + comm._remember_gemini_session(state.get("session_path") or comm.log_reader.current_session_path()) + + # Save to cache + cache_dir = Path.home() / ".cache" / "ccb" + cache_dir.mkdir(parents=True, exist_ok=True) + reply_file = cache_dir / "gemini_last_reply.txt" + + if message_reply: + print(f"🤖 {t('reply_from', provider='Gemini')}") + print(message_reply) + reply_file.write_text(message_reply, encoding="utf-8") + else: + print(f"⏰ Timeout after {int(sync_timeout)}s") + save_pending_state(state) + return 0 + except KeyboardInterrupt: + try: + save_pending_state(locals().get("state", {}) if isinstance(locals().get("state"), dict) else {}) + except Exception: + pass + print("❌ Interrupted", file=sys.stderr) + return 130 except Exception as exc: print(f"❌ {exc}", file=sys.stderr) return 1 if __name__ == "__main__": - sys.exit(main(sys.argv)) + raise SystemExit(main(sys.argv)) diff --git a/bin/gpend b/bin/gpend index 7c463d3..0faf63c 100755 --- a/bin/gpend +++ b/bin/gpend @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -gpend - 查看 Gemini 最新回复 +gpend - View latest Gemini reply """ import sys @@ -9,25 +9,51 @@ from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() + +from i18n import t try: from gemini_comm import GeminiCommunicator except ImportError as exc: - print(f"导入失败: {exc}") + print(f"Import failed: {exc}") sys.exit(1) +def _load_cached_reply() -> str | None: + """Load cached reply from background gask-w process""" + cache_file = Path.home() / ".cache" / "ccb" / "gemini_last_reply.txt" + if not cache_file.exists(): + return None + try: + content = cache_file.read_text(encoding="utf-8").strip() + if content: + cache_file.unlink() # Clear after reading + return content + except Exception: + pass + return None + + def main() -> int: try: + # First check cached reply from background gask-w + cached = _load_cached_reply() + if cached: + print(f"🤖 Gemini reply:") + print(cached) + return 0 + comm = GeminiCommunicator() output = comm.consume_pending(display=False) if output: print(output) else: - print('暂无 Gemini 回复') + print(t('no_reply_available', provider='Gemini')) return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ {t('execution_failed', error=exc)}") return 1 diff --git a/bin/gping b/bin/gping index cff918f..d78e07f 100755 --- a/bin/gping +++ b/bin/gping @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -gping - 测试 Gemini 连通性 +gping - Test Gemini connectivity """ import sys @@ -9,6 +9,8 @@ from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() try: from gemini_comm import GeminiCommunicator @@ -20,12 +22,12 @@ try: print(message) return 0 if healthy else 1 except Exception as e: - print(f"❌ Gemini 连通性测试失败: {e}") + print(f"❌ Gemini connectivity test failed: {e}") return 1 if __name__ == "__main__": sys.exit(main()) except ImportError as e: - print(f"❌ 导入模块失败: {e}") + print(f"❌ Module import failed: {e}") sys.exit(1) diff --git a/ccb b/ccb index a62e8c4..33fbaba 100755 --- a/ccb +++ b/ccb @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -ccb (Claude Code Bridge) - 统一 AI 启动器 -支持 Claude + Codex / Claude + Gemini / 三者同时 -支持 tmux、WezTerm 和 iTerm2 终端 +ccb (Claude Code Bridge) - Unified AI Launcher +Supports Claude + Codex / Claude + Gemini / all three simultaneously +Supports tmux, WezTerm and iTerm2 terminals """ import sys @@ -19,20 +19,155 @@ import platform import tempfile import re import shutil +import posixpath from pathlib import Path script_dir = Path(__file__).resolve().parent sys.path.insert(0, str(script_dir / "lib")) from terminal import TmuxBackend, WeztermBackend, Iterm2Backend, detect_terminal, is_wsl, get_shell_type +from compat import setup_windows_encoding +from ccb_config import get_backend_env +from session_utils import safe_write_session, check_session_writable +from i18n import t -VERSION = "2.1" +setup_windows_encoding() + +backend_env = get_backend_env() +if backend_env and not os.environ.get("CCB_BACKEND_ENV"): + os.environ["CCB_BACKEND_ENV"] = backend_env + +VERSION = "2.2" +GIT_COMMIT = "" +GIT_DATE = "" + +_WIN_DRIVE_RE = re.compile(r"^[A-Za-z]:([/\\\\]|$)") +_MNT_DRIVE_RE = re.compile(r"^/mnt/([A-Za-z])/(.*)$") +_MSYS_DRIVE_RE = re.compile(r"^/([A-Za-z])/(.*)$") + + +def _looks_like_windows_path(value: str) -> bool: + s = value.strip() + if not s: + return False + if _WIN_DRIVE_RE.match(s): + return True + if s.startswith("\\\\") or s.startswith("//"): + return True + return False + + +def _normalize_path_for_match(value: str) -> str: + """ + Normalize a path-like string for loose matching across Windows/WSL/MSYS variations. + This is used only for selecting a session for *current* cwd, so favor robustness. + """ + s = (value or "").strip() + if not s: + return "" + + # Expand "~" early (common in shell-originated values). If expansion fails, keep original. + if s.startswith("~"): + try: + s = os.path.expanduser(s) + except Exception: + pass + + # If the path is relative, absolutize it against current cwd for matching purposes only. + # This reduces false negatives when upstream tools record a relative cwd. + # NOTE: treat Windows-like absolute paths as absolute even on non-Windows hosts. + try: + preview = s.replace("\\", "/") + is_abs = ( + preview.startswith("/") + or preview.startswith("//") + or bool(_WIN_DRIVE_RE.match(preview)) + or preview.startswith("\\\\") + ) + if not is_abs: + s = str((Path.cwd() / Path(s)).absolute()) + except Exception: + pass + + s = s.replace("\\", "/") + + # Map WSL drive mount to Windows-style drive path for comparison. + m = _MNT_DRIVE_RE.match(s) + if m: + drive = m.group(1).lower() + rest = m.group(2) + s = f"{drive}:/{rest}" + else: + # Map MSYS /c/... to c:/... (Git-Bash/MSYS2 environments on Windows). + m = _MSYS_DRIVE_RE.match(s) + if m and ("MSYSTEM" in os.environ or os.name == "nt"): + drive = m.group(1).lower() + rest = m.group(2) + s = f"{drive}:/{rest}" + + # Collapse redundant separators and dot segments using POSIX semantics (we forced "/"). + # Preserve UNC double-slash prefix. + if s.startswith("//"): + prefix = "//" + rest = s[2:] + rest = posixpath.normpath(rest) + s = prefix + rest.lstrip("/") + else: + s = posixpath.normpath(s) + + # Normalize Windows drive letter casing (c:/..., not C:/...). + if _WIN_DRIVE_RE.match(s): + s = s[0].lower() + s[1:] + + # Drop trailing slash (but keep "/" and "c:/"). + if len(s) > 1 and s.endswith("/"): + s = s.rstrip("/") + if _WIN_DRIVE_RE.match(s) and not s.endswith("/"): + # Ensure drive root keeps trailing slash form "c:/". + if len(s) == 2: + s = s + "/" + + # On Windows-like paths, compare case-insensitively to avoid drive letter/case issues. + if _looks_like_windows_path(s): + s = s.casefold() + + return s + + +def _work_dir_match_keys(work_dir: Path) -> set[str]: + keys: set[str] = set() + candidates: list[str] = [] + for raw in (os.environ.get("PWD"), str(work_dir)): + if raw: + candidates.append(raw) + try: + candidates.append(str(work_dir.resolve())) + except Exception: + pass + for candidate in candidates: + normalized = _normalize_path_for_match(candidate) + if normalized: + keys.add(normalized) + return keys + + +def _extract_session_work_dir_norm(session_data: dict) -> str: + """Extract a normalized work dir marker from a session file payload.""" + if not isinstance(session_data, dict): + return "" + raw_norm = session_data.get("work_dir_norm") + if isinstance(raw_norm, str) and raw_norm.strip(): + return _normalize_path_for_match(raw_norm) + raw = session_data.get("work_dir") + if isinstance(raw, str) and raw.strip(): + return _normalize_path_for_match(raw) + return "" def _get_git_info() -> str: try: result = subprocess.run( ["git", "-C", str(script_dir), "log", "-1", "--format=%h %ci"], - capture_output=True, text=True, timeout=2 + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=2 ) if result.returncode == 0: return result.stdout.strip() @@ -78,68 +213,88 @@ class AILauncher: self.processes = {} def _detect_terminal_type(self): - # 环境变量强制指定 + # Forced by environment variable forced = (os.environ.get("CCB_TERMINAL") or os.environ.get("CODEX_TERMINAL") or "").strip().lower() if forced in {"wezterm", "tmux"}: return forced - # 在 WezTerm pane 内时,强制使用 wezterm,完全不依赖 tmux + # When inside WezTerm pane, force wezterm, no tmux dependency if os.environ.get("WEZTERM_PANE"): return "wezterm" - # 只有在 iTerm2 环境中才用 iTerm2 分屏 + # Only use iTerm2 split when in iTerm2 environment if os.environ.get("ITERM_SESSION_ID"): return "iterm2" - # 使用 detect_terminal() 自动检测(WezTerm 优先) + # Use detect_terminal() for auto-detection (WezTerm preferred) detected = detect_terminal() if detected: return detected - # 兜底:如果都没有,返回 None 让后续逻辑处理 + # Fallback: if nothing found, return None for later handling return None def _detect_launch_terminal(self): - """选择用于启动新窗口的终端程序(仅 tmux 模式下使用)""" - # WezTerm 模式不需要外部终端程序 + """Select terminal program for launching new windows (tmux mode only)""" + # WezTerm mode doesn't need external terminal program if self.terminal_type == "wezterm": return None - # tmux 模式下选择终端 + # tmux mode: select terminal terminals = ["gnome-terminal", "konsole", "alacritty", "xterm"] for term in terminals: if shutil.which(term): return term return "tmux" + def _launch_script_in_macos_terminal(self, script_file: Path) -> bool: + """macOS: Use Terminal.app to open new window for script (avoid tmux launcher nesting issues)""" + if platform.system() != "Darwin": + return False + if not shutil.which("osascript"): + return False + env = os.environ.copy() + env["CCB_WRAPPER_SCRIPT"] = str(script_file) + subprocess.Popen( + [ + "osascript", + "-e", + 'tell application "Terminal" to do script "/bin/bash " & quoted form of (system attribute "CCB_WRAPPER_SCRIPT")', + "-e", + 'tell application "Terminal" to activate', + ], + env=env, + ) + return True + def _start_provider(self, provider: str) -> bool: - # 处理未检测到终端的情况 + # Handle case when no terminal detected if self.terminal_type is None: - print("❌ 未检测到可用的终端后端(WezTerm 或 tmux)") - print(" 解决方案:") - print(" - 安装 WezTerm(推荐): https://wezfurlong.org/wezterm/") - print(" - 或安装 tmux") - print(" - 或设置环境变量 CCB_TERMINAL=wezterm 并配置 CODEX_WEZTERM_BIN") + print(f"❌ {t('no_terminal_backend')}") + print(f" {t('solutions')}") + print(f" - {t('install_wezterm')}") + print(f" - {t('or_install_tmux')}") + print(f" - {t('or_set_ccb_terminal')}") return False - # WezTerm 模式:完全不依赖 tmux + # WezTerm mode: no tmux dependency if self.terminal_type == "wezterm": - print(f"🚀 启动 {provider.capitalize()} 后端 (wezterm)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='wezterm')}") return self._start_provider_wezterm(provider) elif self.terminal_type == "iterm2": return self._start_provider_iterm2(provider) - # tmux 模式:检查 tmux 是否可用 + # tmux mode: check if tmux is available if not shutil.which("tmux"): - # 尝试 fallback 到 WezTerm + # Try fallback to WezTerm if detect_terminal() == "wezterm": self.terminal_type = "wezterm" - print(f"🚀 启动 {provider.capitalize()} 后端 (wezterm - tmux 不可用)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='wezterm - tmux unavailable')}") return self._start_provider_wezterm(provider) else: - print("❌ tmux 未安装,且 WezTerm 不可用") - print(" 解决方案: 安装 WezTerm(推荐)或 tmux") + print(f"❌ {t('tmux_not_installed')}") + print(f" {t('install_wezterm_or_tmux')}") return False - print(f"🚀 启动 {provider.capitalize()} 后端 (tmux)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='tmux')}") tmux_session = f"{provider}-{int(time.time()) % 100000}-{os.getpid()}" self.tmux_sessions[provider] = tmux_session @@ -149,7 +304,7 @@ class AILauncher: elif provider == "gemini": return self._start_gemini(tmux_session) else: - print(f"❌ 未知的 provider: {provider}") + print(f"❌ {t('unknown_provider', provider=provider)}") return False def _start_provider_wezterm(self, provider: str) -> bool: @@ -177,12 +332,12 @@ class AILauncher: if provider == "codex": input_fifo = runtime / "input.fifo" output_fifo = runtime / "output.fifo" - # WezTerm 模式通过 pane 注入文本,不强依赖 FIFO;Windows/WSL 场景也不一定支持 mkfifo。 + # WezTerm mode injects text via pane, no strong FIFO dependency; Windows/WSL may not support mkfifo self._write_codex_session(runtime, None, input_fifo, output_fifo, pane_id=pane_id) else: self._write_gemini_session(runtime, None, pane_id=pane_id) - print(f"✅ {provider.capitalize()} 已启动 (wezterm pane: {pane_id})") + print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='wezterm pane', pane_id=pane_id)}") return True def _start_provider_iterm2(self, provider: str) -> bool: @@ -190,7 +345,7 @@ class AILauncher: runtime.mkdir(parents=True, exist_ok=True) start_cmd = self._get_start_cmd(provider) - # iTerm2 分屏里,进程退出会导致 pane 直接关闭;默认保持 pane 打开便于查看退出信息。 + # In iTerm2 split, process exit will close pane; keep pane open by default to view exit info keep_open = os.environ.get("CODEX_ITERM2_KEEP_OPEN", "1").lower() not in {"0", "false", "no", "off"} if keep_open: start_cmd = ( @@ -216,12 +371,12 @@ class AILauncher: if provider == "codex": input_fifo = runtime / "input.fifo" output_fifo = runtime / "output.fifo" - # iTerm2 模式通过 pane 注入文本,不强依赖 FIFO + # iTerm2 mode injects text via pane, no strong FIFO dependency self._write_codex_session(runtime, None, input_fifo, output_fifo, pane_id=pane_id) else: self._write_gemini_session(runtime, None, pane_id=pane_id) - print(f"✅ {provider.capitalize()} 已启动 (iterm2 session: {pane_id})") + print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='iterm2 session', pane_id=pane_id)}") return True def _work_dir_strings(self, work_dir: Path) -> list[str]: @@ -247,14 +402,17 @@ class AILauncher: try: if not path.exists(): return {} - data = json.loads(path.read_text()) + # Session files are written as UTF-8; on Windows PowerShell 5.1 the default encoding + # may not be UTF-8, so always decode explicitly and tolerate UTF-8 BOM. + raw = path.read_text(encoding="utf-8-sig") + data = json.loads(raw) return data if isinstance(data, dict) else {} except Exception: return {} def _write_json_file(self, path: Path, data: dict) -> None: try: - path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass @@ -265,16 +423,26 @@ class AILauncher: data = self._read_json_file(self._claude_session_file()) sid = data.get("claude_session_id") or data.get("session_id") if isinstance(sid, str) and sid.strip(): + # Guard against path-format mismatch (Windows case/slash differences, MSYS paths, etc.). + recorded_norm = _extract_session_work_dir_norm(data) + if not recorded_norm: + # Old/foreign session file without a recorded work dir: refuse to resume to avoid cross-project reuse. + return None + current_keys = _work_dir_match_keys(Path.cwd()) + if current_keys and recorded_norm not in current_keys: + return None return sid.strip() return None def _write_local_claude_session(self, session_id: str, active: bool = True) -> None: path = self._claude_session_file() data = self._read_json_file(path) + work_dir = Path.cwd() data.update( { "claude_session_id": session_id, - "work_dir": str(Path.cwd()), + "work_dir": str(work_dir), + "work_dir_norm": _normalize_path_for_match(str(work_dir)), "active": bool(active), "started_at": data.get("started_at") or time.strftime("%Y-%m-%d %H:%M:%S"), "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), @@ -290,13 +458,55 @@ class AILauncher: # Only trust local project state; deleting local dotfiles should reset resume behavior. project_session = Path.cwd() / ".codex-session" if project_session.exists(): + data = self._read_json_file(project_session) + cached = data.get("codex_session_id") + if isinstance(cached, str) and cached: + recorded_norm = _extract_session_work_dir_norm(data) + if recorded_norm: + work_keys = _work_dir_match_keys(Path.cwd()) + if not work_keys or recorded_norm in work_keys: + return cached, True + + # Fallback: scan Codex session logs for the latest session bound to this cwd. + # This handles cases where `.codex-session` exists but never got updated with `codex_session_id` + # (e.g., user closed the backend before sending any message through the bridge). + root = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() + if not root.exists(): + return None, False + work_keys = _work_dir_match_keys(Path.cwd()) + if not work_keys: + return None, False + try: + logs = sorted( + (p for p in root.glob("**/*.jsonl") if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + except Exception: + logs = [] + for log_path in logs[:400]: try: - data = json.loads(project_session.read_text()) - cached = data.get("codex_session_id") - if isinstance(cached, str) and cached: - return cached, True + with log_path.open("r", encoding="utf-8", errors="ignore") as handle: + first = handle.readline().strip() + except OSError: + continue + if not first: + continue + try: + entry = json.loads(first) except Exception: - pass + continue + if not isinstance(entry, dict) or entry.get("type") != "session_meta": + continue + payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} + cwd = payload.get("cwd") + if not isinstance(cwd, str) or not cwd.strip(): + continue + if _normalize_path_for_match(cwd) not in work_keys: + continue + sid = payload.get("id") + if isinstance(sid, str) and sid: + return sid, True return None, False def _build_codex_start_cmd(self) -> str: @@ -306,11 +516,11 @@ class AILauncher: session_id, has_history = self._get_latest_codex_session_id() if session_id: cmd = f"{cmd} resume {session_id}" - print(f"🔁 Resuming Codex session: {session_id[:8]}...") + print(f"🔁 {t('resuming_session', provider='Codex', session_id=session_id[:8])}") codex_resumed = True if not codex_resumed: - print("ℹ️ No Codex history found, starting fresh") + print(f"ℹ️ {t('no_history_fresh', provider='Codex')}") return cmd def _get_latest_gemini_project_hash(self) -> tuple[str | None, bool]: @@ -318,20 +528,40 @@ class AILauncher: Returns (project_hash, has_any_history_for_cwd). Gemini CLI stores sessions under ~/.gemini/tmp//chats/. """ - # Only trust local project state; deleting local dotfiles should reset resume behavior. - project_session = Path.cwd() / ".gemini-session" - if not project_session.exists(): - return None, False + import hashlib + + gemini_root = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() + + candidates: list[str] = [] try: - data = json.loads(project_session.read_text()) + candidates.append(str(Path.cwd().absolute())) except Exception: - data = {} - if not isinstance(data, dict): - return None, True - project_hash = data.get("gemini_project_hash") - if isinstance(project_hash, str) and project_hash.strip(): - return project_hash.strip(), True - return None, True + pass + try: + candidates.append(str(Path.cwd().resolve())) + except Exception: + pass + env_pwd = (os.environ.get("PWD") or "").strip() + if env_pwd: + try: + candidates.append(os.path.abspath(os.path.expanduser(env_pwd))) + except Exception: + candidates.append(env_pwd) + + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + project_hash = hashlib.sha256(candidate.encode()).hexdigest() + chats_dir = gemini_root / project_hash / "chats" + if not chats_dir.exists(): + continue + session_files = list(chats_dir.glob("session-*.json")) + if session_files: + return project_hash, True + + return None, False def _build_gemini_start_cmd(self) -> str: cmd = "gemini --yolo" if self.auto else "gemini" @@ -339,9 +569,9 @@ class AILauncher: _, has_history = self._get_latest_gemini_project_hash() if has_history: cmd = f"{cmd} --resume latest" - print("🔁 Resuming Gemini session...") + print(f"🔁 {t('resuming_session', provider='Gemini', session_id='')}") else: - print("ℹ️ No Gemini history found, starting fresh") + print(f"ℹ️ {t('no_history_fresh', provider='Gemini')}") return cmd def _warmup_provider(self, provider: str, timeout: float = 8.0) -> bool: @@ -365,6 +595,8 @@ class AILauncher: cwd=str(Path.cwd()), capture_output=True, text=True, + encoding='utf-8', + errors='replace', ) if last_result.returncode == 0: out = (last_result.stdout or "").strip() @@ -383,8 +615,8 @@ class AILauncher: def _get_start_cmd(self, provider: str) -> str: if provider == "codex": - # NOTE: Codex TUI 有 paste-burst 检测;终端注入(wezterm send-text/tmux paste-buffer) - # 往往会被识别为“粘贴”,导致回车仅换行不提交。默认关闭该检测,保证自动通信可用。 + # NOTE: Codex TUI has paste-burst detection; terminal injection (wezterm send-text/tmux paste-buffer) + # is often detected as "paste", causing Enter to only line-break not submit. Disable detection by default. return self._build_codex_start_cmd() elif provider == "gemini": return self._build_gemini_start_cmd() @@ -431,7 +663,6 @@ CODEX_START_CMD={json.dumps(start_cmd)} if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then cd "$WORK_DIR" tmux new-session -d -s "$TMUX_SESSION" "$CODEX_START_CMD" - sleep 1 fi tmux pipe-pane -o -t "$TMUX_SESSION" "cat >> '$TMUX_LOG_FILE'" @@ -450,11 +681,14 @@ exec tmux attach -t "$TMUX_SESSION" terminal = self._detect_launch_terminal() if terminal == "tmux": - subprocess.run(["tmux", "new-session", "-d", "-s", f"launcher-{tmux_session}", str(script_file)], check=True) + if self._launch_script_in_macos_terminal(script_file): + pass + else: + subprocess.run(["tmux", "new-session", "-d", "-s", f"launcher-{tmux_session}", str(script_file)], check=True) else: subprocess.Popen([terminal, "-e", str(script_file)]) - print(f"✅ Codex 已启动 (tmux: {tmux_session})") + print(f"✅ {t('started_backend', provider='Codex', terminal='tmux', pane_id=tmux_session)}") return True def _start_gemini(self, tmux_session: str) -> bool: @@ -467,7 +701,7 @@ exec tmux attach -t "$TMUX_SESSION" self._write_gemini_session(runtime, tmux_session) - # 创建启动脚本 + # Create startup script wrapper = f'''#!/bin/bash cd "{os.getcwd()}" tmux new-session -d -s "{tmux_session}" 2>/dev/null || true @@ -479,26 +713,45 @@ exec tmux attach -t "$TMUX_SESSION" terminal = self._detect_launch_terminal() if terminal == "tmux": - # 纯 tmux 模式 - subprocess.run(["tmux", "new-session", "-d", "-s", tmux_session], check=True, cwd=os.getcwd()) - time.sleep(0.3) - subprocess.run(["tmux", "send-keys", "-t", tmux_session, start_cmd, "Enter"], check=True) + if self._launch_script_in_macos_terminal(script_file): + pass + else: + # Pure tmux mode + subprocess.run(["tmux", "new-session", "-d", "-s", tmux_session], check=True, cwd=os.getcwd()) + backend = TmuxBackend() + deadline = time.time() + 1.0 + sleep_s = 0.05 + while True: + try: + backend.send_text(tmux_session, start_cmd) + break + except subprocess.CalledProcessError: + if time.time() >= deadline: + raise + time.sleep(sleep_s) + sleep_s = min(0.2, sleep_s * 2) else: - # 打开新终端窗口 + # Open new terminal window subprocess.Popen([terminal, "--", str(script_file)]) - print(f"✅ Gemini 已启动 (tmux: {tmux_session})") + print(f"✅ {t('started_backend', provider='Gemini', terminal='tmux', pane_id=tmux_session)}") return True def _write_codex_session(self, runtime, tmux_session, input_fifo, output_fifo, pane_id=None): session_file = Path.cwd() / ".codex-session" + + # Pre-check permissions + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + data = {} if session_file.exists(): - try: - data = json.loads(session_file.read_text()) - except Exception: - pass + data = self._read_json_file(session_file) + work_dir = Path.cwd() data.update({ "session_id": self.session_id, "runtime_dir": str(runtime), @@ -508,14 +761,28 @@ exec tmux attach -t "$TMUX_SESSION" "tmux_session": tmux_session, "pane_id": pane_id, "tmux_log": str(runtime / "bridge_output.log"), - "work_dir": str(Path.cwd()), + "work_dir": str(work_dir), + "work_dir_norm": _normalize_path_for_match(str(work_dir)), "active": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), }) - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + return True def _write_gemini_session(self, runtime, tmux_session, pane_id=None): session_file = Path.cwd() / ".gemini-session" + + # Pre-check permissions + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + data = { "session_id": self.session_id, "runtime_dir": str(runtime), @@ -526,7 +793,12 @@ exec tmux attach -t "$TMUX_SESSION" "active": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), } - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + return True def _claude_project_dir(self, work_dir: Path) -> Path: projects_root = Path.home() / ".claude" / "projects" @@ -594,8 +866,30 @@ exec tmux attach -t "$TMUX_SESSION" latest = max(uuid_sessions, key=lambda p: p.stat().st_mtime) return latest.stem, True + def _find_claude_cmd(self) -> str: + """Find Claude CLI executable""" + if sys.platform == "win32": + for cmd in ["claude.exe", "claude.cmd", "claude.bat", "claude"]: + path = shutil.which(cmd) + if path: + return path + npm_paths = [ + Path(os.environ.get("APPDATA", "")) / "npm" / "claude.cmd", + Path(os.environ.get("ProgramFiles", "")) / "nodejs" / "claude.cmd", + ] + for npm_path in npm_paths: + if npm_path.exists(): + return str(npm_path) + else: + path = shutil.which("claude") + if path: + return path + raise FileNotFoundError( + "❌ Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code" + ) + def _start_claude(self) -> int: - print("🚀 启动 Claude...") + print(f"🚀 {t('starting_claude')}") env = os.environ.copy() if "codex" in self.providers: @@ -624,49 +918,46 @@ exec tmux attach -t "$TMUX_SESSION" else: env["GEMINI_TMUX_SESSION"] = self.tmux_sessions.get("gemini", "") - cmd = ["claude"] + try: + claude_cmd = self._find_claude_cmd() + except FileNotFoundError as e: + print(str(e)) + return 1 + + cmd = [claude_cmd] if self.auto: cmd.append("--dangerously-skip-permissions") - local_session_id: str | None = None if self.resume: - local_session_id = self._read_local_claude_session_id() - # Only resume if Claude can actually find the session on disk. - if local_session_id and (Path.home() / ".claude" / "session-env" / local_session_id).exists(): - cmd.extend(["--resume", local_session_id]) - print(f"🔁 Resuming Claude session: {local_session_id[:8]}...") + _, has_history = self._get_latest_claude_session_id() + if has_history: + cmd.append("--continue") + print(f"🔁 {t('resuming_claude', session_id='')}") else: - local_session_id = None - print("ℹ️ No local Claude session found, starting fresh") - - # Always start Claude with an explicit session id when not resuming, so the id is local and resettable. - if not local_session_id: - new_id = str(uuid.uuid4()) - cmd.extend(["--session-id", new_id]) - self._write_local_claude_session(new_id, active=True) - - print(f"📋 会话ID: {self.session_id}") - print(f"📁 运行目录: {self.runtime_dir}") - print(f"🔌 活跃后端: {', '.join(self.providers)}") + print(f"ℹ️ {t('no_claude_session')}") + + print(f"📋 Session ID: {self.session_id}") + print(f"📁 Runtime dir: {self.runtime_dir}") + print(f"🔌 Active backends: {', '.join(self.providers)}") print() - print("🎯 可用命令:") + print("🎯 Available commands:") if "codex" in self.providers: - print(" cask/cask-w/cping/cpend - Codex 通信") + print(" cask/cask-w/cping/cpend - Codex communication") if "gemini" in self.providers: - print(" gask/gask-w/gping/gpend - Gemini 通信") + print(" gask/gask-w/gping/gpend - Gemini communication") print() - print(f"执行: {' '.join(cmd)}") + print(f"Executing: {' '.join(cmd)}") try: return subprocess.run(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env).returncode except KeyboardInterrupt: - print("\n⚠️ 用户中断") + print(f"\n⚠️ {t('user_interrupted')}") return 130 def cleanup(self): if self._cleaned: return self._cleaned = True - print("\n🧹 清理会话资源...") + print(f"\n🧹 {t('cleaning_up')}") if self.terminal_type == "wezterm": backend = WeztermBackend() @@ -686,10 +977,12 @@ exec tmux attach -t "$TMUX_SESSION" for session_file in [Path.cwd() / ".codex-session", Path.cwd() / ".gemini-session", Path.cwd() / ".claude-session"]: if session_file.exists(): try: - data = json.loads(session_file.read_text()) + data = self._read_json_file(session_file) + if not data: + continue data["active"] = False data["ended_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) except Exception: pass @@ -697,14 +990,14 @@ exec tmux attach -t "$TMUX_SESSION" if self.runtime_dir.exists(): shutil.rmtree(self.runtime_dir, ignore_errors=True) - print("✅ 清理完成") + print(f"✅ {t('cleanup_complete')}") def run_up(self) -> int: git_info = _get_git_info() version_str = f"v{VERSION}" + (f" ({git_info})" if git_info else "") print(f"🚀 Claude Code Bridge {version_str}") print(f"📅 {time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"🔌 后端: {', '.join(self.providers)}") + print(f"🔌 Backends: {', '.join(self.providers)}") print("=" * 50) atexit.register(self.cleanup) @@ -720,13 +1013,10 @@ exec tmux attach -t "$TMUX_SESSION" for provider in providers: if not self._start_provider(provider): return 1 - time.sleep(1) self._warmup_provider(provider) - time.sleep(2) - if self.no_claude: - print("✅ 后端已启动(--no-claude 模式)") + print(f"✅ {t('backends_started_no_claude')}") print() for provider in self.providers: if self.terminal_type == "wezterm": @@ -742,7 +1032,7 @@ exec tmux attach -t "$TMUX_SESSION" if tmux: print(f" {provider}: tmux attach -t {tmux}") print() - print(f"终止: ccb kill {' '.join(self.providers)}") + print(f"Kill: ccb kill {' '.join(self.providers)}") atexit.unregister(self.cleanup) return 0 @@ -769,11 +1059,11 @@ def cmd_status(args): for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - results[provider] = {"status": "未配置", "active": False} + results[provider] = {"status": "Not configured", "active": False} continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") active = data.get("active", False) @@ -791,16 +1081,16 @@ def cmd_status(args): alive = False results[provider] = { - "status": "运行中" if (active and alive) else "已停止", + "status": "Running" if (active and alive) else "Stopped", "active": active and alive, "terminal": terminal, "pane_id": pane_id, "runtime_dir": data.get("runtime_dir", ""), } except Exception as e: - results[provider] = {"status": f"错误: {e}", "active": False} + results[provider] = {"status": f"Error: {e}", "active": False} - print("📊 AI 后端状态:") + print(f"📊 {t('backend_status')}") for provider, info in results.items(): icon = "✅" if info.get("active") else "❌" print(f" {icon} {provider.capitalize()}: {info['status']}") @@ -816,11 +1106,11 @@ def cmd_kill(args): for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - print(f"⚠️ {provider}: 未找到会话文件") + print(f"⚠️ {provider}: Session file not found") continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") @@ -836,9 +1126,9 @@ def cmd_kill(args): data["active"] = False data["ended_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) - print(f"✅ {provider.capitalize()} 已终止") + print(f"✅ {provider.capitalize()} terminated") except Exception as e: print(f"❌ {provider}: {e}") @@ -851,11 +1141,11 @@ def cmd_restore(args): for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - print(f"⚠️ {provider}: 未找到会话文件") + print(f"⚠️ {provider}: Session file not found") continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") active = data.get("active", False) @@ -882,15 +1172,16 @@ def cmd_restore(args): if provider == "codex": session_id = data.get("codex_session_id") if isinstance(session_id, str) and session_id: - has_history = True + recorded_norm = _extract_session_work_dir_norm(data) + work_keys = _work_dir_match_keys(Path.cwd()) + if recorded_norm and (not work_keys or recorded_norm in work_keys): + has_history = True + else: + session_id = None else: # Fallback: scan ~/.codex/sessions for latest session bound to this cwd. root = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() - work_dirs = set([os.environ.get("PWD", ""), str(Path.cwd())]) - try: - work_dirs.add(str(Path.cwd().resolve())) - except Exception: - pass + work_dirs = _work_dir_match_keys(Path.cwd()) try: logs = sorted( (p for p in root.glob("**/*.jsonl") if p.is_file()), @@ -915,7 +1206,7 @@ def cmd_restore(args): continue payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} cwd = payload.get("cwd") - if isinstance(cwd, str) and cwd in work_dirs: + if isinstance(cwd, str) and cwd.strip() and _normalize_path_for_match(cwd) in work_dirs: has_history = True sid = payload.get("id") if isinstance(sid, str) and sid: @@ -940,14 +1231,14 @@ def cmd_restore(args): break if has_history: - print(f"ℹ️ {provider}: 会话已结束,但可恢复历史会话") + print(f"ℹ️ {provider}: Session ended but history recoverable") if session_id: - print(f" 会话ID: {session_id[:8]}...") - print(f" 使用: ccb up {provider} -r") + print(f" Session ID: {session_id[:8]}...") + print(f" Use: ccb up {provider} -r") else: - print(f"⚠️ {provider}: 会话已结束,无可恢复历史") + print(f"⚠️ {provider}: Session ended, no recoverable history") else: - print(f"⚠️ {provider}: 会话已丢失,使用 ccb up {provider} -r 重启") + print(f"⚠️ {provider}: Session lost, use ccb up {provider} -r to restart") except Exception as e: print(f"❌ {provider}: {e}") @@ -955,9 +1246,121 @@ def cmd_restore(args): return 0 +def _get_version_info(dir_path: Path) -> dict: + """Get commit hash, date and version from install directory""" + info = {"commit": None, "date": None, "version": None} + ccb_file = dir_path / "ccb" + if ccb_file.exists(): + try: + content = ccb_file.read_text(encoding='utf-8', errors='replace') + for line in content.split('\n')[:60]: + line = line.strip() + if line.startswith('VERSION') and '=' in line: + info["version"] = line.split('=')[1].strip().strip('"').strip("'") + elif line.startswith('GIT_COMMIT') and '=' in line: + val = line.split('=')[1].strip().strip('"').strip("'") + if val: + info["commit"] = val + elif line.startswith('GIT_DATE') and '=' in line: + val = line.split('=')[1].strip().strip('"').strip("'") + if val: + info["date"] = val + except Exception: + pass + if shutil.which("git") and (dir_path / ".git").exists(): + result = subprocess.run( + ["git", "-C", str(dir_path), "log", "-1", "--format=%h|%ci"], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split("|") + if len(parts) >= 2: + info["commit"] = parts[0] + info["date"] = parts[1].split()[0] + return info + + +def _format_version_info(info: dict) -> str: + """Format version info for display""" + parts = [] + if info.get("version"): + parts.append(f"v{info['version']}") + if info.get("commit"): + parts.append(info["commit"]) + if info.get("date"): + parts.append(info["date"]) + return " ".join(parts) if parts else "unknown" + + +def _get_remote_version_info() -> dict | None: + """Get latest version info from GitHub API""" + import urllib.request + import ssl + + api_url = "https://api.github.com/repos/bfly123/claude_code_bridge/commits/main" + try: + ctx = ssl.create_default_context() + req = urllib.request.Request(api_url, headers={"User-Agent": "ccb"}) + with urllib.request.urlopen(req, context=ctx, timeout=5) as resp: + data = json.loads(resp.read().decode('utf-8')) + commit = data.get("sha", "")[:7] + date_str = data.get("commit", {}).get("committer", {}).get("date", "") + date = date_str[:10] if date_str else None + return {"commit": commit, "date": date} + except Exception: + pass + + if shutil.which("curl"): + result = subprocess.run( + ["curl", "-fsSL", api_url], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10 + ) + if result.returncode == 0: + try: + data = json.loads(result.stdout) + commit = data.get("sha", "")[:7] + date_str = data.get("commit", {}).get("committer", {}).get("date", "") + date = date_str[:10] if date_str else None + return {"commit": commit, "date": date} + except Exception: + pass + return None + + +def cmd_version(args): + """Show version info and check for updates""" + script_root = Path(__file__).resolve().parent + default_install_dir = Path.home() / ".local/share/codex-dual" + install_dir = Path(os.environ.get("CODEX_INSTALL_PREFIX") or default_install_dir).expanduser() + if (script_root / "install.sh").exists(): + install_dir = script_root + + local_info = _get_version_info(install_dir) + local_str = _format_version_info(local_info) + + print(f"ccb (Claude Code Bridge) {local_str}") + print(f"Install path: {install_dir}") + + print("\nChecking for updates...") + remote_info = _get_remote_version_info() + + if remote_info is None: + print("⚠️ Unable to check for updates (network error)") + elif local_info.get("commit") and remote_info.get("commit"): + if local_info["commit"] == remote_info["commit"]: + print(f"✅ Up to date") + else: + remote_str = f"{remote_info['commit']} {remote_info.get('date', '')}" + print(f"📦 Update available: {remote_str}") + print(f" Run: ccb update") + else: + print("⚠️ Unable to compare versions") + + return 0 + + def cmd_update(args): """Update ccb to latest version""" - import shutil import urllib.request import tarfile import tempfile @@ -970,6 +1373,9 @@ def cmd_update(args): install_dir = script_root repo_url = "https://github.com/bfly123/claude_code_bridge" + # Get current version info before update + old_info = _get_version_info(install_dir) + print("🔄 Checking for updates...") # Method 1: Prefer git if available @@ -977,21 +1383,71 @@ def cmd_update(args): print("📦 Updating via git pull...") result = subprocess.run( ["git", "-C", str(install_dir), "pull", "--ff-only"], - capture_output=True, text=True + capture_output=True, text=True, encoding='utf-8', errors='replace' ) if result.returncode == 0: print(result.stdout.strip() if result.stdout.strip() else "Already up to date.") print("🔧 Reinstalling...") subprocess.run([str(install_dir / "install.sh"), "install"]) - print("✅ Update complete!") + # Show upgrade info + new_info = _get_version_info(install_dir) + old_str = _format_version_info(old_info) + new_str = _format_version_info(new_info) + if old_info.get("commit") != new_info.get("commit"): + print(f"✅ Updated: {old_str} → {new_str}") + else: + print(f"✅ Already up to date: {new_str}") return 0 else: print(f"⚠️ Git pull failed: {result.stderr.strip()}") print("Falling back to tarball download...") + def _pick_temp_base_dir() -> Path: + candidates: list[Path] = [] + for key in ("CCB_TMPDIR", "TMPDIR", "TEMP", "TMP"): + value = (os.environ.get(key) or "").strip() + if value: + candidates.append(Path(value).expanduser()) + try: + candidates.append(Path(tempfile.gettempdir())) + except Exception: + pass + candidates.extend( + [ + Path("/tmp"), + Path("/var/tmp"), + Path("/usr/tmp"), + Path.home() / ".cache" / "ccb" / "tmp", + install_dir / ".tmp", + Path.cwd() / ".tmp", + ] + ) + + for base in candidates: + try: + base.mkdir(parents=True, exist_ok=True) + probe = base / f".ccb_tmp_probe_{os.getpid()}_{int(time.time() * 1000)}" + probe.write_bytes(b"1") + probe.unlink(missing_ok=True) + return base + except Exception: + continue + + raise RuntimeError( + "❌ No usable temporary directory found.\n" + "Fix options:\n" + " - Create /tmp (Linux/WSL): sudo mkdir -p /tmp && sudo chmod 1777 /tmp\n" + " - Or set TMPDIR/CCB_TMPDIR to a writable path (e.g. export TMPDIR=$HOME/.cache/tmp)" + ) + # Method 2: Download tarball tarball_url = f"{repo_url}/archive/refs/heads/main.tar.gz" - tmp_dir = Path(tempfile.gettempdir()) / "ccb_update" + try: + tmp_base = _pick_temp_base_dir() + except Exception as exc: + print(str(exc)) + return 1 + tmp_dir = tmp_base / "ccb_update" try: print(f"📥 Downloading latest version...") @@ -1000,7 +1456,7 @@ def cmd_update(args): tmp_dir.mkdir(parents=True, exist_ok=True) tarball_path = tmp_dir / "main.tar.gz" - # 优先使用 curl/wget(更好的证书处理) + # Prefer curl/wget (better certificate handling) downloaded = False if shutil.which("curl"): result = subprocess.run( @@ -1015,12 +1471,12 @@ def cmd_update(args): ) downloaded = result.returncode == 0 if not downloaded: - # 回退到 urllib(可能有 SSL 问题) + # Fallback to urllib (may have SSL issues) import ssl try: urllib.request.urlretrieve(tarball_url, tarball_path) except ssl.SSLError: - print("⚠️ SSL 证书验证失败,尝试跳过验证...") + print("⚠️ SSL certificate verification failed, trying to skip...") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE @@ -1046,8 +1502,14 @@ def cmd_update(args): env["CODEX_INSTALL_PREFIX"] = str(install_dir) subprocess.run([str(extracted_dir / "install.sh"), "install"], check=True, env=env) - print("✅ Update complete!") - print("💡 推荐:安装 WezTerm 作为终端前端(分屏/滚动体验更好),详情见 README。") + # Show upgrade info + new_info = _get_version_info(install_dir) + old_str = _format_version_info(old_info) + new_str = _format_version_info(new_info) + if old_info.get("commit") != new_info.get("commit") or old_info.get("version") != new_info.get("version"): + print(f"✅ Updated: {old_str} → {new_str}") + else: + print(f"✅ Already up to date: {new_str}") return 0 except Exception as e: @@ -1060,32 +1522,41 @@ def cmd_update(args): def main(): - parser = argparse.ArgumentParser(description="Claude AI 统一启动器", add_help=True) - subparsers = parser.add_subparsers(dest="command", help="子命令") - - # up 子命令 - up_parser = subparsers.add_parser("up", help="启动 AI 后端") - up_parser.add_argument("providers", nargs="*", choices=["codex", "gemini"], help="要启动的后端") - up_parser.add_argument("-r", "--resume", action="store_true", help="恢复上下文") - up_parser.add_argument("-a", "--auto", action="store_true", help="全自动权限模式") - up_parser.add_argument("--no-claude", action="store_true", help="不启动 Claude 主窗口") - - # status 子命令 - status_parser = subparsers.add_parser("status", help="检查状态") - status_parser.add_argument("providers", nargs="*", default=[], help="要检查的后端 (codex/gemini)") - - # kill 子命令 - kill_parser = subparsers.add_parser("kill", help="终止会话") - kill_parser.add_argument("providers", nargs="*", default=[], help="要终止的后端 (codex/gemini)") - - # restore 子命令 - restore_parser = subparsers.add_parser("restore", help="恢复/attach 会话") - restore_parser.add_argument("providers", nargs="*", default=[], help="要恢复的后端 (codex/gemini)") - - # update 子命令 - update_parser = subparsers.add_parser("update", help="Update to latest version") - - args = parser.parse_args() + parser = argparse.ArgumentParser(description="Claude AI unified launcher", add_help=True) + subparsers = parser.add_subparsers(dest="command", help="Subcommands") + + # up subcommand + up_parser = subparsers.add_parser("up", help="Start AI backends") + up_parser.add_argument("providers", nargs="*", choices=["codex", "gemini"], help="Backends to start") + up_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context") + up_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode") + up_parser.add_argument("--no-claude", action="store_true", help="Don't start Claude main window") + + # status subcommand + status_parser = subparsers.add_parser("status", help="Check status") + status_parser.add_argument("providers", nargs="*", default=[], help="Backends to check (codex/gemini)") + + # kill subcommand + kill_parser = subparsers.add_parser("kill", help="Terminate session") + kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini)") + + # restore subcommand + restore_parser = subparsers.add_parser("restore", help="Restore/attach session") + restore_parser.add_argument("providers", nargs="*", default=[], help="Backends to restore (codex/gemini)") + + # update subcommand + subparsers.add_parser("update", help="Update to latest version") + + # version subcommand + subparsers.add_parser("version", help="Show version and check for updates") + + argv = sys.argv[1:] + # Backward/shortcut compatibility + if argv and argv[0] in {"-r", "--resume", "--restore"}: + argv = ["up"] + argv + elif argv and argv[0] in {"-v", "--version"}: + argv = ["version"] + args = parser.parse_args(argv) if not args.command: parser.print_help() @@ -1101,6 +1572,8 @@ def main(): return cmd_restore(args) elif args.command == "update": return cmd_update(args) + elif args.command == "version": + return cmd_version(args) else: parser.print_help() return 1 diff --git a/commands/cask-w.md b/commands/cask-w.md index 20ee42f..28b5946 100644 --- a/commands/cask-w.md +++ b/commands/cask-w.md @@ -1,24 +1,23 @@ -通过 `cask-w` 命令将指令转发到 Codex 会话并等待回复(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 +Forward commands to Codex session and wait for reply via `cask-w` command (supports tmux / WezTerm). -执行方式: -- 沉默运行 `Bash(cask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `cask-w <转发内容>`,效果等同 +Execution: +1. Run `Bash(cask-w "", run_in_background=true)` to start background task +2. Tell user the task_id and that Codex is processing +3. Wait for bash-notification (task completed) +4. When notification arrives, immediately `cat` the output file to show result -参数说明: -- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令) -- ⚠️ 注意:cask-w 转发后会等待 Codex 回复(默认无超时,`Ctrl-C` 可中断) +Parameters: +- `` required, will be forwarded to Codex session -交互流程: -1. 转发命令到 Codex tmux 会话 -2. 捕获当前日志状态 -3. 等待新的回复消息 -4. 打印回复内容后返回 +Workflow: +1. Start cask-w in background -> get task_id +2. Inform user: "Codex processing (task: xxx)" +3. When bash-notification arrives -> `cat ` to show result -示例: -- `Bash(cask-w "pwd")` -- `Bash(cask-w "echo test")` +Examples: +- `Bash(cask-w "analyze code", run_in_background=true)` +- bash-notification arrives -> `cat /tmp/.../tasks/xxx.output` -提示: -- cask-w 会阻塞等待 Codex 回复,适合需要立即看到结果的场景 -- 如不需要等待,使用 `/cask` 即可 -- cask-w 返回后表示已获取回复,无需继续执行任何操作 +Hints: +- Use `cask` for fire-and-forget (no wait) +- Use `/cpend` to view latest reply anytime diff --git a/commands/cask.md b/commands/cask.md index 75a12e0..d8ec24f 100644 --- a/commands/cask.md +++ b/commands/cask.md @@ -1,18 +1,18 @@ -通过 `cask` 命令将指令转发到 Codex 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 +Forward commands to Codex session via `cask` command (supports tmux / WezTerm, forward only, does not execute in current Claude process). -执行方式: -- 沉默运行 `Bash(cask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `cask <转发内容>`,效果等同 +Execution: +- Run silently `Bash(cask "")`, no additional analysis or operations after execution +- Can also run `cask ` in terminal with same effect -参数说明: -- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令) -- ⚠️ 注意:cask 仅负责转发,不在本 Claude 进程执行,返回后立即结束 +Parameters: +- `` required, will be forwarded to Codex session (e.g. `pwd`, `ls` commands) +- Note: cask only forwards, does not execute in Claude process, returns immediately after -示例: +Examples: - `Bash(cask "pwd")` - `Bash(cask "ls -la")` -提示: -- cask 发送命令后立即返回,不等待结果 -- 如需等待 Codex 回复,请使用 `/cask-w` -- cask 返回后表示转发完成,无需继续执行任何操作 +Hints: +- cask returns immediately after sending, does not wait for result +- Use `/cask-w` if you need to wait for Codex reply +- After cask returns, forwarding is complete, no further action needed diff --git a/commands/cpend.md b/commands/cpend.md index 6f2423a..a514e16 100644 --- a/commands/cpend.md +++ b/commands/cpend.md @@ -1,19 +1,19 @@ -使用 `cpend` 从 Codex 官方日志中抓取最新回复,适合异步模式或超时后的补充查询。 +Use `cpend` to fetch latest reply from Codex official logs, suitable for async mode or follow-up queries after timeout. -执行方式: -- Claude 端使用 `Bash(cpend)`,命令执行过程保持静默 -- 本地终端可直接运行 `cpend` +Execution: +- Use `Bash(cpend)` on Claude side, keep command execution silent +- Run `cpend` directly in local terminal -功能特点: -1. 解析 `.codex-session` 记录的日志路径,定位本次会话的最新 JSONL 文件 -2. 读取尾部消息并返回 Codex 最近一次输出 -3. 若无新内容,将提示“暂无 Codex 回复” +Features: +1. Parses log path recorded in `.codex-session`, locates latest JSONL file for current session +2. Reads tail messages and returns Codex's most recent output +3. If no new content, will prompt "No Codex reply yet" -常见场景: -- `cask` 异步提交多条任务后集中查看结果 -- `cask-w` 因超时退出后手动确认 Codex 是否已回应 -- 需要核对 Codex 回复与原始问题是否匹配 +Common scenarios: +- View results after submitting multiple tasks via `cask` async +- Manually confirm if Codex has responded after `cask-w` timeout exit +- Need to verify if Codex reply matches original question -提示: -- 日志文件通常位于 `~/.codex/sessions/.../rollout-.jsonl` -- 如果命令返回空,请先确认 Codex 会话仍在运行(可用 `/cping` 检查) +Hints: +- Log file usually located at `~/.codex/sessions/.../rollout-.jsonl` +- If command returns empty, first confirm Codex session is still running (use `/cping` to check) diff --git a/commands/cping.md b/commands/cping.md index 5b3a5bc..9be1f1c 100644 --- a/commands/cping.md +++ b/commands/cping.md @@ -1,19 +1,19 @@ -使用 `cping` 检查当前 Codex 会话是否健康,快速定位通信问题。 +Use `cping` to check if current Codex session is healthy, quickly locate communication issues. -执行方式: -- Claude 端运行 `Bash(cping)`,无需输出命令执行过程 -- 本地终端直接执行 `cping` +Execution: +- Run `Bash(cping)` on Claude side, no need to output command execution process +- Run `cping` directly in local terminal -检测内容: -1. `.codex-session` 是否标记为活跃,运行目录是否存在 -2. tmux 模式:FIFO 管道是否仍可访问 -3. tmux 模式:Codex 侧进程是否存活(根据 `codex.pid` 验证) -4. WezTerm 模式:pane 是否仍存在(根据 `wezterm cli list` 检测) +Detection items: +1. Is `.codex-session` marked as active, does runtime directory exist +2. tmux mode: Is FIFO pipe still accessible +3. tmux mode: Is Codex side process alive (verified by `codex.pid`) +4. WezTerm mode: Does pane still exist (detected via `wezterm cli list`) -输出说明: -- 成功:`✅ Codex连接正常 (...)` -- 失败:列出缺失的组件或异常信息,便于进一步排查 +Output: +- Success: `Codex connection OK (...)` +- Failure: Lists missing components or error info for further troubleshooting -提示: -- 若检测失败,可尝试重新运行 `ccb up codex` 或查看桥接日志 -- 在多次超时或无回复时,先执行 `cping` 再决定是否重启会话 +Hints: +- If detection fails, try re-running `ccb up codex` or check bridge logs +- On multiple timeouts or no response, run `cping` first before deciding to restart session diff --git a/commands/gask-w.md b/commands/gask-w.md index 62e88e3..37fa754 100644 --- a/commands/gask-w.md +++ b/commands/gask-w.md @@ -1,18 +1,23 @@ -通过 `gask-w` 命令将指令转发到 Gemini 会话,并同步等待回复(支持 tmux / WezTerm)。 +Forward commands to Gemini session and wait for reply via `gask-w` command (supports tmux / WezTerm). -执行方式: -- 沉默运行 `Bash(gask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gask-w <转发内容>`,效果等同 +Execution: +1. Run `Bash(gask-w "", run_in_background=true)` to start background task +2. Tell user the task_id and that Gemini is processing +3. Wait for bash-notification (task completed) +4. When notification arrives, immediately `cat` the output file to show result -参数说明: -- `<转发内容>` 必填,会被转发到 Gemini 会话 -- ⚠️ 注意:gask-w 会等待 Gemini 回复后再返回 +Parameters: +- `` required, will be forwarded to Gemini session -示例: -- `Bash(gask-w "解释一下这段代码")` -- `Bash(gask-w "这个方案有什么建议?")` +Workflow: +1. Start gask-w in background -> get task_id +2. Inform user: "Gemini processing (task: xxx)" +3. When bash-notification arrives -> `cat ` to show result -提示: -- gask-w 会阻塞等待 Gemini 回复 -- 默认无超时,`Ctrl-C` 可中断(如需非阻塞,使用 `/gask`) -- 适合需要获取 Gemini 反馈的场景 +Examples: +- `Bash(gask-w "explain this", run_in_background=true)` +- bash-notification arrives -> `cat /tmp/.../tasks/xxx.output` + +Hints: +- Use `gask` for fire-and-forget (no wait) +- Use `/gpend` to view latest reply anytime diff --git a/commands/gask.md b/commands/gask.md index 2a0a8cf..f55acf9 100644 --- a/commands/gask.md +++ b/commands/gask.md @@ -1,18 +1,18 @@ -通过 `gask` 命令将指令转发到 Gemini 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 +Forward commands to Gemini session via `gask` command (supports tmux / WezTerm, forward only, does not execute in current Claude process). -执行方式: -- 沉默运行 `Bash(gask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gask <转发内容>`,效果等同 +Execution: +- Run silently `Bash(gask "")`, no additional analysis or operations after execution +- Can also run `gask ` in terminal with same effect -参数说明: -- `<转发内容>` 必填,会被转发到 Gemini 会话 -- ⚠️ 注意:gask 仅负责转发,不在本 Claude 进程执行,返回后立即结束 +Parameters: +- `` required, will be forwarded to Gemini session +- Note: gask only forwards, does not execute in Claude process, returns immediately after -示例: -- `Bash(gask "解释一下这段代码")` -- `Bash(gask "帮我优化这个函数")` +Examples: +- `Bash(gask "explain this code")` +- `Bash(gask "help me optimize this function")` -提示: -- gask 发送命令后立即返回,不等待结果 -- 如需等待 Gemini 回复,请使用 `/gask-w` -- gask 返回后表示转发完成,无需继续执行任何操作 +Hints: +- gask returns immediately after sending, does not wait for result +- Use `/gask-w` if you need to wait for Gemini reply +- After gask returns, forwarding is complete, no further action needed diff --git a/commands/gpend.md b/commands/gpend.md index 86b197b..389c268 100644 --- a/commands/gpend.md +++ b/commands/gpend.md @@ -1,9 +1,9 @@ -通过 `gpend` 命令查看 Gemini 最新回复。 +View latest Gemini reply via `gpend` command. -执行方式: -- 沉默运行 `Bash(gpend)`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gpend`,效果等同 +Execution: +- Run silently `Bash(gpend)`, no additional analysis or operations after execution +- Can also run `gpend` in terminal with same effect -提示: -- 用于查看 gask 异步发送后的回复 -- 或 gask-w 超时后继续获取回复 +Hints: +- Used to view reply after gask async send +- Or continue getting reply after gask-w timeout diff --git a/commands/gping.md b/commands/gping.md index e284a09..a02b33a 100644 --- a/commands/gping.md +++ b/commands/gping.md @@ -1,9 +1,9 @@ -通过 `gping` 命令测试与 Gemini 的连通性。 +Test connectivity with Gemini via `gping` command. -执行方式: -- 沉默运行 `Bash(gping)`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gping`,效果等同 +Execution: +- Run silently `Bash(gping)`, no additional analysis or operations after execution +- Can also run `gping` in terminal with same effect -提示: -- 返回 Gemini 会话状态 -- 用于检查 Gemini 是否正常运行 +Hints: +- Returns Gemini session status +- Used to check if Gemini is running normally diff --git a/install.ps1 b/install.ps1 index b6fb456..46ddc7f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,13 +1,59 @@ -param( +param( [Parameter(Position = 0)] [ValidateSet("install", "uninstall", "help")] [string]$Command = "help", - [string]$InstallPrefix = "$env:LOCALAPPDATA\codex-dual" + [string]$InstallPrefix = "$env:LOCALAPPDATA\codex-dual", + [switch]$Yes ) +# --- UTF-8 / BOM compatibility (Windows PowerShell 5.1) --- +# Keep this near the top so Chinese/emoji output is rendered correctly. +try { + $script:utf8NoBom = [System.Text.UTF8Encoding]::new($false) +} catch { + $script:utf8NoBom = [System.Text.Encoding]::UTF8 +} +try { $OutputEncoding = $script:utf8NoBom } catch {} +try { [Console]::OutputEncoding = $script:utf8NoBom } catch {} +try { [Console]::InputEncoding = $script:utf8NoBom } catch {} +try { chcp 65001 | Out-Null } catch {} + $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +# i18n support +function Get-CCBLang { + $lang = $env:CCB_LANG + if ($lang -in @("zh", "cn", "chinese")) { return "zh" } + if ($lang -in @("en", "english")) { return "en" } + # Auto-detect from system + try { + $culture = (Get-Culture).Name + if ($culture -like "zh*") { return "zh" } + } catch {} + return "en" +} + +$script:CCBLang = Get-CCBLang + +function Get-Msg { + param([string]$Key, [string]$Arg1 = "", [string]$Arg2 = "") + $msgs = @{ + "install_complete" = @{ en = "Installation complete"; zh = "安装完成" } + "uninstall_complete" = @{ en = "Uninstall complete"; zh = "卸载完成" } + "python_old" = @{ en = "Python version too old: $Arg1"; zh = "Python 版本过旧: $Arg1" } + "requires_python" = @{ en = "ccb requires Python 3.10+"; zh = "ccb 需要 Python 3.10+" } + "confirm_windows" = @{ en = "Continue installation in Windows? (y/N)"; zh = "确认继续在 Windows 中安装?(y/N)" } + "cancelled" = @{ en = "Installation cancelled"; zh = "安装已取消" } + "windows_warning" = @{ en = "You are installing ccb in native Windows environment"; zh = "你正在 Windows 原生环境安装 ccb" } + "same_env" = @{ en = "ccb/cask-w must run in the same environment as codex/gemini."; zh = "ccb/cask-w 必须与 codex/gemini 在同一环境运行。" } + } + if ($msgs.ContainsKey($Key)) { + return $msgs[$Key][$script:CCBLang] + } + return $Key +} + function Show-Usage { Write-Host "Usage:" Write-Host " .\install.ps1 install # Install or update" @@ -38,30 +84,55 @@ function Require-Python310 { } try { - $version = & $exe @args -c "import sys; print('{}.{}.{}'.format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))" + $vinfo = & $exe @args -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro} {v.major} {v.minor}')" + $parts = $vinfo.Trim() -split " " + $version = $parts[0] + $major = [int]$parts[1] + $minor = [int]$parts[2] } catch { - Write-Host "Failed to query Python version using: $PythonCmd" + Write-Host "[ERROR] Failed to query Python version using: $PythonCmd" exit 1 } - $verParts = ($version -split "\\.") | Where-Object { $_ } - if ($verParts.Length -lt 2) { - Write-Host "❌ Unable to parse Python version: $version" - exit 1 - } - $major = [int]$verParts[0] - $minor = [int]$verParts[1] - if (($major -ne 3) -or ($minor -lt 10)) { - Write-Host "❌ Python version too old: $version" + Write-Host "[ERROR] Python version too old: $version" Write-Host " ccb requires Python 3.10+" Write-Host " Download: https://www.python.org/downloads/" exit 1 } - Write-Host "✓ Python $version" + Write-Host "[OK] Python $version" +} + +function Confirm-BackendEnv { + if ($Yes -or $env:CCB_INSTALL_ASSUME_YES -eq "1") { return } + + if (-not [Environment]::UserInteractive) { + Write-Host "[ERROR] Non-interactive environment detected, aborting to prevent Windows/WSL mismatch." + Write-Host " If codex/gemini will run in native Windows:" + Write-Host " Re-run: powershell -ExecutionPolicy Bypass -File .\install.ps1 install -Yes" + exit 1 + } + + Write-Host "" + Write-Host "================================================================" + Write-Host "[WARNING] You are installing ccb in native Windows environment" + Write-Host "================================================================" + Write-Host "ccb/cask-w must run in the same environment as codex/gemini." + Write-Host "" + Write-Host "Please confirm: You will install and run codex/gemini in native Windows (not WSL)." + Write-Host "If you plan to run codex/gemini in WSL, exit and run in WSL:" + Write-Host " ./install.sh install" + Write-Host "================================================================" + $reply = Read-Host "Continue installation in Windows? (y/N)" + if ($reply.Trim().ToLower() -notin @("y", "yes")) { + Write-Host "Installation cancelled" + exit 1 + } } function Install-Native { + Confirm-BackendEnv + $binDir = Join-Path $InstallPrefix "bin" $pythonCmd = Find-Python @@ -93,17 +164,45 @@ function Install-Native { } } + function Fix-PythonShebang { + param([string]$TargetPath) + if (-not $TargetPath -or -not (Test-Path $TargetPath)) { return } + try { + $text = [System.IO.File]::ReadAllText($TargetPath, [System.Text.Encoding]::UTF8) + if ($text -match '^\#\!/usr/bin/env python3') { + $text = $text -replace '^\#\!/usr/bin/env python3', '#!/usr/bin/env python' + [System.IO.File]::WriteAllText($TargetPath, $text, $script:utf8NoBom) + } + } catch { + return + } + } + $scripts = @("ccb", "cask", "cask-w", "cping", "cpend", "gask", "gask-w", "gping", "gpend") + + # In MSYS/Git-Bash, invoking the script file directly will honor the shebang. + # Windows typically has `python` but not `python3`, so rewrite shebangs for compatibility. + foreach ($script in $scripts) { + if ($script -eq "ccb") { + Fix-PythonShebang (Join-Path $InstallPrefix "ccb") + } else { + Fix-PythonShebang (Join-Path $InstallPrefix ("bin\\" + $script)) + } + } + foreach ($script in $scripts) { $batPath = Join-Path $binDir "$script.bat" + $cmdPath = Join-Path $binDir "$script.cmd" if ($script -eq "ccb") { $relPath = "..\\ccb" } else { - $relPath = "..\\bin\\$script" + # Script is installed alongside the wrapper under $InstallPrefix\bin + $relPath = $script } - $batContent = "@echo off`r`n$pythonCmd `"%~dp0$relPath`" %*" - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($batPath, $batContent, $utf8NoBom) + $wrapperContent = "@echo off`r`nset `"PYTHON=python`"`r`nwhere python >NUL 2>&1 || set `"PYTHON=py -3`"`r`n%PYTHON% `"%~dp0$relPath`" %*" + [System.IO.File]::WriteAllText($batPath, $wrapperContent, $script:utf8NoBom) + # .cmd wrapper for PowerShell/CMD users (and tools preferring .cmd over raw shebang scripts) + [System.IO.File]::WriteAllText($cmdPath, $wrapperContent, $script:utf8NoBom) } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") diff --git a/install.sh b/install.sh index 0900572..44f6477 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,81 @@ INSTALL_PREFIX="${CODEX_INSTALL_PREFIX:-$HOME/.local/share/codex-dual}" BIN_DIR="${CODEX_BIN_DIR:-$HOME/.local/bin}" readonly REPO_ROOT INSTALL_PREFIX BIN_DIR +# i18n support +detect_lang() { + local lang="${CCB_LANG:-auto}" + case "$lang" in + zh|cn|chinese) echo "zh" ;; + en|english) echo "en" ;; + *) + local sys_lang="${LANG:-${LC_ALL:-${LC_MESSAGES:-}}}" + if [[ "$sys_lang" == zh* ]] || [[ "$sys_lang" == *chinese* ]]; then + echo "zh" + else + echo "en" + fi + ;; + esac +} + +CCB_LANG_DETECTED="$(detect_lang)" + +# Message function +msg() { + local key="$1" + shift + local en_msg zh_msg + case "$key" in + install_complete) + en_msg="Installation complete" + zh_msg="安装完成" ;; + uninstall_complete) + en_msg="Uninstall complete" + zh_msg="卸载完成" ;; + python_version_old) + en_msg="Python version too old: $1" + zh_msg="Python 版本过旧: $1" ;; + requires_python) + en_msg="Requires Python 3.10+" + zh_msg="需要 Python 3.10+" ;; + missing_dep) + en_msg="Missing dependency: $1" + zh_msg="缺少依赖: $1" ;; + detected_env) + en_msg="Detected $1 environment" + zh_msg="检测到 $1 环境" ;; + wsl1_not_supported) + en_msg="WSL 1 does not support FIFO pipes, please upgrade to WSL 2" + zh_msg="WSL 1 不支持 FIFO 管道,请升级到 WSL 2" ;; + confirm_wsl) + en_msg="Confirm continue installing in WSL? (y/N)" + zh_msg="确认继续在 WSL 中安装?(y/N)" ;; + cancelled) + en_msg="Installation cancelled" + zh_msg="安装已取消" ;; + wsl_warning) + en_msg="Detected WSL environment" + zh_msg="检测到 WSL 环境" ;; + same_env_required) + en_msg="ccb/cask-w must run in the same environment as codex/gemini." + zh_msg="ccb/cask-w 必须与 codex/gemini 在同一环境运行。" ;; + confirm_wsl_native) + en_msg="Please confirm: you will install and run codex/gemini in WSL (not Windows native)." + zh_msg="请确认:你将在 WSL 中安装并运行 codex/gemini(不是 Windows 原生)。" ;; + wezterm_recommended) + en_msg="Recommend installing WezTerm as terminal frontend" + zh_msg="推荐安装 WezTerm 作为终端前端" ;; + *) + en_msg="$key" + zh_msg="$key" ;; + esac + if [[ "$CCB_LANG_DETECTED" == "zh" ]]; then + echo "$zh_msg" + else + echo "$en_msg" + fi +} + SCRIPTS_TO_LINK=( bin/cask bin/cask-w @@ -43,14 +118,14 @@ LEGACY_SCRIPTS=( usage() { cat <<'USAGE' -用法: - ./install.sh install # 安装或更新 Codex 双窗口工具 - ./install.sh uninstall # 卸载已安装内容 - -可选环境变量: - CODEX_INSTALL_PREFIX 安装目录 (默认: ~/.local/share/codex-dual) - CODEX_BIN_DIR 可执行文件目录 (默认: ~/.local/bin) - CODEX_CLAUDE_COMMAND_DIR 自定义 Claude 命令目录 (默认自动检测) +Usage: + ./install.sh install # Install or update Codex dual-window tools + ./install.sh uninstall # Uninstall installed content + +Optional environment variables: + CODEX_INSTALL_PREFIX Install directory (default: ~/.local/share/codex-dual) + CODEX_BIN_DIR Executable directory (default: ~/.local/bin) + CODEX_CLAUDE_COMMAND_DIR Custom Claude commands directory (default: auto-detect) USAGE } @@ -82,8 +157,8 @@ require_command() { local cmd="$1" local pkg="${2:-$1}" if ! command -v "$cmd" >/dev/null 2>&1; then - echo "❌ 缺少依赖: $cmd" - echo " 请先安装 $pkg,再重新运行 install.sh" + echo "❌ Missing dependency: $cmd" + echo " Please install $pkg first, then re-run install.sh" exit 1 fi } @@ -93,14 +168,14 @@ require_python_version() { local version version="$(python3 -c 'import sys; print("{}.{}.{}".format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))' 2>/dev/null || echo unknown)" if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then - echo "❌ Python 版本过低: $version" - echo " 需要 Python 3.10+,请升级后重试" + echo "❌ Python version too old: $version" + echo " Requires Python 3.10+, please upgrade and retry" exit 1 fi echo "✓ Python $version" } -# 根据 uname 返回 linux / macos / unknown +# Return linux / macos / unknown based on uname detect_platform() { local name name="$(uname -s 2>/dev/null || echo unknown)" @@ -128,23 +203,57 @@ check_wsl_compatibility() { local ver ver="$(get_wsl_version)" if [[ "$ver" == "1" ]]; then - echo "❌ WSL 1 不支持 FIFO 管道,请升级到 WSL 2" - echo " 运行: wsl --set-version 2" + echo "❌ WSL 1 does not support FIFO pipes, please upgrade to WSL 2" + echo " Run: wsl --set-version 2" exit 1 fi - echo "✅ 检测到 WSL 2 环境" + echo "✅ Detected WSL 2 environment" fi } +confirm_backend_env_wsl() { + if ! is_wsl; then + return + fi + + if [[ "${CCB_INSTALL_ASSUME_YES:-}" == "1" ]]; then + return + fi + + if [[ ! -t 0 ]]; then + echo "❌ Installing in WSL but detected non-interactive terminal; aborted to avoid env mismatch." + echo " If you confirm codex/gemini will be installed and run in WSL:" + echo " Re-run: CCB_INSTALL_ASSUME_YES=1 ./install.sh install" + exit 1 + fi + + echo + echo "================================================================" + echo "⚠️ Detected WSL environment" + echo "================================================================" + echo "ccb/cask-w must run in the same environment as codex/gemini." + echo + echo "Please confirm: you will install and run codex/gemini in WSL (not Windows native)." + echo "If you plan to run codex/gemini in Windows native, exit and run on Windows side:" + echo " powershell -ExecutionPolicy Bypass -File .\\install.ps1 install" + echo "================================================================" + echo + read -r -p "Confirm continue installing in WSL? (y/N): " reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "Installation cancelled"; exit 1 ;; + esac +} + print_tmux_install_hint() { local platform platform="$(detect_platform)" case "$platform" in macos) if command -v brew >/dev/null 2>&1; then - echo " macOS: 运行 'brew install tmux'" + echo " macOS: Run 'brew install tmux'" else - echo " macOS: 未检测到 Homebrew,可先安装 https://brew.sh 然后执行 'brew install tmux'" + echo " macOS: Homebrew not detected, install from https://brew.sh then run 'brew install tmux'" fi ;; linux) @@ -161,75 +270,75 @@ print_tmux_install_hint() { elif command -v zypper >/dev/null 2>&1; then echo " openSUSE: sudo zypper install -y tmux" else - echo " Linux: 请使用发行版自带的包管理器安装 tmux" + echo " Linux: Please use your distro's package manager to install tmux" fi ;; *) - echo " 请参考 https://github.com/tmux/tmux/wiki/Installing 获取 tmux 安装方法" + echo " See https://github.com/tmux/tmux/wiki/Installing for tmux installation" ;; esac } -# 检测是否在 iTerm2 环境中运行 +# Detect if running in iTerm2 environment is_iterm2_environment() { - # 检查 ITERM_SESSION_ID 环境变量 + # Check ITERM_SESSION_ID environment variable if [[ -n "${ITERM_SESSION_ID:-}" ]]; then return 0 fi - # 检查 TERM_PROGRAM + # Check TERM_PROGRAM if [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; then return 0 fi - # macOS 上检查 iTerm2 是否正在运行 + # Check if iTerm2 is running on macOS if [[ "$(uname)" == "Darwin" ]] && pgrep -x "iTerm2" >/dev/null 2>&1; then return 0 fi return 1 } -# 安装 it2 CLI +# Install it2 CLI install_it2() { echo - echo "📦 正在安装 it2 CLI..." + echo "📦 Installing it2 CLI..." - # 检查 pip3 是否可用 + # Check if pip3 is available if ! command -v pip3 >/dev/null 2>&1; then - echo "❌ 未找到 pip3,无法自动安装 it2" - echo " 请手动运行: python3 -m pip install it2" + echo "❌ pip3 not found, cannot auto-install it2" + echo " Please run manually: python3 -m pip install it2" return 1 fi - # 安装 it2 + # Install it2 if pip3 install it2 --user 2>&1; then - echo "✅ it2 CLI 安装成功" + echo "✅ it2 CLI installed successfully" - # 检查是否在 PATH 中 + # Check if in PATH if ! command -v it2 >/dev/null 2>&1; then local user_bin user_bin="$(python3 -m site --user-base)/bin" echo - echo "⚠️ it2 可能不在 PATH 中,请添加以下路径到你的 shell 配置文件:" + echo "⚠️ it2 may not be in PATH, please add the following to your shell config:" echo " export PATH=\"$user_bin:\$PATH\"" fi return 0 else - echo "❌ it2 安装失败" + echo "❌ it2 installation failed" return 1 fi } -# 显示 iTerm2 Python API 启用提示 +# Show iTerm2 Python API enable reminder show_iterm2_api_reminder() { echo echo "================================================================" - echo "🔔 重要提示:请在 iTerm2 中启用 Python API" + echo "🔔 Important: Please enable Python API in iTerm2" echo "================================================================" - echo " 步骤:" - echo " 1. 打开 iTerm2" - echo " 2. 进入 Preferences (⌘ + ,)" - echo " 3. 选择 Magic 标签页" - echo " 4. 勾选 \"Enable Python API\"" - echo " 5. 确认警告对话框" + echo " Steps:" + echo " 1. Open iTerm2" + echo " 2. Go to Preferences (⌘ + ,)" + echo " 3. Select Magic tab" + echo " 4. Check \"Enable Python API\"" + echo " 5. Confirm the warning dialog" echo "================================================================" echo } @@ -238,34 +347,34 @@ require_terminal_backend() { local wezterm_override="${CODEX_WEZTERM_BIN:-${WEZTERM_BIN:-}}" # ============================================ - # 优先检测当前运行环境,确保使用正确的终端工具 + # Prioritize detecting current environment # ============================================ - # 1. 如果在 WezTerm 环境中运行 + # 1. If running in WezTerm environment if [[ -n "${WEZTERM_PANE:-}" ]]; then if [[ -n "${wezterm_override}" ]] && { command -v "${wezterm_override}" >/dev/null 2>&1 || [[ -f "${wezterm_override}" ]]; }; then - echo "✓ 检测到 WezTerm 环境 (${wezterm_override})" + echo "✓ Detected WezTerm environment (${wezterm_override})" return fi if command -v wezterm >/dev/null 2>&1 || command -v wezterm.exe >/dev/null 2>&1; then - echo "✓ 检测到 WezTerm 环境" + echo "✓ Detected WezTerm environment" return fi fi - # 2. 如果在 iTerm2 环境中运行 + # 2. If running in iTerm2 environment if is_iterm2_environment; then - # 检查是否已安装 it2 + # Check if it2 is installed if command -v it2 >/dev/null 2>&1; then - echo "✓ 检测到 iTerm2 环境 (it2 CLI 已安装)" - echo " 💡 请确保已启用 iTerm2 Python API (Preferences > Magic > Enable Python API)" + echo "✓ Detected iTerm2 environment (it2 CLI installed)" + echo " 💡 Please ensure iTerm2 Python API is enabled (Preferences > Magic > Enable Python API)" return fi - # 未安装 it2,询问是否安装 - echo "🍎 检测到 iTerm2 环境,但未安装 it2 CLI" + # it2 not installed, ask to install + echo "🍎 Detected iTerm2 environment but it2 CLI not installed" echo - read -p "是否自动安装 it2 CLI?(Y/n): " -n 1 -r + read -p "Auto-install it2 CLI? (Y/n): " -n 1 -r echo if [[ ! $REPLY =~ ^[Nn]$ ]]; then @@ -274,68 +383,68 @@ require_terminal_backend() { return fi else - echo "跳过 it2 安装,将使用 tmux 作为后备方案" + echo "Skipping it2 installation, will use tmux as fallback" fi fi - # 3. 如果在 tmux 环境中运行 + # 3. If running in tmux environment if [[ -n "${TMUX:-}" ]]; then - echo "✓ 检测到 tmux 环境" + echo "✓ Detected tmux environment" return fi # ============================================ - # 不在特定环境中,按可用性检测 + # Not in specific environment, detect by availability # ============================================ - # 4. 检查 WezTerm 环境变量覆盖 + # 4. Check WezTerm environment variable override if [[ -n "${wezterm_override}" ]]; then if command -v "${wezterm_override}" >/dev/null 2>&1 || [[ -f "${wezterm_override}" ]]; then - echo "✓ 检测到 WezTerm (${wezterm_override})" + echo "✓ Detected WezTerm (${wezterm_override})" return fi fi - # 5. 检查 WezTerm 命令 + # 5. Check WezTerm command if command -v wezterm >/dev/null 2>&1 || command -v wezterm.exe >/dev/null 2>&1; then - echo "✓ 检测到 WezTerm" + echo "✓ Detected WezTerm" return fi - # WSL 场景:Windows PATH 可能未注入 WSL,尝试常见安装路径 + # WSL: Windows PATH may not be injected, try common install paths if [[ -f "/proc/version" ]] && grep -qi microsoft /proc/version 2>/dev/null; then if [[ -x "/mnt/c/Program Files/WezTerm/wezterm.exe" ]] || [[ -f "/mnt/c/Program Files/WezTerm/wezterm.exe" ]]; then - echo "✓ 检测到 WezTerm (/mnt/c/Program Files/WezTerm/wezterm.exe)" + echo "✓ Detected WezTerm (/mnt/c/Program Files/WezTerm/wezterm.exe)" return fi if [[ -x "/mnt/c/Program Files (x86)/WezTerm/wezterm.exe" ]] || [[ -f "/mnt/c/Program Files (x86)/WezTerm/wezterm.exe" ]]; then - echo "✓ 检测到 WezTerm (/mnt/c/Program Files (x86)/WezTerm/wezterm.exe)" + echo "✓ Detected WezTerm (/mnt/c/Program Files (x86)/WezTerm/wezterm.exe)" return fi fi - # 6. 检查 it2 CLI + # 6. Check it2 CLI if command -v it2 >/dev/null 2>&1; then - echo "✓ 检测到 it2 CLI" + echo "✓ Detected it2 CLI" return fi - # 7. 检查 tmux + # 7. Check tmux if command -v tmux >/dev/null 2>&1; then - echo "✓ 检测到 tmux(建议同时安装 WezTerm 以获得更好体验)" + echo "✓ Detected tmux (recommend also installing WezTerm for better experience)" return fi - # 8. 没有找到任何可用的终端复用器 - echo "❌ 缺少依赖: WezTerm、tmux 或 it2 (至少需要安装其中一个)" - echo " WezTerm 官网: https://wezfurlong.org/wezterm/" + # 8. No terminal multiplexer found + echo "❌ Missing dependency: WezTerm, tmux or it2 (at least one required)" + echo " WezTerm website: https://wezfurlong.org/wezterm/" - # macOS 上额外提示 iTerm2 + it2 选项 + # Extra hint for macOS users about iTerm2 + it2 if [[ "$(uname)" == "Darwin" ]]; then echo - echo "💡 macOS 用户推荐选项:" - echo " - 如果你使用 iTerm2,可以安装 it2 CLI: pip3 install it2" - echo " - 或者安装 tmux: brew install tmux" + echo "💡 macOS user recommended options:" + echo " - If using iTerm2, install it2 CLI: pip3 install it2" + echo " - Or install tmux: brew install tmux" fi print_tmux_install_hint @@ -384,7 +493,7 @@ save_wezterm_config() { if [[ -n "$wezterm_path" ]]; then mkdir -p "$HOME/.config/ccb" echo "CODEX_WEZTERM_BIN=${wezterm_path}" > "$HOME/.config/ccb/env" - echo "✓ WezTerm 路径已缓存: $wezterm_path" + echo "✓ WezTerm path cached: $wezterm_path" fi } @@ -415,6 +524,37 @@ copy_project() { mkdir -p "$(dirname "$INSTALL_PREFIX")" mv "$staging" "$INSTALL_PREFIX" trap - EXIT + + # Update GIT_COMMIT and GIT_DATE in ccb file + local git_commit="" git_date="" + + # Method 1: From git repo + if command -v git >/dev/null 2>&1 && [[ -d "$REPO_ROOT/.git" ]]; then + git_commit=$(git -C "$REPO_ROOT" log -1 --format='%h' 2>/dev/null || echo "") + git_date=$(git -C "$REPO_ROOT" log -1 --format='%cs' 2>/dev/null || echo "") + fi + + # Method 2: From environment variables (set by ccb update) + if [[ -z "$git_commit" && -n "${CCB_GIT_COMMIT:-}" ]]; then + git_commit="$CCB_GIT_COMMIT" + git_date="${CCB_GIT_DATE:-}" + fi + + # Method 3: From GitHub API (fallback) + if [[ -z "$git_commit" ]] && command -v curl >/dev/null 2>&1; then + local api_response + api_response=$(curl -fsSL "https://api.github.com/repos/bfly123/claude_code_bridge/commits/main" 2>/dev/null || echo "") + if [[ -n "$api_response" ]]; then + git_commit=$(echo "$api_response" | grep -o '"sha": "[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-7) + git_date=$(echo "$api_response" | grep -o '"date": "[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-10) + fi + fi + + if [[ -n "$git_commit" && -f "$INSTALL_PREFIX/ccb" ]]; then + sed -i.bak "s/^GIT_COMMIT = .*/GIT_COMMIT = \"$git_commit\"/" "$INSTALL_PREFIX/ccb" + sed -i.bak "s/^GIT_DATE = .*/GIT_DATE = \"$git_date\"/" "$INSTALL_PREFIX/ccb" + rm -f "$INSTALL_PREFIX/ccb.bak" + fi } install_bin_links() { @@ -424,7 +564,7 @@ install_bin_links() { local name name="$(basename "$path")" if [[ ! -f "$INSTALL_PREFIX/$path" ]]; then - echo "⚠️ 未找到脚本 $INSTALL_PREFIX/$path,跳过创建链接" + echo "⚠️ Script not found $INSTALL_PREFIX/$path, skipping link creation" continue fi chmod +x "$INSTALL_PREFIX/$path" @@ -441,7 +581,7 @@ install_bin_links() { rm -f "$BIN_DIR/$legacy" done - echo "已在 $BIN_DIR 创建可执行入口" + echo "Created executable links in $BIN_DIR" } install_claude_commands() { @@ -454,10 +594,11 @@ install_claude_commands() { chmod 0644 "$claude_dir/$doc" 2>/dev/null || true done - echo "已更新 Claude 命令目录: $claude_dir" + echo "Updated Claude commands directory: $claude_dir" } -RULE_MARKER="## Codex Collaboration Rules" +CCB_START_MARKER="" +CCB_END_MARKER="" LEGACY_RULE_MARKER="## Codex 协作规则" remove_codex_mcp() { @@ -468,7 +609,7 @@ remove_codex_mcp() { fi if ! command -v python3 >/dev/null 2>&1; then - echo "⚠️ 需要 python3 来检测 MCP 配置" + echo "⚠️ python3 required to detect MCP configuration" return fi @@ -493,7 +634,7 @@ except: " 2>/dev/null) if [[ "$has_codex_mcp" == "yes" ]]; then - echo "⚠️ 检测到 codex 相关的 MCP 配置,正在移除以避免冲突..." + echo "⚠️ Detected codex-related MCP configuration, removing to avoid conflicts..." python3 -c " import json with open('$claude_config', 'r') as f: @@ -508,11 +649,11 @@ for proj, cfg in data.get('projects', {}).items(): with open('$claude_config', 'w') as f: json.dump(data, f, indent=2) if removed: - print('已移除以下 MCP 配置:') + print('Removed the following MCP configurations:') for r in removed: print(f' - {r}') " - echo "✅ Codex MCP 配置已清理" + echo "✅ Codex MCP configuration cleaned" fi } @@ -520,38 +661,18 @@ install_claude_md_config() { local claude_md="$HOME/.claude/CLAUDE.md" mkdir -p "$HOME/.claude" - # Remove old rules (both legacy Chinese and new English versions) - if [[ -f "$claude_md" ]]; then - if grep -qE "$RULE_MARKER|$LEGACY_RULE_MARKER|## Gemini" "$claude_md" 2>/dev/null; then - echo "Removing old collaboration rules..." - python3 -c " -import re -with open('$claude_md', 'r', encoding='utf-8') as f: - content = f.read() -# Remove all collaboration rule sections -patterns = [ - r'## Codex Collaboration Rules.*?(?=\n## |\Z)', - r'## Codex 协作规则.*?(?=\n## |\Z)', - r'## Gemini Collaboration Rules.*?(?=\n## |\Z)', - r'## Gemini 协作规则.*?(?=\n## |\Z)', -] -for p in patterns: - content = re.sub(p, '', content, flags=re.DOTALL) -content = content.rstrip() + '\n' -with open('$claude_md', 'w', encoding='utf-8') as f: - f.write(content) -" - fi - fi - - cat >> "$claude_md" << 'AI_RULES' - + # Use temp file to avoid Bash 3.2 heredoc parsing bug with single quotes + local ccb_tmpfile="" + ccb_tmpfile="$(mktemp)" || { echo "Failed to create temp file"; return 1; } + trap 'rm -f "${ccb_tmpfile:-}"' RETURN + cat > "$ccb_tmpfile" << 'AI_RULES' + ## Codex Collaboration Rules Codex is another AI assistant running in a separate terminal session (WezTerm, iTerm2 or tmux). When user intent involves asking/consulting/collaborating with Codex: Fast path (minimize latency): -- If the user message starts with any of: `调取codex`, `@codex`, `codex:`, `codex:`, `问codex`, `让codex` then immediately run: - - `cask-w ""` (no extra analysis, no pre-checks like cping) +- If the user message starts with any of: `@codex`, `codex:`, `ask codex`, `let codex`, `/cask-w` then immediately run: + - `Bash(cask-w "", run_in_background=true)` then STOP and wait for bash-notification - If user message is only the prefix (no content), ask a 1-line clarification for what to send. Trigger conditions (any match): @@ -560,23 +681,26 @@ Trigger conditions (any match): - User asks about codex's status or previous reply Command selection: -- Default ask/collaborate → `cask-w ""` (sync, waits for reply) -- Send without waiting → `cask ""` (async, returns immediately) -- Check connectivity → `cping` -- View previous reply → `cpend` +- Default ask/collaborate -> `Bash(cask-w "", run_in_background=true)` + - When bash-notification arrives (task completed), immediately cat the output file to show result + - Do NOT continue with other work until result is shown +- Send without waiting -> `cask ""` (fire and forget) +- Check connectivity -> `cping` +- View previous reply -> `cpend` Examples: -- "what does codex think" → cask-w -- "ask codex to review this" → cask-w -- "is codex alive" → cping -- "don't wait for reply" → cask +- "what does codex think" -> `Bash(cask-w "...", run_in_background=true)`, wait for notification, cat output +- "ask codex to review this" -> `Bash(cask-w "...", run_in_background=true)`, wait for notification, cat output +- "is codex alive" -> cping +- "don't wait for reply" -> cask +- "view codex reply" -> cpend ## Gemini Collaboration Rules Gemini is another AI assistant running in a separate terminal session (WezTerm, iTerm2 or tmux). When user intent involves asking/consulting/collaborating with Gemini: Fast path (minimize latency): -- If the user message starts with any of: `调取gemini`, `@gemini`, `gemini:`, `gemini:`, `问gemini`, `让gemini` then immediately run: - - `gask-w ""` (no extra analysis, no pre-checks like gping) +- If the user message starts with any of: `@gemini`, `gemini:`, `ask gemini`, `let gemini`, `/gask-w` then immediately run: + - `Bash(gask-w "", run_in_background=true)` then STOP and wait for bash-notification - If user message is only the prefix (no content), ask a 1-line clarification for what to send. Trigger conditions (any match): @@ -585,17 +709,62 @@ Trigger conditions (any match): - User asks about gemini's status or previous reply Command selection: -- Default ask/collaborate → `gask-w ""` (sync, waits for reply) -- Send without waiting → `gask ""` (async, returns immediately) -- Check connectivity → `gping` -- View previous reply → `gpend` +- Default ask/collaborate -> `Bash(gask-w "", run_in_background=true)` + - When bash-notification arrives (task completed), immediately cat the output file to show result + - Do NOT continue with other work until result is shown +- Send without waiting -> `gask ""` (fire and forget) +- Check connectivity -> `gping` +- View previous reply -> `gpend` Examples: -- "what does gemini think" → gask-w -- "ask gemini to review this" → gask-w -- "is gemini alive" → gping -- "don't wait for reply" → gask +- "what does gemini think" -> `Bash(gask-w "...", run_in_background=true)`, wait for notification, cat output +- "ask gemini to review this" -> `Bash(gask-w "...", run_in_background=true)`, wait for notification, cat output +- "is gemini alive" -> gping +- "don't wait for reply" -> gask +- "view gemini reply" -> gpend + AI_RULES + local ccb_content + ccb_content="$(cat "$ccb_tmpfile")" + + if [[ -f "$claude_md" ]]; then + if grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then + echo "Updating existing CCB config block..." + python3 -c " +import re +with open('$claude_md', 'r', encoding='utf-8') as f: + content = f.read() +pattern = r'.*?' +new_block = '''$ccb_content''' +content = re.sub(pattern, new_block, content, flags=re.DOTALL) +with open('$claude_md', 'w', encoding='utf-8') as f: + f.write(content) +" + elif grep -qE "$LEGACY_RULE_MARKER|## Codex Collaboration Rules|## Gemini" "$claude_md" 2>/dev/null; then + echo "Removing legacy rules and adding new CCB config block..." + python3 -c " +import re +with open('$claude_md', 'r', encoding='utf-8') as f: + content = f.read() +patterns = [ + r'## Codex Collaboration Rules.*?(?=\n## (?!Gemini)|\Z)', + r'## Codex 协作规则.*?(?=\n## |\Z)', + r'## Gemini Collaboration Rules.*?(?=\n## |\Z)', + r'## Gemini 协作规则.*?(?=\n## |\Z)', +] +for p in patterns: + content = re.sub(p, '', content, flags=re.DOTALL) +content = content.rstrip() + '\n' +with open('$claude_md', 'w', encoding='utf-8') as f: + f.write(content) +" + echo "$ccb_content" >> "$claude_md" + else + echo "$ccb_content" >> "$claude_md" + fi + else + echo "$ccb_content" > "$claude_md" + fi echo "Updated AI collaboration rules in $claude_md" } @@ -660,23 +829,24 @@ with open('$settings_file', 'w') as f: done if [[ $added -eq 1 ]]; then - echo "已更新 $settings_file 权限配置" + echo "Updated $settings_file permissions" else - echo "权限配置已存在于 $settings_file" + echo "Permissions already exist in $settings_file" fi } install_requirements() { check_wsl_compatibility + confirm_backend_env_wsl require_command python3 python3 require_python_version require_terminal_backend if ! has_wezterm; then echo echo "================================================================" - echo "⚠️ 建议安装 WezTerm 作为终端前端(体验更好,推荐 WSL2/Windows 用户)" - echo " - 官网: https://wezfurlong.org/wezterm/" - echo " - 优势: 更顺滑的分屏/滚动/字体渲染,WezTerm 模式下桥接更稳定" + echo "⚠️ Recommend installing WezTerm as terminal frontend (better experience, recommended for WSL2/Windows)" + echo " - Website: https://wezfurlong.org/wezterm/" + echo " - Benefits: Smoother split/scroll/font rendering, more stable bridging in WezTerm mode" echo "================================================================" echo fi @@ -691,12 +861,12 @@ install_all() { install_claude_commands install_claude_md_config install_settings_permissions - echo "✅ 安装完成" - echo " 项目目录 : $INSTALL_PREFIX" - echo " 可执行目录: $BIN_DIR" - echo " Claude 命令已更新" - echo " 全局 CLAUDE.md 已配置 Codex 协作规则" - echo " 全局 settings.json 已添加权限" + echo "✅ Installation complete" + echo " Project dir : $INSTALL_PREFIX" + echo " Executable dir : $BIN_DIR" + echo " Claude commands updated" + echo " Global CLAUDE.md configured with Codex collaboration rules" + echo " Global settings.json permissions added" } uninstall_claude_md_config() { @@ -706,16 +876,32 @@ uninstall_claude_md_config() { return fi - if grep -qE "$RULE_MARKER|$LEGACY_RULE_MARKER|## Gemini" "$claude_md" 2>/dev/null; then - echo "正在移除 CLAUDE.md 中的协作规则..." + if grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then + echo "Removing CCB config block from CLAUDE.md..." + if command -v python3 >/dev/null 2>&1; then + python3 -c " +import re +with open('$claude_md', 'r', encoding='utf-8') as f: + content = f.read() +pattern = r'\n?.*?\n?' +content = re.sub(pattern, '\n', content, flags=re.DOTALL) +content = content.strip() + '\n' +with open('$claude_md', 'w', encoding='utf-8') as f: + f.write(content) +" + echo "Removed CCB config from CLAUDE.md" + else + echo "⚠️ python3 required to clean CLAUDE.md, please manually remove CCB_CONFIG block" + fi + elif grep -qE "$LEGACY_RULE_MARKER|## Codex Collaboration Rules|## Gemini" "$claude_md" 2>/dev/null; then + echo "Removing legacy collaboration rules from CLAUDE.md..." if command -v python3 >/dev/null 2>&1; then python3 -c " import re with open('$claude_md', 'r', encoding='utf-8') as f: content = f.read() -# Remove all collaboration rule sections patterns = [ - r'## Codex Collaboration Rules.*?(?=\n## |\Z)', + r'## Codex Collaboration Rules.*?(?=\n## (?!Gemini)|\Z)', r'## Codex 协作规则.*?(?=\n## |\Z)', r'## Gemini Collaboration Rules.*?(?=\n## |\Z)', r'## Gemini 协作规则.*?(?=\n## |\Z)', @@ -726,9 +912,9 @@ content = content.rstrip() + '\n' with open('$claude_md', 'w', encoding='utf-8') as f: f.write(content) " - echo "已移除 CLAUDE.md 中的协作规则" + echo "Removed collaboration rules from CLAUDE.md" else - echo "⚠️ 需要 python3 来清理 CLAUDE.md,请手动移除协作规则部分" + echo "⚠️ python3 required to clean CLAUDE.md, please manually remove collaboration rules" fi fi } @@ -761,7 +947,7 @@ uninstall_settings_permissions() { done if [[ $has_perms -eq 1 ]]; then - echo "正在移除 settings.json 中的权限配置..." + echo "Removing permission configuration from settings.json..." python3 -c " import json perms_to_remove = [ @@ -784,23 +970,23 @@ if 'permissions' in data and 'allow' in data['permissions']: with open('$settings_file', 'w') as f: json.dump(data, f, indent=2) " - echo "已移除 settings.json 中的权限配置" + echo "Removed permission configuration from settings.json" fi else - echo "⚠️ 需要 python3 来清理 settings.json,请手动移除相关权限" + echo "⚠️ python3 required to clean settings.json, please manually remove related permissions" fi } uninstall_all() { - echo "🧹 开始卸载 ccb..." + echo "🧹 Starting ccb uninstall..." - # 1. 移除项目目录 + # 1. Remove project directory if [[ -d "$INSTALL_PREFIX" ]]; then rm -rf "$INSTALL_PREFIX" - echo "已移除项目目录: $INSTALL_PREFIX" + echo "Removed project directory: $INSTALL_PREFIX" fi - # 2. 移除 bin 链接 + # 2. Remove bin links for path in "${SCRIPTS_TO_LINK[@]}"; do local name name="$(basename "$path")" @@ -811,24 +997,31 @@ uninstall_all() { for legacy in "${LEGACY_SCRIPTS[@]}"; do rm -f "$BIN_DIR/$legacy" done - echo "已移除 bin 链接: $BIN_DIR" + echo "Removed bin links: $BIN_DIR" - # 3. 移除 Claude 命令文件 - local claude_dir - claude_dir="$(detect_claude_dir)" - for doc in "${CLAUDE_MARKDOWN[@]}"; do - rm -f "$claude_dir/$doc" + # 3. Remove Claude command files (clean all possible locations) + local cmd_dirs=( + "$HOME/.claude/commands" + "$HOME/.config/claude/commands" + "$HOME/.local/share/claude/commands" + ) + for dir in "${cmd_dirs[@]}"; do + if [[ -d "$dir" ]]; then + for doc in "${CLAUDE_MARKDOWN[@]}"; do + rm -f "$dir/$doc" + done + echo "Cleaned commands directory: $dir" + fi done - echo "已移除 Claude 命令: $claude_dir" - # 4. 移除 CLAUDE.md 中的协作规则 + # 4. Remove collaboration rules from CLAUDE.md uninstall_claude_md_config - # 5. 移除 settings.json 中的权限配置 + # 5. Remove permission configuration from settings.json uninstall_settings_permissions - echo "✅ 卸载完成" - echo " 💡 注意: 依赖项 (python3, tmux, wezterm, it2) 未被移除" + echo "✅ Uninstall complete" + echo " 💡 Note: Dependencies (python3, tmux, wezterm, it2) were not removed" } main() { diff --git a/lib/ccb_config.py b/lib/ccb_config.py new file mode 100644 index 0000000..15ac039 --- /dev/null +++ b/lib/ccb_config.py @@ -0,0 +1,83 @@ +"""CCB configuration for Windows/WSL backend environment""" +import json +import os +import subprocess +import sys +from pathlib import Path + + +def get_backend_env() -> str | None: + """Get BackendEnv from env var or .ccb-config.json""" + v = (os.environ.get("CCB_BACKEND_ENV") or "").strip().lower() + if v in {"wsl", "windows"}: + return v + path = Path.cwd() / ".ccb-config.json" + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + v = (data.get("BackendEnv") or "").strip().lower() + if v in {"wsl", "windows"}: + return v + except Exception: + pass + return "windows" if sys.platform == "win32" else None + + +def _wsl_probe_distro_and_home() -> tuple[str, str]: + """Probe default WSL distro and home directory""" + try: + r = subprocess.run( + ["wsl.exe", "-e", "sh", "-lc", "echo $WSL_DISTRO_NAME; echo $HOME"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10 + ) + if r.returncode == 0: + lines = r.stdout.strip().split("\n") + if len(lines) >= 2: + return lines[0].strip(), lines[1].strip() + except Exception: + pass + try: + r = subprocess.run( + ["wsl.exe", "-l", "-q"], + capture_output=True, text=True, encoding="utf-16-le", errors="replace", timeout=5 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + distro = line.strip().strip("\x00") + if distro: + break + else: + distro = "Ubuntu" + else: + distro = "Ubuntu" + except Exception: + distro = "Ubuntu" + try: + r = subprocess.run( + ["wsl.exe", "-d", distro, "-e", "sh", "-lc", "echo $HOME"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5 + ) + home = r.stdout.strip() if r.returncode == 0 else "/root" + except Exception: + home = "/root" + return distro, home + + +def apply_backend_env() -> None: + """Apply BackendEnv=wsl settings (set session root paths for Windows to access WSL)""" + if sys.platform != "win32" or get_backend_env() != "wsl": + return + if os.environ.get("CODEX_SESSION_ROOT") and os.environ.get("GEMINI_ROOT"): + return + distro, home = _wsl_probe_distro_and_home() + for base in (fr"\\wsl.localhost\{distro}", fr"\\wsl$\{distro}"): + prefix = base + home.replace("/", "\\") + codex_path = prefix + r"\.codex\sessions" + gemini_path = prefix + r"\.gemini\tmp" + if Path(codex_path).exists() or Path(gemini_path).exists(): + os.environ.setdefault("CODEX_SESSION_ROOT", codex_path) + os.environ.setdefault("GEMINI_ROOT", gemini_path) + return + prefix = fr"\\wsl.localhost\{distro}" + home.replace("/", "\\") + os.environ.setdefault("CODEX_SESSION_ROOT", prefix + r"\.codex\sessions") + os.environ.setdefault("GEMINI_ROOT", prefix + r"\.gemini\tmp") diff --git a/lib/codex_comm.py b/lib/codex_comm.py index 89237d9..2f6063f 100644 --- a/lib/codex_comm.py +++ b/lib/codex_comm.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Codex 通信模块(日志驱动版本) -通过 FIFO 发送请求,并从 ~/.codex/sessions 下的官方日志解析回复。 +Codex communication module (log-driven version) +Sends requests via FIFO and parses replies from ~/.codex/sessions logs. """ from __future__ import annotations @@ -9,6 +9,7 @@ import json import os import re +import sys import time import shlex from datetime import datetime @@ -16,6 +17,10 @@ from typing import Optional, Tuple, Dict, Any from terminal import get_backend_for_session, get_pane_id_from_session +from ccb_config import apply_backend_env +from i18n import t + +apply_backend_env() SESSION_ROOT = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() SESSION_ID_PATTERN = re.compile( @@ -25,7 +30,7 @@ class CodexLogReader: - """读取 ~/.codex/sessions 内的 Codex 官方日志""" + """Reads Codex official logs from ~/.codex/sessions""" def __init__(self, root: Path = SESSION_ROOT, log_path: Optional[Path] = None, session_id_filter: Optional[str] = None): self.root = Path(root).expanduser() @@ -59,8 +64,6 @@ def _scan_latest(self) -> Optional[Path]: latest_mtime = -1.0 for p in (p for p in self.root.glob("**/*.jsonl") if p.is_file()): try: - if self._session_id_filter and self._session_id_filter not in p.name: - continue mtime = p.stat().st_mtime except OSError: continue @@ -74,37 +77,52 @@ def _scan_latest(self) -> Optional[Path]: def _latest_log(self) -> Optional[Path]: preferred = self._preferred_log - if preferred and preferred.exists(): - return preferred + # Always scan for latest to detect if preferred is stale latest = self._scan_latest() if latest: - self._preferred_log = latest - return latest + # If preferred is stale (different file or older), update it + if not preferred or not preferred.exists() or latest != preferred: + try: + preferred_mtime = preferred.stat().st_mtime if preferred and preferred.exists() else 0 + latest_mtime = latest.stat().st_mtime + if latest_mtime > preferred_mtime: + self._preferred_log = latest + return latest + except OSError: + self._preferred_log = latest + return latest + return preferred if preferred and preferred.exists() else latest + return preferred if preferred and preferred.exists() else None def current_log_path(self) -> Optional[Path]: return self._latest_log() def capture_state(self) -> Dict[str, Any]: - """记录当前日志与偏移""" + """Capture current log path and offset""" log = self._latest_log() - offset = 0 + offset = -1 if log and log.exists(): try: offset = log.stat().st_size except OSError: - offset = 0 + try: + with log.open("rb") as handle: + handle.seek(0, os.SEEK_END) + offset = handle.tell() + except OSError: + offset = -1 return {"log_path": log, "offset": offset} def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: - """阻塞等待新的回复""" + """Block and wait for new reply""" return self._read_since(state, timeout, block=True) def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: - """非阻塞读取回复""" + """Non-blocking read for reply""" return self._read_since(state, timeout=0.0, block=False) def latest_message(self) -> Optional[str]: - """直接获取最新一条回复""" + """Get the latest reply directly""" log_path = self._latest_log() if not log_path or not log_path.exists(): return None @@ -140,7 +158,9 @@ def latest_message(self) -> Optional[str]: def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: deadline = time.time() + timeout current_path = self._normalize_path(state.get("log_path")) - offset = state.get("offset", 0) + offset = state.get("offset", -1) + if not isinstance(offset, int): + offset = -1 # Keep rescans infrequent; new messages usually append to the same log file. rescan_interval = min(2.0, max(0.2, timeout / 2.0)) last_rescan = time.time() @@ -157,7 +177,7 @@ def ensure_log() -> Path: if latest: self._preferred_log = latest return latest - raise FileNotFoundError("未找到 Codex session 日志") + raise FileNotFoundError("Codex session log not found") while True: try: @@ -168,16 +188,40 @@ def ensure_log() -> Path: time.sleep(self._poll_interval) continue - with log_path.open("r", encoding="utf-8", errors="ignore") as fh: - fh.seek(offset) + try: + size = log_path.stat().st_size + except OSError: + size = None + + # If caller couldn't capture a baseline, establish it now (start from EOF). + if offset < 0: + offset = size if isinstance(size, int) else 0 + + with log_path.open("rb") as fh: + try: + if isinstance(size, int) and offset > size: + offset = size + fh.seek(offset, os.SEEK_SET) + except OSError: + # If seek fails, reset to EOF and try again on next loop. + offset = size if isinstance(size, int) else 0 + if not block: + return None, {"log_path": log_path, "offset": offset} + time.sleep(self._poll_interval) + continue while True: if block and time.time() >= deadline: return None, {"log_path": log_path, "offset": offset} - line = fh.readline() - if not line: + pos_before = fh.tell() + raw_line = fh.readline() + if not raw_line: + break + # If we hit EOF without a newline, the writer may still be appending this line. + if not raw_line.endswith(b"\n"): + fh.seek(pos_before) break offset = fh.tell() - line = line.strip() + line = raw_line.decode("utf-8", errors="ignore").strip() if not line: continue try: @@ -193,13 +237,13 @@ def ensure_log() -> Path: if latest and latest != log_path: current_path = latest self._preferred_log = latest - try: - offset = latest.stat().st_size - except OSError: - offset = 0 + # When switching to a new log file (session rotation / new session), + # start from the beginning to avoid missing a reply that was already written + # before we noticed the new file. + offset = 0 if not block: return None, {"log_path": current_path, "offset": offset} - time.sleep(0.05) + time.sleep(self._poll_interval) last_rescan = time.time() continue last_rescan = time.time() @@ -231,12 +275,12 @@ def _extract_message(entry: dict) -> Optional[str]: class CodexCommunicator: - """通过 FIFO 与 Codex 桥接器通信,并使用日志读取回复""" + """Communicates with Codex bridge via FIFO and reads replies from logs""" - def __init__(self): + def __init__(self, lazy_init: bool = False): self.session_info = self._load_session_info() if not self.session_info: - raise RuntimeError("❌ 未找到活跃的Codex会话,请先运行 ccb up codex") + raise RuntimeError("❌ No active Codex session found. Run 'ccb up codex' first") self.session_id = self.session_info["session_id"] self.runtime_dir = Path(self.session_info["runtime_dir"]) @@ -247,21 +291,40 @@ def __init__(self): self.timeout = int(os.environ.get("CODEX_SYNC_TIMEOUT", "30")) self.marker_prefix = "ask" - preferred_log = self.session_info.get("codex_session_path") - bound_session_id = self.session_info.get("codex_session_id") - self.log_reader = CodexLogReader(log_path=preferred_log, session_id_filter=bound_session_id) self.project_session_file = self.session_info.get("_session_file") - self._prime_log_binding() + # Lazy initialization: defer log reader and health check + self._log_reader: Optional[CodexLogReader] = None + self._log_reader_primed = False - healthy, msg = self._check_session_health() - if not healthy: - raise RuntimeError(f"❌ 会话不健康: {msg}\n提示: 请运行 ccb up codex 启动新会话") + if not lazy_init: + self._ensure_log_reader() + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError(f"❌ Session unhealthy: {msg}\nTip: Run 'ccb up codex' to start a new session") + + @property + def log_reader(self) -> CodexLogReader: + """Lazy-load log reader on first access""" + if self._log_reader is None: + self._ensure_log_reader() + return self._log_reader + + def _ensure_log_reader(self) -> None: + """Initialize log reader if not already done""" + if self._log_reader is not None: + return + preferred_log = self.session_info.get("codex_session_path") + bound_session_id = self.session_info.get("codex_session_id") + self._log_reader = CodexLogReader(log_path=preferred_log, session_id_filter=bound_session_id) + if not self._log_reader_primed: + self._prime_log_binding() + self._log_reader_primed = True def _load_session_info(self): if "CODEX_SESSION_ID" in os.environ: terminal = os.environ.get("CODEX_TERMINAL", "tmux") - # 根据终端类型获取正确的 pane_id + # Get pane_id based on terminal type if terminal == "wezterm": pane_id = os.environ.get("CODEX_WEZTERM_PANE", "") elif terminal == "iterm2": @@ -284,7 +347,7 @@ def _load_session_info(self): return None try: - with open(project_session, "r", encoding="utf-8") as f: + with open(project_session, "r", encoding="utf-8-sig") as f: data = json.load(f) if not isinstance(data, dict): @@ -304,7 +367,7 @@ def _load_session_info(self): return None def _prime_log_binding(self) -> None: - """确保在会话启动时尽早绑定日志路径和会话ID""" + """Ensure log path and session ID are bound early at session start""" log_hint = self.log_reader.current_log_path() if not log_hint: return @@ -316,39 +379,52 @@ def _check_session_health(self): def _check_session_health_impl(self, probe_terminal: bool): try: if not self.runtime_dir.exists(): - return False, "运行时目录不存在" + return False, "Runtime directory does not exist" - # WezTerm/iTerm2 模式:没有 tmux wrapper,因此通常不会生成 codex.pid; - # 以 pane 存活作为健康判定(与 Gemini 逻辑一致)。 + # WezTerm/iTerm2 mode: no tmux wrapper, so codex.pid usually not generated; + # use pane liveness as health check (consistent with Gemini logic). if self.terminal in ("wezterm", "iterm2"): if not self.pane_id: - return False, f"未找到 {self.terminal} pane_id" + return False, f"{self.terminal} pane_id not found" if probe_terminal and (not self.backend or not self.backend.is_alive(self.pane_id)): - return False, f"{self.terminal} pane 不存在: {self.pane_id}" - return True, "会话正常" + return False, f"{self.terminal} pane does not exist: {self.pane_id}" + return True, "Session healthy" - # tmux 模式:依赖 wrapper 写入 codex.pid 与 FIFO + # tmux mode: relies on wrapper to write codex.pid and FIFO codex_pid_file = self.runtime_dir / "codex.pid" if not codex_pid_file.exists(): - return False, "Codex进程PID文件不存在" + return False, "Codex process PID file not found" with open(codex_pid_file, "r", encoding="utf-8") as f: codex_pid = int(f.read().strip()) try: os.kill(codex_pid, 0) except OSError: - return False, f"Codex进程(PID:{codex_pid})已退出" + return False, f"Codex process (PID:{codex_pid}) has exited" + + bridge_pid_file = self.runtime_dir / "bridge.pid" + if not bridge_pid_file.exists(): + return False, "Bridge process PID file not found" + try: + with bridge_pid_file.open("r", encoding="utf-8") as handle: + bridge_pid = int(handle.read().strip()) + except Exception: + return False, "Failed to read bridge process PID" + try: + os.kill(bridge_pid, 0) + except OSError: + return False, f"Bridge process (PID:{bridge_pid}) has exited" if not self.input_fifo.exists(): - return False, "通信管道不存在" + return False, "Communication pipe does not exist" - return True, "会话正常" + return True, "Session healthy" except Exception as exc: - return False, f"检查失败: {exc}" + return False, f"Health check failed: {exc}" def _send_via_terminal(self, content: str) -> None: if not self.backend or not self.pane_id: - raise RuntimeError("未配置终端会话") + raise RuntimeError("Terminal session not configured") self.backend.send_text(self.pane_id, content) def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: @@ -361,7 +437,7 @@ def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: state = self.log_reader.capture_state() - # tmux 模式优先通过 FIFO 驱动桥接器;WezTerm/iTerm2 模式则直接向 pane 注入文本 + # tmux mode drives bridge via FIFO; WezTerm/iTerm2 mode injects text directly to pane if self.terminal in ("wezterm", "iterm2"): self._send_via_terminal(content) else: @@ -378,29 +454,29 @@ def ask_async(self, question: str) -> bool: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") marker, state = self._send_message(question) log_hint = state.get("log_path") or self.log_reader.current_log_path() self._remember_codex_session(log_hint) - print(f"✅ 已发送到Codex (标记: {marker[:12]}...)") - print("提示: 使用 /cpend 查看最新回复") + print(f"✅ Sent to Codex (marker: {marker[:12]}...)") + print("Tip: Use /cpend to view latest reply") return True except Exception as exc: - print(f"❌ 发送失败: {exc}") + print(f"❌ Send failed: {exc}") return False def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str]: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") - print("🔔 发送问题到Codex...") + print(f"🔔 {t('sending_to', provider='Codex')}", flush=True) marker, state = self._send_message(question) wait_timeout = self.timeout if timeout is None else int(timeout) if wait_timeout == 0: - print("⏳ 等待 Codex 回复 (无超时,Ctrl-C 可中断)...") + print(f"⏳ {t('waiting_for_reply', provider='Codex')}", flush=True) start_time = time.time() last_hint = 0 while True: @@ -411,29 +487,29 @@ def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str log_hint = self.log_reader.current_log_path() self._remember_codex_session(log_hint) if message: - print("🤖 Codex回复:") + print(f"🤖 {t('reply_from', provider='Codex')}") print(message) return message elapsed = int(time.time() - start_time) if elapsed >= last_hint + 30: last_hint = elapsed - print(f"⏳ 仍在等待... ({elapsed}s)") + print(f"⏳ Still waiting... ({elapsed}s)") - print(f"⏳ 等待Codex回复 (超时 {wait_timeout} 秒)...") + print(f"⏳ Waiting for Codex reply (timeout {wait_timeout}s)...") message, new_state = self.log_reader.wait_for_message(state, float(wait_timeout)) log_hint = (new_state or {}).get("log_path") if isinstance(new_state, dict) else None if not log_hint: log_hint = self.log_reader.current_log_path() self._remember_codex_session(log_hint) if message: - print("🤖 Codex回复:") + print(f"🤖 {t('reply_from', provider='Codex')}") print(message) return message - print("⏰ Codex未在限定时间内回复,可稍后执行 /cpend 获取最新答案") + print(f"⏰ {t('timeout_no_reply', provider='Codex')}") return None except Exception as exc: - print(f"❌ 同步询问失败: {exc}") + print(f"❌ Sync ask failed: {exc}") return None def consume_pending(self, display: bool = True): @@ -444,7 +520,7 @@ def consume_pending(self, display: bool = True): self._remember_codex_session(self.log_reader.current_log_path()) if not message: if display: - print("暂无 Codex 回复") + print(t('no_reply_available', provider='Codex')) return None if display: print(message) @@ -452,7 +528,7 @@ def consume_pending(self, display: bool = True): def ping(self, display: bool = True) -> Tuple[bool, str]: healthy, status = self._check_session_health() - msg = f"✅ Codex连接正常 ({status})" if healthy else f"❌ Codex连接异常: {status}" + msg = f"✅ Codex connection OK ({status})" if healthy else f"❌ Codex connection error: {status}" if display: print(msg) return healthy, msg @@ -494,7 +570,7 @@ def _remember_codex_session(self, log_path: Optional[Path]) -> None: if not project_file.exists(): return try: - with project_file.open("r", encoding="utf-8") as handle: + with project_file.open("r", encoding="utf-8-sig") as handle: data = json.load(handle) except Exception: return @@ -527,7 +603,13 @@ def _remember_codex_session(self, log_path: Optional[Path]) -> None: with tmp_file.open("w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) os.replace(tmp_file, project_file) - except Exception: + except PermissionError as e: + print(f"⚠️ Cannot update {project_file.name}: {e}", file=sys.stderr) + print(f"💡 Try: sudo chown $USER:$USER {project_file}", file=sys.stderr) + if tmp_file.exists(): + tmp_file.unlink(missing_ok=True) + except Exception as e: + print(f"⚠️ Failed to update {project_file.name}: {e}", file=sys.stderr) if tmp_file.exists(): tmp_file.unlink(missing_ok=True) @@ -579,13 +661,13 @@ def _extract_session_id(log_path: Path) -> Optional[str]: def main() -> int: import argparse - parser = argparse.ArgumentParser(description="Codex 通信工具(日志驱动)") - parser.add_argument("question", nargs="*", help="要发送的问题") - parser.add_argument("--wait", "-w", action="store_true", help="同步等待回复") - parser.add_argument("--timeout", type=int, default=30, help="同步超时时间(秒)") - parser.add_argument("--ping", action="store_true", help="测试连通性") - parser.add_argument("--status", action="store_true", help="查看状态") - parser.add_argument("--pending", action="store_true", help="查看待处理回复") + parser = argparse.ArgumentParser(description="Codex communication tool (log-driven)") + parser.add_argument("question", nargs="*", help="Question to send") + parser.add_argument("--wait", "-w", action="store_true", help="Wait for reply synchronously") + parser.add_argument("--timeout", type=int, default=30, help="Sync timeout in seconds") + parser.add_argument("--ping", action="store_true", help="Test connectivity") + parser.add_argument("--status", action="store_true", help="Show status") + parser.add_argument("--pending", action="store_true", help="Show pending reply") args = parser.parse_args() @@ -596,7 +678,7 @@ def main() -> int: comm.ping() elif args.status: status = comm.get_status() - print("📊 Codex状态:") + print("📊 Codex status:") for key, value in status.items(): print(f" {key}: {value}") elif args.pending: @@ -607,18 +689,18 @@ def main() -> int: tokens = tokens[1:] question_text = " ".join(tokens).strip() if not question_text: - print("❌ 请提供问题内容") + print("❌ Please provide a question") return 1 if args.wait: comm.ask_sync(question_text, args.timeout) else: comm.ask_async(question_text) else: - print("请提供问题或使用 --ping/--status/--pending 选项") + print("Please provide a question or use --ping/--status/--pending options") return 1 return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ Execution failed: {exc}") return 1 diff --git a/lib/codex_dual_bridge.py b/lib/codex_dual_bridge.py index 563ff8a..1da8876 100644 --- a/lib/codex_dual_bridge.py +++ b/lib/codex_dual_bridge.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Codex 双窗口桥接器 -负责发送命令到 Codex,支持 tmux 和 WezTerm。 +Codex dual-window bridge +Sends commands to Codex, supports tmux and WezTerm. """ from __future__ import annotations @@ -18,8 +18,19 @@ from terminal import TmuxBackend, WeztermBackend +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return max(0.0, value) + + class TerminalCodexSession: - """通过终端会话向 Codex CLI 注入指令""" + """Inject commands to Codex CLI via terminal session""" def __init__(self, terminal_type: str, pane_id: str): self.terminal_type = terminal_type @@ -33,7 +44,7 @@ def send(self, text: str) -> None: class DualBridge: - """Claude ↔ Codex 桥接主流程""" + """Claude ↔ Codex bridge main process""" def __init__(self, runtime_dir: Path, session_id: str): self.runtime_dir = runtime_dir @@ -47,7 +58,7 @@ def __init__(self, runtime_dir: Path, session_id: str): terminal_type = os.environ.get("CODEX_TERMINAL", "tmux") pane_id = os.environ.get("CODEX_WEZTERM_PANE") if terminal_type == "wezterm" else os.environ.get("CODEX_TMUX_SESSION") if not pane_id: - raise RuntimeError(f"缺少 {'CODEX_WEZTERM_PANE' if terminal_type == 'wezterm' else 'CODEX_TMUX_SESSION'} 环境变量") + raise RuntimeError(f"Missing {'CODEX_WEZTERM_PANE' if terminal_type == 'wezterm' else 'CODEX_TMUX_SESSION'} environment variable") self.codex_session = TerminalCodexSession(terminal_type, pane_id) self._running = True @@ -56,25 +67,34 @@ def __init__(self, runtime_dir: Path, session_id: str): def _handle_signal(self, signum: int, _: Any) -> None: self._running = False - self._log_console(f"⚠️ 收到信号 {signum},准备退出...") + self._log_console(f"⚠️ Received signal {signum}, exiting...") def run(self) -> int: - self._log_console("🔌 Codex桥接器已启动,等待Claude指令...") + self._log_console("🔌 Codex bridge started, waiting for Claude commands...") + idle_sleep = _env_float("CCB_BRIDGE_IDLE_SLEEP", 0.05) + error_backoff_min = _env_float("CCB_BRIDGE_ERROR_BACKOFF_MIN", 0.05) + error_backoff_max = _env_float("CCB_BRIDGE_ERROR_BACKOFF_MAX", 0.2) + error_backoff = max(0.0, min(error_backoff_min, error_backoff_max)) while self._running: try: payload = self._read_request() if payload is None: - time.sleep(0.1) + if idle_sleep: + time.sleep(idle_sleep) continue self._process_request(payload) + error_backoff = max(0.0, min(error_backoff_min, error_backoff_max)) except KeyboardInterrupt: self._running = False except Exception as exc: - self._log_console(f"❌ 处理消息失败: {exc}") + self._log_console(f"❌ Failed to process message: {exc}") self._log_bridge(f"error: {exc}") - time.sleep(0.5) + if error_backoff: + time.sleep(error_backoff) + if error_backoff_max: + error_backoff = min(error_backoff_max, max(error_backoff_min, error_backoff * 2)) - self._log_console("👋 Codex桥接器已退出") + self._log_console("👋 Codex bridge exited") return 0 def _read_request(self) -> Optional[Dict[str, Any]]: @@ -86,7 +106,7 @@ def _read_request(self) -> Optional[Dict[str, Any]]: if not line: return None return json.loads(line) - except json.JSONDecodeError: + except (OSError, json.JSONDecodeError): return None def _process_request(self, payload: Dict[str, Any]) -> None: @@ -100,7 +120,7 @@ def _process_request(self, payload: Dict[str, Any]) -> None: try: self.codex_session.send(content) except Exception as exc: - msg = f"❌ 发送至 Codex 失败: {exc}" + msg = f"❌ Failed to send to Codex: {exc}" self._append_history("codex", msg, marker) self._log_console(msg) @@ -116,7 +136,7 @@ def _append_history(self, role: str, content: str, marker: str) -> None: json.dump(entry, handle, ensure_ascii=False) handle.write("\n") except Exception as exc: - self._log_console(f"⚠️ 写入历史失败: {exc}") + self._log_console(f"⚠️ Failed to write history: {exc}") def _log_bridge(self, message: str) -> None: try: @@ -139,9 +159,9 @@ def _log_console(message: str) -> None: def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Claude-Codex 桥接器") - parser.add_argument("--runtime-dir", required=True, help="运行目录") - parser.add_argument("--session-id", required=True, help="会话ID") + parser = argparse.ArgumentParser(description="Claude-Codex bridge") + parser.add_argument("--runtime-dir", required=True, help="Runtime directory") + parser.add_argument("--session-id", required=True, help="Session ID") return parser.parse_args() diff --git a/lib/compat.py b/lib/compat.py new file mode 100644 index 0000000..50f17af --- /dev/null +++ b/lib/compat.py @@ -0,0 +1,9 @@ +"""Windows compatibility utilities""" +import sys + +def setup_windows_encoding(): + """Configure UTF-8 encoding for Windows console""" + if sys.platform == "win32": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index 305cfb9..cad5c48 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Gemini 通信模块 -支持 tmux 和 WezTerm 终端发送请求,从 ~/.gemini/tmp//chats/session-*.json 读取回复 +Gemini communication module +Supports tmux and WezTerm terminals, reads replies from ~/.gemini/tmp//chats/session-*.json """ from __future__ import annotations @@ -14,15 +14,19 @@ from typing import Optional, Tuple, Dict, Any from terminal import get_backend_for_session, get_pane_id_from_session +from ccb_config import apply_backend_env +from i18n import t + +apply_backend_env() GEMINI_ROOT = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() def _get_project_hash(work_dir: Optional[Path] = None) -> str: - """计算项目目录的哈希值(与 gemini-cli 的 Storage.getFilePathHash 一致)""" + """Calculate project directory hash (consistent with gemini-cli's Storage.getFilePathHash)""" path = work_dir or Path.cwd() - # gemini-cli 使用的是 Node.js 的 path.resolve()(不会 realpath 解析符号链接), - # 因此这里使用 absolute() 而不是 resolve(),避免在 WSL/Windows 场景下 hash 不一致。 + # gemini-cli uses Node.js path.resolve() (doesn't resolve symlinks), + # so we use absolute() instead of resolve() to avoid hash mismatch on WSL/Windows. try: normalized = str(path.expanduser().absolute()) except Exception: @@ -31,7 +35,7 @@ def _get_project_hash(work_dir: Optional[Path] = None) -> str: class GeminiLogReader: - """读取 ~/.gemini/tmp//chats 内的 Gemini 会话文件""" + """Reads Gemini session files from ~/.gemini/tmp//chats""" def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): self.root = Path(root).expanduser() @@ -57,7 +61,7 @@ def _chats_dir(self) -> Optional[Path]: return chats if chats.exists() else None def _scan_latest_session_any_project(self) -> Optional[Path]: - """在所有 projectHash 下扫描最新 session 文件(用于 Windows/WSL 路径哈希不一致的兜底)""" + """Scan latest session across all projectHash (fallback for Windows/WSL path hash mismatch)""" if not self.root.exists(): return None try: @@ -85,23 +89,33 @@ def _scan_latest_session(self) -> Optional[Path]: if sessions: return sessions[-1] - # fallback: projectHash 可能因路径规范化差异(Windows/WSL、符号链接等)而不匹配 + # fallback: projectHash may mismatch due to path normalization differences (Windows/WSL, symlinks, etc.) return self._scan_latest_session_any_project() def _latest_session(self) -> Optional[Path]: - if self._preferred_session and self._preferred_session.exists(): - return self._preferred_session + preferred = self._preferred_session + # Always scan for latest to detect if preferred is stale latest = self._scan_latest_session() if latest: - self._preferred_session = latest - try: - # 若是 fallback 扫描到的 session,则反向绑定 projectHash,后续避免全量扫描 - project_hash = latest.parent.parent.name - if project_hash: - self._project_hash = project_hash - except Exception: - pass - return latest + # If preferred is stale (different file or older), update it + if not preferred or not preferred.exists() or latest != preferred: + try: + preferred_mtime = preferred.stat().st_mtime if preferred and preferred.exists() else 0 + latest_mtime = latest.stat().st_mtime + if latest_mtime > preferred_mtime: + self._preferred_session = latest + try: + project_hash = latest.parent.parent.name + if project_hash: + self._project_hash = project_hash + except Exception: + pass + return latest + except OSError: + self._preferred_session = latest + return latest + return preferred + return preferred if preferred and preferred.exists() else None def set_preferred_session(self, session_path: Optional[Path]) -> None: if not session_path: @@ -117,7 +131,7 @@ def current_session_path(self) -> Optional[Path]: return self._latest_session() def capture_state(self) -> Dict[str, Any]: - """记录当前会话文件和消息数量""" + """Record current session file and message count""" session = self._latest_session() msg_count = 0 mtime = 0.0 @@ -126,20 +140,39 @@ def capture_state(self) -> Dict[str, Any]: last_gemini_id: Optional[str] = None last_gemini_hash: Optional[str] = None if session and session.exists(): + data: Optional[dict] = None try: stat = session.stat() mtime = stat.st_mtime mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) size = stat.st_size - with session.open("r", encoding="utf-8") as f: - data = json.load(f) + except OSError: + stat = None + + # The session JSON may be written in-place; retry briefly to avoid transient JSONDecodeError. + for attempt in range(10): + try: + with session.open("r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + data = loaded + break + except json.JSONDecodeError: + if attempt < 9: + time.sleep(min(self._poll_interval, 0.05)) + continue + except OSError: + break + + if data is None: + # Unknown baseline (parse failed). Let the wait loop establish a stable baseline first. + msg_count = -1 + else: msg_count = len(data.get("messages", [])) last = self._extract_last_gemini(data) if last: last_gemini_id, content = last last_gemini_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() - except (OSError, json.JSONDecodeError): - pass return { "session_path": session, "msg_count": msg_count, @@ -151,15 +184,15 @@ def capture_state(self) -> Dict[str, Any]: } def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: - """阻塞等待新的 Gemini 回复""" + """Block and wait for new Gemini reply""" return self._read_since(state, timeout, block=True) def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: - """非阻塞读取回复""" + """Non-blocking read reply""" return self._read_since(state, timeout=0.0, block=False) def latest_message(self) -> Optional[str]: - """直接获取最新一条 Gemini 回复""" + """Get the latest Gemini reply directly""" session = self._latest_session() if not session or not session.exists(): return None @@ -177,6 +210,7 @@ def latest_message(self) -> Optional[str]: def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: deadline = time.time() + timeout prev_count = state.get("msg_count", 0) + unknown_baseline = isinstance(prev_count, int) and prev_count < 0 prev_mtime = state.get("mtime", 0.0) prev_mtime_ns = state.get("mtime_ns") if prev_mtime_ns is None: @@ -185,18 +219,18 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup prev_session = state.get("session_path") prev_last_gemini_id = state.get("last_gemini_id") prev_last_gemini_hash = state.get("last_gemini_hash") - # 允许短 timeout 场景下也能扫描到新 session 文件(gask-w 默认 1s/次) + # Allow short timeout to scan new session files (gask-w defaults 1s/poll) rescan_interval = min(2.0, max(0.2, timeout / 2.0)) last_rescan = time.time() last_forced_read = time.time() while True: - # 定期重新扫描,检测是否有新会话文件 + # Periodically rescan to detect new session files if time.time() - last_rescan >= rescan_interval: latest = self._scan_latest_session() if latest and latest != self._preferred_session: self._preferred_session = latest - # 新会话文件,重置计数 + # New session file, reset counters if latest != prev_session: prev_count = 0 prev_mtime = 0.0 @@ -226,8 +260,8 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup current_mtime = stat.st_mtime current_mtime_ns = getattr(stat, "st_mtime_ns", int(current_mtime * 1_000_000_000)) current_size = stat.st_size - # Windows/WSL 场景下文件 mtime 可能是秒级精度,单靠 mtime 会漏掉快速写入的更新; - # 因此同时用文件大小作为变化信号。 + # On Windows/WSL, mtime may have second-level precision, which can miss rapid writes. + # Use file size as additional change signal. if block and current_mtime_ns <= prev_mtime_ns and current_size == prev_size: if time.time() - last_forced_read < self._force_read_interval: time.sleep(self._poll_interval) @@ -250,23 +284,99 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup messages = data.get("messages", []) current_count = len(messages) + if unknown_baseline: + # If capture_state couldn't parse the JSON (transient in-place writes), the wait + # loop may see a fully-written reply in the first successful read. If we treat + # that read as a "baseline" we can miss the reply forever. + last_msg = messages[-1] if messages else None + if isinstance(last_msg, dict): + last_type = last_msg.get("type") + last_content = (last_msg.get("content") or "").strip() + else: + last_type = None + last_content = "" + + # Only fast-path when the file has changed since the baseline stat and the + # latest message is a non-empty Gemini reply. + if ( + last_type == "gemini" + and last_content + and (current_mtime_ns > prev_mtime_ns or current_size != prev_size) + ): + msg_id = last_msg.get("id") if isinstance(last_msg, dict) else None + content_hash = hashlib.sha256(last_content.encode("utf-8")).hexdigest() + return last_content, { + "session_path": session, + "msg_count": current_count, + "mtime": current_mtime, + "mtime_ns": current_mtime_ns, + "size": current_size, + "last_gemini_id": msg_id, + "last_gemini_hash": content_hash, + } + + prev_mtime = current_mtime + prev_mtime_ns = current_mtime_ns + prev_size = current_size + prev_count = current_count + last = self._extract_last_gemini(data) + if last: + prev_last_gemini_id, content = last + prev_last_gemini_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() if content else None + unknown_baseline = False + if not block: + return None, { + "session_path": session, + "msg_count": prev_count, + "mtime": prev_mtime, + "mtime_ns": prev_mtime_ns, + "size": prev_size, + "last_gemini_id": prev_last_gemini_id, + "last_gemini_hash": prev_last_gemini_hash, + } + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, { + "session_path": session, + "msg_count": prev_count, + "mtime": prev_mtime, + "mtime_ns": prev_mtime_ns, + "size": prev_size, + "last_gemini_id": prev_last_gemini_id, + "last_gemini_hash": prev_last_gemini_hash, + } + continue + if current_count > prev_count: + # Find the LAST gemini message with content (not the first) + # to avoid returning intermediate status messages + last_gemini_content = None + last_gemini_id = None + last_gemini_hash = None for msg in messages[prev_count:]: if msg.get("type") == "gemini": content = msg.get("content", "").strip() if content: - new_state = { - "session_path": session, - "msg_count": current_count, - "mtime": current_mtime, - "mtime_ns": current_mtime_ns, - "size": current_size, - "last_gemini_id": msg.get("id"), - "last_gemini_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(), - } - return content, new_state + content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() + msg_id = msg.get("id") + if msg_id == prev_last_gemini_id and content_hash == prev_last_gemini_hash: + continue + last_gemini_content = content + last_gemini_id = msg_id + last_gemini_hash = content_hash + if last_gemini_content: + new_state = { + "session_path": session, + "msg_count": current_count, + "mtime": current_mtime, + "mtime_ns": current_mtime_ns, + "size": current_size, + "last_gemini_id": last_gemini_id, + "last_gemini_hash": last_gemini_hash, + } + return last_gemini_content, new_state else: - # 有些版本会先写入空的 gemini 消息,再“原地更新 content”,消息数不变。 + # Some versions write empty gemini message first, then update content in-place. last = self._extract_last_gemini(data) if last: last_id, content = last @@ -337,32 +447,52 @@ def _extract_last_gemini(payload: dict) -> Optional[Tuple[Optional[str], str]]: class GeminiCommunicator: - """通过终端与 Gemini 通信,并从会话文件读取回复""" + """Communicate with Gemini via terminal and read replies from session files""" - def __init__(self): + def __init__(self, lazy_init: bool = False): self.session_info = self._load_session_info() if not self.session_info: - raise RuntimeError("❌ 未找到活跃的 Gemini 会话,请先运行 ccb up gemini") + raise RuntimeError("❌ No active Gemini session found, please run ccb up gemini first") self.session_id = self.session_info["session_id"] self.runtime_dir = Path(self.session_info["runtime_dir"]) self.terminal = self.session_info.get("terminal", "tmux") self.pane_id = get_pane_id_from_session(self.session_info) self.timeout = int(os.environ.get("GEMINI_SYNC_TIMEOUT", "60")) - work_dir_hint = self.session_info.get("work_dir") - log_work_dir = Path(work_dir_hint) if isinstance(work_dir_hint, str) and work_dir_hint else None - self.log_reader = GeminiLogReader(work_dir=log_work_dir) - preferred_session = self.session_info.get("gemini_session_path") or self.session_info.get("session_path") - if preferred_session: - self.log_reader.set_preferred_session(Path(str(preferred_session))) + self.marker_prefix = "ask" self.project_session_file = self.session_info.get("_session_file") self.backend = get_backend_for_session(self.session_info) - healthy, msg = self._check_session_health() - if not healthy: - raise RuntimeError(f"❌ 会话不健康: {msg}\n提示: 请运行 ccb up gemini") + # Lazy initialization: defer log reader and health check + self._log_reader: Optional[GeminiLogReader] = None + self._log_reader_primed = False - self._prime_log_binding() + if not lazy_init: + self._ensure_log_reader() + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError(f"❌ Session unhealthy: {msg}\nHint: Please run ccb up gemini") + + @property + def log_reader(self) -> GeminiLogReader: + """Lazy-load log reader on first access""" + if self._log_reader is None: + self._ensure_log_reader() + return self._log_reader + + def _ensure_log_reader(self) -> None: + """Initialize log reader if not already done""" + if self._log_reader is not None: + return + work_dir_hint = self.session_info.get("work_dir") + log_work_dir = Path(work_dir_hint) if isinstance(work_dir_hint, str) and work_dir_hint else None + self._log_reader = GeminiLogReader(work_dir=log_work_dir) + preferred_session = self.session_info.get("gemini_session_path") or self.session_info.get("session_path") + if preferred_session: + self._log_reader.set_preferred_session(Path(str(preferred_session))) + if not self._log_reader_primed: + self._prime_log_binding() + self._log_reader_primed = True def _prime_log_binding(self) -> None: session_path = self.log_reader.current_session_path() @@ -373,7 +503,7 @@ def _prime_log_binding(self) -> None: def _load_session_info(self): if "GEMINI_SESSION_ID" in os.environ: terminal = os.environ.get("GEMINI_TERMINAL", "tmux") - # 根据终端类型获取正确的 pane_id + # Get correct pane_id based on terminal type if terminal == "wezterm": pane_id = os.environ.get("GEMINI_WEZTERM_PANE", "") elif terminal == "iterm2": @@ -416,49 +546,58 @@ def _check_session_health(self) -> Tuple[bool, str]: def _check_session_health_impl(self, probe_terminal: bool) -> Tuple[bool, str]: try: if not self.runtime_dir.exists(): - return False, "运行时目录不存在" + return False, "Runtime directory not found" if not self.pane_id: - return False, "未找到会话 ID" + return False, "Session ID not found" if probe_terminal and self.backend and not self.backend.is_alive(self.pane_id): - return False, f"{self.terminal} 会话 {self.pane_id} 不存在" - return True, "会话正常" + return False, f"{self.terminal} session {self.pane_id} not found" + return True, "Session OK" except Exception as exc: - return False, f"检查失败: {exc}" + return False, f"Check failed: {exc}" def _send_via_terminal(self, content: str) -> bool: if not self.backend or not self.pane_id: - raise RuntimeError("未配置终端会话") + raise RuntimeError("Terminal session not configured") self.backend.send_text(self.pane_id, content) return True + def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: + marker = self._generate_marker() + state = self.log_reader.capture_state() + self._send_via_terminal(content) + return marker, state + + def _generate_marker(self) -> str: + return f"{self.marker_prefix}-{int(time.time())}-{os.getpid()}" + def ask_async(self, question: str) -> bool: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") self._send_via_terminal(question) - print(f"✅ 已发送到 Gemini") - print("提示: 使用 gpend 查看回复") + print(f"✅ Sent to Gemini") + print("Hint: Use gpend to view reply") return True except Exception as exc: - print(f"❌ 发送失败: {exc}") + print(f"❌ Send failed: {exc}") return False def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str]: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") - print("🔔 发送问题到 Gemini...") + print(f"🔔 {t('sending_to', provider='Gemini')}", flush=True) self._send_via_terminal(question) # Capture state after sending to reduce "question → send" latency. state = self.log_reader.capture_state() wait_timeout = self.timeout if timeout is None else int(timeout) if wait_timeout == 0: - print("⏳ 等待 Gemini 回复 (无超时,Ctrl-C 可中断)...") + print(f"⏳ {t('waiting_for_reply', provider='Gemini')}", flush=True) start_time = time.time() last_hint = 0 while True: @@ -468,28 +607,28 @@ def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str if isinstance(session_path, Path): self._remember_gemini_session(session_path) if message: - print("🤖 Gemini 回复:") + print(f"🤖 {t('reply_from', provider='Gemini')}") print(message) return message elapsed = int(time.time() - start_time) if elapsed >= last_hint + 30: last_hint = elapsed - print(f"⏳ 仍在等待... ({elapsed}s)") + print(f"⏳ Still waiting... ({elapsed}s)") - print(f"⏳ 等待 Gemini 回复 (超时 {wait_timeout} 秒)...") + print(f"⏳ Waiting for Gemini reply (timeout {wait_timeout}s)...") message, new_state = self.log_reader.wait_for_message(state, float(wait_timeout)) session_path = (new_state or {}).get("session_path") if isinstance(new_state, dict) else None if isinstance(session_path, Path): self._remember_gemini_session(session_path) if message: - print("🤖 Gemini 回复:") + print(f"🤖 {t('reply_from', provider='Gemini')}") print(message) return message - print("⏰ Gemini 未在限定时间内回复,可稍后执行 gpend 获取答案") + print(f"⏰ {t('timeout_no_reply', provider='Gemini')}") return None except Exception as exc: - print(f"❌ 同步询问失败: {exc}") + print(f"❌ Sync ask failed: {exc}") return None def consume_pending(self, display: bool = True): @@ -499,7 +638,7 @@ def consume_pending(self, display: bool = True): message = self.log_reader.latest_message() if not message: if display: - print("暂无 Gemini 回复") + print(t('no_reply_available', provider='Gemini')) return None if display: print(message) @@ -551,7 +690,16 @@ def _remember_gemini_session(self, session_path: Path) -> None: with tmp_file.open("w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) os.replace(tmp_file, project_file) - except Exception: + except PermissionError as e: + print(f"⚠️ Cannot update {project_file.name}: {e}", file=sys.stderr) + print(f"💡 Try: sudo chown $USER:$USER {project_file}", file=sys.stderr) + try: + if tmp_file.exists(): + tmp_file.unlink(missing_ok=True) + except Exception: + pass + except Exception as e: + print(f"⚠️ Failed to update {project_file.name}: {e}", file=sys.stderr) try: if tmp_file.exists(): tmp_file.unlink(missing_ok=True) @@ -560,7 +708,7 @@ def _remember_gemini_session(self, session_path: Path) -> None: def ping(self, display: bool = True) -> Tuple[bool, str]: healthy, status = self._check_session_health() - msg = f"✅ Gemini 连接正常 ({status})" if healthy else f"❌ Gemini 连接异常: {status}" + msg = f"✅ Gemini connection OK ({status})" if healthy else f"❌ Gemini connection error: {status}" if display: print(msg) return healthy, msg @@ -580,13 +728,13 @@ def get_status(self) -> Dict[str, Any]: def main() -> int: import argparse - parser = argparse.ArgumentParser(description="Gemini 通信工具") - parser.add_argument("question", nargs="*", help="要发送的问题") - parser.add_argument("--wait", "-w", action="store_true", help="同步等待回复") - parser.add_argument("--timeout", type=int, default=60, help="同步超时时间(秒)") - parser.add_argument("--ping", action="store_true", help="测试连通性") - parser.add_argument("--status", action="store_true", help="查看状态") - parser.add_argument("--pending", action="store_true", help="查看待处理回复") + parser = argparse.ArgumentParser(description="Gemini communication tool") + parser.add_argument("question", nargs="*", help="Question to send") + parser.add_argument("--wait", "-w", action="store_true", help="Wait for reply synchronously") + parser.add_argument("--timeout", type=int, default=60, help="Sync timeout in seconds") + parser.add_argument("--ping", action="store_true", help="Test connectivity") + parser.add_argument("--status", action="store_true", help="View status") + parser.add_argument("--pending", action="store_true", help="View pending reply") args = parser.parse_args() @@ -597,7 +745,7 @@ def main() -> int: comm.ping() elif args.status: status = comm.get_status() - print("📊 Gemini 状态:") + print("📊 Gemini status:") for key, value in status.items(): print(f" {key}: {value}") elif args.pending: @@ -605,18 +753,18 @@ def main() -> int: elif args.question: question_text = " ".join(args.question).strip() if not question_text: - print("❌ 请提供问题内容") + print("❌ Please provide a question") return 1 if args.wait: comm.ask_sync(question_text, args.timeout) else: comm.ask_async(question_text) else: - print("请提供问题或使用 --ping/--status/--pending 选项") + print("Please provide a question or use --ping/--status/--pending") return 1 return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ Execution failed: {exc}") return 1 diff --git a/lib/i18n.py b/lib/i18n.py new file mode 100644 index 0000000..9d5332b --- /dev/null +++ b/lib/i18n.py @@ -0,0 +1,245 @@ +""" +i18n - Internationalization support for CCB + +Language detection priority: +1. CCB_LANG environment variable (zh/en/auto) +2. System locale (LANG/LC_ALL/LC_MESSAGES) +3. Default to English +""" + +import os +import locale + +_current_lang = None + +MESSAGES = { + "en": { + # Terminal detection + "no_terminal_backend": "No terminal backend detected (WezTerm or tmux)", + "solutions": "Solutions:", + "install_wezterm": "Install WezTerm (recommended): https://wezfurlong.org/wezterm/", + "or_install_tmux": "Or install tmux", + "or_set_ccb_terminal": "Or set CCB_TERMINAL=wezterm and configure CODEX_WEZTERM_BIN", + "tmux_not_installed": "tmux not installed and WezTerm unavailable", + "install_wezterm_or_tmux": "Solution: Install WezTerm (recommended) or tmux", + + # Startup messages + "starting_backend": "Starting {provider} backend ({terminal})...", + "started_backend": "{provider} started ({terminal}: {pane_id})", + "unknown_provider": "Unknown provider: {provider}", + "resuming_session": "Resuming {provider} session: {session_id}...", + "no_history_fresh": "No {provider} history found, starting fresh", + "warmup": "Warmup: {script}", + "warmup_failed": "Warmup failed: {provider}", + + # Claude + "starting_claude": "Starting Claude...", + "resuming_claude": "Resuming Claude session: {session_id}...", + "no_claude_session": "No local Claude session found, starting fresh", + "session_id": "Session ID: {session_id}", + "runtime_dir": "Runtime dir: {runtime_dir}", + "active_backends": "Active backends: {backends}", + "available_commands": "Available commands:", + "codex_commands": "cask/cask-w/cping/cpend - Codex communication", + "gemini_commands": "gask/gask-w/gping/gpend - Gemini communication", + "executing": "Executing: {cmd}", + "user_interrupted": "User interrupted", + "cleaning_up": "Cleaning up session resources...", + "cleanup_complete": "Cleanup complete", + + # Banner + "banner_title": "Claude Code Bridge {version}", + "banner_date": "{date}", + "banner_backends": "Backends: {backends}", + + # No-claude mode + "backends_started_no_claude": "Backends started (--no-claude mode)", + "switch_to_pane": "Switch to pane:", + "kill_hint": "Kill: ccb kill {providers}", + + # Status + "backend_status": "AI backend status:", + + # Errors + "cannot_write_session": "Cannot write {filename}: {reason}", + "fix_hint": "Fix: {fix}", + "error": "Error", + "execution_failed": "Execution failed: {error}", + "import_failed": "Import failed: {error}", + "module_import_failed": "Module import failed: {error}", + + # Connectivity + "connectivity_test_failed": "{provider} connectivity test failed: {error}", + "no_reply_available": "No {provider} reply available", + + # Commands + "usage": "Usage: {cmd}", + "sending_to": "Sending question to {provider}...", + "waiting_for_reply": "Waiting for {provider} reply (no timeout, Ctrl-C to interrupt)...", + "reply_from": "{provider} reply:", + "timeout_no_reply": "Timeout: no reply from {provider}", + "session_not_found": "No active {provider} session found", + + # Install messages + "install_complete": "Installation complete", + "uninstall_complete": "Uninstall complete", + "python_version_old": "Python version too old: {version}", + "requires_python": "Requires Python 3.10+", + "missing_dependency": "Missing dependency: {dep}", + "detected_env": "Detected {env} environment", + "wsl_not_supported": "WSL 1 does not support FIFO pipes, please upgrade to WSL 2", + "confirm_continue": "Confirm continue? (y/N)", + "cancelled": "Cancelled", + }, + "zh": { + # Terminal detection + "no_terminal_backend": "未检测到终端后端 (WezTerm 或 tmux)", + "solutions": "解决方案:", + "install_wezterm": "安装 WezTerm (推荐): https://wezfurlong.org/wezterm/", + "or_install_tmux": "或安装 tmux", + "or_set_ccb_terminal": "或设置 CCB_TERMINAL=wezterm 并配置 CODEX_WEZTERM_BIN", + "tmux_not_installed": "tmux 未安装且 WezTerm 不可用", + "install_wezterm_or_tmux": "解决方案:安装 WezTerm (推荐) 或 tmux", + + # Startup messages + "starting_backend": "正在启动 {provider} 后端 ({terminal})...", + "started_backend": "{provider} 已启动 ({terminal}: {pane_id})", + "unknown_provider": "未知提供者: {provider}", + "resuming_session": "正在恢复 {provider} 会话: {session_id}...", + "no_history_fresh": "未找到 {provider} 历史记录,全新启动", + "warmup": "预热: {script}", + "warmup_failed": "预热失败: {provider}", + + # Claude + "starting_claude": "正在启动 Claude...", + "resuming_claude": "正在恢复 Claude 会话: {session_id}...", + "no_claude_session": "未找到本地 Claude 会话,全新启动", + "session_id": "会话 ID: {session_id}", + "runtime_dir": "运行目录: {runtime_dir}", + "active_backends": "活动后端: {backends}", + "available_commands": "可用命令:", + "codex_commands": "cask/cask-w/cping/cpend - Codex 通信", + "gemini_commands": "gask/gask-w/gping/gpend - Gemini 通信", + "executing": "执行: {cmd}", + "user_interrupted": "用户中断", + "cleaning_up": "正在清理会话资源...", + "cleanup_complete": "清理完成", + + # Banner + "banner_title": "Claude Code Bridge {version}", + "banner_date": "{date}", + "banner_backends": "后端: {backends}", + + # No-claude mode + "backends_started_no_claude": "后端已启动 (--no-claude 模式)", + "switch_to_pane": "切换到面板:", + "kill_hint": "终止: ccb kill {providers}", + + # Status + "backend_status": "AI 后端状态:", + + # Errors + "cannot_write_session": "无法写入 {filename}: {reason}", + "fix_hint": "修复: {fix}", + "error": "错误", + "execution_failed": "执行失败: {error}", + "import_failed": "导入失败: {error}", + "module_import_failed": "模块导入失败: {error}", + + # Connectivity + "connectivity_test_failed": "{provider} 连通性测试失败: {error}", + "no_reply_available": "暂无 {provider} 回复", + + # Commands + "usage": "用法: {cmd}", + "sending_to": "正在发送问题到 {provider}...", + "waiting_for_reply": "等待 {provider} 回复 (无超时,Ctrl-C 中断)...", + "reply_from": "{provider} 回复:", + "timeout_no_reply": "超时: 未收到 {provider} 回复", + "session_not_found": "未找到活动的 {provider} 会话", + + # Install messages + "install_complete": "安装完成", + "uninstall_complete": "卸载完成", + "python_version_old": "Python 版本过旧: {version}", + "requires_python": "需要 Python 3.10+", + "missing_dependency": "缺少依赖: {dep}", + "detected_env": "检测到 {env} 环境", + "wsl_not_supported": "WSL 1 不支持 FIFO 管道,请升级到 WSL 2", + "confirm_continue": "确认继续?(y/N)", + "cancelled": "已取消", + }, +} + + +def detect_language() -> str: + """Detect language from environment. + + Priority: + 1. CCB_LANG environment variable (zh/en/auto) + 2. System locale + 3. Default to English + """ + ccb_lang = os.environ.get("CCB_LANG", "auto").lower() + + if ccb_lang in ("zh", "cn", "chinese"): + return "zh" + if ccb_lang in ("en", "english"): + return "en" + + # Auto-detect from system locale + try: + lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "") or os.environ.get("LC_MESSAGES", "") + if not lang: + lang, _ = locale.getdefaultlocale() + lang = lang or "" + + lang = lang.lower() + if lang.startswith("zh") or "chinese" in lang: + return "zh" + except Exception: + pass + + return "en" + + +def get_lang() -> str: + """Get current language setting.""" + global _current_lang + if _current_lang is None: + _current_lang = detect_language() + return _current_lang + + +def set_lang(lang: str) -> None: + """Set language explicitly.""" + global _current_lang + if lang in ("zh", "en"): + _current_lang = lang + + +def t(key: str, **kwargs) -> str: + """Get translated message by key. + + Args: + key: Message key + **kwargs: Format arguments + + Returns: + Translated and formatted message + """ + lang = get_lang() + messages = MESSAGES.get(lang, MESSAGES["en"]) + + msg = messages.get(key) + if msg is None: + # Fallback to English + msg = MESSAGES["en"].get(key, key) + + if kwargs: + try: + msg = msg.format(**kwargs) + except (KeyError, ValueError): + pass + + return msg diff --git a/lib/session_utils.py b/lib/session_utils.py new file mode 100644 index 0000000..3b3b3a7 --- /dev/null +++ b/lib/session_utils.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +session_utils.py - Session file permission check utility +""" +from __future__ import annotations +import os +import stat +from pathlib import Path +from typing import Tuple, Optional + + +def check_session_writable(session_file: Path) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Check if session file is writable + + Returns: + (writable, error_reason, fix_suggestion) + """ + session_file = Path(session_file) + parent = session_file.parent + + # 1. Check if parent directory exists and is accessible + if not parent.exists(): + return False, f"Directory not found: {parent}", f"mkdir -p {parent}" + + if not os.access(parent, os.X_OK): + return False, f"Directory not accessible (missing x permission): {parent}", f"chmod +x {parent}" + + # 2. Check if parent directory is writable + if not os.access(parent, os.W_OK): + return False, f"Directory not writable: {parent}", f"chmod u+w {parent}" + + # 3. If file doesn't exist, directory writable is enough + if not session_file.exists(): + return True, None, None + + # 4. Check if it's a regular file + if session_file.is_symlink(): + target = session_file.resolve() + return False, f"Is symlink pointing to {target}", f"rm -f {session_file}" + + if session_file.is_dir(): + return False, "Is directory, not file", f"rmdir {session_file} or rm -rf {session_file}" + + if not session_file.is_file(): + return False, "Not a regular file", f"rm -f {session_file}" + + # 5. Check file ownership + try: + file_stat = session_file.stat() + file_uid = file_stat.st_uid + current_uid = os.getuid() + + if file_uid != current_uid: + import pwd + try: + owner_name = pwd.getpwuid(file_uid).pw_name + except KeyError: + owner_name = str(file_uid) + current_name = pwd.getpwuid(current_uid).pw_name + return False, f"File owned by {owner_name} (current user: {current_name})", \ + f"sudo chown {current_name}:{current_name} {session_file}" + except Exception: + pass + + # 6. Check if file is writable + if not os.access(session_file, os.W_OK): + mode = stat.filemode(session_file.stat().st_mode) + return False, f"File not writable (mode: {mode})", f"chmod u+w {session_file}" + + return True, None, None + + +def safe_write_session(session_file: Path, content: str) -> Tuple[bool, Optional[str]]: + """ + Safely write session file, return friendly error on failure + + Returns: + (success, error_message) + """ + session_file = Path(session_file) + + # Pre-check + writable, reason, fix = check_session_writable(session_file) + if not writable: + return False, f"❌ Cannot write {session_file.name}: {reason}\n💡 Fix: {fix}" + + # Attempt atomic write + tmp_file = session_file.with_suffix(".tmp") + try: + tmp_file.write_text(content, encoding="utf-8") + os.replace(tmp_file, session_file) + return True, None + except PermissionError as e: + if tmp_file.exists(): + try: + tmp_file.unlink() + except Exception: + pass + return False, f"❌ Cannot write {session_file.name}: {e}\n💡 Try: rm -f {session_file} then retry" + except Exception as e: + if tmp_file.exists(): + try: + tmp_file.unlink() + except Exception: + pass + return False, f"❌ Write failed: {e}" + + +def print_session_error(msg: str, to_stderr: bool = True) -> None: + """Output session-related error""" + import sys + output = sys.stderr if to_stderr else sys.stdout + print(msg, file=output) diff --git a/lib/terminal.py b/lib/terminal.py index 5068d6d..4e49ec4 100644 --- a/lib/terminal.py +++ b/lib/terminal.py @@ -13,6 +13,17 @@ from typing import Optional +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return max(0.0, value) + + def is_windows() -> bool: return platform.system() == "Windows" @@ -25,7 +36,7 @@ def is_wsl() -> bool: def _load_cached_wezterm_bin() -> str | None: - """读取安装时缓存的 WezTerm 路径""" + """Load cached WezTerm path from installation""" config = Path.home() / ".config/ccb/env" if config.exists(): try: @@ -43,11 +54,11 @@ def _load_cached_wezterm_bin() -> str | None: def _get_wezterm_bin() -> str | None: - """获取 WezTerm 路径(带缓存)""" + """Get WezTerm path (with cache)""" global _cached_wezterm_bin if _cached_wezterm_bin: return _cached_wezterm_bin - # 优先级: 环境变量 > 安装缓存 > PATH > 硬编码路径 + # Priority: env var > install cache > PATH > hardcoded paths override = os.environ.get("CODEX_WEZTERM_BIN") or os.environ.get("WEZTERM_BIN") if override and Path(override).exists(): _cached_wezterm_bin = override @@ -71,7 +82,7 @@ def _get_wezterm_bin() -> str | None: def _is_windows_wezterm() -> bool: - """检测 WezTerm 是否运行在 Windows 上""" + """Detect if WezTerm is running on Windows""" override = os.environ.get("CODEX_WEZTERM_BIN") or os.environ.get("WEZTERM_BIN") if override: if ".exe" in override.lower() or "/mnt/" in override: @@ -99,6 +110,8 @@ def _default_shell() -> tuple[str, str]: def get_shell_type() -> str: + if is_windows() and os.environ.get("CCB_BACKEND_ENV", "").lower() == "wsl": + return "bash" shell, _ = _default_shell() if shell in ("pwsh", "powershell"): return "powershell" @@ -130,12 +143,16 @@ def send_text(self, session: str, text: str) -> None: return buffer_name = f"tb-{os.getpid()}-{int(time.time() * 1000)}" - encoded = (sanitized + "\n").encode("utf-8") + encoded = sanitized.encode("utf-8") subprocess.run(["tmux", "load-buffer", "-b", buffer_name, "-"], input=encoded, check=True) - subprocess.run(["tmux", "paste-buffer", "-t", session, "-b", buffer_name, "-p"], check=True) - time.sleep(0.02) - subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True) - subprocess.run(["tmux", "delete-buffer", "-b", buffer_name], stderr=subprocess.DEVNULL) + try: + subprocess.run(["tmux", "paste-buffer", "-t", session, "-b", buffer_name, "-p"], check=True) + enter_delay = _env_float("CCB_TMUX_ENTER_DELAY", 0.0) + if enter_delay: + time.sleep(enter_delay) + subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True) + finally: + subprocess.run(["tmux", "delete-buffer", "-b", buffer_name], stderr=subprocess.DEVNULL) def is_alive(self, session: str) -> bool: result = subprocess.run(["tmux", "has-session", "-t", session], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -154,7 +171,7 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int class Iterm2Backend(TerminalBackend): - """iTerm2 后端,使用 it2 CLI (pip install it2)""" + """iTerm2 backend, using it2 CLI (pip install it2)""" _it2_bin: Optional[str] = None @classmethod @@ -172,15 +189,15 @@ def send_text(self, session_id: str, text: str) -> None: sanitized = text.replace("\r", "").strip() if not sanitized: return - # 类似 WezTerm 的方式:先发送文本,再发送回车 - # it2 session send 发送文本(不带换行) + # Similar to WezTerm: send text first, then send Enter + # it2 session send sends text (without newline) subprocess.run( [self._bin(), "session", "send", sanitized, "--session", session_id], check=True, ) - # 等待一点时间,让 TUI 处理输入 + # Wait a bit for TUI to process input time.sleep(0.01) - # 发送回车键(使用 \r) + # Send Enter key (using \r) subprocess.run( [self._bin(), "session", "send", "\r", "--session", session_id], check=True, @@ -190,7 +207,7 @@ def is_alive(self, session_id: str) -> bool: try: result = subprocess.run( [self._bin(), "session", "list", "--json"], - capture_output=True, text=True + capture_output=True, text=True, encoding='utf-8', errors='replace' ) if result.returncode != 0: return False @@ -209,29 +226,29 @@ def activate(self, session_id: str) -> None: subprocess.run([self._bin(), "session", "focus", session_id]) def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int = 50, parent_pane: Optional[str] = None) -> str: - # iTerm2 分屏:vertical 对应 right,horizontal 对应 bottom + # iTerm2 split: vertical corresponds to right, horizontal to bottom args = [self._bin(), "session", "split"] if direction == "right": args.append("--vertical") - # 如果有 parent_pane,指定目标 session + # If parent_pane specified, target that session if parent_pane: args.extend(["--session", parent_pane]) - result = subprocess.run(args, capture_output=True, text=True, check=True) - # it2 输出格式: "Created new pane: " + result = subprocess.run(args, capture_output=True, text=True, encoding='utf-8', errors='replace', check=True) + # it2 output format: "Created new pane: " output = result.stdout.strip() if ":" in output: new_session_id = output.split(":")[-1].strip() else: - # 尝试从 stderr 或其他地方获取 + # Try to get from stderr or elsewhere new_session_id = output - # 在新 pane 中执行启动命令 + # Execute startup command in new pane if new_session_id and cmd: - # 先 cd 到工作目录,再执行命令 + # First cd to work directory, then execute command full_cmd = f"cd {shlex.quote(cwd)} && {cmd}" - time.sleep(0.2) # 等待 pane 就绪 - # 使用 send + 回车的方式,与 send_text 保持一致 + time.sleep(0.2) # Wait for pane ready + # Use send + Enter, consistent with send_text subprocess.run( [self._bin(), "session", "send", full_cmd, "--session", new_session_id], check=True @@ -269,34 +286,34 @@ def _bin(cls) -> str: return cls._wezterm_bin def send_text(self, pane_id: str, text: str) -> None: - sanitized = text.replace("\r", "").strip() + sanitized = text.replace("\r", "").replace("\n", "").strip() if not sanitized: return - # tmux 可单独发 Enter 键;wezterm cli 没有 send-key,只能用 send-text 发送控制字符。 - # 经验上,很多交互式 CLI 在“粘贴/多行输入”里不会自动执行;这里将文本和 Enter 分两次发送更可靠。 subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], - input=sanitized.encode("utf-8"), + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste", sanitized], check=True, ) - # 给 TUI 一点时间退出“粘贴/突发输入”路径,再发送 Enter 更像真实按键 - time.sleep(0.01) - try: - subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], - input=b"\r", - check=True, - ) - except subprocess.CalledProcessError: - subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], - input=b"\n", - check=True, - ) + enter_delay = _env_float("CCB_WEZTERM_ENTER_DELAY", 0.01) + if enter_delay: + time.sleep(enter_delay) + for char in ["\r", "\n", "\r\n"]: + try: + subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste", char], + check=True, + ) + return + except subprocess.CalledProcessError: + continue + subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], + input=b"\r", + check=False, + ) def is_alive(self, pane_id: str) -> bool: try: - result = subprocess.run([*self._cli_base_args(), "list", "--format", "json"], capture_output=True, text=True) + result = subprocess.run([*self._cli_base_args(), "list", "--format", "json"], capture_output=True, text=True, encoding='utf-8', errors='replace') if result.returncode != 0: return False panes = json.loads(result.stdout) @@ -312,7 +329,9 @@ def activate(self, pane_id: str) -> None: def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int = 50, parent_pane: Optional[str] = None) -> str: args = [*self._cli_base_args(), "split-pane"] - if is_wsl() and _is_windows_wezterm(): + force_wsl = os.environ.get("CCB_BACKEND_ENV", "").lower() == "wsl" + use_wsl_launch = (is_wsl() and _is_windows_wezterm()) or (force_wsl and is_windows()) + if use_wsl_launch: in_wsl_pane = bool(os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP")) wsl_cwd = cwd wsl_localhost_match = re.match(r'^[/\\]{1,2}wsl\.localhost[/\\][^/\\]+(.+)$', cwd, re.IGNORECASE) @@ -320,7 +339,8 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int wsl_cwd = wsl_localhost_match.group(1).replace('\\', '/') elif "\\" in cwd or (len(cwd) > 2 and cwd[1] == ":"): try: - result = subprocess.run(["wslpath", "-a", cwd], capture_output=True, text=True, check=True) + wslpath_cmd = ["wslpath", "-a", cwd] if is_wsl() else ["wsl.exe", "wslpath", "-a", cwd] + result = subprocess.run(wslpath_cmd, capture_output=True, text=True, check=True) wsl_cwd = result.stdout.strip() except Exception: pass @@ -347,28 +367,31 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int args.extend(["--pane-id", parent_pane]) shell, flag = _default_shell() args.extend(["--", shell, flag, cmd]) - result = subprocess.run(args, capture_output=True, text=True, check=True) - return result.stdout.strip() + try: + result = subprocess.run(args, capture_output=True, text=True, encoding='utf-8', errors='replace', check=True) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"WezTerm split-pane failed:\nCommand: {' '.join(args)}\nStderr: {e.stderr}") from e _backend_cache: Optional[TerminalBackend] = None def detect_terminal() -> Optional[str]: - # 优先检测当前环境变量(已在某终端中运行) + # Priority: check current env vars (already running in a terminal) if os.environ.get("WEZTERM_PANE"): return "wezterm" if os.environ.get("ITERM_SESSION_ID"): return "iterm2" if os.environ.get("TMUX"): return "tmux" - # 检查配置的二进制覆盖或缓存路径 + # Check configured binary override or cached path if _get_wezterm_bin(): return "wezterm" override = os.environ.get("CODEX_IT2_BIN") or os.environ.get("IT2_BIN") if override and Path(override).expanduser().exists(): return "iterm2" - # 检查可用的终端工具 + # Check available terminal tools if shutil.which("it2"): return "iterm2" if shutil.which("tmux") or shutil.which("tmux.exe"):