diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e2ee183..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(mkdir:*)", - "Bash(find:*)", - "Bash(rg:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 49420c5..f140614 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ Thumbs.db # Logs *.log .claude/ +./hako diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52d4600 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# 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 deleted file mode 100644 index 3be558e..0000000 --- a/TODO.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index 20e726b..0000000 --- a/cli/down.go +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index dc39c01..0000000 --- a/cli/init.go +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 0d69fc1..0000000 --- a/cli/ps.go +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 7ec8359..0000000 --- a/cli/root.go +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 125b58f..0000000 --- a/cli/sync.go +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 3a75ccd..0000000 --- a/cli/up.go +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 29f2b21..0000000 --- a/cmd/hako/main.go +++ /dev/null @@ -1,7 +0,0 @@ -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 new file mode 100644 index 0000000..6bdb5ba --- /dev/null +++ b/commands.go @@ -0,0 +1,295 @@ +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 new file mode 100644 index 0000000..f671ea6 --- /dev/null +++ b/docker.go @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000..537267d --- /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 +} diff --git a/go.mod b/go.mod index 0e92979..40774a1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module l4.pm/hako +module hako go 1.21 diff --git a/idea.md b/idea.md index 75add08..46f96c4 100644 --- a/idea.md +++ b/idea.md @@ -10,6 +10,8 @@ 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 new file mode 100644 index 0000000..86f4f83 --- /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) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index dc65d5b..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 00d7ef1..0000000 --- a/pkg/docker/client.go +++ /dev/null @@ -1,288 +0,0 @@ -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 deleted file mode 100644 index 698b96f..0000000 --- a/pkg/git/repo.go +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 8c7ed66..0000000 --- a/pkg/hako.go +++ /dev/null @@ -1,89 +0,0 @@ -// 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 deleted file mode 100644 index 4d87cdd..0000000 --- a/pkg/sync/sync.go +++ /dev/null @@ -1,154 +0,0 @@ -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 new file mode 100644 index 0000000..a972c41 --- /dev/null +++ b/utils.go @@ -0,0 +1,140 @@ +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 +}