diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts index 6b45b3ca6..f3dabe97d 100644 --- a/src/core/available-tools.ts +++ b/src/core/available-tools.ts @@ -13,12 +13,26 @@ import { AI_TOOLS, type AIToolOption } from './config.js'; * Scans the project path for AI tool configuration directories and returns * the tools that are present. * - * Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the - * project root. Only tools with a `skillsDir` property are considered. + * For tools with `detectionPaths`, checks those specific paths (files or + * directories). Otherwise checks for the tool's `skillsDir` directory at + * the project root. Only tools with a `skillsDir` property are considered. */ export function getAvailableTools(projectPath: string): AIToolOption[] { return AI_TOOLS.filter((tool) => { if (!tool.skillsDir) return false; + + if (tool.detectionPaths && tool.detectionPaths.length > 0) { + // statSync without .isDirectory() — detection paths can be files or directories + return tool.detectionPaths.some((p) => { + try { + fs.statSync(path.join(projectPath, p)); + return true; + } catch { + return false; + } + }); + } + const dirPath = path.join(projectPath, tool.skillsDir); try { return fs.statSync(dirPath).isDirectory(); diff --git a/src/core/config.ts b/src/core/config.ts index f35f92861..bf852999e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,6 +15,7 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection } export const AI_TOOLS: AIToolOption[] = [ @@ -31,7 +32,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' }, { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' }, { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' }, - { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' }, + { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] }, { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' }, { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index fb2912eb5..83942dfb3 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -87,5 +87,66 @@ describe('available-tools', () => { expect(tools).toHaveLength(1); expect(tools[0].value).toBe('claude'); }); + + it('should not detect GitHub Copilot from bare .github directory', async () => { + // .github/ exists in virtually every GitHub repo (for workflows, issue templates, etc.) + // A bare .github/ directory should NOT trigger Copilot detection + await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).not.toContain('github-copilot'); + }); + + it('should detect GitHub Copilot when copilot-instructions.md exists', async () => { + await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), ''); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('github-copilot'); + }); + + it('should detect GitHub Copilot when .github/prompts directory exists', async () => { + await fs.mkdir(path.join(testDir, '.github', 'prompts'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('github-copilot'); + }); + + it('should detect GitHub Copilot when .github/agents directory exists', async () => { + await fs.mkdir(path.join(testDir, '.github', 'agents'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('github-copilot'); + }); + + it('should detect GitHub Copilot when .github/skills directory exists', async () => { + await fs.mkdir(path.join(testDir, '.github', 'skills'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('github-copilot'); + }); + + it('should detect GitHub Copilot when copilot-setup-steps.yml exists', async () => { + await fs.mkdir(path.join(testDir, '.github', 'workflows'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), ''); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('github-copilot'); + }); + + it('should still use skillsDir detection for tools without detectionPaths', async () => { + // Claude Code has no detectionPaths, so .claude/ directory should still work + await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('claude'); + }); }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6af92aed2..c2499e4d6 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -565,6 +565,7 @@ describe('InitCommand - profile and detection features', () => { // Directory detected only (not configured with OpenSpec) await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), ''); searchableMultiSelectMock.mockResolvedValue(['claude']); @@ -587,6 +588,7 @@ describe('InitCommand - profile and detection features', () => { it('should preselect detected tools for first-time interactive setup', async () => { // First-time init: no openspec/ directory and no configured OpenSpec skills. await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), ''); searchableMultiSelectMock.mockResolvedValue(['github-copilot']); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c36ac3da8..f3d393d93 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1644,6 +1644,7 @@ content // Create two unconfigured tool directories await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), ''); await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true }); const consoleSpy = vi.spyOn(console, 'log');