Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 62 additions & 18 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
AIToolOption,
OPENSPEC_MARKERS,
} from './config.js';
import { PALETTE } from './styles/palette.js';

Expand Down Expand Up @@ -388,7 +389,7 @@ export class InitCommand {

// Validation happens silently in the background
const extendMode = await this.validate(projectPath, openspecPath);
const existingToolStates = await this.getExistingToolStates(projectPath);
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);

this.renderBanner(extendMode);

Expand Down Expand Up @@ -627,35 +628,78 @@ export class InitCommand {
}

private async getExistingToolStates(
projectPath: string
projectPath: string,
extendMode: boolean
): Promise<Record<string, boolean>> {
const states: Record<string, boolean> = {};
for (const tool of AI_TOOLS) {
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
// Fresh initialization - no tools configured yet
if (!extendMode) {
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
}
return states;

// Extend mode - check all tools in parallel for better performance
const entries = await Promise.all(
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
);
return Object.fromEntries(entries);
}

private async isToolConfigured(
projectPath: string,
toolId: string
): Promise<boolean> {
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
// For tools with both config files and slash commands, BOTH must have markers.
// For slash commands, at least one file with markers is sufficient (not all required).

// Helper to check if a file exists and contains OpenSpec markers
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
try {
const content = await FileSystemUtils.readFile(absolutePath);
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
} catch {
return false;
}
};

let hasConfigFile = false;
let hasSlashCommands = false;

// Check if the tool has a config file with OpenSpec markers
const configFile = ToolRegistry.get(toolId)?.configFileName;
if (
configFile &&
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
)
return true;
if (configFile) {
const configPath = path.join(projectPath, configFile);
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
}

// Check if any slash command file exists with OpenSpec markers
const slashConfigurator = SlashCommandRegistry.get(toolId);
if (!slashConfigurator) return false;
for (const target of slashConfigurator.getTargets()) {
const absolute = slashConfigurator.resolveAbsolutePath(
projectPath,
target.id
);
if (await FileSystemUtils.fileExists(absolute)) return true;
if (slashConfigurator) {
for (const target of slashConfigurator.getTargets()) {
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
hasSlashCommands = true;
break; // At least one file with markers is sufficient
}
}
}

// Tool is only configured if BOTH exist with markers
// OR if the tool has no config file requirement (slash commands only)
// OR if the tool has no slash commands requirement (config file only)
const hasConfigFileRequirement = configFile !== undefined;
const hasSlashCommandRequirement = slashConfigurator !== undefined;

if (hasConfigFileRequirement && hasSlashCommandRequirement) {
// Both are required - both must be present with markers
return hasConfigFile && hasSlashCommands;
} else if (hasConfigFileRequirement) {
// Only config file required
return hasConfigFile;
} else if (hasSlashCommandRequirement) {
// Only slash commands required
return hasSlashCommands;
}

return false;
}

Expand Down
81 changes: 81 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,87 @@ describe('InitCommand', () => {
});
});

describe('already configured detection', () => {
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
// Simulate user having their own CLAUDE.md before running openspec init
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');

queueSelections('claude', DONE);

await initCommand.execute(testDir);

// In the first run (non-interactive mode via queueSelections),
// the prompt is called with configured: false for claude
const firstCallArgs = mockPrompt.mock.calls[0][0];
const claudeChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);

expect(claudeChoice.configured).toBe(false);
});

it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
// Simulate user having their own custom slash commands
const customCommandDir = path.join(testDir, '.claude/commands/custom');
await fs.mkdir(customCommandDir, { recursive: true });
await fs.writeFile(
path.join(customCommandDir, 'mycommand.md'),
'# My Custom Command\n'
);

queueSelections('claude', DONE);

await initCommand.execute(testDir);

const firstCallArgs = mockPrompt.mock.calls[0][0];
const claudeChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);

expect(claudeChoice.configured).toBe(false);
});

it('should show tools as already configured in extend mode', async () => {
// First initialization
queueSelections('claude', DONE);
await initCommand.execute(testDir);

// Second initialization (extend mode)
queueSelections('cursor', DONE);
await initCommand.execute(testDir);

const secondCallArgs = mockPrompt.mock.calls[1][0];
const claudeChoice = secondCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);

expect(claudeChoice.configured).toBe(true);
});

it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
// Create global Codex prompts (simulating previous installation)
const codexPromptsDir = path.join(testDir, '.codex/prompts');
await fs.mkdir(codexPromptsDir, { recursive: true });
await fs.writeFile(
path.join(codexPromptsDir, 'openspec-proposal.md'),
'# Existing prompt\n'
);

queueSelections('claude', DONE);

await initCommand.execute(testDir);

const firstCallArgs = mockPrompt.mock.calls[0][0];
const codexChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'codex'
);

// In fresh init, even global tools should not show as configured
expect(codexChoice.configured).toBe(false);
});
});

describe('error handling', () => {
it('should provide helpful error for insufficient permissions', async () => {
// This is tricky to test cross-platform, but we can test the error message
Expand Down
Loading