UNPKG

context7

Version:

CLI to query the Context7 API

458 lines (430 loc) 16.2 kB
#!/usr/bin/env node import yargs from 'yargs/yargs'; import { hideBin } from "yargs/helpers" import chalk from "chalk" import ora from "ora" import fs from "fs/promises" // --- Configuration --- const API_BASE_URL = "https://context7.com" const PROJECTS_API_URL = `${API_BASE_URL}/api/projects` const USER_AGENT = "c7-cli/1.0.3" // Increment version example // --- Helper Functions --- function formatJsonOutput(data) { /* ... as before ... */ return JSON.stringify(data, null, 2) } function printHeaderFooter(isHeader = true) { /* ... as before ... */ let termWidth = process.stdout.columns || 80 termWidth = Math.max(4, termWidth) const text = isHeader ? " Context7 CLI " : " Data provided by Context7 API " const textLength = text.length let paddingLength = Math.floor((termWidth - textLength) / 2) - 1 paddingLength = Math.max(0, paddingLength) const padding = " ".repeat(paddingLength) const totalInnerLength = padding.length * 2 + textLength let rightPaddingLength = termWidth - totalInnerLength - 2 rightPaddingLength = Math.max(0, rightPaddingLength) const rightPadding = " ".repeat(rightPaddingLength) const line = "─".repeat(termWidth > 2 ? termWidth - 2 : 0) const border = chalk.blueBright.bold const footerBorderStyle = chalk.dim.blue if (isHeader) { console.log(border(`┌${line}┐`)) const content = `${padding}${text}${rightPadding}` const safeContent = content.slice(0, termWidth - 2) const finalPadding = " ".repeat( Math.max(0, termWidth - 2 - safeContent.length) ) console.log(border(`│${safeContent}${finalPadding}│`)) console.log(border(`└${line}┘`)) } else { console.log(footerBorderStyle(`\n┌${line}┐`)) const content = `${padding}${text}${rightPadding}` const safeContent = content.slice(0, termWidth - 2) const finalPadding = " ".repeat( Math.max(0, termWidth - 2 - safeContent.length) ) console.log(footerBorderStyle(`│${safeContent}${finalPadding}│`)) console.log(footerBorderStyle(`└${line}┘\n`)) } } // Fetches the full list of projects (cached within a single run) let projectListCache = null async function fetchProjects() { if (projectListCache) { return projectListCache } // console.error(chalk.dim("Fetching project list...")); // Optional debug log try { const response = await fetch(PROJECTS_API_URL, { headers: { "User-Agent": USER_AGENT } }) if (!response.ok) { throw new Error( `Failed to fetch projects: ${response.status} ${response.statusText}` ) } const data = await response.json() if (!Array.isArray(data)) { throw new Error("Invalid project list format received from API.") } projectListCache = data return projectListCache } catch (error) { projectListCache = null // Clear cache on error console.error("Error fetching projects:", error) throw error } } // --- Project Path Resolution Function (NEW) --- async function resolveProjectPath(identifier) { if ( !identifier || typeof identifier !== "string" || identifier.trim() === "" ) { throw new Error("Project identifier cannot be empty.") } const projects = await fetchProjects() const trimmedIdentifier = identifier.trim() // 1. Check if it's already the full path format '/org/repo' if (trimmedIdentifier.startsWith("/") && trimmedIdentifier.includes("/")) { const exactMatch = projects.find( p => p?.settings?.project === trimmedIdentifier ) if (exactMatch) { return trimmedIdentifier // Found exact match } // If not found, fall through to other checks in case of typo or similar } // 2. Check if it's 'org/repo' format (missing leading slash) if (!trimmedIdentifier.startsWith("/") && trimmedIdentifier.includes("/")) { const potentialPath = `/${trimmedIdentifier}` const exactMatch = projects.find( p => p?.settings?.project === potentialPath ) if (exactMatch) { return potentialPath // Found exact match by adding slash } // If not found, continue to repo-only check } // 3. Check if it's just 'repo' format (and potentially ambiguous) const repoNameOnly = trimmedIdentifier.includes("/") ? trimmedIdentifier.substring(trimmedIdentifier.lastIndexOf("/") + 1) : trimmedIdentifier const possibleMatches = projects.filter( // Ensure it's the new format p => p?.settings?.project?.endsWith(`/${repoNameOnly}`) && p?.settings?.project?.includes("/") ) if (possibleMatches.length === 1) { return possibleMatches[0].settings.project // Found unique match by repo name } if (possibleMatches.length > 1) { // Ambiguous! List options for the user. const options = possibleMatches .map(p => ` - ${p.settings.project} (${p.settings.title})`) .join("\n") throw new Error( `Ambiguous project identifier "${identifier}". Found multiple matches:\n${options}\nPlease use a more specific path (e.g., /org/repo or org/repo).` ) } // 4. Not Found after all checks throw new Error( `Could not resolve project identifier "${identifier}" to a valid project path. Use 'c7 search <term>' to find projects.` ) } // --- Search Function (MODIFIED) --- async function handleSearch(searchTerm) { printHeaderFooter(true) const spinner = ora({ text: chalk.cyan(`Searching for projects matching "${searchTerm}"...`), spinner: "dots" }).start() try { const projects = await fetchProjects() // Use helper to potentially get cached list spinner.text = chalk.cyan("Filtering results...") const lowerSearchTerm = searchTerm.toLowerCase() const matches = projects .filter( // MUST include '/' (new format) p => (p?.settings?.title?.toLowerCase().includes(lowerSearchTerm) || p?.settings?.project?.toLowerCase().includes(lowerSearchTerm)) && // Match title OR path p?.settings?.project?.includes("/") ) .map(p => ({ title: p.settings.title, name: p.settings.project // The usable name is the full path })) // Sort matches alphabetically by title for better readability matches.sort((a, b) => a.title.localeCompare(b.title)) if (matches.length > 0) { spinner.succeed( chalk.green(`Found ${matches.length} matching project(s):`) ) console.log( chalk.yellow( '\n--- Search Results (Use "Project Path" for queries) ---' ) ) matches.forEach(m => console.log( chalk.magenta(` - ${m.title}`) + chalk.dim(` (Project Path: ${chalk.cyan(m.name)})`) ) ) console.log( chalk.yellow( "--------------------------------------------------------\n" ) ) console.log( chalk.cyan(`Example query: c7 "${matches[0].name}" <your query>`) ) // Quote path in example } else { spinner.info( chalk.yellow( `No projects found matching "${searchTerm}" with format /org/repo.` ) ) } printHeaderFooter(false) } catch (error) { spinner.fail(chalk.red("Error during search!")) console.error(chalk.red(error.message)) printHeaderFooter(false) process.exit(1) } } // --- Query Function (MODIFIED) --- async function handleQuery( projectIdentifier, query, format, saveToFile, maxTokens ) { // Now takes user input identifier let resolvedPath = "" const spinner = ora( chalk.cyan(`Resolving project "${projectIdentifier}"...`) ).start() try { resolvedPath = await resolveProjectPath(projectIdentifier) // Resolve first spinner.text = chalk.cyan( `Fetching data for project "${resolvedPath}" on topic "${query}"...` ) // Use resolved path in messages const encodedQuery = encodeURIComponent(query) let apiUrl = `${API_BASE_URL}${resolvedPath}/llms.${format}?topic=${encodedQuery}` if (maxTokens !== undefined && maxTokens !== null) { if (typeof maxTokens === "number" && maxTokens > 0) { apiUrl += `&tokens=${maxTokens}` } else if ( process.argv.includes("--tokens") || process.argv.includes("-k") ) { spinner.warn( chalk.yellow( `Invalid --tokens value ignored. Must be a positive number.` ) ) } } spinner.text = chalk.cyan(`Querying: ${apiUrl}`) const response = await fetch(apiUrl, { headers: { "User-Agent": USER_AGENT } }) if (!response.ok) { spinner.fail( chalk.red( `API request failed: ${response.status} ${response.statusText}` ) ) try { const errorBody = await response.text() console.error(chalk.red("Error details:", errorBody)) } catch (e) { /* ignore */ } printHeaderFooter(false) process.exit(1) } let data if (format === "json") { data = await response.json() } else { data = await response.text() } if (saveToFile) { const sanitizedPath = (resolvedPath.startsWith("/") ? resolvedPath.substring(1) : resolvedPath ).replace(/\//g, "_") const filename = `llms_${sanitizedPath}.${format}` const contentToWrite = format === "json" ? formatJsonOutput(data) : data await fs.writeFile(filename, contentToWrite) spinner.succeed( chalk.green( `Successfully saved data for "${resolvedPath}" to ${chalk.cyan( filename )}!` ) ) } else { spinner.succeed( chalk.green(`Successfully fetched data for "${resolvedPath}"!`) ) console.log( chalk.bold(`\n--- Query Result (${format.toUpperCase()}) ---`) ) if (format === "json") { console.log(chalk.magenta(formatJsonOutput(data))) } else { console.log(chalk.white(data)) } } printHeaderFooter(false) } catch (error) { // Handle errors from resolveProjectPath OR the fetch/save process spinner.fail(chalk.red(`Error: ${error.message}`)) // console.error(chalk.red(error.stack)); // Optional: more detailed stack trace printHeaderFooter(false) process.exit(1) } } // --- Info Function (MODIFIED) --- async function handleInfo(projectIdentifier) { // Now takes user input identifier let resolvedPath = "" const spinner = ora( chalk.cyan(`Resolving project "${projectIdentifier}"...`) ).start() try { resolvedPath = await resolveProjectPath(projectIdentifier) // Resolve first spinner.text = chalk.cyan(`Fetching info for project "${resolvedPath}"...`) // We need the project list again to find the details for the resolved path // Could optimize by having resolveProjectPath return the project object too const projects = await fetchProjects() const projectInfo = projects.find( p => p?.settings?.project === resolvedPath ) if (projectInfo) { spinner.succeed(chalk.green(`Found info for "${resolvedPath}":`)) const s = projectInfo.settings || {} const v = projectInfo.version || {} console.log(chalk.yellow("\n--- Project Information ---")) console.log( `${chalk.blueBright.bold("Title:")} ${chalk.white( s.title || "N/A" )}` ) console.log( `${chalk.blueBright.bold("Project Path:")} ${chalk.cyan(s.project)}` ) console.log( `${chalk.blueBright.bold("Docs Source:")} ${chalk.green( s.docsRepoUrl || "N/A" )}` ) console.log( `${chalk.blueBright.bold("Branch:")} ${chalk.white( s.branch || "N/A" )}` ) console.log(chalk.yellow("--- Processing Version ---")) console.log( `${chalk.blueBright.bold("State:")} ${chalk.white( v.state || "N/A" )}` ) console.log( `${chalk.blueBright.bold("Last Update:")} ${chalk.white( v.lastUpdate || "N/A" )}` ) console.log( `${chalk.blueBright.bold("Total Pages:")} ${chalk.white( v.totalPages ?? "N/A" )}` ) console.log( `${chalk.blueBright.bold("Total Snippets:")} ${chalk.white( v.totalSnippets ?? "N/A" )}` ) console.log( `${chalk.blueBright.bold("Errors Count:")} ${chalk.white( v.errorCount ?? "N/A" )}` ) if (Array.isArray(s.excludeFolders) && s.excludeFolders.length > 0) { console.log(chalk.yellow("--- Excluded Folders ---")) s.excludeFolders.forEach(folder => console.log(chalk.dim(` - ${folder}`)) ) } console.log(chalk.yellow("-------------------------\n")) } else { // This case *shouldn't* happen if resolveProjectPath worked, but good to keep spinner.fail( chalk.red( `Internal error: Could not find details for resolved path "${resolvedPath}".` ) ) } printHeaderFooter(false) } catch (error) { // Handle errors from resolveProjectPath OR fetchProjects spinner.fail(chalk.red(`Error: ${error.message}`)) // console.error(chalk.red(error.stack)); // Optional: more detailed stack trace printHeaderFooter(false) process.exit(1) } } // --- Main Execution --- yargs(hideBin(process.argv)) // --- Default Command (Query) (MODIFIED) --- .command( '$0 <projectIdentifier> [query...]', 'Query Context7 API using a project path or name', // Slightly broader description (yargs) => { yargs .positional('projectIdentifier', { // Updated description: describe: chalk.green('Project path (/org/repo), partial path (org/repo), or unique name (repo). Use "c7 search <term>" to find valid identifiers.'), type: 'string' }) .positional('query', { describe: chalk.green('Topic/question to query'), type: 'array' }) .option('type', { alias: 't', describe: chalk.yellow('Output format'), choices: ['txt', 'json'], default: 'txt', type: 'string' }) .option('save', { alias: 's', describe: chalk.yellow('Save output to file'), type: 'boolean', default: false }) .option('tokens', { alias: 'k', describe: chalk.yellow('Max tokens'), type: 'number', default: 5000 }) .check((argv) => { if (!argv.query || argv.query.length === 0) { throw new Error(chalk.red('Missing query.')); } return true; }); }, async (argv) => { await handleQuery(argv.projectIdentifier, argv.query.join(' '), argv.type, argv.save, argv.tokens); } ) // --- Search Command --- .command( 'search <term>', 'Search for projects and their paths', // Slightly updated description (yargs) => { yargs.positional('term', { describe: chalk.green('Keyword for title/path'), type: 'string' }); }, async (argv) => { await handleSearch(argv.term); } ) // --- Info Command (MODIFIED) --- .command( 'info <projectIdentifier>', // Argument name reflects flexibility 'Display metadata about a specific project', // Kept description simple (yargs) => { yargs.positional('projectIdentifier', { // Updated argument name // Updated description: describe: chalk.green('Project path (/org/repo), partial path (org/repo), or unique name (repo). Use "c7 search <term>" to find valid identifiers.'), type: 'string' }); }, async (argv) => { await handleInfo(argv.projectIdentifier); } // Pass identifier ) // --- Global Options & Settings --- .demandCommand(1, chalk.yellow('Specify command (search, info) or query params.')) .alias('h', 'help').alias('v', 'version').strict().wrap(process.stdout.columns) .fail((msg, err, yargsInstance) => { /* ... fail handler ... */ console.error(chalk.red.bold('\n❌ Error!')); if (msg) { console.error(chalk.red(msg)); } else if (err) { console.error(chalk.red(err.message)); } const scriptName = yargsInstance?.$0 || 'c7'; console.error(chalk.yellow(`\nRun "${scriptName} --help" for usage information.`)); printHeaderFooter(false); process.exit(1); }) .parse();