feat(init): add --stealth flag for invisible beads usage (#381)

Adds `bd init --stealth` to enable beads usage without affecting repo collaborators:
- Global gitattributes: configures beads merge driver across all repos
- Global gitignore: prevents .beads/ and .claude/settings.local.json from being committed
- Claude Code integration: adds 'bd onboard' instruction automatically
- Respects existing global git config files, only creates when necessary

Perfect for personal experimentation or contributing to repos where not everyone uses beads.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Dane Bertram
2025-11-25 17:46:01 -05:00
committed by GitHub
parent 25ccdf9892
commit c33e984d66
2 changed files with 326 additions and 1 deletions

View File

@@ -183,6 +183,33 @@ The `.gitignore` entries are automatically created inside `.beads/.gitignore` by
**Using devcontainers?** Open the repository in a devcontainer (GitHub Codespaces or VS Code Remote Containers) and bd will be automatically installed with git hooks configured. See [.devcontainer/README.md](.devcontainer/README.md) for details.
### Stealth Mode (Isolated Usage)
Want to use beads in your local clone without other collaborators seeing any beads-related files? Use **stealth mode**:
```bash
bd init --stealth
```
**Stealth mode configures:**
- **Global gitattributes** (`~/.config/git/attributes`) - Enables beads merge for `**/.beads/issues.jsonl` files across all repos
- **Global gitignore** (`~/.config/git/ignore`) - Ignores `**/.beads/` and `**/.claude/settings.local.json` globally
- **Claude Code settings** (`.claude/settings.local.json`) - Adds `bd onboard` instruction for AI agents
**Perfect for:**
- Personal experimentation with beads
- Working on repos where not everyone uses beads yet
- Keeping your issue tracking private while contributing to open source projects
- AI agents that should use beads without affecting the main repo
**What stays invisible to others:**
- No `.beads/` directory tracked in git
- No AGENTS.md or README.md mentions of beads
- No local `.gitattributes` or `.gitignore` modifications
- Your beads database and issues remain local-only
**How it works:** The global git configuration handles beads merging automatically, while the global gitignore ensures beads files never get committed to shared repos. Your AI agents get the onboard instruction automatically without exposing beads to other repo collaborators.
Most tasks will be created and managed by agents during conversations. You can check on things with:
```bash

View File

@@ -28,13 +28,20 @@ var initCmd = &cobra.Command{
Long: `Initialize bd in the current directory by creating a .beads/ directory
and database file. Optionally specify a custom issue prefix.
With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite database.`,
With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite database.
With --stealth: configures global git settings for invisible beads usage:
• Global gitattributes for beads merge support across all repos
• Global gitignore to prevent beads files from being committed
• Claude Code settings with bd onboard instruction
Perfect for personal use without affecting repo collaborators.`,
Run: func(cmd *cobra.Command, _ []string) {
prefix, _ := cmd.Flags().GetString("prefix")
quiet, _ := cmd.Flags().GetBool("quiet")
branch, _ := cmd.Flags().GetString("branch")
contributor, _ := cmd.Flags().GetBool("contributor")
team, _ := cmd.Flags().GetBool("team")
stealth, _ := cmd.Flags().GetBool("stealth")
skipMergeDriver, _ := cmd.Flags().GetBool("skip-merge-driver")
skipHooks, _ := cmd.Flags().GetBool("skip-hooks")
@@ -44,6 +51,19 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite
// Non-fatal - continue with defaults
}
// Handle stealth mode setup
if stealth {
if err := setupStealthMode(!quiet); err != nil {
fmt.Fprintf(os.Stderr, "Error setting up stealth mode: %v\n", err)
os.Exit(1)
}
// In stealth mode, skip git hooks and merge driver installation
// since we handle it globally
skipHooks = true
skipMergeDriver = true
}
// Check BEADS_DB environment variable if --db flag not set
// (PersistentPreRun doesn't run for init command)
if dbPath == "" {
@@ -400,6 +420,7 @@ func init() {
initCmd.Flags().StringP("branch", "b", "", "Git branch for beads commits (default: current branch)")
initCmd.Flags().Bool("contributor", false, "Run OSS contributor setup wizard")
initCmd.Flags().Bool("team", false, "Run team workflow setup wizard")
initCmd.Flags().Bool("stealth", false, "Enable stealth mode: global gitattributes and gitignore, no local repo tracking")
initCmd.Flags().Bool("skip-hooks", false, "Skip git hooks installation")
initCmd.Flags().Bool("skip-merge-driver", false, "Skip git merge driver setup")
rootCmd.AddCommand(initCmd)
@@ -1137,3 +1158,280 @@ func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
return nil, nil
}
// setupStealthMode configures global git settings for stealth operation
func setupStealthMode(verbose bool) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
// Setup global gitattributes
if err := setupGlobalGitAttributes(homeDir, verbose); err != nil {
return fmt.Errorf("failed to setup global gitattributes: %w", err)
}
// Setup global gitignore
if err := setupGlobalGitIgnore(homeDir, verbose); err != nil {
return fmt.Errorf("failed to setup global gitignore: %w", err)
}
// Setup claude settings
if err := setupClaudeSettings(verbose); err != nil {
return fmt.Errorf("failed to setup claude settings: %w", err)
}
if verbose {
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Stealth mode configured successfully!\n\n", green("✓"))
fmt.Printf(" Global gitattributes: %s\n", cyan("configured for beads merge"))
fmt.Printf(" Global gitignore: %s\n", cyan(".beads/ and .claude/settings.local.json ignored"))
fmt.Printf(" Claude settings: %s\n\n", cyan("configured with bd onboard instruction"))
fmt.Printf("Your beads setup is now %s - other repo collaborators won't see any beads-related files.\n\n", cyan("invisible"))
}
return nil
}
// setupGlobalGitAttributes configures global gitattributes for beads merge
func setupGlobalGitAttributes(homeDir string, verbose bool) error {
// Check if user already has a global gitattributes file configured
cmd := exec.Command("git", "config", "--global", "core.attributesfile")
output, err := cmd.Output()
var attributesPath string
if err == nil && len(output) > 0 {
// User has already configured a global gitattributes file, use it
attributesPath = strings.TrimSpace(string(output))
if verbose {
fmt.Printf("Using existing configured global gitattributes file: %s\n", attributesPath)
}
} else {
// No global gitattributes file configured, check if standard location exists
configDir := filepath.Join(homeDir, ".config", "git")
standardAttributesPath := filepath.Join(configDir, "attributes")
if _, err := os.Stat(standardAttributesPath); err == nil {
// Standard global gitattributes file exists, use it
// No need to set git config - git automatically uses this standard location
attributesPath = standardAttributesPath
if verbose {
fmt.Printf("Using existing global gitattributes file: %s\n", attributesPath)
}
} else {
// No global gitattributes file exists, create one in standard location
// No need to set git config - git automatically uses this standard location
attributesPath = standardAttributesPath
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create git config directory: %w", err)
}
if verbose {
fmt.Printf("Creating new global gitattributes file: %s\n", attributesPath)
}
}
}
// Read existing attributes file if it exists
var existingContent string
if content, err := os.ReadFile(attributesPath); err == nil {
existingContent = string(content)
}
// Check if beads merge attribute already exists
beadsPattern := "**/.beads/issues.jsonl merge=beads"
if strings.Contains(existingContent, beadsPattern) {
if verbose {
fmt.Printf("Global gitattributes already configured for beads\n")
}
return nil
}
// Append beads configuration
newContent := existingContent
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
newContent += "\n"
}
newContent += "\n# Beads merge configuration (added by bd init --stealth)\n"
newContent += beadsPattern + "\n"
// Write the updated attributes file
if err := os.WriteFile(attributesPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write global gitattributes: %w", err)
}
// Configure the beads merge driver
cmd = exec.Command("git", "config", "--global", "merge.beads.driver", "bd merge %A %O %A %B")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to configure beads merge driver: %w\n%s", err, output)
}
cmd = exec.Command("git", "config", "--global", "merge.beads.name", "bd JSONL merge driver")
if output, err := cmd.CombinedOutput(); err != nil {
// Non-fatal, the name is just descriptive
if verbose {
fmt.Fprintf(os.Stderr, "Warning: failed to set merge driver name: %v\n%s", err, output)
}
}
if verbose {
fmt.Printf("Configured global gitattributes for beads merge\n")
}
return nil
}
// setupGlobalGitIgnore configures global gitignore to ignore beads and claude files
func setupGlobalGitIgnore(homeDir string, verbose bool) error {
// Check if user already has a global gitignore file configured
cmd := exec.Command("git", "config", "--global", "core.excludesfile")
output, err := cmd.Output()
var ignorePath string
if err == nil && len(output) > 0 {
// User has already configured a global gitignore file, use it
ignorePath = strings.TrimSpace(string(output))
if verbose {
fmt.Printf("Using existing configured global gitignore file: %s\n", ignorePath)
}
} else {
// No global gitignore file configured, check if standard location exists
configDir := filepath.Join(homeDir, ".config", "git")
standardIgnorePath := filepath.Join(configDir, "ignore")
if _, err := os.Stat(standardIgnorePath); err == nil {
// Standard global gitignore file exists, use it
// No need to set git config - git automatically uses this standard location
ignorePath = standardIgnorePath
if verbose {
fmt.Printf("Using existing global gitignore file: %s\n", ignorePath)
}
} else {
// No global gitignore file exists, create one in standard location
// No need to set git config - git automatically uses this standard location
ignorePath = standardIgnorePath
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create git config directory: %w", err)
}
if verbose {
fmt.Printf("Creating new global gitignore file: %s\n", ignorePath)
}
}
}
// Read existing ignore file if it exists
var existingContent string
if content, err := os.ReadFile(ignorePath); err == nil {
existingContent = string(content)
}
// Check if beads patterns already exist
beadsPattern := "**/.beads/"
claudePattern := "**/.claude/settings.local.json"
hasBeads := strings.Contains(existingContent, beadsPattern)
hasClaude := strings.Contains(existingContent, claudePattern)
if hasBeads && hasClaude {
if verbose {
fmt.Printf("Global gitignore already configured for stealth mode\n")
}
return nil
}
// Append missing patterns
newContent := existingContent
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
newContent += "\n"
}
if !hasBeads || !hasClaude {
newContent += "\n# Beads stealth mode configuration (added by bd init --stealth)\n"
}
if !hasBeads {
newContent += beadsPattern + "\n"
}
if !hasClaude {
newContent += claudePattern + "\n"
}
// Write the updated ignore file
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write global gitignore: %w", err)
}
if verbose {
fmt.Printf("Configured global gitignore for stealth mode\n")
}
return nil
}
// setupClaudeSettings creates or updates .claude/settings.local.json with onboard instruction
func setupClaudeSettings(verbose bool) error {
claudeDir := ".claude"
settingsPath := filepath.Join(claudeDir, "settings.local.json")
// Create .claude directory if it doesn't exist
if err := os.MkdirAll(claudeDir, 0755); err != nil {
return fmt.Errorf("failed to create .claude directory: %w", err)
}
// Check if settings.local.json already exists
var existingSettings map[string]interface{}
if content, err := os.ReadFile(settingsPath); err == nil {
if err := json.Unmarshal(content, &existingSettings); err != nil {
existingSettings = make(map[string]interface{})
}
} else {
existingSettings = make(map[string]interface{})
}
// Add or update the prompt with onboard instruction
onboardPrompt := "Before starting any work, run 'bd onboard' to understand the current project state and available issues."
// Check if prompt already contains onboard instruction
if promptValue, exists := existingSettings["prompt"]; exists {
if promptStr, ok := promptValue.(string); ok {
if strings.Contains(promptStr, "bd onboard") {
if verbose {
fmt.Printf("Claude settings already configured with bd onboard instruction\n")
}
return nil
}
// Update existing prompt to include onboard instruction
existingSettings["prompt"] = promptStr + "\n\n" + onboardPrompt
} else {
// Existing prompt is not a string, replace it
existingSettings["prompt"] = onboardPrompt
}
} else {
// Add new prompt with onboard instruction
existingSettings["prompt"] = onboardPrompt
}
// Write updated settings
updatedContent, err := json.MarshalIndent(existingSettings, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal settings JSON: %w", err)
}
if err := os.WriteFile(settingsPath, updatedContent, 0644); err != nil {
return fmt.Errorf("failed to write claude settings: %w", err)
}
if verbose {
fmt.Printf("Configured Claude settings with bd onboard instruction\n")
}
return nil
}