Squashed commits: - test: fix risk_level mock case and prettier formatting in tool-direct-cli.test - test: add edge-case coverage for detectChangesCommand formatter
219 lines
6.3 KiB
TypeScript
219 lines
6.3 KiB
TypeScript
/**
|
|
* Direct CLI Tool Commands
|
|
*
|
|
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
|
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
|
*
|
|
* Usage:
|
|
* gitnexus query "authentication flow"
|
|
* gitnexus context --name "validateUser"
|
|
* gitnexus impact --target "AuthService" --direction upstream
|
|
* gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
|
|
*
|
|
* Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
|
|
* native module which captures the Node.js process.stdout stream during init.
|
|
* See the output() function for details (#324).
|
|
*/
|
|
|
|
import { writeSync } from 'node:fs';
|
|
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
|
|
let _backend: LocalBackend | null = null;
|
|
|
|
async function getBackend(): Promise<LocalBackend> {
|
|
if (_backend) return _backend;
|
|
_backend = new LocalBackend();
|
|
const ok = await _backend.init();
|
|
if (!ok) {
|
|
console.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
|
|
process.exit(1);
|
|
}
|
|
return _backend;
|
|
}
|
|
|
|
/**
|
|
* Write tool output to stdout using low-level fd write.
|
|
*
|
|
* LadybugDB's native module captures Node.js process.stdout during init,
|
|
* but the underlying OS file descriptor 1 (stdout) remains intact.
|
|
* By using fs.writeSync(1, ...) we bypass the Node.js stream layer
|
|
* and write directly to the real stdout fd (#324).
|
|
*
|
|
* Falls back to stderr if the fd write fails (e.g., broken pipe).
|
|
*/
|
|
function output(data: any): void {
|
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
try {
|
|
writeSync(1, text + '\n');
|
|
} catch (err: any) {
|
|
if (err?.code === 'EPIPE') {
|
|
// Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
|
|
// Exit cleanly per Unix convention
|
|
process.exit(0);
|
|
}
|
|
// Fallback: stderr (previous behavior, works on all platforms)
|
|
process.stderr.write(text + '\n');
|
|
}
|
|
}
|
|
|
|
export async function queryCommand(
|
|
queryText: string,
|
|
options?: {
|
|
repo?: string;
|
|
context?: string;
|
|
goal?: string;
|
|
limit?: string;
|
|
content?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!queryText?.trim()) {
|
|
console.error('Usage: gitnexus query <search_query>');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('query', {
|
|
query: queryText,
|
|
task_context: options?.context,
|
|
goal: options?.goal,
|
|
limit: options?.limit ? parseInt(options.limit) : undefined,
|
|
include_content: options?.content ?? false,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
export async function contextCommand(
|
|
name: string,
|
|
options?: {
|
|
repo?: string;
|
|
file?: string;
|
|
uid?: string;
|
|
content?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!name?.trim() && !options?.uid) {
|
|
console.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('context', {
|
|
name: name || undefined,
|
|
uid: options?.uid,
|
|
file_path: options?.file,
|
|
include_content: options?.content ?? false,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
export async function impactCommand(
|
|
target: string,
|
|
options?: {
|
|
direction?: string;
|
|
repo?: string;
|
|
depth?: string;
|
|
includeTests?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!target?.trim()) {
|
|
console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('impact', {
|
|
target,
|
|
direction: options?.direction || 'upstream',
|
|
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
|
includeTests: options?.includeTests ?? false,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
} catch (err: unknown) {
|
|
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
|
|
// The backend's impact() already returns structured errors for graph query failures
|
|
output({
|
|
error:
|
|
(err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
|
|
target: { name: target },
|
|
direction: options?.direction || 'upstream',
|
|
suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
export async function cypherCommand(
|
|
query: string,
|
|
options?: {
|
|
repo?: string;
|
|
},
|
|
): Promise<void> {
|
|
if (!query?.trim()) {
|
|
console.error('Usage: gitnexus cypher <cypher_query>');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('cypher', {
|
|
query,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
function formatDetectChangesResult(result: any): string {
|
|
if (result?.error) return `Error: ${result.error}`;
|
|
|
|
const summary = result?.summary || {};
|
|
if ((summary.changed_count || 0) === 0) {
|
|
return 'No changes detected.';
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`Changes: ${summary.changed_files || 0} files, ${summary.changed_count || 0} symbols`);
|
|
lines.push(`Affected processes: ${summary.affected_count || 0}`);
|
|
lines.push(`Risk level: ${summary.risk_level || 'unknown'}`);
|
|
lines.push('');
|
|
|
|
const changed = result?.changed_symbols || [];
|
|
if (changed.length > 0) {
|
|
lines.push('Changed symbols:');
|
|
for (const symbol of changed.slice(0, 15)) {
|
|
lines.push(` ${symbol.type} ${symbol.name} → ${symbol.filePath}`);
|
|
}
|
|
if (changed.length > 15) {
|
|
lines.push(` ... and ${changed.length - 15} more`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
const affected = result?.affected_processes || [];
|
|
if (affected.length > 0) {
|
|
lines.push('Affected execution flows:');
|
|
for (const processInfo of affected.slice(0, 10)) {
|
|
const steps = (processInfo.changed_steps || []).map((s: any) => s.symbol).join(', ');
|
|
lines.push(` • ${processInfo.name} (${processInfo.step_count} steps) — changed: ${steps}`);
|
|
}
|
|
}
|
|
|
|
return lines.join('\n').trim();
|
|
}
|
|
|
|
export async function detectChangesCommand(options?: {
|
|
scope?: string;
|
|
baseRef?: string;
|
|
repo?: string;
|
|
}): Promise<void> {
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('detect_changes', {
|
|
scope: options?.scope || 'unstaged',
|
|
base_ref: options?.baseRef,
|
|
repo: options?.repo,
|
|
});
|
|
output(formatDetectChangesResult(result));
|
|
}
|