﻿# 介紹好用工具：duti (用命令列設定 macOS 檔案的預設開啟程式)

最近我想把 Mac 上 `.md` 檔案的預設開啟程式改成 VS Code。一般的做法是在 Finder 對檔案按「取得資訊」，在「打開檔案的應用程式」選好之後再按「全部更改⋯」。偶爾改一次還好，但這種設定如果能用一行指令完成，就能寫進指令稿，想換到其他不同應用程式時，就可以一鍵切換。

後來找到 [duti](https://github.com/moretension/duti/) 這套命令列工具，一行指令就能把某種檔案類型綁定到指定的應用程式，用起來相當順手。不過實際用下去，我也踩到幾個沒有寫在說明文件裡的地方：它**只能綁定已經註冊的 `.app` 應用程式**，沒辦法直接指定某個 CLI 工具；在 macOS 26 上，它回傳成功之後設定其實還沒生效，要等使用者按下系統跳出的確認視窗才算數；連 `.md` 這麼常見的副檔名，背後都可能同時對應好幾個 [UTI](https://developer.apple.com/documentation/uniformtypeidentifiers)。

這篇文章就來整理 [duti](https://github.com/moretension/duti/) 的安裝與常用指令、我實測時遇到的注意事項，最後再分享一個小技巧：用 AppleScript 做一個很小的 `.app` 當作「轉接頭」，讓你在 Finder 雙擊檔案時，可以把檔案轉交給任何 CLI 工具執行。

![多張不同類型的文件卡片透過彩色纜線接到中央的配線盤，其中一條線經過一個應用程式包裝盒轉接到右側的終端機視窗，配線盤上方浮著一個確認對話框](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/duti-default-apps/banner.webp)

> 本文的測試環境為 Apple Silicon 的 macOS 26.6.2 (Tahoe)，[duti](https://github.com/moretension/duti/) 版本為 Homebrew 提供的 1.5.4，測試時間為 2026 年 10 月。文中的 `com.example.open-with-cli` 只是示意用的 bundle ID，`.owcdemo` 也是我為了測試自訂的副檔名，實作時請換成你自己的名稱。

* * *

### 先搞懂幾個基本觀念

macOS 決定「這個檔案要用哪個 App 打開」的機制叫做 [Launch Services](https://developer.apple.com/documentation/coreservices/launch_services)。它不是直接拿副檔名去對照應用程式，中間還隔了一層 UTI，所以在使用 [duti](https://github.com/moretension/duti/) 之前，先把下面幾個名詞分清楚會比較好理解：

| 名詞  | 說明  | 範例  |
| --- | --- | --- |
| UTI (Uniform Type Identifier) | macOS 用來描述檔案內容類型的識別碼，副檔名與 MIME 類型都會對應到 UTI | `public.plain-text`、`net.daringfireball.markdown` |
| Bundle ID | 應用程式的唯一識別碼，寫在 `.app` 的 `Info.plist` 裡的 `CFBundleIdentifier` | `com.microsoft.VSCode`、`com.apple.TextEdit` |
| Role (角色) | 應用程式對這種檔案扮演的角色 | `viewer`、`editor`、`shell`、`all`、`none` |
| URL scheme | 網址的協定名稱，也可以指定預設處理程式 | `ftp`、`vscode` |

[duti](https://github.com/moretension/duti/) 做的事情，就是呼叫 [Launch Services](https://developer.apple.com/documentation/coreservices/launch_services) 的 API，把「某個 UTI (或 URL scheme)」對應到「某個 bundle ID」。所以你會發現，它的參數裡面從頭到尾都沒有應用程式的路徑，只有 bundle ID。這一點很重要，後面的注意事項幾乎都跟這件事有關。

* * *

### 安裝 duti

[duti](https://github.com/moretension/duti/) 可以透過 Homebrew 安裝：

```bash
brew install duti
```

安裝過程很快，主要的輸出如下：

```
==> Fetching downloads for: duti
✔︎ Bottle duti (1.5.4_1)                Downloaded   15.2KB/ 15.2KB
==> Pouring duti--1.5.4_1.arm64_tahoe.bottle.2.tar.gz
```

裝好之後執行 `duti -h`，可以看到它的用法其實只有三種：

```
usage: duti [ -hvV ] [ -d uti ] [ -l uti ] [ settings_path ]
usage: duti -s bundle_id { uti | url_scheme } [ role ]
usage: duti -x extension
```

如果直接執行 `duti -s` 不帶任何參數，它什麼都不會輸出，也不會有錯誤訊息，第一次用的時候我還以為它當掉了。

* * *

### duti 的常用指令

#### 1\. 先查出應用程式的 bundle ID

既然 [duti](https://github.com/moretension/duti/) 只吃 bundle ID，第一步就是查出目標 App 的 bundle ID。最快的方法是用 `osascript`：

```bash
osascript -e 'id of app "Visual Studio Code"'
```

```
com.microsoft.VSCode
```

這裡要注意，`app "..."` 裡面要填的是 `.app` 的實際名稱。我一開始習慣性地打了 `VS Code`，結果直接得到錯誤：

```
0:2: execution error: 無法取得「application "VS Code"」。 (-1728)
```

如果不確定 App 的名稱，也可以直接從 `.app` 的路徑讀取，這兩種寫法的結果都一樣：

```bash
mdls -name kMDItemCFBundleIdentifier -r "/Applications/Visual Studio Code.app"
defaults read "/Applications/Visual Studio Code.app/Contents/Info" CFBundleIdentifier
```

#### 2\. 設定檔案類型的預設開啟程式

有了 bundle ID 之後，就可以用 `-s` 設定。第二個參數可以寫 UTI，也可以寫以 `.` 開頭的副檔名，[duti](https://github.com/moretension/duti/) 會自動幫你換算成對應的 UTI：

```bash
# 用副檔名指定
duti -s com.microsoft.VSCode .md all

# 用 UTI 指定
duti -s com.microsoft.VSCode net.daringfireball.markdown all
```

最後一個參數是角色，一般想要「雙擊就用這個 App 開」的話，填 `all` 就對了。如果第三個參數省略，[duti](https://github.com/moretension/duti/) 會把第二個參數當成 URL scheme，例如 `duti -s com.apple.Finder ftp` 就是設定 `ftp://` 網址的處理程式。

#### 3\. 查詢目前的設定

[duti](https://github.com/moretension/duti/) 提供三個查詢用的參數：

```bash
# 查詢某個 UTI 目前的預設程式
duti -d net.daringfireball.markdown

# 列出所有宣告可以處理這個 UTI 的程式
duti -l net.daringfireball.markdown

# 用副檔名查詢預設程式
duti -x md
```

不過 `duti -x` 的結果不一定可信，後面的注意事項會詳細說明。我自己會以 `duti -d` 搭配檔案實際的 UTI 來確認設定有沒有生效。

#### 4\. 用設定檔一次套用多筆

[duti](https://github.com/moretension/duti/) 也可以讀取設定檔，每一行依序寫上 bundle ID、UTI 與角色，很適合放進 dotfiles 裡面：

```
com.microsoft.VSCode    net.daringfireball.markdown    all
com.microsoft.VSCode    public.json                    all
com.apple.TextEdit      public.plain-text              all
```

假設這個檔案存成 `~/.duti`，執行 `duti ~/.duti` 就會一次套用。不過在 macOS 26 上，這個用法變得沒那麼方便了，原因請看下一節。

* * *

### 使用 duti 的注意事項

#### 1\. macOS 26 會跳出確認視窗，但 duti 照樣回傳成功

這是我這次花最多時間才搞懂的地方。

我在測試時，把 `.log` 檔暫時綁到自己做的測試 App，測完之後想改回原本的「系統監視程式」(Console)，於是執行了：

```bash
duti -s com.apple.Console com.apple.log all
echo $?
```

指令馬上就結束了，結束代碼是 `0`，也沒有任何錯誤訊息。但用 `duti -d com.apple.log` 一查，結果還是我的測試 App。我以為是 [Launch Services](https://developer.apple.com/documentation/coreservices/launch_services) 有快取，又多試了兩次，結果都一樣。

後來才發現，原來螢幕上已經疊了三個系統對話框，正在等我回答：

![macOS 系統對話框詢問：你要使用「Open With CLI」打開所有副檔名為「.log」的文件，還是繼續使用「系統監視程式」？下方有使用與保留兩個按鈕](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/duti-default-apps/macos-confirm-default-app-dialog.webp)

上面這張是我後來重現時截的圖，問的是另一個方向的變更，但對話框的樣式完全一樣。根據 Scripting OS X 的 [macOS 26.4 brings more default app confirmation prompts](https://scriptingosx.com/2026/03/macos-26-4-brings-more-default-app-confirmation-prompts/) 一文，Apple 從 macOS 26.4 開始，替所有透過 API 或指令稿變更檔案類型預設程式的操作加上這個確認步驟。我在 macOS 26.6.2 上實測整理出以下幾點：

-   **`duti -s` 會立刻回傳 `0`**，但設定在使用者按下「使用」之前都不會生效。
-   使用者如果按「保留」，這次變更就直接作廢，[duti](https://github.com/moretension/duti/) 不會知道，也不會有任何錯誤。
-   沒有回答的對話框會一直堆疊在畫面上，每多一次會改變設定的 `duti -s`，就多一個等待確認的視窗。
-   如果目標已經是目前的預設程式，就不會跳出對話框。
-   如果該副檔名原本沒有預設程式，對話框的文字會變成「你要使用『某 App』打開所有副檔名為『.xxx』的文件嗎？」，按鈕則是「使用」與「不更改」。

所以在 macOS 26 上使用 [duti](https://github.com/moretension/duti/)，**請一定要在執行之後看一下畫面上的確認視窗，按下「使用」之後，再用 `duti -d` 驗證一次**。前面提到的 `~/.duti` 設定檔，也要有心理準備：設定檔裡有幾筆會變更的設定，就得逐一按下確認。同一篇文章也提到，從 Finder 的「取得資訊」手動修改，則不會跳出這個額外的確認視窗。

老實說，在發現這些對話框之前，我繞了好大一圈。因為 `duti -s` 有時生效、有時不生效，我一度懷疑是轉接 App 宣告的角色 (Role) 不對，還去修改 `Info.plist`，加上 `LSItemContentTypes`、把 `CFBundleTypeRole` 從 `Viewer` 改成 `Editor`。改完之後設定果然生效了，我差點就把「角色要宣告成 Editor」當成結論寫進文章。後來回頭重測才發現，真正讓設定生效的，是確認視窗剛好被按掉了；只補上 bundle ID、什麼宣告都沒改的 App，按下「使用」之後一樣可以綁定。**這也是這次最值得記錄的地方：在 macOS 26 上測試預設程式，任何「指令成功但沒生效」的現象，都要先懷疑畫面上是不是有一個還沒回答的視窗。**

如果你跟我一樣是在終端機裡反覆實驗，可以用 System Events 檢查目前有幾個等待確認的視窗。這個指令需要終端機 App 擁有「輔助使用」權限：

```bash
osascript -e 'tell application "System Events" to count windows of process "CoreServicesUIAgent"'
```

回傳 `0` 代表沒有待確認的視窗，這時候再用 `duti -d` 查到的結果才準確。

#### 2\. 只能綁定已註冊的 .app，不能直接指定 CLI

我原本的想法很單純：既然 [duti](https://github.com/moretension/duti/) 可以設定預設程式，那能不能直接指定一個 CLI 工具，例如讓 `.md` 檔案雙擊之後用 [glow](https://github.com/charmbracelet/glow) 在終端機裡預覽？

答案是不行，但 [duti](https://github.com/moretension/duti/) 不會告訴你。我第一次測試時，為了不弄亂系統設定，用的是自己編的副檔名 `.dutidemo`，結果 CLI 路徑得到 `error -50`，我就以為 [duti](https://github.com/moretension/duti/) 會擋下不是 App 的參數。後來才發現，那個 `-50` 其實跟 CLI 路徑無關，而是未知副檔名造成的 (請看本節第 4 點)，連 VS Code 的 bundle ID 也會得到同樣的錯誤。改用真實存在的 UTI 重新測試 CLI 路徑與不存在的 bundle ID，結果完全不同：

```bash
duti -s /opt/homebrew/bin/glow net.daringfireball.markdown all; echo $?
duti -s com.example.not-exist net.daringfireball.markdown all; echo $?
```

兩個指令的結束代碼都是 `0`，沒有任何錯誤訊息。其中 CLI 路徑那一個，系統還很認真地跳出確認視窗，問我「你要使用『/opt/homebrew/bin/glow』打開所有副檔名為『.md』和『.markdown』的文件，還是繼續使用『Code』？」；不存在的 bundle ID 則是連視窗都沒有出現，設定也沒有任何改變。看到 glow 這種確認視窗時，請直接按「保留」。

也就是說，**[Launch Services](https://developer.apple.com/documentation/coreservices/launch_services) 只能把檔案交給一個已經註冊的 `.app`**，[duti](https://github.com/moretension/duti/) 的第一個參數必須是那個 App 的 bundle ID。[duti](https://github.com/moretension/duti/) 本身不會幫你檢查第一個參數是不是一個真的 App，所以結束代碼是 `0` 也不代表設定有意義。這也是本文最後一節要介紹的技巧，會用一個小小的 `.app` 來解決這個問題。

#### 3\. 一個副檔名可能對應好幾個 UTI

我照著前面的方式執行了 `duti -s com.microsoft.VSCode .md all`，在 Finder 雙擊 `.md` 檔案也確實會用 VS Code 打開。但是當我用 `duti -x md` 確認時，看到的卻是 Word：

```
Microsoft Word.app
/Applications/Microsoft Word.app
com.microsoft.Word
```

追查之後才發現，在我這台電腦上，`.md` 這個副檔名同時對應到三個 UTI：系統內建的 `public.markdown`，以及其他 App 宣告的 `net.daringfireball.markdown` 與 `com.unknown.md`。分別查詢的結果如下：

```bash
duti -d public.markdown               # com.microsoft.Word
duti -d net.daringfireball.markdown   # com.microsoft.VSCode
duti -d com.unknown.md                # com.google.antigravity-ide
```

`duti -s` 用 `.md` 指定時，只會設定系統認定的「首選 UTI」，也就是這台電腦上的 `net.daringfireball.markdown`。而 `duti -x` 用的是另一支較舊的 API (`LSGetApplicationForInfo`)，它查出來的 Word，剛好跟 `public.markdown` 的設定一致，所以才會跟 Finder 的實際行為對不起來。

要知道某個檔案實際被系統認定成哪一種 UTI，最可靠的方式是拿一個真實檔案，用 `mdls` 查詢：

```bash
mdls -name kMDItemContentType README.md
```

```
kMDItemContentType = "net.daringfireball.markdown"
```

知道實際的 UTI 之後，再用 `duti -d` 查詢那個 UTI，才是 Finder 雙擊時真正會用的程式，所以只要設定這個 UTI 就夠了。我原本也想順手把 `public.markdown` 一起設定，結果 [duti](https://github.com/moretension/duti/) 直接拒絕：

```
public.markdown does not conform to any UTI hierarchy
```

[duti](https://github.com/moretension/duti/) 在設定之前，會先用較舊的 API 檢查 UTI 是否屬於已知的類型階層，而 `public.markdown` 沒有通過這項檢查，所以 [duti](https://github.com/moretension/duti/) 無法替它設定預設程式。

#### 4\. 系統不認得的副檔名，要用 mdls 查出動態 UTI

如果副檔名沒有任何 App 宣告過，macOS 會替它產生一個以 `dyn.` 開頭的動態 UTI。這時候用 `.ext` 的寫法反而會失敗：

```bash
duti -s com.example.open-with-cli .owcdemo all
```

```
failed to set com.example.open-with-cli as handler for dyn.ah62d4qmxhk2x4551qrwgn5pt (error -50)
```

我去翻了 [duti](https://github.com/moretension/duti/) 的原始碼，它在換算副檔名時，會要求產生的 UTI 必須符合 `public.content`，算出來的動態 UTI 就跟檔案實際拿到的不一樣。用 `mdls` 查詢真實檔案，可以看到兩者確實不同：

```bash
mdls -raw -name kMDItemContentType "範例 檔案.owcdemo"
```

```
dyn.ah62d4rv4ge80875dqvw0452
```

改用這個查出來的 UTI 來設定就可以成功，當然同樣要按下確認視窗：

```bash
duti -s com.example.open-with-cli dyn.ah62d4rv4ge80875dqvw0452 all
duti -d dyn.ah62d4rv4ge80875dqvw0452
```

```
com.example.open-with-cli
```

* * *

### 技巧：用 AppleScript 小程式把檔案轉發給任意 CLI

既然 [duti](https://github.com/moretension/duti/) 只認 `.app`，那就做一個 `.app` 給它認。這個 App 本身什麼事都不做，只負責把 Finder 交給它的檔案路徑，原封不動地轉交給一支 shell 指令稿，要用哪個 CLI 處理，全部由這支指令稿決定。

原理是這樣的：在 Finder 雙擊檔案時，[Launch Services](https://developer.apple.com/documentation/coreservices/launch_services) 會啟動預設程式，並透過 Apple Event 把檔案清單傳給它。用 AppleScript 撰寫的小程式 (applet) 只要實作 `on open` 處理常式，就能收到這份清單，再透過 `do shell script` 呼叫任何命令列工具。整個流程如下：

```
Finder 雙擊檔案
  → Launch Services 找到預設程式 Open With CLI.app
  → AppleScript 的 on open 收到檔案清單
  → do shell script 呼叫 ~/.local/bin/open-with-cli
  → 指令稿依副檔名轉給 glow、less 或其他 CLI
```

#### 步驟一：撰寫 AppleScript 程式碼

先建立 `OpenWithCLI.applescript`：

```applescript
on open theItems
    set args to ""
    repeat with anItem in theItems
        set args to args & " " & quoted form of POSIX path of anItem
    end repeat
    do shell script "\"$HOME/.local/bin/open-with-cli\"" & args & " > /dev/null 2>&1 &"
end open

on run
    display dialog "請把檔案拖曳到這個 App，或用 duti 將它設為預設開啟程式。" buttons {"OK"} default button 1
end run
```

這裡有幾個細節：

-   `quoted form of` 會替每個路徑加上適當的引號，檔名有空白或中文都不會出問題。
-   指令最後的 `> /dev/null 2>&1 &` 讓指令稿在背景執行。少了這段，`do shell script` 會等指令稿結束才返回，App 就會一直掛在 Dock 上。
-   `on run` 是直接雙擊 App 本身時會執行的部分，這裡只顯示一段提示文字。

#### 步驟二：編譯成 .app 並補上 bundle ID

用 macOS 內建的 `osacompile` 編譯成 App，我習慣放在 `~/Applications`：

```bash
mkdir -p ~/Applications
osacompile -o ~/Applications/"Open With CLI.app" OpenWithCLI.applescript
```

**這一步最容易踩坑的地方是：`osacompile` 產生的 `Info.plist` 裡面根本沒有 `CFBundleIdentifier`**。沒有 bundle ID，[duti](https://github.com/moretension/duti/) 就無從指定它。所以要自己補上，名稱可以自己取，只要不跟其他 App 重複就好：

```bash
/usr/libexec/PlistBuddy -c 'Add :CFBundleIdentifier string com.example.open-with-cli' \
  ~/Applications/"Open With CLI.app/Contents/Info.plist"
```

修改 `Info.plist` 之後，App 原本的簽章就失效了，用 `codesign --verify` 檢查會看到這樣的錯誤：

```
invalid Info.plist (plist or signature have been modified)
```

所以我會重新做一次 ad-hoc 簽章，讓簽章與修改後的內容一致，再手動向 [Launch Services](https://developer.apple.com/documentation/coreservices/launch_services) 註冊：

```bash
codesign --force --deep -s - ~/Applications/"Open With CLI.app"

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -f ~/Applications/"Open With CLI.app"
```

`osacompile` 產生的 App 預設就宣告了可以開啟所有類型的檔案 (`CFBundleTypeExtensions` 為 `*`)，所以不需要另外修改文件類型的宣告。

#### 步驟三：撰寫轉發用的指令稿

接著建立 `~/.local/bin/open-with-cli`，這支指令稿才是真正決定「要交給哪個 CLI」的地方：

```zsh
#!/bin/zsh
# 由 Open With CLI.app 呼叫，參數是 Finder 傳進來的檔案完整路徑
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# 需要互動的 CLI，產生一個暫時的 .command 檔交給 Terminal 執行
run_in_terminal() {
  local script
  script=$(mktemp "${TMPDIR:-/tmp}/open-with-cli.XXXXXX") || return 1
  mv "$script" "$script.command" && script="$script.command"
  {
    print -r -- '#!/bin/zsh -l'
    print -r -- "rm -f ${(q)script}"
    print -r -- "${(j: :)${(q)@}}"
  } > "$script"
  chmod +x "$script"
  open -a Terminal "$script"
}

for f in "$@"; do
  case "${f:l}" in
    *.md|*.markdown) run_in_terminal glow -p "$f" ;;
    *.log)           run_in_terminal less +F "$f" ;;
    *)               open -a TextEdit "$f" ;;
  esac
done
```

記得加上執行權限：

```bash
chmod +x ~/.local/bin/open-with-cli
```

這支指令稿有兩個重點：

1.  **一定要自己設定 `PATH`**。依照 Apple 的 [Technical Note TN2065](https://developer.apple.com/library/archive/technotes/tn2065/_index.html)，`do shell script` 一律使用 `/bin/sh`，而且不會讀取互動式 shell 的設定檔。從 Finder 啟動的 App 只會繼承很陽春的環境變數，像 Homebrew 安裝在 `/opt/homebrew/bin` 的 `glow` 很可能就找不到。
2.  **需要終端機畫面的 CLI，要另外開一個 Terminal 視窗**。`glow -p`、`less +F` 這類互動式工具需要 TTY，所以我讓指令稿產生一個暫時的 `.command` 檔，交給 Terminal 執行。`.command` 檔一開始就會刪除自己，不會在暫存資料夾留下垃圾。用 `${(q)...}` 處理參數，則是為了讓含有空白或特殊字元的路徑也能正確傳遞。

如果你要轉發的是不需要互動的 CLI (例如用 `pandoc` 轉檔)，直接在 `case` 裡面呼叫就好，不需要經過 Terminal。

#### 步驟四：用 duti 綁定並驗證

最後就是用 [duti](https://github.com/moretension/duti/) 把檔案類型指向這個 App。以我測試用的 `.owcdemo` 為例，先用 `mdls` 查出實際的 UTI，再進行設定：

```bash
duti -s com.example.open-with-cli dyn.ah62d4rv4ge80875dqvw0452 all
```

記得在畫面上按下「使用『Open With CLI』」，然後確認設定確實生效：

```bash
duti -d dyn.ah62d4rv4ge80875dqvw0452
```

```
com.example.open-with-cli
```

接著就可以實際測試了。我刻意用了檔名含有空白與中文的檔案：

```bash
# 模擬在 Finder 雙擊，會走預設程式
open "範例 檔案.owcdemo"

# 直接指定用這個 App 開啟 Markdown 檔案
open -a ~/Applications/"Open With CLI.app" "週報 草稿.md"
```

第一個指令走的是 `*)` 分支，用 TextEdit 開啟了 `範例 檔案.owcdemo`。第二個指令則開了一個新的 Terminal 視窗，在裡面執行 `glow -p`，完整路徑 `.../週報 草稿.md` 被當成單一參數正確傳入。一次選取多個檔案打開也沒問題，`on open` 會收到完整的清單，全部一起交給指令稿處理。

如果之後不想用了，先用 `duti -s` 把綁定過的檔案類型指回原本的 App，再反註冊並刪除 App 即可：

```bash
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -u ~/Applications/"Open With CLI.app"
rm -rf ~/Applications/"Open With CLI.app" ~/.local/bin/open-with-cli
```

* * *

### 實際應用：雙擊 .md 改用 mhtml 在瀏覽器預覽

做好轉接 App 之後，我自己實際用來做的第一件事，就是把 `.md` 的預設開啟程式換成我寫的 [mhtml](https://github.com/doggy8088/mhtml)。它可以把 Markdown 轉成一個 CSS 全部內嵌的獨立 HTML 檔，支援 GFM 表格、程式碼語法醒目提示、數學式與 Mermaid 圖表。執行 `mhtml 檔名.md` 而不指定輸出檔時，它會在暫存資料夾產生 HTML 並直接用瀏覽器開啟，剛好適合拿來當 Markdown 的預覽器。

mhtml 不需要終端機畫面，所以轉發指令稿反而比前面的範例更簡單：

```zsh
#!/bin/zsh
# 由 ~/Applications/Open With CLI.app 呼叫，參數是 Finder 傳進來的檔案完整路徑
export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"

for f in "$@"; do
  case "${f:l}" in
    *.md|*.markdown) mhtml "$f" ;;
    *)               open -a TextEdit "$f" ;;
  esac
done
```

我的 mhtml 是用 `cargo install` 安裝的，執行檔在 `~/.cargo/bin`，所以 `PATH` 一定要把這個資料夾加進去。如果你是用 `npm install -g mhtml-cli` 安裝，請先在終端機執行 `command -v mhtml`，再把查到的資料夾加進 `PATH`。

接著用 [duti](https://github.com/moretension/duti/) 綁定 `.md` 實際使用的 UTI，並按下確認視窗的「使用『Open With CLI』」：

```bash
duti -s local.will.open-with-cli net.daringfireball.markdown all
duti -d net.daringfireball.markdown
```

```
local.will.open-with-cli
```

這次確認視窗問的是「你要使用『Open With CLI』打開所有副檔名為『.md』和『.markdown』的文件，還是繼續使用『Code』？」，可見一個 UTI 會同時涵蓋好幾個副檔名。我本來還想順便設定 `public.markdown`，結果就是在這裡踩到前面第 3 點提到的 `does not conform to any UTI hierarchy`。好在檔案實際的 UTI 是 `net.daringfireball.markdown`，只設定這一個就夠了。

設定完成之後，在 Finder 雙擊任何 `.md` 檔，瀏覽器就會開出排版好的預覽頁面。Markdown 裡用相對路徑引用的圖片也能正常顯示，因為 mhtml 在預覽模式下，會自動把 `<base href>` 設成 Markdown 檔所在的資料夾。

這樣設定之後，有兩件事要知道：

-   雙擊 `.md` 不會再開 VS Code，需要編輯時可以用 `code 檔名.md`，或在 Finder 按右鍵選「打開檔案的應用程式」。
-   預覽用的 HTML 會放在 `$TMPDIR/mhtml/`，mhtml 不會自動刪除。

想改回 VS Code 的話，執行下面這行，再按一次確認視窗就好：

```bash
duti -s com.microsoft.VSCode net.daringfireball.markdown all
```

* * *

### 總結

[duti](https://github.com/moretension/duti/) 是一個很小卻很實用的工具，特別適合想把 macOS 環境設定寫成指令稿的人。整理一下這次的重點：

-   **[duti](https://github.com/moretension/duti/) 只吃 bundle ID**：用 `osascript -e 'id of app "完整 App 名稱"'` 查詢。傳入 CLI 路徑或不存在的 bundle ID，[duti](https://github.com/moretension/duti/) 一樣回傳成功，不會替你檢查，遇到 CLI 路徑的確認視窗請按「保留」。
-   **macOS 26 需要使用者確認**：`duti -s` 回傳 `0` 不代表設定已經生效，一定要按下確認視窗，再用 `duti -d` 驗證。
-   **先查出檔案實際的 UTI**：一個副檔名可能對應好幾個 UTI，`duti -x` 的結果也可能跟 Finder 的實際行為不同，用 `mdls -name kMDItemContentType` 查真實檔案最可靠。系統不認得的副檔名用 `.ext` 寫法會得到 `error -50`，要改用查出來的 `dyn.` UTI。
-   **指令成功但沒生效，先找確認視窗**：我曾經把「角色要宣告成 Editor」、「CLI 路徑會得到 `-50`」當成原因，最後都證明是誤判。用 `CoreServicesUIAgent` 的視窗數確認沒有待回答的對話框，再下結論。
-   **想用 CLI 開檔案，就做一個轉接用的 App**：`osacompile` 編譯 AppleScript 小程式，補上 `CFBundleIdentifier`，重新簽章、註冊之後，就能用 [duti](https://github.com/moretension/duti/) 綁定，再由指令稿把檔案轉發給任何 CLI。

這個轉接 App 的好處是只要做一次，之後想換工具或新增檔案類型，只需要修改 `~/.local/bin/open-with-cli` 這支指令稿就好，不用重新編譯 App。

* * *

### 相關連結

-   [moretension/duti: A command-line tool to select default applications for document types and URL schemes on Mac OS X](https://github.com/moretension/duti/)
-   [Homebrew Formulae：duti](https://formulae.brew.sh/formula/duti)
-   [Scripting OS X：macOS 26.4 brings more default app confirmation prompts](https://scriptingosx.com/2026/03/macos-26-4-brings-more-default-app-confirmation-prompts/)
-   [Apple Technical Note TN2065：do shell script in AppleScript](https://developer.apple.com/library/archive/technotes/tn2065/_index.html)
-   [Apple Developer Documentation：Launch Services](https://developer.apple.com/documentation/coreservices/launch_services)
-   [Apple Developer Documentation：Uniform Type Identifiers](https://developer.apple.com/documentation/uniformtypeidentifiers)
-   [doggy8088/mhtml: Convert Markdown to a beautiful, self-contained HTML page](https://github.com/doggy8088/mhtml)
-   [charmbracelet/glow: Render markdown on the CLI](https://github.com/charmbracelet/glow)
