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
5 changes: 5 additions & 0 deletions .changeset/legacy-cleanup-keeps-user-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Stop legacy cleanup deleting the user's own files. The six pre-skills tools that kept their commands in a `<tool>/commands/openspec/` folder (Claude Code, CodeBuddy, Qoder, Lingma, Crush and Gemini CLI) had that whole folder removed recursively whenever it existed, so a command the user kept there, such as a team review checklist, was deleted along with OpenSpec's files, and the summary named only the folder. Because `openspec init` cleans up automatically when there is no TTY, an agent or CI running plain `openspec init` did this without `--force` and without a prompt, and `openspec update --force` did the same. Cleanup now deletes only the files OpenSpec wrote there: `proposal`, `apply` and `archive` files that still carry the OpenSpec markers every legacy command was generated with, so a same-named file the user wrote is kept. It never follows a symlinked command folder, removes the folder only once nothing else is left in it, and lists each thing it kept. A folder holding nothing OpenSpec wrote is no longer reported as legacy at all. A folder holding only OpenSpec's files, or nothing, is still removed exactly as before, with the same summary line.
211 changes: 199 additions & 12 deletions src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,27 @@ export const LEGACY_CONFIG_FILES = [
'QWEN.md',
] as const;

/** The three commands the old SlashCommandRegistry wrote into each directory. */
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'] as const;

/**
* Legacy slash command patterns from the old SlashCommandRegistry.
* These map toolId to the path pattern where legacy commands were created.
* Some tools used a directory structure, others used individual files.
*/
export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {
// Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
'claude': { type: 'directory', path: '.claude/commands/openspec' },
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
'crush': { type: 'directory', path: '.crush/commands/openspec' },
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
// Directory-based: .tooldir/commands/openspec/. Each entry names the files
// OpenSpec wrote there, because users keep their own commands in the same
// folder: only those files are deleted, and the folder only once it is empty.
'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
// Lingma support arrived after the opsx rename and has always written to
// `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
// leftover folder is removed.
'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },

// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
Expand Down Expand Up @@ -110,6 +118,8 @@ export const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record<string, LegacyGlobalPromp
export interface LegacySlashCommandPattern {
type: 'directory' | 'files';
path?: string; // For directory type
/** For directory type: the only files in `path` that OpenSpec wrote. */
managedFileNames?: readonly string[];
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
}

Expand Down Expand Up @@ -320,8 +330,20 @@ export async function detectLegacySlashCommands(
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'directory' && pattern.path) {
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
if (await FileSystemUtils.directoryExists(dirPath)) {
if (!(await FileSystemUtils.directoryExists(dirPath))) {
continue;
}
const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
if (!entries) {
continue;
}
if (entries.others.length === 0) {
// Only OpenSpec's own files, or nothing: the whole folder can go.
directories.push(pattern.path);
} else {
// The folder also holds the user's files, so report OpenSpec's files
// one by one; cleanup deletes those and leaves the folder in place.
files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
}
} else if (pattern.type === 'files' && pattern.pattern) {
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
Expand All @@ -335,6 +357,102 @@ export async function detectLegacySlashCommands(
return { directories, files };
}

/**
* Splits a legacy command directory's entries into the files OpenSpec wrote
* there and everything else, sorted. A file counts as OpenSpec's only when it
* is a regular file with a managed name whose content still carries the
* OpenSpec markers every legacy command was written with; a folder, a link, or
* a same-named file the user wrote is the user's. Subdirectories are listed
* with a trailing '/'. Returns undefined when the directory cannot be read or
* is itself a symlink, which is never followed.
*/
async function readLegacyCommandDir(
dirPath: string,
managedFileNames: readonly string[]
): Promise<{ managed: string[]; others: string[] } | undefined> {
let entries;
try {
if ((await fs.lstat(dirPath)).isSymbolicLink()) {
return undefined;
}
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return undefined;
}

const managed: string[] = [];
const others: string[] = [];
for (const entry of entries) {
if (
entry.isFile() &&
managedFileNames.includes(entry.name) &&
(await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))
) {
managed.push(entry.name);
} else {
others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
}
}
return { managed: managed.sort(), others: others.sort() };
}

/**
* The legacy command directory, and its tool, that a repo-local path is one of
* OpenSpec's own files in.
*/
function legacyCommandDirForFile(file: string): { toolId: string; dir: string } | undefined {
const normalizedFile = normalizePathForMatch(file);
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type !== 'directory' || !pattern.path) continue;
const dir = pattern.path;
if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
return { toolId, dir };
}
}
return undefined;
}

