Skip to content

Commit cf0de5e

Browse files
authored
fix: prevent false 'already configured' detection for tools (#239)
* fix: prevent false "already configured" detection for tools Fixes #195 ## Problem Users with existing tool config files (like CLAUDE.md) would see those tools marked as "already configured" even when running `openspec init` for the first time. This caused confusion as users thought OpenSpec was already set up when it wasn't. Root causes: 1. Tool detection checked only for file existence, not OpenSpec ownership 2. Detection ran even in fresh projects without openspec/ folder ## Solution Two-part fix: 1. **Conditional detection**: Only check tool configuration when in extend mode (when openspec/ directory already exists). Fresh initializations skip the check entirely, treating all tools as unconfigured. 2. **Marker-based validation**: For tools to be considered "configured by OpenSpec", their files must contain OpenSpec markers (<!-- OPENSPEC:START --> and <!-- OPENSPEC:END -->). For tools with both config files and slash commands (like Claude Code), BOTH must have markers. ## Changes - Modified getExistingToolStates() to accept extendMode parameter - Rewrote isToolConfigured() to verify OpenSpec markers in files - Added OPENSPEC_MARKERS to imports - Added 4 comprehensive test cases covering the new behavior ## Test Coverage - Fresh init with existing CLAUDE.md → NOT shown as configured ✅ - Fresh init with existing slash commands → NOT shown as configured ✅ - Extend mode with OpenSpec files → shown as configured ✅ - Fresh init with global Codex prompts → NOT shown as configured ✅ All 240 tests pass. * refactor: optimize tool state detection and improve code clarity Address code review feedback: 1. **Parallelize tool state checks**: Changed from sequential `for` loop to `Promise.all()` for checking multiple tools simultaneously. This reduces I/O latency during extend mode initialization. 2. **Extract marker validation helper**: Created `fileHasMarkers()` helper function to eliminate code duplication between config file and slash command checks. Makes the logic clearer and more maintainable. 3. **Clarify slash command policy**: Added explicit comment that "at least one file with markers is sufficient" (not all required) for slash commands. This is correct because OpenSpec creates all files together - if any exists with markers, the tool was configured by OpenSpec. 4. **Simplify fresh init path**: Use `Object.fromEntries()` for cleaner initialization of all-false states. Performance improvement: Extend mode now checks tools in parallel instead of serially, reducing init time especially for projects with many tools.
1 parent 92b4546 commit cf0de5e

File tree

2 files changed

+143
-18
lines changed

2 files changed

+143
-18
lines changed

src/core/init.ts

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
AI_TOOLS,
2222
OPENSPEC_DIR_NAME,
2323
AIToolOption,
24+
OPENSPEC_MARKERS,
2425
} from './config.js';
2526
import { PALETTE } from './styles/palette.js';
2627

@@ -388,7 +389,7 @@ export class InitCommand {
388389

389390
// Validation happens silently in the background
390391
const extendMode = await this.validate(projectPath, openspecPath);
391-
const existingToolStates = await this.getExistingToolStates(projectPath);
392+
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);
392393

393394
this.renderBanner(extendMode);
394395

@@ -627,35 +628,78 @@ export class InitCommand {
627628
}
628629

629630
private async getExistingToolStates(
630-
projectPath: string
631+
projectPath: string,
632+
extendMode: boolean
631633
): Promise<Record<string, boolean>> {
632-
const states: Record<string, boolean> = {};
633-
for (const tool of AI_TOOLS) {
634-
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
634+
// Fresh initialization - no tools configured yet
635+
if (!extendMode) {
636+
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
635637
}
636-
return states;
638+
639+
// Extend mode - check all tools in parallel for better performance
640+
const entries = await Promise.all(
641+
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
642+
);
643+
return Object.fromEntries(entries);
637644
}
638645

639646
private async isToolConfigured(
640647
projectPath: string,
641648
toolId: string
642649
): Promise<boolean> {
650+
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
651+
// For tools with both config files and slash commands, BOTH must have markers.
652+
// For slash commands, at least one file with markers is sufficient (not all required).
653+
654+
// Helper to check if a file exists and contains OpenSpec markers
655+
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
656+
try {
657+
const content = await FileSystemUtils.readFile(absolutePath);
658+
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
659+
} catch {
660+
return false;
661+
}
662+
};
663+
664+
let hasConfigFile = false;
665+
let hasSlashCommands = false;
666+
667+
// Check if the tool has a config file with OpenSpec markers
643668
const configFile = ToolRegistry.get(toolId)?.configFileName;
644-
if (
645-
configFile &&
646-
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
647-
)
648-
return true;
669+
if (configFile) {
670+
const configPath = path.join(projectPath, configFile);
671+
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
672+
}
649673

674+
// Check if any slash command file exists with OpenSpec markers
650675
const slashConfigurator = SlashCommandRegistry.get(toolId);
651-
if (!slashConfigurator) return false;
652-
for (const target of slashConfigurator.getTargets()) {
653-
const absolute = slashConfigurator.resolveAbsolutePath(
654-
projectPath,
655-
target.id
656-
);
657-
if (await FileSystemUtils.fileExists(absolute)) return true;
676+
if (slashConfigurator) {
677+
for (const target of slashConfigurator.getTargets()) {
678+
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
679+
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
680+
hasSlashCommands = true;
681+
break; // At least one file with markers is sufficient
682+
}
683+
}
684+
}
685+
686+
// Tool is only configured if BOTH exist with markers
687+
// OR if the tool has no config file requirement (slash commands only)
688+
// OR if the tool has no slash commands requirement (config file only)
689+
const hasConfigFileRequirement = configFile !== undefined;
690+
const hasSlashCommandRequirement = slashConfigurator !== undefined;
691+
692+
if (hasConfigFileRequirement && hasSlashCommandRequirement) {
693+
// Both are required - both must be present with markers
694+
return hasConfigFile && hasSlashCommands;
695+
} else if (hasConfigFileRequirement) {
696+
// Only config file required
697+
return hasConfigFile;
698+
} else if (hasSlashCommandRequirement) {
699+
// Only slash commands required
700+
return hasSlashCommands;
658701
}
702+
659703
return false;
660704
}
661705

