diff --git a/packages/cli/src/commands/$default.refactored.ts b/packages/cli/src/commands/$default.refactored.ts new file mode 100644 index 0000000..dfad221 --- /dev/null +++ b/packages/cli/src/commands/$default.refactored.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs/promises'; + +import { userPrompt } from 'mycoder-agent'; + +import { getContainer } from '../di/container.js'; +import { SharedOptions } from '../options.js'; +import { CommandService } from '../services/command.service.js'; +import { LoggerService } from '../services/logger.service.js'; +import { ServiceFactory } from '../services/service.factory.js'; + +import type { CommandModule, Argv } from 'yargs'; + +interface DefaultArgs extends SharedOptions { + prompt?: string; +} + +/** + * Executes a prompt with the given configuration + * This function is exported to be reused by custom commands + */ +export async function executePrompt( + prompt: string, + _argv: DefaultArgs, // Prefix with underscore to indicate it's intentionally unused +): Promise { + const container = getContainer(); + + // Get the command service from the container + const commandService = container.get('commandService'); + + // Execute the command + await commandService.execute(prompt); +} + +export const command: CommandModule = { + command: '* [prompt]', + describe: 'Execute a prompt or start interactive mode', + builder: (yargs: Argv): Argv => { + return yargs.positional('prompt', { + type: 'string', + description: 'The prompt to execute', + }) as Argv; + }, + handler: async (argv) => { + // Initialize services + const serviceFactory = new ServiceFactory(); + await serviceFactory.initializeServices(argv); + + const container = getContainer(); + const loggerService = container.get('loggerService'); + const logger = loggerService.getDefaultLogger(); + + // Determine the prompt source + let prompt: string | undefined; + + // If promptFile is specified, read from file + if (argv.file) { + prompt = await fs.readFile(argv.file, 'utf-8'); + } + + // If interactive mode + if (argv.interactive) { + prompt = await userPrompt( + "Type your request below or 'help' for usage information. Use Ctrl+C to exit.", + ); + } else if (!prompt) { + // Use command line prompt if provided + prompt = argv.prompt; + } + + if (!prompt) { + logger.error( + 'No prompt provided. Either specify a prompt, use --promptFile, or run in --interactive mode.', + ); + throw new Error('No prompt provided'); + } + + // Execute the prompt + await executePrompt(prompt, argv); + }, +}; diff --git a/packages/cli/src/di/container.ts b/packages/cli/src/di/container.ts new file mode 100644 index 0000000..bc9d6c7 --- /dev/null +++ b/packages/cli/src/di/container.ts @@ -0,0 +1,79 @@ +/** + * Simple dependency injection container + * Manages service instances and dependencies + */ +export class Container { + private services: Map = new Map(); + + /** + * Register a service instance with the container + * @param name Service name/key + * @param instance Service instance + */ + register(name: string, instance: T): void { + this.services.set(name, instance); + } + + /** + * Get a service instance by name + * @param name Service name/key + * @returns Service instance + * @throws Error if service is not registered + */ + get(name: string): T { + if (!this.services.has(name)) { + throw new Error(`Service '${name}' not registered in container`); + } + return this.services.get(name) as T; + } + + /** + * Check if a service is registered + * @param name Service name/key + * @returns True if service is registered + */ + has(name: string): boolean { + return this.services.has(name); + } + + /** + * Remove a service from the container + * @param name Service name/key + * @returns True if service was removed + */ + remove(name: string): boolean { + return this.services.delete(name); + } + + /** + * Clear all registered services + */ + clear(): void { + this.services.clear(); + } +} + +// Global container instance +let globalContainer: Container | null = null; + +/** + * Get the global container instance + * Creates a new container if none exists + */ +export function getContainer(): Container { + if (!globalContainer) { + globalContainer = new Container(); + } + return globalContainer; +} + +/** + * Reset the global container + * Useful for testing + */ +export function resetContainer(): void { + if (globalContainer) { + globalContainer.clear(); + } + globalContainer = null; +} diff --git a/packages/cli/src/services/command.service.ts b/packages/cli/src/services/command.service.ts new file mode 100644 index 0000000..8fc82c3 --- /dev/null +++ b/packages/cli/src/services/command.service.ts @@ -0,0 +1,194 @@ +import chalk from 'chalk'; +import { + toolAgent, + Logger, + getTools, + getProviderApiKeyError, + providerConfig, + LogLevel, + errorToString, + DEFAULT_CONFIG, + AgentConfig, + ModelProvider, + BackgroundTools, +} from 'mycoder-agent'; +import { TokenTracker } from 'mycoder-agent/dist/core/tokens.js'; + +import { captureException } from '../sentry/index.js'; + +import { GitHubService } from './github.service.js'; +import { VersionService } from './version.service.js'; + +import type { Config } from '../settings/config.js'; + +/** + * Service for executing commands + * Handles the core logic of executing prompts with the AI agent + */ +export class CommandService { + private logger: Logger; + private config: Config; + private versionService: VersionService; + private githubService: GitHubService; + + /** + * Create a new CommandService + * @param logger Logger instance + * @param config Application configuration + * @param versionService Version service for update checks + * @param githubService GitHub service for GitHub mode validation + */ + constructor( + logger: Logger, + config: Config, + versionService: VersionService, + githubService: GitHubService, + ) { + this.logger = logger; + this.config = config; + this.versionService = versionService; + this.githubService = githubService; + } + + /** + * Execute a prompt with the AI agent + * @param prompt The prompt to execute + * @returns Promise that resolves when execution is complete + */ + async execute(prompt: string): Promise { + const packageInfo = this.versionService.getPackageInfo(); + + this.logger.info( + `MyCoder v${packageInfo.version} - AI-powered coding assistant`, + ); + + // Check for updates if enabled + await this.versionService.checkForUpdates(); + + // Validate GitHub mode if enabled + if (this.config.githubMode) { + this.config.githubMode = await this.githubService.validateGitHubMode(); + } + + const tokenTracker = new TokenTracker( + 'Root', + undefined, + this.config.tokenUsage ? LogLevel.info : LogLevel.debug, + ); + // Use command line option if provided, otherwise use config value + tokenTracker.tokenCache = this.config.tokenCache; + + const backgroundTools = new BackgroundTools('mainAgent'); + + try { + await this.executeWithAgent(prompt, tokenTracker, backgroundTools); + } catch (error) { + this.logger.error( + 'An error occurred:', + errorToString(error), + error instanceof Error ? error.stack : '', + ); + // Capture the error with Sentry + captureException(error); + } finally { + await backgroundTools.cleanup(); + } + + this.logger.log( + tokenTracker.logLevel, + chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), + ); + } + + /** + * Execute the prompt with the AI agent + * @param prompt The prompt to execute + * @param tokenTracker Token usage tracker + * @param backgroundTools Background tools manager + */ + private async executeWithAgent( + prompt: string, + tokenTracker: TokenTracker, + backgroundTools: BackgroundTools, + ): Promise { + // Early API key check based on model provider + const providerSettings = + providerConfig[this.config.provider as keyof typeof providerConfig]; + + if (!providerSettings) { + // Unknown provider + this.logger.info(`Unknown provider: ${this.config.provider}`); + throw new Error(`Unknown provider: ${this.config.provider}`); + } + + const { keyName } = providerSettings; + let apiKey: string | undefined = undefined; + if (keyName) { + // Then fall back to environment variable + apiKey = process.env[keyName]; + if (!apiKey) { + this.logger.error(getProviderApiKeyError(this.config.provider)); + throw new Error(`${this.config.provider} API key not found`); + } + } + + this.logger.info(`LLM: ${this.config.provider}/${this.config.model}`); + if (this.config.baseUrl) { + // For Ollama, we check if the base URL is set + this.logger.info(`Using base url: ${this.config.baseUrl}`); + } + console.log(); + + // Add the standard suffix to all prompts + prompt += [ + 'Please ask for clarifications if required or if the tasks is confusing.', + "If you need more context, don't be scared to create a sub-agent to investigate and generate report back, this can save a lot of time and prevent obvious mistakes.", + 'Once the task is complete ask the user, via the userPrompt tool if the results are acceptable or if changes are needed or if there are additional follow on tasks.', + ].join('\\n'); + + const tools = getTools({ + userPrompt: this.config.userPrompt, + mcpConfig: this.config.mcp, + }); + + // Error handling + process.on('SIGINT', () => { + this.logger.log( + tokenTracker.logLevel, + chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), + ); + process.exit(0); + }); + + // Create a config for the agent + const agentConfig: AgentConfig = { + ...DEFAULT_CONFIG, + }; + + const result = await toolAgent(prompt, tools, agentConfig, { + logger: this.logger, + headless: this.config.headless, + userSession: this.config.userSession, + pageFilter: this.config.pageFilter, + workingDirectory: '.', + tokenTracker, + githubMode: this.config.githubMode, + customPrompt: this.config.customPrompt, + tokenCache: this.config.tokenCache, + userPrompt: this.config.userPrompt, + provider: this.config.provider as ModelProvider, + baseUrl: this.config.baseUrl, + model: this.config.model, + maxTokens: this.config.maxTokens, + temperature: this.config.temperature, + backgroundTools, + apiKey, + }); + + const output = + typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result, null, 2); + this.logger.info('\\n=== Result ===\\n', output); + } +} diff --git a/packages/cli/src/services/config.service.ts b/packages/cli/src/services/config.service.ts new file mode 100644 index 0000000..bc5f0c0 --- /dev/null +++ b/packages/cli/src/services/config.service.ts @@ -0,0 +1,76 @@ +import { loadConfig, watchConfigForChanges } from '../settings/config.js'; + +import type { Config } from '../settings/config.js'; + +/** + * Service for managing application configuration + * Centralizes config loading and provides a single source of truth + */ +export class ConfigService { + private config: Config | null = null; + private unwatchFn: (() => void) | null = null; + + /** + * Load configuration from files and CLI options + * @param cliOptions Options provided via command line + * @returns The loaded configuration + */ + async load(cliOptions: Partial = {}): Promise { + this.config = await loadConfig(cliOptions); + return this.config; + } + + /** + * Get the current configuration + * @throws Error if configuration has not been loaded + * @returns The current configuration + */ + getConfig(): Config { + if (!this.config) { + throw new Error('Configuration not loaded. Call load() first.'); + } + return this.config; + } + + /** + * Watch configuration files for changes and update automatically + * @param cliOptions Options provided via command line + * @param onUpdate Optional callback when configuration changes + * @returns The current configuration + */ + async watchConfig( + cliOptions: Partial = {}, + onUpdate?: (config: Config) => void, + ): Promise { + // Unwatch previous config if any + if (this.unwatchFn) { + this.unwatchFn(); + this.unwatchFn = null; + } + + const { config, unwatch } = await watchConfigForChanges( + cliOptions, + (updatedConfig) => { + this.config = updatedConfig; + if (onUpdate) { + onUpdate(updatedConfig); + } + }, + ); + + this.config = config; + this.unwatchFn = unwatch; + + return config; + } + + /** + * Stop watching configuration files + */ + stopWatching(): void { + if (this.unwatchFn) { + this.unwatchFn(); + this.unwatchFn = null; + } + } +} diff --git a/packages/cli/src/services/github.service.ts b/packages/cli/src/services/github.service.ts new file mode 100644 index 0000000..9fbea46 --- /dev/null +++ b/packages/cli/src/services/github.service.ts @@ -0,0 +1,83 @@ +import { Logger } from 'mycoder-agent'; + +import { checkGitCli } from '../utils/gitCliCheck.js'; + +import type { Config } from '../settings/config.js'; +import type { GitCliCheckResult } from '../utils/gitCliCheck.js'; + +/** + * Service for managing GitHub-related functionality + * Handles GitHub mode and CLI tool validation + */ +export class GitHubService { + private logger: Logger; + private config: Config; + + /** + * Create a new GitHubService + * @param logger Logger instance + * @param config Application configuration + */ + constructor(logger: Logger, config: Config) { + this.logger = logger; + this.config = config; + } + + /** + * Check if GitHub mode should be enabled + * Validates git and gh CLI tools if GitHub mode is enabled + * @returns Updated GitHub mode status (may be disabled if tools are missing) + */ + async validateGitHubMode(): Promise { + // If GitHub mode is not enabled, no need to check + if (!this.config.githubMode) { + return false; + } + + this.logger.debug( + 'GitHub mode is enabled, checking for git and gh CLI tools...', + ); + const gitCliCheck = await checkGitCli(this.logger); + + return this.handleGitCliCheckResult(gitCliCheck); + } + + /** + * Process the results of the Git CLI check + * @param gitCliCheck Result of the Git CLI check + * @returns Whether GitHub mode should remain enabled + */ + private handleGitCliCheckResult(gitCliCheck: GitCliCheckResult): boolean { + if (gitCliCheck.errors.length > 0) { + this.logger.warn( + 'GitHub mode is enabled but there are issues with git/gh CLI tools:', + ); + gitCliCheck.errors.forEach((error) => this.logger.warn(`- ${error}`)); + + if (!gitCliCheck.gitAvailable || !gitCliCheck.ghAvailable) { + this.logger.warn( + 'GitHub mode requires git and gh CLI tools to be installed.', + ); + this.logger.warn( + 'Please install the missing tools or disable GitHub mode with --githubMode false', + ); + this.logger.info('Disabling GitHub mode due to missing CLI tools.'); + return false; + } else if (!gitCliCheck.ghAuthenticated) { + this.logger.warn( + 'GitHub CLI is not authenticated. Please run "gh auth login" to authenticate.', + ); + this.logger.info( + 'Disabling GitHub mode due to unauthenticated GitHub CLI.', + ); + return false; + } + } else { + this.logger.info( + 'GitHub mode is enabled and all required CLI tools are available.', + ); + } + + return true; + } +} diff --git a/packages/cli/src/services/logger.service.ts b/packages/cli/src/services/logger.service.ts new file mode 100644 index 0000000..052af43 --- /dev/null +++ b/packages/cli/src/services/logger.service.ts @@ -0,0 +1,75 @@ +import { Logger, subAgentTool } from 'mycoder-agent'; + +import { nameToLogIndex } from '../utils/nameToLogIndex.js'; + +import type { Config } from '../settings/config.js'; + +/** + * Centralized service for managing loggers + * Prevents creating multiple logger instances with the same configuration + */ +export class LoggerService { + private loggers: Map = new Map(); + private config: Config; + + /** + * Create a new LoggerService + * @param config The application configuration + */ + constructor(config: Config) { + this.config = config; + } + + /** + * Get or create a logger with the given name + * @param name The logger name + * @returns A Logger instance + */ + getLogger(name: string): Logger { + if (!this.loggers.has(name)) { + this.loggers.set( + name, + new Logger({ + name, + logLevel: nameToLogIndex(this.config.logLevel), + customPrefix: subAgentTool.logPrefix, + }), + ); + } + + return this.loggers.get(name)!; + } + + /** + * Get the default logger (named 'Default') + * @returns The default logger + */ + getDefaultLogger(): Logger { + return this.getLogger('Default'); + } + + /** + * Update all loggers with new configuration + * @param config The updated configuration + */ + updateConfig(config: Config): void { + this.config = config; + + // Create new loggers with updated log level instead of updating existing ones + // since Logger doesn't expose a setLogLevel method + const newLoggers = new Map(); + + this.loggers.forEach((logger, name) => { + newLoggers.set( + name, + new Logger({ + name, + logLevel: nameToLogIndex(config.logLevel), + customPrefix: subAgentTool.logPrefix, + }), + ); + }); + + this.loggers = newLoggers; + } +} diff --git a/packages/cli/src/services/service.factory.ts b/packages/cli/src/services/service.factory.ts new file mode 100644 index 0000000..ffa67f1 --- /dev/null +++ b/packages/cli/src/services/service.factory.ts @@ -0,0 +1,162 @@ +import { Logger } from 'mycoder-agent'; + +import { getContainer } from '../di/container.js'; + +import { CommandService } from './command.service.js'; +import { ConfigService } from './config.service.js'; +import { GitHubService } from './github.service.js'; +import { LoggerService } from './logger.service.js'; +import { VersionService } from './version.service.js'; + +import type { Container } from '../di/container.js'; +import type { SharedOptions } from '../options.js'; +import type { Config } from '../settings/config.js'; +import type { ArgumentsCamelCase } from 'yargs'; + +/** + * Factory for creating and registering services + * Manages the initialization and dependencies of services + */ +export class ServiceFactory { + private container: Container; + + /** + * Create a new ServiceFactory + * @param container Optional DI container (uses global container if not provided) + */ + constructor(container?: Container) { + this.container = container || getContainer(); + } + + /** + * Initialize all core services + * @param argv Command line arguments + * @returns Promise that resolves when services are initialized + */ + async initializeServices( + argv: ArgumentsCamelCase, + ): Promise { + // Create and register ConfigService + const configService = await this.createConfigService(argv); + + // Create and register LoggerService + const loggerService = this.createLoggerService(configService.getConfig()); + + // Create and register VersionService + const versionService = this.createVersionService( + loggerService.getDefaultLogger(), + configService.getConfig(), + ); + + // Create and register GitHubService + const githubService = this.createGitHubService( + loggerService.getDefaultLogger(), + configService.getConfig(), + ); + + // Create and register CommandService + this.createCommandService( + loggerService.getDefaultLogger(), + configService.getConfig(), + versionService, + githubService, + ); + } + + /** + * Create and register ConfigService + * @param argv Command line arguments + * @returns ConfigService instance + */ + private async createConfigService( + argv: ArgumentsCamelCase, + ): Promise { + const configService = new ConfigService(); + + // Convert argv to Partial + const cliOptions: Partial = { + logLevel: argv.logLevel as string, + tokenCache: argv.tokenCache as boolean, + provider: argv.provider as string, + model: argv.model as string, + maxTokens: argv.maxTokens as number, + temperature: argv.temperature as number, + profile: argv.profile as boolean, + githubMode: argv.githubMode as boolean, + userSession: argv.userSession as boolean, + pageFilter: argv.pageFilter as 'simple' | 'none' | 'readability', + headless: argv.headless as boolean, + baseUrl: argv.ollamaBaseUrl as string, + userPrompt: argv.userPrompt as boolean, + upgradeCheck: argv.upgradeCheck as boolean, + tokenUsage: argv.tokenUsage as boolean, + }; + + // Load configuration + await configService.load(cliOptions); + + // Register with container + this.container.register('configService', configService); + + return configService; + } + + /** + * Create and register LoggerService + * @param config Application configuration + * @returns LoggerService instance + */ + private createLoggerService(config: Config): LoggerService { + const loggerService = new LoggerService(config); + this.container.register('loggerService', loggerService); + return loggerService; + } + + /** + * Create and register VersionService + * @param logger Logger instance + * @param config Application configuration + * @returns VersionService instance + */ + private createVersionService(logger: Logger, config: Config): VersionService { + const versionService = new VersionService(logger, config); + this.container.register('versionService', versionService); + return versionService; + } + + /** + * Create and register GitHubService + * @param logger Logger instance + * @param config Application configuration + * @returns GitHubService instance + */ + private createGitHubService(logger: Logger, config: Config): GitHubService { + const githubService = new GitHubService(logger, config); + this.container.register('githubService', githubService); + return githubService; + } + + /** + * Create and register CommandService + * @param logger Logger instance + * @param config Application configuration + * @param versionService VersionService instance + * @param githubService GitHubService instance + * @returns CommandService instance + */ + private createCommandService( + logger: Logger, + config: Config, + versionService: VersionService, + githubService: GitHubService, + ): CommandService { + const commandService = new CommandService( + logger, + config, + versionService, + githubService, + ); + this.container.register('commandService', commandService); + return commandService; + } +} diff --git a/packages/cli/src/services/version.service.ts b/packages/cli/src/services/version.service.ts new file mode 100644 index 0000000..4105663 --- /dev/null +++ b/packages/cli/src/services/version.service.ts @@ -0,0 +1,47 @@ +import { Logger } from 'mycoder-agent'; + +import { checkForUpdates, getPackageInfo } from '../utils/versionCheck.js'; + +import type { Config } from '../settings/config.js'; + +/** + * Service for managing version-related functionality + * Handles version checking and update notifications + */ +export class VersionService { + private logger: Logger; + private config: Config; + + /** + * Create a new VersionService + * @param logger Logger instance + * @param config Application configuration + */ + constructor(logger: Logger, config: Config) { + this.logger = logger; + this.config = config; + } + + /** + * Get the current package information + * @returns Object with name and version + */ + getPackageInfo(): { name: string; version: string } { + return getPackageInfo(); + } + + /** + * Check for updates if enabled in config + * @returns Promise that resolves when check is complete + */ + async checkForUpdates(): Promise { + // Skip version check if upgradeCheck is false + if (this.config.upgradeCheck === false) { + this.logger.debug('Version check disabled by configuration'); + return; + } + + this.logger.debug('Checking for updates...'); + await checkForUpdates(this.logger); + } +}