diff --git a/.github/workflows/gemini-extension.yml b/.github/workflows/gemini-extension.yml new file mode 100644 index 0000000..8406fd6 --- /dev/null +++ b/.github/workflows/gemini-extension.yml @@ -0,0 +1,42 @@ +name: Gemini extension branch +# Gemini CLI installs a GitHub repository only from its root, so plugins/ditto is +# published unchanged as the root of the gemini-extension branch: +# gemini extensions install https://github.com/ditto-assistant/ditto-plugins --ref gemini-extension +# Fast-forward only; nothing is force-pushed, released or submitted. +on: + push: + branches: [main] + paths: + - 'plugins/ditto/**' + - 'scripts/publish_extension_ref.py' + - '.github/workflows/gemini-extension.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gemini-extension-branch + cancel-in-progress: false +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Verify the package before publishing it + run: | + python3 scripts/shared_skills.py verify --host plugins --repo . + python3 -m unittest discover -s tests + python3 scripts/validate_package.py + - name: Publish plugins/ditto to the gemini-extension branch + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: python3 scripts/publish_extension_ref.py --source "$GITHUB_SHA" --branch gemini-extension diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index 3e464e3..f71f25c 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4bb6a6..4547f31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,10 @@ host. Every root marketplace lists the same plugins, and skill needs this repository's host-connection adapter appended to it. 2. **Scaffold `plugins//`** here by copying `plugins/ditto/` without `skills/` and `shared-skills.lock.json`. Then: - - set `name` to `` in all seven host manifests (`.claude-plugin`, + - set `name` to `` in all eight host manifests (`.claude-plugin`, `.cursor-plugin`, `.grok-plugin`, `.codex-plugin`, `.github/plugin`, - `.antigravity-plugin`, `.kimi-plugin`), and give them one shared `version`. + `.antigravity-plugin`, `.kimi-plugin`, `gemini-extension.json`), and give them + one shared `version`. - write host-tailored descriptions, keywords and display names. For example, "Sign in through Cursor" goes in the Cursor manifest, and Grok keywords must be brand-scoped because the Grok catalog uses them for its call-to-action. @@ -23,10 +24,14 @@ host. Every root marketplace lists the same plugins, and - keep the Agent Plugins `$schema` in `.antigravity-plugin/plugin.json` and leave out its `skills` field; `agy plugin validate --strict` needs both. - rename the MCP server key to `` in `.mcp.json`, `mcp.json`, - `mcp_config.json` and the inline Copilot and Kimi entries. + `mcp_config.json` and the inline Copilot, Kimi and Gemini entries. - Kimi Code installs a GitHub URL only from the repository root, and the root `.kimi-plugin/plugin.json` serves the `ditto` plugin. Other plugins install in Kimi from a clone of `plugins/`. + - Gemini CLI installs from GitHub only through the `gemini-extension` branch, which + `.github/workflows/gemini-extension.yml` publishes from `plugins/ditto`. Another + plugin needs its own branch (`scripts/publish_extension_ref.py --plugin + --branch `) or installs from a clone. 3. **Allow its endpoint.** Add `: ` to `HOSTED_MCP` in `scripts/validate_package.py`. Only hosted HTTP servers are allowed: no commands, headers, env or credentials. diff --git a/README.md b/README.md index a377092..0044ca4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ own OAuth flow: no API keys, npm helpers, CLI bootstrap or lifecycle hooks. | Codex | `.agents/plugins/marketplace.json` | `codex plugin marketplace add ditto-assistant/ditto-plugins` then `codex plugin add ditto@ditto-plugins` (or install from `/plugins`) | | GitHub Copilot CLI / VS Code | `.github/plugin/marketplace.json` | `copilot plugin marketplace add ditto-assistant/ditto-plugins` then `copilot plugin install ditto@ditto-plugins` | | Antigravity | `plugins/ditto/.antigravity-plugin/` | `agy plugin install https://github.com/ditto-assistant/ditto-plugins`, or `/plugin install ./plugins/ditto` from a clone | +| Gemini CLI | `plugins/ditto/gemini-extension.json` (published to the `gemini-extension` branch) | `gemini extensions install https://github.com/ditto-assistant/ditto-plugins --ref gemini-extension`, or `gemini extensions install ./plugins/ditto` from a clone | | Kimi Code | `.kimi-plugin/plugin.json` (root, points into `plugins/ditto`) | `/plugins install https://github.com/ditto-assistant/ditto-plugins` in Kimi Code, or install `plugins/ditto` from a clone | ### Claude @@ -127,6 +128,24 @@ rebased, and the validator keeps the two identical. To pin a branch or tag, use `https://github.com/ditto-assistant/ditto-plugins/tree/`. From a clone, install `./plugins/ditto` instead. +### Gemini CLI + +```sh +gemini extensions install https://github.com/ditto-assistant/ditto-plugins --ref gemini-extension +``` + +Gemini CLI installs a GitHub repository only from its root (`gemini-extension.json` +at the top, skills in `skills/`) and has no subdirectory option. A root manifest here +would also make Antigravity install the whole repository as a Gemini extension, so +`.github/workflows/gemini-extension.yml` publishes `plugins/ditto` unchanged as the root +of the `gemini-extension` branch after each change on `main`. Gemini first looks for a +GitHub release with that tag, finds none, and asks whether to "attempt to install via +git clone instead": answer yes. `gemini extensions list` then shows the three skills +and the `ditto` MCP server. Sign in with `/mcp auth ditto` in a Gemini CLI session; `gemini mcp +list` reports the server as Disconnected until then. From a clone, run +`gemini extensions install ./plugins/ditto` or `gemini extensions link ./plugins/ditto`. +`gemini extensions update ditto` follows the branch. + The bundled skills are the verified loading path. The hosted MCP server also offers the canonical skill files through `skills/list`, `skills/get` and `resources/read` for compatible importers; keep the bundle installed. See the @@ -157,11 +176,12 @@ the host-labelled sections of [`adapters/ditto-memory.md`](adapters/ditto-memory .agents/plugins/marketplace.json # Codex .github/plugin/marketplace.json # GitHub Copilot / VS Code .kimi-plugin/plugin.json # Kimi Code GitHub installs (ditto, skills rebased into plugins/ditto) +.github/workflows/gemini-extension.yml # publishes plugins/ditto as the root of the gemini-extension branch adapters/ditto-memory.md # per-host connection guidance (source for the generated skill) plugins// .claude-plugin/plugin.json .cursor-plugin/plugin.json .grok-plugin/plugin.json .codex-plugin/plugin.json .github/plugin/plugin.json .antigravity-plugin/plugin.json - .kimi-plugin/plugin.json + .kimi-plugin/plugin.json gemini-extension.json # Gemini CLI (httpUrl, streamable HTTP) .mcp.json # Claude, Cursor, Grok (type http) mcp.json # Codex; Antigravity in Agent Plugins mode (portable, streamable-http) mcp_config.json # Antigravity native mode (serverUrl) @@ -181,6 +201,7 @@ python3 scripts/validate_package.py claude plugin validate ./plugins/ditto grok plugin validate ./plugins/ditto agy plugin validate --strict ./plugins/ditto +gemini extensions validate ./plugins/ditto ``` Source, package validation, installed plugin, authenticated MCP and actual tool diff --git a/RELEASING.md b/RELEASING.md index 27550af..dc4a9db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -17,8 +17,9 @@ not distribute or refresh a CLI, run hooks or provision agent accounts. generator automatically patch-bumps changed skill content; avoid a second bump. 3. Run the offline verifier, `python3 -m unittest discover -s tests -v`, `python3 scripts/validate_package.py`, `claude plugin validate ./plugins/ditto`, and - `grok plugin validate ./plugins/ditto` and `agy plugin validate --strict ./plugins/ditto` - where those CLIs are available. + `grok plugin validate ./plugins/ditto`, `agy plugin validate --strict ./plugins/ditto` and + `gemini extensions validate ./plugins/ditto` where those CLIs are available. Merging a + change under `plugins/ditto` also republishes the `gemini-extension` branch. 4. Open a PR and inspect current-head CI and Ditto Review. Merge only when authorized. Every host marketplace tracks main, so merging updates the plugin on all hosts. 5. Refresh the marketplace and update Ditto in the actual host. Verify the installed diff --git a/adapters/ditto-memory.md b/adapters/ditto-memory.md index 4f15e46..188c1d7 100644 --- a/adapters/ditto-memory.md +++ b/adapters/ditto-memory.md @@ -8,6 +8,7 @@ Use the hosted Ditto MCP server at https://api.heyditto.ai/mcp, declared by this - **Codex and ChatGPT**: the server comes from `mcp.json`. Authenticate through the plugin's install or connection prompt; in the Codex CLI, `/mcp` shows its status. - **GitHub Copilot and VS Code**: the server is declared in the plugin manifest. Start and sign in from the MCP servers view or the Copilot CLI's `/mcp`. - **Antigravity**: the server comes from `mcp_config.json`. Sign in from the MCP servers panel when prompted. +- **Gemini CLI**: the server is declared in `gemini-extension.json`. Check it with `/mcp` and sign in with `/mcp auth ditto`. - **Other hosts**: use whatever MCP connection UI the host provides for the plugin's declared server. Discover the connected server's live tools and resources, including `memory://capabilities` when exposed. Use its MCP tools for search, full-content fetch and authorized saves or updates; do not invent tool names or assume every host exposes every capability. If the server is missing or authentication has expired, explain the gap and ask the user to connect or reconnect through the host. Never request API keys in chat or fall back to shell memory commands. diff --git a/plugins/ditto/.antigravity-plugin/plugin.json b/plugins/ditto/.antigravity-plugin/plugin.json index a6f9c47..61438c6 100644 --- a/plugins/ditto/.antigravity-plugin/plugin.json +++ b/plugins/ditto/.antigravity-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ditto", "displayName": "Ditto", - "version": "0.3.7", + "version": "0.3.8", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Antigravity.", "logo": "assets/icon.png", "suggestedPrompts": [ diff --git a/plugins/ditto/.claude-plugin/plugin.json b/plugins/ditto/.claude-plugin/plugin.json index 1f036cc..85f6024 100644 --- a/plugins/ditto/.claude-plugin/plugin.json +++ b/plugins/ditto/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Claude.", "author": { "name": "Omni Aura LLC", diff --git a/plugins/ditto/.codex-plugin/plugin.json b/plugins/ditto/.codex-plugin/plugin.json index c679f03..b270147 100644 --- a/plugins/ditto/.codex-plugin/plugin.json +++ b/plugins/ditto/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.cursor-plugin/plugin.json b/plugins/ditto/.cursor-plugin/plugin.json index 72a53bb..81c5806 100644 --- a/plugins/ditto/.cursor-plugin/plugin.json +++ b/plugins/ditto/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "ditto", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Cursor.", - "version": "0.3.7", + "version": "0.3.8", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai" diff --git a/plugins/ditto/.github/plugin/plugin.json b/plugins/ditto/.github/plugin/plugin.json index 620d437..1f76205 100644 --- a/plugins/ditto/.github/plugin/plugin.json +++ b/plugins/ditto/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "displayName": "Ditto", "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through GitHub Copilot.", "author": { diff --git a/plugins/ditto/.grok-plugin/plugin.json b/plugins/ditto/.grok-plugin/plugin.json index c48ff93..922bf78 100644 --- a/plugins/ditto/.grok-plugin/plugin.json +++ b/plugins/ditto/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/.kimi-plugin/plugin.json b/plugins/ditto/.kimi-plugin/plugin.json index 1ea5d1a..4024221 100644 --- a/plugins/ditto/.kimi-plugin/plugin.json +++ b/plugins/ditto/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ditto", - "version": "0.3.7", + "version": "0.3.8", "author": { "name": "Omni Aura LLC", "email": "support@heyditto.ai", diff --git a/plugins/ditto/gemini-extension.json b/plugins/ditto/gemini-extension.json new file mode 100644 index 0000000..77621a8 --- /dev/null +++ b/plugins/ditto/gemini-extension.json @@ -0,0 +1,10 @@ +{ + "name": "ditto", + "version": "0.3.8", + "description": "Search and save Ditto memories through hosted MCP, curate music, and hand off chats with verified continuation notes. Archival and owned workspace cleanup depend on host capabilities and authorization. Sign in through Gemini CLI.", + "mcpServers": { + "ditto": { + "httpUrl": "https://api.heyditto.ai/mcp" + } + } +} diff --git a/plugins/ditto/shared-skills.lock.json b/plugins/ditto/shared-skills.lock.json index bbed476..bb8ceab 100644 --- a/plugins/ditto/shared-skills.lock.json +++ b/plugins/ditto/shared-skills.lock.json @@ -1,12 +1,12 @@ { "schema_version": 1, "source_repository": "https://github.com/ditto-assistant/ditto-skills", - "source_commit": "b65d94a9efaad70f0677550a030bb09ec57c0d91", + "source_commit": "b4065445c5bc4d637503f0982119b957686dbf50", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", - "adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1", - "generator_sha256": "c3cab1c78bcbd50b81bc80698d87567921326ee9886d220ff61c0d5bd18128fe", - "skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835", + "adapter_sha256": "c44c1d6054861d915c77f6333f1f972b9c3144cbc8d3c387988c3af9b917d9a4", + "generator_sha256": "f0fe5c02da33e861c0962230d0d1486a0542b915808c33b8bd8d4350012467db", + "skill_sha256": "d74135ea9af4d33fe1fb55727b3dbe93e448449ac124f95b333f0aebfdf880ed", "files_sha256": { "plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4", "plugins/ditto/skills/curate-music/references/youtube-music.md": "e763797cc7821114682c7b92cec6919e6f957792cdc54756f670c7086b09e52c", diff --git a/plugins/ditto/skills/ditto-memory/SKILL.md b/plugins/ditto/skills/ditto-memory/SKILL.md index 7017257..5330b8f 100644 --- a/plugins/ditto/skills/ditto-memory/SKILL.md +++ b/plugins/ditto/skills/ditto-memory/SKILL.md @@ -43,6 +43,7 @@ Use the hosted Ditto MCP server at https://api.heyditto.ai/mcp, declared by this - **Codex and ChatGPT**: the server comes from `mcp.json`. Authenticate through the plugin's install or connection prompt; in the Codex CLI, `/mcp` shows its status. - **GitHub Copilot and VS Code**: the server is declared in the plugin manifest. Start and sign in from the MCP servers view or the Copilot CLI's `/mcp`. - **Antigravity**: the server comes from `mcp_config.json`. Sign in from the MCP servers panel when prompted. +- **Gemini CLI**: the server is declared in `gemini-extension.json`. Check it with `/mcp` and sign in with `/mcp auth ditto`. - **Other hosts**: use whatever MCP connection UI the host provides for the plugin's declared server. Discover the connected server's live tools and resources, including `memory://capabilities` when exposed. Use its MCP tools for search, full-content fetch and authorized saves or updates; do not invent tool names or assume every host exposes every capability. If the server is missing or authentication has expired, explain the gap and ask the user to connect or reconnect through the host. Never request API keys in chat or fall back to shell memory commands. diff --git a/scripts/publish_extension_ref.py b/scripts/publish_extension_ref.py new file mode 100644 index 0000000..8fb2524 --- /dev/null +++ b/scripts/publish_extension_ref.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Publish one plugin folder as the root of a dedicated branch for Gemini CLI. + +Gemini CLI installs a GitHub repository only from its root: the manifest must +be `gemini-extension.json` at the top of the checkout, and skills load only from +`/skills/`. It accepts `--ref` but no subdirectory. A root manifest here +would also make `agy plugin install ` treat the whole repository as a +Gemini extension instead of installing plugins/ditto. + +This script commits the tree of `:plugins/` unchanged as the +next commit of the `gemini-extension` branch, so + + gemini extensions install https://github.com/ditto-assistant/ditto-plugins --ref gemini-extension + +installs plugins/ditto with its manifest, skills and MCP server. Each publish +fast-forwards the branch from its previous head. It never force-pushes and does +nothing when the tree is unchanged. +""" +import argparse +import subprocess +import sys + +MANIFEST = "gemini-extension.json" + + +def git(*args, check=True): + result = subprocess.run(["git", *args], capture_output=True, text=True) + if check and result.returncode: + raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}") + return result + + +def publish(source, prefix, branch, remote, dry_run=False): + commit = git("rev-parse", "--verify", f"{source}^{{commit}}").stdout.strip() + tree = git("rev-parse", "--verify", f"{commit}:{prefix}").stdout.strip() + if git("cat-file", "-e", f"{commit}:{prefix}/{MANIFEST}", check=False).returncode: + raise ValueError(f"{prefix}/{MANIFEST} is missing at {commit}") + tracking = f"refs/remotes/{remote}/{branch}" + fetched = git("fetch", "--no-tags", remote, f"+refs/heads/{branch}:{tracking}", check=False) + if fetched.returncode and git("ls-remote", "--exit-code", "--heads", remote, branch, check=False).returncode != 2: + raise RuntimeError(f"could not fetch {branch}: {fetched.stderr.strip()}") + parent = git("rev-parse", "--verify", "--quiet", tracking, check=False).stdout.strip() if not fetched.returncode else "" + if parent and git("rev-parse", f"{parent}^{{tree}}").stdout.strip() == tree: + print(f"{branch}: already {prefix} from {commit[:12]} (tree {tree[:12]}); nothing to publish") + return None + message = f"{prefix} from {commit}\n\nGenerated by scripts/publish_extension_ref.py; do not edit this branch.\n" + new = git("commit-tree", tree, *(["-p", parent] if parent else []), "-m", message).stdout.strip() + if dry_run: + print(f"{branch}: dry run; would push {new[:12]} ({prefix} at {commit[:12]}) on top of {parent[:12] or 'nothing'}") + return new + git("push", remote, f"{new}:refs/heads/{branch}") + print(f"{branch}: published {new} ({prefix} at {commit})") + return new + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--source", default="HEAD", help="commit to publish from (default HEAD)") + parser.add_argument("--plugin", default="ditto") + parser.add_argument("--branch", default="gemini-extension") + parser.add_argument("--remote", default="origin") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + try: + publish(args.source, f"plugins/{args.plugin}", args.branch, args.remote, args.dry_run) + except (RuntimeError, ValueError) as error: + sys.exit(str(error)) + + +if __name__ == "__main__": + main() diff --git a/scripts/shared_skills.py b/scripts/shared_skills.py index 92e0f34..bba733b 100644 --- a/scripts/shared_skills.py +++ b/scripts/shared_skills.py @@ -6,8 +6,8 @@ whose generated SKILL.md is the canonical core plus that distribution's host-connection adapter. The ``plugins`` host is the multi-host monorepo (ditto-assistant/ditto-plugins): one shared skills folder per plugin serves -Claude, Cursor, Grok, Codex, Copilot, Antigravity and Kimi manifests, so every -versioned manifest in the plugin directory is bumped together. +Claude, Cursor, Grok, Codex, Copilot, Antigravity, Kimi and Gemini CLI manifests, +so every versioned manifest in the plugin directory is bumped together. """ import argparse import hashlib @@ -40,6 +40,7 @@ ".github/plugin/plugin.json", ".antigravity-plugin/plugin.json", ".kimi-plugin/plugin.json", + "gemini-extension.json", ], } MANIFESTS = {host: f"plugins/{DEFAULT_PLUGIN}/{paths[0]}" for host, paths in PLUGIN_MANIFESTS.items()} diff --git a/scripts/validate_package.py b/scripts/validate_package.py index dccba80..1765df9 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -27,6 +27,7 @@ "copilot": ".github/plugin/plugin.json", "antigravity": ".antigravity-plugin/plugin.json", "kimi": ".kimi-plugin/plugin.json", + "gemini": "gemini-extension.json", } # Hosts that install a GitHub URL only from the repository root get a root # manifest that points into one plugin. Kimi Code reads /.kimi-plugin/plugin.json. @@ -46,6 +47,11 @@ TEXT = {".md", ".json", ".yaml", ".yml", ".txt"} ASSETS = {".png", ".svg"} SERVER_KEYS = {"type", "transport", "url", "serverUrl"} +# Gemini CLI reads `url` as SSE and `httpUrl` as streamable HTTP, and loads +# commands/, hooks/, policies/ and agents/ from the extension directory itself. +GEMINI_KEYS = {"name", "version", "description", "mcpServers"} +# Only Gemini reads httpUrl; another host given one would install without MCP. +GEMINI_SERVER_KEYS = {"httpUrl"} def load(path): @@ -63,12 +69,13 @@ def plugin_dir(root, entry): return root / source -def check_server(name, server, plugin): +def check_server(name, server, plugin, keys=SERVER_KEYS): """A hosted server declaration: URL plus transport only.""" expected = HOSTED_MCP.get(plugin) - if not isinstance(server, dict) or set(server) - SERVER_KEYS: + if not isinstance(server, dict) or set(server) - keys: raise ValueError("Expected hosted Ditto MCP without launcher, headers or credentials") - url = server.get("url", server.get("serverUrl")) + urls = [server[k] for k in ("url", "serverUrl", "httpUrl") if k in server] + url = urls[0] if len(urls) == 1 else None kind = server.get("type", server.get("transport", "http")) if expected is None or url != expected or name != plugin or kind not in {"http", "streamable-http"}: raise ValueError("Expected hosted Ditto MCP without launcher, headers or credentials") @@ -92,6 +99,11 @@ def check_manifest(host, manifest, plugin, folder): mcp = manifest.get("mcpServers") if host == "claude" and mcp is not None: raise ValueError("Unexpected runtime component in MCP-only claude manifest") # Claude loads .mcp.json itself + if host == "gemini": + if set(manifest) - GEMINI_KEYS or not isinstance(manifest.get("mcpServers"), dict): + raise ValueError(f"gemini manifest may only set {sorted(GEMINI_KEYS)}") + if any(set(server) != {"httpUrl"} for server in manifest["mcpServers"].values() if isinstance(server, dict)): + raise ValueError("gemini manifest must declare the hosted server with httpUrl (streamable HTTP) only") if host == "antigravity": if manifest.get("$schema") != AGENT_PLUGINS_SCHEMA: raise ValueError("antigravity manifest must declare the Agent Plugins $schema (agy --strict warns on other hosts' MCP files otherwise)") @@ -104,7 +116,7 @@ def check_manifest(host, manifest, plugin, folder): if len(mcp) != 1: raise ValueError("Expected hosted Ditto MCP without launcher, headers or credentials") for name, server in mcp.items(): - check_server(name, server, plugin) + check_server(name, server, plugin, GEMINI_SERVER_KEYS if host == "gemini" else SERVER_KEYS) elif mcp is not None: raise ValueError(f"Unexpected mcpServers in {host} manifest") interface = manifest.get("interface") if isinstance(manifest.get("interface"), dict) else {} diff --git a/tests/test_package.py b/tests/test_package.py index f29655d..8c5189a 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -61,6 +61,14 @@ def test_inline_host_servers_are_checked(self): with self.assertRaisesRegex(ValueError, "Expected hosted Ditto MCP"): package.validate(self.root) + def test_http_url_is_only_accepted_from_gemini(self): + for relative in ("plugins/ditto/.mcp.json", "plugins/ditto/mcp_config.json", "plugins/ditto/.kimi-plugin/plugin.json"): + with self.subTest(relative=relative): + self.setUp() + self.edit(relative, lambda d: d["mcpServers"].__setitem__("ditto", {"httpUrl": "https://api.heyditto.ai/mcp"})) + with self.assertRaisesRegex(ValueError, "Expected hosted Ditto MCP"): + package.validate(self.root) + def test_antigravity_manifest_loads_as_agent_plugins_package(self): # Without the portable $schema, agy warns that .mcp.json and mcp.json are # never loaded and `agy plugin validate --strict` fails (DITTO-1800). @@ -113,6 +121,36 @@ def test_root_kimi_manifest_points_into_the_plugin(self): with self.assertRaisesRegex(ValueError, "Linked release input"): package.validate(self.root) + def test_gemini_extension_is_mcp_only_streamable_http(self): + relative = "plugins/ditto/gemini-extension.json" + cases = ( + (lambda d: d["mcpServers"].__setitem__("ditto", {"url": "https://api.heyditto.ai/mcp"}), "httpUrl"), + (lambda d: d["mcpServers"]["ditto"].__setitem__("headers", {"Authorization": "Bearer x"}), "httpUrl"), + (lambda d: d.__setitem__("excludeTools", ["run_shell_command"]), "may only set"), + (lambda d: d.__setitem__("contextFileName", "GEMINI.md"), "may only set"), + (lambda d: d["mcpServers"]["ditto"].__setitem__("httpUrl", "https://example.com/mcp"), "Expected hosted Ditto MCP"), + ) + for change, message in cases: + with self.subTest(message=message): + self.setUp() + self.edit(relative, change) + with self.assertRaisesRegex(ValueError, message): + package.validate(self.root) + self.setUp() + (self.root / relative).unlink() + with self.assertRaisesRegex(ValueError, "Missing gemini manifest"): + package.validate(self.root) + + def test_gemini_extension_directories_are_not_release_files(self): + for folder in ("commands", "hooks", "policies", "agents"): + with self.subTest(folder=folder): + self.setUp() + extra = self.root / "plugins/ditto" / folder / "x.toml" + extra.parent.mkdir() + extra.write_text("prompt = 'x'\n") + with self.assertRaisesRegex(ValueError, "Unexpected release file"): + package.validate(self.root) + def test_other_endpoint_is_rejected(self): f = self.root / "plugins/ditto/.mcp.json" f.write_text(f.read_text().replace("api.heyditto.ai", "example.com")) @@ -140,7 +178,7 @@ def test_new_plugin_must_be_listed_in_every_marketplace(self): self.edit(f"plugins/ditto-extra/{relative}", lambda d: d.__setitem__("name", "ditto-extra")) for relative in ("plugins/ditto-extra/.mcp.json", "plugins/ditto-extra/mcp.json", "plugins/ditto-extra/mcp_config.json"): self.edit(relative, lambda d: d.__setitem__("mcpServers", {"ditto-extra": d["mcpServers"]["ditto"]})) - for relative in ("plugins/ditto-extra/.github/plugin/plugin.json", "plugins/ditto-extra/.kimi-plugin/plugin.json"): + for relative in ("plugins/ditto-extra/.github/plugin/plugin.json", "plugins/ditto-extra/.kimi-plugin/plugin.json", "plugins/ditto-extra/gemini-extension.json"): self.edit(relative, lambda d: d.__setitem__("mcpServers", {"ditto-extra": d["mcpServers"]["ditto"]})) with patch.dict(package.HOSTED_MCP, {"ditto-extra": "https://api.heyditto.ai/mcp"}): with self.assertRaisesRegex(ValueError, "listed in every marketplace"): diff --git a/tests/test_publish_extension_ref.py b/tests/test_publish_extension_ref.py new file mode 100644 index 0000000..97226ae --- /dev/null +++ b/tests/test_publish_extension_ref.py @@ -0,0 +1,99 @@ +"""The Gemini extension branch carries plugins/ditto as its root and only fast-forwards.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/publish_extension_ref.py" +# Empty global/system config, so a developer's signing, hooks or templates +# cannot reach these throwaway repositories. +ENV = {**os.environ, "GIT_AUTHOR_NAME": "T", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "T", "GIT_COMMITTER_EMAIL": "t@example.invalid", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull} + + +class PublishExtensionRefTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.remote = base / "remote.git" + self.work = base / "work" + self.git(base, "init", "-q", "--bare", str(self.remote)) + self.git(base, "init", "-q", "-b", "main", str(self.work)) + self.git(self.work, "remote", "add", "origin", str(self.remote)) + self.write("plugins/ditto/gemini-extension.json", '{"name": "ditto", "version": "1.0.0"}\n') + self.write("plugins/ditto/skills/a/SKILL.md", "---\nname: a\ndescription: A.\n---\n") + self.write("README.md", "root only\n") + self.commit("one") + + def git(self, cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, env=ENV, check=True, capture_output=True, text=True).stdout.strip() + + def write(self, relative, text): + path = self.work / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + def commit(self, message): + self.git(self.work, "add", "-A") + self.git(self.work, "commit", "-qm", message) + + def publish(self, *args): + return subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.work, env=ENV, capture_output=True, text=True) + + def remote_tree(self, ref="gemini-extension"): + return self.git(self.remote, "ls-tree", "-r", "--name-only", ref).splitlines() + + def test_branch_root_is_the_plugin_folder_and_republishing_is_a_fast_forward(self): + self.assertEqual(self.publish().returncode, 0) + self.assertEqual(self.remote_tree(), ["gemini-extension.json", "skills/a/SKILL.md"]) + first = self.git(self.remote, "rev-parse", "gemini-extension") + unchanged = self.publish() + self.assertIn("nothing to publish", unchanged.stdout) + self.assertEqual(self.git(self.remote, "rev-parse", "gemini-extension"), first) + self.write("README.md", "root edits do not republish\n") + self.commit("root") + self.assertIn("nothing to publish", self.publish().stdout) + self.write("plugins/ditto/gemini-extension.json", '{"name": "ditto", "version": "1.0.1"}\n') + self.commit("bump") + self.assertEqual(self.publish().returncode, 0) + second = self.git(self.remote, "rev-parse", "gemini-extension") + self.assertEqual(self.git(self.remote, "rev-parse", f"{second}^"), first) + self.assertIn("1.0.1", self.git(self.remote, "show", "gemini-extension:gemini-extension.json")) + + def test_dry_run_pushes_nothing(self): + result = self.publish("--dry-run") + self.assertIn("dry run", result.stdout) + self.assertEqual(self.git(self.remote, "for-each-ref", "refs/heads"), "") + + def test_missing_manifest_is_refused(self): + self.git(self.work, "rm", "-q", "plugins/ditto/gemini-extension.json") + self.commit("drop") + result = self.publish() + self.assertNotEqual(result.returncode, 0) + self.assertIn("gemini-extension.json is missing", result.stderr) + self.assertEqual(self.git(self.remote, "for-each-ref", "refs/heads"), "") + + def test_a_branch_moved_by_someone_else_is_not_overwritten(self): + self.assertEqual(self.publish().returncode, 0) + other = Path(self.tmp.name) / "other" + self.git(Path(self.tmp.name), "clone", "-q", "--branch", "gemini-extension", str(self.remote), str(other)) + (other / "extra.md").write_text("x\n") + self.git(other, "add", "-A") + self.git(other, "commit", "-qm", "manual") + self.git(other, "push", "-q", "origin", "gemini-extension") + moved = self.git(self.remote, "rev-parse", "gemini-extension") + self.write("plugins/ditto/skills/a/SKILL.md", "---\nname: a\ndescription: A, revised.\n---\n") + self.commit("edit") + # The fetched head is the parent, so the push is a fast-forward over the manual commit. + self.assertEqual(self.publish().returncode, 0) + self.assertEqual(self.git(self.remote, "rev-parse", "gemini-extension^"), moved) + self.assertNotIn("extra.md", self.remote_tree()) + + +if __name__ == "__main__": + unittest.main()