@@ -93,6 +93,17 @@ func main() {
9393 os .Exit (0 )
9494 }
9595
96+ if args [0 ] == "status" {
97+ var jsonOutput bool
98+ for i := 1 ; i < len (args ); i ++ {
99+ if args [i ] == "-json" || args [i ] == "--json" {
100+ jsonOutput = true
101+ }
102+ }
103+ runStatusCommand (jsonOutput )
104+ os .Exit (0 )
105+ }
106+
96107 sourceName := args [0 ]
97108 source , ok := sources [sourceName ]
98109 if ! ok {
@@ -169,15 +180,17 @@ func main() {
169180func printUsage () {
170181 fmt .Fprintf (os .Stderr , "aic - AI Coding Agent Changelog Viewer\n \n " )
171182 fmt .Fprintf (os .Stderr , "Usage: aic <source> [flags]\n " )
172- fmt .Fprintf (os .Stderr , " aic latest [flags]\n \n " )
183+ fmt .Fprintf (os .Stderr , " aic latest [flags]\n " )
184+ fmt .Fprintf (os .Stderr , " aic status [flags]\n \n " )
173185 fmt .Fprintf (os .Stderr , "Sources:\n " )
174186 fmt .Fprintf (os .Stderr , " claude Claude Code (Anthropic)\n " )
175187 fmt .Fprintf (os .Stderr , " codex Codex CLI (OpenAI)\n " )
176188 fmt .Fprintf (os .Stderr , " opencode OpenCode (SST)\n " )
177189 fmt .Fprintf (os .Stderr , " gemini Gemini CLI (Google)\n " )
178190 fmt .Fprintf (os .Stderr , " copilot Copilot CLI (GitHub)\n \n " )
179191 fmt .Fprintf (os .Stderr , "Commands:\n " )
180- fmt .Fprintf (os .Stderr , " latest Show releases from all sources in last 24h\n \n " )
192+ fmt .Fprintf (os .Stderr , " latest Show releases from all sources in last 24h\n " )
193+ fmt .Fprintf (os .Stderr , " status Show status table of all sources\n \n " )
181194 fmt .Fprintf (os .Stderr , "Flags:\n " )
182195 fmt .Fprintf (os .Stderr , " -json Output as JSON\n " )
183196 fmt .Fprintf (os .Stderr , " -md Output as markdown\n " )
@@ -191,6 +204,7 @@ func printUsage() {
191204 fmt .Fprintf (os .Stderr , " aic opencode -list # List OpenCode versions\n " )
192205 fmt .Fprintf (os .Stderr , " aic gemini -version 0.21.0 # Specific Gemini version\n " )
193206 fmt .Fprintf (os .Stderr , " aic latest # All releases in last 24h\n " )
207+ fmt .Fprintf (os .Stderr , " aic status # Status table of all tools\n " )
194208}
195209
196210func runLatestCommand (jsonOutput bool ) {
@@ -263,6 +277,250 @@ func runLatestCommand(jsonOutput bool) {
263277 }
264278}
265279
280+ func runStatusCommand (jsonOutput bool ) {
281+ type statusResult struct {
282+ source string
283+ displayName string
284+ entries []ChangelogEntry
285+ err error
286+ }
287+
288+ results := make (chan statusResult , len (sources ))
289+ var wg sync.WaitGroup
290+
291+ // Fetch up to 10 entries from each source concurrently
292+ for name , src := range sources {
293+ wg .Add (1 )
294+ go func (name string , src Source ) {
295+ defer wg .Done ()
296+ entries , err := src .FetchFunc ()
297+ results <- statusResult {
298+ source : name ,
299+ displayName : src .DisplayName ,
300+ entries : entries ,
301+ err : err ,
302+ }
303+ }(name , src )
304+ }
305+
306+ go func () {
307+ wg .Wait ()
308+ close (results )
309+ }()
310+
311+ type statusEntry struct {
312+ Name string `json:"name"`
313+ Version string `json:"version"`
314+ PreviousVersion string `json:"previous_version"`
315+ UpdatedAgo string `json:"updated_ago"`
316+ UpdatedRecently bool `json:"updated_recently"`
317+ AvgReleaseFreq string `json:"avg_release_freq"`
318+ releasedAt time.Time
319+ }
320+
321+ var statusEntries []statusEntry
322+ cutoff := time .Now ().Add (- 24 * time .Hour )
323+
324+ for r := range results {
325+ if r .err != nil {
326+ fmt .Fprintf (os .Stderr , "Warning: Failed to fetch %s: %v\n " , r .displayName , r .err )
327+ continue
328+ }
329+
330+ if len (r .entries ) == 0 {
331+ continue
332+ }
333+
334+ entry := statusEntry {
335+ Name : r .displayName ,
336+ Version : r .entries [0 ].Version ,
337+ PreviousVersion : "-" ,
338+ UpdatedAgo : "-" ,
339+ UpdatedRecently : false ,
340+ AvgReleaseFreq : "-" ,
341+ releasedAt : r .entries [0 ].ReleasedAt ,
342+ }
343+
344+ if len (r .entries ) > 1 {
345+ entry .PreviousVersion = r .entries [1 ].Version
346+ }
347+
348+ if ! r .entries [0 ].ReleasedAt .IsZero () {
349+ entry .UpdatedAgo = formatRelativeTime (r .entries [0 ].ReleasedAt )
350+ entry .UpdatedRecently = r .entries [0 ].ReleasedAt .After (cutoff )
351+ }
352+
353+ // Calculate average release frequency from up to 10 entries
354+ entry .AvgReleaseFreq = calculateAvgReleaseFreq (r .entries )
355+
356+ statusEntries = append (statusEntries , entry )
357+ }
358+
359+ // Sort by most recently updated
360+ sort .Slice (statusEntries , func (i , j int ) bool {
361+ if statusEntries [i ].releasedAt .IsZero () && statusEntries [j ].releasedAt .IsZero () {
362+ return statusEntries [i ].Name < statusEntries [j ].Name
363+ }
364+ if statusEntries [i ].releasedAt .IsZero () {
365+ return false
366+ }
367+ if statusEntries [j ].releasedAt .IsZero () {
368+ return true
369+ }
370+ return statusEntries [i ].releasedAt .After (statusEntries [j ].releasedAt )
371+ })
372+
373+ if jsonOutput {
374+ encoder := json .NewEncoder (os .Stdout )
375+ encoder .SetIndent ("" , " " )
376+ encoder .Encode (statusEntries )
377+ return
378+ }
379+
380+ // Print table with borders
381+ // Column widths
382+ const (
383+ colTool = 20
384+ col24h = 3
385+ colVersion = 12
386+ colPrevious = 12
387+ colUpdated = 10
388+ colFreq = 19
389+ )
390+
391+ // Top border
392+ fmt .Printf ("┌%s┬%s┬%s┬%s┬%s┬%s┐\n " ,
393+ strings .Repeat ("─" , colTool + 2 ),
394+ strings .Repeat ("─" , col24h + 2 ),
395+ strings .Repeat ("─" , colVersion + 2 ),
396+ strings .Repeat ("─" , colPrevious + 2 ),
397+ strings .Repeat ("─" , colUpdated + 2 ),
398+ strings .Repeat ("─" , colFreq + 2 ))
399+
400+ // Header row
401+ fmt .Printf ("│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n " ,
402+ colTool , "Tool" ,
403+ col24h , "24h" ,
404+ colVersion , "Version" ,
405+ colPrevious , "Previous" ,
406+ colUpdated , "Updated" ,
407+ colFreq , "Vers. Release Freq." )
408+
409+ // Header separator
410+ fmt .Printf ("├%s┼%s┼%s┼%s┼%s┼%s┤\n " ,
411+ strings .Repeat ("─" , colTool + 2 ),
412+ strings .Repeat ("─" , col24h + 2 ),
413+ strings .Repeat ("─" , colVersion + 2 ),
414+ strings .Repeat ("─" , colPrevious + 2 ),
415+ strings .Repeat ("─" , colUpdated + 2 ),
416+ strings .Repeat ("─" , colFreq + 2 ))
417+
418+ // Data rows
419+ for _ , e := range statusEntries {
420+ recentMarker := " "
421+ if e .UpdatedRecently {
422+ recentMarker = "[✓]"
423+ }
424+ fmt .Printf ("│ %-*s │ %s │ %-*s │ %-*s │ %-*s │ %-*s │\n " ,
425+ colTool , truncateString (e .Name , colTool ),
426+ recentMarker ,
427+ colVersion , truncateString (e .Version , colVersion ),
428+ colPrevious , truncateString (e .PreviousVersion , colPrevious ),
429+ colUpdated , e .UpdatedAgo ,
430+ colFreq , e .AvgReleaseFreq )
431+ }
432+
433+ // Bottom border
434+ fmt .Printf ("└%s┴%s┴%s┴%s┴%s┴%s┘\n " ,
435+ strings .Repeat ("─" , colTool + 2 ),
436+ strings .Repeat ("─" , col24h + 2 ),
437+ strings .Repeat ("─" , colVersion + 2 ),
438+ strings .Repeat ("─" , colPrevious + 2 ),
439+ strings .Repeat ("─" , colUpdated + 2 ),
440+ strings .Repeat ("─" , colFreq + 2 ))
441+ }
442+
443+ func truncateString (s string , maxLen int ) string {
444+ if len (s ) <= maxLen {
445+ return s
446+ }
447+ if maxLen <= 3 {
448+ return s [:maxLen ]
449+ }
450+ return s [:maxLen - 3 ] + "..."
451+ }
452+
453+ func formatRelativeTime (t time.Time ) string {
454+ if t .IsZero () {
455+ return "-"
456+ }
457+
458+ duration := time .Since (t )
459+
460+ minutes := int (duration .Minutes ())
461+ hours := int (duration .Hours ())
462+ days := hours / 24
463+ weeks := days / 7
464+ months := days / 30
465+
466+ if minutes < 60 {
467+ return fmt .Sprintf ("%dm ago" , minutes )
468+ }
469+ if hours < 24 {
470+ return fmt .Sprintf ("%dh ago" , hours )
471+ }
472+ if days < 7 {
473+ return fmt .Sprintf ("%dd ago" , days )
474+ }
475+ if weeks < 4 {
476+ return fmt .Sprintf ("%dw ago" , weeks )
477+ }
478+ return fmt .Sprintf ("%dmo ago" , months )
479+ }
480+
481+ func calculateAvgReleaseFreq (entries []ChangelogEntry ) string {
482+ // Need at least 2 entries with valid dates to calculate average
483+ var validEntries []ChangelogEntry
484+ for _ , e := range entries {
485+ if ! e .ReleasedAt .IsZero () {
486+ validEntries = append (validEntries , e )
487+ }
488+ if len (validEntries ) >= 10 {
489+ break
490+ }
491+ }
492+
493+ if len (validEntries ) < 2 {
494+ return "-"
495+ }
496+
497+ // Calculate intervals between consecutive releases
498+ var totalDuration time.Duration
499+ for i := 0 ; i < len (validEntries )- 1 ; i ++ {
500+ interval := validEntries [i ].ReleasedAt .Sub (validEntries [i + 1 ].ReleasedAt )
501+ totalDuration += interval
502+ }
503+
504+ avgDuration := totalDuration / time .Duration (len (validEntries )- 1 )
505+
506+ // Format as relative time
507+ hours := int (avgDuration .Hours ())
508+ days := hours / 24
509+ weeks := days / 7
510+ months := days / 30
511+
512+ if days < 1 {
513+ return fmt .Sprintf ("~%dh" , hours )
514+ }
515+ if days < 7 {
516+ return fmt .Sprintf ("~%dd" , days )
517+ }
518+ if weeks < 4 {
519+ return fmt .Sprintf ("~%dw" , weeks )
520+ }
521+ return fmt .Sprintf ("~%dmo" , months )
522+ }
523+
266524func fetchClaudeChangelog () ([]ChangelogEntry , error ) {
267525 url := "https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md"
268526 content , err := httpGet (url )
0 commit comments