@agentspan/agentspan 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cmd/server.go ADDED
@@ -0,0 +1,446 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ package cmd
5
+
6
+ import (
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "os/exec"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "time"
16
+
17
+ "github.com/agentspan/agentspan/cli/config"
18
+ "github.com/agentspan/agentspan/cli/internal/progress"
19
+ "github.com/fatih/color"
20
+ "github.com/spf13/cobra"
21
+ )
22
+
23
+ const (
24
+ s3Bucket = "https://agentspan.s3.us-east-2.amazonaws.com"
25
+ jarName = "agentspan-runtime.jar"
26
+ )
27
+
28
+ var (
29
+ serverPort string
30
+ serverModel string
31
+ serverVersion string
32
+ serverJar string
33
+ serverLocal bool
34
+ followLogs bool
35
+ )
36
+
37
+ var serverCmd = &cobra.Command{
38
+ Use: "server",
39
+ Short: "Manage the agent runtime server",
40
+ }
41
+
42
+ var serverStartCmd = &cobra.Command{
43
+ Use: "start",
44
+ Short: "Download (if needed) and start the agent runtime server",
45
+ RunE: runServerStart,
46
+ }
47
+
48
+ var serverStopCmd = &cobra.Command{
49
+ Use: "stop",
50
+ Short: "Stop the running agent runtime server",
51
+ RunE: runServerStop,
52
+ }
53
+
54
+ var serverLogsCmd = &cobra.Command{
55
+ Use: "logs",
56
+ Short: "Show server logs",
57
+ RunE: runServerLogs,
58
+ }
59
+
60
+ func init() {
61
+ serverStartCmd.Flags().StringVarP(&serverPort, "port", "p", "8080", "Server port")
62
+ serverStartCmd.Flags().StringVarP(&serverModel, "model", "m", "", "Default LLM model (e.g. openai/gpt-4o)")
63
+ serverStartCmd.Flags().StringVar(&serverVersion, "version", "", "Specific server version to download (e.g. 0.1.0)")
64
+ serverStartCmd.Flags().StringVar(&serverJar, "jar", "", "Path to a local JAR file to use directly")
65
+ serverStartCmd.Flags().BoolVar(&serverLocal, "local", false, "Use locally built JAR from server/build/libs/")
66
+
67
+ serverLogsCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "Follow log output")
68
+
69
+ serverCmd.AddCommand(serverStartCmd, serverStopCmd, serverLogsCmd)
70
+ rootCmd.AddCommand(serverCmd)
71
+ }
72
+
73
+ func serverDir() string {
74
+ return filepath.Join(config.ConfigDir(), "server")
75
+ }
76
+
77
+ func pidFile() string {
78
+ return filepath.Join(serverDir(), "server.pid")
79
+ }
80
+
81
+ func logFile() string {
82
+ return filepath.Join(serverDir(), "server.log")
83
+ }
84
+
85
+ func runServerStart(cmd *cobra.Command, args []string) error {
86
+ dir := serverDir()
87
+ if err := os.MkdirAll(dir, 0o755); err != nil {
88
+ return fmt.Errorf("create server dir: %w", err)
89
+ }
90
+
91
+ var jarPath string
92
+ switch {
93
+ case serverJar != "":
94
+ // Use explicit JAR path
95
+ abs, err := filepath.Abs(serverJar)
96
+ if err != nil {
97
+ return fmt.Errorf("resolve JAR path: %w", err)
98
+ }
99
+ if _, err := os.Stat(abs); err != nil {
100
+ return fmt.Errorf("JAR not found: %s", abs)
101
+ }
102
+ jarPath = abs
103
+ color.Green("Using JAR: %s", jarPath)
104
+
105
+ case serverLocal:
106
+ // Find locally built JAR by walking up from executable or CWD
107
+ localJar, err := findLocalJAR()
108
+ if err != nil {
109
+ return err
110
+ }
111
+ jarPath = localJar
112
+ color.Green("Using local JAR: %s", jarPath)
113
+
114
+ case serverVersion != "":
115
+ jarPath = filepath.Join(dir, fmt.Sprintf("agentspan-runtime-%s.jar", serverVersion))
116
+ if err := ensureVersionedJAR(jarPath, serverVersion); err != nil {
117
+ return err
118
+ }
119
+
120
+ default:
121
+ jarPath = filepath.Join(dir, jarName)
122
+ if err := ensureLatestJAR(jarPath); err != nil {
123
+ return err
124
+ }
125
+ }
126
+
127
+ // Check if already running
128
+ if pid, err := readPID(); err == nil {
129
+ if processRunning(pid) {
130
+ color.Yellow("Server already running (PID %d). Stop it first with: agentspan server stop", pid)
131
+ return nil
132
+ }
133
+ // Stale PID file
134
+ os.Remove(pidFile())
135
+ }
136
+
137
+ checkAIProviderKeys()
138
+
139
+ bold := color.New(color.Bold)
140
+ bold.Printf("Starting agent runtime on port %s...\n", serverPort)
141
+
142
+ // Build java args
143
+ javaArgs := []string{"-jar", jarPath}
144
+
145
+ env := os.Environ()
146
+ if serverPort != "8080" {
147
+ env = append(env, "SERVER_PORT="+serverPort)
148
+ }
149
+ if serverModel != "" {
150
+ env = append(env, "AGENT_DEFAULT_MODEL="+serverModel)
151
+ }
152
+
153
+ // Open log file
154
+ logF, err := os.OpenFile(logFile(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
155
+ if err != nil {
156
+ return fmt.Errorf("open log file: %w", err)
157
+ }
158
+
159
+ proc := exec.Command("java", javaArgs...)
160
+ proc.Env = env
161
+ proc.Stdout = logF
162
+ proc.Stderr = logF
163
+ proc.SysProcAttr = sysProcAttr()
164
+
165
+ if err := proc.Start(); err != nil {
166
+ logF.Close()
167
+ return fmt.Errorf("failed to start server: %w", err)
168
+ }
169
+
170
+ // Write PID
171
+ pid := proc.Process.Pid
172
+ if err := os.WriteFile(pidFile(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
173
+ logF.Close()
174
+ return fmt.Errorf("write PID file: %w", err)
175
+ }
176
+
177
+ // Detach - release the process so CLI can exit
178
+ proc.Process.Release()
179
+ logF.Close()
180
+
181
+ color.Green("Server started (PID %d)", pid)
182
+ fmt.Printf(" Logs: %s\n", logFile())
183
+ fmt.Printf(" URL: http://localhost:%s\n", serverPort)
184
+ fmt.Println("\nUse 'agentspan server logs -f' to follow output.")
185
+ return nil
186
+ }
187
+
188
+ func runServerStop(cmd *cobra.Command, args []string) error {
189
+ pid, err := readPID()
190
+ if err != nil {
191
+ color.Yellow("No server PID file found. Server may not be running.")
192
+ return nil
193
+ }
194
+
195
+ if !processRunning(pid) {
196
+ os.Remove(pidFile())
197
+ color.Yellow("Server process (PID %d) is not running. Cleaned up stale PID file.", pid)
198
+ return nil
199
+ }
200
+
201
+ process, err := os.FindProcess(pid)
202
+ if err != nil {
203
+ return fmt.Errorf("find process %d: %w", pid, err)
204
+ }
205
+
206
+ if err := killProcess(process); err != nil {
207
+ return fmt.Errorf("stop process %d: %w", pid, err)
208
+ }
209
+
210
+ os.Remove(pidFile())
211
+ color.Green("Server stopped (PID %d)", pid)
212
+ return nil
213
+ }
214
+
215
+ func runServerLogs(cmd *cobra.Command, args []string) error {
216
+ path := logFile()
217
+ if _, err := os.Stat(path); os.IsNotExist(err) {
218
+ return fmt.Errorf("no log file found at %s", path)
219
+ }
220
+
221
+ if !followLogs {
222
+ data, err := os.ReadFile(path)
223
+ if err != nil {
224
+ return err
225
+ }
226
+ fmt.Print(string(data))
227
+ return nil
228
+ }
229
+
230
+ // Follow mode: tail -f style
231
+ f, err := os.Open(path)
232
+ if err != nil {
233
+ return err
234
+ }
235
+ defer f.Close()
236
+
237
+ // Seek to end
238
+ f.Seek(0, io.SeekEnd)
239
+
240
+ buf := make([]byte, 4096)
241
+ for {
242
+ n, err := f.Read(buf)
243
+ if n > 0 {
244
+ fmt.Print(string(buf[:n]))
245
+ }
246
+ if err == io.EOF {
247
+ time.Sleep(200 * time.Millisecond)
248
+ continue
249
+ }
250
+ if err != nil {
251
+ return err
252
+ }
253
+ }
254
+ }
255
+
256
+ // --- Local JAR helpers ---
257
+
258
+ func findLocalJAR() (string, error) {
259
+ // Try CWD first, then walk up to find 'server/build/libs/agentspan-runtime.jar'
260
+ cwd, err := os.Getwd()
261
+ if err != nil {
262
+ return "", fmt.Errorf("get working directory: %w", err)
263
+ }
264
+
265
+ // Check common relative paths from likely CWD locations
266
+ candidates := []string{
267
+ filepath.Join(cwd, "server", "build", "libs", jarName),
268
+ filepath.Join(cwd, "build", "libs", jarName),
269
+ filepath.Join(cwd, "..", "server", "build", "libs", jarName),
270
+ }
271
+
272
+ // Also walk up from CWD looking for server/build/libs/
273
+ dir := cwd
274
+ for i := 0; i < 5; i++ {
275
+ candidate := filepath.Join(dir, "server", "build", "libs", jarName)
276
+ candidates = append(candidates, candidate)
277
+ parent := filepath.Dir(dir)
278
+ if parent == dir {
279
+ break
280
+ }
281
+ dir = parent
282
+ }
283
+
284
+ for _, c := range candidates {
285
+ if _, err := os.Stat(c); err == nil {
286
+ return filepath.Abs(c)
287
+ }
288
+ }
289
+
290
+ return "", fmt.Errorf("local JAR not found. Build it first with: cd server && ./gradlew build")
291
+ }
292
+
293
+ // --- JAR download helpers ---
294
+
295
+ func ensureVersionedJAR(jarPath, version string) error {
296
+ if _, err := os.Stat(jarPath); err == nil {
297
+ color.Green("Using cached JAR for version %s", version)
298
+ return nil
299
+ }
300
+
301
+ downloadURL := fmt.Sprintf("%s/agentspan-server-%s.jar", s3Bucket, version)
302
+ return downloadJAR(downloadURL, jarPath)
303
+ }
304
+
305
+ func ensureLatestJAR(jarPath string) error {
306
+ downloadURL := fmt.Sprintf("%s/agentspan-server-latest.jar", s3Bucket)
307
+
308
+ // If we already have a cached JAR, do a HEAD request to check if remote has changed
309
+ if info, err := os.Stat(jarPath); err == nil {
310
+ httpClient := &http.Client{Timeout: 15 * time.Second}
311
+ resp, err := httpClient.Head(downloadURL)
312
+ if err != nil {
313
+ color.Yellow("Could not check for updates (%v), using cached JAR", err)
314
+ return nil
315
+ }
316
+ resp.Body.Close()
317
+
318
+ if resp.StatusCode == http.StatusOK {
319
+ // Compare content-length as a simple freshness check
320
+ remoteSize := resp.ContentLength
321
+ if remoteSize > 0 && remoteSize == info.Size() {
322
+ color.Green("Server JAR is up to date")
323
+ return nil
324
+ }
325
+ } else if resp.StatusCode == http.StatusNotFound {
326
+ color.Yellow("No remote release found, using cached JAR")
327
+ return nil
328
+ }
329
+ }
330
+
331
+ return downloadJAR(downloadURL, jarPath)
332
+ }
333
+
334
+ func downloadJAR(downloadURL, destPath string) error {
335
+ color.Yellow("Downloading server JAR...")
336
+ fmt.Printf(" URL: %s\n", downloadURL)
337
+
338
+ httpClient := &http.Client{
339
+ Timeout: 10 * time.Minute,
340
+ }
341
+
342
+ resp, err := httpClient.Get(downloadURL)
343
+ if err != nil {
344
+ return fmt.Errorf("download: %w", err)
345
+ }
346
+ defer resp.Body.Close()
347
+
348
+ if resp.StatusCode != http.StatusOK {
349
+ return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
350
+ }
351
+
352
+ // Write to temp file first, then rename
353
+ tmpPath := destPath + ".tmp"
354
+ f, err := os.Create(tmpPath)
355
+ if err != nil {
356
+ return fmt.Errorf("create temp file: %w", err)
357
+ }
358
+
359
+ pr, bar := progress.NewReader(resp.Body, resp.ContentLength, "Downloading")
360
+ _, err = io.Copy(f, pr)
361
+ f.Close()
362
+ bar.Finish()
363
+ if err != nil {
364
+ os.Remove(tmpPath)
365
+ return fmt.Errorf("write JAR: %w", err)
366
+ }
367
+
368
+ if err := os.Rename(tmpPath, destPath); err != nil {
369
+ os.Remove(tmpPath)
370
+ return fmt.Errorf("rename JAR: %w", err)
371
+ }
372
+
373
+ color.Green("Download complete!")
374
+ return nil
375
+ }
376
+
377
+ // --- AI provider check ---
378
+
379
+ const aiModelsDocURL = "https://github.com/agentspan/agentspan/blob/main/docs/ai-models.md"
380
+
381
+ func checkAIProviderKeys() {
382
+ hasAny := false
383
+ for _, p := range aiProviders {
384
+ allSet := true
385
+ for _, env := range p.envVars {
386
+ if os.Getenv(env) == "" {
387
+ allSet = false
388
+ break
389
+ }
390
+ }
391
+ if allSet {
392
+ hasAny = true
393
+ break
394
+ }
395
+ }
396
+
397
+ // Check provider-specific warnings
398
+ for _, p := range aiProviders {
399
+ for _, w := range p.warns {
400
+ if w.condition() {
401
+ warn := color.New(color.FgYellow, color.Bold)
402
+ warn.Printf("WARNING: %s — %s\n", p.name, w.message)
403
+ fmt.Println()
404
+ fmt.Printf(" %s\n", w.fix)
405
+ fmt.Println()
406
+ }
407
+ }
408
+ }
409
+
410
+ if hasAny {
411
+ return
412
+ }
413
+
414
+ warn := color.New(color.FgYellow, color.Bold)
415
+ warn.Println("WARNING: No AI provider API keys detected!")
416
+ fmt.Println()
417
+ fmt.Println(" The server will start, but agents won't be able to call any LLM")
418
+ fmt.Println(" until you set at least one provider's API key.")
419
+ fmt.Println()
420
+ fmt.Println(" Set one or more of these before starting the server:")
421
+ fmt.Println()
422
+ fmt.Println(" # OpenAI")
423
+ fmt.Println(" export OPENAI_API_KEY=sk-...")
424
+ fmt.Println()
425
+ fmt.Println(" # Anthropic (Claude)")
426
+ fmt.Println(" export ANTHROPIC_API_KEY=sk-ant-...")
427
+ fmt.Println()
428
+ fmt.Println(" # Google Gemini")
429
+ fmt.Println(" export GEMINI_API_KEY=AI...")
430
+ fmt.Println(" export GOOGLE_CLOUD_PROJECT=your-gcp-project-id")
431
+ fmt.Println()
432
+ fmt.Println(" Run 'agentspan doctor' for a full diagnostic.")
433
+ fmt.Printf(" Docs: %s\n", aiModelsDocURL)
434
+ fmt.Println()
435
+ }
436
+
437
+ // --- PID helpers ---
438
+
439
+ func readPID() (int, error) {
440
+ data, err := os.ReadFile(pidFile())
441
+ if err != nil {
442
+ return 0, err
443
+ }
444
+ return strconv.Atoi(strings.TrimSpace(string(data)))
445
+ }
446
+
@@ -0,0 +1,36 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ //go:build !windows
5
+
6
+ package cmd
7
+
8
+ import (
9
+ "os"
10
+ "syscall"
11
+ )
12
+
13
+ func sysProcAttr() *syscall.SysProcAttr {
14
+ return &syscall.SysProcAttr{Setpgid: true}
15
+ }
16
+
17
+ func killProcess(process *os.Process) error {
18
+ return process.Signal(syscall.SIGTERM)
19
+ }
20
+
21
+ func processRunning(pid int) bool {
22
+ process, err := os.FindProcess(pid)
23
+ if err != nil {
24
+ return false
25
+ }
26
+ err = process.Signal(syscall.Signal(0))
27
+ return err == nil
28
+ }
29
+
30
+ func getFreeDiskMB(path string) int64 {
31
+ var stat syscall.Statfs_t
32
+ if err := syscall.Statfs(path, &stat); err != nil {
33
+ return -1
34
+ }
35
+ return int64(stat.Bavail) * int64(stat.Bsize) / (1024 * 1024)
36
+ }
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ //go:build windows
5
+
6
+ package cmd
7
+
8
+ import (
9
+ "os"
10
+ "syscall"
11
+ )
12
+
13
+ func sysProcAttr() *syscall.SysProcAttr {
14
+ return &syscall.SysProcAttr{}
15
+ }
16
+
17
+ func killProcess(process *os.Process) error {
18
+ return process.Kill()
19
+ }
20
+
21
+ func processRunning(pid int) bool {
22
+ process, err := os.FindProcess(pid)
23
+ if err != nil {
24
+ return false
25
+ }
26
+ // On Windows, FindProcess always succeeds; try to open the process
27
+ err = process.Signal(os.Kill)
28
+ if err != nil {
29
+ return false
30
+ }
31
+ return true
32
+ }
33
+
34
+ func getFreeDiskMB(path string) int64 {
35
+ // Not easily available on Windows without unsafe; skip check
36
+ return -1
37
+ }
package/cmd/status.go ADDED
@@ -0,0 +1,72 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ package cmd
5
+
6
+ import (
7
+ "fmt"
8
+
9
+ "github.com/fatih/color"
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ var statusCmd = &cobra.Command{
14
+ Use: "status <execution-id>",
15
+ Short: "Get the detailed status of an agent execution",
16
+ Args: cobra.ExactArgs(1),
17
+ RunE: func(cmd *cobra.Command, args []string) error {
18
+ cfg := getConfig()
19
+ c := newClient(cfg)
20
+
21
+ detail, err := c.GetExecutionDetail(args[0])
22
+ if err != nil {
23
+ return fmt.Errorf("failed to get status: %w", err)
24
+ }
25
+
26
+ bold := color.New(color.Bold)
27
+ bold.Printf("Execution: %s\n", detail.WorkflowID)
28
+ fmt.Printf(" Agent: %s (v%d)\n", detail.AgentName, detail.Version)
29
+
30
+ statusColor := color.FgWhite
31
+ switch detail.Status {
32
+ case "RUNNING":
33
+ statusColor = color.FgYellow
34
+ case "COMPLETED":
35
+ statusColor = color.FgGreen
36
+ case "FAILED", "TERMINATED", "TIMED_OUT":
37
+ statusColor = color.FgRed
38
+ }
39
+ color.New(statusColor, color.Bold).Printf(" Status: %s\n", detail.Status)
40
+
41
+ if detail.Input != nil {
42
+ fmt.Printf("\nInput:\n")
43
+ printJSON(detail.Input)
44
+ }
45
+
46
+ if detail.Output != nil {
47
+ fmt.Printf("\nOutput:\n")
48
+ printJSON(detail.Output)
49
+ }
50
+
51
+ if detail.CurrentTask != nil {
52
+ fmt.Printf("\nCurrent Task:\n")
53
+ fmt.Printf(" Name: %s\n", detail.CurrentTask.TaskRefName)
54
+ fmt.Printf(" Type: %s\n", detail.CurrentTask.TaskType)
55
+ fmt.Printf(" Status: %s\n", detail.CurrentTask.Status)
56
+ if detail.CurrentTask.InputData != nil {
57
+ fmt.Printf(" Input:\n")
58
+ printJSON(detail.CurrentTask.InputData)
59
+ }
60
+ if detail.CurrentTask.OutputData != nil {
61
+ fmt.Printf(" Output:\n")
62
+ printJSON(detail.CurrentTask.OutputData)
63
+ }
64
+ }
65
+
66
+ return nil
67
+ },
68
+ }
69
+
70
+ func init() {
71
+ agentCmd.AddCommand(statusCmd)
72
+ }
package/cmd/stream.go ADDED
@@ -0,0 +1,32 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ package cmd
5
+
6
+ import (
7
+ "fmt"
8
+
9
+ "github.com/spf13/cobra"
10
+ )
11
+
12
+ var streamLastEventID string
13
+
14
+ var streamCmd = &cobra.Command{
15
+ Use: "stream <workflow-id>",
16
+ Short: "Stream events from a running agent",
17
+ Args: cobra.ExactArgs(1),
18
+ RunE: func(cmd *cobra.Command, args []string) error {
19
+ cfg := getConfig()
20
+ c := newClient(cfg)
21
+
22
+ workflowID := args[0]
23
+ fmt.Printf("Streaming events for %s...\n\n", workflowID)
24
+
25
+ return streamWorkflow(c, workflowID)
26
+ },
27
+ }
28
+
29
+ func init() {
30
+ streamCmd.Flags().StringVar(&streamLastEventID, "last-event-id", "", "Resume from a specific event ID")
31
+ agentCmd.AddCommand(streamCmd)
32
+ }
package/cmd/update.go ADDED
@@ -0,0 +1,91 @@
1
+ // Copyright (c) 2025 AgentSpan
2
+ // Licensed under the MIT License. See LICENSE file in the project root for details.
3
+
4
+ package cmd
5
+
6
+ import (
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "runtime"
12
+ "time"
13
+
14
+ "github.com/agentspan/agentspan/cli/internal/progress"
15
+ "github.com/fatih/color"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ const cliS3Bucket = "https://agentspan.s3.us-east-2.amazonaws.com"
20
+
21
+ var updateCmd = &cobra.Command{
22
+ Use: "update",
23
+ Short: "Update the CLI to the latest version",
24
+ RunE: func(cmd *cobra.Command, args []string) error {
25
+ goos := runtime.GOOS
26
+ goarch := runtime.GOARCH
27
+
28
+ binaryName := fmt.Sprintf("agentspan_%s_%s", goos, goarch)
29
+ if goos == "windows" {
30
+ binaryName += ".exe"
31
+ }
32
+
33
+ downloadURL := fmt.Sprintf("%s/cli/latest/%s", cliS3Bucket, binaryName)
34
+
35
+ color.Yellow("Downloading latest CLI...")
36
+ fmt.Printf(" URL: %s\n", downloadURL)
37
+
38
+ httpClient := &http.Client{Timeout: 5 * time.Minute}
39
+ resp, err := httpClient.Get(downloadURL)
40
+ if err != nil {
41
+ return fmt.Errorf("download failed: %w", err)
42
+ }
43
+ defer resp.Body.Close()
44
+
45
+ if resp.StatusCode != http.StatusOK {
46
+ return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
47
+ }
48
+
49
+ // Find current executable path
50
+ execPath, err := os.Executable()
51
+ if err != nil {
52
+ return fmt.Errorf("find executable path: %w", err)
53
+ }
54
+
55
+ // Write to temp file with progress bar
56
+ tmpPath := execPath + ".new"
57
+ f, err := os.Create(tmpPath)
58
+ if err != nil {
59
+ return fmt.Errorf("create temp file: %w", err)
60
+ }
61
+
62
+ pr, bar := progress.NewReader(resp.Body, resp.ContentLength, "Downloading")
63
+ _, err = io.Copy(f, pr)
64
+ f.Close()
65
+ bar.Finish()
66
+ if err != nil {
67
+ os.Remove(tmpPath)
68
+ return fmt.Errorf("write binary: %w", err)
69
+ }
70
+
71
+ // Make executable
72
+ if err := os.Chmod(tmpPath, 0o755); err != nil {
73
+ os.Remove(tmpPath)
74
+ return fmt.Errorf("chmod: %w", err)
75
+ }
76
+
77
+ // Replace current executable
78
+ if err := os.Rename(tmpPath, execPath); err != nil {
79
+ os.Remove(tmpPath)
80
+ return fmt.Errorf("replace binary: %w", err)
81
+ }
82
+
83
+ color.Green("Updated successfully!")
84
+ fmt.Println("Run 'agentspan version' to see the new version.")
85
+ return nil
86
+ },
87
+ }
88
+
89
+ func init() {
90
+ rootCmd.AddCommand(updateCmd)
91
+ }