Skip to content

fix(update): refresh command files for tools configured without skills - #1442

Merged
clay-good merged 11 commits into
Fission-AI:mainfrom
hsusul:fix/update-command-only-tools
Jul 28, 2026
Merged

fix(update): refresh command files for tools configured without skills#1442
clay-good merged 11 commits into
Fission-AI:mainfrom
hsusul:fix/update-command-only-tools

Conversation

@hsusul

@hsusul hsusul commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What was wrong

openspec update never refreshed a tool configured with command files but no skills — delivery commands, or any project whose skill files were removed. It reported ✓ All configured tools are up to date on every run, forever, so command files kept whatever content the CLI that first wrote them produced.

Skill files record the version that generated them (generatedBy in frontmatter); command files carry no such stamp. getToolVersionStatus() read only skill files, so a commands-only tool came back { configured: false, generatedByVersion: null, needsUpdate: false } and got filtered out of the update set.

How it was fixed

For a tool that has commands and no skills, compare the command file contents against what the CLI would generate right now. Content equality is the only "is this current?" signal available when there is no version stamp. Everything else — including any tool that has skills — keeps using the recorded version, unchanged.

Three things that approach gets wrong if done naively, each fixed here:

  • Gating on generatedByVersion === null also catches "a SKILL.md exists but its version is unreadable." That let a truncated or hand-edited skill file be masked by matching command files, so openspec update could never repair it again. Gated on !skillConfigured instead.
  • Byte-exact comparison treats CRLF and a UTF-8 BOM as drift. Command files are committed, so a Windows clone with core.autocrlf would spend one spurious update per checkout rewriting identical content and announcing a bogus unknown → 1.6.0. Both sides are normalized.
  • The workflow set used for the comparison must match the one the generation loop writes (legacyWorkflowOverrides[toolId] ?? desiredWorkflows), or a legacy-upgraded tool is fingerprinted against commands it was never given.

A command file left behind by a workflow you have since deselected now also counts as drift, and is removed.

Replication / proof

Reproduce the original bug on main:

mkdir repro && cd repro && git init
# global config: {"profile":"core","delivery":"commands"}
openspec init --tools claude
echo "stale" > .claude/commands/opsx/explore.md
openspec update          # main: "All configured tools are up to date" — file untouched

Fixed behavior, run end to end against the built CLI with an isolated XDG_CONFIG_HOME:

Step Output
init commands-only, then update ✓ All 1 tool(s) up to date
corrupt one command file, update Updating 1 tool(s): claude (unknown → 1.6.0), file regenerated
update again ✓ All 1 tool(s) up to date
add a stray verify.md, update Removed: 1 command files (deselected workflows)
update again ✓ All 1 tool(s) up to date
delivery: both, truncate a SKILL.md, update skill repaired; next run is a no-op

Three new regressions, each verified to fail when its own fix is reverted:

Test Guards
should not let matching command files mask an unreadable skill version a corrupt SKILL.md still forces a rewrite
should treat CRLF line endings and a BOM as up to date, not as drift no spurious update on a Windows checkout
should detect needsUpdate when a deselected workflow left a command file behind stray command files are removed

npm run build, npx tsc --noEmit, npm run lint, and npx vitest run (114 files, 3309 tests) pass. CI green on linux-bash, macos-bash, and windows-pwsh.

Notes

  • Side fix: widening tool detection to include command-configured tools makes cline commands-only projects work at all. Its skillsDir is .cline but commands go to .clinerules/workflows/, so the old directory guard made those projects report No configured tools found.
  • Scope limit worth knowing: with delivery skills or both — the default — only the recorded version is checked, so a hand-edited generated file whose version still matches is left alone; --force rewrites it. Content checking applies to commands-only installs. docs/cli.md now says this under openspec update.
  • Test isolation: runCLI() gives every invocation its own config home, and the tool-detection tests stub XDG_CONFIG_HOME, so no test can write the developer's real ~/.config/openspec/config.json.
  • Merged main after feat(update): offer to upgrade a stale CLI during openspec update #1470 landed; the only conflict was the openspec update doc section, and both sides are independent additions. No source file overlapped.

AI-assistance disclosure

Original fix developed with pair-programming assistance from the Antigravity AI coding assistant. Review rounds and the follow-up fixes above were done with Claude Code.

@hsusul
hsusul requested a review from TabishB as a code owner July 26, 2026 00:57
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The update pipeline now treats generated command files as configured tool artifacts, validates workflow-specific contents, and passes workflow context through status calculations. Tests and documentation cover command-only refreshes, stale command detection, repeated no-op updates, and isolated CLI configuration.

Changes

Command-aware status and update behavior

Layer / File(s) Summary
Command status detection and shared exports
src/core/shared/tool-detection.ts, src/core/shared/index.ts
Command files are detected alongside skills, validated against selected workflows, and included in tool version status and configured-tool selection.
Update execution and drift integration
src/core/update.ts, src/core/profile-sync-drift.ts
Update status calculations receive workflow context, while profile drift uses shared command detection and returns skill-configured tools.
Command-only validation and isolated test configuration
test/core/shared/tool-detection.test.ts, test/core/update.test.ts, test/helpers/run-cli.ts, docs/cli.md, .changeset/update-detects-command-only-tools.md
Tests, documentation, and release notes cover command-only configuration, stale files, command refreshes without skills, repeated no-op updates, and isolated CLI configuration directories.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UpdateCommand
  participant getToolVersionStatus
  participant areCommandFilesUpToDate
  UpdateCommand->>getToolVersionStatus: pass selected workflows
  getToolVersionStatus->>areCommandFilesUpToDate: validate generated command files
  areCommandFilesUpToDate-->>getToolVersionStatus: return freshness state
  getToolVersionStatus-->>UpdateCommand: return tool status
Loading

Possibly related PRs

Suggested reviewers: tabishb, clay-good

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main fix: refreshing command files for commands-only tools without skills.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/core/update.test.ts (1)

390-403: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert every core command is refreshed.

The test creates six command files but checks only explore.md, so it can pass while the other five remain stale. Iterate over coreCommandIds and verify each generated file changed and contains frontmatter.

Proposed test adjustment
-      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('---');
+      }

Run with pnpm exec vitest run test/core/update.test.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/update.test.ts` around lines 390 - 403, Update the test case around
updateCommand.execute to iterate over every ID in coreCommandIds, read each
corresponding command file, and assert its content is no longer the old content
and contains frontmatter. Keep the existing commands-only configuration and
setup unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/core/update.test.ts`:
- Around line 390-403: Update the test case around updateCommand.execute to
iterate over every ID in coreCommandIds, read each corresponding command file,
and assert its content is no longer the old content and contains frontmatter.
Keep the existing commands-only configuration and setup unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 953a918a-8314-4d90-9479-6ceb5244a240

📥 Commits

Reviewing files that changed from the base of the PR and between 19d4171 and 5c37249.

📒 Files selected for processing (2)
  • src/core/update.ts
  • test/core/update.test.ts

@clay-good
clay-good requested a review from a team as a code owner July 27, 2026 17:30
@clay-good

Copy link
Copy Markdown
Collaborator

Code review — verdict: good to merge

I verified this end-to-end rather than taking the description at its word. Everything checks out.

1. Real need — yes, and I reproduced the bug

The described root cause is accurate. getToolVersionStatus() in src/core/shared/tool-detection.ts only ever looks at <skillsDir>/skills/<name>/SKILL.md, and derives needsUpdate = configured && ... where configured comes from the skill-only getToolSkillStatus(). So a commands-only project returns {configured: false, generatedByVersion: null, needsUpdate: false}, and update.ts patched configured without recomputing needsUpdate.

Reproduced against main with a real project (delivery: "commands", .claude/commands/opsx/ only, no skills dir), after corrupting one command file:

$ printf 'STALE\n' > .claude/commands/opsx/apply.md
$ openspec update
✓ All 1 tool(s) up to date (v1.6.0)
  Tools: claude

$ head -2 .claude/commands/opsx/apply.md
STALE

That's a genuine user-facing bug, and it's worse than "skips an update" — it reports up to date (v1.6.0) when no version was ever read and the files are stale. Users on commands-only delivery could never refresh their command files without --force.

With this PR applied, the same project correctly updates the file.

2. Does the fix work — yes, and the test is a real regression test

This is the part I most wanted to confirm, since a test that passes both with and without the fix proves nothing. It's a true regression test:

  • PR branch as-is: test/core/update.test.ts76 passed.
  • PR branch with only src/core/update.ts reverted to main: → 1 failed / 75 passed, failing precisely on the new case:
    FAIL  should update command files when tool is configured via commands-only delivery without skills
    AssertionError: expected 'old command content' not to be 'old command content'
    

Full suite on the PR branch (rebased on c33fcb3): 112 files / 2254 tests, all passing.

3. Breaking changes — none

The new branch is guarded by !status.configured && commandConfiguredSet.has(toolId), so it only fires for tools that have command files but no skill files. For the default delivery: "both" and for delivery: "skills", skills exist, configured is already true, the branch isn't taken, and behaviour is bit-for-bit unchanged. Confirmed by the full suite passing with zero pre-existing tests modified — the diff adds a test, it doesn't adjust any existing expectations, which is exactly what you want to see on a fix like this.

4. Scope — correctly surgical

Four lines in one .map() callback, plus one test. No new abstractions, no config surface, no adapter or template changes. This is the right shape.


One follow-up worth knowing about (not a blocker)

Command files carry no version stamp. I checked the generated frontmatter:

---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---

No generatedBy, unlike SKILL.md. So for a commands-only project generatedByVersion is permanently null, and this fix makes needsUpdate permanently true. Verified — the same project reports the same thing on every consecutive run:

--- update run 1:  Updating 1 tool(s): claude (unknown → 1.6.0)
--- update run 2:  Updating 1 tool(s): claude (unknown → 1.6.0)

So commands-only users will never see "up to date"; openspec update rewrites their command files (with identical content) every time and always displays unknown → 1.6.0.

To be clear, I don't think this should block the merge. Always-refreshing is strictly better than never-refreshing, the rewrite is content-idempotent, and given no version is recorded anywhere there's no way for this function to do better. But it does mean the fix trades one wrong report ("up to date" when stale) for a milder one ("needs update" when current).

The clean follow-up is to stamp generatedBy into command-file frontmatter the way SKILL.md does, and teach getToolVersionStatus() to fall back to reading it from a command file when no skill file exists. That would make the status accurate in both directions and let this branch compute a real version delta. Worth a separate issue — it touches the adapters, so it doesn't belong in this PR.

Nit (optional)

status.generatedByVersion === null || status.generatedByVersion !== OPENSPEC_VERSION — the first clause is subsumed by the second (null !== "1.6.0" is already true). It could just be status.generatedByVersion !== OPENSPEC_VERSION. That said, it mirrors the existing expression on tool-detection.ts:191 verbatim, so keeping it parallel is a defensible call. No change required.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commands-only installs have no persisted generatedBy version, so this makes every later update rewrite the commands and report unknown to current again; the affected code is unchanged from the reproduced two-run hold. Please persist or compare a version/content fingerprint and add a second-run no-op regression.

@hsusul

hsusul commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

I've updated the PR to fix the issue where commands-only installations re-triggered command file generation on every subsequent update.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/core/shared/tool-detection.ts (2)

116-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate COMMAND_IDS existence-scan logic.

toolHasAnyConfiguredCommand (Lines 119-129) and the version-lookup loop in getToolVersionStatus (Lines 291-301) both iterate COMMAND_IDS, resolve the adapter path, and check fs.existsSync. Consider extracting a shared helper (e.g., returning the first existing command file path) that both call sites can reuse.

♻️ Proposed extraction
+function findFirstExistingCommandFilePath(
+  projectPath: string,
+  adapter: ReturnType<typeof CommandAdapterRegistry.get>
+): string | null {
+  if (!adapter) return null;
+  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 fullPath;
+  }
+  return null;
+}

