From a62cf65376df03a18b97ba4ae6af703fcb9a8b0d Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 19:16:15 -0300 Subject: [PATCH 01/10] restart codebase --- TODO.md | 65 ---------- cli/down.go | 35 ------ cli/init.go | 34 ----- cli/ps.go | 37 ------ cli/root.go | 33 ----- cli/sync.go | 28 ----- cli/up.go | 34 ----- cmd/hako/main.go | 7 -- go.mod | 10 -- go.sum | 10 -- idea.md | 2 + pkg/config/config.go | 170 ------------------------- pkg/docker/client.go | 288 ------------------------------------------- pkg/git/repo.go | 178 -------------------------- pkg/hako.go | 89 ------------- pkg/sync/sync.go | 154 ----------------------- 16 files changed, 2 insertions(+), 1172 deletions(-) delete mode 100644 TODO.md delete mode 100644 cli/down.go delete mode 100644 cli/init.go delete mode 100644 cli/ps.go delete mode 100644 cli/root.go delete mode 100644 cli/sync.go delete mode 100644 cli/up.go delete mode 100644 cmd/hako/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 pkg/config/config.go delete mode 100644 pkg/docker/client.go delete mode 100644 pkg/git/repo.go delete mode 100644 pkg/hako.go delete mode 100644 pkg/sync/sync.go 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/go.mod b/go.mod deleted file mode 100644 index 0e92979..0000000 --- a/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module l4.pm/hako - -go 1.21 - -require github.com/spf13/cobra v1.8.0 - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index d0e8c2c..0000000 --- a/go.sum +++ /dev/null @@ -1,10 +0,0 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/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/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 -} From ed3c0eae14c861d6d45d286ada081ba8e3df85b4 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 21:16:53 -0300 Subject: [PATCH 02/10] initial commit (second try) --- commands.go | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++ docker.go | 208 +++++++++++++++++++++++++++++++++++++++++++++++ git.go | 68 ++++++++++++++++ go.mod | 10 +++ go.sum | 10 +++ main.go | 99 +++++++++++++++++++++++ utils.go | 35 ++++++++ 7 files changed, 656 insertions(+) create mode 100644 commands.go create mode 100644 docker.go create mode 100644 git.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 utils.go diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..d061eac --- /dev/null +++ b/commands.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func initCommand(lang string) error { + if lang == "" { + fmt.Println("Building base hako image...") + return buildBaseImage() + } + + if !imageExists("hako-userland") { + fmt.Println("Base image not found, building it first...") + if err := buildBaseImage(); err != nil { + return err + } + } + + fmt.Printf("Building %s language image...\n", lang) + return buildLanguageImage(lang) +} + +func upCommand(lang string) error { + containerName, err := getProjectContainerName(lang) + if err != nil { + return err + } + + imageName := fmt.Sprintf("hako-userland-%s", lang) + if !imageExists(imageName) { + return fmt.Errorf("image %s not found, run 'hako init %s' first", imageName, lang) + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + + if containerExists(containerName) { + if !containerIsRunning(containerName) { + fmt.Printf("Starting existing container %s...\n", containerName) + if err := startContainer(containerName); err != nil { + return err + } + } else { + fmt.Printf("Container %s is already running\n", containerName) + } + } else { + fmt.Printf("Creating new container %s...\n", containerName) + if err := createContainer(containerName, imageName, cwd); err != nil { + return err + } + if err := startContainer(containerName); err != nil { + return err + } + fmt.Printf("Copying workspace to container...\n") + if err := copyWorkspaceToContainer(containerName); err != nil { + return err + } + } + + fmt.Printf("Entering container %s...\n", containerName) + return execContainer(containerName) +} + +func downCommand(container string) error { + var containerName string + + if container != "" { + containerName = container + } else { + // Find any hako container for current project + output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + if err != nil { + return err + } + containers := strings.Split(strings.TrimSpace(output), "\n") + if len(containers) == 0 || containers[0] == "" { + return fmt.Errorf("no hako containers found for current project") + } + containerName = containers[0] // Use first found container + } + + if !containerExists(containerName) { + return fmt.Errorf("container %s does not exist", containerName) + } + + fmt.Printf("Stopping container %s...\n", containerName) + if containerIsRunning(containerName) { + if err := stopContainer(containerName); err != nil { + return err + } + } + + fmt.Printf("Removing container %s...\n", containerName) + return removeContainer(containerName) +} + +func psCommand() error { + fmt.Println("Hako containers:") + return listHakoContainers() +} + +func syncCommand() error { + output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + if err != nil { + return err + } + containers := strings.Split(strings.TrimSpace(output), "\n") + if len(containers) == 0 || containers[0] == "" { + return fmt.Errorf("no hako containers found for current project") + } + containerName := containers[0] // Use first found container + + if !containerExists(containerName) { + return fmt.Errorf("container %s does not exist", containerName) + } + + fmt.Printf("Syncing files from container %s...\n", containerName) + + tempDir, err := os.MkdirTemp("", "hako-sync-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + if err := copyFromContainer(containerName, "/workspace/.", tempDir); err != nil { + return err + } + + return syncFiles(tempDir, ".") +} + +func syncFiles(srcDir, destDir string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + if relPath == ".git" || relPath == ".gitignore" { + return filepath.SkipDir + } + + if strings.HasPrefix(filepath.Base(relPath), ".git") { + return nil + } + + destPath := filepath.Join(destDir, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + srcFile, err := os.Open(path) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + return err + }) +} + +func copyWorkspaceToContainer(containerName string) error { + // Get all files that should be copied (tracked + untracked, excluding ignored) + trackedFiles, err := runCommandOutput("git", "ls-files") + if err != nil { + return err + } + + untrackedFiles, err := runCommandOutput("git", "ls-files", "--others", "--exclude-standard") + if err != nil { + return err + } + + allFiles := []string{} + if trackedFiles != "" { + allFiles = append(allFiles, strings.Split(trackedFiles, "\n")...) + } + if untrackedFiles != "" { + allFiles = append(allFiles, strings.Split(untrackedFiles, "\n")...) + } + + if len(allFiles) == 0 { + return fmt.Errorf("no files to copy") + } + + // Copy files one by one to preserve directory structure + for _, file := range allFiles { + if file == "" { + continue + } + + // Create directory structure in container if needed + dir := filepath.Dir(file) + if dir != "." { + if err := runCommand("docker", "exec", containerName, "mkdir", "-p", "/workspace/"+dir); err != nil { + return err + } + } + + // Copy the file + if err := copyToContainer(containerName, file, "/workspace/"+file); err != nil { + return fmt.Errorf("failed to copy %s: %v", file, err) + } + } + + return nil +} \ No newline at end of file diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..46268f4 --- /dev/null +++ b/docker.go @@ -0,0 +1,208 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const hakoVersion = "1" + +func getConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + configDir := filepath.Join(homeDir, ".config", "hako") + return configDir, nil +} + +func ensureConfigDir() error { + configDir, err := getConfigDir() + if err != nil { + return err + } + return os.MkdirAll(configDir, 0755) +} + +func getBaseDockerfile() string { + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM debian:bookworm + +RUN apt-get update && apt-get install -y \ + curl \ + git \ + build-essential \ + ca-certificates \ + fish \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js LTS +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs + +# Install Claude Code +RUN npm install -g @anthropic-ai/claude-code + +# configure some things +RUN mkdir /workspace +RUN git config --global --add safe.directory /workspace + +WORKDIR /workspace +CMD ["/usr/bin/fish"]`, hakoVersion) +} + +func getLanguageDockerfile(lang string) string { + switch lang { + case "go": + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM hako-userland + +RUN curl -fsSL https://go.dev/dl/go1.21.5.linux-amd64.tar.gz | tar -C /usr/local -xzf - +ENV PATH="/usr/local/go/bin:${PATH}" + +WORKDIR /workspace +CMD ["/bin/bash"]`, hakoVersion) + case "py", "python": + return fmt.Sprintf(`# HAKO-VERSION=%s +FROM hako-userland + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -s /usr/bin/python3 /usr/bin/python + +WORKDIR /workspace +CMD ["/bin/bash"]`, hakoVersion) + default: + return "" + } +} + +func buildBaseImage() error { + if err := ensureConfigDir(); err != nil { + return err + } + + configDir, _ := getConfigDir() + dockerfilePath := filepath.Join(configDir, "Dockerfile.base") + + dockerfile := getBaseDockerfile() + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + return err + } + + fmt.Printf("Building base image hako-userland...\n") + return runCommand("docker", "build", "-f", dockerfilePath, "-t", "hako-userland", ".") +} + +func buildLanguageImage(lang string) error { + dockerfile := getLanguageDockerfile(lang) + if dockerfile == "" { + return fmt.Errorf("unsupported language: %s", lang) + } + + configDir, err := getConfigDir() + if err != nil { + return err + } + + dockerfilePath := filepath.Join(configDir, fmt.Sprintf("Dockerfile.%s", lang)) + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + return err + } + + imageName := fmt.Sprintf("hako-userland-%s", lang) + fmt.Printf("Building %s image %s...\n", lang, imageName) + return runCommand("docker", "build", "-f", dockerfilePath, "-t", imageName, ".") +} + +func imageExists(imageName string) bool { + err := runCommand("docker", "image", "inspect", imageName) + return err == nil +} + +func containerExists(containerName string) bool { + err := runCommand("docker", "container", "inspect", containerName) + return err == nil +} + +func containerIsRunning(containerName string) bool { + output, err := runCommandOutput("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + return err == nil && strings.TrimSpace(output) == "true" +} + +func createContainer(containerName, imageName, workspaceDir string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + + args := []string{"create", "--name", containerName} + + // Mount workspace + args = append(args, "-v", workspaceDir+":/workspace") + + + args = append(args, "-it", imageName) + + if err := runCommand("docker", args...); err != nil { + return err + } + + // Copy Claude config directory if it exists + claudeDir := filepath.Join(homeDir, ".claude") + if _, err := os.Stat(claudeDir); err == nil { + if err := copyToContainer(containerName, claudeDir, "/root/.claude"); err != nil { + return fmt.Errorf("failed to copy Claude config directory: %w", err) + } + } + + // Copy Claude config file if it exists + claudeFile := filepath.Join(homeDir, ".claude.json") + if _, err := os.Stat(claudeFile); err == nil { + if err := copyToContainer(containerName, claudeFile, "/root/.claude.json"); err != nil { + return fmt.Errorf("failed to copy Claude config file: %w", err) + } + } + + return nil +} + +func startContainer(containerName string) error { + return runCommand("docker", "start", containerName) +} + +func stopContainer(containerName string) error { + return runCommand("docker", "stop", containerName) +} + +func removeContainer(containerName string) error { + return runCommand("docker", "rm", containerName) +} + +func execContainer(containerName string) error { + cmd := exec.Command("docker", "exec", "-it", containerName, "/usr/bin/fish") + fmt.Fprintf(os.Stderr, "+ %s %s\n", "docker", strings.Join([]string{"exec", "-it", containerName, "/usr/bin/fish"}, " ")) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func copyToContainer(containerName, srcPath, destPath string) error { + return runCommand("docker", "cp", srcPath, containerName+":"+destPath) +} + +func copyFromContainer(containerName, srcPath, destPath string) error { + return runCommand("docker", "cp", containerName+":"+srcPath, destPath) +} + +func listHakoContainers() error { + return runCommand("docker", "ps", "-a", "--filter", "name=hako-", "--format", "table {{.Names}}\\t{{.Status}}\\t{{.Image}}") +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..b98a706 --- /dev/null +++ b/git.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +func isGitRepository() bool { + _, err := os.Stat(".git") + return err == nil +} + +func getGitRootPath() (string, error) { + output, err := runCommandOutput("git", "rev-parse", "--show-toplevel") + if err != nil { + return "", errors.New("not in a git repository") + } + return strings.TrimSpace(output), nil +} + +func getProjectContainerName(lang string) (string, error) { + if !isGitRepository() { + return "", errors.New("must be run inside a git repository") + } + + gitRoot, err := getGitRootPath() + if err != nil { + return "", err + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + relPath, err := filepath.Rel(gitRoot, cwd) + if err != nil { + return "", err + } + + projectName := filepath.Base(gitRoot) + + var pathComponents []string + if relPath != "." { + pathComponents = strings.Split(relPath, string(os.PathSeparator)) + } + + containerName := "hako-" + lang + "-" + sanitizeName(projectName) + for _, component := range pathComponents { + containerName += "-" + sanitizeName(component) + } + + if len(containerName) > 60 { + return "", errors.New("container name too long, try running from a shallower directory") + } + + return containerName, nil +} + +func sanitizeName(name string) string { + result := strings.ToLower(name) + result = strings.ReplaceAll(result, " ", "_") + result = strings.ReplaceAll(result, ".", "_") + result = strings.ReplaceAll(result, "-", "_") + return result +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..40774a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module hako + +go 1.21 + +require github.com/spf13/cobra v1.8.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0e8c2c --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..05ecf82 --- /dev/null +++ b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hako", + Short: "Docker container management for Claude Code", + Long: "A tool that manages Docker containers providing sandboxed environments for Claude Code", +} + +var initCmd = &cobra.Command{ + Use: "init [language]", + Short: "Initialize Docker images", + Long: "Build base Docker image or language-specific variants", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var lang string + if len(args) > 0 { + lang = args[0] + } + if err := initCommand(lang); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var upCmd = &cobra.Command{ + Use: "up ", + Short: "Start and enter a container", + Long: "Create or start a container for the current project and drop into shell", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := upCommand(args[0]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var downCmd = &cobra.Command{ + Use: "down [container]", + Short: "Stop a container", + Long: "Stop and remove a container", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var container string + if len(args) > 0 { + container = args[0] + } + if err := downCommand(container); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var psCmd = &cobra.Command{ + Use: "ps", + Short: "List running containers", + Long: "Show all running hako containers", + Run: func(cmd *cobra.Command, args []string) { + if err := psCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync files from container", + Long: "Copy files from container workspace to working directory", + Run: func(cmd *cobra.Command, args []string) { + if err := syncCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(upCmd) + rootCmd.AddCommand(downCmd) + rootCmd.AddCommand(psCmd) + rootCmd.AddCommand(syncCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} \ No newline at end of file diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..ca62b74 --- /dev/null +++ b/utils.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runCommandOutput(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +func runCommandWithInput(name string, input string, args ...string) error { + cmd := exec.Command(name, args...) + fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) + cmd.Stdin = strings.NewReader(input) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} \ No newline at end of file From bd5abff9368d598709de0a5389607167c39fff9b Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 21:19:20 -0300 Subject: [PATCH 03/10] add CLAUDE.md --- CLAUDE.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f8bc13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# 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) for upgrade management + +## 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 \ No newline at end of file From a14bf027cd0e79c285afa97d359e8bbec051f69f Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 21:20:02 -0300 Subject: [PATCH 04/10] remove .claude/settings.local.json --- .claude/settings.local.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.local.json 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 From 33f45eae028166e5f1483e302851098f7b326660 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 21:23:44 -0300 Subject: [PATCH 05/10] use trixie-20250610 --- docker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker.go b/docker.go index 46268f4..4aef59a 100644 --- a/docker.go +++ b/docker.go @@ -29,7 +29,7 @@ func ensureConfigDir() error { func getBaseDockerfile() string { return fmt.Sprintf(`# HAKO-VERSION=%s -FROM debian:bookworm +FROM debian:trixie-20250610 RUN apt-get update && apt-get install -y \ curl \ From 30a510cb17972d5219e3bf83e85cecd22d73acc6 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 21:39:03 -0300 Subject: [PATCH 06/10] add HAKO-VERSION checking --- CLAUDE.md | 5 +++-- commands.go | 43 +++++++++++++++++++++++++++++++++---------- docker.go | 38 +++++++++++++++++++++++++++++--------- git.go | 4 ++-- main.go | 2 +- utils.go | 9 --------- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f8bc13..52d4600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,8 @@ Hako is a Docker container management tool designed specifically for Claude Code - 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) for upgrade management +- 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 @@ -44,4 +45,4 @@ go build -o hako - 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 \ No newline at end of file +- Mounts Claude config directory/file if present for seamless integration diff --git a/commands.go b/commands.go index d061eac..41d17f1 100644 --- a/commands.go +++ b/commands.go @@ -19,6 +19,9 @@ func initCommand(lang string) error { if err := buildBaseImage(); err != nil { return err } + } else { + // Check base image version + checkVersionMismatch("hako-userland") } fmt.Printf("Building %s language image...\n", lang) @@ -36,6 +39,9 @@ func upCommand(lang string) error { 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 @@ -103,7 +109,24 @@ func downCommand(container string) error { func psCommand() error { fmt.Println("Hako containers:") - return listHakoContainers() + 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 { @@ -122,7 +145,7 @@ func syncCommand() error { } fmt.Printf("Syncing files from container %s...\n", containerName) - + tempDir, err := os.MkdirTemp("", "hako-sync-*") if err != nil { return err @@ -184,12 +207,12 @@ func copyWorkspaceToContainer(containerName string) error { 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")...) @@ -197,17 +220,17 @@ func copyWorkspaceToContainer(containerName string) error { 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 != "." { @@ -215,12 +238,12 @@ func copyWorkspaceToContainer(containerName string) error { return err } } - + // Copy the file if err := copyToContainer(containerName, file, "/workspace/"+file); err != nil { return fmt.Errorf("failed to copy %s: %v", file, err) } } - + return nil -} \ No newline at end of file +} diff --git a/docker.go b/docker.go index 4aef59a..6faea18 100644 --- a/docker.go +++ b/docker.go @@ -28,8 +28,8 @@ func ensureConfigDir() error { } func getBaseDockerfile() string { - return fmt.Sprintf(`# HAKO-VERSION=%s -FROM debian:trixie-20250610 + return fmt.Sprintf(`FROM debian:trixie-20250610 +LABEL HAKO-VERSION=%s RUN apt-get update && apt-get install -y \ curl \ @@ -57,17 +57,17 @@ CMD ["/usr/bin/fish"]`, hakoVersion) func getLanguageDockerfile(lang string) string { switch lang { case "go": - return fmt.Sprintf(`# HAKO-VERSION=%s -FROM hako-userland + 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 ["/bin/bash"]`, hakoVersion) +CMD ["/usr/bin/fish"]`, hakoVersion) case "py", "python": - return fmt.Sprintf(`# HAKO-VERSION=%s -FROM hako-userland + return fmt.Sprintf(`FROM hako-userland +LABEL HAKO-VERSION=%s RUN apt-get update && apt-get install -y \ python3 \ @@ -78,7 +78,7 @@ RUN apt-get update && apt-get install -y \ RUN ln -s /usr/bin/python3 /usr/bin/python WORKDIR /workspace -CMD ["/bin/bash"]`, hakoVersion) +CMD ["/usr/bin/fish"]`, hakoVersion) default: return "" } @@ -148,7 +148,6 @@ func createContainer(containerName, imageName, workspaceDir string) error { // Mount workspace args = append(args, "-v", workspaceDir+":/workspace") - args = append(args, "-it", imageName) if err := runCommand("docker", args...); err != nil { @@ -206,3 +205,24 @@ func copyFromContainer(containerName, srcPath, destPath string) error { 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 index b98a706..537267d 100644 --- a/git.go +++ b/git.go @@ -41,7 +41,7 @@ func getProjectContainerName(lang string) (string, error) { } projectName := filepath.Base(gitRoot) - + var pathComponents []string if relPath != "." { pathComponents = strings.Split(relPath, string(os.PathSeparator)) @@ -65,4 +65,4 @@ func sanitizeName(name string) string { result = strings.ReplaceAll(result, ".", "_") result = strings.ReplaceAll(result, "-", "_") return result -} \ No newline at end of file +} diff --git a/main.go b/main.go index 05ecf82..86f4f83 100644 --- a/main.go +++ b/main.go @@ -96,4 +96,4 @@ func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } -} \ No newline at end of file +} diff --git a/utils.go b/utils.go index ca62b74..65508f9 100644 --- a/utils.go +++ b/utils.go @@ -24,12 +24,3 @@ func runCommandOutput(name string, args ...string) (string, error) { } return strings.TrimSpace(string(output)), nil } - -func runCommandWithInput(name string, input string, args ...string) error { - cmd := exec.Command(name, args...) - fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " ")) - cmd.Stdin = strings.NewReader(input) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} \ No newline at end of file From cd1f23527ecade95b8c1ce0c972090fb3631bc31 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 22:16:16 -0300 Subject: [PATCH 07/10] fix hako sync --- commands.go | 34 +++++++++++++--- docker.go | 8 ++-- utils.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/commands.go b/commands.go index 41d17f1..3fc368b 100644 --- a/commands.go +++ b/commands.go @@ -130,6 +130,7 @@ func psCommand() error { } func syncCommand() error { + // TODO use the container for the currently working directory instead of just the first one in the filter... output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") if err != nil { return err @@ -160,44 +161,65 @@ func syncCommand() error { } 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 err + return fmt.Errorf("failed to walk %q: %v", path, err) } relPath, err := filepath.Rel(srcDir, path) if err != nil { - return err + return fmt.Errorf("failed to calculate relative path for %q: %v", path, err) } - if relPath == ".git" || relPath == ".gitignore" { + // 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 { - return err + 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 { - return err + fmt.Fprintf(os.Stderr, "Error: failed to create %s: %v\n", destPath, err) + return nil } defer destFile.Close() _, err = io.Copy(destFile, srcFile) - return err + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to copy %s to %s: %v\n", path, destPath, err) + } + return nil }) } diff --git a/docker.go b/docker.go index 6faea18..f671ea6 100644 --- a/docker.go +++ b/docker.go @@ -123,12 +123,12 @@ func buildLanguageImage(lang string) error { } func imageExists(imageName string) bool { - err := runCommand("docker", "image", "inspect", imageName) + err := runCommandSilent("docker", "image", "inspect", imageName) return err == nil } func containerExists(containerName string) bool { - err := runCommand("docker", "container", "inspect", containerName) + err := runCommandSilent("docker", "container", "inspect", containerName) return err == nil } @@ -145,9 +145,7 @@ func createContainer(containerName, imageName, workspaceDir string) error { args := []string{"create", "--name", containerName} - // Mount workspace - args = append(args, "-v", workspaceDir+":/workspace") - + // No mounting - workspace is isolated and copied separately args = append(args, "-it", imageName) if err := runCommand("docker", args...); err != nil { diff --git a/utils.go b/utils.go index 65508f9..8d6ac7f 100644 --- a/utils.go +++ b/utils.go @@ -1,9 +1,11 @@ package main import ( + "bufio" "fmt" "os" "os/exec" + "path/filepath" "strings" ) @@ -15,6 +17,14 @@ func runCommand(name string, args ...string) error { 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, " ")) @@ -24,3 +34,107 @@ func runCommandOutput(name string, args ...string) (string, error) { } 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 +} From ed8a6f2d81e1e091644d53083550cd056e57cc3d Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 22:16:25 -0300 Subject: [PATCH 08/10] fix line --- utils.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils.go b/utils.go index 8d6ac7f..a972c41 100644 --- a/utils.go +++ b/utils.go @@ -43,7 +43,7 @@ type GitIgnore struct { // 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) { @@ -104,7 +104,7 @@ func (gi *GitIgnore) matchPattern(pattern, path string) bool { // 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 From c59527c8d81ebae1cd2fe589292eb85cb07b9b11 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 22:26:39 -0300 Subject: [PATCH 09/10] use cwd to find container --- commands.go | 56 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/commands.go b/commands.go index 3fc368b..6bdb5ba 100644 --- a/commands.go +++ b/commands.go @@ -8,6 +8,39 @@ import ( "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...") @@ -80,16 +113,12 @@ func downCommand(container string) error { if container != "" { containerName = container } else { - // Find any hako container for current project - output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + // Find the appropriate hako container for current directory + var err error + containerName, err = getContainerForCurrentDirectory() if err != nil { - return err + return fmt.Errorf("no hako containers found for current project: %v", err) } - containers := strings.Split(strings.TrimSpace(output), "\n") - if len(containers) == 0 || containers[0] == "" { - return fmt.Errorf("no hako containers found for current project") - } - containerName = containers[0] // Use first found container } if !containerExists(containerName) { @@ -130,16 +159,11 @@ func psCommand() error { } func syncCommand() error { - // TODO use the container for the currently working directory instead of just the first one in the filter... - output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}") + // Find the appropriate hako container for current directory + containerName, err := getContainerForCurrentDirectory() if err != nil { - return err + return fmt.Errorf("no hako containers found for current project: %v", err) } - containers := strings.Split(strings.TrimSpace(output), "\n") - if len(containers) == 0 || containers[0] == "" { - return fmt.Errorf("no hako containers found for current project") - } - containerName := containers[0] // Use first found container if !containerExists(containerName) { return fmt.Errorf("container %s does not exist", containerName) From a62df0ee5f675ccbbbb98a4a3997554cc3518c92 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Jun 2025 22:27:55 -0300 Subject: [PATCH 10/10] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 49420c5..f140614 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ Thumbs.db # Logs *.log .claude/ +./hako