Skip to content
Closed
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- `teamai init --dry-run` writes nothing again. It created `.teamai/`, the tool directories, the hooks and the project partition config, so a preview could not just be discarded: a follow-up real run stopped at `already initialized` unless `--force` was passed. A dry run now only prints what a real run would do — the `teamai.yaml` it would create, the tool dirs it would seed, the hooks it would inject, the config it would save — and the re-init guard reports the same instead of asking to overwrite. `init --agent claude,codex` also honors the selection now: the clone-time self-heal bootstrap fired mid-run, after the `mode: self` marker had been written, detected every tool installed in `$HOME` (a mirrored `cursor`, say) and merged them into `enabledAgents` ahead of the explicit list. The existing project config is now read once, before that marker exists, so the bootstrap never sees a half-written install (for [#198](https://github.com/Tencent/teamai-cli/issues/198)).
- `uninstall --agent <tool>` prunes a stale config entry. A tool whose files were already removed by hand reported `Nothing to uninstall` yet stayed listed in `enabledAgents`, so the next pull kept re-seeding its directories and its session-start hooks kept firing. When a targeted plan has no files to remove but the config still references the tool, the entry is now dropped from `enabledAgents` and recorded in `disabledAgents` — `--dry-run` previews that, and no `--force` confirmation is needed since no file is touched — while a tool the config does not reference still reports `Nothing to uninstall`.
- When `TEAMAI_GITLAB_HOST` and `GITLAB_URL` name different hosts, GitLab commands stop with an error naming both, before any request. A repo on `TEAMAI_GITLAB_HOST` was detected as GitLab while every API call, the token included, went to `GITLAB_URL`. An invalid `GITLAB_URL` is now reported as such by `init` instead of as a failed GitLab login (for [#789](https://github.com/Tencent/teamai-cli/issues/789)).
- A misspelled top-level key in `mcp/mcp.yaml` or `hooks/hooks.yaml` (`server:` for `servers:`, `hook:` for `hooks:`) no longer removes every installed team MCP server or hook: such a file read as empty. It now fails like a file that does not parse, so pull keeps what is installed, and pull and `teamai doctor` name the file, the keys found and the key expected. An extra top-level key beside `servers:` or `hooks:` is still ignored (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
- The closing line of `teamai recall` output is in English (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
Expand Down
2 changes: 1 addition & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -2435,7 +2435,7 @@ What gets removed:

`--agent <tool>` removes only that tool's teamai resources (hooks, CLAUDE.md block, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status.

Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.)
Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (Targeting a tool that has no teamai resources of its own therefore never touches shared resources, even if it happens to be the only tool. If the config still references such a tool — its files were deleted by hand, say — the only change is to the config itself: the stale entry is dropped from `enabledAgents` and recorded in `disabledAgents`, and nothing on disk is modified. A tool the config does not reference at all is a full no-op.)

The exclusion is durable: `uninstall --agent <tool>` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, CLAUDE.md block, or hooks. Running `init --agent <tool>` again clears the exclusion and re-enables sync for that tool.

Expand Down
2 changes: 1 addition & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2277,7 +2277,7 @@ teamai uninstall --agent claude

`--agent <tool>` 只移除该工具的 teamai 资源(hooks、CLAUDE.md 块、skills、rules、团队同步的自定义 agents、内置 agents)。工具名即 `toolPaths` 的键(如 `claude`、`codex`、`codebuddy`),匹配大小写不敏感。传入未知工具名会直接报错并列出可用工具、不执行任何删除,并以非零状态码退出。

跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(因此,定向卸载一个自身没有任何 teamai 资源的工具是 no-op,即便它恰好是唯一的工具,也不会删除共享资源。)
跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(定向卸载一个自身没有任何 teamai 资源的工具因此不会触碰共享资源,即便它恰好是唯一的工具。若配置中仍引用这样的工具——例如其文件被手工删除——唯一的变化发生在配置本身:过期条目会从 `enabledAgents` 移除并记入 `disabledAgents`,磁盘上不会有任何改动。配置完全未引用的工具则是彻底的 no-op。)

该排除是持久的:`uninstall --agent <tool>` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、CLAUDE.md 块或 hooks 重新装回。重新执行 `init --agent <tool>` 会清除该排除、恢复对该工具的同步。

Expand Down
92 changes: 73 additions & 19 deletions src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,13 +871,11 @@ describe('init', () => {
expect(errorCalls).not.toContainEqual(expect.stringContaining('Could not parse the business repo remote'));
expect(errorCalls.join('\n')).not.toContain('token-must-not-appear');
expect(debugCalls.join('\n')).not.toContain('token-must-not-appear');
expect(saveLocalConfigForScope).toHaveBeenCalledWith(
expect.objectContaining({
repo: expect.objectContaining({ remote: 'http://git.example.com/group/repo.git' }),
}),
'project',
process.cwd(),
);
// Dry-run persists nothing: the assembled config stays in memory, and only
// the preview line says where a real run would save it.
expect(saveLocalConfigForScope).not.toHaveBeenCalled();
const infoCalls = vi.mocked(log.info).mock.calls.map(([message]) => String(message));
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would save local config'))).toBe(true);
});

it('aborts on a malformed roles manifest instead of initializing role-less', async () => {
Expand Down Expand Up @@ -915,11 +913,65 @@ describe('init', () => {

await init({ repo: '.', dryRun: true });

expect(saveLocalConfigForScope).toHaveBeenCalledWith(
expect.not.objectContaining({ primaryRole: expect.anything() }),
'project',
process.cwd(),
);
// The missing manifest must leave the role unset without aborting: the run
// reaches the (previewed) save step. Dry-run persists nothing, so the
// would-save line is the observable that the flow completed.
expect(saveLocalConfigForScope).not.toHaveBeenCalled();
const { log } = await import('../utils/logger.js');
const infoCalls = vi.mocked(log.info).mock.calls.map(([message]) => String(message));
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would save local config'))).toBe(true);
});

it('dry-run writes nothing — repo, tool dirs and config all stay untouched', async () => {
pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git');
mockGit.raw.mockResolvedValue('https://git.example.com/group/repo.git\n');

const { writeFile, ensureDir } = await import('../utils/fs.js');
const { saveLocalConfigForScope, saveStateForScope } = await import('../config.js');
vi.mocked(writeFile).mockClear();
vi.mocked(ensureDir).mockClear();
vi.mocked(saveLocalConfigForScope).mockClear();
vi.mocked(saveStateForScope).mockClear();

await init({ repo: '.', provider: 'git', agent: ['claude', 'codex'], dryRun: true });

expect(mockExit).not.toHaveBeenCalled();
// No skeleton .gitkeep files, no teamai.yaml, no .gitignore.
expect(writeFile).not.toHaveBeenCalled();
// No .teamai/, no partition, no tool dirs.
expect(ensureDir).not.toHaveBeenCalled();
// No config or state persisted anywhere.
expect(saveLocalConfigForScope).not.toHaveBeenCalled();
expect(saveStateForScope).not.toHaveBeenCalled();
// The preview still says what a real run would do.
const { log } = await import('../utils/logger.js');
const infoCalls = vi.mocked(log.info).mock.calls.map(([message]) => String(message));
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would create .teamai/teamai.yaml'))).toBe(true);
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would save local config'))).toBe(true);
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would seed tool dirs for: claude, codex'))).toBe(true);
expect(infoCalls.some((msg) => msg.includes('[dry-run] Would inject teamai hooks into: claude, codex'))).toBe(true);
});

it('reads the existing project config once, before the self-mode marker is written', async () => {
// Re-reading the config AFTER Step 3 wrote `mode: self` fires the
// clone-time self-heal bootstrap mid-init. That path synthesizes
// enabledAgents from HOME detection, so an explicit `--agent claude,codex`
// came out unioned with whatever tools the developer had under HOME
// (a .cursor/ conjured out of a .cursor that init was never told about).
pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git');
mockGit.raw.mockResolvedValue('https://git.example.com/group/repo.git\n');
vi.mocked(loadLocalConfigForScope).mockClear();

await init({ repo: '.', provider: 'git', agent: ['claude', 'codex'], dryRun: true });

expect(loadLocalConfigForScope).toHaveBeenCalledTimes(1);
// And the seeded set is exactly the explicit selection — no HOME-mirrored
// extras leaked into the preview.
const { log } = await import('../utils/logger.js');
const infoCalls = vi.mocked(log.info).mock.calls.map(([message]) => String(message));
const seedLine = infoCalls.find((msg) => msg.includes('[dry-run] Would seed tool dirs for:'));
expect(seedLine).toBeDefined();
expect(seedLine).not.toContain('cursor');
});
});
describe('CLAUDE_CONFIG_DIR', () => {
Expand Down Expand Up @@ -1337,15 +1389,17 @@ describe('init --provider', () => {

expect(mockExit).not.toHaveBeenCalled();
expect(GitLabProvider.prototype.authenticate).not.toHaveBeenCalled();
// A new committed .teamai/teamai.yaml keeps the host's provider for teammates.
// Dry-run writes nothing — the teamai.yaml that WOULD be committed (keeping
// the host's provider for teammates) is previewed on the debug channel.
const { writeFile } = await import('../utils/fs.js');
const teamYaml = vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml'));
expect(teamYaml?.[1]).toContain('"provider":"gitlab"');
expect(saveLocalConfigForScope).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'git', username: 'plain-member' }),
'project',
process.cwd(),
);
expect(teamYaml).toBeUndefined();
const { log } = await import('../utils/logger.js');
const debugCalls = vi.mocked(log.debug).mock.calls.map(([message]) => String(message));
const yamlPreview = debugCalls.find((m) => m.includes('teamai.yaml content'));
expect(yamlPreview).toBeDefined();
expect(yamlPreview).toContain('"provider":"gitlab"');
expect(saveLocalConfigForScope).not.toHaveBeenCalled();
});
it('refuses to create a single-repo teamai.yaml on an unconfigured self-hosted GitLab', async () => {
fetchMock.mockResolvedValue(gitlabSignInResponse());
Expand Down
82 changes: 82 additions & 0 deletions src/__tests__/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2036,3 +2036,85 @@ describe('uninstall', () => {
expect(await fse.pathExists(path.join(homeDir, '.claude', 'CLAUDE.md'))).toBe(false);
});
});


describe('uninstall — stale --agent config entry', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-uninstall-stale-'));
});

afterEach(async () => {
vi.unstubAllEnvs();
await fse.remove(tmpDir);
vi.clearAllMocks();
});

const makeTeamConfigWithCursor = () =>
makeTeamConfig({
toolPaths: {
...makeTeamConfig().toolPaths,
cursor: { skills: '.cursor/skills', settings: '.cursor/hooks.json' },
},
});

it('prunes a stale --agent entry when the tool has no files left to remove', async () => {
const { homeDir, repoPath } = await setupFixture(tmpDir);
vi.stubEnv('HOME', homeDir);
vi.stubEnv('SHELL', '/bin/zsh');
// No .cursor dir anywhere in the fixture — the tool was deleted by hand,
// but the config still whitelists it (doctor keeps flagging this).
const localConfig = makeLocalConfig(homeDir, repoPath, {
enabledAgents: ['claude', 'cursor'],
disabledAgents: [],
});
mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfigWithCursor() });

await uninstall({ force: true, agent: 'cursor' });

// The entry moves out of the whitelist into the exclusion list, so the next
// pull cannot resurrect the tool.
expect(mockSaveLocalConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabledAgents: ['claude'],
disabledAgents: ['cursor'],
}),
);
});

it('dry-run previews the stale-entry prune without saving', async () => {
const { homeDir, repoPath } = await setupFixture(tmpDir);
vi.stubEnv('HOME', homeDir);
vi.stubEnv('SHELL', '/bin/zsh');
const localConfig = makeLocalConfig(homeDir, repoPath, {
enabledAgents: ['claude', 'cursor'],
disabledAgents: [],
});
mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfigWithCursor() });

await uninstall({ force: true, agent: 'cursor', dryRun: true });

expect(mockSaveLocalConfig).not.toHaveBeenCalled();
const { log } = await import('../utils/logger.js');
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('would remove the stale cursor entry'));
});

it('still reports nothing to uninstall when the config does not reference the tool', async () => {
const { homeDir, repoPath } = await setupFixture(tmpDir);
vi.stubEnv('HOME', homeDir);
vi.stubEnv('SHELL', '/bin/zsh');
const localConfig = makeLocalConfig(homeDir, repoPath, {
enabledAgents: ['claude'],
disabledAgents: [],
});
mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfigWithCursor() });

await uninstall({ force: true, agent: 'cursor' });

expect(mockSaveLocalConfig).not.toHaveBeenCalled();
expect(mockSaveLocalConfigForScope).not.toHaveBeenCalled();
const { log } = await import('../utils/logger.js');
expect(log.info).toHaveBeenCalledWith('Nothing to uninstall');
});
});
Loading
Loading