GitNexus/gitnexus/src/cli/setup.ts
Linus Beckhaus a13fa2a7c7 feat(plugin): transform Claude Code plugin into self-contained installable package
Bundle MCP server config (.mcp.json), fix hook to use spawnSync with npx
fallback and read stderr (KuzuDB stdout workaround), wire plugin.json with
hooks/mcpServers paths, add guide skill with tools/resources/schema reference,
add AMP-compatible mcp.json to all skill dirs, and slim CLAUDE.md generator
by moving reference content into the guide skill.
2026-02-25 11:47:16 +01:00

384 lines
12 KiB
TypeScript

/**
* Setup Command
*
* One-time global MCP configuration writer.
* Detects installed AI editors and writes the appropriate MCP config
* so the GitNexus MCP server is available in all projects.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { getGlobalDir } from '../storage/repo-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface SetupResult {
configured: string[];
skipped: string[];
errors: string[];
}
/**
* The MCP server entry for all editors
*/
function getMcpEntry() {
return {
command: 'npx',
args: ['-y', 'gitnexus@latest', 'mcp'],
};
}
/**
* Merge gitnexus entry into an existing MCP config JSON object.
* Returns the updated config.
*/
function mergeMcpConfig(existing: any): any {
if (!existing || typeof existing !== 'object') {
existing = {};
}
if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
existing.mcpServers = {};
}
existing.mcpServers.gitnexus = getMcpEntry();
return existing;
}
/**
* Try to read a JSON file, returning null if it doesn't exist or is invalid.
*/
async function readJsonFile(filePath: string): Promise<any | null> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Write JSON to a file, creating parent directories if needed.
*/
async function writeJsonFile(filePath: string, data: any): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
/**
* Check if a directory exists
*/
async function dirExists(dirPath: string): Promise<boolean> {
try {
const stat = await fs.stat(dirPath);
return stat.isDirectory();
} catch {
return false;
}
}
// ─── Editor-specific setup ─────────────────────────────────────────
async function setupCursor(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
if (!(await dirExists(cursorDir))) {
result.skipped.push('Cursor (not installed)');
return;
}
const mcpPath = path.join(cursorDir, 'mcp.json');
try {
const existing = await readJsonFile(mcpPath);
const updated = mergeMcpConfig(existing);
await writeJsonFile(mcpPath, updated);
result.configured.push('Cursor');
} catch (err: any) {
result.errors.push(`Cursor: ${err.message}`);
}
}
async function setupClaudeCode(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
const hasClaude = await dirExists(claudeDir);
if (!hasClaude) {
result.skipped.push('Claude Code (not installed)');
return;
}
// Claude Code uses a JSON settings file at ~/.claude.json or claude mcp add
console.log('');
console.log(' Claude Code detected. Run this command to add GitNexus MCP:');
console.log('');
console.log(' claude mcp add gitnexus -- npx -y gitnexus mcp');
console.log('');
result.configured.push('Claude Code (MCP manual step printed)');
}
/**
* Install GitNexus skills to ~/.claude/skills/ for Claude Code.
*/
async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const skillsDir = path.join(claudeDir, 'skills');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`Claude Code skills (${installed.length} skills → ~/.claude/skills/)`);
}
} catch (err: any) {
result.errors.push(`Claude Code skills: ${err.message}`);
}
}
/**
* Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
* Merges hook config without overwriting existing hooks.
*/
async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const settingsPath = path.join(claudeDir, 'settings.json');
// Source hooks bundled within the gitnexus package (hooks/claude/)
const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
// Copy unified hook script to ~/.claude/hooks/gitnexus/
const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
try {
await fs.mkdir(destHooksDir, { recursive: true });
const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
const content = await fs.readFile(src, 'utf-8');
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip
}
const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`;
// Merge hook config into ~/.claude/settings.json
const existing = await readJsonFile(settingsPath) || {};
if (!existing.hooks) existing.hooks = {};
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
// Add PreToolUse hook if not already present
if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
const hasPreToolHook = existing.hooks.PreToolUse.some(
(h: any) => h.hooks?.some((hh: any) => hh.command?.includes('gitnexus'))
);
if (!hasPreToolHook) {
existing.hooks.PreToolUse.push({
matcher: 'Grep|Glob|Bash',
hooks: [{
type: 'command',
command: hookCmd,
timeout: 8000,
statusMessage: 'Enriching with GitNexus graph context...',
}],
});
}
await writeJsonFile(settingsPath, existing);
result.configured.push('Claude Code hooks (PreToolUse)');
} catch (err: any) {
result.errors.push(`Claude Code hooks: ${err.message}`);
}
}
async function setupOpenCode(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
if (!(await dirExists(opencodeDir))) {
result.skipped.push('OpenCode (not installed)');
return;
}
const configPath = path.join(opencodeDir, 'config.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};
if (!config.mcp) config.mcp = {};
config.mcp.gitnexus = getMcpEntry();
await writeJsonFile(configPath, config);
result.configured.push('OpenCode');
} catch (err: any) {
result.errors.push(`OpenCode: ${err.message}`);
}
}
// ─── Skill Installation ───────────────────────────────────────────
const SKILL_NAMES = ['exploring', 'debugging', 'impact-analysis', 'refactoring', 'guide'];
/**
* Install GitNexus skills to a target directory.
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
* following the Agent Skills standard (both Cursor and Claude Code).
*
* Supports two source layouts:
* - Flat file: skills/{name}.md → copied as SKILL.md
* - Directory: skills/{name}/SKILL.md → copied recursively (includes references/, etc.)
*/
async function installSkillsTo(targetDir: string): Promise<string[]> {
const installed: string[] = [];
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
for (const skillName of SKILL_NAMES) {
const skillDir = path.join(targetDir, `gitnexus-${skillName}`);
try {
// Try directory-based skill first (skills/{name}/SKILL.md)
const dirSource = path.join(skillsRoot, skillName);
const dirSkillFile = path.join(dirSource, 'SKILL.md');
let isDirectory = false;
try {
const stat = await fs.stat(dirSource);
isDirectory = stat.isDirectory();
} catch { /* not a directory */ }
if (isDirectory) {
await copyDirRecursive(dirSource, skillDir);
installed.push(skillName);
} else {
// Fall back to flat file (skills/{name}.md)
const flatSource = path.join(skillsRoot, `${skillName}.md`);
const content = await fs.readFile(flatSource, 'utf-8');
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
installed.push(skillName);
}
} catch {
// Source skill not found — skip
}
}
return installed;
}
/**
* Recursively copy a directory tree.
*/
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Install global Cursor skills to ~/.cursor/skills/gitnexus/
*/
async function installCursorSkills(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
if (!(await dirExists(cursorDir))) return;
const skillsDir = path.join(cursorDir, 'skills');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`Cursor skills (${installed.length} skills → ~/.cursor/skills/)`);
}
} catch (err: any) {
result.errors.push(`Cursor skills: ${err.message}`);
}
}
/**
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
*/
async function installOpenCodeSkills(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
if (!(await dirExists(opencodeDir))) return;
const skillsDir = path.join(opencodeDir, 'skill');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`OpenCode skills (${installed.length} skills → ~/.config/opencode/skill/)`);
}
} catch (err: any) {
result.errors.push(`OpenCode skills: ${err.message}`);
}
}
// ─── Main command ──────────────────────────────────────────────────
export const setupCommand = async () => {
console.log('');
console.log(' GitNexus Setup');
console.log(' ==============');
console.log('');
// Ensure global directory exists
const globalDir = getGlobalDir();
await fs.mkdir(globalDir, { recursive: true });
const result: SetupResult = {
configured: [],
skipped: [],
errors: [],
};
// Detect and configure each editor's MCP
await setupCursor(result);
await setupClaudeCode(result);
await setupOpenCode(result);
// Install global skills for platforms that support them
await installClaudeCodeSkills(result);
await installClaudeCodeHooks(result);
await installCursorSkills(result);
await installOpenCodeSkills(result);
// Print results
if (result.configured.length > 0) {
console.log(' Configured:');
for (const name of result.configured) {
console.log(` + ${name}`);
}
}
if (result.skipped.length > 0) {
console.log('');
console.log(' Skipped:');
for (const name of result.skipped) {
console.log(` - ${name}`);
}
}
if (result.errors.length > 0) {
console.log('');
console.log(' Errors:');
for (const err of result.errors) {
console.log(` ! ${err}`);
}
}
console.log('');
console.log(' Summary:');
console.log(` MCP configured for: ${result.configured.filter(c => !c.includes('skills')).join(', ') || 'none'}`);
console.log(` Skills installed to: ${result.configured.filter(c => c.includes('skills')).length > 0 ? result.configured.filter(c => c.includes('skills')).join(', ') : 'none'}`);
console.log('');
console.log(' Next steps:');
console.log(' 1. cd into any git repo');
console.log(' 2. Run: gitnexus analyze');
console.log(' 3. Open the repo in your editor — MCP is ready!');
console.log('');
};