diff --git a/README.md b/README.md index 942d027..c5cc265 100644 --- a/README.md +++ b/README.md @@ -453,13 +453,15 @@ Check distro name with `wsl -l -v` in PowerShell. If `ccb`, `cask`, `cping` commands are not found after running `./install.sh install`: -**Cause:** The install directory (`~/.local/bin`) is not in your PATH. +**Cause:** The install directory (`$CODEX_BIN_DIR` or `$CCB_BIN_DIR`, default `~/.local/bin`) is not in your PATH. + +**Note:** Runtime helpers also honor `CCB_BIN_DIR` as an alias for `CODEX_BIN_DIR`. **Solution:** ```bash # 1. Check if install directory exists -ls -la ~/.local/bin/ +ls -la "${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}/" # 2. Check if PATH includes the directory echo $PATH | tr ':' '\n' | grep local @@ -468,7 +470,7 @@ echo $PATH | tr ':' '\n' | grep local cat ~/.zshrc | grep local # 4. If not configured, add manually -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +echo 'export PATH="${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}:$PATH"' >> ~/.zshrc # 5. Reload config source ~/.zshrc @@ -482,7 +484,7 @@ If WezTerm cannot find ccb commands but regular Terminal can: - Add PATH to `~/.zprofile` as well: ```bash -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zprofile +echo 'export PATH="${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}:$PATH"' >> ~/.zprofile ``` Then restart WezTerm completely (Cmd+Q, reopen). diff --git a/README_zh.md b/README_zh.md index 631b1d2..79f3b44 100644 --- a/README_zh.md +++ b/README_zh.md @@ -474,13 +474,15 @@ cping 如果运行 `./install.sh install` 后找不到 `ccb`、`cask`、`cping` 等命令: -**原因:** 安装目录 (`~/.local/bin`) 不在 PATH 中。 +**原因:** 安装目录(`$CODEX_BIN_DIR` 或 `$CCB_BIN_DIR`,默认 `~/.local/bin`)不在 PATH 中。 + +**提示:** 运行时会将 `CCB_BIN_DIR` 视为 `CODEX_BIN_DIR` 的别名。 **解决方法:** ```bash # 1. 检查安装目录是否存在 -ls -la ~/.local/bin/ +ls -la "${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}/" # 2. 检查 PATH 是否包含该目录 echo $PATH | tr ':' '\n' | grep local @@ -489,7 +491,7 @@ echo $PATH | tr ':' '\n' | grep local cat ~/.zshrc | grep local # 4. 如果没有配置,手动添加 -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +echo 'export PATH="${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}:$PATH"' >> ~/.zshrc # 5. 重新加载配置 source ~/.zshrc @@ -503,7 +505,7 @@ source ~/.zshrc - 同时添加 PATH 到 `~/.zprofile`: ```bash -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zprofile +echo 'export PATH="${CODEX_BIN_DIR:-${CCB_BIN_DIR:-$HOME/.local/bin}}:$PATH"' >> ~/.zprofile ``` 然后完全重启 WezTerm(Cmd+Q 退出后重新打开)。 diff --git a/bin/ccb-completion-hook b/bin/ccb-completion-hook index 5b17d4b..5ea0196 100755 --- a/bin/ccb-completion-hook +++ b/bin/ccb-completion-hook @@ -24,6 +24,7 @@ import argparse import json import os import subprocess +import shutil import sys from pathlib import Path @@ -54,6 +55,43 @@ def env_float(name: str, default: float) -> float: return default +def _iter_env_bin_dirs() -> list[Path]: + bin_dirs: list[Path] = [] + for name in ("CODEX_BIN_DIR", "CCB_BIN_DIR"): + value = (os.environ.get(name) or "").strip() + if value: + bin_dirs.append(Path(value).expanduser()) + return bin_dirs + + +def _build_ask_notify_cmd(ask_cmd: str, caller: str, message: str) -> list[str]: + args = [caller, "--notify", "--no-wrap", message] + if os.name != "nt": + return [ask_cmd, *args] + + suffix = Path(ask_cmd).suffix.lower() + if suffix in (".cmd", ".bat"): + return ["cmd.exe", "/c", ask_cmd, *args] + if suffix == ".ps1": + return ["powershell", "-ExecutionPolicy", "Bypass", "-File", ask_cmd, *args] + if suffix in ("", ".py"): + return [sys.executable, ask_cmd, *args] + return [ask_cmd, *args] + + +def _run_ask_notify(ask_cmd: str, caller: str, message: str, timeout: float) -> int: + try: + result = subprocess.run( + _build_ask_notify_cmd(ask_cmd, caller, message), + capture_output=True, + text=True, + timeout=timeout, + ) + return result.returncode + except Exception: + return 0 + + def load_session_file(session_path: Path) -> dict: """Load session file with utf-8-sig encoding (handles BOM from PowerShell).""" try: @@ -187,15 +225,32 @@ def send_via_tmux(pane_id: str, message: str) -> bool: def find_ask_command() -> str | None: """Find the ask command in common locations.""" ask_paths = [ + # Prefer the sibling command in the same installed bin folder. Path(__file__).resolve().parent / "ask", - Path.home() / ".local" / "share" / "codex-dual" / "bin" / "ask", - Path.home() / ".local" / "bin" / "ask", ] - # On Windows, also check LOCALAPPDATA + + for bin_dir in _iter_env_bin_dirs(): + ask_paths.append(bin_dir / "ask") + + install_prefix = (os.environ.get("CODEX_INSTALL_PREFIX") or "").strip() + if install_prefix: + ask_paths.append(Path(install_prefix).expanduser() / "bin" / "ask") + + # Windows default install location if os.name == "nt": localappdata = os.environ.get("LOCALAPPDATA", "") if localappdata: - ask_paths.insert(0, Path(localappdata) / "codex-dual" / "bin" / "ask") + ask_paths.append(Path(localappdata) / "codex-dual" / "bin" / "ask") + + # Legacy/default locations + ask_paths.extend([ + Path.home() / ".local" / "share" / "codex-dual" / "bin" / "ask", + Path.home() / ".local" / "bin" / "ask", + ]) + + found = shutil.which("ask") + if found: + ask_paths.append(Path(found)) for p in ask_paths: if p.exists(): @@ -365,7 +420,10 @@ Result: {reply_content} session_filename = session_files.get(caller, ".claude-session") # Search for session file in multiple locations (order matters - most specific first) - work_dir = os.environ.get("CCB_WORK_DIR", "") + work_dir = os.environ.get("CCB_WORK_DIR", "").strip() + if not work_dir: + work_dir = os.getcwd() + search_paths = [] # 1. Request's work_dir (most specific) @@ -374,13 +432,18 @@ Result: {reply_content} # 2. Current working directory (fallback) cwd = os.getcwd() - if cwd != work_dir: + if cwd and cwd != work_dir: search_paths.append(Path(cwd) / ".ccb_config" / session_filename) - # 3. User's home-based locations + # 3. Explicit install prefix (optional) + install_prefix = (os.environ.get("CODEX_INSTALL_PREFIX") or "").strip() + if install_prefix: + search_paths.append(Path(install_prefix).expanduser() / ".ccb_config" / session_filename) + + # 4. Legacy/default install location search_paths.append(Path.home() / ".local" / "share" / "codex-dual" / ".ccb_config" / session_filename) - # 4. On Windows, also check LOCALAPPDATA + # 5. On Windows, also check LOCALAPPDATA if os.name == "nt": localappdata = os.environ.get("LOCALAPPDATA", "") if localappdata: @@ -412,25 +475,7 @@ Result: {reply_content} ask_cmd = find_ask_command() if not ask_cmd: return 0 - try: - # Use sys.executable to run ask command on Windows - if os.name == "nt": - result = subprocess.run( - [sys.executable, ask_cmd, caller, "--notify", "--no-wrap", message], - capture_output=True, - text=True, - timeout=timeout - ) - else: - result = subprocess.run( - [ask_cmd, caller, "--notify", "--no-wrap", message], - capture_output=True, - text=True, - timeout=timeout - ) - return result.returncode - except Exception: - return 0 + return _run_ask_notify(ask_cmd, caller, message, timeout) # Send directly via terminal backend if send_via_terminal(pane_id, message, terminal, session_data): @@ -441,23 +486,7 @@ Result: {reply_content} ask_cmd = find_ask_command() if not ask_cmd: return 0 - try: - if os.name == "nt": - subprocess.run( - [sys.executable, ask_cmd, caller, "--notify", "--no-wrap", message], - capture_output=True, - text=True, - timeout=timeout - ) - else: - subprocess.run( - [ask_cmd, caller, "--notify", "--no-wrap", message], - capture_output=True, - text=True, - timeout=timeout - ) - except Exception: - pass + _run_ask_notify(ask_cmd, caller, message, timeout) return 0 diff --git a/ccb b/ccb index b5b7afa..b78d070 100755 --- a/ccb +++ b/ccb @@ -53,6 +53,55 @@ _MNT_DRIVE_RE = re.compile(r"^/mnt/([A-Za-z])/(.*)$") _MSYS_DRIVE_RE = re.compile(r"^/([A-Za-z])/(.*)$") +def _get_bin_dir() -> Path: + """Best-effort resolve the bin dir where CCB helper scripts live. + + Priority: + 1) CODEX_BIN_DIR / CCB_BIN_DIR env + 2) directory of the invoked executable on PATH (shutil.which) + 3) default ~/.local/bin + """ + env_bin = (os.environ.get("CODEX_BIN_DIR") or os.environ.get("CCB_BIN_DIR") or "").strip() + if env_bin: + return Path(env_bin).expanduser() + + argv0 = (sys.argv[0] or "").strip() + candidates: list[Path] = [] + if argv0: + # If invoked via an explicit path, keep its directory (do NOT resolve symlinks). + if ("/" in argv0) or ("\\" in argv0): + candidates.append(Path(argv0).expanduser()) + found = shutil.which(argv0) + if found: + candidates.append(Path(found)) + + found_ccb = shutil.which("ccb") + if found_ccb: + candidates.append(Path(found_ccb)) + + for p in candidates: + try: + if p.exists(): + return p.parent + except Exception: + continue + + return Path.home() / ".local" / "bin" + + +def _find_helper_script(name: str) -> Path | None: + """Locate an installed helper script by name.""" + found = shutil.which(name) + if found: + return Path(found) + + for base in (_get_bin_dir(), script_dir / "config"): + p = base / name + if p.exists(): + return p + return None + + def _looks_like_windows_path(value: str) -> bool: s = value.strip() if not s: @@ -901,14 +950,15 @@ class AILauncher: Enable/disable CCB tmux UI theming for the *current tmux session*. This is session-scoped and reversible (saves/restores user options) via helper scripts - installed to `~/.local/bin/`. + installed to BIN_DIR (see env `CODEX_BIN_DIR`). """ if self.terminal_type != "tmux": return if not os.environ.get("TMUX"): return - script = Path.home() / ".local" / "bin" / ("ccb-tmux-on.sh" if active else "ccb-tmux-off.sh") - if not script.exists(): + script_name = "ccb-tmux-on.sh" if active else "ccb-tmux-off.sh" + script = _find_helper_script(script_name) + if not script: return try: debug = os.environ.get("CCB_DEBUG") in ("1", "true", "yes") @@ -3847,9 +3897,13 @@ def _detect_cca() -> tuple[str | None, str | None]: install_dir = _infer_install_dir_from_exe(exe) return str(exe.resolve()), str(install_dir) candidates = [ - Path.home() / ".local/share/claude_code_autoflow", - Path.home() / ".local/bin/cca", + Path.home() / ".local" / "share" / "claude_code_autoflow", ] + # If user configured a custom bin dir for tools, also consider it for legacy fallbacks. + env_bin_dir = (os.environ.get("CODEX_BIN_DIR") or os.environ.get("CCB_BIN_DIR") or "").strip() + if env_bin_dir: + candidates.append(Path(env_bin_dir).expanduser() / "cca") + candidates.append(Path.home() / ".local" / "bin" / "cca") # Windows 特定路径 if platform.system() == "Windows": localappdata = os.environ.get("LOCALAPPDATA", "") diff --git a/config/tmux-ccb.conf b/config/tmux-ccb.conf index dfaff19..b222e7b 100644 --- a/config/tmux-ccb.conf +++ b/config/tmux-ccb.conf @@ -168,6 +168,10 @@ bind M set -g mouse off \; display "Mouse OFF" bind r source-file ~/.tmux.conf \; display "Config reloaded!" +# Manually toggle CCB theme for current session (optional). +bind-key C run-shell "#{@ccb_bin_dir}/ccb-tmux-on.sh" +bind-key V run-shell "#{@ccb_bin_dir}/ccb-tmux-off.sh" + # ----------------------------------------------------------------------------- # Session Management # ----------------------------------------------------------------------------- @@ -191,9 +195,9 @@ set -g visual-activity off # NOTE: # CCB intentionally does not set any persistent statusbar/theme options here. # The CCB theme is applied per-session only while CCB is active via: -# - `~/.local/bin/ccb-tmux-on.sh` +# - `$CODEX_BIN_DIR/ccb-tmux-on.sh` (or default `~/.local/bin/ccb-tmux-on.sh`) # and restored on exit via: -# - `~/.local/bin/ccb-tmux-off.sh` +# - `$CODEX_BIN_DIR/ccb-tmux-off.sh` (or default `~/.local/bin/ccb-tmux-off.sh`) # # This avoids clobbering your existing tmux theme when CCB is not running. diff --git a/lib/completion_hook.py b/lib/completion_hook.py index 11fc997..c67fe64 100644 --- a/lib/completion_hook.py +++ b/lib/completion_hook.py @@ -9,6 +9,7 @@ import os import subprocess +import shutil import sys import threading from pathlib import Path @@ -40,17 +41,34 @@ def _run_hook_async( def _run(): try: # Find ccb-completion-hook script (Python script only, not .cmd wrapper) - script_paths = [ - Path(__file__).parent.parent / "bin" / "ccb-completion-hook", - Path.home() / ".local" / "bin" / "ccb-completion-hook", - Path("/usr/local/bin/ccb-completion-hook"), - ] + script_paths: list[Path] = [] + + for name in ("CODEX_BIN_DIR", "CCB_BIN_DIR"): + bin_dir = (os.environ.get(name) or "").strip() + if bin_dir: + script_paths.append(Path(bin_dir).expanduser() / "ccb-completion-hook") + + install_prefix = (os.environ.get("CODEX_INSTALL_PREFIX") or "").strip() + if install_prefix: + script_paths.append(Path(install_prefix).expanduser() / "bin" / "ccb-completion-hook") + + script_paths.append(Path(__file__).parent.parent / "bin" / "ccb-completion-hook") + # On Windows, check installed location (Python script, not .cmd) if os.name == "nt": localappdata = os.environ.get("LOCALAPPDATA", "") if localappdata: # The actual Python script is in the bin folder without extension - script_paths.insert(0, Path(localappdata) / "codex-dual" / "bin" / "ccb-completion-hook") + script_paths.append(Path(localappdata) / "codex-dual" / "bin" / "ccb-completion-hook") + + script_paths.extend([ + Path.home() / ".local" / "bin" / "ccb-completion-hook", + Path("/usr/local/bin/ccb-completion-hook"), + ]) + + found = shutil.which("ccb-completion-hook") + if found: + script_paths.append(Path(found)) script = None for p in script_paths: