Skip to content

feat(plugin): self-contained Gemini CLI extension in plugins/gemini/#2803

Merged
max-sixty merged 5 commits into
mainfrom
dsa/gemini-extension
May 18, 2026
Merged

feat(plugin): self-contained Gemini CLI extension in plugins/gemini/#2803
max-sixty merged 5 commits into
mainfrom
dsa/gemini-extension

Conversation

@max-sixty
Copy link
Copy Markdown
Owner

@max-sixty max-sixty commented May 18, 2026

Supersedes #2765 (the original branch was based on a pre-consolidation main and its ${extensionPath}/.claude-plugin/hooks/wt.sh reference was deleted by #2789 — see "Why a new PR" below).

Adds a Gemini CLI extension for wt list activity tracking, integrated with the consolidated plugin layout from #2789.

Design

Gemini's loader has no path indirection: gemini-extension.json is {name, version, description} only — Gemini hard-probes ${extensionPath}/hooks/hooks.json and ${extensionPath}/skills/ relative to wherever the manifest sits. And gemini extensions install copies the extension directory. So the extension must be entirely self-contained re: its hooks/shim. It is not freely relocatable, though: gemini extensions install owner/repo clones and reads <clone-root>/gemini-extension.json, so with the manifest under plugins/gemini/ the github-URL install path is unsupported — install is local-path only (gemini extensions install <repo>/plugins/gemini) until a wt config plugins gemini install command exists. This is an accepted tradeoff of keeping the repo root clean; documented in CLAUDE.md → "Plugin Layout" and the test docstring.

The extension lives in its own payload dir, plugins/gemini/ — consistent with #2789's principle (every tool's payload under plugins/, repo root clean). It can't share plugins/worktrunk/ because that dir's hooks/hooks.json slot is Claude's (incompatible format).

plugins/gemini/
├── gemini-extension.json     manifest
├── hooks/hooks.json          BeforeAgent→🤖  AfterAgent→💬  SessionEnd→clear
├── hooks/wt.sh               bundled, byte-identical to plugins/worktrunk/hooks/wt.sh
└── skills -> ../../skills     reuses the repo-root skills (Gemini accepts symlinked skills)

wt.sh is bundled (not referenced cross-dir) specifically because install copies the dir — a ../worktrunk/hooks/wt.sh reference would break in the installed copy. wt.sh is a generic PATH-shim (exec wt "$@" + a Windows-Terminal-alias guard), so the duplication is a trivial shim, not logic — and test_plugin_layout_is_consolidated asserts the two copies stay byte-identical so they can't silently diverge.

Gemini activity tracking is genuinely viable (unlike Codex): Gemini has an AfterAgent turn-end event, so 🤖→💬 actually works within a session.

Why a new PR instead of updating #2765

#2765's branch was never rebased — based on a main predating the codex-hooks removal, the deprecation fixes, and the whole plugin consolidation. Its only content was two repo-root files whose hook command pointed at .claude-plugin/hooks/wt.sh, a path #2789 deleted. Rebasing + reworking it would have meant a force-push rewriting that branch's history; opening fresh is cleaner.

Reviewer navigation

  • plugins/gemini/ — the whole extension (4 files).
  • tests/integration_tests/config_show.rstest_plugin_layout_is_consolidated extended: Gemini lives in plugins/gemini/ with nothing at the repo root, hooks reference the bundled ${extensionPath}/hooks/wt.sh, and the bundled wt.sh is asserted byte-identical to plugins/worktrunk/hooks/wt.sh.

Verification

gemini extensions validate passes on the layout (including the symlinked skills/). Full pre-merge gate green: 3742 passed, 0 skipped, lints clean, no snapshot churn. End-to-end gemini extensions install was not run non-interactively (it blocks on an interactive trust/settings prompt); validate is the non-interactive proxy.

Reviewer (worktrunk-bot) cross-checked the gemini-cli source for the copy path validate doesn't exercise: gemini extensions install runs fs.cp(src, dest, {recursive:true}), which rewrites the relative skills -> ../../skills symlink to an absolute path at the resolved target. Consequence: a local install resolves skills only while the source repo stays in place; a git/URL install would point the symlink into the deleted clone tempdir and skills would silently load as empty. Non-blocking for the supported local path; the robust fix is below.

Follow-ups (not in this PR)

  • wt config plugins gemini install command (the install path is currently gemini extensions install <local>/plugins/gemini; the github-URL flow has no subdir syntax). This command should also bundle copied SKILL.md trees into plugins/gemini/skills/ at install/release time (drift-guarded like wt.sh) so skills survive Gemini's symlink-mangling copy — the durable fix for the fs.cp issue above.
  • skills/wt-switch-create is exposed to Gemini via the shared symlink but its body invokes Claude's EnterWorktree tool — same accepted tradeoff as Codex, documented in CLAUDE.md → "Plugin Layout".

🤖 Generated with Claude Code

worktrunk-bot and others added 4 commits May 18, 2026 11:29
Mirrors the OpenCode plugin's activity-tracking surface for Gemini CLI:
sets 🤖 on BeforeAgent, 💬 on AfterAgent, and clears on SessionEnd via
`wt config state marker`. No skills, slash commands, or MCP servers —
those can follow if/when there's demand.

Refs #2763
Gemini's extension loader treats the manifest's parent as ${extensionPath}
and auto-discovers skills/ and hooks/ beneath it. Moving the manifest to
the repo root lets the existing skills/worktrunk and skills/wt-switch-create
load without duplication, and the hooks.json now invokes
.claude-plugin/hooks/wt.sh instead of carrying its own copy of the shim.

