#!/usr/bin/env rc nl=' ' debounce=2 nextok=() once=0 model=() target=() fn usage { echo 'usage: todowatch.rc [-h] [--model model] [file-or-directory]' >[1=2] echo 'try: todowatch.rc --help' >[1=2] exit usage } fn help { cat <<'---' todowatch - watch files and run TODO(ai) tasks with opencode usage: todowatch.rc [-h] [--model model] [file-or-directory] With no argument, the script watches the current directory. Give a directory name to watch a whole tree. Give a file name to watch one file. How it works: - The script waits for edits with entr or inotifywait. - After an edit, it searches the files for the exact text TODO(ai): . - The task text starts after the marker on that line. - Comment lines below the marker continue the task. The script treats these prefixes as comments: // # -- ; * block comments - A blank line can sit inside the task. - A code line, or two blank lines in a row, end the task. The script then runs one command per task: opencode run "Do the following TODO(ai) task: File: :" is relative to the directory where you started the script. Use --model to select the opencode model for each task. Notes: - The script waits 2 seconds after each edit before it acts. Change the debounce value at the top of the script to adjust this. - You need ripgrep, awk, entr or inotifywait, and opencode. --- } while(! ~ $1 ()){ switch($1){ case -h --help help exit 0 case --once once=1 case --model if(~ $#* 1) usage shift model=$1 case --* usage case * if(! ~ $#target 0) usage target=$1 } shift } if(~ $#target 0) target=. if(! test -e $target){ usage } fn todotext { file=$1 start=$2 awk -v start=$start -f /dev/stdin $file <<'awk' # Collect a TODO(ai) task that starts on line "start" of the file. # The task continues on comment lines below it. A code line ends the task. # Append s to the result and stop reading. END then prints the result. function done(s){ if(s!="") out=out "\n" s; exit } # The TODO line: keep only the text after the marker. NR==start{ out=$0 sub(/.*TODO\(ai\): /,"",out) p=$0 sub(/^[ \t]+/,"",p) inblock=0 if(p~/^\/\*/){ t=p sub(/^\/\*/,"",t) inblock=(t!~/\*\//) } next } # Each line after the TODO line. NR>start{ trim=$0 sub(/\r$/,"",trim) sub(/^[ \t]+/,"",trim) # One blank line is allowed inside the task. # Two blank lines in a row end the task. if(trim==""){ if(++blank>=2) exit next } l=trim # Inside a /* block: strip the * at the start of each line. # A */ ends the task; keep any text before it. if(inblock){ sub(/^\*+[ \t]?/,"",l) if(l~/\*\/[ \t]*$/){ sub(/[ \t]*\*\/[ \t]*$/,"",l) done(l) } if(l!=""){out=out "\n" l; blank=0} next } # A new /* here opens a block. If it closes on this same line, # keep the text before */ and stop. if(l~/^\/\*/){ sub(/^\/\*/,"",l) if(l~/\*\/[ \t]*$/){ sub(/[ \t]*\*\/[ \t]*$/,"",l) done(l) } inblock=1 if(l!=""){out=out "\n" l; blank=0} next } # HTML comments: closes and stops the task. html=0 if(l~/^[ \t]*$/){html=1; sub(/[ \t]*-->[ \t]*$/,"",l)} # Line comments in most languages: // # -- ;; mark=0 if(l~/^(\/\/|#|--|;+)/){mark=1; sub(/^(\/\/|#|--|;+)[ \t]?/,"",l)} # A comment line adds its text to the task. # Any other line, for example code, ends the task. if(html || mark){ if(l!=""){out=out "\n" l; blank=0} if(html && trim~/-->[ \t]*$/) exit next } exit } END{print out} awk } fn scan { now=`{date +%s} if(! ~ $#nextok 0 && test $now -lt $nextok) return nextok=`{expr $now + $debounce} locs=() ifs=$nl{ if(test -f $target){ locs=`{rg -n --with-filename --no-heading 'TODO\(ai\): ' $target >[2]/dev/null | cut -d: -f1-2} } else { files=`{rg --files $target >[2]/dev/null} if(! ~ $#files 0) locs=`{rg -n --with-filename --no-heading 'TODO\(ai\): ' $files >[2]/dev/null | cut -d: -f1-2} } } if(~ $#locs 0) return cwd=`{pwd} ifs=$nl{ for(loc in $locs){ f=`{echo $loc | cut -d: -f1} n=`{echo $loc | cut -d: -f2} ifs=''{text=`{todotext $f $n}} if(! ~ $#text 0){ rel=`{printf %s $f | sed s@^$cwd/@@} msg='Do the following TODO(ai) task:' ^ $nl ^ $text ^ $nl ^ $nl ^ 'File: '$rel:$n args=() if(! ~ $#model 0) args=(--model $model) opencode run $args $^msg } } } } if(~ $once 1){ scan exit 0 } watcher=() if(which inotifywait >/dev/null >[2=1]){ watcher=inotifywait } else if(which entr >/dev/null >[2=1]){ watcher=entr } if(~ $#watcher 0){ echo 'todowatch: install entr or inotifywait' >[1=2] exit deps } if(~ $watcher inotifywait){ excl='(^|/)(\.git|node_modules)(/|$)' args=(-q -m -e close_write,moved_to --format %w%f --exclude $excl) if(test -d $target) args=(-r $args) $args $target | for(f){scan} } else { while(true){ files=() ifs=$nl{ if(test -f $target){files=($target)} else {files=`{rg --files $target >[2]/dev/null}} } args=($0 --once) if(! ~ $#model 0) args=($args --model $model) if(! ~ $#files 0) {for(f in $files) echo $f} | entr -d $args $target sleep 1 } }