chore: initialize recovered claude workspace

This commit is contained in:
2026-04-02 15:29:01 +08:00
commit a10efa3b4b
1940 changed files with 506426 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
/**
* Deep Link Origin Banner
*
* Builds the warning text shown when a session was opened by an external
* claude-cli:// deep link. Linux xdg-open and browsers with "always allow"
* set dispatch the link with no OS-level confirmation, so the application
* provides its own provenance signal — mirroring claude.ai's security
* interstitial for external-source prefills.
*
* The user must press Enter to submit; this banner primes them to read the
* prompt (which may use homoglyphs or padding to hide instructions) and
* notice which directory — and therefore which CLAUDE.md — was loaded.
*/
import { stat } from 'fs/promises'
import { homedir } from 'os'
import { join, sep } from 'path'
import { formatNumber, formatRelativeTimeAgo } from '../format.js'
import { getCommonDir } from '../git/gitFilesystem.js'
import { getGitDir } from '../git.js'
const STALE_FETCH_WARN_MS = 7 * 24 * 60 * 60 * 1000
/**
* Above this length, a pre-filled prompt no longer fits on one screen
* (~12-15 lines on an 80-col terminal). The banner switches from "review
* carefully" to an explicit "scroll to review the entire prompt" so a
* malicious tail buried past line 60 isn't silently off-screen.
*/
const LONG_PREFILL_THRESHOLD = 1000
export type DeepLinkBannerInfo = {
/** Resolved working directory the session launched in. */
cwd: string
/** Length of the ?q= prompt pre-filled in the input box. Undefined = no prefill. */
prefillLength?: number
/** The ?repo= slug if the cwd was resolved from the githubRepoPaths MRU. */
repo?: string
/** Last-fetch timestamp for the repo (FETCH_HEAD mtime). Undefined = never fetched or not a git repo. */
lastFetch?: Date
}
/**
* Build the multi-line warning banner for a deep-link-originated session.
*
* Always shows the working directory so the user can see which CLAUDE.md
* will load. When the link pre-filled a prompt, adds a second line prompting
* the user to review it — the prompt itself is visible in the input box.
*
* When the cwd was resolved from a ?repo= slug, also shows the slug and the
* clone's last-fetch age so the user knows which local clone was selected
* and whether its CLAUDE.md may be stale relative to upstream.
*/
export function buildDeepLinkBanner(info: DeepLinkBannerInfo): string {
const lines = [
`This session was opened by an external deep link in ${tildify(info.cwd)}`,
]
if (info.repo) {
const age = info.lastFetch ? formatRelativeTimeAgo(info.lastFetch) : 'never'
const stale =
!info.lastFetch ||
Date.now() - info.lastFetch.getTime() > STALE_FETCH_WARN_MS
lines.push(
`Resolved ${info.repo} from local clones · last fetched ${age}${stale ? ' — CLAUDE.md may be stale' : ''}`,
)
}
if (info.prefillLength) {
lines.push(
info.prefillLength > LONG_PREFILL_THRESHOLD
? `The prompt below (${formatNumber(info.prefillLength)} chars) was supplied by the link — scroll to review the entire prompt before pressing Enter.`
: 'The prompt below was supplied by the link — review carefully before pressing Enter.',
)
}
return lines.join('\n')
}
/**
* Read the mtime of .git/FETCH_HEAD, which git updates on every fetch or
* pull. Returns undefined if the directory is not a git repo or has never
* been fetched.
*
* FETCH_HEAD is per-worktree — fetching from the main worktree does not
* touch a sibling worktree's FETCH_HEAD. When cwd is a worktree, we check
* both and return whichever is newer so a recently-fetched main repo
* doesn't read as "never fetched" just because the deep link landed in
* a worktree.
*/
export async function readLastFetchTime(
cwd: string,
): Promise<Date | undefined> {
const gitDir = await getGitDir(cwd)
if (!gitDir) return undefined
const commonDir = await getCommonDir(gitDir)
const [local, common] = await Promise.all([
mtimeOrUndefined(join(gitDir, 'FETCH_HEAD')),
commonDir
? mtimeOrUndefined(join(commonDir, 'FETCH_HEAD'))
: Promise.resolve(undefined),
])
if (local && common) return local > common ? local : common
return local ?? common
}
async function mtimeOrUndefined(p: string): Promise<Date | undefined> {
try {
const { mtime } = await stat(p)
return mtime
} catch {
return undefined
}
}
/**
* Shorten home-dir-prefixed paths to ~ notation for the banner.
* Not using getDisplayPath() because cwd is the current working directory,
* so the relative-path branch would collapse it to the empty string.
*/
function tildify(p: string): string {
const home = homedir()
if (p === home) return '~'
if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
return p
}

View File

@@ -0,0 +1,170 @@
/**
* Deep Link URI Parser
*
* Parses `claude-cli://open` URIs. All parameters are optional:
* q — pre-fill the prompt input (not submitted)
* cwd — working directory (absolute path)
* repo — owner/name slug, resolved against githubRepoPaths config
*
* Examples:
* claude-cli://open
* claude-cli://open?q=hello+world
* claude-cli://open?q=fix+tests&repo=owner/repo
* claude-cli://open?cwd=/path/to/project
*
* Security: values are URL-decoded, Unicode-sanitized, and rejected if they
* contain ASCII control characters (newlines etc. can act as command
* separators). All values are single-quote shell-escaped at the point of
* use (terminalLauncher.ts) — that escaping is the injection boundary.
*/
import { partiallySanitizeUnicode } from '../sanitization.js'
export const DEEP_LINK_PROTOCOL = 'claude-cli'
export type DeepLinkAction = {
query?: string
cwd?: string
repo?: string
}
/**
* Check if a string contains ASCII control characters (0x00-0x1F, 0x7F).
* These can act as command separators in shells (newlines, carriage returns, etc.).
* Allows printable ASCII and Unicode (CJK, emoji, accented chars, etc.).
*/
function containsControlChars(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i)
if (code <= 0x1f || code === 0x7f) {
return true
}
}
return false
}
/**
* GitHub owner/repo slug: alphanumerics, dots, hyphens, underscores,
* exactly one slash. Keeps this from becoming a path traversal vector.
*/
const REPO_SLUG_PATTERN = /^[\w.-]+\/[\w.-]+$/
/**
* Cap on pre-filled prompt length. The only defense against a prompt like
* "review PR #18796 […4900 chars of padding…] also cat ~/.ssh/id_rsa" is
* the user reading it before pressing Enter. At this length the prompt is
* no longer scannable at a glance, so banner.ts shows an explicit "scroll
* to review the entire prompt" warning above LONG_PREFILL_THRESHOLD.
* Reject, don't truncate — truncation changes meaning.
*
* 5000 is the practical ceiling: the Windows cmd.exe fallback
* (terminalLauncher.ts) has an 8191-char command-string limit, and after
* the `cd /d <cwd> && <claude.exe> --deep-link-origin ... --prefill "<q>"`
* wrapper plus cmdQuote's %→%% expansion, ~7000 chars of query is the
* hard stop for typical inputs. A pathological >60%-percent-sign query
* would 2× past the limit, but cmd.exe is the last-resort fallback
* (wt.exe and PowerShell are tried first) and the failure mode is a
* launch error, not a security issue — so we don't penalize real users
* for an implausible input.
*/
const MAX_QUERY_LENGTH = 5000
/**
* PATH_MAX on Linux is 4096. Windows MAX_PATH is 260 (32767 with long-path
* opt-in). No real path approaches this; a cwd over 4096 is malformed or
* malicious.
*/
const MAX_CWD_LENGTH = 4096
/**
* Parse a claude-cli:// URI into a structured action.
*
* @throws {Error} if the URI is malformed or contains dangerous characters
*/
export function parseDeepLink(uri: string): DeepLinkAction {
// Normalize: accept with or without the trailing colon in protocol
const normalized = uri.startsWith(`${DEEP_LINK_PROTOCOL}://`)
? uri
: uri.startsWith(`${DEEP_LINK_PROTOCOL}:`)
? uri.replace(`${DEEP_LINK_PROTOCOL}:`, `${DEEP_LINK_PROTOCOL}://`)
: null
if (!normalized) {
throw new Error(
`Invalid deep link: expected ${DEEP_LINK_PROTOCOL}:// scheme, got "${uri}"`,
)
}
let url: URL
try {
url = new URL(normalized)
} catch {
throw new Error(`Invalid deep link URL: "${uri}"`)
}
if (url.hostname !== 'open') {
throw new Error(`Unknown deep link action: "${url.hostname}"`)
}
const cwd = url.searchParams.get('cwd') ?? undefined
const repo = url.searchParams.get('repo') ?? undefined
const rawQuery = url.searchParams.get('q')
// Validate cwd if present — must be an absolute path
if (cwd && !cwd.startsWith('/') && !/^[a-zA-Z]:[/\\]/.test(cwd)) {
throw new Error(
`Invalid cwd in deep link: must be an absolute path, got "${cwd}"`,
)
}
// Reject control characters in cwd (newlines, etc.) but allow path chars like backslash.
if (cwd && containsControlChars(cwd)) {
throw new Error('Deep link cwd contains disallowed control characters')
}
if (cwd && cwd.length > MAX_CWD_LENGTH) {
throw new Error(
`Deep link cwd exceeds ${MAX_CWD_LENGTH} characters (got ${cwd.length})`,
)
}
// Validate repo slug format. Resolution happens later (protocolHandler.ts) —
// this parser stays pure with no config/filesystem access.
if (repo && !REPO_SLUG_PATTERN.test(repo)) {
throw new Error(
`Invalid repo in deep link: expected "owner/repo", got "${repo}"`,
)
}
let query: string | undefined
if (rawQuery && rawQuery.trim().length > 0) {
// Strip hidden Unicode characters (ASCII smuggling / hidden prompt injection)
query = partiallySanitizeUnicode(rawQuery.trim())
if (containsControlChars(query)) {
throw new Error('Deep link query contains disallowed control characters')
}
if (query.length > MAX_QUERY_LENGTH) {
throw new Error(
`Deep link query exceeds ${MAX_QUERY_LENGTH} characters (got ${query.length})`,
)
}
}
return { query, cwd, repo }
}
/**
* Build a claude-cli:// deep link URL.
*/
export function buildDeepLink(action: DeepLinkAction): string {
const url = new URL(`${DEEP_LINK_PROTOCOL}://open`)
if (action.query) {
url.searchParams.set('q', action.query)
}
if (action.cwd) {
url.searchParams.set('cwd', action.cwd)
}
if (action.repo) {
url.searchParams.set('repo', action.repo)
}
return url.toString()
}

View File

@@ -0,0 +1,136 @@
/**
* 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() }
}

View File

@@ -0,0 +1,348 @@
/**
* Protocol Handler Registration
*
* Registers the `claude-cli://` custom URI scheme with the OS,
* so that clicking a `claude-cli://` link in a browser (or any app) will
* invoke `claude --handle-uri <url>`.
*
* Platform details:
* macOS — Creates a minimal .app trampoline in ~/Applications with
* CFBundleURLTypes in its Info.plist
* Linux — Creates a .desktop file in $XDG_DATA_HOME/applications
* (default ~/.local/share/applications) and registers it with xdg-mime
* Windows — Writes registry keys under HKEY_CURRENT_USER\Software\Classes
*/
import { promises as fs } from 'fs'
import * as os from 'os'
import * as path from 'path'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { logForDebugging } from '../debug.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { getErrnoCode } from '../errors.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { getInitialSettings } from '../settings/settings.js'
import { which } from '../which.js'
import { getUserBinDir, getXDGDataHome } from '../xdg.js'
import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js'
export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler'
const APP_NAME = 'Claude Code URL Handler'
const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop'
const MACOS_APP_NAME = 'Claude Code URL Handler.app'
// Shared between register* (writes these paths/values) and
// isProtocolHandlerCurrent (reads them back). Keep the writer and reader
// in lockstep — drift here means the check returns a perpetual false.
const MACOS_APP_DIR = path.join(os.homedir(), 'Applications', MACOS_APP_NAME)
const MACOS_SYMLINK_PATH = path.join(
MACOS_APP_DIR,
'Contents',
'MacOS',
'claude',
)
function linuxDesktopPath(): string {
return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME)
}
const WINDOWS_REG_KEY = `HKEY_CURRENT_USER\\Software\\Classes\\${DEEP_LINK_PROTOCOL}`
const WINDOWS_COMMAND_KEY = `${WINDOWS_REG_KEY}\\shell\\open\\command`
const FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000
function linuxExecLine(claudePath: string): string {
return `Exec="${claudePath}" --handle-uri %u`
}
function windowsCommandValue(claudePath: string): string {
return `"${claudePath}" --handle-uri "%1"`
}
/**
* Register the protocol handler on macOS.
*
* Creates a .app bundle where the CFBundleExecutable is a symlink to the
* already-installed (and signed) `claude` binary. When macOS opens a
* `claude-cli://` URL, it launches `claude` through this app bundle.
* Claude then uses the url-handler NAPI module to read the URL from the
* Apple Event and handles it normally.
*
* This approach avoids shipping a separate executable (which would need
* to be signed and allowlisted by endpoint security tools like Santa).
*/
async function registerMacos(claudePath: string): Promise<void> {
const contentsDir = path.join(MACOS_APP_DIR, 'Contents')
// Remove any existing app bundle to start clean
try {
await fs.rm(MACOS_APP_DIR, { recursive: true })
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code !== 'ENOENT') {
throw e
}
}
await fs.mkdir(path.dirname(MACOS_SYMLINK_PATH), { recursive: true })
// Info.plist — registers the URL scheme with claude as the executable
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${MACOS_BUNDLE_ID}</string>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleExecutable</key>
<string>claude</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSBackgroundOnly</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Claude Code Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>${DEEP_LINK_PROTOCOL}</string>
</array>
</dict>
</array>
</dict>
</plist>`
await fs.writeFile(path.join(contentsDir, 'Info.plist'), infoPlist)
// Symlink to the already-signed claude binary — avoids a new executable
// that would need signing and endpoint-security allowlisting.
// Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads
// this symlink, so it acts as the commit marker. If Info.plist write
// failed above, no symlink → next session retries.
await fs.symlink(claudePath, MACOS_SYMLINK_PATH)
// Re-register the app with LaunchServices so macOS picks up the URL scheme.
const lsregister =
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
await execFileNoThrow(lsregister, ['-R', MACOS_APP_DIR], { useCwd: false })
logForDebugging(
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${MACOS_APP_DIR}`,
)
}
/**
* Register the protocol handler on Linux.
* Creates a .desktop file and registers it with xdg-mime.
*/
async function registerLinux(claudePath: string): Promise<void> {
await fs.mkdir(path.dirname(linuxDesktopPath()), { recursive: true })
const desktopEntry = `[Desktop Entry]
Name=${APP_NAME}
Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Claude Code
${linuxExecLine(claudePath)}
Type=Application
NoDisplay=true
MimeType=x-scheme-handler/${DEEP_LINK_PROTOCOL};
`
await fs.writeFile(linuxDesktopPath(), desktopEntry)
// Register as the default handler for the scheme. On headless boxes
// (WSL, Docker, CI) xdg-utils isn't installed — not a failure: there's
// no desktop to click links from, and some apps read the .desktop
// MimeType line directly. The artifact check still short-circuits
// next session since the .desktop file is present.
const xdgMime = await which('xdg-mime')
if (xdgMime) {
const { code } = await execFileNoThrow(
xdgMime,
['default', DESKTOP_FILE_NAME, `x-scheme-handler/${DEEP_LINK_PROTOCOL}`],
{ useCwd: false },
)
if (code !== 0) {
throw Object.assign(new Error(`xdg-mime exited with code ${code}`), {
code: 'XDG_MIME_FAILED',
})
}
}
logForDebugging(
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${linuxDesktopPath()}`,
)
}
/**
* Register the protocol handler on Windows via the registry.
*/
async function registerWindows(claudePath: string): Promise<void> {
for (const args of [
['add', WINDOWS_REG_KEY, '/ve', '/d', `URL:${APP_NAME}`, '/f'],
['add', WINDOWS_REG_KEY, '/v', 'URL Protocol', '/d', '', '/f'],
[
'add',
WINDOWS_COMMAND_KEY,
'/ve',
'/d',
windowsCommandValue(claudePath),
'/f',
],
]) {
const { code } = await execFileNoThrow('reg', args, { useCwd: false })
if (code !== 0) {
throw Object.assign(new Error(`reg add exited with code ${code}`), {
code: 'REG_FAILED',
})
}
}
logForDebugging(
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler in Windows registry`,
)
}
/**
* Register the `claude-cli://` protocol handler with the operating system.
* After registration, clicking a `claude-cli://` link will invoke claude.
*/
export async function registerProtocolHandler(
claudePath?: string,
): Promise<void> {
const resolved = claudePath ?? (await resolveClaudePath())
switch (process.platform) {
case 'darwin':
await registerMacos(resolved)
break
case 'linux':
await registerLinux(resolved)
break
case 'win32':
await registerWindows(resolved)
break
default:
throw new Error(`Unsupported platform: ${process.platform}`)
}
}
/**
* Resolve the claude binary path for protocol registration. Prefers the
* native installer's stable symlink (~/.local/bin/claude) which survives
* auto-updates; falls back to process.execPath when the symlink is absent
* (dev builds, non-native installs).
*/
async function resolveClaudePath(): Promise<string> {
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude'
const stablePath = path.join(getUserBinDir(), binaryName)
try {
await fs.realpath(stablePath)
return stablePath
} catch {
return process.execPath
}
}
/**
* Check whether the OS-level protocol handler is already registered AND
* points at the expected `claude` binary. Reads the registration artifact
* directly (symlink target, .desktop Exec line, registry value) rather than
* a cached flag in ~/.claude.json, so:
* - the check is per-machine (config can sync across machines; OS state can't)
* - stale paths self-heal (install-method change → re-register next session)
* - deleted artifacts self-heal
*
* Any read error (ENOENT, EACCES, reg nonzero) → false → re-register.
*/
export async function isProtocolHandlerCurrent(
claudePath: string,
): Promise<boolean> {
try {
switch (process.platform) {
case 'darwin': {
const target = await fs.readlink(MACOS_SYMLINK_PATH)
return target === claudePath
}
case 'linux': {
const content = await fs.readFile(linuxDesktopPath(), 'utf8')
return content.includes(linuxExecLine(claudePath))
}
case 'win32': {
const { stdout, code } = await execFileNoThrow(
'reg',
['query', WINDOWS_COMMAND_KEY, '/ve'],
{ useCwd: false },
)
return code === 0 && stdout.includes(windowsCommandValue(claudePath))
}
default:
return false
}
} catch {
return false
}
}
/**
* Auto-register the claude-cli:// deep link protocol handler when missing
* or stale. Runs every session from backgroundHousekeeping (fire-and-forget),
* but the artifact check makes it a no-op after the first successful run
* unless the install path moves or the OS artifact is deleted.
*/
export async function ensureDeepLinkProtocolRegistered(): Promise<void> {
if (getInitialSettings().disableDeepLinkRegistration === 'disable') {
return
}
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_lodestone_enabled', false)) {
return
}
const claudePath = await resolveClaudePath()
if (await isProtocolHandlerCurrent(claudePath)) {
return
}
// EACCES/ENOSPC are deterministic — retrying next session won't help.
// Throttle to once per 24h so a read-only ~/.local/share/applications
// doesn't generate a failure event on every startup. Marker lives in
// ~/.claude (per-machine, not synced) rather than ~/.claude.json (can sync).
const failureMarkerPath = path.join(
getClaudeConfigHomeDir(),
'.deep-link-register-failed',
)
try {
const stat = await fs.stat(failureMarkerPath)
if (Date.now() - stat.mtimeMs < FAILURE_BACKOFF_MS) {
return
}
} catch {
// Marker absent — proceed.
}
try {
await registerProtocolHandler(claudePath)
logEvent('tengu_deep_link_registered', { success: true })
logForDebugging('Auto-registered claude-cli:// deep link protocol handler')
await fs.rm(failureMarkerPath, { force: true }).catch(() => {})
} catch (error) {
const code = getErrnoCode(error)
logEvent('tengu_deep_link_registered', {
success: false,
error_code:
code as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
logForDebugging(
`Failed to auto-register deep link protocol handler: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
if (code === 'EACCES' || code === 'ENOSPC') {
await fs.writeFile(failureMarkerPath, '').catch(() => {})
}
}
}

View File

@@ -0,0 +1,557 @@
/**
* Terminal Launcher
*
* Detects the user's preferred terminal emulator and launches Claude Code
* inside it. Used by the deep link protocol handler when invoked by the OS
* (i.e., not already running inside a terminal).
*
* Platform support:
* macOS — Terminal.app, iTerm2, Ghostty, Kitty, Alacritty, WezTerm
* Linux — $TERMINAL, x-terminal-emulator, gnome-terminal, konsole, etc.
* Windows — Windows Terminal (wt.exe), PowerShell, cmd.exe
*/
import { spawn } from 'child_process'
import { basename } from 'path'
import { getGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { which } from '../which.js'
export type TerminalInfo = {
name: string
command: string
}
// macOS terminals in preference order.
// Each entry: [display name, app bundle name or CLI command, detection method]
const MACOS_TERMINALS: Array<{
name: string
bundleId: string
app: string
}> = [
{ name: 'iTerm2', bundleId: 'com.googlecode.iterm2', app: 'iTerm' },
{ name: 'Ghostty', bundleId: 'com.mitchellh.ghostty', app: 'Ghostty' },
{ name: 'Kitty', bundleId: 'net.kovidgoyal.kitty', app: 'kitty' },
{ name: 'Alacritty', bundleId: 'org.alacritty', app: 'Alacritty' },
{ name: 'WezTerm', bundleId: 'com.github.wez.wezterm', app: 'WezTerm' },
{
name: 'Terminal.app',
bundleId: 'com.apple.Terminal',
app: 'Terminal',
},
]
// Linux terminals in preference order (command name)
const LINUX_TERMINALS = [
'ghostty',
'kitty',
'alacritty',
'wezterm',
'gnome-terminal',
'konsole',
'xfce4-terminal',
'mate-terminal',
'tilix',
'xterm',
]
/**
* Detect the user's preferred terminal on macOS.
* Checks running processes first (most likely to be what the user prefers),
* then falls back to checking installed .app bundles.
*/
async function detectMacosTerminal(): Promise<TerminalInfo> {
// Stored preference from a previous interactive session. This is the only
// signal that survives into the headless LaunchServices context — the env
// var check below never hits when we're launched from a browser link.
const stored = getGlobalConfig().deepLinkTerminal
if (stored) {
const match = MACOS_TERMINALS.find(t => t.app === stored)
if (match) {
return { name: match.name, command: match.app }
}
}
// Check the TERM_PROGRAM env var — if set, the user has a clear preference.
// TERM_PROGRAM may include a .app suffix (e.g., "iTerm.app"), so strip it.
const termProgram = process.env.TERM_PROGRAM
if (termProgram) {
const normalized = termProgram.replace(/\.app$/i, '').toLowerCase()
const match = MACOS_TERMINALS.find(
t =>
t.app.toLowerCase() === normalized ||
t.name.toLowerCase() === normalized,
)
if (match) {
return { name: match.name, command: match.app }
}
}
// Check which terminals are installed by looking for .app bundles.
// Try mdfind first (Spotlight), but fall back to checking /Applications
// directly since mdfind can return empty results if Spotlight is disabled
// or hasn't indexed the app yet.
for (const terminal of MACOS_TERMINALS) {
const { code, stdout } = await execFileNoThrow(
'mdfind',
[`kMDItemCFBundleIdentifier == "${terminal.bundleId}"`],
{ timeout: 5000, useCwd: false },
)
if (code === 0 && stdout.trim().length > 0) {
return { name: terminal.name, command: terminal.app }
}
}
// Fallback: check /Applications directly (mdfind may not work if
// Spotlight indexing is disabled or incomplete)
for (const terminal of MACOS_TERMINALS) {
const { code: lsCode } = await execFileNoThrow(
'ls',
[`/Applications/${terminal.app}.app`],
{ timeout: 1000, useCwd: false },
)
if (lsCode === 0) {
return { name: terminal.name, command: terminal.app }
}
}
// Terminal.app is always available on macOS
return { name: 'Terminal.app', command: 'Terminal' }
}
/**
* Detect the user's preferred terminal on Linux.
* Checks $TERMINAL, then x-terminal-emulator, then walks a priority list.
*/
async function detectLinuxTerminal(): Promise<TerminalInfo | null> {
// Check $TERMINAL env var
const termEnv = process.env.TERMINAL
if (termEnv) {
const resolved = await which(termEnv)
if (resolved) {
return { name: basename(termEnv), command: resolved }
}
}
// Check x-terminal-emulator (Debian/Ubuntu alternative)
const xte = await which('x-terminal-emulator')
if (xte) {
return { name: 'x-terminal-emulator', command: xte }
}
// Walk the priority list
for (const terminal of LINUX_TERMINALS) {
const resolved = await which(terminal)
if (resolved) {
return { name: terminal, command: resolved }
}
}
return null
}
/**
* Detect the user's preferred terminal on Windows.
*/
async function detectWindowsTerminal(): Promise<TerminalInfo> {
// Check for Windows Terminal first
const wt = await which('wt.exe')
if (wt) {
return { name: 'Windows Terminal', command: wt }
}
// PowerShell 7+ (separate install)
const pwsh = await which('pwsh.exe')
if (pwsh) {
return { name: 'PowerShell', command: pwsh }
}
// Windows PowerShell 5.1 (built into Windows)
const powershell = await which('powershell.exe')
if (powershell) {
return { name: 'PowerShell', command: powershell }
}
// cmd.exe is always available
return { name: 'Command Prompt', command: 'cmd.exe' }
}
/**
* Detect the user's preferred terminal emulator.
*/
export async function detectTerminal(): Promise<TerminalInfo | null> {
switch (process.platform) {
case 'darwin':
return detectMacosTerminal()
case 'linux':
return detectLinuxTerminal()
case 'win32':
return detectWindowsTerminal()
default:
return null
}
}
/**
* Launch Claude Code in the detected terminal emulator.
*
* Pure argv paths (no shell, user input never touches an interpreter):
* macOS — Ghostty, Alacritty, Kitty, WezTerm (via open -na --args)
* Linux — all ten in LINUX_TERMINALS
* Windows — Windows Terminal
*
* Shell-string paths (user input is shell-quoted and relied upon):
* macOS — iTerm2, Terminal.app (AppleScript `write text` / `do script`
* are inherently shell-interpreted; no argv interface exists)
* Windows — PowerShell -Command, cmd.exe /k (no argv exec mode)
*
* For pure-argv paths: claudePath, --prefill, query, cwd travel as distinct
* argv elements end-to-end. No sh -c. No shellQuote(). The terminal does
* chdir(cwd) and execvp(claude, argv). Spaces/quotes/metacharacters in
* query or cwd are preserved by argv boundaries with zero interpretation.
*/
export async function launchInTerminal(
claudePath: string,
action: {
query?: string
cwd?: string
repo?: string
lastFetchMs?: number
},
): Promise<boolean> {
const terminal = await detectTerminal()
if (!terminal) {
logForDebugging('No terminal emulator detected', { level: 'error' })
return false
}
logForDebugging(
`Launching in terminal: ${terminal.name} (${terminal.command})`,
)
const claudeArgs = ['--deep-link-origin']
if (action.repo) {
claudeArgs.push('--deep-link-repo', action.repo)
if (action.lastFetchMs !== undefined) {
claudeArgs.push('--deep-link-last-fetch', String(action.lastFetchMs))
}
}
if (action.query) {
claudeArgs.push('--prefill', action.query)
}
switch (process.platform) {
case 'darwin':
return launchMacosTerminal(terminal, claudePath, claudeArgs, action.cwd)
case 'linux':
return launchLinuxTerminal(terminal, claudePath, claudeArgs, action.cwd)
case 'win32':
return launchWindowsTerminal(terminal, claudePath, claudeArgs, action.cwd)
default:
return false
}
}
async function launchMacosTerminal(
terminal: TerminalInfo,
claudePath: string,
claudeArgs: string[],
cwd?: string,
): Promise<boolean> {
switch (terminal.command) {
// --- SHELL-STRING PATHS (AppleScript has no argv interface) ---
// User input is shell-quoted via shellQuote(). These two are the only
// macOS paths where shellQuote() correctness is load-bearing.
case 'iTerm': {
const shCmd = buildShellCommand(claudePath, claudeArgs, cwd)
// If iTerm isn't running, `tell application` launches it and iTerm's
// default startup behavior opens a window — so `create window` would
// make a second one. Check `running` first: if already running (even
// with zero windows), create a window; if not, `activate` lets iTerm's
// startup create the first window.
const script = `tell application "iTerm"
if running then
create window with default profile
else
activate
end if
tell current session of current window
write text ${appleScriptQuote(shCmd)}
end tell
end tell`
const { code } = await execFileNoThrow('osascript', ['-e', script], {
useCwd: false,
})
if (code === 0) return true
break
}
case 'Terminal': {
const shCmd = buildShellCommand(claudePath, claudeArgs, cwd)
const script = `tell application "Terminal"
do script ${appleScriptQuote(shCmd)}
activate
end tell`
const { code } = await execFileNoThrow('osascript', ['-e', script], {
useCwd: false,
})
return code === 0
}
// --- PURE ARGV PATHS (no shell, no shellQuote) ---
// open -na <App> --args <argv> → app receives argv verbatim →
// terminal's native --working-directory + -e exec the command directly.
case 'Ghostty': {
const args = [
'-na',
terminal.command,
'--args',
'--window-save-state=never',
]
if (cwd) args.push(`--working-directory=${cwd}`)
args.push('-e', claudePath, ...claudeArgs)
const { code } = await execFileNoThrow('open', args, { useCwd: false })
if (code === 0) return true
break
}
case 'Alacritty': {
const args = ['-na', terminal.command, '--args']
if (cwd) args.push('--working-directory', cwd)
args.push('-e', claudePath, ...claudeArgs)
const { code } = await execFileNoThrow('open', args, { useCwd: false })
if (code === 0) return true
break
}
case 'kitty': {
const args = ['-na', terminal.command, '--args']
if (cwd) args.push('--directory', cwd)
args.push(claudePath, ...claudeArgs)
const { code } = await execFileNoThrow('open', args, { useCwd: false })
if (code === 0) return true
break
}
case 'WezTerm': {
const args = ['-na', terminal.command, '--args', 'start']
if (cwd) args.push('--cwd', cwd)
args.push('--', claudePath, ...claudeArgs)
const { code } = await execFileNoThrow('open', args, { useCwd: false })
if (code === 0) return true
break
}
}
logForDebugging(
`Failed to launch ${terminal.name}, falling back to Terminal.app`,
)
return launchMacosTerminal(
{ name: 'Terminal.app', command: 'Terminal' },
claudePath,
claudeArgs,
cwd,
)
}
async function launchLinuxTerminal(
terminal: TerminalInfo,
claudePath: string,
claudeArgs: string[],
cwd?: string,
): Promise<boolean> {
// All Linux paths are pure argv. Each terminal's --working-directory
// (or equivalent) sets cwd natively; the command is exec'd directly.
// For the few terminals without a cwd flag (xterm, and the opaque
// x-terminal-emulator / $TERMINAL), spawn({cwd}) sets the terminal
// process's cwd — most inherit it for the child.
let args: string[]
let spawnCwd: string | undefined
switch (terminal.name) {
case 'gnome-terminal':
args = cwd ? [`--working-directory=${cwd}`, '--'] : ['--']
args.push(claudePath, ...claudeArgs)
break
case 'konsole':
args = cwd ? ['--workdir', cwd, '-e'] : ['-e']
args.push(claudePath, ...claudeArgs)
break
case 'kitty':
args = cwd ? ['--directory', cwd] : []
args.push(claudePath, ...claudeArgs)
break
case 'wezterm':
args = cwd ? ['start', '--cwd', cwd, '--'] : ['start', '--']
args.push(claudePath, ...claudeArgs)
break
case 'alacritty':
args = cwd ? ['--working-directory', cwd, '-e'] : ['-e']
args.push(claudePath, ...claudeArgs)
break
case 'ghostty':
args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e']
args.push(claudePath, ...claudeArgs)
break
case 'xfce4-terminal':
case 'mate-terminal':
args = cwd ? [`--working-directory=${cwd}`, '-x'] : ['-x']
args.push(claudePath, ...claudeArgs)
break
case 'tilix':
args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e']
args.push(claudePath, ...claudeArgs)
break
default:
// xterm, x-terminal-emulator, $TERMINAL — no reliable cwd flag.
// spawn({cwd}) sets the terminal's own cwd; most inherit.
args = ['-e', claudePath, ...claudeArgs]
spawnCwd = cwd
break
}
return spawnDetached(terminal.command, args, { cwd: spawnCwd })
}
async function launchWindowsTerminal(
terminal: TerminalInfo,
claudePath: string,
claudeArgs: string[],
cwd?: string,
): Promise<boolean> {
const args: string[] = []
switch (terminal.name) {
// --- PURE ARGV PATH ---
case 'Windows Terminal':
if (cwd) args.push('-d', cwd)
args.push('--', claudePath, ...claudeArgs)
break
// --- SHELL-STRING PATHS ---
// PowerShell -Command and cmd /k take a command string. No argv exec
// mode that also keeps the session interactive after claude exits.
// User input is escaped per-shell; correctness of that escaping is
// load-bearing here.
case 'PowerShell': {
// Single-quoted PowerShell strings have NO escape sequences (only
// '' for a literal quote). Double-quoted strings interpret backtick
// escapes — a query containing `" could break out.
const cdCmd = cwd ? `Set-Location ${psQuote(cwd)}; ` : ''
args.push(
'-NoExit',
'-Command',
`${cdCmd}& ${psQuote(claudePath)} ${claudeArgs.map(psQuote).join(' ')}`,
)
break
}
default: {
const cdCmd = cwd ? `cd /d ${cmdQuote(cwd)} && ` : ''
args.push(
'/k',
`${cdCmd}${cmdQuote(claudePath)} ${claudeArgs.map(a => cmdQuote(a)).join(' ')}`,
)
break
}
}
// cmd.exe does NOT use MSVCRT-style argument parsing. libuv's default
// quoting for spawn() on Windows assumes MSVCRT rules and would double-
// escape our already-cmdQuote'd string. Bypass it for cmd.exe only.
return spawnDetached(terminal.command, args, {
windowsVerbatimArguments: terminal.name === 'Command Prompt',
})
}
/**
* Spawn a terminal detached so the handler process can exit without
* waiting for the terminal to close. Resolves false on spawn failure
* (ENOENT, EACCES) rather than crashing.
*/
function spawnDetached(
command: string,
args: string[],
opts: { cwd?: string; windowsVerbatimArguments?: boolean } = {},
): Promise<boolean> {
return new Promise<boolean>(resolve => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
cwd: opts.cwd,
windowsVerbatimArguments: opts.windowsVerbatimArguments,
})
child.once('error', err => {
logForDebugging(`Failed to spawn ${command}: ${err.message}`, {
level: 'error',
})
void resolve(false)
})
child.once('spawn', () => {
child.unref()
void resolve(true)
})
})
}
/**
* Build a single-quoted POSIX shell command string. ONLY used by the
* AppleScript paths (iTerm, Terminal.app) which have no argv interface.
*/
function buildShellCommand(
claudePath: string,
claudeArgs: string[],
cwd?: string,
): string {
const cdPrefix = cwd ? `cd ${shellQuote(cwd)} && ` : ''
return `${cdPrefix}${[claudePath, ...claudeArgs].map(shellQuote).join(' ')}`
}
/**
* POSIX single-quote escaping. Single-quoted strings have zero
* interpretation except for the closing single quote itself.
* Only used by buildShellCommand() for the AppleScript paths.
*/
function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
/**
* AppleScript string literal escaping (backslash then double-quote).
*/
function appleScriptQuote(s: string): string {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* PowerShell single-quoted string. The ONLY special sequence is '' for a
* literal single quote — no backtick escapes, no variable expansion, no
* subexpressions. This is the safe PowerShell quoting; double-quoted
* strings interpret `n `t `" etc. and can be escaped out of.
*/
function psQuote(s: string): string {
return `'${s.replace(/'/g, "''")}'`
}
/**
* cmd.exe argument quoting. cmd.exe does NOT use CommandLineToArgvW-style
* backslash escaping — it toggles its quoting state on every raw "
* character, so an embedded " breaks out of the quoted region and exposes
* metacharacters (& | < > ^) to cmd.exe interpretation = command injection.
*
* Strategy: strip " from the input (it cannot be safely represented in a
* cmd.exe double-quoted string). Escape % as %% to prevent environment
* variable expansion (%PATH% etc.) which cmd.exe performs even inside
* double quotes. Trailing backslashes are still doubled because the
* *child process* (claude.exe) uses CommandLineToArgvW, where a trailing
* \ before our closing " would eat the close-quote.
*/
function cmdQuote(arg: string): string {
const stripped = arg.replace(/"/g, '').replace(/%/g, '%%')
const escaped = stripped.replace(/(\\+)$/, '$1$1')
return `"${escaped}"`
}

View File

@@ -0,0 +1,54 @@
/**
* Terminal preference capture for deep link handling.
*
* Separate from terminalLauncher.ts so interactiveHelpers.tsx can import
* this without pulling the full launcher module into the startup path
* (which would defeat LODESTONE tree-shaking).
*/
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
/**
* Map TERM_PROGRAM env var values (lowercased) to the `app` name used by
* launchMacosTerminal's switch cases. TERM_PROGRAM values are what terminals
* self-report; they don't always match the .app bundle name (e.g.,
* "iTerm.app" → "iTerm", "Apple_Terminal" → "Terminal").
*/
const TERM_PROGRAM_TO_APP: Record<string, string> = {
iterm: 'iTerm',
'iterm.app': 'iTerm',
ghostty: 'Ghostty',
kitty: 'kitty',
alacritty: 'Alacritty',
wezterm: 'WezTerm',
apple_terminal: 'Terminal',
}
/**
* Capture the current terminal from TERM_PROGRAM and store it for the deep
* link handler to use later. The handler runs headless (LaunchServices/xdg)
* where TERM_PROGRAM is unset, so without this it falls back to a static
* priority list that picks whatever is installed first — often not the
* terminal the user actually uses.
*
* Called fire-and-forget from interactive startup, same as
* updateGithubRepoPathMapping.
*/
export function updateDeepLinkTerminalPreference(): void {
// Only detectMacosTerminal reads the stored value — skip the write on
// other platforms.
if (process.platform !== 'darwin') return
const termProgram = process.env.TERM_PROGRAM
if (!termProgram) return
const app = TERM_PROGRAM_TO_APP[termProgram.toLowerCase()]
if (!app) return
const config = getGlobalConfig()
if (config.deepLinkTerminal === app) return
saveGlobalConfig(current => ({ ...current, deepLinkTerminal: app }))
logForDebugging(`Stored deep link terminal preference: ${app}`)
}