cli/pkg/devops/claude.go
Snider c00b374147 feat(devops): add Claude sandbox session
Starts Claude in immutable dev environment with auth forwarding.
Auto-boots VM, mounts project, forwards credentials.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 02:17:34 +00:00

141 lines
3.6 KiB
Go

package devops
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ClaudeOptions configures the Claude sandbox session.
type ClaudeOptions struct {
NoAuth bool // Don't forward any auth
Auth []string // Selective auth: "gh", "anthropic", "ssh", "git"
Model string // Model to use: opus, sonnet
}
// Claude starts a sandboxed Claude session in the dev environment.
func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error {
// Auto-boot if not running
running, err := d.IsRunning(ctx)
if err != nil {
return err
}
if !running {
fmt.Println("Dev environment not running, booting...")
if err := d.Boot(ctx, DefaultBootOptions()); err != nil {
return fmt.Errorf("failed to boot: %w", err)
}
}
// Mount project
if err := d.mountProject(ctx, projectDir); err != nil {
return fmt.Errorf("failed to mount project: %w", err)
}
// Prepare environment variables to forward
envVars := []string{}
if !opts.NoAuth {
authTypes := opts.Auth
if len(authTypes) == 0 {
authTypes = []string{"gh", "anthropic", "ssh", "git"}
}
for _, auth := range authTypes {
switch auth {
case "anthropic":
if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
envVars = append(envVars, "ANTHROPIC_API_KEY="+key)
}
case "git":
// Forward git config
name, _ := exec.Command("git", "config", "user.name").Output()
email, _ := exec.Command("git", "config", "user.email").Output()
if len(name) > 0 {
envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name)))
envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name)))
}
if len(email) > 0 {
envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email)))
envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email)))
}
}
}
}
// Build SSH command with agent forwarding
args := []string{
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-A", // SSH agent forwarding
"-p", "2222",
}
args = append(args, "root@localhost")
// Build command to run inside
claudeCmd := "cd /app && claude"
if opts.Model != "" {
claudeCmd += " --model " + opts.Model
}
args = append(args, claudeCmd)
// Set environment for SSH
cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Pass environment variables through SSH
for _, env := range envVars {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
cmd.Env = append(os.Environ(), env)
}
}
fmt.Println("Starting Claude in sandboxed environment...")
fmt.Println("Project mounted at /app")
fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts))
fmt.Println()
return cmd.Run()
}
func formatAuthList(opts ClaudeOptions) string {
if opts.NoAuth {
return " (none)"
}
if len(opts.Auth) == 0 {
return ", gh, anthropic, git"
}
return ", " + strings.Join(opts.Auth, ", ")
}
// CopyGHAuth copies GitHub CLI auth to the VM.
func (d *DevOps) CopyGHAuth(ctx context.Context) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
ghConfigDir := filepath.Join(home, ".config", "gh")
if _, err := os.Stat(ghConfigDir); os.IsNotExist(err) {
return nil // No gh config to copy
}
// Use scp to copy gh config
cmd := exec.CommandContext(ctx, "scp",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-P", "2222",
"-r", ghConfigDir,
"root@localhost:/root/.config/",
)
return cmd.Run()
}