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"
"io/fs" "io/fs"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
@ -138,6 +139,8 @@ func main() {
migrateCmd() migrateCmd()
case "auth": case "auth":
authCmd() authCmd()
case "install":
installCmd()
case "cron": case "cron":
cronCmd() cronCmd()
case "skills": case "skills":
@ -207,6 +210,7 @@ func printHelp() {
fmt.Println(" agent Interact with the agent directly") fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway") fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" install Install/uninstall launchd service (macOS)")
fmt.Println(" status Show picoclaw status") fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
@ -1410,3 +1414,199 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
fmt.Println("----------------------") fmt.Println("----------------------")
fmt.Println(content) 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"
```