本文重點
- CLI 不併進 app。能力收斂到那個已經在跑的 daemon,CLI 和 app 都只是它的前端。
- 一致性不靠紀律:一份命令表 → 五個 renderer,表外的命令編不過。
- 實作日誌:釘住 89 項現有行為 → 建表 → 從表長出 HTTP 端點,缺口 7 → 0。
- 拿當天蓋好的端點去問一台空機器,量出「有本機模型卻拿不到回應」並修掉。
全文分四部:① 推導——為什麼不該併 ② 設計——一份表,五個 renderer ③ 實作日誌——今天實際動的手 ④ 收束
〔第一部 · 推導〕
前兩天我把 spectyn 的架構攤開,也對照過五家商業級專案。今天要處理一個我拖了很久的問題。
拖很久是因為它聽起來很簡單,做起來卻不知道從哪開始:我之後主要會用的是 app 版,各個平台的 app。那我這一整套 CLI 該怎麼辦?
61 個 subcommand、兩萬行的入口檔、還有一個一萬行的終端機介面。這些東西要怎麼「併進」app?
我想了幾種做法,最後的結論是:這個問題本身問錯了。
我原本的想法是「把 CLI 的功能搬進 app 的介面」。
但前天盤點的時候我已經量過了,現在的狀況是這樣:
| 一套 | 另一套 | 誰活著 |
|---|---|---|
| 4,268 行的供應商鷹架 | 8,727 行的供應商層 | 後者 |
| 10,630 行的終端機介面(行程內) | 11,195 行的 HTTP daemon | 兩套都活 |
| 173 個桌面 IPC 命令 | 34 個 HTTP 端點 | 兩套都活,網頁端只拿得到後者 |
我已經有兩套了。把 CLI 再「搬進」app,就是第三套。
所以正確的問法不是「CLI 怎麼併進 app」,而是:
能力應該住在哪裡,才能讓 CLI 和 app 都只是它的前端?
答案是那個已經在跑的 daemon。

