diff --git a/src/__tests__/wiki-bugfix.test.ts b/src/__tests__/wiki-bugfix.test.ts index 71998a4..39e5fff 100644 --- a/src/__tests__/wiki-bugfix.test.ts +++ b/src/__tests__/wiki-bugfix.test.ts @@ -12,6 +12,11 @@ * `REMOVABLE_TYPES` didn't include 'wiki'. * BUG #4 — `teamai pull` didn't honor wiki tombstones: a `wiki/.removed` * entry in the team repo never deleted the local copy. + * + * NOTE (refactored for shared wiki location): + * With the wiki refactoring, BUG #4 no longer iterates through per-tool + * wiki directories. Instead, pull.ts has a dedicated wiki tombstone cleanup + * block that removes pages from the shared wiki location (~/.teamai/wiki/). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -38,10 +43,9 @@ vi.mock('../utils/logger.js', () => ({ import { filterExistingTopLevelPaths } from '../push.js'; import { WikiHandler } from '../resources/wiki.js'; -import { ResourceHandler } from '../resources/base.js'; -import { resolveBaseDir } from '../types.js'; import { remove as removeFile } from '../utils/fs.js'; -import type { TeamaiConfig, LocalConfig, ResourceType } from '../types.js'; +import { getTeamaiHome } from '../types.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; // ───────────────────────────────────────────────────────────────────────── // BUG #1 — filterExistingTopLevelPaths @@ -107,6 +111,8 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => { try { const repoPath = path.join(tmpDir, 'team-repo'); await fse.ensureDir(path.join(repoPath, 'wiki')); + const teamaiHome = path.join(tmpDir, '.teamai'); + await fse.ensureDir(path.join(teamaiHome, 'wiki')); const teamConfig: TeamaiConfig = { team: 'test', @@ -124,7 +130,6 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => { claude: { skills: '.claude/skills', rules: '.claude/rules', - wiki: '.claude/wiki', }, }, } as TeamaiConfig; @@ -133,7 +138,8 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => { username: 'tester', updatePolicy: 'auto', additionalRoles: [], - scope: 'user', + scope: 'project', + projectRoot: tmpDir, } as LocalConfig; const removed = await handler.removeItem('entities/alpha', teamConfig, localConfig); @@ -153,18 +159,18 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => { // ───────────────────────────────────────────────────────────────────────── describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => { let tmpDir: string; - let homeDir: string; let repoPath: string; + let teamaiHome: string; let teamConfig: TeamaiConfig; let localConfig: LocalConfig; beforeEach(async () => { tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pullts-')); - homeDir = path.join(tmpDir, 'home'); repoPath = path.join(tmpDir, 'team-repo'); + teamaiHome = path.join(tmpDir, '.teamai'); + await fse.ensureDir(path.join(repoPath, 'wiki')); - await fse.ensureDir(path.join(homeDir, '.claude', 'wiki', 'entities')); - vi.stubEnv('HOME', homeDir); + await fse.ensureDir(path.join(teamaiHome, 'wiki', 'entities')); teamConfig = { team: 'test', @@ -182,7 +188,6 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => { claude: { skills: '.claude/skills', rules: '.claude/rules', - wiki: '.claude/wiki', }, }, } as TeamaiConfig; @@ -192,20 +197,20 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => { username: 'tester', updatePolicy: 'auto', additionalRoles: [], - scope: 'user', + scope: 'project', + projectRoot: tmpDir, } as LocalConfig; }); afterEach(async () => { - vi.unstubAllEnvs(); await fse.remove(tmpDir); }); /** - * Reproduces the exact loop pull.ts runs after the fix (tombstoneTypes - * now includes wiki with toolPathField='wiki'). We keep the check at - * this layer instead of end-to-end because pullForScope touches git, - * spinners, and file-system fetches that are costly to mock. + * Reproduces the new wiki tombstone cleanup logic in pull.ts. + * With the refactoring, wiki tombstones are cleaned up from the shared + * ~/.teamai/wiki/ location (or /.teamai/wiki/ for project scope) + * instead of iterating through per-tool directories. */ it('removes local wiki pages that are listed in wiki/.removed', async () => { // Simulate a team member running `teamai remove wiki entities/alpha`: @@ -214,36 +219,21 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => { path.join(repoPath, 'wiki', '.removed'), 'entities/alpha\n', ); - // Local copy that should be cleaned up. - const localAlpha = path.join(homeDir, '.claude', 'wiki', 'entities', 'alpha.md'); + // Local copy in shared wiki location that should be cleaned up. + const localAlpha = path.join(teamaiHome, 'wiki', 'entities', 'alpha.md'); await fse.writeFile(localAlpha, '# alpha'); - // Mimic pull.ts tombstone loop with the fixed config - const tombstoneTypes: { - type: ResourceType; - ext?: string; - toolPathField: 'rules' | 'skills' | 'wiki'; - }[] = [ - { type: 'rules', ext: '.md', toolPathField: 'rules' }, - { type: 'skills', toolPathField: 'skills' }, - { type: 'wiki', ext: '.md', toolPathField: 'wiki' }, - ]; - + // Mimic pull.ts wiki tombstone cleanup with the new architecture const handler = new WikiHandler(); - const baseDir = resolveBaseDir(localConfig); + const wikiTombstones = await handler.readTombstones(localConfig); - for (const { type, ext, toolPathField } of tombstoneTypes) { - if (type !== 'wiki') continue; - const tombstones = await handler.readTombstones(localConfig); - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { - const dir = toolPath[toolPathField]; - if (!dir) continue; - if (!await ResourceHandler.isToolInstalled(dir, baseDir)) continue; - for (const name of tombstones) { - const p = path.join(baseDir, dir, ext ? `${name}${ext}` : name); - if (await fse.pathExists(p)) { - await removeFile(p); - } + if (wikiTombstones.size > 0) { + const sharedWikiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + const wikiDir = path.join(sharedWikiHome, 'wiki'); + for (const name of wikiTombstones) { + const wikiPath = path.join(wikiDir, `${name}.md`); + if (await fse.pathExists(wikiPath)) { + await removeFile(wikiPath); } } } @@ -256,21 +246,22 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => { path.join(repoPath, 'wiki', '.removed'), 'entities/alpha\n', ); - const localAlpha = path.join(homeDir, '.claude', 'wiki', 'entities', 'alpha.md'); - const localBeta = path.join(homeDir, '.claude', 'wiki', 'entities', 'beta.md'); + const localAlpha = path.join(teamaiHome, 'wiki', 'entities', 'alpha.md'); + const localBeta = path.join(teamaiHome, 'wiki', 'entities', 'beta.md'); await fse.writeFile(localAlpha, '# alpha'); await fse.writeFile(localBeta, '# beta'); const handler = new WikiHandler(); - const baseDir = resolveBaseDir(localConfig); - const tombstones = await handler.readTombstones(localConfig); + const wikiTombstones = await handler.readTombstones(localConfig); - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { - if (!toolPath.wiki) continue; - if (!await ResourceHandler.isToolInstalled(toolPath.wiki, baseDir)) continue; - for (const name of tombstones) { - const p = path.join(baseDir, toolPath.wiki, `${name}.md`); - if (await fse.pathExists(p)) await removeFile(p); + if (wikiTombstones.size > 0) { + const sharedWikiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + const wikiDir = path.join(sharedWikiHome, 'wiki'); + for (const name of wikiTombstones) { + const wikiPath = path.join(wikiDir, `${name}.md`); + if (await fse.pathExists(wikiPath)) { + await removeFile(wikiPath); + } } } diff --git a/src/__tests__/wiki-handler.test.ts b/src/__tests__/wiki-handler.test.ts index 03c1d70..cd5c63e 100644 --- a/src/__tests__/wiki-handler.test.ts +++ b/src/__tests__/wiki-handler.test.ts @@ -19,20 +19,18 @@ import type { TeamaiConfig, LocalConfig } from '../types.js'; describe('WikiHandler', () => { let tmpDir: string; - let homeDir: string; + let teamaiHome: string; let handler: WikiHandler; let teamConfig: TeamaiConfig; let localConfig: LocalConfig; beforeEach(async () => { tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-wiki-test-')); - homeDir = path.join(tmpDir, 'home'); + teamaiHome = path.join(tmpDir, '.teamai'); const repoPath = path.join(tmpDir, 'team-repo'); await fse.ensureDir(path.join(repoPath, 'wiki')); - await fse.ensureDir(path.join(homeDir, '.claude-internal', 'wiki')); - - vi.stubEnv('HOME', homeDir); + await fse.ensureDir(path.join(teamaiHome, 'wiki')); handler = new WikiHandler(); @@ -49,14 +47,14 @@ describe('WikiHandler', () => { env: { injectShellProfile: true }, }, toolPaths: { - 'claude-internal': { - skills: '.claude-internal/skills', - rules: '.claude-internal/rules', - wiki: '.claude-internal/wiki', + claude: { + skills: '.claude/skills', + rules: '.claude/rules', }, }, }; + // Use project scope with projectRoot so getTeamaiHome returns the test tmpDir localConfig = { repo: { localPath: repoPath, @@ -65,19 +63,19 @@ describe('WikiHandler', () => { username: 'testuser', updatePolicy: 'auto', additionalRoles: [], - scope: 'user', + scope: 'project', + projectRoot: tmpDir, }; }); afterEach(async () => { - vi.unstubAllEnvs(); await fse.remove(tmpDir); }); describe('scanLocalForPush', () => { it('detects new wiki page not in team repo', async () => { - // Create local wiki page - const localWiki = path.join(homeDir, '.claude-internal', 'wiki'); + // Create local wiki page in shared location + const localWiki = path.join(teamaiHome, 'wiki'); await fse.ensureDir(path.join(localWiki, 'entities')); await fse.writeFile( path.join(localWiki, 'entities', 'test-module.md'), @@ -102,7 +100,7 @@ describe('WikiHandler', () => { ); // Create same page locally with different content - const localWiki = path.join(homeDir, '.claude-internal', 'wiki'); + const localWiki = path.join(teamaiHome, 'wiki'); await fse.ensureDir(path.join(localWiki, 'entities')); await fse.writeFile( path.join(localWiki, 'entities', 'test-module.md'), @@ -124,7 +122,7 @@ describe('WikiHandler', () => { await fse.ensureDir(path.join(teamWiki, 'entities')); await fse.writeFile(path.join(teamWiki, 'entities', 'test-module.md'), content); - const localWiki = path.join(homeDir, '.claude-internal', 'wiki'); + const localWiki = path.join(teamaiHome, 'wiki'); await fse.ensureDir(path.join(localWiki, 'entities')); await fse.writeFile(path.join(localWiki, 'entities', 'test-module.md'), content); @@ -134,7 +132,7 @@ describe('WikiHandler', () => { }); it('excludes _metadata.json from push', async () => { - const localWiki = path.join(homeDir, '.claude-internal', 'wiki'); + const localWiki = path.join(teamaiHome, 'wiki'); await fse.writeFile( path.join(localWiki, '_metadata.json'), '{"version":1}', @@ -185,7 +183,7 @@ describe('WikiHandler', () => { describe('pushItem', () => { it('copies wiki page to team repo', async () => { - const localWiki = path.join(homeDir, '.claude-internal', 'wiki'); + const localWiki = path.join(teamaiHome, 'wiki'); await fse.ensureDir(path.join(localWiki, 'entities')); const sourcePath = path.join(localWiki, 'entities', 'test.md'); await fse.writeFile(sourcePath, '# Test'); @@ -209,7 +207,7 @@ describe('WikiHandler', () => { }); describe('pullItem', () => { - it('copies wiki page to local tool directory', async () => { + it('copies wiki page to shared wiki location', async () => { const teamWiki = path.join(localConfig.repo.localPath, 'wiki'); await fse.ensureDir(path.join(teamWiki, 'entities')); const sourcePath = path.join(teamWiki, 'entities', 'test.md'); @@ -226,7 +224,7 @@ describe('WikiHandler', () => { localConfig, ); - const dest = path.join(homeDir, '.claude-internal', 'wiki', 'entities', 'test.md'); + const dest = path.join(teamaiHome, 'wiki', 'entities', 'test.md'); expect(await fse.pathExists(dest)).toBe(true); expect(await fse.readFile(dest, 'utf-8')).toBe('# Test from team'); }); @@ -234,7 +232,7 @@ describe('WikiHandler', () => { describe('rebuildMetadata', () => { it('rebuilds metadata from wiki pages', async () => { - const wikiDir = path.join(tmpDir, 'test-wiki'); + const wikiDir = path.join(teamaiHome, 'wiki'); await fse.ensureDir(path.join(wikiDir, 'entities')); await fse.writeFile( path.join(wikiDir, 'entities', 'foo.md'), @@ -245,7 +243,7 @@ describe('WikiHandler', () => { '---\ntitle: Bar\ncategory: entity\ntags: [util]\nupdated: 2026-04-08\n---\n\n# Bar\n\n## Related\n\n## Backlinks\n', ); - await WikiHandler.rebuildMetadata(wikiDir); + await WikiHandler.rebuildMetadata(localConfig); const metadataPath = path.join(wikiDir, '_metadata.json'); expect(await fse.pathExists(metadataPath)).toBe(true); diff --git a/src/pull.ts b/src/pull.ts index ea004b4..259c228 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -417,11 +417,10 @@ async function pullForScope( const tombstoneTypes: { type: ResourceType; ext?: string; - toolPathField: 'rules' | 'skills' | 'wiki'; + toolPathField: 'rules' | 'skills'; }[] = [ { type: 'rules', ext: '.md', toolPathField: 'rules' }, { type: 'skills', toolPathField: 'skills' }, - { type: 'wiki', ext: '.md', toolPathField: 'wiki' }, ]; const baseDir = resolveBaseDir(localConfig); @@ -445,6 +444,25 @@ async function pullForScope( } } + // Wiki tombstone cleanup: wiki is now in shared location, not per-tool + try { + const wikiHandler = getHandler('wiki'); + const wikiTombstones = await wikiHandler.readTombstones(localConfig); + if (wikiTombstones.size > 0) { + const teamaiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + const wikiDir = path.join(teamaiHome, 'wiki'); + for (const name of wikiTombstones) { + const wikiPath = path.join(wikiDir, `${name}.md`); + if (await pathExists(wikiPath)) { + await remove(wikiPath); + log.debug(`[${scopeLabel}] Cleaned up tombstoned wiki ${name} from shared wiki`); + } + } + } + } catch (e) { + log.debug(`[${scopeLabel}] Wiki tombstone cleanup skipped: ${(e as Error).message}`); + } + if (roleContext) { await cleanupInactiveNamespaceSkills( freshConfig, diff --git a/src/resources/wiki.ts b/src/resources/wiki.ts index 43df0d8..3973f85 100644 --- a/src/resources/wiki.ts +++ b/src/resources/wiki.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; -import { resolveBaseDir } from '../types.js'; +import { resolveBaseDir, getTeamaiHome } from '../types.js'; import { listFilesRecursive, pathExists, @@ -9,7 +9,6 @@ import { remove, ensureDir, readFileSafe, - getFileMtime, fileContentEqual, } from '../utils/fs.js'; import { log } from '../utils/logger.js'; @@ -18,13 +17,20 @@ import { log } from '../utils/logger.js'; /** * Wiki resource handler. * - * Unlike skills (directory-based), wiki pages are individual .md files - * stored in category subdirectories (entities/, concepts/, etc.). - * The handler treats the entire wiki/ tree as flat files keyed by - * their relative path within wiki/ (e.g. "entities/message-builder"). + * Wiki pages are now stored in a centralized shared location: + * - User scope → ~/.teamai/wiki/ + * - Project scope → /.teamai/wiki/ + * + * Individual pages are stored as .md files in category subdirectories + * (entities/, concepts/, etc.). The handler treats the entire wiki/ tree + * as flat files keyed by their relative path within wiki/. * * _metadata.json is NOT synced via push/pull — it is rebuilt locally * by the /wiki skill after each pull. + * + * BREAKING CHANGE: Wiki is no longer distributed to individual tool + * directories (e.g., ~/.claude/wiki/, ~/.cursor/wiki/). All tools now + * reference the single shared wiki at ~/.teamai/wiki/. */ export class WikiHandler extends ResourceHandler { readonly type = 'wiki' as const; @@ -55,7 +61,17 @@ export class WikiHandler extends ResourceHandler { } /** - * Scan local AI tool wiki directories for pages that are new or modified + * Get the shared wiki directory for the current scope. + * - User scope → ~/.teamai/wiki/ + * - Project scope → /.teamai/wiki/ + */ + private static getSharedWikiDir(localConfig: LocalConfig): string { + const teamaiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + return path.join(teamaiHome, 'wiki'); + } + + /** + * Scan the shared wiki directory for pages that are new or modified * compared to the team repo. */ async scanLocalForPush( @@ -76,76 +92,44 @@ export class WikiHandler extends ResourceHandler { } } - const tombstones = await this.readTombstones(localConfig); + const sharedWikiDir = WikiHandler.getSharedWikiDir(localConfig); + if (!await pathExists(sharedWikiDir)) return []; - // Collect best candidate for each page across all tool directories - const candidates = new Map< - string, - { sourcePath: string; mtime: number; status: ResourceItemStatus } - >(); + const tombstones = await this.readTombstones(localConfig); + const items: ResourceItem[] = []; - for (const [__, toolPath] of Object.entries(teamConfig.toolPaths)) { - if (!toolPath.wiki) continue; - const wikiDir = path.join(resolveBaseDir(localConfig), toolPath.wiki); - if (!await pathExists(wikiDir)) continue; + const files = await listFilesRecursive(sharedWikiDir); + for (const file of files) { + if (!WikiHandler.isWikiPage(file)) continue; - const files = await listFilesRecursive(wikiDir); - for (const file of files) { - if (!WikiHandler.isWikiPage(file)) continue; - - const name = WikiHandler.pathToName(file); - if (tombstones.has(name)) continue; - - const localFilePath = path.join(wikiDir, file); - - if (teamPages.has(name)) { - const teamFilePath = teamPages.get(name)!; - const equal = await fileContentEqual(localFilePath, teamFilePath); - if (equal) continue; - - const mtime = await getFileMtime(localFilePath); - const existing = candidates.get(name); - if (!existing || mtime > existing.mtime) { - candidates.set(name, { - sourcePath: localFilePath, - mtime, - status: 'modified', - }); - } - } else { - const existing = candidates.get(name); - if (!existing) { - const mtime = await getFileMtime(localFilePath); - candidates.set(name, { - sourcePath: localFilePath, - mtime, - status: 'new', - }); - } else if (existing.status === 'new') { - const mtime = await getFileMtime(localFilePath); - if (mtime > existing.mtime) { - candidates.set(name, { - sourcePath: localFilePath, - mtime, - status: 'new', - }); - } - } - } + const name = WikiHandler.pathToName(file); + if (tombstones.has(name)) continue; + + const localFilePath = path.join(sharedWikiDir, file); + + if (teamPages.has(name)) { + const teamFilePath = teamPages.get(name)!; + const equal = await fileContentEqual(localFilePath, teamFilePath); + if (equal) continue; + + items.push({ + name, + type: 'wiki', + sourcePath: localFilePath, + relativePath: `wiki/${name}.md`, + status: 'modified', + }); + } else { + items.push({ + name, + type: 'wiki', + sourcePath: localFilePath, + relativePath: `wiki/${name}.md`, + status: 'new', + }); } } - const items: ResourceItem[] = []; - for (const [name, candidate] of candidates) { - items.push({ - name, - type: 'wiki', - sourcePath: candidate.sourcePath, - relativePath: `wiki/${name}.md`, - status: candidate.status, - }); - } - return items; } @@ -185,50 +169,38 @@ export class WikiHandler extends ResourceHandler { } /** - * Pull a wiki page from team repo to all configured AI tool wiki directories. + * Pull a wiki page from team repo to the shared wiki directory. */ async pullItem( item: ResourceItem, - teamConfig: TeamaiConfig, + _teamConfig: TeamaiConfig, localConfig: LocalConfig, ): Promise { - const baseDir = resolveBaseDir(localConfig); - - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { - if (!toolPath.wiki) continue; - - if (!await ResourceHandler.isToolInstalled(toolPath.wiki, baseDir)) { - log.debug(`Skipping wiki sync for ${tool}: tool not installed`); - continue; - } - - const dest = path.join(baseDir, toolPath.wiki, `${item.name}.md`); - await ensureDir(path.dirname(dest)); - try { - await copyFile(item.sourcePath, dest); - log.debug(`Synced wiki page ${item.name} → ${tool}`); - } catch (e) { - log.warn( - `Failed to sync wiki page ${item.name} to ${tool}: ${(e as Error).message}`, - ); - } + const sharedWikiDir = WikiHandler.getSharedWikiDir(localConfig); + const dest = path.join(sharedWikiDir, `${item.name}.md`); + await ensureDir(path.dirname(dest)); + try { + await copyFile(item.sourcePath, dest); + log.debug(`Synced wiki page ${item.name} → shared wiki`); + } catch (e) { + log.warn( + `Failed to sync wiki page ${item.name}: ${(e as Error).message}`, + ); } } /** - * Remove a wiki page from the team repo and all local AI tool wiki directories. + * Remove a wiki page from the team repo and the shared wiki directory. */ async removeItem( name: string, - teamConfig: TeamaiConfig, + _teamConfig: TeamaiConfig, localConfig: LocalConfig, ): Promise { const removed: string[] = []; - const baseDir = resolveBaseDir(localConfig); - const fileName = `${name}.md`; // Remove from team repo - const teamFile = path.join(localConfig.repo.localPath, 'wiki', fileName); + const teamFile = path.join(localConfig.repo.localPath, 'wiki', `${name}.md`); if (await pathExists(teamFile)) { await remove(teamFile); removed.push(teamFile); @@ -236,25 +208,24 @@ export class WikiHandler extends ResourceHandler { await this.addTombstone(name, localConfig); - // Remove from each tool's wiki directory - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { - if (!toolPath.wiki) continue; - const filePath = path.join(baseDir, toolPath.wiki, fileName); - if (await pathExists(filePath)) { - await remove(filePath); - removed.push(filePath); - log.debug(`Removed wiki page ${name} from ${tool}`); - } + // Remove from shared wiki directory + const sharedWikiDir = WikiHandler.getSharedWikiDir(localConfig); + const sharedFile = path.join(sharedWikiDir, `${name}.md`); + if (await pathExists(sharedFile)) { + await remove(sharedFile); + removed.push(sharedFile); + log.debug(`Removed wiki page ${name} from shared wiki`); } return removed; } /** - * Rebuild _metadata.json from wiki pages on disk. + * Rebuild _metadata.json from wiki pages in the shared wiki directory. * Called after pull to reconstruct local metadata. */ - static async rebuildMetadata(wikiDir: string): Promise { + static async rebuildMetadata(localConfig: LocalConfig): Promise { + const wikiDir = WikiHandler.getSharedWikiDir(localConfig); if (!await pathExists(wikiDir)) return; const files = await listFilesRecursive(wikiDir);