Skip to content

Commit fe99680

Browse files
hiiamtrongsotatek
andauthored
feat: allow custom docs folder (#32)
* feat: allow custom docs folder path via docsDir config Add a docsDir option to .ai-devkit.json that allows users to customize where AI documentation is stored instead of the hardcoded docs/ai path. The init command now prompts for the docs directory, and templates also support the new field. Closes #31 * Add --docs-dir CLI flag to init command Allows setting custom docs directory non-interactively via `ai-devkit init --docs-dir .ai-docs`. Priority: CLI flag > template config > interactive prompt > default (docs/ai). * fix: replace hardcoded docs/ai in command templates with custom docsDir When copying command templates, replace all `docs/ai` references with the configured docsDir value. This affects copyCommands, copyGeminiSpecificFiles, and copyCommandsToGlobal. Also fix init command to use docsDir-aware template manager for environment setup. * refactor: address PR review feedback for custom docs dir - Rename config field from flat `docsDir` to namespaced `paths.docs` - Change TemplateManager constructor to options object pattern - Use {{docsDir}} template variables instead of naive string replacement - Separate docsDir from LintOptions into standalone parameter - Remove interactive prompt for docsDir during init - Remove redundant DOCS_DIR constant re-export * refactor: remove docsDir from InitTemplateConfig, use paths.docs only Since the feature hasn't shipped yet, there's no need for backward compatibility with docsDir in templates. This eliminates the dual-schema confusion and simplifies the fallback chain. * test: remove redundant docsDir unknown field test The existing 'throws when unknown field exists' test already covers this code path. --------- Co-authored-by: sotatek <sotatek@Davids-Mac.local>
1 parent 120f7ad commit fe99680

31 files changed

+383
-67
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/src/__tests__/commands/lint.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { ui } from '../../util/terminal-ui';
33
import { lintCommand, renderLintReport } from '../../commands/lint';
44
import { LintReport, runLintChecks } from '../../services/lint/lint.service';
55

6+
jest.mock('../../lib/Config', () => ({
7+
ConfigManager: jest.fn(() => ({
8+
getDocsDir: jest.fn<() => Promise<string>>().mockResolvedValue('docs/ai')
9+
}))
10+
}));
11+
612
jest.mock('../../services/lint/lint.service', () => ({
713
runLintChecks: jest.fn()
814
}));
@@ -46,7 +52,7 @@ describe('lint command', () => {
4652

4753
await lintCommand({ feature: 'lint-command', json: true });
4854

49-
expect(mockedRunLintChecks).toHaveBeenCalledWith({ feature: 'lint-command', json: true });
55+
expect(mockedRunLintChecks).toHaveBeenCalledWith({ feature: 'lint-command', json: true }, 'docs/ai');
5056
expect(mockedUi.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2));
5157
expect(process.exitCode).toBe(0);
5258
});

packages/cli/src/__tests__/lib/Config.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,72 @@ describe('ConfigManager', () => {
254254
});
255255
});
256256

