Skip to content

Commit f3bb6a1

Browse files
feat(cli): add service subcommand for systemd user service management
Adds `adk-claw service` with install, uninstall, start, stop, restart, status, and logs subcommands. The install flow writes a user-level systemd service file (~/.config/systemd/user/adk-claw.service), enables linger so the service survives SSH logout, and embeds the install-time PATH so nvm-managed node is found correctly at runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b0a6a4a commit f3bb6a1

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed

src/cli/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ async function main() {
2323
case "pairing":
2424
await import("./pairing.js").then((m) => m.pairingCommand(args.slice(1)));
2525
break;
26+
case "service":
27+
await import("./service.js").then((m) => m.serviceCommand(args.slice(1)));
28+
break;
2629
case "help":
2730
case "--help":
2831
case "-h":
@@ -51,6 +54,7 @@ Commands:
5154
init Initialize ADK Claw (default)
5255
start Start the Telegram bot
5356
skill <cmd> Manage skills (add, list, remove)
57+
service <cmd> Manage the systemd user service (install, start, stop, logs, ...)
5458
pairing <cmd> Manage user pairing (list, approve, deny, users, remove)
5559
help Show this help message
5660
version Show version
@@ -61,6 +65,8 @@ Examples:
6165
adk-claw start # Launch Telegram bot
6266
adk-claw skill add anthropics/skills --skill frontend-design # Install a skill
6367
adk-claw skill list # List installed skills
68+
adk-claw service install # Install as a systemd service
69+
adk-claw service logs # Follow live service logs
6470
adk-claw pairing list telegram # List pending pairing requests
6571
adk-claw pairing approve telegram ABC12DEF # Approve a user
6672
`);

src/cli/service.ts

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* CLI command handlers for systemd user service management
3+
*/
4+
5+
import { execSync, spawn } from "node:child_process";
6+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
7+
import { homedir } from "node:os";
8+
import { join } from "node:path";
9+
import * as p from "@clack/prompts";
10+
import pc from "picocolors";
11+
import { getConfig } from "../config/index.js";
12+
13+
const PROJECT_DIR = process.cwd();
14+
const ADK_CLAW_DIR = join(PROJECT_DIR, ".adk-claw");
15+
const CONFIG_PATH = join(ADK_CLAW_DIR, "config.json");
16+
17+
const SERVICE_NAME = "adk-claw";
18+
const SERVICE_DIR = join(homedir(), ".config", "systemd", "user");
19+
const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`);
20+
21+
/**
22+
* Main service command router
23+
*/
24+
export async function serviceCommand(args: string[]) {
25+
const subcommand = args[0];
26+
27+
if (!existsSync(CONFIG_PATH)) {
28+
p.log.error("ADK Claw is not initialized. Run 'adk-claw init' first.");
29+
process.exit(1);
30+
}
31+
32+
switch (subcommand) {
33+
case "install":
34+
await installService();
35+
break;
36+
case "uninstall":
37+
case "remove":
38+
await uninstallService();
39+
break;
40+
case "start":
41+
runServiceCommand("start");
42+
break;
43+
case "stop":
44+
runServiceCommand("stop");
45+
break;
46+
case "restart":
47+
runServiceCommand("restart");
48+
break;
49+
case "status":
50+
streamServiceCommand("status");
51+
break;
52+
case "logs":
53+
streamLogs();
54+
break;
55+
case "help":
56+
case "--help":
57+
case "-h":
58+
case undefined:
59+
printServiceHelp();
60+
break;
61+
default:
62+
p.log.error(`Unknown service command: ${subcommand}`);
63+
printServiceHelp();
64+
process.exit(1);
65+
}
66+
}
67+
68+
/**
69+
* Install the systemd user service
70+
*/
71+
async function installService() {
72+
const s = p.spinner();
73+
74+
try {
75+
// Step 1: Resolve binary path
76+
s.start("Resolving adk-claw binary...");
77+
let adkClawBin: string;
78+
try {
79+
adkClawBin = execSync("which adk-claw", { encoding: "utf-8" }).trim();
80+
} catch {
81+
s.stop("Failed to resolve binary");
82+
p.log.error(
83+
"Could not find 'adk-claw' in PATH. Make sure it is installed globally.\n" +
84+
" pnpm link --global or npm install -g adk-claw",
85+
);
86+
process.exit(1);
87+
}
88+
89+
// Step 2: Get agent name from config
90+
const config = getConfig();
91+
const agentName = config.agentName;
92+
const cwd = PROJECT_DIR;
93+
94+
// Step 3: Generate service file content
95+
// Capture PATH now so systemd (which has a minimal environment) can find node/nvm binaries
96+
const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
97+
s.message("Generating service file...");
98+
const serviceContent = generateServiceFile(
99+
agentName,
100+
cwd,
101+
adkClawBin,
102+
currentPath,
103+
);
104+
105+
// Step 4: Write service file
106+
mkdirSync(SERVICE_DIR, { recursive: true });
107+
writeFileSync(SERVICE_FILE, serviceContent, "utf-8");
108+
109+
// Step 5: daemon-reload
110+
s.message("Reloading systemd daemon...");
111+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
112+
113+
// Step 6: enable
114+
s.message("Enabling service...");
115+
execSync(`systemctl --user enable ${SERVICE_NAME}`, { stdio: "pipe" });
116+
117+
// Step 7: enable-linger so service survives SSH logout
118+
s.message("Enabling linger...");
119+
const username =
120+
process.env.USER ||
121+
process.env.LOGNAME ||
122+
execSync("whoami", { encoding: "utf-8" }).trim();
123+
try {
124+
execSync(`loginctl enable-linger ${username}`, { stdio: "pipe" });
125+
} catch {
126+
// Non-fatal — linger may require sudo on some systems
127+
p.log.warn(
128+
`Could not enable linger for user ${username}. ` +
129+
`Run: sudo loginctl enable-linger ${username}`,
130+
);
131+
}
132+
133+
s.stop("Service installed and enabled");
134+
135+
// Step 8: Ask if they want to start now
136+
const shouldStart = await p.confirm({
137+
message: "Start the service now?",
138+
initialValue: true,
139+
});
140+
141+
if (p.isCancel(shouldStart)) {
142+
p.log.info("Skipped starting service.");
143+
} else if (shouldStart) {
144+
execSync(`systemctl --user start ${SERVICE_NAME}`, { stdio: "pipe" });
145+
p.log.success("Service started!");
146+
}
147+
148+
p.note(
149+
`Service file: ${pc.dim(SERVICE_FILE)}
150+
Binary: ${pc.dim(adkClawBin)}
151+
Working dir: ${pc.dim(cwd)}
152+
153+
${pc.bold("Useful commands:")}
154+
adk-claw service status # Check status
155+
adk-claw service logs # Follow logs
156+
adk-claw service stop # Stop service
157+
adk-claw service restart # Restart service
158+
adk-claw service uninstall # Remove service`,
159+
"Service installed",
160+
);
161+
} catch (error) {
162+
s.stop("Installation failed");
163+
p.log.error(
164+
error instanceof Error ? error.message : "Unknown error occurred",
165+
);
166+
process.exit(1);
167+
}
168+
}
169+
170+
/**
171+
* Uninstall the systemd user service
172+
*/
173+
async function uninstallService() {
174+
const s = p.spinner();
175+
176+
try {
177+
s.start("Stopping service...");
178+
try {
179+
execSync(`systemctl --user stop ${SERVICE_NAME}`, { stdio: "pipe" });
180+
} catch {
181+
// Service may not be running — fine
182+
}
183+
184+
s.message("Disabling service...");
185+
try {
186+
execSync(`systemctl --user disable ${SERVICE_NAME}`, { stdio: "pipe" });
187+
} catch {
188+
// Service may not be enabled — fine
189+
}
190+
191+
s.message("Removing service file...");
192+
if (existsSync(SERVICE_FILE)) {
193+
unlinkSync(SERVICE_FILE);
194+
}
195+
196+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
197+
198+
s.stop("Service uninstalled");
199+
p.log.success("adk-claw service has been removed.");
200+
} catch (error) {
201+
s.stop("Uninstall failed");
202+
p.log.error(
203+
error instanceof Error ? error.message : "Unknown error occurred",
204+
);
205+
process.exit(1);
206+
}
207+
}
208+
209+
/**
210+
* Run a simple one-shot systemctl command
211+
*/
212+
function runServiceCommand(action: "start" | "stop" | "restart") {
213+
try {
214+
execSync(`systemctl --user ${action} ${SERVICE_NAME}`, {
215+
stdio: "inherit",
216+
});
217+
p.log.success(`Service ${action}ed.`);
218+
} catch (error) {
219+
p.log.error(
220+
error instanceof Error ? error.message : `Failed to ${action} service`,
221+
);
222+
process.exit(1);
223+
}
224+
}
225+
226+
/**
227+
* Stream systemctl status output to terminal
228+
*/
229+
function streamServiceCommand(action: "status") {
230+
const child = spawn("systemctl", ["--user", action, SERVICE_NAME], {
231+
stdio: "inherit",
232+
});
233+
child.on("exit", (code) => {
234+
process.exit(code ?? 0);
235+
});
236+
}
237+
238+
/**
239+
* Stream journalctl logs to terminal
240+
*/
241+
function streamLogs() {
242+
const child = spawn("journalctl", ["--user", "-u", SERVICE_NAME, "-f"], {
243+
stdio: "inherit",
244+
});
245+
child.on("exit", (code) => {
246+
process.exit(code ?? 0);
247+
});
248+
}
249+
250+
/**
251+
* Generate systemd service file content
252+
*/
253+
function generateServiceFile(
254+
agentName: string,
255+
cwd: string,
256+
adkClawBin: string,
257+
envPath: string,
258+
): string {
259+
return `[Unit]
260+
Description=ADK Claw Bot (${agentName})
261+
After=network-online.target
262+
Wants=network-online.target
263+
264+
[Service]
265+
Type=simple
266+
WorkingDirectory=${cwd}
267+
Environment=PATH=${envPath}
268+
ExecStart=${adkClawBin} start
269+
Restart=on-failure
270+
RestartSec=10
271+
EnvironmentFile=-${cwd}/.env
272+
StandardOutput=journal
273+
StandardError=journal
274+
SyslogIdentifier=adk-claw
275+
276+
[Install]
277+
WantedBy=default.target
278+
`;
279+
}
280+
281+
/**
282+
* Print service command help
283+
*/
284+
function printServiceHelp() {
285+
console.log(`
286+
${pc.bold("adk-claw service")} - Manage the systemd user service
287+
288+
${pc.bold("Usage:")}
289+
adk-claw service <command>
290+
291+
${pc.bold("Commands:")}
292+
install Install and enable the systemd service
293+
uninstall Stop, disable, and remove the service
294+
start Start the service
295+
stop Stop the service
296+
restart Restart the service
297+
status Show service status
298+
logs Follow live service logs
299+
300+
${pc.bold("Examples:")}
301+
${pc.dim("# Install the service (runs on boot, survives SSH logout)")}
302+
adk-claw service install
303+
304+
${pc.dim("# Check if service is running")}
305+
adk-claw service status
306+
307+
${pc.dim("# Follow live logs")}
308+
adk-claw service logs
309+
310+
${pc.dim("# Remove the service entirely")}
311+
adk-claw service uninstall
312+
`);
313+
}

0 commit comments

Comments
 (0)