diff --git a/CLAUDE.md b/CLAUDE.md index 73eda4d..7c5d429 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ CI runs `npm run build && npm test` on Node 24 on every PR and push to main. A s - `members.ts` — multi-step member management (workspace → project → team → app) - `shared-teams.ts` — workspace-wide shared team management -**Plugin assets** (`plugin/`): Contains the Codex plugin manifest (`plugin/.codex-plugin/plugin.json`), legacy Claude plugin manifest (`plugin/.claude-plugin/plugin.json`), MCP server config (`.mcp.json`), skills (slash commands in `plugin/skills/`), GitHub Actions templates (`plugin/templates/`), and the bundled server script. The `plugin/` subdirectory is the published plugin root. +**Plugin assets** (`plugin/`): Contains the Codex plugin manifest (`plugin/.codex-plugin/plugin.json`), legacy Claude plugin manifest (`plugin/.claude-plugin/plugin.json`), MCP server configs (`.mcp.json` for Claude, which runs the bundle via `${CLAUDE_PLUGIN_ROOT}`; `.codex-mcp.json` for Codex, which does not expand that variable and runs it relative to the plugin root), the Claude plugin directory listing README (`plugin/README.md`) and icon (`plugin/.claude-plugin/icon.png`), skills (slash commands in `plugin/skills/`), GitHub Actions templates (`plugin/templates/`), and the bundled server script. The `plugin/` subdirectory is the published plugin root. **Marketplace config** (`.agents/plugins/marketplace.json`): Top-level pointer to the `plugin/` subdirectory for Codex plugin marketplace registration. @@ -59,4 +59,5 @@ Tests validate structural invariants like version consistency between `package.j - TypeScript strict mode, target ES2022, Node16 module resolution - The build produces both `dist/` (tsc output) and `plugin/scripts/bundle.js` (esbuild single-file bundle); the bundle is what end users run - Authentication uses the device authorization code flow. `login_start` → user approves in browser → `login_wait` stores the token at `~/.config/deploygate/token` (0600). `logout` revokes server-side and deletes the file. +- Skill `allowed-tools` must name each MCP tool with the plugin scope (`mcp__plugin_deploygate_deploygate__`), never a wildcard, and may pre-approve only the non-destructive MCP tools that grant no access listed in `PRE_APPROVABLE_MCP_TOOLS` in `skills.test.ts`; it must not pre-approve build tools (`./gradlew`, `xcodebuild`, `fastlane`), file-mutating shell commands, or unscoped `Write`/`Edit` (use `Edit()`, which also covers `Write`). The Claude plugin directory holds versions that pre-approve broad access; `skills.test.ts` enforces these rules. - The `members.ts` `add_member` tool orchestrates multiple API calls (workspace → project → team) in a single tool invocation, handling "already exists" gracefully diff --git a/README.md b/README.md index e0b372f..8ac8c76 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The plugin signs you in to DeployGate via a browser-based device authorization c 2. Open the URL in a browser where you are signed in to DeployGate and click approve. 3. The agent calls `login_wait`, which returns your workspace information once you approve. -The issued token is stored at `~/.config/deploygate/token` (on Windows, `%APPDATA%\deploygate\token`) with `0600` permissions and reused across sessions. Run the `logout` tool to revoke it server-side and delete the local file. +The issued token is stored at `$XDG_CONFIG_HOME/deploygate/token`, which is `~/.config/deploygate/token` when `XDG_CONFIG_HOME` isn't set (on Windows, `%APPDATA%\deploygate\token`), and reused across sessions. On macOS and Linux, the file has `0600` permissions; on Windows, it keeps the default access control of `%APPDATA%`. Run the `logout` tool to revoke it server-side and delete the local file. ## Skills @@ -91,7 +91,7 @@ Claude Code invokes plugin skills as slash commands. Codex invokes plugin skills | Tool | Description | |---|---| | `login_start` | Begin a browser-based device authorization login. Returns a URL for the user to open and approve. | -| `login_wait` | Poll until the user approves `login_start`. On success, persists the token to `~/.config/deploygate/token` (0600). | +| `login_wait` | Poll until the user approves `login_start`. On success, persists the token to the token file described in [Authentication](#authentication). | | `logout` | Revoke the stored token on the server and delete the local token file. | | `get_user_info` | Get current user information (workspace names, projects). Auto-clears the local token on a 401 response. | diff --git a/plugin/.claude-plugin/icon.png b/plugin/.claude-plugin/icon.png new file mode 100644 index 0000000..40b4185 Binary files /dev/null and b/plugin/.claude-plugin/icon.png differ diff --git a/plugin/.codex-mcp.json b/plugin/.codex-mcp.json new file mode 100644 index 0000000..65d7ccd --- /dev/null +++ b/plugin/.codex-mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "deploygate": { + "command": "node", + "args": ["./scripts/bundle.js"], + "cwd": "." + } + } +} diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 020abe1..2d26e5c 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -20,7 +20,7 @@ "ci-cd" ], "skills": "./skills/", - "mcpServers": "./.mcp.json", + "mcpServers": "./.codex-mcp.json", "interface": { "displayName": "DeployGate", "shortDescription": "Upload mobile apps and manage DeployGate distribution workflows", diff --git a/plugin/.mcp.json b/plugin/.mcp.json index 3d6e411..4a66a67 100644 --- a/plugin/.mcp.json +++ b/plugin/.mcp.json @@ -2,11 +2,7 @@ "mcpServers": { "deploygate": { "command": "node", - "args": [ - "-e", - "const fs=require('node:fs');const path=require('node:path');const roots=[process.env.CODEX_PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT,process.cwd(),path.join(process.cwd(),'plugin')].filter(Boolean);const entry=roots.map((root)=>path.join(root,'scripts','bundle.js')).find(fs.existsSync);if(!entry){throw new Error('DeployGate MCP bundle not found');}import(entry);" - ], - "cwd": "." + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/bundle.js"] } } } diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..1cc0a61 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,56 @@ +# DeployGate + +Distribute iOS and Android test builds with [DeployGate](https://deploygate.com) without leaving your coding agent. The plugin uploads IPA, APK, and AAB builds, creates and updates distribution pages, invites testers, lists iOS device UDIDs, and generates CI/CD workflows that upload every build automatically. + +## Install + +In Claude Code, run: + +```text +/plugin install deploygate --marketplace DeployGate/deploygate-agent-plugin +``` + +On Claude Code versions before 2.1.275, run `/plugin marketplace add DeployGate/deploygate-agent-plugin` first, then `/plugin install deploygate@deploygate-marketplace`. Then run `/deploygate:setup` to start. + +## Skills + +| Skill | What it does | +|---|---| +| `/deploygate:setup` | Guided onboarding: sign in, build and upload your first app, create a distribution page, connect notifications, and register iOS devices | +| `/deploygate:deploy` | Build the current project and upload the binary to DeployGate | +| `/deploygate:ci-setup` | Generate a GitHub Actions workflow, or set up Bitrise, CircleCI, and other CI services, to upload builds and distribute pull requests | +| `/deploygate:sdk-setup` | Add the DeployGate SDK to an Android app for crash reporting and screen capture | + +The skills don't pre-approve build commands such as Gradle, `xcodebuild`, or fastlane, so Claude Code asks for your permission before it runs them, unless your own permission settings already allow them. File edits are pre-approved only for the CI configuration files and Gradle build scripts that the skills set up. + +## Requirements + +- Node.js 20 or later +- A DeployGate account. The setup skill links you to sign-up if you don't have one. + +The plugin runs a local MCP server, so its tools work in Claude Code and Cowork but not in Claude chat on claude.ai. + +## What the plugin runs, sends, and stores + +**Runs locally.** The MCP server is `scripts/bundle.js`, started with `node`. It talks to Claude over stdio and opens no network ports. + +**Sends data only to DeployGate.** Every request goes to `https://deploygate.com` over HTTPS: the app binaries and Android signing keystores you choose to upload, distribution page settings, member invitations, and requests for account, app, and device information. The plugin contacts no other service and sends no telemetry. + +**Stores one credential.** You sign in with a browser-based device authorization flow (`login_start`, then `login_wait`). DeployGate issues a token for this plugin, and the plugin saves it to `$XDG_CONFIG_HOME/deploygate/token`, which is `~/.config/deploygate/token` when `XDG_CONFIG_HOME` isn't set, or to `%APPDATA%\deploygate\token` on Windows. On macOS and Linux, the file has `0600` permissions, so only your user can read it. On Windows, it keeps the default access control of your `%APPDATA%` folder. The token is sent only to `https://deploygate.com`. The `logout` tool revokes it on the server and deletes the file. + +**Reads other credentials only when you ask.** The `update_keystore` tool reads the Android signing keystore file at the path you give it and takes the keystore and key passwords you provide. It sends them to `https://deploygate.com` to replace the app's signing keystore, and doesn't store them locally. The plugin reads no environment variables that hold secrets. + +**CI templates.** The workflows that `ci-setup` writes refer to secrets such as `DEPLOYGATE_API_TOKEN` that you add in your CI service. They run on your CI provider; the plugin itself never reads them. + +## About `scripts/bundle.js` + +`scripts/bundle.js` is the MCP server and its npm dependencies, mainly `@modelcontextprotocol/sdk` and `zod`, bundled into one unminified file with esbuild. It is built from the TypeScript source in [`src/`](https://github.com/DeployGate/deploygate-agent-plugin/tree/main/src) by `npm ci && npm run bundle`, and CI checks that the committed bundle matches a fresh build of the source before each release. + +## Support + +- Source and issues: https://github.com/DeployGate/deploygate-agent-plugin +- Help: help@deploygate.com + +## License + +MIT diff --git a/plugin/skills/ci-setup/SKILL.md b/plugin/skills/ci-setup/SKILL.md index 996d2bc..1c4ec1a 100644 --- a/plugin/skills/ci-setup/SKILL.md +++ b/plugin/skills/ci-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: ci-setup description: Set up CI/CD integration for automated DeployGate uploads and PR-based distribution -allowed-tools: mcp__deploygate__get_user_info Read Write Edit Glob Bash(which:*) Bash(ls:*) +allowed-tools: mcp__plugin_deploygate_deploygate__get_user_info Read Glob Edit(.github/workflows/**) Edit(bitrise.yml) Edit(.circleci/config.yml) Bash(which:*) Bash(ls:*) --- # DeployGate CI/CD Setup diff --git a/plugin/skills/deploy/SKILL.md b/plugin/skills/deploy/SKILL.md index 031895e..4d7461e 100644 --- a/plugin/skills/deploy/SKILL.md +++ b/plugin/skills/deploy/SKILL.md @@ -1,7 +1,7 @@ --- name: deploy description: Build the current project and upload the app binary to DeployGate -allowed-tools: mcp__deploygate__get_user_info mcp__deploygate__upload_app Bash(./gradlew:*) Bash(fastlane:*) Bash(xcodebuild:*) Bash(git rev-parse:*) Bash(which:*) Read Glob +allowed-tools: mcp__plugin_deploygate_deploygate__get_user_info mcp__plugin_deploygate_deploygate__upload_app Bash(git rev-parse:*) Bash(which:*) Read Glob --- # Build and upload app to DeployGate diff --git a/plugin/skills/sdk-setup/SKILL.md b/plugin/skills/sdk-setup/SKILL.md index d863a1b..0e12ca6 100644 --- a/plugin/skills/sdk-setup/SKILL.md +++ b/plugin/skills/sdk-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: sdk-setup description: Add the DeployGate SDK to your Android app for crash reporting and screen capture -allowed-tools: mcp__deploygate__upload_app Read Edit Glob Bash(./gradlew:*) +allowed-tools: mcp__plugin_deploygate_deploygate__upload_app Read Glob Edit(build.gradle) Edit(build.gradle.kts) Edit(settings.gradle) Edit(settings.gradle.kts) --- # DeployGate SDK Setup diff --git a/plugin/skills/setup/SKILL.md b/plugin/skills/setup/SKILL.md index 669782f..f6dbe1c 100644 --- a/plugin/skills/setup/SKILL.md +++ b/plugin/skills/setup/SKILL.md @@ -1,7 +1,7 @@ --- name: setup description: Start DeployGate onboarding — set up app distribution from first upload to team-wide deployment -allowed-tools: mcp__deploygate__* Bash(./gradlew:*) Bash(xcodebuild:*) Bash(fastlane:*) Bash(security find-identity:*) Bash(git rev-parse:*) Bash(which:*) Bash(mkdir:*) Bash(cp:*) Bash(zip:*) Bash(cd:*) Bash(ls:*) Read Glob AskUserQuestion +allowed-tools: mcp__plugin_deploygate_deploygate__login_start mcp__plugin_deploygate_deploygate__login_wait mcp__plugin_deploygate_deploygate__get_user_info mcp__plugin_deploygate_deploygate__upload_app mcp__plugin_deploygate_deploygate__create_distribution mcp__plugin_deploygate_deploygate__get_udids mcp__plugin_deploygate_deploygate__get_notification_settings_url Bash(security find-identity:*) Bash(git rev-parse:*) Bash(which:*) Bash(ls:*) Read Glob AskUserQuestion --- ## Response Language diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts index 349da53..7d9ecff 100644 --- a/src/__tests__/plugin.test.ts +++ b/src/__tests__/plugin.test.ts @@ -134,21 +134,17 @@ describe("plugin/.mcp.json", () => { expect(servers.deploygate).toBeDefined(); }); - it("uses a Codex-compatible portable entry point resolver", () => { + // The Claude plugin directory blocks inline programs (`node -e`) and paths + // not written in full from ${CLAUDE_PLUGIN_ROOT} when the plugin lives in a + // subfolder of the repository. + it("runs the bundle by its full path from CLAUDE_PLUGIN_ROOT", () => { const servers = mcp.mcpServers as Record< string, Record >; const dg = servers.deploygate; expect(dg.command).toBe("node"); - const args = dg.args as string[]; - expect(args).toHaveLength(2); - expect(args[0]).toBe("-e"); - expect(args[1]).toContain("CODEX_PLUGIN_ROOT"); - expect(args[1]).toContain("CLAUDE_PLUGIN_ROOT"); - expect(args[1]).toContain("scripts"); - expect(args[1]).toContain("bundle.js"); - expect(dg.cwd).toBe("."); + expect(dg.args).toEqual(["${CLAUDE_PLUGIN_ROOT}/scripts/bundle.js"]); }); it("does not pass DEPLOYGATE_API_TOKEN through env", () => { @@ -160,6 +156,52 @@ describe("plugin/.mcp.json", () => { }); }); +describe("plugin/.codex-mcp.json", () => { + const mcp = loadJson("plugin/.codex-mcp.json"); + + it("is the MCP config the Codex manifest points to", () => { + const plugin = loadJson("plugin/.codex-plugin/plugin.json"); + expect(plugin.mcpServers).toBe("./.codex-mcp.json"); + }); + + // Codex does not expand ${CLAUDE_PLUGIN_ROOT}, so it starts the server from + // the plugin root and runs the bundle by a relative path. + it("runs the bundle relative to the plugin root", () => { + const servers = mcp.mcpServers as Record< + string, + Record + >; + const dg = servers.deploygate; + expect(dg.command).toBe("node"); + expect(dg.args).toEqual(["./scripts/bundle.js"]); + expect(dg.cwd).toBe("."); + expect(dg.env).toBeUndefined(); + }); +}); + +// The Claude plugin directory reads only the plugin folder, so the README it +// lists and the icon have to live inside plugin/. +describe("plugin folder listing assets", () => { + it("has a README of at least 40 words outside code blocks", () => { + const readme = readFileSync(resolve(ROOT, "plugin/README.md"), "utf-8"); + const prose = readme.replace(/```[\s\S]*?```/g, ""); + expect(prose.split(/\s+/).filter(Boolean).length).toBeGreaterThanOrEqual( + 40, + ); + }); + + it("has a square PNG icon between 512 and 2048 px under 2 MB", () => { + const icon = readFileSync(resolve(ROOT, "plugin/.claude-plugin/icon.png")); + expect(icon.subarray(1, 4).toString("ascii")).toBe("PNG"); + const width = icon.readUInt32BE(16); + const height = icon.readUInt32BE(20); + expect(width).toBe(height); + expect(width).toBeGreaterThanOrEqual(512); + expect(width).toBeLessThanOrEqual(2048); + expect(icon.length).toBeLessThan(2 * 1024 * 1024); + }); +}); + describe("plugin/scripts/package.json", () => { // The plugin cache copies only plugin/, so this file is what makes Node // load bundle.js as ESM on versions without module syntax detection diff --git a/src/__tests__/skills.test.ts b/src/__tests__/skills.test.ts index d01844b..6ef03b7 100644 --- a/src/__tests__/skills.test.ts +++ b/src/__tests__/skills.test.ts @@ -159,29 +159,108 @@ describe("skills allowed-tools frontmatter", () => { }); } - it("setup skill pre-approves MCP deploygate tools (wildcard)", () => { + it("setup skill pre-approves the Phase 1 onboarding tools", () => { const content = loadSkill("plugin/skills/setup/SKILL.md"); - expect(content).toMatch(/allowed-tools:.*mcp__deploygate__\*/); + for (const tool of [ + "login_start", + "login_wait", + "get_user_info", + "upload_app", + "create_distribution", + "get_udids", + "get_notification_settings_url", + ]) { + expect(content).toMatch( + new RegExp(`allowed-tools:.*mcp__plugin_deploygate_deploygate__${tool}\\b`), + ); + } }); it("deploy skill pre-approves upload_app and get_user_info", () => { const content = loadSkill("plugin/skills/deploy/SKILL.md"); - expect(content).toContain("mcp__deploygate__upload_app"); - expect(content).toContain("mcp__deploygate__get_user_info"); + expect(content).toContain("mcp__plugin_deploygate_deploygate__upload_app"); + expect(content).toContain( + "mcp__plugin_deploygate_deploygate__get_user_info", + ); }); - it("ci-setup skill pre-approves file-editing tools", () => { + it("ci-setup skill pre-approves edits to CI config files only", () => { const content = loadSkill("plugin/skills/ci-setup/SKILL.md"); - expect(content).toMatch(/allowed-tools:.*\bWrite\b/); - expect(content).toMatch(/allowed-tools:.*\bEdit\b/); + expect(content).toMatch(/allowed-tools:.*Edit\(\.github\/workflows\/\*\*\)/); }); - it("sdk-setup skill pre-approves Edit for build.gradle", () => { + it("sdk-setup skill pre-approves Edit for build.gradle only", () => { const content = loadSkill("plugin/skills/sdk-setup/SKILL.md"); - expect(content).toMatch(/allowed-tools:.*\bEdit\b/); + expect(content).toMatch(/allowed-tools:.*Edit\(build\.gradle\)/); + expect(content).toMatch(/allowed-tools:.*Edit\(build\.gradle\.kts\)/); }); }); +// The Claude plugin directory holds a version for review when a skill +// pre-approves broad access, so allowed-tools must stay narrow. +describe("skills allowed-tools meet Claude plugin directory policy", () => { + const skills = ["setup", "deploy", "ci-setup", "sdk-setup"]; + + function allowedTools(skill: string): string[] { + const content = loadSkill(`plugin/skills/${skill}/SKILL.md`); + const line = content.match(/^allowed-tools:(.*)$/m); + expect(line).not.toBeNull(); + return line![1].trim().split(/\s+/); + } + + for (const skill of skills) { + it(`${skill} scopes MCP tools to the plugin`, () => { + for (const tool of allowedTools(skill).filter((t) => + t.startsWith("mcp__"), + )) { + expect(tool).toMatch(/^mcp__plugin_deploygate_deploygate__/); + } + }); + + // The server also exposes destructive and access-granting tools + // (delete_project, remove_*, update_saml_certificate, add_member, + // create_shared_team, ...). Skills may pre-approve only the tools in + // this list, so a wildcard or any other tool, including one added to + // the server later, needs a per-call approval unless this list is + // deliberately extended. + const PRE_APPROVABLE_MCP_TOOLS = [ + "login_start", + "login_wait", + "get_user_info", + "upload_app", + "create_distribution", + "get_udids", + "get_notification_settings_url", + ].map((tool) => `mcp__plugin_deploygate_deploygate__${tool}`); + + it(`${skill} pre-approves only non-destructive MCP tools that grant no access`, () => { + for (const tool of allowedTools(skill).filter((t) => + t.startsWith("mcp__"), + )) { + expect(PRE_APPROVABLE_MCP_TOOLS).toContain(tool); + } + }); + + it(`${skill} does not pre-approve build tools or file-mutating shell commands`, () => { + for (const tool of allowedTools(skill).filter((t) => + t.startsWith("Bash("), + )) { + expect(tool).not.toMatch( + /^Bash\((\.\/|gradlew|fastlane|xcodebuild|cp|cd|mkdir|zip|rm|mv)\b/, + ); + } + }); + + it(`${skill} does not pre-approve unscoped Write or Edit`, () => { + const tools = allowedTools(skill); + expect(tools).not.toContain("Write"); + expect(tools).not.toContain("Edit"); + // Claude Code never consults path rules on Write; Edit(path) covers it. + expect(tools.some((t) => t.startsWith("Write("))).toBe(false); + }); + } +}); + describe("skills/deploy delegates to setup skill for complex cases", () => { const content = loadSkill("plugin/skills/deploy/SKILL.md");