Skip to content
Draft
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
96 changes: 12 additions & 84 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ import {
getSkillTemplates,
getCommandContents,
generateSkillContent,
removeAllSkillDirs,
removeAllCommandFiles,
printOnboardingFooter,
type ToolSkillStatus,
} from './shared/index.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getProfileWorkflows } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { migrateIfNeeded } from './migration.js';

Expand All @@ -60,20 +63,6 @@ const PROGRESS_SPINNER = {
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
};

const WORKFLOW_TO_SKILL_DIR: Record<string, string> = {
'explore': 'openspec-explore',
'new': 'openspec-new-change',
'continue': 'openspec-continue-change',
'apply': 'openspec-apply-change',
'ff': 'openspec-ff-change',
'sync': 'openspec-sync-specs',
'archive': 'openspec-archive-change',
'bulk-archive': 'openspec-bulk-archive-change',
'verify': 'openspec-verify-change',
'onboard': 'openspec-onboard',
'propose': 'openspec-propose',
};

// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -547,7 +536,7 @@ export class InitCommand {
}
if (!shouldGenerateSkills) {
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
removedSkillCount += await this.removeSkillDirs(skillsDir);
removedSkillCount += await removeAllSkillDirs(skillsDir);
}

// Generate commands if delivery includes commands
Expand All @@ -565,7 +554,7 @@ export class InitCommand {
}
}
if (!shouldGenerateCommands) {
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
removedCommandCount += await removeAllCommandFiles(projectPath, tool.value);
}

spinner.succeed(`Setup complete for ${tool.name}`);
Expand Down Expand Up @@ -696,33 +685,14 @@ export class InitCommand {
console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
}

// Getting started (task 7.6: show propose if in profile)
// Onboarding footer (getting started + links + IDE restart)
const globalCfg = getGlobalConfig();
const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
console.log();
if (activeWorkflows.includes('propose')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:propose "your idea"');
} else if (activeWorkflows.includes('new')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:new "your idea"');
} else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
}

// Links
console.log();
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);

// Restart instruction if any tools were configured
if (results.createdTools.length > 0 || results.refreshedTools.length > 0) {
console.log();
console.log(chalk.white('Restart your IDE for slash commands to take effect.'));
}

console.log();
printOnboardingFooter({
profile: activeProfile,
customWorkflows: globalCfg.workflows,
hasConfiguredTools: results.createdTools.length > 0 || results.refreshedTools.length > 0,
});
}

private startSpinner(text: string) {
Expand All @@ -734,46 +704,4 @@ export class InitCommand {
}).start();
}

private async removeSkillDirs(skillsDir: string): Promise<number> {
let removed = 0;

for (const workflow of ALL_WORKFLOWS) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
if (!dirName) continue;

const skillDir = path.join(skillsDir, dirName);
try {
if (fs.existsSync(skillDir)) {
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}

private async removeCommandFiles(projectPath: string, toolId: string): Promise<number> {
let removed = 0;
const adapter = CommandAdapterRegistry.get(toolId);
if (!adapter) return 0;

for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);

try {
if (fs.existsSync(fullPath)) {
await fs.promises.unlink(fullPath);
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}
}
138 changes: 138 additions & 0 deletions src/core/shared/artifact-removal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Artifact Removal Helpers
*
* Shared functions for removing skill directories and command files.
* Used by both init and update commands when delivery mode changes or
* workflows are deselected.
*/

import path from 'path';
import * as fs from 'fs';
import { ALL_WORKFLOWS } from '../profiles.js';
import { WORKFLOW_TO_SKILL_DIR } from '../profile-sync-drift.js';
import { CommandAdapterRegistry } from '../command-generation/index.js';

// ---------------------------------------------------------------------------
// Skill directory removal
// ---------------------------------------------------------------------------

/**
* Removes ALL workflow skill directories under the given skillsDir.
* Used when delivery changes to commands-only.
*/
export async function removeAllSkillDirs(skillsDir: string): Promise<number> {
let removed = 0;

for (const workflow of ALL_WORKFLOWS) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
if (!dirName) continue;

const skillDir = path.join(skillsDir, dirName);
try {
if (fs.existsSync(skillDir)) {
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}

/**
* Removes skill directories for workflows that are NOT in the desired set.
* Used during profile-sync to clean up deselected workflows.
*/
export async function removeUnselectedSkillDirs(
skillsDir: string,
desiredWorkflows: readonly string[],
): Promise<number> {
const desiredSet = new Set(desiredWorkflows);
let removed = 0;

for (const workflow of ALL_WORKFLOWS) {
if (desiredSet.has(workflow)) continue;
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
if (!dirName) continue;

const skillDir = path.join(skillsDir, dirName);
try {
if (fs.existsSync(skillDir)) {
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}

// ---------------------------------------------------------------------------
// Command file removal
// ---------------------------------------------------------------------------

/**
* Removes ALL workflow command files for a given tool.
* Used when delivery changes to skills-only.
*/
export async function removeAllCommandFiles(
projectPath: string,
toolId: string,
): Promise<number> {
let removed = 0;
const adapter = CommandAdapterRegistry.get(toolId);
if (!adapter) return 0;

for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);

try {
if (fs.existsSync(fullPath)) {
await fs.promises.unlink(fullPath);
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}

/**
* Removes command files for workflows that are NOT in the desired set.
* Used during profile-sync to clean up deselected workflows.
*/
export async function removeUnselectedCommandFiles(
projectPath: string,
toolId: string,
desiredWorkflows: readonly string[],
): Promise<number> {
let removed = 0;
const adapter = CommandAdapterRegistry.get(toolId);
if (!adapter) return 0;

const desiredSet = new Set(desiredWorkflows);

for (const workflow of ALL_WORKFLOWS) {
if (desiredSet.has(workflow)) continue;
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);

try {
if (fs.existsSync(fullPath)) {
await fs.promises.unlink(fullPath);
removed++;
}
} catch {
// Ignore errors
}
}

return removed;
}
15 changes: 15 additions & 0 deletions src/core/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,18 @@ export {
getCommandContents,
generateSkillContent,
} from './skill-generation.js';

export {
removeAllSkillDirs,
removeUnselectedSkillDirs,
removeAllCommandFiles,
removeUnselectedCommandFiles,
} from './artifact-removal.js';

export {
type OnboardingContext,
formatGettingStarted,
formatLinks,
formatIdeRestart,
printOnboardingFooter,
} from './onboarding.js';
Loading
Loading