feat(plugin): native Claude Code plugin + dual-mode npx install - #11
Open
mh0pe wants to merge 4 commits into
Open
feat(plugin): native Claude Code plugin + dual-mode npx install#11mh0pe wants to merge 4 commits into
mh0pe wants to merge 4 commits into
Conversation
Add .claude-plugin/plugin.json and .claude-plugin/marketplace.json so
carl is installable as a native Claude Code marketplace plugin via
`claude plugin marketplace add mh0pe/carl` + `claude plugin install carl@carl`.
Add hooks/hooks.json wiring UserPromptSubmit to carl-hook.py via the
${CLAUDE_PLUGIN_ROOT} macro (no .claude/settings.json exists in the repo;
hook config derived from the install.js wireHook logic).
Add root .mcp.json registering carl-mcp with `node ${CLAUDE_PLUGIN_ROOT}/mcp/index.js`
and env CLAUDE_PROJECT_DIR=${CLAUDE_PROJECT_DIR} per the carl.toml [[mcp]] spec.
Apply carl.toml transforms #1 and #2 to mcp/index.js: add `import fs from
'node:fs'` and replace the static WORKSPACE_PATH with a CLAUDE_PROJECT_DIR-first
resolver. These are required so that in plugin mode (where __dirname resolves
inside the plugin cache, not .carl/carl-mcp/) the MCP server reaches the user's
project, not the plugin installation directory. Hook transform #3 intentionally
omitted per minimal-change policy (hook cwd payload is present in normal use).
Update bin/install.js for dual-mode: add copyFileWithMacroSub() which replaces
${CLAUDE_PLUGIN_ROOT} with the actual install target (claudeDir) in all copied
text files. This guarantees no literal macro survives in npx-installed output.
Existing flags (--global, --local, --config-dir) are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native plugin ships mcp/index.js (ESM, imports @modelcontextprotocol/sdk)
but no node_modules. Claude Code does not auto-install plugin MCP deps.
Fix:
- Add hooks/install-mcp-deps.py: idempotent SessionStart hook that
- copies mcp/package.json into CLAUDE_PLUGIN_DATA
- runs npm install --omit=dev --prefix CLAUDE_PLUGIN_DATA
- creates symlink CLAUDE_PLUGIN_ROOT/mcp/node_modules ->
CLAUDE_PLUGIN_DATA/node_modules (operative ESM fix; NODE_PATH
is inert for ESM but kept for CJS belt-and-suspenders)
- is fail-open: any error/missing env/absent npm -> warning + exit 0
- Wire SessionStart array into hooks/hooks.json (preserving existing
UserPromptSubmit carl-hook entry and {hooks:{...}} envelope)
- Add NODE_PATH: "${CLAUDE_PLUGIN_DATA}/node_modules" env to .mcp.json
alongside existing CLAUDE_PROJECT_DIR
- Fix .gitignore to also exclude the mcp/node_modules symlink (prior
pattern only matched directories, not symlinks)
npx mode is unaffected: the npx installer copies only carl-hook.py and
runs npm install directly into .carl/carl-mcp/ with fully-resolved absolute
paths; it never reads hooks/hooks.json or hooks/install-mcp-deps.py and
writes no ${CLAUDE_PLUGIN_ROOT} literals into user settings.
Validated:
- node --check mcp/index.js -> OK
- python3 -m py_compile hooks/install-mcp-deps.py -> OK
- Negative control: node mcp/index.js with no deps -> ERR_MODULE_NOT_FOUND
- Bootstrap run: sentinel + symlink created; CARL MCP Server running on stdio
- Idempotency: second run exits in <0.06s (sentinel short-circuits,
symlink re-asserted)
- Fail-open: npm stripped from PATH -> warning to stderr + exit 0
- npx --global into temp HOME succeeds; no CLAUDE_PLUGIN_ROOT literals
in installed output
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… path
- .github/workflows/plugin-install.yml: validate --strict + smoke install,
mirroring the shape used in paul/seed native-plugin branches
- hooks/hooks.json: quote ${CLAUDE_PLUGIN_ROOT} in SessionStart command so
a space in the path cannot cause word-splitting, matching the existing
UserPromptSubmit carl-hook entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
|
Related PRs — part of a coordinated cross-repo offer (native Claude Code plugin + dual-mode npx install) applied across the carl/base/paul/seed framework forks:
Reviewing them together is recommended; the same change pattern is mirrored per repo. |
This was referenced Jun 14, 2026
Author
|
Hi @ChristopherKahler — friendly nudge on this set of coordinated PRs. There are 12 open across carl/base/paul/seed (this one is the native-plugin hub; it cross-links base#3, paul#20, seed#5, and the earlier |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Makes the repo installable both as a native Claude Code marketplace plugin and via the existing npx commands, from one committed source. Adds root
.claude-plugin/plugin.json+.claude-plugin/marketplace.json(single-plugin,source: "."), so:works, while
npx carl-core --global|--localkeeps working unchanged.How dual-mode works
The committed framework/hook/mcp files use
${CLAUDE_PLUGIN_ROOT}macros. In plugin context Claude Code expands them; in npx mode the installer substitutes the real install target during copy (andinstallMcpruns its ownnpm install). One source form, two resolution paths.MCP bootstrapping
A marketplace/git plugin has no install step, so the carl-mcp npm deps aren't present at launch and
node mcp/index.jswould fail withERR_MODULE_NOT_FOUND. A committed fail-open SessionStart hook (hooks/install-mcp-deps.py) idempotently installs the MCP deps into${CLAUDE_PLUGIN_DATA}and links them onto the MCP's module-resolution path (the MCP is ESM, and Node's ESM loader ignoresNODE_PATH, so anode_modulessymlink is the operative mechanism;.mcp.jsonalso setsNODE_PATHas a CJS fallback). It short-circuits when deps exist and never blocks the session if npm /${CLAUDE_PLUGIN_DATA}is unavailable.Verified: after the bootstrap, the MCP boots cleanly (
CARL MCP Server running on stdio, 30 tools), noERR_MODULE_NOT_FOUND.First-session caveat: Claude Code may spawn the MCP concurrently with the SessionStart hook, so on a brand-new install carl-mcp becomes available from the next session (or after a restart) once deps are linked.
CI
Adds
.github/workflows/plugin-install.yml:claude plugin validate . --strict(the CI-grade manifest gate) plus an install smoke (marketplace add+install carl@carl+list). Both verified to run auth-free (no API key / OAuth token needed).Notes
hooks/carl-hook.py, runs its ownnpm install, and writes its own absolute-path.mcp.json— the plugin-mode env additions don't leak into npx installs, and no literal${CLAUDE_PLUGIN_ROOT}appears in npx output.@.carl/...) are untouched.🤖 Generated with Claude Code