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
4 changes: 3 additions & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ func GetAgent(agentName, model, agentMode string, env []string) (Agent, error) {
switch agentName {
case "omp":
return &OmpAgent{Model: model, AgentMode: agentMode, Env: effectiveEnv}, nil
case "oh-my-pi":
return &OmpAgent{Model: model, AgentMode: agentMode, Env: effectiveEnv}, nil
case "claude":
return &ClaudeAgent{Model: model, AgentMode: agentMode, Env: effectiveEnv}, nil
case "cursor":
return &CursorAgent{Model: model, AgentMode: agentMode, Env: effectiveEnv}, nil
case "opencode":
return &OpencodeAgent{Model: model, AgentMode: agentMode, Env: effectiveEnv}, nil
default:
return nil, fmt.Errorf("unknown agent %q (supported: omp, opencode, claude, cursor)", agentName)
return nil, fmt.Errorf("unknown agent %q (supported: omp, oh-my-pi, opencode, claude, cursor)", agentName)
}
}
10 changes: 5 additions & 5 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const (
questionKeyWriteConfiguration = "write-configuration"
)

var supportedInitAgents = []string{"omp", "opencode", "claude", "cursor"}
var supportedInitAgents = []string{"omp", "opencode", "claude", "cursor", "oh-my-pi"}

var errInvalidConfirmAnswer = errors.New("please answer yes or no")

