fix: Remove unsupported engines field and add runtime version checking

Fixes Claude Code marketplace plugin installation failure (bd-183).

Problem: The plugin.json manifest included an engines field (borrowed from npm)
to specify minimum bd CLI version requirements. However, Claude Code's plugin
manifest schema doesn't recognize this field, causing validation errors when
installing via /plugin marketplace add.

Solution:
1. Remove the engines field from plugin.json
2. Add runtime version checking in the MCP server startup
3. Update documentation to reflect automatic version checking

Changes:
- .claude-plugin/plugin.json: Remove unsupported engines field
- integrations/beads-mcp/src/beads_mcp/bd_client.py:
  - Add BdVersionError exception class
  - Add _check_version() method to validate bd CLI >= 0.9.0
  - Use bd version command (not bd --version)
- integrations/beads-mcp/src/beads_mcp/tools.py:
  - Make _get_client() async to support version checking
  - Update all tool functions to await _get_client()
  - Add version check on first MCP server use
- .claude-plugin/commands/bd-version.md: Update to mention automatic checking
- PLUGIN.md: Document automatic version validation at startup

Benefits:
- Plugin installs successfully via Claude Code marketplace
- Clear error messages if bd CLI version is too old
- Version check happens once per MCP server lifetime (not per command)
- Users get actionable update instructions in error messages

Closes bd-183
This commit is contained in:
Steve Yegge
2025-10-14 15:54:50 -07:00
parent 29938f67c2
commit a4d816d1f4
6 changed files with 289 additions and 98 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,19 +4,23 @@ description: Check beads and plugin versions
Check the installed versions of beads components and verify compatibility. Check the installed versions of beads components and verify compatibility.
**Note:** The MCP server automatically checks bd CLI version >= 0.9.0 on startup. This command provides detailed version info and update instructions.
Use the beads MCP tools to: Use the beads MCP tools to:
1. Run `bd --version` via bash to get the CLI version 1. Run `bd version` via bash to get the CLI version
2. Check the plugin version from the environment 2. Check the plugin version (0.9.2)
3. Compare versions and report any mismatches 3. Compare versions and report any mismatches
Display: Display:
- bd CLI version (from `bd --version`) - bd CLI version (from `bd version`)
- Plugin version (0.9.0) - Plugin version (0.9.2)
- MCP server version (0.9.2)
- MCP server status (from `stats` tool or connection test) - MCP server status (from `stats` tool or connection test)
- Compatibility status (✓ compatible or ⚠️ update needed) - Compatibility status (✓ compatible or ⚠️ update needed)
If versions are mismatched, provide instructions: If versions are mismatched, provide instructions:
- Update bd CLI: `curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash` - Update bd CLI: `curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash`
- Update plugin: `/plugin update beads` - Update plugin: `/plugin update beads`
- Restart Claude Code after updating
Suggest checking for updates if the user is on an older version. Suggest checking for updates if the user is on an older version.

View File

