From 908fb8453f32306c668ba2524399f34830273e71 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 8 Jul 2026 12:27:51 +0100 Subject: [PATCH 1/2] Package as a Cursor plugin (skill + Context7 MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor 3.9+ supports Anthropic Agent Skills and a plugin system that bundles skills and MCP servers, mirroring the Claude Code plugin this repo already ships. Add the Cursor equivalents so Cursor users get the same one-step install instead of the previous manual symlink (which wired no doc source and broke without SSH keys). - .cursor-plugin/plugin.json + marketplace.json — mirror the .claude-plugin manifests; point skills at ./iterable-android and declare the Context7 MCP server inline (an mcpServers path reference silently fails to register). - README: rewrite the Cursor install section around the plugin, with a skill-only fallback and a one-click Context7 MCP deeplink; note both plugins in the repo layout. - SKILL.md: make the AskUserQuestion references host-agnostic so the skill degrades gracefully under Cursor's agent. Co-Authored-By: Claude Fable 5 --- .cursor-plugin/marketplace.json | 15 +++++++++++++ .cursor-plugin/plugin.json | 14 ++++++++++++ README.md | 38 +++++++++++++++++++++++++-------- iterable-android/SKILL.md | 33 +++++++++++++++------------- 4 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 .cursor-plugin/plugin.json diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..bb85f5a --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "iterable", + "owner": { "name": "Iterable" }, + "metadata": { + "description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "iterable-sdk", + "source": "./", + "description": "Iterable mobile SDK skill" + } + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..c204559 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "iterable-sdk", + "displayName": "Iterable SDK", + "version": "0.1.0", + "description": "Reliable Iterable mobile SDK integration guidance for AI coding assistants — version-pinned snippets + silent-failure pitfalls. Android today; iOS/RN/Web later.", + "author": { "name": "Iterable" }, + "repository": "https://github.com/Iterable/iterable-sdk-skill", + "license": "MIT", + "keywords": ["iterable", "android", "sdk", "mobile", "push", "in-app"], + "skills": ["./iterable-android"], + "mcpServers": { + "context7": { "url": "https://mcp.context7.com/mcp" } + } +} diff --git a/README.md b/README.md index cd35dc7..958277c 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,30 @@ callback, and more). This installs the skill and wires up its documentation source in one step. -**Cursor (or a manual install)** — symlink the skill directory into your -assistant's skills folder: +**Cursor** — install it as a plugin, which wires up the skill **and** its +documentation source (the Context7 MCP server) in one step. Cursor 3.9+ supports +Agent Skills and plugins natively. + +Clone the repo and symlink it into Cursor's local-plugins folder, then restart +Cursor (or reload the window): ```bash -git clone --depth 1 git@github.com:Iterable/iterable-sdk-skill.git ~/iterable-skills -ln -s ~/iterable-skills/iterable-android ~/.cursor/skills/iterable-android +git clone --depth 1 https://github.com/Iterable/iterable-sdk-skill.git ~/iterable-skills +ln -s ~/iterable-skills ~/.cursor/plugins/local/iterable-sdk ``` +A Cursor team admin can instead publish it to the whole org via **Dashboard → +Plugins → Add Marketplace → Import from Repo**, pointing at this repo. + +> **Just the skill, no plugin?** Cursor 3.9+ auto-loads Agent Skills from +> `~/.cursor/skills/` — symlink only the skill directory: +> `ln -s ~/iterable-skills/iterable-android ~/.cursor/skills/iterable-android`. +> This skips the doc source; to add it, paste this into your browser to install +> the Context7 MCP server: +> ``` +> cursor://anysphere.cursor-deeplink/mcp/install?name=context7&config=eyJ1cmwiOiJodHRwczovL21jcC5jb250ZXh0Ny5jb20vbWNwIn0= +> ``` + Either way, the skill activates automatically whenever you mention Iterable: it loads its always-on rules and `PITFALLS.md`, then pulls the documentation for whatever feature you're working on (see [How it works](#how-it-works)). @@ -39,11 +55,12 @@ The skill carries a copy of the Iterable documentation inside it (`iterable-android/snapshot/`), so it always has the docs on hand — even offline. This snapshot is the active source today. -The Claude Code plugin also connects to [Context7](https://context7.com), a -service that hosts docs for AI assistants to query on demand. That connection is -bundled and ready, but stays dormant until Iterable's curated library is -published there; once it is, the skill fetches the latest docs live, with the -snapshot as its fallback. No reinstall needed when that happens. +The plugin (both Claude Code and Cursor) also connects to +[Context7](https://context7.com), a service that hosts docs for AI assistants to +query on demand. That connection is bundled and ready, but stays dormant until +Iterable's curated library is published there; once it is, the skill fetches the +latest docs live, with the snapshot as its fallback. No reinstall needed when +that happens. > Context7 works without an API key at a lower rate limit. To raise it, get a > free key at [context7.com](https://context7.com) and add it as a @@ -67,6 +84,9 @@ polished/ the docs in agent-ready form (what gets published to Context pipeline/ tooling that builds polished/ from sources/, CI-gated sources/ raw Iterable docs, fetched at pinned commits context7.json Context7 manifest +.claude-plugin/ Claude Code plugin + marketplace manifests +.cursor-plugin/ Cursor plugin + marketplace manifests +.mcp.json Context7 MCP server (Claude Code auto-discovery) ``` ## Staying current diff --git a/iterable-android/SKILL.md b/iterable-android/SKILL.md index 2b01efb..e3f3943 100644 --- a/iterable-android/SKILL.md +++ b/iterable-android/SKILL.md @@ -33,10 +33,11 @@ human readers but omit several silent-failure traps documented in Do this **first**, before Preflight and before any edits. The SDK covers many features — push, in-app, mobile inbox, embedded, deep links, event tracking, user profiles. Do **not** assume the developer wants all of them, and do **not** -start implementing on a guessed scope. Ask which features they want via -`AskUserQuestion`. Set `multiSelect: true` so they can pick several, and offer -**at most 4 options** (the tool rejects more than 4 per question) — group the -long tail under one bucket, e.g.: +start implementing on a guessed scope. Ask which features they want. If your +host exposes an interactive multi-select question tool (in Claude Code, +`AskUserQuestion` with `multiSelect: true`), use it and offer **at most 4 +options** (that tool rejects more than 4 per question); otherwise ask in plain +text. Either way, group the long tail under one bucket, e.g.: - Push notifications (FCM) - In-app messages @@ -88,17 +89,19 @@ pauses for a missing input is **always better** than one that compiles by faking the input — the latter ships a broken or misleading state that looks done. -> **Always ask via selectable options, not prose — every time.** For *any* -> question to the developer — the Step 0 scope question, the inputs below, the -> identity model, region, and "what would you like to do next?" — use the -> `AskUserQuestion` tool so they get interactive choices, not a plain-text list -> they must answer by typing. Offer realistic options with a short description -> each (e.g. identity: "Stable per-install UUID via `setUserId`" / "Account -> email via `setEmail`"; region: "US" / "EU"). **Offer at most 4 options per -> question** — the tool rejects more than 4 and the call fails with an -> "invalid parameters" error. If you have more than 4, group them or split into -> a second question. Only fall back to plain text if the question genuinely has -> no enumerable options. +> **Prefer selectable options over prose — every time.** For *any* question to +> the developer — the Step 0 scope question, the inputs below, the identity +> model, region, and "what would you like to do next?" — if your host exposes an +> interactive question tool (in Claude Code, `AskUserQuestion`), use it so they +> get interactive choices instead of a plain-text list they must answer by +> typing. Offer realistic options with a short description each (e.g. identity: +> "Stable per-install UUID via `setUserId`" / "Account email via `setEmail`"; +> region: "US" / "EU"). With `AskUserQuestion`, **offer at most 4 options per +> question** — it rejects more than 4 and the call fails with an "invalid +> parameters" error; if you have more than 4, group them or split into a second +> question. If no such tool is available, ask in plain text with the options +> clearly enumerated. Only skip options entirely if the question genuinely has +> none. | Input | Needed when | If missing | |---|---|---| From b69bc34d8678cc62a0959cdabc94847423e7c558 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 8 Jul 2026 15:55:33 +0100 Subject: [PATCH 2/2] Cursor plugin fixes --- .cursor-plugin/plugin.json | 6 +- .github/workflows/validate-plugins.yml | 57 +++++++ .mcp.json | 5 +- README.md | 7 +- iterable-android/SKILL.md | 27 ++-- mcp.json | 11 ++ pipeline/package.json | 3 +- pipeline/src/validate-plugins.ts | 199 +++++++++++++++++++++++++ 8 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/validate-plugins.yml create mode 100644 mcp.json create mode 100644 pipeline/src/validate-plugins.ts diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c204559..a23f603 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,14 +1,10 @@ { "name": "iterable-sdk", - "displayName": "Iterable SDK", "version": "0.1.0", "description": "Reliable Iterable mobile SDK integration guidance for AI coding assistants — version-pinned snippets + silent-failure pitfalls. Android today; iOS/RN/Web later.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", "license": "MIT", "keywords": ["iterable", "android", "sdk", "mobile", "push", "in-app"], - "skills": ["./iterable-android"], - "mcpServers": { - "context7": { "url": "https://mcp.context7.com/mcp" } - } + "skills": ["./iterable-android"] } diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 0000000..490b2d3 --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,57 @@ +name: Validate plugin manifests + +# Lightweight gate for Cursor/Claude plugin packaging — no Java/Kotlin needed. + +on: + push: + branches: [main] + paths: + - .cursor-plugin/** + - .claude-plugin/** + - mcp.json + - .mcp.json + - pipeline/src/validate-plugins.ts + - pipeline/package.json + - .github/workflows/validate-plugins.yml + pull_request: + branches: [main] + paths: + - .cursor-plugin/** + - .claude-plugin/** + - mcp.json + - .mcp.json + - pipeline/src/validate-plugins.ts + - pipeline/package.json + - .github/workflows/validate-plugins.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-plugins: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pipeline/pnpm-lock.yaml + + - name: Install pipeline deps + working-directory: pipeline + run: pnpm install --frozen-lockfile + + - name: Validate plugin manifests + working-directory: pipeline + run: pnpm validate:plugins diff --git a/.mcp.json b/.mcp.json index c5280c5..1c0b2d3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "context7": { "type": "http", - "url": "https://mcp.context7.com/mcp" + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}" + } } } } diff --git a/README.md b/README.md index 958277c..c443419 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ latest docs live, with the snapshot as its fallback. No reinstall needed when that happens. > Context7 works without an API key at a lower rate limit. To raise it, get a -> free key at [context7.com](https://context7.com) and add it as a -> `CONTEXT7_API_KEY` header on the `context7` MCP server. +> free key at [context7.com](https://context7.com) and set the `CONTEXT7_API_KEY` +> environment variable — the bundled MCP config forwards it automatically. ## What it covers @@ -86,7 +86,8 @@ sources/ raw Iterable docs, fetched at pinned commits context7.json Context7 manifest .claude-plugin/ Claude Code plugin + marketplace manifests .cursor-plugin/ Cursor plugin + marketplace manifests -.mcp.json Context7 MCP server (Claude Code auto-discovery) +mcp.json Context7 MCP server (Cursor plugin auto-discovery) +.mcp.json same config (Claude Code auto-discovery; kept in sync by CI) ``` ## Staying current diff --git a/iterable-android/SKILL.md b/iterable-android/SKILL.md index e3f3943..4fa9f06 100644 --- a/iterable-android/SKILL.md +++ b/iterable-android/SKILL.md @@ -35,9 +35,10 @@ features — push, in-app, mobile inbox, embedded, deep links, event tracking, user profiles. Do **not** assume the developer wants all of them, and do **not** start implementing on a guessed scope. Ask which features they want. If your host exposes an interactive multi-select question tool (in Claude Code, -`AskUserQuestion` with `multiSelect: true`), use it and offer **at most 4 -options** (that tool rejects more than 4 per question); otherwise ask in plain -text. Either way, group the long tail under one bucket, e.g.: +`AskUserQuestion` with `multiSelect: true`; in Cursor, `AskQuestion` with +`allow_multiple: true`), use it and offer **at most 4 options** (those tools +reject more than 4 per question); otherwise ask in plain text. Either way, group +the long tail under one bucket, e.g.: - Push notifications (FCM) - In-app messages @@ -92,16 +93,16 @@ done. > **Prefer selectable options over prose — every time.** For *any* question to > the developer — the Step 0 scope question, the inputs below, the identity > model, region, and "what would you like to do next?" — if your host exposes an -> interactive question tool (in Claude Code, `AskUserQuestion`), use it so they -> get interactive choices instead of a plain-text list they must answer by -> typing. Offer realistic options with a short description each (e.g. identity: -> "Stable per-install UUID via `setUserId`" / "Account email via `setEmail`"; -> region: "US" / "EU"). With `AskUserQuestion`, **offer at most 4 options per -> question** — it rejects more than 4 and the call fails with an "invalid -> parameters" error; if you have more than 4, group them or split into a second -> question. If no such tool is available, ask in plain text with the options -> clearly enumerated. Only skip options entirely if the question genuinely has -> none. +> interactive question tool (in Claude Code, `AskUserQuestion`; in Cursor, +> `AskQuestion`), use it so they get interactive choices instead of a plain-text +> list they must answer by typing. Offer realistic options with a short +> description each (e.g. identity: "Stable per-install UUID via `setUserId`" / +> "Account email via `setEmail`"; region: "US" / "EU"). With those tools, +> **offer at most 4 options per question** — they reject more than 4 and the +> call fails with an "invalid parameters" error; if you have more than 4, group +> them or split into a second question. If no such tool is available, ask in +> plain text with the options clearly enumerated. Only skip options entirely if +> the question genuinely has none. | Input | Needed when | If missing | |---|---|---| diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..1c0b2d3 --- /dev/null +++ b/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}" + } + } + } +} diff --git a/pipeline/package.json b/pipeline/package.json index f26581c..4648bed 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -10,6 +10,7 @@ "fetch:sources": "tsx src/fetch.ts", "polish:a": "tsx src/polish-layer-a.ts", "validate:polished": "tsx src/validate-polished.ts", + "validate:plugins": "tsx src/validate-plugins.ts", "check:snippets": "tsx src/check-snippets.ts", "lint:chunking": "tsx src/lint-chunking.ts", "recompute:manifest": "tsx src/recompute-manifest.ts", @@ -20,7 +21,7 @@ "eval:run": "tsx src/eval-run.ts run", "eval:report": "tsx src/eval-report.ts", "typecheck": "tsc --noEmit", - "check:all": "pnpm typecheck && pnpm validate:polished && pnpm snapshot:verify && pnpm check:snippets && pnpm lint:chunking" + "check:all": "pnpm typecheck && pnpm validate:polished && pnpm validate:plugins && pnpm snapshot:verify && pnpm check:snippets && pnpm lint:chunking" }, "devDependencies": { "@types/node": "^22.7.0", diff --git a/pipeline/src/validate-plugins.ts b/pipeline/src/validate-plugins.ts new file mode 100644 index 0000000..51e86cd --- /dev/null +++ b/pipeline/src/validate-plugins.ts @@ -0,0 +1,199 @@ +/** + * Validates Cursor/Claude plugin manifests and MCP config files. + * + * Checks: + * - `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace.json` parse + * and have required fields. + * - `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` parse. + * - `mcp.json` and `.mcp.json` exist and have identical `mcpServers` content. + * - Declared skill paths exist on disk. + * + * Exit code is non-zero on any failure. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, "../.."); + +const PLUGIN_NAME_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; + +interface Issue { + file: string; + message: string; +} + +const issues: Issue[] = []; + +function fail(file: string, message: string): void { + issues.push({ file, message }); +} + +function readJson(file: string): unknown { + const path = resolve(REPO_ROOT, file); + if (!existsSync(path)) { + fail(file, "file is missing"); + return undefined; + } + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(file, `invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertPluginName(file: string, name: unknown): void { + if (typeof name !== "string" || name.length === 0) { + fail(file, "`name` must be a non-empty string"); + return; + } + if (!PLUGIN_NAME_RE.test(name)) { + fail(file, `\`name\` "${name}" must be lowercase kebab-case`); + } +} + +function assertSkillPaths(file: string, skills: unknown): void { + if (skills === undefined) return; + const paths = Array.isArray(skills) ? skills : [skills]; + for (const entry of paths) { + if (typeof entry !== "string") { + fail(file, "`skills` entries must be strings"); + continue; + } + const skillDir = resolve(REPO_ROOT, entry); + const skillMd = resolve(skillDir, "SKILL.md"); + if (!existsSync(skillMd)) { + fail(file, `skill path "${entry}" is missing SKILL.md`); + } + } +} + +function validateCursorPlugin(): void { + const file = ".cursor-plugin/plugin.json"; + const manifest = readJson(file); + if (!isRecord(manifest)) return; + + assertPluginName(file, manifest.name); + assertSkillPaths(file, manifest.skills); + + if ("mcpServers" in manifest) { + fail( + file, + "omit `mcpServers` — Cursor auto-discovers `mcp.json` at the plugin root", + ); + } +} + +function validateCursorMarketplace(): void { + const file = ".cursor-plugin/marketplace.json"; + const manifest = readJson(file); + if (!isRecord(manifest)) return; + + assertPluginName(file, manifest.name); + + if (!isRecord(manifest.owner) || typeof manifest.owner.name !== "string") { + fail(file, "`owner.name` is required"); + } + + if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) { + fail(file, "`plugins` must be a non-empty array"); + return; + } + + for (const entry of manifest.plugins) { + if (!isRecord(entry)) { + fail(file, "each `plugins` entry must be an object"); + continue; + } + assertPluginName(file, entry.name); + if (typeof entry.source !== "string" || entry.source.length === 0) { + fail(file, "each `plugins` entry needs a `source` path"); + } + } +} + +function validateClaudePlugin(): void { + const file = ".claude-plugin/plugin.json"; + const manifest = readJson(file); + if (!isRecord(manifest)) return; + + assertPluginName(file, manifest.name); + assertSkillPaths(file, manifest.skills); +} + +function validateClaudeMarketplace(): void { + const file = ".claude-plugin/marketplace.json"; + const manifest = readJson(file); + if (!isRecord(manifest)) return; + + assertPluginName(file, manifest.name); + + if (!isRecord(manifest.owner) || typeof manifest.owner.name !== "string") { + fail(file, "`owner.name` is required"); + } + + if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) { + fail(file, "`plugins` must be a non-empty array"); + } +} + +function validateMcpConfigs(): void { + const cursorMcp = readJson("mcp.json"); + const claudeMcp = readJson(".mcp.json"); + if (!isRecord(cursorMcp) || !isRecord(claudeMcp)) return; + + const cursorServers = cursorMcp.mcpServers; + const claudeServers = claudeMcp.mcpServers; + + if (!isRecord(cursorServers)) { + fail("mcp.json", "`mcpServers` must be an object"); + return; + } + if (!isRecord(claudeServers)) { + fail(".mcp.json", "`mcpServers` must be an object"); + return; + } + + const cursorJson = JSON.stringify(cursorServers); + const claudeJson = JSON.stringify(claudeServers); + if (cursorJson !== claudeJson) { + fail("mcp.json", "`mcpServers` must match `.mcp.json` exactly"); + } + + const context7 = cursorServers.context7; + if (!isRecord(context7)) { + fail("mcp.json", "`mcpServers.context7` is required"); + return; + } + if (context7.url !== "https://mcp.context7.com/mcp") { + fail("mcp.json", "`mcpServers.context7.url` must point at Context7"); + } +} + +function main(): void { + validateCursorPlugin(); + validateCursorMarketplace(); + validateClaudePlugin(); + validateClaudeMarketplace(); + validateMcpConfigs(); + + if (issues.length === 0) { + console.log("validate-plugins: OK"); + return; + } + + console.error(`validate-plugins: ${issues.length} issue(s)\n`); + for (const issue of issues) { + console.error(` ${issue.file}: ${issue.message}`); + } + process.exit(1); +} + +main();