diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ccb1a17..404e822 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,8 @@ { "name": "synter", "source": "./", - "description": "Connect every ad platform, build audiences from signals, generate on-brand creative, launch cross-platform campaigns, and reallocate spend by ROAS — one conversation, every platform. You direct, the agents execute." + "version": "1.1.3", + "description": "Operate connected ad platforms with Synter agents, skills, and approval-gated MCP tools." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bb9fc01..16b205d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,12 +2,12 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "synter", "displayName": "Synter", - "description": "The AI Agent Operator for Ads. Connect every ad platform, build audiences from signals and first-party data, generate on-brand creative, launch cross-platform campaigns, and reallocate spend by ROAS — all in one conversation. You direct, the agents execute. Nothing ships without your approval.", - "version": "1.1.2", + "description": "The AI Agent Operator for Ads. Operate connected ad platforms, build audiences, generate on-brand creative, launch campaigns, and optimize spend — all with explicit approval before spend-changing actions.", + "version": "1.1.3", "license": "MIT", "author": { "name": "Synter", - "email": "joel@synterai.com", + "email": "support@synterai.com", "url": "https://syntermedia.ai" }, "homepage": "https://syntermedia.ai", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 1f2df9c..07598b6 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -2,18 +2,18 @@ "name": "synter", "owner": { "name": "Synter", - "email": "joel@synterai.com" + "email": "support@synterai.com" }, "metadata": { "description": "Synter's official plugin marketplace. The AI Agent Operator for Ads.", - "version": "1.1.2" + "version": "1.1.3" }, "plugins": [ { "name": "synter", "source": ".", "logo": "https://raw.githubusercontent.com/Synter-Media-AI/plugin/main/assets/logo.png", - "description": "The AI Agent Operator for Ads. Connect 21 ad platforms, build audiences, generate on-brand creative, launch cross-platform campaigns, and reallocate spend by ROAS — one conversation, every platform. You direct, the agents execute. Nothing that spends money ships without your approval." + "description": "Operate connected ad platforms with Synter agents, skills, and approval-gated MCP tools." } ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index b1e2018..8be9e64 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,11 +2,11 @@ "$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json", "name": "synter", "displayName": "Synter", - "version": "1.1.2", - "description": "The AI Agent Operator for Ads. Connect 21 ad platforms, build audiences, generate on-brand creative, launch cross-platform campaigns, and reallocate spend by ROAS — one conversation, every platform. You direct, the agents execute. Nothing that spends money ships without your approval.", + "version": "1.1.3", + "description": "The AI Agent Operator for Ads. Operate connected ad platforms, build audiences, generate on-brand creative, launch campaigns, and optimize spend — all with explicit approval before spend-changing actions.", "author": { "name": "Synter", - "email": "joel@synterai.com" + "email": "support@synterai.com" }, "homepage": "https://syntermedia.ai", "repository": "https://github.com/Synter-Media-AI/plugin", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 313dacf..5970790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,10 +27,20 @@ jobs: - name: Run strict Claude plugin validation run: npx -y @anthropic-ai/claude-code@2.1.263 plugin validate . --strict + - name: Ensure release tag matches manifest version + run: | + version=$(node -p "require('./.claude-plugin/plugin.json').version") + test "$GITHUB_REF_NAME" = "synter--v$version" || { + echo "Tag $GITHUB_REF_NAME does not match manifest version $version" >&2 + exit 1 + } + - name: Ensure zip/rsync are available run: | - command -v zip >/dev/null || sudo apt-get update -qq && sudo apt-get install -y -qq zip - command -v rsync >/dev/null || sudo apt-get update -qq && sudo apt-get install -y -qq rsync + if ! command -v zip >/dev/null || ! command -v rsync >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq zip rsync + fi - name: Build plugin zip run: bash scripts/build-zip.sh @@ -42,5 +52,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: dist/synter-plugin-${{ steps.version.outputs.version }}.zip + files: | + dist/synter-plugin-${{ steps.version.outputs.version }}.zip + dist/synter-plugin-${{ steps.version.outputs.version }}.zip.sha256 generate_release_notes: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7bf6ff4..6207219 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,3 +23,6 @@ jobs: - name: Run strict Claude plugin validation run: npx -y @anthropic-ai/claude-code@2.1.263 plugin validate . --strict + + - name: Build and validate Desktop package + run: bash scripts/build-zip.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 631fd87..474e1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to the Synter plugin are documented here. This project follows [semantic versioning](https://semver.org). +## [1.1.3] — 2026-09-09 + +### Fixed +- Separated Claude Code marketplace installation from Claude Desktop/Cowork custom ZIP upload, including explicit update steps for an existing uploaded 1.0.0 plugin and duplicate marketplace-name troubleshooting. +- Kept `.claude-plugin/plugin.json` authoritative through the root manifest symlink, aligned version 1.1.3 across Claude and Cursor marketplace metadata, removed stale fixed platform-count claims, and standardized publisher contact metadata. +- Replaced broad “every platform” coverage claims with capability wording tied to connected accounts and the current hosted MCP catalog. + +### Added +- A clean, allowlisted Desktop/Cowork ZIP build with a SHA-256 checksum and archive-level validation for one-folder layout, version, 57 skills, 7 agents, the SessionStart hook, OAuth connector, and 1024×1024 Synter logo. +- Dedicated installation, update, OAuth, and troubleshooting guidance in `INSTALL.md`. + +### Verification limits +- Source manifests, the generated ZIP, component inventory, hook syntax, logo file, and strict Claude plugin schema are locally verifiable. Fresh Desktop upload and OAuth authorization remain client-side flows and are not claimed by these package checks. + ## [1.1.2] — 2026-09-06 ### Fixed diff --git a/CLAUDE_COMMUNITY_SUBMISSION.md b/CLAUDE_COMMUNITY_SUBMISSION.md index 5352dd4..1ca3f45 100644 --- a/CLAUDE_COMMUNITY_SUBMISSION.md +++ b/CLAUDE_COMMUNITY_SUBMISSION.md @@ -1,6 +1,6 @@ # Claude plugin directory submission -Prepared: 2026-09-06 +Prepared: 2026-09-09 This is the reviewer-ready package for the public Synter plugin repository. Synter's separate remote MCP connector submission is in review; that does not @@ -69,6 +69,11 @@ Cowork: **Customize → Plugins → Personal plugins → + → Add marketplace from a repository**, then enter `Synter-Media-AI/plugin` and install `synter`. Direct installation is not public-directory discovery. +Desktop custom ZIP upload is a separate source. Use the versioned archive from +the latest GitHub release and replace an older same-name `synter` upload in its +existing personal/manual marketplace; do not add a duplicate marketplace as an +update workaround. See `INSTALL.md` for the complete update flow. + ## Validation and reviewer checklist - [x] Repository is public and contains an MIT license. @@ -91,11 +96,14 @@ Direct installation is not public-directory discovery. - [x] Production OAuth protected-resource and authorization-server metadata return `200`; a protected tool call returns `401` with a `WWW-Authenticate` resource-metadata challenge. -- [x] The production MCP tool surface has 195 tools; every tool supplies a - title plus boolean `readOnlyHint` and `destructiveHint` annotations. -- [x] Clean Claude Code configuration can add `Synter-Media-AI/plugin`, install - `synter@synter`, and inventory all 57 skills, 7 agents, 1 hook, and 1 MCP - server. +- [x] The production MCP tool surface had 196 tools in the September 9 QA run; + every discovered tool supplied a title plus boolean `readOnlyHint` and + `destructiveHint` annotations. The hosted catalog is dynamic and this is not + a package invariant. +- [x] A clean Claude Code configuration installed release 1.1.2 from + `Synter-Media-AI/plugin` and inventoried all 57 skills, 7 agents, 1 hook, and + 1 MCP server. Release 1.1.3 source and archive checks are local; a fresh + Desktop upload is still unverified. - [ ] Provider reviewer exercises `/synter:quickstart`, one read-only report, and one write preview without approving the write. - [ ] Provider reviewer uses a Synter-owned test workspace with sample data; @@ -138,7 +146,7 @@ boundary before any external spend-changing action. | --- | --- | | Public source and license | Public GitHub repository; MIT `LICENSE` | | OAuth for authenticated remote MCP | OAuth metadata, DCR, PKCE, and protected-tool `401` challenge at `https://mcp.syntermedia.ai` | -| Tool annotations | All 195 discovered tools include `title`, boolean `readOnlyHint`, and boolean `destructiveHint` | +| Tool annotations | All 196 tools discovered in the September 9 QA run included `title`, boolean `readOnlyHint`, and boolean `destructiveHint`; hosted count may change | | Privacy and support | `https://syntermedia.ai/privacy`; `https://syntermedia.ai/contact` | | Narrow, visible instructions | Human-readable skills and agents in this repository; no encoded or remotely loaded behavioral instructions | | Approval boundary | Spend-changing operations require explicit approval; a model response is not approval | diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..8dc943b --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,96 @@ +# Install and update Synter + +Synter has two Claude installation flows. They share the same plugin identity +(`synter`) and OAuth connector, but they do not update each other. + +## Claude Code marketplace + +Run these inside Claude Code: + +```text +/plugin marketplace add Synter-Media-AI/plugin +/plugin install synter@synter +``` + +For an existing installation, refresh rather than adding a duplicate: + +```text +/plugin marketplace update synter +/plugin update synter@synter +``` + +Third-party marketplaces do not auto-update unless the user enables that +setting. Restart the session after an update so hooks and the MCP connection use +the new cached plugin version. + +If **Add marketplace** reports that the name already exists, the marketplace is +already configured. Open the existing `synter` marketplace or run `/plugin +marketplace list`; do not create a second marketplace with the same name. + +## Claude Desktop and Cowork custom upload + +1. Download `synter-plugin-.zip` and + `synter-plugin-.zip.sha256` from the [latest release](https://github.com/Synter-Media-AI/plugin/releases/latest). +2. Optionally verify the download: + + ```bash + shasum -a 256 -c synter-plugin-.zip.sha256 + ``` + +3. Open **Customize → Plugins → Personal plugins → + → Upload plugin** and + select the ZIP. Do not unzip it or put it inside another ZIP. +4. Start a new conversation and inspect Synter's plugin details. The current + package reports version 1.1.3 and contains 57 skills, 7 agents, one + SessionStart hook, and one Synter connector. +5. Use a protected Synter tool and complete the browser OAuth prompt. No API key + belongs in Claude plugin settings or chat. + +The package's authoritative identity and version come from +`.claude-plugin/plugin.json`. The bundled `assets/logo.png` is the canonical +Synter artwork, but Claude's current plugin manifest does not accept a `logo` or +`icon` field. A generic fallback avatar in a Claude surface is therefore not +evidence that the asset is missing, and adding an unsupported field would make +strict package validation fail. + +To update an uploaded Synter 1.0.0, upload the current ZIP to the same +personal/manual marketplace. Claude identifies the plugin by the stable name +`synter`; the supported manual-marketplace flow overwrites a same-name plugin. +If your personal-plugin UI only offers **Remove** rather than replacement, +remove the old upload and upload the current ZIP. Do not add the GitHub +marketplace as an update workaround: that creates a second installation source +and can trigger a duplicate marketplace-name failure. + +## Claude Desktop repository marketplace + +Instead of a custom ZIP, Desktop can sync this repository: + +1. Open **Customize → Plugins → Personal plugins → + → Add marketplace → Add + from a repository**. +2. Enter `Synter-Media-AI/plugin` and install `synter` from the resulting + marketplace. +3. If `synter` already appears under Personal plugins, use that existing source + rather than adding it again. + +Repository-marketplace updates and custom-upload replacements are different +operations. Keep one source for Synter unless intentionally testing both. + +## Authentication and troubleshooting + +- Installation adds the remote connector from `.mcp.json`. Authentication + happens later through Synter's browser OAuth flow on the first protected tool + call. +- The Claude connector has no static API-key header. Never paste an API key, + access token, or authorization code into chat. +- A generic Desktop **Failed to add marketplace** message can hide a duplicate + marketplace name. Check whether `synter` already exists before retrying. +- A generic upload failure is emitted by Claude Desktop, not this repository. + Confirm that the file is the release ZIP, is under 50 MB, has the published + checksum, and was not re-zipped. If those checks pass, use Claude's displayed + diagnostics or contact Claude support; repeatedly adding marketplaces will + not repair an upload failure. +- After an update, start a new conversation. Existing sessions can retain the + previous plugin files and connector process. + +The local npm package `@synterai/mcp-server` is a separate, MCP-only stdio +client. Installing it does not install the branded Claude plugin or update a +Desktop custom upload. diff --git a/README.md b/README.md index 2d6f775..8562648 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ **The AI Agent Operator for Ads.** Claude, Cowork, Claude Code, and Cursor for advertising. You direct, the agents execute. -Connect every ad platform, build audiences from signals and first-party data, generate on-brand creative, plan and launch cross-platform campaigns, and reallocate spend by ROAS — all in one conversation. Nothing that spends money ships without your approval. +Operate connected ad platforms, build audiences from signals and first-party data, generate on-brand creative, plan and launch cross-platform campaigns, and reallocate spend by ROAS — all in one conversation. Nothing that spends money ships without your approval. -One interface. Every ad platform. Ship faster. +One interface. Your ad platforms. Ship faster. --- @@ -16,33 +16,62 @@ One interface. Every ad platform. Ship faster. ### Claude Code +Claude Code installs Synter from its GitHub marketplace. Run these commands in +Claude Code, not Claude Desktop: + ```text /plugin marketplace add Synter-Media-AI/plugin /plugin install synter@synter ``` +If the `synter` marketplace is already configured, do not add it again. Refresh +the existing marketplace and plugin instead: + +```text +/plugin marketplace update synter +/plugin update synter@synter +``` + Start a new conversation and run `/synter:quickstart`. Claude opens Synter's secure browser OAuth flow when the first protected tool is used. Never paste an API key, access token, or authorization code into chat. ### Claude Desktop and Cowork -Open **Customize → Plugins**. Under **Personal plugins**, click **+ → Add -marketplace → Add from a repository**, enter `Synter-Media-AI/plugin`, and -install `synter` from that marketplace. The full GitHub URL also works. - -Alternatively, download the package from the [latest GitHub -release](https://github.com/Synter-Media-AI/plugin/releases/latest) and choose -**+ → Upload plugin**. A tagged release (`synter--v*`) rebuilds the zip through -the repository's release workflow. +Desktop supports two separate install sources. Pick one; do not create a second +marketplace merely to update an existing uploaded plugin. + +**Repository marketplace:** open **Customize → Plugins**. Under **Personal +plugins**, click **+ → Add marketplace → Add from a repository**, enter +`Synter-Media-AI/plugin`, and install `synter`. If a marketplace named `synter` +already exists, use that existing marketplace instead of adding the repository +again. + +**Custom upload:** download `synter-plugin-.zip` and its checksum from +the [latest GitHub release](https://github.com/Synter-Media-AI/plugin/releases/latest), +then choose **+ → Upload plugin**. Upload the ZIP itself without unpacking or +wrapping it in another folder. This path is independent of the GitHub +marketplace. + +To replace an uploaded **Synter 1.0.0**, upload the current ZIP to the same +personal/manual marketplace. The stable plugin name is `synter`, so a +same-name upload replaces the older version rather than creating a second +plugin. Start a new conversation and verify the installed version is at least +1.1.3. If the UI only offers removal for the old personal upload, remove that +upload and then upload the new ZIP; do not add another marketplace named +`synter`. Start a new conversation after installation and complete the Synter browser sign-in prompt. Skills work in Claude chat and Cowork; hooks and sub-agents run in Cowork. The plugin can be installed directly today, but it will not appear in Anthropic's public directory until Anthropic approves the separate community -submission. +submission. Installation and OAuth are separate: a successful install adds the +connector, while the first protected tool call starts browser authorization. -Official install documentation: [Use plugins in Claude](https://support.claude.com/en/articles/13837440-use-plugins-in-claude) and [Install plugins in Cowork](https://claude.com/docs/cowork/guide/plugins). +See [INSTALL.md](./INSTALL.md) for update and troubleshooting details. Official +references: [Use plugins in Claude](https://support.claude.com/en/articles/13837440-use-plugins-in-claude), +[Claude Code plugin discovery and updates](https://code.claude.com/docs/en/discover-plugins), +and the [plugin package reference](https://code.claude.com/docs/en/plugins-reference). ### Cursor @@ -77,7 +106,15 @@ Supported connection paths are: ## What's in the box -This plugin packages **agents, skills, an MCP server, a hook, and an output style** — the same shape as the [Watt Data plugin](https://github.com/wattdata/plugin), built for Synter. +The Claude package contains **57 skills, 7 agents, one SessionStart hook, one +OAuth MCP connector, and one output style**. The release build verifies this +inventory and its one-folder ZIP layout before producing a checksum. + +`.claude-plugin/plugin.json` is the authoritative Claude package manifest. +`assets/logo.png` is the canonical 1024×1024 transparent Synter mark and ships +in the ZIP. Claude's current plugin manifest schema has no `logo` or `icon` +field, so the repository does not add unsupported branding metadata; Cursor's +supported `logo` field uses the same asset through the canonical GitHub URL. ### Skills — `/synter:*` @@ -120,7 +157,12 @@ They run automatically when the task fits, or call one directly. ### MCP server -The Synter Advertising Platform MCP (`https://mcp.syntermedia.ai`) — cross-platform campaign read/write, creative generation, audiences, attribution, and GA4. Tools register automatically once the plugin is enabled. +The Synter Advertising Platform MCP (`https://mcp.syntermedia.ai`) provides the +hosted tool catalog for cross-platform campaign operations, creative, +audiences, attribution, and GA4. The connector registers when the plugin is +enabled; protected tools require browser OAuth. Tool availability depends on +the connected accounts and current hosted catalog rather than a fixed platform +count in this repository. --- @@ -128,6 +170,7 @@ The Synter Advertising Platform MCP (`https://mcp.syntermedia.ai`) — cross-pla ```text plugin/ +├── plugin.json # symlink to the authoritative Claude manifest ├── .claude-plugin/ │ ├── plugin.json # Claude/Cowork plugin manifest │ └── marketplace.json # Claude Code marketplace @@ -146,6 +189,7 @@ plugin/ │ ├── synter-agent.mjs │ └── README.md ├── CHANGELOG.md +├── INSTALL.md └── LICENSE # MIT ``` @@ -206,6 +250,9 @@ and store it in the client's secret configuration, never in chat. } ``` +The npm stdio package is a separate MCP-only installation. It does not install +this plugin's branded logo, skills, agents, hook, or Desktop upload metadata. + --- ## Safety @@ -220,6 +267,7 @@ Your agent can create campaigns, change budgets, and pause spend. The plugin def git clone https://github.com/Synter-Media-AI/plugin synter-plugin && cd synter-plugin claude plugin validate . --strict # check the manifest + frontmatter claude --plugin-dir . # load locally without a marketplace +bash scripts/build-zip.sh # build + validate the Desktop ZIP/checksum ``` After editing `hooks/`, `.mcp.json`, or `agents/`, run `/reload-plugins`. diff --git a/context/brand-and-safety.md b/context/brand-and-safety.md index f8b6867..7e01db4 100644 --- a/context/brand-and-safety.md +++ b/context/brand-and-safety.md @@ -2,7 +2,7 @@ ## What Synter is The AI Agent Operator for Ads. Claude Code for advertising. **You direct, the agents execute.** -One interface. Every ad platform. Ship faster. +One interface. Your connected ad platforms. Ship faster. ## Voice Sound like the agent talking: terse, certain, doing the work. Show, don't sell. @@ -26,7 +26,7 @@ CTAs: "See how it works", "Book a demo", "Sign up", "Start Growing", "Start Ship Verbs/nouns: **Operate** (not manage), **Ship** (not launch/deploy), **Platforms** (not channels), **Direct API** (not integration), **AI Agents** (not bots/automation). ## Approved headlines -"The AI Agent Operator for Ads." · "Claude Code for advertising." · "You direct, they execute." · "One interface. Every ad platform. Ship faster." · "Direct API connections. No middleware. No sync delays." +"The AI Agent Operator for Ads." · "Claude Code for advertising." · "You direct, they execute." · "One interface. Your connected ad platforms. Ship faster." · "Direct API connections. No middleware. No sync delays." ## Accuracy Never fabricate a price, competitor, statistic, or claim. If you don't have the real number, say so. A plain true line beats an invented impressive one. diff --git a/scripts/build-zip.sh b/scripts/build-zip.sh index 47afeda..c366aa8 100755 --- a/scripts/build-zip.sh +++ b/scripts/build-zip.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build dist/synter-plugin-.zip with the plugin at the archive root — -# exactly the layout Claude Desktop's "Upload plugin" zip import expects. +# Build a versioned Claude Desktop/Cowork upload with exactly one top-level +# synter/ folder. Claude accepts this layout and does not scan deeper nesting. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -10,23 +10,35 @@ VERSION=$(node -p "require('./.claude-plugin/plugin.json').version") STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT -# Plugin surface only — dev/consumption paths stay out of the artifact. +# Validate source metadata before staging an artifact from it. +node scripts/validate-manifests.js + +# Allowlist the Claude runtime and user-facing package surface. Cursor, SDK, +# CI, and packaging utilities do not belong in a Desktop upload. +mkdir -p "$STAGE/synter/.claude-plugin" "$STAGE/synter/scripts" "$STAGE/synter/assets" rsync -a \ - --exclude '.git' --exclude '.github' --exclude 'dist' \ - --exclude 'sdk' --exclude '.cursor-plugin' \ - ./ "$STAGE/synter/" + .mcp.json agents context hooks output-styles skills \ + README.md INSTALL.md CHANGELOG.md LICENSE \ + "$STAGE/synter/" +cp -p .claude-plugin/plugin.json "$STAGE/synter/.claude-plugin/plugin.json" +cp -p scripts/session-context.sh "$STAGE/synter/scripts/session-context.sh" +cp -p assets/logo.png "$STAGE/synter/assets/logo.png" mkdir -p dist ZIP_PATH="dist/synter-plugin-$VERSION.zip" rm -f "$ZIP_PATH" -(cd "$STAGE" && zip -qr "$REPO_ROOT/$ZIP_PATH" synter) +export COPYFILE_DISABLE=1 +(cd "$STAGE" && find synter -type f -print | LC_ALL=C sort | zip -Xq "$REPO_ROOT/$ZIP_PATH" -@) echo "Built $ZIP_PATH" +bash scripts/validate-package.sh "$ZIP_PATH" -# Hard layout check: Claude accepts the plugin directly at the archive root or -# inside one top-level folder. The release artifact uses the latter. -unzip -Z1 "$ZIP_PATH" > "$STAGE/archive-entries.txt" -grep -Fxq 'synter/.claude-plugin/plugin.json' "$STAGE/archive-entries.txt" \ - || { echo "FATAL: plugin.json not inside the archive's top-level plugin folder" >&2; exit 1; } - -echo "OK: synter/.claude-plugin/plugin.json present" +node - "$ZIP_PATH" "$ZIP_PATH.sha256" <<'NODE' +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const [zipPath, checksumPath] = process.argv.slice(2); +const digest = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex'); +fs.writeFileSync(checksumPath, `${digest} ${path.basename(zipPath)}\n`); +NODE +echo "Wrote $ZIP_PATH.sha256" diff --git a/scripts/validate-manifests.js b/scripts/validate-manifests.js index ec87308..6cdf51c 100644 --- a/scripts/validate-manifests.js +++ b/scripts/validate-manifests.js @@ -1,21 +1,40 @@ #!/usr/bin/env node -// Validates the plugin's manifests and skill frontmatter before a PR merges. +// Validates a source tree or an extracted Desktop package before release. // Node stdlib only — no dependencies to keep CI fast and reproducible. 'use strict'; const fs = require('fs'); const path = require('path'); -const REPO_ROOT = path.resolve(__dirname, '..'); +const REPO_ROOT = process.argv[2] + ? path.resolve(process.argv[2]) + : path.resolve(__dirname, '..'); const errors = []; +const EXPECTED = { + name: 'synter', + displayName: 'Synter', + skills: 57, + agents: 7, + mcpUrl: 'https://mcp.syntermedia.ai', + repository: 'https://github.com/Synter-Media-AI/plugin', + logo: 'https://raw.githubusercontent.com/Synter-Media-AI/plugin/main/assets/logo.png', + supportEmail: 'support@synterai.com', +}; + function readFile(relPath) { const abs = path.join(REPO_ROOT, relPath); return fs.readFileSync(abs, 'utf8'); } function parseJson(relPath) { - const raw = readFile(relPath); + let raw; + try { + raw = readFile(relPath); + } catch (err) { + errors.push(`${relPath}: missing or unreadable — ${err.message}`); + return null; + } try { return JSON.parse(raw); } catch (err) { @@ -39,11 +58,24 @@ for (const relPath of MANIFEST_PATHS) { manifests[relPath] = parseJson(relPath); } -// (b) marketplace.json plugins[0].name must match plugin.json name. +// Claude's authoritative manifest lives in .claude-plugin/. Keep the root alias +// as a symlink so tools that inspect plugin.json cannot drift to another copy. +const rootManifestPath = path.join(REPO_ROOT, 'plugin.json'); +if (!fs.existsSync(rootManifestPath) || !fs.lstatSync(rootManifestPath).isSymbolicLink()) { + errors.push('plugin.json: expected a symlink to .claude-plugin/plugin.json'); +} else if (fs.readlinkSync(rootManifestPath) !== '.claude-plugin/plugin.json') { + errors.push('plugin.json: symlink must target .claude-plugin/plugin.json'); +} + +// (b) Identity and version must agree on every marketplace surface. const pluginJson = manifests['.claude-plugin/plugin.json']; const marketplaceJson = manifests['.claude-plugin/marketplace.json']; if (pluginJson && marketplaceJson) { - const marketplaceName = marketplaceJson.plugins && marketplaceJson.plugins[0] && marketplaceJson.plugins[0].name; + const marketplaceEntry = marketplaceJson.plugins && marketplaceJson.plugins[0]; + const marketplaceName = marketplaceEntry && marketplaceEntry.name; + if (marketplaceJson.name !== EXPECTED.name || !Array.isArray(marketplaceJson.plugins) || marketplaceJson.plugins.length !== 1) { + errors.push('.claude-plugin/marketplace.json: expected marketplace "synter" with exactly one plugin'); + } if (marketplaceName !== pluginJson.name) { errors.push( `.claude-plugin/marketplace.json: plugins[0].name ("${marketplaceName}") does not match ` + @@ -54,27 +86,61 @@ if (pluginJson && marketplaceJson) { const cursorPluginJson = manifests['.cursor-plugin/plugin.json']; const cursorMarketplaceJson = manifests['.cursor-plugin/marketplace.json']; const cursorMarketplaceVersion = cursorMarketplaceJson && cursorMarketplaceJson.metadata && cursorMarketplaceJson.metadata.version; - if (cursorPluginJson && cursorPluginJson.version !== pluginJson.version) { - errors.push( - `.cursor-plugin/plugin.json version ("${cursorPluginJson.version}") does not match ` + - `.claude-plugin/plugin.json version ("${pluginJson.version}")` - ); + const versions = { + '.claude-plugin/plugin.json': pluginJson.version, + '.claude-plugin/marketplace.json': marketplaceEntry && marketplaceEntry.version, + '.cursor-plugin/plugin.json': cursorPluginJson && cursorPluginJson.version, + '.cursor-plugin/marketplace.json': cursorMarketplaceVersion, + }; + for (const [relPath, version] of Object.entries(versions)) { + if (version !== pluginJson.version) { + errors.push(`${relPath} version ("${version}") does not match canonical version ("${pluginJson.version}")`); + } } - if (cursorMarketplaceVersion !== pluginJson.version) { - errors.push( - `.cursor-plugin/marketplace.json metadata.version ("${cursorMarketplaceVersion}") does not match ` + - `.claude-plugin/plugin.json version ("${pluginJson.version}")` - ); + if (!/^\d+\.\d+\.\d+$/.test(pluginJson.version || '')) { + errors.push(`.claude-plugin/plugin.json: version ("${pluginJson.version}") is not semantic MAJOR.MINOR.PATCH`); + } + if (pluginJson.name !== EXPECTED.name || pluginJson.displayName !== EXPECTED.displayName) { + errors.push('.claude-plugin/plugin.json: canonical name/displayName must be synter/Synter'); + } + if (pluginJson.repository !== EXPECTED.repository) { + errors.push(`.claude-plugin/plugin.json: repository must be ${EXPECTED.repository}`); + } + if (!pluginJson.author || pluginJson.author.name !== EXPECTED.displayName || pluginJson.author.email !== EXPECTED.supportEmail) { + errors.push(`.claude-plugin/plugin.json: publisher must be Synter <${EXPECTED.supportEmail}>`); + } + if (cursorPluginJson && (cursorPluginJson.name !== EXPECTED.name || cursorPluginJson.displayName !== EXPECTED.displayName || cursorPluginJson.repository !== EXPECTED.repository)) { + errors.push('.cursor-plugin/plugin.json: canonical name, displayName, or repository is incorrect'); + } + if (cursorPluginJson && (!cursorPluginJson.author || cursorPluginJson.author.email !== EXPECTED.supportEmail)) { + errors.push(`.cursor-plugin/plugin.json: publisher email must be ${EXPECTED.supportEmail}`); + } + if (cursorPluginJson && cursorPluginJson.logo !== EXPECTED.logo) { + errors.push(`.cursor-plugin/plugin.json: logo must be ${EXPECTED.logo}`); + } + const cursorEntry = cursorMarketplaceJson && cursorMarketplaceJson.plugins && cursorMarketplaceJson.plugins[0]; + if (!cursorMarketplaceJson || cursorMarketplaceJson.name !== EXPECTED.name || !Array.isArray(cursorMarketplaceJson.plugins) || cursorMarketplaceJson.plugins.length !== 1) { + errors.push('.cursor-plugin/marketplace.json: expected marketplace "synter" with exactly one plugin'); + } + if (cursorEntry && (cursorEntry.name !== EXPECTED.name || cursorEntry.source !== '.')) { + errors.push('.cursor-plugin/marketplace.json: plugin must be named "synter" and sourced from "."'); + } + if (!cursorEntry || cursorEntry.logo !== EXPECTED.logo) { + errors.push(`.cursor-plugin/marketplace.json: logo must be ${EXPECTED.logo}`); } } -// (c) Skill frontmatter check: name matches directory, description present. +// (c) Runtime inventory and frontmatter. const SKILLS_DIR = path.join(REPO_ROOT, 'skills'); if (fs.existsSync(SKILLS_DIR)) { const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); + if (skillDirs.length !== EXPECTED.skills) { + errors.push(`skills/: expected ${EXPECTED.skills} skill directories, found ${skillDirs.length}`); + } + for (const dirName of skillDirs) { const skillPath = path.join('skills', dirName, 'SKILL.md'); const absSkillPath = path.join(REPO_ROOT, skillPath); @@ -111,11 +177,76 @@ if (fs.existsSync(SKILLS_DIR)) { errors.push('skills/: directory not found'); } +const AGENTS_DIR = path.join(REPO_ROOT, 'agents'); +const agentFiles = fs.existsSync(AGENTS_DIR) + ? fs.readdirSync(AGENTS_DIR).filter((name) => name.endsWith('.md')) + : []; +if (agentFiles.length !== EXPECTED.agents) { + errors.push(`agents/: expected ${EXPECTED.agents} Markdown agents, found ${agentFiles.length}`); +} +for (const fileName of agentFiles) { + const content = fs.readFileSync(path.join(AGENTS_DIR, fileName), 'utf8'); + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatter || !/^name:\s*\S+/m.test(frontmatter[1]) || !/^description:\s*\S+/m.test(frontmatter[1])) { + errors.push(`agents/${fileName}: frontmatter must contain name and description`); + } +} + +const hooks = parseJson('hooks/hooks.json'); +const sessionHooks = hooks && hooks.hooks && hooks.hooks.SessionStart; +if (!Array.isArray(sessionHooks) || sessionHooks.length !== 1) { + errors.push('hooks/hooks.json: expected exactly one SessionStart hook group'); +} else { + const commands = sessionHooks[0] && sessionHooks[0].hooks; + if (!Array.isArray(commands) || commands.length !== 1 || commands[0].type !== 'command') { + errors.push('hooks/hooks.json: expected exactly one SessionStart command hook'); + } else if (!commands[0].command.includes('${CLAUDE_PLUGIN_ROOT}')) { + errors.push('hooks/hooks.json: SessionStart hook must resolve its script through ${CLAUDE_PLUGIN_ROOT}'); + } +} + +const requiredFiles = [ + 'assets/logo.png', + 'context/brand-and-safety.md', + 'hooks/hooks.json', + 'output-styles/synter.md', + 'scripts/session-context.sh', + 'README.md', + 'INSTALL.md', + 'CHANGELOG.md', + 'LICENSE', +]; +for (const relPath of requiredFiles) { + if (!fs.existsSync(path.join(REPO_ROOT, relPath))) { + errors.push(`${relPath}: required package file missing`); + } +} + +const logoPath = path.join(REPO_ROOT, 'assets/logo.png'); +if (fs.existsSync(logoPath)) { + const logo = fs.readFileSync(logoPath); + const pngSignature = '89504e470d0a1a0a'; + if (logo.subarray(0, 8).toString('hex') !== pngSignature || logo.readUInt32BE(16) !== 1024 || logo.readUInt32BE(20) !== 1024 || logo[25] !== 6) { + errors.push('assets/logo.png: expected a valid 1024x1024 RGBA PNG'); + } +} + +const publicDocs = ['README.md', 'INSTALL.md'].map((relPath) => readFile(relPath)).join('\n'); +for (const staleUrl of ['https://syntermedia.ai/logo.svg', 'https://docs.syntermedia.ai/tools']) { + if (publicDocs.includes(staleUrl)) { + errors.push(`documentation: remove unavailable URL ${staleUrl}`); + } +} + // (d) Authentication guards — Claude's public directory requires OAuth for // authenticated remote MCP services. Cursor keeps its supported API-key path. const claudeMcp = manifests['.mcp.json']; const claudeServer = claudeMcp && claudeMcp.mcpServers && claudeMcp.mcpServers.synter; -if (!claudeServer || claudeServer.type !== 'http' || claudeServer.url !== 'https://mcp.syntermedia.ai') { +const claudeServerNames = claudeMcp && claudeMcp.mcpServers ? Object.keys(claudeMcp.mcpServers) : []; +if (claudeServerNames.length !== 1 || claudeServerNames[0] !== EXPECTED.name) { + errors.push('.mcp.json: expected exactly one connector named "synter"'); +} +if (!claudeServer || claudeServer.type !== 'http' || claudeServer.url !== EXPECTED.mcpUrl) { errors.push('.mcp.json: Synter must use the production HTTPS remote MCP endpoint'); } if (claudeServer && claudeServer.headers) { @@ -125,9 +256,14 @@ if (pluginJson && pluginJson.userConfig && pluginJson.userConfig.synter_api_key) errors.push('.claude-plugin/plugin.json: Claude plugin must not collect a static Synter API key'); } -const mcpJson = readFile('mcp.json'); -if (!mcpJson.includes('SYNTER_API_KEY')) { - errors.push('mcp.json: missing required literal "SYNTER_API_KEY"'); +const cursorMcp = manifests['mcp.json']; +const cursorServers = cursorMcp && cursorMcp.mcpServers ? Object.keys(cursorMcp.mcpServers) : []; +const cursorServer = cursorMcp && cursorMcp.mcpServers && cursorMcp.mcpServers.synter; +if (cursorServers.length !== 1 || !cursorServer || cursorServer.type !== 'http' || cursorServer.url !== EXPECTED.mcpUrl) { + errors.push('mcp.json: expected exactly one Synter HTTP connector at the production endpoint'); +} +if (!cursorServer || !cursorServer.headers || cursorServer.headers['X-Synter-Key'] !== '${SYNTER_API_KEY}') { + errors.push('mcp.json: Cursor connector must use the SYNTER_API_KEY variable'); } if (errors.length > 0) { @@ -139,4 +275,7 @@ if (errors.length > 0) { process.exit(1); } -console.log('All manifests, skill frontmatter, versions, and authentication guards passed.'); +console.log( + `Validated Synter ${pluginJson && pluginJson.version}: ` + + `${EXPECTED.skills} skills, ${EXPECTED.agents} agents, 1 SessionStart hook, 1 OAuth MCP connector, and canonical branding.` +); diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh new file mode 100755 index 0000000..5b0b639 --- /dev/null +++ b/scripts/validate-package.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Validate the exact Desktop/Cowork ZIP, not just its source checkout. +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 dist/synter-plugin-.zip" >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ZIP_PATH="$1" +[[ "$ZIP_PATH" = /* ]] || ZIP_PATH="$REPO_ROOT/$ZIP_PATH" +[[ -f "$ZIP_PATH" ]] || { echo "FATAL: archive not found: $ZIP_PATH" >&2; exit 1; } + +VERSION=$(node -p "require('$REPO_ROOT/.claude-plugin/plugin.json').version") +EXPECTED_NAME="synter-plugin-$VERSION.zip" +[[ "$(basename "$ZIP_PATH")" == "$EXPECTED_NAME" ]] \ + || { echo "FATAL: expected archive name $EXPECTED_NAME" >&2; exit 1; } + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +unzip -Z1 "$ZIP_PATH" > "$STAGE/entries.txt" + +[[ -s "$STAGE/entries.txt" ]] || { echo 'FATAL: archive is empty' >&2; exit 1; } +if grep -Eq '(^/|(^|/)\.\.(/|$)|^__MACOSX/|(^|/)\.DS_Store$)' "$STAGE/entries.txt"; then + echo 'FATAL: archive contains unsafe or platform-specific paths' >&2 + exit 1 +fi +if grep -Ev '^synter/' "$STAGE/entries.txt" | grep -q .; then + echo 'FATAL: every archive entry must be under the single synter/ folder' >&2 + exit 1 +fi +if grep -Eq '^synter/synter/' "$STAGE/entries.txt"; then + echo 'FATAL: plugin is nested more than one folder deep' >&2 + exit 1 +fi + +unzip -q "$ZIP_PATH" -d "$STAGE/unpacked" +PLUGIN="$STAGE/unpacked/synter" + +required=( + '.claude-plugin/plugin.json' + '.mcp.json' + 'hooks/hooks.json' + 'context/brand-and-safety.md' + 'output-styles/synter.md' + 'scripts/session-context.sh' + 'assets/logo.png' + 'README.md' + 'INSTALL.md' + 'CHANGELOG.md' + 'LICENSE' +) +for rel in "${required[@]}"; do + [[ -f "$PLUGIN/$rel" ]] || { echo "FATAL: package is missing $rel" >&2; exit 1; } +done + +for forbidden in plugin.json .claude-plugin/marketplace.json .cursor-plugin mcp.json .git .github dist sdk scripts/build-zip.sh scripts/validate-manifests.js scripts/validate-package.sh; do + [[ ! -e "$PLUGIN/$forbidden" ]] || { echo "FATAL: package contains non-runtime path $forbidden" >&2; exit 1; } +done + +PACKAGE_VERSION=$(node -p "require('$PLUGIN/.claude-plugin/plugin.json').version") +[[ "$PACKAGE_VERSION" == "$VERSION" ]] \ + || { echo "FATAL: packaged version $PACKAGE_VERSION does not match source version $VERSION" >&2; exit 1; } + +SKILLS=$(find "$PLUGIN/skills" -mindepth 2 -maxdepth 2 -type f -name SKILL.md | wc -l | tr -d ' ') +AGENTS=$(find "$PLUGIN/agents" -mindepth 1 -maxdepth 1 -type f -name '*.md' | wc -l | tr -d ' ') +[[ "$SKILLS" == 57 ]] || { echo "FATAL: expected 57 skills, found $SKILLS" >&2; exit 1; } +[[ "$AGENTS" == 7 ]] || { echo "FATAL: expected 7 agents, found $AGENTS" >&2; exit 1; } + +node - "$PLUGIN" <<'NODE' +const fs = require('fs'); +const path = require('path'); +const root = process.argv[2]; +const manifest = JSON.parse(fs.readFileSync(path.join(root, '.claude-plugin/plugin.json'))); +const mcp = JSON.parse(fs.readFileSync(path.join(root, '.mcp.json'))); +const hooks = JSON.parse(fs.readFileSync(path.join(root, 'hooks/hooks.json'))); +const servers = Object.keys(mcp.mcpServers || {}); +if (manifest.name !== 'synter' || manifest.displayName !== 'Synter') throw new Error('canonical Synter identity is missing'); +if (servers.length !== 1 || servers[0] !== 'synter') throw new Error('expected exactly one Synter MCP connector'); +const server = mcp.mcpServers.synter; +if (server.type !== 'http' || server.url !== 'https://mcp.syntermedia.ai' || server.headers) throw new Error('Claude MCP must use the OAuth HTTP endpoint without static headers'); +const sessionHooks = hooks.hooks?.SessionStart; +if (!Array.isArray(sessionHooks) || sessionHooks.length !== 1 || !Array.isArray(sessionHooks[0].hooks) || sessionHooks[0].hooks.length !== 1 || sessionHooks[0].hooks[0].type !== 'command') throw new Error('expected exactly one SessionStart command hook'); +const logo = fs.readFileSync(path.join(root, 'assets/logo.png')); +if (logo.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || logo.readUInt32BE(16) !== 1024 || logo.readUInt32BE(20) !== 1024 || logo[25] !== 6) throw new Error('expected a valid 1024x1024 RGBA PNG logo'); +NODE + +bash -n "$PLUGIN/scripts/session-context.sh" +echo "Validated $EXPECTED_NAME: Synter $VERSION, 57 skills, 7 agents, 1 hook, 1 OAuth connector, one-folder layout."