From c1c0a37e622a2156fc1f0e3c4eef8f6a54978be5 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 5 Nov 2025 16:01:00 +0100 Subject: [PATCH 1/2] fix: Github Copilot with vscode --- src/__tests__/lib/TemplateManager.test.ts | 240 +++++++++++++++++++++- src/lib/TemplateManager.ts | 6 +- src/types.ts | 1 + src/util/env.ts | 3 +- 4 files changed, 246 insertions(+), 4 deletions(-) diff --git a/src/__tests__/lib/TemplateManager.test.ts b/src/__tests__/lib/TemplateManager.test.ts index 01310fe..cdd478b 100644 --- a/src/__tests__/lib/TemplateManager.test.ts +++ b/src/__tests__/lib/TemplateManager.test.ts @@ -1,16 +1,19 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { TemplateManager } from '../../lib/TemplateManager'; -import { EnvironmentDefinition } from '../../types'; +import { EnvironmentDefinition, Phase, EnvironmentCode } from '../../types'; jest.mock('fs-extra'); +jest.mock('../../util/env'); describe('TemplateManager', () => { let templateManager: TemplateManager; let mockFs: jest.Mocked; + let mockGetEnvironment: jest.MockedFunction; beforeEach(() => { mockFs = fs as jest.Mocked; + mockGetEnvironment = require('../../util/env').getEnvironment as jest.MockedFunction; templateManager = new TemplateManager('/test/target'); jest.clearAllMocks(); @@ -218,4 +221,239 @@ describe('TemplateManager', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('copyPhaseTemplate', () => { + it('should copy phase template and return target file path', async () => { + const phase: Phase = 'requirements'; + + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.copy as any).mockResolvedValue(undefined); + + const result = await templateManager.copyPhaseTemplate(phase); + + expect(mockFs.ensureDir).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], 'docs', 'ai', phase) + ); + expect(mockFs.copy).toHaveBeenCalledWith( + path.join(templateManager['templatesDir'], 'phases', `${phase}.md`), + path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md') + ); + expect(result).toBe(path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md')); + }); + }); + + describe('fileExists', () => { + it('should return true when phase file exists', async () => { + const phase: Phase = 'design'; + + (mockFs.pathExists as any).mockResolvedValue(true); + + const result = await templateManager.fileExists(phase); + + expect(mockFs.pathExists).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md') + ); + expect(result).toBe(true); + }); + + it('should return false when phase file does not exist', async () => { + const phase: Phase = 'planning'; + + (mockFs.pathExists as any).mockResolvedValue(false); + + const result = await templateManager.fileExists(phase); + + expect(mockFs.pathExists).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md') + ); + expect(result).toBe(false); + }); + }); + + describe('setupMultipleEnvironments', () => { + it('should setup multiple environments successfully', async () => { + const envIds: EnvironmentCode[] = ['cursor', 'gemini']; + const cursorEnv = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + const geminiEnv = { + code: 'gemini', + name: 'Gemini', + contextFileName: 'AGENTS.md', + commandPath: '.gemini/commands', + isCustomCommandPath: true, + }; + + mockGetEnvironment + .mockReturnValueOnce(cursorEnv) + .mockReturnValueOnce(geminiEnv); + + // Mock setupSingleEnvironment + const mockSetupSingleEnvironment = jest.fn(); + mockSetupSingleEnvironment + .mockResolvedValueOnce(['/path/to/cursor/file1', '/path/to/cursor/file2']) + .mockResolvedValueOnce(['/path/to/gemini/file1']); + + (templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment; + + const result = await templateManager.setupMultipleEnvironments(envIds); + + expect(mockGetEnvironment).toHaveBeenCalledWith('cursor'); + expect(mockGetEnvironment).toHaveBeenCalledWith('gemini'); + expect(mockSetupSingleEnvironment).toHaveBeenCalledWith(cursorEnv); + expect(mockSetupSingleEnvironment).toHaveBeenCalledWith(geminiEnv); + expect(result).toEqual([ + '/path/to/cursor/file1', + '/path/to/cursor/file2', + '/path/to/gemini/file1' + ]); + }); + + it('should skip invalid environments and continue with valid ones', async () => { + const envIds: EnvironmentCode[] = ['cursor', 'invalid' as any, 'gemini']; + const cursorEnv = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + const geminiEnv = { + code: 'gemini', + name: 'Gemini', + contextFileName: 'AGENTS.md', + commandPath: '.gemini/commands', + isCustomCommandPath: true, + }; + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + mockGetEnvironment + .mockReturnValueOnce(cursorEnv) + .mockReturnValueOnce(undefined) // invalid environment + .mockReturnValueOnce(geminiEnv); + + // Mock setupSingleEnvironment + const mockSetupSingleEnvironment = jest.fn(); + mockSetupSingleEnvironment + .mockResolvedValueOnce(['/path/to/cursor/file1']) + .mockResolvedValueOnce(['/path/to/gemini/file1']); + + (templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment; + + const result = await templateManager.setupMultipleEnvironments(envIds); + + expect(consoleWarnSpy).toHaveBeenCalledWith("Warning: Environment 'invalid' not found, skipping"); + expect(result).toEqual([ + '/path/to/cursor/file1', + '/path/to/gemini/file1' + ]); + + consoleWarnSpy.mockRestore(); + }); + + it('should throw error when setupSingleEnvironment fails', async () => { + const envIds: EnvironmentCode[] = ['cursor']; + const cursorEnv = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + + mockGetEnvironment.mockReturnValue(cursorEnv); + + const mockSetupSingleEnvironment = jest.fn().mockRejectedValue(new Error('Setup failed')); + (templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await expect(templateManager.setupMultipleEnvironments(envIds)).rejects.toThrow('Setup failed'); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Error setting up environment 'Cursor':", expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('checkEnvironmentExists', () => { + it('should return false when environment does not exist', async () => { + const envId: EnvironmentCode = 'cursor'; + + mockGetEnvironment.mockReturnValue(undefined); + + const result = await templateManager.checkEnvironmentExists(envId); + + expect(mockGetEnvironment).toHaveBeenCalledWith(envId); + expect(result).toBe(false); + }); + + it('should return true when context file exists', async () => { + const envId: EnvironmentCode = 'cursor'; + const env = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + + mockGetEnvironment.mockReturnValue(env); + + (mockFs.pathExists as any) + .mockResolvedValueOnce(true) // context file exists + .mockResolvedValueOnce(false); // command dir doesn't exist + + const result = await templateManager.checkEnvironmentExists(envId); + + expect(mockFs.pathExists).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], env.contextFileName) + ); + expect(mockFs.pathExists).toHaveBeenCalledWith( + path.join(templateManager['targetDir'], env.commandPath) + ); + expect(result).toBe(true); + }); + + it('should return true when command directory exists', async () => { + const envId: EnvironmentCode = 'cursor'; + const env = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + + mockGetEnvironment.mockReturnValue(env); + + (mockFs.pathExists as any) + .mockResolvedValueOnce(false) // context file doesn't exist + .mockResolvedValueOnce(true); // command dir exists + + const result = await templateManager.checkEnvironmentExists(envId); + + expect(result).toBe(true); + }); + + it('should return false when neither context file nor command directory exists', async () => { + const envId: EnvironmentCode = 'cursor'; + const env = { + code: 'cursor', + name: 'Cursor', + contextFileName: 'AGENTS.md', + commandPath: '.cursor/commands', + }; + + mockGetEnvironment.mockReturnValue(env); + + (mockFs.pathExists as any) + .mockResolvedValueOnce(false) // context file doesn't exist + .mockResolvedValueOnce(false); // command dir doesn't exist + + const result = await templateManager.checkEnvironmentExists(envId); + + expect(result).toBe(false); + }); + }); }); diff --git a/src/lib/TemplateManager.ts b/src/lib/TemplateManager.ts index 49ee3d3..076b006 100644 --- a/src/lib/TemplateManager.ts +++ b/src/lib/TemplateManager.ts @@ -117,6 +117,7 @@ export class TemplateManager { copiedFiles: string[] ): Promise { const commandsSourceDir = path.join(this.templatesDir, "commands"); + const commandExtension = env.customCommandExtension || ".md"; const commandsTargetDir = path.join(this.targetDir, env.commandPath); if (await fs.pathExists(commandsSourceDir)) { @@ -127,11 +128,12 @@ export class TemplateManager { commandFiles .filter((file: string) => file.endsWith(".md")) .map(async (file: string) => { + const targetFile = file.replace('.md', commandExtension); await fs.copy( path.join(commandsSourceDir, file), - path.join(commandsTargetDir, file) + path.join(commandsTargetDir, targetFile) ); - copiedFiles.push(path.join(commandsTargetDir, file)); + copiedFiles.push(path.join(commandsTargetDir, targetFile)); }) ); } else { diff --git a/src/types.ts b/src/types.ts index 5ac001d..bf9671e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface EnvironmentDefinition { commandPath: string; description?: string; isCustomCommandPath?: boolean; + customCommandExtension?: string; } export type EnvironmentCode = 'cursor' | 'claude' | 'github' | 'gemini' | 'codex' | 'windsurf' | 'kilocode' | 'amp' | 'opencode' | 'roo'; diff --git a/src/util/env.ts b/src/util/env.ts index 6264fe7..f545aa0 100644 --- a/src/util/env.ts +++ b/src/util/env.ts @@ -17,7 +17,8 @@ export const ENVIRONMENT_DEFINITIONS: Record Date: Wed, 5 Nov 2025 16:03:09 +0100 Subject: [PATCH 2/2] Update CHANGELOG --- CHANGELOG.md | 1 + README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd901f6..c937714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed Gemini CLI integration https://github.com/codeaholicguy/ai-devkit/issues/3 - Added test for TemplateManager.ts +- Fixed Github Copilot integration https://github.com/codeaholicguy/ai-devkit/issues/4 ## [0.4.0] - 2025-10-31 diff --git a/README.md b/README.md index 603ca8e..9e418dd 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ Supported Tools & Agents: | Agent | Support | Notes | |-----------------------------------------------------------|---------|---------------------------------------------------| | [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | -| [GitHub Copilot](https://code.visualstudio.com/) | 🚧 | Testing | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | 🚧 | Testing | +| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | VSCode only | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | | [Cursor](https://cursor.sh/) | ✅ | | | [opencode](https://opencode.ai/) | 🚧 | Testing | | [Windsurf](https://windsurf.com/) | 🚧 | Testing |