mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-27 06:12:20 +00:00
* fix(hooks): pass windowsHide:true to every spawnSync to suppress flashing console windows on Windows
On Windows, every PostToolUse and Stop event from Claude Code (and
the Cursor integration variant) cold-spawns ``node`` / ``npx.cmd`` /
``git`` / ``lsof`` through ``child_process.spawnSync``. Without
``windowsHide: true`` in the options, Node's child_process module
asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
``SW_SHOWDEFAULT``, and a black console window flashes onto the
user's desktop for the duration of the call. Under active
editor / agent use this means a near-continuous stream of pop-up
windows — unusable in practice (reported live on a Windows 11
workstation running the gitnexus Claude plugin against an active
project; the flashes stack on the taskbar and steal focus from the
editor).
The Node fix is one option flag per spawnSync:
spawnSync(cmd, args, {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, // <-- new
});
``windowsHide`` is a no-op on macOS/Linux (Node docs: "Hide the
subprocess console window that would normally be created on Windows
systems"), so the patch is platform-neutral and zero-risk on the
other two majors.
This commit touches every ``spawnSync`` call in the three sources
that ship the hook layer:
* gitnexus/hooks/claude/gitnexus-hook.cjs (4 sites)
* gitnexus/hooks/claude/hook-db-lock-probe.cjs (3 sites)
* gitnexus-claude-plugin/hooks/gitnexus-hook.js (6 sites)
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs (3 sites)
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (3 sites)
Total: 19 spawn sites guarded. ``hook-lock.cjs`` / ``hook-lock.js``
don't spawn subprocesses; nothing else in the hooks/ dirs touches
``child_process``.
Verified on Windows 10 22H2 / Node 22.21 / gitnexus 1.6.5 by
installing the locally-built tarball and running an active Claude
Code session against a large mixed-language repo — no console
window appears for any hook fire (pre-fix: ~2-3 visible flashes per
edit). No behavioural change on Linux/macOS hosts.
* test(hooks): regression — every hook spawnSync paired with windowsHide:true
Source-level assertion that every ``spawnSync`` invocation in the
hook layer has a matching ``windowsHide: true`` in its options
object. Without the flag, Node's child_process module asks
CreateProcess to use STARTF_USESHOWWINDOW with SW_SHOWDEFAULT and
a black console window flashes onto the user's desktop for the
duration of each call — see the parent fix commit.
The check is source-level rather than behavioural because:
* the flag's effect is observable only on Windows;
* GitHub Actions runs vitest on Linux for the hook tests;
* regressing this is easy (every new spawnSync site has to remember
to add the flag), and a runtime check on a Windows-only CI leg
would still let a PR land on the main branch first.
Counts spawnSync occurrences and windowsHide:true occurrences per
file (in code, ignoring comments) and asserts equality. Five files
covered:
* gitnexus/hooks/claude/gitnexus-hook.cjs
* gitnexus/hooks/claude/hook-db-lock-probe.cjs
* gitnexus-claude-plugin/hooks/gitnexus-hook.js
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Adding a new hook file requires updating the HOOK_FILES tuple. A
sanity assertion ``spawnCount > 0`` catches accidental deletion of
all spawn calls in a future refactor (would otherwise silently make
the count-equality assertion trivially true).
Sits next to the existing "no shell: true" and ".cmd extension"
regression tests in test/unit/hooks.test.ts — same shape, same
spirit.
* fix(src): extend windowsHide:true to every spawn-family call in cli/core/mcp/server
Companion to the hook-layer fix in this branch's first commit. The
same Windows console-window flash bug applies to every
``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
``execFileAsync`` / ``execSync`` call in the source tree — not just
the hooks. The MCP local backend
(``src/mcp/local/local-backend.ts``) and the ``gitnexus serve`` git
helpers (``src/server/git-clone.ts``) are particularly bad because
they run from daemonized processes that have no parent console; the
spawned child auto-allocates one and it pops onto the user's
desktop. The CLI sites are less visible (the user is at a terminal
with an existing console; ``stdio: 'inherit'`` shares it) but the
flag is harmless there — windowsHide only suppresses NEW console
allocation, an inherited parent console is untouched. The visible
output of ``gitnexus analyze`` and friends is preserved verbatim.
The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
established the convention in this codebase. This commit applies it
uniformly.
Sites covered (21 new):
| File | Sites |
|---|---|
| src/cli/analyze.ts | 1 |
| src/cli/setup.ts | 2 |
| src/cli/wiki.ts | 3 |
| src/core/embeddings/embedder.ts | 1 |
| src/core/git-staleness.ts | 3 |
| src/core/run-analyze.ts | 1 |
| src/core/wiki/cursor-client.ts | 2 |
| src/core/wiki/generator.ts | 3 |
| src/mcp/local/local-backend.ts | 2 |
| src/server/git-clone.ts | 2 |
| src/core/lbug/extension-loader.ts | (already had it, untouched) |
Combined with the 19 hook sites from the first commit + the 1
pre-existing extension-loader site, the codebase now has uniform
``windowsHide: true`` on every spawn-family call.
Behavioural notes:
* ``windowsHide`` is documented by Node as a no-op on POSIX —
Linux/macOS hosts see byte-identical behaviour.
* ``stdio: 'inherit'`` callers (e.g. ``cli/wiki.ts:522`` opens the
editor in the user's terminal) keep their interactive UX. The
child inherits the parent's stdio handles; no new console is
allocated; the flag has nothing to hide.
* Piped callers (``stdio: ['pipe',…]``) continue to deliver every
byte of stdout/stderr back to the parent for the parent to log
/ process / re-print. No output is swallowed.
* ``execSync`` / ``execFileSync`` callers that previously had no
``stdio`` option (e.g. ``generator.ts:887`` ``execSync('git
rev-parse HEAD', { cwd })``) keep their default pipe semantics
(``.toString()`` still works) — windowsHide is added alongside
the existing ``cwd`` option.
Verified on Windows 10 22H2 / Node 22.21 by installing the locally
built tarball and exercising:
* MCP detect_changes via the local backend → no flash.
* gitnexus serve → no flash on git clone/clone-pull.
* gitnexus analyze interactively → output appears in terminal as
before, no extra window.
* test(windowsHide): extend regression to every spawn-family call in src/
Companion to the src/ patch. The hooks.test.ts regression now
covers 16 files (5 hooks + 11 source files), and asserts the
invariant for every spawn-family function — not just spawnSync.
Changes:
* Generalise countSpawnCalls() to also count spawn, execFile,
execFileSync, execFileAsync, execSync (the entire spawn-family
surface of child_process). Skip method calls (e.g. RegExp.exec)
via a negative-lookbehind on ``.``.
* Add SRC_FILES table with all 11 source-tree files that import
spawn-family functions from child_process.
* Loop over [...HOOK_FILES, ...SRC_FILES] so a regression in any
file fails the same test name.
* Tighten the assertion to ``hideCount >= spawnCount`` rather
than strict equality, because some sites (e.g. setup.ts:534
using execFileAsync via shell:true on Windows) may legitimately
add windowsHide to nested option objects in future refactors.
* Sanity gate ``spawnCount > 0`` per file catches a refactor
that deletes all spawn calls (would otherwise make the
assertion trivially true).
Manually exercised against the patched repo:
16 files, 28 total spawn-family calls, 28 windowsHide:true.
All pass.
The convention to keep this list in sync: every new file in
gitnexus/src/ that imports from 'child_process' must be added to
the SRC_FILES tuple. The cost is one line per file; the benefit
is the next contributor never has to think about windowsHide
again — the test will catch a miss before merge.
* style: prettier --write on storage/git.ts + hooks.test.ts
CI quality / format job flagged two formatting issues in the
merge-resolution commit: a long single-line options object in
storage/git.ts and similar in hooks.test.ts. prettier --write
fixes both with the project's standard wrap-and-trailing-comma
style. No semantic change.
* test(git): include windowsHide in toHaveBeenCalledWith assertion
The merge-resolution commit added windowsHide:true to the
'git rev-parse --is-inside-work-tree' execSync call in
src/storage/git.ts, but the matching strict-shape assertion in
git.test.ts:31-34 still expected the pre-patch two-key options
object {cwd, stdio}. vitest's toHaveBeenCalledWith does a deep
structural match, so the extra third key flipped the assertion
to fail.
Add windowsHide: true to the expected shape. Only this one
assertion is strict; the two siblings ('passes the correct cwd'
and the no-cwd-arg case) use expect.objectContaining and
expect.any(String) and remain green without modification.
* test(setup-codex): include windowsHide in execFile shape assertions
Same root cause as the git.test.ts fix on this branch: the windowsHide
patch added windowsHide:true to the execFile() options in
src/cli/setup.ts, but three strict-shape toHaveBeenCalledWith
assertions in setup-codex.test.ts still expected the pre-patch
{shell:true} / {shell:false} two-key options. vitest does a deep
structural match, so the extra key flipped the assertions to fail
on every CI matrix leg (ubuntu coverage + macos + windows).
Adding windowsHide:true alongside the existing 'shell' key in
all three sites.
* ci: retrigger checks
go-parity failed on a flaky onnxruntime-node postinstall network timeout
(AggregateError [ETIMEDOUT] in node ./script/install), which cascaded into
the CI Gate. No code change — empty commit to re-run the pipeline.
* fix(test): strengthen windowsHide regression assertions (PR #1794 review)
- Replace toBeGreaterThanOrEqual with exact toBe per DoD §2.7
- Remove unused `m` variable in countSpawnCalls (CodeQL finding)
- Add windowsHide: true to runGit test helper for consistency
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: ManniX-ITA <35522085+ManniX-ITA@users.noreply.github.com>
Co-authored-by: Test <test@example.com>
756 lines
24 KiB
TypeScript
756 lines
24 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 { execFile, execFileSync } from 'child_process';
|
|
import { createRequire } from 'module';
|
|
import { promisify } from 'util';
|
|
import { fileURLToPath } from 'url';
|
|
import { glob } from 'glob';
|
|
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
|
|
import { getGlobalDir } from '../storage/repo-manager.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Pin the npx fallback to the installed version. Reason: setup.ts writes
|
|
// a config that persists in the user's editor and is invoked on every MCP
|
|
// connect. Pinning to the installed version means subsequent invocations
|
|
// skip the npm-registry metadata roundtrip (and stay reproducible until
|
|
// the user upgrades). Static configs and READMEs intentionally use
|
|
// `gitnexus@latest` since they're quickstart docs, not persisted state.
|
|
const _require = createRequire(import.meta.url);
|
|
const _pkg = _require('../../package.json') as { version?: unknown };
|
|
if (typeof _pkg.version !== 'string' || !_pkg.version) {
|
|
throw new Error(
|
|
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
|
|
);
|
|
}
|
|
const NPX_REF = `gitnexus@${_pkg.version}`;
|
|
|
|
interface SetupResult {
|
|
configured: string[];
|
|
skipped: string[];
|
|
errors: string[];
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path to the `gitnexus` binary if it's installed
|
|
* globally (or via npm -g / yarn global). Returns null when not found.
|
|
*/
|
|
function resolveGitnexusBin(): string | null {
|
|
try {
|
|
const isWin = process.platform === 'win32';
|
|
const cmd = isWin ? 'where' : 'which';
|
|
const output = execFileSync(cmd, ['gitnexus'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
});
|
|
const lines = output
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
|
|
if (isWin) {
|
|
// On Windows, npm global installs can surface multiple launchers for the
|
|
// same package (e.g. a POSIX shell shim plus .cmd/.bat wrappers). Claude
|
|
// and the other MCP hosts need a directly spawnable command path, so only
|
|
// accept the Windows wrapper. If it is missing, fall back to the slower
|
|
// npx entry instead of persisting a non-spawnable shim path.
|
|
const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
|
|
return cmdLine || null;
|
|
}
|
|
|
|
return lines[0] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The MCP server entry for all editors.
|
|
*
|
|
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
|
|
* `npx -y gitnexus@<version>` (cold-cache install of native deps can take
|
|
* >60 s, exceeding Claude Code's 30 s MCP connection timeout). The fallback
|
|
* version is read from gitnexus/package.json#version at module load so the
|
|
* persisted user config matches the installed package.
|
|
*
|
|
* Falls back to npx when the binary isn't on PATH — e.g. first-time
|
|
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
|
|
*/
|
|
function getMcpEntry() {
|
|
const bin = resolveGitnexusBin();
|
|
|
|
if (bin) {
|
|
return { command: bin, args: ['mcp'] };
|
|
}
|
|
|
|
// Fallback: npx (works without a global install, but slow cold-start)
|
|
if (process.platform === 'win32') {
|
|
return {
|
|
command: 'cmd',
|
|
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
|
};
|
|
}
|
|
return {
|
|
command: 'npx',
|
|
args: ['-y', NPX_REF, 'mcp'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* OpenCode uses a different MCP format: { type: "local", command: [...] }
|
|
* where command is a flat array (command + args combined).
|
|
*/
|
|
function getOpenCodeMcpEntry() {
|
|
const bin = resolveGitnexusBin();
|
|
|
|
if (bin) {
|
|
return { type: 'local', command: [bin, 'mcp'] };
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
|
|
}
|
|
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
|
|
}
|
|
|
|
/**
|
|
* Detect indentation style from file content.
|
|
* Returns formatting options matching the file's existing style.
|
|
*/
|
|
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
|
|
const firstIndented = raw.match(/^( +|\t)/m);
|
|
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
|
|
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
|
|
return { tabSize: firstIndented[1].length, insertSpaces: true };
|
|
}
|
|
|
|
/**
|
|
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
|
|
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
|
|
*/
|
|
async function mergeJsoncFile(
|
|
filePath: string,
|
|
keyPath: string[],
|
|
value: unknown,
|
|
): Promise<boolean> {
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.readFile(filePath, 'utf-8');
|
|
} catch {
|
|
raw = '';
|
|
}
|
|
|
|
if (raw.trim().length === 0) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
const formattingOptions = { tabSize: 2, insertSpaces: true };
|
|
const edits = modify('{}', keyPath, value, { formattingOptions });
|
|
const result = applyEdits('{}', edits);
|
|
await fs.writeFile(filePath, result, 'utf-8');
|
|
return true;
|
|
}
|
|
|
|
const parseErrors: ParseError[] = [];
|
|
const tree = parseTree(raw, parseErrors);
|
|
|
|
if (tree && tree.type === 'object' && parseErrors.length === 0) {
|
|
const formattingOptions = detectIndentation(raw);
|
|
const edits = modify(raw, keyPath, value, { formattingOptions });
|
|
const result = applyEdits(raw, edits);
|
|
await fs.writeFile(filePath, result, 'utf-8');
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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 ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
|
if (ok) {
|
|
result.configured.push('Cursor');
|
|
} else {
|
|
result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content');
|
|
}
|
|
} catch (err: any) {
|
|
result.errors.push(`Cursor: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function setupClaudeCode(result: SetupResult): Promise<void> {
|
|
const claudeDir = path.join(os.homedir(), '.claude');
|
|
if (!(await dirExists(claudeDir))) {
|
|
result.skipped.push('Claude Code (not installed)');
|
|
return;
|
|
}
|
|
|
|
// Claude Code stores MCP config in ~/.claude.json
|
|
const mcpPath = path.join(os.homedir(), '.claude.json');
|
|
try {
|
|
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
|
if (ok) {
|
|
result.configured.push('Claude Code');
|
|
} else {
|
|
result.errors.push(
|
|
'Claude Code: .claude.json is corrupt — skipping to preserve existing content',
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
result.errors.push(`Claude Code: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether an event array already contains a gitnexus-hook entry.
|
|
*/
|
|
function hasGitnexusHook(hooksObj: any, eventName: string): boolean {
|
|
const entries = hooksObj?.[eventName];
|
|
if (!Array.isArray(entries)) return false;
|
|
return entries.some(
|
|
(h: any) =>
|
|
Array.isArray(h.hooks) &&
|
|
h.hooks.some(
|
|
(hh: any) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook'),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Merge hook entries into a JSONC settings file, preserving comments and formatting.
|
|
* Uses chained modify()+applyEdits() calls to append to arrays without a full
|
|
* JSON.stringify roundtrip that would strip comments.
|
|
*/
|
|
async function mergeHooksJsonc(
|
|
filePath: string,
|
|
entries: Array<{ eventName: string; value: unknown }>,
|
|
): Promise<boolean> {
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.readFile(filePath, 'utf-8');
|
|
} catch {
|
|
raw = '';
|
|
}
|
|
|
|
if (raw.trim().length === 0) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
const hooks: any = {};
|
|
for (const { eventName, value } of entries) {
|
|
hooks[eventName] = [value];
|
|
}
|
|
const formattingOptions = { tabSize: 2, insertSpaces: true };
|
|
const edits = modify('{}', ['hooks'], hooks, { formattingOptions });
|
|
await fs.writeFile(filePath, applyEdits('{}', edits), 'utf-8');
|
|
return true;
|
|
}
|
|
|
|
const parseErrors: ParseError[] = [];
|
|
const tree = parseTree(raw, parseErrors);
|
|
|
|
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
|
|
return false;
|
|
}
|
|
|
|
const formattingOptions = detectIndentation(raw);
|
|
let current = raw;
|
|
|
|
for (const { eventName, value } of entries) {
|
|
// Re-parse after each edit to get a fresh insertion index.
|
|
const currentTree = parseTree(current, []);
|
|
const hooksNode = currentTree?.children?.find(
|
|
(c) => c.type === 'property' && c.children?.[0]?.value === 'hooks',
|
|
);
|
|
const eventNode = hooksNode?.children?.[1]?.children?.find(
|
|
(c: any) => c.type === 'property' && c.children?.[0]?.value === eventName,
|
|
);
|
|
|
|
let insertIndex: number;
|
|
if (eventNode?.children?.[1] && Array.isArray(eventNode.children[1].children)) {
|
|
insertIndex = eventNode.children[1].children.length;
|
|
} else {
|
|
insertIndex = 0;
|
|
}
|
|
|
|
const edits = modify(current, ['hooks', eventName, insertIndex], value, {
|
|
formattingOptions,
|
|
});
|
|
current = applyEdits(current, edits);
|
|
}
|
|
|
|
await fs.writeFile(filePath, current, 'utf-8');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
|
|
* Merges hook config without overwriting existing hooks, preserving
|
|
* comments and formatting in the JSONC file.
|
|
*/
|
|
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 {
|
|
let content = await fs.readFile(src, 'utf-8');
|
|
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
|
|
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
|
|
const jsonCli = JSON.stringify(normalizedCli);
|
|
content = content.replace(
|
|
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
|
`let cliPath = ${jsonCli};`,
|
|
);
|
|
await fs.writeFile(dest, content, 'utf-8');
|
|
} catch {
|
|
// Script not found in source — skip
|
|
}
|
|
|
|
try {
|
|
await fs.copyFile(
|
|
path.join(pluginHooksPath, 'hook-lock.cjs'),
|
|
path.join(destHooksDir, 'hook-lock.cjs'),
|
|
);
|
|
} catch {
|
|
// Helper not found in source — skip
|
|
}
|
|
|
|
try {
|
|
await fs.copyFile(
|
|
path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
|
|
path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
|
|
);
|
|
} catch {
|
|
// Helper not found in source — skip
|
|
}
|
|
|
|
try {
|
|
await fs.copyFile(
|
|
path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
|
|
path.join(destHooksDir, 'win-rm-list-json.ps1'),
|
|
);
|
|
} catch {
|
|
// Helper not found in source — skip
|
|
}
|
|
|
|
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
|
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
|
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
|
// become `path\with\"quote`, where the trailing `\` before `"` could
|
|
// unescape the quote inside the surrounding double-quoted shell context.
|
|
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
const hookCmd = `node "${escapedHookPath}"`;
|
|
|
|
// Check which hook events need entries (idempotent: skip if already registered)
|
|
const parsed = await (async () => {
|
|
try {
|
|
const r = await fs.readFile(settingsPath, 'utf-8');
|
|
return parseJsonc(r);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
|
|
|
|
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
|
// Session context is delivered via CLAUDE.md / skills instead.
|
|
|
|
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
|
|
hookEntries.push({
|
|
eventName: 'PreToolUse',
|
|
value: {
|
|
matcher: 'Grep|Glob|Bash',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: hookCmd,
|
|
timeout: 10,
|
|
statusMessage: 'Enriching with GitNexus graph context...',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
|
|
hookEntries.push({
|
|
eventName: 'PostToolUse',
|
|
value: {
|
|
matcher: 'Bash',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: hookCmd,
|
|
timeout: 10,
|
|
statusMessage: 'Checking GitNexus index freshness...',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (hookEntries.length === 0) {
|
|
result.configured.push('Claude Code hooks (already configured)');
|
|
return;
|
|
}
|
|
|
|
const ok = await mergeHooksJsonc(settingsPath, hookEntries);
|
|
if (ok) {
|
|
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
|
|
} else {
|
|
result.errors.push(
|
|
'Claude Code hooks: settings.json is corrupt — skipping to preserve existing content',
|
|
);
|
|
}
|
|
} 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, 'opencode.json');
|
|
try {
|
|
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
|
|
if (ok) {
|
|
result.configured.push('OpenCode');
|
|
} else {
|
|
result.errors.push(
|
|
'OpenCode: opencode.json is corrupt — skipping to preserve existing content',
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
result.errors.push(`OpenCode: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a TOML section for Codex MCP config (~/.codex/config.toml).
|
|
*/
|
|
function getCodexMcpTomlSection(): string {
|
|
const entry = getMcpEntry();
|
|
const command = JSON.stringify(entry.command);
|
|
const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
|
|
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
|
|
}
|
|
|
|
/**
|
|
* Append GitNexus MCP server config to Codex's config.toml if missing.
|
|
*/
|
|
async function upsertCodexConfigToml(configPath: string): Promise<void> {
|
|
let existing = '';
|
|
try {
|
|
existing = await fs.readFile(configPath, 'utf-8');
|
|
} catch {
|
|
existing = '';
|
|
}
|
|
|
|
if (existing.includes('[mcp_servers.gitnexus]')) {
|
|
return;
|
|
}
|
|
|
|
const section = getCodexMcpTomlSection();
|
|
const nextContent = existing.trim().length > 0 ? `${existing.trimEnd()}\n\n${section}` : section;
|
|
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
|
|
}
|
|
|
|
async function setupCodex(result: SetupResult): Promise<void> {
|
|
const codexDir = path.join(os.homedir(), '.codex');
|
|
if (!(await dirExists(codexDir))) {
|
|
result.skipped.push('Codex (not installed)');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const entry = getMcpEntry();
|
|
await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], {
|
|
shell: process.platform === 'win32',
|
|
windowsHide: true,
|
|
});
|
|
result.configured.push('Codex');
|
|
return;
|
|
} catch {
|
|
// Fallback for environments where `codex` binary isn't on PATH.
|
|
}
|
|
|
|
try {
|
|
const configPath = path.join(codexDir, 'config.toml');
|
|
await upsertCodexConfigToml(configPath);
|
|
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
|
|
} catch (err: any) {
|
|
result.errors.push(`Codex: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ─── Skill Installation ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Install GitNexus skills to a target directory.
|
|
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
|
|
* following the Agent Skills standard (Cursor, Claude Code, and Codex).
|
|
*
|
|
* 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');
|
|
|
|
let flatFiles: string[] = [];
|
|
let dirSkillFiles: string[] = [];
|
|
try {
|
|
[flatFiles, dirSkillFiles] = await Promise.all([
|
|
glob('*.md', { cwd: skillsRoot }),
|
|
glob('*/SKILL.md', { cwd: skillsRoot }),
|
|
]);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const skillSources = new Map<string, { isDirectory: boolean }>();
|
|
|
|
for (const relPath of dirSkillFiles) {
|
|
skillSources.set(path.dirname(relPath), { isDirectory: true });
|
|
}
|
|
for (const relPath of flatFiles) {
|
|
const skillName = path.basename(relPath, '.md');
|
|
if (!skillSources.has(skillName)) {
|
|
skillSources.set(skillName, { isDirectory: false });
|
|
}
|
|
}
|
|
|
|
for (const [skillName, source] of skillSources) {
|
|
const skillDir = path.join(targetDir, skillName);
|
|
|
|
try {
|
|
if (source.isDirectory) {
|
|
const dirSource = path.join(skillsRoot, skillName);
|
|
await copyDirRecursive(dirSource, skillDir);
|
|
installed.push(skillName);
|
|
} else {
|
|
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/skills/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, 'skills');
|
|
try {
|
|
const installed = await installSkillsTo(skillsDir);
|
|
if (installed.length > 0) {
|
|
result.configured.push(
|
|
`OpenCode skills (${installed.length} skills → ~/.config/opencode/skills/)`,
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
result.errors.push(`OpenCode skills: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install global Codex skills to ~/.agents/skills/gitnexus/
|
|
*/
|
|
async function installCodexSkills(result: SetupResult): Promise<void> {
|
|
const codexDir = path.join(os.homedir(), '.codex');
|
|
if (!(await dirExists(codexDir))) return;
|
|
|
|
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
|
|
try {
|
|
const installed = await installSkillsTo(skillsDir);
|
|
if (installed.length > 0) {
|
|
result.configured.push(`Codex skills (${installed.length} skills → ~/.agents/skills/)`);
|
|
}
|
|
} catch (err: any) {
|
|
result.errors.push(`Codex 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);
|
|
await setupCodex(result);
|
|
|
|
// Install global skills for platforms that support them
|
|
await installClaudeCodeSkills(result);
|
|
await installClaudeCodeHooks(result);
|
|
await installCursorSkills(result);
|
|
await installOpenCodeSkills(result);
|
|
await installCodexSkills(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('');
|
|
};
|