Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.
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
20 changes: 20 additions & 0 deletions internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/jmgilman/headjack/internal/config"
"github.com/jmgilman/headjack/internal/instance"
)

Expand All @@ -25,6 +27,24 @@ func repoPath() (string, error) {
return path, nil
}

func defaultDataDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get home directory: %w", err)
}
return filepath.Join(home, config.DefaultDataDir), nil
}

func resolveBaseImage(ctx context.Context, override string) string {
if override != "" {
return override
}
if cfg := ConfigFromContext(ctx); cfg != nil && cfg.Default.BaseImage != "" {
return cfg.Default.BaseImage
}
return config.DefaultBaseImage
}

func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch, notFoundMsg string) (*instance.Instance, error) {
repoPath, err := repoPath()
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ func getLogsDir(ctx context.Context) (string, error) {
}

// Fallback to default
home, err := os.UserHomeDir()
dataDir, err := defaultDataDir()
if err != nil {
return "", fmt.Errorf("get home directory: %w", err)
return "", err
}
return filepath.Join(home, ".local", "share", "headjack", "logs"), nil
return filepath.Join(dataDir, "logs"), nil
}
8 changes: 2 additions & 6 deletions internal/cmd/recreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,11 @@ The worktree (and all git-tracked and untracked files) is preserved.`,

// Determine image to use (precedence: flag > config)
// Config already has defaults set via Viper, so just use it
image, err := cmd.Flags().GetString("base")
imageOverride, err := cmd.Flags().GetString("base")
if err != nil {
return fmt.Errorf("get base flag: %w", err)
}
if image == "" {
if cfg := ConfigFromContext(cmd.Context()); cfg != nil {
image = cfg.Default.BaseImage
}
}
image := resolveBaseImage(cmd.Context(), imageOverride)

// Recreate the instance
newInst, err := mgr.Recreate(cmd.Context(), inst.ID, image)
Expand Down
10 changes: 5 additions & 5 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,13 @@ func initManager(muxOverride string) error {
logsDir = appConfig.Storage.Logs
} else {
// Fallback to defaults
home, err := os.UserHomeDir()
dataDir, err := defaultDataDir()
if err != nil {
return fmt.Errorf("get home directory: %w", err)
return err
}
worktreesDir = filepath.Join(home, ".local", "share", "headjack", "git")
catalogPath = filepath.Join(home, ".local", "share", "headjack", "catalog.json")
logsDir = filepath.Join(home, ".local", "share", "headjack", "logs")
worktreesDir = filepath.Join(dataDir, "git")
catalogPath = filepath.Join(dataDir, "catalog.json")
logsDir = filepath.Join(dataDir, "logs")
}

executor := hjexec.New()
Expand Down
90 changes: 48 additions & 42 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"

"github.com/jmgilman/headjack/internal/auth"
"github.com/jmgilman/headjack/internal/config"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/keychain"
)
Expand Down Expand Up @@ -78,12 +79,7 @@ func parseRunFlags(cmd *cobra.Command) (*runFlags, error) {
return nil, fmt.Errorf("get detached flag: %w", err)
}

// Use default image from config if not specified
if image == "" {
if cfg := ConfigFromContext(cmd.Context()); cfg != nil {
image = cfg.Default.BaseImage
}
}
image = resolveBaseImage(cmd.Context(), image)

return &runFlags{
image: image,
Expand Down Expand Up @@ -129,46 +125,52 @@ func buildSessionConfig(cmd *cobra.Command, flags *runFlags, args []string) (*in

// injectAuthToken retrieves the auth token for the agent and adds it to the session config.
func injectAuthToken(agent string, cfg *instance.CreateSessionConfig) error {
storage := keychain.New()
spec, ok := agentAuthSpecs[agent]
if !ok {
return nil
}

switch agent {
case "claude":
provider := auth.NewClaudeProvider()
token, err := provider.Get(storage)
if err != nil {
if errors.Is(err, keychain.ErrNotFound) {
return errors.New("claude auth not configured: run 'headjack auth claude' first")
}
return fmt.Errorf("get claude token: %w", err)
}
cfg.Env = append(cfg.Env, "CLAUDE_CODE_OAUTH_TOKEN="+token)

case "gemini":
provider := auth.NewGeminiProvider()
creds, err := provider.Get(storage)
if err != nil {
if errors.Is(err, keychain.ErrNotFound) {
return errors.New("gemini auth not configured: run 'headjack auth gemini' first")
}
return fmt.Errorf("get gemini credentials: %w", err)
}
cfg.Env = append(cfg.Env, "GEMINI_OAUTH_CREDS="+creds)

case "codex":
provider := auth.NewCodexProvider()
creds, err := provider.Get(storage)
if err != nil {
if errors.Is(err, keychain.ErrNotFound) {
return errors.New("codex auth not configured: run 'headjack auth codex' first")
}
return fmt.Errorf("get codex credentials: %w", err)
storage := keychain.New()
credential, err := spec.provider().Get(storage)
if err != nil {
if errors.Is(err, keychain.ErrNotFound) {
return errors.New(spec.notConfigured)
}
cfg.Env = append(cfg.Env, "CODEX_AUTH_JSON="+creds)
return fmt.Errorf("%s: %w", spec.errPrefix, err)
}

cfg.Env = append(cfg.Env, spec.envVar+"="+credential)
return nil
}

type agentAuthSpec struct {
provider func() auth.Provider
envVar string
notConfigured string
errPrefix string
}

var agentAuthSpecs = map[string]agentAuthSpec{
"claude": {
provider: func() auth.Provider { return auth.NewClaudeProvider() },
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
notConfigured: "claude auth not configured: run 'headjack auth claude' first",
errPrefix: "get claude token",
},
"gemini": {
provider: func() auth.Provider { return auth.NewGeminiProvider() },
envVar: "GEMINI_OAUTH_CREDS",
notConfigured: "gemini auth not configured: run 'headjack auth gemini' first",
errPrefix: "get gemini credentials",
},
"codex": {
provider: func() auth.Provider { return auth.NewCodexProvider() },
envVar: "CODEX_AUTH_JSON",
notConfigured: "codex auth not configured: run 'headjack auth codex' first",
errPrefix: "get codex credentials",
},
}

func runRunCmd(cmd *cobra.Command, args []string) error {
branch := args[0]

Expand Down Expand Up @@ -261,9 +263,8 @@ func resolveAgent(cmd *cobra.Command, agent string) (string, error) {
}

// Validate agent name
cfg := ConfigFromContext(cmd.Context())
if cfg == nil || !cfg.IsValidAgent(agent) {
return "", fmt.Errorf("invalid agent %q (valid: claude, gemini, codex)", agent)
if !config.IsValidAgent(agent) {
return "", fmt.Errorf("invalid agent %q (valid: %s)", agent, formatList(config.ValidAgentNames()))
}

return agent, nil
Expand All @@ -285,4 +286,9 @@ func init() {
runCmd.Flags().String("name", "", "override auto-generated session name")
runCmd.Flags().String("base", "", "override the default base image")
runCmd.Flags().BoolP("detached", "d", false, "create session but don't attach (run in background)")

agentFlag := runCmd.Flags().Lookup("agent")
if agentFlag != nil {
agentFlag.NoOptDefVal = agentDefaultSentinel
}
}
6 changes: 3 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ const (
DefaultDataDir = ".local/share/headjack"
)

// defaultBaseImage is the default container image (unexported).
// DefaultBaseImage is the default container image.
// Available variants: :base (minimal), :systemd (+ init), :dind (+ Docker)
const defaultBaseImage = "ghcr.io/jmgilman/headjack:base"
const DefaultBaseImage = "ghcr.io/jmgilman/headjack:base"

// Sentinel errors for configuration operations.
var (
Expand Down Expand Up @@ -181,7 +181,7 @@ func NewLoader() (*Loader, error) {
// setDefaults sets all default configuration values using Viper.
func (l *Loader) setDefaults() {
l.v.SetDefault("default.agent", "")
l.v.SetDefault("default.base_image", defaultBaseImage)
l.v.SetDefault("default.base_image", DefaultBaseImage)
l.v.SetDefault("default.multiplexer", "tmux")
l.v.SetDefault("storage.worktrees", "~/.local/share/headjack/git")
l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json")
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestLoader_Load_CreatesDefaultIfMissing(t *testing.T) {

// Check defaults
assert.Empty(t, cfg.Default.Agent)
assert.Equal(t, defaultBaseImage, cfg.Default.BaseImage)
assert.Equal(t, DefaultBaseImage, cfg.Default.BaseImage)
assert.Contains(t, cfg.Storage.Worktrees, "headjack")
assert.Contains(t, cfg.Storage.Catalog, "catalog.json")
assert.Contains(t, cfg.Storage.Logs, "logs")
Expand Down Expand Up @@ -117,7 +117,7 @@ func TestLoader_Get(t *testing.T) {
t.Run("valid key returns value", func(t *testing.T) {
val, err := loader.Get("default.base_image")
require.NoError(t, err)
assert.Equal(t, defaultBaseImage, val)
assert.Equal(t, DefaultBaseImage, val)
})

t.Run("invalid key returns error", func(t *testing.T) {
Expand Down