From fa2c6b29868588deea7e3a716050a6597a03d9f8 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 14 Aug 2026 22:13:20 +0800 Subject: [PATCH] Simplify community plugin contributions --- .github/PULL_REQUEST_TEMPLATE.md | 12 +- .github/workflows/verify-submissions.yml | 31 ---- CONTRIBUTING.md | 86 +++++------ GOVERNANCE.md | 11 +- README.md | 157 ++++++++++--------- README.zh-CN.md | 134 +++++++++++----- SECURITY.md | 11 +- assets/hero.svg | 45 ++++++ docs/architecture.md | 30 ++-- docs/plugin-compatibility.md | 7 +- docs/security-model.md | 24 ++- package-lock.json | 4 +- package.json | 9 +- plugins/README.md | 23 +++ proposals/README.md | 7 +- registry/README.md | 13 -- schemas/registry-entry.schema.json | 97 ------------ scripts/add-plugin.mjs | 109 ------------- scripts/create-plugin.mjs | 50 ++++++ scripts/lib/validation.mjs | 94 +++++------- scripts/validate.mjs | 44 ++++-- scripts/verify-plugin.mjs | 72 --------- test/hosted-plugins.test.mjs | 187 +++++++++++++++++++++++ test/validation.test.mjs | 32 +--- 24 files changed, 656 insertions(+), 633 deletions(-) delete mode 100644 .github/workflows/verify-submissions.yml create mode 100644 assets/hero.svg create mode 100644 plugins/README.md delete mode 100644 registry/README.md delete mode 100644 schemas/registry-entry.schema.json delete mode 100644 scripts/add-plugin.mjs create mode 100644 scripts/create-plugin.mjs delete mode 100644 scripts/verify-plugin.mjs create mode 100644 test/hosted-plugins.test.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1cef9b9..ff298f7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ ## What changes - + ## User value @@ -8,13 +8,15 @@ ## Plugin submission checklist -- [ ] Source is public, open-source licensed, and pinned to a full commit SHA. -- [ ] Declared Skills and MCP servers match the pinned source. +- [ ] Plugin lives at `plugins//`. +- [ ] `plugin.json` name matches the Plugin directory. +- [ ] `README.md` includes a real example prompt and expected result. +- [ ] `LICENSE` and `plugin.json` declare an open-source license. - [ ] Required executables, accounts, paid services, and supported platforms are disclosed. - [ ] Network destinations and data handled by the plugin are disclosed. -- [ ] No credentials, private endpoints, installers, or native binaries are included. +- [ ] No credentials, private endpoints, hidden telemetry, installers, symlinks, or native binaries are included. +- [ ] Every scaffold `TODO` has been replaced. - [ ] `npm run check` passes. -- [ ] `npm run verify -- registry/.json` passes for a registry change. ## Evidence diff --git a/.github/workflows/verify-submissions.yml b/.github/workflows/verify-submissions.yml deleted file mode 100644 index 53e1b82..0000000 --- a/.github/workflows/verify-submissions.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Verify plugin submissions - -on: - pull_request: - paths: - - "registry/*.json" - -permissions: - contents: read - -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b18 # v7.0.1 - with: - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: Verify changed registry entries - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - GITHUB_TOKEN: ${{ github.token }} - run: | - mapfile -t entries < <(git diff --diff-filter=AM --name-only "$BASE_SHA" HEAD -- 'registry/*.json' | sort) - if (( ${#entries[@]} > 0 )); then - npm run verify -- "${entries[@]}" - fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a949d8..e06db84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,66 +1,66 @@ # Contributing -Thank you for helping the MCode plugin ecosystem grow. Contributions may improve the registry -tooling and documentation or submit a plugin that you maintain in another public repository. +One folder is one Plugin. One pull request is one contribution. -## Submit a plugin +## 1. Create your Plugin -Your plugin must: - -- be hosted in a public GitHub repository; -- include a recognized open-source license; -- pin a full 40-character Git commit SHA in the registry entry; -- use the MCode-compatible Agent Plugins surface documented in - [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md); -- contain no embedded credentials, private endpoints, telemetry without disclosure, installers, or - native binaries; -- document required executables, accounts, paid services, network access, and operating-system - limits; and -- expose at least one Skill or MCP server that a reviewer can understand and exercise. - -Generate the first draft instead of writing catalog metadata by hand: +Fork this repository, install dependencies, and run: ```bash npm install -npm run add -- https://github.com// --path +npm run create -- / ``` -Then edit the generated entry, run: +The command creates `plugins//` with a portable `plugin.json`, README, +Apache-2.0 license, and starter Skill. + +## 2. Make it real + +Replace every scaffold `TODO`. Your Plugin must: + +- expose at least one Skill or MCP server; +- use the supported package shape in [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md); +- include `README.md`, `LICENSE`, and a matching open-source license in `plugin.json`; +- explain the user problem, an example prompt, and the expected result; +- disclose required executables, accounts, paid services, platforms, network destinations, and data; +- contain no credentials, private endpoints, hidden telemetry, installers, native binaries, or symlinks. + +Keep source and docs inside your Plugin directory. Do not edit another contributor's Plugin in the +same pull request. + +## 3. Check it ```bash npm run check -npm run verify -- registry/.json ``` -and open a pull request. Include the user problem, an example prompt, expected result, dependencies, -network destinations, data handled by the plugin, and test evidence. One pull request should add or -update one plugin unless the entries are inseparable. +The validator checks the hosted directory, Manifest, Skills, MCP transports, required docs, +placeholders, and path safety. CI runs the same command. -## Registry review +## 4. Open the pull request -Reviewers verify the pinned source rather than a mutable branch. Automated checks cover package -shape and declared capabilities. Human review covers usefulness, clear ownership, dependency and -data-flow disclosure, obvious credential or supply-chain risks, and whether the example can be -reproduced. +Include: -Acceptance means “listed as community software.” It is not an audit or product endorsement. A -plugin can be removed or marked unavailable if its pinned source disappears, changes ownership -without explanation, becomes malicious, or is no longer maintained. +- the problem your Plugin solves; +- a copyable example prompt; +- the expected result; +- dependencies and supported platforms; +- network and data behavior; +- automated and manual test evidence. -## Update a plugin +Review covers usefulness, reproducibility, clear ownership, data flow, dependency risk, and obvious +supply-chain issues. Acceptance means “available as community software”; it is not a MiniMax +endorsement or a complete security audit. -Open a pull request that changes the pinned commit and any metadata affected by that release. Include -a short changelog and rerun both local and remote verification. Never replace source at an existing -tag to bypass registry review. +## Update or remove a Plugin -## Improve this repository +The owner directory identifies the maintainer. Submit changes under the same path and explain user +impact. A Plugin may be quarantined or removed if it becomes malicious, abandoned, misleading, or +unsafe. -For validator, schema, example, documentation, or workflow changes: +## Improve the platform -1. Open an issue for contract-breaking changes. -2. Keep the change focused and add or update automated tests. -3. Run `npm run check`. -4. Explain user impact and migration requirements in the pull request. +Validator, documentation, example, and workflow changes are welcome. Open an issue before a +contract-breaking change, add focused tests, and describe migration impact. -Contributions to this repository are licensed under Apache-2.0. External plugin repositories retain -their own licenses. +Repository contributions are licensed under Apache-2.0. Each hosted Plugin carries its own license. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6910f13..90cff55 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,14 +1,14 @@ # Governance -MCode Plugins is maintained in the open. The initial maintainers are responsible for registry -policy, compatibility contracts, releases of the validation toolkit, and time-sensitive security -actions. Plugin authors retain responsibility for their own repositories and users. +MiniMax Code Plugins is maintained in the open. Maintainers own contribution policy, compatibility +contracts, validation tooling, review queues, and time-sensitive security actions. Plugin authors +own the code and user support under their `plugins//` path. ## Decision principles 1. Match documented MiniMax Code runtime behavior before expanding the catalog format. 2. Prefer portable Agent Plugins and Agent Skills contracts over host-specific invention. -3. Keep submissions reproducible by pinning immutable source. +3. Keep the contribution path to one hosted folder and one pull request. 4. Make dependencies, data access, network access, and maintenance ownership visible. 5. Use evidence from real users and contributors; plugin count and stars are not success metrics. @@ -17,4 +17,5 @@ migration note. Security removals and obvious malicious submissions may be handl documented after users are protected. The project may introduce additional reviewer and maintainer roles as contribution volume grows. -No contributor gains authority over external plugin code merely because it is listed here. +No contributor gains authority over another owner's Plugin because their own contribution is hosted +here. diff --git a/README.md b/README.md index 259bc35..37e4c6c 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,123 @@ -# MCode Plugins +

