commit 4dd3d1af7812b995852c7a8d52cdea7aee836285 Author: 万能助手 Date: Tue Sep 1 09:12:51 2026 +0800 初版:中文交互式 Coding CLI 装机与订阅助手(体检/安装/状态/订阅切换) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..85b782c --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Coding Helper(cch) + +中文交互式 Coding CLI 装机与订阅助手。一个命令完成:环境体检修复 → 安装编码工具 → 检测订阅状态 → 切换第三方端点。 + +## 功能 + +| 功能 | 说明 | +| --- | --- | +| 🏥 环境体检 | 检测 Node/npm/Git/curl/网络/目录权限,缺失项一键调用系统包管理器修复 | +| 📦 软件广场 | 交互式安装 / 更新 8 款编码工具,自动按平台(Windows/macOS/Linux)选择渠道 | +| 📊 状态总览 | 一览各工具:是否安装、版本、官方订阅 / 第三方端点 / 未登录 | +| 🔑 订阅管理 | 第三方 API 端点预设(新增 / 删除 / 切换 / 恢复官方),切换前自动备份原配置 | + +## 支持的工具 + +| 工具 | 安装 | 第三方端点切换 | +| --- | --- | --- | +| Claude Code | npm / winget / 官方脚本 | ✅ settings.json env 块 | +| Codex CLI | npm / 官方脚本 | ✅ config.toml + auth.json | +| OpenCode | npm / brew / choco / scoop / 官方脚本 | ✅ opencode.json provider.baseURL | +| Crush | npm / winget / brew / apt | 🧪 实验性(crushrc export 注入) | +| Kimi Code CLI | 官方脚本 | — | +| Qwen Code | npm | — | +| CodeBuddy Code | npm | — | +| Cursor CLI | 官方脚本 | — | + +## 使用 + +```bash +# 交互模式(推荐) +npx coding-helper +# 或安装后使用短命令 +npm install -g coding-helper +cch + +# 非交互(脚本 / CI) +cch doctor # 环境体检,打印结果,缺关键依赖时退出码 1 +cch status # 打印各工具安装与订阅状态 +``` + +## 订阅切换说明 + +- 预设保存在 `~/.cch/presets.json`,切换时原配置备份为 `*.cch.bak`,可随时在「恢复官方配置」还原 +- Codex 切换会以 API Key 模式覆盖 ChatGPT 订阅登录(auth.json 同样有备份) +- OpenCode 的 API Key 为明文写入 opencode.json,注意文件权限 +- 各家 CLI 升级可能改变配置格式,如切换后异常,先执行「恢复官方配置」 + +## 开发 + +```bash +npm install +npm run dev # tsx 直接运行 +npm run build # 编译到 dist/ +npm run typecheck +``` + +### 架构 + +```text +src/ +├── index.ts # 入口:交互主菜单 + 非交互子命令 +├── run.ts # 跨平台命令执行(Windows 自动 shell、脚本安装封装) +├── platform.ts # 平台 / 架构 / WSL / 发行版 / 包管理器探测 +├── registry.ts # 工具注册表(数据驱动:新增工具只需追加配置) +├── doctor.ts # 环境体检 + 修复命令生成 +├── status.ts # 安装 / 版本 / 授权状态探测 +└── subscribe.ts # 预设 CRUD + 四工具切换实现(备份 + 原子写) +``` + +- 技术栈:Node.js ≥ 18 + TypeScript + @clack/prompts(交互 UI)+ picocolors +- 全中文文案;Windows 走 PowerShell(irm | iex),macOS/Linux 走 curl | bash + +## 路线图 + +- [ ] Windows 真机验证(路径 / PowerShell 提权) +- [ ] Crush 端点级配置支持(provider 段,替换当前实验性 export 方案) +- [ ] Kimi / Qwen / CodeBuddy / Cursor 的第三方端点支持(视官方能力) +- [ ] 一键更新全部已装工具 +- [ ] 代理 / 镜像(npm registry、安装脚本)配置项 + +## 许可 + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4d5f0ca --- /dev/null +++ b/package-lock.json @@ -0,0 +1,641 @@ +{ + "name": "coding-helper", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coding-helper", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.0", + "picocolors": "^1.1.1" + }, + "bin": { + "cch": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f0ec88 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "coding-helper", + "version": "0.1.0", + "description": "跨平台中文交互式 Coding CLI 助手:一键安装 / 环境体检修复 / 状态检测 / 第三方订阅切换", + "type": "module", + "bin": { + "cch": "dist/index.js" + }, + "main": "dist/index.js", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "cli", + "coding", + "helper", + "installer", + "claude-code", + "codex", + "opencode" + ], + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.0", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..bd61506 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,142 @@ +import os from 'node:os' +import fs from 'node:fs' +import { commandExists, run } from './run.js' +import type { PlatformInfo, PackageManager } from './platform.js' + +export interface EnvCheckItem { + id: string + label: string + ok: boolean + detail: string + /** 修复命令(数组形式),无法自动修复时为 null */ + fix: string[] | null + fixNote: string +} + +/** 环境体检:检测编码工具运行所需的基础环境 */ +export async function checkEnv(pf: PlatformInfo): Promise { + const items: EnvCheckItem[] = [] + + // Node 版本 + const nodeMajor = parseInt(process.versions.node.split('.')[0], 10) + items.push({ + id: 'node', + label: 'Node.js 运行时', + ok: nodeMajor >= 18, + detail: `v${process.versions.node}${nodeMajor >= 18 ? '' : '(需 ≥ 18)'}`, + fix: nodeMajor >= 18 ? null : fixCommand(pf, 'node'), + fixNote: '安装/升级 Node.js LTS', + }) + + // npm + const npmOk = commandExists('npm') + const npmVer = npmOk ? run('npm', ['--version'], 15_000).stdout.trim() : '' + items.push({ + id: 'npm', + label: 'npm 包管理器', + ok: npmOk, + detail: npmOk ? `v${npmVer}` : '未找到(多数工具经 npm 安装)', + fix: npmOk ? null : fixCommand(pf, 'node'), + fixNote: '随 Node.js 一并安装', + }) + + // git + const gitOk = commandExists('git') + const gitVer = gitOk ? run('git', ['--version'], 15_000).stdout.trim() : '' + items.push({ + id: 'git', + label: 'Git', + ok: gitOk, + detail: gitOk ? gitVer : '未找到(版本管理与部分安装脚本依赖)', + fix: gitOk ? null : fixCommand(pf, 'git'), + fixNote: '安装 Git', + }) + + // curl(Windows 用 PowerShell,不需要 curl) + if (pf.platform !== 'windows') { + const curlOk = commandExists('curl') + items.push({ + id: 'curl', + label: 'curl', + ok: curlOk, + detail: curlOk ? '可用' : '未找到(官方安装脚本依赖)', + fix: curlOk ? null : fixCommand(pf, 'curl'), + fixNote: '安装 curl', + }) + } else { + const psOk = commandExists('powershell') + items.push({ + id: 'powershell', + label: 'PowerShell', + ok: psOk, + detail: psOk ? '可用(用于官方脚本安装)' : '未找到', + fix: null, + fixNote: 'Windows 10+ 自带,请手动排查', + }) + } + + // 网络(探测 npm 当前 registry,兼顾国内镜像) + const registry = (() => { + const r = run('npm', ['config', 'get', 'registry'], 10_000) + return /^https?:\/\//.test(r.stdout.trim()) ? r.stdout.trim().replace(/\/$/, '') : 'https://registry.npmjs.org' + })() + const netOk = await checkNetwork(registry) + items.push({ + id: 'network', + label: `网络连通性(${registry.replace(/^https?:\/\//, '')})`, + ok: netOk, + detail: netOk ? '正常' : '不可达(安装将失败,请检查网络或代理)', + fix: null, + fixNote: '无法自动修复,请检查网络 / HTTP(S)_PROXY 设置', + }) + + // 家目录可写 + const HOME = os.homedir() + let homeOk = false + try { + fs.accessSync(HOME, fs.constants.W_OK) + homeOk = true + } catch { + homeOk = false + } + items.push({ + id: 'home', + label: '用户目录可写', + ok: homeOk, + detail: homeOk ? HOME : `${HOME} 不可写`, + fix: null, + fixNote: '请检查用户目录权限', + }) + + return items +} + +async function checkNetwork(baseUrl: string): Promise { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 6_000) + const res = await fetch(baseUrl, { signal: ctrl.signal }) + clearTimeout(timer) + return res.ok + } catch { + return false + } +} + +/** 生成某依赖在当前平台的安装命令 */ +function fixCommand(pf: PlatformInfo, what: 'node' | 'git' | 'curl'): string[] | null { + const pm: PackageManager | undefined = pf.packageManagers[0] + const pkg: Record> = { + winget: { node: 'OpenJS.NodeJS.LTS', git: 'Git.Git', curl: 'cURL.cURL' }, + scoop: { node: 'nodejs-lts', git: 'git', curl: 'curl' }, + choco: { node: 'nodejs-lts', git: 'git', curl: 'curl' }, + brew: { node: 'node@22', git: 'git', curl: 'curl' }, + apt: { node: 'nodejs', git: 'git', curl: 'curl' }, + dnf: { node: 'nodejs', git: 'git', curl: 'curl' }, + pacman: { node: 'nodejs', git: 'git', curl: 'curl' }, + } + if (!pm) return null + const p = pkg[pm.id]?.[what] + if (!p) return null + return [...pm.installPrefix, p] +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e95e20f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,300 @@ +#!/usr/bin/env node +import * as p from '@clack/prompts' +import color from 'picocolors' +import { detectPlatform, type PlatformInfo } from './platform.js' +import { TOOLS, channelsFor, getTool, type ToolDef } from './registry.js' +import { checkEnv } from './doctor.js' +import { checkAll, checkTool } from './status.js' +import { + loadPresets, upsertPreset, deletePreset, applyPreset, restoreOfficial, + type Preset, +} from './subscribe.js' +import { runLive, scriptInstallArgs, commandExists } from './run.js' + +const VERSION = '0.1.0' +const pf: PlatformInfo = detectPlatform() + +// ---------- 工具函数 ---------- + +function guard(v: T | symbol): T { + if (p.isCancel(v)) { + p.cancel('已取消') + process.exit(0) + } + return v as T +} + +function banner(): void { + const line = color.cyan('◈') + color.dim('─'.repeat(38)) + console.log(line) + console.log(` ${color.bold(color.cyan('Coding Helper'))} ${color.dim(`v${VERSION}`)}`) + console.log(` ${color.dim('中文交互式 Coding CLI 装机与订阅助手')}`) + console.log(line) + console.log(` 平台:${pf.platformLabel} 架构:${pf.arch}${pf.isWsl ? '(WSL)' : ''}`) + console.log(` 包管理器:${pf.packageManagers.length ? pf.packageManagers.map((m) => m.name).join('、') : color.yellow('未检测到')}`) + console.log('') +} + +const SYM = { ok: color.green('√'), no: color.red('×'), warn: color.yellow('!') } + +// ---------- 1. 环境体检 ---------- + +async function doctorMenu(): Promise { + const s = p.spinner() + s.start('正在体检系统环境…') + const items = await checkEnv(pf) + s.stop('体检完成') + + const lines = items + .map((i) => `${i.ok ? SYM.ok : i.fix ? SYM.no : SYM.warn} ${i.label}:${color.dim(i.detail)}`) + .join('\n') + p.note(lines, '环境体检结果') + + const broken = items.filter((i) => !i.ok && i.fix) + if (broken.length === 0) { + p.log.success('基础环境完备,无需修复') + return + } + const doFix = guard(await p.confirm({ message: `发现 ${broken.length} 项可自动修复,是否立即执行?` })) + if (!doFix) return + for (const item of broken) { + p.log.step(`修复 ${item.label}:${item.fixNote}`) + const code = await runLive(item.fix![0], item.fix!.slice(1)) + if (code === 0) p.log.success(`${item.label} 修复完成`) + else p.log.error(`${item.label} 修复失败(退出码 ${code}),请手动处理`) + } +} + +// ---------- 2. 软件广场(安装) ---------- + +async function storeMenu(): Promise { + const s = p.spinner() + s.start('正在检测安装状态…') + const statuses = checkAll() + s.stop('检测完成') + + const options = statuses.map((st) => ({ + value: st.tool.id, + label: `${st.installed ? SYM.ok : SYM.no} ${st.tool.name} ${color.dim(`· ${st.tool.vendor}`)}`, + hint: st.installed ? `已装 ${st.version ? 'v' + st.version : ''}` : '未安装', + })) + const toolId = guard(await p.select({ message: '选择要安装/更新的工具(回车确认)', options })) + await installTool(getTool(toolId)!) +} + +async function installTool(tool: ToolDef): Promise { + p.log.info(`${tool.name}:${tool.desc}`) + const channels = channelsFor(tool, pf.platform) + if (channels.length === 0) { + p.log.error(`当前平台(${pf.platformLabel})暂无可用安装渠道`) + return + } + const ch = guard( + await p.select({ + message: '选择安装渠道', + options: channels.map((c) => ({ value: c.id, label: c.id, hint: c.desc })), + }), + ) + const channel = channels.find((c) => c.id === ch)! + + if (channel.kind === 'script' && channel.scriptUrl) { + const { cmd, args } = scriptInstallArgs(channel.scriptUrl) + p.log.step(`执行官方脚本:${channel.scriptUrl}`) + const code = await runLive(cmd, args) + if (code === 0) p.log.success('脚本执行完成') + else p.log.error(`脚本执行失败(退出码 ${code})`) + } else if (channel.cmd) { + p.log.step(`执行:${channel.cmd.join(' ')}`) + const code = await runLive(channel.cmd[0], channel.cmd.slice(1)) + if (code === 0) p.log.success('安装命令执行完成') + else p.log.error(`安装失败(退出码 ${code}),请检查上方输出`) + } + + // 安装后验证 + const st = checkTool(tool) + if (st.installed) { + p.log.success(`验证通过:${tool.name} 已可用${st.version ? `(v${st.version})` : ''}`) + p.log.info(`授权提示:${tool.authHint}`) + } else { + p.log.warn('未检测到命令,可能需要重开终端使 PATH 生效') + } +} + +// ---------- 3. 状态总览 ---------- + +function statusView(): void { + const statuses = checkAll() + const authLabel: Record = { + official: color.green('官方订阅'), + 'third-party': color.magenta('第三方端点'), + unknown: color.dim('未知'), + none: color.dim('未登录'), + } + const rows = statuses + .map((st) => { + const install = st.installed + ? color.green(`已装${st.version ? ' v' + st.version : ''}`) + : color.red('未安装') + return `${st.installed ? SYM.ok : SYM.no} ${st.tool.name.padEnd(16, ' ')} ${install.padEnd(22, ' ')} ${authLabel[st.auth]} ${color.dim(st.authDetail)}` + }) + .join('\n') + p.note(rows, '工具状态总览') +} + +// ---------- 4. 订阅管理 ---------- + +const SWITCHABLE = TOOLS.filter((t) => t.subscription.supported) + +async function subscriptionMenu(): Promise { + const action = guard( + await p.select({ + message: '订阅管理', + options: [ + { value: 'list', label: '查看预设' }, + { value: 'add', label: '新增预设' }, + { value: 'apply', label: '切换:应用预设到工具' }, + { value: 'restore', label: '恢复官方配置' }, + ], + }), + ) + + const presets = loadPresets() + + if (action === 'list') { + if (presets.length === 0) { + p.log.info('暂无预设,先「新增预设」录入第三方端点') + return + } + const rows = presets + .map((x) => `${color.bold(x.name)} ${color.dim(x.baseUrl)}${x.note ? ' ' + color.dim('(' + x.note + ')') : ''}`) + .join('\n') + p.note(rows, `第三方端点预设(${presets.length} 个,存储于 ~/.cch/presets.json)`) + const del = guard(await p.confirm({ message: '是否删除某个预设?', initialValue: false })) + if (!del) return + const id = guard(await p.select({ message: '选择要删除的预设', options: presets.map((x) => ({ value: x.id, label: x.name })) })) + deletePreset(id) + p.log.success('已删除') + return + } + + if (action === 'add') { + const name = guard(await p.text({ message: '预设名称(如:某某中转站)', validate: (v) => (v?.trim() ? undefined : '必填') }))!.trim() + const baseUrl = guard( + await p.text({ + message: 'Base URL(如 https://api.example.com)', + validate: (v) => (/^https?:\/\/.+/.test(v?.trim() ?? '') ? undefined : '需以 http(s):// 开头'), + }), + )!.trim() + const apiKey = guard(await p.password({ message: 'API Key(输入隐藏,留空跳过)' })) ?? '' + const note = guard(await p.text({ message: '备注(可空)' })) ?? '' + upsertPreset({ name, baseUrl, apiKey: apiKey.trim(), note: note.trim() }) + p.log.success(`预设「${name}」已保存(~/.cch/presets.json)`) + return + } + + if (action === 'apply') { + if (presets.length === 0) { + p.log.warn('暂无预设,请先新增') + return + } + const preset = guard( + await p.select({ message: '选择要应用的预设', options: presets.map((x) => ({ value: x.id, label: x.name, hint: x.baseUrl })) }), + ) + const targets = guard( + await p.multiselect({ + message: '应用到哪些工具(空格选择,回车确认)', + options: SWITCHABLE.map((t) => ({ + value: t.id, + label: `${t.name}${t.subscription.level === 'experimental' ? color.yellow('(实验性)') : ''}`, + hint: t.subscription.note, + })), + required: true, + }), + ) + const target = presets.find((x) => x.id === preset)! + for (const id of targets) { + const r = applyPreset(id, target) + if (r.ok) p.log.success(`${getTool(id)!.name}:${r.message}`) + else p.log.error(`${getTool(id)!.name}:${r.message}`) + } + p.log.info('原配置已备份为 *.cch.bak,可随时在「恢复官方配置」中还原') + return + } + + if (action === 'restore') { + const id = guard( + await p.select({ + message: '选择要恢复官方配置的工具', + options: SWITCHABLE.map((t) => ({ value: t.id, label: t.name })), + }), + ) + const r = restoreOfficial(id) + if (r.ok) p.log.success(`${getTool(id)!.name}:${r.message}`) + else p.log.error(`${getTool(id)!.name}:${r.message}`) + return + } +} + +// ---------- 非交互模式(测试 / 脚本用) ---------- + +function nonInteractive(arg: string): void { + if (arg === 'doctor') { + checkEnv(pf).then((items) => { + for (const i of items) { + console.log(`${i.ok ? '√' : '×'} ${i.label}:${i.detail}${!i.ok && i.fix ? `(修复:${i.fix.join(' ')})` : ''}`) + } + process.exit(items.every((i) => i.ok || !i.fix) ? 0 : 1) + }) + return + } + if (arg === 'status') { + for (const st of checkAll()) { + console.log(`${st.installed ? '√' : '×'} ${st.tool.name}:${st.installed ? `已装${st.version ? ' v' + st.version : ''}` : '未安装'}|${st.auth}|${st.authDetail}`) + } + return + } + if (arg === 'version' || arg === '--version' || arg === '-v') { + console.log(VERSION) + return + } + console.log('用法:cch [doctor|status|version](无参数进入交互模式)') +} + +// ---------- 主流程 ---------- + +async function main(): Promise { + const arg = process.argv[2] + if (arg) { + nonInteractive(arg) + return + } + + banner() + p.intro(color.cyan('cch')) + + for (;;) { + const action = guard( + await p.select({ + message: '主菜单', + options: [ + { value: 'doctor', label: '🏥 环境体检', hint: '检测缺失依赖,支持一键修复' }, + { value: 'store', label: '📦 软件广场', hint: '安装 / 更新 8 款编码工具' }, + { value: 'status', label: '📊 状态总览', hint: '安装 / 版本 / 订阅状态一览' }, + { value: 'subscribe', label: '🔑 订阅管理', hint: '第三方端点预设与切换(claude code / codex / opencode / crush)' }, + { value: 'exit', label: '🚪 退出' }, + ], + }), + ) + if (action === 'exit') break + if (action === 'doctor') await doctorMenu() + if (action === 'store') await storeMenu() + if (action === 'status') statusView() + if (action === 'subscribe') await subscriptionMenu() + } + p.outro(color.dim('再见,祝编码愉快')) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..081fe71 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,92 @@ +import os from 'node:os' +import { run } from './run.js' + +export type Platform = 'windows' | 'macos' | 'linux' + +export interface PlatformInfo { + platform: Platform + platformLabel: string + arch: string + osVersion: string + isWsl: boolean + /** 按优先级排列的可用包管理器 */ + packageManagers: PackageManager[] + /** Linux 发行版(仅 linux) */ + distro: string | null +} + +export interface PackageManager { + id: 'winget' | 'choco' | 'scoop' | 'brew' | 'apt' | 'dnf' | 'pacman' + name: string + /** 安装包的命令前缀,如 ['sudo', 'apt-get', 'install', '-y'] */ + installPrefix: string[] + needsSudo: boolean +} + +export function detectPlatform(): PlatformInfo { + const platform: Platform = + process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux' + const isWsl = + platform === 'linux' && /microsoft|WSL/i.test(os.release()) && os.release().toLowerCase().includes('microsoft') + + const distro = platform === 'linux' ? detectDistro() : null + + return { + platform, + platformLabel: { windows: 'Windows', macos: 'macOS', linux: distro ? `Linux (${distro})` : 'Linux' }[platform], + arch: process.arch, + osVersion: os.release(), + isWsl, + packageManagers: detectPackageManagers(platform), + distro, + } +} + +function detectDistro(): string | null { + const r = run('/bin/sh', ['-c', '. /etc/os-release 2>/dev/null && echo "$ID"'], 5_000) + const id = r.stdout.trim() + if (id) return id + return run('/bin/sh', ['-c', 'command -v apk >/dev/null && echo alpine'], 5_000).stdout.trim() || null +} + +const PM_DEFS: Record & { installPrefix: string[]; needsSudo: boolean }> = { + winget: { id: 'winget', name: 'winget', installPrefix: ['winget', 'install'], needsSudo: false }, + choco: { id: 'choco', name: 'Chocolatey', installPrefix: ['choco', 'install', '-y'], needsSudo: true }, + scoop: { id: 'scoop', name: 'Scoop', installPrefix: ['scoop', 'install'], needsSudo: false }, + brew: { id: 'brew', name: 'Homebrew', installPrefix: ['brew', 'install'], needsSudo: false }, + apt: { id: 'apt', name: 'apt', installPrefix: ['sudo', 'apt-get', 'install', '-y'], needsSudo: true }, + dnf: { id: 'dnf', name: 'dnf', installPrefix: ['sudo', 'dnf', 'install', '-y'], needsSudo: true }, + pacman: { id: 'pacman', name: 'pacman', installPrefix: ['sudo', 'pacman', '-S', '--noconfirm'], needsSudo: true }, +} + +const PM_ORDER: Record = { + windows: ['winget', 'scoop', 'choco'], + macos: ['brew'], + linux: ['apt', 'dnf', 'pacman'], +} + +function detectPackageManagers(platform: Platform): PackageManager[] { + const found: PackageManager[] = [] + for (const id of PM_ORDER[platform]) { + const probe = { winget: 'winget', choco: 'choco', scoop: 'scoop', brew: 'brew', apt: 'apt-get', dnf: 'dnf', pacman: 'pacman' }[id] + const ok = process.platform === 'win32' + ? run('where.exe', [probe], 8_000).stdout.trim().length > 0 + : run('/bin/sh', ['-c', `command -v ${probe}`], 8_000).ok + if (ok) found.push(PM_DEFS[id]) + } + return found +} + +/** 展开 ~ 为家目录 */ +export function home(p: string): string { + if (p === '~') return os.homedir() + if (p.startsWith('~/') || p.startsWith('~\\')) { + return joinPath(os.homedir(), p.slice(2)) + } + return p +} + +export function joinPath(...parts: string[]): string { + const sep = process.platform === 'win32' ? '\\' : '/' + return parts.join(sep) +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..91db0cb --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,180 @@ +import type { Platform } from './platform.js' + +export interface InstallChannel { + id: string + platforms: Platform[] + kind: 'command' | 'script' + /** kind=command 时的命令 */ + cmd?: string[] + /** kind=script 时的脚本地址(Windows 用 irm|iex,Unix 用 curl|bash) */ + scriptUrl?: string + desc: string +} + +export interface ToolDef { + id: string + name: string + vendor: string + desc: string + exe: string + versionArgs: string[] + channels: InstallChannel[] + /** 订阅切换支持:null 表示不支持第三方端点切换 */ + subscription: { + supported: boolean + level: 'full' | 'experimental' + note: string + } + /** 登录/授权检测提示 */ + authHint: string +} + +/** + * 工具注册表(数据驱动:新增工具 = 追加一条配置,不改代码) + * 数据来源:官方文档 + Agent-Tools/adapters 调研底稿(2026-08) + */ +export const TOOLS: ToolDef[] = [ + { + id: 'claude-code', + name: 'Claude Code', + vendor: 'Anthropic', + desc: 'Anthropic 官方终端编码代理', + exe: 'claude', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', '@anthropic-ai/claude-code'], desc: 'npm 全局安装(需 Node 18+)' }, + { id: 'winget', platforms: ['windows'], kind: 'command', cmd: ['winget', 'install', 'Anthropic.ClaudeCode'], desc: 'winget 安装' }, + { id: 'script', platforms: ['windows'], kind: 'script', scriptUrl: 'https://claude.ai/install.ps1', desc: '官方 PowerShell 脚本' }, + { id: 'script', platforms: ['macos', 'linux'], kind: 'script', scriptUrl: 'https://claude.ai/install.sh', desc: '官方 Shell 脚本' }, + ], + subscription: { + supported: true, + level: 'full', + note: '写入 ~/.claude/settings.json 的 env 块(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)', + }, + authHint: '运行 claude 按提示浏览器登录,或用本助手切换第三方端点', + }, + { + id: 'codex', + name: 'Codex CLI', + vendor: 'OpenAI', + desc: 'OpenAI 官方终端编码代理', + exe: 'codex', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', '@openai/codex'], desc: 'npm 全局安装' }, + { id: 'script', platforms: ['windows'], kind: 'script', scriptUrl: 'https://chatgpt.com/codex/install.ps1', desc: '官方 PowerShell 脚本' }, + { id: 'script', platforms: ['macos', 'linux'], kind: 'script', scriptUrl: 'https://chatgpt.com/codex/install.sh', desc: '官方 Shell 脚本' }, + ], + subscription: { + supported: true, + level: 'full', + note: '写入 ~/.codex/config.toml(openai_base_url)与 auth.json(OPENAI_API_KEY)', + }, + authHint: '运行 codex login 登录 ChatGPT 账号(需 Plus/Pro 等计划)', + }, + { + id: 'opencode', + name: 'OpenCode', + vendor: 'SST / 开源社区', + desc: '开源 AI 编码代理,多模型接入', + exe: 'opencode', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', 'opencode-ai'], desc: 'npm 全局安装' }, + { id: 'brew', platforms: ['macos'], kind: 'command', cmd: ['brew', 'install', 'opencode'], desc: 'Homebrew 安装' }, + { id: 'choco', platforms: ['windows'], kind: 'command', cmd: ['choco', 'install', 'opencode', '-y'], desc: 'Chocolatey 安装' }, + { id: 'scoop', platforms: ['windows'], kind: 'command', cmd: ['scoop', 'install', 'opencode'], desc: 'Scoop 安装' }, + { id: 'script', platforms: ['macos', 'linux'], kind: 'script', scriptUrl: 'https://opencode.ai/install', desc: '官方脚本' }, + ], + subscription: { + supported: true, + level: 'full', + note: '写入 ~/.config/opencode/opencode.json 的 provider.baseURL', + }, + authHint: '运行 opencode auth login 授权,或用本助手切换第三方端点', + }, + { + id: 'crush', + name: 'Crush', + vendor: 'Charmbracelet', + desc: '高颜值终端 AI 编码代理', + exe: 'crush', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', '@charmland/crush'], desc: 'npm 全局安装' }, + { id: 'winget', platforms: ['windows'], kind: 'command', cmd: ['winget', 'install', 'charmbracelet.crush'], desc: 'winget 安装' }, + { id: 'brew', platforms: ['macos'], kind: 'command', cmd: ['brew', 'install', 'charmbracelet/tap/crush'], desc: 'Homebrew 安装' }, + { id: 'apt', platforms: ['linux'], kind: 'command', cmd: ['sudo', 'apt-get', 'install', '-y', 'crush'], desc: 'apt 安装' }, + ], + subscription: { + supported: true, + level: 'experimental', + note: '实验性:写入 ~/.config/crush/crushrc(export 注入),端点级配置请参照官方文档核对', + }, + authHint: '通过 API Key 环境变量授权', + }, + { + id: 'kimi', + name: 'Kimi Code CLI', + vendor: 'Moonshot AI', + desc: '月之暗面 Kimi 终端编码助手', + exe: 'kimi', + versionArgs: ['--version'], + channels: [ + { id: 'script', platforms: ['windows'], kind: 'script', scriptUrl: 'https://code.kimi.com/kimi-code/install.ps1', desc: '官方 PowerShell 脚本' }, + { id: 'script', platforms: ['macos', 'linux'], kind: 'script', scriptUrl: 'https://code.kimi.com/kimi-code/install.sh', desc: '官方 Shell 脚本' }, + ], + subscription: { supported: false, level: 'full', note: '暂不支持第三方端点切换(官方账号体系)' }, + authHint: '运行 kimi 按提示登录 Kimi 账号', + }, + { + id: 'qwen', + name: 'Qwen Code', + vendor: '阿里通义', + desc: '通义千问终端编码助手(Gemini CLI 血统)', + exe: 'qwen', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', '@qwen-code/qwen-code'], desc: 'npm 全局安装' }, + ], + subscription: { supported: false, level: 'full', note: '暂不支持第三方端点切换(DASHSCOPE_API_KEY 官方体系)' }, + authHint: '设置 DASHSCOPE_API_KEY 环境变量后使用', + }, + { + id: 'codebuddy', + name: 'CodeBuddy Code', + vendor: '腾讯', + desc: '腾讯 CodeBuddy 终端编码代理', + exe: 'codebuddy', + versionArgs: ['--version'], + channels: [ + { id: 'npm', platforms: ['windows', 'macos', 'linux'], kind: 'command', cmd: ['npm', 'install', '-g', '@tencent-ai/codebuddy-code'], desc: 'npm 全局安装' }, + ], + subscription: { supported: false, level: 'full', note: '暂不支持第三方端点切换(官方账号体系)' }, + authHint: '运行 codebuddy 按提示登录腾讯账号', + }, + { + id: 'cursor', + name: 'Cursor CLI', + vendor: 'Cursor (Anysphere)', + desc: 'Cursor 编辑器的终端 Agent(命令名 agent)', + exe: 'agent', + versionArgs: ['--version'], + channels: [ + { id: 'script', platforms: ['windows'], kind: 'script', scriptUrl: 'https://cursor.com/install?win32=true', desc: '官方 PowerShell 脚本' }, + { id: 'script', platforms: ['macos', 'linux'], kind: 'script', scriptUrl: 'https://cursor.com/install', desc: '官方 Shell 脚本' }, + ], + subscription: { supported: false, level: 'full', note: '暂不支持第三方端点切换(Cursor 账号体系)' }, + authHint: '运行 agent login 登录 Cursor 账号', + }, +] + +export function getTool(id: string): ToolDef | undefined { + return TOOLS.find((t) => t.id === id) +} + +/** 按当前平台过滤出可用的安装渠道 */ +export function channelsFor(tool: ToolDef, platform: Platform): InstallChannel[] { + return tool.channels.filter((c) => c.platforms.includes(platform)) +} diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..2a9faaf --- /dev/null +++ b/src/run.ts @@ -0,0 +1,61 @@ +import { spawn, spawnSync } from 'node:child_process' + +export interface RunResult { + ok: boolean + code: number | null + stdout: string + stderr: string +} + +const IS_WIN = process.platform === 'win32' + +/** 同步执行命令并捕获输出(Windows 自动走 shell 以支持 npm.cmd 等) */ +export function run(cmd: string, args: string[] = [], timeoutMs = 30_000): RunResult { + const r = spawnSync(cmd, args, { + encoding: 'utf8', + timeout: timeoutMs, + shell: IS_WIN, + windowsHide: true, + }) + return { + ok: r.status === 0, + code: r.status, + stdout: (r.stdout ?? '').toString(), + stderr: (r.stderr ?? '').toString(), + } +} + +/** 异步执行命令并实时透传输出(用于安装等长任务),返回退出码 */ +export function runLive(cmd: string, args: string[] = []): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + stdio: 'inherit', + shell: IS_WIN, + windowsHide: true, + }) + child.on('error', (err) => { + console.error(`无法启动命令 ${cmd}: ${err.message}`) + resolve(null) + }) + child.on('close', (code) => resolve(code)) + }) +} + +/** 检测可执行文件是否在 PATH 中 */ +export function commandExists(exe: string): boolean { + if (IS_WIN) { + return run('where.exe', [exe], 10_000).stdout.trim().length > 0 + } + return run('/bin/sh', ['-c', `command -v ${JSON.stringify(exe)}`], 10_000).ok +} + +/** 平台差异化的脚本安装命令(PowerShell irm / curl|bash) */ +export function scriptInstallArgs(url: string): { cmd: string; args: string[] } { + if (IS_WIN) { + return { + cmd: 'powershell', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `irm ${url} | iex`], + } + } + return { cmd: 'bash', args: ['-c', `curl -fsSL ${url} | bash`] } +} diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..09c652e --- /dev/null +++ b/src/status.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs' +import { commandExists, run } from './run.js' +import { home } from './platform.js' +import { TOOLS, type ToolDef } from './registry.js' + +export interface ToolStatus { + tool: ToolDef + installed: boolean + version: string | null + /** 登录/授权状态(尽力而为的探测) */ + auth: 'official' | 'third-party' | 'unknown' | 'none' + authDetail: string +} + +/** 检测单个工具:安装、版本、授权状态 */ +export function checkTool(tool: ToolDef): ToolStatus { + const installed = commandExists(tool.exe) + let version: string | null = null + if (installed) { + const r = run(tool.exe, tool.versionArgs, 20_000) + const m = r.stdout.match(/(\d+\.\d+\.\d+(?:[-.][\w]+)?)/) + version = m ? m[1] : (r.stdout.trim().split('\n')[0] || null) + } + + const auth = detectAuth(tool.id) + return { tool, installed, version, auth: auth.state, authDetail: auth.detail } +} + +export function checkAll(): ToolStatus[] { + return TOOLS.map(checkTool) +} + +interface AuthState { + state: 'official' | 'third-party' | 'unknown' | 'none' + detail: string +} + +function exists(p: string): boolean { + try { + fs.accessSync(home(p)) + return true + } catch { + return false + } +} + +function readText(p: string): string | null { + try { + return fs.readFileSync(home(p), 'utf8') + } catch { + return null + } +} + +/** + * 授权状态探测(尽力而为,各家 CLI 升级可能改变文件格式): + * - official: 检测到官方登录凭证 + * - third-party: 检测到自定义端点配置 + * - none: 未检测到任何凭证 + * - unknown: 无法判断 + */ +function detectAuth(toolId: string): AuthState { + switch (toolId) { + case 'claude-code': { + const settings = readText('~/.claude/settings.json') + if (settings && /ANTHROPIC_BASE_URL/i.test(settings)) { + return { state: 'third-party', detail: 'settings.json 已配置第三方端点' } + } + if (exists('~/.claude/.credentials.json')) { + return { state: 'official', detail: '已登录官方账号(Pro/Max 等)' } + } + if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) { + return { state: 'official', detail: '环境变量已设置 API Key' } + } + return { state: 'none', detail: '未检测到登录凭证' } + } + case 'codex': { + const cfg = readText('~/.codex/config.toml') + if (cfg && /openai_base_url\s*=|model_providers\./.test(cfg)) { + return { state: 'third-party', detail: 'config.toml 已配置自定义端点' } + } + if (exists('~/.codex/auth.json')) { + return { state: 'official', detail: '已登录(ChatGPT 订阅或 API Key)' } + } + return { state: 'none', detail: '未检测到 auth.json' } + } + case 'opencode': { + const cfg = readText('~/.config/opencode/opencode.json') + if (cfg && /baseURL/i.test(cfg)) { + return { state: 'third-party', detail: 'opencode.json 已配置自定义 baseURL' } + } + if (exists('~/.local/share/opencode/auth.json')) { + return { state: 'official', detail: '已保存授权' } + } + return { state: 'none', detail: '未检测到授权文件' } + } + case 'crush': { + const cfg = readText('~/.config/crush/crushrc') + if (cfg && /apiKey|api_key|OPENAI_API_KEY/.test(cfg)) { + return { state: 'third-party', detail: 'crushrc 已配置 API Key' } + } + return { state: 'unknown', detail: '请运行 crush 查看授权状态' } + } + case 'kimi': + return exists('~/.kimi-code') ? { state: 'official', detail: '检测到配置目录' } : { state: 'none', detail: '未检测到配置' } + case 'qwen': + return process.env.DASHSCOPE_API_KEY + ? { state: 'official', detail: 'DASHSCOPE_API_KEY 已设置' } + : { state: 'none', detail: '未设置 DASHSCOPE_API_KEY' } + case 'codebuddy': + return exists('~/.codebuddy') ? { state: 'official', detail: '检测到配置目录' } : { state: 'none', detail: '未检测到配置' } + case 'cursor': + return exists('~/.cursor/cli-config.json') ? { state: 'official', detail: '检测到 CLI 配置' } : { state: 'none', detail: '未检测到配置' } + default: + return { state: 'unknown', detail: '' } + } +} diff --git a/src/subscribe.ts b/src/subscribe.ts new file mode 100644 index 0000000..18dd74a --- /dev/null +++ b/src/subscribe.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { home } from './platform.js' + +/** 第三方订阅预设 */ +export interface Preset { + id: string + name: string + baseUrl: string + apiKey: string + note: string + createdAt: string +} + +const PRESETS_DIR = path.join(os.homedir(), '.cch') +const PRESETS_FILE = path.join(PRESETS_DIR, 'presets.json') +const BAK_SUFFIX = '.cch.bak' + +export function loadPresets(): Preset[] { + try { + return JSON.parse(fs.readFileSync(PRESETS_FILE, 'utf8')) + } catch { + return [] + } +} + +export function savePresets(presets: Preset[]): void { + fs.mkdirSync(PRESETS_DIR, { recursive: true }) + fs.writeFileSync(PRESETS_FILE, JSON.stringify(presets, null, 2), 'utf8') +} + +export function upsertPreset(p: Omit & { id?: string }): Preset { + const presets = loadPresets() + const id = p.id ?? `p_${Date.now().toString(36)}` + const preset: Preset = { ...p, id, createdAt: new Date().toISOString() } + const idx = presets.findIndex((x) => x.id === id) + if (idx >= 0) { + presets[idx] = { ...presets[idx], ...preset } + } else { + presets.push(preset) + } + savePresets(presets) + return preset +} + +export function deletePreset(id: string): void { + savePresets(loadPresets().filter((p) => p.id !== id)) +} + +// ---------- 配置文件安全读写(备份 + 原子写) ---------- + +function backup(file: string): void { + const f = home(file) + if (fs.existsSync(f)) { + fs.copyFileSync(f, f + BAK_SUFFIX) + } +} + +function restore(file: string): boolean { + const f = home(file) + const bak = f + BAK_SUFFIX + if (fs.existsSync(bak)) { + fs.copyFileSync(bak, f) + return true + } + return false +} + +function atomicWrite(file: string, content: string): void { + const f = home(file) + fs.mkdirSync(path.dirname(f), { recursive: true }) + const tmp = f + '.cch.tmp' + fs.writeFileSync(tmp, content, 'utf8') + fs.renameSync(tmp, f) +} + +function readJson(file: string): Record { + try { + return JSON.parse(fs.readFileSync(home(file), 'utf8')) + } catch { + return {} + } +} + +// ---------- 各工具的切换实现 ---------- + +export interface SwitchResult { + toolId: string + ok: boolean + message: string +} + +/** 将预设应用到工具(写配置前自动备份原文件为 *.cch.bak) */ +export function applyPreset(toolId: string, preset: Preset): SwitchResult { + switch (toolId) { + case 'claude-code': + return applyClaude(preset) + case 'codex': + return applyCodex(preset) + case 'opencode': + return applyOpencode(preset) + case 'crush': + return applyCrush(preset) + default: + return { toolId, ok: false, message: '该工具暂不支持第三方订阅切换' } + } +} + +/** 恢复官方配置(还原备份) */ +export function restoreOfficial(toolId: string): SwitchResult { + const files: Record = { + 'claude-code': ['~/.claude/settings.json'], + codex: ['~/.codex/config.toml', '~/.codex/auth.json'], + opencode: ['~/.config/opencode/opencode.json'], + crush: ['~/.config/crush/crushrc'], + } + const list = files[toolId] + if (!list) return { toolId, ok: false, message: '该工具暂不支持恢复操作' } + const restored = list.filter((f) => restore(f)) + return restored.length > 0 + ? { toolId, ok: true, message: `已还原备份:${restored.join('、')}` } + : { toolId, ok: false, message: '未找到切换前的备份(*.cch.bak)' } +} + +function applyClaude(p: Preset): SwitchResult { + const file = '~/.claude/settings.json' + backup(file) + const obj = readJson(file) as Record + obj.env = obj.env ?? {} + obj.env.ANTHROPIC_BASE_URL = p.baseUrl + if (p.apiKey) obj.env.ANTHROPIC_AUTH_TOKEN = p.apiKey + atomicWrite(file, JSON.stringify(obj, null, 2)) + return { toolId: 'claude-code', ok: true, message: `已写入 ${file}(env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)` } +} + +function applyCodex(p: Preset): SwitchResult { + const cfgFile = '~/.codex/config.toml' + backup(cfgFile) + const raw = (() => { + try { + return fs.readFileSync(home(cfgFile), 'utf8') + } catch { + return '' + } + })() + const lines = raw + .split('\n') + .filter((l) => !/^\s*openai_base_url\s*=/.test(l) && !/^\s*# --- cch /.test(l)) + lines.push(`# --- cch 第三方订阅 ${p.name} ---`) + lines.push(`openai_base_url = "${p.baseUrl}"`) + atomicWrite(cfgFile, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n') + + if (p.apiKey) { + const authFile = '~/.codex/auth.json' + backup(authFile) + atomicWrite(authFile, JSON.stringify({ OPENAI_API_KEY: p.apiKey }, null, 2)) + } + return { + toolId: 'codex', + ok: true, + message: `已写入 ${cfgFile}(openai_base_url)${p.apiKey ? ' 及 auth.json(API Key 模式,将覆盖 ChatGPT 订阅登录,恢复官方可用「恢复官方配置」)' : ''}`, + } +} + +function applyOpencode(p: Preset): SwitchResult { + const file = '~/.config/opencode/opencode.json' + backup(file) + const obj = readJson(file) as Record + obj.provider = obj.provider ?? {} + // 同时配置 anthropic / openai 两个 provider 槽位,OpenCode 按模型自动选用 + obj.provider.anthropic = { ...(obj.provider.anthropic ?? {}), baseURL: p.baseUrl, apiKey: p.apiKey } + obj.provider.openai = { ...(obj.provider.openai ?? {}), baseURL: p.baseUrl, apiKey: p.apiKey } + atomicWrite(file, JSON.stringify(obj, null, 2)) + return { + toolId: 'opencode', + ok: true, + message: `已写入 ${file}(provider.anthropic/openai.baseURL,注意 API Key 为明文存储)`, + } +} + +function applyCrush(p: Preset): SwitchResult { + const file = '~/.config/crush/crushrc' + backup(file) + let raw = (() => { + try { + return fs.readFileSync(home(file), 'utf8') + } catch { + return '' + } + })() + raw = raw + .split('\n') + .filter((l) => !/^\s*# --- cch /.test(l) && !/^\s*export\s+(OPENAI_API_KEY|ANTHROPIC_API_KEY)=/.test(l)) + .join('\n') + const block = [ + `# --- cch 第三方订阅 ${p.name}(实验性) ---`, + `# 端点:${p.baseUrl}`, + `export OPENAI_API_KEY=${p.apiKey}`, + `export OPENAI_BASE_URL=${p.baseUrl}`, + '# 端点级配置如需精细控制,请参照 https://docs.crush.chat 核对 provider 段', + ].join('\n') + atomicWrite(file, (raw.trimEnd() ? raw.trimEnd() + '\n' : '') + block + '\n') + return { + toolId: 'crush', + ok: true, + message: `已写入 ${file}(export 注入,实验性:请运行 crush 验证生效)`, + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe5b2d7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": false + }, + "include": ["src"] +}