@neuroequalityorg/knightcode
Version:
Knightcode CLI - Your local AI coding assistant using Ollama, LM Studio, and more
659 lines (546 loc) • 16.3 kB
text/typescript
/**
* Command System
*
* Provides a framework for registering, managing, and executing
* CLI commands. Handles argument parsing, validation, and help text.
*/
import { createUserError } from '../errors/formatter.js';
import { ErrorCategory } from '../errors/types.js';
import { logger } from '../utils/logger.js';
import { isNonEmptyString } from '../utils/validation.js';
import { registerCommands } from './register.js';
/**
* Command argument types
*/
export enum ArgType {
STRING = 'string',
NUMBER = 'number',
BOOLEAN = 'boolean',
ARRAY = 'array'
}
/**
* Command argument definition
*/
export interface CommandArgDef {
/**
* Argument name
*/
name: string;
/**
* Argument description
*/
description: string;
/**
* Argument type
*/
type: ArgType;
/**
* Whether the argument is required
*/
required?: boolean;
/**
* Default value if not provided
*/
default?: any;
/**
* Valid values (for enum-like arguments)
*/
choices?: string[];
/**
* For positional args, the position (0-based)
*/
position?: number;
/**
* Short flag (e.g., -v for --verbose)
*/
shortFlag?: string;
/**
* Whether to hide from help
*/
hidden?: boolean;
}
/**
* Command definition
*/
export interface CommandDef {
/**
* Command name
*/
name: string;
/**
* Command description
*/
description: string;
/**
* Command usage examples
*/
examples?: string[];
/**
* Command arguments
*/
args?: CommandArgDef[];
/**
* Command handler function
*/
handler: (args: Record<string, any>) => Promise<any>;
/**
* Command aliases
*/
aliases?: string[];
/**
* Command category for grouping in help
*/
category?: string;
/**
* Whether the command requires authentication
*/
requiresAuth?: boolean;
/**
* Whether the command can be used in interactive mode
*/
interactive?: boolean;
/**
* Whether to hide from help
*/
hidden?: boolean;
}
/**
* Command registry
*/
class CommandRegistry {
private commands: Map<string, CommandDef> = new Map();
private aliases: Map<string, string> = new Map();
/**
* Register a command
*/
register(command: CommandDef): void {
// Validate command definition
if (!isNonEmptyString(command.name)) {
throw new Error('Command name is required');
}
if (!isNonEmptyString(command.description)) {
throw new Error(`Command ${command.name} requires a description`);
}
if (!command.handler || typeof command.handler !== 'function') {
throw new Error(`Command ${command.name} requires a handler function`);
}
// Check for duplicate command names
if (this.commands.has(command.name) || this.aliases.has(command.name)) {
throw new Error(`Command or alias '${command.name}' is already registered`);
}
// Register the command
this.commands.set(command.name, command);
logger.debug(`Registered command: ${command.name}`);
// Register aliases
if (command.aliases && Array.isArray(command.aliases)) {
for (const alias of command.aliases) {
if (this.commands.has(alias) || this.aliases.has(alias)) {
logger.warn(`Skipping duplicate alias '${alias}' for command '${command.name}'`);
continue;
}
this.aliases.set(alias, command.name);
logger.debug(`Registered alias '${alias}' for command '${command.name}'`);
}
}
}
/**
* Get a command by name or alias
*/
get(nameOrAlias: string): CommandDef | undefined {
// Check if it's a direct command name
if (this.commands.has(nameOrAlias)) {
return this.commands.get(nameOrAlias);
}
// Check if it's an alias
const commandName = this.aliases.get(nameOrAlias);
if (commandName) {
return this.commands.get(commandName);
}
return undefined;
}
/**
* List all commands
*/
list(options: { includeHidden?: boolean } = {}): CommandDef[] {
const { includeHidden = false } = options;
return Array.from(this.commands.values())
.filter(cmd => includeHidden || !cmd.hidden);
}
/**
* Check if a command exists
*/
exists(nameOrAlias: string): boolean {
return this.commands.has(nameOrAlias) || this.aliases.has(nameOrAlias);
}
/**
* Get command categories
*/
getCategories(): string[] {
const categories = new Set<string>();
for (const command of this.commands.values()) {
if (command.category) {
categories.add(command.category);
}
}
return Array.from(categories).sort();
}
/**
* Get commands by category
*/
getByCategory(category: string, options: { includeHidden?: boolean } = {}): CommandDef[] {
const { includeHidden = false } = options;
return Array.from(this.commands.values())
.filter(cmd => (includeHidden || !cmd.hidden) && cmd.category === category);
}
}
// Create a singleton command registry
export const commandRegistry = new CommandRegistry();
/**
* Parse command-line arguments
*/
export function parseArgs(
args: string[],
command: CommandDef
): Record<string, any> {
const result: Record<string, any> = {};
const positionalArgs: string[] = [];
const flagArgs: Map<string, CommandArgDef> = new Map();
const errors: string[] = [];
// Initialize defaults
if (command.args) {
for (const arg of command.args) {
if (arg.default !== undefined) {
result[arg.name] = arg.default;
}
// Map flags to arg definitions
if (arg.position === undefined) {
// Flag argument (--name or -n)
flagArgs.set(`--${arg.name}`, arg);
if (arg.shortFlag) {
flagArgs.set(`-${arg.shortFlag}`, arg);
}
}
}
}
// Parse args
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--') || (arg.startsWith('-') && arg.length === 2)) {
// Flag argument
const argDef = flagArgs.get(arg);
if (!argDef) {
errors.push(`Unknown argument: ${arg}`);
continue;
}
if (argDef.type === ArgType.BOOLEAN) {
// Boolean flags don't need a value
result[argDef.name] = true;
} else {
// Other flags need a value
if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
errors.push(`Missing value for argument: ${arg}`);
continue;
}
const value = args[++i];
// Convert value based on type
result[argDef.name] = convertArgValue(value, argDef);
// Validate choices
if (argDef.choices && !argDef.choices.includes(String(result[argDef.name]))) {
errors.push(`Invalid value for ${argDef.name}: ${value}. Valid values are: ${argDef.choices.join(', ')}`);
}
}
} else {
// Positional argument
positionalArgs.push(arg);
}
}
// Process positional args
if (command.args) {
const positionalArgDefs = command.args
.filter(arg => arg.position !== undefined)
.sort((a, b) => (a.position || 0) - (b.position || 0));
for (let i = 0; i < positionalArgDefs.length; i++) {
const argDef = positionalArgDefs[i];
if (i < positionalArgs.length) {
// Value provided
result[argDef.name] = convertArgValue(positionalArgs[i], argDef);
// Validate choices
if (argDef.choices && !argDef.choices.includes(String(result[argDef.name]))) {
errors.push(`Invalid value for ${argDef.name}: ${positionalArgs[i]}. Valid values are: ${argDef.choices.join(', ')}`);
}
} else if (argDef.required) {
// Required value not provided
errors.push(`Missing required argument: ${argDef.name}`);
}
}
}
// Check for missing required flag args
if (command.args) {
for (const arg of command.args) {
if (arg.required && result[arg.name] === undefined) {
errors.push(`Missing required argument: ${arg.name}`);
}
}
}
// Throw if there are errors
if (errors.length > 0) {
throw createUserError(`Invalid arguments: ${errors.join('; ')}`, {
category: ErrorCategory.VALIDATION,
resolution: `Use 'claude-code help ${command.name}' to see usage information.`
});
}
return result;
}
/**
* Convert an argument value based on its type
*/
function convertArgValue(value: string, argDef: CommandArgDef): any {
switch (argDef.type) {
case ArgType.NUMBER:
const num = Number(value);
if (isNaN(num)) {
throw createUserError(`Invalid number: ${value}`, {
category: ErrorCategory.VALIDATION
});
}
return num;
case ArgType.BOOLEAN:
return value.toLowerCase() === 'true';
case ArgType.ARRAY:
return value.split(',').map(v => v.trim());
case ArgType.STRING:
default:
return value;
}
}
/**
* Generate help text for a command
*/
export function generateCommandHelp(command: CommandDef): string {
let help = `\n${command.name} - ${command.description}\n\n`;
// Usage
help += 'Usage:\n';
help += ` claude-code ${command.name}`;
// Add positional args to usage
if (command.args) {
const positionalArgs = command.args
.filter(arg => arg.position !== undefined)
.sort((a, b) => (a.position || 0) - (b.position || 0));
for (const arg of positionalArgs) {
const argDisplay = arg.required
? `<${arg.name}>`
: `[${arg.name}]`;
help += ` ${argDisplay}`;
}
}
// Add flag options to usage
if (command.args) {
const flagArgs = command.args.filter(arg => arg.position === undefined);
if (flagArgs.length > 0) {
help += ' [options]';
}
}
help += '\n\n';
// Arguments section
if (command.args && command.args.length > 0) {
// List positional args
const positionalArgs = command.args
.filter(arg => arg.position !== undefined && !arg.hidden)
.sort((a, b) => (a.position || 0) - (b.position || 0));
if (positionalArgs.length > 0) {
help += 'Arguments:\n';
for (const arg of positionalArgs) {
help += ` ${arg.name.padEnd(20)} ${arg.description}`;
if (arg.default !== undefined) {
help += ` (default: ${arg.default})`;
}
if (arg.choices) {
help += ` (choices: ${arg.choices.join(', ')})`;
}
help += '\n';
}
help += '\n';
}
// List flag options
const flagArgs = command.args.filter(arg =>
arg.position === undefined && !arg.hidden
);
if (flagArgs.length > 0) {
help += 'Options:\n';
for (const arg of flagArgs) {
let flag = `--${arg.name}`;
if (arg.shortFlag) {
flag = `-${arg.shortFlag}, ${flag}`;
}
help += ` ${flag.padEnd(20)} ${arg.description}`;
if (arg.default !== undefined) {
help += ` (default: ${arg.default})`;
}
if (arg.choices) {
help += ` (choices: ${arg.choices.join(', ')})`;
}
help += '\n';
}
help += '\n';
}
}
// Examples
if (command.examples && command.examples.length > 0) {
help += 'Examples:\n';
for (const example of command.examples) {
help += ` $ claude-code ${example}\n`;
}
help += '\n';
}
// Aliases
if (command.aliases && command.aliases.length > 0) {
help += `Aliases: ${command.aliases.join(', ')}\n\n`;
}
return help;
}
/**
* Execute a command
*/
export async function executeCommand(
commandName: string,
args: string[]
): Promise<any> {
const command = commandRegistry.get(commandName);
if (!command) {
throw createUserError(`Unknown command: ${commandName}`, {
category: ErrorCategory.COMMAND,
resolution: 'Use "claude-code help" to see available commands.'
});
}
try {
// Parse arguments
const parsedArgs = parseArgs(args, command);
// Log command execution
logger.debug(`Executing command: ${command.name}`, { args: parsedArgs });
// Execute the command
return await command.handler(parsedArgs);
} catch (error) {
logger.error(`Command ${command.name} failed:`, error);
throw error;
}
}
/**
* Initialize the command processor
*
* @param config Configuration options
* @param dependencies Application dependencies needed by commands
*/
export async function initCommandProcessor(
config: any,
dependencies: {
terminal: any;
auth: any;
ai: any;
codebase: any;
fileOps: any;
execution: any;
errors: any;
}
): Promise<any> {
logger.info('Initializing command processor');
try {
// Register all commands
registerCommands();
// Return the command processor interface
return {
/**
* Execute a command with the given arguments
*/
executeCommand: async (commandName: string, args: string[]): Promise<any> => {
return executeCommand(commandName, args);
},
/**
* Start the interactive command loop
*/
startCommandLoop: async (): Promise<void> => {
const { terminal } = dependencies;
let running = true;
// Command loop
while (running) {
try {
// Get command input from user
const input = await terminal.prompt({
type: 'input',
name: 'command',
message: 'claude-code>',
prefix: '',
});
if (!input.command || input.command.trim() === '') {
continue;
}
// Handle special exit commands
if (['exit', 'quit', 'q', '.exit'].includes(input.command.toLowerCase())) {
running = false;
continue;
}
// Parse input into command and args
const parts = input.command.trim().split(/\s+/);
const commandName = parts[0];
const commandArgs = parts.slice(1);
// Check if command exists
if (!commandRegistry.exists(commandName)) {
terminal.error(`Unknown command: ${commandName}`);
terminal.info('Type "help" to see available commands.');
continue;
}
// Execute the command
await executeCommand(commandName, commandArgs);
} catch (error) {
dependencies.errors.handleError(error);
}
}
},
/**
* Register a new command
*/
registerCommand: (command: CommandDef): void => {
commandRegistry.register(command);
},
/**
* Get a command by name or alias
*/
getCommand: (nameOrAlias: string): CommandDef | undefined => {
return commandRegistry.get(nameOrAlias);
},
/**
* List all registered commands
*/
listCommands: (options: { includeHidden?: boolean } = {}): CommandDef[] => {
return commandRegistry.list(options);
},
/**
* Get available command categories
*/
getCategories: (): string[] => {
return commandRegistry.getCategories();
},
/**
* Get commands by category
*/
getCommandsByCategory: (category: string, options: { includeHidden?: boolean } = {}): CommandDef[] => {
return commandRegistry.getByCategory(category, options);
},
/**
* Generate help text for a command
*/
generateCommandHelp: (command: CommandDef): string => {
return generateCommandHelp(command);
}
};
} catch (error) {
logger.error('Failed to initialize command processor', error);
throw error;
}
}
// Export main symbols
export default {
commandRegistry,
parseArgs,
executeCommand,
generateCommandHelp
};