|
10 | 10 |
|
11 | 11 | from klarity_mcp import ( |
12 | 12 | CLAUDE_PLUGIN_DIR, |
| 13 | + CODEX_PLUGIN_DIR, |
13 | 14 | GEMINI_EXTENSION_PATH, |
14 | 15 | KLARITY_MCP_METADATA, |
15 | 16 | REPO_ROOT, |
|
19 | 20 | build_claude_marketplace_manifest, |
20 | 21 | build_claude_mcp_config, |
21 | 22 | build_claude_plugin_manifest, |
| 23 | + build_codex_marketplace_manifest, |
| 24 | + build_codex_plugin_manifest, |
22 | 25 | build_gemini_extension_manifest, |
23 | 26 | build_manifest_texts, |
24 | 27 | render_manifest, |
@@ -102,6 +105,84 @@ def test_claude_marketplace_has_plugin_with_resolvable_source() -> None: |
102 | 105 | ) |
103 | 106 |
|
104 | 107 |
|
| 108 | +def test_codex_plugin_manifest_required_metadata() -> None: |
| 109 | + payload = build_codex_plugin_manifest(KLARITY_MCP_METADATA) |
| 110 | + for key in ("name", "version", "description", "homepage", "repository", "license", "keywords"): |
| 111 | + assert payload.get(key), f"Codex plugin.json is missing required field: {key}" |
| 112 | + assert payload["name"] == KLARITY_MCP_METADATA.plugin_name |
| 113 | + interface = payload["interface"] |
| 114 | + for key in ("displayName", "shortDescription", "longDescription", "category", "brandColor", "defaultPrompt"): |
| 115 | + assert interface.get(key), f"Codex plugin.json interface missing: {key}" |
| 116 | + # Codex caps defaultPrompt to 3 entries (extras are dropped silently). |
| 117 | + assert len(interface["defaultPrompt"]) <= 3, ( |
| 118 | + "Codex truncates `defaultPrompt` past 3 entries; keep the canonical list <= 3 " |
| 119 | + "so the rendered prompts match what's declared." |
| 120 | + ) |
| 121 | + |
| 122 | + |
| 123 | +def test_codex_plugin_mcp_servers_path_resolves_to_real_file() -> None: |
| 124 | + """Codex resolves `mcpServers` from the plugin root, same as Claude. Mirror |
| 125 | + the runtime-fidelity test we already have for Claude. |
| 126 | + """ |
| 127 | + plugin_manifest_path = CODEX_PLUGIN_DIR / "plugin.json" |
| 128 | + plugin_root = plugin_manifest_path.parent.parent |
| 129 | + manifest = json.loads(plugin_manifest_path.read_text()) |
| 130 | + mcp_ref = manifest["mcpServers"] |
| 131 | + assert isinstance(mcp_ref, str) |
| 132 | + mcp_path = (plugin_root / mcp_ref.removeprefix("./")).resolve() |
| 133 | + assert mcp_path.exists(), ( |
| 134 | + f"Codex manifest.mcpServers points to {mcp_ref!r}, which resolves to " |
| 135 | + f"{mcp_path}, but no file is committed there." |
| 136 | + ) |
| 137 | + |
| 138 | + |
| 139 | +def test_codex_plugin_skills_path_resolves_to_real_dir() -> None: |
| 140 | + """Codex resolves `skills` from the plugin root. The bundled skill must |
| 141 | + actually exist there or `/plugin install` ships a plugin with no skills. |
| 142 | + """ |
| 143 | + plugin_manifest_path = CODEX_PLUGIN_DIR / "plugin.json" |
| 144 | + plugin_root = plugin_manifest_path.parent.parent |
| 145 | + manifest = json.loads(plugin_manifest_path.read_text()) |
| 146 | + skills_ref = manifest["skills"] |
| 147 | + assert isinstance(skills_ref, str) |
| 148 | + skills_path = (plugin_root / skills_ref.removeprefix("./")).resolve() |
| 149 | + assert skills_path.exists() and skills_path.is_dir(), ( |
| 150 | + f"Codex manifest.skills points to {skills_ref!r}, which resolves to " |
| 151 | + f"{skills_path}, but no directory is committed there." |
| 152 | + ) |
| 153 | + |
| 154 | + |
| 155 | +def test_codex_marketplace_has_plugin_with_supported_source_shape() -> None: |
| 156 | + """Codex's marketplace loader accepts string-local, object-local, `url`, |
| 157 | + and `git-subdir` source shapes (per openai/codex PR #18017). We use the |
| 158 | + `url` shape so the entire repo can act as the plugin without duplicating |
| 159 | + the manifest under `.agents/plugins/`. Guard the shape so the file stays |
| 160 | + parseable. |
| 161 | + """ |
| 162 | + payload = build_codex_marketplace_manifest(KLARITY_MCP_METADATA) |
| 163 | + assert payload["name"] == KLARITY_MCP_METADATA.plugin_name |
| 164 | + assert isinstance(payload["plugins"], list) and payload["plugins"] |
| 165 | + for plugin in payload["plugins"]: |
| 166 | + assert plugin.get("name"), "marketplace plugin must have a name" |
| 167 | + source = plugin.get("source") |
| 168 | + assert isinstance(source, dict), ( |
| 169 | + "Codex marketplace source must be an object (string-local form is allowed " |
| 170 | + "but we use the explicit object form for the repo-as-plugin pattern)" |
| 171 | + ) |
| 172 | + kind = source.get("source") |
| 173 | + assert kind in {"local", "url", "git-subdir"}, ( |
| 174 | + f"unsupported source kind {kind!r}; must be local/url/git-subdir" |
| 175 | + ) |
| 176 | + if kind == "url": |
| 177 | + assert source.get("url", "").startswith("https://"), ( |
| 178 | + "url-shaped sources must point at an https git URL" |
| 179 | + ) |
| 180 | + policy = plugin.get("policy") |
| 181 | + assert isinstance(policy, dict) |
| 182 | + assert policy.get("installation") in {"AVAILABLE", "NOT_AVAILABLE", "INSTALLED_BY_DEFAULT"} |
| 183 | + assert policy.get("authentication") in {"ON_INSTALL", "ON_USE"} |
| 184 | + |
| 185 | + |
105 | 186 | def test_gemini_extension_name_matches_canonical_plugin_name() -> None: |
106 | 187 | payload = build_gemini_extension_manifest(KLARITY_MCP_METADATA) |
107 | 188 | assert payload["name"] == KLARITY_MCP_METADATA.plugin_name, ( |
|
0 commit comments