From 6d550118354290de191e6203ba2fa14bcf4ac88d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:06:40 +0000 Subject: [PATCH 1/8] Initial plan From fb2179d1b5a6ba487e371e13a8c4d04c0caebe30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:11:44 +0000 Subject: [PATCH 2/8] Add Telegram Group Topics (Forum) support - Add threadIDs sync.Map to TelegramChannel struct to store thread ID mappings - Implement parseTelegramChatID() to parse chatID:threadID format (similar to Slack) - Update handleMessage() to extract and store MessageThreadID from incoming messages - Encode thread ID into chatID string when messages come from topics - Update Send() method to parse and set MessageThreadID on outgoing messages - Set MessageThreadID on "Thinking..." placeholder messages - Set MessageThreadID on SendChatAction (typing indicator) - Add metadata fields: message_thread_id and is_topic_message - Add comprehensive unit tests for parseTelegramChatID() Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- pkg/channels/telegram.go | 67 ++++++++++++++++-- pkg/channels/telegram_test.go | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 pkg/channels/telegram_test.go diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index b14b1632e..70422a714 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -29,6 +29,7 @@ type TelegramChannel struct { transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel + threadIDs sync.Map // chatIDStr -> MessageThreadID } type thinkingCancel struct { @@ -124,7 +125,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("telegram bot not running") } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID: %w", err) } @@ -144,6 +145,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err c.placeholders.Delete(msg.ChatID) editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) editMsg.ParseMode = telego.ModeHTML + // Note: EditMessageText doesn't require MessageThreadID as it edits existing message if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { return nil @@ -153,6 +155,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + + // Set thread ID if present + if threadID != 0 { + tgMsg.MessageThreadID = threadID + } if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ @@ -195,6 +202,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat chatID := message.Chat.ID c.chatIDs[senderID] = chatID + // Check for message thread (forum topic) + messageThreadID := message.MessageThreadID + chatIDStr := fmt.Sprintf("%d", chatID) + if messageThreadID != 0 { + // Store thread ID for later use + c.threadIDs.Store(chatIDStr, messageThreadID) + // Encode thread ID into chatID string (similar to Slack pattern) + chatIDStr = fmt.Sprintf("%d:%d", chatID, messageThreadID) + } + content := "" mediaPaths := []string{} localFiles := []string{} // 跟踪需要清理的本地文件 @@ -301,11 +318,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat logger.DebugCF("telegram", "Received message", map[string]interface{}{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), + "thread_id": messageThreadID, "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + // Thinking indicator - include thread ID if present + chatAction := tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping) + if messageThreadID != 0 { + chatAction.MessageThreadID = messageThreadID + } + err := c.bot.SendChatAction(ctx, chatAction) if err != nil { logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ "error": err.Error(), @@ -313,7 +335,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { cf.Cancel() @@ -324,7 +345,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + // Send "Thinking..." message - include thread ID if present + thinkingMsg := tu.Message(tu.ID(chatID), "Thinking... 💭") + if messageThreadID != 0 { + thinkingMsg.MessageThreadID = messageThreadID + } + pMsg, err := c.bot.SendMessage(ctx, thinkingMsg) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) @@ -337,8 +363,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + + if messageThreadID != 0 { + metadata["message_thread_id"] = fmt.Sprintf("%d", messageThreadID) + metadata["is_topic_message"] = "true" + } - c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage(senderID, chatIDStr, content, mediaPaths, metadata) } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { @@ -386,6 +417,30 @@ func parseChatID(chatIDStr string) (int64, error) { return id, err } +// parseTelegramChatID extracts chatID and threadID from a combined chatID string +// Format: "chatID" or "chatID:threadID" +func parseTelegramChatID(chatIDStr string) (chatID int64, threadID int, err error) { + parts := strings.SplitN(chatIDStr, ":", 2) + + var id int64 + _, err = fmt.Sscanf(parts[0], "%d", &id) + if err != nil { + return 0, 0, err + } + chatID = id + + if len(parts) > 1 { + var tid int + _, err = fmt.Sscanf(parts[1], "%d", &tid) + if err != nil { + return chatID, 0, fmt.Errorf("invalid thread ID: %w", err) + } + threadID = tid + } + + return chatID, threadID, nil +} + func markdownToTelegramHTML(text string) string { if text == "" { return "" diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go new file mode 100644 index 000000000..969f03222 --- /dev/null +++ b/pkg/channels/telegram_test.go @@ -0,0 +1,128 @@ +package channels + +import ( + "testing" +) + +func TestParseTelegramChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + wantChatID int64 + wantThreadID int + wantErr bool + }{ + { + name: "chat only", + chatIDStr: "123456789", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: false, + }, + { + name: "negative chat ID (private chat)", + chatIDStr: "-987654321", + wantChatID: -987654321, + wantThreadID: 0, + wantErr: false, + }, + { + name: "chat with thread", + chatIDStr: "123456789:42", + wantChatID: 123456789, + wantThreadID: 42, + wantErr: false, + }, + { + name: "negative chat with thread", + chatIDStr: "-987654321:100", + wantChatID: -987654321, + wantThreadID: 100, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + { + name: "invalid thread ID", + chatIDStr: "123456789:invalid", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: true, + }, + { + name: "empty string", + chatIDStr: "", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chatID, threadID, err := parseTelegramChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseTelegramChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr { + if chatID != tt.wantChatID { + t.Errorf("parseTelegramChatID(%q) chatID = %d, want %d", tt.chatIDStr, chatID, tt.wantChatID) + } + if threadID != tt.wantThreadID { + t.Errorf("parseTelegramChatID(%q) threadID = %d, want %d", tt.chatIDStr, threadID, tt.wantThreadID) + } + } + }) + } +} + +func TestParseChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + want int64 + wantErr bool + }{ + { + name: "positive chat ID", + chatIDStr: "123456789", + want: 123456789, + wantErr: false, + }, + { + name: "negative chat ID", + chatIDStr: "-987654321", + want: -987654321, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.want { + t.Errorf("parseChatID(%q) = %d, want %d", tt.chatIDStr, got, tt.want) + } + }) + } +} From e088cbafa61845294f4c408cad70c42ffe20145f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:13:06 +0000 Subject: [PATCH 3/8] Improve error message for invalid thread ID Include the actual invalid value in error message for better debugging Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- pkg/channels/telegram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 70422a714..1670ec48d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -433,7 +433,7 @@ func parseTelegramChatID(chatIDStr string) (chatID int64, threadID int, err erro var tid int _, err = fmt.Sscanf(parts[1], "%d", &tid) if err != nil { - return chatID, 0, fmt.Errorf("invalid thread ID: %w", err) + return chatID, 0, fmt.Errorf("invalid thread ID '%s': %w", parts[1], err) } threadID = tid } From b453f7e9fb113a08533a8dca0d41d2ee90530159 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:15:01 +0000 Subject: [PATCH 4/8] Complete Telegram Group Topics implementation Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- go.mod | 5 +---- go.sum | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 98aecd6ab..c321ec7b9 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,6 @@ require ( golang.org/x/oauth2 v0.35.0 ) - - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -28,9 +26,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/google/jsonschema-go v0.4.2 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -47,5 +45,4 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - ) diff --git a/go.sum b/go.sum index 6a565b93e..18da86f62 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= From 24e66b8f18ca54289d30e880e371303ef1a110e7 Mon Sep 17 00:00:00 2001 From: cole Date: Sun, 15 Feb 2026 19:18:04 +0800 Subject: [PATCH 5/8] 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" +``` From 8b5f3430a1be69cc070574784f58d7635df79c2f Mon Sep 17 00:00:00 2001 From: cole Date: Sun, 15 Feb 2026 19:21:27 +0800 Subject: [PATCH 6/8] chore: update go-resty to v2.17.2 and clean up docs/plans --- ...26-02-15-macos-launchd-autostart-design.md | 52 ---- ...-macos-launchd-autostart-implementation.md | 252 ------------------ go.mod | 2 +- go.sum | 4 +- 4 files changed, 3 insertions(+), 307 deletions(-) delete mode 100644 docs/plans/2026-02-15-macos-launchd-autostart-design.md delete mode 100644 docs/plans/2026-02-15-macos-launchd-autostart-implementation.md diff --git a/docs/plans/2026-02-15-macos-launchd-autostart-design.md b/docs/plans/2026-02-15-macos-launchd-autostart-design.md deleted file mode 100644 index 8c9b6383b..000000000 --- a/docs/plans/2026-02-15-macos-launchd-autostart-design.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index e9d329fce..000000000 --- a/docs/plans/2026-02-15-macos-launchd-autostart-implementation.md +++ /dev/null @@ -1,252 +0,0 @@ -# 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" -``` diff --git a/go.mod b/go.mod index c321ec7b9..8b7b75bbd 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect diff --git a/go.sum b/go.sum index 18da86f62..84a94df31 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQ github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= From ca1eef200ac8577adb035a2ab0f7f0ea0583f0d4 Mon Sep 17 00:00:00 2001 From: cole Date: Sun, 15 Feb 2026 19:39:08 +0800 Subject: [PATCH 7/8] fix: add PATH environment variable to launchd plist Add EnvironmentVariables to plist so commands can be found when the service runs at login. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/main.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index b5f0cbdf6..2f05737e7 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -1536,6 +1536,7 @@ func findExecutable() (string, error) { // generatePlist generates the plist content for launch agent func generatePlist(execPath string) string { + home := os.Getenv("HOME") return fmt.Sprintf(` @@ -1551,8 +1552,13 @@ func generatePlist(execPath string) string { KeepAlive + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:%s/.local/bin:%s/bin + -`, execPath) +`, execPath, home, home) } // uninstallInstall uninstalls the launch agent From 05c08dfa742a221f841b5eddd003ce02c86b2b9c Mon Sep 17 00:00:00 2001 From: cole Date: Sun, 15 Feb 2026 19:43:02 +0800 Subject: [PATCH 8/8] fix: use current PATH instead of hardcoded path in launchd plist --- cmd/picoclaw/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2f05737e7..4a492faca 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -1536,7 +1536,7 @@ func findExecutable() (string, error) { // generatePlist generates the plist content for launch agent func generatePlist(execPath string) string { - home := os.Getenv("HOME") + currentPath := os.Getenv("PATH") return fmt.Sprintf(` @@ -1555,10 +1555,10 @@ func generatePlist(execPath string) string { EnvironmentVariables PATH - /usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:%s/.local/bin:%s/bin + %s -`, execPath, home, home) +`, execPath, currentPath) } // uninstallInstall uninstalls the launch agent