Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5.5 e9f5448dec fix(update): stop kept command files from re-syncing on every update
Under skills-only delivery the drift check counted any command file on
disk, so a file cleanup kept on purpose (an edit, or a file an older
OpenSpec version wrote) made every later update re-sync and print the
kept list again. Drift now counts only files whose content matches
generated output, using the same check cleanup uses to decide what to
delete.

The kept-files headline now says such a file may come from an older
OpenSpec version, since command files carry no version and a template
change makes an untouched file stop matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:00:46 -05:00
Clay GoodandClaude Opus 5.5 7553e83fb9 fix(update): report unreadable command files apart from edits
Two review findings on the cleanup:

- `resolveProjectArtifactPath` throws on a command path that is a dangling
  symlink or one escaping the project. It ran outside the per-file guard,
  so one such entry failed the tool's whole update and left its other
  command files unreconciled. The entry is now left untouched, never read
  or deleted through, and cleanup continues.
- A file that could not be read or deleted was reported as "does not match
  generated output", though no mismatch was found. Those now go to a
  separate `unchecked` list with their own message, since the fix for an
  access problem differs from the fix for an edit.

Part of #2022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:40:38 -05:00
Clay GoodandClaude Opus 5 2501a41097 fix(update): keep command files the user edited when moving to skills
Switching `delivery` to `skills` unlinked every generated command file
without looking at it, so a hand-edited command was discarded silently.

Cleanup now compares each file against what this CLI would generate and
deletes only what matches, normalizing a BOM and CRLF so a Windows
checkout is not read as an edit. Content depends on which workflows were
installed when a file was written, so three candidate sets are compared:
the workflows whose command files are on disk, the set this run uses, and
the full set. A file that still differs is left in place and named, as is
one that cannot be read or deleted.

`init` and `update` had separate copies of the delete loop; both now call
one shared `reconcileCommandFiles`.

Part of #2022

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 08:26:54 -05:00
Clay GoodandClaude Opus 5.5 9111a7654d fix(security): accept the unpatched braces advisory in pnpm audit (#2048)
* fix(security): clear the unpatched braces advisory on main

pnpm audit --prod fails on main for GHSA-vfj7-8cjw-p6xm (braces <=3.0.3,
stack exhaustion on deeply nested patterns). braces ships at runtime via
fast-glob > micromatch, and no patched version exists, so no override can
fix it.

Reject artifact output patterns that nest braces more than 16 levels deep
before they reach fast-glob, and record the advisory in auditConfig with
that mitigation and a removal check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): keep only the audit exception for the braces advisory

Move the brace-nesting guard to a follow-up PR: it adds a user-visible
limit to schema `generates` that needs a docs-lab contract update and a
spec change. The audit exception alone clears main's Security workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): record the braces advisory's residual risk accurately

Name both inputs that reach fast-glob (generates and apply.tracks) and
state that a crafted schema can still crash the CLI, instead of relying on
the input cap or a failed local reproduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): drop unsupported claims from the braces risk record

Schemas resolve from the project, user, or package directories, not a
store, and the input-length cap does not prevent stack exhaustion. State
only the accepted risk and the removal check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:21:34 +00:00
12 changed files with 537 additions and 65 deletions
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---
Switching to skills-only delivery no longer deletes a command file you edited. `openspec init` and `openspec update` now compare each generated command file against what OpenSpec would write before removing it; anything that differs is left on disk and listed by path, with a line telling you to move what you want into the skill and delete the rest by hand. The comparison ignores a UTF-8 BOM and CRLF line endings, so a Windows checkout is not mistaken for an edit, and it accounts for files written under a different profile than the one now configured. A file that cannot be read or deleted, or a symbolic link that points outside the project, is left alone and listed separately, so one bad entry no longer stops the rest of the cleanup.
+12
View File
@@ -26,3 +26,15 @@ overrides:
# Dev-only (transitive via @vitest/ui); never in the published CLI. Remove once
# transitive fflate is >=0.8.3 (check: pnpm why fflate).
fflate@<0.8.3: '>=0.8.3 <0.9'
auditConfig:
ignoreGhsas:
# GHSA-vfj7-8cjw-p6xm — braces <=3.0.3 stack exhaustion on deeply nested
# patterns. Runtime (fast-glob > micromatch > braces), and no patched braces
# exists, so no override can fix it. Accepted residual risk: schema artifact
# `generates` and `apply.tracks` patterns reach fast-glob through
# resolveArtifactOutputs without a brace-depth guard, and the 10,000-character
# input limit does not prevent stack exhaustion. A crafted project-local or
# user schema can still terminate the openspec process. Remove this exception
# once braces ships a patched release (check: pnpm why braces; pnpm audit --prod).
- GHSA-vfj7-8cjw-p6xm
+15 -22
View File
@@ -61,6 +61,7 @@ import {
type ToolSkillStatus,
formatIdeRestart,
} from './shared/index.js';
import { reconcileCommandFiles, formatKeptCommandFiles } from './shared/command-reconcile.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
@@ -958,6 +959,7 @@ export class InitCommand {
commandsSkipped: string[];
skillsInvocableCommandSkips: string[];
removedCommandCount: number;
leftCommandFiles: { kept: string[]; unchecked: string[] };
removedSkillCount: number;
}> {
const createdTools: typeof tools = [];
@@ -966,6 +968,7 @@ export class InitCommand {
const commandsSkipped: string[] = [];
const skillsInvocableCommandSkips: string[] = [];
let removedCommandCount = 0;
const leftCommandFiles = { kept: [] as string[], unchecked: [] as string[] };
let removedSkillCount = 0;
// Read global config for profile and delivery settings (use --profile override if set)
@@ -1039,7 +1042,10 @@ export class InitCommand {
}
}
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
const reconciled = await reconcileCommandFiles(projectPath, tool.value, workflows);
removedCommandCount += reconciled.removed.length;
leftCommandFiles.kept.push(...reconciled.kept);
leftCommandFiles.unchecked.push(...reconciled.unchecked);
}
if (tool.value === 'github-copilot' && writeCopilotCloud) {
await writeCopilotCloudFiles(projectPath);
@@ -1079,6 +1085,7 @@ export class InitCommand {
commandsSkipped,
skillsInvocableCommandSkips,
removedCommandCount,
leftCommandFiles,
removedSkillCount,
};
}
@@ -1189,6 +1196,7 @@ export class InitCommand {
commandsSkipped: string[];
skillsInvocableCommandSkips: string[];
removedCommandCount: number;
leftCommandFiles: { kept: string[]; unchecked: string[] };
removedSkillCount: number;
},
configStatus: 'created' | 'exists' | 'skipped',
@@ -1301,6 +1309,12 @@ export class InitCommand {
if (results.removedCommandCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
}
for (const [headline, ...detail] of formatKeptCommandFiles(results.leftCommandFiles)) {
console.log(chalk.yellow(headline));
for (const line of detail) {
console.log(chalk.dim(line));
}
}
if (results.removedSkillCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
}
@@ -1514,25 +1528,4 @@ export class InitCommand {
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 = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
try {
if (fs.existsSync(fullPath)) {
await fs.promises.unlink(fullPath);
removed++;
}
} catch {
// Ignore errors
}
}
return removed;
}
}
+6 -6
View File
@@ -5,6 +5,7 @@ import type { Delivery } from './global-config.js';
import { ALL_WORKFLOWS } from './profiles.js';
import { CommandAdapterRegistry } from './command-generation/index.js';
import { getConfiguredTools } from './shared/index.js';
import { classifyCommandFiles } from './shared/command-reconcile.js';
import {
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
@@ -163,12 +164,11 @@ export function hasToolProfileOrDeliveryDrift(
}
}
} else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) {
for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
if (fs.existsSync(fullPath)) {
return true;
}
// Only generated output is drift. Files cleanup keeps (edits, older
// versions, unreadable entries) stay on disk by design; counting them would
// re-sync on every update.
if (classifyCommandFiles(projectPath, toolId, knownDesiredWorkflows).generated.length > 0) {
return true;
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Command File Reconciliation
*
* Removing generated command files when delivery becomes skills-only.
*/
import * as fs from 'fs';
import { FileSystemUtils } from '../../utils/file-system.js';
import {
generateCommands,
CommandAdapterRegistry,
} from '../command-generation/index.js';
import type { GeneratedCommand } from '../command-generation/types.js';
import { ALL_WORKFLOWS } from '../profiles.js';
import { getCommandContents } from './skill-generation.js';
import { normalizeCommandContent } from './tool-detection.js';
export interface CommandReconcileResult {
/** Project-relative paths of generated command files that were deleted. */
removed: string[];
/** Project-relative paths left on disk because they are not our output. */
kept: string[];
/**
* Project-relative paths left on disk because they could not be checked or
* removed: unreadable, undeletable, or a symlink that cannot be proven to stay
* inside the project. Reported apart from `kept`, since no edit was found.
*/
unchecked: string[];
}
interface CommandFileEntry {
cmdPath: string;
fullPath: string;
}
interface CommandFileClassification {
/** Files whose content matches generated output, safe to delete. */
generated: CommandFileEntry[];
kept: string[];
unchecked: string[];
}
/**
* Sorts a tool's command files on disk into generated output, files the user
* owns, and files that could not be checked.
*
* Command files carry no version stamp, so content equality is the only signal
* for "this is still our generated output". A file that does not match is the
* user's, whether they edited it or an older OpenSpec version wrote it.
*
* Content depends on which workflows were installed when a file was written,
* because cross-workflow references resolve against that set. Three candidate
* sets are therefore compared: the workflows whose command files are on disk
* (the closest reconstruction of the original inputs), the set this run is
* using, and the full set. A file written under a wider or narrower profile
* than the one now configured still matches.
*/
export function classifyCommandFiles(
projectPath: string,
toolId: string,
workflows: readonly string[]
): CommandFileClassification {
const generated: CommandFileEntry[] = [];
const kept: string[] = [];
const unchecked: string[] = [];
const adapter = CommandAdapterRegistry.get(toolId);
if (!adapter) return { generated, kept, unchecked };
const present: (CommandFileEntry & { workflow: string })[] = [];
for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
let fullPath: string;
try {
fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
} catch {
// A symlink that escapes the project or dangles cannot be proven
// confined. Never read or delete through it, and do not let one such
// entry abort cleanup of the tool's other command files.
unchecked.push(cmdPath);
continue;
}
if (fs.existsSync(fullPath)) {
present.push({ workflow, cmdPath, fullPath });
}
}
if (present.length === 0) return { generated, kept, unchecked };
const candidateSets = [present.map((entry) => entry.workflow), workflows, ALL_WORKFLOWS];
const generatedByPath = new Map<string, string[]>();
for (const workflowSet of candidateSets) {
let commands: GeneratedCommand[];
try {
commands = generateCommands(getCommandContents(workflowSet), adapter);
} catch {
// A workflow set this CLI cannot render leaves nothing to compare
// against; files it would have covered are kept rather than guessed at.
continue;
}
for (const cmd of commands) {
const variants = generatedByPath.get(cmd.path) ?? [];
variants.push(normalizeCommandContent(cmd.fileContent));
generatedByPath.set(cmd.path, variants);
}
}
for (const { cmdPath, fullPath } of present) {
try {
const onDisk = normalizeCommandContent(fs.readFileSync(fullPath, 'utf-8'));
if ((generatedByPath.get(cmdPath) ?? []).includes(onDisk)) {
generated.push({ cmdPath, fullPath });
} else {
kept.push(cmdPath);
}
} catch {
// An unreadable file cannot be proven to be generated output.
unchecked.push(cmdPath);
}
}
return { generated, kept, unchecked };
}
/**
* Deletes a tool's generated command files, keeping back any the user owns.
* A file that does not match generated output is left on disk and reported,
* so moving to skills-only delivery can never silently discard a hand-edited
* command. See `classifyCommandFiles` for how a match is decided.
*/
export async function reconcileCommandFiles(
projectPath: string,
toolId: string,
workflows: readonly string[]
): Promise<CommandReconcileResult> {
const { generated, kept, unchecked } = classifyCommandFiles(projectPath, toolId, workflows);
const removed: string[] = [];
for (const { cmdPath, fullPath } of generated) {
try {
await fs.promises.unlink(fullPath);
removed.push(cmdPath);
} catch {
// A matched file that cannot be deleted is still on disk. Report it
// rather than passing over it in silence: the user needs to know the
// directory was not fully cleared.
unchecked.push(cmdPath);
}
}
return { removed, kept, unchecked };
}
/**
* Formats the report for command files `reconcileCommandFiles` left on disk.
* Each block is a headline, its paths, then what to do; edits and files that
* could not be checked are told apart, because the fix for each differs.
* Returns an empty array when nothing was left behind.
*/
export function formatKeptCommandFiles(
result: Pick<CommandReconcileResult, 'kept' | 'unchecked'>
): string[][] {
const blocks: string[][] = [];
const files = (count: number) => `command ${count === 1 ? 'file' : 'files'}`;
if (result.kept.length > 0) {
blocks.push([
`Kept ${result.kept.length} ${files(result.kept.length)} that ${
result.kept.length === 1 ? 'does' : 'do'
} not match generated output (edited, or written by an older OpenSpec version):`,
...result.kept.map((cmdPath) => ` ${cmdPath}`),
'Move anything worth keeping into the skill, then delete by hand.',
]);
}
if (result.unchecked.length > 0) {
blocks.push([
`Left ${result.unchecked.length} ${files(result.unchecked.length)} in place because ${
result.unchecked.length === 1 ? 'it' : 'they'
} could not be read or removed:`,
...result.unchecked.map((cmdPath) => ` ${cmdPath}`),
'Check the file permissions or symbolic links, then delete by hand.',
]);
}
return blocks;
}
+1
View File
@@ -18,6 +18,7 @@ export {
getToolVersionStatus,
getConfiguredTools,
getAllToolVersionStatus,
normalizeCommandContent,
} from './tool-detection.js';
export {
+1 -1
View File
@@ -152,7 +152,7 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string)
* CRLF line endings, which a Windows clone with `core.autocrlf` reintroduces on
* every checkout of committed command files.
*/
function normalizeCommandContent(content: string): string {
export function normalizeCommandContent(content: string): string {
return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
}
+16 -31
View File
@@ -30,6 +30,7 @@ import {
type ToolVersionStatus,
formatIdeRestart,
} from './shared/index.js';
import { reconcileCommandFiles, formatKeptCommandFiles } from './shared/command-reconcile.js';
import {
detectLegacyArtifacts,
cleanupLegacyArtifacts,
@@ -315,6 +316,7 @@ export class UpdateCommand {
const skillsInvocableCommandSkips: string[] = [];
const zeroArtifactTools: string[] = [];
let removedCommandCount = 0;
const leftCommandFiles = { kept: [] as string[], unchecked: [] as string[] };
let removedSkillCount = 0;
let removedDeselectedCommandCount = 0;
let removedDeselectedSkillCount = 0;
@@ -405,7 +407,14 @@ export class UpdateCommand {
// Delete command files if delivery is skills-only
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
const reconciled = await reconcileCommandFiles(
resolvedProjectPath,
toolId,
toolWorkflows
);
removedCommandCount += reconciled.removed.length;
leftCommandFiles.kept.push(...reconciled.kept);
leftCommandFiles.unchecked.push(...reconciled.unchecked);
}
spinner.succeed(`Updated ${tool.name}`);
@@ -448,6 +457,12 @@ export class UpdateCommand {
if (removedCommandCount > 0) {
console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
}
for (const [headline, ...detail] of formatKeptCommandFiles(leftCommandFiles)) {
console.log(chalk.yellow(headline));
for (const line of detail) {
console.log(chalk.dim(line));
}
}
if (removedSkillCount > 0) {
console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
}
@@ -887,36 +902,6 @@ export class UpdateCommand {
return removed;
}
/**
* Removes command files for workflows when delivery changed to skills-only.
* Returns the number of files 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 = FileSystemUtils.resolveProjectArtifactPath(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 no longer selected in the active profile.
* Returns the number of files removed.
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import {
reconcileCommandFiles,
formatKeptCommandFiles,
} from '../../src/core/shared/command-reconcile.js';
import { getCommandContents } from '../../src/core/shared/skill-generation.js';
import {
CommandAdapterRegistry,
generateCommands,
} from '../../src/core/command-generation/index.js';
import { ALL_WORKFLOWS } from '../../src/core/profiles.js';
const WORKFLOWS = ['propose', 'apply', 'archive'] as const;
async function writeGeneratedCommands(
projectPath: string,
toolId: string,
workflows: readonly string[]
): Promise<string[]> {
const adapter = CommandAdapterRegistry.get(toolId)!;
const generated = generateCommands(getCommandContents(workflows), adapter);
for (const cmd of generated) {
const full = path.join(projectPath, cmd.path);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, cmd.fileContent);
}
return generated.map((cmd) => cmd.path);
}
describe('reconcileCommandFiles', () => {
let projectPath: string;
beforeEach(async () => {
projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-reconcile-'));
});
afterEach(async () => {
await fs.rm(projectPath, { recursive: true, force: true });
});
it('removes command files it generated', async () => {
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
expect(result.removed.sort()).toEqual([...written].sort());
expect(result.kept).toEqual([]);
for (const cmdPath of written) {
await expect(fs.access(path.join(projectPath, cmdPath))).rejects.toThrow();
}
});
it('keeps a hand-edited command file and reports it', async () => {
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const edited = written.find((p) => p.endsWith('propose.md'))!;
const editedFull = path.join(projectPath, edited);
const original = await fs.readFile(editedFull, 'utf-8');
await fs.writeFile(editedFull, `${original}\n<!-- our team's extra step -->\n`);
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
expect(result.kept).toEqual([edited]);
expect(result.removed).not.toContain(edited);
// The edit survives byte-for-byte: kept means untouched, not rewritten.
expect(await fs.readFile(editedFull, 'utf-8')).toBe(
`${original}\n<!-- our team's extra step -->\n`
);
expect(formatKeptCommandFiles(result)).toEqual([
[
'Kept 1 command file that does not match generated output (edited, or written by an older OpenSpec version):',
` ${edited}`,
'Move anything worth keeping into the skill, then delete by hand.',
],
]);
});
it('treats a BOM and CRLF checkout as untouched, not edited', async () => {
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const checkedOut = path.join(projectPath, written[0]);
const original = await fs.readFile(checkedOut, 'utf-8');
await fs.writeFile(checkedOut, `${original.replace(/\n/g, '\r\n')}`);
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
expect(result.kept).toEqual([]);
expect(result.removed).toContain(written[0]);
});
it('removes a file left over from a wider profile than the one in use', async () => {
// Written when every workflow was installed, reconciled against a narrower
// set: still our generated output, so it must not be reported as an edit.
const written = await writeGeneratedCommands(projectPath, 'claude', ALL_WORKFLOWS);
const result = await reconcileCommandFiles(projectPath, 'claude', ['propose']);
expect(result.kept).toEqual([]);
expect(result.removed.sort()).toEqual([...written].sort());
});
it('keeps and reports a file it cannot read rather than passing over it', async () => {
// A file left on disk must always be named. Silently skipping an
// unreadable one would tell the user the directory was cleared when it
// was not.
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const unreadable = written.find((p) => p.endsWith('apply.md'))!;
// A directory at the command path exists but cannot be read as a file
// (EISDIR), on every platform and without mocking the read.
await fs.rm(path.join(projectPath, unreadable));
await fs.mkdir(path.join(projectPath, unreadable));
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
// Reported as unchecked, not as an edit: no mismatch was established.
expect(result.unchecked).toEqual([unreadable]);
expect(result.kept).toEqual([]);
expect(result.removed).not.toContain(unreadable);
expect(formatKeptCommandFiles(result)).toEqual([
[
'Left 1 command file in place because it could not be read or removed:',
` ${unreadable}`,
'Check the file permissions or symbolic links, then delete by hand.',
],
]);
// Still on disk, which is what being reported as kept has to mean.
await expect(fs.access(path.join(projectPath, unreadable))).resolves.toBeUndefined();
});
it.skipIf(process.platform === 'win32')(
'never follows a command symlink that escapes the project, and still cleans the rest',
async () => {
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const linked = written.find((p) => p.endsWith('apply.md'))!;
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-reconcile-outside-'));
const outsideFile = path.join(outsideDir, 'apply.md');
// Same bytes as generated output, so only the confinement check can
// stop it being deleted through the link.
await fs.copyFile(path.join(projectPath, linked), outsideFile);
await fs.rm(path.join(projectPath, linked));
await fs.symlink(outsideFile, path.join(projectPath, linked));
try {
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
expect(result.unchecked).toEqual([linked]);
// One bad entry must not abort cleanup of the tool's other files.
expect(result.removed.sort()).toEqual(written.filter((p) => p !== linked).sort());
await expect(fs.access(outsideFile)).resolves.toBeUndefined();
await expect(fs.lstat(path.join(projectPath, linked))).resolves.toBeTruthy();
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
}
);
it.skipIf(process.platform === 'win32')(
'reports a dangling command symlink instead of failing the run',
async () => {
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
const dangling = written.find((p) => p.endsWith('apply.md'))!;
await fs.rm(path.join(projectPath, dangling));
await fs.symlink(path.join(projectPath, 'does-not-exist.md'), path.join(projectPath, dangling));
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
expect(result.unchecked).toEqual([dangling]);
expect(result.removed).toHaveLength(written.length - 1);
}
);
it('does nothing for a tool with no command adapter', async () => {
const result = await reconcileCommandFiles(projectPath, 'codex', WORKFLOWS);
expect(result).toEqual({ removed: [], kept: [], unchecked: [] });
});
it('reports nothing when nothing was left behind', () => {
expect(formatKeptCommandFiles({ kept: [], unchecked: [] })).toEqual([]);
});
});
+17
View File
@@ -1401,6 +1401,23 @@ describe('InitCommand', () => {
expect(getConsoleOutput()).toContain('Restart your IDE to refresh skills.');
});
it('keeps a command file the user edited when init reconciles to skills-only', async () => {
// `init --force` over an existing project reconciles the same way `update`
// does, so it must not discard a hand-edited command either.
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
const proposeCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');
const applyCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md');
const edited = `${await fs.readFile(proposeCmd, 'utf-8')}\n<!-- team step -->\n`;
await fs.writeFile(proposeCmd, edited);
saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
expect(await fileExists(applyCmd)).toBe(false);
expect(getConsoleOutput()).toContain('Kept 1 command file that does not match');
});
it('should create skills for multiple tools at once', async () => {
const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });
+26 -3
View File
@@ -8,7 +8,8 @@ import {
WORKFLOW_TO_SKILL_DIR,
} from '../../src/core/profile-sync-drift.js';
import { CORE_WORKFLOWS } from '../../src/core/profiles.js';
import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js';
import { CommandAdapterRegistry, generateCommands } from '../../src/core/command-generation/index.js';
import { getCommandContents } from '../../src/core/shared/index.js';
function writeSkill(projectDir: string, workflowId: string): void {
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId as keyof typeof WORKFLOW_TO_SKILL_DIR];
@@ -26,6 +27,16 @@ function writeCommand(projectDir: string, workflowId: string): void {
fs.writeFileSync(fullPath, `# ${workflowId}\n`);
}
function writeGeneratedCommand(projectDir: string, workflowId: string): string {
const adapter = CommandAdapterRegistry.get('claude');
if (!adapter) throw new Error('Claude adapter unavailable in test environment');
const [generated] = generateCommands(getCommandContents([workflowId]), adapter);
const fullPath = path.join(projectDir, generated.path);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, generated.fileContent);
return fullPath;
}
function setupCoreSkills(projectDir: string): void {
for (const workflow of CORE_WORKFLOWS) {
writeSkill(projectDir, workflow);
@@ -65,12 +76,24 @@ describe('profile sync drift detection', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('detects drift for skills-only delivery when commands still exist', () => {
it('detects drift for skills-only delivery when generated commands still exist', () => {
setupCoreSkills(tempDir);
for (const workflow of CORE_WORKFLOWS) {
writeGeneratedCommand(tempDir, workflow);
}
const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills');
expect(hasDrift).toBe(true);
});
it('does not report drift for skills-only delivery when only kept command files remain', () => {
// Cleanup leaves edited or older-version command files on disk. Counting
// them as drift would re-sync on every update without ever clearing them.
setupCoreSkills(tempDir);
setupCoreCommands(tempDir);
const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills');
expect(hasDrift).toBe(true);
expect(hasDrift).toBe(false);
});
it('detects drift for commands-only delivery when skills still exist', () => {
+66 -2
View File
@@ -7,6 +7,11 @@ import { FileSystemUtils } from '../../src/utils/file-system.js';
import { OPENSPEC_MARKERS } from '../../src/core/config.js';
import type { GlobalConfig } from '../../src/core/global-config.js';
import { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/core/github-copilot/cloud-agent.js';
import { getCommandContents } from '../../src/core/shared/index.js';
import {
CommandAdapterRegistry,
generateCommands,
} from '../../src/core/command-generation/index.js';
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
@@ -58,6 +63,24 @@ function resetMockConfig() {
mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' };
}
/**
* Writes the command file OpenSpec itself would generate for `workflow`.
* Fixtures that stand in a placeholder no longer exercise cleanup, because
* cleanup now only deletes files whose content matches generated output.
*/
async function writeGeneratedCommand(
projectDir: string,
toolId: string,
workflow: string
): Promise<string> {
const adapter = CommandAdapterRegistry.get(toolId)!;
const [generated] = generateCommands(getCommandContents([workflow]), adapter);
const full = path.join(projectDir, generated.path);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, generated.fileContent);
return full;
}
async function markCodexTarget(skillsDir: string): Promise<void> {
await fs.mkdir(skillsDir, { recursive: true });
await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n');
@@ -1477,6 +1500,48 @@ metadata:
});
});
describe('migrating to skills-only delivery', () => {
it('deletes generated command files but keeps one the user edited', async () => {
// The migration path for #2022: switching delivery to skills must clear
// the generated commands without discarding a user's own work.
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
const proposeCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');
const applyCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md');
const edited = `${await fs.readFile(proposeCmd, 'utf-8')}\n<!-- team step -->\n`;
await fs.writeFile(proposeCmd, edited);
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
const consoleSpy = vi.spyOn(console, 'log');
await new UpdateCommand({ force: true }).execute(testDir);
// The skill is in place, so the workflow still works...
expect(
await FileSystemUtils.fileExists(
path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md')
)
).toBe(true);
// ...the untouched command is gone...
expect(await FileSystemUtils.fileExists(applyCmd)).toBe(false);
// ...and the edited one survives byte-for-byte, named in the output.
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
const logs = consoleSpy.mock.calls.flat().map(String);
expect(
logs.some((entry) => entry.includes('Kept 1 command file that does not match'))
).toBe(true);
expect(
logs.some((entry) => entry.includes(path.join('.claude', 'commands', 'opsx', 'propose.md')))
).toBe(true);
// A kept file is not drift: the next plain update finds nothing to sync.
consoleSpy.mockClear();
await new UpdateCommand().execute(testDir);
const nextLogs = consoleSpy.mock.calls.flat().map(String);
expect(nextLogs.some((entry) => entry.includes('config sync'))).toBe(false);
expect(nextLogs.some((entry) => entry.includes('Kept 1 command file'))).toBe(false);
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
});
});
describe('multi-tool support', () => {
it('should update multiple configured tools', async () => {
// Set up Claude
@@ -4291,8 +4356,7 @@ content
);
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
await fs.mkdir(commandsDir, { recursive: true });
await fs.writeFile(path.join(commandsDir, 'explore.md'), 'old command');
await writeGeneratedCommand(testDir, 'claude', 'explore');
await updateCommand.execute(testDir);