Skip to content
Greg's Space
Go back

打造一個像 Claude Code 的終端機工具:用 Textual 做介面,pip install 後一鍵啟動

Claude Code 給人的體驗跟一般 CLI 工具不太一樣:你打 claude 這個指令,跳出來的不是一行一行往下滾的文字,而是一整個佔滿畫面、有輸入框、可以互動的終端機介面。這篇文章要做的事情,就是把這種體驗做出來——用 Textual 蓋介面,包裝成套件,讓別人 pip install mypack 之後,打 mypack 就跳出你做的那個介面。

這篇文章專注在「怎麼把它包裝成一個指令」這件事;至於介面裡的聊天框、輸入框、怎麼接 agent 回覆,我在另一篇文章裡已經寫過完整版本,這裡會直接沿用那個架構,重點放在打包上。

先想清楚:這裡的「指令」跟一般 CLI 工具不一樣

前面提過的 black、pytest 這種指令,執行完就結束了,回到終端機的命令列。但 Claude Code 這種工具不是——指令執行之後,終端機的控制權會整個交給你的 App,畫面被清空重繪,使用者在裡面打字、按方向鍵、按 Ctrl+C 離開,離開之後畫面才還給原本的命令列。

這正是 Textual 的 App.run() 在做的事:它會接管整個終端機,直到使用者退出為止。所以等一下的 entry point,呼叫的不會是「印一行字就結束」的函式,而是「啟動一整個 App,並且卡在那裡直到使用者離開」的函式。

專案結構

一樣用 src layout,把 App 的程式碼跟 CLI 進入點分開放:

mypack-project/
├── pyproject.toml
├── src/
│   └── mypack/
│       ├── __init__.py
│       ├── app.py      # Textual App 本體
│       └── cli.py      # pip 指令實際呼叫的進入點

分成兩個檔案是刻意的:app.py 只管「畫面長怎樣、按鍵怎麼反應」,cli.py 只管「這個套件被當成指令執行時,該做什麼事」——兩者職責不要混在一起,以後要幫 App 加參數(例如 --config)才不會綁死。

Step 1:把 Textual App 包成一個獨立函式

沿用前一篇文章裡的骨架,先簡化成一個最小可動版本:

# src/mypack/app.py
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Input, Header, Footer, Static


