diff --git a/src/claude/utils/claudeSettings.test.ts b/src/claude/utils/claudeSettings.test.ts new file mode 100644 index 00000000..423edbb7 --- /dev/null +++ b/src/claude/utils/claudeSettings.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for Claude settings reading functionality + * + * Tests reading Claude's settings.json file and respecting the includeCoAuthoredBy setting + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { readClaudeSettings, shouldIncludeCoAuthoredBy } from './claudeSettings'; + +describe('Claude Settings', () => { + let testClaudeDir: string; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + // Create a temporary directory for testing + testClaudeDir = join(tmpdir(), `test-claude-${Date.now()}`); + mkdirSync(testClaudeDir, { recursive: true }); + + // Set environment variable to point to test directory + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = testClaudeDir; + }); + + afterEach(() => { + // Restore original environment variable + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + + // Clean up test directory + if (existsSync(testClaudeDir)) { + rmSync(testClaudeDir, { recursive: true, force: true }); + } + }); + + describe('readClaudeSettings', () => { + it('returns null when settings file does not exist', () => { + const settings = readClaudeSettings(); + expect(settings).toBe(null); + }); + + it('reads settings when file exists', () => { + const settingsPath = join(testClaudeDir, 'settings.json'); + const testSettings = { includeCoAuthoredBy: false, otherSetting: 'value' }; + writeFileSync(settingsPath, JSON.stringify(testSettings)); + + const settings = readClaudeSettings(); + expect(settings).toEqual(testSettings); + }); + + it('returns null when settings file is invalid JSON', () => { + const settingsPath = join(testClaudeDir, 'settings.json'); + writeFileSync(settingsPath, 'invalid json'); + + const settings = readClaudeSettings(); + expect(settings).toBe(null); + }); + }); + + describe('shouldIncludeCoAuthoredBy', () => { + it('returns true when no settings file exists (default behavior)', () => { + const result = shouldIncludeCoAuthoredBy(); + expect(result).toBe(true); + }); + + it('returns true when includeCoAuthoredBy is not set (default behavior)', () => { + const settingsPath = join(testClaudeDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ otherSetting: 'value' })); + + const result = shouldIncludeCoAuthoredBy(); + expect(result).toBe(true); + }); + + it('returns false when includeCoAuthoredBy is explicitly set to false', () => { + const settingsPath = join(testClaudeDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ includeCoAuthoredBy: false })); + + const result = shouldIncludeCoAuthoredBy(); + expect(result).toBe(false); + }); + + it('returns true when includeCoAuthoredBy is explicitly set to true', () => { + const settingsPath = join(testClaudeDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ includeCoAuthoredBy: true })); + + const result = shouldIncludeCoAuthoredBy(); + expect(result).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/claude/utils/claudeSettings.ts b/src/claude/utils/claudeSettings.ts new file mode 100644 index 00000000..356c17b5 --- /dev/null +++ b/src/claude/utils/claudeSettings.ts @@ -0,0 +1,69 @@ +/** + * Utilities for reading Claude's settings.json configuration + * + * Handles reading Claude's settings.json file to respect user preferences + * like includeCoAuthoredBy setting for commit message generation. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '@/ui/logger'; + +export interface ClaudeSettings { + includeCoAuthoredBy?: boolean; + [key: string]: any; +} + +/** + * Get the path to Claude's settings.json file + */ +function getClaudeSettingsPath(): string { + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); + return join(claudeConfigDir, 'settings.json'); +} + +/** + * Read Claude's settings.json file from the default location + * + * @returns Claude settings object or null if file doesn't exist or can't be read + */ +export function readClaudeSettings(): ClaudeSettings | null { + try { + const settingsPath = getClaudeSettingsPath(); + + if (!existsSync(settingsPath)) { + logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); + return null; + } + + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(settingsContent) as ClaudeSettings; + + logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); + logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); + + return settings; + } catch (error) { + logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); + return null; + } +} + +/** + * Check if Co-Authored-By lines should be included in commit messages + * based on Claude's settings + * + * @returns true if Co-Authored-By should be included, false otherwise + */ +export function shouldIncludeCoAuthoredBy(): boolean { + const settings = readClaudeSettings(); + + // If no settings file or includeCoAuthoredBy is not explicitly set, + // default to true to maintain backward compatibility + if (!settings || settings.includeCoAuthoredBy === undefined) { + return true; + } + + return settings.includeCoAuthoredBy; +} \ No newline at end of file diff --git a/src/claude/utils/systemPrompt.ts b/src/claude/utils/systemPrompt.ts index 83e2b3ec..78a59b78 100644 --- a/src/claude/utils/systemPrompt.ts +++ b/src/claude/utils/systemPrompt.ts @@ -1,8 +1,17 @@ import { trimIdent } from "@/utils/trimIdent"; +import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; -export const systemPrompt = trimIdent(` +/** + * Base system prompt shared across all configurations + */ +const BASE_SYSTEM_PROMPT = (() => trimIdent(` ALWAYS when you start a new chat - you must call a tool "mcp__happy__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. +`))(); +/** + * Co-authored-by credits to append when enabled + */ +const CO_AUTHORED_CREDITS = (() => trimIdent(` When making commit messages, instead of just giving co-credit to Claude, also give credit to Happy like so:
@@ -12,4 +21,18 @@ export const systemPrompt = trimIdent(` Co-Authored-By: Claude Co-Authored-By: Happy -`); \ No newline at end of file +`))(); + +/** + * System prompt with conditional Co-Authored-By lines based on Claude's settings.json configuration. + * Settings are read once on startup for performance. + */ +export const systemPrompt = (() => { + const includeCoAuthored = shouldIncludeCoAuthoredBy(); + + if (includeCoAuthored) { + return BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS; + } else { + return BASE_SYSTEM_PROMPT; + } +})(); \ No newline at end of file