Expand Down Expand Up @@ -336,12 +336,12 @@ func seedInitStringDefaults(answers *InitAnswers, existingConfig *config.Config)
apply func(string)
}{
{existingConfig.Model, func(value string) { answers.Model = value }},
{existingConfig.AgentMode, func(value string) { answers.AgentMode = value }},
// AgentMode intentionally omitted - always shows no default
{existingConfig.SpecsDir, func(value string) { answers.SpecsDir = value }},
{existingConfig.SpecsIndexFile, func(value string) { answers.SpecsIndexFile = value }},
{existingConfig.ImplementationPlanName, func(value string) { answers.ImplementationPlanName = value }},
{existingConfig.PromptsDir, func(value string) { answers.PromptsDir = value }},
{existingConfig.LogFile, func(value string) { answers.LogFile = value }},
// LogFile intentionally omitted - always shows no default
} {
if strings.TrimSpace(field.value) == "" {
continue
Expand Down Expand Up @@ -509,7 +509,7 @@ func baseInitQuestions(defaults *InitAnswers) []InitQuestion {
return []InitQuestion{
newSelectQuestion(
questionKeyAgentName,
"AI agent (opencode/claude/cursor)",
"AI agent (omp/opencode/claude/cursor/oh-my-pi)",
defaults.AgentName,
supportedInitAgents,
validateInitAgent,
Expand All @@ -533,7 +533,7 @@ func baseInitQuestions(defaults *InitAnswers) []InitQuestion {
nil,
),
newInputQuestion(questionKeyPromptsDir, "Prompts directory", defaults.PromptsDir, true, nil),
newInputQuestion(questionKeyLogFile, "Log file path (optional)", defaults.LogFile, false, nil),
newInputQuestion(questionKeyLogFile, "Log file path (leave empty to disable logging)", defaults.LogFile, false, nil),
}
}

Expand Down
10 changes: 5 additions & 5 deletions internal/cli/init_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func TestSeedInitMaxIterationsDefault(t *testing.T) {
}

func TestSeedInitStringDefaults(t *testing.T) {
t.Run("populate existing config fields", func(t *testing.T) {
t.Run("populate existing config fields except AgentMode and LogFile", func(t *testing.T) {
var answers InitAnswers
existingConfig := &config.Config{
Model: "gpt-4",
Expand All @@ -219,8 +219,8 @@ func TestSeedInitStringDefaults(t *testing.T) {
if answers.Model != "gpt-4" {
t.Errorf("expected Model \"gpt-4\", got %q", answers.Model)
}
if answers.AgentMode != "agent" {
t.Errorf("expected AgentMode \"agent\", got %q", answers.AgentMode)
if answers.AgentMode != "" {
t.Errorf("expected AgentMode \"\" (omitted), got %q", answers.AgentMode)
}
if answers.SpecsDir != "my-specs" {
t.Errorf("expected SpecsDir \"my-specs\", got %q", answers.SpecsDir)
Expand All @@ -234,8 +234,8 @@ func TestSeedInitStringDefaults(t *testing.T) {
if answers.PromptsDir != "my-prompts" {
t.Errorf("expected PromptsDir \"my-prompts\", got %q", answers.PromptsDir)
}
if answers.LogFile != "/my/log.log" {
t.Errorf("expected LogFile \"/my/log.log\", got %q", answers.LogFile)
if answers.LogFile != "" {
t.Errorf("expected LogFile \"\" (omitted), got %q", answers.LogFile)
}
})
}
Expand Down
151 changes: 143 additions & 8 deletions internal/cli/init_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ func TestInitCommandWritesDefaultConfigFile(t *testing.T) {
if !strings.Contains(contentText, `agent = "opencode"`) {
t.Fatalf("expected config to include default agent, got %q", contentText)
}

// Verify empty optional fields are omitted
if strings.Contains(contentText, `log-file`) {
t.Errorf("empty log-file should be omitted, got %q", contentText)
}
if strings.Contains(contentText, `log-truncate`) {
t.Errorf("false log-truncate should be omitted, got %q", contentText)
}
if strings.Contains(contentText, `model = ""`) {
t.Errorf("empty model should be omitted, got %q", contentText)
}
if strings.Contains(contentText, `agent-mode = ""`) {
t.Errorf("empty agent-mode should be omitted, got %q", contentText)
}
}

func TestInitCommandWritesConfigToOutputPath(t *testing.T) {
Expand Down Expand Up @@ -135,15 +149,15 @@ func TestInitCommandAsksQuestionsInSpecifiedOrder(t *testing.T) {
}

assertOutputContainsPromptsInOrder(t, out.String(), []string{
"AI agent (opencode/claude/cursor)",
"AI agent (omp/opencode/claude/cursor/oh-my-pi)",
"Model (optional)",
"Agent mode/sub-agent (optional)",
"Maximum iterations",
"Specs directory",
"Specs index file",
"Implementation plan file",
"Prompts directory",
"Log file path (optional)",
"Log file path (leave empty to disable logging)",
"Write configuration now?",
})
}
Expand Down Expand Up @@ -278,29 +292,28 @@ func seededInitConfigLines() []string {
return []string{
`agent = "claude"`,
`model = "gpt-4o-mini"`,
`agent-mode = "planner"`,
"max-iterations = 7",
`specs-dir = "docs/specs"`,
`specs-index-file = "INDEX.md"`,
`implementation-plan-name = "PLAN.md"`,
`prompts-dir = ".ralph/custom-prompts"`,
`log-file = "./logs/custom.log"`,
"log-truncate = true",
}
}

func seededInitPromptDefaults() []string {
return []string{
"AI agent (opencode/claude/cursor) [claude]:",
"Overwrite existing configuration? [no]:",
"AI agent (omp/opencode/claude/cursor/oh-my-pi) [claude]:",
"Model (optional) [gpt-4o-mini]:",
"Agent mode/sub-agent (optional) [planner]:",
"Agent mode/sub-agent (optional):",
"Maximum iterations [7]:",
"Specs directory [docs/specs]:",
"Specs index file [INDEX.md]:",
"Implementation plan file [PLAN.md]:",
"Prompts directory [.ralph/custom-prompts]:",
"Log file path (optional) [./logs/custom.log]:",
"Truncate log file on each run? [yes]:",
"Log file path (leave empty to disable logging):",
"Configuration preview:",
"Write configuration now? [yes]:",
}
}
Expand All @@ -315,6 +328,7 @@ func assertOutputContainsAll(t *testing.T, output string, expectedFragments []st
}
}


func TestInitCommandSeedsQuestionDefaultsFromExistingConfig(t *testing.T) {
tmp := t.TempDir()
configPath := filepath.Join(tmp, "ralph.toml")
Expand Down Expand Up @@ -473,3 +487,124 @@ func TestIsInteractiveTerminalRejectsDevNullStreams(t *testing.T) {
t.Fatal("expected non-interactive terminal check for /dev/null streams")
}
}

func TestAskSingleQuestionWithReaderSuccess(t *testing.T) {
tmp := t.TempDir()
cmd, out := setupInteractiveInitCommand(t, tmp)
cmd.SetIn(strings.NewReader("yes\n"))

question := newConfirmQuestion(questionKeyWriteConfiguration, "Write configuration now?", confirmYes)
answer, err := askSingleQuestionWithReader(&InitSession{
Reader: bufio.NewReader(cmd.InOrStdin()),
Writer: out,
}, question, &bufioAnswerReader{reader: bufio.NewReader(cmd.InOrStdin())})

if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if answer != "yes" {
t.Errorf("expected answer yes, got %q", answer)
}
}

func TestReadBoolFlagOverrideForTest(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Bool("test-flag", false, "")

result, err := ReadBoolFlagOverrideForTest(cmd, "test-flag")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Changed {
t.Error("expected Changed=false")
}
if result.Value {
t.Error("expected Value=false")
}
}

func TestReadEnvFlagOverridesForTest(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().StringArray("env", []string{}, "")
if err := cmd.Flags().Set("env", "KEY=value"); err != nil {
t.Fatalf("failed to set flag: %v", err)
}

result, err := ReadEnvFlagOverridesForTest(cmd)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result["KEY"] != "value" {
t.Errorf("expected KEY=value, got %v", result)
}
}

func TestAskQuestionsSuccess(t *testing.T) {
cmd, out := setupInteractiveInitCommand(t, t.TempDir())
cmd.SetIn(strings.NewReader("test-answer\n"))

session := &InitSession{
Reader: bufio.NewReader(cmd.InOrStdin()),
Writer: out,
Answers: &InitAnswers{},
}

questions := []InitQuestion{
newInputQuestion(questionKeyModel, "Model", "", false, nil),
}

err := askQuestions(session, questions)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if session.Answers.Model != "test-answer" {
t.Errorf("expected Model to be test-answer, got %q", session.Answers.Model)
}
}

func TestStandardQuestionnaireRunnerAskQuestions(t *testing.T) {
cmd, out := setupInteractiveInitCommand(t, t.TempDir())
cmd.SetIn(strings.NewReader("answer1\nanswer2\n"))

session := &InitSession{
Reader: bufio.NewReader(cmd.InOrStdin()),
Writer: out,
Answers: &InitAnswers{},
}

runner := &standardQuestionnaireRunner{}
questions := []InitQuestion{
newInputQuestion(questionKeyModel, "Model", "", false, nil),
newInputQuestion(questionKeySpecsDir, "Specs dir", "", false, nil),
}

err := runner.AskQuestions(session, questions)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if session.Answers.Model != "answer1" {
t.Errorf("expected Model to be answer1, got %q", session.Answers.Model)
}
if session.Answers.SpecsDir != "answer2" {
t.Errorf("expected SpecsDir to be answer2, got %q", session.Answers.SpecsDir)
}
}

func TestAskSingleQuestion(t *testing.T) {
cmd, out := setupInteractiveInitCommand(t, t.TempDir())
cmd.SetIn(strings.NewReader("test-response\n"))

session := &InitSession{
Reader: bufio.NewReader(cmd.InOrStdin()),
Writer: out,
}

question := newInputQuestion(questionKeyModel, "Model", "", false, nil)
answer, err := askSingleQuestion(session, question)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if answer != "test-response" {
t.Errorf("expected answer test-response, got %q", answer)
}
}
18 changes: 9 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,22 @@ const (

// Config holds all Ralph configuration.
type Config struct {
ConfigFile string `toml:"config-file"`
ConfigFile string `toml:"config-file,omitempty"`
MaxIterations int `toml:"max-iterations"`
PromptFile string `toml:"prompt-file"`
PromptFile string `toml:"prompt-file,omitempty"`
SpecsDir string `toml:"specs-dir"`
SpecsIndexFile string `toml:"specs-index-file"`
NoSpecsIndex bool `toml:"no-specs-index"`
ImplementationPlanName string `toml:"implementation-plan-name"`
LogFile string `toml:"log-file"`
LogTruncate bool `toml:"log-truncate"`
CustomPrompt string `toml:"custom-prompt"`
LogFile string `toml:"log-file,omitempty"`
LogTruncate bool `toml:"log-truncate,omitempty"`
CustomPrompt string `toml:"custom-prompt,omitempty"`
PromptsDir string `toml:"prompts-dir"`
AgentName string `toml:"agent"`
Model string `toml:"model"`
AgentMode string `toml:"agent-mode"`
Env map[string]string `toml:"env"`
PromptOverrides map[string]PromptConfigOverride `toml:"prompt-overrides"`
Model string `toml:"model,omitempty"`
AgentMode string `toml:"agent-mode,omitempty"`
Env map[string]string `toml:"env,omitempty"`
PromptOverrides map[string]PromptConfigOverride `toml:"prompt-overrides,omitempty"`

configLoaded bool
}
Expand Down
41 changes: 41 additions & 0 deletions internal/config/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,44 @@ func TestWriteConfig_SuccessAndVerify(t *testing.T) {
t.Error("expected max-iterations in output")
}
}

func TestWriteConfig_EmptyStringsShouldBeOmitted(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.toml")
cfg := &config.Config{
AgentName: "opencode",
Model: "gpt-4",
AgentMode: "reviewer",
MaxIterations: 25,
SpecsDir: "specs",
SpecsIndexFile: "README.md",
ImplementationPlanName: "IMPLEMENTATION_PLAN.md",
PromptsDir: ".ralph/prompts",
LogFile: "ralph.log",
LogTruncate: false,
}

err := config.WriteConfig(path, cfg)
if err != nil {
t.Fatalf("WriteConfig failed: %v", err)
}

content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}

contentStr := string(content)
t.Logf("Generated TOML:\n%s", contentStr)

// Should NOT contain empty string values
if strings.Contains(contentStr, `config-file = ""`) {
t.Error("Config file should not write empty config-file field")
}
if strings.Contains(contentStr, `prompt-file = ""`) {
t.Error("Config file should not write empty prompt-file field")
}
if strings.Contains(contentStr, `custom-prompt = ""`) {
t.Error("Config file should not write empty custom-prompt field")
}
}
16 changes: 4 additions & 12 deletions ralph.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
agent = "opencode"
# model = "gpt-4"
# Optional agent mode/sub-agent (if supported by the CLI)
agent-mode = "ralph"

# Iteration Settings
max-iterations = 25

# Directory Settings
max-iterations = 50
specs-dir = "specs"
specs-index-file = "README.md"
no-specs-index = false
implementation-plan-name = "IMPLEMENTATION_PLAN.md"
log-file = ".ralph/logs/ralph.log"
prompts-dir = ".ralph/prompts"

# Logging Configuration
log-file = "logs/ralph.log"
agent = "oh-my-pi"
Loading
Loading