Skip to content

Commit a0608d0

Browse files
authored
Sync update to prune deselected workflows (Fission-AI#741)
1 parent e4c32db commit a0608d0

4 files changed

Lines changed: 125 additions & 17 deletions

File tree

openspec/changes/simplify-skill-installation/specs/cli-update/spec.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,14 @@ The update command SHALL only run inside an initialized OpenSpec project.
160160
- **THEN** the system SHALL display: "No OpenSpec project found. Run 'openspec init' to set up."
161161
- **THEN** the system SHALL exit with code 1
162162

163-
### Requirement: Extra workflows preserved
164-
The update command SHALL NOT remove workflow files that aren't in the current profile.
163+
### Requirement: Extra workflows synchronized to active profile
164+
The update command SHALL remove workflow files that are no longer selected in the current profile.
165165

166-
#### Scenario: Extra workflows from previous profile
166+
#### Scenario: Deselected workflows from previous profile
167167
- **WHEN** user runs `openspec update`
168-
- **AND** project has workflows not in current profile (e.g., user switched from custom to core)
169-
- **THEN** the system SHALL NOT delete those extra workflow files
170-
- **THEN** the system SHALL only add/update workflows in the current profile
171-
- **THEN** the system SHALL display a note: "Note: <count> extra workflows not in profile (use `openspec config profile` to manage)"
168+
- **AND** project has workflows not in current profile (e.g., user switched from custom to core or deselected workflows via `openspec config profile`)
169+
- **THEN** the system SHALL delete skill and command workflow files for deselected workflows (respecting active delivery mode)
170+
- **THEN** the system SHALL keep only workflows currently selected in profile
172171

173172
#### Scenario: Delivery change with extra workflows
174173
- **WHEN** user runs `openspec update`

src/core/profile-sync-drift.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,10 @@ export function getConfiguredToolsForProfileSync(projectPath: string): string[]
8080
/**
8181
* Detects if a single tool has profile/delivery drift against the desired state.
8282
*
83-
* Note: this function is intentionally scoped to "required artifacts missing"
84-
* and "artifacts that should not exist for the selected delivery mode".
85-
* Extra workflows that are outside the desired profile are handled by
86-
* `hasProjectConfigDrift`, which compares installed workflow IDs against
87-
* the desired workflow set.
83+
* This function covers:
84+
* - required artifacts missing for selected workflows
85+
* - artifacts that should not exist for the selected delivery mode
86+
* - artifacts for workflows that were deselected from the current profile
8887
*/
8988
export function hasToolProfileOrDeliveryDrift(
9089
projectPath: string,
@@ -96,6 +95,7 @@ export function hasToolProfileOrDeliveryDrift(
9695
if (!tool?.skillsDir) return false;
9796

9897
const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
98+
const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows);
9999
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
100100
const adapter = CommandAdapterRegistry.get(toolId);
101101
const shouldGenerateSkills = delivery !== 'commands';
@@ -109,6 +109,16 @@ export function hasToolProfileOrDeliveryDrift(
109109
return true;
110110
}
111111
}
112+
113+
// Deselecting workflows in a profile should trigger sync.
114+
for (const workflow of ALL_WORKFLOWS) {
115+
if (desiredWorkflowSet.has(workflow)) continue;
116+
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
117+
const skillDir = path.join(skillsDir, dirName);
118+
if (fs.existsSync(skillDir)) {
119+
return true;
120+
}
121+
}
112122
} else {
113123
for (const workflow of ALL_WORKFLOWS) {
114124
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -127,6 +137,16 @@ export function hasToolProfileOrDeliveryDrift(
127137
return true;
128138
}
129139
}
140+
141+
// Deselecting workflows in a profile should trigger sync.
142+
for (const workflow of ALL_WORKFLOWS) {
143+
if (desiredWorkflowSet.has(workflow)) continue;
144+
const cmdPath = adapter.getFilePath(workflow);
145+
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
146+
if (fs.existsSync(fullPath)) {
147+
return true;
148+
}
149+
}
130150
} else if (!shouldGenerateCommands && adapter) {
131151
for (const workflow of ALL_WORKFLOWS) {
132152
const cmdPath = adapter.getFilePath(workflow);

src/core/update.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export class UpdateCommand {
176176
const failedTools: Array<{ name: string; error: string }> = [];
177177
let removedCommandCount = 0;
178178
let removedSkillCount = 0;
179+
let removedDeselectedCommandCount = 0;
180+
let removedDeselectedSkillCount = 0;
179181

180182
for (const toolId of toolsToUpdate) {
181183
const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -197,6 +199,8 @@ export class UpdateCommand {
197199
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
198200
await FileSystemUtils.writeFile(skillFile, skillContent);
199201
}
202+
203+
removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
200204
}
201205

202206
// Delete skill directories if delivery is commands-only
@@ -214,6 +218,12 @@ export class UpdateCommand {
214218
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
215219
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
216220
}
221+
222+
removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(
223+
resolvedProjectPath,
224+
toolId,
225+
desiredWorkflows
226+
);
217227
}
218228
}
219229

@@ -247,6 +257,12 @@ export class UpdateCommand {
247257
if (removedSkillCount > 0) {
248258
console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
249259
}
260+
if (removedDeselectedCommandCount > 0) {
261+
console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
262+
}
263+
if (removedDeselectedSkillCount > 0) {
264+
console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
265+
}
250266

251267
// 12. Show onboarding message for newly configured tools from legacy upgrade
252268
if (newlyConfiguredTools.length > 0) {
@@ -378,6 +394,36 @@ export class UpdateCommand {
378394
return removed;
379395
}
380396

397+
/**
398+
* Removes skill directories for workflows that are no longer selected in the active profile.
399+
* Returns the number of directories removed.
400+
*/
401+
private async removeUnselectedSkillDirs(
402+
skillsDir: string,
403+
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
404+
): Promise<number> {
405+
const desiredSet = new Set(desiredWorkflows);
406+
let removed = 0;
407+
408+
for (const workflow of ALL_WORKFLOWS) {
409+
if (desiredSet.has(workflow)) continue;
410+
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
411+
if (!dirName) continue;
412+
413+
const skillDir = path.join(skillsDir, dirName);
414+
try {
415+
if (fs.existsSync(skillDir)) {
416+
await fs.promises.rm(skillDir, { recursive: true, force: true });
417+
removed++;
418+
}
419+
} catch {
420+
// Ignore errors
421+
}
422+
}
423+
424+
return removed;
425+
}
426+
381427
/**
382428
* Removes command files for workflows when delivery changed to skills-only.
383429
* Returns the number of files removed.
@@ -408,6 +454,40 @@ export class UpdateCommand {
408454
return removed;
409455
}
410456

457+
/**
458+
* Removes command files for workflows that are no longer selected in the active profile.
459+
* Returns the number of files removed.
460+
*/
461+
private async removeUnselectedCommandFiles(
462+
projectPath: string,
463+
toolId: string,
464+
desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
465+
): Promise<number> {
466+
let removed = 0;
467+
468+
const adapter = CommandAdapterRegistry.get(toolId);
469+
if (!adapter) return 0;
470+
471+
const desiredSet = new Set(desiredWorkflows);
472+
473+
for (const workflow of ALL_WORKFLOWS) {
474+
if (desiredSet.has(workflow)) continue;
475+
const cmdPath = adapter.getFilePath(workflow);
476+
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
477+
478+
try {
479+
if (fs.existsSync(fullPath)) {
480+
await fs.promises.unlink(fullPath);
481+
removed++;
482+
}
483+
} catch {
484+
// Ignore errors
485+
}
486+
}
487+
488+
return removed;
489+
}
490+
411491
/**
412492
* Detect and handle legacy OpenSpec artifacts.
413493
* Unlike init, update warns but continues if legacy files found in non-interactive mode.

test/core/update.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,7 +1567,7 @@ content
15671567
consoleSpy.mockRestore();
15681568
});
15691569

1570-
it('should display extra workflows note when workflows outside profile exist', async () => {
1570+
it('should remove workflows outside profile during update sync', async () => {
15711571
// Set core profile (propose, explore, apply, archive)
15721572
setMockConfig({
15731573
featureFlags: {},
@@ -1583,19 +1583,28 @@ content
15831583
// Add a non-core workflow
15841584
await fs.mkdir(path.join(skillsDir, 'openspec-new-change'), { recursive: true });
15851585
await fs.writeFile(path.join(skillsDir, 'openspec-new-change', 'SKILL.md'), 'old');
1586+
const extraCommandFile = path.join(testDir, '.claude', 'commands', 'opsx', 'new.md');
1587+
await fs.mkdir(path.dirname(extraCommandFile), { recursive: true });
1588+
await fs.writeFile(extraCommandFile, 'old');
15861589

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

15891592
await updateCommand.execute(testDir);
15901593

1591-
// Should display note about extra workflows
1594+
// Deselected workflow artifacts should be removed for both delivery surfaces.
1595+
expect(await FileSystemUtils.fileExists(
1596+
path.join(skillsDir, 'openspec-new-change', 'SKILL.md')
1597+
)).toBe(false);
1598+
expect(await FileSystemUtils.fileExists(extraCommandFile)).toBe(false);
1599+
1600+
// Should report deselected workflow cleanup.
15921601
const calls = consoleSpy.mock.calls.map(call =>
15931602
call.map(arg => String(arg)).join(' ')
15941603
);
1595-
const hasExtraNote = calls.some(call =>
1596-
call.includes('extra workflows not in profile')
1604+
const hasDeselectedRemovalNote = calls.some(call =>
1605+
call.includes('deselected workflows')
15971606
);
1598-
expect(hasExtraNote).toBe(true);
1607+
expect(hasDeselectedRemovalNote).toBe(true);
15991608

16001609
consoleSpy.mockRestore();
16011610
});

0 commit comments

Comments
 (0)