Checkpoint

This commit is contained in:
Onur Solmaz 2025-05-27 12:13:41 +02:00
parent 94bc9e55ff
commit 1ad06ad7ad
6 changed files with 425 additions and 61 deletions

107
README.md
View file

@ -12,7 +12,7 @@ The primary goal of Claude Code Sandbox is to enable **full async agentic workfl
- Create commits and manage git operations
- Work continuously without interrupting the user
This creates a truly autonomous development assistant that can work asynchronously while you focus on other tasks, similar to [OpenAI Codex](https://chatgpt.com/codex) or [Google Jules](https://jules.dev), but running locally on your machine.
Access Claude through a **browser-based terminal** that lets you monitor and interact with the AI assistant while you work on other tasks. This creates a truly autonomous development assistant, similar to [OpenAI Codex](https://chatgpt.com/codex) or [Google Jules](https://jules.dev), but running locally on your machine with full control.
## Overview
@ -46,7 +46,7 @@ npm link # Creates global 'claude-sandbox' command
## Usage
### Basic Usage
### Quick Start
Simply run in any git repository:
@ -55,26 +55,90 @@ claude-sandbox
```
This will:
1. Create a new branch (`claude/[timestamp]`)
2. Start a Docker container with Claude Code
3. Forward your credentials automatically
4. Open an interactive session with Claude
3. Launch a web UI at `http://localhost:3456`
4. Open your browser automatically
### Command Options
### Commands
#### `claude-sandbox` (default)
Start a new container with web UI (recommended):
```bash
claude-sandbox [options]
claude-sandbox
```
#### `claude-sandbox start`
Explicitly start a new container with options:
```bash
claude-sandbox start [options]
Options:
-c, --config <path> Path to configuration file (default: ./claude-sandbox.config.json)
-d, --detached Run in detached mode
-c, --config <path> Configuration file (default: ./claude-sandbox.config.json)
-n, --name <name> Container name prefix
--no-web Disable web UI (use terminal attach)
--no-push Disable automatic branch pushing
--no-pr Disable automatic PR creation
-w, --web Launch web UI for terminal access
-h, --help Display help
-V, --version Display version
```
#### `claude-sandbox attach [container-id]`
Attach to an existing container:
```bash
# Interactive selection
claude-sandbox attach
# Specific container
claude-sandbox attach abc123def456
Options:
--no-web Use terminal attach instead of web UI
```
#### `claude-sandbox list`
List all Claude Sandbox containers:
```bash
claude-sandbox list
claude-sandbox ls # alias
Options:
-a, --all Show all containers (including stopped)
```
#### `claude-sandbox stop [container-id]`
Stop containers:
```bash
# Interactive selection
claude-sandbox stop
# Specific container
claude-sandbox stop abc123def456
# Stop all
claude-sandbox stop --all
```
#### `claude-sandbox logs [container-id]`
View container logs:
```bash
claude-sandbox logs
claude-sandbox logs abc123def456
Options:
-f, --follow Follow log output
-n, --tail <lines> Number of lines to show (default: 50)
```
#### `claude-sandbox clean`
Remove stopped containers:
```bash
claude-sandbox clean
claude-sandbox clean --force # Remove all containers
```
#### `claude-sandbox config`
Show current configuration:
```bash
claude-sandbox config
```
### Configuration
@ -209,19 +273,22 @@ When Claude makes a commit:
- Push branch and create PR
- Exit
### Asynchronous Operation
### Working with Multiple Containers
Run multiple instances simultaneously:
Run multiple Claude instances simultaneously:
```bash
# Terminal 1
claude-sandbox
# Terminal 1: Start main development
claude-sandbox start --name main-dev
# Terminal 2
claude-sandbox --name project-feature
# Terminal 2: Start feature branch work
claude-sandbox start --name feature-auth
# Terminal 3
claude-sandbox --detached --name background-task
# Terminal 3: List all running containers
claude-sandbox list
# Terminal 4: Attach to any container
claude-sandbox attach
```
## Docker Environment

2
package-lock.json generated
View file

@ -31,7 +31,7 @@
},
"devDependencies": {
"@types/dockerode": "^3.3.23",
"@types/inquirer": "^9.0.7",
"@types/inquirer": "^9.0.8",
"@types/node": "^20.11.5",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",

View file

@ -43,7 +43,7 @@
},
"devDependencies": {
"@types/dockerode": "^3.3.23",
"@types/inquirer": "^9.0.7",
"@types/inquirer": "^9.0.8",
"@types/node": "^20.11.5",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",

View file

@ -116,8 +116,13 @@ function initSocket() {
});
socket.on('disconnect', () => {
updateStatus('error', 'Disconnected');
term.writeln('\r\n\x1b[1;31mConnection lost. Click "Reconnect" to retry.\x1b[0m');
updateStatus('error', 'Disconnected from server');
term.writeln('\r\n\x1b[1;31mServer connection lost. Click "Reconnect" to retry.\x1b[0m');
});
socket.on('container-disconnected', () => {
updateStatus('error', 'Container disconnected');
term.writeln('\r\n\x1b[1;31mContainer connection lost. Click "Reconnect" to retry.\x1b[0m');
});
socket.on('error', (error) => {

View file

@ -1,55 +1,347 @@
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
import inquirer from "inquirer";
import Docker from "dockerode";
import { ClaudeSandbox } from "./index";
import { loadConfig } from "./config";
import { WebUIServer } from "./web-server";
import ora from "ora";
const docker = new Docker();
const program = new Command();
// Helper function to get Claude Sandbox containers
async function getClaudeSandboxContainers() {
const containers = await docker.listContainers({ all: true });
return containers.filter(c =>
c.Names.some(name => name.includes('claude-code-sandbox'))
);
}
// Helper function to select a container interactively
async function selectContainer(containers: any[]): Promise<string | null> {
if (containers.length === 0) {
console.log(chalk.yellow("No Claude Sandbox containers found."));
return null;
}
const choices = containers.map(c => ({
name: `${c.Names[0].substring(1)} - ${c.State} (${c.Status})`,
value: c.Id,
short: c.Id.substring(0, 12)
}));
const { containerId } = await inquirer.prompt([{
type: 'list',
name: 'containerId',
message: 'Select a container:',
choices
}]);
return containerId;
}
program
.name("claude-sandbox")
.description("Run Claude Code as an autonomous agent in Docker containers")
.version("0.1.0")
.option(
"-c, --config <path>",
"Path to configuration file",
"./claude-sandbox.config.json",
)
.option(
"-d, --detached",
"Run in detached mode (container runs in background)",
false,
)
.description("Run Claude Code in isolated Docker containers")
.version("0.1.0");
// Default command (start with web UI)
program
.action(async () => {
console.log(chalk.blue("🚀 Starting Claude Sandbox..."));
const config = await loadConfig("./claude-sandbox.config.json");
config.webUI = true; // Always use web UI by default
config.detached = true; // Web UI requires detached mode
const sandbox = new ClaudeSandbox(config);
await sandbox.run();
});
// Start command - explicitly start a new container
program
.command("start")
.description("Start a new Claude Sandbox container")
.option("-c, --config <path>", "Configuration file", "./claude-sandbox.config.json")
.option("-n, --name <name>", "Container name prefix")
.option(
"--claude-config <path>",
"Path to Claude configuration file (default: ~/.claude.json)",
)
.option("--no-web", "Disable web UI (use terminal attach)")
.option("--no-push", "Disable automatic branch pushing")
.option("--no-pr", "Disable automatic PR creation")
.option("--no-auto-claude", "Disable automatic Claude Code startup", false)
.option("-w, --web", "Launch web UI for terminal access")
.action(async (options) => {
console.log(chalk.blue("🚀 Starting new Claude Sandbox container..."));
const config = await loadConfig(options.config);
config.webUI = options.web !== false;
config.detached = config.webUI; // Web UI requires detached
config.containerPrefix = options.name || config.containerPrefix;
config.autoPush = options.push !== false;
config.autoCreatePR = options.pr !== false;
const sandbox = new ClaudeSandbox(config);
await sandbox.run();
});
// Attach command - attach to existing container
program
.command("attach [container-id]")
.description("Attach to an existing Claude Sandbox container")
.option("--no-web", "Use terminal attach instead of web UI")
.action(async (containerId, options) => {
const spinner = ora("Looking for containers...").start();
try {
console.log(chalk.blue("🚀 Starting Claude Sandbox..."));
const config = await loadConfig(options.config);
const sandbox = new ClaudeSandbox({
...config,
detached: options.detached,
containerPrefix: options.name,
claudeConfigPath: options.claudeConfig || config.claudeConfigPath,
autoPush: options.push,
autoCreatePR: options.pr,
autoStartClaude: !options.noAutoClaude,
webUI: options.web,
});
await sandbox.run();
} catch (error) {
console.error(chalk.red("Error:"), error);
let targetContainerId = containerId;
// If no container ID provided, show selection UI
if (!targetContainerId) {
spinner.stop();
const containers = await getClaudeSandboxContainers();
targetContainerId = await selectContainer(containers);
if (!targetContainerId) {
console.log(chalk.red("No container selected."));
process.exit(1);
}
}
spinner.text = "Attaching to container...";
if (options.web !== false) {
// Launch web UI for existing container
const webServer = new WebUIServer(docker);
const url = await webServer.start();
const fullUrl = `${url}?container=${targetContainerId}`;
spinner.succeed(chalk.green(`Web UI available at: ${fullUrl}`));
await webServer.openInBrowser(fullUrl);
console.log(chalk.yellow("Keep this terminal open to maintain the session"));
// Keep process running
await new Promise(() => {});
} else {
// Direct terminal attach
spinner.stop();
console.log(chalk.blue(`Attaching to container ${targetContainerId.substring(0, 12)}...`));
const container = docker.getContainer(targetContainerId);
const stream = await container.attach({
stream: true,
stdin: true,
stdout: true,
stderr: true
});
// Set up TTY
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
// Connect streams
stream.pipe(process.stdout);
process.stdin.pipe(stream);
// Handle cleanup
const cleanup = () => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
stream.end();
};
process.on('SIGINT', cleanup);
stream.on('end', cleanup);
}
} catch (error: any) {
spinner.fail(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
program.parse();
// List command - list all Claude Sandbox containers
program
.command("list")
.alias("ls")
.description("List all Claude Sandbox containers")
.option("-a, --all", "Show all containers (including stopped)")
.action(async (options) => {
const spinner = ora("Fetching containers...").start();
try {
const containers = await docker.listContainers({ all: options.all });
const claudeContainers = containers.filter(c =>
c.Names.some(name => name.includes('claude-code-sandbox'))
);
spinner.stop();
if (claudeContainers.length === 0) {
console.log(chalk.yellow("No Claude Sandbox containers found."));
return;
}
console.log(chalk.blue(`Found ${claudeContainers.length} Claude Sandbox container(s):\n`));
claudeContainers.forEach(c => {
const name = c.Names[0].substring(1);
const id = c.Id.substring(0, 12);
const state = c.State === 'running' ? chalk.green(c.State) : chalk.gray(c.State);
const status = c.Status;
console.log(`${chalk.cyan(id)} - ${name} - ${state} - ${status}`);
});
} catch (error: any) {
spinner.fail(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
// Stop command - stop Claude Sandbox containers
program
.command("stop [container-id]")
.description("Stop Claude Sandbox container(s)")
.option("-a, --all", "Stop all Claude Sandbox containers")
.action(async (containerId, options) => {
const spinner = ora("Stopping containers...").start();
try {
if (options.all) {
// Stop all Claude Sandbox containers
const containers = await getClaudeSandboxContainers();
const runningContainers = containers.filter(c => c.State === 'running');
if (runningContainers.length === 0) {
spinner.info("No running Claude Sandbox containers found.");
return;
}
for (const c of runningContainers) {
const container = docker.getContainer(c.Id);
await container.stop();
spinner.text = `Stopped ${c.Id.substring(0, 12)}`;
}
spinner.succeed(`Stopped ${runningContainers.length} container(s)`);
} else {
// Stop specific container
let targetContainerId = containerId;
if (!targetContainerId) {
spinner.stop();
const containers = await getClaudeSandboxContainers();
const runningContainers = containers.filter(c => c.State === 'running');
targetContainerId = await selectContainer(runningContainers);
if (!targetContainerId) {
console.log(chalk.red("No container selected."));
process.exit(1);
}
spinner.start();
}
const container = docker.getContainer(targetContainerId);
await container.stop();
spinner.succeed(`Stopped container ${targetContainerId.substring(0, 12)}`);
}
} catch (error: any) {
spinner.fail(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
// Logs command - view container logs
program
.command("logs [container-id]")
.description("View logs from a Claude Sandbox container")
.option("-f, --follow", "Follow log output")
.option("-n, --tail <lines>", "Number of lines to show from the end", "50")
.action(async (containerId, options) => {
try {
let targetContainerId = containerId;
if (!targetContainerId) {
const containers = await getClaudeSandboxContainers();
targetContainerId = await selectContainer(containers);
if (!targetContainerId) {
console.log(chalk.red("No container selected."));
process.exit(1);
}
}
const container = docker.getContainer(targetContainerId);
const logStream = await container.logs({
stdout: true,
stderr: true,
follow: options.follow,
tail: parseInt(options.tail)
});
// Docker logs come with headers, we need to parse them
container.modem.demuxStream(logStream, process.stdout, process.stderr);
if (options.follow) {
console.log(chalk.gray("Following logs... Press Ctrl+C to exit"));
}
} catch (error: any) {
console.error(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
// Clean command - remove stopped containers
program
.command("clean")
.description("Remove all stopped Claude Sandbox containers")
.option("-f, --force", "Remove all containers (including running)")
.action(async (options) => {
const spinner = ora("Cleaning up containers...").start();
try {
const containers = await getClaudeSandboxContainers();
const targetContainers = options.force
? containers
: containers.filter(c => c.State !== 'running');
if (targetContainers.length === 0) {
spinner.info("No containers to clean up.");
return;
}
for (const c of targetContainers) {
const container = docker.getContainer(c.Id);
if (c.State === 'running' && options.force) {
await container.stop();
}
await container.remove();
spinner.text = `Removed ${c.Id.substring(0, 12)}`;
}
spinner.succeed(`Cleaned up ${targetContainers.length} container(s)`);
} catch (error: any) {
spinner.fail(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
// Config command - show configuration
program
.command("config")
.description("Show current configuration")
.option("-p, --path <path>", "Configuration file path", "./claude-sandbox.config.json")
.action(async (options) => {
try {
const config = await loadConfig(options.path);
console.log(chalk.blue("Current configuration:"));
console.log(JSON.stringify(config, null, 2));
} catch (error: any) {
console.error(chalk.red(`Failed to load config: ${error.message}`));
process.exit(1);
}
});
program.parse();

View file

@ -90,7 +90,7 @@ export class WebUIServer {
});
stream.on('end', () => {
socket.emit('disconnect');
socket.emit('container-disconnected');
this.activeStreams.delete(socket.id);
});