From 5c372496f4c9e24b59d30e2c602c0c3b11ad7d1f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 25 Jul 2026 19:57:41 -0500 Subject: [PATCH 1/8] fix(update): mark command-configured tools as needing update when skill version is missing --- src/core/update.ts | 6 +++++- test/core/update.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/core/update.ts b/src/core/update.ts index e983dd8383..e8086f9dfc 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -169,7 +169,11 @@ export class UpdateCommand { const toolStatuses = configuredTools.map((toolId) => { const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION); if (!status.configured && commandConfiguredSet.has(toolId)) { - return { ...status, configured: true }; + return { + ...status, + configured: true, + needsUpdate: status.generatedByVersion === null || status.generatedByVersion !== OPENSPEC_VERSION, + }; } return status; }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 8c801b767b..3672de4aeb 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -387,6 +387,21 @@ 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); + + const updatedContent = await fs.readFile(path.join(commandsDir, 'explore.md'), 'utf-8'); + expect(updatedContent).not.toBe('old command content'); + expect(updatedContent).toContain('---'); + }); }); describe('multi-tool support', () => { From 1f51ae5e7421dc803fb644128018bccf3132f3a2 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 27 Jul 2026 14:38:17 -0500 Subject: [PATCH 2/8] fix(update): compare command content fingerprint for commands-only tools when skill version is missing --- src/core/profile-sync-drift.ts | 21 +--- src/core/shared/index.ts | 2 + src/core/shared/tool-detection.ts | 144 ++++++++++++++++++++++-- src/core/update.ts | 20 +--- test/core/shared/tool-detection.test.ts | 42 +++++++ test/core/update.test.ts | 26 +++++ test/helpers/run-cli.ts | 1 + 7 files changed, 216 insertions(+), 40 deletions(-) diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index a876d6ce72..931e5e4492 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 { COMMAND_IDS, getConfiguredTools, toolHasAnyConfiguredCommand } from './shared/index.js'; import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, @@ -12,6 +12,8 @@ import { shouldRemoveSkillsForTool, } from './command-surface.js'; +export { toolHasAnyConfiguredCommand }; + type WorkflowId = (typeof ALL_WORKFLOWS)[number]; /** @@ -39,23 +41,6 @@ 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. diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696a..1ef9d88338 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -13,6 +13,8 @@ export { type ToolVersionStatus, getToolsWithSkillsDir, getToolSkillStatus, + toolHasAnyConfiguredCommand, + areCommandFilesUpToDate, getToolStates, extractGeneratedByVersion, getToolVersionStatus, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..5ab48d2f4a 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, type Delivery } from '../global-config.js'; +import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; /** * Names of skill directories created by openspec init. @@ -109,6 +113,91 @@ 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; +} + +/** + * Checks whether command files for a tool on disk match current generated command contents. + */ +export function areCommandFilesUpToDate( + projectRoot: string, + toolId: string, + options?: { + workflows?: readonly string[]; + delivery?: Delivery; + } +): 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 (existingContent !== 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 +246,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 available skill or command files or checking content fingerprint. */ export function getToolVersionStatus( projectRoot: string, toolId: string, - currentVersion: string + currentVersion: string, + options?: { + workflows?: readonly string[]; + delivery?: Delivery; + } ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) { @@ -178,7 +271,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 +280,34 @@ 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. If version is not found in skills, check command files + if (generatedByVersion === null && commandConfigured) { + const adapter = CommandAdapterRegistry.get(toolId); + if (adapter) { + for (const commandId of COMMAND_IDS) { + const cmdPath = adapter.getFilePath(commandId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath); + if (fs.existsSync(fullPath)) { + const version = extractGeneratedByVersion(fullPath); + if (version !== null) { + generatedByVersion = version; + break; + } + } + } + + if (generatedByVersion === null) { + if (areCommandFilesUpToDate(projectRoot, toolId, options)) { + generatedByVersion = currentVersion; + } + } + } + } + const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion); return { @@ -200,11 +320,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); } @@ -213,10 +336,15 @@ export function getConfiguredTools(projectRoot: string): string[] { */ export function getAllToolVersionStatus( projectRoot: string, - currentVersion: string + currentVersion: string, + options?: { + workflows?: readonly string[]; + delivery?: Delivery; + } ): ToolVersionStatus[] { const configuredTools = getConfiguredTools(projectRoot); return configuredTools.map((toolId) => - getToolVersionStatus(projectRoot, toolId, currentVersion) + getToolVersionStatus(projectRoot, toolId, currentVersion, options) ); } + diff --git a/src/core/update.ts b/src/core/update.ts index e8086f9dfc..e4925344cf 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -163,20 +163,12 @@ 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, - needsUpdate: status.generatedByVersion === null || status.generatedByVersion !== OPENSPEC_VERSION, - }; - } - return status; - }); + const toolStatuses = configuredTools.map((toolId) => + getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, { + workflows: desiredWorkflows, + delivery, + }) + ); 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..4e23fb5907 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -258,6 +258,48 @@ 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'], + delivery: 'commands', + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + }); + + 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'], + delivery: 'commands', + }); + + 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 3672de4aeb..63ebe099b7 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1934,6 +1934,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..e269049ad7 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -148,6 +148,7 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): { OPENSPEC_TELEMETRY: '0', OPEN_SPEC_INTERACTIVE: '0', + XDG_CONFIG_HOME: options.env?.XDG_CONFIG_HOME ?? path.join(projectRoot, 'test', 'fixtures', '.tmp-config'), }, options.env ), From 7a9a88fe03f128c458b7759621472c3938143de5 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 27 Jul 2026 19:00:01 -0500 Subject: [PATCH 3/8] test(update): isolate config homes in regressions --- test/core/shared/tool-detection.test.ts | 4 +++- test/core/update.test.ts | 8 +++++--- test/helpers/run-cli.ts | 12 ++++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 4e23fb5907..9f21efc0f2 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 }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 63ebe099b7..5126565b97 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -398,9 +398,11 @@ Old instructions content await updateCommand.execute(testDir); - const updatedContent = await fs.readFile(path.join(commandsDir, 'explore.md'), 'utf-8'); - expect(updatedContent).not.toBe('old command content'); - expect(updatedContent).toContain('---'); + 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('---'); + } }); }); diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index e269049ad7..8d1e50252c 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,9 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): const finalArgs = Array.isArray(args) ? args : [args]; const invocation = [cliEntry, ...finalArgs].join(' '); + const isolatedConfigHome = options.env?.XDG_CONFIG_HOME + ? 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,7 +152,7 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): { OPENSPEC_TELEMETRY: '0', OPEN_SPEC_INTERACTIVE: '0', - XDG_CONFIG_HOME: options.env?.XDG_CONFIG_HOME ?? path.join(projectRoot, 'test', 'fixtures', '.tmp-config'), + XDG_CONFIG_HOME: options.env?.XDG_CONFIG_HOME ?? isolatedConfigHome, }, options.env ), @@ -226,6 +230,10 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): } else if (child.stdin) { child.stdin.end(); } + }).finally(async () => { + if (isolatedConfigHome) { + await fs.rm(isolatedConfigHome, { recursive: true, force: true }); + } }); } From d699f30fad5c8ef06bf400db8c71891b8f477874 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:14:57 -0500 Subject: [PATCH 4/8] fix(update): keep skill drift detectable behind the command fingerprint Review follow-ups on the commands-only update fix: - Only fall back to the command-content fingerprint when a tool has no skill files at all. Gating on `generatedByVersion === null` also swallowed the case where a SKILL.md exists but its version is unreadable, so a truncated or hand-edited skill file could never be repaired by `openspec update` again. - Drop the command `generatedBy` scan: command adapters emit no version stamp, so the loop was unreachable and made the fingerprint fallback read as a secondary path rather than the only one. - Compute version status with the same workflow set the generation loop writes (`legacyWorkflowOverrides[toolId] ?? desiredWorkflows`), so a legacy-upgraded tool is not fingerprinted against commands it was never given. - Remove the unread `delivery` option from the three tool-detection signatures, the leftover `getCommandConfiguredTools` / `COMMAND_IDS` imports, and the unused `toolHasAnyConfiguredCommand` re-export. - runCLI: never let temp-dir cleanup replace the CLI result or a real failure, and treat an explicitly-empty XDG_CONFIG_HOME as an override. Adds regressions for the unreadable-skill case and for a deselected workflow leaving a command file behind, and documents how "up to date" is decided. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli.md | 8 +++++ src/core/profile-sync-drift.ts | 5 +-- src/core/shared/tool-detection.ts | 39 ++++++-------------- src/core/update.ts | 7 ++-- test/core/shared/tool-detection.test.ts | 47 +++++++++++++++++++++++-- test/helpers/run-cli.ts | 13 ++++--- 6 files changed, 77 insertions(+), 42 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c2172d900f..fdae579b54 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -177,6 +177,14 @@ npm update @fission-ai/openspec openspec update ``` +**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. Either way, generated files are OpenSpec's to own — local edits to them are +overwritten on the next update. Put your own instructions in project files +instead. + --- ## Stores (standalone OpenSpec repos) diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 931e5e4492..6174d958d9 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, toolHasAnyConfiguredCommand } from './shared/index.js'; +import { getConfiguredTools, toolHasAnyConfiguredCommand } from './shared/index.js'; import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, @@ -12,8 +12,6 @@ import { shouldRemoveSkillsForTool, } from './command-surface.js'; -export { toolHasAnyConfiguredCommand }; - type WorkflowId = (typeof ALL_WORKFLOWS)[number]; /** @@ -41,7 +39,6 @@ function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { ); } - /** * Returns tools with at least one generated command file on disk. */ diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 5ab48d2f4a..5e1ad56113 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -9,7 +9,7 @@ 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, type Delivery } from '../global-config.js'; +import { getGlobalConfig } from '../global-config.js'; import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; /** @@ -133,13 +133,15 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string) /** * 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[]; - delivery?: Delivery; } ): boolean { const adapter = CommandAdapterRegistry.get(toolId); @@ -246,7 +248,8 @@ export function extractGeneratedByVersion(skillFilePath: string): string | null } /** - * Gets version status for a tool by reading available skill or command files or checking content fingerprint. + * 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, @@ -254,7 +257,6 @@ export function getToolVersionStatus( currentVersion: string, options?: { workflows?: readonly string[]; - delivery?: Delivery; } ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -284,28 +286,11 @@ export function getToolVersionStatus( const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId); const configured = skillConfigured || commandConfigured; - // 2. If version is not found in skills, check command files - if (generatedByVersion === null && commandConfigured) { - const adapter = CommandAdapterRegistry.get(toolId); - if (adapter) { - for (const commandId of COMMAND_IDS) { - const cmdPath = adapter.getFilePath(commandId); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath); - if (fs.existsSync(fullPath)) { - const version = extractGeneratedByVersion(fullPath); - if (version !== null) { - generatedByVersion = version; - break; - } - } - } - - if (generatedByVersion === null) { - if (areCommandFilesUpToDate(projectRoot, toolId, options)) { - generatedByVersion = currentVersion; - } - } - } + // 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); @@ -339,7 +324,6 @@ export function getAllToolVersionStatus( currentVersion: string, options?: { workflows?: readonly string[]; - delivery?: Delivery; } ): ToolVersionStatus[] { const configuredTools = getConfiguredTools(projectRoot); @@ -347,4 +331,3 @@ export function getAllToolVersionStatus( getToolVersionStatus(projectRoot, toolId, currentVersion, options) ); } - diff --git a/src/core/update.ts b/src/core/update.ts index e4925344cf..23c3066568 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,10 +162,12 @@ export class UpdateCommand { return; } + // 6. Check version status for all configured tools. The workflow set must match + // the one the generation loop below writes, or a legacy-upgraded tool would be + // fingerprinted against the wrong commands and never settle as up to date. const toolStatuses = configuredTools.map((toolId) => getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, { - workflows: desiredWorkflows, - delivery, + workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows, }) ); const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const)); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 9f21efc0f2..264edd27d2 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -271,7 +271,6 @@ Content here const { version } = await import('../../../package.json'); const status = getToolVersionStatus(testDir, 'claude', version, { workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], - delivery: 'commands', }); expect(status.configured).toBe(true); @@ -279,6 +278,51 @@ Content here 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'); @@ -294,7 +338,6 @@ Content here const { version } = await import('../../../package.json'); const status = getToolVersionStatus(testDir, 'claude', version, { workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], - delivery: 'commands', }); expect(status.configured).toBe(true); diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index 8d1e50252c..3c0cf43f77 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -139,9 +139,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): const finalArgs = Array.isArray(args) ? args : [args]; const invocation = [cliEntry, ...finalArgs].join(' '); - const isolatedConfigHome = options.env?.XDG_CONFIG_HOME - ? undefined - : await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-cli-config-')); + 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; @@ -152,7 +154,7 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): { OPENSPEC_TELEMETRY: '0', OPEN_SPEC_INTERACTIVE: '0', - XDG_CONFIG_HOME: options.env?.XDG_CONFIG_HOME ?? isolatedConfigHome, + XDG_CONFIG_HOME: explicitConfigHome ?? isolatedConfigHome, }, options.env ), @@ -232,7 +234,8 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): } }).finally(async () => { if (isolatedConfigHome) { - await fs.rm(isolatedConfigHome, { recursive: true, force: true }); + // Never let cleanup replace the CLI result or a genuine CLI failure. + await fs.rm(isolatedConfigHome, { recursive: true, force: true }).catch(() => {}); } }); } From 63a485aa25ca48badc1955d5175f32e857eab02d Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:29:02 -0500 Subject: [PATCH 5/8] fix(update): ignore CRLF and BOM when fingerprinting command files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-two review follow-ups: - Command files are committed project files. A Windows clone with `core.autocrlf` re-materializes them with CRLF endings, which the byte-exact comparison read as drift: every fresh checkout spent one `openspec update` rewriting identical content and announcing a bogus "unknown → ". Normalize CRLF and a leading BOM on both sides before comparing. - Collapse `getCommandConfiguredTools`, which the widened `getConfiguredTools` made a strict subset of itself, into the single remaining caller. - Correct the new `openspec update` doc paragraph: content drift is only detected for commands-only installs, so it must not promise that hand edits are always overwritten. - Add the changeset this repo requires per fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../update-detects-command-only-tools.md | 5 ++++ docs/cli.md | 8 +++--- src/core/profile-sync-drift.ts | 22 +--------------- src/core/shared/tool-detection.ts | 11 +++++++- src/core/update.ts | 6 ++--- test/core/shared/tool-detection.test.ts | 26 +++++++++++++++++++ 6 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 .changeset/update-detects-command-only-tools.md 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 fdae579b54..7fb9adad1f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -181,9 +181,11 @@ openspec update 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. Either way, generated files are OpenSpec's to own — local edits to them are -overwritten on the next update. Put your own instructions in project files -instead. +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. --- diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 6174d958d9..0ca0ede309 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -39,31 +39,11 @@ function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { ); } -/** - * 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 5e1ad56113..1e73298529 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -131,6 +131,15 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string) 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. * @@ -178,7 +187,7 @@ export function areCommandFilesUpToDate( } try { const existingContent = fs.readFileSync(cmdPath, 'utf-8'); - if (existingContent !== cmd.fileContent) { + if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) { return false; } } catch { diff --git a/src/core/update.ts b/src/core/update.ts index 23c3066568..fb1f089112 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -162,9 +162,9 @@ export class UpdateCommand { return; } - // 6. Check version status for all configured tools. The workflow set must match - // the one the generation loop below writes, or a legacy-upgraded tool would be - // fingerprinted against the wrong commands and never settle as up to date. + // 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, diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 264edd27d2..ff38b534a6 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -278,6 +278,32 @@ Content here expect(status.needsUpdate).toBe(false); }); + 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(/\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'); From e85402f31b65abed32ba0dff9f2abaaa144450d3 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:39:58 -0500 Subject: [PATCH 6/8] chore(update): drop dead exports and correct stale status doc comments `ToolVersionStatus.configured` and `.generatedByVersion` are now fed by command files too, so their comments no longer say "skills". Removes the barrel exports and the `options` parameter this change added but nothing consumes, and the import left dangling by the previous commit. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/profile-sync-drift.ts | 2 +- src/core/shared/index.ts | 2 -- src/core/shared/tool-detection.ts | 15 ++++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 0ca0ede309..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 { getConfiguredTools, toolHasAnyConfiguredCommand } from './shared/index.js'; +import { getConfiguredTools } from './shared/index.js'; import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 1ef9d88338..32b965696a 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -13,8 +13,6 @@ export { type ToolVersionStatus, getToolsWithSkillsDir, getToolSkillStatus, - toolHasAnyConfiguredCommand, - areCommandFilesUpToDate, getToolStates, extractGeneratedByVersion, getToolVersionStatus, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 1e73298529..b6efdc3790 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -72,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; @@ -330,13 +334,10 @@ export function getConfiguredTools(projectRoot: string): string[] { */ export function getAllToolVersionStatus( projectRoot: string, - currentVersion: string, - options?: { - workflows?: readonly string[]; - } + currentVersion: string ): ToolVersionStatus[] { const configuredTools = getConfiguredTools(projectRoot); return configuredTools.map((toolId) => - getToolVersionStatus(projectRoot, toolId, currentVersion, options) + getToolVersionStatus(projectRoot, toolId, currentVersion) ); } From 3bc15a1919b58dd6fd9a76a0539ac47d7cd048dd Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:46:03 -0500 Subject: [PATCH 7/8] test(update): make the CRLF fixture idempotent on a CRLF checkout Co-Authored-By: Claude Opus 5 (1M context) --- test/core/shared/tool-detection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index ff38b534a6..39226765f4 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -292,7 +292,7 @@ Content here 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(/\n/g, '\r\n')); + await fs.writeFile(file, '\ufeff' + content.replace(/\r?\n/g, '\r\n')); } const { version } = await import('../../../package.json'); From ffb1fc06b717168e01777b7cff039105c171dd22 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 11:57:57 -0500 Subject: [PATCH 8/8] test(update): cover non-claude adapters and a custom profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fingerprint regressions all ran against claude and the core profile, so two things were covered by reasoning rather than by an executed test: - Command paths differ in shape per adapter. Added a parametrized case over gemini (nested dir, TOML), cursor (flat opsx-* file), and cline, whose commands live in .clinerules/workflows — not in its skillsDir (.cline) at all, so a commands-only install leaves that directory absent. Each asserts detection, a clean fingerprint, and drift. Reverting the getConfiguredTools widening fails all three. - A custom profile must be fingerprinted against its own workflow subset. The new case inits with ['explore', 'apply'] and asserts the same tree reads as drifted when compared against the wider core set. Making the fingerprint ignore the caller's workflows and fall back to global config fails it. Co-Authored-By: Claude Opus 5 (1M context) --- test/core/shared/tool-detection.test.ts | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 39226765f4..0d5e74febd 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -278,6 +278,71 @@ Content here 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');