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
7 changes: 7 additions & 0 deletions .changeset/quiet-cli-update.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(', ')}`);
}
Expand Down
66 changes: 64 additions & 2 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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')
);
Expand Down Expand Up @@ -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();
});
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down
Loading