@@ -16,9 +16,6 @@
"agent-memory", "agent-memory",
"mcp-server" "mcp-server"
], ],
"engines": {
"beads": ">=0.9.0"
},
"mcpServers": { "mcpServers": {
"beads": { "beads": {
"command": "uv", "command": "uv",

View File

@@ -256,7 +256,9 @@ go install github.com/steveyegge/beads/cmd/bd@latest
### 3. Version Compatibility ### 3. Version Compatibility
Check version compatibility: The MCP server **automatically checks** bd CLI version on startup and will fail with a clear error if your version is too old.
Check version compatibility manually:
```bash ```bash
/bd-version /bd-version
``` ```
@@ -277,8 +279,7 @@ This will show:
### Version Numbering ### Version Numbering
Beads follows semantic versioning. The plugin version tracks the bd CLI version: Beads follows semantic versioning. The plugin version tracks the bd CLI version:
- Plugin 0.9.2 requires bd CLI 0.9.2+ - Plugin 0.9.2 requires bd CLI >= 0.9.0 (checked automatically at startup)
- Plugin 0.9.x requires bd CLI 0.9.0+
- Major version bumps may introduce breaking changes - Major version bumps may introduce breaking changes
- Check CHANGELOG.md for release notes - Check CHANGELOG.md for release notes

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import re
from .config import load_config from .config import load_config
from .models import ( from .models import (
@@ -43,6 +44,12 @@ class BdCommandError(BdError):
self.returncode = returncode self.returncode = returncode
class BdVersionError(BdError):
"""Raised when bd version is incompatible with MCP server."""
pass
class BdClient: class BdClient:
"""Client for calling bd CLI commands and parsing JSON output.""" """Client for calling bd CLI commands and parsing JSON output."""
@@ -144,6 +151,57 @@ class BdClient:
stderr=stdout_str, stderr=stdout_str,
) from e ) from e
async def _check_version(self) -> None:
"""Check that bd CLI version meets minimum requirements.
Raises:
BdVersionError: If bd version is incompatible
BdNotFoundError: If bd command not found
"""
# Minimum required version
min_version = (0, 9, 0)
try:
process = await asyncio.create_subprocess_exec(
self.bd_path,
"version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
except FileNotFoundError as e:
raise BdNotFoundError(
f"bd command not found at '{self.bd_path}'. "
f"Install bd from: https://github.com/steveyegge/beads"
) from e
if process.returncode != 0:
raise BdCommandError(
f"bd version failed: {stderr.decode()}",
stderr=stderr.decode(),
returncode=process.returncode or 1,
)
# Parse version from output like "bd version 0.9.2"
version_output = stdout.decode().strip()
match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_output)
if not match:
raise BdVersionError(
f"Could not parse bd version from: {version_output}"
)
version = tuple(int(x) for x in match.groups())
if version < min_version:
min_ver_str = ".".join(str(x) for x in min_version)
cur_ver_str = ".".join(str(x) for x in version)
install_cmd = "curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/install.sh | bash"
raise BdVersionError(
f"bd version {cur_ver_str} is too old. "
f"This MCP server requires bd >= {min_ver_str}. "
f"Update with: {install_cmd}"
)
async def ready(self, params: ReadyWorkParams | None = None) -> list[Issue]: async def ready(self, params: ReadyWorkParams | None = None) -> list[Issue]:
"""Get ready work (issues with no blocking dependencies). """Get ready work (issues with no blocking dependencies).

View File

@@ -22,21 +22,33 @@ from .models import (
# Global client instance - initialized on first use # Global client instance - initialized on first use
_client: BdClient | None = None _client: BdClient | None = None
_version_checked: bool = False
# Default constants # Default constants
DEFAULT_ISSUE_TYPE: IssueType = "task" DEFAULT_ISSUE_TYPE: IssueType = "task"
DEFAULT_DEPENDENCY_TYPE: DependencyType = "blocks" DEFAULT_DEPENDENCY_TYPE: DependencyType = "blocks"
def _get_client() -> BdClient: async def _get_client() -> BdClient:
"""Get a BdClient instance, creating it on first use. """Get a BdClient instance, creating it on first use.
Performs version check on first initialization.
Returns: Returns:
Configured BdClient instance (config loaded automatically) Configured BdClient instance (config loaded automatically)
Raises:
BdError: If bd is not installed or version is incompatible
""" """
global _client global _client, _version_checked
if _client is None: if _client is None:
_client = BdClient() _client = BdClient()
# Check version once per server lifetime
if not _version_checked:
await _client._check_version()
_version_checked = True
return _client return _client
@@ -50,7 +62,7 @@ async def beads_ready_work(
Ready work = status is 'open' AND no blocking dependencies. Ready work = status is 'open' AND no blocking dependencies.
Perfect for agents to claim next work! Perfect for agents to claim next work!
""" """
client = _get_client() client = await _get_client()
params = ReadyWorkParams(limit=limit, priority=priority, assignee=assignee) params = ReadyWorkParams(limit=limit, priority=priority, assignee=assignee)
return await client.ready(params) return await client.ready(params)
@@ -67,7 +79,7 @@ async def beads_list_issues(
limit: Annotated[int, "Maximum number of issues to return (1-1000)"] = 50, limit: Annotated[int, "Maximum number of issues to return (1-1000)"] = 50,
) -> list[Issue]: ) -> list[Issue]:
"""List all issues with optional filters.""" """List all issues with optional filters."""
client = _get_client() client = await _get_client()
params = ListIssuesParams( params = ListIssuesParams(
status=status, status=status,
@@ -86,7 +98,7 @@ async def beads_show_issue(
Includes full description, dependencies, and dependents. Includes full description, dependencies, and dependents.
""" """
client = _get_client() client = await _get_client()
params = ShowIssueParams(issue_id=issue_id) params = ShowIssueParams(issue_id=issue_id)
return await client.show(params) return await client.show(params)
@@ -111,7 +123,7 @@ async def beads_create_issue(
Use this when you discover new work during your session. Use this when you discover new work during your session.
Link it back with beads_add_dependency using 'discovered-from' type. Link it back with beads_add_dependency using 'discovered-from' type.
""" """
client = _get_client() client = await _get_client()
params = CreateIssueParams( params = CreateIssueParams(
title=title, title=title,
description=description, description=description,
@@ -143,7 +155,7 @@ async def beads_update_issue(
Claim work by setting status to 'in_progress'. Claim work by setting status to 'in_progress'.
""" """
client = _get_client() client = await _get_client()
params = UpdateIssueParams( params = UpdateIssueParams(
issue_id=issue_id, issue_id=issue_id,
status=status, status=status,
@@ -166,7 +178,7 @@ async def beads_close_issue(
Mark work as done when you've finished implementing/fixing it. Mark work as done when you've finished implementing/fixing it.
""" """
client = _get_client() client = await _get_client()
params = CloseIssueParams(issue_id=issue_id, reason=reason) params = CloseIssueParams(issue_id=issue_id, reason=reason)
return await client.close(params) return await client.close(params)
@@ -189,7 +201,7 @@ async def beads_add_dependency(
Use 'discovered-from' when you find new work during your session. Use 'discovered-from' when you find new work during your session.
""" """
client = _get_client() client = await _get_client()
params = AddDependencyParams( params = AddDependencyParams(
from_id=from_id, from_id=from_id,
to_id=to_id, to_id=to_id,
@@ -207,7 +219,7 @@ async def beads_quickstart() -> str:
Read this first to understand how to use beads (bd) commands. Read this first to understand how to use beads (bd) commands.
""" """
client = _get_client() client = await _get_client()
return await client.quickstart() return await client.quickstart()
@@ -217,7 +229,7 @@ async def beads_stats() -> Stats:
Returns total issues, open, in_progress, closed, blocked, ready issues, Returns total issues, open, in_progress, closed, blocked, ready issues,
and average lead time in hours. and average lead time in hours.
""" """
client = _get_client() client = await _get_client()
return await client.stats() return await client.stats()
@@ -226,7 +238,7 @@ async def beads_blocked() -> list[BlockedIssue]:
Returns issues that have blocking dependencies, showing what blocks them. Returns issues that have blocking dependencies, showing what blocks them.
""" """
client = _get_client() client = await _get_client()
return await client.blocked() return await client.blocked()
@@ -239,6 +251,6 @@ async def beads_init(
Creates .beads/ directory and database file with optional custom prefix. Creates .beads/ directory and database file with optional custom prefix.
""" """
client = _get_client() client = await _get_client()
params = InitParams(prefix=prefix) params = InitParams(prefix=prefix)
return await client.init(params) return await client.init(params)