圖 1 終局架構:一個安裝物、一份能力面、幾個薄前端。發行 app 化,執行不 app 化。
這是解掉「安裝零零落落」的正解。
app bundle 內含 spectyn 執行檔。使用者裝的是 app(.dmg / .msi / App Store / Play Store),CLI 跟著一起進來。
想用 CLI 的人,app 提供一個「安裝 shell 命令」的按鈕,把它 symlink 到 PATH——就像 VS Code 的 code 命令。不想用的人根本不需要知道底下有這東西。
這一步的好處是複利的:自動更新、程式簽章、公證,全部只做一次而不是兩次。 而且 app 可以 per-user 安裝,剛好對上我工作電腦沒有管理員權限的限制。
| 平台 | daemon 怎麼活 |
|---|---|
| Mac / Windows / Linux | app 首次啟動時註冊到系統的自啟機制。app 關掉之後 daemon 繼續跑 |
| Android | 前景服務 |
| iOS | 只能在 app 前景時活著 |
第三行是硬限制,不是我偷懶。iOS 不讓你跑常駐背景程序,所以 iOS 上的 spectyn 預設不是自足節點,是連到我桌面節點的 client。
這件事要寫進設計文件,因為它跟我原本「單機就是硬底線」的假設衝突——那條在 iOS 上不成立。
CLI 和 app 必須讀寫同一個地方。這點現在是對的,不要動。
要改的是金鑰去處。目前使用者在 UI 貼的 API key 會落到明文設定檔——因為我的 keystore 工單四張(Mac / Windows / iOS / Android)一張都沒做。
在沒有終端機的世界裡,UI 是使用者唯一能貼 key 的地方。所以 keystore 從「以後再說」升級成安裝流程的前置條件。
我量過現在的 IPC 命令,173 個註冊、前端只叫了 19 個。逐個分類之後:
搬完的效益是複利的:一個端點同時餵桌面 app、手機 app、瀏覽器、CLI、終端機介面五個表面。
判準很簡單:需要 app 自己的行程脈絡或作業系統整合的,留原生;其餘一律搬。 注意 daemon 本身也是原生程式,所以「瀏覽器做不到」不等於「必須是原生 IPC」。
這是今天最意外的發現。
我以為「怎麼把終端機介面弄進 app」是個大工程。翻程式碼才發現,daemon 裡已經有一條 PTY-over-WebSocket 的橋,而且做得比我記憶中完整:
最後一項是修過的漏洞:舊版只簽 body,所以一個從別的端點抓來的簽章可以被重放成 ?cmd=shell。現在補上了。
前端也在——xterm.js 和 fit addon 都烘進執行檔裡,還有一個 504 行的網頁終端機頁面。
所以答案是:終端機介面不用進 app。它跑在 PTY 裡,app 裡就是一個分頁。
但有個天花板要先說清楚:那是把畫面搬到瀏覽器,不是原生介面。手機上用終端機介面會很痛苦,而且 app 讀不懂畫面內容——對它來說那只是一堆像素。
所以:桌面用這條就夠,手機端要真正的原生畫面,而且只做手機真正需要的那幾個,不要整套搬過去。
| 表面 | 角色 |
|---|---|
| app | 主力,日常九成的事 |
| CLI | 腳本、自動化、CI,以及 app 起不來時的救援 |
| 終端機介面 | 進階模式,在 app 的分頁裡或獨立終端機裡 |
我認真想過。好處很實在:只有一個安裝物、沒有 PATH 要管、沒有版本不一致、免管理員權限。
但這會直接殺掉我整個艦隊。
| 依賴 CLI 的東西 | 為什麼 GUI 取代不了 |
|---|---|
| 多終端派工協定 | 全部是 headless 呼叫 |
| 包裝各家 AI CLI 的 wrapper 腳本 | 靠 subprocess |
| 驅動別家 AI CLI 的那一層 | spawn 子行程是它的本質 |
| 把自己接進併審輪替 | 本質是被程式呼叫 |
| MCP stdio server | 必須是能被別的程式 spawn 的行程 |
doctor / selftest |
app 起不來的時候要靠它們 |
| headless Linux / CI / SSH | 跑不了 GUI |
第三項特別要命。前天對照完五家之後,我把「我領先的東西」從五樣修正成兩樣半,而剩下最主要的那一樣就是**「讓別家 AI CLI 在我的治理下工作」**——那件事天生活在 shell 的世界裡。
把自己 app 化到沒有 CLI,等於親手拆掉自己唯一的差異化。
技術上可行:看啟動參數,有 subcommand 就走 CLI、沒有就開 GUI。
我也不建議。艦隊一天要呼叫 CLI 幾百次,每次都載入一個帶 webview 的執行檔太慢。CLI 應該小而快,GUI 免不了要帶 webview。
(雖然我機器上的 claude 是 345MB、codex 是 210MB,說明大執行檔在實務上可以忍——但那是每次開一個 session,不是每分鐘幾十次的派工。)
〔第二部 · 設計〕
上面那張圖解決了「能力住哪裡」,但沒解決另一半:Windows、Linux、Mac 上的行為要一致,而且 app 的介面要跟 CLI 一致。
這件事我不能靠自律,因為前面已經證明過我做不到。
現在的狀況是四份手寫:兩萬行的 CLI 入口檔手寫 61 個子命令、桌面殼手寫 173 個 IPC、daemon 手寫 34 個端點、終端機介面手寫選單。
四份手寫,所以永遠對不齊,而且沒人知道哪份是對的。

圖 2 介面不是「做得像」,是同一份表的五種畫法;不一致就編不過。
命令不再手寫在任何一個地方,而是宣告在一份表裡:
Command {
name "goals.create" 命名空間化,不是 61 個平面名字
surfaces CLI | API | TUI | APP 這個命令出現在哪些面
args [{ name, 型別, 必填, 說明 }]
returns 結構化回傳,不是自由文字
side_effect 唯讀 | 會改東西 | 對外動作
permission 權限類別
platform 全部 | 只有 Mac
}
沒有任何 renderer 可以有表以外的命令。 所以 app 的介面跟 CLI 一樣,不是因為我「做得像」,是因為它們是同一份表的兩種畫法。
有了 side_effect 和 platform 這兩個欄位,很多事會自動發生:

圖 3 不是「三個系統寫三份」,是「一份,加一層薄薄的落地層」。
我的樹裡真正會分岔的只有六處:
| 分岔源 | 現況 |
|---|---|
| 資料目錄路徑 | 有唯一出口的函式,但有呼叫點繞過它 |
| 服務安裝 | 三個系統三份實作 |
| 金鑰保管 | 三個系統三份,而且一份都沒做 |
| shell 種類 | ✅ 已經在 PTY 白名單裡處理好 |
| PTY | ✅ 已經用條件編譯隔離 |
| 終端機能力 | 顏色、字寬 |
第一行是現行的傷口。那個「唯一出口」的函式,它自己的註解寫著「其他臨時呼叫點要遷移到這裡」——而我前幾天才發現,有一個呼叫點自己硬寫路徑繞過了它,結果是設了環境變數之後,資料寫一個地方、log 寫另一個地方。
模式在,紀律不在。
這才是重點。
我的問題從來不是不知道規則。四大支柱、七種事件、單一真相來源——這些規則我都寫過。問題是規則漂走的時候,沒有任何東西會叫。
所以每條規則都要配一條會紅的檢查:
| 不變量 | 守衛(CI 會紅) |
|---|---|
| 只能有一份命令表 | renderer 裡出現表以外的命令 → 失敗 |
| 平台差異只在平台模組裡 | 條件編譯出現在白名單外的檔案 → 失敗 |
| 資料路徑只從那個函式來 | 任何檔案自己拼路徑 → 失敗 |
| 每個宣告的 surface 都要有 | 表說有 API 但路由不存在 → 失敗 |
| 沒有死程式碼 | 模組沒有任何非測試呼叫者 → 警告 |
| 檢查本身不准空綠 | 任何檢查必須先示範它會紅,才算數 |
倒數第二條會當場抓到我那個斷路器——369 行、有測試、唯一的使用者是一個不會被執行的檔案。
最後一條是母規則。如果只能加一條,加它。
而且這些大多是 grep 等級的檢查,不用寫框架。我今天就修了一個現成的例子:自測框架的 feature 篩選,打錯名字時原本回「0 通過 0 失敗 0 跳過」而退出碼是 0,現在改成退出碼 2 並列出所有合法名稱。
〔第三部 · 實作日誌〕
一次重寫兩萬行是自殺。但更重要的是——在行為被釘住之前動手,就是在賭。
這兩天我已經付過兩次學費:兩次動了不在執行路徑上的程式碼,第二次還連帶污染了不相關的測試。所以順序是這樣:

圖 4 六個步驟的順序,以及每一步的產出。第 0 步不動任何程式碼。
不是「應該的行為」,是現在實際的行為,包含 bug。
spectyn --help 全部 61 個子命令的完整輸出
spectyn <每個子命令> --help 參數、預設值、說明字
spectyn doctor --json schema
各命令的退出碼 0 / 1 / 2 的分界
存成 golden master。之後任何重構讓這些變了就是紅燈——除非我刻意要改,那就更新 golden。
我已經有一半:自測框架 20 個功能項、doctor --json 的 schema 回歸閘。缺的是 --help 全表和退出碼。
這一步的價值不是程式碼變好,是從此以後「我沒弄壞東西」不是感覺,是可證明的。
我原本打算從目標管理那組開始,因為它是最大的一塊 CRUD。但那是錯的——那組目前只存在於桌面 IPC,CLI 根本沒有。統一它證明不了「CLI 跟 app 一致」這件事。
provider 才是對的第一組,因為它是整個病的縮影:
| 表面 | 覆蓋 |
|---|---|
| CLI | 4 個(其中一個是會印警告的已棄用別名) |
| 桌面 IPC | 約 14 個 |
| HTTP 端點 | 1 個 |
同一件事,三個表面,覆蓋率 4 / 14 / 1。 這就是為什麼網頁端做不到 app 做得到的事。
而且它剛好落在我已經排定的第一階段(LLM 導入)路徑上——重構和進度是同一件事,不用二選一。
把那組宣告成一份表。這一步不改任何行為,純粹是把散在三個地方的知識寫成資料。
寫的過程本身就有價值:我會被迫回答「偵測本機模型服務」和「測試 ollama」是不是同一件事——而現在沒人知道答案。
從表生成 CLI parser,然後跟 golden master 逐字比對。
不一樣就是錯,不管新的看起來多合理。 這一步把「重寫」變成「等價轉換」。
同一份表生成端點,那組從 1 個變成 14 個。這一刻網頁端和手機端立刻獲得它們。
第一次證明「一組做完,五個表面同時獲得」不是理論。
生成版綠了之後才刪手寫的。然後加守衛:這組出現表以外的命令就紅。
沒有守衛的重構,只是把債換個地方放。
寫完上面那段之後我就去做了,因為它不需要等任何決定。
一支腳本,把 CLI 的現況存成快照並比對三件事:每個子命令的 --help 逐字輸出、--help 的退出碼、doctor --json 的 key 集合。三種模式:比對、重產快照、以及驗證這支腳本自己會紅。
目前 91 項全部相符,連跑兩次穩定。 而一行 production 程式碼都還沒改,它就抓到四件事。
hello、update、version 這三個不是真的子命令。打下去會落到「當成提問丟給 LLM」的那條 fallthrough。
spectyn update --help 的實際輸出:
● file_read({"path":"docs/ironman-2026/day03-app-and-cli-endgame.md"})
✓ # CLI 要怎麼併進 app?我的答案是不要併
agent 真的去讀檔了。 我打一個不存在的子命令,它花了我一次模型呼叫,還順便把今天正在寫的這篇文章讀了進去。
對一個整套系統的出發點就是「只負擔得起一個訂閱」的人來說,這不是小事。
要做快照,得先知道有哪些子命令。我拿了三次來源,前兩次都錯:
| 第幾次 | 來源 | 結果 |
|---|---|---|
| 1 | 原始碼裡那個看起來像命令表的常數 | ❌ 註解明寫「它從不決定實際派發」——那是打錯字時的建議清單,裡面就有 update、version 這種不存在的 |
| 2 | 靜態分析找 match 分支 | ❌ 得出「37 個沒有處理」,但 doctor、serve、exec 明顯有。數字錯,撤回 |
| 3 | 拿一個空的資料根實跑每一個 | ✅ 52 個真的 / 3 個落到 LLM / 6 個常駐跳過 |
第二次特別值得記,因為它看起來很有說服力——有程式碼分析、有具體數字。但它是錯的,而如果我沒有順手驗證幾個明顯的例子,我會把那個數字寫進文章裡。
連「有哪些命令」都沒有機器可讀的答案。
這正是「一份宣告式命令表」非做不可的理由——不是為了漂亮,是因為現在連盤點都得靠實跑去猜。
--help 的退出碼不一致34 個回 0、7 個回 2、3 個回 1。
回 1 的那三個更糟——它們把 --help 當成未知的子命令,錯誤訊息直接指名它:「未知的 peer 子命令:--help」。
快照刻意保留這些缺陷。修的時候要同時更新快照,那是一次刻意的決定,不是順手。
第一個:比對函式用 diff 快照 - 從標準輸入讀實際輸出。但外層是一個 while read 迴圈——diff 把迴圈的標準輸入吃掉了。
症狀:只汙染一份快照,卻報三個失敗。
第二個:那個「驗證自己會紅」的模式,一開始是空綠的。環境變數覆寫寫在檢查之後,所以子行程還是讀原本的快照,汙染了也照樣通過。
而它誠實地報告了自己失敗——「快照被汙染但比對仍然通過,這支腳本是空綠的」。
母規則的價值在這裡變具體了:如果我沒有強迫「先示範它會紅」,這支腳本會帶著一個永遠通過的自檢上線,而我會以為自己有守衛。
第 0 步的價值不是程式碼變好,是它會先幫你找到 bug。 三個燒錢的假命令、一組退出碼不一致、我自己檢查裡的兩個 bug——全部在動任何 production 程式碼之前。
三個假命令裡最刺眼的是「打錯字要花錢」,所以先修它。
先寫會紅的測試(四條,其中一條是安全欄:真提問必須放行,拒太多會弄壞 spectyn "為什麼我的 build 這麼慢")。然後才動程式碼。
翻到那段程式碼的時候我愣了一下——防護本來就在,而且註解寫得很清楚:
打錯字守衛:單獨一個
spectyn <某個字>,如果它是某個真命令的近似拼法,否則會落到隱含提問並悄悄發出一次模型呼叫。
作者(也就是幾個月前的我)已經想到這個問題了。但守衛有兩個洞:
洞一:判斷「是不是打錯字」的函式,對已經在清單裡的字回傳「不是打錯字」。所以 update 因為在清單裡,被判定為「正常命令」,直接放行。
而那份清單的說明自己寫著:「它從不決定實際派發」。它只是拼字建議表。
清單說「這是已知命令」,派發說「我沒有這個」,兩邊從來沒有人對帳。
洞二:守衛只在「參數剛好兩個」時執行。所以 spectyn update --help(三個參數)根本不經過它。
新增一個判斷函式,回傳三種結果之一:近似打錯字(建議正確的)、宣告了但沒被派發、或者這真的是提問。
第二種的錯誤訊息我刻意寫得很直白:
error: 'update' 被列為指令但沒有任何處理常式 — 這是 spectyn 自己的缺陷,不是你打錯。
因為它確實是。使用者沒做錯任何事,是我的派發表跟建議表對不上。
修完之後端到端一跑,spectyn hello --help 印出了正常的用法說明。
hello 其實有 help handler,只是裸打 spectyn hello 才會落到提問。我先前把它跟另外兩個歸為同類,那是不精確的。這件事寫進了提交訊息,因為我不想讓一個錯誤的分類留在紀錄裡。
改完之後跑比對,它正確抓到三項行為變更——正是我刻意改的那三個。
然後我更新快照,退出碼分布從 34/7/3 變成 33/7/3。
這就是我要的樣子:它不是攔住我改東西,是逼我知道自己改了什麼。
而全套回歸測試的失敗數,改動前後完全一樣——我先 git stash 讓樹回到動手之前跑了一次,確認那些失敗本來就在。在說「不是我弄壞的」之前,先證明它。
修完那個 bug 之後,就是建表。第一組選 provider——理由前面說過,它同時存在於三個表面,是整個病的縮影。
先精確盤點(不靠印象,逐個從程式碼數出來):
| 表面 | 數量 |
|---|---|
| CLI | 4 |
| 桌面 IPC | 16 |
| HTTP 端點 | 1 |
4 / 16 / 1。 這就是為什麼網頁端做不到桌面 app 做得到的事——不是缺功能,只是缺一個沒人去寫的表面。

圖 5 盤點結果與收斂後的表:九個命令、三個表面,缺口 7 → 0,兩個空格是刻意的。
surfaces 不預設「處處可用」。 少一個表面必須明確寫下來。因為另一個選項——表面之間悄悄分岔——正是這張表要防的事。
effect(唯讀 / 會改東西 / 對外動作)讓每個 renderer 自己就能決定要不要先問使用者。唯讀的到處都不用確認,對外的一律升級到手機核准。規則寫一次,不用每個表面各自重新發明。
參數的 secret 標記寫在宣告處,不寫在每個呼叫點。因為「記得要遮蔽」不是策略。
7 of 8 commands declare an HTTP surface but have no route:
provider.list
provider.models
provider.set_key
provider.validate_key
provider.detect_local
provider.detect_free
provider.config_status
它的失敗訊息就是待辦清單。 每補一條路由就短一行,全補完就解除 ignore。
而 ignore 的理由字串我寫死了一句:「不准放寬斷言讓它過」。因為那是這種測試唯一會死掉的方式。
另外還有一條測試只做一件事——把現況印成數字:
provider group — declared 8 | CLI today 2 | IPC today 7 | HTTP today 1
放在測試輸出裡,而不是放在一份沒人會重讀的文件裡。文件會腐爛,測試不會——因為它每次都跑。
要把命令寫進表,就得先確定「這個命名空間到底有哪些命令」。我把兩份說明並排看:
| 說明來源 | 列出的命令 |
|---|---|
spectyn --help |
provider list · provider **models** |
spectyn provider --help |
providers list · providers **priority** |
兩份說明對同一個命名空間各講一套。 models 只出現在主說明,priority 只出現在子說明,兩份都不完整——而且子說明整頁還在用已經棄用的複數形。
provider priority 是真的能跑的(我實測,它會印出那個 agent 的 provider 失效順序)。但它從來沒進過主說明。
如果我照主說明生成 CLI,它會被無聲刪掉。
它之所以進得了表,只因為建表強迫我把兩份文字並排。所以我又加了一條測試:今天能跑的每一個 CLI 形式都必須在表裡,否則生成的時候會靜默少一個。
(順帶還查到棄用別名的指引訊息是錯的:打 providers priority master,它提示你「現在改叫 provider list」——動詞掉了。行為本身是對的,訊息是錯的。)
不是那九行宣告。是缺口從軼事變成了機器可讀的數字。
provider group — declared 9 | CLI today 3 | IPC today 7 | HTTP today 1
在這之前,「網頁端功能比較少」是一種感覺。現在它是一個數字,我知道是哪幾個,而且每補一個,數字就會自己變。
表建好之後,就是從它生成 HTTP 端點。
原本的缺口測試讀表裡的一個欄位——「這個命令有沒有路由」。寫完之後我才意識到:那又是一個需要人手更新的欄位。
它會像它要取代的那四個手寫表面一樣漂走。我會寫好 handler,然後忘記回去把欄位改掉,然後測試會說還有缺口,而我會開始不相信那個數字。
改成:缺口由「有沒有 handler」自動判定。
寫一個 handler,缺口就自動關閉一個,不用記得去改任何宣告。另外加一條測試釘住「報告的」和「實際接上的」不得分歧。
路徑也一樣是推導的,不是手寫的:命令叫 provider.list,路徑就是 /api/provider/list。改名的命令不可能留下一條指向空無的孤兒路由。
而舊路徑是額外註冊,不是取代——既有的呼叫端不能因為我們整理 URL 而壞掉。
寫了三個唯讀的 handler,全部只用 daemon 已經有的東西。跑起來:
/api/provider/list HTTP 200 441B
/api/provider/config_status HTTP 200 57B
/api/provider/priority HTTP 200 166B
兩個取捨值得說:
列出 provider 時只回「金鑰存不存在」和來源,永遠不回金鑰本身。 表裡標了輸入側的 secret,這是同一條規則的輸出側。
設定狀態不回設定檔的絕對路徑。 在一個可能被截圖或共享的儀表板上,家目錄路徑是沒有任何好處的資訊外洩。
之前 declared 9 | CLI 3 | IPC 7 | HTTP 1
之後 declared 9 | CLI 3 | IPC 7 | HTTP 4
紅測試的清單從七行變五行。而我沒有改動任何欄位。
這是我今天做的事情裡最有感覺的一刻——不是因為多了三個端點,是因為進度變成了一個會自己走的數字。
提交完之後跑比對,它報了一項不符。
查下去是 doctor 說明裡的 git commit hash 和一個檔案系統快照的時間戳——兩個都是每次跑本來就會變的東西,不是行為變更。
我補上遮蔽規則,然後在註解裡寫了一句給未來的自己:
每加一條遮蔽都要問「這真的是天生會變的嗎」。遮錯了等於偷偷放行一個行為變更。
因為這是這種檢查唯一的死法。不是被刪掉,是被一條一條遮蔽規則慢慢挖空,直到它什麼都不管了。
剩下五個裡,我先做偵測本機模型和偵測免費層——因為它們直接服務「零設定導入」:一台已經跑著本機模型的機器,設定精靈不該再問使用者要 API 金鑰。
跑起來:
detect_local → 1 個(ollama,3 個模型)
detect_free → 登錄簿 4 家 · 現在就能用 0 家
第二行讓我停了一下。
四家免費層全部免信用卡,而我一把金鑰都沒有?不對——我明明在用 Gemini 和 Groq。
翻一下就懂了:我的金鑰是直接寫在設定檔裡的,不在環境變數,所以這個端點看不到。
這不是 bug,是兩種存放方式沒有對齊。但它剛好就是設定精靈需要知道的那個落差——「你有四個免費選項,而你目前一個都沒接上」。我不是為了找它才寫這個端點的,是端點寫完之後它自己浮出來。
偵測免費層時,回傳的是編譯進來的登錄簿,不做存活探測。
那些網址是硬編在程式碼裡的,而免費層會無預警改。所以「這家還在不在」是另一件事,必須真的打出去才知道。把兩者混在一起,會讓一份過期的登錄簿看起來很健康——那比不知道更危險。
只回「金鑰存不存在」和環境變數的名稱。 名稱可以露,值永遠不行。
本機模型清單只取前八個。 一台拉了四十個模型的機器,不該讓回應被模型名塞爆——而挑選器也用不到那麼多。
還有一個小地方:偵測本機模型這個命令,我在表裡標成「對外動作」,雖然它從來不離開這台機器。因為它會開 socket、會阻塞。一個把「非唯讀就顯示進度」當規則的介面,因此自動拿到正確的行為——不用有人記得為這個命令特別處理。
HTTP 端點 1 → 4 → 6
紅測試清單 7 → 5 → 3
剩下三個:列出模型、設定金鑰、驗證金鑰。後兩個會碰到金鑰保管,那是另一場仗。
剩下列出模型、設定金鑰、驗證金鑰。第一個直接寫了,後兩個我停下來查了一下金鑰現在到底存在哪。
答案是:明文,存在一個設定目錄底下的檔案裡。
於是問題變成:我要不要開一個「透過 HTTP 寫入金鑰」的端點?
不要。兩個理由:
第一,daemon 綁在所有網卡上,而且 /api 不需要任何憑證。 我今天早些時候才量過——同一個 Wi-Fi 上的任何裝置都能打進來,/ 和 /rpc/ping 都回 200。在那上面開一個會寫入金鑰的端點,等於讓隔壁的人改寫我的 provider 設定。
第二,金鑰目前是明文存的。 在明文儲存上再加一個表面,是把缺陷擴散,不是把它收斂。
驗證金鑰也一樣要等——它不洩漏金鑰,但每次呼叫都花我的錢,而今天任何人都能無憑證觸發它。
我沒有就這樣把它們留白。我在表裡把這兩個命令標成「不提供 HTTP 表面」,並且把理由寫在宣告的正上方。
因為:
「刻意不做」和「還沒做」,對下一個人不能長得一樣。
留白的話,三個月後的我(或任何接手的人)只會看到一個缺口,然後「順手補上」——把兩個問題一起帶進生產環境。寫下來,它就變成一個有條件的決定:daemon 有了認證、金鑰有了安全的去處,把開關打開就好。
七條路由實跑,其中一條回 404。
是 provider.health——那個表裡唯一「本來就有 HTTP 端點」的命令。我的函式說它 served,但實際上只有舊的路徑存在,新的規範路徑我根本沒註冊。
「報告的」和「實際接上的」分歧了。
而那正是這整個模組存在的理由。它不能從它自己身上開始。
修完之後我又加了一條測試:每個被報告為「已服務」的名字,都必須真的有一條規範路徑被註冊。
HTTP 端點 1 → 4 → 6 → 7
紅測試清單 7 → 5 → 3 → 0
那條刻意寫紅的測試,今天解除了 ignore。從現在起它是守衛,不是待辦。
而它的註解裡留了一句給下一個看到的人:兩個命令不在計數裡是刻意的,不是疏漏,理由寫在宣告處。
七個端點蓋完,我想知道一件事:它們真的服務到那個目標了嗎?
所以我開了一個空目錄當資料根,模擬一台什麼都沒有的機器,把 daemon 跑起來,然後用剛蓋好的端點問它四個問題。

