Skip to content

Commit 323e0be

Browse files
Merge pull request #23 from IQAIcom/feat/service-command
feat(cli): add service subcommand for systemd user service management
2 parents 64d14d7 + af72bf8 commit 323e0be

File tree

2 files changed

+323
-0
lines changed

2 files changed

+323
-0
lines changed

src/cli/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function printSplash() {
3232
console.log(` ${pc.magenta("init")} Create a new ADK Claw project`);
3333
console.log(` ${pc.magenta("start")} Start the AI agent`);
3434
console.log(` ${pc.magenta("skill")} Manage agent skills`);
35+
console.log(` ${pc.magenta("service")} Manage the systemd user service`);
3536
console.log(` ${pc.magenta("pairing")} Manage Telegram user pairing`);
3637
console.log();
3738
console.log(pc.dim(` Docs: https://github.com/IQAIcom/adk-claw`));
@@ -57,6 +58,9 @@ async function main() {
5758
case "pairing":
5859
await import("./pairing.js").then((m) => m.pairingCommand(args.slice(1)));
5960
break;
61+
case "service":
62+
await import("./service.js").then((m) => m.serviceCommand(args.slice(1)));
63+
break;
6064
case "help":
6165
case "--help":
6266
case "-h":

src/cli/service.ts

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

0 commit comments

Comments
 (0)