Skip to content
Open
Show file tree
Hide file tree
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
20 changes: 20 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "carl",
"owner": {
"name": "ChristopherKahler",
"url": "https://github.com/ChristopherKahler/carl"
},
"metadata": {
"description": "CARL — Context Augmentation & Reinforcement Layer: decision log + domain rules, as a native plugin."
},
"plugins": [
{
"name": "carl",
"source": ".",
"description": "CARL — Context Augmentation & Reinforcement Layer: decision log + domain rules, as a native plugin.",
"category": "framework",
"keywords": ["carl", "decisions", "rules", "memory"]
}
]
}
10 changes: 10 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "carl",
"version": "2.0.2",
"description": "CARL — Context Augmentation & Reinforcement Layer: decision log + domain rules, as a native plugin.",
"author": {
"name": "Chris Kahler"
},
"license": "MIT",
"homepage": "https://github.com/ChristopherKahler/carl#readme"
}
26 changes: 26 additions & 0 deletions .github/workflows/plugin-install.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: plugin-install

on:
push:
pull_request:
workflow_dispatch:

jobs:
validate-and-install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
- name: Validate plugin + marketplace manifest (strict)
run: claude plugin validate . --strict
- name: Install smoke test (claude can install the plugin)
run: |
set -euo pipefail
claude plugin marketplace add ./
claude plugin install carl@carl
claude plugin list
claude plugin list | grep -i carl
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ Thumbs.db
# PAUL project state (per-machine)
.paul/

# Node modules in MCP
# Node modules in MCP (also matches the symlink created by install-mcp-deps.py)
mcp/node_modules
mcp/node_modules/
12 changes: 12 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"carl-mcp": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/index.js"],
"env": {
"CLAUDE_PROJECT_DIR": "${CLAUDE_PROJECT_DIR}",
"NODE_PATH": "${CLAUDE_PLUGIN_DATA}/node_modules"
}
}
}
}
51 changes: 40 additions & 11 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,33 @@ function expandTilde(filePath) {
}

/**
* Recursively copy directory
* Copy a single file, substituting ${CLAUDE_PLUGIN_ROOT} with pluginRootSub
* in text files (UTF-8 decodable). Binary files are copied byte-for-byte.
*/
function copyDir(srcDir, destDir) {
function copyFileWithMacroSub(srcPath, destPath, pluginRootSub) {
const _TEXT_EXTS = new Set(['.js', '.mjs', '.py', '.json', '.md', '.txt', '.sh', '.yaml', '.yml', '.toml']);
const ext = path.extname(destPath).toLowerCase();
const isText = ext === '' || _TEXT_EXTS.has(ext);
if (isText && pluginRootSub) {
let content;
try {
content = fs.readFileSync(srcPath, 'utf8');
} catch (_e) {
// Not valid UTF-8 — copy raw
fs.copyFileSync(srcPath, destPath);
return;
}
const rewritten = content.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRootSub);
fs.writeFileSync(destPath, rewritten, 'utf8');
} else {
fs.copyFileSync(srcPath, destPath);
}
}