257+
describe('getDocsDir', () => {
258+
it('should return custom docsDir when set in config', async () => {
259+
const config: DevKitConfig = {
260+
version: '1.0.0',
261+
paths: { docs: '.ai-docs' },
262+
environments: [],
263+
phases: [],
264+
createdAt: '2024-01-01T00:00:00.000Z',
265+
updatedAt: '2024-01-01T00:00:00.000Z'
266+
};
267+
268+
(mockFs.pathExists as any).mockResolvedValue(true);
269+
(mockFs.readJson as any).mockResolvedValue(config);
270+
271+
const result = await configManager.getDocsDir();
272+
273+
expect(result).toBe('.ai-docs');
274+
});
275+
276+
it('should return default docs/ai when docsDir is not set', async () => {
277+
const config: DevKitConfig = {
278+
version: '1.0.0',
279+
environments: [],
280+
phases: [],
281+
createdAt: '2024-01-01T00:00:00.000Z',
282+
updatedAt: '2024-01-01T00:00:00.000Z'
283+
};
284+
285+
(mockFs.pathExists as any).mockResolvedValue(true);
286+
(mockFs.readJson as any).mockResolvedValue(config);
287+
288+
const result = await configManager.getDocsDir();
289+
290+
expect(result).toBe('docs/ai');
291+
});
292+
293+
it('should return default docs/ai when config does not exist', async () => {
294+
(mockFs.pathExists as any).mockResolvedValue(false);
295+
296+
const result = await configManager.getDocsDir();
297+
298+
expect(result).toBe('docs/ai');
299+
});
300+
});
301+
302+
describe('setDocsDir', () => {
303+
it('should update docsDir in config', async () => {
304+
const config: DevKitConfig = {
305+
version: '1.0.0',
306+
environments: [],
307+
phases: [],
308+
createdAt: '2024-01-01T00:00:00.000Z',
309+
updatedAt: '2024-01-01T00:00:00.000Z'
310+
};
311+
312+
(mockFs.pathExists as any).mockResolvedValue(true);
313+
(mockFs.readJson as any).mockResolvedValue(config);
314+
(mockFs.writeJson as any).mockResolvedValue(undefined);
315+
316+
const result = await configManager.setDocsDir('.ai-docs');
317+
318+
expect(result.paths?.docs).toBe('.ai-docs');
319+
expect(mockFs.writeJson).toHaveBeenCalled();
320+
});
321+
});
322+
257323
describe('getEnvironments', () => {
258324
it('should return environments array when config exists', async () => {
259325
const config: DevKitConfig = {

packages/cli/src/__tests__/lib/InitTemplate.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,44 @@ environments:
8888
);
8989
});
9090

91+
it('loads template with paths.docs config', async () => {
92+
mockFs.pathExists.mockResolvedValue(true as never);
93+
mockFs.readFile.mockResolvedValue(`
94+
paths:
95+
docs: .ai-docs
96+
environments:
97+
- claude
98+
phases:
99+
- requirements
100+
` as never);
101+
102+
const result = await loadInitTemplate('/tmp/init.yaml');
103+
104+
expect(result.paths?.docs).toBe('.ai-docs');
105+
expect(result.environments).toEqual(['claude']);
106+
});
107+
108+
it('throws when paths.docs is empty string', async () => {
109+
mockFs.pathExists.mockResolvedValue(true as never);
110+
mockFs.readFile.mockResolvedValue(`
111+
paths:
112+
docs: " "
113+
` as never);
114+
115+
await expect(loadInitTemplate('/tmp/init.yaml')).rejects.toThrow(
116+
'"paths.docs" must be a non-empty string'
117+
);
118+
});
119+
120+
it('throws when paths is not an object', async () => {
121+
mockFs.pathExists.mockResolvedValue(true as never);
122+
mockFs.readFile.mockResolvedValue(JSON.stringify({ paths: 'invalid' }) as never);
123+
124+
await expect(loadInitTemplate('/tmp/init.json')).rejects.toThrow(
125+
'"paths" must be an object'
126+
);
127+
});
128+
91129
it('throws when unknown field exists', async () => {
92130
mockFs.pathExists.mockResolvedValue(true as never);
93131
mockFs.readFile.mockResolvedValue(`

packages/cli/src/__tests__/lib/TemplateManager.test.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('TemplateManager', () => {
1414
beforeEach(() => {
1515
mockFs = fs as jest.Mocked<typeof fs>;
1616
mockGetEnvironment = require('../../util/env').getEnvironment as jest.MockedFunction<any>;
17-
templateManager = new TemplateManager('/test/target');
17+
templateManager = new TemplateManager({ targetDir: '/test/target' });
1818

1919
jest.clearAllMocks();
2020
});
@@ -36,10 +36,12 @@ describe('TemplateManager', () => {
3636
(mockFs.pathExists as any).mockResolvedValueOnce(true);
3737

3838
(mockFs.readdir as any).mockResolvedValue(['command1.md', 'command2.toml']);
39+
(mockFs.readFile as any).mockResolvedValue('command content');
40+
(mockFs.writeFile as any).mockResolvedValue(undefined);
3941

4042
const result = await (templateManager as any).setupSingleEnvironment(env);
4143

42-
expect(mockFs.copy).toHaveBeenCalledTimes(1);
44+
expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
4345
expect(result).toEqual([path.join(templateManager['targetDir'], env.commandPath, 'command1.md')]);
4446
});
4547

@@ -56,6 +58,8 @@ describe('TemplateManager', () => {
5658
(mockFs.pathExists as any).mockResolvedValueOnce(true);
5759

5860
(mockFs.readdir as any).mockResolvedValue(['command1.md']);
61+
(mockFs.readFile as any).mockResolvedValue('command content');
62+
(mockFs.writeFile as any).mockResolvedValue(undefined);
5963

6064
const result = await (templateManager as any).setupSingleEnvironment(env);
6165

@@ -79,27 +83,52 @@ describe('TemplateManager', () => {
7983
(mockFs.pathExists as any).mockResolvedValueOnce(true); // commands directory exists
8084

8185
(mockFs.readdir as any).mockResolvedValue(mockCommandFiles);
86+
(mockFs.readFile as any).mockResolvedValue('command content');
87+
(mockFs.writeFile as any).mockResolvedValue(undefined);
8288

8389
const result = await (templateManager as any).setupSingleEnvironment(env);
8490

8591
expect(mockFs.ensureDir).toHaveBeenCalledWith(
8692
path.join(templateManager['targetDir'], env.commandPath)
8793
);
8894

89-
// Should only copy .md files (not .toml files)
90-
expect(mockFs.copy).toHaveBeenCalledWith(
91-
path.join(templateManager['templatesDir'], 'commands', 'command1.md'),
92-
path.join(templateManager['targetDir'], env.commandPath, 'command1.md')
95+
// Should only write .md files (not .toml files)
96+
expect(mockFs.writeFile).toHaveBeenCalledWith(
97+
path.join(templateManager['targetDir'], env.commandPath, 'command1.md'),
98+
'command content'
9399
);
94-
expect(mockFs.copy).toHaveBeenCalledWith(
95-
path.join(templateManager['templatesDir'], 'commands', 'command3.md'),
96-
path.join(templateManager['targetDir'], env.commandPath, 'command3.md')
100+
expect(mockFs.writeFile).toHaveBeenCalledWith(
101+
path.join(templateManager['targetDir'], env.commandPath, 'command3.md'),
102+
'command content'
97103
);
98104

99105
expect(result).toContain(path.join(templateManager['targetDir'], env.commandPath, 'command1.md'));
100106
expect(result).toContain(path.join(templateManager['targetDir'], env.commandPath, 'command3.md'));
101107
});
102108

109+
it('should replace docs/ai with custom docsDir in command content', async () => {
110+
const customManager = new TemplateManager({ targetDir: '/test/target', docsDir: '.ai-docs' });
111+
const env: EnvironmentDefinition = {
112+
code: 'test-env',
113+
name: 'Test Environment',
114+
contextFileName: '.test-context.md',
115+
commandPath: '.test',
116+
isCustomCommandPath: false
117+
};
118+
119+
(mockFs.pathExists as any).mockResolvedValueOnce(true);
120+
(mockFs.readdir as any).mockResolvedValue(['command1.md']);
121+
(mockFs.readFile as any).mockResolvedValue('Review {{docsDir}}/design/feature-{name}.md and {{docsDir}}/requirements/.');
122+
(mockFs.writeFile as any).mockResolvedValue(undefined);
123+
124+
await (customManager as any).setupSingleEnvironment(env);
125+
126+
expect(mockFs.writeFile).toHaveBeenCalledWith(
127+
path.join(customManager['targetDir'], env.commandPath, 'command1.md'),
128+
'Review .ai-docs/design/feature-{name}.md and .ai-docs/requirements/.'
129+
);
130+
});
131+
103132
it('should skip commands when isCustomCommandPath is true', async () => {
104133
const env: EnvironmentDefinition = {
105134
code: 'test-env',
@@ -235,6 +264,25 @@ This is the prompt content.`;
235264
);
236265
expect(result).toBe(path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md'));
237266
});
267+
268+
it('should use custom docsDir when provided', async () => {
269+
const customManager = new TemplateManager({ targetDir: '/test/target', docsDir: '.ai-docs' });
270+
const phase: Phase = 'design';
271+
272+
(mockFs.ensureDir as any).mockResolvedValue(undefined);
273+
(mockFs.copy as any).mockResolvedValue(undefined);
274+
275+
const result = await customManager.copyPhaseTemplate(phase);
276+
277+
expect(mockFs.ensureDir).toHaveBeenCalledWith(
278+
path.join(customManager['targetDir'], '.ai-docs', phase)
279+
);
280+
expect(mockFs.copy).toHaveBeenCalledWith(
281+
path.join(customManager['templatesDir'], 'phases', `${phase}.md`),
282+
path.join(customManager['targetDir'], '.ai-docs', phase, 'README.md')
283+
);
284+
expect(result).toBe(path.join(customManager['targetDir'], '.ai-docs', phase, 'README.md'));
285+
});
238286
});
239287

240288
describe('fileExists', () => {
@@ -263,6 +311,20 @@ This is the prompt content.`;
263311
);
264312
expect(result).toBe(false);
265313
});
314+
315+
it('should check custom docsDir path when provided', async () => {
316+
const customManager = new TemplateManager({ targetDir: '/test/target', docsDir: 'custom/docs' });
317+
const phase: Phase = 'testing';
318+
319+
(mockFs.pathExists as any).mockResolvedValue(true);
320+
321+
const result = await customManager.fileExists(phase);
322+
323+
expect(mockFs.pathExists).toHaveBeenCalledWith(
324+
path.join(customManager['targetDir'], 'custom/docs', phase, 'README.md')
325+
);
326+
expect(result).toBe(true);
327+
});
266328
});
267329

268330
describe('setupMultipleEnvironments', () => {
@@ -633,12 +695,13 @@ description: Test
633695
mockGetEnvironment.mockReturnValue(envWithGlobal);
634696
(mockFs.ensureDir as any).mockResolvedValue(undefined);
635697
(mockFs.readdir as any).mockResolvedValue(mockCommandFiles);
636-
(mockFs.copy as any).mockResolvedValue(undefined);
698+
(mockFs.readFile as any).mockResolvedValue('command content');
699+
(mockFs.writeFile as any).mockResolvedValue(undefined);
637700

638701
const result = await templateManager.copyCommandsToGlobal('antigravity');
639702

640703
expect(mockFs.ensureDir).toHaveBeenCalled();
641-
expect(mockFs.copy).toHaveBeenCalledTimes(2); // Only .md files
704+
expect(mockFs.writeFile).toHaveBeenCalledTimes(2); // Only .md files
642705
expect(result).toHaveLength(2);
643706
});
644707

@@ -656,11 +719,12 @@ description: Test
656719
mockGetEnvironment.mockReturnValue(envWithGlobal);
657720
(mockFs.ensureDir as any).mockResolvedValue(undefined);
658721
(mockFs.readdir as any).mockResolvedValue(mockCommandFiles);
659-
(mockFs.copy as any).mockResolvedValue(undefined);
722+
(mockFs.readFile as any).mockResolvedValue('command content');
723+
(mockFs.writeFile as any).mockResolvedValue(undefined);
660724

661725
const result = await templateManager.copyCommandsToGlobal('codex');
662726

663-
expect(mockFs.copy).toHaveBeenCalledTimes(1);
727+
expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
664728
expect(result).toHaveLength(1);
665729
});
666730

0 commit comments

Comments
 (0)