From f3bb6a140809ca04e9846d58a35423e48200607f Mon Sep 17 00:00:00 2001 From: MarvelNwachukwu Date: Thu, 19 Feb 2026 10:08:30 +0000 Subject: [PATCH 1/2] 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 --- src/cli/index.ts | 6 + src/cli/service.ts | 313 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 src/cli/service.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 958a8cc..65ef988 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,6 +23,9 @@ async function main() { case "pairing": await import("./pairing.js").then((m) => m.pairingCommand(args.slice(1))); break; + case "service": + await import("./service.js").then((m) => m.serviceCommand(args.slice(1))); + break; case "help": case "--help": case "-h": @@ -51,6 +54,7 @@ Commands: init Initialize ADK Claw (default) start Start the Telegram bot skill Manage skills (add, list, remove) + service Manage the systemd user service (install, start, stop, logs, ...) pairing Manage user pairing (list, approve, deny, users, remove) help Show this help message version Show version @@ -61,6 +65,8 @@ Examples: adk-claw start # Launch Telegram bot adk-claw skill add anthropics/skills --skill frontend-design # Install a skill adk-claw skill list # List installed skills + adk-claw service install # Install as a systemd service + adk-claw service logs # Follow live service logs adk-claw pairing list telegram # List pending pairing requests adk-claw pairing approve telegram ABC12DEF # Approve a user `); diff --git a/src/cli/service.ts b/src/cli/service.ts new file mode 100644 index 0000000..6d90f81 --- /dev/null +++ b/src/cli/service.ts @@ -0,0 +1,313 @@ +/** + * CLI command handlers for systemd user service management + */ + +import { execSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { getConfig } from "../config/index.js"; + +const PROJECT_DIR = process.cwd(); +const ADK_CLAW_DIR = join(PROJECT_DIR, ".adk-claw"); +const CONFIG_PATH = join(ADK_CLAW_DIR, "config.json"); + +const SERVICE_NAME = "adk-claw"; +const SERVICE_DIR = join(homedir(), ".config", "systemd", "user"); +const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`); + +/** + * Main service command router + */ +export async function serviceCommand(args: string[]) { + const subcommand = args[0]; + + if (!existsSync(CONFIG_PATH)) { + p.log.error("ADK Claw is not initialized. Run 'adk-claw init' first."); + process.exit(1); + } + + switch (subcommand) { + case "install": + await installService(); + break; + case "uninstall": + case "remove": + await uninstallService(); + break; + case "start": + runServiceCommand("start"); + break; + case "stop": + runServiceCommand("stop"); + break; + case "restart": + runServiceCommand("restart"); + break; + case "status": + streamServiceCommand("status"); + break; + case "logs": + streamLogs(); + break; + case "help": + case "--help": + case "-h": + case undefined: + printServiceHelp(); + break; + default: + p.log.error(`Unknown service command: ${subcommand}`); + printServiceHelp(); + process.exit(1); + } +} + +/** + * Install the systemd user service + */ +async function installService() { + const s = p.spinner(); + + try { + // Step 1: Resolve binary path + s.start("Resolving adk-claw binary..."); + let adkClawBin: string; + try { + adkClawBin = execSync("which adk-claw", { encoding: "utf-8" }).trim(); + } catch { + s.stop("Failed to resolve binary"); + p.log.error( + "Could not find 'adk-claw' in PATH. Make sure it is installed globally.\n" + + " pnpm link --global or npm install -g adk-claw", + ); + process.exit(1); + } + + // Step 2: Get agent name from config + const config = getConfig(); + const agentName = config.agentName; + const cwd = PROJECT_DIR; + + // Step 3: Generate service file content + // Capture PATH now so systemd (which has a minimal environment) can find node/nvm binaries + const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; + s.message("Generating service file..."); + const serviceContent = generateServiceFile( + agentName, + cwd, + adkClawBin, + currentPath, + ); + + // Step 4: Write service file + mkdirSync(SERVICE_DIR, { recursive: true }); + writeFileSync(SERVICE_FILE, serviceContent, "utf-8"); + + // Step 5: daemon-reload + s.message("Reloading systemd daemon..."); + execSync("systemctl --user daemon-reload", { stdio: "pipe" }); + + // Step 6: enable + s.message("Enabling service..."); + execSync(`systemctl --user enable ${SERVICE_NAME}`, { stdio: "pipe" }); + + // Step 7: enable-linger so service survives SSH logout + s.message("Enabling linger..."); + const username = + process.env.USER || + process.env.LOGNAME || + execSync("whoami", { encoding: "utf-8" }).trim(); + try { + execSync(`loginctl enable-linger ${username}`, { stdio: "pipe" }); + } catch { + // Non-fatal — linger may require sudo on some systems + p.log.warn( + `Could not enable linger for user ${username}. ` + + `Run: sudo loginctl enable-linger ${username}`, + ); + } + + s.stop("Service installed and enabled"); + + // Step 8: Ask if they want to start now + const shouldStart = await p.confirm({ + message: "Start the service now?", + initialValue: true, + }); + + if (p.isCancel(shouldStart)) { + p.log.info("Skipped starting service."); + } else if (shouldStart) { + execSync(`systemctl --user start ${SERVICE_NAME}`, { stdio: "pipe" }); + p.log.success("Service started!"); + } + + p.note( + `Service file: ${pc.dim(SERVICE_FILE)} +Binary: ${pc.dim(adkClawBin)} +Working dir: ${pc.dim(cwd)} + +${pc.bold("Useful commands:")} + adk-claw service status # Check status + adk-claw service logs # Follow logs + adk-claw service stop # Stop service + adk-claw service restart # Restart service + adk-claw service uninstall # Remove service`, + "Service installed", + ); + } catch (error) { + s.stop("Installation failed"); + p.log.error( + error instanceof Error ? error.message : "Unknown error occurred", + ); + process.exit(1); + } +} + +/** + * Uninstall the systemd user service + */ +async function uninstallService() { + const s = p.spinner(); + + try { + s.start("Stopping service..."); + try { + execSync(`systemctl --user stop ${SERVICE_NAME}`, { stdio: "pipe" }); + } catch { + // Service may not be running — fine + } + + s.message("Disabling service..."); + try { + execSync(`systemctl --user disable ${SERVICE_NAME}`, { stdio: "pipe" }); + } catch { + // Service may not be enabled — fine + } + + s.message("Removing service file..."); + if (existsSync(SERVICE_FILE)) { + unlinkSync(SERVICE_FILE); + } + + execSync("systemctl --user daemon-reload", { stdio: "pipe" }); + + s.stop("Service uninstalled"); + p.log.success("adk-claw service has been removed."); + } catch (error) { + s.stop("Uninstall failed"); + p.log.error( + error instanceof Error ? error.message : "Unknown error occurred", + ); + process.exit(1); + } +} + +/** + * Run a simple one-shot systemctl command + */ +function runServiceCommand(action: "start" | "stop" | "restart") { + try { + execSync(`systemctl --user ${action} ${SERVICE_NAME}`, { + stdio: "inherit", + }); + p.log.success(`Service ${action}ed.`); + } catch (error) { + p.log.error( + error instanceof Error ? error.message : `Failed to ${action} service`, + ); + process.exit(1); + } +} + +/** + * Stream systemctl status output to terminal + */ +function streamServiceCommand(action: "status") { + const child = spawn("systemctl", ["--user", action, SERVICE_NAME], { + stdio: "inherit", + }); + child.on("exit", (code) => { + process.exit(code ?? 0); + }); +} + +/** + * Stream journalctl logs to terminal + */ +function streamLogs() { + const child = spawn("journalctl", ["--user", "-u", SERVICE_NAME, "-f"], { + stdio: "inherit", + }); + child.on("exit", (code) => { + process.exit(code ?? 0); + }); +} + +/** + * Generate systemd service file content + */ +function generateServiceFile( + agentName: string, + cwd: string, + adkClawBin: string, + envPath: string, +): string { + return `[Unit] +Description=ADK Claw Bot (${agentName}) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=${cwd} +Environment=PATH=${envPath} +ExecStart=${adkClawBin} start +Restart=on-failure +RestartSec=10 +EnvironmentFile=-${cwd}/.env +StandardOutput=journal +StandardError=journal +SyslogIdentifier=adk-claw + +[Install] +WantedBy=default.target +`; +} + +/** + * Print service command help + */ +function printServiceHelp() { + console.log(` +${pc.bold("adk-claw service")} - Manage the systemd user service + +${pc.bold("Usage:")} + adk-claw service + +${pc.bold("Commands:")} + install Install and enable the systemd service + uninstall Stop, disable, and remove the service + start Start the service + stop Stop the service + restart Restart the service + status Show service status + logs Follow live service logs + +${pc.bold("Examples:")} + ${pc.dim("# Install the service (runs on boot, survives SSH logout)")} + adk-claw service install + + ${pc.dim("# Check if service is running")} + adk-claw service status + + ${pc.dim("# Follow live logs")} + adk-claw service logs + + ${pc.dim("# Remove the service entirely")} + adk-claw service uninstall +`); +} From af72bf8ee17f2d6d4b13f362ea9801ae03599959 Mon Sep 17 00:00:00 2001 From: MarvelNwachukwu Date: Thu, 19 Feb 2026 10:39:26 +0000 Subject: [PATCH 2/2] fix(service): address code review security and robustness issues - Replace execSync template literal with execFileSync for loginctl enable-linger to prevent command injection - Replace execSync whoami + env vars with os.userInfo().username for a simpler, cross-platform username lookup - Add systemctl existence check at the start of serviceCommand to give a clear error on non-systemd systems instead of a cryptic failure Co-Authored-By: Claude Sonnet 4.6 --- src/cli/service.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cli/service.ts b/src/cli/service.ts index 6d90f81..ca6a65b 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -2,9 +2,9 @@ * CLI command handlers for systemd user service management */ -import { execSync, spawn } from "node:child_process"; +import { execFileSync, execSync, spawn } from "node:child_process"; import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { homedir, userInfo } from "node:os"; import { join } from "node:path"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -24,6 +24,15 @@ const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`); export async function serviceCommand(args: string[]) { const subcommand = args[0]; + try { + execFileSync("which", ["systemctl"], { stdio: "pipe" }); + } catch { + p.log.error( + "Service management requires systemd, which was not found on this system.", + ); + process.exit(1); + } + if (!existsSync(CONFIG_PATH)) { p.log.error("ADK Claw is not initialized. Run 'adk-claw init' first."); process.exit(1); @@ -116,12 +125,9 @@ async function installService() { // Step 7: enable-linger so service survives SSH logout s.message("Enabling linger..."); - const username = - process.env.USER || - process.env.LOGNAME || - execSync("whoami", { encoding: "utf-8" }).trim(); + const username = userInfo().username; try { - execSync(`loginctl enable-linger ${username}`, { stdio: "pipe" }); + execFileSync("loginctl", ["enable-linger", username], { stdio: "pipe" }); } catch { // Non-fatal — linger may require sudo on some systems p.log.warn(