Skip to content

Commit 765df47

Browse files
alleninnzclaude
andauthored
fix(init): prevent false GitHub Copilot auto-detection from bare .github/ directory (#917)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 64d476f commit 765df47

File tree

5 files changed

+82
-3
lines changed

5 files changed

+82
-3
lines changed

src/core/available-tools.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,26 @@ import { AI_TOOLS, type AIToolOption } from './config.js';
1313
* Scans the project path for AI tool configuration directories and returns
1414
* the tools that are present.
1515
*
16-
* Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the
17-
* project root. Only tools with a `skillsDir` property are considered.
16+
* For tools with `detectionPaths`, checks those specific paths (files or
17+
* directories). Otherwise checks for the tool's `skillsDir` directory at
18+
* the project root. Only tools with a `skillsDir` property are considered.
1819
*/
1920
export function getAvailableTools(projectPath: string): AIToolOption[] {
2021
return AI_TOOLS.filter((tool) => {
2122
if (!tool.skillsDir) return false;
23+
24+
if (tool.detectionPaths && tool.detectionPaths.length > 0) {
25+
// statSync without .isDirectory() — detection paths can be files or directories
26+
return tool.detectionPaths.some((p) => {
27+
try {
28+
fs.statSync(path.join(projectPath, p));
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
});
34+
}
35+
2236
const dirPath = path.join(projectPath, tool.skillsDir);
2337
try {
2438
return fs.statSync(dirPath).isDirectory();

src/core/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface AIToolOption {
1515
available: boolean;
1616
successLabel?: string;
1717
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
18+
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
1819
}
1920

2021
export const AI_TOOLS: AIToolOption[] = [
@@ -31,7 +32,7 @@ export const AI_TOOLS: AIToolOption[] = [
3132
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
3233
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
3334
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
34-
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },
35+
{ 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'] },
3536
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
3637
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
3738
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },

test/core/available-tools.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,66 @@ describe('available-tools', () => {
8787
expect(tools).toHaveLength(1);
8888
expect(tools[0].value).toBe('claude');
8989
});
90+
91+
it('should not detect GitHub Copilot from bare .github directory', async () => {
92+
// .github/ exists in virtually every GitHub repo (for workflows, issue templates, etc.)
93+
// A bare .github/ directory should NOT trigger Copilot detection
94+
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
95+
96+
const tools = getAvailableTools(testDir);
97+
const toolValues = tools.map((t) => t.value);
98+
expect(toolValues).not.toContain('github-copilot');
99+
});
100+
101+
it('should detect GitHub Copilot when copilot-instructions.md exists', async () => {
102+
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
103+
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
104+
105+
const tools = getAvailableTools(testDir);
106+
const toolValues = tools.map((t) => t.value);
107+
expect(toolValues).toContain('github-copilot');
108+
});
109+
110+
it('should detect GitHub Copilot when .github/prompts directory exists', async () => {
111+
await fs.mkdir(path.join(testDir, '.github', 'prompts'), { recursive: true });
112+
113+
const tools = getAvailableTools(testDir);
114+
const toolValues = tools.map((t) => t.value);
115+
expect(toolValues).toContain('github-copilot');
116+
});
117+
118+
it('should detect GitHub Copilot when .github/agents directory exists', async () => {
119+
await fs.mkdir(path.join(testDir, '.github', 'agents'), { recursive: true });
120+
121+
const tools = getAvailableTools(testDir);
122+
const toolValues = tools.map((t) => t.value);
123+
expect(toolValues).toContain('github-copilot');
124+
});
125+
126+
it('should detect GitHub Copilot when .github/skills directory exists', async () => {
127+
await fs.mkdir(path.join(testDir, '.github', 'skills'), { recursive: true });
128+
129+
const tools = getAvailableTools(testDir);
130+
const toolValues = tools.map((t) => t.value);
131+
expect(toolValues).toContain('github-copilot');
132+
});
133+
134+
it('should detect GitHub Copilot when copilot-setup-steps.yml exists', async () => {
135+
await fs.mkdir(path.join(testDir, '.github', 'workflows'), { recursive: true });
136+
await fs.writeFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), '');
137+
138+
const tools = getAvailableTools(testDir);
139+
const toolValues = tools.map((t) => t.value);
140+
expect(toolValues).toContain('github-copilot');
141+
});
142+
143+
it('should still use skillsDir detection for tools without detectionPaths', async () => {
144+
// Claude Code has no detectionPaths, so .claude/ directory should still work
145+
await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });
146+
147+
const tools = getAvailableTools(testDir);
148+
const toolValues = tools.map((t) => t.value);
149+
expect(toolValues).toContain('claude');
150+
});
90151
});
91152
});

test/core/init.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ describe('InitCommand - profile and detection features', () => {
565565

566566
// Directory detected only (not configured with OpenSpec)
567567
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
568+
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
568569

569570
searchableMultiSelectMock.mockResolvedValue(['claude']);
570571

@@ -587,6 +588,7 @@ describe('InitCommand - profile and detection features', () => {
587588
it('should preselect detected tools for first-time interactive setup', async () => {
588589
// First-time init: no openspec/ directory and no configured OpenSpec skills.
589590
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
591+
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
590592

591593
searchableMultiSelectMock.mockResolvedValue(['github-copilot']);
592594

test/core/update.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,7 @@ content
16441644

16451645
// Create two unconfigured tool directories
16461646
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
1647+
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
16471648
await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true });
16481649

16491650
const consoleSpy = vi.spyOn(console, 'log');

0 commit comments

Comments
 (0)