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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ GOOS=windows GOARCH=amd64 go build ./...
- `session_timeout` config option: per-session timeout for claude (e.g., "30m", "1h"). Kills hanging sessions and continues to next iteration. CLI flag `--session-timeout` takes precedence. Disabled by default
- `idle_timeout` config option: kills claude sessions when no output for specified duration (e.g., "5m"). Resets on each output line, only fires when session goes silent. CLI flag `--idle-timeout` takes precedence. Disabled by default
- `move_plan_on_completion` config option: controls whether completed plans move to `docs/plans/completed/` on success. Default `true`. Disable for workflows that manage plan lifecycle externally (spec-driven tooling with separate archive steps)
- `preserve_anthropic_api_key` config option / `--preserve-anthropic-api-key` CLI flag: when true, `ANTHROPIC_API_KEY` is passed through to the child claude process. Required for users who authenticate Claude Code via API key rather than OAuth/keychain. Default `false` strips the key so a host-set value cannot silently override OAuth credentials and bill a different account. The merge sentinel `PreserveAnthropicAPIKeySet` lives only on `Values` (load-bearing for local-overrides-global merge); `Config` carries the resolved bool only. Plumbed: `Config.PreserveAnthropicAPIKey` → `pkg/processor/runner.go` → `ClaudeExecutor.PreserveAPIKey` → `execClaudeRunner.preserveAPIKey` → `claudeChildEnv()` in `pkg/executor/executor.go`. When enabled, the startup banner emits `auth: ANTHROPIC_API_KEY passthrough enabled` (in both task-execution and plan-creation modes) so users can spot wrong-context runs before claude bills the wrong account. CLAUDECODE is always stripped regardless of this flag (prevents nested-session errors)

### Local Project Config (.ralphex/)

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,9 @@ ralphex --session-timeout=30m docs/plans/feature.md
# kill claude session when no output for 5 minutes (idle detection)
ralphex --idle-timeout=5m docs/plans/feature.md

# preserve ANTHROPIC_API_KEY in the claude child env (for API-key auth users)
ralphex --preserve-anthropic-api-key docs/plans/feature.md

# with web dashboard
ralphex --serve docs/plans/feature.md

Expand Down Expand Up @@ -596,6 +599,7 @@ ralphex --serve --port=3000 docs/plans/feature.md
| `--session-timeout` | Per-session timeout for claude (e.g., `30m`, `1h`). Kills hanging sessions | disabled |
| `--idle-timeout` | Kill claude session when no output for specified duration (e.g., `5m`). Resets on each output line | disabled |
| `--worktree` | Run in isolated git worktree (full and tasks-only modes only) | false |
| `--preserve-anthropic-api-key` | Pass `ANTHROPIC_API_KEY` through to claude (for users authenticating Claude Code via API key rather than OAuth/keychain) | false |
| `--plan` | Create plan interactively (provide description) | - |
| `-s, --serve` | Start web dashboard for real-time streaming | false |
| `-p, --port` | Web dashboard port (used with `--serve`) | 8080 |
Expand Down Expand Up @@ -841,6 +845,7 @@ Provider-related CLI flags (`--claude-command`, `--claude-args`, `--external-rev
| `finalize_enabled` | Enable finalize step after reviews | `false` |
| `move_plan_on_completion` | Move completed plan file into `docs/plans/completed/` on success (disable for external plan-lifecycle workflows) | `true` |
| `use_worktree` | Run each plan in an isolated git worktree (full and tasks-only modes only) | `false` |
| `preserve_anthropic_api_key` | Pass `ANTHROPIC_API_KEY` through to the claude child process (for users authenticating Claude Code via API key rather than OAuth/keychain). Default `false` strips the key so a host-set value cannot silently override OAuth credentials | `false` |
| `plans_dir` | Plans directory | `docs/plans` |
| `default_branch` | Override auto-detected default branch for review diffs | auto-detect |
| `vcs_command` | VCS command for the git backend (set to a translation script for hg repos) | `git` |
Expand Down
112 changes: 63 additions & 49 deletions cmd/ralphex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,39 @@ import (

// opts holds all command-line options.
type opts struct {
MaxIterations int `short:"m" long:"max-iterations" description:"maximum task iterations (default: 50)"`
MaxExternalIterations int `long:"max-external-iterations" default:"0" description:"override external review iteration limit (0 = auto)"`
ReviewPatience int `long:"review-patience" default:"0" description:"terminate external review after N unchanged rounds (0 = disabled)"`
TaskModel string `long:"task-model" description:"model for task execution as model[:effort] (e.g., opus, opus:high, :medium)"`
ReviewModel string `long:"review-model" description:"model for review phases as model[:effort] (falls back to --task-model)"`
ClaudeCommand string `long:"claude-command" description:"override claude-compatible command for this run"`
ClaudeArgs string `long:"claude-args" description:"override claude-compatible command args for this run"`
ExternalReviewTool string `long:"external-review-tool" choice:"codex" choice:"custom" choice:"none" description:"override external review tool for this run"`
CustomReviewScript string `long:"custom-review-script" description:"override custom external review script for this run"`
Review bool `short:"r" long:"review" description:"skip task execution, run full review pipeline"`
ExternalOnly bool `short:"e" long:"external-only" description:"skip tasks and first review, run only external review loop"`
CodexOnly bool `short:"c" long:"codex-only" description:"alias for --external-only (deprecated)"`
TasksOnly bool `short:"t" long:"tasks-only" description:"run only task phase, skip all reviews"`
BaseRef string `short:"b" long:"base-ref" description:"override default branch for review diffs (branch name or commit hash)"`
Wait time.Duration `long:"wait" description:"wait duration on rate limit before retry (e.g. 1h, 30m)"`
SessionTimeout time.Duration `long:"session-timeout" description:"per-session timeout for claude (e.g. 30m, 1h)"`
IdleTimeout time.Duration `long:"idle-timeout" description:"kill claude session after no output for this duration (e.g. 5m, 10m)"`
SkipFinalize bool `long:"skip-finalize" description:"skip finalize step even if enabled in config"`
Worktree bool `long:"worktree" description:"run in isolated git worktree"`
Branch string `long:"branch" description:"override branch name for worktree/branch creation (default: derived from plan filename)"`
PlanDescription string `long:"plan" description:"create plan interactively (enter plan description)"`
Debug bool `short:"d" long:"debug" description:"enable debug logging"`
NoColor bool `long:"no-color" description:"disable color output"`
Version bool `short:"v" long:"version" description:"print version and exit"`
Serve bool `short:"s" long:"serve" description:"start web dashboard for real-time streaming"`
Port int `short:"p" long:"port" default:"8080" description:"web dashboard port"`
Host string `long:"host" default:"127.0.0.1" env:"RALPHEX_WEB_HOST" description:"web dashboard listen address"`
Watch []string `short:"w" long:"watch" description:"directories to watch for progress files (repeatable)"`
Init bool `long:"init" description:"initialize local .ralphex/ config directory in current project"`
Reset bool `long:"reset" description:"interactively reset global config to embedded defaults"`
DumpDefaults string `long:"dump-defaults" description:"extract raw embedded defaults to specified directory"`
ConfigDir string `long:"config-dir" env:"RALPHEX_CONFIG_DIR" description:"custom config directory"`
MaxIterations int `short:"m" long:"max-iterations" description:"maximum task iterations (default: 50)"`
MaxExternalIterations int `long:"max-external-iterations" default:"0" description:"override external review iteration limit (0 = auto)"`
ReviewPatience int `long:"review-patience" default:"0" description:"terminate external review after N unchanged rounds (0 = disabled)"`
TaskModel string `long:"task-model" description:"model for task execution as model[:effort] (e.g., opus, opus:high, :medium)"`
ReviewModel string `long:"review-model" description:"model for review phases as model[:effort] (falls back to --task-model)"`
ClaudeCommand string `long:"claude-command" description:"override claude-compatible command for this run"`
ClaudeArgs string `long:"claude-args" description:"override claude-compatible command args for this run"`
ExternalReviewTool string `long:"external-review-tool" choice:"codex" choice:"custom" choice:"none" description:"override external review tool for this run"`
CustomReviewScript string `long:"custom-review-script" description:"override custom external review script for this run"`
Review bool `short:"r" long:"review" description:"skip task execution, run full review pipeline"`
ExternalOnly bool `short:"e" long:"external-only" description:"skip tasks and first review, run only external review loop"`
CodexOnly bool `short:"c" long:"codex-only" description:"alias for --external-only (deprecated)"`
TasksOnly bool `short:"t" long:"tasks-only" description:"run only task phase, skip all reviews"`
BaseRef string `short:"b" long:"base-ref" description:"override default branch for review diffs (branch name or commit hash)"`
Wait time.Duration `long:"wait" description:"wait duration on rate limit before retry (e.g. 1h, 30m)"`
SessionTimeout time.Duration `long:"session-timeout" description:"per-session timeout for claude (e.g. 30m, 1h)"`
IdleTimeout time.Duration `long:"idle-timeout" description:"kill claude session after no output for this duration (e.g. 5m, 10m)"`
SkipFinalize bool `long:"skip-finalize" description:"skip finalize step even if enabled in config"`
PreserveAnthropicAPIKey bool `long:"preserve-anthropic-api-key" description:"pass ANTHROPIC_API_KEY through to claude (for users authenticating Claude Code via API key rather than OAuth/keychain)"`
Worktree bool `long:"worktree" description:"run in isolated git worktree"`
Branch string `long:"branch" description:"override branch name for worktree/branch creation (default: derived from plan filename)"`
PlanDescription string `long:"plan" description:"create plan interactively (enter plan description)"`
Debug bool `short:"d" long:"debug" description:"enable debug logging"`
NoColor bool `long:"no-color" description:"disable color output"`
Version bool `short:"v" long:"version" description:"print version and exit"`
Serve bool `short:"s" long:"serve" description:"start web dashboard for real-time streaming"`
Port int `short:"p" long:"port" default:"8080" description:"web dashboard port"`
Host string `long:"host" default:"127.0.0.1" env:"RALPHEX_WEB_HOST" description:"web dashboard listen address"`
Watch []string `short:"w" long:"watch" description:"directories to watch for progress files (repeatable)"`
Init bool `long:"init" description:"initialize local .ralphex/ config directory in current project"`
Reset bool `long:"reset" description:"interactively reset global config to embedded defaults"`
DumpDefaults string `long:"dump-defaults" description:"extract raw embedded defaults to specified directory"`
ConfigDir string `long:"config-dir" env:"RALPHEX_CONFIG_DIR" description:"custom config directory"`

PlanFile string `positional-arg-name:"plan-file" description:"path to plan file (optional, uses fzf if omitted)"`

Expand Down Expand Up @@ -127,12 +128,13 @@ func (stderrLog) Print(format string, args ...any) {

// startupInfo holds parameters for printing startup information.
type startupInfo struct {
PlanFile string
PlanDescription string // used for plan mode instead of PlanFile
Branch string
Mode processor.Mode
MaxIterations int
ProgressPath string
PlanFile string
PlanDescription string // used for plan mode instead of PlanFile
Branch string
Mode processor.Mode
MaxIterations int
ProgressPath string
PreserveAnthropicAPIKey bool // when true, surfaced in the banner so users can spot wrong-context runs before claude bills the wrong account
}

// executePlanRequest holds parameters for plan execution.
Expand Down Expand Up @@ -552,11 +554,12 @@ func executePlan(ctx context.Context, o opts, req executePlanRequest) error {

// print startup info
printStartupInfo(startupInfo{
PlanFile: req.PlanFile,
Branch: branch,
Mode: req.Mode,
MaxIterations: resolveMaxIterations(o.MaxIterations, req.Config),
ProgressPath: plr.baseLog.Path(),
PlanFile: req.PlanFile,
Branch: branch,
Mode: req.Mode,
MaxIterations: resolveMaxIterations(o.MaxIterations, req.Config),
ProgressPath: plr.baseLog.Path(),
PreserveAnthropicAPIKey: req.Config.PreserveAnthropicAPIKey,
}, req.Colors)

// create and run the runner
Expand Down Expand Up @@ -935,7 +938,11 @@ func printStartupInfo(info startupInfo, colors *progress.Colors) {
colors.Info().Printf("starting interactive plan creation\n")
colors.Info().Printf("request: %s\n", info.PlanDescription)
colors.Info().Printf("branch: %s (max %d iterations)\n", info.Branch, info.MaxIterations)
colors.Info().Printf("progress log: %s\n\n", toRelPath(info.ProgressPath))
colors.Info().Printf("progress log: %s\n", toRelPath(info.ProgressPath))
if info.PreserveAnthropicAPIKey {
colors.Warn().Printf("auth: ANTHROPIC_API_KEY passthrough enabled\n")
}
colors.Info().Printf("\n")
return
}

Expand All @@ -945,6 +952,9 @@ func printStartupInfo(info startupInfo, colors *progress.Colors) {
}
colors.Info().Printf("starting ralphex loop (max %d iterations)%s\n", info.MaxIterations, modeStr)
displayMeta(colors, 0, info.PlanFile, info.Branch, info.ProgressPath)
if info.PreserveAnthropicAPIKey {
colors.Warn().Printf("auth: ANTHROPIC_API_KEY passthrough enabled\n")
}
colors.Info().Printf("\n")
}

Expand Down Expand Up @@ -989,11 +999,12 @@ func runPlanMode(ctx context.Context, o opts, req executePlanRequest, selector *

// print startup info for plan mode
printStartupInfo(startupInfo{
PlanDescription: o.PlanDescription,
Branch: branch,
Mode: processor.ModePlan,
MaxIterations: maxIter,
ProgressPath: baseLog.Path(),
PlanDescription: o.PlanDescription,
Branch: branch,
Mode: processor.ModePlan,
MaxIterations: maxIter,
ProgressPath: baseLog.Path(),
PreserveAnthropicAPIKey: req.Config.PreserveAnthropicAPIKey,
}, req.Colors)

// create input collector
Expand Down Expand Up @@ -1268,6 +1279,9 @@ func applyCLIOverrides(o opts, cfg *config.Config) {
if o.SkipFinalize {
cfg.FinalizeEnabled = false
}
if o.PreserveAnthropicAPIKey {
cfg.PreserveAnthropicAPIKey = true
}
if o.Worktree {
cfg.WorktreeEnabled = true
}
Expand Down
79 changes: 79 additions & 0 deletions cmd/ralphex/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,35 @@ func TestSkipFinalizeFlag(t *testing.T) {
})
}

func TestPreserveAnthropicAPIKeyFlag(t *testing.T) {
t.Run("flag enables when config disabled", func(t *testing.T) {
cfg := &config.Config{PreserveAnthropicAPIKey: false}
o := parseTestOpts(t, "--preserve-anthropic-api-key")

applyCLIOverrides(o, cfg)

assert.True(t, cfg.PreserveAnthropicAPIKey, "CLI flag should enable preserve in config")
})

t.Run("absent flag preserves config true", func(t *testing.T) {
cfg := &config.Config{PreserveAnthropicAPIKey: true}
o := parseTestOpts(t)

applyCLIOverrides(o, cfg)

assert.True(t, cfg.PreserveAnthropicAPIKey, "config-set true should be preserved when flag absent")
})

t.Run("absent flag preserves config false", func(t *testing.T) {
cfg := &config.Config{PreserveAnthropicAPIKey: false}
o := parseTestOpts(t)

applyCLIOverrides(o, cfg)

assert.False(t, cfg.PreserveAnthropicAPIKey)
})
}

func TestProviderOverrideFlags(t *testing.T) {
t.Run("claude_command_overrides_config", func(t *testing.T) {
cfg := &config.Config{ClaudeCommand: "configured-claude"}
Expand Down Expand Up @@ -927,6 +956,56 @@ func TestPrintStartupInfo(t *testing.T) {
// verify it doesn't panic with empty plan
printStartupInfo(info, colors)
})

t.Run("shows auth passthrough line when preserve enabled", func(t *testing.T) {
info := startupInfo{
PlanFile: "/path/to/plan.md",
Branch: "feature-branch",
Mode: processor.ModeFull,
MaxIterations: 50,
ProgressPath: "progress.txt",
PreserveAnthropicAPIKey: true,
}
out := captureStdout(t, func() {
printStartupInfo(info, colors)
})
assert.Contains(t, out, "ANTHROPIC_API_KEY passthrough enabled",
"banner must surface API key passthrough so users notice wrong-context runs")
})

t.Run("hides auth line when preserve disabled", func(t *testing.T) {
info := startupInfo{
PlanFile: "/path/to/plan.md",
Branch: "feature-branch",
Mode: processor.ModeFull,
MaxIterations: 50,
ProgressPath: "progress.txt",
PreserveAnthropicAPIKey: false,
}
out := captureStdout(t, func() {
printStartupInfo(info, colors)
})
assert.NotContains(t, out, "passthrough", "no auth line when default-strip behavior")
})

t.Run("shows auth passthrough line in plan mode when preserve enabled", func(t *testing.T) {
// plan mode has its own early-return branch in printStartupInfo; the auth
// line must surface there too because passthrough is the only safety
// signal once the run is on the wrong account.
info := startupInfo{
PlanDescription: "add health endpoint",
Branch: "plan-branch",
Mode: processor.ModePlan,
MaxIterations: 50,
ProgressPath: "progress.txt",
PreserveAnthropicAPIKey: true,
}
out := captureStdout(t, func() {
printStartupInfo(info, colors)
})
assert.Contains(t, out, "ANTHROPIC_API_KEY passthrough enabled",
"plan mode banner must surface API key passthrough")
})
}

func TestToRelPath(t *testing.T) {
Expand Down
Loading