Skip to content

Commit eaadee9

Browse files
arimxyerclaude
andcommitted
Detect locally installed tools and show version in status table
Adds local tool detection using exec.LookPath and --version flag execution with a 3s timeout. Runs detection in parallel with GitHub API fetches. Replaces Previous column with Installed column showing the local version or "-" if not found. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 3fc2e47 commit eaadee9

1 file changed

Lines changed: 98 additions & 55 deletions

File tree

main.go

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
@@ -33,6 +34,32 @@ type Source struct {
3334
DisplayName string
3435
Owner string
3536
Repo string
37+
BinaryNames []string
38+
VersionArgs []string
39+
}
40+
41+
type InstalledInfo struct {
42+
Installed bool `json:"installed"`
43+
Version string `json:"version,omitempty"`
44+
Binary string `json:"binary,omitempty"`
45+
}
46+
47+
var semverRegex = regexp.MustCompile(`(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)`)
48+
49+
func (s Source) DetectInstalled(ctx context.Context) InstalledInfo {
50+
for _, bin := range s.BinaryNames {
51+
path, err := exec.LookPath(bin)
52+
if err != nil {
53+
continue
54+
}
55+
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
56+
defer cancel()
57+
cmd := exec.CommandContext(ctx, path, s.VersionArgs...)
58+
out, _ := cmd.CombinedOutput()
59+
ver := semverRegex.FindString(string(out))
60+
return InstalledInfo{Installed: true, Version: ver, Binary: bin}
61+
}
62+
return InstalledInfo{}
3663
}
3764

