Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function printSplash() {
console.log(` ${pc.magenta("init")} Create a new ADK Claw project`);
console.log(` ${pc.magenta("start")} Start the AI agent`);
console.log(` ${pc.magenta("skill")} Manage agent skills`);
console.log(` ${pc.magenta("service")} Manage the systemd user service`);
console.log(` ${pc.magenta("pairing")} Manage Telegram user pairing`);
console.log();
console.log(pc.dim(` Docs: https://github.com/IQAIcom/adk-claw`));
Expand All @@ -57,6 +58,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":
Expand Down
319 changes: 319 additions & 0 deletions src/cli/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
/**
* CLI command handlers for systemd user service management
*/

import { execFileSync, execSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir, userInfo } 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];

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);
}
Comment on lines +36 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The service management commands are specific to systemd, which is typically found on Linux. Running these commands on other operating systems like macOS or Windows will fail with a potentially confusing error message (e.g., systemctl not found). It would improve the user experience to add a check at the beginning of this function to ensure systemd is available and provide a clear error message if it's not.

For example, you could check for the existence of the systemctl binary before proceeding:

try {
  execSync('which systemctl', { stdio: 'pipe' });
} catch {
  p.log.error('Service management requires systemd, which was not found.');
  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 = userInfo().username;
try {
execFileSync("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 <command>

${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
`);
}
Loading