Skip to content

Instantly share code, notes, and snippets.

@andreabalducci
Last active January 30, 2026 09:31
Show Gist options
  • Select an option

  • Save andreabalducci/ace9291b65869d7be938f7e587b1ffa6 to your computer and use it in GitHub Desktop.

Select an option

Save andreabalducci/ace9291b65869d7be938f7e587b1ffa6 to your computer and use it in GitHub Desktop.
bd-vibekanban: Bidirectional sync between Beads issue tracker and VibeKanban kanban board
#!/bin/bash
# bd-vibekanban - VibeKanban integration for Beads
# Bidirectional sync between Beads issue tracker and VibeKanban kanban board
#
# Usage: bd-vibekanban sync [--pull|--push|--dry-run|--prefer-local]
# bd-vibekanban status
# bd-vibekanban config
# bd-vibekanban help
set -euo pipefail
# Find script directory and source library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Source the library
if [[ -f "$SCRIPT_DIR/bd-vibekanban-lib.sh" ]]; then
source "$SCRIPT_DIR/bd-vibekanban-lib.sh"
else
echo "Error: bd-vibekanban-lib.sh not found in $SCRIPT_DIR" >&2
exit 1
fi
# Load configuration
# Priority: environment variables > bd config > defaults
load_config() {
# URL configuration
if [[ -z "${VK_URL:-}" ]]; then
VK_URL=$(bd config get vibekanban.url 2>/dev/null || echo "http://localhost:8090")
fi
# Project ID configuration
if [[ -z "${VK_PROJECT:-}" ]]; then
VK_PROJECT=$(bd config get vibekanban.project_id 2>/dev/null || echo "")
fi
# Mapping file location (relative to repo root)
MAPPING_FILE="$REPO_ROOT/.beads/vk-mapping.json"
# Validate required config
if [[ -z "$VK_PROJECT" ]]; then
log_error "VibeKanban project ID not configured"
echo ""
echo "Configure with:"
echo " bd config set vibekanban.project_id <uuid>"
echo ""
echo "Or set environment variable:"
echo " export VK_PROJECT=<uuid>"
echo ""
echo "Find your project ID:"
echo " curl -s $VK_URL/api/projects | jq '.data[] | {id, name}'"
exit 1
fi
# Initialize mapping file
init_mapping_file
export VK_URL VK_PROJECT MAPPING_FILE
}
# Main entry point
main() {
local cmd="${1:-help}"
shift || true
# Commands that don't need full config
case "$cmd" in
help|--help|-h)
show_help
exit 0
;;
config)
show_config
exit 0
;;
esac
# Load config for other commands
load_config
case "$cmd" in
sync)
do_sync "$@"
;;
status)
show_status
;;
*)
log_error "Unknown command: $cmd"
echo ""
show_help
exit 1
;;
esac
}
# Run main
main "$@"
#!/bin/bash
# bd-vibekanban-lib.sh - Shared functions for Beads ↔ VibeKanban sync
# Source this file from bd-vibekanban
set -euo pipefail
# Configuration defaults
VK_URL="${VK_URL:-http://localhost:8090}"
VK_PROJECT="${VK_PROJECT:-}"
MAPPING_FILE="${MAPPING_FILE:-.beads/vk-mapping.json}"
DRY_RUN="${DRY_RUN:-false}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging helpers
log_info() { echo -e "${BLUE}ℹ${NC} $*"; }
log_success() { echo -e "${GREEN}✓${NC} $*"; }
log_warn() { echo -e "${YELLOW}⚠${NC} $*"; }
log_error() { echo -e "${RED}✗${NC} $*" >&2; }
# ============================================================================
# Status Mapping Functions
# ============================================================================
# Map Beads status to VibeKanban status
map_beads_to_vk() {
local beads_status="$1"
case "$beads_status" in
open) echo "todo" ;;
in_progress) echo "inprogress" ;;
blocked) echo "todo" ;;
deferred) echo "cancelled" ;;
closed) echo "done" ;;
*) echo "todo" ;;
esac
}
# Map VibeKanban status to Beads status
map_vk_to_beads() {
local vk_status="$1"
case "$vk_status" in
todo) echo "open" ;;
inprogress) echo "in_progress" ;;
inreview) echo "in_progress" ;;
done) echo "closed" ;;
cancelled) echo "deferred" ;;
*) echo "open" ;;
esac
}
# Map Beads priority (0-4) to VibeKanban priority (string or keep numeric)
# VK seems to not have priority in API, so we skip for now
map_beads_priority_to_vk() {
local priority="$1"
case "$priority" in
0) echo "critical" ;;
1) echo "high" ;;
2) echo "medium" ;;
3) echo "low" ;;
4) echo "backlog" ;;
*) echo "medium" ;;
esac
}
# ============================================================================
# ID Mapping Functions
# ============================================================================
# Initialize mapping file if it doesn't exist
init_mapping_file() {
if [[ ! -f "$MAPPING_FILE" ]]; then
echo "{}" > "$MAPPING_FILE"
log_info "Created mapping file: $MAPPING_FILE"
fi
}
# Get VibeKanban ID for a Beads issue
get_vk_id() {
local beads_id="$1"
if [[ ! -f "$MAPPING_FILE" ]]; then
return 1
fi
jq -r --arg id "$beads_id" '.beads_to_vk[$id] // empty' "$MAPPING_FILE"
}
# Get Beads ID for a VibeKanban task
get_beads_id() {
local vk_id="$1"
if [[ ! -f "$MAPPING_FILE" ]]; then
return 1
fi
jq -r --arg id "$vk_id" '.vk_to_beads[$id] // empty' "$MAPPING_FILE"
}
# Set bidirectional mapping between Beads ID and VK ID
set_mapping() {
local beads_id="$1"
local vk_id="$2"
local tmp
tmp=$(mktemp)
jq --arg bid "$beads_id" --arg vid "$vk_id" \
'.beads_to_vk[$bid] = $vid | .vk_to_beads[$vid] = $bid' \
"$MAPPING_FILE" > "$tmp"
mv "$tmp" "$MAPPING_FILE"
}
# Remove mapping for a Beads ID
remove_mapping_by_beads() {
local beads_id="$1"
local vk_id
vk_id=$(get_vk_id "$beads_id")
if [[ -n "$vk_id" ]]; then
local tmp
tmp=$(mktemp)
jq --arg bid "$beads_id" --arg vid "$vk_id" \
'del(.beads_to_vk[$bid]) | del(.vk_to_beads[$vid])' \
"$MAPPING_FILE" > "$tmp"
mv "$tmp" "$MAPPING_FILE"
fi
}
# ============================================================================
# VibeKanban API Functions
# ============================================================================
# Check VK API availability
check_vk_api() {
if ! curl -sf "$VK_URL/api/projects" > /dev/null 2>&1; then
log_error "VibeKanban API not available at $VK_URL"
return 1
fi
}
# Get all tasks from VibeKanban for the configured project
vk_get_tasks() {
curl -sf "$VK_URL/api/tasks?project_id=$VK_PROJECT" | jq -c '.data // []'
}
# Get a single task by ID
vk_get_task() {
local task_id="$1"
curl -sf "$VK_URL/api/tasks/$task_id" | jq -c '.data // empty'
}
# Create a task in VibeKanban
# Returns the created task's ID
# In dry-run mode, returns empty string (caller should check DRY_RUN)
vk_create_task() {
local title="$1"
local description="${2:-}"
local status="${3:-todo}"
local payload
payload=$(jq -n \
--arg pid "$VK_PROJECT" \
--arg title "$title" \
--arg desc "$description" \
--arg status "$status" \
'{project_id: $pid, title: $title, description: $desc, status: $status}')
if [[ "$DRY_RUN" == "true" ]]; then
# Don't echo anything - caller handles dry-run logging
return 0
fi
local response
response=$(curl -sf -X POST "$VK_URL/api/tasks" \
-H "Content-Type: application/json" \
-d "$payload")
echo "$response" | jq -r '.data.id // empty'
}
# Update a task in VibeKanban
vk_update_task() {
local task_id="$1"
local title="${2:-}"
local description="${3:-}"
local status="${4:-}"
# Build payload with only non-empty fields
local payload="{}"
if [[ -n "$title" ]]; then
payload=$(echo "$payload" | jq --arg v "$title" '. + {title: $v}')
fi
if [[ -n "$description" ]]; then
payload=$(echo "$payload" | jq --arg v "$description" '. + {description: $v}')
fi
if [[ -n "$status" ]]; then
payload=$(echo "$payload" | jq --arg v "$status" '. + {status: $v}')
fi
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would update task $task_id: $payload"
return 0
fi
curl -sf -X PUT "$VK_URL/api/tasks/$task_id" \
-H "Content-Type: application/json" \
-d "$payload" > /dev/null
}
# Delete a task in VibeKanban
vk_delete_task() {
local task_id="$1"
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would delete task $task_id"
return 0
fi
curl -sf -X DELETE "$VK_URL/api/tasks/$task_id" > /dev/null
}
# ============================================================================
# Dependency Awareness Functions
# ============================================================================
# Get newline-separated list of blocked issue IDs
get_blocked_ids() {
bd blocked --json 2>/dev/null | jq -r '.[].id' 2>/dev/null || echo ""
}
# Get newline-separated list of ready-to-work issue IDs
get_ready_ids() {
bd ready --json --limit 0 2>/dev/null | jq -r '.[].id' 2>/dev/null || echo ""
}
# Check if an ID appears in a newline-separated list
id_in_list() {
local id="$1"
local list="$2"
[[ -n "$list" ]] && echo "$list" | grep -qx "$id" || return 1
}
# Get the dependency tag prefix for a task
# [BLOCKED] = has unresolved dependencies
# [READY] = open, no blockers
# (empty) = in progress, closed, or deferred
get_dep_tag() {
local beads_id="$1"
local blocked_ids="$2"
local ready_ids="$3"
if id_in_list "$beads_id" "$blocked_ids"; then
echo "[BLOCKED] "
elif id_in_list "$beads_id" "$ready_ids"; then
echo "[READY] "
else
echo ""
fi
}
# Build enriched description with dependency info appended
# Appends a "Blocked by" section if the issue has unresolved blockers
enrich_description() {
local beads_id="$1"
local desc="$2"
local blocked_json="$3"
local blockers
blockers=$(echo "$blocked_json" | jq -r --arg id "$beads_id" \
'.[] | select(.id == $id) | .blocked_by[]' 2>/dev/null || echo "")
if [[ -n "$blockers" ]]; then
local dep_lines=""
while IFS= read -r blocker_id; do
if [[ -n "$blocker_id" ]]; then
dep_lines="${dep_lines}\n- ${blocker_id}"
fi
done <<< "$blockers"
printf '%s\n\n---\nBLOCKED by:%b' "$desc" "$dep_lines"
else
echo "$desc"
fi
}
# ============================================================================
# Sync Functions
# ============================================================================
# Push Beads issues to VibeKanban
# Args: [--all] to include closed/deferred issues
do_push() {
local include_all=false
if [[ "${1:-}" == "--all" ]]; then
include_all=true
shift
fi
local created=0
local updated=0
local skipped=0
if [[ "$include_all" == "true" ]]; then
log_info "Pushing ALL Beads issues → VibeKanban (including closed)..."
else
log_info "Pushing Beads → VibeKanban..."
fi
# Pre-fetch dependency data for emoji and description enrichment
log_info "Fetching dependency data..."
local blocked_ids ready_ids blocked_json
blocked_ids=$(get_blocked_ids)
ready_ids=$(get_ready_ids)
blocked_json=$(bd blocked --json 2>/dev/null || echo "[]")
# Get all issues (bd list without --all returns open only by default)
local issues
if [[ "$include_all" == "true" ]]; then
# Use bd list --all to include closed/deferred issues, --limit 0 for unlimited
issues=$(bd list --json --all --limit 0 2>/dev/null || echo "[]")
else
issues=$(bd list --json --limit 0 2>/dev/null || echo "[]")
fi
echo "$issues" | jq -c '.[]' | while read -r issue; do
local beads_id title desc status priority issue_type
beads_id=$(echo "$issue" | jq -r '.id')
title=$(echo "$issue" | jq -r '.title')
desc=$(echo "$issue" | jq -r '.description // ""')
status=$(echo "$issue" | jq -r '.status')
priority=$(echo "$issue" | jq -r '.priority // 2')
issue_type=$(echo "$issue" | jq -r '.issue_type // "task"')
# Skip closed and deferred unless --all is specified
if [[ "$include_all" != "true" ]] && [[ "$status" == "closed" || "$status" == "deferred" ]]; then
continue
fi
local vk_status
vk_status=$(map_beads_to_vk "$status")
# Determine dependency tag prefix
local dep_tag
dep_tag=$(get_dep_tag "$beads_id" "$blocked_ids" "$ready_ids")
# Build title with dep tag + beads ID prefix + issue type
local full_title
if [[ "$issue_type" != "task" ]]; then
full_title="${dep_tag}[$beads_id] [$issue_type] $title"
else
full_title="${dep_tag}[$beads_id] $title"
fi
# Enrich description with dependency info
local enriched_desc
enriched_desc=$(enrich_description "$beads_id" "$desc" "$blocked_json")
# Check if already mapped
local vk_id
vk_id=$(get_vk_id "$beads_id" || echo "")
if [[ -z "$vk_id" ]]; then
# Create new task in VK
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would create: $beads_id → VK task '$full_title' (status: $vk_status)"
((created++)) || true
else
vk_id=$(vk_create_task "$full_title" "$enriched_desc" "$vk_status")
if [[ -n "$vk_id" ]]; then
set_mapping "$beads_id" "$vk_id"
log_success "Created: $beads_id → $vk_id"
((created++)) || true
else
log_error "Failed to create task for $beads_id"
fi
fi
else
# Update existing task
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would update: $beads_id → $vk_id (status: $vk_status)"
((updated++)) || true
elif vk_update_task "$vk_id" "$full_title" "$enriched_desc" "$vk_status"; then
log_success "Updated: $beads_id → $vk_id"
((updated++)) || true
else
log_error "Failed to update task $vk_id for $beads_id"
fi
fi
done
echo ""
log_info "Push complete: $created created, $updated updated, $skipped skipped"
}
# Pull VibeKanban tasks to Beads (status sync only)
do_pull() {
local updated=0
local skipped=0
log_info "Pulling VibeKanban → Beads (status sync only)..."
local tasks
tasks=$(vk_get_tasks)
echo "$tasks" | jq -c '.[]' | while read -r task; do
local vk_id vk_status vk_title
vk_id=$(echo "$task" | jq -r '.id')
vk_status=$(echo "$task" | jq -r '.status')
vk_title=$(echo "$task" | jq -r '.title')
# Get corresponding Beads ID
local beads_id
beads_id=$(get_beads_id "$vk_id" || echo "")
if [[ -z "$beads_id" ]]; then
log_warn "Skipped: VK task '$vk_title' ($vk_id) - no Beads mapping"
((skipped++)) || true
continue
fi
# Get current Beads status (bd show returns array)
local current_status beads_status
current_status=$(bd show "$beads_id" --json 2>/dev/null | jq -r '.[0].status // empty' || echo "")
beads_status=$(map_vk_to_beads "$vk_status")
if [[ -z "$current_status" ]]; then
log_warn "Skipped: Beads issue $beads_id not found"
((skipped++)) || true
continue
fi
# Update if status differs
if [[ "$current_status" != "$beads_status" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Would update $beads_id: $current_status → $beads_status"
else
# Handle special case: VK "done" should close the issue using bd close
if [[ "$beads_status" == "closed" ]]; then
bd close "$beads_id" 2>/dev/null || true
log_success "Closed: $beads_id (was $current_status, VK is done)"
else
bd update "$beads_id" --status "$beads_status" 2>/dev/null || true
log_success "Updated: $beads_id status $current_status → $beads_status"
fi
fi
((updated++)) || true
fi
done
echo ""
log_info "Pull complete: $updated updated, $skipped skipped"
}
# Bidirectional sync
do_sync() {
local mode="bidirectional"
local prefer_local=false
local include_all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--pull)
mode="pull"
;;
--push)
mode="push"
;;
--dry-run)
DRY_RUN="true"
log_warn "DRY-RUN mode enabled - no changes will be made"
;;
--prefer-local)
prefer_local=true
;;
--all)
include_all=true
;;
*)
log_error "Unknown option: $1"
return 1
;;
esac
shift
done
local push_args=""
if [[ "$include_all" == "true" ]]; then
push_args="--all"
fi
case "$mode" in
pull)
do_pull
;;
push)
do_push $push_args
;;
bidirectional)
if [[ "$prefer_local" == "true" ]]; then
log_info "Prefer-local mode: pushing first, then pulling"
do_push $push_args
echo ""
do_pull
else
log_info "Bidirectional sync: pulling first, then pushing"
do_pull
echo ""
do_push $push_args
fi
;;
esac
}
# ============================================================================
# Status and Config Functions
# ============================================================================
show_status() {
echo "=== Beads ↔ VibeKanban Sync Status ==="
echo ""
echo "Configuration:"
echo " VK URL: $VK_URL"
echo " VK Project: ${VK_PROJECT:-<not set>}"
echo " Mapping: $MAPPING_FILE"
echo ""
# Check API
if check_vk_api 2>/dev/null; then
log_success "VibeKanban API is reachable"
else
log_error "VibeKanban API is not reachable"
fi
# Count mappings
if [[ -f "$MAPPING_FILE" ]]; then
local count
count=$(jq '.beads_to_vk | length' "$MAPPING_FILE" 2>/dev/null || echo "0")
echo " Mapped issues: $count"
else
echo " Mapped issues: 0 (no mapping file)"
fi
echo ""
echo "Beads open issues: $(bd list --status open --json 2>/dev/null | jq 'length' || echo "?")"
if [[ -n "$VK_PROJECT" ]]; then
echo "VK tasks: $(vk_get_tasks 2>/dev/null | jq 'length' || echo "?")"
fi
}
show_config() {
echo "=== Beads ↔ VibeKanban Configuration ==="
echo ""
echo "Current settings:"
echo " vibekanban.url: $(bd config get vibekanban.url 2>/dev/null || echo '<not set>')"
echo " vibekanban.project_id: $(bd config get vibekanban.project_id 2>/dev/null || echo '<not set>')"
echo ""
echo "To configure:"
echo " bd config set vibekanban.url http://localhost:8090"
echo " bd config set vibekanban.project_id <uuid>"
echo ""
echo "Or use environment variables:"
echo " export VK_URL=http://localhost:8090"
echo " export VK_PROJECT=<uuid>"
}
show_help() {
cat << 'EOF'
bd-vibekanban - Bidirectional sync between Beads and VibeKanban
USAGE:
bd-vibekanban <command> [options]
COMMANDS:
sync [options] Synchronize issues between Beads and VibeKanban
status Show sync status and configuration
config Show configuration help
help Show this help message
SYNC OPTIONS:
--push Push Beads → VibeKanban only
--pull Pull VibeKanban → Beads only (status sync)
--all Include closed/deferred issues in push
--dry-run Preview changes without applying them
--prefer-local On bidirectional sync, push before pull
CONFIGURATION:
Set via bd config:
bd config set vibekanban.url http://localhost:8090
bd config set vibekanban.project_id <project-uuid>
Or via environment variables:
VK_URL=http://localhost:8090
VK_PROJECT=<project-uuid>
EXAMPLES:
# Initial push of all open issues
bd-vibekanban sync --push
# Pull status changes from VibeKanban
bd-vibekanban sync --pull
# Bidirectional sync
bd-vibekanban sync
# Preview what would change
bd-vibekanban sync --dry-run
STATUS MAPPING:
Beads VibeKanban
─────────────────────────────
open todo
in_progress inprogress
blocked todo (preserved in beads)
deferred cancelled
closed done
DEPENDENCY INDICATORS:
Push adds visual dependency indicators to VibeKanban tasks:
Title tag:
[BLOCKED] Task has unresolved dependencies
[READY] Task is ready to work (open, no blockers)
(none) Task is in progress, closed, or deferred
Description:
Blocked tasks get a "BLOCKED by" section appended
with the IDs of blocking issues.
NOTES:
- Beads is the source of truth for content (title, description, type)
- VibeKanban status changes are synced back to Beads
- ID mappings are stored in .beads/vk-mapping.json
- Blocked issues appear as 'todo' in VK but retain 'blocked' in Beads
- Dependency indicators refresh on each push
EOF
}