class MyPackApp(App):
    CSS = """
    VerticalScroll {
        height: 1fr;
        border: round $accent;
        padding: 1 2;
    }
    Input {
        dock: bottom;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        yield VerticalScroll(id="messages")
        yield Input(placeholder="輸入訊息後按 Enter…")
        yield Footer()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        messages = self.query_one("#messages", VerticalScroll)
        messages.mount(Static(f"你: {event.value}"))
        event.input.value = ""

注意這裡沒有 if __name__ == "__main__": MyPackApp().run() 這種寫法。原因跟上一篇講純 CLI 套件時一樣:這個檔案不會被使用者直接執行,而是被 cli.py import 進去、由 pip 生出來的指令去呼叫。

Step 2:寫真正的 entry point

cli.py 負責「被當成指令執行時該做的事」——這裡就是把 App 建出來並 run():

# src/mypack/cli.py
from mypack.app import MyPackApp


def main() -> None:
    MyPackApp().run()

main() 這個函式,執行下去就會一路卡在 Textual 的事件迴圈裡,直到使用者退出 App 為止——這跟前一篇文章裡「服務啟動函式」的角色是一樣的,只是這次啟動的服務,是一個會接管整個終端機畫面的互動介面。

Step 3:pyproject.toml——同一張委託單

entry point 的設定方式跟一般 CLI 套件完全相同,沒有因為換成 Textual 而有任何差異:

[project]
name = "mypack"
version = "0.1.0"
description = "一個像 Claude Code 的終端機工具"
requires-python = ">=3.9"
dependencies = [
    "textual>=0.60",
]

[project.scripts]
mypack = "mypack.cli:main"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

這行 mypack = "mypack.cli:main" 的意思還是那句話:「幫我生一個叫 mypack 的指令,執行的時候去呼叫 mypack.cli 模組裡的 main()」。pip 完全不在乎 main() 裡面是印一行字就結束,還是接管整個終端機畫面直到使用者按 Ctrl+C——對 pip 來說,這只是「呼叫一個函式」而已,差異都發生在函式內部。

Step 4:本地測試

跟一般套件一樣,先用 editable 模式裝起來測試:

pip install -e .
mypack

打下 mypack 之後,終端機應該會被 MyPackApp 整個接管,畫面變成你在 compose() 裡排好的樣子。按 Ctrl+C(或你自己設定的退出鍵)離開之後,才會回到原本的命令列——這就是判斷「有沒有包成功」最直接的方式:如果只是印一行字就結束,代表你可能不小心呼叫錯函式,或是漏掉了 .run()。

幫指令加參數:啟動前先決定一些設定

實務上你可能會想讓使用者用 mypack --model gpt-4o-mini 這種方式,啟動前先決定一些設定值。這時候參數解析要放在 cli.py,而不是塞進 App 裡:

# src/mypack/cli.py
import argparse

from mypack.app import MyPackApp


def main() -> None:
    parser = argparse.ArgumentParser(prog="mypack")
    parser.add_argument("--model", default="gpt-4o-mini")
    args = parser.parse_args()

    app = MyPackApp(model=args.model)
    app.run()

對應地,讓 MyPackApp 可以接受這個設定:

# src/mypack/app.py
class MyPackApp(App):
    def __init__(self, model: str = "gpt-4o-mini", **kwargs) -> None:
        super().__init__(**kwargs)
        self.model = model

這樣分工的好處是:cli.py 專心處理「使用者從終端機打了什麼」,App 專心處理「畫面跟互動邏輯」,兩邊互不干擾——以後想把 MyPackApp 包成套件之外的用途(例如寫測試、或是被別的程式 import 進去用)也不會被參數解析卡住。

打包發佈

跟一般套件的流程一模一樣,不會因為裡面是 Textual App 而有差別:

pip install build twine
python -m build
twine upload dist/*

別人 pip install mypack 之後,打 mypack 就會直接跳出你做的終端機介面——體驗上跟 claude 這個指令是同一件事,差別只在畫面裡跑的邏輯是你自己寫的。

常見的坑(Textual App 特有的部分)

問題常見原因
打指令之後畫面沒有被接管,只是印出一堆文字就結束main() 裡忘了呼叫 .run(),或是不小心呼叫成一般函式而不是 App 實例的方法
在某些 CI 環境或非互動式終端機裡執行會直接報錯Textual 需要一個真正的 TTY,在沒有終端機的環境(例如某些 CI pipeline、被重導向輸出的場合)執行會失敗,這是預期行為,不是套件寫錯
透過 SSH 連進去跑,畫面跑版或顏色怪怪的遠端終端機的 TERM 環境變數設定不完整,通常不是程式碼問題,先確認本機終端機支援度
使用者裝了套件但打指令沒反應跟一般 CLI 套件一樣,檢查是不是站在正確的虛擬環境、PATH 有沒有包含對應的 bin/Scripts 資料夾

一句話總結

想做出「像 Claude Code 一樣,裝完打指令就跳出完整終端機介面」的工具,關鍵拆成兩塊:Textual 負責接管畫面、處理互動(這部分跟平台無關,做法在前一篇文章有完整介紹);pyproject.toml 的 [project.scripts] 負責把這個 App 包成一個指令,而這一步跟包裝任何一般 CLI 工具完全沒有差別——差的只是委託單上寫的那個函式,做的事情從「印一行字」換成了「接管整個終端機畫面」。



Previous Post
用 Textual 和 Rich 打造一個能接 LangChain Agent 的聊天機器人終端機介面
Next Post
什麼是 Agent Skills?