/ utils / claudeInChrome / setup.ts
setup.ts
  1  import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp'
  2  import { chmod, mkdir, readFile, writeFile } from 'fs/promises'
  3  import { homedir } from 'os'
  4  import { join } from 'path'
  5  import { fileURLToPath } from 'url'
  6  import {
  7    getIsInteractive,
  8    getIsNonInteractiveSession,
  9    getSessionBypassPermissionsMode,
 10  } from '../../bootstrap/state.js'
 11  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 12  import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
 13  import { isInBundledMode } from '../bundledMode.js'
 14  import { getGlobalConfig, saveGlobalConfig } from '../config.js'
 15  import { logForDebugging } from '../debug.js'
 16  import {
 17    getClaudeConfigHomeDir,
 18    isEnvDefinedFalsy,
 19    isEnvTruthy,
 20  } from '../envUtils.js'
 21  import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
 22  import { getPlatform } from '../platform.js'
 23  import { jsonStringify } from '../slowOperations.js'
 24  import {
 25    CLAUDE_IN_CHROME_MCP_SERVER_NAME,
 26    getAllBrowserDataPaths,
 27    getAllNativeMessagingHostsDirs,
 28    getAllWindowsRegistryKeys,
 29    openInChrome,
 30  } from './common.js'
 31  import { getChromeSystemPrompt } from './prompt.js'
 32  import { isChromeExtensionInstalledPortable } from './setupPortable.js'
 33  
 34  const CHROME_EXTENSION_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
 35  
 36  const NATIVE_HOST_IDENTIFIER = 'com.anthropic.claude_code_browser_extension'
 37  const NATIVE_HOST_MANIFEST_NAME = `${NATIVE_HOST_IDENTIFIER}.json`
 38  
 39  export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
 40    // Disable by default in non-interactive sessions (e.g., SDK, CI)
 41    if (getIsNonInteractiveSession() && chromeFlag !== true) {
 42      return false
 43    }
 44  
 45    // Check CLI flags
 46    if (chromeFlag === true) {
 47      return true
 48    }
 49    if (chromeFlag === false) {
 50      return false
 51    }
 52  
 53    // Check environment variables
 54    if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
 55      return true
 56    }
 57    if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
 58      return false
 59    }
 60  
 61    // Check default config settings
 62    const config = getGlobalConfig()
 63    if (config.claudeInChromeDefaultEnabled !== undefined) {
 64      return config.claudeInChromeDefaultEnabled
 65    }
 66  
 67    return false
 68  }
 69  
 70  let shouldAutoEnable: boolean | undefined = undefined
 71  
 72  export function shouldAutoEnableClaudeInChrome(): boolean {
 73    if (shouldAutoEnable !== undefined) {
 74      return shouldAutoEnable
 75    }
 76  
 77    shouldAutoEnable =
 78      getIsInteractive() &&
 79      isChromeExtensionInstalled_CACHED_MAY_BE_STALE() &&
 80      (process.env.USER_TYPE === 'ant' ||
 81        getFeatureValue_CACHED_MAY_BE_STALE('tengu_chrome_auto_enable', false))
 82  
 83    return shouldAutoEnable
 84  }
 85  
 86  /**
 87   * Setup Claude in Chrome MCP server and tools
 88   *
 89   * @returns MCP config and allowed tools, or throws an error if platform is unsupported
 90   */
 91  export function setupClaudeInChrome(): {
 92    mcpConfig: Record<string, ScopedMcpServerConfig>
 93    allowedTools: string[]
 94    systemPrompt: string
 95  } {
 96    const isNativeBuild = isInBundledMode()
 97    const allowedTools = BROWSER_TOOLS.map(
 98      tool => `mcp__claude-in-chrome__${tool.name}`,
 99    )
100  
101    const env: Record<string, string> = {}
102    if (getSessionBypassPermissionsMode()) {
103      env.CLAUDE_CHROME_PERMISSION_MODE = 'skip_all_permission_checks'
104    }
105    const hasEnv = Object.keys(env).length > 0
106  
107    if (isNativeBuild) {
108      // Create a wrapper script that calls the same binary with --chrome-native-host. This
109      // is needed because the native host manifest "path" field cannot contain arguments.
110      const execCommand = `"${process.execPath}" --chrome-native-host`
111  
112      // Run asynchronously without blocking; best-effort so swallow errors
113      void createWrapperScript(execCommand)
114        .then(manifestBinaryPath =>
115          installChromeNativeHostManifest(manifestBinaryPath),
116        )
117        .catch(e =>
118          logForDebugging(
119            `[Claude in Chrome] Failed to install native host: ${e}`,
120            { level: 'error' },
121          ),
122        )
123  
124      return {
125        mcpConfig: {
126          [CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
127            type: 'stdio' as const,
128            command: process.execPath,
129            args: ['--claude-in-chrome-mcp'],
130            scope: 'dynamic' as const,
131            ...(hasEnv && { env }),
132          },
133        },
134        allowedTools,
135        systemPrompt: getChromeSystemPrompt(),
136      }
137    } else {
138      const __filename = fileURLToPath(import.meta.url)
139      const __dirname = join(__filename, '..')
140      const cliPath = join(__dirname, 'cli.js')
141  
142      void createWrapperScript(
143        `"${process.execPath}" "${cliPath}" --chrome-native-host`,
144      )
145        .then(manifestBinaryPath =>
146          installChromeNativeHostManifest(manifestBinaryPath),
147        )
148        .catch(e =>
149          logForDebugging(
150            `[Claude in Chrome] Failed to install native host: ${e}`,
151            { level: 'error' },
152          ),
153        )
154  
155      const mcpConfig = {
156        [CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
157          type: 'stdio' as const,
158          command: process.execPath,
159          args: [`${cliPath}`, '--claude-in-chrome-mcp'],
160          scope: 'dynamic' as const,
161          ...(hasEnv && { env }),
162        },
163      }
164  
165      return {
166        mcpConfig,
167        allowedTools,
168        systemPrompt: getChromeSystemPrompt(),
169      }
170    }
171  }
172  
173  /**
174   * Get native messaging hosts directories for all supported browsers
175   * Returns an array of directories where the native host manifest should be installed
176   */
177  function getNativeMessagingHostsDirs(): string[] {
178    const platform = getPlatform()
179  
180    if (platform === 'windows') {
181      // Windows uses a single location with registry entries pointing to it
182      const home = homedir()
183      const appData = process.env.APPDATA || join(home, 'AppData', 'Local')
184      return [join(appData, 'Claude Code', 'ChromeNativeHost')]
185    }
186  
187    // macOS and Linux: return all browser native messaging directories
188    return getAllNativeMessagingHostsDirs().map(({ path }) => path)
189  }
190  
191  export async function installChromeNativeHostManifest(
192    manifestBinaryPath: string,
193  ): Promise<void> {
194    const manifestDirs = getNativeMessagingHostsDirs()
195    if (manifestDirs.length === 0) {
196      throw Error('Claude in Chrome Native Host not supported on this platform')
197    }
198  
199    const manifest = {
200      name: NATIVE_HOST_IDENTIFIER,
201      description: 'Claude Code Browser Extension Native Host',
202      path: manifestBinaryPath,
203      type: 'stdio',
204      allowed_origins: [
205        `chrome-extension://fcoeoabgfenejglbffodgkkbkcdhcgfn/`, // PROD_EXTENSION_ID
206        ...(process.env.USER_TYPE === 'ant'
207          ? [
208              'chrome-extension://dihbgbndebgnbjfmelmegjepbnkhlgni/', // DEV_EXTENSION_ID
209              'chrome-extension://dngcpimnedloihjnnfngkgjoidhnaolf/', // ANT_EXTENSION_ID
210            ]
211          : []),
212      ],
213    }
214  
215    const manifestContent = jsonStringify(manifest, null, 2)
216    let anyManifestUpdated = false
217  
218    // Install manifest to all browser directories
219    for (const manifestDir of manifestDirs) {
220      const manifestPath = join(manifestDir, NATIVE_HOST_MANIFEST_NAME)
221  
222      // Check if content matches to avoid unnecessary writes
223      const existingContent = await readFile(manifestPath, 'utf-8').catch(
224        () => null,
225      )
226      if (existingContent === manifestContent) {
227        continue
228      }
229  
230      try {
231        await mkdir(manifestDir, { recursive: true })
232        await writeFile(manifestPath, manifestContent)
233        logForDebugging(
234          `[Claude in Chrome] Installed native host manifest at: ${manifestPath}`,
235        )
236        anyManifestUpdated = true
237      } catch (error) {
238        // Log but don't fail - the browser might not be installed
239        logForDebugging(
240          `[Claude in Chrome] Failed to install manifest at ${manifestPath}: ${error}`,
241        )
242      }
243    }
244  
245    // Windows requires registry entries pointing to the manifest for each browser
246    if (getPlatform() === 'windows') {
247      const manifestPath = join(manifestDirs[0]!, NATIVE_HOST_MANIFEST_NAME)
248      registerWindowsNativeHosts(manifestPath)
249    }
250  
251    // Restart the native host if we have rewritten any manifest
252    if (anyManifestUpdated) {
253      void isChromeExtensionInstalled().then(isInstalled => {
254        if (isInstalled) {
255          logForDebugging(
256            `[Claude in Chrome] First-time install detected, opening reconnect page in browser`,
257          )
258          void openInChrome(CHROME_EXTENSION_RECONNECT_URL)
259        } else {
260          logForDebugging(
261            `[Claude in Chrome] First-time install detected, but extension not installed, skipping reconnect`,
262          )
263        }
264      })
265    }
266  }
267  
268  /**
269   * Register the native host in Windows registry for all supported browsers
270   */
271  function registerWindowsNativeHosts(manifestPath: string): void {
272    const registryKeys = getAllWindowsRegistryKeys()
273  
274    for (const { browser, key } of registryKeys) {
275      const fullKey = `${key}\\${NATIVE_HOST_IDENTIFIER}`
276      // Use reg.exe to add the registry entry
277      // https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging
278      void execFileNoThrowWithCwd('reg', [
279        'add',
280        fullKey,
281        '/ve', // Set the default (unnamed) value
282        '/t',
283        'REG_SZ',
284        '/d',
285        manifestPath,
286        '/f', // Force overwrite without prompt
287      ]).then(result => {
288        if (result.code === 0) {
289          logForDebugging(
290            `[Claude in Chrome] Registered native host for ${browser} in Windows registry: ${fullKey}`,
291          )
292        } else {
293          logForDebugging(
294            `[Claude in Chrome] Failed to register native host for ${browser} in Windows registry: ${result.stderr}`,
295          )
296        }
297      })
298    }
299  }
300  
301  /**
302   * Create a wrapper script in ~/.claude/chrome/ that invokes the given command. This is
303   * necessary because Chrome's native host manifest "path" field cannot contain arguments.
304   *
305   * @param command - The full command to execute (e.g., "/path/to/claude --chrome-native-host")
306   * @returns The path to the wrapper script
307   */
308  async function createWrapperScript(command: string): Promise<string> {
309    const platform = getPlatform()
310    const chromeDir = join(getClaudeConfigHomeDir(), 'chrome')
311    const wrapperPath =
312      platform === 'windows'
313        ? join(chromeDir, 'chrome-native-host.bat')
314        : join(chromeDir, 'chrome-native-host')
315  
316    const scriptContent =
317      platform === 'windows'
318        ? `@echo off
319  REM Chrome native host wrapper script
320  REM Generated by Claude Code - do not edit manually
321  ${command}
322  `
323        : `#!/bin/sh
324  # Chrome native host wrapper script
325  # Generated by Claude Code - do not edit manually
326  exec ${command}
327  `
328  
329    // Check if content matches to avoid unnecessary writes
330    const existingContent = await readFile(wrapperPath, 'utf-8').catch(() => null)
331    if (existingContent === scriptContent) {
332      return wrapperPath
333    }
334  
335    await mkdir(chromeDir, { recursive: true })
336    await writeFile(wrapperPath, scriptContent)
337  
338    if (platform !== 'windows') {
339      await chmod(wrapperPath, 0o755)
340    }
341  
342    logForDebugging(
343      `[Claude in Chrome] Created Chrome native host wrapper script: ${wrapperPath}`,
344    )
345    return wrapperPath
346  }
347  
348  /**
349   * Get cached value of whether Chrome extension is installed. Returns
350   * from disk cache immediately, updates cache in background.
351   *
352   * Use this for sync/startup-critical paths where blocking on filesystem
353   * access is not acceptable. The value may be stale if the cache hasn't
354   * been updated recently.
355   *
356   * Only positive detections are persisted. A negative result from the
357   * filesystem scan is not cached, because it may come from a machine that
358   * shares ~/.claude.json but has no local Chrome (e.g. a remote dev
359   * environment using the bridge), and caching it would permanently poison
360   * auto-enable for every session on every machine that reads that config.
361   */
362  function isChromeExtensionInstalled_CACHED_MAY_BE_STALE(): boolean {
363    // Update cache in background without blocking
364    void isChromeExtensionInstalled().then(isInstalled => {
365      // Only persist positive detections — see docstring. The cost of a stale
366      // `true` is one silent MCP connection attempt per session; the cost of a
367      // stale `false` is auto-enable never working again without manual repair.
368      if (!isInstalled) {
369        return
370      }
371      const config = getGlobalConfig()
372      if (config.cachedChromeExtensionInstalled !== isInstalled) {
373        saveGlobalConfig(prev => ({
374          ...prev,
375          cachedChromeExtensionInstalled: isInstalled,
376        }))
377      }
378    })
379  
380    // Return cached value immediately from disk
381    const cached = getGlobalConfig().cachedChromeExtensionInstalled
382    return cached ?? false
383  }
384  
385  /**
386   * Detects if the Claude in Chrome extension is installed by checking the Extensions
387   * directory across all supported Chromium-based browsers and their profiles.
388   *
389   * @returns Object with isInstalled boolean and the browser where the extension was found
390   */
391  export async function isChromeExtensionInstalled(): Promise<boolean> {
392    const browserPaths = getAllBrowserDataPaths()
393    if (browserPaths.length === 0) {
394      logForDebugging(
395        `[Claude in Chrome] Unsupported platform for extension detection: ${getPlatform()}`,
396      )
397      return false
398    }
399    return isChromeExtensionInstalledPortable(browserPaths, logForDebugging)
400  }