UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

326 lines (325 loc) • 15.7 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * launchd backend for `akm task` (macOS default). * * Each task is written as a per-user LaunchAgent plist at * `~/Library/LaunchAgents/com.akm.task.<id>.plist` and registered via * `launchctl bootstrap gui/<uid> <plist>`. Disabling uses * `launchctl disable gui/<uid>/<label>` and re-enabling uses `enable`. * * Platform notes: * • The `bootstrap` / `bootout` / `enable` / `disable` subcommands require * macOS 10.10 (Yosemite) or newer. We only target modern macOS. * • `gui/<uid>` is the per-user GUI launchd domain — agents in this * domain only run while the user is logged in. Tasks that need to run * when the user is logged out should be installed as system Daemons, * which is out of scope. * • launchd strips the environment; the syncing shell's PATH goes into the * plist's `EnvironmentVariables` so task bodies find the same binaries, * next to the AKM directory environment the row sets (see * `src/tasks/scheduler-invocation.ts`). * * Tests inject a fake exec + filesystem so the backend can be unit-tested * without touching the host launchctl. */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import launchdTemplate from "../../assets/backends/launchd-template.xml" with { type: "text" }; import { hasErrnoCode } from "../../core/common.js"; import { ConfigError } from "../../core/errors.js"; import { getTaskLogDir } from "../../core/paths.js"; import { warn } from "../../core/warn.js"; import { resolveAkmInvocation } from "../resolve-akm-bin.js"; import { parseSchedule, translateToLaunchd } from "../schedule.js"; import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js"; import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js"; import { escapeXml, nodeExec, nodeFs, runOrThrow } from "./exec-utils.js"; export const LAUNCHD_LABEL_PREFIX = "com.akm.task."; const LAUNCHD_AKM_LABEL_RE = /^com\.akm\.task\.[A-Za-z0-9._-]{1,1024}$/u; export function LAUNCHD_BACKEND(options = {}) { const exec = options.exec ?? defaultLaunchdExec(); const fsLike = options.fs ?? defaultLaunchdFs(); const agentsDir = options.agentsDir ?? defaultAgentsDir(); const logDir = options.logDir ?? getTaskLogDir(); const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv; const envPath = options.envPath === false ? undefined : typeof options.envPath === "string" ? options.envPath : process.env.PATH; const plistPath = (nativeId) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${nativeId}.plist`); const label = (nativeId) => `${LAUNCHD_LABEL_PREFIX}${nativeId}`; const target = (nativeId) => `gui/${exec.uid()}/${label(nativeId)}`; const setEnableState = (nativeId, enabled) => { const verb = enabled ? "enable" : "disable"; runOrThrow(exec, ["launchctl", verb, target(nativeId)], { message: (result) => `launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`, }); }; const bootout = (nativeId) => runOrThrow(exec, ["launchctl", "bootout", target(nativeId)], { isOk: (r) => r.status === 0 || isServiceNotFoundResult(r), message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, }); const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment, envPath); return { name: "launchd", install(task, opts) { const xml = xmlFor(task, opts); const nativeId = schedulerBindingNativeId(task); const file = plistPath(nativeId); fsLike.ensureDir(agentsDir); // launchd refuses to start a job whose StandardOutPath directory is // missing; create it before touching launchd state. fsLike.ensureDir(logDir); const tempFile = path.join(agentsDir, `.${nativeId}.${Date.now()}.tmp`); fsLike.writeFile(tempFile, xml); try { bootout(nativeId); fsLike.replaceFile(tempFile, file); // A disable override survives bootout and plist replacement. Clear it // before bootstrap, then apply the desired state after registration. setEnableState(nativeId, true); runOrThrow(exec, ["launchctl", "bootstrap", `gui/${exec.uid()}`, file], { message: (r) => `launchctl bootstrap failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, hint: "Ensure `launchctl` is available; on macOS it is part of the base system.", }); if (!task.enabled) setEnableState(nativeId, false); } finally { if (fsLike.exists(tempFile)) fsLike.removeFile(tempFile); } }, uninstall(nativeId) { bootout(nativeId); // launchctl disable overrides persist after the plist is removed. setEnableState(nativeId, true); const file = plistPath(nativeId); if (fsLike.exists(file)) fsLike.removeFile(file); }, setEnabled(nativeId, enabled) { setEnableState(nativeId, enabled); }, list() { const domain = exec.run(["launchctl", "print", `gui/${exec.uid()}`]); if (domain.status !== 0) { throw new ConfigError(`launchctl failed to enumerate the loaded user domain: ${domain.stderr || domain.stdout || "no output"}.`, "INVALID_CONFIG_FILE"); } const loadedLabels = parseLaunchdLoadedLabels(domain.stdout); const disabledLabels = readDisabledLabels(exec); const rows = []; if (!fsLike.exists(agentsDir)) return rows; for (const file of fsLike.list(agentsDir).sort()) { if (!file.startsWith(LAUNCHD_LABEL_PREFIX) || !file.endsWith(".plist")) continue; const nativeId = file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length); const raw = fsLike.readFile(plistPath(nativeId)); const parsed = extractPlistInvocation(raw); if (!parsed) continue; const enabled = !disabledLabels.has(label(nativeId)); const loaded = loadedLabels.has(label(nativeId)); rows.push({ id: schedulerLogicalBindingId(nativeId, parsed.invocation), nativeId, enabled: enabled && loaded, // An unloaded service or a disable override is drift: the signature // carries both, so sync bootstraps/enables it again. signature: launchdFingerprint(raw, enabled, loaded), ...(parsed.target !== undefined ? { target: parsed.target } : {}), binding: parsed.binding, ...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}), ...(parsed.environment !== undefined ? { environment: parsed.environment } : {}), invocation: parsed.invocation, }); } return rows; }, expectedSignature(task, opts) { return launchdFingerprint(xmlFor(task, opts), task.enabled, true); }, }; } function launchdFingerprint(raw, enabled, loaded) { const signed = raw.replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${enabled} -->`); return `${signed.replace(/\r\n/g, "\n").trim()}:loaded=${loaded}`; } export function extractPlistInvocation(xml) { const block = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/); if (!block) return undefined; const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => decodeXmlEntities(m[1])); const parsed = parseScheduledInvocationArgv(args); if (!parsed) return undefined; const variables = {}; const dict = xml.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/)?.[1] ?? ""; for (const entry of dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) { variables[decodeXmlEntities(entry[1])] = decodeXmlEntities(entry[2]); } const environment = scheduledRowEnvironmentFrom(variables); return environment ? { ...parsed, environment } : parsed; } function decodeXmlEntities(value) { return value .replaceAll("&lt;", "<") .replaceAll("&gt;", ">") .replaceAll("&quot;", '"') .replaceAll("&apos;", "'") .replaceAll("&amp;", "&"); } // ── XML builder (exported for tests) ──────────────────────────────────────── function renderPlistEnvironment(envPath, environment) { const variables = [ ...(envPath ? [["PATH", envPath]] : []), ...scheduledRowEnvironmentEntries(environment), ]; if (variables.length === 0) return ""; return [ " <key>EnvironmentVariables</key>", " <dict>", ...variables.flatMap(([name, value]) => [` <key>${name}</key>`, ` <string>${escapeXml(value)}</string>`]), " </dict>", "", ].join("\n"); } export function buildPlistXml(task, akmArgv, logDir, environment, envPath) { const trigger = translateToLaunchd(parseSchedule(task.cron, "launchd")); const argv = buildScheduledInvocation(akmArgv, task.invocation); const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n"); const nativeId = schedulerBindingNativeId(task); const logPath = path.join(logDir, `${nativeId}.log`); const xml = launchdTemplate .replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n`) .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(nativeId)) .replace("{{PROGRAM_ARGS}}", programArgs) .replaceAll("{{LOG_PATH}}", escapeXml(logPath)) .replace("{{ENV_VARS}}", () => renderPlistEnvironment(envPath, environment)) .replace("{{TRIGGER_XML}}", renderLaunchdTrigger(trigger)); for (const char of xml) { const code = char.codePointAt(0) ?? 0; if (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f)) { throw new ConfigError("Launchd plist values must not contain XML-forbidden control characters.", "INVALID_CONFIG_FILE"); } } return xml; } function renderLaunchdTrigger(trigger) { if (trigger.calendars !== undefined) { const lines = [" <key>StartCalendarInterval</key>", " <array>"]; for (const calendar of trigger.calendars) lines.push(...renderCalendar(calendar, " ")); lines.push(" </array>"); return lines.join("\n"); } return [" <key>StartCalendarInterval</key>", ...renderCalendar(trigger.calendar ?? {}, " ")].join("\n"); } function renderCalendar(calendar, indent) { const valueIndent = `${indent} `; const lines = [`${indent}<dict>`]; if (calendar.Minute !== undefined) lines.push(`${valueIndent}<key>Minute</key><integer>${calendar.Minute}</integer>`); if (calendar.Hour !== undefined) lines.push(`${valueIndent}<key>Hour</key><integer>${calendar.Hour}</integer>`); if (calendar.Day !== undefined) lines.push(`${valueIndent}<key>Day</key><integer>${calendar.Day}</integer>`); if (calendar.Month !== undefined) lines.push(`${valueIndent}<key>Month</key><integer>${calendar.Month}</integer>`); if (calendar.Weekday !== undefined) { lines.push(`${valueIndent}<key>Weekday</key><integer>${calendar.Weekday}</integer>`); } lines.push(`${indent}</dict>`); return lines; } /** * Collect the akm-owned service labels present in `launchctl` output. * * This reads the CURRENT USER'S OWN scheduler inventory, so it is parsed * permissively: scan for labels in our own `com.akm.task.` namespace and * ignore everything else. */ export function parseLaunchdLoadedLabels(output) { const labels = new Set(); // `[^\s"{}=,()]` stops the token at whatever punctuation the surrounding // launchctl syntax uses, so a label works whether it appears as a bare // table cell, a quoted string, or a dictionary key. for (const match of output.matchAll(/com\.akm\.task\.[^\s"{}=,()]+/gu)) { if (LAUNCHD_AKM_LABEL_RE.test(match[0])) labels.add(match[0]); } return labels; } /** `print-disabled` failing or looking unfamiliar degrades to "nothing is disabled". */ function readDisabledLabels(exec) { try { const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]); if (result.status !== 0) { warn("[akm] launchctl print-disabled exited %d; assuming no akm task is disabled.", result.status); return new Set(); } const disabled = new Set(); const entryPattern = /"(com\.akm\.task\.[^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)/gu; for (const match of result.stdout.matchAll(entryPattern)) { const label = match[1]; if (LAUNCHD_AKM_LABEL_RE.test(label) && (match[2] === "true" || match[2] === "disabled")) disabled.add(label); } return disabled; } catch (error) { warn("[akm] launchctl print-disabled could not be run; assuming no akm task is disabled: %s", error instanceof Error ? error.message : String(error)); return new Set(); } } function isServiceNotFoundResult(result) { const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`; return /could not find service\b|service\b.*\bnot found\b|\bno such process\b/i.test(output); } function defaultAgentsDir() { const home = os.homedir(); if (!home) { throw new ConfigError("Cannot determine user home directory; launchd backend requires HOME to locate ~/Library/LaunchAgents.", "INVALID_CONFIG_FILE", "Set $HOME (POSIX) or the equivalent before running `akm task` on macOS."); } return path.join(home, "Library", "LaunchAgents"); } function defaultLaunchdExec() { return { ...nodeExec(), uid() { const fn = process.getuid; return typeof fn === "function" ? fn.call(process) : 0; }, }; } function defaultLaunchdFs() { return { ...nodeFs(), readFile(file) { return fs.readFileSync(file, "utf8"); }, removeFile(file) { fs.rmSync(file, { force: true }); }, replaceFile(source, destination) { fs.renameSync(source, destination); }, list(dir) { try { return fs.readdirSync(dir); } catch (error) { // Genuinely absent is an empty listing; anything else (e.g. EACCES) // is not "no plists". if (hasErrnoCode(error, "ENOENT")) return []; throw new ConfigError(`Unable to read LaunchAgents directory at "${dir}".`, "INVALID_CONFIG_FILE"); } }, exists(file) { return fs.existsSync(file); }, }; }