Skip to content

Commit b69bc34

Browse files
Cursor plugin fixes
1 parent 908fb84 commit b69bc34

8 files changed

Lines changed: 292 additions & 23 deletions

File tree

.cursor-plugin/plugin.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
{
22
"name": "iterable-sdk",
3-
"displayName": "Iterable SDK",
43
"version": "0.1.0",
54
"description": "Reliable Iterable mobile SDK integration guidance for AI coding assistants — version-pinned snippets + silent-failure pitfalls. Android today; iOS/RN/Web later.",
65
"author": { "name": "Iterable" },
76
"repository": "https://github.com/Iterable/iterable-sdk-skill",
87
"license": "MIT",
98
"keywords": ["iterable", "android", "sdk", "mobile", "push", "in-app"],
10-
"skills": ["./iterable-android"],
11-
"mcpServers": {
12-
"context7": { "url": "https://mcp.context7.com/mcp" }
13-
}
9+
"skills": ["./iterable-android"]
1410
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Validate plugin manifests
2+
3+
# Lightweight gate for Cursor/Claude plugin packaging — no Java/Kotlin needed.
4+
5+
on:
6+
push:
7+
branches: [main]
8+
paths:
9+
- .cursor-plugin/**
10+
- .claude-plugin/**
11+
- mcp.json
12+
- .mcp.json
13+
- pipeline/src/validate-plugins.ts
14+
- pipeline/package.json
15+
- .github/workflows/validate-plugins.yml
16+
pull_request:
17+
branches: [main]
18+
paths:
19+
- .cursor-plugin/**
20+
- .claude-plugin/**
21+
- mcp.json
22+
- .mcp.json
23+
- pipeline/src/validate-plugins.ts
24+
- pipeline/package.json
25+
- .github/workflows/validate-plugins.yml
26+
workflow_dispatch:
27+
28+
permissions:
29+
contents: read
30+
31+
jobs:
32+
validate-plugins:
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 5
35+
steps:
36+
- name: Checkout
37+
uses: actions/checkout@v4
38+
39+
- name: Setup pnpm
40+
uses: pnpm/action-setup@v4
41+
with:
42+
version: 9
43+
44+
- name: Setup Node
45+
uses: actions/setup-node@v4
46+
with:
47+
node-version: 20
48+
cache: pnpm
49+
cache-dependency-path: pipeline/pnpm-lock.yaml
50+
51+
- name: Install pipeline deps
52+
working-directory: pipeline
53+
run: pnpm install --frozen-lockfile
54+
55+
- name: Validate plugin manifests
56+
working-directory: pipeline
57+
run: pnpm validate:plugins

.mcp.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"mcpServers": {
33
"context7": {
44
"type": "http",
5-
"url": "https://mcp.context7.com/mcp"
5+
"url": "https://mcp.context7.com/mcp",
6+
"headers": {
7+
"CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}"
8+
}
69
}
710
}
811
}

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ latest docs live, with the snapshot as its fallback. No reinstall needed when
6363
that happens.
6464
6565
> Context7 works without an API key at a lower rate limit. To raise it, get a
66-
> free key at [context7.com](https://context7.com) and add it as a
67-
> `CONTEXT7_API_KEY` header on the `context7` MCP server.
66+
> free key at [context7.com](https://context7.com) and set the `CONTEXT7_API_KEY`
67+
> environment variable — the bundled MCP config forwards it automatically.
6868
6969
## What it covers
7070
@@ -86,7 +86,8 @@ sources/ raw Iterable docs, fetched at pinned commits
8686
context7.json Context7 manifest
8787
.claude-plugin/ Claude Code plugin + marketplace manifests
8888
.cursor-plugin/ Cursor plugin + marketplace manifests
89-
.mcp.json Context7 MCP server (Claude Code auto-discovery)
89+
mcp.json Context7 MCP server (Cursor plugin auto-discovery)
90+
.mcp.json same config (Claude Code auto-discovery; kept in sync by CI)
9091
```
9192
9293
## Staying current

iterable-android/SKILL.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ features — push, in-app, mobile inbox, embedded, deep links, event tracking,
3535
user profiles. Do **not** assume the developer wants all of them, and do **not**
3636
start implementing on a guessed scope. Ask which features they want. If your
3737
host exposes an interactive multi-select question tool (in Claude Code,
38-
`AskUserQuestion` with `multiSelect: true`), use it and offer **at most 4
39-
options** (that tool rejects more than 4 per question); otherwise ask in plain
40-
text. Either way, group the long tail under one bucket, e.g.:
38+
`AskUserQuestion` with `multiSelect: true`; in Cursor, `AskQuestion` with
39+
`allow_multiple: true`), use it and offer **at most 4 options** (those tools
40+
reject more than 4 per question); otherwise ask in plain text. Either way, group
41+
the long tail under one bucket, e.g.:
4142

4243
- Push notifications (FCM)
4344
- In-app messages
@@ -92,16 +93,16 @@ done.
9293
> **Prefer selectable options over prose — every time.** For *any* question to
9394
> the developer — the Step 0 scope question, the inputs below, the identity
9495
> model, region, and "what would you like to do next?" — if your host exposes an
95-
> interactive question tool (in Claude Code, `AskUserQuestion`), use it so they
96-
> get interactive choices instead of a plain-text list they must answer by
97-
> typing. Offer realistic options with a short description each (e.g. identity:
98-
> "Stable per-install UUID via `setUserId`" / "Account email via `setEmail`";
99-
> region: "US" / "EU"). With `AskUserQuestion`, **offer at most 4 options per
100-
> question**it rejects more than 4 and the call fails with an "invalid
101-
> parameters" error; if you have more than 4, group them or split into a second
102-
> question. If no such tool is available, ask in plain text with the options
103-
> clearly enumerated. Only skip options entirely if the question genuinely has
104-
> none.
96+
> interactive question tool (in Claude Code, `AskUserQuestion`; in Cursor,
97+
> `AskQuestion`), use it so they get interactive choices instead of a plain-text
98+
> list they must answer by typing. Offer realistic options with a short
99+
> description each (e.g. identity: "Stable per-install UUID via `setUserId`" /
100+
> "Account email via `setEmail`"; region: "US" / "EU"). With those tools,
101+
> **offer at most 4 options per question**they reject more than 4 and the
102+
> call fails with an "invalid parameters" error; if you have more than 4, group
103+
> them or split into a second question. If no such tool is available, ask in
104+
> plain text with the options clearly enumerated. Only skip options entirely if
105+
> the question genuinely has none.
105106
106107
| Input | Needed when | If missing |
107108
|---|---|---|

mcp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mcpServers": {
3+
"context7": {
4+
"type": "http",
5+
"url": "https://mcp.context7.com/mcp",
6+
"headers": {
7+
"CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}"
8+
}
9+
}
10+
}
11+
}

pipeline/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"fetch:sources": "tsx src/fetch.ts",
1111
"polish:a": "tsx src/polish-layer-a.ts",
1212
"validate:polished": "tsx src/validate-polished.ts",
13+
"validate:plugins": "tsx src/validate-plugins.ts",
1314
"check:snippets": "tsx src/check-snippets.ts",
1415
"lint:chunking": "tsx src/lint-chunking.ts",
1516
"recompute:manifest": "tsx src/recompute-manifest.ts",
@@ -20,7 +21,7 @@
2021
"eval:run": "tsx src/eval-run.ts run",
2122
"eval:report": "tsx src/eval-report.ts",
2223
"typecheck": "tsc --noEmit",
23-
"check:all": "pnpm typecheck && pnpm validate:polished && pnpm snapshot:verify && pnpm check:snippets && pnpm lint:chunking"
24+
"check:all": "pnpm typecheck && pnpm validate:polished && pnpm validate:plugins && pnpm snapshot:verify && pnpm check:snippets && pnpm lint:chunking"
2425
},
2526
"devDependencies": {
2627
"@types/node": "^22.7.0",

pipeline/src/validate-plugins.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Validates Cursor/Claude plugin manifests and MCP config files.
3+
*
4+
* Checks:
5+
* - `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace.json` parse
6+
* and have required fields.
7+
* - `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` parse.
8+
* - `mcp.json` and `.mcp.json` exist and have identical `mcpServers` content.
9+
* - Declared skill paths exist on disk.
10+
*
11+
* Exit code is non-zero on any failure.
12+
*/
13+
14+
import { existsSync, readFileSync } from "node:fs";
15+
import { dirname, resolve } from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
18+
const HERE = dirname(fileURLToPath(import.meta.url));
19+
const REPO_ROOT = resolve(HERE, "../..");
20+
21+
const PLUGIN_NAME_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
22+
23+
interface Issue {
24+
file: string;
25+
message: string;
26+
}
27+
28+
const issues: Issue[] = [];
29+
30+
function fail(file: string, message: string): void {
31+
issues.push({ file, message });
32+
}
33+
34+
function readJson(file: string): unknown {
35+
const path = resolve(REPO_ROOT, file);
36+
if (!existsSync(path)) {
37+
fail(file, "file is missing");
38+
return undefined;
39+
}
40+
try {
41+
return JSON.parse(readFileSync(path, "utf8"));
42+
} catch (error) {
43+
fail(file, `invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
44+
return undefined;
45+
}
46+
}
47+
48+
function isRecord(value: unknown): value is Record<string, unknown> {
49+
return typeof value === "object" && value !== null && !Array.isArray(value);
50+
}
51+
52+
function assertPluginName(file: string, name: unknown): void {
53+
if (typeof name !== "string" || name.length === 0) {
54+
fail(file, "`name` must be a non-empty string");
55+
return;
56+
}
57+
if (!PLUGIN_NAME_RE.test(name)) {
58+
fail(file, `\`name\` "${name}" must be lowercase kebab-case`);
59+
}
60+
}
61+
62+
function assertSkillPaths(file: string, skills: unknown): void {
63+
if (skills === undefined) return;
64+
const paths = Array.isArray(skills) ? skills : [skills];
65+
for (const entry of paths) {
66+
if (typeof entry !== "string") {
67+
fail(file, "`skills` entries must be strings");
68+
continue;
69+
}
70+
const skillDir = resolve(REPO_ROOT, entry);
71+
const skillMd = resolve(skillDir, "SKILL.md");
72+
if (!existsSync(skillMd)) {
73+
fail(file, `skill path "${entry}" is missing SKILL.md`);
74+
}
75+
}
76+
}
77+
78+
function validateCursorPlugin(): void {
79+
const file = ".cursor-plugin/plugin.json";
80+
const manifest = readJson(file);
81+
if (!isRecord(manifest)) return;
82+
83+
assertPluginName(file, manifest.name);
84+
assertSkillPaths(file, manifest.skills);
85+
86+
if ("mcpServers" in manifest) {
87+
fail(
88+
file,
89+
"omit `mcpServers` — Cursor auto-discovers `mcp.json` at the plugin root",
90+
);
91+
}
92+
}
93+
94+
function validateCursorMarketplace(): void {
95+
const file = ".cursor-plugin/marketplace.json";
96+
const manifest = readJson(file);
97+
if (!isRecord(manifest)) return;
98+
99+
assertPluginName(file, manifest.name);
100+
101+
if (!isRecord(manifest.owner) || typeof manifest.owner.name !== "string") {
102+
fail(file, "`owner.name` is required");
103+
}
104+
105+
if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) {
106+
fail(file, "`plugins` must be a non-empty array");
107+
return;
108+
}
109+
110+
for (const entry of manifest.plugins) {
111+
if (!isRecord(entry)) {
112+
fail(file, "each `plugins` entry must be an object");
113+
continue;
114+
}
115+
assertPluginName(file, entry.name);
116+
if (typeof entry.source !== "string" || entry.source.length === 0) {
117+
fail(file, "each `plugins` entry needs a `source` path");
118+
}
119+
}
120+
}
121+
122+
function validateClaudePlugin(): void {
123+
const file = ".claude-plugin/plugin.json";
124+
const manifest = readJson(file);
125+
if (!isRecord(manifest)) return;
126+
127+
assertPluginName(file, manifest.name);
128+
assertSkillPaths(file, manifest.skills);
129+
}
130+
131+
function validateClaudeMarketplace(): void {
132+
const file = ".claude-plugin/marketplace.json";
133+
const manifest = readJson(file);
134+
if (!isRecord(manifest)) return;
135+
136+
assertPluginName(file, manifest.name);
137+
138+
if (!isRecord(manifest.owner) || typeof manifest.owner.name !== "string") {
139+
fail(file, "`owner.name` is required");
140+
}
141+
142+
if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) {
143+
fail(file, "`plugins` must be a non-empty array");
144+
}
145+
}
146+
147+
function validateMcpConfigs(): void {
148+
const cursorMcp = readJson("mcp.json");
149+
const claudeMcp = readJson(".mcp.json");
150+
if (!isRecord(cursorMcp) || !isRecord(claudeMcp)) return;
151+
152+
const cursorServers = cursorMcp.mcpServers;
153+
const claudeServers = claudeMcp.mcpServers;
154+
155+
if (!isRecord(cursorServers)) {
156+
fail("mcp.json", "`mcpServers` must be an object");
157+
return;
158+
}
159+
if (!isRecord(claudeServers)) {
160+
fail(".mcp.json", "`mcpServers` must be an object");
161+
return;
162+
}
163+
164+
const cursorJson = JSON.stringify(cursorServers);
165+
const claudeJson = JSON.stringify(claudeServers);
166+
if (cursorJson !== claudeJson) {
167+
fail("mcp.json", "`mcpServers` must match `.mcp.json` exactly");
168+
}
169+
170+
const context7 = cursorServers.context7;
171+
if (!isRecord(context7)) {
172+
fail("mcp.json", "`mcpServers.context7` is required");
173+
return;
174+
}
175+
if (context7.url !== "https://mcp.context7.com/mcp") {
176+
fail("mcp.json", "`mcpServers.context7.url` must point at Context7");
177+
}
178+
}
179+
180+
function main(): void {
181+
validateCursorPlugin();
182+
validateCursorMarketplace();
183+
validateClaudePlugin();
184+
validateClaudeMarketplace();
185+
validateMcpConfigs();
186+
187+
if (issues.length === 0) {
188+
console.log("validate-plugins: OK");
189+
return;
190+
}
191+
192+
console.error(`validate-plugins: ${issues.length} issue(s)\n`);
193+
for (const issue of issues) {
194+
console.error(` ${issue.file}: ${issue.message}`);
195+
}
196+
process.exit(1);
197+
}
198+
199+
main();

0 commit comments

Comments
 (0)