Skip to content
Merged
1 change: 1 addition & 0 deletions gitnexus/scripts/cross-platform-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const SPAWN_CLI = [
'test/integration/group/group-cli.test.ts',
'test/integration/cli/tool-no-index-stderr.test.ts',
'test/integration/setup-skills.test.ts',
'test/unit/local-cli-subprocess.test.ts',
];

// Worker threads tests — exercise real worker_threads which have
Expand Down
3 changes: 2 additions & 1 deletion gitnexus/src/cli/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ export const en = {
'help.option.clean.all': 'Clean all indexed repos',
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider': 'LLM provider: openai or cursor (default: openai)',
'help.option.wiki.provider':
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
Expand Down
3 changes: 2 additions & 1 deletion gitnexus/src/cli/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ export const zhCN = {
'help.option.clean.all': '清理所有已索引仓库',
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider': 'LLM 提供商:openai 或 cursor(默认:openai)',
'help.option.wiki.provider':
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)',
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',
Expand Down
5 changes: 4 additions & 1 deletion gitnexus/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ program
.command('wiki [path]')
.description('Generate repository wiki from knowledge graph')
.option('-f, --force', 'Force full regeneration even if up to date')
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
.option(
'--provider <provider>',
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
)
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
.option(
'--base-url <url>',
Expand Down
81 changes: 62 additions & 19 deletions gitnexus/src/cli/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
import { logger } from '../core/logger.js';

export interface WikiCommandOptions {
Expand Down Expand Up @@ -55,6 +56,18 @@ function parsePositiveIntegerOption(
return parsed;
}

function isLocalProvider(
provider: LLMProvider | undefined,
): provider is 'cursor' | 'claude' | 'codex' {
return provider === 'cursor' || provider === 'claude' || provider === 'codex';
}

function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') {
if (provider === 'cursor') return 'cursorModel';
if (provider === 'claude') return 'claudeModel';
return 'codexModel';
}

/**
* Prompt the user for input via stdin.
*/
Expand Down Expand Up @@ -191,10 +204,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
if (options.provider) updates.provider = options.provider;
if (options.apiVersion) updates.apiVersion = options.apiVersion;
if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel;
// Save model to appropriate field based on provider
// Save model to appropriate field based on provider.
if (options.model) {
if (options.provider === 'cursor') {
updates.cursorModel = options.model;
const targetProvider = options.provider ?? existing.provider;
if (isLocalProvider(targetProvider)) {
updates[localModelConfigKey(targetProvider)] = options.model;
} else {
updates.model = options.model;
}
Expand All @@ -205,7 +219,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)

const savedConfig = await loadCLIConfig();
const hasSavedConfig = !!(
savedConfig.provider === 'cursor' ||
isLocalProvider(savedConfig.provider) ||
(savedConfig.apiKey && savedConfig.baseUrl)
);
const hasCLIOverrides = !!(
Expand All @@ -231,56 +245,84 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
if (!hasSavedConfig && !hasCLIOverrides) {
if (!process.stdin.isTTY) {
// Non-interactive mode — need either API key or Cursor CLI
if (!llmConfig.apiKey && llmConfig.provider !== 'cursor') {
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
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>, or use --provider cursor.\n');
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex.\n');
process.exitCode = 1;
return;
}
// 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, Azure, any OpenAI-compatible API, or Cursor CLI.\n',
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n',
);

// Check if Cursor CLI is available
// Check if local agent CLIs are available.
const hasCursor = detectCursorCLI();
const hasClaude = detectLocalCLI('claude');
const hasCodex = detectLocalCLI('codex');
const localChoices: Array<{
choice: string;
provider: 'cursor' | 'claude' | 'codex';
}> = [];

// Provider selection
console.log(' [1] OpenAI (api.openai.com)');
console.log(' [2] OpenRouter (openrouter.ai)');
console.log(' [3] Azure OpenAI');
console.log(' [4] Custom endpoint');
let nextChoice = 5;
if (hasCursor) {
console.log(' [5] Cursor CLI (local, uses your Cursor subscription)');
const choice = String(nextChoice++);
localChoices.push({
choice,
provider: 'cursor',
});
console.log(` [${choice}] Cursor CLI (local, uses your Cursor subscription)`);
}
if (hasClaude) {
const choice = String(nextChoice++);
localChoices.push({
choice,
provider: 'claude',
});
console.log(` [${choice}] Claude CLI (local, uses your Claude Code login)`);
}
if (hasCodex) {
const choice = String(nextChoice++);
localChoices.push({
choice,
provider: 'codex',
});
console.log(` [${choice}] Codex CLI (local, uses your Codex login)`);
}
console.log('');

const maxChoice = hasCursor ? '5' : '4';
const maxChoice = String(nextChoice - 1);
const choice = await prompt(` Select provider (1/${maxChoice}): `);

let baseUrl: string;
let defaultModel: string;
let provider: LLMProvider = 'openai';
let key = '';

if (choice === '5' && hasCursor) {
// Cursor CLI selected - model defaults to 'auto' (Cursor's default)
provider = 'cursor';
const selectedLocal = localChoices.find((item) => item.choice === choice);
if (selectedLocal) {
// Local CLI selected - model defaults to the CLI's configured default.
provider = selectedLocal.provider;
baseUrl = '';

const modelInput = await prompt(' Model (leave empty for auto): ');
const modelInput = await prompt(' Model (leave empty for CLI default): ');
const model = modelInput || '';

// Save config for Cursor
const cursorConfig: Record<string, string> = { provider: 'cursor' };
if (model) cursorConfig.cursorModel = model;
await saveCLIConfig(cursorConfig);
const localConfig = { ...savedConfig, provider };
if (model) (localConfig as Record<string, unknown>)[localModelConfigKey(provider)] = model;
await saveCLIConfig(localConfig);
console.log(' Config saved to ~/.gitnexus/config.json\n');

llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
llmConfig = { ...llmConfig, provider, model, apiKey: '', baseUrl: '' };
} else if (choice === '3') {
// Azure OpenAI guided setup — minimal prompts
console.log('\n Azure OpenAI setup.\n');
Expand Down Expand Up @@ -328,6 +370,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
const azureBaseUrl = `${endpoint}/openai/v1`;

await saveCLIConfig({
...savedConfig,
apiKey: azureKey,
baseUrl: azureBaseUrl,
model: deploymentName,
Expand Down
13 changes: 12 additions & 1 deletion gitnexus/src/core/wiki/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from './llm-client.js';

import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js';

import {
GROUPING_SYSTEM_PROMPT,
Expand Down Expand Up @@ -203,7 +204,7 @@ export class WikiGenerator {
}

/**
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
* Route LLM call to the appropriate provider.
*/
private async invokeLLM(
prompt: string,
Expand All @@ -217,6 +218,16 @@ export class WikiGenerator {
});
return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
}
if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') {
const localConfig = resolveLocalCLIConfig({
model: this.llmConfig.model,
workingDirectory: this.repoPath,
requestTimeoutMs: this.llmConfig.requestTimeoutMs,
});
return this.llmConfig.provider === 'claude'
? callClaudeLLM(prompt, localConfig, systemPrompt, options)
: callCodexLLM(prompt, localConfig, systemPrompt, options);
}
return callLLM(prompt, this.llmConfig, systemPrompt, options);
}

Expand Down
31 changes: 24 additions & 7 deletions gitnexus/src/core/wiki/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '
* Config priority: CLI flags > env vars > defaults
*/

export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
export type LLMProvider =
| 'openai'
| 'openrouter'
| 'azure'
| 'custom'
| 'cursor'
| 'claude'
| 'codex';

export interface LLMConfig {
apiKey: string;
Expand All @@ -18,7 +25,7 @@ export interface LLMConfig {
maxTokens: number;
temperature: number;
/** Provider type — controls auth header behaviour */
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
provider?: LLMProvider;
/** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */
apiVersion?: string;
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
Expand All @@ -44,6 +51,17 @@ export interface LLMResponse {
export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<LLMConfig> {
const { loadCLIConfig } = await import('../../storage/repo-manager.js');
const savedConfig = await loadCLIConfig();
const savedProvider = overrides?.provider ?? savedConfig.provider;
const savedLocalModel =
savedProvider === 'cursor'
? savedConfig.cursorModel
: savedProvider === 'claude'
? savedConfig.claudeModel
: savedProvider === 'codex'
? savedConfig.codexModel
: undefined;
const localProvider =
savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex';

const apiKey =
overrides?.apiKey ||
Expand All @@ -61,13 +79,12 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
'https://openrouter.ai/api/v1',
model:
overrides?.model ||
process.env.GITNEXUS_MODEL ||
(savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) ||
savedConfig.model ||
'minimax/minimax-m2.5',
(localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
savedLocalModel ||
(localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
maxTokens: overrides?.maxTokens ?? 16_384,
temperature: overrides?.temperature ?? 0,
provider: overrides?.provider ?? savedConfig.provider ?? 'openai',
provider: savedProvider ?? 'openai',
apiVersion:
overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion,
isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
Expand Down
Loading
Loading