mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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.
384 lines
12 KiB
TypeScript
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('');
|
|
};
|