From 7dc7675de81b82d57eb158194fe23e3abb1c5292 Mon Sep 17 00:00:00 2001 From: Yuzhang Zhong <154724006+yuzhang-zhong@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:03:07 +0800 Subject: [PATCH] fix: make doctor diagnostics actionable --- CHANGELOG.md | 1 + docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/doctor.test.ts | 56 ++++++++++++- src/__tests__/e2e/doctor-cli.test.ts | 114 +++++++++++++++++++++++++++ src/doctor.ts | 65 ++++++++------- src/index.ts | 3 +- 7 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/e2e/doctor-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e05d71f..1979e502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. See [standa - `teamai import --cache-status` and `--cache-gc` now expose their existing JSON output through the CLI `--json` option. - Course-correction matching normalizes prompts and keywords to Unicode NFC, so composed and decomposed accents match. Stored prompt summaries and the 60-second correction window are unchanged. Fixes [#573](https://github.com/Tencent/teamai-cli/issues/573). - Course-correction detection matches keywords in space-separated scripts as whole words, so Spanish "segundo" no longer counts as `undo` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). +- `teamai doctor` no longer assumes TGit before initialization and now exits with code 1 when any diagnostic check fails. - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). - `enabledAgents` now also gates CLI builtin deploy, CLAUDE.md-class injects, and last-pull skip-sync targets, so an already-installed tool outside the whitelist is not written to ([#510](https://github.com/Tencent/teamai-cli/issues/510)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 55c20a60..97b17814 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -480,7 +480,7 @@ The existing SessionStart hook runs `teamai pull`. When the `packages` declarati ```bash teamai packages # Install every team declaration teamai packages --dry-run # Preview native commands without installing or writing files -teamai doctor # Check runtimes and declared package/marketplace/plugin status +teamai doctor # Check runtimes and declared package/marketplace/plugin status; exits 1 when any check fails ``` After a successful install, TeamAI writes a local snapshot to `teamai.lock` under the active scope's `.teamai` directory. The lock records installed versions and the declaration hash used by the SessionStart hint; it is not stored in the team repository. In user scope, machine-wide npm tools and Claude plugins are acknowledged once, while project npm dependencies are acknowledged separately for each working directory so installing in one repository cannot silence another repository's hint. @@ -1460,6 +1460,8 @@ teamai remove agents teamai remove mcp ``` +`teamai doctor` exits with code 0 only when every check passes, and code 1 when any check fails. Before initialization, it reports the missing configuration without assuming a Git provider. + Auto-update runs in the Stop hook and is controlled by two tiers: | Tier | File | Field | Value | diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 867c502f..2d8258cf 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -462,7 +462,7 @@ Claude 插件 target 使用 `plugin@marketplace` 格式。`claude-plugins-offici ```bash teamai packages # 安装团队声明的全部包和插件 teamai packages --dry-run # 预览底层命令,不安装也不写文件 -teamai doctor # 检查运行环境及声明的包、marketplace、插件状态 +teamai doctor # 检查运行环境及声明的包、marketplace、插件状态;任一检查失败时退出码为 1 ``` 安装成功后,TeamAI 会在当前 scope 的 `.teamai` 目录下写入本地快照 `teamai.lock`。该文件记录已安装版本,以及供 SessionStart 提示比对的声明哈希,不会写入团队仓库。在 user scope 下,全局 npm 工具和 Claude 插件只需确认一次;项目 npm 依赖会按工作目录分别确认,避免在一个仓库安装后错误关闭另一个仓库的提示。 @@ -1424,6 +1424,8 @@ teamai remove agents teamai remove mcp ``` +仅当所有检查通过时,`teamai doctor` 才以状态码 0 退出;任一检查失败时以状态码 1 退出。尚未初始化时,它只报告缺少配置,不会臆测 Git 托管平台。 + 自动更新在 Stop hook 中执行,可通过两层控制: | 层级 | 文件 | 字段 | 值 | diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 883ab5d0..8be540b3 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import path from 'node:path'; // ── Mocks ──────────────────────────────────────────────── @@ -35,6 +36,7 @@ import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { pathExists, readFileSafe } from '../utils/fs.js'; import { TEAMAI_HOOK_SUBCOMMANDS } from '../hooks.js'; import { log } from '../utils/logger.js'; +import { isGfInstalled, gfIsAuthenticated } from '../providers/tgit/index.js'; import { doctor } from '../doctor.js'; const mockedLoadLocalConfig = loadLocalConfig as Mock; @@ -42,6 +44,8 @@ const mockedLoadTeamConfig = loadTeamConfig as Mock; const mockedPathExists = pathExists as Mock; const mockedReadFileSafe = readFileSafe as Mock; const mockedLog = log as unknown as { info: Mock; success: Mock; warn: Mock; error: Mock; debug: Mock }; +const mockedIsGfInstalled = isGfInstalled as Mock; +const mockedGfIsAuthenticated = gfIsAuthenticated as Mock; const mockLocalConfig = { repo: { localPath: '/tmp/repo', remote: 'https://git.woa.com/team/repo.git' }, @@ -92,12 +96,17 @@ beforeEach(() => { describe('doctor — hook checks', () => { it('should pass when all subcommands are present in settings', async () => { - await doctor({}); + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + sharing: { env: { injectShellProfile: false } }, + }); + const allPassed = await doctor({}); // Should show the hooks check passing (✔) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('✔'), ); + expect(allPassed).toBe(true); }); it('should fail when a subcommand is missing from settings', async () => { @@ -113,7 +122,7 @@ describe('doctor — hook checks', () => { return null; }); - await doctor({}); + const allPassed = await doctor({}); // Should show the hooks check failing (✖) with fix suggestion expect(consoleSpy).toHaveBeenCalledWith( @@ -122,6 +131,7 @@ describe('doctor — hook checks', () => { expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('teamai hooks inject'), ); + expect(allPassed).toBe(false); }); it('should fail when settings file does not exist', async () => { @@ -180,7 +190,7 @@ describe('doctor — hook checks', () => { // Only carries the hooks (committed to the business repo). mockedReadFileSafe.mockImplementation(async (filePath: string) => { if (filePath.includes('settings.json')) { - return filePath.includes(projectRoot) ? buildFullHooksContent() : '{ "hooks": {} }'; + return filePath.includes(path.normalize(projectRoot)) ? buildFullHooksContent() : '{ "hooks": {} }'; } return null; }); @@ -192,7 +202,7 @@ describe('doctor — hook checks', () => { it('should pass env check when env/env.yaml does not exist in team repo', async () => { mockedPathExists.mockImplementation(async (filePath: string) => { - if (filePath.includes('env/env.yaml')) return false; + if (filePath.endsWith(path.join('env', 'env.yaml'))) return false; return true; }); mockedReadFileSafe.mockImplementation(async (filePath: string) => { @@ -282,4 +292,42 @@ describe('doctor — hook checks', () => { // Should still show claude check expect(allCalls.some((msg: string) => msg.includes('claude'))).toBe(true); }); + + it('does not assume a provider before initialization', async () => { + mockedLoadLocalConfig.mockResolvedValue(null); + mockedLoadTeamConfig.mockResolvedValue(null); + + const allPassed = await doctor({}); + + const allLines = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(allLines).toContain(' Scope: not initialized\n'); + expect(allLines).toContain(' ✖ TeamAI is not initialized'); + expect(allLines.some((line) => line.includes('gf CLI'))).toBe(false); + expect(allLines.some((line) => line.includes('hooks in'))).toBe(false); + expect(mockedIsGfInstalled).not.toHaveBeenCalled(); + expect(mockedGfIsAuthenticated).not.toHaveBeenCalled(); + expect(allPassed).toBe(false); + }); + + it('checks hooks only for enabled agents', async () => { + mockedLoadLocalConfig.mockResolvedValue({ + ...mockLocalConfig, + enabledAgents: ['claude'], + }); + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + sharing: { env: { injectShellProfile: false } }, + toolPaths: { + claude: { settings: '.claude/settings.json', skills: '.claude/skills' }, + codex: { settings: '.codex/hooks.json', skills: '.codex/skills' }, + }, + }); + + const allPassed = await doctor({}); + + const allLines = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(allLines.some((line) => line.includes('hooks in claude settings'))).toBe(true); + expect(allLines.some((line) => line.includes('hooks in codex settings'))).toBe(false); + expect(allPassed).toBe(true); + }); }); diff --git a/src/__tests__/e2e/doctor-cli.test.ts b/src/__tests__/e2e/doctor-cli.test.ts new file mode 100644 index 00000000..b66df65f --- /dev/null +++ b/src/__tests__/e2e/doctor-cli.test.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +describe('teamai doctor CLI (e2e)', () => { + let sandbox: string; + let uninitializedHome: string; + let initializedHome: string; + let missingHookHome: string; + + function runDoctor(home: string) { + return spawnSync(process.execPath, [CLI, 'doctor'], { + cwd: home, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + FORCE_COLOR: '0', + }, + encoding: 'utf8', + }); + } + + function writeLocalConfig(home: string, repoLocal: string) { + fs.mkdirSync(path.join(home, '.teamai'), { recursive: true }); + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${JSON.stringify(repoLocal)}`, + ' remote: https://example.invalid/team/repo.git', + ' kind: git', + 'username: e2e-user', + 'updatePolicy: skip', + 'scope: user', + 'enabledAgents:', + ' - claude', + ].join('\n')); + } + + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error('Run npm run build before the E2E test.'); + + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-doctor-e2e-')); + uninitializedHome = path.join(sandbox, 'uninitialized-home'); + initializedHome = path.join(sandbox, 'initialized-home'); + missingHookHome = path.join(sandbox, 'missing-hook-home'); + const repoLocal = path.join(sandbox, 'team-repo'); + + fs.mkdirSync(uninitializedHome, { recursive: true }); + fs.mkdirSync(repoLocal, { recursive: true }); + writeLocalConfig(initializedHome, repoLocal); + writeLocalConfig(missingHookHome, repoLocal); + fs.writeFileSync(path.join(repoLocal, 'teamai.yaml'), [ + 'team: doctor-e2e', + 'repo: team/repo', + 'provider: git', + 'sharing:', + ' env:', + ' injectShellProfile: false', + 'toolPaths:', + ' claude:', + ' settings: .claude/settings.json', + ' skills: .claude/skills', + ].join('\n')); + fs.writeFileSync( + path.join(initializedHome, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'teamai hook-dispatch' }] }], + }, + }), + ); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('exits 1 without initialization and does not assume TGit', () => { + const result = runDoctor(uninitializedHome); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(1); + expect(output).toContain('TeamAI is not initialized'); + expect(output).not.toContain('gf CLI'); + }); + + it('exits 0 when every diagnostic passes', () => { + const result = runDoctor(initializedHome); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); + expect(output).toContain('Team repo exists locally'); + expect(output).toContain('Team config (teamai.yaml) is valid'); + expect(output).toContain('teamai hooks in claude settings'); + expect(output).toContain('Env variables injected in shell profile'); + expect(output).toContain('All checks passed!'); + }); + + it('exits 1 when an enabled agent is missing teamai hooks', () => { + const result = runDoctor(missingHookHome); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(1); + expect(output).toContain('✖ teamai hooks in claude settings'); + expect(output).toContain('Some checks failed. See suggestions above.'); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts index 36724380..d3e07ca6 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -2,12 +2,13 @@ import path from 'node:path'; import { detectProjectConfig, loadLocalConfig, loadTeamConfig } from './config.js'; import { pathExists, readFileSafe } from './utils/fs.js'; import { log } from './utils/logger.js'; -import type { GlobalOptions, Scope } from './types.js'; +import type { GlobalOptions } from './types.js'; import { - TeamaiConfigSchema, TEAMAI_ENV_START, resolveHookScope, getDataHome, + isAgentExcluded, + scopedToolPaths, type TeamaiConfig, } from './types.js'; import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder } from './hooks.js'; @@ -66,30 +67,37 @@ async function hasInstalledCodexHooks(toolPaths: TeamaiConfig['toolPaths'], base return false; } -export async function doctor(options: GlobalOptions): Promise { +export async function doctor(options: GlobalOptions): Promise { log.info('Running diagnostics...\n'); const projectConfig = await detectProjectConfig(); const localConfig = projectConfig ?? (await loadLocalConfig()); - const scope: Scope = localConfig?.scope ?? 'user'; - const configPathLabel = projectConfig - ? `${projectConfig.projectRoot}/.teamai/config.yaml` - : '~/.teamai/config.yaml'; + if (!localConfig) { + console.log(' Scope: not initialized\n'); + console.log(' ✖ TeamAI is not initialized'); + console.log(' → Run `teamai init ` in a project, or add `--scope user` for all projects'); + console.log(''); + log.warn('Initialization is required before diagnostics can run.'); + return false; + } - console.log(` Scope: ${scope}${scope === 'project' && localConfig?.projectRoot ? ` (${localConfig.projectRoot})` : ''}\n`); + const scope = localConfig.scope ?? 'user'; + const scopeLabel = `${scope}${scope === 'project' && localConfig.projectRoot ? ` (${localConfig.projectRoot})` : ''}`; + console.log(` Scope: ${scopeLabel}\n`); // Try to load team config for dynamic tool paths and provider - let teamConfig: TeamaiConfig | null = null; - if (localConfig) { - teamConfig = await loadTeamConfig(localConfig.repo.localPath); - } - // Fall back to schema defaults if team config is unavailable - const toolPaths = teamConfig?.toolPaths ?? TeamaiConfigSchema.shape.toolPaths.parse(undefined); - const providerName = teamConfig?.provider ?? 'tgit'; + const teamConfig = await loadTeamConfig(localConfig.repo.localPath); + const toolPaths: TeamaiConfig['toolPaths'] = teamConfig + ? Object.fromEntries( + Object.entries(scopedToolPaths(teamConfig, localConfig)) + .filter(([tool]) => !isAgentExcluded(localConfig, tool)), + ) + : {}; + const providerName = teamConfig?.provider; // Hook checks must look where hooks are actually injected. resolveHookScope // maps a non-self project scope to HOME (#264), matching the injection path in // init/pull/hooks-cmd — otherwise doctor checks /.claude while the // hooks live in ~/.claude and always reports them missing. - const baseDir = localConfig ? resolveHookScope(localConfig).baseDir : getUserHome(); + const baseDir = resolveHookScope(localConfig).baseDir; const checks: Check[] = []; @@ -145,23 +153,14 @@ export async function doctor(options: GlobalOptions): Promise { } checks.push( - { - name: `Local config exists (${configPathLabel})`, - check: async () => localConfig !== null, - fix: 'Run `teamai init` to initialize', - }, { name: 'Team repo exists locally', - check: async () => { - if (!localConfig) return false; - return pathExists(localConfig.repo.localPath); - }, + check: async () => pathExists(localConfig.repo.localPath), fix: 'Run `teamai init` to clone the team repo', }, { name: 'Team config (teamai.yaml) is valid', check: async () => { - if (!localConfig) return false; const config = await loadTeamConfig(localConfig.repo.localPath); return config !== null; }, @@ -173,7 +172,6 @@ export async function doctor(options: GlobalOptions): Promise { check: async () => { if (teamConfig?.sharing?.env?.injectShellProfile === false) return true; - if (!localConfig) return true; const envYamlPath = path.join(localConfig.repo.localPath, 'env', 'env.yaml'); if (!await pathExists(envYamlPath)) return true; @@ -212,13 +210,11 @@ export async function doctor(options: GlobalOptions): Promise { } } - if (localConfig) { - const { pkgDoctorReport } = await import('./pkg/commands.js'); - const packageReport = await pkgDoctorReport(localConfig, process.cwd()); - if (packageReport) { - for (const line of packageReport.lines) console.log(line); - if (!packageReport.allPassed) allPassed = false; - } + const { pkgDoctorReport } = await import('./pkg/commands.js'); + const packageReport = await pkgDoctorReport(localConfig, process.cwd()); + if (packageReport) { + for (const line of packageReport.lines) console.log(line); + if (!packageReport.allPassed) allPassed = false; } // Codex trust-gate reminder: even when hooks are installed, Codex may not run @@ -235,4 +231,5 @@ export async function doctor(options: GlobalOptions): Promise { } else { log.warn('Some checks failed. See suggestions above.'); } + return allPassed; } diff --git a/src/index.ts b/src/index.ts index 7fa70798..8ea8e744 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,7 +218,8 @@ program .action(async () => { const globalOpts = program.opts() as GlobalOptions; const { doctor } = await import('./doctor.js'); - await doctor(globalOpts); + const allPassed = await doctor(globalOpts); + if (!allPassed) process.exitCode = 1; }); // ─── Roles subcommand ─────────────────────────────────────