diff --git a/docs/custom-commands.md b/docs/custom-commands.md new file mode 100644 index 0000000..fed76a3 --- /dev/null +++ b/docs/custom-commands.md @@ -0,0 +1,93 @@ +# Custom CLI Commands + +MyCoder allows you to define custom CLI commands in your `mycoder.config.js` file. These commands can have arguments and will execute predefined prompts using JavaScript functions. + +## Configuration + +To add custom commands, add a `commands` section to your `mycoder.config.js` file: + +```js +// mycoder.config.js +export default { + // ... other config options + + // Custom commands + commands: { + search: { + description: 'Search for a term in the codebase', + args: [{ name: 'term', description: 'Search term', required: true }], + execute: (args) => { + return `Find all instances of ${args.term} in the codebase and suggest improvements`; + }, + }, + + 'fix-issue': { + description: 'Fix a GitHub issue', + args: [ + { name: 'issue', description: 'Issue number', required: true }, + { name: 'scope', description: 'Scope of the fix', default: 'full' }, + ], + execute: (args) => { + return `Analyze GitHub issue #${args.issue} and implement a ${args.scope} fix`; + }, + }, + }, +}; +``` + +## Command Structure + +Each command in the `commands` object has the following properties: + +- `description` (optional): A description of what the command does +- `args` (optional): An array of argument definitions + - `name`: The name of the argument + - `description` (optional): A description of the argument + - `required` (optional): Whether the argument is required (default: false) + - `default` (optional): Default value for the argument if not provided +- `execute` (required): A function that takes the arguments and returns a prompt string + +## Using Commands + +Once defined in your config file, you can use your custom commands like any other MyCoder command: + +```bash +# Using the search command +mycoder search "deprecated API" + +# Using the fix-issue command with all arguments +mycoder fix-issue 123 --scope partial + +# Using the fix-issue command with default scope +mycoder fix-issue 123 +``` + +## Advanced Usage + +The `execute` function can also be asynchronous, allowing you to fetch data or perform other async operations before generating the prompt: + +```js +"github-pr": { + description: "Review a GitHub PR", + args: [ + { name: "repo", description: "Repository name", required: true }, + { name: "pr", description: "PR number", required: true } + ], + execute: async (args) => { + // You could fetch PR details here if needed + return `Review GitHub PR #${args.pr} in repository ${args.repo} and provide feedback`; + } +} +``` + +## Command Naming + +Command names must: + +- Start with a letter +- Contain only letters, numbers, hyphens, and underscores + +## Limitations + +- Custom commands cannot override built-in commands +- The `execute` function must return a string (the prompt to execute) diff --git a/mycoder.config.js b/mycoder.config.js index ead98ee..b71f19f 100644 --- a/mycoder.config.js +++ b/mycoder.config.js @@ -23,4 +23,33 @@ export default { customPrompt: '', profile: false, tokenCache: true, + + // Custom commands + // Uncomment and modify to add your own commands + /* + commands: { + // Function-based command example + "search": { + description: "Search for a term in the codebase", + args: [ + { name: "term", description: "Search term", required: true } + ], + execute: (args) => { + return `Find all instances of ${args.term} in the codebase and suggest improvements`; + } + }, + + // Another example with multiple arguments + "fix-issue": { + description: "Fix a GitHub issue", + args: [ + { name: "issue", description: "Issue number", required: true }, + { name: "scope", description: "Scope of the fix", default: "full" } + ], + execute: (args) => { + return `Analyze GitHub issue #${args.issue} and implement a ${args.scope} fix`; + } + } + } + */ }; diff --git a/packages/cli/src/commands/$default.ts b/packages/cli/src/commands/$default.ts index a4370cd..a74c897 100644 --- a/packages/cli/src/commands/$default.ts +++ b/packages/cli/src/commands/$default.ts @@ -25,216 +25,230 @@ import { checkGitCli } from '../utils/gitCliCheck.js'; import { nameToLogIndex } from '../utils/nameToLogIndex.js'; import { checkForUpdates, getPackageInfo } from '../utils/versionCheck.js'; +import type { Config } from '../settings/config.js'; import type { CommandModule, Argv } from 'yargs'; interface DefaultArgs extends SharedOptions { prompt?: string; } -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) => { - const packageInfo = getPackageInfo(); - - // Get configuration for model provider and name - const config = await loadConfig(getConfigFromArgv(argv)); - - const logger = new Logger({ - name: 'Default', - logLevel: nameToLogIndex(config.logLevel), - customPrefix: subAgentTool.logPrefix, - }); - - logger.info( - `MyCoder v${packageInfo.version} - AI-powered coding assistant`, +/** + * Executes a prompt with the given configuration + * This function is exported to be reused by custom commands + */ +export async function executePrompt( + prompt: string, + config: Config, +): Promise { + const packageInfo = getPackageInfo(); + + const logger = new Logger({ + name: 'Default', + logLevel: nameToLogIndex(config.logLevel), + customPrefix: subAgentTool.logPrefix, + }); + + logger.info(`MyCoder v${packageInfo.version} - AI-powered coding assistant`); + + // Skip version check if upgradeCheck is false + if (config.upgradeCheck !== false) { + await checkForUpdates(logger); + } + + // Check for git and gh CLI tools if GitHub mode is enabled + if (config.githubMode) { + logger.debug( + 'GitHub mode is enabled, checking for git and gh CLI tools...', ); + const gitCliCheck = await checkGitCli(logger); - // Skip version check if upgradeCheck is false - if (config.upgradeCheck !== false) { - await checkForUpdates(logger); - } - - // Check for git and gh CLI tools if GitHub mode is enabled - if (config.githubMode) { - logger.debug( - 'GitHub mode is enabled, checking for git and gh CLI tools...', + if (gitCliCheck.errors.length > 0) { + logger.warn( + 'GitHub mode is enabled but there are issues with git/gh CLI tools:', ); - const gitCliCheck = await checkGitCli(logger); + gitCliCheck.errors.forEach((error) => logger.warn(`- ${error}`)); - if (gitCliCheck.errors.length > 0) { + if (!gitCliCheck.gitAvailable || !gitCliCheck.ghAvailable) { logger.warn( - 'GitHub mode is enabled but there are issues with git/gh CLI tools:', + 'GitHub mode requires git and gh CLI tools to be installed.', ); - gitCliCheck.errors.forEach((error) => logger.warn(`- ${error}`)); - - if (!gitCliCheck.gitAvailable || !gitCliCheck.ghAvailable) { - logger.warn( - 'GitHub mode requires git and gh CLI tools to be installed.', - ); - logger.warn( - 'Please install the missing tools or disable GitHub mode with --githubMode false', - ); - // Disable GitHub mode if git or gh CLI is not available - logger.info('Disabling GitHub mode due to missing CLI tools.'); - config.githubMode = false; - } else if (!gitCliCheck.ghAuthenticated) { - logger.warn( - 'GitHub CLI is not authenticated. Please run "gh auth login" to authenticate.', - ); - // Disable GitHub mode if gh CLI is not authenticated - logger.info( - 'Disabling GitHub mode due to unauthenticated GitHub CLI.', - ); - config.githubMode = false; - } - } else { - logger.info( - 'GitHub mode is enabled and all required CLI tools are available.', + logger.warn( + 'Please install the missing tools or disable GitHub mode with --githubMode false', ); + // Disable GitHub mode if git or gh CLI is not available + logger.info('Disabling GitHub mode due to missing CLI tools.'); + config.githubMode = false; + } else if (!gitCliCheck.ghAuthenticated) { + logger.warn( + 'GitHub CLI is not authenticated. Please run "gh auth login" to authenticate.', + ); + // Disable GitHub mode if gh CLI is not authenticated + logger.info('Disabling GitHub mode due to unauthenticated GitHub CLI.'); + config.githubMode = false; } + } else { + logger.info( + 'GitHub mode is enabled and all required CLI tools are available.', + ); } + } + + const tokenTracker = new TokenTracker( + 'Root', + undefined, + config.tokenUsage ? LogLevel.info : LogLevel.debug, + ); + // Use command line option if provided, otherwise use config value + tokenTracker.tokenCache = config.tokenCache; + + const backgroundTools = new BackgroundTools('mainAgent'); + + try { + // Early API key check based on model provider + const providerSettings = + providerConfig[config.provider as keyof typeof providerConfig]; + + if (providerSettings) { + const { keyName } = providerSettings; + + // First check if the API key is in the config + const configApiKey = config[keyName as keyof typeof config] as string; + // Then fall back to environment variable + const envApiKey = process.env[keyName]; + // Use config key if available, otherwise use env key + const apiKey = configApiKey || envApiKey; + + if (!apiKey) { + logger.error(getProviderApiKeyError(config.provider)); + throw new Error(`${config.provider} API key not found`); + } - const tokenTracker = new TokenTracker( - 'Root', - undefined, - config.tokenUsage ? LogLevel.info : LogLevel.debug, - ); - // Use command line option if provided, otherwise use config value - tokenTracker.tokenCache = config.tokenCache; - - const backgroundTools = new BackgroundTools('mainAgent'); - - try { - // Early API key check based on model provider - const providerSettings = - providerConfig[config.provider as keyof typeof providerConfig]; - - if (providerSettings) { - const { keyName } = providerSettings; - - // First check if the API key is in the config - const configApiKey = config[keyName as keyof typeof config] as string; - // Then fall back to environment variable - const envApiKey = process.env[keyName]; - // Use config key if available, otherwise use env key - const apiKey = configApiKey || envApiKey; - - if (!apiKey) { - logger.error(getProviderApiKeyError(config.provider)); - throw new Error(`${config.provider} API key not found`); - } - - // If we're using a key from config, set it as an environment variable - // This ensures it's available to the provider libraries - if (configApiKey && !envApiKey) { - process.env[keyName] = configApiKey; - logger.info(`Using ${keyName} from configuration`); - } - } else if (config.provider === 'ollama') { - // For Ollama, we check if the base URL is set - const ollamaBaseUrl = argv.ollamaBaseUrl || config.ollamaBaseUrl; - logger.info(`Using Ollama with base URL: ${ollamaBaseUrl}`); - } else { - // Unknown provider - logger.info(`Unknown provider: ${config.provider}`); - throw new Error(`Unknown provider: ${config.provider}`); + // If we're using a key from config, set it as an environment variable + // This ensures it's available to the provider libraries + if (configApiKey && !envApiKey) { + process.env[keyName] = configApiKey; + logger.info(`Using ${keyName} from configuration`); } + } else if (config.provider === 'ollama') { + // For Ollama, we check if the base URL is set + logger.info(`Using Ollama with base URL: ${config.ollamaBaseUrl}`); + } else { + // Unknown provider + logger.info(`Unknown provider: ${config.provider}`); + throw new Error(`Unknown provider: ${config.provider}`); + } - let prompt: string | undefined; + // 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'); - // If promptFile is specified, read from file - if (argv.file) { - prompt = await fs.readFile(argv.file, 'utf-8'); - } + const tools = getTools({ + userPrompt: config.userPrompt, + mcpConfig: config.mcp, + }); - // 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; - } + // Error handling + process.on('SIGINT', () => { + logger.log( + tokenTracker.logLevel, + chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), + ); + process.exit(0); + }); - if (!prompt) { - logger.error( - 'No prompt provided. Either specify a prompt, use --promptFile, or run in --interactive mode.', - ); - throw new Error('No prompt provided'); - } + // Create a config for the agent + const agentConfig: AgentConfig = { + ...DEFAULT_CONFIG, + }; + + const result = await toolAgent(prompt, tools, agentConfig, { + logger, + headless: config.headless, + userSession: config.userSession, + pageFilter: config.pageFilter, + workingDirectory: '.', + tokenTracker, + githubMode: config.githubMode, + customPrompt: config.customPrompt, + tokenCache: config.tokenCache, + userPrompt: config.userPrompt, + provider: config.provider as ModelProvider, + model: config.model, + maxTokens: config.maxTokens, + temperature: config.temperature, + backgroundTools, + }); - // 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 output = + typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result, null, 2); + logger.info('\\n=== Result ===\\n', output); + } catch (error) { + logger.error( + 'An error occurred:', + errorToString(error), + error instanceof Error ? error.stack : '', + ); + // Capture the error with Sentry + captureException(error); + } finally { + await backgroundTools.cleanup(); + } + + logger.log( + tokenTracker.logLevel, + chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), + ); +} - const tools = getTools({ - userPrompt: config.userPrompt, - mcpConfig: config.mcp, - }); +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) => { + // Get configuration for model provider and name + const config = await loadConfig(getConfigFromArgv(argv)); - // Error handling - process.on('SIGINT', () => { - logger.log( - tokenTracker.logLevel, - chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), - ); - process.exit(0); - }); + let prompt: string | undefined; + + // If promptFile is specified, read from file + if (argv.file) { + prompt = await fs.readFile(argv.file, 'utf-8'); + } - // Create a config for the agent - const agentConfig: AgentConfig = { - ...DEFAULT_CONFIG, - }; - - const result = await toolAgent(prompt, tools, agentConfig, { - logger, - headless: config.headless, - userSession: config.userSession, - pageFilter: config.pageFilter, - workingDirectory: '.', - tokenTracker, - githubMode: config.githubMode, - customPrompt: config.customPrompt, - tokenCache: config.tokenCache, - userPrompt: config.userPrompt, - provider: config.provider as ModelProvider, - model: config.model, - maxTokens: config.maxTokens, - temperature: config.temperature, - backgroundTools, + // 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) { + const logger = new Logger({ + name: 'Default', + logLevel: nameToLogIndex(config.logLevel), + customPrefix: subAgentTool.logPrefix, }); - const output = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result, null, 2); - logger.info('\n=== Result ===\n', output); - } catch (error) { logger.error( - 'An error occurred:', - errorToString(error), - error instanceof Error ? error.stack : '', + 'No prompt provided. Either specify a prompt, use --promptFile, or run in --interactive mode.', ); - // Capture the error with Sentry - captureException(error); - } finally { - await backgroundTools.cleanup(); + throw new Error('No prompt provided'); } - logger.log( - tokenTracker.logLevel, - chalk.blueBright(`[Token Usage Total] ${tokenTracker.toString()}`), - ); + // Execute the prompt + await executePrompt(prompt, config); }, }; diff --git a/packages/cli/src/commands/custom.ts b/packages/cli/src/commands/custom.ts new file mode 100644 index 0000000..5965fa1 --- /dev/null +++ b/packages/cli/src/commands/custom.ts @@ -0,0 +1,57 @@ +import { CommandModule } from 'yargs'; + +import { loadConfig } from '../settings/config.js'; + +import { executePrompt } from './$default.js'; + +/** + * Gets custom commands defined in the config file + * @returns Array of command modules for custom commands + */ +export async function getCustomCommands(): Promise { + const config = await loadConfig(); + + if (!config.commands) { + return []; + } + + return Object.entries(config.commands).map(([name, commandConfig]) => { + return { + command: `${name} ${(commandConfig.args || []) + .map((arg) => (arg.required ? `<${arg.name}>` : `[${arg.name}]`)) + .join(' ')}`, + describe: commandConfig.description || `Custom command: ${name}`, + builder: (yargs) => { + // Register args as options + (commandConfig.args || []).forEach((arg) => { + yargs.option(arg.name, { + type: 'string', + description: arg.description, + default: arg.default, + demandOption: arg.required, + }); + }); + return yargs; + }, + handler: async (argv) => { + // Extract args + const args = (commandConfig.args || []).reduce( + (acc, arg) => { + acc[arg.name] = argv[arg.name] as string; + return acc; + }, + {} as Record, + ); + + // Load config + const config = await loadConfig(); + + // Execute the command + const prompt = await commandConfig.execute(args); + + // Execute the prompt using the default command handler + await executePrompt(prompt, config); + }, + }; + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ffbabf2..14b8952 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import yargs, { ArgumentsCamelCase, CommandModule } from 'yargs'; import { hideBin } from 'yargs/helpers'; import { command as defaultCommand } from './commands/$default.js'; +import { getCustomCommands } from './commands/custom.js'; import { command as testProfileCommand } from './commands/test-profile.js'; import { command as testSentryCommand } from './commands/test-sentry.js'; import { command as toolsCommand } from './commands/tools.js'; @@ -47,6 +48,10 @@ const main = async () => { // Set up yargs with the new CLI interface mark('Before yargs setup'); + + // Load custom commands from config + const customCommands = await getCustomCommands(); + const argv = await yargs(hideBin(process.argv)) .scriptName(packageInfo.name!) .version(packageInfo.version!) @@ -58,6 +63,7 @@ const main = async () => { testSentryCommand, testProfileCommand, toolsCommand, + ...customCommands, // Add custom commands ] as CommandModule[]) .strict() .showHelpOnFail(true) diff --git a/packages/cli/src/settings/config.ts b/packages/cli/src/settings/config.ts index 6bbf0ef..21b8ea9 100644 --- a/packages/cli/src/settings/config.ts +++ b/packages/cli/src/settings/config.ts @@ -34,6 +34,21 @@ export type Config = { }>; defaultResources?: string[]; }; + + // Custom commands configuration + commands?: Record< + string, + { + description?: string; + args?: Array<{ + name: string; + description?: string; + required?: boolean; + default?: string; + }>; + execute: (args: Record) => string | Promise; + } + >; }; // Default configuration @@ -97,6 +112,31 @@ function removeUndefined(obj: any) { Object.entries(obj).filter(([_, value]) => value !== undefined), ); } + +/** + * Validates custom commands configuration + * @param config The configuration object + * @throws Error if any command configuration is invalid + */ +function validateCustomCommands(config: Config): void { + if (!config.commands) return; + + Object.entries(config.commands).forEach(([name, command]) => { + // Validate name (should be valid command name) + if (!/^[a-z][\w-]*$/.test(name)) { + throw new Error( + `Invalid command name: ${name}. Command names should start with a letter and contain only letters, numbers, hyphens, and underscores.`, + ); + } + + // Validate execute property + if (typeof command.execute !== 'function') { + throw new Error( + `Invalid execute property for command ${name}. Should be a function.`, + ); + } + }); +} /** * Load configuration using cosmiconfig * @returns Merged configuration with default values @@ -121,5 +161,9 @@ export async function loadConfig( ...removeUndefined(fileConfig), ...removeUndefined(cliOptions), }; + + // Validate custom commands if present + validateCustomCommands(mergedConfig); + return mergedConfig; }