diff --git a/.gitignore b/.gitignore index 36a27c0f..66d83019 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ fabric.properties node_modules/ dist/ *.tgz +releases/ # Environment .env diff --git a/docs/getting-started.md b/docs/getting-started.md index a91908d9..dfd72e67 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -106,12 +106,11 @@ The MCP server (`git-mem-mcp`) exposes git-mem to AI tools over stdio: ```bash git mem init -git mem init -y --commit-count 50 +git mem init -y ``` Options: - `-y, --yes` — Accept defaults without prompting -- `--commit-count ` — Number of commits to extract (default: 100) - `--hooks` — Install prepare-commit-msg git hook - `--uninstall-hooks` — Remove the prepare-commit-msg git hook diff --git a/package.json b/package.json index b8e70582..67b4ff03 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "pre-commit": "npm run type-check && npm run lint", "test": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"", "test:unit": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/unit/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"", - "test:integration": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/integration/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"" + "test:integration": "node -e \"const {globSync}=require('glob');const {spawnSync}=require('child_process');const files=globSync('tests/integration/**/*.test.ts');if(!files.length){console.log('No test files found');process.exit(0);}const r=spawnSync('node',['--import','tsx','--test',...files],{stdio:'inherit'});process.exit(r.status===0||r.status===2?0:r.status)\"", + "package": "node -e \"const {rmSync,mkdirSync,renameSync}=require('fs');const {globSync}=require('glob');globSync('*.tgz').forEach(f=>rmSync(f));globSync('releases/*.tgz').forEach(f=>rmSync(f));mkdirSync('releases',{recursive:true})\" && npm run build && npm pack && node -e \"const {renameSync}=require('fs');const {globSync}=require('glob');globSync('*.tgz').forEach(f=>renameSync(f,'releases/'+f))\"" }, "keywords": [ "git", diff --git a/scripts/reinstall-global.sh b/scripts/reinstall-global.sh index f2dc14cb..53741273 100755 --- a/scripts/reinstall-global.sh +++ b/scripts/reinstall-global.sh @@ -1,19 +1,54 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/bash +# Reinstall git-mem globally from local build +# Usage: ./scripts/reinstall-global.sh +# +# This script: +# 1. Builds the project and creates a .tgz package +# 2. Uninstalls existing global git-mem +# 3. Installs the new package globally +# 4. Verifies installation -cd "$(dirname "$0")/.." +set -e -echo "Building..." -npm run build +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RELEASES_DIR="$PROJECT_ROOT/releases" -echo "Uninstalling git-mem globally..." +echo "=== git-mem Local Reinstall ===" +echo "" + +# Step 1: Package (builds + creates tgz) +echo "1. Building and packaging..." +cd "$PROJECT_ROOT" +npm run package + +# Find the latest .tgz file +PACKAGE_FILE=$(ls -t "$RELEASES_DIR"/*.tgz 2>/dev/null | head -1) +if [ -z "$PACKAGE_FILE" ]; then + echo "Error: No .tgz file found in $RELEASES_DIR" + exit 1 +fi +echo " Package: $PACKAGE_FILE" + +# Step 2: Uninstall existing +echo "" +echo "2. Uninstalling existing global git-mem..." npm uninstall -g git-mem 2>/dev/null || true -echo "Installing git-mem globally from $(pwd)..." -npm install -g . +# Step 3: Install new package +echo "" +echo "3. Installing new package globally..." +npm install -g "$PACKAGE_FILE" + +# Step 4: Verify installation +echo "" +echo "4. Verifying installation..." +INSTALLED_VERSION=$(git-mem --version 2>/dev/null || echo "unknown") +echo " Installed version: $INSTALLED_VERSION" +echo " git-mem: $(which git-mem)" +echo " git-mem-mcp: $(which git-mem-mcp)" echo "" -echo "Installed:" -git-mem --version -which git-mem -which git-mem-mcp +echo "=== Done ===" +echo "" +echo "git-mem $INSTALLED_VERSION installed globally from local build." diff --git a/src/cli.ts b/src/cli.ts index a8ead64a..fb2588f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,11 +24,12 @@ program program .command('init') - .description('Set up git-mem: hooks, MCP config, .gitignore, and extract from history') + .description('Set up git-mem: hooks, MCP config, .gitignore') .option('-y, --yes', 'Accept defaults without prompting') - .option('--commit-count ', 'Number of commits to extract', '100') .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') + .option('--extract', 'Extract knowledge from commit history (use with --yes)') + .option('--commit-count ', 'Number of commits to extract (default: 10)', parseInt) .action((options) => initCommand(options, logger)); program @@ -39,8 +40,8 @@ program .option('--confidence ', 'Confidence: verified, high, medium, low', 'high') .option('--lifecycle ', 'Lifecycle: permanent, project, session', 'project') .option('--tags ', 'Comma-separated tags') - .option('--agent ', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)') - .option('--model ', 'AI model identifier (default: $GIT_MEM_MODEL)') + .option('--agent ', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDECODE)') + .option('--model ', 'AI model identifier (default: $GIT_MEM_MODEL / $ANTHROPIC_MODEL)') .option('--no-trailers', 'Skip writing AI-* trailers to the commit message') .action((text, options) => rememberCommand(text, options, logger)); diff --git a/src/commands/init.ts b/src/commands/init.ts index b8a0dcb0..ea23c687 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -24,9 +24,10 @@ import { createStderrProgressHandler } from './progress'; interface IInitCommandOptions { yes?: boolean; - commitCount?: string; hooks?: boolean; uninstallHooks?: boolean; + extract?: boolean; + commitCount?: number; } // ── Pure helpers (exported for testing) ────────────────────────────── @@ -110,12 +111,12 @@ export function ensureEnvPlaceholder(cwd: string): void { // ── Main command ───────────────────────────────────────────────────── -/** Run unified project setup: hooks, MCP config, .gitignore, .env, and optional extract. */ +/** Run unified project setup: hooks, MCP config, .gitignore, and .env. */ export async function initCommand(options: IInitCommandOptions, logger?: ILogger): Promise { const log = logger?.child({ command: 'init' }); const cwd = process.cwd(); - log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount, hooks: options.hooks, uninstallHooks: options.uninstallHooks }); + log?.info('Command invoked', { yes: options.yes, hooks: options.hooks, uninstallHooks: options.uninstallHooks }); // ── Git hook uninstall (early exit) ───────────────────────────── if (options.uninstallHooks) { @@ -129,27 +130,40 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger } // ── Prompts (skipped with --yes) ─────────────────────────────── - let commitCount = options.commitCount ? parseInt(options.commitCount, 10) : 30; - if (!Number.isFinite(commitCount) || commitCount <= 0) { - console.log(`Invalid --commit-count value: "${options.commitCount}". Using default (30).`); - commitCount = 30; - } let claudeIntegration = true; + let runExtract = options.extract ?? false; + let commitCount = options.commitCount ?? 10; + + // Validate commitCount (parseInt returns NaN for invalid input) + if (Number.isNaN(commitCount) || commitCount < 1) { + commitCount = 10; + } if (!options.yes) { const response = await prompts([ - { - type: 'number', - name: 'commitCount', - message: 'How many commits to free?', - initial: commitCount, - }, { type: 'confirm', name: 'claudeIntegration', message: 'Integrate with Claude Code?', initial: true, }, + { + type: 'confirm', + name: 'runExtract', + message: 'Extract knowledge from commit history?', + initial: false, + }, + { + type: (prev) => prev ? 'select' : null, + name: 'commitCount', + message: 'How many commits to extract?', + choices: [ + { title: '10', value: 10 }, + { title: '30', value: 30 }, + { title: '50', value: 50 }, + ], + initial: 0, + }, ], { onCancel: () => { console.log('\nSetup cancelled.'); @@ -157,8 +171,9 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger }, }); - commitCount = response.commitCount ?? commitCount; claudeIntegration = response.claudeIntegration ?? true; + runExtract = response.runExtract ?? false; + commitCount = response.commitCount ?? 10; } // ── Claude Code hooks ────────────────────────────────────────── @@ -209,21 +224,28 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger ensureGitignoreEntries(cwd, ['.env', '.git-mem.json']); console.log('✓ Updated .gitignore'); - // ── API key check & extract ──────────────────────────────────── - console.log('\nChecking for ANTHROPIC_API_KEY in .env...\n'); + // ── .env placeholder ────────────────────────────────────────── + ensureEnvPlaceholder(cwd); + console.log('✓ Ensured ANTHROPIC_API_KEY placeholder in .env'); - const apiKey = readEnvApiKey(cwd); + // ── Extract from history ───────────────────────────────────── + if (runExtract) { + const apiKey = readEnvApiKey(cwd); + const enrich = !!apiKey; - if (apiKey) { - process.env.ANTHROPIC_API_KEY = apiKey; - console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`); + if (enrich) { + process.env.ANTHROPIC_API_KEY = apiKey; + console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`); + } else { + console.log(`\nExtracting knowledge from ${commitCount} commits (heuristic only)...`); + } - const container = createContainer({ logger, scope: 'init', enrich: true }); + const container = createContainer({ logger, scope: 'init', enrich }); const { extractService } = container.cradle; const result = await extractService.extract({ maxCommits: commitCount, - enrich: true, + enrich, onProgress: createStderrProgressHandler(), }); @@ -233,10 +255,10 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger `Facts: ${result.factsExtracted} | ` + `Duration: ${result.durationMs}ms`, ); - } else { - ensureEnvPlaceholder(cwd); - console.log('✓ Added ANTHROPIC_API_KEY= to .env'); - console.log('→ Add your key to .env, then run:'); - console.log(` git-mem extract --enrich --commit-count ${commitCount}`); + + if (!enrich) { + console.log('\nFor a deeper AI summary of each commit, add your ANTHROPIC_API_KEY to .env and run:'); + console.log(' git-mem extract --enrich'); + } } } diff --git a/src/commands/remember.ts b/src/commands/remember.ts index 0f82aa8f..3414bf7d 100644 --- a/src/commands/remember.ts +++ b/src/commands/remember.ts @@ -3,6 +3,7 @@ */ import { createContainer } from '../infrastructure/di'; +import { resolveAgent, resolveModel } from '../infrastructure/detect-agent'; import type { MemoryType } from '../domain/entities/IMemoryEntity'; import type { ConfidenceLevel } from '../domain/types/IMemoryQuality'; import type { MemoryLifecycle } from '../domain/types/IMemoryLifecycle'; @@ -24,13 +25,8 @@ export async function rememberCommand(text: string, options: IRememberOptions, l const { memoryService, logger: log } = container.cradle; log.info('Command invoked', { type: options.type || 'fact' }); - // Resolve agent: explicit flag > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic - const agent = options.agent - || process.env.GIT_MEM_AGENT - || (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined); - - // Resolve model: explicit flag > $GIT_MEM_MODEL - const model = options.model || process.env.GIT_MEM_MODEL || undefined; + const agent = resolveAgent(options.agent); + const model = resolveModel(options.model); const memory = memoryService.remember(text, { sha: options.commit, diff --git a/src/hooks/prepare-commit-msg.ts b/src/hooks/prepare-commit-msg.ts index dcf91eca..3c2ef34d 100644 --- a/src/hooks/prepare-commit-msg.ts +++ b/src/hooks/prepare-commit-msg.ts @@ -6,8 +6,10 @@ * * Detection heuristics (checked in order): * - $GIT_MEM_AGENT env var (explicit, user-defined agent string) - * - $CLAUDE_CODE env var (Claude Code session) + * - $CLAUDECODE env var (Claude Code session — includes version) + * - $CLAUDE_CODE env var (legacy fallback) * - $GIT_MEM_MODEL env var (explicit, user-defined model string) + * - $ANTHROPIC_MODEL env var (set by Claude Code) * * The hook uses `git interpret-trailers` for proper formatting. */ @@ -20,7 +22,7 @@ import { execFileSync } from 'child_process'; const HOOK_FINGERPRINT_PREFIX = '# git-mem:prepare-commit-msg'; /** Full fingerprint with version — used for upgrade detection. */ -const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`; +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v3`; /** * The shell hook script. @@ -42,6 +44,15 @@ esac AGENT="" if [ -n "$GIT_MEM_AGENT" ]; then AGENT="$GIT_MEM_AGENT" +elif [ -n "$CLAUDECODE" ]; then + # Get Claude Code version for the agent string + CC_VERSION="" + CC_VERSION=$(CLAUDECODE= claude --version 2>/dev/null | head -1 | awk '{print $1}') || true + if [ -n "$CC_VERSION" ]; then + AGENT="Claude-Code/$CC_VERSION" + else + AGENT="Claude-Code" + fi elif [ -n "$CLAUDE_CODE" ]; then AGENT="Claude-Code" fi @@ -50,6 +61,8 @@ fi MODEL="" if [ -n "$GIT_MEM_MODEL" ]; then MODEL="$GIT_MEM_MODEL" +elif [ -n "$ANTHROPIC_MODEL" ]; then + MODEL="$ANTHROPIC_MODEL" fi # No agent detected — exit silently diff --git a/src/infrastructure/detect-agent.ts b/src/infrastructure/detect-agent.ts new file mode 100644 index 00000000..70df82d6 --- /dev/null +++ b/src/infrastructure/detect-agent.ts @@ -0,0 +1,49 @@ +/** + * Detect AI agent and model from environment variables. + * + * Agent detection chain: $GIT_MEM_AGENT > $CLAUDECODE (with version) > $CLAUDE_CODE + * Model detection chain: $GIT_MEM_MODEL > $ANTHROPIC_MODEL + */ + +import { execFileSync } from 'child_process'; + +/** + * Detect Claude Code agent string including version. + * Returns e.g. "Claude-Code/2.1.41" or "Claude-Code" if version unavailable. + */ +export function detectClaudeAgent(): string { + try { + // Unset CLAUDECODE so `claude --version` doesn't refuse to run inside a session + const version = execFileSync('claude', ['--version'], { + encoding: 'utf8', + env: { ...process.env, CLAUDECODE: '' }, + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim().split(/\s+/)[0]; + if (version) return `Claude-Code/${version}`; + } catch { + // claude binary not found or timed out — fall back + } + return 'Claude-Code'; +} + +/** + * Resolve the AI agent string from env vars. + * @param explicit Optional explicit value (from CLI flag or MCP param) + */ +export function resolveAgent(explicit?: string): string | undefined { + if (explicit) return explicit; + if (process.env.GIT_MEM_AGENT) return process.env.GIT_MEM_AGENT; + if (process.env.CLAUDECODE) return detectClaudeAgent(); + if (process.env.CLAUDE_CODE) return 'Claude-Code'; + return undefined; +} + +/** + * Resolve the AI model string from env vars. + * @param explicit Optional explicit value (from CLI flag or MCP param) + */ +export function resolveModel(explicit?: string): string | undefined { + if (explicit) return explicit; + return process.env.GIT_MEM_MODEL || process.env.ANTHROPIC_MODEL || undefined; +} diff --git a/src/mcp/tools/remember.ts b/src/mcp/tools/remember.ts index 5a19ccf2..b89b921b 100644 --- a/src/mcp/tools/remember.ts +++ b/src/mcp/tools/remember.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createContainer } from '../../infrastructure/di'; +import { resolveAgent, resolveModel } from '../../infrastructure/detect-agent'; import type { MemoryType } from '../../domain/entities/IMemoryEntity'; import type { ConfidenceLevel } from '../../domain/types/IMemoryQuality'; import type { MemoryLifecycle } from '../../domain/types/IMemoryLifecycle'; @@ -22,8 +23,8 @@ export function registerRememberTool(server: McpServer): void { confidence: z.enum(['verified', 'high', 'medium', 'low']).optional().describe('Confidence level (default: high)'), tags: z.string().optional().describe('Comma-separated tags'), lifecycle: z.enum(['permanent', 'project', 'session']).optional().describe('Lifecycle tier (default: project)'), - agent: z.string().optional().describe('AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)'), - model: z.string().optional().describe('AI model identifier (default: $GIT_MEM_MODEL)'), + agent: z.string().optional().describe('AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDECODE / $CLAUDE_CODE)'), + model: z.string().optional().describe('AI model identifier (default: $GIT_MEM_MODEL / $ANTHROPIC_MODEL)'), trailers: z.boolean().optional().describe('Write AI-* trailers to commit message (default: true)'), }, async (args) => { @@ -32,13 +33,8 @@ export function registerRememberTool(server: McpServer): void { try { logger.info('Tool invoked', { type: args.type || 'fact' }); - // Resolve agent: explicit param > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic - const agent = args.agent - || process.env.GIT_MEM_AGENT - || (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined); - - // Resolve model: explicit param > $GIT_MEM_MODEL - const model = args.model || process.env.GIT_MEM_MODEL || undefined; + const agent = resolveAgent(args.agent); + const model = resolveModel(args.model); const memory = memoryService.remember(args.text, { sha: args.commit, diff --git a/tests/unit/hooks/prepare-commit-msg.test.ts b/tests/unit/hooks/prepare-commit-msg.test.ts index ca9716bb..df2547cd 100644 --- a/tests/unit/hooks/prepare-commit-msg.test.ts +++ b/tests/unit/hooks/prepare-commit-msg.test.ts @@ -39,7 +39,7 @@ describe('installHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:prepare-commit-msg v2')); + assert.ok(content.includes('git-mem:prepare-commit-msg v3')); assert.ok(content.includes('git interpret-trailers')); assert.ok(content.includes('AI-Agent')); }); @@ -75,24 +75,24 @@ describe('installHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:prepare-commit-msg v2')); + assert.ok(content.includes('git-mem:prepare-commit-msg v3')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); } }); - it('should upgrade v1 hook to v2 on reinstall', () => { + it('should upgrade older hook to v3 on reinstall', () => { const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-hook-upgrade-')); git(['init'], freshRepo); const hooksDir = join(freshRepo, '.git', 'hooks'); const hookPath = join(hooksDir, 'prepare-commit-msg'); - // Write a v1 hook (old fingerprint, no AI-Model support) - const v1Hook = '#!/bin/sh\n# git-mem:prepare-commit-msg v1\n# Old hook without AI-Model\nexit 0\n'; + // Write a v2 hook (old fingerprint, no CLAUDECODE/ANTHROPIC_MODEL support) + const v2Hook = '#!/bin/sh\n# git-mem:prepare-commit-msg v2\n# Old hook without auto-detect\nexit 0\n'; mkdirSync(hooksDir, { recursive: true }); - writeFileSync(hookPath, v1Hook); + writeFileSync(hookPath, v2Hook); chmodSync(hookPath, 0o755); try { @@ -103,8 +103,9 @@ describe('installHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:prepare-commit-msg v2'), 'Should be upgraded to v2'); - assert.ok(content.includes('AI-Model'), 'Should include AI-Model support'); + assert.ok(content.includes('git-mem:prepare-commit-msg v3'), 'Should be upgraded to v3'); + assert.ok(content.includes('CLAUDECODE'), 'Should include CLAUDECODE detection'); + assert.ok(content.includes('ANTHROPIC_MODEL'), 'Should include ANTHROPIC_MODEL detection'); // Second install should be idempotent const result2 = installHook(freshRepo); @@ -227,14 +228,35 @@ describe('hook integration — commit message modification', () => { assert.ok(message.includes('AI-Agent: TestAgent/1.0'), `Expected AI-Agent trailer in: ${message}`); }); - it('should add AI-Agent trailer when CLAUDE_CODE is set', () => { + it('should add AI-Agent trailer with version when CLAUDECODE is set', () => { writeFileSync(join(repoDir, 'test2.txt'), 'world'); git(['add', '.'], repoDir); + const env = { ...process.env, CLAUDECODE: '1' }; + delete env.GIT_MEM_AGENT; + execFileSync('git', ['commit', '-m', 'fix: another commit'], { encoding: 'utf8', cwd: repoDir, - env: { ...process.env, CLAUDE_CODE: '1', GIT_MEM_AGENT: '' }, + env, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + // Should contain Claude-Code (with or without version depending on claude binary availability) + assert.ok(message.includes('AI-Agent: Claude-Code'), `Expected Claude-Code trailer in: ${message}`); + }); + + it('should add AI-Agent trailer with legacy CLAUDE_CODE env var', () => { + writeFileSync(join(repoDir, 'test2b.txt'), 'legacy'); + git(['add', '.'], repoDir); + + const env = { ...process.env, CLAUDE_CODE: '1', GIT_MEM_AGENT: '' }; + delete env.CLAUDECODE; + + execFileSync('git', ['commit', '-m', 'fix: legacy env var'], { + encoding: 'utf8', + cwd: repoDir, + env, }); const message = git(['log', '-1', '--format=%B'], repoDir); @@ -249,6 +271,7 @@ describe('hook integration — commit message modification', () => { const cleanEnv = { ...process.env }; delete cleanEnv.GIT_MEM_AGENT; delete cleanEnv.CLAUDE_CODE; + delete cleanEnv.CLAUDECODE; execFileSync('git', ['commit', '-m', 'chore: plain commit'], { encoding: 'utf8', @@ -291,12 +314,30 @@ describe('hook integration — commit message modification', () => { assert.ok(message.includes('AI-Model: claude-opus-4-6'), `Expected AI-Model trailer in: ${message}`); }); - it('should not add AI-Model trailer when GIT_MEM_MODEL is not set', () => { + it('should auto-detect AI-Model from ANTHROPIC_MODEL env var', () => { + writeFileSync(join(repoDir, 'anthropic-model.txt'), 'auto-model'); + git(['add', '.'], repoDir); + + const env = { ...process.env, GIT_MEM_AGENT: 'TestAgent', ANTHROPIC_MODEL: 'claude-sonnet-4-5' }; + delete env.GIT_MEM_MODEL; + + execFileSync('git', ['commit', '-m', 'feat: anthropic model auto-detect'], { + encoding: 'utf8', + cwd: repoDir, + env, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(message.includes('AI-Model: claude-sonnet-4-5'), `Expected AI-Model trailer in: ${message}`); + }); + + it('should not add AI-Model trailer when no model env vars are set', () => { writeFileSync(join(repoDir, 'no-model.txt'), 'no-model'); git(['add', '.'], repoDir); const cleanEnv = { ...process.env, GIT_MEM_AGENT: 'TestAgent' }; delete cleanEnv.GIT_MEM_MODEL; + delete cleanEnv.ANTHROPIC_MODEL; execFileSync('git', ['commit', '-m', 'feat: no model test'], { encoding: 'utf8', diff --git a/tests/unit/infrastructure/detect-agent.test.ts b/tests/unit/infrastructure/detect-agent.test.ts new file mode 100644 index 00000000..cca6b059 --- /dev/null +++ b/tests/unit/infrastructure/detect-agent.test.ts @@ -0,0 +1,137 @@ +/** + * detect-agent.ts — unit tests + * + * Tests agent and model detection from environment variables. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { detectClaudeAgent, resolveAgent, resolveModel } from '../../../src/infrastructure/detect-agent'; + +describe('detect-agent', () => { + // Store original env vars + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('detectClaudeAgent', () => { + it('should return Claude-Code with version when claude binary is available', () => { + // This test depends on having claude CLI installed + // If not available, it should fall back to just "Claude-Code" + const result = detectClaudeAgent(); + assert.ok(result.startsWith('Claude-Code'), `Expected result to start with Claude-Code, got: ${result}`); + }); + + it('should return Claude-Code (possibly with version)', () => { + const result = detectClaudeAgent(); + // Either "Claude-Code" or "Claude-Code/X.Y.Z" + assert.match(result, /^Claude-Code(\/[\d.]+)?$/); + }); + }); + + describe('resolveAgent', () => { + beforeEach(() => { + // Clear all AI-related env vars + delete process.env.GIT_MEM_AGENT; + delete process.env.CLAUDECODE; + delete process.env.CLAUDE_CODE; + }); + + it('should return explicit value when provided', () => { + process.env.GIT_MEM_AGENT = 'should-be-ignored'; + const result = resolveAgent('ExplicitAgent/1.0'); + assert.equal(result, 'ExplicitAgent/1.0'); + }); + + it('should return GIT_MEM_AGENT when set', () => { + process.env.GIT_MEM_AGENT = 'CustomAgent/2.0'; + const result = resolveAgent(); + assert.equal(result, 'CustomAgent/2.0'); + }); + + it('should detect Claude-Code when CLAUDECODE is set', () => { + process.env.CLAUDECODE = '1'; + const result = resolveAgent(); + assert.ok(result?.startsWith('Claude-Code'), `Expected Claude-Code, got: ${result}`); + }); + + it('should return Claude-Code when legacy CLAUDE_CODE is set', () => { + process.env.CLAUDE_CODE = '1'; + const result = resolveAgent(); + assert.equal(result, 'Claude-Code'); + }); + + it('should prioritize GIT_MEM_AGENT over CLAUDECODE', () => { + process.env.GIT_MEM_AGENT = 'CustomAgent'; + process.env.CLAUDECODE = '1'; + const result = resolveAgent(); + assert.equal(result, 'CustomAgent'); + }); + + it('should prioritize CLAUDECODE over CLAUDE_CODE', () => { + process.env.CLAUDECODE = '1'; + process.env.CLAUDE_CODE = '1'; + const result = resolveAgent(); + // CLAUDECODE triggers detectClaudeAgent() which returns Claude-Code with possible version + assert.ok(result?.startsWith('Claude-Code')); + }); + + it('should return undefined when no env vars are set', () => { + const result = resolveAgent(); + assert.equal(result, undefined); + }); + + it('should return undefined when explicit is empty string', () => { + const result = resolveAgent(''); + assert.equal(result, undefined); + }); + }); + + describe('resolveModel', () => { + beforeEach(() => { + delete process.env.GIT_MEM_MODEL; + delete process.env.ANTHROPIC_MODEL; + }); + + it('should return explicit value when provided', () => { + process.env.GIT_MEM_MODEL = 'should-be-ignored'; + const result = resolveModel('explicit-model'); + assert.equal(result, 'explicit-model'); + }); + + it('should return GIT_MEM_MODEL when set', () => { + process.env.GIT_MEM_MODEL = 'claude-opus-4-6'; + const result = resolveModel(); + assert.equal(result, 'claude-opus-4-6'); + }); + + it('should return ANTHROPIC_MODEL when set', () => { + process.env.ANTHROPIC_MODEL = 'claude-sonnet-4-5'; + const result = resolveModel(); + assert.equal(result, 'claude-sonnet-4-5'); + }); + + it('should prioritize GIT_MEM_MODEL over ANTHROPIC_MODEL', () => { + process.env.GIT_MEM_MODEL = 'custom-model'; + process.env.ANTHROPIC_MODEL = 'anthropic-model'; + const result = resolveModel(); + assert.equal(result, 'custom-model'); + }); + + it('should return undefined when no env vars are set', () => { + const result = resolveModel(); + assert.equal(result, undefined); + }); + + it('should return undefined when explicit is empty string', () => { + const result = resolveModel(''); + assert.equal(result, undefined); + }); + }); +});