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