Skip to content

Commit 8e162f4

Browse files
committed
feat: Add service-based architecture for CLI default command
This commit introduces a new service-based architecture for the CLI default command. The implementation includes: - Dependency Injection container for managing services - Service classes for specific functionality: - LoggerService: Centralized logger management - ConfigService: Configuration loading and management - VersionService: Version checking and updates - GitHubService: GitHub mode validation - CommandService: Core command execution logic - ServiceFactory: Factory for creating and initializing services The refactored implementation addresses issues with the current default command: - Reduces nested conditional logic - Eliminates duplicated configuration loading - Centralizes logger creation - Separates concerns into dedicated services - Improves testability and maintainability This is a proof-of-concept implementation that doesn't modify the existing code but demonstrates the proposed architecture. Refs: #276
1 parent 6a3a392 commit 8e162f4

File tree

8 files changed

+796
-0
lines changed

8 files changed

+796
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as fs from 'fs/promises';
2+
3+
import { userPrompt } from 'mycoder-agent';
4+
5+
import { getContainer } from '../di/container.js';
6+
import { SharedOptions } from '../options.js';
7+
import { CommandService } from '../services/command.service.js';
8+
import { LoggerService } from '../services/logger.service.js';
9+
import { ServiceFactory } from '../services/service.factory.js';
10+
11+
import type { CommandModule, Argv } from 'yargs';
12+
13+
interface DefaultArgs extends SharedOptions {
14+
prompt?: string;
15+
}
16+
17+
/**
18+
* Executes a prompt with the given configuration
19+
* This function is exported to be reused by custom commands
20+
*/
21+
export async function executePrompt(
22+
prompt: string,
23+
_argv: DefaultArgs, // Prefix with underscore to indicate it's intentionally unused
24+
): Promise<void> {
25+
const container = getContainer();
26+
27+
// Get the command service from the container
28+
const commandService = container.get<CommandService>('commandService');
29+
30+
// Execute the command
31+
await commandService.execute(prompt);
32+
}
33+
34+
export const command: CommandModule<SharedOptions, DefaultArgs> = {
35+
command: '* [prompt]',
36+
describe: 'Execute a prompt or start interactive mode',
37+
builder: (yargs: Argv<object>): Argv<DefaultArgs> => {
38+
return yargs.positional('prompt', {
39+
type: 'string',
40+
description: 'The prompt to execute',
41+
}) as Argv<DefaultArgs>;
42+
},
43+
handler: async (argv) => {
44+
// Initialize services
45+
const serviceFactory = new ServiceFactory();
46+
await serviceFactory.initializeServices(argv);
47+
48+
const container = getContainer();
49+
const loggerService = container.get<LoggerService>('loggerService');
50+
const logger = loggerService.getDefaultLogger();
51+
52+
// Determine the prompt source
53+
let prompt: string | undefined;
54+
55+
// If promptFile is specified, read from file
56+
if (argv.file) {
57+
prompt = await fs.readFile(argv.file, 'utf-8');
58+
}
59+
60+
// If interactive mode
61+
if (argv.interactive) {
62+
prompt = await userPrompt(
63+
"Type your request below or 'help' for usage information. Use Ctrl+C to exit.",
64+
);
65+
} else if (!prompt) {
66+
// Use command line prompt if provided
67+
prompt = argv.prompt;
68+
}
69+
70+
if (!prompt) {
71+
logger.error(
72+
'No prompt provided. Either specify a prompt, use --promptFile, or run in --interactive mode.',
73+
);
74+
throw new Error('No prompt provided');
75+
}
76+
77+
// Execute the prompt
78+
await executePrompt(prompt, argv);
79+
},
80+
};

