拆包、立边界、可发布:Gemini CLI 的 Monorepo 设计我学到了什么

从 Gemini CLI 到 Innies CLI:Monorepo 架构改造实践

做开源项目二次开发的时候,经常会遇到一个问题:上游项目的架构设计得很好,但文档里不会告诉你"为什么这么设计"。只有真正改造的时候,才会慢慢理解那些设计决策背后的意图。

最近在把 Qwen Code(基于 Google Gemini CLI 改造)再改造成我们自己的 用来私有化部署的CLI,过程中对 Monorepo 架构有了更深的理解。这篇文章从项目架构讲起,学习下 Google 在这个 CLI 项目里的好的设计决策。


项目背景

先说说这个项目的"血统":

java 复制代码
Google Gemini CLI (原版)
       ↓ 改造
Qwen Code (通义版)
       ↓ 改造
xxx CLI (我们的版本)

Google 的 Gemini CLI 是一个 AI 命令行工具,用 TypeScript 写的,架构设计得挺讲究。Qwen Code 把它改造成了适配通义千问的版本,我们又在 Qwen Code 基础上做了进一步定制。

整个项目用的是 npm workspaces 管理的 Monorepo 结构,这个选择在后面会发现特别重要。


Monorepo 结构

先看项目的目录结构:

ruby 复制代码
innies-cli/
├── packages/
│   ├── cli/           # @xxx/xxx-cli - 命令行界面
│   ├── core/          # @xxx/xxx-core - 核心逻辑
│   ├── test-utils/    # @xxx/test-utils - 测试工具
│   └── vscode-ide-companion/  # VS Code 扩展
├── scripts/           # 构建脚本
├── eslint-rules/      # 自定义 ESLint 规则
├── .husky/            # Git hooks
├── .github/workflows/ # CI/CD
└── package.json       # 根配置

package.json 里的 workspaces 配置:

json 复制代码
{
  "workspaces": [
    "packages/*"
  ]
}

为什么要拆成多个包?

刚开始看到这个结构的时候,我在想:一个 CLI 工具有必要拆成这么多包吗?后来改造的时候才明白,这个拆分很有道理。

cli 包:负责终端 UI、命令解析、用户交互。用的是 React + Ink 渲染终端界面。

core 包:负责核心逻辑,包括 AI 模型调用、工具执行、MCP 协议、沙箱管理。这部分跟 UI 完全无关。

这个拆分的好处在改造的时候体现出来了:

  1. 换模型不影响 UI:我们把 Gemini 换成自己的模型,只需要改 core 包,cli 包几乎不用动
  2. 复用核心逻辑:core 包可以被 VS Code 扩展、其他工具直接引用
  3. 独立测试:两个包可以分别跑测试,互不干扰

包之间的依赖关系

packages/cli/package.json 里,可以看到这样的依赖声明:

json 复制代码
{
  "dependencies": {
    "@innies/innies-core": "file:../core"
  }
}

file:../core 是 npm workspaces 的本地包引用方式。在开发时,npm 会自动把这个软链接到 node_modules/@innies/innies-core,修改 core 包的代码,cli 包能立刻感知到。

发布的时候,这个 file: 协议会被替换成真正的版本号。


禁止跨包相对导入

这里就要说到 Google 制定的一条规则了。

eslint-rules/ 目录下,有一个自定义的 ESLint 规则:no-relative-cross-package-imports.js。这个规则做一件事:禁止用相对路径跨包导入

错误写法

typescript 复制代码
// 在 packages/cli/src/someFile.ts 中
import { something } from '../../core/src/utils.js';

正确写法

typescript 复制代码
// 在 packages/cli/src/someFile.ts 中
import { something } from '@innies/innies-core';

为什么要有这条规则?

改造过程中就明白了。

场景一:改包名

我们把 @qwen-code/qwen-code 改成 @innies/innies-cli,如果代码里到处都是 ../../core/src/xxx,那这些路径不用改。但问题是,这些"不用改"的代码,会让你误以为两个包之间没有边界。

场景二:重构目录结构

假设有一天要把 packages/core/src/utils.js 移动到 packages/core/src/shared/utils.js,用包名导入的代码完全不受影响(只要 core 包的导出不变),但用相对路径的代码全部要改。

场景三:发布为独立包

如果未来想把 core 包独立发布到 npm,用包名导入的代码一行不用改。用相对路径的代码?那就只能祈祷了。

完整规则实现

下面是 Google 写的完整规则代码,值得仔细看看:

javascript 复制代码
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Disallows relative imports between specified monorepo packages.
 */
'use strict';

import path from 'node:path';
import fs from 'node:fs';

/**
 * Finds the package name by searching for the nearest `package.json` file
 * in the directory hierarchy, starting from the given file's directory
 * and moving upwards until the specified root directory is reached.
 * It reads the `package.json` and extracts the `name` property.
 *
 * @requires module:path Node.js path module
 * @requires module:fs Node.js fs module
 *
 * @param {string} filePath - The path (absolute or relative) to a file within the potential package structure.
 * The search starts from the directory containing this file.
 * @param {string} root - The absolute path to the root directory of the project/monorepo.
 * The upward search stops when this directory is reached.
 * @returns {string | undefined | null} The value of the `name` field from the first `package.json` found.
 * Returns `undefined` if the `name` field doesn't exist in the found `package.json`.
 * Returns `null` if no `package.json` is found before reaching the `root` directory.
 * @throws {Error} Can throw an error if `fs.readFileSync` fails (e.g., permissions) or if `JSON.parse` fails on invalid JSON content.
 */
function findPackageName(filePath, root) {
  let currentDir = path.dirname(path.resolve(filePath));
  while (currentDir !== root) {
    const parentDir = path.dirname(currentDir);
    const packageJsonPath = path.join(currentDir, 'package.json');
    if (fs.existsSync(packageJsonPath)) {
      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
      return pkg.name;
    }

    // Move up one level
    currentDir = parentDir;
    // Safety break if we somehow reached the root directly in the loop condition (less likely with path.resolve)
    if (path.dirname(currentDir) === currentDir) break;
  }

  return null; // Not found within the expected structure
}

export default {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow relative imports between packages.',
      category: 'Best Practices',
      recommended: 'error',
    },
    fixable: 'code',
    schema: [
      {
        type: 'object',
        properties: {
          root: {
            type: 'string',
            description:
              'Absolute path to the root of all relevant packages to consider.',
          },
        },
        required: ['root'],
        additionalProperties: false,
      },
    ],
    messages: {
      noRelativePathsForCrossPackageImport:
        "Relative import '{{importedPath}}' crosses package boundary from '{{importingPackage}}' to '{{importedPackage}}'. Use a direct package import ('{{importedPackage}}') instead.",
      relativeImportIsInvalidPackage:
        "Relative import '{{importedPath}}' does not reference a valid package. All source must be in a package directory.",
    },
  },

  create(context) {
    const options = context.options[0] || {};
    const allPackagesRoot = options.root;

    const currentFilePath = context.filename;
    if (
      !currentFilePath ||
      currentFilePath === '<input>' ||
      currentFilePath === '<text>'
    ) {
      // Skip if filename is not available (e.g., linting raw text)
      return {};
    }

    const currentPackage = findPackageName(currentFilePath, allPackagesRoot);

    // If the current file isn't inside a package structure, don't apply the rule
    if (!currentPackage) {
      return {};
    }

    return {
      ImportDeclaration(node) {
        const importingPackage = currentPackage;
        const importedPath = node.source.value;

        // Only interested in relative paths
        if (
          !importedPath ||
          typeof importedPath !== 'string' ||
          !importedPath.startsWith('.')
        ) {
          return;
        }

        // Resolve the absolute path of the imported module
        const absoluteImportPath = path.resolve(
          path.dirname(currentFilePath),
          importedPath,
        );

        // Find the package information for the imported file
        const importedPackage = findPackageName(
          absoluteImportPath,
          allPackagesRoot,
        );

        // If the imported file isn't in a recognized package, report issue
        if (!importedPackage) {
          context.report({
            node: node.source,
            messageId: 'relativeImportIsInvalidPackage',
            data: { importedPath: importedPath },
          });
          return;
        }

        // The core check: Are the source and target packages different?
        if (currentPackage !== importedPackage) {
          // We found a relative import crossing package boundaries
          context.report({
            node: node.source, // Report the error on the source string literal
            messageId: 'noRelativePathsForCrossPackageImport',
            data: {
              importedPath,
              importedPackage,
              importingPackage,
            },
            fix(fixer) {
              return fixer.replaceText(node.source, `'${importedPackage}'`);
            },
          });
        }
      },
    };
  },
};

这个规则的设计有几个亮点:

  1. findPackageName 函数 :从当前文件向上查找 package.json,找到就返回包名。这个逻辑很通用,不管包嵌套多深都能正确识别。

  2. 两种错误消息:一种是跨包导入,另一种是导入了不在任何包里的文件。后者能帮你发现项目结构问题。

  3. 自动修复fix(fixer) 函数直接把相对路径替换成包名,跑 eslint --fix 就能批量修复。


其他 Google 制定的规则

除了跨包导入规则,eslint 配置里还有几条值得一提的规则:

禁止 require

javascript 复制代码
{
  selector: 'CallExpression[callee.name="require"]',
  message: 'Avoid using require(). Use ES6 imports instead.',
}

整个项目用的是 ESM("type": "module"),不允许混用 CommonJS 的 require

禁止抛出字符串

javascript 复制代码
{
  selector: 'ThrowStatement > Literal',
  message: 'Do not throw string literals. Throw new Error("...") instead.',
}

这条规则防止 throw "something went wrong" 这种写法,要求必须抛 Error 对象。好处是错误堆栈更清晰。

禁止访问内部模块

javascript 复制代码
'import/no-internal-modules': [
  'error',
  {
    allow: ['react-dom/test-utils', 'memfs/lib/volume.js', 'yargs/**']
  }
]

这条规则禁止直接导入一个包的内部文件(比如 lodash/src/xxx),只能用包的正式导出。白名单里的是一些特殊情况。


改造过程中的踩坑

包名全局替换

第一步是把包名从 @qwen-code 改成 @xxx。这看起来简单,实际上要改的地方很多:

  • 所有 package.json
  • 所有 import 语句
  • Docker 镜像名
  • CI/CD 配置
  • 文档

如果之前用的是相对路径导入,这一步会轻松很多(因为相对路径不涉及包名)。但正因为用了包名导入,后续的维护和理解成本会低很多。这是一个短期痛苦换长期收益的选择。

版本号同步

Monorepo 里有个常见问题:多个包的版本号怎么管理?

一种做法是各个包独立版本(比如 lerna 的 independent mode),但这样容易版本混乱。Google 选择了另一种方式:所有包统一版本号 ,用一个脚本 scripts/version.js 来同步。

执行 npm run release:version patch 时,这个脚本会依次做这些事:

javascript 复制代码
// 1. 先更新根 package.json 的版本
run(`npm version ${versionType} --no-git-tag-version`);

// 2. 遍历所有 workspace,逐个更新版本
for (const workspaceName of workspacesToVersion) {
  run(`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version`);
}

// 3. 同步更新 Docker 镜像的 tag
rootPackageJson.config.sandboxImageUri =
  rootPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);

// 4. 更新 package-lock.json
run('npm install --package-lock-only');

最终效果是:

json 复制代码
// 根 package.json
{
  "version": "0.1.5",
  "config": {
    "sandboxImageUri": "ghcr.io/zhimanai/innies-cli:0.1.5"
  }
}

// packages/cli/package.json
{
  "version": "0.1.5",
  "config": {
    "sandboxImageUri": "ghcr.io/zhimanai/innies-cli:0.1.5"
  }
}

// packages/core/package.json
{
  "version": "0.1.5"
}

一条命令,所有 package.json 和 Docker 镜像 tag 全部同步更新,不会出现版本不一致的问题。

构建顺序

Monorepo 的另一个问题是构建顺序。cli 包依赖 core 包,所以要先构建 core,再构建 cli。

json 复制代码
{
  "scripts": {
    "build:packages": "npm run build --workspaces"
  }
}

npm workspaces 会自动处理依赖顺序,但有时候还是会遇到问题。比如 core 包构建失败了,cli 包的构建也会失败,错误信息可能不太直观。


Husky + lint-staged

项目用 Husky 管理 Git hooks,提交前会自动运行 lint-staged:

json 复制代码
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "prettier --write",
      "eslint --fix --max-warnings 0"
    ],
    "*.{json,md}": [
      "prettier --write"
    ]
  }
}

这个配置保证了提交到仓库的代码格式统一、没有 lint 警告。

一开始觉得 --max-warnings 0 太严格了,后来发现这是对的。warning 堆积起来,总有一天会变成技术债。不如一开始就卡死。


总结

从 Gemini CLI 到 Innies CLI 的改造过程,让我对 Monorepo 架构有了更深的理解:

  1. 包拆分要有明确的边界:cli 负责 UI,core 负责逻辑,职责清晰
  2. 禁止跨包相对导入:短期麻烦,长期收益。这条 Google 制定的规则真的很有用
  3. 版本号统一管理:避免各个包版本不一致的混乱
  4. Lint 规则从严--max-warnings 0 看起来严格,但能防止技术债堆积

这些设计决策,在代码里看不出"为什么",只有真正改造一遍才能体会。

如果你也在做类似的项目改造,希望这些经验能帮到你。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token):

  • code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则
  • 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
  • first-principles-skill - 第一性原理思考,适合架构设计和技术选型

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5
相关推荐
清沫1 小时前
Claude Skills:Agent 能力扩展的新范式
前端·ai编程
程序员佳佳3 小时前
【万字硬核】从零构建企业级AI中台:基于Vector Engine整合GPT-5.2、Sora2与Veo3的落地实践指南
人工智能·gpt·chatgpt·ai作画·aigc·api·ai编程
小小小小小鹿3 小时前
# 险些酿成P0事故!我用 AI 打造了 Android 代码评审“守门员”
agent·ai编程
野生的码农4 小时前
做好自己的份内工作,等着被裁
程序员·ai编程·vibecoding
草梅友仁4 小时前
墨梅博客 1.0.0 发布与更新 | 2026 年第 2 周草梅周报
github·ai编程·nuxt.js
draking6 小时前
1小时用Skill搭一个文章数据追踪系统,踩了 3 个坑
ai编程
peterfei6 小时前
IfAI v0.2.8 技术深度解析:从"工具"到"平台"的架构演进
rust·ai编程
草帽lufei6 小时前
OpenAI API调用实践文本分类和内容生成
openai·agent
msober6 小时前
从零打造你的专属 AI Agent
agent
fox_mt8 小时前
AI Coding - ClaudeCode使用指南
java·ai编程