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
448 lines (447 loc) • 20.1 kB
JavaScript
// 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/.
/**
* schtasks.exe backend for `akm task` (Windows default).
*
* Each task is registered under the `\akm\` Task Scheduler folder so the
* backend never touches user-managed tasks. The full task definition is
* sent through `schtasks /Create /TN \akm\<id> /XML <path> /F` so we can
* express triggers/principals/actions without quoting hell.
*
* Platform notes:
* • `LogonType=InteractiveToken` means the task runs in the context of
* the registering user only when they are logged in — there is no
* stored password and the task will not fire at the lock screen.
* • `<Principal>` records the current user SID; it is part of the
* fingerprint in `<Source>`, so a sync run by another user re-registers
* the task under that user.
* • `<DisallowStartIfOnBatteries>false</…>` and `<StopIfGoingOnBatteries>
* false</…>` allow the task to run on battery.
* • `MultipleInstancesPolicy=IgnoreNew` makes overlapping triggers safe.
* • `/Query /FO CSV /NH` (without `/V`) outputs three columns:
* `TaskName,Next Run Time,Status` — so the regex anchors on the task
* name as the leading quoted field.
* • Task Scheduler runs a task with the account's own environment, so
* PATH is not carried anywhere. The Exec action has no environment of
* its own: the AKM directory environment a row sets (see
* `src/tasks/scheduler-invocation.ts`) is a `$env:NAME='value';`
* assignment ahead of the call operator in the PowerShell command,
* inherited by the akm process it starts.
*
* Tests inject a fake exec + filesystem.
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import schtasksTemplate from "../../assets/backends/schtasks-template.xml" with { type: "text" };
import { ConfigError } from "../../core/errors.js";
import { getTaskLogDir } from "../../core/paths.js";
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
import { parseSchedule, translateToSchtasks } from "../schedule.js";
import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
import { escapeXml, nodeExec, nodeFs, normalizeXmlForUtf16File, runOrThrow, } from "./exec-utils.js";
export const DEFAULT_FOLDER_PREFIX = "\\akm\\";
const SIGNATURE_PREFIX = "akm:v1:";
export function SCHTASKS_BACKEND(options = {}) {
const exec = options.exec ?? nodeExec();
const fsLike = options.fs ?? defaultSchtasksFs();
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
const logDir = options.logDir ?? getTaskLogDir();
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
const userSid = options.userSid ?? resolveCurrentUserSid(exec);
const taskName = (nativeId) => `${folder}${nativeId}`;
const xmlFor = (task, opts) => buildSchtasksXml(task, akmArgv, logDir, {
folderPrefix: folder,
userSid,
binding: [...(opts?.binding ?? akmArgv)],
...(opts?.environment ? { environment: opts.environment } : {}),
});
const queryXml = (nativeId) => runOrThrow(exec, ["schtasks", "/Query", "/TN", taskName(nativeId), "/XML"], {
message: (result) => `schtasks /Query /XML for "${taskName(nativeId)}" failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`,
}).stdout;
return {
name: "schtasks",
install(task, opts) {
const xml = normalizeXmlForUtf16File(xmlFor(task, opts));
const nativeId = schedulerBindingNativeId(task);
fsLike.ensureDir(logDir);
const tmpFile = path.join(fsLike.tmpdir(), `akm-task-${nativeId}-${Date.now()}.xml`);
fsLike.writeFile(tmpFile, xml);
try {
// /F forces overwrite if a task with the same name exists.
runOrThrow(exec, ["schtasks", "/Create", "/TN", taskName(nativeId), "/XML", tmpFile, "/F"], {
message: (r) => `schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
});
if (!task.enabled) {
runOrThrow(exec, ["schtasks", "/Change", "/TN", taskName(nativeId), "/DISABLE"], {
message: (r) => `schtasks /Change /DISABLE failed: ${r.stderr || r.stdout || "no output"}.`,
});
}
}
finally {
fsLike.removeFile(tmpFile);
}
},
uninstall(nativeId) {
runOrThrow(exec, ["schtasks", "/Delete", "/TN", taskName(nativeId), "/F"], {
isOk: (r) => r.status === 0 || isMissingTaskResult(r),
message: (r) => `schtasks /Delete failed: ${r.stderr || r.stdout || "no output"}.`,
});
},
setEnabled(nativeId, enabled) {
const flag = enabled ? "/ENABLE" : "/DISABLE";
runOrThrow(exec, ["schtasks", "/Change", "/TN", taskName(nativeId), flag], {
message: (r) => `schtasks /Change ${flag} failed: ${r.stderr || r.stdout || "no output"}.`,
});
},
list() {
const listing = runOrThrow(exec, ["schtasks", "/Query", "/FO", "CSV", "/NH"], {
message: (result) => `schtasks /Query failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`,
});
const rows = [];
for (const line of (listing.stdout ?? "").split(/\r?\n/)) {
const name = line.match(/^"([^"]+)",/)?.[1];
if (!name?.startsWith(folder))
continue;
const nativeId = name.slice(folder.length);
const xml = queryXml(nativeId);
const parsed = extractSchtasksInvocation(xml);
if (!parsed)
continue;
rows.push({
id: schedulerLogicalBindingId(nativeId, parsed.invocation),
nativeId,
enabled: taskXmlEnabled(xml),
signature: taskXmlSignature(xml),
...(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 taskXmlSignature(xmlFor(task, opts));
},
};
}
/** The `--bundle <bundle>` token of an installed Task Scheduler definition; undefined for the primary form. */
export function extractSchtasksTarget(xml) {
return extractSchtasksInvocation(xml)?.target;
}
export function extractSchtasksInvocation(xml) {
const argsElement = xml.match(/<(?:[\w.-]+:)?Arguments>([\s\S]*?)<\/(?:[\w.-]+:)?Arguments>/i);
if (!argsElement)
return undefined;
const commandLine = decodeXml(argsElement[1]);
const invocationStart = findPowerShellInvocationOperator(commandLine);
if (invocationStart === undefined)
return undefined;
const parsed = parseScheduledInvocationArgv(parsePowerShellSingleQuotedArgs(commandLine, invocationStart + 1));
if (!parsed)
return undefined;
const variables = {};
const assignments = commandLine.slice(0, invocationStart);
for (const assignment of assignments.matchAll(/\$env:([A-Za-z_][A-Za-z0-9_]*)='((?:[^']|'')*)'\s*;/g)) {
variables[assignment[1]] = assignment[2].replaceAll("''", "'");
}
const environment = scheduledRowEnvironmentFrom(variables);
return environment ? { ...parsed, environment } : parsed;
}
function findPowerShellInvocationOperator(script) {
let inSingleQuote = false;
for (let index = 0; index < script.length; index += 1) {
const char = script[index];
if (char === "'") {
if (inSingleQuote && script[index + 1] === "'")
index += 1;
else
inSingleQuote = !inSingleQuote;
continue;
}
if (!inSingleQuote && char === "&" && /\s/.test(script[index + 1] ?? ""))
return index;
}
return undefined;
}
function parsePowerShellSingleQuotedArgs(script, start) {
const argv = [];
let index = start;
while (index < script.length) {
while (/\s/.test(script[index] ?? ""))
index += 1;
if (script[index] === ";" || script[index] === '"' || index >= script.length)
break;
if (script[index] !== "'")
return [];
index += 1;
let value = "";
let closed = false;
while (index < script.length) {
const char = script[index];
if (char !== "'") {
value += char;
index += 1;
continue;
}
if (script[index + 1] === "'") {
value += "'";
index += 2;
continue;
}
index += 1;
closed = true;
break;
}
if (!closed)
return [];
argv.push(value);
}
return argv;
}
export function buildSchtasksXml(task, akmArgv, logDir, options) {
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
const now = options.now ? options.now() : new Date();
const trigger = translateToSchtasks(parseSchedule(task.cron, "schtasks"));
const argv = buildScheduledInvocation(options.binding ?? akmArgv, task.invocation);
const assignments = scheduledRowEnvironmentEntries(options.environment)
.map(([name, value]) => `$env:${name}=${quotePowerShell(value)}; `)
.join("");
const script = `${assignments}& ${argv.map((arg) => quotePowerShell(arg)).join(" ")}; exit $LASTEXITCODE`;
const command = "powershell.exe";
const args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script].map(quoteArg).join(" ");
const nativeId = schedulerBindingNativeId(task);
const logPath = path.join(logDir, `${nativeId}.log`);
// The boundary changes on reinstall, and enabled state can change via
// /Change. Keep both outside the stored definition fingerprint so no-op
// sync stays stable.
const fingerprint = createHash("sha256")
.update(JSON.stringify({ folder, id: nativeId, trigger, command, args, logPath, userSid: options.userSid }))
.digest("hex");
return schtasksTemplate
.replaceAll("{{TASK_ID}}", escapeXml(nativeId))
.replaceAll("{{FOLDER}}", escapeXml(folder))
.replace("{{SIGNATURE}}", `${SIGNATURE_PREFIX}${fingerprint}`)
.replace("{{TRIGGER_XML}}", renderSchtasksTrigger(trigger, now))
.replace('<Principal id="Author">', `<Principal id="Author">\n <UserId>${escapeXml(options.userSid)}</UserId>`)
.replace("{{ENABLED}}", task.enabled ? "true" : "false")
.replace("{{COMMAND}}", escapeXml(command))
.replace("{{ARGS}}", () => escapeXml(args))
.replace("{{LOG_PATH}}", escapeXml(logPath));
}
function renderSchtasksTrigger(trigger, now) {
return expandNativeTriggers(trigger)
.map((native) => renderNativeTrigger(native, formatStartBoundary(nextStartBoundary(native, now))))
.join("\n");
}
function expandNativeTriggers(trigger) {
switch (trigger.kind) {
case "minute":
return [{ kind: "daily", atHour: 0, atMinute: 0, repeatEveryMinutes: trigger.everyMinutes }];
case "minuteValues":
return trigger.minutes.map((atMinute) => ({ kind: "daily", atHour: 0, atMinute, repeatEveryMinutes: 60 }));
case "hour":
return [{ kind: "daily", atHour: 0, atMinute: trigger.atMinute, repeatEveryMinutes: trigger.everyHours * 60 }];
case "hourValues":
return trigger.hours.map((atHour) => ({ kind: "daily", atHour, atMinute: trigger.atMinute }));
case "daily":
return [{ kind: "daily", atHour: trigger.atHour, atMinute: trigger.atMinute }];
case "weekly":
return [trigger];
case "monthly":
return [trigger];
}
}
function renderNativeTrigger(trigger, startBoundary) {
if (trigger.kind === "daily") {
const repetition = trigger.repeatEveryMinutes === undefined
? ""
: ` <Repetition>
<Interval>${formatMinuteDuration(trigger.repeatEveryMinutes)}</Interval>
<Duration>${formatMinuteDuration(24 * 60 - trigger.repeatEveryMinutes)}</Duration>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
`;
return ` <CalendarTrigger>
${repetition} <StartBoundary>${startBoundary}</StartBoundary>
<Enabled>true</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
</CalendarTrigger>`;
}
if (trigger.kind === "weekly") {
const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
return ` <CalendarTrigger>
<StartBoundary>${startBoundary}</StartBoundary>
<Enabled>true</Enabled>
<ScheduleByWeek>
<DaysOfWeek>
${days}
</DaysOfWeek>
<WeeksInterval>1</WeeksInterval>
</ScheduleByWeek>
</CalendarTrigger>`;
}
const monthMap = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const days = trigger.daysOfMonth.map((d) => ` <Day>${d}</Day>`).join("\n");
const months = trigger.months.map((m) => ` <${monthMap[m - 1]} />`).join("\n");
return ` <CalendarTrigger>
<StartBoundary>${startBoundary}</StartBoundary>
<Enabled>true</Enabled>
<ScheduleByMonth>
<DaysOfMonth>
${days}
</DaysOfMonth>
<Months>
${months}
</Months>
</ScheduleByMonth>
</CalendarTrigger>`;
}
function formatMinuteDuration(minutes) {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
}
function nextStartBoundary(trigger, now) {
const boundary = new Date(now.getTime());
switch (trigger.kind) {
case "daily":
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
if (trigger.repeatEveryMinutes !== undefined) {
while (boundary.getTime() <= now.getTime()) {
boundary.setMinutes(boundary.getMinutes() + trigger.repeatEveryMinutes);
}
}
else if (boundary.getTime() <= now.getTime()) {
boundary.setDate(boundary.getDate() + 1);
}
return boundary;
case "weekly":
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
while (!trigger.daysOfWeek.includes(boundary.getDay()) || boundary.getTime() <= now.getTime()) {
boundary.setDate(boundary.getDate() + 1);
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
}
return boundary;
case "monthly": {
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
const daysOfMonth = new Set(trigger.daysOfMonth);
const months = new Set(trigger.months);
for (let guard = 0; guard < 4000; guard++) {
if (daysOfMonth.has(boundary.getDate()) &&
months.has(boundary.getMonth() + 1) &&
boundary.getTime() > now.getTime()) {
break;
}
boundary.setDate(boundary.getDate() + 1);
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
}
return boundary;
}
}
}
function formatStartBoundary(d) {
// Local-time ISO-8601 (no zone suffix) — Task Scheduler interprets a bare
// boundary in the registering user's timezone, which matches what a user
// typing "0 9 * * *" means ("9am local").
const pad = (value) => String(value).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function quoteArg(s) {
if (/^[A-Za-z0-9_\-./@:%=+,\\]+$/.test(s))
return s;
// CommandLineToArgvW requires backslashes before quotes (including the
// closing quote) to be doubled so they survive as literal backslashes.
return `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
}
function quotePowerShell(value) {
return `'${value.replaceAll("'", "''")}'`;
}
function isMissingTaskResult(result) {
return /cannot find|not found/i.test(`${result.stderr ?? ""}\n${result.stdout ?? ""}`);
}
// ── Installed-definition signature ──────────────────────────────────────────
//
// Task Scheduler rewrites a registered definition (schema defaults filled in,
// StartBoundary moved on), so a row is compared by the fingerprint akm stamps
// into `<Source>` — everything it renders except the enabled state — plus the
// enabled state `/Change` toggles. A task edited in Task Scheduler that keeps
// that fingerprint is left as it is.
function taskXmlSignature(xml) {
const source = xml.match(/<(?:[\w.-]+:)?Source>([^<]*)<\//i)?.[1]?.trim() ?? "";
return `${decodeXml(source)}|enabled=${taskXmlEnabled(xml)}`;
}
function taskXmlEnabled(xml) {
const settings = xml.match(/<(?:[\w.-]+:)?Settings>([\s\S]*?)<\/(?:[\w.-]+:)?Settings>/i)?.[1] ?? "";
const value = settings.match(/<(?:[\w.-]+:)?Enabled>\s*([^<]*?)\s*<\//i)?.[1]?.toLowerCase();
return value !== "false" && value !== "0";
}
function decodeXml(value) {
return value.replace(/&(?:#(\d+)|#x([\da-f]+)|amp|lt|gt|quot|apos);/gi, (entity, decimal, hex) => {
if (decimal !== undefined)
return String.fromCodePoint(Number(decimal));
if (hex !== undefined)
return String.fromCodePoint(Number.parseInt(hex, 16));
switch (entity.toLowerCase()) {
case "&":
return "&";
case "<":
return "<";
case ">":
return ">";
case """:
return '"';
case "'":
return "'";
default:
return entity;
}
});
}
function resolveCurrentUserSid(exec) {
const result = runOrThrow(exec, ["whoami", "/user", "/fo", "csv", "/nh"], {
message: (r) => `whoami /user failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
});
const match = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.match(/\bS-\d+(?:-\d+){2,}\b/i);
if (!match)
throw new ConfigError("whoami /user returned no Windows user SID.", "INVALID_CONFIG_FILE");
return `S${match[0].slice(1)}`;
}
function defaultSchtasksFs() {
return {
...nodeFs(),
writeFile(file, content) {
fs.writeFileSync(file, `${content}`, { encoding: "utf16le" });
},
removeFile(file) {
try {
fs.rmSync(file, { force: true });
}
catch {
/* ignore */
}
},
tmpdir() {
return os.tmpdir();
},
};
}