Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .aidev/commands/debug.md
Original file line number Diff line number Diff line change
@@ -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}
8 changes: 8 additions & 0 deletions .aidev/commands/explain.md
Original file line number Diff line number Diff line change
@@ -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}
8 changes: 8 additions & 0 deletions .aidev/commands/optimize.md
Original file line number Diff line number Diff line change
@@ -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}
14 changes: 14 additions & 0 deletions .aidev/commands/refactor.md
Original file line number Diff line number Diff line change
@@ -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}.
8 changes: 8 additions & 0 deletions .aidev/commands/review.md
Original file line number Diff line number Diff line change
@@ -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}
8 changes: 8 additions & 0 deletions .aidev/commands/test.md
Original file line number Diff line number Diff line change
@@ -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}
27 changes: 26 additions & 1 deletion src/chat/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,11 +64,33 @@ export const commands: CommandDescription[] = [
writeCommand,
]

let allCommands: CommandDescription[] | undefined = undefined

export async function getCommands(): Promise<CommandDescription[]> {
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<boolean> {
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)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/chat/commands/help.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion src/chat/completer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,6 +21,7 @@ export async function completer(context: ChatContext, line: string): Promise<Com
}

async function metaCompleter(context: ChatContext, line: string): Promise<CompleterResult> {
const commands = await getCommands()
const prefixes = commands
.filter(({ valid }) => valid?.(context) ?? true)
.map(({ prefix, expectsArgs }) => prefix + (expectsArgs ? ' ' : ''))
Expand Down
50 changes: 50 additions & 0 deletions src/chat/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export function processTemplate(template: string, args: Record<string, string>): 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
}
62 changes: 62 additions & 0 deletions src/chat/user_command_handler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, string> = {}
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<CompleterResult> {
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]
}
}
72 changes: 72 additions & 0 deletions src/chat/user_commands.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UserCommandArgSchema>
export type UserCommand = z.infer<typeof UserCommandSchema>

export async function loadUserCommands(): Promise<Record<string, UserCommand>> {
const globalCommands = await loadUserCommandsFromDirectory(globalCommandsDirectory())
const repoCommands = await loadUserCommandsFromDirectory(repoCommandsDirectory())

// Repository commands override global commands
return { ...globalCommands, ...repoCommands }
}

async function loadUserCommandsFromDirectory(dirPath: string): Promise<Record<string, UserCommand>> {
if (!(await exists(dirPath))) {
return {}
}

try {
const files = await readdir(dirPath)
const commands: Record<string, UserCommand> = {}

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')
}
31 changes: 31 additions & 0 deletions src/util/yaml/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFile } from 'fs/promises'
import { parse } from 'yaml'

export interface FrontmatterResult<T = any> {
frontmatter: T
content: string
}

export async function loadMarkdownWithFrontmatter<T = any>(filePath: string): Promise<FrontmatterResult<T>> {
const fileContent = await readFile(filePath, 'utf-8')
return parseMarkdownWithFrontmatter<T>(fileContent)
}

export function parseMarkdownWithFrontmatter<T = any>(content: string): FrontmatterResult<T> {
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(),
}
}