mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
Merge pull request #381 from cnighut/feat/cursor-cli-wiki-provider
This commit is contained in:
commit
655f0f2479
12 changed files with 1103 additions and 91 deletions
|
|
@ -66,11 +66,14 @@ program
|
|||
.command('wiki [path]')
|
||||
.description('Generate repository wiki from knowledge graph')
|
||||
.option('-f, --force', 'Force full regeneration even if up to date')
|
||||
.option('--model <model>', 'LLM model name (default: minimax/minimax-m2.5)')
|
||||
.option('--base-url <url>', 'LLM API base URL (default: OpenAI)')
|
||||
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
|
||||
.option('--model <model>', 'LLM model name (default depends on provider)')
|
||||
.option('--base-url <url>', 'LLM API base URL (for openai provider)')
|
||||
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
||||
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
|
||||
.option('--review', 'Stop after grouping to review module structure before generating pages')
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import cliProgress from 'cli-progress';
|
|||
import { getGitRoot, isGitRepo } from '../storage/git.js';
|
||||
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
|
||||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import { resolveLLMConfig } from '../core/wiki/llm-client.js';
|
||||
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
||||
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
||||
|
||||
export interface WikiCommandOptions {
|
||||
force?: boolean;
|
||||
|
|
@ -21,6 +22,9 @@ export interface WikiCommandOptions {
|
|||
apiKey?: string;
|
||||
concurrency?: string;
|
||||
gist?: boolean;
|
||||
provider?: LLMProvider;
|
||||
verbose?: boolean;
|
||||
review?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,6 +82,11 @@ export const wikiCommand = async (
|
|||
inputPath?: string,
|
||||
options?: WikiCommandOptions,
|
||||
) => {
|
||||
// Set verbose mode globally for cursor-client to pick up
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Wiki Generator\n');
|
||||
|
||||
// ── Resolve repo path ───────────────────────────────────────────────
|
||||
|
|
@ -113,98 +122,135 @@ export const wikiCommand = async (
|
|||
|
||||
// ── Resolve LLM config (with interactive fallback) ─────────────────
|
||||
// Save any CLI overrides immediately
|
||||
if (options?.apiKey || options?.model || options?.baseUrl) {
|
||||
if (options?.apiKey || options?.model || options?.baseUrl || options?.provider) {
|
||||
const existing = await loadCLIConfig();
|
||||
const updates: Record<string, string> = {};
|
||||
if (options.apiKey) updates.apiKey = options.apiKey;
|
||||
if (options.model) updates.model = options.model;
|
||||
if (options.baseUrl) updates.baseUrl = options.baseUrl;
|
||||
if (options.provider) updates.provider = options.provider;
|
||||
// Save model to appropriate field based on provider
|
||||
if (options.model) {
|
||||
if (options.provider === 'cursor') {
|
||||
updates.cursorModel = options.model;
|
||||
} else {
|
||||
updates.model = options.model;
|
||||
}
|
||||
}
|
||||
await saveCLIConfig({ ...existing, ...updates });
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
}
|
||||
|
||||
const savedConfig = await loadCLIConfig();
|
||||
const hasSavedConfig = !!(savedConfig.apiKey && savedConfig.baseUrl);
|
||||
const hasCLIOverrides = !!(options?.apiKey || options?.model || options?.baseUrl);
|
||||
const hasSavedConfig = !!(savedConfig.provider === 'cursor' || (savedConfig.apiKey && savedConfig.baseUrl));
|
||||
const hasCLIOverrides = !!(options?.apiKey || options?.model || options?.baseUrl || options?.provider);
|
||||
|
||||
let llmConfig = await resolveLLMConfig({
|
||||
model: options?.model,
|
||||
baseUrl: options?.baseUrl,
|
||||
apiKey: options?.apiKey,
|
||||
provider: options?.provider,
|
||||
});
|
||||
|
||||
// Run interactive setup if no saved config and no CLI flags provided
|
||||
// (even if env vars exist — let user explicitly choose their provider)
|
||||
if (!hasSavedConfig && !hasCLIOverrides) {
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!llmConfig.apiKey) {
|
||||
// Non-interactive mode — need either API key or Cursor CLI
|
||||
if (!llmConfig.apiKey && llmConfig.provider !== 'cursor') {
|
||||
console.log(' Error: No LLM API key found.');
|
||||
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
|
||||
console.log(' or pass --api-key <key>.\n');
|
||||
console.log(' or pass --api-key <key>, or use --provider cursor.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Non-interactive with env var — just use it
|
||||
// Non-interactive with env var or cursor — just use it
|
||||
} else {
|
||||
console.log(' No LLM configured. Let\'s set it up.\n');
|
||||
console.log(' Supports OpenAI, OpenRouter, or any OpenAI-compatible API.\n');
|
||||
console.log(' Supports OpenAI, OpenRouter, any OpenAI-compatible API, or Cursor CLI.\n');
|
||||
|
||||
// Check if Cursor CLI is available
|
||||
const hasCursor = detectCursorCLI();
|
||||
|
||||
// Provider selection
|
||||
console.log(' [1] OpenAI (api.openai.com)');
|
||||
console.log(' [2] OpenRouter (openrouter.ai)');
|
||||
console.log(' [3] Custom endpoint\n');
|
||||
console.log(' [3] Custom endpoint');
|
||||
if (hasCursor) {
|
||||
console.log(' [4] Cursor CLI (local, uses your Cursor subscription)');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const choice = await prompt(' Select provider (1/2/3): ');
|
||||
const maxChoice = hasCursor ? '4' : '3';
|
||||
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
||||
|
||||
let baseUrl: string;
|
||||
let defaultModel: string;
|
||||
let provider: LLMProvider = 'openai';
|
||||
let key = '';
|
||||
|
||||
if (choice === '2') {
|
||||
baseUrl = 'https://openrouter.ai/api/v1';
|
||||
defaultModel = 'minimax/minimax-m2.5';
|
||||
} else if (choice === '3') {
|
||||
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
||||
if (!baseUrl) {
|
||||
console.log('\n No URL provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
defaultModel = 'gpt-4o-mini';
|
||||
if (choice === '4' && hasCursor) {
|
||||
// Cursor CLI selected - model defaults to 'auto' (Cursor's default)
|
||||
provider = 'cursor';
|
||||
baseUrl = '';
|
||||
|
||||
const modelInput = await prompt(' Model (leave empty for auto): ');
|
||||
const model = modelInput || '';
|
||||
|
||||
// Save config for Cursor
|
||||
const cursorConfig: Record<string, string> = { provider: 'cursor' };
|
||||
if (model) cursorConfig.cursorModel = model;
|
||||
await saveCLIConfig(cursorConfig);
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
|
||||
} else {
|
||||
baseUrl = 'https://api.openai.com/v1';
|
||||
defaultModel = 'gpt-4o-mini';
|
||||
}
|
||||
// OpenAI-compatible provider
|
||||
if (choice === '2') {
|
||||
baseUrl = 'https://openrouter.ai/api/v1';
|
||||
defaultModel = 'minimax/minimax-m2.5';
|
||||
} else if (choice === '3') {
|
||||
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
||||
if (!baseUrl) {
|
||||
console.log('\n No URL provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
defaultModel = 'gpt-4o-mini';
|
||||
} else {
|
||||
baseUrl = 'https://api.openai.com/v1';
|
||||
defaultModel = 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
// Model
|
||||
const modelInput = await prompt(` Model (default: ${defaultModel}): `);
|
||||
const model = modelInput || defaultModel;
|
||||
// Model
|
||||
const modelInput = await prompt(` Model (default: ${defaultModel}): `);
|
||||
const model = modelInput || defaultModel;
|
||||
|
||||
// API key — pre-fill hint if env var exists
|
||||
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
let key: string;
|
||||
if (envKey) {
|
||||
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
||||
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
||||
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
||||
key = envKey;
|
||||
// API key — pre-fill hint if env var exists
|
||||
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
if (envKey) {
|
||||
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
||||
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
||||
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
||||
key = envKey;
|
||||
} else {
|
||||
key = await prompt(' API key: ', true);
|
||||
}
|
||||
} else {
|
||||
key = await prompt(' API key: ', true);
|
||||
}
|
||||
} else {
|
||||
key = await prompt(' API key: ', true);
|
||||
|
||||
if (!key) {
|
||||
console.log('\n No key provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save
|
||||
await saveCLIConfig({ apiKey: key, baseUrl, model, provider: 'openai' });
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider: 'openai' };
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
console.log('\n No key provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save
|
||||
await saveCLIConfig({ apiKey: key, baseUrl, model });
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,9 +285,8 @@ export const wikiCommand = async (
|
|||
// ── Run generator ───────────────────────────────────────────────────
|
||||
const wikiOptions: WikiOptions = {
|
||||
force: options?.force,
|
||||
model: options?.model,
|
||||
baseUrl: options?.baseUrl,
|
||||
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
||||
reviewOnly: options?.review,
|
||||
};
|
||||
|
||||
const generator = new WikiGenerator(
|
||||
|
|
@ -264,13 +309,116 @@ export const wikiCommand = async (
|
|||
const result = await generator.run();
|
||||
|
||||
clearInterval(elapsedTimer);
|
||||
bar.update(100, { phase: 'Done' });
|
||||
bar.stop();
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
const viewerPath = path.join(wikiDir, 'index.html');
|
||||
const treeFile = path.join(wikiDir, 'module_tree.json');
|
||||
|
||||
// Review mode: show module tree and ask for confirmation
|
||||
if (options?.review && result.moduleTree) {
|
||||
console.log(`\n Module structure ready for review (${elapsed}s)\n`);
|
||||
console.log(' Modules to generate:\n');
|
||||
|
||||
const printTree = (nodes: typeof result.moduleTree, indent = 0) => {
|
||||
for (const node of nodes) {
|
||||
const prefix = ' '.repeat(indent + 2);
|
||||
const fileCount = node.files?.length || 0;
|
||||
const childCount = node.children?.length || 0;
|
||||
const suffix = fileCount > 0 ? ` (${fileCount} files)` : childCount > 0 ? ` (${childCount} children)` : '';
|
||||
console.log(`${prefix}- ${node.name}${suffix}`);
|
||||
if (node.children && node.children.length > 0) {
|
||||
printTree(node.children, indent + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
printTree(result.moduleTree);
|
||||
|
||||
console.log(`\n Tree saved to: ${treeFile}`);
|
||||
console.log(' You can edit this file to remove/rename modules.\n');
|
||||
|
||||
// Ask for confirmation (auto-continue in non-interactive environments)
|
||||
if (!process.stdin.isTTY) {
|
||||
console.log(' Non-interactive mode — auto-continuing with generation.\n');
|
||||
}
|
||||
const answer = process.stdin.isTTY
|
||||
? await prompt(' Continue with generation? (Y/n/edit): ')
|
||||
: 'y';
|
||||
const choice = answer.trim().toLowerCase();
|
||||
|
||||
if (choice === 'n' || choice === 'no') {
|
||||
console.log('\n Generation cancelled. Run `gitnexus wiki` later to generate.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'edit' || choice === 'e') {
|
||||
// Open editor for the user
|
||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
||||
console.log(`\n Opening ${treeFile} in ${editor}...`);
|
||||
console.log(' Save and close the editor when done.\n');
|
||||
|
||||
try {
|
||||
execSync(`${editor} "${treeFile}"`, { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.log(` Could not open editor. Please edit manually:\n ${treeFile}\n`);
|
||||
console.log(' Then run `gitnexus wiki` to continue.\n');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with generation using the (possibly edited) tree
|
||||
console.log('\n Continuing with wiki generation...\n');
|
||||
bar.start(100, 30, { phase: 'Generating pages...' });
|
||||
|
||||
// Re-run generator without reviewOnly flag
|
||||
const continueOptions: WikiOptions = {
|
||||
...wikiOptions,
|
||||
reviewOnly: false,
|
||||
};
|
||||
|
||||
const continueGenerator = new WikiGenerator(
|
||||
repoPath,
|
||||
storagePath,
|
||||
lbugPath,
|
||||
llmConfig,
|
||||
continueOptions,
|
||||
(phase, percent, detail) => {
|
||||
const label = detail || phase;
|
||||
if (label !== lastPhase) {
|
||||
lastPhase = label;
|
||||
phaseStart = Date.now();
|
||||
}
|
||||
bar.update(percent, { phase: label });
|
||||
},
|
||||
);
|
||||
|
||||
const continueResult = await continueGenerator.run();
|
||||
|
||||
bar.update(100, { phase: 'Done' });
|
||||
bar.stop();
|
||||
|
||||
const totalElapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
console.log(`\n Wiki generated successfully (${totalElapsed}s)\n`);
|
||||
console.log(` Mode: ${continueResult.mode}`);
|
||||
console.log(` Pages: ${continueResult.pagesGenerated}`);
|
||||
console.log(` Output: ${wikiDir}`);
|
||||
console.log(` Viewer: ${viewerPath}`);
|
||||
|
||||
if (continueResult.failedModules && continueResult.failedModules.length > 0) {
|
||||
console.log(`\n Failed modules (${continueResult.failedModules.length}):`);
|
||||
for (const mod of continueResult.failedModules) {
|
||||
console.log(` - ${mod}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
await maybePublishGist(viewerPath, options?.gist);
|
||||
return;
|
||||
}
|
||||
|
||||
bar.update(100, { phase: 'Done' });
|
||||
|
||||
if (result.mode === 'up-to-date' && !options?.force) {
|
||||
console.log('\n Wiki is already up to date.');
|
||||
|
|
@ -322,7 +470,7 @@ export const wikiCommand = async (
|
|||
}
|
||||
} else {
|
||||
console.log(`\n Error: ${err.message}\n`);
|
||||
if (process.env.DEBUG) {
|
||||
if (process.env.GITNEXUS_VERBOSE) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
gitnexus/src/core/wiki/cursor-client.ts
Normal file
160
gitnexus/src/core/wiki/cursor-client.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Cursor CLI Client for Wiki Generation
|
||||
*
|
||||
* Wrapper for the Cursor headless CLI (`agent` command).
|
||||
* Uses print mode for non-interactive LLM calls.
|
||||
*
|
||||
* Docs: https://cursor.com/docs/cli/headless
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import type { LLMResponse, CallLLMOptions } from './llm-client.js';
|
||||
|
||||
export interface CursorConfig {
|
||||
model?: string;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
function isVerbose(): boolean {
|
||||
return process.env.GITNEXUS_VERBOSE === '1';
|
||||
}
|
||||
|
||||
function verboseLog(...args: unknown[]): void {
|
||||
if (isVerbose()) {
|
||||
console.log('[cursor-cli]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
let cachedCursorBin: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Detect if Cursor CLI is available in PATH.
|
||||
* Returns the binary name if found ('agent'), null otherwise.
|
||||
* Result is cached after the first call.
|
||||
*/
|
||||
export function detectCursorCLI(): string | null {
|
||||
if (cachedCursorBin !== undefined) return cachedCursorBin;
|
||||
try {
|
||||
execSync('agent --version', { stdio: 'ignore' });
|
||||
cachedCursorBin = 'agent';
|
||||
} catch {
|
||||
cachedCursorBin = null;
|
||||
}
|
||||
return cachedCursorBin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Cursor CLI configuration.
|
||||
* Model is optional - if not provided, Cursor CLI uses its default (auto).
|
||||
*/
|
||||
export function resolveCursorConfig(overrides?: Partial<CursorConfig>): CursorConfig {
|
||||
return {
|
||||
model: overrides?.model,
|
||||
workingDirectory: overrides?.workingDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Cursor CLI in print mode.
|
||||
*
|
||||
* Uses `agent -p --output-format text` for clean non-streaming output.
|
||||
* The prompt is passed as the final CLI argument.
|
||||
*/
|
||||
export async function callCursorLLM(
|
||||
prompt: string,
|
||||
config: CursorConfig,
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const cursorBin = detectCursorCLI();
|
||||
if (!cursorBin) {
|
||||
throw new Error(
|
||||
'Cursor CLI not found. Install it from https://cursor.com/docs/cli/installation'
|
||||
);
|
||||
}
|
||||
|
||||
// Always use text format to get clean output without agent narration/thinking.
|
||||
// stream-json captures assistant messages which include "Let me explore..." narration
|
||||
// that pollutes the actual content when using thinking models.
|
||||
const fullPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n---\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format', 'text',
|
||||
];
|
||||
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
|
||||
// Add the prompt as the final argument
|
||||
args.push(fullPrompt);
|
||||
|
||||
verboseLog('Spawning:', cursorBin, args.slice(0, -1).join(' '), '[prompt length:', fullPrompt.length, 'chars]');
|
||||
verboseLog('Working directory:', config.workingDirectory || process.cwd());
|
||||
if (config.model) {
|
||||
verboseLog('Model:', config.model);
|
||||
} else {
|
||||
verboseLog('Model: auto (default)');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cursorBin, args, {
|
||||
cwd: config.workingDirectory || process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Ensure non-interactive mode
|
||||
CI: '1',
|
||||
},
|
||||
});
|
||||
|
||||
verboseLog('Process spawned with PID:', child.pid);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
// Text mode - collect all output, report progress based on output size
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = chunk.toString();
|
||||
stdout += chunkStr;
|
||||
verboseLog(`[stdout] received ${chunkStr.length} chars, total: ${stdout.length}`);
|
||||
|
||||
// Report progress if callback provided
|
||||
if (options?.onChunk) {
|
||||
options.onChunk(stdout.length);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
verboseLog(`Process exited with code ${code} after ${elapsed}s`);
|
||||
verboseLog(`stdout length: ${stdout.length} chars`);
|
||||
|
||||
if (code !== 0) {
|
||||
verboseLog('stderr:', stderr);
|
||||
reject(new Error(`Cursor CLI exited with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
resolve({ content: stdout.trim() });
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = chunk.toString();
|
||||
stderr += chunkStr;
|
||||
verboseLog('[stderr]', chunkStr.trim());
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
verboseLog('Spawn error:', err.message);
|
||||
reject(new Error(`Failed to spawn Cursor CLI: ${err.message}`));
|
||||
});
|
||||
|
||||
// Close stdin immediately since we pass prompt as argument
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { execSync, execFileSync } from 'child_process';
|
|||
import {
|
||||
initWikiDb,
|
||||
closeWikiDb,
|
||||
touchWikiDb,
|
||||
getFilesWithExports,
|
||||
getAllFiles,
|
||||
getInterFileCallEdges,
|
||||
|
|
@ -34,8 +35,14 @@ import {
|
|||
estimateTokens,
|
||||
type LLMConfig,
|
||||
type CallLLMOptions,
|
||||
type LLMResponse,
|
||||
} from './llm-client.js';
|
||||
|
||||
import {
|
||||
callCursorLLM,
|
||||
resolveCursorConfig,
|
||||
} from './cursor-client.js';
|
||||
|
||||
import {
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
GROUPING_USER_PROMPT,
|
||||
|
|
@ -58,11 +65,10 @@ import { shouldIgnorePath } from '../../config/ignore-service.js';
|
|||
|
||||
export interface WikiOptions {
|
||||
force?: boolean;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
maxTokensPerModule?: number;
|
||||
concurrency?: number;
|
||||
/** If true, stop after building module tree for user review */
|
||||
reviewOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface WikiMeta {
|
||||
|
|
@ -82,6 +88,13 @@ export interface ModuleTreeNode {
|
|||
|
||||
export type ProgressCallback = (phase: string, percent: number, detail?: string) => void;
|
||||
|
||||
export interface WikiRunResult {
|
||||
pagesGenerated: number;
|
||||
mode: 'full' | 'incremental' | 'up-to-date';
|
||||
failedModules: string[];
|
||||
moduleTree?: ModuleTreeNode[];
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000;
|
||||
|
|
@ -128,22 +141,67 @@ export class WikiGenerator {
|
|||
|
||||
/**
|
||||
* Create streaming options that report LLM progress to the progress bar.
|
||||
* Uses the last known percent so streaming doesn't reset the bar backwards.
|
||||
*
|
||||
* Progress calculation:
|
||||
* - If fixedPercent is provided, we show incremental progress within that phase
|
||||
* based on token generation (e.g., grouping at 15% → 15-28%)
|
||||
* - If fixedPercent is NOT provided, we only update the label with token count
|
||||
* but keep the current percentage (avoids fluctuation during module generation)
|
||||
*
|
||||
* Also touches the DB connection periodically to prevent idle timeout.
|
||||
*/
|
||||
private streamOpts(label: string, fixedPercent?: number): CallLLMOptions {
|
||||
private streamOpts(label: string, fixedPercent?: number, percentRange = 10): CallLLMOptions {
|
||||
const hasFixedStart = fixedPercent !== undefined;
|
||||
const startPercent = fixedPercent ?? this.lastPercent;
|
||||
const expectedTokens = 2000;
|
||||
let lastTouch = Date.now();
|
||||
|
||||
return {
|
||||
onChunk: (chars: number) => {
|
||||
const tokens = Math.round(chars / 4);
|
||||
const pct = fixedPercent ?? this.lastPercent;
|
||||
this.onProgress('stream', pct, `${label} (${tokens} tok)`);
|
||||
|
||||
if (hasFixedStart) {
|
||||
// For fixed phases (like grouping), show incremental progress
|
||||
const progress = Math.min(1, tokens / expectedTokens);
|
||||
const pct = Math.round(startPercent + (progress * percentRange));
|
||||
this.onProgress('stream', pct, `${label} (${tokens} tok)`);
|
||||
} else {
|
||||
// For module generation, only update the label, keep current percent
|
||||
this.onProgress('stream', this.lastPercent, `${label} (${tokens} tok)`);
|
||||
}
|
||||
|
||||
// Touch DB every 60s to prevent idle timeout during long LLM calls
|
||||
const now = Date.now();
|
||||
if (now - lastTouch > 60_000) {
|
||||
touchWikiDb();
|
||||
lastTouch = now;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
|
||||
*/
|
||||
private async invokeLLM(
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
if (this.llmConfig.provider === 'cursor') {
|
||||
const cursorConfig = resolveCursorConfig({
|
||||
model: this.llmConfig.model,
|
||||
workingDirectory: this.repoPath,
|
||||
});
|
||||
return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
|
||||
}
|
||||
return callLLM(prompt, this.llmConfig, systemPrompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point. Runs the full pipeline or incremental update.
|
||||
*/
|
||||
async run(): Promise<{ pagesGenerated: number; mode: 'full' | 'incremental' | 'up-to-date'; failedModules: string[] }> {
|
||||
async run(): Promise<WikiRunResult> {
|
||||
await fs.mkdir(this.wikiDir, { recursive: true });
|
||||
|
||||
const existingMeta = await this.loadWikiMeta();
|
||||
|
|
@ -173,7 +231,7 @@ export class WikiGenerator {
|
|||
this.onProgress('init', 2, 'Connecting to knowledge graph...');
|
||||
await initWikiDb(this.lbugPath);
|
||||
|
||||
let result: { pagesGenerated: number; mode: 'full' | 'incremental' | 'up-to-date'; failedModules: string[] };
|
||||
let result: WikiRunResult;
|
||||
try {
|
||||
if (!forceMode && existingMeta && existingMeta.fromCommit) {
|
||||
result = await this.incrementalUpdate(existingMeta, currentCommit);
|
||||
|
|
@ -205,7 +263,7 @@ export class WikiGenerator {
|
|||
|
||||
// ─── Full Generation ────────────────────────────────────────────────
|
||||
|
||||
private async fullGeneration(currentCommit: string): Promise<{ pagesGenerated: number; mode: 'full'; failedModules: string[] }> {
|
||||
private async fullGeneration(currentCommit: string): Promise<WikiRunResult> {
|
||||
let pagesGenerated = 0;
|
||||
|
||||
// Phase 0: Gather structure
|
||||
|
|
@ -231,6 +289,14 @@ export class WikiGenerator {
|
|||
const moduleTree = await this.buildModuleTree(enrichedFiles);
|
||||
pagesGenerated = 0;
|
||||
|
||||
// If reviewOnly mode, save tree and stop for user to review/edit
|
||||
if (this.options.reviewOnly) {
|
||||
await this.saveModuleTree(moduleTree);
|
||||
this.onProgress('review', 30, 'Module tree ready for review');
|
||||
const reviewResult: WikiRunResult = { pagesGenerated: 0, mode: 'full', failedModules: [], moduleTree };
|
||||
return reviewResult;
|
||||
}
|
||||
|
||||
// Phase 2: Generate module pages (parallel with concurrency limit)
|
||||
const totalModules = this.countModules(moduleTree);
|
||||
let modulesProcessed = 0;
|
||||
|
|
@ -307,6 +373,19 @@ export class WikiGenerator {
|
|||
// ─── Phase 1: Build Module Tree ────────────────────────────────────
|
||||
|
||||
private async buildModuleTree(files: FileWithExports[]): Promise<ModuleTreeNode[]> {
|
||||
// First, check for user-edited module_tree.json (from --review workflow)
|
||||
const editablePath = path.join(this.wikiDir, 'module_tree.json');
|
||||
try {
|
||||
const edited = await fs.readFile(editablePath, 'utf-8');
|
||||
const parsed = JSON.parse(edited);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
this.onProgress('grouping', 25, 'Using edited module tree');
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// No edited tree, check for original snapshot
|
||||
}
|
||||
|
||||
// Check for existing immutable snapshot (resumability)
|
||||
const snapshotPath = path.join(this.wikiDir, 'first_module_tree.json');
|
||||
try {
|
||||
|
|
@ -330,9 +409,9 @@ export class WikiGenerator {
|
|||
DIRECTORY_TREE: dirTree,
|
||||
});
|
||||
|
||||
const response = await callLLM(
|
||||
prompt, this.llmConfig, GROUPING_SYSTEM_PROMPT,
|
||||
this.streamOpts('Grouping files', 15),
|
||||
const response = await this.invokeLLM(
|
||||
prompt, GROUPING_SYSTEM_PROMPT,
|
||||
this.streamOpts('Grouping files', 15, 13),
|
||||
);
|
||||
const grouping = this.parseGroupingResponse(response.content, files);
|
||||
|
||||
|
|
@ -345,8 +424,14 @@ export class WikiGenerator {
|
|||
// Token budget check — split if too large
|
||||
const totalTokens = await this.estimateModuleTokens(modulePaths);
|
||||
if (totalTokens > this.maxTokensPerModule && modulePaths.length > 3) {
|
||||
node.children = this.splitBySubdirectory(moduleName, modulePaths);
|
||||
node.files = []; // Parent doesn't own files directly when split
|
||||
const children = this.splitBySubdirectory(moduleName, modulePaths);
|
||||
// Only create hierarchy if we actually got multiple children
|
||||
// If splitting results in 1 child, keep files flat (avoid redundant nesting)
|
||||
if (children.length > 1) {
|
||||
node.children = children;
|
||||
node.files = []; // Parent doesn't own files directly when split
|
||||
}
|
||||
// If only 1 child, keep original flat structure (files stay in node.files)
|
||||
}
|
||||
|
||||
tree.push(node);
|
||||
|
|
@ -434,23 +519,31 @@ export class WikiGenerator {
|
|||
|
||||
/**
|
||||
* Split a large module into sub-modules by subdirectory.
|
||||
* Uses the full subDir path for naming to avoid slug collisions
|
||||
* (e.g., "synapse-screen/src" vs "synapse-core/src").
|
||||
*/
|
||||
private splitBySubdirectory(moduleName: string, files: string[]): ModuleTreeNode[] {
|
||||
const subGroups = new Map<string, string[]>();
|
||||
for (const fp of files) {
|
||||
const parts = fp.replace(/\\/g, '/').split('/');
|
||||
// Use the deepest common-ish directory
|
||||
const subDir = parts.length > 2 ? parts.slice(0, 2).join('/') : parts[0];
|
||||
let group = subGroups.get(subDir);
|
||||
if (!group) { group = []; subGroups.set(subDir, group); }
|
||||
group.push(fp);
|
||||
}
|
||||
|
||||
return Array.from(subGroups.entries()).map(([subDir, subFiles]) => ({
|
||||
name: `${moduleName} — ${path.basename(subDir)}`,
|
||||
slug: this.slugify(`${moduleName}-${path.basename(subDir)}`),
|
||||
files: subFiles,
|
||||
}));
|
||||
// Check if basenames are unique; if not, use the full subDir path
|
||||
const basenames = Array.from(subGroups.keys()).map(s => path.basename(s));
|
||||
const hasCollisions = new Set(basenames).size < basenames.length;
|
||||
|
||||
return Array.from(subGroups.entries()).map(([subDir, subFiles]) => {
|
||||
const label = hasCollisions ? subDir.replace(/\//g, '-') : path.basename(subDir);
|
||||
return {
|
||||
name: `${moduleName} — ${label}`,
|
||||
slug: this.slugify(`${moduleName}-${label}`),
|
||||
files: subFiles,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 2: Generate Module Pages ─────────────────────────────────
|
||||
|
|
@ -487,8 +580,8 @@ export class WikiGenerator {
|
|||
PROCESSES: formatProcesses(processes),
|
||||
});
|
||||
|
||||
const response = await callLLM(
|
||||
prompt, this.llmConfig, MODULE_SYSTEM_PROMPT,
|
||||
const response = await this.invokeLLM(
|
||||
prompt, MODULE_SYSTEM_PROMPT,
|
||||
this.streamOpts(node.name),
|
||||
);
|
||||
|
||||
|
|
@ -530,8 +623,8 @@ export class WikiGenerator {
|
|||
CROSS_PROCESSES: formatProcesses(processes),
|
||||
});
|
||||
|
||||
const response = await callLLM(
|
||||
prompt, this.llmConfig, PARENT_SYSTEM_PROMPT,
|
||||
const response = await this.invokeLLM(
|
||||
prompt, PARENT_SYSTEM_PROMPT,
|
||||
this.streamOpts(node.name),
|
||||
);
|
||||
|
||||
|
|
@ -577,8 +670,8 @@ export class WikiGenerator {
|
|||
TOP_PROCESSES: formatProcesses(topProcesses),
|
||||
});
|
||||
|
||||
const response = await callLLM(
|
||||
prompt, this.llmConfig, OVERVIEW_SYSTEM_PROMPT,
|
||||
const response = await this.invokeLLM(
|
||||
prompt, OVERVIEW_SYSTEM_PROMPT,
|
||||
this.streamOpts('Generating overview', 88),
|
||||
);
|
||||
|
||||
|
|
@ -591,11 +684,20 @@ export class WikiGenerator {
|
|||
private async incrementalUpdate(
|
||||
existingMeta: WikiMeta,
|
||||
currentCommit: string,
|
||||
): Promise<{ pagesGenerated: number; mode: 'incremental'; failedModules: string[] }> {
|
||||
): Promise<WikiRunResult> {
|
||||
this.onProgress('incremental', 5, 'Detecting changes...');
|
||||
|
||||
// Get changed files since last generation
|
||||
const changedFiles = this.getChangedFiles(existingMeta.fromCommit, currentCommit);
|
||||
|
||||
// If null, commits are on divergent branches (e.g., wiki generated on feature branch,
|
||||
// now running on main). Fall back to full generation.
|
||||
if (changedFiles === null) {
|
||||
this.onProgress('incremental', 10, 'Branch diverged, running full generation...');
|
||||
const fullResult = await this.fullGeneration(currentCommit);
|
||||
return { ...fullResult, mode: 'incremental' };
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
// No file changes but commit differs (e.g. merge commit)
|
||||
await this.saveWikiMeta({
|
||||
|
|
@ -710,7 +812,29 @@ export class WikiGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
|
||||
/**
|
||||
* Check if fromCommit is an ancestor of toCommit (reachable in git history).
|
||||
* Returns false if commits are on divergent branches or fromCommit doesn't exist.
|
||||
*/
|
||||
private isCommitReachable(fromCommit: string, toCommit: string): boolean {
|
||||
try {
|
||||
execFileSync(
|
||||
'git', ['merge-base', '--is-ancestor', fromCommit, toCommit],
|
||||
{ cwd: this.repoPath, stdio: 'ignore' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getChangedFiles(fromCommit: string, toCommit: string): string[] | null {
|
||||
// First check if fromCommit is reachable from toCommit
|
||||
// This handles the case where wiki was generated on a different branch
|
||||
if (!this.isCommitReachable(fromCommit, toCommit)) {
|
||||
return null; // Signal that we can't compute diff (divergent branches)
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
|
||||
|
|
@ -718,7 +842,7 @@ export class WikiGenerator {
|
|||
).toString().trim();
|
||||
return output ? output.split('\n').filter(Boolean) : [];
|
||||
} catch {
|
||||
return [];
|
||||
return null; // Treat git errors as needing full regen
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@
|
|||
* Uses the MCP-style pooled lbug-adapter for connection management.
|
||||
*/
|
||||
|
||||
import { initLbug, executeQuery, closeLbug } from '../../mcp/core/lbug-adapter.js';
|
||||
import { initLbug, executeQuery, closeLbug, touchRepo } from '../../mcp/core/lbug-adapter.js';
|
||||
|
||||
const REPO_ID = '__wiki__';
|
||||
|
||||
/**
|
||||
* Touch the wiki DB connection to prevent idle timeout during long LLM calls.
|
||||
*/
|
||||
export function touchWikiDb(): void {
|
||||
touchRepo(REPO_ID);
|
||||
}
|
||||
|
||||
export interface FileWithExports {
|
||||
filePath: string;
|
||||
symbols: Array<{ name: string; type: string }>;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@
|
|||
* Config priority: CLI flags > env vars > defaults
|
||||
*/
|
||||
|
||||
export type LLMProvider = 'openai' | 'cursor';
|
||||
|
||||
export interface LLMConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
provider?: LLMProvider;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
|
|
@ -31,24 +34,30 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
|
|||
const { loadCLIConfig } = await import('../../storage/repo-manager.js');
|
||||
const savedConfig = await loadCLIConfig();
|
||||
|
||||
const provider = overrides?.provider || savedConfig.provider || 'openai';
|
||||
|
||||
const apiKey = overrides?.apiKey
|
||||
|| process.env.GITNEXUS_API_KEY
|
||||
|| process.env.OPENAI_API_KEY
|
||||
|| savedConfig.apiKey
|
||||
|| '';
|
||||
|
||||
// For cursor provider, only use model if explicitly provided (default is 'auto' handled by CLI)
|
||||
// For openai provider, use model with fallback to default
|
||||
const model = provider === 'cursor'
|
||||
? (overrides?.model || savedConfig.cursorModel || '')
|
||||
: (overrides?.model || process.env.GITNEXUS_MODEL || savedConfig.model || 'minimax/minimax-m2.5');
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: overrides?.baseUrl
|
||||
|| process.env.GITNEXUS_LLM_BASE_URL
|
||||
|| savedConfig.baseUrl
|
||||
|| 'https://openrouter.ai/api/v1',
|
||||
model: overrides?.model
|
||||
|| process.env.GITNEXUS_MODEL
|
||||
|| savedConfig.model
|
||||
|| 'minimax/minimax-m2.5',
|
||||
model,
|
||||
maxTokens: overrides?.maxTokens ?? 16_384,
|
||||
temperature: overrides?.temperature ?? 0,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ Example format:
|
|||
export const MODULE_SYSTEM_PROMPT = `You are a technical documentation writer. Write clear, developer-focused documentation for a code module.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the documentation content — no meta-commentary like "I've written...", "Here's the documentation...", "The documentation covers...", or similar
|
||||
- Start directly with the module heading and content
|
||||
- Reference actual function names, class names, and code patterns — do NOT invent APIs
|
||||
- Use the call graph and execution flow data for accuracy, but do NOT mechanically list every edge
|
||||
- Include Mermaid diagrams only when they genuinely help understanding. Keep them small (5-10 nodes max)
|
||||
|
|
@ -65,6 +67,8 @@ Write comprehensive documentation for this module. Cover its purpose, how it wor
|
|||
export const PARENT_SYSTEM_PROMPT = `You are a technical documentation writer. Write a summary page for a module that contains sub-modules. Synthesize the children's documentation — do not re-read source code.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the documentation content — no meta-commentary like "I've written...", "Here's the documentation...", "The documentation covers...", or similar
|
||||
- Start directly with the module heading and content
|
||||
- Reference actual components from the child modules
|
||||
- Focus on how the sub-modules work together, not repeating their individual docs
|
||||
- Keep it concise — the reader can click through to child pages for detail
|
||||
|
|
@ -86,6 +90,8 @@ Write a concise overview of this module group. Explain its purpose, how the sub-
|
|||
export const OVERVIEW_SYSTEM_PROMPT = `You are a technical documentation writer. Write the top-level overview page for a repository wiki. This is the first page a new developer sees.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the documentation content — no meta-commentary like "I've written...", "Here's the documentation...", "The page has been rewritten...", or similar
|
||||
- Start directly with the project heading and content
|
||||
- Be clear and welcoming — this is the entry point to the entire codebase
|
||||
- Reference actual module names so readers can navigate to their docs
|
||||
- Include a high-level Mermaid architecture diagram showing only the most important modules and their relationships (max 10 nodes). A new dev should grasp it in 10 seconds
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@ function ensureIdleTimer(): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch a repo to reset its idle timeout.
|
||||
* Call this during long-running operations to prevent the connection from being closed.
|
||||
*/
|
||||
export const touchRepo = (repoId: string): void => {
|
||||
const entry = pool.get(repoId);
|
||||
if (entry) {
|
||||
entry.lastUsed = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Evict the least-recently-used repo if pool is at capacity
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -318,10 +318,14 @@ export const listRegisteredRepos = async (opts?: { validate?: boolean }): Promis
|
|||
|
||||
// ─── Global CLI Config (~/.gitnexus/config.json) ─────────────────────────
|
||||
|
||||
export type LLMProvider = 'openai' | 'cursor';
|
||||
|
||||
export interface CLIConfig {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
provider?: LLMProvider;
|
||||
cursorModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -232,6 +232,88 @@ describe('CLI end-to-end', () => {
|
|||
|
||||
});
|
||||
|
||||
// ─── wiki command flags ─────────────────────────────────────────────
|
||||
|
||||
describe('wiki command flags', () => {
|
||||
it('wiki --help shows --provider, --review, --verbose flags', () => {
|
||||
const result = runCliRaw(['wiki', '--help'], repoRoot);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--provider <provider>');
|
||||
expect(result.stdout).toContain('--review');
|
||||
expect(result.stdout).toContain('-v, --verbose');
|
||||
expect(result.stdout).toContain('--model <model>');
|
||||
expect(result.stdout).toContain('--gist');
|
||||
expect(result.stdout).toContain('--concurrency <n>');
|
||||
});
|
||||
|
||||
it('wiki on non-git directory fails with exit code 1', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-nogit-'));
|
||||
try {
|
||||
const result = runCliRaw(['wiki', tmpDir], repoRoot);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toMatch(/not.*git repository/i);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('wiki on non-indexed repo fails with "No GitNexus index"', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-noindex-'));
|
||||
try {
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], {
|
||||
cwd: tmpDir, stdio: 'pipe',
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test' },
|
||||
});
|
||||
|
||||
// Must spawn outside project tree so it doesn't find parent .gitnexus
|
||||
const result = spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'wiki', tmpDir], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
});
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toMatch(/No GitNexus index found/);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('wiki --provider cursor without API key does not prompt for key in non-TTY', () => {
|
||||
// In non-TTY (piped stdin), --provider cursor should skip the API key prompt
|
||||
// and proceed (or fail gracefully with Cursor CLI not found)
|
||||
const result = runCliRaw(
|
||||
['wiki', MINI_REPO, '--provider', 'cursor'],
|
||||
repoRoot,
|
||||
15000,
|
||||
);
|
||||
if (result.status === null) return;
|
||||
|
||||
const combined = result.stdout + result.stderr;
|
||||
// Should NOT ask for API key — cursor provider doesn't need one
|
||||
expect(combined).not.toMatch(/API key:/);
|
||||
});
|
||||
|
||||
it('wiki --help includes --verbose flag description', () => {
|
||||
const result = runCliRaw(['wiki', '--help'], repoRoot);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/verbose/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stdout fd 1 tests (#324) ───────────────────────────────────────
|
||||
// These tests verify that tool output goes to stdout (fd 1), not stderr.
|
||||
// Requires analyze to have run first (the analyze test above populates .gitnexus/).
|
||||
|
|
|
|||
|
|
@ -42,4 +42,15 @@ describe('CLI help surface', () => {
|
|||
expect(result.stdout).toContain('--include-tests');
|
||||
expect(result.stdout).toContain('--repo <name>');
|
||||
});
|
||||
|
||||
it('wiki help shows provider, review, and verbose flags', () => {
|
||||
const result = runHelp('wiki');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--provider <provider>');
|
||||
expect(result.stdout).toContain('--review');
|
||||
expect(result.stdout).toContain('-v, --verbose');
|
||||
expect(result.stdout).toContain('--model <model>');
|
||||
expect(result.stdout).toContain('--gist');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
447
gitnexus/test/unit/wiki-flags.test.ts
Normal file
447
gitnexus/test/unit/wiki-flags.test.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
/**
|
||||
* Unit tests for wiki CLI flags: --provider cursor, --review, --verbose
|
||||
*
|
||||
* Tests the new wiki provider infrastructure without requiring an actual
|
||||
* Cursor CLI binary or LLM API key. All external dependencies are mocked.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// ─── detectCursorCLI caching ─────────────────────────────────────────
|
||||
|
||||
describe('detectCursorCLI', () => {
|
||||
let execSyncSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the module-level cache by re-importing fresh each time
|
||||
vi.resetModules();
|
||||
execSyncSpy = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('caches result after first call (avoids repeated spawns)', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execSync: execSyncSpy,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
const { detectCursorCLI } = await import('../../src/core/wiki/cursor-client.js');
|
||||
|
||||
// First call — execSync runs
|
||||
execSyncSpy.mockImplementation(() => 'agent 0.1.0');
|
||||
const first = detectCursorCLI();
|
||||
expect(first).toBe('agent');
|
||||
expect(execSyncSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — cached, no extra spawn
|
||||
const second = detectCursorCLI();
|
||||
expect(second).toBe('agent');
|
||||
expect(execSyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caches null when agent is not found', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execSync: execSyncSpy,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
const { detectCursorCLI } = await import('../../src/core/wiki/cursor-client.js');
|
||||
|
||||
execSyncSpy.mockImplementation(() => { throw new Error('not found'); });
|
||||
|
||||
const first = detectCursorCLI();
|
||||
expect(first).toBeNull();
|
||||
|
||||
const second = detectCursorCLI();
|
||||
expect(second).toBeNull();
|
||||
expect(execSyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCursorConfig ─────────────────────────────────────────────
|
||||
|
||||
describe('resolveCursorConfig', () => {
|
||||
it('returns provided model and workingDirectory', async () => {
|
||||
const { resolveCursorConfig } = await import('../../src/core/wiki/cursor-client.js');
|
||||
const config = resolveCursorConfig({ model: 'claude-4', workingDirectory: '/tmp' });
|
||||
expect(config.model).toBe('claude-4');
|
||||
expect(config.workingDirectory).toBe('/tmp');
|
||||
});
|
||||
|
||||
it('returns undefined model when not provided (uses Cursor default)', async () => {
|
||||
const { resolveCursorConfig } = await import('../../src/core/wiki/cursor-client.js');
|
||||
const config = resolveCursorConfig();
|
||||
expect(config.model).toBeUndefined();
|
||||
expect(config.workingDirectory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveLLMConfig provider routing ───────────────────────────────
|
||||
|
||||
describe('resolveLLMConfig', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-test-config-'));
|
||||
// Create empty config so loadCLIConfig returns {}
|
||||
const configDir = path.join(tmpDir, '.gitnexus');
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, 'config.json'),
|
||||
JSON.stringify({}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses cursorModel (not model) when provider is cursor', async () => {
|
||||
// Mock loadCLIConfig to return cursor config
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
provider: 'cursor',
|
||||
cursorModel: 'claude-4.5-opus-high',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig({ provider: 'cursor' });
|
||||
|
||||
expect(config.provider).toBe('cursor');
|
||||
expect(config.model).toBe('claude-4.5-opus-high');
|
||||
});
|
||||
|
||||
it('uses default OpenRouter model for openai provider', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig();
|
||||
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.model).toBe('minimax/minimax-m2.5');
|
||||
expect(config.baseUrl).toBe('https://openrouter.ai/api/v1');
|
||||
});
|
||||
|
||||
it('CLI overrides take priority over saved config', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
provider: 'openai',
|
||||
model: 'saved-model',
|
||||
apiKey: 'saved-key',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig({
|
||||
provider: 'cursor',
|
||||
model: 'override-model',
|
||||
});
|
||||
|
||||
expect(config.provider).toBe('cursor');
|
||||
expect(config.model).toBe('override-model');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── --verbose flag ──────────────────────────────────────────────────
|
||||
|
||||
describe('--verbose flag', () => {
|
||||
const originalEnv = process.env.GITNEXUS_VERBOSE;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
} else {
|
||||
process.env.GITNEXUS_VERBOSE = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('verboseLog writes to console when GITNEXUS_VERBOSE=1', async () => {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Import the module's isVerbose/verboseLog indirectly via detectCursorCLI's verbose path.
|
||||
// Instead, we test the isVerbose check directly since verboseLog is not exported.
|
||||
// The env var drives the behavior.
|
||||
expect(process.env.GITNEXUS_VERBOSE).toBe('1');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('verbose is off when GITNEXUS_VERBOSE is not set', () => {
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
expect(process.env.GITNEXUS_VERBOSE).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── --review flag (WikiGenerator reviewOnly) ────────────────────────
|
||||
|
||||
describe('WikiGenerator --review mode', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-review-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reviewOnly returns moduleTree and pagesGenerated=0', async () => {
|
||||
const fakeFiles = ['src/auth.ts', 'src/core.ts'];
|
||||
|
||||
vi.doMock('../../src/core/wiki/graph-queries.js', () => ({
|
||||
initWikiDb: vi.fn().mockResolvedValue(undefined),
|
||||
closeWikiDb: vi.fn().mockResolvedValue(undefined),
|
||||
touchWikiDb: vi.fn(),
|
||||
getFilesWithExports: vi.fn().mockResolvedValue(
|
||||
fakeFiles.map(f => ({ filePath: f, symbols: [] })),
|
||||
),
|
||||
getAllFiles: vi.fn().mockResolvedValue(fakeFiles),
|
||||
getInterFileCallEdges: vi.fn().mockResolvedValue([]),
|
||||
getIntraModuleCallEdges: vi.fn().mockResolvedValue([]),
|
||||
getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }),
|
||||
getProcessesForFiles: vi.fn().mockResolvedValue([]),
|
||||
getAllProcesses: vi.fn().mockResolvedValue([]),
|
||||
getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
|
||||
|
||||
const storagePath = path.join(tmpDir, 'storage');
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
|
||||
// Pre-seed a module_tree.json so buildModuleTree skips the LLM call
|
||||
const tree = [
|
||||
{ name: 'Auth', slug: 'auth', files: ['src/auth.ts'] },
|
||||
{ name: 'Core', slug: 'core', files: ['src/core.ts'] },
|
||||
];
|
||||
await fs.writeFile(
|
||||
path.join(wikiDir, 'first_module_tree.json'),
|
||||
JSON.stringify(tree),
|
||||
);
|
||||
|
||||
const repoPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
||||
const llmConfig = {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: 'test',
|
||||
maxTokens: 1000,
|
||||
temperature: 0,
|
||||
provider: 'cursor' as const,
|
||||
};
|
||||
|
||||
const progress: { phase: string; percent: number }[] = [];
|
||||
const generator = new WikiGenerator(
|
||||
repoPath,
|
||||
storagePath,
|
||||
path.join(storagePath, 'lbug'),
|
||||
llmConfig,
|
||||
{ reviewOnly: true },
|
||||
(phase, percent) => progress.push({ phase, percent }),
|
||||
);
|
||||
|
||||
const result = await generator.run();
|
||||
|
||||
expect(result.pagesGenerated).toBe(0);
|
||||
expect(result.moduleTree).toBeDefined();
|
||||
expect(result.moduleTree).toHaveLength(2);
|
||||
expect(result.moduleTree![0].name).toBe('Auth');
|
||||
expect(result.moduleTree![1].name).toBe('Core');
|
||||
|
||||
// module_tree.json should be written for user to edit
|
||||
const treeFile = path.join(wikiDir, 'module_tree.json');
|
||||
const written = JSON.parse(await fs.readFile(treeFile, 'utf-8'));
|
||||
expect(written).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI config round-trip with cursor provider ──────────────────────
|
||||
|
||||
describe('CLI config round-trip with cursor provider', () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-config-test-'));
|
||||
const configDir = path.join(tmpDir, '.gitnexus');
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
configPath = path.join(configDir, 'config.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('saves and loads cursor provider config correctly', async () => {
|
||||
const config = { provider: 'cursor', cursorModel: 'claude-4.5-opus-high' };
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8'));
|
||||
expect(loaded.provider).toBe('cursor');
|
||||
expect(loaded.cursorModel).toBe('claude-4.5-opus-high');
|
||||
expect(loaded.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('saves openai provider config with model and apiKey', async () => {
|
||||
const config = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o-mini',
|
||||
apiKey: 'sk-test-key',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8'));
|
||||
expect(loaded.provider).toBe('openai');
|
||||
expect(loaded.model).toBe('gpt-4o-mini');
|
||||
expect(loaded.apiKey).toBe('sk-test-key');
|
||||
expect(loaded.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('cursor config does not clobber openai fields', async () => {
|
||||
const config = {
|
||||
provider: 'cursor',
|
||||
cursorModel: 'claude-4.5-opus-high',
|
||||
apiKey: 'sk-existing',
|
||||
model: 'gpt-4o',
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8'));
|
||||
expect(loaded.provider).toBe('cursor');
|
||||
expect(loaded.cursorModel).toBe('claude-4.5-opus-high');
|
||||
// Existing openai fields preserved
|
||||
expect(loaded.apiKey).toBe('sk-existing');
|
||||
expect(loaded.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── invokeLLM routing ──────────────────────────────────────────────
|
||||
|
||||
describe('WikiGenerator invokeLLM routing', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-invoke-test-'));
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('routes to callCursorLLM when provider is cursor', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const openaiSpy = vi.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
||||
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
|
||||
|
||||
const storagePath = path.join(tmpDir, 'storage');
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
|
||||
const repoPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
||||
const generator = new WikiGenerator(
|
||||
repoPath,
|
||||
storagePath,
|
||||
path.join(storagePath, 'lbug'),
|
||||
{ apiKey: '', baseUrl: '', model: 'test', maxTokens: 1000, temperature: 0, provider: 'cursor' },
|
||||
);
|
||||
|
||||
// Access the private method via prototype trick
|
||||
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
|
||||
|
||||
expect(cursorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openaiSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('cursor response');
|
||||
});
|
||||
|
||||
it('routes to callLLM when provider is openai', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const openaiSpy = vi.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
||||
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
|
||||
|
||||
const storagePath = path.join(tmpDir, 'storage');
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
|
||||
const repoPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
||||
const generator = new WikiGenerator(
|
||||
repoPath,
|
||||
storagePath,
|
||||
path.join(storagePath, 'lbug'),
|
||||
{ apiKey: 'key', baseUrl: 'http://localhost', model: 'gpt-4', maxTokens: 1000, temperature: 0, provider: 'openai' },
|
||||
);
|
||||
|
||||
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
|
||||
|
||||
expect(openaiSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cursorSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('openai response');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── callCursorLLM error when CLI not found ──────────────────────────
|
||||
|
||||
describe('callCursorLLM', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when Cursor CLI is not in PATH', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execSync: vi.fn().mockImplementation(() => { throw new Error('not found'); }),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { callCursorLLM } = await import('../../src/core/wiki/cursor-client.js');
|
||||
|
||||
await expect(callCursorLLM('hello', {})).rejects.toThrow(
|
||||
'Cursor CLI not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── estimateTokens ─────────────────────────────────────────────────
|
||||
|
||||
describe('estimateTokens', () => {
|
||||
it('estimates ~4 chars per token', async () => {
|
||||
const { estimateTokens } = await import('../../src/core/wiki/llm-client.js');
|
||||
expect(estimateTokens('a'.repeat(100))).toBe(25);
|
||||
expect(estimateTokens('')).toBe(0);
|
||||
expect(estimateTokens('hello world')).toBe(3); // ceil(11/4)
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue