• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

astronomer / astro-cli / 6e9dd8d1-3b8b-47cc-a01f-b67fb42c13d6

24 Apr 2026 03:25PM UTC coverage: 39.548% (+0.03%) from 39.515%
6e9dd8d1-3b8b-47cc-a01f-b67fb42c13d6

Pull #2100

circleci

jlaneve
Add `astro agent` command with Otto binary management

Adds a new top-level CLI command that downloads and launches Otto,
Astronomer's AI coding agent for Airflow. Introduces a shared
`pkg/agent/` package so other tools can reuse the same binary lifecycle.

Commands:

    astro agent                        # interactive TUI
    astro agent "fix my DAG"           # one-shot with a prompt
    astro agent -c                     # continue last session
    astro agent update                 # force-update Otto
    astro agent version                # show installed version

Otto auto-updates on launch by default (cached-latest > installed →
sync download + swap). Opt out with `astro config set -g
agent.auto_update false`.

Surrounding code fixes required to make the agent reliable:

- `config/config.go`: `saveConfig` wraps viper writes in a `gofrs/flock`
  so concurrent astro processes can't corrupt `~/.astro/config.yaml`.
  `gofrs/flock` was already a transitive dep.
- `config/context.go`: `SetContextKey` / `SetExpiresIn` work around
  viper#1106 — `viper.Set` on a nested path leaves the override layer
  holding a partial tree, so later `UnmarshalKey` silently zeros every
  untouched sibling.
- `airflow/proxy/`: `DetectAirflow` prefers the proxy hostname URL
  over the direct container port, which rotates on every
  `astro dev restart`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull Request #2100: Add `astro agent` command with Otto binary management

250 of 562 new or added lines in 11 files covered. (44.48%)

2 existing lines in 2 files now uncovered.

25340 of 64074 relevant lines covered (39.55%)

9.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.7
/pkg/agent/binary.go
1
package agent
2

3
import (
4
        "archive/tar"
5
        "archive/zip"
6
        "compress/gzip"
7
        "encoding/json"
8
        "fmt"
9
        "io"
10
        "net/http"
11
        "os"
12
        "path/filepath"
13
        "runtime"
14
        "strings"
15
        "time"
16

17
        "github.com/Masterminds/semver/v3"
18

19
        "github.com/astronomer/astro-cli/config"
20
)
21

22
const (
23
        // MinVersion is the minimum Otto version compatible with this CLI.
24
        // Bump this when breaking changes are made to the RPC/flag interface.
25
        MinVersion = "0.0.2"
26

27
        cdnBaseURL    = "https://install.astronomer.io/otto"
28
        binaryName    = "otto"
29
        pkgJSON       = "package.json"
30
        windowsGOOS   = "windows"
31
        dirPerm       = 0o755
32
        binPerm       = 0o755
33
        logFilePerm   = 0o644
34
        stateFilePerm = 0o600
35
)
36

37
// packageInfo represents the package.json next to the Otto binary.
38
type packageInfo struct {
39
        Version string `json:"version"`
40
}
41

42
// BinDir returns the directory where Otto is installed (~/.astro/bin/).
43
func BinDir() string {
127✔
44
        return filepath.Join(config.HomeConfigPath, "bin")
127✔
45
}
127✔
46

47
// BinaryPath returns the full path to the Otto binary.
48
func BinaryPath() string {
2✔
49
        name := binaryName
2✔
50
        if runtime.GOOS == windowsGOOS {
2✔
NEW
51
                name += ".exe"
×
NEW
52
        }
×
53
        return filepath.Join(BinDir(), name)
2✔
54
}
55

56
// InstalledVersion reads the version from package.json next to the binary.
57
// Returns empty string if not installed.
58
func InstalledVersion() (string, error) {
19✔
59
        data, err := os.ReadFile(filepath.Join(BinDir(), pkgJSON))
19✔
60
        if os.IsNotExist(err) {
23✔
61
                return "", nil
4✔
62
        }
4✔
63
        if err != nil {
15✔
NEW
64
                return "", fmt.Errorf("reading otto metadata: %w", err)
×
NEW
65
        }
×
66
        var info packageInfo
15✔
67
        if err := json.Unmarshal(data, &info); err != nil {
16✔
68
                return "", fmt.Errorf("parsing otto metadata: %w", err)
1✔
69
        }
1✔
70
        return info.Version, nil
14✔
71
}
72

73
// LatestVersion fetches the latest version string from the CDN.
NEW
74
func LatestVersion() (string, error) {
×
NEW
75
        client := &http.Client{Timeout: 5 * time.Second}
×
NEW
76
        resp, err := client.Get(cdnBaseURL + "/latest/version")
×
NEW
77
        if err != nil {
×
NEW
78
                return "", fmt.Errorf("checking latest otto version: %w", err)
×
NEW
79
        }
×
NEW
80
        defer resp.Body.Close()
×
NEW
81
        if resp.StatusCode != http.StatusOK {
×
NEW
82
                return "", fmt.Errorf("checking latest otto version: HTTP %d", resp.StatusCode)
×
NEW
83
        }
×
NEW
84
        body, err := io.ReadAll(resp.Body)
×
NEW
85
        if err != nil {
×
NEW
86
                return "", fmt.Errorf("reading latest otto version: %w", err)
×
NEW
87
        }
×
NEW
88
        return strings.TrimSpace(string(body)), nil
×
89
}
90

91
// IsUpdateAvailable returns true if a newer version is available on the CDN.
92
func IsUpdateAvailable() (available bool, latest string, err error) {
1✔
93
        installed, err := InstalledVersion()
1✔
94
        if err != nil || installed == "" {
2✔
95
                return false, "", err
1✔
96
        }
1✔
NEW
97
        latest, err = LatestVersion()
×
NEW
98
        if err != nil {
×
NEW
99
                return false, "", err
×
NEW
100
        }
×
NEW
101
        iv, err := semver.NewVersion(installed)
×
NEW
102
        if err != nil {
×
NEW
103
                return false, latest, nil // can't parse installed, suggest update
×
NEW
104
        }
×
NEW
105
        lv, err := semver.NewVersion(latest)
×
NEW
106
        if err != nil {
×
NEW
107
                return false, "", fmt.Errorf("parsing latest version %q: %w", latest, err)
×
NEW
108
        }
×
NEW
109
        return lv.GreaterThan(iv), latest, nil
×
110
}
111

112
// EnsureBinary downloads Otto if it's missing or below MinVersion.
113
func EnsureBinary() error {
1✔
114
        version, err := InstalledVersion()
1✔
115
        if err != nil {
1✔
NEW
116
                return err
×
NEW
117
        }
×
118
        if version == "" {
1✔
NEW
119
                fmt.Println("Downloading Otto agent...")
×
NEW
120
                return downloadAndInstall()
×
NEW
121
        }
×
122
        iv, err := semver.NewVersion(version)
1✔
123
        if err != nil {
1✔
NEW
124
                return downloadAndInstall()
×
NEW
125
        }
×
126
        minVer, _ := semver.NewVersion(MinVersion)
1✔
127
        if iv.LessThan(minVer) {
1✔
NEW
128
                fmt.Printf("Otto %s is below minimum required version %s, updating...\n", version, MinVersion)
×
NEW
129
                return downloadAndInstall()
×
NEW
130
        }
×
131
        return nil
1✔
132
}
133

134
// Update downloads and installs the latest Otto binary.
NEW
135
func Update() error {
×
NEW
136
        fmt.Println("Updating Otto agent...")
×
NEW
137
        return downloadAndInstall()
×
NEW
138
}
×
139

140
// downloadURL constructs the CDN URL for the latest Otto on this platform.
141
func downloadURL() string {
1✔
142
        goos := runtime.GOOS   // "darwin", "linux", "windows"
1✔
143
        arch := runtime.GOARCH // "arm64", "amd64"
1✔
144
        if arch == "amd64" {
2✔
145
                arch = "x64"
1✔
146
        }
1✔
147
        if goos == windowsGOOS {
1✔
NEW
148
                return fmt.Sprintf("%s/latest/%s-%s-%s.exe.zip", cdnBaseURL, binaryName, goos, arch)
×
NEW
149
        }
×
150
        return fmt.Sprintf("%s/latest/%s-%s-%s.tar.gz", cdnBaseURL, binaryName, goos, arch)
1✔
151
}
152

153
// downloadAndInstall fetches the Otto archive and extracts it to BinDir().
NEW
154
func downloadAndInstall() error {
×
NEW
155
        url := downloadURL()
×
NEW
156
        client := &http.Client{Timeout: 120 * time.Second}
×
NEW
157
        resp, err := client.Get(url)
×
NEW
158
        if err != nil {
×
NEW
159
                return fmt.Errorf("downloading otto: %w", err)
×
NEW
160
        }
×
NEW
161
        defer resp.Body.Close()
×
NEW
162
        if resp.StatusCode != http.StatusOK {
×
NEW
163
                return fmt.Errorf("downloading otto: HTTP %d from %s", resp.StatusCode, url)
×
NEW
164
        }
×
165

NEW
166
        binDir := BinDir()
×
NEW
167
        if err := os.MkdirAll(binDir, dirPerm); err != nil {
×
NEW
168
                return fmt.Errorf("creating bin directory: %w", err)
×
NEW
169
        }
×
170

171
        // Save to a temp file first (needed for zip; simpler for tar.gz too)
NEW
172
        tmpFile, err := os.CreateTemp("", "otto-download-*")
×
NEW
173
        if err != nil {
×
NEW
174
                return fmt.Errorf("creating temp file: %w", err)
×
NEW
175
        }
×
NEW
176
        defer os.Remove(tmpFile.Name())
×
NEW
177
        if _, err := io.Copy(tmpFile, resp.Body); err != nil {
×
NEW
178
                tmpFile.Close()
×
NEW
179
                return fmt.Errorf("downloading otto: %w", err)
×
NEW
180
        }
×
NEW
181
        tmpFile.Close()
×
NEW
182

×
NEW
183
        if runtime.GOOS == windowsGOOS {
×
NEW
184
                if err := extractZip(tmpFile.Name(), binDir); err != nil {
×
NEW
185
                        return err
×
NEW
186
                }
×
NEW
187
        } else {
×
NEW
188
                if err := extractTarGz(tmpFile.Name(), binDir); err != nil {
×
NEW
189
                        return err
×
NEW
190
                }
×
191
        }
192

NEW
193
        if err := renamePlatformBinary(binDir); err != nil {
×
NEW
194
                return err
×
NEW
195
        }
×
NEW
196
        _ = os.Chmod(BinaryPath(), binPerm)
×
NEW
197

×
NEW
198
        v, _ := InstalledVersion()
×
NEW
199
        if v != "" {
×
NEW
200
                fmt.Printf("Otto %s installed\n", v)
×
NEW
201
        }
×
NEW
202
        return nil
×
203
}
204

205
// renamePlatformBinary renames the extracted `otto-<os>-<arch>` to `otto`,
206
// overwriting any prior version. os.Rename on POSIX replaces the target
207
// atomically, so an `astro agent update` reliably swaps the old binary out.
208
func renamePlatformBinary(binDir string) error {
1✔
209
        entries, err := os.ReadDir(binDir)
1✔
210
        if err != nil {
1✔
NEW
211
                return fmt.Errorf("reading bin directory: %w", err)
×
NEW
212
        }
×
213
        for _, e := range entries {
3✔
214
                if strings.HasPrefix(e.Name(), binaryName+"-") && !e.IsDir() {
3✔
215
                        if err := os.Rename(filepath.Join(binDir, e.Name()), BinaryPath()); err != nil {
1✔
NEW
216
                                return fmt.Errorf("installing otto binary: %w", err)
×
NEW
217
                        }
×
218
                        return nil
1✔
219
                }
220
        }
NEW
221
        return nil
×
222
}
223

224
// extractTarGz extracts a .tar.gz file to the target directory.
NEW
225
func extractTarGz(src, dst string) error {
×
NEW
226
        f, err := os.Open(src)
×
NEW
227
        if err != nil {
×
NEW
228
                return fmt.Errorf("opening archive: %w", err)
×
NEW
229
        }
×
NEW
230
        defer f.Close()
×
NEW
231

×
NEW
232
        gz, err := gzip.NewReader(f)
×
NEW
233
        if err != nil {
×
NEW
234
                return fmt.Errorf("decompressing archive: %w", err)
×
NEW
235
        }
×
NEW
236
        defer gz.Close()
×
NEW
237

×
NEW
238
        tr := tar.NewReader(gz)
×
NEW
239
        for {
×
NEW
240
                header, err := tr.Next()
×
NEW
241
                if err == io.EOF {
×
NEW
242
                        break
×
243
                }
NEW
244
                if err != nil {
×
NEW
245
                        return fmt.Errorf("reading archive: %w", err)
×
NEW
246
                }
×
247

NEW
248
                name := filepath.Clean(header.Name)
×
NEW
249
                if strings.Contains(name, "..") {
×
NEW
250
                        continue
×
251
                }
NEW
252
                target := filepath.Join(dst, name)
×
NEW
253

×
NEW
254
                switch header.Typeflag {
×
NEW
255
                case tar.TypeDir:
×
NEW
256
                        if err := os.MkdirAll(target, dirPerm); err != nil {
×
NEW
257
                                return fmt.Errorf("creating directory %s: %w", name, err)
×
NEW
258
                        }
×
NEW
259
                case tar.TypeReg:
×
NEW
260
                        if err := os.MkdirAll(filepath.Dir(target), dirPerm); err != nil {
×
NEW
261
                                return fmt.Errorf("creating parent dir for %s: %w", name, err)
×
NEW
262
                        }
×
NEW
263
                        out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
×
NEW
264
                        if err != nil {
×
NEW
265
                                return fmt.Errorf("writing %s: %w", name, err)
×
NEW
266
                        }
×
NEW
267
                        if _, err := io.Copy(out, tr); err != nil { //nolint:gosec
×
NEW
268
                                out.Close()
×
NEW
269
                                return fmt.Errorf("writing %s: %w", name, err)
×
NEW
270
                        }
×
NEW
271
                        out.Close()
×
272
                }
273
        }
NEW
274
        return nil
×
275
}
276

277
// extractZip extracts a .zip file to the target directory.
NEW
278
func extractZip(src, dst string) error {
×
NEW
279
        r, err := zip.OpenReader(src)
×
NEW
280
        if err != nil {
×
NEW
281
                return fmt.Errorf("opening zip archive: %w", err)
×
NEW
282
        }
×
NEW
283
        defer r.Close()
×
NEW
284

×
NEW
285
        for _, f := range r.File {
×
NEW
286
                name := filepath.Clean(f.Name)
×
NEW
287
                if strings.Contains(name, "..") {
×
NEW
288
                        continue
×
289
                }
NEW
290
                target := filepath.Join(dst, name)
×
NEW
291

×
NEW
292
                if f.FileInfo().IsDir() {
×
NEW
293
                        if err := os.MkdirAll(target, dirPerm); err != nil {
×
NEW
294
                                return fmt.Errorf("creating directory %s: %w", name, err)
×
NEW
295
                        }
×
NEW
296
                        continue
×
297
                }
298

NEW
299
                if err := os.MkdirAll(filepath.Dir(target), dirPerm); err != nil {
×
NEW
300
                        return fmt.Errorf("creating parent dir for %s: %w", name, err)
×
NEW
301
                }
×
NEW
302
                rc, err := f.Open()
×
NEW
303
                if err != nil {
×
NEW
304
                        return fmt.Errorf("reading %s: %w", name, err)
×
NEW
305
                }
×
NEW
306
                out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
×
NEW
307
                if err != nil {
×
NEW
308
                        rc.Close()
×
NEW
309
                        return fmt.Errorf("writing %s: %w", name, err)
×
NEW
310
                }
×
NEW
311
                if _, err := io.Copy(out, rc); err != nil { //nolint:gosec
×
NEW
312
                        out.Close()
×
NEW
313
                        rc.Close()
×
NEW
314
                        return fmt.Errorf("writing %s: %w", name, err)
×
NEW
315
                }
×
NEW
316
                out.Close()
×
NEW
317
                rc.Close()
×
318
        }
NEW
319
        return nil
×
320
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc