|
1 | 1 | /** |
2 | 2 | * CLI subcommands for codebase-context. |
3 | 3 | * Memory list/add/remove — vendor-neutral access without any AI agent. |
| 4 | + * search/metadata/status/reindex/style-guide/patterns/refs/cycles — all MCP tools. |
4 | 5 | */ |
5 | 6 |
|
6 | 7 | import path from 'path'; |
| 8 | +import { promises as fs } from 'fs'; |
7 | 9 | import type { Memory, MemoryCategory, MemoryType } from './types/index.js'; |
8 | | -import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js'; |
| 10 | +import { |
| 11 | + CODEBASE_CONTEXT_DIRNAME, |
| 12 | + MEMORY_FILENAME, |
| 13 | + INTELLIGENCE_FILENAME, |
| 14 | + KEYWORD_INDEX_FILENAME, |
| 15 | + VECTOR_DB_DIRNAME |
| 16 | +} from './constants/codebase-context.js'; |
9 | 17 | import { |
10 | 18 | appendMemoryFile, |
11 | 19 | readMemoriesFile, |
12 | 20 | removeMemory, |
13 | 21 | filterMemories, |
14 | 22 | withConfidence |
15 | 23 | } from './memory/store.js'; |
| 24 | +import { CodebaseIndexer } from './core/indexer.js'; |
| 25 | +import { dispatchTool } from './tools/index.js'; |
| 26 | +import type { ToolContext } from './tools/index.js'; |
| 27 | +import type { IndexState } from './tools/types.js'; |
| 28 | +import { analyzerRegistry } from './core/analyzer-registry.js'; |
| 29 | +import { AngularAnalyzer } from './analyzers/angular/index.js'; |
| 30 | +import { GenericAnalyzer } from './analyzers/generic/index.js'; |
| 31 | + |
| 32 | +analyzerRegistry.register(new AngularAnalyzer()); |
| 33 | +analyzerRegistry.register(new GenericAnalyzer()); |
| 34 | + |
| 35 | +const CLI_COMMANDS = [ |
| 36 | + 'memory', |
| 37 | + 'search', |
| 38 | + 'metadata', |
| 39 | + 'status', |
| 40 | + 'reindex', |
| 41 | + 'style-guide', |
| 42 | + 'patterns', |
| 43 | + 'refs', |
| 44 | + 'cycles' |
| 45 | +] as const; |
| 46 | + |
| 47 | +type CliCommand = (typeof CLI_COMMANDS)[number]; |
| 48 | + |
| 49 | +function printUsage(): void { |
| 50 | + console.log('codebase-context <command> [options]'); |
| 51 | + console.log(''); |
| 52 | + console.log('Commands:'); |
| 53 | + console.log(' memory <list|add|remove> Memory CRUD'); |
| 54 | + console.log( |
| 55 | + ' search --query <q> Search the indexed codebase' |
| 56 | + ); |
| 57 | + console.log(' [--intent explore|edit|refactor|migrate]'); |
| 58 | + console.log(' [--limit <n>] [--lang <l>] [--framework <f>] [--layer <l>]'); |
| 59 | + console.log(' metadata Project structure, frameworks, deps'); |
| 60 | + console.log(' status Index state and progress'); |
| 61 | + console.log(' reindex [--incremental] [--reason <r>] Re-index the codebase'); |
| 62 | + console.log(' style-guide [--query <q>] [--category <c>] Style guide rules'); |
| 63 | + console.log( |
| 64 | + ' patterns [--category all|di|state|testing|libraries] Team patterns' |
| 65 | + ); |
| 66 | + console.log(' refs --symbol <name> [--limit <n>] Symbol references'); |
| 67 | + console.log(' cycles [--scope <path>] Circular dependency detection'); |
| 68 | + console.log(''); |
| 69 | + console.log('Global flags:'); |
| 70 | + console.log(' --json Output raw JSON (default: human-readable)'); |
| 71 | + console.log(' --help Show this help'); |
| 72 | + console.log(''); |
| 73 | + console.log('Environment:'); |
| 74 | + console.log(' CODEBASE_ROOT Project root path (default: cwd)'); |
| 75 | +} |
| 76 | + |
| 77 | +async function initToolContext(): Promise<ToolContext> { |
| 78 | + const rootPath = path.resolve(process.env.CODEBASE_ROOT || process.cwd()); |
| 79 | + |
| 80 | + const paths = { |
| 81 | + baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), |
| 82 | + memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME), |
| 83 | + intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME), |
| 84 | + keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), |
| 85 | + vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) |
| 86 | + }; |
| 87 | + |
| 88 | + // Check if index exists to determine initial status |
| 89 | + let indexExists = false; |
| 90 | + try { |
| 91 | + await fs.access(paths.keywordIndex); |
| 92 | + indexExists = true; |
| 93 | + } catch { |
| 94 | + // no index on disk |
| 95 | + } |
| 96 | + |
| 97 | + const indexState: IndexState = { |
| 98 | + status: indexExists ? 'ready' : 'idle' |
| 99 | + }; |
| 100 | + |
| 101 | + const performIndexing = async (incrementalOnly?: boolean): Promise<void> => { |
| 102 | + indexState.status = 'indexing'; |
| 103 | + const mode = incrementalOnly ? 'incremental' : 'full'; |
| 104 | + console.error(`Indexing (${mode}): ${rootPath}`); |
| 105 | + |
| 106 | + try { |
| 107 | + let lastLoggedProgress = { phase: '', percentage: -1 }; |
| 108 | + const indexer = new CodebaseIndexer({ |
| 109 | + rootPath, |
| 110 | + incrementalOnly, |
| 111 | + onProgress: (progress) => { |
| 112 | + const shouldLog = |
| 113 | + progress.phase !== lastLoggedProgress.phase || |
| 114 | + (progress.percentage % 10 === 0 && |
| 115 | + progress.percentage !== lastLoggedProgress.percentage); |
| 116 | + if (shouldLog) { |
| 117 | + console.error(`[${progress.phase}] ${progress.percentage}%`); |
| 118 | + lastLoggedProgress = { phase: progress.phase, percentage: progress.percentage }; |
| 119 | + } |
| 120 | + } |
| 121 | + }); |
| 122 | + |
| 123 | + indexState.indexer = indexer; |
| 124 | + const stats = await indexer.index(); |
| 125 | + indexState.status = 'ready'; |
| 126 | + indexState.lastIndexed = new Date(); |
| 127 | + indexState.stats = stats; |
| 128 | + |
| 129 | + console.error( |
| 130 | + `Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${( |
| 131 | + stats.duration / 1000 |
| 132 | + ).toFixed(2)}s` |
| 133 | + ); |
| 134 | + } catch (error) { |
| 135 | + indexState.status = 'error'; |
| 136 | + indexState.error = error instanceof Error ? error.message : String(error); |
| 137 | + console.error('Indexing failed:', indexState.error); |
| 138 | + } |
| 139 | + }; |
| 140 | + |
| 141 | + return { indexState, paths, rootPath, performIndexing }; |
| 142 | +} |
| 143 | + |
| 144 | +function extractText(result: { content?: Array<{ type: string; text: string }> }): string { |
| 145 | + return result.content?.[0]?.text ?? ''; |
| 146 | +} |
| 147 | + |
| 148 | +function formatJson(json: string, useJson: boolean): void { |
| 149 | + if (useJson) { |
| 150 | + console.log(json); |
| 151 | + return; |
| 152 | + } |
| 153 | + // Pretty-print already-formatted JSON as-is (it's already readable) |
| 154 | + console.log(json); |
| 155 | +} |
| 156 | + |
| 157 | +export async function handleCliCommand(argv: string[]): Promise<void> { |
| 158 | + const command = argv[0] as CliCommand | '--help' | undefined; |
| 159 | + |
| 160 | + if (!command || command === '--help') { |
| 161 | + printUsage(); |
| 162 | + return; |
| 163 | + } |
| 164 | + |
| 165 | + if (command === 'memory') { |
| 166 | + return handleMemoryCli(argv.slice(1)); |
| 167 | + } |
| 168 | + |
| 169 | + const useJson = argv.includes('--json'); |
| 170 | + |
| 171 | + // Parse flags into a map |
| 172 | + const flags: Record<string, string | boolean> = {}; |
| 173 | + for (let i = 1; i < argv.length; i++) { |
| 174 | + const arg = argv[i]; |
| 175 | + if (arg === '--json') continue; |
| 176 | + if (arg.startsWith('--')) { |
| 177 | + const key = arg.slice(2); |
| 178 | + const next = argv[i + 1]; |
| 179 | + if (next && !next.startsWith('--')) { |
| 180 | + flags[key] = next; |
| 181 | + i++; |
| 182 | + } else { |
| 183 | + flags[key] = true; |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + const ctx = await initToolContext(); |
| 189 | + |
| 190 | + let toolName: string; |
| 191 | + let toolArgs: Record<string, unknown> = {}; |
| 192 | + |
| 193 | + switch (command) { |
| 194 | + case 'search': { |
| 195 | + if (!flags['query']) { |
| 196 | + console.error('Error: --query is required'); |
| 197 | + console.error('Usage: codebase-context search --query <text> [--intent <i>] [--limit <n>]'); |
| 198 | + process.exit(1); |
| 199 | + } |
| 200 | + toolName = 'search_codebase'; |
| 201 | + toolArgs = { |
| 202 | + query: flags['query'], |
| 203 | + ...(flags['intent'] ? { intent: flags['intent'] } : {}), |
| 204 | + ...(flags['limit'] ? { limit: Number(flags['limit']) } : {}), |
| 205 | + ...(flags['lang'] ? { filters: { language: flags['lang'] } } : {}), |
| 206 | + ...(flags['framework'] ? { filters: { framework: flags['framework'] } } : {}), |
| 207 | + ...(flags['layer'] ? { filters: { layer: flags['layer'] } } : {}) |
| 208 | + }; |
| 209 | + break; |
| 210 | + } |
| 211 | + case 'metadata': { |
| 212 | + toolName = 'get_codebase_metadata'; |
| 213 | + break; |
| 214 | + } |
| 215 | + case 'status': { |
| 216 | + toolName = 'get_indexing_status'; |
| 217 | + break; |
| 218 | + } |
| 219 | + case 'reindex': { |
| 220 | + toolName = 'refresh_index'; |
| 221 | + toolArgs = { |
| 222 | + ...(flags['incremental'] ? { incrementalOnly: true } : {}), |
| 223 | + ...(flags['reason'] ? { reason: flags['reason'] } : {}) |
| 224 | + }; |
| 225 | + // For CLI, reindex must be awaited (fire-and-forget won't work in a process that exits) |
| 226 | + await ctx.performIndexing(Boolean(flags['incremental'])); |
| 227 | + const statusResult = await dispatchTool('get_indexing_status', {}, ctx); |
| 228 | + formatJson(extractText(statusResult), useJson); |
| 229 | + return; |
| 230 | + } |
| 231 | + case 'style-guide': { |
| 232 | + toolName = 'get_style_guide'; |
| 233 | + toolArgs = { |
| 234 | + ...(flags['query'] ? { query: flags['query'] } : {}), |
| 235 | + ...(flags['category'] ? { category: flags['category'] } : {}) |
| 236 | + }; |
| 237 | + break; |
| 238 | + } |
| 239 | + case 'patterns': { |
| 240 | + toolName = 'get_team_patterns'; |
| 241 | + toolArgs = { |
| 242 | + ...(flags['category'] ? { category: flags['category'] } : {}) |
| 243 | + }; |
| 244 | + break; |
| 245 | + } |
| 246 | + case 'refs': { |
| 247 | + if (!flags['symbol']) { |
| 248 | + console.error('Error: --symbol is required'); |
| 249 | + console.error('Usage: codebase-context refs --symbol <name> [--limit <n>]'); |
| 250 | + process.exit(1); |
| 251 | + } |
| 252 | + toolName = 'get_symbol_references'; |
| 253 | + toolArgs = { |
| 254 | + symbol: flags['symbol'], |
| 255 | + ...(flags['limit'] ? { limit: Number(flags['limit']) } : {}) |
| 256 | + }; |
| 257 | + break; |
| 258 | + } |
| 259 | + case 'cycles': { |
| 260 | + toolName = 'detect_circular_dependencies'; |
| 261 | + toolArgs = { |
| 262 | + ...(flags['scope'] ? { scope: flags['scope'] } : {}) |
| 263 | + }; |
| 264 | + break; |
| 265 | + } |
| 266 | + default: { |
| 267 | + console.error(`Unknown command: ${command}`); |
| 268 | + console.error(''); |
| 269 | + printUsage(); |
| 270 | + process.exit(1); |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + try { |
| 275 | + const result = await dispatchTool(toolName, toolArgs, ctx); |
| 276 | + if (result.isError) { |
| 277 | + console.error(extractText(result)); |
| 278 | + process.exit(1); |
| 279 | + } |
| 280 | + formatJson(extractText(result), useJson); |
| 281 | + } catch (error) { |
| 282 | + console.error('Error:', error instanceof Error ? error.message : String(error)); |
| 283 | + process.exit(1); |
| 284 | + } |
| 285 | +} |
16 | 286 |
|
17 | 287 | export async function handleMemoryCli(args: string[]): Promise<void> { |
18 | 288 | // Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path) |
|
0 commit comments