Also applies to: 291-301

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/shared/tool-detection.ts` around lines 116 - 132, Extract the
duplicated COMMAND_IDS path-resolution and fs.existsSync scan into a shared
helper that returns the first existing command file path, using the adapter and
projectPath inputs. Update toolHasAnyConfiguredCommand and the version-lookup
loop in getToolVersionStatus to reuse this helper while preserving their current
boolean and version-status behavior.

137-159: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Drop or use delivery in areCommandFilesUpToDate.

delivery is accepted on the options signatures and passed through callers, but areCommandFilesUpToDate only reads options.workflows; keeping dead plumbing makes the API invite future behavior that currently can’t differ by delivery type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/shared/tool-detection.ts` around lines 137 - 159, Remove the unused
delivery property from the options parameter of areCommandFilesUpToDate and
update its callers to stop passing delivery, or implement delivery-specific
behavior there; prefer removing the dead plumbing since the function currently
only selects workflows and does not vary by delivery.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/shared/tool-detection.ts`:
- Around line 283-310: Update the getToolVersionStatus call in the generation
loop to pass legacyWorkflowOverrides[tool.value] ?? desiredWorkflows, matching
the workflow set used for generation. Ensure tools with legacy overrides
evaluate command fingerprints and needsUpdate status against their tool-specific
workflows.

In `@test/helpers/run-cli.ts`:
- Line 151: The default XDG_CONFIG_HOME in runCLI() must be isolated for each
invocation. Replace the shared test/fixtures/.tmp-config fallback with a unique
temporary directory created per call, while preserving
options.env?.XDG_CONFIG_HOME when explicitly provided.

---

Nitpick comments:
In `@src/core/shared/tool-detection.ts`:
- Around line 116-132: Extract the duplicated COMMAND_IDS path-resolution and
fs.existsSync scan into a shared helper that returns the first existing command
file path, using the adapter and projectPath inputs. Update
toolHasAnyConfiguredCommand and the version-lookup loop in getToolVersionStatus
to reuse this helper while preserving their current boolean and version-status
behavior.
- Around line 137-159: Remove the unused delivery property from the options
parameter of areCommandFilesUpToDate and update its callers to stop passing
delivery, or implement delivery-specific behavior there; prefer removing the
dead plumbing since the function currently only selects workflows and does not
vary by delivery.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12668755-8c21-45a7-814a-e165303009ac

📥 Commits

Reviewing files that changed from the base of the PR and between 5c37249 and 1f51ae5.

📒 Files selected for processing (7)
  • src/core/profile-sync-drift.ts
  • src/core/shared/index.ts
  • src/core/shared/tool-detection.ts
  • src/core/update.ts
  • test/core/shared/tool-detection.test.ts
  • test/core/update.test.ts
  • test/helpers/run-cli.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/update.ts

Comment thread src/core/shared/tool-detection.ts
Comment thread test/helpers/run-cli.ts Outdated

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repeated-regeneration bug is fixed at 1f51ae5, including the two-run no-op. One blocker remains: the new tool-detection tests call saveGlobalConfig without isolating XDG_CONFIG_HOME, so the focused test run overwrote the real ~/.config/openspec/config.json; the new runCLI fallback also shares one repo-wide .tmp-config across parallel invocations. Please give each test/temp project an isolated config home (while preserving explicit overrides), and expand the first-run regression to assert all six command files.

@hsusul

hsusul commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both blockers: tool-detection tests now isolate XDG_CONFIG_HOME within each test project, and runCLI() creates and cleans a unique config home per invocation while preserving explicit overrides. The first-run regression now checks all six core command files. Full suite passes: 112 files, 2257 tests; build, lint, typecheck, and diff checks also pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/helpers/run-cli.ts`:
- Around line 233-236: Update the cleanup logic in runCLI’s finally callback so
failures from fs.rm are caught and reported without being rethrown. Preserve the
original CLI result or error regardless of cleanup outcome, while retaining
conditional removal of isolatedConfigHome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99227021-e5a8-4f26-bf17-ea2ffda2dbdc

📥 Commits

Reviewing files that changed from the base of the PR and between 1f51ae5 and 7a9a88f.

📒 Files selected for processing (3)
  • test/core/shared/tool-detection.test.ts
  • test/core/update.test.ts
  • test/helpers/run-cli.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/core/shared/tool-detection.test.ts

Comment thread test/helpers/run-cli.ts
Comment on lines +233 to +236
}).finally(async () => {
if (isolatedConfigHome) {
await fs.rm(isolatedConfigHome, { recursive: true, force: true });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Prevent cleanup failures from masking the CLI result.

If fs.rm(...) rejects, .finally() replaces a successful result—or the original CLI error—with the cleanup failure. Catch and report cleanup errors without rejecting the main runCLI operation.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcess, spawn } from 'child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/helpers/run-cli.ts` around lines 233 - 236, Update the cleanup logic in
runCLI’s finally callback so failures from fs.rm are caught and reported without
being rethrown. Preserve the original CLI result or error regardless of cleanup
outcome, while retaining conditional removal of isolatedConfigHome.

@clay-good
clay-good self-requested a review July 28, 2026 14:54
clay-good and others added 2 commits July 28, 2026 10:14
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) <noreply@anthropic.com>
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 → <version>".
  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) <noreply@anthropic.com>

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 63a485a. The commands-only fingerprint now settles after the first rewrite, preserves skill-version drift, isolates every test config home, handles CRLF and BOM without false drift, and checks all six core command files. Build, 105 focused tests, and all CI checks pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/core/shared/tool-detection.test.ts`:
- Around line 289-296: Update the command-file content transformation in the
test setup loop to normalize existing CRLF and LF line endings before converting
them to CRLF, avoiding duplicated carriage returns on Windows. Preserve the BOM
prefix and the existing file iteration and write behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ce8298f4-2f2c-432b-b714-03368ca8fad7

📥 Commits

Reviewing files that changed from the base of the PR and between d699f30 and 63a485a.

📒 Files selected for processing (6)
  • .changeset/update-detects-command-only-tools.md
  • docs/cli.md
  • src/core/profile-sync-drift.ts
  • src/core/shared/tool-detection.ts
  • src/core/update.ts
  • test/core/shared/tool-detection.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • docs/cli.md
  • src/core/update.ts
  • src/core/shared/tool-detection.ts

Comment thread test/core/shared/tool-detection.test.ts
`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) <noreply@anthropic.com>
@clay-good clay-good changed the title fix(update): mark command-configured tools as needing update when skill version is missing fix(update): refresh command files for tools configured without skills Jul 28, 2026
@clay-good

