mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-08-04 10:59:28 +00:00
Checkpoint
This commit is contained in:
parent
94bc9e55ff
commit
1ad06ad7ad
6 changed files with 425 additions and 61 deletions
107
README.md
107
README.md
|
@ -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
2
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
364
src/cli.ts
364
src/cli.ts
|
@ -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();
|
|
@ -90,7 +90,7 @@ export class WebUIServer {
|
|||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
socket.emit('disconnect');
|
||||
socket.emit('container-disconnected');
|
||||
this.activeStreams.delete(socket.id);
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue