From 3ad9bc7f60996076b36ea2c6bb8e6bb13f93175b Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 26 May 2026 20:51:28 +0800 Subject: [PATCH 1/2] feat: add wiki feature toggle via team config with local override (fixes #21) Add ability to disable the built-in wiki feature at the team level (teamai.yaml sharing.wiki.enabled) with optional local user override (config.yaml wikiEnabled). This allows teams using third-party wiki plugins to disable the built-in wiki without affecting other teams. Priority: env var (TEAMAI_WIKI_DISABLED) > local config > team config > default (enabled) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/wiki-toggle-team.test.ts | 181 +++++++++++++++++++++++++ src/builtin-skills.ts | 10 +- src/pull.ts | 10 +- src/push.ts | 6 +- src/status.ts | 12 ++ src/types.ts | 21 +++ 6 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/wiki-toggle-team.test.ts diff --git a/src/__tests__/wiki-toggle-team.test.ts b/src/__tests__/wiki-toggle-team.test.ts new file mode 100644 index 0000000..b697952 --- /dev/null +++ b/src/__tests__/wiki-toggle-team.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { resolveWikiEnabled, LocalConfigSchema, TeamaiConfigSchema } from '../types.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +describe('resolveWikiEnabled', () => { + const baseLocalConfig: LocalConfig = { + repo: { localPath: '/tmp/repo', remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + scope: 'user', + additionalRoles: [], + }; + + const baseTeamConfig: TeamaiConfig = { + team: 'test-team', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '~/.teamai/docs' }, + env: { injectShellProfile: true }, + }, + toolPaths: {}, + }; + + it('returns true when team has no wiki section and no local override', () => { + expect(resolveWikiEnabled(baseTeamConfig, baseLocalConfig)).toBe(true); + }); + + it('returns true when team wiki.enabled is true and no local override', () => { + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: true } }, + }; + expect(resolveWikiEnabled(teamConfig, baseLocalConfig)).toBe(true); + }); + + it('returns false when team wiki.enabled is false and no local override', () => { + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: false } }, + }; + expect(resolveWikiEnabled(teamConfig, baseLocalConfig)).toBe(false); + }); + + it('returns false when team enabled + local override false (local wins)', () => { + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: true } }, + }; + const localConfig = { ...baseLocalConfig, wikiEnabled: false }; + expect(resolveWikiEnabled(teamConfig, localConfig)).toBe(false); + }); + + it('returns true when team disabled + local override true (local wins)', () => { + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: false } }, + }; + const localConfig = { ...baseLocalConfig, wikiEnabled: true }; + expect(resolveWikiEnabled(teamConfig, localConfig)).toBe(true); + }); + + it('backward compat: team config without wiki section defaults to enabled', () => { + expect(resolveWikiEnabled(baseTeamConfig, baseLocalConfig)).toBe(true); + }); + + it('backward compat: old local config without wikiEnabled follows team config', () => { + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: false } }, + }; + expect(resolveWikiEnabled(teamConfig, baseLocalConfig)).toBe(false); + }); +}); + +describe('LocalConfigSchema wikiEnabled', () => { + const baseInput = { + repo: { localPath: '/tmp/repo', remote: 'https://git.woa.com/test/repo.git' }, + username: 'test-user', + }; + + it('defaults wikiEnabled to undefined when not provided', () => { + const config = LocalConfigSchema.parse(baseInput); + expect(config.wikiEnabled).toBeUndefined(); + }); + + it('accepts wikiEnabled: true', () => { + const config = LocalConfigSchema.parse({ ...baseInput, wikiEnabled: true }); + expect(config.wikiEnabled).toBe(true); + }); + + it('accepts wikiEnabled: false', () => { + const config = LocalConfigSchema.parse({ ...baseInput, wikiEnabled: false }); + expect(config.wikiEnabled).toBe(false); + }); + + it('rejects non-boolean wikiEnabled', () => { + expect(() => LocalConfigSchema.parse({ ...baseInput, wikiEnabled: 'no' })).toThrow(); + }); + + it('backward compat: old configs without wikiEnabled parse correctly', () => { + const oldConfig = { + repo: { localPath: '/home/user/.teamai/team-repo', remote: 'git@git.woa.com:team/repo.git' }, + username: 'alice', + scope: 'user', + primaryRole: 'backend', + additionalRoles: ['infra'], + subscribedTags: ['api', 'core'], + }; + const config = LocalConfigSchema.parse(oldConfig); + expect(config.wikiEnabled).toBeUndefined(); + expect(config.primaryRole).toBe('backend'); + }); +}); + +describe('TeamaiConfigSchema wiki in sharing', () => { + const baseInput = { + team: 'my-team', + repo: 'https://git.woa.com/test/repo.git', + }; + + it('defaults wiki to undefined when not in sharing config', () => { + const config = TeamaiConfigSchema.parse(baseInput); + expect(config.sharing.wiki).toBeUndefined(); + }); + + it('accepts wiki.enabled: true', () => { + const config = TeamaiConfigSchema.parse({ + ...baseInput, + sharing: { wiki: { enabled: true } }, + }); + expect(config.sharing.wiki).toEqual({ enabled: true }); + }); + + it('accepts wiki.enabled: false with disabledHint', () => { + const config = TeamaiConfigSchema.parse({ + ...baseInput, + sharing: { wiki: { enabled: false, disabledHint: 'use external team-wiki plugin' } }, + }); + expect(config.sharing.wiki).toEqual({ enabled: false, disabledHint: 'use external team-wiki plugin' }); + }); +}); + +describe('pull skips wiki when resolved to disabled', () => { + it('filters wiki from resource types when disabled', () => { + const wikiEnabled = false; + const resourceTypes = wikiEnabled + ? ['skills', 'rules', 'docs', 'env', 'wiki'] + : ['skills', 'rules', 'docs', 'env']; + expect(resourceTypes).not.toContain('wiki'); + }); + + it('includes wiki in resource types when enabled', () => { + const wikiEnabled = true; + const resourceTypes = wikiEnabled + ? ['skills', 'rules', 'docs', 'env', 'wiki'] + : ['skills', 'rules', 'docs', 'env']; + expect(resourceTypes).toContain('wiki'); + }); +}); + +describe('builtin-skills skips teamai-wiki when disabled', () => { + it('filters teamai-wiki from skill names when skipWiki is true', () => { + const skillNames = ['teamai-share-learnings', 'teamai-wiki']; + const filteredSkills = true + ? skillNames.filter(name => name !== 'teamai-wiki') + : skillNames; + expect(filteredSkills).toEqual(['teamai-share-learnings']); + }); + + it('keeps teamai-wiki when skipWiki is false/undefined', () => { + const skillNames = ['teamai-share-learnings', 'teamai-wiki']; + const filteredSkills = false + ? skillNames.filter(name => name !== 'teamai-wiki') + : skillNames; + expect(filteredSkills).toEqual(['teamai-share-learnings', 'teamai-wiki']); + }); +}); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 2b85a8e..d0fa214 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)) { @@ -76,6 +76,12 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? if (skillNames.length === 0) return 0; + // Skip teamai-wiki deployment when wiki feature is disabled + const filteredSkills = options?.skipWiki + ? skillNames.filter(name => name !== 'teamai-wiki') + : skillNames; + if (filteredSkills.length === 0) return 0; + const baseDir = localConfig ? resolveBaseDir(localConfig) : (process.env.HOME ?? ''); let deployed = 0; @@ -90,7 +96,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const targetSkillsDir = path.join(baseDir, toolPath.skills); - for (const skillName of skillNames) { + for (const skillName of filteredSkills) { const srcDir = path.join(builtinDir, skillName); const destDir = path.join(targetSkillsDir, skillName); diff --git a/src/pull.ts b/src/pull.ts index 7ad562a..a35a7c9 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -19,6 +19,7 @@ import { TEAMAI_CLAUDEMD_END, CultureFrontmatterSchema, resolveBaseDir, + resolveWikiEnabled, getTeamaiHome, } from './types.js'; import type { CultureFrontmatter } from './types.js'; @@ -259,8 +260,11 @@ async function pullForScope( const tagsConfig = await loadTagsConfig(localConfig.repo.localPath); const subscribedTags = localConfig.subscribedTags; - // Step 2: Sync each resource type - const resourceTypes: ResourceType[] = ['skills', 'rules', 'docs', 'env', 'wiki']; + // Step 2: Sync each resource type (skip wiki when disabled) + const wikiEnabled = resolveWikiEnabled(freshConfig, 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..5a1c6f6 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 { resolveWikiEnabled } 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,10 @@ 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 wikiEnabled = resolveWikiEnabled(teamConfig, localConfig); + const pushableTypes: ResourceType[] = wikiEnabled + ? ['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..ea71b27 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 { resolveWikiEnabled } from './types.js'; export interface ListOptions extends GlobalOptions { /** Where to look for resources: 'repo' (default for backwards compat), @@ -100,6 +101,17 @@ export async function status(options: GlobalOptions): Promise { console.log(` ${type}: ${count}`); } + // Wiki feature status + const wikiEnabled = resolveWikiEnabled(teamConfig, localConfig); + const envDisabled = process.env.TEAMAI_WIKI_DISABLED === '1' || process.env.TEAMAI_WIKI_DISABLED === 'true' || process.env.TEAMAI_WIKI_ENABLED === '0' || process.env.TEAMAI_WIKI_ENABLED === 'false'; + const wikiSource = envDisabled + ? 'disabled (TEAMAI_WIKI_DISABLED=1) — wiki routing handled by external plugin' + : localConfig.wikiEnabled !== undefined + ? (wikiEnabled ? 'enabled (local override)' : 'disabled (local override)') + : (wikiEnabled ? 'enabled (team default)' : 'disabled (team config)'); + console.log(''); + log.info(`Wiki: ${wikiSource}`); + // Local pushable items console.log(''); log.info('Local resources not yet pushed:'); diff --git a/src/types.ts b/src/types.ts index 38b3319..d5de6c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,11 @@ export const SharingConfigSchema = z.object({ injectShellProfile: z.boolean().default(true), shellProfilePath: z.string().optional(), }).default({}), + wiki: z.object({ + enabled: z.boolean().default(true), + /** Hint message shown when wiki is disabled */ + disabledHint: z.string().optional(), + }).optional(), }); // ─── Source config (cross-team subscription) ───────── @@ -133,6 +138,8 @@ 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(), + /** Override team-level wiki setting. undefined = follow team config. */ + wikiEnabled: z.boolean().optional(), }); export type LocalConfig = z.infer; @@ -534,3 +541,17 @@ export function getStatePath(scope: Scope, projectRoot?: string): string { export function getPushignorePath(): string { return path.join(process.env.HOME ?? '', '.teamai', 'pushignore'); } + +/** + * Resolve whether wiki is enabled, considering team config + local override. + * Local override takes precedence over team config. + * Defaults to enabled for backward compatibility. + */ +export function resolveWikiEnabled(teamConfig: TeamaiConfig, localConfig: LocalConfig): boolean { + // Environment variable takes highest priority (zero-config approach) + if (process.env.TEAMAI_WIKI_DISABLED === '1' || process.env.TEAMAI_WIKI_DISABLED === 'true') return false; + if (process.env.TEAMAI_WIKI_ENABLED === '0' || process.env.TEAMAI_WIKI_ENABLED === 'false') return false; + if (localConfig.wikiEnabled !== undefined) return localConfig.wikiEnabled; + const wiki = (teamConfig.sharing as Record)?.wiki as { enabled?: boolean } | undefined; + return wiki?.enabled ?? true; +} From a87cf489c1047e3fea4462ebca0e2963c83066cc Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 26 May 2026 20:54:25 +0800 Subject: [PATCH 2/2] test: add env var override tests for resolveWikiEnabled (fixes #21) Verify that TEAMAI_WIKI_DISABLED=1 takes highest priority over both local config (wikiEnabled: true) and team config (sharing.wiki.enabled: true). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/wiki-toggle-team.test.ts | 93 +++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/__tests__/wiki-toggle-team.test.ts b/src/__tests__/wiki-toggle-team.test.ts index b697952..2e4c2e8 100644 --- a/src/__tests__/wiki-toggle-team.test.ts +++ b/src/__tests__/wiki-toggle-team.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { resolveWikiEnabled, LocalConfigSchema, TeamaiConfigSchema } from '../types.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; @@ -179,3 +179,94 @@ describe('builtin-skills skips teamai-wiki when disabled', () => { expect(filteredSkills).toEqual(['teamai-share-learnings', 'teamai-wiki']); }); }); + +describe('resolveWikiEnabled - environment variable override', () => { + const baseLocalConfig = { + repo: { localPath: '/tmp/repo', remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + scope: 'user' as const, + additionalRoles: [] as string[], + }; + + const baseTeamConfig = { + team: 'test-team', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [] as string[], + sharing: { + skills: {}, + rules: { enforced: [] as string[] }, + docs: { localDir: '~/.teamai/docs' }, + env: { injectShellProfile: true }, + }, + toolPaths: {}, + }; + + let originalWikiDisabled: string | undefined; + let originalWikiEnabled: string | undefined; + + beforeEach(() => { + originalWikiDisabled = process.env.TEAMAI_WIKI_DISABLED; + originalWikiEnabled = process.env.TEAMAI_WIKI_ENABLED; + delete process.env.TEAMAI_WIKI_DISABLED; + delete process.env.TEAMAI_WIKI_ENABLED; + }); + + afterEach(() => { + if (originalWikiDisabled !== undefined) { + process.env.TEAMAI_WIKI_DISABLED = originalWikiDisabled; + } else { + delete process.env.TEAMAI_WIKI_DISABLED; + } + if (originalWikiEnabled !== undefined) { + process.env.TEAMAI_WIKI_ENABLED = originalWikiEnabled; + } else { + delete process.env.TEAMAI_WIKI_ENABLED; + } + }); + + it('returns true by default (no env vars, no config)', () => { + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(true); + }); + + it('returns false when TEAMAI_WIKI_DISABLED=1', () => { + process.env.TEAMAI_WIKI_DISABLED = '1'; + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(false); + }); + + it('returns false when TEAMAI_WIKI_DISABLED=true', () => { + process.env.TEAMAI_WIKI_DISABLED = 'true'; + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(false); + }); + + it('returns false when TEAMAI_WIKI_ENABLED=0', () => { + process.env.TEAMAI_WIKI_ENABLED = '0'; + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(false); + }); + + it('returns false when TEAMAI_WIKI_ENABLED=false', () => { + process.env.TEAMAI_WIKI_ENABLED = 'false'; + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(false); + }); + + it('env var overrides local config wikiEnabled=true', () => { + process.env.TEAMAI_WIKI_DISABLED = '1'; + const localConfig = { ...baseLocalConfig, wikiEnabled: true }; + expect(resolveWikiEnabled(baseTeamConfig as any, localConfig as any)).toBe(false); + }); + + it('env var overrides team config wiki.enabled=true', () => { + process.env.TEAMAI_WIKI_DISABLED = '1'; + const teamConfig = { + ...baseTeamConfig, + sharing: { ...baseTeamConfig.sharing, wiki: { enabled: true } }, + }; + expect(resolveWikiEnabled(teamConfig as any, baseLocalConfig as any)).toBe(false); + }); + + it('returns true when TEAMAI_WIKI_DISABLED=0 (not a disable value)', () => { + process.env.TEAMAI_WIKI_DISABLED = '0'; + expect(resolveWikiEnabled(baseTeamConfig as any, baseLocalConfig as any)).toBe(true); + }); +});