Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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__<tool>`), 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(<path>)`, 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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. |

Expand Down
Binary file added plugin/.claude-plugin/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions plugin/.codex-mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mcpServers": {
"deploygate": {
"command": "node",
"args": ["./scripts/bundle.js"],
"cwd": "."
}
}
}
2 changes: 1 addition & 1 deletion plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 1 addition & 5 deletions plugin/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
56 changes: 56 additions & 0 deletions plugin/README.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion plugin/skills/ci-setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/deploy/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/sdk-setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 51 additions & 9 deletions src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
>;
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", () => {
Expand All @@ -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<string, unknown>
>;
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
Expand Down
97 changes: 88 additions & 9 deletions src/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
);
Comment on lines +248 to +250
}
});

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");

Expand Down
Loading