Skip to content

Commit f76c06b

Browse files
gcmsgclaude
andcommitted
feat: add peerclaw service for OS-level agent service management
One-command install of the agent as a persistent OS service: - macOS: launchd plist (~/ LaunchAgents, KeepAlive, log rotation) - Linux: systemd user unit (no sudo, restart-on-failure, linger hint) - Subcommands: install, uninstall, status, logs - Modes: mcp (default) or heartbeat - Unsupported platforms get a clear error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent db91771 commit f76c06b

6 files changed

Lines changed: 572 additions & 2 deletions

File tree

internal/cmd/completion.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ _peerclaw() {
3434
cur="${COMP_WORDS[COMP_CWORD]}"
3535
prev="${COMP_WORDS[COMP_CWORD-1]}"
3636
37-
commands="agent invoke inbox send health config reputation identity send-file transfer mcp acp notifications completion version help"
37+
commands="agent invoke inbox send health config reputation identity send-file transfer mcp acp notifications service completion version help"
3838
agent_sub="list get register claim delete update discover heartbeat verify"
3939
inbox_sub="request status list"
4040
config_sub="set get list"
4141
reputation_sub="show list"
4242
identity_sub="anchor verify"
4343
notifications_sub="list count read read-all"
4444
transfer_sub="status"
45+
service_sub="install uninstall status logs"
4546
4647
case "${prev}" in
4748
peerclaw)
@@ -76,6 +77,10 @@ _peerclaw() {
7677
COMPREPLY=($(compgen -W "${transfer_sub}" -- "${cur}"))
7778
return 0
7879
;;
80+
service)
81+
COMPREPLY=($(compgen -W "${service_sub}" -- "${cur}"))
82+
return 0
83+
;;
7984
completion)
8085
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
8186
return 0
@@ -96,7 +101,7 @@ const zshCompletion = `#compdef peerclaw
96101
# Add to ~/.zshrc: eval "$(peerclaw completion zsh)"
97102
98103
_peerclaw() {
99-
local -a commands agent_sub inbox_sub config_sub reputation_sub identity_sub notifications_sub transfer_sub
104+
local -a commands agent_sub inbox_sub config_sub reputation_sub identity_sub notifications_sub transfer_sub service_sub
100105
101106
commands=(
102107
'agent:Manage agents'
@@ -112,6 +117,7 @@ _peerclaw() {
112117
'mcp:MCP server for AI tool integration'
113118
'acp:ACP stdio bridge'
114119
'notifications:Manage notifications'
120+
'service:Manage OS-level agent service'
115121
'completion:Generate shell completion'
116122
'version:Print version'
117123
'help:Show help'
@@ -124,6 +130,7 @@ _peerclaw() {
124130
identity_sub=(anchor verify)
125131
notifications_sub=(list count read read-all)
126132
transfer_sub=(status)
133+
service_sub=(install uninstall status logs)
127134
128135
_arguments -C \
129136
'1:command:->command' \
@@ -142,6 +149,7 @@ _peerclaw() {
142149
identity) _describe 'subcommand' identity_sub ;;
143150
notifications) _describe 'subcommand' notifications_sub ;;
144151
transfer) _describe 'subcommand' transfer_sub ;;
152+
service) _describe 'subcommand' service_sub ;;
145153
completion) _describe 'shell' '(bash zsh fish)' ;;
146154
esac
147155
;;
@@ -172,6 +180,7 @@ complete -c peerclaw -n '__fish_use_subcommand' -a 'transfer' -d 'Manage file tr
172180
complete -c peerclaw -n '__fish_use_subcommand' -a 'mcp' -d 'MCP server for AI tool integration'
173181
complete -c peerclaw -n '__fish_use_subcommand' -a 'acp' -d 'ACP stdio bridge'
174182
complete -c peerclaw -n '__fish_use_subcommand' -a 'notifications' -d 'Manage notifications'
183+
complete -c peerclaw -n '__fish_use_subcommand' -a 'service' -d 'Manage OS-level agent service'
175184
complete -c peerclaw -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion'
176185
complete -c peerclaw -n '__fish_use_subcommand' -a 'version' -d 'Print version'
177186
complete -c peerclaw -n '__fish_use_subcommand' -a 'help' -d 'Show help'
@@ -197,6 +206,9 @@ complete -c peerclaw -n '__fish_seen_subcommand_from notifications' -a 'list cou
197206
# transfer subcommands.
198207
complete -c peerclaw -n '__fish_seen_subcommand_from transfer' -a 'status'
199208
209+
# service subcommands.
210+
complete -c peerclaw -n '__fish_seen_subcommand_from service' -a 'install uninstall status logs'
211+
200212
# completion subcommands.
201213
complete -c peerclaw -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
202214

internal/cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func Run(args []string) int {
6767
return RunACP(args[1:], serverURL)
6868
case "notifications":
6969
return RunNotifications(args[1:], serverURL)
70+
case "service":
71+
return RunService(args[1:], serverURL)
7072
case "completion":
7173
return RunCompletion(args[1:])
7274
case "help", "-h", "--help":
@@ -99,6 +101,7 @@ Commands:
99101
mcp MCP server for AI tool integration (serve)
100102
acp ACP stdio bridge for agent communication (serve)
101103
notifications Manage notifications (list, count, read, read-all)
104+
service Manage OS-level agent service (install, uninstall, status, logs)
102105
completion Generate shell completion (bash, zsh, fish)
103106
version Print version
104107

internal/cmd/service.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package cmd
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// serviceManager abstracts OS-level service operations.
12+
type serviceManager interface {
13+
Install(cfg serviceConfig) error
14+
Uninstall() error
15+
Status() (string, error)
16+
Logs(lines int, follow bool) error
17+
}
18+
19+
// serviceConfig holds configuration for the managed service.
20+
type serviceConfig struct {
21+
BinaryPath string // absolute path to the peerclaw binary
22+
ServerURL string // PeerClaw server URL
23+
AgentID string // agent identity (required for heartbeat mode)
24+
Mode string // "mcp" or "heartbeat"
25+
Force bool // overwrite existing service
26+
}
27+
28+
// execArgs returns the command-line arguments for the service process.
29+
func (c serviceConfig) execArgs() []string {
30+
switch c.Mode {
31+
case "heartbeat":
32+
return []string{c.BinaryPath, "agent", "heartbeat", c.AgentID, "--loop", "--server", c.ServerURL}
33+
default: // "mcp"
34+
return []string{c.BinaryPath, "mcp", "serve", "--server", c.ServerURL}
35+
}
36+
}
37+
38+
// RunService handles the "service" subcommand.
39+
func RunService(args []string, serverURL string) int {
40+
if len(args) < 1 {
41+
printServiceUsage()
42+
return 1
43+
}
44+
45+
mgr := newServiceManager()
46+
47+
switch args[0] {
48+
case "install":
49+
return runServiceInstall(args[1:], serverURL, mgr)
50+
case "uninstall":
51+
return runServiceUninstall(mgr)
52+
case "status":
53+
return runServiceStatus(mgr)
54+
case "logs":
55+
return runServiceLogs(args[1:], mgr)
56+
default:
57+
fmt.Fprintf(os.Stderr, "unknown service command: %s\n\n", args[0])
58+
printServiceUsage()
59+
return 1
60+
}
61+
}
62+
63+
func runServiceInstall(args []string, serverURL string, mgr serviceManager) int {
64+
fs := flag.NewFlagSet("service install", flag.ContinueOnError)
65+
mode := fs.String("mode", "mcp", "Service mode: mcp (default) or heartbeat")
66+
force := fs.Bool("force", false, "Overwrite existing service file")
67+
addServerFlag(fs, &serverURL)
68+
69+
if err := fs.Parse(args); err != nil {
70+
return 1
71+
}
72+
73+
if *mode != "mcp" && *mode != "heartbeat" {
74+
fmt.Fprintf(os.Stderr, "Error: invalid mode %q (must be mcp or heartbeat)\n", *mode)
75+
return 1
76+
}
77+
78+
// Resolve binary path.
79+
exe, err := os.Executable()
80+
if err != nil {
81+
fmt.Fprintf(os.Stderr, "Error: cannot determine binary path: %v\n", err)
82+
return 1
83+
}
84+
binPath, err := filepath.EvalSymlinks(exe)
85+
if err != nil {
86+
fmt.Fprintf(os.Stderr, "Error: cannot resolve binary path: %v\n", err)
87+
return 1
88+
}
89+
90+
// Warn if binary is in a temporary location.
91+
if strings.Contains(binPath, "/tmp/") || strings.Contains(binPath, "go-build") {
92+
fmt.Fprintf(os.Stderr, "Warning: binary path %q looks temporary — the service may break after cleanup\n", binPath)
93+
}
94+
95+
// Load config for agent_id.
96+
cfg, _ := loadCLIConfig()
97+
agentID := ""
98+
if cfg != nil {
99+
agentID = cfg.AgentID
100+
}
101+
102+
if *mode == "heartbeat" && agentID == "" {
103+
fmt.Fprintln(os.Stderr, "Error: heartbeat mode requires agent_id in config")
104+
fmt.Fprintln(os.Stderr, "Run: peerclaw agent claim <name> --server <url>")
105+
return 1
106+
}
107+
108+
svcCfg := serviceConfig{
109+
BinaryPath: binPath,
110+
ServerURL: serverURL,
111+
AgentID: agentID,
112+
Mode: *mode,
113+
Force: *force,
114+
}
115+
116+
if err := mgr.Install(svcCfg); err != nil {
117+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
118+
return 1
119+
}
120+
121+
fmt.Println("Service installed and started successfully.")
122+
fmt.Printf(" Mode: %s\n", svcCfg.Mode)
123+
fmt.Printf(" Binary: %s\n", svcCfg.BinaryPath)
124+
fmt.Printf(" Server: %s\n", svcCfg.ServerURL)
125+
if svcCfg.AgentID != "" {
126+
fmt.Printf(" Agent: %s\n", svcCfg.AgentID)
127+
}
128+
fmt.Println()
129+
fmt.Println("Manage with:")
130+
fmt.Println(" peerclaw service status")
131+
fmt.Println(" peerclaw service logs -f")
132+
fmt.Println(" peerclaw service uninstall")
133+
return 0
134+
}
135+
136+
func runServiceUninstall(mgr serviceManager) int {
137+
if err := mgr.Uninstall(); err != nil {
138+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
139+
return 1
140+
}
141+
fmt.Println("Service uninstalled.")
142+
return 0
143+
}
144+
145+
func runServiceStatus(mgr serviceManager) int {
146+
status, err := mgr.Status()
147+
if err != nil {
148+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
149+
return 1
150+
}
151+
fmt.Print(status)
152+
return 0
153+
}
154+
155+
func runServiceLogs(args []string, mgr serviceManager) int {
156+
fs := flag.NewFlagSet("service logs", flag.ContinueOnError)
157+
lines := fs.Int("n", 50, "Number of log lines to show")
158+
follow := fs.Bool("f", false, "Follow log output")
159+
160+
if err := fs.Parse(args); err != nil {
161+
return 1
162+
}
163+
164+
if err := mgr.Logs(*lines, *follow); err != nil {
165+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
166+
return 1
167+
}
168+
return 0
169+
}
170+
171+
func printServiceUsage() {
172+
fmt.Fprintf(os.Stderr, `Usage: peerclaw service <command> [options]
173+
174+
Commands:
175+
install Install and start the agent as an OS service
176+
uninstall Stop and remove the agent service
177+
status Show service status
178+
logs View service logs
179+
180+
Options (install):
181+
--mode Service mode: mcp (default) or heartbeat
182+
--force Overwrite existing service file
183+
--server PeerClaw server URL
184+
185+
Options (logs):
186+
-n Number of lines to show (default: 50)
187+
-f Follow log output
188+
189+
Examples:
190+
peerclaw service install
191+
peerclaw service install --mode heartbeat
192+
peerclaw service install --force
193+
peerclaw service status
194+
peerclaw service logs -n 20 -f
195+
peerclaw service uninstall
196+
`)
197+
}

0 commit comments

Comments
 (0)