bd-vibekanban: Beads <> VibeKanban Bidirectional Sync

Bash scripts for bidirectional synchronization between Beads (CLI-first issue tracker) and VibeKanban (kanban board).

Beads is the source of truth for content (titles, descriptions, types). VibeKanban status changes are synced back to Beads.

Files

File Purpose
bd-vibekanban Main entry point (executable command)
bd-vibekanban-lib.sh Shared functions: API calls, status mapping, ID tracking

Place both files in the same directory (e.g. scripts/) and make the main script executable:

chmod +x bd-vibekanban

Prerequisites

  • Beads (bd) CLI installed
  • jq for JSON processing
  • curl for API calls
  • A running VibeKanban instance with API access

Configuration

Set via bd config:

bd config set vibekanban.url http://localhost:8090
bd config set vibekanban.project_id <your-project-uuid>

Or via environment variables:

export VK_URL=http://localhost:8090
export VK_PROJECT=<your-project-uuid>

To find your project ID:

curl -s http://localhost:8090/api/projects | jq '.data[] | {id, name}'

Usage

# Push all open Beads issues to VibeKanban
bd-vibekanban sync --push

# Pull status changes from VibeKanban back to Beads
bd-vibekanban sync --pull

# Full bidirectional sync (pull first, then push)
bd-vibekanban sync

