Files
2026-07-13 12:36:35 +08:00

273 lines
10 KiB
Python

"""Cursor adapter (thin manifests + curated rules).
Cursor 2.5 added a real plugin marketplace AND reads `.claude/skills/` + `.claude/agents/`
directly. So the adapter:
1. Emits `.cursor-plugin/plugin.json` per plugin (manifest only; no component refs — Cursor
auto-discovers under `.claude/`).
2. Emits `.cursor-plugin/marketplace.json` at root mirroring `.claude-plugin/marketplace.json`
with Cursor's required `owner` field and `source` (not `path`) per-entry.
3. Copies hand-curated `.cursor/rules/*.mdc` from `tools/adapters/cursor_rules/`.
Sources: research summary by `a461df376c2b92017` synthesized into the plan.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from tools.adapters.base import (
WORKTREE,
EmitResult,
HarnessAdapter,
PluginSource,
read_file,
)
# Cursor MDC frontmatter — ONLY these keys are real. agentRequested/mode/tags are folklore.
_ALLOWED_MDC_KEYS = {"description", "globs", "alwaysApply"}
_CURATED_RULES_DIR = Path(__file__).resolve().parent / "cursor_rules"
# Matches `Name <email@example.com>` (npm-style author strings).
_AUTHOR_STRING_RE = re.compile(r"^(?P<name>[^<]+?)(?:\s*<(?P<email>[^>]+)>)?\s*$")
def _normalize_author(author) -> dict | None:
"""Normalize plugin.json author to a {name, email} dict.
Accepts:
- dict — passes through with name/email defaults
- string in npm `Name <email>` form — parsed
- list/tuple of authors — first entry is used (Cursor's marketplace.json schema is
single-author; recursively normalize the first element)
- None / falsy — returns None
"""
if not author:
return None
if isinstance(author, dict):
return {
"name": author.get("name", ""),
"email": author.get("email", ""),
}
if isinstance(author, str):
m = _AUTHOR_STRING_RE.match(author.strip())
if not m:
return {"name": author.strip(), "email": ""}
return {
"name": m.group("name").strip(),
"email": (m.group("email") or "").strip(),
}
if isinstance(author, (list, tuple)) and author:
# Multi-author lists are legal in npm; Cursor wants a single author so pick the first.
return _normalize_author(author[0])
return None
def _read_marketplace_root(source_root: Path = WORKTREE) -> dict:
"""Read the source-of-truth Claude Code marketplace.json from `source_root`.
Defaults to the import-time WORKTREE for the common case (running in-repo). Callers
that operate against a different source tree (test sandboxes, out-of-tree builds)
can pass an explicit `source_root`.
"""
path = source_root / ".claude-plugin" / "marketplace.json"
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _validate_mdc_frontmatter(content: str, source: Path) -> list[str]:
"""Return list of validation errors. Empty list = valid.
Tracks YAML block-scalar continuations (`description: |` / `description: >`) so
indented body lines containing colons (e.g. `Use: when …`) don't get picked up as
phantom top-level keys.
"""
errors: list[str] = []
if not content.startswith("---"):
errors.append(f"{source}: missing frontmatter")
return errors
end = content.find("\n---", 3)
if end == -1:
errors.append(f"{source}: unterminated frontmatter")
return errors
block = content[3:end]
keys: set[str] = set()
in_block_scalar = False # inside `key: >` / `key: |` continuation
in_inline_list_block = False # inside `key:\n - …` continuation
for raw_line in block.splitlines():
# Continuation: indented OR empty → part of the prior key's value, skip key scan.
if (raw_line.startswith((" ", "\t")) or raw_line.strip() == "") and (
in_block_scalar or in_inline_list_block
):
continue
# Non-indented non-empty line: we've left any continuation block.
in_block_scalar = False
in_inline_list_block = False
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip()
keys.add(key)
if val in (">", ">-", "|", "|-"):
in_block_scalar = True
elif val == "": # could be the start of a block list (`tools:\n - x`)
in_inline_list_block = True
invalid = keys - _ALLOWED_MDC_KEYS
if invalid:
errors.append(
f"{source}: invalid MDC frontmatter keys {sorted(invalid)}; "
f"only {sorted(_ALLOWED_MDC_KEYS)} are supported by Cursor"
)
return errors
class CursorAdapter(HarnessAdapter):
harness_id = "cursor"
def emit_plugin(self, plugin: PluginSource) -> EmitResult:
"""Emit `.cursor-plugin/plugin.json` per plugin (manifest only).
Cursor auto-discovers components from `.claude/` directories, so we don't
re-emit agents/skills/commands.
"""
result = EmitResult()
manifest = self._build_plugin_manifest(plugin)
result.written.append(
self.write(
Path(".cursor-plugin") / "plugins" / f"{plugin.name}.json",
json.dumps(manifest, indent=2) + "\n",
)
)
return result
def emit_global(self, plugins: list[PluginSource]) -> EmitResult:
result = EmitResult()
# 1. Marketplace
marketplace = self._build_marketplace(plugins)
result.written.append(
self.write(
Path(".cursor-plugin") / "marketplace.json",
json.dumps(marketplace, indent=2) + "\n",
)
)
# 2. Top-level plugin.json (matches Cursor convention for single-plugin repos
# AND advertises the marketplace bundle)
if marketplace["plugins"]:
top_manifest = {
"name": marketplace["name"],
"displayName": marketplace["name"].replace("-", " ").title(),
"version": marketplace.get("metadata", {}).get("version", "0.0.0"),
"description": marketplace.get("metadata", {}).get("description", ""),
"author": {
"name": marketplace["owner"]["name"],
"email": marketplace["owner"].get("email", ""),
},
"homepage": marketplace["owner"].get("url", ""),
"license": "MIT",
}
result.written.append(
self.write(
Path(".cursor-plugin") / "plugin.json",
json.dumps(top_manifest, indent=2) + "\n",
)
)
# 3. Curated rules
rules_emitted = 0
if _CURATED_RULES_DIR.is_dir():
for mdc in sorted(_CURATED_RULES_DIR.glob("*.mdc")):
content = read_file(mdc)
errors = _validate_mdc_frontmatter(content, mdc)
if errors:
for err in errors:
result.warnings.append(err)
continue
rel = Path(".cursor") / "rules" / mdc.name
result.written.append(self.write(rel, content))
rules_emitted += 1
if rules_emitted == 0:
result.warnings.append(
"no curated rules emitted; add MDC files to tools/adapters/cursor_rules/"
)
return result
# ── Internals ──────────────────────────────────────────────────────────
def _build_plugin_manifest(self, plugin: PluginSource) -> dict:
"""Per-plugin .cursor-plugin/plugin.json.
Only `name` is required. We omit component arrays — Cursor auto-discovers
agents/skills/commands from `.claude/` paths.
"""
author = _normalize_author(plugin.author)
manifest: dict = {
"name": plugin.name,
"displayName": plugin.name.replace("-", " ").title(),
"version": plugin.version,
}
if plugin.description:
manifest["description"] = plugin.description
if author:
manifest["author"] = author
homepage = (plugin.plugin_json.get("homepage") or "").strip()
if homepage:
manifest["homepage"] = homepage
if plugin.plugin_json.get("license"):
manifest["license"] = plugin.plugin_json["license"]
return manifest
def _build_marketplace(self, plugins: list[PluginSource]) -> dict:
"""Root .cursor-plugin/marketplace.json — mirror of .claude-plugin/ shape with
Cursor-required tweaks (`owner` field, per-entry `source` not `path`).
Reads source marketplace from self.output_root if it has one, else from WORKTREE.
This lets out-of-tree builds (--output-root /tmp/foo) bake the right metadata.
"""
# Prefer the source marketplace next to our output_root if present; fall back to repo.
candidate = self.output_root / ".claude-plugin" / "marketplace.json"
source_root = self.output_root if candidate.is_file() else WORKTREE
root = _read_marketplace_root(source_root)
owner = root.get("owner")
if not isinstance(owner, dict):
# Defensive: if marketplace.json has a string or null owner, fall back to a
# placeholder to avoid TypeError on owner["name"] downstream.
owner = {"name": "Unknown", "email": ""}
entries = []
for p in plugins:
entry: dict = {
"name": p.name,
"source": f"./plugins/{p.name}",
"version": p.version,
}
if p.description:
entry["description"] = p.description
normalized = _normalize_author(p.author)
if normalized:
entry["author"] = normalized
if p.plugin_json.get("homepage"):
entry["homepage"] = p.plugin_json["homepage"]
if p.plugin_json.get("license"):
entry["license"] = p.plugin_json["license"]
if p.plugin_json.get("category"):
entry["category"] = p.plugin_json["category"]
entries.append(entry)
return {
"name": root.get("name", "claude-code-workflows"),
"owner": owner,
"metadata": root.get("metadata", {}),
"plugins": entries,
}