Skip to content

Instantly share code, notes, and snippets.

@jcasimir
Last active April 2, 2026 18:31
Show Gist options
  • Select an option

  • Save jcasimir/d22ede06bc00bd13319d1c334b109144 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--browser-url", "http://127.0.0.1:9223"]
}
}
}
<?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>
#!/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
#!/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
// 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