Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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)
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 } 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
137 changes: 129 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 @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand All @@ -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;
}

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

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

Expand Down
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