diff --git a/.changeset/update-detects-command-only-tools.md b/.changeset/update-detects-command-only-tools.md new file mode 100644 index 0000000000..c65d98a112 --- /dev/null +++ b/.changeset/update-detects-command-only-tools.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +`openspec update` now refreshes tools that are configured with command files but no skills (delivery `commands`). Previously it read the generating version only from skill files, so such a tool was reported as "up to date" forever and its command files were never regenerated after a CLI upgrade. Command files carry no version stamp, so OpenSpec compares their contents against what it would generate now — including removing a command file left behind by a workflow you have since deselected. CRLF line endings and a UTF-8 BOM are treated as checkout artifacts rather than drift, so a Windows clone does not report a spurious update. diff --git a/docs/cli.md b/docs/cli.md index 7bc8ff9f74..9eeec8e29d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -203,6 +203,16 @@ Whenever anything is printed, it names the directory the running CLI was loaded It asks the registry in `npm_config_registry` when npm exports it, and `https://registry.npmjs.org` otherwise. No `.npmrc` is read: letting file contents choose where an outbound request goes is a flow worth avoiding, and a project's `.npmrc` travels with the repository. On a private mirror, export `npm_config_registry` — or set `OPENSPEC_NO_UPDATE_CHECK` to skip the check entirely. The check is skipped when `CI` is set to anything but an explicit off-value (`false`, `0`, `no`, `off`, or empty), under `NODE_ENV=test`, and whenever `OPENSPEC_NO_UPDATE_CHECK` (any value), `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. It runs before the update and can delay it by at most 1.5 seconds — it gives up after that even when the network drops packets silently, and stays quiet when the registry is unreachable. +**How "up to date" is decided:** skill files record the version that generated +them, so OpenSpec compares that against the installed CLI. Command files carry no +version stamp, so for a tool that has commands but no skills (delivery +`commands`), OpenSpec compares the file contents against what it would generate +now — edits to those files count as drift and are overwritten. With delivery +`skills` or `both`, only the recorded version is checked, so a hand-edited file +whose version still matches is left alone; use `--force` to rewrite it. Either +way, generated files are OpenSpec's to own — keep your own instructions +elsewhere. + --- ## Stores (standalone OpenSpec repos) diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index a876d6ce72..65fa539b0f 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -4,7 +4,7 @@ import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; -import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { getConfiguredTools } from './shared/index.js'; import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, @@ -39,49 +39,11 @@ function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { ); } -/** - * Checks whether a tool has at least one generated OpenSpec command file. - */ -export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return false; - - for (const commandId of COMMAND_IDS) { - const cmdPath = adapter.getFilePath(commandId); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { - return true; - } - } - - return false; -} - -/** - * Returns tools with at least one generated command file on disk. - */ -export function getCommandConfiguredTools(projectPath: string): string[] { - return AI_TOOLS - .filter((tool) => { - if (!tool.skillsDir) return false; - const toolDir = path.join(projectPath, tool.skillsDir); - try { - return fs.statSync(toolDir).isDirectory(); - } catch { - return false; - } - }) - .map((tool) => tool.value) - .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId)); -} - /** * Returns tools that are configured via either skills or commands. */ export function getConfiguredToolsForProfileSync(projectPath: string): string[] { - const skillConfigured = getConfiguredTools(projectPath); - const commandConfigured = getCommandConfiguredTools(projectPath); - return [...new Set([...skillConfigured, ...commandConfigured])]; + return getConfiguredTools(projectPath); } /** diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..b6efdc3790 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -7,6 +7,10 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS } from '../config.js'; +import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js'; +import { getCommandContents } from './skill-generation.js'; +import { getGlobalConfig } from '../global-config.js'; +import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; /** * Names of skill directories created by openspec init. @@ -68,9 +72,13 @@ export interface ToolVersionStatus { toolId: string; /** The tool's display name */ toolName: string; - /** Whether the tool has any skills configured */ + /** Whether the tool has any skills or commands configured */ configured: boolean; - /** The generatedBy version found in the skill files, or null if not found */ + /** + * The generatedBy version recorded in the tool's skill files. For a tool that + * has commands but no skills, the current version when the command files match + * what would be generated now. Null when neither says the files are current. + */ generatedByVersion: string | null; /** Whether the tool needs updating (version mismatch or missing) */ needsUpdate: boolean; @@ -109,6 +117,102 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki }; } +/** + * Checks whether a tool has at least one generated OpenSpec command file. + */ +export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return false; + + for (const commandId of COMMAND_IDS) { + const cmdPath = adapter.getFilePath(commandId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { + return true; + } + } + + return false; +} + +/** + * Normalizes checkout artifacts that are not real content drift: a UTF-8 BOM and + * CRLF line endings, which a Windows clone with `core.autocrlf` reintroduces on + * every checkout of committed command files. + */ +function normalizeCommandContent(content: string): string { + return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); +} + +/** + * Checks whether command files for a tool on disk match current generated command contents. + * + * Command files carry no version stamp, so content equality is the only available + * "is this current?" signal for a commands-only install. + */ +export function areCommandFilesUpToDate( + projectRoot: string, + toolId: string, + options?: { + workflows?: readonly string[]; + } +): boolean { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return false; + + let workflows: readonly string[]; + if (options?.workflows) { + workflows = options.workflows; + } else { + try { + const globalCfg = getGlobalConfig(); + const profile = globalCfg.profile ?? 'core'; + workflows = getProfileWorkflows(profile, globalCfg.workflows); + } catch { + workflows = ALL_WORKFLOWS; + } + } + + const knownWorkflows = workflows.filter((w): w is (typeof ALL_WORKFLOWS)[number] => + (ALL_WORKFLOWS as readonly string[]).includes(w) + ); + + const commandContents = getCommandContents(knownWorkflows); + const generatedCommands = generateCommands(commandContents, adapter); + + if (generatedCommands.length === 0) { + return false; + } + + for (const cmd of generatedCommands) { + const cmdPath = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectRoot, cmd.path); + if (!fs.existsSync(cmdPath)) { + return false; + } + try { + const existingContent = fs.readFileSync(cmdPath, 'utf-8'); + if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) { + return false; + } + } catch { + return false; + } + } + + // Also check no extra command files exist for deselected workflows + const desiredWorkflowSet = new Set(knownWorkflows); + for (const workflow of ALL_WORKFLOWS) { + if (desiredWorkflowSet.has(workflow)) continue; + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath); + if (fs.existsSync(fullPath)) { + return false; + } + } + + return true; +} + /** * Gets the skill status for all tools with skillsDir configured. */ @@ -157,12 +261,16 @@ export function extractGeneratedByVersion(skillFilePath: string): string | null } /** - * Gets version status for a tool by reading the first available skill file. + * Gets version status for a tool by reading its skill files, falling back to a + * command-content fingerprint for installs that have commands but no skills. */ export function getToolVersionStatus( projectRoot: string, toolId: string, - currentVersion: string + currentVersion: string, + options?: { + workflows?: readonly string[]; + } ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) { @@ -178,7 +286,7 @@ export function getToolVersionStatus( const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); let generatedByVersion: string | null = null; - // Find the first skill file that exists and read its version + // 1. Find the first skill file that exists and read its version for (const skillName of SKILL_NAMES) { const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); if (fs.existsSync(skillFile)) { @@ -187,7 +295,17 @@ export function getToolVersionStatus( } } - const configured = getToolSkillStatus(projectRoot, toolId).configured; + const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured; + const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId); + const configured = skillConfigured || commandConfigured; + + // 2. Commands-only installs have no skill file to read a version from, so fall + // back to comparing the generated command content. Deliberately skipped when + // skill files exist: an unreadable version there must still force a rewrite. + if (!skillConfigured && commandConfigured && areCommandFilesUpToDate(projectRoot, toolId, options)) { + generatedByVersion = currentVersion; + } + const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion); return { @@ -200,11 +318,14 @@ export function getToolVersionStatus( } /** - * Gets all configured tools in the project. + * Gets all configured tools in the project (configured via skills or commands). */ export function getConfiguredTools(projectRoot: string): string[] { return AI_TOOLS - .filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured) + .filter((t) => { + if (!t.skillsDir) return false; + return getToolSkillStatus(projectRoot, t.value).configured || toolHasAnyConfiguredCommand(projectRoot, t.value); + }) .map((t) => t.value); } diff --git a/src/core/update.ts b/src/core/update.ts index e983dd8383..fb1f089112 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -45,7 +45,6 @@ import { getOnboardingCommands } from './onboarding-commands.js'; import { getAvailableTools } from './available-tools.js'; import { WORKFLOW_TO_SKILL_DIR, - getCommandConfiguredTools, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js'; @@ -163,16 +162,14 @@ export class UpdateCommand { return; } - // 6. Check version status for all configured tools - const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath); - const commandConfiguredSet = new Set(commandConfiguredTools); - const toolStatuses = configuredTools.map((toolId) => { - const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION); - if (!status.configured && commandConfiguredSet.has(toolId)) { - return { ...status, configured: true }; - } - return status; - }); + // 6. Check version status for all configured tools, against the same workflow set + // the generation loop below writes — otherwise a legacy-upgraded tool would be + // fingerprinted against commands it was never given. + const toolStatuses = configuredTools.map((toolId) => + getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, { + workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows, + }) + ); const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const)); // 7. Smart update detection diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 73f19bd1c8..0d5e74febd 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -18,9 +18,11 @@ describe('tool-detection', () => { beforeEach(async () => { testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); + vi.stubEnv('XDG_CONFIG_HOME', path.join(testDir, 'config')); }); afterEach(async () => { + vi.unstubAllEnvs(); await fs.rm(testDir, { recursive: true, force: true }); }); @@ -258,6 +260,182 @@ Content here expect(status.needsUpdate).toBe(false); }); + it('should detect configured status and version match for commands-only setup', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + }); + + // Command paths vary in shape across adapters: a nested directory with a + // per-tool extension (gemini writes TOML), a flat opsx-* file, and — for + // cline — a directory that is not the tool's skillsDir at all. + it.each([ + ['gemini', path.join('.gemini', 'commands', 'opsx', 'explore.toml')], + ['cursor', path.join('.cursor', 'commands', 'opsx-explore.md')], + ['cline', path.join('.clinerules', 'workflows', 'opsx-explore.md')], + ])('should fingerprint commands-only %s installs', async (toolId, explorePath) => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: toolId, force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const coreWorkflows = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; + + // cline's commands live outside its skillsDir (.cline), so a commands-only + // install leaves that directory absent entirely. + expect(getConfiguredTools(testDir)).toContain(toolId); + + const fresh = getToolVersionStatus(testDir, toolId, version, { workflows: coreWorkflows }); + expect(fresh.configured).toBe(true); + expect(fresh.generatedByVersion).toBe(version); + expect(fresh.needsUpdate).toBe(false); + + await fs.writeFile(path.join(testDir, explorePath), 'stale content'); + + const drifted = getToolVersionStatus(testDir, toolId, version, { workflows: coreWorkflows }); + expect(drifted.generatedByVersion).toBeNull(); + expect(drifted.needsUpdate).toBe(true); + }); + + it('should fingerprint a custom profile against its own workflow subset', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + const customWorkflows = ['explore', 'apply']; + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: customWorkflows, + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: customWorkflows, + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + + // The core set is a superset of this profile, so comparing against it must + // report drift — the fingerprint has to use the workflows actually selected. + const againstCore = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + expect(againstCore.needsUpdate).toBe(true); + }); + + it('should treat CRLF line endings and a BOM as up to date, not as drift', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // A Windows clone with core.autocrlf re-materializes committed command + // files with CRLF endings; that is a checkout artifact, not content drift. + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + for (const entry of await fs.readdir(commandsDir)) { + const file = path.join(commandsDir, entry); + const content = await fs.readFile(file, 'utf-8'); + await fs.writeFile(file, '\ufeff' + content.replace(/\r?\n/g, '\r\n')); + } + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + }); + + it('should detect needsUpdate when a deselected workflow left a command file behind', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // A workflow that is no longer selected still has a command file on disk + const strayFile = path.join(testDir, '.claude', 'commands', 'opsx', 'verify.md'); + await fs.writeFile(strayFile, 'stray command from a previous profile'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + + it('should not let matching command files mask an unreadable skill version', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Corrupt a skill file so its generatedBy version can no longer be read, + // while every command file still matches the current generated content. + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.writeFile(skillFile, 'truncated skill file'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + + it('should detect needsUpdate when command file content differs in commands-only setup', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Modify one command file + const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + await fs.writeFile(cmdFile, 'outdated content'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + it('should include tool name in status', async () => { const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore'); await fs.mkdir(skillDir, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 8c801b767b..5126565b97 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -387,6 +387,23 @@ Old instructions content } }); + it('should update command files when tool is configured via commands-only delivery without skills', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); + const coreCommandIds = ['explore', 'apply', 'update', 'sync', 'archive', 'propose']; + for (const cmdId of coreCommandIds) { + await fs.writeFile(path.join(commandsDir, `${cmdId}.md`), 'old command content'); + } + + await updateCommand.execute(testDir); + + for (const cmdId of coreCommandIds) { + const updatedContent = await fs.readFile(path.join(commandsDir, `${cmdId}.md`), 'utf-8'); + expect(updatedContent).not.toBe('old command content'); + expect(updatedContent).toContain('---'); + } + }); }); describe('multi-tool support', () => { @@ -1919,6 +1936,32 @@ More user content after markers. )).toBe(false); }); + it('should be a no-op on second update run for commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + // First run updates commands and removes skills + await updateCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + // Second run should report all tools up to date without updating + await updateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Updating 1 tool(s)'))).toBe(false); + + consoleSpy.mockRestore(); + }); + it.each(['both', 'skills', 'commands'] as const)( 'should refresh Codex skills and not create global prompts when delivery=%s', async (delivery) => { diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index 6dd40304bd..3c0cf43f77 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -1,5 +1,6 @@ import { type ChildProcess, spawn } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, promises as fs } from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -138,6 +139,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): const finalArgs = Array.isArray(args) ? args : [args]; const invocation = [cliEntry, ...finalArgs].join(' '); + const explicitConfigHome = options.env?.XDG_CONFIG_HOME; + const isolatedConfigHome = + explicitConfigHome !== undefined + ? undefined + : await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-cli-config-')); return new Promise((resolve, reject) => { const timeoutMs = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS; @@ -148,6 +154,7 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): { OPENSPEC_TELEMETRY: '0', OPEN_SPEC_INTERACTIVE: '0', + XDG_CONFIG_HOME: explicitConfigHome ?? isolatedConfigHome, }, options.env ), @@ -225,6 +232,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): } else if (child.stdin) { child.stdin.end(); } + }).finally(async () => { + if (isolatedConfigHome) { + // Never let cleanup replace the CLI result or a genuine CLI failure. + await fs.rm(isolatedConfigHome, { recursive: true, force: true }).catch(() => {}); + } }); }