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 <cursoragent@cursor.com>
This commit is contained in:
YS Liu 2026-02-25 14:16:59 +08:00
parent 18ba88869a
commit 85c94a55ce
15 changed files with 1086 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
@ -26,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice" "github.com/sipeed/picoclaw/pkg/voice"
svcmgr "github.com/sipeed/picoclaw/pkg/service"
) )
func gatewayCmd() { 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() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)

View file

@ -12,6 +12,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
@ -107,6 +108,8 @@ func main() {
agentCmd() agentCmd()
case "gateway": case "gateway":
gatewayCmd() gatewayCmd()
case "service":
serviceCmd()
case "status": case "status":
statusCmd() statusCmd()
case "migrate": case "migrate":
@ -182,6 +185,7 @@ func printHelp() {
fmt.Println(" agent Interact with the agent directly") fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway") fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" service Manage background gateway service (launchd/systemd)")
fmt.Println(" status Show picoclaw status") fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
@ -197,3 +201,13 @@ func getConfigPath() string {
func loadConfig() (*config.Config, error) { func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath()) 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"
}

271
cmd/picoclaw/service_cmd.go Normal file
View file

@ -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 <N> 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")
}

23
pkg/service/detect.go Normal file
View file

@ -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")
}

View file

@ -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))
}

View file

@ -0,0 +1,7 @@
//go:build !darwin
package service
func newLaunchdManager(exePath string, runner commandRunner) Manager {
return newUnsupportedManager("launchd is only available on macOS")
}

47
pkg/service/logs.go Normal file
View file

@ -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
}

121
pkg/service/manager.go Normal file
View file

@ -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
}

74
pkg/service/path.go Normal file
View file

@ -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)
}

View file

@ -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))
}

View file

@ -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")
}

59
pkg/service/templates.go Normal file
View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>%s</string>
</dict>
</dict>
</plist>
`, label, exePath, stdoutPath, stderrPath, pathEnv)
}

View file

@ -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)
}

View file

@ -2,6 +2,7 @@ package tools
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
@ -229,6 +230,9 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
entries, err := t.fs.ReadDir(path) entries, err := t.fs.ReadDir(path)
if err != nil { 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 ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
} }
return formatDirEntries(entries) return formatDirEntries(entries)

View file

@ -232,8 +232,9 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
t.Errorf("Expected error for non-existent directory, got IsError=false") t.Errorf("Expected error for non-existent directory, got IsError=false")
} }
// Should contain error message // Should contain error message (either "directory does not exist" or "failed to read")
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "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) t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
} }
} }