diff --git a/cmd/check.go b/cmd/check.go index 3c1c0934..2a6b5a51 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -5,10 +5,10 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/hmans/beans/internal/beancore" "github.com/hmans/beans/internal/config" "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" ) var ( @@ -98,6 +98,18 @@ Note: Cycles cannot be auto-fixed and require manual intervention.`, } } + // 5. Check custom prime template exists (if configured) + if cfg.Templates.Prime != "" { + primePath := cfg.ResolvePrimeTemplatePath() + if _, err := os.Stat(primePath); os.IsNotExist(err) { + configErrors = append(configErrors, fmt.Sprintf("custom prime template not found: %s", primePath)) + } else if err != nil { + configErrors = append(configErrors, fmt.Sprintf("cannot access custom prime template: %s: %v", primePath, err)) + } else if !checkJSON { + fmt.Printf(" %s Custom prime template exists (%s)\n", ui.Success.Render("✓"), cfg.Templates.Prime) + } + } + // Print config errors in human-readable mode if !checkJSON { for _, e := range configErrors { diff --git a/cmd/prime.go b/cmd/prime.go index c5fe3e44..ac7d2bbe 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -1,12 +1,15 @@ package cmd import ( + "bytes" _ "embed" + "fmt" + "io" "os" "text/template" - "github.com/spf13/cobra" "github.com/hmans/beans/internal/config" + "github.com/spf13/cobra" ) //go:embed prompt.tmpl @@ -18,6 +21,62 @@ type promptData struct { Types []config.TypeConfig Statuses []config.StatusConfig Priorities []config.PriorityConfig + OriginalPrime string +} + +// primeResult holds the output of renderPrime for inspection. +type primeResult struct { + Output string // The rendered prime output + Warning string // Non-empty if a fallback occurred +} + +// renderPrime renders the prime output using the given config. +// If a custom template is configured but cannot be loaded or parsed, +// it falls back to the built-in template and sets a warning. +func renderPrime(primeCfg *config.Config, data promptData) (*primeResult, error) { + builtinTmpl, err := template.New("prompt").Parse(agentPromptTemplate) + if err != nil { + return nil, err + } + + primePath := primeCfg.ResolvePrimeTemplatePath() + if primePath == "" { + var buf bytes.Buffer + if err := builtinTmpl.Execute(&buf, data); err != nil { + return nil, err + } + return &primeResult{Output: buf.String()}, nil + } + + // Custom template configured - render built-in to string first + var builtinBuf bytes.Buffer + if err := builtinTmpl.Execute(&builtinBuf, data); err != nil { + return nil, fmt.Errorf("rendering built-in prime template: %w", err) + } + data.OriginalPrime = builtinBuf.String() + + // Read and parse the custom template + customTmplContent, err := os.ReadFile(primePath) + if err != nil { + return &primeResult{ + Output: builtinBuf.String(), + Warning: fmt.Sprintf("custom prime template not found: %s (falling back to built-in)", primePath), + }, nil + } + + customTmpl, err := template.New("custom-prompt").Parse(string(customTmplContent)) + if err != nil { + return &primeResult{ + Output: builtinBuf.String(), + Warning: fmt.Sprintf("parsing custom prime template: %v (falling back to built-in)", err), + }, nil + } + + var customBuf bytes.Buffer + if err := customTmpl.Execute(&customBuf, data); err != nil { + return nil, fmt.Errorf("executing custom prime template: %w", err) + } + return &primeResult{Output: customBuf.String()}, nil } var primeCmd = &cobra.Command{ @@ -28,6 +87,7 @@ var primeCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { // If no explicit path given, check if a beans project exists by searching // upward for a .beans.yml config file + var primeCfg *config.Config if beansPath == "" && configPath == "" { cwd, err := os.Getwd() if err != nil { @@ -38,11 +98,25 @@ var primeCmd = &cobra.Command{ // No config file found - silently exit return nil } - } - - tmpl, err := template.New("prompt").Parse(agentPromptTemplate) - if err != nil { - return err + primeCfg, err = config.Load(configFile) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + } else if configPath != "" { + var err error + primeCfg, err = config.Load(configPath) + if err != nil { + return fmt.Errorf("loading config from %s: %w", configPath, err) + } + } else { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + primeCfg, err = config.LoadFromDirectory(cwd) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } } data := promptData{ @@ -52,7 +126,17 @@ var primeCmd = &cobra.Command{ Priorities: config.DefaultPriorities, } - return tmpl.Execute(os.Stdout, data) + result, err := renderPrime(primeCfg, data) + if err != nil { + return err + } + + if result.Warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", result.Warning) + } + + _, err = io.WriteString(os.Stdout, result.Output) + return err }, } diff --git a/cmd/prime_test.go b/cmd/prime_test.go new file mode 100644 index 00000000..2a33256b --- /dev/null +++ b/cmd/prime_test.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hmans/beans/internal/config" +) + +func testPromptData() promptData { + return promptData{ + GraphQLSchema: "type Query { bean(id: ID!): Bean }", + Types: config.DefaultTypes, + Statuses: config.DefaultStatuses, + Priorities: config.DefaultPriorities, + } +} + +func TestRenderPrime_BuiltinTemplate(t *testing.T) { + cfg := config.Default() + cfg.SetConfigDir(t.TempDir()) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v", err) + } + + if result.Warning != "" { + t.Errorf("unexpected warning: %s", result.Warning) + } + if !strings.Contains(result.Output, "Beans Usage Guide") { + t.Error("built-in output should contain 'Beans Usage Guide'") + } + // All types should be rendered + for _, typ := range config.DefaultTypes { + if !strings.Contains(result.Output, typ.Name) { + t.Errorf("built-in output should contain type %q", typ.Name) + } + } + // All statuses should be rendered + for _, status := range config.DefaultStatuses { + if !strings.Contains(result.Output, status.Name) { + t.Errorf("built-in output should contain status %q", status.Name) + } + } + // All priorities should be rendered + for _, priority := range config.DefaultPriorities { + if !strings.Contains(result.Output, priority.Name) { + t.Errorf("built-in output should contain priority %q", priority.Name) + } + } +} + +func TestRenderPrime_CustomTemplate(t *testing.T) { + tmpDir := t.TempDir() + + customContent := `# Custom Prime +{{range .Types}}- {{.Name}} +{{end}} +Schema: {{.GraphQLSchema}} +Original: {{.OriginalPrime}}` + + if err := os.WriteFile(filepath.Join(tmpDir, "prime.tmpl"), []byte(customContent), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg := &config.Config{ + Templates: config.TemplatesConfig{Prime: "prime.tmpl"}, + } + cfg.SetConfigDir(tmpDir) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v", err) + } + + if result.Warning != "" { + t.Errorf("unexpected warning: %s", result.Warning) + } + if !strings.Contains(result.Output, "Custom Prime") { + t.Error("output should contain custom header") + } + // Types should be rendered via the custom template + for _, typ := range config.DefaultTypes { + if !strings.Contains(result.Output, typ.Name) { + t.Errorf("output should contain type %q", typ.Name) + } + } + // GraphQL schema should be accessible + if !strings.Contains(result.Output, "type Query") { + t.Error("output should contain GraphQL schema") + } + // OriginalPrime should contain the built-in output + if !strings.Contains(result.Output, "Beans Usage Guide") { + t.Error("OriginalPrime in output should contain built-in prime content") + } +} + +func TestRenderPrime_CustomTemplateWithOriginalPrime(t *testing.T) { + tmpDir := t.TempDir() + + customContent := `# Before +{{.OriginalPrime}} +# After` + + if err := os.WriteFile(filepath.Join(tmpDir, "prime.tmpl"), []byte(customContent), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg := &config.Config{ + Templates: config.TemplatesConfig{Prime: "prime.tmpl"}, + } + cfg.SetConfigDir(tmpDir) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v", err) + } + + if result.Warning != "" { + t.Errorf("unexpected warning: %s", result.Warning) + } + + // Verify ordering: custom header, then original, then custom footer + beforeIdx := strings.Index(result.Output, "# Before") + usageIdx := strings.Index(result.Output, "Beans Usage Guide") + afterIdx := strings.Index(result.Output, "# After") + + if beforeIdx == -1 || usageIdx == -1 || afterIdx == -1 { + t.Fatalf("missing expected sections in output:\n%s", result.Output) + } + if beforeIdx >= usageIdx { + t.Error("'# Before' should appear before 'Beans Usage Guide'") + } + if usageIdx >= afterIdx { + t.Error("'Beans Usage Guide' should appear before '# After'") + } +} + +func TestRenderPrime_FallbackOnMissingTemplate(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Templates: config.TemplatesConfig{Prime: "nonexistent.tmpl"}, + } + cfg.SetConfigDir(tmpDir) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v, expected fallback", err) + } + + // Should have a warning + if result.Warning == "" { + t.Error("expected warning about missing template") + } + if !strings.Contains(result.Warning, "nonexistent.tmpl") { + t.Errorf("warning should mention the missing file, got: %s", result.Warning) + } + if !strings.Contains(result.Warning, "falling back") { + t.Errorf("warning should mention fallback, got: %s", result.Warning) + } + + // Should still output the built-in prime + if !strings.Contains(result.Output, "Beans Usage Guide") { + t.Error("fallback output should contain built-in prime content") + } +} + +func TestRenderPrime_FallbackOnBadTemplateSyntax(t *testing.T) { + tmpDir := t.TempDir() + + badContent := `{{.Unclosed` + if err := os.WriteFile(filepath.Join(tmpDir, "bad.tmpl"), []byte(badContent), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg := &config.Config{ + Templates: config.TemplatesConfig{Prime: "bad.tmpl"}, + } + cfg.SetConfigDir(tmpDir) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v, expected fallback", err) + } + + // Should have a warning about parse error + if result.Warning == "" { + t.Error("expected warning about parse error") + } + if !strings.Contains(result.Warning, "parsing") { + t.Errorf("warning should mention parsing, got: %s", result.Warning) + } + if !strings.Contains(result.Warning, "falling back") { + t.Errorf("warning should mention fallback, got: %s", result.Warning) + } + + // Should still output the built-in prime + if !strings.Contains(result.Output, "Beans Usage Guide") { + t.Error("fallback output should contain built-in prime content") + } +} + +func TestRenderPrime_NoCustomTemplateConfigured(t *testing.T) { + cfg := config.Default() + cfg.SetConfigDir(t.TempDir()) + + // Verify that Templates.Prime is empty + if cfg.Templates.Prime != "" { + t.Fatalf("expected empty Templates.Prime in default config, got %q", cfg.Templates.Prime) + } + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v", err) + } + + if result.Warning != "" { + t.Errorf("unexpected warning: %s", result.Warning) + } + if !strings.Contains(result.Output, "Beans Usage Guide") { + t.Error("output should contain built-in prime content") + } +} + +func TestRenderPrime_CustomTemplateWithoutOriginalPrime(t *testing.T) { + tmpDir := t.TempDir() + + // Custom template that completely replaces the built-in (doesn't use OriginalPrime) + customContent := `# Fully Custom +This project uses beans. +Types: {{range .Types}}{{.Name}} {{end}}` + + if err := os.WriteFile(filepath.Join(tmpDir, "prime.tmpl"), []byte(customContent), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg := &config.Config{ + Templates: config.TemplatesConfig{Prime: "prime.tmpl"}, + } + cfg.SetConfigDir(tmpDir) + + result, err := renderPrime(cfg, testPromptData()) + if err != nil { + t.Fatalf("renderPrime() error = %v", err) + } + + if result.Warning != "" { + t.Errorf("unexpected warning: %s", result.Warning) + } + if !strings.Contains(result.Output, "Fully Custom") { + t.Error("output should contain custom content") + } + // Should NOT contain built-in content since OriginalPrime wasn't used + if strings.Contains(result.Output, "EXTREMELY_IMPORTANT") { + t.Error("output should not contain built-in prime content when OriginalPrime is not referenced") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1ae7fdf1..fdb79d8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,10 +69,17 @@ type PriorityConfig struct { Description string `yaml:"description,omitempty"` } +// TemplatesConfig defines custom template overrides. +type TemplatesConfig struct { + // Prime is the path to a custom prime template file (relative to config file location) + Prime string `yaml:"prime,omitempty"` +} + // Config holds the beans configuration. // Note: Statuses are no longer stored in config - they are hardcoded like types. type Config struct { - Beans BeansConfig `yaml:"beans"` + Beans BeansConfig `yaml:"beans"` + Templates TemplatesConfig `yaml:"templates,omitempty"` // configDir is the directory containing the config file (not serialized) // Used to resolve relative paths @@ -200,6 +207,22 @@ func (c *Config) ResolveBeansPath() string { return filepath.Join(c.configDir, c.Beans.Path) } +// ResolvePrimeTemplatePath returns the absolute path to the custom prime template file, +// or an empty string if no custom prime template is configured. +func (c *Config) ResolvePrimeTemplatePath() string { + if c.Templates.Prime == "" { + return "" + } + if filepath.IsAbs(c.Templates.Prime) { + return c.Templates.Prime + } + if c.configDir == "" { + cwd, _ := os.Getwd() + return filepath.Join(cwd, c.Templates.Prime) + } + return filepath.Join(c.configDir, c.Templates.Prime) +} + // ConfigDir returns the directory containing the config file. func (c *Config) ConfigDir() string { return c.configDir diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 77906e0b..56578239 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -243,6 +243,73 @@ func TestLoadAndSave(t *testing.T) { } } +func TestLoadAndSaveWithTemplates(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &Config{ + Beans: BeansConfig{ + Path: ".beans", + Prefix: "test-", + IDLength: 4, + DefaultType: "task", + }, + Templates: TemplatesConfig{ + Prime: "extras/prime.tmpl", + }, + } + cfg.SetConfigDir(tmpDir) + + // Save it + if err := cfg.Save(tmpDir); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Load it back + configPath := filepath.Join(tmpDir, ConfigFileName) + loaded, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Verify templates section round-trips + if loaded.Templates.Prime != "extras/prime.tmpl" { + t.Errorf("Templates.Prime = %q, want %q", loaded.Templates.Prime, "extras/prime.tmpl") + } +} + +func TestLoadAndSaveWithoutTemplates(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &Config{ + Beans: BeansConfig{ + Path: ".beans", + Prefix: "test-", + IDLength: 4, + DefaultType: "task", + }, + } + cfg.SetConfigDir(tmpDir) + + // Save it + if err := cfg.Save(tmpDir); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Load it back - templates should be empty + configPath := filepath.Join(tmpDir, ConfigFileName) + loaded, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loaded.Templates.Prime != "" { + t.Errorf("Templates.Prime = %q, want empty string", loaded.Templates.Prime) + } + if loaded.ResolvePrimeTemplatePath() != "" { + t.Errorf("ResolvePrimeTemplatePath() = %q, want empty string", loaded.ResolvePrimeTemplatePath()) + } +} + func TestLoadAppliesDefaults(t *testing.T) { // Create temp directory with minimal config tmpDir := t.TempDir() @@ -681,6 +748,102 @@ func TestResolveBeansPath(t *testing.T) { }) } +func TestResolvePrimeTemplatePath(t *testing.T) { + t.Run("returns empty when not configured", func(t *testing.T) { + cfg := Default() + cfg.SetConfigDir("/project/root") + + got := cfg.ResolvePrimeTemplatePath() + if got != "" { + t.Errorf("ResolvePrimeTemplatePath() = %q, want empty string", got) + } + }) + + t.Run("resolves relative path from config directory", func(t *testing.T) { + cfg := &Config{ + Templates: TemplatesConfig{Prime: "extras/custom-prime.tmpl"}, + } + cfg.SetConfigDir("/project/root") + + got := cfg.ResolvePrimeTemplatePath() + want := "/project/root/extras/custom-prime.tmpl" + if got != want { + t.Errorf("ResolvePrimeTemplatePath() = %q, want %q", got, want) + } + }) + + t.Run("returns absolute path unchanged", func(t *testing.T) { + cfg := &Config{ + Templates: TemplatesConfig{Prime: "/absolute/path/to/prime.tmpl"}, + } + cfg.SetConfigDir("/project/root") + + got := cfg.ResolvePrimeTemplatePath() + want := "/absolute/path/to/prime.tmpl" + if got != want { + t.Errorf("ResolvePrimeTemplatePath() = %q, want %q", got, want) + } + }) +} + +func TestLoadWithTemplatesConfig(t *testing.T) { + t.Run("loads templates.prime from config", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ConfigFileName) + + configYAML := `beans: + prefix: "test-" + id_length: 4 +templates: + prime: extras/custom-prime.tmpl +` + if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Templates.Prime != "extras/custom-prime.tmpl" { + t.Errorf("Templates.Prime = %q, want %q", cfg.Templates.Prime, "extras/custom-prime.tmpl") + } + + // Verify path resolution + want := filepath.Join(tmpDir, "extras/custom-prime.tmpl") + got := cfg.ResolvePrimeTemplatePath() + if got != want { + t.Errorf("ResolvePrimeTemplatePath() = %q, want %q", got, want) + } + }) + + t.Run("templates section is optional", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ConfigFileName) + + configYAML := `beans: + prefix: "test-" + id_length: 4 +` + if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Templates.Prime != "" { + t.Errorf("Templates.Prime = %q, want empty string", cfg.Templates.Prime) + } + if cfg.ResolvePrimeTemplatePath() != "" { + t.Errorf("ResolvePrimeTemplatePath() = %q, want empty string", cfg.ResolvePrimeTemplatePath()) + } + }) +} + func TestDefaultHasBeansPath(t *testing.T) { cfg := Default() if cfg.Beans.Path != DefaultBeansPath {