Skip to content
Open
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
12 changes: 10 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ export function registerConfigCommand(program: Command): void {
delivery: currentState.delivery,
workflows: [...currentState.workflows],
};
let workflowSelectionChanged = false;

if (action === 'both' || action === 'delivery') {
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
Expand Down Expand Up @@ -599,7 +600,12 @@ export function registerConfigCommand(program: Command): void {
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
});
nextState.workflows = selectedWorkflows;
nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);
workflowSelectionChanged =
selectedWorkflows.length !== currentState.workflows.length ||
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
nextState.profile = workflowSelectionChanged
? deriveProfileFromWorkflowSelection(selectedWorkflows)
: currentState.profile;
}

const diff = diffProfileState(currentState, nextState);
Expand All @@ -617,7 +623,9 @@ export function registerConfigCommand(program: Command): void {

config.profile = nextState.profile;
config.delivery = nextState.delivery;
config.workflows = nextState.workflows;
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
config.workflows = nextState.workflows;
}
saveGlobalConfig(config);

// Check if inside an OpenSpec project
Expand Down
17 changes: 15 additions & 2 deletions src/core/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,27 @@ export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number];
* Resolves which workflows should be active for a given profile configuration.
*
* - 'core' profile always returns CORE_WORKFLOWS
* - 'custom' profile returns the provided customWorkflows, or empty array if not provided
* - 'custom' profile returns the provided customWorkflows and required dependencies
*/
export function getProfileWorkflows(
profile: Profile,
customWorkflows?: string[]
): readonly string[] {
if (profile === 'custom') {
return customWorkflows ?? [];
const workflows = customWorkflows ?? [];
const syncDependentIndex = workflows.findIndex(
(workflow) => workflow === 'archive' || workflow === 'bulk-archive'
);

if (syncDependentIndex !== -1 && !workflows.includes('sync')) {
return [
...workflows.slice(0, syncDependentIndex),
'sync',
...workflows.slice(syncDependentIndex),
];
}

return workflows;
}
return CORE_WORKFLOWS;
}
11 changes: 8 additions & 3 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export class UpdateCommand {
// Still check for new tool directories and extra workflows
this.detectNewTools(resolvedProjectPath, configuredTools);
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
this.displaySetupNotes(configuredTools);
return;
}
Expand Down Expand Up @@ -474,7 +474,7 @@ export class UpdateCommand {

// 14. Display note about extra workflows not in profile
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
this.displayMissingCoreWorkflowsNote(profile, desiredWorkflows);
this.displaySetupNotes(configuredAndNewTools);

// 15. List affected tools
Expand Down Expand Up @@ -1100,7 +1100,12 @@ export class UpdateCommand {
}
}

const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
const inferredCodexWorkflows = getProfileWorkflows(
'custom',
getLegacyWorkflowIdsForTool(detection, 'codex')
).filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] =>
(ALL_WORKFLOWS as readonly string[]).includes(workflow)
);

// Create skills/commands for selected tools using effective profile+delivery.
const newlyConfigured: string[] = [];
Expand Down
59 changes: 59 additions & 0 deletions test/commands/config-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,65 @@ describe('config profile interactive flow', () => {
expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');
});

it('should preserve a custom profile when dependency expansion matches the core set', async () => {
const { saveGlobalConfig, getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js');
const { select, checkbox, confirm } = await getPromptMocks();

saveGlobalConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
});
const configPath = getGlobalConfigPath();
const beforeContent = fs.readFileSync(configPath, 'utf-8');

select.mockResolvedValueOnce('workflows');
checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'update', 'sync', 'archive']);

await runConfigCommand(['profile']);

expect(getGlobalConfig().profile).toBe('custom');
expect(fs.readFileSync(configPath, 'utf-8')).toBe(beforeContent);
expect(confirm).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.');
});

it.each(['delivery', 'both'] as const)(
'should preserve raw custom workflows during a %s change',
async (action) => {
const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js');
const { select, checkbox } = await getPromptMocks();

saveGlobalConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
});
select.mockResolvedValueOnce(action);
select.mockResolvedValueOnce('skills');
if (action === 'both') {
checkbox.mockResolvedValueOnce([
'propose',
'explore',
'apply',
'update',
'sync',
'archive',
]);
}

await runConfigCommand(['profile']);

expect(getGlobalConfig()).toMatchObject({
profile: 'custom',
delivery: 'skills',
workflows: ['propose', 'explore', 'apply', 'update', 'archive'],
});
}
);

it('keep action should warn when project files drift from global config', async () => {
const { saveGlobalConfig } = await import('../../src/core/global-config.js');
const { select } = await getPromptMocks();
Expand Down
28 changes: 28 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ describe('InitCommand', () => {
}
});

it.each([
['archive', 'openspec-archive-change'],
['bulk-archive', 'openspec-bulk-archive-change'],
] as const)(
'should install the sync workflow required by %s in a custom profile',
async (archiveWorkflow, archiveSkill) => {
saveGlobalConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: ['propose', 'explore', 'apply', archiveWorkflow],
});

const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);

