diff --git a/.changeset/quiet-cli-update.md b/.changeset/quiet-cli-update.md new file mode 100644 index 0000000000..2ccf0b9a70 --- /dev/null +++ b/.changeset/quiet-cli-update.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- `openspec update` now suggests restarting an IDE only when it updates an IDE-resident tool. CLI tools such as Claude Code, Codex, and Gemini CLI no longer show an unnecessary restart hint. diff --git a/src/core/update.ts b/src/core/update.ts index 69fa4bafe4..2c8bdda96a 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -267,6 +267,7 @@ export class UpdateCommand { // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; + const updatedToolIds: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; const skillsInvocableCommandSkips: string[] = []; const zeroArtifactTools: string[] = []; @@ -361,6 +362,7 @@ export class UpdateCommand { spinner.succeed(`Updated ${tool.name}`); updatedTools.push(tool.name); + updatedToolIds.push(tool.value); for (const migration of migrateLegacyToolDirs( resolvedProjectPath, [tool.value], @@ -484,7 +486,20 @@ export class UpdateCommand { } console.log(); - console.log(chalk.dim('Restart your IDE for changes to take effect.')); + const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])]; + const shouldRestartIde = affectedToolIds.some((toolId) => { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + return Boolean( + tool?.requiresIdeRestart && + ( + shouldGenerateCommandsForTool(toolId, delivery) || + shouldGenerateSkillsForTool(toolId, delivery) + ) + ); + }); + if (shouldRestartIde) { + console.log(chalk.dim('Restart your IDE for changes to take effect.')); + } if (failedTools.length > 0) { throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`); } diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b541bdadc1..3c21fc9657 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1675,9 +1675,43 @@ metadata: expect.stringContaining('Failed') ); + // Cursor succeeded, so its IDE process still needs to reload the changes. + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Restart your IDE') + ); + writeSpy.mockRestore(); consoleSpy.mockRestore(); }); + + it('should not suggest an IDE restart when only the IDE tool fails', async () => { + const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(claudeSkill), { recursive: true }); + await fs.mkdir(path.dirname(cursorSkill), { recursive: true }); + await fs.writeFile(claudeSkill, 'old'); + await fs.writeFile(cursorSkill, 'old'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes('.cursor') && filePath.includes('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + const consoleSpy = vi.spyOn(console, 'log'); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Cursor' + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated: Claude Code') + ); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restart your IDE') + ); + }); }); describe('tool detection', () => { @@ -1800,8 +1834,8 @@ metadata: consoleSpy.mockRestore(); }); - it('should suggest IDE restart after update', async () => { - // Set up a configured tool + it('should not suggest an IDE restart for CLI-only tools', async () => { + // Set up a configured CLI tool const skillsDir = path.join(testDir, '.claude', 'skills'); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true, @@ -1815,6 +1849,27 @@ metadata: await updateCommand.execute(testDir); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restart your IDE') + ); + + consoleSpy.mockRestore(); + }); + + it('should suggest an IDE restart for IDE-resident tools', async () => { + const skillsDir = path.join(testDir, '.cursor', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Restart your IDE') ); @@ -2199,6 +2254,11 @@ metadata: expect.stringContaining('Already up to date: cursor') ); + // A configured IDE tool that was not affected must not cause the hint. + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Restart your IDE') + ); + consoleSpy.mockRestore(); }); }); @@ -2392,6 +2452,7 @@ ${OPENSPEC_MARKERS.end} expect(menuLines).toHaveLength(1); expect(menuLines[0]).toContain('/opsx-propose'); expect(logCalls.some((entry) => entry.includes('/opsx:propose'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('Restart your IDE'))).toBe(true); }); it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => { @@ -2691,6 +2752,7 @@ More user content after markers. .join('\n'); expect(gettingStartedCalls).not.toContain('/opsx:new'); expect(gettingStartedCalls).not.toContain('/opsx:continue'); + expect(gettingStartedCalls).not.toContain('Restart your IDE'); // Skills should be created const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');