test/core/init.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,87 @@ describe('InitCommand', () => {
10511051
});
10521052
});
10531053

1054+
describe('already configured detection', () => {
1055+
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
1056+
// Simulate user having their own CLAUDE.md before running openspec init
1057+
const claudePath = path.join(testDir, 'CLAUDE.md');
1058+
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');
1059+
1060+
queueSelections('claude', DONE);
1061+
1062+
await initCommand.execute(testDir);
1063+
1064+
// In the first run (non-interactive mode via queueSelections),
1065+
// the prompt is called with configured: false for claude
1066+
const firstCallArgs = mockPrompt.mock.calls[0][0];
1067+
const claudeChoice = firstCallArgs.choices.find(
1068+
(choice: any) => choice.value === 'claude'
1069+
);
1070+
1071+
expect(claudeChoice.configured).toBe(false);
1072+
});
1073+
1074+
it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
1075+
// Simulate user having their own custom slash commands
1076+
const customCommandDir = path.join(testDir, '.claude/commands/custom');
1077+
await fs.mkdir(customCommandDir, { recursive: true });
1078+
await fs.writeFile(
1079+
path.join(customCommandDir, 'mycommand.md'),
1080+
'# My Custom Command\n'
1081+
);
1082+
1083+
queueSelections('claude', DONE);
1084+
1085+
await initCommand.execute(testDir);
1086+
1087+
const firstCallArgs = mockPrompt.mock.calls[0][0];
1088+
const claudeChoice = firstCallArgs.choices.find(
1089+
(choice: any) => choice.value === 'claude'
1090+
);
1091+
1092+
expect(claudeChoice.configured).toBe(false);
1093+
});
1094+
1095+
it('should show tools as already configured in extend mode', async () => {
1096+
// First initialization
1097+
queueSelections('claude', DONE);
1098+
await initCommand.execute(testDir);
1099+
1100+
// Second initialization (extend mode)
1101+
queueSelections('cursor', DONE);
1102+
await initCommand.execute(testDir);
1103+
1104+
const secondCallArgs = mockPrompt.mock.calls[1][0];
1105+
const claudeChoice = secondCallArgs.choices.find(
1106+
(choice: any) => choice.value === 'claude'
1107+
);
1108+
1109+
expect(claudeChoice.configured).toBe(true);
1110+
});
1111+
1112+
it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
1113+
// Create global Codex prompts (simulating previous installation)
1114+
const codexPromptsDir = path.join(testDir, '.codex/prompts');
1115+
await fs.mkdir(codexPromptsDir, { recursive: true });
1116+
await fs.writeFile(
1117+
path.join(codexPromptsDir, 'openspec-proposal.md'),
1118+
'# Existing prompt\n'
1119+
);
1120+
1121+
queueSelections('claude', DONE);
1122+
1123+
await initCommand.execute(testDir);
1124+
1125+
const firstCallArgs = mockPrompt.mock.calls[0][0];
1126+
const codexChoice = firstCallArgs.choices.find(
1127+
(choice: any) => choice.value === 'codex'
1128+
);
1129+
1130+
// In fresh init, even global tools should not show as configured
1131+
expect(codexChoice.configured).toBe(false);
1132+
});
1133+
});
1134+
10541135
describe('error handling', () => {
10551136
it('should provide helpful error for insufficient permissions', async () => {
10561137
// This is tricky to test cross-platform, but we can test the error message

0 commit comments

Comments
 (0)