From 85c94a55ce568ea7a8aba8f409724d08e11c650f Mon Sep 17 00:00:00 2001 From: YS Liu Date: Wed, 25 Feb 2026 14:16:59 +0800 Subject: [PATCH 1/2] feat: add service command (install/uninstall/start/stop/restart/status/logs) - Add pkg/service with launchd (macOS) and systemd user (Linux) backends - Add picoclaw service install|uninstall|start|stop|restart|status|logs|refresh - service logs: support -f/--follow for tailing; -n/--lines for line count - main: add service case, invokedCLIName(), printHelp entry for service - gateway: optional check to avoid double-run when service already running Co-authored-by: Cursor --- cmd/picoclaw/cmd_gateway.go | 22 +++ cmd/picoclaw/main.go | 14 ++ cmd/picoclaw/service_cmd.go | 271 ++++++++++++++++++++++++++++++++++ pkg/service/detect.go | 23 +++ pkg/service/launchd_darwin.go | 222 ++++++++++++++++++++++++++++ pkg/service/launchd_stub.go | 7 + pkg/service/logs.go | 47 ++++++ pkg/service/manager.go | 121 +++++++++++++++ pkg/service/path.go | 74 ++++++++++ pkg/service/systemd_linux.go | 160 ++++++++++++++++++++ pkg/service/systemd_stub.go | 7 + pkg/service/templates.go | 59 ++++++++ pkg/service/unsupported.go | 52 +++++++ pkg/tools/filesystem.go | 4 + pkg/tools/filesystem_test.go | 5 +- 15 files changed, 1086 insertions(+), 2 deletions(-) create mode 100644 cmd/picoclaw/service_cmd.go create mode 100644 pkg/service/detect.go create mode 100644 pkg/service/launchd_darwin.go create mode 100644 pkg/service/launchd_stub.go create mode 100644 pkg/service/logs.go create mode 100644 pkg/service/manager.go create mode 100644 pkg/service/path.go create mode 100644 pkg/service/systemd_linux.go create mode 100644 pkg/service/systemd_stub.go create mode 100644 pkg/service/templates.go create mode 100644 pkg/service/unsupported.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index cf7f3563a..f59de4b5f 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "os/signal" "path/filepath" "strings" @@ -26,6 +27,7 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" + svcmgr "github.com/sipeed/picoclaw/pkg/service" ) func gatewayCmd() { @@ -39,6 +41,26 @@ func gatewayCmd() { } } + // Avoid double-running when the managed service is already active. + if os.Getenv("XPC_SERVICE_NAME") == "" && os.Getenv("INVOCATION_ID") == "" { + exePath, err := resolveServiceExecutablePath(os.Args[0], exec.LookPath, os.Executable) + if err == nil { + if mgr, mgrErr := svcmgr.NewManager(exePath); mgrErr == nil { + if st, statusErr := mgr.Status(); statusErr == nil && st.Running { + backend := strings.TrimSpace(st.Backend) + if backend == "" { + backend = mgr.Backend() + } + fmt.Fprintf(os.Stderr, "Gateway is already running via %s service.\n", backend) + fmt.Fprintf(os.Stderr, " Stop it first: %s service stop\n", invokedCLIName()) + fmt.Fprintf(os.Stderr, " View logs: %s service logs\n", invokedCLIName()) + fmt.Fprintf(os.Stderr, " Restart: %s service restart\n", invokedCLIName()) + os.Exit(1) + } + } + } + } + cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 25ad701ca..149d58d08 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" @@ -107,6 +108,8 @@ func main() { agentCmd() case "gateway": gatewayCmd() + case "service": + serviceCmd() case "status": statusCmd() case "migrate": @@ -182,6 +185,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(" service Manage background gateway service (launchd/systemd)") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") @@ -197,3 +201,13 @@ func getConfigPath() string { func loadConfig() (*config.Config, error) { return config.LoadConfig(getConfigPath()) } + +func invokedCLIName() string { + if len(os.Args) > 0 { + name := filepath.Base(os.Args[0]) + if strings.TrimSpace(name) != "" { + return name + } + } + return "picoclaw" +} diff --git a/cmd/picoclaw/service_cmd.go b/cmd/picoclaw/service_cmd.go new file mode 100644 index 000000000..509cabb3e --- /dev/null +++ b/cmd/picoclaw/service_cmd.go @@ -0,0 +1,271 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + + svcmgr "github.com/sipeed/picoclaw/pkg/service" +) + +type serviceLogsOptions struct { + Lines int + Follow bool +} + +func serviceCmd() { + args := os.Args[2:] + if len(args) == 0 { + serviceHelp() + return + } + + sub := strings.ToLower(args[0]) + if sub == "help" || sub == "--help" || sub == "-h" { + serviceHelp() + return + } + + exePath, err := resolveServiceExecutablePath(os.Args[0], exec.LookPath, os.Executable) + if err != nil { + fmt.Printf("Error resolving executable path: %v\n", err) + os.Exit(1) + } + + mgr, err := svcmgr.NewManager(exePath) + if err != nil { + fmt.Printf("Error initializing service manager: %v\n", err) + os.Exit(1) + } + + switch sub { + case "install": + prepareServiceInstallEnvPath() + if err := mgr.Install(); err != nil { + fmt.Printf("Service install failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service installed") + fmt.Printf(" Start with: %s service start\n", invokedCLIName()) + case "refresh": + if err := runServiceRefresh(mgr); err != nil { + fmt.Printf("Service refresh failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service refreshed") + fmt.Printf(" Reinstalled and restarted (run: %s service status)\n", invokedCLIName()) + case "uninstall", "remove": + if err := mgr.Uninstall(); err != nil { + fmt.Printf("Service uninstall failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service uninstalled") + case "start": + if err := mgr.Start(); err != nil { + fmt.Printf("Service start failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service started") + case "stop": + if err := mgr.Stop(); err != nil { + fmt.Printf("Service stop failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service stopped") + case "restart": + if err := mgr.Restart(); err != nil { + fmt.Printf("Service restart failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Service restarted") + case "status": + st, err := mgr.Status() + if err != nil { + fmt.Printf("Service status check failed: %v\n", err) + os.Exit(1) + } + printServiceStatus(st) + case "logs": + opts, showHelp, err := parseServiceLogsOptions(args[1:]) + if err != nil { + fmt.Printf("Error: %v\n", err) + serviceHelp() + os.Exit(2) + } + if showHelp { + serviceHelp() + return + } + if opts.Follow { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := mgr.LogsFollow(ctx, opts.Lines, os.Stdout); err != nil && ctx.Err() == nil { + fmt.Fprintf(os.Stderr, "Service logs failed: %v\n", err) + os.Exit(1) + } + } else { + out, err := mgr.Logs(opts.Lines) + if err != nil { + fmt.Printf("Service logs failed: %v\n", err) + os.Exit(1) + } + fmt.Print(out) + } + default: + fmt.Printf("Unknown service command: %s\n", sub) + serviceHelp() + os.Exit(2) + } +} + +func prepareServiceInstallEnvPath() { + cfg, err := loadConfig() + if err != nil || cfg == nil { + return + } + venvBin := strings.TrimSpace(workspaceVenvBinDir(cfg.WorkspacePath())) + if venvBin == "" { + return + } + if _, err := os.Stat(venvBin); err != nil { + return + } + prependPathEnv(venvBin) +} + +func prependPathEnv(pathEntry string) { + pathEntry = strings.TrimSpace(pathEntry) + if pathEntry == "" { + return + } + sep := string(os.PathListSeparator) + current := os.Getenv("PATH") + parts := []string{pathEntry} + for _, p := range strings.Split(current, sep) { + p = strings.TrimSpace(p) + if p == "" || p == pathEntry { + continue + } + parts = append(parts, p) + } + _ = os.Setenv("PATH", strings.Join(parts, sep)) +} + +func runServiceRefresh(mgr svcmgr.Manager) error { + prepareServiceInstallEnvPath() + if err := mgr.Install(); err != nil { + return fmt.Errorf("install failed: %w", err) + } + if err := mgr.Restart(); err != nil { + return fmt.Errorf("restart failed: %w", err) + } + return nil +} + +func resolveServiceExecutablePath( + argv0 string, + lookPath func(string) (string, error), + executable func() (string, error), +) (string, error) { + arg0 := strings.TrimSpace(argv0) + + if arg0 != "" && (strings.Contains(arg0, "/") || strings.Contains(arg0, `\`)) { + if abs, err := filepath.Abs(arg0); err == nil { + return abs, nil + } + return arg0, nil + } + + base := strings.TrimSpace(filepath.Base(arg0)) + if base != "" { + if resolved, err := lookPath(base); err == nil && strings.TrimSpace(resolved) != "" { + if abs, err := filepath.Abs(resolved); err == nil { + return abs, nil + } + return resolved, nil + } + } + + return executable() +} + +func serviceHelp() { + commandName := invokedCLIName() + fmt.Println("\nService commands:") + fmt.Println(" install Install background gateway service") + fmt.Println(" refresh Reinstall + restart service after upgrades") + fmt.Println(" uninstall Remove background gateway service") + fmt.Println(" start Start background gateway service") + fmt.Println(" stop Stop background gateway service") + fmt.Println(" restart Restart background gateway service") + fmt.Println(" status Show service install/runtime status") + fmt.Println(" logs Show recent service logs") + fmt.Println() + fmt.Println("Logs options:") + fmt.Println(" -n, --lines Number of log lines to show (default: 100)") + fmt.Println(" -f, --follow Follow log output (like tail -f); Ctrl+C to stop") + fmt.Println() + fmt.Println("Examples:") + fmt.Printf(" %s service install\n", commandName) + fmt.Printf(" %s service refresh\n", commandName) + fmt.Printf(" %s service start\n", commandName) + fmt.Printf(" %s service status\n", commandName) + fmt.Printf(" %s service logs --lines 200\n", commandName) + fmt.Printf(" %s service logs -f\n", commandName) +} + +func parseServiceLogsOptions(args []string) (serviceLogsOptions, bool, error) { + opts := serviceLogsOptions{Lines: 100} + for i := 0; i < len(args); i++ { + switch args[i] { + case "-n", "--lines": + if i+1 >= len(args) { + return opts, false, fmt.Errorf("%s requires a value", args[i]) + } + n, err := strconv.Atoi(args[i+1]) + if err != nil || n <= 0 { + return opts, false, fmt.Errorf("invalid value for %s: %q", args[i], args[i+1]) + } + opts.Lines = n + i++ + case "-f", "--follow": + opts.Follow = true + case "help", "--help", "-h": + return opts, true, nil + default: + return opts, false, fmt.Errorf("unknown option: %s", args[i]) + } + } + return opts, false, nil +} + +func printServiceStatus(st svcmgr.Status) { + yn := func(v bool) string { + if v { + return "yes" + } + return "no" + } + + fmt.Println("\nGateway service status:") + fmt.Printf(" Backend: %s\n", st.Backend) + fmt.Printf(" Installed: %s\n", yn(st.Installed)) + fmt.Printf(" Running: %s\n", yn(st.Running)) + fmt.Printf(" Enabled: %s\n", yn(st.Enabled)) + if strings.TrimSpace(st.Detail) != "" { + fmt.Printf(" Detail: %s\n", st.Detail) + } +} + +func workspaceVenvBinDir(workspace string) string { + if strings.TrimSpace(workspace) == "" { + return "" + } + return filepath.Join(workspace, ".venv", "bin") +} diff --git a/pkg/service/detect.go b/pkg/service/detect.go new file mode 100644 index 000000000..15ef57c6e --- /dev/null +++ b/pkg/service/detect.go @@ -0,0 +1,23 @@ +package service + +import ( + "os" + "strings" +) + +func detectWSL() bool { + return detectWSLWith(os.Getenv, os.ReadFile) +} + +func detectWSLWith(getenv func(string) string, readFile func(string) ([]byte, error)) bool { + for _, key := range []string{"WSL_DISTRO_NAME", "WSL_INTEROP"} { + if strings.TrimSpace(getenv(key)) != "" { + return true + } + } + b, err := readFile("/proc/version") + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(b)), "microsoft") +} diff --git a/pkg/service/launchd_darwin.go b/pkg/service/launchd_darwin.go new file mode 100644 index 000000000..3f663efdf --- /dev/null +++ b/pkg/service/launchd_darwin.go @@ -0,0 +1,222 @@ +//go:build darwin + +package service + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type launchdManager struct { + runner commandRunner + exePath string + label string + domainTarget string + serviceTarget string + plistPath string + stdoutPath string + stderrPath string +} + +func newLaunchdManager(exePath string, runner commandRunner) Manager { + home, _ := os.UserHomeDir() + label := "io.picoclaw.gateway" + domain := fmt.Sprintf("gui/%d", os.Getuid()) + serviceTarget := fmt.Sprintf("%s/%s", domain, label) + return &launchdManager{ + runner: runner, + exePath: exePath, + label: label, + domainTarget: domain, + serviceTarget: serviceTarget, + plistPath: filepath.Join(home, "Library", "LaunchAgents", label+".plist"), + stdoutPath: filepath.Join(home, ".picoclaw", "gateway.log"), + stderrPath: filepath.Join(home, ".picoclaw", "gateway.err.log"), + } +} + +func (m *launchdManager) Backend() string { return BackendLaunchd } + +func (m *launchdManager) Install() error { + _ = m.Uninstall() + + if err := os.MkdirAll(filepath.Dir(m.plistPath), 0755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(m.stdoutPath), 0700); err != nil { + return err + } + + pathEnv := buildSystemdPath(os.Getenv("PATH"), m.detectBrewPrefix()) + plist := renderLaunchdPlist(m.label, m.exePath, m.stdoutPath, m.stderrPath, pathEnv) + if err := os.WriteFile(m.plistPath, []byte(plist), 0644); err != nil { + return err + } + + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Second) + } + out, err := runCommand(m.runner, 10*time.Second, "launchctl", "bootstrap", m.domainTarget, m.plistPath) + if err == nil { + lastErr = nil + break + } + msg := strings.ToLower(string(out)) + if strings.Contains(msg, "already bootstrapped") { + lastErr = nil + break + } + lastErr = fmt.Errorf("bootstrap failed: %s", oneLine(string(out))) + } + if lastErr != nil { + return lastErr + } + if out, err := runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget); err != nil { + return fmt.Errorf("enable failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *launchdManager) Uninstall() error { + _, _ = runCommand(m.runner, 10*time.Second, "launchctl", "bootout", m.serviceTarget) + _, _ = runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget) + if err := os.Remove(m.plistPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (m *launchdManager) Start() error { + if _, err := os.Stat(m.plistPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service is not installed; run `picoclaw service install`") + } + return err + } + + _, _ = runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget) + if _, err := runCommand(m.runner, 3*time.Second, "launchctl", "print", m.serviceTarget); err != nil { + if out, err2 := runCommand(m.runner, 10*time.Second, "launchctl", "bootstrap", m.domainTarget, m.plistPath); err2 != nil { + msg := strings.ToLower(string(out)) + if !strings.Contains(msg, "already bootstrapped") { + return fmt.Errorf("bootstrap failed: %s", oneLine(string(out))) + } + } + } + if out, err := runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget); err != nil { + return fmt.Errorf("enable failed: %s", commandErrorDetail(err, out)) + } + if out, err := runCommand(m.runner, 10*time.Second, "launchctl", "kickstart", "-k", m.serviceTarget); err != nil { + if st, stErr := m.Status(); stErr == nil && st.Running { + return nil + } + return fmt.Errorf("kickstart failed: %s", commandErrorDetail(err, out)) + } + return nil +} + +func (m *launchdManager) Stop() error { + if out, err := runCommand(m.runner, 10*time.Second, "launchctl", "bootout", m.serviceTarget); err != nil { + msg := strings.ToLower(string(out)) + if strings.Contains(msg, "could not find service") || strings.Contains(msg, "no such process") { + return nil + } + return fmt.Errorf("bootout failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *launchdManager) Restart() error { + if err := m.Stop(); err != nil { + return err + } + return m.Start() +} + +func (m *launchdManager) Status() (Status, error) { + st := Status{Backend: BackendLaunchd} + if _, err := os.Stat(m.plistPath); err == nil { + st.Installed = true + st.Enabled = true + } + out, err := runCommand(m.runner, 3*time.Second, "launchctl", "print", m.serviceTarget) + if err == nil { + text := strings.ToLower(string(out)) + if strings.Contains(text, "state = running") || hasNonZeroPID(text) { + st.Running = true + } + st.Detail = oneLine(string(out)) + return st, nil + } + if st.Installed { + st.Detail = "installed but not loaded" + } + return st, nil +} + +func (m *launchdManager) Logs(lines int) (string, error) { + sections := map[string]string{} + if out, err := tailFileLines(m.stdoutPath, lines); err == nil { + sections[m.stdoutPath] = out + } + if out, err := tailFileLines(m.stderrPath, lines); err == nil { + sections[m.stderrPath] = out + } + combined := combineLogSections(sections) + if strings.TrimSpace(combined) == "" { + return "", fmt.Errorf("no launchd logs found at %s or %s", m.stdoutPath, m.stderrPath) + } + return combined, nil +} + +func (m *launchdManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error { + n := fmt.Sprintf("%d", lines) + if lines <= 0 { + n = "100" + } + cmd := exec.CommandContext(ctx, "tail", "-n", n, "-f", m.stdoutPath, m.stderrPath) + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() +} + +func hasNonZeroPID(text string) bool { + idx := strings.Index(text, "pid = ") + if idx < 0 { + return false + } + rest := strings.TrimSpace(text[idx+len("pid = "):]) + if len(rest) > 0 && rest[0] != '0' && rest[0] >= '1' && rest[0] <= '9' { + return true + } + return false +} + +func commandErrorDetail(err error, out []byte) string { + if msg := oneLine(string(out)); msg != "" { + return msg + } + if err != nil { + return err.Error() + } + return "" +} + +func (m *launchdManager) detectBrewPrefix() string { + if _, err := exec.LookPath("brew"); err != nil { + return "" + } + out, err := runCommand(m.runner, 4*time.Second, "brew", "--prefix") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/service/launchd_stub.go b/pkg/service/launchd_stub.go new file mode 100644 index 000000000..ab690ef8a --- /dev/null +++ b/pkg/service/launchd_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package service + +func newLaunchdManager(exePath string, runner commandRunner) Manager { + return newUnsupportedManager("launchd is only available on macOS") +} diff --git a/pkg/service/logs.go b/pkg/service/logs.go new file mode 100644 index 000000000..4bd48a504 --- /dev/null +++ b/pkg/service/logs.go @@ -0,0 +1,47 @@ +package service + +import ( + "fmt" + "os" + "sort" + "strings" +) + +func tailFileLines(path string, lines int) (string, error) { + if lines <= 0 { + lines = 100 + } + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + txt := strings.TrimRight(string(b), "\n") + if txt == "" { + return "", nil + } + all := strings.Split(txt, "\n") + if len(all) <= lines { + return strings.Join(all, "\n") + "\n", nil + } + return strings.Join(all[len(all)-lines:], "\n") + "\n", nil +} + +func combineLogSections(sections map[string]string) string { + out := "" + keys := make([]string, 0, len(sections)) + for name := range sections { + keys = append(keys, name) + } + sort.Strings(keys) + for _, name := range keys { + text := sections[name] + if strings.TrimSpace(text) == "" { + continue + } + if out != "" { + out += "\n" + } + out += fmt.Sprintf("==> %s <==\n%s", name, text) + } + return out +} diff --git a/pkg/service/manager.go b/pkg/service/manager.go new file mode 100644 index 000000000..05cc03018 --- /dev/null +++ b/pkg/service/manager.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +const ( + BackendLaunchd = "launchd" + BackendSystemdUser = "systemd-user" + BackendUnsupported = "unsupported" +) + +// Status captures installation and runtime state for the background gateway service. +type Status struct { + Backend string + Installed bool + Running bool + Enabled bool + Detail string +} + +// Manager controls the background gateway service for the current platform. +type Manager interface { + Backend() string + Install() error + Uninstall() error + Start() error + Stop() error + Restart() error + Status() (Status, error) + Logs(lines int) (string, error) + // LogsFollow streams log output to w until ctx is done (e.g. SIGINT). Follows new lines like tail -f. + LogsFollow(ctx context.Context, lines int, w io.Writer) error +} + +type commandRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +type osCommandRunner struct{} + +func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + +func NewManager(exePath string) (Manager, error) { + exePath = strings.TrimSpace(exePath) + if exePath == "" { + return nil, errors.New("executable path is empty") + } + + runner := osCommandRunner{} + switch runtime.GOOS { + case "darwin": + return newLaunchdManager(exePath, runner), nil + case "linux": + if detectWSL() && !isSystemdUserAvailable(runner) { + return newUnsupportedManager("WSL detected but systemd user manager is not active. Enable systemd in /etc/wsl.conf or run `picoclaw gateway` in a terminal."), nil + } + if !isSystemdUserAvailable(runner) { + return newUnsupportedManager("systemd user manager is not available. Run `picoclaw gateway` in a terminal."), nil + } + return newSystemdUserManager(exePath, runner), nil + default: + return newUnsupportedManager(fmt.Sprintf("%s is not currently supported for service management", runtime.GOOS)), nil + } +} + +func isSystemdUserAvailable(runner commandRunner) bool { + if _, err := exec.LookPath("systemctl"); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := runner.Run(ctx, "systemctl", "--user", "show-environment") + return err == nil +} + +func runCommand(runner commandRunner, timeout time.Duration, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := runner.Run(ctx, name, args...) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return out, fmt.Errorf("%s %s timed out", name, strings.Join(args, " ")) + } + if err != nil { + return out, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return out, nil +} + +func writeFileIfChanged(path string, content []byte, perm os.FileMode) error { + if existing, err := os.ReadFile(path); err == nil { + if string(existing) == string(content) { + return nil + } + } + if err := os.WriteFile(path, content, perm); err != nil { + return err + } + return nil +} + +func oneLine(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " ")) + if s == "" { + return "" + } + if len(s) > 220 { + return s[:220] + "..." + } + return s +} diff --git a/pkg/service/path.go b/pkg/service/path.go new file mode 100644 index 000000000..2b29ccd40 --- /dev/null +++ b/pkg/service/path.go @@ -0,0 +1,74 @@ +package service + +import ( + "os" + "path/filepath" + "strings" +) + +func buildSystemdPath(installerPath, brewPrefix string) string { + paths := make([]string, 0, 24) + + // Deterministic baseline first. + for _, p := range []string{ + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/local/sbin", + "/usr/sbin", + "/sbin", + } { + paths = appendUniquePath(paths, p) + } + + // Known Homebrew/Linuxbrew locations. + for _, p := range []string{ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/home/linuxbrew/.linuxbrew/bin", + "/home/linuxbrew/.linuxbrew/sbin", + } { + paths = appendUniquePath(paths, p) + } + + // If brew --prefix resolves, include it explicitly. + if strings.TrimSpace(brewPrefix) != "" { + paths = appendUniquePath(paths, filepath.Join(brewPrefix, "bin")) + paths = appendUniquePath(paths, filepath.Join(brewPrefix, "sbin")) + } + + // Managed workspace virtualenv location. + for _, p := range managedVenvBinCandidates() { + paths = appendUniquePath(paths, p) + } + + // Include PATH from the shell running install for custom bins. + for _, p := range strings.Split(installerPath, string(os.PathListSeparator)) { + paths = appendUniquePath(paths, p) + } + + return strings.Join(paths, string(os.PathListSeparator)) +} + +func managedVenvBinCandidates() []string { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return nil + } + return []string{ + filepath.Join(home, ".picoclaw", "workspace", ".venv", "bin"), + } +} + +func appendUniquePath(paths []string, path string) []string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return paths + } + for _, existing := range paths { + if existing == trimmed { + return paths + } + } + return append(paths, trimmed) +} diff --git a/pkg/service/systemd_linux.go b/pkg/service/systemd_linux.go new file mode 100644 index 000000000..eb92cf592 --- /dev/null +++ b/pkg/service/systemd_linux.go @@ -0,0 +1,160 @@ +//go:build linux + +package service + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type systemdUserManager struct { + runner commandRunner + exePath string + unitName string + unitPath string + journalID string +} + +func newSystemdUserManager(exePath string, runner commandRunner) Manager { + home, _ := os.UserHomeDir() + unitName := "picoclaw-gateway.service" + return &systemdUserManager{ + runner: runner, + exePath: exePath, + unitName: unitName, + unitPath: filepath.Join(home, ".config", "systemd", "user", unitName), + journalID: unitName, + } +} + +func (m *systemdUserManager) Backend() string { return BackendSystemdUser } + +func (m *systemdUserManager) Install() error { + if err := os.MkdirAll(filepath.Dir(m.unitPath), 0755); err != nil { + return err + } + pathEnv := buildSystemdPath(os.Getenv("PATH"), m.detectBrewPrefix()) + unit := renderSystemdUnit(m.exePath, pathEnv) + if err := writeFileIfChanged(m.unitPath, []byte(unit), 0644); err != nil { + return err + } + if out, err := runCommand(m.runner, 8*time.Second, "systemctl", "--user", "daemon-reload"); err != nil { + return fmt.Errorf("daemon-reload failed: %s", oneLine(string(out))) + } + if out, err := runCommand(m.runner, 8*time.Second, "systemctl", "--user", "enable", m.unitName); err != nil { + return fmt.Errorf("enable failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *systemdUserManager) Uninstall() error { + _, _ = runCommand(m.runner, 8*time.Second, "systemctl", "--user", "disable", "--now", m.unitName) + if err := os.Remove(m.unitPath); err != nil && !os.IsNotExist(err) { + return err + } + _, _ = runCommand(m.runner, 8*time.Second, "systemctl", "--user", "daemon-reload") + return nil +} + +func (m *systemdUserManager) Start() error { + if _, err := os.Stat(m.unitPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service is not installed; run `picoclaw service install`") + } + return err + } + if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "start", m.unitName); err != nil { + return fmt.Errorf("start failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *systemdUserManager) Stop() error { + if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "stop", m.unitName); err != nil { + msg := strings.ToLower(string(out)) + if strings.Contains(msg, "not loaded") || strings.Contains(msg, "could not be found") { + return nil + } + return fmt.Errorf("stop failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *systemdUserManager) Restart() error { + if _, err := os.Stat(m.unitPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service is not installed; run `picoclaw service install`") + } + return err + } + if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "restart", m.unitName); err != nil { + return fmt.Errorf("restart failed: %s", oneLine(string(out))) + } + return nil +} + +func (m *systemdUserManager) Status() (Status, error) { + st := Status{Backend: BackendSystemdUser} + if _, err := os.Stat(m.unitPath); err == nil { + st.Installed = true + } + + if out, err := runCommand(m.runner, 5*time.Second, "systemctl", "--user", "is-active", m.unitName); err == nil { + if strings.TrimSpace(string(out)) == "active" { + st.Running = true + } + } + + if out, err := runCommand(m.runner, 5*time.Second, "systemctl", "--user", "is-enabled", m.unitName); err == nil { + en := strings.TrimSpace(string(out)) + if strings.HasPrefix(en, "enabled") { + st.Enabled = true + } + } + + if !st.Installed { + st.Detail = "unit file not installed" + } else if !st.Running { + st.Detail = "installed but not running" + } + return st, nil +} + +func (m *systemdUserManager) Logs(lines int) (string, error) { + if lines <= 0 { + lines = 100 + } + out, err := runCommand(m.runner, 10*time.Second, "journalctl", "--user", "-u", m.journalID, "-n", fmt.Sprintf("%d", lines), "--no-pager") + if err != nil { + return "", fmt.Errorf("journalctl failed: %s", oneLine(string(out))) + } + return string(out), nil +} + +func (m *systemdUserManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error { + n := lines + if n <= 0 { + n = 100 + } + cmd := exec.CommandContext(ctx, "journalctl", "--user", "-u", m.journalID, "-n", fmt.Sprintf("%d", n), "-f", "--no-pager") + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() +} + +func (m *systemdUserManager) detectBrewPrefix() string { + if _, err := exec.LookPath("brew"); err != nil { + return "" + } + out, err := runCommand(m.runner, 4*time.Second, "brew", "--prefix") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/service/systemd_stub.go b/pkg/service/systemd_stub.go new file mode 100644 index 000000000..e07ea6b4d --- /dev/null +++ b/pkg/service/systemd_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package service + +func newSystemdUserManager(exePath string, runner commandRunner) Manager { + return newUnsupportedManager("systemd user services are only available on Linux") +} diff --git a/pkg/service/templates.go b/pkg/service/templates.go new file mode 100644 index 000000000..bb4a2bf80 --- /dev/null +++ b/pkg/service/templates.go @@ -0,0 +1,59 @@ +package service + +import "fmt" + +func renderSystemdUnit(exePath, pathEnv string) string { + return fmt.Sprintf(`[Unit] +Description=PicoClaw Gateway +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%s gateway +Restart=always +RestartSec=3 +Environment=HOME=%%h +Environment=PATH=%s +WorkingDirectory=%%h + +[Install] +WantedBy=default.target +`, exePath, pathEnv) +} + +func renderLaunchdPlist(label, exePath, stdoutPath, stderrPath, pathEnv string) string { + return fmt.Sprintf(` + + + + Label + %s + + ProgramArguments + + %s + gateway + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + %s + + StandardErrorPath + %s + + EnvironmentVariables + + PATH + %s + + + +`, label, exePath, stdoutPath, stderrPath, pathEnv) +} diff --git a/pkg/service/unsupported.go b/pkg/service/unsupported.go new file mode 100644 index 000000000..d77838d77 --- /dev/null +++ b/pkg/service/unsupported.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "fmt" + "io" +) + +type unsupportedManager struct { + detail string +} + +func newUnsupportedManager(detail string) Manager { + return &unsupportedManager{detail: detail} +} + +func (m *unsupportedManager) Backend() string { return BackendUnsupported } + +func (m *unsupportedManager) Install() error { + return fmt.Errorf("service management unavailable: %s", m.detail) +} + +func (m *unsupportedManager) Uninstall() error { + return fmt.Errorf("service management unavailable: %s", m.detail) +} + +func (m *unsupportedManager) Start() error { + return fmt.Errorf("service management unavailable: %s", m.detail) +} + +func (m *unsupportedManager) Stop() error { + return fmt.Errorf("service management unavailable: %s", m.detail) +} + +func (m *unsupportedManager) Restart() error { + return fmt.Errorf("service management unavailable: %s", m.detail) +} + +func (m *unsupportedManager) Status() (Status, error) { + return Status{ + Backend: BackendUnsupported, + Detail: m.detail, + }, nil +} + +func (m *unsupportedManager) Logs(lines int) (string, error) { + return "", fmt.Errorf("service logs unavailable: %s", m.detail) +} + +func (m *unsupportedManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error { + return fmt.Errorf("service logs unavailable: %s", m.detail) +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 37db8b4ae..ea7039d34 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -2,6 +2,7 @@ package tools import ( "context" + "errors" "fmt" "io/fs" "os" @@ -229,6 +230,9 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes entries, err := t.fs.ReadDir(path) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrorResult(fmt.Sprintf("directory does not exist: %s", path)) + } return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) } return formatDirEntries(entries) diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 6f896e22d..d2bafa3e8 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -232,8 +232,9 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { t.Errorf("Expected error for non-existent directory, got IsError=false") } - // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { + // Should contain error message (either "directory does not exist" or "failed to read") + msg := result.ForLLM + result.ForUser + if !strings.Contains(msg, "directory does not exist") && !strings.Contains(msg, "failed to read") { t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } From 640fcff409844398abe12c547420050ee700e032 Mon Sep 17 00:00:00 2001 From: YS Liu Date: Wed, 25 Feb 2026 14:21:21 +0800 Subject: [PATCH 2/2] docs: add full service command documentation - Add docs/service.md: install/uninstall/start/stop/restart/status/logs/refresh - Platform support (launchd, systemd user, WSL), file locations, PATH/env - Logs options -n/--lines and -f/--follow, troubleshooting - README: add service to commands table with link, clarify gateway (foreground) Co-authored-by: Cursor --- README.md | 5 +- docs/service.md | 257 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 docs/service.md diff --git a/README.md b/README.md index 2b770f215..a482caede 100644 --- a/README.md +++ b/README.md @@ -1112,7 +1112,8 @@ picoclaw agent -m "Hello" | `picoclaw onboard` | Initialize config & workspace | | `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent` | Interactive chat mode | -| `picoclaw gateway` | Start the gateway | +| `picoclaw gateway` | Start the gateway (foreground)| +| `picoclaw service` | Background gateway (launchd/systemd); see [Service docs](docs/service.md) | | `picoclaw status` | Show status | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | @@ -1178,7 +1179,7 @@ Some providers (like Zhipu) have content filtering. Try rephrasing your query or ### Telegram bot says "Conflict: terminated by other getUpdates" -This happens when another instance of the bot is running. Make sure only one `picoclaw gateway` is running at a time. +This happens when another instance of the bot is running. Make sure only one gateway is running: either stop the background service (`picoclaw service stop`) or do not start a second `picoclaw gateway`. See [Service docs](docs/service.md) for background service usage. --- diff --git a/docs/service.md b/docs/service.md new file mode 100644 index 000000000..9f76ccdbf --- /dev/null +++ b/docs/service.md @@ -0,0 +1,257 @@ +# PicoClaw Service (Background Gateway) + +The `picoclaw service` command manages the PicoClaw gateway as a **background service** using the system’s native service manager. This keeps the gateway running after you close the terminal and across reboots (when enabled). + +## When to Use Service vs Gateway + +| Mode | Command | Use case | +|------|---------|----------| +| **Foreground** | `picoclaw gateway` | Quick run in a terminal; stops when you press Ctrl+C. | +| **Background service** | `picoclaw service install` then `picoclaw service start` | Long‑running gateway; survives logout and can start on boot. | + +If you run `picoclaw gateway` while the service is already running, the CLI will warn you and exit to avoid two gateways (and port conflicts). + +--- + +## Supported Platforms + +| Platform | Backend | Notes | +|----------|---------|--------| +| **macOS** | launchd | Per-user agent: `~/Library/LaunchAgents/io.picoclaw.gateway.plist`. | +| **Linux** | systemd user | Per-user unit: `~/.config/systemd/user/picoclaw-gateway.service`. Requires `systemctl --user` (user session). | +| **WSL** | systemd user or unsupported | If systemd is enabled in WSL, service works. Otherwise use `picoclaw gateway` in a terminal. | +| **Windows / other** | Unsupported | Use `picoclaw gateway` in a terminal or another process manager. | + +--- + +## Commands Overview + +```text +picoclaw service install # Register the gateway with launchd (macOS) or systemd (Linux) +picoclaw service uninstall # Remove the service registration +picoclaw service start # Start the background gateway +picoclaw service stop # Stop the background gateway +picoclaw service restart # Stop then start (e.g. after config change) +picoclaw service status # Show whether the service is installed and running +picoclaw service logs # Show recent gateway logs (options: -n, -f) +picoclaw service refresh # Reinstall unit/plist and restart (e.g. after upgrading the binary) +``` + +--- + +## Subcommands in Detail + +### install + +Registers the PicoClaw gateway as a background service. The executable path is resolved from how you invoked `picoclaw` (e.g. `/opt/homebrew/bin/picoclaw` or `~/go/bin/picoclaw`). + +- **macOS**: Creates a launchd plist and runs `launchctl bootstrap`. +- **Linux**: Creates a systemd user unit and runs `systemctl --user daemon-reload` and `systemctl --user enable`. + +The service is configured to run `picoclaw gateway` with a PATH that includes common system paths and, if present, `~/.picoclaw/workspace/.venv/bin`. **`config.json` is not modified** by install. + +**Example** + +```bash +picoclaw service install +# ✓ Service installed +# Start with: picoclaw service start +``` + +--- + +### uninstall + +Removes the service registration and stops the gateway if it is running. + +- **macOS**: `launchctl bootout` and deletes the plist. +- **Linux**: `systemctl --user disable --now` and deletes the unit file. + +**Example** + +```bash +picoclaw service uninstall +# ✓ Service uninstalled +``` + +--- + +### start + +Starts the background gateway. Fails with a clear message if the service is not installed (run `picoclaw service install` first). + +**Example** + +```bash +picoclaw service start +# ✓ Service started +``` + +--- + +### stop + +Stops the background gateway. No error if it was already stopped. + +**Example** + +```bash +picoclaw service stop +# ✓ Service stopped +``` + +--- + +### restart + +Stops then starts the gateway. Useful after editing `config.json` or upgrading the binary (or use `picoclaw service refresh` to also reinstall the unit/plist). + +**Example** + +```bash +picoclaw service restart +# ✓ Service restarted +``` + +--- + +### status + +Shows whether the service is installed, running, and (on Linux) enabled. Also shows the backend (e.g. `launchd` or `systemd-user`) and optional detail (e.g. “installed but not loaded”). + +**Example** + +```bash +picoclaw service status +``` + +**Example output** + +```text +Gateway service status: + Backend: launchd + Installed: yes + Running: yes + Enabled: yes +``` + +--- + +### logs + +Prints recent gateway logs. On macOS these are the launchd stdout/stderr files; on Linux they come from `journalctl --user -u picoclaw-gateway.service`. + +**Options** + +| Option | Description | +|--------|-------------| +| `-n`, `--lines ` | Number of lines to show (default: 100). | +| `-f`, `--follow` | Follow log output (like `tail -f`). Press Ctrl+C to stop. | + +**Examples** + +```bash +picoclaw service logs +picoclaw service logs -n 200 +picoclaw service logs --lines 50 +picoclaw service logs -f +picoclaw service logs -f -n 20 +``` + +--- + +### refresh + +Reinstalls the service definition (plist or unit) and restarts the gateway. Use after upgrading the `picoclaw` binary so the service runs the new version and keeps PATH/env in sync. + +**Example** + +```bash +picoclaw service refresh +# ✓ Service refreshed +# Reinstalled and restarted (run: picoclaw service status) +``` + +--- + +## File Locations + +### macOS (launchd) + +| Item | Path | +|------|------| +| Plist | `~/Library/LaunchAgents/io.picoclaw.gateway.plist` | +| Stdout log | `~/.picoclaw/gateway.log` | +| Stderr log | `~/.picoclaw/gateway.err.log` | + +### Linux (systemd user) + +| Item | Path | +|------|------| +| Unit file | `~/.config/systemd/user/picoclaw-gateway.service` | +| Logs | `journalctl --user -u picoclaw-gateway.service` (see `picoclaw service logs`) | + +--- + +## Environment and PATH + +- **install** uses the current process `PATH` (and, if present, `~/.picoclaw/workspace/.venv/bin`) when generating the plist or unit so the background process has a sane PATH. +- **config.json** is **not** updated by `service install`; the gateway still reads `~/.picoclaw/config.json` at runtime. + +--- + +## Troubleshooting + +### "Gateway is already running via launchd/service" + +You started `picoclaw gateway` while the background service is already running. Either: + +- Use the service: `picoclaw service stop` / `picoclaw service restart` / `picoclaw service logs`, or +- Stop the service first: `picoclaw service stop`, then run `picoclaw gateway` if you want foreground only. + +### "service is not installed; run \`picoclaw service install\`" + +You ran `picoclaw service start` (or `restart`) before installing. Run: + +```bash +picoclaw service install +picoclaw service start +``` + +### "WSL detected but systemd user manager is not active" + +On WSL, enable systemd (e.g. in `/etc/wsl.conf`) or run the gateway in the foreground: + +```bash +picoclaw gateway +``` + +### "directory does not exist" or "no such file or directory" in logs + +Those messages come from the gateway (e.g. tools or config). They are not caused by the service command itself. Check `config.json` and paths (e.g. workspace) used by the agent. + +### Logs are empty or "no launchd logs found" + +- **macOS**: Ensure the service has been started at least once (`picoclaw service start`). Logs are written to `~/.picoclaw/gateway.log` and `~/.picoclaw/gateway.err.log`. +- **Linux**: Use `picoclaw service logs` (or `journalctl --user -u picoclaw-gateway.service`) after the service has run. + +--- + +## Quick Reference + +```bash +# First-time: install and start +picoclaw service install && picoclaw service start + +# Daily use +picoclaw service status # check it's running +picoclaw service logs -f # follow logs +picoclaw service restart # after editing config + +# Upgrade binary then refresh service +picoclaw service refresh + +# Remove service +picoclaw service stop +picoclaw service uninstall +```