值班的人半夜三點被 call 起來,翻開 runbook 第三步:

  1. 開一個終端機,cd 到 web-gateway 的 repo,啟動 Claude Code,把下面這段貼進去:
    1
    2
    5xx rate is elevated on web-gateway. Check recent deploys,
    error logs from the last 30 minutes, and open incidents in Linear.

看起來很完整。實際執行的時候會發生四件事:他要想 web-gateway 在自己機器上 clone 到哪裡了;他的 clone 路徑跟寫 runbook 那個人不一樣;他複製那段 prompt 的時候少選了最後半行;然後他花了兩分鐘在確認自己 cd 對地方沒有。

這四件事每一件都很小。加起來就是為什麼半夜的 runbook 總是比白天演練的時候慢。

舊做法的問題不在寫得不夠清楚

把上面那段再寫得更詳細一點,加上「如果你不確定 clone 在哪,用 find ~ -name web-gateway」,問題不會消失,只會變長。

因為那段文字要求讀的人做四次轉譯:從文字轉成 cd 指令、從自己的心智模型轉成實際路徑、從畫面上的段落轉成剪貼簿內容、從剪貼簿轉成輸入框。每一次轉譯都是一次出錯的機會,而半夜三點的出錯率跟白天不是同一個數量級。

新做法把這四次轉譯壓成一次點擊:

1
2
3
4
5
## High 5xx rate on web-gateway

1. Acknowledge the page in PagerDuty.
2. [Open Claude Code in the gateway repo](claude-cli://open?repo=acme/web-gateway&q=5xx%20rate%20is%20elevated%20on%20web-gateway.%20Check%20recent%20deploys%2C%20error%20logs%20from%20the%20last%2030%20minutes%2C%20and%20open%20incidents%20in%20Linear.)
3. Post initial findings in #incident.

點下去,一個新的終端機視窗開起來,Claude Code 已經跑在他自己那份 clone 裡,prompt 已經在輸入框裡,游標在後面等他按 Enter。

它跟 mailto: 走同一條路

claude-cli:// 是一個自訂的 URL scheme,跟 mailto: 是同一種東西。

mailto: 你早就在用了。網頁上一個 email 連結點下去,瀏覽器自己不會處理,它把整串 URL 交給作業系統,作業系統查表發現 mailto: 這個前綴登記給你的郵件軟體,就把它叫起來。claude-cli:// 走的是完全一樣的路徑,只是查表查到的是 Claude Code。

流程分四步:瀏覽器把 URL 交給作業系統 → 作業系統認出前綴、啟動 Claude Code → 一個新終端機視窗開起來,工作目錄是連結指定的那個,prompt 已經填好 → 你讀過、想改就改,按 Enter 才送出。

最後那句是重點,等下會回來講。

三個參數,兩種指定目錄的方式

claude-cli://open 是 handler 唯一接受的路徑,後面接查詢參數。最短的形式什麼都不帶:

1
claude-cli://open

這樣會在你的家目錄開一個空的 session。要有用得加參數,總共只有三個:

q 是要預先填進輸入框的文字。必須 URL-encode,換行用 %0A,上限 5,000 字元。

cwd 是絕對路徑。網路路徑和 UNC 路徑會被拒絕,路徑裡含有隱形字元或雙向控制字元的也會被拒絕。後者是防止有人用從右到左的覆寫字元把 /tmp/evil 顯示成看起來像 /home/you/project 的樣子。

repo 是 GitHub 的 owner/name slug,Claude Code 會把它解析成一份它見過的本地 clone。

cwdrepo 都給的話,cwd 贏,repo 被忽略——就算 cwd 指的那個路徑根本不存在也一樣。這條規則不太直覺,但它是確定的行為,不是「盡量」。

cwd 還是 repo,看誰會點這個連結

這兩個參數不是新舊關係,是兩種不同情境。

cwd,前提是點連結的每個人都把專案放在同一個絕對路徑下。標準化的 devcontainer、公司統一的 VM image,這種環境下 cwd 最直接。

repo,前提是連結要給一群 clone 在不同地方的人。它的解析邏輯值得知道細節,因為它決定了什麼時候會失敗:

每次你在一個 Git repo 裡跑 claude,Claude Code 就把那個目錄的路徑記在該 repo 的 GitHub slug 底下。deep link 進來的時候,repo 會開你最近用過的那一個。多份 clone、多個 worktree 是分開追蹤的,所以它挑的是你最後工作的那份。

失敗的情況只有一種:你從來沒有在那份 clone 裡跑過 claude。查表查不到,session 就開在家目錄。這也是為什麼 runbook 裡的 repo 連結對新人常常「沒反應」。不是連結壞了,是他還沒在那個 repo 裡跑過一次。

session 開起來的時候,歡迎訊息會顯示它挑了哪個路徑,可以拿來確認開對地方了。另外,連結不會幫你切 branch,開起來是什麼狀態就是什麼狀態。

handler 什麼時候才註冊

這是最多人卡住的一格,而且卡住的原因跟直覺相反。

Claude Code 在 macOS、Linux、Windows 上都會註冊 handler,但註冊的時機是你送出互動式 session 的第一個 prompt 的那一刻。開了 claude 然後直接離開,沒送過任何 prompt,handler 不會註冊。沒有另外的安裝指令要跑。

註冊只寫使用者層級的位置:

平台 handler 位置
macOS ~/Applications/Claude Code URL Handler.app
Linux $XDG_DATA_HOME/applications 下的 claude-code-url-handler.desktop,預設 ~/.local/share/applications
Windows HKEY_CURRENT_USER\Software\Classes\claude-cli

不確定自己註冊好了沒,在 macOS 上可以直接翻那個 app 的 Info.plist 來看,不用真的點一個連結:

1
plutil -p ~/Applications/"Claude Code URL Handler.app"/Contents/Info.plist

我在自己機器上跑出來是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"CFBundleExecutable" => "claude"
"CFBundleIdentifier" => "com.anthropic.claude-code-url-handler"
"CFBundleName" => "Claude Code URL Handler"
"CFBundlePackageType" => "APPL"
"CFBundleURLTypes" => [
0 => {
"CFBundleURLName" => "Claude Code Deep Link"
"CFBundleURLSchemes" => [
0 => "claude-cli"
]
}
]
"CFBundleVersion" => "1.0"
"LSBackgroundOnly" => true
}

CFBundleURLSchemes 裡有 claude-cli 就代表這個 app 有宣告要處理這個 scheme。但宣告歸宣告,作業系統認不認又是一回事,要查 LaunchServices 的實際登記:

1
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -dump | grep -i "claude-cli"

看到 claimed schemes: claude-cli: 就是真的登記上去了。

LSBackgroundOnly 那一行也解釋了一個小疑問:這個 app 不會在 Dock 上跳出來,它只負責把請求轉給終端機。

至於開哪個終端機,macOS 會記住你最近一次互動 session 用的那個並沿用,支援 iTerm2、Ghostty、kitty、Alacritty、WezTerm 和 Terminal.app。Linux 先看 $TERMINAL 環境變數,再找 x-terminal-emulator,再往下試常見的。Windows 的順序是寫死的:Windows Terminal、PowerShell、cmd.exe。所以在 macOS 上開錯終端機的解法很單純:在你想要的那個終端機裡跑一次 claude 就好。

不用點也能觸發

deep link 不是只能點。腳本、alias、監控系統的 webhook 都可以叫它,用作業系統各自的開啟指令:

1
2
3
4
5
# macOS
open "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"

# Linux
xdg-open "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"
1
2
# Windows PowerShell
Start-Process "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"

cmd.exe 有個陷阱:start 會把第一個帶引號的參數當成視窗標題,所以要先塞一個空標題進去。

1
start "" "claude-cli://open?repo=acme/payments&q=review%20open%20PRs"

Linux 上如果 xdg-open 找不到,它屬於 xdg-utils 套件,精簡的 server image、容器和 WSL 常常沒裝。裝完還是沒動靜的話,那台可能根本沒有桌面環境可以派發。

會被吃掉的地方

寫好連結貼進 GitHub README,會發現它變成一段沒有連結的純文字。

GitHub 渲染 Markdown 的時候只放行 httphttps,其他 scheme 一律剝掉。README、issue、PR、wiki 全都一樣,[label](claude-cli://...) 只剩下 label,連結沒了,URL 也看不到。

這件事的麻煩不在於不能用,在於它失敗得很安靜。你貼上去、預覽看起來有字、就以為好了,實際上讀的人看到一個不能點的詞。

在 GitHub 上的做法是把整串 URL 放進 code block,讓人看得到、可以複製到網址列。內部 wiki、Notion、Slack 這些允許自訂 scheme 的地方就沒有這個問題。

填進來的 prompt 在你按 Enter 之前是死的

現在回到前面那句「按 Enter 才送出」。

deep link 本身不執行任何東西。它只做兩件事:選一個目錄、把文字填進輸入框。從一個你不信任的頁面點進來,那段 prompt 還是死的,在你讀過並按下 Enter 之前不會有任何東西送到模型。

session 開起來的時候,輸入框下面會有一行警告寫著 Prompt from an external link,一直留到你送出或清掉為止。prompt 超過 1,000 字元的話,這行警告還會附上字元數,提醒你捲上去把全文看完。因為長 prompt 可以把真正的指令推到畫面外,這是這類攻擊最常見的手法。

權限規則、CLAUDE.md、對該目錄的信任提示,全部照常套用,跟你自己開的 session 沒有差別。

想整個關掉的話,settings.jsondisableDeepLinkRegistration 設成 "disable" 就不會註冊。組織要強制、不讓使用者自己打開,就設在 managed settings 裡。

runbook 從說明書變成入口

回到半夜三點那個人。

舊做法裡,runbook 是一份說明書,它描述一組動作,由人負責執行。新做法裡,runbook 的那一行是一個入口,動作已經編碼在裡面,人只負責決定要不要進去。

差別不在少打幾個字。差別在出錯的地方變了。舊做法可能錯在 cd 錯目錄、複製漏一行、記錯 repo 名字,這些錯誤跟你要解的問題完全無關,卻要佔用你半夜最清醒的那五分鐘。新做法把這些錯誤集中到一個地方:連結寫錯了。而連結是白天寫的,可以被 review、可以被測、只要錯一次就會被修掉。

同樣的道理可以往外推。CI 失敗的通知可以帶一個把失敗 job 名稱填好的連結;監控告警可以帶一個針對那個指標的調查 prompt;新人的 onboarding 文件可以讓每一節都直接開在對的子專案。q 塞不下的長 prompt,官方的建議是把它存成 repo 裡的一個 skill,讓 q 只負責喊那個 skill 的名字。

有兩件事要說清楚。第一,我實際驗證過的只有 handler 在我這台 Mac 上的註冊狀態:app bundle 的 scheme 宣告確認過,LaunchServices 的實際登記也確認過。上面那些參數行為與各平台路徑是照官方 deep links 文件整理的,Linux 和 Windows 我沒有實跑。

第二,VS Code 擴充有它自己的一套 handler,走 vscode://anthropic.claude-code/open,開的是編輯器分頁不是終端機視窗。那組參數跟這篇講的不完全一樣。

要試的話,最小的一步是打開瀏覽器網址列,貼 claude-cli://open 按 Enter。如果一個終端機視窗跳出來,你的 handler 就是好的,剩下的都是在這個基礎上加參數而已。如果沒跳出來,回去跑一次 claude 並且送出一個 prompt,再試一次。