mono/packages/kbot/ref/utils/deepLink/protocolHandler.ts
2026-04-01 01:05:48 +02:00

137 lines
4.8 KiB
TypeScript

/**
* 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() }
}