+ MiniMax Code Plugins — one folder, one pull request, a new agent superpower +

+ +

+ 简体中文 · + Contribute · + Plugin contract · + Security +

+ +

+ Build status + Agent Plugins 1.0 + Apache-2.0 license + Pull requests welcome +

+ +## One folder is the release + +MiniMax Code Plugins is the community home for Agent Plugins that run in MiniMax Code. Put a +portable Plugin under `plugins//`, open a pull request, and let CI check +the package users will actually install. -[简体中文](README.zh-CN.md) +```text +fork → create → build → check → pull request → discover +``` -MCode Plugins is the community registry and contribution toolkit for plugins that run in MiniMax -Code. Plugin authors keep their source and release history in their own GitHub repositories. This -repository provides a searchable catalog, a pinned and reviewable submission format, offline -validation, compatibility guidance, and a shared contribution process. +No second repository. No catalog JSON. No commit pin to copy. Your Plugin source, docs, review, and +history live together. -> Status: community preview. A catalog entry means the package passed automated compatibility -> checks at the pinned commit. It is not an endorsement, security audit, or guarantee by MiniMax. +## Ship your first Plugin -## Why this repository exists +```bash +git clone https://github.com//MiniMax-Code-Plugins.git +cd MiniMax-Code-Plugins +npm install +npm run create -- /my-first-plugin +``` -A useful plugin ecosystem needs more than a list of links. It needs a complete path from creation to -real use: +The scaffold gives you a Skill-first Plugin: ```text -create -> validate -> submit -> review -> discover -> install -> feedback -> maintain +plugins//my-first-plugin/ +├── plugin.json +├── README.md +├── LICENSE +└── skills/ + └── my-first-plugin/ + └── SKILL.md ``` -MCode Plugins keeps that path open and auditable: +Replace every `TODO`, then run: -- Authors own their plugin repository, issues, license, and release cadence. -- Catalog entries pin a full commit SHA, so review and installation refer to immutable source. -- CI validates the exact MCode-compatible surface instead of inferring support from a README. -- Capability and security metadata make dependencies and network access visible before install. -- Users report problems to the plugin author; registry policy issues stay in this repository. +```bash +npm run check +``` -## Supported plugin surface +If it passes, open one pull request for that Plugin. Start with +[`CONTRIBUTING.md`](CONTRIBUTING.md) when you want the full review checklist. -The first public contract intentionally stays small: +## What can a Plugin add? -- `plugin.json` using [Agent Plugins 1.0](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) -- zero or more Agent Skills at `skills//SKILL.md` -- an optional `mcp.json` with `stdio`, `streamable-http`, or `sse` servers +### Skills -MiniMax Code does not currently promise Plugin Hooks, custom Agents, Commands, LSP servers, Apps, -generic OAuth configuration, or arbitrary `extensions`. A cross-client plugin may contain those -components, but its catalog entry must describe only the capabilities that MCode can load. See -[Plugin compatibility](docs/plugin-compatibility.md). +Package reusable instructions, workflows, and domain knowledge. Skills are the fastest path from a +good prompt pattern to a capability anyone can install. -## Create a plugin +### MCP servers -Start from the Skill-only [`examples/hello-mcode`](examples/hello-mcode) or the dependency-free -stdio [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp): +Connect MiniMax Code to local tools or remote services with `stdio`, `streamable-http`, or `sse`. +Dependencies, accounts, network destinations, and data handling must be visible before install. + +### Both + +Use a Skill to teach the workflow and MCP to provide the tools. The portable package stays small: ```text -my-plugin/ +plugin-root/ ├── plugin.json ├── mcp.json # optional -└── skills/ - └── my-skill/ - └── SKILL.md +└── skills/ # optional ``` -Keep the plugin in its own public GitHub repository. Then fork this registry and generate a pinned -entry: +This repository is for **Agent capabilities**. TUI Extensions are a separate system and are not +loaded from this package format. -```bash -npm install -npm run add -- https://github.com/you/my-plugin --path optional/subdirectory -npm run check -npm run verify -- registry/my-plugin.json -``` +## The gate is simple -The generator resolves the repository's current default-branch commit, reads the package, and writes -a draft registry entry. Review the generated categories and security metadata before opening a pull -request. +A contribution must: -## Submit an existing plugin +- live at `plugins//`; +- include `plugin.json`, `README.md`, and `LICENSE`; +- expose at least one valid Skill or MCP server; +- document a copyable example, requirements, network access, and data use; +- contain no secrets, private endpoints, hidden telemetry, native binaries, or symlinks; +- pass `npm run check` and human review. -1. Make the plugin source public and add a recognized open-source license. -2. Ensure the root (or declared subdirectory) contains a valid `plugin.json`. -3. Generate a registry entry pinned to a 40-character Git commit SHA. -4. Run `npm run check` and `npm run verify -- registry/.json`. -5. Open a pull request using the checklist in [CONTRIBUTING.md](CONTRIBUTING.md). +Passing review means the Plugin is available as community software. It is not a MiniMax endorsement +or a complete security audit. Read the source and requested capabilities before installing. -Review focuses on reproducibility, MCode compatibility, transparent dependencies, least privilege, -and a runnable example. It does not transfer maintenance ownership to the registry maintainers. +## Explore the project -## Trust model +- [`plugins/`](plugins/) — community Plugin source +- [`examples/hello-mcode`](examples/hello-mcode/) — smallest Skill Plugin +- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/) — dependency-free stdio MCP +- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md) — exact supported contract +- [`docs/security-model.md`](docs/security-model.md) — validation and trust model +- [`docs/architecture.md`](docs/architecture.md) — hosted contribution architecture +- [`GOVERNANCE.md`](GOVERNANCE.md) — decisions and maintainer responsibilities -Catalog packages are community code. Read the plugin source and requested capabilities before use. -Never put credentials directly in `plugin.json`, `mcp.json`, a Skill, or a registry entry. A plugin -may invoke local executables or remote services; those dependencies remain the plugin author's -responsibility. See [Security](SECURITY.md) and the [security model](docs/security-model.md). +## Community preview -## Project layout +The contract is intentionally narrow while MiniMax Code's public Plugin surface stabilizes. Hooks, +custom Agents, Commands, LSP, Apps, generic OAuth, and TUI Extensions are not advertised as current +Agent Plugin capabilities. -```text -registry/ pinned plugin catalog entries -schemas/ machine-readable registry entry contract -scripts/ dependency-free validation and submission tools -examples/ known-good plugin packages -docs/ compatibility, architecture, governance, and security notes -``` +Bring one useful capability. Make the example undeniable. Ship it in one pull request. ## License -Registry code and documentation are licensed under Apache-2.0. Every external plugin keeps its own -license; inclusion in the catalog does not relicense it. +Repository tooling and documentation use Apache-2.0. Every hosted Plugin includes and declares its +own open-source license. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e61806..b77b9a4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,64 +1,118 @@ -# MCode Plugins +

