chore: initialize recovered claude workspace
This commit is contained in:
123
src/utils/deepLink/banner.ts
Normal file
123
src/utils/deepLink/banner.ts
Normal 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
|
||||
}
|
||||
170
src/utils/deepLink/parseDeepLink.ts
Normal file
170
src/utils/deepLink/parseDeepLink.ts
Normal 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()
|
||||
}
|
||||
136
src/utils/deepLink/protocolHandler.ts
Normal file
136
src/utils/deepLink/protocolHandler.ts
Normal 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() }
|
||||
}
|
||||
348
src/utils/deepLink/registerProtocol.ts
Normal file
348
src/utils/deepLink/registerProtocol.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
557
src/utils/deepLink/terminalLauncher.ts
Normal file
557
src/utils/deepLink/terminalLauncher.ts
Normal 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}"`
|
||||
}
|
||||
54
src/utils/deepLink/terminalPreference.ts
Normal file
54
src/utils/deepLink/terminalPreference.ts
Normal 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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user