restart codebase

This commit is contained in:
Luna 2025-06-10 19:16:15 -03:00
parent d0159c2ba4
commit a62cf65376
16 changed files with 2 additions and 1172 deletions

65
TODO.md
View file

@ -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 <lang>** - 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-<lang>-<sanitized-path>` 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 🚀

View file

@ -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)
},
}

View file

@ -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)
},
}

View file

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

View file

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

View file

@ -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()
},
}

View file

@ -1,34 +0,0 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
"l4.pm/hako/pkg"
)
var upCmd = &cobra.Command{
Use: "up <language>",
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)
},
}

View file

@ -1,7 +0,0 @@
package main
import "l4.pm/hako/cli"
func main() {
cli.Execute()
}

10
go.mod
View file

@ -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
)

10
go.sum
View file

@ -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=

View file

@ -10,6 +10,8 @@ proposed UI:
# builds the base hako image (debian:bookworm + node (latest lts) + npm install -g @anthropic-ai/claude-code + gcc) # 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 # 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` # 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 # target image name: hako-userland
hako init hako init

View file

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

View file

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

View file

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

View file

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

View file

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