Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 298 additions & 0 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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 <path>${reset} Specify custom Claude config directory
${amber}--skills-dir [--dir <path>]${reset}
Install as a Claude Code skills-directory plugin.
Target: --dir <path> or <cwd>/.claude/skills/carl/
${amber}--skip-claude-md${reset} Don't modify CLAUDE.md
${amber}-h, --help${reset} Show this help message

Expand All @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -393,6 +422,270 @@ function install(isGlobal, addToClaudeMd = true) {
`);
}

/**
* Rewrite framework path references in a file content string:
* @~/.claude/<x>-framework/ and @./.claude/<x>-framework/ -> ${CLAUDE_PLUGIN_ROOT}/<x>-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/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, '..');
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 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');
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`);

// 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: [
{
hooks: [
{
type: 'command',
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/carl-hook.py'
}
]
}
],
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/install-mcp-deps.py'
}
]
}
]
}
};
let hooksJsonStr = JSON.stringify(hooksJson, null, 2);
hooksJsonStr = rewriteFrameworkRefs(hooksJsonStr);
fs.writeFileSync(path.join(hooksDest, '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 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',
env: {
CLAUDE_PROJECT_DIR: '${CLAUDE_PROJECT_DIR}',
NODE_PATH: '${CLAUDE_PLUGIN_DATA}/node_modules'
}
}
}
};
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}
${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/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
`);
}

/**
* Prompt for install location
*/
Expand Down Expand Up @@ -446,6 +739,11 @@ 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) {
install(true, !skipClaudeMd);
} else if (hasLocal) {
Expand Down