From 4b9e5cf1fde3bae5d2d5519fcfa430d571694d00 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 28 Jul 2025 21:36:12 -0500 Subject: [PATCH] Squash commits. --- .aidev/commands/debug.md | 10 +++++ .aidev/commands/explain.md | 8 ++++ .aidev/commands/optimize.md | 8 ++++ .aidev/commands/refactor.md | 14 +++++++ .aidev/commands/review.md | 8 ++++ .aidev/commands/test.md | 8 ++++ src/chat/commands.ts | 27 +++++++++++- src/chat/commands/help.ts | 3 +- src/chat/completer.ts | 3 +- src/chat/template.ts | 50 ++++++++++++++++++++++ src/chat/user_command_handler.ts | 62 +++++++++++++++++++++++++++ src/chat/user_commands.ts | 72 ++++++++++++++++++++++++++++++++ src/util/yaml/frontmatter.ts | 31 ++++++++++++++ 13 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 .aidev/commands/debug.md create mode 100644 .aidev/commands/explain.md create mode 100644 .aidev/commands/optimize.md create mode 100644 .aidev/commands/refactor.md create mode 100644 .aidev/commands/review.md create mode 100644 .aidev/commands/test.md create mode 100644 src/chat/template.ts create mode 100644 src/chat/user_command_handler.ts create mode 100644 src/chat/user_commands.ts create mode 100644 src/util/yaml/frontmatter.ts diff --git a/.aidev/commands/debug.md b/.aidev/commands/debug.md new file mode 100644 index 0000000..70f85a5 --- /dev/null +++ b/.aidev/commands/debug.md @@ -0,0 +1,10 @@ +--- +description: "Help debug an issue" +args: + - name: problem + description: "Description of the problem or error" + - name: code + description: "Relevant code (optional)" +--- + +Help me debug this issue: {problem}. Here's the relevant code: {code} \ No newline at end of file diff --git a/.aidev/commands/explain.md b/.aidev/commands/explain.md new file mode 100644 index 0000000..1ad4837 --- /dev/null +++ b/.aidev/commands/explain.md @@ -0,0 +1,8 @@ +--- +description: "Explain code functionality in detail" +args: + - name: code + description: "Code to explain" +--- + +Explain what this code does, how it works, and its purpose: {code} \ No newline at end of file diff --git a/.aidev/commands/optimize.md b/.aidev/commands/optimize.md new file mode 100644 index 0000000..20065fc --- /dev/null +++ b/.aidev/commands/optimize.md @@ -0,0 +1,8 @@ +--- +description: "Optimize code for performance" +args: + - name: code + description: "Code to optimize" +--- + +Optimize this code for better performance while maintaining readability: {code} \ No newline at end of file diff --git a/.aidev/commands/refactor.md b/.aidev/commands/refactor.md new file mode 100644 index 0000000..167cdb1 --- /dev/null +++ b/.aidev/commands/refactor.md @@ -0,0 +1,14 @@ +--- +description: "Refactor code with specific focus" +args: + - name: code + description: "Code to refactor" + - name: focus + description: "Refactoring focus (e.g., performance, readability, maintainability)" +--- + +Refactor the following code focusing on {focus}: + +{code} + +Please ensure the refactored code maintains the same functionality while improving {focus}. \ No newline at end of file diff --git a/.aidev/commands/review.md b/.aidev/commands/review.md new file mode 100644 index 0000000..e78f0ed --- /dev/null +++ b/.aidev/commands/review.md @@ -0,0 +1,8 @@ +--- +description: "Review code for best practices and suggest improvements" +args: + - name: files + description: "Files or code to review" +--- + +Please review this code for best practices, potential bugs, and suggest improvements: {files} \ No newline at end of file diff --git a/.aidev/commands/test.md b/.aidev/commands/test.md new file mode 100644 index 0000000..3036f05 --- /dev/null +++ b/.aidev/commands/test.md @@ -0,0 +1,8 @@ +--- +description: "Generate unit tests for code" +args: + - name: code + description: "Code to generate tests for" +--- + +Generate comprehensive unit tests for this code: {code} \ No newline at end of file diff --git a/src/chat/commands.ts b/src/chat/commands.ts index b9cf515..cbc2322 100644 --- a/src/chat/commands.ts +++ b/src/chat/commands.ts @@ -30,8 +30,10 @@ import { unloadCommand } from './commands/unload' import { unstashCommand } from './commands/unstash' import { writeCommand } from './commands/write' import { ChatContext } from './context' +import { createUserCommandCompleter, createUserCommandHandler } from './user_command_handler' +import { loadUserCommands } from './user_commands' -export const commands: CommandDescription[] = [ +const builtinCommands: CommandDescription[] = [ branchCommand, clearCommand, continueCommand, @@ -62,11 +64,33 @@ export const commands: CommandDescription[] = [ writeCommand, ] +let allCommands: CommandDescription[] | undefined = undefined + +export async function getCommands(): Promise { + if (!allCommands) { + const userCommands = await loadUserCommands() + const userCommandDescriptions: CommandDescription[] = Object.entries(userCommands).map( + ([name, userCommand]) => ({ + prefix: `:${name}`, + description: userCommand.description, + expectsArgs: userCommand.args.length > 0, + handler: createUserCommandHandler(name, userCommand), + complete: userCommand.args.length > 0 ? createUserCommandCompleter(userCommand) : undefined, + }), + ) + + allCommands = [...builtinCommands, ...userCommandDescriptions] + } + + return allCommands +} + export async function handleCommand(context: ChatContext, message: string): Promise { const parts = message.split(' ') const command = parts[0] const args = parts.slice(1).join(' ') + const commands = await getCommands() for (const { prefix, handler, continuePrompt } of commands) { if (command === prefix) { await handler(context, args) @@ -88,6 +112,7 @@ export async function completeCommand(context: ChatContext, message: string): Pr return undefined } + const commands = await getCommands() for (const { prefix, expectsArgs, complete } of commands) { if (expectsArgs && command === prefix && prefix === message) { // Force insert missing space after command diff --git a/src/chat/commands/help.ts b/src/chat/commands/help.ts index 95a59c1..51c03b9 100644 --- a/src/chat/commands/help.ts +++ b/src/chat/commands/help.ts @@ -1,6 +1,6 @@ import chalk from 'chalk' import { CommandDescription } from '../command' -import { commands } from '../commands' +import { getCommands } from '../commands' import { ChatContext } from '../context' export const helpCommand: CommandDescription = { @@ -16,6 +16,7 @@ async function handleHelp(_context: ChatContext, args: string) { return } + const commands = await getCommands() const maxWidth = commands.reduce((max, { prefix }) => Math.max(max, prefix.length), 0) console.log() diff --git a/src/chat/completer.ts b/src/chat/completer.ts index c653c17..c3c7b88 100644 --- a/src/chat/completer.ts +++ b/src/chat/completer.ts @@ -1,5 +1,5 @@ import { CompleterResult } from 'readline' -import { commands, completeCommand } from './commands' +import { completeCommand, getCommands } from './commands' import { ChatContext } from './context' export type CompleterType = 'meta' | 'choice' @@ -21,6 +21,7 @@ export async function completer(context: ChatContext, line: string): Promise { + const commands = await getCommands() const prefixes = commands .filter(({ valid }) => valid?.(context) ?? true) .map(({ prefix, expectsArgs }) => prefix + (expectsArgs ? ' ' : '')) diff --git a/src/chat/template.ts b/src/chat/template.ts new file mode 100644 index 0000000..d96fd1e --- /dev/null +++ b/src/chat/template.ts @@ -0,0 +1,50 @@ +export function processTemplate(template: string, args: Record): string { + return template.replace(/\{(\w+)\}/g, (match, placeholder) => { + if (placeholder in args) { + return args[placeholder] + } + // Keep placeholder if no matching argument + return match + }) +} + +export function extractPlaceholders(template: string): string[] { + const matches = template.match(/\{(\w+)\}/g) + if (!matches) { + return [] + } + + return matches.map(match => match.slice(1, -1)) // Remove { and } +} + +export function parseArguments(input: string): string[] { + const args: string[] = [] + let current = '' + let inQuotes = false + let quoteChar = '' + + for (let i = 0; i < input.length; i++) { + const char = input[i] + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true + quoteChar = char + } else if (inQuotes && char === quoteChar) { + inQuotes = false + quoteChar = '' + } else if (!inQuotes && char === ' ') { + if (current.trim()) { + args.push(current.trim()) + current = '' + } + } else { + current += char + } + } + + if (current.trim()) { + args.push(current.trim()) + } + + return args +} diff --git a/src/chat/user_command_handler.ts b/src/chat/user_command_handler.ts new file mode 100644 index 0000000..77a915a --- /dev/null +++ b/src/chat/user_command_handler.ts @@ -0,0 +1,62 @@ +import { CompleterResult } from 'readline' +import chalk from 'chalk' +import { ChatContext } from './context' +import { parseArguments, processTemplate } from './template' +import { UserCommand } from './user_commands' + +export function createUserCommandHandler( + commandName: string, + userCommand: UserCommand, +): (context: ChatContext, args: string) => Promise { + return async (context: ChatContext, args: string) => { + const parsedArgs = parseArguments(args) + const expectedArgCount = userCommand.args.length + + // Validate argument count + if (parsedArgs.length !== expectedArgCount) { + const argNames = userCommand.args.map(arg => arg.name).join(', ') + console.log( + chalk.red.bold( + `Command :${commandName} expects ${expectedArgCount} argument${ + expectedArgCount === 1 ? '' : 's' + } (${argNames}), got ${parsedArgs.length}`, + ), + ) + console.log() + return + } + + // Map arguments to template placeholders + const argMap: Record = {} + for (let i = 0; i < userCommand.args.length; i++) { + argMap[userCommand.args[i].name] = parsedArgs[i] + } + + // Process template and submit as user message + const processedMessage = processTemplate(userCommand.template, argMap) + + // Submit the processed message back to the chat handler + // This allows for chaining meta-commands + const { handle } = await import('./handler') + await handle(context, processedMessage) + } +} + +export function createUserCommandCompleter( + userCommand: UserCommand, +): (context: ChatContext, args: string) => Promise { + return async (_context: ChatContext, args: string) => { + const parsedArgs = parseArguments(args) + const currentArgIndex = parsedArgs.length + + // If we haven't provided all arguments yet, show the next expected argument name + if (currentArgIndex < userCommand.args.length) { + const nextArg = userCommand.args[currentArgIndex] + const hint = `<${nextArg.name}>` + return [[hint], args] + } + + // All arguments provided, no completion + return [[], args] + } +} diff --git a/src/chat/user_commands.ts b/src/chat/user_commands.ts new file mode 100644 index 0000000..505bf13 --- /dev/null +++ b/src/chat/user_commands.ts @@ -0,0 +1,72 @@ +import { readdir } from 'fs/promises' +import path from 'path' +import { z } from 'zod' +import { exists } from '../util/fs/safe' +import { xdgConfigHome } from '../util/fs/xdgconfig' +import { loadMarkdownWithFrontmatter } from '../util/yaml/frontmatter' + +const UserCommandArgSchema = z.object({ + name: z.string(), + description: z.string(), +}) + +const UserCommandSchema = z.object({ + description: z.string(), + args: z.array(UserCommandArgSchema), + template: z.string(), +}) + +export type UserCommandArg = z.infer +export type UserCommand = z.infer + +export async function loadUserCommands(): Promise> { + const globalCommands = await loadUserCommandsFromDirectory(globalCommandsDirectory()) + const repoCommands = await loadUserCommandsFromDirectory(repoCommandsDirectory()) + + // Repository commands override global commands + return { ...globalCommands, ...repoCommands } +} + +async function loadUserCommandsFromDirectory(dirPath: string): Promise> { + if (!(await exists(dirPath))) { + return {} + } + + try { + const files = await readdir(dirPath) + const commands: Record = {} + + for (const file of files) { + if (!file.endsWith('.md')) { + continue + } + + const commandName = path.basename(file, '.md') + const filePath = path.join(dirPath, file) + + try { + const { frontmatter, content } = await loadMarkdownWithFrontmatter(filePath) + const parsedCommand = UserCommandSchema.parse({ + ...frontmatter, + template: content, + }) + commands[commandName] = parsedCommand + } catch (error) { + console.error(`Error loading user command from ${filePath}:`, error) + } + } + + return commands + } catch (error) { + console.error(`Error loading user commands from ${dirPath}:`, error) + return {} + } +} + +function globalCommandsDirectory(): string { + return path.join(xdgConfigHome(), 'aidev', 'commands') +} + +function repoCommandsDirectory(): string { + return path.join('.aidev', 'commands') +} diff --git a/src/util/yaml/frontmatter.ts b/src/util/yaml/frontmatter.ts new file mode 100644 index 0000000..50924c8 --- /dev/null +++ b/src/util/yaml/frontmatter.ts @@ -0,0 +1,31 @@ +import { readFile } from 'fs/promises' +import { parse } from 'yaml' + +export interface FrontmatterResult { + frontmatter: T + content: string +} + +export async function loadMarkdownWithFrontmatter(filePath: string): Promise> { + const fileContent = await readFile(filePath, 'utf-8') + return parseMarkdownWithFrontmatter(fileContent) +} + +export function parseMarkdownWithFrontmatter(content: string): FrontmatterResult { + const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/ + const match = content.match(frontmatterRegex) + + if (!match) { + return { + frontmatter: {} as T, + content: content.trim(), + } + } + + const [, frontmatterYaml, markdownContent] = match + + return { + frontmatter: parse(frontmatterYaml) as T, + content: markdownContent.trim(), + } +}