initial commit (second try)

This commit is contained in:
Luna 2025-06-10 21:16:53 -03:00
parent a62cf65376
commit ed3c0eae14
7 changed files with 656 additions and 0 deletions

226
commands.go Normal file
View file

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

208
docker.go Normal file
View file

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

68
git.go Normal file
View file

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

10
go.mod Normal file
View file

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

10
go.sum Normal file
View file

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

99
main.go Normal file
View file

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

35
utils.go Normal file
View file

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