1819 words
9 minutes
qianwen-cli 架构拆解:分层、双模式与 OAuth 如何塞进一个 ESM 二进制

qianwen-cli 架构拆解:分层、双模式与 OAuth 如何塞进一个 ESM 二进制#

这是个什么 CLI#

@qianwenai/qianwen-cli 是千问的命令行客户端,装成一个 Node 单文件 ESM 二进制。它能浏览模型、查用量和账单、管 OAuth 登录、看本地环境诊断,也能进交互式 REPL。

它有个明显的双面性格:不带参数启动进 REPL(src/repl.ts),带参数就跑一次命令。后者是为脚本、CI 和 AI agent 准备的——输出不能带 ANSI 颜色码,否则管道和解析器会吃瘪。

先看一张图#

整张架构图我画在这里:

带暗亮主题切换和导出菜单(右上角可导出 PNG/JPEG/SVG),跟随站点的 prefers-color-scheme 自动切主题。

图的骨架是一条从左到右的主路径:用户 → 入口 → 命令 → 服务 → API 层 → 后端。下面逐段拆。

分层:命令到后端不跳层#

代码按层组织,请求自上而下走,高一层不该知道低一层的内部:

commands/ Commander action handlers,解析参数 → 调 service → 渲染
services/ 编排,跨调用组合、缓存
api/ 传输,base-client → auth-client/api-client → cli-facade
view-models/ 把域类型转成显示行,纯函数,不碰 I/O
output/ 按 JSON / text 渲染
ui/ Ink(React)组件,交互表格、提示、spinner

这条规矩不是摆设。commands/ 里的 action handler 只做三件事:解析 flag、决定格式、调 service 再渲染。它不直接 fetch,也不直接组装视图模型。

举一个真实的 service 编排例子。UsageServicecreateServices() 里这样构造:

const usageService = new UsageService(
apiClient,
billingService,
freetierService,
tokenplanService,
cache,
);

一个用量查询要同时动用计费、免费额度、token 套餐三个子服务。这些子服务在同一次 CLI 运行里只构造一次,被共享,所以 FreetierService 的配额缓存能跨命令存活,不用每次重查。

组合根:一个地方管所有装配#

src/services/index.tscreateServices() 是唯一的装配点。它按依赖顺序把整条服务链搭起来,源码注释里写得很直白:

BaseClient → AuthClient ↘
BaseClient → ApiClient → FreetierService ↘
→ BillingService ↘
→ TokenplanService → UsageService
→ ModelsService (← FreetierService)
→ AuthService (← AuthClient)

把所有 new 集中在一个函数,好处是依赖都是注入的,不是在服务内部自己 new 出来。这正是它能被单测的原因——tests/helpers/service-mocks.ts 注入假的 ApiClientCachedFetcher,不用碰网络。

返回的 ServiceContainerapiClientauthClientcache 和十来个 service 一次性交出来。commands 拿到这个容器,只认它暴露的窄接口,不直接碰传输层。

双模式 和 one-shot 怎么切换#

bin/qianwen.ts 是真入口。它看 process.argv 有没有参数,走两条路:

if (args.length === 0) {
const { startRepl } = await import('../src/repl.js');
await startRepl();
} else {
const { createProgram } = await import('../src/cli.js');
const program = createProgram();
await program.parseAsync(process.argv);
}

REPL 是动态 import 的,这样 one-shot 模式不用付加载 REPL 的成本。

模式标记存在 src/utils/runtime-mode.ts 的一个模块级 _isRepl 变量里,REPL 启动时 setReplMode() 置一次。之后所有面向用户的输出都查 isReplMode():

  • one-shot 模式输出必须不带 ANSI 颜色码,要能被管道和 grep 干净处理
  • 帮助样式(cli.ts 里的 styleSectionTitle 等)只在 REPL 模式才上色
  • 命令提示里的 qianwen 前缀,只有 one-shot 模式才加;REPL 里直接写 login

写新输出时这条规矩要守住——别硬编码 qianwen 前缀,也别无条件下色。

协议#

后端不说 REST,说一套“flat-parameter 协议”。请求靠 productaction 两个常量来路由,常量都集中在 src/types/api-routes.ts:

export const API_PRODUCT_GATEWAY = 'sfm_bailian';
export const API_PRODUCT_SEARCH = 'aliyun-search-maas';
export const API_ACTION_LIST_MODELS = 'ListModelSeries';
export const API_ACTION_CONSUME_SUMMARY = 'MaasListConsumeSummary';
export const API_ACTION_GET_FUND_ACCOUNT_BALANCE = 'GetFundAccountAvailableAmount';

api-client.ts 暴露 callFlatApicallEnvelopeApi 两个方法发请求。cli-facade.ts 再把这些包成 commands 真正消费的窄业务方法——getModelsgetModelDetaillistConsumeSummarydescribeFqInstancegetAvailableBalance 这类。

传输层和域类型之间还插着 wire-format 适配器(api/adapters/),把扁平的接口响应翻译成 types/ 里的域类型。模型映射逻辑更重一点,单独放在 api/model-mapper/ 下,里面有定价、免费额度、属性各自一套映射。所以 product/action 常量属于 types/api-routes.ts,不能内联到调用处——这是协议层的纪律。

认证 Device Flow 加 keychain#

命令在发任何 API 请求前都要先过 ensureAuthenticated() 这道门,它来自 src/auth/credentials.ts

登录走 OAuth Device Flow(auth/login-flow.ts)。凭证存在 OS keychain 里(auth/keychain.ts),没有 keychain 就退回加密文件(auth/crypto-store.ts)——这里没用 keytar 这种原生绑定。

传输层 src/api/base-client.ts 是 HTTP 底座,包全局 fetch,带超时、注入 auth、错误归一化和调试脱敏。authModerequired/optional/none 三档:

if (authMode === 'required') {
const creds = resolveCredentials();
if (!creds) throw new Error('Not authenticated. Please login first.');
headers.Authorization = `Bearer ${creds.access_token}`;
}

redactToken 在调试输出里把 token 抹掉。站点端点都收在 src/site.ts 这一个地方https://cli.qianwenai.com,认证是 https://t.qianwenai.com,模型映射 CDN 是 https://alioth.alicdn.com,货币是 CNY。读 site 的值,别硬编码——这套配置让 CLI 能换皮。

输出:格式怎么决定#

格式有三档优先级(src/output/format.ts):--format flag 最高,其次是 config 里的 output.format,最后是 TTY 检测(交互式落 table,管道落 JSON)。非法 --format 值直接在 stderr 报结构化错误退出,挡住 agent 传一个不支持的格式。

退出码集中管(src/utils/exit-codes.ts):0 成功,1 通用/用法错,2 认证失败,3 网络错,4 配置/参数错,130 中断。JSON 错误走固定的 { error: { code, message, exit_code } } 形状,统一从 handleError 出,不手写。

这套设计的目标很明确 和脚本能稳定解析输出和退出码,人在终端能用表格。两条路同源,差异只在 ANSI 和格式。

构建管线和注入的全局变量#

scripts/build.ts 编排构建bin/qianwen.ts 打成 dist/bin/qianwen.js,prod 跑 Terser 混淆,默认不产出 sourcemap,然后跑一遍 smoke test(除非 --skip-smoke)。

构建时通过 tsup define 注入几个全局变量,用到的地方必须显式声明:

  • __VERSION__ 来自 package.json 版本,在 src/index.tssrc/api/base-client.ts
  • __BUILD_TIME____NODE_ENV__
  • __ERROR_VERBOSITY__(取 suppress/graceful/verbose),测试里固定为 verbose

引用这些全局的新代码,要加一句 declare const __FOO__: ... 带个测试/dev 回退字面量,不然构建会断。prod 只发二进制入口,index.ts/cli.ts/repl.ts 这些库导出只在 dev 模式构建。

一些取舍#

这套分层看着重,但有几个地方是刻意留的弹性。view-model 这层如果某个命令是纯 JSON 输出,可以跳过——但要在改动说明里说清楚跳了。Commander 的内部属性访问全走 src/utils/commander-helpers.ts,因为 prod 的属性 mangling 会把下划线前缀的字段打乱,直接读会断。

服务层不引新的抽象,除非有两个真实调用方需要。组合根和 types/api-routes.ts 常量是指定的扩展点——扩它们,别 fork 它们。

一句话总结这套架构想干嘛:把一个对 agent 友好、对人也能看的 CLI,用清晰的分层和一个集中的装配点管住,让传输、协议、认证这些容易发散的东西各自有边界。

参考资料#

qianwen-cli 架构拆解:分层、双模式与 OAuth 如何塞进一个 ESM 二进制
https://sgjki547.top/posts/2026-07-15-qianwen-cli-架构拆解/
Author
SGJki
Published at
2026-07-15
License
CC BY-NC-SA 4.0