diff --git a/.release-it.notes.js b/.release-it.notes.js index 6b942f3e..3d88c3aa 100755 --- a/.release-it.notes.js +++ b/.release-it.notes.js @@ -1,51 +1,57 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; +import { execSync } from "child_process"; /** * Generate release notes using Claude Code by analyzing git commits * Usage: node scripts/generate-release-notes.js */ -const [,, fromTag, toVersion] = process.argv; +const [, , fromTag, toVersion] = process.argv; if (!fromTag || !toVersion) { - console.error('Usage: node scripts/generate-release-notes.js '); - process.exit(1); + console.error( + "Usage: node scripts/generate-release-notes.js " + ); + process.exit(1); } async function generateReleaseNotes() { + try { + // Get commit range for the release + const commitRange = + fromTag === "null" || !fromTag ? "--all" : `${fromTag}..HEAD`; + + // Get git log for the commits + let gitLog; try { - // Get commit range for the release - const commitRange = fromTag === 'null' || !fromTag ? '--all' : `${fromTag}..HEAD`; - - // Get git log for the commits - let gitLog; - try { - gitLog = execSync( - `git log ${commitRange} --pretty=format:"%h - %s (%an, %ar)" --no-merges`, - { encoding: 'utf8' } - ); - } catch (error) { - // Fallback to recent commits if tag doesn't exist - console.error(`Tag ${fromTag} not found, using recent commits instead`); - gitLog = execSync( - `git log -10 --pretty=format:"%h - %s (%an, %ar)" --no-merges`, - { encoding: 'utf8' } - ); - } + gitLog = execSync( + `git log ${commitRange} --pretty=format:"%h - %s (%an, %ar)" --no-merges`, + { encoding: "utf8" } + ); + } catch (error) { + // Fallback to recent commits if tag doesn't exist + console.error(`Tag ${fromTag} not found, using recent commits instead`); + gitLog = execSync( + `git log -10 --pretty=format:"%h - %s (%an, %ar)" --no-merges`, + { encoding: "utf8" } + ); + } - if (!gitLog.trim()) { - console.error('No commits found for release notes generation'); - process.exit(1); - } + if (!gitLog.trim()) { + console.error("No commits found for release notes generation"); + process.exit(1); + } - // Create a prompt for Claude to analyze commits and generate release notes - const prompt = `Please analyze these git commits and generate professional release notes for version ${toVersion} of the Happy CLI tool (a Claude Code session sharing CLI). + // Create a prompt for Claude to analyze commits and generate release notes + const prompt = `Please analyze these git commits and generate professional release notes for version ${toVersion} of the Happy CLI tool (a Claude Code session sharing CLI). Git commits: ${gitLog} +If the previous version was a beta version - like x.y.z-a +You should look back in the commit history until the previous non-beta version tag. These are really the changes that will go into this non-beta release. + Please format the output as markdown with: - A brief summary of the release - Organized sections for: @@ -60,24 +66,20 @@ Please format the output as markdown with: Do not include any preamble or explanations, just return the markdown release notes.`; - // Call Claude Code to generate release notes - console.error('Generating release notes with Claude Code...'); - const releaseNotes = execSync( - `claude --print "${prompt}"`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'inherit'], - maxBuffer: 1024 * 1024 * 10 // 10MB buffer - } - ); - - // Output release notes to stdout for release-it to use - console.log(releaseNotes.trim()); + // Call Claude Code to generate release notes + console.error("Generating release notes with Claude Code..."); + const releaseNotes = execSync(`claude --add-dir . --print "${prompt}"`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "inherit"], + maxBuffer: 1024 * 1024 * 10, // 10MB buffer + }); - } catch (error) { - console.error('Error generating release notes:', error.message); - process.exit(1); - } + // Output release notes to stdout for release-it to use + console.log(releaseNotes.trim()); + } catch (error) { + console.error("Error generating release notes:", error.message); + process.exit(1); + } } -generateReleaseNotes(); \ No newline at end of file +generateReleaseNotes(); diff --git a/README.md b/README.md index 4280c213..d8ab661a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This will: - `-v, --version` - Show version - `-m, --model ` - Claude model to use (default: sonnet) - `-p, --permission-mode ` - Permission mode: auto, default, or plan -- `--claude-env KEY=VALUE` - Set environment variable for Claude Code +- `--claude-env KEY=VALUE` - Set environment variable for Claude Code (e.g., for [claude-code-router](https://github.com/musistudio/claude-code-router)) - `--claude-arg ARG` - Pass additional argument to Claude CLI ## Environment Variables diff --git a/package.json b/package.json index b9cc33bd..59e02675 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "happy-coder", - "version": "0.12.0-0", + "version": "0.12.0", "description": "Mobile and Web client for Claude Code and Codex", "author": "Kirill Dubovitskiy", "license": "MIT", diff --git a/src/claude/claudeLocal.test.ts b/src/claude/claudeLocal.test.ts new file mode 100644 index 00000000..dd05dd54 --- /dev/null +++ b/src/claude/claudeLocal.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { claudeLocal } from './claudeLocal'; + +// Mock dependencies - use vi.hoisted to ensure mocks are available at hoist time +const { mockSpawn, mockClaudeFindLastSession } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockClaudeFindLastSession: vi.fn() +})); + +vi.mock('node:child_process', () => ({ + spawn: mockSpawn +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +vi.mock('./utils/claudeFindLastSession', () => ({ + claudeFindLastSession: mockClaudeFindLastSession +})); + +vi.mock('./utils/path', () => ({ + getProjectPath: vi.fn((path: string) => path) +})); + +vi.mock('./utils/systemPrompt', () => ({ + systemPrompt: 'test-system-prompt' +})); + +vi.mock('node:fs', () => ({ + mkdirSync: vi.fn(), + existsSync: vi.fn(() => true) +})); + +vi.mock('./utils/claudeCheckSession', () => ({ + claudeCheckSession: vi.fn(() => true) // Always return true (session exists) +})); + +describe('claudeLocal --continue handling', () => { + let onSessionFound: any; + + beforeEach(() => { + // Mock spawn to resolve immediately + mockSpawn.mockReturnValue({ + stdio: [null, null, null, null], + on: vi.fn((event, callback) => { + // Immediately call the 'exit' callback + if (event === 'exit') { + process.nextTick(() => callback(0)); + } + }), + addListener: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + stdin: { + on: vi.fn(), + end: vi.fn() + } + }); + + onSessionFound = vi.fn(); + + // Reset mocks + vi.clearAllMocks(); + }); + + it('should convert --continue to --resume with last session ID', async () => { + // Mock claudeFindLastSession to return a session ID + mockClaudeFindLastSession.mockReturnValue('123e4567-e89b-12d3-a456-426614174000'); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'] // User wants to continue last session + }); + + // Verify spawn was called + expect(mockSpawn).toHaveBeenCalled(); + + // Get the args passed to spawn (second argument is the array) + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // Should NOT contain --continue (converted to --resume) + expect(spawnArgs).not.toContain('--continue'); + + // Should NOT contain --session-id (no conflict) + expect(spawnArgs).not.toContain('--session-id'); + + // Should contain --resume with the found session ID + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('123e4567-e89b-12d3-a456-426614174000'); + + // Should notify about the session + expect(onSessionFound).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + }); + + it('should create new session when --continue but no sessions exist', async () => { + // Mock claudeFindLastSession to return null (no sessions) + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'] + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // Should contain --session-id for new session + expect(spawnArgs).toContain('--session-id'); + + // Should not contain --resume or --continue + expect(spawnArgs).not.toContain('--resume'); + expect(spawnArgs).not.toContain('--continue'); + }); + + it('should add --session-id for normal new sessions without --continue', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: [] // No session flags - new session + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--session-id'); + expect(spawnArgs).not.toContain('--continue'); + expect(spawnArgs).not.toContain('--resume'); + }); + + it('should handle --resume with specific session ID without conflict', async () => { + mockClaudeFindLastSession.mockReturnValue(null); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: 'existing-session-123', + path: '/tmp', + onSessionFound, + claudeArgs: [] // No --continue + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).toContain('--resume'); + expect(spawnArgs).toContain('existing-session-123'); + expect(spawnArgs).not.toContain('--session-id'); + }); + + it('should remove --continue from claudeArgs after conversion', async () => { + mockClaudeFindLastSession.mockReturnValue('session-456'); + + const claudeArgs = ['--continue', '--other-flag']; + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs + }); + + // Verify spawn was called without --continue (it gets converted to --resume) + const spawnArgs = mockSpawn.mock.calls[0][1]; + expect(spawnArgs).not.toContain('--continue'); + expect(spawnArgs).toContain('--other-flag'); + }); +}); \ No newline at end of file diff --git a/src/claude/claudeLocal.ts b/src/claude/claudeLocal.ts index a0b9d861..84b833e0 100644 --- a/src/claude/claudeLocal.ts +++ b/src/claude/claudeLocal.ts @@ -5,6 +5,7 @@ import { mkdirSync, existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { logger } from "@/ui/logger"; import { claudeCheckSession } from "./utils/claudeCheckSession"; +import { claudeFindLastSession } from "./utils/claudeFindLastSession"; import { getProjectPath } from "./utils/path"; import { projectPath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; @@ -33,21 +34,100 @@ export async function claudeLocal(opts: { // - If resuming an existing session: use --resume (Claude keeps the same session ID) // - If starting fresh: generate UUID and pass via --session-id let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { - startFrom = null; + + // Handle session-related flags from claudeArgs to ensure transparent behavior + // We intercept these flags to use happy-cli's session storage rather than Claude's default + // + // Supported patterns: + // --continue / -c : Resume last session in current directory + // --resume / -r : Resume last session (picker in Claude, but we handle) + // --resume / -r : Resume specific session by ID + // --session-id : Use specific UUID for new session + + // Helper to find and extract flag with optional value + const extractFlag = (flags: string[], withValue: boolean = false): { found: boolean; value?: string } => { + if (!opts.claudeArgs) return { found: false }; + + for (const flag of flags) { + const index = opts.claudeArgs.indexOf(flag); + if (index !== -1) { + if (withValue && index + 1 < opts.claudeArgs.length) { + const nextArg = opts.claudeArgs[index + 1]; + // Check if next arg looks like a value (doesn't start with -) + if (!nextArg.startsWith('-')) { + const value = nextArg; + // Remove both flag and value + opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index && i !== index + 1); + return { found: true, value }; + } + } + // Remove just the flag + opts.claudeArgs = opts.claudeArgs.filter((_, i) => i !== index); + return { found: true }; + } + } + return { found: false }; + }; + + // 1. Check for --session-id (explicit new session with specific ID) + const sessionIdFlag = extractFlag(['--session-id'], true); + if (sessionIdFlag.found && sessionIdFlag.value) { + startFrom = null; // Force new session mode, will use this ID below + logger.debug(`[ClaudeLocal] Using explicit --session-id: ${sessionIdFlag.value}`); + } + + // 2. Check for --resume / -r (resume specific session) + if (!startFrom && !sessionIdFlag.value) { + const resumeFlag = extractFlag(['--resume', '-r'], true); + if (resumeFlag.found) { + if (resumeFlag.value) { + startFrom = resumeFlag.value; + logger.debug(`[ClaudeLocal] Using provided session ID from --resume: ${startFrom}`); + } else { + // --resume without value: find last session + const lastSession = claudeFindLastSession(opts.path); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --resume: Found last session: ${lastSession}`); + } + } + } + } + + // 3. Check for --continue / -c (resume last session) + if (!startFrom && !sessionIdFlag.value) { + const continueFlag = extractFlag(['--continue', '-c'], false); + if (continueFlag.found) { + const lastSession = claudeFindLastSession(opts.path); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --continue: Found last session: ${lastSession}`); + } + } } // Generate new session ID if not resuming - const newSessionId = startFrom ? null : randomUUID(); + // Priority: 1. startFrom (resuming), 2. explicit --session-id, 3. generate new UUID + // + // Race condition safety: + // - New sessions: randomUUID() guarantees uniqueness even if multiple sessions start simultaneously + // - --continue/--resume: If multiple sessions resume the same last session, that's expected + // behavior (Claude Code handles concurrent access to session files) + // - --continue when no session: Falls through to randomUUID(), so each gets a unique ID + const explicitSessionId = sessionIdFlag.value || null; + const newSessionId = startFrom ? null : (explicitSessionId || randomUUID()); const effectiveSessionId = startFrom || newSessionId!; - + // Notify about session ID immediately (we know it upfront now!) - if (newSessionId) { - logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); - opts.onSessionFound(newSessionId); - } else { + if (startFrom) { logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`); - opts.onSessionFound(startFrom!); + opts.onSessionFound(startFrom); + } else if (explicitSessionId) { + logger.debug(`[ClaudeLocal] Using explicit session ID: ${explicitSessionId}`); + opts.onSessionFound(explicitSessionId); + } else { + logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`); + opts.onSessionFound(newSessionId!); } // Thinking state diff --git a/src/claude/utils/claudeFindLastSession.ts b/src/claude/utils/claudeFindLastSession.ts new file mode 100644 index 00000000..2d12b1c9 --- /dev/null +++ b/src/claude/utils/claudeFindLastSession.ts @@ -0,0 +1,50 @@ +import { readdirSync, statSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getProjectPath } from './path'; +import { claudeCheckSession } from './claudeCheckSession'; + +/** + * Finds the most recently modified VALID session in the project directory. + * A valid session must: + * 1. Contain at least one message with a uuid field + * 2. Have a session ID in UUID format (Claude Code v2.0.65+ requires this for --resume) + * + * Note: Agent sessions (agent-*) are excluded because --resume only accepts UUID format. + * Returns the session ID (filename without .jsonl extension) or null if no valid sessions found. + */ +export function claudeFindLastSession(workingDirectory: string): string | null { + try { + const projectDir = getProjectPath(workingDirectory); + + // UUID format pattern (8-4-4-4-12 hex digits) + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const files = readdirSync(projectDir) + .filter(f => f.endsWith('.jsonl')) + .map(f => { + const sessionId = f.replace('.jsonl', ''); + + // Filter out non-UUID session IDs (e.g., agent-* sessions) + // Claude Code --resume only accepts UUID format as of v2.0.65 + if (!uuidPattern.test(sessionId)) { + return null; + } + + // Check if this is a valid session (has messages with uuid field) + if (claudeCheckSession(sessionId, workingDirectory)) { + return { + name: f, + sessionId: sessionId, + mtime: statSync(join(projectDir, f)).mtime.getTime() + }; + } + return null; + }) + .filter(f => f !== null) + .sort((a, b) => b.mtime - a.mtime); // Most recent valid session first + + return files.length > 0 ? files[0].sessionId : null; + } catch { + return null; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c0293eb2..72febfa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,19 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('happy doctor c unknownArgs.push('--dangerously-skip-permissions') } else if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal' + } else if (arg === '--claude-env') { + // Parse KEY=VALUE environment variable to pass to Claude + const envArg = args[++i] + if (envArg && envArg.includes('=')) { + const eqIndex = envArg.indexOf('=') + const key = envArg.substring(0, eqIndex) + const value = envArg.substring(eqIndex + 1) + options.claudeEnvVars = options.claudeEnvVars || {} + options.claudeEnvVars[key] = value + } else { + console.error(chalk.red(`Invalid --claude-env format: ${envArg}. Expected KEY=VALUE`)) + process.exit(1) + } } else { // Pass unknown arguments through to claude unknownArgs.push(arg) @@ -303,8 +316,10 @@ ${chalk.bold('Usage:')} ${chalk.bold('Examples:')} happy Start session - happy --yolo Start with bypassing permissions + happy --yolo Start with bypassing permissions happy sugar for --dangerously-skip-permissions + happy --claude-env ANTHROPIC_BASE_URL=http://127.0.0.1:3456 + Use a custom API endpoint (e.g., claude-code-router) happy auth login --force Authenticate happy doctor Run diagnostics