iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

前面幾篇談的多是 CLI 內部的機制:命令怎麼解析、輸出怎麼排版、錯誤怎麼分層。從這篇開始要轉向應用面,帶你把 CLI 接上幾個常見的外部系統與協定:讀取剪貼簿圖片之後,還會把既有的 REST API 包成命令列工具、使用 WebSocket 連線接收即時訊息、走完 OAuth 2.0 的瀏覽器授權流程,以及串接 LLM API 的串流回應。

這篇要讓 CLI 除了接收文字,也能接收使用者貼上剪貼簿中的圖片或檔案。像 Claude Code CLI,使用者可以在對話中加入截圖,讓模型查看畫面上的錯誤訊息或操作介面。要提供這類輸入,程式除了處理 Cobra 的 args 與 flags,還要識別終端機的貼上事件,再從系統剪貼簿讀取資料。

開啟 cli-sample/clipboard-image/ 範例,目錄如下:

cli-sample/clipboard-image/
├── go.mod
├── main.go
└── cmd/
    ├── root.go
    └── paste.go

我們會用兩個套件來處理:golang.design/x/clipboard 負責讀取系統剪貼簿,golang.org/x/term 負責切換終端機輸入模式。

終端機貼上事件的傳遞機制

當使用者在終端機執行貼上操作(如按下 Command+V 或右鍵 Paste)時,這項動作是由終端機接收並處理。

終端機的 stdin 只傳送文字字元與控制命令,不會包含剪貼簿裡的圖片二進位資料。在預設狀況下,終端機只會把貼上的文字直接送進 stdin,並且會等到使用者按下 Enter 才一次交付給程式。這種預設模式無法讓 Go 程式直接得知「使用者執行了貼上」,也無法處理圖片輸入。

要讓 CLI 能夠即時捕捉貼上事件,需要向終端機設定兩個條件:

  1. 開啟 Bracketed Paste 模式:要求終端機用控制標記把貼上內容像括號(Bracket)一樣包裹起來(開始標記為 ESC[200~,結束標記為 ESC[201~)。傳統終端機貼上多行文字時容易誤觸 Enter 執行,這個模式原本用來將貼上內容標記為單一區塊;這裡我們則用它讓 CLI 辨識使用者執行了貼上。
  2. 切換終端機至 Raw Mode:讓終端機在收到輸入時立即送出,不必等到使用者按下 Enter,使 Go 程式能夠即時讀取 escape sequence。

模式開啟後,當使用者執行貼上時(以貼上文字 hello 為例),Go 程式會從 stdin 逐字讀到:

ESC[200~helloESC[201~

其中:

ESC[200~  貼上開始標記
hello     貼上的內容
ESC[201~  貼上結束標記

圖片的二進位資料不會出現在 stdin 中。Go 程式是以 ESC[200~ 作為「使用者執行了貼上」的通知,收到這項通知後,才呼叫系統剪貼簿 API 讀取圖片。不論使用者設定哪一組快捷鍵,只要終端機識別為貼上操作,送出的開始與結束標記都相同。

理解事件流程後,接著就來看 runPasteImage是怎麼處理貼上事件的流程:

var rootCmd = &cobra.Command{
    Use:   "clipboard-image",
    Short: "偵測貼上事件並讀取剪貼簿圖片",
    Args:  cobra.NoArgs,
    RunE:  runPasteImage,
}

runPasteImage 定義在 cmd/paste.go。函式執行時,首先確認 stdin 連接到互動式終端機。如果輸入來自檔案或 pipe,程式無法切換終端機模式,直接回傳錯誤:

inputFile, ok := cmd.InOrStdin().(*os.File)
if !ok || !term.IsTerminal(int(inputFile.Fd())) {
    return fmt.Errorf("clipboard-image 需要在互動式終端機中執行")
}

接著呼叫 term.MakeRaw 切換 stdin 為 Raw Mode。終端機預設是 Canonical Mode,要等按下 Enter 才整行交給程式;Raw Mode 讓每個按鍵一送出就交給程式,這裡需要它讓 stdin 能即時讀到 escape sequence。Raw Mode 完整的行為留到「CLI 實作簡易編輯器」的 Raw Mode 專篇再細講。函式結束時透過 defer term.Restore 恢復原本的終端機設定:

oldState, err := term.MakeRaw(int(inputFile.Fd()))
if err != nil {
    return fmt.Errorf("無法切換終端機模式:%w", err)
}
defer term.Restore(int(inputFile.Fd()), oldState)

切換 Raw Mode 後,程式接著向終端機輸出控制命令 ESC[?2004h(在 Go 中寫為 \x1b[?2004h),要求終端機開啟 Bracketed Paste 模式。並用 defer 在程式結束時寫入 \x1b[?2004l 關閉模式:

fmt.Fprint(out, "\x1b[?2004h")
defer fmt.Fprint(out, "\x1b[?2004l")

控制命令的語法結構如下:

  • \x1b[ 代表 ESC [,為控制命令的開始序列。
  • ?2004 為 Bracketed Paste 的功能代碼。
  • 結尾 h 代表開啟模式,l 代表關閉模式。

解析 bracketed paste

開啟 Raw Mode 與 Bracketed Paste 後,runPasteImage 使用 bufio.NewReader(inputFile) 建立讀取器,在主迴圈中透過 reader.ReadRune() 逐字讀取 stdin。當讀到 ESC 字元(數值 27)時,將 reader 交給 readBracketedPaste 確認後面是否接續貼上標記 [200~;確認為貼上事件後,再呼叫 handlePastedImage 向系統剪貼簿讀取圖片:

reader := bufio.NewReader(inputFile)
for {
    r, _, err := reader.ReadRune()
    if err != nil {
        return err
    }

    switch r {
    case 3, 4: // Ctrl+C、Ctrl+D
        return nil
    case 27: // 讀到 ESC,繼續檢查後面是否為 [200~
        pastedText, isPaste, err := readBracketedPaste(reader)
        if err != nil {
            return err
        }
        if !isPaste {
            continue
        }

        if err := handlePastedImage(out, pastedText); err != nil {
            fmt.Fprintln(errOut, err)
        }
    }
}

readBracketedPaste 會持續收集字元直到讀到貼上結束標記 ESC[201~,並回傳兩標記之間的內容:

ESC[200~helloESC[201~
         └─┬─┘
      pastedText

回傳值 isPaste 表示是否確認為完整的貼上事件,pastedText 則保存中間的文字內容。

從剪貼簿取得圖片

確認收到貼上事件後,handlePastedImage 呼叫 readClipboardImage 讀取圖片內容,並將二進位資料轉為 base64 格式:

// handlePastedImage 處理已確認的 Paste 事件。
// pastedText 是終端機送進 stdin 的文字內容,不包含圖片資料;
// 圖片要由 readClipboardImage 另外從系統剪貼簿讀取。
// out 用來輸出圖片格式、大小與 base64 長度。
func handlePastedImage(out io.Writer, pastedText string) error {
    data, mediaType, err := readClipboardImage()
    if err != nil {
        if pastedText != "" {
            return fmt.Errorf("貼上的內容是文字,不是圖片")
        }
        return err
    }

    encoded := base64.StdEncoding.EncodeToString(data)
    fmt.Fprintf(
        out,
        "已讀取圖片:%s,%d bytes,base64 %d 字元\n",
        mediaType,
        len(data),
        len(encoded),
    )
    return nil
}

readClipboardImage 負責處理 macOS 剪貼簿的兩種圖片來源:

  • 在 Finder 中複製圖片檔案(Cmd+C):剪貼簿主要儲存檔案路徑。為了取得完整的原始檔案(而非轉碼後的預覽圖),readClipboardImage 會先呼叫 readCopiedImageFile,透過 macOS 內建的 osascript 取得實體路徑後,再用 os.ReadFile 讀取原始檔案。
  • 直接拷貝影像內容(截圖、瀏覽器或 Preview 的拷貝影像):剪貼簿中只有二進位圖片資料而無檔案路徑。當沒有找到檔案路徑時,程式才呼叫 clipboard.Read(clipboard.FmtImage) 直接讀取圖片內容:
func readClipboardImage() ([]byte, string, error) {
    if runtime.GOOS == "darwin" {
        data, mediaType, found, err := readCopiedImageFile()
        if err != nil {
            return nil, "", err
        }
        if found {
            return data, mediaType, nil
        }
    }

    data := clipboard.Read(clipboard.FmtImage)
    if data == nil {
        return nil, "", fmt.Errorf("讀不到剪貼簿圖片")
    }

    return data, "image/png", nil
}

從 Finder 複製的圖片檔案取得路徑

使用者在 Finder 選取圖片檔後按 Cmd+C,剪貼簿裡存的是檔案參照。osascript 是 macOS 內建的命令列工具,可以從終端機執行 AppleScript;這裡用它向系統剪貼簿取得 Finder 複製的檔案路徑。取得路徑後,readCopiedImageFile 再用 os.ReadFile 開啟原始檔案:

// readCopiedImageFile 嘗試讀取 Finder 複製的單一圖片檔案。
// 回傳值依序是檔案內容、MIME type、是否找到檔案參照,以及處理錯誤。
func readCopiedImageFile() ([]byte, string, bool, error) {
    // 從 macOS 剪貼簿取得 file URL,再轉成 Go 可以讀取的 POSIX 路徑。
    out, err := exec.Command(
        "osascript",
        "-e", "set copiedFile to the clipboard as «class furl»",
        "-e", "POSIX path of (copiedFile as alias)",
    ).Output()
    if err != nil {
        // 剪貼簿沒有檔案參照,讓呼叫端改讀一般的圖片內容。
        return nil, "", false, nil
    }

    // osascript 的輸出結尾帶有換行,讀檔前先移除前後空白。
    path := strings.TrimSpace(string(out))
    data, err := os.ReadFile(path)
    if err != nil {
        // 已找到檔案參照,所以 found 回傳 true,並保留實際的讀檔錯誤。
        return nil, "", true, fmt.Errorf("無法讀取剪貼簿中的檔案 %q:%w", path, err)
    }

    // 根據檔案內容偵測 MIME type,不依賴 .png 或 .jpg 副檔名。
    mediaType := http.DetectContentType(data)
    if !strings.HasPrefix(mediaType, "image/") {
        // 剪貼簿裡有檔案,但該檔案不是圖片。
        return nil, "", true, fmt.Errorf("剪貼簿中的檔案 %q 不是圖片", path)
    }

    // 成功讀取圖片檔案,回傳原始 bytes、格式,以及 found=true。
    return data, mediaType, true, nil
}

兩行 AppleScript 分別負責:

  • the clipboard as «class furl»:從剪貼簿取得 file URL。
  • POSIX path of ...:把 file URL 轉成 /Users/{user}/Pictures/photo.jpg 這類 Go 可以使用的路徑。

osascript 沒有取得檔案參照時,found 回傳 false,readClipboardImage 會改讀一般的圖片內容。取得檔案時,found 回傳 true;後續即使讀檔或格式檢查失敗,也會把該檔案的錯誤回傳給使用者。

執行範例

啟動程式:

go run .

準備剪貼簿圖片,可以使用以下任一方式:

  • 按下 Cmd+Ctrl+Shift+4 截取畫面。
  • 在 Preview 或瀏覽器中複製圖片內容。
  • 在 Finder 選取一個圖片檔案後按 Cmd+C。

回到終端機,使用該終端機的 Paste。收到事件並讀到圖片時會顯示:

已讀取圖片:image/jpeg,4609317 bytes,base64 6145756 字元

按下 Ctrl+C 或 Ctrl+D 結束程式。

最後,小結一下。整個運作的核心邏輯可以歸納為三個步驟:

  1. 開啟終端機模式:透過 Raw Mode 與 Bracketed Paste,讓終端機在使用者按下貼上時,即時從 stdin 送出 ESC[200~ 開頭暗號。
  2. 捕捉貼上事件:Go 程式監聽 stdin,讀到暗號時就知道使用者剛剛執行了貼上。
  3. 讀取剪貼簿圖片:收到通知後,程式才向作業系統剪貼簿 API(或透過 AppleScript 讀取 Finder 複製的圖片檔)取得圖片資料。

這套「終端機負責發出事件通知,CLI 負責向系統 API 拿資料」的分工,就是讓命令列工具也能支援貼上圖片的核心做法。


Go 知識補充:型別斷言、Rune 字碼點、switch 語法與多重 defer

如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:

1. 型別斷言(Type Assertion)與安全檢查

Go 的介面變數(如 io.Reader)在執行期可以透過型別斷言轉回原本的具體型別。若轉型失敗且未捕捉,程式會引發 panic。使用雙回傳值的 ok 模式可以安全判斷:

// 安全的型別斷言:若 r 不是 *os.File,ok 會為 false 而非引發 panic
inputFile, ok := r.(*os.File)
if !ok {
	return errors.New("輸入來源不是檔案")
}

2. rune 型別與 UTF-8 字碼點

在 Go 中,byte 是 8-bit 位元組,而 rune 是 int32 的別名,代表一個 Unicode 字碼點。當需要逐字處理包含 ANSI 控制碼(如 ESC 27)或 UTF-8 多位元組字元時,應使用 ReadRune() 而非 ReadByte():

reader := bufio.NewReader(os.Stdin)
r, size, err := reader.ReadRune() // r 為 rune 型別,size 為該字元佔用的 bytes 數
if r == 27 {                      // 27 代表 ASCII ESC 控制碼
	// 處理控制序列
}

3. switch 控制流程與多重 case 比對

與 C 或 Java 不同,Go 的 switch 語法預設每個 case 區塊執行完後就會自動跳出,不需要手動撰寫 break。此外,一個 case 支援同時比對多個可能的值:

switch r {
case 3, 4: // 一次比對多個數值(3 代表 Ctrl+C,4 代表 Ctrl+D)
	return nil
case 27: // 27 代表 ESC 字元,繼續處理後續序列
	pastedText, isPaste, err := readBracketedPaste(reader)
}

4. 多重 defer 語法與 LIFO 執行順序

defer 會將其後方的函式延後到當前外層函式 return 前執行。若同一個函式內宣告了多個 defer,Go 會依照「後進先出」(LIFO)的堆疊順序倒序執行:

// 先執行的 defer 後觸發;最後執行的 defer 最先觸發
fmt.Fprint(out, "\x1b[?2004h")
defer fmt.Fprint(out, "\x1b[?2004l") // 2. 離開函式時後執行關閉

defer term.Restore(fd, oldState)    // 1. 離開函式時先執行恢復狀態

上一篇
CLI 安全防護
下一篇
將既有 REST API 重新設計為 CLI
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言