simplify and modularize the entrypoint for cli

This commit is contained in:
dal 2025-10-08 10:17:45 -06:00
parent 7410459ad5
commit bbfdb5794e
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
10 changed files with 341 additions and 286 deletions

View File

@ -0,0 +1,14 @@
import type { Command } from 'commander';
/**
* Sets up pre-action hook to handle global options like --cwd
*/
export function setupPreActionHook(program: Command): void {
program.hook('preAction', (thisCommand) => {
// Process --cwd option before any command runs
const opts = thisCommand.optsWithGlobals();
if (opts.cwd) {
process.chdir(opts.cwd);
}
});
}

View File

@ -0,0 +1,51 @@
import { program as commander } from 'commander';
import { render } from 'ink';
import { Main } from '../commands/main';
import { getCurrentVersion } from '../commands/update/update-handler';
import { setupPreActionHook } from './hooks';
interface RootOptions {
cwd?: string;
prompt?: string;
chatId?: string;
research?: boolean;
}
export const program = commander
.name('buster')
.description('Buster CLI - AI-powered data analytics platform')
.version(getCurrentVersion())
.option('--cwd <path>', 'Set working directory for the CLI')
.option('--prompt <prompt>', 'Run agent in headless mode with the given prompt')
.option('--chatId <id>', 'Continue an existing conversation (used with --prompt)')
.option('--research', 'Run agent in research mode (read-only, no file modifications)');
setupPreActionHook(program);
// Root action - runs when no subcommand is specified
program.action(async (options: RootOptions) => {
// Change working directory if specified
if (options.cwd) {
process.chdir(options.cwd);
}
// Check if running in headless mode
if (options.prompt) {
try {
const { runHeadless } = await import('../services/headless-handler');
const chatId = await runHeadless({
prompt: options.prompt,
...(options.chatId && { chatId: options.chatId }),
...(options.research && { isInResearchMode: options.research }),
});
console.log(chatId);
process.exit(0);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
} else {
// Run interactive TUI mode
render(<Main />);
}
});

View File

@ -0,0 +1,48 @@
import chalk from 'chalk';
import { getCurrentVersion } from '../commands/update/update-handler';
import { checkForUpdate, formatVersion } from '../utils/version/index';
/**
* Sets up background update checking for the CLI
* Runs non-blocking and shows notification if update is available
*/
export function setupUpdateChecker(): void {
// Skip in CI or if explicitly disabled
if (process.env.CI || process.env.BUSTER_NO_UPDATE_CHECK) {
return;
}
const currentVersion = getCurrentVersion();
checkForUpdate(currentVersion)
.then((result) => {
if (result?.updateAvailable) {
// Show update notification after a small delay to not interfere with command output
setTimeout(() => {
console.info('');
console.info(chalk.yellow('╭────────────────────────────────────────────╮'));
console.info(
chalk.yellow('│') +
' ' +
chalk.bold('Update available!') +
' ' +
chalk.dim(`${formatVersion(currentVersion)}${formatVersion(result.latestVersion)}`) +
' ' +
chalk.yellow('│')
);
console.info(
chalk.yellow('│') +
' Run ' +
chalk.cyan('buster update') +
' to update ' +
chalk.yellow('│')
);
console.info(chalk.yellow('╰────────────────────────────────────────────╯'));
console.info('');
}, 100);
}
})
.catch(() => {
// Silently ignore errors in update check
});
}

View File

@ -0,0 +1,85 @@
import { Command } from 'commander';
import { render } from 'ink';
import { Auth } from './auth';
/**
* Creates the auth command for authentication management
*/
export function createAuthCommand(): Command {
return new Command('auth')
.description('Authenticate with Buster API')
.option('--api-key <key>', 'Your Buster API key')
.option('--host <url>', 'Custom API host URL')
.option('--local', 'Use local development server (http://localhost:3001)')
.option('--cloud', 'Use cloud instance (https://api2.buster.so)')
.option('--clear', 'Clear saved credentials')
.option('--show', 'Show current credentials')
.option('--no-save', "Don't save credentials to disk")
.action(async (options) => {
// Check if we're in a non-TTY environment (CI/CD)
const isTTY = process.stdin.isTTY;
const isCIEnvironment = process.env.CI || !isTTY;
// In CI environments, we need to handle auth differently
if (isCIEnvironment && !options.apiKey && !process.env.BUSTER_API_KEY) {
console.error('❌ Non-interactive environment detected.');
console.error(
' Please provide API key via --api-key flag or BUSTER_API_KEY environment variable.'
);
console.error(' Example: buster auth --api-key YOUR_API_KEY');
console.error(' Or set: export BUSTER_API_KEY=YOUR_API_KEY');
process.exit(1);
}
// If we have an API key in CI, just validate and save it without interactive UI
if (isCIEnvironment && (options.apiKey || process.env.BUSTER_API_KEY)) {
const { createBusterSDK } = await import('@buster/sdk');
const { saveCredentials } = await import('../utils/credentials');
const apiKey = options.apiKey || process.env.BUSTER_API_KEY;
const host =
options.host ||
(options.local
? 'http://localhost:3001'
: options.cloud
? 'https://api2.buster.so'
: 'https://api2.buster.so');
const normalizedHost = host.startsWith('http') ? host : `https://${host}`;
try {
// Validate the API key
const sdk = createBusterSDK({
apiKey: apiKey,
apiUrl: normalizedHost,
timeout: 30000,
});
const isValid = await sdk.auth.isApiKeyValid();
if (isValid) {
if (!options.noSave) {
await saveCredentials({ apiKey, apiUrl: normalizedHost });
console.log('✅ Authentication successful and credentials saved.');
} else {
console.log(
'✅ Authentication successful (credentials not saved due to --no-save flag).'
);
}
process.exit(0);
} else {
console.error('❌ Invalid API key.');
process.exit(1);
}
} catch (error) {
console.error(
'❌ Authentication failed:',
error instanceof Error ? error.message : 'Unknown error'
);
process.exit(1);
}
}
// For interactive environments, use the Ink UI
render(<Auth {...options} />);
});
}

View File

@ -0,0 +1,17 @@
import type { Command } from 'commander';
import { createAuthCommand } from './auth-command';
import { createDeployCommand } from './deploy-command';
import { createInitCommand } from './init-command';
import { createSettingsCommand } from './settings-command';
import { createUpdateCommand } from './update-command';
/**
* Registers all CLI subcommands with the program
*/
export function registerCommands(program: Command): void {
program.addCommand(createAuthCommand());
program.addCommand(createDeployCommand());
program.addCommand(createInitCommand());
program.addCommand(createSettingsCommand());
program.addCommand(createUpdateCommand());
}

View File

@ -0,0 +1,56 @@
import { Command } from 'commander';
import { render } from 'ink';
import { DeployCommand } from './deploy/deploy';
import { DeployOptionsSchema } from './deploy/schemas';
/**
* Creates the deploy command for deploying semantic models
*/
export function createDeployCommand(): Command {
return new Command('deploy')
.description('Deploy semantic models to Buster API')
.option(
'--path <path>',
'Path to search for buster.yml and model files (defaults to current directory)'
)
.option('--dry-run', 'Validate models without deploying')
.option('--verbose', 'Show detailed output')
.option('--debug', 'Enable debug mode with detailed SQL logging')
.option('--interactive', 'Use interactive UI mode')
.action(async (options) => {
try {
// Parse and validate options
const parsedOptions = DeployOptionsSchema.parse({
path: options.path,
dryRun: options.dryRun || false,
verbose: options.verbose || false,
debug: options.debug || false,
});
// Use interactive UI mode only if explicitly requested
if (options.interactive) {
render(<DeployCommand {...parsedOptions} />);
} else {
// Direct execution for cleaner CLI output
const { deployHandler } = await import('./deploy/deploy-handler.js');
await deployHandler(parsedOptions);
}
} catch (error) {
// Import the error formatter and type guard
const { isDeploymentValidationError, formatDeployError, getExitCode } = await import(
'./deploy/utils/errors.js'
);
// Check if it's a DeploymentValidationError to handle it specially
if (isDeploymentValidationError(error)) {
// The error message already contains the formatted output
console.error(formatDeployError(error));
process.exit(getExitCode(error));
} else {
// For other errors, still show them but formatted properly
console.error(formatDeployError(error));
process.exit(getExitCode(error));
}
}
});
}

View File

@ -0,0 +1,18 @@
import { Command } from 'commander';
import { render } from 'ink';
import { InitCommand } from './init';
/**
* Creates the init command for initializing a new Buster project
*/
export function createInitCommand(): Command {
return new Command('init')
.description('Initialize a new Buster project')
.option('--api-key <key>', 'Your Buster API key')
.option('--host <url>', 'Custom API host URL')
.option('--local', 'Use local development server')
.option('--path <path>', 'Project location (defaults to current directory)')
.action(async (options) => {
render(<InitCommand {...options} />);
});
}

View File

@ -0,0 +1,28 @@
import { Command } from 'commander';
import { render } from 'ink';
import { SettingsCommand } from './settings';
/**
* Creates the settings command for managing CLI settings
*/
export function createSettingsCommand(): Command {
return new Command('settings')
.description('Manage CLI settings')
.option('--vim-mode <enabled>', 'Enable or disable vim keybindings (true/false)')
.option('--toggle <setting>', 'Toggle vim mode')
.option('--show', 'Show current settings')
.action(async (options) => {
// Parse vim-mode option if provided
let vimMode: boolean | undefined;
if (options.vimMode !== undefined) {
vimMode = options.vimMode === 'true' || options.vimMode === '1' || options.vimMode === 'on';
}
render(
<SettingsCommand
toggle={options.toggle}
show={options.show}
{...(vimMode !== undefined ? { vimMode } : {})}
/>
);
});
}

View File

@ -0,0 +1,17 @@
import { Command } from 'commander';
import { render } from 'ink';
import { UpdateCommand } from './update/index';
/**
* Creates the update command for updating the CLI to the latest version
*/
export function createUpdateCommand(): Command {
return new Command('update')
.description('Update Buster CLI to the latest version')
.option('--check', 'Check for updates without installing')
.option('--force', 'Force update even if on latest version')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (options) => {
render(<UpdateCommand {...options} />);
});
}

