diff --git a/package-lock.json b/package-lock.json index 62c708a..9d6db05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "workspaces": [ "apps/*", @@ -4168,6 +4168,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4185,6 +4186,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4202,6 +4204,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4219,6 +4222,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4236,6 +4240,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4253,6 +4258,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4270,6 +4276,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4287,6 +4294,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4304,6 +4312,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4321,6 +4330,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts index 51cc548..dda3ad6 100644 --- a/packages/cli/src/__tests__/commands/lint.test.ts +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -3,6 +3,12 @@ import { ui } from '../../util/terminal-ui'; import { lintCommand, renderLintReport } from '../../commands/lint'; import { LintReport, runLintChecks } from '../../services/lint/lint.service'; +jest.mock('../../lib/Config', () => ({ + ConfigManager: jest.fn(() => ({ + getDocsDir: jest.fn<() => Promise>().mockResolvedValue('docs/ai') + })) +})); + jest.mock('../../services/lint/lint.service', () => ({ runLintChecks: jest.fn() })); @@ -46,7 +52,7 @@ describe('lint command', () => { await lintCommand({ feature: 'lint-command', json: true }); - expect(mockedRunLintChecks).toHaveBeenCalledWith({ feature: 'lint-command', json: true }); + expect(mockedRunLintChecks).toHaveBeenCalledWith({ feature: 'lint-command', json: true, docsDir: 'docs/ai' }); expect(mockedUi.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); expect(process.exitCode).toBe(0); }); diff --git a/packages/cli/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index 8f0f951..f7a0742 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -254,6 +254,72 @@ describe('ConfigManager', () => { }); }); + describe('getDocsDir', () => { + it('should return custom docsDir when set in config', async () => { + const config: DevKitConfig = { + version: '1.0.0', + docsDir: '.ai-docs', + environments: [], + phases: [], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue(config); + + const result = await configManager.getDocsDir(); + + expect(result).toBe('.ai-docs'); + }); + + it('should return default docs/ai when docsDir is not set', async () => { + const config: DevKitConfig = { + version: '1.0.0', + environments: [], + phases: [], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue(config); + + const result = await configManager.getDocsDir(); + + expect(result).toBe('docs/ai'); + }); + + it('should return default docs/ai when config does not exist', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + + const result = await configManager.getDocsDir(); + + expect(result).toBe('docs/ai'); + }); + }); + + describe('setDocsDir', () => { + it('should update docsDir in config', async () => { + const config: DevKitConfig = { + version: '1.0.0', + environments: [], + phases: [], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue(config); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const result = await configManager.setDocsDir('.ai-docs'); + + expect(result.docsDir).toBe('.ai-docs'); + expect(mockFs.writeJson).toHaveBeenCalled(); + }); + }); + describe('getEnvironments', () => { it('should return environments array when config exists', async () => { const config: DevKitConfig = { diff --git a/packages/cli/src/__tests__/lib/InitTemplate.test.ts b/packages/cli/src/__tests__/lib/InitTemplate.test.ts index e6d13ea..d627eaa 100644 --- a/packages/cli/src/__tests__/lib/InitTemplate.test.ts +++ b/packages/cli/src/__tests__/lib/InitTemplate.test.ts @@ -88,6 +88,42 @@ environments: ); }); + it('loads template with custom docsDir', async () => { + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(` +docsDir: .ai-docs +environments: + - claude +phases: + - requirements +` as never); + + const result = await loadInitTemplate('/tmp/init.yaml'); + + expect(result.docsDir).toBe('.ai-docs'); + expect(result.environments).toEqual(['claude']); + }); + + it('throws when docsDir is empty string', async () => { + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(` +docsDir: " " +` as never); + + await expect(loadInitTemplate('/tmp/init.yaml')).rejects.toThrow( + '"docsDir" must be a non-empty string' + ); + }); + + it('throws when docsDir is not a string', async () => { + mockFs.pathExists.mockResolvedValue(true as never); + mockFs.readFile.mockResolvedValue(JSON.stringify({ docsDir: 123 }) as never); + + await expect(loadInitTemplate('/tmp/init.json')).rejects.toThrow( + '"docsDir" must be a non-empty string' + ); + }); + it('throws when unknown field exists', async () => { mockFs.pathExists.mockResolvedValue(true as never); mockFs.readFile.mockResolvedValue(` diff --git a/packages/cli/src/__tests__/lib/TemplateManager.test.ts b/packages/cli/src/__tests__/lib/TemplateManager.test.ts index fd447ba..828a3f9 100644 --- a/packages/cli/src/__tests__/lib/TemplateManager.test.ts +++ b/packages/cli/src/__tests__/lib/TemplateManager.test.ts @@ -36,10 +36,12 @@ describe('TemplateManager', () => { (mockFs.pathExists as any).mockResolvedValueOnce(true); (mockFs.readdir as any).mockResolvedValue(['command1.md', 'command2.toml']); + (mockFs.readFile as any).mockResolvedValue('command content'); + (mockFs.writeFile as any).mockResolvedValue(undefined); const result = await (templateManager as any).setupSingleEnvironment(env); - expect(mockFs.copy).toHaveBeenCalledTimes(1); + expect(mockFs.writeFile).toHaveBeenCalledTimes(1); expect(result).toEqual([path.join(templateManager['targetDir'], env.commandPath, 'command1.md')]); }); @@ -56,6 +58,8 @@ describe('TemplateManager', () => { (mockFs.pathExists as any).mockResolvedValueOnce(true); (mockFs.readdir as any).mockResolvedValue(['command1.md']); + (mockFs.readFile as any).mockResolvedValue('command content'); + (mockFs.writeFile as any).mockResolvedValue(undefined); const result = await (templateManager as any).setupSingleEnvironment(env); @@ -79,6 +83,8 @@ describe('TemplateManager', () => { (mockFs.pathExists as any).mockResolvedValueOnce(true); // commands directory exists (mockFs.readdir as any).mockResolvedValue(mockCommandFiles); + (mockFs.readFile as any).mockResolvedValue('command content'); + (mockFs.writeFile as any).mockResolvedValue(undefined); const result = await (templateManager as any).setupSingleEnvironment(env); @@ -86,20 +92,43 @@ describe('TemplateManager', () => { path.join(templateManager['targetDir'], env.commandPath) ); - // Should only copy .md files (not .toml files) - expect(mockFs.copy).toHaveBeenCalledWith( - path.join(templateManager['templatesDir'], 'commands', 'command1.md'), - path.join(templateManager['targetDir'], env.commandPath, 'command1.md') + // Should only write .md files (not .toml files) + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], env.commandPath, 'command1.md'), + 'command content' ); - expect(mockFs.copy).toHaveBeenCalledWith( - path.join(templateManager['templatesDir'], 'commands', 'command3.md'), - path.join(templateManager['targetDir'], env.commandPath, 'command3.md') + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], env.commandPath, 'command3.md'), + 'command content' ); expect(result).toContain(path.join(templateManager['targetDir'], env.commandPath, 'command1.md')); expect(result).toContain(path.join(templateManager['targetDir'], env.commandPath, 'command3.md')); }); + it('should replace docs/ai with custom docsDir in command content', async () => { + const customManager = new TemplateManager('/test/target', '.ai-docs'); + const env: EnvironmentDefinition = { + code: 'test-env', + name: 'Test Environment', + contextFileName: '.test-context.md', + commandPath: '.test', + isCustomCommandPath: false + }; + + (mockFs.pathExists as any).mockResolvedValueOnce(true); + (mockFs.readdir as any).mockResolvedValue(['command1.md']); + (mockFs.readFile as any).mockResolvedValue('Review docs/ai/design/feature-{name}.md and docs/ai/requirements/.'); + (mockFs.writeFile as any).mockResolvedValue(undefined); + + await (customManager as any).setupSingleEnvironment(env); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.join(customManager['targetDir'], env.commandPath, 'command1.md'), + 'Review .ai-docs/design/feature-{name}.md and .ai-docs/requirements/.' + ); + }); + it('should skip commands when isCustomCommandPath is true', async () => { const env: EnvironmentDefinition = { code: 'test-env', @@ -235,6 +264,25 @@ This is the prompt content.`; ); expect(result).toBe(path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md')); }); + + it('should use custom docsDir when provided', async () => { + const customManager = new TemplateManager('/test/target', '.ai-docs'); + const phase: Phase = 'design'; + + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.copy as any).mockResolvedValue(undefined); + + const result = await customManager.copyPhaseTemplate(phase); + + expect(mockFs.ensureDir).toHaveBeenCalledWith( + path.join(customManager['targetDir'], '.ai-docs', phase) + ); + expect(mockFs.copy).toHaveBeenCalledWith( + path.join(customManager['templatesDir'], 'phases', `${phase}.md`), + path.join(customManager['targetDir'], '.ai-docs', phase, 'README.md') + ); + expect(result).toBe(path.join(customManager['targetDir'], '.ai-docs', phase, 'README.md')); + }); }); describe('fileExists', () => { @@ -263,6 +311,20 @@ This is the prompt content.`; ); expect(result).toBe(false); }); + + it('should check custom docsDir path when provided', async () => { + const customManager = new TemplateManager('/test/target', 'custom/docs'); + const phase: Phase = 'testing'; + + (mockFs.pathExists as any).mockResolvedValue(true); + + const result = await customManager.fileExists(phase); + + expect(mockFs.pathExists).toHaveBeenCalledWith( + path.join(customManager['targetDir'], 'custom/docs', phase, 'README.md') + ); + expect(result).toBe(true); + }); }); describe('setupMultipleEnvironments', () => { @@ -633,12 +695,13 @@ description: Test mockGetEnvironment.mockReturnValue(envWithGlobal); (mockFs.ensureDir as any).mockResolvedValue(undefined); (mockFs.readdir as any).mockResolvedValue(mockCommandFiles); - (mockFs.copy as any).mockResolvedValue(undefined); + (mockFs.readFile as any).mockResolvedValue('command content'); + (mockFs.writeFile as any).mockResolvedValue(undefined); const result = await templateManager.copyCommandsToGlobal('antigravity'); expect(mockFs.ensureDir).toHaveBeenCalled(); - expect(mockFs.copy).toHaveBeenCalledTimes(2); // Only .md files + expect(mockFs.writeFile).toHaveBeenCalledTimes(2); // Only .md files expect(result).toHaveLength(2); }); @@ -656,11 +719,12 @@ description: Test mockGetEnvironment.mockReturnValue(envWithGlobal); (mockFs.ensureDir as any).mockResolvedValue(undefined); (mockFs.readdir as any).mockResolvedValue(mockCommandFiles); - (mockFs.copy as any).mockResolvedValue(undefined); + (mockFs.readFile as any).mockResolvedValue('command content'); + (mockFs.writeFile as any).mockResolvedValue(undefined); const result = await templateManager.copyCommandsToGlobal('codex'); - expect(mockFs.copy).toHaveBeenCalledTimes(1); + expect(mockFs.writeFile).toHaveBeenCalledTimes(1); expect(result).toHaveLength(1); }); diff --git a/packages/cli/src/__tests__/services/install/install.service.test.ts b/packages/cli/src/__tests__/services/install/install.service.test.ts index 72291e8..f5d9a6a 100644 --- a/packages/cli/src/__tests__/services/install/install.service.test.ts +++ b/packages/cli/src/__tests__/services/install/install.service.test.ts @@ -6,7 +6,8 @@ const mockConfigManager: any = { read: jest.fn(), create: jest.fn(), update: jest.fn(), - addPhase: jest.fn() + addPhase: jest.fn(), + getDocsDir: jest.fn() }; const mockTemplateManager: any = { @@ -65,6 +66,7 @@ describe('install service', () => { }); mockConfigManager.update.mockResolvedValue({}); mockConfigManager.addPhase.mockResolvedValue({}); + mockConfigManager.getDocsDir.mockResolvedValue('docs/ai'); mockTemplateManager.checkEnvironmentExists.mockResolvedValue(false); mockTemplateManager.fileExists.mockResolvedValue(false); diff --git a/packages/cli/src/__tests__/services/lint/lint.test.ts b/packages/cli/src/__tests__/services/lint/lint.test.ts index 76cb2fd..bd1e1ed 100644 --- a/packages/cli/src/__tests__/services/lint/lint.test.ts +++ b/packages/cli/src/__tests__/services/lint/lint.test.ts @@ -104,6 +104,34 @@ describe('lint service', () => { ).toBe(true); }); + it('uses custom docsDir from options', () => { + const existingPaths = new Set([ + '/repo/custom-docs/requirements/README.md', + '/repo/custom-docs/design/README.md', + '/repo/custom-docs/planning/README.md', + '/repo/custom-docs/implementation/README.md', + '/repo/custom-docs/testing/README.md', + ]); + + const report = runLintChecks({ docsDir: 'custom-docs' }, { + cwd: () => '/repo', + existsSync: (p: string) => existingPaths.has(p) + }); + + expect(report.exitCode).toBe(0); + expect(report.pass).toBe(true); + expect(report.checks.every(check => check.message.startsWith('custom-docs/'))).toBe(true); + }); + + it('falls back to default docs/ai when docsDir is not provided', () => { + const report = runLintChecks({}, { + cwd: () => '/repo', + existsSync: () => false + }); + + expect(report.checks.every(check => check.message.startsWith('docs/ai/'))).toBe(true); + }); + it('fails fast for invalid feature names', () => { const report = runLintChecks({ feature: 'bad name;rm -rf /' }, { cwd: () => '/repo', diff --git a/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts b/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts index d9268ca..ef46c06 100644 --- a/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts +++ b/packages/cli/src/__tests__/services/lint/rules/base-docs.rule.lint.test.ts @@ -10,12 +10,33 @@ describe('base docs rule', () => { execFileSync: () => '' }; - const checks = runBaseDocsRules('/repo', deps); + const checks = runBaseDocsRules('/repo', 'docs/ai', deps); expect(checks).toHaveLength(5); expect(checks.every(check => check.level === 'ok')).toBe(true); }); + it('uses custom docsDir for file paths', () => { + const existingPaths = new Set([ + '/repo/.ai-docs/requirements/README.md', + '/repo/.ai-docs/design/README.md', + '/repo/.ai-docs/planning/README.md', + '/repo/.ai-docs/implementation/README.md', + '/repo/.ai-docs/testing/README.md', + ]); + const deps: LintDependencies = { + cwd: () => '/repo', + existsSync: (p: string) => existingPaths.has(p), + execFileSync: () => '' + }; + + const checks = runBaseDocsRules('/repo', '.ai-docs', deps); + + expect(checks).toHaveLength(5); + expect(checks.every(check => check.level === 'ok')).toBe(true); + expect(checks[0].message).toBe('.ai-docs/requirements/README.md'); + }); + it('returns missing checks when base docs do not exist', () => { const deps: LintDependencies = { cwd: () => '/repo', @@ -23,7 +44,7 @@ describe('base docs rule', () => { execFileSync: () => '' }; - const checks = runBaseDocsRules('/repo', deps); + const checks = runBaseDocsRules('/repo', 'docs/ai', deps); expect(checks).toHaveLength(5); expect(checks.every(check => check.level === 'miss')).toBe(true); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 23013ed..6ded0e2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -26,6 +26,7 @@ program .option('-a, --all', 'Initialize all phases') .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)') .action(initCommand); program diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3f58c3d..5d844a9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,7 +6,7 @@ import { EnvironmentSelector } from '../lib/EnvironmentSelector'; import { PhaseSelector } from '../lib/PhaseSelector'; import { SkillManager } from '../lib/SkillManager'; import { loadInitTemplate, InitTemplateSkill } from '../lib/InitTemplate'; -import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase } from '../types'; +import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase, DEFAULT_DOCS_DIR } from '../types'; import { isValidEnvironmentCode } from '../util/env'; import { ui } from '../util/terminal-ui'; @@ -46,6 +46,7 @@ interface InitOptions { all?: boolean; phases?: string; template?: string; + docsDir?: string; } function normalizeEnvironmentOption( @@ -208,6 +209,25 @@ export async function initCommand(options: InitOptions) { return; } + let docsDir = DEFAULT_DOCS_DIR; + if (options.docsDir?.trim()) { + docsDir = options.docsDir.trim(); + } else if (templateConfig?.docsDir) { + docsDir = templateConfig.docsDir; + } else if (!hasTemplate) { + const { selectedDocsDir } = await inquirer.prompt([ + { + type: 'input', + name: 'selectedDocsDir', + message: 'Where would you like to store AI documentation?', + default: DEFAULT_DOCS_DIR + } + ]); + docsDir = selectedDocsDir.trim() || DEFAULT_DOCS_DIR; + } + + const phaseTemplateManager = new TemplateManager(undefined, docsDir); + ui.text('Initializing AI DevKit...', { breakline: true }); let config = await configManager.read(); @@ -216,6 +236,10 @@ export async function initCommand(options: InitOptions) { ui.success('Created configuration file'); } + if (docsDir !== DEFAULT_DOCS_DIR) { + await configManager.setDocsDir(docsDir); + } + await configManager.setEnvironments(selectedEnvironments); ui.success('Updated configuration with selected environments'); @@ -229,13 +253,13 @@ export async function initCommand(options: InitOptions) { } } ui.text('Setting up environment templates...', { breakline: true }); - const envFiles = await templateManager.setupMultipleEnvironments(selectedEnvironments); + const envFiles = await phaseTemplateManager.setupMultipleEnvironments(selectedEnvironments); envFiles.forEach(file => { ui.success(`Created ${file}`); }); for (const phase of selectedPhases) { - const exists = await templateManager.fileExists(phase); + const exists = await phaseTemplateManager.fileExists(phase); let shouldCopy = true; if (exists) { @@ -255,7 +279,7 @@ export async function initCommand(options: InitOptions) { } if (shouldCopy) { - await templateManager.copyPhaseTemplate(phase); + await phaseTemplateManager.copyPhaseTemplate(phase); await configManager.addPhase(phase); ui.success(`Created ${phase} phase`); } else { @@ -288,7 +312,7 @@ export async function initCommand(options: InitOptions) { ui.text('AI DevKit initialized successfully!', { breakline: true }); ui.info('Next steps:'); - ui.text(' • Review and customize templates in docs/ai/'); + ui.text(` • Review and customize templates in ${docsDir}/`); ui.text(' • Your AI environments are ready to use with the generated configurations'); ui.text(' • Run `ai-devkit phase ` to add more phases later'); ui.text(' • Run `ai-devkit init` again to add more environments\n'); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index be38ede..3766c95 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,9 +1,12 @@ +import { ConfigManager } from '../lib/Config'; import { ui } from '../util/terminal-ui'; import { LINT_STATUS_LABEL } from '../services/lint/constants'; import { LintCheckResult, LintOptions, LintReport, runLintChecks } from '../services/lint/lint.service'; export async function lintCommand(options: LintOptions): Promise { - const report = runLintChecks(options); + const configManager = new ConfigManager(); + const docsDir = await configManager.getDocsDir(); + const report = runLintChecks({ ...options, docsDir }); renderLintReport(report, options); process.exitCode = report.exitCode; } diff --git a/packages/cli/src/commands/phase.ts b/packages/cli/src/commands/phase.ts index 7ada43a..11c88b4 100644 --- a/packages/cli/src/commands/phase.ts +++ b/packages/cli/src/commands/phase.ts @@ -6,7 +6,8 @@ import { ui } from '../util/terminal-ui'; export async function phaseCommand(phaseName?: string) { const configManager = new ConfigManager(); - const templateManager = new TemplateManager(); + const docsDir = await configManager.getDocsDir(); + const templateManager = new TemplateManager(undefined, docsDir); if (!(await configManager.exists())) { ui.error('AI DevKit not initialized. Run `ai-devkit init` first.'); diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 8cbc2a4..943fef6 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -1,6 +1,6 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill } from '../types'; +import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill, DEFAULT_DOCS_DIR } from '../types'; import packageJson from '../../package.json'; const CONFIG_FILE_NAME = '.ai-devkit.json'; @@ -80,6 +80,15 @@ export class ConfigManager { return Array.isArray(config.phases) && config.phases.includes(phase); } + async getDocsDir(): Promise { + const config = await this.read(); + return config?.docsDir || DEFAULT_DOCS_DIR; + } + + async setDocsDir(docsDir: string): Promise { + return this.update({ docsDir }); + } + async getEnvironments(): Promise { const config = await this.read(); return config?.environments || []; diff --git a/packages/cli/src/lib/InitTemplate.ts b/packages/cli/src/lib/InitTemplate.ts index df3d5f5..4414d23 100644 --- a/packages/cli/src/lib/InitTemplate.ts +++ b/packages/cli/src/lib/InitTemplate.ts @@ -11,12 +11,13 @@ export interface InitTemplateSkill { export interface InitTemplateConfig { version?: number | string; + docsDir?: string; environments?: EnvironmentCode[]; phases?: Phase[]; skills?: InitTemplateSkill[]; } -const ALLOWED_TEMPLATE_FIELDS = new Set(['version', 'environments', 'phases', 'skills']); +const ALLOWED_TEMPLATE_FIELDS = new Set(['version', 'docsDir', 'environments', 'phases', 'skills']); function validationError(templatePath: string, message: string): Error { return new Error(`Invalid template at ${templatePath}: ${message}`); @@ -81,6 +82,13 @@ function validateTemplate(raw: unknown, resolvedPath: string): InitTemplateConfi result.version = candidate.version; } + if (candidate.docsDir !== undefined) { + if (typeof candidate.docsDir !== 'string' || candidate.docsDir.trim().length === 0) { + throw validationError(resolvedPath, '"docsDir" must be a non-empty string'); + } + result.docsDir = candidate.docsDir.trim(); + } + if (candidate.environments !== undefined) { if (!Array.isArray(candidate.environments)) { throw validationError(resolvedPath, '"environments" must be an array of environment codes'); diff --git a/packages/cli/src/lib/TemplateManager.ts b/packages/cli/src/lib/TemplateManager.ts index 0e16809..980ddb1 100644 --- a/packages/cli/src/lib/TemplateManager.ts +++ b/packages/cli/src/lib/TemplateManager.ts @@ -2,21 +2,23 @@ import * as fs from "fs-extra"; import * as path from "path"; import * as os from "os"; import matter from "gray-matter"; -import { Phase, EnvironmentCode, EnvironmentDefinition } from "../types"; +import { Phase, EnvironmentCode, EnvironmentDefinition, DEFAULT_DOCS_DIR } from "../types"; import { getEnvironment } from "../util/env"; export class TemplateManager { private templatesDir: string; private targetDir: string; + private docsDir: string; - constructor(targetDir: string = process.cwd()) { + constructor(targetDir: string = process.cwd(), docsDir: string = DEFAULT_DOCS_DIR) { this.templatesDir = path.join(__dirname, "../../templates"); this.targetDir = targetDir; + this.docsDir = docsDir; } async copyPhaseTemplate(phase: Phase): Promise { const sourceFile = path.join(this.templatesDir, "phases", `${phase}.md`); - const targetDir = path.join(this.targetDir, "docs", "ai", phase); + const targetDir = path.join(this.targetDir, this.docsDir, phase); const targetFile = path.join(targetDir, "README.md"); await fs.ensureDir(targetDir); @@ -28,8 +30,7 @@ export class TemplateManager { async fileExists(phase: Phase): Promise { const targetFile = path.join( this.targetDir, - "docs", - "ai", + this.docsDir, phase, "README.md" ); @@ -118,9 +119,14 @@ export class TemplateManager { .filter((file: string) => file.endsWith(".md")) .map(async (file: string) => { const targetFile = file.replace('.md', commandExtension); - await fs.copy( + const content = await fs.readFile( path.join(commandsSourceDir, file), - path.join(commandsTargetDir, targetFile) + "utf-8" + ); + const replaced = this.replaceDocsDir(content); + await fs.writeFile( + path.join(commandsTargetDir, targetFile), + replaced ); copiedFiles.push(path.join(commandsTargetDir, targetFile)); }) @@ -167,7 +173,8 @@ export class TemplateManager { path.join(this.templatesDir, "commands", file), "utf-8" ); - const { data, content } = matter(mdContent); + const replaced = this.replaceDocsDir(mdContent); + const { data, content } = matter(replaced); const description = (data.description as string) || ""; const tomlContent = this.generateTomlContent(description, content.trim()); const tomlFile = file.replace(".md", ".toml"); @@ -196,6 +203,13 @@ prompt='''${escapedPrompt}''' `; } + private replaceDocsDir(content: string): string { + if (this.docsDir === DEFAULT_DOCS_DIR) { + return content; + } + return content.split(DEFAULT_DOCS_DIR).join(this.docsDir); + } + /** * Copy command templates to the global folder for a specific environment. * Global folders are located in the user's home directory. @@ -220,8 +234,10 @@ prompt='''${escapedPrompt}''' const sourceFile = path.join(commandsSourceDir, file); const targetFile = path.join(globalTargetDir, file); + const content = await fs.readFile(sourceFile, "utf-8"); + const replaced = this.replaceDocsDir(content); - await fs.copy(sourceFile, targetFile); + await fs.writeFile(targetFile, replaced); copiedFiles.push(targetFile); } } catch (error) { diff --git a/packages/cli/src/services/install/install.service.ts b/packages/cli/src/services/install/install.service.ts index d8a410d..56c5f6c 100644 --- a/packages/cli/src/services/install/install.service.ts +++ b/packages/cli/src/services/install/install.service.ts @@ -27,7 +27,8 @@ export async function reconcileAndInstall( options: InstallRunOptions = {} ): Promise { const configManager = new ConfigManager(); - const templateManager = new TemplateManager(); + const docsDir = await configManager.getDocsDir(); + const templateManager = new TemplateManager(undefined, docsDir); const skillManager = new SkillManager(configManager, new EnvironmentSelector()); const report: InstallReport = { diff --git a/packages/cli/src/services/lint/constants.ts b/packages/cli/src/services/lint/constants.ts index 5e4006c..6ed57ef 100644 --- a/packages/cli/src/services/lint/constants.ts +++ b/packages/cli/src/services/lint/constants.ts @@ -1,4 +1,6 @@ -export const DOCS_DIR = 'docs/ai'; +import { DEFAULT_DOCS_DIR } from '../../types'; + +export const DOCS_DIR = DEFAULT_DOCS_DIR; export const LIFECYCLE_PHASES = ['requirements', 'design', 'planning', 'implementation', 'testing'] as const; export const FEATURE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; diff --git a/packages/cli/src/services/lint/lint.service.ts b/packages/cli/src/services/lint/lint.service.ts index c30bb8f..ef03e5b 100644 --- a/packages/cli/src/services/lint/lint.service.ts +++ b/packages/cli/src/services/lint/lint.service.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import { execFileSync } from 'child_process'; -import { LINT_LEVEL } from './constants'; +import { DOCS_DIR, LINT_LEVEL } from './constants'; import { runBaseDocsRules } from './rules/base-docs.rule'; import { runFeatureDocsRules } from './rules/feature-docs.rule'; import { validateFeatureNameRule, normalizeFeatureName } from './rules/feature-name.rule'; @@ -27,19 +27,21 @@ export function runLintChecks( }; const cwd = deps.cwd(); + const docsDir = options.docsDir || DOCS_DIR; const checks: LintCheckResult[] = []; - checks.push(...runBaseDocsRules(cwd, deps)); + checks.push(...runBaseDocsRules(cwd, docsDir, deps)); if (!options.feature) { return finalizeReport(cwd, checks); } - return runFeatureChecks(cwd, checks, options.feature, deps); + return runFeatureChecks(cwd, docsDir, checks, options.feature, deps); } function runFeatureChecks( cwd: string, + docsDir: string, checks: LintCheckResult[], rawFeature: string, deps: LintDependencies @@ -50,7 +52,7 @@ function runFeatureChecks( return finalizeReport(cwd, checks, featureValidation.target); } - checks.push(...runFeatureDocsRules(cwd, featureValidation.target.normalizedName, deps)); + checks.push(...runFeatureDocsRules(cwd, docsDir, featureValidation.target.normalizedName, deps)); checks.push(...runGitWorktreeRules(cwd, featureValidation.target.branchName, deps)); return finalizeReport(cwd, checks, featureValidation.target); diff --git a/packages/cli/src/services/lint/rules/base-docs.rule.ts b/packages/cli/src/services/lint/rules/base-docs.rule.ts index d8a9657..531194e 100644 --- a/packages/cli/src/services/lint/rules/base-docs.rule.ts +++ b/packages/cli/src/services/lint/rules/base-docs.rule.ts @@ -1,14 +1,14 @@ -import { DOCS_DIR, LIFECYCLE_PHASES } from '../constants'; +import { LIFECYCLE_PHASES } from '../constants'; import { LintCheckResult, LintDependencies } from '../types'; import { runPhaseDocRules } from './phase-docs.rule'; -export function runBaseDocsRules(cwd: string, deps: LintDependencies): LintCheckResult[] { +export function runBaseDocsRules(cwd: string, docsDir: string, deps: LintDependencies): LintCheckResult[] { return runPhaseDocRules({ cwd, phases: LIFECYCLE_PHASES, idPrefix: 'base', category: 'base-docs', - filePathForPhase: (phase: string) => `${DOCS_DIR}/${phase}/README.md`, + filePathForPhase: (phase: string) => `${docsDir}/${phase}/README.md`, missingFix: 'Run: npx ai-devkit@latest init', deps }); diff --git a/packages/cli/src/services/lint/rules/feature-docs.rule.ts b/packages/cli/src/services/lint/rules/feature-docs.rule.ts index 01aead6..414bc83 100644 --- a/packages/cli/src/services/lint/rules/feature-docs.rule.ts +++ b/packages/cli/src/services/lint/rules/feature-docs.rule.ts @@ -1,9 +1,10 @@ -import { DOCS_DIR, LIFECYCLE_PHASES } from '../constants'; +import { LIFECYCLE_PHASES } from '../constants'; import { LintCheckResult, LintDependencies } from '../types'; import { runPhaseDocRules } from './phase-docs.rule'; export function runFeatureDocsRules( cwd: string, + docsDir: string, normalizedName: string, deps: LintDependencies ): LintCheckResult[] { @@ -12,7 +13,7 @@ export function runFeatureDocsRules( phases: LIFECYCLE_PHASES, idPrefix: 'feature-doc', category: 'feature-docs', - filePathForPhase: (phase: string) => `${DOCS_DIR}/${phase}/feature-${normalizedName}.md`, + filePathForPhase: (phase: string) => `${docsDir}/${phase}/feature-${normalizedName}.md`, deps }); } diff --git a/packages/cli/src/services/lint/types.ts b/packages/cli/src/services/lint/types.ts index f01f3ed..68f6f65 100644 --- a/packages/cli/src/services/lint/types.ts +++ b/packages/cli/src/services/lint/types.ts @@ -4,6 +4,7 @@ import { LINT_LEVEL } from './constants'; export interface LintOptions { feature?: string; json?: boolean; + docsDir?: string; } export type LintLevel = (typeof LINT_LEVEL)[keyof typeof LINT_LEVEL]; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 7ef25fd..d7f6ee5 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -21,8 +21,11 @@ export interface EnvironmentDefinition { export type EnvironmentCode = 'cursor' | 'claude' | 'github' | 'gemini' | 'codex' | 'windsurf' | 'kilocode' | 'amp' | 'opencode' | 'roo' | 'antigravity'; +export const DEFAULT_DOCS_DIR = 'docs/ai'; + export interface DevKitConfig { version: string; + docsDir?: string; environments: EnvironmentCode[]; phases: Phase[]; skills?: ConfigSkill[]; diff --git a/packages/cli/src/util/config.ts b/packages/cli/src/util/config.ts index aebd5a4..0b00702 100644 --- a/packages/cli/src/util/config.ts +++ b/packages/cli/src/util/config.ts @@ -30,6 +30,7 @@ const skillEntrySchema = z.object({ }); const installConfigSchema = z.object({ + docsDir: z.string().trim().min(1).optional(), environments: z.array(z.string()).optional().default([]).superRefine((values, ctx) => { values.forEach((value, index) => { if (!isValidEnvironmentCode(value)) {