打開終端機,輸入 gemini
按下 Enter 鍵之後,然後 Gemini 就出現在畫面上了。但啟動的過程發生什麼事?今天就來看看 gemini
程式的主要進入點 packages/cli/index.ts
,看看它是怎麼做到的。
先看看 Gemini CLI 整個專案的架構,比較主要程式碼大概都是放在 packages
目錄裡:
|-- packages
- a2a-server # A2A 伺服器
- cli # CLI 主程式
- core # 核心邏輯
- test-utils # 測試工具
- ... # 其他套件
目錄的名字滿好猜的,其中 packages/cli
目錄裡的 index.ts
應該就是 CLI 啟動的主要進入點:
// 檔案: packages/cli/index.ts
#!/usr/bin/env node
import './src/gemini.js';
import { main } from './src/gemini.js';
import { FatalError } from '@google/gemini-cli-core';
// --- Global Entry Point ---
main().catch((error) => {
// ... 略 ...
});
果然,一進來就看到一個大大的註解寫著 --- Global Entry Point ---
,應該就是它了。index.ts
的程式碼看起來很簡單,大概就是 import
進來的 main()
在做大部份的事,在往下追 main()
函數之前有幾個地方可以先看一下。首先是這行:
#!/usr/bin/env node
有寫過 shell script 的人應該對這行不陌生,這是所謂的 shebang(或 hashbang),它告訴作業系統這個檔案要用什麼程式來執行。這裡用的是 /usr/bin/env node
,意思是「用系統環境中找到的 node 來執行我」。但為什麼不直接寫 #!/usr/bin/node
就好?好問題,這是因為 /usr/bin/env node
的寫法會在系統的 PATH
中尋找 node 執行檔,不是寫死路徑,保留一些彈性。
import "./src/gemini.js";
import { main } from "./src/gemini.js";
如果你寫過 JavaScript,這兩行的 import
看起來感覺好像有點多餘?幹嘛 import
兩次 ./src/gemini.js
?通常會這樣主要的目的通常是要啟用 ./src/gemini.js
這個模組的副作用(side effects),同時又要明確地取得某個匯出的東西。但我很快的巡了一下 ./src/gemini.ts
好像沒有任何需要立即執行的頂層副作用程式碼,所以這裡的雙重 import
看起來有點多餘,應該只要保留第二行就好(如果我有理解錯的話,歡迎指正)。
如果真的是多餘的話,這個發 PR 不知道會不會收,我猜不會。
重點來了,追進去看這個 main()
函數,這是一個大概有 250 行程式碼的函數,最終回傳一個 Promise,代表整個 CLI 啟動的非同步流程。但是大家可以想一下,為什麼在本機啟動程式,還得弄個非同步處理?因為 CLI 啟動的時候,可能會需要:
這些操作都可能需要等一點時間,如果用同步的方式處理而且要做的事如果比較多的話,使用者一啟動 CLI 可能就看到程式卡卡的。使用非同步處理就像是讓程式可以一邊等待 API 回應,一邊顯示載入動畫給使用者看。
錯誤處理和基礎設定
// 行 197-198
export async function main() {
setupUnhandledRejectionHandler();
函式一進來就一個處理錯誤的函數,先不細追,大概知道它會攔截 & 處理錯誤訊息,而不是讓整個程式當掉。
// 行 199
const settings = loadSettings();
這裡會載入使用者的偏好設定,然後:
// 行 201
await cleanupCheckpoints();
清理舊的「檢查點(Checkpoint)」檔案。什麼是「檢查點」?在 Gemini CLI 中,檢查點是對話的存檔,就像遊戲的存檔點一樣。這個功能可以讓使用者儲存目前的對話狀態,之後可以載入回來繼續對話避免對話遺失。從這段從程式碼可以看的出來,這個清理的行為是每次啟動都會執行的,所以之前儲存的對話檢查點會被清除,無法跨 session 保留對話歷史
// 行 203-210
const argv = await parseArguments(settings.merged);
const extensions = loadExtensions();
const config = await loadCliConfig(
settings.merged,
extensions,
sessionId,
argv,
);
parseArguments()
負責解析命令列參數,loadExtensions()
負責載入外掛,loadCliConfig()
則是把設定、外掛、命令列參數整合成一個完整的設定物件。
argv
:解析使用者在命令列輸入的參數(例如 gemini --model gpt-4 -p "你好"
)extensions
:載入所有已安裝的外掛程式,這些外掛可以擴充 CLI 的功能config
:把所有設定、外掛、命令列參數整合成一個完整的設定物件,程式後續都會參考這個設定來運作// 行 212-218
const wasRaw = process.stdin.isRaw;
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
if (config.isInteractive() && !wasRaw) {
process.stdin.setRawMode(true);
如果進入互動模式(像聊天介面),就把終端切換到「原始模式」,所謂的原始模式就是每按一個鍵就立即傳給程式,不用使用者等按 Enter,這樣才能做出即時回應的介面。但是,咦?這個 Kitty Protocol(Kitty 鍵盤協定)是什麼?
經查這個 Kitty Protocol 是由 https://sw.kovidgoyal.net/kitty/ 開發的進階鍵盤輸入協定,用來解決傳統終端機在處理鍵盤輸入時的限制。
傳統的鍵盤輸入協定主要限制無法區分某些按鍵或是像 ctrl 或 shift 之類的修飾鍵組合受限而無法正常識別,而 Kitty Protocol 使用新的編碼方式,完整傳遞所有鍵盤資訊,例如:
傳統終端機:
按下 Tab → 收到: \t (0x09)
按下 Ctrl+I → 收到: \t (0x09) # 完全一樣!無法區分
Kitty Protocol:
按下 Tab → 收到: CSI 9 u
按下 Ctrl+I → 收到: CSI 105;5 u # 可以區分了!
這也是在 Gemini CLI 裡按下 Ctrl + Enter 可以換行的實作方式。
好吧,所以跟 Hello Kitty 無關。
接著往下看:
// 行 220-225
process.on("SIGTERM", () => {
process.stdin.setRawMode(wasRaw);
});
process.on("SIGINT", () => {
process.stdin.setRawMode(wasRaw);
});
當使用者按 Ctrl+C 或系統要關閉程式時,記得把終端恢復成原本的模式,不然終端可能會變得很奇怪(例如按鍵不會顯示)。
// 行 228
kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();
檢查終端是否支援 Kitty 協定。Kitty 是一種進階的終端功能,可以識別更多按鍵組合(例如區分 Ctrl+Enter 和單純的 Enter)。如果支援就開啟它。
// 行 230-238
if (argv.sessionSummary) {
registerCleanup(() => {
const metrics = uiTelemetryService.getMetrics();
writeFileSync(
argv.sessionSummary!,
JSON.stringify({ sessionMetrics: metrics }, null, 2),
);
});
}
如果使用者要求產生使用報告(透過 --session-summary
參數),就註冊一個「結束前要做的事」:收集這次使用的統計資料(例如執行了多少指令、花了多少時間),並儲存成 JSON 檔案。
// 行 247-249
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
);
設定網路查詢的優先順序。例如當同時有 IPv4 和 IPv6 地址時,要優先使用哪一個。這影響程式連接網路服務的方式。
// 行 251-256
if (argv.promptInteractive && !process.stdin.isTTY) {
console.error(
"Error: The --prompt-interactive flag is not supported when piping input from stdin.",
);
process.exit(1);
}
檢查是否有矛盾的使用方式。--prompt-interactive
表示要互動式輸入,但如果同時又用管道輸入資料(例如 cat file | gemini --prompt-interactive
),這兩個是衝突的,所以要報錯並結束。
// 行 258-264
if (config.getListExtensions()) {
console.log("Installed extensions:");
for (const extension of extensions) {
console.log(`- ${extension.config.name}`);
}
process.exit(0);
}
如果使用者只是想看有哪些外掛(--list-extensions
),就列出所有外掛名稱然後結束。就像查看手機上安裝了哪些 App 一樣。
// 行 267-274
if (!settings.merged.security?.auth?.selectedType) {
if (process.env["CLOUD_SHELL"] === "true") {
settings.setValue(
SettingScope.User,
"selectedAuthType",
AuthType.CLOUD_SHELL,
);
}
}
如果還沒設定登入方式,系統會自動判斷。如果偵測到在 Google Cloud Shell 環境中執行,就自動使用 Cloud Shell 的認證方式。
// 行 280-281
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
載入使用者自訂的顏色主題。就像載入自己設計的桌布或佈景主題。
// 行 283-288
if (settings.merged.ui?.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`);
}
}
套用使用者選擇的主題。如果找不到指定的主題(可能被刪除或打錯字),顯示警告但繼續執行,然後使用預設的主題。
// 行 293-297
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
? getNodeMemoryArgs(config)
: [];
如果不在沙盒中,檢查是否要自動調整記憶體。如果開啟自動配置,會分配系統一半的記憶體給程式使用,避免記憶體不足。沙盒就像一個虛擬的安全房間,程式在裡面執行時:
// 行 298-300
const sandboxConfig = config.getSandbox();
if (sandboxConfig) {
檢查是否需要在沙盒(隔離環境)中執行。
// 行 301-313
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
try {
const err = validateAuthMethod(settings.merged.security.auth.selectedType);
if (err) {
throw new Error(err);
}
await config.refreshAuth(settings.merged.security.auth.selectedType);
} catch (err) {
console.error("Error authenticating:", err);
process.exit(1);
}
}
在進入沙盒前先進行身份驗證。因為沙盒會限制網路連線,所以要先完成登入。就像進入飛航模式前先下載需要的資料。
// 行 314-318
let stdinData = "";
if (!process.stdin.isTTY) {
stdinData = await readStdin();
}
如果有 pipe 輸入(例如 echo "hello" | gemini
),就讀取這些資料。
// 行 359-365
if (
settings.merged.security?.auth?.selectedType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
如果使用 Google 登入但禁止自動開啟瀏覽器,就在這裡處理 OAuth 認證,讓使用者可以手動複製連結到瀏覽器。
// 行 367-369
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, extensions, argv);
}
如果啟用實驗性的 Zed 編輯器整合,就執行特殊的整合模式。
// 行 379-389
if (config.isInteractive()) {
await kittyProtocolDetectionComplete;
await startInteractiveUI(
config,
settings,
startupWarnings,
process.cwd(),
initializationResult,
);
return;
}
如果是預設的互動模式,等待 Kitty 協定檢測完成(確保鍵盤輸入正常)就會啟動圖形化介面(雖然還是黑黑的終端機視窗就是了)
// 行 396-401
if (!process.stdin.isTTY) {
const stdinData = await readStdin();
if (stdinData) {
input = `${stdinData}\n\n${input}`;
}
}
如果有 Pipe 輸入(例如 cat code.js | gemini
),讀取這些資料並與命令列的提示詞合併成 input
。
// 行 402-407
if (!input) {
console.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
);
process.exit(1);
}
如果沒有提供任何輸入(既沒有 --prompt
參數,也沒有 pipe 輸入),顯示錯誤訊息並結束。
// 行 409-418
const prompt_id = Math.random().toString(16).slice(2);
logUserPrompt(config, {
"event.name": "user_prompt",
"event.timestamp": new Date().toISOString(),
prompt: input,
prompt_id,
auth_type: config.getContentGeneratorConfig()?.authType,
prompt_length: input.length,
});
產生一個隨機 ID 給這次的提問,用來記錄這次的使用行為,包括什麼時間、問了什麼問題、用哪種登入方式等。
// 行 430-433
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
await runExitCleanup();
process.exit(0);
整理一下整個從 main()
函式的流程:
gemini
啟動完整的聊天介面,可以持續對話
gemini --prompt "解釋什麼是閉包"
得到答案後程式會自動結束
將檔案內容和提示詞一起送出:
cat app.js | gemini --prompt "幫我檢查這段程式碼有什麼問題"
gemini --list-extensions
指定使用特定的 AI 模型
gemini --model claude-3-opus --prompt "告訴我什麼是 9527!"
在隔離環境中執行,限制系統存取
gemini --sandbox
傳統終端只能識別基本按鍵,Kitty 協定可以:
終端有兩種輸入模式:
互動式程式需要 Raw 模式才能即時回應按鍵(例如方向鍵移動游標)。
main()
函式是整個 Gemini CLI 的起點,主要做這些事:
你學會了嗎 :)