From 82aa3873cb03af84010193e9c8117512a8ff77ad Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 1 Jul 2026 16:05:21 +0800 Subject: [PATCH] fix(scope): close remaining #85 scope-isolation gaps #73/#77 fixed recall's dual-scope merge and #91 fixed auto-recall's upvote scope, but issue #85 flagged four more spots where project vs user scope still leaked into each other: - hooks-cmd.ts: `hooks inject`/`hooks remove` reconciled the user-home copy of team hooks using the PROJECT's manifest instead of the user's own, diverging from pull.ts's per-scope reconcile and risking duplicate injection / wrongful cleanup of shared tool settings files. - tags.ts: subscribe/unsubscribe (and list/add/remove) always read and wrote ~/.teamai/config.yaml, ignoring project-scope installs. - contribute.ts: wrote new learnings to the team repo but never rebuilt the local search index, so `recall` couldn't see a contribution until the next `pull`. - types.ts/config.ts: getTeamaiHome()/resolveBaseDir() silently fell back to the user home directory when a project LocalConfig was missing `projectRoot` (e.g. a pre-migration config.yaml). Now detectProjectConfig()/loadLocalConfigForScope() backfill projectRoot from the directory the config was actually loaded from, and the scope helpers throw instead of silently degrading if it's ever still missing. Added an e2e suite (scope-isolation-e2e-issue85.test.ts) that drives the built CLI against real git fixtures to cover all four fixes end-to-end, plus unit coverage in hooks-cmd.test.ts/scope.test.ts. Closes #85 Co-Authored-By: Claude Sonnet 5 --- src/__tests__/hooks-cmd.test.ts | 17 ++ .../scope-isolation-e2e-issue85.test.ts | 253 ++++++++++++++++++ src/__tests__/scope.test.ts | 9 +- src/config.ts | 17 +- src/contribute.ts | 54 +++- src/hooks-cmd.ts | 35 ++- src/tags.ts | 40 ++- src/types.ts | 16 +- 8 files changed, 412 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/scope-isolation-e2e-issue85.test.ts diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index ce5c2d02..bf3e1d61 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -121,6 +121,17 @@ describe('hooksInject', () => { expect(mockedReconcile).toHaveBeenCalledTimes(2); expect(mockedReconcile).toHaveBeenNthCalledWith(1, mockTeamConfig.toolPaths, '/path/to/project', TEAM_DEFS, expect.any(String), { builtinOverride: undefined }); expect(mockedReconcile).toHaveBeenNthCalledWith(2, mockTeamConfig.toolPaths, '/home/testuser', TEAM_DEFS, expect.any(String), { builtinOverride: undefined }); + + // #85: the user-home target must be reconciled against the USER's own + // manifest, not the project's — otherwise `pull`'s per-scope reconcile + // (which always uses each scope's own manifest) diverges from `inject`, + // causing duplicate injection / wrongful cleanup of the shared file. + const projectManifestPath = mockedReconcile.mock.calls[0][3] as string; + const userManifestPath = mockedReconcile.mock.calls[1][3] as string; + expect(userManifestPath).not.toBe(projectManifestPath); + expect(projectManifestPath).toContain('/path/to/project'); + expect(userManifestPath).toContain('/home/testuser'); + expect(userManifestPath).not.toContain('/path/to/project'); }); }); @@ -248,6 +259,12 @@ describe('hooksRemove', () => { restoreHome(); } expect(mockedReconcile).toHaveBeenCalledTimes(2); + + // #85: same per-scope manifest requirement as `hooksInject`. + const projectManifestPath = mockedReconcile.mock.calls[0][3] as string; + const userManifestPath = mockedReconcile.mock.calls[1][3] as string; + expect(userManifestPath).not.toBe(projectManifestPath); + expect(userManifestPath).not.toContain('/path/to/project'); }); it('does not duplicate when HOME equals projectRoot', async () => { diff --git a/src/__tests__/scope-isolation-e2e-issue85.test.ts b/src/__tests__/scope-isolation-e2e-issue85.test.ts new file mode 100644 index 00000000..15522a0e --- /dev/null +++ b/src/__tests__/scope-isolation-e2e-issue85.test.ts @@ -0,0 +1,253 @@ +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 #85 end-to-end: remaining scope-isolation gaps ────────────── +// +// #73/#77 already fixed recall's dual-scope merge and #91 fixed auto-recall's +// upvote scope (see scope-isolation-e2e.test.ts). This file drives the real +// CLI binary against offline git fixtures to cover the four gaps that were +// still open after those landed: +// 1. `hooks inject`/`hooks remove` must track the user-home copy under the +// USER's own manifest, not the project's (else duplicate injection / +// wrongful cleanup of the shared tool settings file). +// 2. `tags subscribe`/`unsubscribe` must write to the active scope's +// config.yaml, not always ~/.teamai/config.yaml. +// 3. `contribute` must make a new learning immediately recallable, without +// requiring a separate `pull` to rebuild the index. +// 4. A project config.yaml missing `projectRoot` (pre-migration / hand +// edited) must still resolve to the project directory rather than +// silently degrading to the user's home. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +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 } }); +} + +const TEAM_YAML = [ + 'team: e2e-team', + 'repo: https://example.com/e2e.git', + 'provider: tgit', + 'toolPaths:', + ' claude:', + ' skills: .claude/skills', + ' rules: .claude/rules', + ' settings: .claude/settings.json', +].join('\n'); + +const HOOKS_YAML = [ + 'hooks:', + ' - id: e2e-marker-hook', + ' description: e2e marker hook', + ' event: Stop', + ' command: echo teamai-e2e-hook-marker', +].join('\n'); + +/** Create a bare-ish pushable git remote fixture with teamai.yaml + hooks.yaml. */ +function makeRemote(dir: string): void { + fs.mkdirSync(path.join(dir, 'learnings'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'teamai.yaml'), TEAM_YAML); + fs.writeFileSync(path.join(dir, 'hooks', 'hooks.yaml'), HOOKS_YAML); + git('init -q', dir); + // Allow `git push` to update the checked-out branch directly (contribute() + // pushes straight to master with no separate bare remote in this fixture). + git('config receive.denyCurrentBranch updateInstead', dir); + git('add -A', dir); + git('commit -q -m init', dir); +} + +describe('issue #85 remaining scope-isolation gaps (e2e)', () => { + let sandbox: string; + let homeDir: string; + let projectRoot: string; + let userConfigPath: string; + let userConfigBefore: string; + + beforeAll(() => { + 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-issue85-e2e-')); + homeDir = path.join(sandbox, 'home'); + projectRoot = path.join(sandbox, 'proj'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(homeDir, '.claude', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); + + // ── User-scope fixture (present only so we can prove project-scope + // commands do NOT write into it, except where #44 intentionally + // mirrors hooks into the user's home). ── + const userRemote = path.join(sandbox, 'user-remote'); + makeRemote(userRemote); + const userLocal = path.join(homeDir, '.teamai', 'team-repo'); + git(`clone -q "${userRemote}" "${userLocal}"`, sandbox); + userConfigPath = path.join(homeDir, '.teamai', 'config.yaml'); + fs.writeFileSync( + userConfigPath, + [ + 'repo:', + ` localPath: ${userLocal}`, + ` remote: ${userRemote}`, + 'username: ci-user', + 'updatePolicy: auto', + 'scope: user', + ].join('\n'), + ); + userConfigBefore = fs.readFileSync(userConfigPath, 'utf-8'); + + // ── Project-scope fixture. Deliberately omit `projectRoot:` from the + // on-disk config to exercise the detectProjectConfig() backfill + // (item 4) through every test below. ── + const projectRemote = path.join(sandbox, 'proj-remote'); + makeRemote(projectRemote); + 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', + ].join('\n'), + ); + }, 60_000); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + describe('projectRoot backfill (item 4)', () => { + it('recall resolves the project scope even though config.yaml has no projectRoot field', async () => { + const res = await runCLI(['recall', 'anything'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + expect(res.output).not.toContain('not initialized'); + }); + }); + + describe('hooks inject/remove manifest scoping (item 1)', () => { + it('inject creates BOTH a project manifest and a user manifest, each tracking its own directory', async () => { + const res = await runCLI(['hooks', 'inject'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + + const projectManifestPath = path.join(projectRoot, '.teamai', 'managed-hooks.json'); + const userManifestPath = path.join(homeDir, '.teamai', 'managed-hooks.json'); + expect(fs.existsSync(projectManifestPath)).toBe(true); + expect(fs.existsSync(userManifestPath)).toBe(true); + + const projectManifest = JSON.parse(fs.readFileSync(projectManifestPath, 'utf-8')); + const userManifest = JSON.parse(fs.readFileSync(userManifestPath, 'utf-8')); + expect(projectManifest.claude?.[0]?.command).toContain('teamai-e2e-hook-marker'); + expect(userManifest.claude?.[0]?.command).toContain('teamai-e2e-hook-marker'); + + // Both settings files actually received the hook (the #44 behavior of + // also writing into the user's home dir must still work). + const projectSettings = fs.readFileSync(path.join(projectRoot, '.claude', 'settings.json'), 'utf-8'); + const userSettings = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); + expect(projectSettings).toContain('teamai-e2e-hook-marker'); + expect(userSettings).toContain('teamai-e2e-hook-marker'); + }); + + it('re-running inject is idempotent — no duplicate hook entries in the user settings file', async () => { + const before = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); + const beforeCount = before.split('teamai-e2e-hook-marker').length - 1; + + const res = await runCLI(['hooks', 'inject'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + + const after = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); + const afterCount = after.split('teamai-e2e-hook-marker').length - 1; + expect(afterCount).toBe(beforeCount); + }); + + it('remove cleans up both the project and user copies', async () => { + const res = await runCLI(['hooks', 'remove'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + + const projectSettings = fs.readFileSync(path.join(projectRoot, '.claude', 'settings.json'), 'utf-8'); + const userSettings = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); + expect(projectSettings).not.toContain('teamai-e2e-hook-marker'); + expect(userSettings).not.toContain('teamai-e2e-hook-marker'); + }); + }); + + describe('tags subscribe/unsubscribe scope isolation (item 2)', () => { + it('subscribe writes to the project config, leaving the user config untouched', async () => { + const res = await runCLI(['tags', 'subscribe', 'hai'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + + const projectConfig = fs.readFileSync(path.join(projectRoot, '.teamai', 'config.yaml'), 'utf-8'); + expect(projectConfig).toContain('hai'); + + expect(fs.readFileSync(userConfigPath, 'utf-8')).toBe(userConfigBefore); + }); + + it('unsubscribe removes it again from the project config', async () => { + const res = await runCLI(['tags', 'unsubscribe', 'hai'], { HOME: homeDir }, projectRoot); + expect(res.code, res.output).toBe(0); + + const projectConfig = fs.readFileSync(path.join(projectRoot, '.teamai', 'config.yaml'), 'utf-8'); + expect(projectConfig).not.toContain('subscribedTags'); + expect(fs.readFileSync(userConfigPath, 'utf-8')).toBe(userConfigBefore); + }); + }); + + describe('contribute rebuilds the index immediately (item 3)', () => { + it('a newly contributed learning is recallable without a separate `pull`', async () => { + const docPath = path.join(sandbox, 'new-learning.md'); + fs.writeFileSync( + docPath, + '---\ntitle: "Zzyzx Contribution Marker"\nauthor: ci\ndate: 2026-07-01\ntags: [e2e]\n---\n\nUnique marker content for the e2e contribute test: Zzyzx Contribution Marker.\n', + ); + + const res = await runCLI( + ['contribute', '--file', docPath, '--title', 'Zzyzx Contribution Marker'], + { HOME: homeDir }, + projectRoot, + ); + expect(res.code, res.output).toBe(0); + + const recallRes = await runCLI(['recall', 'Zzyzx Contribution Marker'], { HOME: homeDir }, projectRoot); + expect(recallRes.code, recallRes.output).toBe(0); + expect(recallRes.output).toContain('Zzyzx Contribution Marker'); + }, 20_000); + }); +}); diff --git a/src/__tests__/scope.test.ts b/src/__tests__/scope.test.ts index 0a321fdd..ac4dd321 100644 --- a/src/__tests__/scope.test.ts +++ b/src/__tests__/scope.test.ts @@ -113,7 +113,7 @@ scope: 'project', expect(resolveBaseDir(config)).toBe('/Users/testuser/my-project'); }); - it('should fallback to HOME if project scope without projectRoot', () => { + it('should throw instead of silently falling back to HOME if project scope without projectRoot (#85)', () => { const config: LocalConfig = { repo: { localPath: '/tmp/repo', remote: 'https://example.com' }, username: 'test', @@ -121,7 +121,7 @@ scope: 'project', additionalRoles: [], scope: 'project', }; - expect(resolveBaseDir(config)).toBe('/Users/testuser'); + expect(() => resolveBaseDir(config)).toThrow(/projectRoot is missing/); }); }); @@ -138,9 +138,8 @@ describe('getTeamaiHome', () => { expect(result).toBe('/Users/test/proj/.teamai'); }); - it('should fallback to ~/.teamai if project scope without projectRoot', () => { - const result = getTeamaiHome('project'); - expect(result).toBe(`${process.env.HOME}/.teamai`); + it('should throw instead of silently falling back to ~/.teamai if project scope without projectRoot (#85)', () => { + expect(() => getTeamaiHome('project')).toThrow(/projectRoot is missing/); }); }); diff --git a/src/config.ts b/src/config.ts index d81b4cd0..5dae61e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -137,7 +137,15 @@ export async function loadLocalConfigForScope( try { const raw = YAML.parse(content); const parsed = LocalConfigSchema.parse(raw); - return await migrateLegacyRoleConfig(parsed, configPath); + // Config files written before `projectRoot` was added to the schema (or + // hand-edited) may be missing it. We already know the project root — it's + // the directory this config was loaded for — so backfill it instead of + // letting getTeamaiHome()/resolveBaseDir() silently fall back to the user + // home directory later (#85). + const withProjectRoot = scope === 'project' && projectRoot && !parsed.projectRoot + ? { ...parsed, projectRoot } + : parsed; + return await migrateLegacyRoleConfig(withProjectRoot, configPath); } catch (e) { log.error(`Invalid ${scope} config at ${configPath}: ${(e as Error).message}`); return null; @@ -187,8 +195,11 @@ export async function detectProjectConfig(cwd?: string): Promise { + const repoPath = localConfig.repo.localPath; + const learningsRepoDir = path.join(repoPath, 'learnings'); + const docsRepoDir = path.join(repoPath, 'docs'); + const rulesRepoDir = path.join(repoPath, 'rules'); + const skillsRepoDir = path.join(repoPath, 'skills'); + const votesDir = path.join(repoPath, 'votes'); + + // user scope mirrors learnings/ into ~/.teamai/learnings/ (legacy behavior, + // same as pull.ts); project scope indexes the repo's learnings/ directly. + let effectiveLearningsDir: string | undefined; + if (localConfig.scope === 'user') { + if (await pathExists(learningsRepoDir)) { + await fse.copy(learningsRepoDir, LEARNINGS_LOCAL_DIR, { + overwrite: true, + filter: (src: string) => !path.basename(src).startsWith('.'), + }); + } + effectiveLearningsDir = (await pathExists(LEARNINGS_LOCAL_DIR)) ? LEARNINGS_LOCAL_DIR : undefined; + } else { + effectiveLearningsDir = (await pathExists(learningsRepoDir)) ? learningsRepoDir : undefined; + } + + const teamaiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + const indexPath = path.join(teamaiHome, 'search-index.json'); + const { buildIndex } = await import('./utils/search-index.js'); + await buildIndex({ + learningsDir: effectiveLearningsDir, + docsDir: (await pathExists(docsRepoDir)) ? docsRepoDir : undefined, + rulesDir: (await pathExists(rulesRepoDir)) ? rulesRepoDir : undefined, + skillsDir: (await pathExists(skillsRepoDir)) ? skillsRepoDir : undefined, + votesDir: (await pathExists(votesDir)) ? votesDir : undefined, + indexPath, + }); +} // ─── Contribute data flow ───────────────────────────────── // @@ -116,6 +160,14 @@ export async function contribute( log.debug('contribute: pull failed, continuing with local state'); } + // Rebuild the index now so recall can find this contribution immediately, + // independent of whether the push below succeeds. + try { + await rebuildIndexAfterContribute(localConfig); + } catch (e) { + log.debug(`contribute: index rebuild skipped: ${(e as Error).message}`); + } + // Push directly to master with timeout const commitMsg = `[teamai] Contribute session knowledge from ${username}`; const pushPromise = pushRepoDirectly( diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index f8887320..9ea75827 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -15,18 +15,37 @@ interface HookListRow { settingsPath: string; } -function resolveHookBaseDirs(localConfig: LocalConfig): string[] { +interface HookScopeTarget { + baseDir: string; + manifestPath: string; +} + +/** + * Resolve every (baseDir, manifestPath) pair that hook reconciliation must + * touch for this config's scope. Project scope also targets the user's home + * directory (so hooks fire from subdirectories, see #44) — but that target + * MUST use the user's own manifest, not the project's. Attributing the + * user-home write to the project's manifest is what let `hooks inject` + * diverge from `pull`'s per-scope reconcile (each scope's own baseDir + + * manifest, see reconcileHooksAllScopes in pull.ts) and caused duplicate + * injection / wrongful cleanup of ~/.cursor/hooks.json et al. (#85). + */ +function resolveHookScopeTargets(localConfig: LocalConfig): HookScopeTarget[] { const baseDir = resolveBaseDir(localConfig) ?? ''; + const manifestPath = getManagedHooksPath(localConfig.scope, localConfig.projectRoot); if (localConfig.scope !== 'project') { - return [baseDir]; + return [{ baseDir, manifestPath }]; } const userBaseDir = process.env.HOME ?? ''; if (!userBaseDir || userBaseDir === baseDir) { - return [baseDir]; + return [{ baseDir, manifestPath }]; } - return [baseDir, userBaseDir]; + return [ + { baseDir, manifestPath }, + { baseDir: userBaseDir, manifestPath: getManagedHooksPath('user') }, + ]; } function formatDisplayPath(settingsPath: string): string { @@ -70,8 +89,7 @@ export async function hooksInject(options: GlobalOptions): Promise { auto: false, silent: options.silent, }); - const manifestPath = getManagedHooksPath(localConfig.scope, localConfig.projectRoot); - for (const baseDir of resolveHookBaseDirs(localConfig)) { + for (const { baseDir, manifestPath } of resolveHookScopeTargets(localConfig)) { await reconcileHooksToAllTools(teamConfig.toolPaths, baseDir, teamDefs, manifestPath, { builtinOverride: builtin }); } @@ -87,7 +105,7 @@ export async function hooksInject(options: GlobalOptions): Promise { */ export async function hooksList(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); - const baseDirs = resolveHookBaseDirs(localConfig); + const baseDirs = resolveHookScopeTargets(localConfig).map((t) => t.baseDir); const rows: HookListRow[] = []; for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { @@ -137,8 +155,7 @@ export async function hooksList(_options: GlobalOptions): Promise { export async function hooksRemove(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); - const manifestPath = getManagedHooksPath(localConfig.scope, localConfig.projectRoot); - for (const baseDir of resolveHookBaseDirs(localConfig)) { + for (const { baseDir, manifestPath } of resolveHookScopeTargets(localConfig)) { await reconcileHooksToAllTools(teamConfig.toolPaths, baseDir, [], manifestPath, { removeAll: true }); } diff --git a/src/tags.ts b/src/tags.ts index dd341c34..e2c62336 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -1,17 +1,39 @@ import path from 'node:path'; import YAML from 'yaml'; -import { requireInit, saveLocalConfig } from './config.js'; +import { requireInit, saveLocalConfig, saveLocalConfigForScope, detectProjectConfig } from './config.js'; import { loadTagsConfig, collectTagStats, saveTagsConfig } from './utils/tags.js'; import { log } from './utils/logger.js'; import { readFileSafe } from './utils/fs.js'; -import type { GlobalOptions, TagsConfig } from './types.js'; +import type { GlobalOptions, LocalConfig, TagsConfig } from './types.js'; + +/** + * Resolve the active scope for tag operations: project scope when the cwd has + * a project-scope install, otherwise user scope. Mirrors recall.ts/contribute.ts + * so `tags list/subscribe/unsubscribe` agree with what `recall` actually queries + * instead of always reading/writing ~/.teamai/config.yaml (#85). + */ +async function resolveTagsScope(): Promise { + const projectConfig = await detectProjectConfig(); + return projectConfig ?? (await requireInit()).localConfig; +} + +/** + * Persist a LocalConfig back to whichever scope it was loaded from. + */ +async function saveTagsScopeConfig(localConfig: LocalConfig): Promise { + if (localConfig.scope === 'project') { + await saveLocalConfigForScope(localConfig, 'project', localConfig.projectRoot); + } else { + await saveLocalConfig(localConfig); + } +} /** * List all available tags from the team repo's tags.yaml. * Shows tag name, skill count, and rule count. */ export async function tagsList(options: GlobalOptions): Promise { - const { localConfig } = await requireInit(); + const localConfig = await resolveTagsScope(); const tagsConfig = await loadTagsConfig(localConfig.repo.localPath); if (!tagsConfig) { @@ -67,7 +89,7 @@ export async function tagsSubscribe(tags: string[], options: GlobalOptions): Pro return; } - const { localConfig } = await requireInit(); + const localConfig = await resolveTagsScope(); const existing = new Set(localConfig.subscribedTags ?? []); const newTags: string[] = []; @@ -87,7 +109,7 @@ export async function tagsSubscribe(tags: string[], options: GlobalOptions): Pro ...localConfig, subscribedTags: [...existing].sort(), }; - await saveLocalConfig(updatedConfig); + await saveTagsScopeConfig(updatedConfig); log.success(`Subscribed to: ${newTags.join(', ')}`); log.dim('Run `teamai pull` to sync matching resources.'); } @@ -101,7 +123,7 @@ export async function tagsUnsubscribe(tags: string[], options: GlobalOptions): P return; } - const { localConfig } = await requireInit(); + const localConfig = await resolveTagsScope(); const existing = new Set(localConfig.subscribedTags ?? []); const removed: string[] = []; @@ -121,7 +143,7 @@ export async function tagsUnsubscribe(tags: string[], options: GlobalOptions): P ...localConfig, subscribedTags: existing.size > 0 ? [...existing].sort() : undefined, }; - await saveLocalConfig(updatedConfig); + await saveTagsScopeConfig(updatedConfig); log.success(`Unsubscribed from: ${removed.join(', ')}`); log.dim('Run `teamai pull` to clean up filtered-out resources.'); } @@ -141,7 +163,7 @@ export async function tagsAdd( return; } - const { localConfig } = await requireInit(); + const localConfig = await resolveTagsScope(); const repoPath = localConfig.repo.localPath; let tagsConfig = await loadTagsConfig(repoPath); @@ -180,7 +202,7 @@ export async function tagsRemove( return; } - const { localConfig } = await requireInit(); + const localConfig = await resolveTagsScope(); const repoPath = localConfig.repo.localPath; const tagsConfig = await loadTagsConfig(repoPath); diff --git a/src/types.ts b/src/types.ts index 99a3963e..5b328043 100644 --- a/src/types.ts +++ b/src/types.ts @@ -758,7 +758,13 @@ export type CultureFrontmatter = z.infer; * - project scope → localConfig.projectRoot (e.g. /Users/xxx/my-project) */ export function resolveBaseDir(localConfig: LocalConfig): string { - if (localConfig.scope === 'project' && localConfig.projectRoot) { + if (localConfig.scope === 'project') { + if (!localConfig.projectRoot) { + throw new Error( + 'resolveBaseDir: localConfig.scope is "project" but projectRoot is missing — ' + + 'refusing to silently fall back to the user home directory. Re-run `teamai init` in this project.', + ); + } return localConfig.projectRoot; } return process.env.HOME!; @@ -770,7 +776,13 @@ export function resolveBaseDir(localConfig: LocalConfig): string { * - project scope → /.teamai */ export function getTeamaiHome(scope: Scope, projectRoot?: string): string { - if (scope === 'project' && projectRoot) { + if (scope === 'project') { + if (!projectRoot) { + throw new Error( + 'getTeamaiHome: scope is "project" but projectRoot is missing — ' + + 'refusing to silently fall back to the user home directory.', + ); + } return path.join(projectRoot, '.teamai'); } return path.join(process.env.HOME ?? '', '.teamai');