main
   1#!/usr/bin/env bash
   2# org-manager - CLI tool for org-mode file manipulation via Emacs batch mode
   3# Copyright (C) 2026 Vincent Demeester
   4# Loads elisp from site-lisp for consistency with interactive Emacs
   5
   6set -euo pipefail
   7
   8# Configuration
   9EMACS="${EMACS:-emacs}"
  10EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
  11SITE_LISP="$EMACS_DIR/site-lisp"
  12
  13# Debug mode
  14DEBUG="${DEBUG:-0}"
  15
  16# Colors for output (if not outputting JSON)
  17if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
  18    RED='\033[0;31m'
  19    YELLOW='\033[1;33m'
  20    NC='\033[0m' # No Color
  21else
  22    RED=''
  23    YELLOW=''
  24    NC=''
  25fi
  26
  27# Error handling
  28error() {
  29    echo -e "${RED}Error: $*${NC}" >&2
  30    exit 1
  31}
  32
  33debug() {
  34    if [[ "$DEBUG" == "1" ]]; then
  35        echo -e "${YELLOW}Debug: $*${NC}" >&2
  36    fi
  37}
  38
  39# Check dependencies
  40check_deps() {
  41    if ! command -v "$EMACS" &> /dev/null; then
  42        error "Emacs not found. Set EMACS environment variable or install emacs."
  43    fi
  44
  45    if [[ ! -d "$SITE_LISP" ]]; then
  46        error "Emacs site-lisp directory not found at: $SITE_LISP"
  47    fi
  48
  49    if [[ ! -f "$SITE_LISP/org-batch-functions.el" ]]; then
  50        error "org-batch-functions.el not found in site-lisp"
  51    fi
  52}
  53
  54# Run Emacs in batch mode
  55run_elisp() {
  56    local elisp_code="$1"
  57
  58    debug "Running elisp: $elisp_code"
  59
  60    if [[ "$DEBUG" == "1" ]]; then
  61        "$EMACS" --batch --no-init-file \
  62            --directory "$SITE_LISP" \
  63            --load org-batch-functions.el \
  64            --eval "$elisp_code" 2>&1
  65    else
  66        "$EMACS" --batch --no-init-file \
  67            --directory "$SITE_LISP" \
  68            --load org-batch-functions.el \
  69            --eval "$elisp_code" 2>/dev/null
  70    fi
  71}
  72
  73# Usage information
  74usage() {
  75    cat <<EOF
  76org-manager - Org-mode file manipulation tool
  77
  78Usage: org-manager <command> <file> [options]
  79
  80READ COMMANDS:
  81  list <file> [--state=STATE] [--priority=N] [--tags=TAG1,TAG2]
  82      List TODO items with optional filters
  83
  84  scheduled <file> [--date=YYYY-MM-DD|today]
  85      Get items scheduled for date (default: today)
  86
  87  count <file> [--state=STATE]
  88      Count TODO items by state
  89
  90  search <file> <term> [--with-content]
  91      Search for term in file
  92      Use --with-content to include full TODO content and properties in results
  93
  94  by-section <file> <section>
  95      Get TODOs in specific section
  96
  97  sections <file>
  98      List all top-level sections
  99
 100  children <file> <heading>
 101      Get direct children of a specific heading
 102
 103  get <file> <heading>
 104      Get full content and metadata of a specific TODO
 105      Returns heading, state, priority, tags, scheduled, deadline, properties, and body content
 106
 107  overdue <file>
 108      Get all tasks with deadlines before today
 109      Only shows active TODOs (excludes DONE/CANX)
 110
 111  upcoming <file> [--days=N]
 112      Get tasks scheduled or due in next N days (default: 7)
 113      Shows tasks with SCHEDULED or DEADLINE dates in range
 114
 115WRITE COMMANDS:
 116  add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
 117      Add new TODO item
 118
 119  append-content <file> <heading> <content-file>
 120      Append content from file to existing TODO
 121      Content should be in org-mode format (not markdown)
 122      Adds content after properties/scheduling, before subheadings
 123      Automatically adjusts heading levels (# or * → relative to parent)
 124
 125  update-state <file> <heading> <new-state>
 126      Change TODO state (NEXT, STRT, TODO, WAIT, DONE, CANX)
 127
 128  schedule <file> <heading> <date>
 129      Set SCHEDULED date (YYYY-MM-DD)
 130
 131  deadline <file> <heading> <date>
 132      Set DEADLINE date (YYYY-MM-DD)
 133
 134  priority <file> <heading> <priority>
 135      Set priority (1-5)
 136
 137  archive <file>
 138      Archive all DONE and CANX items
 139
 140TAG MANAGEMENT:
 141  add-tags <file> <heading> <tags>
 142      Add tags to existing TODO (comma-separated, e.g., work,urgent)
 143
 144  remove-tags <file> <heading> <tags>
 145      Remove specific tags from TODO (comma-separated)
 146
 147  replace-tags <file> <heading> <tags>
 148      Replace all tags on TODO with new set (comma-separated)
 149
 150  list-tags <file>
 151      List all unique tags used in file
 152
 153PROPERTY OPERATIONS:
 154  get-property <file> <heading> <property>
 155      Get value of a specific property
 156
 157  set-property <file> <heading> <property> <value>
 158      Set property value
 159
 160  list-properties <file> <heading>
 161      List all properties of a heading
 162
 163BULK OPERATIONS:
 164  bulk-update-state <file> <filter-state> <new-state> [filter-tags]
 165      Update all tasks matching filter-state to new-state
 166      Optional: filter by tags (comma-separated)
 167      Example: org-manager bulk-update-state todos.org TODO DONE work,urgent
 168
 169  bulk-add-tags <file> <filter-state> <tags>
 170      Add tags to all tasks with filter-state
 171      Tags: comma-separated list
 172      Example: org-manager bulk-add-tags todos.org NEXT urgent,review
 173
 174  bulk-set-priority <file> <filter-state> <priority>
 175      Set priority for all tasks with filter-state
 176      Priority: 1-5 (1=highest, 5=lowest)
 177      Example: org-manager bulk-set-priority todos.org TODO 1
 178
 179TIME TRACKING:
 180  clock-in <file> <heading>
 181      Start time tracking on a task
 182      Creates CLOCK entry in :LOGBOOK: drawer
 183
 184  clock-out <file>
 185      Stop time tracking on currently clocked task
 186      Records total time spent
 187
 188  get-active-clock <file>
 189      Get currently clocked task (if any)
 190      Returns heading and clock-in time
 191
 192  get-clocked-time <file> <heading>
 193      Get total time spent on a task (in minutes)
 194      Sums all CLOCK entries for the task
 195
 196STATISTICS & ANALYTICS:
 197  get-statistics <file>
 198      Get comprehensive statistics about all TODOs
 199      Returns counts by state, priority, tags, scheduled, overdue
 200
 201  get-priority-distribution <file>
 202      Get distribution of tasks across priorities (1-5)
 203      Shows how many tasks at each priority level
 204
 205  get-tag-statistics <file>
 206      Get tag usage statistics
 207      Returns list of tags with usage counts, sorted by frequency
 208
 209EXPORT & REPORTING:
 210  export-csv <file> <output-file>
 211      Export all TODOs to CSV format
 212      Creates spreadsheet-compatible file
 213
 214  export-json <file> <output-file>
 215      Export all TODOs to JSON format
 216      Creates machine-readable structured export
 217
 218RECURRING TASKS:
 219  set-repeater <file> <heading> <repeater-spec>
 220      Set repeater for a task (e.g., +1w for weekly, .+2d for 2 days after completion)
 221      Adds or updates SCHEDULED timestamp with repeater syntax
 222
 223  get-recurring-tasks <file>
 224      List all tasks with repeater specifications
 225      Shows tasks that recur automatically
 226
 227DEPENDENCIES & RELATIONSHIPS:
 228  set-blocker <file> <heading> <blocker-heading>
 229      Set a blocker for a task (task that must be completed first)
 230      Uses BLOCKER property to track dependency
 231
 232  get-blocker <file> <heading>
 233      Get the blocker for a specific task
 234      Returns the blocking task heading if set
 235
 236  get-blocked-tasks <file>
 237      List all tasks that have blockers set
 238      Shows tasks waiting on other tasks
 239
 240  set-related <file> <heading> <related-heading> <relation-type>
 241      Create relationship between tasks
 242      Relation types: child, parent, related, depends-on
 243      Uses RELATED_* properties
 244
 245  get-related <file> <heading>
 246      Get all relationships for a task
 247      Returns all RELATED_* properties
 248
 249OPTIONS:
 250  --state=STATE         Filter by TODO state
 251  --priority=N          Filter by priority (1-5) or list: 1,2
 252  --tags=TAG1,TAG2      Filter by tags (match any)
 253  --date=YYYY-MM-DD     Specific date (or 'today')
 254  --section=NAME        Section name for new items
 255  --scheduled=DATE      Schedule date for new items
 256
 257ENVIRONMENT:
 258  EMACS                 Path to emacs binary (default: emacs)
 259  BATCH_FUNCTIONS       Path to batch-functions.el
 260  DEBUG                 Enable debug output (1 or 0)
 261
 262EXAMPLES:
 263  # List NEXT tasks
 264  org-manager list ~/desktop/org/todos.org --state=NEXT
 265
 266  # Add high-priority task
 267  org-manager add ~/desktop/org/todos.org "Review PR" \\
 268    --section=Work --priority=2 --scheduled=2025-12-10
 269
 270  # Mark task done
 271  org-manager update-state ~/desktop/org/todos.org "Review PR" DONE
 272
 273  # Get today's schedule
 274  org-manager scheduled ~/desktop/org/todos.org
 275
 276  # Search for term in TODOs (basic mode - no content)
 277  org-manager search ~/desktop/org/todos.org "NixOS"
 278
 279  # Search with full content and properties
 280  org-manager search ~/desktop/org/todos.org "NixOS" --with-content
 281
 282  # Count by state
 283  org-manager count ~/desktop/org/todos.org
 284
 285  # Get children of a heading
 286  org-manager children ~/desktop/org/todos.org "Migrate aion"
 287
 288  # Get full content of a TODO
 289  org-manager get ~/desktop/org/todos.org "Review PR"
 290
 291OUTPUT:
 292  All commands return JSON for easy parsing:
 293  {"success": true, "data": [...]}
 294  {"success": false, "error": "message"}
 295
 296EXIT CODES:
 297  0    Success
 298  1    General error
 299  2    File not found
 300  3    Invalid arguments
 301
 302EOF
 303    exit 0
 304}
 305
 306# Parse arguments helper
 307parse_option() {
 308    local arg="$1"
 309    local prefix="$2"
 310    echo "${arg#"$prefix"}"
 311}
 312
 313# Commands
 314
 315cmd_list() {
 316    local file="$1"; shift
 317    local state="" priority="" tags=""
 318
 319    while [[ $# -gt 0 ]]; do
 320        case "$1" in
 321            --state=*)
 322                state=$(parse_option "$1" "--state=")
 323                shift
 324                ;;
 325            --priority=*)
 326                priority=$(parse_option "$1" "--priority=")
 327                shift
 328                ;;
 329            --tags=*)
 330                tags=$(parse_option "$1" "--tags=")
 331                shift
 332                ;;
 333            *)
 334                error "Unknown option: $1"
 335                ;;
 336        esac
 337    done
 338
 339    [[ -f "$file" ]] || error "File not found: $file"
 340
 341    local elisp
 342    elisp="(progn
 343      (let ((result (org-batch-list-todos \"$file\"
 344                      $([ -n "$state" ] && echo "\"$state\"" || echo "nil")
 345                      $([ -n "$priority" ] && echo "'($priority)" || echo "nil")
 346                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
 347        (org-batch-output-json t result))
 348      (kill-emacs 0))"
 349
 350    run_elisp "$elisp"
 351}
 352
 353cmd_scheduled() {
 354    local file="$1"; shift
 355    local date="today"
 356
 357    while [[ $# -gt 0 ]]; do
 358        case "$1" in
 359            --date=*)
 360                date=$(parse_option "$1" "--date=")
 361                shift
 362                ;;
 363            *)
 364                error "Unknown option: $1"
 365                ;;
 366        esac
 367    done
 368
 369    [[ -f "$file" ]] || error "File not found: $file"
 370
 371    local elisp="(progn
 372      (let ((result (org-batch-scheduled-today \"$file\" \"$date\")))
 373        (org-batch-output-json t result))
 374      (kill-emacs 0))"
 375
 376    run_elisp "$elisp"
 377}
 378
 379cmd_count() {
 380    local file="$1"; shift
 381
 382    [[ -f "$file" ]] || error "File not found: $file"
 383
 384    local elisp="(progn
 385      (let ((result (org-batch-count-by-state \"$file\")))
 386        (org-batch-output-json t result))
 387      (kill-emacs 0))"
 388
 389    run_elisp "$elisp"
 390}
 391
 392cmd_search() {
 393    local file="$1"
 394    local term="$2"; shift 2
 395    local with_content="nil"
 396
 397    [[ -f "$file" ]] || error "File not found: $file"
 398    [[ -n "$term" ]] || error "Search term required"
 399
 400    while [[ $# -gt 0 ]]; do
 401        case "$1" in
 402            --with-content)
 403                with_content="t"
 404                shift
 405                ;;
 406            *)
 407                error "Unknown option: $1"
 408                ;;
 409        esac
 410    done
 411
 412    local elisp="(progn
 413      (let ((result (org-batch-search \"$file\" \"$term\" $with_content)))
 414        (org-batch-output-json t result))
 415      (kill-emacs 0))"
 416
 417    run_elisp "$elisp"
 418}
 419
 420cmd_by_section() {
 421    local file="$1"
 422    local section="$2"
 423
 424    [[ -f "$file" ]] || error "File not found: $file"
 425    [[ -n "$section" ]] || error "Section name required"
 426
 427    local elisp="(progn
 428      (let ((result (org-batch-by-section \"$file\" \"$section\")))
 429        (org-batch-output-json t result))
 430      (kill-emacs 0))"
 431
 432    run_elisp "$elisp"
 433}
 434
 435cmd_sections() {
 436    local file="$1"
 437
 438    [[ -f "$file" ]] || error "File not found: $file"
 439
 440    local elisp="(progn
 441      (let ((result (org-batch-get-sections \"$file\")))
 442        (org-batch-output-json t result))
 443      (kill-emacs 0))"
 444
 445    run_elisp "$elisp"
 446}
 447
 448cmd_children() {
 449    local file="$1"
 450    local heading="$2"
 451
 452    [[ -f "$file" ]] || error "File not found: $file"
 453    [[ -n "$heading" ]] || error "Heading name required"
 454
 455    # Escape double quotes in heading for elisp string
 456    local heading_escaped="${heading//\"/\\\"}"
 457
 458    local elisp="(progn
 459      (let ((result (org-batch-get-children \"$file\" \"$heading_escaped\")))
 460        (org-batch-output-json t result))
 461      (kill-emacs 0))"
 462
 463    run_elisp "$elisp"
 464}
 465
 466cmd_get() {
 467    local file="$1"
 468    local heading="$2"
 469
 470    [[ -f "$file" ]] || error "File not found: $file"
 471    [[ -n "$heading" ]] || error "Heading name required"
 472
 473    # Escape double quotes in heading for elisp string
 474    local heading_escaped="${heading//\"/\\\"}"
 475
 476    local elisp="(progn
 477      (let ((result (org-batch-get-todo-content \"$file\" \"$heading_escaped\")))
 478        (if result
 479            (org-batch-output-json t result)
 480          (org-batch-output-error \"Heading not found: $heading\")))
 481      (kill-emacs 0))"
 482
 483    run_elisp "$elisp"
 484}
 485
 486cmd_overdue() {
 487    local file="$1"
 488
 489    [[ -f "$file" ]] || error "File not found: $file"
 490
 491    local elisp="(progn
 492      (let ((result (org-batch-get-overdue \"$file\")))
 493        (org-batch-output-json t result))
 494      (kill-emacs 0))"
 495
 496    run_elisp "$elisp"
 497}
 498
 499cmd_upcoming() {
 500    local file="$1"; shift
 501    local days=7
 502
 503    [[ -f "$file" ]] || error "File not found: $file"
 504
 505    while [[ $# -gt 0 ]]; do
 506        case "$1" in
 507            --days=*)
 508                days=$(parse_option "$1" "--days=")
 509                shift
 510                ;;
 511            *)
 512                error "Unknown option: $1"
 513                ;;
 514        esac
 515    done
 516
 517    local elisp="(progn
 518      (let ((result (org-batch-get-upcoming \"$file\" $days)))
 519        (org-batch-output-json t result))
 520      (kill-emacs 0))"
 521
 522    run_elisp "$elisp"
 523}
 524
 525cmd_add() {
 526    local file="$1"
 527    local heading="$2"; shift 2
 528    local section="" scheduled="" priority="" tags=""
 529
 530    [[ -f "$file" ]] || error "File not found: $file"
 531    [[ -n "$heading" ]] || error "Heading required"
 532
 533    while [[ $# -gt 0 ]]; do
 534        case "$1" in
 535            --section=*)
 536                section=$(parse_option "$1" "--section=")
 537                shift
 538                ;;
 539            --scheduled=*)
 540                scheduled=$(parse_option "$1" "--scheduled=")
 541                shift
 542                ;;
 543            --priority=*)
 544                priority=$(parse_option "$1" "--priority=")
 545                shift
 546                ;;
 547            --tags=*)
 548                tags=$(parse_option "$1" "--tags=")
 549                shift
 550                ;;
 551            *)
 552                error "Unknown option: $1"
 553                ;;
 554        esac
 555    done
 556
 557    [[ -n "$section" ]] || error "--section required"
 558
 559    local elisp
 560    elisp="(progn
 561      (let ((result (org-batch-add-todo \"$file\" \"$section\" \"$heading\"
 562                      $([ -n "$scheduled" ] && echo "\"$scheduled\"" || echo "nil")
 563                      $([ -n "$priority" ] && echo "$priority" || echo "nil")
 564                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
 565        (if result
 566            (org-batch-output-json t (list :added t :heading \"$heading\"))
 567          (org-batch-output-error \"Section not found: $section\")))
 568      (kill-emacs 0))"
 569
 570    run_elisp "$elisp"
 571}
 572
 573cmd_append_content() {
 574    local file="$1"
 575    local heading="$2"
 576    local content_file="$3"
 577
 578    [[ -f "$file" ]] || error "File not found: $file"
 579    [[ -n "$heading" ]] || error "Heading required"
 580    [[ -f "$content_file" ]] || error "Content file not found: $content_file"
 581
 582    # Read content from file
 583    local content
 584    content=$(<"$content_file")
 585
 586    # Escape quotes and backslashes for elisp
 587    content="${content//\\/\\\\}"
 588    content="${content//\"/\\\"}"
 589
 590    local elisp="(progn
 591      (let ((result (org-batch-append-content \"$file\" \"$heading\" \"$content\")))
 592        (if result
 593            (org-batch-output-json t (list :content-appended t :heading \"$heading\"))
 594          (org-batch-output-error \"Heading not found: $heading\")))
 595      (kill-emacs 0))"
 596
 597    run_elisp "$elisp"
 598}
 599
 600cmd_update_state() {
 601    local file="$1"
 602    local heading="$2"
 603    local new_state="$3"
 604
 605    [[ -f "$file" ]] || error "File not found: $file"
 606    [[ -n "$heading" ]] || error "Heading required"
 607    [[ -n "$new_state" ]] || error "New state required"
 608
 609    local elisp="(progn
 610      (let ((result (org-batch-update-state \"$file\" \"$heading\" \"$new_state\")))
 611        (if result
 612            (org-batch-output-json t (list :updated t :heading \"$heading\" :state \"$new_state\"))
 613          (org-batch-output-error \"Heading not found: $heading\")))
 614      (kill-emacs 0))"
 615
 616    run_elisp "$elisp"
 617}
 618
 619cmd_schedule() {
 620    local file="$1"
 621    local heading="$2"
 622    local date="$3"
 623
 624    [[ -f "$file" ]] || error "File not found: $file"
 625    [[ -n "$heading" ]] || error "Heading required"
 626    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
 627
 628    local elisp="(progn
 629      (let ((result (org-batch-schedule-task \"$file\" \"$heading\" \"$date\")))
 630        (if result
 631            (org-batch-output-json t (list :scheduled t :heading \"$heading\" :date \"$date\"))
 632          (org-batch-output-error \"Heading not found: $heading\")))
 633      (kill-emacs 0))"
 634
 635    run_elisp "$elisp"
 636}
 637
 638cmd_deadline() {
 639    local file="$1"
 640    local heading="$2"
 641    local date="$3"
 642
 643    [[ -f "$file" ]] || error "File not found: $file"
 644    [[ -n "$heading" ]] || error "Heading required"
 645    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
 646
 647    local elisp="(progn
 648      (let ((result (org-batch-set-deadline \"$file\" \"$heading\" \"$date\")))
 649        (if result
 650            (org-batch-output-json t (list :deadline t :heading \"$heading\" :date \"$date\"))
 651          (org-batch-output-error \"Heading not found: $heading\")))
 652      (kill-emacs 0))"
 653
 654    run_elisp "$elisp"
 655}
 656
 657cmd_priority() {
 658    local file="$1"
 659    local heading="$2"
 660    local priority="$3"
 661
 662    [[ -f "$file" ]] || error "File not found: $file"
 663    [[ -n "$heading" ]] || error "Heading required"
 664    [[ -n "$priority" ]] || error "Priority required (1-5)"
 665
 666    local elisp="(progn
 667      (let ((result (org-batch-set-priority \"$file\" \"$heading\" $priority)))
 668        (if result
 669            (org-batch-output-json t (list :priority t :heading \"$heading\" :value $priority))
 670          (org-batch-output-error \"Heading not found: $heading\")))
 671      (kill-emacs 0))"
 672
 673    run_elisp "$elisp"
 674}
 675
 676cmd_archive() {
 677    local file="$1"
 678
 679    [[ -f "$file" ]] || error "File not found: $file"
 680
 681    local elisp="(progn
 682      (let ((count (org-batch-archive-done \"$file\")))
 683        (org-batch-output-json t (list :archived count)))
 684      (kill-emacs 0))"
 685
 686    run_elisp "$elisp"
 687}
 688
 689# Tag management commands
 690
 691cmd_add_tags() {
 692    local file="$1"
 693    local heading="$2"
 694    local tags="$3"
 695
 696    [[ -f "$file" ]] || error "File not found: $file"
 697    [[ -n "$heading" ]] || error "Heading required"
 698    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 699
 700    # Convert comma-separated tags to quoted elisp list
 701    local tags_list
 702    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 703
 704    local elisp="(progn
 705      (let ((result (org-batch-add-tags \"$file\" \"$heading\" $tags_list)))
 706        (if result
 707            (org-batch-output-json t (list :tags-added t :heading \"$heading\"))
 708          (org-batch-output-error \"Heading not found: $heading\")))
 709      (kill-emacs 0))"
 710
 711    run_elisp "$elisp"
 712}
 713
 714cmd_remove_tags() {
 715    local file="$1"
 716    local heading="$2"
 717    local tags="$3"
 718
 719    [[ -f "$file" ]] || error "File not found: $file"
 720    [[ -n "$heading" ]] || error "Heading required"
 721    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 722
 723    # Convert comma-separated tags to quoted elisp list
 724    local tags_list
 725    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 726
 727    local elisp="(progn
 728      (let ((result (org-batch-remove-tags \"$file\" \"$heading\" $tags_list)))
 729        (if result
 730            (org-batch-output-json t (list :tags-removed t :heading \"$heading\"))
 731          (org-batch-output-error \"Heading not found: $heading\")))
 732      (kill-emacs 0))"
 733
 734    run_elisp "$elisp"
 735}
 736
 737cmd_replace_tags() {
 738    local file="$1"
 739    local heading="$2"
 740    local tags="$3"
 741
 742    [[ -f "$file" ]] || error "File not found: $file"
 743    [[ -n "$heading" ]] || error "Heading required"
 744    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 745
 746    # Convert comma-separated tags to quoted elisp list
 747    local tags_list
 748    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 749
 750    local elisp="(progn
 751      (let ((result (org-batch-replace-tags \"$file\" \"$heading\" $tags_list)))
 752        (if result
 753            (org-batch-output-json t (list :tags-replaced t :heading \"$heading\"))
 754          (org-batch-output-error \"Heading not found: $heading\")))
 755      (kill-emacs 0))"
 756
 757    run_elisp "$elisp"
 758}
 759
 760cmd_list_tags() {
 761    local file="$1"
 762
 763    [[ -f "$file" ]] || error "File not found: $file"
 764
 765    local elisp="(progn
 766      (let ((tags (org-batch-list-all-tags \"$file\")))
 767        (org-batch-output-json t tags))
 768      (kill-emacs 0))"
 769
 770    run_elisp "$elisp"
 771}
 772
 773# Property operations commands
 774
 775cmd_get_property() {
 776    local file="$1"
 777    local heading="$2"
 778    local property="$3"
 779
 780    [[ -f "$file" ]] || error "File not found: $file"
 781    [[ -n "$heading" ]] || error "Heading required"
 782    [[ -n "$property" ]] || error "Property name required"
 783
 784    local elisp="(progn
 785      (let ((value (org-batch-get-property \"$file\" \"$heading\" \"$property\")))
 786        (if value
 787            (org-batch-output-json t (list :property \"$property\" :value value))
 788          (org-batch-output-error \"Property not found or heading not found\")))
 789      (kill-emacs 0))"
 790
 791    run_elisp "$elisp"
 792}
 793
 794cmd_set_property() {
 795    local file="$1"
 796    local heading="$2"
 797    local property="$3"
 798    local value="$4"
 799
 800    [[ -f "$file" ]] || error "File not found: $file"
 801    [[ -n "$heading" ]] || error "Heading required"
 802    [[ -n "$property" ]] || error "Property name required"
 803    [[ -n "$value" ]] || error "Property value required"
 804
 805    local elisp="(progn
 806      (let ((result (org-batch-set-property \"$file\" \"$heading\" \"$property\" \"$value\")))
 807        (if result
 808            (org-batch-output-json t (list :property-set t :property \"$property\" :value \"$value\"))
 809          (org-batch-output-error \"Heading not found: $heading\")))
 810      (kill-emacs 0))"
 811
 812    run_elisp "$elisp"
 813}
 814
 815cmd_list_properties() {
 816    local file="$1"
 817    local heading="$2"
 818
 819    [[ -f "$file" ]] || error "File not found: $file"
 820    [[ -n "$heading" ]] || error "Heading required"
 821
 822    local elisp="(progn
 823      (let ((props (org-batch-list-properties \"$file\" \"$heading\")))
 824        (org-batch-output-json t props))
 825      (kill-emacs 0))"
 826
 827    run_elisp "$elisp"
 828}
 829
 830# Bulk operations
 831
 832cmd_bulk_update_state() {
 833    local file="$1"
 834    local filter_state="$2"
 835    local new_state="$3"
 836    local filter_tags="$4"
 837
 838    [[ -f "$file" ]] || error "File not found: $file"
 839    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 840    [[ -n "$new_state" ]] || error "New state required (DONE, NEXT, etc.)"
 841
 842    local tags_list="nil"
 843    if [[ -n "$filter_tags" ]]; then
 844        tags_list="'($(echo "$filter_tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 845    fi
 846
 847    local elisp="(progn
 848      (let ((count (org-batch-bulk-update-state \"$file\" \"$filter_state\" \"$new_state\" $tags_list)))
 849        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :new_state \"$new_state\")))
 850      (kill-emacs 0))"
 851
 852    run_elisp "$elisp"
 853}
 854
 855cmd_bulk_add_tags() {
 856    local file="$1"
 857    local filter_state="$2"
 858    local tags="$3"
 859
 860    [[ -f "$file" ]] || error "File not found: $file"
 861    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 862    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 863
 864    local tags_list
 865    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 866
 867    local elisp="(progn
 868      (let ((count (org-batch-bulk-add-tags \"$file\" \"$filter_state\" $tags_list)))
 869        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :tags_added t)))
 870      (kill-emacs 0))"
 871
 872    run_elisp "$elisp"
 873}
 874
 875cmd_bulk_set_priority() {
 876    local file="$1"
 877    local filter_state="$2"
 878    local priority="$3"
 879
 880    [[ -f "$file" ]] || error "File not found: $file"
 881    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 882    [[ -n "$priority" ]] || error "Priority required (1-5)"
 883
 884    [[ "$priority" =~ ^[1-5]$ ]] || error "Priority must be 1-5"
 885
 886    local elisp="(progn
 887      (let ((count (org-batch-bulk-set-priority \"$file\" \"$filter_state\" $priority)))
 888        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :priority $priority)))
 889      (kill-emacs 0))"
 890
 891    run_elisp "$elisp"
 892}
 893
 894# Time tracking commands
 895
 896cmd_clock_in() {
 897    local file="$1"
 898    local heading="$2"
 899
 900    [[ -f "$file" ]] || error "File not found: $file"
 901    [[ -n "$heading" ]] || error "Heading required"
 902
 903    local elisp="(progn
 904      (let ((result (org-batch-clock-in \"$file\" \"$heading\")))
 905        (if result
 906            (org-batch-output-json t (list :clocked_in t :heading \"$heading\"))
 907          (org-batch-output-error \"Heading not found: $heading\")))
 908      (kill-emacs 0))"
 909
 910    run_elisp "$elisp"
 911}
 912
 913cmd_clock_out() {
 914    local file="$1"
 915
 916    [[ -f "$file" ]] || error "File not found: $file"
 917
 918    local elisp="(progn
 919      (let ((result (org-batch-clock-out \"$file\")))
 920        (if result
 921            (org-batch-output-json t (list :clocked_out t))
 922          (org-batch-output-error \"No active clock found\")))
 923      (kill-emacs 0))"
 924
 925    run_elisp "$elisp"
 926}
 927
 928cmd_get_active_clock() {
 929    local file="$1"
 930
 931    [[ -f "$file" ]] || error "File not found: $file"
 932
 933    local elisp="(progn
 934      (let ((result (org-batch-get-active-clock \"$file\")))
 935        (if result
 936            (org-batch-output-json t result)
 937          (org-batch-output-json t nil)))
 938      (kill-emacs 0))"
 939
 940    run_elisp "$elisp"
 941}
 942
 943cmd_get_clocked_time() {
 944    local file="$1"
 945    local heading="$2"
 946
 947    [[ -f "$file" ]] || error "File not found: $file"
 948    [[ -n "$heading" ]] || error "Heading required"
 949
 950    local elisp="(progn
 951      (let ((minutes (org-batch-get-clocked-time \"$file\" \"$heading\")))
 952        (org-batch-output-json t (list :heading \"$heading\" :minutes minutes)))
 953      (kill-emacs 0))"
 954
 955    run_elisp "$elisp"
 956}
 957
 958# Statistics & Analytics commands
 959
 960cmd_get_statistics() {
 961    local file="$1"
 962
 963    [[ -f "$file" ]] || error "File not found: $file"
 964
 965    local elisp="(progn
 966      (let ((stats (org-batch-get-statistics \"$file\")))
 967        (org-batch-output-json t stats))
 968      (kill-emacs 0))"
 969
 970    run_elisp "$elisp"
 971}
 972
 973cmd_get_priority_distribution() {
 974    local file="$1"
 975
 976    [[ -f "$file" ]] || error "File not found: $file"
 977
 978    local elisp="(progn
 979      (let ((distribution (org-batch-get-priority-distribution \"$file\")))
 980        (org-batch-output-json t (list :distribution distribution)))
 981      (kill-emacs 0))"
 982
 983    run_elisp "$elisp"
 984}
 985
 986cmd_get_tag_statistics() {
 987    local file="$1"
 988
 989    [[ -f "$file" ]] || error "File not found: $file"
 990
 991    local elisp="(progn
 992      (let ((stats (org-batch-get-tag-statistics \"$file\")))
 993        (org-batch-output-json t (list :tag_stats stats)))
 994      (kill-emacs 0))"
 995
 996    run_elisp "$elisp"
 997}
 998
 999# Export commands
1000
1001cmd_export_csv() {
1002    local file="$1"
1003    local output="$2"
1004
1005    [[ -f "$file" ]] || error "File not found: $file"
1006    [[ -n "$output" ]] || error "Output file required"
1007
1008    local elisp="(progn
1009      (let ((result (org-batch-export-csv \"$file\" \"$output\")))
1010        (if result
1011            (org-batch-output-json t (list :exported t :output \"$output\"))
1012          (org-batch-output-error \"Export failed\")))
1013      (kill-emacs 0))"
1014
1015    run_elisp "$elisp"
1016}
1017
1018cmd_export_json() {
1019    local file="$1"
1020    local output="$2"
1021
1022    [[ -f "$file" ]] || error "File not found: $file"
1023    [[ -n "$output" ]] || error "Output file required"
1024
1025    local elisp="(progn
1026      (let ((result (org-batch-export-json \"$file\" \"$output\")))
1027        (if result
1028            (org-batch-output-json t (list :exported t :output \"$output\"))
1029          (org-batch-output-error \"Export failed\")))
1030      (kill-emacs 0))"
1031
1032    run_elisp "$elisp"
1033}
1034
1035# Recurring tasks commands
1036
1037cmd_set_repeater() {
1038    local file="$1"
1039    local heading="$2"
1040    local repeater="$3"
1041
1042    [[ -f "$file" ]] || error "File not found: $file"
1043    [[ -n "$heading" ]] || error "Heading required"
1044    [[ -n "$repeater" ]] || error "Repeater specification required (e.g., +1w, .+2d)"
1045
1046    local elisp="(progn
1047      (let ((result (org-batch-set-repeater \"$file\" \"$heading\" \"$repeater\")))
1048        (if result
1049            (org-batch-output-json t (list :repeater-set t :heading \"$heading\" :repeater \"$repeater\"))
1050          (org-batch-output-error \"Heading not found: $heading\")))
1051      (kill-emacs 0))"
1052
1053    run_elisp "$elisp"
1054}
1055
1056cmd_get_recurring_tasks() {
1057    local file="$1"
1058
1059    [[ -f "$file" ]] || error "File not found: $file"
1060
1061    local elisp="(progn
1062      (let ((tasks (org-batch-get-recurring-tasks \"$file\")))
1063        (org-batch-output-json t tasks))
1064      (kill-emacs 0))"
1065
1066    run_elisp "$elisp"
1067}
1068
1069# Dependencies & relationships commands
1070
1071cmd_set_blocker() {
1072    local file="$1"
1073    local heading="$2"
1074    local blocker="$3"
1075
1076    [[ -f "$file" ]] || error "File not found: $file"
1077    [[ -n "$heading" ]] || error "Heading required"
1078    [[ -n "$blocker" ]] || error "Blocker heading required"
1079
1080    local elisp="(progn
1081      (let ((result (org-batch-set-blocker \"$file\" \"$heading\" \"$blocker\")))
1082        (if result
1083            (org-batch-output-json t (list :blocker-set t :heading \"$heading\" :blocker \"$blocker\"))
1084          (org-batch-output-error \"Heading not found: $heading\")))
1085      (kill-emacs 0))"
1086
1087    run_elisp "$elisp"
1088}
1089
1090cmd_get_blocker() {
1091    local file="$1"
1092    local heading="$2"
1093
1094    [[ -f "$file" ]] || error "File not found: $file"
1095    [[ -n "$heading" ]] || error "Heading required"
1096
1097    local elisp="(progn
1098      (let ((blocker (org-batch-get-blocker \"$file\" \"$heading\")))
1099        (if blocker
1100            (org-batch-output-json t (list :heading \"$heading\" :blocker blocker))
1101          (org-batch-output-json t nil)))
1102      (kill-emacs 0))"
1103
1104    run_elisp "$elisp"
1105}
1106
1107cmd_get_blocked_tasks() {
1108    local file="$1"
1109
1110    [[ -f "$file" ]] || error "File not found: $file"
1111
1112    local elisp="(progn
1113      (let ((tasks (org-batch-get-blocked-tasks \"$file\")))
1114        (org-batch-output-json t tasks))
1115      (kill-emacs 0))"
1116
1117    run_elisp "$elisp"
1118}
1119
1120cmd_set_related() {
1121    local file="$1"
1122    local heading="$2"
1123    local related="$3"
1124    local relation_type="$4"
1125
1126    [[ -f "$file" ]] || error "File not found: $file"
1127    [[ -n "$heading" ]] || error "Heading required"
1128    [[ -n "$related" ]] || error "Related heading required"
1129    [[ -n "$relation_type" ]] || error "Relation type required (child/parent/related/depends-on)"
1130
1131    local elisp="(progn
1132      (let ((result (org-batch-set-related \"$file\" \"$heading\" \"$related\" \"$relation_type\")))
1133        (if result
1134            (org-batch-output-json t (list :related-set t :heading \"$heading\" :related \"$related\" :type \"$relation_type\"))
1135          (org-batch-output-error \"Heading not found: $heading\")))
1136      (kill-emacs 0))"
1137
1138    run_elisp "$elisp"
1139}
1140
1141cmd_get_related() {
1142    local file="$1"
1143    local heading="$2"
1144
1145    [[ -f "$file" ]] || error "File not found: $file"
1146    [[ -n "$heading" ]] || error "Heading required"
1147
1148    local elisp="(progn
1149      (let ((related (org-batch-get-related \"$file\" \"$heading\")))
1150        (org-batch-output-json t (list :heading \"$heading\" :related related)))
1151      (kill-emacs 0))"
1152
1153    run_elisp "$elisp"
1154}
1155
1156# Main
1157main() {
1158    check_deps
1159
1160    if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
1161        usage
1162    fi
1163
1164    local command="$1"; shift
1165
1166    case "$command" in
1167        list)
1168            cmd_list "$@"
1169            ;;
1170        scheduled)
1171            cmd_scheduled "$@"
1172            ;;
1173        count)
1174            cmd_count "$@"
1175            ;;
1176        search)
1177            cmd_search "$@"
1178            ;;
1179        by-section)
1180            cmd_by_section "$@"
1181            ;;
1182        sections)
1183            cmd_sections "$@"
1184            ;;
1185        children)
1186            cmd_children "$@"
1187            ;;
1188        get)
1189            cmd_get "$@"
1190            ;;
1191        overdue)
1192            cmd_overdue "$@"
1193            ;;
1194        upcoming)
1195            cmd_upcoming "$@"
1196            ;;
1197        add)
1198            cmd_add "$@"
1199            ;;
1200        append-content)
1201            cmd_append_content "$@"
1202            ;;
1203        update-state)
1204            cmd_update_state "$@"
1205            ;;
1206        schedule)
1207            cmd_schedule "$@"
1208            ;;
1209        deadline)
1210            cmd_deadline "$@"
1211            ;;
1212        priority)
1213            cmd_priority "$@"
1214            ;;
1215        archive)
1216            cmd_archive "$@"
1217            ;;
1218        add-tags)
1219            cmd_add_tags "$@"
1220            ;;
1221        remove-tags)
1222            cmd_remove_tags "$@"
1223            ;;
1224        replace-tags)
1225            cmd_replace_tags "$@"
1226            ;;
1227        list-tags)
1228            cmd_list_tags "$@"
1229            ;;
1230        get-property)
1231            cmd_get_property "$@"
1232            ;;
1233        set-property)
1234            cmd_set_property "$@"
1235            ;;
1236        list-properties)
1237            cmd_list_properties "$@"
1238            ;;
1239        bulk-update-state)
1240            cmd_bulk_update_state "$@"
1241            ;;
1242        bulk-add-tags)
1243            cmd_bulk_add_tags "$@"
1244            ;;
1245        bulk-set-priority)
1246            cmd_bulk_set_priority "$@"
1247            ;;
1248        clock-in)
1249            cmd_clock_in "$@"
1250            ;;
1251        clock-out)
1252            cmd_clock_out "$@"
1253            ;;
1254        get-active-clock)
1255            cmd_get_active_clock "$@"
1256            ;;
1257        get-clocked-time)
1258            cmd_get_clocked_time "$@"
1259            ;;
1260        get-statistics)
1261            cmd_get_statistics "$@"
1262            ;;
1263        get-priority-distribution)
1264            cmd_get_priority_distribution "$@"
1265            ;;
1266        get-tag-statistics)
1267            cmd_get_tag_statistics "$@"
1268            ;;
1269        export-csv)
1270            cmd_export_csv "$@"
1271            ;;
1272        export-json)
1273            cmd_export_json "$@"
1274            ;;
1275        set-repeater)
1276            cmd_set_repeater "$@"
1277            ;;
1278        get-recurring-tasks)
1279            cmd_get_recurring_tasks "$@"
1280            ;;
1281        set-blocker)
1282            cmd_set_blocker "$@"
1283            ;;
1284        get-blocker)
1285            cmd_get_blocker "$@"
1286            ;;
1287        get-blocked-tasks)
1288            cmd_get_blocked_tasks "$@"
1289            ;;
1290        set-related)
1291            cmd_set_related "$@"
1292            ;;
1293        get-related)
1294            cmd_get_related "$@"
1295            ;;
1296        *)
1297            error "Unknown command: $command. Use --help for usage."
1298            ;;
1299    esac
1300}
1301
1302main "$@"