protocolHandler.ts
utils/deepLink/protocolHandler.ts
137
Lines
4943
Bytes
2
Exports
8
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with ui-flow. It contains 137 lines, 8 detected imports, and 2 detected exports.
Important relationships
Detected exports
handleDeepLinkUrihandleUrlSchemeLaunch
Keywords
actionrepoclaudeterminallaunchedlogfordebugginglinkbundleresolvedrepobinary
Detected imports
os../debug.js../githubRepoPathMapping.js../slowOperations.js./banner.js./parseDeepLink.js./registerProtocol.js./terminalLauncher.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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() }
}