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 -}