Per maintainer direction in #2765.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror the broader phrasing from .claude-plugin/plugin.json so the
extension blurb covers the bundled skills (configuration guidance)
in addition to activity tracking — the previous wording only
described the markers.
Integrate the Gemini extension with the merged plugin consolidation
(#2789). Gemini hard-probes ${extensionPath}/{hooks,skills}/ with no
path indirection and `gemini extensions install` copies the extension
dir, so the extension must be self-contained and can live anywhere:

- move gemini-extension.json + hooks/hooks.json into plugins/gemini/
  (own payload dir; repo root stays clean — consistent with #2789)
- bundle hooks/wt.sh (byte-identical to plugins/worktrunk/hooks/wt.sh);
  hooks call ${extensionPath}/hooks/wt.sh, not the cross-dir
  ../worktrunk path (would break under copy-on-install) nor the
  .claude-plugin/ path #2789 deleted
- skills -> ../../skills symlink reuses the repo-root skills (Gemini
  accepts symlinked skills — verified via `gemini extensions validate`)
- extend test_plugin_layout_is_consolidated: Gemini lives in
  plugins/gemini/ with nothing at the repo root, hooks reference the
  bundled shim, and the bundled wt.sh stays byte-identical to the
  canonical plugins/worktrunk/hooks/wt.sh (drift guard)

Verified: `gemini extensions validate` passes; full pre-merge gate green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few install-time concerns surfaced from reading the gemini-cli source (google-gemini/gemini-cli, packages/cli/src/config/extension-manager.ts and extensions/github.ts). The local-install path the PR verifies (gemini extensions validate) doesn't exercise these, so flagging in case they're not already factored in.

gemini extensions install <github-url> no longer resolves the manifest

tryParseGithubUrl only accepts owner/repo — no subdirectory component — and cloneFromGit clones to tempDir with localSourcePath = tempDir. loadExtensionConfig(localSourcePath) then reads <tempDir>/gemini-extension.json (the constant EXTENSIONS_CONFIG_FILENAME is "gemini-extension.json", joined directly to the extension dir). With the manifest now at plugins/gemini/gemini-extension.json, a clone of this repo has nothing at <tempDir>/gemini-extension.json, so gemini extensions install max-sixty/worktrunk (or any https://github.com/... form) fails with Configuration file not found at <tempDir>/gemini-extension.json. The previous arrangement in this branch's first commits (manifest at the repo root) made that path work; the move into plugins/gemini/ trades it away.

Today there's no wt config plugins gemini install command yet (per the closed #2765's description), so the only working install path is gemini extensions install /local/path/to/plugins/gemini. Worth confirming this is the intended tradeoff — and probably worth a note in the test docstring or plugins/worktrunk/CLAUDE.md saying "github-direct install is not supported; install via a local path / wt config plugins gemini install (TBD)" so a future contributor doesn't read "self-contained… can live anywhere" and assume the github URL path works.

plugins/gemini/skills -> ../../skills doesn't survive gemini extensions install

copyExtension (called for local, git, and github-release install types — only link skips it) is fs.promises.cp(source, destination, { recursive: true }). With Node's defaults (dereference: false, verbatimSymlinks: false), relative symlinks are rewritten to absolute paths pointing at the resolved target. Reproduction:

$ mkdir -p src/ext skills && echo x > skills/file && ln -s ../../skills src/ext/skills
$ node -e "require('fs').promises.cp('src/ext','dest',{recursive:true}).then(()=>console.log(require('fs').readlinkSync('dest/skills')))"
/abs/path/to/skills

For local installs that's tolerable — the absolute symlink keeps working as long as the worktrunk source repo stays in place (though it silently breaks if the user moves the repo). For git installs (if/once the manifest finds its way to a place Gemini can load it), localSourcePath = tempDir, the symlink gets rewritten to an absolute path inside that tempDir, and the finally block (extension-manager.ts:~440, fs.promises.rm(tempDir, { recursive: true, force: true })) deletes the target before the user's first session. loadSkillsFromDir then fs.stats a broken path, hits ENOENT, and silently returns [] — the skills load to nothing with no error surfaced.

Not blocking for the local path, but if wt config plugins gemini install ends up wrapping gemini extensions install <url>, the skills the manifest description advertises ("configuration guidance…") won't actually be there. The drift guard already enforces wt.sh byte-identity to handle the same problem for the shim — for skills, the equivalent would be copying the SKILL.md trees into plugins/gemini/skills/ at build/release time rather than relying on a symlink that fs.cp mangles.

Docs drift

CLAUDE.md → "Plugin Layout" still reads "One plugin payload serves both tools, in plugins/worktrunk/. The repo root keeps only the two loader-mandated marketplace pointers…" — no mention that Gemini now has its own sibling under plugins/gemini/. Worth a sentence so the section matches the new layout this PR introduces; plugins/worktrunk/CLAUDE.md (scoped to Claude+Codex) can probably stay as-is.

worktrunk-bot flagged that moving the manifest under plugins/gemini/
trades away github-URL install, and that gemini's fs.cp rewrites the
relative skills symlink to an absolute path (silently empty on a
git/URL install). Both are accepted tradeoffs for the supported
local-path install; document them so a future contributor doesn't
read "self-contained" and assume the github-URL path works:

- CLAUDE.md "Plugin Layout": new paragraph describing plugins/gemini/,
  the bundled drift-guarded wt.sh, and the two install constraints
- test_plugin_layout_is_consolidated docstring: same constraints + the
  robust fix (release-time SKILL.md bundling) tracked with the
  wt config plugins gemini install follow-up

No code change — the reviewer agreed none is blocking for the local path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@max-sixty max-sixty merged commit 7071141 into main May 18, 2026
34 checks passed
@max-sixty max-sixty deleted the dsa/gemini-extension branch May 18, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants