Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill
| `teamai remove <type> <name>` | リソースを削除し、MR を開く |
| `teamai session save` | プライバシー除去済みのセッション要約を月次ログへ記録(`--push` は `digest` へ供給) |
| `teamai digest` | チーム利用の週次 digest を生成 |
| `teamai doctor` | 設定の問題を診断 |
| `teamai doctor` | 設定の問題を診断(`--json` で JSON 出力、CI・hook・agent 向け)|
| `teamai uninstall` | すべての teamai リソースと hooks を削除 |

## ライセンス
Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill
| `teamai remove <type> <name>` | 리소스를 제거하고 MR을 엽니다 |
| `teamai session save` | 개인정보를 제거한 세션 요약을 월별 로그에 기록 (`--push`는 `digest`에 공급) |
| `teamai digest` | 주간 팀 사용 digest 생성 |
| `teamai doctor` | 구성 문제 진단 |
| `teamai doctor` | 구성 문제 진단 (`--json`으로 JSON 출력, CI·hook·agent용) |
| `teamai uninstall` | 모든 teamai 리소스와 hooks 제거 |

## 라이선스
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t
| `teamai remove <type> <name>` | 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
Expand Down
2 changes: 1 addition & 1 deletion README.th.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # draft updates for stale skill
| `teamai remove <type> <name>` | ลบทรัพยากรแล้วเปิด MR |
| `teamai session save` | บันทึกสรุปเซสชันที่ล้างข้อมูลส่วนตัวแล้วลงล็อกรายเดือน (`--push` จะป้อน `digest`) |
| `teamai digest` | สร้างสรุปการใช้งานทีมรายสัปดาห์ |
| `teamai doctor` | วินิจฉัยปัญหาคอนฟิก |
| `teamai doctor` | วินิจฉัยปัญหาคอนฟิก (`--json` แสดงผลเป็น JSON สำหรับ CI, hook และ agent) |
| `teamai uninstall` | ลบทรัพยากรและ hooks ของ teamai ทั้งหมด |

## ใบอนุญาต
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生
| `teamai remove <type> <name>` | 删除资源并创建 MR |
| `teamai session save` | 将脱敏后的 session 摘要记录到月度日志(`--push` 可喂给 `digest`) |
| `teamai digest` | 生成团队周报 |
| `teamai doctor` | 诊断配置问题 |
| `teamai doctor` | 诊断配置问题(`--json` 输出 JSON,供 CI、hook 与 agent 消费)|
| `teamai uninstall` | 移除所有 teamai 资源和 hooks |

## 许可证
Expand Down
20 changes: 20 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1462,6 +1463,25 @@ teamai remove mcp <name>

`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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 更新
Expand All @@ -1426,6 +1427,25 @@ teamai remove mcp <name>

仅当所有检查通过时,`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 中执行,可通过两层控制:

| 层级 | 文件 | 字段 | 值 |
Expand Down
108 changes: 106 additions & 2 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
});
39 changes: 37 additions & 2 deletions src/__tests__/e2e/doctor-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
});
});
Loading
Loading