forked from claude-did-this/claude-hub
* 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>
250 lines
7.2 KiB
TypeScript
250 lines
7.2 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
SessionConfig,
|
|
SessionStatus,
|
|
SessionListOptions
|
|
} from '../types/session';
|
|
import { DockerUtils } from './dockerUtils';
|
|
|
|
/**
|
|
* Session manager for storing and retrieving Claude session data
|
|
*/
|
|
export class SessionManager {
|
|
private sessionsDir: string;
|
|
private dockerUtils: DockerUtils;
|
|
|
|
constructor() {
|
|
// Store sessions in ~/.claude-hub/sessions
|
|
this.sessionsDir = path.join(os.homedir(), '.claude-hub', 'sessions');
|
|
this.ensureSessionsDirectory();
|
|
this.dockerUtils = new DockerUtils();
|
|
}
|
|
|
|
/**
|
|
* Ensure the sessions directory exists
|
|
*/
|
|
private ensureSessionsDirectory(): void {
|
|
if (!fs.existsSync(this.sessionsDir)) {
|
|
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a new session ID
|
|
*/
|
|
generateSessionId(): string {
|
|
return uuidv4().substring(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Create a new session
|
|
*/
|
|
createSession(sessionConfig: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt'>): SessionConfig {
|
|
const id = this.generateSessionId();
|
|
const now = new Date().toISOString();
|
|
|
|
const session: SessionConfig = {
|
|
...sessionConfig,
|
|
id,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
|
|
this.saveSession(session);
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Save session to disk
|
|
*/
|
|
saveSession(session: SessionConfig): void {
|
|
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Get session by ID
|
|
*/
|
|
getSession(id: string): SessionConfig | null {
|
|
try {
|
|
const filePath = path.join(this.sessionsDir, `${id}.json`);
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(fileContent) as SessionConfig;
|
|
} catch (error) {
|
|
console.error(`Error reading session ${id}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update session status
|
|
*/
|
|
updateSessionStatus(id: string, status: SessionStatus): boolean {
|
|
const session = this.getSession(id);
|
|
if (!session) {
|
|
return false;
|
|
}
|
|
|
|
session.status = status;
|
|
session.updatedAt = new Date().toISOString();
|
|
this.saveSession(session);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Delete session
|
|
*/
|
|
deleteSession(id: string): boolean {
|
|
try {
|
|
const filePath = path.join(this.sessionsDir, `${id}.json`);
|
|
if (!fs.existsSync(filePath)) {
|
|
return false;
|
|
}
|
|
|
|
fs.unlinkSync(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error deleting session ${id}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List sessions with optional filtering
|
|
*/
|
|
async listSessions(options: SessionListOptions = {}): Promise<SessionConfig[]> {
|
|
try {
|
|
const files = fs.readdirSync(this.sessionsDir)
|
|
.filter(file => file.endsWith('.json'));
|
|
|
|
let sessions = files.map(file => {
|
|
const filePath = path.join(this.sessionsDir, file);
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(fileContent) as SessionConfig;
|
|
});
|
|
|
|
// Apply filters
|
|
if (options.status) {
|
|
sessions = sessions.filter(session => session.status === options.status);
|
|
}
|
|
|
|
if (options.repo) {
|
|
const repoFilter = options.repo;
|
|
sessions = sessions.filter(session => session.repoFullName.includes(repoFilter));
|
|
}
|
|
|
|
// Verify status of running sessions
|
|
const runningSessionsToCheck = sessions.filter(session => session.status === 'running');
|
|
await Promise.all(runningSessionsToCheck.map(async (session) => {
|
|
const isRunning = await this.dockerUtils.isContainerRunning(session.containerId);
|
|
if (!isRunning) {
|
|
session.status = 'stopped';
|
|
this.updateSessionStatus(session.id, 'stopped');
|
|
}
|
|
}));
|
|
|
|
// Sort by creation date (newest first)
|
|
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
|
|
// Apply limit if specified
|
|
if (options.limit && options.limit > 0) {
|
|
sessions = sessions.slice(0, options.limit);
|
|
}
|
|
|
|
return sessions;
|
|
} catch (error) {
|
|
console.error('Error listing sessions:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recover a session by recreating the container
|
|
*/
|
|
async recoverSession(id: string): Promise<boolean> {
|
|
try {
|
|
const session = this.getSession(id);
|
|
if (!session) {
|
|
console.error(`Session ${id} not found`);
|
|
return false;
|
|
}
|
|
|
|
if (session.status !== 'stopped') {
|
|
console.error(`Session ${id} is not stopped (status: ${session.status})`);
|
|
return false;
|
|
}
|
|
|
|
// Generate a new container name
|
|
const containerName = `claude-hub-${session.id}-recovered`;
|
|
|
|
// Prepare environment variables for the container
|
|
const envVars: Record<string, string> = {
|
|
REPO_FULL_NAME: session.repoFullName,
|
|
ISSUE_NUMBER: session.issueNumber ? String(session.issueNumber) : (session.prNumber ? String(session.prNumber) : ''),
|
|
IS_PULL_REQUEST: session.isPullRequest ? 'true' : 'false',
|
|
IS_ISSUE: session.isIssue ? 'true' : 'false',
|
|
BRANCH_NAME: session.branchName || '',
|
|
OPERATION_TYPE: 'default',
|
|
COMMAND: session.command,
|
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN || '',
|
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
|
|
BOT_USERNAME: process.env.BOT_USERNAME || 'ClaudeBot',
|
|
BOT_EMAIL: process.env.BOT_EMAIL || 'claude@example.com'
|
|
};
|
|
|
|
// Start the container
|
|
const containerId = await this.dockerUtils.startContainer(
|
|
containerName,
|
|
envVars,
|
|
session.resourceLimits
|
|
);
|
|
|
|
if (!containerId) {
|
|
console.error('Failed to start container for session recovery');
|
|
return false;
|
|
}
|
|
|
|
// Update session with new container ID and status
|
|
session.containerId = containerId;
|
|
session.status = 'running';
|
|
session.updatedAt = new Date().toISOString();
|
|
this.saveSession(session);
|
|
|
|
console.log(`Session ${id} recovered with new container ID: ${containerId}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error recovering session ${id}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronize session status with container status
|
|
* Updates session statuses based on actual container states
|
|
*/
|
|
async syncSessionStatuses(): Promise<void> {
|
|
try {
|
|
const sessions = await this.listSessions();
|
|
|
|
for (const session of sessions) {
|
|
if (session.status === 'running') {
|
|
const isRunning = await this.dockerUtils.isContainerRunning(session.containerId);
|
|
if (!isRunning) {
|
|
session.status = 'stopped';
|
|
this.updateSessionStatus(session.id, 'stopped');
|
|
console.log(`Updated session ${session.id} status from running to stopped (container not found)`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error syncing session statuses:', error);
|
|
}
|
|
}
|
|
} |