gsd-sdk Query CLI Adapter 模块拆解:让 query 分发、错误映射与输出退出处理从 cli.ts 中独立出来
本文以 changeset .changeset/cool-monkeys-smell.md(type: Changed / PR #3074)为核心骨架,讲述 get-shit-done 的 SDK(
gsd-sdk)如何把query相关的 CLI 逻辑抽取为独立的 Query CLI Adapter Module(sdk/src/query/query-cli-adapter.ts)。你会看到适配器与分发层的职责边界、错误→退出码的映射约定、CJS 回退策略,以及"抽取专有模块提升可测试性"这一重构在源码与测试中是如何落地的。
一、变更内容速览:一次围绕职责单一的重构
该 changeset 记录了一次结构性的内部重构(无用户可见行为变化):
query CLI path extracted into a dedicated Query CLI Adapter Module —
sdk/src/cli.tsnow delegates query-specific dispatch, error mapping, and output/exit handling tosdk/src/query/query-cli-adapter.tsfor better locality and testability.
拆开来看,这条 release note 包含四个要点:
- 抽取对象:query 专属的 分发(dispatch)、错误映射(error mapping)、输出与退出码处理(output/exit handling);
- 抽取去处:新增的独立模块 sdk/src/query/query-cli-adapter.ts;
- 宿主瘦身:原来的总入口 sdk/src/cli.ts 不再直接承担这些逻辑,只负责把 query 分支委托出去;
- 重构动机:更好的代码局部性(locality)与可测试性(testability)。
这是典型的 "Seam Module" 式演进——与仓库 docs/adr 中多次出现的模块化 seams 思路一脉相承:把易变的、命令专属的适配逻辑收拢到贴近功能域的目录里(这里是 sdk/src/query/),让总入口保持扁平。
二、变更前的痛点:cli.ts 身兼数职
在抽取之前,query 相关的逻辑分散在 sdk/src/cli.ts 的 main() 中。而 cli.ts 本身已经承担了大量职责:
- 参数解析(
parseCliArgs,含run/auto/init/query四类命令); - 读版本号、打印 USAGE;
- workstream 名校验、
GSD_WORKSTREAM环境变量回退、多仓库 project root 查找; init/auto/run三个命令的完整执行流程(构造GSD实例、挂载 CLI/WebSocket transport、跑InitRunner等);- query 命令的分发、错误映射与输出。
问题在于,cli.ts 是进程级入口,天然难以单测:依赖真实 process、真实注册表、真实文件系统。把 query 分支留在入口文件里,意味着每次调整 query 的分发或错误语义都要经过整条 CLI 链路验证。
三、适配器模块的内部契约:输入与输出
抽取后的模块用一组清晰的 TypeScript 接口定义了"CLI 世界"与"分发世界"的边界。首先是输入:
// sdk/src/query/query-cli-adapter.ts
export interface QueryCliAdapterInput {
projectDir: string;
ws?: string; // workstream 名,路由 .planning/ 到 .planning/workstreams/<name>/
queryArgv?: string[]; // query 后的原始 argv(去除了已知 SDK 全局 flag)
}
其次是输出。这里有一个值得注意的设计:适配器不直接写 stdout/stderr、不直接设 process.exitCode,而是返回一个结构化结果,由调用方(cli.ts)负责落盘式输出:
// sdk/src/query/query-cli-output.ts
export interface QueryCliAdapterOutput {
exitCode: number;
stdoutChunks: string[];
stderrLines: string[];
}
正是这个"只计算、不 IO"的约定让测试成为可能——测试无需真实进程即可断言退出码与输出内容。cli.ts 中的 query 分支因此变得极其单薄:
// sdk/src/cli.ts#L314-L324(query 命令分支)
if (args.command === 'query') {
const result = await runQueryCliCommand({
projectDir: args.projectDir,
ws: args.ws,
queryArgv: args.queryArgv,
});
for (const line of result.stderrLines) console.error(line);
for (const chunk of result.stdoutChunks) process.stdout.write(chunk);
process.exitCode = result.exitCode;
return;
}
可见,cli.ts 保留的是与"终端进程"相关的薄壳行为,而 query 域内的语义全部下沉到适配器。
四、runQueryCliCommand:运行时上下文、注册表与分发编排
query-cli-adapter.ts 中的 runQueryCliCommand 是模块的核心,它把一次 gsd-sdk query … 调用编排为四个步骤:
export async function runQueryCliCommand(input: QueryCliAdapterInput): Promise<QueryCliAdapterOutput> {
try {
// 1) 解析运行时上下文(project root + workstream)
const runtime = resolveQueryRuntimeContext({ projectDir: input.projectDir, ws: input.ws });
// 2) 装配查询注册表与命令拓扑
const registry = createRegistry();
const topology = createCommandTopology(registry);
// 3) 执行分发(native / cjs / error 三种模式)
const out = await runQueryDispatch({
registry,
projectDir: runtime.projectDir,
ws: runtime.ws,
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
resolveGsdToolsPath,
topology,
}, input.queryArgv ?? []);
// 4) 把分发结果翻译成 CLI 输出
return buildQueryCliOutputFromDispatch(out);
} catch (err) {
return buildQueryCliOutputFromError(err);
}
}
4.1 运行时上下文解析:workstream 的四级优先级
query-runtime-context.ts 负责确定一次查询作用在哪个 .planning/ 树。其优先级(源码注释中明确记录)为:
--ws <name>flag(最高优先级,若校验失败则视为未提供);GSD_WORKSTREAM环境变量;.planning/active-workstream文件(由readActiveWorkstream读取);- 兜底:根目录
.planning/(无 workstream)。
同时 resolveQueryRuntimeContext 内部通过 findProjectRoot 处理多仓库场景的 project root 向上查找,保证无论从哪个子目录发起查询,语义都落在正确的工作区上。
4.2 分发内部:验证 → 拓扑匹配 → 原生/CJS 回退
真正干活的是 runQueryDispatch(sdk/src/query/query-dispatch.ts),其内部流程为:
- 输入验证:空 argv、
--pick缺字段等都会直接产出validation_error形态的失败结果; - 命令归一化:
normalizeQueryCommand把state json、init.execute-phase、scaffold …等拼写映射到注册表的规范命令; - 拓扑匹配:
createCommandTopology(registry).resolve(...)对归一化后的 argv 做最长前缀匹配(先试a.b.c,再试a b c空格形态,越长者优先); - 三种分发模式:命中注册表走
native;未命中且 CJS 回退开启走cjs(shell out 到gsd-tools.cjs);两者皆不可走error(携带 no-match 提示信息)。
分发结果统一为结构化联合类型(见 query-dispatch-contract.ts):
- 成功:
{ ok: true, stdout, stderr, exit_code: 0 } - 失败:
{ ok: false, error: { kind, code, message, details }, stderr, exit_code }
kind 的取值集合固定为六种:unknown_command、native_failure、native_timeout、fallback_failure、validation_error、internal_error。CLI 适配器作为"薄壳",直接消费这个契约里的 exit_code 与 stdout/stderr。
4.3 一个值得注意的安全细节:mutating 命令的 --help 短路
在 runQueryDispatch 中还有一道防御性保护:若匹配到的原生 handler 在命令清单中标为 mutation(会写盘),且 argv 里带 --help/-h,则直接短路返回一个非写盘的 usage stub,避免 milestone.complete --help 这类调用意外触发落盘副作用(见源码注释 #3259 的说明)。这说明分发层不仅管"把命令送到 handler",还负责"拦截危险的组合"。
五、错误到退出码的映射,与输出约定
适配器模块的第三项职责是错误映射,实现在 query-cli-output.ts 的两个函数中。
buildQueryCliOutputFromDispatch 处理已结构化的失败:把 out.stderr 与 out.error.message 拼进 stderrLines,并透传分发层算好的 exit_code。
buildQueryCliOutputFromError 处理抛出型异常,按异常类型分三层映射:
GSDError:按err.classification通过exitCodeFor映射语义化退出码(见 sdk/src/errors.ts):validation(参数缺失、schema 违规)→ 10blocked(依赖缺失、phase 不存在)→ 11execution(运行期失败、文件 I/O、解析错误)→ 1interruption(超时、信号、用户取消)→ 1
GSDToolsError:优先把子进程原始 stderr 按行透传(err.stderr.split(/\r?\n/)),让用户看到gsd-tools.cjs的真实诊断而非二次包装文本;退出码取err.exitCode ?? 1。- 其他未知异常:统一为
Error: <message>,退出码1。
输出侧约定同样明确:分发成功且结果为 JSON 时,以 JSON.stringify(data, null, 2) 的两空格缩进写入 stdout(纯文本格式则原样追加换行);--pick <field> 只对 JSON 输出生效,若对 text 输出使用 --pick 会直接抛错。
这样的分层让调用方与 handler 可以区分"输入该修"(10)、"环境被阻塞"(11)与"运行失败"(1),Agent 也便于据此决定下一步动作——这正是错误分类系统(sdk/src/errors.ts)设计时想要的可观测语义。
六、兼容性开关:GSD_QUERY_FALLBACK 与 CJS 回退
适配器模块中还有一段容易被忽略、但对运行行为至关重要的逻辑——CJS 回退开关:
// sdk/src/query/query-cli-adapter.ts#L15-L19
function queryFallbackToCjsEnabled(): boolean {
const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase();
if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false;
return true;
}
也就是说,默认开启 CJS 回退:当某个命令没有对应的原生注册 handler(例如仍是 CLI-only 的 graphify、from-gsd2)时,runQueryDispatch 会把归一化后的 argv 交给 resolveGsdToolsPath(指向 query-gsd-tools-path.ts 背后的 SDK Package Compatibility 模块,最终落到仓库的 gsd-tools.cjs)以子进程方式执行,stderr 会附带一条简短的 bridge 警告。只有显式设置 GSD_QUERY_FALLBACK=off / never / false / 0 时才进入严格模式:未命中原生 handler 直接报 unknown_command。
这一点在官方文档中有对应说明(sdk/README.md 的环境变量表),也在 sdk/src/query/QUERY-HANDLERS.md 的 gsd-sdk query 路由一节得到确认。回退的粒度还延伸到策略层:query-fallback-policy.ts 中另有 GSD_QUERY_FALLBACK=registered 形态的受限策略(对应 describeFallbackDisabledPolicy),供需要部分约束的调用方使用。
对使用者而言,最常见的几个实测命令形态是:
# 查询状态(原生 handler)
gsd-sdk query state show
gsd-sdk query state json
# 取 JSON 结果中的单个字段
gsd-sdk query check auto-mode --pick active
# 阶段操作(空格或点号拼写均可)
gsd-sdk query phase add 12 --help # 命中原生 handler,输出该子命令的上下文帮助
cli.ts 的宽松解析器 parseCliArgsQueryPermissive(sdk/src/cli.ts)只吞掉 --project-dir、--ws、--ws-port、--model、--max-budget、-h/--help、-v/--version 等已知 flag,其余 token 一律原样进入 queryArgv 透传给注册表——这正是 gsd-sdk query phase add --help 能把 --help 送达到 handler 而不是被顶层 usage 截胡的原因(注释中的 #3019 决策)。
七、可测试性红利:依赖注入 seams 与单测覆盖
changeset 把动机明确表述为 "for better locality and testability"。源码层面证据充分——适配器把变化点收敛为四个可 mock 的依赖 seam:
createRegistry()(注册表)createCommandTopology(registry)(命令拓扑)runQueryDispatch(...)(分发器)resolveGsdToolsPath(CJS 路径解析)resolveQueryRuntimeContext(运行上下文)
于是单测 sdk/src/query/query-cli-adapter.test.ts 得以用 vi.mock + vi.hoisted 构造一个纯净环境,覆盖三条关键行为:
- 缺失命令的验证失败:
queryArgv: []时分发返回validation_error(exit_code 10),断言输出 stderr 含 "requires a command"; - ws 与拓扑透传:断言
runQueryDispatch收到的ws、topology正确、且未注入已废弃的nativeAdapter; - 路径 seam 装配:断言传给分发器的
resolveGsdToolsPath来自 query seam 模块,且调用后把'/tmp/project'映射为/mock/gsd-tools.cjs。
配合同目录的 query-cli-output.test.ts,错误映射与输出构建也被独立锁定。仓库层面还有跨层集成回归:tests/bug-2524-sdk-query-ws-flag.test.cjs(文件名直指历史上 ws flag 的缺陷)验证 SDK query 的 workstream flag 行为,并在注释中回链到 query-cli-adapter.test.ts。这形成了"单测锁行为 + 集成防回归"的双层保障——而这正是把逻辑从进程入口里挪出来之后才有的可测性。
八、对外行为不变:契约如何被完整继承
抽取重构最大的风险是悄悄改变 CLI 语义。从现有代码可以确认,迁移是按契约平移而非重写:
cli.ts的 query 分支只是把"拿到结构化输出 → 逐行/逐块写出 → 设置退出码"这段通用 shell 行为保留在入口,其余全部委托;Query CLI Adapter Module 对外暴露的仍是原来的 exit code、JSON stdout 与 stderr 约定。- 分发的规范化、最长前缀匹配、CJS 回退等语义定义在 sdk/src/query/query-dispatch.ts 与 QUERY-HANDLERS.md,未被入口文件改动波及。
gsd-sdk query的完整路由说明(normalize → 最长前缀匹配 → dotted token 拆解 → CJS 回退 → JSON 输出)与命令覆盖矩阵(state.*、verify.*、phase.*、init.*等 manifest 家族)都记录在 sdk/src/query/QUERY-HANDLERS.md,可作为本次抽取后"行为不变量"的对照基准。
九、小结:这次重构留给架构的启发
从一次小小的 changeset(.changeset/cool-monkeys-smell.md,type: Changed / PR #3074)出发,可以看到 get-shit-done 在 SDK 演进中反复使用的手法:
- 进程入口保持薄:
cli.ts只做参数解析与 IO 收尾,业务语义全部下沉; - 命令域自治:
sdk/src/query/目录既是 handler 的家,也是 CLI 适配器的家,改动不再跨层; - 结构化契约:从分发结果(
ok/exit_code/stderr)到 CLI 输出(exitCode/stdoutChunks/stderrLines)再到错误分类(validation=10 / blocked=11 / execution=1),层层都是可断言的数据结构; - 可测优先:依赖以 seam 形式注入,让 query-cli-adapter.test.ts 这样的单测可以在无进程、无真实文件系统的情况下锁定行为。
如果你正在维护一个被多个子命令撑大的 CLI 入口,这条演进路径可以直接复用:先定义"输入结构 + 输出结构",再把命令专属的分发、错误映射、退出处理抽进贴近功能域的适配器模块,最后用依赖注入让它在测试里"无痛重现"。这既是 sdk/src/query/query-cli-adapter.ts 给出的答案,也是 cli.ts 瘦身之后依然稳定运行的原因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00