diff --git a/internal/beads/catalog.go b/internal/beads/catalog.go new file mode 100644 index 00000000..b70535d8 --- /dev/null +++ b/internal/beads/catalog.go @@ -0,0 +1,212 @@ +// Package beads provides molecule catalog support for hierarchical template loading. +package beads + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// CatalogMolecule represents a molecule template in the catalog. +// Unlike regular issues, catalog molecules are read-only templates. +type CatalogMolecule struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Source string `json:"source,omitempty"` // "builtin", "town", "rig", "project" +} + +// MoleculeCatalog provides hierarchical molecule template loading. +// It loads molecules from multiple sources in priority order: +// 1. Built-in molecules (shipped with the binary) +// 2. Town-level: /.beads/molecules.jsonl +// 3. Rig-level: //.beads/molecules.jsonl +// 4. Project-level: .beads/molecules.jsonl in current directory +// +// Later sources can override earlier ones by ID. +type MoleculeCatalog struct { + molecules map[string]*CatalogMolecule // ID -> molecule + order []string // Insertion order for listing +} + +// NewMoleculeCatalog creates an empty catalog. +func NewMoleculeCatalog() *MoleculeCatalog { + return &MoleculeCatalog{ + molecules: make(map[string]*CatalogMolecule), + order: make([]string, 0), + } +} + +// LoadCatalog creates a catalog with all molecule sources loaded. +// Parameters: +// - townRoot: Path to the Gas Town root (e.g., ~/gt). Empty to skip town-level. +// - rigPath: Path to the rig directory (e.g., ~/gt/gastown). Empty to skip rig-level. +// - projectPath: Path to the project directory. Empty to skip project-level. +// +// Built-in molecules are always loaded first. +func LoadCatalog(townRoot, rigPath, projectPath string) (*MoleculeCatalog, error) { + catalog := NewMoleculeCatalog() + + // 1. Load built-in molecules + for _, builtin := range BuiltinMolecules() { + catalog.Add(&CatalogMolecule{ + ID: builtin.ID, + Title: builtin.Title, + Description: builtin.Description, + Source: "builtin", + }) + } + + // 2. Load town-level molecules + if townRoot != "" { + townMolsPath := filepath.Join(townRoot, ".beads", "molecules.jsonl") + if err := catalog.LoadFromFile(townMolsPath, "town"); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading town molecules: %w", err) + } + } + + // 3. Load rig-level molecules + if rigPath != "" { + rigMolsPath := filepath.Join(rigPath, ".beads", "molecules.jsonl") + if err := catalog.LoadFromFile(rigMolsPath, "rig"); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading rig molecules: %w", err) + } + } + + // 4. Load project-level molecules + if projectPath != "" { + projectMolsPath := filepath.Join(projectPath, ".beads", "molecules.jsonl") + if err := catalog.LoadFromFile(projectMolsPath, "project"); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading project molecules: %w", err) + } + } + + return catalog, nil +} + +// Add adds or replaces a molecule in the catalog. +func (c *MoleculeCatalog) Add(mol *CatalogMolecule) { + if _, exists := c.molecules[mol.ID]; !exists { + c.order = append(c.order, mol.ID) + } + c.molecules[mol.ID] = mol +} + +// Get returns a molecule by ID, or nil if not found. +func (c *MoleculeCatalog) Get(id string) *CatalogMolecule { + return c.molecules[id] +} + +// List returns all molecules in insertion order. +func (c *MoleculeCatalog) List() []*CatalogMolecule { + result := make([]*CatalogMolecule, 0, len(c.order)) + for _, id := range c.order { + if mol, ok := c.molecules[id]; ok { + result = append(result, mol) + } + } + return result +} + +// Count returns the number of molecules in the catalog. +func (c *MoleculeCatalog) Count() int { + return len(c.molecules) +} + +// LoadFromFile loads molecules from a JSONL file. +// Each line should be a JSON object with id, title, and description fields. +// The source parameter is added to each loaded molecule. +func (c *MoleculeCatalog) LoadFromFile(path, source string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + var mol CatalogMolecule + if err := json.Unmarshal([]byte(line), &mol); err != nil { + return fmt.Errorf("line %d: %w", lineNum, err) + } + + if mol.ID == "" { + return fmt.Errorf("line %d: molecule missing id", lineNum) + } + + mol.Source = source + c.Add(&mol) + } + + return scanner.Err() +} + +// SaveToFile writes all molecules to a JSONL file. +// This is useful for exporting the catalog or creating template files. +func (c *MoleculeCatalog) SaveToFile(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + for _, mol := range c.List() { + // Don't include source in exported file + exportMol := struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + }{ + ID: mol.ID, + Title: mol.Title, + Description: mol.Description, + } + if err := encoder.Encode(exportMol); err != nil { + return err + } + } + + return nil +} + +// ToIssue converts a catalog molecule to an Issue struct for compatibility. +// The issue has Type="molecule" and is marked as a template. +func (mol *CatalogMolecule) ToIssue() *Issue { + return &Issue{ + ID: mol.ID, + Title: mol.Title, + Description: mol.Description, + Type: "molecule", + Status: "open", + Priority: 2, + } +} + +// ExportBuiltinMolecules writes all built-in molecules to a JSONL file. +// This is useful for creating a base molecules.jsonl file. +func ExportBuiltinMolecules(path string) error { + catalog := NewMoleculeCatalog() + for _, builtin := range BuiltinMolecules() { + catalog.Add(&CatalogMolecule{ + ID: builtin.ID, + Title: builtin.Title, + Description: builtin.Description, + Source: "builtin", + }) + } + return catalog.SaveToFile(path) +} diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index 3fc92f6e..90a771c7 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" ) // Molecule command flags @@ -16,6 +18,8 @@ var ( moleculeJSON bool moleculeInstParent string moleculeInstContext []string + moleculeCatalogOnly bool // List only catalog templates + moleculeDBOnly bool // List only database molecules ) var moleculeCmd = &cobra.Command{ @@ -32,10 +36,34 @@ var moleculeListCmd = &cobra.Command{ Short: "List molecules", Long: `List all molecule definitions. -Molecules are issues with type=molecule.`, +By default, lists molecules from all sources: +- Built-in molecules (shipped with gt) +- Town-level: /.beads/molecules.jsonl +- Rig-level: /.beads/molecules.jsonl +- Project-level: .beads/molecules.jsonl +- Database: molecules stored as issues + +Use --catalog to show only template molecules (not instantiated). +Use --db to show only database molecules.`, RunE: runMoleculeList, } +var moleculeExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export built-in molecules to JSONL", + Long: `Export built-in molecule templates to a JSONL file. + +This creates a molecules.jsonl file containing all built-in molecules. +You can place this in: +- /.beads/molecules.jsonl (town-level) +- /.beads/molecules.jsonl (rig-level) +- .beads/molecules.jsonl (project-level) + +The file can be edited to customize or add new molecules.`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeExport, +} + var moleculeShowCmd = &cobra.Command{ Use: "show ", Short: "Show molecule with parsed steps", @@ -88,6 +116,8 @@ Lists each instantiation with its status and progress.`, func init() { // List flags moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + moleculeListCmd.Flags().BoolVar(&moleculeCatalogOnly, "catalog", false, "Show only catalog templates") + moleculeListCmd.Flags().BoolVar(&moleculeDBOnly, "db", false, "Show only database molecules") // Show flags moleculeShowCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") @@ -109,6 +139,7 @@ func init() { moleculeCmd.AddCommand(moleculeParseCmd) moleculeCmd.AddCommand(moleculeInstantiateCmd) moleculeCmd.AddCommand(moleculeInstancesCmd) + moleculeCmd.AddCommand(moleculeExportCmd) rootCmd.AddCommand(moleculeCmd) } @@ -119,49 +150,141 @@ func runMoleculeList(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a beads workspace: %w", err) } - b := beads.New(workDir) - issues, err := b.List(beads.ListOptions{ - Type: "molecule", - Status: "all", - Priority: -1, - }) - if err != nil { - return fmt.Errorf("listing molecules: %w", err) + // Collect molecules from requested sources + type moleculeEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + StepCount int `json:"step_count,omitempty"` + Status string `json:"status,omitempty"` + Description string `json:"description,omitempty"` + } + + var entries []moleculeEntry + + // Load from catalog (unless --db only) + if !moleculeDBOnly { + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + for _, mol := range catalog.List() { + steps, _ := beads.ParseMoleculeSteps(mol.Description) + entries = append(entries, moleculeEntry{ + ID: mol.ID, + Title: mol.Title, + Source: mol.Source, + StepCount: len(steps), + Description: mol.Description, + }) + } + } + + // Load from database (unless --catalog only) + if !moleculeCatalogOnly { + b := beads.New(workDir) + issues, err := b.List(beads.ListOptions{ + Type: "molecule", + Status: "all", + Priority: -1, + }) + if err != nil { + return fmt.Errorf("listing molecules: %w", err) + } + + // Track catalog IDs to avoid duplicates + catalogIDs := make(map[string]bool) + for _, e := range entries { + catalogIDs[e.ID] = true + } + + for _, mol := range issues { + // Skip if already in catalog (catalog takes precedence) + if catalogIDs[mol.ID] { + continue + } + + steps, _ := beads.ParseMoleculeSteps(mol.Description) + entries = append(entries, moleculeEntry{ + ID: mol.ID, + Title: mol.Title, + Source: "database", + StepCount: len(steps), + Status: mol.Status, + Description: mol.Description, + }) + } } if moleculeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(issues) + return enc.Encode(entries) } // Human-readable output - fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(issues)) + fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(entries)) - if len(issues) == 0 { + if len(entries) == 0 { fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)")) return nil } - for _, mol := range issues { + for _, mol := range entries { + sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source)) + + stepCount := "" + if mol.StepCount > 0 { + stepCount = fmt.Sprintf(" (%d steps)", mol.StepCount) + } + statusMarker := "" if mol.Status == "closed" { statusMarker = " " + style.Dim.Render("[closed]") } - // Parse steps to show count - steps, _ := beads.ParseMoleculeSteps(mol.Description) - stepCount := "" - if len(steps) > 0 { - stepCount = fmt.Sprintf(" (%d steps)", len(steps)) - } - - fmt.Printf(" %s: %s%s%s\n", style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker) + fmt.Printf(" %s: %s%s%s %s\n", + style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker) } return nil } +// loadMoleculeCatalog loads the molecule catalog with hierarchical sources. +func loadMoleculeCatalog(workDir string) (*beads.MoleculeCatalog, error) { + var townRoot, rigPath, projectPath string + + // Try to find town root + townRoot, _ = workspace.FindFromCwd() + + // Try to find rig path + if townRoot != "" { + rigName, _, err := findCurrentRig(townRoot) + if err == nil && rigName != "" { + rigPath = filepath.Join(townRoot, rigName) + } + } + + // Project path is the work directory + projectPath = workDir + + return beads.LoadCatalog(townRoot, rigPath, projectPath) +} + +func runMoleculeExport(cmd *cobra.Command, args []string) error { + path := args[0] + + if err := beads.ExportBuiltinMolecules(path); err != nil { + return fmt.Errorf("exporting molecules: %w", err) + } + + fmt.Printf("%s Exported %d built-in molecules to %s\n", + style.Bold.Render("✓"), len(beads.BuiltinMolecules()), path) + + return nil +} + func runMoleculeShow(cmd *cobra.Command, args []string) error { molID := args[0] @@ -170,10 +293,26 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a beads workspace: %w", err) } - b := beads.New(workDir) - mol, err := b.Show(molID) + // Try catalog first + catalog, err := loadMoleculeCatalog(workDir) if err != nil { - return fmt.Errorf("getting molecule: %w", err) + return fmt.Errorf("loading catalog: %w", err) + } + + var mol *beads.Issue + var source string + + if catalogMol := catalog.Get(molID); catalogMol != nil { + mol = catalogMol.ToIssue() + source = catalogMol.Source + } else { + // Fall back to database + b := beads.New(workDir) + mol, err = b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + source = "database" } if mol.Type != "molecule" { @@ -182,15 +321,17 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error { // Parse steps steps, parseErr := beads.ParseMoleculeSteps(mol.Description) + _ = source // Used below in output // For JSON, include parsed steps if moleculeJSON { type moleculeOutput struct { *beads.Issue + Source string `json:"source"` Steps []beads.MoleculeStep `json:"steps,omitempty"` ParseError string `json:"parse_error,omitempty"` } - out := moleculeOutput{Issue: mol, Steps: steps} + out := moleculeOutput{Issue: mol, Source: source, Steps: steps} if parseErr != nil { out.ParseError = parseErr.Error() } @@ -200,7 +341,7 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error { } // Human-readable output - fmt.Printf("\n%s: %s\n", style.Bold.Render(mol.ID), mol.Title) + fmt.Printf("\n%s: %s %s\n", style.Bold.Render(mol.ID), mol.Title, style.Dim.Render(fmt.Sprintf("[%s]", source))) fmt.Printf("Type: %s\n", mol.Type) if parseErr != nil { @@ -230,7 +371,8 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error { } } - // Count instances + // Count instances (need beads client for this) + b := beads.New(workDir) instances, _ := findMoleculeInstances(b, molID) fmt.Printf("\nInstances: %d\n", len(instances)) @@ -327,10 +469,22 @@ func runMoleculeInstantiate(cmd *cobra.Command, args []string) error { b := beads.New(workDir) - // Get the molecule - mol, err := b.Show(molID) + // Try catalog first + catalog, err := loadMoleculeCatalog(workDir) if err != nil { - return fmt.Errorf("getting molecule: %w", err) + return fmt.Errorf("loading catalog: %w", err) + } + + var mol *beads.Issue + + if catalogMol := catalog.Get(molID); catalogMol != nil { + mol = catalogMol.ToIssue() + } else { + // Fall back to database + mol, err = b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } } if mol.Type != "molecule" {