diff --git a/bin/install.js b/bin/install.js index 4551e06..006457a 100644 --- a/bin/install.js +++ b/bin/install.js @@ -69,6 +69,23 @@ function parseWorkspaceDirArg() { const explicitWorkspaceDir = parseWorkspaceDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); +// Parse --skills-dir flag and optional --dir +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 @@ -81,6 +98,7 @@ if (hasHelp) { ${cyan}-w, --workspace${reset} Install workspace layer (.base/ in current directory) ${cyan}-c, --config-dir ${reset} Specify custom Claude config directory ${cyan}--workspace-dir ${reset} Specify workspace root (default: cwd) + ${cyan}--skills-dir [--dir ]${reset} Install as a self-contained Claude Code skills-dir plugin ${cyan}-h, --help${reset} Show this help message ${yellow}Examples:${reset} @@ -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 or default /.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 */ @@ -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}`); diff --git a/src/hooks/active-hook.py b/src/hooks/active-hook.py index cca4716..0d25d8f 100644 --- a/src/hooks/active-hook.py +++ b/src/hooks/active-hook.py @@ -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 @@ -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. diff --git a/src/hooks/apex-insights.py b/src/hooks/apex-insights.py index 9016734..408fb9e 100644 --- a/src/hooks/apex-insights.py +++ b/src/hooks/apex-insights.py @@ -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" diff --git a/src/hooks/backlog-hook.py b/src/hooks/backlog-hook.py index 1f4599d..16c58eb 100644 --- a/src/hooks/backlog-hook.py +++ b/src/hooks/backlog-hook.py @@ -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 @@ -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. diff --git a/src/hooks/base-pulse-check.py b/src/hooks/base-pulse-check.py index ef4db98..2b92ab3 100644 --- a/src/hooks/base-pulse-check.py +++ b/src/hooks/base-pulse-check.py @@ -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" diff --git a/src/hooks/operator.py b/src/hooks/operator.py index 5b4aa0c..192091b 100644 --- a/src/hooks/operator.py +++ b/src/hooks/operator.py @@ -6,11 +6,15 @@ Controlled by: hook_active field in operator.json (true/false) """ +import os import json 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 DATA_FILE = WORKSPACE_ROOT / ".base" / "operator.json" diff --git a/src/hooks/psmm-injector.py b/src/hooks/psmm-injector.py index 331faf4..ca2da75 100644 --- a/src/hooks/psmm-injector.py +++ b/src/hooks/psmm-injector.py @@ -13,12 +13,16 @@ Output: Current session's PSMM entries as system context, or silent if empty. """ +import os import sys import json 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 PSMM_FILE = WORKSPACE_ROOT / ".base" / "data" / "psmm.json" diff --git a/src/hooks/satellite-detection.py b/src/hooks/satellite-detection.py index ead4c49..3f6c168 100644 --- a/src/hooks/satellite-detection.py +++ b/src/hooks/satellite-detection.py @@ -15,6 +15,7 @@ Respects satellite.sync: false as opt-out for steps 3-4. """ +import os import sys import json from datetime import datetime @@ -22,7 +23,10 @@ # Workspace root — find .base/ relative to this hook's location HOOK_DIR = Path(__file__).resolve().parent -WORKSPACE_ROOT = HOOK_DIR.parent.parent # hooks/ -> .base/ -> workspace +if os.environ.get('CLAUDE_PROJECT_DIR', '').strip(): + WORKSPACE_ROOT = Path(os.environ['CLAUDE_PROJECT_DIR']).resolve() +else: + WORKSPACE_ROOT = HOOK_DIR.parent.parent # hooks/ -> .base/ -> workspace BASE_DIR = WORKSPACE_ROOT / ".base" MANIFEST_FILE = BASE_DIR / "workspace.json" PROJECTS_FILE = BASE_DIR / "data" / "projects.json" diff --git a/src/packages/base-mcp/index.js b/src/packages/base-mcp/index.js index e029ad5..71278be 100644 --- a/src/packages/base-mcp/index.js +++ b/src/packages/base-mcp/index.js @@ -27,7 +27,7 @@ import { TOOLS as satelliteTools, handleTool as handleSatellite } from './tools/ // Resolve workspace from this file's location: base-mcp/ → .base/ → workspace root const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const WORKSPACE_PATH = path.resolve(__dirname, '../..'); +const WORKSPACE_PATH = (process.env.CLAUDE_PROJECT_DIR && process.env.CLAUDE_PROJECT_DIR.trim()) ? path.resolve(process.env.CLAUDE_PROJECT_DIR.trim()) : path.resolve(__dirname, '../..'); function debugLog(...args) { console.error('[BASE]', new Date().toISOString(), ...args);