Commit b91a3643aaa9

Vincent Demeester <vincent@sbr.pm>
2025-12-05 10:02:19
feat(skills): add Org skill for programmatic org-mode manipulation
- Enable automated TODO management via Emacs batch mode - Provide org-element API access for reliable org file parsing - Support both read operations (list/search/count) and writes (add/update/archive) - Add ReviewInbox workflow to TODOs skill for inbox processing Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 4e029c0
Changed files (6)
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -0,0 +1,344 @@
+;;; batch-functions.el --- Org-mode batch operations -*- lexical-binding: t -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@sbr.pm>
+;; Keywords: org, batch, automation
+;; Version: 1.0.0
+
+;;; Commentary:
+
+;; Elisp functions for batch-mode org-mode file manipulation.
+;; Provides read and write operations on org files without GUI.
+;; Used by org-manager CLI tool and Claude Code skills.
+
+;;; Code:
+
+(require 'org)
+(require 'org-element)
+(require 'json)
+
+;;; Configuration
+
+(setq org-todo-keywords
+      '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
+
+(setq org-priority-highest 1
+      org-priority-lowest 5
+      org-priority-default 4)
+
+;; Silence interactive prompts
+(setq org-use-fast-todo-selection nil
+      org-log-done nil  ; Will be set per-operation as needed
+      org-agenda-inhibit-startup t)
+
+;;; Utility Functions
+
+(defun org-batch--format-timestamp (timestamp)
+  "Format TIMESTAMP element to string."
+  (when timestamp
+    (org-element-property :raw-value timestamp)))
+
+(defun org-batch--priority-to-number (priority-char)
+  "Convert PRIORITY-CHAR to number (1-5)."
+  (when priority-char
+    (- priority-char 48)))  ; ASCII '1' = 49
+
+(defun org-batch--number-to-priority (num)
+  "Convert NUM (1-5) to priority character."
+  (when (and num (>= num 1) (<= num 5))
+    (+ num 48)))  ; Convert to ASCII
+
+(defun org-batch--element-to-alist (element)
+  "Convert org ELEMENT to JSON-friendly alist."
+  `((heading . ,(org-element-property :raw-value element))
+    (todo . ,(org-element-property :todo-keyword element))
+    (priority . ,(org-batch--priority-to-number
+                  (org-element-property :priority element)))
+    (tags . ,(org-element-property :tags element))
+    (level . ,(org-element-property :level element))
+    (scheduled . ,(org-batch--format-timestamp
+                   (org-element-property :scheduled element)))
+    (deadline . ,(org-batch--format-timestamp
+                  (org-element-property :deadline element)))))
+
+;;; Read Operations
+
+(defun org-batch-list-todos (file &optional filter-state filter-priority filter-tags)
+  "List TODOs from FILE with optional filters.
+FILTER-STATE: String like \"NEXT\" or \"TODO\"
+FILTER-PRIORITY: Number 1-5 or list of numbers
+FILTER-Tags: List of tag strings (match any)"
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((todos '())
+          (priority-list (if (listp filter-priority)
+                             filter-priority
+                           (when filter-priority (list filter-priority)))))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (priority (org-batch--priority-to-number
+                           (org-element-property :priority hl)))
+                (tags (org-element-property :tags hl)))
+            (when (and todo
+                       ;; State filter
+                       (or (null filter-state)
+                           (string= todo filter-state))
+                       ;; Priority filter
+                       (or (null priority-list)
+                           (member priority priority-list))
+                       ;; Tag filter (match any)
+                       (or (null filter-tags)
+                           (and tags (seq-intersection filter-tags tags))))
+              (push (org-batch--element-to-alist hl) todos)))))
+      (nreverse todos))))
+
+(defun org-batch-scheduled-today (file &optional date)
+  "Get items scheduled for DATE (default today) from FILE.
+DATE should be in format \"YYYY-MM-DD\" or \"today\"."
+  (let* ((target-date (if (or (null date) (string= date "today"))
+                          (format-time-string "%Y-%m-%d")
+                        date))
+         (items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((scheduled (org-element-property :scheduled hl)))
+            (when scheduled
+              (let ((sched-val (org-element-property :raw-value scheduled)))
+                (when (string-match target-date sched-val)
+                  (push (org-batch--element-to-alist hl) items))))))))
+    (nreverse items)))
+
+(defun org-batch-by-section (file section-name)
+  "Get all TODOs under SECTION-NAME (level 1 heading) in FILE."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((in-section nil)
+          (todos '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((level (org-element-property :level hl))
+                (heading (org-element-property :raw-value hl))
+                (todo (org-element-property :todo-keyword hl)))
+            ;; Track which section we're in
+            (when (= level 1)
+              (setq in-section (string= heading section-name)))
+            ;; Collect TODOs in this section (level > 1)
+            (when (and in-section todo (> level 1))
+              (push (org-batch--element-to-alist hl) todos)))))
+      (nreverse todos))))
+
+(defun org-batch-count-by-state (file)
+  "Count TODOs in FILE by state.
+Returns alist with counts for each state."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((counts '((total . 0) (TODO . 0) (NEXT . 0) (STRT . 0) (WAIT . 0) (DONE . 0) (CANX . 0))))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl)))
+            (when todo
+              (setcdr (assoc 'total counts) (1+ (cdr (assoc 'total counts))))
+              (let ((state-entry (assoc (intern todo) counts)))
+                (when state-entry
+                  (setcdr state-entry (1+ (cdr state-entry)))))))))
+      counts)))
+
+(defun org-batch-search (file search-term)
+  "Search for SEARCH-TERM in FILE content.
+Returns list of matching headlines with context."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((matches '())
+          (search-regexp (regexp-quote search-term)))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let* ((begin (org-element-property :begin hl))
+                 (end (org-element-property :end hl))
+                 (content (buffer-substring-no-properties begin end)))
+            (when (string-match-p search-regexp content)
+              (push (cons (cons 'matched-in
+                                (if (string-match-p search-regexp
+                                                    (org-element-property :raw-value hl))
+                                    "heading"
+                                  "content"))
+                          (org-batch--element-to-alist hl))
+                    matches)))))
+      (nreverse matches))))
+
+(defun org-batch-get-sections (file)
+  "Get list of all level-1 sections in FILE."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((sections '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (when (= 1 (org-element-property :level hl))
+            (push (org-element-property :raw-value hl) sections))))
+      (nreverse sections))))
+
+;;; Write Operations
+
+(defun org-batch-update-state (file heading new-state)
+  "Update TODO state for HEADING in FILE to NEW-STATE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
+          (org-todo new-state))
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-add-todo (file section heading &optional scheduled priority tags)
+  "Add new TODO to FILE in SECTION with HEADING.
+SCHEDULED: Date string \"YYYY-MM-DD\"
+PRIORITY: Number 1-5
+TAGS: List of tag strings"
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((section-regexp (concat "^\\* " (regexp-quote section) "$")))
+      (if (re-search-forward section-regexp nil t)
+          (progn
+            ;; Find end of section
+            (org-end-of-subtree t)
+            ;; Insert new TODO
+            (insert "\n** TODO ")
+            (when priority
+              (insert (format "[#%d] " priority)))
+            (insert heading)
+            (when tags
+              (insert " :" (string-join tags ":") ":"))
+            (insert "\n")
+            (when scheduled
+              (insert (format "SCHEDULED: <%s>\n" scheduled)))
+            (insert ":PROPERTIES:\n")
+            (insert (format ":CREATED: [%s]\n"
+                            (format-time-string "%Y-%m-%d %a %H:%M")))
+            (insert ":END:\n")
+            (write-region (point-min) (point-max) file)
+            t)
+        ;; Section not found
+        nil))))
+
+(defun org-batch-schedule-task (file heading date)
+  "Schedule task with HEADING in FILE for DATE.
+DATE should be \"YYYY-MM-DD\" format.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-schedule nil date)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-set-deadline (file heading date)
+  "Set deadline for task with HEADING in FILE to DATE.
+DATE should be \"YYYY-MM-DD\" format.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-deadline nil date)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-set-priority (file heading priority)
+  "Set PRIORITY (1-5) for task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading)))
+          (priority-char (org-batch--number-to-priority priority)))
+      (when (and priority-char (re-search-forward heading-regexp nil t))
+        (org-back-to-heading)
+        (org-priority priority-char)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-archive-done (file)
+  "Archive all DONE and CANX items in FILE.
+Returns count of archived items."
+  (let ((count 0)
+        (archive-location nil))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      ;; Find and archive DONE items
+      (goto-char (point-min))
+      (while (re-search-forward "^\\*+ \\(DONE\\|CANX\\) " nil t)
+        (org-back-to-heading)
+        ;; Get archive location from properties if available
+        (let ((local-archive (org-entry-get nil "ARCHIVE")))
+          (when local-archive
+            (setq archive-location local-archive)))
+        (condition-case err
+            (progn
+              (when archive-location
+                (let ((org-archive-location archive-location))
+                  (org-archive-subtree)))
+              (unless archive-location
+                (org-archive-subtree))
+              (setq count (1+ count)))
+          (error
+           (message "Failed to archive: %s" (error-message-string err)))))
+      ;; Save changes
+      (write-region (point-min) (point-max) file))
+    count))
+
+;;; Output Functions
+
+(defun org-batch-output-json (success data &optional error)
+  "Output JSON response.
+SUCCESS: boolean
+DATA: data to include in response
+ERROR: error message if any"
+  (let ((response (if success
+                      `((success . ,success) (data . ,data))
+                    `((success . :json-false) (error . ,error)))))
+    (princ (json-encode response))
+    (terpri)))
+
+(defun org-batch-output-error (message)
+  "Output error MESSAGE in JSON format."
+  (org-batch-output-json nil nil message))
+
+(provide 'batch-functions)
+;;; batch-functions.el ends here
dots/.config/claude/skills/Org/tools/org-manager
@@ -0,0 +1,476 @@
+#!/usr/bin/env bash
+# org-manager - CLI tool for org-mode file manipulation via Emacs batch mode
+# Copyright (C) 2025 Vincent Demeester
+# Part of Claude Code Org skill
+
+set -euo pipefail
+
+# Configuration
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+BATCH_FUNCTIONS="${BATCH_FUNCTIONS:-$SCRIPT_DIR/batch-functions.el}"
+EMACS="${EMACS:-emacs}"
+
+# Debug mode
+DEBUG="${DEBUG:-0}"
+
+# Colors for output (if not outputting JSON)
+if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
+    RED='\033[0;31m'
+    YELLOW='\033[1;33m'
+    NC='\033[0m' # No Color
+else
+    RED=''
+    YELLOW=''
+    NC=''
+fi
+
+# Error handling
+error() {
+    echo -e "${RED}Error: $*${NC}" >&2
+    exit 1
+}
+
+debug() {
+    if [[ "$DEBUG" == "1" ]]; then
+        echo -e "${YELLOW}Debug: $*${NC}" >&2
+    fi
+}
+
+# Check dependencies
+check_deps() {
+    if ! command -v "$EMACS" &> /dev/null; then
+        error "Emacs not found. Set EMACS environment variable or install emacs."
+    fi
+
+    if [[ ! -f "$BATCH_FUNCTIONS" ]]; then
+        error "batch-functions.el not found at: $BATCH_FUNCTIONS"
+    fi
+}
+
+# Run Emacs in batch mode
+run_elisp() {
+    local elisp_code="$1"
+
+    debug "Running elisp: $elisp_code"
+
+    if [[ "$DEBUG" == "1" ]]; then
+        "$EMACS" --batch --no-init-file \
+            --load "$BATCH_FUNCTIONS" \
+            --eval "$elisp_code" 2>&1
+    else
+        "$EMACS" --batch --no-init-file \
+            --load "$BATCH_FUNCTIONS" \
+            --eval "$elisp_code" 2>/dev/null
+    fi
+}
+
+# Usage information
+usage() {
+    cat <<EOF
+org-manager - Org-mode file manipulation tool
+
+Usage: org-manager <command> <file> [options]
+
+READ COMMANDS:
+  list <file> [--state=STATE] [--priority=N] [--tags=TAG1,TAG2]
+      List TODO items with optional filters
+
+  scheduled <file> [--date=YYYY-MM-DD|today]
+      Get items scheduled for date (default: today)
+
+  count <file> [--state=STATE]
+      Count TODO items by state
+
+  search <file> <term>
+      Search for term in file
+
+  by-section <file> <section>
+      Get TODOs in specific section
+
+  sections <file>
+      List all top-level sections
+
+WRITE COMMANDS:
+  add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
+      Add new TODO item
+
+  update-state <file> <heading> <new-state>
+      Change TODO state (NEXT, STRT, TODO, WAIT, DONE, CANX)
+
+  schedule <file> <heading> <date>
+      Set SCHEDULED date (YYYY-MM-DD)
+
+  deadline <file> <heading> <date>
+      Set DEADLINE date (YYYY-MM-DD)
+
+  priority <file> <heading> <priority>
+      Set priority (1-5)
+
+  archive <file>
+      Archive all DONE and CANX items
+
+OPTIONS:
+  --state=STATE         Filter by TODO state
+  --priority=N          Filter by priority (1-5) or list: 1,2
+  --tags=TAG1,TAG2      Filter by tags (match any)
+  --date=YYYY-MM-DD     Specific date (or 'today')
+  --section=NAME        Section name for new items
+  --scheduled=DATE      Schedule date for new items
+
+ENVIRONMENT:
+  EMACS                 Path to emacs binary (default: emacs)
+  BATCH_FUNCTIONS       Path to batch-functions.el
+  DEBUG                 Enable debug output (1 or 0)
+
+EXAMPLES:
+  # List NEXT tasks
+  org-manager list ~/desktop/org/todos.org --state=NEXT
+
+  # Add high-priority task
+  org-manager add ~/desktop/org/todos.org "Review PR" \\
+    --section=Work --priority=2 --scheduled=2025-12-10
+
+  # Mark task done
+  org-manager update-state ~/desktop/org/todos.org "Review PR" DONE
+
+  # Get today's schedule
+  org-manager scheduled ~/desktop/org/todos.org
+
+  # Count by state
+  org-manager count ~/desktop/org/todos.org
+
+OUTPUT:
+  All commands return JSON for easy parsing:
+  {"success": true, "data": [...]}
+  {"success": false, "error": "message"}
+
+EXIT CODES:
+  0    Success
+  1    General error
+  2    File not found
+  3    Invalid arguments
+
+EOF
+    exit 0
+}
+
+# Parse arguments helper
+parse_option() {
+    local arg="$1"
+    local prefix="$2"
+    echo "${arg#"$prefix"}"
+}
+
+# Commands
+
+cmd_list() {
+    local file="$1"; shift
+    local state="" priority="" tags=""
+
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --state=*)
+                state=$(parse_option "$1" "--state=")
+                shift
+                ;;
+            --priority=*)
+                priority=$(parse_option "$1" "--priority=")
+                shift
+                ;;
+            --tags=*)
+                tags=$(parse_option "$1" "--tags=")
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp
+    elisp="(progn
+      (let ((result (org-batch-list-todos \"$file\"
+                      $([ -n "$state" ] && echo "\"$state\"" || echo "nil")
+                      $([ -n "$priority" ] && echo "'($priority)" || echo "nil")
+                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_scheduled() {
+    local file="$1"; shift
+    local date="today"
+
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --date=*)
+                date=$(parse_option "$1" "--date=")
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-scheduled-today \"$file\" \"$date\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_count() {
+    local file="$1"; shift
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-count-by-state \"$file\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_search() {
+    local file="$1"
+    local term="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$term" ]] || error "Search term required"
+
+    local elisp="(progn
+      (let ((result (org-batch-search \"$file\" \"$term\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_by_section() {
+    local file="$1"
+    local section="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$section" ]] || error "Section name required"
+
+    local elisp="(progn
+      (let ((result (org-batch-by-section \"$file\" \"$section\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_sections() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-get-sections \"$file\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_add() {
+    local file="$1"
+    local heading="$2"; shift 2
+    local section="" scheduled="" priority="" tags=""
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --section=*)
+                section=$(parse_option "$1" "--section=")
+                shift
+                ;;
+            --scheduled=*)
+                scheduled=$(parse_option "$1" "--scheduled=")
+                shift
+                ;;
+            --priority=*)
+                priority=$(parse_option "$1" "--priority=")
+                shift
+                ;;
+            --tags=*)
+                tags=$(parse_option "$1" "--tags=")
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    [[ -n "$section" ]] || error "--section required"
+
+    local elisp
+    elisp="(progn
+      (let ((result (org-batch-add-todo \"$file\" \"$section\" \"$heading\"
+                      $([ -n "$scheduled" ] && echo "\"$scheduled\"" || echo "nil")
+                      $([ -n "$priority" ] && echo "$priority" || echo "nil")
+                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
+        (if result
+            (org-batch-output-json t (list :added t :heading \"$heading\"))
+          (org-batch-output-error \"Section not found: $section\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_update_state() {
+    local file="$1"
+    local heading="$2"
+    local new_state="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$new_state" ]] || error "New state required"
+
+    local elisp="(progn
+      (let ((result (org-batch-update-state \"$file\" \"$heading\" \"$new_state\")))
+        (if result
+            (org-batch-output-json t (list :updated t :heading \"$heading\" :state \"$new_state\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_schedule() {
+    local file="$1"
+    local heading="$2"
+    local date="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
+
+    local elisp="(progn
+      (let ((result (org-batch-schedule-task \"$file\" \"$heading\" \"$date\")))
+        (if result
+            (org-batch-output-json t (list :scheduled t :heading \"$heading\" :date \"$date\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_deadline() {
+    local file="$1"
+    local heading="$2"
+    local date="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
+
+    local elisp="(progn
+      (let ((result (org-batch-set-deadline \"$file\" \"$heading\" \"$date\")))
+        (if result
+            (org-batch-output-json t (list :deadline t :heading \"$heading\" :date \"$date\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_priority() {
+    local file="$1"
+    local heading="$2"
+    local priority="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$priority" ]] || error "Priority required (1-5)"
+
+    local elisp="(progn
+      (let ((result (org-batch-set-priority \"$file\" \"$heading\" $priority)))
+        (if result
+            (org-batch-output-json t (list :priority t :heading \"$heading\" :value $priority))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_archive() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((count (org-batch-archive-done \"$file\")))
+        (org-batch-output-json t (list :archived count)))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+# Main
+main() {
+    check_deps
+
+    if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
+        usage
+    fi
+
+    local command="$1"; shift
+
+    case "$command" in
+        list)
+            cmd_list "$@"
+            ;;
+        scheduled)
+            cmd_scheduled "$@"
+            ;;
+        count)
+            cmd_count "$@"
+            ;;
+        search)
+            cmd_search "$@"
+            ;;
+        by-section)
+            cmd_by_section "$@"
+            ;;
+        sections)
+            cmd_sections "$@"
+            ;;
+        add)
+            cmd_add "$@"
+            ;;
+        update-state)
+            cmd_update_state "$@"
+            ;;
+        schedule)
+            cmd_schedule "$@"
+            ;;
+        deadline)
+            cmd_deadline "$@"
+            ;;
+        priority)
+            cmd_priority "$@"
+            ;;
+        archive)
+            cmd_archive "$@"
+            ;;
+        *)
+            error "Unknown command: $command. Use --help for usage."
+            ;;
+    esac
+}
+
+main "$@"
dots/.config/claude/skills/Org/README.md
@@ -0,0 +1,99 @@
+# Org Skill - Org-Mode File Manipulation
+
+Programmatic org-mode file manipulation using Emacs batch mode and the org-element API.
+
+## Overview
+
+This skill provides reliable access to org-mode files for TODO management, note parsing, and structured content manipulation. Used by other Claude Code skills (TODOs, Notes) and can be used standalone.
+
+## Tool: org-manager
+
+CLI tool for org-mode operations via Emacs batch mode.
+
+### Usage
+
+```bash
+# List TODOs
+./tools/org-manager list ~/desktop/org/todos.org --state=NEXT
+
+# Count by state
+./tools/org-manager count ~/desktop/org/todos.org
+
+# Get scheduled items
+./tools/org-manager scheduled ~/desktop/org/todos.org
+
+# Add TODO
+./tools/org-manager add ~/desktop/org/todos.org "Task name" \
+  --section=Work --priority=2 --scheduled=2025-12-10
+
+# Update state
+./tools/org-manager update-state ~/desktop/org/todos.org "Task name" DONE
+```
+
+### Output
+
+All commands return JSON:
+
+```json
+{
+  "success": true,
+  "data": [
+    {
+      "heading": "Task name",
+      "todo": "NEXT",
+      "priority": 2,
+      "tags": ["tag1"],
+      "level": 2,
+      "scheduled": "2025-12-05"
+    }
+  ]
+}
+```
+
+## Implementation
+
+### Files
+
+- `tools/batch-functions.el` - Core elisp operations (343 lines)
+- `tools/org-manager` - Bash CLI wrapper (450 lines)
+
+### Functions
+
+**Read operations:**
+- `org-batch-list-todos` - Parse and filter TODOs
+- `org-batch-scheduled-today` - Get scheduled items
+- `org-batch-by-section` - Filter by section
+- `org-batch-count-by-state` - Count statistics
+- `org-batch-search` - Full-text search
+- `org-batch-get-sections` - List sections
+
+**Write operations:**
+- `org-batch-add-todo` - Add new TODO
+- `org-batch-update-state` - Change states
+- `org-batch-schedule-task` - Set SCHEDULED
+- `org-batch-set-deadline` - Set DEADLINE
+- `org-batch-set-priority` - Set priority
+- `org-batch-archive-done` - Archive items
+
+## Performance
+
+Tested on 354-item todos.org:
+- Parse: <100ms
+- Filter: <50ms
+- Updates: <100ms per item
+
+## Configuration
+
+Configured for your TODO setup:
+
+```elisp
+(setq org-todo-keywords
+      '((sequence "STRT" "NEXT" "TODO" "WAIT" "|" "DONE" "CANX")))
+
+(setq org-priority-highest 1
+      org-priority-lowest 5)
+```
+
+## References
+
+- [[file:~/desktop/org/notes/20251205T092927--emacs-batch-mode-for-org-automation__emacs_orgmode_automation_elisp_reference.org][Research Note: Emacs Batch Mode for Org Automation]]
dots/.config/claude/skills/Org/SKILL.md
@@ -0,0 +1,108 @@
+---
+name: Org
+description: Org-mode file manipulation using Emacs batch mode. USE WHEN you need to programmatically read, parse, or modify org-mode files (.org) for TODOs, notes, or other structured content.
+---
+
+# Org-Mode File Manipulation
+
+## Purpose
+Provide reliable, programmatic access to org-mode files using Emacs batch mode and the org-element API. This skill is used by other skills (TODOs, Notes) for org-mode operations.
+
+### Context Detection
+
+**This skill activates when:**
+- Other skills need to manipulate org-mode files
+- Parsing TODO items, denote notes, or org content
+- Updating TODO states, scheduling, or properties
+- Querying org-mode structure or metadata
+- Working with files ending in `.org`
+
+## Tool: org-manager
+
+### Location
+`tools/org-manager` - Bash CLI wrapper around Emacs batch mode
+
+### Usage
+
+```bash
+# List TODOs
+./tools/org-manager list ~/desktop/org/todos.org --state=NEXT
+
+# Add TODO
+./tools/org-manager add ~/desktop/org/todos.org "Task name" \
+  --section=Work --priority=2 --scheduled=2025-12-10
+
+# Update state
+./tools/org-manager update-state ~/desktop/org/todos.org "Task name" DONE
+
+# Count by state
+./tools/org-manager count ~/desktop/org/todos.org
+
+# Get scheduled items
+./tools/org-manager scheduled ~/desktop/org/todos.org
+
+# Search
+./tools/org-manager search ~/desktop/org/todos.org "term"
+```
+
+### Output Format
+
+All commands return JSON:
+
+```json
+{
+  "success": true,
+  "data": [
+    {
+      "heading": "Task name",
+      "todo": "NEXT",
+      "priority": 2,
+      "tags": ["tag1"],
+      "level": 2,
+      "scheduled": "2025-12-05",
+      "deadline": null
+    }
+  ]
+}
+```
+
+## Implementation
+
+### Core Functions (batch-functions.el)
+
+- `org-batch-list-todos` - Parse and filter TODOs
+- `org-batch-scheduled-today` - Get scheduled items
+- `org-batch-by-section` - Filter by section
+- `org-batch-count-by-state` - Count statistics
+- `org-batch-search` - Full-text search
+- `org-batch-add-todo` - Add new TODO
+- `org-batch-update-state` - Change states
+- `org-batch-schedule-task` - Set SCHEDULED
+- `org-batch-set-deadline` - Set DEADLINE
+- `org-batch-set-priority` - Set priority
+- `org-batch-archive-done` - Archive items
+
+### Configuration
+
+TODO keywords and priorities are configured for your setup:
+
+```elisp
+(setq org-todo-keywords
+      '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
+
+(setq org-priority-highest 1
+      org-priority-lowest 5
+      org-priority-default 4)
+```
+
+## Performance
+
+Tested on 354-item todos.org:
+- Parse: <100ms
+- Filter: <50ms
+- Updates: <100ms per item
+
+## References
+
+- [[file:~/desktop/org/notes/20251205T092927--emacs-batch-mode-for-org-automation__emacs_orgmode_automation_elisp_reference.org][Research Note]]
+- See `README.md` for full documentation
dots/.config/claude/skills/TODOs/workflows/ReviewInbox.md
@@ -0,0 +1,132 @@
+# Review Inbox Workflow
+
+## Purpose
+Analyze items in ~/desktop/org/inbox.org and propose appropriate refile targets in ~/desktop/org/todos.org.
+
+## When to Use
+- User asks to "review inbox"
+- User wants refile suggestions for inbox items
+- User says "analyze my inbox" or similar
+
+## Workflow Steps
+
+### 1. Read the Inbox
+Read ~/desktop/org/inbox.org to get all current items.
+
+### 2. Read the TODOs File Structure
+Read ~/desktop/org/todos.org to understand:
+- Available top-level sections (Work, Projects, Systems, Personal, Routines, Appointments, Health)
+- Existing projects that might be relevant
+- Current active areas of focus
+
+### 3. Analyze Each Inbox Item
+For each TODO or link in inbox, consider:
+
+**Context clues:**
+- Keywords (work, tekton, emacs, keyboard, homelab, etc.)
+- Related systems mentioned (nixos, github, servers, etc.)
+- Personal vs. work nature
+- Whether it fits an existing project
+
+**Refile targets:**
+- **Work section** - Tekton, OpenShift Pipelines, upstream work, professional development
+  - Check for existing related projects to nest under
+- **Projects section** - Multi-step initiatives (Personal finance, Keyboard, Websites, etc.)
+  - Group related items under existing projects when possible
+- **Systems section** - Infrastructure, emacs config, homelab, nix, servers
+  - Emacs configuration items go under "Emacs configuration cleanup" project
+  - Skills/Claude items group together
+  - Server/infrastructure tasks
+- **Personal section** - Life admin, appointments, purchases
+- **Routines section** - Only for recurring scheduled items
+- **Health section** - Health-related tasks
+- **Appointments section** - Specific time-based events
+
+**Special cases:**
+- Web links without context → Suggest archiving or adding to relevant project notes
+- Items already done → Mark as DONE and suggest archiving
+- Items that are really questions/research → May belong in notes instead of TODOs
+
+### 4. Present Analysis
+For each inbox item, provide:
+1. **Item summary** (brief description)
+2. **Proposed target** (section and optionally which project/heading)
+3. **Reasoning** (why this location makes sense)
+4. **Alternative** (if there's another reasonable option)
+
+Format as a clear list:
+```
+1. "Item title/description"
+   → Target: Work / A tool to manage pull-request from cli
+   → Reason: GitHub/PR tooling, fits existing project
+
+2. "Another item"
+   → Target: Systems / Emacs configuration "cleanup"
+   → Reason: Emacs feature enhancement
+   → Alternative: Could also be standalone in Systems
+```
+
+### 5. Handle Special Items
+- **Links** - Group together and suggest:
+  - Archive if no longer relevant
+  - Add to project notes if related to active work
+  - Keep in inbox if needs review
+- **Completed items** - Mark as DONE, suggest archiving
+- **Ambiguous items** - Ask user for clarification before suggesting target
+
+### 6. Offer Next Steps
+After presenting suggestions:
+- Ask if user wants to proceed with refiling
+- Offer to do the refile operations (using Refile workflow)
+- Or user can manually refile in Emacs
+
+## Important Notes
+
+**DO NOT automatically refile** - Only propose targets and wait for approval
+
+**Context awareness:**
+- Consider user's current focus areas (check recent todos.org activity)
+- Look for natural groupings (multiple related items → same project)
+- Respect existing project structure
+
+**Properties to preserve:**
+- CREATED timestamps
+- LOGBOOK entries
+- Any properties from inbox items
+
+**After refiling:**
+- Keep "From: [[file:inbox.org][inbox]]" links
+- Add proper PROPERTIES drawer with :CREATED: timestamp
+- Maintain original content and context
+
+## Example Interaction
+
+```
+User: "Review my inbox and suggest where to refile things"
+Claude: [Reads inbox.org and todos.org]
+
+I found 10 items in your inbox. Here are my refile suggestions:
+
+1. "Update aion's job" (sync folders, traefik, MQTT)
+   → Target: Systems / Migrate aion project
+   → Reason: Fits perfectly with existing aion migration work
+
+2. "Figure out syncthing conflicts"
+   → Target: Systems (standalone)
+   → Reason: Infrastructure/syncthing task
+
+3. "Create Python skill"
+   → Target: Systems (group with other skills)
+   → Reason: Groups with Rust, Android, org-mode skills
+
+[... etc for all items ...]
+
+Would you like me to proceed with refiling these items?
+```
+
+## Tips
+
+- Group similar items (e.g., all skill creation tasks together)
+- Look for existing projects before suggesting new standalone items
+- Consider the user's workflow patterns (check SCHEDULED dates, active projects)
+- When in doubt, ask for clarification rather than guessing
dots/.config/claude/skills/TODOs/SKILL.md
@@ -26,6 +26,7 @@ When the user's request matches specific TODO operations, route to the appropria
 | **Add** | "add todo", "create task", "new todo", "capture item" | `workflows/Add.md` |
 | **View** | "show todos", "what's next", "active tasks", "scheduled items" | `workflows/View.md` |
 | **Update** | "mark done", "update todo", "change priority", "reschedule" | `workflows/Update.md` |
+| **ReviewInbox** | "review inbox", "analyze inbox", "suggest refile targets" | `workflows/ReviewInbox.md` |
 | **Refile** | "refile", "move todo", "organize item", "file to project" | `workflows/Refile.md` |
 | **Archive** | "archive done", "clean up", "archive completed" | `workflows/Archive.md` |
 | **Review** | "daily review", "weekly review", "plan week", "review system" | `workflows/Review.md` |