refactor(plugins): consolidate Claude + Codex into one payload dir (#2789)

The Claude plugin lived at the repo root (`.claude-plugin/`) while the
Codex plugin lived in `plugins/worktrunk/` — two homes for one logical
plugin, with the description string duplicated across both and drifting
independently. This collapses them into a single payload directory.

**What moved.** `git mv
.claude-plugin/{plugin.json,hooks/,CLAUDE.md,README.md} →
plugins/worktrunk/` (history-preserving renames). The repo root keeps
only the two marketplace pointers — Claude and Codex each hardcode their
marketplace path with no fallback, so two pointer files is the
irreducible floor. Both pointers' `source` now resolves to
`./plugins/worktrunk`. `.claude-plugin/` is now a single 2-line file.

**The load-bearing constraint** (verified live against claude-cli
2.1.x): for a *subdirectory* `source`, Claude expects `plugin.json` at
the plugin root **without** a `.claude-plugin/` wrapper — that wrapper
is marketplace-root-only. (First attempt with the wrapper failed `Plugin
not found`; the corrected layout installs cleanly.) Codex keeps its own
required `.codex-plugin/` wrapper. So inside `plugins/worktrunk/`:
`plugin.json` is Claude's, `.codex-plugin/plugin.json` is Codex's,
`hooks/` is Claude's, `skills → ../../skills` is shared.

**Path edits inside moved files:** `plugin.json` `hooks` →
`./hooks/hooks.json`; `hooks.json`
`${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh` →
`${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh`.

**Reviewer navigation:** `git log` shows the moves as renames.
`plugins/worktrunk/CLAUDE.md` was rewritten to document the unified
layout + per-tool path resolution; the repo `CLAUDE.md` "Plugin Layout"
section likewise. `tests/integration_tests/config_show.rs` adds
`test_plugin_layout_is_consolidated`, which locks the layout invariants
(`.claude-plugin/` is marketplace-only, Claude manifest at plugin root,
hooks relative to it) **and** asserts the duplicated Claude description
stays byte-identical across the marketplace pointer and the manifest —
that was the standing follow-up; JSON can't `include!`, so a drift test
is the right tool, not a generator.

**Verification:** Live end-to-end on both real CLIs, twice — once with
copied skill dirs, once mirroring the real tree *including the
`plugins/worktrunk/skills` symlink* (Claude's `skills` array resolves
through it). Full pre-merge gate green: 3720 passed, 0 skipped, all
pre-commit lints, no snapshot churn. No user-facing docs/help text
changed (the consolidation is repo-internal), so `test_docs_are_in_sync`
needed no resync.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximilian Roos
2026-05-17 16:35:25 -07:00
committed by GitHub
parent 233cd493d0
commit d22917702e
10 changed files with 121 additions and 57 deletions
-32
View File
@@ -1,32 +0,0 @@
# Claude Code Plugin Guidelines
## Directory Layout
Skills are at the repo root (`skills/`) — the standard plugin location. Hooks
remain in `.claude-plugin/hooks/` for now.
```
worktrunk/ ← plugin root (repo root)
├── .claude-plugin/
│ ├── plugin.json ← manifest (lists every skill dir)
│ └── hooks/hooks.json ← activity tracking + WorktreeCreate/Remove hooks
└── skills/
├── worktrunk/ ← config/hook guidance skill + reference docs
└── wt-switch-create/ ← /wt-switch-create slash command
```
Paths in `plugin.json` and `marketplace.json` resolve from the plugin root (repo
root). Each skill directory must be listed in `plugin.json`'s `skills` array.
## Known Limitations
### Status persists after user interrupt
The hooks track Claude Code activity via git config (`worktrunk.status.{branch}`):
- `UserPromptSubmit` → 🤖 (working)
- `Notification` → 💬 (waiting for input)
- `SessionEnd` → clears status
**Problem**: If the user interrupts Claude Code (Escape/Ctrl+C), the 🤖 status persists because there's no `UserInterrupt` hook. The `Stop` hook explicitly does not fire on user interrupt.
**Tracking**: [claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)
+1 -1
View File
@@ -11,7 +11,7 @@
{
"name": "worktrunk",
"description": "Worktrunk is a CLI for Git worktree management, designed for parallel AI agent workflows. This plugin provides configuration guidance (LLM commit messages, project hooks, worktree paths) and automatic activity tracking (🤖/💬 indicators in `wt list` showing active Claude sessions).",
"source": "./"
"source": "./plugins/worktrunk"
}
]
}
+4 -12
View File
@@ -168,21 +168,13 @@ cargo insta test --accept -- --test integration "test_help"
Config docs (`USER_CONFIG_START`/`PROJECT_CONFIG_START` sections in `src/cli/mod.rs`) generate `dev/*.example.toml` files where every line is commented out with `#`. TOML comments inside code blocks become double-commented (`# # comment`). Use plain text descriptions ending with colons before each code block instead — inline end-of-line comments (e.g., `key = "value" # explanation`) are fine.
## Codex Plugin
## Plugin Layout
The Codex plugin is separate from the Claude Code plugin (`.claude-plugin/`) and uses Codex's marketplace layout:
One plugin payload serves both tools, in `plugins/worktrunk/`. The repo root keeps only the two loader-mandated marketplace pointers — Claude (`.claude-plugin/marketplace.json`) and Codex (`.agents/plugins/marketplace.json`) each hardcode their marketplace path with no fallback, so two pointer files is the irreducible floor. Both point `source` at `./plugins/worktrunk`. Inside that directory: `plugin.json` is the Claude manifest (at the plugin root — Claude's `.claude-plugin/` wrapper is marketplace-root-only), `.codex-plugin/plugin.json` is the Codex manifest (Codex's required wrapper), and `skills -> ../../skills` single-sources the skills across both plugins and the docs auto-sync. The full layout, path-resolution rules, and the live-CLI verification are documented in `plugins/worktrunk/CLAUDE.md`; keep that file current when the layout changes.
```
.agents/plugins/marketplace.json ← marketplace manifest Codex enumerates
plugins/worktrunk/.codex-plugin/plugin.json ← plugin manifest
plugins/worktrunk/skills -> ../../skills ← symlink to repo-root skills/
```
**No Codex activity-marker hooks.** The Claude manifest carries `hooks: "./hooks/hooks.json"`; the Codex manifest has no `hooks` key and Codex ships no hooks. Codex's `HookEventNameWire` vocabulary (codex-cli 0.130.0: `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SessionStart`, `UserPromptSubmit`) has no `Stop`/turn-end event, so a 🤖 marker set on `UserPromptSubmit` could never return to 💬 — it would stick at "working" indefinitely. Re-add a Codex `hooks.json`, the `hooks` manifest key, the install hints in `src/commands/config/codex.rs`, and the docs (`docs/content/claude-code.md` "Activity tracking", `src/cli/config.rs` plugin list) once Codex exposes a turn-end hook event.
Codex discovers a marketplace's plugin list strictly from `<repo-root>/.agents/plugins/marketplace.json` (no fallback to `.claude-plugin/marketplace.json`). Each plugin's `source` must be an object pointing at a non-empty subdirectory — codex rejects a bare or root-relative source — so the plugin lives in `plugins/worktrunk/`, not at the repo root.
**No activity-marker hooks.** Unlike the Claude Code plugin, the Codex plugin ships no `hooks` — the manifest has no `hooks` key and there is no `hooks/` directory. Codex's `HookEventNameWire` vocabulary (codex-cli 0.130.0: `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SessionStart`, `UserPromptSubmit`) has no `Stop`/turn-end event, so a 🤖 marker set on `UserPromptSubmit` could never return to 💬 within a session — it would stick at "working" indefinitely. Re-add `hooks.json`, the `hooks` manifest key, the install hints in `src/commands/config/codex.rs`, and the docs (`docs/content/claude-code.md` "Activity tracking", `src/cli/config.rs` plugin list) once Codex exposes a turn-end hook event.
**Accepted tradeoff — `skills` exposes `wt-switch-create`.** The manifest's `"skills": "./skills/"` resolves through the `plugins/worktrunk/skills -> ../../skills` symlink, exposing the entire repo-root `skills/` directory — including `wt-switch-create`, which depends on Claude session-cwd switching and the `WorktreeCreate` hook that Codex doesn't provide. We accept this: Codex loading a skill it can't act on is harmless, and a single repo-root `skills/` keeps the `worktrunk` skill single-source across both plugins. Don't add a Codex-only skills subtree to exclude it.
**Accepted tradeoff — `skills` exposes `wt-switch-create` to Codex.** The Codex manifest's `"skills": "./skills/"` resolves through the shared `skills` symlink, exposing the entire repo-root `skills/` directory — including `wt-switch-create`, which depends on Claude session-cwd switching and the `WorktreeCreate` hook that Codex doesn't provide. We accept this: Codex loading a skill it can't act on is harmless, and a single repo-root `skills/` keeps the `worktrunk` skill single-source across both plugins and the docs sync. Don't add a Codex-only skills subtree to exclude it.
## Data Safety
+57
View File
@@ -0,0 +1,57 @@
# Worktrunk Plugin Guidelines (Claude Code + Codex)
## Directory Layout
One plugin payload, two tools. The plugin lives entirely in this directory
(`plugins/worktrunk/`); only the two loader-mandated marketplace pointers stay
at the repo root, because Claude Code and Codex each hardcode their marketplace
path with no fallback.
```
worktrunk/ ← repo root = marketplace root
├── .claude-plugin/marketplace.json ← Claude pointer (source → ./plugins/worktrunk)
├── .agents/plugins/marketplace.json← Codex pointer (source → ./plugins/worktrunk)
└── plugins/worktrunk/ ← plugin root (both tools resolve source here)
├── plugin.json ← Claude manifest (NO .claude-plugin/ wrapper —
│ the wrapper is marketplace-root-only)
├── .codex-plugin/plugin.json ← Codex manifest (Codex's required wrapper)
├── hooks/hooks.json ← Claude activity + WorktreeCreate/Remove hooks
├── hooks/wt.sh ← hook helper (referenced via ${CLAUDE_PLUGIN_ROOT})
├── skills -> ../../skills ← symlink; single-sources skills across both
│ plugins and the docs auto-sync
├── CLAUDE.md / README.md
└── (Codex ships no hooks — see repo CLAUDE.md → "Plugin Layout")
```
Path resolution differs by tool, both verified end-to-end against the real CLIs:
- **Claude**: `.claude-plugin/marketplace.json` `source: "./plugins/worktrunk"`.
Claude reads `plugins/worktrunk/plugin.json` (at the plugin root, *not* a
`.claude-plugin/` subdir). `hooks` and `skills` paths in `plugin.json` resolve
from the plugin root, so `./skills/worktrunk` follows the `skills` symlink to
the repo-root `skills/worktrunk`. `${CLAUDE_PLUGIN_ROOT}` is the plugin root.
- **Codex**: `.agents/plugins/marketplace.json` `source` object
`{ "source": "local", "path": "./plugins/worktrunk" }`. Codex reads
`plugins/worktrunk/.codex-plugin/plugin.json`. `skills: "./skills/"` resolves
through the same symlink.
Each Claude skill directory must be listed in `plugin.json`'s `skills` array;
Codex picks up the whole `skills/` dir via the symlink (accepted tradeoff — see
repo CLAUDE.md → "Plugin Layout").
## Known Limitations
### Status persists after user interrupt (Claude)
The Claude hooks track activity via git config (`worktrunk.status.{branch}`):
- `UserPromptSubmit` → 🤖 (working)
- `Notification` → 💬 (waiting for input)
- `SessionEnd` → clears status
**Problem**: If the user interrupts Claude Code (Escape/Ctrl+C), the 🤖 status persists because there's no `UserInterrupt` hook. The `Stop` hook explicitly does not fire on user interrupt.
**Tracking**: [claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)
### Codex ships no activity hooks
Codex-cli 0.130.0's hook event vocabulary has no `Stop`/turn-end event, so a 🤖 marker could never return to 💬. The Codex manifest deliberately carries no `hooks` key. See repo CLAUDE.md → "Plugin Layout" for the re-enablement conditions.
@@ -5,7 +5,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh\" config state marker set 🤖 || true"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" config state marker set 🤖 || true"
}
]
}
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh\" config state marker set 💬 || true"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" config state marker set 💬 || true"
}
]
}
@@ -27,7 +27,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh\" config state marker clear || true"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" config state marker clear || true"
}
]
}
@@ -37,7 +37,7 @@
"hooks": [
{
"type": "command",
"command": "jq -r '.name' | xargs -I{} bash \"${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh\" switch --create {} --no-cd --format=json | jq -r '.path'"
"command": "jq -r '.name' | xargs -I{} bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" switch --create {} --no-cd --format=json | jq -r '.path'"
}
]
}
@@ -47,7 +47,7 @@
"hooks": [
{
"type": "command",
"command": "jq -r '.worktree_path' | xargs bash \"${CLAUDE_PLUGIN_ROOT}/.claude-plugin/hooks/wt.sh\" remove -D --foreground"
"command": "jq -r '.worktree_path' | xargs bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" remove -D --foreground"
}
]
}
@@ -4,7 +4,7 @@
"author": {
"name": "Worktrunk"
},
"hooks": "./.claude-plugin/hooks/hooks.json",
"hooks": "./hooks/hooks.json",
"skills": [
"./skills/worktrunk",
"./skills/wt-switch-create"
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn handle_codex_install(yes: bool) -> Result<()> {
// HookEventNameWire vocabulary (codex-cli 0.130.0) has no `Stop`/turn-end
// event, so a 🤖 set on UserPromptSubmit could never return to 💬 within a
// session. Re-add the hooks (and restore the marker hints + docs) once
// Codex exposes a turn-end hook event. See CLAUDE.md → "Codex Plugin".
// Codex exposes a turn-end hook event. See CLAUDE.md → "Plugin Layout".
Ok(())
}
+52 -5
View File
@@ -3588,13 +3588,16 @@ fn test_codex_plugin_metadata_is_valid_json() {
// The Codex plugin ships no activity-marker hooks: Codex's
// HookEventNameWire vocabulary (codex-cli 0.130.0) has no `Stop`/turn-end
// event, so a 🤖 set on UserPromptSubmit could never return to 💬 within a
// session. Keep the manifest free of a `hooks` key (and the hooks/ dir
// absent) until Codex adds a turn-end hook event — see CLAUDE.md
// "Codex Plugin".
// session. Keep the Codex manifest free of a `hooks` key, and its wrapper
// dir manifest-only, until Codex adds a turn-end hook event — see CLAUDE.md
// → "Plugin Layout". (plugins/worktrunk/hooks/ exists post-consolidation,
// but it is the *Claude* plugin's — Codex's manifest never references it.)
assert_eq!(plugin.get("hooks"), None);
assert!(
!project_root.join("plugins/worktrunk/hooks").exists(),
"plugins/worktrunk/hooks/ must not exist while Codex lacks a turn-end hook event"
!project_root
.join("plugins/worktrunk/.codex-plugin/hooks")
.exists(),
"the Codex wrapper dir must hold only plugin.json"
);
assert_eq!(marketplace["plugins"][0]["name"], "worktrunk");
// Source is a non-empty subdir object; a bare "./" is rejected by codex.
@@ -3612,6 +3615,50 @@ fn test_codex_plugin_metadata_is_valid_json() {
assert_eq!(marketplace["interface"]["displayName"], "Worktrunk");
}
/// The Claude Code and Codex plugins share one payload dir, `plugins/worktrunk/`.
/// Both marketplace pointers must stay at the repo root (each tool hardcodes its
/// path) and point `source` at that subdir; the Claude manifest sits at the
/// plugin root with NO `.claude-plugin/` wrapper (the wrapper is
/// marketplace-root-only — verified end-to-end against claude-cli 2.1.x:
/// `source: "./plugins/worktrunk"` + manifest at `<subdir>/.claude-plugin/`
/// fails "Plugin not found"). The duplicated description string can't be
/// `include!`d into JSON, so this test is the drift guard.
#[test]
fn test_plugin_layout_is_consolidated() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let read = |p: &str| fs::read_to_string(root.join(p)).unwrap();
let json = |p: &str| serde_json::from_str::<serde_json::Value>(&read(p)).unwrap();
// Repo root keeps ONLY the two loader-mandated marketplace pointers.
assert!(
!root.join(".claude-plugin/plugin.json").exists()
&& !root.join(".claude-plugin/hooks").exists(),
".claude-plugin/ at the repo root must hold only marketplace.json"
);
let claude_mkt = json(".claude-plugin/marketplace.json");
assert_eq!(claude_mkt["plugins"][0]["source"], "./plugins/worktrunk");
// Claude manifest at the plugin root (no wrapper); hooks relative to it.
let claude = json("plugins/worktrunk/plugin.json");
assert_eq!(claude["hooks"], "./hooks/hooks.json");
assert!(
root.join("plugins/worktrunk/hooks/hooks.json").exists()
&& root.join("plugins/worktrunk/hooks/wt.sh").exists(),
"Claude hooks must live at the plugin root's hooks/"
);
assert!(
!read("plugins/worktrunk/hooks/hooks.json").contains(".claude-plugin/hooks/"),
"hooks.json must reference ${{CLAUDE_PLUGIN_ROOT}}/hooks/wt.sh, not the old wrapper path"
);
// The description is duplicated across the Claude marketplace pointer and
// the Claude manifest — they must stay byte-identical.
assert_eq!(
claude_mkt["plugins"][0]["description"], claude["description"],
".claude-plugin/marketplace.json and plugins/worktrunk/plugin.json descriptions drifted"
);
}
// ==================== Plugin Install-Statusline Tests ====================
#[rstest]