在終端機執行一個指令時,畫面上看起來只是一行文字印出來;但對作業系統與 Shell 來說,一個程式結束時其實會回報兩類完全不同的資訊:
0 代表成功,非 0(通常是 1)代表失敗。Exit Code 讓外部呼叫端不必解析文字就能判斷成敗,stderr 則留給人工閱讀與排查問題。
然而,當我們開始開發自己的 CLI 工具時,底層的檔案系統、網路請求與業務邏輯通常只會回傳語言層級的 error。如果每個子命令或函式一拿到 error 就各自用 fmt.Println 印出並呼叫 os.Exit(1),錯誤處理很快就會遇到三個問題:
os.Exit 會立刻結束 Process,已註冊的 defer 清理函式完全不會執行,開啟中的檔案、鎖與連線可能來不及釋放。1,Shell 無法區分參數錯誤還是內部異常,AI Agent 與外部程式也無法透過結構化欄位進行自動修復。os.Exit,容易造成同一筆錯誤被重複列印,也無法全域切換 JSON 輸出或開啟 debug log。要解決這些問題,CLI 內部不能讓每個地方自行決定如何終止程式。程式架構必須遵守一條固定原則:底層函式專注於辨認原因並封裝錯誤,沿著 Call Stack 向上拋出;最外層入口統一處理輸出與 Process 終止。
本篇使用一個名為 filectl 的檔案檢查 CLI 工具範例,來示範如何建立這條錯誤路徑。它提供 check <path> 命令,透過 os.Stat 檢查指定路徑;無論遇到檔案不存在、權限不足還是系統錯誤,都會遵循同一套傳遞與輸出規則。
設計 CLI 的錯誤與日誌時,首先要釐清「誰在接收這次執行的結果」。現代 CLI 的呼叫端不只有人類,還包含外部程式與 AI Agent,每一類呼叫者需要的資訊完全不同:
| 呼叫者 | 關注重點 | 接收通道 | 格式需求 |
|---|---|---|---|
| Shell / CI | 命令成功或失敗,決定 if/else 分支 |
Exit Code | 數值(0 為成功,非 0 為失敗) |
| 終端機使用者 | 哪個操作失敗、原因為何、下一步該怎麼做 | stderr | 人類可讀文字(Message 與 Hint) |
| AI Agent / 程式 | 具體錯誤型態,依固定代碼執行修正 | stderr | 機器可讀的結構化 JSON(固定的 type) |
| 維運排查人員 | 錯誤發生前的執行軌跡與系統內部狀態 | stderr | 除錯日誌(平時安靜,開啟 --debug 才輸出) |
整個錯誤處理與日誌架構,就是為這四種呼叫者提供各自獨立、互不干擾的資訊通道。
好的終端機錯誤訊息不應只印出未經處理的底層錯誤(例如 open missing.csv: no such file or directory),而要清楚拆成兩部分:說明哪個操作因何失敗的 Message(失敗原因),以及告訴使用者下一步可以執行什麼具體動作的 Hint(處置建議):
Error: file "missing.csv" not found
Hint: verify the path with ls -la
為了在程式內部統一把這兩項資訊(連同給 Shell 與程式使用的狀態碼)沿路傳遞,我們定義自訂的 ExitError:
// internal/errs/errors.go
type ExitError struct {
ExitCode int // 作業系統與 Shell 接收的退出碼
Type string // 程式與 AI Agent 判斷用的固定類型
Message string // 使用者看到的失敗原因
Hint string // 可以執行的下一步建議(可為空)
}
func (e *ExitError) Error() string {
return e.Message
}
只要實作了 Error() string 方法,*ExitError 就是標準的 Go error,可以一路向上回傳。
在業務邏輯中,我們檢查底層操作回傳的原始 error,辨認原因後再封裝成帶有具體建議的 ExitError:
// cmd/check.go
func runCheck(opts *checkOptions) error {
_, err := opts.Factory.Stat(opts.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return &errs.ExitError{
ExitCode: errs.ExitFailure,
Type: "not_found",
Message: fmt.Sprintf("file %q not found", opts.Path),
Hint: "verify the path with ls -la",
}
}
if errors.Is(err, fs.ErrPermission) {
return &errs.ExitError{
ExitCode: errs.ExitAccess,
Type: "permission_denied",
Message: fmt.Sprintf("permission denied: %q", opts.Path),
Hint: "check the file and directory permissions",
}
}
// 未知或未分類的系統錯誤,不洩漏內部細節,引導使用者開啟 debug
return &errs.ExitError{
ExitCode: errs.ExitFailure,
Type: "internal",
Message: fmt.Sprintf("cannot inspect file %q", opts.Path),
Hint: "run again with --debug to see system details",
}
}
fmt.Fprintf(opts.Factory.IOStreams.Out, "%s exists\n", opts.Path)
return nil
}
寫錯誤提示時把握兩個原則:
ls -la),不要重複說「請確認檔案是否存在」。子命令與內部函式不能直接呼叫 os.Exit(1) 或 fmt.Println,原因有兩個:
defer 清理不會執行:os.Exit 會直接終止 Process,開啟中的檔案、鎖與連線無法正常釋放。整座 CLI 的錯誤處理必須維持單一出口:所有子命令只負責向上回傳 error;只有最外層入口(如 cmd/root.go)才能統一寫入 stderr 與呼叫 os.Exit。

在 Cobra 中,使用 RunE 取代 Run。子命令只專注在執行邏輯並回傳 error,絕不在內部自己退出:
// cmd/check.go
func newCheckCmd(factory *cmdutil.Factory) *cobra.Command {
opts := &checkOptions{Factory: factory}
return &cobra.Command{
Use: "check <path>",
Short: "Check whether a path exists",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Path = args[0]
return runCheck(opts) // 只回傳 error,不印訊息也不 exit
},
}
}
所有的 error 最終都會回到最外層的 root.Execute()。我們用 errors.As 提取出前面定義的 ExitError,輸出訊息後才結束程式:
// cmd/root.go
func Execute() {
factory := cmdutil.NewFactory(os.Stdin, os.Stdout, os.Stderr)
root := NewRootCmd(factory)
if err := root.Execute(); err != nil {
handleError(os.Stderr, err, errorFormat)
}
}
func handleError(stderr io.Writer, err error, format string) {
var exitErr *errs.ExitError
if errors.As(err, &exitErr) {
errs.WriteError(stderr, exitErr, format)
os.Exit(exitErr.ExitCode)
}
// 非 ExitError(例如 Cobra 參數解析錯誤),轉為 validation 錯誤處理
usageErr := &errs.ExitError{
ExitCode: errs.ExitUsage,
Type: "validation",
Message: err.Error(),
}
errs.WriteError(stderr, usageErr, format)
os.Exit(usageErr.ExitCode)
}
為了避免 Cobra 自己印一次錯誤、我們的 handleError 又印一次,必須在 Root 命令關閉預設輸出:
root := &cobra.Command{
Use: "filectl",
SilenceErrors: true, // 關閉 Cobra 預設的錯誤輸出,避免重複列印
SilenceUsage: true, // 發生錯誤時不要自動印出落落長的 Help 訊息
}
這樣一來,整座 CLI 只有唯一的出口決定 Process 的生死。執行成功時資料走 stdout,失敗時錯誤走 stderr,分工乾淨明確:
$ filectl check data.csv
data.csv exists
$ echo $?
0
$ filectl check missing.csv
Error: file "missing.csv" not found
Hint: verify the path with ls -la
$ echo $?
1
文字訊息是寫給人看的,用詞隨時可能潤飾或改版。如果自動化程式或 AI Agent 靠爬文字(例如 grep "not found")來判斷結果,一旦文案修改,流程就會損壞。
對機器來說,穩定的錯誤介面只有兩種:Exit Code 與 結構化 JSON。
Shell 透過 Exit Code 數字($?)來決定流程走向。設計狀態碼時應把握精簡原則:
0:成功。1:通用失敗(多數錯誤回傳 1 即可)。2 代表參數錯誤、4 代表權限不足):// internal/errs/exitcode.go
const (
ExitOK = 0 // 成功
ExitFailure = 1 // 一般執行失敗(如檔案不存在、內部錯誤)
ExitUsage = 2 // 參數或使用方式錯誤
ExitAccess = 4 // 權限不足
)
不需要為每個錯誤都發明一個代碼。對多數 Shell 流程來說,只要能區分成功與失敗就足夠了:
if filectl check data.csv; then
echo "file exists, continue"
else
echo "execution failed"
fi
AI Agent 與自動化程式需要比 Shell 更細緻的處置能力(例如在 not_found 時主動建立檔案,在 permission_denied 時請求授權)。
這類呼叫端應透過 --error-format json 取得結構化資訊,直接讀取固定不變的 error.type:
$ filectl check missing.csv --error-format json
{
"error": {
"type": "not_found",
"message": "file \"missing.csv\" not found",
"hint": "verify the path with ls -la"
}
}
$ echo $?
1
在 Go 中定義對應的 Envelope 結構:
// internal/errs/envelope.go
type ErrorEnvelope struct {
Error *ErrorBody `json:"error"`
}
type ErrorBody struct {
Type string `json:"type"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"` // 沒有 Hint 時透過 omitempty 自動省略
}
WriteError 函式集中處理輸出:文字模式寫入人讀的格式,JSON 模式則輸出結構化資料。成敗已由 Exit Code 表達,JSON 內部不需再放多餘的 ok: false 欄位:
func WriteError(w io.Writer, err *ExitError, format string) {
if format == "json" {
env := ErrorEnvelope{
Error: &ErrorBody{
Type: err.Type,
Message: err.Message,
Hint: err.Hint,
},
}
data, _ := json.MarshalIndent(env, "", " ")
fmt.Fprintln(w, string(data))
return
}
// 文字模式
fmt.Fprintf(w, "Error: %s\n", err.Message)
if err.Hint != "" {
fmt.Fprintf(w, "Hint: %s\n", err.Hint)
}
}
最後將 --error-format 宣告為 Persistent Flag,所有子命令就能共用相同的機器介面:
root.PersistentFlags().StringVar(
&errorFormat,
"error-format",
"text",
"error format: text or json",
)
在 CLI 的日常使用中,使用者只需要看到簡潔的操作結果;但當指令出錯或行為不如預期時,開發者需要掌握執行過程中的細節(例如檢查了哪個檔案路徑、底層報了什麼系統錯誤)。
這個機制的實作原則很單純:只有在加上 --debug 參數時,才將詳細日誌寫入 stderr。
使用 Go 標準庫的 log/slog,在關鍵步驟與出錯點記錄 slog.Debug:
// cmd/check.go
func runCheck(opts *checkOptions) error {
slog.Debug("checking file", "path", opts.Path) // 記錄執行前參數
_, err := opts.Factory.Stat(opts.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// ... 回傳 not_found ExitError ...
}
// 遇到未分類的底層錯誤時,記錄原始 error
slog.Debug("stat failed", "path", opts.Path, "error", err)
return &errs.ExitError{
ExitCode: errs.ExitFailure,
Type: "internal",
Message: fmt.Sprintf("cannot inspect file %q", opts.Path),
Hint: "run again with --debug to see system details",
}
}
fmt.Fprintf(opts.Factory.IOStreams.Out, "%s exists\n", opts.Path)
return nil
}
遇到錯誤時,只要用 slog.Debug 記錄底層原因並回傳 ExitError 即可。不要在底層呼叫 slog.Error,因為最外層入口(handleError)已經會輸出錯誤,重複記錄會讓終端機出現兩筆錯誤訊息。
在 Root 命令中註冊 --debug 的 Persistent Flag,並依據 flag 設定 slog 的門檻層級:
// cmd/root.go
var debugMode bool
func NewRootCmd(factory *cmdutil.Factory) *cobra.Command {
root := &cobra.Command{
Use: "filectl",
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setupLogger(debugMode)
},
}
root.PersistentFlags().BoolVar(
&debugMode,
"debug",
false,
"print debug logs to stderr",
)
return root
}
func setupLogger(debug bool) {
level := slog.LevelWarn // 平常只顯示 WARN 以上,保持安靜
if debug {
level = slog.LevelDebug // 加上 --debug 才顯示 DEBUG 日誌
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
slog.SetDefault(slog.New(handler))
}
平常執行時,終端機維持簡潔:
$ filectl check missing.csv
Error: file "missing.csv" not found
Hint: run again with --debug to see system details
$ echo $?
1
加上 --debug 後,執行軌跡與原始錯誤直接輸出到 stderr:
$ filectl check missing.csv --debug
time=2026-07-26T18:30:00.000+08:00 level=DEBUG msg="checking file" path=missing.csv
time=2026-07-26T18:30:00.000+08:00 level=DEBUG msg="stat failed" path=missing.csv error="stat missing.csv: no such file or directory"
Error: file "missing.csv" not found
$ echo $?
1
因為日誌是寫入 os.Stderr,即使開了 --debug,也不會污染 stdout 的資料管線:
$ filectl check data.csv --debug | tee result.txt
tee 只會接收到 stdout 的 data.csv exists,Debug 日誌則留在終端機畫面上。
設計 CLI 的錯誤處理與日誌,核心在於維持單一出口與明確分流:
defer 資源能正常釋放,單元測試也能正常執行。Message 與可執行的 Hint,自動化程式與 AI Agent 則依賴標準 Exit Code 與固定的 JSON type。--debug 時寫入 stderr,不污染 stdout 的資料管線。讓錯誤資訊與執行軌跡各走各的通道,CLI 就能同時兼顧人類操作的友善度與自動化串接的穩定性。