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:
parent
20f40139d1
commit
24e66b8f18
3 changed files with 504 additions and 0 deletions
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
docs/plans/2026-02-15-macos-launchd-autostart-design.md
Normal file
52
docs/plans/2026-02-15-macos-launchd-autostart-design.md
Normal 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 子命令
|
||||
252
docs/plans/2026-02-15-macos-launchd-autostart-implementation.md
Normal file
252
docs/plans/2026-02-15-macos-launchd-autostart-implementation.md
Normal 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"
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue