Enhance CLI command input with suggestions and navigation

- Added command suggestions for available CLI commands, improving user experience.
- Implemented keyboard navigation for command selection using up/down arrows and tab for autocomplete.
- Integrated the `init` and `deploy` commands into the main command handling logic.
- Updated configuration loading to recursively find and validate `buster.yml` files, ensuring no duplicates are processed.
- Improved error handling and user feedback in the deploy command.
This commit is contained in:
dal 2025-09-03 15:17:10 -06:00
parent 54e0317e38
commit 38db7ca4f8
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
5 changed files with 902 additions and 32 deletions

View File

@ -0,0 +1,651 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import yaml from 'js-yaml';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BusterConfig, ProjectContext } from '../schemas';
import { loadBusterConfig, resolveConfiguration } from './config-loader';
describe('config-loader', () => {
let testDir: string;
let testId: string;
beforeEach(async () => {
testId = Math.random().toString(36).substring(7);
testDir = join(tmpdir(), `buster-cli-test-${testId}`);
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
vi.clearAllMocks();
});
describe('loadBusterConfig', () => {
describe('when no buster.yml file exists', () => {
it('should throw error when no config files are found', async () => {
await expect(loadBusterConfig(testDir)).rejects.toThrow(
'No buster.yml found in the repository'
);
});
it('should throw error for non-existent path', async () => {
const nonExistentPath = join(testDir, 'does-not-exist');
await expect(loadBusterConfig(nonExistentPath)).rejects.toThrow(
`Path does not exist: ${nonExistentPath}`
);
});
it('should throw error for file instead of directory', async () => {
const filePath = join(testDir, 'file.txt');
await writeFile(filePath, 'content');
await expect(loadBusterConfig(filePath)).rejects.toThrow(
`Path is not a directory: ${filePath}`
);
});
});
describe('when single buster.yml exists', () => {
it('should load a valid buster.yml file', async () => {
const config: BusterConfig = {
projects: [
{
name: 'test-project',
data_source: 'postgres',
database: 'test_db',
schema: 'public',
},
],
};
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
const result = await loadBusterConfig(testDir);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe('test-project');
expect(result.projects[0].data_source).toBe('postgres');
});
it('should handle buster.yaml (with .yaml extension)', async () => {
const config: BusterConfig = {
projects: [
{
name: 'yaml-project',
data_source: 'mysql',
database: 'yaml_db',
schema: 'default',
},
],
};
await writeFile(join(testDir, 'buster.yaml'), yaml.dump(config));
const result = await loadBusterConfig(testDir);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe('yaml-project');
});
it('should skip invalid yaml files with warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeFile(join(testDir, 'buster.yml'), 'invalid: yaml: content:');
await expect(loadBusterConfig(testDir)).rejects.toThrow(
'No valid projects found in any buster.yml files'
);
expect(consoleSpy).toHaveBeenCalled();
});
it('should skip files with invalid schema', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const invalidConfig = {
projects: [
{
// Missing required fields
name: 'invalid-project',
},
],
};
await writeFile(join(testDir, 'buster.yml'), yaml.dump(invalidConfig));
await expect(loadBusterConfig(testDir)).rejects.toThrow(
'No valid projects found in any buster.yml files'
);
expect(consoleSpy).toHaveBeenCalled();
});
});
describe('when multiple buster.yml files exist in different directories', () => {
it('should find and merge configs from subdirectories', async () => {
// Create subdirectories
const subDir1 = join(testDir, 'project1');
const subDir2 = join(testDir, 'project2');
await mkdir(subDir1, { recursive: true });
await mkdir(subDir2, { recursive: true });
const config1: BusterConfig = {
projects: [
{
name: 'project1',
data_source: 'postgres',
database: 'db1',
schema: 'public',
},
],
};
const config2: BusterConfig = {
projects: [
{
name: 'project2',
data_source: 'mysql',
database: 'db2',
schema: 'default',
},
],
};
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
const result = await loadBusterConfig(testDir);
expect(result.projects).toHaveLength(2);
expect(result.projects.map((p) => p.name).sort()).toEqual(['project1', 'project2']);
});
it('should handle deeply nested directories', async () => {
const deepPath = join(testDir, 'a', 'b', 'c', 'd');
await mkdir(deepPath, { recursive: true });
const config: BusterConfig = {
projects: [
{
name: 'deep-project',
data_source: 'bigquery',
database: 'deep_db',
schema: 'dataset',
},
],
};
await writeFile(join(deepPath, 'buster.yml'), yaml.dump(config));
const result = await loadBusterConfig(testDir);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe('deep-project');
});
it('should skip common directories like node_modules', async () => {
const nodeModulesDir = join(testDir, 'node_modules');
const gitDir = join(testDir, '.git');
const distDir = join(testDir, 'dist');
await mkdir(nodeModulesDir, { recursive: true });
await mkdir(gitDir, { recursive: true });
await mkdir(distDir, { recursive: true });
// Add configs in directories that should be skipped
const ignoredConfig: BusterConfig = {
projects: [
{
name: 'ignored-project',
data_source: 'postgres',
database: 'ignored_db',
schema: 'public',
},
],
};
await writeFile(join(nodeModulesDir, 'buster.yml'), yaml.dump(ignoredConfig));
await writeFile(join(gitDir, 'buster.yml'), yaml.dump(ignoredConfig));
await writeFile(join(distDir, 'buster.yml'), yaml.dump(ignoredConfig));
await expect(loadBusterConfig(testDir)).rejects.toThrow(
'No buster.yml found in the repository'
);
});
});
describe('when handling duplicate projects', () => {
it('should warn about exact duplicates and keep first occurrence', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const subDir1 = join(testDir, 'dir1');
const subDir2 = join(testDir, 'dir2');
await mkdir(subDir1, { recursive: true });
await mkdir(subDir2, { recursive: true });
const duplicateProject: ProjectContext = {
name: 'duplicate-project',
data_source: 'postgres',
database: 'same_db',
schema: 'public',
};
const config1: BusterConfig = {
projects: [duplicateProject],
};
const config2: BusterConfig = {
projects: [{ ...duplicateProject }], // Same project
};
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
const result = await loadBusterConfig(testDir);
// Should only have one instance of the duplicate project
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe('duplicate-project');
// Should have warned about the duplicate
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Duplicate project'));
});
it('should allow projects with same name but different data sources', async () => {
const subDir1 = join(testDir, 'dir1');
const subDir2 = join(testDir, 'dir2');
await mkdir(subDir1, { recursive: true });
await mkdir(subDir2, { recursive: true });
const config1: BusterConfig = {
projects: [
{
name: 'my-project',
data_source: 'postgres',
database: 'pg_db',
schema: 'public',
},
],
};
const config2: BusterConfig = {
projects: [
{
name: 'my-project',
data_source: 'mysql', // Different data source
database: 'mysql_db',
schema: 'default',
},
],
};
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
const result = await loadBusterConfig(testDir);
// Both projects should be included since they have different data sources
expect(result.projects).toHaveLength(2);
expect(result.projects.map((p) => p.data_source).sort()).toEqual(['mysql', 'postgres']);
});
it('should handle multiple projects in same file with some duplicates in other files', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const subDir1 = join(testDir, 'dir1');
const subDir2 = join(testDir, 'dir2');
await mkdir(subDir1, { recursive: true });
await mkdir(subDir2, { recursive: true });
const config1: BusterConfig = {
projects: [
{
name: 'project-a',
data_source: 'postgres',
database: 'db_a',
schema: 'public',
},
{
name: 'project-b',
data_source: 'mysql',
database: 'db_b',
schema: 'default',
},
],
};
const config2: BusterConfig = {
projects: [
{
name: 'project-b', // Duplicate
data_source: 'mysql',
database: 'db_b',
schema: 'default',
},
{
name: 'project-c', // New project
data_source: 'bigquery',
database: 'db_c',
schema: 'dataset',
},
],
};
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
const result = await loadBusterConfig(testDir);
// Should have 3 unique projects
expect(result.projects).toHaveLength(3);
expect(result.projects.map((p) => p.name).sort()).toEqual([
'project-a',
'project-b',
'project-c',
]);
// Should warn about the duplicate
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Duplicate project 'project-b'")
);
});
});
describe('when handling edge cases', () => {
it('should handle empty projects array', async () => {
const config: BusterConfig = {
projects: [],
};
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
await expect(loadBusterConfig(testDir)).rejects.toThrow(
'No valid projects found in any buster.yml files'
);
});
it('should provide informative console output', async () => {
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const subDir = join(testDir, 'subdir');
await mkdir(subDir, { recursive: true });
const config1: BusterConfig = {
projects: [
{
name: 'project1',
data_source: 'postgres',
database: 'db',
schema: 'public',
},
],
};
const config2: BusterConfig = {
projects: [
{
name: 'project2',
data_source: 'mysql',
database: 'db',
schema: 'default',
},
],
};
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config1));
await writeFile(join(subDir, 'buster.yml'), yaml.dump(config2));
await loadBusterConfig(testDir);
// Should log search message
expect(consoleInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('Searching for buster.yml files')
);
// Should log found files count
expect(consoleInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('Found 2 buster.yml file(s)')
);
// Should log unique projects count
expect(consoleInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('Found 2 unique project(s)')
);
});
it('should handle multiple files where some have no valid projects', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const subDir1 = join(testDir, 'valid');
const subDir2 = join(testDir, 'invalid');
await mkdir(subDir1, { recursive: true });
await mkdir(subDir2, { recursive: true });
const validConfig: BusterConfig = {
projects: [
{
name: 'valid-project',
data_source: 'postgres',
database: 'db',
schema: 'public',
},
],
};
const invalidConfig = {
projects: [
{
// Missing required fields
name: 'invalid',
},
],
};
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(validConfig));
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(invalidConfig));
const result = await loadBusterConfig(testDir);
// Should only have the valid project
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe('valid-project');
// Should have warned about invalid config
expect(consoleSpy).toHaveBeenCalled();
});
it('should handle complex project structures with mixed patterns', async () => {
// Create a complex directory structure
const projectA = join(testDir, 'services', 'api');
const projectB = join(testDir, 'services', 'web');
const projectC = join(testDir, 'data', 'analytics');
await mkdir(projectA, { recursive: true });
await mkdir(projectB, { recursive: true });
await mkdir(projectC, { recursive: true });
const apiConfig: BusterConfig = {
projects: [
{
name: 'api-postgres',
data_source: 'postgres',
database: 'api_db',
schema: 'public',
include: ['models/**/*.yml'],
exclude: ['**/test/**'],
},
{
name: 'api-redis',
data_source: 'redis',
database: 'cache',
schema: 'default',
},
],
};
const webConfig: BusterConfig = {
projects: [
{
name: 'web-analytics',
data_source: 'bigquery',
database: 'analytics',
schema: 'web_events',
},
],
};
const analyticsConfig: BusterConfig = {
projects: [
{
name: 'data-warehouse',
data_source: 'snowflake',
database: 'warehouse',
schema: 'prod',
include: ['**/*.sql', '**/*.yml'],
},
{
name: 'api-postgres', // Duplicate from api config
data_source: 'postgres',
database: 'api_db',
schema: 'public',
include: ['models/**/*.yml'],
exclude: ['**/test/**'],
},
],
};
await writeFile(join(projectA, 'buster.yml'), yaml.dump(apiConfig));
await writeFile(join(projectB, 'buster.yml'), yaml.dump(webConfig));
await writeFile(join(projectC, 'buster.yml'), yaml.dump(analyticsConfig));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await loadBusterConfig(testDir);
// Should have 4 unique projects (api-postgres is duplicate)
expect(result.projects).toHaveLength(4);
const projectNames = result.projects.map((p) => p.name).sort();
expect(projectNames).toEqual([
'api-postgres',
'api-redis',
'data-warehouse',
'web-analytics',
]);
// Should have warned about duplicate
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Duplicate project 'api-postgres'")
);
});
});
});
describe('resolveConfiguration', () => {
const createTestConfig = (projects: ProjectContext[]): BusterConfig => ({
projects,
});
it('should resolve configuration for first project when no name specified', () => {
const config = createTestConfig([
{
name: 'first-project',
data_source: 'postgres',
database: 'first_db',
schema: 'public',
},
{
name: 'second-project',
data_source: 'mysql',
database: 'second_db',
schema: 'default',
},
]);
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
expect(resolved.data_source_name).toBe('postgres');
expect(resolved.database).toBe('first_db');
expect(resolved.schema).toBe('public');
});
it('should resolve configuration for named project', () => {
const config = createTestConfig([
{
name: 'first-project',
data_source: 'postgres',
database: 'first_db',
schema: 'public',
},
{
name: 'second-project',
data_source: 'mysql',
database: 'second_db',
schema: 'default',
},
]);
const resolved = resolveConfiguration(
config,
{ dryRun: false, verbose: false },
'second-project'
);
expect(resolved.data_source_name).toBe('mysql');
expect(resolved.database).toBe('second_db');
expect(resolved.schema).toBe('default');
});
it('should throw error when specified project not found', () => {
const config = createTestConfig([
{
name: 'existing-project',
data_source: 'postgres',
database: 'db',
schema: 'public',
},
]);
expect(() =>
resolveConfiguration(config, { dryRun: false, verbose: false }, 'non-existent')
).toThrow("Project 'non-existent' not found in buster.yml");
});
it('should throw error when no projects defined', () => {
const config = createTestConfig([]);
expect(() => resolveConfiguration(config, { dryRun: false, verbose: false })).toThrow(
'No projects defined in buster.yml'
);
});
it('should handle include and exclude patterns', () => {
const config = createTestConfig([
{
name: 'project-with-patterns',
data_source: 'postgres',
database: 'db',
schema: 'public',
include: ['models/**/*.yml', 'metrics/**/*.yml'],
exclude: ['**/test/**', '**/temp/**'],
},
]);
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
expect(resolved.include).toEqual(['models/**/*.yml', 'metrics/**/*.yml']);
expect(resolved.exclude).toEqual(['**/test/**', '**/temp/**']);
});
it('should use default include patterns when not specified', () => {
const config = createTestConfig([
{
name: 'project-without-patterns',
data_source: 'postgres',
database: 'db',
schema: 'public',
},
]);
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
expect(resolved.include).toEqual(['**/*.yml', '**/*.yaml']);
expect(resolved.exclude).toEqual([]);
});
});
});

