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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
4 changes: 3 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1460,6 +1460,8 @@ teamai remove agents <name>
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.

Auto-update runs in the Stop hook and is controlled by two tiers:

| Tier | File | Field | Value |
Expand Down
4 changes: 3 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 依赖会按工作目录分别确认,避免在一个仓库安装后错误关闭另一个仓库的提示。
Expand Down Expand Up @@ -1424,6 +1424,8 @@ teamai remove agents <name>
teamai remove mcp <name>
```

仅当所有检查通过时,`teamai doctor` 才以状态码 0 退出;任一检查失败时以状态码 1 退出。尚未初始化时,它只报告缺少配置,不会臆测 Git 托管平台。

自动更新在 Stop hook 中执行,可通过两层控制:

| 层级 | 文件 | 字段 | 值 |
Expand Down
56 changes: 52 additions & 4 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import path from 'node:path';

// ── Mocks ────────────────────────────────────────────────

Expand Down Expand Up @@ -35,13 +36,16 @@ 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;
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' },
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -180,7 +190,7 @@ describe('doctor — hook checks', () => {
// Only <projectRoot> 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;
});
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
});
});
114 changes: 114 additions & 0 deletions src/__tests__/e2e/doctor-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});
Loading
Loading