From 663befa7894e3825c4dcb2ffb7efe6bdcae9f4c3 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sat, 31 Oct 2026 20:56:08 -0400 Subject: [PATCH 1/5] feat: add Gemini CLI as a host via a published extension branch [DITTO-1798] Add plugins/ditto/gemini-extension.json: the hosted Ditto MCP server as httpUrl (Gemini reads `url` as SSE), OAuth left to Gemini's dynamic discovery on the server's 401. Gemini loads the bundled skills natively from skills/, so `gemini extensions validate ./plugins/ditto` passes and a local install lists 3 skills and the ditto server. Gemini CLI installs a GitHub repository only from its root and has no subdirectory option, and a root gemini-extension.json makes `agy plugin install ` install the whole repository as a Gemini extension with no skills. So instead of a root manifest, scripts/publish_extension_ref.py commits the plugins/ditto tree unchanged as the next fast-forward commit of a dedicated branch, and .github/workflows/gemini-extension.yml runs it on main: gemini extensions install https://github.com/ditto-assistant/ditto-plugins --ref gemini-extension The validator adds the Gemini manifest (MCP-only, httpUrl only); the ditto-memory adapter gains a Gemini CLI section; the vendored generator comes from ditto-skills aab3efa, which versions gemini-extension.json. All manifests move to 0.3.8. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gemini-extension.yml | 41 ++++++++ .kimi-plugin/plugin.json | 2 +- CONTRIBUTING.md | 11 ++- README.md | 23 ++++- RELEASING.md | 5 +- adapters/ditto-memory.md | 1 + plugins/ditto/.antigravity-plugin/plugin.json | 2 +- plugins/ditto/.claude-plugin/plugin.json | 2 +- plugins/ditto/.codex-plugin/plugin.json | 2 +- plugins/ditto/.cursor-plugin/plugin.json | 2 +- plugins/ditto/.github/plugin/plugin.json | 2 +- plugins/ditto/.grok-plugin/plugin.json | 2 +- plugins/ditto/.kimi-plugin/plugin.json | 2 +- plugins/ditto/gemini-extension.json | 10 ++ plugins/ditto/shared-skills.lock.json | 8 +- plugins/ditto/skills/ditto-memory/SKILL.md | 1 + scripts/publish_extension_ref.py | 71 ++++++++++++++ scripts/shared_skills.py | 5 +- scripts/validate_package.py | 14 ++- tests/test_package.py | 32 ++++++- tests/test_publish_extension_ref.py | 96 +++++++++++++++++++ 21 files changed, 311 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/gemini-extension.yml create mode 100644 plugins/ditto/gemini-extension.json create mode 100644 scripts/publish_extension_ref.py create mode 100644 tests/test_publish_extension_ref.py diff --git a/.github/workflows/gemini-extension.yml b/.github/workflows/gemini-extension.yml new file mode 100644 index 0000000..ff01dcf --- /dev/null +++ b/.github/workflows/gemini-extension.yml @@ -0,0 +1,41 @@ +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 + 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..638949a 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; when asked 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..c50c1a5 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": "aab3efaef4e7301c5d76f3e6db4a59d7179f7b24", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", - "adapter_sha256": "7cd895ab3feb0581e7e782de46b8b8220e4281a9ebfe6f6b651753e3a545bac1", - "generator_sha256": "c3cab1c78bcbd50b81bc80698d87567921326ee9886d220ff61c0d5bd18128fe", - "skill_sha256": "dc4a46fc6de30ceac400ced65b122895738757cdcdaf1be529d63bec165e5835", + "adapter_sha256": "c44c1d6054861d915c77f6333f1f972b9c3144cbc8d3c387988c3af9b917d9a4", + "generator_sha256": "df3db4c7b729b9a7253df13746502423c07ab99cc443cdbce3a68f3319131183", + "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..f40cb11 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. @@ -45,7 +46,10 @@ ALLOWED_FILES = {*MANIFESTS.values(), *MCP_FILES, "README.md", "shared-skills.lock.json"} TEXT = {".md", ".json", ".yaml", ".yml", ".txt"} ASSETS = {".png", ".svg"} -SERVER_KEYS = {"type", "transport", "url", "serverUrl"} +SERVER_KEYS = {"type", "transport", "url", "serverUrl", "httpUrl"} +# 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"} def load(path): @@ -68,7 +72,8 @@ def check_server(name, server, plugin): expected = HOSTED_MCP.get(plugin) if not isinstance(server, dict) or set(server) - 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 +97,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)") diff --git a/tests/test_package.py b/tests/test_package.py index f29655d..5cdfccf 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -113,6 +113,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 +170,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..2443e01 --- /dev/null +++ b/tests/test_publish_extension_ref.py @@ -0,0 +1,96 @@ +"""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" +ENV = {**os.environ, "GIT_AUTHOR_NAME": "T", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "T", "GIT_COMMITTER_EMAIL": "t@example.invalid"} + + +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() From 5e89b327ff94ef666ec929ef1c30916e0a634c57 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sat, 31 Oct 2026 21:07:25 -0400 Subject: [PATCH 2/5] docs: describe the Gemini git-clone fallback prompt [DITTO-1798] Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 638949a..0044ca4 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ at the top, skills in `skills/`) and has no subdirectory option. A root manifest 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; when asked 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 +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. From 5c0d2df426ee2ae7d4a7a4dd66dd41f1202d37ea Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sat, 31 Oct 2026 21:14:04 -0400 Subject: [PATCH 3/5] chore: vendor the generator from ditto-skills b406544 [DITTO-1798] Restacked on the DITTO-1799 Ditto Review fixes; regenerated the lock from ditto-skills b406544. No skill bytes change. Co-Authored-By: Claude Opus 5.5 --- plugins/ditto/shared-skills.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ditto/shared-skills.lock.json b/plugins/ditto/shared-skills.lock.json index c50c1a5..bb8ceab 100644 --- a/plugins/ditto/shared-skills.lock.json +++ b/plugins/ditto/shared-skills.lock.json @@ -1,11 +1,11 @@ { "schema_version": 1, "source_repository": "https://github.com/ditto-assistant/ditto-skills", - "source_commit": "aab3efaef4e7301c5d76f3e6db4a59d7179f7b24", + "source_commit": "b4065445c5bc4d637503f0982119b957686dbf50", "host": "plugins", "core_sha256": "5912af5483c469ec15db27858122fc9128fc25cd239202c0fdd9727de7dd5f6b", "adapter_sha256": "c44c1d6054861d915c77f6333f1f972b9c3144cbc8d3c387988c3af9b917d9a4", - "generator_sha256": "df3db4c7b729b9a7253df13746502423c07ab99cc443cdbce3a68f3319131183", + "generator_sha256": "f0fe5c02da33e861c0962230d0d1486a0542b915808c33b8bd8d4350012467db", "skill_sha256": "d74135ea9af4d33fe1fb55727b3dbe93e448449ac124f95b333f0aebfdf880ed", "files_sha256": { "plugins/ditto/skills/curate-music/SKILL.md": "b498f8639aeb64e59f88830ac162a894f9f128b7e1f2db6a7c528092904c1eb4", From 4c52018acae8677c94f40f98dc1570419c3026fd Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 1 Nov 2026 16:12:01 -0500 Subject: [PATCH 4/5] fix(validate): accept httpUrl only from the Gemini manifest [DITTO-1798] Every other host reads url or serverUrl, so an httpUrl-only declaration there validated while installing with no working MCP server. Also bound the gemini-extension publish job to 15 minutes so a stalled run cannot hold the concurrency slot for hours. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gemini-extension.yml | 1 + scripts/validate_package.py | 10 ++++++---- tests/test_package.py | 8 ++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gemini-extension.yml b/.github/workflows/gemini-extension.yml index ff01dcf..8406fd6 100644 --- a/.github/workflows/gemini-extension.yml +++ b/.github/workflows/gemini-extension.yml @@ -20,6 +20,7 @@ jobs: publish: if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 + timeout-minutes: 15 permissions: contents: write steps: diff --git a/scripts/validate_package.py b/scripts/validate_package.py index f40cb11..1765df9 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -46,10 +46,12 @@ ALLOWED_FILES = {*MANIFESTS.values(), *MCP_FILES, "README.md", "shared-skills.lock.json"} TEXT = {".md", ".json", ".yaml", ".yml", ".txt"} ASSETS = {".png", ".svg"} -SERVER_KEYS = {"type", "transport", "url", "serverUrl", "httpUrl"} +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): @@ -67,10 +69,10 @@ 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") urls = [server[k] for k in ("url", "serverUrl", "httpUrl") if k in server] url = urls[0] if len(urls) == 1 else None @@ -114,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 5cdfccf..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). From 7843a3e02640ca56ef05c2cc71cc63891af578f5 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 1 Nov 2026 16:48:14 -0500 Subject: [PATCH 5/5] test(publish): isolate throwaway repos from global Git config [DITTO-1798] A global commit.gpgsign or hooks path made the publish tests depend on the machine running them. Co-Authored-By: Claude Opus 5.5 --- tests/test_publish_extension_ref.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_publish_extension_ref.py b/tests/test_publish_extension_ref.py index 2443e01..97226ae 100644 --- a/tests/test_publish_extension_ref.py +++ b/tests/test_publish_extension_ref.py @@ -8,8 +8,11 @@ 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_COMMITTER_NAME": "T", "GIT_COMMITTER_EMAIL": "t@example.invalid", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull} class PublishExtensionRefTests(unittest.TestCase):