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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/update-detects-command-only-tools.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ 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 — 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)
Expand Down
42 changes: 2 additions & 40 deletions src/core/profile-sync-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, toolHasAnyConfiguredCommand } from './shared/index.js';
import {
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
Expand Down Expand Up @@ -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);
}

/**
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
136 changes: 128 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 } 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,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.
*/
Expand Down Expand Up @@ -157,12 +257,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) {
Expand All @@ -178,7 +282,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 +291,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;
}

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

return {
Expand All @@ -200,11 +314,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 +330,13 @@ export function getConfiguredTools(projectRoot: string): string[] {
*/
export function getAllToolVersionStatus(
projectRoot: string,
currentVersion: string
currentVersion: string,
options?: {
workflows?: readonly string[];
}
): ToolVersionStatus[] {
const configuredTools = getConfiguredTools(projectRoot);
return configuredTools.map((toolId) =>
getToolVersionStatus(projectRoot, toolId, currentVersion)
getToolVersionStatus(projectRoot, toolId, currentVersion, options)
);
}
19 changes: 8 additions & 11 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading