diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index b3ec659..4ac3872 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -4,6 +4,7 @@ const mockConfigManager: any = { exists: jest.fn(), read: jest.fn(), create: jest.fn(), + update: jest.fn(), setEnvironments: jest.fn(), addPhase: jest.fn() }; @@ -77,16 +78,29 @@ jest.mock('../../lib/InitTemplate', () => ({ loadInitTemplate: (...args: unknown[]) => mockLoadInitTemplate(...args) })); +jest.mock('../../lib/gitignoreArtifacts', () => ({ + writeGitignoreWithAiDevkitBlock: jest.fn(async () => { + /* noop */ + }) +})); + jest.mock('../../util/terminal-ui', () => ({ ui: mockUi })); import { initCommand } from '../../commands/init'; +import { writeGitignoreWithAiDevkitBlock } from '../../lib/gitignoreArtifacts'; + +const mockWriteGitignore = writeGitignoreWithAiDevkitBlock as jest.MockedFunction< + typeof writeGitignoreWithAiDevkitBlock +>; describe('init command template mode', () => { beforeEach(() => { jest.clearAllMocks(); process.exitCode = undefined; + mockWriteGitignore.mockClear(); + mockWriteGitignore.mockResolvedValue(undefined); mockExecSync.mockReturnValue(undefined); mockPrompt.mockResolvedValue({}); @@ -94,6 +108,7 @@ describe('init command template mode', () => { mockConfigManager.exists.mockResolvedValue(false); mockConfigManager.read.mockResolvedValue(null); mockConfigManager.create.mockResolvedValue({ environments: [], phases: [] }); + mockConfigManager.update.mockResolvedValue(undefined); mockConfigManager.setEnvironments.mockResolvedValue(undefined); mockConfigManager.addPhase.mockResolvedValue(undefined); @@ -195,4 +210,50 @@ describe('init command template mode', () => { expect(process.exitCode).toBe(1); expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); }); + + it('updates .gitignore when template sets gitignoreArtifacts true', async () => { + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo'); + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'], + gitignoreArtifacts: true, + paths: { docs: 'custom-ai-docs' } + }); + + await initCommand({ template: './init.yaml' }); + + expect(mockWriteGitignore).toHaveBeenCalledTimes(1); + expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'custom-ai-docs'); + expect(mockUi.success).toHaveBeenCalledWith( + 'Updated .gitignore to exclude AI DevKit artifacts (not shared via git).' + ); + + cwdSpy.mockRestore(); + }); + + it('does not update .gitignore when template sets gitignoreArtifacts false', async () => { + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'], + gitignoreArtifacts: false + }); + + await initCommand({ template: './init.yaml' }); + + expect(mockWriteGitignore).not.toHaveBeenCalled(); + }); + + it('updates .gitignore when --gitignore-artifacts is passed', async () => { + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo'); + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'] + }); + + await initCommand({ template: './init.yaml', gitignoreArtifacts: true }); + + expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'docs/ai'); + + cwdSpy.mockRestore(); + }); }); diff --git a/packages/cli/src/__tests__/lib/InitTemplate.test.ts b/packages/cli/src/__tests__/lib/InitTemplate.test.ts index ad39e1d..481092f 100644 --- a/packages/cli/src/__tests__/lib/InitTemplate.test.ts +++ b/packages/cli/src/__tests__/lib/InitTemplate.test.ts @@ -126,6 +126,32 @@ paths: ); }); + it('loads gitignoreArtifacts boolean from YAML', async () => { + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(` +environments: + - codex +phases: + - requirements +gitignoreArtifacts: true +` as never); + + const result = await loadInitTemplate('/tmp/init.yaml'); + + expect(result.gitignoreArtifacts).toBe(true); + }); + + it('throws when gitignoreArtifacts is not boolean', async () => { + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(` +gitignoreArtifacts: "yes" +` as never); + + await expect(loadInitTemplate('/tmp/init.yaml')).rejects.toThrow( + '"gitignoreArtifacts" must be a boolean' + ); + }); + it('throws when unknown field exists', async () => { mockFs.pathExists.mockResolvedValue(true as never); mockFs.readFile.mockResolvedValue(` diff --git a/packages/cli/src/__tests__/lib/gitignoreArtifacts.test.ts b/packages/cli/src/__tests__/lib/gitignoreArtifacts.test.ts new file mode 100644 index 0000000..5c6534d --- /dev/null +++ b/packages/cli/src/__tests__/lib/gitignoreArtifacts.test.ts @@ -0,0 +1,155 @@ +import { + AI_DEVKIT_GITIGNORE_END, + AI_DEVKIT_GITIGNORE_START, + buildManagedGitignoreBody, + collectInitCommandPathIgnorePatterns, + commandPathToGitignoreDirPattern, + mergeAiDevkitGitignoreBlock, + normalizeDocsDirForIgnore, + writeGitignoreWithAiDevkitBlock +} from '../../lib/gitignoreArtifacts'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +jest.mock('fs-extra'); + +describe('gitignoreArtifacts', () => { + describe('normalizeDocsDirForIgnore', () => { + it('trims and normalizes slashes', () => { + expect(normalizeDocsDirForIgnore(' docs/ai ')).toBe('docs/ai'); + expect(normalizeDocsDirForIgnore('docs\\ai')).toBe('docs/ai'); + }); + + it('strips leading ./', () => { + expect(normalizeDocsDirForIgnore('./docs/ai')).toBe('docs/ai'); + }); + + it('rejects empty, absolute, .., and empty segments', () => { + expect(() => normalizeDocsDirForIgnore('')).toThrow(); + expect(() => normalizeDocsDirForIgnore(' ')).toThrow(); + expect(() => normalizeDocsDirForIgnore('/abs')).toThrow(); + expect(() => normalizeDocsDirForIgnore('docs/../x')).toThrow(); + expect(() => normalizeDocsDirForIgnore('docs//ai')).toThrow(); + }); + }); + + describe('commandPathToGitignoreDirPattern', () => { + it('normalizes to a directory line with trailing slash', () => { + expect(commandPathToGitignoreDirPattern('.cursor/commands')).toBe('.cursor/commands/'); + expect(commandPathToGitignoreDirPattern('./.opencode/commands')).toBe('.opencode/commands/'); + }); + + it('returns null for unsafe paths', () => { + expect(commandPathToGitignoreDirPattern('')).toBeNull(); + expect(commandPathToGitignoreDirPattern('docs/../x')).toBeNull(); + expect(commandPathToGitignoreDirPattern('/abs/path')).toBeNull(); + }); + }); + + describe('collectInitCommandPathIgnorePatterns', () => { + it('matches every environment commandPath and excludes skills / global paths', () => { + const patterns = collectInitCommandPathIgnorePatterns(); + expect(patterns).toEqual([ + '.agent/workflows/', + '.agents/commands/', + '.claude/commands/', + '.codex/commands/', + '.cursor/commands/', + '.gemini/commands/', + '.github/prompts/', + '.kilocode/commands/', + '.opencode/commands/', + '.roo/commands/', + '.windsurf/commands/' + ]); + expect(patterns).not.toContain('.cursor/skills/'); + expect(patterns).not.toContain('.opencode/skills/'); + expect(patterns).not.toContain('.gemini/antigravity/'); + expect(patterns).not.toContain('.codex/prompts/'); + }); + }); + + describe('buildManagedGitignoreBody', () => { + it('includes config file, docs dir, and command directories only', () => { + const body = buildManagedGitignoreBody('docs/ai'); + expect(body).toMatch(/^\.ai-devkit\.json\ndocs\/ai\/\n/); + expect(body).toContain('.cursor/commands/'); + expect(body).toContain('.opencode/commands/'); + expect(body).not.toContain('.cursor/skills'); + }); + }); + + describe('mergeAiDevkitGitignoreBlock', () => { + const blockForDocsAi = `${AI_DEVKIT_GITIGNORE_START}\n${buildManagedGitignoreBody('docs/ai')}${AI_DEVKIT_GITIGNORE_END}\n`; + + it('creates block only when file empty', () => { + expect(mergeAiDevkitGitignoreBlock('', 'docs/ai')).toBe(blockForDocsAi); + }); + + it('appends block after existing user content', () => { + const user = 'node_modules/\n'; + expect(mergeAiDevkitGitignoreBlock(user, 'docs/ai')).toBe(`node_modules/\n\n${blockForDocsAi}`); + }); + + it('replaces managed block when docs path changes', () => { + const before = `keep-me\n\n${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\nold/\n${AI_DEVKIT_GITIGNORE_END}\n\ntrailer\n`; + const merged = mergeAiDevkitGitignoreBlock(before, 'docs/custom'); + expect(merged).toContain('keep-me'); + expect(merged).toContain('trailer'); + expect(merged).toContain('docs/custom/'); + expect(merged).not.toContain('old/'); + }); + + it('is idempotent when content unchanged', () => { + const once = mergeAiDevkitGitignoreBlock('', 'docs/ai'); + const twice = mergeAiDevkitGitignoreBlock(once, 'docs/ai'); + expect(twice).toBe(once); + }); + + it('repairs missing end marker by replacing from start', () => { + const broken = `${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\ndocs/ai/\n`; + const merged = mergeAiDevkitGitignoreBlock(broken, 'docs/ai'); + expect(merged).toContain(AI_DEVKIT_GITIGNORE_END); + expect(merged.split(AI_DEVKIT_GITIGNORE_START).length - 1).toBe(1); + }); + + it('preserves content outside the managed block', () => { + const content = `# top\n\n${blockForDocsAi}# bottom\n`; + const merged = mergeAiDevkitGitignoreBlock(content, 'docs/ai'); + expect(merged).toContain('# top'); + expect(merged).toContain('# bottom'); + }); + }); + + describe('writeGitignoreWithAiDevkitBlock', () => { + const mockFs = fs as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('writes merged content when file missing', async () => { + mockFs.pathExists.mockResolvedValue(false as never); + mockFs.writeFile.mockResolvedValue(undefined as never); + + await writeGitignoreWithAiDevkitBlock('/repo', 'docs/ai'); + + expect(mockFs.pathExists).toHaveBeenCalledWith(path.join('/repo', '.gitignore')); + expect(mockFs.writeFile).toHaveBeenCalledTimes(1); + const written = mockFs.writeFile.mock.calls[0][1] as string; + expect(written).toContain('.ai-devkit.json'); + expect(written).toContain('docs/ai/'); + }); + + it('skips write when merge is identical', async () => { + const body = buildManagedGitignoreBody('docs/ai'); + const unchanged = `${AI_DEVKIT_GITIGNORE_START}\n${body}${AI_DEVKIT_GITIGNORE_END}\n`; + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(unchanged as never); + + await writeGitignoreWithAiDevkitBlock('/tmp', 'docs/ai'); + + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ded0e2..a7f594b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -27,6 +27,10 @@ program .option('-p, --phases ', 'Comma-separated list of phases to initialize') .option('-t, --template ', 'Initialize from template file (.yaml, .yml, .json)') .option('-d, --docs-dir ', 'Custom directory for AI documentation (default: docs/ai)') + .option( + '--gitignore-artifacts', + 'Add .ai-devkit.json, the AI docs directory, and ai-devkit command folders (per-tool commandPath) to .gitignore' + ) .action(initCommand); program diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1aa3630..31c9fd1 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -5,7 +5,8 @@ import { TemplateManager } from '../lib/TemplateManager'; import { EnvironmentSelector } from '../lib/EnvironmentSelector'; import { PhaseSelector } from '../lib/PhaseSelector'; import { SkillManager } from '../lib/SkillManager'; -import { loadInitTemplate, InitTemplateSkill } from '../lib/InitTemplate'; +import { loadInitTemplate, InitTemplateConfig, InitTemplateSkill } from '../lib/InitTemplate'; +import { writeGitignoreWithAiDevkitBlock } from '../lib/gitignoreArtifacts'; import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase, DEFAULT_DOCS_DIR } from '../types'; import { isValidEnvironmentCode } from '../util/env'; import { ui } from '../util/terminal-ui'; @@ -19,6 +20,18 @@ function isGitAvailable(): boolean { } } +function isInsideGitWorkTree(): boolean { + if (!isGitAvailable()) { + return false; + } + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + function ensureGitRepository(): void { if (!isGitAvailable()) { ui.warning( @@ -47,6 +60,35 @@ interface InitOptions { phases?: string; template?: string; docsDir?: string; + gitignoreArtifacts?: boolean; +} + +async function resolveShouldGitignoreArtifacts( + options: InitOptions, + templateConfig: InitTemplateConfig | null +): Promise { + if (options.gitignoreArtifacts === true) { + return true; + } + if (templateConfig?.gitignoreArtifacts === true) { + return true; + } + if (templateConfig?.gitignoreArtifacts === false) { + return false; + } + if (process.stdin.isTTY) { + const { addGitignore } = await inquirer.prompt([ + { + type: 'confirm', + name: 'addGitignore', + message: + 'Add .ai-devkit.json, your AI docs folder, and installed slash-command folders (e.g. .cursor/commands, .opencode/commands) to .gitignore? They will not be shared when you push to git.', + default: false + } + ]); + return Boolean(addGitignore); + } + return false; } function normalizeEnvironmentOption( @@ -300,6 +342,22 @@ export async function initCommand(options: InitOptions) { } } + const shouldGitignore = await resolveShouldGitignoreArtifacts(options, templateConfig); + if (shouldGitignore) { + if (!isInsideGitWorkTree()) { + ui.warning('Not inside a git repository; skipped updating .gitignore for AI DevKit artifacts.'); + } else { + try { + await writeGitignoreWithAiDevkitBlock(process.cwd(), docsDir); + ui.success('Updated .gitignore to exclude AI DevKit artifacts (not shared via git).'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ui.error(`Failed to update .gitignore: ${message}`); + process.exitCode = 1; + } + } + } + ui.text('AI DevKit initialized successfully!', { breakline: true }); ui.info('Next steps:'); ui.text(` • Review and customize templates in ${docsDir}/`); diff --git a/packages/cli/src/lib/InitTemplate.ts b/packages/cli/src/lib/InitTemplate.ts index 7f148a4..144591b 100644 --- a/packages/cli/src/lib/InitTemplate.ts +++ b/packages/cli/src/lib/InitTemplate.ts @@ -17,9 +17,18 @@ export interface InitTemplateConfig { environments?: EnvironmentCode[]; phases?: Phase[]; skills?: InitTemplateSkill[]; + /** When true, add .ai-devkit.json, docs dir, and each environment commandPath folder to .gitignore after init. */ + gitignoreArtifacts?: boolean; } -const ALLOWED_TEMPLATE_FIELDS = new Set(['version', 'paths', 'environments', 'phases', 'skills']); +const ALLOWED_TEMPLATE_FIELDS = new Set([ + 'version', + 'paths', + 'environments', + 'phases', + 'skills', + 'gitignoreArtifacts' +]); function validationError(templatePath: string, message: string): Error { return new Error(`Invalid template at ${templatePath}: ${message}`); @@ -138,6 +147,13 @@ function validateTemplate(raw: unknown, resolvedPath: string): InitTemplateConfi }); } + if (candidate.gitignoreArtifacts !== undefined) { + if (typeof candidate.gitignoreArtifacts !== 'boolean') { + throw validationError(resolvedPath, '"gitignoreArtifacts" must be a boolean'); + } + result.gitignoreArtifacts = candidate.gitignoreArtifacts; + } + if (candidate.skills !== undefined) { if (!Array.isArray(candidate.skills)) { throw validationError(resolvedPath, '"skills" must be an array of skill objects'); diff --git a/packages/cli/src/lib/gitignoreArtifacts.ts b/packages/cli/src/lib/gitignoreArtifacts.ts new file mode 100644 index 0000000..444a5a4 --- /dev/null +++ b/packages/cli/src/lib/gitignoreArtifacts.ts @@ -0,0 +1,131 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { ENVIRONMENT_DEFINITIONS } from '../util/env'; + +/** Start of the managed block; entire block is replaced on update. */ +export const AI_DEVKIT_GITIGNORE_START = '# --- ai-devkit (managed) ---'; + +/** End marker for the managed block. */ +export const AI_DEVKIT_GITIGNORE_END = '# --- end ai-devkit ---'; + +/** + * Turn an environment `commandPath` into a single .gitignore directory line. + * Only repo-local command folders from init are included (see TemplateManager.copyCommands / + * copyGeminiSpecificFiles). Returns null if the path is unsafe or empty. + */ +export function commandPathToGitignoreDirPattern(commandPath: string): string | null { + let s = commandPath.trim().replace(/\\/g, '/'); + while (s.startsWith('./')) { + s = s.slice(2); + } + if (!s) { + return null; + } + if (path.isAbsolute(s)) { + return null; + } + const parts = s.split('/'); + if (parts.some(p => p === '..')) { + return null; + } + if (parts.some(p => p === '')) { + return null; + } + const dirLine = s.endsWith('/') ? s : `${s}/`; + return dirLine; +} + +/** + * Sorted, deduplicated ignore lines for each supported environment's `commandPath` in + * {@link ENVIRONMENT_DEFINITIONS}. Does not include skill dirs or global (home) command paths. + */ +export function collectInitCommandPathIgnorePatterns(): string[] { + const set = new Set(); + for (const env of Object.values(ENVIRONMENT_DEFINITIONS)) { + const pattern = commandPathToGitignoreDirPattern(env.commandPath); + if (pattern) { + set.add(pattern); + } + } + return [...set].sort((a, b) => a.localeCompare(b)); +} + +/** + * Normalize docs dir for .gitignore: relative, forward slashes, no `..`. + * @throws if path is empty, absolute, or unsafe + */ +export function normalizeDocsDirForIgnore(docsDir: string): string { + let s = docsDir.trim().replace(/\\/g, '/'); + while (s.startsWith('./')) { + s = s.slice(2); + } + if (!s) { + throw new Error('docs directory must be a non-empty relative path'); + } + if (path.isAbsolute(s)) { + throw new Error('docs directory must be a relative path'); + } + const parts = s.split('/'); + if (parts.some(p => p === '..')) { + throw new Error('docs directory must not contain parent path segments'); + } + if (parts.some(p => p === '')) { + throw new Error('docs directory must not contain empty path segments'); + } + return s; +} + +export function buildManagedGitignoreBody(docsDir: string): string { + const norm = normalizeDocsDirForIgnore(docsDir); + const dirLine = norm.endsWith('/') ? norm : `${norm}/`; + const toolLines = collectInitCommandPathIgnorePatterns().join('\n'); + return `.ai-devkit.json\n${dirLine}\n${toolLines}\n`; +} + +/** + * Insert or replace the ai-devkit managed block. Preserves all content outside the block. + */ +export function mergeAiDevkitGitignoreBlock(existingContent: string, docsDir: string): string { + const inner = buildManagedGitignoreBody(docsDir); + const block = `${AI_DEVKIT_GITIGNORE_START}\n${inner}${AI_DEVKIT_GITIGNORE_END}\n`; + + const startIdx = existingContent.indexOf(AI_DEVKIT_GITIGNORE_START); + if (startIdx === -1) { + if (existingContent.trim() === '') { + return block; + } + const trimmedEnd = existingContent.replace(/\s+$/, ''); + return `${trimmedEnd}\n\n${block}`; + } + + const searchFrom = startIdx + AI_DEVKIT_GITIGNORE_START.length; + const endMarkerIdx = existingContent.indexOf(AI_DEVKIT_GITIGNORE_END, searchFrom); + if (endMarkerIdx === -1) { + const before = existingContent.slice(0, startIdx).replace(/\s+$/, ''); + const prefix = before === '' ? '' : `${before}\n\n`; + return `${prefix}${block}`; + } + + const endAfterLine = endMarkerIdx + AI_DEVKIT_GITIGNORE_END.length; + let afterStart = endAfterLine; + while (afterStart < existingContent.length && existingContent[afterStart] === '\n') { + afterStart += 1; + } + + const before = existingContent.slice(0, startIdx); + const after = existingContent.slice(afterStart); + return `${before}${block}${after}`; +} + +export async function writeGitignoreWithAiDevkitBlock(root: string, docsDir: string): Promise { + const gitignorePath = path.join(root, '.gitignore'); + let existing = ''; + if (await fs.pathExists(gitignorePath)) { + existing = await fs.readFile(gitignorePath, 'utf8'); + } + const merged = mergeAiDevkitGitignoreBlock(existing, docsDir); + if (merged === existing) { + return; + } + await fs.writeFile(gitignorePath, merged, 'utf8'); +}