Copy link
Copy Markdown
Collaborator

Review round: all open comments addressed

Three adversarial review passes over the branch, plus end-to-end runs of the built CLI. Pushed as d699f30, 63a485a, e85402f.

Note: the PR body's Root Cause and Implementation sections describe the original approach (recomputing needsUpdate for command-configured tools). That was superseded by the content-fingerprint approach after @alfred-openspec's second-run review. Everything below describes what actually ships.

What ships

Skill files record the version that generated them; command files carry no such stamp. So for a tool that has commands and no skills, openspec update compares the command file contents against what it would generate now. Anything else — including a tool with skills — keeps using the recorded version.

Blockers fixed this round

generatedByVersion === null conflated two different states. It meant both "no skill files exist" and "a skill file exists but its version is unreadable." The fingerprint fallback fired on both, so a truncated or hand-edited SKILL.md could never be repaired by openspec update again — it reported ✓ All 1 tool(s) up to date forever. Now gated on !skillConfigured, with a regression test that fails without the gate.

CRLF/BOM broke the fingerprint on Windows. Command files are committed project files. A clone with core.autocrlf re-materializes them with CRLF, which the byte-exact compare read as drift — every fresh checkout spent one openspec update rewriting identical content and announcing a bogus unknown → 1.6.0. Both sides are now normalized for CRLF and a leading BOM.

legacyWorkflowOverrides (CodeRabbit). Real: status was computed from desiredWorkflows while the generation loop writes legacyWorkflowOverrides[tool.value] ?? desiredWorkflows. Unreachable today (only codex gets an override, and codex has no command adapter), but fixed rather than commented.

delivery option (CodeRabbit). Removed — it was accepted by three signatures and never read.

runCLI cleanup (CodeRabbit). fs.rm in the .finally could reject and replace the CLI result or a genuine failure. Now .catch(() => {}). An explicitly-empty XDG_CONFIG_HOME is also treated as an override rather than falling back to the real one.

Also cleaned up

  • Deleted the command-file generatedBy scan: no adapter emits that stamp, so the loop was unreachable and made the fingerprint read as a secondary path rather than the only one.
  • getCommandConfiguredTools became a strict subset of the widened getConfiguredTools; collapsed into its one caller. Side effect: cline commands-only projects now work at all.cline/ is never created under delivery: commands, so the old statSync guard made those projects report No configured tools found.
  • Dropped exports, an unused options parameter, and stale ToolVersionStatus doc comments that still said "skills".
  • Added .changeset/update-detects-command-only-tools.md (this repo requires one per fix).

Proof

New regressions, each verified to fail when its fix is reverted:

Test Guards
should not let matching command files mask an unreadable skill version corrupt SKILL.md still forces a rewrite
should treat CRLF line endings and a BOM as up to date, not as drift no spurious update on a Windows checkout
should detect needsUpdate when a deselected workflow left a command file behind stray command files get removed

End-to-end against the built CLI, isolated XDG_CONFIG_HOME: init commands-only → up to date; corrupt one command file → refreshed; run again → no-op; add a stray verify.mdRemoved: 1 command files (deselected workflows); run again → no-op. Under delivery: both, a truncated SKILL.md is repaired and the next run is a no-op.

npm run build, npx tsc --noEmit, npm run lint, and npx vitest run (113 files, 3264 tests) all pass.

One thing to know before merging

For delivery: skills or both — the default — a hand-edited generated file whose recorded version still matches is not detected as drift and is left alone; --force rewrites it. Content checking applies only to commands-only installs. docs/cli.md now says this explicitly under openspec update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good clay-good closed this Jul 28, 2026
@clay-good clay-good reopened this Jul 28, 2026
@clay-good

Copy link
Copy Markdown
Collaborator

Merged main (#1470 landed and touched the same openspec update doc section). The conflict was in docs/cli.md only and both sides were independent additions — #1470's stale-CLI upgrade section is preserved verbatim, with the "How up to date is decided" paragraph appended after it. No source file overlapped: git log HEAD..origin/main touches none of update.ts, tool-detection.ts, profile-sync-drift.ts, or the test files this PR changes.

Also applied CodeRabbit's last nit (/\r?\n/g in the CRLF fixture, so the test stays idempotent on a CRLF checkout).

Green on all three OSes at d8adf4d: linux-bash, macos-bash, windows-pwsh, plus lint/typecheck, audit, dependency review, and release tracking. 114 files / 3309 tests. Only a maintainer approval is outstanding.

@clay-good

Copy link
Copy Markdown
Collaborator

Rewrote the PR description: the original Root Cause / Implementation sections still described the first approach (recomputing needsUpdate), which was superseded by the content fingerprint after the second-run review. The body now matches what ships, so the release notes and any reader of this PR see the same thing.

Also verified the post-merge state, which no earlier review round had seen (#1470 landed inside the same openspec update command):

  • No interaction defect. feat(update): offer to upgrade a stale CLI during openspec update #1470's upgrade path re-runs the update in a spawned process and returns — UpdateCommand.execute never runs twice in one invocation, so there is no shared state with the fingerprint. The registry call is awaited strictly before any UpdateCommand is constructed, so no reordering or output interleaving. Verified against a fake global install and a local registry: the update block prints first, the upgrade note last, and OPENSPEC_NO_UPDATE_CHECK / CI / NODE_ENV=test each suppress only the note while drift detection keeps working.
  • No semantic merge damage. Checked with git patch-id --stable against both parents: src, test, and .changeset are an exact union in both directions, and docs differs only in the resolved cli.md context lines. Test-name sets from both parents are fully present in the merge — 2216 + 45 = 2254 + 7 = 2261 — so no it() block was dropped by the conflict resolution.
  • Changesets don't collide. update-flags-stale-cli (minor) and update-detects-command-only-tools (patch) are separate files and read as complementary.

Gates at d8adf4d: build, tsc --noEmit, lint, and 114 files / 3309 tests all pass; CI green on linux-bash, macos-bash, and windows-pwsh. Ready for a maintainer look — I've deliberately left getConfiguredToolsForProfileSync as a thin wrapper over getConfiguredTools rather than inlining it, to keep this diff free of churn that would invalidate the green run for no user-visible benefit.

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) <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator

Closed the last two coverage gaps — both were previously covered by reasoning rather than by an executed test.

Non-claude adapters. The fingerprint regressions all ran against claude, whose commands sit in a nested opsx/ directory. Added a parametrized case over three other path shapes: gemini (nested dir, but TOML rather than markdown), cursor (flat opsx-*.md), and cline, whose commands live in .clinerules/workflows/not in its skillsDir (.cline) at all, so a commands-only install leaves that directory absent entirely. Each asserts detection, a clean fingerprint on a fresh install, and drift after an edit.

Custom profile. Every earlier test hard-coded the six core workflows, so nothing proved the fingerprint honors a narrower selection. The new case inits with ['explore', 'apply'] and asserts the same tree reads as drifted when compared against the wider core set.

Both are real guards, not decoration — each was verified by mutation:

Mutation Result
getConfiguredTools drops command detection all three adapter cases fail
areCommandFilesUpToDate ignores the caller's workflows and falls back to global config the custom-profile case fails

Gates at ffb1fc0: build, tsc --noEmit, lint, and 114 files / 3313 tests pass locally; CI green on linux-bash, macos-bash, and windows-pwsh, plus audit, dependency review, and release tracking. No unresolved review comments.

Ready for review.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head ffb1fc0 after the main rebase. The fingerprint path now has direct coverage across Gemini, Cursor, Cline, and custom workflow subsets, while the CRLF/BOM, skill-drift, config-isolation, and second-run no-op guarantees remain intact. Build, lint, 109 focused tests, and all hosted checks pass.

@clay-good
clay-good added this pull request to the merge queue Jul 28, 2026
Merged via the queue into Fission-AI:main with commit 10fa39b Jul 28, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants