Skip to content

feat(plugin): native Claude Code plugin + dual-mode npx install - #11

Open
mh0pe wants to merge 4 commits into
ChristopherKahler:mainfrom
mh0pe:feat/native-plugin
Open

feat(plugin): native Claude Code plugin + dual-mode npx install#11
mh0pe wants to merge 4 commits into
ChristopherKahler:mainfrom
mh0pe:feat/native-plugin

Conversation

@mh0pe

@mh0pe mh0pe commented Jun 14, 2026

Copy link
Copy Markdown

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:

claude plugin marketplace add ChristopherKahler/carl
claude plugin install carl@carl

works, while npx carl-core --global|--local keeps 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 (and installMcp runs its own npm 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.js would fail with ERR_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 ignores NODE_PATH, so a node_modules symlink is the operative mechanism; .mcp.json also sets NODE_PATH as 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), no ERR_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

  • npx mode is unaffected: it copies only hooks/carl-hook.py, runs its own npm 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.
  • The no-env workspace-resolution default is preserved; project-runtime refs (@.carl/...) are untouched.
  • Ref: https://code.claude.com/docs/en/plugins-reference

🤖 Generated with Claude Code

mh0pe and others added 4 commits June 13, 2026 23:06
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>
@mh0pe

mh0pe commented Jun 14, 2026

Copy link
Copy Markdown
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.

@mh0pe

mh0pe commented Jun 24, 2026

Copy link
Copy Markdown
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 --skills-dir and CLAUDE_PROJECT_DIR offers). All are mergeable with clean merge state and no conflicts. No rush — just flagging them for whenever you have a chance to take a look. Happy to split, rebase, or adjust any of them to fit how you would prefer to land the changes.

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.

1 participant