/**
* Recursively copy directory, substituting ${CLAUDE_PLUGIN_ROOT} in text files.
*/
function copyDir(srcDir, destDir, pluginRootSub) {
fs.mkdirSync(destDir, { recursive: true });

const entries = fs.readdirSync(srcDir, { withFileTypes: true });
Expand All @@ -124,9 +148,9 @@ function copyDir(srcDir, destDir) {
const destPath = path.join(destDir, entry.name);

if (entry.isDirectory()) {
copyDir(srcPath, destPath);
copyDir(srcPath, destPath, pluginRootSub);
} else {
fs.copyFileSync(srcPath, destPath);
copyFileWithMacroSub(srcPath, destPath, pluginRootSub);
}
}
}
Expand Down Expand Up @@ -265,7 +289,7 @@ function addCarlBlock(claudeMdPath) {
/**
* Install MCP server
*/
function installMcp(carlDir, src) {
function installMcp(carlDir, src, pluginRootSub) {
const mcpDest = path.join(carlDir, 'carl-mcp');
const mcpSrc = path.join(src, 'mcp');

Expand All @@ -274,8 +298,8 @@ function installMcp(carlDir, src) {
return null;
}

// Copy MCP files
copyDir(mcpSrc, mcpDest);
// Copy MCP files (substitute ${CLAUDE_PLUGIN_ROOT} with install base)
copyDir(mcpSrc, mcpDest, pluginRootSub);
console.log(` ${green}✓${reset} Installed carl-mcp`);

// Run npm install for MCP dependencies
Expand Down Expand Up @@ -319,19 +343,24 @@ function install(isGlobal, addToClaudeMd = true) {

console.log(` Installing to ${amber}${locationLabel}${reset} and ${amber}${carlLabel}${reset}\n`);

// The effective plugin root for substituting ${CLAUDE_PLUGIN_ROOT} in copied
// text files. In plugin mode this macro is resolved by Claude Code; in npx
// mode we substitute the actual claudeDir so no literal macro remains.
const pluginRootSub = claudeDir;

// 1. Copy hook script
const hooksDir = path.join(claudeDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookSrc = path.join(src, 'hooks', 'carl-hook.py');
const hookDest = path.join(hooksDir, 'carl-hook.py');
fs.copyFileSync(hookSrc, hookDest);
copyFileWithMacroSub(hookSrc, hookDest, pluginRootSub);
fs.chmodSync(hookDest, '755');
console.log(` ${green}✓${reset} Installed hooks/carl-hook.py (v2)`);

// 2. Copy .carl-template to .carl (carl.json + sessions/)
const carlTemplateSrc = path.join(src, '.carl-template');
if (!fs.existsSync(carlDir)) {
copyDir(carlTemplateSrc, carlDir);
copyDir(carlTemplateSrc, carlDir, pluginRootSub);
// Stamp the install date in carl.json
const carlJsonPath = path.join(carlDir, 'carl.json');
if (fs.existsSync(carlJsonPath)) {
Expand All @@ -348,15 +377,15 @@ function install(isGlobal, addToClaudeMd = true) {
// .carl/ exists but no carl.json — v1 user, copy template
const carlJsonSrc = path.join(carlTemplateSrc, 'carl.json');
const carlJsonDest = path.join(carlDir, 'carl.json');
fs.copyFileSync(carlJsonSrc, carlJsonDest);
copyFileWithMacroSub(carlJsonSrc, carlJsonDest, pluginRootSub);
console.log(` ${green}✓${reset} Added carl.json to existing ${carlLabel}`);
console.log(` ${yellow}Note: Existing v1 files detected. Run migrate-v1-to-v2.sh to convert.${reset}`);
} else {
console.log(` ${dim}${carlLabel}/carl.json already exists, skipping${reset}`);
}

// 3. Install MCP server
const mcpIndexPath = installMcp(carlDir, src);
const mcpIndexPath = installMcp(carlDir, src, pluginRootSub);

// 4. Wire hook into settings.json
wireHook(claudeDir, hookDest);
Expand Down
24 changes: 24 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"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\""
}
]
}
]
}
}
112 changes: 112 additions & 0 deletions hooks/install-mcp-deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
install-mcp-deps.py — SessionStart hook for the CARL native plugin.

Bootstraps @modelcontextprotocol/sdk into CLAUDE_PLUGIN_DATA so the MCP
server (mcp/index.js) can boot without shipping node_modules.

The MCP 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)
3 changes: 2 additions & 1 deletion mcp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'node:fs';

// Tool group imports
import { TOOLS as domainTools, handleTool as handleDomain } from './tools/domains.js';
Expand All @@ -28,7 +29,7 @@ import { TOOLS as carlJsonTools, handleTool as handleCarlJson } from './tools/ca
// Resolve workspace from this file's location:
// Installed at .carl/carl-mcp/index.js → workspace is ../..
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORKSPACE_PATH = path.resolve(__dirname, '../..');
const WORKSPACE_PATH = (() => { const c = process.env.CLAUDE_PROJECT_DIR; if (c && c.trim()) return path.resolve(c.trim()); const real = (p) => { try { return fs.realpathSync(path.resolve(p)); } catch { return path.resolve(p); } }; const h = process.env.HOME || process.env.USERPROFILE; const home = h ? real(h) : null; const f = real(path.resolve(__dirname, '../..')); const vendored = path.basename(__dirname) === 'carl-mcp' && path.basename(path.dirname(__dirname)) === '.carl'; if (vendored && f !== home) return f; const cwd = real(process.cwd()); if (cwd !== home) return cwd; throw new Error('[CARL] Refusing to start: CLAUDE_PROJECT_DIR is unset and no project workspace could be resolved (cwd and install location are your home directory; refusing to read a global ~/.carl store). Set CLAUDE_PROJECT_DIR or launch carl-mcp from the project root.'); })();

function debugLog(...args) {
console.error('[CARL]', new Date().toISOString(), ...args);
Expand Down