From 8c90dd94b997884c1fee30e244fc0e8c926a5cc4 Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:38:07 -0700 Subject: [PATCH 1/3] feat(install): add --skills-dir mode (install as a Claude Code skills-directory plugin) Adds --skills-dir [--dir ] flag that installs carl as a self-contained Claude Code skills-directory plugin under .claude/skills/carl/ (or a custom path). Writes .claude-plugin/plugin.json, copies hooks/ and mcp/, generates hooks.json and .mcp.json with ${CLAUDE_PLUGIN_ROOT} references so the plugin is relocatable. Existing --global / --local install paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/install.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/bin/install.js b/bin/install.js index fc73fe2..8817ba7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -43,6 +43,7 @@ const hasGlobal = args.includes('--global') || args.includes('-g'); const hasLocal = args.includes('--local') || args.includes('-l'); const hasHelp = args.includes('--help') || args.includes('-h'); const skipClaudeMd = args.includes('--skip-claude-md'); +const hasSkillsDir = args.includes('--skills-dir'); // Parse --config-dir argument function parseConfigDirArg() { @@ -63,6 +64,21 @@ function parseConfigDirArg() { } const explicitConfigDir = parseConfigDirArg(); +// Parse --dir argument (used with --skills-dir) +function parseSkillsDirArg() { + const dirIndex = args.findIndex(arg => arg === '--dir'); + if (dirIndex !== -1) { + const nextArg = args[dirIndex + 1]; + if (!nextArg || nextArg.startsWith('-')) { + console.error(` ${yellow}--dir requires a path argument${reset}`); + process.exit(1); + } + return nextArg; + } + return null; +} +const explicitSkillsDir = parseSkillsDirArg(); + console.log(banner); // Show help if requested @@ -73,6 +89,9 @@ if (hasHelp) { ${amber}-g, --global${reset} Install globally (to ~/.claude and ~/.carl) ${amber}-l, --local${reset} Install locally (to ./.claude and ./.carl) ${amber}-c, --config-dir ${reset} Specify custom Claude config directory + ${amber}--skills-dir [--dir ]${reset} + Install as a Claude Code skills-directory plugin. + Target: --dir or /.claude/skills/carl/ ${amber}--skip-claude-md${reset} Don't modify CLAUDE.md ${amber}-h, --help${reset} Show this help message @@ -86,6 +105,10 @@ if (hasHelp) { ${dim}# Install to current project only${reset} npx carl-core --local + ${dim}# Install as a Claude Code skills-directory plugin${reset} + npx carl-core --skills-dir + npx carl-core --skills-dir --dir /path/to/.claude/skills/carl/ + ${yellow}What gets installed:${reset} hooks/carl-hook.py - Rule injection hook (v2, JSON-based) .carl/carl.json - Domain rules, decisions, config @@ -94,6 +117,12 @@ if (hasHelp) { .mcp.json - MCP server registration CLAUDE.md - CARL integration block (optional) + ${yellow}Skills-dir mode installs:${reset} + .claude-plugin/plugin.json - Plugin manifest + commands/ - CARL slash commands + hooks/ + hooks.json - Hook files + mcp/ + .mcp.json - MCP server files + ${yellow}v1 Migration:${reset} If upgrading from v1 (flat-file manifest), run: ${dim}bash node_modules/carl-core/bin/migrate-v1-to-v2.sh ~/.carl${reset} @@ -393,6 +422,125 @@ function install(isGlobal, addToClaudeMd = true) { `); } +/** + * Rewrite framework path references in a file content string: + * @~/.claude/-framework/ and @./.claude/-framework/ -> ${CLAUDE_PLUGIN_ROOT}/-framework/ + */ +function rewriteFrameworkRefs(content) { + return content + .replace(/@~\/\.claude\/([\w-]+-framework)\//g, '${CLAUDE_PLUGIN_ROOT}/$1/') + .replace(/@\.\/\.claude\/([\w-]+-framework)\//g, '${CLAUDE_PLUGIN_ROOT}/$1/'); +} + +/** + * Install as a Claude Code skills-directory plugin. + * + * Layout inside targetDir (.claude/skills/carl/ by default): + * .claude-plugin/plugin.json -- plugin manifest + * commands/ -- CARL slash commands (if any) + * hooks/carl-hook.py -- hook script + * hooks.json -- hook registration (uses ${CLAUDE_PLUGIN_ROOT}) + * mcp/ -- MCP server files + * .mcp.json -- MCP registration (uses ${CLAUDE_PLUGIN_ROOT}) + */ +function installSkillsDir() { + const src = path.join(__dirname, '..'); + const short = 'carl'; + + // Resolve target directory + const targetDir = explicitSkillsDir + ? path.resolve(expandTilde(explicitSkillsDir)) + : path.join(process.cwd(), '.claude', 'skills', short); + + console.log(` Installing skills-dir plugin to ${amber}${targetDir}${reset}\n`); + + // Read package.json for version + description + const pkgJson = JSON.parse(fs.readFileSync(path.join(src, 'package.json'), 'utf8')); + + // 1. Create .claude-plugin/plugin.json + const pluginDir = path.join(targetDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + const pluginJson = { + name: short, + version: pkgJson.version, + description: pkgJson.description || 'Context Augmentation & Reinforcement Layer' + }; + fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(pluginJson, null, 2)); + console.log(` ${green}✓${reset} Created .claude-plugin/plugin.json`); + + // 2. Copy commands/ (if present in source) + const cmdsSrc = path.join(src, 'commands'); + if (fs.existsSync(cmdsSrc)) { + copyDir(cmdsSrc, path.join(targetDir, 'commands')); + console.log(` ${green}✓${reset} Copied commands/`); + } + + // 3. Copy hooks/carl-hook.py + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const hookSrc = path.join(src, 'hooks', 'carl-hook.py'); + fs.copyFileSync(hookSrc, path.join(hooksDest, 'carl-hook.py')); + fs.chmodSync(path.join(hooksDest, 'carl-hook.py'), '755'); + console.log(` ${green}✓${reset} Copied hooks/carl-hook.py`); + + // 4. Write hooks.json with ${CLAUDE_PLUGIN_ROOT} reference + const hooksJson = { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/carl-hook.py' + } + ] + } + ] + }; + let hooksJsonStr = JSON.stringify(hooksJson, null, 2); + hooksJsonStr = rewriteFrameworkRefs(hooksJsonStr); + fs.writeFileSync(path.join(targetDir, 'hooks.json'), hooksJsonStr); + console.log(` ${green}✓${reset} Wrote hooks.json`); + + // 5. Copy mcp/ directory + const mcpSrc = path.join(src, 'mcp'); + if (fs.existsSync(mcpSrc)) { + copyDir(mcpSrc, path.join(targetDir, 'mcp')); + console.log(` ${green}✓${reset} Copied mcp/`); + } + + // 6. Write .mcp.json with ${CLAUDE_PLUGIN_ROOT} reference + const mcpJson = { + mcpServers: { + 'carl-mcp': { + command: 'node', + args: ['${CLAUDE_PLUGIN_ROOT}/mcp/index.js'], + type: 'stdio' + } + } + }; + let mcpJsonStr = JSON.stringify(mcpJson, null, 2); + mcpJsonStr = rewriteFrameworkRefs(mcpJsonStr); + fs.writeFileSync(path.join(targetDir, '.mcp.json'), mcpJsonStr); + console.log(` ${green}✓${reset} Wrote .mcp.json`); + + console.log(` + ${green}Done!${reset} Skills-dir plugin installed. + + ${amber}Next steps:${reset} + ${dim}• Loads as ${short}@skills-dir next session (no marketplace/install needed)${reset} + ${dim}• Requires workspace trust to activate${reset} + ${dim}• For Claude Code Cloud: commit the .claude/skills/${short}/ directory${reset} + + ${amber}What's installed at:${reset} ${targetDir} + ${dim}.claude-plugin/plugin.json${reset} - Plugin manifest + ${dim}commands/${reset} - CARL slash commands + ${dim}hooks/carl-hook.py${reset} - Rule injection hook + ${dim}hooks.json${reset} - Hook registration + ${dim}mcp/${reset} - MCP server files + ${dim}.mcp.json${reset} - MCP registration +`); +} + /** * Prompt for install location */ @@ -446,6 +594,8 @@ if (hasGlobal && hasLocal) { } else if (explicitConfigDir && hasLocal) { console.error(` ${yellow}Cannot use --config-dir with --local${reset}`); process.exit(1); +} else if (hasSkillsDir) { + installSkillsDir(); } else if (hasGlobal) { install(true, !skipClaudeMd); } else if (hasLocal) { From 2503f1541353c3b6e8e0e9b257a8c9972707c904 Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:14:15 -0700 Subject: [PATCH 2/3] fix(install): correct skills-dir plugin layout (hooks.json path+shape, conflict guard) - Move hooks.json from plugin root to hooks/hooks.json so auto-discovery finds it - Wrap hooks.json content in {hooks:{...}} envelope to match framework spec - Add conflict guard: --skills-dir with --global or --local now exits non-zero - Fix rewriteFrameworkRefs: restore leading @ on @${CLAUDE_PLUGIN_ROOT} substitutions Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/install.js | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/bin/install.js b/bin/install.js index 8817ba7..fe0ba09 100755 --- a/bin/install.js +++ b/bin/install.js @@ -428,8 +428,8 @@ function install(isGlobal, addToClaudeMd = true) { */ function rewriteFrameworkRefs(content) { return content - .replace(/@~\/\.claude\/([\w-]+-framework)\//g, '${CLAUDE_PLUGIN_ROOT}/$1/') - .replace(/@\.\/\.claude\/([\w-]+-framework)\//g, '${CLAUDE_PLUGIN_ROOT}/$1/'); + .replace(/@~\/\.claude\/([\w-]+-framework)\//g, '@${CLAUDE_PLUGIN_ROOT}/$1/') + .replace(/@\.\/\.claude\/([\w-]+-framework)\//g, '@${CLAUDE_PLUGIN_ROOT}/$1/'); } /** @@ -485,20 +485,22 @@ function installSkillsDir() { // 4. Write hooks.json with ${CLAUDE_PLUGIN_ROOT} reference const hooksJson = { - UserPromptSubmit: [ - { - hooks: [ - { - type: 'command', - command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/carl-hook.py' - } - ] - } - ] + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/carl-hook.py' + } + ] + } + ] + } }; let hooksJsonStr = JSON.stringify(hooksJson, null, 2); hooksJsonStr = rewriteFrameworkRefs(hooksJsonStr); - fs.writeFileSync(path.join(targetDir, 'hooks.json'), hooksJsonStr); + fs.writeFileSync(path.join(hooksDest, 'hooks.json'), hooksJsonStr); console.log(` ${green}✓${reset} Wrote hooks.json`); // 5. Copy mcp/ directory @@ -594,6 +596,9 @@ if (hasGlobal && hasLocal) { } else if (explicitConfigDir && hasLocal) { console.error(` ${yellow}Cannot use --config-dir with --local${reset}`); process.exit(1); +} else if (hasSkillsDir && (hasGlobal || hasLocal)) { + console.error(` ${yellow}Cannot combine --skills-dir with --global or --local${reset}`); + process.exit(1); } else if (hasSkillsDir) { installSkillsDir(); } else if (hasGlobal) { From 4d8de33198dad3cef5a4dafc549bbfca155d1e56 Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:39:31 -0700 Subject: [PATCH 3/3] fix(install): bootstrap skills-dir MCP deps via SessionStart + NODE_PATH The plugin emitted by installSkillsDir() runs node mcp/index.js which imports @modelcontextprotocol/sdk, but no node_modules are shipped. Claude Code does not auto-install a plugin MCP's deps. Fix: emit hooks/install-mcp-deps.py as a SessionStart hook that: - Is idempotent (sentinel on node_modules/@modelcontextprotocol/sdk) - Copies mcp/package.json into CLAUDE_PLUGIN_DATA and runs `npm install --omit=dev --prefix CLAUDE_PLUGIN_DATA` - Creates a symlink CLAUDE_PLUGIN_ROOT/mcp/node_modules -> CLAUDE_PLUGIN_DATA/node_modules so Node's ESM walk-up resolver finds the packages (NODE_PATH is inert for ESM; the symlink is the operative mechanism) - Is fail-open: any error prints a warning to stderr and exits 0 Also adds NODE_PATH=${CLAUDE_PLUGIN_DATA}/node_modules to the emitted .mcp.json env (belt-and-suspenders for any CJS callers) alongside the existing CLAUDE_PROJECT_DIR. Validated: - node --check bin/install.js passes - Negative control: mcp/index.js exits ERR_MODULE_NOT_FOUND without deps - After install: server logs [CARL] init + "running on stdio" (boot proof) - Idempotent: second run exits in <0.05s (sentinel short-circuits) - Fail-open: npm stripped from PATH -> exits 0 with warning to stderr - Conflict guard and --global/--local paths still work Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/install.js | 173 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 158 insertions(+), 15 deletions(-) diff --git a/bin/install.js b/bin/install.js index fe0ba09..3826d6a 100755 --- a/bin/install.js +++ b/bin/install.js @@ -436,12 +436,13 @@ function rewriteFrameworkRefs(content) { * Install as a Claude Code skills-directory plugin. * * Layout inside targetDir (.claude/skills/carl/ by default): - * .claude-plugin/plugin.json -- plugin manifest - * commands/ -- CARL slash commands (if any) - * hooks/carl-hook.py -- hook script - * hooks.json -- hook registration (uses ${CLAUDE_PLUGIN_ROOT}) - * mcp/ -- MCP server files - * .mcp.json -- MCP registration (uses ${CLAUDE_PLUGIN_ROOT}) + * .claude-plugin/plugin.json -- plugin manifest + * commands/ -- CARL slash commands (if any) + * hooks/carl-hook.py -- hook script + * hooks/install-mcp-deps.py -- SessionStart deps installer (idempotent, fail-open) + * hooks/hooks.json -- hook registration (uses ${CLAUDE_PLUGIN_ROOT}) + * mcp/ -- MCP server files + * .mcp.json -- MCP registration (uses ${CLAUDE_PLUGIN_ROOT}, NODE_PATH) */ function installSkillsDir() { const src = path.join(__dirname, '..'); @@ -475,7 +476,7 @@ function installSkillsDir() { console.log(` ${green}✓${reset} Copied commands/`); } - // 3. Copy hooks/carl-hook.py + // 3. Copy hooks/carl-hook.py and write install-mcp-deps.py const hooksDest = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDest, { recursive: true }); const hookSrc = path.join(src, 'hooks', 'carl-hook.py'); @@ -483,7 +484,131 @@ function installSkillsDir() { fs.chmodSync(path.join(hooksDest, 'carl-hook.py'), '755'); console.log(` ${green}✓${reset} Copied hooks/carl-hook.py`); + // Write the SessionStart MCP deps installer script. + // Uses literal ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} tokens + // (not JS template interpolation) so they are resolved at hook-run time. + // The ESM loader does not read NODE_PATH, so we also symlink + // ${CLAUDE_PLUGIN_ROOT}/mcp/node_modules -> ${CLAUDE_PLUGIN_DATA}/node_modules + // so Node's walk-up ESM resolver finds @modelcontextprotocol/sdk. + const installDepsScript = `#!/usr/bin/env python3 +""" +CARL MCP deps installer — SessionStart hook +Installs @modelcontextprotocol/sdk (and other deps from mcp/package.json) into +CLAUDE_PLUGIN_DATA so the skills-dir MCP server can boot without shipping +node_modules. + +The MCP (mcp/index.js) uses ESM imports; Node's ESM resolver does NOT read +NODE_PATH. After installing into CLAUDE_PLUGIN_DATA, this script creates a +symlink at CLAUDE_PLUGIN_ROOT/mcp/node_modules -> CLAUDE_PLUGIN_DATA/node_modules +so the ESM walk-up resolver finds the packages. NODE_PATH in .mcp.json is kept +as belt-and-suspenders for any CJS callers. + +This script is idempotent: if the sentinel directory already exists AND the +symlink is already in place, it exits 0 immediately. +It is fail-open: any error prints a warning to stderr and exits 0 so the +session is never blocked. +""" +import os +import sys +import shutil +import subprocess + +def warn(msg): + print(f"[carl-install-mcp-deps] WARNING: {msg}", file=sys.stderr) + +def main(): + plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "").strip() + plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip() + + if not plugin_root: + warn("CLAUDE_PLUGIN_ROOT is unset; cannot install MCP deps. Skipping.") + return + + if not plugin_data: + # Fall back to a dir inside the plugin root so at least something works + plugin_data = os.path.join(plugin_root, ".mcp-deps") + warn(f"CLAUDE_PLUGIN_DATA is unset; falling back to {plugin_data}") + + sentinel = os.path.join(plugin_data, "node_modules", "@modelcontextprotocol", "sdk") + symlink_path = os.path.join(plugin_root, "mcp", "node_modules") + nm_target = os.path.join(plugin_data, "node_modules") + + # Ensure symlink is in place (idempotent, even on re-runs after a partial first run) + def ensure_symlink(): + try: + if os.path.islink(symlink_path): + current = os.readlink(symlink_path) + if current == nm_target: + return # already correct + os.unlink(symlink_path) + elif os.path.exists(symlink_path): + # Something else is there (directory from a previous strategy) — leave it + return + os.symlink(nm_target, symlink_path) + except Exception as e: + warn(f"Could not create node_modules symlink: {e}") + + # Fast exit if already installed + if os.path.isdir(sentinel): + ensure_symlink() + return + + # Copy mcp/package.json into plugin_data so npm install can read deps + mcp_pkg_src = os.path.join(plugin_root, "mcp", "package.json") + if not os.path.isfile(mcp_pkg_src): + warn(f"mcp/package.json not found at {mcp_pkg_src}; cannot install deps.") + return + + try: + os.makedirs(plugin_data, exist_ok=True) + dest_pkg = os.path.join(plugin_data, "package.json") + shutil.copy2(mcp_pkg_src, dest_pkg) + + # Also copy lockfile if present (for reproducible installs) + for lockfile in ("package-lock.json", "npm-shrinkwrap.json"): + src_lock = os.path.join(plugin_root, "mcp", lockfile) + if os.path.isfile(src_lock): + shutil.copy2(src_lock, os.path.join(plugin_data, lockfile)) + break + + # Run npm install into plugin_data + npm = shutil.which("npm") + if not npm: + warn("npm not found in PATH; cannot install MCP deps. " + "Install node/npm and restart Claude Code.") + return + + result = subprocess.run( + [npm, "install", "--omit=dev", "--prefix", plugin_data], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + warn(f"npm install exited {result.returncode}: {result.stderr.strip()}") + return + + # Create the symlink so ESM walk-up resolution works + ensure_symlink() + + except Exception as e: + warn(f"Unexpected error during MCP dep install: {e}") + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"[carl-install-mcp-deps] WARNING: unhandled error: {e}", file=sys.stderr) +`; + const installerPath = path.join(hooksDest, 'install-mcp-deps.py'); + fs.writeFileSync(installerPath, installDepsScript); + fs.chmodSync(installerPath, '755'); + console.log(` ${green}✓${reset} Wrote hooks/install-mcp-deps.py`); + // 4. Write hooks.json with ${CLAUDE_PLUGIN_ROOT} reference + // Keep UserPromptSubmit carl-hook entry; add SessionStart deps-installer entry. + // Use string concatenation (not template literals) so ${CLAUDE_PLUGIN_ROOT} is + // written verbatim into the file rather than being interpolated by JS. const hooksJson = { hooks: { UserPromptSubmit: [ @@ -495,6 +620,16 @@ function installSkillsDir() { } ] } + ], + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/install-mcp-deps.py' + } + ] + } ] } }; @@ -510,13 +645,19 @@ function installSkillsDir() { console.log(` ${green}✓${reset} Copied mcp/`); } - // 6. Write .mcp.json with ${CLAUDE_PLUGIN_ROOT} reference + // 6. Write .mcp.json with ${CLAUDE_PLUGIN_ROOT} reference and NODE_PATH env. + // NODE_PATH is belt-and-suspenders for CJS callers; the operative fix for the + // ESM server is the mcp/node_modules symlink created by install-mcp-deps.py. const mcpJson = { mcpServers: { 'carl-mcp': { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/mcp/index.js'], - type: 'stdio' + type: 'stdio', + env: { + CLAUDE_PROJECT_DIR: '${CLAUDE_PROJECT_DIR}', + NODE_PATH: '${CLAUDE_PLUGIN_DATA}/node_modules' + } } } }; @@ -532,14 +673,16 @@ function installSkillsDir() { ${dim}• Loads as ${short}@skills-dir next session (no marketplace/install needed)${reset} ${dim}• Requires workspace trust to activate${reset} ${dim}• For Claude Code Cloud: commit the .claude/skills/${short}/ directory${reset} + ${dim}• On first session start, install-mcp-deps.py installs MCP deps automatically${reset} ${amber}What's installed at:${reset} ${targetDir} - ${dim}.claude-plugin/plugin.json${reset} - Plugin manifest - ${dim}commands/${reset} - CARL slash commands - ${dim}hooks/carl-hook.py${reset} - Rule injection hook - ${dim}hooks.json${reset} - Hook registration - ${dim}mcp/${reset} - MCP server files - ${dim}.mcp.json${reset} - MCP registration + ${dim}.claude-plugin/plugin.json${reset} - Plugin manifest + ${dim}commands/${reset} - CARL slash commands + ${dim}hooks/carl-hook.py${reset} - Rule injection hook + ${dim}hooks/install-mcp-deps.py${reset} - SessionStart MCP deps installer + ${dim}hooks/hooks.json${reset} - Hook registration + ${dim}mcp/${reset} - MCP server files + ${dim}.mcp.json${reset} - MCP registration `); }