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
286 changes: 286 additions & 0 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ function parseWorkspaceDirArg() {
const explicitWorkspaceDir = parseWorkspaceDirArg();
const hasHelp = args.includes('--help') || args.includes('-h');

// Parse --skills-dir flag and optional --dir <path>
const hasSkillsDir = args.includes('--skills-dir');

function parseSkillsDirTarget() {
const idx = args.findIndex(arg => arg === '--dir');
if (idx !== -1) {
const nextArg = args[idx + 1];
if (!nextArg || nextArg.startsWith('-')) {
console.error(` ${yellow}--dir requires a path argument${reset}`);
process.exit(1);
}
return nextArg;
}
return null;
}
const explicitSkillsDirTarget = parseSkillsDirTarget();

console.log(banner);

// Show help if requested
Expand All @@ -81,6 +98,7 @@ if (hasHelp) {
${cyan}-w, --workspace${reset} Install workspace layer (.base/ in current directory)
${cyan}-c, --config-dir <path>${reset} Specify custom Claude config directory
${cyan}--workspace-dir <path>${reset} Specify workspace root (default: cwd)
${cyan}--skills-dir [--dir <path>]${reset} Install as a self-contained Claude Code skills-dir plugin
${cyan}-h, --help${reset} Show this help message

${yellow}Examples:${reset}
Expand Down Expand Up @@ -513,6 +531,265 @@ function installWorkspace() {
console.log(` Run ${cyan}/base:scaffold${reset} to complete setup (hook wiring, operator profile).\n`);
}

/**
* Rewrite @~/.claude/base-framework/ and @./.claude/base-framework/ refs
* to ${CLAUDE_PLUGIN_ROOT}/base-framework/ in the given text.
*/
function rewriteBaseFrameworkRefs(text) {
text = text.replace(/@~\/\.claude\/base-framework\//g, '@${CLAUDE_PLUGIN_ROOT}/base-framework/');
text = text.replace(/@\.\/\.claude\/base-framework\//g, '@${CLAUDE_PLUGIN_ROOT}/base-framework/');
return text;
}

/**
* Install as a self-contained Claude Code skills-dir plugin.
* Target: --dir <path> or default <cwd>/.claude/skills/base/
*/
function installSkillsDir() {
const src = path.join(__dirname, '..');
const targetBase = explicitSkillsDirTarget
? path.resolve(expandTilde(explicitSkillsDirTarget))
: path.join(process.cwd(), '.claude', 'skills', 'base');

const pluginDir = path.join(targetBase, '.claude-plugin');
const commandsDir = path.join(targetBase, 'commands');
const skillsDir = path.join(targetBase, 'skills');
const frameworkDir = path.join(targetBase, 'base-framework');
const hooksDir = path.join(targetBase, 'hooks');
const mcpDir = path.join(targetBase, 'mcp');

const targetLabel = targetBase.replace(os.homedir(), '~').replace(process.cwd(), '.');
console.log(` Installing skills-dir plugin to ${cyan}${targetLabel}${reset}\n`);

// Create plugin manifest
fs.mkdirSync(pluginDir, { recursive: true });
const pluginJson = {
name: 'base',
version: pkg.version,
description: "Builder's Automated State Engine — workspace lifecycle management for Claude Code. Scaffold, audit, groom, and maintain AI builder workspaces."
};
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(pluginJson, null, 2));
console.log(` ${green}+${reset} .claude-plugin/plugin.json`);

// Copy commands — rewrite base-framework refs in each .md file
const commandsSrc = path.join(src, 'src', 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const copyDirRewritingRefs = (srcDir, destDir) => {
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
copyDirRewritingRefs(srcPath, destPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(srcPath, 'utf-8');
fs.writeFileSync(destPath, rewriteBaseFrameworkRefs(content));
} else {
fs.copyFileSync(srcPath, destPath);
}
}
};
copyDirRewritingRefs(commandsSrc, commandsDir);
const commandCount = fs.readdirSync(commandsSrc).filter(f => f.endsWith('.md')).length;
console.log(` ${green}+${reset} commands/ (${commandCount} slash commands, refs rewritten)`);

// Copy skill entry point — rewrite refs
const skillSrc = path.join(src, 'src', 'skill');
fs.mkdirSync(skillsDir, { recursive: true });
copyDirRewritingRefs(skillSrc, skillsDir);
console.log(` ${green}+${reset} skills/ (entry point)`);

// Copy BASE framework — tasks, templates, context, frameworks
const frameworkSrc = path.join(src, 'src', 'framework');
copyDirRewritingRefs(frameworkSrc, frameworkDir);
// Copy hooks into base-framework/hooks/ (for scaffold reference)
const hooksFrameworkDest = path.join(frameworkDir, 'hooks');
fs.mkdirSync(hooksFrameworkDest, { recursive: true });
const hooksSrcDir = path.join(src, 'src', 'hooks');
const hookFiles = fs.readdirSync(hooksSrcDir).filter(f => f.endsWith('.py'));
for (const hookFile of hookFiles) {
fs.copyFileSync(path.join(hooksSrcDir, hookFile), path.join(hooksFrameworkDest, hookFile));
}
// Copy MCP package into base-framework/packages/base-mcp/
const fwPkgDest = path.join(frameworkDir, 'packages', 'base-mcp');
fs.mkdirSync(fwPkgDest, { recursive: true });
copyDir(path.join(src, 'src', 'packages', 'base-mcp'), fwPkgDest);
console.log(` ${green}+${reset} base-framework/ (tasks, templates, context, frameworks, hooks, packages)`);

// Copy hooks/ as standalone directory
fs.mkdirSync(hooksDir, { recursive: true });
for (const hookFile of hookFiles) {
fs.copyFileSync(path.join(hooksSrcDir, hookFile), path.join(hooksDir, hookFile));
}
console.log(` ${green}+${reset} hooks/ (${hookFiles.length} hook scripts)`);

// Emit SessionStart deps-installer script
const installMcpDepsScript = `#!/usr/bin/env python3
"""
SessionStart hook: install base-mcp npm dependencies into CLAUDE_PLUGIN_DATA.

Idempotent: exits 0 immediately if @modelcontextprotocol/sdk is already present.
Fail-open: warns to stderr and exits 0 on any error so the session always starts.

Strategy:
1. npm install --omit=dev --prefix "$CLAUDE_PLUGIN_DATA"
-> places node_modules at $CLAUDE_PLUGIN_DATA/node_modules/
2. symlink $CLAUDE_PLUGIN_ROOT/mcp/base-mcp/node_modules
-> $CLAUDE_PLUGIN_DATA/node_modules
Node ESM resolves bare specifiers by walking up from the importing file, so
node_modules must live adjacent to index.js. NODE_PATH is honored only by
the CommonJS loader (node:internal/modules/cjs), not the ESM loader; this
symlink bridges the two locations so ESM resolution succeeds.
"""
import os
import sys
import shutil
import subprocess

def warn(msg):
print(f"[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_data:
warn("CLAUDE_PLUGIN_DATA is not set; skipping MCP deps install.")
sys.exit(0)

if not plugin_root:
warn("CLAUDE_PLUGIN_ROOT is not set; skipping MCP deps install.")
sys.exit(0)

sdk_marker = os.path.join(plugin_data, "node_modules", "@modelcontextprotocol", "sdk")
mcp_dir = os.path.join(plugin_root, "mcp", "base-mcp")
mcp_nm = os.path.join(mcp_dir, "node_modules")

# Idempotent: if sdk already installed, just (re)assert symlink and exit
if os.path.isdir(sdk_marker):
# Re-assert symlink so it survives if the MCP dir was re-emitted
_assert_symlink(mcp_nm, plugin_data)
sys.exit(0)

# Copy package.json (and lockfile if present) into CLAUDE_PLUGIN_DATA
src_pkg = os.path.join(mcp_dir, "package.json")
if not os.path.isfile(src_pkg):
warn(f"package.json not found at {src_pkg}; skipping.")
sys.exit(0)

try:
os.makedirs(plugin_data, exist_ok=True)
shutil.copy2(src_pkg, os.path.join(plugin_data, "package.json"))

lockfile = os.path.join(mcp_dir, "package-lock.json")
if os.path.isfile(lockfile):
shutil.copy2(lockfile, os.path.join(plugin_data, "package-lock.json"))

npm = shutil.which("npm")
if not npm:
warn("npm not found in PATH; skipping MCP deps install.")
sys.exit(0)

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 failed (exit {result.returncode}): {result.stderr.strip()}")
sys.exit(0)

_assert_symlink(mcp_nm, plugin_data)
print(f"[install-mcp-deps] MCP deps installed to {plugin_data}", file=sys.stderr)

except Exception as exc:
warn(f"Unexpected error during MCP deps install: {exc}")
sys.exit(0)


def _assert_symlink(link_path, plugin_data):
"""Create or update the node_modules symlink inside the MCP dir."""
target = os.path.join(plugin_data, "node_modules")
try:
# Remove stale symlink or real dir so we can set the correct target
if os.path.islink(link_path):
if os.readlink(link_path) == target:
return # already correct
os.unlink(link_path)
elif os.path.isdir(link_path):
# A real node_modules exists (e.g. from a previous local install);
# leave it alone so we don't break a working setup.
return
os.symlink(target, link_path)
except Exception as exc:
print(f"[install-mcp-deps] WARNING: could not assert symlink {link_path} -> {target}: {exc}", file=sys.stderr)


if __name__ == "__main__":
main()
`;

fs.writeFileSync(path.join(hooksDir, 'install-mcp-deps.py'), installMcpDepsScript);
console.log(` ${green}+${reset} hooks/install-mcp-deps.py (SessionStart MCP deps installer)`);

// Write hooks.json — wires base's hooks using ${CLAUDE_PLUGIN_ROOT} paths
const hooksJson = {
hooks: {
UserPromptSubmit: [
{
hooks: [
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/active-hook.py' },
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/backlog-hook.py' },
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/base-pulse-check.py' },
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/psmm-injector.py' },
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/operator.py' }
]
}
],
SessionStart: [
{
hooks: [
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/satellite-detection.py' },
{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/install-mcp-deps.py' }
]
}
]
}
};
fs.writeFileSync(path.join(hooksDir, 'hooks.json'), JSON.stringify(hooksJson, null, 2));
console.log(` ${green}+${reset} hooks/hooks.json (UserPromptSubmit x5 + SessionStart x2: satellite-detection + install-mcp-deps)`);

// Copy MCP package into mcp/base-mcp/
const mcpPkgDest = path.join(mcpDir, 'base-mcp');
fs.mkdirSync(mcpPkgDest, { recursive: true });
copyDir(path.join(src, 'src', 'packages', 'base-mcp'), mcpPkgDest);
console.log(` ${green}+${reset} mcp/base-mcp/ (MCP server source)`);

// Write .mcp.json at plugin root with NODE_PATH + CLAUDE_PROJECT_DIR env
const mcpJson = {
mcpServers: {
'base-mcp': {
type: 'stdio',
command: 'node',
args: ['${CLAUDE_PLUGIN_ROOT}/mcp/base-mcp/index.js'],
env: {
CLAUDE_PROJECT_DIR: '${CLAUDE_PROJECT_DIR}',
NODE_PATH: '${CLAUDE_PLUGIN_DATA}/node_modules'
}
}
}
};
fs.writeFileSync(path.join(targetBase, '.mcp.json'), JSON.stringify(mcpJson, null, 2));
console.log(` ${green}+${reset} .mcp.json (base-mcp at plugin root; NODE_PATH + CLAUDE_PROJECT_DIR in env)`);

console.log(`\n ${green}Skills-dir plugin installed.${reset}`);
console.log(` ${dim}Loads next session as base@skills-dir (no marketplace/install).${reset}`);
console.log(` ${dim}Trust the workspace if prompted.${reset}`);
console.log(` ${dim}For Claude Code Cloud, commit .claude/skills/base/.${reset}\n`);
}

/**
* Prompt for install location
*/
Expand Down Expand Up @@ -556,6 +833,15 @@ async function main() {
return; // Already handled above
}

if (hasSkillsDir) {
if (hasGlobal || hasLocal) {
console.error(` ${yellow}Cannot combine --skills-dir with --global or --local${reset}`);
process.exit(1);
}
installSkillsDir();
return;
}

if (hasGlobal || hasLocal || hasWorkspace) {
if (hasGlobal && hasLocal) {
console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/active-hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Legacy active-hook.py reads from .base/data/active.json (unchanged).
"""

import os
import sys
import json
from pathlib import Path
Expand All @@ -17,7 +18,10 @@
SURFACE_NAME = "active"

HOOK_DIR = Path(__file__).resolve().parent
WORKSPACE_ROOT = HOOK_DIR.parent.parent
if os.environ.get('CLAUDE_PROJECT_DIR', '').strip():
WORKSPACE_ROOT = Path(os.environ['CLAUDE_PROJECT_DIR']).resolve()
else:
WORKSPACE_ROOT = HOOK_DIR.parent.parent
DATA_FILE = WORKSPACE_ROOT / ".base" / "data" / "projects.json"

BEHAVIOR_DIRECTIVE = f"""BEHAVIOR: This context is PASSIVE AWARENESS ONLY.
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/apex-insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
Invoked by /apex:insights slash command via !command injection.
"""

import os
import json
import sys
from datetime import datetime, date
from pathlib import Path
from collections import defaultdict

WORKSPACE = Path(__file__).resolve().parent.parent.parent
if os.environ.get('CLAUDE_PROJECT_DIR', '').strip():
WORKSPACE = Path(os.environ['CLAUDE_PROJECT_DIR']).resolve()
else:
WORKSPACE = Path(__file__).resolve().parent.parent.parent
PROJECTS_FILE = WORKSPACE / ".base" / "data" / "projects.json"
WORKSPACE_JSON = WORKSPACE / ".base" / "workspace.json"

Expand Down
6 changes: 5 additions & 1 deletion src/hooks/backlog-hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Legacy backlog-hook.py reads from .base/data/backlog.json (unchanged).
"""

import os
import sys
import json
from pathlib import Path
Expand All @@ -17,7 +18,10 @@
SURFACE_NAME = "backlog"

HOOK_DIR = Path(__file__).resolve().parent
WORKSPACE_ROOT = HOOK_DIR.parent.parent
if os.environ.get('CLAUDE_PROJECT_DIR', '').strip():
WORKSPACE_ROOT = Path(os.environ['CLAUDE_PROJECT_DIR']).resolve()
else:
WORKSPACE_ROOT = HOOK_DIR.parent.parent
DATA_FILE = WORKSPACE_ROOT / ".base" / "data" / "projects.json"

BEHAVIOR_DIRECTIVE = f"""BEHAVIOR: This context is PASSIVE AWARENESS ONLY.
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/base-pulse-check.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
Legacy base-pulse-check.py reads STATE.md + workspace.json (unchanged).
"""

import os
import sys
import json
from datetime import datetime, date
from pathlib import Path

HOOK_DIR = Path(__file__).resolve().parent
WORKSPACE_ROOT = HOOK_DIR.parent.parent
if os.environ.get('CLAUDE_PROJECT_DIR', '').strip():
WORKSPACE_ROOT = Path(os.environ['CLAUDE_PROJECT_DIR']).resolve()
else:
WORKSPACE_ROOT = HOOK_DIR.parent.parent
BASE_DIR = WORKSPACE_ROOT / ".base"
STATE_FILE = BASE_DIR / "data" / "state.json"
PROJECTS_FILE = BASE_DIR / "data" / "projects.json"
Expand Down
Loading