/ utils / deepLink / protocolHandler.ts
protocolHandler.ts
  1  /**
  2   * Protocol Handler
  3   *
  4   * Entry point for `claude --handle-uri <url>`. When the OS invokes claude
  5   * with a `claude-cli://` URL, this module:
  6   *   1. Parses the URI into a structured action
  7   *   2. Detects the user's terminal emulator
  8   *   3. Opens a new terminal window running claude with the appropriate args
  9   *
 10   * This runs in a headless context (no TTY) because the OS launches the binary
 11   * directly — there is no terminal attached.
 12   */
 13  
 14  import { homedir } from 'os'
 15  import { logForDebugging } from '../debug.js'
 16  import {
 17    filterExistingPaths,
 18    getKnownPathsForRepo,
 19  } from '../githubRepoPathMapping.js'
 20  import { jsonStringify } from '../slowOperations.js'
 21  import { readLastFetchTime } from './banner.js'
 22  import { parseDeepLink } from './parseDeepLink.js'
 23  import { MACOS_BUNDLE_ID } from './registerProtocol.js'
 24  import { launchInTerminal } from './terminalLauncher.js'
 25  
 26  /**
 27   * Handle an incoming deep link URI.
 28   *
 29   * Called from the CLI entry point when `--handle-uri` is passed.
 30   * This function parses the URI, resolves the claude binary, and
 31   * launches it in the user's terminal.
 32   *
 33   * @param uri - The raw URI string (e.g., "claude-cli://prompt?q=hello+world")
 34   * @returns exit code (0 = success)
 35   */
 36  export async function handleDeepLinkUri(uri: string): Promise<number> {
 37    logForDebugging(`Handling deep link URI: ${uri}`)
 38  
 39    let action
 40    try {
 41      action = parseDeepLink(uri)
 42    } catch (error) {
 43      const message = error instanceof Error ? error.message : String(error)
 44      // biome-ignore lint/suspicious/noConsole: intentional error output
 45      console.error(`Deep link error: ${message}`)
 46      return 1
 47    }
 48  
 49    logForDebugging(`Parsed deep link action: ${jsonStringify(action)}`)
 50  
 51    // Always the running executable — no PATH lookup. The OS launched us via
 52    // an absolute path (bundle symlink / .desktop Exec= / registry command)
 53    // baked at registration time, and we want the terminal-launched Claude to
 54    // be the same binary. process.execPath is that binary.
 55    const { cwd, resolvedRepo } = await resolveCwd(action)
 56    // Resolve FETCH_HEAD age here, in the trampoline process, so main.tsx
 57    // stays await-free — the launched instance receives it as a precomputed
 58    // flag instead of statting the filesystem on its own startup path.
 59    const lastFetch = resolvedRepo ? await readLastFetchTime(cwd) : undefined
 60    const launched = await launchInTerminal(process.execPath, {
 61      query: action.query,
 62      cwd,
 63      repo: resolvedRepo,
 64      lastFetchMs: lastFetch?.getTime(),
 65    })
 66    if (!launched) {
 67      // biome-ignore lint/suspicious/noConsole: intentional error output
 68      console.error(
 69        'Failed to open a terminal. Make sure a supported terminal emulator is installed.',
 70      )
 71      return 1
 72    }
 73  
 74    return 0
 75  }
 76  
 77  /**
 78   * Handle the case where claude was launched as the app bundle's executable
 79   * by macOS (via URL scheme). Uses the NAPI module to receive the URL from
 80   * the Apple Event, then handles it normally.
 81   *
 82   * @returns exit code (0 = success, 1 = error, null = not a URL launch)
 83   */
 84  export async function handleUrlSchemeLaunch(): Promise<number | null> {
 85    // LaunchServices overwrites __CFBundleIdentifier with the launching bundle's
 86    // ID. This is a precise positive signal — it's set to our exact bundle ID
 87    // if and only if macOS launched us via the URL handler .app bundle.
 88    // (`open` from a terminal passes the caller's env through, so negative
 89    // heuristics like !TERM don't work — the terminal's TERM leaks in.)
 90    if (process.env.__CFBundleIdentifier !== MACOS_BUNDLE_ID) {
 91      return null
 92    }
 93  
 94    try {
 95      const { waitForUrlEvent } = await import('url-handler-napi')
 96      const url = waitForUrlEvent(5000)
 97      if (!url) {
 98        return null
 99      }
100      return await handleDeepLinkUri(url)
101    } catch {
102      // NAPI module not available, or handleDeepLinkUri rejected — not a URL launch
103      return null
104    }
105  }
106  
107  /**
108   * Resolve the working directory for the launched Claude instance.
109   * Precedence: explicit cwd > repo lookup (MRU clone) > home.
110   * A repo that isn't cloned locally is not an error — fall through to home
111   * so a web link referencing a repo the user doesn't have still opens Claude.
112   *
113   * Returns the resolved cwd, and the repo slug if (and only if) the MRU
114   * lookup hit — so the launched instance can show which clone was selected
115   * and its git freshness.
116   */
117  async function resolveCwd(action: {
118    cwd?: string
119    repo?: string
120  }): Promise<{ cwd: string; resolvedRepo?: string }> {
121    if (action.cwd) {
122      return { cwd: action.cwd }
123    }
124    if (action.repo) {
125      const known = getKnownPathsForRepo(action.repo)
126      const existing = await filterExistingPaths(known)
127      if (existing[0]) {
128        logForDebugging(`Resolved repo ${action.repo} → ${existing[0]}`)
129        return { cwd: existing[0], resolvedRepo: action.repo }
130      }
131      logForDebugging(
132        `No local clone found for repo ${action.repo}, falling back to home`,
133      )
134    }
135    return { cwd: homedir() }
136  }