iT邦幫忙

2025 iThome 鐵人賽

DAY 2
4
AI & Data

為你自己學 Gemini CLI系列 第 2

[為你自己學 Gemini CLI ... 的原始碼] 第 2 天,主程式裡的 Hello Kitty!

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250916/20065770n8ajPs3fje.jpg

打開終端機,輸入 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 啟動的時候,可能會需要:

  • 讀取設定檔
  • 連接到遠端 API
  • 載入外掛模組
  • 初始化資料庫連接

這些操作都可能需要等一點時間,如果用同步的方式處理而且要做的事如果比較多的話,使用者一啟動 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),就讀取這些資料。

OAuth 和特殊整合

// 行 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 編輯器整合,就執行特殊的整合模式。

互動式 UI 啟動

// 行 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);
  1. 執行單次的問答(送出問題,等待回答,顯示結果)
  2. 執行所有清理工作(關閉連線、儲存狀態等)
  3. 結束程式

總結流程

整理一下整個從 main() 函式的流程:

https://ithelp.ithome.com.tw/upload/images/20250916/20065770QekXsCWvpT.png

實際使用範例

1. 互動模式啟動(預設)

gemini

啟動完整的聊天介面,可以持續對話

2. 非互動式單次執行

gemini --prompt "解釋什麼是閉包"

得到答案後程式會自動結束

3. 管道輸入程式碼

將檔案內容和提示詞一起送出:

cat app.js | gemini --prompt "幫我檢查這段程式碼有什麼問題"

4. 列出已安裝的擴充套件

gemini --list-extensions

5. 使用特定模型

指定使用特定的 AI 模型

gemini --model claude-3-opus --prompt "告訴我什麼是 9527!"

6. 在沙盒中執行(更安全)

在隔離環境中執行,限制系統存取

gemini --sandbox

我學到了...

Kitty 鍵盤協定?

傳統終端只能識別基本按鍵,Kitty 協定可以:

  • 區分 Ctrl + I 和 Tab(傳統上是一樣的)
  • 識別 Shift + Enter、Ctrl + Enter 等組合
  • 支援更多功能鍵

Raw 模式?

終端有兩種輸入模式:

  • 一般模式:輸入文字,按 Enter 才送出
  • Raw 模式:每按一個鍵立即送出,不等 Enter

互動式程式需要 Raw 模式才能即時回應按鍵(例如方向鍵移動游標)。

總結

main() 函式是整個 Gemini CLI 的起點,主要做這些事:

  1. 初始化:載入設定、解析參數、設定環境
  2. 安全性:處理認證、sandbox 隔離
  3. 使用者體驗:主題系統、鍵盤增強、錯誤處理
  4. 執行模式:判斷並啟動互動式或非互動式模式

你學會了嗎 :)


上一篇
[為你自己學 Gemini CLI ... 的原始碼] 第 1 天,從黑黑的畫面開始!
系列文
為你自己學 Gemini CLI2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言