diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..d061eac --- /dev/null +++ b/commands.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func initCommand(lang string) error { + if lang == "" { + fmt.Println("Building base hako image...") + return buildBaseImage() + } + + if !imageExists("hako-userland") { + fmt.Println("Base image not found, building it first...") + if err := buildBaseImage(); err != nil { + return err + } + } + + fmt.Printf("Building %s language image...\n", lang) + return buildLanguageImage(lang) +} + +func upCommand(lang string) error { + containerName, err := getProjectContainerName(lang) + if err != nil { + return err + } + + imageName := fmt.Sprintf("hako-userland-%s", lang) + if !imageExists(imageName) { + return fmt.Errorf("image %s not found, run 'hako init %s' first", imageName, lang) + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + + if containerExists(containerName) { + if !containerIsRunning(containerName) { + fmt.Printf("Starting existing container %s...\n", containerName) + if err := startContainer(containerName); err != nil { + return err + } + } else { + fmt.Printf("Container %s is already running\n", containerName) + } + } else { + fmt.Printf("Creating new container %s...\n", containerName) + if err := createContainer(containerName, imageName, cwd); err != nil { + return err + } + if err := startContainer(containerName); err != nil { + return err + } + fmt.Printf("Copying workspace to container...\n") + if err := copyWorkspaceToContainer(containerName); err != nil { + return err + } + } + + fmt.Printf("Entering container %s...\n", containerName) + return execContainer(containerName) +} + +func downCommand(container string) error { + var containerName string + + if container != "" { + containerName = container + } else { + // Find any hako container for current project + output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + if err != nil { + return err + } + containers := strings.Split(strings.TrimSpace(output), "\n") + if len(containers) == 0 || containers[0] == "" { + return fmt.Errorf("no hako containers found for current project") + } + containerName = containers[0] // Use first found container + } + + if !containerExists(containerName) { + return fmt.Errorf("container %s does not exist", containerName) + } + + fmt.Printf("Stopping container %s...\n", containerName) + if containerIsRunning(containerName) { + if err := stopContainer(containerName); err != nil { + return err + } + } + + fmt.Printf("Removing container %s...\n", containerName) + return removeContainer(containerName) +} + +func psCommand() error { + fmt.Println("Hako containers:") + return listHakoContainers() +} + +func syncCommand() error { + output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + if err != nil { + return err + } + containers := strings.Split(strings.TrimSpace(output), "\n") + if len(containers) == 0 || containers[0] == "" { + return fmt.Errorf("no hako containers found for current project") + } + containerName := containers[0] // Use first found container + + if !containerExists(containerName) { + return fmt.Errorf("container %s does not exist", containerName) + } + + fmt.Printf("Syncing files from container %s...\n", containerName) + + tempDir, err := os.MkdirTemp("", "hako-sync-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + if err := copyFromContainer(containerName, "/workspace/.", tempDir); err != nil { + return err + } + + return syncFiles(tempDir, ".") +} + +func syncFiles(srcDir, destDir string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + if relPath == ".git" || relPath == ".gitignore" { + return filepath.SkipDir + } + + if strings.HasPrefix(filepath.Base(relPath), ".git") { + return nil + } + + destPath := filepath.Join(destDir, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + srcFile, err := os.Open(path) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + return err + }) +} + +func copyWorkspaceToContainer(containerName string) error { + // Get all files that should be copied (tracked + untracked, excluding ignored) + trackedFiles, err := runCommandOutput("git", "ls-files") + if err != nil { + return err + } + + untrackedFiles, err := runCommandOutput("git", "ls-files", "--others", "--exclude-standard") + if err != nil { + return err + } + + allFiles := []string{} + if trackedFiles != "" { + allFiles = append(allFiles, strings.Split(trackedFiles, "\n")...) + } + if untrackedFiles != "" { + allFiles = append(allFiles, strings.Split(untrackedFiles, "\n")...) + } + + if len(allFiles) == 0 { + return fmt.Errorf("no files to copy") + } + + // Copy files one by one to preserve directory structure + for _, file := range allFiles { + if file == "" { + continue + } + + // Create directory structure in container if needed + dir := filepath.Dir(file) + if dir != "." { + if err := runCommand("docker", "exec", containerName, "mkdir", "-p", "/workspace/"+dir); err != nil { + return err + } + } + + // Copy the file + if err := copyToContainer(containerName, file, "/workspace/"+file); err != nil { + return fmt.Errorf("failed to copy %s: %v", file, err) + } + } + + return nil +} \ No newline at end of file diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..46268f4 --- /dev/null +++ b/docker.go @@ -0,0 +1,208 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const hakoVersion = "1" + +func getConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + configDir := filepath.Join(homeDir, ".config", "hako") + return configDir, nil +} + +func ensureConfigDir() error { + configDir, err := getConfigDir() + if err != nil { + return err + } + return os.MkdirAll(configDir, 0755) +} + +func getBaseDockerfile() string { + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM debian:bookworm + +RUN apt-get update && apt-get install -y \ + curl \ + git \ + build-essential \ + ca-certificates \ + fish \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js LTS +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs + +# Install Claude Code +RUN npm install -g @anthropic-ai/claude-code + +# configure some things +RUN mkdir /workspace +RUN git config --global --add safe.directory /workspace + +WORKDIR /workspace +CMD ["/usr/bin/fish"]`, hakoVersion) +} + +func getLanguageDockerfile(lang string) string { + switch lang { + case "go": + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM hako-userland + +RUN curl -fsSL https://go.dev/dl/go1.21.5.linux-amd64.tar.gz | tar -C /usr/local -xzf - +ENV PATH="/usr/local/go/bin:${PATH}" + +WORKDIR /workspace +CMD ["/bin/bash"]`, hakoVersion) + case "py", "python": + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM hako-userland + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -s /usr/bin/python3 /usr/bin/python + +WORKDIR /workspace +CMD ["/bin/bash"]`, hakoVersion) + default: + return "" + } +} + +func buildBaseImage() error { + if err := ensureConfigDir(); err != nil { + return err + } + + configDir, _ := getConfigDir() + dockerfilePath := filepath.Join(configDir, "Dockerfile.base") + + dockerfile := getBaseDockerfile() + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + return err + } + + fmt.Printf("Building base image hako-userland...\n") + return runCommand("docker", "build", "-f", dockerfilePath, "-t", "hako-userland", ".") +} + +func buildLanguageImage(lang string) error { + dockerfile := getLanguageDockerfile(lang) + if dockerfile == "" { + return fmt.Errorf("unsupported language: %s", lang) + } + + configDir, err := getConfigDir() + if err != nil { + return err + } + + dockerfilePath := filepath.Join(configDir, fmt.Sprintf("Dockerfile.%s", lang)) + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + return err + } + + imageName := fmt.Sprintf("hako-userland-%s", lang) + fmt.Printf("Building %s image %s...\n", lang, imageName) + return runCommand("docker", "build", "-f", dockerfilePath, "-t", imageName, ".") +} + +func imageExists(imageName string) bool { + err := runCommand("docker", "image", "inspect", imageName) + return err == nil +} + +func containerExists(containerName string) bool { + err := runCommand("docker", "container", "inspect", containerName) + return err == nil +} + +func containerIsRunning(containerName string) bool { + output, err := runCommandOutput("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + return err == nil && strings.TrimSpace(output) == "true" +} + +func createContainer(containerName, imageName, workspaceDir string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + args := []string{"create", "--name", containerName} + + // Mount workspace + args = append(args, "-v", workspaceDir+":/workspace") + + + args = append(args, "-it", imageName) + + if err := runCommand("docker", args...); err != nil { + return err + } + + // Copy Claude config directory if it exists + claudeDir := filepath.Join(homeDir, ".claude") + if _, err := os.Stat(claudeDir); err == nil { + if err := copyToContainer(containerName, claudeDir, "/root/.claude"); err != nil { + return fmt.Errorf("failed to copy Claude config directory: %w", err) + } + } + + // Copy Claude config file if it exists + claudeFile := filepath.Join(homeDir, ".claude.json") + if _, err := os.Stat(claudeFile); err == nil { + if err := copyToContainer(containerName, claudeFile, "/root/.claude.json"); err != nil { + return fmt.Errorf("failed to copy Claude config file: %w", err) + } + } + + return nil +} + +func startContainer(containerName string) error { + return runCommand("docker", "start", containerName) +} + +func stopContainer(containerName string) error { + return runCommand("docker", "stop", containerName) +} + +func removeContainer(containerName string) error { + return runCommand("docker", "rm", containerName) +} + +func execContainer(containerName string) error { + cmd := exec.Command("docker", "exec", "-it", containerName, "/usr/bin/fish") + fmt.Fprintf(os.Stderr, "+ %s %s\n", "docker", strings.Join([]string{"exec", "-it", containerName, "/usr/bin/fish"}, " ")) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func copyToContainer(containerName, srcPath, destPath string) error { + return runCommand("docker", "cp", srcPath, containerName+":"+destPath) +} + +func copyFromContainer(containerName, srcPath, destPath string) error { + return runCommand("docker", "cp", containerName+":"+srcPath, destPath) +} + +func listHakoContainers() error { + return runCommand("docker", "ps", "-a", "--filter", "name=hako-", "--format", "table {{.Names}}\\t{{.Status}}\\t{{.Image}}") +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..b98a706 --- /dev/null +++ b/git.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +func isGitRepository() bool { + _, err := os.Stat(".git") + return err == nil +} + +func getGitRootPath() (string, error) { + output, err := runCommandOutput("git", "rev-parse", "--show-toplevel") + if err != nil { + return "", errors.New("not in a git repository") + } + return strings.TrimSpace(output), nil +} + +func getProjectContainerName(lang string) (string, error) { + if !isGitRepository() { + return "", errors.New("must be run inside a git repository") + } + + gitRoot, err := getGitRootPath() + if err != nil { + return "", err + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + relPath, err := filepath.Rel(gitRoot, cwd) + if err != nil { + return "", err + } + + projectName := filepath.Base(gitRoot) + + var pathComponents []string + if relPath != "." { + pathComponents = strings.Split(relPath, string(os.PathSeparator)) + } + + containerName := "hako-" + lang + "-" + sanitizeName(projectName) + for _, component := range pathComponents { + containerName += "-" + sanitizeName(component) + } + + if len(containerName) > 60 { + return "", errors.New("container name too long, try running from a shallower directory") + } + + return containerName, nil +} + +func sanitizeName(name string) string { + result := strings.ToLower(name) + result = strings.ReplaceAll(result, " ", "_") + result = strings.ReplaceAll(result, ".", "_") + result = strings.ReplaceAll(result, "-", "_") + return result +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..40774a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module hako + +go 1.21 + +require github.com/spf13/cobra v1.8.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0e8c2c --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..05ecf82 --- /dev/null +++ b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hako", + Short: "Docker container management for Claude Code", + Long: "A tool that manages Docker containers providing sandboxed environments for Claude Code", +} + +var initCmd = &cobra.Command{ + Use: "init [language]", + Short: "Initialize Docker images", + Long: "Build base Docker image or language-specific variants", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var lang string + if len(args) > 0 { + lang = args[0] + } + if err := initCommand(lang); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var upCmd = &cobra.Command{ + Use: "up ", + Short: "Start and enter a container", + Long: "Create or start a container for the current project and drop into shell", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := upCommand(args[0]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var downCmd = &cobra.Command{ + Use: "down [container]", + Short: "Stop a container", + Long: "Stop and remove a container", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var container string + if len(args) > 0 { + container = args[0] + } + if err := downCommand(container); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var psCmd = &cobra.Command{ + Use: "ps", + Short: "List running containers", + Long: "Show all running hako containers", + Run: func(cmd *cobra.Command, args []string) { + if err := psCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync files from container", + Long: "Copy files from container workspace to working directory", + Run: func(cmd *cobra.Command, args []string) { + if err := syncCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(upCmd) + rootCmd.AddCommand(downCmd) + rootCmd.AddCommand(psCmd) + rootCmd.AddCommand(syncCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} \ No newline at end of file diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..ca62b74 --- /dev/null +++ b/utils.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runCommandOutput(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +func runCommandWithInput(name string, input string, args ...string) error { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + cmd.Stdin = strings.NewReader(input) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} \ No newline at end of file