feat: Replace figlet with @clack/prompts for polished CLI experience

Replaces ASCII art banner and basic readline prompts with @clack/prompts for a modern interactive CLI. Adds animated shimmer progress bars with spinner glyphs during indexing operations. Improves installer UX with structured prompts, better error handling, and cleaner output formatting throughout all CLI commands.
This commit is contained in:
Colby McHenry
2026-04-04 11:07:18 -05:00
parent 3a44d5c4d1
commit ed35d65f4c
8 changed files with 521 additions and 484 deletions
+213 -109
View File
@@ -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<typeof import('@clack/prompts')>;
/**
* 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<void> {
// 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<void> {
async function initializeLocalProject(clack: typeof import('@clack/prompts')): Promise<void> {
const projectPath = process.cwd();
// Lazy-load CodeGraph (requires native modules)
@@ -129,52 +169,116 @@ async function initializeLocalProject(): Promise<void> {
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<string, string> = {
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<string, string> = {
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 };