mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
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:
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user