Skip to content
Merged
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
7 changes: 5 additions & 2 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ prepare-commit-msg:
skip:
- merge
- rebase
jobs:
- run: ./dist/cli.js --message-only > {1}
commands:
commitment:
# Only run for regular commits (not merge, squash, or when message specified)
# {1} is the commit message file, {2} is the commit source
run: '[ -z "{2}" ] && ./dist/cli.js --message-only > {1} || true'
interactive: true
13 changes: 13 additions & 0 deletions src/agents/__tests__/base-agent.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mock.module('../../utils/shell.js', () => ({
exec: mockExec,
}));

import { SilentLogger } from '../../utils/logger';
import { BaseAgent } from '../base-agent.js';
import type { Agent } from '../types.js';

Expand All @@ -22,6 +23,10 @@ class TestAgent extends BaseAgent {
// Track calls to verify execution order
public callOrder: string[] = [];

constructor() {
super(new SilentLogger());
}

// Make executeCommand public for testing (override access modifier)
public async executeCommand(_prompt: string, _workdir: string): Promise<string> {
this.callOrder.push('executeCommand');
Expand Down Expand Up @@ -52,6 +57,10 @@ class TestAgent extends BaseAgent {
class CustomCleanAgent extends BaseAgent {
readonly name = 'CustomCleanAgent';

constructor() {
super(new SilentLogger());
}

// Make executeCommand public for testing
public async executeCommand(_prompt: string, _workdir: string): Promise<string> {
return '[CUSTOM]feat: test\n\nDescription';
Expand All @@ -71,6 +80,10 @@ class CustomCleanAgent extends BaseAgent {
class CustomValidateAgent extends BaseAgent {
readonly name = 'CustomValidateAgent';

constructor() {
super(new SilentLogger());
}

// Make executeCommand public for testing
public async executeCommand(_prompt: string, _workdir: string): Promise<string> {
return 'feat: test';
Expand Down
25 changes: 25 additions & 0 deletions src/agents/base-agent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Logger } from '../utils/logger';
import { exec } from '../utils/shell.js';
import { cleanAIResponse, validateConventionalCommit } from './agent-utils';
import type { Agent } from './types';
Expand Down Expand Up @@ -65,6 +66,11 @@ import type { Agent } from './types';
* ```
*/
export abstract class BaseAgent implements Agent {
/**
* Optional logger for debugging and diagnostics
*/
logger?: Logger;

/**
* Human-readable name of the agent
*
Expand All @@ -74,6 +80,15 @@ export abstract class BaseAgent implements Agent {
*/
abstract readonly name: string;

/**
* Constructor accepting optional logger
*
* @param logger - Optional logger for debugging
*/
constructor(logger?: Logger) {
this.logger = logger;
}

/**
* Template method for generating commit messages
*
Expand All @@ -92,17 +107,27 @@ export abstract class BaseAgent implements Agent {
* @throws {Error} If CLI not found, execution fails, or validation fails
*/
async generate(prompt: string, workdir: string): Promise<string> {
this.logger?.debug(`[${this.name}] Starting commit message generation`);

// Step 1: Check CLI availability
this.logger?.debug(`[${this.name}] Checking CLI availability`);
await this.checkAvailability(this.name, workdir);
this.logger?.debug(`[${this.name}] CLI is available`);

// Step 2: Execute agent-specific command
this.logger?.debug(`[${this.name}] Executing command`);
const rawOutput = await this.executeCommand(prompt, workdir);
this.logger?.debug(`[${this.name}] Command executed, output length: ${rawOutput.length}`);

// Step 3: Clean response (remove artifacts, normalize whitespace)
this.logger?.debug(`[${this.name}] Cleaning response`);
const cleanedOutput = this.cleanResponse(rawOutput);
this.logger?.debug(`[${this.name}] Response cleaned, length: ${cleanedOutput.length}`);

// Step 4: Validate response (check conventional commit format)
this.logger?.debug(`[${this.name}] Validating response format`);
this.validateResponse(cleanedOutput);
this.logger?.debug(`[${this.name}] Response validated successfully`);

return cleanedOutput;
}
Expand Down
13 changes: 11 additions & 2 deletions src/agents/factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { match } from 'ts-pattern';
import type { Logger } from '../utils/logger';

import { ClaudeAgent } from './claude';
import { CodexAgent } from './codex';
Expand All @@ -13,6 +14,7 @@ import type { Agent, AgentName } from './types';
* to the type but not handled here, TypeScript will error.
*
* @param name - The agent name ('claude', 'codex', or 'gemini')
* @param logger - Optional logger for debugging
* @returns Agent instance for the specified name
*
* @example
Expand All @@ -21,10 +23,17 @@ import type { Agent, AgentName } from './types';
* const message = await agent.generate(prompt, workdir);
* ```
*/
export function createAgent(name: AgentName): Agent {
return match(name)
export function createAgent(name: AgentName, logger?: Logger): Agent {
const agent = match(name)
.with('claude', () => new ClaudeAgent())
.with('codex', () => new CodexAgent())
.with('gemini', () => new GeminiAgent())
.exhaustive();

// Set logger on agent if provided
if (logger) {
agent.logger = logger;
}

return agent;
}
6 changes: 6 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import type { Logger } from '../utils/logger';

/**
* Core Agent interface for AI-powered commit message generation
Expand Down Expand Up @@ -49,6 +50,11 @@ export type Agent = {
*/
generate(prompt: string, workdir: string): Promise<string>;

/**
* Optional logger for debugging and diagnostics
*/
logger?: Logger;

/**
* Human-readable name of the agent (e.g., "Claude CLI", "Codex CLI")
*/
Expand Down
46 changes: 28 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { formatValidationError, validateCliOptions } from './cli/schemas';
import { CommitMessageGenerator } from './generator';
import type { GitStatus } from './utils/git-schemas';
import { ConsoleLogger, SilentLogger } from './utils/logger';

// Read version from package.json
const Filename = fileURLToPath(import.meta.url);
Expand All @@ -37,37 +38,38 @@ async function generateCommitCommand(rawOptions: {
const agentName = options.agent ?? 'claude';
const quiet = options.quiet === true;

// Create logger based on --quiet flag
const logger = quiet ? new SilentLogger() : new ConsoleLogger();

try {
const gitStatus = await checkGitStatusOrExit(options.cwd);
displayStagedChanges(gitStatus, options.messageOnly === true);
displayGenerationStatus(agentName, quiet);
const gitStatus = await checkGitStatusOrExit(options.cwd, logger);
displayStagedChanges(gitStatus, options.messageOnly === true, logger);
displayGenerationStatus(agentName, logger);

const task = {
description: 'Analyze git diff to generate appropriate commit message',
produces: gitStatus.stagedFiles,
title: 'Code changes',
};

// Pass logger to Generator via config
const generator = new CommitMessageGenerator({
agent: agentName,
logger: {
warn: (warningMessage: string) => {
console.error(chalk.yellow(`⚠️ ${warningMessage}`));
},
},
logger,
});

const message = await generator.generateCommitMessage(task, {
files: gitStatus.stagedFiles,
workdir: options.cwd,
});

displayCommitMessage(message, options.messageOnly === true);
displayCommitMessage(message, options.messageOnly === true, logger);
await executeCommit(
message,
options.cwd,
options.dryRun === true,
options.messageOnly === true
options.messageOnly === true,
logger
);
} catch (error) {
console.error(chalk.red('❌ Error:'), error instanceof Error ? error.message : String(error));
Expand Down Expand Up @@ -99,12 +101,15 @@ function validateOptionsOrExit(
*
* Returns a simplified GitStatus-like object with just the fields we need
*/
async function checkGitStatusOrExit(cwd: string): Promise<GitStatus> {
async function checkGitStatusOrExit(
cwd: string,
logger: ConsoleLogger | SilentLogger
): Promise<GitStatus> {
const gitStatus = await getGitStatus(cwd);

if (!gitStatus.hasChanges) {
console.log(chalk.yellow('No staged changes to commit'));
console.log(chalk.gray('Run `git add` to stage changes first'));
logger.warn('No staged changes to commit');
logger.info('Run `git add` to stage changes first');
process.exit(1);
}

Expand Down Expand Up @@ -132,11 +137,16 @@ prog
'hook-manager'?: 'husky' | 'simple-git-hooks' | 'plain';
agent?: 'claude' | 'codex' | 'gemini';
}) => {
await initCommand({
agent: options.agent,
cwd: options.cwd,
hookManager: options['hook-manager'],
});
// Init command always uses console logger (never quiet)
const logger = new ConsoleLogger();
await initCommand(
{
agent: options.agent,
cwd: options.cwd,
hookManager: options['hook-manager'],
},
logger
);
}
);

Expand Down
Loading