diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e2ee183 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(find:*)", + "Bash(rg:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f140614..49420c5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,3 @@ Thumbs.db # Logs *.log .claude/ -./hako diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 52d4600..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,48 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Hako is a Docker container management tool designed specifically for Claude Code. It provides sandboxed environments to prevent Claude from accidentally affecting files outside the current working directory. - -## Architecture - -- **main.go**: CLI entry point using Cobra framework with commands: init, up, down, ps, sync -- **commands.go**: Command implementations that orchestrate Docker operations and file syncing -- **docker.go**: Core Docker operations (image building, container lifecycle, file copying) -- **git.go**: Git integration for project detection and container naming -- **utils.go**: Utility functions for command execution with logging to stderr - -## Key Design Patterns - -- Container names follow pattern: `hako-{language}-{project}-{subdirs}` (sanitized, max 60 chars) -- All Docker commands are logged to stderr for transparency -- File syncing respects .gitignore and excludes .git directory for security -- Images are versioned (HAKO-VERSION=1, as a Docker label) for upgrade management - - NOTE: changes to the base Dockerfiles should involve bumping the hako version - -## Development Commands - -Build and run: -```bash -go run . -``` - -Build binary: -```bash -go build -o hako -``` - -## Core Workflows - -1. **Image Management**: Base image (hako-userland) + language variants (hako-userland-{lang}) -2. **Container Lifecycle**: Create โ†’ Start โ†’ Copy workspace โ†’ Enter shell -3. **File Syncing**: Uses git ls-files to copy only tracked/untracked (non-ignored) files -4. **Project Isolation**: Must run within git repositories, containers scoped to project+path - -## Configuration - -- Base Dockerfile stored in `~/.config/hako/Dockerfile.base` -- Language Dockerfiles in `~/.config/hako/Dockerfile.{lang}` -- Mounts Claude config directory/file if present for seamless integration diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3be558e --- /dev/null +++ b/TODO.md @@ -0,0 +1,65 @@ +# Hako Implementation TODO + +## ๐Ÿ—๏ธ Core Architecture +- [x] **Package Structure Design** - Library + CLI separation +- [x] **Public API Design** - Manager interface with clean methods +- [ ] **Project Setup** - go.mod, basic file structure + +## ๐Ÿณ Docker Integration +- [ ] **Base Image Building** - Dockerfile templates for hako-userland +- [ ] **Language Variants** - Template system for go/py/node/etc extensions +- [ ] **Image Management** - Build, tag, cleanup operations +- [ ] **Container Lifecycle** - Create, start, stop, remove operations + +## ๐Ÿ”ง Git Operations (CLI-based) +- [ ] **Repository Detection** - `git rev-parse --is-inside-work-tree` +- [ ] **Path Resolution** - Get repo root and relative paths +- [ ] **File Listing** - Integration with `git ls-files` for sync +- [ ] **Gitignore Support** - Use `git check-ignore` for filtering + +## ๐Ÿ“ File Synchronization +- [ ] **Host โ†’ Container** - Copy workspace files on `hako up` +- [ ] **Container โ†’ Host** - `hako sync` with gitignore filtering +- [ ] **Permission Preservation** - Maintain file modes and ownership +- [ ] **Incremental Sync** - Only copy changed files (optimization) + +## ๐ŸŽฏ CLI Commands +- [ ] **hako init [lang]** - Image building command +- [ ] **hako up ** - Container creation and shell access +- [ ] **hako sync** - File synchronization back to host +- [ ] **hako ps** - List running containers +- [ ] **hako down [name]** - Container teardown + +## ๐Ÿ” Container Management +- [ ] **Naming Strategy** - `hako--` format +- [ ] **Path Sanitization** - Safe container names from filesystem paths +- [ ] **Container Discovery** - Find existing containers by labels +- [ ] **Resource Limits** - Sensible defaults for CPU/memory + +## โš™๏ธ Configuration +- [ ] **Base Dockerfile** - Store in `~/.config/hako/Dockerfile.base` +- [ ] **User Customization** - Allow editing base image before builds +- [ ] **Language Templates** - Configurable language-specific additions +- [ ] **Settings Persistence** - User preferences and defaults + +## ๐Ÿ›ก๏ธ Security & Safety +- [ ] **Git Repo Enforcement** - Fail outside of git repositories +- [ ] **Container Isolation** - Limited host filesystem access +- [ ] **Sync Filtering** - Respect .gitignore, exclude .git directory +- [ ] **Path Validation** - Prevent directory traversal attacks + +## ๐Ÿงช Testing & Quality +- [ ] **Unit Tests** - Core functionality coverage +- [ ] **Integration Tests** - End-to-end CLI workflows +- [ ] **Docker Mocks** - Testable without actual containers +- [ ] **Error Handling** - Graceful failures with helpful messages + +## ๐Ÿ“š Documentation +- [ ] **README** - Installation and usage guide +- [ ] **API Docs** - Package documentation for library users +- [ ] **Examples** - Common workflow demonstrations +- [ ] **Troubleshooting** - Common issues and solutions + +--- + +**Current Status:** Architecture approved โœ… | Ready for implementation ๐Ÿš€ \ No newline at end of file diff --git a/cli/down.go b/cli/down.go new file mode 100644 index 0000000..20e726b --- /dev/null +++ b/cli/down.go @@ -0,0 +1,35 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "l4.pm/hako/pkg" +) + +var downCmd = &cobra.Command{ + Use: "down [container-name]", + Short: "Stop and remove a hako container", + Long: `Stop and remove a hako container. + +If no container name is provided, it will stop the container +for the current project directory. + +Examples: + hako down # Stop current project's container + hako down hako-go-myproject # Stop specific container`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manager, err := hako.New() + if err != nil { + return fmt.Errorf("failed to create hako manager: %w", err) + } + + containerName := "" + if len(args) > 0 { + containerName = args[0] + } + + return manager.Down(containerName) + }, +} diff --git a/cli/init.go b/cli/init.go new file mode 100644 index 0000000..dc39c01 --- /dev/null +++ b/cli/init.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "l4.pm/hako/pkg" +) + +var initCmd = &cobra.Command{ + Use: "init [language]", + Short: "Build hako images", + Long: `Build the base hako image and optionally a language-specific variant. + +Examples: + hako init # Build base image only + hako init go # Build base + Go image + hako init python # Build base + Python image + hako init rust # Build base + Rust image`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manager, err := hako.New() + if err != nil { + return fmt.Errorf("failed to create hako manager: %w", err) + } + + language := "" + if len(args) > 0 { + language = args[0] + } + + return manager.Init(language) + }, +} diff --git a/cli/ps.go b/cli/ps.go new file mode 100644 index 0000000..0d69fc1 --- /dev/null +++ b/cli/ps.go @@ -0,0 +1,37 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "l4.pm/hako/pkg" +) + +var psCmd = &cobra.Command{ + Use: "ps", + Short: "List running hako containers", + Long: `Show all currently running hako containers.`, + RunE: func(cmd *cobra.Command, args []string) error { + manager, err := hako.New() + if err != nil { + return fmt.Errorf("failed to create hako manager: %w", err) + } + + containers, err := manager.List() + if err != nil { + return err + } + + if len(containers) == 0 { + fmt.Println("No hako containers running") + return nil + } + + fmt.Println("Running hako containers:") + for _, container := range containers { + fmt.Printf(" %s\n", container) + } + + return nil + }, +} diff --git a/cli/root.go b/cli/root.go new file mode 100644 index 0000000..7ec8359 --- /dev/null +++ b/cli/root.go @@ -0,0 +1,33 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hako", + Short: "Containerized development environments for Claude Code", + Long: `Hako provides sandboxed Docker containers for Claude Code development. + +It creates isolated environments that prevent Claude from accidentally +modifying files outside your current project directory.`, +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(upCmd) + rootCmd.AddCommand(syncCmd) + rootCmd.AddCommand(psCmd) + rootCmd.AddCommand(downCmd) +} diff --git a/cli/sync.go b/cli/sync.go new file mode 100644 index 0000000..125b58f --- /dev/null +++ b/cli/sync.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "l4.pm/hako/pkg" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync files from container to host", + Long: `Copy files from the container workspace back to your host filesystem. + +This respects .gitignore patterns and excludes .git directory to prevent +any security issues. + +Files are synced from /workspace in the container to your current +project directory.`, + RunE: func(cmd *cobra.Command, args []string) error { + manager, err := hako.New() + if err != nil { + return fmt.Errorf("failed to create hako manager: %w", err) + } + + return manager.Sync() + }, +} diff --git a/cli/up.go b/cli/up.go new file mode 100644 index 0000000..3a75ccd --- /dev/null +++ b/cli/up.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "l4.pm/hako/pkg" +) + +var upCmd = &cobra.Command{ + Use: "up ", + Short: "Start a hako container", + Long: `Create and start a hako container for the current project. + +This will: +1. Create a container if it doesn't exist +2. Copy your project files to the container +3. Drop you into an interactive shell + +Examples: + hako up go # Start Go development container + hako up python # Start Python development container + hako up rust # Start Rust development container`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manager, err := hako.New() + if err != nil { + return fmt.Errorf("failed to create hako manager: %w", err) + } + + language := args[0] + return manager.Up(language) + }, +} diff --git a/cmd/hako/main.go b/cmd/hako/main.go new file mode 100644 index 0000000..29f2b21 --- /dev/null +++ b/cmd/hako/main.go @@ -0,0 +1,7 @@ +package main + +import "l4.pm/hako/cli" + +func main() { + cli.Execute() +} \ No newline at end of file diff --git a/commands.go b/commands.go deleted file mode 100644 index 6bdb5ba..0000000 --- a/commands.go +++ /dev/null @@ -1,295 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" -) - -func getContainerForCurrentDirectory() (string, error) { - // Get all hako containers - 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") - } - - // Try to find containers that match any supported language for the current directory - supportedLangs := []string{"go", "py", "python"} - - for _, lang := range supportedLangs { - expectedName, err := getProjectContainerName(lang) - if err != nil { - continue // Skip if we can't generate a name for this language - } - - // Check if this expected container name exists in our list - for _, container := range containers { - if strings.TrimSpace(container) == expectedName { - return expectedName, nil - } - } - } - - // If no exact match found, return the first container as fallback - return strings.TrimSpace(containers[0]), nil -} - -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 - } - } else { - // Check base image version - checkVersionMismatch("hako-userland") - } - - 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) - } - - // Check for version mismatch - checkVersionMismatch(imageName) - - 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 the appropriate hako container for current directory - var err error - containerName, err = getContainerForCurrentDirectory() - if err != nil { - return fmt.Errorf("no hako containers found for current project: %v", err) - } - } - - 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:") - if err := listHakoContainers(); err != nil { - return err - } - - // Check for version mismatches in running containers - output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Image}}") - if err == nil { - images := strings.Split(strings.TrimSpace(output), "\n") - checkedImages := make(map[string]bool) - for _, image := range images { - if image != "" && !checkedImages[image] { - checkVersionMismatch(image) - checkedImages[image] = true - } - } - } - - return nil -} - -func syncCommand() error { - // Find the appropriate hako container for current directory - containerName, err := getContainerForCurrentDirectory() - if err != nil { - return fmt.Errorf("no hako containers found for current project: %v", err) - } - - 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 { - gitignore, err := NewGitIgnore() - if err != nil { - return fmt.Errorf("failed to parse .gitignore: %v", err) - } - - return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return fmt.Errorf("failed to walk %q: %v", path, err) - } - - relPath, err := filepath.Rel(srcDir, path) - if err != nil { - return fmt.Errorf("failed to calculate relative path for %q: %v", path, err) - } - - // Skip .git directory and its contents - if relPath == ".git" { - return filepath.SkipDir - } - - // Skip files that start with .git (like .gitignore, .gitmodules, etc.) - if strings.HasPrefix(filepath.Base(relPath), ".git") { - return nil - } - - // Check if the file should be ignored according to .gitignore - if gitignore.IsIgnored(relPath) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - - destPath := filepath.Join(destDir, relPath) - - if info.IsDir() { - return os.MkdirAll(destPath, info.Mode()) - } - - fmt.Println("cp", path, destPath) - srcFile, err := os.Open(path) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to open %s: %v\n", path, err) - return nil - } - defer srcFile.Close() - - destFile, err := os.Create(destPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to create %s: %v\n", destPath, err) - return nil - } - defer destFile.Close() - - _, err = io.Copy(destFile, srcFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to copy %s to %s: %v\n", path, destPath, err) - } - return nil - }) -} - -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 -} diff --git a/docker.go b/docker.go deleted file mode 100644 index f671ea6..0000000 --- a/docker.go +++ /dev/null @@ -1,226 +0,0 @@ -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(`FROM debian:trixie-20250610 -LABEL HAKO-VERSION=%s - -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(`FROM hako-userland -LABEL HAKO-VERSION=%s - -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 ["/usr/bin/fish"]`, hakoVersion) - case "py", "python": - return fmt.Sprintf(`FROM hako-userland -LABEL HAKO-VERSION=%s - -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 ["/usr/bin/fish"]`, 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 := runCommandSilent("docker", "image", "inspect", imageName) - return err == nil -} - -func containerExists(containerName string) bool { - err := runCommandSilent("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} - - // No mounting - workspace is isolated and copied separately - 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}}") -} - -func getImageVersion(imageName string) (string, error) { - output, err := runCommandOutput("docker", "image", "inspect", "-f", "{{index .Config.Labels \"HAKO-VERSION\"}}", imageName) - if err != nil { - return "", err - } - return strings.TrimSpace(output), nil -} - -func checkVersionMismatch(imageName string) { - imageVersion, err := getImageVersion(imageName) - if err != nil { - // If we can't get the version, don't warn (might be old image without version) - return - } - - if imageVersion != "" && imageVersion != hakoVersion { - fmt.Printf("\033[33mWarning: Image %s has version %s but hako executable is version %s\033[0m\n", imageName, imageVersion, hakoVersion) - fmt.Printf("\033[33mConsider running 'hako init' to rebuild with the current version\033[0m\n") - } -} diff --git a/git.go b/git.go deleted file mode 100644 index 537267d..0000000 --- a/git.go +++ /dev/null @@ -1,68 +0,0 @@ -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 -} diff --git a/go.mod b/go.mod index 40774a1..0e92979 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module hako +module l4.pm/hako go 1.21 diff --git a/idea.md b/idea.md index 46f96c4..75add08 100644 --- a/idea.md +++ b/idea.md @@ -10,8 +10,6 @@ proposed UI: # builds the base hako image (debian:bookworm + node (latest lts) + npm install -g @anthropic-ai/claude-code + gcc) # this writes to ~/.config/hako/Dockerfile.base with the defaults # if you want to customize the base image, then edit the dockerfile and rerun `hako init` -# NOTE: dockerfiles have some special metadata on the first comment (say `# HAKO-VERSION=1`, this is important because if we want to edit the base one we would have to require -# users to wipe their dockerfiles first and then do it. we should be able to _warn_ them of that while running hako commands, and then init recreates the dockerfile on the latest version) # target image name: hako-userland hako init diff --git a/main.go b/main.go deleted file mode 100644 index 86f4f83..0000000 --- a/main.go +++ /dev/null @@ -1,99 +0,0 @@ -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) - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..dc65d5b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,170 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +// Config holds hako configuration. +type Config struct { + ConfigDir string +} + +// Load loads or creates the hako configuration. +func Load() (*Config, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + configDir := filepath.Join(homeDir, ".config", "hako") + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + return &Config{ + ConfigDir: configDir, + }, nil +} + +// BaseDockerfilePath returns the path to the base Dockerfile. +func (c *Config) BaseDockerfilePath() string { + return filepath.Join(c.ConfigDir, "Dockerfile.base") +} + +// EnsureBaseDockerfile creates the base Dockerfile if it doesn't exist. +func (c *Config) EnsureBaseDockerfile() error { + path := c.BaseDockerfilePath() + + if _, err := os.Stat(path); err == nil { + return nil // File already exists + } + + content := `FROM debian:bookworm + +# Install basic dependencies +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + gnupg \ + lsb-release \ + git \ + gcc \ + g++ \ + make \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js (latest LTS) +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs + +# Install Claude Code CLI +RUN npm install -g @anthropic-ai/claude-code + +# Configure passwordless sudo for all users +RUN echo 'ALL ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers + +# Create workspace directory +RUN mkdir -p /workspace +WORKDIR /workspace + +# Default command +CMD ["/bin/bash"] +` + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write base dockerfile: %w", err) + } + + fmt.Printf("Created base Dockerfile at: %s\n", path) + fmt.Println("You can edit this file to customize the base image before running 'hako init'") + return nil +} + +// LanguageDockerfile returns the path to a language-specific Dockerfile. +func (c *Config) LanguageDockerfile(language string) (string, error) { + path := filepath.Join(c.ConfigDir, fmt.Sprintf("Dockerfile.%s", language)) + + if _, err := os.Stat(path); err == nil { + return path, nil // File already exists + } + + content, err := c.getLanguageDockerfileContent(language) + if err != nil { + return "", err + } + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return "", fmt.Errorf("failed to write %s dockerfile: %w", language, err) + } + + fmt.Printf("Created %s Dockerfile at: %s\n", language, path) + return path, nil +} + +// getLanguageDockerfileContent returns the Dockerfile content for a language. +func (c *Config) getLanguageDockerfileContent(language string) (string, error) { + switch language { + case "go": + return `FROM hako-userland + +# Install Go +RUN curl -fsSL https://go.dev/dl/go1.21.0.linux-amd64.tar.gz | tar -xzC /usr/local +ENV PATH=/usr/local/go/bin:$PATH + +# Verify Go installation +RUN go version + +CMD ["/bin/bash"] +`, nil + + case "py", "python": + return `FROM hako-userland + +# Install Python and pip +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +# Create python3 symlink +RUN ln -sf /usr/bin/python3 /usr/bin/python + +# Verify Python installation +RUN python --version && pip3 --version + +CMD ["/bin/bash"] +`, nil + + case "rust": + return `FROM hako-userland + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH=/root/.cargo/bin:$PATH + +# Verify Rust installation +RUN rustc --version && cargo --version + +CMD ["/bin/bash"] +`, nil + + case "node", "js": + return `FROM hako-userland + +# Node.js is already installed in the base image +# Add any additional Node.js specific tools here + +# Install common development tools +RUN npm install -g typescript ts-node nodemon + +CMD ["/bin/bash"] +`, nil + + default: + return "", fmt.Errorf("unsupported language: %s", language) + } +} diff --git a/pkg/docker/client.go b/pkg/docker/client.go new file mode 100644 index 0000000..00d7ef1 --- /dev/null +++ b/pkg/docker/client.go @@ -0,0 +1,288 @@ +package docker + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "l4.pm/hako/pkg/config" +) + +// loggedCommand creates a command and logs it to stderr +func loggedCommand(name string, args ...string) *exec.Cmd { + log.Printf("exec: %s %s", name, strings.Join(args, " ")) + return exec.Command(name, args...) +} + +// Client wraps Docker operations. +type Client struct { + ctx context.Context +} + +// NewClient creates a new Docker client. +func NewClient() (*Client, error) { + // Check if Docker is available + cmd := loggedCommand("docker", "version", "--format", "{{.Server.Version}}") + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("docker is not available: %w", err) + } + + return &Client{ + ctx: context.Background(), + }, nil +} + +// BuildBaseImage builds the base hako image. +func (c *Client) BuildBaseImage(cfg *config.Config) error { + dockerfile := cfg.BaseDockerfilePath() + + // Ensure base dockerfile exists + if err := cfg.EnsureBaseDockerfile(); err != nil { + return fmt.Errorf("failed to create base dockerfile: %w", err) + } + + // Build the base image + cmd := loggedCommand("docker", "build", + "-f", dockerfile, + "-t", "hako-userland", + filepath.Dir(dockerfile)) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to build base image: %w", err) + } + + fmt.Println("โœ… Built base image: hako-userland") + return nil +} + +// BuildLanguageImage builds a language-specific image. +func (c *Client) BuildLanguageImage(language string, cfg *config.Config) error { + // First ensure base image exists + if !c.imageExists("hako-userland") { + if err := c.BuildBaseImage(cfg); err != nil { + return err + } + } + + dockerfile, err := cfg.LanguageDockerfile(language) + if err != nil { + return fmt.Errorf("failed to get language dockerfile: %w", err) + } + + imageName := fmt.Sprintf("hako-userland-%s", language) + + // Build the language image + cmd := loggedCommand("docker", "build", + "-f", dockerfile, + "-t", imageName, + filepath.Dir(dockerfile)) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to build %s image: %w", language, err) + } + + fmt.Printf("โœ… Built language image: %s\n", imageName) + return nil +} + +// GenerateContainerName creates a container name from language and path. +func (c *Client) GenerateContainerName(language, path string) (string, error) { + // Sanitize the path for container naming + sanitized := sanitizeContainerName(path) + name := fmt.Sprintf("hako-%s-%s", language, sanitized) + + // Docker container names have a 63 character limit + if len(name) > 63 { + return "", fmt.Errorf("container name too long: %s (max 63 chars)", name) + } + + return name, nil +} + +// StartShell creates/starts a container and drops into a shell. +func (c *Client) StartShell(containerName, language string, syncer interface{ ToContainer(string) error }) error { + imageName := fmt.Sprintf("hako-userland-%s", language) + + // Check if container exists + if c.containerExists(containerName) { + fmt.Printf("Starting existing container: %s\n", containerName) + + // Start the container if it's stopped + cmd := loggedCommand("docker", "start", containerName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start container: %w", err) + } + } else { + fmt.Printf("Creating new container: %s\n", containerName) + + // Get current user info for running container as host user + uid := os.Getenv("UID") + if uid == "" { + cmd := loggedCommand("id", "-u") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get user ID: %w", err) + } + uid = strings.TrimSpace(string(output)) + } + + gid := os.Getenv("GID") + if gid == "" { + cmd := loggedCommand("id", "-g") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get group ID: %w", err) + } + gid = strings.TrimSpace(string(output)) + } + + // Get home directory for mounting Claude auth + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + args := []string{"run", "-it", "-d", + "--name", containerName, + "--user", fmt.Sprintf("%s:%s", uid, gid), + "-w", "/workspace"} + + // Mount Claude config file if it exists + claudeConfigPath := filepath.Join(homeDir, ".claude.json") + if _, err := os.Stat(claudeConfigPath); err == nil { + args = append(args, "-v", fmt.Sprintf("%s:/workspace/.claude.json:ro", claudeConfigPath)) + } + + // Mount Claude directory if it exists + claudeDirPath := filepath.Join(homeDir, ".claude") + if _, err := os.Stat(claudeDirPath); err == nil { + args = append(args, "-v", fmt.Sprintf("%s:/workspace/.claude", claudeDirPath)) + } + + args = append(args, imageName, "/bin/bash") + + // Create and start the container + cmd := loggedCommand("docker", args...) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create container: %w", err) + } + } + + // Copy workspace files to container + if err := syncer.ToContainer(containerName); err != nil { + return err + } + + // Execute interactive shell + fmt.Printf("Dropping into shell in container: %s\n", containerName) + cmd := loggedCommand("docker", "exec", "-it", containerName, "/bin/bash") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// GetCurrentContainer finds the container for the current directory. +func (c *Client) GetCurrentContainer(path string) (string, error) { + // This would need to search through containers to find one matching the path + // For now, we'll implement a simple version + sanitized := sanitizeContainerName(path) + + // Try to find a container that matches this path pattern + cmd := loggedCommand("docker", "ps", "-a", "--format", "{{.Names}}") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to list containers: %w", err) + } + + names := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, name := range names { + if strings.Contains(name, sanitized) { + return name, nil + } + } + + return "", fmt.Errorf("no container found for current directory") +} + +// ListContainers returns all running hako containers. +func (c *Client) ListContainers() ([]string, error) { + cmd := loggedCommand("docker", "ps", "--filter", "name=hako-", "--format", "{{.Names}}") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + names := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(names) == 1 && names[0] == "" { + return []string{}, nil + } + + return names, nil +} + +// StopContainer stops and removes a container. +func (c *Client) StopContainer(containerName string) error { + // Stop the container + cmd := loggedCommand("docker", "stop", containerName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + + // Remove the container + cmd = loggedCommand("docker", "rm", containerName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to remove container: %w", err) + } + + fmt.Printf("โœ… Stopped and removed container: %s\n", containerName) + return nil +} + +// imageExists checks if a Docker image exists locally. +func (c *Client) imageExists(imageName string) bool { + cmd := loggedCommand("docker", "images", "-q", imageName) + output, err := cmd.Output() + return err == nil && len(strings.TrimSpace(string(output))) > 0 +} + +// containerExists checks if a container exists. +func (c *Client) containerExists(containerName string) bool { + cmd := loggedCommand("docker", "ps", "-a", "-q", "-f", fmt.Sprintf("name=%s", containerName)) + output, err := cmd.Output() + return err == nil && len(strings.TrimSpace(string(output))) > 0 +} + +// sanitizeContainerName converts a path to a safe container name component. +func sanitizeContainerName(path string) string { + // Get just the directory name, not the full path + base := filepath.Base(path) + + // Replace unsafe characters with hyphens + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '-' + }, base) + + // Remove leading/trailing hyphens and collapse multiple hyphens + result = strings.Trim(result, "-") + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + + return strings.ToLower(result) +} diff --git a/pkg/git/repo.go b/pkg/git/repo.go new file mode 100644 index 0000000..698b96f --- /dev/null +++ b/pkg/git/repo.go @@ -0,0 +1,178 @@ +package git + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// loggedCommand creates a command and logs it to stderr +func loggedCommand(name string, args ...string) *exec.Cmd { + log.Printf("exec: %s %s", name, strings.Join(args, " ")) + return exec.Command(name, args...) +} + +// loggedCommandWithDir creates a command with a working directory and logs it +func loggedCommandWithDir(dir, name string, args ...string) *exec.Cmd { + log.Printf("exec (in %s): %s %s", dir, name, strings.Join(args, " ")) + cmd := exec.Command(name, args...) + cmd.Dir = dir + return cmd +} + +// Repo represents a git repository. +type Repo struct { + workDir string + rootDir string +} + +// NewRepo creates a new git repository instance from the current directory. +func NewRepo() (*Repo, error) { + workDir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get working directory: %w", err) + } + + // Check if we're inside a git repository + cmd := loggedCommandWithDir(workDir, "git", "rev-parse", "--is-inside-work-tree") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("not inside a git repository") + } + + if strings.TrimSpace(string(output)) != "true" { + return nil, fmt.Errorf("not inside a git repository") + } + + // Get the repository root directory + cmd = loggedCommandWithDir(workDir, "git", "rev-parse", "--show-toplevel") + output, err = cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get git root directory: %w", err) + } + + rootDir := strings.TrimSpace(string(output)) + + return &Repo{ + workDir: workDir, + rootDir: rootDir, + }, nil +} + +// Path returns the current working directory. +func (r *Repo) Path() string { + return r.workDir +} + +// Root returns the git repository root directory. +func (r *Repo) Root() string { + return r.rootDir +} + +// RelativePath returns the current directory relative to the git root. +func (r *Repo) RelativePath() (string, error) { + cmd := loggedCommandWithDir(r.workDir, "git", "rev-parse", "--show-prefix") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get relative path: %w", err) + } + + return strings.TrimSpace(string(output)), nil +} + +// ListFiles returns all tracked files in the repository. +func (r *Repo) ListFiles() ([]string, error) { + cmd := loggedCommandWithDir(r.rootDir, "git", "ls-files") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list git files: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 1 && lines[0] == "" { + return []string{}, nil + } + + return lines, nil +} + +// ListUntrackedFiles returns all untracked files that are not ignored. +func (r *Repo) ListUntrackedFiles() ([]string, error) { + cmd := loggedCommandWithDir(r.rootDir, "git", "ls-files", "--others", "--exclude-standard") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list untracked files: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 1 && lines[0] == "" { + return []string{}, nil + } + + return lines, nil +} + +// ListAllFiles returns both tracked and untracked files. +func (r *Repo) ListAllFiles() ([]string, error) { + trackedFiles, err := r.ListFiles() + if err != nil { + return nil, err + } + + untrackedFiles, err := r.ListUntrackedFiles() + if err != nil { + return nil, err + } + + // Combine both lists + allFiles := make([]string, 0, len(trackedFiles)+len(untrackedFiles)) + allFiles = append(allFiles, trackedFiles...) + allFiles = append(allFiles, untrackedFiles...) + + return allFiles, nil +} + +// IsIgnored checks if a file path is ignored by git. +func (r *Repo) IsIgnored(path string) (bool, error) { + cmd := loggedCommandWithDir(r.rootDir, "git", "check-ignore", path) + err := cmd.Run() + + if err == nil { + return true, nil // File is ignored + } + + if exitError, ok := err.(*exec.ExitError); ok { + if exitError.ExitCode() == 1 { + return false, nil // File is not ignored + } + } + + return false, fmt.Errorf("failed to check if file is ignored: %w", err) +} + +// ProjectName returns a sanitized project name based on the git root directory. +func (r *Repo) ProjectName() string { + return sanitizeName(filepath.Base(r.rootDir)) +} + +// sanitizeName converts a path component to a safe container name part. +func sanitizeName(name string) string { + // Replace unsafe characters with hyphens + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '-' + }, name) + + // Remove leading/trailing hyphens and collapse multiple hyphens + result = strings.Trim(result, "-") + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + + return strings.ToLower(result) +} diff --git a/pkg/hako.go b/pkg/hako.go new file mode 100644 index 0000000..8c7ed66 --- /dev/null +++ b/pkg/hako.go @@ -0,0 +1,89 @@ +// Package hako provides containerized development environments for Claude Code. +package hako + +import ( + "l4.pm/hako/pkg/config" + "l4.pm/hako/pkg/docker" + "l4.pm/hako/pkg/git" + "l4.pm/hako/pkg/sync" +) + +// Manager manages hako containers and operations. +type Manager struct { + docker *docker.Client + git *git.Repo + sync *sync.Syncer + config *config.Config +} + +// New creates a new hako manager. +func New() (*Manager, error) { + dockerClient, err := docker.NewClient() + if err != nil { + return nil, err + } + + gitRepo, err := git.NewRepo() + if err != nil { + return nil, err + } + + cfg, err := config.Load() + if err != nil { + return nil, err + } + + syncer := sync.New(gitRepo) + + return &Manager{ + docker: dockerClient, + git: gitRepo, + sync: syncer, + config: cfg, + }, nil +} + +// Init builds the base image and optionally a language variant. +func (m *Manager) Init(language string) error { + if language == "" { + return m.docker.BuildBaseImage(m.config) + } + return m.docker.BuildLanguageImage(language, m.config) +} + +// Up creates and starts a container, dropping into a shell. +func (m *Manager) Up(language string) error { + containerName, err := m.docker.GenerateContainerName(language, m.git.Path()) + if err != nil { + return err + } + + return m.docker.StartShell(containerName, language, m.sync) +} + +// Sync copies files from container back to host. +func (m *Manager) Sync() error { + containerName, err := m.docker.GetCurrentContainer(m.git.Path()) + if err != nil { + return err + } + + return m.sync.FromContainer(containerName) +} + +// List returns all running hako containers. +func (m *Manager) List() ([]string, error) { + return m.docker.ListContainers() +} + +// Down stops and removes a container. +func (m *Manager) Down(containerName string) error { + if containerName == "" { + var err error + containerName, err = m.docker.GetCurrentContainer(m.git.Path()) + if err != nil { + return err + } + } + return m.docker.StopContainer(containerName) +} \ No newline at end of file diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go new file mode 100644 index 0000000..4d87cdd --- /dev/null +++ b/pkg/sync/sync.go @@ -0,0 +1,154 @@ +package sync + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "l4.pm/hako/pkg/git" +) + +// loggedCommand creates a command and logs it to stderr +func loggedCommand(name string, args ...string) *exec.Cmd { + log.Printf("exec: %s %s", name, strings.Join(args, " ")) + return exec.Command(name, args...) +} + +// Syncer handles file synchronization between host and containers. +type Syncer struct { + repo *git.Repo +} + +// New creates a new syncer. +func New(repo *git.Repo) *Syncer { + return &Syncer{ + repo: repo, + } +} + +// ToContainer copies files from host to container. +func (s *Syncer) ToContainer(containerName string) error { + workspaceFiles, err := s.getWorkspaceFiles() + if err != nil { + return fmt.Errorf("failed to get workspace files: %w", err) + } + + if len(workspaceFiles) == 0 { + fmt.Println("No files to copy to container") + return nil + } + + // Create workspace directory in container + cmd := loggedCommand("docker", "exec", containerName, "mkdir", "-p", "/workspace") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create workspace directory in container: %w", err) + } + + // Copy files to container + for _, file := range workspaceFiles { + srcPath := filepath.Join(s.repo.Root(), file) + dstPath := fmt.Sprintf("%s:/workspace/%s", containerName, file) + + // Ensure directory exists in container + dir := filepath.Dir(file) + if dir != "." { + cmd := loggedCommand("docker", "exec", containerName, "mkdir", "-p", fmt.Sprintf("/workspace/%s", dir)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create directory %s in container: %w", dir, err) + } + } + + cmd := loggedCommand("docker", "cp", srcPath, dstPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to copy %s to container: %w", file, err) + } + } + + fmt.Printf("โœ… Copied %d files to container\n", len(workspaceFiles)) + return nil +} + +// FromContainer copies files from container back to host. +func (s *Syncer) FromContainer(containerName string) error { + // Get list of files in container workspace + cmd := loggedCommand("docker", "exec", containerName, "find", "/workspace", "-type", "f", "-not", "-path", "/workspace/.git/*") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to list files in container: %w", err) + } + + containerFiles := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(containerFiles) == 1 && containerFiles[0] == "" { + fmt.Println("No files found in container workspace") + return nil + } + + copied := 0 + for _, containerFile := range containerFiles { + // Remove /workspace prefix to get relative path + relPath := strings.TrimPrefix(containerFile, "/workspace/") + if relPath == containerFile { + continue // Skip files not in workspace + } + + // Check if file should be ignored + ignored, err := s.repo.IsIgnored(relPath) + if err != nil { + fmt.Printf("Warning: failed to check if %s is ignored: %v\n", relPath, err) + } + if ignored { + continue + } + + // Copy file from container to host + srcPath := fmt.Sprintf("%s:%s", containerName, containerFile) + dstPath := filepath.Join(s.repo.Root(), relPath) + + // Ensure directory exists on host + dir := filepath.Dir(dstPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + cmd := loggedCommand("docker", "cp", srcPath, dstPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to copy %s from container: %w", relPath, err) + } + + copied++ + } + + fmt.Printf("โœ… Synced %d files from container\n", copied) + return nil +} + +// getWorkspaceFiles returns all files that should be copied to the workspace. +func (s *Syncer) getWorkspaceFiles() ([]string, error) { + // Get all files (tracked + untracked) from git + files, err := s.repo.ListAllFiles() + if err != nil { + return nil, err + } + + // Filter out files that shouldn't be copied + var workspaceFiles []string + for _, file := range files { + // Skip .git directory + if strings.HasPrefix(file, ".git/") { + continue + } + + // Check if file exists (might have been deleted) + fullPath := filepath.Join(s.repo.Root(), file) + if _, err := os.Stat(fullPath); err != nil { + continue + } + + workspaceFiles = append(workspaceFiles, file) + } + + return workspaceFiles, nil +} diff --git a/utils.go b/utils.go deleted file mode 100644 index a972c41..0000000 --- a/utils.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "path/filepath" - "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 runCommandSilent(name string, args ...string) error { - cmd := exec.Command(name, args...) - fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) - cmd.Stdout = nil - cmd.Stderr = nil - 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 -} - -// GitIgnore represents a .gitignore parser -type GitIgnore struct { - patterns []string -} - -// NewGitIgnore creates a new GitIgnore parser by reading .gitignore file -func NewGitIgnore() (*GitIgnore, error) { - gi := &GitIgnore{} - - file, err := os.Open(".gitignore") - if err != nil { - if os.IsNotExist(err) { - return gi, nil // No .gitignore file is fine - } - return nil, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" && !strings.HasPrefix(line, "#") { - gi.patterns = append(gi.patterns, line) - } - } - - return gi, scanner.Err() -} - -// IsIgnored checks if a file path should be ignored according to .gitignore patterns -func (gi *GitIgnore) IsIgnored(path string) bool { - for _, pattern := range gi.patterns { - if gi.matchPattern(pattern, path) { - return true - } - } - return false -} - -// matchPattern implements basic gitignore pattern matching -func (gi *GitIgnore) matchPattern(pattern, path string) bool { - // Handle negation patterns (starting with !) - if strings.HasPrefix(pattern, "!") { - return false // Negation patterns would require more complex logic - } - - // Convert gitignore pattern to filepath.Match compatible pattern - // Handle directory patterns (ending with /) - if strings.HasSuffix(pattern, "/") { - pattern = strings.TrimSuffix(pattern, "/") - // Check if any directory component matches - parts := strings.Split(path, string(filepath.Separator)) - for _, part := range parts { - if matched, _ := filepath.Match(pattern, part); matched { - return true - } - } - return false - } - - // Handle patterns with path separators - if strings.Contains(pattern, "/") { - // Exact path match - if matched, _ := filepath.Match(pattern, path); matched { - return true - } - // Check if pattern matches any suffix of the path - pathParts := strings.Split(path, string(filepath.Separator)) - patternParts := strings.Split(pattern, "/") - - if len(patternParts) <= len(pathParts) { - for i := 0; i <= len(pathParts)-len(patternParts); i++ { - match := true - for j, patternPart := range patternParts { - if matched, _ := filepath.Match(patternPart, pathParts[i+j]); !matched { - match = false - break - } - } - if match { - return true - } - } - } - return false - } - - // Simple filename pattern - check against basename and any path component - basename := filepath.Base(path) - if matched, _ := filepath.Match(pattern, basename); matched { - return true - } - - // Check if pattern matches any directory component - parts := strings.Split(path, string(filepath.Separator)) - for _, part := range parts { - if matched, _ := filepath.Match(pattern, part); matched { - return true - } - } - - return false -}