feat(gateway): add --install-daemon to run picoclaw as system service
Add daemon management flags to the gateway subcommand using kardianos/service, supporting Linux (systemd), macOS (launchd), and Windows (SCM). New flags: --install-daemon Install as system service --uninstall-daemon Uninstall system service --start-daemon Start the installed service --stop-daemon Stop the installed service --daemon-status Show service status Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9edfe98ba1
commit
f903cd047c
5 changed files with 216 additions and 42 deletions
26
README.ja.md
26
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 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
|
|
|
|||
30
README.md
30
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. 🤗
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
1
go.mod
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue