π File detail
utils/deepLink/protocolHandler.ts
π― 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)
handleDeepLinkUrihandleUrlSchemeLaunch
π 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() }
}