Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/bd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ EXAMPLES:
bd import backup.jsonl # Import from a specific file
bd import --dry-run # Show what would be imported`,
GroupID: "sync",
RunE: runImport,
RunE: runImport,
}

var (
Expand Down
57 changes: 53 additions & 4 deletions cmd/bd/init_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func addAgentsInstructions(verbose bool, templatePath string) {
}

// updateAgentFile creates or updates an agent instructions file with embedded template content.
// When a beads section already exists (legacy or current), it is updated to the latest
// versioned format so that `bd init` never silently locks in stale sections.
func updateAgentFile(filename string, verbose bool, templatePath string) error {
// Check if file exists
//nolint:gosec // G304: filename comes from hardcoded list in addAgentsInstructions
Expand All @@ -43,6 +45,12 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error {
newContent = agents.EmbeddedDefault()
}

// Ensure the beads section uses versioned markers even in new files.
// EmbeddedDefault() may contain legacy markers; upgrade them.
if strings.Contains(newContent, "BEGIN BEADS INTEGRATION") && !strings.Contains(newContent, "profile:") {
newContent = upgradeBeadsSection(newContent, agents.ProfileFull)
}

// #nosec G306 - markdown needs to be readable
if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to create %s: %w", filename, err)
Expand All @@ -60,19 +68,29 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error {
hasBeads := strings.Contains(contentStr, "BEGIN BEADS INTEGRATION")

if hasBeads {
if verbose {
fmt.Printf(" %s already has agent instructions\n", filename)
// Update existing section to latest versioned format (upgrades legacy markers)
updated := upgradeBeadsSection(contentStr, agents.ProfileFull)
if updated != contentStr {
// #nosec G306 - markdown needs to be readable
if err := os.WriteFile(filename, []byte(updated), 0644); err != nil {
return fmt.Errorf("failed to update %s: %w", filename, err)
}
if verbose {
fmt.Printf(" %s Updated beads section in %s to latest format\n", ui.RenderPass("✓"), filename)
}
} else if verbose {
fmt.Printf(" %s already has current agent instructions\n", filename)
}
return nil
}

// Append beads section (includes landing-the-plane)
// Append beads section with profile metadata (includes landing-the-plane)
newContent := contentStr
if !strings.HasSuffix(newContent, "\n") {
newContent += "\n"
}

newContent += "\n" + agents.EmbeddedBeadsSection()
newContent += "\n" + agents.RenderSection(agents.ProfileFull)

// #nosec G306 - markdown needs to be readable
if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil {
Expand All @@ -84,6 +102,37 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error {
return nil
}

// upgradeBeadsSection replaces the beads section markers and content with the
// latest versioned format for the given profile. This handles both legacy
// markers (no metadata) and stale versioned markers (wrong hash).
func upgradeBeadsSection(content string, profile agents.Profile) string {
beginIdx := strings.Index(content, "<!-- BEGIN BEADS INTEGRATION")
endMarker := "<!-- END BEADS INTEGRATION -->"
endIdx := strings.Index(content, endMarker)

if beginIdx == -1 || endIdx == -1 || beginIdx > endIdx {
return content
}

// Check if already current
firstLine := content[beginIdx:]
if nl := strings.Index(firstLine, "\n"); nl != -1 {
firstLine = firstLine[:nl]
}
meta := agents.ParseMarker(firstLine)
if meta != nil && meta.Hash == agents.CurrentHash(profile) && meta.Profile == profile {
return content // already up to date
}

// Replace section
endOfEndMarker := endIdx + len(endMarker)
if endOfEndMarker < len(content) && content[endOfEndMarker] == '\n' {
endOfEndMarker++
}

return content[:beginIdx] + agents.RenderSection(profile) + content[endOfEndMarker:]
}

// setupClaudeSettings creates or updates .claude/settings.local.json with onboard instruction
func setupClaudeSettings(verbose bool) error {
claudeDir := ".claude"
Expand Down
171 changes: 146 additions & 25 deletions cmd/bd/setup/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/steveyegge/beads/internal/templates/agents"
"github.com/steveyegge/beads/internal/utils"
)

// readFileBytesImpl is used in tests; avoids import cycle.
var readFileBytesImpl = os.ReadFile

// AGENTS.md integration markers for beads section
const (
agentsBeginMarker = "<!-- BEGIN BEADS INTEGRATION -->"
Expand All @@ -19,6 +24,7 @@ const (
var (
errAgentsFileMissing = errors.New("agents file not found")
errBeadsSectionMissing = errors.New("beads section missing")
errBeadsSectionStale = errors.New("beads section is stale")
)

const muxAgentInstructionsURL = "https://mux.coder.com/AGENTS.md"
Expand All @@ -34,6 +40,7 @@ type agentsIntegration struct {
setupCommand string
readHint string
docsURL string
profile agents.Profile // "full" or "minimal"; empty defaults to "full"
}

func defaultAgentsEnv() agentsEnv {
Expand All @@ -44,43 +51,88 @@ func defaultAgentsEnv() agentsEnv {
}
}

// containsBeadsMarker returns true if content contains a BEGIN BEADS INTEGRATION marker
// (either legacy or new format with metadata).
func containsBeadsMarker(content string) bool {
return strings.Contains(content, "<!-- BEGIN BEADS INTEGRATION")
}

// resolveProfile returns the integration's profile, defaulting to full.
func resolveProfile(integration agentsIntegration) agents.Profile {
if integration.profile != "" {
return integration.profile
}
return agents.ProfileFull
}

func agentsFileName(path string) string {
base := filepath.Base(path)
if base == "" || base == "." {
return path
}
return base
}

func installAgents(env agentsEnv, integration agentsIntegration) error {
_, _ = fmt.Fprintf(env.stdout, "Installing %s integration...\n", integration.name)
agentsFile := agentsFileName(env.agentsPath)

beadsSection := agents.EmbeddedBeadsSection()
profile := resolveProfile(integration)

// Resolve symlinks so that e.g. CLAUDE.md -> AGENTS.md writes to the real target.
// This uses the existing atomicWriteFile path which also calls ResolveForWrite,
// but we need the resolved path here to read the current content from the right place.
resolvedPath, err := utils.ResolveForWrite(env.agentsPath)
if err != nil {
_, _ = fmt.Fprintf(env.stderr, "Error: resolve path %s: %v\n", env.agentsPath, err)
return err
}

var currentContent string
data, err := os.ReadFile(env.agentsPath)
data, err := os.ReadFile(resolvedPath) // #nosec G304 -- resolvedPath is derived from env.agentsPath via ResolveForWrite
if err == nil {
currentContent = string(data)
} else if !os.IsNotExist(err) {
_, _ = fmt.Fprintf(env.stderr, "Error: failed to read %s: %v\n", env.agentsPath, err)
return err
}

// Profile precedence: if the file already has a full profile and we're
// requesting minimal, preserve full to avoid information loss (e.g. when
// CLAUDE.md is a symlink to AGENTS.md and both Claude and Codex target it).
if currentContent != "" && containsBeadsMarker(currentContent) {
existingProfile := existingBeadsProfile(currentContent)
if existingProfile == agents.ProfileFull && profile == agents.ProfileMinimal {
_, _ = fmt.Fprintf(env.stdout, " ℹ File already has full profile; preserving (higher-information) content\n")
profile = agents.ProfileFull
}
}

beadsSection := agents.RenderSection(profile)

if currentContent != "" {
if strings.Contains(currentContent, agentsBeginMarker) {
newContent := updateBeadsSection(currentContent)
if containsBeadsMarker(currentContent) {
newContent := updateBeadsSectionWithProfile(currentContent, profile)
if err := atomicWriteFile(env.agentsPath, []byte(newContent)); err != nil {
_, _ = fmt.Fprintf(env.stderr, "Error: write %s: %v\n", env.agentsPath, err)
return err
}
_, _ = fmt.Fprintln(env.stdout, "✓ Updated existing beads section in AGENTS.md")
_, _ = fmt.Fprintf(env.stdout, "✓ Updated existing beads section in %s\n", agentsFile)
} else {
newContent := currentContent + "\n\n" + beadsSection
if err := atomicWriteFile(env.agentsPath, []byte(newContent)); err != nil {
_, _ = fmt.Fprintf(env.stderr, "Error: write %s: %v\n", env.agentsPath, err)
return err
}
_, _ = fmt.Fprintln(env.stdout, "✓ Added beads section to existing AGENTS.md")
_, _ = fmt.Fprintf(env.stdout, "✓ Added beads section to existing %s\n", agentsFile)
}
} else {
newContent := createNewAgentsFile()
newContent := createNewAgentsFileWithProfile(profile)
if err := atomicWriteFile(env.agentsPath, []byte(newContent)); err != nil {
_, _ = fmt.Fprintf(env.stderr, "Error: write %s: %v\n", env.agentsPath, err)
return err
}
_, _ = fmt.Fprintln(env.stdout, "✓ Created new AGENTS.md with beads integration")
_, _ = fmt.Fprintf(env.stdout, "✓ Created new %s with beads integration\n", agentsFile)
}

_, _ = fmt.Fprintf(env.stdout, "\n✓ %s integration installed\n", integration.name)
Expand All @@ -96,9 +148,11 @@ func installAgents(env agentsEnv, integration agentsIntegration) error {
}

func checkAgents(env agentsEnv, integration agentsIntegration) error {
agentsFile := agentsFileName(env.agentsPath)

data, err := os.ReadFile(env.agentsPath)
if os.IsNotExist(err) {
_, _ = fmt.Fprintln(env.stdout, "✗ AGENTS.md not found")
_, _ = fmt.Fprintf(env.stdout, "✗ %s not found\n", agentsFile)
_, _ = fmt.Fprintf(env.stdout, " Run: %s\n", integration.setupCommand)
return errAgentsFileMissing
} else if err != nil {
Expand All @@ -107,31 +161,58 @@ func checkAgents(env agentsEnv, integration agentsIntegration) error {
}

content := string(data)
if strings.Contains(content, agentsBeginMarker) {
_, _ = fmt.Fprintf(env.stdout, "✓ %s integration installed: %s\n", integration.name, env.agentsPath)
_, _ = fmt.Fprintln(env.stdout, " Beads section found in AGENTS.md")
if !containsBeadsMarker(content) {
_, _ = fmt.Fprintf(env.stdout, "⚠ %s exists but no beads section found\n", agentsFile)
_, _ = fmt.Fprintf(env.stdout, " Run: %s (to add beads section)\n", integration.setupCommand)
return errBeadsSectionMissing
}

// Section exists — check freshness via profile and hash
profile := resolveProfile(integration)
existingProf := existingBeadsProfile(content)

// Extract hash from marker
idx := findBeginMarker(content)
line := content[idx:]
if nl := strings.Index(line, "\n"); nl != -1 {
line = line[:nl]
}
meta := agents.ParseMarker(line)

checkProfile := profile
if profile == agents.ProfileMinimal && existingProf == agents.ProfileFull {
// Accept full profile as current when a minimal integration targets the same
// file (typically via symlinks like CLAUDE.md -> AGENTS.md).
checkProfile = agents.ProfileFull
}

currentHash := agents.CurrentHash(checkProfile)
if meta != nil && meta.Hash == currentHash && existingProf == checkProfile {
_, _ = fmt.Fprintf(env.stdout, "✓ %s integration installed: %s (current)\n", integration.name, env.agentsPath)
return nil
}

_, _ = fmt.Fprintln(env.stdout, "⚠ AGENTS.md exists but no beads section found")
_, _ = fmt.Fprintf(env.stdout, " Run: %s (to add beads section)\n", integration.setupCommand)
return errBeadsSectionMissing
// Stale or legacy section
_, _ = fmt.Fprintf(env.stdout, "⚠ %s integration installed but stale: %s\n", integration.name, env.agentsPath)
_, _ = fmt.Fprintf(env.stdout, " Run: %s (to update)\n", integration.setupCommand)
return errBeadsSectionStale
}

func removeAgents(env agentsEnv, integration agentsIntegration) error {
_, _ = fmt.Fprintf(env.stdout, "Removing %s integration...\n", integration.name)
agentsFile := agentsFileName(env.agentsPath)
data, err := os.ReadFile(env.agentsPath)
if os.IsNotExist(err) {
_, _ = fmt.Fprintln(env.stdout, "No AGENTS.md file found")
_, _ = fmt.Fprintf(env.stdout, "No %s file found\n", agentsFile)
return nil
} else if err != nil {
_, _ = fmt.Fprintf(env.stderr, "Error: failed to read %s: %v\n", env.agentsPath, err)
return err
}

content := string(data)
if !strings.Contains(content, agentsBeginMarker) {
_, _ = fmt.Fprintln(env.stdout, "No beads section found in AGENTS.md")
if !containsBeadsMarker(content) {
_, _ = fmt.Fprintf(env.stdout, "No beads section found in %s\n", agentsFile)
return nil
}

Expand All @@ -141,15 +222,22 @@ func removeAgents(env agentsEnv, integration agentsIntegration) error {
_, _ = fmt.Fprintf(env.stderr, "Error: write %s: %v\n", env.agentsPath, err)
return err
}
_, _ = fmt.Fprintln(env.stdout, "✓ Removed beads section from AGENTS.md")
_, _ = fmt.Fprintf(env.stdout, "✓ Removed beads section from %s\n", agentsFile)
return nil
}

// updateBeadsSection replaces the beads section in existing content
// updateBeadsSection replaces the beads section in existing content using the full profile.
// Kept for backward compatibility with existing callers and tests.
func updateBeadsSection(content string) string {
beadsSection := agents.EmbeddedBeadsSection()
return updateBeadsSectionWithProfile(content, agents.ProfileFull)
}

// updateBeadsSectionWithProfile replaces the beads section with the given profile.
// Handles both legacy markers (exact match) and new-format markers (prefix match with metadata).
func updateBeadsSectionWithProfile(content string, profile agents.Profile) string {
beadsSection := agents.RenderSection(profile)

start := strings.Index(content, agentsBeginMarker)
start := findBeginMarker(content)
end := strings.Index(content, agentsEndMarker)

if start == -1 || end == -1 || start > end {
Expand All @@ -170,7 +258,7 @@ func updateBeadsSection(content string) string {

// removeBeadsSection removes the beads section from content
func removeBeadsSection(content string) string {
start := strings.Index(content, agentsBeginMarker)
start := findBeginMarker(content)
end := strings.Index(content, agentsEndMarker)

if start == -1 || end == -1 || start > end {
Expand All @@ -196,9 +284,42 @@ func removeBeadsSection(content string) string {
return content[:start] + content[endOfEndMarker:]
}

// createNewAgentsFile creates a new AGENTS.md with a basic template
// findBeginMarker returns the index of the BEGIN BEADS INTEGRATION marker in content,
// matching both legacy (exact) and new (with metadata) formats via prefix match.
// Returns -1 if not found.
func findBeginMarker(content string) int {
return strings.Index(content, "<!-- BEGIN BEADS INTEGRATION")
}

// existingBeadsProfile extracts the profile from an existing beads section's
// begin marker. Returns ProfileFull if the marker contains "profile:full" or
// if it's a legacy marker (legacy sections contain full content).
// Returns ProfileMinimal only if explicitly marked as such.
func existingBeadsProfile(content string) agents.Profile {
idx := findBeginMarker(content)
if idx == -1 {
return agents.ProfileFull
}
line := content[idx:]
if nl := strings.Index(line, "\n"); nl != -1 {
line = line[:nl]
}
meta := agents.ParseMarker(line)
if meta == nil || meta.Profile == "" {
// Legacy marker — assume full (it contains all the content)
return agents.ProfileFull
}
return meta.Profile
}

// createNewAgentsFile creates a new AGENTS.md with a basic template using the full profile.
func createNewAgentsFile() string {
beadsSection := agents.EmbeddedBeadsSection()
return createNewAgentsFileWithProfile(agents.ProfileFull)
}

// createNewAgentsFileWithProfile creates a new AGENTS.md with the given profile.
func createNewAgentsFileWithProfile(profile agents.Profile) string {
beadsSection := agents.RenderSection(profile)

return `# Project Instructions for AI Agents

Expand Down
Loading
Loading