# Push first, then pull (prefer local state on conflicts)
bd-vibekanban sync --prefer-local

# Include closed/deferred issues in push
bd-vibekanban sync --push --all

# Preview changes without applying them
bd-vibekanban sync --dry-run

# Check sync status and connectivity
bd-vibekanban status

# Show configuration help
bd-vibekanban config

Status Mapping

Beads VibeKanban Notes
open todo
in_progress inprogress
blocked todo Preserved as blocked in Beads on pull
deferred cancelled
closed done
(n/a) inreview Mapped to in_progress in Beads

Field Coverage

VibeKanban's task model is intentionally minimal. Here's what the sync covers:

Field In VK Synced Notes
title yes yes Prefixed with Beads ID on push
description yes yes
status yes yes Bidirectional, see mapping above
project_id yes yes Set via config
parent_workspace_id yes no VK workspace hierarchy, no Beads equivalent
image_ids yes no VK screenshot attachments, no Beads equivalent
priority no n/a VK has no priority field
tags no n/a VK tags are reusable text templates, not task labels
assignee no n/a VK has no assignee field on tasks

All meaningful fields are synced. The two unsupported fields (parent_workspace_id, image_ids) are VK-internal concepts that don't map to Beads.

How It Works

  1. Push (Beads -> VibeKanban): Reads all Beads issues via bd list --json, creates or updates corresponding VibeKanban tasks via the REST API. Titles are prefixed with the Beads ID (e.g. [beads-abc123] Fix login bug). Issue type is included when not a plain task (e.g. [beads-abc123] [feature] Add export).

  2. Pull (VibeKanban -> Beads): Fetches all VibeKanban tasks, looks up mapped Beads issues, and syncs status changes back. Only status is synced from VK to Beads (content flows one-way from Beads).

  3. ID Mapping: A bidirectional mapping file (.beads/vk-mapping.json) tracks which Beads issue corresponds to which VibeKanban task. This file is stored in the repo and synced via Git.

API Compatibility Note

The VK REST routes nest task endpoints under /projects/:project_id/tasks/. These scripts use flat /api/tasks?project_id=... endpoints. If you encounter 404s, the URL structure may need adjusting to match your VK version.

License

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment