diff --git a/README.ja.md b/README.ja.md index 2f700993..e2531c9a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai remove ` | リソースを削除し、MR を開く | | `teamai session save` | プライバシー除去済みのセッション要約を月次ログへ記録(`--push` は `digest` へ供給) | | `teamai digest` | チーム利用の週次 digest を生成 | -| `teamai doctor` | 設定の問題を診断 | +| `teamai doctor` | 設定の問題を診断(`--json` で JSON 出力、CI・hook・agent 向け)| | `teamai uninstall` | すべての teamai リソースと hooks を削除 | ## ライセンス diff --git a/README.ko.md b/README.ko.md index 0d356721..0af43c00 100644 --- a/README.ko.md +++ b/README.ko.md @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai remove ` | 리소스를 제거하고 MR을 엽니다 | | `teamai session save` | 개인정보를 제거한 세션 요약을 월별 로그에 기록 (`--push`는 `digest`에 공급) | | `teamai digest` | 주간 팀 사용 digest 생성 | -| `teamai doctor` | 구성 문제 진단 | +| `teamai doctor` | 구성 문제 진단 (`--json`으로 JSON 출력, CI·hook·agent용) | | `teamai uninstall` | 모든 teamai 리소스와 hooks 제거 | ## 라이선스 diff --git a/README.md b/README.md index 242c1569..16051d2a 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai remove ` | Remove a resource and open MR | | `teamai session save` | Record a privacy-scrubbed session summary to a monthly log (`--push` feeds `digest`) | | `teamai digest` | Generate weekly team usage digest | -| `teamai doctor` | Diagnose configuration issues | +| `teamai doctor` | Diagnose configuration issues (`--json` for CI, hooks and agents) | | `teamai uninstall` | Remove all teamai resources and hooks | ## License diff --git a/README.th.md b/README.th.md index 456581f2..74a7b210 100644 --- a/README.th.md +++ b/README.th.md @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill | `teamai remove ` | ลบทรัพยากรแล้วเปิด MR | | `teamai session save` | บันทึกสรุปเซสชันที่ล้างข้อมูลส่วนตัวแล้วลงล็อกรายเดือน (`--push` จะป้อน `digest`) | | `teamai digest` | สร้างสรุปการใช้งานทีมรายสัปดาห์ | -| `teamai doctor` | วินิจฉัยปัญหาคอนฟิก | +| `teamai doctor` | วินิจฉัยปัญหาคอนฟิก (`--json` แสดงผลเป็น JSON สำหรับ CI, hook และ agent) | | `teamai uninstall` | ลบทรัพยากรและ hooks ของ teamai ทั้งหมด | ## ใบอนุญาต diff --git a/README.zh-CN.md b/README.zh-CN.md index 8657dd28..5892a9bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai remove ` | 删除资源并创建 MR | | `teamai session save` | 将脱敏后的 session 摘要记录到月度日志(`--push` 可喂给 `digest`) | | `teamai digest` | 生成团队周报 | -| `teamai doctor` | 诊断配置问题 | +| `teamai doctor` | 诊断配置问题(`--json` 输出 JSON,供 CI、hook 与 agent 消费)| | `teamai uninstall` | 移除所有 teamai 资源和 hooks | ## 许可证 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b7e522f5..63eb6d2f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1450,6 +1450,7 @@ Upgrading from an earlier version: `.cursor/rules/*.md` copies written by the ol ```bash teamai doctor # Config diagnostics +teamai doctor --json # Same diagnostics as JSON on stdout (CI, hooks, agents) teamai stats # Skill usage stats teamai update --check # Check for a CLI update without installing it teamai update # Check for and install a CLI update @@ -1462,6 +1463,25 @@ 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. +`--json` prints the same report as one object on stdout and routes every log line to stderr, so `teamai doctor --json 2>/dev/null` parses whole. The exit code is unchanged. Each check carries the fix suggestion it prints in human mode: + +```json +{ + "ok": false, + "scope": "user", + "checks": [ + { "name": "Team repo exists locally", "ok": true }, + { + "name": "teamai hooks in claude settings", + "ok": false, + "fix": "Run `teamai hooks inject` to inject/update hooks" + } + ] +} +``` + +`scope` is `null` before initialization. `packages` is present only when the team repo declares packages, and carries the rendered report lines. `notes` appears only when there is an advisory — today, the Codex trust-gate reminder. + 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 819df5d4..4f0e1eb7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1414,6 +1414,7 @@ Cursor 的项目规则必须以 **`.mdc`** 文件形式放在 `.cursor/rules/` ```bash teamai doctor # 配置诊断 +teamai doctor --json # 同样的诊断结果,以 JSON 输出到 stdout(CI、hook、agent 可直接消费) teamai stats # skill 使用统计 teamai update --check # 仅检查 CLI 更新,不安装 teamai update # 检查并安装 CLI 更新 @@ -1426,6 +1427,25 @@ teamai remove mcp 仅当所有检查通过时,`teamai doctor` 才以状态码 0 退出;任一检查失败时以状态码 1 退出。尚未初始化时,它只报告缺少配置,不会臆测 Git 托管平台。 +`--json` 把同一份报告作为单个对象打印到 stdout,并将所有日志改走 stderr,因此 `teamai doctor --json 2>/dev/null` 可以整体解析;退出码不变。每个检查都会带上人类模式下显示的修复建议: + +```json +{ + "ok": false, + "scope": "user", + "checks": [ + { "name": "Team repo exists locally", "ok": true }, + { + "name": "teamai hooks in claude settings", + "ok": false, + "fix": "Run `teamai hooks inject` to inject/update hooks" + } + ] +} +``` + +尚未初始化时 `scope` 为 `null`。仅当团队仓库声明了 packages 时才会出现 `packages` 字段,内容是已渲染的报告行;`notes` 只在有额外提示时出现 —— 目前是 Codex 信任门槛提醒。 + 自动更新在 Stop hook 中执行,可通过两层控制: | 层级 | 文件 | 字段 | 值 | diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 8be540b3..9fc91c65 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -22,6 +22,7 @@ vi.mock('../utils/logger.js', () => ({ error: vi.fn(), debug: vi.fn(), }, + setStderrOnly: vi.fn(), })); // Mock the tgit provider to avoid side effects @@ -35,9 +36,10 @@ vi.mock('../providers/tgit/index.js', () => ({ 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 { log, setStderrOnly } from '../utils/logger.js'; import { isGfInstalled, gfIsAuthenticated } from '../providers/tgit/index.js'; -import { doctor } from '../doctor.js'; +import { buildChecks, doctor, resolveDoctorContext } from '../doctor.js'; +import type { DoctorReport } from '../doctor.js'; const mockedLoadLocalConfig = loadLocalConfig as Mock; const mockedLoadTeamConfig = loadTeamConfig as Mock; @@ -331,3 +333,105 @@ describe('doctor — hook checks', () => { expect(allPassed).toBe(true); }); }); + +describe('doctor — JSON report', () => { + /** + * Parses the report and, by insisting on a single console.log, proves that + * stdout carried nothing but JSON. + */ + function emittedReport(): DoctorReport { + expect(consoleSpy.mock.calls).toHaveLength(1); + return JSON.parse(String(consoleSpy.mock.calls[0][0])) as DoctorReport; + } + + it('emits a single JSON object carrying every check', async () => { + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + sharing: { env: { injectShellProfile: false } }, + }); + + const allPassed = await doctor({ json: true }); + + // stdout must stay a pure data channel: one console.log, logs on stderr. + expect(setStderrOnly).toHaveBeenCalledWith(true); + + const report = emittedReport(); + expect(allPassed).toBe(true); + expect(report.ok).toBe(true); + expect(report.scope).toBe('user'); + const names = report.checks.map((c) => c.name); + expect(names).toContain('Team repo exists locally'); + expect(names).toContain('teamai hooks in claude settings'); + expect(report.checks.every((c) => c.ok)).toBe(true); + }); + + it('carries the fix string of a failing check', async () => { + mockedReadFileSafe.mockImplementation(async (filePath: string) => { + if (filePath.includes('settings.json')) { + return '{ "hooks": { "command": "bash -lc \\"teamai pull\\"" } }'; + } + if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { + return '# [teamai:env:start]'; + } + return null; + }); + + const allPassed = await doctor({ json: true }); + + const report = emittedReport(); + expect(allPassed).toBe(false); + expect(report.ok).toBe(false); + const failing = report.checks.find((c) => c.name === 'teamai hooks in claude settings'); + expect(failing?.ok).toBe(false); + expect(failing?.fix).toContain('teamai hooks inject'); + }); + + it('emits the same envelope before initialization', async () => { + mockedLoadLocalConfig.mockResolvedValue(null); + mockedLoadTeamConfig.mockResolvedValue(null); + + const allPassed = await doctor({ json: true }); + + const report = emittedReport(); + expect(allPassed).toBe(false); + expect(report.ok).toBe(false); + expect(report.scope).toBeNull(); + expect(report.checks).toHaveLength(1); + expect(report.checks[0]).toMatchObject({ name: 'TeamAI is not initialized', ok: false }); + expect(report.checks[0].fix).toContain('teamai init'); + }); +}); + +describe('buildChecks', () => { + it('runs outside doctor and yields one hook check per enabled agent', async () => { + mockedLoadLocalConfig.mockResolvedValue({ + ...mockLocalConfig, + enabledAgents: ['claude'], + }); + mockedLoadTeamConfig.mockResolvedValue({ + ...mockTeamConfig, + toolPaths: { + claude: { settings: '.claude/settings.json', skills: '.claude/skills' }, + codex: { settings: '.codex/hooks.json', skills: '.codex/skills' }, + }, + }); + + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + + const checks = await buildChecks(ctx); + const names = checks.map((c) => c.name); + + expect(names).toContain('teamai hooks in claude settings'); + expect(names).not.toContain('teamai hooks in codex settings'); + // Building the registry renders nothing — that is what makes it reusable. + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('returns a null context before initialization', async () => { + mockedLoadLocalConfig.mockResolvedValue(null); + mockedLoadTeamConfig.mockResolvedValue(null); + + expect(await resolveDoctorContext()).toBeNull(); + }); +}); diff --git a/src/__tests__/e2e/doctor-cli.test.ts b/src/__tests__/e2e/doctor-cli.test.ts index b66df65f..9b388f69 100644 --- a/src/__tests__/e2e/doctor-cli.test.ts +++ b/src/__tests__/e2e/doctor-cli.test.ts @@ -14,8 +14,8 @@ describe('teamai doctor CLI (e2e)', () => { let initializedHome: string; let missingHookHome: string; - function runDoctor(home: string) { - return spawnSync(process.execPath, [CLI, 'doctor'], { + function runDoctor(home: string, ...args: string[]) { + return spawnSync(process.execPath, [CLI, 'doctor', ...args], { cwd: home, env: { ...process.env, @@ -111,4 +111,39 @@ describe('teamai doctor CLI (e2e)', () => { expect(output).toContain('✖ teamai hooks in claude settings'); expect(output).toContain('Some checks failed. See suggestions above.'); }); + + it('--json puts the report on stdout and nothing else', () => { + const result = runDoctor(missingHookHome, '--json'); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + + // stdout must parse whole: any human line leaking there breaks a consumer. + const report = JSON.parse(result.stdout) as { + ok: boolean; + scope: string; + checks: Array<{ name: string; ok: boolean; fix?: string }>; + }; + expect(report.ok).toBe(false); + expect(report.scope).toBe('user'); + + const hookCheck = report.checks.find((c) => c.name === 'teamai hooks in claude settings'); + expect(hookCheck?.ok).toBe(false); + expect(hookCheck?.fix).toContain('teamai hooks inject'); + expect(report.checks.some((c) => c.name === 'Team repo exists locally' && c.ok)).toBe(true); + }); + + it('--json keeps its envelope before initialization', () => { + const result = runDoctor(uninitializedHome, '--json'); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + + const report = JSON.parse(result.stdout) as { + ok: boolean; + scope: string | null; + checks: Array<{ name: string; ok: boolean }>; + }; + expect(report.ok).toBe(false); + expect(report.scope).toBeNull(); + expect(report.checks[0]?.name).toBe('TeamAI is not initialized'); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index d3e07ca6..4d6cb61b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -1,8 +1,8 @@ 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 } from './types.js'; +import { log, setStderrOnly } from './utils/logger.js'; +import type { GlobalOptions, LocalConfig } from './types.js'; import { TEAMAI_ENV_START, resolveHookScope, @@ -14,12 +14,50 @@ import { import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder } from './hooks.js'; import { getUserHome } from './utils/home.js'; -interface Check { +export interface Check { name: string; check: () => Promise; fix?: string; } +/** + * Everything the check registry needs to describe this machine. Resolved once + * by `resolveDoctorContext`, then passed to `buildChecks` — so any caller + * (doctor, and later a post-pull run) builds the same checks the same way. + */ +export interface DoctorContext { + localConfig: LocalConfig; + teamConfig: TeamaiConfig | null; + /** Tool paths already narrowed to the enabled, non-excluded agents. */ + toolPaths: TeamaiConfig['toolPaths']; + /** Where hooks are actually injected — see `resolveHookScope` (#264). */ + baseDir: string; +} + +export interface DoctorOptions extends GlobalOptions { + /** Emit the report as JSON on stdout instead of the human rendering. */ + json?: boolean; +} + +/** One check after it ran. */ +export interface CheckResult { + name: string; + ok: boolean; + fix?: string; +} + +/** What `doctor --json` prints. One object, one place that builds it. */ +export interface DoctorReport { + ok: boolean; + /** null before initialization, when there is no config to scope. */ + scope: string | null; + checks: CheckResult[]; + /** Present only when the team repo declares packages. Human text, not checks. */ + packages?: { ok: boolean; lines: string[] }; + /** Advisories that are not checks — today, the Codex trust-gate reminder. */ + notes?: string[]; +} + /** * Build hook checks only for tools whose settings parent directory already * exists (i.e. the tool is installed). Tools that are not installed are skipped. @@ -67,24 +105,15 @@ async function hasInstalledCodexHooks(toolPaths: TeamaiConfig['toolPaths'], base return false; } -export async function doctor(options: GlobalOptions): Promise { - log.info('Running diagnostics...\n'); +/** + * Resolve the local/team configuration the checks run against. Returns null + * when TeamAI is not initialized here — the caller decides how to report that. + */ +export async function resolveDoctorContext(): Promise { const projectConfig = await detectProjectConfig(); const localConfig = projectConfig ?? (await loadLocalConfig()); - 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; - } + if (!localConfig) return null; - 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 const teamConfig = await loadTeamConfig(localConfig.repo.localPath); const toolPaths: TeamaiConfig['toolPaths'] = teamConfig ? Object.fromEntries( @@ -92,13 +121,22 @@ export async function doctor(options: GlobalOptions): Promise { .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 = resolveHookScope(localConfig).baseDir; + return { localConfig, teamConfig, toolPaths, baseDir }; +} + +/** + * The check registry. Exported so callers other than `teamai doctor` can run + * the same diagnostics and act on the result. + */ +export async function buildChecks(ctx: DoctorContext): Promise { + const { localConfig, teamConfig, toolPaths, baseDir } = ctx; + const providerName = teamConfig?.provider; const checks: Check[] = []; // Provider-specific checks: gf CLI only needed for TGit, gh CLI for GitHub @@ -198,31 +236,108 @@ export async function doctor(options: GlobalOptions): Promise { }, ); - let allPassed = true; + return checks; +} + +/** + * Run every check once, in registry order. `onResult` reports each one as it + * lands, so the human rendering keeps streaming while a slow check (a provider + * CLI auth probe) is still running. + */ +async function runChecks( + checks: Check[], + onResult?: (result: CheckResult) => void, +): Promise { + const results: CheckResult[] = []; for (const { name, check, fix } of checks) { const ok = await check(); - if (ok) { - console.log(` ✔ ${name}`); + const result: CheckResult = ok ? { name, ok } : { name, ok, fix }; + results.push(result); + onResult?.(result); + } + return results; +} + +/** The only writer of the JSON channel. */ +function emitReport(report: DoctorReport): void { + console.log(JSON.stringify(report, null, 2)); +} + +/** The human rendering of one finished check. */ +function renderResult({ name, ok, fix }: CheckResult): void { + if (ok) { + console.log(` ✔ ${name}`); + return; + } + console.log(` ✖ ${name}`); + if (fix) console.log(` → ${fix}`); +} + +export async function doctor(options: DoctorOptions): Promise { + const jsonMode = options.json === true; + // In JSON mode stdout is a data channel: route every log line to stderr so a + // consumer can parse stdout whole (same trick as hook-dispatch commands). + if (jsonMode) setStderrOnly(true); + + log.info('Running diagnostics...\n'); + const ctx = await resolveDoctorContext(); + if (!ctx) { + const notInitialized: CheckResult = { + name: 'TeamAI is not initialized', + ok: false, + fix: 'Run `teamai init ` in a project, or add `--scope user` for all projects', + }; + if (jsonMode) { + emitReport({ ok: false, scope: null, checks: [notInitialized] }); } else { - console.log(` ✖ ${name}`); - if (fix) console.log(` → ${fix}`); - allPassed = false; + console.log(' Scope: not initialized\n'); + renderResult(notInitialized); + console.log(''); } + log.warn('Initialization is required before diagnostics can run.'); + return false; } + const { localConfig, toolPaths, baseDir } = ctx; + const scope = localConfig.scope ?? 'user'; + if (!jsonMode) { + const scopeLabel = `${scope}${scope === 'project' && localConfig.projectRoot ? ` (${localConfig.projectRoot})` : ''}`; + console.log(` Scope: ${scopeLabel}\n`); + } + + const results = await runChecks(await buildChecks(ctx), jsonMode ? undefined : renderResult); + let allPassed = results.every((r) => r.ok); + 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; - } + if (packageReport && !packageReport.allPassed) allPassed = false; // Codex trust-gate reminder: even when hooks are installed, Codex may not run // them until the user reviews/trusts them. Note only — teamai never writes // [hooks.state] to auto-trust. - if (await hasInstalledCodexHooks(toolPaths, baseDir)) { + const codexNote = await hasInstalledCodexHooks(toolPaths, baseDir) + ? codexTrustReminder() + : null; + + if (jsonMode) { + emitReport({ + ok: allPassed, + scope, + checks: results, + // pkgDoctorReport renders its own lines; they are human text, not checks. + ...(packageReport ? { packages: { ok: packageReport.allPassed, lines: packageReport.lines } } : {}), + ...(codexNote ? { notes: [codexNote] } : {}), + }); + return allPassed; + } + + if (packageReport) { + for (const line of packageReport.lines) console.log(line); + } + + if (codexNote) { console.log(''); - log.info(codexTrustReminder()); + log.info(codexNote); } console.log(''); diff --git a/src/index.ts b/src/index.ts index 8ea8e744..1a2f352b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -215,10 +215,11 @@ registerPackagesCommand(program); program .command('doctor') .description('Diagnose configuration issues') - .action(async () => { + .option('--json', 'Output the report as JSON (suitable for CI)') + .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { doctor } = await import('./doctor.js'); - const allPassed = await doctor(globalOpts); + const allPassed = await doctor({ ...globalOpts, ...cmdOpts }); if (!allPassed) process.exitCode = 1; });