feat(crew): Provision .claude/commands/ for crew and polecat workspaces (gt-jhr85)

When adding a crew member with 'gt crew add' or spawning a polecat,
provision the .claude/commands/ directory with standard slash commands
like /handoff. This ensures all agents have Gas Town utilities even if
the source repo does not have them tracked.

Changes:
- Add embedded commands templates (internal/templates/commands/)
- Add ProvisionCommands() to templates package
- Call ProvisionCommands from crew and polecat managers
- Add gt doctor commands-provisioned check with --fix support
This commit is contained in:
slit
2026-01-02 17:21:43 -08:00
committed by Steve Yegge
parent a62b35a85c
commit d4eed2dcf2
6 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
---
description: Hand off to fresh session, work continues from hook
allowed-tools: Bash(gt mail send:*),Bash(gt handoff:*)
argument-hint: [message]
---
Hand off to a fresh session.
User's handoff message (if any): $ARGUMENTS
Execute these steps in order:
1. If user provided a message, send handoff mail to yourself first.
Construct your mail address from your identity (e.g., gastown/crew/max for crew, mayor/ for mayor).
Example: `gt mail send gastown/crew/max -s "HANDOFF: Session cycling" -m "USER_MESSAGE_HERE"`
2. Run the handoff command (this will respawn your session with a fresh Claude):
`gt handoff`
Note: The new session will auto-prime via the SessionStart hook and find your handoff mail.
End watch. A new session takes over, picking up any molecule on the hook.

View File

@@ -5,12 +5,17 @@ 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
@@ -148,3 +153,90 @@ func GetAllRoleTemplates() (map[string][]byte, error) {
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 {
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
}