diff --git a/package-lock.json b/package-lock.json index bd0029f..910a6c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@clack/prompts": "^1.2.0", "@xenova/transformers": "^2.17.0", "commander": "^14.0.2", - "figlet": "^1.8.0", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", "tree-sitter-wasms": "^0.1.11", @@ -23,7 +23,6 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", - "@types/figlet": "^1.5.8", "@types/node": "^20.19.30", "@types/picomatch": "^4.0.2", "typescript": "^5.0.0", @@ -37,6 +36,28 @@ "sqlite-vss": "^0.1.2" } }, + "node_modules/@clack/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", + "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", + "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.2.0", + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -875,13 +896,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/figlet": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@types/figlet/-/figlet-1.7.0.tgz", - "integrity": "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -1476,19 +1490,28 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/figlet": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.10.0.tgz", - "integrity": "sha512-aktIwEZZ6Gp9AWdMXW4YCi0J2Ahuxo67fNJRUIWD81w8pQ0t9TS8FFpbl27ChlTLF06VkwjDesZSzEVzN75rzA==", + "node_modules/fast-string-truncated-width": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", + "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", "license": "MIT", "dependencies": { - "commander": "^14.0.0" - }, - "bin": { - "figlet": "bin/index.js" - }, - "engines": { - "node": ">= 17.0.0" + "fast-string-truncated-width": "^1.2.0" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", + "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^1.1.0" } }, "node_modules/file-uri-to-path": { @@ -2082,6 +2105,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 36c03fd..cff7b65 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,9 @@ "author": "", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.2.0", "@xenova/transformers": "^2.17.0", "commander": "^14.0.2", - "figlet": "^1.8.0", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", "tree-sitter-wasms": "^0.1.11", @@ -44,7 +44,6 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", - "@types/figlet": "^1.5.8", "@types/node": "^20.19.30", "@types/picomatch": "^4.0.2", "typescript": "^5.0.0", diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 31a110b..ef6f230 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -45,6 +45,12 @@ async function loadCodeGraph(): Promise { type IndexProgress = import('../index').IndexProgress; +// Dynamic import helper — tsc compiles import() to require() in CJS mode, +// which fails for ESM-only packages. This bypasses the transformation. +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const importESM = new Function('specifier', 'return import(specifier)') as + (specifier: string) => Promise; + // Check if running with no arguments - run installer if (process.argv.length === 2) { import('../installer').then(({ runInstaller }) => @@ -168,59 +174,140 @@ function formatDuration(ms: number): string { return `${minutes}m ${remainingSeconds.toFixed(0)}s`; } -/** - * Create a progress bar string - */ -function progressBar(current: number, total: number, width: number = 30): string { - const percent = total > 0 ? current / total : 0; - const filled = Math.round(width * percent); - const empty = width - filled; - const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty)); - const percentStr = `${Math.round(percent * 100)}%`.padStart(4); - return `${bar} ${percentStr}`; +// ============================================================================= +// Shimmer Progress Renderer (devpit-style animation) +// ============================================================================= + +const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽']; +const ANIM_INTERVAL = 150; +const FRAMES_PER_GLYPH = 3; + +function lerp(a: number, b: number, t: number): number { + return Math.round(a + (b - a) * t); } -/** - * Truncate a string to fit a given visible width, adding ellipsis if needed - */ -function truncate(str: string, maxWidth: number): string { - if (maxWidth <= 0) return ''; - if (str.length <= maxWidth) return str; - if (maxWidth <= 1) return str.charAt(0); - return '\u2026' + str.slice(-(maxWidth - 1)); +function shimmerColor(frame: number): string { + const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2; + const r = lerp(160, 251, t); + const g = lerp(100, 191, t); + const b = lerp(9, 36, t); + return `\x1b[38;2;${r};${g};${b}m\x1b[1m`; } -/** - * Print a progress update (overwrites current line) - */ -function printProgress(progress: IndexProgress): void { - const phaseNames: Record = { - scanning: 'Scanning files', - parsing: 'Parsing code', - storing: 'Storing data', - resolving: 'Resolving refs', - }; +const PHASE_NAMES: Record = { + scanning: 'Scanning files', + parsing: 'Parsing code', + storing: 'Storing data', + resolving: 'Resolving refs', +}; - const phaseName = phaseNames[progress.phase] || progress.phase; - const cols = process.stdout.columns || 80; +interface ShimmerProgress { + onProgress: (progress: IndexProgress) => void; + stop: () => void; +} - if (progress.total > 0) { - const bar = progressBar(progress.current, progress.total); - // "Phase: [bar] XX% filename" — calculate space left for filename - // phaseName + ": " = phaseName.length + 2, bar visible = 30 + 1 + 4 = 35, space before file = 1 - const prefixWidth = phaseName.length + 2 + 35; - const fileMaxWidth = cols - prefixWidth - 1; - const file = progress.currentFile ? chalk.dim(` ${truncate(progress.currentFile, fileMaxWidth)}`) : ''; - process.stdout.write(`\r${chalk.cyan(phaseName)}: ${bar}${file}\x1b[K`); - } else { - // No known total (e.g. scanning) — show a running count - const countStr = progress.current > 0 ? ` ${formatNumber(progress.current)} found` : ''; - const prefixWidth = phaseName.length + 1 + countStr.length; - const fileMaxWidth = cols - prefixWidth - 1; - const file = progress.currentFile ? chalk.dim(` ${truncate(progress.currentFile, fileMaxWidth)}`) : ''; - const count = progress.current > 0 ? ` ${chalk.green(formatNumber(progress.current))} found` : ''; - process.stdout.write(`\r${chalk.cyan(phaseName)}:${count}${file}\x1b[K`); +function createShimmerProgress(): ShimmerProgress { + const startTime = Date.now(); + let interval: ReturnType | null = null; + let currentMessage = ''; + let currentPercent = -1; + let currentCount = 0; + let lastPhase = ''; + + function animFrame(): number { + return Math.floor((Date.now() - startTime) / ANIM_INTERVAL); } + + function spinnerGlyph(): string { + const idx = Math.floor(animFrame() / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length; + return SPINNER_GLYPHS[idx] ?? '·'; + } + + function renderBar(filled: number, empty: number): string { + if (filled === 0) return `${colors.dim}${'░'.repeat(empty)}${colors.reset}`; + // Shimmer sweeps left-to-right across the filled portion + const cycleFrames = 24; + const shimmerPos = ((animFrame() % cycleFrames) / cycleFrames) * (filled + 6) - 3; + const shimmerWidth = 3; + let bar = ''; + for (let i = 0; i < filled; i++) { + const dist = Math.abs(i - shimmerPos); + const t = Math.max(0, 1 - dist / shimmerWidth); + const r = lerp(160, 251, t); + const g = lerp(100, 191, t); + const b = lerp(9, 36, t); + bar += `\x1b[38;2;${r};${g};${b}m\x1b[1m█`; + } + bar += `${colors.reset}${colors.dim}${'░'.repeat(empty)}${colors.reset}`; + return bar; + } + + function render() { + const glyph = spinnerGlyph(); + const frame = animFrame(); + const color = shimmerColor(frame); + const rst = colors.reset; + const dm = colors.dim; + + let line: string; + if (currentPercent >= 0) { + const barWidth = 25; + const filled = Math.round(barWidth * currentPercent / 100); + const empty = barWidth - filled; + line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage} ${renderBar(filled, empty)} ${currentPercent}%`; + } else if (currentCount > 0) { + line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage}... ${formatNumber(currentCount)} found`; + } else { + line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage}...`; + } + + process.stdout.write(`\r\x1b[K${line}`); + } + + function finishPhase() { + if (!currentMessage) return; + process.stdout.write(`\r\x1b[K`); + let detail = ''; + if (currentPercent >= 0) detail = ' — done'; + else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`; + process.stdout.write(`${colors.dim}│${colors.reset} ${colors.green}◆${colors.reset} ${currentMessage}${detail}\n`); + } + + // Start animation loop + interval = setInterval(render, ANIM_INTERVAL); + + return { + onProgress(progress: IndexProgress) { + const phaseName = PHASE_NAMES[progress.phase] || progress.phase; + + if (progress.phase !== lastPhase && lastPhase) { + finishPhase(); + } + lastPhase = progress.phase; + currentMessage = phaseName; + + if (progress.total > 0) { + currentPercent = Math.round((progress.current / progress.total) * 100); + currentCount = 0; + } else if (progress.current > 0) { + currentPercent = -1; + currentCount = progress.current; + } else { + currentPercent = -1; + currentCount = 0; + } + + // Render immediately — scanning is synchronous so setInterval can't fire + render(); + }, + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + finishPhase(); + }, + }; } /** @@ -251,30 +338,37 @@ function warn(message: string): void { console.log(chalk.yellow('⚠') + ' ' + message); } +type IndexResult = { + success: boolean; + filesIndexed: number; + filesSkipped: number; + filesErrored: number; + nodesCreated: number; + edgesCreated: number; + errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; + durationMs: number; +}; + /** - * Print a summary of indexing results with clear error breakdown + * Print indexing results using clack log methods */ -function printIndexResult(result: { success: boolean; filesIndexed: number; filesSkipped: number; filesErrored: number; nodesCreated: number; edgesCreated: number; errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; durationMs: number }, projectPath?: string): void { +function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void { const hasErrors = result.filesErrored > 0; - // Always show what was indexed if (result.filesIndexed > 0) { if (hasErrors) { - success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`); + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`); } else { - success(`Indexed ${formatNumber(result.filesIndexed)} files`); + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`); } - info(`Created ${formatNumber(result.nodesCreated)} nodes and ${formatNumber(result.edgesCreated)} edges`); - info(`Completed in ${formatDuration(result.durationMs)}`); + clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); } else if (hasErrors) { - error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`); + clack.log.error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`); } else { - warn('No files found to index'); + clack.log.warn('No files found to index'); } - // Show error breakdown if there were errors if (hasErrors) { - // Group errors by code for a concise summary const errorsByCode = new Map(); for (const err of result.errors) { if (err.severity === 'error') { @@ -292,26 +386,20 @@ function printIndexResult(result: { success: boolean; filesIndexed: number; file parser_error: 'parser initialization failures', }; - console.log(''); - console.log(chalk.dim(' Error breakdown:')); - for (const [code, count] of errorsByCode) { - const label = codeLabels[code] || code; - console.log(chalk.dim(` ${formatNumber(count)} ${label}`)); - } + const breakdown = Array.from(errorsByCode) + .map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`) + .join('\n'); + clack.note(breakdown, 'Error breakdown'); - // Write detailed error log to .codegraph/errors.log if (projectPath) { writeErrorLog(projectPath, result.errors); + clack.log.info('See .codegraph/errors.log for details'); } - // Reassure the user the index is usable if (result.filesIndexed > 0) { - console.log(''); - info('The index is fully usable — only the failed files are missing from the graph.'); - info('This is common in large repos with test fixtures or generated files that use non-standard syntax.'); + clack.log.info('The index is fully usable — only the failed files are missing.'); } } else if (projectPath) { - // No errors — clean up any stale error log const logPath = path.join(projectPath, '.codegraph', 'errors.log'); if (fs.existsSync(logPath)) { fs.unlinkSync(logPath); @@ -363,7 +451,6 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil } fs.writeFileSync(logPath, lines.join('\n') + '\n'); - info(`See .codegraph/errors.log for the full list of failed files`); } // ============================================================================= @@ -378,47 +465,41 @@ program .description('Initialize CodeGraph in a project directory') .option('-i, --index', 'Run initial indexing after initialization') .action(async (pathArg: string | undefined, options: { index?: boolean }) => { - // init should always target the exact path given (or cwd), never walk up parents const projectPath = path.resolve(pathArg || process.cwd()); + const clack = await importESM('@clack/prompts'); - console.log(chalk.bold('\nInitializing CodeGraph...\n')); + clack.intro('Initializing CodeGraph'); try { - // Check if already initialized if (isInitialized(projectPath)) { - warn(`CodeGraph already initialized in ${projectPath}`); - info('Use "codegraph index" to re-index or "codegraph sync" to update'); + clack.log.warn(`Already initialized in ${projectPath}`); + clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); + clack.outro(''); return; } - // Initialize const { default: CodeGraph } = await loadCodeGraph(); - const cg = await CodeGraph.init(projectPath, { - index: false, // We'll handle indexing ourselves for progress - }); + const cg = await CodeGraph.init(projectPath, { index: false }); + clack.log.success(`Initialized in ${projectPath}`); - success(`Initialized CodeGraph in ${projectPath}`); - info(`Created .codegraph/ directory`); - - // Run initial index if requested if (options.index) { - console.log('\nIndexing project...\n'); + process.stdout.write(`${colors.dim}│${colors.reset}\n`); + const progress = createShimmerProgress(); const result = await cg.indexAll({ - onProgress: printProgress, + onProgress: progress.onProgress, }); - // Clear progress line - process.stdout.write('\r\x1b[K'); - - printIndexResult(result, projectPath); + progress.stop(); + printIndexResult(clack, result, projectPath); } else { - info('Run "codegraph index" to index the project'); + clack.log.info('Run "codegraph index" to index the project'); } + clack.outro('Done'); cg.destroy(); } catch (err) { - error(`Failed to initialize: ${err instanceof Error ? err.message : String(err)}`); + clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); @@ -489,35 +570,38 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); - if (!options.quiet) { - console.log(chalk.bold('\nIndexing project...\n')); + if (options.quiet) { + // Quiet mode: no UI, just run + if (options.force) cg.clear(); + const result = await cg.indexAll(); + if (!result.success) process.exit(1); + cg.destroy(); + return; } - // Clear existing data if force + const clack = await importESM('@clack/prompts'); + clack.intro('Indexing project'); + if (options.force) { cg.clear(); - if (!options.quiet) { - info('Cleared existing index'); - } + clack.log.info('Cleared existing index'); } + process.stdout.write(`${colors.dim}│${colors.reset}\n`); + const progress = createShimmerProgress(); + const result = await cg.indexAll({ - onProgress: options.quiet ? undefined : printProgress, + onProgress: progress.onProgress, }); - // Clear progress line - if (!options.quiet) { - process.stdout.write('\r\x1b[K'); - } - - if (!options.quiet) { - printIndexResult(result, projectPath); - } + progress.stop(); + printIndexResult(clack, result, projectPath); if (!result.success) { process.exit(1); } + clack.outro('Done'); cg.destroy(); } catch (err) { error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`); @@ -546,35 +630,38 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); + if (options.quiet) { + await cg.sync(); + cg.destroy(); + return; + } + + const clack = await importESM('@clack/prompts'); + clack.intro('Syncing CodeGraph'); + + process.stdout.write(`${colors.dim}│${colors.reset}\n`); + const progress = createShimmerProgress(); + const result = await cg.sync({ - onProgress: options.quiet ? undefined : printProgress, + onProgress: progress.onProgress, }); - // Clear progress line - if (!options.quiet) { - process.stdout.write('\r\x1b[K'); - } + progress.stop(); const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved; - if (!options.quiet) { - if (totalChanges === 0) { - success('Already up to date'); - } else { - success(`Synced ${formatNumber(totalChanges)} changed files`); - if (result.filesAdded > 0) { - info(` Added: ${result.filesAdded}`); - } - if (result.filesModified > 0) { - info(` Modified: ${result.filesModified}`); - } - if (result.filesRemoved > 0) { - info(` Removed: ${result.filesRemoved}`); - } - info(`Updated ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); - } + if (totalChanges === 0) { + clack.log.info('Already up to date'); + } else { + clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`); + const details: string[] = []; + if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`); + if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`); + if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`); + clack.log.info(`${details.join(', ')} — ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); } + clack.outro('Done'); cg.destroy(); } catch (err) { if (!options.quiet) { diff --git a/src/installer/banner.ts b/src/installer/banner.ts deleted file mode 100644 index fb70bf3..0000000 --- a/src/installer/banner.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Banner and branding for the CodeGraph installer - */ - -import * as figlet from 'figlet'; -import * as path from 'path'; -import * as fs from 'fs'; - -// ============================================================================= -// ANSI Color Helpers (same pattern as CLI to avoid chalk ESM issues) -// ============================================================================= - -const colors = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - white: '\x1b[37m', - gray: '\x1b[90m', -}; - -export const chalk = { - bold: (s: string) => `${colors.bold}${s}${colors.reset}`, - dim: (s: string) => `${colors.dim}${s}${colors.reset}`, - red: (s: string) => `${colors.red}${s}${colors.reset}`, - green: (s: string) => `${colors.green}${s}${colors.reset}`, - yellow: (s: string) => `${colors.yellow}${s}${colors.reset}`, - blue: (s: string) => `${colors.blue}${s}${colors.reset}`, - magenta: (s: string) => `${colors.magenta}${s}${colors.reset}`, - cyan: (s: string) => `${colors.cyan}${s}${colors.reset}`, - white: (s: string) => `${colors.white}${s}${colors.reset}`, - gray: (s: string) => `${colors.gray}${s}${colors.reset}`, -}; - -/** - * Get the package version - */ -function getVersion(): string { - try { - const packageJsonPath = path.join(__dirname, '..', '..', 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - return packageJson.version; - } catch { - return '0.0.0'; - } -} - -/** - * Display the CodeGraph banner - */ -export function showBanner(): void { - // Generate ASCII art using figlet - let banner: string; - try { - banner = figlet.textSync('CODEGRAPH', { - font: 'ANSI Shadow', - horizontalLayout: 'default', - }); - } catch { - // Fallback if figlet fails - banner = ` - ██████╗ ██████╗ ██████╗ ███████╗ ██████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗ - ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔══██╗██╔══██╗██║ ██║ - ██║ ██║ ██║██║ ██║█████╗ ██║ ███╗██████╔╝███████║██████╔╝███████║ - ██║ ██║ ██║██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══██║██╔═══╝ ██╔══██║ - ╚██████╗╚██████╔╝██████╔╝███████╗╚██████╔╝██║ ██║██║ ██║██║ ██║ ██║ - ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ -`; - } - - console.log(); - console.log(chalk.cyan(banner)); - console.log(); - console.log(` ${chalk.bold('CodeGraph')} v${getVersion()}`); - console.log(' Semantic code intelligence for Claude Code'); - console.log(chalk.dim(' Created by: Colby McHenry')); - console.log(); -} - -/** - * Show success checkmark - */ -export function success(message: string): void { - console.log(chalk.green(' ✓') + ' ' + message); -} - -/** - * Show error message - */ -export function error(message: string): void { - console.log(chalk.red(' ✗') + ' ' + message); -} - -/** - * Show info message - */ -export function info(message: string): void { - console.log(chalk.blue(' ℹ') + ' ' + message); -} - -/** - * Show warning message - */ -export function warn(message: string): void { - console.log(chalk.yellow(' ⚠') + ' ' + message); -} - -/** - * Show the "next steps" section after installation - */ -export function showNextSteps(location: 'global' | 'local'): void { - console.log(); - console.log(chalk.bold(' Done!') + ' Restart Claude Code to use CodeGraph.'); - console.log(); - - if (location === 'global') { - console.log(chalk.dim(' Quick start:')); - console.log(chalk.dim(' cd your-project')); - console.log(chalk.cyan(' codegraph init -i')); - console.log(); - console.log(chalk.dim(' To uninstall:')); - console.log(chalk.dim(' npm uninstall -g @colbymchenry/codegraph')); - } else { - console.log(chalk.dim(' CodeGraph is ready to use in this project!')); - } - console.log(); -} diff --git a/src/installer/clack.d.ts b/src/installer/clack.d.ts new file mode 100644 index 0000000..08e9687 --- /dev/null +++ b/src/installer/clack.d.ts @@ -0,0 +1,43 @@ +/** + * Type declarations for @clack/prompts + * + * The package ships ESM-only (.d.mts) which TypeScript can't resolve + * with moduleResolution "node". We declare the subset we use here. + */ + +declare module '@clack/prompts' { + export function intro(title?: string): void; + export function outro(message?: string): void; + export function cancel(message?: string): void; + export function isCancel(value: unknown): value is symbol; + + export function confirm(opts: { + message: string; + active?: string; + inactive?: string; + initialValue?: boolean; + }): Promise; + + export function select(opts: { + message: string; + options: { value: Value; label: string; hint?: string }[]; + initialValue?: Value; + }): Promise; + + export function spinner(): { + start(message?: string): void; + stop(message?: string): void; + message(message?: string): void; + }; + + export function note(message: string, title?: string): void; + + export const log: { + message(message: string): void; + info(message: string): void; + success(message: string): void; + step(message: string): void; + warn(message: string): void; + error(message: string): void; + }; +} diff --git a/src/installer/config-writer.ts b/src/installer/config-writer.ts index 2d9e8ab..b0bf19a 100644 --- a/src/installer/config-writer.ts +++ b/src/installer/config-writer.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { InstallLocation } from './prompts'; +export type InstallLocation = 'global' | 'local'; import { CLAUDE_MD_TEMPLATE, CODEGRAPH_SECTION_START, diff --git a/src/installer/index.ts b/src/installer/index.ts index 4c13e28..5a23121 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -1,14 +1,24 @@ /** * CodeGraph Interactive Installer * - * Provides a beautiful interactive CLI experience for setting up CodeGraph - * with Claude Code. + * Uses @clack/prompts for a polished interactive CLI experience. */ import { execSync } from 'child_process'; -import { showBanner, showNextSteps, success, error, info, chalk } from './banner'; -import { promptInstallLocation, promptAutoAllow, promptConfirm, InstallLocation } from './prompts'; -import { writeMcpConfig, writePermissions, writeClaudeMd, writeHooks, hasMcpConfig, hasPermissions, hasHooks } from './config-writer'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + writeMcpConfig, writePermissions, writeClaudeMd, writeHooks, + hasMcpConfig, hasPermissions, hasHooks, +} from './config-writer'; + +import type { InstallLocation } from './config-writer'; + +// Dynamic import helper — tsc compiles import() to require() in CJS mode, +// which fails for ESM-only packages. This bypasses the transformation. +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const importESM = new Function('specifier', 'return import(specifier)') as + (specifier: string) => Promise; /** * Format a number with commas @@ -17,110 +27,140 @@ function formatNumber(n: number): string { return n.toLocaleString(); } +/** + * Get the package version + */ +function getVersion(): string { + try { + const packageJsonPath = path.join(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return packageJson.version; + } catch { + return '0.0.0'; + } +} + /** * Run the interactive installer */ export async function runInstaller(): Promise { - // Show the banner - showBanner(); + const clack = await importESM('@clack/prompts'); - try { - // Step 1: Install codegraph globally (with user consent). - // The global install is needed because Claude Code hooks and the MCP server - // invoke `codegraph` by name — the temporary npx binary vanishes when npx exits. - console.log(chalk.bold(' Install codegraph globally?') + chalk.dim(' (Required for hooks & MCP server)')); - console.log(); - const shouldInstallGlobally = await promptConfirm('Install globally via npm', true); + clack.intro(`CodeGraph v${getVersion()}`); - if (shouldInstallGlobally) { - console.log(chalk.dim(' Installing codegraph globally...')); - try { - execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); - success('Installed codegraph command globally'); - } catch { - info('Could not install globally (permission denied)'); - info('Try: sudo npm install -g @colbymchenry/codegraph'); - } - } else { - info('Skipped global install — hooks and MCP server may not work without it'); - info('You can install later: npm install -g @colbymchenry/codegraph'); + // Step 1: Install globally + const shouldInstallGlobally = await clack.confirm({ + message: 'Install codegraph globally? (Required for hooks & MCP server)', + initialValue: true, + }); + + if (clack.isCancel(shouldInstallGlobally)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } + + if (shouldInstallGlobally) { + const s = clack.spinner(); + s.start('Installing codegraph globally...'); + try { + execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); + s.stop('Installed codegraph globally'); + } catch { + s.stop('Could not install globally (permission denied)'); + clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph'); } - console.log(); + } else { + clack.log.info('Skipped global install — hooks and MCP server may not work without it'); + } - // Step 2: Ask for installation location - const location = await promptInstallLocation(); - console.log(); + // Step 2: Installation location + const location = await clack.select({ + message: 'Where would you like to install?', + options: [ + { value: 'global' as const, label: 'Global', hint: '~/.claude — available in all projects' }, + { value: 'local' as const, label: 'Local', hint: './.claude — this project only' }, + ], + initialValue: 'global' as const, + }); - // Step 3: Ask about auto-allow permissions - const autoAllow = await promptAutoAllow(); - console.log(); + if (clack.isCancel(location)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } - // Step 4: Write MCP configuration - const alreadyHasMcp = hasMcpConfig(location); - writeMcpConfig(location); + // Step 3: Auto-allow permissions + const autoAllow = await clack.confirm({ + message: 'Auto-allow CodeGraph commands? (Skips permission prompts)', + initialValue: true, + }); - if (alreadyHasMcp) { - success(`Updated MCP server in ${location === 'global' ? '~/.claude.json' : './.claude.json'}`); - } else { - success(`Added MCP server to ${location === 'global' ? '~/.claude.json' : './.claude.json'}`); - } + if (clack.isCancel(autoAllow)) { + clack.cancel('Installation cancelled.'); + process.exit(0); + } - if (autoAllow) { - const alreadyHasPerms = hasPermissions(location); - writePermissions(location); + // Step 4: Write configuration files + writeConfigs(clack, location, autoAllow); - if (alreadyHasPerms) { - success(`Updated permissions in ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`); - } else { - success(`Added permissions to ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`); - } - } + // Step 5: For local install, initialize the project + if (location === 'local') { + await initializeLocalProject(clack); + } - // Step 6: Write auto-sync hooks - const alreadyHasHooks = hasHooks(location); - writeHooks(location); + // Done + if (location === 'global') { + clack.note( + 'cd your-project\ncodegraph init -i', + 'Quick start', + ); + } - if (alreadyHasHooks) { - success(`Updated auto-sync hooks in ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`); - } else { - success(`Added auto-sync hooks to ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`); - } + clack.outro('Done! Restart Claude Code to use CodeGraph.'); +} - // Step 7: Write CLAUDE.md instructions - const claudeMdResult = writeClaudeMd(location); - const claudeMdPath = location === 'global' ? '~/.claude/CLAUDE.md' : './.claude/CLAUDE.md'; +/** + * Write all configuration files and log results + */ +function writeConfigs( + clack: typeof import('@clack/prompts'), + location: InstallLocation, + autoAllow: boolean, +): void { + const locationLabel = location === 'global' ? '~/.claude' : './.claude'; - if (claudeMdResult.created) { - success(`Created ${claudeMdPath} with CodeGraph instructions`); - } else if (claudeMdResult.updated) { - success(`Updated CodeGraph section in ${claudeMdPath}`); - } else { - success(`Added CodeGraph instructions to ${claudeMdPath}`); - } + // MCP config + const mcpAction = hasMcpConfig(location) ? 'Updated' : 'Added'; + writeMcpConfig(location); + clack.log.success(`${mcpAction} MCP server in ${locationLabel}.json`); - // Step 7: For local install, initialize the project - if (location === 'local') { - await initializeLocalProject(); - } + // Permissions + if (autoAllow) { + const permAction = hasPermissions(location) ? 'Updated' : 'Added'; + writePermissions(location); + clack.log.success(`${permAction} permissions in ${locationLabel}/settings.json`); + } - // Show next steps - showNextSteps(location); - } catch (err) { - console.log(); - if (err instanceof Error && err.message.includes('readline was closed')) { - // User cancelled with Ctrl+C - console.log(chalk.dim(' Installation cancelled.')); - } else { - error(`Installation failed: ${err instanceof Error ? err.message : String(err)}`); - } - process.exit(1); + // Hooks + const hookAction = hasHooks(location) ? 'Updated' : 'Added'; + writeHooks(location); + clack.log.success(`${hookAction} auto-sync hooks in ${locationLabel}/settings.json`); + + // CLAUDE.md + const claudeMdResult = writeClaudeMd(location); + const claudeMdPath = `${locationLabel}/CLAUDE.md`; + if (claudeMdResult.created) { + clack.log.success(`Created ${claudeMdPath}`); + } else if (claudeMdResult.updated) { + clack.log.success(`Updated ${claudeMdPath}`); + } else { + clack.log.success(`Added CodeGraph instructions to ${claudeMdPath}`); } } /** * Initialize CodeGraph in the current project (for local installs) */ -async function initializeLocalProject(): Promise { +async function initializeLocalProject(clack: typeof import('@clack/prompts')): Promise { const projectPath = process.cwd(); // Lazy-load CodeGraph (requires native modules) @@ -129,52 +169,116 @@ async function initializeLocalProject(): Promise { CodeGraph = (await import('../index')).default; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - error(`Could not load native modules: ${msg}`); - info('Skipping project initialization. You can run "codegraph init -i" later.'); - info('If this persists, try a Node.js LTS version (20 or 22).'); + clack.log.error(`Could not load native modules: ${msg}`); + clack.log.info('Skipping project initialization. Run "codegraph init -i" later.'); return; } // Check if already initialized if (CodeGraph.isInitialized(projectPath)) { - info('CodeGraph already initialized in this project'); + clack.log.info('CodeGraph already initialized in this project'); return; } - console.log(); - console.log(chalk.dim(' Initializing CodeGraph in current project...')); - - // Initialize CodeGraph + // Initialize const cg = await CodeGraph.init(projectPath); - success('Created .codegraph/ directory'); + clack.log.success('Created .codegraph/ directory'); + + // Index the project with shimmer progress + const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽']; + const ANIM_INTERVAL = 150; + const FRAMES_PER_GLYPH = 3; + const _lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); + const _shimmerColor = (frame: number) => { + const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2; + return `\x1b[38;2;${_lerp(160, 251, t)};${_lerp(100, 191, t)};${_lerp(9, 36, t)}m\x1b[1m`; + }; + const rst = '\x1b[0m'; + const dm = '\x1b[2m'; + const grn = '\x1b[32m'; + const phaseNames: Record = { + scanning: 'Scanning files', + parsing: 'Parsing code', + storing: 'Storing data', + resolving: 'Resolving refs', + }; + + const _startTime = Date.now(); + const _animFrame = () => Math.floor((Date.now() - _startTime) / ANIM_INTERVAL); + let curMsg = ''; + let curPercent = -1; + let curCount = 0; + let lastPhase = ''; + + const renderBar = (filled: number, empty: number): string => { + if (filled === 0) return `${dm}${'░'.repeat(empty)}${rst}`; + const cycleFrames = 24; + const shimmerPos = ((_animFrame() % cycleFrames) / cycleFrames) * (filled + 6) - 3; + const shimmerWidth = 3; + let bar = ''; + for (let i = 0; i < filled; i++) { + const dist = Math.abs(i - shimmerPos); + const t = Math.max(0, 1 - dist / shimmerWidth); + const r = _lerp(160, 251, t); + const g = _lerp(100, 191, t); + const b = _lerp(9, 36, t); + bar += `\x1b[38;2;${r};${g};${b}m\x1b[1m█`; + } + return bar + `${rst}${dm}${'░'.repeat(empty)}${rst}`; + }; + + const renderTick = () => { + const frame = _animFrame(); + const glyph = SPINNER_GLYPHS[Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length]; + const color = _shimmerColor(frame); + let line: string; + if (curPercent >= 0) { + const barW = 25, filled = Math.round(barW * curPercent / 100), empty = barW - filled; + line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg} ${renderBar(filled, empty)} ${curPercent}%`; + } else if (curCount > 0) { + line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg}... ${formatNumber(curCount)} found`; + } else { + line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg}...`; + } + process.stdout.write(`\r\x1b[K${line}`); + }; + + const finishPhase = () => { + if (!curMsg) return; + process.stdout.write(`\r\x1b[K`); + let detail = ''; + if (curPercent >= 0) detail = ' — done'; + else if (curCount > 0) detail = ` — ${formatNumber(curCount)} found`; + process.stdout.write(`${dm}│${rst} ${grn}◆${rst} ${curMsg}${detail}\n`); + }; + + process.stdout.write(`${dm}│${rst}\n`); + const ticker = setInterval(renderTick, ANIM_INTERVAL); - // Index the project const result = await cg.indexAll({ onProgress: (progress) => { - // Simple progress indicator - const phaseNames: Record = { - scanning: 'Scanning files', - parsing: 'Parsing code', - storing: 'Storing data', - resolving: 'Resolving refs', - }; const phaseName = phaseNames[progress.phase] || progress.phase; - const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; - process.stdout.write(`\r ${chalk.dim(phaseName)}... ${percent}%\x1b[K`); + if (progress.phase !== lastPhase && lastPhase) finishPhase(); + lastPhase = progress.phase; + curMsg = phaseName; + if (progress.total > 0) { curPercent = Math.round((progress.current / progress.total) * 100); curCount = 0; } + else if (progress.current > 0) { curPercent = -1; curCount = progress.current; } + else { curPercent = -1; curCount = 0; } + renderTick(); }, }); - // Clear progress line - process.stdout.write('\r\x1b[K'); + clearInterval(ticker); + finishPhase(); if (result.filesErrored > 0) { - success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} files failed, ${formatNumber(result.nodesCreated)} symbols)`); + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`); } else { - success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`); + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`); } cg.close(); } -// Export for use in CLI -export { InstallLocation }; +// Re-export for CLI +export type { InstallLocation }; diff --git a/src/installer/prompts.ts b/src/installer/prompts.ts deleted file mode 100644 index fa22daf..0000000 --- a/src/installer/prompts.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * User prompts for the CodeGraph installer - * Uses built-in readline to avoid ESM issues with inquirer - */ - -import * as readline from 'readline'; -import { chalk } from './banner'; - -export type InstallLocation = 'global' | 'local'; - -/** - * Create a readline interface for prompts - */ -function createInterface(): readline.Interface { - return readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); -} - -/** - * Prompt the user with a question and return their answer - */ -function prompt(rl: readline.Interface, question: string): Promise { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer.trim()); - }); - }); -} - -/** - * Prompt for installation location (global or local) - */ -export async function promptInstallLocation(): Promise { - const rl = createInterface(); - - console.log(chalk.bold(' Where would you like to install?')); - console.log(); - console.log(' 1) Global (~/.claude) - available in all projects'); - console.log(' 2) Local (./.claude) - this project only'); - console.log(); - - const answer = await prompt(rl, ' Choice [1]: '); - rl.close(); - - // Default to '1' if empty, parse the answer - const choice = answer === '' ? '1' : answer; - - if (choice === '2') { - return 'local'; - } - return 'global'; -} - -/** - * Prompt for auto-allow permissions - */ -export async function promptAutoAllow(): Promise { - const rl = createInterface(); - - console.log(); - console.log(chalk.bold(' Auto-allow CodeGraph commands?') + chalk.dim(' (Skips permission prompts)')); - console.log(); - console.log(' 1) Yes - auto-approve all codegraph_* tools'); - console.log(' 2) No - ask for permission each time'); - console.log(); - - const answer = await prompt(rl, ' Choice [1]: '); - rl.close(); - - // Default to '1' if empty - const choice = answer === '' ? '1' : answer; - - return choice !== '2'; -} - -/** - * Prompt for confirmation (yes/no) - */ -export async function promptConfirm(message: string, defaultYes: boolean = true): Promise { - const rl = createInterface(); - - const defaultStr = defaultYes ? 'Y/n' : 'y/N'; - const answer = await prompt(rl, ` ${message} [${defaultStr}]: `); - rl.close(); - - if (answer === '') { - return defaultYes; - } - - return answer.toLowerCase().startsWith('y'); -}