From 239c0cc9cbba6dbbfae4c61fb414ca3a7c633d33 Mon Sep 17 00:00:00 2001 From: Nick Persico Date: Fri, 16 Oct 2026 11:17:53 -0400 Subject: [PATCH] Migrate Close to the Agent Plugins standard --- .cursor-mcp.json | 7 - .cursor-plugin/plugin.json | 18 -- .github/workflows/validate.yml | 21 ++ CONTRIBUTING.md | 10 +- README.md | 74 +++++-- SECURITY.md | 2 +- mcp.json | 9 + package-lock.json | 94 ++++++++ package.json | 18 +- plugin.json | 14 ++ scripts/schemas/1.0.0/mcp.schema.json | 120 +++++++++++ scripts/schemas/1.0.0/plugin.schema.json | 65 ++++++ scripts/schemas/LICENSE | 202 ++++++++++++++++++ scripts/schemas/README.md | 12 ++ scripts/validate.mjs | 161 +++++++++----- scripts/validate.test.mjs | 110 ++++++++++ .../account-brief/SKILL.md | 4 +- .../follow-up-plan/SKILL.md | 4 +- .../pipeline-review/SKILL.md | 4 +- 19 files changed, 834 insertions(+), 115 deletions(-) delete mode 100644 .cursor-mcp.json delete mode 100644 .cursor-plugin/plugin.json create mode 100644 .github/workflows/validate.yml create mode 100644 mcp.json create mode 100644 package-lock.json create mode 100644 plugin.json create mode 100644 scripts/schemas/1.0.0/mcp.schema.json create mode 100644 scripts/schemas/1.0.0/plugin.schema.json create mode 100644 scripts/schemas/LICENSE create mode 100644 scripts/schemas/README.md create mode 100644 scripts/validate.test.mjs rename commands/account-brief.md => skills/account-brief/SKILL.md (59%) rename commands/follow-up-plan.md => skills/follow-up-plan/SKILL.md (62%) rename commands/pipeline-review.md => skills/pipeline-review/SKILL.md (59%) diff --git a/.cursor-mcp.json b/.cursor-mcp.json deleted file mode 100644 index a87bb4a..0000000 --- a/.cursor-mcp.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mcpServers": { - "close": { - "url": "https://mcp.close.com/mcp" - } - } -} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json deleted file mode 100644 index 398ed58..0000000 --- a/.cursor-plugin/plugin.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "close", - "displayName": "Close", - "version": "0.1.0", - "description": "Connect Cursor to Close CRM to research leads, review pipeline, analyze sales activity, and safely update CRM records.", - "author": { - "name": "Close", - "email": "support@close.com" - }, - "homepage": "https://close.com", - "repository": "https://github.com/closeio/cursor-plugin", - "license": "MIT", - "keywords": ["close", "crm", "sales", "mcp", "leads", "pipeline"], - "logo": "assets/mark.png", - "mcpServers": "../.cursor-mcp.json", - "skills": "./skills/", - "commands": "./commands/" -} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..fe8c214 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,21 @@ +name: Validate plugin + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run validate + - run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9a0d90..99107d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,16 @@ # Contributing -Thanks for helping improve the Close plugin for Cursor. +Thanks for helping improve the Close Agent Plugin. 1. Open an issue describing the bug or proposed improvement. 2. Make a focused change on a branch. -3. Run `npm run validate`. +3. Install development dependencies with `npm ci`, then run `npm run validate` and `npm test`. 4. Open a pull request with testing notes. Never commit Close API keys, OAuth tokens, customer data, or other credentials. By contributing, you agree that your contribution is licensed under the MIT License. + +Keep the portable package aligned with [Agent Plugins 1.0.0](https://agent-plugins.org/specification): metadata belongs in root `plugin.json`, MCP configuration in root `mcp.json`, and skills in `skills//SKILL.md`. Follow the [Agent Skills specification](https://agentskills.io/specification) for skill frontmatter and layout. Client-specific behavior, if needed, belongs in a documented reverse-domain extension namespace rather than the portable core. + +The validator uses vendored, versioned schemas in `scripts/schemas/1.0.0/` so validation does not fetch remote schemas. See [schema provenance](scripts/schemas/README.md) before updating them. A specification upgrade must update both schema identifiers and both vendored schemas together. + +Before a marketplace submission, load the plugin in the target client, verify that all four skills are discovered, authenticate to Close, and test a read-only request. Record the client and version in the pull request. Marketplace listings and client-specific installation flows are managed separately from this base package. diff --git a/README.md b/README.md index 0cbc202..8373e5f 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,87 @@ -# Close for Cursor +# Close Agent Plugin -The official Close plugin for Cursor connects Cursor to your Close CRM through Close's hosted Model Context Protocol (MCP) server. +The official Close plugin connects AI agents to your Close CRM through Close's hosted Model Context Protocol (MCP) server. It follows the [Agent Plugins 1.0.0 standard](https://agent-plugins.org/specification) so the same package can be used by compatible clients and marketplaces. Use natural language to research leads, prepare account briefs, review sales activity and pipeline, find tasks, answer Close product questions, and—with the permission scope you choose—create or update CRM records. -## Install +## Install and connect -Install **Close** from the Cursor Plugin Marketplace. When Cursor shows that Close needs authentication, select **Connect**, choose your Close organization and access scope, and approve the connection. +Use a client that supports Agent Plugins 1.0.0, skills, Streamable HTTP MCP servers, and MCP OAuth authorization. See the standard's [compatible clients](https://agent-plugins.org/compatible-clients) and your client's documentation for installation instructions. -Close supports three OAuth scopes: +For clients that accept local plugins, clone this repository and select its root directory in the client's plugin installation flow: + +```sh +git clone https://github.com/closeio/agent-plugin.git +``` + +For clients or marketplaces that accept a Git repository, use `https://github.com/closeio/agent-plugin`. Installation, marketplace approval, and skill invocation are client-specific; this repository does not imply a listing in every marketplace. + +After installation, connect the `close` MCP server when your client prompts for authentication. Choose your Close organization and access scope, then approve the connection. Close supports three OAuth scopes: - `mcp.read` — search and read Close data; - `mcp.write_safe` — read plus create and update records; - `mcp.write_destructive` — read, create, update, and delete records. -Choose the least-privileged scope that covers your work. You can revoke the connection from Close at any time. +Choose the least-privileged scope that covers your work. You can revoke the connection from Close at any time. Available tools depend on the scope you approve. -## Included workflows +## Included skills -- `/close:account-brief ` — prepare a brief from contacts, opportunities, activities, and tasks. -- `/close:pipeline-review [filter]` — identify pipeline risk, stale deals, and next actions. -- `/close:follow-up-plan ` — build a follow-up plan from recent conversations and open work. -- `close-crm` skill — helps Cursor choose appropriate Close tools and handle CRM writes safely. +| Skill | Purpose | +| --- | --- | +| `close-crm` | Choose appropriate Close tools, research records, answer product questions, and handle CRM writes safely. | +| `account-brief` | Prepare a lead brief from contacts, opportunities, recent activity, and tasks. | +| `pipeline-review` | Identify pipeline risk, stale deals, and next actions. | +| `follow-up-plan` | Prepare a follow-up plan from recent conversations and open work. | -You can also ask Cursor directly: +Ask your agent directly, or select a skill using your client's interface: -- “Which active opportunities need attention this week?” - “Brief me on Acme before my call.” +- “Which active opportunities need attention this week?” +- “Prepare a follow-up plan for Acme.” - “Find my overdue Close tasks and prioritize them.” - “How many calls did our team make last week?” - “Create a follow-up task for Friday on this lead.” -Available tools depend on the OAuth scope you approve. Cursor should ask before ambiguous or destructive changes. +The brief, pipeline review, and follow-up plan skills produce read-only analysis. CRM changes require a user request; OAuth access alone does not authorize an action. + +## Package structure + +```text +plugin.json +mcp.json +skills/ + close-crm/SKILL.md + account-brief/SKILL.md + pipeline-review/SKILL.md + follow-up-plan/SKILL.md +assets/ + mark.png + mark.svg +``` + +`plugin.json` contains portable metadata. Compatible clients discover skills from `skills/` and the Close MCP server from `mcp.json`. The server uses `type: "streamable-http"` and connects directly to `https://mcp.close.com/mcp`. OAuth discovery and credential storage are managed by the client; the package contains no API keys, local server process, or additional data proxy. -## How it works +The brand assets are available for marketplace listings. Agent Plugins 1.0.0 does not define a portable logo field. Future client-specific behavior can use the standard's reverse-domain extension namespaces. -The plugin configures Cursor to connect directly to `https://mcp.close.com/mcp` using Streamable HTTP and Close OAuth 2.0. It does not contain API keys, run a local process, or send Close data through an additional service. +## Migration from the Cursor plugin + +This repository continues `closeio/cursor-plugin` as `closeio/agent-plugin`, preserving its Git history. The Cursor manifest and MCP file have been replaced by the standard root files. The three Cursor commands are now portable skills; their selection and invocation depend on the client. The old `/close:...` command names are not part of the portable format. + +Cursor marketplace registration is handled separately using this repository. The old listing is not maintained by this package. ## Development -Requirements: Node.js 18 or newer. +Requirements: Node.js 22 or newer. Node.js and npm dependencies are only needed for development validation; the installed plugin connects to the hosted MCP server directly. ```sh +npm ci npm run validate +npm test ``` -To test before marketplace publication, clone this repository and load it as a local Cursor plugin, then authenticate when prompted. +Validation checks the manifest and MCP configuration against vendored official 1.0.0 schemas, the Close endpoint and transport, version consistency, and skill frontmatter. GitHub Actions runs the same checks. Before a marketplace submission, also verify skill discovery, OAuth connection, and a read-only request in the target client; schema validation does not exercise those client flows. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development and marketplace verification guidance. ## Support and documentation @@ -58,4 +94,4 @@ Security issues should be reported privately as described in [SECURITY.md](SECUR ## License -MIT +MIT. Vendored Agent Plugins schemas retain their upstream Apache-2.0 license; see [schema provenance](scripts/schemas/README.md). diff --git a/SECURITY.md b/SECURITY.md index cc36936..06c94af 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ Please report suspected vulnerabilities privately to security@close.com. Do not open a public issue for a security vulnerability. -This plugin contains no credentials and does not proxy Close data. Cursor connects directly to Close's hosted MCP server over HTTPS and authenticates through Close OAuth. Users choose the Close organization and permission scope during authorization and can revoke access from their Close account. +This plugin contains no credentials and does not proxy Close data. The agent client connects directly to Close's hosted MCP server over HTTPS and manages OAuth discovery, authorization, and credential storage. Users choose the Close organization and permission scope during authorization and can revoke access from their Close account. diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..b8b61c6 --- /dev/null +++ b/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "close": { + "type": "streamable-http", + "url": "https://mcp.close.com/mcp" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a1badb3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "close-agent-plugin", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "close-agent-plugin", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "ajv": "8.20.0", + "yaml": "2.9.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json index d30ebc9..e581dcf 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,18 @@ { - "name": "cursor-plugin", - "version": "0.1.0", + "name": "close-agent-plugin", + "version": "0.2.0", "private": true, - "description": "Official Close plugin for Cursor", + "description": "Official Close plugin for clients supporting the Agent Plugins standard", + "engines": { + "node": ">=22" + }, "scripts": { - "validate": "node scripts/validate.mjs" + "validate": "node scripts/validate.mjs", + "test": "node --test scripts/validate.test.mjs" }, - "license": "MIT" + "license": "MIT", + "devDependencies": { + "ajv": "8.20.0", + "yaml": "2.9.1" + } } diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..52eec84 --- /dev/null +++ b/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "close", + "version": "0.2.0", + "description": "Connect your AI agent to Close CRM to research leads, review pipeline, analyze sales activity, and safely update CRM records.", + "author": { + "name": "Close", + "email": "support@close.com" + }, + "homepage": "https://developer.close.com/mcp", + "repository": "https://github.com/closeio/agent-plugin", + "license": "MIT", + "keywords": ["close", "crm", "sales", "mcp", "leads", "pipeline"] +} diff --git a/scripts/schemas/1.0.0/mcp.schema.json b/scripts/schemas/1.0.0/mcp.schema.json new file mode 100644 index 0000000..a9139a4 --- /dev/null +++ b/scripts/schemas/1.0.0/mcp.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "title": "Agent Plugins MCP Configuration", + "description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server" + } + } + }, + "required": ["$schema", "mcpServers"], + "additionalProperties": false, + "$defs": { + "server": { + "title": "MCP server", + "oneOf": [ + { + "$ref": "#/$defs/stdioServer" + }, + { + "$ref": "#/$defs/streamableHttpServer" + }, + { + "$ref": "#/$defs/sseServer" + } + ] + }, + "stdioServer": { + "title": "stdio MCP server", + "type": "object", + "properties": { + "type": { + "const": "stdio" + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable token. Resolution rules are defined by the Agent Plugins specification." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "propertyNames": { + "not": { + "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] + } + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "type": "string", + "pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))", + "description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately." + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "streamableHttpServer": { + "title": "Streamable HTTP MCP server", + "type": "object", + "properties": { + "type": { + "const": "streamable-http" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "sseServer": { + "title": "Legacy HTTP+SSE MCP server", + "type": "object", + "properties": { + "type": { + "const": "sse" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "headers": { + "title": "HTTP headers", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/scripts/schemas/1.0.0/plugin.schema.json b/scripts/schemas/1.0.0/plugin.schema.json new file mode 100644 index 0000000..8fed0e1 --- /dev/null +++ b/scripts/schemas/1.0.0/plugin.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "title": "Agent Plugins Manifest", + "description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", + "description": "Human-readable plugin name." + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "homepage": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "license": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "object", + "description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["$schema", "name"], + "additionalProperties": false +} diff --git a/scripts/schemas/LICENSE b/scripts/schemas/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/scripts/schemas/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/scripts/schemas/README.md b/scripts/schemas/README.md new file mode 100644 index 0000000..ba0bb0b --- /dev/null +++ b/scripts/schemas/README.md @@ -0,0 +1,12 @@ +# Agent Plugins schemas + +The unmodified schemas in `1.0.0/` were retrieved on 2026-10-16 from the canonical, versioned URLs: + +- https://agent-plugins.org/schemas/1.0.0/plugin.schema.json +- https://agent-plugins.org/schemas/1.0.0/mcp.schema.json + +Source project: https://github.com/agentplugins/agent-plugins-spec + +Upstream licenses schemas and other software material under Apache-2.0; the license is included in [LICENSE](LICENSE). See [upstream licensing](https://github.com/agentplugins/agent-plugins-spec/blob/main/LICENSE.md). + +Keep these copies unchanged. To target a new specification version, vendor both schemas under a new version directory, update both root `$schema` identifiers and the validator's schema paths, and rerun validation and tests. Validation uses these local copies and does not retrieve schemas from the network. diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 83497ef..f29f303 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,78 +1,125 @@ #!/usr/bin/env node -import { access, readFile, readdir } from "node:fs/promises"; +import { readFile, readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { parseDocument } from "yaml"; -const root = process.cwd(); -const errors = []; - -async function json(relativePath) { - try { - return JSON.parse(await readFile(path.join(root, relativePath), "utf8")); - } catch (error) { - errors.push(`${relativePath}: ${error.message}`); - return null; - } +const ajv = new Ajv2020({ allErrors: true }); +const schemas = {}; +for (const kind of ["plugin", "mcp"]) { + const source = await readFile(new URL(`./schemas/1.0.0/${kind}.schema.json`, import.meta.url), "utf8"); + schemas[kind] = ajv.compile(JSON.parse(source)); } -async function exists(relativePath) { - try { - await access(path.join(root, relativePath)); - return true; - } catch { - errors.push(`Missing referenced path: ${relativePath}`); - return false; - } -} +export async function validatePlugin(directory) { + const root = await realpath(directory); + const errors = []; -function frontmatter(source, required, relativePath) { - const match = source.match(/^---\n([\s\S]*?)\n---\n/); - if (!match) { - errors.push(`${relativePath}: missing YAML frontmatter`); - return; + async function packagePath(relativePath, kind = "file") { + const resolved = await realpath(path.join(root, relativePath)); + const relative = path.relative(root, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error("path resolves outside the plugin root"); + } + const info = await stat(resolved); + if (kind === "file" ? !info.isFile() : !info.isDirectory()) { + throw new Error(`expected a ${kind}`); + } + return resolved; } - for (const key of required) { - if (!new RegExp(`^${key}:\\s*.+$`, "m").test(match[1])) { - errors.push(`${relativePath}: missing ${key} in frontmatter`); + + async function json(relativePath, schema) { + try { + const data = JSON.parse(await readFile(await packagePath(relativePath), "utf8")); + if (schema && !schema(data)) { + errors.push(`${relativePath}: ${ajv.errorsText(schema.errors, { separator: "; " })}`); + } + return data; + } catch (error) { + errors.push(`${relativePath}: ${error.message}`); + return null; } } -} -const manifest = await json(".cursor-plugin/plugin.json"); -const mcp = await json(".cursor-mcp.json"); + const manifest = await json("plugin.json", schemas.plugin); + const mcp = await json("mcp.json", schemas.mcp); + const pkg = await json("package.json"); -if (manifest) { - if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(manifest.name ?? "")) { - errors.push("plugin name must be lowercase and kebab-case"); + if (manifest?.version !== pkg?.version) { + errors.push("plugin.json: version must match package.json"); } - for (const key of ["name", "displayName", "version", "description", "author", "license"]) { - if (!manifest[key]) errors.push(`plugin.json: missing ${key}`); + // Both schemas pin the same Agent Plugins version. Also preserve this package's + // single hosted connection rather than accidentally shipping another service. + const close = mcp?.mcpServers?.close; + if (close?.type !== "streamable-http" || close?.url !== "https://mcp.close.com/mcp") { + errors.push("mcp.json: close must use streamable-http at https://mcp.close.com/mcp"); } - for (const key of ["logo", "skills", "commands"]) { - if (manifest[key]) await exists(manifest[key].replace(/^\.\//, "")); + if (Object.keys(mcp?.mcpServers ?? {}).length !== 1 || close?.headers !== undefined) { + errors.push("mcp.json: configure only the Close server, with client-managed authentication and no fixed headers"); } - if (manifest.mcpServers) { - const mcpPath = path.normalize(path.join(".cursor-plugin", manifest.mcpServers)); - await exists(mcpPath); + + try { + const skillsPath = await packagePath("skills", "directory"); + const entries = await readdir(skillsPath, { withFileTypes: true }); + let count = 0; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + count++; + const relativePath = `skills/${entry.name}/SKILL.md`; + try { + const source = await readFile(await packagePath(relativePath), "utf8"); + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) throw new Error("missing YAML frontmatter"); + const document = parseDocument(match[1], { uniqueKeys: true }); + if (document.errors.length) throw new Error(document.errors.map(error => error.message).join("; ")); + const fields = document.toJS({ maxAliasCount: 0 }); + if (!fields || typeof fields !== "object" || Array.isArray(fields)) { + throw new Error("frontmatter must be an object"); + } + const allowed = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools"]); + for (const key of Object.keys(fields)) { + if (!allowed.has(key)) errors.push(`${relativePath}: unsupported frontmatter field ${key}`); + } + if (typeof fields.name !== "string" || fields.name.length > 64 || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(fields.name) || fields.name !== entry.name) { + errors.push(`${relativePath}: name must match its directory and be 1-64 lowercase alphanumeric or hyphen characters, without consecutive hyphens`); + } + for (const [key, limit] of [["description", 1024], ["compatibility", 500]]) { + if (key === "compatibility" && fields[key] === undefined) continue; + if (typeof fields[key] !== "string" || !fields[key].trim() || fields[key].length > limit) { + errors.push(`${relativePath}: ${key} must be a nonempty string of at most ${limit} characters`); + } + } + for (const key of ["license", "allowed-tools"]) { + if (fields[key] !== undefined && typeof fields[key] !== "string") { + errors.push(`${relativePath}: ${key} must be a string`); + } + } + if (fields.metadata !== undefined && (!fields.metadata || typeof fields.metadata !== "object" || + Array.isArray(fields.metadata) || Object.values(fields.metadata).some(value => typeof value !== "string"))) { + errors.push(`${relativePath}: metadata must map string keys to string values`); + } + if (!source.slice(match[0].length).trim()) errors.push(`${relativePath}: missing skill instructions`); + } catch (error) { + errors.push(`${relativePath}: ${error.message}`); + } + } + if (!count) errors.push("skills/: at least one skill is required for this package"); + } catch (error) { + errors.push(`skills/: ${error.message}`); } -} -if (mcp?.mcpServers?.close?.url !== "https://mcp.close.com/mcp") { - errors.push(".cursor-mcp.json: Close MCP URL is missing or incorrect"); + return errors; } -for (const directory of ["commands", "skills/close-crm"]) { - if (!(await exists(directory))) continue; - for (const file of await readdir(path.join(root, directory))) { - if (!file.endsWith(".md")) continue; - const relativePath = path.join(directory, file); - frontmatter(await readFile(path.join(root, relativePath), "utf8"), ["name", "description"], relativePath); +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const errors = await validatePlugin(process.cwd()); + if (errors.length) { + console.error(`Validation failed:\n- ${errors.join("\n- ")}`); + process.exitCode = 1; + } else { + console.log("Validation passed (Agent Plugins 1.0.0, Close MCP, and Agent Skills)."); } } - -if (errors.length) { - console.error(`Validation failed:\n- ${errors.join("\n- ")}`); - process.exit(1); -} - -console.log("Validation passed."); diff --git a/scripts/validate.test.mjs b/scripts/validate.test.mjs new file mode 100644 index 0000000..4e1577d --- /dev/null +++ b/scripts/validate.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { validatePlugin } from "./validate.mjs"; + +const source = fileURLToPath(new URL("../", import.meta.url)); + +async function fixture(t) { + const root = await mkdtemp(path.join(os.tmpdir(), "close-agent-plugin-")); + t.after(() => rm(root, { recursive: true, force: true })); + for (const name of ["plugin.json", "mcp.json", "package.json", "skills"]) { + await cp(path.join(source, name), path.join(root, name), { recursive: true }); + } + return root; +} + +async function editJSON(root, file, change) { + const target = path.join(root, file); + const data = JSON.parse(await readFile(target, "utf8")); + change(data); + await writeFile(target, JSON.stringify(data)); +} + +async function rejects(t, mutate, message) { + const root = await fixture(t); + await mutate(root); + assert.match((await validatePlugin(root)).join("\n"), message); +} + +test("the portable package and all four skills validate", async () => { + assert.deepEqual(await validatePlugin(source), []); +}); + +test("Cursor-only manifest fields are rejected", async t => { + await rejects(t, root => editJSON(root, "plugin.json", data => { + data.displayName = "Close"; + data.commands = "./commands/"; + }), /plugin.json:.*additional properties/); +}); + +test("missing root manifest is rejected", async t => { + await rejects(t, root => rm(path.join(root, "plugin.json")), /plugin.json:.*ENOENT/); +}); + +test("manifest name constraints are enforced by the official schema", async t => { + await rejects(t, root => editJSON(root, "plugin.json", data => { data.name = "close--crm"; }), /plugin.json:.*pattern/); +}); + +test("mismatched Agent Plugins schema versions are rejected", async t => { + await rejects(t, root => editJSON(root, "mcp.json", data => { + data.$schema = "https://agent-plugins.org/schemas/1.1.0/mcp.schema.json"; + }), /mcp.json:.*constant/); +}); + +test("the legacy MCP shape with no type is rejected", async t => { + await rejects(t, root => editJSON(root, "mcp.json", data => { delete data.mcpServers.close.type; }), /mcp.json:.*required property 'type'/); +}); + +test("a different endpoint cannot replace Close", async t => { + await rejects(t, root => editJSON(root, "mcp.json", data => { + data.mcpServers.close.url = "https://example.com/mcp"; + }), /close must use streamable-http/); +}); + +test("fixed authentication headers are rejected", async t => { + await rejects(t, root => editJSON(root, "mcp.json", data => { + data.mcpServers.close.headers = { Authorization: "Bearer example" }; + }), /client-managed authentication/); +}); + +test("package and plugin versions must agree", async t => { + await rejects(t, root => editJSON(root, "package.json", data => { data.version = "0.0.0"; }), /version must match/); +}); + +test("valid multiline YAML descriptions are accepted", async t => { + const root = await fixture(t); + await writeFile(path.join(root, "skills/account-brief/SKILL.md"), + "---\nname: account-brief\ndescription: >\n Prepare a Close account brief\n before a call.\n---\n\nRead the lead.\n"); + assert.deepEqual(await validatePlugin(root), []); +}); + +test("malformed or duplicate YAML fields are rejected", async t => { + await rejects(t, root => writeFile(path.join(root, "skills/account-brief/SKILL.md"), + "---\nname: account-brief\nname: other\ndescription: Brief a lead.\n---\nRead the lead.\n"), /Map keys must be unique/); +}); + +test("skills must use their directory name and string descriptions", async t => { + await rejects(t, root => writeFile(path.join(root, "skills/account-brief/SKILL.md"), + "---\nname: other\ndescription: 42\n---\nRead the lead.\n"), /name must match[\s\S]*description must be a nonempty string/); +}); + +test("skill metadata values must be strings", async t => { + await rejects(t, root => writeFile(path.join(root, "skills/account-brief/SKILL.md"), + "---\nname: account-brief\ndescription: Brief a lead.\nmetadata:\n version: 1\n---\nRead the lead.\n"), /metadata must map string keys to string values/); +}); + +test("a missing skill entry point is rejected", async t => { + await rejects(t, root => rm(path.join(root, "skills/account-brief/SKILL.md")), /skills\/account-brief\/SKILL.md:.*ENOENT/); +}); + +test("package files cannot escape the plugin root through symlinks", async t => { + const root = await fixture(t); + const external = await fixture(t); + await rm(path.join(root, "mcp.json")); + await symlink(path.join(external, "mcp.json"), path.join(root, "mcp.json")); + assert.match((await validatePlugin(root)).join("\n"), /mcp.json: path resolves outside the plugin root/); +}); diff --git a/commands/account-brief.md b/skills/account-brief/SKILL.md similarity index 59% rename from commands/account-brief.md rename to skills/account-brief/SKILL.md index 38ae3a7..bf6c2bf 100644 --- a/commands/account-brief.md +++ b/skills/account-brief/SKILL.md @@ -1,9 +1,9 @@ --- name: account-brief -description: Build a concise Close account brief from recent activity, contacts, opportunities, and tasks. +description: Build a Close account brief when preparing for a call or researching a lead, using recent activity, contacts, opportunities, and tasks. --- -Create a concise account brief for `$ARGUMENTS` using Close MCP tools. +Create a concise account brief for the lead identified in the user's request using Close MCP tools. If no lead is specified, ask which lead to research. Find and disambiguate the lead first. Then cover: diff --git a/commands/follow-up-plan.md b/skills/follow-up-plan/SKILL.md similarity index 62% rename from commands/follow-up-plan.md rename to skills/follow-up-plan/SKILL.md index d9de3b5..86ad53c 100644 --- a/commands/follow-up-plan.md +++ b/skills/follow-up-plan/SKILL.md @@ -1,9 +1,9 @@ --- name: follow-up-plan -description: Prepare a follow-up plan for a Close lead based on its latest conversations and open tasks. +description: Prepare a follow-up plan when the user wants next steps for a Close lead, based on its latest conversations and open tasks. --- -Prepare a follow-up plan for `$ARGUMENTS` using Close MCP tools. +Prepare a follow-up plan for the lead identified in the user's request using Close MCP tools. If no lead is specified, ask which lead to review. Find and disambiguate the lead, review its recent calls, meetings, emails, notes, opportunity state, and incomplete tasks, then provide: diff --git a/commands/pipeline-review.md b/skills/pipeline-review/SKILL.md similarity index 59% rename from commands/pipeline-review.md rename to skills/pipeline-review/SKILL.md index 296543f..ccaa1bc 100644 --- a/commands/pipeline-review.md +++ b/skills/pipeline-review/SKILL.md @@ -1,9 +1,9 @@ --- name: pipeline-review -description: Review the Close pipeline for risk, stale deals, and recommended next actions. +description: Review the Close pipeline when the user wants to assess opportunity risk, find stale deals, or prioritize next actions. --- -Review the Close pipeline for `$ARGUMENTS`. If no filter is provided, review active opportunities visible to the current user. +Review the Close pipeline using the filters in the user's request. If no filter is provided, review active opportunities visible to the current user. Use Close MCP tools to identify: