Last active
April 2, 2026 18:31
-
-
Save jcasimir/d22ede06bc00bd13319d1c334b109144 to your computer and use it in GitHub Desktop.
Claude Code + Chrome DevTools MCP: Per-profile browser control for AI coding agents
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -e | |
| # Claude Chrome — Dedicated Chrome instances for Claude Code profiles | |
| # https://jumpstartlab.com/writing/giving-claude-code-its-own-browser | |
| VERSION="1.0.0" | |
| BASE_DIR="$HOME/.chrome-debug" | |
| APP_DIR="$HOME/Applications" | |
| SCRIPT_DIR="$HOME/.local/bin" | |
| # Colors | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| NC='\033[0m' | |
| header() { | |
| echo "" | |
| echo -e "${BOLD}Claude Chrome${NC} v${VERSION}" | |
| echo -e "Dedicated Chrome instances for Claude Code profiles" | |
| echo "" | |
| } | |
| # ─── Install ────────────────────────────────────────────────────────────────── | |
| do_install() { | |
| echo -e "${CYAN}Setting up a new Chrome debug profile...${NC}" | |
| echo "" | |
| # Prompt for project details | |
| read -p "What should this profile be called? (e.g., Work, Freelance, Client): " PROJECT_NAME | |
| if [ -z "$PROJECT_NAME" ]; then | |
| echo -e "${RED}Profile name is required.${NC}" | |
| exit 1 | |
| fi | |
| PROJECT_SLUG=$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') | |
| read -p "Debug port [9223]: " DEBUG_PORT | |
| DEBUG_PORT=${DEBUG_PORT:-9223} | |
| # Check if port is already in use by another profile | |
| if [ -f "${BASE_DIR}/.ports" ] && grep -q ":${DEBUG_PORT}$" "${BASE_DIR}/.ports" 2>/dev/null; then | |
| EXISTING=$(grep ":${DEBUG_PORT}$" "${BASE_DIR}/.ports" | cut -d: -f1) | |
| echo -e "${RED}Port ${DEBUG_PORT} is already used by '${EXISTING}'.${NC}" | |
| echo "Pick a different port or uninstall '${EXISTING}' first." | |
| exit 1 | |
| fi | |
| # Claude config dir | |
| DEFAULT_CLAUDE_DIR="$HOME/.claude-${PROJECT_SLUG}" | |
| read -p "Claude config directory [${DEFAULT_CLAUDE_DIR}]: " CLAUDE_DIR | |
| CLAUDE_DIR=${CLAUDE_DIR:-"$DEFAULT_CLAUDE_DIR"} | |
| APP_NAME="${PROJECT_NAME} Chrome" | |
| APP_PATH="${APP_DIR}/${APP_NAME}.app" | |
| DATA_DIR="${BASE_DIR}/${PROJECT_SLUG}" | |
| echo "" | |
| echo -e "${BOLD}Ready to create:${NC}" | |
| echo -e " App: ${APP_PATH}" | |
| echo -e " Chrome data: ${DATA_DIR}" | |
| echo -e " Debug port: ${DEBUG_PORT}" | |
| echo -e " Claude dir: ${CLAUDE_DIR}" | |
| echo -e " CLI command: ${PROJECT_SLUG}" | |
| echo "" | |
| read -p "Look good? [Y/n] " CONFIRM | |
| CONFIRM=${CONFIRM:-Y} | |
| [[ "$CONFIRM" =~ ^[Yy] ]] || { echo "Cancelled."; exit 0; } | |
| echo "" | |
| # Create data directory | |
| mkdir -p "${DATA_DIR}" | |
| echo -e " ${GREEN}✓${NC} Chrome data directory" | |
| # Create .app bundle | |
| mkdir -p "${APP_PATH}/Contents/MacOS" | |
| mkdir -p "${APP_PATH}/Contents/Resources" | |
| cat > "${APP_PATH}/Contents/MacOS/launch" << LAUNCHER | |
| #!/bin/bash | |
| /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \\ | |
| --user-data-dir="\$HOME/.chrome-debug/${PROJECT_SLUG}" \\ | |
| --profile-directory="Default" \\ | |
| --remote-debugging-port=${DEBUG_PORT} \\ | |
| "\$@" & | |
| wait | |
| LAUNCHER | |
| chmod +x "${APP_PATH}/Contents/MacOS/launch" | |
| cat > "${APP_PATH}/Contents/Info.plist" << PLIST | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>CFBundleName</key> | |
| <string>${APP_NAME}</string> | |
| <key>CFBundleDisplayName</key> | |
| <string>${APP_NAME}</string> | |
| <key>CFBundleIdentifier</key> | |
| <string>com.claude-chrome.${PROJECT_SLUG}</string> | |
| <key>CFBundleVersion</key> | |
| <string>1.0</string> | |
| <key>CFBundleExecutable</key> | |
| <string>launch</string> | |
| <key>CFBundleIconFile</key> | |
| <string>app.icns</string> | |
| <key>CFBundlePackageType</key> | |
| <string>APPL</string> | |
| <key>LSMinimumSystemVersion</key> | |
| <string>10.15</string> | |
| <key>NSHighResolutionCapable</key> | |
| <true/> | |
| </dict> | |
| </plist> | |
| PLIST | |
| echo -e " ${GREEN}✓${NC} App bundle" | |
| # Tint Chrome icon | |
| TMPICON=$(mktemp -d) | |
| mkdir -p "${TMPICON}/chrome.iconset" "${TMPICON}/tinted.iconset" | |
| iconutil -c iconset -o "${TMPICON}/chrome.iconset" \ | |
| /Applications/Google\ Chrome.app/Contents/Resources/app.icns 2>/dev/null | |
| cat > "${TMPICON}/tint.swift" << 'SWIFT' | |
| import Cocoa | |
| let args = CommandLine.arguments | |
| guard args.count == 3, let img = NSImage(contentsOfFile: args[1]) else { exit(1) } | |
| let s = img.size; let out = NSImage(size: s); out.lockFocus() | |
| img.draw(in: NSRect(origin: .zero, size: s)) | |
| NSColor(red: 0.85, green: 0.55, blue: 0.1, alpha: 0.45).setFill() | |
| NSRect(origin: .zero, size: s).fill(using: .sourceAtop) | |
| out.unlockFocus() | |
| guard let t = out.tiffRepresentation, let b = NSBitmapImageRep(data: t), | |
| let p = b.representation(using: .png, properties: [:]) else { exit(1) } | |
| try! p.write(to: URL(fileURLWithPath: args[2])) | |
| SWIFT | |
| if swiftc "${TMPICON}/tint.swift" -o "${TMPICON}/tint" -framework Cocoa 2>/dev/null; then | |
| for f in "${TMPICON}/chrome.iconset/"*.png; do | |
| name=$(basename "$f") | |
| "${TMPICON}/tint" "$f" "${TMPICON}/tinted.iconset/$name" 2>/dev/null | |
| done | |
| iconutil -c icns -o "${APP_PATH}/Contents/Resources/app.icns" "${TMPICON}/tinted.iconset" 2>/dev/null | |
| echo -e " ${GREEN}✓${NC} Tinted icon" | |
| else | |
| # Fallback: copy Chrome's icon as-is | |
| cp /Applications/Google\ Chrome.app/Contents/Resources/app.icns \ | |
| "${APP_PATH}/Contents/Resources/app.icns" | |
| echo -e " ${YELLOW}~${NC} Icon (couldn't tint — using Chrome's default)" | |
| fi | |
| rm -rf "${TMPICON}" | |
| # Register the app with macOS | |
| /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ | |
| -f "${APP_PATH}" 2>/dev/null | |
| echo -e " ${GREEN}✓${NC} Registered with macOS" | |
| # Configure Claude MCP | |
| mkdir -p "${CLAUDE_DIR}" | |
| if [ -f "${CLAUDE_DIR}/.claude.json" ]; then | |
| python3 -c " | |
| import json | |
| with open('${CLAUDE_DIR}/.claude.json') as f: cfg = json.load(f) | |
| cfg.setdefault('mcpServers', {})['chrome-devtools'] = { | |
| 'type': 'stdio', 'command': 'npx', | |
| 'args': ['-y', 'chrome-devtools-mcp@latest', '--browser-url', 'http://127.0.0.1:${DEBUG_PORT}'] | |
| } | |
| with open('${CLAUDE_DIR}/.claude.json', 'w') as f: json.dump(cfg, f, indent=2) | |
| " | |
| else | |
| cat > "${CLAUDE_DIR}/.claude.json" << MCPCFG | |
| { | |
| "mcpServers": { | |
| "chrome-devtools": { | |
| "type": "stdio", | |
| "command": "npx", | |
| "args": ["-y", "chrome-devtools-mcp@latest", "--browser-url", "http://127.0.0.1:${DEBUG_PORT}"] | |
| } | |
| } | |
| } | |
| MCPCFG | |
| fi | |
| echo -e " ${GREEN}✓${NC} Claude MCP config" | |
| # Create orchestrator script | |
| mkdir -p "${SCRIPT_DIR}" | |
| cat > "${SCRIPT_DIR}/${PROJECT_SLUG}" << SCRIPT | |
| #!/bin/bash | |
| # Launch ${PROJECT_NAME} environment: normal Chrome + debug Chrome + Claude Code | |
| if ! pgrep -f "Google Chrome" > /dev/null 2>&1; then | |
| echo "Starting Chrome..." | |
| open -a "Google Chrome" --args --profile-directory="Default" | |
| sleep 2 | |
| fi | |
| if ! curl -s "http://127.0.0.1:${DEBUG_PORT}/json/version" > /dev/null 2>&1; then | |
| echo "Starting ${APP_NAME}..." | |
| open -a "${APP_NAME}" | |
| echo "Waiting for debug Chrome..." | |
| for i in \$(seq 1 10); do | |
| if curl -s "http://127.0.0.1:${DEBUG_PORT}/json/version" > /dev/null 2>&1; then | |
| echo "Chrome ready on port ${DEBUG_PORT}" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| fi | |
| CLAUDE_CONFIG_DIR=${CLAUDE_DIR} claude | |
| SCRIPT | |
| chmod +x "${SCRIPT_DIR}/${PROJECT_SLUG}" | |
| echo -e " ${GREEN}✓${NC} CLI command: ${PROJECT_SLUG}" | |
| # Track the installation | |
| mkdir -p "${BASE_DIR}" | |
| echo "${PROJECT_SLUG}:${DEBUG_PORT}:${CLAUDE_DIR}:${APP_NAME}" >> "${BASE_DIR}/.installs" | |
| echo "" | |
| echo -e "${GREEN}${BOLD}Done!${NC}" | |
| echo "" | |
| echo -e "To start everything: ${BOLD}${PROJECT_SLUG}${NC}" | |
| echo -e " (make sure ~/.local/bin is on your PATH)" | |
| echo "" | |
| echo -e "Or launch the browser directly: ${BOLD}open -a '${APP_NAME}'${NC}" | |
| echo "" | |
| echo -e "${YELLOW}First launch:${NC} Sign into your Google account and install" | |
| echo "any extensions you need. They'll persist across restarts." | |
| } | |
| # ─── Uninstall ──────────────────────────────────────────────────────────────── | |
| do_uninstall() { | |
| if [ ! -f "${BASE_DIR}/.installs" ] || [ ! -s "${BASE_DIR}/.installs" ]; then | |
| echo "No profiles installed." | |
| exit 0 | |
| fi | |
| echo -e "${CYAN}Installed profiles:${NC}" | |
| echo "" | |
| i=1 | |
| while IFS=: read -r slug port claude_dir app_name; do | |
| echo " ${i}) ${slug} (port ${port})" | |
| i=$((i + 1)) | |
| done < "${BASE_DIR}/.installs" | |
| echo "" | |
| read -p "Which profile to uninstall? (number or name): " CHOICE | |
| # Resolve choice to slug | |
| if [[ "$CHOICE" =~ ^[0-9]+$ ]]; then | |
| LINE=$(sed -n "${CHOICE}p" "${BASE_DIR}/.installs") | |
| else | |
| LINE=$(grep "^${CHOICE}:" "${BASE_DIR}/.installs" | head -1) | |
| fi | |
| if [ -z "$LINE" ]; then | |
| echo -e "${RED}Not found.${NC}" | |
| exit 1 | |
| fi | |
| IFS=: read -r SLUG PORT CLAUDE_DIR APP_NAME <<< "$LINE" | |
| APP_PATH="${APP_DIR}/${APP_NAME}.app" | |
| DATA_DIR="${BASE_DIR}/${SLUG}" | |
| echo "" | |
| echo -e "${BOLD}This will remove:${NC}" | |
| echo " App: ${APP_PATH}" | |
| echo " Chrome data: ${DATA_DIR}" | |
| echo " CLI command: ${SCRIPT_DIR}/${SLUG}" | |
| echo "" | |
| echo -e " ${YELLOW}Note:${NC} Claude config at ${CLAUDE_DIR} will NOT be deleted" | |
| echo " (only the chrome-devtools MCP entry will be removed from it)." | |
| echo "" | |
| read -p "Continue? [y/N] " CONFIRM | |
| CONFIRM=${CONFIRM:-N} | |
| [[ "$CONFIRM" =~ ^[Yy] ]] || { echo "Cancelled."; exit 0; } | |
| echo "" | |
| # Kill any running debug Chrome on this port | |
| if curl -s "http://127.0.0.1:${PORT}/json/version" > /dev/null 2>&1; then | |
| pkill -f "chrome-debug/${SLUG}" 2>/dev/null | |
| echo -e " ${GREEN}✓${NC} Stopped debug Chrome" | |
| sleep 1 | |
| fi | |
| # Remove app | |
| if [ -d "$APP_PATH" ]; then | |
| rm -rf "$APP_PATH" | |
| echo -e " ${GREEN}✓${NC} Removed app" | |
| fi | |
| # Remove data dir | |
| if [ -d "$DATA_DIR" ]; then | |
| rm -rf "$DATA_DIR" | |
| echo -e " ${GREEN}✓${NC} Removed Chrome data" | |
| fi | |
| # Remove CLI script | |
| if [ -f "${SCRIPT_DIR}/${SLUG}" ]; then | |
| rm "${SCRIPT_DIR}/${SLUG}" | |
| echo -e " ${GREEN}✓${NC} Removed CLI command" | |
| fi | |
| # Remove MCP config from Claude | |
| if [ -f "${CLAUDE_DIR}/.claude.json" ]; then | |
| python3 -c " | |
| import json | |
| with open('${CLAUDE_DIR}/.claude.json') as f: cfg = json.load(f) | |
| if 'chrome-devtools' in cfg.get('mcpServers', {}): | |
| del cfg['mcpServers']['chrome-devtools'] | |
| with open('${CLAUDE_DIR}/.claude.json', 'w') as f: json.dump(cfg, f, indent=2) | |
| " 2>/dev/null | |
| echo -e " ${GREEN}✓${NC} Removed MCP config" | |
| fi | |
| # Remove from installs tracking | |
| grep -v "^${SLUG}:" "${BASE_DIR}/.installs" > "${BASE_DIR}/.installs.tmp" 2>/dev/null | |
| mv "${BASE_DIR}/.installs.tmp" "${BASE_DIR}/.installs" | |
| echo "" | |
| echo -e "${GREEN}${BOLD}Uninstalled '${SLUG}'.${NC}" | |
| } | |
| # ─── Status ─────────────────────────────────────────────────────────────────── | |
| do_status() { | |
| if [ ! -f "${BASE_DIR}/.installs" ] || [ ! -s "${BASE_DIR}/.installs" ]; then | |
| echo "No profiles installed." | |
| exit 0 | |
| fi | |
| echo -e "${BOLD}Installed profiles:${NC}" | |
| echo "" | |
| while IFS=: read -r slug port claude_dir app_name; do | |
| app_path="${APP_DIR}/${app_name}.app" | |
| # Check if debug Chrome is running on this port | |
| if curl -s "http://127.0.0.1:${port}/json/version" > /dev/null 2>&1; then | |
| status="${GREEN}running${NC}" | |
| else | |
| status="${YELLOW}stopped${NC}" | |
| fi | |
| echo -e " ${BOLD}${slug}${NC}" | |
| echo -e " Status: ${status}" | |
| echo -e " Port: ${port}" | |
| echo -e " App: ${app_path}" | |
| echo -e " Claude dir: ${claude_dir}" | |
| echo "" | |
| done < "${BASE_DIR}/.installs" | |
| } | |
| # ─── Main ───────────────────────────────────────────────────────────────────── | |
| header | |
| if [ -n "$1" ]; then | |
| ACTION="$1" | |
| else | |
| echo " 1) Install a new profile" | |
| echo " 2) Uninstall a profile" | |
| echo " 3) Check status" | |
| echo "" | |
| read -p "What would you like to do? [1/2/3]: " ACTION_NUM | |
| case "$ACTION_NUM" in | |
| 1) ACTION="install" ;; | |
| 2) ACTION="uninstall" ;; | |
| 3) ACTION="status" ;; | |
| *) echo "Invalid choice."; exit 1 ;; | |
| esac | |
| fi | |
| echo "" | |
| case "$ACTION" in | |
| install) do_install ;; | |
| uninstall) do_uninstall ;; | |
| status) do_status ;; | |
| *) | |
| echo "Usage: claude-chrome [install|uninstall|status]" | |
| exit 1 | |
| ;; | |
| esac |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "mcpServers": { | |
| "chrome-devtools": { | |
| "type": "stdio", | |
| "command": "npx", | |
| "args": ["-y", "chrome-devtools-mcp@latest", "--browser-url", "http://127.0.0.1:9223"] | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>CFBundleName</key> | |
| <string>Project Chrome</string> | |
| <key>CFBundleDisplayName</key> | |
| <string>Project Chrome</string> | |
| <key>CFBundleIdentifier</key> | |
| <string>com.yourname.chrome-debug</string> | |
| <key>CFBundleVersion</key> | |
| <string>1.0</string> | |
| <key>CFBundleExecutable</key> | |
| <string>launch</string> | |
| <key>CFBundleIconFile</key> | |
| <string>app.icns</string> | |
| <key>CFBundlePackageType</key> | |
| <string>APPL</string> | |
| <key>LSMinimumSystemVersion</key> | |
| <string>10.15</string> | |
| <key>NSHighResolutionCapable</key> | |
| <true/> | |
| </dict> | |
| </plist> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # App wrapper launcher — goes in YourApp.app/Contents/MacOS/launch | |
| # Launches Chrome with a dedicated debug profile on a specific port | |
| /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ | |
| --user-data-dir="$HOME/.chrome-debug/myproject" \ | |
| --profile-directory="Default" \ | |
| --remote-debugging-port=9223 \ | |
| "$@" & | |
| wait |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # One command to launch everything: normal Chrome, debug Chrome, and Claude Code | |
| # Customize PROJECT_NAME, DEBUG_PORT, and CLAUDE_CONFIG_DIR for your setup | |
| PROJECT_NAME="Project Chrome" | |
| DEBUG_PORT=9223 | |
| # Ensure normal Chrome is running first (so Spotlight/Dock opens the real one) | |
| if ! pgrep -f "Google Chrome" > /dev/null 2>&1; then | |
| echo "Starting Chrome..." | |
| open -a "Google Chrome" --args --profile-directory="Default" | |
| sleep 2 | |
| fi | |
| # Start debug Chrome if not already running | |
| if ! curl -s "http://127.0.0.1:${DEBUG_PORT}/json/version" > /dev/null 2>&1; then | |
| echo "Starting ${PROJECT_NAME}..." | |
| open -a "${PROJECT_NAME}" | |
| echo "Waiting for debug Chrome..." | |
| for i in $(seq 1 10); do | |
| if curl -s "http://127.0.0.1:${DEBUG_PORT}/json/version" > /dev/null 2>&1; then | |
| echo "Chrome ready on port ${DEBUG_PORT}" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| fi | |
| # Launch Claude Code with your project profile | |
| CLAUDE_CONFIG_DIR=~/.claude-myproject claude --dangerously-skip-permissions |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Compile: swiftc tint-icon.swift -o tint-icon -framework Cocoa | |
| // Usage: ./tint-icon input.png output.png | |
| import Cocoa | |
| let args = CommandLine.arguments | |
| guard args.count == 3 else { | |
| print("Usage: tint-icon <input.png> <output.png>") | |
| exit(1) | |
| } | |
| guard let inputImage = NSImage(contentsOfFile: args[1]) else { | |
| print("Could not load \(args[1])") | |
| exit(1) | |
| } | |
| let size = inputImage.size | |
| let newImage = NSImage(size: size) | |
| newImage.lockFocus() | |
| inputImage.draw(in: NSRect(origin: .zero, size: size)) | |
| // Amber tint — change the RGBA values for a different color | |
| NSColor(red: 0.85, green: 0.55, blue: 0.1, alpha: 0.45).setFill() | |
| NSRect(origin: .zero, size: size).fill(using: .sourceAtop) | |
| newImage.unlockFocus() | |
| guard let tiffData = newImage.tiffRepresentation, | |
| let bitmap = NSBitmapImageRep(data: tiffData), | |
| let pngData = bitmap.representation(using: .png, properties: [:]) else { | |
| print("Could not convert image") | |
| exit(1) | |
| } | |
| try! pngData.write(to: URL(fileURLWithPath: args[2])) | |
| print("OK: \(args[2])") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment