From 25c58f8ef2a5ef26a30653dac4323ebda5317780 Mon Sep 17 00:00:00 2001 From: fengjunhuii <993935340@qq.com> Date: Sun, 27 Sep 2026 11:56:44 +0800 Subject: [PATCH] fix(init,uninstall): dry-run purity, --agent filter, stale-entry prune - init --dry-run wrote to disk: it created .teamai/, the tool dirs, the hooks and the project partition config, so a preview could not be discarded and a follow-up real run stopped at 'already initialized' without --force. Every write step is now gated on dryRun and previews what a real run would do instead. - init --agent claude,codex still enabled other installed tools: the project config was re-read after the 'mode: self' marker was written, which fired the clone-time self-heal bootstrap (#198); it detected every tool in $HOME and merged them into enabledAgents ahead of the explicit selection. The pre-init config is now read once, before the marker exists. - uninstall --agent reported 'Nothing to uninstall' for a tool whose files were already gone yet left it in enabledAgents, so pull kept re-seeding it. When the targeted plan is empty but the config still references the tool, the entry is dropped from enabledAgents and recorded in disabledAgents; --dry-run previews it, and a tool the config does not reference still reports nothing to uninstall. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/init.test.ts | 92 ++++++++++++++++++----- src/__tests__/uninstall.test.ts | 82 +++++++++++++++++++++ src/init.ts | 127 ++++++++++++++++++++++---------- src/uninstall.ts | 31 +++++++- 7 files changed, 277 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd169e40d..115b097c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d80c62cef..d3b2a8ced 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2435,7 +2435,7 @@ What gets removed: `--agent ` 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 ` 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 ` again clears the exclusion and re-enables sync for that tool. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a57baeb7a..6a5574cfa 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2277,7 +2277,7 @@ teamai uninstall --agent claude `--agent ` 只移除该工具的 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 ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、CLAUDE.md 块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index af1a39680..26ceb2439 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -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 () => { @@ -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', () => { @@ -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()); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 106fe4988..d273dc443 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -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'); + }); +}); diff --git a/src/init.ts b/src/init.ts index ef6d6267a..0bd2cbb46 100644 --- a/src/init.ts +++ b/src/init.ts @@ -44,6 +44,7 @@ import { scopedToolPaths, toolRootRejection, type TeamaiConfig, + TeamaiConfigSchema, getTeamaiHomeDir, REPORTS_BRANCH, type GlobalOptions, @@ -964,10 +965,16 @@ export async function initSelfRepo(options: GlobalOptions & { // Attaching dataHome routes every getDataHome()-based write into the partition. const partitionHome = await resolveProjectDataHome(businessRepoRoot); + // Read any existing project config BEFORE anything is written. This must + // happen before Step 3 creates the `mode: self` marker: once the marker + // exists, loadLocalConfigForScope fires the clone-time self-heal bootstrap + // (issue #198 path), which conjures enabledAgents from HOME detection, + // injects hooks into every HOME tool and writes a config — defeating an + // explicit `--agent` selection mid-run (and leaking writes into a --dry-run). + const preInitConfig = await loadLocalConfigForScope('project', businessRepoRoot); let inheritUserScope: boolean | undefined; try { - const existing = await loadLocalConfigForScope('project', businessRepoRoot); - inheritUserScope = resolveInheritUserScope('project', options.inheritUserScope, existing?.inheritUserScope); + inheritUserScope = resolveInheritUserScope('project', options.inheritUserScope, preInitConfig?.inheritUserScope); } catch (e) { log.error((e as Error).message); process.exit(1); @@ -985,7 +992,9 @@ export async function initSelfRepo(options: GlobalOptions & { const legacyConfigPath = getConfigPath('project', businessRepoRoot); if ((await pathExists(existingConfigPath)) || (await pathExists(legacyConfigPath))) { log.warn(`teamai is already initialized (project scope) at ${existingConfigPath}`); - if (options.force) { + if (options.dryRun) { + log.info('[dry-run] A real run would overwrite it (--force or confirmation)'); + } else if (options.force) { log.info('Overwriting existing config (--force)'); } else { const confirmed = await askConfirmation('Overwrite existing config? [y/N] '); @@ -1042,17 +1051,21 @@ export async function initSelfRepo(options: GlobalOptions & { // Includes hooks/ and mcp/ too: in single-repo mode those are contributed by // editing .teamai/{hooks/hooks.yaml,mcp/mcp.yaml} directly and committing (they // don't go through `teamai push`), so seeding the dirs makes that path obvious. - await ensureDir(localPath); - for (const dir of ['skills', 'rules', 'docs', 'learnings', 'env', 'agents', 'hooks', 'mcp']) { - await ensureDir(path.join(localPath, dir)); - const gitkeep = path.join(localPath, dir, '.gitkeep'); - if (!await pathExists(gitkeep)) { - await writeFile(gitkeep, ''); + // Dry-run: touch nothing — the skeleton is previewed, not written. + if (!options.dryRun) { + await ensureDir(localPath); + for (const dir of ['skills', 'rules', 'docs', 'learnings', 'env', 'agents', 'hooks', 'mcp']) { + await ensureDir(path.join(localPath, dir)); + const gitkeep = path.join(localPath, dir, '.gitkeep'); + if (!await pathExists(gitkeep)) { + await writeFile(gitkeep, ''); + } } } // teamai.yaml carries `mode: self` so teammates auto-bootstrap after clone. const teamaiYamlPath = path.join(localPath, 'teamai.yaml'); + let defaultConfig: string | undefined; if (!await pathExists(teamaiYamlPath)) { let teamProvider: string; try { @@ -1062,7 +1075,7 @@ export async function initSelfRepo(options: GlobalOptions & { process.exit(1); return; } - const defaultConfig = YAML.stringify({ + defaultConfig = YAML.stringify({ team: repoInfo.repo, mode: 'self', description: 'TeamAI single-repo (knowledge on main, reports on teamai-reports)', @@ -1074,10 +1087,20 @@ export async function initSelfRepo(options: GlobalOptions & { env: { injectShellProfile: true }, }, }); - await writeFile(teamaiYamlPath, defaultConfig); - log.success('Created .teamai/teamai.yaml (mode: self)'); + if (options.dryRun) { + log.info('[dry-run] Would create .teamai/teamai.yaml (mode: self)'); + log.debug(`[dry-run] teamai.yaml content:\n${defaultConfig}`); + } else { + await writeFile(teamaiYamlPath, defaultConfig); + log.success('Created .teamai/teamai.yaml (mode: self)'); + } } - const teamConfig = await loadTeamConfig(localPath); + // Dry-run on a fresh repo has no teamai.yaml on disk (nothing was written), so + // parse the config we just built in memory — same schema, same result the real + // run would persist. + const teamConfig = (await pathExists(teamaiYamlPath)) + ? await loadTeamConfig(localPath) + : TeamaiConfigSchema.parse(YAML.parse(defaultConfig ?? '')); if (!teamConfig) { log.error('Failed to write a valid .teamai/teamai.yaml. Check filesystem permissions.'); process.exit(1); @@ -1112,13 +1135,15 @@ export async function initSelfRepo(options: GlobalOptions & { // Which AI tools to set up in this repo (create skills dir + inject hooks + // commit their settings.json). Resolved from --agent, else HOME detection // (non-interactive), else an interactive picker. Written to enabledAgents, - // which drives seedSelfModeToolDirs and hook injection alike. - const existingSelfConfig = await loadLocalConfigForScope('project', businessRepoRoot); + // which drives seedSelfModeToolDirs and hook injection alike. The previous + // config is `preInitConfig`, read before the self-mode marker was written — + // re-reading here would trigger the clone-time self-heal bootstrap and + // union HOME-detected tools into the explicit selection. const selectedAgents = await promptForSelfModeAgents(options); if (selectedAgents.length > 0) { - const prev = existingSelfConfig?.enabledAgents ?? []; + const prev = preInitConfig?.enabledAgents ?? []; localConfig.enabledAgents = [...new Set([...prev, ...selectedAgents])]; - localConfig.disabledAgents = (existingSelfConfig?.disabledAgents ?? []).filter((t) => !selectedAgents.includes(t)); + localConfig.disabledAgents = (preInitConfig?.disabledAgents ?? []).filter((t) => !selectedAgents.includes(t)); } // Carry the member's recorded tool roots across a re-init. `init` is @@ -1126,44 +1151,66 @@ export async function initSelfRepo(options: GlobalOptions & { // from a shell that does not export it must not quietly send every later sync // back to the default root. recordClaudeConfigRoot then overwrites the claude // entry when the variable IS set. - if (existingSelfConfig?.toolRoots) localConfig.toolRoots = { ...existingSelfConfig.toolRoots }; + if (preInitConfig?.toolRoots) localConfig.toolRoots = { ...preInitConfig.toolRoots }; recordClaudeConfigRoot(localConfig); // Step 5: write local config (into the partition via dataHome) + single-repo // gitignore. ensureDir both the knowledge dir (class B, in the repo) and the // partition (class A1 machine data). saveLocalConfigForScope writes through - // getDataHome, which now resolves to the partition. - await ensureDir(teamaiHome); - await ensureDir(partitionHome); - await settleModeSwitch(existingSelfConfig, localConfig, () => - saveLocalConfigForScope(localConfig, 'project', businessRepoRoot)); - log.success(`Local config saved to ${partitionHome}/config.yaml`); + // getDataHome, which now resolves to the partition. Dry-run: keep the config + // in memory (later steps read it) and only say where it would land. + if (options.dryRun) { + log.info(`[dry-run] Would save local config to ${partitionHome}/config.yaml`); + } else { + await ensureDir(teamaiHome); + await ensureDir(partitionHome); + await settleModeSwitch(preInitConfig, localConfig, () => + saveLocalConfigForScope(localConfig, 'project', businessRepoRoot)); + log.success(`Local config saved to ${partitionHome}/config.yaml`); + } // (Pre-P2 this retired any stale partition config so detection fell back to the // in-repo self config. P2 makes self USE the partition, so there is nothing to // retire — the config we just wrote there is the authoritative one.) const gitignorePath = path.join(teamaiHome, '.gitignore'); - await writeFile(gitignorePath, buildSelfModeGitignore()); - log.debug('Generated single-repo .teamai/.gitignore'); + if (options.dryRun) { + log.info('[dry-run] Would generate single-repo .teamai/.gitignore'); + } else { + await writeFile(gitignorePath, buildSelfModeGitignore()); + log.debug('Generated single-repo .teamai/.gitignore'); + } // Step 5.3: seed the selected tools' skills dir so first-run hook + skill // injection lands. Single-repo mode must inject into the project even on a // brand-new clone where no /.claude exists yet (isToolInstalled would // otherwise skip everything). const filterAgents = selectedAgents.length > 0 ? selectedAgents : undefined; - try { - const { seedSelfModeToolDirs } = await import('./known-agents.js'); - const seeded = await seedSelfModeToolDirs(localConfig, teamConfig); - if (seeded.length > 0) log.debug(`Seeded tool dirs for: ${seeded.join(', ')}`); - } catch (e) { - log.debug(`Tool-dir seeding skipped: ${(e as Error).message}`); + if (options.dryRun) { + // Print what a real run would seed: enabledAgents (what seedSelfModeToolDirs + // reads), not the --agent filter. A value here that the member never asked + // for is exactly how the mid-init self-heal leak shows itself. + const seedTargets = localConfig.enabledAgents ?? []; + if (seedTargets.length > 0) log.info(`[dry-run] Would seed tool dirs for: ${seedTargets.join(', ')}`); + } else { + try { + const { seedSelfModeToolDirs } = await import('./known-agents.js'); + const seeded = await seedSelfModeToolDirs(localConfig, teamConfig); + if (seeded.length > 0) log.debug(`Seeded tool dirs for: ${seeded.join(', ')}`); + } catch (e) { + log.debug(`Tool-dir seeding skipped: ${(e as Error).message}`); + } } // Step 5.4: inject hooks BEFORE the skeleton commit, so each selected tool's // settings file exists on disk and can be committed to main below. This is what // makes a teammate's fresh clone carry the session-start hook that triggers the // self-heal bootstrap — the core of "clone = initialized". - await reconcileHooksForInit(teamConfig, localConfig, filterAgents); + if (options.dryRun) { + const hookTargets = filterAgents ?? localConfig.enabledAgents ?? []; + if (hookTargets.length > 0) log.info(`[dry-run] Would inject teamai hooks into: ${hookTargets.join(', ')}`); + } else { + await reconcileHooksForInit(teamConfig, localConfig, filterAgents); + } // Step 5.5: commit the .teamai/ knowledge skeleton + selected tools' hook // settings to the current branch. Single-repo mode keeps knowledge on main, and @@ -1251,12 +1298,14 @@ export async function initSelfRepo(options: GlobalOptions & { } // Step 6.5: invalidate pull cache so next pull does a full sync. - try { - const state = await loadStateForScope(localConfig); - state.lastPullRev = null; - await saveStateForScope(state, localConfig); - } catch { - // state may not exist yet + if (!options.dryRun) { + try { + const state = await loadStateForScope(localConfig); + state.lastPullRev = null; + await saveStateForScope(state, localConfig); + } catch { + // state may not exist yet + } } log.success('teamai initialized (single-repo mode)!'); diff --git a/src/uninstall.ts b/src/uninstall.ts index a8d9de59f..25cd45fc5 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1152,7 +1152,36 @@ export async function uninstall(opts: UninstallOptions): Promise { const plan = await buildRemovalPlan(localConfig, teamConfig, agentKey); if (isPlanEmpty(plan)) { - log.info('Nothing to uninstall'); + // The tool has no files left to remove, but the config may still reference + // it — a stale `enabledAgents`/`disabledAgents` entry (e.g. the tool dir + // was deleted by hand, or the entry was written by a machine that has + // since moved). Prune it so `teamai doctor` stops flagging it and future + // syncs do not resurrect the tool; only report "Nothing to uninstall" + // when the config does not reference the tool either. + const cfg = localConfig!; + if (!agentKey) { + log.info('Nothing to uninstall'); + return; + } + const referenced = cfg.enabledAgents?.includes(agentKey) || cfg.disabledAgents?.includes(agentKey); + if (!referenced) { + log.info('Nothing to uninstall'); + return; + } + if (opts.dryRun) { + log.info(`Dry run — would remove the stale ${agentKey} entry from the teamai config`); + return; + } + if (cfg.enabledAgents) { + cfg.enabledAgents = cfg.enabledAgents.filter((t) => t !== agentKey); + } + cfg.disabledAgents = [...new Set([...(cfg.disabledAgents ?? []), agentKey])]; + if (cfg.scope === 'project') { + await saveLocalConfigForScope(cfg, cfg.scope, cfg.projectRoot); + } else { + await saveLocalConfig(cfg); + } + log.success(`Removed the stale ${agentKey} entry from the teamai config (no files to uninstall)`); return; }