View File

@ -1,292 +1,13 @@
#!/usr/bin/env bun
import chalk from 'chalk';
import { program } from 'commander';
import { render } from 'ink';
import { Auth } from './commands/auth';
import { DeployCommand } from './commands/deploy/deploy';
import { DeployOptionsSchema } from './commands/deploy/schemas';
import { InitCommand } from './commands/init';
import { Main } from './commands/main';
import { SettingsCommand } from './commands/settings';
import { UpdateCommand } from './commands/update/index';
import { getCurrentVersion } from './commands/update/update-handler';
import { checkForUpdate, formatVersion } from './utils/version/index';
import { program } from './buster/program';
import { setupUpdateChecker } from './buster/update-checker';
import { registerCommands } from './commands/command-registry';
// Get current version
const currentVersion = getCurrentVersion();
// Setup background update checking
setupUpdateChecker();
// CLI metadata
program
.name('buster')
.description('Buster CLI - AI-powered data analytics platform')
.version(currentVersion)
.option('--cwd <path>', 'Set working directory for the CLI')
.option('--prompt <prompt>', 'Run agent in headless mode with the given prompt')
.option('--chatId <id>', 'Continue an existing conversation (used with --prompt)')
.option('--research', 'Run agent in research mode (read-only, no file modifications)')
.hook('preAction', (thisCommand) => {
// Process --cwd option before any command runs
const opts = thisCommand.optsWithGlobals();
if (opts.cwd) {
process.chdir(opts.cwd);
}
});
program.action(async (options: { cwd?: string; prompt?: string; chatId?: string; research?: boolean }) => {
// Change working directory if specified
if (options.cwd) {
process.chdir(options.cwd);
}
// Check if running in headless mode
if (options.prompt) {
try {
const { runHeadless } = await import('./services/headless-handler');
const chatId = await runHeadless({
prompt: options.prompt,
...(options.chatId && { chatId: options.chatId }),
...(options.research && { isInResearchMode: options.research }),
});
console.log(chatId);
process.exit(0);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
} else {
// Run interactive TUI mode
render(<Main />);
}
});
// Check for updates in the background (non-blocking)
if (!process.env.CI && !process.env.BUSTER_NO_UPDATE_CHECK) {
checkForUpdate(currentVersion)
.then((result) => {
if (result?.updateAvailable) {
// Show update notification after a small delay to not interfere with command output
setTimeout(() => {
console.info('');
console.info(chalk.yellow('╭────────────────────────────────────────────╮'));
console.info(
chalk.yellow('│') +
' ' +
chalk.bold('Update available!') +
' ' +
chalk.dim(
`${formatVersion(currentVersion)}${formatVersion(result.latestVersion)}`
) +
' ' +
chalk.yellow('│')
);
console.info(
chalk.yellow('│') +
' Run ' +
chalk.cyan('buster update') +
' to update ' +
chalk.yellow('│')
);
console.info(chalk.yellow('╰────────────────────────────────────────────╯'));
console.info('');
}, 100);
}
})
.catch(() => {
// Silently ignore errors in update check
});
}
// Auth command - authentication management
program
.command('auth')
.description('Authenticate with Buster API')
.option('--api-key <key>', 'Your Buster API key')
.option('--host <url>', 'Custom API host URL')
.option('--local', 'Use local development server (http://localhost:3001)')
.option('--cloud', 'Use cloud instance (https://api2.buster.so)')
.option('--clear', 'Clear saved credentials')
.option('--show', 'Show current credentials')
.option('--no-save', "Don't save credentials to disk")
.action(async (options) => {
// Check if we're in a non-TTY environment (CI/CD)
const isTTY = process.stdin.isTTY;
const isCIEnvironment = process.env.CI || !isTTY;
// In CI environments, we need to handle auth differently
if (isCIEnvironment && !options.apiKey && !process.env.BUSTER_API_KEY) {
console.error('❌ Non-interactive environment detected.');
console.error(
' Please provide API key via --api-key flag or BUSTER_API_KEY environment variable.'
);
console.error(' Example: buster auth --api-key YOUR_API_KEY');
console.error(' Or set: export BUSTER_API_KEY=YOUR_API_KEY');
process.exit(1);
}
// If we have an API key in CI, just validate and save it without interactive UI
if (isCIEnvironment && (options.apiKey || process.env.BUSTER_API_KEY)) {
const { createBusterSDK } = await import('@buster/sdk');
const { saveCredentials } = await import('./utils/credentials');
const apiKey = options.apiKey || process.env.BUSTER_API_KEY;
const host =
options.host ||
(options.local
? 'http://localhost:3001'
: options.cloud
? 'https://api2.buster.so'
: 'https://api2.buster.so');
const normalizedHost = host.startsWith('http') ? host : `https://${host}`;
try {
// Validate the API key
const sdk = createBusterSDK({
apiKey: apiKey,
apiUrl: normalizedHost,
timeout: 30000,
});
const isValid = await sdk.auth.isApiKeyValid();
if (isValid) {
if (!options.noSave) {
await saveCredentials({ apiKey, apiUrl: normalizedHost });
console.log('✅ Authentication successful and credentials saved.');
} else {
console.log(
'✅ Authentication successful (credentials not saved due to --no-save flag).'
);
}
process.exit(0);
} else {
console.error('❌ Invalid API key.');
process.exit(1);
}
} catch (error) {
console.error(
'❌ Authentication failed:',
error instanceof Error ? error.message : 'Unknown error'
);
process.exit(1);
}
}
// For interactive environments, use the Ink UI
render(<Auth {...options} />);
});
// Hidden commands - not shown to users but kept for development
// Hello command - basic example (hidden)
// program
// .command('hello')
// .description('Say hello')
// .argument('[name]', 'Name to greet', 'World')
// .option('-u, --uppercase', 'Output in uppercase')
// .action(async (name: string, options: { uppercase?: boolean }) => {
// render(<HelloCommand name={name} uppercase={options.uppercase || false} />);
// });
// Interactive command - demonstrates Ink's capabilities (hidden)
// program
// .command('interactive')
// .description('Run an interactive demo')
// .action(async () => {
// render(<InteractiveCommand />);
// });
// Deploy command - deploy semantic models to Buster API
program
.command('deploy')
.description('Deploy semantic models to Buster API')
.option(
'--path <path>',
'Path to search for buster.yml and model files (defaults to current directory)'
)
.option('--dry-run', 'Validate models without deploying')
.option('--verbose', 'Show detailed output')
.option('--debug', 'Enable debug mode with detailed SQL logging')
.option('--interactive', 'Use interactive UI mode')
.action(async (options) => {
try {
// Parse and validate options
const parsedOptions = DeployOptionsSchema.parse({
path: options.path,
dryRun: options.dryRun || false,
verbose: options.verbose || false,
debug: options.debug || false,
});
// Use interactive UI mode only if explicitly requested
if (options.interactive) {
render(<DeployCommand {...parsedOptions} />);
} else {
// Direct execution for cleaner CLI output
const { deployHandler } = await import('./commands/deploy/deploy-handler.js');
await deployHandler(parsedOptions);
}
} catch (error) {
// Import the error formatter and type guard
const { isDeploymentValidationError, formatDeployError, getExitCode } = await import(
'./commands/deploy/utils/errors.js'
);
// Check if it's a DeploymentValidationError to handle it specially
if (isDeploymentValidationError(error)) {
// The error message already contains the formatted output
console.error(formatDeployError(error));
process.exit(getExitCode(error));
} else {
// For other errors, still show them but formatted properly
console.error(formatDeployError(error));
process.exit(getExitCode(error));
}
}
});
// Init command - initialize a new Buster project
program
.command('init')
.description('Initialize a new Buster project')
.option('--api-key <key>', 'Your Buster API key')
.option('--host <url>', 'Custom API host URL')
.option('--local', 'Use local development server')
.option('--path <path>', 'Project location (defaults to current directory)')
.action(async (options) => {
render(<InitCommand {...options} />);
});
// Settings command - manage CLI settings
program
.command('settings')
.description('Manage CLI settings')
.option('--vim-mode <enabled>', 'Enable or disable vim keybindings (true/false)')
.option('--toggle <setting>', 'Toggle vim mode')
.option('--show', 'Show current settings')
.action(async (options) => {
// Parse vim-mode option if provided
let vimMode: boolean | undefined;
if (options.vimMode !== undefined) {
vimMode = options.vimMode === 'true' || options.vimMode === '1' || options.vimMode === 'on';
}
render(
<SettingsCommand
toggle={options.toggle}
show={options.show}
{...(vimMode !== undefined ? { vimMode } : {})}
/>
);
});
// Update command - update the CLI to the latest version
program
.command('update')
.description('Update Buster CLI to the latest version')
.option('--check', 'Check for updates without installing')
.option('--force', 'Force update even if on latest version')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (options) => {
render(<UpdateCommand {...options} />);
});
// Register all subcommands
registerCommands(program);
// Parse command line arguments
program.parse(process.argv);