forked from gastownhall/gastown
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtemplates.go
More file actions
278 lines (234 loc) · 7.9 KB
/
templates.go
File metadata and controls
278 lines (234 loc) · 7.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
// Package templates provides embedded templates for role contexts and messages.
package templates
import (
"bytes"
"embed"
"fmt"
"os"
"path/filepath"
"text/template"
)
// The //go:embed directives below use Go's embed package to include template files
// directly in the compiled binary. At compile time, the Go toolchain reads matching
// files from disk and embeds their contents into the embed.FS variable. This allows
// the templates to be distributed as a single binary without external file dependencies.
// The glob patterns (e.g., "roles/*.md.tmpl") specify which files to embed.
//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
}
// 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.
func New() (*Templates, error) {
t := &Templates{}
// Parse 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
return t, nil
}
// RenderRole renders a role context template.
func (t *Templates) RenderRole(role string, data RoleData) (string, error) {
templateName := role + ".md.tmpl"
var buf bytes.Buffer
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"}
}
// 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
}