mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
* fix(lbug): route diagnostic logs to stderr to avoid MCP stdio corruption
Replace console.log/console.warn with console.error in core/lbug so
diagnostic messages reach stderr and never corrupt the JSON-RPC stream
on MCP stdio. Per spec, the server MUST NOT write anything to stdout
that is not a valid MCP message.
- lbug-adapter.ts:367 - schema creation warning (MCP-reachable via lazy
DB init from tool handlers)
- lbug-adapter.ts:1047,1054 - legacy embedding fallback diagnostics
(currently HTTP-only, but covered by upcoming no-console lint rule)
- extension-loader.ts:191 - default warn handler fallback used during
DuckDB extension loading
* feat(mcp): add stdout sentinel via AsyncLocalStorage transport-write tagging
Untagged process.stdout.write calls now redirect to stderr with a
[mcp:stdout-redirect] prefix instead of corrupting the JSON-RPC frame
stream. Identification is correctness-by-construction: the transport
wraps every send() in withMcpWrite() (AsyncLocalStorage) and the
sentinel checks isMcpWrite() per call. A byte-shape heuristic would
have falsely rejected Content-Length frames (start with C, end with })
and misclassified multi-chunk writes.
- gitnexus/src/mcp/stdio-context.ts: AsyncLocalStorage helpers + factory
- gitnexus/src/mcp/server.ts: install sentinel in safeStdout Proxy,
flush summary at process exit
- gitnexus/src/mcp/compatible-stdio-transport.ts: wrap send() write in
withMcpWrite so transport frames pass through cleanly
- gitnexus/test/unit/mcp-stdout-sentinel.test.ts: 17 cases covering
pass-through, redirect, prefix, truncation (default 200 / custom),
rate limit (default 10), one-shot warning, summary, mixed sequences
* feat(eslint): forbid console.log/warn and process.stdout.write in MCP-reachable code
Add a narrow ESLint override for gitnexus/src/mcp/**, gitnexus/src/core/lbug/**,
gitnexus/src/core/embeddings/**, and gitnexus/src/cli/mcp.ts that:
- sets no-console: ['error', { allow: ['error'] }] — only console.error
survives, since stderr is the only spec-safe channel for diagnostics
while the MCP stdio transport owns stdout for JSON-RPC frames
- adds no-restricted-syntax matching MemberExpression and CallExpression
forms of process.stdout.write to close the bypass path that the
AsyncLocalStorage sentinel cannot guarantee
Migrates 18 pre-existing console.log/warn call sites in core/embeddings/
(embedder.ts, embedding-pipeline.ts) to console.error; these are reached
from gitnexus_query semantic search and would have polluted MCP stdio
once a query triggered the embedding pipeline.
Adds eslint-disable-next-line comments in pool-adapter.ts at the four
legitimate process.stdout.write sites — they ARE the captured-real-write
infrastructure used by the sentinel and the silenceStdout/restoreStdout
mechanism.
The override is forward-compatible with feat/pino-logger (PR #1336)
which adds a broader no-console rule for gitnexus/src/; the narrow rule
here is a strict subset and rebases trivially when #1336 lands.
* feat(setup): pin setup-generated MCP config to installed version, keep static configs on @latest
The user-facing MCP config that 'gitnexus setup' writes into editor configs
now references gitnexus@<installed-version> instead of gitnexus@latest, read
dynamically from gitnexus/package.json#version at module load. This skips
the npm-registry metadata roundtrip on every MCP connect and stays
reproducible until the user explicitly upgrades.
Static example configs and quickstart docs intentionally keep @latest:
- .mcp.json, gitnexus-claude-plugin/.mcp.json
- gitnexus-claude-plugin/skills/*/mcp.json (6 files)
- README.md / gitnexus/README.md MCP examples
Pinning these would create per-release version-bump churn for marginal
(~100-500ms) savings. The dominant cold-cache cost is the native rebuild
addressed separately by the GITNEXUS_SKIP_OPTIONAL_GRAMMARS env var.
README adds a one-line steer above the @latest quickstart pointing
repeated users at 'gitnexus setup' for the absolute-path config that
bypasses npx entirely.
Tests refactored to assert against the dynamic version (createRequire of
package.json) so they don't break on every release bump:
- gitnexus/test/unit/setup.test.ts
- gitnexus/test/unit/setup-jsonc.test.ts
- gitnexus/test/unit/setup-codex.test.ts
- gitnexus/test/integration/setup-skills.test.ts (regex match)
* feat(install,mcp): GITNEXUS_SKIP_OPTIONAL_GRAMMARS opt-out + missing-grammar warnings
Postinstall scripts (build-tree-sitter-dart.cjs, build-tree-sitter-proto.cjs)
gain a strict 'process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === "1"'
early-exit so users without a C++ toolchain (or anyone wanting fast
'npm install gitnexus') can skip the native rebuild. Strict '=1' only —
'true', 'yes', '0' and any other value fall through to the rebuild.
Add gitnexus/src/cli/optional-grammars.ts: cheap require.resolve probe for
each optional grammar, with a stderr warning helper. The warning surfaces:
- At MCP server start (cli/mcp.ts) — unconditional, since the server
serves any indexed repo and we cannot pre-filter by language.
- At 'gitnexus analyze' start (cli/analyze.ts) — conditional on the
target repo containing .dart/.proto files (cheap glob), so users with
no relevant code don't see noise.
README documents the env var with the strict '=1' value and the trade-off
(faster install, no Dart/Proto parsing until reinstalled).
* test(mcp): child-process integration test asserts end-to-end stdout discipline
Spawns 'node dist/cli/index.js mcp' as a child, drives the MCP stdio
handshake (initialize -> initialized -> tools/list), reassembles every
stdout chunk into Content-Length-framed JSON-RPC messages, and asserts
zero stray bytes. Any byte outside a valid header-then-body window is
captured and surfaced in the failure message alongside the server's
stderr — this is the regression gate for U1 (no console.log/warn in
MCP-reachable code) and U3 (AsyncLocalStorage stdout sentinel).
Time budget: 5s local / 15s CI for first frame; 10s/30s total. Asserts
the published GitNexus tool surface (list_repos, query, context, impact,
detect_changes, rename) is reported by tools/list.
Adds 'pretest:integration': 'node scripts/build.js' so 'npm run
test:integration' rebuilds dist before the spawn — closes the
'stale dist masks regression' DX gap.
* fix(mcp): address PR #1383 review — sentinel scope, grammar detection, lint, contract
Blockers:
- B2: detectMissingOptionalGrammars now actually require()s each grammar
instead of require.resolve(). For 'file:' optional dependencies the
package directory is always installed regardless of postinstall outcome,
so resolve() never threw and the missing-grammar warning never fired
for the exact target users (those who set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1
or whose native rebuild soft-failed). require() loads the entry, which
triggers node-gyp-build and throws if .node is absent. Result memoized.
Should-fix:
- S1: Removed duplicate uncaughtException/unhandledRejection handlers from
cli/mcp.ts. server.ts:startMCPServer already registers handlers with
full stack traces; cli/mcp.ts handlers fired first with worse output and
never got a chance to exit because server.ts shuts down immediately.
- S2: Sentinel is now actually global. New setActiveStdoutWrite() in
pool-adapter so silenceStdout/restoreStdout cycles preserve a
registered wrapper instead of unwinding to raw realStdoutWrite. At
startMCPServer: install sentinel.write as process.stdout.write AND
register it as the active handler. Direct process.stdout.write calls
from anywhere (console.log, dependency banners, etc.) now route through
the sentinel instead of bypassing it. The transport's _safeStdout Proxy
remains as belt-and-suspenders.
- S3: ESLint no-restricted-syntax now also forbids destructuring of
process.stdout (covers both 'const { write } = process.stdout' shapes
and rest patterns).
Minor:
- M1: chunkToBuffer now handles plain Uint8Array (Buffer.from(u8)) instead
of falling through to String(chunk) which produced '1,2,3,...' garbage.
- M2: Untagged-write callbacks are now invoked on next tick per the
Node Writable.write contract — both within and beyond the rate-limit cap.
extractCallback handles the (chunk, cb) and (chunk, encoding, cb) overloads.
- M3: setup.ts throws early if package.json#version is missing/non-string
instead of emitting 'gitnexus@undefined'.
- M4: parser-loader.ts console.warn → console.error; ESLint scope extended
to gitnexus/src/core/tree-sitter/** so future violations are caught.
New tests cover:
- Plain Uint8Array redirect (asserts no String(chunk) garbage).
- Writable callback fired async (next-tick) for both normal and
past-rate-limit redirects.
Validation: cd gitnexus && npx tsc --noEmit clean; vitest run 7863 passed,
11 skipped; eslint clean on MCP-reachable scope; integration test green
against rebuilt dist/.
* fix(mcp): close pre-sentinel stdout window + tighten contracts
Address ce-code-review findings on PR #1383:
P1 — Sentinel install order (was: stdout corruption window during
mcpCommand pre-startup):
- Add idempotent installGlobalStdoutSentinel() to mcp/stdio-context.ts.
It captures realStdoutWrite/realStderrWrite, replaces process.stdout.write,
and registers with pool-adapter's setActiveStdoutWrite — exactly once.
- cli/mcp.ts now installs the sentinel as the FIRST line of mcpCommand,
before warnMissingOptionalGrammars (which after the B2 fix actually
require()s each native grammar binding and could emit node-gyp-build
banners to raw stdout in the pre-sentinel window).
- mcp/server.ts startMCPServer keeps a safety-net call to the same helper;
the second invocation is a no-op.
P1 — WriteFn type erasure:
- WriteFn now declared as instead of
, so the assignment
and the
setActiveStdoutWrite(sentinel.write) call don't silently cross a
type boundary.
P1 — extractCallback fragility:
- Replaced backward-scan-with-undefined-break heuristic with a strict
'last arg if function' check matching the documented Writable.write
contract. No longer breaks on a future (chunk, options, cb) overload.
P2 — _detectionCache premature memoization:
- Removed the explicit cache. Node's module cache already memoizes
require() — calling detectMissingOptionalGrammars multiple times is
cheap. Removing the module-level mutable state makes the helper
trivially testable (no need for a reset hatch).
P2 — Misleading 'reinstall' message on broken (not missing) grammars:
- detectMissingOptionalGrammars now distinguishes MODULE_NOT_FOUND /
node-gyp-build 'no native build' patterns from other errors
(SyntaxError, EACCES, native crash). Broken bindings get an
actionable stderr line naming the real failure instead of the
misleading 'reinstall to enable' hint.
Other:
- mcp/core/lbug-adapter.ts updated with a KEEP-THIS-FILE note. Tests
use the path as a vi.mock seam (calltool-dispatch.test.ts and 7
others); new non-test code may import core/lbug/pool-adapter.js
directly. The maintainability finding flagging the shim as
self-contradictory was incorrect — the shim has a real test purpose.
Validation: tsc clean, vitest 7863 passed (no regressions), eslint
clean on MCP-reachable scope, integration test green against rebuilt
dist/.
* fix(mcp): close import-time stdout corruption window
Codex's adversarial review on PR #1383 found that even though cli/mcp.ts
is loaded lazily by Commander, ITS static imports (startMCPServer,
LocalBackend, installGlobalStdoutSentinel, warnMissingOptionalGrammars)
evaluate synchronously when the module loads — well before mcpCommand's
function body runs. Three of those four imports transitively pulled in
core/lbug/pool-adapter.ts, which imports @ladybugdb/core at module top
level. The native binding's init can write to raw stdout in that
pre-sentinel window and corrupt the JSON-RPC frame stream.
Fix: shrink cli/mcp.ts's static-import closure to a single zero-dep
chain (mcp/stdio-context.js -> mcp/stdio-capture.js, both leaf-clean),
install the sentinel as the first executable statement of mcpCommand,
then dynamically import the heavy backend modules in parallel via
await Promise.all.
Per the plan at docs/plans/2026-05-06-002-fix-import-time-stdout-window-plan.md:
- U1: New leaf module gitnexus/src/mcp/stdio-capture.ts owns the
stdout-capture singleton state (realStdoutWrite, realStderrWrite,
activeStdoutWrite + setActiveStdoutWrite/getActiveStdoutWrite).
Zero non-node: imports — adding any would re-introduce the hazard.
- U2: pool-adapter.ts re-exports the relocated symbols under the
existing names so the test mock seam (8+ files use vi.mock on
mcp/core/lbug-adapter.ts which re-exports * from pool-adapter)
keeps working without churn. restoreStdout and the watchdog now
read the active handler via getActiveStdoutWrite(). stdio-context.ts
imports from stdio-capture directly.
- U3: cli/mcp.ts's static imports collapse to one
(installGlobalStdoutSentinel). startMCPServer / LocalBackend /
warnMissingOptionalGrammars become parallel await import()
inside mcpCommand, after the sentinel install.
- U4: New regression test gitnexus/test/integration/mcp/import-closure.test.ts
spawns a child Node process that imports dist/cli/mcp.js (without
invoking mcpCommand), inspects the CJS module cache via createRequire,
and asserts @ladybugdb/core (and tree-sitter native bindings) are
NOT in the static-import closure. Characterization-first: this test
was authored to fail against the pre-fix code and confirmed to do so
before U1-U3 landed.
Validation: tsc clean; vitest 7865 passed / 11 skipped (2 new U4 cases);
eslint clean on MCP-reachable scope; integration server-startup test
green against rebuilt dist/.
* fix(mcp): drop dead ESLint selector + suppress redundant grammar warning
Two minor PR #1383 review findings:
1. eslint.config.mjs: removed Selector 3 (`Property[key.name='write'].properties:has(...)`).
`.properties` is not a valid attribute on a Property node in the ESTree
AST, so the :has clause never matched — dead code. Selector 4 covers
the canonical `const { write } = process.stdout` shape; tightened its
comment to make that explicit.
2. cli/mcp.ts: removed the unconditional warnMissingOptionalGrammars call
at MCP startup. The analyze path already emits this warning at index
time with relevantExtensions filtered to the repo's actual file types,
and a repo can only be served by MCP after analyze has run. Repeating
the warning unconditionally on every MCP session was pure noise on
machines whose indexed repos don't use .dart/.proto.
* chore(mcp): address PR #1383 review nits
Three minor hygiene findings from the production-readiness review:
- cli/mcp.ts: rewrite stale comment that described
warnMissingOptionalGrammars as living inside mcpCommand. The call was
removed in ca617552 — this path no longer invokes it at all.
- test/integration/mcp/import-closure.test.ts: same comment drift fixed.
Test assertion is unchanged and still passes for the right reason
(cli/mcp.js's static-import closure is leaf-only).
- mcp/server.ts: rename _safeStdout to safeStdout. The leading underscore
conventionally signals "intentionally unused" but the Proxy is passed
to CompatibleStdioServerTransport on the next line.
No behavior change. Typecheck clean; ESLint MCP-reachable scope still 0
errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
720 lines
22 KiB
TypeScript
720 lines
22 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'],
|
|
});
|
|
const lines = output
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
|
|
if (isWin) {
|
|
// On Windows, `where` returns multiple entries (e.g. the POSIX shell
|
|
// script AND the .cmd/.bat wrapper). Prefer the wrapper because
|
|
// child_process.spawn() cannot execute a shell script directly.
|
|
const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
|
|
return cmdLine || lines[0] || 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
|
|
}
|
|
|
|
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
|
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
|
|
|
|
// 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',
|
|
});
|
|
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/skill/)`,
|
|
);
|
|
}
|
|
} 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('');
|
|
};
|