diff --git a/README.ja.md b/README.ja.md index daeee50b4..6fdf230e6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -403,8 +403,34 @@ picoclaw agent -m "Hello" | `picoclaw agent -m "..."` | エージェントとチャット | | `picoclaw agent` | インタラクティブチャットモード | | `picoclaw gateway` | ゲートウェイを起動 | +| `picoclaw gateway --install-daemon` | システムサービスとしてインストール | +| `picoclaw gateway --uninstall-daemon` | システムサービスをアンインストール | +| `picoclaw gateway --start-daemon` | インストール済みサービスを起動 | +| `picoclaw gateway --stop-daemon` | インストール済みサービスを停止 | +| `picoclaw gateway --daemon-status` | サービスの状態を表示 | | `picoclaw status` | ステータスを表示 | +### システムサービス(デーモン)として実行 + +PicoClaw は [kardianos/service](https://github.com/kardianos/service) を使用してシステムサービスとしてインストールできます。**Linux (systemd)**、**macOS (launchd)**、**Windows (SCM)** に対応しています。 + +```bash +# システムサービスとしてインストール(Linux では root/sudo が必要) +sudo picoclaw gateway --install-daemon + +# サービスを起動 +sudo picoclaw gateway --start-daemon + +# サービスの状態を確認 +picoclaw gateway --daemon-status + +# サービスを停止 +sudo picoclaw gateway --stop-daemon + +# サービスをアンインストール +sudo picoclaw gateway --uninstall-daemon +``` + ## 🤝 コントリビュート&ロードマップ PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗 diff --git a/README.md b/README.md index 381998211..bd9c5a7b0 100644 --- a/README.md +++ b/README.md @@ -527,6 +527,11 @@ picoclaw agent -m "Hello" | `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent` | Interactive chat mode | | `picoclaw gateway` | Start the gateway | +| `picoclaw gateway --install-daemon` | Install as system service | +| `picoclaw gateway --uninstall-daemon` | Uninstall system service | +| `picoclaw gateway --start-daemon` | Start the installed service | +| `picoclaw gateway --stop-daemon` | Stop the installed service | +| `picoclaw gateway --daemon-status` | Show service status | | `picoclaw status` | Show status | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | @@ -541,6 +546,31 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. +### Run as System Service (Daemon) + +PicoClaw can be installed as a system service using [kardianos/service](https://github.com/kardianos/service), which supports **Linux (systemd)**, **macOS (launchd)**, and **Windows (SCM)**. + +```bash +# Install as a system service (requires root/sudo on Linux) +sudo picoclaw gateway --install-daemon + +# Start the service +sudo picoclaw gateway --start-daemon + +# Check service status +picoclaw gateway --daemon-status + +# Stop the service +sudo picoclaw gateway --stop-daemon + +# Uninstall the service +sudo picoclaw gateway --uninstall-daemon +``` + +> [!NOTE] +> The service runs as the user who installed it, so it can access `~/.picoclaw/config.json`. +> To install with debug logging enabled, use `sudo picoclaw gateway --install-daemon --debug`. + ## 🤝 Contribute & Roadmap PRs welcome! The codebase is intentionally small and readable. 🤗 diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 19867b065..6e3dd9a55 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -12,13 +12,14 @@ import ( "fmt" "io" "os" - "os/signal" + "os/user" "path/filepath" "runtime" "strings" "time" "github.com/chzyer/readline" + "github.com/kardianos/service" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/bus" @@ -180,6 +181,11 @@ 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-daemon Install as system service") + fmt.Println(" --uninstall-daemon Uninstall system service") + fmt.Println(" --start-daemon Start the installed service") + fmt.Println(" --stop-daemon Stop the installed service") + fmt.Println(" --daemon-status Show service status") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") @@ -605,35 +611,68 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } } -func gatewayCmd() { - // Check for --debug flag - args := os.Args[2:] - for _, arg := range args { - if arg == "--debug" || arg == "-d" { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") - break - } +// gatewayProgram implements service.Interface for running picoclaw as a system daemon. +type gatewayProgram struct { + debug bool + ctx context.Context + cancel context.CancelFunc + cronService *cron.CronService + heartbeatService *heartbeat.HeartbeatService + agentLoop *agent.AgentLoop + channelManager *channels.Manager +} + +func (p *gatewayProgram) Start(s service.Service) error { + p.ctx, p.cancel = context.WithCancel(context.Background()) + go p.run() + return nil +} + +func (p *gatewayProgram) Stop(s service.Service) error { + fmt.Println("\nShutting down...") + p.cancel() + if p.heartbeatService != nil { + p.heartbeatService.Stop() + } + if p.cronService != nil { + p.cronService.Stop() + } + if p.agentLoop != nil { + p.agentLoop.Stop() + } + if p.channelManager != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + p.channelManager.StopAll(shutdownCtx) + } + fmt.Println("✓ Gateway stopped") + return nil +} + +func (p *gatewayProgram) run() { + if p.debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("🔍 Debug mode enabled") } cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) + return } provider, err := providers.CreateProvider(cfg) if err != nil { fmt.Printf("Error creating provider: %v\n", err) - os.Exit(1) + return } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + p.agentLoop = agent.NewAgentLoop(cfg, msgBus, provider) // Print agent startup info fmt.Println("\n📦 Agent Status:") - startupInfo := agentLoop.GetStartupInfo() + startupInfo := p.agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]interface{}) skillsInfo := startupInfo["skills"].(map[string]interface{}) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) @@ -650,19 +689,20 @@ func gatewayCmd() { }) // Setup cron tool and service - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) + p.cronService = setupCronTool(p.agentLoop, msgBus, cfg.WorkspacePath()) - heartbeatService := heartbeat.NewHeartbeatService( + p.heartbeatService = heartbeat.NewHeartbeatService( cfg.WorkspacePath(), nil, 30*60, true, ) - channelManager, err := channels.NewManager(cfg, msgBus) - if err != nil { - fmt.Printf("Error creating channel manager: %v\n", err) - os.Exit(1) + var channelErr error + p.channelManager, channelErr = channels.NewManager(cfg, msgBus) + if channelErr != nil { + fmt.Printf("Error creating channel manager: %v\n", channelErr) + return } var transcriber *voice.GroqTranscriber @@ -672,19 +712,19 @@ func gatewayCmd() { } if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { + if telegramChannel, ok := p.channelManager.GetChannel("telegram"); ok { if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { tc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Telegram channel") } } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { + if discordChannel, ok := p.channelManager.GetChannel("discord"); ok { if dc, ok := discordChannel.(*channels.DiscordChannel); ok { dc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Discord channel") } } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { + if slackChannel, ok := p.channelManager.GetChannel("slack"); ok { if sc, ok := slackChannel.(*channels.SlackChannel); ok { sc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Slack channel") @@ -692,7 +732,7 @@ func gatewayCmd() { } } - enabledChannels := channelManager.GetEnabledChannels() + enabledChannels := p.channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) } else { @@ -700,38 +740,113 @@ func gatewayCmd() { } fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Println("Press Ctrl+C to stop") + if service.Interactive() { + fmt.Println("Press Ctrl+C to stop") + } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := cronService.Start(); err != nil { + if err := p.cronService.Start(); err != nil { fmt.Printf("Error starting cron service: %v\n", err) } fmt.Println("✓ Cron service started") - if err := heartbeatService.Start(); err != nil { + if err := p.heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } fmt.Println("✓ Heartbeat service started") - if err := channelManager.StartAll(ctx); err != nil { + if err := p.channelManager.StartAll(p.ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } - go agentLoop.Run(ctx) + // Run agent loop - blocks until ctx is cancelled + p.agentLoop.Run(p.ctx) +} - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - <-sigChan +func gatewayCmd() { + args := os.Args[2:] - fmt.Println("\nShutting down...") - cancel() - heartbeatService.Stop() - cronService.Stop() - agentLoop.Stop() - channelManager.StopAll(ctx) - fmt.Println("✓ Gateway stopped") + var debug bool + var daemonAction string + + for _, arg := range args { + switch arg { + case "--debug", "-d": + debug = true + case "--install-daemon": + daemonAction = "install" + case "--uninstall-daemon": + daemonAction = "uninstall" + case "--start-daemon": + daemonAction = "start" + case "--stop-daemon": + daemonAction = "stop" + case "--daemon-status": + daemonAction = "status" + } + } + + execPath, err := os.Executable() + if err != nil { + fmt.Printf("Error finding executable path: %v\n", err) + os.Exit(1) + } + + serviceArgs := []string{"gateway"} + if debug { + serviceArgs = append(serviceArgs, "--debug") + } + + svcConfig := &service.Config{ + Name: "picoclaw", + DisplayName: "PicoClaw Gateway", + Description: "PicoClaw personal AI assistant gateway service", + Executable: execPath, + Arguments: serviceArgs, + } + + // Run as the current user so the service can access ~/.picoclaw + if u, err := user.Current(); err == nil { + svcConfig.UserName = u.Username + } + + prg := &gatewayProgram{debug: debug} + s, err := service.New(prg, svcConfig) + if err != nil { + fmt.Printf("Error creating service: %v\n", err) + os.Exit(1) + } + + if daemonAction != "" { + if daemonAction == "status" { + status, err := s.Status() + if err != nil { + fmt.Printf("Error getting service status: %v\n", err) + os.Exit(1) + } + switch status { + case service.StatusRunning: + fmt.Println("Service is running") + case service.StatusStopped: + fmt.Println("Service is stopped") + default: + fmt.Printf("Service status: unknown (%v)\n", status) + } + return + } + err := service.Control(s, daemonAction) + if err != nil { + fmt.Printf("Error: failed to %s service: %v\n", daemonAction, err) + os.Exit(1) + } + fmt.Printf("Service %sed successfully\n", daemonAction) + return + } + + // Run the service (works both interactively and as a system daemon) + if err := s.Run(); err != nil { + fmt.Printf("Error running service: %v\n", err) + os.Exit(1) + } } func statusCmd() { diff --git a/go.mod b/go.mod index f4c233ea8..dfcf67781 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/chzyer/readline v1.5.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/kardianos/service v1.2.4 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 diff --git a/go.sum b/go.sum index 9174d2889..55f385501 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= +github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=