From 24e66b8f18ca54289d30e880e371303ef1a110e7 Mon Sep 17 00:00:00 2001 From: cole Date: Sun, 15 Feb 2026 19:18:04 +0800 Subject: [PATCH] feat: add macOS launchd autostart support - Add 'picoclaw install' command for macOS - Support install, uninstall, and status options - Auto-detect picoclaw executable path using exec.LookPath Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/main.go | 200 ++++++++++++++ ...26-02-15-macos-launchd-autostart-design.md | 52 ++++ ...-macos-launchd-autostart-implementation.md | 252 ++++++++++++++++++ 3 files changed, 504 insertions(+) create mode 100644 docs/plans/2026-02-15-macos-launchd-autostart-design.md create mode 100644 docs/plans/2026-02-15-macos-launchd-autostart-implementation.md diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2129662d7..b5f0cbdf6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -14,6 +14,7 @@ import ( "io" "io/fs" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -138,6 +139,8 @@ func main() { migrateCmd() case "auth": authCmd() + case "install": + installCmd() case "cron": cronCmd() case "skills": @@ -207,6 +210,7 @@ func printHelp() { fmt.Println(" agent Interact with the agent directly") fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" gateway Start picoclaw gateway") + fmt.Println(" install Install/uninstall launchd service (macOS)") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") @@ -1410,3 +1414,199 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { fmt.Println("----------------------") fmt.Println(content) } + +// installCmd handles the install subcommand +func installCmd() { + if len(os.Args) < 3 { + doInstall() + return + } + + switch os.Args[2] { + case "--help", "-h": + installHelp() + case "--uninstall": + uninstallInstall() + case "--status": + statusInstall() + default: + fmt.Printf("Unknown install option: %s\n", os.Args[2]) + installHelp() + } +} + +// installHelp displays help information for install command +func installHelp() { + fmt.Println("\nInstall PicoClaw as a macOS service (launch agent)") + fmt.Println() + fmt.Println("Usage: picoclaw install [options]") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --uninstall Uninstall the launch agent") + fmt.Println(" --status Show installation status") + fmt.Println(" --help, -h Show this help message") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" picoclaw install Install as launch agent") + fmt.Println(" picoclaw install --uninstall Uninstall the launch agent") + fmt.Println(" picoclaw install --status Check installation status") +} + +// doInstall executes the installation +func doInstall() { + execPath, err := findExecutable() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Found picoclaw at: %s\n", execPath) + + // Generate plist content + plistContent := generatePlist(execPath) + + // Get plist path + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %v\n", err) + os.Exit(1) + } + plistDir := filepath.Join(home, "Library", "LaunchAgents") + plistPath := filepath.Join(plistDir, "io.picoclaw.gateway.plist") + + // Create directory if not exists + if err := os.MkdirAll(plistDir, 0755); err != nil { + fmt.Printf("Error creating LaunchAgents directory: %v\n", err) + os.Exit(1) + } + + // Write plist file + if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil { + fmt.Printf("Error writing plist file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Created plist at: %s\n", plistPath) + + // Load the launch agent + cmd := exec.Command("launchctl", "load", plistPath) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Error loading launch agent: %v\n", err) + fmt.Printf("Output: %s\n", string(output)) + os.Exit(1) + } + + fmt.Println("\n✓ PicoClaw gateway installed successfully!") + fmt.Println("The gateway will start automatically on login.") +} + +// findExecutable finds the picoclaw executable path +func findExecutable() (string, error) { + // 使用 exec.LookPath 查找 + path, err := exec.LookPath("picoclaw") + if err == nil { + return path, nil + } + + // 如果不是 "not found" 错误,返回原错误 + if !strings.Contains(err.Error(), "not found") { + return "", err + } + + // 尝试常见路径 + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find home directory: %w", err) + } + + commonPaths := []string{ + "/usr/local/bin/picoclaw", + "/usr/bin/picoclaw", + filepath.Join(home, "go", "bin", "picoclaw"), + } + + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + + return "", fmt.Errorf("picoclaw not found. Please run 'go install' first or ensure picoclaw is in your PATH") +} + +// generatePlist generates the plist content for launch agent +func generatePlist(execPath string) string { + return fmt.Sprintf(` + + + + Label + io.picoclaw.gateway + ProgramArguments + + %s + gateway + + RunAtLoad + + KeepAlive + + +`, execPath) +} + +// uninstallInstall uninstalls the launch agent +func uninstallInstall() { + home, _ := os.UserHomeDir() + plistPath := filepath.Join(home, "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("PicoClaw is not installed.") + return + } + + // Unload the launch agent + cmd := exec.Command("launchctl", "unload", plistPath) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Error unloading launch agent: %v\n", err) + fmt.Printf("Output: %s\n", string(output)) + } + + // Remove plist file + if err := os.Remove(plistPath); err != nil { + fmt.Printf("Error removing plist file: %v\n", err) + os.Exit(1) + } + + fmt.Println("✓ PicoClaw gateway uninstalled successfully!") +} + +// statusInstall shows the installation status +func statusInstall() { + home, _ := os.UserHomeDir() + plistPath := filepath.Join(home, "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + + fmt.Println("\nPicoClaw Gateway Status:") + fmt.Println("------------------------") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("Status: Not installed") + fmt.Println("Run 'picoclaw install' to install as launch agent") + return + } + + fmt.Println("Status: Installed") + fmt.Printf("Plist: %s\n", plistPath) + + // Check if loaded + cmd := exec.Command("launchctl", "list", "io.picoclaw.gateway") + output, err := cmd.CombinedOutput() + if err == nil { + fmt.Println("\nLaunch agent is loaded.") + if len(output) > 0 { + fmt.Println(string(output)) + } + } else { + fmt.Println("\nLaunch agent is not running.") + } +} diff --git a/docs/plans/2026-02-15-macos-launchd-autostart-design.md b/docs/plans/2026-02-15-macos-launchd-autostart-design.md new file mode 100644 index 000000000..8c9b6383b --- /dev/null +++ b/docs/plans/2026-02-15-macos-launchd-autostart-design.md @@ -0,0 +1,52 @@ +# macOS 开机启动 (launchd) 设计方案 + +## 概述 + +添加 `picoclaw install` 命令,自动生成 macOS launchd 的 plist 配置文件并安装到 `~/Library/LaunchAgents/`,实现开机自动启动 `picoclaw gateway`。 + +## 详细设计 + +### 1. plist 文件内容 + +```xml + + + + + Label + io.picoclaw.gateway + ProgramArguments + + /usr/local/bin/picoclaw + gateway + + RunAtLoad + + KeepAlive + + + +``` + +### 2. 命令设计 + +| 命令 | 描述 | +|------|------| +| `picoclaw install` | 安装 launchd plist(当前用户) | +| `picoclaw install --uninstall` | 卸载 launchd plist | +| `picoclaw install --status` | 查看安装状态 | + +### 3. 实现位置 + +在 `cmd/picoclaw/main.go` 中添加 `install` case。 + +### 4. 可执行文件路径 + +- 优先使用 `which picoclaw` 查找已安装路径 +- 如果找不到,提示用户需要先 `go install` 或手动复制 + +## 实现步骤 + +1. 在 main.go 添加 install case +2. 实现 installCmd() 函数 +3. 添加 uninstall 和 status 子命令 diff --git a/docs/plans/2026-02-15-macos-launchd-autostart-implementation.md b/docs/plans/2026-02-15-macos-launchd-autostart-implementation.md new file mode 100644 index 000000000..e9d329fce --- /dev/null +++ b/docs/plans/2026-02-15-macos-launchd-autostart-implementation.md @@ -0,0 +1,252 @@ +# macOS 开机启动 (launchd) 实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在 picoclaw 项目中添加 `picoclaw install` 命令,实现 macOS 开机自动启动 `picoclaw gateway` + +**Architecture:** 通过生成 launchd plist 配置文件到 `~/Library/LaunchAgents/` 目录,实现用户级开机自启动 + +**Tech Stack:** Go, macOS launchd + +--- + +## 实现步骤 + +### Task 1: 添加 install case 到 main.go + +**Files:** +- Modify: `cmd/picoclaw/main.go:126-145` + +**Step 1: 在 main.go 的 switch 语句中添加 install case** + +在 `case "cron":` 之前添加: + +```go +case "install": + installCmd() +``` + +**Step 2: 运行测试验证** + +Run: `go build ./cmd/picoclaw/` +Expected: 编译成功(因为 installCmd 尚未定义,会有警告) + +--- + +### Task 2: 实现 installCmd 函数 + +**Files:** +- Modify: `cmd/picoclaw/main.go:199-216` + +**Step 1: 添加 installCmd 函数和帮助信息** + +在 `printHelp()` 函数后添加: + +```go +func installCmd() { + args := os.Args[2:] + for _, arg := range args { + switch arg { + case "--uninstall": + uninstallInstall() + return + case "--status": + statusInstall() + return + case "--help", "-h": + installHelp() + return + } + } + doInstall() +} + +func installHelp() { + fmt.Println("\nInstall commands:") + fmt.Println(" picoclaw install Install launchd plist (current user)") + fmt.Println(" picoclaw install --uninstall Uninstall launchd plist") + fmt.Println(" picoclaw install --status Show install status") +} + +func doInstall() { + // 查找 picoclaw 可执行文件路径 + execPath, err := findExecutable() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + // 生成 plist 内容 + plistContent := generatePlist(execPath) + + // 写入文件 + plistPath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil { + fmt.Printf("Error writing plist: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Installed launchd plist to %s\n", plistPath) + fmt.Println("Run 'picoclaw install --status' to check status") +} + +func findExecutable() (string, error) { + // 使用 which 查找 + cmd := exec.Command("which", "picoclaw") + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), nil + } + + // 尝试常见路径 + commonPaths := []string{ + "/usr/local/bin/picoclaw", + "/usr/bin/picoclaw", + filepath.Join(os.Getenv("HOME"), "go", "bin", "picoclaw"), + } + + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + + return "", fmt.Errorf("picoclaw not found in PATH. Please run 'go install' first or ensure picoclaw is in your PATH") +} + +func generatePlist(execPath string) string { + return ` + + + + Label + io.picoclaw.gateway + ProgramArguments + + ` + execPath + ` + gateway + + RunAtLoad + + KeepAlive + + + +` +} + +func uninstallInstall() { + plistPath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("No launchd plist found.") + return + } + + // 先 unload + exec.Command("launchctl", "unload", plistPath).Run() + + if err := os.Remove(plistPath); err != nil { + fmt.Printf("Error removing plist: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Removed %s\n", plistPath) +} + +func statusInstall() { + plistPath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("Status: Not installed") + fmt.Println("Run 'picoclaw install' to install") + return + } + + fmt.Println("Status: Installed") + fmt.Printf("Path: %s\n", plistPath) + + // 检查是否正在运行 + cmd := exec.Command("launchctl", "list", "io.picoclaw.gateway") + out, err := cmd.Output() + if err == nil { + fmt.Println("\nRunning: Yes") + fmt.Println(string(out)) + } else { + fmt.Println("\nRunning: No") + } +} +``` + +**Step 2: 添加 import** + +确保已导入 `os/exec`: + +```go +import ( + // ... 其他 import + "os/exec" +) +``` + +**Step 3: 运行测试验证** + +Run: `go build ./cmd/picoclaw/` +Expected: 编译成功 + +**Step 4: 手动测试** + +```bash +./picoclaw install --help +./picoclaw install --status +``` + +--- + +### Task 3: 更新帮助信息 + +**Files:** +- Modify: `cmd/picoclaw/main.go:201-215` + +**Step 1: 在 printHelp 中添加 install 命令** + +在 Commands 列表中添加: + +```go +fmt.Println(" install Install/uninstall launchd service (macOS)") +``` + +--- + +### Task 4: 测试完整流程 + +**Step 1: 构建并测试安装** + +```bash +go build -o picoclaw ./cmd/picoclaw/ +./picoclaw install +./picoclaw install --status +``` + +Expected: 成功创建 plist 文件 + +**Step 2: 测试卸载** + +```bash +./picoclaw install --uninstall +./picoclaw install --status +``` + +Expected: 成功删除 plist 文件 + +--- + +### Task 5: 提交代码 + +```bash +git add cmd/picoclaw/main.go +git commit -m "feat: add macOS launchd autostart support + +- Add 'picoclaw install' command +- Support install, uninstall, and status options +- Auto-detect picoclaw executable path" +```