File: ./info.txt 1 timer [command...] [args...] 2 3 4 Run a live timer/chronograph on stderr, always showing below all lines 5 from stdin, which update stdout as they come. 6 7 When stdin is over, it simply quits showing how long it ran, by extending 8 its last visible timer line on stderr. 9 10 You can also use this app without piping anything to its stdin, acting as 11 a simple live clock until you force-quit it. 12 13 To ensure any stderr lines don't clash with the chronograph output line, 14 you can also run this app with arguments to have it run a command, thus 15 guaranteeing the subtask's stderr lines show without interference. File: ./main.go 1 package main 2 3 import ( 4 "bufio" 5 "io" 6 "os" 7 "os/exec" 8 "os/signal" 9 "strconv" 10 "sync" 11 "time" 12 13 _ "embed" 14 ) 15 16 const ( 17 spaces = ` ` 18 19 // clear has enough spaces in it to cover any chronograph output 20 clear = "\r" + spaces + spaces + spaces + "\r" 21 ) 22 23 //go:embed info.txt 24 var info string 25 26 func main() { 27 if len(os.Args) > 1 { 28 switch os.Args[1] { 29 case `-h`, `--h`, `-help`, `--help`: 30 os.Stderr.WriteString(info) 31 return 32 } 33 } 34 35 if len(os.Args) > 1 { 36 os.Exit(runTask(os.Args[1], os.Args[2:])) 37 } 38 39 err := chronograph(os.Stdin, nil) 40 if quit, ok := err.(justQuit); ok { 41 os.Exit(quit.exitCode) 42 } 43 } 44 45 // justQuit is a custom error-type which isn't for showing, but quitting the 46 // app right away instead 47 type justQuit struct { 48 exitCode int 49 } 50 51 // Error is only to satisfy the error interface, and not for showing 52 func (justQuit) Error() string { 53 return `quitting right away` 54 } 55 56 // runTask handles running the app in `subtask-mode` 57 func runTask(name string, args []string) (exitCode int) { 58 cmd := exec.Command(name, args...) 59 cmd.Stdin = os.Stdin 60 61 stdout, err := cmd.StdoutPipe() 62 if err != nil { 63 showError(err) 64 return 1 65 } 66 defer stdout.Close() 67 68 stderr, err := cmd.StderrPipe() 69 if err != nil { 70 showError(err) 71 return 1 72 } 73 defer stderr.Close() 74 75 if err := cmd.Start(); err != nil { 76 showError(err) 77 return 1 78 } 79 80 err = chronograph(stdout, stderr) 81 if quit, ok := err.(justQuit); ok { 82 return quit.exitCode 83 } 84 if err != nil { 85 showError(err) 86 } 87 88 if err := cmd.Wait(); err != nil { 89 showError(err) 90 } 91 return cmd.ProcessState.ExitCode() 92 } 93 94 // showError gives a consistent style/look to any of the app's own errors 95 func showError(err error) { 96 if err == nil { 97 return 98 } 99 100 os.Stderr.WriteString("\x1b[31m") 101 os.Stderr.WriteString(err.Error()) 102 os.Stderr.WriteString("\x1b[0m\n") 103 } 104 105 // readLines is run twice asynchronously, so that both stdin and stderr lines 106 // are handled independently, which matters when running a subtask 107 func readLines(r io.Reader, lines chan []byte) error { 108 defer close(lines) 109 110 // when not handling a subtask, this func will be called with a nil 111 // reader, since without a subtask, there's no stderr to read from 112 if r == nil { 113 return nil 114 } 115 116 const gb = 1024 * 1024 * 1024 117 sc := bufio.NewScanner(r) 118 sc.Buffer(nil, 8*gb) 119 120 for sc.Scan() { 121 // interesting: trying to filter out needlessly-chatty pipes 122 // doesn't work as intended when done here, but is fine when 123 // done at both receiving ends in the big select statement 124 lines <- sc.Bytes() 125 } 126 return sc.Err() 127 } 128 129 // chronograph runs a live chronograph, showing the time elapsed: 2 input 130 // sources for lines are handled concurrently, one destined for the app's 131 // stdout, the other for the app's stderr, without interfering with the 132 // chronograph lines, which also show on stderr 133 func chronograph(stdout io.Reader, stderr io.Reader) error { 134 start := time.Now() 135 t := time.NewTicker(100 * time.Millisecond) 136 startChronoLine(start, start) 137 138 stopped := make(chan os.Signal, 1) 139 defer close(stopped) 140 signal.Notify(stopped, os.Interrupt) 141 142 errors := make(chan error) 143 var waitAllLines sync.WaitGroup 144 waitAllLines.Add(2) 145 146 outLines := make(chan []byte) 147 go func() { 148 defer waitAllLines.Done() 149 errors <- readLines(stdout, outLines) 150 }() 151 152 errLines := make(chan []byte) 153 go func() { 154 defer waitAllLines.Done() 155 errors <- readLines(stderr, errLines) 156 }() 157 158 quit := make(chan struct{}) 159 defer close(quit) 160 161 go func() { 162 waitAllLines.Wait() 163 close(errors) 164 quit <- struct{}{} 165 }() 166 167 for { 168 select { 169 case now := <-t.C: 170 os.Stderr.WriteString(clear) 171 startChronoLine(start, now) 172 173 case line := <-outLines: 174 if line == nil { 175 // filter out junk for needlessly-chatty pipes, while still 176 // keeping actual empty lines 177 continue 178 } 179 180 // write-order of the next 3 steps matters, to avoid mixing 181 // up lines since stdout and stderr lines can show up together 182 os.Stderr.WriteString(clear) 183 os.Stdout.Write(line) 184 _, err := os.Stdout.WriteString("\n") 185 startChronoLine(start, time.Now()) 186 if err != nil { 187 endChronoLine(start) 188 return err 189 } 190 191 case line := <-errLines: 192 if line == nil { 193 // filter out junk for needlessly-chatty pipes, while still 194 // keeping actual empty lines 195 continue 196 } 197 198 // write-order of the next 3 steps matters, to avoid mixing 199 // up lines since stdout and stderr lines can show up together 200 os.Stderr.WriteString(clear) 201 os.Stderr.Write(line) 202 _, err := os.Stderr.WriteString("\n") 203 startChronoLine(start, time.Now()) 204 if err != nil { 205 endChronoLine(start) 206 return err 207 } 208 209 case err := <-errors: 210 if err == nil { 211 continue 212 } 213 os.Stderr.WriteString(clear) 214 showError(err) 215 startChronoLine(start, time.Now()) 216 217 case <-quit: 218 endChronoLine(start) 219 return justQuit{0} 220 221 case <-stopped: 222 t.Stop() 223 endChronoLine(start) 224 return justQuit{255} 225 } 226 } 227 } 228 229 func startChronoLine(start, now time.Time) { 230 var buf [64]byte 231 dt := now.Sub(start) 232 233 os.Stderr.Write(time.Time{}.Add(dt).AppendFormat(buf[:0], `15:04:05`)) 234 os.Stderr.Write([]byte(` `)) 235 os.Stderr.Write(now.AppendFormat(buf[:0], `2006-01-02 15:04:05 Jan Mon`)) 236 } 237 238 func endChronoLine(start time.Time) { 239 var buf [64]byte 240 secs := time.Since(start).Seconds() 241 242 os.Stderr.Write([]byte(` `)) 243 os.Stderr.Write(strconv.AppendFloat(buf[:0], secs, 'f', 4, 64)) 244 os.Stderr.Write([]byte(" seconds\n")) 245 } File: ./mit-license.txt 1 The MIT License (MIT) 2 3 Copyright (c) 2026 pacman64 4 5 Permission is hereby granted, free of charge, to any person obtaining a copy of 6 this software and associated documentation files (the "Software"), to deal 7 in the Software without restriction, including without limitation the rights to 8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 of the Software, and to permit persons to whom the Software is furnished to do 10 so, subject to the following conditions: 11 12 The above copyright notice and this permission notice shall be included in all 13 copies or substantial portions of the Software. 14 15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 SOFTWARE.