diff --git a/src/__tests__/pull-scope-isolation.test.ts b/src/__tests__/pull-scope-isolation.test.ts new file mode 100644 index 0000000..8984093 --- /dev/null +++ b/src/__tests__/pull-scope-isolation.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +// Issue #73: when a project-scope install is detected, `pull` must NOT touch the +// user scope. These tests mock the config + git layers and assert the top-level +// orchestration in pull() short-circuits user scope and routes source pull to +// the active scope. + +vi.mock('../config.js', () => ({ + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +// Stub the cross-team source pull so we can assert which scope it runs against +// without doing any real git work. +vi.mock('../source.js', () => ({ + pullSources: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../roles.js', () => ({ + loadRolesManifest: vi.fn().mockResolvedValue({ + version: 1, + roles: [], + defaults: { shareTarget: 'primary-role' }, + }), + resolveRoleResourceNamespaces: vi.fn(() => ({ knowledge: [], skills: [], learnings: [] })), +})); + +import { pull } from '../pull.js'; +import { + loadLocalConfigForScope, + loadTeamConfig, + detectProjectConfig, + loadStateForScope, +} from '../config.js'; +import { getHeadRev } from '../utils/git.js'; +import { pullSources } from '../source.js'; +import { log } from '../utils/logger.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +const SKIP_MSG = '检测到 project scope,已跳过 user scope'; + +describe('pull scope isolation (issue #73)', () => { + let tmpDir: string; + let homeDir: string; + let userRepoPath: string; + let projectRoot: string; + let projectRepoPath: string; + let teamConfig: TeamaiConfig; + let userConfig: LocalConfig; + let projectConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-scope-iso-')); + homeDir = path.join(tmpDir, 'home'); + userRepoPath = path.join(tmpDir, 'user-repo'); + projectRoot = path.join(tmpDir, 'proj'); + projectRepoPath = path.join(projectRoot, '.teamai', 'team-repo'); + + for (const repo of [userRepoPath, projectRepoPath]) { + await fse.ensureDir(path.join(repo, 'skills')); + await fse.ensureDir(path.join(repo, 'rules')); + } + await fse.ensureDir(path.join(homeDir, '.claude', 'skills')); + await fse.ensureDir(path.join(projectRoot, '.claude', 'skills')); + + vi.stubEnv('HOME', homeDir); + + teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + userConfig = { + repo: { localPath: userRepoPath, remote: 'https://git.woa.com/test/repo.git' }, + username: 'userscope', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + + projectConfig = { + repo: { localPath: projectRepoPath, remote: 'https://git.woa.com/test/proj.git' }, + username: 'projscope', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + }; + + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + vi.mocked(getHeadRev).mockResolvedValue('abc1234'); + // Make pullForScope hit the "Already synced" fast path so the heavy sync + // loop is skipped — we only care about top-level scope routing here. + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPull: '2026-04-01', + lastPullRev: 'abc1234', + lastPush: null, + pushedRules: [], + pushedSkills: [], + pushedEnvVars: [], + lastUpdateCheck: null, + availableUpdate: null, + }); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await fse.remove(tmpDir); + }); + + it('project mode: skips user scope entirely and pulls source against project', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig); + + await pull({ silent: true }); + + // User scope must never be loaded / pulled. + expect(loadLocalConfigForScope).not.toHaveBeenCalled(); + // The skip notice is printed. + expect(log.info).toHaveBeenCalledWith(SKIP_MSG); + // Source pull still runs (decision 1), against the project config. + expect(pullSources).toHaveBeenCalledTimes(1); + expect(vi.mocked(pullSources).mock.calls[0][0]).toMatchObject({ + scope: 'project', + projectRoot, + }); + }); + + it('user mode: pulls user scope and routes source against user (no skip notice)', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(loadLocalConfigForScope).mockResolvedValue(userConfig); + + await pull({ silent: true }); + + expect(loadLocalConfigForScope).toHaveBeenCalledWith('user'); + expect(log.info).not.toHaveBeenCalledWith(SKIP_MSG); + expect(pullSources).toHaveBeenCalledTimes(1); + expect(vi.mocked(pullSources).mock.calls[0][0]).toMatchObject({ scope: 'user' }); + }); +}); diff --git a/src/__tests__/recall-scope-isolation.test.ts b/src/__tests__/recall-scope-isolation.test.ts new file mode 100644 index 0000000..5a425c9 --- /dev/null +++ b/src/__tests__/recall-scope-isolation.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +// Issue #73: recall must query the project-scope index ONLY when a project +// install is detected, and the user-scope index otherwise. The two are never +// merged anymore. + +vi.mock('../config.js', () => ({ + detectProjectConfig: vi.fn(), + requireInit: vi.fn(), +})); + +import { recall } from '../recall.js'; +import { detectProjectConfig, requireInit } from '../config.js'; +import { buildIndex } from '../utils/search-index.js'; +import { getTeamaiHome, type LocalConfig } from '../types.js'; + +const PROJECT_TITLE = 'Project Deployment Timeout Fix'; +const USER_TITLE = 'User Deployment Timeout Fix'; + +function learningDoc(title: string): string { + return `---\ntitle: "${title}"\nauthor: tester\ndate: 2026-05-01\ntags: [deployment, timeout]\n---\n\nNotes about deployment timeout handling.\n`; +} + +describe('recall scope isolation (issue #73)', () => { + let tmpDir: string; + let homeDir: string; + let projectRoot: string; + let userConfig: LocalConfig; + let projectConfig: LocalConfig; + let writeSpy: { mockRestore: () => void }; + let captured: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recall-iso-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'proj'); + await fse.ensureDir(homeDir); + await fse.ensureDir(projectRoot); + vi.stubEnv('HOME', homeDir); + + // ── User scope index (HOME/.teamai/search-index.json) ── + const userLearnings = path.join(tmpDir, 'user-learnings'); + await fse.ensureDir(userLearnings); + await fse.writeFile(path.join(userLearnings, 'user-deploy-2026-05-01-aaa.md'), learningDoc(USER_TITLE)); + await fse.ensureDir(getTeamaiHome('user')); + await buildIndex({ learningsDir: userLearnings, indexPath: path.join(getTeamaiHome('user'), 'search-index.json') }); + + // ── Project scope index (/.teamai/search-index.json) ── + const projectRepo = path.join(projectRoot, '.teamai', 'team-repo'); + const projectLearnings = path.join(projectRepo, 'learnings'); + await fse.ensureDir(projectLearnings); + await fse.writeFile(path.join(projectLearnings, 'proj-deploy-2026-05-01-bbb.md'), learningDoc(PROJECT_TITLE)); + await fse.ensureDir(getTeamaiHome('project', projectRoot)); + await buildIndex({ learningsDir: projectLearnings, indexPath: path.join(getTeamaiHome('project', projectRoot), 'search-index.json') }); + + userConfig = { + repo: { localPath: path.join(homeDir, '.teamai', 'team-repo'), remote: 'https://git.woa.com/test/repo.git' }, + username: 'userscope', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + projectConfig = { + repo: { localPath: projectRepo, remote: 'https://git.woa.com/test/proj.git' }, + username: 'projscope', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + }; + + captured = ''; + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + captured += chunk.toString(); + return true; + }) as never); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await fse.remove(tmpDir); + }); + + it('project mode: returns project results only, never consults user scope', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(projectConfig); + + await recall('deployment timeout', { dryRun: true }); + + expect(captured).toContain(PROJECT_TITLE); + expect(captured).not.toContain(USER_TITLE); + expect(captured).toContain('[project]'); + // User scope must never be initialized in project mode. + expect(requireInit).not.toHaveBeenCalled(); + }); + + it('user mode: returns user results only when no project scope detected', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(requireInit).mockResolvedValue({ localConfig: userConfig, teamConfig: {} as never }); + + await recall('deployment timeout', { dryRun: true }); + + expect(captured).toContain(USER_TITLE); + expect(captured).not.toContain(PROJECT_TITLE); + expect(captured).toContain('[user]'); + }); +}); diff --git a/src/__tests__/scope-isolation-e2e.test.ts b/src/__tests__/scope-isolation-e2e.test.ts new file mode 100644 index 0000000..0d4adaa --- /dev/null +++ b/src/__tests__/scope-isolation-e2e.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, execSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// ─── Issue #73 end-to-end: project scope isolates user scope ────────────── +// +// Drives the real CLI binary against two offline git fixture repos (one user, +// one project). No network / token needed. Verifies that, when run inside a +// project-scope install: +// - `pull` skips the user scope (notice printed, user skills NOT deployed) +// while still deploying the project scope, and +// - `recall` only returns project-scope knowledge, and +// - `uninstall` clearly states it is acting on the project scope. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const PROJECT_TITLE = 'Project Deployment Timeout Fix'; +const USER_TITLE = 'User Deployment Timeout Fix'; +const SKIP_MSG = '检测到 project scope,已跳过 user scope'; + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], env: Record, cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + env: { ...process.env, FORCE_COLOR: '0', ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + cwd, + }); + let out = ''; + child.stdout.on('data', (d: Buffer) => { out += d.toString(); }); + child.stderr.on('data', (d: Buffer) => { out += d.toString(); }); + child.stdin.end(); + child.on('close', (code) => resolve({ code, output: out })); + }); +} + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +function git(cmd: string, cwd: string): void { + execSync(`git ${cmd}`, { cwd, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +function learningDoc(title: string): string { + return `---\ntitle: "${title}"\nauthor: tester\ndate: 2026-05-01\ntags: [deployment, timeout]\n---\n\nNotes about deployment timeout handling.\n`; +} + +function skillDoc(name: string): string { + return `---\nname: ${name}\ndescription: demo skill for e2e\n---\n\n# ${name}\n\nDemo.\n`; +} + +const TEAM_YAML = [ + 'team: e2e-team', + 'repo: https://example.com/e2e.git', + 'provider: tgit', + 'toolPaths:', + ' claude:', + ' skills: .claude/skills', + ' rules: .claude/rules', +].join('\n'); + +/** Create a git repo fixture with a learning doc + a skill, return its path. */ +function makeRemote(dir: string, learningTitle: string, skillName: string): void { + fs.mkdirSync(path.join(dir, 'learnings'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'skills', skillName), { recursive: true }); + fs.writeFileSync(path.join(dir, 'teamai.yaml'), TEAM_YAML); + fs.writeFileSync( + path.join(dir, 'learnings', `${skillName}-2026-05-01-aaa.md`), + learningDoc(learningTitle), + ); + fs.writeFileSync(path.join(dir, 'skills', skillName, 'SKILL.md'), skillDoc(skillName)); + git('init -q', dir); + git('add -A', dir); + git('commit -q -m init', dir); +} + +describe('scope isolation e2e (issue #73)', () => { + let sandbox: string; + let homeDir: string; + let projectRoot: string; + let pullOut: RunResult; + + beforeAll(async () => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-iso-e2e-')); + homeDir = path.join(sandbox, 'home'); + projectRoot = path.join(sandbox, 'proj'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + // Pre-create the claude tool dirs in BOTH scopes so the deploy "is tool + // installed" gate passes for each. This makes the user-skill absence a + // genuine isolation signal (not just a missing tool dir). + fs.mkdirSync(path.join(homeDir, '.claude', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); + + // ── User-scope fixture ── + const userRemote = path.join(sandbox, 'user-remote'); + makeRemote(userRemote, USER_TITLE, 'user-skill'); + const userLocal = path.join(homeDir, '.teamai', 'team-repo'); + git(`clone -q "${userRemote}" "${userLocal}"`, sandbox); + fs.writeFileSync( + path.join(homeDir, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${userLocal}`, + ` remote: ${userRemote}`, + 'username: ci-user', + 'updatePolicy: auto', + 'scope: user', + ].join('\n'), + ); + + // ── Project-scope fixture ── + const projectRemote = path.join(sandbox, 'proj-remote'); + makeRemote(projectRemote, PROJECT_TITLE, 'demo-skill'); + const projectLocal = path.join(projectRoot, '.teamai', 'team-repo'); + git(`clone -q "${projectRemote}" "${projectLocal}"`, sandbox); + fs.writeFileSync( + path.join(projectRoot, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${projectLocal}`, + ` remote: ${projectRemote}`, + 'username: ci-proj', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${projectRoot}`, + ].join('\n'), + ); + + // Run pull once from within the project root. + pullOut = await runCLI(['pull'], { HOME: homeDir }, projectRoot); + }, 60_000); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('pull: prints skip notice and exits cleanly', () => { + expect(pullOut.code, pullOut.output).toBe(0); + expect(pullOut.output).toContain(SKIP_MSG); + }); + + it('pull: deploys project skills but NOT user skills', () => { + const projectSkill = path.join(projectRoot, '.claude', 'skills', 'demo-skill'); + const userSkillInHome = path.join(homeDir, '.claude', 'skills', 'user-skill'); + expect(fs.existsSync(projectSkill)).toBe(true); + expect(fs.existsSync(userSkillInHome)).toBe(false); + }); + + it('recall: returns project knowledge only', async () => { + const res = await runCLI(['recall', 'deployment timeout'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + expect(res.output).toContain(PROJECT_TITLE); + expect(res.output).not.toContain(USER_TITLE); + }); + + it('uninstall --dry-run: states it is acting on the project scope', async () => { + const res = await runCLI(['uninstall', '--dry-run', '--force'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + expect(res.output).toContain('正在卸载 project scope(项目级)'); + }); +}); diff --git a/src/pull.ts b/src/pull.ts index dabe2a1..a428cb9 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1085,8 +1085,12 @@ async function autoMigrateHooksIfNeeded(): Promise { /** * Main pull entry point. - * Implements Scheme B: user scope is always pulled (baseline), - * project scope is additionally pulled if detected in cwd. + * + * Scope isolation (issue #73): when a project-scope install is detected in cwd, + * the user scope is **not** touched — pull and reconcile run for the project + * scope only. When no project scope is present, the user scope is pulled as + * before. Cross-team source skills are always pulled, against whichever scope + * is active. */ export async function pull(options: GlobalOptions): Promise { // 0. Auto-migrate hooks if settings.json has old format (pre-dispatch era). @@ -1098,41 +1102,55 @@ export async function pull(options: GlobalOptions): Promise { // Non-fatal — pull continues even if hook migration fails } - // 1. Always try to pull user scope - let userConfig: LocalConfig | null = null; + // 1. Detect project scope first. Its presence decides whether user scope is + // processed at all (issue #73: project install isolates from user). + let projectConfig: LocalConfig | null = null; try { - userConfig = await loadLocalConfigForScope('user'); - if (userConfig) { - await pullForScope(userConfig, options); - } else { - log.debug('No user-scope config found, skipping user pull'); - } + projectConfig = await detectProjectConfig(); } catch (e) { - log.warn(`User-scope pull error: ${(e as Error).message}`); + log.warn(`Project-scope detection error: ${(e as Error).message}`); } + const projectMode = projectConfig !== null; - // 2. Detect and pull project scope if cwd has .teamai/config.yaml with scope='project' - let projectConfig: LocalConfig | null = null; - try { - projectConfig = await detectProjectConfig(); - if (projectConfig) { + // 2. User scope — only when NOT in project mode. + let userConfig: LocalConfig | null = null; + if (projectMode) { + log.info('检测到 project scope,已跳过 user scope'); + } else { + try { + userConfig = await loadLocalConfigForScope('user'); + if (userConfig) { + await pullForScope(userConfig, options); + } else { + log.debug('No user-scope config found, skipping user pull'); + } + } catch (e) { + log.warn(`User-scope pull error: ${(e as Error).message}`); + } + } + + // 3. Project scope. + if (projectConfig) { + try { await pullForScope(projectConfig, options); + } catch (e) { + log.warn(`Project-scope pull error: ${(e as Error).message}`); } - } catch (e) { - log.warn(`Project-scope pull error: ${(e as Error).message}`); } - // 2.5. Reconcile built-in + team hooks for every scope. Runs OUTSIDE + // 3.5. Reconcile built-in + team hooks for the active scope only. Runs OUTSIDE // pullForScope so it bypasses the "Already synced" rev fast-path — this is // what self-heals new built-in hooks and applies hooks.yaml changes on every - // session start. - await reconcileHooksAllScopes(userConfig, projectConfig, options); + // session start. In project mode user is null, so user hooks are left alone. + await reconcileHooksAllScopes(projectMode ? null : userConfig, projectConfig, options); - // 3. Pull cross-team source skills (runs outside pullForScope to bypass fast-path) - if (userConfig) { + // 4. Pull cross-team source skills (always — even in project mode), against + // the active scope so deploys land in the right base dir. + const sourceConfig = projectConfig ?? userConfig; + if (sourceConfig) { try { const { pullSources } = await import('./source.js'); - await pullSources(userConfig, options); + await pullSources(sourceConfig, options); } catch (e) { log.debug(`Source pull skipped: ${(e as Error).message}`); } diff --git a/src/recall.ts b/src/recall.ts index ac21149..ee48952 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -218,8 +218,9 @@ async function loadOrBuildScopeIndex( /** * Handle `teamai recall `. * - * Searches both user and project scope learnings indexes, merges results, - * and displays ranked results. Auto-upvotes returned documents. + * Scope isolation (issue #73): queries the project-scope index when a project + * install is detected in cwd, otherwise the user-scope index. Displays ranked + * results and auto-upvotes returned documents. */ export async function recall( query: string, @@ -231,32 +232,39 @@ export async function recall( return; } - // Collect indexes from both scopes (project first — when both scopes share - // the same team repo, project wins dedup so results show project-local paths) + // Scope isolation (issue #73): when a project-scope install is detected in + // cwd, recall queries the project index ONLY. Otherwise it falls back to the + // user scope. The two scopes are never merged anymore. const scopeIndexes: Array<{ index: SearchIndex; scope: 'user' | 'project'; config: LocalConfig; learningsBase: string }> = []; - // Try project scope first (only when cwd has project-scope config) + let projectConfig: LocalConfig | null = null; try { - const projectConfig = await detectProjectConfig(); - if (projectConfig) { + projectConfig = await detectProjectConfig(); + } catch { + log.debug('recall: project scope detection failed'); + } + + if (projectConfig) { + // Project mode: project scope only. + try { const result = await loadOrBuildScopeIndex(projectConfig, 'project'); if (result && result.index.entries.length > 0) { scopeIndexes.push({ index: result.index, scope: 'project', config: projectConfig, learningsBase: result.learningsBase }); } + } catch { + log.debug('recall: project scope not available'); } - } catch { - log.debug('recall: project scope not available'); - } - - // Try user scope - try { - const { localConfig: userConfig } = await requireInit(); - const result = await loadOrBuildScopeIndex(userConfig, 'user'); - if (result && result.index.entries.length > 0) { - scopeIndexes.push({ index: result.index, scope: 'user', config: userConfig, learningsBase: result.learningsBase }); + } else { + // User mode: user scope only. + try { + const { localConfig: userConfig } = await requireInit(); + const result = await loadOrBuildScopeIndex(userConfig, 'user'); + if (result && result.index.entries.length > 0) { + scopeIndexes.push({ index: result.index, scope: 'user', config: userConfig, learningsBase: result.learningsBase }); + } + } catch { + log.debug('recall: user scope not available'); } - } catch { - log.debug('recall: user scope not available'); } const hasWiki = await pathExists(path.join(process.cwd(), 'teamwiki')); diff --git a/src/uninstall.ts b/src/uninstall.ts index d505c47..943a9e8 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -19,6 +19,7 @@ import { type GlobalOptions, type TeamaiConfig, type LocalConfig, + type Scope, } from './types.js'; import { EXCLUDED_RULE_NAMES } from './builtin-rules.js'; import { @@ -60,6 +61,8 @@ interface RemovalPlan { teamaiHomeExists: boolean; /** Managed-hooks manifest path (for team-hook cleanup). */ managedHooksPath: string; + /** Scope being uninstalled (issue #73: surfaced to the user). */ + scope: Scope; } // ─── Helpers ─────────────────────────────────────────── @@ -145,6 +148,7 @@ async function buildRemovalPlan( teamaiHome, teamaiHomeExists: await pathExists(teamaiHome), managedHooksPath: getManagedHooksPath(localConfig.scope, localConfig.projectRoot), + scope: localConfig.scope, }; // Discover team repo resource names for targeted removal @@ -251,7 +255,9 @@ function isPlanEmpty(plan: RemovalPlan): boolean { } function printSummary(plan: RemovalPlan): void { + const cn = plan.scope === 'project' ? '项目级' : '用户级'; console.log(''); + console.log(`⚠ 正在卸载 ${plan.scope} scope(${cn})— ${plan.teamaiHome}`); console.log('⚠ 以下 teamai 资源将被移除:'); console.log(''); @@ -475,6 +481,7 @@ export async function uninstall(opts: UninstallOptions): Promise { } console.log(''); + console.log('⚠ 正在卸载 user scope(用户级,未检测到有效配置,仅清理主目录)'); console.log('⚠ 将移除 TeamAI 主目录:'); console.log(` ${home}/`); console.log('');