await expect(
fs.access(path.join(testDir, '.claude', 'skills', archiveSkill, 'SKILL.md'))
).resolves.toBeUndefined();
await expect(
fs.access(path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md'))
).resolves.toBeUndefined();
await expect(
fs.access(path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md'))
).resolves.toBeUndefined();
}
);

it('should create core profile commands for Claude Code by default', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });

Expand Down
26 changes: 26 additions & 0 deletions test/core/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ describe('profiles', () => {
expect(result).toEqual(customWorkflows);
});

it('should include sync when a custom profile selects archive', () => {
const result = getProfileWorkflows('custom', ['propose', 'explore', 'apply', 'archive']);
expect(result).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']);
});

it('should include sync when a custom profile selects bulk archive', () => {
const result = getProfileWorkflows('custom', ['explore', 'bulk-archive']);
expect(result).toEqual(['explore', 'sync', 'bulk-archive']);
});

it('should not duplicate or reorder an existing sync dependency', () => {
const workflows = ['sync', 'archive', 'bulk-archive'];
const result = getProfileWorkflows('custom', workflows);

expect(result).toEqual(workflows);
expect(result).toBe(workflows);
});

it('should not mutate the custom workflow selection when adding sync', () => {
const workflows = ['archive', 'bulk-archive'];
const result = getProfileWorkflows('custom', workflows);

expect(result).toEqual(['sync', 'archive', 'bulk-archive']);
expect(workflows).toEqual(['archive', 'bulk-archive']);
});

it('should return empty array for custom profile with no customWorkflows', () => {
const result = getProfileWorkflows('custom');
expect(result).toEqual([]);
Expand Down
110 changes: 80 additions & 30 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2333,6 +2333,33 @@ ${OPENSPEC_MARKERS.end}
)).toBe(false);
});

it.each([
['opsx-archive.md', 'openspec-archive-change'],
['opsx-bulk-archive.md', 'openspec-bulk-archive-change'],
])('should include sync when replacing legacy Codex %s', async (promptName, archiveSkill) => {
setMockConfig({
featureFlags: {},
profile: 'core',
delivery: 'skills',
});

const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
const managedPrompt = path.join(promptDir, promptName);
await fs.mkdir(promptDir, { recursive: true });
await fs.writeFile(managedPrompt, 'legacy archive prompt');

const forceUpdateCommand = new UpdateCommand({ force: true });
await forceUpdateCommand.execute(testDir);

expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.agents', 'skills', archiveSkill, 'SKILL.md')
)).toBe(true);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.agents', 'skills', 'openspec-sync-specs', 'SKILL.md')
)).toBe(true);
});

it('should print a skill-based getting-started menu when a legacy upgrade newly configures codex', async () => {
setMockConfig({
featureFlags: {},
Expand Down Expand Up @@ -3003,40 +3030,63 @@ More user content after markers.
)).toBe(false);
});

it('should list missing core workflows when custom profile preserves the old core workflow set', async () => {
setMockConfig({
featureFlags: {},
profile: 'custom',
delivery: 'both',
workflows: ['propose', 'explore', 'apply', 'archive'],
});

const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);

const consoleSpy = vi.spyOn(console, 'log');
it.each(['skills', 'commands', 'both'] as const)(
'should repair an archive profile missing sync with %s delivery',
async (delivery) => {
setMockConfig({
featureFlags: {},
profile: 'custom',
delivery,
workflows: ['propose', 'explore', 'apply', 'archive'],
});

await updateCommand.execute(testDir);
const archiveSkill = path.join(
testDir,
'.claude',
'skills',
'openspec-archive-change',
'SKILL.md'
);
const archiveCommand = path.join(
testDir,
'.claude',
'commands',
'opsx',
'archive.md'
);
if (delivery !== 'commands') {
await fs.mkdir(path.dirname(archiveSkill), { recursive: true });
await fs.writeFile(archiveSkill, 'old archive skill');
}
if (delivery !== 'skills') {
await fs.mkdir(path.dirname(archiveCommand), { recursive: true });
await fs.writeFile(archiveCommand, 'old archive command');
}

const calls = consoleSpy.mock.calls.map(call =>
call.map(arg => String(arg)).join(' ')
);
expect(calls.some(call =>
call.includes('Your custom profile is missing 2 core workflows: update, sync')
)).toBe(true);
expect(calls.some(call =>
call.includes('openspec config profile core')
)).toBe(true);
const consoleSpy = vi.spyOn(console, 'log');

expect(await FileSystemUtils.fileExists(
path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md')
)).toBe(false);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md')
)).toBe(false);
await updateCommand.execute(testDir);

consoleSpy.mockRestore();
});
const calls = consoleSpy.mock.calls.map(call =>
call.map(arg => String(arg)).join(' ')
);
expect(calls.some(call =>
call.includes('Your custom profile is missing 1 core workflow: update')
)).toBe(true);
expect(calls.some(call =>
call.includes('openspec config profile core')
)).toBe(true);

expect(await FileSystemUtils.fileExists(
path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md')
)).toBe(delivery !== 'commands');
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md')
)).toBe(delivery !== 'skills');

consoleSpy.mockRestore();
}
);

it('should list a single missing core workflow when custom profile lacks only update', async () => {
setMockConfig({
Expand Down
Loading