+ MiniMax Code Plugins:一个目录、一个 PR,给 Agent 一项新能力 +

-[English](README.md) +

+ English · + 贡献指南 · + Plugin 契约 · + 安全 +

-MCode Plugins 是面向 MiniMax Code 的社区插件索引与贡献工具集。插件作者继续在自己的 GitHub -仓库中维护源码、Issue、License 和发布节奏;本仓库只负责可检索索引、固定 commit 的可审查提交格式、 -离线校验、兼容性说明和统一贡献流程。 +

+ 构建状态 + Agent Plugins 1.0 + Apache-2.0 License + 欢迎提交 PR +

-> 当前状态:社区预览。插件进入索引,只代表其固定 commit 通过了自动兼容性检查;不代表 MiniMax -> 背书、完成安全审计或提供可用性保证。 +## 一个目录,就是一个发布单元 -## 为什么不是一个巨型插件仓库 +MiniMax Code Plugins 是 MiniMax Code Agent Plugin 的社区入口。把 Plugin 放进 +`plugins//`,提交一个 PR,CI 会直接检查用户最终安装的那份代码。 -一个真正能运转的生态,需要打通完整链路: +```text +Fork → 创建 → 开发 → 校验 → Pull Request → 被发现 +``` + +不用另建仓库,不用写 Catalog JSON,也不用手抄 commit SHA。源码、文档、Review 和修改历史都在 +一个地方。 + +## 30 秒创建第一个 Plugin + +```bash +git clone https://github.com/<你的用户名>/MiniMax-Code-Plugins.git +cd MiniMax-Code-Plugins +npm install +npm run create -- <你的用户名>/my-first-plugin +``` + +脚手架会生成一个 Skill-first Plugin: ```text -创作 -> 校验 -> 提交 -> Review -> 发现 -> 安装 -> 反馈 -> 维护 +plugins/<你的用户名>/my-first-plugin/ +├── plugin.json +├── README.md +├── LICENSE +└── skills/ + └── my-first-plugin/ + └── SKILL.md ``` -本仓库采用“作者自持源码 + 中央索引”的方式: +替换全部 `TODO`,然后运行: -- 作者保留插件仓库、Issue、License 和版本的所有权; -- 索引固定完整 Git commit SHA,Review 和安装对应同一份不可变源码; -- CI 校验 MCode 实际支持的能力,不根据 README 名称猜测兼容性; -- 能力与安全元数据在安装前展示依赖、可执行程序和网络访问; -- 插件问题回到作者仓库,索引规则与治理问题留在本仓库。 +```bash +npm run check +``` -## 首版公开契约 +通过后,为这个 Plugin 提交一个 PR。完整 Review 要求见 +[`CONTRIBUTING.md`](CONTRIBUTING.md)。 -首版只承诺已经有稳定 Runtime ownership 的能力: +## Plugin 能给 Agent 加什么? -- 根目录 `plugin.json`,遵循 [Agent Plugins 1.0](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json); -- `skills//SKILL.md` 下的 Agent Skill; -- 可选的 `mcp.json`,支持 `stdio`、`streamable-http`、`sse`。 +### Skills -目前不把 Plugin Hook、自定义 Agent、Command、LSP、App、通用 OAuth 或任意 `extensions` -宣传为 MCode 能力。跨客户端插件可以包含这些内容,但索引只描述 MCode 实际可加载的部分。完整边界见 -[兼容性说明](docs/plugin-compatibility.md)。 +把可复用的指令、工作流和领域知识打包。一个验证过的提示词方法,可以直接变成任何人都能安装的能力。 -## 创建并提交插件 +### MCP Servers -从 Skill-only 的 [`examples/hello-mcode`](examples/hello-mcode) 或无第三方依赖的 stdio -[`examples/hello-mcode-mcp`](examples/hello-mcode-mcp) 复制一个最小可用包,把插件放在自己的公开 -GitHub 仓库,然后在本仓库运行: +通过 `stdio`、`streamable-http` 或 `sse` 连接本地工具和远程服务。依赖、账号、网络目标和数据处理必须 +在安装前说清楚。 -```bash -npm install -npm run add -- https://github.com/you/my-plugin --path optional/subdirectory -npm run check -npm run verify -- registry/my-plugin.json +### Skill + MCP + +Skill 教会 Agent 怎么做,MCP 给它真正的工具。可移植包结构保持简单: + +```text +plugin-root/ +├── plugin.json +├── mcp.json # 可选 +└── skills/ # 可选 ``` -生成器会固定默认分支当前的 commit,读取插件能力并生成索引草稿。提交 PR 前,请人工核对分类、运行依赖、 -网络访问和安全说明。详细要求见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +这个仓库只承接 **Agent 能力**。TUI Extension 是另一套独立扩展体系,不使用这里的包格式和加载流程。 + +## 门槛也很简单 + +一个贡献必须: + +- 位于 `plugins//`; +- 包含 `plugin.json`、`README.md` 和 `LICENSE`; +- 至少提供一个有效的 Skill 或 MCP Server; +- 写清示例、依赖、网络访问和数据用途; +- 不包含密钥、私有地址、隐藏遥测、原生二进制或 symlink; +- 通过 `npm run check` 和人工 Review。 + +通过 Review 代表它可以作为社区软件被发现,不代表 MiniMax 背书或已经完成完整安全审计。安装前仍需阅读 +源码和能力声明。 + +## 逛逛这个仓库 + +- [`plugins/`](plugins/):社区 Plugin 源码 +- [`examples/hello-mcode`](examples/hello-mcode/):最小 Skill Plugin +- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/):零依赖 stdio MCP +- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md):当前支持的精确契约 +- [`docs/security-model.md`](docs/security-model.md):校验与信任模型 +- [`docs/architecture.md`](docs/architecture.md):中央托管架构 +- [`GOVERNANCE.md`](GOVERNANCE.md):决策与维护者职责 + +## Community Preview -## 信任边界 +MiniMax Code 的公开 Plugin 能力仍在稳定中,所以首版契约刻意保持克制。Hooks、自定义 Agent、Commands、 +LSP、Apps、通用 OAuth 和 TUI Extension 暂不作为当前 Agent Plugin 能力宣传。 -索引里的插件仍是社区代码。使用前应阅读源码和能力声明,不要在 `plugin.json`、`mcp.json`、Skill 或索引 -条目中直接写入凭证。插件可能调用本机可执行程序或远程服务,其依赖和服务质量仍由插件作者负责。参见 -[SECURITY.md](SECURITY.md) 和 [安全模型](docs/security-model.md)。 +带来一个真的有用的能力,给出一个无法误解的示例,然后用一个 PR 把它发布出来。 ## License -索引工具与文档使用 Apache-2.0。外部插件保留各自的 License;被索引不会改变插件原有授权。 +仓库工具和文档使用 Apache-2.0。每个托管 Plugin 都必须包含并声明自己的开源 License。 diff --git a/SECURITY.md b/SECURITY.md index 613dce7..01ac6f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,15 +7,14 @@ public issue. Use the repository's private vulnerability reporting feature when private reporting is not configured, contact the repository owner through the security contact shown on the GitHub repository page before sharing technical details. -Include the affected registry entry and pinned commit, impact, reproduction conditions, and a safe -way to validate the fix. Registry maintainers may temporarily remove or disable an entry while a -report is investigated. +Include the affected `plugins//` path, commit, impact, reproduction conditions, and a +safe way to validate the fix. Maintainers may quarantine or remove a Plugin while a report is +investigated. ## Scope -This policy covers the registry validator, contribution automation, and metadata in this repository. -Each external plugin is maintained and released by its author. Report a plugin implementation flaw -to the author's security channel as well as notifying this registry when users may be exposed. +This policy covers hosted Plugins, validation tooling, contribution automation, and repository +metadata. Notify repository maintainers when a Plugin implementation may expose users. Never include live credentials in a report. Revoke and rotate any credential that may have been exposed. diff --git a/assets/hero.svg b/assets/hero.svg new file mode 100644 index 0000000..1806982 --- /dev/null +++ b/assets/hero.svg @@ -0,0 +1,45 @@ + + MiniMax Code Plugins + One folder, one pull request, a new agent superpower. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MiniMax Code Plugins + One folder. One pull request. A new agent superpower. + + + SKILLS + + MCP + + OPEN SOURCE + + + diff --git a/docs/architecture.md b/docs/architecture.md index 391dfe2..e44624e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,27 +1,25 @@ -# Registry architecture +# Hosted Plugin architecture -MCode Plugins separates plugin ownership from discovery. +MiniMax Code Plugins keeps contribution and review in one repository. ```text -author repository - plugin.json + Skills + optional MCP + license + releases +plugins// + plugin.json + README + LICENSE + Skills and/or MCP | - | immutable repository URL + commit SHA + path v -MCode Plugins registry - metadata -> static validation -> remote source verification -> human review +local validation -> pull request CI -> human review | v -catalog consumers / MiniMax Code discovery +main branch -> catalog consumers / MiniMax Code discovery ``` -This keeps the contribution path lightweight without turning one repository into the owner of every -plugin. It also lets reviewers inspect the same immutable source users are told about. +The hosted directory is the publication unit. Reviewers inspect the exact files that enter `main`; +contributors do not create a second repository or maintain a separate catalog record. -Registry entries are intentionally declarative. They do not execute plugin code during static -validation. Remote verification reads `plugin.json`, declared Skill files, and optional `mcp.json` -from GitHub at the pinned commit. Runtime behavior and external service quality require separate -review and testing. +Static validation reads package metadata and text contracts without executing Plugin code. Human +review covers usefulness, dependencies, data flow, and reproducibility. MCP runtime behavior and +external service quality still require explicit test evidence. -The initial catalog does not claim a direct production Marketplace publishing API. Consumers may -use the registry as a source of reviewed metadata while MiniMax Code distribution evolves. +The first version intentionally optimizes for a low-friction community path. External source +registries, release mirroring, and Marketplace publishing interfaces can be proposed later without +changing the portable Agent Plugin package itself. diff --git a/docs/plugin-compatibility.md b/docs/plugin-compatibility.md index 2d8b755..b7fcf0a 100644 --- a/docs/plugin-compatibility.md +++ b/docs/plugin-compatibility.md @@ -6,6 +6,8 @@ MiniMax Code reads the portable subset of Agent Plugins 1.0: ```text plugin-root/ +├── README.md # required by this community repository +├── LICENSE # required by this community repository ├── plugin.json ├── mcp.json # optional └── skills/ @@ -63,5 +65,6 @@ The following are not currently public MCode Plugin capabilities: - generic OAuth setup - host-specific fields hidden in `extensions` -Cross-client repositories may contain extra assets, but catalog metadata and examples must not imply -that MiniMax Code loads unsupported components. +Hosted contributions may contain extra assets, but documentation must not imply that MiniMax Code +loads unsupported components. TUI Extensions are a separate product extension system, not an Agent +Plugin capability. diff --git a/docs/security-model.md b/docs/security-model.md index 25a7580..f7b2649 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -1,19 +1,17 @@ # Security model -The registry uses three layers of evidence: +Hosted Plugins use three layers of evidence: -1. **Static entry validation** checks metadata, immutable commit pins, paths, and declared - capabilities without executing plugin code. -2. **Remote source verification** reads the package at the pinned GitHub commit and compares the - registry claims with `plugin.json`, `mcp.json`, and Skill frontmatter. -3. **Human review** evaluates usefulness, dependency and data-flow disclosures, suspicious code, - maintenance ownership, and reproducible test evidence. +1. **Package validation** checks the owner/name layout, Manifest, Skill and MCP contracts, required + documentation, unfinished placeholders, and symlinks without executing Plugin code. +2. **Pull request evidence** makes every source change reviewable before it reaches `main`. +3. **Human review** evaluates usefulness, dependencies, data flow, suspicious code, maintenance + ownership, and reproducible test evidence. These checks reduce ambiguity but are not a sandbox or full audit. `stdio` MCP servers execute local -programs with the user's permissions. Remote MCP servers send data to their configured destination. -Skills can instruct an agent to use tools and change files. Users must review capabilities and trust -the author before installing community code. +programs with the user's permissions. Remote MCP servers send data to configured destinations. +Skills can instruct an agent to use tools and change files. -Registry entries pin source because mutable branches and tags are not sufficient review anchors. -Updates require a new commit pin and another pull request. Credentials are never valid registry or -package content; use runtime-supported secret and environment mechanisms instead. +Never commit credentials, private endpoints, or personal data. Use runtime-supported secret and +environment mechanisms. Maintainers may quarantine or remove a Plugin while a security report is +investigated. diff --git a/package-lock.json b/package-lock.json index 5cd8dbb..497688a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "mcode-plugins-registry", + "name": "minimax-code-plugins", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mcode-plugins-registry", + "name": "minimax-code-plugins", "version": "0.1.0", "license": "Apache-2.0", "engines": { diff --git a/package.json b/package.json index ba0cb41..77520b5 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,17 @@ { - "name": "mcode-plugins-registry", + "name": "minimax-code-plugins", "version": "0.1.0", "private": true, - "description": "Community registry and contribution toolkit for MiniMax Code plugins.", + "description": "The community home for MiniMax Code Agent Plugins.", "type": "module", "engines": { "node": ">=22" }, "scripts": { - "add": "node scripts/add-plugin.mjs", + "create": "node scripts/create-plugin.mjs", "check": "npm run validate && npm test", "test": "node --test", - "validate": "node scripts/validate.mjs", - "verify": "node scripts/verify-plugin.mjs" + "validate": "node scripts/validate.mjs" }, "license": "Apache-2.0" } diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..f05168d --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,23 @@ +# Community Plugins + +This directory is the source of truth for community Agent Plugins published through this repository. + +```text +plugins/ +└── / + └── / + ├── plugin.json + ├── README.md + ├── LICENSE + ├── mcp.json # optional + └── skills/ # optional +``` + +Create a contribution from the repository root: + +```bash +npm run create -- / +``` + +Replace every `TODO`, run `npm run check`, and open one pull request for one Plugin. See +[`CONTRIBUTING.md`](../CONTRIBUTING.md) for review and security requirements. diff --git a/proposals/README.md b/proposals/README.md index c2fc7d6..ee8f249 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -1,8 +1,11 @@ # Capability proposals -This directory records evidence and design proposals for capabilities outside the portable Agent -Plugins 1.0 surface, such as Hooks, TUI or UI extensions, custom Agents, Commands, LSP, and OAuth. +This directory records evidence and design proposals for future Agent capabilities outside the +portable Agent Plugins 1.0 surface, such as Hooks, custom Agents, Commands, LSP, and OAuth. A proposal is not a supported plugin capability. It must identify real user workflows, define host ownership and security boundaries, explain cross-client portability, and link to implementation and conformance evidence before documentation can present it as available. + +TUI Extensions are a separate product extension system. They do not enter this proposal or package +contract. diff --git a/registry/README.md b/registry/README.md deleted file mode 100644 index 21a55b2..0000000 --- a/registry/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Registry entries - -Each plugin has one JSON file named after its Agent Plugins manifest name. The entry points to a -public GitHub repository, a full immutable commit SHA, and an optional plugin subdirectory. - -Do not add hand-written entries unless the generator cannot represent a valid package: - -```bash -npm run add -- https://github.com// -``` - -The JSON Schema is [`../schemas/registry-entry.schema.json`](../schemas/registry-entry.schema.json). -An empty registry is valid; the first community plugins should enter through reviewed pull requests. diff --git a/schemas/registry-entry.schema.json b/schemas/registry-entry.schema.json deleted file mode 100644 index 258bef6..0000000 --- a/schemas/registry-entry.schema.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "MCode Plugins registry entry", - "type": "object", - "additionalProperties": false, - "required": [ - "schemaVersion", - "name", - "repository", - "commit", - "path", - "summary", - "license", - "maintainers", - "categories", - "capabilities", - "requirements", - "dataAndNetwork", - "lifecycle" - ], - "properties": { - "schemaVersion": { "const": 1 }, - "name": { - "type": "string", - "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", - "maxLength": 64 - }, - "repository": { - "type": "string", - "pattern": "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" - }, - "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "path": { - "type": "string", - "pattern": "^(?:\\.|(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+)$" - }, - "summary": { "type": "string", "minLength": 20, "maxLength": 280 }, - "license": { "type": "string", "minLength": 1, "maxLength": 100 }, - "maintainers": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$" } - }, - "categories": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "enum": ["coding", "data", "design", "developer-tools", "productivity", "research", "other"] - } - }, - "capabilities": { - "type": "object", - "additionalProperties": false, - "required": ["skills", "mcpServers"], - "properties": { - "skills": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, - "mcpServers": { "type": "array", "uniqueItems": true, "items": { "type": "string" } } - } - }, - "requirements": { - "type": "object", - "additionalProperties": false, - "required": ["executables", "accounts", "platforms"], - "properties": { - "executables": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, - "accounts": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, - "platforms": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { "enum": ["macos", "windows", "linux"] } - } - } - }, - "dataAndNetwork": { - "type": "object", - "additionalProperties": false, - "required": ["networkAccess", "destinations", "dataHandled"], - "properties": { - "networkAccess": { "type": "boolean" }, - "destinations": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, - "dataHandled": { "type": "array", "uniqueItems": true, "items": { "type": "string" } } - } - }, - "lifecycle": { - "type": "object", - "additionalProperties": false, - "required": ["disableBehavior", "uninstallBehavior"], - "properties": { - "disableBehavior": { "type": "string", "minLength": 20, "maxLength": 500 }, - "uninstallBehavior": { "type": "string", "minLength": 20, "maxLength": 500 } - } - } - } -} diff --git a/scripts/add-plugin.mjs b/scripts/add-plugin.mjs deleted file mode 100644 index 1280a03..0000000 --- a/scripts/add-plugin.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { parseJson, validateMcp, validatePluginManifest, validateSkillText } from './lib/validation.mjs'; - -const [repositoryUrl, ...options] = process.argv.slice(2); -if (!repositoryUrl) { - console.error('Usage: npm run add -- https://github.com// [--ref ] [--path ]'); - process.exit(2); -} - -const repositoryMatch = repositoryUrl.replace(/\.git$/u, '').match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/u); -if (!repositoryMatch) throw new Error('Only canonical public GitHub repository URLs are supported.'); -const owner = repositoryMatch[1]; -const repository = repositoryMatch[2]; -const pluginPath = readOption('--path') ?? '.'; -let commit = readOption('--ref'); -const headers = { accept: 'application/vnd.github+json', 'user-agent': 'mcode-plugins-registry' }; -if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; - -if (!commit) { - const metadata = await githubJson(`/repos/${owner}/${repository}`); - const branch = await githubJson(`/repos/${owner}/${repository}/branches/${encodeURIComponent(metadata.default_branch)}`); - commit = branch.commit.sha; -} -if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error('--ref must resolve to a full lowercase commit SHA.'); - -const prefix = pluginPath === '.' ? '' : `${pluginPath.replace(/\/$/u, '')}/`; -const manifest = validatePluginManifest(await rawJson(`${prefix}plugin.json`)); -if (!manifest.license) throw new Error('plugin.json must declare an open-source license before registry submission.'); -const skills = []; -const skillsResponse = await github(`/repos/${owner}/${repository}/contents/${prefix}skills?ref=${commit}`); -if (skillsResponse.status !== 404) { - if (!skillsResponse.ok) throw new Error(`Cannot list Skills: HTTP ${skillsResponse.status}`); - const children = await skillsResponse.json(); - for (const child of children.filter((item) => item.type === 'dir').sort((a, b) => a.name.localeCompare(b.name))) { - const skillResponse = await raw(`${prefix}skills/${child.name}/SKILL.md`); - if (!skillResponse.ok) continue; - validateSkillText(await skillResponse.text(), child.name, `skills/${child.name}/SKILL.md`); - skills.push(child.name); - } -} - -let mcpServers = []; -let networkAccess = false; -const mcpResponse = await raw(`${prefix}mcp.json`); -if (mcpResponse.ok) { - const mcp = parseJson(await mcpResponse.text(), 'mcp.json'); - mcpServers = validateMcp(mcp); - networkAccess = Object.values(mcp.mcpServers).some((server) => server.type !== 'stdio'); -} -else if (mcpResponse.status !== 404) throw new Error(`Cannot read mcp.json: HTTP ${mcpResponse.status}`); -if (skills.length + mcpServers.length === 0) throw new Error('Plugin exposes no MCode-compatible Skill or MCP server.'); - -const entry = { - schemaVersion: 1, - name: manifest.name, - repository: `https://github.com/${owner}/${repository}`, - commit, - path: pluginPath, - summary: normalizeSummary(manifest.description ?? `${manifest.name} adds reusable capabilities to MiniMax Code.`), - license: manifest.license, - maintainers: [owner], - categories: ['other'], - capabilities: { skills, mcpServers }, - requirements: { executables: [], accounts: [], platforms: ['macos', 'windows', 'linux'] }, - dataAndNetwork: { networkAccess, destinations: [], dataHandled: [] }, - lifecycle: { - disableBehavior: 'Review the plugin implementation and describe what remains after disable.', - uninstallBehavior: 'Review the plugin implementation and describe files or remote data left after uninstall.', - }, -}; -const output = path.resolve('registry', `${manifest.name}.json`); -await mkdir(path.dirname(output), { recursive: true }); -await writeFile(output, `${JSON.stringify(entry, null, 2)}\n`, { flag: 'wx' }); -console.log(`Created ${output}`); -console.log('Review categories, requirements, platforms, network destinations, and dataHandled before submitting.'); - -function readOption(name) { - const index = options.indexOf(name); - if (index === -1) return undefined; - if (!options[index + 1]) throw new Error(`${name} requires a value`); - return options[index + 1]; -} - -function normalizeSummary(value) { - const compact = value.replace(/\s+/gu, ' ').trim(); - return compact.length >= 20 ? compact.slice(0, 280) : `${compact} for MiniMax Code users.`; -} - -async function github(apiPath) { - return fetch(`https://api.github.com${apiPath}`, { headers }); -} - -async function githubJson(apiPath) { - const response = await github(apiPath); - if (!response.ok) throw new Error(`GitHub API ${apiPath}: HTTP ${response.status}`); - return response.json(); -} - -function raw(relativePath) { - return fetch(`https://raw.githubusercontent.com/${owner}/${repository}/${commit}/${relativePath}`); -} - -async function rawJson(relativePath) { - const response = await raw(relativePath); - if (!response.ok) throw new Error(`Cannot read ${relativePath}: HTTP ${response.status}`); - return parseJson(await response.text(), relativePath); -} diff --git a/scripts/create-plugin.mjs b/scripts/create-plugin.mjs new file mode 100644 index 0000000..3f02153 --- /dev/null +++ b/scripts/create-plugin.mjs @@ -0,0 +1,50 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const contribution = process.argv[2]; +if (!contribution) { + console.error('Usage: npm run create -- /'); + process.exit(2); +} + +const match = contribution.match(/^([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})\/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/u); +if (!match || match[2].length > 64 || match[2].includes('..') || match[2].includes('--')) { + throw new Error('Plugin path must be /.'); +} + +const [, owner, pluginName] = match; +const title = pluginName.split(/[.-]/u).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(' '); +const destination = path.resolve('plugins', owner, pluginName); +const skillRoot = path.join(destination, 'skills', pluginName); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +await mkdir(path.dirname(destination), { recursive: true }); +try { + await mkdir(destination); +} catch (error) { + if (error.code === 'EEXIST') throw new Error(`${path.relative(process.cwd(), destination)} already exists.`); + throw error; +} +await mkdir(skillRoot, { recursive: true }); +await Promise.all([ + write('plugin.json', `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: pluginName, + version: '0.1.0', + description: `TODO: Describe what ${title} helps MiniMax Code users accomplish.`, + author: { name: owner, url: `https://github.com/${owner}` }, + license: 'Apache-2.0', + keywords: ['minimax-code', 'plugin'], + }, null, 2)}\n`), + write('README.md', `# ${title}\n\n> TODO: Explain the user problem this Plugin solves.\n\n## Try it\n\n\`\`\`text\nTODO: Add an example prompt.\n\`\`\`\n\n## Requirements\n\n- None.\n\n## Data and network\n\n- No network access.\n- No credentials required.\n`), + write('LICENSE', await readFile(path.join(repositoryRoot, 'LICENSE'), 'utf8')), + write(path.join('skills', pluginName, 'SKILL.md'), `---\nname: ${pluginName}\ndescription: TODO Describe what this Skill does and when MiniMax Code should use it.\n---\n\n# ${title}\n\nTODO: Add clear, executable instructions for the agent.\n`), +]); + +console.log(`Created ${path.relative(process.cwd(), destination)}`); +console.log('Next: replace TODOs, run npm run check, then open a pull request.'); + +function write(relativePath, contents) { + return writeFile(path.join(destination, relativePath), contents, { flag: 'wx' }); +} diff --git a/scripts/lib/validation.mjs b/scripts/lib/validation.mjs index f628659..cc2a324 100644 --- a/scripts/lib/validation.mjs +++ b/scripts/lib/validation.mjs @@ -5,19 +5,8 @@ export const PLUGIN_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/plugin.sch export const MCP_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; const PLUGIN_NAME = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const OWNER_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/u; const SKILL_NAME = /^(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const SHA = /^[0-9a-f]{40}$/u; -const GITHUB_REPOSITORY = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; -const CATEGORIES = new Set([ - 'coding', - 'data', - 'design', - 'developer-tools', - 'productivity', - 'research', - 'other', -]); -const PLATFORMS = new Set(['macos', 'windows', 'linux']); const PLUGIN_FIELDS = new Set([ '$schema', 'name', @@ -113,51 +102,6 @@ export function validateMcp(value, label = 'mcp.json') { return entries.map(([name]) => name).sort(); } -function stringArray(value, label, { min = 0, allowed } = {}) { - assert(Array.isArray(value) && value.length >= min, `${label}: must be an array with at least ${min} item(s)`); - assert(value.every((item) => typeof item === 'string' && item.length > 0), `${label}: must contain non-empty strings`); - assert(new Set(value).size === value.length, `${label}: duplicate values are not allowed`); - if (allowed) assert(value.every((item) => allowed.has(item)), `${label}: contains an unsupported value`); -} - -export function validateRegistryEntry(value, label = 'registry entry') { - assert(isRecord(value), `${label}: root must be an object`); - const fields = new Set(['schemaVersion', 'name', 'repository', 'commit', 'path', 'summary', 'license', 'maintainers', 'categories', 'capabilities', 'requirements', 'dataAndNetwork', 'lifecycle']); - assert(Object.keys(value).every((key) => fields.has(key)), `${label}: unknown field`); - assert(Object.keys(value).length === fields.size, `${label}: required fields are missing`); - assert(value.schemaVersion === 1, `${label}: schemaVersion must be 1`); - assert(typeof value.name === 'string' && PLUGIN_NAME.test(value.name) && value.name.length <= 64, `${label}: invalid name`); - assert(typeof value.repository === 'string' && GITHUB_REPOSITORY.test(value.repository), `${label}: repository must be a canonical public GitHub URL`); - assert(typeof value.commit === 'string' && SHA.test(value.commit), `${label}: commit must be a full lowercase SHA`); - assert(typeof value.path === 'string' && (value.path === '.' || /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/u.test(value.path)), `${label}: invalid plugin path`); - assert(typeof value.summary === 'string' && value.summary.length >= 20 && value.summary.length <= 280, `${label}: summary must be 20-280 characters`); - assert(typeof value.license === 'string' && value.license.length >= 1 && value.license.length <= 100, `${label}: license is required`); - stringArray(value.maintainers, `${label}.maintainers`, { min: 1 }); - stringArray(value.categories, `${label}.categories`, { min: 1, allowed: CATEGORIES }); - assert(isRecord(value.capabilities), `${label}.capabilities: must be an object`); - assert(Object.keys(value.capabilities).sort().join(',') === 'mcpServers,skills', `${label}.capabilities: requires only mcpServers and skills`); - stringArray(value.capabilities.skills, `${label}.capabilities.skills`); - stringArray(value.capabilities.mcpServers, `${label}.capabilities.mcpServers`); - assert(value.capabilities.skills.length + value.capabilities.mcpServers.length > 0, `${label}: at least one MCode capability is required`); - assert(isRecord(value.requirements), `${label}.requirements: must be an object`); - assert(Object.keys(value.requirements).sort().join(',') === 'accounts,executables,platforms', `${label}.requirements: invalid fields`); - stringArray(value.requirements.executables, `${label}.requirements.executables`); - stringArray(value.requirements.accounts, `${label}.requirements.accounts`); - stringArray(value.requirements.platforms, `${label}.requirements.platforms`, { min: 1, allowed: PLATFORMS }); - assert(isRecord(value.dataAndNetwork), `${label}.dataAndNetwork: must be an object`); - assert(Object.keys(value.dataAndNetwork).sort().join(',') === 'dataHandled,destinations,networkAccess', `${label}.dataAndNetwork: invalid fields`); - assert(typeof value.dataAndNetwork.networkAccess === 'boolean', `${label}.dataAndNetwork.networkAccess: must be boolean`); - stringArray(value.dataAndNetwork.destinations, `${label}.dataAndNetwork.destinations`); - stringArray(value.dataAndNetwork.dataHandled, `${label}.dataAndNetwork.dataHandled`); - assert(value.dataAndNetwork.networkAccess || value.dataAndNetwork.destinations.length === 0, `${label}: destinations require networkAccess=true`); - assert(isRecord(value.lifecycle), `${label}.lifecycle: must be an object`); - assert(Object.keys(value.lifecycle).sort().join(',') === 'disableBehavior,uninstallBehavior', `${label}.lifecycle: invalid fields`); - for (const key of ['disableBehavior', 'uninstallBehavior']) { - assert(typeof value.lifecycle[key] === 'string' && value.lifecycle[key].length >= 20 && value.lifecycle[key].length <= 500, `${label}.lifecycle.${key}: must be 20-500 characters`); - } - return value; -} - function isBareCommand(value) { return !/[\\/]/u.test(value); } @@ -206,3 +150,39 @@ export async function validatePluginDirectory(root) { assert(skills.length + mcpServers.length > 0, `${root}: plugin must expose at least one Skill or MCP server`); return { manifest, skills: skills.sort(), mcpServers }; } + +export async function validateHostedPluginDirectory(root, { owner, pluginName }) { + assert(OWNER_NAME.test(owner), `${root}: invalid GitHub owner directory ${owner}`); + assert(PLUGIN_NAME.test(pluginName) && pluginName.length <= 64, `${root}: invalid Plugin directory ${pluginName}`); + await assertNoSymlinks(root); + const result = await validatePluginDirectory(root); + assert(result.manifest.name === pluginName, `${root}: plugin.json name must equal directory name ${pluginName}`); + assert(typeof result.manifest.license === 'string' && result.manifest.license.length > 0, `${root}: plugin.json must declare a license`); + for (const file of ['README.md', 'LICENSE']) { + const contents = await readFile(path.join(root, file), 'utf8'); + assert(contents.trim().length > 0, `${root}: ${file} must not be empty`); + } + for (const file of await listTextContractFiles(root)) { + const contents = await readFile(file, 'utf8'); + assert(!/\bTODO\b/u.test(contents), `${file}: replace every TODO before submission`); + } + return { id: `${owner}/${pluginName}`, ...result }; +} + +async function assertNoSymlinks(root) { + for (const child of await readdir(root, { withFileTypes: true })) { + const file = path.join(root, child.name); + assert(!child.isSymbolicLink(), `${file}: symlinks are not allowed in hosted Plugins`); + if (child.isDirectory()) await assertNoSymlinks(file); + } +} + +async function listTextContractFiles(root) { + const files = []; + for (const child of await readdir(root, { withFileTypes: true })) { + const file = path.join(root, child.name); + if (child.isDirectory()) files.push(...await listTextContractFiles(file)); + else if (child.isFile() && (child.name.endsWith('.md') || ['plugin.json', 'mcp.json'].includes(child.name))) files.push(file); + } + return files; +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index ef2e480..f6b2bb3 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,35 +1,38 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { parseJson, validatePluginDirectory, validateRegistryEntry } from './lib/validation.mjs'; +import { validateHostedPluginDirectory, validatePluginDirectory } from './lib/validation.mjs'; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rootOption = process.argv.indexOf('--root'); +const root = rootOption === -1 + ? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + : path.resolve(process.argv[rootOption + 1] ?? ''); const failures = []; -const identities = new Set(); +let hostedPluginCount = 0; -for (const child of await readdir(path.join(root, 'examples'), { withFileTypes: true })) { +for (const child of await readDirectory(path.join(root, 'examples'))) { if (!child.isDirectory()) continue; await check(`example ${child.name}`, () => validatePluginDirectory(path.join(root, 'examples', child.name))); } -for (const child of await readdir(path.join(root, 'registry'), { withFileTypes: true })) { - if (!child.isFile() || !child.name.endsWith('.json')) continue; - await check(`registry/${child.name}`, async () => { - const file = path.join(root, 'registry', child.name); - const entry = validateRegistryEntry(parseJson(await readFile(file, 'utf8'), file), file); - if (child.name !== `${entry.name}.json`) throw new Error(`${file}: filename must match plugin name`); - const identity = `${entry.repository}#${entry.path}`.toLowerCase(); - if (identities.has(identity)) throw new Error(`${file}: duplicate repository and path`); - identities.add(identity); - }); +for (const owner of await readDirectory(path.join(root, 'plugins'))) { + if (!owner.isDirectory()) continue; + for (const plugin of await readDirectory(path.join(root, 'plugins', owner.name))) { + if (!plugin.isDirectory()) continue; + await check(`plugin ${owner.name}/${plugin.name}`, () => validateHostedPluginDirectory( + path.join(root, 'plugins', owner.name, plugin.name), + { owner: owner.name, pluginName: plugin.name }, + )); + hostedPluginCount += 1; + } } if (failures.length > 0) { for (const failure of failures) console.error(`FAIL ${failure}`); process.exitCode = 1; } else { - console.log(`Validated examples and ${identities.size} registry entr${identities.size === 1 ? 'y' : 'ies'}.`); + console.log(`Validated ${hostedPluginCount} hosted Plugin${hostedPluginCount === 1 ? '' : 's'} and all examples.`); } async function check(label, task) { @@ -40,3 +43,12 @@ async function check(label, task) { failures.push(error.message); } } + +async function readDirectory(directory) { + try { + return await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } +} diff --git a/scripts/verify-plugin.mjs b/scripts/verify-plugin.mjs deleted file mode 100644 index f26a417..0000000 --- a/scripts/verify-plugin.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { parseJson, validateMcp, validatePluginManifest, validateRegistryEntry, validateSkillText } from './lib/validation.mjs'; - -const files = process.argv.slice(2); -if (files.length === 0) { - console.error('Usage: npm run verify -- registry/.json [...]'); - process.exit(2); -} - -for (const file of files) { - const entry = validateRegistryEntry(parseJson(await readFile(file, 'utf8'), file), file); - const { owner, repository } = parseGitHub(entry.repository); - const prefix = entry.path === '.' ? '' : `${entry.path.replace(/\/$/u, '')}/`; - const rawBase = `https://raw.githubusercontent.com/${owner}/${repository}/${entry.commit}/${prefix}`; - const manifest = validatePluginManifest(await fetchJson(`${rawBase}plugin.json`), `${entry.name}/plugin.json`); - if (manifest.name !== entry.name) throw new Error(`${file}: registry name does not match plugin.json`); - if (manifest.license !== entry.license) throw new Error(`${file}: registry license does not match plugin.json`); - - const discoveredSkills = await listSkillDirectories(owner, repository, entry.commit, prefix); - if (discoveredSkills.join(',') !== [...entry.capabilities.skills].sort().join(',')) { - throw new Error(`${file}: declared Skills do not match the pinned source`); - } - for (const skill of discoveredSkills) { - const response = await fetch(`${rawBase}skills/${skill}/SKILL.md`); - if (!response.ok) throw new Error(`${file}: cannot read Skill ${skill}: HTTP ${response.status}`); - validateSkillText(await response.text(), skill, `${entry.name}/skills/${skill}/SKILL.md`); - } - - let mcpServers = []; - const mcpResponse = await fetch(`${rawBase}mcp.json`); - if (mcpResponse.ok) mcpServers = validateMcp(parseJson(await mcpResponse.text(), `${entry.name}/mcp.json`), `${entry.name}/mcp.json`); - else if (mcpResponse.status !== 404) throw new Error(`${file}: cannot read mcp.json: HTTP ${mcpResponse.status}`); - if (mcpServers.join(',') !== [...entry.capabilities.mcpServers].sort().join(',')) { - throw new Error(`${file}: declared MCP servers do not match mcp.json`); - } - console.log(`Verified ${entry.name} at ${entry.commit}.`); -} - -function parseGitHub(url) { - const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/u); - if (!match) throw new Error(`Unsupported repository URL: ${url}`); - return { owner: match[1], repository: match[2] }; -} - -async function fetchJson(url) { - const response = await fetch(url, { headers: { accept: 'application/json' } }); - if (!response.ok) throw new Error(`Cannot read ${url}: HTTP ${response.status}`); - return parseJson(await response.text(), url); -} - -async function listSkillDirectories(owner, repository, commit, prefix) { - const token = process.env.GITHUB_TOKEN; - const headers = { accept: 'application/vnd.github+json', 'user-agent': 'mcode-plugins-registry' }; - if (token) headers.authorization = `Bearer ${token}`; - const encodedPath = `${prefix}skills`.split('/').filter(Boolean).map(encodeURIComponent).join('/'); - const response = await fetch(`https://api.github.com/repos/${owner}/${repository}/contents/${encodedPath}?ref=${commit}`, { headers }); - if (response.status === 404) return []; - if (!response.ok) throw new Error(`Cannot list Skills: HTTP ${response.status}`); - const value = await response.json(); - if (!Array.isArray(value)) throw new Error('Skills path is not a directory'); - const directories = value.filter((item) => item.type === 'dir').map((item) => item.name).sort(); - const discovered = []; - for (const directory of directories) { - const skillUrl = `https://raw.githubusercontent.com/${owner}/${repository}/${commit}/${prefix}skills/${directory}/SKILL.md`; - const skillResponse = await fetch(skillUrl); - if (skillResponse.ok) discovered.push(directory); - else if (skillResponse.status !== 404) throw new Error(`Cannot inspect Skill ${directory}: HTTP ${skillResponse.status}`); - } - return discovered; -} diff --git a/test/hosted-plugins.test.mjs b/test/hosted-plugins.test.mjs new file mode 100644 index 0000000..4e58656 --- /dev/null +++ b/test/hosted-plugins.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { validateHostedPluginDirectory } from '../scripts/lib/validation.mjs'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('contributor can scaffold a hosted Skill plugin with one command', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugins-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + + const { stdout } = await execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'create-plugin.mjs'), 'alice/hello-world'], + { cwd: workspace }, + ); + + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world'); + const manifest = JSON.parse(await readFile(path.join(pluginRoot, 'plugin.json'), 'utf8')); + const readme = await readFile(path.join(pluginRoot, 'README.md'), 'utf8'); + const license = await readFile(path.join(pluginRoot, 'LICENSE'), 'utf8'); + const skill = await readFile(path.join(pluginRoot, 'skills', 'hello-world', 'SKILL.md'), 'utf8'); + + assert.equal(manifest.name, 'hello-world'); + assert.equal(manifest.license, 'Apache-2.0'); + assert.match(readme, /# Hello World/u); + assert.match(license, /Apache License/u); + assert.match(skill, /^---\nname: hello-world\n/mu); + assert.match(stdout, /plugins\/alice\/hello-world/u); +}); + +test('hosted Plugin is valid when its package and contribution docs are complete', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-validation-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world'); + await mkdir(path.join(pluginRoot, 'skills', 'hello-world'), { recursive: true }); + await Promise.all([ + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'hello-world', + version: '1.0.0', + description: 'Greets MiniMax Code users with a reusable Skill.', + license: 'Apache-2.0', + })}\n`), + writeFile(path.join(pluginRoot, 'README.md'), '# Hello World\n\nUse the Skill to greet a user.\n'), + writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'), + writeFile(path.join(pluginRoot, 'skills', 'hello-world', 'SKILL.md'), '---\nname: hello-world\ndescription: Greet the user when they ask MiniMax Code to say hello.\n---\n\n# Instructions\n\nRespond with a friendly greeting.\n'), + ]); + + const result = await validateHostedPluginDirectory(pluginRoot, { + owner: 'alice', + pluginName: 'hello-world', + }); + + assert.equal(result.id, 'alice/hello-world'); + assert.deepEqual(result.skills, ['hello-world']); +}); + +test('scaffold stays review-incomplete until contributor replaces every TODO', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-todo-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + await execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'create-plugin.mjs'), 'alice/hello-world'], + { cwd: workspace }, + ); + + await assert.rejects( + validateHostedPluginDirectory(path.join(workspace, 'plugins', 'alice', 'hello-world'), { + owner: 'alice', + pluginName: 'hello-world', + }), + /replace every TODO/u, + ); +}); + +test('repository check discovers hosted Plugins by owner and directory name', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-repository-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + await execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'create-plugin.mjs'), 'alice/hello-world'], + { cwd: workspace }, + ); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world'); + const manifest = JSON.parse(await readFile(path.join(pluginRoot, 'plugin.json'), 'utf8')); + manifest.description = 'Greets MiniMax Code users with a reusable Skill.'; + await Promise.all([ + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`), + writeFile(path.join(pluginRoot, 'README.md'), '# Hello World\n\nAsk MiniMax Code for a friendly greeting.\n'), + writeFile(path.join(pluginRoot, 'skills', 'hello-world', 'SKILL.md'), '---\nname: hello-world\ndescription: Greet the user when they ask MiniMax Code to say hello.\n---\n\n# Instructions\n\nRespond with a friendly greeting.\n'), + ]); + + const { stdout } = await execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'validate.mjs'), '--root', workspace], + { cwd: workspace }, + ); + + assert.match(stdout, /OK plugin alice\/hello-world/u); + assert.match(stdout, /Validated 1 hosted Plugin/u); +}); + +test('hosted Plugin rejects symlinks that can escape its package root', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-symlink-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world'); + await mkdir(path.join(pluginRoot, 'skills', 'hello-world'), { recursive: true }); + await Promise.all([ + writeFile(path.join(workspace, 'outside.md'), '# Outside\n'), + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'hello-world', + description: 'Greets MiniMax Code users with a reusable Skill.', + license: 'Apache-2.0', + })}\n`), + writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'), + writeFile(path.join(pluginRoot, 'skills', 'hello-world', 'SKILL.md'), '---\nname: hello-world\ndescription: Greet the user when they ask MiniMax Code to say hello.\n---\n\n# Instructions\n\nRespond with a friendly greeting.\n'), + symlink(path.join(workspace, 'outside.md'), path.join(pluginRoot, 'README.md')), + ]); + + await assert.rejects( + validateHostedPluginDirectory(pluginRoot, { owner: 'alice', pluginName: 'hello-world' }), + /symlinks are not allowed/u, + ); +}); + +test('scaffold refuses to populate an existing Plugin directory', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-existing-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const destination = path.join(workspace, 'plugins', 'alice', 'hello-world'); + await mkdir(destination, { recursive: true }); + + await assert.rejects( + execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'create-plugin.mjs'), 'alice/hello-world'], + { cwd: workspace }, + ), + /already exists/u, + ); + await assert.rejects(readFile(path.join(destination, 'plugin.json'), 'utf8'), /ENOENT/u); +}); + +test('scaffold rejects paths that cannot identify a GitHub owner and portable Plugin', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-path-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + + for (const contribution of ['alice-/hello', 'alice--dev/hello', 'alice/Hello', 'alice/hello--world']) { + await assert.rejects( + execFileAsync( + process.execPath, + [path.join(repositoryRoot, 'scripts', 'create-plugin.mjs'), contribution], + { cwd: workspace }, + ), + /must be \//u, + ); + } +}); diff --git a/test/validation.test.mjs b/test/validation.test.mjs index 7afc080..c7dfce6 100644 --- a/test/validation.test.mjs +++ b/test/validation.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { validateMcp, validatePluginManifest, validateRegistryEntry, validateSkillText } from '../scripts/lib/validation.mjs'; +import { validateMcp, validatePluginManifest, validateSkillText } from '../scripts/lib/validation.mjs'; test('accepts the portable Agent Plugins manifest', () => { const value = validatePluginManifest({ @@ -42,33 +42,3 @@ test('validates supported MCP transports and reserved environment variables', () /env is invalid/u, ); }); - -test('requires immutable source and at least one capability in registry entries', () => { - const entry = { - schemaVersion: 1, - name: 'example-plugin', - repository: 'https://github.com/example/example-plugin', - commit: 'a'.repeat(40), - path: '.', - summary: 'A useful example plugin for registry tests.', - license: 'Apache-2.0', - maintainers: ['example'], - categories: ['developer-tools'], - capabilities: { skills: ['example-skill'], mcpServers: [] }, - requirements: { executables: [], accounts: [], platforms: ['macos', 'windows', 'linux'] }, - dataAndNetwork: { networkAccess: false, destinations: [], dataHandled: [] }, - lifecycle: { - disableBehavior: 'Stops exposing the plugin capabilities to future turns.', - uninstallBehavior: 'Removes the package while leaving author-owned remote data unchanged.', - }, - }; - assert.equal(validateRegistryEntry(entry).name, 'example-plugin'); - assert.throws( - () => validateRegistryEntry({ ...entry, commit: 'main' }), - /full lowercase SHA/u, - ); - assert.throws( - () => validateRegistryEntry({ ...entry, capabilities: { skills: [], mcpServers: [] } }), - /at least one MCode capability/u, - ); -});