1. 从一次“配置没生效”的启动说起如果你正在给 CLI 工具写首次引导大概率会遇到这个场景用户第一次运行mycli工具要问几个问题、写一份配置、然后进入主流程。听起来简单但真正落地时问题一堆——配置写到哪、全局配置和项目配置怎么分层、用户中途 CtrlC 了怎么办、下次启动怎么知道“已经引导过了”。我最近在给一个内部 CLI 工具做初始化接入核心文件就是setup.ts首次引导和utils/config.ts全局配置加载。目标很明确让工具在首次启动时完成引导把统一 Key/API 通道写进配置后续所有请求都走 TaoToken 的 API 端点。这篇文章就把这套流程拆开讲清楚包括可复制的config.toml、settings.json骨架以及验证配置是否真正生效的命令。先说清楚这套东西适合谁如果你在写 Node.js/TypeScript 的 CLI 工具需要一套“首次引导 分层配置 统一 API 通道”的初始化方案那这篇可以直接抄结构。如果你只是想给自己的脚本加个 API Key 配置也能从第三节的配置骨架里拿到能用的模板。核心检索词先摆出来setup.ts负责首次引导交互config.ts负责全局配置加载两者配合完成“配置系统接入”。整个链路是——启动时检查是否已引导 → 未引导则进入交互 → 写入全局配置 → 加载项目配置 → 合并多源配置 → 校验 API 通道可用。2. 接入前的准备TaoToken 的 Key 与端点在写任何配置代码之前先把“要接入什么”确定下来。TaoToken 提供的是统一的模型 API 通道你只需要两样东西一个 API Key和一个 API 端点。API 端点固定为https://taotoken.net/api注意这个地址不带任何查询参数直接作为 base URL 使用。API Key 需要你在控制台创建创建入口在https://taotoken.net/console创建完成后在https://taotoken.net/api-keys页面可以查看和管理。这里有个容易踩的坑很多人把 Key 直接硬编码进setup.ts或者提交到 Git 的settings.json里。正确做法是——首次引导时把 Key 写入全局配置用户主目录下项目配置里只放非敏感的端点、模型名等。这样团队成员 clone 项目后各自用自己的 Key不会互相污染。如果你还没创建 Key先去控制台建一个。创建时建议按用途命名比如cli-dev、cli-prod方便后续轮换。Key 只在创建时完整显示一次记得及时保存到安全的地方。对于需要长期跑编码任务或 Agent 的场景可以了解下 Coding Plan它更适合高频调用如果只是验证模型连通性直接用模型对话页面测试即可。接入文档在https://taotoken.net/doc里面有各语言的调用示例。3. 可复制的配置骨架config.toml 与 settings.json配置系统分两层全局配置放用户级信息Key、默认模型项目配置放项目级信息端点、超时、权限规则。下面两份骨架可以直接复制修改。先看全局配置~/.mycli/config.toml# ~/.mycli/config.toml # 全局配置跨项目共享包含敏感信息权限设为 0600 [api] # TaoToken 统一 API 端点不要带尾部斜杠 base_url https://taotoken.net/api # API Key 从控制台创建后填入不要提交到版本控制 api_key sk-xxxxxxxxxxxxxxxxxxxxxxxx # 默认请求超时毫秒 timeout_ms 60000 # 失败重试次数 max_retries 3 [model] # 默认模型可按需替换 default claude-sonnet-4-20250514 # 备用模型主模型不可用时切换 fallback claude-haiku-3-5-20241022 [user] # 首次引导完成标记setup.ts 会检查这个字段 onboarding_completed false # 配置版本用于后续迁移 config_version 1再看项目配置.mycli/settings.json{ api: { base_url: https://taotoken.net/api, timeout_ms: 30000 }, model: { default: claude-sonnet-4-20250514 }, permissions: { allow: [read_file, list_dir], deny: [write_file, exec_shell] }, hooks: { on_start: [], on_exit: [] } }项目配置里不要放 api_key。加载时config.ts会把全局配置和项目配置合并项目配置的api.base_url覆盖全局的同名字段但api_key只从全局配置读取。这样设计的好处是项目配置可以安全提交到 Git团队成员各自维护自己的全局 Key。合并策略要明确标量字段如timeout_ms高优先级覆盖低优先级数组字段如permissions.allow合并去重对象字段如api深层合并。这个规则写在config.ts的mergeConfig函数里下面会给实现。4. setup.ts 首次引导流程实现setup.ts的核心职责是检查是否已完成引导未完成则进入交互完成后写入全局配置。整个流程分四个阶段。4.1 引导状态检查启动时第一件事是读全局配置看onboarding_completed是否为true。这里要注意配置文件可能不存在首次运行也可能存在但损坏用户手动编辑出错。两种情况都要能优雅处理。// setup.ts import { existsSync, readFileSync, writeFileSync, mkdirSync } from fs; import { join } from path; import { homedir } from os; import { parse as parseToml, stringify as stringifyToml } from iarna/toml; const GLOBAL_CONFIG_DIR join(homedir(), .mycli); const GLOBAL_CONFIG_PATH join(GLOBAL_CONFIG_DIR, config.toml); interface GlobalConfig { api: { base_url: string; api_key: string; timeout_ms: number; max_retries: number; }; model: { default: string; fallback: string; }; user: { onboarding_completed: boolean; config_version: number; }; } function loadGlobalConfig(): GlobalConfig | null { if (!existsSync(GLOBAL_CONFIG_PATH)) { return null; } try { const raw readFileSync(GLOBAL_CONFIG_PATH, utf-8); return parseToml(raw) as unknown as GlobalConfig; } catch (err) { // 配置损坏返回 null 触发重新引导 console.warn(全局配置解析失败将重新引导: ${(err as Error).message}); return null; } }4.2 交互式引导脚本如果配置不存在或onboarding_completed为false进入交互。交互只问三个问题API Key、默认模型、是否信任当前项目。用readline实现不引入额外依赖。// setup.ts续 import { createInterface } from readline; async function runOnboarding(): PromiseGlobalConfig { const rl createInterface({ input: process.stdin, output: process.stdout, }); const question (q: string): Promisestring new Promise((resolve) rl.question(q, resolve)); console.log(\n 首次引导 \n); const apiKey (await question( 请输入 TaoToken API Key控制台创建: )).trim(); if (!apiKey.startsWith(sk-)) { console.error(API Key 格式不正确应以 sk- 开头); rl.close(); process.exit(1); } const model (await question( 默认模型 [claude-sonnet-4-20250514]: )).trim() || claude-sonnet-4-20250514; const trustAnswer (await question( 是否信任当前项目目录(y/N): )).trim().toLowerCase(); rl.close(); return { api: { base_url: https://taotoken.net/api, api_key: apiKey, timeout_ms: 60000, max_retries: 3, }, model: { default: model, fallback: claude-haiku-3-5-20241022, }, user: { onboarding_completed: true, config_version: 1, }, }; }4.3 配置写入与权限控制写入时有两个关键点目录不存在要先创建文件权限要设为0600仅所有者可读写。因为里面有 API Key不能让同机器其他用户读到。// setup.ts续 function saveGlobalConfig(config: GlobalConfig): void { if (!existsSync(GLOBAL_CONFIG_DIR)) { mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true, mode: 0o700 }); } const content stringifyToml(config as any); writeFileSync(GLOBAL_CONFIG_PATH, content, { encoding: utf-8, mode: 0o600, }); console.log(配置已写入: ${GLOBAL_CONFIG_PATH}); } export async function setup(): Promisevoid { const existing loadGlobalConfig(); if (existing?.user?.onboarding_completed) { console.log(已完成引导跳过 setup); return; } const config await runOnboarding(); saveGlobalConfig(config); console.log(引导完成正在进入主流程...\n); }4.4 中断恢复处理用户可能在引导过程中按 CtrlC。如果不处理会留下一个半成品配置。做法是写入前先写临时文件写完再原子重命名。这样即使中断原配置也不会被破坏。// setup.ts续 import { renameSync } from fs; function saveGlobalConfigAtomic(config: GlobalConfig): void { if (!existsSync(GLOBAL_CONFIG_DIR)) { mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true, mode: 0o700 }); } const tmpPath ${GLOBAL_CONFIG_PATH}.tmp.${Date.now()}; const content stringifyToml(config as any); writeFileSync(tmpPath, content, { encoding: utf-8, mode: 0o600 }); renameSync(tmpPath, GLOBAL_CONFIG_PATH); // 原子替换 }5. config.ts 配置加载与多源合并setup.ts负责写config.ts负责读和合并。加载顺序是全局配置 → 项目配置 → 环境变量覆盖。合并规则前面说过这里给完整实现。5.1 全局配置加载与缓存全局配置在进程生命周期内只读一次之后走内存缓存。这样避免每次请求都读磁盘。// utils/config.ts import { existsSync, readFileSync } from fs; import { join } from path; import { homedir } from os; import { parse as parseToml } from iarna/toml; let globalConfigCache: GlobalConfig | null null; export function getGlobalConfig(): GlobalConfig { if (globalConfigCache) { return globalConfigCache; } const path join(homedir(), .mycli, config.toml); if (!existsSync(path)) { throw new Error(全局配置不存在请先运行 setup); } const raw readFileSync(path, utf-8); globalConfigCache parseToml(raw) as unknown as GlobalConfig; return globalConfigCache; }5.2 项目配置加载项目配置从当前工作目录向上查找.mycli/settings.json找到第一个就用。这样在子目录运行也能读到项目根目录的配置。// utils/config.ts续 import { readFileSync, existsSync } from fs; import { resolve, dirname } from path; function findProjectConfig(startDir: string): string | null { let current resolve(startDir); while (true) { const candidate join(current, .mycli, settings.json); if (existsSync(candidate)) { return candidate; } const parent dirname(current); if (parent current) return null; // 到达根目录 current parent; } } export function loadProjectConfig(cwd: string): Recordstring, any | null { const path findProjectConfig(cwd); if (!path) return null; try { return JSON.parse(readFileSync(path, utf-8)); } catch { return null; } }5.3 多源合并实现合并顺序全局配置为基础项目配置覆盖环境变量最后覆盖。数组去重对象深层合并。// utils/config.ts续 function isPlainObject(v: unknown): v is Recordstring, any { return typeof v object v ! null !Array.isArray(v); } export function mergeConfig( base: Recordstring, any, override: Recordstring, any ): Recordstring, any { const result { ...base }; for (const key of Object.keys(override)) { const bv base[key]; const ov override[key]; if (isPlainObject(bv) isPlainObject(ov)) { result[key] mergeConfig(bv, ov); } else if (Array.isArray(bv) Array.isArray(ov)) { result[key] Array.from(new Set([...bv, ...ov])); } else { result[key] ov; } } return result; } export function getEffectiveConfig(cwd: string): Recordstring, any { const global getGlobalConfig() as unknown as Recordstring, any; const project loadProjectConfig(cwd) ?? {}; let merged mergeConfig(global, project); // 环境变量覆盖最高优先级 if (process.env.MYCLI_API_KEY) { merged.api { ...merged.api, api_key: process.env.MYCLI_API_KEY }; } if (process.env.MYCLI_BASE_URL) { merged.api { ...merged.api, base_url: process.env.MYCLI_BASE_URL }; } return merged; }6. 验证配置生效命令与预期输出配置写完不算完得验证它真的生效了。下面给三个验证步骤从配置读取到实际请求。6.1 验证配置加载先加一个调试命令打印合并后的配置隐藏 Key// cli.ts import { getEffectiveConfig } from ./utils/config; if (process.argv[2] config:show) { const cfg getEffectiveConfig(process.cwd()); const safe JSON.parse(JSON.stringify(cfg)); if (safe.api?.api_key) { safe.api.api_key safe.api.api_key.slice(0, 6) ... safe.api.api_key.slice(-4); } console.log(JSON.stringify(safe, null, 2)); }运行mycli config:show预期输出{ api: { base_url: https://taotoken.net/api, api_key: sk-xxx...xxxx, timeout_ms: 30000, max_retries: 3 }, model: { default: claude-sonnet-4-20250514, fallback: claude-haiku-3-5-20241022 }, permissions: { allow: [read_file, list_dir], deny: [write_file, exec_shell] } }注意timeout_ms是30000而不是全局的60000说明项目配置覆盖生效了。6.2 验证 API 通道连通写一个最小请求确认 Key 和端点都能用// scripts/verify-api.ts import { getEffectiveConfig } from ../utils/config; async function verify() { const cfg getEffectiveConfig(process.cwd()); const res await fetch(${cfg.api.base_url}/v1/messages, { method: POST, headers: { Content-Type: application/json, x-api-key: cfg.api.api_key, anthropic-version: 2023-06-01, }, body: JSON.stringify({ model: cfg.model.default, max_tokens: 32, messages: [{ role: user, content: ping }], }), }); if (!res.ok) { console.error(请求失败: ${res.status} ${await res.text()}); process.exit(1); } const data await res.json(); console.log(API 通道正常返回:, JSON.stringify(data).slice(0, 200)); } verify();运行npx tsx scripts/verify-api.ts预期看到类似输出API 通道正常返回: {id:msg_xxx,type:message,role:assistant,content:[{type:text,text:pong}]}如果返回401说明 Key 不对返回404检查base_url是否多了尾部斜杠返回超时检查网络和timeout_ms。6.3 验证首次引导幂等再运行一次mycli预期输出已完成引导跳过 setup不会重复问问题。这一步验证onboarding_completed标记生效。7. 本篇常见错误排查配置系统接入时报错集中在几个地方。下面按现象列排查路径。报错一全局配置不存在请先运行 setup说明~/.mycli/config.toml没生成。检查setup.ts是否真的被调用——很多 CLI 在main.ts里忘了await setup()。另外确认homedir()返回的路径和你以为的一致Windows 上是C:\Users\用户名macOS/Linux 是/home/用户名或/Users/用户名。报错二API Key 格式不正确应以 sk- 开头引导脚本里的校验太严或太松。如果你的 Key 不是sk-开头把校验改成非空即可。但更常见的是用户复制 Key 时带了空格记得.trim()。报错三请求返回401 Unauthorized三个可能Key 写错了、Key 被环境变量覆盖成了空值、请求头字段名不对。TaoToken 的 API 用x-api-key头不是Authorization: Bearer。检查verify-api.ts里的头字段。报错四项目配置没生效findProjectConfig从cwd向上找如果你在/project/src/deep/运行而配置在/project/.mycli/settings.json是能找到的。但如果配置在/project/src/.mycli/就会先命中那个。用mycli config:show确认实际加载的路径。报错五数组字段被覆盖而不是合并检查mergeConfig里是否走了Array.isArray分支。如果项目配置的permissions.allow是[write_file]全局是[read_file]合并后应该是[read_file, write_file]。如果结果是[write_file]说明合并函数没生效可能被{ ...base }浅拷贝覆盖了。报错六配置写入后权限不对writeFileSync的mode选项只在文件新建时生效。如果文件已存在mode会被忽略。要确保权限正确先unlinkSync再写或者用fs.chmodSync显式设置。8. 下一步把配置系统接进主流程到这里setup.ts和config.ts的骨架已经能跑通了。接下来要做的是在 CLI 入口处把两者串起来// cli.ts import { setup } from ./setup; import { getEffectiveConfig } from ./utils/config; async function main() { await setup(); // 首次引导幂等 const cfg getEffectiveConfig(process.cwd()); // 加载合并配置 // 后续所有请求用 cfg.api.base_url 和 cfg.api.api_key await runMainLoop(cfg); } main().catch((err) { console.error(err); process.exit(1); });如果你要长期跑编码任务或 Agent建议把 Key 换成 Coding Plan 的凭证调用频率和稳定性会更好。验证模型连通性时直接用模型对话页面测一下最快。接入过程中遇到具体报错先查接入文档大部分错误码都有说明。最后提醒一句全局配置里的 Key 是明文存储的0600权限只能防同机器其他用户防不了拿到你磁盘的人。生产环境建议走环境变量注入或者用系统钥匙串。项目配置里永远不要放 Key这是底线。