diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4a088bd..c9e5fb7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "sent", "source": "./claude-plugins/sent", - "description": "Official Sent business messaging plugin with 13 agent skills and 19 MCP tools for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", "category": "Productivity" } ] diff --git a/README.md b/README.md index fc86a4c..cef57cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sent Agent Skills for SMS, WhatsApp, RCS, and MCP -The official Sent agent plugin combines thirteen [Agent Skills](https://agentskills.io/) with nineteen live [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools. Use it to send and track business messages, manage contacts and templates, query analytics and account readiness, diagnose delivery failures, and guide SMS 10DLC, WhatsApp Business, RCS/RBM, and Sender Profile implementations. +The official Sent agent plugin combines focused [Agent Skills](https://agentskills.io/) with live [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools. Use it to send and track business messages, manage contacts and templates, query analytics and account readiness, diagnose delivery failures, and guide SMS 10DLC, WhatsApp Business, RCS/RBM, and Sender Profile implementations. This repository is both: @@ -33,7 +33,7 @@ npx skills add https://github.com/sentdm/sent-plugin \ --skill messaging-performance-analyzer ``` -Install all thirteen skills: +Install all skills: ```bash npx skills add https://github.com/sentdm/sent-plugin --skill '*' diff --git a/adapter-sources/shared/README.md b/adapter-sources/shared/README.md index 0bb35cb..dbe2e6a 100644 --- a/adapter-sources/shared/README.md +++ b/adapter-sources/shared/README.md @@ -1,6 +1,6 @@ # Sent Agent Plugin -This generated host adapter packages thirteen official Sent Agent Skills and nineteen remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. +This generated host adapter packages the official Sent Agent Skills and remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. ## Skills diff --git a/claude-plugins/sent/.claude-plugin/plugin.json b/claude-plugins/sent/.claude-plugin/plugin.json index 9ba9b23..6054a50 100644 --- a/claude-plugins/sent/.claude-plugin/plugin.json +++ b/claude-plugins/sent/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin with 13 agent skills and 19 MCP tools for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", "author": { "name": "Sent", "url": "https://sent.dm" diff --git a/claude-plugins/sent/README.md b/claude-plugins/sent/README.md index 0bb35cb..dbe2e6a 100644 --- a/claude-plugins/sent/README.md +++ b/claude-plugins/sent/README.md @@ -1,6 +1,6 @@ # Sent Agent Plugin -This generated host adapter packages thirteen official Sent Agent Skills and nineteen remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. +This generated host adapter packages the official Sent Agent Skills and remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. ## Skills diff --git a/claude-plugins/sent/public-surface.json b/claude-plugins/sent/public-surface.json new file mode 100644 index 0000000..5e87cae --- /dev/null +++ b/claude-plugins/sent/public-surface.json @@ -0,0 +1,100 @@ +{ + "schema_version": 1, + "tools": { + "account.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "balance.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.create_many": { + "owner": "sent-contacts", + "mutation": "state_changing", + "confirmation_required": true + }, + "contacts.delete": { + "owner": "sent-contacts", + "mutation": "destructive", + "confirmation_required": true + }, + "contacts.get": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.list": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.message_summary": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.contacts": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.deliverability": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.messages_sent": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.activities.list": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.get": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.send": { + "owner": "sent-messaging", + "mutation": "destructive", + "confirmation_required": true + }, + "numbers.lookup": { + "owner": "sent-analytics", + "mutation": "state_changing", + "confirmation_required": false + }, + "onboarding.status": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.delete": { + "owner": "sent-templates", + "mutation": "destructive", + "confirmation_required": true + }, + "templates.get": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.get_by_name": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.list": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + } + } +} diff --git a/packages/sent/README.md b/packages/sent/README.md index a20b36a..c369ffc 100644 --- a/packages/sent/README.md +++ b/packages/sent/README.md @@ -1,6 +1,6 @@ # Sent Agent Plugin -Sent `0.1.0` is the canonical portable [Agent Plugins 1.0.0](https://agent-plugins.org/) package for the official Sent skills collection. It combines thirteen Agent Skills with nineteen remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. +This is the canonical portable [Agent Plugins 1.0.0](https://agent-plugins.org/) package for the official Sent skills collection and remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. Skills remain usable for planning and implementation guidance when a host does not support MCP or when authorization is unavailable. Live account reads and mutations require a compatible client connected to the Sent MCP server. diff --git a/packages/sent/plugin.json b/packages/sent/plugin.json index 2c3145c..f5577b0 100644 --- a/packages/sent/plugin.json +++ b/packages/sent/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin with 13 agent skills and 19 MCP tools for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", "author": { "name": "Sent", "url": "https://sent.dm" diff --git a/packages/sent/public-surface.json b/packages/sent/public-surface.json new file mode 100644 index 0000000..5e87cae --- /dev/null +++ b/packages/sent/public-surface.json @@ -0,0 +1,100 @@ +{ + "schema_version": 1, + "tools": { + "account.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "balance.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.create_many": { + "owner": "sent-contacts", + "mutation": "state_changing", + "confirmation_required": true + }, + "contacts.delete": { + "owner": "sent-contacts", + "mutation": "destructive", + "confirmation_required": true + }, + "contacts.get": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.list": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.message_summary": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.contacts": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.deliverability": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.messages_sent": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.activities.list": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.get": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.send": { + "owner": "sent-messaging", + "mutation": "destructive", + "confirmation_required": true + }, + "numbers.lookup": { + "owner": "sent-analytics", + "mutation": "state_changing", + "confirmation_required": false + }, + "onboarding.status": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.delete": { + "owner": "sent-templates", + "mutation": "destructive", + "confirmation_required": true + }, + "templates.get": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.get_by_name": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.list": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + } + } +} diff --git a/plugin.json b/plugin.json index 2c3145c..f5577b0 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin with 13 agent skills and 19 MCP tools for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", "author": { "name": "Sent", "url": "https://sent.dm" diff --git a/plugins/sent/.codex-plugin/plugin.json b/plugins/sent/.codex-plugin/plugin.json index 97e421c..442317e 100644 --- a/plugins/sent/.codex-plugin/plugin.json +++ b/plugins/sent/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sent", "version": "0.1.0", - "description": "Official Sent business messaging plugin with 13 agent skills and 19 MCP tools for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", + "description": "Official Sent business messaging plugin for safe SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, 10DLC, WABA, RBM, and Sender Profile workflows.", "author": { "name": "Sent", "url": "https://sent.dm" diff --git a/plugins/sent/README.md b/plugins/sent/README.md index 0bb35cb..dbe2e6a 100644 --- a/plugins/sent/README.md +++ b/plugins/sent/README.md @@ -1,6 +1,6 @@ # Sent Agent Plugin -This generated host adapter packages thirteen official Sent Agent Skills and nineteen remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. +This generated host adapter packages the official Sent Agent Skills and remote MCP operations for SMS, WhatsApp, RCS, contacts, templates, analytics, account readiness, delivery diagnosis, compliance, and onboarding. ## Skills diff --git a/plugins/sent/public-surface.json b/plugins/sent/public-surface.json new file mode 100644 index 0000000..5e87cae --- /dev/null +++ b/plugins/sent/public-surface.json @@ -0,0 +1,100 @@ +{ + "schema_version": 1, + "tools": { + "account.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "balance.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.create_many": { + "owner": "sent-contacts", + "mutation": "state_changing", + "confirmation_required": true + }, + "contacts.delete": { + "owner": "sent-contacts", + "mutation": "destructive", + "confirmation_required": true + }, + "contacts.get": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.list": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.message_summary": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.contacts": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.deliverability": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.messages_sent": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.activities.list": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.get": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.send": { + "owner": "sent-messaging", + "mutation": "destructive", + "confirmation_required": true + }, + "numbers.lookup": { + "owner": "sent-analytics", + "mutation": "state_changing", + "confirmation_required": false + }, + "onboarding.status": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.delete": { + "owner": "sent-templates", + "mutation": "destructive", + "confirmation_required": true + }, + "templates.get": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.get_by_name": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.list": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + } + } +} diff --git a/public-surface.json b/public-surface.json new file mode 100644 index 0000000..5e87cae --- /dev/null +++ b/public-surface.json @@ -0,0 +1,100 @@ +{ + "schema_version": 1, + "tools": { + "account.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "balance.get": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.create_many": { + "owner": "sent-contacts", + "mutation": "state_changing", + "confirmation_required": true + }, + "contacts.delete": { + "owner": "sent-contacts", + "mutation": "destructive", + "confirmation_required": true + }, + "contacts.get": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.list": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "contacts.message_summary": { + "owner": "sent-contacts", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.contacts": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.deliverability": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "dashboard.messages_sent": { + "owner": "sent-analytics", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.activities.list": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.get": { + "owner": "sent-messaging", + "mutation": "read_only", + "confirmation_required": false + }, + "messages.send": { + "owner": "sent-messaging", + "mutation": "destructive", + "confirmation_required": true + }, + "numbers.lookup": { + "owner": "sent-analytics", + "mutation": "state_changing", + "confirmation_required": false + }, + "onboarding.status": { + "owner": "sent-account-readiness", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.delete": { + "owner": "sent-templates", + "mutation": "destructive", + "confirmation_required": true + }, + "templates.get": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.get_by_name": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + }, + "templates.list": { + "owner": "sent-templates", + "mutation": "read_only", + "confirmation_required": false + } + } +} diff --git a/scripts/generate_adapters.py b/scripts/generate_adapters.py index 14219f2..f6edd4b 100644 --- a/scripts/generate_adapters.py +++ b/scripts/generate_adapters.py @@ -10,11 +10,10 @@ import tempfile from pathlib import Path +from repository_metadata import load_repository_metadata + ROOT = Path(__file__).resolve().parents[1] -PACKAGE = ROOT / "packages" / "sent" -COMMANDS = ROOT / "adapter-sources" / "claude" / "commands" -ADAPTER_README = ROOT / "adapter-sources" / "shared" / "README.md" MCP_URL = "https://mcp.sent.dm/mcp" GENERATED_TREES = ( Path("skills"), @@ -25,6 +24,7 @@ GENERATED_FILES = ( Path("plugin.json"), Path("mcp.json"), + Path("public-surface.json"), Path(".agents/plugins/marketplace.json"), Path(".claude-plugin/marketplace.json"), ) @@ -59,10 +59,15 @@ def reset_tree(path: Path, output_root: Path) -> None: shutil.rmtree(path) -def build(output_root: Path) -> None: - portable_manifest = read_json(PACKAGE / "plugin.json") - portable_mcp = read_json(PACKAGE / "mcp.json") - version = portable_manifest["version"] +def build(output_root: Path, source_root: Path = ROOT) -> None: + package = source_root / "packages" / "sent" + commands = source_root / "adapter-sources" / "claude" / "commands" + adapter_readme = source_root / "adapter-sources" / "shared" / "README.md" + metadata = load_repository_metadata(source_root) + portable_manifest = read_json(package / "plugin.json") + portable_mcp = read_json(package / "mcp.json") + public_surface = read_json(package / "public-surface.json") + version = metadata.version endpoint = portable_mcp["mcpServers"]["sent"]["url"] if endpoint != MCP_URL: raise RuntimeError(f"unexpected Sent MCP URL: {endpoint}") @@ -71,24 +76,26 @@ def build(output_root: Path) -> None: root_assets = output_root / "assets" reset_tree(root_skills, output_root) reset_tree(root_assets, output_root) - copy_tree(PACKAGE / "skills", root_skills) - copy_tree(PACKAGE / "assets", root_assets) + copy_tree(package / "skills", root_skills) + copy_tree(package / "assets", root_assets) write_json(output_root / "plugin.json", portable_manifest) write_json(output_root / "mcp.json", portable_mcp) + write_json(output_root / "public-surface.json", public_surface) codex_root = output_root / "plugins" / "sent" claude_root = output_root / "claude-plugins" / "sent" reset_tree(codex_root, output_root) reset_tree(claude_root, output_root) - copy_tree(PACKAGE / "skills", codex_root / "skills") - copy_tree(PACKAGE / "assets", codex_root / "assets") - copy_tree(PACKAGE / "skills", claude_root / "skills") - copy_tree(PACKAGE / "assets", claude_root / "assets") - copy_tree(COMMANDS, claude_root / ".claude" / "commands") + copy_tree(package / "skills", codex_root / "skills") + copy_tree(package / "assets", codex_root / "assets") + copy_tree(package / "skills", claude_root / "skills") + copy_tree(package / "assets", claude_root / "assets") + copy_tree(commands, claude_root / ".claude" / "commands") for adapter_root in (codex_root, claude_root): - shutil.copy2(ADAPTER_README, adapter_root / "README.md") - shutil.copy2(PACKAGE / "LICENSE", adapter_root / "LICENSE") + shutil.copy2(adapter_readme, adapter_root / "README.md") + shutil.copy2(package / "LICENSE", adapter_root / "LICENSE") + write_json(adapter_root / "public-surface.json", public_surface) common = { "name": "sent", @@ -226,7 +233,8 @@ def main() -> None: check() else: build(ROOT) - print("Generated repository-root, Codex, and Claude packages for sent 0.1.0.") + metadata = load_repository_metadata(ROOT) + print(f"Generated repository-root, Codex, and Claude packages for sent {metadata.version}.") if __name__ == "__main__": diff --git a/scripts/repository_metadata.py b/scripts/repository_metadata.py new file mode 100644 index 0000000..8fc7b3c --- /dev/null +++ b/scripts/repository_metadata.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Load canonical release, skill, and public MCP surface metadata.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +MUTATION_CLASSES = {"read_only", "state_changing", "destructive"} + + +class MetadataError(ValueError): + """Raised when canonical repository metadata is missing or malformed.""" + + +@dataclass(frozen=True) +class ToolMetadata: + owner: str + mutation: str + confirmation_required: bool + + +@dataclass(frozen=True) +class RepositoryMetadata: + version: str + skills: tuple[str, ...] + tools: dict[str, ToolMetadata] + + @property + def tools_by_owner(self) -> dict[str, set[str]]: + grouped: dict[str, set[str]] = {} + for name, tool in self.tools.items(): + grouped.setdefault(tool.owner, set()).add(name) + return grouped + + @property + def confirmation_tools(self) -> dict[str, str]: + return { + name: tool.owner + for name, tool in self.tools.items() + if tool.confirmation_required + } + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise MetadataError(f"could not read {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise MetadataError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(value, dict): + raise MetadataError(f"{path}: root must be an object") + return value + + +def load_repository_metadata(root: Path) -> RepositoryMetadata: + package = root / "packages" / "sent" + plugin = _read_json(package / "plugin.json") + version = plugin.get("version") + if not isinstance(version, str) or not version.strip(): + raise MetadataError("packages/sent/plugin.json: version must be a non-empty string") + + skill_root = package / "skills" + if not skill_root.is_dir(): + raise MetadataError("packages/sent/skills: directory is missing") + skills = tuple( + sorted( + path.name + for path in skill_root.iterdir() + if path.is_dir() and (path / "SKILL.md").is_file() + ) + ) + if not skills: + raise MetadataError("packages/sent/skills: no canonical skills discovered") + + surface_path = package / "public-surface.json" + surface = _read_json(surface_path) + if set(surface) != {"schema_version", "tools"} or surface.get("schema_version") != 1: + raise MetadataError(f"{surface_path}: expected schema_version 1 and tools") + raw_tools = surface.get("tools") + if not isinstance(raw_tools, dict) or not raw_tools: + raise MetadataError(f"{surface_path}: tools must be a non-empty object") + + tools: dict[str, ToolMetadata] = {} + for name, value in raw_tools.items(): + if not isinstance(name, str) or not name or not isinstance(value, dict): + raise MetadataError(f"{surface_path}: every tool entry must be a named object") + if set(value) != {"owner", "mutation", "confirmation_required"}: + raise MetadataError(f"{surface_path}: {name} has unsupported or missing fields") + owner = value.get("owner") + mutation = value.get("mutation") + confirmation = value.get("confirmation_required") + if owner not in skills: + raise MetadataError(f"{surface_path}: {name} owner {owner!r} is not a canonical skill") + if mutation not in MUTATION_CLASSES: + raise MetadataError(f"{surface_path}: {name} has invalid mutation class {mutation!r}") + if not isinstance(confirmation, bool): + raise MetadataError(f"{surface_path}: {name} confirmation_required must be boolean") + if mutation == "read_only" and confirmation: + raise MetadataError(f"{surface_path}: read-only tool {name} cannot require mutation confirmation") + tools[name] = ToolMetadata(owner, mutation, confirmation) + + return RepositoryMetadata(version=version, skills=skills, tools=tools) diff --git a/scripts/test_contracts.py b/scripts/test_contracts.py index cf853dd..97265e8 100644 --- a/scripts/test_contracts.py +++ b/scripts/test_contracts.py @@ -6,10 +6,15 @@ import importlib.util import json import re +import shutil +import tempfile import unittest from pathlib import Path from types import ModuleType +import generate_adapters as GENERATOR +import repository_metadata as REPOSITORY_METADATA + ROOT = Path(__file__).resolve().parents[1] SKILLS = ROOT / "packages" / "sent" / "skills" @@ -45,6 +50,40 @@ def test_campaign_manifest_values_match_validator(self) -> None: self.assertEqual(set(MANIFEST["campaign"]["use_case_values"]), CAMPAIGN.USE_CASES) +class RepositoryMetadataContractTests(unittest.TestCase): + def test_public_surface_matches_skills_and_openai_submission(self) -> None: + metadata = REPOSITORY_METADATA.load_repository_metadata(ROOT) + submission = json.loads((ROOT / "chatgpt-app-submission.json").read_text(encoding="utf-8")) + self.assertEqual(set(metadata.tools), set(submission["tools"])) + self.assertEqual(set(metadata.skills), {path.stem for path in (ROOT / "evals").glob("*.yaml")}) + self.assertTrue(all(tool.owner in metadata.skills for tool in metadata.tools.values())) + + def test_version_change_propagates_to_generated_manifests(self) -> None: + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + source = temporary / "source" + output = temporary / "output" + shutil.copytree(ROOT / "packages", source / "packages") + shutil.copytree(ROOT / "adapter-sources", source / "adapter-sources") + plugin_path = source / "packages" / "sent" / "plugin.json" + plugin = json.loads(plugin_path.read_text(encoding="utf-8")) + plugin["version"] = "9.8.7" + plugin_path.write_text(json.dumps(plugin, indent=2) + "\n", encoding="utf-8") + + GENERATOR.build(output, source_root=source) + + generated = ( + output / "plugin.json", + output / "plugins" / "sent" / ".codex-plugin" / "plugin.json", + output / "claude-plugins" / "sent" / ".claude-plugin" / "plugin.json", + ) + self.assertTrue(all(json.loads(path.read_text(encoding="utf-8"))["version"] == "9.8.7" for path in generated)) + marketplace = json.loads( + (output / ".claude-plugin" / "marketplace.json").read_text(encoding="utf-8") + ) + self.assertEqual(marketplace["metadata"]["version"], "9.8.7") + + class BundledExampleTests(unittest.TestCase): def test_every_markdown_json_block_parses(self) -> None: for path in SKILLS.rglob("*.md"): diff --git a/scripts/test_validation_gates.py b/scripts/test_validation_gates.py index ce22475..c483455 100644 --- a/scripts/test_validation_gates.py +++ b/scripts/test_validation_gates.py @@ -106,6 +106,31 @@ def wrong_openai_test_count(root: Path) -> None: path.write_text(json.dumps(submission, indent=2) + "\n", encoding="utf-8") +def unexpected_public_tool(root: Path) -> None: + path = root / "packages" / "sent" / "public-surface.json" + surface = json.loads(path.read_text(encoding="utf-8")) + surface["tools"]["messages.teleport"] = { + "owner": "sent-messaging", + "mutation": "destructive", + "confirmation_required": True, + } + path.write_text(json.dumps(surface, indent=2) + "\n", encoding="utf-8") + + +def invalid_tool_owner(root: Path) -> None: + path = root / "packages" / "sent" / "public-surface.json" + surface = json.loads(path.read_text(encoding="utf-8")) + surface["tools"]["messages.send"]["owner"] = "missing-skill" + path.write_text(json.dumps(surface, indent=2) + "\n", encoding="utf-8") + + +def mutation_annotation_drift(root: Path) -> None: + path = root / "chatgpt-app-submission.json" + submission = json.loads(path.read_text(encoding="utf-8")) + submission["tools"]["messages.send"]["annotations"]["destructiveHint"] = False + path.write_text(json.dumps(submission, indent=2) + "\n", encoding="utf-8") + + def retired_brand_endpoint(root: Path) -> None: path = root / "packages" / "sent" / "skills" / "sms-10dlc-registration" / "SKILL.md" with path.open("a", encoding="utf-8") as handle: @@ -151,6 +176,9 @@ def contract_manifest_drift(root: Path) -> None: ("README skill catalog coverage", missing_readme_catalog_entry, "skill catalog does not link"), ("skill discovery descriptions", weak_skill_discovery_description, "discovery description must explain"), ("OpenAI test counts", wrong_openai_test_count, "exactly three negative test cases"), + ("unexpected MCP tool", unexpected_public_tool, "OpenAI submission tool set mismatch"), + ("invalid MCP tool owner", invalid_tool_owner, "is not a canonical skill"), + ("mutation annotation consistency", mutation_annotation_drift, "public mutation class destructive"), ("retired brand endpoint", retired_brand_endpoint, "retired endpoint"), ("template webhook envelope", template_sub_type, "template webhook example uses sub_type"), ("ordered channel fallback", ordered_channel_fallback, "explicit RCS/SMS array"), diff --git a/scripts/validate.py b/scripts/validate.py index cf82577..df4cb82 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -15,6 +15,8 @@ import yaml from jsonschema import Draft202012Validator +from repository_metadata import MetadataError, load_repository_metadata + ROOT = Path(__file__).resolve().parents[1] PACKAGE = ROOT / "packages" / "sent" @@ -25,46 +27,17 @@ SCHEMAS = ROOT / "schemas" / "agent-plugins" / "1.0.0" OPENAI_SUBMISSION_SCHEMA = ROOT / "schemas" / "openai" / "chatgpt-app-submission.v1.json" CONTRACT_MANIFEST = ROOT / "schemas" / "sent" / "v3-contract-manifest.json" -VERSION = "0.1.0" MCP_URL = "https://mcp.sent.dm/mcp" PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" -EXPECTED_PACKAGE_ENTRIES = {"plugin.json", "mcp.json", "skills", "assets", "README.md", "LICENSE"} -EXPECTED_SKILLS = { - "sent", - "sent-messaging", - "sent-contacts", - "sent-templates", - "sent-analytics", - "sent-account-readiness", - "messaging-performance-analyzer", - "rcs-agent-onboarding", - "sender-profile-architect", - "sms-10dlc-registration", - "template-builder-ui", - "waba-embedded-signup", - "waba-template-author", -} -EXPECTED_TOOLS = { - "account.get", - "balance.get", - "contacts.create_many", - "contacts.delete", - "contacts.get", - "contacts.list", - "contacts.message_summary", - "dashboard.contacts", - "dashboard.deliverability", - "dashboard.messages_sent", - "messages.activities.list", - "messages.get", - "messages.send", - "numbers.lookup", - "onboarding.status", - "templates.delete", - "templates.get", - "templates.get_by_name", - "templates.list", +EXPECTED_PACKAGE_ENTRIES = { + "plugin.json", + "mcp.json", + "public-surface.json", + "skills", + "assets", + "README.md", + "LICENSE", } README_CATALOGS = ( ROOT / "README.md", @@ -72,43 +45,18 @@ ADAPTER_README, ) SKILLS_INSTALL_COMMAND = "npx skills add https://github.com/sentdm/sent-plugin --skill sent" -MCP_SKILLS = { - "sent-messaging": { - "messages.send", - "messages.get", - "messages.activities.list", - }, - "sent-contacts": { - "contacts.list", - "contacts.get", - "contacts.create_many", - "contacts.delete", - "contacts.message_summary", - }, - "sent-templates": { - "templates.list", - "templates.get", - "templates.get_by_name", - "templates.delete", - }, - "sent-analytics": { - "numbers.lookup", - "dashboard.messages_sent", - "dashboard.deliverability", - "dashboard.contacts", - }, - "sent-account-readiness": { - "account.get", - "balance.get", - "onboarding.status", - }, -} -MUTATION_TOOLS = { - "messages.send": "sent-messaging", - "contacts.create_many": "sent-contacts", - "contacts.delete": "sent-contacts", - "templates.delete": "sent-templates", -} +try: + REPOSITORY_METADATA = load_repository_metadata(ROOT) + REPOSITORY_METADATA_ERROR: str | None = None +except MetadataError as exc: + REPOSITORY_METADATA = None + REPOSITORY_METADATA_ERROR = str(exc) + +VERSION = REPOSITORY_METADATA.version if REPOSITORY_METADATA else "" +EXPECTED_SKILLS = set(REPOSITORY_METADATA.skills) if REPOSITORY_METADATA else set() +EXPECTED_TOOLS = set(REPOSITORY_METADATA.tools) if REPOSITORY_METADATA else set() +MCP_SKILLS = REPOSITORY_METADATA.tools_by_owner if REPOSITORY_METADATA else {} +MUTATION_TOOLS = REPOSITORY_METADATA.confirmation_tools if REPOSITORY_METADATA else {} PUBLIC_FORBIDDEN = { "Linear URL": re.compile(r"https?://(?:www\.)?linear\.app", re.IGNORECASE), "Slack-derived content": re.compile(r"\bSlack(?:-derived| thread| message| channel)?\b", re.IGNORECASE), @@ -187,7 +135,10 @@ def finish(self) -> None: for error in self.errors: print(f"- {error}", file=sys.stderr) raise SystemExit(1) - print("Validated Sent 0.1.0: 13 skills, 19 MCP tools, manifests, evals, and adapters.") + print( + f"Validated Sent {VERSION}: {len(EXPECTED_SKILLS)} skills, " + f"{len(EXPECTED_TOOLS)} MCP tools, manifests, evals, and adapters." + ) def load_json(path: Path) -> dict: @@ -213,10 +164,14 @@ def parse_skill(path: Path, validation: Validation) -> tuple[dict, str]: def validate_manifests(validation: Validation) -> None: + validation.check( + REPOSITORY_METADATA_ERROR is None, + f"repository metadata is invalid: {REPOSITORY_METADATA_ERROR}", + ) actual_entries = {entry.name for entry in PACKAGE.iterdir()} validation.check( actual_entries == EXPECTED_PACKAGE_ENTRIES, - "packages/sent must contain only plugin.json, mcp.json, skills/, assets/, README.md, and LICENSE; " + "packages/sent must contain only plugin.json, mcp.json, public-surface.json, skills/, assets/, README.md, and LICENSE; " f"found {sorted(actual_entries)}", ) plugin = load_json(PACKAGE / "plugin.json") @@ -231,7 +186,7 @@ def validate_manifests(validation: Validation) -> None: validation.check(plugin.get("$schema") == PLUGIN_SCHEMA_ID, "plugin schema version must be 1.0.0") validation.check(mcp.get("$schema") == MCP_SCHEMA_ID, "MCP schema version must be 1.0.0") validation.check(plugin.get("name") == "sent", "portable plugin name must be sent") - validation.check(plugin.get("version") == VERSION, "portable plugin version must be 0.1.0") + validation.check(plugin.get("version") == VERSION, f"portable plugin version must match canonical {VERSION!r}") expected_server = {"type": "streamable-http", "url": MCP_URL} validation.check( mcp.get("mcpServers") == {"sent": expected_server}, @@ -386,7 +341,11 @@ def validate_tool_contract(validation: Validation) -> None: for tool in tools: validation.check(tool in text, f"{name}: missing documented MCP tool {tool}") advertised.update(tools) - validation.check(len(advertised) == 19, f"expected 19 MCP tools, found {len(advertised)}") + validation.check( + advertised == EXPECTED_TOOLS, + "documented MCP tool ownership must match public-surface.json; " + f"missing={sorted(EXPECTED_TOOLS - advertised)}, unexpected={sorted(advertised - EXPECTED_TOOLS)}", + ) for tool, skill in MUTATION_TOOLS.items(): normalized = (SKILLS / skill / "SKILL.md").read_text(encoding="utf-8").lower() @@ -760,6 +719,21 @@ def validate_openai_submission(validation: Validation) -> None: and all(isinstance(value, bool) for value in annotations.values()), f"OpenAI tool {name} must declare all three Boolean annotations", ) + surface_tool = REPOSITORY_METADATA.tools.get(name) if REPOSITORY_METADATA else None + if surface_tool is not None: + expected_mutation_hints = { + "read_only": (True, False), + "state_changing": (False, False), + "destructive": (False, True), + }[surface_tool.mutation] + validation.check( + ( + annotations.get("readOnlyHint"), + annotations.get("destructiveHint"), + ) + == expected_mutation_hints, + f"OpenAI tool {name} annotations conflict with public mutation class {surface_tool.mutation}", + ) justifications = tool.get("justifications", {}) expected_justifications = { "read_only_justification",