View File

@ -1,48 +1,166 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { readFile, readdir, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import yaml from 'js-yaml';
import {
type BusterConfig,
BusterConfigSchema,
type DeployOptions,
type ProjectContext,
type ResolvedConfig,
ResolvedConfigSchema,
} from '../schemas';
/**
* Find and load buster.yml configuration file
* Searches in the given path and parent directories
* Recursively find all buster.yml files in a directory and its subdirectories
*/
async function findBusterYmlFiles(dir: string): Promise<string[]> {
const files: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Skip common directories that shouldn't be scanned
if (
entry.name === 'node_modules' ||
entry.name === '.git' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.next' ||
entry.name === 'coverage' ||
entry.name === '.turbo'
) {
continue;
}
// Recursively search subdirectory
const subFiles = await findBusterYmlFiles(fullPath);
files.push(...subFiles);
} else if (entry.name === 'buster.yml' || entry.name === 'buster.yaml') {
files.push(fullPath);
}
}
} catch (error) {
// Silently skip directories we can't read
if (
(error as NodeJS.ErrnoException).code !== 'EACCES' &&
(error as NodeJS.ErrnoException).code !== 'EPERM'
) {
console.warn(`Warning: Error reading directory ${dir}:`, error);
}
}
return files;
}
/**
* Load and parse a single buster.yml file
*/
async function loadSingleBusterConfig(configPath: string): Promise<BusterConfig | null> {
try {
const content = await readFile(configPath, 'utf-8');
const rawConfig = yaml.load(content) as unknown;
// Validate and parse with Zod schema
const result = BusterConfigSchema.safeParse(rawConfig);
if (result.success) {
return result.data;
}
console.warn(`Warning: Invalid buster.yml at ${configPath}:`, result.error.issues);
return null;
} catch (error) {
console.warn(`Warning: Failed to read ${configPath}:`, error);
return null;
}
}
/**
* Check if two projects are duplicates (same name and data source)
*/
function areProjectsDuplicate(p1: ProjectContext, p2: ProjectContext): boolean {
return (
p1.name === p2.name &&
p1.data_source === p2.data_source &&
p1.database === p2.database &&
p1.schema === p2.schema
);
}
/**
* Find and load all buster.yml configuration files recursively
* Merges all found configurations and checks for duplicates
* @throws Error if no buster.yml is found
*/
export async function loadBusterConfig(searchPath = '.'): Promise<BusterConfig> {
const absolutePath = resolve(searchPath);
let currentPath = absolutePath;
// Search for buster.yml in current and parent directories
while (currentPath !== '/') {
const configPath = join(currentPath, 'buster.yml');
if (existsSync(configPath)) {
const content = await readFile(configPath, 'utf-8');
const rawConfig = yaml.load(content) as unknown;
// Validate and parse with Zod schema
const result = BusterConfigSchema.safeParse(rawConfig);
if (result.success) {
return result.data;
}
throw new Error(`Invalid buster.yml at ${configPath}`);
}
// Move up one directory
const parentPath = join(currentPath, '..');
if (parentPath === currentPath) break; // Reached root
currentPath = parentPath;
// Check if the path exists
if (!existsSync(absolutePath)) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
throw new Error('No buster.yml found');
// Check if it's a directory
const pathStat = await stat(absolutePath);
if (!pathStat.isDirectory()) {
throw new Error(`Path is not a directory: ${absolutePath}`);
}
console.info(`🔍 Searching for buster.yml files in ${absolutePath}...`);
// Find all buster.yml files recursively
const configFiles = await findBusterYmlFiles(absolutePath);
if (configFiles.length === 0) {
throw new Error('No buster.yml found in the repository');
}
console.info(`📄 Found ${configFiles.length} buster.yml file(s):`);
for (const file of configFiles) {
console.info(` - ${relative(absolutePath, file)}`);
}
// Load all configurations
const allProjects: ProjectContext[] = [];
const configSources = new Map<ProjectContext, string>();
for (const configFile of configFiles) {
const config = await loadSingleBusterConfig(configFile);
if (config?.projects) {
for (const project of config.projects) {
// Check for duplicates
const existingProject = allProjects.find((p) => areProjectsDuplicate(p, project));
if (existingProject) {
const existingSource = configSources.get(existingProject);
console.warn(
`⚠️ Warning: Duplicate project '${project.name}' found:
First defined in: ${existingSource}
Also defined in: ${relative(absolutePath, configFile)}
Using the first definition.`
);
continue;
}
allProjects.push(project);
configSources.set(project, relative(absolutePath, configFile));
}
}
}
if (allProjects.length === 0) {
throw new Error('No valid projects found in any buster.yml files');
}
console.info(`✅ Found ${allProjects.length} unique project(s)`);
// Return merged configuration
return {
projects: allProjects,
};
}
/**

View File

@ -67,7 +67,7 @@ export function DeployCommand(props: DeployCommandProps) {
return (
<Box flexDirection='column'>
<BusterBanner showSubtitle={false} />
{/* Error state */}
{status === 'error' && (
<>

View File

@ -1,5 +1,6 @@
import { createBusterSDK } from '@buster/sdk';
import { Box, Text, useApp, useInput } from 'ink';
import { render } from 'ink';
import Spinner from 'ink-spinner';
import TextInput from 'ink-text-input';
import React, { useState, useEffect } from 'react';
@ -10,6 +11,9 @@ import {
hasCredentials,
saveCredentials,
} from '../utils/credentials.js';
import { DeployCommand } from './deploy/deploy.js';
import { DeployOptionsSchema } from './deploy/schemas.js';
import { InitCommand } from './init.js';
const DEFAULT_HOST = 'https://api2.buster.so';
const _LOCAL_HOST = 'http://localhost:3001';
@ -43,28 +47,105 @@ function WelcomeHeader() {
);
}
// Available commands definition
const COMMANDS = [
{ name: '/help', description: 'Show available commands' },
{ name: '/init', description: 'Initialize a new Buster project' },
{ name: '/deploy', description: 'Deploy semantic models to Buster API' },
{ name: '/clear', description: 'Clear the screen' },
{ name: '/exit', description: 'Exit the CLI' },
];
// Input box component for authenticated users
function CommandInput({ onSubmit }: { onSubmit: (input: string) => void }) {
const [input, setInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
// Filter commands based on input
const filteredCommands = input.startsWith('/')
? COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(input.toLowerCase()))
: COMMANDS;
const handleSubmit = () => {
if (input.trim()) {
// If suggestions are shown and we have a selection, use the selected command
if (showSuggestions && filteredCommands.length > 0) {
const selectedCommand = filteredCommands[selectedIndex];
if (selectedCommand) {
onSubmit(selectedCommand.name);
setInput('');
setShowSuggestions(false);
setSelectedIndex(0);
}
} else if (input.trim()) {
onSubmit(input);
setInput('');
setShowSuggestions(false);
setSelectedIndex(0);
}
};
const handleChange = (value: string) => {
setInput(value);
// Show suggestions when user starts typing a slash command
setShowSuggestions(value.startsWith('/') && value.length >= 1);
// Reset selection when input changes
setSelectedIndex(0);
};
// Handle keyboard navigation
useInput((_input, key) => {
if (!showSuggestions) return;
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredCommands.length - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < filteredCommands.length - 1 ? prev + 1 : 0));
} else if (key.tab) {
// Tab autocompletes the selected command
if (filteredCommands.length > 0) {
const selectedCommand = filteredCommands[selectedIndex];
if (selectedCommand) {
setInput(selectedCommand.name);
setShowSuggestions(false);
setSelectedIndex(0);
}
}
}
});
return (
<Box paddingX={2} paddingBottom={1}>
<Box flexDirection='column' paddingX={2} paddingBottom={1}>
<Box borderStyle='single' borderColor='#7C3AED' paddingX={1} width='100%'>
<Text color='#7C3AED'> </Text>
<TextInput
value={input}
onChange={setInput}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder='Enter a command or question...'
/>
</Box>
{/* Show command suggestions */}
{showSuggestions && filteredCommands.length > 0 && (
<Box flexDirection='column' marginTop={1} paddingX={1}>
<Text color='#7C3AED' bold>
Available Commands:
</Text>
{filteredCommands.map((cmd, index) => (
<Box key={cmd.name} marginTop={1}>
<Text color={index === selectedIndex ? 'green' : 'cyan'} bold>
{index === selectedIndex ? '▶ ' : ' '}
{cmd.name}
</Text>
<Text color={index === selectedIndex ? 'white' : 'gray'}> - {cmd.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor> Navigate Tab Autocomplete Enter Select</Text>
</Box>
</Box>
)}
</Box>
);
}
@ -217,9 +298,29 @@ export function Main() {
if (command === '/help') {
console.info('\nAvailable commands:');
console.info(' /help - Show this help message');
console.info(' /init - Initialize a new Buster project');
console.info(' /deploy - Deploy semantic models to Buster API');
console.info(' /clear - Clear the screen');
console.info(' /exit - Exit the CLI');
console.info('\nFor more information, visit https://docs.buster.so');
} else if (command === '/init') {
// Launch the init command
render(<InitCommand />);
} else if (command === '/deploy' || command.startsWith('/deploy ')) {
// Parse deploy options from the command
const parts = command.split(' ');
const options = {
path: process.cwd(),
dryRun: parts.includes('--dry-run'),
verbose: parts.includes('--verbose'),
};
try {
const parsedOptions = DeployOptionsSchema.parse(options);
render(<DeployCommand {...parsedOptions} />);
} catch (error) {
console.error('Invalid deploy options:', error);
}
} else if (command === '/clear') {
console.clear();
} else if (command === '/exit') {

View File

@ -37,4 +37,4 @@ export function BusterBanner({ showSubtitle = true, inline = false }: BannerProp
{content}
</Box>
);
}
}