if (process.argv.includes('-m') || process.argv.includes('--message')) {
process.env.PROBE_NON_INTERACTIVE = '1';
}
if (!process.stdin.isTTY) {
process.env.PROBE_NON_INTERACTIVE = '1';
process.env.PROBE_STDIN_PIPED = '1';
}
import 'dotenv/config';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { Command } from 'commander';
import { existsSync, realpathSync, readFileSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import { ProbeChat } from './probeChat.js';
import { TokenUsageDisplay } from './tokenUsageDisplay.js';
import { DEFAULT_SYSTEM_MESSAGE } from '@buger/probe';
export function main() {
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(__dirname, 'package.json');
let version = '1.0.0'; try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
version = packageJson.version || version;
} catch (error) {
}
const program = new Command();
program
.name('probe-chat')
.description('CLI chat interface for Probe code search')
.version(version)
.option('-d, --debug', 'Enable debug mode')
.option('--model-name <model>', 'Specify the model to use') .option('-f, --force-provider <provider>', 'Force a specific provider (options: anthropic, openai, google)')
.option('-w, --web', 'Run in web interface mode')
.option('-p, --port <port>', 'Port to run web server on (default: 8080)')
.option('-m, --message <message>', 'Send a single message and exit (non-interactive mode)')
.option('-s, --session-id <sessionId>', 'Specify a session ID for the chat (optional)')
.option('--json', 'Output the response as JSON in non-interactive mode')
.option('--max-iterations <number>', 'Maximum number of tool iterations allowed (default: 30)')
.option('--prompt <value>', 'Use a custom prompt (values: architect, code-review, support, path to a file, or arbitrary string)')
.option('--allow-edit', 'Enable the implement tool for editing files')
.option('--trace-file [path]', 'Enable tracing to file (default: ./traces.jsonl)')
.option('--trace-remote [endpoint]', 'Enable tracing to remote endpoint (default: http://localhost:4318/v1/traces)')
.option('--trace-console', 'Enable tracing to console (for debugging)')
.argument('[path]', 'Path to the codebase to search (overrides ALLOWED_FOLDERS)')
.parse(process.argv);
const options = program.opts();
const pathArg = program.args[0];
const isPipedInput = process.env.PROBE_STDIN_PIPED === '1';
const isNonInteractive = !!options.message || isPipedInput;
if (isNonInteractive && process.env.PROBE_NON_INTERACTIVE !== '1') {
process.env.PROBE_NON_INTERACTIVE = '1';
}
const rawLog = (...args) => console.log(...args);
const rawError = (...args) => console.error(...args);
if (isNonInteractive && !options.json && !options.debug) {
chalk.level = 0;
}
const logInfo = (...args) => {
if (!isNonInteractive || options.debug) {
console.log(...args);
}
};
const logWarn = (...args) => {
if (!isNonInteractive || options.debug) {
console.warn(...args);
} else if (isNonInteractive) {
}
};
const logError = (...args) => {
if (isNonInteractive) {
rawError('Error:', ...args); } else {
console.error(...args);
}
};
if (options.debug) {
process.env.DEBUG_CHAT = '1';
logInfo(chalk.yellow('Debug mode enabled'));
}
if (options.modelName) { process.env.MODEL_NAME = options.modelName;
logInfo(chalk.blue(`Using model: ${options.modelName}`));
}
if (options.forceProvider) {
const provider = options.forceProvider.toLowerCase();
if (!['anthropic', 'openai', 'google'].includes(provider)) {
logError(chalk.red(`Invalid provider "${provider}". Must be one of: anthropic, openai, google`));
process.exit(1);
}
process.env.FORCE_PROVIDER = provider;
logInfo(chalk.blue(`Forcing provider: ${provider}`));
}
if (options.maxIterations) {
const maxIterations = parseInt(options.maxIterations, 10);
if (isNaN(maxIterations) || maxIterations <= 0) {
logError(chalk.red(`Invalid max iterations value: ${options.maxIterations}. Must be a positive number.`));
process.exit(1);
}
process.env.MAX_TOOL_ITERATIONS = maxIterations.toString();
logInfo(chalk.blue(`Setting maximum tool iterations to: ${maxIterations}`));
}
if (options.allowEdit) {
process.env.ALLOW_EDIT = '1';
logInfo(chalk.blue(`Enabling implement tool with --allow-edit flag`));
}
if (options.traceFile !== undefined) {
process.env.OTEL_ENABLE_FILE = 'true';
process.env.OTEL_FILE_PATH = options.traceFile || './traces.jsonl';
logInfo(chalk.blue(`Enabling file tracing to: ${process.env.OTEL_FILE_PATH}`));
}
if (options.traceRemote !== undefined) {
process.env.OTEL_ENABLE_REMOTE = 'true';
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = options.traceRemote || 'http://localhost:4318/v1/traces';
logInfo(chalk.blue(`Enabling remote tracing to: ${process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT}`));
}
if (options.traceConsole) {
process.env.OTEL_ENABLE_CONSOLE = 'true';
logInfo(chalk.blue(`Enabling console tracing`));
}
let customPrompt = null;
if (options.prompt) {
const predefinedPrompts = ['architect', 'code-review', 'support', 'engineer'];
if (predefinedPrompts.includes(options.prompt)) {
process.env.PROMPT_TYPE = options.prompt;
logInfo(chalk.blue(`Using predefined prompt: ${options.prompt}`));
} else {
try {
const promptPath = resolve(options.prompt);
if (existsSync(promptPath)) {
customPrompt = readFileSync(promptPath, 'utf8');
process.env.CUSTOM_PROMPT = customPrompt;
logInfo(chalk.blue(`Loaded custom prompt from file: ${promptPath}`));
} else {
customPrompt = options.prompt;
process.env.CUSTOM_PROMPT = customPrompt;
logInfo(chalk.blue(`Using custom prompt string`));
}
} catch (error) {
customPrompt = options.prompt;
process.env.CUSTOM_PROMPT = customPrompt;
logInfo(chalk.blue(`Using custom prompt string`));
}
}
}
const allowedFolders = process.env.ALLOWED_FOLDERS
? process.env.ALLOWED_FOLDERS.split(',').map(folder => folder.trim()).filter(Boolean)
: [];
if (pathArg) {
const resolvedPath = resolve(pathArg);
if (existsSync(resolvedPath)) {
const realPath = realpathSync(resolvedPath);
process.env.ALLOWED_FOLDERS = realPath;
logInfo(chalk.blue(`Using codebase path: ${realPath}`));
allowedFolders.length = 0;
allowedFolders.push(realPath);
} else {
logError(chalk.red(`Path does not exist: ${resolvedPath}`));
process.exit(1);
}
} else {
logInfo('Configured search folders:');
for (const folder of allowedFolders) {
const exists = existsSync(folder);
logInfo(`- ${folder} ${exists ? '✓' : '✗ (not found)'}`);
if (!exists) {
logWarn(chalk.yellow(`Warning: Folder "${folder}" does not exist or is not accessible`));
}
}
if (allowedFolders.length === 0 && !isNonInteractive) { logWarn(chalk.yellow('No folders configured. Set ALLOWED_FOLDERS in .env file or provide a path argument.'));
}
}
if (options.port) {
process.env.PORT = options.port;
}
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
const openaiApiKey = process.env.OPENAI_API_KEY;
const googleApiKey = process.env.GOOGLE_API_KEY;
const hasApiKeys = !!(anthropicApiKey || openaiApiKey || googleApiKey);
if (isNonInteractive) {
if (!hasApiKeys) {
logError(chalk.red('No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY environment variable.'));
process.exit(1);
}
let chat;
try {
chat = new ProbeChat({
sessionId: options.sessionId,
isNonInteractive: true,
customPrompt: customPrompt,
promptType: options.prompt && ['architect', 'code-review', 'support', 'engineer'].includes(options.prompt) ? options.prompt : null,
allowEdit: options.allowEdit
});
logInfo(chalk.blue(`Using Session ID: ${chat.getSessionId()}`)); } catch (error) {
logError(chalk.red(`Initializing chat failed: ${error.message}`));
process.exit(1);
}
const readFromStdin = () => {
return new Promise((resolve) => {
let data = '';
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
resolve(data.trim());
});
});
};
const runNonInteractiveChat = async () => {
try {
let message = options.message;
if (!message && isPipedInput) {
logInfo('Reading message from stdin...'); message = await readFromStdin();
}
if (!message) {
logError('No message provided. Use --message option or pipe input to stdin.');
process.exit(1);
}
logInfo('Sending message...'); const result = await chat.chat(message, chat.getSessionId());
if (result && typeof result === 'object' && result.response !== undefined) {
if (options.json) {
const outputData = {
response: result.response,
sessionId: chat.getSessionId(),
tokenUsage: result.tokenUsage || null };
rawLog(JSON.stringify(outputData, null, 2));
} else {
rawLog(result.response);
}
process.exit(0); } else if (typeof result === 'string') { if (options.json) {
rawLog(JSON.stringify({ response: result, sessionId: chat.getSessionId(), tokenUsage: null }, null, 2));
} else {
rawLog(result);
}
process.exit(0); }
else {
logError('Received an unexpected or empty response structure from chat.');
if (options.json) {
rawError(JSON.stringify({ error: 'Unexpected response structure', response: result, sessionId: chat.getSessionId() }, null, 2));
}
process.exit(1); }
} catch (error) {
logError(`Chat request failed: ${error.message}`);
if (options.json) {
rawError(JSON.stringify({ error: error.message, sessionId: chat.getSessionId() }, null, 2));
}
process.exit(1); }
};
runNonInteractiveChat();
return; }
if (options.web) {
if (!hasApiKeys) {
logWarn(chalk.yellow('Warning: No API key provided. The web interface will show instructions on how to set up API keys.'));
}
import('./webServer.js')
.then(module => {
const { startWebServer } = module;
logInfo(`Starting web server on port ${process.env.PORT || 8080}...`);
startWebServer(version, hasApiKeys, { allowEdit: options.allowEdit });
})
.catch(error => {
logError(chalk.red(`Error starting web server: ${error.message}`));
process.exit(1);
});
return; }
if (!hasApiKeys) {
logError(chalk.red('No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY environment variable.'));
console.log(chalk.cyan('You can find these instructions in the .env.example file:'));
console.log(chalk.cyan('1. Create a .env file by copying .env.example'));
console.log(chalk.cyan('2. Add your API key to the .env file'));
console.log(chalk.cyan('3. Restart the application'));
process.exit(1);
}
let chat;
try {
chat = new ProbeChat({
sessionId: options.sessionId,
isNonInteractive: false,
customPrompt: customPrompt,
promptType: options.prompt && ['architect', 'code-review', 'support', 'engineer'].includes(options.prompt) ? options.prompt : null,
allowEdit: options.allowEdit
});
if (chat.apiType === 'anthropic') {
logInfo(chalk.green(`Using Anthropic API with model: ${chat.model}`));
} else if (chat.apiType === 'openai') {
logInfo(chalk.green(`Using OpenAI API with model: ${chat.model}`));
} else if (chat.apiType === 'google') {
logInfo(chalk.green(`Using Google API with model: ${chat.model}`));
}
logInfo(chalk.blue(`Session ID: ${chat.getSessionId()}`));
logInfo(chalk.cyan('Type "exit" or "quit" to end the chat'));
logInfo(chalk.cyan('Type "usage" to see token usage statistics'));
logInfo(chalk.cyan('Type "clear" to clear the chat history'));
logInfo(chalk.cyan('-------------------------------------------'));
} catch (error) {
logError(chalk.red(`Error initializing chat: ${error.message}`));
process.exit(1);
}
function formatResponseInteractive(response) {
let textResponse = '';
if (response && typeof response === 'object' && 'response' in response) {
textResponse = response.response;
} else if (typeof response === 'string') {
textResponse = response;
} else {
return chalk.red('[Error: Invalid response format]');
}
return textResponse.replace(
/<tool_call>(.*?)<\/tool_call>/gs,
(match, toolCall) => chalk.magenta(`[Tool Call] ${toolCall}`)
);
}
async function startChat() {
while (true) {
const { message } = await inquirer.prompt([
{
type: 'input',
name: 'message',
message: chalk.blue('You:'),
prefix: '',
},
]);
if (message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
logInfo(chalk.yellow('Goodbye!'));
break;
} else if (message.toLowerCase() === 'usage') {
const usage = chat.getTokenUsage();
const display = new TokenUsageDisplay();
const formatted = display.format(usage);
logInfo(chalk.blue('Current:', formatted.current.total));
logInfo(chalk.blue('Context:', formatted.contextWindow));
logInfo(chalk.blue('Cache:',
`Read: ${formatted.current.cache.read},`,
`Write: ${formatted.current.cache.write},`,
`Total: ${formatted.current.cache.total}`));
logInfo(chalk.blue('Total:', formatted.total.total));
process.stdout.write('\x1B]0;Context: ' + formatted.contextWindow + '\x07');
continue;
} else if (message.toLowerCase() === 'clear') {
const newSessionId = chat.clearHistory();
logInfo(chalk.yellow('Chat history cleared'));
logInfo(chalk.blue(`New session ID: ${newSessionId}`));
continue;
}
const spinner = ora('Thinking...').start(); try {
const result = await chat.chat(message); spinner.stop();
logInfo(chalk.green('Assistant:'));
console.log(formatResponseInteractive(result)); console.log();
if (result && typeof result === 'object' && result.tokenUsage && result.tokenUsage.contextWindow) {
process.stdout.write('\x1B]0;Context: ' + result.tokenUsage.contextWindow + '\x07');
}
} catch (error) {
spinner.stop();
logError(chalk.red(`Error: ${error.message}`)); }
}
}
startChat().catch((error) => {
logError(chalk.red(`Fatal error in interactive chat: ${error.message}`));
process.exit(1);
});
}
if (import.meta.url.startsWith('file:') && process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}