3865
func (s Source) URL() string {
@@ -44,11 +71,11 @@ func (s Source) Fetch() ([]ChangelogEntry, error) {
4471
}
4572

4673
var sources = map[string]Source{
47-
"claude": {DisplayName: "Claude Code", Owner: "anthropics", Repo: "claude-code"},
48-
"codex": {DisplayName: "OpenAI Codex", Owner: "openai", Repo: "codex"},
49-
"opencode": {DisplayName: "OpenCode", Owner: "sst", Repo: "opencode"},
50-
"gemini": {DisplayName: "Gemini CLI", Owner: "google-gemini", Repo: "gemini-cli"},
51-
"copilot": {DisplayName: "GitHub Copilot CLI", Owner: "github", Repo: "copilot-cli"},
74+
"claude": {DisplayName: "Claude Code", Owner: "anthropics", Repo: "claude-code", BinaryNames: []string{"claude"}, VersionArgs: []string{"--version"}},
75+
"codex": {DisplayName: "OpenAI Codex", Owner: "openai", Repo: "codex", BinaryNames: []string{"codex"}, VersionArgs: []string{"--version"}},
76+
"opencode": {DisplayName: "OpenCode", Owner: "sst", Repo: "opencode", BinaryNames: []string{"opencode"}, VersionArgs: []string{"--version"}},
77+
"gemini": {DisplayName: "Gemini CLI", Owner: "google-gemini", Repo: "gemini-cli", BinaryNames: []string{"gemini"}, VersionArgs: []string{"--version"}},
78+
"copilot": {DisplayName: "GitHub Copilot CLI", Owner: "github", Repo: "copilot-cli", BinaryNames: []string{"github-copilot-cli", "copilot"}, VersionArgs: []string{"--version"}},
5279
}
5380

5481
func main() {
@@ -303,11 +330,15 @@ func runStatusCommand(jsonOutput bool) {
303330
}
304331

305332
results := make(chan statusResult, len(sources))
333+
installedResults := make(map[string]InstalledInfo)
334+
var mu sync.Mutex
306335
var wg sync.WaitGroup
307336

308-
// Fetch up to 10 entries from each source concurrently
337+
ctx := context.Background()
338+
339+
// Fetch releases and detect installed tools concurrently
309340
for name, src := range sources {
310-
wg.Add(1)
341+
wg.Add(2)
311342
go func(name string, src Source) {
312343
defer wg.Done()
313344
entries, err := src.Fetch()
@@ -318,6 +349,13 @@ func runStatusCommand(jsonOutput bool) {
318349
err: err,
319350
}
320351
}(name, src)
352+
go func(name string, src Source) {
353+
defer wg.Done()
354+
info := src.DetectInstalled(ctx)
355+
mu.Lock()
356+
installedResults[name] = info
357+
mu.Unlock()
358+
}(name, src)
321359
}
322360

323361
go func() {
@@ -326,13 +364,14 @@ func runStatusCommand(jsonOutput bool) {
326364
}()
327365

328366
type statusEntry struct {
329-
Name string `json:"name"`
330-
Version string `json:"version"`
331-
PreviousVersion string `json:"previous_version"`
332-
UpdatedAgo string `json:"updated_ago"`
333-
UpdatedRecently bool `json:"updated_recently"`
334-
AvgReleaseFreq string `json:"avg_release_freq"`
335-
releasedAt time.Time
367+
Name string `json:"name"`
368+
InstalledVersion string `json:"installed_version"`
369+
Version string `json:"version"`
370+
UpdatedAgo string `json:"updated_ago"`
371+
UpdatedRecently bool `json:"updated_recently"`
372+
AvgReleaseFreq string `json:"avg_release_freq"`
373+
sourceName string
374+
releasedAt time.Time
336375
}
337376

338377
var statusEntries []statusEntry
@@ -349,17 +388,14 @@ func runStatusCommand(jsonOutput bool) {
349388
}
350389

351390
entry := statusEntry{
352-
Name: r.displayName,
353-
Version: r.entries[0].Version,
354-
PreviousVersion: "-",
355-
UpdatedAgo: "-",
356-
UpdatedRecently: false,
357-
AvgReleaseFreq: "-",
358-
releasedAt: r.entries[0].ReleasedAt,
359-
}
360-
361-
if len(r.entries) > 1 {
362-
entry.PreviousVersion = r.entries[1].Version
391+
Name: r.displayName,
392+
InstalledVersion: "-",
393+
Version: r.entries[0].Version,
394+
UpdatedAgo: "-",
395+
UpdatedRecently: false,
396+
AvgReleaseFreq: "-",
397+
sourceName: r.source,
398+
releasedAt: r.entries[0].ReleasedAt,
363399
}
364400

365401
if !r.entries[0].ReleasedAt.IsZero() {
@@ -373,6 +409,20 @@ func runStatusCommand(jsonOutput bool) {
373409
statusEntries = append(statusEntries, entry)
374410
}
375411

412+
// Populate installed versions
413+
for i := range statusEntries {
414+
mu.Lock()
415+
info, ok := installedResults[statusEntries[i].sourceName]
416+
mu.Unlock()
417+
if ok && info.Installed {
418+
if info.Version != "" {
419+
statusEntries[i].InstalledVersion = info.Version
420+
} else {
421+
statusEntries[i].InstalledVersion = "yes"
422+
}
423+
}
424+
}
425+
376426
// Sort by most recently updated
377427
sort.Slice(statusEntries, func(i, j int) bool {
378428
if statusEntries[i].releasedAt.IsZero() && statusEntries[j].releasedAt.IsZero() {
@@ -397,40 +447,39 @@ func runStatusCommand(jsonOutput bool) {
397447
// Print table with borders
398448
// Column widths
399449
const (
400-
colTool = 20
401-
col24h = 3
402-
colVersion = 12
403-
colPrevious = 12
404-
colUpdated = 10
405-
colFreq = 19
450+
colTool = 20
451+
col24h = 3
452+
colInstalled = 12
453+
colVersion = 12
454+
colUpdated = 10
455+
colFreq = 19
406456
)
407457

458+
border := func(left, mid, right string) string {
459+
return fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s%s\n",
460+
left, strings.Repeat("─", colTool+2),
461+
mid, strings.Repeat("─", col24h+2),
462+
mid, strings.Repeat("─", colInstalled+2),
463+
mid, strings.Repeat("─", colVersion+2),
464+
mid, strings.Repeat("─", colUpdated+2),
465+
mid, strings.Repeat("─", colFreq+2),
466+
right)
467+
}
468+
408469
// Top border
409-
fmt.Printf("┌%s┬%s┬%s┬%s┬%s┬%s┐\n",
410-
strings.Repeat("─", colTool+2),
411-
strings.Repeat("─", col24h+2),
412-
strings.Repeat("─", colVersion+2),
413-
strings.Repeat("─", colPrevious+2),
414-
strings.Repeat("─", colUpdated+2),
415-
strings.Repeat("─", colFreq+2))
470+
fmt.Print(border("┌", "┬", "┐"))
416471

417472
// Header row
418473
fmt.Printf("│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n",
419474
colTool, "Tool",
420475
col24h, "24h",
421-
colVersion, "Version",
422-
colPrevious, "Previous",
476+
colInstalled, "Installed",
477+
colVersion, "Latest",
423478
colUpdated, "Updated",
424479
colFreq, "Vers. Release Freq.")
425480

426481
// Header separator
427-
fmt.Printf("├%s┼%s┼%s┼%s┼%s┼%s┤\n",
428-
strings.Repeat("─", colTool+2),
429-
strings.Repeat("─", col24h+2),
430-
strings.Repeat("─", colVersion+2),
431-
strings.Repeat("─", colPrevious+2),
432-
strings.Repeat("─", colUpdated+2),
433-
strings.Repeat("─", colFreq+2))
482+
fmt.Print(border("├", "┼", "┤"))
434483

435484
// Data rows
436485
for _, e := range statusEntries {
@@ -441,20 +490,14 @@ func runStatusCommand(jsonOutput bool) {
441490
fmt.Printf("│ %-*s │ %s │ %-*s │ %-*s │ %-*s │ %-*s │\n",
442491
colTool, truncateString(e.Name, colTool),
443492
recentMarker,
493+
colInstalled, truncateString(e.InstalledVersion, colInstalled),
444494
colVersion, truncateString(e.Version, colVersion),
445-
colPrevious, truncateString(e.PreviousVersion, colPrevious),
446495
colUpdated, e.UpdatedAgo,
447496
colFreq, e.AvgReleaseFreq)
448497
}
449498

450499
// Bottom border
451-
fmt.Printf("└%s┴%s┴%s┴%s┴%s┴%s┘\n",
452-
strings.Repeat("─", colTool+2),
453-
strings.Repeat("─", col24h+2),
454-
strings.Repeat("─", colVersion+2),
455-
strings.Repeat("─", colPrevious+2),
456-
strings.Repeat("─", colUpdated+2),
457-
strings.Repeat("─", colFreq+2))
500+
fmt.Print(border("└", "┴", "┘"))
458501
}
459502

460503
func truncateString(s string, maxLen int) string {

0 commit comments

Comments
 (0)