圖 6 缺口不是憑印象排出來的優先序,是拿當天蓋好的工具量出來的。
config_status → 有 1 個 provider
detect_local → 偵測到 ollama,3 個模型
detect_free → 登錄簿 4 家,0 家就緒
list → anthropic,key_source: none,usable: false
第四行讓我停下來。
那唯一的 provider 是 anthropic,沒有金鑰,用不了。 而同一台機器上,一個能用的本機模型正跑著三個模型,被完全忽略。
我試著拿一個回應:
$ spectyn exec "說 hi"
錯誤:找不到 agents.toml — 執行 `spectyn onboarding`
→ exit 2
一台裝好、而且有可用本機模型的機器,拿不到第一個回應。
而它給的指引是「執行 spectyn onboarding」——一個在 app 裡的使用者永遠看不到這句話。他只會看到什麼都不動。
我沒有為了找它去查任何東西。我只是把七個端點蓋完之後,拿去問一台空機器四個問題,答案自己拼出了問題的形狀。
而且範圍比我原本估的窄很多。我在計畫裡寫的是「讓 daemon 能零設定啟動」——它本來就能。真正斷的只有一件事:零設定時的預設 provider,指向了一個不可能運作的東西。
改動很小,但有三個地方我刻意想過:
第一,選擇要是純函式。 偵測留在呼叫端,「選誰」只吃一份已經偵測到的清單。這樣它可測、不碰網路,而且空清單時的結果與原本完全相同——既有的每一條路徑都不會被影響。
第二,優先挑 coder 模型。 這條規則不是猜的,是前一天實測出來的:同一台機器上,一般模型在「讀個檔說出 bug」這題上從沒嘗試呼叫工具,而 coder 模型正確發出了呼叫。照清單順序取第一個會挑到比較差的那個——伺服器回報的順序只反映拉取順序,不帶任何能力訊號。
第三,雲端留在鏈上,不當主要。 這樣哪天環境裡出現一把金鑰,它會自己接手,使用者不用回來改任何東西。
還有一個決定是「不做什麼」:我沒有替 AgentsConfig 加上序列化能力來產生設定檔。那個結構存著 API 金鑰,給它序列化能力,等於讓「把設定寫出去」離「把金鑰寫到磁碟」只差一行。 改成直接產出最小的設定文字,波及範圍就固定在那幾個欄位。
配了兩條測試盯著:產出的文字必須解析回跟結構建構器一樣的選擇(否則兩邊會分岔,而其中一個在說謊),以及產出的文字絕不能含金鑰。
$ spectyn exec "只回三個字:你好世界"
提示:沒有 agents.toml — 改用偵測到的本機模型(ollama / qwen2.5-coder:7b)。
執行 `spectyn onboarding` 可寫入永久設定。
從「exit 2,去讀一句你看不到的指引」變成「自動用能用的那個,並且說清楚它做了什麼」。
那句提示是刻意的。一份悄悄出現的設定,是使用者搞不清楚「到底是誰回答我」的開始。
今天前面做的都是基礎工程——釘住行為、建表、生成端點。它們沒有一個是使用者看得到的。
而這一步是第一個使用者真的會遇到的改變。
更重要的是,它不是我憑印象排出來的優先序。是我蓋好的工具拿去問一台空機器,然後它自己指出來的。
| 為什麼 | |
|---|---|
| 七種事件語彙 | 唯一沒有分岔過的東西——因為它小到不可能偷偷長出第二套。動它就毀了唯一的錨 |
| agent 迴圈本身 | 它是對的 |
| 反幻覺掃描器、行程沙盒 | 剩下的兩樣半差異化 |
| 終端機介面改成 client | ⚠️ 最後才做。風險最高、立即價值最低,而且網頁終端機已經給了桌面一條可用的路 |
最後一項要特別忍住。一萬行的終端機介面很刺眼,但它現在能用,而且改它不會讓任何使用者拿到新東西。
〔第四部 · 收束〕
一個安裝物 → app bundle(GUI 殼 + CLI 執行檔 + 首次啟動起 daemon)
一份能力面 → daemon 的 HTTP / WebSocket
幾個薄前端 → app、瀏覽器、CLI、終端機介面
app 不得包含任何 daemon 沒有的能力。
任何 renderer 不得有命令表以外的命令。
任何檢查必須先示範它會紅,才算數。
前兩條防止雙實作再長出來,第三條防止前兩條漂走。
我已經為這個病付了很多代價:四份手寫的介面、一個接在死線上的斷路器、五個「綠燈但什麼都沒做」的檢查。這三條規則就是不讓它再發生一次。
使用者裝一個 app、打開、就能用
← app 要能啟動並管理 daemon
← daemon 要能在零設定下啟動並送出設定精靈
← 能力要在 HTTP 端點而不是原生 IPC
← 要先知道哪一層供應商程式碼是活的
最後一項前天已經確認了。而這條鏈的底端——「零設定啟動」——剛好就是我排在最前面的那一關。
那不是巧合。零設定啟動同時是「把安裝做好」和「把整套 app 化」的共同前提,所以不管從哪個方向倒推,都會落在同一個點上。
89 項行為被釘住 · 1 個燒錢的 bug 被修掉 · 1 張命令表 · 而缺口從「感覺網頁端功能比較少」變成一個會自己走的數字:7 → 5 → 3 → 0。而拿這七個端點去問一台空機器之後,一台有本機模型卻拿不到回應的全新機器,現在拿得到了。
今天最有用的不是那張架構圖,是把問題重新問了一次。
「CLI 怎麼併進 app」問不出答案,因為它預設了「要有一個東西被塞進另一個東西」。換成「能力該住在哪裡」之後,答案就只有一個:住在那個已經在跑的 daemon 裡,其他全部是它的前端。
而最後那條規則——任何檢查必須先示範它會紅——是這三天裡我唯一覺得可以直接照抄給別人的東西。它不需要架構、不需要重構,今天就能加,而且它會立刻告訴你有多少「綠燈」其實是空的。
明天回到最前面那一關:一台什麼都沒有的機器,daemon 到底起不起得來。 這一關過不了,上面所有的推導都是紙上作業。