/**
* Removes a legacy command directory once OpenSpec's files are gone from it,
* or records what is left in it as kept. Never recursive: whatever remains was
* not written by OpenSpec. Returns true when the directory was removed.
*/
async function settleLegacyCommandDir(
projectPath: string,
dirPath: string,
result: CleanupResult
): Promise<boolean> {
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
const remaining = await readLegacyCommandDir(fullPath, []);
if (!remaining) {
return false;
}
if (remaining.others.length === 0) {
await fs.rmdir(fullPath);
result.deletedDirs.push(dirPath);
return true;
}
result.keptFiles!.push(...remaining.others.map((name) => `${dirPath}/${name}`));
return false;
}

/**
* Whether a file is a legacy command OpenSpec generated: a regular file (not a
* link) whose content carries the OpenSpec markers. Every legacy slash command
* was written with them, and OpenSpec refused to update one that lost them, so
* a same-named file without them is the user's.
*/
async function isGeneratedLegacyCommand(filePath: string): Promise<boolean> {
try {
if (!(await fs.lstat(filePath)).isFile()) {
return false;
}
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
} catch {
return false;
}
}

/**
* Detects legacy global slash command files.
*
Expand Down Expand Up @@ -506,6 +624,8 @@ export interface CleanupResult {
modifiedFiles: string[];
/** Directories that were deleted */
deletedDirs: string[];
/** Entries left in a legacy command directory because OpenSpec did not write them */
keptFiles?: string[];
/** Whether project.md exists and needs manual migration */
projectMdNeedsMigration: boolean;
/** Error messages if any operations failed */
Expand All @@ -529,6 +649,7 @@ export async function cleanupLegacyArtifacts(
deletedFileReplacementLabels: {},
modifiedFiles: [],
deletedDirs: [],
keptFiles: [],
projectMdNeedsMigration: detection.hasProjectMd,
errors: [],
};
Expand All @@ -548,28 +669,67 @@ export async function cleanupLegacyArtifacts(
}
}

// Delete legacy slash command directories (these are 100% OpenSpec-managed)
// Delete legacy slash command directories: only the files OpenSpec wrote,
// then the directory once it is empty. Detection reports a directory only
// when it holds nothing else, but a file the user added since is still kept.
for (const dirPath of detection.slashCommandDirs) {
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
try {
await fs.rm(fullPath, { recursive: true, force: true });
result.deletedDirs.push(dirPath);
const managedFileNames = legacyManagedFileNamesForDir(dirPath);
const entries = await readLegacyCommandDir(fullPath, managedFileNames);
if (!entries) {
continue;
}
const deleted: string[] = [];
for (const name of entries.managed) {
const filePath = path.join(fullPath, name);
// Check again just before deleting: the file may have been replaced
// with the user's own since the scan. A kept file is reported below.
if (!(await isGeneratedLegacyCommand(filePath))) {
continue;
}
await fs.unlink(filePath);
deleted.push(name);
}
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
}
} catch (error: any) {
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
}
}

// Delete legacy slash command files (these are 100% OpenSpec-managed)
const partlyCleanedDirs = new Set<string>();
for (const filePath of detection.slashCommandFiles) {
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
try {
const commandDir = legacyCommandDirForFile(filePath);
if (commandDir) {
partlyCleanedDirs.add(commandDir.dir);
// Check again just before deleting: the file may have been replaced
// with the user's own since detection. A kept file is reported below.
if (!(await isGeneratedLegacyCommand(fullPath))) {
continue;
}
}
await fs.unlink(fullPath);
result.deletedFiles.push(filePath);
} catch (error: any) {
result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
}
}

// A legacy command directory that also held the user's files was cleaned
// file by file above; record what was left in it.
for (const dirPath of partlyCleanedDirs) {
try {
await settleLegacyCommandDir(projectPath, dirPath, result);
} catch (error: any) {
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
}
}

// Delete managed global slash command files (these are 100% OpenSpec-managed)
const globalPromptMatchesByPath = new Map(
getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const)
Expand Down Expand Up @@ -621,7 +781,14 @@ export async function cleanupLegacyArtifacts(
export function formatCleanupSummary(result: CleanupResult): string {
const lines: string[] = [];

if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
const keptFiles = result.keptFiles ?? [];

if (
result.deletedFiles.length > 0 ||
result.deletedDirs.length > 0 ||
result.modifiedFiles.length > 0 ||
keptFiles.length > 0
) {
lines.push('Cleaned up legacy files:');

for (const file of result.deletedFiles) {
Expand All @@ -637,6 +804,10 @@ export function formatCleanupSummary(result: CleanupResult): string {
lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
}

for (const entry of keptFiles) {
lines.push(` • Kept ${entry} (not created by OpenSpec)`);
}

for (const file of result.modifiedFiles) {
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
}
Expand Down Expand Up @@ -832,8 +1003,24 @@ function legacyToolIdForDir(dir: string): string | undefined {
return undefined;
}

/** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
function legacyManagedFileNamesForDir(dir: string): readonly string[] {
const normalizedDir = normalizePathForMatch(dir);
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'directory' && pattern.path === normalizedDir) {
return pattern.managedFileNames ?? [];
}
}
return [];
}

/** The tool that owns a repo-local legacy slash-command file, if any. */
function legacyToolIdForFile(file: string): string | undefined {
// A file from a directory-based tool, reported because the directory also
// holds the user's own files.
const commandDir = legacyCommandDirForFile(file);
if (commandDir) return commandDir.toolId;

// Normalize to forward slashes so the glob patterns match on Windows too.
const normalizedFile = normalizePathForMatch(file);
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
Expand Down
5 changes: 3 additions & 2 deletions test/core/legacy-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ ${OPENSPEC_MARKERS.end}`);
it('should detect legacy Claude slash command directory', async () => {
const dirPath = path.join(testDir, '.claude', 'commands', 'openspec');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'proposal.md'), 'content');
await fs.writeFile(path.join(dirPath, 'proposal.md'), '<!-- OPENSPEC:START -->\ncontent\n<!-- OPENSPEC:END -->\n');

const result = await detectLegacySlashCommands(testDir);
expect(result.directories).toContain('.claude/commands/openspec');
Expand Down Expand Up @@ -612,7 +612,7 @@ ${OPENSPEC_MARKERS.end}`);
it('should delete legacy slash command directories', async () => {
const dirPath = path.join(testDir, '.claude', 'commands', 'openspec');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'proposal.md'), 'content');
await fs.writeFile(path.join(dirPath, 'proposal.md'), '<!-- OPENSPEC:START -->\ncontent\n<!-- OPENSPEC:END -->\n');

const detection = await detectLegacyArtifacts(testDir);
const result = await cleanupLegacyArtifacts(testDir, detection);
Expand Down Expand Up @@ -1162,6 +1162,7 @@ ${OPENSPEC_MARKERS.end}`);
expect(LEGACY_SLASH_COMMAND_PATHS['claude']).toEqual({
type: 'directory',
path: '.claude/commands/openspec',
managedFileNames: ['proposal.md', 'apply.md', 'archive.md'],
});

expect(LEGACY_SLASH_COMMAND_PATHS['cursor']).toEqual({
Expand Down
Loading
Loading