mirror of
https://github.com/claude-did-this/claude-hub.git
synced 2026-02-15 03:31:47 +01:00
* feat: Add CLI for managing autonomous Claude Code container sessions This commit implements a new CLI tool 'claude-hub' for managing autonomous Claude Code container sessions. The CLI provides commands for: - Starting autonomous sessions (start) - Listing active/completed sessions (list) - Viewing session logs (logs) - Continuing sessions with new commands (continue) - Stopping sessions (stop) Each session runs in an isolated Docker container and maintains its state across interactions. The implementation includes session management, Docker container operations, and a comprehensive command-line interface. Resolves #133 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Complete autonomous CLI feature implementation This commit adds the following enhancements to the autonomous Claude CLI: - Add --issue flag to start command for GitHub issue context - Implement start-batch command with tasks.yaml support - Enhance PR flag functionality for better context integration - Implement session recovery mechanism with recover and sync commands - Add comprehensive documentation for all CLI commands Resolves all remaining requirements from issue #133 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * test: Add comprehensive test coverage for CLI - Add unit tests for SessionManager utility - Add simplified unit tests for DockerUtils utility - Add integration tests for start and start-batch commands - Configure Jest with TypeScript support - Add test mocks for Docker API and filesystem - Add test fixtures for batch processing - Document testing approach in README - Add code coverage reporting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * ci: Add CLI tests workflow and configure stable test suite - Create dedicated GitHub workflow for CLI tests - Update CLI test script to run only stable tests - Add test:all script for running all tests locally 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve CLI with TypeScript fixes and CI enhancements - Fix TypeScript Promise handling in list.ts and stop.ts - Update CI workflow to add build step and run all tests - Move ora dependency from devDependencies to dependencies - Update Docker build path to use repository root - Improve CLI script organization in package.json 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Skip Docker-dependent tests in CI - Update test scripts to exclude dockerUtils tests - Add SKIP_DOCKER_TESTS environment variable to CI workflow - Remove dockerUtils.simple.test.ts from specific tests This prevents timeouts in CI caused by Docker tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Refine test patterns to exclude only full Docker tests - Replace testPathIgnorePatterns with more precise glob patterns - Ensure dockerUtils.simple.test.ts is still included in the test runs - Keep specific tests command with all relevant tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Update Jest test patterns to correctly match test files The previous glob pattern '__tests__/\!(utils/dockerUtils.test).ts' was not finding any tests because it was looking for .ts files directly in the __tests__ folder, but all test files are in subdirectories. Fixed by using Jest's testPathIgnorePatterns option instead. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * test: Add tests for CLI list and continue commands Added comprehensive test coverage for the CLI list and continue commands: - Added list.test.ts with tests for all filtering options and edge cases - Added continue.test.ts with tests for successful continuation and error cases - Both files achieve full coverage of their respective commands These new tests help improve the overall test coverage for the CLI commands module. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * test: Add comprehensive tests for CLI logs, recover, and stop commands Added test coverage for remaining CLI commands: - logs.test.ts - tests for logs command functionality (94.54% coverage) - recover.test.ts - tests for recover and sync commands (100% coverage) - stop.test.ts - tests for stop command with single and all sessions (95.71% coverage) These tests dramatically improve the overall commands module coverage from 56% to 97%. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Align PR review prompt header with test expectations The PR review prompt header in githubController.ts now matches what the test expects in githubController-check-suite.test.js, fixing the failing test. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
287 lines
9.3 KiB
TypeScript
287 lines
9.3 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import mockFs from 'mock-fs';
|
|
import { SessionManager } from '../../src/utils/sessionManager';
|
|
import { SessionConfig, SessionStatus } from '../../src/types/session';
|
|
import { DockerUtils } from '../../src/utils/dockerUtils';
|
|
|
|
// Mock DockerUtils
|
|
jest.mock('../../src/utils/dockerUtils');
|
|
|
|
// Type for mocked DockerUtils
|
|
type MockedDockerUtils = {
|
|
isContainerRunning: jest.MockedFunction<DockerUtils['isContainerRunning']>;
|
|
startContainer: jest.MockedFunction<DockerUtils['startContainer']>;
|
|
};
|
|
|
|
describe('SessionManager', () => {
|
|
let sessionManager: SessionManager;
|
|
const sessionsDir = path.join(process.env.HOME as string, '.claude-hub', 'sessions');
|
|
|
|
// Sample session data
|
|
const sampleSession: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
|
repoFullName: 'test/repo',
|
|
containerId: 'test-container-id',
|
|
command: 'analyze this code',
|
|
status: 'running' as SessionStatus
|
|
};
|
|
|
|
// Mock DockerUtils implementation
|
|
const mockDockerUtils = DockerUtils as jest.MockedClass<typeof DockerUtils>;
|
|
let mockDockerInstance: MockedDockerUtils;
|
|
|
|
beforeEach(() => {
|
|
// Clear mocks before each test
|
|
jest.clearAllMocks();
|
|
|
|
// Setup mock DockerUtils instance
|
|
mockDockerInstance = {
|
|
isContainerRunning: jest.fn(),
|
|
startContainer: jest.fn()
|
|
} as unknown as MockedDockerUtils;
|
|
|
|
mockDockerUtils.mockImplementation(() => mockDockerInstance as any);
|
|
|
|
// Default mock implementation
|
|
mockDockerInstance.isContainerRunning.mockResolvedValue(true);
|
|
mockDockerInstance.startContainer.mockResolvedValue('new-container-id');
|
|
|
|
// Setup mock file system
|
|
const testHomeDir = process.env.HOME as string;
|
|
const claudeHubDir = path.join(testHomeDir, '.claude-hub');
|
|
mockFs({
|
|
[testHomeDir]: {},
|
|
[claudeHubDir]: {},
|
|
[sessionsDir]: {} // Empty directory
|
|
});
|
|
|
|
// Create fresh instance for each test
|
|
sessionManager = new SessionManager();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore real file system
|
|
mockFs.restore();
|
|
});
|
|
|
|
describe('createSession', () => {
|
|
it('should create a new session with a generated ID', () => {
|
|
const session = sessionManager.createSession(sampleSession);
|
|
|
|
expect(session).toHaveProperty('id');
|
|
expect(session.repoFullName).toBe('test/repo');
|
|
expect(session.containerId).toBe('test-container-id');
|
|
expect(session.command).toBe('analyze this code');
|
|
expect(session.status).toBe('running');
|
|
expect(session).toHaveProperty('createdAt');
|
|
expect(session).toHaveProperty('updatedAt');
|
|
});
|
|
|
|
it('should save the session to disk', () => {
|
|
// We need to spy on the filesystem write operation
|
|
const spy = jest.spyOn(fs, 'writeFileSync');
|
|
|
|
const session = sessionManager.createSession(sampleSession);
|
|
|
|
// Verify the write operation was called with the correct arguments
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy.mock.calls[0][0]).toContain(`${session.id}.json`);
|
|
|
|
// Check that the content passed to writeFileSync is correct
|
|
const writtenContent = JSON.parse(spy.mock.calls[0][1] as string);
|
|
expect(writtenContent).toEqual(session);
|
|
|
|
// Clean up
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('getSession', () => {
|
|
it('should retrieve a session by ID', () => {
|
|
const session = sessionManager.createSession(sampleSession);
|
|
const retrievedSession = sessionManager.getSession(session.id);
|
|
|
|
expect(retrievedSession).toEqual(session);
|
|
});
|
|
|
|
it('should return null for a non-existent session', () => {
|
|
const retrievedSession = sessionManager.getSession('non-existent');
|
|
|
|
expect(retrievedSession).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateSessionStatus', () => {
|
|
it('should update the status of a session', () => {
|
|
const session = sessionManager.createSession(sampleSession);
|
|
const result = sessionManager.updateSessionStatus(session.id, 'completed');
|
|
|
|
expect(result).toBe(true);
|
|
|
|
const updatedSession = sessionManager.getSession(session.id);
|
|
expect(updatedSession?.status).toBe('completed');
|
|
});
|
|
|
|
it('should return false for a non-existent session', () => {
|
|
const result = sessionManager.updateSessionStatus('non-existent', 'completed');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deleteSession', () => {
|
|
it('should delete a session', () => {
|
|
const session = sessionManager.createSession(sampleSession);
|
|
const result = sessionManager.deleteSession(session.id);
|
|
|
|
expect(result).toBe(true);
|
|
|
|
const filePath = path.join(sessionsDir, `${session.id}.json`);
|
|
expect(fs.existsSync(filePath)).toBe(false);
|
|
});
|
|
|
|
it('should return false for a non-existent session', () => {
|
|
const result = sessionManager.deleteSession('non-existent');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('listSessions', () => {
|
|
beforeEach(() => {
|
|
// Create multiple sessions for testing
|
|
sessionManager.createSession({
|
|
...sampleSession,
|
|
repoFullName: 'test/repo1',
|
|
status: 'running'
|
|
});
|
|
|
|
sessionManager.createSession({
|
|
...sampleSession,
|
|
repoFullName: 'test/repo2',
|
|
status: 'completed'
|
|
});
|
|
|
|
sessionManager.createSession({
|
|
...sampleSession,
|
|
repoFullName: 'other/repo',
|
|
status: 'running'
|
|
});
|
|
});
|
|
|
|
it('should list all sessions', async () => {
|
|
const sessions = await sessionManager.listSessions();
|
|
|
|
expect(sessions.length).toBe(3);
|
|
});
|
|
|
|
it('should filter sessions by status', async () => {
|
|
const sessions = await sessionManager.listSessions({ status: 'running' });
|
|
|
|
expect(sessions.length).toBe(2);
|
|
expect(sessions.every(s => s.status === 'running')).toBe(true);
|
|
});
|
|
|
|
it('should filter sessions by repo', async () => {
|
|
const sessions = await sessionManager.listSessions({ repo: 'test' });
|
|
|
|
expect(sessions.length).toBe(2);
|
|
expect(sessions.every(s => s.repoFullName.includes('test'))).toBe(true);
|
|
});
|
|
|
|
it('should apply limit to results', async () => {
|
|
const sessions = await sessionManager.listSessions({ limit: 2 });
|
|
|
|
expect(sessions.length).toBe(2);
|
|
});
|
|
|
|
it('should verify running container status', async () => {
|
|
// Mock container not running for one session
|
|
mockDockerInstance.isContainerRunning.mockImplementation(async (containerId) => {
|
|
return containerId !== 'test-container-id';
|
|
});
|
|
|
|
const sessions = await sessionManager.listSessions();
|
|
|
|
// At least one session should be updated to stopped
|
|
expect(sessions.some(s => s.status === 'stopped')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('recoverSession', () => {
|
|
let stoppedSessionId: string;
|
|
|
|
beforeEach(() => {
|
|
// Create a stopped session for recovery testing
|
|
const session = sessionManager.createSession({
|
|
...sampleSession,
|
|
status: 'stopped'
|
|
});
|
|
stoppedSessionId = session.id;
|
|
});
|
|
|
|
it('should recover a stopped session', async () => {
|
|
const result = await sessionManager.recoverSession(stoppedSessionId);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockDockerInstance.startContainer).toHaveBeenCalled();
|
|
|
|
const updatedSession = sessionManager.getSession(stoppedSessionId);
|
|
expect(updatedSession?.status).toBe('running');
|
|
expect(updatedSession?.containerId).toBe('new-container-id');
|
|
});
|
|
|
|
it('should fail to recover a non-existent session', async () => {
|
|
const result = await sessionManager.recoverSession('non-existent');
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockDockerInstance.startContainer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fail to recover a running session', async () => {
|
|
// Create a running session
|
|
const session = sessionManager.createSession({
|
|
...sampleSession,
|
|
status: 'running'
|
|
});
|
|
|
|
const result = await sessionManager.recoverSession(session.id);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockDockerInstance.startContainer).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('syncSessionStatuses', () => {
|
|
beforeEach(() => {
|
|
// Create multiple sessions for testing
|
|
sessionManager.createSession({
|
|
...sampleSession,
|
|
containerId: 'running-container',
|
|
status: 'running'
|
|
});
|
|
|
|
sessionManager.createSession({
|
|
...sampleSession,
|
|
containerId: 'stopped-container',
|
|
status: 'running'
|
|
});
|
|
});
|
|
|
|
it('should sync session statuses with container states', async () => {
|
|
// Mock container running check
|
|
mockDockerInstance.isContainerRunning.mockImplementation(async (containerId) => {
|
|
return containerId === 'running-container';
|
|
});
|
|
|
|
await sessionManager.syncSessionStatuses();
|
|
|
|
// Get all sessions after sync
|
|
const sessions = await sessionManager.listSessions();
|
|
|
|
// Should have one running and one stopped session
|
|
expect(sessions.filter(s => s.status === 'running').length).toBe(1);
|
|
expect(sessions.filter(s => s.status === 'stopped').length).toBe(1);
|
|
});
|
|
});
|
|
}); |