πŸ“„ File detail

utils/deepLink/protocolHandler.ts

🧩 .tsπŸ“ 137 linesπŸ’Ύ 4,943 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes handleDeepLinkUri and handleUrlSchemeLaunch β€” mainly functions, hooks, or classes. Dependencies touch Node OS/process metadata. It composes internal code from debug, githubRepoPathMapping, slowOperations, banner, and parseDeepLink (relative imports). What the file header says: Protocol Handler Entry point for `claude --handle-uri <url>`. When the OS invokes claude with a `claude-cli://` URL, this module: 1. Parses the URI into a structured action 2. Detects the user's terminal emulator 3. Opens a new terminal window running claude with the appropriate.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

Protocol Handler Entry point for `claude --handle-uri <url>`. When the OS invokes claude with a `claude-cli://` URL, this module: 1. Parses the URI into a structured action 2. Detects the user's terminal emulator 3. Opens a new terminal window running claude with the appropriate args This runs in a headless context (no TTY) because the OS launches the binary directly β€” there is no terminal attached.

πŸ“€ Exports (heuristic)

  • handleDeepLinkUri
  • handleUrlSchemeLaunch

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • os

πŸ–₯️ Source preview

/**
 * Protocol Handler
 *
 * Entry point for `claude --handle-uri <url>`. When the OS invokes claude
 * with a `claude-cli://` URL, this module:
 *   1. Parses the URI into a structured action
 *   2. Detects the user's terminal emulator
 *   3. Opens a new terminal window running claude with the appropriate args
 *
 * This runs in a headless context (no TTY) because the OS launches the binary
 * directly β€” there is no terminal attached.
 */

import { homedir } from 'os'
import { logForDebugging } from '../debug.js'
import {
  filterExistingPaths,
  getKnownPathsForRepo,
} from '../githubRepoPathMapping.js'
import { jsonStringify } from '../slowOperations.js'
import { readLastFetchTime } from './banner.js'
import { parseDeepLink } from './parseDeepLink.js'
import { MACOS_BUNDLE_ID } from './registerProtocol.js'
import { launchInTerminal } from './terminalLauncher.js'

/**
 * Handle an incoming deep link URI.
 *
 * Called from the CLI entry point when `--handle-uri` is passed.
 * This function parses the URI, resolves the claude binary, and
 * launches it in the user's terminal.
 *
 * @param uri - The raw URI string (e.g., "claude-cli://prompt?q=hello+world")
 * @returns exit code (0 = success)
 */
export async function handleDeepLinkUri(uri: string): Promise<number> {
  logForDebugging(`Handling deep link URI: ${uri}`)

  let action
  try {
    action = parseDeepLink(uri)
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    // biome-ignore lint/suspicious/noConsole: intentional error output
    console.error(`Deep link error: ${message}`)
    return 1
  }

  logForDebugging(`Parsed deep link action: ${jsonStringify(action)}`)

  // Always the running executable β€” no PATH lookup. The OS launched us via
  // an absolute path (bundle symlink / .desktop Exec= / registry command)
  // baked at registration time, and we want the terminal-launched Claude to
  // be the same binary. process.execPath is that binary.
  const { cwd, resolvedRepo } = await resolveCwd(action)
  // Resolve FETCH_HEAD age here, in the trampoline process, so main.tsx
  // stays await-free β€” the launched instance receives it as a precomputed
  // flag instead of statting the filesystem on its own startup path.
  const lastFetch = resolvedRepo ? await readLastFetchTime(cwd) : undefined
  const launched = await launchInTerminal(process.execPath, {
    query: action.query,
    cwd,
    repo: resolvedRepo,
    lastFetchMs: lastFetch?.getTime(),
  })
  if (!launched) {
    // biome-ignore lint/suspicious/noConsole: intentional error output
    console.error(
      'Failed to open a terminal. Make sure a supported terminal emulator is installed.',
    )
    return 1
  }

  return 0
}

/**
 * Handle the case where claude was launched as the app bundle's executable
 * by macOS (via URL scheme). Uses the NAPI module to receive the URL from
 * the Apple Event, then handles it normally.
 *
 * @returns exit code (0 = success, 1 = error, null = not a URL launch)
 */
export async function handleUrlSchemeLaunch(): Promise<number | null> {
  // LaunchServices overwrites __CFBundleIdentifier with the launching bundle's
  // ID. This is a precise positive signal β€” it's set to our exact bundle ID
  // if and only if macOS launched us via the URL handler .app bundle.
  // (`open` from a terminal passes the caller's env through, so negative
  // heuristics like !TERM don't work β€” the terminal's TERM leaks in.)
  if (process.env.__CFBundleIdentifier !== MACOS_BUNDLE_ID) {
    return null
  }

  try {
    const { waitForUrlEvent } = await import('url-handler-napi')
    const url = waitForUrlEvent(5000)
    if (!url) {
      return null
    }
    return await handleDeepLinkUri(url)
  } catch {
    // NAPI module not available, or handleDeepLinkUri rejected β€” not a URL launch
    return null
  }
}

/**
 * Resolve the working directory for the launched Claude instance.
 * Precedence: explicit cwd > repo lookup (MRU clone) > home.
 * A repo that isn't cloned locally is not an error β€” fall through to home
 * so a web link referencing a repo the user doesn't have still opens Claude.
 *
 * Returns the resolved cwd, and the repo slug if (and only if) the MRU
 * lookup hit β€” so the launched instance can show which clone was selected
 * and its git freshness.
 */
async function resolveCwd(action: {
  cwd?: string
  repo?: string
}): Promise<{ cwd: string; resolvedRepo?: string }> {
  if (action.cwd) {
    return { cwd: action.cwd }
  }
  if (action.repo) {
    const known = getKnownPathsForRepo(action.repo)
    const existing = await filterExistingPaths(known)
    if (existing[0]) {
      logForDebugging(`Resolved repo ${action.repo} β†’ ${existing[0]}`)
      return { cwd: existing[0], resolvedRepo: action.repo }
    }
    logForDebugging(
      `No local clone found for repo ${action.repo}, falling back to home`,
    )
  }
  return { cwd: homedir() }
}