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 <noreply@anthropic.com>
This commit is contained in:
cole 2026-02-15 19:18:04 +08:00
parent 20f40139d1
commit 24e66b8f18
3 changed files with 504 additions and 0 deletions

View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.picoclaw.gateway</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>`, 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.")
}
}

View file

@ -0,0 +1,52 @@
# macOS 开机启动 (launchd) 设计方案
## 概述
添加 `picoclaw install` 命令,自动生成 macOS launchd 的 plist 配置文件并安装到 `~/Library/LaunchAgents/`,实现开机自动启动 `picoclaw gateway`
## 详细设计
### 1. plist 文件内容
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.picoclaw.gateway</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/picoclaw</string>
<string>gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
```
### 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 子命令

View file

@ -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 `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.picoclaw.gateway</string>
<key>ProgramArguments</key>
<array>
<string>` + execPath + `</string>
<string>gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
`
}
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"
```