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
39 changes: 29 additions & 10 deletions internal/cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

var agentCmd = &cobra.Command{
Use: "agent <branch> [agent_name]",
Use: "agent <branch> [agent_name] [-- <agent-flags>...]",
Short: "Start an agent session in an existing instance",
Long: `Start an agent session within an existing instance for the specified branch.

Expand All @@ -25,6 +25,8 @@ to it unless --detached is specified.
If agent_name is not specified, the default agent from configuration is used.
Set the default with 'hjk config default.agent <agent_name>'.

Additional flags can be passed to the agent CLI by placing them after a -- separator.

All session output is captured to a log file regardless of attached/detached mode.`,
Example: ` # Start default agent on existing instance
hjk agent feat/auth
Expand All @@ -40,8 +42,11 @@ All session output is captured to a log file regardless of attached/detached mod
hjk agent feat/auth gemini --name auth-session

# Start agent in detached mode (run in background)
hjk agent feat/auth -d --prompt "Refactor the auth module"`,
Args: cobra.RangeArgs(1, 2),
hjk agent feat/auth -d --prompt "Refactor the auth module"

# Pass additional flags to the agent CLI
hjk agent feat/auth claude -- --dangerously-skip-permissions`,
Args: cobra.MinimumNArgs(1),
RunE: runAgentCmd,
}

Expand All @@ -50,10 +55,11 @@ type agentFlags struct {
sessionName string
detached bool
prompt string
agentFlags []string // flags to pass to the agent CLI (after --)
}

// parseAgentFlags extracts and validates flags from the command.
func parseAgentFlags(cmd *cobra.Command) (*agentFlags, error) {
func parseAgentFlags(cmd *cobra.Command, args []string) (*agentFlags, error) {
sessionName, err := cmd.Flags().GetString("name")
if err != nil {
return nil, fmt.Errorf("get name flag: %w", err)
Expand All @@ -71,6 +77,7 @@ func parseAgentFlags(cmd *cobra.Command) (*agentFlags, error) {
sessionName: sessionName,
detached: detached,
prompt: prompt,
agentFlags: parsePassthroughArgs(cmd, args),
}, nil
}

Expand Down Expand Up @@ -136,17 +143,22 @@ func injectAuthCredential(agent string, cfg *instance.CreateSessionConfig) error
}

// buildAgentCommand builds the command for launching an agent.
func buildAgentCommand(agent, prompt string) []string {
// The command is: agent [prompt] [flags...]
func buildAgentCommand(agent, prompt string, flags []string) []string {
cmd := []string{agent}
if prompt != "" {
cmd = append(cmd, prompt)
}
cmd = append(cmd, flags...)
return cmd
}

// resolveAgentName determines the agent name from args or config default.
func resolveAgentName(ctx context.Context, args []string) (string, error) {
if len(args) > 1 {
// dashIdx is the index of the -- separator (-1 if not present).
func resolveAgentName(ctx context.Context, args []string, dashIdx int) (string, error) {
// Check if args[1] exists and is before the -- separator (or no separator)
hasAgentArg := len(args) > 1 && (dashIdx < 0 || dashIdx > 1)
if hasAgentArg {
return args[1], nil
}

Expand All @@ -173,12 +185,12 @@ func runAgentCmd(cmd *cobra.Command, args []string) error {
return err
}

flags, err := parseAgentFlags(cmd)
flags, err := parseAgentFlags(cmd, args)
if err != nil {
return err
}

agentName, err := resolveAgentName(cmd.Context(), args)
agentName, err := resolveAgentName(cmd.Context(), args, cmd.ArgsLenAtDash())
if err != nil {
return err
}
Expand All @@ -194,11 +206,18 @@ func runAgentCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid agent %q (valid: %s)", agentName, formatList(config.ValidAgentNames()))
}

// Merge config flags with CLI flags
var configFlags []string
if loader := LoaderFromContext(cmd.Context()); loader != nil {
configFlags = loader.GetAgentFlags(agentName)
}
mergedFlags := mergeFlags(configFlags, flags.agentFlags)

// Build session config
sessionCfg := &instance.CreateSessionConfig{
Type: agentName,
Name: flags.sessionName,
Command: buildAgentCommand(agentName, flags.prompt),
Command: buildAgentCommand(agentName, flags.prompt, mergedFlags),
}

// Inject agent-specific environment variables from config
Expand Down
23 changes: 23 additions & 0 deletions internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ func formatNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) str
return fmt.Sprintf("container %s is %s; check logs with `%s`", err.ContainerID, err.Status, logsCmd)
}

// parsePassthroughArgs extracts arguments after the -- separator from a cobra command.
// Returns nil if no -- separator was used.
func parsePassthroughArgs(cmd *cobra.Command, args []string) []string {
dashIdx := cmd.ArgsLenAtDash()
if dashIdx < 0 || dashIdx >= len(args) {
return nil
}
return args[dashIdx:]
}

// mergeFlags combines base flags with additional flags.
// Base flags come first, additional flags are appended.
// Returns nil if both inputs are empty.
func mergeFlags(base, additional []string) []string {
if len(base) == 0 && len(additional) == 0 {
return nil
}
result := make([]string, 0, len(base)+len(additional))
result = append(result, base...)
result = append(result, additional...)
return result
}

// getInstanceByBranch gets an existing instance by branch, returning an error with hint if not found.
// If the instance is stopped, it will be automatically restarted.
func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch string) (*instance.Instance, error) {
Expand Down
25 changes: 7 additions & 18 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/jmgilman/headjack/internal/config"
"github.com/jmgilman/headjack/internal/container"
hjexec "github.com/jmgilman/headjack/internal/exec"
"github.com/jmgilman/headjack/internal/flags"
"github.com/jmgilman/headjack/internal/git"
"github.com/jmgilman/headjack/internal/instance"
"github.com/jmgilman/headjack/internal/multiplexer"
Expand Down Expand Up @@ -178,17 +177,11 @@ func initManager() error {
// Map runtime name to RuntimeType
runtimeType := runtimeNameToType(runtimeName)

// Parse config flags
configFlags, err := getConfigFlags()
if err != nil {
return err
}

mgr = instance.NewManager(store, runtime, opener, mux, instance.ManagerConfig{
mgr = instance.NewManager(store, runtime, opener, mux, &instance.ManagerConfig{
WorktreesDir: worktreesDir,
LogsDir: logsDir,
RuntimeType: runtimeType,
ConfigFlags: configFlags,
ConfigFlags: getConfigFlags(),
Executor: executor,
})

Expand All @@ -205,16 +198,12 @@ func runtimeNameToType(name string) instance.RuntimeType {
}
}

// getConfigFlags parses runtime flags from config.
func getConfigFlags() (flags.Flags, error) {
if appConfig == nil || appConfig.Runtime.Flags == nil {
return make(flags.Flags), nil
}
configFlags, err := flags.FromConfig(appConfig.Runtime.Flags)
if err != nil {
return nil, fmt.Errorf("parse runtime flags: %w", err)
// getConfigFlags returns runtime flags from config.
func getConfigFlags() []string {
if appConfig == nil {
return nil
}
return configFlags, nil
return appConfig.Runtime.Flags
}

// formatList joins strings with commas and "and" before the last item.
Expand Down
37 changes: 23 additions & 14 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

var runCmd = &cobra.Command{
Use: "run <branch>",
Use: "run <branch> [-- <runtime-flags>...]",
Short: "Create a new instance for the specified branch",
Long: `Create a new instance (worktree + container) for the specified branch.

Expand All @@ -25,6 +25,9 @@ stopped). The container environment is determined by:
2. Base image: Use --image to specify a container image directly, bypassing
devcontainer detection.

Additional flags can be passed to the container runtime (or devcontainer CLI)
by placing them after a -- separator.

This command only creates the instance. To start a session, use:
- 'hjk agent <branch> <agent>' to start an agent session
- 'hjk exec <branch>' to start a shell session`,
Expand All @@ -34,21 +37,25 @@ This command only creates the instance. To start a session, use:
# Use a specific container image (bypasses devcontainer)
hjk run feat/auth --image my-registry.io/custom-image:latest

# Pass additional flags to the container runtime
hjk run feat/auth -- --memory=4g --privileged

# Typical workflow: create instance, then start agent
hjk run feat/auth
hjk agent feat/auth claude --prompt "Implement JWT authentication"`,
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: runRunCmd,
}

// runFlags holds parsed flags for the run command.
type runFlags struct {
image string
imageExplicit bool // true if --image was explicitly passed
imageExplicit bool // true if --image was explicitly passed
runtimeFlags []string // flags to pass to the container runtime (after --)
}

// parseRunFlags extracts and validates flags from the command.
func parseRunFlags(cmd *cobra.Command) (*runFlags, error) {
func parseRunFlags(cmd *cobra.Command, args []string) (*runFlags, error) {
image, err := cmd.Flags().GetString("image")
if err != nil {
return nil, fmt.Errorf("get image flag: %w", err)
Expand All @@ -60,6 +67,7 @@ func parseRunFlags(cmd *cobra.Command) (*runFlags, error) {
return &runFlags{
image: image,
imageExplicit: imageExplicit,
runtimeFlags: parsePassthroughArgs(cmd, args),
}, nil
}

Expand All @@ -71,7 +79,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error {
return err
}

flags, err := parseRunFlags(cmd)
flags, err := parseRunFlags(cmd, args)
if err != nil {
return err
}
Expand All @@ -81,7 +89,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error {
return err
}

inst, err := getOrCreateInstance(cmd, mgr, repoPath, branch, flags.image, flags.imageExplicit)
inst, err := getOrCreateInstance(cmd, mgr, repoPath, branch, flags)
if err != nil {
return err
}
Expand All @@ -93,7 +101,7 @@ func runRunCmd(cmd *cobra.Command, args []string) error {
// getOrCreateInstance retrieves an existing instance or creates a new one.
// If the instance exists but is stopped, it restarts the container.
// If imageExplicit is false and a devcontainer.json exists, devcontainer mode is used.
func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, branch, image string, imageExplicit bool) (*instance.Instance, error) {
func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, branch string, flags *runFlags) (*instance.Instance, error) {
// Try to get existing instance
inst, err := mgr.GetByBranch(cmd.Context(), repoPath, branch)
if err == nil {
Expand All @@ -116,13 +124,13 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br
}

// Build create config - detect devcontainer mode if applicable
createCfg, err := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit)
createCfg, err := buildCreateConfig(cmd, repoPath, branch, flags)
if err != nil {
return nil, err
}

// Create new instance
inst, err = mgr.Create(cmd.Context(), repoPath, createCfg)
inst, err = mgr.Create(cmd.Context(), repoPath, &createCfg)
if err != nil {
return nil, fmt.Errorf("create instance: %w", err)
}
Expand All @@ -138,14 +146,15 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br
// - The devcontainer CLI is available (or can be installed)
//
// Returns an error if no devcontainer.json is found and no image is configured.
func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) (instance.CreateConfig, error) {
func buildCreateConfig(cmd *cobra.Command, repoPath, branch string, flags *runFlags) (instance.CreateConfig, error) {
cfg := instance.CreateConfig{
Branch: branch,
Image: image,
Branch: branch,
Image: flags.image,
RuntimeFlags: flags.runtimeFlags,
}

// If image was explicitly passed, use vanilla mode
if imageExplicit {
if flags.imageExplicit {
return cfg, nil
}

Expand Down Expand Up @@ -190,7 +199,7 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image
}

// No devcontainer.json - need an image
if image == "" {
if flags.image == "" {
return cfg, errors.New("no devcontainer.json found and no image configured\n\nTo fix this, either:\n 1. Add a devcontainer.json to your repository\n 2. Use --image to specify a container image\n 3. Set default.base_image in your config")
}

Expand Down
19 changes: 15 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ type DefaultConfig struct {

// AgentConfig holds agent-specific configuration.
type AgentConfig struct {
Env map[string]string `mapstructure:"env"`
Env map[string]string `mapstructure:"env"`
Flags []string `mapstructure:"flags"`
}

// StorageConfig holds storage location configuration.
Expand All @@ -81,8 +82,8 @@ type StorageConfig struct {

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

// DevcontainerConfig holds devcontainer CLI configuration.
Expand Down Expand Up @@ -152,10 +153,13 @@ func (l *Loader) setDefaults() {
l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json")
l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs")
l.v.SetDefault("agents.claude.env", map[string]string{"CLAUDE_CODE_MAX_TURNS": "100"})
l.v.SetDefault("agents.claude.flags", []string{})
l.v.SetDefault("agents.gemini.env", map[string]string{})
l.v.SetDefault("agents.gemini.flags", []string{})
l.v.SetDefault("agents.codex.env", map[string]string{})
l.v.SetDefault("agents.codex.flags", []string{})
l.v.SetDefault("runtime.name", "docker")
l.v.SetDefault("runtime.flags", map[string]any{})
l.v.SetDefault("runtime.flags", []string{})
l.v.SetDefault("devcontainer.path", "")
}

Expand Down Expand Up @@ -211,6 +215,13 @@ func (l *Loader) GetAgentEnv(agent string) map[string]string {
return raw
}

// GetAgentFlags returns the CLI flags for a specific agent.
// Returns nil if the agent has no flags configuration.
func (l *Loader) GetAgentFlags(agent string) []string {
key := fmt.Sprintf("agents.%s.flags", agent)
return l.v.GetStringSlice(key)
}

// Set sets a configuration value by dot-notation key.
func (l *Loader) Set(key, value string) error {
if err := ValidateKey(key); err != nil {
Expand Down
Loading