cli.ts•11.8 kB
import readline from 'readline';
import type { SearchResult, SearchResultsPage, ExtractedContent, ContentMatch, Platform } from './types.js';
import type { LLMResearcher } from './index.js';
import { GitHubCodeSearcher } from './github-code-search.js';
export class CLIInterface {
private researcher: LLMResearcher;
public currentResults: SearchResult[] = [];
public currentPage: SearchResultsPage | null = null;
private rl: readline.Interface | null = null;
constructor(researcher: LLMResearcher) {
this.researcher = researcher;
}
private setupReadline(): void {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
private closeReadline(): void {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
displayResults(page: SearchResultsPage): void {
console.log(`\n🔍 Search Results (Page ${page.currentPage}/${page.totalPages}) - ${this.researcher.getSearcherType()}:`);
console.log(`📊 Total Results: ~${page.totalResults} | Showing: ${page.results.length} results`);
page.results.forEach((result, index) => {
console.log(`\n${index + 1}. ${result.title}`);
console.log(` URL: ${result.url}`);
if (result.snippet) {
console.log(` ${result.snippet}`);
}
});
// Build pagination commands
const commands = [`[1-${page.results.length}] select result`];
if (this.researcher.searcher.hasPreviousPage(page)) {
commands.push('p) previous page');
}
if (this.researcher.searcher.hasNextPage(page)) {
commands.push('n) next page');
}
commands.push('b) back to search', 'q) quit', 'open <n>) open in browser');
console.log(`\nCommands: ${commands.join(' | ')}`);
}
displayContent(content: ExtractedContent): void {
console.log('\n📄 Content:');
console.log(`\n**${content.title}**`);
console.log(`Source: ${content.url}`);
console.log(`Extracted: ${new Date(content.extractedAt).toLocaleString()}`);
console.log('\n---');
console.log(content.content);
console.log('\nCommands: b) back to results | /<term>) search in text | q) quit | open) open in browser');
}
private async prompt(message: string): Promise<string> {
return new Promise(resolve => {
this.rl!.question(message, resolve);
});
}
async handleSearchMode(query: string, page = 1): Promise<void> {
try {
console.log(`\n🔍 Searching for: "${query}" (Page ${page})`);
// Convert page number to nextToken for GitHub searcher
const nextToken = page > 1 ? page.toString() : undefined;
const searchPage = await this.researcher.search(query, nextToken, undefined);
if (searchPage.results.length === 0) {
console.log('No results found.');
return;
}
this.currentResults = searchPage.results;
this.currentPage = searchPage;
this.displayResults(searchPage);
await this.handleResultSelection();
} catch (error) {
console.error('Search failed:', (error as Error).message);
}
}
async handleResultSelection(): Promise<void> {
this.setupReadline();
while (true) {
const input = await this.prompt('\n> ');
const command = input.trim().toLowerCase();
if (command === 'q' || command === 'quit') {
break;
} else if (command === 'b' || command === 'back') {
// Go back to main search prompt
break;
} else if (command === 'n' || command === 'next') {
if (this.currentPage && this.researcher.searcher.hasNextPage(this.currentPage)) {
try {
const nextPage = await this.researcher.searcher.getNextPage(this.currentPage);
this.currentResults = nextPage.results;
this.currentPage = nextPage;
this.displayResults(nextPage);
} catch (error) {
console.log('Failed to load next page:', (error as Error).message);
}
} else {
console.log('No next page available.');
}
} else if (command === 'p' || command === 'prev' || command === 'previous') {
if (this.currentPage && this.researcher.searcher.hasPreviousPage(this.currentPage)) {
try {
const prevPage = await this.researcher.searcher.getPreviousPage(this.currentPage);
this.currentResults = prevPage.results;
this.currentPage = prevPage;
this.displayResults(prevPage);
} catch (error) {
console.log('Failed to load previous page:', (error as Error).message);
}
} else {
console.log('No previous page available.');
}
} else if (command.startsWith('open ')) {
const index = parseInt(command.split(' ')[1] || '') - 1;
if (index >= 0 && index < this.currentResults.length) {
await this.openInBrowser(this.currentResults[index]!.url);
} else {
console.log('Invalid result number.');
}
} else {
const index = parseInt(command) - 1;
if (index >= 0 && index < this.currentResults.length) {
await this.handleContentView(this.currentResults[index]!);
} else {
console.log('Invalid selection. Please enter a number 1-' + this.currentResults.length);
}
}
}
this.closeReadline();
}
private async handleContentView(result: SearchResult): Promise<void> {
try {
console.log(`\n📥 Extracting content from: ${result.title}`);
const content = await this.researcher.extractFromUrl(result.url, false);
this.displayContent(content);
await this.handleContentCommands(content);
} catch (error) {
console.error('Content extraction failed:', (error as Error).message);
console.log('Press Enter to return to results...');
await this.prompt('');
}
}
async handleContentCommands(content: ExtractedContent): Promise<string | void> {
while (true) {
const input = await this.prompt('\n> ');
const command = input.trim();
if (command.toLowerCase() === 'q' || command.toLowerCase() === 'quit') {
return 'quit';
} else if (command.toLowerCase() === 'b' || command.toLowerCase() === 'back') {
if (this.currentPage) {
this.displayResults(this.currentPage);
}
return;
} else if (command.toLowerCase() === 'open') {
await this.openInBrowser(content.url);
} else if (command.startsWith('/')) {
const searchTerm = command.slice(1);
this.searchInContent(content, searchTerm);
} else {
console.log('Unknown command. Available: b) back | /<term>) search | open) open in browser | q) quit');
}
}
}
private searchInContent(content: ExtractedContent, searchTerm: string): void {
if (!searchTerm) {
console.log('Please provide a search term after /');
return;
}
const lines = content.content.split('\n');
const matches: ContentMatch[] = [];
lines.forEach((line, index) => {
if (line.toLowerCase().includes(searchTerm.toLowerCase())) {
matches.push({ lineNumber: index + 1, content: line.trim() });
}
});
if (matches.length === 0) {
console.log(`No matches found for "${searchTerm}"`);
} else {
console.log(`\n🔍 Found ${matches.length} matches for "${searchTerm}":`)
matches.forEach(match => {
console.log(`Line ${match.lineNumber}: ${match.content}`);
});
}
}
private async openInBrowser(url: string): Promise<void> {
const { exec } = await import('child_process');
const platform = process.platform as Platform;
let command: string;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "${url}"`;
} else {
command = `xdg-open "${url}"`;
}
exec(command, (error) => {
if (error) {
console.log(`Failed to open browser: ${error.message}`);
console.log(`Please open manually: ${url}`);
} else {
console.log('Opened in browser');
}
});
}
async interactive(): Promise<void> {
console.log('🔬 LLM Researcher - Interactive Mode');
console.log(`Current search engine: ${this.researcher.getSearcherType()}`);
console.log('Enter search queries or commands. Type "help" for help, "quit" to exit.\n');
this.setupReadline();
while (true) {
const input = await this.prompt('Search> ');
const command = input.trim();
if (command.toLowerCase() === 'quit' || command.toLowerCase() === 'q') {
break;
} else if (command.toLowerCase() === 'help') {
this.showHelp();
} else if (command.toLowerCase() === 'engine') {
console.log(`Current search engine: ${this.researcher.getSearcherType()}`);
} else if (command.toLowerCase() === 'ddg') {
this.researcher.useDuckDuckGoSearch();
console.log(`Switched to DuckDuckGo search`);
} else if (command.toLowerCase() === 'github') {
this.researcher.useGitHubCodeSearch();
console.log(`Switched to GitHub Code Search`);
} else if (command.toLowerCase() === 'examples') {
this.showGitHubExamples();
} else if (command) {
await this.handleSearchMode(command);
}
}
this.closeReadline();
}
private showHelp(): void {
console.log(`
🔬 LLM Researcher Help
Search Commands:
<query> Search using current search engine (${this.researcher.getSearcherType()})
help Show this help
quit, q Exit the program
Search Engine Commands:
github Switch to GitHub Code Search
ddg Switch to DuckDuckGo search
engine Show current search engine
examples Show GitHub Code Search query examples
Result Navigation:
1-N Select a result by number (N = number of results shown)
b, back Return to search results
open <n> Open result #n in external browser
q, quit Exit the program
Content Navigation:
b, back Return to search results
/<term> Search for term within the content
open Open current page in external browser
q, quit Exit the program
Direct URL Mode:
llmresearcher -u <url> Extract content directly from URL
llmresearcher -g <query> Search GitHub code
llmresearcher -g --language js <query> Search JavaScript code
llmresearcher -g --repo owner/repo <query> Search in specific repository
llmresearcher -v <query> Enable verbose logging
`);
}
private showGitHubExamples(): void {
console.log(`
🔍 GitHub Code Search Query Examples:
Basic Usage:
addClass language:js repo:jquery/jquery
useState in:file extension:tsx
function language:python user:octocat
console.log extension:js org:facebook
Advanced Queries:
import filename:package.json
class size:>1000 language:java
TODO in:file path:src/
async/await language:javascript created:>2020-01-01
Query Syntax:
language:LANG Filter by programming language
extension:EXT Filter by file extension
repo:OWNER/NAME Search in specific repository
user:USERNAME Search in user's repositories
org:ORGNAME Search in organization's repositories
filename:NAME Search in files with specific name
path:PATH Search in specific path
size:>1000 Filter by file size
in:file Search file contents
in:path Search file paths
Examples you can try right now:
> useState language:typescript
> import React extension:tsx
> console.log repo:facebook/react
`);
}
}