Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 48 additions & 46 deletions .release-it.notes.js
Original file line number Diff line number Diff line change
@@ -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 <from-tag> <to-version>
*/

const [,, fromTag, toVersion] = process.argv;
const [, , fromTag, toVersion] = process.argv;

if (!fromTag || !toVersion) {
console.error('Usage: node scripts/generate-release-notes.js <from-tag> <to-version>');
process.exit(1);
console.error(
"Usage: node scripts/generate-release-notes.js <from-tag> <to-version>"
);
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:
Expand All @@ -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();
generateReleaseNotes();
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ This will:
- `-v, --version` - Show version
- `-m, --model <model>` - Claude model to use (default: sonnet)
- `-p, --permission-mode <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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
177 changes: 177 additions & 0 deletions src/claude/claudeLocal.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading