#!/usr/bin/env bash # install.sh — one-shot installer for cowork-to-code-bridge (macOS, Linux, WSL2). # # Usage (macOS Terminal, Linux shell, or WSL Ubuntu — not PowerShell/Git Bash): # curl -fsSL https://raw.githubusercontent.com/abhinaykrupa/cowork-to-code-bridge/main/install.sh | bash # # What this does: # 1. Locates a usable Python 3.10+ interpreter (probes python3.14 → 3.10, then python3). # 2. pip-installs the cowork-to-code-bridge package (PyPI, fallback GitHub main). # 3. Creates ~/.cowork-to-code-bridge/ with queue/, results/, processed/, scripts/. # 4. Generates BRIDGE_TOKEN and writes it to ~/.cowork-to-code-bridge/.env. # 5. Installs ping.sh + hello.sh + system-info starter scripts. # 6. Installs a launchd plist with absolute Python interpreter path so the # daemon auto-starts on login and survives reboots. # 7. Bootstraps the daemon via `launchctl bootstrap` (fallback `load -w`). # 8. Verifies daemon-up heartbeat (20s window). # 9. Detects whether the user's PATH includes the per-user pip scripts dir # and offers to append it to ~/.zshrc. # 10. Prints the Cowork paste snippet. # # Re-runnable: skips already-completed steps. Regenerates BRIDGE_TOKEN if the # existing value is empty. set -euo pipefail trap 'echo "✗ Install failed at line $LINENO. Run cowork-to-code-bridge-uninstall (or: curl -fsSL https://raw.githubusercontent.com/abhinaykrupa/cowork-to-code-bridge/main/daemon/uninstall.sh | bash) to clean up partial state." >&2' ERR REPO="abhinaykrupa/cowork-to-code-bridge" BRIDGE_ROOT="$HOME/.cowork-to-code-bridge" PLIST="$HOME/Library/LaunchAgents/dev.cowork-to-code-bridge.daemon.plist" PACKAGE="cowork-to-code-bridge" PACKAGE_SPEC="cowork-to-code-bridge>=0.5.1" DAEMON_LOG="$BRIDGE_ROOT/daemon.log" DAEMON_ERR="$BRIDGE_ROOT/daemon.err" c_green() { printf "\033[0;32m%s\033[0m\n" "$1"; } c_yellow() { printf "\033[0;33m%s\033[0m\n" "$1"; } c_red() { printf "\033[0;31m%s\033[0m\n" "$1"; } step() { printf "\n\033[1;36m==> %s\033[0m\n" "$1"; } # ─── 0. Preflight: detect OS / service manager ─────────────────────────────── # macOS uses launchd; Linux uses systemd --user when available, else manual # (setsid/nohup + optional @reboot cron). WSL2 uses systemd when enabled. NO_SYSTEMD_DOC="https://github.com/abhinaykrupa/cowork-to-code-bridge/blob/main/docs/LINUX-NO-SYSTEMD.md" WSL_DOC="https://github.com/abhinaykrupa/cowork-to-code-bridge/blob/main/docs/WSL.md" _INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" || true if [[ -n "$_INSTALL_DIR" && -f "$_INSTALL_DIR/scripts/lib/platform.sh" ]]; then # shellcheck source=scripts/lib/platform.sh source "$_INSTALL_DIR/scripts/lib/platform.sh" fi if [[ -n "$_INSTALL_DIR" && -f "$_INSTALL_DIR/scripts/lib/daemon_service.sh" ]]; then # shellcheck source=scripts/lib/daemon_service.sh source "$_INSTALL_DIR/scripts/lib/daemon_service.sh" fi # Inline fallbacks when install.sh is piped (curl | bash) and lib/ is unavailable. if ! declare -F linux_service_mgr >/dev/null 2>&1; then is_wsl() { [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null } has_systemctl() { command -v systemctl >/dev/null 2>&1; } has_systemd_user_bus() { has_systemctl && systemctl --user ping >/dev/null 2>&1 } linux_service_mgr() { local forced="${BRIDGE_FORCE_SERVICE_MGR:-}" if [[ -n "$forced" ]]; then echo "$forced"; return 0; fi if has_systemd_user_bus; then echo "systemd"; return 0; fi if is_wsl; then echo "wsl_need_systemd"; return 0; fi echo "manual" } fi OS="$(uname -s)" IS_WSL=0 if declare -F is_wsl >/dev/null 2>&1 && is_wsl; then IS_WSL=1; fi PLATFORM_NOTE="" case "$OS" in Darwin) SERVICE_MGR="launchd" PLATFORM_NOTE="macOS (launchd)" ;; Linux) _linux_mgr="$(linux_service_mgr)" case "$_linux_mgr" in systemd) SERVICE_MGR="systemd" if [[ "$IS_WSL" -eq 1 ]]; then PLATFORM_NOTE="Linux (WSL2, systemd --user)" else PLATFORM_NOTE="Linux (systemd --user)" fi ;; manual) SERVICE_MGR="manual" PLATFORM_NOTE="Linux (manual daemon, no systemd)" ;; wsl_need_systemd) c_red "✗ WSL2 without systemd is not supported." echo " Enable systemd in WSL, then re-run this installer:" echo echo " 1. In WSL: sudo tee /etc/wsl.conf >/dev/null <<'EOF'" echo " [boot]" echo " systemd=true" echo " EOF" echo " 2. In Windows PowerShell: wsl --shutdown" echo " 3. Re-open your Ubuntu/WSL app and run this installer again." echo echo " Full guide: $WSL_DOC" exit 1 ;; *) c_red "✗ Unsupported Linux service manager: $_linux_mgr" exit 1 ;; esac ;; MINGW*|MSYS*|CYGWIN*) c_red "✗ Native Windows shell is not supported." echo " Run this installer inside WSL2 (Ubuntu), not PowerShell, cmd, or Git Bash." echo " Open the Ubuntu app (or: wsl), then paste the install command there." echo echo " Guide: $WSL_DOC" exit 1 ;; *) c_red "✗ Unsupported OS: $OS" echo " Supported: macOS (launchd), Linux (systemd or manual), and WSL2 with systemd." echo " Native Windows is not supported — use WSL2. Guide: $WSL_DOC" exit 1 ;; esac c_green " ✓ OS: $PLATFORM_NOTE" # ─── 1. Preflight: locate a Python 3.10+ interpreter ───────────────────────── step "Locating Python 3.10+ interpreter" PY="" PY_VER="" for candidate in python3.14 python3.13 python3.12 python3.11 python3.10 python3; do if command -v "$candidate" >/dev/null 2>&1; then cand_path="$(command -v "$candidate")" # Resolve to absolute, dereferenced path so launchd doesn't depend on PATH. if command -v readlink >/dev/null 2>&1; then resolved="$(readlink -f "$cand_path" 2>/dev/null || echo "$cand_path")" else resolved="$cand_path" fi ver=$("$resolved" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || echo "") if [[ -z "$ver" ]]; then continue; fi major=$(echo "$ver" | cut -d. -f1) minor=$(echo "$ver" | cut -d. -f2) if [[ "$major" -eq 3 ]] && [[ "$minor" -ge 10 ]]; then PY="$resolved" PY_VER="$ver" break fi fi done # Re-scan helper: look again for a usable python3.10+ after an install attempt. rescan_python() { PY=""; PY_VER="" local candidate cand_path resolved ver major minor for candidate in python3.14 python3.13 python3.12 python3.11 python3.10 python3; do command -v "$candidate" >/dev/null 2>&1 || continue cand_path="$(command -v "$candidate")" if command -v readlink >/dev/null 2>&1; then resolved="$(readlink -f "$cand_path" 2>/dev/null || echo "$cand_path")" else resolved="$cand_path" fi ver=$("$resolved" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || echo "") [[ -z "$ver" ]] && continue major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2) if [[ "$major" -eq 3 ]] && [[ "$minor" -ge 10 ]]; then PY="$resolved"; PY_VER="$ver"; return 0 fi done return 1 } # Auto-install Python 3.12 on the fly: ensure Homebrew, then brew install. # Gated by BRIDGE_PYTHON_AUTOINSTALL (default 1; set 0 to get instructions). AUTO_PY="${BRIDGE_PYTHON_AUTOINSTALL:-1}" autoinstall_python() { c_yellow " No Python 3.10+ found — attempting on-the-fly install." c_yellow " (Set BRIDGE_PYTHON_AUTOINSTALL=0 to skip this and get manual steps.)" # 1. Ensure Homebrew exists (it ships its own Python as a dep). local BREW="" for b in brew /opt/homebrew/bin/brew /usr/local/bin/brew; do command -v "$b" >/dev/null 2>&1 && { BREW="$(command -v "$b")"; break; } [[ -x "$b" ]] && { BREW="$b"; break; } done if [[ -z "$BREW" ]]; then c_yellow " Homebrew not found. Installing Homebrew first (this can take a few" c_yellow " minutes and may ask for your Mac password — that's expected)." # Official Homebrew installer. NONINTERACTIVE avoids the 'press RETURN' prompt; # it may still prompt for sudo password, which we cannot bypass safely. if ! command -v curl >/dev/null 2>&1; then c_red " ✗ curl not available — cannot install Homebrew automatically." return 1 fi NONINTERACTIVE=1 /bin/bash -c \ "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \ &2 2>&1 || { c_red " ✗ brew install python@3.12 failed."; return 1; } # Make sure the brew bin dir is on PATH so python3.12 resolves on rescan. eval "$("$BREW" shellenv)" 2>/dev/null || true hash -r 2>/dev/null || true return 0 } if [[ -z "$PY" ]]; then installed_ok=0 if [[ "$AUTO_PY" == "1" ]] && [[ "$OS" == "Darwin" ]] && autoinstall_python && rescan_python; then installed_ok=1 fi if [[ "$installed_ok" -ne 1 ]]; then c_red " ✗ No Python 3.10+ interpreter available." echo if [[ "$OS" == "Darwin" ]]; then echo " Apple's stock /usr/bin/python3 is too old (3.8) and is intentionally" echo " ignored here. Install a modern Python, then re-run this installer:" echo echo " # if you have Homebrew:" echo " brew install python@3.12" echo echo " # if you don't have Homebrew, install it first (one paste):" echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' echo " # then: brew install python@3.12" else echo " Install Python 3.10+, then re-run this installer:" echo if [[ -f /etc/os-release ]] && grep -qiE 'ubuntu|debian' /etc/os-release 2>/dev/null; then echo " # Ubuntu / Debian (typical WSL):" echo " sudo apt update && sudo apt install -y python3.12 python3.12-venv" echo " # or, if your distro ships 3.10+ as python3:" echo " sudo apt install -y python3 python3-venv" else echo " # use your distro package manager, or:" echo " https://www.python.org/downloads/" fi if [[ "$IS_WSL" -eq 1 ]]; then echo echo " WSL setup guide: $WSL_DOC" fi fi echo echo " After install, verify: command -v python3.12 || command -v python3" exit 1 fi fi export PY c_green " ✓ using $PY (Python $PY_VER)" # pip sanity if ! "$PY" -m pip --version >/dev/null 2>&1; then c_yellow " ! pip missing for $PY — bootstrapping with ensurepip" "$PY" -m ensurepip --upgrade || { c_red " ✗ ensurepip failed. Install pip manually for $PY and re-run." exit 1 } fi c_green " ✓ pip available for $PY" # ─── 2. Install package (handle PEP 668 externally-managed) ────────────────── step "Installing $PACKAGE" # pip_install_user — try --user, retry with --break-system-packages # if PEP 668 blocks. Returns non-zero only if both attempts fail. pip_install_user() { local out rc out=$("$PY" -m pip install --user --upgrade "$@" 2>&1) && rc=0 || rc=$? if [[ $rc -eq 0 ]]; then return 0 fi # PEP 668 marker: "externally-managed-environment" if echo "$out" | grep -qi "externally-managed-environment"; then c_yellow " ! PEP 668: this Python is marked externally-managed." c_yellow " Retrying with --break-system-packages (per-user install, won't touch system site-packages)." "$PY" -m pip install --user --break-system-packages --upgrade "$@" return $? fi # Some other failure — surface the original output and bubble up. echo "$out" >&2 return $rc } if pip_install_user "$PACKAGE_SPEC" 2>/dev/null; then c_green " ✓ installed from PyPI" else c_yellow " PyPI install failed (package may not be published yet) — falling back to GitHub" pip_install_user "git+https://github.com/$REPO.git@main" c_green " ✓ installed from GitHub main" fi # Resolve where pip put the console scripts (per-user "scripts" path). # Example on Mac: ~/Library/Python/3.12/bin # Python 3.10+ exposes `get_preferred_scheme('user')`; we fall back to manual # probes for older interpreters or oddly-configured frameworks (where # get_default_scheme returns e.g. 'osx_framework_library' which has no _user variant). USER_SCRIPTS_DIR=$("$PY" - <<'PYEOF' import sysconfig, sys, os # Preferred: 3.10+ API try: scheme = sysconfig.get_preferred_scheme('user') print(sysconfig.get_path('scripts', scheme)) sys.exit(0) except Exception: pass # Fallback 1: '_user' (works when the default scheme has a _user variant) try: scheme = f"{sysconfig.get_default_scheme()}_user" if scheme in sysconfig.get_scheme_names(): print(sysconfig.get_path('scripts', scheme)) sys.exit(0) except Exception: pass # Fallback 2: pick any *_user scheme that exists for s in sysconfig.get_scheme_names(): if s.endswith('_user'): print(sysconfig.get_path('scripts', s)) sys.exit(0) # Last resort: derive from site.USER_BASE import site print(os.path.join(site.getuserbase(), 'bin')) PYEOF ) if [[ -z "$USER_SCRIPTS_DIR" ]]; then c_red " ✗ could not resolve user scripts dir from $PY" exit 1 fi c_green " ✓ user scripts dir: $USER_SCRIPTS_DIR" # ─── 3. Bridge directory layout ────────────────────────────────────────────── step "Setting up $BRIDGE_ROOT" mkdir -p "$BRIDGE_ROOT"/{queue,results,processed,scripts} c_green " ✓ directories created" # ─── 4. Token ──────────────────────────────────────────────────────────────── step "Generating bridge token" ENV_FILE="$BRIDGE_ROOT/.env" existing_token="" if [[ -f "$ENV_FILE" ]] && grep -q "^BRIDGE_TOKEN=" "$ENV_FILE"; then existing_token=$(grep "^BRIDGE_TOKEN=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]') fi if [[ -n "$existing_token" ]]; then BRIDGE_TOKEN="$existing_token" c_yellow " → BRIDGE_TOKEN already set in $ENV_FILE — keeping existing token" else if [[ -f "$ENV_FILE" ]] && grep -q "^BRIDGE_TOKEN=" "$ENV_FILE"; then c_yellow " → existing BRIDGE_TOKEN line is empty — regenerating" # Strip the empty line; we'll append a fresh one. tmp="$(mktemp)" grep -v "^BRIDGE_TOKEN=" "$ENV_FILE" > "$tmp" || true mv "$tmp" "$ENV_FILE" fi BRIDGE_TOKEN=$(openssl rand -hex 16) { echo "BRIDGE_TOKEN=$BRIDGE_TOKEN" # Only add BRIDGE_ROOT if not already present. if ! [[ -f "$ENV_FILE" ]] || ! grep -q "^BRIDGE_ROOT=" "$ENV_FILE"; then echo "BRIDGE_ROOT=$BRIDGE_ROOT" fi } >> "$ENV_FILE" chmod 600 "$ENV_FILE" c_green " ✓ token generated and saved (chmod 600)" fi # ─── 5. Starter scripts ────────────────────────────────────────────────────── step "Installing starter scripts" cat > "$BRIDGE_ROOT/scripts/ping.sh" <<'PING' #!/usr/bin/env bash # ping.sh — minimal health check. Used by daemon_alive() from the client. echo "OK" echo "pwd: $(pwd)" echo "ts: $(date -u +%Y-%m-%dT%H:%M:%SZ)" PING chmod +x "$BRIDGE_ROOT/scripts/ping.sh" cat > "$BRIDGE_ROOT/scripts/hello.sh" <<'HELLO' #!/usr/bin/env bash # hello.sh — sample script you can call via the bridge. echo "hello from $(hostname) — args: $*" HELLO chmod +x "$BRIDGE_ROOT/scripts/hello.sh" # run_claude.sh — THE bridge's purpose: hand a task to Claude Code on the Mac. cat > "$BRIDGE_ROOT/scripts/run_claude.sh" <<'RUNCLAUDE' #!/usr/bin/env bash # run_claude.sh — the heart of the bridge: hand a task to Claude Code on the Mac. # # Cowork sends a free-form task; this invokes the local `claude` CLI headless so # a real Claude Code agent does the work on your Mac. Result flows back through # the bridge. # # Usage from Cowork: # call_remote("scripts/run_claude.sh", # args=["Build a Flask app with one /health route", "/path/to/project"], # timeout=600, idempotency_key="...") # # Args: # $1 the task / prompt for Claude Code (required) # $2 working directory to run Claude Code in (optional; default: $PWD) # # CLI RESOLUTION (handles "Desktop app but no CLI on PATH"): # 1. `claude` on PATH # 2. Common install locations (Homebrew, official installer, ~/.local/bin) # 3. The Claude Desktop app's bundled claude-code binary # 4. Auto-install the CLI on the fly (Homebrew cask, else official installer) # — gated by BRIDGE_CLAUDE_AUTOINSTALL (default: on). Set to 0 to disable # and get a clear "install it yourself" message instead. # 5. If all fail: exit 127 with the exact command the user should run. # # Idempotency: Claude Code tasks have side effects (edits, commits, pushes). # Always pass an idempotency_key so a retry returns the cached result instead of # running the agent twice. set -uo pipefail TASK="${1:?run_claude.sh: a task/prompt is required as the first argument}" WORKDIR="${2:-$PWD}" AUTOINSTALL="${BRIDGE_CLAUDE_AUTOINSTALL:-1}" log() { echo "run_claude.sh: $*" >&2; } # ── 1+2+3: locate an existing claude binary ────────────────────────────────── find_claude() { # PATH first local p; p="$(command -v claude 2>/dev/null || true)" if [[ -n "$p" && -x "$p" ]]; then echo "$p"; return 0; fi # Common standalone install locations local cand for cand in \ /opt/homebrew/bin/claude \ /usr/local/bin/claude \ "$HOME/.local/bin/claude" \ "$HOME/.claude/bin/claude"; do [[ -x "$cand" ]] && { echo "$cand"; return 0; } done # Claude Desktop app's bundled claude-code binary (newest version dir wins) local appdir="$HOME/Library/Application Support/Claude/claude-code" if [[ -d "$appdir" ]]; then local bundled bundled="$(find "$appdir" -maxdepth 4 -type f -name claude -perm -u+x 2>/dev/null | sort -V | tail -1)" [[ -n "$bundled" && -x "$bundled" ]] && { echo "$bundled"; return 0; } fi return 1 } # ── 4: auto-install on the fly ─────────────────────────────────────────────── install_claude() { log "claude CLI not found — attempting on-the-fly install (set BRIDGE_CLAUDE_AUTOINSTALL=0 to disable)." # Prefer Homebrew if present (cleanest, matches how it's usually installed). if command -v brew >/dev/null 2>&1; then log "installing via: brew install claude-code" if brew install claude-code >&2 2>&1; then hash -r 2>/dev/null || true return 0 fi log "brew install failed — trying official installer." fi # Fallback: official installer script. if command -v curl >/dev/null 2>&1; then log "installing via official installer (curl)" if curl -fsSL https://claude.ai/install.sh | bash >&2 2>&1; then hash -r 2>/dev/null || true # Official installer typically drops it in ~/.local/bin export PATH="$HOME/.local/bin:$PATH" return 0 fi fi return 1 } CLAUDE_BIN="$(find_claude || true)" if [[ -z "${CLAUDE_BIN:-}" ]]; then if [[ "$AUTOINSTALL" == "1" ]] && install_claude; then CLAUDE_BIN="$(find_claude || true)" fi fi if [[ -z "${CLAUDE_BIN:-}" ]]; then cat >&2 <; a lower effort is # cheaper/faster for trivial tasks, higher for hard multi-file work. An unset or # unknown value means: don't pass --effort, let the CLI pick its default. Kept in # sync with the daemon's CLAUDE_EFFORT validation set. EFFORT_FLAGS=() if [[ -n "${CLAUDE_EFFORT:-}" ]]; then EFFORT_LEVEL="$(echo "$CLAUDE_EFFORT" | tr '[:upper:]' '[:lower:]')" case "$EFFORT_LEVEL" in low|medium|high|xhigh|max) log "effort '$CLAUDE_EFFORT' → --effort $EFFORT_LEVEL" EFFORT_FLAGS=(--effort "$EFFORT_LEVEL") ;; *) log "unknown CLAUDE_EFFORT='$CLAUDE_EFFORT' — using CLI default effort" ;; esac fi exec "$CLAUDE_BIN" "${EXTRA_FLAGS[@]}" "${MODEL_FLAGS[@]}" "${EFFORT_FLAGS[@]}" "${BUDGET_FLAGS[@]}" -p "$TASK" --output-format text RUNCLAUDE chmod +x "$BRIDGE_ROOT/scripts/run_claude.sh" # ─── System-info scripts: let Cowork check the Mac directly ────────────────── # These answer "check my Mac's health / RAM / disk / processes / network" with # real data, fast, without invoking the agent. This is the thing Cowork can't # do on its own — the bridge makes it possible. cat > "$BRIDGE_ROOT/scripts/mac_health.sh" <<'MH' #!/usr/bin/env bash # mac_health.sh — full health snapshot of this machine (macOS or Linux). # # Usage: mac_health.sh [--json] # (no flag) human-readable text sections (default) # --json structured JSON — parse with json.loads() in Cowork # # JSON fields: host, os, uptime, load_1m/5m/15m, cpu_usage_pct, # memory_total_bytes, memory_free_bytes, memory_used_bytes, memory_used_pct, # disk_total_1k, disk_used_1k, disk_avail_1k, disk_used_pct, # top_procs [{pid, cpu_pct, mem_pct, name}] # # No external dependencies (no jq, no python). set -u JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done OS="$(uname -s)" # Escape a string for safe embedding in JSON (no jq required). _jstr() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\r\n'; } # ── collect ────────────────────────────────────────────────────────────────── HOST="$(hostname 2>/dev/null)" if [ "$OS" = "Darwin" ]; then OS_NAME="$(sw_vers -productName 2>/dev/null) $(sw_vers -productVersion 2>/dev/null)" else OS_NAME="$(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}" || uname -sr)" fi UPTIME_STR="$(uptime 2>/dev/null)" LOAD_1="$(echo "$UPTIME_STR" | grep -oE '[0-9]+\.[0-9]+' | sed -n '1p' || echo '')" LOAD_5="$(echo "$UPTIME_STR" | grep -oE '[0-9]+\.[0-9]+' | sed -n '2p' || echo '')" LOAD_15="$(echo "$UPTIME_STR" | grep -oE '[0-9]+\.[0-9]+' | sed -n '3p' || echo '')" CPU_USAGE="" if [ "$OS" = "Darwin" ]; then CPU_USAGE="$(top -l 1 -n 0 2>/dev/null | grep -oE '[0-9]+\.[0-9]+% user' | grep -oE '^[0-9.]+' || echo '')" fi MEM_TOTAL_BYTES=0; MEM_FREE_BYTES=0; MEM_USED_BYTES=0; MEM_USED_PCT=0 if [ "$OS" = "Darwin" ]; then MEM_TOTAL_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 0)" PAGE_SIZE="$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)" PAGES_FREE="$(vm_stat 2>/dev/null | awk '/^Pages free/{gsub(/\./,"",$3); print $3}' || echo 0)" MEM_FREE_BYTES=$(( PAGES_FREE * PAGE_SIZE )) MEM_USED_BYTES=$(( MEM_TOTAL_BYTES - MEM_FREE_BYTES )) [ "$MEM_TOTAL_BYTES" -gt 0 ] && MEM_USED_PCT=$(( MEM_USED_BYTES * 100 / MEM_TOTAL_BYTES )) elif command -v free >/dev/null 2>&1; then eval "$(free | awk '/^Mem:/{printf "MEM_TOTAL_BYTES=%d MEM_USED_BYTES=%d MEM_FREE_BYTES=%d", $2*1024, $3*1024, $4*1024}')" [ "$MEM_TOTAL_BYTES" -gt 0 ] && MEM_USED_PCT=$(( MEM_USED_BYTES * 100 / MEM_TOTAL_BYTES )) fi DISK_LINE="$(df -k / 2>/dev/null | awk 'NR==2{print}')" DISK_TOTAL="$(echo "$DISK_LINE" | awk '{print $2}')" DISK_USED="$(echo "$DISK_LINE" | awk '{print $3}')" DISK_AVAIL="$(echo "$DISK_LINE" | awk '{print $4}')" DISK_PCT="$(echo "$DISK_LINE" | awk '{gsub(/%/,"",$5); print $5}')" TOP_PROCS_RAW="$(ps -eo pid,pcpu,pmem,comm 2>/dev/null | sort -k2 -rn | awk 'NR>1&&NR<=6{print}')" # ── output ─────────────────────────────────────────────────────────────────── if [ "$JSON" -eq 1 ]; then PROCS_JSON="["; first=1 while IFS= read -r line; do [ -z "$line" ] && continue [ "$first" -eq 0 ] && PROCS_JSON="$PROCS_JSON," PROCS_JSON="$PROCS_JSON{\"pid\":$(echo "$line"|awk '{print $1}'),\"cpu_pct\":$(echo "$line"|awk '{print $2}'),\"mem_pct\":$(echo "$line"|awk '{print $3}'),\"name\":\"$(_jstr "$(echo "$line"|awk '{print $4}')")\"}" first=0 done <<< "$TOP_PROCS_RAW" PROCS_JSON="$PROCS_JSON]" cat </dev/null else (. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-$(uname -sr)}"); fi echo "=== UPTIME / LOAD ==="; echo "$UPTIME_STR" echo "=== CPU ===" if [ "$OS" = "Darwin" ]; then top -l 1 -n 0 2>/dev/null | grep -E "CPU usage" || echo n/a else grep 'cpu ' /proc/stat >/dev/null 2>&1 && echo "load: $(cut -d' ' -f1-3 /proc/loadavg)" || echo n/a; fi echo "=== MEMORY ===" if [ "$OS" = "Darwin" ]; then vm_stat 2>/dev/null | head -6 else free -h 2>/dev/null || head -3 /proc/meminfo; fi echo "=== DISK ==="; df -h / 2>/dev/null echo "=== TOP 5 PROCS BY CPU ==="; ps -eo pid,pcpu,pmem,comm 2>/dev/null | sort -k2 -rn | head -6 fi exit 0 MH cat > "$BRIDGE_ROOT/scripts/mac_ram.sh" <<'MR' #!/usr/bin/env bash # mac_ram.sh — RAM usage summary (macOS or Linux). # # Usage: mac_ram.sh [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() in Cowork # # JSON fields: total_bytes, free_bytes, used_bytes, used_pct # No external dependencies (no jq, no python). set -u JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done OS="$(uname -s)" TOTAL_BYTES=0; FREE_BYTES=0; USED_BYTES=0; USED_PCT=0 if [ "$OS" = "Darwin" ]; then TOTAL_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 0)" PAGE_SIZE="$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)" PAGES_FREE="$(vm_stat 2>/dev/null | awk '/^Pages free/{gsub(/\./,"",$3); print $3}' || echo 0)" FREE_BYTES=$(( PAGES_FREE * PAGE_SIZE )) USED_BYTES=$(( TOTAL_BYTES - FREE_BYTES )) [ "$TOTAL_BYTES" -gt 0 ] && USED_PCT=$(( USED_BYTES * 100 / TOTAL_BYTES )) elif command -v free >/dev/null 2>&1; then eval "$(free | awk '/^Mem:/{printf "TOTAL_BYTES=%d USED_BYTES=%d FREE_BYTES=%d", $2*1024, $3*1024, $4*1024}')" [ "$TOTAL_BYTES" -gt 0 ] && USED_PCT=$(( USED_BYTES * 100 / TOTAL_BYTES )) fi if [ "$JSON" -eq 1 ]; then cat </dev/null else free -h 2>/dev/null || cat /proc/meminfo 2>/dev/null | head -5 fi fi exit 0 MR cat > "$BRIDGE_ROOT/scripts/mac_disk.sh" <<'MD' #!/usr/bin/env bash # mac_disk.sh — disk usage (fast). Args: [path] [--json] (path default /). # (no flag) human-readable text (default) # --json structured JSON for the queried path — parse with json.loads() # JSON fields: path, total_1k, used_1k, avail_1k, used_pct # No external dependencies (no jq, no python). set -u JSON=0; TARGET="/" for arg in "$@"; do case "$arg" in --json) JSON=1 ;; *) TARGET="$arg" ;; esac done if [ "$JSON" -eq 1 ]; then # POSIX `df -P` gives stable 1K-block columns: Filesystem Blocks Used Avail Cap% Mount read -r TOTAL_1K USED_1K AVAIL_1K USED_PCT </dev/null | awk 'NR==2{gsub(/%/,"",$5); print $2, $3, $4, $5}') EOF cat </dev/null echo; echo "=== ALL MOUNTED VOLUMES ==="; df -h 2>/dev/null | grep -E "^/dev|Filesystem" | head -10 fi exit 0 MD cat > "$BRIDGE_ROOT/scripts/mac_top.sh" <<'MT' #!/usr/bin/env bash # mac_top.sh — top processes by CPU and memory (macOS or Linux). # Args: [count] [--json] (count default 15). # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() # JSON fields: count, by_cpu[], by_mem[] (each entry: pid, cpu_pct, mem_pct, name) # No external dependencies (no jq, no python). set -u JSON=0; N=15 for arg in "$@"; do case "$arg" in --json) JSON=1 ;; *) [[ "$arg" =~ ^[0-9]+$ ]] && N="$arg" ;; esac done if [ "$JSON" -eq 1 ]; then # Emit a JSON array of the top-N rows, sorted by the given column ($1=cpu, $2=mem). emit_procs() { ps -eo pid,pcpu,pmem,comm 2>/dev/null | sort -k"$1" -rn | head -"$N" \ | awk 'BEGIN{first=1; printf "["} { name=$4; for(i=5;i<=NF;i++) name=name" "$i; gsub(/\\/,"\\\\",name); gsub(/"/,"\\\"",name); if(!first) printf ","; printf "{\"pid\":%d,\"cpu_pct\":%s,\"mem_pct\":%s,\"name\":\"%s\"}", $1, $2, $3, name; first=0 } END{printf "]"}' } printf '{\n "count": %d,\n "by_cpu": %s,\n "by_mem": %s\n}\n' \ "$N" "$(emit_procs 2)" "$(emit_procs 3)" else echo "=== by CPU ==="; ps -eo pid,pcpu,pmem,comm 2>/dev/null | sort -k2 -rn | head -"$((N+1))" echo "=== by MEM ==="; ps -eo pid,pcpu,pmem,comm 2>/dev/null | sort -k3 -rn | head -"$((N+1))" fi exit 0 MT cat > "$BRIDGE_ROOT/scripts/mac_network.sh" <<'MN' #!/usr/bin/env bash # mac_network.sh — network status (macOS or Linux). Args: [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() # JSON fields: interfaces[] (each: name, addr), default_route, online (bool) # No external dependencies (no jq, no python). set -u JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done get_interfaces() { # one "name addr" pair per line, loopback excluded. Prefer `ip` (Linux), # fall back to `ifconfig` (macOS / older Linux). if command -v ip >/dev/null 2>&1; then ip -brief addr 2>/dev/null | grep -v '127.0.0.1' | awk 'NF>=3{print $1, $3}' else ifconfig 2>/dev/null | awk ' /^[a-z]/{iface=$1; sub(/:$/,"",iface)} /inet /{if($2!="127.0.0.1") print iface, $2}' fi } get_default_route() { if command -v ip >/dev/null 2>&1; then ip route show default 2>/dev/null | awk '{print $3; exit}' else route -n get default 2>/dev/null | awk '/gateway/{print $2; exit}' fi } is_online() { ping -c 2 -W 3 1.1.1.1 >/dev/null 2>&1 || ping -c 2 -t 3 1.1.1.1 >/dev/null 2>&1 } if [ "$JSON" -eq 1 ]; then IFACES_JSON="$(get_interfaces | awk 'BEGIN{first=1; printf "["} NF>=1{ name=$1; addr=$2; gsub(/"/,"\\\"",name); gsub(/"/,"\\\"",addr); if(!first) printf ","; printf "{\"name\":\"%s\",\"addr\":\"%s\"}", name, addr; first=0 } END{printf "]"}')" ROUTE="$(get_default_route)" if is_online; then ONLINE=true; else ONLINE=false; fi cat </dev/null | grep -v '127.0.0.1' \ || ifconfig 2>/dev/null | grep -E "^[a-z]|inet " | grep -v "127.0.0.1" | head -20 echo "=== default route ===" ip route show default 2>/dev/null || route -n get default 2>/dev/null | grep -E "gateway|interface" echo "=== connectivity ===" if is_online; then echo "online (1.1.1.1 reachable)" else echo "no connectivity" fi fi exit 0 MN cat > "$BRIDGE_ROOT/scripts/port_check.sh" <<'PC' #!/usr/bin/env bash # port_check.sh — show what is listening on a TCP port (macOS or Linux). # Usage: port_check.sh PORT [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() in Cowork # JSON fields: port (int), listening (bool), tool (lsof|ss|netstat|null), # raw (the captured listener lines as a string; "" when nothing is listening). set -u JSON=0 PORT="" for arg in "$@"; do if [ "$arg" = "--json" ]; then JSON=1 else PORT="$arg" fi done usage() { echo "Usage: $0 PORT [--json]" >&2 echo "PORT must be a number from 1 to 65535." >&2 exit 2 } case "$PORT" in ""|*[!0-9]*) usage ;; esac if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then usage fi found=0 tool="" raw="" if command -v lsof >/dev/null 2>&1; then raw="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)" if [ -n "$raw" ]; then found=1; tool="lsof"; fi fi if [ "$found" -eq 0 ] && command -v ss >/dev/null 2>&1; then raw="$(ss -H -ltnp "sport = :$PORT" 2>/dev/null || true)" if [ -n "$raw" ]; then found=1; tool="ss"; fi fi if [ "$found" -eq 0 ] && command -v netstat >/dev/null 2>&1; then raw="$(netstat -an 2>/dev/null | grep -E "([.:])${PORT}[[:space:]].*LISTEN" || true)" if [ -n "$raw" ]; then found=1; tool="netstat"; fi fi if [ "$JSON" -eq 1 ]; then jesc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} {if(NR>1) printf "\\n"; print}'; } bool() { [ "$1" -eq 1 ] && printf 'true' || printf 'false'; } tool_json() { if [ -z "$tool" ]; then printf 'null'; else printf '"%s"' "$tool"; fi; } printf '{\n' printf ' "port": %s,\n' "$PORT" printf ' "listening": %s,\n' "$(bool "$found")" printf ' "tool": %s,\n' "$(tool_json)" printf ' "raw": "%s"\n' "$(jesc "$raw")" printf '}\n' exit 0 fi echo "=== TCP LISTENERS ON PORT $PORT ===" if [ "$found" -eq 1 ]; then [ "$tool" = "ss" ] && echo "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process" echo "$raw" else echo "No TCP listener found on port $PORT." fi exit 0 PC cat > "$BRIDGE_ROOT/scripts/docker_ps.sh" <<'DPS' #!/usr/bin/env bash # docker_ps.sh — list running Docker containers (macOS or Linux). # Usage: docker_ps.sh [--json] # (no flag) human-readable table (default) # --json structured JSON — parse with json.loads() in Cowork # JSON shape: {"ok": bool, "error": str|null, "containers": [{name,image,status,ports}, ...]} set -u JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done emit_error() { # $1 = error message if [ "$JSON" -eq 1 ]; then esc="$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')" printf '{\n "ok": false,\n "error": "%s",\n "containers": []\n}\n' "$esc" exit 0 fi echo "$1" >&2 exit 1 } if ! command -v docker >/dev/null 2>&1; then emit_error "Docker is not installed or not in PATH." fi if ! docker info >/dev/null 2>&1; then emit_error "Docker is installed but the daemon is not running or not reachable." fi if [ "$JSON" -eq 1 ]; then # docker emits one JSON object per running container; wrap them into an array. first=1 printf '{\n "ok": true,\n "error": null,\n "containers": [' while IFS= read -r line; do [ -z "$line" ] && continue name="$(printf '%s' "$line" | sed -n 's/.*"Names":"\([^"]*\)".*/\1/p')" image="$(printf '%s' "$line" | sed -n 's/.*"Image":"\([^"]*\)".*/\1/p')" status="$(printf '%s' "$line"| sed -n 's/.*"Status":"\([^"]*\)".*/\1/p')" ports="$(printf '%s' "$line" | sed -n 's/.*"Ports":"\([^"]*\)".*/\1/p')" esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } [ "$first" -eq 1 ] && first=0 || printf ',' printf '\n {"name": "%s", "image": "%s", "status": "%s", "ports": "%s"}' \ "$(esc "$name")" "$(esc "$image")" "$(esc "$status")" "$(esc "$ports")" done </dev/null) EOF [ "$first" -eq 1 ] && printf ']\n}\n' || printf '\n ]\n}\n' exit 0 fi echo "=== RUNNING DOCKER CONTAINERS ===" docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' exit 0 DPS cat > "$BRIDGE_ROOT/scripts/docker_logs.sh" <<'DLG' #!/usr/bin/env bash # docker_logs.sh — tail a container's logs (macOS or Linux). # Args: $1 = container name/ID (required), $2 = line count (optional, default 50). # Usage from Cowork: call_remote("scripts/docker_logs.sh", args=["my-app", "100"]) set -u usage() { echo "Usage: $0 CONTAINER [LINES]" >&2 exit 2 } CONTAINER="${1:-}" LINES="${2:-50}" [[ -n "$CONTAINER" ]] || usage case "$LINES" in *[!0-9]*|'') usage ;; esac if [ "$LINES" -lt 1 ] || [ "$LINES" -gt 10000 ]; then echo "LINES must be a number from 1 to 10000." >&2 exit 2 fi if ! command -v docker >/dev/null 2>&1; then echo "Docker is not installed or not in PATH." >&2 exit 1 fi if ! docker info >/dev/null 2>&1; then echo "Docker is installed but the daemon is not running or not reachable." >&2 exit 1 fi if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then echo "Container not found: $CONTAINER" >&2 exit 1 fi echo "=== DOCKER LOGS (last $LINES lines): $CONTAINER ===" docker logs --tail "$LINES" "$CONTAINER" exit 0 DLG cat > "$BRIDGE_ROOT/scripts/git_status.sh" <<'GS' #!/usr/bin/env bash # git_status.sh — git status in any repo directory. # # Text (default): the familiar `git status --short --branch` output. # JSON (--json): a parseable object Cowork can consume without scraping text — # { "repo", "branch", "upstream", "ahead", "behind", "clean", "files": [ {x,y,path} ] } # # Usage from Cowork: # call_remote("scripts/git_status.sh", args=["/path/to/repo"]) # call_remote("scripts/git_status.sh", args=["/path/to/repo", "--json"]) set -euo pipefail REPO="$PWD" JSON=0 for arg in "$@"; do case "$arg" in --json) JSON=1 ;; *) REPO="$arg" ;; esac done cd "$REPO" if [ "$JSON" -eq 0 ]; then git status --short --branch exit 0 fi # ── JSON mode ──────────────────────────────────────────────────────────────── # Use porcelain v2 + branch headers: stable, machine-oriented, locale-independent. status="$(git status --porcelain=v2 --branch 2>/dev/null)" branch="" upstream="" ahead=0 behind=0 while IFS= read -r line; do case "$line" in "# branch.head "*) branch="${line#\# branch.head }" ;; "# branch.upstream "*) upstream="${line#\# branch.upstream }" ;; "# branch.ab "*) ab="${line#\# branch.ab }" a="${ab%% *}"; b="${ab##* }" ahead="${a#+}"; behind="${b#-}" ;; esac done <<< "$status" json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } files_json="" append_file() { local x="$1" y="$2" path="$3" local entry entry="{\"x\":\"$(json_escape "$x")\",\"y\":\"$(json_escape "$y")\",\"path\":\"$(json_escape "$path")\"}" if [ -z "$files_json" ]; then files_json="$entry"; else files_json="$files_json,$entry"; fi } unquote_path() { local p="$1" case "$p" in \"*\") p="${p#\"}"; p="${p%\"}" p="${p//\\\"/\"}"; p="${p//\\\\/\\}" ;; esac printf '%s' "$p" } while IFS= read -r line; do case "$line" in "1 "*) xy="$(printf '%s' "$line" | cut -d' ' -f2)" path="$(printf '%s' "$line" | cut -d' ' -f9-)" append_file "${xy:0:1}" "${xy:1:1}" "$path" ;; "2 "*) xy="$(printf '%s' "$line" | cut -d' ' -f2)" rest="$(printf '%s' "$line" | cut -d' ' -f10-)" path="${rest%%$'\t'*}" append_file "${xy:0:1}" "${xy:1:1}" "$path" ;; "u "*) xy="$(printf '%s' "$line" | cut -d' ' -f2)" path="$(printf '%s' "$line" | cut -d' ' -f11-)" append_file "${xy:0:1}" "${xy:1:1}" "$path" ;; "? "*) append_file "?" "?" "$(unquote_path "${line#\? }")" ;; esac done <<< "$status" clean=true [ -n "$files_json" ] && clean=false repo_abs="$(pwd)" printf '{"repo":"%s","branch":"%s","upstream":"%s","ahead":%s,"behind":%s,"clean":%s,"files":[%s]}\n' \ "$(json_escape "$repo_abs")" \ "$(json_escape "$branch")" \ "$(json_escape "$upstream")" \ "${ahead:-0}" "${behind:-0}" "$clean" "$files_json" GS cat > "$BRIDGE_ROOT/scripts/list_scripts.sh" <<'LS' #!/usr/bin/env bash # list_scripts.sh — list every script the bridge can run, with its one-line description. # Lets Cowork discover what's available instead of guessing. # # Usage: list_scripts.sh [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() in Cowork # # JSON shape: {"scripts":[{"name":"mac_ram.sh","description":"..."},...],"count":N} # No external dependencies (no jq, no python). # Usage from Cowork: call_remote("scripts/list_scripts.sh") # or ("scripts/list_scripts.sh", "--json") set -uo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done # Escape a string for safe embedding in JSON (no jq required). _jstr() { local s="$1" s="${s//\\/\\\\}" # backslash first s="${s//\"/\\\"}" # then double-quote s="${s// /\\t}" # literal tab printf '%s' "$s" } shopt -s nullglob if [ "$JSON" -eq 1 ]; then out="{\"scripts\":[" first=1 count=0 for f in "$DIR"/*.sh; do name="$(basename "$f")" [ "$name" = "list_scripts.sh" ] && continue desc="$(awk 'NR>1 && /^#/ {sub(/^# */,""); print; exit}' "$f")" [ "$first" -eq 0 ] && out="$out," out="$out{\"name\":\"$(_jstr "$name")\",\"description\":\"$(_jstr "${desc:-}")\"}" first=0 count=$((count + 1)) done out="$out],\"count\":$count}" printf '%s\n' "$out" exit 0 fi echo "=== AVAILABLE BRIDGE SCRIPTS ===" echo "(call any of these with call_remote(\"scripts/\"))" echo found=0 for f in "$DIR"/*.sh; do name="$(basename "$f")" [ "$name" = "list_scripts.sh" ] && continue # Pull the first comment line after the shebang as the description. desc="$(awk 'NR>1 && /^#/ {sub(/^# */,""); print; exit}' "$f")" printf ' %-22s %s\n' "$name" "${desc:-(no description)}" found=$((found + 1)) done [ "$found" -eq 0 ] && echo " (no scripts found in $DIR)" exit 0 LS cat > "$BRIDGE_ROOT/scripts/env_check.sh" <<'EC' #!/usr/bin/env bash # env_check.sh — show the environment values Cowork and Claude Code care about. # Never prints secret VALUES (only whether they are set). # Usage: env_check.sh [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() in Cowork # JSON fields: bridge_root, bridge_root_exists, bridge_token_set, # claude_flags (null if unset), shell, home, os, claude_cli (null if missing). set -uo pipefail JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done root="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" [ -d "$root" ] && ROOT_EXISTS=1 || ROOT_EXISTS=0 if [ -n "${BRIDGE_TOKEN:-}" ]; then TOKEN_SET=1 elif [ -f "$root/.env" ] && grep -q '^BRIDGE_TOKEN=' "$root/.env" 2>/dev/null; then TOKEN_SET=1 else TOKEN_SET=0 fi if [ "$(uname)" = "Darwin" ]; then OS_DESC="macOS $(sw_vers -productVersion 2>/dev/null || echo '?')" elif [ -r /etc/os-release ]; then OS_DESC="$(. /etc/os-release && echo "$PRETTY_NAME")" else OS_DESC="$(uname -s) $(uname -r)" fi claude_path="$(command -v claude 2>/dev/null || true)" if [ "$JSON" -eq 1 ]; then jesc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } jstr_or_null() { if [ -z "$1" ]; then printf 'null'; else printf '"%s"' "$(jesc "$1")"; fi; } bool() { [ "$1" -eq 1 ] && printf 'true' || printf 'false'; } printf '{\n' printf ' "bridge_root": "%s",\n' "$(jesc "$root")" printf ' "bridge_root_exists": %s,\n' "$(bool "$ROOT_EXISTS")" printf ' "bridge_token_set": %s,\n' "$(bool "$TOKEN_SET")" printf ' "claude_flags": %s,\n' "$(jstr_or_null "${CLAUDE_FLAGS:-}")" printf ' "shell": "%s",\n' "$(jesc "${SHELL:-unknown}")" printf ' "home": "%s",\n' "$(jesc "${HOME:-unknown}")" printf ' "os": "%s",\n' "$(jesc "$OS_DESC")" printf ' "claude_cli": %s\n' "$(jstr_or_null "$claude_path")" printf '}\n' exit 0 fi echo "=== BRIDGE ENVIRONMENT ===" printf '%-13s: %s\n' "PATH" "${PATH:-}" if [ "$ROOT_EXISTS" -eq 1 ]; then printf '%-13s: %s (exists)\n' "BRIDGE_ROOT" "$root" else printf '%-13s: %s (MISSING)\n' "BRIDGE_ROOT" "$root" fi if [ "$TOKEN_SET" -eq 1 ]; then if [ -n "${BRIDGE_TOKEN:-}" ]; then printf '%-13s: set\n' "BRIDGE_TOKEN" else printf '%-13s: set (in .env)\n' "BRIDGE_TOKEN" fi else printf '%-13s: not set\n' "BRIDGE_TOKEN" fi printf '%-13s: %s\n' "CLAUDE_FLAGS" "${CLAUDE_FLAGS:-(not set)}" printf '%-13s: %s\n' "SHELL" "${SHELL:-unknown}" printf '%-13s: %s\n' "HOME" "${HOME:-unknown}" printf '%-13s: %s\n' "OS" "$OS_DESC" printf '%-13s: %s\n' "claude CLI" "${claude_path:-not found on PATH}" exit 0 EC cat > "$BRIDGE_ROOT/scripts/disk_hogs.sh" <<'DH' #!/usr/bin/env bash # disk_hogs.sh — biggest files and folders in a directory (default: home). # Args: [path] [count] [--json] e.g. call_remote("scripts/disk_hogs.sh", args=["~/Downloads","15"]) # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() # JSON fields: path, count, items[] (each: size_bytes, size_human, name) set -uo pipefail JSON=0; POS=() for arg in "$@"; do case "$arg" in --json) JSON=1 ;; *) POS+=("$arg") ;; esac done TARGET="${POS[0]:-$HOME}" COUNT="${POS[1]:-15}" # expand a leading ~ since args arrive as literal strings case "$TARGET" in "~"|"~/"*) TARGET="$HOME${TARGET#\~}";; esac if ! [[ "$COUNT" =~ ^[0-9]+$ ]]; then echo "count must be a number, got: $COUNT" >&2; exit 1 fi if [ ! -d "$TARGET" ]; then echo "not a directory: $TARGET" >&2; exit 1 fi if [ "$JSON" -eq 1 ]; then # `du -k` gives sizes in 1K blocks (portable across macOS/Linux); convert to bytes. ITEMS="$(du -k "$TARGET"/* "$TARGET"/.[!.]* 2>/dev/null \ | sort -rn \ | head -n "$COUNT" \ | awk 'BEGIN{first=1; printf "["} { bytes=$1*1024; name=$2; for(i=3;i<=NF;i++) name=name" "$i; gsub(/\\/,"\\\\",name); gsub(/"/,"\\\"",name); # compact human size h=$1; u="K"; if(h>=1048576){h=h/1048576; u="G"} else if(h>=1024){h=h/1024; u="M"} hs=sprintf("%.1f%s", h, u); if(!first) printf ","; printf "{\"size_bytes\":%d,\"size_human\":\"%s\",\"name\":\"%s\"}", bytes, hs, name; first=0 } END{printf "]"}')" cat </dev/null \ | sort -rh \ | head -n "$COUNT" fi exit 0 DH cat > "$BRIDGE_ROOT/scripts/open_browser.sh" <<'OB' #!/usr/bin/env bash # open_browser.sh — open a URL in the machine's default browser. # Args: e.g. call_remote("scripts/open_browser.sh", args=["http://localhost:3000"]) set -uo pipefail URL="${1:-}" if [ -z "$URL" ]; then echo "usage: open_browser.sh " >&2; exit 1 fi # Only allow http(s) and localhost-style targets; reject file:// and bare paths. if ! [[ "$URL" =~ ^https?:// ]] \ && ! [[ "$URL" =~ ^(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]]; then echo "refusing to open non-http URL: $URL" >&2; exit 1 fi # normalise a bare localhost:PORT into a full URL [[ "$URL" =~ ^https?:// ]] || URL="http://$URL" if [ "$(uname)" = "Darwin" ]; then open "$URL" && echo "opened: $URL" elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 && echo "opened: $URL" else echo "no display / no opener available — open manually: $URL" fi exit 0 OB cat > "$BRIDGE_ROOT/scripts/pkg_outdated.sh" <<'POD' #!/usr/bin/env bash # pkg_outdated.sh — list outdated system packages (macOS or Linux). # Detects the package manager: brew on macOS; apt/dnf/yum/pacman on Linux. # Usage: pkg_outdated.sh [--json] # (no flag) human-readable text (default) # --json structured JSON — parse with json.loads() in Cowork # JSON fields: manager (brew|apt|dnf|yum|pacman|null), count (int), # packages (array of package-name strings, best-effort), raw (full output). set -u JSON=0 for arg in "$@"; do [ "$arg" = "--json" ] && JSON=1; done manager="" raw="" if command -v brew >/dev/null 2>&1; then manager="brew" raw="$(brew outdated 2>/dev/null || true)" elif command -v apt >/dev/null 2>&1; then manager="apt" # Drop the "Listing..." header line apt prints to stdout. raw="$(apt list --upgradable 2>/dev/null | grep -v '^Listing' || true)" elif command -v dnf >/dev/null 2>&1; then manager="dnf" # dnf check-update exits 100 when updates exist; don't treat that as an error. raw="$(dnf check-update 2>/dev/null || true)" elif command -v yum >/dev/null 2>&1; then manager="yum" raw="$(yum check-update 2>/dev/null || true)" elif command -v pacman >/dev/null 2>&1; then manager="pacman" raw="$(pacman -Qu 2>/dev/null || true)" fi # Best-effort package-name extraction (first whitespace/`/`-delimited token per # non-empty line). brew lists bare names; apt uses "name/suite"; pacman/dnf put # the name first too. extract_names() { printf '%s\n' "$raw" | awk 'NF { n=$1; sub(/\/.*/,"",n); print n }' } if [ "$JSON" -eq 1 ]; then jesc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } raw_json() { printf '%s' "$raw" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} {if(NR>1) printf "\\n"; print}'; } mgr_json() { if [ -z "$manager" ]; then printf 'null'; else printf '"%s"' "$manager"; fi; } names="" count=0 if [ -n "$manager" ]; then while IFS= read -r name; do [ -z "$name" ] && continue if [ -z "$names" ]; then names="\"$(jesc "$name")\"" else names="$names, \"$(jesc "$name")\"" fi count=$((count + 1)) done < "$BRIDGE_ROOT/scripts/request_cowork.sh" <<'REQCW' #!/usr/bin/env bash # request_cowork.sh — drop a request for a Claude Cowork session (async inbox). # Cowork has no inbound address, so this queues to BRIDGE_ROOT/to_cowork/ and a # Cowork session picks it up next time one is open and checks its inbox. # Usage: request_cowork.sh "" [--wait SECONDS] set -euo pipefail BRIDGE_ROOT="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" INBOX="$BRIDGE_ROOT/to_cowork"; REPLIES="$BRIDGE_ROOT/cowork_results" REQUEST="${1:?usage: request_cowork.sh \"\" [--wait SECONDS]}"; shift || true WAIT=0 if [[ "${1:-}" == "--wait" ]]; then WAIT="${2:-300}" [[ "$WAIT" =~ ^[0-9]+$ ]] || { echo "--wait expects seconds, got: $WAIT" >&2; exit 2; } shift 2 || true fi mkdir -p "$INBOX" "$REPLIES"; chmod 700 "$INBOX" "$REPLIES" 2>/dev/null || true ID="$(date +%s)_$$_${RANDOM}" TOKEN="" [[ -f "$BRIDGE_ROOT/.env" ]] && TOKEN="$(grep '^BRIDGE_TOKEN=' "$BRIDGE_ROOT/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]')" TMP="$INBOX/.$ID.json.tmp"; OUT="$INBOX/$ID.json" PARENT="${BRIDGE_CMD_ID:-}" python3 - "$ID" "$REQUEST" "$TOKEN" "$PARENT" >"$TMP" <<'PY' import json,sys,time _id,req,tok,parent=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4] o={"id":_id,"request":req,"ts":time.time(),"from":"claude-code"} if tok:o["token"]=tok if parent:o["parent"]=parent print(json.dumps(o)) PY mv "$TMP" "$OUT"; echo "queued request for Cowork: $OUT" if [[ "$WAIT" -gt 0 ]]; then RF="$REPLIES/$ID.json"; dl=$(( $(date +%s)+WAIT )) while [[ "$(date +%s)" -lt "$dl" ]]; do [[ -f "$RF" ]] && { echo "=== reply ==="; cat "$RF"; exit 0; }; sleep 2; done echo "no reply within ${WAIT}s (Cowork may not be open); request stays queued." >&2 exit 1 fi REQCW chmod +x "$BRIDGE_ROOT/scripts/request_cowork.sh" mkdir -p "$BRIDGE_ROOT/to_cowork" "$BRIDGE_ROOT/cowork_results" chmod 700 "$BRIDGE_ROOT/to_cowork" "$BRIDGE_ROOT/cowork_results" 2>/dev/null || true chmod +x "$BRIDGE_ROOT"/scripts/mac_*.sh "$BRIDGE_ROOT/scripts/port_check.sh" "$BRIDGE_ROOT/scripts/docker_ps.sh" "$BRIDGE_ROOT/scripts/docker_logs.sh" "$BRIDGE_ROOT/scripts/pkg_outdated.sh" "$BRIDGE_ROOT/scripts/git_status.sh" "$BRIDGE_ROOT/scripts/list_scripts.sh" "$BRIDGE_ROOT/scripts/env_check.sh" "$BRIDGE_ROOT/scripts/disk_hogs.sh" "$BRIDGE_ROOT/scripts/open_browser.sh" # process_kill.sh — terminate a named process or PID from Cowork. # Safety guards: refuses PID ≤ 10, refuses protected names (launchd/kernel_task/ # systemd/init/kernel/kthreadd), refuses >1 name match unless --all is passed. # Sends SIGTERM (graceful); never SIGKILL. # Testability: BRIDGE_PGREP_CMD / BRIDGE_KILL_CMD env vars let tests inject fakes. cat > "$BRIDGE_ROOT/scripts/process_kill.sh" <<'PK' #!/usr/bin/env bash # process_kill.sh — terminate a named process or PID on this machine. # # Usage # ----- # process_kill.sh [--all] [--json] # # Name path : exact match via pgrep -x. # Refuses if >1 match unless --all is passed. # PID path : numeric PID; must exist and be > 10. # --json : emit a machine-parseable result object instead of text. # # Safety guards # ------------- # - PID ≤ 10 refused (kernel/init territory on all UNIX-like systems) # - Protected names refused: launchd, kernel_task, systemd, init, kernel, kthreadd # - Sends SIGTERM (graceful), never SIGKILL # - Confirms process is gone after the signal # # JSON output (--json) # -------------------- # A stable object Cowork can consume without scraping text: # { "ok", "target", "mode": "pid"|"name", "killed": [pids], # "remaining": [pids], "error": "" } # On refusal/error, ok=false and error is set; killed/remaining reflect state. # # Works on macOS and Linux. No deps beyond bash + coreutils. # # Testability hooks (used by tests/test_system_scripts.py) # BRIDGE_PGREP_CMD override pgrep binary # BRIDGE_KILL_CMD override kill binary set -uo pipefail BRIDGE_PGREP_CMD="${BRIDGE_PGREP_CMD:-pgrep}" BRIDGE_KILL_CMD="${BRIDGE_KILL_CMD:-kill}" TARGET="${1:?usage: process_kill.sh [--all] [--json]}" ALL_FLAG=0 JSON=0 shift || true for arg in "$@"; do case "$arg" in --all) ALL_FLAG=1 ;; --json) JSON=1 ;; esac done PROTECTED_NAMES=("launchd" "kernel_task" "systemd" "init" "kernel" "kthreadd") _is_protected() { local name="$1" for pname in "${PROTECTED_NAMES[@]}"; do [[ "$name" == "$pname" ]] && return 0 done return 1 } # ── JSON emitters ──────────────────────────────────────────────────────────── # Emit a JSON result and exit with the given code. Whitespace-separated PID # lists are turned into JSON arrays; error is a string or null. _json_emit() { local ok="$1" mode="$2" killed="$3" remaining="$4" err="$5" code="$6" python3 - "$ok" "$TARGET" "$mode" "$killed" "$remaining" "$err" <<'PY' import json, sys ok, target, mode, killed, remaining, err = sys.argv[1:7] to_list = lambda s: [int(x) for x in s.split()] if s.strip() else [] print(json.dumps({ "ok": ok == "1", "target": target, "mode": mode, "killed": to_list(killed), "remaining": to_list(remaining), "error": err if err else None, })) PY exit "$code" } # Fail helper: in JSON mode emit structured error, else print to stderr. _fail() { local msg="$1" mode="${2:-}" code="${3:-1}" if [[ "$JSON" -eq 1 ]]; then _json_emit 0 "$mode" "" "" "$msg" "$code" fi echo "ERROR: $msg" >&2 exit "$code" } # Refuse protected names before any pgrep/kill call. if _is_protected "$TARGET"; then _fail "refusing to kill protected process: $TARGET" "" 1 fi # ─── PID path ───────────────────────────────────────────────────────────────── if [[ "$TARGET" =~ ^[0-9]+$ ]]; then PID="$TARGET" if (( PID <= 10 )); then _fail "refusing to kill PID $PID (≤ 10 is kernel/init territory)" "pid" 1 fi if ! "$BRIDGE_KILL_CMD" -0 "$PID" 2>/dev/null; then _fail "no process with PID $PID" "pid" 1 fi PROC_NAME="$(ps -p "$PID" -o comm= 2>/dev/null | tr -d ' ' || echo '?')" if _is_protected "$PROC_NAME"; then _fail "refusing to kill protected process: $PROC_NAME (PID $PID)" "pid" 1 fi [[ "$JSON" -eq 0 ]] && echo "Sending SIGTERM to PID $PID ($PROC_NAME)..." "$BRIDGE_KILL_CMD" -TERM "$PID" for i in 1 2 3 4 5 6; do sleep 0.5 if ! "$BRIDGE_KILL_CMD" -0 "$PID" 2>/dev/null; then if [[ "$JSON" -eq 1 ]]; then _json_emit 1 "pid" "$PID" "" "" 0 fi echo "✓ PID $PID ($PROC_NAME) is gone" exit 0 fi done if [[ "$JSON" -eq 1 ]]; then _json_emit 0 "pid" "" "$PID" "PID $PID still alive after 3s — may need SIGKILL" 1 fi echo "⚠ PID $PID ($PROC_NAME) still alive after 3s — may need SIGKILL" >&2 exit 1 fi # ─── Name path ──────────────────────────────────────────────────────────────── # pgrep -x: exact name match (won't kill 'rail' when asked for 'rails'). PIDS="$("$BRIDGE_PGREP_CMD" -x "$TARGET" 2>/dev/null || true)" if [[ -z "$PIDS" ]]; then _fail "no process named '$TARGET' found" "name" 1 fi PID_COUNT="$(echo "$PIDS" | wc -l | tr -d ' ')" if [[ "$PID_COUNT" -gt 1 && "$ALL_FLAG" -eq 0 ]]; then if [[ "$JSON" -eq 1 ]]; then _json_emit 0 "name" "" "$(echo "$PIDS" | tr '\n' ' ')" \ "$PID_COUNT processes named '$TARGET' found — pass --all to kill all, or use a specific PID" 1 fi echo "ERROR: $PID_COUNT processes named '$TARGET' found (PIDs: $(echo "$PIDS" | tr '\n' ' '))" >&2 echo " Pass --all to kill all of them, or use a specific PID instead." >&2 exit 1 fi KILLED_PIDS="" KILLED=0 while IFS= read -r pid; do [[ -z "$pid" ]] && continue if (( pid <= 10 )); then [[ "$JSON" -eq 0 ]] && echo " skipping PID $pid (≤ 10)" >&2 continue fi [[ "$JSON" -eq 0 ]] && echo "Sending SIGTERM to PID $pid ($TARGET)..." if "$BRIDGE_KILL_CMD" -TERM "$pid" 2>/dev/null; then KILLED=$(( KILLED + 1 )) KILLED_PIDS="$KILLED_PIDS $pid" else [[ "$JSON" -eq 0 ]] && echo " WARNING: could not send SIGTERM to PID $pid" >&2 fi done <<< "$PIDS" if [[ "$KILLED" -eq 0 ]]; then _fail "no processes were killed" "name" 1 fi sleep 0.5 REMAINING_PIDS="$({ "$BRIDGE_PGREP_CMD" -x "$TARGET" 2>/dev/null || true; } | tr '\n' ' ')" REMAINING="$(echo "$REMAINING_PIDS" | wc -w | tr -d ' ')" if [[ "$REMAINING" -eq 0 ]]; then if [[ "$JSON" -eq 1 ]]; then _json_emit 1 "name" "$KILLED_PIDS" "" "" 0 fi echo "✓ $KILLED '$TARGET' process(es) terminated" else if [[ "$JSON" -eq 1 ]]; then _json_emit 0 "name" "$KILLED_PIDS" "$REMAINING_PIDS" \ "$REMAINING '$TARGET' process(es) still alive after SIGTERM — may need SIGKILL" 1 fi echo "⚠ $REMAINING '$TARGET' process(es) still alive after SIGTERM — may need SIGKILL" >&2 exit 1 fi PK chmod +x "$BRIDGE_ROOT/scripts/process_kill.sh" # ── escalate_to_claude.sh ───────────────────────────────────────────────────── # REVERSE direction, for external agents (Hermes, cron, CI): hand a task to a # Claude Code/Cowork session via the to_cowork inbox, optionally waiting for a # reply. Same queue as request_cowork.sh, but tagged from an escalation daemon. cat > "$BRIDGE_ROOT/scripts/escalate_to_claude.sh" <<'ESCALATE' #!/usr/bin/env bash # escalate_to_claude.sh — hand a complex task from a daemon/agent to Claude Code # on your machine, using your subscription (no token needed). # # Use from: Hermes, Open Claw, cron jobs, CI/CD workflows, or any external agent. # # Usage (from your escalating agent): # escalate_to_claude.sh "Debug the API health check failures and fix them" --wait 300 # escalate_to_claude.sh "Summarize recent errors and suggest fixes" # # The request goes to BRIDGE_ROOT/to_cowork/ and waits for Claude Code in a # Cowork chat to pick it up, debug, and reply to BRIDGE_ROOT/cowork_results/. # # Args: # $1 the escalation text (required) # --wait SECS optional: poll for a reply for up to SECS (default: no wait) # set -euo pipefail BRIDGE_ROOT="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" INBOX="$BRIDGE_ROOT/to_cowork" REPLIES="$BRIDGE_ROOT/cowork_results" REQUEST="${1:-}" if [[ -z "$REQUEST" ]]; then echo "usage: escalate_to_claude.sh \"\" [--wait SECONDS]" >&2 exit 2 fi shift || true WAIT=0 if [[ "${1:-}" == "--wait" ]]; then WAIT="${2:-300}" if [[ ! "$WAIT" =~ ^[0-9]+$ ]]; then echo "escalate_to_claude.sh: --wait expects a number of seconds, got: ${WAIT}" >&2 exit 2 fi shift 2 || true fi mkdir -p "$INBOX" "$REPLIES" chmod 700 "$INBOX" "$REPLIES" 2>/dev/null || true # Unique id. Avoid $RANDOM-only collisions; combine epoch + pid + a short rand. ID="$(date +%s)_$$_${RANDOM}" TOKEN="" if [[ -f "$BRIDGE_ROOT/.env" ]]; then # Strip surrounding quotes then whitespace — same robust pipeline as install.sh. TOKEN="$(grep '^BRIDGE_TOKEN=' "$BRIDGE_ROOT/.env" 2>/dev/null | head -1 \ | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]')" fi # Atomic write: .tmp then mv, so a polling Cowork session never reads a partial file. TMP="$INBOX/.$ID.json.tmp" OUT="$INBOX/$ID.json" # Build JSON payload with escalation metadata. python3 - "$ID" "$REQUEST" "$TOKEN" >"$TMP" <<'PY' import json, sys, time, os _id, req, tok = sys.argv[1], sys.argv[2], sys.argv[3] obj = { "id": _id, "request": req, "ts": time.time(), "from": "escalation-daemon", "escalation_context": { "hostname": os.uname().nodename, "user": os.getenv("USER", "unknown"), "cwd": os.getcwd() } } if tok: obj["token"] = tok print(json.dumps(obj)) PY mv "$TMP" "$OUT" echo "escalation queued for Claude Code: $OUT" echo " → waiting for a Cowork session to pick it up and reply..." if [[ "$WAIT" -gt 0 ]]; then REPLY_FILE="$REPLIES/$ID.json" deadline=$(( $(date +%s) + WAIT )) while [[ "$(date +%s)" -lt "$deadline" ]]; do if [[ -f "$REPLY_FILE" ]]; then echo "=== Claude Code reply ===" cat "$REPLY_FILE" exit 0 fi sleep 2 done echo " no reply within ${WAIT}s (Claude Code / Cowork may not be active right now)." >&2 echo " the escalation stays queued at $OUT until a Cowork session picks it up." >&2 exit 0 fi ESCALATE chmod +x "$BRIDGE_ROOT/scripts/escalate_to_claude.sh" # ── mcp_proxy.sh ────────────────────────────────────────────────────────────── cat > "$BRIDGE_ROOT/scripts/mcp_proxy.sh" <<'MCPPROXY' #!/usr/bin/env bash # mcp_proxy.sh — relay a single MCP JSON-RPC call to a local stdio MCP server # and return the response through the bridge queue. # # This lets Claude Cowork reach any local stdio MCP server — a database client, # filesystem tool, or custom CLI — without a public HTTPS tunnel. # Addresses: anthropics/claude-code#53476, anthropics/claude-code#48909 # # Usage # ----- # mcp_proxy.sh --server --method [--params ] # mcp_proxy.sh --server --request # # Server registry: $BRIDGE_ROOT/mcp_servers.json # Register servers with: mcp_register.sh # # Output (stdout): JSON-RPC response object, always valid JSON. # Exit code: always 0 — MCP-level errors are reported inside the JSON. # # Testability hooks # BRIDGE_MCP_REGISTRY override registry file path set -uo pipefail BRIDGE_ROOT="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" REGISTRY="${BRIDGE_MCP_REGISTRY:-$BRIDGE_ROOT/mcp_servers.json}" usage() { cat >&2 <<'EOF' Usage: mcp_proxy.sh --server --method [--params ] mcp_proxy.sh --server --request Examples: mcp_proxy.sh --server filesystem --method tools/list --params '{}' mcp_proxy.sh --server postgres --method tools/call \ --params '{"name":"query","arguments":{"sql":"SELECT 1"}}' EOF exit 1 } SERVER_NAME="" METHOD="" PARAMS="null" REQUEST_JSON="" while [[ $# -gt 0 ]]; do case "$1" in --server) SERVER_NAME="$2"; shift 2 ;; --method) METHOD="$2"; shift 2 ;; --params) PARAMS="$2"; shift 2 ;; --request) REQUEST_JSON="$2";shift 2 ;; -h|--help) usage ;; *) echo "Unknown argument: $1" >&2; usage ;; esac done [[ -z "$SERVER_NAME" ]] && { echo "ERROR: --server is required" >&2; usage; } [[ -z "$METHOD" && -z "$REQUEST_JSON" ]] && { echo "ERROR: --method or --request is required" >&2; usage; } # ── Python handles the MCP stdio protocol ───────────────────────────────────── python3 - \ "$SERVER_NAME" \ "$METHOD" \ "$PARAMS" \ "$REQUEST_JSON" \ "$REGISTRY" \ <<'PYEOF' import sys, json, subprocess, time, os server_name = sys.argv[1] method = sys.argv[2] # empty string if --request used params_raw = sys.argv[3] # "null" if not provided request_raw = sys.argv[4] # full JSON-RPC string if --request used registry_path = sys.argv[5] def die(msg): print(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": msg}})) sys.exit(0) # ── Load registry ────────────────────────────────────────────────────────────── if not os.path.exists(registry_path): die(f"No server registry at {registry_path}. Run mcp_register.sh first.") try: registry = json.load(open(registry_path)) except Exception as e: die(f"Cannot read registry: {e}") if server_name not in registry: known = list(registry.keys()) die(f"Server '{server_name}' not registered. Known servers: {known}") cfg = registry[server_name] cmd = [cfg["command"]] + cfg.get("args", []) env = {**os.environ, **cfg.get("env", {})} # ── Build the user request ──────────────────────────────────────────────────── if request_raw: try: user_req = json.loads(request_raw) except Exception as e: die(f"Invalid --request JSON: {e}") else: try: params = json.loads(params_raw) except Exception as e: die(f"Invalid --params JSON: {e}") user_req = { "jsonrpc": "2.0", "id": 2, "method": method, "params": params if params is not None else {}, } req_id = user_req.get("id", 2) # ── MCP stdio: initialize → initialized → user_req → response ──────────────── INIT_REQ = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "cowork-bridge-proxy", "version": "1.0"}, } }) INIT_NOTIF = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) try: proc = subprocess.Popen( cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, ) except FileNotFoundError: die(f"Command not found: {cmd[0]}") except Exception as e: die(f"Failed to start server '{server_name}': {e}") def readline_timeout(stream, timeout=10.0): """Read one line; return None on timeout or EOF.""" import select deadline = time.time() + timeout buf = "" while time.time() < deadline: ready, _, _ = select.select([stream], [], [], 0.05) if ready: ch = stream.read(1) if not ch: return None if ch == "\n": return buf buf += ch return None result = None try: # 1. Send initialize proc.stdin.write(INIT_REQ + "\n") proc.stdin.flush() # 2. Read initialize response deadline = time.time() + 10 while time.time() < deadline: line = readline_timeout(proc.stdout, timeout=2.0) if line is None: break line = line.strip() if not line: continue try: msg = json.loads(line) if msg.get("id") == 1: if "error" in msg: result = msg break except Exception: continue else: result = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32001, "message": "Initialize timeout"}} if result is None: # 3. Send initialized notification proc.stdin.write(INIT_NOTIF + "\n") proc.stdin.flush() # 4. Send user request proc.stdin.write(json.dumps(user_req) + "\n") proc.stdin.flush() # 5. Read response (skip notifications) deadline = time.time() + 30 while time.time() < deadline: line = readline_timeout(proc.stdout, timeout=2.0) if line is None: result = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32002, "message": "Response timeout after 30s"}} break line = line.strip() if not line: continue try: msg = json.loads(line) # Skip notifications (no id field) if "id" in msg and msg["id"] == req_id: result = msg break except Exception: continue else: result = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32002, "message": "Response timeout after 30s"}} except Exception as e: result = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": f"Protocol error: {e}"}} finally: try: proc.stdin.close() proc.terminate() proc.wait(timeout=3) except Exception: try: proc.kill() except Exception: pass if result is None: result = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": "No response received"}} print(json.dumps(result)) PYEOF MCPPROXY chmod +x "$BRIDGE_ROOT/scripts/mcp_proxy.sh" # ── mcp_register.sh ─────────────────────────────────────────────────────────── cat > "$BRIDGE_ROOT/scripts/mcp_register.sh" <<'MCPREG' #!/usr/bin/env bash # mcp_register.sh — register a local stdio MCP server in the bridge registry. # # Usage # ----- # mcp_register.sh --name --command [--args ] [--env ] # mcp_register.sh --remove # mcp_register.sh --list # # Examples # -------- # # Register the MCP filesystem server (npx) # mcp_register.sh --name filesystem \ # --command npx \ # --args '["-y","@modelcontextprotocol/server-filesystem","/Users/me/projects"]' # # # Register a postgres MCP server (uvx) # mcp_register.sh --name postgres \ # --command uvx \ # --args '["mcp-server-postgres","postgresql://localhost/mydb"]' # # # Register a custom CLI tool # mcp_register.sh --name mytool --command /usr/local/bin/my-mcp-server # # # Remove a server # mcp_register.sh --remove filesystem # # Registry: $BRIDGE_ROOT/mcp_servers.json # Testability hooks # BRIDGE_MCP_REGISTRY override registry file path set -uo pipefail BRIDGE_ROOT="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" REGISTRY="${BRIDGE_MCP_REGISTRY:-$BRIDGE_ROOT/mcp_servers.json}" usage() { cat >&2 <<'EOF' Usage: mcp_register.sh --name --command [--args ] [--env ] mcp_register.sh --remove mcp_register.sh --list EOF exit 1 } MODE="register" NAME="" COMMAND="" ARGS_JSON="[]" ENV_JSON="{}" while [[ $# -gt 0 ]]; do case "$1" in --name) NAME="$2"; shift 2 ;; --command) COMMAND="$2"; shift 2 ;; --args) ARGS_JSON="$2"; shift 2 ;; --env) ENV_JSON="$2"; shift 2 ;; --remove) MODE="remove"; NAME="$2"; shift 2 ;; --list) MODE="list"; shift ;; -h|--help) usage ;; *) echo "Unknown argument: $1" >&2; usage ;; esac done python3 - "$MODE" "$NAME" "$COMMAND" "$ARGS_JSON" "$ENV_JSON" "$REGISTRY" <<'PYEOF' import sys, json, os mode = sys.argv[1] name = sys.argv[2] command = sys.argv[3] args_raw = sys.argv[4] env_raw = sys.argv[5] registry_path = sys.argv[6] # Load existing registry (or start fresh) registry = {} if os.path.exists(registry_path): try: registry = json.load(open(registry_path)) except Exception as e: print(f"WARNING: could not parse existing registry, starting fresh: {e}", file=sys.stderr) if mode == "list": if not registry: print("No MCP servers registered.") print(f"Registry: {registry_path}") else: print(f"Registered MCP servers ({len(registry)}):") for n, cfg in registry.items(): args_str = " ".join(cfg.get("args", [])) print(f" {n:20s} {cfg['command']} {args_str}") print(f"\nRegistry: {registry_path}") sys.exit(0) if mode == "remove": if not name: print("ERROR: --remove requires a server name", file=sys.stderr) sys.exit(1) if name not in registry: print(f"ERROR: '{name}' is not registered", file=sys.stderr) sys.exit(1) del registry[name] os.makedirs(os.path.dirname(registry_path), exist_ok=True) with open(registry_path, "w") as f: json.dump(registry, f, indent=2) print(f"✓ Removed '{name}' from registry") sys.exit(0) # register mode if not name: print("ERROR: --name is required", file=sys.stderr) sys.exit(1) if not command: print("ERROR: --command is required", file=sys.stderr) sys.exit(1) try: args = json.loads(args_raw) if not isinstance(args, list): raise ValueError("--args must be a JSON array") except Exception as e: print(f"ERROR: invalid --args JSON: {e}", file=sys.stderr) sys.exit(1) try: env = json.loads(env_raw) if not isinstance(env, dict): raise ValueError("--env must be a JSON object") except Exception as e: print(f"ERROR: invalid --env JSON: {e}", file=sys.stderr) sys.exit(1) registry[name] = {"command": command, "args": args, "env": env} os.makedirs(os.path.dirname(registry_path), exist_ok=True) tmp = registry_path + ".tmp" with open(tmp, "w") as f: json.dump(registry, f, indent=2) os.replace(tmp, registry_path) action = "Updated" if name in registry else "Registered" print(f"✓ {action} '{name}' → {command} {' '.join(str(a) for a in args)}") print(f" Registry: {registry_path}") print(f" Total servers: {len(registry)}") PYEOF MCPREG chmod +x "$BRIDGE_ROOT/scripts/mcp_register.sh" # ── mcp_list_servers.sh ─────────────────────────────────────────────────────── cat > "$BRIDGE_ROOT/scripts/mcp_list_servers.sh" <<'MCPLIST' #!/usr/bin/env bash # mcp_list_servers.sh — list all MCP servers registered in the bridge registry. # # Usage # ----- # mcp_list_servers.sh [--json] # # Without --json: human-readable table. # With --json: raw JSON of the registry (for programmatic use). # # Registry: $BRIDGE_ROOT/mcp_servers.json # Testability hooks # BRIDGE_MCP_REGISTRY override registry file path set -uo pipefail BRIDGE_ROOT="${BRIDGE_ROOT:-$HOME/.cowork-to-code-bridge}" REGISTRY="${BRIDGE_MCP_REGISTRY:-$BRIDGE_ROOT/mcp_servers.json}" JSON_OUT=0 while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_OUT=1; shift ;; -h|--help) echo "Usage: mcp_list_servers.sh [--json]" >&2 exit 0 ;; *) echo "Unknown argument: $1" >&2; exit 1 ;; esac done if [[ ! -f "$REGISTRY" ]]; then if [[ "$JSON_OUT" -eq 1 ]]; then echo '{"servers":{},"count":0,"registry":null}' else echo "No MCP servers registered." echo "Register one with: mcp_register.sh --name --command " fi exit 0 fi python3 - "$REGISTRY" "$JSON_OUT" <<'PYEOF' import sys, json registry_path = sys.argv[1] json_out = sys.argv[2] == "1" try: registry = json.load(open(registry_path)) except Exception as e: if json_out: print(json.dumps({"error": str(e), "servers": {}, "count": 0})) else: print(f"ERROR reading registry: {e}") sys.exit(0) if json_out: print(json.dumps({ "servers": registry, "count": len(registry), "registry": registry_path, }, indent=2)) sys.exit(0) if not registry: print("No MCP servers registered.") print(f"Registry: {registry_path}") sys.exit(0) print(f"Registered MCP servers ({len(registry)}):\n") print(f" {'NAME':<20} {'COMMAND':<12} ARGS") print(f" {'-'*20} {'-'*12} {'-'*30}") for name, cfg in sorted(registry.items()): args_str = " ".join(str(a) for a in cfg.get("args", [])) if len(args_str) > 40: args_str = args_str[:37] + "..." print(f" {name:<20} {cfg['command']:<12} {args_str}") print(f"\nRegistry: {registry_path}") print(f"Use mcp_proxy.sh to call any of these from Cowork.") PYEOF MCPLIST chmod +x "$BRIDGE_ROOT/scripts/mcp_list_servers.sh" # mcp_audit.sh — cross-surface MCP audit. # Addresses: anthropics/claude-code#56353 — no first-class tool to compare # MCPs registered in local Claude Code vs what a Cowork session can reach. # This script runs on the machine side; Cowork receives the JSON output and # can diff it against its own session's available connectors/plugins. cat > "$BRIDGE_ROOT/scripts/mcp_audit.sh" <<'MCPAUDIT' #!/usr/bin/env bash # mcp_audit.sh — enumerate MCPs registered in local Claude Code (all scopes). # # Motivation # ---------- # `claude mcp list` only shows one surface at a time. There is no built-in # tool to compare what's registered locally vs what a remote surface (e.g. # Cowork) can reach. This script captures the local side so Cowork can # produce a side-by-side diff of local MCPs vs Cowork-reachable connectors. # (ref: anthropics/claude-code#56353, labeled area:mcp + enhancement) # # Usage from Cowork # ----------------- # r = call_remote("scripts/mcp_audit.sh") # # r["stdout"] contains JSON with the local MCP registry snapshot # # Output format # ------------- # {"claude_version":"...","mcps":[{"scope":"...","name":"...","type":"...","command":"..."},...]} # Falls back to {"claude_version":"...","mcps_raw":""} for older # Claude Code versions that do not support --output-format json on mcp list. set -uo pipefail find_claude() { local p; p="$(command -v claude 2>/dev/null || true)" if [[ -n "$p" && -x "$p" ]]; then echo "$p"; return 0; fi local cand for cand in /opt/homebrew/bin/claude /usr/local/bin/claude \ "$HOME/.local/bin/claude" "$HOME/.claude/bin/claude"; do [[ -x "$cand" ]] && { echo "$cand"; return 0; } done local appdir="$HOME/Library/Application Support/Claude/claude-code" if [[ -d "$appdir" ]]; then local b; b="$(find "$appdir" -maxdepth 4 -type f -name claude -perm -u+x 2>/dev/null | sort -V | tail -1)" [[ -n "$b" && -x "$b" ]] && { echo "$b"; return 0; } fi return 1 } CLAUDE_BIN="$(find_claude 2>/dev/null || true)" if [[ -z "${CLAUDE_BIN:-}" ]]; then printf '{"error":"claude CLI not found — install it: curl -fsSL https://claude.ai/install.sh | bash","mcps":[]}\n' exit 127 fi CLAUDE_VERSION="$("$CLAUDE_BIN" --version 2>/dev/null | head -1 | tr -d '\n' || echo 'unknown')" HOSTNAME_VAL="$(hostname 2>/dev/null || echo 'unknown')" TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo 'unknown')" # Try JSON output (supported in Claude Code >= 1.x with mcp list --output-format). # If the flag is unrecognised, fall back to plain-text and wrap it. if MCP_JSON="$("$CLAUDE_BIN" mcp list --output-format json 2>/dev/null)" && \ echo "$MCP_JSON" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then # Wrap with audit metadata so Cowork has context alongside the raw list. python3 - "$CLAUDE_VERSION" "$HOSTNAME_VAL" "$TIMESTAMP" "$MCP_JSON" <<'PY' import json, sys version, host, ts, raw = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] mcps = json.loads(raw) if isinstance(json.loads(raw), list) else json.loads(raw).get("mcps", json.loads(raw)) print(json.dumps({ "claude_version": version, "hostname": host, "timestamp": ts, "mcp_count": len(mcps) if isinstance(mcps, list) else "unknown", "mcps": mcps, })) PY else # Older Claude Code: plain-text fallback. MCP_TEXT="$("$CLAUDE_BIN" mcp list 2>&1 || echo '(no MCPs registered or command failed)')" python3 - "$CLAUDE_VERSION" "$HOSTNAME_VAL" "$TIMESTAMP" "$MCP_TEXT" <<'PY' import json, sys version, host, ts, text = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] print(json.dumps({ "claude_version": version, "hostname": host, "timestamp": ts, "note": "plain-text fallback (upgrade claude CLI for structured JSON output)", "mcps_raw": text, })) PY fi MCPAUDIT chmod +x "$BRIDGE_ROOT/scripts/mcp_audit.sh" c_green " ✓ scripts installed: ping, hello, run_claude, mac_health, mac_ram, mac_disk, mac_top, mac_network, port_check, docker_ps, docker_logs, pkg_outdated, git_status, list_scripts, env_check, disk_hogs, open_browser, request_cowork, process_kill, mcp_proxy, mcp_register, mcp_list_servers, mcp_audit" # ─── 5b. Fetch the single-file Cowork client (one source of truth) ─────────── # bridge_client.py is the EXACT file the Cowork sandbox imports. To avoid drift, # we fetch the canonical copy from the repo at install time (the Mac has network # — this runs in the user's terminal, not the sandbox). Falls back to the # installed package's client if the fetch fails offline. CLIENT_URL="https://raw.githubusercontent.com/$REPO/main/bridge_client.py" if curl -fsSL "$CLIENT_URL" -o "$BRIDGE_ROOT/bridge_client.py" 2>/dev/null \ && head -1 "$BRIDGE_ROOT/bridge_client.py" | grep -q "bridge_client"; then c_green " ✓ Cowork client fetched to $BRIDGE_ROOT/bridge_client.py" else # Offline fallback: copy from the just-installed package. PKG_CLIENT="$("$PY" -c 'import cowork_to_code_bridge, os; print(os.path.join(os.path.dirname(cowork_to_code_bridge.__file__), "client.py"))' 2>/dev/null || true)" if [[ -n "$PKG_CLIENT" && -f "$PKG_CLIENT" ]]; then cp "$PKG_CLIENT" "$BRIDGE_ROOT/bridge_client.py" c_yellow " ! fetch failed; used the installed package client as fallback" else c_red " ✗ could not obtain bridge_client.py (no network and no package copy)." exit 1 fi fi # ─── 5c. Install the GLOBAL Cowork skill (~/.claude/skills) ────────────────── # This is what makes the bridge work in EVERY Cowork session, every project, # with zero fetch / zero paste / zero popups: a skill in ~/.claude/skills auto- # loads into all sessions. We drop the SKILL.md + the client + a tiny config # pointing at BRIDGE_ROOT. No /plugin, no marketplace. step "Installing the global Cowork skill" SKILLS_DIR="$HOME/.claude/skills/cowork-to-code-bridge" mkdir -p "$SKILLS_DIR" # The client (same single file) lives next to the skill so Cowork can import it. cp "$BRIDGE_ROOT/bridge_client.py" "$SKILLS_DIR/bridge_client.py" # Config so the skill knows BRIDGE_ROOT without the user pasting a path. printf '{ "BRIDGE_ROOT": "%s" }\n' "$BRIDGE_ROOT" > "$SKILLS_DIR/bridge_env.json" cat > "$SKILLS_DIR/SKILL.md" </projects/app"], timeout=600, idempotency_key="unique-key-per-task") print(r["exit_code"]); print(r["stdout"]) \`\`\` Always pass a unique \`idempotency_key\` — Claude Code tasks have side effects, so a retry must not run twice. ### Per-task cost cap Pass \`max_budget_usd=2.00\` to stop the agent when that amount is spent. The owner can set \`BRIDGE_MAX_BUDGET_USD\` as a global ceiling that can never be exceeded regardless of what Cowork sends (effective = min(caller, owner)). ## Step 3 — quick system checks (no agent) \`call_remote("scripts/mac_health.sh")\` · \`mac_ram.sh\` · \`mac_disk.sh\` · \`mac_top.sh\` · \`mac_network.sh\` · \`port_check.sh\` · \`docker_ps.sh\` · \`docker_logs.sh\` · \`pkg_outdated.sh\` · \`git_status.sh \` ## Results Dict with exit_code/stdout/stderr. Codes: -1 refused, -2 timeout, -3 internal, -4 crashed-midrun (indeterminate, not retried). \`idempotent_replay: True\` = cached. Be brief with the user; never claim success without exit_code 0 / BRIDGE LIVE. SKILLMD c_green " ✓ global skill installed at $SKILLS_DIR (loads in every Cowork session)" # ─── 5d. Drop a CLAUDE.md INTO the bridge folder ───────────────────────────── # When a Cowork session mounts BRIDGE_ROOT, this note tells the agent exactly # how to use the bridge — so the connection is self-documenting after the mount. cat > "$BRIDGE_ROOT/CLAUDE.md" <", ""], timeout=600, idempotency_key="") print(r["exit_code"]); print(r["stdout"]) \`\`\` Always pass a unique idempotency_key (tasks have side effects). For long builds, use call_remote_streaming(..., on_progress=cb). For a live spinner/ticker: \`\`\`python def on_status(s): sp = "⣾⣽⣻⢿⡿⣟⣯⣷" print(f"\r {sp[s['elapsed_s']%8]} {s['last_line'][:60]}… ({s['elapsed_s']}s)", end="", flush=True) call_remote_streaming("scripts/run_claude.sh", args=[...], timeout=900, on_status=on_status) \`\`\` Status dict keys: elapsed_s (int), last_line (str), state ("running"|"done"|"error"). ## Quick checks (no agent) scripts/mac_health.sh · mac_ram.sh · mac_disk.sh · mac_top.sh · mac_network.sh · port_check.sh · docker_ps.sh · docker_logs.sh · pkg_outdated.sh · git_status.sh Results: dict with exit_code/stdout/stderr (-1 refused, -2 timeout, -3 internal, -4 crashed). Never claim success without exit_code 0 / BRIDGE LIVE. CLAUDEMD c_green " ✓ wrote $BRIDGE_ROOT/CLAUDE.md (self-documents the bridge once mounted)" # ─── 6. Install + start the daemon as a per-user service ───────────────────── step "Installing background service ($SERVICE_MGR, auto-start + reboot-safe)" # Resolve the daemon entry point to an ABSOLUTE path. Prefer the installed # console script under the per-user scripts dir (independent of PATH). DAEMON_ARGS=() CONSOLE_SCRIPT="$USER_SCRIPTS_DIR/cowork-to-code-bridge-daemon" if [[ -x "$CONSOLE_SCRIPT" ]]; then DAEMON_ARGS=("$CONSOLE_SCRIPT") elif command -v cowork-to-code-bridge-daemon >/dev/null 2>&1; then DAEMON_ARGS=("$(command -v cowork-to-code-bridge-daemon)") elif "$PY" -c "import cowork_to_code_bridge.daemon" 2>/dev/null; then DAEMON_ARGS=("$PY" "-m" "cowork_to_code_bridge.daemon") else c_red " ✗ cowork-to-code-bridge daemon module not found after install" exit 1 fi c_green " ✓ daemon entry: ${DAEMON_ARGS[*]}" LABEL="dev.cowork-to-code-bridge.daemon" daemon_up=0 if [[ "$SERVICE_MGR" == "launchd" ]]; then # ── macOS: launchd user agent ────────────────────────────────────────────── mkdir -p "$(dirname "$PLIST")" { echo '' echo '' echo '' echo '' echo " Label$LABEL" echo ' ProgramArguments' echo ' ' for arg in "${DAEMON_ARGS[@]}"; do safe=$(printf '%s' "$arg" | sed -e 's/&/\&/g' -e 's//\>/g') echo " $safe" done echo ' ' echo ' EnvironmentVariables' echo ' ' echo " BRIDGE_ROOT$BRIDGE_ROOT" echo " PATH$USER_SCRIPTS_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" echo ' ' echo ' RunAtLoad' echo ' KeepAlive' echo " StandardOutPath$DAEMON_LOG" echo " StandardErrorPath$DAEMON_ERR" echo " WorkingDirectory$BRIDGE_ROOT" echo '' echo '' } > "$PLIST" c_green " ✓ plist written: $PLIST" UID_NUM="$(id -u)" DOMAIN="gui/$UID_NUM" if launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1; then c_yellow " → bootout existing agent" launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true fi launchctl unload "$PLIST" 2>/dev/null || true if launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null; then c_green " ✓ launchctl bootstrap $DOMAIN succeeded" else c_yellow " ! launchctl bootstrap failed — falling back to legacy load -w" launchctl load -w "$PLIST" fi launchctl enable "$DOMAIN/$LABEL" 2>/dev/null || true sleep 2 step "Verifying daemon" if launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || \ launchctl list 2>/dev/null | grep -q "$LABEL"; then c_green " ✓ daemon registered with launchd" else c_red " ✗ daemon failed to register" tail -20 "$DAEMON_ERR" 2>/dev/null || true exit 1 fi elif [[ "$SERVICE_MGR" == "systemd" ]]; then # ── Linux: systemd --user unit ───────────────────────────────────────────── UNIT_DIR="$HOME/.config/systemd/user" UNIT="$UNIT_DIR/cowork-to-code-bridge.service" mkdir -p "$UNIT_DIR" # Build ExecStart with each arg shell-quoted. EXECSTART="" for arg in "${DAEMON_ARGS[@]}"; do EXECSTART+="'$arg' "; done { echo '[Unit]' echo 'Description=cowork-to-code-bridge daemon (connect Claude to this machine)' echo 'After=default.target' echo echo '[Service]' echo 'Type=simple' echo "Environment=BRIDGE_ROOT=$BRIDGE_ROOT" echo "Environment=PATH=$USER_SCRIPTS_DIR:/usr/local/bin:/usr/bin:/bin" echo "WorkingDirectory=$BRIDGE_ROOT" echo "ExecStart=/bin/sh -lc \"exec $EXECSTART\"" echo 'Restart=always' echo 'RestartSec=2' echo "StandardOutput=append:$DAEMON_LOG" echo "StandardError=append:$DAEMON_ERR" echo echo '[Install]' echo 'WantedBy=default.target' } > "$UNIT" c_green " ✓ systemd unit written: $UNIT" # Enable lingering so the user service survives logout and starts at boot. linger_ok=0 if command -v loginctl >/dev/null 2>&1; then if loginctl enable-linger "$(id -un)" 2>/dev/null; then c_green " ✓ lingering enabled (survives logout/reboot)" linger_ok=1 else c_yellow " ! could not enable lingering (service still runs while logged in)" fi fi if [[ "$IS_WSL" -eq 1 ]] && [[ "$linger_ok" -eq 0 ]]; then c_yellow " ! On WSL2, lingering may not survive Windows sleep/reboot like on a server;" c_yellow " the daemon runs while your WSL session is up." fi systemctl --user daemon-reload 2>/dev/null || true systemctl --user enable --now cowork-to-code-bridge.service 2>/dev/null \ || systemctl --user restart cowork-to-code-bridge.service 2>/dev/null || true sleep 2 step "Verifying daemon" if systemctl --user is-active --quiet cowork-to-code-bridge.service; then c_green " ✓ daemon active under systemd --user" else c_red " ✗ systemd service is not active" systemctl --user status cowork-to-code-bridge.service --no-pager 2>/dev/null | tail -15 || true tail -20 "$DAEMON_ERR" 2>/dev/null || true exit 1 fi else # ── Linux: manual daemon (no systemd user bus) ─────────────────────────── mkdir -p "$BRIDGE_ROOT/lib" if [[ -n "$_INSTALL_DIR" && -f "$_INSTALL_DIR/scripts/lib/daemon_service.sh" ]]; then cp "$_INSTALL_DIR/scripts/lib/daemon_service.sh" "$BRIDGE_ROOT/lib/daemon_service.sh" elif ! curl -fsSL "https://raw.githubusercontent.com/$REPO/main/scripts/lib/daemon_service.sh" \ -o "$BRIDGE_ROOT/lib/daemon_service.sh" 2>/dev/null; then c_red " ✗ could not install daemon_service.sh (offline?)" exit 1 fi if ! declare -F bridge_start_daemon_manual >/dev/null 2>&1; then # shellcheck source=/dev/null source "$BRIDGE_ROOT/lib/daemon_service.sh" fi START_SCRIPT="$BRIDGE_ROOT/start-daemon.sh" { echo '#!/usr/bin/env bash' echo '# start-daemon.sh — start the bridge daemon (non-systemd Linux).' echo 'set -euo pipefail' echo "BRIDGE_ROOT=\"$BRIDGE_ROOT\"" echo "export BRIDGE_ROOT" echo "DAEMON_LOG=\"$DAEMON_LOG\"" echo "DAEMON_ERR=\"$DAEMON_ERR\"" echo "USER_SCRIPTS_DIR=\"$USER_SCRIPTS_DIR\"" echo 'export PATH="'"$USER_SCRIPTS_DIR"':$PATH:/usr/local/bin:/usr/bin:/bin"' echo 'DAEMON_ARGS=(' for arg in "${DAEMON_ARGS[@]}"; do printf ' %q\n' "$arg" done echo ')' echo 'source "$BRIDGE_ROOT/lib/daemon_service.sh"' echo 'bridge_start_daemon_manual' } > "$START_SCRIPT" chmod +x "$START_SCRIPT" c_green " ✓ wrote $START_SCRIPT" if bridge_start_daemon_manual; then c_green " ✓ daemon started (setsid/nohup, pid in $BRIDGE_ROOT/daemon.pid)" else c_red " ✗ failed to start daemon manually" tail -20 "$DAEMON_ERR" 2>/dev/null || true exit 1 fi if bridge_install_cron_reboot "$START_SCRIPT"; then c_green " ✓ @reboot cron entry installed (survives reboot when crond runs)" else c_yellow " ! no @reboot cron (crontab missing or BRIDGE_SKIP_CRON=1)" c_yellow " After reboot, run: $START_SCRIPT" c_yellow " Guide: $NO_SYSTEMD_DOC" fi step "Verifying daemon" if bridge_manual_daemon_running; then c_green " ✓ daemon process running (manual)" else c_red " ✗ manual daemon is not running" tail -20 "$DAEMON_ERR" 2>/dev/null || true exit 1 fi fi # Wait up to 20s for the daemon to log its first heartbeat (cold caches are slow). for i in $(seq 1 20); do if [[ -f "$DAEMON_LOG" ]] && grep -q "daemon up" "$DAEMON_LOG" 2>/dev/null; then c_green " ✓ daemon log shows 'daemon up' (after ${i}s)" daemon_up=1 break fi sleep 1 done if [[ "$daemon_up" -eq 0 ]]; then c_yellow " ! daemon hasn't logged 'daemon up' within 20s — last stderr lines:" tail -20 "$DAEMON_ERR" 2>/dev/null || true c_yellow " (Not aborting — the daemon may still come up. Check $DAEMON_LOG manually.)" fi # ─── 8. PATH hygiene ───────────────────────────────────────────────────────── step "Checking PATH for $USER_SCRIPTS_DIR" path_has_dir=0 case ":$PATH:" in *":$USER_SCRIPTS_DIR:"*) path_has_dir=1 ;; esac # Prefer bashrc on Linux/WSL; zshrc on macOS or when default shell is zsh. if [[ "$OS" == "Linux" ]] || [[ "$IS_WSL" -eq 1 ]]; then if [[ "${SHELL:-}" == *zsh* ]]; then SHELL_RC="$HOME/.zshrc" else SHELL_RC="$HOME/.bashrc" fi else SHELL_RC="$HOME/.zshrc" fi PATH_LINE="export PATH=\"$USER_SCRIPTS_DIR:\$PATH\" # cowork-to-code-bridge" if [[ "$path_has_dir" -eq 1 ]]; then c_green " ✓ $USER_SCRIPTS_DIR already on PATH" else c_yellow " ! $USER_SCRIPTS_DIR is NOT on your PATH." echo " The 'cowork-to-code-bridge-uninstall' and 'cowork-to-code-bridge-daemon'" echo " commands live there. To use them by bare name, add this to $SHELL_RC:" echo echo " $PATH_LINE" echo # Skip the prompt entirely when stdin isn't a TTY (e.g. curl | bash). if [[ -t 0 ]]; then read -r -p " Append this line to $SHELL_RC now? [y/N] " ans if [[ "$ans" =~ ^[Yy]$ ]]; then if [[ -f "$SHELL_RC" ]] && grep -Fq "$USER_SCRIPTS_DIR" "$SHELL_RC"; then c_yellow " (already referenced in $SHELL_RC — not appending duplicate)" else printf '\n# Added by cowork-to-code-bridge installer\n%s\n' "$PATH_LINE" >> "$SHELL_RC" c_green " ✓ appended to $SHELL_RC — open a new terminal or run: source $SHELL_RC" fi else c_yellow " skipped — add the export line manually when convenient." fi else c_yellow " (non-interactive shell — not prompting. Add the line above manually.)" fi fi # ─── 9. Done ───────────────────────────────────────────────────────────────── step "DONE. Bridge is installed and running." if [[ "$SERVICE_MGR" == "launchd" ]]; then VERIFY_CMD="launchctl print gui/$(id -u)/$LABEL" elif [[ "$SERVICE_MGR" == "systemd" ]]; then VERIFY_CMD="systemctl --user status cowork-to-code-bridge.service" else VERIFY_CMD="test -f $BRIDGE_ROOT/daemon.pid && kill -0 \$(cat $BRIDGE_ROOT/daemon.pid) && tail -3 $DAEMON_LOG" fi cat <