从 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 完全无关。
这个拆分的好处在改造的时候体现出来了:
- 换模型不影响 UI:我们把 Gemini 换成自己的模型,只需要改 core 包,cli 包几乎不用动
- 复用核心逻辑:core 包可以被 VS Code 扩展、其他工具直接引用
- 独立测试:两个包可以分别跑测试,互不干扰
包之间的依赖关系
在 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}'`);
},
});
}
},
};
},
};
这个规则的设计有几个亮点:
-
findPackageName函数 :从当前文件向上查找package.json,找到就返回包名。这个逻辑很通用,不管包嵌套多深都能正确识别。 -
两种错误消息:一种是跨包导入,另一种是导入了不在任何包里的文件。后者能帮你发现项目结构问题。
-
自动修复 :
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 架构有了更深的理解:
- 包拆分要有明确的边界:cli 负责 UI,core 负责逻辑,职责清晰
- 禁止跨包相对导入:短期麻烦,长期收益。这条 Google 制定的规则真的很有用
- 版本号统一管理:避免各个包版本不一致的混乱
- 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