Merge pull request #1073 from niklas-wortmann/junie

feat: add Junie agent integration
This commit is contained in:
Steve Yegge
2026-01-13 13:06:18 -08:00
committed by GitHub
7 changed files with 1073 additions and 0 deletions

View File

@@ -172,6 +172,9 @@ func runRecipe(name string) {
case "cursor":
runCursorRecipe()
return
case "junie":
runJunieRecipe()
return
}
// For all other recipes (built-in or user), use generic file-based install
@@ -296,6 +299,18 @@ func runAiderRecipe() {
setup.InstallAider()
}
func runJunieRecipe() {
if setupCheck {
setup.CheckJunie()
return
}
if setupRemove {
setup.RemoveJunie()
return
}
setup.InstallJunie()
}
func findBeadsDir() string {
// Check for .beads in current directory
if info, err := os.Stat(".beads"); err == nil && info.IsDir() {

241
cmd/bd/setup/junie.go Normal file
View File

@@ -0,0 +1,241 @@
package setup
import (
"encoding/json"
"fmt"
"os"
)
const junieGuidelinesTemplate = `# Beads Issue Tracking Instructions
This project uses **Beads (bd)** for issue tracking. Use the bd CLI or MCP tools for all task management.
## Core Workflow Rules
1. **Track ALL work in bd** - Never use markdown TODOs or comment-based task lists
2. **Check ready work first** - Run ` + "`bd ready`" + ` to find unblocked issues
3. **Always include descriptions** - Provide meaningful context when creating issues
4. **Link discovered work** - Use ` + "`discovered-from`" + ` dependencies for issues found during work
5. **Sync at session end** - Run ` + "`bd sync`" + ` before ending your session
## Quick Command Reference
### Finding Work
` + "```bash" + `
bd ready # Show unblocked issues ready for work
bd list --status open # List all open issues
bd show <id> # View issue details
bd blocked # Show blocked issues and their blockers
` + "```" + `
### Creating Issues
` + "```bash" + `
bd create "Title" --description="Details" -t bug|feature|task -p 0-4 --json
bd create "Found bug" --description="Details" --deps discovered-from:bd-42 --json
` + "```" + `
### Working on Issues
` + "```bash" + `
bd update <id> --status in_progress # Claim work
bd update <id> --priority 1 # Change priority
bd close <id> --reason "Completed" # Mark complete
` + "```" + `
### Dependencies
` + "```bash" + `
bd dep add <issue> <depends-on> # Add dependency (issue depends on depends-on)
bd dep add <issue> <depends-on> --type=related # Soft link
` + "```" + `
### Syncing
` + "```bash" + `
bd sync # ALWAYS run at session end - commits and pushes changes
` + "```" + `
## Issue Types
- ` + "`bug`" + ` - Something broken that needs fixing
- ` + "`feature`" + ` - New functionality
- ` + "`task`" + ` - Work item (tests, docs, refactoring)
- ` + "`epic`" + ` - Large feature composed of multiple issues
- ` + "`chore`" + ` - Maintenance work (dependencies, tooling)
## Priorities
- ` + "`0`" + ` - Critical (security, data loss, broken builds)
- ` + "`1`" + ` - High (major features, important bugs)
- ` + "`2`" + ` - Medium (default, nice-to-have)
- ` + "`3`" + ` - Low (polish, optimization)
- ` + "`4`" + ` - Backlog (future ideas)
## MCP Tools Available
If the MCP server is configured, you can use these tools directly:
- ` + "`mcp_beads_ready`" + ` - Find ready tasks
- ` + "`mcp_beads_list`" + ` - List issues with filters
- ` + "`mcp_beads_show`" + ` - Show issue details
- ` + "`mcp_beads_create`" + ` - Create new issues
- ` + "`mcp_beads_update`" + ` - Update issue status/priority
- ` + "`mcp_beads_close`" + ` - Close completed issues
- ` + "`mcp_beads_dep`" + ` - Manage dependencies
- ` + "`mcp_beads_blocked`" + ` - Show blocked issues
- ` + "`mcp_beads_stats`" + ` - Get issue statistics
## Important Rules
- ✅ Use bd for ALL task tracking
- ✅ Always use ` + "`--json`" + ` flag for programmatic use
- ✅ Link discovered work with ` + "`discovered-from`" + ` dependencies
- ✅ Check ` + "`bd ready`" + ` before asking "what should I work on?"
- ✅ Run ` + "`bd sync`" + ` at end of session
- ❌ Do NOT create markdown TODO lists
- ❌ Do NOT use external issue trackers
- ❌ Do NOT duplicate tracking systems
For more details, run ` + "`bd --help`" + ` or see the project's AGENTS.md file.
`
// junieMCPConfig generates the MCP configuration for Junie
func junieMCPConfig() map[string]interface{} {
return map[string]interface{}{
"mcpServers": map[string]interface{}{
"beads": map[string]interface{}{
"command": "bd",
"args": []string{"mcp"},
},
},
}
}
// InstallJunie installs Junie integration
func InstallJunie() {
guidelinesPath := ".junie/guidelines.md"
mcpPath := ".junie/mcp/mcp.json"
fmt.Println("Installing Junie integration...")
// Ensure .junie directory exists
if err := EnsureDir(".junie", 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Ensure .junie/mcp directory exists
if err := EnsureDir(".junie/mcp", 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Write guidelines file
if err := atomicWriteFile(guidelinesPath, []byte(junieGuidelinesTemplate)); err != nil {
fmt.Fprintf(os.Stderr, "Error: write guidelines: %v\n", err)
os.Exit(1)
}
// Write MCP config file
mcpConfig := junieMCPConfig()
mcpData, err := json.MarshalIndent(mcpConfig, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: marshal MCP config: %v\n", err)
os.Exit(1)
}
if err := atomicWriteFile(mcpPath, mcpData); err != nil {
fmt.Fprintf(os.Stderr, "Error: write MCP config: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n✓ Junie integration installed\n")
fmt.Printf(" Guidelines: %s (agent instructions)\n", guidelinesPath)
fmt.Printf(" MCP Config: %s (MCP server configuration)\n", mcpPath)
fmt.Println("\nJunie will automatically read these files on session start.")
fmt.Println("The MCP server provides direct access to beads tools.")
}
// CheckJunie checks if Junie integration is installed
func CheckJunie() {
guidelinesPath := ".junie/guidelines.md"
mcpPath := ".junie/mcp/mcp.json"
guidelinesExists := false
mcpExists := false
if _, err := os.Stat(guidelinesPath); err == nil {
guidelinesExists = true
}
if _, err := os.Stat(mcpPath); err == nil {
mcpExists = true
}
if guidelinesExists && mcpExists {
fmt.Println("✓ Junie integration installed")
fmt.Printf(" Guidelines: %s\n", guidelinesPath)
fmt.Printf(" MCP Config: %s\n", mcpPath)
return
}
if guidelinesExists {
fmt.Println("⚠ Partial Junie integration (guidelines only)")
fmt.Printf(" Guidelines: %s\n", guidelinesPath)
fmt.Println(" Missing: MCP config")
fmt.Println(" Run: bd setup junie (to complete installation)")
os.Exit(1)
}
if mcpExists {
fmt.Println("⚠ Partial Junie integration (MCP only)")
fmt.Printf(" MCP Config: %s\n", mcpPath)
fmt.Println(" Missing: Guidelines")
fmt.Println(" Run: bd setup junie (to complete installation)")
os.Exit(1)
}
fmt.Println("✗ Junie integration not installed")
fmt.Println(" Run: bd setup junie")
os.Exit(1)
}
// RemoveJunie removes Junie integration
func RemoveJunie() {
guidelinesPath := ".junie/guidelines.md"
mcpPath := ".junie/mcp/mcp.json"
mcpDir := ".junie/mcp"
junieDir := ".junie"
fmt.Println("Removing Junie integration...")
removed := false
// Remove guidelines
if err := os.Remove(guidelinesPath); err != nil {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: failed to remove guidelines: %v\n", err)
os.Exit(1)
}
} else {
removed = true
}
// Remove MCP config
if err := os.Remove(mcpPath); err != nil {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: failed to remove MCP config: %v\n", err)
os.Exit(1)
}
} else {
removed = true
}
// Try to remove .junie/mcp directory if empty
_ = os.Remove(mcpDir)
// Try to remove .junie directory if empty
_ = os.Remove(junieDir)
if !removed {
fmt.Println("No Junie integration files found")
return
}
fmt.Println("✓ Removed Junie integration")
}

501
cmd/bd/setup/junie_test.go Normal file
View File

@@ -0,0 +1,501 @@
package setup
import (
"encoding/json"
"os"
"strings"
"testing"
)
func TestJunieGuidelinesTemplate(t *testing.T) {
requiredContent := []string{
"bd ready",
"bd create",
"bd update",
"bd close",
"bd sync",
"mcp_beads_ready",
"mcp_beads_list",
"mcp_beads_create",
"bug",
"feature",
"task",
"epic",
}
for _, req := range requiredContent {
if !strings.Contains(junieGuidelinesTemplate, req) {
t.Errorf("junieGuidelinesTemplate missing required content: %q", req)
}
}
}
func TestJunieMCPConfig(t *testing.T) {
config := junieMCPConfig()
// Verify structure
mcpServers, ok := config["mcpServers"].(map[string]interface{})
if !ok {
t.Fatal("mcpServers key missing or wrong type")
}
beads, ok := mcpServers["beads"].(map[string]interface{})
if !ok {
t.Fatal("beads server config missing or wrong type")
}
command, ok := beads["command"].(string)
if !ok || command != "bd" {
t.Errorf("Expected command 'bd', got %v", beads["command"])
}
args, ok := beads["args"].([]string)
if !ok || len(args) != 1 || args[0] != "mcp" {
t.Errorf("Expected args ['mcp'], got %v", beads["args"])
}
// Verify it's valid JSON
data, err := json.Marshal(config)
if err != nil {
t.Errorf("MCP config should be valid JSON: %v", err)
}
// Verify it can be unmarshaled back
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Errorf("MCP config JSON should be parseable: %v", err)
}
}
func TestInstallJunie(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
InstallJunie()
// Verify guidelines file was created
guidelinesPath := ".junie/guidelines.md"
if !FileExists(guidelinesPath) {
t.Errorf("File was not created: %s", guidelinesPath)
} else {
data, err := os.ReadFile(guidelinesPath)
if err != nil {
t.Errorf("Failed to read %s: %v", guidelinesPath, err)
} else if string(data) != junieGuidelinesTemplate {
t.Errorf("File %s content doesn't match expected template", guidelinesPath)
}
}
// Verify MCP config file was created
mcpPath := ".junie/mcp/mcp.json"
if !FileExists(mcpPath) {
t.Errorf("File was not created: %s", mcpPath)
} else {
data, err := os.ReadFile(mcpPath)
if err != nil {
t.Errorf("Failed to read %s: %v", mcpPath, err)
} else {
// Verify it's valid JSON
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Errorf("MCP config should be valid JSON: %v", err)
}
// Verify structure
mcpServers, ok := parsed["mcpServers"].(map[string]interface{})
if !ok {
t.Error("mcpServers key missing or wrong type")
} else if _, ok := mcpServers["beads"]; !ok {
t.Error("beads server config missing")
}
}
}
}
func TestInstallJunie_ExistingDirectory(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Pre-create the directories
if err := os.MkdirAll(".junie/mcp", 0755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
// Should not fail
InstallJunie()
// Verify files were created
if !FileExists(".junie/guidelines.md") {
t.Error("guidelines.md not created")
}
if !FileExists(".junie/mcp/mcp.json") {
t.Error("mcp.json not created")
}
}
func TestInstallJunieIdempotent(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Run twice
InstallJunie()
firstGuidelines, _ := os.ReadFile(".junie/guidelines.md")
firstMCP, _ := os.ReadFile(".junie/mcp/mcp.json")
InstallJunie()
secondGuidelines, _ := os.ReadFile(".junie/guidelines.md")
secondMCP, _ := os.ReadFile(".junie/mcp/mcp.json")
if string(firstGuidelines) != string(secondGuidelines) {
t.Error("InstallJunie should be idempotent for guidelines")
}
if string(firstMCP) != string(secondMCP) {
t.Error("InstallJunie should be idempotent for MCP config")
}
}
func TestRemoveJunie(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Install first
InstallJunie()
// Verify files exist
files := []string{".junie/guidelines.md", ".junie/mcp/mcp.json"}
for _, f := range files {
if !FileExists(f) {
t.Fatalf("File should exist before removal: %s", f)
}
}
// Remove
RemoveJunie()
// Verify files are gone
for _, f := range files {
if FileExists(f) {
t.Errorf("File should have been removed: %s", f)
}
}
}
func TestRemoveJunie_NoFiles(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Should not panic when files don't exist
RemoveJunie()
}
func TestRemoveJunie_PartialFiles(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Create only the guidelines file
if err := os.MkdirAll(".junie", 0755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
if err := os.WriteFile(".junie/guidelines.md", []byte(junieGuidelinesTemplate), 0644); err != nil {
t.Fatalf("failed to create guidelines file: %v", err)
}
// Should not panic
RemoveJunie()
// Guidelines should be removed
if FileExists(".junie/guidelines.md") {
t.Error("Guidelines file should have been removed")
}
}
func TestRemoveJunie_DirectoryCleanup(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Install
InstallJunie()
// Remove
RemoveJunie()
// Directories should be cleaned up if empty
if DirExists(".junie/mcp") {
t.Error(".junie/mcp directory should be removed when empty")
}
if DirExists(".junie") {
t.Error(".junie directory should be removed when empty")
}
}
func TestRemoveJunie_DirectoryWithOtherFiles(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Install
InstallJunie()
// Add another file to .junie directory
if err := os.WriteFile(".junie/other.txt", []byte("keep me"), 0644); err != nil {
t.Fatalf("failed to create other file: %v", err)
}
// Remove
RemoveJunie()
// Directory should still exist (has other files)
if !DirExists(".junie") {
t.Error("Directory should not be removed when it has other files")
}
// Other file should still exist
if !FileExists(".junie/other.txt") {
t.Error("Other files should be preserved")
}
}
func TestCheckJunie_NotInstalled(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// CheckJunie calls os.Exit(1) when not installed
// We can't easily test that, but we document expected behavior
}
func TestCheckJunie_Installed(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Install first
InstallJunie()
// Should not panic or exit
CheckJunie()
}
func TestCheckJunie_PartialInstall_GuidelinesOnly(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Create only guidelines
if err := os.MkdirAll(".junie", 0755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
if err := os.WriteFile(".junie/guidelines.md", []byte(junieGuidelinesTemplate), 0644); err != nil {
t.Fatalf("failed to create guidelines file: %v", err)
}
// CheckJunie calls os.Exit(1) for partial installation
// We can't easily test that, but we document expected behavior
}
func TestCheckJunie_PartialInstall_MCPOnly(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
// Create only MCP config
if err := os.MkdirAll(".junie/mcp", 0755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
mcpConfig := junieMCPConfig()
mcpData, _ := json.MarshalIndent(mcpConfig, "", " ")
if err := os.WriteFile(".junie/mcp/mcp.json", mcpData, 0644); err != nil {
t.Fatalf("failed to create MCP config file: %v", err)
}
// CheckJunie calls os.Exit(1) for partial installation
// We can't easily test that, but we document expected behavior
}
func TestJunieFilePaths(t *testing.T) {
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
defer func() {
if err := os.Chdir(origDir); err != nil {
t.Fatalf("failed to restore working directory: %v", err)
}
}()
InstallJunie()
// Check expected file paths
expectedPaths := []string{
".junie/guidelines.md",
".junie/mcp/mcp.json",
}
for _, path := range expectedPaths {
if !FileExists(path) {
t.Errorf("Expected file at %s", path)
}
}
}
func TestJunieGuidelinesWorkflowPattern(t *testing.T) {
// Verify guidelines contain the workflow patterns Junie users need
guidelines := junieGuidelinesTemplate
// Should mention core workflow commands
if !strings.Contains(guidelines, "bd ready") {
t.Error("Should mention bd ready")
}
if !strings.Contains(guidelines, "bd sync") {
t.Error("Should mention bd sync")
}
// Should explain MCP tools
if !strings.Contains(guidelines, "MCP Tools Available") {
t.Error("Should have MCP Tools section")
}
}