packages/cli/src/di/container.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Simple dependency injection container
3+
* Manages service instances and dependencies
4+
*/
5+
export class Container {
6+
private services: Map<string, unknown> = new Map();
7+
8+
/**
9+
* Register a service instance with the container
10+
* @param name Service name/key
11+
* @param instance Service instance
12+
*/
13+
register<T>(name: string, instance: T): void {
14+
this.services.set(name, instance);
15+
}
16+
17+
/**
18+
* Get a service instance by name
19+
* @param name Service name/key
20+
* @returns Service instance
21+
* @throws Error if service is not registered
22+
*/
23+
get<T>(name: string): T {
24+
if (!this.services.has(name)) {
25+
throw new Error(`Service '${name}' not registered in container`);
26+
}
27+
return this.services.get(name) as T;
28+
}
29+
30+
/**
31+
* Check if a service is registered
32+
* @param name Service name/key
33+
* @returns True if service is registered
34+
*/
35+
has(name: string): boolean {
36+
return this.services.has(name);
37+
}
38+
39+
/**
40+
* Remove a service from the container
41+
* @param name Service name/key
42+
* @returns True if service was removed
43+
*/
44+
remove(name: string): boolean {
45+
return this.services.delete(name);
46+
}
47+
48+
/**
49+
* Clear all registered services
50+
*/
51+
clear(): void {
52+
this.services.clear();
53+
}
54+
}
55+
56+
// Global container instance
57+
let globalContainer: Container | null = null;
58+
59+
/**
60+
* Get the global container instance
61+
* Creates a new container if none exists
62+
*/
63+
export function getContainer(): Container {
64+
if (!globalContainer) {
65+
globalContainer = new Container();
66+
}
67+
return globalContainer;
68+
}
69+
70+
/**
71+
* Reset the global container
72+
* Useful for testing
73+
*/
74+
export function resetContainer(): void {
75+
if (globalContainer) {
76+
globalContainer.clear();
77+
}
78+
globalContainer = null;
79+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import chalk from 'chalk';
2+
import {
3+
toolAgent,
4+
Logger,
5+
getTools,
6+
getProviderApiKeyError,
7+
providerConfig,
8+
LogLevel,
9+
errorToString,
10+
DEFAULT_CONFIG,
11+
AgentConfig,
12+
ModelProvider,
13+
BackgroundTools,
14+
} from 'mycoder-agent';
15+
import { TokenTracker } from 'mycoder-agent/dist/core/tokens.js';
16+
17+
import { captureException } from '../sentry/index.js';
18+
19+
import { GitHubService } from './github.service.js';
20+
import { VersionService } from './version.service.js';
21+
22+
import type { Config } from '../settings/config.js';
23+
24+
/**
25+
* Service for executing commands
26+
* Handles the core logic of executing prompts with the AI agent
27+
*/
28+
export class CommandService {
29+
private logger: Logger;
30+
private config: Config;
31+
private versionService: VersionService;
32+
private githubService: GitHubService;
33+
34+
/**
35+
* Create a new CommandService
36+
* @param logger Logger instance
37+
* @param config Application configuration
38+
* @param versionService Version service for update checks
39+
* @param githubService GitHub service for GitHub mode validation
40+
*/
41+
constructor(
42+
logger: Logger,
43+
config: Config,
44+
versionService: VersionService,
45+
githubService: GitHubService,
46+
) {
47+
this.logger = logger;
48+
this.config = config;
49+
this.versionService = versionService;
50+
this.githubService = githubService;
51+
}
52+
53+
/**
54+
* Execute a prompt with the AI agent
55+
* @param prompt The prompt to execute
56+
* @returns Promise that resolves when execution is complete
57+
*/
58+
async execute(prompt: string): Promise<void> {
59+
const packageInfo = this.versionService.getPackageInfo();
60+
61+
this.logger.info(
62+
`MyCoder v${packageInfo.version} - AI-powered coding assistant`,
63+
);
64+
65+
// Check for updates if enabled
66+
await this.versionService.checkForUpdates();
67+
68+
// Validate GitHub mode if enabled
69+
if (this.config.githubMode) {
70+
this.config.githubMode = await this.githubService.validateGitHubMode();
71+
}
72+
73+
const tokenTracker = new TokenTracker(
74+
'Root',
75+
undefined,
76+
this.config.tokenUsage ? LogLevel.info : LogLevel.debug,
77+
);
78+
// Use command line option if provided, otherwise use config value
79+
tokenTracker.tokenCache = this.config.tokenCache;
80+
81+
const backgroundTools = new BackgroundTools('mainAgent');
82+
83+
try {
84+
await this.executeWithAgent(prompt, tokenTracker, backgroundTools);
85+
} catch (error) {
86+
this.logger.error(
87+
'An error occurred:',
88+
errorToString(error),
89+
error instanceof Error ? error.stack : '',
90+
);
91+
// Capture the error with Sentry
92+
captureException(error);
93+
} finally {
94+
await backgroundTools.cleanup();
95+
}
96+
97+
this.logger.log(
98+
tokenTracker.logLevel,
99+
chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`),
100+
);
101+
}
102+
103+
/**
104+
* Execute the prompt with the AI agent
105+
* @param prompt The prompt to execute
106+
* @param tokenTracker Token usage tracker
107+
* @param backgroundTools Background tools manager
108+
*/
109+
private async executeWithAgent(
110+
prompt: string,
111+
tokenTracker: TokenTracker,
112+
backgroundTools: BackgroundTools,
113+
): Promise<void> {
114+
// Early API key check based on model provider
115+
const providerSettings =
116+
providerConfig[this.config.provider as keyof typeof providerConfig];
117+
118+
if (!providerSettings) {
119+
// Unknown provider
120+
this.logger.info(`Unknown provider: ${this.config.provider}`);
121+
throw new Error(`Unknown provider: ${this.config.provider}`);
122+
}
123+
124+
const { keyName } = providerSettings;
125+
let apiKey: string | undefined = undefined;
126+
if (keyName) {
127+
// Then fall back to environment variable
128+
apiKey = process.env[keyName];
129+
if (!apiKey) {
130+
this.logger.error(getProviderApiKeyError(this.config.provider));
131+
throw new Error(`${this.config.provider} API key not found`);
132+
}
133+
}
134+
135+
this.logger.info(`LLM: ${this.config.provider}/${this.config.model}`);
136+
if (this.config.baseUrl) {
137+
// For Ollama, we check if the base URL is set
138+
this.logger.info(`Using base url: ${this.config.baseUrl}`);
139+
}
140+
console.log();
141+
142+
// Add the standard suffix to all prompts
143+
prompt += [
144+
'Please ask for clarifications if required or if the tasks is confusing.',
145+
"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.",
146+
'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.',
147+
].join('\\n');
148+
149+
const tools = getTools({
150+
userPrompt: this.config.userPrompt,
151+
mcpConfig: this.config.mcp,
152+
});
153+
154+
// Error handling
155+
process.on('SIGINT', () => {
156+
this.logger.log(
157+
tokenTracker.logLevel,
158+
chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`),
159+
);
160+
process.exit(0);
161+
});
162+
163+
// Create a config for the agent
164+
const agentConfig: AgentConfig = {
165+
...DEFAULT_CONFIG,
166+
};
167+
168+
const result = await toolAgent(prompt, tools, agentConfig, {
169+
logger: this.logger,
170+
headless: this.config.headless,
171+
userSession: this.config.userSession,
172+
pageFilter: this.config.pageFilter,
173+
workingDirectory: '.',
174+
tokenTracker,
175+
githubMode: this.config.githubMode,
176+
customPrompt: this.config.customPrompt,
177+
tokenCache: this.config.tokenCache,
178+
userPrompt: this.config.userPrompt,
179+
provider: this.config.provider as ModelProvider,
180+
baseUrl: this.config.baseUrl,
181+
model: this.config.model,
182+
maxTokens: this.config.maxTokens,
183+
temperature: this.config.temperature,
184+
backgroundTools,
185+
apiKey,
186+
});
187+
188+
const output =
189+
typeof result.result === 'string'
190+
? result.result
191+
: JSON.stringify(result.result, null, 2);
192+
this.logger.info('\\n=== Result ===\\n', output);
193+
}
194+
}

0 commit comments

Comments
 (0)