// Package templates provides embedded templates for role contexts and messages. package templates import ( "bytes" "embed" "fmt" "os" "path/filepath" "text/template" ) //go:embed roles/*.md.tmpl messages/*.md.tmpl var templateFS embed.FS //go:embed commands/*.md var commandsFS embed.FS // Templates manages role and message templates. type Templates struct { roleTemplates *template.Template messageTemplates *template.Template // roleOverrides maps role name to override template content. // When set, these are used instead of embedded templates. roleOverrides map[string]*template.Template } // RoleData contains information for rendering role contexts. type RoleData struct { Role string // mayor, witness, refinery, polecat, crew, deacon RigName string // e.g., "greenplace" TownRoot string // e.g., "/Users/steve/ai" TownName string // e.g., "ai" - the town identifier for session names WorkDir string // current working directory DefaultBranch string // default branch for merges (e.g., "main", "develop") Polecat string // polecat name (for polecat role) Polecats []string // list of polecats (for witness role) BeadsDir string // BEADS_DIR path IssuePrefix string // beads issue prefix MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name } // SpawnData contains information for spawn assignment messages. type SpawnData struct { Issue string Title string Priority int Description string Branch string RigName string Polecat string } // NudgeData contains information for nudge messages. type NudgeData struct { Polecat string Reason string NudgeCount int MaxNudges int Issue string Status string } // EscalationData contains information for escalation messages. type EscalationData struct { Polecat string Issue string Reason string NudgeCount int LastStatus string Suggestions []string } // HandoffData contains information for session handoff messages. type HandoffData struct { Role string CurrentWork string Status string NextSteps []string Notes string PendingMail int GitBranch string GitDirty bool } // New creates a new Templates instance with only embedded templates. func New() (*Templates, error) { return NewWithOverrides("", "") } // NewWithOverrides creates a Templates instance that checks for local overrides. // Override resolution order (later overrides earlier): // 1. Built-in templates (embedded in binary) // 2. Town-level overrides (/templates/roles/.md.tmpl) // 3. Rig-level overrides (/templates/roles/.md.tmpl) // // If townRoot or rigPath is empty, that level is skipped. func NewWithOverrides(townRoot, rigPath string) (*Templates, error) { t := &Templates{ roleOverrides: make(map[string]*template.Template), } // Parse embedded role templates roleTempl, err := template.ParseFS(templateFS, "roles/*.md.tmpl") if err != nil { return nil, fmt.Errorf("parsing role templates: %w", err) } t.roleTemplates = roleTempl // Parse message templates msgTempl, err := template.ParseFS(templateFS, "messages/*.md.tmpl") if err != nil { return nil, fmt.Errorf("parsing message templates: %w", err) } t.messageTemplates = msgTempl // Load role overrides from filesystem // Check both town and rig levels, rig takes precedence roles := []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"} overridePaths := []string{} if townRoot != "" { overridePaths = append(overridePaths, filepath.Join(townRoot, "templates", "roles")) } if rigPath != "" { overridePaths = append(overridePaths, filepath.Join(rigPath, "templates", "roles")) } for _, role := range roles { templateName := role + ".md.tmpl" // Check each override path in order (later paths override earlier) for _, overrideDir := range overridePaths { overridePath := filepath.Join(overrideDir, templateName) if content, err := os.ReadFile(overridePath); err == nil { tmpl, err := template.New(templateName).Parse(string(content)) if err != nil { return nil, fmt.Errorf("parsing override template %s: %w", overridePath, err) } t.roleOverrides[role] = tmpl } } } return t, nil } // RenderRole renders a role context template. // If an override template exists for the role, it is used instead of the embedded template. func (t *Templates) RenderRole(role string, data RoleData) (string, error) { templateName := role + ".md.tmpl" var buf bytes.Buffer // Check for override template first if override, ok := t.roleOverrides[role]; ok { if err := override.Execute(&buf, data); err != nil { return "", fmt.Errorf("rendering override template %s: %w", templateName, err) } return buf.String(), nil } // Fall back to embedded template if err := t.roleTemplates.ExecuteTemplate(&buf, templateName, data); err != nil { return "", fmt.Errorf("rendering role template %s: %w", templateName, err) } return buf.String(), nil } // RenderMessage renders a message template. func (t *Templates) RenderMessage(name string, data interface{}) (string, error) { templateName := name + ".md.tmpl" var buf bytes.Buffer if err := t.messageTemplates.ExecuteTemplate(&buf, templateName, data); err != nil { return "", fmt.Errorf("rendering message template %s: %w", templateName, err) } return buf.String(), nil } // RoleNames returns the list of available role templates. func (t *Templates) RoleNames() []string { return []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"} } // HasRoleOverride returns true if the given role has a local override template. func (t *Templates) HasRoleOverride(role string) bool { _, ok := t.roleOverrides[role] return ok } // RoleOverrideCount returns the number of role templates that have local overrides. func (t *Templates) RoleOverrideCount() int { return len(t.roleOverrides) } // MessageNames returns the list of available message templates. func (t *Templates) MessageNames() []string { return []string{"spawn", "nudge", "escalation", "handoff"} } // CreateMayorCLAUDEmd creates the Mayor's CLAUDE.md file at the specified directory. // This is used by both gt install and gt doctor --fix. func CreateMayorCLAUDEmd(mayorDir, townRoot, townName, mayorSession, deaconSession string) error { tmpl, err := New() if err != nil { return err } data := RoleData{ Role: "mayor", TownRoot: townRoot, TownName: townName, WorkDir: mayorDir, MayorSession: mayorSession, DeaconSession: deaconSession, } content, err := tmpl.RenderRole("mayor", data) if err != nil { return err } claudePath := filepath.Join(mayorDir, "CLAUDE.md") return os.WriteFile(claudePath, []byte(content), 0644) } // GetAllRoleTemplates returns all role templates as a map of filename to content. func GetAllRoleTemplates() (map[string][]byte, error) { entries, err := templateFS.ReadDir("roles") if err != nil { return nil, fmt.Errorf("reading roles directory: %w", err) } result := make(map[string][]byte) for _, entry := range entries { if entry.IsDir() { continue } content, err := templateFS.ReadFile("roles/" + entry.Name()) if err != nil { return nil, fmt.Errorf("reading %s: %w", entry.Name(), err) } result[entry.Name()] = content } return result, nil } // ProvisionCommands creates the .claude/commands/ directory with standard slash commands. // This ensures crew/polecat workspaces have the handoff command and other utilities // even if the source repo doesn't have them tracked. // If a command already exists, it is skipped (no overwrite). func ProvisionCommands(workspacePath string) error { entries, err := commandsFS.ReadDir("commands") if err != nil { return fmt.Errorf("reading commands directory: %w", err) } // Create .claude/commands/ directory commandsDir := filepath.Join(workspacePath, ".claude", "commands") if err := os.MkdirAll(commandsDir, 0755); err != nil { return fmt.Errorf("creating commands directory: %w", err) } for _, entry := range entries { if entry.IsDir() { continue } destPath := filepath.Join(commandsDir, entry.Name()) // Skip if command already exists (don't overwrite user customizations) if _, err := os.Stat(destPath); err == nil { continue } content, err := commandsFS.ReadFile("commands/" + entry.Name()) if err != nil { return fmt.Errorf("reading %s: %w", entry.Name(), err) } if err := os.WriteFile(destPath, content, 0644); err != nil { //nolint:gosec // G306: template files are non-sensitive return fmt.Errorf("writing %s: %w", entry.Name(), err) } } return nil } // CommandNames returns the list of embedded slash commands. func CommandNames() ([]string, error) { entries, err := commandsFS.ReadDir("commands") if err != nil { return nil, fmt.Errorf("reading commands directory: %w", err) } var names []string for _, entry := range entries { if !entry.IsDir() { names = append(names, entry.Name()) } } return names, nil } // HasCommands checks if a workspace has the .claude/commands/ directory provisioned. func HasCommands(workspacePath string) bool { commandsDir := filepath.Join(workspacePath, ".claude", "commands") info, err := os.Stat(commandsDir) return err == nil && info.IsDir() } // MissingCommands returns the list of embedded commands missing from the workspace. func MissingCommands(workspacePath string) ([]string, error) { entries, err := commandsFS.ReadDir("commands") if err != nil { return nil, fmt.Errorf("reading commands directory: %w", err) } commandsDir := filepath.Join(workspacePath, ".claude", "commands") var missing []string for _, entry := range entries { if entry.IsDir() { continue } destPath := filepath.Join(commandsDir, entry.Name()) if _, err := os.Stat(destPath); os.IsNotExist(err) { missing = append(missing, entry.Name()) } } return missing, nil }