From 96f159c1cb3dd8bbb3143ca2ef413571972de01f Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 26 May 2026 21:18:31 +0800 Subject: [PATCH] feat: add wiki feature toggle via local config Add wikiEnabled field to ~/.teamai/config.yaml to let users disable the built-in wiki feature, preventing conflicts with third-party team-wiki plugins. Usage: set `wikiEnabled: false` in ~/.teamai/config.yaml When disabled: - Wiki resources are skipped during pull/push - teamai-wiki builtin skill is not deployed - Status command shows wiki disabled hint Default: enabled (backward compatible, field is optional) Closes #21 --- src/__tests__/wiki-toggle-config.test.ts | 87 ++++++++++++++++++++++++ src/builtin-skills.ts | 3 +- src/pull.ts | 8 ++- src/push.ts | 5 +- src/status.ts | 7 ++ src/types.ts | 12 ++++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/wiki-toggle-config.test.ts diff --git a/src/__tests__/wiki-toggle-config.test.ts b/src/__tests__/wiki-toggle-config.test.ts new file mode 100644 index 0000000..a587828 --- /dev/null +++ b/src/__tests__/wiki-toggle-config.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { isWikiEnabledByConfig, LocalConfigSchema } from '../types.js'; + +describe('Wiki feature toggle via local config (Approach B)', () => { + const baseConfig = { + repo: { localPath: '/tmp/test-repo', remote: 'git@test:repo.git' }, + username: 'test-user', + scope: 'user' as const, + additionalRoles: [], + }; + + describe('isWikiEnabledByConfig', () => { + it('returns true when wikiEnabled is true', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: true }); + expect(isWikiEnabledByConfig(config)).toBe(true); + }); + + it('returns false when wikiEnabled is false', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: false }); + expect(isWikiEnabledByConfig(config)).toBe(false); + }); + + it('returns true when wikiEnabled is not set (default)', () => { + const config = LocalConfigSchema.parse(baseConfig); + expect(isWikiEnabledByConfig(config)).toBe(true); + }); + }); + + describe('LocalConfigSchema backward compatibility', () => { + it('parses config without wikiEnabled field (defaults to true)', () => { + const config = LocalConfigSchema.parse(baseConfig); + expect(config.wikiEnabled).toBeUndefined(); + }); + + it('parses config with wikiEnabled: false', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: false }); + expect(config.wikiEnabled).toBe(false); + }); + + it('parses config with wikiEnabled: true', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: true }); + expect(config.wikiEnabled).toBe(true); + }); + + it('rejects non-boolean wikiEnabled', () => { + expect(() => + LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: 'yes' }), + ).toThrow(); + }); + }); + + describe('Pull resource type filtering', () => { + it('includes wiki when enabled', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: true }); + const wikiEnabled = isWikiEnabledByConfig(config); + const types = wikiEnabled + ? ['skills', 'rules', 'docs', 'env', 'wiki'] + : ['skills', 'rules', 'docs', 'env']; + expect(types).toContain('wiki'); + }); + + it('excludes wiki when disabled', () => { + const config = LocalConfigSchema.parse({ ...baseConfig, wikiEnabled: false }); + const wikiEnabled = isWikiEnabledByConfig(config); + const types = wikiEnabled + ? ['skills', 'rules', 'docs', 'env', 'wiki'] + : ['skills', 'rules', 'docs', 'env']; + expect(types).not.toContain('wiki'); + }); + }); + + describe('Builtin skills skipWiki', () => { + it('filters teamai-wiki when skipWiki is true', () => { + const names = ['teamai-share-learnings', 'teamai-wiki']; + const opts = { skipWiki: true }; + const filtered = names.filter(n => !(opts.skipWiki && n === 'teamai-wiki')); + expect(filtered).toEqual(['teamai-share-learnings']); + }); + + it('keeps teamai-wiki when skipWiki is false', () => { + const names = ['teamai-share-learnings', 'teamai-wiki']; + const opts = { skipWiki: false }; + const filtered = names.filter(n => !(opts.skipWiki && n === 'teamai-wiki')); + expect(filtered).toEqual(['teamai-share-learnings', 'teamai-wiki']); + }); + }); +}); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 2b85a8e..4f88a97 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -50,7 +50,7 @@ export const BUILTIN_SKILL_NAMES = new Set(['teamai-share-learnings', 'teamai-wi * - Built-in skills directory doesn't exist (dev environment without build) * - A tool's skills directory is not configured */ -export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig): Promise { +export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { skipWiki?: boolean }): Promise { const builtinDir = getBuiltinSkillsDir(); if (!await pathExists(builtinDir)) { @@ -91,6 +91,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const targetSkillsDir = path.join(baseDir, toolPath.skills); for (const skillName of skillNames) { + if (options?.skipWiki && skillName === 'teamai-wiki') continue; const srcDir = path.join(builtinDir, skillName); const destDir = path.join(targetSkillsDir, skillName); diff --git a/src/pull.ts b/src/pull.ts index 7ad562a..5f5bf0f 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -19,6 +19,7 @@ import { TEAMAI_CLAUDEMD_END, CultureFrontmatterSchema, resolveBaseDir, + isWikiEnabledByConfig, getTeamaiHome, } from './types.js'; import type { CultureFrontmatter } from './types.js'; @@ -260,7 +261,10 @@ async function pullForScope( const subscribedTags = localConfig.subscribedTags; // Step 2: Sync each resource type - const resourceTypes: ResourceType[] = ['skills', 'rules', 'docs', 'env', 'wiki']; + const wikiEnabled = isWikiEnabledByConfig(localConfig); + const resourceTypes: ResourceType[] = wikiEnabled + ? ['skills', 'rules', 'docs', 'env', 'wiki'] + : ['skills', 'rules', 'docs', 'env']; let totalSynced = 0; let desiredSkillNames: Set | null = null; let knownRepoSkillNames: Set | null = null; @@ -555,7 +559,7 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const deployed = await deployBuiltinSkills(freshConfig, localConfig); + const deployed = await deployBuiltinSkills(freshConfig, localConfig, { skipWiki: !wikiEnabled }); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed ${deployed} built-in skill(s)`); } diff --git a/src/push.ts b/src/push.ts index a7f63f7..865b71f 100644 --- a/src/push.ts +++ b/src/push.ts @@ -7,6 +7,7 @@ import { log, spinner } from './utils/logger.js'; import { getHandler } from './resources/index.js'; import { scanTeamRepoNamespaces } from './resources/skills.js'; import type { GlobalOptions, ResourceItem, ResourceType } from './types.js'; +import { isWikiEnabledByConfig } from './types.js'; import { loadRolesManifest, resolveRoleResourceNamespaces } from './roles.js'; import { askQuestion, askSelection } from './utils/prompt.js'; import { pathExists } from './utils/fs.js'; @@ -137,7 +138,9 @@ export async function push(options: GlobalOptions & { all?: boolean; role?: stri // Scan for pushable resources first, then resolve namespace for new skills only. // Modified skills already carry their namespace from scanLocalForPush. - const pushableTypes: ResourceType[] = ['skills', 'rules', 'env', 'wiki']; + const pushableTypes: ResourceType[] = isWikiEnabledByConfig(localConfig) + ? ['skills', 'rules', 'env', 'wiki'] + : ['skills', 'rules', 'env']; const allItems: ResourceItem[] = []; for (const type of pushableTypes) { diff --git a/src/status.ts b/src/status.ts index 27092d9..2fccaac 100644 --- a/src/status.ts +++ b/src/status.ts @@ -16,6 +16,7 @@ import { type AgentSkillsView, } from './agent-skills.js'; import type { GlobalOptions, ResourceType } from './types.js'; +import { isWikiEnabledByConfig } from './types.js'; export interface ListOptions extends GlobalOptions { /** Where to look for resources: 'repo' (default for backwards compat), @@ -100,6 +101,12 @@ export async function status(options: GlobalOptions): Promise { console.log(` ${type}: ${count}`); } + // Wiki feature status + if (!isWikiEnabledByConfig(localConfig)) { + console.log(''); + log.info('ℹ Wiki: disabled (wikiEnabled: false in ~/.teamai/config.yaml)'); + } + // Local pushable items console.log(''); log.info('Local resources not yet pushed:'); diff --git a/src/types.ts b/src/types.ts index 38b3319..5ea2ca0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -133,6 +133,9 @@ export const LocalConfigSchema = z.object({ projectRoot: z.string().optional(), /** Tags the user has subscribed to. If empty/undefined, pull all resources. */ subscribedTags: z.array(z.string()).optional(), + /** Whether the built-in wiki feature is enabled. Set to false to disable + * wiki sync/push and avoid conflicts with external wiki plugins. */ + wikiEnabled: z.boolean().optional(), }); export type LocalConfig = z.infer; @@ -534,3 +537,12 @@ export function getStatePath(scope: Scope, projectRoot?: string): string { export function getPushignorePath(): string { return path.join(process.env.HOME ?? '', '.teamai', 'pushignore'); } + +/** + * Check if wiki feature is enabled based on local config. + * Returns false only when explicitly disabled by the user. + * Defaults to true for backward compatibility. + */ +export function isWikiEnabledByConfig(localConfig: LocalConfig): boolean { + return localConfig.wikiEnabled !== false; +}