Merge pull request #381 from cnighut/feat/cursor-cli-wiki-provider

This commit is contained in:
Zander Raycraft 2026-03-25 08:20:23 -05:00 • committed by GitHub
commit 655f0f2479
12 changed files with 1103 additions and 91 deletions

View file

@ -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

View file

@ -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);
}
}

View 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();
});
}

View file

@ -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
}
}

View file

@ -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 }>;

View file

@ -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,
};
}

View file

@ -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

View file

@ -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
*/

View file

@ -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;
}
/**

View file

@ -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/).

View file

@ -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');
});
});

View 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)
});
});