From db98d0efef6ff023db81fd54c345df63ea107d04 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 17:22:17 +0000 Subject: [PATCH 1/4] feat: auto-detect agent/model from Claude Code env vars (GIT-73) - Detect $CLAUDECODE (not $CLAUDE_CODE) and include version in agent string (e.g. "Claude-Code/2.1.41") - Auto-detect model from $ANTHROPIC_MODEL env var - Add shared resolveAgent/resolveModel helpers in detect-agent.ts - Bump hook to v3 with CLAUDECODE + ANTHROPIC_MODEL detection - Make init extraction opt-in via --extract flag (no longer runs by default) - Keep $CLAUDE_CODE and $GIT_MEM_* as explicit overrides for backward compat Co-Authored-By: Claude Opus 4.6 AI-Agent: Claude-Code/2.1.41 AI-Model: claude-opus-4-6 --- src/cli.ts | 9 ++- src/commands/init.ts | 87 ++++++++++++--------- src/commands/remember.ts | 10 +-- src/hooks/prepare-commit-msg.ts | 17 +++- src/infrastructure/detect-agent.ts | 49 ++++++++++++ src/mcp/tools/remember.ts | 14 ++-- tests/unit/hooks/prepare-commit-msg.test.ts | 60 +++++++++++--- 7 files changed, 175 insertions(+), 71 deletions(-) create mode 100644 src/infrastructure/detect-agent.ts diff --git a/src/cli.ts b/src/cli.ts index a8ead64a..5ee8b0f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,9 +24,10 @@ 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('--extract', 'Also extract knowledge from commit history') + .option('--commit-count ', 'Number of commits to extract (with --extract)', '100') .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') .action((options) => initCommand(options, logger)); @@ -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..61d2ff3f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -27,6 +27,7 @@ interface IInitCommandOptions { commitCount?: string; hooks?: boolean; uninstallHooks?: boolean; + extract?: boolean; } // ── Pure helpers (exported for testing) ────────────────────────────── @@ -137,20 +138,25 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger let claudeIntegration = true; if (!options.yes) { - const response = await prompts([ - { + const promptList: prompts.PromptObject[] = []; + + if (options.extract) { + promptList.push({ type: 'number', name: 'commitCount', - message: 'How many commits to free?', + message: 'How many commits to extract?', initial: commitCount, - }, - { - type: 'confirm', - name: 'claudeIntegration', - message: 'Integrate with Claude Code?', - initial: true, - }, - ], { + }); + } + + promptList.push({ + type: 'confirm', + name: 'claudeIntegration', + message: 'Integrate with Claude Code?', + initial: true, + }); + + const response = await prompts(promptList, { onCancel: () => { console.log('\nSetup cancelled.'); process.exit(0); @@ -210,33 +216,38 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger console.log('✓ Updated .gitignore'); // ── API key check & extract ──────────────────────────────────── - console.log('\nChecking for ANTHROPIC_API_KEY in .env...\n'); - - const apiKey = readEnvApiKey(cwd); - - if (apiKey) { - process.env.ANTHROPIC_API_KEY = apiKey; - console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`); - - const container = createContainer({ logger, scope: 'init', enrich: true }); - const { extractService } = container.cradle; - - const result = await extractService.extract({ - maxCommits: commitCount, - enrich: true, - onProgress: createStderrProgressHandler(), - }); - - console.log( - `Commits scanned: ${result.commitsScanned} | ` + - `Annotated: ${result.commitsAnnotated} | ` + - `Facts: ${result.factsExtracted} | ` + - `Duration: ${result.durationMs}ms`, - ); + if (options.extract) { + console.log('\nChecking for ANTHROPIC_API_KEY in .env...\n'); + + const apiKey = readEnvApiKey(cwd); + + if (apiKey) { + process.env.ANTHROPIC_API_KEY = apiKey; + console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`); + + const container = createContainer({ logger, scope: 'init', enrich: true }); + const { extractService } = container.cradle; + + const result = await extractService.extract({ + maxCommits: commitCount, + enrich: true, + onProgress: createStderrProgressHandler(), + }); + + console.log( + `Commits scanned: ${result.commitsScanned} | ` + + `Annotated: ${result.commitsAnnotated} | ` + + `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}`); + } } 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}`); + console.log('\nTo extract knowledge from commit history, run:'); + console.log(` git-mem extract --commit-count ${commitCount}`); } } 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..05c30297 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,32 @@ 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); execFileSync('git', ['commit', '-m', 'fix: another commit'], { encoding: 'utf8', cwd: repoDir, - env: { ...process.env, CLAUDE_CODE: '1', GIT_MEM_AGENT: '' }, + env: { ...process.env, CLAUDECODE: '1', GIT_MEM_AGENT: '' }, + }); + + 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 +268,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 +311,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', From 72bf36422d47ebb79d1fba0ea09f2b67f21c5c84 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 17:39:50 +0000 Subject: [PATCH 2/4] refactor: rework init to use interactive prompts, improve packaging (GIT-73) - Replace --extract/--commit-count CLI flags with interactive prompts - Add `npm run package` script for .tgz builds into releases/ - Improve reinstall-global.sh with proper packaging workflow - Update docs to reflect simplified init options Co-Authored-By: Claude Opus 4.6 AI-Agent: Claude-Code/2.1.41 AI-Model: claude-opus-4-6 --- .gitignore | 1 + docs/getting-started.md | 3 +- package.json | 3 +- scripts/reinstall-global.sh | 59 +++++++++++++++---- src/cli.ts | 2 - src/commands/init.ts | 114 +++++++++++++++++++----------------- 6 files changed, 110 insertions(+), 72 deletions(-) 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..b304811d 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": "rm -f *.tgz releases/*.tgz && npm run build && mkdir -p releases && npm pack && mv *.tgz releases/" }, "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 5ee8b0f3..625e8d51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,8 +26,6 @@ program .command('init') .description('Set up git-mem: hooks, MCP config, .gitignore') .option('-y, --yes', 'Accept defaults without prompting') - .option('--extract', 'Also extract knowledge from commit history') - .option('--commit-count ', 'Number of commits to extract (with --extract)', '100') .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') .action((options) => initCommand(options, logger)); diff --git a/src/commands/init.ts b/src/commands/init.ts index 61d2ff3f..450a8867 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -24,10 +24,8 @@ import { createStderrProgressHandler } from './progress'; interface IInitCommandOptions { yes?: boolean; - commitCount?: string; hooks?: boolean; uninstallHooks?: boolean; - extract?: boolean; } // ── Pure helpers (exported for testing) ────────────────────────────── @@ -111,12 +109,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) { @@ -130,41 +128,45 @@ 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 = false; + let commitCount = 10; if (!options.yes) { - const promptList: prompts.PromptObject[] = []; - - if (options.extract) { - promptList.push({ - type: 'number', + const response = await prompts([ + { + 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?', - initial: commitCount, - }); - } - - promptList.push({ - type: 'confirm', - name: 'claudeIntegration', - message: 'Integrate with Claude Code?', - initial: true, - }); - - const response = await prompts(promptList, { + choices: [ + { title: '10', value: 10 }, + { title: '30', value: 30 }, + { title: '50', value: 50 }, + ], + initial: 0, + }, + ], { onCancel: () => { console.log('\nSetup cancelled.'); process.exit(0); }, }); - commitCount = response.commitCount ?? commitCount; claudeIntegration = response.claudeIntegration ?? true; + runExtract = response.runExtract ?? false; + commitCount = response.commitCount ?? 10; } // ── Claude Code hooks ────────────────────────────────────────── @@ -215,39 +217,41 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger ensureGitignoreEntries(cwd, ['.env', '.git-mem.json']); console.log('✓ Updated .gitignore'); - // ── API key check & extract ──────────────────────────────────── - if (options.extract) { - console.log('\nChecking for ANTHROPIC_API_KEY in .env...\n'); + // ── .env placeholder ────────────────────────────────────────── + ensureEnvPlaceholder(cwd); + console.log('✓ Ensured ANTHROPIC_API_KEY placeholder in .env'); + // ── Extract from history ───────────────────────────────────── + if (runExtract) { const apiKey = readEnvApiKey(cwd); + const enrich = !!apiKey; - if (apiKey) { + if (enrich) { process.env.ANTHROPIC_API_KEY = apiKey; - console.log(`Extracting knowledge from ${commitCount} commits with LLM enrichment...`); - - const container = createContainer({ logger, scope: 'init', enrich: true }); - const { extractService } = container.cradle; - - const result = await extractService.extract({ - maxCommits: commitCount, - enrich: true, - onProgress: createStderrProgressHandler(), - }); - - console.log( - `Commits scanned: ${result.commitsScanned} | ` + - `Annotated: ${result.commitsAnnotated} | ` + - `Facts: ${result.factsExtracted} | ` + - `Duration: ${result.durationMs}ms`, - ); + console.log(`\nExtracting knowledge from ${commitCount} commits with LLM enrichment...`); } 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}`); + console.log(`\nExtracting knowledge from ${commitCount} commits (heuristic only)...`); + } + + const container = createContainer({ logger, scope: 'init', enrich }); + const { extractService } = container.cradle; + + const result = await extractService.extract({ + maxCommits: commitCount, + enrich, + onProgress: createStderrProgressHandler(), + }); + + console.log( + `Commits scanned: ${result.commitsScanned} | ` + + `Annotated: ${result.commitsAnnotated} | ` + + `Facts: ${result.factsExtracted} | ` + + `Duration: ${result.durationMs}ms`, + ); + + 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'); } - } else { - console.log('\nTo extract knowledge from commit history, run:'); - console.log(` git-mem extract --commit-count ${commitCount}`); } } From 033a0ee95ee4f76646807719a80664a55a3fe8ef Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 08:46:01 +0000 Subject: [PATCH 3/4] fix: address PR review comments (GIT-73) - Add unit tests for detect-agent.ts (16 tests) - Add --extract and --commit-count CLI options for non-interactive init - Fix cross-platform package script using node/glob instead of Unix commands - Use consistent `delete env.VAR` pattern in prepare-commit-msg tests Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 --- package.json | 2 +- src/cli.ts | 2 + src/commands/init.ts | 6 +- tests/unit/hooks/prepare-commit-msg.test.ts | 5 +- .../unit/infrastructure/detect-agent.test.ts | 137 ++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 tests/unit/infrastructure/detect-agent.test.ts diff --git a/package.json b/package.json index b304811d..67b4ff03 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "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)\"", - "package": "rm -f *.tgz releases/*.tgz && npm run build && mkdir -p releases && npm pack && mv *.tgz releases/" + "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/src/cli.ts b/src/cli.ts index 625e8d51..fb2588f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,6 +28,8 @@ program .option('-y, --yes', 'Accept defaults without prompting') .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 diff --git a/src/commands/init.ts b/src/commands/init.ts index 450a8867..78e87395 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -26,6 +26,8 @@ interface IInitCommandOptions { yes?: boolean; hooks?: boolean; uninstallHooks?: boolean; + extract?: boolean; + commitCount?: number; } // ── Pure helpers (exported for testing) ────────────────────────────── @@ -129,8 +131,8 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger // ── Prompts (skipped with --yes) ─────────────────────────────── let claudeIntegration = true; - let runExtract = false; - let commitCount = 10; + let runExtract = options.extract ?? false; + let commitCount = options.commitCount ?? 10; if (!options.yes) { const response = await prompts([ diff --git a/tests/unit/hooks/prepare-commit-msg.test.ts b/tests/unit/hooks/prepare-commit-msg.test.ts index 05c30297..df2547cd 100644 --- a/tests/unit/hooks/prepare-commit-msg.test.ts +++ b/tests/unit/hooks/prepare-commit-msg.test.ts @@ -232,10 +232,13 @@ describe('hook integration — commit message modification', () => { 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, CLAUDECODE: '1', GIT_MEM_AGENT: '' }, + env, }); const message = git(['log', '-1', '--format=%B'], repoDir); 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); + }); + }); +}); From 861e1e9719054684357f667a8c05ff0b61f61842 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 08:51:08 +0000 Subject: [PATCH 4/4] fix: validate commitCount for NaN/negative values (GIT-73) Fallback to default of 10 when --commit-count receives invalid input. Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 --- src/commands/init.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/init.ts b/src/commands/init.ts index 78e87395..ea23c687 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -134,6 +134,11 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger 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([ {