Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (
"github.com/jmgilman/headjack/internal/multiplexer"
)

// requiredDeps lists the external binaries that must be available.
var requiredDeps = []string{"container", "git"}
// baseDeps lists the external binaries that must always be available.
var baseDeps = []string{"git"}

// mgr is the instance manager, initialized in PersistentPreRunE.
var mgr *instance.Manager
Expand Down Expand Up @@ -102,17 +102,35 @@ func initConfig() {
// checkDependencies verifies that all required external binaries are available.
func checkDependencies() error {
var missing []string
for _, dep := range requiredDeps {

// Check base dependencies
for _, dep := range baseDeps {
if _, err := exec.LookPath(dep); err != nil {
missing = append(missing, dep)
}
}

// Check runtime-specific dependency
runtimeBin := getRuntimeBinary()
if _, err := exec.LookPath(runtimeBin); err != nil {
missing = append(missing, runtimeBin)
}

if len(missing) > 0 {
return errors.New("missing required dependencies: " + formatList(missing))
}
return nil
}

// getRuntimeBinary returns the binary name for the configured runtime.
func getRuntimeBinary() string {
if appConfig != nil && appConfig.Runtime.Name == "apple" {
return "container"
}
// Default to podman
return "podman"
}

// initManager initializes the instance manager with all dependencies.
// muxOverride can be used to override the configured multiplexer.
func initManager(muxOverride string) error {
Expand All @@ -138,7 +156,30 @@ func initManager(muxOverride string) error {

executor := hjexec.New()
store := catalog.NewStore(catalogPath)
runtime := container.NewAppleRuntime(executor)

// Select runtime: config > default (podman)
var runtime container.Runtime
runtimeName := "podman" // default
if appConfig != nil && appConfig.Runtime.Name != "" {
runtimeName = appConfig.Runtime.Name
}
switch runtimeName {
case "apple":
appleCfg := container.AppleConfig{}
if appConfig != nil {
appleCfg.Privileged = appConfig.Runtime.Privileged
appleCfg.Flags = appConfig.Runtime.Flags
}
runtime = container.NewAppleRuntime(executor, appleCfg)
default:
podmanCfg := container.PodmanConfig{}
if appConfig != nil {
podmanCfg.Privileged = appConfig.Runtime.Privileged
podmanCfg.Flags = appConfig.Runtime.Flags
}
runtime = container.NewPodmanRuntime(executor, podmanCfg)
}

opener := git.NewOpener(executor)

// Select multiplexer: CLI flag > config > default (tmux)
Expand Down
45 changes: 45 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
ErrInvalidKey = errors.New("invalid configuration key")
ErrInvalidAgent = errors.New("invalid agent name")
ErrInvalidMultiplexer = errors.New("invalid multiplexer name")
ErrInvalidRuntime = errors.New("invalid runtime name")
ErrNoEditor = errors.New("$EDITOR environment variable not set")
)

Expand All @@ -45,6 +46,12 @@ var validMultiplexers = map[string]bool{
"zellij": true,
}

// validRuntimes contains the allowed runtime names (unexported).
var validRuntimes = map[string]bool{
"podman": true,
"apple": true,
}

// validKeys is built once from Config struct reflection.
var validKeys = buildValidKeys()

Expand All @@ -56,6 +63,7 @@ type Config struct {
Default DefaultConfig `mapstructure:"default" validate:"required"`
Agents map[string]AgentConfig `mapstructure:"agents" validate:"dive,keys,oneof=claude gemini codex,endkeys"`
Storage StorageConfig `mapstructure:"storage" validate:"required"`
Runtime RuntimeConfig `mapstructure:"runtime"`
}

// DefaultConfig holds default values for new instances.
Expand All @@ -77,6 +85,13 @@ type StorageConfig struct {
Logs string `mapstructure:"logs" validate:"required"`
}

// RuntimeConfig holds container runtime configuration.
type RuntimeConfig struct {
Name string `mapstructure:"name" validate:"omitempty,oneof=podman apple"`
Privileged bool `mapstructure:"privileged"`
Flags []string `mapstructure:"flags"`
}

// Validate checks the configuration for errors using struct tags.
func (c *Config) Validate() error {
if err := validate.Struct(c); err != nil {
Expand Down Expand Up @@ -105,6 +120,16 @@ func (c *Config) ValidMultiplexerNames() []string {
return []string{"tmux", "zellij"}
}

// IsValidRuntime returns true if the runtime name is valid.
func (c *Config) IsValidRuntime(name string) bool {
return validRuntimes[name]
}

// ValidRuntimeNames returns the list of valid runtime names.
func (c *Config) ValidRuntimeNames() []string {
return []string{"podman", "apple"}
}

// Loader provides configuration loading and saving.
type Loader struct {
v *viper.Viper
Expand Down Expand Up @@ -164,6 +189,9 @@ func (l *Loader) setDefaults() {
l.v.SetDefault("agents.claude.env", map[string]string{"CLAUDE_CODE_MAX_TURNS": "100"})
l.v.SetDefault("agents.gemini.env", map[string]string{})
l.v.SetDefault("agents.codex.env", map[string]string{})
l.v.SetDefault("runtime.name", "podman")
l.v.SetDefault("runtime.privileged", false)
l.v.SetDefault("runtime.flags", []string{})
}

// Load reads the configuration file, creating defaults if it doesn't exist.
Expand Down Expand Up @@ -237,6 +265,13 @@ func (l *Loader) Set(key, value string) error {
}
}

// Validate runtime name if setting runtime.name
if key == "runtime.name" && value != "" {
if !validRuntimes[value] {
return fmt.Errorf("%w: %s (valid: podman, apple)", ErrInvalidRuntime, value)
}
}

l.v.Set(key, value)
return l.v.WriteConfig()
}
Expand Down Expand Up @@ -337,3 +372,13 @@ func IsValidMultiplexer(name string) bool {
func ValidMultiplexerNames() []string {
return []string{"tmux", "zellij"}
}

// IsValidRuntime is a package-level helper for checking runtime validity.
func IsValidRuntime(name string) bool {
return validRuntimes[name]
}

// ValidRuntimeNames returns the list of valid runtime names.
func ValidRuntimeNames() []string {
return []string{"podman", "apple"}
}
32 changes: 25 additions & 7 deletions internal/container/apple.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ import (
"github.com/jmgilman/headjack/internal/exec"
)

// AppleConfig holds Apple Containerization-specific runtime configuration.
type AppleConfig struct {
Privileged bool // Run containers in privileged mode
Flags []string // Custom flags passed to container run
}

type appleRuntime struct {
exec exec.Executor
exec exec.Executor
config AppleConfig
}

// NewAppleRuntime creates a Runtime using Apple Containerization CLI.
func NewAppleRuntime(e exec.Executor) Runtime {
return &appleRuntime{exec: e}
func NewAppleRuntime(e exec.Executor, cfg AppleConfig) Runtime {
return &appleRuntime{exec: e, config: cfg}
}

// containerError formats an error from the container CLI, including stderr if available.
Expand All @@ -38,6 +45,13 @@ func containerError(operation string, result *exec.Result, err error) error {
func (r *appleRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) {
args := []string{"run", "--detach", "--name", cfg.Name}

if r.config.Privileged {
args = append(args, "--privileged")
}

// Add custom flags from config
args = append(args, r.config.Flags...)

for _, m := range cfg.Mounts {
mountSpec := fmt.Sprintf("%s:%s", m.Source, m.Target)
if m.ReadOnly {
Expand Down Expand Up @@ -314,9 +328,9 @@ type containerInspect struct {
func (c *containerInspect) toContainer() *Container {
status := StatusUnknown
switch strings.ToLower(c.Status) {
case "running":
case cliStatusRunning:
status = StatusRunning
case "stopped", "exited":
case cliStatusStopped, cliStatusExited:
status = StatusStopped
}

Expand All @@ -343,9 +357,9 @@ type containerListItem struct {
func (c *containerListItem) toContainer() Container {
status := StatusUnknown
switch strings.ToLower(c.Status) {
case "running":
case cliStatusRunning:
status = StatusRunning
case "stopped", "exited":
case cliStatusStopped, cliStatusExited:
status = StatusStopped
}

Expand All @@ -356,3 +370,7 @@ func (c *containerListItem) toContainer() Container {
Status: status,
}
}

func (r *appleRuntime) ExecCommand() []string {
return []string{"container", "exec"}
}
Loading