From b5dea1ae754d1406764bb01ebcc4d59a299f9a1f Mon Sep 17 00:00:00 2001 From: Sanjith Sambath Date: Mon, 5 Oct 2026 15:48:13 -0400 Subject: [PATCH] Canonicalize cross-client plugin releases --- .github/workflows/cursor-marketplace.yml | 24 ++++ .github/workflows/marketplace.yml | 32 +++++ .github/workflows/validate.yml | 3 + AGENTS.md | 14 ++- README.md | 42 ++++++- docs/release.md | 149 +++++++++++++++++++++++ scripts/check_cursor_marketplace.py | 149 +++++++++++++++++++++++ scripts/validate_repo.py | 11 ++ 8 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/cursor-marketplace.yml create mode 100644 docs/release.md create mode 100644 scripts/check_cursor_marketplace.py diff --git a/.github/workflows/cursor-marketplace.yml b/.github/workflows/cursor-marketplace.yml new file mode 100644 index 0000000..ec4528b --- /dev/null +++ b/.github/workflows/cursor-marketplace.yml @@ -0,0 +1,24 @@ +name: Verify Cursor marketplace + +on: + schedule: + - cron: "27 15 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: python3 scripts/check_cursor_marketplace.py --self-test + - run: python3 scripts/check_cursor_marketplace.py diff --git a/.github/workflows/marketplace.yml b/.github/workflows/marketplace.yml index ab4935d..2c424d9 100644 --- a/.github/workflows/marketplace.yml +++ b/.github/workflows/marketplace.yml @@ -55,3 +55,35 @@ jobs: npm install --global @anthropic-ai/claude-code claude plugin validate . --strict claude plugin validate .claude-plugin/plugin.json --strict + + canonical-sources: + runs-on: ubuntu-latest + steps: + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: agentmail-plugins + + - name: Checkout canonical skills + uses: actions/checkout@v4 + with: + repository: agentmail-to/agentmail-skills + path: agentmail-skills + + - name: Checkout canonical MCP contract + uses: actions/checkout@v4 + with: + repository: agentmail-to/agentmail-mcp + path: agentmail-mcp + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check canonical skills against MCP + working-directory: agentmail-skills + run: python3 scripts/skills.py sync --check --backend ../agentmail-mcp + + - name: Check plugin skill export + working-directory: agentmail-skills + run: python3 scripts/skills.py build --check --target ../agentmail-plugins diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e23facc..68568e5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,3 +21,6 @@ jobs: - name: Validate repository structure run: python3 scripts/validate_repo.py + + - name: Test Cursor marketplace verifier + run: python3 scripts/check_cursor_marketplace.py --self-test diff --git a/AGENTS.md b/AGENTS.md index 0dec009..5021853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,11 @@ When sources disagree, verify the shipped interface and document the discrepancy - Use only `name` and `description` in shared `SKILL.md` frontmatter. - Keep `agents/openai.yaml` synchronized with its skill. Declare MCP dependencies only for skills that call MCP tools. - Keep all plugin manifest versions synchronized and update `CHANGELOG.md` and `compatibility.json` with each release. +- Treat `agentmail-skills` as the only source for `skills/`. Synchronize it from + the generated `agentmail-mcp` manifest, then export the complete tree; never + patch generated skill copies in this repository. +- Follow `docs/release.md` for every plugin release. Public-contract changes must + exercise both clean-install and existing-user upgrade paths in every client. ## Safety @@ -44,4 +49,11 @@ python3 scripts/check_compatibility.py claude plugin validate . --strict ``` -Also validate clean installation and read-only `list_inboxes` behavior in Codex, Claude Code, and Cursor before a marketplace release. +Also validate clean installation and existing-user upgrade behavior in Codex, +Claude Code, and Cursor before a marketplace release. Use only tools whose +released manifest annotations mark them read-only, including one tool from each +changed capability. + +For a release, also run the upstream contract/export checks and the complete +client matrix in `docs/release.md`. After Cursor approval, run +`python3 scripts/check_cursor_marketplace.py` from the released commit. diff --git a/README.md b/README.md index b06c195..679936d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The repository keeps shared Agent Skills portable while using native manifests a - `agentmail-mcp` — hosted MCP setup and troubleshooting - `agentmail-cli` — command-line workflows - `agentmail-toolkit` — framework adapters for agent applications +- `agent-email-patterns` — agent email architecture, security, and provider tradeoffs Detailed SDK material uses progressive references so agents only load the language or real-time guidance required for the task. @@ -49,15 +50,44 @@ Install from the [Cursor Marketplace](https://cursor.com/marketplace/agentmail): Complete the AgentMail OAuth browser sign-in when the MCP server first connects. -To test this repository directly instead, clone it and symlink it into Cursor's local plugin directory: +To test this repository directly instead, clone it into Cursor's local plugin +directory: ```bash -git clone https://github.com/agentmail-to/agentmail-plugins.git mkdir -p ~/.cursor/plugins/local -ln -s "$(pwd)/agentmail-plugins" ~/.cursor/plugins/local/agentmail +git clone https://github.com/agentmail-to/agentmail-plugins.git ~/.cursor/plugins/local/agentmail ``` -Reload Cursor after creating the link. +Reload Cursor after cloning. Cursor only follows local-plugin symlinks whose +targets also resolve inside `~/.cursor/plugins/local`, so a symlink to a checkout +elsewhere is not a supported test setup. + +## Updating an existing installation + +Plugin releases use one synchronized version across all package manifests. After +an update is published, refresh the source and load the new plugin in a new +session: + +### Codex + +```bash +codex plugin marketplace upgrade agentmail +``` + +### Claude Code + +```bash +claude plugin update agentmail@agentmail +``` + +Run `/reload-plugins` in an existing Claude Code session, or start a new one. +Third-party Claude marketplaces do not enable automatic updates by default. + +### Cursor + +Update AgentMail from the Cursor Marketplace after the new revision has passed +Cursor's review, then start a new chat. Public Cursor plugin updates are reviewed +before publication, so a repository release can precede marketplace availability. ## AgentID: create accounts for your agent @@ -114,6 +144,10 @@ claude plugin validate . --strict Use disposable inboxes and controlled recipients for integration testing. Automated validation must not send mail or delete live resources. +The complete contract-driven release and marketplace procedure lives in +[`docs/release.md`](docs/release.md). Follow it whenever packaged content changes; +an MCP implementation-only release does not automatically require a plugin bump. + ## Sources - [AgentMail documentation](https://docs.agentmail.to) diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..067b128 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,149 @@ +# Plugin release procedure + +This is the canonical release procedure for the AgentMail plugin on Claude Code, +Codex, and Cursor. It is contract-driven and intentionally does not encode a +specific release number, commit, or tool inventory. + +## Decide whether to release + +Release the plugin when its packaged skills, MCP configuration, metadata, client +compatibility, or user-facing behavior changes. An AgentMail MCP release requires +a plugin release only when it changes the public contract, authentication, +permissions, or a workflow described by a bundled skill. + +Use the generated MCP manifest and canonical skills repository as inputs. Do not +copy tool lists from prose documentation or edit `skills/` directly. + +## Prepare the release + +1. In `agentmail-skills`, synchronize from the released `agentmail-mcp` checkout, + review the reported contract changes, and merge the canonical skill updates. +2. Export the merged canonical skills into this repository: + + ```bash + python3 /path/to/agentmail-skills/scripts/skills.py build \ + --target /path/to/agentmail-plugins + ``` + +3. Choose the next plugin version according to the compatibility impact. Apply + the same version to every package manifest. Never use a version as a proxy for + the MCP server version; they have independent release cadences. +4. Update `CHANGELOG.md` with the packaged behavior change and migration notes. +5. Refresh `compatibility.json` against the published SDK, CLI, and toolkit + versions exercised by the bundled instructions. +6. Update installation, upgrade, authentication, and capability copy when those + behaviors changed. Keep the marketplace description synchronized with the + package manifests. + +## Validate the package + +Run the local checks: + +```bash +python3 scripts/validate_repo.py +python3 scripts/check_compatibility.py +claude plugin validate . --strict +``` + +The marketplace workflow must also prove that: + +- the current `agentmail-mcp` manifest matches the canonical skills contract; +- the plugin's complete `skills/` tree is the generated export of + `agentmail-skills`; +- Codex can add the repository marketplace and install the plugin; +- Claude Code accepts both the marketplace and plugin manifests. + +Do not publish when a generated-tree or upstream-contract check is failing. Fix +the canonical source and regenerate instead of patching a downstream copy. + +## Test clean install and upgrade + +Test both a clean installation and an upgrade from the previously published +version in all three clients. Record the plugin commit, client version, and result +in the release pull request or release issue. + +For each client: + +1. Install or upgrade through the supported marketplace path. +2. Reload plugins or start a new session so cached package content cannot mask the + result. +3. Complete the hosted MCP OAuth flow. +4. Confirm the advertised tool catalog matches the released manifest. +5. Invoke a representative read-only tool for basic connectivity and a read-only + tool from each changed capability. Verify returned data and error handling. +6. Confirm changed skills are discoverable and contain the released instructions. + +Release acceptance must not send mail, create or delete inboxes, mutate messages, +connect accounts, or invoke any tool whose manifest annotations do not mark it +read-only. Schema and discovery inspection is sufficient when a changed +capability has no read-only operation. + +### Claude Code + +Verify both installation and the existing-user update path: + +```bash +claude plugin marketplace update agentmail +claude plugin update agentmail@agentmail +``` + +Apply the new package with `/reload-plugins` or a new session. The manifest +version must change whenever packaged content changes because Claude Code uses it +to determine update availability. + +### Codex + +Verify both installation and marketplace refresh: + +```bash +codex plugin marketplace add agentmail-to/agentmail-plugins +codex plugin marketplace upgrade agentmail +codex plugin add agentmail@agentmail +``` + +Use a clean `CODEX_HOME` for the clean-install test and a previously installed +copy for the upgrade test. Start a new session after each test installation. + +### Cursor + +Test the repository locally from Cursor's local plugin directory before submitting +the public update. Confirm every component loads and OAuth succeeds. + +Update the existing AgentMail listing in the Cursor publisher dashboard to the +released repository commit. Do not create a new listing. Submit once, record the +review state, and wait if the update is pending; each public update is reviewed. + +After approval, verify the public listing from this repository checkout: + +```bash +python3 scripts/check_cursor_marketplace.py +``` + +The check derives its expected commit, description, and skill inventory from the +checkout, so it remains valid for future releases. Do not mark the release +complete until it passes. + +## Complete the release + +The release is complete when: + +- repository and cross-repository checks pass; +- clean-install and upgrade acceptance is recorded for all three clients; +- the Cursor listing is approved and its live revision check passes; +- affected AgentMail documentation is published; and +- rollback remains possible by restoring the previous plugin revision or listing + commit. + +Do not delete or recreate marketplace listings as a rollback mechanism. Restore a +known-good revision and follow the marketplace's normal review process. + +## Vendor references + +Check these live references before changing packaging or update commands: + +- [Claude Code: install and manage plugins](https://code.claude.com/docs/en/discover-plugins) +- [Claude Code: plugin manifest reference](https://code.claude.com/docs/en/plugins-reference) +- [Codex: package your plugin](https://developers.openai.com/plugins/build/plugins) +- [Cursor: plugins](https://cursor.com/docs/plugins) +- [Cursor: plugin reference](https://cursor.com/docs/reference/plugins) +- [Cursor: marketplace security and update review](https://cursor.com/help/security-and-privacy/marketplace-security) diff --git a/scripts/check_cursor_marketplace.py b/scripts/check_cursor_marketplace.py new file mode 100644 index 0000000..78f2e49 --- /dev/null +++ b/scripts/check_cursor_marketplace.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Verify that Cursor's public AgentMail listing matches this checkout.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_URL = "https://cursor.com/marketplace/agentmail" +PACKAGED_PATHS = ( + ".cursor-plugin", + ".mcp.json", + "assets", + "skills", +) + + +def normalize(page: str) -> str: + return html.unescape(page).replace(r'\"', '"') + + +def listing(page: str) -> tuple[str, int, set[str]]: + text = normalize(page) + marker = '"gitUrl":"https://github.com/agentmail-to/agentmail-plugins"' + start = text.find(marker) + if start < 0: + raise ValueError("AgentMail repository entry was not found") + + block = text[start:] + ref_match = re.search(r'"gitRef":"([0-9a-f]{40})"', block) + end = block.find('"mcpServers"') + if not ref_match or end < 0: + raise ValueError("listing metadata is incomplete") + + skills = set(re.findall(r'"sourcePath":"skills/([^/"]+)/SKILL\.md"', block[:end])) + if not skills: + raise ValueError("listing contains no skills") + + before = text[:start] + timestamps = re.findall(r'"updatedAt":"?(\d{10,13})"?', before[-20000:]) + if not timestamps: + raise ValueError("listing has no update timestamp") + updated_at = int(timestamps[-1]) + if updated_at > 10_000_000_000: + updated_at //= 1000 + return ref_match.group(1), updated_at, skills + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], cwd=ROOT, check=True, capture_output=True, text=True + ).stdout.strip() + + +def expected() -> tuple[str, int, str, set[str]]: + manifest = json.loads((ROOT / ".cursor-plugin/plugin.json").read_text()) + skills = { + path.parent.name + for path in (ROOT / "skills").glob("*/SKILL.md") + } + packaged_ref = git("log", "-1", "--format=%H", "--", *PACKAGED_PATHS) + return ( + packaged_ref, + int(git("show", "-s", "--format=%ct", packaged_ref)), + manifest["description"], + skills, + ) + + +def content_equivalent_ref(published_ref: str, packaged_ref: str) -> bool: + if published_ref == packaged_ref: + return True + checks = ( + ("cat-file", "-e", f"{published_ref}^{{commit}}"), + ("merge-base", "--is-ancestor", packaged_ref, published_ref), + ("merge-base", "--is-ancestor", published_ref, "HEAD"), + ("diff", "--quiet", published_ref, "HEAD", "--", *PACKAGED_PATHS), + ) + return all( + subprocess.run(["git", *args], cwd=ROOT, capture_output=True).returncode == 0 + for args in checks + ) + + +def fetch(url: str) -> str: + request = urllib.request.Request( + url, headers={"User-Agent": "agentmail-cursor-marketplace-check/1.0"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8", "replace") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default=DEFAULT_URL) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + sample = r'''{"updatedAt":"1800000000000","gitUrl":"https://github.com/agentmail-to/agentmail-plugins","gitRef":"0123456789abcdef0123456789abcdef01234567","skills":[{"sourcePath":"skills/one/SKILL.md"},{"sourcePath":"skills/two/SKILL.md"}],"mcpServers":[]}''' + assert listing(sample) == ( + "0123456789abcdef0123456789abcdef01234567", + 1_800_000_000, + {"one", "two"}, + ) + print("Cursor marketplace check self-test passed") + return 0 + + expected_ref, commit_time, description, expected_skills = expected() + page = fetch(args.url) + published_ref, updated_at, published_skills = listing(page) + problems: list[str] = [] + + if not content_equivalent_ref(published_ref, expected_ref): + problems.append(f"listing commit is {published_ref}, expected {expected_ref}") + if description not in normalize(page): + problems.append("listing description does not match .cursor-plugin/plugin.json") + if published_skills != expected_skills: + problems.append( + "listing skills differ: " + f"missing={sorted(expected_skills - published_skills)}, " + f"unexpected={sorted(published_skills - expected_skills)}" + ) + if updated_at < commit_time: + problems.append("listing update timestamp predates the released commit") + + if problems: + print("Cursor marketplace verification failed:", file=sys.stderr) + for problem in problems: + print(f"- {problem}", file=sys.stderr) + return 1 + + print( + f"Cursor listing matches {published_ref}: " + f"{len(published_skills)} skills and synchronized description" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_repo.py b/scripts/validate_repo.py index 1d15d6e..7e94a40 100644 --- a/scripts/validate_repo.py +++ b/scripts/validate_repo.py @@ -67,6 +67,13 @@ def check_path(owner: str, value: object) -> None: if f"## {version} " not in changelog: error(f"CHANGELOG.md has no entry for {version}") +descriptions = {name: manifest.get("description") for name, manifest in manifests.items()} +marketplace_description = load_json(".claude-plugin/marketplace.json").get("plugins", [{}])[0].get("description") +if len(set(descriptions.values())) != 1 or None in descriptions.values(): + error(f"plugin manifest descriptions are not synchronized: {descriptions}") +elif marketplace_description != next(iter(descriptions.values())): + error("Claude marketplace description does not match the package manifests") + for surface, manifest in manifests.items(): if manifest.get("name") != "agentmail": error(f"{surface} manifest name must be agentmail") @@ -86,6 +93,10 @@ def check_path(owner: str, value: object) -> None: skills_root = ROOT / "skills" skill_dirs = sorted(path for path in skills_root.iterdir() if path.is_dir()) +readme = (ROOT / "README.md").read_text(encoding="utf-8") +for skill_dir in skill_dirs: + if f"- `{skill_dir.name}` " not in readme: + error(f"README.md does not list the {skill_dir.name} skill") frontmatter_pattern = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) for skill_dir in skill_dirs: