diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 2e375cb..eb8c7aa 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "path/filepath" + "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/instance" ) @@ -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 { diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index f36e549..dddc2a5 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -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 } diff --git a/internal/cmd/recreate.go b/internal/cmd/recreate.go index 8d57b08..57a4a34 100644 --- a/internal/cmd/recreate.go +++ b/internal/cmd/recreate.go @@ -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) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 87bd3ac..e574cf6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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() diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 77c439b..0f79a2d 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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" ) @@ -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, @@ -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] @@ -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 @@ -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 + } } diff --git a/internal/config/config.go b/internal/config/config.go index 0317be6..f0646b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 ( @@ -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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dcdf11c..02b21c0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") @@ -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) {