Implement MoleculeCatalog for loading molecules from multiple sources: 1. Built-in molecules (shipped with the gt binary) 2. Town-level: <town>/.beads/molecules.jsonl 3. Rig-level: <rig>/.beads/molecules.jsonl 4. Project-level: .beads/molecules.jsonl Changes: - Add internal/beads/catalog.go with MoleculeCatalog type - Update gt molecule list to show source (builtin, town, rig, project, database) - Update gt molecule show to check catalog first, then database - Update gt molecule instantiate to check catalog first - Add gt molecule export command to export built-in molecules to JSONL - Add --catalog and --db flags to gt molecule list The catalog enables organizations to share molecule templates independently of work item tracking, and allows customization at different levels of the workspace hierarchy. Closes gt-0ei3. Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
213 lines
6.0 KiB
Go
213 lines
6.0 KiB
Go
// 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: <town>/.beads/molecules.jsonl
|
|
// 3. Rig-level: <town>/<rig>/.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)
|
|
}
|