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 工具完全沒有差別——差的只是委託單上寫的那個函式,做的事情從「印一行字」換成了「接管整個終端機畫面」。