Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 3 additions & 18 deletions src/core/profile-sync-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ 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,
shouldReconcileCommandFilesForTool,
shouldRemoveSkillsForTool,
} from './command-surface.js';

export { toolHasAnyConfiguredCommand };

type WorkflowId = (typeof ALL_WORKFLOWS)[number];

/**
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/core/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export {
type ToolVersionStatus,
getToolsWithSkillsDir,
getToolSkillStatus,
toolHasAnyConfiguredCommand,
areCommandFilesUpToDate,
getToolStates,
extractGeneratedByVersion,
getToolVersionStatus,
Expand Down
144 changes: 136 additions & 8 deletions src/core/shared/tool-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand All @@ -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;
}
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);

return {
Expand All @@ -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);
}

Expand All @@ -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)
);
}

16 changes: 6 additions & 10 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +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 };
}
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
Expand Down
46 changes: 45 additions & 1 deletion test/core/shared/tool-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 });
});

Expand Down Expand Up @@ -258,6 +260,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 });
Expand Down
43 changes: 43 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading