Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ coingecko-cli/
- **Release workflow**: `.github/workflows/release.yml` triggers on `v*` tags, requires `HOMEBREW_TAP_TOKEN` repo secret for tap repo write access
- **Tagging**: always tag from `main` after pulling latest — `git tag vX.Y.Z && git push origin vX.Y.Z`
- **Install script**: `install.sh` downloads the latest release binary from GitHub Releases
- **Go install**: `go install github.com/coingecko/coingecko-cli@latest`
- **Go install**: `go install github.com/coingecko/coingecko-cli@latest` — produces a binary named `coingecko-cli` (module-path basename). Users alias or symlink it to `cg`; the `cg update --method go` flow prints this heads-up before running install.

## Key Design Decisions

Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ curl -sSfL https://raw.githubusercontent.com/coingecko/coingecko-cli/main/instal
go install github.com/coingecko/coingecko-cli@latest
```

This installs a binary named `coingecko-cli` (matching the module path). To use it as `cg`, add an alias to your shell rc:

```sh
alias cg=coingecko-cli
```

Or symlink it onto your `$PATH`:

```sh
ln -s "$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH)/bin")/coingecko-cli" /usr/local/bin/cg
```

### Manual

Download the binary for your platform from [Releases](https://github.com/coingecko/coingecko-cli/releases), extract, and place `cg` in your `$PATH`.
Expand Down Expand Up @@ -333,6 +345,21 @@ cg watch --ids bitcoin --dry-run # Show WebSocket request info

---

### `cg update` — Upgrade the CLI

Check for a new version and upgrade in one step. Auto-detects whether you installed via Homebrew, `go install`, or the install script, and hands off to the right tool.

```sh
cg update

# Override install method if auto-detection gets it wrong
cg update --method homebrew # or: go, script
```

The CLI also checks for updates on launch (cached 24h) and shows a reminder if you're behind. Set `CG_NO_UPDATE_CHECK=1` to disable this in CI.

---

## Category Filtering

CoinGecko tracks 500+ categories including Real World Assets, commodities, and tokenized stocks. Use the `--category` flag to filter:
Expand Down Expand Up @@ -422,6 +449,7 @@ Commands:
top-gainers-losers Show top gaining and losing coins (paid plans only)
watch Stream live coin prices via WebSocket (analyst or above)
tui Interactive terminal UI (markets, trending)
update Upgrade the CLI to the latest version
commands List all commands with API metadata (for agents/LLMs)
help Print help for a command

Expand Down
6 changes: 5 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/coingecko/coingecko-cli/internal/display"
"github.com/coingecko/coingecko-cli/internal/updater"
"github.com/spf13/cobra"
)

Expand All @@ -21,7 +22,10 @@ var rootCmd = &cobra.Command{
Version: version,
Run: func(cmd *cobra.Command, args []string) {
display.PrintLogo()
display.PrintWelcomeBox()
display.PrintWelcomeBox(version)
if info := updater.Check(version); info != nil && info.UpdateAvailable {
display.PrintUpdateReminder(info.CurrentVersion, info.LatestVersion)
}
},
}

Expand Down
137 changes: 137 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package cmd

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/charmbracelet/huh"
"github.com/coingecko/coingecko-cli/internal/updater"
"github.com/spf13/cobra"
)

var fetchLatestFunc = updater.FetchLatest

var updateCmd = &cobra.Command{
Use: "update",
Short: "Upgrade the CLI to the latest version",
RunE: runUpdate,
}

func init() {
updateCmd.Flags().String("method", "", "Install method override (homebrew, go, script)")
rootCmd.AddCommand(updateCmd)
}

func runUpdate(cmd *cobra.Command, args []string) error {
method, _ := cmd.Flags().GetString("method")
if method == "" {
method = detectInstallMethod()
} else {
switch method {
case "homebrew", "go", "script":
default:
return fmt.Errorf("unknown install method %q — must be one of: homebrew, go, script", method)
}
}

warnf("Checking for updates...\n")
latest, err := fetchLatestFunc()
if err != nil {
return fmt.Errorf("checking for updates: %w", err)
}
if !updater.ValidVersion(latest) {
return fmt.Errorf("unexpected version format from GitHub: %q", latest)
}

currentVer := strings.TrimPrefix(version, "v")
if latest == currentVer {
warnf("Already up to date (%s).\n", version)
return nil
}
if updater.VersionGreater(currentVer, latest) {
warnf("Current version (v%s) is ahead of the latest release (v%s).\n", currentVer, latest)
return nil
}

warnf("Current: v%s → Latest: v%s (install via: %s)\n\n", currentVer, latest, method)

var confirmed bool
if err := huh.NewConfirm().
Title(fmt.Sprintf("Update cg v%s → v%s?", currentVer, latest)).
Value(&confirmed).
Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
if !confirmed {
return nil
}

return runInstallCommand(method)
}

func detectInstallMethod() string {
exe, err := os.Executable()
if err != nil {
return "script"
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return "script"
}
return classifyInstallPath(exe)
}

// classifyInstallPath returns the install method ("homebrew", "go", or "script")
// for a resolved executable path.
func classifyInstallPath(exe string) string {
if strings.Contains(exe, "/Cellar/") ||
strings.Contains(exe, "/homebrew/") ||
strings.Contains(exe, "/opt/homebrew/") {
return "homebrew"
}

gobin := os.Getenv("GOBIN")
if gobin == "" {
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, _ := os.UserHomeDir()
gopath = filepath.Join(home, "go")
}
gobin = filepath.Join(gopath, "bin")
}
if strings.HasPrefix(exe, gobin+string(filepath.Separator)) {
return "go"
}

return "script"
}

func runInstallCommand(method string) error {
var name string
var args []string
switch method {
case "homebrew":
name = "brew"
args = []string{"upgrade", "coingecko/coingecko-cli/cg"}
case "go":
warnf("Note: 'go install' produces a binary named 'coingecko-cli'. If you invoke this CLI as 'cg', make sure you have an alias or symlink (e.g. alias cg=coingecko-cli).\n\n")
name = "go"
args = []string{"install", "github.com/coingecko/coingecko-cli@latest"}
default: // "script"
name = "sh"
args = []string{"-c", "curl -fsSL https://raw.githubusercontent.com/coingecko/coingecko-cli/main/install.sh | sh"}
}

c := exec.Command(name, args...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}
158 changes: 158 additions & 0 deletions cmd/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/coingecko/coingecko-cli/internal/updater"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidVersion(t *testing.T) {
valid := []string{"1.2.3", "0.0.1", "10.20.30", "0.0.0"}
for _, v := range valid {
assert.True(t, updater.ValidVersion(v), "expected valid: %s", v)
}

invalid := []string{"v1.2.3", "1.2", "1.2.3.4", "1.2.x", "", "abc", "1.2.", "1.2.3-rc1"}
for _, v := range invalid {
assert.False(t, updater.ValidVersion(v), "expected invalid: %s", v)
}
}

func TestRunUpdate_AlreadyUpToDate(t *testing.T) {
orig := fetchLatestFunc
fetchLatestFunc = func() (string, error) { return "1.2.3", nil }
t.Cleanup(func() { fetchLatestFunc = orig })

origVersion := version
version = "1.2.3"
t.Cleanup(func() { version = origVersion })

err := updateCmd.RunE(updateCmd, nil)
assert.NoError(t, err)
}

func TestRunUpdate_CurrentAhead(t *testing.T) {
orig := fetchLatestFunc
fetchLatestFunc = func() (string, error) { return "1.0.0", nil }
t.Cleanup(func() { fetchLatestFunc = orig })

origVersion := version
version = "2.0.0"
t.Cleanup(func() { version = origVersion })

err := updateCmd.RunE(updateCmd, nil)
assert.NoError(t, err)
}

func TestRunUpdate_InvalidVersionFromGitHub(t *testing.T) {
orig := fetchLatestFunc
fetchLatestFunc = func() (string, error) { return "not-a-version", nil }
t.Cleanup(func() { fetchLatestFunc = orig })

origVersion := version
version = "1.2.3"
t.Cleanup(func() { version = origVersion })

err := updateCmd.RunE(updateCmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected version format")
}

func TestRunUpdate_InvalidMethod(t *testing.T) {
require.NoError(t, updateCmd.Flags().Set("method", "invalid"))
t.Cleanup(func() { _ = updateCmd.Flags().Set("method", "") })

err := updateCmd.RunE(updateCmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown install method")
}

func TestClassifyInstallPath_Homebrew(t *testing.T) {
cases := []struct {
path string
desc string
}{
{"/usr/local/Cellar/cg/1.0.0/bin/cg", "Cellar path"},
{"/home/linuxbrew/.linuxbrew/homebrew/bin/cg", "homebrew path"},
{"/opt/homebrew/bin/cg", "opt/homebrew path"},
}
for _, tc := range cases {
assert.Equal(t, "homebrew", classifyInstallPath(tc.path), tc.desc)
}
}

func TestClassifyInstallPath_Go_ExplicitGOBIN(t *testing.T) {
gobin := t.TempDir()
t.Setenv("GOBIN", gobin)
t.Setenv("GOPATH", "")

exe := filepath.Join(gobin, "cg")
assert.Equal(t, "go", classifyInstallPath(exe))
}

func TestClassifyInstallPath_Go_DefaultGOPATH(t *testing.T) {
home, err := os.UserHomeDir()
require.NoError(t, err)

t.Setenv("GOBIN", "")
t.Setenv("GOPATH", "")

exe := filepath.Join(home, "go", "bin", "cg")
assert.Equal(t, "go", classifyInstallPath(exe))
}

func TestClassifyInstallPath_Go_ExplicitGOPATH(t *testing.T) {
gopath := t.TempDir()
t.Setenv("GOBIN", "")
t.Setenv("GOPATH", gopath)

exe := filepath.Join(gopath, "bin", "cg")
assert.Equal(t, "go", classifyInstallPath(exe))
}

func TestClassifyInstallPath_Script(t *testing.T) {
t.Setenv("GOBIN", "")
t.Setenv("GOPATH", "")

cases := []string{
"/usr/local/bin/cg",
"/home/user/.local/bin/cg",
"/tmp/cg",
}
for _, exe := range cases {
assert.Equal(t, "script", classifyInstallPath(exe), "path: %s", exe)
}
}

func TestClassifyInstallPath_GoBinNotParentDir(t *testing.T) {
gobin := "/home/user/go/bin"
t.Setenv("GOBIN", gobin)

// A path that starts with the gobin string but isn't under it (no separator)
exe := gobin + "extra/cg"
assert.Equal(t, "script", classifyInstallPath(exe))
}

func TestDetectInstallMethod_ReturnsValidMethod(t *testing.T) {
method := detectInstallMethod()
assert.Contains(t, []string{"homebrew", "go", "script"}, method)
}

func TestRunUpdate_FetchError(t *testing.T) {
orig := fetchLatestFunc
fetchLatestFunc = func() (string, error) { return "", fmt.Errorf("network timeout") }
t.Cleanup(func() { fetchLatestFunc = orig })

origVersion := version
version = "1.2.3"
t.Cleanup(func() { version = origVersion })

err := updateCmd.RunE(updateCmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "network timeout")
}
3 changes: 2 additions & 1 deletion cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"runtime"
"strings"

"github.com/spf13/cobra"
)
Expand All @@ -21,7 +22,7 @@ var versionCmd = &cobra.Command{
"arch": runtime.GOARCH,
})
}
fmt.Printf("cg %s\n", version)
fmt.Printf("cg v%s\n", strings.TrimPrefix(version, "v"))
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" built: %s\n", date)
fmt.Printf(" go: %s\n", runtime.Version())
Expand Down
Loading